From 64b192a008023057e6cd4a40b6ad533b43d79181 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 07:12:03 +0000 Subject: [PATCH 01/12] fix(check): parse a .test.mdl file as the microflow bodies it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A test block is a microflow body — that is what the runner turns it into — and `mxcli check` and the LSP were handing the file to the top-level grammar instead. DECLARE is not a top-level statement, so the parser resynced; RETRIEVE is a non-reserved keyword, so it was swallowed as an identifier; and the leftover `FROM …` started an OQL query, whose follow set is {GROUP_BY, SELECT, HAVING}. The reported error therefore told the author their RETRIEVE needed a SELECT — on a statement `mxcli syntax microflow.retrieve` prints as its own example (mendixlabs/mxcli#1103). Not a corner case: the VS Code extension binds MDL to `.mdl`, which `.test.mdl` matches, so every test file open in the editor was a wall of squiggles. 9 of this repository's 10 test files reported errors this way, one of them 392; all 10 now report 0. testrunner.CheckSource renders each block as the microflow it becomes, padded so the body keeps its SOURCE line numbers — wrapper fragments go on the lines the doc comment and the '/' separator occupied, so a diagnostic's line:col is the author's with no mapping table to drift. Everything downstream then applies unchanged: an uncompilable body, an unusable @expect or @verify (MDL-TEST01), and the semantic rules. `make check-mdl` sweeps test files too, which is what keeps this true; `.fail.test.mdl` names one whose annotations are deliberately unusable, and the two existing fixtures of that kind are renamed to match. Control: stubbing IsTestFile back to false reproduces the reporter's message verbatim, `line 6:76 mismatched input 'LIMIT' expecting {GROUP_BY, SELECT, HAVING}`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu --- Makefile | 13 +- cmd/mxcli/cmd_check.go | 31 +++- cmd/mxcli/lsp_diagnostics.go | 47 +++++- cmd/mxcli/testrunner/check_source.go | 143 ++++++++++++++++++ cmd/mxcli/testrunner/check_source_test.go | 117 ++++++++++++++ cmd/mxcli/testrunner/parser.go | 46 +++++- ...t-file-checks-as-microflow-bodies.test.mdl | 33 ++++ ...l-leading-comment-and-count.fail.test.mdl} | 0 ...> expect-vacuous-assertions.fail.test.mdl} | 0 9 files changed, 417 insertions(+), 13 deletions(-) create mode 100644 cmd/mxcli/testrunner/check_source.go create mode 100644 cmd/mxcli/testrunner/check_source_test.go create mode 100644 mdl-examples/bug-tests/1103-test-file-checks-as-microflow-bodies.test.mdl rename mdl-examples/bug-tests/{927-test-mdl-leading-comment-and-count.test.mdl => 927-test-mdl-leading-comment-and-count.fail.test.mdl} (100%) rename mdl-examples/bug-tests/{expect-vacuous-assertions.test.mdl => expect-vacuous-assertions.fail.test.mdl} (100%) diff --git a/Makefile b/Makefile index 4bf9b66553..533490c103 100644 --- a/Makefile +++ b/Makefile @@ -183,6 +183,16 @@ test: grammar sync-all # rule rejects). The runner inverts the exit code for these: an unexpected # pass is treated as a regression of the rule. # +# A test file whose ANNOTATIONS are deliberately unusable is a negative test like +# any other and is named `.fail.test.mdl`; both fixtures of that kind exist to +# prove the runner reports an ERROR rather than a PASS. +# +# `.test.mdl` files are swept too. They used to be skipped because `check` could +# not parse one at all — a test block is a microflow body, not a top-level +# statement, so every test file reported errors about the grammar rather than +# about itself (mendixlabs/mxcli#1103). Now that `check` renders them, the sweep +# is what keeps that true. +# # `check` runs here WITHOUT a project, so only CHECK-TIME rules can be tested # this way. A guard living in the executor or a backend needs a model before it # can decide anything, so its repro is valid MDL, `check` exits 0, and naming @@ -192,7 +202,6 @@ test: grammar sync-all check-mdl: build @FAILED=0; \ for f in mdl-examples/doctype-tests/*.mdl mdl-examples/bug-tests/*.mdl; do \ - case "$$f" in *.test.mdl) continue ;; esac; \ case "$$f" in \ */116-datagrid2-column-name-mismatch.mdl|\ */343-list-attribute-find-filter.mdl|\ @@ -207,7 +216,7 @@ check-mdl: build continue ;; \ esac; \ NAME=$$(basename "$$f"); \ - case "$$f" in *.fail.mdl) \ + case "$$f" in *.fail.mdl|*.fail.test.mdl) \ if ./$(BUILD_DIR)/$(BINARY_NAME) check "$$f" > /dev/null 2>&1; then \ echo "FAIL (negative test unexpectedly passed): $$NAME"; \ FAILED=1; \ diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index 4864bd5075..db2e19a84c 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "github.com/mendixlabs/mxcli/cmd/mxcli/testrunner" "github.com/mendixlabs/mxcli/mdl/executor" "github.com/mendixlabs/mxcli/mdl/linter" "github.com/mendixlabs/mxcli/mdl/visitor" @@ -115,7 +116,31 @@ Examples: if !isStructured { fmt.Printf("Checking syntax: %s\n", mdlSourceLabel(filePath)) } - prog, errs := visitor.Build(string(content)) + + // A .test.mdl / .test.md file is not top-level MDL: each block is a + // microflow body. Render it as the microflows it becomes, on the source's + // own lines, so every rule below applies to what the author actually wrote + // (mendixlabs/mxcli#1103). + source := string(content) + var testProblems []linter.Violation + if testrunner.IsTestFile(filePath) { + checked, terr := testrunner.CheckSource(source, filePath) + if terr != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", terr) + os.Exit(1) + } + source = checked.MDL + for _, p := range checked.Problems { + testProblems = append(testProblems, linter.Violation{ + RuleID: "MDL-TEST01", + Severity: linter.SeverityError, + Message: fmt.Sprintf("test %q: %s", p.Test, p.Message), + Location: linter.Location{DocumentType: "test", DocumentName: p.Test}, + }) + } + } + + prog, errs := visitor.Build(source) if len(errs) > 0 { if isStructured { var parseViolations []linter.Violation @@ -133,7 +158,7 @@ Examples: fmt.Fprintf(os.Stderr, " - %v\n", err) } // Hint: if script contains IMPORT/QUERY with single $ but not $$, suggest dollar-quoting - src := string(content) + src := source if (strings.Contains(src, "IMPORT") || strings.Contains(src, "import")) && (strings.Contains(src, "QUERY") || strings.Contains(src, "query")) && strings.Contains(src, "$") && !strings.Contains(src, "$$") { @@ -150,7 +175,7 @@ Examples: // Every semantic check lives in executor.ValidateProgram, so `mxcli exec` // refuses exactly what `mxcli check` reports. Adding a check there gives // both commands it at once. - violations := executor.ValidateProgram(prog, projectPath) + violations := append(testProblems, executor.ValidateProgram(prog, projectPath)...) if isStructured { // Always emit structured output (even when clean) diff --git a/cmd/mxcli/lsp_diagnostics.go b/cmd/mxcli/lsp_diagnostics.go index 5c42544ea6..7dc7f535a2 100644 --- a/cmd/mxcli/lsp_diagnostics.go +++ b/cmd/mxcli/lsp_diagnostics.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/mendixlabs/mxcli/cmd/mxcli/testrunner" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/executor" "github.com/mendixlabs/mxcli/mdl/linter" @@ -19,6 +20,46 @@ import ( // errLineRegexp parses error messages in the format "line N:M msg". var errLineRegexp = regexp.MustCompile(`^line (\d+):(\d+) (.+)$`) +// checkableDocument renders a document as the MDL to parse, plus the diagnostics +// that come from the document's own format rather than from the grammar. +// +// The extension binds the MDL language to `.mdl`, which `.test.mdl` matches, so +// until now every test file in the editor was diagnosed against the top-level +// grammar it is not written in — a wall of squiggles saying nothing true +// (mendixlabs/mxcli#1103). A test block is a microflow body; testrunner renders +// it as one, on the same lines, so the positions below need no adjustment. +func checkableDocument(docURI uri.URI, text string) (string, []protocol.Diagnostic) { + path := docURI.Filename() + if !testrunner.IsTestFile(path) { + return text, nil + } + checked, err := testrunner.CheckSource(text, path) + if err != nil { + // The file is not a usable test file at all. Report that, at the top, + // rather than whatever the grammar makes of it. + return "", []protocol.Diagnostic{{ + Range: protocol.Range{Start: protocol.Position{Line: 0}, End: protocol.Position{Line: 0}}, + Severity: protocol.DiagnosticSeverityError, + Source: "mdl-test", + Message: err.Error(), + }} + } + var diags []protocol.Diagnostic + for _, p := range checked.Problems { + line := uint32(0) + if p.Line > 0 { + line = uint32(p.Line - 1) + } + diags = append(diags, protocol.Diagnostic{ + Range: protocol.Range{Start: protocol.Position{Line: line}, End: protocol.Position{Line: line}}, + Severity: protocol.DiagnosticSeverityError, + Source: "mdl-test", + Message: p.Message, + }) + } + return checked.MDL, diags +} + // parseMDLDiagnostics runs the MDL parser on text and converts errors to LSP diagnostics. func parseMDLDiagnostics(text string) []protocol.Diagnostic { _, errs := visitor.Build(text) @@ -60,7 +101,8 @@ func parseMDLDiagnostics(text string) []protocol.Diagnostic { // publishDiagnostics parses the document and sends diagnostics to the client. func (s *mdlServer) publishDiagnostics(ctx context.Context, docURI uri.URI, text string) { - diags := parseMDLDiagnostics(text) + text, diags := checkableDocument(docURI, text) + diags = append(diags, parseMDLDiagnostics(text)...) // If no parse errors, run semantic validation inline if len(diags) == 0 { diags = append(diags, s.runSemanticValidation(text)...) @@ -130,7 +172,8 @@ func (s *mdlServer) DidSave(ctx context.Context, params *protocol.DidSaveTextDoc s.mu.Unlock() // If there are parse errors, don't run semantic checks - if diags := parseMDLDiagnostics(text); len(diags) > 0 { + checkable, diags := checkableDocument(docURI, text) + if len(diags) > 0 || len(parseMDLDiagnostics(checkable)) > 0 { return nil } diff --git a/cmd/mxcli/testrunner/check_source.go b/cmd/mxcli/testrunner/check_source.go new file mode 100644 index 0000000000..de4e6be223 --- /dev/null +++ b/cmd/mxcli/testrunner/check_source.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Rendering a .test.mdl file as something `mxcli check` and the LSP can check. +// +// A test file is not a sequence of top-level MDL statements. Each block is a +// MICROFLOW BODY — that is literally what the runner turns it into — so feeding +// one to the top-level grammar produces errors about the grammar rather than +// about the file. Measured on the block in mendixlabs/mxcli#1103: `DECLARE` is +// not a statement, the parser resyncs, `RETRIEVE` is swallowed as a non-reserved +// keyword, and the remaining `FROM …` starts an OQL query whose follow set is +// {GROUP_BY, SELECT, HAVING}. The reader is then told their RETRIEVE needs a +// SELECT — on a statement `mxcli syntax microflow.retrieve` prints as its own +// example. +// +// That is not a niche path. The VS Code extension binds the MDL language to +// `.mdl`, which `.test.mdl` matches, so every test file open in the editor was a +// wall of red squiggles; 9 of the 10 test files in this repository report errors +// this way, one of them 392. +// +// The rendering keeps every body on the line the author wrote it on, by padding +// with blank lines and putting the wrapper on the lines the doc comment and the +// '/' separator occupied. A diagnostic then needs no remapping — which is what +// makes this a translation of the source rather than a second parser for it. +package testrunner + +import ( + "fmt" + "path/filepath" + "strings" +) + +// CheckedSource is a test file rendered for checking. +type CheckedSource struct { + // MDL is one microflow per test block, laid out on the source's own lines. + MDL string + // Problems are the things the MDL cannot carry: annotations that claim to + // assert something and cannot. + Problems []SourceProblem +} + +// SourceProblem is one problem found in a test file's annotations. +type SourceProblem struct { + Line int + Test string + Message string +} + +// IsTestFile reports whether a path is one of the test file formats. +func IsTestFile(name string) bool { return isTestFile(name) } + +// CheckSource renders a test file's blocks as microflows. +// +// It returns an error when the file cannot be parsed as a test file at all — two +// @test comments with no separator between them, say. That is a real problem +// with the file and is reported as itself, rather than as whatever the MDL +// grammar makes of the result. +func CheckSource(content, path string) (CheckedSource, error) { + var tests []TestCase + var err error + if strings.EqualFold(filepath.Ext(path), ".md") { + tests, err = parseMarkdownTests(content, path) + } else { + tests, err = parseMDLTests(content, path) + } + if err != nil { + return CheckedSource{}, err + } + + lines := strings.Split(content, "\n") + // One slot per source line, blank unless something is placed on it. A + // rendered line is only ever the body verbatim or a wrapper fragment, so + // columns survive too. The spare slot is for a closing fragment on a file + // whose last test runs to EOF with no '/' after it; appending past the end + // shifts nothing. + out := make([]string, len(lines)+1) + + var problems []SourceProblem + for i, tc := range tests { + for _, msg := range tc.AssertionErrors { + problems = append(problems, SourceProblem{Line: tc.Line, Test: tc.Name, Message: msg}) + } + body := strings.Split(tc.MDL, "\n") + if tc.MDL == "" || tc.BodyLine <= 0 { + continue + } + first := tc.BodyLine - 1 // 0-based + if first >= len(out) { + continue + } + for j := range body { + if k := first + j; k < len(out) && k < len(lines) { + out[k] = lines[k] + } + } + // A void microflow needs no RETURN, so the wrapper is two fragments and + // the body between them is exactly what the author typed. + place(out, first-1, fmt.Sprintf("CREATE OR REPLACE MICROFLOW %s.%s () BEGIN", mxTestModule, checkFlowName(tc, i))) + place(out, first+len(body), "END; /") + } + + return CheckedSource{MDL: strings.Join(out, "\n"), Problems: problems}, nil +} + +// checkFlowName names the wrapper after the test, because that name is what a +// semantic violation is reported against — linter locations carry a document, +// not a line. "at MxTest.Check_retrieve_with_a_limit" is the author's own words; +// "at MxTest.Check_1" is a number they never wrote and cannot search for. +func checkFlowName(tc TestCase, index int) string { + var b strings.Builder + b.WriteString("Check_") + for _, r := range tc.Name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + if tc.Name == "" { + fmt.Fprintf(&b, "%d", index+1) + } + return b.String() +} + +// place writes a wrapper fragment onto a line, appending when the line is +// already taken. +// +// Two tests separated by a single-line doc comment want the same line — one for +// its END, the next for its header — and both fragments are complete statements, +// so sharing the line costs nothing and keeps every later line where it was. +// A fragment with no slot left is dropped rather than shifting every line after +// it: the point of this rendering is that a diagnostic's line number is the +// author's, and a missing END is reported on the line it is missing from. +func place(out []string, idx int, fragment string) { + if idx < 0 || idx >= len(out) { + return + } + if strings.TrimSpace(out[idx]) == "" { + out[idx] = fragment + return + } + out[idx] += " " + fragment +} diff --git a/cmd/mxcli/testrunner/check_source_test.go b/cmd/mxcli/testrunner/check_source_test.go new file mode 100644 index 0000000000..bd14088970 --- /dev/null +++ b/cmd/mxcli/testrunner/check_source_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// TestCheckSourceParsesATestFile is mendixlabs/mxcli#1103. +// +// A .test.mdl file is not a sequence of top-level MDL statements — each block is +// a MICROFLOW BODY, and that is what it becomes when the runner injects it. Fed +// to the top-level grammar, DECLARE is not a statement at all, the parser +// resyncs, RETRIEVE is swallowed as a non-reserved keyword, and `FROM …` starts +// an OQL query whose follow set is {GROUP_BY, SELECT, HAVING} — which is the +// error the reporter chased, on a statement mxcli's own syntax help prints. +func TestCheckSourceParsesATestFile(t *testing.T) { + src := `/** + * @test retrieve with a limit + * @cleanup none + */ +DECLARE $result Boolean = false; +RETRIEVE $reqs FROM Probe.Request WHERE Status = Probe.ENUM_Status.Approved LIMIT 1; +$req = HEAD($reqs); +$result = $req != empty; +/ +` + got, err := CheckSource(src, "x.test.mdl") + if err != nil { + t.Fatalf("CheckSource: %v", err) + } + if _, errs := visitor.Build(got.MDL); len(errs) > 0 { + t.Fatalf("a valid test file still does not parse:\n%v\n--- rendered ---\n%s", errs, got.MDL) + } +} + +// TestCheckSourceKeepsSourceLineNumbers is what makes the diagnostics usable: a +// rendered block must sit on the lines the author wrote it on, or every error +// points somewhere else and the reader is worse off than with no check. +func TestCheckSourceKeepsSourceLineNumbers(t *testing.T) { + src := `/** + * @test broken + */ +DECLARE $ok Boolean = true; +SET $ok = ; +/ +` + got, err := CheckSource(src, "x.test.mdl") + if err != nil { + t.Fatalf("CheckSource: %v", err) + } + _, errs := visitor.Build(got.MDL) + if len(errs) == 0 { + t.Fatal("the broken statement was not reported at all") + } + if !strings.Contains(errs[0].Error(), "line 5:") { + t.Errorf("error %q is not on line 5, where the bad statement is:\n%s", errs[0], got.MDL) + } +} + +// TestCheckSourceReportsAnnotationProblems: an @expect that cannot be compiled is +// already an ERROR at run time. `mxcli check` is where the author would rather +// hear about it. +func TestCheckSourceReportsAnnotationProblems(t *testing.T) { + src := `/** + * @test bad expect + * @expect count($ok) + */ +DECLARE $ok Boolean = true; +/ +` + got, err := CheckSource(src, "x.test.mdl") + if err != nil { + t.Fatalf("CheckSource: %v", err) + } + if len(got.Problems) == 0 { + t.Fatalf("an unusable @expect was not reported: %+v", got) + } +} + +// TestCheckSourceRejectsAMalformedFile: a file the test parser refuses is not +// checkable, and saying so beats reporting the grammar's confusion about it. +func TestCheckSourceRejectsAMalformedFile(t *testing.T) { + src := `/** + * @test one + */ +/** + * @test two + */ +DECLARE $ok Boolean = true; +/ +` + if _, err := CheckSource(src, "x.test.mdl"); err == nil { + t.Error("a file with two @test comments and no separator was accepted") + } +} + +// TestIsTestFile pins what the translation applies to. A plain .mdl script must +// keep going through the top-level grammar unchanged. +func TestIsTestFile(t *testing.T) { + cases := map[string]bool{ + "suite.test.mdl": true, + "suite.test.md": true, + "/a/b/SUITE.TEST.MDL": true, + "script.mdl": false, + "notes.md": false, + "-": false, + } + for name, want := range cases { + if got := IsTestFile(name); got != want { + t.Errorf("IsTestFile(%q) = %v, want %v", name, got, want) + } + } +} diff --git a/cmd/mxcli/testrunner/parser.go b/cmd/mxcli/testrunner/parser.go index 1062fb47bc..65e31b0c18 100644 --- a/cmd/mxcli/testrunner/parser.go +++ b/cmd/mxcli/testrunner/parser.go @@ -38,6 +38,11 @@ type TestCase struct { Throws string // @throws expected error message, "" when written bare SourceFile string // Original file path Line int // Line number in source file + // BodyLine is the 1-based source line the MDL body starts on, which is not + // Line: that one points at the doc comment. Checking a test file needs the + // body's own line, so a diagnostic can be reported where the author wrote the + // statement rather than where the annotation is — see check_source.go. + BodyLine int } // expectsThrow reports whether the test expects its body to raise an error. @@ -175,7 +180,7 @@ func parseMDLTests(content string, sourcePath string) ([]TestCase, error) { for _, block := range blocks { // Extract javadoc comment and MDL body - doc, body, line, err := extractDocAndBody(block) + doc, body, line, bodyLine, err := extractDocAndBody(block) if err != nil { return nil, fmt.Errorf("%s: %w", sourcePath, err) } @@ -213,6 +218,7 @@ func parseMDLTests(content string, sourcePath string) ([]TestCase, error) { Throws: annotations.Throws, SourceFile: sourcePath, Line: line, + BodyLine: bodyLine, }) } @@ -249,7 +255,7 @@ func parseMarkdownTests(content string, sourcePath string) ([]TestCase, error) { blockContent := strings.Join(blockLines, "\n") // Parse the block as a single test - doc, body, _, err := extractDocAndBody(testBlock{Text: blockContent, Line: blockStart}) + doc, body, _, bodyLine, err := extractDocAndBody(testBlock{Text: blockContent, Line: blockStart}) if err != nil { return nil, fmt.Errorf("%s: %w", sourcePath, err) } @@ -280,6 +286,7 @@ func parseMarkdownTests(content string, sourcePath string) ([]TestCase, error) { Throws: annotations.Throws, SourceFile: sourcePath, Line: blockStart, + BodyLine: bodyLine, }) } else { blockLines = append(blockLines, line) @@ -397,7 +404,7 @@ func splitTestBlocks(content string) []testBlock { // with no message. Scanning for the delimiters by raw substring search is bug // 1b: a `--` line whose prose spelled them out was read as a doc comment, so // describing the bug in a comment re-triggered it. -func extractDocAndBody(block testBlock) (string, string, int, error) { +func extractDocAndBody(block testBlock) (string, string, int, int, error) { docs := scanDocComments(block.Text, block.Line) // More than one @test in a chunk means a '/' separator is missing. Silently @@ -410,7 +417,7 @@ func extractDocAndBody(block testBlock) (string, string, int, error) { } } if len(named) > 1 { - return "", "", 0, fmt.Errorf( + return "", "", 0, 0, fmt.Errorf( "test %q is followed by another @test doc comment (%q) with no '/' separator "+ "between them, so only one of the two could run: add a line containing "+ "just '/' after the first test's statements", named[0], named[1]) @@ -425,9 +432,36 @@ func extractDocAndBody(block testBlock) (string, string, int, error) { } } if doc == nil { - return "", strings.TrimSpace(block.Text), block.Line, nil + return "", strings.TrimSpace(block.Text), block.Line, bodyStartLine(block.Text, 0, block.Line), nil } - return doc.Text, strings.TrimSpace(block.Text[doc.End:]), doc.Line, nil + return doc.Text, strings.TrimSpace(block.Text[doc.End:]), doc.Line, + bodyStartLine(block.Text, doc.End, block.Line), nil +} + +// bodyStartLine is the 1-based file line the body's first non-blank character +// sits on, counting from the chunk's own first line. +// +// Computed here rather than derived from the doc comment's length, because the +// body is TrimSpace'd: leading blank lines belong to neither, and a body that +// starts on the same line as the comment's closing delimiter has no leading line +// of its own at all. +func bodyStartLine(chunk string, from, chunkLine int) int { + line := chunkLine + for i := 0; i < from && i < len(chunk); i++ { + if chunk[i] == '\n' { + line++ + } + } + for i := from; i < len(chunk); i++ { + switch chunk[i] { + case '\n': + line++ + case ' ', '\t', '\r': + default: + return line + } + } + return line } // docComment is one `/** … */` comment found in a chunk. diff --git a/mdl-examples/bug-tests/1103-test-file-checks-as-microflow-bodies.test.mdl b/mdl-examples/bug-tests/1103-test-file-checks-as-microflow-bodies.test.mdl new file mode 100644 index 0000000000..68c23818c0 --- /dev/null +++ b/mdl-examples/bug-tests/1103-test-file-checks-as-microflow-bodies.test.mdl @@ -0,0 +1,33 @@ +-- mendixlabs/mxcli#1103 — a .test.mdl file is checkable. +-- +-- Every block here is a MICROFLOW BODY, which is what the runner turns it into. +-- Checked against the top-level grammar instead, `declare` is not a statement, +-- the parser resyncs, `retrieve` is swallowed as a non-reserved keyword, and the +-- remaining `from …` starts an OQL query whose follow set is +-- {GROUP_BY, SELECT, HAVING} — so the reporter was told their retrieve needed a +-- SELECT. `mxcli check` renders the blocks as microflows on these same lines. + +/** + * @test limit 1 binds one object, so use it as one + * @expect $Found = true + */ +retrieve $Request from Probe.Request where Code = 'X' limit 1; +$Found = $Request != empty; +/ + +/** + * @test without a limit it is a list, and head() takes it + * @expect $Found = true + */ +retrieve $Requests from Probe.Request where Code = 'X'; +$Request = head($Requests); +$Found = $Request != empty; +/ + +/** + * @test a bounded range above one is still a list + * @expect $Count = 2 + */ +retrieve $Requests from Probe.Request where Code = 'X' limit 2; +$Count = count($Requests); +/ diff --git a/mdl-examples/bug-tests/927-test-mdl-leading-comment-and-count.test.mdl b/mdl-examples/bug-tests/927-test-mdl-leading-comment-and-count.fail.test.mdl similarity index 100% rename from mdl-examples/bug-tests/927-test-mdl-leading-comment-and-count.test.mdl rename to mdl-examples/bug-tests/927-test-mdl-leading-comment-and-count.fail.test.mdl diff --git a/mdl-examples/bug-tests/expect-vacuous-assertions.test.mdl b/mdl-examples/bug-tests/expect-vacuous-assertions.fail.test.mdl similarity index 100% rename from mdl-examples/bug-tests/expect-vacuous-assertions.test.mdl rename to mdl-examples/bug-tests/expect-vacuous-assertions.fail.test.mdl From e31b0eadfaf2b5a9377d4b6bf997c154434343e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 07:12:14 +0000 Subject: [PATCH 02/12] fix(check): flag a LIMIT 1 retrieve that is then used as a list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `retrieve $x from Mod.E … limit 1` is compiled to Mendix's "First object" range, so $x is an OBJECT and not a one-element list. That is deliberate and documented — but nothing between the author and mxbuild said so: `check --references` passed, and DESCRIBE re-emits `limit 1`, so an object retrieve and a list retrieve are identical MDL text. The first sign was CE0097 "The selected 'x' variable must be of type List" at the far end of a build, and inside a .test.mdl file not even that — the injected test simply failed to build (mendixlabs/mxcli#1103). MDL-RETRIEVE01 tracks the variables a limit-1-no-offset retrieve binds, in statement order so a rebinding clears them, and flags a later list use — list operation, aggregate, loop, ADD/REMOVE. Keyed on exactly the condition the writer uses, because a check that disagrees with the writer it describes is worse than no check. The confusion is structural rather than careless: the same word means the opposite on `import from mapping`, where `first` binds the object and `limit 1` a one-element list. The message therefore names both working spellings instead of only refusing. Behaviour is unchanged; only the silence is fixed. Measured on the whole mdl-examples corpus (558 scripts, `make check-mdl`): no new failures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu --- ...3-retrieve-limit-one-is-an-object.fail.mdl | 26 ++++ mdl/executor/validate_microflow.go | 1 + .../validate_microflow_retrieve_single.go | 93 +++++++++++++ ...validate_microflow_retrieve_single_test.go | 125 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 mdl-examples/bug-tests/1103-retrieve-limit-one-is-an-object.fail.mdl create mode 100644 mdl/executor/validate_microflow_retrieve_single.go create mode 100644 mdl/executor/validate_microflow_retrieve_single_test.go diff --git a/mdl-examples/bug-tests/1103-retrieve-limit-one-is-an-object.fail.mdl b/mdl-examples/bug-tests/1103-retrieve-limit-one-is-an-object.fail.mdl new file mode 100644 index 0000000000..467ffe1c16 --- /dev/null +++ b/mdl-examples/bug-tests/1103-retrieve-limit-one-is-an-object.fail.mdl @@ -0,0 +1,26 @@ +-- mendixlabs/mxcli#1103 — `retrieve … limit 1` binds a single OBJECT. +-- +-- The executor maps `limit 1` with no offset to Mendix's "First object" range, +-- so $Requests is an object and head() cannot take it. Nothing said so before +-- the build: `mxcli check --references` passed and `describe` re-emits +-- `limit 1`, so an object retrieve and a list retrieve are identical text. +-- mxbuild rejected it as CE0097 at the far end of a build — and inside a +-- .test.mdl file, not even that: the injected test simply failed to build. +-- +-- MDL-RETRIEVE01 reports it at check time. This script must FAIL check. +create module Probe; + +create entity Probe.Request ( + Code: String(20) +); + +create or replace microflow Probe.M_LimitOneAsList () +returns Boolean as $Found +begin + declare $Found Boolean = false; + retrieve $Requests from Probe.Request where Code = 'X' limit 1; + $Request = head($Requests); + set $Found = $Request != empty; + return $Found; +end; +/ diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index f9e4f1e49f..b4eb1c4091 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -101,6 +101,7 @@ func (v *microflowValidator) addViolation(ruleID string, severity linter.Severit // validate runs all checks on the microflow body. func (v *microflowValidator) validate(body []ast.MicroflowStatement) { v.checkListOperationIterator(body) + v.checkRetrieveLimitOneAsList(body) v.checkMergeJoinLabels(body) v.checkAnnotationLabels(body) diff --git a/mdl/executor/validate_microflow_retrieve_single.go b/mdl/executor/validate_microflow_retrieve_single.go new file mode 100644 index 0000000000..d9edf2a2c4 --- /dev/null +++ b/mdl/executor/validate_microflow_retrieve_single.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// retrieveSingleRule is the rule ID for "a LIMIT 1 retrieve used as a list". +const retrieveSingleRule = "MDL-RETRIEVE01" + +// checkRetrieveLimitOneAsList flags a variable that `RETRIEVE … LIMIT 1` bound to +// a single OBJECT and a later statement uses as a LIST. +// +// `limit 1` is not a one-element list here. The executor maps it to Mendix's +// "First object" range (RangeTypeFirst, cmd_microflows_builder_actions.go), which +// makes the output variable an object — and that is deliberate and documented +// (MDL_QUICK_REFERENCE.md), not something to change under anyone's feet. +// +// What was missing is any sign of it before the build. Nothing in the MDL says +// the variable changed shape: `mxcli check --references` passed, and DESCRIBE +// re-emits `limit 1`, so the source of an object retrieve and a list retrieve are +// identical text. The first thing the author saw was CE0097 "The selected 'x' +// variable must be of type List" from mxbuild — and inside a .test.mdl file, not +// even that: the injected test simply failed to build (mendixlabs/mxcli#1103). +// +// The clause is also spelled the other way round elsewhere in the same language — +// `import from mapping … first` binds an object and `… limit 1` a one-element +// list — so reading it as a list is a reasonable mistake rather than a careless +// one. The message therefore names the working spelling instead of only refusing. +// +// Keyed on exactly the condition the writer uses (limit "1", no offset), because +// a check that disagrees with the writer it describes is worse than no check. +func (v *microflowValidator) checkRetrieveLimitOneAsList(body []ast.MicroflowStatement) { + // single holds the variables currently bound to one object by a LIMIT 1 + // retrieve. Maintained in statement order so a rebinding clears it: a name + // reused for a real list further down is not this rule's business. + single := map[string]bool{} + + forEachMicroflowStatement(body, func(s ast.MicroflowStatement) { + if name, op := listUseOf(s); name != "" && single[name] { + v.addViolation(retrieveSingleRule, linter.SeverityError, + fmt.Sprintf("$%s was retrieved with LIMIT 1, which binds a single object rather than a "+ + "one-element list, so %s cannot take it — mxbuild rejects this with CE0097 "+ + "\"The selected '%s' variable must be of type List\".", name, op, name), + fmt.Sprintf("Drop the LIMIT to retrieve a list and keep %s, or keep LIMIT 1 and use "+ + "$%s as the object it already is.", op, name)) + } + + // Rebinding first, so a statement that both consumes and produces the + // name is judged on what it consumed. + for _, p := range statementProducedVars(s) { + delete(single, p.name) + } + if r, ok := s.(*ast.RetrieveStmt); ok && r.Limit == "1" && r.Offset == "" && r.Variable != "" { + single[r.Variable] = true + } + }) +} + +// listUseOf reports the list variable a statement consumes, and a phrase naming +// what consumes it. ("", "") when the statement takes no list. +func listUseOf(s ast.MicroflowStatement) (string, string) { + switch st := s.(type) { + case *ast.ListOperationStmt: + if st.InputVariable != "" { + return st.InputVariable, st.Operation.String() + "()" + } + if st.SecondVariable != "" { + return st.SecondVariable, st.Operation.String() + "()" + } + case *ast.AggregateListStmt: + if st.InputVariable != "" { + return st.InputVariable, st.Operation.String() + "()" + } + case *ast.LoopStmt: + if st.ListVariable != "" { + return st.ListVariable, "a loop" + } + case *ast.AddToListStmt: + if st.List != "" { + return st.List, "ADD … TO" + } + case *ast.RemoveFromListStmt: + if st.List != "" { + return st.List, "REMOVE … FROM" + } + } + return "", "" +} diff --git a/mdl/executor/validate_microflow_retrieve_single_test.go b/mdl/executor/validate_microflow_retrieve_single_test.go new file mode 100644 index 0000000000..e034d0165f --- /dev/null +++ b/mdl/executor/validate_microflow_retrieve_single_test.go @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func retrieveSingleViolations(t *testing.T, stmt *ast.CreateMicroflowStmt) []string { + t.Helper() + var out []string + for _, v := range ValidateMicroflow(stmt) { + if v.RuleID == retrieveSingleRule { + out = append(out, v.Message) + } + } + return out +} + +func mfWith(body ...ast.MicroflowStatement) *ast.CreateMicroflowStmt { + return &ast.CreateMicroflowStmt{ + Name: ast.QualifiedName{Module: "Probe", Name: "M"}, + Body: body, + } +} + +func retrieveLimit(variable, limit, offset string) *ast.RetrieveStmt { + return &ast.RetrieveStmt{ + Variable: variable, + Source: ast.QualifiedName{Module: "Probe", Name: "Request"}, + Limit: limit, + Offset: offset, + } +} + +// TestRetrieveLimitOneUsedAsList is mendixlabs/mxcli#1103's real defect. +// +// `RETRIEVE $x … LIMIT 1` is compiled to Mendix's "First object" range, so $x is +// an OBJECT, not a one-element list. Nothing between the author and mxbuild said +// so: `mxcli check --references` passed, and `describe` re-emits `limit 1`, so +// the source looks identical to a list retrieve. The first sign was CE0097 at +// the far end of a build — and, in a .test.mdl file, a test that failed to +// inject with no explanation at all. +func TestRetrieveLimitOneUsedAsList(t *testing.T) { + t.Run("HEAD of a LIMIT 1 retrieve is flagged", func(t *testing.T) { + got := retrieveSingleViolations(t, mfWith( + retrieveLimit("reqs", "1", ""), + &ast.ListOperationStmt{Operation: ast.ListOpHead, InputVariable: "reqs", OutputVariable: "req"}, + )) + if len(got) != 1 { + t.Fatalf("got %d violations %q, want 1", len(got), got) + } + for _, want := range []string{"$reqs", "LIMIT 1", "CE0097"} { + if !strings.Contains(got[0], want) { + t.Errorf("message %q does not mention %q", got[0], want) + } + } + }) + + t.Run("looping over a LIMIT 1 retrieve is flagged", func(t *testing.T) { + got := retrieveSingleViolations(t, mfWith( + retrieveLimit("reqs", "1", ""), + &ast.LoopStmt{LoopVariable: "r", ListVariable: "reqs"}, + )) + if len(got) != 1 { + t.Fatalf("got %d violations %q, want 1", len(got), got) + } + }) + + t.Run("aggregating a LIMIT 1 retrieve is flagged", func(t *testing.T) { + got := retrieveSingleViolations(t, mfWith( + retrieveLimit("reqs", "1", ""), + &ast.AggregateListStmt{Operation: ast.AggregateCount, InputVariable: "reqs", OutputVariable: "n"}, + )) + if len(got) != 1 { + t.Fatalf("got %d violations %q, want 1", len(got), got) + } + }) +} + +// TestRetrieveLimitOneControls are the cases that must NOT be flagged. Each is a +// shape the rule would swallow if it keyed on the wrong thing. +func TestRetrieveLimitOneControls(t *testing.T) { + cases := map[string]*ast.CreateMicroflowStmt{ + // The reporter's own control: without LIMIT the retrieve is a list, and + // this is the single most common shape in every test suite. + "no limit": mfWith( + retrieveLimit("reqs", "", ""), + &ast.ListOperationStmt{Operation: ast.ListOpHead, InputVariable: "reqs", OutputVariable: "req"}, + ), + // LIMIT 2 is a CustomRange, which is a list however small. + "limit 2": mfWith( + retrieveLimit("reqs", "2", ""), + &ast.ListOperationStmt{Operation: ast.ListOpHead, InputVariable: "reqs", OutputVariable: "req"}, + ), + // An offset forces CustomRange even at limit 1 — the executor's own + // condition, so the rule must use the same one or it will disagree with + // the writer it is describing. + "limit 1 with offset": mfWith( + retrieveLimit("reqs", "1", "5"), + &ast.ListOperationStmt{Operation: ast.ListOpHead, InputVariable: "reqs", OutputVariable: "req"}, + ), + // Using it as an object is exactly right and must stay silent. + "used as an object": mfWith( + retrieveLimit("req", "1", ""), + &ast.MfCommitStmt{Variable: "req"}, + ), + // Rebound to a real list before the list use. + "rebound to a list": mfWith( + retrieveLimit("reqs", "1", ""), + &ast.CreateListStmt{Variable: "reqs", EntityType: ast.QualifiedName{Module: "Probe", Name: "Request"}}, + &ast.ListOperationStmt{Operation: ast.ListOpHead, InputVariable: "reqs", OutputVariable: "req"}, + ), + } + for name, stmt := range cases { + t.Run(name, func(t *testing.T) { + if got := retrieveSingleViolations(t, stmt); len(got) != 0 { + t.Errorf("flagged a valid microflow: %q", got) + } + }) + } +} From d3418fa4ad4019297fa5963948a895a177715434 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 07:12:29 +0000 Subject: [PATCH 03/12] fix(test): say what mxbuild rejected, and leave nothing behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of mendixlabs/mxcli#1104, both about a failed `mxcli test` run telling the reader nothing and then poisoning every later run. MxBuild puts the same text in Message for every failing build, so "build failed: The project cannot be deployed, because it contains errors." cannot distinguish "your test does not compile" from "an unrelated document is broken". The parsed problems were in hand and discarded one line before they were needed: --attach and every --watch rebuild built their error with fmt.Errorf, while the attribution that turns a build error into the failing test's row was wired only into the --local boot. Both now return a *docker.BuildFailedError and go through one shared resultsForBuildFailure, so an error in a generated test microflow becomes that test's ERROR row and one in the project names its document. The hint no longer repeats the errors the error already renders, and names a leftover generated flow with the DROP that removes it. Cleanup now removes every generated MxTest.Test_* the project holds rather than the current suite's. The names are positional — Test_test_1, _2, … from the test's index in its file — and every test file reuses them, so a run with fewer tests than the last one left the surplus behind; under --attach the MxTest module always pre-exists (the dev loop installed it), so the whole-module drop never fired. One leftover that does not build then failed every subsequent run of every test file. Keying on the suite was wrong the other way too: it issued DROP for flows a part-way injection never created, and those failures made cleanup announce "the project has been left modified" for a project it had just cleaned. What cleanup genuinely cannot remove is now named, with its DROP. Measured end to end: with a planted bad MxTest.Test_test_2, a known-good one-test suite failed and left it in place before, and after the fix run 1 fails and cleans while run 2 passes. Reverting buildFailure() to fmt.Errorf reproduces the reported sentence verbatim. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu --- cmd/mxcli/docker/localapp.go | 5 +- cmd/mxcli/testrunner/build_attribution.go | 84 +++++++++++- .../testrunner/build_attribution_test.go | 19 ++- .../build_failure_surfacing_test.go | 123 ++++++++++++++++++ cmd/mxcli/testrunner/cleanup_leftovers.go | 114 ++++++++++++++++ .../testrunner/cleanup_leftovers_test.go | 76 +++++++++++ .../testrunner/generator_endpoint_test.go | 4 +- cmd/mxcli/testrunner/handshake_test.go | 4 +- cmd/mxcli/testrunner/runner.go | 22 +++- cmd/mxcli/testrunner/runner_attach.go | 52 +++++--- cmd/mxcli/testrunner/runner_cleanup_test.go | 4 +- cmd/mxcli/testrunner/runner_endpoint.go | 23 +--- 12 files changed, 473 insertions(+), 57 deletions(-) create mode 100644 cmd/mxcli/testrunner/build_failure_surfacing_test.go create mode 100644 cmd/mxcli/testrunner/cleanup_leftovers.go create mode 100644 cmd/mxcli/testrunner/cleanup_leftovers_test.go diff --git a/cmd/mxcli/docker/localapp.go b/cmd/mxcli/docker/localapp.go index 0950e772ec..058bee2d54 100644 --- a/cmd/mxcli/docker/localapp.go +++ b/cmd/mxcli/docker/localapp.go @@ -249,7 +249,10 @@ func (a *LocalApp) Rebuild(projectPath string) (ApplyAction, *BuildResult, error return ActionReload, nil, err } if !build.OK() { - return ActionReload, build, fmt.Errorf("build failed: %s", build.Message) + // The same error type the cold boot returns, so a caller can attribute the + // problems rather than re-parse a sentence. Message alone is identical for + // every failing build (mendixlabs/mxcli#1104). + return ActionReload, build, &BuildFailedError{Result: build} } action, err := a.Runtime.Controller().ApplyBuild(build, a.Runtime.Restart) return action, build, err diff --git a/cmd/mxcli/testrunner/build_attribution.go b/cmd/mxcli/testrunner/build_attribution.go index b67523e37a..f49a66b335 100644 --- a/cmd/mxcli/testrunner/build_attribution.go +++ b/cmd/mxcli/testrunner/build_attribution.go @@ -25,13 +25,51 @@ package testrunner import ( + "errors" "fmt" "regexp" "strings" + "time" "github.com/mendixlabs/mxcli/cmd/mxcli/docker" ) +// buildFailure is the error every path in this package returns for a build +// MxBuild rejected. +// +// The type is load-bearing, not decoration. MxBuild puts the same sentence in +// Message for every failing build — "The project cannot be deployed, because it +// contains errors." — so an error carrying only that cannot tell "your test does +// not compile" from "an unrelated document in the project is broken". Two paths +// built their error with fmt.Errorf and threw the parsed problems away, which is +// the whole of mendixlabs/mxcli#1104's first half: the detail was already in +// hand and discarded one line before it was needed. +func buildFailure(build *docker.BuildResult) error { + return &docker.BuildFailedError{Result: build} +} + +// resultsForBuildFailure turns a failed build into something the reader can act +// on, and passes any other error through untouched. +// +// A build error belonging to a generated test microflow is that test's problem: +// it becomes an ERROR row and the rest become SKIP. Anything else is in the +// project, and the returned error names the documents so the reader is not sent +// looking through their own model for a message that came from mxcli's. +// +// Shared by both runners on purpose. It was wired into the --local boot only, so +// --attach and every rebuild under --watch reported the bare sentence. +func resultsForBuildFailure(err error, suite *TestSuite) (*SuiteResult, error) { + var bf *docker.BuildFailedError + if !errors.As(err, &bf) { + return nil, err + } + if results := resultsFromFailedBuild(bf.BuildErrors(), suite); results != nil { + return &SuiteResult{Name: suite.Name, Tests: results, Started: time.Now()}, nil + } + _, other := attributeBuildProblems(bf.BuildErrors(), suite) + return nil, fmt.Errorf("%w%s", err, buildFailureHint(other)) +} + // testFlowDocumentPattern matches the `document` MxBuild reports for a generated // test microflow. // @@ -138,17 +176,51 @@ func resultsFromFailedBuild(problems []docker.BuildProblem, suite *TestSuite) [] // buildFailureHint is appended to the error when a build failure could not be // attributed to any test, which means it is in the project rather than in the // suite. +// +// The errors themselves are already rendered by BuildFailedError.Error(), so +// this says what the reader cannot work out from them: that none of them belongs +// to a test in this run, and — for a generated microflow no current test owns — +// that it is a leftover from an earlier run, with the command to remove it. That +// last case is the one that fails every subsequent run of every test file until +// someone finds it by hand (mendixlabs/mxcli#1104). func buildFailureHint(other []docker.BuildProblem) string { if len(other) == 0 { return "" } var b strings.Builder - b.WriteString("\n The build errors are in the project, not in the tests:") - for _, p := range other { - b.WriteString(fmt.Sprintf("\n %s %s", p.ErrorCode, p.Message)) - if w := p.Where(); w != "" { - b.WriteString(" — at " + w) - } + b.WriteString("\n These errors are in the project, not in this run's tests.") + for _, name := range leftoverFlowsIn(other) { + b.WriteString(fmt.Sprintf( + "\n %s is a microflow an earlier `mxcli test` run left behind. Remove it with:"+ + "\n mxcli -p -c \"DROP MICROFLOW %s\"", name, name)) } return b.String() } + +// leftoverFlowsIn names the generated test microflows among a set of build +// problems, deduplicated and in the order MxBuild reported them. +// +// A document matching the generated prefix, in the generated module, that no +// test in this run owns, can only have come from an earlier run: the names are +// positional and nothing else in the project is allowed to use them. +func leftoverFlowsIn(problems []docker.BuildProblem) []string { + var names []string + seen := map[string]bool{} + for _, p := range problems { + for _, loc := range p.Locations { + if !strings.EqualFold(loc.Module, mxTestModule) { + continue + } + m := testFlowDocumentPattern.FindStringSubmatch(loc.Document) + if m == nil { + continue + } + name := mxTestModule + "." + m[1] + if !seen[name] { + seen[name] = true + names = append(names, name) + } + } + } + return names +} diff --git a/cmd/mxcli/testrunner/build_attribution_test.go b/cmd/mxcli/testrunner/build_attribution_test.go index ed626abfbc..dd2cec8404 100644 --- a/cmd/mxcli/testrunner/build_attribution_test.go +++ b/cmd/mxcli/testrunner/build_attribution_test.go @@ -130,11 +130,20 @@ func TestResultsFromFailedBuildDeclinesUnattributableErrors(t *testing.T) { t.Fatalf("expected no results for a project-level failure, got %v", results) } - _, other := attributeBuildProblems([]docker.BuildProblem{p}, suite) - hint := buildFailureHint(other) - for _, want := range []string{"in the project, not in the tests", "CE0109", "SUB_Deal"} { - if !strings.Contains(hint, want) { - t.Errorf("hint %q does not mention %q", hint, want) + // Asserted on the whole message the reader sees, not on the hint alone: the + // errors are rendered once by BuildFailedError.Error() and the hint adds only + // what cannot be read off them. Testing the hint in isolation is what made an + // earlier version print every error twice. + _, err := resultsForBuildFailure(buildFailure(failedBuild(p)), suite) + if err == nil { + t.Fatal("err = nil, want the build failure") + } + for _, want := range []string{"in the project, not in this run's tests", "CE0109", "SUB_Deal"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("message %q does not mention %q", err.Error(), want) } } + if strings.Count(err.Error(), "CE0109") != 1 { + t.Errorf("CE0109 is reported %d times, want once:\n%s", strings.Count(err.Error(), "CE0109"), err.Error()) + } } diff --git a/cmd/mxcli/testrunner/build_failure_surfacing_test.go b/cmd/mxcli/testrunner/build_failure_surfacing_test.go new file mode 100644 index 0000000000..61eec25aac --- /dev/null +++ b/cmd/mxcli/testrunner/build_failure_surfacing_test.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" +) + +// failedBuild is the shape a serve /build response has when MxBuild rejected the +// model: the generic Message, with the real detail only in Problems. +func failedBuild(problems ...docker.BuildProblem) *docker.BuildResult { + return &docker.BuildResult{ + Status: "Failure", + Message: "The project cannot be deployed, because it contains errors.", + Problems: docker.BuildProblems{Problems: problems}, + } +} + +// TestBuildFailureCarriesTheProblems is the guard for mendixlabs/mxcli#1104's +// first half: a rebuild that fails must hand the caller the parsed problems, not +// only the one sentence MxBuild puts in Message. +// +// The sentence is identical for every failing build, so an error carrying only +// that is indistinguishable between "your test does not compile" and "an +// unrelated document in the project is broken" — which is exactly the report. +func TestBuildFailureCarriesTheProblems(t *testing.T) { + build := failedBuild(problem("CE0097", "The selected 'accs' variable must be of type List.", + "MxTest", "Microflow 'Test_test_2'", "List operation activity 'Head'")) + + err := buildFailure(build) + + var bf *docker.BuildFailedError + if !errors.As(err, &bf) { + t.Fatalf("error is %T, want *docker.BuildFailedError — the caller cannot attribute what it cannot inspect", err) + } + if len(bf.BuildErrors()) != 1 { + t.Fatalf("BuildErrors() = %d, want 1", len(bf.BuildErrors())) + } + if msg := err.Error(); !strings.Contains(msg, "CE0097") || !strings.Contains(msg, "Test_test_2") { + t.Errorf("Error() = %q, want the code and the document it was found in", msg) + } +} + +// TestResultsForBuildFailure covers the shared handling both runners now use: a +// build error belonging to a generated test microflow becomes that test's ERROR +// row, and one belonging to the project is reported with the documents named. +func TestResultsForBuildFailure(t *testing.T) { + suite := suiteOf("test_1", "test_2") + + t.Run("an error in a test microflow becomes that test's row", func(t *testing.T) { + err := buildFailure(failedBuild(problem("CE0097", "must be of type List.", + "MxTest", "Microflow 'Test_test_2'", "List operation activity 'Head'"))) + + result, outErr := resultsForBuildFailure(err, suite) + if outErr != nil { + t.Fatalf("outErr = %v, want nil — the run reports per-test rows", outErr) + } + if result == nil { + t.Fatal("result = nil, want one row per test") + } + byID := map[string]TestResult{} + for _, r := range result.Tests { + byID[r.ID] = r + } + if byID["test_2"].Status != StatusError { + t.Errorf("test_2 = %v, want ERROR", byID["test_2"].Status) + } + if byID["test_1"].Status != StatusSkip { + t.Errorf("test_1 = %v, want SKIP — it was never run", byID["test_1"].Status) + } + }) + + t.Run("an error in the project names the document", func(t *testing.T) { + // The leftover case: a generated microflow from an EARLIER run is not in + // this suite, so nothing here can be blamed for it — but the reader still + // has to be told which document to go and remove. + err := buildFailure(failedBuild(problem("CE0097", "must be of type List.", + "MxTest", "Microflow 'Test_test_7'", "List operation activity 'Head'"))) + + result, outErr := resultsForBuildFailure(err, suite) + if result != nil { + t.Errorf("result = %v, want nil — no test in this suite is at fault", result) + } + if outErr == nil { + t.Fatal("outErr = nil, want the build failure") + } + if !strings.Contains(outErr.Error(), "Test_test_7") { + t.Errorf("Error() = %q, want the leftover document named", outErr.Error()) + } + // A Test_* microflow in MxTest that no current test owns can only be a + // leftover from an earlier run. Saying so — and how to remove it — is the + // difference between one command and a hunt through the model. + if !strings.Contains(outErr.Error(), "DROP MICROFLOW MxTest.Test_test_7") { + t.Errorf("Error() = %q, want a runnable DROP for the leftover", outErr.Error()) + } + }) + + t.Run("an error in the user's own model is not called a leftover", func(t *testing.T) { + err := buildFailure(failedBuild(problem("CE0109", "Undefined variable 'x'.", + "Sudoku", "Microflow 'SUB_Deal'", "End event"))) + + _, outErr := resultsForBuildFailure(err, suite) + if outErr == nil { + t.Fatal("outErr = nil, want the build failure") + } + if strings.Contains(outErr.Error(), "DROP MICROFLOW") { + t.Errorf("Error() = %q, must not offer to drop the user's own document", outErr.Error()) + } + }) + + t.Run("a non-build error is passed through untouched", func(t *testing.T) { + want := fmt.Errorf("the runtime is not running") + result, outErr := resultsForBuildFailure(want, suite) + if result != nil || !errors.Is(outErr, want) { + t.Errorf("resultsForBuildFailure(%v) = %v, %v; want nil, the same error", want, result, outErr) + } + }) +} diff --git a/cmd/mxcli/testrunner/cleanup_leftovers.go b/cmd/mxcli/testrunner/cleanup_leftovers.go new file mode 100644 index 0000000000..6f68ac1608 --- /dev/null +++ b/cmd/mxcli/testrunner/cleanup_leftovers.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Deciding what cleanup has to remove. +// +// The generated test microflows are named positionally — MxTest.Test_test_1, +// _2, … from the test's index in its file — so every test file reuses the same +// names. Cleaning up the CURRENT suite's names is therefore wrong in both +// directions, and both were reported as mendixlabs/mxcli#1104: +// +// - A run with fewer tests than the last one leaves the surplus behind, and a +// leftover that does not compile fails the build of every later run of any +// file. Cleanup never looks at it again, because it is not in any suite. +// - An injection that failed part-way created only some of them, so DROPping +// the whole suite fails on the rest and cleanup reports the project left +// modified when it had just been cleaned. +// +// What is in the project is the authority on what to remove. The suite is only +// the fallback for when the project cannot be read. +package testrunner + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" +) + +// generatedTestFlowNames qualifies the microflow names mxcli generated, and only +// those. +// +// A pre-existing MxTest module is the user's — `mxcli test` adds documents to it +// and takes those documents back out, never the module and never anything else +// in it. The prefix is the whole of that distinction, so it is applied in one +// place. +func generatedTestFlowNames(names []string) []string { + bare := strings.TrimPrefix(testFlowPrefix, mxTestModule+".") + out := make([]string, 0, len(names)) + for _, n := range names { + if strings.HasPrefix(n, bare) { + out = append(out, mxTestModule+"."+n) + } + } + return out +} + +// listGeneratedTestFlows asks the project which generated test microflows it +// currently holds. +func listGeneratedTestFlows(projectPath string) ([]string, error) { + mxcliPath, err := findMxcli() + if err != nil { + return nil, err + } + cmd := exec.Command(mxcliPath, "-p", projectPath, "-c", "SHOW MICROFLOWS IN "+mxTestModule, "--json") + cmd.Env = append(os.Environ(), "MXCLI_QUIET=1") + output, err := cmd.Output() + if err != nil { + return nil, err + } + var flows []struct { + Name string `json:"Name"` + } + if err := json.Unmarshal(output, &flows); err != nil { + return nil, fmt.Errorf("parsing microflow list: %w", err) + } + names := make([]string, 0, len(flows)) + for _, f := range flows { + names = append(names, f.Name) + } + return generatedTestFlowNames(names), nil +} + +// testFlowsToDrop returns the generated microflows cleanup should remove. +// +// Discovery can fail — no MxTest module yet, an unreadable project, no mxcli on +// PATH — and a cleanup that removes nothing is worse than one that tries the +// names it knows. So the suite is the fallback, which is exactly the old +// behaviour and no worse than it. +func testFlowsToDrop(projectPath string, suite *TestSuite) []string { + if projectPath == "" { + return suiteTestFlowNames(suite) + } + if flows, err := listGeneratedTestFlows(projectPath); err == nil { + return flows + } + return suiteTestFlowNames(suite) +} + +// suiteTestFlowNames is the fallback: the names this run would have created. +func suiteTestFlowNames(suite *TestSuite) []string { + if suite == nil { + return nil + } + names := make([]string, 0, len(suite.Tests)) + for _, tc := range suite.Tests { + names = append(names, testFlowName(tc)) + } + return names +} + +// survivingTestFlows reports what is still in the project after a failed +// cleanup, best effort. Nothing is reported rather than something guessed: a +// list the reader cannot trust is worse than no list, because the whole point +// is to save them the hunt. +func survivingTestFlows(projectPath string) []string { + if projectPath == "" { + return nil + } + flows, err := listGeneratedTestFlows(projectPath) + if err != nil { + return nil + } + return flows +} diff --git a/cmd/mxcli/testrunner/cleanup_leftovers_test.go b/cmd/mxcli/testrunner/cleanup_leftovers_test.go new file mode 100644 index 0000000000..95bbd8666a --- /dev/null +++ b/cmd/mxcli/testrunner/cleanup_leftovers_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "errors" + "strings" + "testing" +) + +// TestEndpointCleanupDropsEveryGeneratedFlow is the guard for the second half of +// mendixlabs/mxcli#1104. +// +// Generated names are positional — MxTest.Test_test_1, _2, … — and are reused by +// every test file. Cleaning up only the CURRENT suite's names therefore leaves a +// flow behind whenever a later run has fewer tests than an earlier one, and that +// leftover fails the build of every subsequent run of any file. What is in the +// project is the authority on what to drop; the suite is not. +func TestEndpointCleanupDropsEveryGeneratedFlow(t *testing.T) { + st := projectState{afterStartup: "MyModule.Startup"} + // This run has one test; Test_test_7 is an earlier run's leftover. + present := []string{"MxTest.Test_test_1", "MxTest.Test_test_7"} + + cmds := endpointCleanupCommands(st, present, true) + joined := strings.Join(cmds, "\n") + + for _, want := range []string{ + "DROP MICROFLOW MxTest.Test_test_1", + "DROP MICROFLOW MxTest.Test_test_7", + } { + if !strings.Contains(joined, want) { + t.Errorf("cleanup does not %s:\n%s", want, joined) + } + } +} + +// TestCleanupNeverDropsWhatWasNotCreated is the other half of keying cleanup on +// the project rather than on the suite. +// +// An injection that failed part-way leaves some flows created and some not. +// Issuing DROP for every test in the suite makes the missing ones fail, so +// cleanup reported "the project has been left modified" for a project it had +// just cleaned — a false alarm that sends the reader looking for damage. +func TestCleanupNeverDropsWhatWasNotCreated(t *testing.T) { + st := projectState{} + cmds := endpointCleanupCommands(st, []string{"MxTest.Test_test_1"}, true) + if strings.Contains(strings.Join(cmds, "\n"), "Test_test_2") { + t.Errorf("cleanup drops a flow that was never created:\n%s", strings.Join(cmds, "\n")) + } +} + +// TestGeneratedTestFlowNames keeps the prefix filter honest: a user's own +// microflow in a pre-existing MxTest module is not mxcli's to delete. +func TestGeneratedTestFlowNames(t *testing.T) { + got := generatedTestFlowNames([]string{"Test_test_1", "MyOwnFlow", "RegisterEndpoint", "Test_test_12"}) + want := []string{"MxTest.Test_test_1", "MxTest.Test_test_12"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("generatedTestFlowNames = %v, want %v", got, want) + } +} + +// TestReportCleanupNamesWhatWasLeft covers the reporter's second ask: a cleanup +// failure that does not say WHICH document survived leaves them to find it by +// hand, which is the step that cost them the debugging cycle. +func TestReportCleanupNamesWhatWasLeft(t *testing.T) { + var b strings.Builder + reportCleanup(&b, errors.New("DROP MICROFLOW MxTest.Test_test_2: exit status 1"), []string{"MxTest.Test_test_2"}) + out := b.String() + + if !strings.Contains(out, "MxTest.Test_test_2") { + t.Errorf("the surviving document is not named:\n%s", out) + } + if !strings.Contains(out, "DROP MICROFLOW MxTest.Test_test_2") { + t.Errorf("no runnable DROP was offered:\n%s", out) + } +} diff --git a/cmd/mxcli/testrunner/generator_endpoint_test.go b/cmd/mxcli/testrunner/generator_endpoint_test.go index 64f7b644f4..3bbbbbc74f 100644 --- a/cmd/mxcli/testrunner/generator_endpoint_test.go +++ b/cmd/mxcli/testrunner/generator_endpoint_test.go @@ -197,7 +197,7 @@ func TestEndpointCleanupCommands(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := endpointCleanupCommands(tc.state, suite, tc.present) + got := endpointCleanupCommands(tc.state, suiteTestFlowNames(suite), tc.present) if len(got) != len(tc.want) { t.Fatalf("got %d commands %q, want %d %q", len(got), got, len(tc.want), tc.want) } @@ -219,7 +219,7 @@ func TestEndpointCleanupRestoreIsAlwaysFirst(t *testing.T) { {createdMxTest: false}, {afterStartup: "Mod.ASU", createdMxTest: true}, } { - cmds := endpointCleanupCommands(st, suite, true) + cmds := endpointCleanupCommands(st, suiteTestFlowNames(suite), true) if !strings.HasPrefix(cmds[0], "ALTER SETTINGS MODEL AfterStartupMicroflow") { t.Errorf("state %+v: first command is %q, want the after-startup restore", st, cmds[0]) } diff --git a/cmd/mxcli/testrunner/handshake_test.go b/cmd/mxcli/testrunner/handshake_test.go index 26d5ad5033..6748770ea6 100644 --- a/cmd/mxcli/testrunner/handshake_test.go +++ b/cmd/mxcli/testrunner/handshake_test.go @@ -148,7 +148,7 @@ func TestGenerateEndpointMDLNoChainWhenNone(t *testing.T) { } func TestDropTestFlows(t *testing.T) { - got := dropTestFlows(&TestSuite{Tests: []TestCase{{ID: "test_1"}, {ID: "test_2"}}}) + got := dropTestFlows("", &TestSuite{Tests: []TestCase{{ID: "test_1"}, {ID: "test_2"}}}) want := []string{"DROP MICROFLOW MxTest.Test_test_1", "DROP MICROFLOW MxTest.Test_test_2"} if len(got) != len(want) { t.Fatalf("got %q, want %q", got, want) @@ -164,7 +164,7 @@ func TestDropTestFlows(t *testing.T) { // attach adds only test microflows, so it must remove only those. The endpoint // and the after-startup setting belong to the dev loop hosting them. func TestDropTestFlowsNeverTouchesTheEndpoint(t *testing.T) { - for _, cmd := range dropTestFlows(&TestSuite{Tests: []TestCase{{ID: "test_1"}}}) { + for _, cmd := range dropTestFlows("", &TestSuite{Tests: []TestCase{{ID: "test_1"}}}) { for _, forbidden := range []string{"DROP MODULE", endpointStartupFlow, endpointRegisterAction, "AfterStartupMicroflow"} { if strings.Contains(cmd, forbidden) { t.Errorf("attach cleanup would remove %q, which the hosting dev loop owns: %q", forbidden, cmd) diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index 54c017f014..939ca86a62 100644 --- a/cmd/mxcli/testrunner/runner.go +++ b/cmd/mxcli/testrunner/runner.go @@ -284,7 +284,7 @@ func runEndpoint(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. cleanupErr := cleanupEndpoint(opts.ProjectPath, state, cleanupSuite, w) removeGeneratedJavaSource(opts.ProjectPath, w) restoreProjectFile(state, cleanupErr, w) - reportCleanup(w, cleanupErr) + reportCleanup(w, cleanupErr, survivingTestFlows(opts.ProjectPath)) if cleanupErr == nil { fmt.Fprintln(w, " project restored") } @@ -374,7 +374,7 @@ func runAfterStartup(opts RunOptions, suite *TestSuite, timeout time.Duration, w if err != nil { cleanupErr := cleanup(opts.ProjectPath, state, w) restoreProjectFile(state, cleanupErr, w) - reportCleanup(w, cleanupErr) + reportCleanup(w, cleanupErr, survivingTestFlows(opts.ProjectPath)) return nil, err } @@ -384,7 +384,7 @@ func runAfterStartup(opts RunOptions, suite *TestSuite, timeout time.Duration, w fmt.Fprintln(w, "Cleaning up...") cleanupErr := cleanup(opts.ProjectPath, state, w) restoreProjectFile(state, cleanupErr, w) - reportCleanup(w, cleanupErr) + reportCleanup(w, cleanupErr, survivingTestFlows(opts.ProjectPath)) PrintResults(w, result, opts.Color) @@ -752,11 +752,25 @@ func cleanup(projectPath string, st projectState, w io.Writer) error { // reportCleanup prints a cleanup failure prominently. The project is left mutated, // so this must not read as a passing run. -func reportCleanup(w io.Writer, err error) { +// left names the generated documents still in the project, so the reader does +// not have to find them by hand — which is the step mendixlabs/mxcli#1104 says +// cost them a debugging cycle. A surviving test microflow is not inert: it fails +// the build of every later run of any test file. +func reportCleanup(w io.Writer, err error, left []string) { if err == nil { return } fmt.Fprintf(w, "\nERROR: cleanup failed — the project has been left modified:\n%v\n", err) + if len(left) > 0 { + fmt.Fprintf(w, "\nStill in the project (generated by this run or an earlier one):\n") + for _, name := range left { + fmt.Fprintf(w, " %s\n", name) + } + fmt.Fprintf(w, "\nRemove them with:\n") + for _, name := range left { + fmt.Fprintf(w, " mxcli -p -c \"DROP MICROFLOW %s\"\n", name) + } + } fmt.Fprintf(w, "Check the after-startup microflow and the %s module before committing.\n", mxTestModule) } diff --git a/cmd/mxcli/testrunner/runner_attach.go b/cmd/mxcli/testrunner/runner_attach.go index 9d817f391b..800ed11eda 100644 --- a/cmd/mxcli/testrunner/runner_attach.go +++ b/cmd/mxcli/testrunner/runner_attach.go @@ -81,7 +81,11 @@ func (a *attachedApp) applyModelChange(projectPath string) (string, error) { return "", fmt.Errorf("rebuilding through the attached app's build server on port %d: %w", a.hs.ServePort, err) } if !build.OK() { - return "", fmt.Errorf("build failed: %s", build.Message) + // Carries the parsed problems; the caller turns them into per-test rows or + // names the document they were found in. Returning only build.Message here + // is what made an --attach run say nothing but "the project cannot be + // deployed" (mendixlabs/mxcli#1104). + return "", buildFailure(build) } // No restart callback: the runtime belongs to the other process. A structural // change is refused rather than half-applied — see the error below. @@ -113,7 +117,7 @@ func runAttached(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. injected := suite finish := func(result *SuiteResult, runErr error) (*SuiteResult, error) { fmt.Fprintln(w, "Cleaning up...") - cleanupErr := runMDLCommands(opts.ProjectPath, dropTestFlows(injected)) + cleanupErr := runMDLCommands(opts.ProjectPath, dropTestFlows(opts.ProjectPath, injected)) if cleanupErr == nil { // Leave the app serving a model that matches the project on disk; // otherwise the developer's next page load still runs the test flows. @@ -122,7 +126,7 @@ func runAttached(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. } fmt.Fprintln(w, " test microflows removed") } else { - reportCleanup(w, cleanupErr) + reportCleanup(w, cleanupErr, survivingTestFlows(opts.ProjectPath)) } if runErr != nil { return nil, runErr @@ -133,12 +137,28 @@ func runAttached(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. return result, nil } + // Printing is shared because a build MxBuild rejected now produces results + // too: the failing test gets an ERROR row instead of the run getting a bare + // "the project cannot be deployed". + report := func(result *SuiteResult, err error) (*SuiteResult, error) { + if result != nil { + PrintResults(w, result, opts.Color) + if jerr := writeJUnit(opts, result, w); jerr != nil && err == nil { + err = jerr + } + } + return result, err + } + fmt.Fprintln(w, "Injecting test microflows...") if err := execMDLScript(opts.ProjectPath, GenerateTestFlows(suite), "mxtest-flows-*.mdl"); err != nil { return finish(nil, fmt.Errorf("injecting test microflows: %w", err)) } if _, err := app.applyModelChange(opts.ProjectPath); err != nil { - return finish(nil, err) + // Same treatment the --local boot gives a rejected build: attribute each + // error to the generated microflow it was found in, and name the document + // when it belongs to the project instead (mendixlabs/mxcli#1104). + return report(finish(resultsForBuildFailure(err, suite))) } if opts.Watch { @@ -149,24 +169,18 @@ func runAttached(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. if err != nil { return finish(nil, err) } - result, err = finish(result, nil) - if result != nil { - PrintResults(w, result, opts.Color) - if jerr := writeJUnit(opts, result, w); jerr != nil && err == nil { - err = jerr - } - } - return result, err + return report(finish(result, nil)) } // dropTestFlows returns the DROP statements for a suite's generated microflows. -func dropTestFlows(suite *TestSuite) []string { - if suite == nil { - return nil - } - cmds := make([]string, 0, len(suite.Tests)) - for _, tc := range suite.Tests { - cmds = append(cmds, "DROP MICROFLOW "+testFlowName(tc)) +func dropTestFlows(projectPath string, suite *TestSuite) []string { + // Keyed on what the project holds, not on this suite: an attach always runs + // against a project whose MxTest module pre-exists, so nothing else ever + // removes a flow an earlier run left behind (mendixlabs/mxcli#1104). + flows := testFlowsToDrop(projectPath, suite) + cmds := make([]string, 0, len(flows)) + for _, name := range flows { + cmds = append(cmds, "DROP MICROFLOW "+name) } return cmds } diff --git a/cmd/mxcli/testrunner/runner_cleanup_test.go b/cmd/mxcli/testrunner/runner_cleanup_test.go index 0c1f0fbf58..3117f52369 100644 --- a/cmd/mxcli/testrunner/runner_cleanup_test.go +++ b/cmd/mxcli/testrunner/runner_cleanup_test.go @@ -113,8 +113,8 @@ func TestNoSecurityLevelManipulation(t *testing.T) { all := append(setupCommands(mxTestRunner), setupCommands(endpointStartupFlow)...) all = append(all, cleanupCommands(projectState{}, true)...) all = append(all, cleanupCommands(projectState{afterStartup: "Mod.Flow", createdMxTest: true}, true)...) - all = append(all, endpointCleanupCommands(projectState{}, suite, true)...) - all = append(all, endpointCleanupCommands(projectState{afterStartup: "Mod.Flow", createdMxTest: true}, suite, true)...) + all = append(all, endpointCleanupCommands(projectState{}, suiteTestFlowNames(suite), true)...) + all = append(all, endpointCleanupCommands(projectState{afterStartup: "Mod.Flow", createdMxTest: true}, suiteTestFlowNames(suite), true)...) for _, cmd := range all { if strings.Contains(strings.ToUpper(cmd), "SECURITY LEVEL") { t.Errorf("the runner still alters the project Security Level: %q (#802)", cmd) diff --git a/cmd/mxcli/testrunner/runner_endpoint.go b/cmd/mxcli/testrunner/runner_endpoint.go index f0d03069a5..064f08feed 100644 --- a/cmd/mxcli/testrunner/runner_endpoint.go +++ b/cmd/mxcli/testrunner/runner_endpoint.go @@ -3,7 +3,6 @@ package testrunner import ( - "errors" "fmt" "io" "os" @@ -104,17 +103,7 @@ func runViaEndpoint(opts RunOptions, suite *TestSuite, token string, timeout tim // test's problem, not the run's. Reporting it as an ERROR row — and the // rest as SKIP — says which assertion broke, where the bare failure said // only that the project would not deploy (FINDINGS #46 follow-up). - var bf *docker.BuildFailedError - if errors.As(err, &bf) { - if results := resultsFromFailedBuild(bf.BuildErrors(), suite); results != nil { - return &SuiteResult{Name: suite.Name, Tests: results, Started: time.Now()}, nil - } - // Not the tests' doing: the model itself does not build. Say so - // rather than letting the reader assume a test is at fault. - _, other := attributeBuildProblems(bf.BuildErrors(), suite) - return nil, fmt.Errorf("%w%s", err, buildFailureHint(other)) - } - return nil, err + return resultsForBuildFailure(err, suite) } defer sess.stop() return runSuite(sess.client, sess.adminOptions(), suite, opts, w) @@ -221,7 +210,7 @@ func endpointReadyTimeout(suiteTimeout time.Duration) time.Duration { // the MxTest module, dropping the module removes all of it in one statement; // when the module was already the user's, each generated document is named // explicitly so nothing of theirs is touched. -func endpointCleanupCommands(st projectState, suite *TestSuite, mxTestPresent bool) []string { +func endpointCleanupCommands(st projectState, flows []string, mxTestPresent bool) []string { restore := "ALTER SETTINGS MODEL AfterStartupMicroflow = ''" if st.afterStartup != "" { restore = "ALTER SETTINGS MODEL AfterStartupMicroflow = " + quoteMDLString(st.afterStartup) @@ -233,8 +222,10 @@ func endpointCleanupCommands(st projectState, suite *TestSuite, mxTestPresent bo if st.createdMxTest { return append(cmds, "DROP MODULE "+mxTestModule) } - for _, tc := range suite.Tests { - cmds = append(cmds, "DROP MICROFLOW "+testFlowName(tc)) + // Every generated flow the project holds, not just this suite's — see + // cleanup_leftovers.go. + for _, name := range flows { + cmds = append(cmds, "DROP MICROFLOW "+name) } return append(cmds, "DROP MICROFLOW "+endpointStartupFlow, @@ -255,7 +246,7 @@ func cleanupEndpoint(projectPath string, st projectState, suite *TestSuite, w io if mxTestPresent && !st.createdMxTest { fmt.Fprintf(w, " %s module already existed; dropping only the generated documents\n", mxTestModule) } - return runMDLCommands(projectPath, endpointCleanupCommands(st, suite, mxTestPresent)) + return runMDLCommands(projectPath, endpointCleanupCommands(st, testFlowsToDrop(projectPath, suite), mxTestPresent)) } // removeGeneratedJavaSource deletes the .java file the Java action generated. From e133da5451188e8e9740d1eaa45a503bb86e0d39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 07:12:47 +0000 Subject: [PATCH 04/12] docs: record the .test.mdl, LIMIT 1 and test-cleanup findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md gains the `limit 1` idiom, because it is a cardinality change with identical source text on both sides and LLM-generated MDL walks into it; and the test-loop bullet now carries the three things a failed run must do. MDL_QUICK_REFERENCE and `mxcli syntax microflow.retrieve` spell out which LIMIT forms bind an object and which a list, and name the opposite meaning the same word has on `import from mapping` — the contradiction is what makes the mistake reasonable, so saying it is worth more than the rule alone. The test-microflows skill gains "check a test file before you run it", which is now possible and much faster than a run. Three findings records: two in cmd/mxcli (the format a tool owns being handed to another tool's parser; deriving what to clean up from what exists rather than from what you meant to create) and one in mdl/executor (a clause that changes a variable's cardinality needs a check keyed on exactly the writer's condition). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu --- .../skills/fix-issue/findings/cmd-mxcli.jsonl | 2 ++ .../fix-issue/findings/mdl-executor.jsonl | 1 + .../skills/mendix/test-microflows/SKILL.md | 29 +++++++++++++++++-- CHANGELOG.md | 12 ++++++++ CLAUDE.md | 5 ++-- cmd/mxcli/syntax/features_microflow.go | 16 ++++++++-- docs/01-project/MDL_QUICK_REFERENCE.md | 7 ++++- 7 files changed, 65 insertions(+), 7 deletions(-) diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index 9f453c80d2..b26492ffbb 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -109,3 +109,5 @@ {"area": "cmd/mxcli", "date": "2026-09-13", "symptom": "`build-and-test` fails in CI on `TestSettleSourceReturnsPromptlyForOneChange` \u2014 \"a quiet source took 196.975373ms to settle, want under 100ms\" \u2014 while the SAME tree passes in another run of the same workflow minutes earlier", "cause": "The test bounded elapsed wall-clock time as a multiple of the poll interval (`poll * (sourceSettleWindow + 3)`, 100ms against a nominal 40ms). settleSource waits on `time.After(poll)`, which guarantees AT LEAST the duration and nothing about the upper bound, so a loaded runner blows the budget with no defect present.", "file": "cmd/mxcli/docker/runlocal.go (settleSourceWith, the injected tick), cmd/mxcli/docker/runlocal_settle_test.go", "insight": "The property being guarded was a POLL COUNT, not a duration \u2014 'a quiet source costs one extra poll' \u2014 so the fix is to make polls countable (inject the timer) rather than to widen the budget, which only moves the flake threshold. Diagnosis shortcut worth reusing: the same workflow ran twice on the same tree, once from the push event and once from the pull_request merge commit, and disagreed \u2014 two runs of one tree is direct evidence of nondeterminism and cheaper than reading the test. Two things the controls settled that reasoning did not: (1) the assertions are written in terms of `sourceSettleWindow`, so WIDENING that constant leaves both tests green \u2014 they assert the loop honours whatever window is declared, never the number itself, and the real control is a loop that costs one poll MORE than it declares (both fail). (2) Each tick call must return a freshly-armed channel; returning one shared channel makes the multi-file test HANG rather than miscount, so the re-arm is load-bearing and not a style choice. The seam also made a previously untestable guarantee expressible: the window must be sourceSettleWindow CONSECUTIVE quiet polls, and dropping `quiet = 0` from the change branch was green against every pre-existing test in the file.", "refs": ["ako/mxcli#449"]} {"area":"cmd/mxcli","date":"2026-09-15","symptom":"Porting cmd/mxcli/docker off sdk/mpr moved two WRITE paths (ensureDemoUsers, applyHarvest) onto the codec backend. A baseline diff of `docker check` showed the project byte-identical across 421 files — which proved nothing, because the run had not written anything.","cause":"docker check's widget-update harvest is a no-op on an already-clean fixture, so an output+filetree diff against a pre-port binary exercises only the READ paths. Coverage then showed ensureDemoUsers at 0.0% — a write path the port touched that no test in the package ran.","file":"cmd/mxcli/docker/build.go","fix":"Added TestEnsureDemoUsers_CreatesAdminWhenNoneExist and _SkipsWhenUsersExist, plus a clearDemoUsers helper that establishes the precondition. Coverage 0.0% -> 76.5%. The read paths keep the baseline-diff evidence; applyHarvest was already at 76.9% via TestRunUpdateWidgets_RestoresV2AfterConversion.","insight":"A byte-identical baseline diff is strong evidence for a READ port and near-worthless for a WRITE port, because the natural control (nothing changed) is also what a no-op produces. The two need different instruments, and the cheap way to tell which you have is `go test -coverprofile` + `go tool cover -func` grepped for the functions you touched: it answers 'did my port's code even run' in one command, where a passing suite does not. Here it separated applyHarvest (76.9%, genuinely exercised including its UpdateRawUnit) from ensureDemoUsers (0.0%) inside the same package, so the gap was specific rather than a general absence of tests. Second trap, hit while fixing it: the shared v2 fixture ALREADY HAS two demo users, so the create-path test skipped and the idempotence test asserted the wrong count. Skipping on an unmet precondition is the #808 shape — set the precondition up instead (RemoveDemoUser in a helper, then assert the helper actually emptied it before proceeding). Third: read back through a FRESH connection, since asserting on the value the writer still holds passes against a write that never reached disk."} {"area":"cmd/mxcli","date":"2026-09-15","symptom":"Porting the last cmd/mxcli readers off sdk/mpr, cmd_extract_templates.go compiled with a type error (RawType/RawObject are bson.D on sdk/mpr, any on types.RawCustomWidgetType). Casting past it would have compiled — and broken the command at runtime, because FindCustomWidgetType is UNIMPLEMENTED on the codec backend.","cause":"mdl/backend/modelsdk/unimplemented_gen.go carries FindCustomWidgetType; measured at runtime it returns 'FindCustomWidgetType is not implemented on the model engine. This should be unreachable'. cmd_extract_templates.go was calling it through a concrete *mpr.Reader, so it was reachable only by NOT going through the backend.","file":"cmd/mxcli/cmd_extract_templates.go","fix":"Left this one file on sdk/mpr with a comment saying why and what would fix it (implement FindCustomWidgetType on the codec backend), and ported the other five. cmd/mxcli is otherwise clean; importers 13 -> 8.","insight":"The type error was the lucky part. A compile error is the ONLY reason this did not ship as a runtime failure — the cast that silences it is one line, and nothing else would have objected. When a port hits a type mismatch at a backend boundary, check whether the backend method is implemented at all before reconciling the types: `grep -n '' mdl/backend/modelsdk/unimplemented_gen.go` answers it in one command, and a runtime probe (connect read-only, call it, log the error) confirms it in under a minute. Note the direction of the trap: the unimplemented method's own error says 'This should be unreachable', and porting a caller to the backend is precisely what MAKES it reachable — so the #477 census blind spot (callers holding a concrete reader are invisible) cuts both ways. Second, smaller measurement trap in the same slice: a baseline diff of `check --post-migration` showed 50 lines vanishing, which looked like a regression and was not — the FIRST run built and cached a catalog inside the project, so the second run reused it. Two binaries must each get their own fresh copy of the fixture, exactly as for a write port; a command that caches into the project directory makes consecutive runs non-independent even when nothing is being written on purpose."} +{"area": "cmd/mxcli", "date": "2026-09-16", "symptom": "mendixlabs/mxcli#1103: a RETRIEVE with LIMIT inside a .test.mdl block was reported as `mismatched input 'LIMIT' expecting {GROUP_BY, SELECT, HAVING}` — the OQL follow set — on the statement `mxcli syntax microflow.retrieve` prints as its own example. The reporter concluded the test-block path routes microflow statements into the OQL parser.", "cause": "It does not. The generated MxTest.Test_* microflow parses fine (measured end-to-end against a real 11.6.6 project: the flow was created with the LIMIT intact). The message came from `mxcli check`/the LSP being pointed at the .test.mdl file itself, which they parsed as top-level MDL. A test block is a MICROFLOW BODY: DECLARE is not a top-level statement, the parser resyncs, RETRIEVE is a NON-RESERVED keyword so it is swallowed as an identifier, and the leftover `FROM …` starts oqlQueryTerm's FROM-first alternative (mdl/grammar/domains/MDLCatalog.g4), whose follow set is exactly {GROUP_BY, SELECT, HAVING}.", "file": "cmd/mxcli/testrunner/check_source.go", "fix": "testrunner.CheckSource renders each block as the microflow it becomes, padded so every body keeps its SOURCE line numbers (wrapper fragments go on the lines the doc comment and the '/' separator occupied). cmd_check.go and lsp_diagnostics.go translate before parsing, so all downstream rules apply unchanged and no diagnostic needs remapping. .test.mdl files joined `make check-mdl`; `.fail.test.mdl` names one whose annotations are deliberately unusable.", "insight": "Two lessons. First: the reporter's diagnosis was precise, confident and wrong, and the fastest way to find that out was to run the pipeline rather than read it — dumping GenerateTestFlows' output and feeding it to visitor.Build took one throwaway test and settled in seconds what an hour of grepping had not. Their error message was real; the command that produced it was not the one they named. Second, the general shape: a tool that OWNS a file format must not hand that format to a parser for a different one. The VS Code extension binds MDL to `.mdl`, which `.test.mdl` matches, so every test file in the editor was a wall of squiggles — 9 of this repo's 10 test files reported errors, one of them 392, and nobody had noticed because nobody runs `mxcli check` on a test file. When adding a derived file format, check what the EXISTING tooling makes of it; the answer is rarely 'nothing'. Line-preserving padding is what makes the translation honest: render into a slice of the source's own length and place wrapper fragments only on lines the original spent on comments or separators, and a diagnostic's line:col is the author's without a mapping table to drift."} +{"area": "cmd/mxcli", "date": "2026-09-16", "symptom": "mendixlabs/mxcli#1104: `mxcli test --attach` reported only 'build failed: The project cannot be deployed, because it contains errors.' on an injection failure, and afterwards EVERY later run of ANY test file failed the same way until a leftover document was found by hand.", "cause": "Two independent defects. (1) The parsed problems were in hand and discarded: runner_attach.go and LocalApp.Rebuild both built their error with `fmt.Errorf(\"build failed: %s\", build.Message)`, and Message is identical for every failing build. Attribution (build_attribution.go, BuildResult.ErrorSummary) existed but was wired only into the --local BOOT, so --attach and every --watch rebuild lost it. (2) Generated names are positional — MxTest.Test_test_1, _2, … from the test's index in its file — and every test file reuses them, while cleanup dropped only the CURRENT suite's names. A run with fewer tests than the last one therefore left the surplus behind, and under --attach the MxTest module always pre-exists (the dev loop installed it) so the whole-module drop never fires.", "file": "cmd/mxcli/testrunner/cleanup_leftovers.go", "fix": "buildFailure()/resultsForBuildFailure() shared by both runners; cleanup keys on what the project HOLDS (SHOW MICROFLOWS IN MxTest, filtered by the generated prefix) with the suite only as a fallback; reportCleanup names every surviving document and prints its DROP.", "insight": "Measured, not reasoned: planted one bad MxTest.Test_test_2 in a project, ran a known-good one-test suite, and watched it fail and leave the leftover in place — then after the fix watched run 1 fail and CLEAN, and run 2 pass. A self-healing sequence is the control that 'cleanup works' cannot be argued into. The general rule for generated artefacts: derive what to remove from what EXISTS, never from what you intended to create. Keying on the suite was wrong in both directions at once — it missed leftovers AND issued DROPs for flows a part-way injection never created, and those failures made cleanup report 'the project has been left modified' for a project it had just cleaned, which is a false alarm that sends the reader hunting for damage. Positional names (index-in-file) guarantee collisions across files and are worth avoiding, but as long as they exist the prefix is the only safe key. Also worth pinning: once BuildFailedError.Error() renders the errors, a hint that repeats them prints everything twice — assert on the message the READER sees, not on the hint in isolation."} diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 64b2543910..4bfe200a91 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -627,3 +627,4 @@ {"area":"mdl/executor","date":"2026-09-15","symptom":"Two check-time validators (validate_import_mapping_find.go, validate_offline_paths.go) imported sdk/mpr directly — which CLAUDE.md's backend-abstraction checklist forbids for the executor. After porting them to the backend, coverage of every ported function was 0.0%: offlineProfilesIn, projectEntityFacts and the new openProjectForValidation were never executed by the suite.","cause":"Both validators FAIL OPEN by design: an unreadable project returns nil and silences the rule rather than failing the check on something it could not inspect. The existing tests only exercised the pure helpers (offlinePathViolations) or passed an empty projectPath, so the reader half never ran.","file":"mdl/executor/validate_project_reader.go","fix":"Added openProjectForValidation as the package's one read-only connect, and tests that exercise all three against a real fixture. Coverage 0.0% -> 72%/78%/100%.","insight":"A FAIL-OPEN validator is the worst shape for an unverified port: break its reader and the rule stops firing, which looks exactly like a project the rule does not apply to. Nothing goes red. So for any fail-open code path, the test must assert the rule FIRES on a project that should trigger it — asserting it stays quiet proves nothing, since quiet is also the failure mode. Two setup details that decide whether such a test is real. (1) The fixture ships only an ONLINE navigation profile, so offlineProfilesIn returns empty on it either way; the test has to SEED an offline profile, and Mendix fixes the legal names (Responsive/Phone/Tablet + the *Offline variants) — an invented name is refused by the executor, which is how the first attempt failed. (2) Assert the control first: the stock fixture reports zero offline profiles, so a reader that invented one is caught before the positive assertion runs. Also worth noting the signature constraint that shaped the fix: ValidateProgram takes a project PATH, not a backend, because `check --references` validates a script against a project it never connects an executor to — so these open their own short-lived read-only connection rather than threading ctx.Backend through a public signature and every caller."} {"area":"mdl/executor","date":"2026-09-15","symptom":"TestRoundtripPage_MicroflowButtonWithCurrentObject failed on main and on every branch cut from it: 'Expected Target: $currentObject parameter mapping in describe output', while the printed output plainly contained the mapping as \"Target\": $currentObject. Unit tests were green; only the integration suite (-tags integration) caught it.","cause":"Not a describe regression at all. mdl/executor/identifier_quoting.go's mdlIdent quotes any identifier that does not LEX as a bare identifier, running the real ANTLR lexer. #476 (notify workflow ... TARGET) added `TARGET: T A R G E T;` to MDLLexer.g4, so the parameter named Target began lexing as a keyword token and DESCRIBE started quoting it. The output became MORE correct; the test's exact-substring assertion went stale.","file":"mdl/executor/roundtrip_page_test.go","fix":"Made the assertion quoting-agnostic (accepts Target: or \\\"Target\\\":). Controlled by renaming the expected parameter to a name that is absent, which still fails — so the assertion continues to detect a genuinely dropped mapping rather than passing on anything.","insight":"Adding a keyword to MDLLexer.g4 silently reformats DESCRIBE output for every existing element whose NAME matches that keyword, anywhere mdlIdent is used — the grammar change and the broken test are in different packages with no compile-time link, so nothing points from one to the other. When adding a token, grep the test tree for exact-substring assertions containing that word: here `grep -rn '\"Target: '` found the single collision in seconds, where reading the #476 diff never would have. The deeper rule is that an exact-substring assertion on DESCRIBE output encodes a quoting decision the test does not care about; assert the mapping quoting-agnostically, or re-parse the output, since what a roundtrip test means to check is that the mapping survived. Note the input side did NOT break: TARGET was added to the non-reserved-keyword rule, so scripts writing `Target:` unquoted still parse — which is why check-mdl's 544 scripts stayed green and only this one output assertion moved."} {"area": "mdl/executor", "date": "2026-09-15", "symptom": "check --references rejected a page that mxbuild builds at 0 errors: 'the constraint on Administration.Account names \"System.UserRoles\", which is neither an attribute nor an association of it'. Administration.Account extends System.User and System.UserRoles is declared from System.User, so it IS an association of it, by inheritance. Every inherited association was a false positive, and separately so was every cross-module one. Inherited ATTRIBUTES were fine throughout, which is what made it look like a narrow bug rather than a whole axis.", "cause": "associationTargetFrom matched the start entity against the association's two ends by exact equality and scanned only dm.Associations. Its doc comment said a specialisation deliberately returns false, 'the cost of being wrong is a false error on a working script' - sound while its only caller (resolveMemberOnEntity, typing an association retrieve) treated false as silence. The new XPath constraint checker's noteQualified then called the same helper and treated false as EVIDENCE, so the exact case the comment declined to chase became the finding. noteQualified did carry a three-valued guard, but it tested whether the BASE ENTITY was known - the wrong axis; Administration.Account is known.", "file": "mdl/executor/validate_member_refs.go", "fix": "Replaced the boolean with assocResolution (resolved / missing / notAnEnd / unknown). The start entity is matched through its generalization chain (generalizationChain, built on findEntityByQN), and a chain that could not be walked to its root yields assocUnknown = silence. associationEndsByName also reads dm.CrossAssociations, whose far end is held by NAME not by element ID. noteQualified reports only assocMissing and assocNotAnEnd.", "insight": "A helper whose contract is 'false when it cannot establish the answer' is safe only while every caller treats false as silence; the moment one caller treats it as evidence, the comment promising restraint becomes the specification of a false positive. A boolean cannot carry 'no' and 'don't know' to two callers that need to tell them apart - if the codebase already has a three-valued resolver next door (memberResolution, ten lines up), reusing the two-valued sibling is the smell. Two cheap guards would have caught it: a fixture entity with a GENERALIZATION carrying an association, and one CROSS-MODULE association - a fixture of flat single-module entities cannot distinguish a correct resolver from one comparing two names. Establish the verdict with the build, not by reading: mx check on the rejected page said 0 errors, which settles it in one run."} +{"area": "mdl/executor", "date": "2026-09-16", "symptom": "mendixlabs/mxcli#1103's real defect: `retrieve $reqs from Mod.E where … limit 1;` followed by `head($reqs)` passed `mxcli check --references` and failed the build with CE0097 'The selected reqs variable must be of type List'. Inside a .test.mdl file it was worse — the injected test just failed to build, with no error text at all on the --attach path.", "cause": "cmd_microflows_builder_actions.go maps `limit \"1\"` with no offset to microflows.RangeTypeFirst — Mendix's 'First object' range — so the output variable is an OBJECT, not a one-element list. That is deliberate and documented (MDL_QUICK_REFERENCE), but nothing between the author and mxbuild said so: describe re-emits `limit 1`, so an object retrieve and a list retrieve are byte-identical MDL.", "file": "mdl/executor/validate_microflow_retrieve_single.go", "fix": "MDL-RETRIEVE01: track variables bound by a limit-1-no-offset retrieve in statement order (a rebinding clears them) and flag a later list use — list operation, aggregate, loop, ADD/REMOVE. Keyed on exactly the writer's condition. The message names CE0097 and both working spellings.", "insight": "A silent type change is the expensive kind, and this one had every property that makes it hard: the source text is identical on both sides, DESCRIBE round-trips it unchanged, and the only signal is a CE code from a tool at the far end of a build. When a clause changes a variable's CARDINALITY rather than its value, the check that catches it has to key on exactly the same condition as the writer — `limit == \"1\" && offset == \"\"` here, copied from the builder — or the diagnostic and the model disagree, which is worse than neither. The confusion is also structural, not carelessness: the SAME word means the opposite elsewhere in MDL, since `import from mapping … first` binds an object and `… limit 1` a one-element list. Where a language contradicts itself, the message must name the working spelling rather than only refuse. Cheap control worth copying: run the whole mdl-examples corpus (`make check-mdl`, 558 scripts) after adding a rule — zero new failures is a real statement about false positives that unit tests cannot make."} diff --git a/.claude/skills/mendix/test-microflows/SKILL.md b/.claude/skills/mendix/test-microflows/SKILL.md index cf55472aef..75d0b5c33d 100644 --- a/.claude/skills/mendix/test-microflows/SKILL.md +++ b/.claude/skills/mendix/test-microflows/SKILL.md @@ -381,8 +381,33 @@ when deployed anywhere else. The project's **Security Level is not modified**. The after-startup microflow runs in an administrative context and is not subject to it, and forcing it off breaks projects whose published REST/OData services use custom authentication. If a -cleanup step fails the run reports an error and names what was left changed — -the project is modified, so it must not read as a clean pass. +cleanup step fails the run reports an error, **names every generated document +still in the project and prints the `DROP` that removes it** — the project is +modified, so it must not read as a clean pass. + +Cleanup removes **every** generated `MxTest.Test_*` microflow the project holds, +not only the ones this run created. The names are positional (`Test_test_1`, +`_2`, … from the test's index in its file) and every test file reuses them, so +keying cleanup on the current suite left a flow behind whenever a later run had +fewer tests than an earlier one — and a leftover that does not build fails +**every subsequent run of every test file**, with a message about the project +rather than about any test (mendixlabs/mxcli#1104). + +## Check a test file before you run it + +`mxcli check suite.test.mdl` works, and is much faster than a run. A test block +is a **microflow body**, and `check` renders it as the microflow it becomes, on +the file's own lines — so a diagnostic points at the statement you wrote. + +That includes the semantic rules, which is where most of the value is: a test +whose body would not compile is reported here instead of failing the injection +with nothing but "the project cannot be deployed". An `@expect` or `@verify` that +cannot be evaluated is reported here too, as `MDL-TEST01`. + +One rule to know about, because its symptom is confusing and its shape is common +in tests: `retrieve $x … limit 1` binds a **single object**, not a one-element +list, so `head($x)` is `CE0097` at build time and `MDL-RETRIEVE01` at check time. +Drop the `limit` to get a list, or use the variable as the object it is. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index c6110181a6..55189900cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`mxcli check` and the editor could not parse a `.test.mdl` file at all** (mendixlabs/mxcli#1103). A test block is a **microflow body** — that is what the runner turns it into — and both were handing the file to the top-level grammar instead. `DECLARE` is not a top-level statement, so the parser resynced; `RETRIEVE` is a non-reserved keyword, so it was swallowed as an identifier; and the leftover `FROM …` started an OQL query, whose follow set is `{GROUP_BY, SELECT, HAVING}`. The reported error therefore told the author their `RETRIEVE` needed a `SELECT`, on a statement `mxcli syntax microflow.retrieve` prints as its own example. + + This was not a corner: the VS Code extension binds MDL to `.mdl`, which `.test.mdl` matches, so every test file open in the editor was a wall of squiggles — 9 of this repository's 10 test files reported errors, one of them 392, now all 0. Each block is rendered as the microflow it becomes, padded so it keeps its **source line numbers**, which is what lets every existing rule apply with no remapping: `mxcli check suite.test.mdl` now reports an uncompilable body, an unusable `@expect` or `@verify` (`MDL-TEST01`), and everything else, at the line the author wrote it on. `make check-mdl` sweeps test files too, with `.fail.test.mdl` for one whose annotations are deliberately unusable. + +- **`retrieve … limit 1` silently binds a single object, and nothing said so until the build** (mendixlabs/mxcli#1103). It is Mendix's "First object" range, so `head()`, `count()` or a `loop` over that variable is **CE0097** — but `check --references` passed, and `describe` re-emits `limit 1`, so an object retrieve and a list retrieve are identical MDL text. **MDL-RETRIEVE01** reports it at check time, naming the CE code and both working spellings. The behaviour itself is unchanged and still documented; only the silence is fixed. (The same word means the opposite on `import from mapping`, where `first` binds the object and `limit 1` a one-element list — which is why reading it as a list is a reasonable mistake.) + +- **`mxcli test` reported a rejected build as one sentence, and one leftover document then failed every later run of any test file** (mendixlabs/mxcli#1104). + + MxBuild puts the same text in `Message` for every failing build, so "build failed: The project cannot be deployed, because it contains errors." cannot distinguish "your test does not compile" from "an unrelated document is broken". The parsed problems were in hand and discarded one line before they were needed: `--attach` and every `--watch` rebuild built their error with `fmt.Errorf`, while the attribution that turns a build error into the failing test's row was wired only into the `--local` boot. Both paths now carry the problems, so a build error in a generated test microflow becomes that test's ERROR row and one in the project names its document. + + Cleanup now removes **every** generated `MxTest.Test_*` microflow the project holds rather than the current suite's. The names are positional (`Test_test_1`, `_2`, … from the test's index in its file) and every test file reuses them, so a run with fewer tests than the last one left the surplus behind — and under `--attach` the `MxTest` module always pre-exists, so the whole-module drop never fired. A single leftover that does not build then failed every subsequent run of every file, reporting a problem in the project rather than in any test. Keying on the suite was also wrong the other way: it issued `DROP` for flows a part-way injection never created, and those failures made cleanup announce "the project has been left modified" for a project it had just cleaned. What cleanup genuinely cannot remove is now named, with the `DROP` that removes it. + - **`check --references` rejected every XPath constraint that hops an INHERITED or a CROSS-MODULE association** (ako/mxcli-sudoku FINDINGS #57). The constraint-member check added in `3aa2ee0e` reported the stock `Administration.Account_Overview` page — `Administration.Account extends System.User`, so `System.UserRoles` (declared from `System.User`) is an association of it by inheritance, and `mx check` on the rejected page says 0 errors. Because the false positive lands on a Marketplace module almost every app has, `check` stopped being usable as a gate for anyone whose entities inherit, which is the normal case for anything extending `System.User`, `System.Image` or `System.FileDocument`. The lookup matched the start entity against the association's two ends by exact equality and read only `dm.Associations`. Its comment said the specialisation case was deliberately not chased — "the cost of being wrong is a false error on a working script" — and that was sound while its only caller treated `false` as *silence*; the new check treated the same `false` as *evidence*, so the precise case the comment declined to chase became the finding. It is three-valued now (resolved / missing / not-an-end / unknown): the start entity is matched through its generalization chain, a cross-module association is found where it is actually stored (`CrossAssociations`, far end held by name), and a chain that could not be walked to its root is silence rather than a report. The check still fires on an association the entity genuinely lacks, including a specialisation's association named on its generalization. diff --git a/CLAUDE.md b/CLAUDE.md index beb58ae946..5f7d98d22a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -809,7 +809,8 @@ These rules apply whenever generating microflow or nanoflow MDL. Violations are 1. **NEVER create empty list variables as loop sources.** If processing imported data, accept the list as a microflow parameter — `declare $Items list of ... = empty` followed by `loop $item in $Items` is always wrong. 2. **NEVER use nested LOOPs for list matching.** Loop over the primary list and use `$match = FIND($TargetList, key = $item/key)` for an O(N) in-memory lookup. A plain `retrieve … where` **cannot** filter a list variable (only a database/association source), so `retrieve $match from $TargetList where …` is a parse error — use `FIND`/`FILTER`. Nested loops are O(N^2). The `$item` there is the enclosing loop's iterator and stays valid — MDL-LISTOP01 flags a predicate variable that is *not in scope*, not the name. Inside the predicate itself, the item under test is `$currentObject` (a bare attribute name resolves to it). 3. **Use append logic when merging**, not overwrite: `$Existing/Field + '\n' + $New/Field` inside an `if $New/Field != empty` guard. -4. **Read `.claude/skills/patterns-data-processing.md`** for delta merge, batch processing, and list operation patterns. +4. **`retrieve … limit 1` binds a single OBJECT, not a one-element list** — it is Mendix's "First object" range, so `head()`, `count()` or a `loop` over that variable is **CE0097**. Drop the `limit` for a list; `limit 1 offset n` and every other `limit` ARE lists. Note the same word means the opposite on `import from mapping`, where `first` binds the object and `limit 1` a one-element list. `describe` re-emits `limit 1` either way, so the source of an object retrieve and a list retrieve are identical text and only MDL-RETRIEVE01 distinguishes them before a build (mendixlabs/mxcli#1103). +5. **Read `.claude/skills/patterns-data-processing.md`** for delta merge, batch processing, and list operation patterns. **Always validate before presenting to user:** ```bash @@ -844,7 +845,7 @@ Full syntax tables for all MDL statements (microflows, pages, security, navigati - LSP server with hover, go-to-definition, completion, diagnostics, symbols, folding - VS Code extension (`vscode-mdl`) with context menu commands (Run/Check/Selection) - Docker build integration (`mxcli docker build`) with PAD patching (Phase 1) -- Warm test loop (`mxcli test --local [--watch]`, `--attach`, `run --local --test-endpoint`): local test runs go through a **token-guarded HTTP endpoint** registered by a generated Java custom request handler, instead of compiling the suite into the project's after-startup microflow. Boot registers the endpoint and then **chains the project's own after-startup microflow**, so tests see the app as it really boots (`--skip-app-startup` opts out) — without that, a suite depending on startup state passed under `--attach` and failed under `--local`. One microflow per test, resolved by name at request time from `Core.getMicroflowNames()` and invoked with `Core.microflowCall(...).execute(...)` — so a throwing test fails only itself (not the boot), results are returned rather than scraped from the runtime log, and each test has its own variable scope. Owning the `IContext` is also what finally makes **`@cleanup rollback`** (the annotation's documented default, previously parsed and ignored) real: the handler wraps the call in `startTransaction()`/`rollbackTransaction()`, so a test's writes do not survive it — verified against Postgres, with `@cleanup none` as the in-run control. A rollback that fails is reported per test and summarised, never silent; an unknown strategy is a parse error. The handler **survives `reload_model`** (after-startup does not re-run, the JVM is unchanged), which is what makes `--watch` possible: ~30s first run, then ~2s from an edit — to a test *or* to the microflow under test — to a verdict. `--attach` skips even that boot by running against an app already up under `run --local --test-endpoint`, driving that process's serve + admin APIs over loopback; it uses **that app's database**, only ever adds/removes its own test microflows, and refuses a change needing a restart. Security: the handler is **not registered at all** without `MXCLI_TEST_TOKEN` in the runtime env (so a project that kept the `MxTest` module through a failed cleanup is inert in production), the token is constant-time compared, non-loopback callers are refused, `/list` is clamped to the test namespace, and only `MxTest.Test_*` may be invoked. The token reaches the runtime via its environment and is never written into the project. Docker keeps the after-startup runner (`--legacy-runner` selects it locally). Packages: `cmd/mxcli/testrunner/` (`endpoint.go`, `client.go`, `watch.go`, `host.go`, `handshake.go`). See `docs/15-testing/SPIKE_test_endpoint_request_handler.md` +- Warm test loop (`mxcli test --local [--watch]`, `--attach`, `run --local --test-endpoint`): local test runs go through a **token-guarded HTTP endpoint** registered by a generated Java custom request handler, instead of compiling the suite into the project's after-startup microflow. Boot registers the endpoint and then **chains the project's own after-startup microflow**, so tests see the app as it really boots (`--skip-app-startup` opts out) — without that, a suite depending on startup state passed under `--attach` and failed under `--local`. One microflow per test, resolved by name at request time from `Core.getMicroflowNames()` and invoked with `Core.microflowCall(...).execute(...)` — so a throwing test fails only itself (not the boot), results are returned rather than scraped from the runtime log, and each test has its own variable scope. Owning the `IContext` is also what finally makes **`@cleanup rollback`** (the annotation's documented default, previously parsed and ignored) real: the handler wraps the call in `startTransaction()`/`rollbackTransaction()`, so a test's writes do not survive it — verified against Postgres, with `@cleanup none` as the in-run control. A rollback that fails is reported per test and summarised, never silent; an unknown strategy is a parse error. The handler **survives `reload_model`** (after-startup does not re-run, the JVM is unchanged), which is what makes `--watch` possible: ~30s first run, then ~2s from an edit — to a test *or* to the microflow under test — to a verdict. `--attach` skips even that boot by running against an app already up under `run --local --test-endpoint`, driving that process's serve + admin APIs over loopback; it uses **that app's database**, only ever adds/removes its own test microflows, and refuses a change needing a restart. Security: the handler is **not registered at all** without `MXCLI_TEST_TOKEN` in the runtime env (so a project that kept the `MxTest` module through a failed cleanup is inert in production), the token is constant-time compared, non-loopback callers are refused, `/list` is clamped to the test namespace, and only `MxTest.Test_*` may be invoked. The token reaches the runtime via its environment and is never written into the project. A `.test.mdl` file is **checkable**: each block is a microflow body, so `mxcli check` (and the LSP, hence VS Code) renders the blocks as the microflows they become, on the file's own lines — before #1103 the top-level grammar was applied instead, and since `RETRIEVE` is a non-reserved keyword the leftover `FROM …` started an OQL query, so the reader was told their retrieve needed a SELECT; 9 of this repo's 10 test files reported errors that way, one of them 392. `make check-mdl` now sweeps them, with `.fail.test.mdl` for a file whose annotations are deliberately unusable. Two things a failed run must not do, both reported as #1104: **a rejected build is reported with MxBuild's own errors** — `BuildResult.ErrorSummary()` was in hand and discarded by `fmt.Errorf("build failed: %s", build.Message)` on the `--attach` and rebuild paths, and that sentence is identical for every failing build, so it could not tell "your test does not compile" from "an unrelated document is broken"; and **cleanup removes every generated `MxTest.Test_*` the project holds**, not just this suite's. The names are positional and every file reuses them, so keying cleanup on the suite left a flow behind whenever a later run had fewer tests than an earlier one — and one leftover that does not build fails every later run of every test file. What cleanup could not remove is named, with the `DROP` that removes it. Docker keeps the after-startup runner (`--legacy-runner` selects it locally). Packages: `cmd/mxcli/testrunner/` (`endpoint.go`, `client.go`, `watch.go`, `host.go`, `handshake.go`, `check_source.go`, `cleanup_leftovers.go`). See `docs/15-testing/SPIKE_test_endpoint_request_handler.md` - Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`): Docker-free `mxbuild --serve` + standalone runtime, hot `reload_model` for behavioural changes and restart+DDL for structural ones (chosen from the serve build's `restartRequired`). Bundles the browser client (`web/dist/` via mxbuild's rollup runner, which the serve Deploy target skips) so Mendix 11.x apps render in a browser. `--watch` keeps an incremental rollup bundler hot (CHOKIDAR_USEPOLLING for container fs; ~3-4s page re-bundle, skipped for model-only edits) and watches only model source (`.mpr`+`mprcontents/`). `--ensure-db` provisions the local Postgres + app database if missing; `--setup` does the non-blocking prerequisites (cache mxbuild+runtime, ensure DB) and exits — `mxcli init` wires it into a Claude Code SessionStart hook so a fresh/reaped web session self-bootstraps, and `docs-site/src/tools/bootstrap-prompt.md` is the empty-repo seed prompt. `--screenshot` captures a Playwright PNG each change (pixel-perfect page loop), with `--screenshot-url` deep links (repeatable for multi-page sets, one PNG per page) and `--screenshot-user`/`--screenshot-password` form login (session saved as Playwright storage state, reused via `screenshot --load-storage`). See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` - External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`): the app stays local and reverse-tunnels out over a single 443 connection (embedded chisel) to a static relay, so it is reachable in a browser at a public URL — works from egress-only environments (Claude Code web), verified live through the session's MITM egress proxy. `run --hub` implies `--local`, boots the runtime with `ApplicationRootUrl` set to the assigned URL (so the SPA/`originURI` work under the public origin), resolves the control proxy honouring `NO_PROXY`, and retries forever. `mxcli tunnel-hub --domain ` is the **multi-tenant** relay: a registry keyed by prefix/project/solution/branch/worktree (stable URLs on reconnect) fronts many previews at per-subdomain hosts (`[prefix-]project[-branch].`; main collapses to the project) over one 443 with per-subdomain autocert, a registration API (`/api/register|status|deregister|backends|sessions`), and an availability overview at `hub./` **grouped by Claude Code session** (`/api/sessions`): each session lists the endpoints it exposed and links back to its `claude.ai/code` conversation. Client identity flags: `--hub-prefix`/`--hub-project`/`--hub-solution`/`--hub-branch`/`--hub-worktree` (project + branch auto-detected); `--hub-session` groups a session's endpoints (auto-detected from `CLAUDE_CODE_REMOTE_SESSION_ID`). Past sessions are retained: a durable per-session endpoint history (`--sessions-file`, default `~/.mxcli/hub-sessions.json`) survives restarts and reaping, and is pruned after `--session-retention` (default 30d) — so the overview shows offline sessions too (`SessionLog` in `cmd/mxcli/tunnelhub/sessions.go`). Package: `cmd/mxcli/tunnelhub/`. See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` (slices 3–4) - Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub): **viewer plane** — GitHub OAuth web flow + HMAC-signed SSO session cookie (`Domain=.`), owner-checked previews (`--require-auth` default on → 302 to login / 403 non-owner; soft mode filters the listing only), `/api/backends` filtered to the viewer (unauthenticated → 401), admin "signed in as" via `/api/whoami`. **Registration plane** — durable, hashed hub API keys (`--keys-file`, default `~/.mxcli/hub-keys.json`, survive restarts) presented as `X-Hub-Key` → stamps `Backend.Owner`; shared `X-Hub-Secret` still works as an owner-less fallback. **Key issuance** — the hub's `/cli` browser page mints a key from the session cookie (no PAT; the device flow was removed as Claude Code containers block GitHub's device endpoints), rotate-by-default + count + revoke-all; `mxcli auth hub login --token ` is the headless path; `run --hub` reads `MXCLI_HUB_KEY` (env → `~/.mxcli/auth.json`) and degrades to local-only if registration fails. Append-only JSONL audit trail (`--audit-log`, no secrets). Packages: `cmd/mxcli/tunnelhub/` (+`audit/`), `cmd/mxcli/hubauth/`. See `docs/11-proposals/PROPOSAL_hub_authentication.md` diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index 6b46e15ad0..a745ead830 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -66,8 +66,20 @@ func init() { }, // Retrieve-by-association was missing here, so it read as unsupported // even though it works and the write-microflows skill documents it. - Syntax: "-- From the database\nRETRIEVE $Var FROM Module.Entity\n [WHERE condition]\n [SORT BY attr ASC|DESC]\n [LIMIT n] [OFFSET n];\n\n-- Over an association, from an object you already have\nRETRIEVE $Var FROM $Object/Module.Association;", - Example: "RETRIEVE $Customer FROM MyModule.Customer\n WHERE Code = $CustomerCode\n LIMIT 1;\n\nRETRIEVE $Orders FROM MyModule.Order\n WHERE Status = 'Pending'\n SORT BY CreateDate DESC\n LIMIT 10 OFFSET 0;\n\n-- Follow an association rather than querying the database\nRETRIEVE $Orders FROM $Customer/MyModule.Order_Customer;\nRETRIEVE $Customer FROM $Order/MyModule.Order_Customer;", + Syntax: "-- From the database\nRETRIEVE $Var FROM Module.Entity\n [WHERE condition]\n [SORT BY attr ASC|DESC]\n [LIMIT n] [OFFSET n];\n\n-- Over an association, from an object you already have\nRETRIEVE $Var FROM $Object/Module.Association;", + Example: "-- LIMIT 1 binds a single OBJECT (Mendix's \"First object\" range), not a\n" + + "-- one-element list — hence the singular variable name here.\n" + + "RETRIEVE $Customer FROM MyModule.Customer\n WHERE Code = $CustomerCode\n LIMIT 1;\n\n" + + "-- Any other LIMIT is a bounded range, which is a list.\n" + + "RETRIEVE $Orders FROM MyModule.Order\n WHERE Status = 'Pending'\n SORT BY CreateDate DESC\n LIMIT 10 OFFSET 0;\n\n" + + "-- Follow an association rather than querying the database\nRETRIEVE $Orders FROM $Customer/MyModule.Order_Customer;\nRETRIEVE $Customer FROM $Order/MyModule.Order_Customer;\n\n" + + "-- Notes:\n" + + "-- * LIMIT 1 with no OFFSET is the one form that binds an object. HEAD(),\n" + + "-- COUNT() or a LOOP over it is CE0097 at build time; mxcli reports it as\n" + + "-- MDL-RETRIEVE01 at check time.\n" + + "-- * LIMIT 1 OFFSET n is a bounded range, so that one IS a list.\n" + + "-- * `import from mapping … limit 1` means the opposite — a one-element\n" + + "-- list — and `… first` is its object form.", SeeAlso: []string{"microflow.object-operations", "xpath"}, }) diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 0cd17016cd..7fee3b4567 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -597,7 +597,12 @@ and `mxbuild` were all clean. Only the running app showed it. | `TRY ... CATCH ... end TRY` | `on error { ... }` blocks | Use error handlers on specific activities | **Notes:** -- `retrieve ... limit n` IS supported. `limit 1` returns a single entity, otherwise returns a list. +- `retrieve ... limit n` IS supported. **`limit 1` with no `offset` binds a single OBJECT**, not a + one-element list: it is Mendix's "First object" range. Every other `limit` (including + `limit 1 offset n`) is a bounded range, which is a list. Using a `limit 1` variable as a list — + `head()`, `count()`, a `loop` — is **CE0097** at build time and **MDL-RETRIEVE01** at check time. + Note this is the opposite of the import-mapping clause above, where `first` binds an object and + `limit 1` a one-element list. - `rollback $entity [refresh];` IS supported. Rolls back uncommitted changes to an object. ## Project Organization From 7b7a5487043a621cabbab61e7b41e831594475ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 11:37:13 +0000 Subject: [PATCH 05/12] =?UTF-8?q?Delete=20sdk/mpr=20=E2=80=94=20the=20lega?= =?UTF-8?q?cy=20engine=20is=20gone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 163 files, 41,674 lines. This finishes docs/plans/2026-09-14-retire-legacy-engine.md. Zero importers was not the same as safe to rm -rf. Two live dependencies survived, and neither is visible to a check written against the parent package's import path. sdk/mpr/version had six importers, two of them shipping code (cmd/mxcli/docker/build.go and patch.go) — a subpackage is a different import path. And cmd/mxcli/docker/update_widgets_test.go read sdk/mpr/testdata/v1-project by filesystem path, an os.DirFS string rather than an import. So: before deleting a package, search for three things — its own import path, its subpackages' paths, and its directory as a literal string (testdata, go:embed, scripts). The last two are invisible to any importer census. The six importers went to mdl/types, not to modelsdk/mpr/version. sdk/mpr/version.ProjectVersion is `type ProjectVersion = types.ProjectVersion`, an alias, so types.ProjectVersion is the same type — while modelsdk/mpr/version declares a duplicate struct that would have been a different one. Read the declaration, not the name. The v1 fixture moved to modelsdk/mpr/testdata/. Everything was repointed and proven green with the package still present, which separates "the repoint was wrong" from "the deletion was wrong". Two measurements worth keeping. The shipped binary is identical in size before and after, so the linker had already dropped the package: this removes source weight, not runtime behaviour. And sdk/widgets fell to zero importers as a side effect but is deliberately kept — modelsdk/widgets/dirty_template_test.go reads sdk/widgets/templates/mendix-11.6 by path. Same trap, caught by grepping the directory name rather than the import. The import guard added in the previous slice is removed: with the package gone, an import is a compile error, strictly stronger than a test asserting it. Gates: build, vet (incl. -tags integration), go test ./..., check-mdl (560), check-findings, and the full repo-wide integration suite: exit 0, no failures. docker check and check --post-migration are byte-identical to the pre-deletion binary. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ --- .../fix-issue/findings/mdl-backend.jsonl | 1 + cmd/mxcli/docker/build.go | 4 +- cmd/mxcli/docker/build_integration_test.go | 4 +- cmd/mxcli/docker/build_test.go | 10 +- cmd/mxcli/docker/patch.go | 4 +- cmd/mxcli/docker/update_widgets_test.go | 2 +- docs/plans/2026-09-14-retire-legacy-engine.md | 32 + mdl/backend/sdkmpr_import_guard_test.go | 122 -- mdl/executor/doctype_version_gating_test.go | 4 +- mdl/executor/version_filter_test.go | 14 +- .../mpr/testdata/v1-project/App.mpr | Bin sdk/mpr/asyncapi.go | 18 - sdk/mpr/asyncapi_test.go | 160 -- sdk/mpr/bson_testutil_test.go | 37 - sdk/mpr/domainmodel_annotation_test.go | 116 -- sdk/mpr/download_file_test.go | 54 - sdk/mpr/edmx.go | 29 - sdk/mpr/edmx_test.go | 409 ---- sdk/mpr/get_raw_unit_v1_test.go | 52 - sdk/mpr/inheritance_roundtrip_test.go | 107 - ...avaactions_microflowactioninfo_656_test.go | 112 - sdk/mpr/jsonstructure_folder_lookup_test.go | 124 -- sdk/mpr/microflow_call_writer_test.go | 89 - sdk/mpr/microflow_parameter_position_test.go | 67 - sdk/mpr/parser.go | 253 --- sdk/mpr/parser_businessevents.go | 167 -- sdk/mpr/parser_customblob.go | 295 --- sdk/mpr/parser_datatransformer.go | 75 - sdk/mpr/parser_dbconnection.go | 136 -- sdk/mpr/parser_domainmodel.go | 831 -------- sdk/mpr/parser_domainmodel_test.go | 48 - sdk/mpr/parser_enumeration.go | 197 -- sdk/mpr/parser_export_mapping.go | 164 -- sdk/mpr/parser_import_mapping.go | 244 --- sdk/mpr/parser_import_mapping_test.go | 22 - sdk/mpr/parser_javaactions.go | 578 ------ sdk/mpr/parser_javaactions_test.go | 66 - sdk/mpr/parser_listoperation_test.go | 99 - sdk/mpr/parser_menu_signout_test.go | 56 - sdk/mpr/parser_microflow.go | 1246 ----------- sdk/mpr/parser_microflow_actions.go | 1073 ---------- ...rser_microflow_error_handling_1078_test.go | 96 - sdk/mpr/parser_microflow_import_range_test.go | 165 -- sdk/mpr/parser_microflow_test.go | 408 ---- sdk/mpr/parser_microflow_workflow.go | 171 -- sdk/mpr/parser_misc.go | 838 -------- sdk/mpr/parser_misc_test.go | 59 - sdk/mpr/parser_module.go | 55 - sdk/mpr/parser_nanoflow.go | 119 -- sdk/mpr/parser_odata.go | 364 ---- sdk/mpr/parser_page.go | 237 --- sdk/mpr/parser_queued_call_test.go | 54 - sdk/mpr/parser_range_test.go | 95 - sdk/mpr/parser_rest.go | 407 ---- sdk/mpr/parser_rule.go | 99 - sdk/mpr/parser_rule_test.go | 106 - sdk/mpr/parser_scheduledevent_test.go | 54 - sdk/mpr/parser_security.go | 170 -- sdk/mpr/parser_settings.go | 225 -- sdk/mpr/parser_settings_test.go | 89 - sdk/mpr/parser_unknown.go | 64 - sdk/mpr/parser_webservice_source_test.go | 53 - sdk/mpr/parser_workflow.go | 750 ------- sdk/mpr/placeholder_test.go | 57 - sdk/mpr/queues.go | 109 - sdk/mpr/reader.go | 269 --- sdk/mpr/reader_agenteditor.go | 122 -- sdk/mpr/reader_documents.go | 1105 ---------- sdk/mpr/reader_types.go | 451 ---- sdk/mpr/reader_units.go | 684 ------ sdk/mpr/reader_units_type_test.go | 103 - sdk/mpr/reader_widgets.go | 747 ------- sdk/mpr/reader_xmlschema_test.go | 114 - sdk/mpr/regularexpressions.go | 64 - sdk/mpr/roundtrip_test.go | 600 ------ sdk/mpr/scheduledevents.go | 52 - sdk/mpr/showpage_roundtrip_test.go | 261 --- sdk/mpr/system_java_actions.go | 23 - sdk/mpr/system_module.go | 454 ---- .../enumerations/PictureQuality.mxunit | Bin 1685 -> 0 bytes .../testdata/microflows/ChangePassword.mxunit | Bin 9696 -> 0 bytes .../testdata/pages/Account_Overview.mxunit | Bin 441141 -> 0 bytes .../testdata/pages/WidgetDemo_Showcase.mxunit | Bin 1219710 -> 0 bytes .../WorkflowBaseline.Sub_Workflow.bson | Bin 1170 -> 0 bytes .../workflows/WorkflowBaseline.Workflow.bson | Bin 13869 -> 0 bytes sdk/mpr/text_language_test.go | 65 - sdk/mpr/utils.go | 43 - sdk/mpr/version/version.go | 167 -- sdk/mpr/workflow_agent_test.go | 32 - sdk/mpr/workflow_endpath_serialize_test.go | 37 - sdk/mpr/workflow_handlers_test.go | 103 - sdk/mpr/workflow_parse_test.go | 388 ---- sdk/mpr/workflow_write_test.go | 404 ---- sdk/mpr/writer_agenteditor_agent.go | 184 -- sdk/mpr/writer_agenteditor_kb.go | 110 - sdk/mpr/writer_agenteditor_mcpservice.go | 89 - sdk/mpr/writer_agenteditor_model.go | 124 -- sdk/mpr/writer_businessevents.go | 205 -- sdk/mpr/writer_commit_rename_test.go | 160 -- sdk/mpr/writer_core.go | 321 --- sdk/mpr/writer_customblob.go | 130 -- sdk/mpr/writer_datatransformer.go | 120 -- sdk/mpr/writer_dbconnection.go | 213 -- sdk/mpr/writer_dbconnection_querytype_test.go | 122 -- sdk/mpr/writer_domainmodel.go | 1572 -------------- sdk/mpr/writer_domainmodel_test.go | 309 --- sdk/mpr/writer_elision_test.go | 206 -- sdk/mpr/writer_enumeration.go | 228 -- sdk/mpr/writer_export_mapping.go | 205 -- .../writer_export_mapping_properties_test.go | 124 -- sdk/mpr/writer_export_mapping_test.go | 201 -- .../writer_external_action_returntype_test.go | 74 - sdk/mpr/writer_formattinginfo_test.go | 39 - sdk/mpr/writer_id_order_test.go | 233 --- sdk/mpr/writer_imagecollection.go | 71 - sdk/mpr/writer_imagecollection_test.go | 81 - sdk/mpr/writer_import_mapping.go | 290 --- sdk/mpr/writer_import_mapping_test.go | 255 --- sdk/mpr/writer_javaactions.go | 433 ---- sdk/mpr/writer_javaactions_enum_680_test.go | 48 - sdk/mpr/writer_javascriptactions.go | 174 -- sdk/mpr/writer_javascriptactions_test.go | 93 - sdk/mpr/writer_jsonstructure.go | 100 - sdk/mpr/writer_listoperation_test.go | 174 -- sdk/mpr/writer_listview_source_test.go | 87 - sdk/mpr/writer_listview_template_test.go | 68 - sdk/mpr/writer_microflow.go | 864 -------- sdk/mpr/writer_microflow_action_items_test.go | 86 - sdk/mpr/writer_microflow_actions.go | 1841 ---------------- sdk/mpr/writer_microflow_flags_test.go | 45 - sdk/mpr/writer_microflow_version_test.go | 184 -- sdk/mpr/writer_microflow_workflow.go | 190 -- sdk/mpr/writer_modules.go | 347 ---- sdk/mpr/writer_navigation.go | 472 ----- sdk/mpr/writer_navigation_icon_test.go | 272 --- sdk/mpr/writer_navigation_notfound_test.go | 99 - sdk/mpr/writer_navigation_offline_test.go | 74 - sdk/mpr/writer_odata.go | 636 ------ sdk/mpr/writer_odata_test.go | 528 ----- sdk/mpr/writer_order.go | 23 - sdk/mpr/writer_pages.go | 355 ---- sdk/mpr/writer_pages_placeholder_test.go | 110 - sdk/mpr/writer_placement.go | 115 - sdk/mpr/writer_refs.go | 123 -- sdk/mpr/writer_rename.go | 240 --- sdk/mpr/writer_rest.go | 635 ------ sdk/mpr/writer_rest_httpresponse_test.go | 34 - sdk/mpr/writer_rest_inline_mapping_test.go | 175 -- sdk/mpr/writer_rest_test.go | 447 ---- sdk/mpr/writer_rule_split_test.go | 119 -- sdk/mpr/writer_security.go | 1843 ----------------- sdk/mpr/writer_security_inherited_test.go | 229 -- sdk/mpr/writer_security_reconcile_test.go | 230 -- sdk/mpr/writer_security_test.go | 384 ---- sdk/mpr/writer_settings.go | 116 -- sdk/mpr/writer_units.go | 293 --- sdk/mpr/writer_units_test.go | 154 -- sdk/mpr/writer_validationrule_test.go | 150 -- sdk/mpr/writer_webservice_body_test.go | 275 --- sdk/mpr/writer_widgets.go | 727 ------- sdk/mpr/writer_widgets_action.go | 275 --- sdk/mpr/writer_widgets_action_test.go | 286 --- sdk/mpr/writer_widgets_container_test.go | 89 - sdk/mpr/writer_widgets_custom.go | 472 ----- sdk/mpr/writer_widgets_display.go | 970 --------- sdk/mpr/writer_widgets_icon_test.go | 127 -- sdk/mpr/writer_widgets_image_test.go | 105 - sdk/mpr/writer_widgets_input.go | 183 -- sdk/mpr/writer_widgets_layout.go | 209 -- sdk/mpr/writer_widgets_linkbutton_test.go | 50 - sdk/mpr/writer_widgets_snippet_test.go | 110 - sdk/mpr/writer_widgets_test.go | 465 ----- sdk/mpr/writer_workflow.go | 876 -------- 173 files changed, 54 insertions(+), 41674 deletions(-) delete mode 100644 mdl/backend/sdkmpr_import_guard_test.go rename {sdk => modelsdk}/mpr/testdata/v1-project/App.mpr (100%) delete mode 100644 sdk/mpr/asyncapi.go delete mode 100644 sdk/mpr/asyncapi_test.go delete mode 100644 sdk/mpr/bson_testutil_test.go delete mode 100644 sdk/mpr/domainmodel_annotation_test.go delete mode 100644 sdk/mpr/download_file_test.go delete mode 100644 sdk/mpr/edmx.go delete mode 100644 sdk/mpr/edmx_test.go delete mode 100644 sdk/mpr/get_raw_unit_v1_test.go delete mode 100644 sdk/mpr/inheritance_roundtrip_test.go delete mode 100644 sdk/mpr/javaactions_microflowactioninfo_656_test.go delete mode 100644 sdk/mpr/jsonstructure_folder_lookup_test.go delete mode 100644 sdk/mpr/microflow_call_writer_test.go delete mode 100644 sdk/mpr/microflow_parameter_position_test.go delete mode 100644 sdk/mpr/parser.go delete mode 100644 sdk/mpr/parser_businessevents.go delete mode 100644 sdk/mpr/parser_customblob.go delete mode 100644 sdk/mpr/parser_datatransformer.go delete mode 100644 sdk/mpr/parser_dbconnection.go delete mode 100644 sdk/mpr/parser_domainmodel.go delete mode 100644 sdk/mpr/parser_domainmodel_test.go delete mode 100644 sdk/mpr/parser_enumeration.go delete mode 100644 sdk/mpr/parser_export_mapping.go delete mode 100644 sdk/mpr/parser_import_mapping.go delete mode 100644 sdk/mpr/parser_import_mapping_test.go delete mode 100644 sdk/mpr/parser_javaactions.go delete mode 100644 sdk/mpr/parser_javaactions_test.go delete mode 100644 sdk/mpr/parser_listoperation_test.go delete mode 100644 sdk/mpr/parser_menu_signout_test.go delete mode 100644 sdk/mpr/parser_microflow.go delete mode 100644 sdk/mpr/parser_microflow_actions.go delete mode 100644 sdk/mpr/parser_microflow_error_handling_1078_test.go delete mode 100644 sdk/mpr/parser_microflow_import_range_test.go delete mode 100644 sdk/mpr/parser_microflow_test.go delete mode 100644 sdk/mpr/parser_microflow_workflow.go delete mode 100644 sdk/mpr/parser_misc.go delete mode 100644 sdk/mpr/parser_misc_test.go delete mode 100644 sdk/mpr/parser_module.go delete mode 100644 sdk/mpr/parser_nanoflow.go delete mode 100644 sdk/mpr/parser_odata.go delete mode 100644 sdk/mpr/parser_page.go delete mode 100644 sdk/mpr/parser_queued_call_test.go delete mode 100644 sdk/mpr/parser_range_test.go delete mode 100644 sdk/mpr/parser_rest.go delete mode 100644 sdk/mpr/parser_rule.go delete mode 100644 sdk/mpr/parser_rule_test.go delete mode 100644 sdk/mpr/parser_scheduledevent_test.go delete mode 100644 sdk/mpr/parser_security.go delete mode 100644 sdk/mpr/parser_settings.go delete mode 100644 sdk/mpr/parser_settings_test.go delete mode 100644 sdk/mpr/parser_unknown.go delete mode 100644 sdk/mpr/parser_webservice_source_test.go delete mode 100644 sdk/mpr/parser_workflow.go delete mode 100644 sdk/mpr/placeholder_test.go delete mode 100644 sdk/mpr/queues.go delete mode 100644 sdk/mpr/reader.go delete mode 100644 sdk/mpr/reader_agenteditor.go delete mode 100644 sdk/mpr/reader_documents.go delete mode 100644 sdk/mpr/reader_types.go delete mode 100644 sdk/mpr/reader_units.go delete mode 100644 sdk/mpr/reader_units_type_test.go delete mode 100644 sdk/mpr/reader_widgets.go delete mode 100644 sdk/mpr/reader_xmlschema_test.go delete mode 100644 sdk/mpr/regularexpressions.go delete mode 100644 sdk/mpr/roundtrip_test.go delete mode 100644 sdk/mpr/scheduledevents.go delete mode 100644 sdk/mpr/showpage_roundtrip_test.go delete mode 100644 sdk/mpr/system_java_actions.go delete mode 100644 sdk/mpr/system_module.go delete mode 100644 sdk/mpr/testdata/enumerations/PictureQuality.mxunit delete mode 100644 sdk/mpr/testdata/microflows/ChangePassword.mxunit delete mode 100644 sdk/mpr/testdata/pages/Account_Overview.mxunit delete mode 100644 sdk/mpr/testdata/pages/WidgetDemo_Showcase.mxunit delete mode 100644 sdk/mpr/testdata/workflows/WorkflowBaseline.Sub_Workflow.bson delete mode 100644 sdk/mpr/testdata/workflows/WorkflowBaseline.Workflow.bson delete mode 100644 sdk/mpr/text_language_test.go delete mode 100644 sdk/mpr/utils.go delete mode 100644 sdk/mpr/version/version.go delete mode 100644 sdk/mpr/workflow_agent_test.go delete mode 100644 sdk/mpr/workflow_endpath_serialize_test.go delete mode 100644 sdk/mpr/workflow_handlers_test.go delete mode 100644 sdk/mpr/workflow_parse_test.go delete mode 100644 sdk/mpr/workflow_write_test.go delete mode 100644 sdk/mpr/writer_agenteditor_agent.go delete mode 100644 sdk/mpr/writer_agenteditor_kb.go delete mode 100644 sdk/mpr/writer_agenteditor_mcpservice.go delete mode 100644 sdk/mpr/writer_agenteditor_model.go delete mode 100644 sdk/mpr/writer_businessevents.go delete mode 100644 sdk/mpr/writer_commit_rename_test.go delete mode 100644 sdk/mpr/writer_core.go delete mode 100644 sdk/mpr/writer_customblob.go delete mode 100644 sdk/mpr/writer_datatransformer.go delete mode 100644 sdk/mpr/writer_dbconnection.go delete mode 100644 sdk/mpr/writer_dbconnection_querytype_test.go delete mode 100644 sdk/mpr/writer_domainmodel.go delete mode 100644 sdk/mpr/writer_domainmodel_test.go delete mode 100644 sdk/mpr/writer_elision_test.go delete mode 100644 sdk/mpr/writer_enumeration.go delete mode 100644 sdk/mpr/writer_export_mapping.go delete mode 100644 sdk/mpr/writer_export_mapping_properties_test.go delete mode 100644 sdk/mpr/writer_export_mapping_test.go delete mode 100644 sdk/mpr/writer_external_action_returntype_test.go delete mode 100644 sdk/mpr/writer_formattinginfo_test.go delete mode 100644 sdk/mpr/writer_id_order_test.go delete mode 100644 sdk/mpr/writer_imagecollection.go delete mode 100644 sdk/mpr/writer_imagecollection_test.go delete mode 100644 sdk/mpr/writer_import_mapping.go delete mode 100644 sdk/mpr/writer_import_mapping_test.go delete mode 100644 sdk/mpr/writer_javaactions.go delete mode 100644 sdk/mpr/writer_javaactions_enum_680_test.go delete mode 100644 sdk/mpr/writer_javascriptactions.go delete mode 100644 sdk/mpr/writer_javascriptactions_test.go delete mode 100644 sdk/mpr/writer_jsonstructure.go delete mode 100644 sdk/mpr/writer_listoperation_test.go delete mode 100644 sdk/mpr/writer_listview_source_test.go delete mode 100644 sdk/mpr/writer_listview_template_test.go delete mode 100644 sdk/mpr/writer_microflow.go delete mode 100644 sdk/mpr/writer_microflow_action_items_test.go delete mode 100644 sdk/mpr/writer_microflow_actions.go delete mode 100644 sdk/mpr/writer_microflow_flags_test.go delete mode 100644 sdk/mpr/writer_microflow_version_test.go delete mode 100644 sdk/mpr/writer_microflow_workflow.go delete mode 100644 sdk/mpr/writer_modules.go delete mode 100644 sdk/mpr/writer_navigation.go delete mode 100644 sdk/mpr/writer_navigation_icon_test.go delete mode 100644 sdk/mpr/writer_navigation_notfound_test.go delete mode 100644 sdk/mpr/writer_navigation_offline_test.go delete mode 100644 sdk/mpr/writer_odata.go delete mode 100644 sdk/mpr/writer_odata_test.go delete mode 100644 sdk/mpr/writer_order.go delete mode 100644 sdk/mpr/writer_pages.go delete mode 100644 sdk/mpr/writer_pages_placeholder_test.go delete mode 100644 sdk/mpr/writer_placement.go delete mode 100644 sdk/mpr/writer_refs.go delete mode 100644 sdk/mpr/writer_rename.go delete mode 100644 sdk/mpr/writer_rest.go delete mode 100644 sdk/mpr/writer_rest_httpresponse_test.go delete mode 100644 sdk/mpr/writer_rest_inline_mapping_test.go delete mode 100644 sdk/mpr/writer_rest_test.go delete mode 100644 sdk/mpr/writer_rule_split_test.go delete mode 100644 sdk/mpr/writer_security.go delete mode 100644 sdk/mpr/writer_security_inherited_test.go delete mode 100644 sdk/mpr/writer_security_reconcile_test.go delete mode 100644 sdk/mpr/writer_security_test.go delete mode 100644 sdk/mpr/writer_settings.go delete mode 100644 sdk/mpr/writer_units.go delete mode 100644 sdk/mpr/writer_units_test.go delete mode 100644 sdk/mpr/writer_validationrule_test.go delete mode 100644 sdk/mpr/writer_webservice_body_test.go delete mode 100644 sdk/mpr/writer_widgets.go delete mode 100644 sdk/mpr/writer_widgets_action.go delete mode 100644 sdk/mpr/writer_widgets_action_test.go delete mode 100644 sdk/mpr/writer_widgets_container_test.go delete mode 100644 sdk/mpr/writer_widgets_custom.go delete mode 100644 sdk/mpr/writer_widgets_display.go delete mode 100644 sdk/mpr/writer_widgets_icon_test.go delete mode 100644 sdk/mpr/writer_widgets_image_test.go delete mode 100644 sdk/mpr/writer_widgets_input.go delete mode 100644 sdk/mpr/writer_widgets_layout.go delete mode 100644 sdk/mpr/writer_widgets_linkbutton_test.go delete mode 100644 sdk/mpr/writer_widgets_snippet_test.go delete mode 100644 sdk/mpr/writer_widgets_test.go delete mode 100644 sdk/mpr/writer_workflow.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend.jsonl b/.claude/skills/fix-issue/findings/mdl-backend.jsonl index 5cb3649c7e..911a51a7e7 100644 --- a/.claude/skills/fix-issue/findings/mdl-backend.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-backend.jsonl @@ -111,3 +111,4 @@ {"area": "mdl/backend", "date": "2026-09-15", "symptom": "`create or modify workflow` over `--mcp` (Studio Pro 11.14) stored the flow's activities reversed, with an old activity left in and a new one missing ([Start, a, b, End] rewritten as A, B, C → Start, B, A, a, End; a larger flow also lost its parallel split and held a name twice); `alter workflow … replace activity X` left X in place. The update calls all reported SUCCESS", "cause": "One `ped_update_document` batch is not applied in the order sent. Every measured batch fits: ops run highest index first, and at one index the adds go in as a block in op order before the removes — so a remove at an index where something was just added removes the added element. `UpdateWorkflow` sent the flow's removes plus its middles in reverse at index 1 in one batch (and index-less adds for event sub-processes/handlers, which come out reversed); `ReplaceActivity` sent remove @k plus adds at k, k+1; `InsertAfterActivity` sent incrementing indices", "file": "`mdl/backend/mcp/workflow.go` (`UpdateWorkflow`, `InsertAfterActivity`, `ReplaceActivity`, `addAtOp`); simulator `pedListSim` in `mdl/backend/mcp/workflow_listops_test.go`", "insight": "**The code comment claimed the reverse-at-index-1 trick worked, and no fake PED modelled batch semantics, so the unit tests asserted the ops sent rather than the list stored.** Fix tests by simulating the server's list semantics and asserting the resulting ORDER, and keep a table test that replays each raw-PED measurement through the simulator — that is what makes the simulator trustworthy and each control meaningful. The first guess at the rule (\"adds first, then removes\") fit two measurements and failed the third; fit the model to every data point before building on it. Also: a live update-path probe needs a workflow the executor can see — either on disk, or created earlier in the same exec (the backend's session list)", "fix": "Never add to and remove from the same list in one batch: add the statement's elements at a single index in their own order (flow middles @1, event sub-processes and handlers @0, replacement activities @k+1), then remove the stored/replaced ones in a second update. Adding first leaves duplicates, not a gutted workflow, if the second update fails"} {"area":"mdl/backend","date":"2026-09-15","symptom":"Wiring FindCustomWidgetType from modelsdk/mpr.Reader onto the codec Backend by straight delegation made `mxcli extract-templates` extract 0 of 6 templates, reporting for each widget: '[SKIP] Combo box: widget type is bson.D, want bson.D'. The type assertion in the caller names the same type on both sides of 'want'.","cause":"modelsdk/mpr builds RawType/RawObject with the v2 BSON driver (go.mongodb.org/mongo-driver/v2/bson) while sdk/mpr and every caller use v1 (go.mongodb.org/mongo-driver/bson). They are unrelated Go types that both print as 'bson.D', so the mismatch is invisible in the error text. types.RawCustomWidgetType declares the fields as `any` to avoid a BSON dependency, which removes the compiler's ability to catch it too.","file":"mdl/backend/modelsdk/widget_custom_find.go","fix":"Convert at the backend boundary with the package's existing v2ToV1BSON helper, so RawType/RawObject always hold v1 bson.D — the currency sdk/mpr established and callers assert. Verified by extracting all 6 templates byte-for-byte identically to the pre-change binary (1.2MB datagrid.json included); reverting the conversion fails the new test with 'RawType is bson.D, want v1 bson.D'.","insight":"An `any` field crossing an engine boundary can carry the RIGHT type name and the WRONG package, and the error message will look like a tautology. When a type assertion fails with identical type names on both sides, the question is which import path each came from, not what the type is — the two BSON drivers coexist in this repo on purpose (modelsdk is v2, sdk/mpr and the CLI are v1) and widget_pluggable_write.go's v2ToV1BSON already existed for the write direction. A cast written to silence that compile/assert error panics at runtime instead. Two process notes from the same change. (1) GREP FOR AN EXISTING IMPLEMENTATION BEFORE WRITING ONE: the walker had been in modelsdk/mpr all along (FindAllCustomWidgetTypes + collectCustomWidgets, and it populates UnitName/WidgetName which a fresh implementation would omit); only the backend wiring was missing, which is exactly what 'this should be unreachable' in the unimplemented error meant. (2) unimplemented_gen.go still emits the stub after a method is implemented — the generator writes a complete fallback set and Backend's own method shadows it — so the thing to update is the unreachableUnimplemented map in unimplemented_reachability_test.go, which fails loudly if a listed method becomes implemented."} {"area":"mdl/backend","date":"2026-09-15","symptom":"Phase 4a took sdk/mpr from 27 importers to 0, but nothing stopped the count from creeping back — there was no build or test guard, only the plan document and a habit.","cause":"The invariant lived in prose. A single new `import \"github.com/mendixlabs/mxcli/sdk/mpr\"` compiles, passes every test, and reintroduces exactly the blind spot Phase 4a existed to close: the unimplemented-method census in mdl/backend/modelsdk lists methods with NO implementation, so a caller reaching one through a concrete *sdk/mpr.Reader never appears in it. That is what hid project_tree.go's 36 semantic reads (#477) and cmd_extract_templates.go's FindCustomWidgetType (#484) until each was found by hand.","file":"mdl/backend/sdkmpr_import_guard_test.go","fix":"TestNothingImportsTheLegacyEngine parses every .go file's imports (go/parser, ImportsOnly) and fails naming any file that imports sdk/mpr, with the remedy in the message. Controlled by dropping a one-line file importing sdk/mpr into examples/ — it fails and names the file.","insight":"A zero-count invariant needs TWO positive controls or it passes vacuously forever, and the failure mode is silent by construction: a walk rooted at the wrong directory, a skipped-dir rule that is too broad, or an import-parsing mistake all report '0 importers' and read as success. So assert (1) a plausible number of files was actually scanned (here >500; it sees 2551) and (2) the detector can see imports AT ALL, by counting a package the repo definitely does import (mdl/backend, 120 files). Only then does 0 mean zero. This is scripts/check-tunnel-deps.sh's pattern — it asserts chisel IS in the linux graph before asserting it is absent from windows/darwin — and the same reasoning as a bug-fix control: a test that only ever passes has not been shown to detect anything. Practical note: skip sdk/mpr's own directory by comparing the path to the repo root rather than by basename, or a directory named mpr elsewhere is skipped too."} +{"area":"mdl/backend","date":"2026-09-16","symptom":"With sdk/mpr at zero importers, `rm -rf sdk/mpr` still would not have been safe: it had two live dependencies that an import-based check cannot see. sdk/mpr/version has six importers (two of them shipping code, cmd/mxcli/docker/build.go and patch.go), and cmd/mxcli/docker/update_widgets_test.go reads sdk/mpr/testdata/v1-project by FILESYSTEM PATH.","cause":"The zero-importer guard matched the exact string \"github.com/mendixlabs/mxcli/sdk/mpr\". A SUBPACKAGE is a different import path, and a testdata directory is not an import at all — it is an os.DirFS string. Neither shape appears in a check written against the parent package's path.","file":"sdk/mpr","fix":"Repointed the six version importers at mdl/types (sdk/mpr/version.ProjectVersion is `type ProjectVersion = types.ProjectVersion`, an ALIAS, so it is the same type rather than a compatible one — modelsdk/mpr/version declares a duplicate struct and would NOT have been), moved the v1-project fixture to modelsdk/mpr/testdata/, verified both with the package still present, and only then deleted. 163 files, 41,674 lines.","insight":"Before deleting a package, search for THREE things, not one: the package's own import path, its subpackages' import paths (`.../pkg/`), and its directory as a literal string (testdata read through os.DirFS, go:embed, scripts). The last two are invisible to any importer census. Repoint everything FIRST and prove the build and tests green while the package still exists — that separates 'the repoint was wrong' from 'the deletion was wrong', which a single combined commit cannot distinguish. Two measurements worth keeping: the shipped binary is byte-identical in SIZE before and after, confirming the linker had already dropped the package, so this deletion removes source weight and not runtime behaviour; and `sdk/widgets` dropped to zero importers as a side effect but must NOT be deleted, because modelsdk/widgets/dirty_template_test.go reads sdk/widgets/templates/mendix-11.6 by path — the same path-not-import trap, found by grepping for the directory name rather than the import."} diff --git a/cmd/mxcli/docker/build.go b/cmd/mxcli/docker/build.go index 2597e1f91e..fcb27c1a80 100644 --- a/cmd/mxcli/docker/build.go +++ b/cmd/mxcli/docker/build.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "github.com/mendixlabs/mxcli/sdk/mpr/version" + mxversion "github.com/mendixlabs/mxcli/mdl/types" ) // BuildOptions configures the docker build command. @@ -779,7 +779,7 @@ func ensureDemoUsers(projectPath string, w io.Writer) error { } // DescribePatches returns the list of patches that would be applied for a given version. -func DescribePatches(pv *version.ProjectVersion) []string { +func DescribePatches(pv *mxversion.ProjectVersion) []string { var patches []string is116x := pv.MajorVersion == 11 && pv.MinorVersion == 6 patches = append(patches, "Set bin/start execute permission") diff --git a/cmd/mxcli/docker/build_integration_test.go b/cmd/mxcli/docker/build_integration_test.go index 1de5f1602d..0963de303b 100644 --- a/cmd/mxcli/docker/build_integration_test.go +++ b/cmd/mxcli/docker/build_integration_test.go @@ -12,7 +12,7 @@ import ( "testing" "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/mpr/version" + mxversion "github.com/mendixlabs/mxcli/mdl/types" ) // TestBuild_PreservesMPRv2StorageFormat is the end-to-end guard for @@ -96,7 +96,7 @@ func TestBuild_PreservesMPRv2StorageFormat(t *testing.T) { } // mprProductVersion opens the .mpr and returns its Mendix product version. -func mprProductVersion(t *testing.T, mprPath string) *version.ProjectVersion { +func mprProductVersion(t *testing.T, mprPath string) *mxversion.ProjectVersion { t.Helper() reader, err := openReadOnly(mprPath) if err != nil { diff --git a/cmd/mxcli/docker/build_test.go b/cmd/mxcli/docker/build_test.go index da4b1480f4..365a1f8875 100644 --- a/cmd/mxcli/docker/build_test.go +++ b/cmd/mxcli/docker/build_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/mendixlabs/mxcli/sdk/mpr/version" + mxversion "github.com/mendixlabs/mxcli/mdl/types" ) // newTestZipWriter wraps zip.NewWriter for test helpers. @@ -310,7 +310,7 @@ CMD ["./bin/start.sh", "etc/Default"] os.MkdirAll(etcDir, 0755) os.WriteFile(filepath.Join(etcDir, "Default"), []byte("# config\n"), 0644) - pv := &version.ProjectVersion{ + pv := &mxversion.ProjectVersion{ ProductVersion: "11.6.1", MajorVersion: 11, MinorVersion: 6, @@ -353,7 +353,7 @@ CMD ["./bin/start", "etc/Default"] os.MkdirAll(etcDir, 0755) os.WriteFile(filepath.Join(etcDir, "Default"), []byte("# config\n"), 0644) - pv := &version.ProjectVersion{ + pv := &mxversion.ProjectVersion{ ProductVersion: "12.0.0", MajorVersion: 12, MinorVersion: 0, @@ -737,7 +737,7 @@ func TestFlattenPADDir_OverwritesOldContents(t *testing.T) { } func TestDescribePatches_116x(t *testing.T) { - pv := &version.ProjectVersion{MajorVersion: 11, MinorVersion: 6, PatchVersion: 1} + pv := &mxversion.ProjectVersion{MajorVersion: 11, MinorVersion: 6, PatchVersion: 1} patches := DescribePatches(pv) if len(patches) != 7 { t.Errorf("expected 7 patches for 11.6.x, got %d", len(patches)) @@ -745,7 +745,7 @@ func TestDescribePatches_116x(t *testing.T) { } func TestDescribePatches_12x(t *testing.T) { - pv := &version.ProjectVersion{MajorVersion: 12, MinorVersion: 0, PatchVersion: 0} + pv := &mxversion.ProjectVersion{MajorVersion: 12, MinorVersion: 0, PatchVersion: 0} patches := DescribePatches(pv) if len(patches) != 6 { t.Errorf("expected 6 patches for 12.x, got %d", len(patches)) diff --git a/cmd/mxcli/docker/patch.go b/cmd/mxcli/docker/patch.go index 906031e7b4..bc89a9bfab 100644 --- a/cmd/mxcli/docker/patch.go +++ b/cmd/mxcli/docker/patch.go @@ -9,7 +9,7 @@ import ( "regexp" "strings" - "github.com/mendixlabs/mxcli/sdk/mpr/version" + mxversion "github.com/mendixlabs/mxcli/mdl/types" ) // PatchResult describes the outcome of applying a single patch. @@ -21,7 +21,7 @@ type PatchResult struct { // ApplyPatches applies version-aware patches to the PAD output directory. // Returns results for each patch attempted. -func ApplyPatches(padDir string, pv *version.ProjectVersion) []PatchResult { +func ApplyPatches(padDir string, pv *mxversion.ProjectVersion) []PatchResult { var results []PatchResult is116x := pv.MajorVersion == 11 && pv.MinorVersion == 6 diff --git a/cmd/mxcli/docker/update_widgets_test.go b/cmd/mxcli/docker/update_widgets_test.go index 8c73131aac..42ac99966f 100644 --- a/cmd/mxcli/docker/update_widgets_test.go +++ b/cmd/mxcli/docker/update_widgets_test.go @@ -53,7 +53,7 @@ func v2Fixture(t *testing.T) string { func v1Fixture(t *testing.T) string { t.Helper() dst := t.TempDir() - if err := os.CopyFS(dst, os.DirFS("../../../sdk/mpr/testdata/v1-project")); err != nil { + if err := os.CopyFS(dst, os.DirFS("../../../modelsdk/mpr/testdata/v1-project")); err != nil { t.Fatalf("copy v1 fixture: %v", err) } p := filepath.Join(dst, "App.mpr") diff --git a/docs/plans/2026-09-14-retire-legacy-engine.md b/docs/plans/2026-09-14-retire-legacy-engine.md index 4df3b430d3..22c2c6f4c1 100644 --- a/docs/plans/2026-09-14-retire-legacy-engine.md +++ b/docs/plans/2026-09-14-retire-legacy-engine.md @@ -640,3 +640,35 @@ existed to close — a caller holding a concrete reader is invisible to the cens **Phase 4a is complete.** `sdk/mpr` has no importers outside itself; deleting it is now a scheduling decision rather than a risk assessment. + +### The package is gone (2026-09-16) + +`sdk/mpr` deleted: **163 files, 41,674 lines**. The plan is finished. + +**Zero importers was not the same as safe to `rm -rf`.** Two live dependencies survived, and +neither is visible to a check written against the parent package's import path: + +- **`sdk/mpr/version` had six importers**, two of them shipping code (`cmd/mxcli/docker/build.go`, + `patch.go`). A subpackage is a *different* import path. +- **`cmd/mxcli/docker/update_widgets_test.go` read `sdk/mpr/testdata/v1-project` by filesystem + path** — an `os.DirFS` string, not an import at all. + +> Before deleting a package, search for **three** things: its own import path, its subpackages' +> paths, and its directory as a literal string (testdata, `go:embed`, scripts). The last two are +> invisible to any importer census. + +The six went to **`mdl/types`**, not to `modelsdk/mpr/version`: `sdk/mpr/version.ProjectVersion` is +`type ProjectVersion = types.ProjectVersion`, an **alias**, so `types.ProjectVersion` is the same +type — while `modelsdk/mpr/version` declares a *duplicate struct* that would have been a different +one. §7.9's trap, avoided by reading the declaration instead of the name. Everything was repointed +and proven green **with the package still present**, which is what separates "the repoint was +wrong" from "the deletion was wrong". + +Two measurements worth keeping. The shipped binary is **identical in size** before and after, so the +linker had already dropped the package — this removes source weight, not runtime behaviour. And +`sdk/widgets` fell to zero importers as a side effect but is **deliberately kept**: +`modelsdk/widgets/dirty_template_test.go` reads `sdk/widgets/templates/mendix-11.6` by path. Same +trap, caught by grepping the directory name rather than the import. + +The import guard from the previous slice is **removed**: with the package gone, an import is a +compile error, which is strictly stronger than a test asserting the same thing. diff --git a/mdl/backend/sdkmpr_import_guard_test.go b/mdl/backend/sdkmpr_import_guard_test.go deleted file mode 100644 index 1f2f03ddcb..0000000000 --- a/mdl/backend/sdkmpr_import_guard_test.go +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package backend_test - -// Phase 4a of docs/plans/2026-09-14-retire-legacy-engine.md took sdk/mpr from 27 -// importers to 0. This keeps it there. -// -// Without a guard the count creeps back one import at a time, and the reason it -// matters is not tidiness: sdk/mpr is the legacy engine, and a caller holding a -// concrete *sdk/mpr.Reader is INVISIBLE to the unimplemented-method census in -// mdl/backend/modelsdk (that census lists methods with no implementation; a -// caller reaching one through a concrete reader never appears). That blind spot -// is what hid project_tree.go's 36 semantic reads and cmd_extract_templates.go's -// FindCustomWidgetType until each was found by hand. -// -// Modelled on scripts/check-tunnel-deps.sh: assert a positive control first, so -// a scan that silently examined nothing cannot pass. - -import ( - "go/parser" - "go/token" - "os" - "path/filepath" - "strings" - "testing" -) - -const ( - legacyEngine = `"github.com/mendixlabs/mxcli/sdk/mpr"` - // The backend abstraction — something the repo definitely imports widely. - // Used only as the detector's positive control. - backendPkg = `"github.com/mendixlabs/mxcli/mdl/backend"` -) - -func TestNothingImportsTheLegacyEngine(t *testing.T) { - root := repoRoot(t) - - var offenders []string - var scanned, sawControl int - - err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - switch info.Name() { - case ".git", "node_modules", "reference", "vendor": - return filepath.SkipDir - } - // sdk/mpr's own files are the package itself, not importers of it. - if filepath.ToSlash(strings.TrimPrefix(path, root)) == "/sdk/mpr" { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(path, ".go") { - return nil - } - f, perr := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) - if perr != nil { - // Generated parser sources can be enormous but still parse; a file that - // does not is not something to fail the guard on, so it is skipped - // loudly rather than silently. - t.Logf("skipping unparseable %s: %v", path, perr) - return nil - } - scanned++ - rel := strings.TrimPrefix(filepath.ToSlash(strings.TrimPrefix(path, root)), "/") - for _, imp := range f.Imports { - switch imp.Path.Value { - case legacyEngine: - offenders = append(offenders, rel) - case backendPkg: - sawControl++ - } - } - return nil - }) - if err != nil { - t.Fatalf("walk: %v", err) - } - - // Positive controls. Without these a broken walk, a wrong root or an - // import-parsing mistake reports "0 importers" and reads as success. - if scanned < 500 { - t.Fatalf("scanned only %d Go files — the walk is not covering the repo, "+ - "so a clean result here would mean nothing", scanned) - } - if sawControl == 0 { - t.Fatalf("scanned %d files and saw no import of %s — the detector cannot "+ - "see imports at all, so it cannot see sdk/mpr either", scanned, backendPkg) - } - - if len(offenders) > 0 { - t.Errorf("%d file(s) import the legacy engine sdk/mpr, which Phase 4a "+ - "emptied:\n %s\n\nUse mdl/backend (backend.FullBackend) instead. If a "+ - "method you need is missing there, implement it on the codec backend "+ - "rather than reaching past the abstraction — a concrete reader is "+ - "invisible to the unimplemented-method census.", - len(offenders), strings.Join(offenders, "\n ")) - } - t.Logf("scanned %d Go files, %d import mdl/backend, 0 import sdk/mpr", scanned, sawControl) -} - -// repoRoot walks up from the test's directory to the module root. -func repoRoot(t *testing.T) string { - t.Helper() - dir, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - t.Fatal("no go.mod found above the test directory") - } - dir = parent - } -} diff --git a/mdl/executor/doctype_version_gating_test.go b/mdl/executor/doctype_version_gating_test.go index 1ccbbda18a..e6f03a8d76 100644 --- a/mdl/executor/doctype_version_gating_test.go +++ b/mdl/executor/doctype_version_gating_test.go @@ -10,15 +10,15 @@ import ( "strings" "testing" + mxversion "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/mdl/visitor" - "github.com/mendixlabs/mxcli/sdk/mpr/version" ) // nightlyMatrix is the Mendix version set .github/workflows/nightly.yml runs the // doctype scripts against. A script that only parses on the newest of them is a // nightly failure on the others, reported hours later against whatever landed in // between — which is how the DecimalScale gating below was found. -var nightlyMatrix = []*version.ProjectVersion{ +var nightlyMatrix = []*mxversion.ProjectVersion{ {MajorVersion: 10, MinorVersion: 24, ProductVersion: "10.24.24.119349"}, {MajorVersion: 11, MinorVersion: 6, ProductVersion: "11.6.8"}, {MajorVersion: 11, MinorVersion: 12, ProductVersion: "11.12.2"}, diff --git a/mdl/executor/version_filter_test.go b/mdl/executor/version_filter_test.go index 6afeeb6609..e68c952f77 100644 --- a/mdl/executor/version_filter_test.go +++ b/mdl/executor/version_filter_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/mendixlabs/mxcli/sdk/mpr/version" + mxversion "github.com/mendixlabs/mxcli/mdl/types" ) func TestParseVersionDirective(t *testing.T) { @@ -48,13 +48,13 @@ func TestParseVersionDirective(t *testing.T) { } func TestVersionConstraintMatches(t *testing.T) { - mx1024 := &version.ProjectVersion{MajorVersion: 10, MinorVersion: 24} - mx110 := &version.ProjectVersion{MajorVersion: 11, MinorVersion: 0} - mx116 := &version.ProjectVersion{MajorVersion: 11, MinorVersion: 6} + mx1024 := &mxversion.ProjectVersion{MajorVersion: 10, MinorVersion: 24} + mx110 := &mxversion.ProjectVersion{MajorVersion: 11, MinorVersion: 0} + mx116 := &mxversion.ProjectVersion{MajorVersion: 11, MinorVersion: 6} tests := []struct { constraint string - pv *version.ProjectVersion + pv *mxversion.ProjectVersion want bool }{ // min only: 11.0+ @@ -95,8 +95,8 @@ create view entity Test.MyView (...); create entity Test.Universal (...); ` - mx1024 := &version.ProjectVersion{MajorVersion: 10, MinorVersion: 24, ProductVersion: "10.24.0"} - mx116 := &version.ProjectVersion{MajorVersion: 11, MinorVersion: 6, ProductVersion: "11.6.0"} + mx1024 := &mxversion.ProjectVersion{MajorVersion: 10, MinorVersion: 24, ProductVersion: "10.24.0"} + mx116 := &mxversion.ProjectVersion{MajorVersion: 11, MinorVersion: 6, ProductVersion: "11.6.0"} // On 10.24: VIEW ENTITY line should be stripped filtered1024, skipped1024 := filterByVersion(content, mx1024) diff --git a/sdk/mpr/testdata/v1-project/App.mpr b/modelsdk/mpr/testdata/v1-project/App.mpr similarity index 100% rename from sdk/mpr/testdata/v1-project/App.mpr rename to modelsdk/mpr/testdata/v1-project/App.mpr diff --git a/sdk/mpr/asyncapi.go b/sdk/mpr/asyncapi.go deleted file mode 100644 index 2bedd79071..0000000000 --- a/sdk/mpr/asyncapi.go +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/mdl/types" -) - -// Type aliases — all AsyncAPI types now live in mdl/types. -type AsyncAPIDocument = types.AsyncAPIDocument -type AsyncAPIChannel = types.AsyncAPIChannel -type AsyncAPIMessage = types.AsyncAPIMessage -type AsyncAPIProperty = types.AsyncAPIProperty - -// ParseAsyncAPI delegates to types.ParseAsyncAPI. -func ParseAsyncAPI(yamlStr string) (*AsyncAPIDocument, error) { - return types.ParseAsyncAPI(yamlStr) -} diff --git a/sdk/mpr/asyncapi_test.go b/sdk/mpr/asyncapi_test.go deleted file mode 100644 index aa03aa601e..0000000000 --- a/sdk/mpr/asyncapi_test.go +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" -) - -const testAsyncAPIYAML = `asyncapi: 2.2.0 -info: - title: "ShopEventsSvc" - version: "1.0.0" - description: "Shop events for order processing" -channels: - c79d2901578f4ddab69688bde6eaf98c: - subscribe: - operationId: receiveOrderChangedEventEvents - message: - $ref: '#/components/messages/OrderChangedEvent' - abc123: - publish: - operationId: sendProductUpdatedEvents - message: - $ref: '#/components/messages/ProductUpdated' -components: - messages: - OrderChangedEvent: - name: OrderChangedEvent - title: OrderChangedEvent event - description: "Fired when an order changes" - contentType: application/json - payload: - $ref: '#/components/schemas/OrderChangedEventPayload' - ProductUpdated: - name: ProductUpdated - title: Product Updated - description: "" - contentType: application/json - payload: - $ref: '#/components/schemas/ProductUpdatedPayload' - schemas: - OrderChangedEventPayload: - type: object - properties: - OrderId: - type: integer - format: int64 - CustomerId: - type: integer - format: int64 - ProductUpdatedPayload: - type: object - properties: - ProductName: - type: string - Price: - type: number - format: double - InStock: - type: boolean -defaultContentType: application/json -` - -func TestParseAsyncAPI(t *testing.T) { - doc, err := ParseAsyncAPI(testAsyncAPIYAML) - if err != nil { - t.Fatalf("ParseAsyncAPI failed: %v", err) - } - - if doc.Version != "2.2.0" { - t.Errorf("expected version 2.2.0, got %s", doc.Version) - } - if doc.Title != "ShopEventsSvc" { - t.Errorf("expected title ShopEventsSvc, got %s", doc.Title) - } - if doc.Description != "Shop events for order processing" { - t.Errorf("expected description, got %q", doc.Description) - } - - // Check channels - if len(doc.Channels) != 2 { - t.Fatalf("expected 2 channels, got %d", len(doc.Channels)) - } - - var subChannel, pubChannel *AsyncAPIChannel - for _, ch := range doc.Channels { - if ch.OperationType == "subscribe" { - subChannel = ch - } else if ch.OperationType == "publish" { - pubChannel = ch - } - } - - if subChannel == nil { - t.Fatal("subscribe channel not found") - } - if subChannel.MessageRef != "OrderChangedEvent" { - t.Errorf("expected message ref OrderChangedEvent, got %s", subChannel.MessageRef) - } - if subChannel.OperationID != "receiveOrderChangedEventEvents" { - t.Errorf("expected operationId receiveOrderChangedEventEvents, got %s", subChannel.OperationID) - } - - if pubChannel == nil { - t.Fatal("publish channel not found") - } - if pubChannel.MessageRef != "ProductUpdated" { - t.Errorf("expected message ref ProductUpdated, got %s", pubChannel.MessageRef) - } - - // Check messages - if len(doc.Messages) != 2 { - t.Fatalf("expected 2 messages, got %d", len(doc.Messages)) - } - - orderMsg := doc.FindMessage("OrderChangedEvent") - if orderMsg == nil { - t.Fatal("OrderChangedEvent message not found") - } - if orderMsg.Description != "Fired when an order changes" { - t.Errorf("expected description, got %q", orderMsg.Description) - } - if len(orderMsg.Properties) != 2 { - t.Fatalf("expected 2 properties, got %d", len(orderMsg.Properties)) - } - - // Check property resolution - var orderIdProp *AsyncAPIProperty - for _, p := range orderMsg.Properties { - if p.Name == "OrderId" { - orderIdProp = p - break - } - } - if orderIdProp == nil { - t.Fatal("OrderId property not found") - } - if orderIdProp.Type != "integer" { - t.Errorf("expected type integer, got %s", orderIdProp.Type) - } - if orderIdProp.Format != "int64" { - t.Errorf("expected format int64, got %s", orderIdProp.Format) - } - - // Check ProductUpdated message - prodMsg := doc.FindMessage("ProductUpdated") - if prodMsg == nil { - t.Fatal("ProductUpdated message not found") - } - if len(prodMsg.Properties) != 3 { - t.Fatalf("expected 3 properties, got %d", len(prodMsg.Properties)) - } -} - -func TestParseAsyncAPIEmpty(t *testing.T) { - _, err := ParseAsyncAPI("") - if err == nil { - t.Error("expected error for empty document") - } -} diff --git a/sdk/mpr/bson_testutil_test.go b/sdk/mpr/bson_testutil_test.go deleted file mode 100644 index a3d932c8a6..0000000000 --- a/sdk/mpr/bson_testutil_test.go +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "go.mongodb.org/mongo-driver/bson" - -// dToMap recursively converts an ordered bson.D (and any nested bson.D/bson.A) -// into the unordered bson.M / map form that several serializer tests assert -// against by key. The writer now emits ordered bson.D values (so that "$ID" is -// the first property, required by Mendix 11.12+); these tests only care about -// field presence and values, not order, so converting to a map keeps them valid. -// -// Order-sensitivity itself ("$ID" first) is covered separately in -// writer_id_order_test.go. -func dToMap(v any) any { - switch t := v.(type) { - case bson.D: - m := bson.M{} - for _, e := range t { - m[e.Key] = dToMap(e.Value) - } - return m - case bson.A: - out := make(bson.A, len(t)) - for i, e := range t { - out[i] = dToMap(e) - } - return out - default: - return v - } -} - -// dToM is a convenience wrapper that converts a bson.D storage object to bson.M. -func dToM(d bson.D) bson.M { - return dToMap(d).(bson.M) -} diff --git a/sdk/mpr/domainmodel_annotation_test.go b/sdk/mpr/domainmodel_annotation_test.go deleted file mode 100644 index 06788bcbfa..0000000000 --- a/sdk/mpr/domainmodel_annotation_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// A domain model can hold annotations — the note boxes Studio Pro draws on the -// canvas to explain the diagram. Every blank Mendix app ships with one, and -// modellers add more to group and label a large model. -// -// The legacy writer hardcoded `Annotations` to an empty typed array, so ANY -// rewrite of a domain model deleted every note in it: adding one entity to the -// blank app's MyFirstModule took its annotation count from 1 to 0 and the -// caption disappeared from the project. `mx check` reports 0 errors, because an -// annotation is decorative — nothing below Studio Pro can see the loss. -// -// The parser had a second, quieter defect: it read `Location` only as a BSON -// sub-document, while Studio Pro stores it as the string "x;y" (measured on a -// stock 11.13.0 app). So even before the write threw them away, the read had -// already lost every position, and Width was never read at all. -// -// This is the same guard-don't-drop rule as ADR-0005: what MDL cannot express, a -// rewrite carries. - -// storedAnnotation is the shape Studio Pro writes, taken from the annotation in -// a blank 11.13.0 app's MyFirstModule. -func storedAnnotation() map[string]any { - return map[string]any{ - "$Type": "DomainModels$Annotation", - "Caption": "This Domain model defines the data structure of this module.\r\n\r\nMore info: https://docs.mendix.com/refguide/domain-model", - "ExportLevel": "Hidden", - "Location": "60;240", - "Width": int32(440), - } -} - -// The position is a string, not a sub-document. Reading only the sub-document -// form silently returned (0,0) for every real annotation. -func TestParseAnnotationReadsStringLocationAndWidth(t *testing.T) { - got := parseAnnotation(storedAnnotation()) - - if got.Caption == "" { - t.Fatal("Caption is empty") - } - if got.Location.X != 60 || got.Location.Y != 240 { - t.Errorf("Location = (%d,%d), want (60,240) — Studio Pro stores it as the string \"x;y\"", - got.Location.X, got.Location.Y) - } - if got.Width != 440 { - t.Errorf("Width = %d, want 440", got.Width) - } -} - -// The sub-document form is accepted too, exactly as the entity parser does: a -// document written by an older mxcli must still read. -func TestParseAnnotationStillReadsMapLocation(t *testing.T) { - raw := storedAnnotation() - raw["Location"] = map[string]any{"x": int32(12), "y": int32(34)} - - got := parseAnnotation(raw) - if got.Location.X != 12 || got.Location.Y != 34 { - t.Errorf("Location = (%d,%d), want (12,34)", got.Location.X, got.Location.Y) - } -} - -// The write must carry what the read produced. Before this, a domain model -// rewrite emitted `Annotations: [3]` — the empty typed array — regardless. -func TestSerializeAnnotationRoundTrips(t *testing.T) { - annot := &domainmodel.Annotation{ - Caption: "Orders live here", - Location: model.Point{X: 60, Y: 240}, - Width: 440, - } - annot.ID = "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" - - got := parseAnnotation(dToM(serializeDomainModelAnnotation(annot))) - - if got.Caption != annot.Caption { - t.Errorf("Caption = %q, want %q", got.Caption, annot.Caption) - } - if got.Location != annot.Location { - t.Errorf("Location = %+v, want %+v", got.Location, annot.Location) - } - if got.Width != annot.Width { - t.Errorf("Width = %d, want %d", got.Width, annot.Width) - } - if got.ID != annot.ID { - t.Errorf("ID = %q, want the stored %q (a real UUID: idToBsonBinary cannot round-trip a made-up string) — a fresh one makes an unchanged model differ (ADR-0008)", - got.ID, annot.ID) - } -} - -// The serialized shape must match what Studio Pro writes, key for key: the -// position as the string "x;y", and ExportLevel present. A sub-document position -// is what the parser used to expect and is NOT what Mendix stores. -func TestSerializeAnnotationMatchesStudioProShape(t *testing.T) { - annot := &domainmodel.Annotation{Caption: "x", Location: model.Point{X: 60, Y: 240}, Width: 440} - annot.ID = "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" - - got := dToM(serializeDomainModelAnnotation(annot)) - - if got["$Type"] != "DomainModels$Annotation" { - t.Errorf("$Type = %v", got["$Type"]) - } - if loc, ok := got["Location"].(string); !ok || loc != "60;240" { - t.Errorf("Location = %#v, want the string \"60;240\"", got["Location"]) - } - if got["ExportLevel"] != "Hidden" { - t.Errorf("ExportLevel = %v, want Hidden — every Studio Pro annotation carries it", got["ExportLevel"]) - } -} diff --git a/sdk/mpr/download_file_test.go b/sdk/mpr/download_file_test.go deleted file mode 100644 index e18937af9f..0000000000 --- a/sdk/mpr/download_file_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestDownloadFileAction_Roundtrip(t *testing.T) { - action := µflows.DownloadFileAction{ - BaseElement: model.BaseElement{ID: "download-action-id"}, - ErrorHandlingType: microflows.ErrorHandlingTypeContinue, - FileDocument: "GeneratedReport", - ShowInBrowser: true, - } - - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - parsed := parseDownloadFileAction(raw) - if parsed.ErrorHandlingType != microflows.ErrorHandlingTypeContinue { - t.Fatalf("ErrorHandlingType = %q, want Continue", parsed.ErrorHandlingType) - } - if parsed.FileDocument != "GeneratedReport" { - t.Fatalf("FileDocument = %q, want GeneratedReport", parsed.FileDocument) - } - if !parsed.ShowInBrowser { - t.Fatal("ShowInBrowser = false, want true") - } -} - -func TestParseDownloadFileAction_DefaultsErrorHandlingToRollback(t *testing.T) { - action := parseDownloadFileAction(map[string]any{ - "$ID": "download-action-id", - "FileDocumentVariableName": "GeneratedReport", - "ShowInBrowser": false, - }) - - if action.ErrorHandlingType != microflows.ErrorHandlingTypeRollback { - t.Fatalf("ErrorHandlingType = %q, want Rollback", action.ErrorHandlingType) - } -} diff --git a/sdk/mpr/edmx.go b/sdk/mpr/edmx.go deleted file mode 100644 index d407b08b72..0000000000 --- a/sdk/mpr/edmx.go +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/mdl/types" -) - -// Type aliases — all EDMX types now live in mdl/types. -type EdmxDocument = types.EdmxDocument -type EdmSchema = types.EdmSchema -type EdmEntityType = types.EdmEntityType -type EdmProperty = types.EdmProperty -type EdmNavigationProperty = types.EdmNavigationProperty -type EdmEntitySet = types.EdmEntitySet -type EdmAction = types.EdmAction -type EdmActionParameter = types.EdmActionParameter -type EdmEnumType = types.EdmEnumType -type EdmEnumMember = types.EdmEnumMember - -// ParseEdmx delegates to types.ParseEdmx. -func ParseEdmx(metadataXML string) (*EdmxDocument, error) { - return types.ParseEdmx(metadataXML) -} - -// resolveNavType delegates to types.ResolveNavType (kept for test compatibility). -func resolveNavType(t string) (string, bool) { - return types.ResolveNavType(t) -} diff --git a/sdk/mpr/edmx_test.go b/sdk/mpr/edmx_test.go deleted file mode 100644 index 3be5d4abfd..0000000000 --- a/sdk/mpr/edmx_test.go +++ /dev/null @@ -1,409 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" -) - -const testOData3Metadata = ` - - - - - - SAP Purchase Order - Provides access to Purchase Order information from SAP - - - - - - - - - - - - - - - - - - - - - - - - - - - -` - -const testOData4Metadata = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -` - -func TestParseEdmxOData3(t *testing.T) { - doc, err := ParseEdmx(testOData3Metadata) - if err != nil { - t.Fatalf("ParseEdmx failed: %v", err) - } - - if doc.Version != "1.0" { - t.Errorf("expected version 1.0, got %s", doc.Version) - } - - if len(doc.Schemas) != 1 { - t.Fatalf("expected 1 schema, got %d", len(doc.Schemas)) - } - - schema := doc.Schemas[0] - if schema.Namespace != "DefaultNamespace" { - t.Errorf("expected namespace DefaultNamespace, got %s", schema.Namespace) - } - - if len(schema.EntityTypes) != 2 { - t.Fatalf("expected 2 entity types, got %d", len(schema.EntityTypes)) - } - - // Check PurchaseOrder - po := schema.EntityTypes[0] - if po.Name != "PurchaseOrder" { - t.Errorf("expected PurchaseOrder, got %s", po.Name) - } - if po.Summary != "SAP Purchase Order" { - t.Errorf("expected summary 'SAP Purchase Order', got '%s'", po.Summary) - } - if len(po.KeyProperties) != 1 || po.KeyProperties[0] != "ID" { - t.Errorf("expected key [ID], got %v", po.KeyProperties) - } - if len(po.Properties) != 6 { - t.Errorf("expected 6 properties, got %d", len(po.Properties)) - } - if len(po.NavigationProperties) != 2 { - t.Errorf("expected 2 nav properties, got %d", len(po.NavigationProperties)) - } - - // Check SupplierName property - var supplierProp *EdmProperty - for _, p := range po.Properties { - if p.Name == "SupplierName" { - supplierProp = p - break - } - } - if supplierProp == nil { - t.Fatal("SupplierName property not found") - } - if supplierProp.Type != "Edm.String" { - t.Errorf("expected Edm.String, got %s", supplierProp.Type) - } - if supplierProp.MaxLength != "200" { - t.Errorf("expected MaxLength 200, got %s", supplierProp.MaxLength) - } - - // Check entity sets - if len(doc.EntitySets) != 2 { - t.Fatalf("expected 2 entity sets, got %d", len(doc.EntitySets)) - } - if doc.EntitySets[0].Name != "PurchaseOrders" { - t.Errorf("expected PurchaseOrders, got %s", doc.EntitySets[0].Name) - } - - // Check FindEntityType - found := doc.FindEntityType("DefaultNamespace.Customer") - if found == nil { - t.Error("FindEntityType('DefaultNamespace.Customer') returned nil") - } - if found != nil && found.Name != "Customer" { - t.Errorf("expected Customer, got %s", found.Name) - } -} - -func TestParseEdmxOData4(t *testing.T) { - doc, err := ParseEdmx(testOData4Metadata) - if err != nil { - t.Fatalf("ParseEdmx failed: %v", err) - } - - if doc.Version != "4.0" { - t.Errorf("expected version 4.0, got %s", doc.Version) - } - - schema := doc.Schemas[0] - - // Check Product entity - product := schema.EntityTypes[0] - if product.Name != "Product" { - t.Errorf("expected Product, got %s", product.Name) - } - if product.Summary != "Product Inventory" { - t.Errorf("expected summary 'Product Inventory', got '%s'", product.Summary) - } - - // Check navigation property with Collection type - if len(product.NavigationProperties) != 1 { - t.Fatalf("expected 1 nav property, got %d", len(product.NavigationProperties)) - } - nav := product.NavigationProperties[0] - if nav.Name != "Parts" { - t.Errorf("expected Parts, got %s", nav.Name) - } - if nav.TargetType != "Part" { - t.Errorf("expected target type Part, got %s", nav.TargetType) - } - if !nav.IsMany { - t.Error("expected IsMany=true for Collection type") - } - - // Check Part navigation property (single) - part := schema.EntityTypes[1] - partNav := part.NavigationProperties[0] - if partNav.TargetType != "Product" { - t.Errorf("expected target type Product, got %s", partNav.TargetType) - } - if partNav.IsMany { - t.Error("expected IsMany=false for single type") - } - - // Check actions - if len(doc.Actions) != 2 { - t.Fatalf("expected 2 actions, got %d", len(doc.Actions)) - } - createOrder := doc.Actions[0] - if createOrder.Name != "CreateOrder" { - t.Errorf("expected CreateOrder, got %s", createOrder.Name) - } - if len(createOrder.Parameters) != 1 { - t.Errorf("expected 1 parameter, got %d", len(createOrder.Parameters)) - } - if createOrder.ReturnType != "DefaultNamespace.OrderResult" { - t.Errorf("expected return type DefaultNamespace.OrderResult, got %s", createOrder.ReturnType) - } - - // Check function - getTop := doc.Actions[1] - if getTop.Name != "GetTopProducts" { - t.Errorf("expected GetTopProducts, got %s", getTop.Name) - } - if getTop.ReturnType != "Collection(DefaultNamespace.Product)" { - t.Errorf("expected return type Collection(DefaultNamespace.Product), got %s", getTop.ReturnType) - } - - // Check entity sets - if len(doc.EntitySets) != 2 { - t.Fatalf("expected 2 entity sets, got %d", len(doc.EntitySets)) - } -} - -func TestParseEdmxEmpty(t *testing.T) { - _, err := ParseEdmx("") - if err == nil { - t.Error("expected error for empty metadata") - } -} - -const testCapabilitiesMetadata = ` - - - - - - - - - - - - - - - - - - - - - - - - - OrderId - - - - - Lines - - - - - - - - - - OrderId - OrderNumber - - - - - - - - - - - - -` - -func TestParseEdmxCapabilityAnnotations(t *testing.T) { - doc, err := ParseEdmx(testCapabilitiesMetadata) - if err != nil { - t.Fatalf("ParseEdmx failed: %v", err) - } - - // Find the Orders entity set. - var orders *EdmEntitySet - for _, es := range doc.EntitySets { - if es.Name == "Orders" { - orders = es - } - } - if orders == nil { - t.Fatal("Orders entity set not found") - } - - if orders.Insertable == nil || !*orders.Insertable { - t.Errorf("Orders.Insertable = %v, want true", orders.Insertable) - } - if orders.Updatable == nil || !*orders.Updatable { - t.Errorf("Orders.Updatable = %v, want true", orders.Updatable) - } - if orders.Deletable == nil || !*orders.Deletable { - t.Errorf("Orders.Deletable = %v, want true", orders.Deletable) - } - - wantNonIns := []string{"OrderId"} - if !stringSliceEqual(orders.NonInsertableProperties, wantNonIns) { - t.Errorf("NonInsertableProperties = %v, want %v", orders.NonInsertableProperties, wantNonIns) - } - wantNonUpd := []string{"OrderId", "OrderNumber"} - if !stringSliceEqual(orders.NonUpdatableProperties, wantNonUpd) { - t.Errorf("NonUpdatableProperties = %v, want %v", orders.NonUpdatableProperties, wantNonUpd) - } - wantNonInsNav := []string{"Lines"} - if !stringSliceEqual(orders.NonInsertableNavigationProperties, wantNonInsNav) { - t.Errorf("NonInsertableNavigationProperties = %v, want %v", orders.NonInsertableNavigationProperties, wantNonInsNav) - } - - // OrderLines has no annotations → all flags unset. - var lines *EdmEntitySet - for _, es := range doc.EntitySets { - if es.Name == "OrderLines" { - lines = es - } - } - if lines == nil { - t.Fatal("OrderLines entity set not found") - } - if lines.Insertable != nil || lines.Updatable != nil || lines.Deletable != nil { - t.Errorf("OrderLines should have nil capability flags, got Insertable=%v Updatable=%v Deletable=%v", - lines.Insertable, lines.Updatable, lines.Deletable) - } - - // Per-property Computed/Immutable annotations. - order := doc.FindEntityType("DefaultNamespace.Order") - if order == nil { - t.Fatal("Order entity type not found") - } - propByName := map[string]*EdmProperty{} - for _, p := range order.Properties { - propByName[p.Name] = p - } - if !propByName["OrderId"].Computed { - t.Errorf("OrderId.Computed = false, want true") - } - if !propByName["OrderNumber"].Immutable { - t.Errorf("OrderNumber.Immutable = false, want true") - } - if propByName["CustomerName"].Computed || propByName["CustomerName"].Immutable { - t.Errorf("CustomerName should have no capability flags") - } -} - -func stringSliceEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func TestResolveNavType(t *testing.T) { - tests := []struct { - input string - wantType string - wantMany bool - }{ - {"DefaultNamespace.Product", "Product", false}, - {"Collection(DefaultNamespace.Part)", "Part", true}, - {"Edm.String", "String", false}, - {"Product", "Product", false}, - } - - for _, tt := range tests { - typeName, isMany := resolveNavType(tt.input) - if typeName != tt.wantType { - t.Errorf("resolveNavType(%q): got type %q, want %q", tt.input, typeName, tt.wantType) - } - if isMany != tt.wantMany { - t.Errorf("resolveNavType(%q): got isMany=%v, want %v", tt.input, isMany, tt.wantMany) - } - } -} diff --git a/sdk/mpr/get_raw_unit_v1_test.go b/sdk/mpr/get_raw_unit_v1_test.go deleted file mode 100644 index 4e83d45576..0000000000 --- a/sdk/mpr/get_raw_unit_v1_test.go +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" -) - -// TestGetRawUnit_V1 verifies that GetRawUnit works on v1 MPR files (Mendix < 10.18) -// where UnitID is stored as a 16-byte GUID blob in SQLite. -// Regression test for https://github.com/mendixlabs/mxcli/issues/705 -func TestGetRawUnit_V1(t *testing.T) { - mprPath := "testdata/v1-project/App.mpr" - - reader, err := Open(mprPath) - if err != nil { - t.Fatalf("failed to open v1 MPR: %v", err) - } - defer reader.Close() - - if reader.Version() != MPRVersionV1 { - t.Fatalf("expected MPR v1, got v%d", reader.Version()) - } - - // ListAllUnitIDs works correctly (uses blobToUUID internally) - ids, err := reader.ListAllUnitIDs() - if err != nil { - t.Fatalf("ListAllUnitIDs: %v", err) - } - if len(ids) == 0 { - t.Fatal("expected at least one unit ID") - } - - // GetRawUnit must be able to retrieve any unit by the ID that ListAllUnitIDs returns. - // Before the fix, this always returned "no rows in result set" on v1 MPRs. - for _, id := range ids { - raw, err := reader.GetRawUnit(model.ID(id)) - if err != nil { - t.Errorf("GetRawUnit(%s): %v", id, err) - continue - } - if raw == nil { - t.Errorf("GetRawUnit(%s): returned nil map", id) - continue - } - if _, ok := raw["$Type"]; !ok { - t.Errorf("GetRawUnit(%s): BSON missing $Type field", id) - } - } -} diff --git a/sdk/mpr/inheritance_roundtrip_test.go b/sdk/mpr/inheritance_roundtrip_test.go deleted file mode 100644 index b339c94b18..0000000000 --- a/sdk/mpr/inheritance_roundtrip_test.go +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestBuildSequenceFlowCase_InheritanceCase(t *testing.T) { - doc := buildSequenceFlowCase(µflows.InheritanceCase{ - BaseElement: model.BaseElement{ID: "case-1"}, - EntityQualifiedName: "Sample.SpecializedInput", - }) - - if got := bsonGetKey(doc, "$Type"); got != "Microflows$InheritanceCase" { - t.Fatalf("$Type = %v, want Microflows$InheritanceCase", got) - } - if got := bsonGetKey(doc, "Value"); got != "Sample.SpecializedInput" { - t.Fatalf("Value = %v, want Sample.SpecializedInput", got) - } -} - -func TestSerializeMicroflowObject_InheritanceSplit(t *testing.T) { - doc := serializeMicroflowObject(µflows.InheritanceSplit{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: "split-1"}, - Position: model.Point{X: 100, Y: 200}, - Size: model.Size{Width: 120, Height: 60}, - }, - VariableName: "Input", - ErrorHandlingType: microflows.ErrorHandlingTypeRollback, - }) - - if got := bsonGetKey(doc, "$Type"); got != "Microflows$InheritanceSplit" { - t.Fatalf("$Type = %v, want Microflows$InheritanceSplit", got) - } - if got := bsonGetKey(doc, "SplitVariableName"); got != "Input" { - t.Fatalf("SplitVariableName = %v, want Input", got) - } -} - -func TestCastAction_RoundtripVariableName(t *testing.T) { - action := µflows.CastAction{ - BaseElement: model.BaseElement{ID: "cast-1"}, - OutputVariable: "SpecificInput", - } - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("marshal cast action: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal cast action: %v", err) - } - - parsed := parseCastAction(raw) - if parsed.OutputVariable != "SpecificInput" { - t.Fatalf("OutputVariable = %q, want SpecificInput", parsed.OutputVariable) - } -} - -// TestSerializeCastAction_UsesVariableNameFieldKey pins the BSON field key -// Studio Pro emits for Microflows$CastAction. Empirical evidence (BSON -// dump of the Control Centre app on Mendix 9.24): Studio Pro stores the -// output variable under "VariableName", not "OutputVariableName". The -// parser falls back to "VariableName" when "OutputVariableName" is -// absent so projects authored by Studio Pro still parse cleanly; the -// writer must match Studio Pro's authored shape so projects we produce -// open without surprises. -func TestSerializeCastAction_UsesVariableNameFieldKey(t *testing.T) { - action := µflows.CastAction{ - BaseElement: model.BaseElement{ID: "cast-1"}, - OutputVariable: "SpecificInput", - } - doc := serializeMicroflowAction(action) - if got := bsonGetKey(doc, "VariableName"); got != "SpecificInput" { - t.Fatalf("VariableName = %v, want SpecificInput", got) - } - if got := bsonGetKey(doc, "OutputVariableName"); got != nil { - t.Fatalf("OutputVariableName = %v, want absent (Studio Pro uses VariableName)", got) - } -} - -// TestBuildSequenceFlowCase_InheritanceCase_UsesValueFieldKey pins the -// BSON field key for Microflows$InheritanceCase. Empirical evidence -// (BSON dump of the Control Centre app, Mendix 9.24): the entity -// reference is stored under "Value" as a qualified-name string -// (e.g. "Administration.Account"), not "Entity". The parser falls back -// to "Entity" for forward compatibility; the writer must emit "Value" -// so output matches Studio Pro's authored shape. -func TestBuildSequenceFlowCase_InheritanceCase_UsesValueFieldKey(t *testing.T) { - doc := buildSequenceFlowCase(µflows.InheritanceCase{ - BaseElement: model.BaseElement{ID: "case-1"}, - EntityQualifiedName: "Sample.SpecializedInput", - }) - if got := bsonGetKey(doc, "Value"); got != "Sample.SpecializedInput" { - t.Fatalf("Value = %v, want Sample.SpecializedInput", got) - } - if got := bsonGetKey(doc, "Entity"); got != nil { - t.Fatalf("Entity = %v, want absent (Studio Pro uses Value)", got) - } -} diff --git a/sdk/mpr/javaactions_microflowactioninfo_656_test.go b/sdk/mpr/javaactions_microflowactioninfo_656_test.go deleted file mode 100644 index f0f139c1ea..0000000000 --- a/sdk/mpr/javaactions_microflowactioninfo_656_test.go +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func maiField(d bson.D, key string) (any, bool) { - for _, e := range d { - if e.Key == key { - return e.Value, true - } - } - return nil, false -} - -// TestMicroflowActionInfoBSON_HealthyShape asserts the writer emits the current -// metamodel shape — CodeActions$ type, all four icon/image bitmaps present as -// non-null binaries, and no obsolete Icon key — even when every bitmap is empty. -// A null or absent ImageData crashes Studio Pro's UnitWriter (issue #656). -func TestMicroflowActionInfoBSON_HealthyShape(t *testing.T) { - d := microflowActionInfoBSON(&javaactions.MicroflowActionInfo{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Caption: "My Action", - Category: "My Category", - }) - - if v, _ := maiField(d, "$Type"); v != "CodeActions$MicroflowActionInfo" { - t.Errorf("$Type = %v, want CodeActions$MicroflowActionInfo", v) - } - if _, ok := maiField(d, "Icon"); ok { - t.Error("obsolete Icon key must not be emitted") - } - for _, key := range []string{"IconData", "IconDataDark", "ImageData", "ImageDataDark"} { - v, ok := maiField(d, key) - if !ok { - t.Errorf("%s missing — must always be present", key) - continue - } - bin, isBin := v.(primitive.Binary) - if !isBin { - t.Errorf("%s = %T, want primitive.Binary (never null/string)", key, v) - continue - } - if bin.Data == nil { - t.Errorf("%s.Data is nil, want empty (non-null) binary", key) - } - } -} - -// TestParseMicroflowActionInfo_ToleratesLegacyShape asserts the parser reads the -// broken legacy shape (JavaActions$ type, Icon string, null ImageData) without -// error, and that re-serializing the result yields the healthy shape — i.e. a -// corrupted unit loads and self-repairs on rewrite (issue #656). -func TestParseMicroflowActionInfo_ToleratesLegacyShape(t *testing.T) { - legacy := map[string]any{ - "$ID": primitive.Binary{Subtype: 0, Data: make([]byte, 16)}, - "$Type": "JavaActions$MicroflowActionInfo", - "Caption": "Old Action", - "Category": "Old Category", - "Icon": "", // obsolete string key - "ImageData": nil, // the crash-triggering null - } - - mai := parseMicroflowActionInfo(legacy) - if mai.Caption != "Old Action" || mai.Category != "Old Category" { - t.Fatalf("parsed Caption/Category wrong: %+v", mai) - } - if mai.ImageData != nil { - t.Errorf("null ImageData should parse to nil, got %v", mai.ImageData) - } - - // Rewriting must produce the healthy CodeActions$ shape with non-null binaries. - d := microflowActionInfoBSON(mai) - if v, _ := maiField(d, "$Type"); v != "CodeActions$MicroflowActionInfo" { - t.Errorf("rewrite $Type = %v, want CodeActions$MicroflowActionInfo", v) - } - if v, ok := maiField(d, "ImageData"); !ok { - t.Error("rewrite missing ImageData") - } else if bin, isBin := v.(primitive.Binary); !isBin || bin.Data == nil { - t.Errorf("rewrite ImageData = %v, want non-null binary", v) - } -} - -// TestParseMicroflowActionInfo_RoundTripsBinaries asserts real icon/image -// bitmaps survive a parse→write round-trip (no longer silently stripped). -func TestParseMicroflowActionInfo_RoundTripsBinaries(t *testing.T) { - icon := []byte{0xDE, 0xAD, 0xBE, 0xEF} - raw := map[string]any{ - "$ID": primitive.Binary{Subtype: 0, Data: make([]byte, 16)}, - "$Type": "CodeActions$MicroflowActionInfo", - "Caption": "Has Icon", - "Category": "Cat", - "IconData": primitive.Binary{Subtype: 0, Data: icon}, - } - mai := parseMicroflowActionInfo(raw) - if string(mai.IconData) != string(icon) { - t.Fatalf("IconData not preserved: got %v", mai.IconData) - } - d := microflowActionInfoBSON(mai) - v, _ := maiField(d, "IconData") - bin, _ := v.(primitive.Binary) - if string(bin.Data) != string(icon) { - t.Errorf("IconData round-trip lost data: got %v", bin.Data) - } -} diff --git a/sdk/mpr/jsonstructure_folder_lookup_test.go b/sdk/mpr/jsonstructure_folder_lookup_test.go deleted file mode 100644 index 41e4c7d721..0000000000 --- a/sdk/mpr/jsonstructure_folder_lookup_test.go +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "path/filepath" - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" - _ "modernc.org/sqlite" -) - -// JSON structures live inside a module but may be nested in subfolders. -// GetJsonStructureByQualifiedName must resolve container IDs through the -// folder hierarchy up to the owning module; otherwise addRestCallAction -// silently defaults SingleObject=false and produces invalid REST-call -// roundtrips on projects that organise their JSON structures in -// folders. -func TestGetJsonStructureByQualifiedName_ResolvesThroughFolders(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.mpr") - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("open: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - if _, err := db.Exec(` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT, - Contents BLOB - ) - `); err != nil { - t.Fatalf("create Unit: %v", err) - } - - reader := &Reader{db: db, version: MPRVersionV1} - - moduleID := "11111111-1111-1111-1111-111111111111" - folderID := "22222222-2222-2222-2222-222222222222" - jsID := "33333333-3333-3333-3333-333333333333" - otherModuleID := "44444444-4444-4444-4444-444444444444" - - // Module: SBOMModule - modBSON, _ := bson.Marshal(bson.D{ - {Key: "$Type", Value: "Projects$ModuleImpl"}, - {Key: "$ID", Value: idToBsonBinary(moduleID)}, - {Key: "Name", Value: "SBOMModule"}, - }) - if _, err := db.Exec(`INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Contents) VALUES (?, ?, 'Module', ?)`, - uuidToBlob(moduleID), nil, modBSON); err != nil { - t.Fatalf("insert module: %v", err) - } - - // Other module (for the negative case) - otherModBSON, _ := bson.Marshal(bson.D{ - {Key: "$Type", Value: "Projects$ModuleImpl"}, - {Key: "$ID", Value: idToBsonBinary(otherModuleID)}, - {Key: "Name", Value: "OtherModule"}, - }) - if _, err := db.Exec(`INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Contents) VALUES (?, ?, 'Module', ?)`, - uuidToBlob(otherModuleID), nil, otherModBSON); err != nil { - t.Fatalf("insert other module: %v", err) - } - - // Folder inside SBOMModule - folderBSON, _ := bson.Marshal(bson.D{ - {Key: "$Type", Value: "Projects$Folder"}, - {Key: "$ID", Value: idToBsonBinary(folderID)}, - {Key: "Name", Value: "Payloads"}, - }) - if _, err := db.Exec(`INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Contents) VALUES (?, ?, 'Folder', ?)`, - uuidToBlob(folderID), uuidToBlob(moduleID), folderBSON); err != nil { - t.Fatalf("insert folder: %v", err) - } - - // JSON structure nested inside the folder (not the module directly). - jsBSON, _ := bson.Marshal(bson.D{ - {Key: "$Type", Value: "JsonStructures$JsonStructure"}, - {Key: "$ID", Value: idToBsonBinary(jsID)}, - {Key: "Name", Value: "OrderPayload"}, - {Key: "Elements", Value: bson.A{ - int32(2), - bson.D{ - {Key: "$Type", Value: "JsonStructures$JsonElement"}, - {Key: "ExposedName", Value: "Root"}, - {Key: "ElementType", Value: "Object"}, - }, - }}, - }) - if _, err := db.Exec(`INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Contents) VALUES (?, ?, 'Document', ?)`, - uuidToBlob(jsID), uuidToBlob(folderID), jsBSON); err != nil { - t.Fatalf("insert json structure: %v", err) - } - - // Lookup by owning module name should resolve through the folder. - js, err := reader.GetJsonStructureByQualifiedName("SBOMModule", "OrderPayload") - if err != nil { - t.Fatalf("GetJsonStructureByQualifiedName (folder-nested): %v", err) - } - if js == nil { - t.Fatal("expected non-nil JsonStructure") - } - if js.Name != "OrderPayload" { - t.Errorf("Name = %q, want OrderPayload", js.Name) - } - if len(js.Elements) != 1 || js.Elements[0].ElementType != "Object" { - t.Errorf("Elements = %+v, want one Object element", js.Elements) - } - if js.ContainerID != model.ID(folderID) { - t.Errorf("ContainerID = %q, want folder ID %q", js.ContainerID, folderID) - } - - // Cross-module lookup must fail (the structure belongs to SBOMModule, not OtherModule). - if _, err := reader.GetJsonStructureByQualifiedName("OtherModule", "OrderPayload"); err == nil { - t.Error("expected error for wrong-module lookup, got nil") - } -} diff --git a/sdk/mpr/microflow_call_writer_test.go b/sdk/mpr/microflow_call_writer_test.go deleted file mode 100644 index 4df11fdaf2..0000000000 --- a/sdk/mpr/microflow_call_writer_test.go +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "reflect" - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestMicroflowCallAction_WritesStableFieldOrder(t *testing.T) { - action := µflows.MicroflowCallAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - ErrorHandlingType: microflows.ErrorHandlingTypeRollback, - MicroflowCall: µflows.MicroflowCall{ - BaseElement: model.BaseElement{ID: "call-id"}, - Microflow: "Demo.UpdateRecord", - ParameterMappings: []*microflows.MicroflowCallParameterMapping{ - { - BaseElement: model.BaseElement{ID: "mapping-id"}, - Argument: "$Record/Name", - Parameter: "Demo.UpdateRecord.Name", - }, - }, - }, - UseReturnVariable: true, - } - - doc := serializeMicroflowAction(action) - assertBSONKeys(t, doc, []string{ - "$ID", - "$Type", - "ErrorHandlingType", - "MicroflowCall", - "ResultVariableName", - "UseReturnVariable", - }) - - callDoc, ok := bsonValue(doc, "MicroflowCall").(bson.D) - if !ok { - t.Fatalf("MicroflowCall type = %T, want bson.D", bsonValue(doc, "MicroflowCall")) - } - assertBSONKeys(t, callDoc, []string{ - "$ID", - "$Type", - "Microflow", - "ParameterMappings", - "QueueSettings", - }) - - mappings, ok := bsonValue(callDoc, "ParameterMappings").(bson.A) - if !ok || len(mappings) != 2 { - t.Fatalf("ParameterMappings = %#v, want marker plus one mapping", bsonValue(callDoc, "ParameterMappings")) - } - mappingDoc, ok := mappings[1].(bson.D) - if !ok { - t.Fatalf("mapping type = %T, want bson.D", mappings[1]) - } - assertBSONKeys(t, mappingDoc, []string{ - "$ID", - "$Type", - "Argument", - "Parameter", - }) -} - -func assertBSONKeys(t *testing.T, doc bson.D, want []string) { - t.Helper() - - var got []string - for _, elem := range doc { - got = append(got, elem.Key) - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("BSON keys = %#v, want %#v", got, want) - } -} - -func bsonValue(doc bson.D, key string) any { - for _, elem := range doc { - if elem.Key == key { - return elem.Value - } - } - return nil -} diff --git a/sdk/mpr/microflow_parameter_position_test.go b/sdk/mpr/microflow_parameter_position_test.go deleted file mode 100644 index ea7a0731b7..0000000000 --- a/sdk/mpr/microflow_parameter_position_test.go +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -func rmp(t *testing.T, doc bson.D) string { - t.Helper() - v, _ := doc.Map()["RelativeMiddlePoint"].(string) - return v -} - -// #993: a hand-placed parameter must be written where it was placed. Before the -// fix the legacy serializer computed the position from the index and ignored -// anything stored, so a describe → exec of mxcli's own output moved a real -// parameter from -77;0 to 200;53. -func TestSerializeMicroflowParameterKeepsAuthoredPosition(t *testing.T) { - authored := µflows.MicroflowParameter{ - Name: "Feedback", - Position: &model.Point{X: -77, Y: 0}, - } - if got := rmp(t, serializeMicroflowParameter(authored, 0, 11)); got != "-77;0" { - t.Errorf("authored position = %q, want -77;0", got) - } - - // Control: with no authored position the parameter goes where the layout - // puts it — the behaviour every unannotated flow still relies on. Without - // this the test would pass against a writer that had simply stopped - // deriving. - derived := µflows.MicroflowParameter{Name: "Feedback"} - if got := rmp(t, serializeMicroflowParameter(derived, 0, 11)); got != "200;53" { - t.Errorf("derived position at index 0 = %q, want 200;53", got) - } - if got := rmp(t, serializeMicroflowParameter(derived, 2, 11)); got != "400;53" { - t.Errorf("derived position at index 2 = %q, want 400;53", got) - } -} - -// The reader is where the derived/authored arbitration happens, so that -// everything downstream can treat a non-nil Position as intent. A parameter -// stored on the derived grid must come back unset — carrying it over would pin -// it, and inserting a parameter would then strand the others (#951's shape). -func TestParseMicroflowParameterNormalizesDerivedPosition(t *testing.T) { - raw := func(pos string) map[string]any { - return map[string]any{"Name": "A", "RelativeMiddlePoint": pos} - } - if p := parseMicroflowParameter(raw("200;53"), 0); p.Position != nil { - t.Errorf("derived position came back as %v, want nil", *p.Position) - } - if p := parseMicroflowParameter(raw("300;53"), 1); p.Position != nil { - t.Errorf("derived position at index 1 came back as %v, want nil", *p.Position) - } - p := parseMicroflowParameter(raw("-77;0"), 0) - if p.Position == nil { - t.Fatal("authored position was dropped — this is the #993 read-side loss") - } - if *p.Position != (model.Point{X: -77, Y: 0}) { - t.Errorf("position = %v, want -77;0", *p.Position) - } -} diff --git a/sdk/mpr/parser.go b/sdk/mpr/parser.go deleted file mode 100644 index 2c0725e7cd..0000000000 --- a/sdk/mpr/parser.go +++ /dev/null @@ -1,253 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "encoding/base64" - "strings" - - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// extractBsonID extracts an ID string from various BSON ID representations. -// Mendix stores IDs as Binary with Subtype/Data or as primitive.Binary. -func extractBsonID(v any) string { - if v == nil { - return "" - } - - switch val := v.(type) { - case string: - return val - case []byte: - return blobToUUID(val) - case primitive.Binary: - return blobToUUID(val.Data) - case map[string]any: - // Binary UUID stored as {Subtype: 0, Data: "base64..."} - if data, ok := val["Data"].(string); ok { - decoded, err := base64.StdEncoding.DecodeString(data) - if err == nil { - return blobToUUID(decoded) - } - } - // Also try $ID field - if id, ok := val["$ID"]; ok { - return extractBsonID(id) - } - } - - return "" -} - -// extractInt extracts an integer from various BSON number types. -func extractInt(v any) int { - if v == nil { - return 0 - } - switch val := v.(type) { - case int32: - return int(val) - case int64: - return int(val) - case int: - return val - case float64: - return int(val) - } - return 0 -} - -// extractString extracts a string from various BSON representations. -func extractString(v any) string { - if v == nil { - return "" - } - if s, ok := v.(string); ok { - return s - } - return "" -} - -// extractBool extracts a boolean from BSON, with default value. -func extractBool(v any, defaultVal bool) bool { - if v == nil { - return defaultVal - } - if b, ok := v.(bool); ok { - return b - } - return defaultVal -} - -// extractBsonArray extracts items from a Mendix BSON array. -// Mendix arrays start with a type indicator (2 or 3 for storageListType), followed by items. -func extractBsonArray(v any) []any { - if v == nil { - return nil - } - - arr, ok := v.(primitive.A) - if !ok { - // Try regular slice - if slice, ok := v.([]any); ok { - // Check if first element is the array type indicator - if len(slice) > 0 { - if typeIndicator, ok := slice[0].(int32); ok && (typeIndicator == 2 || typeIndicator == 3) { - // Skip the type indicator - return slice[1:] - } - } - return slice - } - return nil - } - - // primitive.A is []interface{} underneath - slice := []any(arr) - - // Check if first element is the array type indicator (2 or 3) - if len(slice) > 0 { - if typeIndicator, ok := slice[0].(int32); ok && (typeIndicator == 2 || typeIndicator == 3) { - // Skip the type indicator - return slice[1:] - } - } - - return slice -} - -// extractBsonMap coerces a BSON value to map[string]interface{}. -// Handles map[string]interface{}, primitive.D, and primitive.M. -func extractBsonMap(v any) map[string]any { - if v == nil { - return nil - } - switch val := v.(type) { - case map[string]any: - return val - case primitive.D: - return val.Map() - case primitive.M: - return map[string]any(val) - } - return nil -} - -// extractBsonSlice coerces a BSON value to []interface{}. -// Handles []interface{} and primitive.A. Unlike extractBsonArray, -// this does NOT strip Mendix type-indicator prefixes. -func extractBsonSlice(v any) []any { - if v == nil { - return nil - } - switch val := v.(type) { - case []any: - return val - case primitive.A: - return []any(val) - } - return nil -} - -// BsonArrayInfo holds the extracted items and the marker from a Mendix BSON array. -type BsonArrayInfo struct { - Marker int32 - Items []any -} - -// extractBsonArrayWithMarker extracts items from a Mendix BSON array, preserving the marker. -// Returns the marker (2 or 3) and the items after the marker. -func extractBsonArrayWithMarker(v any) BsonArrayInfo { - if v == nil { - return BsonArrayInfo{} - } - - var slice []any - switch val := v.(type) { - case primitive.A: - slice = []any(val) - case []any: - slice = val - default: - return BsonArrayInfo{} - } - - if len(slice) > 0 { - if marker, ok := slice[0].(int32); ok && (marker == 1 || marker == 2 || marker == 3) { - return BsonArrayInfo{Marker: marker, Items: slice[1:]} - } - } - return BsonArrayInfo{Items: slice} -} - -// inferPropertyKind determines the Mendix property kind of a BSON field from its key -// and value shape. Returns one of: "id", "type-discriminator", "by-name-reference", -// "primitive", "part", "collection:by-name" (marker=1), "collection:part-secondary" -// (marker=2), "collection:part-primary" (marker=3), "collection". -// Used by UnknownElement to surface diagnostic info when an unimplemented $Type is encountered. -func inferPropertyKind(key string, v any) string { - if v == nil { - return "primitive" - } - - // Key-based shortcuts take priority over value shape. - switch key { - case "$ID", "$ContainerID": - return "id" - case "$Type": - return "type-discriminator" - } - - switch val := v.(type) { - case map[string]any: - if _, hasType := val["$Type"]; hasType { - return "part" - } - if _, hasID := val["$ID"]; hasID { - return "part" - } - return "primitive" - - case primitive.D: - m := val.Map() - if _, hasType := m["$Type"]; hasType { - return "part" - } - if _, hasID := m["$ID"]; hasID { - return "part" - } - return "primitive" - - case primitive.M: - if _, hasType := val["$Type"]; hasType { - return "part" - } - if _, hasID := val["$ID"]; hasID { - return "part" - } - return "primitive" - - case primitive.A, []any: - info := extractBsonArrayWithMarker(v) - switch info.Marker { - case 1: - return "collection:by-name" - case 2: - return "collection:part-secondary" - case 3: - return "collection:part-primary" - } - return "collection" - - case string: - // Heuristic: qualified names like "Module.Entity" are likely by-name references. - if strings.Contains(val, ".") && !strings.Contains(val, " ") && !strings.Contains(val, "/") { - return "by-name-reference" - } - return "primitive" - - default: - return "primitive" - } -} diff --git a/sdk/mpr/parser_businessevents.go b/sdk/mpr/parser_businessevents.go deleted file mode 100644 index 2f6b6ac89f..0000000000 --- a/sdk/mpr/parser_businessevents.go +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseBusinessEventService parses a BusinessEvents$BusinessEventService from BSON. -func (r *Reader) parseBusinessEventService(unitID, containerID string, contents []byte) (*model.BusinessEventService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - svc := &model.BusinessEventService{} - svc.ID = model.ID(unitID) - svc.TypeName = "BusinessEvents$BusinessEventService" - svc.ContainerID = model.ID(containerID) - - svc.Name = extractString(raw["Name"]) - svc.Documentation = extractString(raw["Documentation"]) - svc.Excluded = extractBool(raw["Excluded"], false) - svc.ExportLevel = extractString(raw["ExportLevel"]) - svc.Document = extractString(raw["Document"]) - - // Parse Definition (non-nil for service definitions) - if defRaw, ok := raw["Definition"]; ok && defRaw != nil { - if defMap := extractBsonMap(defRaw); defMap != nil { - svc.Definition = parseBusinessEventDefinition(defMap) - } - } - - // Parse OperationImplementations - opImpls := extractBsonArray(raw["OperationImplementations"]) - for _, oi := range opImpls { - if oiMap := extractBsonMap(oi); oiMap != nil { - svc.OperationImplementations = append(svc.OperationImplementations, parseServiceOperation(oiMap)) - } - } - - return svc, nil -} - -// parseBusinessEventDefinition parses a BusinessEvents$BusinessEventDefinition from a BSON map. -func parseBusinessEventDefinition(raw map[string]any) *model.BusinessEventDefinition { - def := &model.BusinessEventDefinition{} - def.ID = model.ID(extractBsonID(raw["$ID"])) - def.TypeName = extractString(raw["$Type"]) - def.ServiceName = extractString(raw["ServiceName"]) - def.EventNamePrefix = extractString(raw["EventNamePrefix"]) - def.Description = extractString(raw["Description"]) - def.Summary = extractString(raw["Summary"]) - - // Parse Channels - channels := extractBsonArray(raw["Channels"]) - for _, ch := range channels { - if chMap := extractBsonMap(ch); chMap != nil { - def.Channels = append(def.Channels, parseBusinessEventChannel(chMap)) - } - } - - return def -} - -// parseBusinessEventChannel parses a BusinessEvents$Channel from a BSON map. -func parseBusinessEventChannel(raw map[string]any) *model.BusinessEventChannel { - ch := &model.BusinessEventChannel{} - ch.ID = model.ID(extractBsonID(raw["$ID"])) - ch.TypeName = extractString(raw["$Type"]) - ch.ChannelName = extractString(raw["ChannelName"]) - ch.Description = extractString(raw["Description"]) - - // Parse Messages - messages := extractBsonArray(raw["Messages"]) - for _, msg := range messages { - if msgMap := extractBsonMap(msg); msgMap != nil { - ch.Messages = append(ch.Messages, parseBusinessEventMessage(msgMap)) - } - } - - return ch -} - -// parseBusinessEventMessage parses a BusinessEvents$Message from a BSON map. -func parseBusinessEventMessage(raw map[string]any) *model.BusinessEventMessage { - msg := &model.BusinessEventMessage{} - msg.ID = model.ID(extractBsonID(raw["$ID"])) - msg.TypeName = extractString(raw["$Type"]) - msg.MessageName = extractString(raw["MessageName"]) - msg.Description = extractString(raw["Description"]) - msg.CanPublish = extractBool(raw["CanPublish"], false) - msg.CanSubscribe = extractBool(raw["CanSubscribe"], false) - - // Parse Attributes - attrs := extractBsonArray(raw["Attributes"]) - for _, a := range attrs { - if aMap := extractBsonMap(a); aMap != nil { - msg.Attributes = append(msg.Attributes, parseBusinessEventAttribute(aMap)) - } - } - - return msg -} - -// parseBusinessEventAttribute parses a BusinessEvents$MessageAttribute from a BSON map. -func parseBusinessEventAttribute(raw map[string]any) *model.BusinessEventAttribute { - attr := &model.BusinessEventAttribute{} - attr.ID = model.ID(extractBsonID(raw["$ID"])) - attr.TypeName = extractString(raw["$Type"]) - attr.AttributeName = extractString(raw["AttributeName"]) - attr.Description = extractString(raw["Description"]) - - // Parse AttributeType — extract kind from the nested $Type field - // e.g., "DomainModels$LongAttributeType" → "Long" - if atRaw := extractBsonMap(raw["AttributeType"]); atRaw != nil { - typeName := extractString(atRaw["$Type"]) - attr.AttributeType = attributeTypeFromBsonType(typeName) - } - - return attr -} - -// attributeTypeFromBsonType converts a BSON $Type like "DomainModels$LongAttributeType" to "Long". -func attributeTypeFromBsonType(bsonType string) string { - switch bsonType { - case "DomainModels$LongAttributeType": - return "Long" - case "DomainModels$StringAttributeType": - return "String" - case "DomainModels$IntegerAttributeType": - return "Integer" - case "DomainModels$BooleanAttributeType": - return "Boolean" - case "DomainModels$DateTimeAttributeType": - return "DateTime" - case "DomainModels$DecimalAttributeType": - return "Decimal" - case "DomainModels$AutoNumberAttributeType": - return "AutoNumber" - case "DomainModels$BinaryAttributeType": - return "Binary" - default: - return bsonType - } -} - -// parseServiceOperation parses a BusinessEvents$ServiceOperation from a BSON map. -func parseServiceOperation(raw map[string]any) *model.ServiceOperation { - op := &model.ServiceOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.TypeName = extractString(raw["$Type"]) - op.MessageName = extractString(raw["MessageName"]) - op.Operation = extractString(raw["Operation"]) - op.Entity = extractString(raw["Entity"]) - op.Microflow = extractString(raw["Microflow"]) - return op -} diff --git a/sdk/mpr/parser_customblob.go b/sdk/mpr/parser_customblob.go deleted file mode 100644 index 30b46e238a..0000000000 --- a/sdk/mpr/parser_customblob.go +++ /dev/null @@ -1,295 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Parsing of CustomBlobDocuments$CustomBlobDocument units. -// -// The agent-editor Studio Pro extension (Mendix 11.9+) stores all of its -// documents — Agent, Model, Knowledge Base, Consumed MCP Service — as -// generic CustomBlobDocument units. They share the same BSON wrapper and -// are discriminated by the CustomDocumentType field. The actual document -// payload lives in a JSON string in the Contents field. -// -// This file provides the generic wrapper decode plus type-specific -// decoders for each inner JSON schema. -package mpr - -import ( - "encoding/json" - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/agenteditor" - - "go.mongodb.org/mongo-driver/bson" -) - -// customBlobDocType is the BSON $Type of the wrapper. -const customBlobDocType = "CustomBlobDocuments$CustomBlobDocument" - -// rawCustomBlobDoc is the decoded BSON wrapper (fields we care about). -type rawCustomBlobDoc struct { - Name string - Documentation string - Excluded bool - ExportLevel string - CustomDocumentType string - Contents string // JSON payload -} - -// parseCustomBlobWrapper decodes the outer CustomBlobDocument BSON wrapper. -// Returns a rawCustomBlobDoc or an error. -func parseCustomBlobWrapper(contents []byte) (*rawCustomBlobDoc, error) { - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal CustomBlobDocument BSON: %w", err) - } - - out := &rawCustomBlobDoc{} - if v, ok := raw["Name"].(string); ok { - out.Name = v - } - if v, ok := raw["Documentation"].(string); ok { - out.Documentation = v - } - if v, ok := raw["Excluded"].(bool); ok { - out.Excluded = v - } - if v, ok := raw["ExportLevel"].(string); ok { - out.ExportLevel = v - } - if v, ok := raw["CustomDocumentType"].(string); ok { - out.CustomDocumentType = v - } - if v, ok := raw["Contents"].(string); ok { - out.Contents = v - } - return out, nil -} - -// parseAgentEditorModel parses a CustomBlobDocument with -// CustomDocumentType == "agenteditor.model" into an agenteditor.Model. -func (r *Reader) parseAgentEditorModel(unitID, containerID string, contents []byte) (*agenteditor.Model, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - wrap, err := parseCustomBlobWrapper(contents) - if err != nil { - return nil, err - } - if wrap.CustomDocumentType != agenteditor.CustomTypeModel { - return nil, fmt.Errorf("unit %s is not an agent-editor model (CustomDocumentType=%q)", - unitID, wrap.CustomDocumentType) - } - - m := &agenteditor.Model{} - m.ID = model.ID(unitID) - m.TypeName = customBlobDocType - m.ContainerID = model.ID(containerID) - m.Name = wrap.Name - m.Documentation = wrap.Documentation - m.Excluded = wrap.Excluded - m.ExportLevel = wrap.ExportLevel - - // Decode the Contents JSON payload. - if wrap.Contents != "" { - var payload struct { - Type string `json:"type"` - Name string `json:"name"` - DisplayName string `json:"displayName"` - Provider string `json:"provider"` - ProviderFields struct { - Environment string `json:"environment"` - DeepLinkURL string `json:"deepLinkURL"` - KeyID string `json:"keyId"` - KeyName string `json:"keyName"` - ResourceName string `json:"resourceName"` - Key *agenteditor.ConstantRef `json:"key"` - } `json:"providerFields"` - } - if err := json.Unmarshal([]byte(wrap.Contents), &payload); err != nil { - return nil, fmt.Errorf("failed to unmarshal agent-editor Model Contents JSON: %w", err) - } - - m.Type = payload.Type - m.InnerName = payload.Name - m.DisplayName = payload.DisplayName - m.Provider = payload.Provider - m.Environment = payload.ProviderFields.Environment - m.DeepLinkURL = payload.ProviderFields.DeepLinkURL - m.KeyID = payload.ProviderFields.KeyID - m.KeyName = payload.ProviderFields.KeyName - m.ResourceName = payload.ProviderFields.ResourceName - m.Key = payload.ProviderFields.Key - } - - return m, nil -} - -// parseAgentEditorKnowledgeBase parses a CustomBlobDocument with -// CustomDocumentType == "agenteditor.knowledgebase" into an -// agenteditor.KnowledgeBase. -func (r *Reader) parseAgentEditorKnowledgeBase(unitID, containerID string, contents []byte) (*agenteditor.KnowledgeBase, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - wrap, err := parseCustomBlobWrapper(contents) - if err != nil { - return nil, err - } - if wrap.CustomDocumentType != agenteditor.CustomTypeKnowledgeBase { - return nil, fmt.Errorf("unit %s is not an agent-editor knowledge base (CustomDocumentType=%q)", - unitID, wrap.CustomDocumentType) - } - - k := &agenteditor.KnowledgeBase{} - k.ID = model.ID(unitID) - k.TypeName = customBlobDocType - k.ContainerID = model.ID(containerID) - k.Name = wrap.Name - k.Documentation = wrap.Documentation - k.Excluded = wrap.Excluded - k.ExportLevel = wrap.ExportLevel - - if wrap.Contents != "" { - var payload struct { - Name string `json:"name"` - Provider string `json:"provider"` - ProviderFields struct { - Environment string `json:"environment"` - DeepLinkURL string `json:"deepLinkURL"` - KeyID string `json:"keyId"` - KeyName string `json:"keyName"` - ModelDisplayName string `json:"modelDisplayName"` - ModelName string `json:"modelName"` - Key *agenteditor.ConstantRef `json:"key"` - } `json:"providerFields"` - } - if err := json.Unmarshal([]byte(wrap.Contents), &payload); err != nil { - return nil, fmt.Errorf("failed to unmarshal agent-editor KnowledgeBase Contents JSON: %w", err) - } - k.Provider = payload.Provider - k.Environment = payload.ProviderFields.Environment - k.DeepLinkURL = payload.ProviderFields.DeepLinkURL - k.KeyID = payload.ProviderFields.KeyID - k.KeyName = payload.ProviderFields.KeyName - k.ModelDisplayName = payload.ProviderFields.ModelDisplayName - k.ModelName = payload.ProviderFields.ModelName - k.Key = payload.ProviderFields.Key - } - - return k, nil -} - -// parseAgentEditorConsumedMCPService parses a CustomBlobDocument with -// CustomDocumentType == "agenteditor.consumedMCPService" into an -// agenteditor.ConsumedMCPService. -func (r *Reader) parseAgentEditorConsumedMCPService(unitID, containerID string, contents []byte) (*agenteditor.ConsumedMCPService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - wrap, err := parseCustomBlobWrapper(contents) - if err != nil { - return nil, err - } - if wrap.CustomDocumentType != agenteditor.CustomTypeConsumedMCPService { - return nil, fmt.Errorf("unit %s is not an agent-editor consumed MCP service (CustomDocumentType=%q)", - unitID, wrap.CustomDocumentType) - } - - c := &agenteditor.ConsumedMCPService{} - c.ID = model.ID(unitID) - c.TypeName = customBlobDocType - c.ContainerID = model.ID(containerID) - c.Name = wrap.Name - c.Documentation = wrap.Documentation - c.Excluded = wrap.Excluded - c.ExportLevel = wrap.ExportLevel - - if wrap.Contents != "" { - var payload struct { - ProtocolVersion string `json:"protocolVersion"` - Documentation string `json:"documentation"` - Version string `json:"version"` - ConnectionTimeoutSeconds int `json:"connectionTimeoutSeconds"` - } - if err := json.Unmarshal([]byte(wrap.Contents), &payload); err != nil { - return nil, fmt.Errorf("failed to unmarshal agent-editor ConsumedMCPService Contents JSON: %w", err) - } - c.ProtocolVersion = payload.ProtocolVersion - c.InnerDocumentation = payload.Documentation - c.Version = payload.Version - c.ConnectionTimeoutSeconds = payload.ConnectionTimeoutSeconds - } - - return c, nil -} - -// parseAgentEditorAgent parses a CustomBlobDocument with -// CustomDocumentType == "agenteditor.agent" into an agenteditor.Agent. -func (r *Reader) parseAgentEditorAgent(unitID, containerID string, contents []byte) (*agenteditor.Agent, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - wrap, err := parseCustomBlobWrapper(contents) - if err != nil { - return nil, err - } - if wrap.CustomDocumentType != agenteditor.CustomTypeAgent { - return nil, fmt.Errorf("unit %s is not an agent-editor agent (CustomDocumentType=%q)", - unitID, wrap.CustomDocumentType) - } - - a := &agenteditor.Agent{} - a.ID = model.ID(unitID) - a.TypeName = customBlobDocType - a.ContainerID = model.ID(containerID) - a.Name = wrap.Name - a.Documentation = wrap.Documentation - a.Excluded = wrap.Excluded - a.ExportLevel = wrap.ExportLevel - - if wrap.Contents != "" { - // Decode the fields we know about; unknown fields are ignored so - // the parser stays forward-compatible with editor updates. - var payload struct { - Description string `json:"description"` - SystemPrompt string `json:"systemPrompt"` - UserPrompt string `json:"userPrompt"` - UsageType string `json:"usageType"` - Variables []agenteditor.AgentVar `json:"variables"` - Tools []agenteditor.AgentTool `json:"tools"` - KnowledgebaseTools []agenteditor.AgentKBTool `json:"knowledgebaseTools"` - Model *agenteditor.DocRef `json:"model"` - Entity *agenteditor.DocRef `json:"entity"` - MaxTokens *int `json:"maxTokens"` - ToolChoice string `json:"toolChoice"` - Temperature *float64 `json:"temperature"` - TopP *float64 `json:"topP"` - } - if err := json.Unmarshal([]byte(wrap.Contents), &payload); err != nil { - return nil, fmt.Errorf("failed to unmarshal agent-editor Agent Contents JSON: %w", err) - } - a.Description = payload.Description - a.SystemPrompt = payload.SystemPrompt - a.UserPrompt = payload.UserPrompt - a.UsageType = payload.UsageType - a.Variables = payload.Variables - a.Tools = payload.Tools - a.KBTools = payload.KnowledgebaseTools - a.Model = payload.Model - a.Entity = payload.Entity - a.MaxTokens = payload.MaxTokens - a.ToolChoice = payload.ToolChoice - a.Temperature = payload.Temperature - a.TopP = payload.TopP - } - - return a, nil -} diff --git a/sdk/mpr/parser_datatransformer.go b/sdk/mpr/parser_datatransformer.go deleted file mode 100644 index fbad192dc1..0000000000 --- a/sdk/mpr/parser_datatransformer.go +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseDataTransformer parses a DataTransformers$DataTransformer from BSON. -func (r *Reader) parseDataTransformer(unitID, containerID string, contents []byte) (*model.DataTransformer, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - dt := &model.DataTransformer{} - dt.ID = model.ID(unitID) - dt.TypeName = "DataTransformers$DataTransformer" - dt.ContainerID = model.ID(containerID) - dt.Name = extractString(raw["Name"]) - dt.Excluded = extractBool(raw["Excluded"], false) - - // Parse Source - if srcMap := extractBsonMap(raw["Source"]); srcMap != nil { - srcType := extractString(srcMap["$Type"]) - switch srcType { - case "DataTransformers$JsonSource": - dt.SourceType = "JSON" - dt.SourceJSON = extractString(srcMap["Content"]) - case "DataTransformers$XmlSource": - dt.SourceType = "XML" - dt.SourceJSON = extractString(srcMap["Content"]) - } - } - - // Parse Steps - steps := extractBsonArray(raw["Steps"]) - for _, step := range steps { - stepMap, ok := step.(map[string]any) - if !ok { - continue - } - if extractString(stepMap["$Type"]) != "DataTransformers$Step" { - continue - } - actionMap := extractBsonMap(stepMap["Action"]) - if actionMap == nil { - continue - } - actionType := extractString(actionMap["$Type"]) - s := &model.DataTransformerStep{} - switch actionType { - case "DataTransformers$JsltAction": - s.Technology = "JSLT" - s.Expression = extractString(actionMap["Jslt"]) - case "DataTransformers$XsltAction": - s.Technology = "XSLT" - s.Expression = extractString(actionMap["Xslt"]) - default: - s.Technology = actionType - } - dt.Steps = append(dt.Steps, s) - } - - return dt, nil -} diff --git a/sdk/mpr/parser_dbconnection.go b/sdk/mpr/parser_dbconnection.go deleted file mode 100644 index 1b3192e12e..0000000000 --- a/sdk/mpr/parser_dbconnection.go +++ /dev/null @@ -1,136 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "github.com/mendixlabs/mxcli/mdl/dbconnector" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// parseDBConnection parses a DatabaseConnector$DatabaseConnection from BSON. -func (r *Reader) parseDBConnection(unitID, containerID string, contents []byte) (*model.DatabaseConnection, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - conn := &model.DatabaseConnection{} - conn.ID = model.ID(unitID) - conn.TypeName = "DatabaseConnector$DatabaseConnection" - conn.ContainerID = model.ID(containerID) - - conn.Name = extractString(raw["Name"]) - conn.DatabaseType = extractString(raw["DatabaseType"]) - conn.ConnectionString = extractString(raw["ConnectionString"]) - conn.UserName = extractString(raw["UserName"]) - conn.Password = extractString(raw["Password"]) - conn.Documentation = extractString(raw["Documentation"]) - conn.Excluded = extractBool(raw["Excluded"], false) - conn.ExportLevel = extractString(raw["ExportLevel"]) - - // Parse ConnectionInput.Value (actual JDBC URL for Studio Pro) - if ci := extractBsonMap(raw["ConnectionInput"]); ci != nil { - conn.ConnectionInputValue = extractString(ci["Value"]) - } - - // Parse Queries - queries := extractBsonArray(raw["Queries"]) - for _, q := range queries { - if qMap := extractBsonMap(q); qMap != nil { - conn.Queries = append(conn.Queries, parseDBQuery(qMap)) - } - } - - return conn, nil -} - -func parseDBQuery(raw map[string]any) *model.DatabaseQuery { - q := &model.DatabaseQuery{} - q.ID = model.ID(extractBsonID(raw["$ID"])) - q.TypeName = extractString(raw["$Type"]) - q.Name = extractString(raw["Name"]) - q.SQL = extractString(raw["Query"]) - // Mendix 11.13 replaced the integer QueryType with the `Type` string enum; - // read whichever key this project stores so a round-trip preserves it. - q.QueryTypeName = extractString(raw[dbconnector.TypeKey]) - if q.QueryTypeName != "" { - q.QueryType = dbconnector.LegacyQueryTypeFor(q.QueryTypeName) - } else { - q.QueryType = extractInt(raw[dbconnector.QueryTypeKey]) - } - - // Parse TableMappings - mappings := extractBsonArray(raw["TableMappings"]) - for _, m := range mappings { - if mMap := extractBsonMap(m); mMap != nil { - q.TableMappings = append(q.TableMappings, parseDBTableMapping(mMap)) - } - } - - // Parse Parameters - params := extractBsonArray(raw["Parameters"]) - for _, p := range params { - if pMap := extractBsonMap(p); pMap != nil { - q.Parameters = append(q.Parameters, parseDBQueryParameter(pMap)) - } - } - - return q -} - -func parseDBQueryParameter(raw map[string]any) *model.DatabaseQueryParameter { - p := &model.DatabaseQueryParameter{} - p.ID = model.ID(extractBsonID(raw["$ID"])) - p.TypeName = extractString(raw["$Type"]) - p.ParameterName = extractString(raw["ParameterName"]) - p.DefaultValue = extractString(raw["DefaultValue"]) - p.EmptyValueBecomesNull = extractBool(raw["EmptyValueBecomesNull"], false) - - // DataType is a nested object like {"$Type": "DataTypes$IntegerType", "$ID": "..."} - if dt := extractBsonMap(raw["DataType"]); dt != nil { - p.DataType = extractString(dt["$Type"]) - } - - return p -} - -func parseDBTableMapping(raw map[string]any) *model.DatabaseTableMapping { - m := &model.DatabaseTableMapping{} - m.ID = model.ID(extractBsonID(raw["$ID"])) - m.TypeName = extractString(raw["$Type"]) - m.Entity = extractString(raw["Entity"]) - m.TableName = extractString(raw["TableName"]) - - // Parse Columns - columns := extractBsonArray(raw["Columns"]) - for _, c := range columns { - if cMap := extractBsonMap(c); cMap != nil { - m.Columns = append(m.Columns, parseDBColumnMapping(cMap)) - } - } - - return m -} - -func parseDBColumnMapping(raw map[string]any) *model.DatabaseColumnMapping { - c := &model.DatabaseColumnMapping{} - c.ID = model.ID(extractBsonID(raw["$ID"])) - c.TypeName = extractString(raw["$Type"]) - c.Attribute = extractString(raw["Attribute"]) - c.ColumnName = extractString(raw["ColumnName"]) - - // SqlDataType is polymorphic: SimpleSqlDataType or LimitedLengthSqlDataType - if dt := extractBsonMap(raw["SqlDataType"]); dt != nil { - c.SqlDataType = extractString(dt["$Type"]) - } - - return c -} diff --git a/sdk/mpr/parser_domainmodel.go b/sdk/mpr/parser_domainmodel.go deleted file mode 100644 index 26a22df457..0000000000 --- a/sdk/mpr/parser_domainmodel.go +++ /dev/null @@ -1,831 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parseDomainModel(unitID, containerID string, contents []byte) (*domainmodel.DomainModel, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - dm := &domainmodel.DomainModel{} - dm.ID = model.ID(unitID) - dm.TypeName = "DomainModels$DomainModel" - dm.ContainerID = model.ID(containerID) - - // Parse entities - use extractBsonArray to handle Mendix array format - entities := extractBsonArray(raw["Entities"]) - for _, e := range entities { - if entityMap, ok := e.(map[string]any); ok { - entity := parseEntity(entityMap) - dm.Entities = append(dm.Entities, entity) - } - } - - // Parse associations - associations := extractBsonArray(raw["Associations"]) - for _, a := range associations { - if assocMap, ok := a.(map[string]any); ok { - assoc := parseAssociation(assocMap) - dm.Associations = append(dm.Associations, assoc) - } - } - - // Parse cross-module associations - crossAssocs := extractBsonArray(raw["CrossAssociations"]) - for _, ca := range crossAssocs { - if caMap, ok := ca.(map[string]any); ok { - crossAssoc := parseCrossAssociation(caMap) - dm.CrossAssociations = append(dm.CrossAssociations, crossAssoc) - } - } - - // Parse annotations - annotations := extractBsonArray(raw["Annotations"]) - for _, a := range annotations { - if annotMap, ok := a.(map[string]any); ok { - annot := parseAnnotation(annotMap) - dm.Annotations = append(dm.Annotations, annot) - } - } - - return dm, nil -} - -func parseEntity(raw map[string]any) *domainmodel.Entity { - entity := &domainmodel.Entity{} - - // Use extractBsonID to handle various ID formats (string, binary, base64) - entity.ID = model.ID(extractBsonID(raw["$ID"])) - if typeName, ok := raw["$Type"].(string); ok { - entity.TypeName = typeName - } - if name, ok := raw["Name"].(string); ok { - entity.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - entity.Documentation = doc - } - - // Parse location - handle string "x;y" format or map format - if locStr, ok := raw["Location"].(string); ok { - // Parse "x;y" format - parts := strings.Split(locStr, ";") - if len(parts) == 2 { - fmt.Sscanf(parts[0], "%d", &entity.Location.X) - fmt.Sscanf(parts[1], "%d", &entity.Location.Y) - } - } else if loc, ok := raw["Location"].(map[string]any); ok { - entity.Location.X = extractInt(loc["x"]) - entity.Location.Y = extractInt(loc["y"]) - } - - // Parse persistable - default to true if not specified - entity.Persistable = true - if persistable, ok := raw["Persistable"].(bool); ok { - entity.Persistable = persistable - } - - // Parse source (for view/external entities) - if source, ok := raw["Source"].(map[string]any); ok { - if sourceType, ok := source["$Type"].(string); ok { - entity.Source = sourceType - } - // Preserve the Source object's $ID to avoid CE-6770 on updates - if sourceID := extractID(source["$ID"]); sourceID != "" { - entity.SourceObjectID = model.ID(sourceID) - } - // For view entities, extract the OQL query directly - if oqlQuery, ok := source["OqlQuery"].(string); ok { - entity.OqlQuery = oqlQuery - } - // For view entities, extract the source document reference - if sourceDocRef, ok := source["SourceDocument"].(string); ok { - entity.SourceDocumentRef = sourceDocRef - } - // External entity sources (three flavors) - switch entity.Source { - case "Rest$ODataRemoteEntitySource": - entity.RemoteServiceName = extractString(source["SourceDocument"]) - entity.RemoteEntitySet = extractString(source["EntitySet"]) - entity.RemoteEntityName = extractString(source["RemoteName"]) - entity.Countable = extractBool(source["Countable"], false) - entity.Creatable = extractBool(source["Creatable"], false) - entity.Deletable = extractBool(source["Deletable"], false) - entity.Updatable = extractBool(source["Updatable"], false) - entity.SkipSupported = extractBool(source["SkipSupported"], false) - entity.TopSupported = extractBool(source["TopSupported"], false) - entity.CreateChangeLocally = extractBool(source["CreateChangeLocally"], false) - parseRemoteKey(source, entity) - case "Rest$ODataEntityTypeSource": - entity.RemoteServiceName = extractString(source["SourceDocument"]) - entity.RemoteEntityName = extractString(source["EntityTypeName"]) - entity.IsOpen = extractBool(source["IsOpen"], false) - parseRemoteKey(source, entity) - case "Rest$ODataPrimitiveCollectionEntitySource": - entity.RemoteServiceName = extractString(source["SourceDocument"]) - } - } - - // Parse generalization (parent entity) - field is MaybeGeneralization in newer formats - genField := raw["Generalization"] - if genField == nil { - genField = raw["MaybeGeneralization"] - } - if genField != nil { - if genMap, ok := genField.(map[string]any); ok { - if genID := extractBsonID(genMap["$ID"]); genID != "" { - entity.GeneralizationID = model.ID(genID) - } - // Handle qualified name reference (e.g., "System.User") - if genRef, ok := genMap["Generalization"].(string); ok { - entity.GeneralizationRef = genRef - } - // For NoGeneralization, system flags are stored inside the generalization object. - // Mendix < 11.9 uses HasOwner/HasChangedBy/HasChangedDate/HasCreatedDate. - // Mendix >= 11.9 uses HasOwnerAttr/HasChangedByAttr/HasChangedDateAttr/HasCreatedDateAttr. - if genType, ok := genMap["$Type"].(string); ok && genType == "DomainModels$NoGeneralization" { - if persistable, ok := genMap["Persistable"].(bool); ok { - entity.Persistable = persistable - } - entity.HasOwner = extractBool(genMap["HasOwner"], false) || extractBool(genMap["HasOwnerAttr"], false) - entity.HasChangedBy = extractBool(genMap["HasChangedBy"], false) || extractBool(genMap["HasChangedByAttr"], false) - entity.HasChangedDate = extractBool(genMap["HasChangedDate"], false) || extractBool(genMap["HasChangedDateAttr"], false) - entity.HasCreatedDate = extractBool(genMap["HasCreatedDate"], false) || extractBool(genMap["HasCreatedDateAttr"], false) - } - } - } - - // Fallback: check both old and new field names at entity level - if extractBool(raw["HasOwner"], false) || extractBool(raw["HasOwnerAttr"], false) { - entity.HasOwner = true - } - if extractBool(raw["HasChangedBy"], false) || extractBool(raw["HasChangedByAttr"], false) { - entity.HasChangedBy = true - } - if extractBool(raw["HasChangedDate"], false) || extractBool(raw["HasChangedDateAttr"], false) { - entity.HasChangedDate = true - } - if extractBool(raw["HasCreatedDate"], false) || extractBool(raw["HasCreatedDateAttr"], false) { - entity.HasCreatedDate = true - } - - // Parse attributes using extractBsonArray - attrs := extractBsonArray(raw["Attributes"]) - for _, a := range attrs { - if attrMap, ok := a.(map[string]any); ok { - attr := parseAttribute(attrMap) - entity.Attributes = append(entity.Attributes, attr) - } - } - - // Parse indexes - indexes := extractBsonArray(raw["Indexes"]) - for _, i := range indexes { - if indexMap, ok := i.(map[string]any); ok { - index := parseIndex(indexMap) - entity.Indexes = append(entity.Indexes, index) - } - } - - // Parse access rules - rules := extractBsonArray(raw["AccessRules"]) - for _, r := range rules { - if ruleMap, ok := r.(map[string]any); ok { - rule := parseAccessRule(ruleMap) - entity.AccessRules = append(entity.AccessRules, rule) - } - } - - // Parse validation rules - validations := extractBsonArray(raw["ValidationRules"]) - for _, v := range validations { - if validMap, ok := v.(map[string]any); ok { - validation := parseValidationRule(validMap) - entity.ValidationRules = append(entity.ValidationRules, validation) - } - } - - // Parse event handlers — field is "Events" in BSON (not "EventHandlers") - handlers := extractBsonArray(raw["Events"]) - if len(handlers) == 0 { - handlers = extractBsonArray(raw["EventHandlers"]) // fallback for older format - } - for _, h := range handlers { - if handlerMap, ok := h.(map[string]any); ok { - handler := parseEventHandler(handlerMap) - entity.EventHandlers = append(entity.EventHandlers, handler) - } - } - - return entity -} - -// parseRemoteKey reads the Rest$ODataKey block from a Source map and populates -// entity.RemoteKeyParts. -func parseRemoteKey(source map[string]any, entity *domainmodel.Entity) { - keyMap, ok := source["Key"].(map[string]any) - if !ok { - return - } - partsArr := extractBsonArray(keyMap["Parts"]) - for _, p := range partsArr { - pMap, ok := p.(map[string]any) - if !ok { - continue - } - kp := &domainmodel.RemoteKeyPart{ - Name: extractString(pMap["EntityKeyPartName"]), - RemoteName: extractString(pMap["Name"]), - RemoteType: extractString(pMap["RemoteType"]), - } - if typeMap, ok := pMap["Type"].(map[string]any); ok { - kp.Type = parseAttributeType(typeMap) - } - entity.RemoteKeyParts = append(entity.RemoteKeyParts, kp) - } -} - -func parseAttribute(raw map[string]any) *domainmodel.Attribute { - attr := &domainmodel.Attribute{} - - attr.ID = model.ID(extractBsonID(raw["$ID"])) - attr.TypeName = extractString(raw["$Type"]) - attr.Name = extractString(raw["Name"]) - attr.Documentation = extractString(raw["Documentation"]) - - // Parse attribute type - Mendix uses "NewType" field - if attrType, ok := raw["NewType"].(map[string]any); ok { - attr.Type = parseAttributeType(attrType) - } else if attrType, ok := raw["Type"].(map[string]any); ok { - // Fallback to "Type" for older format - attr.Type = parseAttributeType(attrType) - } - - // Parse default value - if val, ok := raw["Value"].(map[string]any); ok { - attr.Value = parseAttributeValue(val) - - // For external entities, the Value is a Rest$ODataMappedValue that - // carries the OData property name, type, and capability flags. - switch extractString(val["$Type"]) { - case "Rest$ODataMappedValue": - attr.RemoteName = extractString(val["RemoteName"]) - attr.RemoteType = extractString(val["RemoteType"]) - attr.Filterable = extractBool(val["Filterable"], false) - attr.Sortable = extractBool(val["Sortable"], false) - attr.Creatable = extractBool(val["Creatable"], false) - attr.Updatable = extractBool(val["Updatable"], false) - case "Rest$ODataMappedPrimitiveCollectionValue": - attr.RemoteName = extractString(val["RemoteName"]) - attr.RemoteType = extractString(val["RemoteType"]) - attr.IsPrimitiveCollection = true - } - } - - return attr -} - -func parseAttributeValue(raw map[string]any) *domainmodel.AttributeValue { - typeName := extractString(raw["$Type"]) - defaultValue := extractString(raw["DefaultValue"]) - valueID := model.ID(extractBsonID(raw["$ID"])) - - switch typeName { - case "DomainModels$StoredValue": - val := &domainmodel.AttributeValue{ - Type: "StoredValue", - DefaultValue: defaultValue, - } - val.ID = valueID - return val - case "DomainModels$CalculatedValue": - val := &domainmodel.AttributeValue{ - Type: "CalculatedValue", - MicroflowID: model.ID(extractBsonID(raw["Microflow"])), - MicroflowName: extractString(raw["Microflow"]), - } - val.ID = valueID - return val - case "DomainModels$OqlViewValue": - val := &domainmodel.AttributeValue{ - Type: "OqlViewValue", - ViewReference: extractString(raw["Reference"]), - } - val.ID = valueID - return val - case "Rest$ODataMappedValue": - val := &domainmodel.AttributeValue{ - Type: "ODataMappedValue", - DefaultValue: extractString(raw["DefaultValueDesignTime"]), - } - val.ID = valueID - return val - case "Rest$ODataMappedPrimitiveCollectionValue": - val := &domainmodel.AttributeValue{ - Type: "ODataMappedPrimitiveCollectionValue", - DefaultValue: extractString(raw["DefaultValueDesignTime"]), - } - val.ID = valueID - return val - default: - val := &domainmodel.AttributeValue{ - DefaultValue: defaultValue, - } - val.ID = valueID - return val - } -} - -func parseAttributeType(raw map[string]any) domainmodel.AttributeType { - typeName, _ := raw["$Type"].(string) - typeID := model.ID(extractBsonID(raw["$ID"])) - - switch typeName { - case "DomainModels$StringAttributeType": - t := &domainmodel.StringAttributeType{} - t.ID = typeID - // Issue #583: Studio Pro stores Length as BSON int64; mxcli's writer - // emits int32. extractInt accepts both (plus int and float64). - t.Length = extractInt(raw["Length"]) - return t - case "DomainModels$IntegerAttributeType": - t := &domainmodel.IntegerAttributeType{} - t.ID = typeID - return t - case "DomainModels$LongAttributeType": - t := &domainmodel.LongAttributeType{} - t.ID = typeID - return t - case "DomainModels$DecimalAttributeType": - t := &domainmodel.DecimalAttributeType{} - t.ID = typeID - return t - case "DomainModels$BooleanAttributeType": - t := &domainmodel.BooleanAttributeType{} - t.ID = typeID - return t - case "DomainModels$DateTimeAttributeType": - localize, ok := raw["LocalizeDate"].(bool) - if !ok || localize { - // Default to DateTime when LocalizeDate is absent or true - t := &domainmodel.DateTimeAttributeType{LocalizeDate: true} - t.ID = typeID - return t - } - // LocalizeDate explicitly false means date-only type - dt := &domainmodel.DateAttributeType{} - dt.ID = typeID - return dt - case "DomainModels$EnumerationAttributeType": - t := &domainmodel.EnumerationAttributeType{} - t.ID = typeID - // Enumeration is stored as qualified name string (BY_NAME_REFERENCE) - if enumRef, ok := raw["Enumeration"].(string); ok { - t.EnumerationRef = enumRef - // Also store in EnumerationID for backward compatibility - t.EnumerationID = model.ID(enumRef) - } - return t - case "DomainModels$AutoNumberAttributeType": - t := &domainmodel.AutoNumberAttributeType{} - t.ID = typeID - return t - case "DomainModels$BinaryAttributeType": - t := &domainmodel.BinaryAttributeType{} - t.ID = typeID - return t - case "DomainModels$HashedStringAttributeType": - t := &domainmodel.HashedStringAttributeType{} - t.ID = typeID - return t - default: - t := &domainmodel.StringAttributeType{} // Default fallback - t.ID = typeID - return t - } -} - -func parseAssociation(raw map[string]any) *domainmodel.Association { - assoc := &domainmodel.Association{} - - assoc.ID = model.ID(extractBsonID(raw["$ID"])) - assoc.TypeName = extractString(raw["$Type"]) - assoc.Name = extractString(raw["Name"]) - assoc.Documentation = extractString(raw["Documentation"]) - assoc.ParentID = model.ID(extractBsonID(raw["ParentPointer"])) - assoc.ChildID = model.ID(extractBsonID(raw["ChildPointer"])) - assoc.Type = domainmodel.AssociationType(extractString(raw["Type"])) - assoc.Owner = domainmodel.AssociationOwner(extractString(raw["Owner"])) - if sf := extractString(raw["StorageFormat"]); sf != "" { - assoc.StorageFormat = domainmodel.AssociationStorageFormat(sf) - } else { - assoc.StorageFormat = domainmodel.StorageFormatTable - } - // The line anchors are read so the writer can put them back unchanged; every - // association write rebuilds the whole element, so a field not read here is - // a field destroyed on the next `alter association`. (issue #872) - assoc.ParentConnection = domainmodel.ParseConnectionPoint(extractString(raw["ParentConnection"])) - assoc.ChildConnection = domainmodel.ParseConnectionPoint(extractString(raw["ChildConnection"])) - - // Parse delete behavior - if deleteBehaviorRaw, ok := raw["DeleteBehavior"].(map[string]any); ok { - if parentType := extractString(deleteBehaviorRaw["ParentDeleteBehavior"]); parentType != "" { - assoc.ParentDeleteBehavior = &domainmodel.DeleteBehavior{ - Type: domainmodel.DeleteBehaviorType(parentType), - } - } - if childType := extractString(deleteBehaviorRaw["ChildDeleteBehavior"]); childType != "" { - assoc.ChildDeleteBehavior = &domainmodel.DeleteBehavior{ - Type: domainmodel.DeleteBehaviorType(childType), - // Read the refusal message back, or DESCRIBE cannot emit it and a - // describe -> exec round trip rebuilds an association whose runtime - // will not start (CapTrackV2 §1). - ErrorMessage: deleteBehaviorErrorMessage(deleteBehaviorRaw["ChildErrorMessage"]), - } - } - } - - // Parse OData remote association source - if sourceMap, ok := raw["Source"].(map[string]any); ok { - switch extractString(sourceMap["$Type"]) { - case "Rest$ODataRemoteAssociationSource": - assoc.Source = "Rest$ODataRemoteAssociationSource" - assoc.RemoteParentNavigationProperty = extractString(sourceMap["RemoteParentNavigationProperty"]) - assoc.RemoteChildNavigationProperty = extractString(sourceMap["RemoteChildNavigationProperty"]) - assoc.CreatableFromParent = extractBool(sourceMap["CreatableFromParent"], false) - assoc.CreatableFromChild = extractBool(sourceMap["CreatableFromChild"], false) - assoc.UpdatableFromParent = extractBool(sourceMap["UpdatableFromParent"], false) - assoc.UpdatableFromChild = extractBool(sourceMap["UpdatableFromChild"], false) - assoc.Navigability2 = extractString(sourceMap["Navigability2"]) - case "Rest$ODataPrimitiveCollectionAssociationSource": - assoc.Source = "Rest$ODataPrimitiveCollectionAssociationSource" - case "DomainModels$OqlViewAssociationSource": - // A view entity's association to a persistent entity. Reading it is - // not a convenience: an unread Source is written back as null on the - // next rewrite of this domain model, which turns a working project - // into CE6771 + CE6770 with no statement having asked for that. - assoc.Source = "DomainModels$OqlViewAssociationSource" - assoc.ViewSourceReference = extractString(sourceMap["Reference"]) - } - } - - return assoc -} - -func parseCrossAssociation(raw map[string]any) *domainmodel.CrossModuleAssociation { - ca := &domainmodel.CrossModuleAssociation{} - - ca.ID = model.ID(extractBsonID(raw["$ID"])) - ca.TypeName = extractString(raw["$Type"]) - ca.Name = extractString(raw["Name"]) - ca.Documentation = extractString(raw["Documentation"]) - ca.ParentID = model.ID(extractBsonID(raw["ParentPointer"])) - ca.ChildRef = extractString(raw["Child"]) - ca.Type = domainmodel.AssociationType(extractString(raw["Type"])) - ca.Owner = domainmodel.AssociationOwner(extractString(raw["Owner"])) - if sf := extractString(raw["StorageFormat"]); sf != "" { - ca.StorageFormat = domainmodel.AssociationStorageFormat(sf) - } else { - ca.StorageFormat = domainmodel.StorageFormatTable - } - - // Parse delete behavior - if deleteBehaviorRaw, ok := raw["DeleteBehavior"].(map[string]any); ok { - if parentType := extractString(deleteBehaviorRaw["ParentDeleteBehavior"]); parentType != "" { - ca.ParentDeleteBehavior = &domainmodel.DeleteBehavior{ - Type: domainmodel.DeleteBehaviorType(parentType), - } - } - if childType := extractString(deleteBehaviorRaw["ChildDeleteBehavior"]); childType != "" { - ca.ChildDeleteBehavior = &domainmodel.DeleteBehavior{ - Type: domainmodel.DeleteBehaviorType(childType), - ErrorMessage: deleteBehaviorErrorMessage(deleteBehaviorRaw["ChildErrorMessage"]), - } - } - } - - // A view entity pointing at an entity in ANOTHER module lands here rather - // than in parseAssociation, so the Source has to be read in both places. - if sourceMap, ok := raw["Source"].(map[string]any); ok { - if extractString(sourceMap["$Type"]) == "DomainModels$OqlViewAssociationSource" { - ca.Source = "DomainModels$OqlViewAssociationSource" - ca.ViewSourceReference = extractString(sourceMap["Reference"]) - } - } - - return ca -} - -func parseAnnotation(raw map[string]any) *domainmodel.Annotation { - annot := &domainmodel.Annotation{} - - annot.ID = model.ID(extractBsonID(raw["$ID"])) - annot.TypeName = extractString(raw["$Type"]) - annot.Caption = extractString(raw["Caption"]) - annot.Width = extractInt(raw["Width"]) - - // Studio Pro stores the position as the string "x;y", the same shape an - // entity's Location uses. Reading only the sub-document form returned (0,0) - // for every real annotation, so a rewrite piled them all in one corner. - if locStr, ok := raw["Location"].(string); ok { - parts := strings.Split(locStr, ";") - if len(parts) == 2 { - fmt.Sscanf(parts[0], "%d", &annot.Location.X) - fmt.Sscanf(parts[1], "%d", &annot.Location.Y) - } - } else if loc, ok := raw["Location"].(map[string]any); ok { - annot.Location.X = extractInt(loc["x"]) - annot.Location.Y = extractInt(loc["y"]) - } - - return annot -} - -func parseIndex(raw map[string]any) *domainmodel.Index { - index := &domainmodel.Index{} - - index.ID = model.ID(extractBsonID(raw["$ID"])) - index.Name = extractString(raw["Name"]) - - // Parse index attributes - attrs := extractBsonArray(raw["Attributes"]) - for _, a := range attrs { - if attrMap, ok := a.(map[string]any); ok { - // Try "AttributePointer" first (Mendix format), then "Attribute" - attrID := extractBsonID(attrMap["AttributePointer"]) - if attrID == "" { - attrID = extractBsonID(attrMap["Attribute"]) - } - if attrID != "" { - // Populate both AttributeIDs and Attributes for compatibility - index.AttributeIDs = append(index.AttributeIDs, model.ID(attrID)) - - // Parse as IndexAttribute with ascending/descending info - // Default to ascending (true) unless explicitly set to false - ascending := true - if asc, ok := attrMap["Ascending"].(bool); ok { - ascending = asc - } else if sortOrder := extractString(attrMap["SortOrder"]); sortOrder == "Descending" { - ascending = false - } - - indexAttr := &domainmodel.IndexAttribute{ - AttributeID: model.ID(attrID), - Ascending: ascending, - } - index.Attributes = append(index.Attributes, indexAttr) - } - } - } - - return index -} - -func parseAccessRule(raw map[string]any) *domainmodel.AccessRule { - rule := &domainmodel.AccessRule{} - - rule.ID = model.ID(extractBsonID(raw["$ID"])) - rule.AllowCreate = extractBool(raw["AllowCreate"], false) - rule.AllowRead = extractBool(raw["AllowRead"], false) - rule.AllowWrite = extractBool(raw["AllowWrite"], false) - rule.AllowDelete = extractBool(raw["AllowDelete"], false) - rule.XPathConstraint = extractString(raw["XPathConstraint"]) - - // Parse default member access rights - if dmr := extractString(raw["DefaultMemberAccessRights"]); dmr != "" { - rule.DefaultMemberAccessRights = domainmodel.MemberAccessRights(dmr) - } - - // Parse module roles - try both field names (AllowedModuleRoles for newer, ModuleRoles for older) - rolesField := raw["AllowedModuleRoles"] - if rolesField == nil { - rolesField = raw["ModuleRoles"] - } - roles := extractBsonArray(rolesField) - for _, r := range roles { - // Module roles can be BY_NAME (string) or BY_ID (binary) - if name, ok := r.(string); ok { - rule.ModuleRoleNames = append(rule.ModuleRoleNames, name) - rule.ModuleRoles = append(rule.ModuleRoles, model.ID(name)) - } else { - roleID := extractBsonID(r) - if roleID != "" { - rule.ModuleRoles = append(rule.ModuleRoles, model.ID(roleID)) - } - } - } - - // Parse member accesses - memberAccesses := extractBsonArray(raw["MemberAccesses"]) - for _, ma := range memberAccesses { - maMap := toMap(ma) - if maMap == nil { - continue - } - access := parseMemberAccess(maMap) - rule.MemberAccesses = append(rule.MemberAccesses, access) - } - - return rule -} - -func parseMemberAccess(raw map[string]any) *domainmodel.MemberAccess { - ma := &domainmodel.MemberAccess{} - ma.ID = model.ID(extractBsonID(raw["$ID"])) - - // Access rights - if ar := extractString(raw["AccessRights"]); ar != "" { - ma.AccessRights = domainmodel.MemberAccessRights(ar) - } - - // Attribute - BY_NAME reference (e.g., "Shop.Customer.FirstName") - if attr := extractString(raw["Attribute"]); attr != "" { - ma.AttributeName = attr - ma.AttributeID = model.ID(attr) - } - - // Association - BY_NAME reference (e.g., "Shop.Order_Customer") - if assoc := extractString(raw["Association"]); assoc != "" { - ma.AssociationName = assoc - ma.AssociationID = model.ID(assoc) - } - - return ma -} - -func parseValidationRule(raw map[string]any) *domainmodel.ValidationRule { - rule := &domainmodel.ValidationRule{} - - rule.ID = model.ID(extractBsonID(raw["$ID"])) - - // Attribute can be a qualified name like "DmTest.Cars.CarId" or an ID - attrRef := raw["Attribute"] - if attrID := extractBsonID(attrRef); attrID != "" { - rule.AttributeID = model.ID(attrID) - } else if attrName, ok := attrRef.(string); ok { - // Store qualified name as ID - will need to resolve later - rule.AttributeID = model.ID(attrName) - } - - // Get rule type from RuleInfo.$Type field - // e.g., "DomainModels$RequiredRuleInfo" -> "Required" - if ruleInfo, ok := raw["RuleInfo"].(map[string]any); ok { - ruleType := extractString(ruleInfo["$Type"]) - rule.Type = normalizeValidationRuleType(ruleType) - rule.Rule = parseValidationRuleInfo(rule.Type, ruleInfo) - } - - // Parse error message from "Message" field (not "ErrorMessage") - if errMsg, ok := raw["Message"].(map[string]any); ok { - rule.ErrorMessage = parseText(errMsg) - } else if errMsg, ok := raw["ErrorMessage"].(map[string]any); ok { - // Fallback for older format - rule.ErrorMessage = parseText(errMsg) - } - - return rule -} - -// parseValidationRuleInfo carries the rule's payload onto the model so a -// read-modify-write can rebuild it. -// -// Without this the reader reported the right TYPE and dropped everything that -// made the rule mean something, which is half of the silent RegEx→Required -// downgrade (the writer's fallback was the other half). A type with no case -// here yields a nil payload, which the writer treats as a refusal — the safe -// direction. -// -// The BSON keys are the STORAGE names, which differ from the SDK names for the -// regex reference: Studio Pro stores "RegExIdentifier", not "RegularExpression" -// (see CLAUDE.md, "modelsdk/gen Binds Some Properties Under the Wrong BSON Key"). -func parseValidationRuleInfo(ruleType string, raw map[string]any) domainmodel.ValidationRuleInfo { - switch ruleType { - case "RegEx": - info := &domainmodel.RegexValidationRuleInfo{ - RegularExpressionQualifiedName: extractString(raw["RegExIdentifier"]), - } - info.ID = model.ID(extractBsonID(raw["$ID"])) - return info - - case "Range": - info := &domainmodel.RangeValidationRuleInfo{ - UseMinValue: extractBool(raw["UseMinValue"], false), - UseMaxValue: extractBool(raw["UseMaxValue"], false), - MinAttributeQualifiedName: extractString(raw["MinAttribute"]), - MaxAttributeQualifiedName: extractString(raw["MaxAttribute"]), - } - if v := extractString(raw["MinValue"]); v != "" { - info.MinValue = &v - } - if v := extractString(raw["MaxValue"]); v != "" { - info.MaxValue = &v - } - info.ID = model.ID(extractBsonID(raw["$ID"])) - return info - - case "Required", "": - info := &domainmodel.RequiredValidationRuleInfo{} - info.ID = model.ID(extractBsonID(raw["$ID"])) - return info - - case "Unique": - info := &domainmodel.UniqueValidationRuleInfo{} - info.ID = model.ID(extractBsonID(raw["$ID"])) - return info - - default: - // MaxLength, EqualsTo — no model payload type, so the writer refuses to - // rewrite an entity carrying one rather than downgrading it. - return nil - } -} - -// normalizeValidationRuleType converts BSON type names to simple rule types. -// e.g., "DomainModels$RequiredRuleInfo" -> "Required" -func normalizeValidationRuleType(fullType string) string { - // Strip prefix "DomainModels$" - if idx := strings.Index(fullType, "$"); idx >= 0 { - fullType = fullType[idx+1:] - } - // Strip suffix "RuleInfo" - if strings.HasSuffix(fullType, "RuleInfo") { - fullType = fullType[:len(fullType)-8] - } - // Strip suffix "Rule" (for backward compatibility) - if strings.HasSuffix(fullType, "Rule") { - fullType = fullType[:len(fullType)-4] - } - return fullType -} - -func parseEventHandler(raw map[string]any) *domainmodel.EventHandler { - handler := &domainmodel.EventHandler{} - - handler.ID = model.ID(extractBsonID(raw["$ID"])) - handler.Moment = domainmodel.EventMoment(extractString(raw["Moment"])) - // BSON field is "Type" (e.g., "Commit", "Create", "Delete", "RollBack") - handler.Event = domainmodel.EventType(extractString(raw["Type"])) - if handler.Event == "" { - handler.Event = domainmodel.EventType(extractString(raw["Event"])) // fallback - } - // Microflow can be either a binary ID (BY_ID_REFERENCE) or a string (BY_NAME_REFERENCE) - if mfStr, ok := raw["Microflow"].(string); ok { - handler.MicroflowName = mfStr - } else { - handler.MicroflowID = model.ID(extractBsonID(raw["Microflow"])) - } - handler.RaiseErrorOnFalse = extractBool(raw["RaiseErrorOnFalse"], false) - // BSON field is "SendInputParameter" (not "PassEventObject") - handler.PassEventObject = extractBool(raw["SendInputParameter"], true) - if _, ok := raw["PassEventObject"]; ok { - handler.PassEventObject = extractBool(raw["PassEventObject"], true) // fallback - } - - return handler -} - -// parseMicroflow parses microflow contents from BSON. - -// deleteBehaviorErrorMessage reads the en_US text out of a delete behaviour's -// error message. The message is an ordinary Texts$Text; MDL carries one string. -func deleteBehaviorErrorMessage(raw any) string { - m, ok := raw.(map[string]any) - if !ok { - return "" - } - items, ok := m["Items"].([]any) - if !ok { - return "" - } - first := "" - for _, it := range items { - tr, ok := it.(map[string]any) - if !ok { - continue - } - text := extractString(tr["Text"]) - if extractString(tr["LanguageCode"]) == "en_US" { - return text - } - if first == "" { - first = text - } - } - return first -} diff --git a/sdk/mpr/parser_domainmodel_test.go b/sdk/mpr/parser_domainmodel_test.go deleted file mode 100644 index f021bbe33c..0000000000 --- a/sdk/mpr/parser_domainmodel_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// Issue #583: parseAttributeType silently dropped the Length value for -// StringAttributeType when Mendix Studio Pro stored it as BSON int64. -// The previous code only handled int32, so every String attribute in a -// Studio Pro-written MPR was reported as String(unlimited) / Length: 0. -// -// Studio Pro and mxcli can both store integers as int32 or int64 depending -// on encoder choice; the parser must handle every BSON numeric width. -func TestParseAttributeType_StringLength_BsonNumericWidths(t *testing.T) { - cases := []struct { - name string - length any - want int - }{ - {"int32 (mxcli writer)", int32(40), 40}, - {"int64 (Studio Pro writer)", int64(40), 40}, - {"int", int(40), 40}, - {"float64 (extended JSON)", float64(40), 40}, - {"missing field = unlimited", nil, 0}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - raw := map[string]any{ - "$Type": "DomainModels$StringAttributeType", - } - if tc.length != nil { - raw["Length"] = tc.length - } - at := parseAttributeType(raw) - st, ok := at.(*domainmodel.StringAttributeType) - if !ok { - t.Fatalf("parseAttributeType returned %T, want *StringAttributeType", at) - } - if st.Length != tc.want { - t.Errorf("Length = %d, want %d (input %T(%v))", st.Length, tc.want, tc.length, tc.length) - } - }) - } -} diff --git a/sdk/mpr/parser_enumeration.go b/sdk/mpr/parser_enumeration.go deleted file mode 100644 index 75a74dffb1..0000000000 --- a/sdk/mpr/parser_enumeration.go +++ /dev/null @@ -1,197 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/scheduledevents" - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parseEnumeration(unitID, containerID string, contents []byte) (*model.Enumeration, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - enum := &model.Enumeration{} - enum.ID = model.ID(unitID) - enum.TypeName = "Enumerations$Enumeration" - enum.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - enum.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - enum.Documentation = doc - } - // Excluded must survive a read→rebuild→write cycle; defaulting it to false - // un-excludes the document on the next CREATE OR MODIFY (#914). - if excl, ok := raw["Excluded"].(bool); ok { - enum.Excluded = excl - } - - // Parse values - array may start with a version number, skip non-map elements - if values, ok := raw["Values"].(bson.A); ok { - for _, v := range values { - if valMap, ok := v.(map[string]any); ok { - value := parseEnumerationValue(valMap) - enum.Values = append(enum.Values, value) - } - } - } - - return enum, nil -} - -func parseEnumerationValue(raw map[string]any) model.EnumerationValue { - value := model.EnumerationValue{} - - if name, ok := raw["Name"].(string); ok { - value.Name = name - } - if caption, ok := raw["Caption"].(map[string]any); ok { - value.Caption = parseTextMap(caption) - } - - return value -} - -// parseTextMap parses a Text from map[string]interface{} -func parseTextMap(raw map[string]any) *model.Text { - text := &model.Text{ - Translations: make(map[string]string), - } - - if items, ok := raw["Items"].(bson.A); ok { - for _, item := range items { - if transMap, ok := item.(map[string]any); ok { - langCode, _ := transMap["LanguageCode"].(string) - textVal, _ := transMap["Text"].(string) - if langCode != "" { - text.Translations[langCode] = textVal - } - } - } - } - - return text -} - -// parseConstant parses constant contents from BSON. -func (r *Reader) parseConstant(unitID, containerID string, contents []byte) (*model.Constant, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - constant := &model.Constant{} - constant.ID = model.ID(unitID) - constant.TypeName = "Constants$Constant" - constant.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - constant.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - constant.Documentation = doc - } - // Parse Type as a nested BSON object containing $Type field - if typeObj, ok := raw["Type"].(map[string]any); ok { - constant.Type = parseConstantDataType(typeObj) - } - if defaultValue, ok := raw["DefaultValue"].(string); ok { - constant.DefaultValue = defaultValue - } - if exposed, ok := raw["ExposedToClient"].(bool); ok { - constant.ExposedToClient = exposed - } - if excluded, ok := raw["Excluded"].(bool); ok { - constant.Excluded = excluded - } - if exportLevel, ok := raw["ExportLevel"].(string); ok { - constant.ExportLevel = exportLevel - } - - return constant, nil -} - -// parseConstantDataType extracts the data type from a constant's Type field. -func parseConstantDataType(typeObj map[string]any) model.ConstantDataType { - dt := model.ConstantDataType{} - typeName, _ := typeObj["$Type"].(string) - - switch typeName { - case "DataTypes$StringType": - dt.Kind = "String" - case "DataTypes$IntegerType": - dt.Kind = "Integer" - case "DataTypes$LongType": - dt.Kind = "Long" - case "DataTypes$DecimalType": - dt.Kind = "Decimal" - case "DataTypes$BooleanType": - dt.Kind = "Boolean" - case "DataTypes$DateTimeType": - dt.Kind = "DateTime" - case "DataTypes$BinaryType": - dt.Kind = "Binary" - case "DataTypes$FloatType": - dt.Kind = "Float" - case "DataTypes$EnumerationType": - dt.Kind = "Enumeration" - // Enumeration reference can be string (qualified name) or binary ID - if enumRef, ok := typeObj["Enumeration"].(string); ok { - dt.EnumRef = enumRef - } - case "DataTypes$ObjectType": - dt.Kind = "Object" - if entityRef, ok := typeObj["Entity"].(string); ok { - dt.EntityRef = entityRef - } - case "DataTypes$ListType": - dt.Kind = "List" - if entityRef, ok := typeObj["Entity"].(string); ok { - dt.EntityRef = entityRef - } - default: - dt.Kind = "Unknown" - } - - return dt -} - -// parseScheduledEvent parses scheduled event contents from BSON. -func (r *Reader) parseScheduledEvent(unitID, containerID string, contents []byte) (*model.ScheduledEvent, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw bson.M - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - // Shared with the modelsdk engine so both read the same keys the shared - // writer produces — including the polymorphic Schedule child, which this - // parser used to drop, and StartDateTime, which Studio Pro stores as a BSON - // datetime. (Interval is int64 in Studio Pro documents; the codec accepts - // every numeric width — issue #585.) - return scheduledevents.Parse(raw, model.ID(unitID), model.ID(containerID)), nil -} - -// resolveContents handles MPR v2 external file references. diff --git a/sdk/mpr/parser_export_mapping.go b/sdk/mpr/parser_export_mapping.go deleted file mode 100644 index 62b64b26e7..0000000000 --- a/sdk/mpr/parser_export_mapping.go +++ /dev/null @@ -1,164 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseExportMapping parses an ExportMappings$ExportMapping unit from BSON. -func (r *Reader) parseExportMapping(unitID, containerID string, contents []byte) (*model.ExportMapping, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - em := &model.ExportMapping{} - em.ID = model.ID(unitID) - em.TypeName = "ExportMappings$ExportMapping" - em.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - em.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - em.Documentation = doc - } - if excluded, ok := raw["Excluded"].(bool); ok { - em.Excluded = excluded - } - if exportLevel, ok := raw["ExportLevel"].(string); ok { - em.ExportLevel = exportLevel - } - if v, ok := raw["JsonStructure"].(string); ok { - em.JsonStructure = v - } - if v, ok := raw["XmlSchema"].(string); ok { - em.XmlSchema = v - } - // MessageDefinition2 is version-introduced (11.10+) and carried, not derived: - // nil means the stored document does not have the key (ako/mxcli#279). - if v, ok := raw["MessageDefinition2"].(string); ok { - em.MessageDefinition2 = &v - } - if v, ok := raw["MessageDefinition"].(string); ok { - em.MessageDefinition = v - } - if v, ok := raw["NullValueOption"].(string); ok { - em.NullValueOption = v - } - em.WebServiceSource = parseWebServiceSource(raw) - - // Parse top-level mapping elements (array with int32 version prefix) - if elements, ok := raw["Elements"].(bson.A); ok { - for _, e := range elements { - if elemMap, ok := e.(map[string]any); ok { - elem := parseExportMappingElement(elemMap) - if elem != nil { - em.Elements = append(em.Elements, elem) - } - } - } - } - - return em, nil -} - -// parseExportMappingElement dispatches to the correct parser based on $Type. -func parseExportMappingElement(raw map[string]any) *model.ExportMappingElement { - typeName, _ := raw["$Type"].(string) - switch typeName { - case "ExportMappings$ObjectMappingElement": - return parseExportObjectMappingElement(raw) - case "ExportMappings$ValueMappingElement": - return parseExportValueMappingElement(raw) - default: - return nil - } -} - -func parseExportObjectMappingElement(raw map[string]any) *model.ExportMappingElement { - elem := &model.ExportMappingElement{Kind: "Object"} - - if id := extractBsonID(raw["$ID"]); id != "" { - elem.ID = model.ID(id) - } - elem.TypeName = "ExportMappings$ObjectMappingElement" - - if v, ok := raw["Entity"].(string); ok { - elem.Entity = v - } - if v, ok := raw["ExposedName"].(string); ok { - elem.ExposedName = v - } - if v, ok := raw["JsonPath"].(string); ok { - elem.JsonPath = v - } - if v, ok := raw["XmlPath"].(string); ok { - elem.XmlPath = v - } - if v, ok := raw["Converter"].(string); ok { - elem.Converter = v - } - if v, ok := raw["Association"].(string); ok { - elem.Association = v - } - - // Parse children recursively (mix of object and value elements) - if children, ok := raw["Children"].(bson.A); ok { - for _, c := range children { - if childMap, ok := c.(map[string]any); ok { - child := parseExportMappingElement(childMap) - if child != nil { - elem.Children = append(elem.Children, child) - } - } - } - } - - return elem -} - -func parseExportValueMappingElement(raw map[string]any) *model.ExportMappingElement { - elem := &model.ExportMappingElement{Kind: "Value"} - - if id := extractBsonID(raw["$ID"]); id != "" { - elem.ID = model.ID(id) - } - elem.TypeName = "ExportMappings$ValueMappingElement" - - if v, ok := raw["Attribute"].(string); ok { - elem.Attribute = v - } - if v, ok := raw["ExposedName"].(string); ok { - elem.ExposedName = v - } - if v, ok := raw["JsonPath"].(string); ok { - elem.JsonPath = v - } - if v, ok := raw["XmlPath"].(string); ok { - elem.XmlPath = v - } - if v, ok := raw["Converter"].(string); ok { - elem.Converter = v - } - if v, ok := raw["OriginalValue"].(string); ok { - elem.OriginalValue = v - } - - // Extract the primitive type from the nested Type object - if typeObj, ok := raw["Type"].(map[string]any); ok { - elem.DataType = extractPrimitiveTypeName(typeObj) - } - - return elem -} diff --git a/sdk/mpr/parser_import_mapping.go b/sdk/mpr/parser_import_mapping.go deleted file mode 100644 index ceacdd1aeb..0000000000 --- a/sdk/mpr/parser_import_mapping.go +++ /dev/null @@ -1,244 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseImportMapping parses an ImportMappings$ImportMapping unit from BSON. -func (r *Reader) parseImportMapping(unitID, containerID string, contents []byte) (*model.ImportMapping, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - im := &model.ImportMapping{} - im.ID = model.ID(unitID) - im.TypeName = "ImportMappings$ImportMapping" - im.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - im.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - im.Documentation = doc - } - if excluded, ok := raw["Excluded"].(bool); ok { - im.Excluded = excluded - } - if exportLevel, ok := raw["ExportLevel"].(string); ok { - im.ExportLevel = exportLevel - } - if v, ok := raw["JsonStructure"].(string); ok { - im.JsonStructure = v - } - if v, ok := raw["XmlSchema"].(string); ok { - im.XmlSchema = v - } - if v, ok := raw["MessageDefinition"].(string); ok { - im.MessageDefinition = v - } - im.WebServiceSource = parseWebServiceSource(raw) - // MessageDefinition2 is version-introduced (11.10+) and carried, not derived: - // nil means the stored document does not have the key (ako/mxcli#279). - if v, ok := raw["MessageDefinition2"].(string); ok { - im.MessageDefinition2 = &v - } - // The mapping's input object (#265). Only DataTypes$ObjectType carries an - // entity — the DataTypes$UnknownType marker an unparameterised mapping - // stores means "none". - if pt, ok := raw["ParameterType"].(map[string]any); ok { - if t, _ := pt["$Type"].(string); t == "DataTypes$ObjectType" { - if e, _ := pt["Entity"].(string); e != "" { - im.ParameterEntity = e - } - } - } - - // Parse top-level mapping elements (may start with int32 version prefix) - if elements, ok := raw["Elements"].(bson.A); ok { - for _, e := range elements { - if elemMap, ok := e.(map[string]any); ok { - elem := parseImportMappingElement(elemMap) - if elem != nil { - im.Elements = append(im.Elements, elem) - } - } - } - } - - return im, nil -} - -// parseImportMappingElement dispatches to the correct parser based on $Type. -func parseImportMappingElement(raw map[string]any) *model.ImportMappingElement { - typeName, _ := raw["$Type"].(string) - switch typeName { - case "ImportMappings$ObjectMappingElement": - return parseImportObjectMappingElement(raw) - case "ImportMappings$ValueMappingElement": - return parseImportValueMappingElement(raw) - default: - return nil - } -} - -func parseImportObjectMappingElement(raw map[string]any) *model.ImportMappingElement { - elem := &model.ImportMappingElement{Kind: "Object"} - - if id := extractBsonID(raw["$ID"]); id != "" { - elem.ID = model.ID(id) - } - elem.TypeName = "ImportMappings$ObjectMappingElement" - - if v, ok := raw["Entity"].(string); ok { - elem.Entity = v - } - if v, ok := raw["ExposedName"].(string); ok { - elem.ExposedName = v - } - if v, ok := raw["JsonPath"].(string); ok { - elem.JsonPath = v - } - if v, ok := raw["XmlPath"].(string); ok { - elem.XmlPath = v - } - if v, ok := raw["Converter"].(string); ok { - elem.Converter = v - } - if v, ok := raw["ObjectHandling"].(string); ok { - elem.ObjectHandling = v - if v == "Find" { - if backup, ok := raw["ObjectHandlingBackup"].(string); ok && backup == "Create" { - elem.ObjectHandling = "FindOrCreate" - } - } - } - // The backup is what the element does when the object is NOT found, and it - // is carried in its own right now that MDL can say `or ignore` / `or error` - // (#261). FindOrCreate above stays as the shorthand for Find + Create. - if v, ok := raw["ObjectHandlingBackup"].(string); ok { - elem.ObjectHandlingBackup = v - } - if v, ok := raw["ObjectHandlingBackupAllowOverride"].(bool); ok { - elem.BackupAllowOverride = v - } - if v, ok := raw["Association"].(string); ok { - elem.Association = v - } - elem.MinOccurs = extractInt(raw["MinOccurs"]) - elem.MaxOccurs = extractInt(raw["MaxOccurs"]) - - // Parse children recursively (mix of object and value elements) - if children, ok := raw["Children"].(bson.A); ok { - for _, c := range children { - if childMap, ok := c.(map[string]any); ok { - child := parseImportMappingElement(childMap) - if child != nil { - elem.Children = append(elem.Children, child) - } - } - } - } - - return elem -} - -func parseImportValueMappingElement(raw map[string]any) *model.ImportMappingElement { - elem := &model.ImportMappingElement{Kind: "Value"} - - if id := extractBsonID(raw["$ID"]); id != "" { - elem.ID = model.ID(id) - } - elem.TypeName = "ImportMappings$ValueMappingElement" - - if v, ok := raw["Attribute"].(string); ok { - elem.Attribute = v - } - if v, ok := raw["ExposedName"].(string); ok { - elem.ExposedName = v - } - if v, ok := raw["JsonPath"].(string); ok { - elem.JsonPath = v - } - if v, ok := raw["XmlPath"].(string); ok { - elem.XmlPath = v - } - if v, ok := raw["Converter"].(string); ok { - elem.Converter = v - } - if v, ok := raw["IsKey"].(bool); ok { - elem.IsKey = v - } - elem.MinOccurs = extractInt(raw["MinOccurs"]) - elem.MaxOccurs = extractInt(raw["MaxOccurs"]) - - // Extract the primitive type from the nested Type object - if typeObj, ok := raw["Type"].(map[string]any); ok { - elem.DataType = extractPrimitiveTypeName(typeObj) - } - - return elem -} - -// extractPrimitiveTypeName converts a DataTypes$* BSON type object to a simple type string. -func extractPrimitiveTypeName(typeObj map[string]any) string { - typeName, _ := typeObj["$Type"].(string) - switch typeName { - case "DataTypes$StringType": - return "String" - case "DataTypes$IntegerType": - return "Integer" - case "DataTypes$LongType": - return "Long" - case "DataTypes$DecimalType": - return "Decimal" - case "DataTypes$BooleanType": - return "Boolean" - case "DataTypes$DateTimeType": - return "DateTime" - case "DataTypes$BinaryType": - return "Binary" - default: - return "String" - } -} - -// parseWebServiceSource reads a mapping's SOAP binding. -// -// Read-only, and read for one reason: a rewrite that dropped these keys turned a -// working integration into CE6896 + CE0270. ImportedWebService is stored under -// `wsdlFile`'s SDK name — the BSON key is ImportedWebService — and the root -// element under RootElementName (`xsdRootElementName` in the SDK). -func parseWebServiceSource(raw map[string]any) model.WebServiceMappingSource { - var w model.WebServiceMappingSource - if v, ok := raw["ImportedWebService"].(string); ok { - w.ImportedWebService = v - } - if v, ok := raw["ServiceName"].(string); ok { - w.ServiceName = v - } - if v, ok := raw["OperationName"].(string); ok { - w.OperationName = v - } - if v, ok := raw["RootElementName"].(string); ok { - w.RootElementName = v - } - if v, ok := raw["ParameterName"].(string); ok { - w.ParameterName = v - } - if v, ok := raw["IsHeader"].(bool); ok { - w.IsHeader = v - } - return w -} diff --git a/sdk/mpr/parser_import_mapping_test.go b/sdk/mpr/parser_import_mapping_test.go deleted file mode 100644 index 9a054a5b91..0000000000 --- a/sdk/mpr/parser_import_mapping_test.go +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -func TestParseImportObjectMappingElement_FindWithCreateBackupBecomesFindOrCreate(t *testing.T) { - elem := parseImportObjectMappingElement(map[string]any{ - "$ID": "ignored", - "$Type": "ImportMappings$ObjectMappingElement", - "Entity": "MyModule.Pet", - "ObjectHandling": "Find", - "ObjectHandlingBackup": "Create", - }) - - if elem == nil { - t.Fatal("expected element, got nil") - } - if elem.ObjectHandling != "FindOrCreate" { - t.Fatalf("ObjectHandling = %q, want %q", elem.ObjectHandling, "FindOrCreate") - } -} diff --git a/sdk/mpr/parser_javaactions.go b/sdk/mpr/parser_javaactions.go deleted file mode 100644 index 2ee31900b1..0000000000 --- a/sdk/mpr/parser_javaactions.go +++ /dev/null @@ -1,578 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Java action parsing. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// ReadJavaAction reads a Java action by its ID. -func (r *Reader) ReadJavaAction(id model.ID) (*javaactions.JavaAction, error) { - units, err := r.listUnitsByType("JavaActions$JavaAction") - if err != nil { - return nil, err - } - - for _, u := range units { - if u.ID == string(id) { - return r.parseJavaActionFull(u.ID, u.ContainerID, u.Contents) - } - } - - return nil, fmt.Errorf("java action not found: %s", id) -} - -// ReadJavaActionByName reads a Java action by its qualified name (Module.ActionName). -func (r *Reader) ReadJavaActionByName(qualifiedName string) (*javaactions.JavaAction, error) { - // First, list all Java actions - units, err := r.listUnitsByType("JavaActions$JavaAction") - if err != nil { - return nil, err - } - - // Build module and folder hierarchy - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleNames := make(map[model.ID]string) - for _, m := range modules { - moduleNames[m.ID] = m.Name - } - - // Get all folders for hierarchy resolution - folders, err := r.ListFolders() - if err != nil { - return nil, err - } - folderContainers := make(map[model.ID]model.ID) - for _, f := range folders { - folderContainers[f.ID] = f.ContainerID - } - - for _, u := range units { - contents, err := r.resolveContents(u.ID, u.Contents) - if err != nil { - continue - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - continue - } - - name := extractString(raw["Name"]) - - // Find module name by walking up the container hierarchy - modName := "" - containerID := model.ID(u.ContainerID) - for range 20 { // Max depth to prevent infinite loops - if mn, ok := moduleNames[containerID]; ok { - modName = mn - break - } - // Check if container is a folder and get its parent - if parent, ok := folderContainers[containerID]; ok { - containerID = parent - } else { - break - } - } - - fullName := modName + "." + name - if fullName == qualifiedName { - return r.parseJavaActionFull(u.ID, u.ContainerID, contents) - } - } - - return nil, fmt.Errorf("java action not found: %s", qualifiedName) -} - -// parseJavaActionFull parses a Java action with full details. -func (r *Reader) parseJavaActionFull(unitID, containerID string, contents []byte) (*javaactions.JavaAction, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ja := &javaactions.JavaAction{} - ja.ID = model.ID(unitID) - ja.TypeName = "JavaActions$JavaAction" - ja.ContainerID = model.ID(containerID) - - // Basic fields - ja.Name = extractString(raw["Name"]) - ja.Documentation = extractString(raw["Documentation"]) - ja.Excluded = extractBool(raw["Excluded"], false) - ja.ExportLevel = extractString(raw["ExportLevel"]) - ja.ActionDefaultReturnName = extractString(raw["ActionDefaultReturnName"]) - - // Parse return type - handle both map and primitive.D - switch rt := raw["JavaReturnType"].(type) { - case map[string]any: - ja.ReturnType = parseCodeActionReturnType(rt) - case primitive.D: - ja.ReturnType = parseCodeActionReturnType(primitiveToMap(rt)) - } - - // Parse parameters - handle both map and primitive.D and primitive.A - switch params := raw["Parameters"].(type) { - case []any: - for _, p := range params { - pMap := toMap(p) - if pMap != nil { - param := parseJavaActionParameter(pMap) - if param != nil { - ja.Parameters = append(ja.Parameters, param) - } - } - } - case primitive.A: - for _, p := range params { - pMap := toMap(p) - if pMap != nil { - param := parseJavaActionParameter(pMap) - if param != nil { - ja.Parameters = append(ja.Parameters, param) - } - } - } - } - - // Parse type parameters (generics) - preserve IDs for BY_ID references - switch typeParams := raw["TypeParameters"].(type) { - case []any: - for _, tp := range typeParams { - tpMap := toMap(tp) - if tpMap != nil { - if name := extractString(tpMap["Name"]); name != "" { - tpDef := &javaactions.TypeParameterDef{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(tpMap["$ID"]))}, - Name: name, - } - ja.TypeParameters = append(ja.TypeParameters, tpDef) - } - } - } - case primitive.A: - for _, tp := range typeParams { - tpMap := toMap(tp) - if tpMap != nil { - if name := extractString(tpMap["Name"]); name != "" { - tpDef := &javaactions.TypeParameterDef{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(tpMap["$ID"]))}, - Name: name, - } - ja.TypeParameters = append(ja.TypeParameters, tpDef) - } - } - } - } - - // Parse MicroflowActionInfo - if mai := toMap(raw["MicroflowActionInfo"]); mai != nil { - ja.MicroflowActionInfo = parseMicroflowActionInfo(mai) - } - - // Resolve type parameter names for EntityTypeParameterType and ParameterizedEntityType parameters - for _, param := range ja.Parameters { - switch pt := param.ParameterType.(type) { - case *javaactions.EntityTypeParameterType: - pt.TypeParameterName = ja.FindTypeParameterName(pt.TypeParameterID) - case *javaactions.TypeParameter: - if pt.TypeParameterID != "" && pt.TypeParameter == "" { - pt.TypeParameter = ja.FindTypeParameterName(pt.TypeParameterID) - } - } - } - - // Resolve type parameter name for return type if it's a ParameterizedEntityType - if tp, ok := ja.ReturnType.(*javaactions.TypeParameter); ok { - if tp.TypeParameterID != "" && tp.TypeParameter == "" { - tp.TypeParameter = ja.FindTypeParameterName(tp.TypeParameterID) - } - } - - return ja, nil -} - -// parseCodeActionReturnType parses a Java action return type. -func parseCodeActionReturnType(raw map[string]any) javaactions.CodeActionReturnType { - if raw == nil { - return nil - } - - typeName := extractString(raw["$Type"]) - switch typeName { - case "CodeActions$VoidType": - return &javaactions.VoidType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$BooleanType": - return &javaactions.BooleanType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$IntegerType": - return &javaactions.IntegerType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$LongType": - return &javaactions.LongType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DecimalType": - return &javaactions.DecimalType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$StringType": - return &javaactions.StringType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DateTimeType": - return &javaactions.DateTimeType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$EntityType", "CodeActions$ConcreteEntityType": - et := &javaactions.EntityType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - et.Entity = extractString(raw["Entity"]) - return et - case "CodeActions$ListType": - lt := &javaactions.ListType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // ListType can have Entity directly or Parameter containing ConcreteEntityType - if entity := extractString(raw["Entity"]); entity != "" { - lt.Entity = entity - } else if param := toMap(raw["Parameter"]); param != nil { - lt.Entity = extractString(param["Entity"]) - } - return lt - case "CodeActions$FileDocumentType": - return &javaactions.FileDocumentType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$EnumerationType": - et := &javaactions.EnumerationType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - et.Enumeration = extractString(raw["Enumeration"]) - return et - case "CodeActions$TypeParameter": - tp := &javaactions.TypeParameter{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - tp.TypeParameter = extractString(raw["TypeParameter"]) - return tp - case "CodeActions$ParameterizedEntityType": - // Return type referencing a type parameter (e.g., returns the entity passed as type param) - tp := &javaactions.TypeParameter{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // ParameterizedEntityType stores the type parameter as a binary ID pointer - id := extractBsonID(raw["TypeParameterPointer"]) - if id == "" { - id = extractBsonID(raw["TypeParameter"]) - } - tp.TypeParameterID = model.ID(id) - return tp - } - - // Unknown type - return nil - return nil -} - -// parseJavaActionParameter parses a Java action parameter. -func parseJavaActionParameter(raw map[string]any) *javaactions.JavaActionParameter { - if raw == nil { - return nil - } - - // Skip array markers (items without $ID) - if raw["$ID"] == nil { - return nil - } - - param := &javaactions.JavaActionParameter{} - param.ID = model.ID(extractBsonID(raw["$ID"])) - param.TypeName = extractString(raw["$Type"]) - param.Name = extractString(raw["Name"]) - param.Description = extractString(raw["Description"]) - param.Category = extractString(raw["Category"]) - param.IsRequired = extractBool(raw["IsRequired"], false) - - // Parse parameter type - handle both map and primitive.D - switch pt := raw["ParameterType"].(type) { - case map[string]any: - param.ParameterType = parseCodeActionParameterType(pt) - case primitive.D: - param.ParameterType = parseCodeActionParameterType(primitiveToMap(pt)) - } - - return param -} - -// parseCodeActionParameterType parses a Java action parameter type. -func parseCodeActionParameterType(raw map[string]any) javaactions.CodeActionParameterType { - if raw == nil { - return nil - } - - typeName := extractString(raw["$Type"]) - switch typeName { - case "CodeActions$BasicParameterType": - // BasicParameterType wraps the actual type in a "Type" property - innerType := toMap(raw["Type"]) - if innerType != nil { - return parseInnerParameterType(innerType) - } - return nil - case "CodeActions$BooleanType": - return &javaactions.BooleanType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$IntegerType": - return &javaactions.IntegerType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$LongType": - return &javaactions.LongType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DecimalType": - return &javaactions.DecimalType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$StringType": - return &javaactions.StringType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DateTimeType": - return &javaactions.DateTimeType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$EntityType", "CodeActions$ConcreteEntityType": - et := &javaactions.EntityType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - et.Entity = extractString(raw["Entity"]) - return et - case "CodeActions$ListType": - lt := &javaactions.ListType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // ListType can have Entity directly or Parameter containing ConcreteEntityType - if entity := extractString(raw["Entity"]); entity != "" { - lt.Entity = entity - } else if param := toMap(raw["Parameter"]); param != nil { - lt.Entity = extractString(param["Entity"]) - } - return lt - case "CodeActions$StringTemplateParameterType": - st := &javaactions.StringTemplateParameterType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - st.Grammar = extractString(raw["Grammar"]) - return st - case "CodeActions$FileDocumentType": - return &javaactions.FileDocumentType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$EnumerationType": - et := &javaactions.EnumerationType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - et.Enumeration = extractString(raw["Enumeration"]) - return et - case "CodeActions$MicroflowType", "JavaActions$MicroflowJavaActionParameterType": - return &javaactions.MicroflowType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$TypeParameter": - tp := &javaactions.TypeParameter{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - tp.TypeParameter = extractString(raw["TypeParameter"]) - return tp - case "CodeActions$EntityTypeParameterType": - etpt := &javaactions.EntityTypeParameterType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // Studio Pro uses "TypeParameterPointer"; fall back to "TypeParameter" for backward compat - id := extractBsonID(raw["TypeParameterPointer"]) - if id == "" { - id = extractBsonID(raw["TypeParameter"]) - } - etpt.TypeParameterID = model.ID(id) - return etpt - case "JavaScriptActions$NanoflowJavaScriptActionParameterType": - return &javaactions.NanoflowType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - } - - // Unknown type - return nil - return nil -} - -// parseInnerParameterType parses the inner type from BasicParameterType. -func parseInnerParameterType(raw map[string]any) javaactions.CodeActionParameterType { - if raw == nil { - return nil - } - - typeName := extractString(raw["$Type"]) - switch typeName { - case "CodeActions$BooleanType": - return &javaactions.BooleanType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$IntegerType": - return &javaactions.IntegerType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DecimalType": - return &javaactions.DecimalType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$StringType": - return &javaactions.StringType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$DateTimeType": - return &javaactions.DateTimeType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$MicroflowType", "JavaActions$MicroflowJavaActionParameterType": - return &javaactions.MicroflowType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - case "CodeActions$ConcreteEntityType", "CodeActions$EntityType": - et := &javaactions.EntityType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - et.Entity = extractString(raw["Entity"]) - return et - case "CodeActions$ListType": - lt := &javaactions.ListType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // ListType contains Parameter with ConcreteEntityType - if param := toMap(raw["Parameter"]); param != nil { - lt.Entity = extractString(param["Entity"]) - } else if entity := extractString(raw["Entity"]); entity != "" { - lt.Entity = entity - } - return lt - case "CodeActions$EntityTypeParameterType": - etpt := &javaactions.EntityTypeParameterType{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // Studio Pro uses "TypeParameterPointer"; fall back to "TypeParameter" for backward compat - id := extractBsonID(raw["TypeParameterPointer"]) - if id == "" { - id = extractBsonID(raw["TypeParameter"]) - } - etpt.TypeParameterID = model.ID(id) - return etpt - case "CodeActions$ParameterizedEntityType": - tp := &javaactions.TypeParameter{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(raw["$ID"]))}, - } - // ParameterizedEntityType stores the type parameter as a binary ID pointer - id := extractBsonID(raw["TypeParameterPointer"]) - if id == "" { - id = extractBsonID(raw["TypeParameter"]) - } - tp.TypeParameterID = model.ID(id) - return tp - } - - return nil -} - -// ListJavaActionsFull returns all Java actions with full details, including virtual System module actions. -func (r *Reader) ListJavaActionsFull() ([]*javaactions.JavaAction, error) { - units, err := r.listUnitsByType("JavaActions$JavaAction") - if err != nil { - return nil, err - } - - var result []*javaactions.JavaAction - for _, u := range units { - ja, err := r.parseJavaActionFull(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse java action %s: %w", u.ID, err) - } - result = append(result, ja) - } - - // Append virtual System module Java actions (not stored in the MPR database) - result = append(result, BuildSystemJavaActionsFull()...) - - return result, nil -} - -// toMap converts various BSON types to map[string]interface{}. -func toMap(v any) map[string]any { - if v == nil { - return nil - } - switch m := v.(type) { - case map[string]any: - return m - case primitive.D: - return primitiveToMap(m) - default: - return nil - } -} - -// primitiveToMap converts primitive.D to map[string]interface{}. -func primitiveToMap(d primitive.D) map[string]any { - result := make(map[string]any) - for _, e := range d { - result[e.Key] = e.Value - } - return result -} - -// extractBinary returns the bytes of a BSON binary value, or nil when the field -// is absent, BSON null, or some other type. This tolerates the legacy -// MicroflowActionInfo shape (null/absent ImageData) on read so already-corrupted -// units can still be loaded and repaired. See issue #656. -func extractBinary(v any) []byte { - if b, ok := v.(primitive.Binary); ok { - return b.Data - } - return nil -} - -// parseMicroflowActionInfo reads a MicroflowActionInfo sub-document. It accepts -// both the current CodeActions$ binary shape and the legacy JavaActions$ shape -// (an `Icon` string and a null/absent `ImageData`), reading the four icon/image -// bitmaps as binaries and silently dropping the obsolete `Icon` key. Shared by -// the Java- and JavaScript-action parsers. See issue #656. -func parseMicroflowActionInfo(mai map[string]any) *javaactions.MicroflowActionInfo { - return &javaactions.MicroflowActionInfo{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(mai["$ID"]))}, - Caption: extractString(mai["Caption"]), - Category: extractString(mai["Category"]), - IconData: extractBinary(mai["IconData"]), - IconDataDark: extractBinary(mai["IconDataDark"]), - ImageData: extractBinary(mai["ImageData"]), - ImageDataDark: extractBinary(mai["ImageDataDark"]), - } -} diff --git a/sdk/mpr/parser_javaactions_test.go b/sdk/mpr/parser_javaactions_test.go deleted file mode 100644 index 16456a6c55..0000000000 --- a/sdk/mpr/parser_javaactions_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/javaactions" -) - -func TestBuildSystemJavaActions_VerifyPassword(t *testing.T) { - actions := BuildSystemJavaActions() - - var found bool - for _, a := range actions { - if a.Name == "VerifyPassword" && string(a.ContainerID) == SystemModuleID { - found = true - break - } - } - if !found { - t.Error("BuildSystemJavaActions: System.VerifyPassword not present") - } -} - -func TestBuildSystemJavaActionsFull_VerifyPassword(t *testing.T) { - actions := BuildSystemJavaActionsFull() - - var found bool - for _, a := range actions { - if a.Name != "VerifyPassword" || string(a.ContainerID) != SystemModuleID { - continue - } - found = true - if len(a.Parameters) != 2 { - t.Errorf("VerifyPassword: want 2 parameters, got %d", len(a.Parameters)) - } - if _, ok := a.ReturnType.(*javaactions.BooleanType); !ok { - t.Errorf("VerifyPassword: want BooleanType return, got %T", a.ReturnType) - } - } - if !found { - t.Error("BuildSystemJavaActionsFull: System.VerifyPassword not present") - } -} - -func TestBuildSystemJavaActions_DeterministicIDs(t *testing.T) { - a1 := BuildSystemJavaActions() - a2 := BuildSystemJavaActions() - for i := range a1 { - if a1[i].ID != a2[i].ID { - t.Errorf("non-deterministic ID for %s", a1[i].Name) - } - } -} - -func TestParseCodeActionParameterType_JavaActionMicroflowParameter(t *testing.T) { - value := parseCodeActionParameterType(map[string]any{ - "$ID": "type-1", - "$Type": "JavaActions$MicroflowJavaActionParameterType", - }) - - if _, ok := value.(*javaactions.MicroflowType); !ok { - t.Fatalf("value = %T, want *MicroflowType", value) - } -} diff --git a/sdk/mpr/parser_listoperation_test.go b/sdk/mpr/parser_listoperation_test.go deleted file mode 100644 index 8fd6c0394c..0000000000 --- a/sdk/mpr/parser_listoperation_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -func TestParseListOperation_FindByAttribute(t *testing.T) { - raw := map[string]any{ - "$Type": "Microflows$Find", - "$ID": nil, - "ListName": "Orders", - "Attribute": "MyModule.Order.Status", - "Expression": "'Active'", - } - op := parseListOperation(raw) - got, ok := op.(*microflows.FindByAttributeOperation) - if !ok { - t.Fatalf("expected *FindByAttributeOperation, got %T", op) - } - if got.ListVariable != "Orders" { - t.Errorf("ListVariable: got %q, want %q", got.ListVariable, "Orders") - } - if got.Attribute != "MyModule.Order.Status" { - t.Errorf("Attribute: got %q, want %q", got.Attribute, "MyModule.Order.Status") - } - if got.Expression != "'Active'" { - t.Errorf("Expression: got %q, want %q", got.Expression, "'Active'") - } -} - -func TestParseListOperation_FindByAssociation(t *testing.T) { - raw := map[string]any{ - "$Type": "Microflows$Find", - "$ID": nil, - "ListName": "Orders", - "Association": "MyModule.Order_Customer", - "Expression": "$Customer", - } - op := parseListOperation(raw) - got, ok := op.(*microflows.FindByAttributeOperation) - if !ok { - t.Fatalf("expected *FindByAttributeOperation, got %T", op) - } - if got.Association != "MyModule.Order_Customer" { - t.Errorf("Association: got %q, want %q", got.Association, "MyModule.Order_Customer") - } -} - -func TestParseListOperation_FilterByAttribute(t *testing.T) { - raw := map[string]any{ - "$Type": "Microflows$Filter", - "$ID": nil, - "ListName": "Orders", - "Attribute": "MyModule.Order.IsActive", - "Expression": "true", - } - op := parseListOperation(raw) - got, ok := op.(*microflows.FilterByAttributeOperation) - if !ok { - t.Fatalf("expected *FilterByAttributeOperation, got %T", op) - } - if got.ListVariable != "Orders" { - t.Errorf("ListVariable: got %q, want %q", got.ListVariable, "Orders") - } - if got.Attribute != "MyModule.Order.IsActive" { - t.Errorf("Attribute: got %q, want %q", got.Attribute, "MyModule.Order.IsActive") - } -} - -func TestParseListOperation_Range(t *testing.T) { - raw := map[string]any{ - "$Type": "Microflows$ListRange", - "$ID": nil, - "ListName": "Orders", - "CustomRange": map[string]any{ - "$Type": "Microflows$CustomRange", - "OffsetExpression": "0", - "LimitExpression": "10", - }, - } - op := parseListOperation(raw) - got, ok := op.(*microflows.ListRangeOperation) - if !ok { - t.Fatalf("expected *ListRangeOperation, got %T", op) - } - if got.ListVariable != "Orders" { - t.Errorf("ListVariable: got %q, want %q", got.ListVariable, "Orders") - } - if got.OffsetExpression != "0" { - t.Errorf("OffsetExpression: got %q, want %q", got.OffsetExpression, "0") - } - if got.LimitExpression != "10" { - t.Errorf("LimitExpression: got %q, want %q", got.LimitExpression, "10") - } -} diff --git a/sdk/mpr/parser_menu_signout_test.go b/sdk/mpr/parser_menu_signout_test.go deleted file mode 100644 index ed85a207dc..0000000000 --- a/sdk/mpr/parser_menu_signout_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -// menuItemRaw builds the minimum a Menus$MenuItem needs to parse: a caption with -// a real translation (parseNavMenuItem deliberately returns nil for an item with -// no caption, no page and no children) plus the action under test. -func menuItemRaw(actionType string) map[string]any { - return map[string]any{ - "Caption": map[string]any{ - "$Type": "Texts$Text", - "Items": []any{ - int32(3), - map[string]any{"$Type": "Texts$Translation", "LanguageCode": "en_US", "Text": "Sign out"}, - }, - }, - "Action": map[string]any{"$Type": actionType}, - } -} - -// The legacy reader is the other half of reading a sign-out MENU ITEM back. -// Before this case it fell to the raw-type-name default, so the item was -// described as a plain `menu item 'x';` and DESCRIBE -> exec turned ako/TestApp's -// working sign-out entry into a dead one — silently, with mx check clean. -func TestParseNavMenuItem_SignOut(t *testing.T) { - mi := parseNavMenuItem(menuItemRaw("Forms$SignOutClientAction")) - if mi == nil { - t.Fatal("parseNavMenuItem returned nil") - } - if mi.ActionType != "SignOutAction" { - t.Errorf("ActionType = %q, want SignOutAction — the writers and DESCRIBE key on that string", - mi.ActionType) - } -} - -// CONTROL: the action types already read must be unchanged, and an unknown one -// must still fall through to its raw name rather than being absorbed. -func TestParseNavMenuItem_OtherActionsUnchanged(t *testing.T) { - cases := []struct { - typeName string - want string - }{ - {"Forms$FormAction", "PageAction"}, - {"Forms$MicroflowAction", "MicroflowAction"}, - {"Forms$NoAction", "NoAction"}, - {"Forms$SomethingElseAction", "Forms$SomethingElseAction"}, - } - for _, c := range cases { - mi := parseNavMenuItem(menuItemRaw(c.typeName)) - if mi.ActionType != c.want { - t.Errorf("%s -> %q, want %q", c.typeName, mi.ActionType, c.want) - } - } -} diff --git a/sdk/mpr/parser_microflow.go b/sdk/mpr/parser_microflow.go deleted file mode 100644 index 070b572fe2..0000000000 --- a/sdk/mpr/parser_microflow.go +++ /dev/null @@ -1,1246 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strconv" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func (r *Reader) parseMicroflow(unitID, containerID string, contents []byte) (*microflows.Microflow, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - return ParseMicroflowBSON(contents, model.ID(unitID), model.ID(containerID)) -} - -// ParseMicroflowBSON parses raw microflow BSON bytes into a Microflow. -// Unlike (*Reader).parseMicroflow it does not require a Reader, so it can -// parse arbitrary blobs (e.g. historical versions read via `git show`). -func ParseMicroflowBSON(contents []byte, unitID, containerID model.ID) (*microflows.Microflow, error) { - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - return ParseMicroflowFromRaw(raw, unitID, containerID), nil -} - -// ParseMicroflowFromRaw builds a Microflow from an already-unmarshalled BSON map. -// Useful when the caller already has the decoded map (e.g. diff-local). -func ParseMicroflowFromRaw(raw map[string]any, unitID, containerID model.ID) *microflows.Microflow { - mf := µflows.Microflow{} - mf.ID = unitID - mf.TypeName = "Microflows$Microflow" - mf.ContainerID = containerID - - if name, ok := raw["Name"].(string); ok { - mf.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - mf.Documentation = doc - } - if concurrent, ok := raw["AllowConcurrentExecution"].(bool); ok { - mf.AllowConcurrentExecution = concurrent - } - if markAsUsed, ok := raw["MarkAsUsed"].(bool); ok { - mf.MarkAsUsed = markAsUsed - } - if excluded, ok := raw["Excluded"].(bool); ok { - mf.Excluded = excluded - } - // A security setting: without reading it, a rewrite turned "apply entity - // access" OFF, widening what the microflow may read and write with nothing - // reporting it. - if applyEntityAccess, ok := raw["ApplyEntityAccess"].(bool); ok { - mf.ApplyEntityAccess = applyEntityAccess - } - - // Parse allowed module roles (BY_NAME references) - allowedRoles := extractBsonArray(raw["AllowedModuleRoles"]) - for _, r := range allowedRoles { - if name, ok := r.(string); ok { - mf.AllowedModuleRoles = append(mf.AllowedModuleRoles, model.ID(name)) - } - } - - // Parse parameters from MicroflowParameterCollection (new format) or MicroflowParameters/Parameters (old format) - var paramsArray any - if mpc, ok := raw["MicroflowParameterCollection"]; ok { - // New format: MicroflowParameterCollection contains Parameters array - if mpcMap := extractBsonMap(mpc); mpcMap != nil { - paramsArray = mpcMap["Parameters"] - } - } else { - // Old format: direct MicroflowParameters or Parameters field - paramKey := "MicroflowParameters" - if _, ok := raw[paramKey]; !ok { - paramKey = "Parameters" - } - paramsArray = raw[paramKey] - } - for _, p := range extractBsonSlice(paramsArray) { - if paramMap := extractBsonMap(p); paramMap != nil { - param := parseMicroflowParameter(paramMap, len(mf.Parameters)) - mf.Parameters = append(mf.Parameters, param) - } - } - - // Parse return type (Mendix uses "MicroflowReturnType") - if rt, ok := raw["MicroflowReturnType"].(map[string]any); ok { - mf.ReturnType = parseMicroflowDataType(rt) - } - - // Parse return variable name - if rvn, ok := raw["ReturnVariableName"].(string); ok { - mf.ReturnVariableName = rvn - } - - // Parse object collection (flow elements) - if oc := extractBsonMap(raw["ObjectCollection"]); oc != nil { - mf.ObjectCollection = parseMicroflowObjectCollection(oc) - } - - // Also extract parameters from ObjectCollection.Objects (modern format) - // Parameters are stored as Microflows$MicroflowParameter in ObjectCollection - if len(mf.Parameters) == 0 { - if ocRaw := extractBsonMap(raw["ObjectCollection"]); ocRaw != nil { - for _, obj := range extractBsonSlice(ocRaw["Objects"]) { - if objMap := extractBsonMap(obj); objMap != nil { - if typeName, _ := objMap["$Type"].(string); typeName == "Microflows$MicroflowParameter" { - param := parseMicroflowParameter(objMap, len(mf.Parameters)) - mf.Parameters = append(mf.Parameters, param) - } - } - } - } - } - - // Parse Flows array (SequenceFlows and AnnotationFlows are at root level, not in ObjectCollection) - if flowsRaw := raw["Flows"]; flowsRaw != nil { - if mf.ObjectCollection == nil { - mf.ObjectCollection = µflows.MicroflowObjectCollection{} - } - for _, f := range extractBsonSlice(flowsRaw) { - if flowMap := extractBsonMap(f); flowMap != nil { - typeName, _ := flowMap["$Type"].(string) - switch typeName { - case "Microflows$AnnotationFlow": - if af := parseAnnotationFlow(flowMap); af != nil { - mf.ObjectCollection.AnnotationFlows = append(mf.ObjectCollection.AnnotationFlows, af) - } - default: - if flow := parseSequenceFlow(flowMap); flow != nil { - mf.ObjectCollection.Flows = append(mf.ObjectCollection.Flows, flow) - } - } - } - } - } - - return mf -} - -// parseSequenceFlow parses a SequenceFlow from raw BSON data. -func parseSequenceFlow(raw map[string]any) *microflows.SequenceFlow { - flow := µflows.SequenceFlow{} - flow.ID = model.ID(extractBsonID(raw["$ID"])) - - // OriginPointer and DestinationPointer are binary IDs - flow.OriginID = model.ID(extractBsonID(raw["OriginPointer"])) - flow.DestinationID = model.ID(extractBsonID(raw["DestinationPointer"])) - - flow.OriginConnectionIndex = extractInt(raw["OriginConnectionIndex"]) - flow.DestinationConnectionIndex = extractInt(raw["DestinationConnectionIndex"]) - if isErr, ok := raw["IsErrorHandler"].(bool); ok { - flow.IsErrorHandler = isErr - } - - // Parse decision branch values. Newer Mendix versions store branch data - // in CaseValues ([marker, case]), while older projects use a single - // inline NewCaseValue document. - if caseVals := raw["CaseValues"]; caseVals != nil { - flow.CaseValue = parseCaseValues(caseVals) - } else if caseVal := raw["NewCaseValue"]; caseVal != nil { - flow.CaseValue = parseCaseValue(caseVal) - } - - // Parse BezierCurve control vectors from Line - if lineMap := extractBsonMap(raw["Line"]); lineMap != nil { - if v, ok := lineMap["OriginControlVector"].(string); ok { - flow.OriginControlVector = v - } - if v, ok := lineMap["DestinationControlVector"].(string); ok { - flow.DestinationControlVector = v - } - } - - return flow -} - -// parseCaseValues parses CaseValues from raw BSON data. -// CaseValues is stored as an array: [count_marker, case_object, ...] -// Usually [2] for empty, or [2, {case}] for a single case value. -func parseCaseValues(raw any) microflows.CaseValue { - arr := extractBsonSlice(raw) - if arr == nil { - return nil - } - - // Skip the count marker (first element), process actual case values - if len(arr) < 2 { - return nil // Empty array or just count marker - } - - // Parse the first case value (element at index 1) - return parseCaseValue(arr[1]) -} - -// parseCaseValue parses a single CaseValue from raw BSON data. -func parseCaseValue(raw any) microflows.CaseValue { - caseMap := extractBsonMap(raw) - if caseMap == nil { - return nil - } - - typeName, _ := caseMap["$Type"].(string) - id := model.ID(extractBsonID(caseMap["$ID"])) - switch typeName { - case "Microflows$NoCase": - return µflows.NoCase{BaseElement: model.BaseElement{ID: id}} - case "Microflows$ExpressionCase": - if expr, ok := caseMap["Expression"].(string); ok { - return µflows.ExpressionCase{ - BaseElement: model.BaseElement{ID: id}, - Expression: expr, - } - } - case "Microflows$EnumerationCase": - if val, ok := caseMap["Value"].(string); ok { - return µflows.EnumerationCase{ - BaseElement: model.BaseElement{ID: id}, - Value: val, - } - } - case "Microflows$InheritanceCase": - entityName := extractString(caseMap["Value"]) - if entityName == "" { - entityName = extractString(caseMap["Entity"]) - } - return µflows.InheritanceCase{ - BaseElement: model.BaseElement{ID: id}, - EntityID: model.ID(extractBsonID(caseMap["Entity"])), - EntityQualifiedName: entityName, - } - } - return nil -} - -// parseMicroflowParameter reads one Microflows$MicroflowParameter. idx is the -// parameter's ordinal in its flow, needed to tell a stored position that says -// something from one that is mxcli's own layout arithmetic handed back — see -// microflows.AuthoredParameterPosition. -func parseMicroflowParameter(raw map[string]any, idx int) *microflows.MicroflowParameter { - param := µflows.MicroflowParameter{} - - // Use extractBsonID to handle binary IDs - param.ID = model.ID(extractBsonID(raw["$ID"])) - if name, ok := raw["Name"].(string); ok { - param.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - param.Documentation = doc - } - // Parse parameter type - Mendix uses "VariableType" in ObjectCollection.Objects format - // and "ParameterType" in older formats - if pt := extractBsonMap(raw["VariableType"]); pt != nil { - param.Type = parseMicroflowDataType(pt) - } else if pt := extractBsonMap(raw["ParameterType"]); pt != nil { - param.Type = parseMicroflowDataType(pt) - } - if rmp, ok := raw["RelativeMiddlePoint"]; ok { - param.Position = microflows.AuthoredParameterPosition(parsePoint(rmp), idx) - } - - return param -} - -func parseMicroflowObjectCollection(raw map[string]any) *microflows.MicroflowObjectCollection { - collection := µflows.MicroflowObjectCollection{} - - // Handle various ID formats (string, binary, etc.) - collection.ID = model.ID(extractBsonID(raw["$ID"])) - - // Parse objects array (int32/int64 version markers are skipped by extractBsonMap returning nil) - for _, obj := range extractBsonSlice(raw["Objects"]) { - // Prefer primitive.D path to preserve field ordering for unknown types - if rawD, ok := obj.(primitive.D); ok { - typeName, _ := rawD.Map()["$Type"].(string) - if typeName == "" || typeName == "Microflows$MicroflowParameter" { - // Parameters are handled separately via mf.Parameters - continue - } - if fn, ok := microflowObjectParsers[typeName]; ok { - if mfObj := fn(rawD.Map()); mfObj != nil { - collection.Objects = append(collection.Objects, mfObj) - } - } else { - collection.Objects = append(collection.Objects, newUnknownObjectFromD(typeName, bson.D(rawD))) - } - continue - } - // Fallback for map[string]any - if objMap := extractBsonMap(obj); objMap != nil { - if typeName, _ := objMap["$Type"].(string); typeName == "Microflows$MicroflowParameter" { - continue // Parameters are handled separately - } - if mfObj := parseMicroflowObject(objMap); mfObj != nil { - collection.Objects = append(collection.Objects, mfObj) - } - } - } - - return collection -} - -// microflowObjectParsers maps Mendix $Type strings to their parser functions. -// Adding support for a new type requires only one new entry here. -// Declared as a nil var and populated in init() so that the map literal can -// reference parseLoopedActivity, which itself calls parseMicroflowObjectCollection, -// keeping the package-level initialization order unambiguous. -var microflowObjectParsers map[string]func(map[string]any) microflows.MicroflowObject - -func init() { - microflowObjectParsers = map[string]func(map[string]any) microflows.MicroflowObject{ - "Microflows$StartEvent": func(r map[string]any) microflows.MicroflowObject { return parseStartEvent(r) }, - "Microflows$EndEvent": func(r map[string]any) microflows.MicroflowObject { return parseEndEvent(r) }, - "Microflows$ErrorEvent": func(r map[string]any) microflows.MicroflowObject { return parseErrorEvent(r) }, - "Microflows$ActionActivity": func(r map[string]any) microflows.MicroflowObject { return parseActionActivity(r) }, - "Microflows$ExclusiveSplit": func(r map[string]any) microflows.MicroflowObject { return parseExclusiveSplit(r) }, - "Microflows$ExclusiveMerge": func(r map[string]any) microflows.MicroflowObject { return parseExclusiveMerge(r) }, - "Microflows$InheritanceSplit": func(r map[string]any) microflows.MicroflowObject { return parseInheritanceSplit(r) }, - "Microflows$LoopedActivity": func(r map[string]any) microflows.MicroflowObject { return parseLoopedActivity(r) }, - "Microflows$BreakEvent": func(r map[string]any) microflows.MicroflowObject { return parseBreakEvent(r) }, - "Microflows$ContinueEvent": func(r map[string]any) microflows.MicroflowObject { return parseContinueEvent(r) }, - "Microflows$Annotation": func(r map[string]any) microflows.MicroflowObject { return parseMicroflowAnnotation(r) }, - } -} - -// parseMicroflowObject parses a single microflow object based on its $Type. -// Returns nil for elements with an empty $Type (corrupt or placeholder records). -func parseMicroflowObject(raw map[string]any) microflows.MicroflowObject { - typeName, _ := raw["$Type"].(string) - if typeName == "" { - return nil - } - if fn, ok := microflowObjectParsers[typeName]; ok { - return fn(raw) - } - return newUnknownObject(typeName, raw) -} - -func parseStartEvent(raw map[string]any) *microflows.StartEvent { - event := µflows.StartEvent{} - event.ID = model.ID(extractBsonID(raw["$ID"])) - event.Position = parsePoint(raw["RelativeMiddlePoint"]) - event.Size = parseSize(raw["Size"]) - return event -} - -func parseEndEvent(raw map[string]any) *microflows.EndEvent { - event := µflows.EndEvent{} - event.ID = model.ID(extractBsonID(raw["$ID"])) - event.Position = parsePoint(raw["RelativeMiddlePoint"]) - event.Size = parseSize(raw["Size"]) - event.ReturnValue = extractString(raw["ReturnValue"]) - return event -} - -func parseErrorEvent(raw map[string]any) *microflows.ErrorEvent { - event := µflows.ErrorEvent{} - event.ID = model.ID(extractBsonID(raw["$ID"])) - event.Position = parsePoint(raw["RelativeMiddlePoint"]) - event.Size = parseSize(raw["Size"]) - return event -} - -func parseBreakEvent(raw map[string]any) *microflows.BreakEvent { - event := µflows.BreakEvent{} - event.ID = model.ID(extractBsonID(raw["$ID"])) - event.Position = parsePoint(raw["RelativeMiddlePoint"]) - event.Size = parseSize(raw["Size"]) - return event -} - -func parseContinueEvent(raw map[string]any) *microflows.ContinueEvent { - event := µflows.ContinueEvent{} - event.ID = model.ID(extractBsonID(raw["$ID"])) - event.Position = parsePoint(raw["RelativeMiddlePoint"]) - event.Size = parseSize(raw["Size"]) - return event -} - -func parseExclusiveSplit(raw map[string]any) *microflows.ExclusiveSplit { - split := µflows.ExclusiveSplit{} - split.ID = model.ID(extractBsonID(raw["$ID"])) - split.Position = parsePoint(raw["RelativeMiddlePoint"]) - split.Size = parseSize(raw["Size"]) - split.Caption = extractString(raw["Caption"]) - split.Documentation = extractString(raw["Documentation"]) - - // Parse split condition - if condition, ok := raw["SplitCondition"].(map[string]any); ok { - split.SplitCondition = parseSplitCondition(condition) - } - - return split -} - -func parseExclusiveMerge(raw map[string]any) *microflows.ExclusiveMerge { - merge := µflows.ExclusiveMerge{} - merge.ID = model.ID(extractBsonID(raw["$ID"])) - merge.Position = parsePoint(raw["RelativeMiddlePoint"]) - merge.Size = parseSize(raw["Size"]) - return merge -} - -func parseInheritanceSplit(raw map[string]any) *microflows.InheritanceSplit { - split := µflows.InheritanceSplit{} - split.ID = model.ID(extractBsonID(raw["$ID"])) - split.Position = parsePoint(raw["RelativeMiddlePoint"]) - split.Size = parseSize(raw["Size"]) - split.Caption = extractString(raw["Caption"]) - split.Documentation = extractString(raw["Documentation"]) - split.VariableName = extractString(raw["SplitVariableName"]) - return split -} - -func parseLoopedActivity(raw map[string]any) *microflows.LoopedActivity { - loop := µflows.LoopedActivity{} - loop.ID = model.ID(extractBsonID(raw["$ID"])) - loop.Position = parsePoint(raw["RelativeMiddlePoint"]) - loop.Size = parseSize(raw["Size"]) - loop.Caption = extractString(raw["Caption"]) - loop.Documentation = extractString(raw["Documentation"]) - - // Parse LoopSource (IterableList or WhileLoopCondition) - if loopSourceMap := extractBsonMap(raw["LoopSource"]); loopSourceMap != nil { - typeName := extractString(loopSourceMap["$Type"]) - switch typeName { - case "Microflows$WhileLoopCondition": - loop.LoopSource = µflows.WhileLoopCondition{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(loopSourceMap["$ID"]))}, - WhileExpression: extractString(loopSourceMap["WhileExpression"]), - } - default: // Microflows$IterableList - loop.LoopSource = µflows.IterableList{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(loopSourceMap["$ID"]))}, - ListVariableName: extractString(loopSourceMap["ListVariableName"]), - VariableName: extractString(loopSourceMap["VariableName"]), - } - } - } - - // Parse nested object collection - if oc := extractBsonMap(raw["ObjectCollection"]); oc != nil { - loop.ObjectCollection = parseMicroflowObjectCollection(oc) - } - - return loop -} - -func parseMicroflowAnnotation(raw map[string]any) *microflows.Annotation { - annot := µflows.Annotation{} - annot.ID = model.ID(extractBsonID(raw["$ID"])) - annot.Position = parsePoint(raw["RelativeMiddlePoint"]) - annot.Size = parseSize(raw["Size"]) - annot.Caption = extractString(raw["Caption"]) - return annot -} - -// parseAnnotationFlow parses an AnnotationFlow from raw BSON data. -func parseAnnotationFlow(raw map[string]any) *microflows.AnnotationFlow { - flow := µflows.AnnotationFlow{} - flow.ID = model.ID(extractBsonID(raw["$ID"])) - flow.OriginID = model.ID(extractBsonID(raw["OriginPointer"])) - flow.DestinationID = model.ID(extractBsonID(raw["DestinationPointer"])) - return flow -} - -func parseSplitCondition(raw map[string]any) microflows.SplitCondition { - typeName, _ := raw["$Type"].(string) - - switch typeName { - case "Microflows$ExpressionSplitCondition": - return µflows.ExpressionSplitCondition{ - Expression: extractString(raw["Expression"]), - } - case "Microflows$RuleSplitCondition": - cond := µflows.RuleSplitCondition{} - // Mendix nests the rule reference under a RuleCall sub-document whose - // "Microflow" field holds the rule's qualified name (rules share the - // microflow namespace). Parameter mappings are scoped inside RuleCall too. - rcSource := raw - if rc := extractBsonMap(raw["RuleCall"]); rc != nil { - cond.RuleQualifiedName = extractString(rc["Microflow"]) - rcSource = rc - } - for _, m := range extractBsonArray(rcSource["ParameterMappings"]) { - mMap := extractBsonMap(m) - if mMap == nil { - continue - } - mapping := µflows.RuleCallParameterMapping{ - ParameterID: model.ID(extractBsonID(mMap["Parameter"])), - ParameterName: extractString(mMap["Parameter"]), - Argument: extractString(mMap["Argument"]), - } - cond.ParameterMappings = append(cond.ParameterMappings, mapping) - } - return cond - default: - return nil - } -} - -func parseActionActivity(raw map[string]any) *microflows.ActionActivity { - activity := µflows.ActionActivity{} - activity.ID = model.ID(extractBsonID(raw["$ID"])) - activity.Position = parsePoint(raw["RelativeMiddlePoint"]) - activity.Size = parseSize(raw["Size"]) - activity.Caption = extractString(raw["Caption"]) - activity.Documentation = extractString(raw["Documentation"]) - activity.AutoGenerateCaption = extractBool(raw["AutoGenerateCaption"], false) - activity.BackgroundColor = extractString(raw["BackgroundColor"]) - activity.Disabled = extractBool(raw["Disabled"], false) - - if errorHandling, ok := raw["ErrorHandlingType"].(string); ok { - activity.ErrorHandlingType = microflows.ErrorHandlingType(errorHandling) - } - - // Parse the action. - if action := parseMicroflowActionValue(raw["Action"]); action != nil { - activity.Action = action - } - - return activity -} - -// microflowActionParsers maps Mendix $Type strings to their action parser functions. -// Storage names (e.g. CreateChangeAction) and qualified names (e.g. CreateObjectAction) -// both map to the same parser to handle BSON format variations. -var microflowActionParsers = map[string]func(map[string]any) microflows.MicroflowAction{ - // Variable actions - "Microflows$CreateVariableAction": func(r map[string]any) microflows.MicroflowAction { return parseCreateVariableAction(r) }, - "Microflows$ChangeVariableAction": func(r map[string]any) microflows.MicroflowAction { return parseChangeVariableAction(r) }, - - // Object actions (storageName may differ from qualifiedName) - "Microflows$CreateObjectAction": func(r map[string]any) microflows.MicroflowAction { return parseCreateObjectAction(r) }, - "Microflows$CreateChangeAction": func(r map[string]any) microflows.MicroflowAction { return parseCreateObjectAction(r) }, - "Microflows$ChangeObjectAction": func(r map[string]any) microflows.MicroflowAction { return parseChangeObjectAction(r) }, - "Microflows$ChangeAction": func(r map[string]any) microflows.MicroflowAction { return parseChangeObjectAction(r) }, - "Microflows$DeleteAction": func(r map[string]any) microflows.MicroflowAction { return parseDeleteAction(r) }, - "Microflows$CommitAction": func(r map[string]any) microflows.MicroflowAction { return parseCommitAction(r) }, - "Microflows$RollbackAction": func(r map[string]any) microflows.MicroflowAction { return parseRollbackAction(r) }, - - // Retrieve actions - "Microflows$RetrieveAction": func(r map[string]any) microflows.MicroflowAction { return parseRetrieveAction(r) }, - "Microflows$AggregateListAction": func(r map[string]any) microflows.MicroflowAction { return parseAggregateListAction(r) }, - "Microflows$AggregateAction": func(r map[string]any) microflows.MicroflowAction { return parseAggregateListAction(r) }, - - // List actions - "Microflows$CreateListAction": func(r map[string]any) microflows.MicroflowAction { return parseCreateListAction(r) }, - "Microflows$ChangeListAction": func(r map[string]any) microflows.MicroflowAction { return parseChangeListAction(r) }, - "Microflows$ListOperationAction": func(r map[string]any) microflows.MicroflowAction { return parseListOperationAction(r) }, - "Microflows$ListOperationsAction": func(r map[string]any) microflows.MicroflowAction { return parseListOperationAction(r) }, - - // Integration actions - "Microflows$MicroflowCallAction": func(r map[string]any) microflows.MicroflowAction { return parseMicroflowCallAction(r) }, - "Microflows$NanoflowCallAction": func(r map[string]any) microflows.MicroflowAction { return parseNanoflowCallAction(r) }, - "Microflows$JavaActionCallAction": func(r map[string]any) microflows.MicroflowAction { return parseJavaActionCallAction(r) }, - "Microflows$JavaScriptActionCallAction": func(r map[string]any) microflows.MicroflowAction { return parseJavaScriptActionCallAction(r) }, - "Microflows$CallExternalAction": func(r map[string]any) microflows.MicroflowAction { return parseCallExternalAction(r) }, - "Microflows$CallWebServiceAction": func(r map[string]any) microflows.MicroflowAction { return parseWebServiceCallAction(r) }, - - // Client actions (ShowFormAction is storageName for ShowPageAction) - "Microflows$ShowFormAction": func(r map[string]any) microflows.MicroflowAction { return parseShowPageAction(r) }, - "Microflows$ShowPageAction": func(r map[string]any) microflows.MicroflowAction { return parseShowPageAction(r) }, - "Microflows$ShowHomePageAction": func(r map[string]any) microflows.MicroflowAction { return parseShowHomePageAction(r) }, - "Microflows$CloseFormAction": func(r map[string]any) microflows.MicroflowAction { return parseClosePageAction(r) }, - "Microflows$ShowMessageAction": func(r map[string]any) microflows.MicroflowAction { return parseShowMessageAction(r) }, - "Microflows$ValidationFeedbackAction": func(r map[string]any) microflows.MicroflowAction { return parseValidationFeedbackAction(r) }, - "Microflows$DownloadFileAction": func(r map[string]any) microflows.MicroflowAction { return parseDownloadFileAction(r) }, - - // Log action - "Microflows$LogMessageAction": func(r map[string]any) microflows.MicroflowAction { return parseLogMessageAction(r) }, - - // Cast action - "Microflows$CastAction": func(r map[string]any) microflows.MicroflowAction { return parseCastAction(r) }, - - // REST call action (inline HTTP) - "Microflows$RestCallAction": func(r map[string]any) microflows.MicroflowAction { return parseRestCallAction(r) }, - - // REST operation call action (consumed REST service) - "Microflows$RestOperationCallAction": func(r map[string]any) microflows.MicroflowAction { - return parseRestOperationCallAction(r) - }, - - // Import/Export mapping actions - "Microflows$ImportXmlAction": func(r map[string]any) microflows.MicroflowAction { return parseImportXmlAction(r) }, - "Microflows$ExportXmlAction": func(r map[string]any) microflows.MicroflowAction { return parseExportXmlAction(r) }, - - // Data transformer action - "Microflows$TransformJsonAction": func(r map[string]any) microflows.MicroflowAction { return parseTransformJsonAction(r) }, - - // Database Connector action - "DatabaseConnector$ExecuteDatabaseQueryAction": func(r map[string]any) microflows.MicroflowAction { return parseExecuteDatabaseQueryAction(r) }, - - // Workflow actions - "Microflows$WorkflowCallAction": func(r map[string]any) microflows.MicroflowAction { return parseWorkflowCallAction(r) }, - "Microflows$GetWorkflowDataAction": func(r map[string]any) microflows.MicroflowAction { return parseGetWorkflowDataAction(r) }, - "Microflows$GetWorkflowsAction": func(r map[string]any) microflows.MicroflowAction { return parseGetWorkflowsAction(r) }, - "Microflows$GetWorkflowActivityRecordsAction": func(r map[string]any) microflows.MicroflowAction { return parseGetWorkflowActivityRecordsAction(r) }, - "Microflows$WorkflowOperationAction": func(r map[string]any) microflows.MicroflowAction { return parseWorkflowOperationAction(r) }, - "Microflows$SetTaskOutcomeAction": func(r map[string]any) microflows.MicroflowAction { return parseSetTaskOutcomeAction(r) }, - "Microflows$OpenUserTaskAction": func(r map[string]any) microflows.MicroflowAction { return parseOpenUserTaskAction(r) }, - "Microflows$NotifyWorkflowAction": func(r map[string]any) microflows.MicroflowAction { return parseNotifyWorkflowAction(r) }, - "Microflows$OpenWorkflowAction": func(r map[string]any) microflows.MicroflowAction { return parseOpenWorkflowAction(r) }, - "Microflows$LockWorkflowAction": func(r map[string]any) microflows.MicroflowAction { return parseLockWorkflowAction(r) }, - "Microflows$UnlockWorkflowAction": func(r map[string]any) microflows.MicroflowAction { return parseUnlockWorkflowAction(r) }, -} - -// parseMicroflowAction parses a microflow action based on its $Type. -func parseMicroflowAction(raw map[string]any) microflows.MicroflowAction { - typeName, _ := raw["$Type"].(string) - if fn, ok := microflowActionParsers[typeName]; ok { - return fn(raw) - } - return µflows.UnknownAction{TypeName: typeName} -} - -func parseMicroflowActionValue(raw any) microflows.MicroflowAction { - switch action := raw.(type) { - case primitive.D: - actionMap := action.Map() - typeName, _ := actionMap["$Type"].(string) - if typeName == "Microflows$CallWebServiceAction" { - return parseWebServiceCallActionFromD(action) - } - return parseMicroflowAction(actionMap) - case map[string]any: - return parseMicroflowAction(action) - case primitive.M: - return parseMicroflowAction(map[string]any(action)) - default: - if actionMap := extractBsonMap(raw); actionMap != nil { - return parseMicroflowAction(actionMap) - } - return nil - } -} - -func parseCreateVariableAction(raw map[string]any) *microflows.CreateVariableAction { - action := µflows.CreateVariableAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.VariableName = extractString(raw["VariableName"]) - action.InitialValue = extractString(raw["InitialValue"]) - - if dt, ok := raw["VariableType"].(map[string]any); ok { - action.DataType = parseMicroflowDataType(dt) - } - - return action -} - -func parseChangeVariableAction(raw map[string]any) *microflows.ChangeVariableAction { - action := µflows.ChangeVariableAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.VariableName = extractString(raw["ChangeVariableName"]) - action.Value = extractString(raw["Value"]) - return action -} - -func parseCreateObjectAction(raw map[string]any) *microflows.CreateObjectAction { - action := µflows.CreateObjectAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - // Entity is BY_NAME_REFERENCE - can be string (qualified name) or binary (legacy) - if entityStr, ok := raw["Entity"].(string); ok { - action.EntityQualifiedName = entityStr - } else { - action.EntityID = model.ID(extractBsonID(raw["Entity"])) - } - // OutputVariable has storageName "VariableName" but qualifiedName "OutputVariableName" - action.OutputVariable = extractString(raw["VariableName"]) - if action.OutputVariable == "" { - action.OutputVariable = extractString(raw["OutputVariableName"]) - } - action.RefreshInClient = extractBool(raw["RefreshInClient"], false) - - if commit, ok := raw["Commit"].(string); ok { - action.Commit = microflows.CommitType(commit) - } - - // Parse initial member values - for _, item := range extractBsonSlice(raw["Items"]) { - if itemMap := extractBsonMap(item); itemMap != nil { - if change := parseMemberChange(itemMap); change != nil { - action.InitialMembers = append(action.InitialMembers, change) - } - } - } - - return action -} - -func parseChangeObjectAction(raw map[string]any) *microflows.ChangeObjectAction { - action := µflows.ChangeObjectAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ChangeVariable = extractString(raw["ChangeVariableName"]) - action.RefreshInClient = extractBool(raw["RefreshInClient"], false) - - if commit, ok := raw["Commit"].(string); ok { - action.Commit = microflows.CommitType(commit) - } - - // Parse member changes - for _, item := range extractBsonSlice(raw["Items"]) { - if itemMap := extractBsonMap(item); itemMap != nil { - if change := parseMemberChange(itemMap); change != nil { - action.Changes = append(action.Changes, change) - } - } - } - - return action -} - -func parseMemberChange(raw map[string]any) *microflows.MemberChange { - change := µflows.MemberChange{} - change.ID = model.ID(extractBsonID(raw["$ID"])) - - // Attribute can be BY_NAME_REFERENCE (string) or BY_ID (binary) - if attrStr, ok := raw["Attribute"].(string); ok { - change.AttributeQualifiedName = attrStr - } else { - change.AttributeID = model.ID(extractBsonID(raw["Attribute"])) - } - - // Association can be BY_NAME_REFERENCE (string) or BY_ID (binary) - if assocStr, ok := raw["Association"].(string); ok { - change.AssociationQualifiedName = assocStr - } else { - change.AssociationID = model.ID(extractBsonID(raw["Association"])) - } - - change.Value = extractString(raw["Value"]) - - if changeType, ok := raw["Type"].(string); ok { - change.Type = microflows.MemberChangeType(changeType) - } - - return change -} - -func parseDeleteAction(raw map[string]any) *microflows.DeleteObjectAction { - action := µflows.DeleteObjectAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.DeleteVariable = extractString(raw["DeleteVariableName"]) - action.RefreshInClient = extractBool(raw["RefreshInClient"], false) - return action -} - -func parseCommitAction(raw map[string]any) *microflows.CommitObjectsAction { - action := µflows.CommitObjectsAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.CommitVariable = extractString(raw["CommitVariableName"]) - action.WithEvents = extractBool(raw["WithEvents"], false) - action.RefreshInClient = extractBool(raw["RefreshInClient"], false) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - if action.ErrorHandlingType == "" { - action.ErrorHandlingType = microflows.ErrorHandlingTypeRollback - } - return action -} - -func parseRollbackAction(raw map[string]any) *microflows.RollbackObjectAction { - action := µflows.RollbackObjectAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.RollbackVariable = extractString(raw["RollbackVariableName"]) - action.RefreshInClient = extractBool(raw["RefreshInClient"], false) - return action -} - -func parseRetrieveAction(raw map[string]any) *microflows.RetrieveAction { - action := µflows.RetrieveAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - // Writer uses "ResultVariableName" as the storage name - action.OutputVariable = extractString(raw["ResultVariableName"]) - - // Parse retrieve source - if source, ok := raw["RetrieveSource"].(map[string]any); ok { - action.Source = parseRetrieveSource(source) - } - - return action -} - -// parseSortItems parses sort items from a BSON map that wraps a SortItemList. -// It tries multiple field-name conventions (modern and legacy storage names). -func parseSortItems(raw map[string]any) []*microflows.SortItem { - // Try field names: "sortItemList", "NewSortings", "Sortings", "SortItemList" - var listMap map[string]any - for _, key := range []string{"sortItemList", "NewSortings", "Sortings", "SortItemList"} { - if m := extractBsonMap(raw[key]); m != nil { - listMap = m - break - } - } - if listMap == nil { - return nil - } - - // Extract items array — try "items", "Sortings", "Items" - var items []any - for _, key := range []string{"items", "Sortings", "Items"} { - if s := extractBsonSlice(listMap[key]); s != nil { - items = s - break - } - } - - var result []*microflows.SortItem - for _, item := range items { - itemMap := extractBsonMap(item) - if itemMap == nil { - continue - } - sortItem := µflows.SortItem{} - sortItem.ID = model.ID(extractBsonID(itemMap["$ID"])) - - // Try AttributeRef (modern: DomainModels$AttributeRef with BY_NAME_REFERENCE) - if attrRefMap := extractBsonMap(itemMap["AttributeRef"]); attrRefMap != nil { - if attrStr, ok := attrRefMap["Attribute"].(string); ok { - sortItem.AttributeQualifiedName = attrStr - } else { - sortItem.AttributeID = model.ID(extractBsonID(attrRefMap["Attribute"])) - } - sortItem.EntityRefSteps = parseEntityRefSteps(attrRefMap["EntityRef"]) - } - - // Fall back to AttributePath (legacy) - if sortItem.AttributeQualifiedName == "" && sortItem.AttributeID == "" { - if attrStr, ok := itemMap["AttributePath"].(string); ok { - sortItem.AttributeQualifiedName = attrStr - } else { - sortItem.AttributeID = model.ID(extractBsonID(itemMap["AttributePath"])) - } - } - - if dir, ok := itemMap["SortOrder"].(string); ok { - sortItem.Direction = microflows.SortDirection(dir) - } - result = append(result, sortItem) - } - return result -} - -func parseEntityRefSteps(raw any) []microflows.EntityRefStep { - entityRefMap := extractBsonMap(raw) - if entityRefMap == nil { - return nil - } - items := extractBsonSlice(entityRefMap["Steps"]) - if len(items) == 0 { - return nil - } - var steps []microflows.EntityRefStep - for _, item := range items { - itemMap := extractBsonMap(item) - if itemMap == nil { - continue - } - step := microflows.EntityRefStep{ - Association: extractString(itemMap["Association"]), - DestinationEntity: extractString(itemMap["DestinationEntity"]), - } - if step.Association != "" || step.DestinationEntity != "" { - steps = append(steps, step) - } - } - return steps -} - -func parseRetrieveSource(raw map[string]any) microflows.RetrieveSource { - typeName, _ := raw["$Type"].(string) - - switch typeName { - case "Microflows$DatabaseRetrieveSource": - source := µflows.DatabaseRetrieveSource{} - source.ID = model.ID(extractBsonID(raw["$ID"])) - // Entity can be stored as string (BY_NAME_REFERENCE) or binary ID - if entityStr, ok := raw["Entity"].(string); ok { - source.EntityQualifiedName = entityStr - } else { - source.EntityID = model.ID(extractBsonID(raw["Entity"])) - } - // XPath constraint - Studio Pro uses lowercase 'p' (XpathConstraint), but we also support uppercase for backwards compatibility - source.XPathConstraint = extractString(raw["XpathConstraint"]) - if source.XPathConstraint == "" { - source.XPathConstraint = extractString(raw["XPathConstraint"]) - } - - // Parse range - if rangeMap, ok := raw["Range"].(map[string]any); ok { - source.Range = parseRange(rangeMap) - } - - // Parse sorting - source.Sorting = parseSortItems(raw) - - return source - - case "Microflows$AssociationRetrieveSource": - source := µflows.AssociationRetrieveSource{} - source.ID = model.ID(extractBsonID(raw["$ID"])) - source.StartVariable = extractString(raw["StartVariableName"]) - source.AssociationID = model.ID(extractBsonID(raw["Association"])) - // AssociationId contains BY_NAME_REFERENCE (qualified name) - source.AssociationQualifiedName = extractString(raw["AssociationId"]) - return source - - default: - return nil - } -} - -func parseRange(raw map[string]any) *microflows.Range { - typeName, _ := raw["$Type"].(string) - - r := µflows.Range{} - r.ID = model.ID(extractBsonID(raw["$ID"])) - - switch typeName { - case "Microflows$ConstantRange": - // MEASURED (Mendix 11.13.0, ako/TestApp MyFirstModule.RetrieveExamples — - // three retrieves, one per UI option): - // - // All ConstantRange{SingleObject:false} - // First ConstantRange{SingleObject:true} - // Custom CustomRange{LimitExpression, OffsetExpression} - // - // So a ConstantRange carries ONLY SingleObject, exactly as - // generated/metamodel and modelsdk/gen declare it, and the Limit/Offset - // read below has never been observed to fire. It is kept as tolerance - // for formats we have not sampled (no pre-11 document has been checked), - // NOT because Studio Pro is known to write that shape — an earlier - // comment here asserted it did, which is false for 11.13 and nearly cost - // a phantom bug report against the other engine. - // - // The engines differ on this input and that is deliberate: modelsdk's - // rangeFromGen cannot read it at all, because gen binds only - // SingleObject on ConstantRange. If a real document ever turns up with - // Limit on a ConstantRange, that asymmetry becomes a data-loss bug and - // gen needs a property override — see CLAUDE.md on gen's wrong keys. - r.Limit = extractString(raw["LimitExpression"]) - r.Offset = extractString(raw["OffsetExpression"]) - if singleObject := extractBool(raw["SingleObject"], false); singleObject { - r.RangeType = microflows.RangeTypeFirst - } else if r.Limit != "" || r.Offset != "" { - r.RangeType = microflows.RangeTypeCustom - } else { - r.RangeType = microflows.RangeTypeAll - } - case "Microflows$CustomRange": - r.RangeType = microflows.RangeTypeCustom - r.Limit = extractString(raw["LimitExpression"]) - r.Offset = extractString(raw["OffsetExpression"]) - default: - r.RangeType = microflows.RangeTypeAll - } - - return r -} - -func parseAggregateListAction(raw map[string]any) *microflows.AggregateListAction { - action := µflows.AggregateListAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // Storage name is AggregateVariableName, qualified name is inputListVariableName - action.InputVariable = extractString(raw["AggregateVariableName"]) - if action.InputVariable == "" { - action.InputVariable = extractString(raw["InputListVariableName"]) - } - // Storage name is VariableName, qualified name is outputVariableName - action.OutputVariable = extractString(raw["VariableName"]) - if action.OutputVariable == "" { - action.OutputVariable = extractString(raw["OutputVariableName"]) - } - - // Attribute is BY_NAME_REFERENCE - can be string (qualified name) or binary (legacy ID) - if attrStr, ok := raw["Attribute"].(string); ok { - action.AttributeQualifiedName = attrStr - } else { - action.AttributeID = model.ID(extractBsonID(raw["Attribute"])) - } - - if fn, ok := raw["AggregateFunction"].(string); ok { - action.Function = microflows.AggregateFunction(fn) - } - - if useExpr, ok := raw["UseExpression"].(bool); ok { - action.UseExpression = useExpr - } - if action.UseExpression { - action.Expression = extractString(raw["Expression"]) - } - - // Reduce's fold: what it starts from and what it folds to. Studio Pro writes - // both on every AggregateAction (empty initial value on All/Any), so read - // them unconditionally rather than only for Reduce — a rewrite that dropped - // them silently deleted the fold (#1004). - action.ReduceInitialValue = extractString(raw["ReduceInitialValueExpression"]) - if rt, ok := raw["ReduceReturnDataType"].(map[string]any); ok { - action.ReduceReturnType = parseMicroflowDataType(rt) - } - - return action -} - -func parseCreateListAction(raw map[string]any) *microflows.CreateListAction { - action := µflows.CreateListAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // Entity is BY_NAME_REFERENCE - can be string (qualified name) or binary (legacy) - if entityStr, ok := raw["Entity"].(string); ok { - action.EntityQualifiedName = entityStr - } else { - action.EntityID = model.ID(extractBsonID(raw["Entity"])) - } - action.OutputVariable = extractString(raw["VariableName"]) - return action -} - -func parseChangeListAction(raw map[string]any) *microflows.ChangeListAction { - action := µflows.ChangeListAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ChangeVariable = extractString(raw["ChangeVariableName"]) - action.Value = extractString(raw["Value"]) - if t, ok := raw["Type"].(string); ok { - action.Type = microflows.ChangeListType(t) - } - return action -} - -func parseListOperationAction(raw map[string]any) *microflows.ListOperationAction { - action := µflows.ListOperationAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.OutputVariable = extractString(raw["ResultVariableName"]) - - // Parse the operation from NewOperation (storage name for operation) - if opRaw, ok := raw["NewOperation"].(map[string]any); ok { - action.Operation = parseListOperation(opRaw) - } - - return action -} - -func parseListOperation(raw map[string]any) microflows.ListOperation { - typeName, _ := raw["$Type"].(string) - listVar := extractString(raw["ListName"]) - id := model.ID(extractBsonID(raw["$ID"])) - - switch typeName { - case "Microflows$Head": - return µflows.HeadOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - } - case "Microflows$Tail": - return µflows.TailOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - } - case "Microflows$Find": - return µflows.FindByAttributeOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - Attribute: extractString(raw["Attribute"]), - Association: extractString(raw["Association"]), - Expression: extractString(raw["Expression"]), - } - case "Microflows$FindByExpression": - return µflows.FindOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - Expression: extractString(raw["Expression"]), - } - case "Microflows$Filter": - return µflows.FilterByAttributeOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - Attribute: extractString(raw["Attribute"]), - Association: extractString(raw["Association"]), - Expression: extractString(raw["Expression"]), - } - case "Microflows$FilterByExpression": - return µflows.FilterOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - Expression: extractString(raw["Expression"]), - } - case "Microflows$Sort": - sortOp := µflows.SortOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - } - sortOp.Sorting = parseSortItems(raw) - return sortOp - case "Microflows$Union": - return µflows.UnionOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable1: listVar, - ListVariable2: extractString(raw["SecondListOrObjectName"]), - } - case "Microflows$Intersect": - return µflows.IntersectOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable1: listVar, - ListVariable2: extractString(raw["SecondListOrObjectName"]), - } - case "Microflows$Subtract": - return µflows.SubtractOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable1: listVar, - ListVariable2: extractString(raw["SecondListOrObjectName"]), - } - case "Microflows$Contains": - return µflows.ContainsOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - ObjectVariable: extractString(raw["SecondListOrObjectName"]), - } - case "Microflows$ListEquals": - return µflows.EqualsOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable1: listVar, - ListVariable2: extractString(raw["SecondListOrObjectName"]), - } - case "Microflows$ListRange": - rangeOp := µflows.ListRangeOperation{ - BaseElement: model.BaseElement{ID: id}, - ListVariable: listVar, - } - if cr := extractBsonMap(raw["CustomRange"]); cr != nil { - rangeOp.LimitExpression = extractString(cr["LimitExpression"]) - rangeOp.OffsetExpression = extractString(cr["OffsetExpression"]) - } - return rangeOp - default: - return nil - } -} - -func parseMicroflowDataType(raw map[string]any) microflows.DataType { - typeName, _ := raw["$Type"].(string) - - switch typeName { - case "DataTypes$BooleanType": - return µflows.BooleanType{} - case "DataTypes$IntegerType": - return µflows.IntegerType{} - case "DataTypes$LongType": - return µflows.LongType{} - case "DataTypes$DecimalType": - return µflows.DecimalType{} - case "DataTypes$StringType": - return µflows.StringType{} - case "DataTypes$DateTimeType": - return µflows.DateTimeType{} - case "DataTypes$BinaryType": - return µflows.BinaryType{} - case "DataTypes$VoidType": - return µflows.VoidType{} - case "DataTypes$ObjectType": - objType := µflows.ObjectType{} - // Entity can be BY_NAME_REFERENCE (string) or binary ID (legacy) - if entityStr, ok := raw["Entity"].(string); ok { - objType.EntityQualifiedName = entityStr - } else { - objType.EntityID = model.ID(extractBsonID(raw["Entity"])) - } - return objType - case "DataTypes$ListType": - listType := µflows.ListType{} - // Entity can be BY_NAME_REFERENCE (string) or binary ID (legacy) - if entityStr, ok := raw["Entity"].(string); ok { - listType.EntityQualifiedName = entityStr - } else { - listType.EntityID = model.ID(extractBsonID(raw["Entity"])) - } - return listType - case "DataTypes$EnumerationType": - enumType := µflows.EnumerationType{} - // Enumeration can be BY_NAME_REFERENCE (string) or binary ID (legacy) - if enumStr, ok := raw["Enumeration"].(string); ok { - enumType.EnumerationQualifiedName = enumStr - } else { - enumType.EnumerationID = model.ID(extractBsonID(raw["Enumeration"])) - } - return enumType - default: - return nil - } -} - -func parsePoint(raw any) model.Point { - switch v := raw.(type) { - case map[string]any: - return model.Point{ - X: extractInt(v["X"]), - Y: extractInt(v["Y"]), - } - case string: - // MPR v2 stores positions as "X;Y" strings, e.g. "570;297" - parts := strings.SplitN(v, ";", 2) - if len(parts) == 2 { - x, _ := strconv.Atoi(strings.TrimSpace(parts[0])) - y, _ := strconv.Atoi(strings.TrimSpace(parts[1])) - return model.Point{X: x, Y: y} - } - } - return model.Point{} -} - -// parseSize parses a Size from raw BSON data (stored as "W;H" string). -func parseSize(raw any) model.Size { - if s, ok := raw.(string); ok { - parts := strings.SplitN(s, ";", 2) - if len(parts) == 2 { - w, _ := strconv.Atoi(strings.TrimSpace(parts[0])) - h, _ := strconv.Atoi(strings.TrimSpace(parts[1])) - return model.Size{Width: w, Height: h} - } - } - return model.Size{} -} - -// parseNanoflow parses nanoflow contents from BSON. diff --git a/sdk/mpr/parser_microflow_actions.go b/sdk/mpr/parser_microflow_actions.go deleted file mode 100644 index b41ff8a5fc..0000000000 --- a/sdk/mpr/parser_microflow_actions.go +++ /dev/null @@ -1,1073 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func parseCallExternalAction(raw map[string]any) *microflows.CallExternalAction { - action := µflows.CallExternalAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ConsumedODataService = extractString(raw["ConsumedODataService"]) - action.Name = extractString(raw["Name"]) - action.ResultVariableName = extractString(raw["VariableName"]) - action.UseReturnVariable = action.ResultVariableName != "" - - // Parse parameter mappings - if mappings := extractBsonArray(raw["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.ExternalActionParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.ParameterName = extractString(mMap["ParameterName"]) - mapping.Argument = extractString(mMap["Argument"]) - mapping.CanBeEmpty = extractBool(mMap["CanBeEmpty"], false) - action.ParameterMappings = append(action.ParameterMappings, mapping) - } - } - } - - return action -} - -func parseMicroflowCallAction(raw map[string]any) *microflows.MicroflowCallAction { - action := µflows.MicroflowCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ResultVariableName = extractString(raw["ResultVariableName"]) - action.UseReturnVariable = extractBool(raw["UseReturnVariable"], false) - - // Parse nested MicroflowCall structure - if mfCall, ok := raw["MicroflowCall"].(map[string]any); ok { - call := µflows.MicroflowCall{} - call.ID = model.ID(extractBsonID(mfCall["$ID"])) - call.Microflow = extractString(mfCall["Microflow"]) - - // Parse parameter mappings from MicroflowCall (use extractBsonArray for BSON array format) - if mappings := extractBsonArray(mfCall["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.MicroflowCallParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.Parameter = extractString(mMap["Parameter"]) - mapping.Argument = extractString(mMap["Argument"]) - call.ParameterMappings = append(call.ParameterMappings, mapping) - } - } - } - call.QueueSettings = parseQueueSettings(mfCall) - action.MicroflowCall = call - } - - return action -} - -func parseNanoflowCallAction(raw map[string]any) *microflows.NanoflowCallAction { - action := µflows.NanoflowCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.UseReturnVariable = extractBool(raw["UseReturnVariable"], false) - - // Parse nested NanoflowCall structure - if nfCall, ok := raw["NanoflowCall"].(map[string]any); ok { - call := µflows.NanoflowCall{} - call.ID = model.ID(extractBsonID(nfCall["$ID"])) - call.Nanoflow = extractString(nfCall["Nanoflow"]) - - if mappings := extractBsonArray(nfCall["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.NanoflowCallParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.Parameter = extractString(mMap["Parameter"]) - mapping.Argument = extractString(mMap["Argument"]) - call.ParameterMappings = append(call.ParameterMappings, mapping) - } - } - } - action.NanoflowCall = call - } - - return action -} - -func parseJavaActionCallAction(raw map[string]any) *microflows.JavaActionCallAction { - action := µflows.JavaActionCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.JavaAction = extractString(raw["JavaAction"]) - action.QueueSettings = parseQueueSettings(raw) - action.ResultVariableName = extractString(raw["ResultVariableName"]) - action.UseReturnVariable = extractBool(raw["UseReturnVariable"], false) - - // Parse parameter mappings (use extractBsonArray to handle BSON array format) - if mappings := extractBsonArray(raw["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.JavaActionParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.Parameter = extractString(mMap["Parameter"]) - // Parse Value - it can be various types - if value, ok := mMap["Value"].(map[string]any); ok { - mapping.Value = parseCodeActionParameterValue(value) - } - action.ParameterMappings = append(action.ParameterMappings, mapping) - } - } - } - - return action -} - -func parseJavaScriptActionCallAction(raw map[string]any) *microflows.JavaScriptActionCallAction { - action := µflows.JavaScriptActionCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.JavaScriptAction = extractString(raw["JavaScriptAction"]) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.UseReturnVariable = extractBool(raw["UseReturnVariable"], false) - - // Parse parameter mappings - if mappings := extractBsonArray(raw["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.JavaScriptActionParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.Parameter = extractString(mMap["Parameter"]) - // BSON key is "ParameterValue"; Go struct JSON tag is "value" — intentional asymmetry - if value, ok := mMap["ParameterValue"].(map[string]any); ok { - mapping.Value = parseCodeActionParameterValue(value) - } - action.ParameterMappings = append(action.ParameterMappings, mapping) - } - } - } - - return action -} - -func parseCodeActionParameterValue(raw map[string]any) microflows.CodeActionParameterValue { - if raw == nil { - return nil - } - typeName := extractString(raw["$Type"]) - switch typeName { - case "Microflows$StringTemplateParameterValue": - value := µflows.StringTemplateParameterValue{} - value.ID = model.ID(extractBsonID(raw["$ID"])) - if tt, ok := raw["TypedTemplate"].(map[string]any); ok { - value.TypedTemplate = µflows.TypedTemplate{} - value.TypedTemplate.ID = model.ID(extractBsonID(tt["$ID"])) - value.TypedTemplate.Text = extractString(tt["Text"]) - } - return value - case "Microflows$ExpressionBasedCodeActionParameterValue": - value := µflows.ExpressionBasedCodeActionParameterValue{} - value.ID = model.ID(extractBsonID(raw["$ID"])) - value.Expression = extractString(raw["Expression"]) - return value - case "Microflows$BasicCodeActionParameterValue": - value := µflows.BasicCodeActionParameterValue{} - value.ID = model.ID(extractBsonID(raw["$ID"])) - value.Argument = extractString(raw["Argument"]) - return value - case "Microflows$MicroflowParameterValue": - value := µflows.MicroflowParameterValue{} - value.ID = model.ID(extractBsonID(raw["$ID"])) - value.Microflow = extractString(raw["Microflow"]) - return value - case "Microflows$EntityTypeCodeActionParameterValue": - value := µflows.EntityTypeCodeActionParameterValue{} - value.ID = model.ID(extractBsonID(raw["$ID"])) - value.Entity = extractString(raw["Entity"]) - return value - } - return nil -} - -func parseShowPageAction(raw map[string]any) *microflows.ShowPageAction { - action := µflows.ShowPageAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.PageID = model.ID(extractBsonID(raw["Page"])) - action.PassedObject = extractString(raw["PassedObjectVariableName"]) - - // Parse FormSettings (modern Mendix 10+ format with BY_NAME_REFERENCE) - if fs := toMap(raw["FormSettings"]); fs != nil { - action.PageName = extractString(fs["Form"]) - action.FormSettingsID = model.ID(extractBsonID(fs["$ID"])) - // Parse ParameterMappings from FormSettings - action.PageParameterMappings = parseParameterMappingsAny(fs["ParameterMappings"]) - } - - // Parse PageSettings (legacy format) - if ps := toMap(raw["PageSettings"]); ps != nil { - action.PageSettings = µflows.PageSettings{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(ps["$ID"]))}, - Location: microflows.PageLocation(extractString(ps["Location"])), - } - } - - // Parse PageParameterMappings from top-level (legacy format, only if not already parsed from FormSettings) - if action.PageParameterMappings == nil { - action.PageParameterMappings = parseParameterMappingsAny(raw["ParameterMappings"]) - } - - return action -} - -// parseParameterMappingsAny parses parameter mappings from any array type (primitive.A or []any). -func parseParameterMappingsAny(v any) []*microflows.PageParameterMapping { - arr := extractBsonArray(v) - if len(arr) == 0 { - return nil - } - var result []*microflows.PageParameterMapping - for _, m := range arr { - mMap := toMap(m) - if mMap != nil { - mapping := µflows.PageParameterMapping{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(mMap["$ID"]))}, - Parameter: extractString(mMap["Parameter"]), - Argument: extractString(mMap["Argument"]), - } - result = append(result, mapping) - } - } - return result -} - -// parseFormParameterMappings parses parameter mappings from FormSettings (primitive.A type). -func parseFormParameterMappings(mappings primitive.A) []*microflows.PageParameterMapping { - var result []*microflows.PageParameterMapping - for _, m := range mappings { - // Skip the count element - if _, isInt := m.(int32); isInt { - continue - } - mMap := toMap(m) - if mMap != nil { - mapping := µflows.PageParameterMapping{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(mMap["$ID"]))}, - Parameter: extractString(mMap["Parameter"]), - Argument: extractString(mMap["Argument"]), - } - result = append(result, mapping) - } - } - return result -} - -// parseFormParameterMappingsSlice parses parameter mappings from FormSettings ([]interface{} type). -func parseFormParameterMappingsSlice(mappings []any) []*microflows.PageParameterMapping { - var result []*microflows.PageParameterMapping - for _, m := range mappings { - // Skip the count element - if _, isInt := m.(int32); isInt { - continue - } - mMap := toMap(m) - if mMap != nil { - mapping := µflows.PageParameterMapping{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(mMap["$ID"]))}, - Parameter: extractString(mMap["Parameter"]), - Argument: extractString(mMap["Argument"]), - } - result = append(result, mapping) - } - } - return result -} - -func parseShowHomePageAction(raw map[string]any) *microflows.ShowHomePageAction { - action := µflows.ShowHomePageAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - return action -} - -func parseClosePageAction(raw map[string]any) *microflows.ClosePageAction { - action := µflows.ClosePageAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - // Issue #585: collapse the int32/int64 dispatch to the shared extractInt - // helper. Default of 1 is preserved when the field is absent. - // Storage name is "NumberOfPages"; also accept the legacy "NumberOfPagesToClose" - // that older mxcli/Mendix wrote, for round-trip fidelity on existing projects. - if _, ok := raw["NumberOfPages"]; ok { - action.NumberOfPages = extractInt(raw["NumberOfPages"]) - } else if _, ok := raw["NumberOfPagesToClose"]; ok { - action.NumberOfPages = extractInt(raw["NumberOfPagesToClose"]) - } else { - action.NumberOfPages = 1 - } - return action -} - -func parseShowMessageAction(raw map[string]any) *microflows.ShowMessageAction { - action := µflows.ShowMessageAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.Blocking = extractBool(raw["Blocking"], false) - - if msgType, ok := raw["Type"].(string); ok { - action.Type = microflows.MessageType(msgType) - } - - // Parse template (nested Microflows$TextTemplate -> Texts$Text) - if template, ok := raw["Template"].(map[string]any); ok { - // TextTemplate contains a nested Text property with the actual translations - if text, ok := template["Text"].(map[string]any); ok { - action.Template = parseText(text) - } - - // Extract template parameters from Microflows$TextTemplate.Parameters - if params := extractBsonArray(template["Parameters"]); len(params) > 0 { - for _, p := range params { - if paramMap, ok := p.(map[string]any); ok { - if expr := extractString(paramMap["Expression"]); expr != "" { - action.TemplateParameters = append(action.TemplateParameters, expr) - } - } - } - } - } - - return action -} - -func parseValidationFeedbackAction(raw map[string]any) *microflows.ValidationFeedbackAction { - action := µflows.ValidationFeedbackAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ObjectVariable = extractString(raw["ValidationVariableName"]) - action.AttributeName = extractString(raw["Attribute"]) // BY_NAME_REFERENCE - action.AssociationName = extractString(raw["Association"]) // BY_NAME_REFERENCE - - // Parse template (nested Microflows$TextTemplate -> Texts$Text) - if template, ok := raw["FeedbackTemplate"].(map[string]any); ok { - // TextTemplate contains a nested Text property - if text, ok := template["Text"].(map[string]any); ok { - action.Template = parseText(text) - } - } - - return action -} - -func parseDownloadFileAction(raw map[string]any) *microflows.DownloadFileAction { - action := µflows.DownloadFileAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - if action.ErrorHandlingType == "" { - action.ErrorHandlingType = microflows.ErrorHandlingTypeRollback - } - action.FileDocument = extractString(raw["FileDocumentVariableName"]) - action.ShowInBrowser = extractBool(raw["ShowInBrowser"], false) - return action -} - -func parseLogMessageAction(raw map[string]any) *microflows.LogMessageAction { - action := µflows.LogMessageAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - // ErrorHandlingType gates the whole error branch in DESCRIBE, not just a - // suffix — see getActionErrorHandlingType (mendixlabs/mxcli#1078). - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.LogNodeName = extractString(raw["Node"]) - action.IncludeLastStackTrace = extractBool(raw["IncludeLatestStackTrace"], false) - - if level, ok := raw["Level"].(string); ok { - action.LogLevel = microflows.LogLevel(level) - } - - // Parse message template (Microflows$StringTemplate) - if template, ok := raw["MessageTemplate"].(map[string]any); ok { - action.MessageTemplate = parseText(template) - - // Extract template parameters from Microflows$StringTemplate.Parameters - if params := extractBsonArray(template["Parameters"]); len(params) > 0 { - for _, p := range params { - if paramMap, ok := p.(map[string]any); ok { - if expr := extractString(paramMap["Expression"]); expr != "" { - action.TemplateParameters = append(action.TemplateParameters, expr) - } - } - } - } - } - - return action -} - -func parseCastAction(raw map[string]any) *microflows.CastAction { - action := µflows.CastAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ObjectVariable = extractString(raw["ObjectVariableName"]) - action.OutputVariable = extractString(raw["OutputVariableName"]) - if action.OutputVariable == "" { - action.OutputVariable = extractString(raw["VariableName"]) - } - return action -} - -func parseRestCallAction(raw map[string]any) *microflows.RestCallAction { - action := µflows.RestCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.TimeoutExpression = extractString(raw["TimeOutExpression"]) - action.UseReturnVariable = extractBool(raw["UseRequestTimeOut"], false) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - - // Parse HttpConfiguration - if httpConfig, ok := raw["HttpConfiguration"].(map[string]any); ok { - action.HttpConfiguration = parseHttpConfiguration(httpConfig) - } else if httpConfigD, ok := raw["HttpConfiguration"].(primitive.D); ok { - action.HttpConfiguration = parseHttpConfiguration(httpConfigD.Map()) - } - - // Parse ResultHandling - resultHandlingType := extractString(raw["ResultHandlingType"]) - if resultHandling, ok := raw["ResultHandling"].(map[string]any); ok { - action.ResultHandling = parseResultHandling(resultHandling, resultHandlingType) - } else if resultHandlingD, ok := raw["ResultHandling"].(primitive.D); ok { - action.ResultHandling = parseResultHandling(resultHandlingD.Map(), resultHandlingType) - } - - // Parse RequestHandling - requestHandlingType := extractString(raw["RequestHandlingType"]) - if requestHandling, ok := raw["RequestHandling"].(map[string]any); ok { - action.RequestHandling = parseRequestHandling(requestHandling, requestHandlingType) - } else if requestHandlingD, ok := raw["RequestHandling"].(primitive.D); ok { - action.RequestHandling = parseRequestHandling(requestHandlingD.Map(), requestHandlingType) - } - - return action -} - -func parseWebServiceCallAction(raw map[string]any) *microflows.WebServiceCallAction { - action := µflows.WebServiceCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ServiceID = model.ID(extractString(raw["ImportedService"])) - action.OperationName = extractString(raw["OperationName"]) - action.TimeoutExpression = extractString(raw["TimeOutExpression"]) - - if resultHandling := extractBsonMap(raw["NewResultHandling"]); resultHandling != nil { - action.OutputVariable = extractString(resultHandling["ResultVariableName"]) - action.UseReturnVariable = action.OutputVariable != "" - if call := extractBsonMap(resultHandling["ImportMappingCall"]); call != nil { - action.ReceiveMappingID = model.ID(extractString(call["ReturnValueMapping"])) - } - } - // RequestHandling / ExportMappingCall is a shape no reference document - // carries — the real key is RequestBodyHandling, read below — so this never - // populated SendMappingID from a real project. - if requestHandling := extractBsonMap(raw["RequestHandling"]); requestHandling != nil { - if call := extractBsonMap(requestHandling["ExportMappingCall"]); call != nil { - action.SendMappingID = model.ID(extractString(call["Mapping"])) - } - } - parseWebServiceRequestBody(raw, action) - if webServiceActionRequiresRawBSON(raw) { - if rawBSON, err := bson.Marshal(raw); err == nil { - action.RawBSON = rawBSON - } - } - - return action -} - -// parseWebServiceRequestBody reads a SOAP call's RequestBodyHandling back into -// the semantic model. Mirrors modelsdkbackend.readWebServiceRequestBody. -// -// Dispatched on $Type, never on which fields are present: MappingRequestHandling -// and SimpleRequestHandling differ in arity, so assigning whichever keys turn up -// would quietly turn one into the other. -func parseWebServiceRequestBody(raw map[string]any, action *microflows.WebServiceCallAction) { - body := extractBsonMap(raw["RequestBodyHandling"]) - if body == nil { - return - } - switch extractString(body["$Type"]) { - case "Microflows$MappingRequestHandling": - // STORAGE NAMES: MappingId / MappingVariableName — not gen's Mapping / - // MappingArgumentVariableName, both of which its key audit lists as wrong. - action.SendMappingID = model.ID(extractString(body["MappingId"])) - action.SendMappingVariable = extractString(body["MappingVariableName"]) - action.SendMappingContentType = extractString(body["ContentType"]) - case "Microflows$SimpleRequestHandling": - for _, el := range extractBsonArray(body["ParameterMappings"]) { - pm := extractBsonMap(el) - if pm == nil || extractString(pm["$Type"]) != "Microflows$WebServiceOperationSimpleParameterMapping" { - // An advanced (per-parameter export mapping) entry, which MDL - // cannot author. The raw fallback carries the action; reading - // half of it here would be worse than reading none. - continue - } - path := extractString(pm["ParameterPath"]) - name := "" - if i := strings.LastIndex(path, "|"); i >= 0 { - name = path[i+1:] - } - action.Arguments = append(action.Arguments, microflows.WebServiceArgument{ - Name: name, - Path: path, - Expression: extractString(pm["Argument"]), - // Absent reads as true: both reference mappings carry true, and - // a bound parameter is one Studio Pro has ticked. - Checked: extractBool(pm["IsChecked"], true), - }) - } - } -} - -// webServiceActionRequiresRawBSON reports whether the structured describe form -// would fail to reproduce this action, in which case the renderer falls back to -// `call web service raw ''`. Mirrors -// modelsdkbackend.webServiceActionRequiresRawBSON decision for decision — see the -// comment there for why the six boilerplate keys are admitted only AT the value -// mxcli writes rather than by name. -func webServiceActionRequiresRawBSON(raw map[string]any) bool { - represented := map[string]bool{ - "$ID": true, - "$Type": true, - "ErrorHandlingType": true, - "ImportedService": true, - "OperationName": true, - "TimeOutExpression": true, - "RequestHandling": true, - // Re-read from the imported service document on write (the CE0386 fix), - // so DESCRIBE need not carry it. - "ServiceName": true, - } - for key, value := range raw { - if represented[key] { - continue - } - ok, known := webServiceFixedValueIsDefault(key, value) - if !known || !ok { - return true - } - } - return false -} - -// webServiceFixedValueIsDefault reports whether one of the keys mxcli writes at a -// FIXED value currently holds it. known is false for a key it does not judge. -func webServiceFixedValueIsDefault(key string, value any) (ok, known bool) { - switch key { - case "IsValidationRequired": - return !extractBool(value, true), true - case "UseRequestTimeOut": - return extractBool(value, false), true - case "RequestProxyType": - return extractString(value) == "DefaultProxy", true - case "ProxyConfiguration": - return value == nil, true - case "HttpConfiguration": - return isDefaultWebServiceHTTPConfig(extractBsonMap(value)), true - case "RequestHeaderHandling": - return isEmptySimpleRequestHandling(extractBsonMap(value)), true - case "RequestBodyHandling": - return webServiceRequestBodyIsRepresentable(extractBsonMap(value)), true - case "NewResultHandling": - return webServiceResultHandlingIsRepresentable(extractBsonMap(value)), true - } - return false, false -} - -// webServiceResultHandlingIsRepresentable reports whether a call's result -// handling is one the writer reproduces exactly. See the comment on the -// modelsdk twin for the two ako/TestApp calls that prove it cannot be admitted -// by name: a BooleanType result with no mapping, and Range.SingleObject false. -func webServiceResultHandlingIsRepresentable(doc map[string]any) bool { - if doc == nil || extractString(doc["$Type"]) != "Microflows$ResultHandling" { - return false - } - bound := extractString(doc["ResultVariableName"]) != "" - if extractBool(doc["Bind"], !bound) != bound { - return false - } - vt := extractBsonMap(doc["VariableType"]) - if vt == nil { - return false - } - imc := extractBsonMap(doc["ImportMappingCall"]) - if imc == nil { - return extractString(vt["$Type"]) == "DataTypes$VoidType" - } - if extractString(vt["$Type"]) != "DataTypes$ObjectType" { - return false - } - if extractString(imc["$Type"]) != "Microflows$ImportMappingCall" || - extractString(imc["Commit"]) != "YesWithoutEvents" || - extractString(imc["ContentType"]) != "Xml" || - extractString(imc["ObjectHandlingBackup"]) != "Create" || - extractString(imc["ParameterVariableName"]) != "" || - extractString(imc["ReturnValueMapping"]) == "" || - extractBool(imc["ForceSingleOccurrence"], true) { - return false - } - rng := extractBsonMap(imc["Range"]) - return rng != nil && - extractString(rng["$Type"]) == "Microflows$ConstantRange" && - extractBool(rng["SingleObject"], false) -} - -// isDefaultWebServiceHTTPConfig reports whether an HttpConfiguration is the one a -// SOAP call gets when nothing is configured — the only one mxcli writes. -func isDefaultWebServiceHTTPConfig(doc map[string]any) bool { - if doc == nil || extractString(doc["$Type"]) != "Microflows$HttpConfiguration" { - return false - } - for _, key := range []string{"ClientCertificate", "CustomLocation", - "HttpAuthenticationPassword", "HttpAuthenticationUserName"} { - if extractString(doc[key]) != "" { - return false - } - } - if doc["CustomLocationTemplate"] != nil { - return false - } - if extractString(doc["HttpMethod"]) != "Post" { - return false - } - if extractBool(doc["OverrideLocation"], true) || extractBool(doc["UseHttpAuthentication"], true) { - return false - } - return len(extractBsonArray(doc["HttpHeaderEntries"])) == 0 -} - -// isEmptySimpleRequestHandling reports whether a request handling is the bare -// Simple form — no parameter mappings — which is all mxcli writes for headers. -func isEmptySimpleRequestHandling(doc map[string]any) bool { - return doc != nil && - extractString(doc["$Type"]) == "Microflows$SimpleRequestHandling" && - extractString(doc["NullValueOption"]) == "LeaveOutElement" && - len(extractBsonArray(doc["ParameterMappings"])) == 0 -} - -// webServiceRequestBodyIsRepresentable reports whether a RequestBodyHandling is -// one MDL can spell: an export mapping, or simple parameter mappings whose names -// survive the round trip. -func webServiceRequestBodyIsRepresentable(doc map[string]any) bool { - if doc == nil { - return false - } - switch extractString(doc["$Type"]) { - case "Microflows$MappingRequestHandling": - return extractString(doc["MappingId"]) != "" && extractString(doc["MappingVariableName"]) != "" - case "Microflows$SimpleRequestHandling": - if extractString(doc["NullValueOption"]) != "LeaveOutElement" { - return false - } - for _, el := range extractBsonArray(doc["ParameterMappings"]) { - pm := extractBsonMap(el) - if pm == nil || - extractString(pm["$Type"]) != "Microflows$WebServiceOperationSimpleParameterMapping" || - extractString(pm["ParameterName"]) != "" { - return false - } - if !strings.Contains(extractString(pm["ParameterPath"]), "|") { - return false - } - } - return true - } - return false -} - -func parseWebServiceCallActionFromD(raw primitive.D) *microflows.WebServiceCallAction { - action := parseWebServiceCallAction(raw.Map()) - if rawBSON, err := bson.Marshal(raw); err == nil { - action.RawBSON = rawBSON - } - return action -} - -// parseRestOperationCallAction parses a Microflows$RestOperationCallAction from BSON. -func parseRestOperationCallAction(raw map[string]any) *microflows.RestOperationCallAction { - action := µflows.RestOperationCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.Operation = extractString(raw["Operation"]) - - // Parse OutputVariable (nested Microflows$OutputVariable) - if ov := extractBsonMap(raw["OutputVariable"]); ov != nil { - action.OutputVariable = µflows.RestOutputVar{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(ov["$ID"]))}, - VariableName: extractString(ov["VariableName"]), - } - } - - // Parse BodyVariable (nested object) - if bv := extractBsonMap(raw["BodyVariable"]); bv != nil { - action.BodyVariable = µflows.RestBodyVar{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(bv["$ID"]))}, - VariableName: extractString(bv["VariableName"]), - } - } - - // Parse ParameterMappings (path params) - for _, pm := range extractBsonArray(raw["ParameterMappings"]) { - if pmMap, ok := pm.(map[string]any); ok { - action.ParameterMappings = append(action.ParameterMappings, µflows.RestParameterMapping{ - Parameter: extractString(pmMap["Parameter"]), - Value: extractString(pmMap["Value"]), - }) - } - } - - // Parse QueryParameterMappings - for _, qm := range extractBsonArray(raw["QueryParameterMappings"]) { - if qmMap, ok := qm.(map[string]any); ok { - action.QueryParameterMappings = append(action.QueryParameterMappings, µflows.RestQueryParameterMapping{ - Parameter: extractString(qmMap["QueryParameter"]), - Value: extractString(qmMap["Value"]), - Included: extractString(qmMap["Included"]), - }) - } - } - - return action -} - -func parseHttpConfiguration(raw map[string]any) *microflows.HttpConfiguration { - config := µflows.HttpConfiguration{} - config.ID = model.ID(extractBsonID(raw["$ID"])) - config.HttpMethod = microflows.HttpMethod(extractString(raw["HttpMethod"])) - config.CustomLocation = extractString(raw["CustomLocation"]) - config.UseAuthentication = extractBool(raw["UseHttpAuthentication"], false) - config.Username = extractString(raw["HttpAuthenticationUserName"]) - config.Password = extractString(raw["HttpAuthenticationPassword"]) - - // Parse CustomLocationTemplate (URL template with parameters) - if locTemplate, ok := raw["CustomLocationTemplate"].(map[string]any); ok { - config.LocationTemplate = extractString(locTemplate["Text"]) - config.LocationParams = parseTemplateParameters(locTemplate) - } else if locTemplateD, ok := raw["CustomLocationTemplate"].(primitive.D); ok { - locTemplateM := locTemplateD.Map() - config.LocationTemplate = extractString(locTemplateM["Text"]) - config.LocationParams = parseTemplateParameters(locTemplateM) - } - - // Parse HttpHeaderEntries - if headers, ok := raw["HttpHeaderEntries"].(primitive.A); ok { - for _, h := range headers { - if hMap, ok := h.(primitive.D); ok { - header := parseHttpHeader(hMap.Map()) - if header != nil { - config.CustomHeaders = append(config.CustomHeaders, header) - } - } else if hMap, ok := h.(map[string]any); ok { - header := parseHttpHeader(hMap) - if header != nil { - config.CustomHeaders = append(config.CustomHeaders, header) - } - } - } - } - - return config -} - -func parseTemplateParameters(raw map[string]any) []string { - var params []string - if paramsArr, ok := raw["Parameters"].(primitive.A); ok { - for _, p := range paramsArr { - if pMap, ok := p.(primitive.D); ok { - expr := extractString(pMap.Map()["Expression"]) - params = append(params, expr) - } else if pMap, ok := p.(map[string]any); ok { - expr := extractString(pMap["Expression"]) - params = append(params, expr) - } - } - } - return params -} - -func parseHttpHeader(raw map[string]any) *microflows.HttpHeader { - if raw == nil { - return nil - } - return µflows.HttpHeader{ - Name: extractString(raw["Key"]), - Value: extractString(raw["Value"]), - } -} - -func parseResultHandling(raw map[string]any, handlingType string) microflows.ResultHandling { - switch handlingType { - case "String": - result := µflows.ResultHandlingString{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - result.VariableName = extractString(raw["ResultVariableName"]) - return result - case "HttpResponse": - result := µflows.ResultHandlingHttpResponse{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - result.VariableName = extractString(raw["ResultVariableName"]) - return result - case "FileDocument": - // The entity lives in VariableType and is always a specialization of - // System.FileDocument — the base is rejected as a return type (CE0362). - // Without this case the whole handling read back as nil, which the - // describer rendered as `returns String` while also losing the output - // variable, so a describe → exec round trip silently retyped the - // activity and still built clean. Issue #922. - result := µflows.ResultHandlingFileDocument{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - result.VariableName = extractString(raw["ResultVariableName"]) - if varType := toMap(raw["VariableType"]); varType != nil { - result.EntityRef = extractString(varType["Entity"]) - } - return result - case "Mapping": - result := µflows.ResultHandlingMapping{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - result.ResultVariable = extractString(raw["ResultVariableName"]) - if call := toMap(raw["ImportMappingCall"]); call != nil { - // Newer BSON uses "Mapping", older uses "ReturnValueMapping" - mappingRef := extractString(call["Mapping"]) - if mappingRef == "" { - mappingRef = extractString(call["ReturnValueMapping"]) - } - result.MappingID = model.ID(mappingRef) - forceSingleOccurrence := extractBool(call["ForceSingleOccurrence"], false) - result.ForceSingleOccurrence = &forceSingleOccurrence - // The Range is polymorphic: a ConstantRange carries SingleObject - // (All/First) while a CustomRange carries the limit and offset - // expressions. Reading only SingleObject dropped the Custom setting - // entirely, so a describe→edit→exec cycle turned a bounded import - // into an unbounded one (issue #881). - if rangeMap := toMap(call["Range"]); rangeMap != nil { - switch extractString(rangeMap["$Type"]) { - case "Microflows$CustomRange": - result.LimitExpression = extractString(rangeMap["LimitExpression"]) - result.OffsetExpression = extractString(rangeMap["OffsetExpression"]) - default: - result.SingleObject = extractBool(rangeMap["SingleObject"], false) - } - } - } - if varType := toMap(raw["VariableType"]); varType != nil { - result.ResultEntityID = model.ID(extractString(varType["Entity"])) - // A bounded range is a LIST, so an ObjectType variable cannot make it - // single — without this guard a CustomRange read back as First. - if extractString(varType["$Type"]) == "DataTypes$ObjectType" && - result.LimitExpression == "" && result.OffsetExpression == "" { - result.SingleObject = true - } - } - return result - case "None": - result := µflows.ResultHandlingNone{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - return result - default: - return nil - } -} - -func parseRequestHandling(raw map[string]any, handlingType string) microflows.RequestHandling { - switch handlingType { - case "Custom": - result := µflows.CustomRequestHandling{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - if template, ok := raw["Template"].(map[string]any); ok { - result.Template = extractString(template["Text"]) - result.TemplateParams = parseTemplateParameters(template) - } else if templateD, ok := raw["Template"].(primitive.D); ok { - templateM := templateD.Map() - result.Template = extractString(templateM["Text"]) - result.TemplateParams = parseTemplateParameters(templateM) - } - return result - case "Binary": - result := µflows.BinaryRequestHandling{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - result.Expression = extractString(raw["Expression"]) - return result - case "Mapping": - result := µflows.MappingRequestHandling{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - // ExportMappingCall would be parsed here if needed - return result - case "FormData": - result := µflows.FormDataRequestHandling{} - result.ID = model.ID(extractBsonID(raw["$ID"])) - return result - default: - return nil - } -} - -func parseExecuteDatabaseQueryAction(raw map[string]any) *microflows.ExecuteDatabaseQueryAction { - action := µflows.ExecuteDatabaseQueryAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.Query = extractString(raw["Query"]) - action.DynamicQuery = extractString(raw["DynamicQuery"]) - - // Parse ParameterMappings - if mappings := extractBsonArray(raw["ParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.DatabaseQueryParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.ParameterName = extractString(mMap["ParameterName"]) - mapping.Value = extractString(mMap["Value"]) - action.ParameterMappings = append(action.ParameterMappings, mapping) - } - } - } - - // Parse ConnectionParameterMappings - if mappings := extractBsonArray(raw["ConnectionParameterMappings"]); len(mappings) > 0 { - for _, m := range mappings { - if mMap, ok := m.(map[string]any); ok { - mapping := µflows.DatabaseConnectionParameterMapping{} - mapping.ID = model.ID(extractBsonID(mMap["$ID"])) - mapping.ParameterName = extractString(mMap["ParameterName"]) - mapping.Value = extractString(mMap["Value"]) - action.ConnectionParameterMappings = append(action.ConnectionParameterMappings, mapping) - } - } - } - - return action -} - -func parseImportXmlAction(raw map[string]any) *microflows.ImportXmlAction { - action := µflows.ImportXmlAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.IsValidationRequired = extractBool(raw["IsValidationRequired"], false) - action.XmlDocumentVariable = extractString(raw["XmlDocumentVariableName"]) - - if rh := toMap(raw["ResultHandling"]); rh != nil { - handling := µflows.ResultHandlingMapping{} - handling.ID = model.ID(extractBsonID(rh["$ID"])) - handling.ResultVariable = extractString(rh["ResultVariableName"]) - if call := toMap(rh["ImportMappingCall"]); call != nil { - mappingRef := extractString(call["Mapping"]) - if mappingRef == "" { - mappingRef = extractString(call["ReturnValueMapping"]) - } - handling.MappingID = model.ID(mappingRef) - if varType := toMap(call["VariableType"]); varType != nil { - handling.ResultEntityID = model.ID(extractString(varType["Entity"])) - } - forceSingleOccurrence := extractBool(call["ForceSingleOccurrence"], false) - handling.ForceSingleOccurrence = &forceSingleOccurrence - // The Range is polymorphic — a ConstantRange carries SingleObject - // (Studio Pro's All/First), a CustomRange the limit and offset - // expressions. Reading only SingleObject dropped Custom entirely, so - // describe→edit→exec turned a bounded import unbounded. (issue #881) - if rangeMap := toMap(call["Range"]); rangeMap != nil { - switch extractString(rangeMap["$Type"]) { - case "Microflows$CustomRange": - handling.LimitExpression = extractString(rangeMap["LimitExpression"]) - handling.OffsetExpression = extractString(rangeMap["OffsetExpression"]) - default: - single := extractBool(rangeMap["SingleObject"], false) - handling.RangeSingleObject = &single - handling.SingleObject = single - } - } - // The result variable's cardinality is the stored VariableType where - // there is one; it does NOT track the range (Mendix's own - // SUB_Feedback_PostToAppInsights pairs ConstantRange{SingleObject:false} - // with an ObjectType). Only otherwise does ForceSingleOccurrence stand - // in — and never for a bounded range, which is always a list, or a - // Custom range reads back as First and loses the limit. - switch extractString(toMap(rh["VariableType"])["$Type"]) { - case "DataTypes$ObjectType": - handling.SingleObject = true - case "DataTypes$ListType": - handling.SingleObject = false - default: - if !handling.SingleObject && handling.LimitExpression == "" && handling.OffsetExpression == "" { - handling.SingleObject = forceSingleOccurrence - } - } - } - // The writer stores VariableType on the ResultHandling, not on the - // ImportMappingCall, so the lookup above finds nothing on anything mxcli or - // Studio Pro writes — leaving the result entity empty. - if varType := toMap(rh["VariableType"]); varType != nil && handling.ResultEntityID == "" { - handling.ResultEntityID = model.ID(extractString(varType["Entity"])) - } - action.ResultHandling = handling - } - - return action -} - -func parseTransformJsonAction(raw map[string]any) *microflows.TransformJsonAction { - action := µflows.TransformJsonAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.InputVariableName = extractString(raw["InputVariableName"]) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.Transformation = extractString(raw["Transformation"]) - return action -} - -func parseExportXmlAction(raw map[string]any) *microflows.ExportXmlAction { - action := µflows.ExportXmlAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.IsValidationRequired = extractBool(raw["IsValidationRequired"], false) - - // OutputMethod: ExportXmlAction$StringExport has OutputVariableName - if om := toMap(raw["OutputMethod"]); om != nil { - action.OutputVariable = extractString(om["OutputVariableName"]) - } - - // ResultHandling: Microflows$MappingRequestHandling with MappingId and MappingVariableName - if rh := toMap(raw["ResultHandling"]); rh != nil { - handling := µflows.MappingRequestHandling{} - handling.ID = model.ID(extractBsonID(rh["$ID"])) - handling.ParameterVariable = extractString(rh["MappingVariableName"]) - handling.MappingID = model.ID(extractString(rh["MappingId"])) - action.RequestHandling = handling - } - - return action -} - -// parseQueueSettings reads a call's Queues$QueueSettings child — the binding to -// a task queue. Without it the legacy engine's DESCRIBE rendered a queued call -// as an ordinary one, so a describe → exec round trip dropped the binding and -// nothing on this engine could see it (FINDINGS #25's "describe showing nothing -// is not evidence of nothing"). -func parseQueueSettings(raw map[string]any) *microflows.QueueSettings { - qs, ok := raw["QueueSettings"].(map[string]any) - if !ok || qs == nil { - return nil - } - out := µflows.QueueSettings{Queue: extractString(qs["Queue"])} - out.ID = model.ID(extractBsonID(qs["$ID"])) - if retry, ok := qs["Retry"]; ok && retry != nil { - out.Retry = retry - } - return out -} diff --git a/sdk/mpr/parser_microflow_error_handling_1078_test.go b/sdk/mpr/parser_microflow_error_handling_1078_test.go deleted file mode 100644 index 431e5eb0d9..0000000000 --- a/sdk/mpr/parser_microflow_error_handling_1078_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -// mendixlabs/mxcli#1078, the legacy engine's half. -// -// Fixing the describer made the default (modelsdk) engine round-trip an error -// handler again, and MXCLI_ENGINE=legacy still dropped it — for a second, -// independent reason: nine parse functions never read ErrorHandlingType off the -// BSON at all, so the value was gone before the describer could be asked about -// it. Measured on the same project: 0 errors on modelsdk, handler still missing -// on legacy, until these were fixed too. -// -// The two defects are stacked, which is why fixing one looked like fixing both. -func TestParse1078_ActionsReadErrorHandlingType(t *testing.T) { - // "Custom" is what Studio Pro's "custom with rollback" stores, and it is the - // value the reporter's create-variable activity carried. - const custom = "Custom" - - for _, tc := range []struct { - name string - got func() microflows.ErrorHandlingType - }{ - {"create variable", func() microflows.ErrorHandlingType { - return parseCreateVariableAction(map[string]any{ - "$ID": "a", "VariableName": "name", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"change variable", func() microflows.ErrorHandlingType { - return parseChangeVariableAction(map[string]any{ - "$ID": "a", "ChangeVariableName": "name", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"create object", func() microflows.ErrorHandlingType { - return parseCreateObjectAction(map[string]any{ - "$ID": "a", "Entity": "Mod.Car", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"change object", func() microflows.ErrorHandlingType { - return parseChangeObjectAction(map[string]any{ - "$ID": "a", "ChangeVariableName": "Car", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"close page", func() microflows.ErrorHandlingType { - return parseClosePageAction(map[string]any{ - "$ID": "a", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"log message", func() microflows.ErrorHandlingType { - return parseLogMessageAction(map[string]any{ - "$ID": "a", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"show message", func() microflows.ErrorHandlingType { - return parseShowMessageAction(map[string]any{ - "$ID": "a", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"show page", func() microflows.ErrorHandlingType { - return parseShowPageAction(map[string]any{ - "$ID": "a", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - {"validation feedback", func() microflows.ErrorHandlingType { - return parseValidationFeedbackAction(map[string]any{ - "$ID": "a", "ErrorHandlingType": custom, - }).ErrorHandlingType - }}, - } { - t.Run(tc.name, func(t *testing.T) { - if got := tc.got(); got != microflows.ErrorHandlingTypeCustom { - t.Errorf("ErrorHandlingType = %q, want %q — the whole error branch is "+ - "dropped from DESCRIBE when this is empty", - got, microflows.ErrorHandlingTypeCustom) - } - }) - } -} - -// Control. An absent ErrorHandlingType must stay empty rather than being invented: -// #840 established that a rendered `on error rollback` puts a clause in the -// user's script they never wrote, and these parsers are read by the same -// describer. -func TestParse1078_AbsentErrorHandlingTypeStaysEmpty(t *testing.T) { - if got := parseCreateVariableAction(map[string]any{ - "$ID": "a", "VariableName": "name", - }).ErrorHandlingType; got != "" { - t.Errorf("ErrorHandlingType = %q, want empty for BSON that carries none", got) - } -} diff --git a/sdk/mpr/parser_microflow_import_range_test.go b/sdk/mpr/parser_microflow_import_range_test.go deleted file mode 100644 index 4bcd28d93d..0000000000 --- a/sdk/mpr/parser_microflow_import_range_test.go +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// upstream #881, legacy engine. Both engines share the semantic model, so a fix -// in one is invisible to anyone running the other (MXCLI_ENGINE=legacy). These -// mirror the modelsdk tests against the legacy parser/serializer. - -// A CustomRange must survive the round trip: the reader dispatches on $Type, and -// the writer selects the variant. Before this, "Custom" was unrepresentable and -// a bounded import silently became unbounded on the next exec. -func TestLegacyImportXmlActionRoundTripsCustomRange(t *testing.T) { - doc := serializeImportXmlAction(µflows.ImportXmlAction{ - BaseElement: model.BaseElement{ID: model.ID("a-1")}, - ResultHandling: µflows.ResultHandlingMapping{ - BaseElement: model.BaseElement{ID: model.ID("rh-1")}, - MappingID: model.ID("M.IMM"), - ResultEntityID: model.ID("M.Root"), - ResultVariable: "Out", - LimitExpression: "10", - OffsetExpression: "5", - }, - XmlDocumentVariable: "Resp", - }) - - rhFields := bsonDMap(asD(t, bsonDMap(doc)["ResultHandling"])) - call := bsonDMap(asD(t, rhFields["ImportMappingCall"])) - rangeDoc := bsonDMap(asD(t, call["Range"])) - - if got := rangeDoc["$Type"]; got != "Microflows$CustomRange" { - t.Fatalf("Range $Type = %v, want Microflows$CustomRange", got) - } - if got := rangeDoc["LimitExpression"]; got != "10" { - t.Errorf("LimitExpression = %v, want 10", got) - } - if got := rangeDoc["OffsetExpression"]; got != "5" { - t.Errorf("OffsetExpression = %v, want 5", got) - } - if _, ok := rangeDoc["SingleObject"]; ok { - t.Error("a CustomRange must not carry SingleObject — a bounded range is always bounded") - } -} - -// The read side of the same. The result entity also lives on the ResultHandling, -// not on the ImportMappingCall, which is where the legacy parser looked — so it -// came back empty for everything mxcli or Studio Pro writes. -func TestLegacyParseImportXmlActionReadsCustomRange(t *testing.T) { - got := parseImportXmlAction(map[string]any{ - "$ID": "a-1", - "XmlDocumentVariableName": "Resp", - "ResultHandling": map[string]any{ - "$ID": "rh-1", - "ResultVariableName": "Out", - "ImportMappingCall": map[string]any{ - "ReturnValueMapping": "M.IMM", - "ForceSingleOccurrence": false, - "Range": map[string]any{ - "$Type": "Microflows$CustomRange", - "LimitExpression": "10", - "OffsetExpression": "5", - }, - }, - "VariableType": map[string]any{ - "$Type": "DataTypes$ListType", - "Entity": "M.Root", - }, - }, - }) - - if got.ResultHandling == nil { - t.Fatal("ResultHandling missing") - } - h := got.ResultHandling - if h.LimitExpression != "10" || h.OffsetExpression != "5" { - t.Errorf("limit/offset = %q/%q, want 10/5", h.LimitExpression, h.OffsetExpression) - } - if h.SingleObject { - t.Error("SingleObject = true, want false (ListType variable)") - } - if string(h.ResultEntityID) != "M.Root" { - t.Errorf("ResultEntityID = %q, want M.Root — VariableType is stored on the "+ - "ResultHandling, not on the ImportMappingCall", h.ResultEntityID) - } -} - -// The shape Mendix ships in the blank app: range All against an OBJECT variable. -// The range and the variable's cardinality are separate axes, and folding one -// into the other describes this as `first` — rewriting the activity on re-exec. -func TestLegacyParseImportXmlActionSeparatesRangeFromCardinality(t *testing.T) { - got := parseImportXmlAction(map[string]any{ - "$ID": "a-1", - "XmlDocumentVariableName": "Resp", - "ResultHandling": map[string]any{ - "$ID": "rh-1", - "ResultVariableName": "Out", - "ImportMappingCall": map[string]any{ - "ReturnValueMapping": "M.IMM", - "ForceSingleOccurrence": false, - "Range": map[string]any{"$Type": "Microflows$ConstantRange", "SingleObject": false}, - }, - "VariableType": map[string]any{"$Type": "DataTypes$ObjectType", "Entity": "M.Root"}, - }, - }) - - h := got.ResultHandling - if h == nil { - t.Fatal("ResultHandling missing") - } - if h.RangeSingleObject == nil || *h.RangeSingleObject { - t.Errorf("RangeSingleObject = %v, want explicit false — the range is All", h.RangeSingleObject) - } - if !h.SingleObject { - t.Error("SingleObject = false, want true — the stored ObjectType is the authority " + - "on the variable's cardinality, and mxbuild rejects the mismatch with CE0243") - } -} - -// The writer's variant choice must read the RANGE's flag, not the variable's: -// serializing Mendix's own All-against-an-object shape as First changes it. -func TestLegacySerializeImportXmlActionWritesRangeFlagNotCardinality(t *testing.T) { - no := false - doc := serializeImportXmlAction(µflows.ImportXmlAction{ - BaseElement: model.BaseElement{ID: model.ID("a-1")}, - ResultHandling: µflows.ResultHandlingMapping{ - BaseElement: model.BaseElement{ID: model.ID("rh-1")}, - MappingID: model.ID("M.IMM"), - ResultEntityID: model.ID("M.Root"), - ResultVariable: "Out", - SingleObject: true, // an object-rooted mapping - RangeSingleObject: &no, // …with the range left at All - }, - XmlDocumentVariable: "Resp", - }) - - rhFields := bsonDMap(asD(t, bsonDMap(doc)["ResultHandling"])) - call := bsonDMap(asD(t, rhFields["ImportMappingCall"])) - rangeDoc := bsonDMap(asD(t, call["Range"])) - if got := rangeDoc["SingleObject"]; got != false { - t.Errorf("Range.SingleObject = %v, want false — the range is All", got) - } - varType := bsonDMap(asD(t, rhFields["VariableType"])) - if got := varType["$Type"]; got != "DataTypes$ObjectType" { - t.Errorf("VariableType = %v, want DataTypes$ObjectType — the variable follows the "+ - "mapping, not the range", got) - } -} - -// asD narrows a nested BSON value so a missing sub-document fails at the field -// it is missing from rather than as a bare type-assertion panic. -func asD(t *testing.T, v any) primitive.D { - t.Helper() - d, ok := v.(primitive.D) - if !ok { - t.Fatalf("expected a BSON document, got %T", v) - } - return d -} diff --git a/sdk/mpr/parser_microflow_test.go b/sdk/mpr/parser_microflow_test.go deleted file mode 100644 index 2c8688712c..0000000000 --- a/sdk/mpr/parser_microflow_test.go +++ /dev/null @@ -1,408 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "bytes" - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func TestParseSequenceFlow_NewCaseValueEnumerationCase(t *testing.T) { - flow := parseSequenceFlow(map[string]any{ - "$ID": "flow-1", - "OriginPointer": "start-1", - "DestinationPointer": "dest-1", - "OriginConnectionIndex": int32(1), - "DestinationConnectionIndex": int32(2), - "NewCaseValue": primitive.D{ - {Key: "$ID", Value: "case-1"}, - {Key: "$Type", Value: "Microflows$EnumerationCase"}, - {Key: "Value", Value: "true"}, - }, - }) - - got, ok := flow.CaseValue.(*microflows.EnumerationCase) - if !ok { - t.Fatalf("expected *EnumerationCase, got %T", flow.CaseValue) - } - if got.Value != "true" { - t.Fatalf("expected true branch, got %q", got.Value) - } -} - -func TestParseSequenceFlow_NewCaseValueExpressionCase(t *testing.T) { - flow := parseSequenceFlow(map[string]any{ - "$ID": "flow-1", - "OriginPointer": "start-1", - "DestinationPointer": "dest-1", - "NewCaseValue": primitive.D{ - {Key: "$ID", Value: "case-1"}, - {Key: "$Type", Value: "Microflows$ExpressionCase"}, - {Key: "Expression", Value: "false"}, - }, - }) - - got, ok := flow.CaseValue.(*microflows.ExpressionCase) - if !ok { - t.Fatalf("expected *ExpressionCase, got %T", flow.CaseValue) - } - if got.Expression != "false" { - t.Fatalf("expected false branch, got %q", got.Expression) - } -} - -func TestParseSequenceFlow_NewCaseValueNoCase(t *testing.T) { - flow := parseSequenceFlow(map[string]any{ - "$ID": "flow-1", - "OriginPointer": "start-1", - "DestinationPointer": "dest-1", - "NewCaseValue": primitive.D{ - {Key: "$ID", Value: "case-1"}, - {Key: "$Type", Value: "Microflows$NoCase"}, - }, - }) - - if _, ok := flow.CaseValue.(*microflows.NoCase); !ok { - t.Fatalf("expected *NoCase, got %T", flow.CaseValue) - } -} - -func TestParseCommitAction_ErrorHandlingTypeExplicit(t *testing.T) { - action := parseCommitAction(map[string]any{ - "$ID": "commit-1", - "CommitVariableName": "Order", - "WithEvents": true, - "RefreshInClient": false, - "ErrorHandlingType": "Continue", - }) - - if action.ErrorHandlingType != microflows.ErrorHandlingTypeContinue { - t.Errorf("expected Continue, got %q", action.ErrorHandlingType) - } - if action.CommitVariable != "Order" { - t.Errorf("expected CommitVariable Order, got %q", action.CommitVariable) - } -} - -func TestParseCommitAction_ErrorHandlingTypeDefaultsToRollback(t *testing.T) { - // When ErrorHandlingType is absent from BSON, the describer must still - // emit "on error rollback" — matching Mendix Studio Pro's default. - // Without this default, describe → exec → describe drops the suffix - // because the writer omits the field when it equals Rollback. - action := parseCommitAction(map[string]any{ - "$ID": "commit-1", - "CommitVariableName": "Order", - "WithEvents": false, - "RefreshInClient": false, - }) - - if action.ErrorHandlingType != microflows.ErrorHandlingTypeRollback { - t.Errorf("expected default Rollback, got %q", action.ErrorHandlingType) - } -} - -func TestParseCodeActionParameterValue_MicroflowParameterValue(t *testing.T) { - value := parseCodeActionParameterValue(map[string]any{ - "$ID": "value-1", - "$Type": "Microflows$MicroflowParameterValue", - "Microflow": "SyntheticModule.Callback", - }) - - got, ok := value.(*microflows.MicroflowParameterValue) - if !ok { - t.Fatalf("value = %T, want *MicroflowParameterValue", value) - } - if got.Microflow != "SyntheticModule.Callback" { - t.Fatalf("microflow = %q", got.Microflow) - } -} - -func TestParseResultHandlingMappingUsesRangeForSingleObject(t *testing.T) { - got := parseResultHandling(map[string]any{ - "$ID": "result-handling-1", - "ResultVariableName": "RemoteApp", - "ImportMappingCall": map[string]any{ - "ReturnValueMapping": "SampleRuntimeApi.IMM_RemoteApp", - "ForceSingleOccurrence": false, - "Range": map[string]any{ - "SingleObject": true, - }, - }, - "VariableType": map[string]any{ - "$Type": "DataTypes$ObjectType", - "Entity": "SampleRuntimeApi.RemoteApp", - }, - }, "Mapping") - - rh, ok := got.(*microflows.ResultHandlingMapping) - if !ok { - t.Fatalf("got %T, want *microflows.ResultHandlingMapping", got) - } - if !rh.SingleObject { - t.Fatal("Range.SingleObject=true must make the result object-valued") - } - if rh.ForceSingleOccurrence == nil || *rh.ForceSingleOccurrence { - t.Fatalf("ForceSingleOccurrence = %v, want explicit false", rh.ForceSingleOccurrence) - } -} - -func TestSerializeRestResultHandlingPreservesForceSingleOccurrenceSeparately(t *testing.T) { - forceSingleOccurrence := false - doc := serializeRestResultHandling(µflows.ResultHandlingMapping{ - BaseElement: model.BaseElement{ID: model.ID("result-handling-1")}, - MappingID: model.ID("SampleRuntimeApi.IMM_RemoteApp"), - ResultEntityID: model.ID("SampleRuntimeApi.RemoteApp"), - ResultVariable: "RemoteApp", - SingleObject: true, - ForceSingleOccurrence: &forceSingleOccurrence, - }, "RemoteApp") - - importCall, ok := bsonDMap(doc)["ImportMappingCall"].(primitive.D) - if !ok { - t.Fatalf("ImportMappingCall missing or wrong type: %T", bsonDMap(doc)["ImportMappingCall"]) - } - callFields := bsonDMap(importCall) - if got := callFields["ForceSingleOccurrence"]; got != false { - t.Fatalf("ForceSingleOccurrence = %v, want false", got) - } - rangeDoc, ok := callFields["Range"].(primitive.D) - if !ok { - t.Fatalf("Range missing or wrong type: %T", callFields["Range"]) - } - if got := bsonDMap(rangeDoc)["SingleObject"]; got != true { - t.Fatalf("Range.SingleObject = %v, want true", got) - } - varType, ok := bsonDMap(doc)["VariableType"].(primitive.D) - if !ok { - t.Fatalf("VariableType missing or wrong type: %T", bsonDMap(doc)["VariableType"]) - } - if got := bsonDMap(varType)["$Type"]; got != "DataTypes$ObjectType" { - t.Fatalf("VariableType.$Type = %v, want DataTypes$ObjectType", got) - } -} - -func TestSerializeImportXmlActionPreservesSingleObjectRange(t *testing.T) { - forceSingleOccurrence := false - doc := serializeImportXmlAction(µflows.ImportXmlAction{ - BaseElement: model.BaseElement{ID: model.ID("import-action-1")}, - ResultHandling: µflows.ResultHandlingMapping{ - BaseElement: model.BaseElement{ID: model.ID("result-handling-1")}, - MappingID: model.ID("SampleRest.IMM_ErrorResponse"), - ResultEntityID: model.ID("SampleRest.Error"), - ResultVariable: "ErrorResponse", - SingleObject: true, - ForceSingleOccurrence: &forceSingleOccurrence, - }, - XmlDocumentVariable: "LatestHttpResponse", - }) - - resultHandling, ok := bsonDMap(doc)["ResultHandling"].(primitive.D) - if !ok { - t.Fatalf("ResultHandling missing or wrong type: %T", bsonDMap(doc)["ResultHandling"]) - } - importCall, ok := bsonDMap(resultHandling)["ImportMappingCall"].(primitive.D) - if !ok { - t.Fatalf("ImportMappingCall missing or wrong type: %T", bsonDMap(resultHandling)["ImportMappingCall"]) - } - callFields := bsonDMap(importCall) - if got := callFields["ForceSingleOccurrence"]; got != false { - t.Fatalf("ForceSingleOccurrence = %v, want false", got) - } - rangeDoc, ok := callFields["Range"].(primitive.D) - if !ok { - t.Fatalf("Range missing or wrong type: %T", callFields["Range"]) - } - if got := bsonDMap(rangeDoc)["SingleObject"]; got != true { - t.Fatalf("Range.SingleObject = %v, want true", got) - } -} - -func TestParseImportXmlActionUsesRangeForSingleObject(t *testing.T) { - got := parseImportXmlAction(map[string]any{ - "$ID": "import-action-1", - "XmlDocumentVariable": "LatestHttpResponse", - "XmlDocumentVariableName": "LatestHttpResponse", - "ResultHandling": map[string]any{ - "$ID": "result-handling-1", - "ResultVariableName": "ErrorResponse", - "ImportMappingCall": map[string]any{ - "ReturnValueMapping": "SampleRest.IMM_ErrorResponse", - "ForceSingleOccurrence": false, - "Range": map[string]any{ - "SingleObject": true, - }, - "VariableType": map[string]any{ - "$Type": "DataTypes$ObjectType", - "Entity": "SampleRest.Error", - }, - }, - }, - }) - - if got.ResultHandling == nil { - t.Fatal("ResultHandling missing") - } - if !got.ResultHandling.SingleObject { - t.Fatal("Range.SingleObject=true must make XML import result object-valued") - } - if got.ResultHandling.ForceSingleOccurrence == nil || *got.ResultHandling.ForceSingleOccurrence { - t.Fatalf("ForceSingleOccurrence = %v, want explicit false", got.ResultHandling.ForceSingleOccurrence) - } -} - -func bsonDMap(doc primitive.D) map[string]any { - out := make(map[string]any, len(doc)) - for _, elem := range doc { - out[elem.Key] = elem.Value - } - return out -} - -func TestSerializeSortItemPreservesIndirectEntityRef(t *testing.T) { - doc := serializeSortItem(µflows.SortItem{ - BaseElement: model.BaseElement{ID: model.ID("sort-1")}, - AttributeQualifiedName: "SampleApps.ApplicationView.CreatedAt", - EntityRefSteps: []microflows.EntityRefStep{ - { - Association: "SampleApps.DeploymentTarget_ApplicationView", - DestinationEntity: "SampleApps.ApplicationView", - }, - }, - Direction: microflows.SortDirectionDescending, - }) - - attrRef, ok := bsonDMap(doc)["AttributeRef"].(primitive.D) - if !ok { - t.Fatalf("AttributeRef missing or wrong type: %T", bsonDMap(doc)["AttributeRef"]) - } - entityRef, ok := bsonDMap(attrRef)["EntityRef"].(primitive.D) - if !ok { - t.Fatalf("EntityRef missing or wrong type: %T", bsonDMap(attrRef)["EntityRef"]) - } - if got := bsonDMap(entityRef)["$Type"]; got != "DomainModels$IndirectEntityRef" { - t.Fatalf("EntityRef.$Type = %v, want DomainModels$IndirectEntityRef", got) - } - steps, ok := bsonDMap(entityRef)["Steps"].(primitive.A) - if !ok || len(steps) != 2 { - t.Fatalf("Steps = %#v, want marker plus one step", bsonDMap(entityRef)["Steps"]) - } - step, ok := steps[1].(primitive.D) - if !ok { - t.Fatalf("step type = %T, want primitive.D", steps[1]) - } - stepFields := bsonDMap(step) - if got := stepFields["Association"]; got != "SampleApps.DeploymentTarget_ApplicationView" { - t.Fatalf("Association = %v", got) - } - if got := stepFields["DestinationEntity"]; got != "SampleApps.ApplicationView" { - t.Fatalf("DestinationEntity = %v", got) - } -} - -func TestParseSortItemsPreservesIndirectEntityRef(t *testing.T) { - got := parseSortItems(map[string]any{ - "NewSortings": map[string]any{ - "Sortings": []any{ - int32(2), - map[string]any{ - "$ID": "sort-1", - "$Type": "Microflows$RetrieveSorting", - "SortOrder": "Descending", - "AttributeRef": map[string]any{ - "$Type": "DomainModels$AttributeRef", - "Attribute": "SampleApps.ApplicationView.CreatedAt", - "EntityRef": map[string]any{ - "$Type": "DomainModels$IndirectEntityRef", - "Steps": []any{ - int32(2), - map[string]any{ - "$Type": "DomainModels$EntityRefStep", - "Association": "SampleApps.DeploymentTarget_ApplicationView", - "DestinationEntity": "SampleApps.ApplicationView", - }, - }, - }, - }, - }, - }, - }, - }) - - if len(got) != 1 { - t.Fatalf("got %d sort items, want 1", len(got)) - } - if steps := got[0].EntityRefSteps; len(steps) != 1 || steps[0].Association != "SampleApps.DeploymentTarget_ApplicationView" || steps[0].DestinationEntity != "SampleApps.ApplicationView" { - t.Fatalf("EntityRefSteps = %#v", steps) - } -} - -func TestParseActionActivityPreservesWebServiceActionRawBSONOrder(t *testing.T) { - rawAction := primitive.D{ - {Key: "$ID", Value: "web-service-action-ordered"}, - {Key: "$Type", Value: "Microflows$CallWebServiceAction"}, - {Key: "ImportedService", Value: "SyntheticSOAP.OrderService"}, - {Key: "OperationName", Value: "FetchItemsByTenant"}, - {Key: "TimeOutExpression", Value: "30"}, - {Key: "NewResultHandling", Value: primitive.D{ - {Key: "$Type", Value: "Microflows$WebServiceOperationResultHandling"}, - {Key: "ResultVariableName", Value: "SampleResponse"}, - }}, - } - expectedRaw, err := bson.Marshal(rawAction) - if err != nil { - t.Fatal(err) - } - - activity := parseActionActivity(map[string]any{ - "$ID": "activity-with-web-service-action", - "$Type": "Microflows$ActionActivity", - "Action": rawAction, - }) - action, ok := activity.Action.(*microflows.WebServiceCallAction) - if !ok { - t.Fatalf("Action = %T, want *WebServiceCallAction", activity.Action) - } - if !bytes.Equal(action.RawBSON, expectedRaw) { - t.Fatalf("RawBSON was not preserved byte-for-byte") - } - - serializedRaw, err := bson.Marshal(serializeWebServiceCallAction(action)) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(serializedRaw, expectedRaw) { - t.Fatalf("serialized raw BSON was not preserved byte-for-byte") - } -} - -func TestParseWebServiceActionFallsBackToRawBSONForUnsupportedFields(t *testing.T) { - action := parseWebServiceCallAction(map[string]any{ - "$ID": "soap-action-with-simple-request", - "$Type": "Microflows$CallWebServiceAction", - "ImportedService": "SyntheticSOAP.OrderService", - "OperationName": "SubmitOrder", - "RequestBodyHandling": map[string]any{ - "$Type": "Microflows$SimpleRequestHandling", - "ParameterMappings": []any{ - int32(2), - map[string]any{ - "$Type": "Microflows$WebServiceOperationSimpleParameterMapping", - "Argument": "$OrderID", - }, - }, - }, - }) - - if len(action.RawBSON) == 0 { - t.Fatal("RawBSON was empty for unsupported SOAP request details") - } - serialized := serializeWebServiceCallAction(action) - if got := bsonGetKey(serialized, "RequestBodyHandling"); got == nil { - t.Fatalf("RequestBodyHandling was not preserved in raw fallback: %#v", serialized) - } -} diff --git a/sdk/mpr/parser_microflow_workflow.go b/sdk/mpr/parser_microflow_workflow.go deleted file mode 100644 index da7ecaabb0..0000000000 --- a/sdk/mpr/parser_microflow_workflow.go +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -func parseWorkflowCallAction(raw map[string]any) *microflows.WorkflowCallAction { - action := µflows.WorkflowCallAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.Workflow = extractString(raw["Workflow"]) - action.WorkflowContextVariable = extractString(raw["WorkflowContextVariable"]) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.UseReturnVariable = extractBool(raw["UseReturnVariable"], false) - return action -} - -func parseGetWorkflowDataAction(raw map[string]any) *microflows.GetWorkflowDataAction { - action := µflows.GetWorkflowDataAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.Workflow = extractString(raw["Workflow"]) - action.WorkflowVariable = extractString(raw["WorkflowVariable"]) - return action -} - -func parseGetWorkflowsAction(raw map[string]any) *microflows.GetWorkflowsAction { - action := µflows.GetWorkflowsAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.WorkflowContextVariableName = extractString(raw["WorkflowContextVariableName"]) - return action -} - -func parseGetWorkflowActivityRecordsAction(raw map[string]any) *microflows.GetWorkflowActivityRecordsAction { - action := µflows.GetWorkflowActivityRecordsAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.WorkflowVariable = extractString(raw["WorkflowVariable"]) - return action -} - -func parseWorkflowOperationAction(raw map[string]any) *microflows.WorkflowOperationAction { - action := µflows.WorkflowOperationAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - - if opRaw, ok := raw["Operation"].(map[string]any); ok { - action.Operation = parseWorkflowOperation(opRaw) - } - return action -} - -func parseWorkflowOperation(raw map[string]any) microflows.WorkflowOperation { - typeName := extractString(raw["$Type"]) - wfVar := extractString(raw["WorkflowVariable"]) - - switch typeName { - case "Microflows$AbortOperation": - op := µflows.AbortOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - // Reason is a StringTemplate - if reason, ok := raw["Reason"].(map[string]any); ok { - op.Reason = extractString(reason["Text"]) - } - return op - case "Microflows$ContinueOperation": - op := µflows.ContinueOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - return op - case "Microflows$PauseOperation": - op := µflows.PauseOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - return op - case "Microflows$RestartOperation": - op := µflows.RestartOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - return op - case "Microflows$RetryOperation": - op := µflows.RetryOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - return op - case "Microflows$UnpauseOperation": - op := µflows.UnpauseOperation{} - op.ID = model.ID(extractBsonID(raw["$ID"])) - op.WorkflowVariable = wfVar - return op - } - return nil -} - -func parseSetTaskOutcomeAction(raw map[string]any) *microflows.SetTaskOutcomeAction { - action := µflows.SetTaskOutcomeAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutcomeValue = extractString(raw["OutcomeValue"]) - action.WorkflowTaskVariable = extractString(raw["WorkflowTaskVariable"]) - return action -} - -func parseOpenUserTaskAction(raw map[string]any) *microflows.OpenUserTaskAction { - action := µflows.OpenUserTaskAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.UserTaskVariable = extractString(raw["UserTaskVariable"]) - return action -} - -func parseNotifyWorkflowAction(raw map[string]any) *microflows.NotifyWorkflowAction { - action := µflows.NotifyWorkflowAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.OutputVariableName = extractString(raw["OutputVariableName"]) - action.WorkflowVariable = extractString(raw["WorkflowVariable"]) - return action -} - -func parseOpenWorkflowAction(raw map[string]any) *microflows.OpenWorkflowAction { - action := µflows.OpenWorkflowAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.WorkflowVariable = extractString(raw["WorkflowVariable"]) - return action -} - -func parseLockWorkflowAction(raw map[string]any) *microflows.LockWorkflowAction { - action := µflows.LockWorkflowAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.PauseAllWorkflows = extractBool(raw["PauseAllWorkflows"], false) - - if sel, ok := raw["WorkflowSelection"].(map[string]any); ok { - selType := extractString(sel["$Type"]) - switch selType { - case "Workflows$WorkflowDefinitionNameSelection": - action.Workflow = extractString(sel["Workflow"]) - case "Workflows$WorkflowDefinitionObjectSelection": - action.WorkflowVariable = extractString(sel["WorkflowDefinitionVariable"]) - } - } - return action -} - -func parseUnlockWorkflowAction(raw map[string]any) *microflows.UnlockWorkflowAction { - action := µflows.UnlockWorkflowAction{} - action.ID = model.ID(extractBsonID(raw["$ID"])) - action.ErrorHandlingType = microflows.ErrorHandlingType(extractString(raw["ErrorHandlingType"])) - action.ResumeAllPausedWorkflows = extractBool(raw["ResumeAllPausedWorkflows"], false) - - if sel, ok := raw["WorkflowSelection"].(map[string]any); ok { - selType := extractString(sel["$Type"]) - switch selType { - case "Workflows$WorkflowDefinitionNameSelection": - action.Workflow = extractString(sel["Workflow"]) - case "Workflows$WorkflowDefinitionObjectSelection": - action.WorkflowVariable = extractString(sel["WorkflowDefinitionVariable"]) - } - } - return action -} diff --git a/sdk/mpr/parser_misc.go b/sdk/mpr/parser_misc.go deleted file mode 100644 index 690140df72..0000000000 --- a/sdk/mpr/parser_misc.go +++ /dev/null @@ -1,838 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func (r *Reader) resolveContents(unitID string, contents []byte) ([]byte, error) { - // For MPR v1, contents are stored directly in the database - if r.version == MPRVersionV1 { - return contents, nil - } - - // For MPR v2, check if contents is a reference to an external file - // Contents might be empty or contain just a hash - if len(contents) > 0 { - // Check if it's actual BSON content (starts with length prefix) - if len(contents) >= 4 { - return contents, nil - } - } - - // Look for the external file in mprcontents - externalPath := filepath.Join(r.contentsDir, unitID) - if _, err := os.Stat(externalPath); err == nil { - return os.ReadFile(externalPath) - } - - // Try with common extensions - for _, ext := range []string{".mxunit", ".json", ""} { - path := filepath.Join(r.contentsDir, unitID+ext) - if data, err := os.ReadFile(path); err == nil { - return data, nil - } - } - - return contents, nil -} - -// parseSnippet parses snippet contents from BSON. -func (r *Reader) parseSnippet(unitID, containerID string, contents []byte) (*pages.Snippet, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - snippet := &pages.Snippet{} - snippet.ID = model.ID(unitID) - snippet.TypeName = "Pages$Snippet" - snippet.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - snippet.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - snippet.Documentation = doc - } - // Excluded must survive read→rebuild→write (#914). - if excl, ok := raw["Excluded"].(bool); ok { - snippet.Excluded = excl - } - if entityID := extractID(raw["Entity"]); entityID != "" { - snippet.EntityID = model.ID(entityID) - } - - // Parse snippet parameters so callers can validate SNIPPETCALL param mappings. - if params, ok := raw["Parameters"].(bson.A); ok { - for i := 1; i < len(params); i++ { - var paramMap map[string]any - switch v := params[i].(type) { - case bson.D: - paramMap = make(map[string]any, len(v)) - for _, elem := range v { - paramMap[elem.Key] = elem.Value - } - case map[string]any: - paramMap = v - } - if paramMap == nil { - continue - } - sp := &pages.SnippetParameter{} - if id := extractID(paramMap["$ID"]); id != "" { - sp.ID = model.ID(id) - } - if n, ok := paramMap["Name"].(string); ok { - sp.Name = n - } - if sp.Name == "" { - continue - } - // ParameterType: either bson.D or map[string]any - extractParamType := func(m map[string]any) { - if t, ok := m["$Type"].(string); ok { - sp.Type = t - } - if e, ok := m["Entity"].(string); ok { - sp.EntityName = e - } - } - switch pt := paramMap["ParameterType"].(type) { - case bson.D: - m := make(map[string]any, len(pt)) - for _, e := range pt { - m[e.Key] = e.Value - } - extractParamType(m) - case map[string]any: - extractParamType(pt) - } - snippet.Parameters = append(snippet.Parameters, sp) - } - } - - return snippet, nil -} - -// parseJavaAction parses Java action contents from BSON. -func (r *Reader) parseJavaAction(unitID, containerID string, contents []byte) (*JavaAction, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ja := &JavaAction{} - ja.ID = model.ID(unitID) - ja.TypeName = "JavaActions$JavaAction" - ja.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - ja.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - ja.Documentation = doc - } - // Excluded must survive read→rebuild→write; defaulting it to false - // un-excludes the document on the next CREATE OR MODIFY (#914). - if excl, ok := raw["Excluded"].(bool); ok { - ja.Excluded = excl - } - - return ja, nil -} - -// extractID extracts an ID from various BSON representations. -// IDs in Mendix BSON can be strings, binary UUIDs, or nested structures. -func extractID(v any) string { - if v == nil { - return "" - } - - switch val := v.(type) { - case string: - return val - case []byte: - return blobToUUID(val) - case map[string]any: - // Could be a reference structure with $ID - if id, ok := val["$ID"].(string); ok { - return id - } - if id, ok := val["$ID"].([]byte); ok { - return blobToUUID(id) - } - } - - return "" -} - -// WriteJSON serializes the given element to JSON. -func WriteJSON(element any) ([]byte, error) { - return json.MarshalIndent(element, "", " ") -} - -// parseJavaScriptAction parses JavaScript action contents from BSON. -func (r *Reader) parseJavaScriptAction(unitID, containerID string, contents []byte) (*JavaScriptAction, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - jsa := &JavaScriptAction{} - jsa.ID = model.ID(unitID) - jsa.TypeName = "JavaScriptActions$JavaScriptAction" - jsa.ContainerID = model.ID(containerID) - - // Basic fields - jsa.Name = extractString(raw["Name"]) - jsa.Documentation = extractString(raw["Documentation"]) - jsa.Platform = extractString(raw["Platform"]) - jsa.Excluded = extractBool(raw["Excluded"], false) - jsa.ExportLevel = extractString(raw["ExportLevel"]) - jsa.ActionDefaultReturnName = extractString(raw["ActionDefaultReturnName"]) - - // Parse return type - switch rt := raw["JavaReturnType"].(type) { - case map[string]any: - jsa.ReturnType = parseCodeActionReturnType(rt) - case primitive.D: - jsa.ReturnType = parseCodeActionReturnType(primitiveToMap(rt)) - } - - // Parse parameters - switch params := raw["Parameters"].(type) { - case []any: - for _, p := range params { - if pMap := toMap(p); pMap != nil { - if param := parseJavaActionParameter(pMap); param != nil { - jsa.Parameters = append(jsa.Parameters, param) - } - } - } - case primitive.A: - for _, p := range params { - if pMap := toMap(p); pMap != nil { - if param := parseJavaActionParameter(pMap); param != nil { - jsa.Parameters = append(jsa.Parameters, param) - } - } - } - } - - // Parse type parameters - switch typeParams := raw["TypeParameters"].(type) { - case []any: - for _, tp := range typeParams { - if tpMap := toMap(tp); tpMap != nil { - if name := extractString(tpMap["Name"]); name != "" { - jsa.TypeParameters = append(jsa.TypeParameters, &javaactions.TypeParameterDef{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(tpMap["$ID"]))}, - Name: name, - }) - } - } - } - case primitive.A: - for _, tp := range typeParams { - if tpMap := toMap(tp); tpMap != nil { - if name := extractString(tpMap["Name"]); name != "" { - jsa.TypeParameters = append(jsa.TypeParameters, &javaactions.TypeParameterDef{ - BaseElement: model.BaseElement{ID: model.ID(extractBsonID(tpMap["$ID"]))}, - Name: name, - }) - } - } - } - } - - // Parse MicroflowActionInfo - if mai := toMap(raw["MicroflowActionInfo"]); mai != nil { - jsa.MicroflowActionInfo = parseMicroflowActionInfo(mai) - } - - // Resolve type parameter names for EntityTypeParameterType and TypeParameter - for _, param := range jsa.Parameters { - switch pt := param.ParameterType.(type) { - case *javaactions.EntityTypeParameterType: - pt.TypeParameterName = jsa.FindTypeParameterName(pt.TypeParameterID) - case *javaactions.TypeParameter: - if pt.TypeParameterID != "" && pt.TypeParameter == "" { - pt.TypeParameter = jsa.FindTypeParameterName(pt.TypeParameterID) - } - } - } - - // Resolve type parameter name for return type - if tp, ok := jsa.ReturnType.(*javaactions.TypeParameter); ok { - if tp.TypeParameterID != "" && tp.TypeParameter == "" { - tp.TypeParameter = jsa.FindTypeParameterName(tp.TypeParameterID) - } - } - - return jsa, nil -} - -// ReadJavaScriptActionByName reads a JavaScript action by qualified name (Module.ActionName). -func (r *Reader) ReadJavaScriptActionByName(qualifiedName string) (*JavaScriptAction, error) { - units, err := r.listUnitsByType("JavaScriptActions$JavaScriptAction") - if err != nil { - return nil, err - } - - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleNames := make(map[model.ID]string) - for _, m := range modules { - moduleNames[m.ID] = m.Name - } - - folders, err := r.ListFolders() - if err != nil { - return nil, err - } - folderContainers := make(map[model.ID]model.ID) - for _, f := range folders { - folderContainers[f.ID] = f.ContainerID - } - - for _, u := range units { - contents, err := r.resolveContents(u.ID, u.Contents) - if err != nil { - continue - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - continue - } - - name := extractString(raw["Name"]) - - modName := "" - containerID := model.ID(u.ContainerID) - for range 20 { - if mn, ok := moduleNames[containerID]; ok { - modName = mn - break - } - if parent, ok := folderContainers[containerID]; ok { - containerID = parent - } else { - break - } - } - - if modName+"."+name == qualifiedName { - return r.parseJavaScriptAction(u.ID, u.ContainerID, contents) - } - } - - return nil, fmt.Errorf("javascript action not found: %s", qualifiedName) -} - -// parseBuildingBlock parses building block contents from BSON. -func (r *Reader) parseBuildingBlock(unitID, containerID string, contents []byte) (*pages.BuildingBlock, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - bb := &pages.BuildingBlock{} - bb.ID = model.ID(unitID) - bb.TypeName = "Pages$BuildingBlock" - bb.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - bb.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - bb.Documentation = doc - } - if displayName, ok := raw["DisplayName"].(string); ok { - bb.DisplayName = displayName - } - if platform, ok := raw["Platform"].(string); ok { - bb.Platform = platform - } - if category, ok := raw["TemplateCategory"].(string); ok { - bb.TemplateCategory = category - } - - return bb, nil -} - -// parsePageTemplate parses page template contents from BSON. -func (r *Reader) parsePageTemplate(unitID, containerID string, contents []byte) (*pages.PageTemplate, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - pt := &pages.PageTemplate{} - pt.ID = model.ID(unitID) - pt.TypeName = "Forms$PageTemplate" - pt.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - pt.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - pt.Documentation = doc - } - - return pt, nil -} - -// parseNavigationDocument parses navigation document contents from BSON. -func (r *Reader) parseNavigationDocument(unitID, containerID string, contents []byte) (*NavigationDocument, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - nav := &NavigationDocument{} - nav.ID = model.ID(unitID) - nav.TypeName = "Navigation$NavigationDocument" - nav.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - nav.Name = name - } - - // Parse navigation profiles - for _, item := range extractBsonArray(raw["Profiles"]) { - profMap, ok := item.(map[string]any) - if !ok { - continue - } - profile := parseNavigationProfile(profMap) - if profile != nil { - nav.Profiles = append(nav.Profiles, profile) - } - } - - return nav, nil -} - -// parseNavigationProfile parses a single navigation profile from BSON. -func parseNavigationProfile(raw map[string]any) *NavigationProfile { - typeName := extractString(raw["$Type"]) - profile := &NavigationProfile{ - Name: extractString(raw["Name"]), - Kind: extractString(raw["Kind"]), - } - - if typeName == "Navigation$NativeNavigationProfile" { - profile.IsNative = true - // Native home page - if hp, ok := raw["NativeHomePage"].(map[string]any); ok { - page := extractString(hp["HomePagePage"]) - nanoflow := extractString(hp["HomePageNanoflow"]) - if page != "" || nanoflow != "" { - profile.HomePage = &NavHomePage{Page: page, Microflow: nanoflow} - } - } - // Native role-based home pages - for _, item := range extractBsonArray(raw["RoleBasedNativeHomePages"]) { - if rbMap, ok := item.(map[string]any); ok { - rbh := &NavRoleBasedHome{ - UserRole: extractString(rbMap["UserRole"]), - Page: extractString(rbMap["HomePagePage"]), - Microflow: extractString(rbMap["HomePageNanoflow"]), - } - if rbh.UserRole != "" { - profile.RoleBasedHomePages = append(profile.RoleBasedHomePages, rbh) - } - } - } - // Native bottom bar items contribute to menu - for _, item := range extractBsonArray(raw["BottomBarItems"]) { - if barMap, ok := item.(map[string]any); ok { - mi := parseNavMenuItemFromBottomBar(barMap) - if mi != nil { - profile.MenuItems = append(profile.MenuItems, mi) - } - } - } - } else { - // Web profile (Navigation$NavigationProfile) - // Default home page - if hp, ok := raw["HomePage"].(map[string]any); ok { - page := extractString(hp["Page"]) - mf := extractString(hp["Microflow"]) - if page != "" || mf != "" { - profile.HomePage = &NavHomePage{Page: page, Microflow: mf} - } - } - // Role-based home pages (stored as "HomeItems") - for _, item := range extractBsonArray(raw["HomeItems"]) { - if rbMap, ok := item.(map[string]any); ok { - rbh := &NavRoleBasedHome{ - UserRole: extractString(rbMap["UserRole"]), - Page: extractString(rbMap["Page"]), - Microflow: extractString(rbMap["Microflow"]), - } - if rbh.UserRole != "" { - profile.RoleBasedHomePages = append(profile.RoleBasedHomePages, rbh) - } - } - } - // Login page (stored as "LoginPageSettings" with type Forms$FormSettings) - if lps, ok := raw["LoginPageSettings"].(map[string]any); ok { - profile.LoginPage = extractString(lps["Form"]) - } - // Not-found page - if nfp, ok := raw["NotFoundHomepage"].(map[string]any); ok { - profile.NotFoundPage = extractString(nfp["Page"]) - if profile.NotFoundPage == "" { - profile.NotFoundPage = extractString(nfp["Microflow"]) - } - } - // Menu items (stored as "Menu" → MenuItemCollection) - if menu, ok := raw["Menu"].(map[string]any); ok { - for _, item := range extractBsonArray(menu["Items"]) { - if miMap, ok := item.(map[string]any); ok { - mi := parseNavMenuItem(miMap) - if mi != nil { - profile.MenuItems = append(profile.MenuItems, mi) - } - } - } - } - } - - // Studio Pro writes this on every web profile, online ones included, and - // neither gen nor generated/metamodel declares it — so it is read straight - // off the raw document. Defaulting to true matches every reference profile - // and Studio Pro's own checked-by-default box, so a document that somehow - // lacks the key is not silently flipped to "do not throw". - profile.ThrowPartialSyncError = extractBool(raw["ThrowPartialSyncError"], true) - - // Offline entity configs (both web and native) - for _, item := range extractBsonArray(raw["OfflineEntityConfigs"]) { - if oeMap, ok := item.(map[string]any); ok { - oe := &NavOfflineEntity{ - Entity: extractString(oeMap["Entity"]), - SyncMode: extractString(oeMap["SyncMode"]), - Constraint: extractString(oeMap["Constraint"]), - CompatibilityMode: extractBool(oeMap["CompatibilityMode"], false), - } - if oe.Entity != "" { - profile.OfflineEntities = append(profile.OfflineEntities, oe) - } - } - } - - return profile -} - -// parseNavMenuItem parses a Menus$MenuItem from BSON. -func parseNavMenuItem(raw map[string]any) *NavMenuItem { - mi := &NavMenuItem{} - - // Extract caption text (Caption → Items → first Translation → Text) - if caption, ok := raw["Caption"].(map[string]any); ok { - mi.Caption = extractTextFromBson(caption) - } - - // Icon is polymorphic: Forms$IconCollectionIcon and Forms$ImageIcon carry a - // qualified Image, Forms$GlyphIcon carries a numeric Code and no name at - // all. Keep the $Type so callers can tell which one they got. - if icon, ok := raw["Icon"].(map[string]any); ok { - mi.IconType = extractString(icon["$Type"]) - mi.Icon = extractString(icon["Image"]) - // The glyph's Code identifies WHICH glyph; without it a caller knows one - // was there and nothing more, so it cannot be re-emitted or carried - // through a rewrite. - mi.IconCode = extractInt(icon["Code"]) - } - - // Extract action type and target from Action - if action, ok := raw["Action"].(map[string]any); ok { - actionType := extractString(action["$Type"]) - switch { - case strings.HasSuffix(actionType, "FormAction") || strings.HasSuffix(actionType, "PageClientAction"): - mi.ActionType = "PageAction" - if fs, ok := action["FormSettings"].(map[string]any); ok { - mi.Page = extractString(fs["Form"]) - } - case strings.HasSuffix(actionType, "MicroflowAction") || strings.HasSuffix(actionType, "MicroflowClientAction"): - mi.ActionType = "MicroflowAction" - if ms, ok := action["MicroflowSettings"].(map[string]any); ok { - mi.Microflow = extractString(ms["Microflow"]) - } - case strings.HasSuffix(actionType, "SignOutClientAction"): - // Named rather than left to the raw-type-name default: DESCRIBE and - // both writers key on "SignOutAction", so a round trip only closes - // if the reader produces the name the writer consumes. - mi.ActionType = "SignOutAction" - case strings.HasSuffix(actionType, "OpenLinkAction") || strings.HasSuffix(actionType, "OpenLinkClientAction"): - mi.ActionType = "OpenLinkAction" - case strings.HasSuffix(actionType, "NoAction") || strings.HasSuffix(actionType, "NoClientAction"): - mi.ActionType = "NoAction" - default: - mi.ActionType = actionType - } - } - - // Recurse into sub-items - for _, item := range extractBsonArray(raw["Items"]) { - if subMap, ok := item.(map[string]any); ok { - sub := parseNavMenuItem(subMap) - if sub != nil { - mi.Items = append(mi.Items, sub) - } - } - } - - // Only return if we have at least a caption or a page - if mi.Caption == "" && mi.Page == "" && len(mi.Items) == 0 { - return nil - } - return mi -} - -// parseNavMenuItemFromBottomBar parses a NativePages$BottomBarItem as a NavMenuItem. -func parseNavMenuItemFromBottomBar(raw map[string]any) *NavMenuItem { - mi := &NavMenuItem{} - if caption, ok := raw["Caption"].(map[string]any); ok { - mi.Caption = extractTextFromBson(caption) - } - mi.Page = extractString(raw["Page"]) - if mi.Caption == "" && mi.Page == "" { - return nil - } - return mi -} - -// extractTextFromBson extracts the first English text from a Texts$Text BSON object. -// Tries Items array first (Items → Translation → Text), then Translations map. -func extractTextFromBson(raw map[string]any) string { - // Try Items array: [{LanguageCode: "en_US", Text: "..."}] - for _, item := range extractBsonArray(raw["Items"]) { - if transMap, ok := item.(map[string]any); ok { - text := extractString(transMap["Text"]) - if text != "" { - return text - } - } - } - // Try Translations array - for _, item := range extractBsonArray(raw["Translations"]) { - if transMap, ok := item.(map[string]any); ok { - text := extractString(transMap["Text"]) - if text != "" { - return text - } - } - } - return "" -} - -// parseImageCollection parses image collection contents from BSON. -func (r *Reader) parseImageCollection(unitID, containerID string, contents []byte) (*ImageCollection, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ic := &ImageCollection{} - ic.ID = model.ID(unitID) - ic.TypeName = "Images$ImageCollection" - ic.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - ic.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - ic.Documentation = doc - } - if exp, ok := raw["ExportLevel"].(string); ok { - ic.ExportLevel = exp - } - - // Parse images in the collection - if images, ok := raw["Images"].(bson.A); ok { - for _, img := range images { - if imgMap, ok := img.(map[string]any); ok { - image := Image{} - if id := extractID(imgMap["$ID"]); id != "" { - image.ID = model.ID(id) - } - if name, ok := imgMap["Name"].(string); ok { - image.Name = name - } - if format, ok := imgMap["ImageFormat"].(string); ok { - image.Format = format - } - if data, ok := imgMap["Image"].(primitive.Binary); ok { - image.Data = data.Data - } else if data, ok := imgMap["Image"].([]byte); ok { - image.Data = data - } - ic.Images = append(ic.Images, image) - } - } - } - - return ic, nil -} - -// parseJsonStructure parses JSON structure contents from BSON. -func (r *Reader) parseJsonStructure(unitID, containerID string, contents []byte) (*JsonStructure, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - js := &JsonStructure{} - js.ID = model.ID(unitID) - js.TypeName = "JsonStructures$JsonStructure" - js.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - js.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - js.Documentation = doc - } - if snippet, ok := raw["JsonSnippet"].(string); ok { - js.JsonSnippet = snippet - } - if exp, ok := raw["ExportLevel"].(string); ok { - js.ExportLevel = exp - } - if exc, ok := raw["Excluded"].(bool); ok { - js.Excluded = exc - } - - // Parse elements (bson.A with version prefix) - if elements, ok := raw["Elements"].(bson.A); ok { - for _, elem := range elements { - if elemMap, ok := elem.(map[string]any); ok { - js.Elements = append(js.Elements, parseJsonElement(elemMap)) - } - } - } - - return js, nil -} - -// parseJsonElement recursively parses a JsonStructures$JsonElement from BSON. -func parseJsonElement(raw map[string]any) *JsonElement { - elem := &JsonElement{ - MaxLength: -1, - FractionDigits: -1, - TotalDigits: -1, - } - - if v, ok := raw["ExposedName"].(string); ok { - elem.ExposedName = v - } - if v, ok := raw["ExposedItemName"].(string); ok { - elem.ExposedItemName = v - } - if v, ok := raw["Path"].(string); ok { - elem.Path = v - } - if v, ok := raw["ElementType"].(string); ok { - elem.ElementType = v - } - if v, ok := raw["PrimitiveType"].(string); ok { - elem.PrimitiveType = v - } - // Issue #585: Studio Pro writes these numeric facets as BSON int64; - // mxcli's writer emits int32. extractInt accepts both (plus int and - // float64). Default values for MaxLength/FractionDigits/TotalDigits - // stay at -1 (set in the literal above) when the field is absent. - if _, ok := raw["MinOccurs"]; ok { - elem.MinOccurs = extractInt(raw["MinOccurs"]) - } - if _, ok := raw["MaxOccurs"]; ok { - elem.MaxOccurs = extractInt(raw["MaxOccurs"]) - } - if v, ok := raw["Nillable"].(bool); ok { - elem.Nillable = v - } - if v, ok := raw["IsDefaultType"].(bool); ok { - elem.IsDefaultType = v - } - if _, ok := raw["MaxLength"]; ok { - elem.MaxLength = extractInt(raw["MaxLength"]) - } - if _, ok := raw["FractionDigits"]; ok { - elem.FractionDigits = extractInt(raw["FractionDigits"]) - } - if _, ok := raw["TotalDigits"]; ok { - elem.TotalDigits = extractInt(raw["TotalDigits"]) - } - if v, ok := raw["OriginalValue"].(string); ok { - elem.OriginalValue = v - } - - // Parse children (bson.A with version prefix) - if children, ok := raw["Children"].(bson.A); ok { - for _, child := range children { - if childMap, ok := child.(map[string]any); ok { - elem.Children = append(elem.Children, parseJsonElement(childMap)) - } - } - } - - return elem -} diff --git a/sdk/mpr/parser_misc_test.go b/sdk/mpr/parser_misc_test.go deleted file mode 100644 index a12d01b0c5..0000000000 --- a/sdk/mpr/parser_misc_test.go +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" -) - -// Issue #585: parseJsonElement asserted `raw[field].(int32)` for every numeric -// facet on a JSON-structure element. Mendix Studio Pro stores these fields as -// BSON int64, so the assertion failed silently and the parsed value defaulted -// to 0 — the same class of bug fixed for StringAttributeType.Length in #583. -// -// Each numeric facet must round-trip across every BSON numeric width that the -// mongo-driver may produce (int32, int64, int, float64) and preserve the -// default when the field is missing. -func TestParseJsonElement_NumericFields_BsonNumericWidths(t *testing.T) { - type fieldCase struct { - name string - bsonKey string - read func(*JsonElement) int - missing int // expected zero value when field is absent - } - fields := []fieldCase{ - {"MinOccurs", "MinOccurs", func(e *JsonElement) int { return e.MinOccurs }, 0}, - {"MaxOccurs", "MaxOccurs", func(e *JsonElement) int { return e.MaxOccurs }, 0}, - {"MaxLength", "MaxLength", func(e *JsonElement) int { return e.MaxLength }, -1}, - {"FractionDigits", "FractionDigits", func(e *JsonElement) int { return e.FractionDigits }, -1}, - {"TotalDigits", "TotalDigits", func(e *JsonElement) int { return e.TotalDigits }, -1}, - } - - widths := []struct { - name string - value any - }{ - {"int32 (mxcli writer)", int32(42)}, - {"int64 (Studio Pro writer)", int64(42)}, - {"int", int(42)}, - {"float64 (extended JSON)", float64(42)}, - } - - for _, f := range fields { - for _, w := range widths { - t.Run(f.name+"/"+w.name, func(t *testing.T) { - raw := map[string]any{f.bsonKey: w.value} - elem := parseJsonElement(raw) - if got := f.read(elem); got != 42 { - t.Errorf("%s = %d, want 42 (input %T(%v))", f.name, got, w.value, w.value) - } - }) - } - t.Run(f.name+"/missing", func(t *testing.T) { - elem := parseJsonElement(map[string]any{}) - if got := f.read(elem); got != f.missing { - t.Errorf("%s = %d, want default %d when field absent", f.name, got, f.missing) - } - }) - } -} diff --git a/sdk/mpr/parser_module.go b/sdk/mpr/parser_module.go deleted file mode 100644 index 076482e126..0000000000 --- a/sdk/mpr/parser_module.go +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parseModule(unitID string, contents []byte) (*model.Module, error) { - // For MPR v2, contents might be a reference to an external file - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - // Parse BSON contents - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - module := &model.Module{} - module.ID = model.ID(unitID) - module.TypeName = "Projects$Module" - - if name, ok := raw["Name"].(string); ok { - module.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - module.Documentation = doc - } - if excluded, ok := raw["Excluded"].(bool); ok { - module.Excluded = excluded - } - if fromAppStore, ok := raw["FromAppStore"].(bool); ok { - module.FromAppStore = fromAppStore - } - if appStoreVersion, ok := raw["AppStoreVersion"].(string); ok { - module.AppStoreVersion = appStoreVersion - } - if appStoreGuid, ok := raw["AppStoreGuid"].(string); ok { - module.AppStoreGuid = appStoreGuid - } - if isReusable, ok := raw["IsReusableComponent"].(bool); ok { - module.IsReusableComponent = isReusable - } - - return module, nil -} - -// parseDomainModel parses domain model contents from BSON. diff --git a/sdk/mpr/parser_nanoflow.go b/sdk/mpr/parser_nanoflow.go deleted file mode 100644 index bf60f1b930..0000000000 --- a/sdk/mpr/parser_nanoflow.go +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parseNanoflow(unitID, containerID string, contents []byte) (*microflows.Nanoflow, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - nf := µflows.Nanoflow{} - nf.ID = model.ID(unitID) - nf.TypeName = "Microflows$Nanoflow" - nf.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - nf.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - nf.Documentation = doc - } - if markAsUsed, ok := raw["MarkAsUsed"].(bool); ok { - nf.MarkAsUsed = markAsUsed - } - if excluded, ok := raw["Excluded"].(bool); ok { - nf.Excluded = excluded - } - - // Parse AllowedModuleRoles - for _, role := range extractBsonArray(raw["AllowedModuleRoles"]) { - if roleID, ok := role.(string); ok { - nf.AllowedModuleRoles = append(nf.AllowedModuleRoles, model.ID(roleID)) - } - } - - // Parse parameters (same format variants as microflows) - var paramsArray any - if mpc, ok := raw["MicroflowParameterCollection"]; ok { - if mpcMap := extractBsonMap(mpc); mpcMap != nil { - paramsArray = mpcMap["Parameters"] - } - } else { - paramKey := "MicroflowParameters" - if _, ok := raw[paramKey]; !ok { - paramKey = "Parameters" - } - paramsArray = raw[paramKey] - } - for _, p := range extractBsonSlice(paramsArray) { - if paramMap := extractBsonMap(p); paramMap != nil { - param := parseMicroflowParameter(paramMap, len(nf.Parameters)) - nf.Parameters = append(nf.Parameters, param) - } - } - - // Parse return type (uses same BSON key as microflows) - if rt, ok := raw["MicroflowReturnType"].(map[string]any); ok { - nf.ReturnType = parseMicroflowDataType(rt) - } - - // Parse object collection (activities) - if oc := extractBsonMap(raw["ObjectCollection"]); oc != nil { - nf.ObjectCollection = parseMicroflowObjectCollection(oc) - } - - // Also extract parameters from ObjectCollection.Objects (modern format) - if len(nf.Parameters) == 0 { - if ocRaw := extractBsonMap(raw["ObjectCollection"]); ocRaw != nil { - for _, obj := range extractBsonSlice(ocRaw["Objects"]) { - if objMap := extractBsonMap(obj); objMap != nil { - if typeName, _ := objMap["$Type"].(string); typeName == "Microflows$MicroflowParameter" { - param := parseMicroflowParameter(objMap, len(nf.Parameters)) - nf.Parameters = append(nf.Parameters, param) - } - } - } - } - } - - // Parse Flows array (SequenceFlows and AnnotationFlows at root level) - if flowsRaw := raw["Flows"]; flowsRaw != nil { - if nf.ObjectCollection == nil { - nf.ObjectCollection = µflows.MicroflowObjectCollection{} - } - for _, f := range extractBsonSlice(flowsRaw) { - if flowMap := extractBsonMap(f); flowMap != nil { - typeName, _ := flowMap["$Type"].(string) - switch typeName { - case "Microflows$AnnotationFlow": - if af := parseAnnotationFlow(flowMap); af != nil { - nf.ObjectCollection.AnnotationFlows = append(nf.ObjectCollection.AnnotationFlows, af) - } - default: - if flow := parseSequenceFlow(flowMap); flow != nil { - nf.ObjectCollection.Flows = append(nf.ObjectCollection.Flows, flow) - } - } - } - } - } - - return nf, nil -} - -// parsePage parses page contents from BSON. diff --git a/sdk/mpr/parser_odata.go b/sdk/mpr/parser_odata.go deleted file mode 100644 index b4e8a52084..0000000000 --- a/sdk/mpr/parser_odata.go +++ /dev/null @@ -1,364 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseConsumedODataService parses a consumed OData service (OData client) from BSON. -func (r *Reader) parseConsumedODataService(unitID, containerID string, contents []byte) (*model.ConsumedODataService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - svc := &model.ConsumedODataService{} - svc.ID = model.ID(unitID) - svc.TypeName = "Rest$ConsumedODataService" - svc.ContainerID = model.ID(containerID) - - svc.Name = extractString(raw["Name"]) - svc.Documentation = extractString(raw["Documentation"]) - svc.Version = extractString(raw["Version"]) - svc.ServiceName = extractString(raw["ServiceName"]) - svc.ODataVersion = extractString(raw["ODataVersion"]) - svc.MetadataUrl = extractString(raw["MetadataUrl"]) - svc.TimeoutExpression = extractString(raw["TimeoutExpression"]) - svc.ProxyType = extractString(raw["ProxyType"]) - svc.Description = extractString(raw["Description"]) - svc.Validated = extractBool(raw["Validated"], false) - svc.Excluded = extractBool(raw["Excluded"], false) - - // Microflow references (BY_NAME). The microflow storage fields were renamed/ - // split across Mendix versions (see writer_odata.go / issue #728). Config - // slot: ConfigurationEntityMicroflow (11.10+) or the pre-11.10 single slot - // (ConfigurationMicroflow / ancient HeadersMicroflow). Headers slot is only - // distinct on 11.10+ (HeaderListMicroflow). - for _, key := range []string{"ConfigurationEntityMicroflow", "ConfigurationMicroflow", "HeadersMicroflow"} { - if v := extractString(raw[key]); v != "" { - svc.ConfigurationMicroflow = v - break - } - } - svc.HeadersMicroflow = extractString(raw["HeaderListMicroflow"]) - svc.ErrorHandlingMicroflow = extractString(raw["ErrorHandlingMicroflow"]) - - // Proxy constant references (BY_NAME) - svc.ProxyHost = extractString(raw["ProxyHost"]) - svc.ProxyPort = extractString(raw["ProxyPort"]) - svc.ProxyUsername = extractString(raw["ProxyUsername"]) - svc.ProxyPassword = extractString(raw["ProxyPassword"]) - - // Cached contract metadata - svc.Metadata = extractString(raw["Metadata"]) - svc.MetadataHash = extractString(raw["MetadataHash"]) - - // Mendix Catalog integration - svc.ApplicationId = extractString(raw["ApplicationId"]) - svc.EndpointId = extractString(raw["EndpointId"]) - svc.CatalogUrl = extractString(raw["CatalogUrl"]) - svc.EnvironmentType = extractString(raw["EnvironmentType"]) - - // Parse HTTP configuration (nested part) - if httpCfg, ok := raw["HttpConfiguration"].(map[string]any); ok { - svc.HttpConfiguration = parseODataHttpConfiguration(httpCfg) - } - - return svc, nil -} - -// parseODataHttpConfiguration parses a Microflows$HttpConfiguration BSON map -// into the model.HttpConfiguration type used by consumed OData services. -func parseODataHttpConfiguration(raw map[string]any) *model.HttpConfiguration { - cfg := &model.HttpConfiguration{} - cfg.ID = model.ID(extractBsonID(raw["$ID"])) - cfg.TypeName = extractString(raw["$Type"]) - cfg.UseAuthentication = extractBool(raw["UseHttpAuthentication"], false) - cfg.Username = extractString(raw["HttpAuthenticationUserName"]) - cfg.Password = extractString(raw["HttpAuthenticationPassword"]) - cfg.HttpMethod = extractString(raw["HttpMethod"]) - cfg.OverrideLocation = extractBool(raw["OverrideLocation"], false) - cfg.CustomLocation = extractString(raw["CustomLocation"]) - cfg.ClientCertificate = extractString(raw["ClientCertificate"]) - - // Parse header entries - headers := extractBsonArray(raw["HttpHeaderEntries"]) - for _, h := range headers { - if hMap, ok := h.(map[string]any); ok { - entry := &model.HttpHeaderEntry{} - entry.ID = model.ID(extractBsonID(hMap["$ID"])) - entry.TypeName = extractString(hMap["$Type"]) - entry.Key = extractString(hMap["Key"]) - entry.Value = extractString(hMap["Value"]) - cfg.HeaderEntries = append(cfg.HeaderEntries, entry) - } - } - - return cfg -} - -// parsePublishedODataService parses a published OData service from BSON. -func (r *Reader) parsePublishedODataService(unitID, containerID string, contents []byte) (*model.PublishedODataService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - svc := &model.PublishedODataService{} - svc.ID = model.ID(unitID) - svc.TypeName = "ODataPublish$PublishedODataService2" - svc.ContainerID = model.ID(containerID) - - svc.Name = extractString(raw["Name"]) - svc.Documentation = extractString(raw["Documentation"]) - svc.Path = extractString(raw["Path"]) - svc.Namespace = extractString(raw["Namespace"]) - svc.ServiceName = extractString(raw["ServiceName"]) - svc.Version = extractString(raw["Version"]) - svc.ODataVersion = extractString(raw["ODataVersion"]) - svc.Summary = extractString(raw["Summary"]) - svc.Description = extractString(raw["Description"]) - svc.PublishAssociations = extractBool(raw["PublishAssociations"], false) - svc.SupportsGraphQL = extractBool(raw["SupportsGraphQL"], false) - svc.UseGeneralization = extractBool(raw["UseGeneralization"], false) - svc.Excluded = extractBool(raw["Excluded"], false) - svc.AuthMicroflow = extractString(raw["AuthenticationMicroflow"]) - - // Parse authentication types - authTypes := extractBsonArray(raw["AuthenticationTypes"]) - for _, at := range authTypes { - if s, ok := at.(string); ok { - svc.AuthenticationTypes = append(svc.AuthenticationTypes, s) - } - } - - // Parse allowed module roles (BY_NAME references) - allowedRoles := extractBsonArray(raw["AllowedModuleRoles"]) - for _, r := range allowedRoles { - if name, ok := r.(string); ok { - svc.AllowedModuleRoles = append(svc.AllowedModuleRoles, name) - } - } - - // Build map of entity type IDs for EntitySet -> EntityType resolution - entityTypeMap := make(map[string]*model.PublishedEntityType) // ID -> EntityType - - // Parse entity types - entityTypes := extractBsonArray(raw["EntityTypes"]) - for _, et := range entityTypes { - if etMap, ok := et.(map[string]any); ok { - entityType := parsePublishedEntityType(etMap) - svc.EntityTypes = append(svc.EntityTypes, entityType) - entityTypeMap[string(entityType.ID)] = entityType - } - } - - // Parse entity sets - entitySets := extractBsonArray(raw["EntitySets"]) - for _, es := range entitySets { - if esMap, ok := es.(map[string]any); ok { - entitySet := parsePublishedEntitySet(esMap, entityTypeMap) - svc.EntitySets = append(svc.EntitySets, entitySet) - } - } - - // Parse published microflows (OData actions) - for _, pm := range extractBsonArray(raw["Microflows"]) { - if pmMap, ok := pm.(map[string]any); ok { - svc.Microflows = append(svc.Microflows, parsePublishedMicroflow(pmMap)) - } - } - - return svc, nil -} - -// parsePublishedMicroflow parses an ODataPublish$PublishedMicroflow — an OData -// action — from a BSON map. -func parsePublishedMicroflow(raw map[string]any) *model.PublishedMicroflow { - pm := &model.PublishedMicroflow{ - Microflow: extractString(raw["Microflow"]), - ExposedName: extractString(raw["ExposedName"]), - Summary: extractString(raw["Summary"]), - Description: extractString(raw["Description"]), - } - pm.ID = model.ID(extractID(raw["$ID"])) - pm.TypeName = "ODataPublish$PublishedMicroflow" - pm.ReturnTypeKind, pm.ReturnTypeRef = parseODataDataType(raw["ReturnType"]) - - for _, p := range extractBsonArray(raw["Parameters"]) { - pMap, ok := p.(map[string]any) - if !ok { - continue - } - mp := &model.PublishedMicroflowParameter{ - MicroflowParameter: extractString(pMap["MicroflowParameter"]), - ExposedName: extractString(pMap["ExposedName"]), - CanBeEmpty: extractBool(pMap["CanBeEmpty"], false), - Summary: extractString(pMap["Summary"]), - Description: extractString(pMap["Description"]), - } - mp.ID = model.ID(extractID(pMap["$ID"])) - mp.TypeName = "ODataPublish$PublishedMicroflowParameter" - mp.DataTypeKind, mp.DataTypeRef = parseODataDataType(pMap["DataType"]) - pm.Parameters = append(pm.Parameters, mp) - } - return pm -} - -// parseODataDataType reads a DataTypes$* element back into the kind + ref pair -// the semantic model carries. Object/List name an Entity, Enumeration names an -// Enumeration; everything else is a bare primitive whose kind is the $Type with -// the "DataTypes$" prefix and "Type" suffix removed. -func parseODataDataType(v any) (kind, ref string) { - m, ok := v.(map[string]any) - if !ok { - return "", "" - } - t := extractString(m["$Type"]) - t = strings.TrimPrefix(t, "DataTypes$") - t = strings.TrimSuffix(t, "Type") - switch t { - case "": - return "", "" - case "Object", "List": - return t, extractString(m["Entity"]) - case "Enumeration": - return t, extractString(m["Enumeration"]) - } - return t, "" -} - -// parsePublishedEntityType parses a published entity type from a BSON map. -func parsePublishedEntityType(raw map[string]any) *model.PublishedEntityType { - et := &model.PublishedEntityType{} - et.ID = model.ID(extractBsonID(raw["$ID"])) - et.TypeName = extractString(raw["$Type"]) - et.Entity = extractString(raw["Entity"]) - et.ExposedName = extractString(raw["ExposedName"]) - et.Summary = extractString(raw["Summary"]) - et.Description = extractString(raw["Description"]) - - // Parse members (attributes, associations, ids) - members := extractBsonArray(raw["ChildMembers"]) - for _, m := range members { - if mMap, ok := m.(map[string]any); ok { - member := parsePublishedMember(mMap) - et.Members = append(et.Members, member) - } - } - - return et -} - -// parsePublishedEntitySet parses a published entity set from a BSON map. -func parsePublishedEntitySet(raw map[string]any, entityTypeMap map[string]*model.PublishedEntityType) *model.PublishedEntitySet { - es := &model.PublishedEntitySet{} - es.ID = model.ID(extractBsonID(raw["$ID"])) - es.TypeName = extractString(raw["$Type"]) - es.ExposedName = extractString(raw["ExposedName"]) - es.UsePaging = extractBool(raw["UsePaging"], false) - es.PageSize = extractInt(raw["PageSize"]) - - // Resolve EntityType pointer (BY_ID reference) - entityTypeID := extractBsonID(raw["EntityTypePointer"]) - if entityTypeID != "" { - if et, ok := entityTypeMap[entityTypeID]; ok { - es.EntityTypeName = et.Entity - } - } - - // Parse mode objects - es.ReadMode = parseChangeMode(raw["ReadMode"]) - es.InsertMode = parseChangeMode(raw["InsertMode"]) - es.UpdateMode = parseChangeMode(raw["UpdateMode"]) - es.DeleteMode = parseChangeMode(raw["DeleteMode"]) - - return es -} - -// parsePublishedMember parses a published member from a BSON map. -func parsePublishedMember(raw map[string]any) *model.PublishedMember { - m := &model.PublishedMember{} - m.ID = model.ID(extractBsonID(raw["$ID"])) - m.TypeName = extractString(raw["$Type"]) - m.ExposedName = extractString(raw["ExposedName"]) - m.Filterable = extractBool(raw["Filterable"], false) - m.Sortable = extractBool(raw["Sortable"], false) - m.IsPartOfKey = extractBool(raw["IsPartOfKey"], false) - - // Determine kind from $Type - switch m.TypeName { - case "ODataPublish$PublishedAttribute": - m.Kind = "attribute" - m.Name = extractString(raw["Attribute"]) - m.EdmType = extractString(raw["EdmType"]) - case "ODataPublish$PublishedAssociationEnd": - m.Kind = "association" - m.Name = extractString(raw["Association"]) - // Studio Pro stores the target entity and the bare association - // name (separate from ExposedName, which is the navigation - // property). They round-trip through these fields so that - // ALTER ODATA SERVICE doesn't blank them out. - m.AssociationTargetEntity = extractString(raw["Entity"]) - m.ExposedAssociationName = extractString(raw["ExposedAssociationName"]) - m.IsMany = extractBool(raw["IsMany"], false) - case "ODataPublish$PublishedId": - m.Kind = "id" - m.Name = extractString(raw["Attribute"]) - default: - m.Kind = "unknown" - } - - return m -} - -// parseChangeMode extracts the mode string from a change/read source BSON object. -func parseChangeMode(v any) string { - if v == nil { - return "" - } - modeMap, ok := v.(map[string]any) - if !ok { - return "" - } - - typeName := extractString(modeMap["$Type"]) - switch typeName { - case "ODataPublish$ReadSource": - return "ReadFromDatabase" - case "ODataPublish$CallMicroflowToRead": - mfName := extractString(modeMap["Microflow"]) - if mfName != "" { - return "CallMicroflow:" + mfName - } - return "CallMicroflow" - case "ODataPublish$ChangeSource": - return "ChangeFromDatabase" - case "ODataPublish$ChangeNotSupported": - return "NotSupported" - case "ODataPublish$CallMicroflowToChange": - mfName := extractString(modeMap["Microflow"]) - if mfName != "" { - return "CallMicroflow:" + mfName - } - return "CallMicroflow" - default: - return typeName - } -} diff --git a/sdk/mpr/parser_page.go b/sdk/mpr/parser_page.go deleted file mode 100644 index 7df870d2a3..0000000000 --- a/sdk/mpr/parser_page.go +++ /dev/null @@ -1,237 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parsePage(unitID, containerID string, contents []byte) (*pages.Page, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - page := &pages.Page{} - page.ID = model.ID(unitID) - page.TypeName = "Pages$Page" - page.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - page.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - page.Documentation = doc - } - // URL field is stored as "Url" (not "URL") - if url, ok := raw["Url"].(string); ok { - page.URL = url - } else if url, ok := raw["URL"].(string); ok { - // Fallback for legacy format - page.URL = url - } - if layoutID, ok := raw["Layout"].(string); ok { - page.LayoutID = model.ID(layoutID) - } - if markAsUsed, ok := raw["MarkAsUsed"].(bool); ok { - page.MarkAsUsed = markAsUsed - } - if excluded, ok := raw["Excluded"].(bool); ok { - page.Excluded = excluded - } - - // Parse allowed module roles (BY_NAME references) - allowedRoles := extractBsonArray(raw["AllowedModuleRoles"]) - for _, r := range allowedRoles { - if name, ok := r.(string); ok { - page.AllowedRoles = append(page.AllowedRoles, model.ID(name)) - } - } - - // Parse title - if title, ok := raw["Title"].(map[string]any); ok { - page.Title = parseText(title) - } - - // Parse parameters - // Format: [3] for empty, [3, {param1}, {param2}...] for non-empty - // Each parameter is a bson.D document directly in the array - if params, ok := raw["Parameters"].(bson.A); ok { - // Skip version marker (first element), iterate through rest - for i := 1; i < len(params); i++ { - // Primary format: direct bson.D document - if paramDoc, ok := params[i].(bson.D); ok { - paramMapInterface := make(map[string]any) - for _, elem := range paramDoc { - paramMapInterface[elem.Key] = elem.Value - } - param := parsePageParameter(paramMapInterface) - page.Parameters = append(page.Parameters, param) - } else if paramMap, ok := params[i].(map[string]any); ok { - // Alternative: direct map - param := parsePageParameter(paramMap) - page.Parameters = append(page.Parameters, param) - } - } - } - - return page, nil -} - -func parseText(raw map[string]any) *model.Text { - text := &model.Text{} - - text.ID = model.ID(extractBsonID(raw["$ID"])) - - text.Translations = make(map[string]string) - - // Handle Microflows$StringTemplate format (direct "Text" field) - if textVal, ok := raw["Text"].(string); ok { - text.Translations["en_US"] = textVal - return text - } - - // Try "Translations" format - could be a map or an array - if translations, ok := raw["Translations"].(map[string]any); ok { - for lang, val := range translations { - if str, ok := val.(string); ok { - text.Translations[lang] = str - } - } - } - - // Also try "Translations" as an array of Translation objects (BSON format: [2, {$Type: "Texts$Translation", ...}]) - if transArray := extractBsonArray(raw["Translations"]); len(transArray) > 0 { - for _, item := range transArray { - if transMap, ok := item.(map[string]any); ok { - langCode := extractString(transMap["LanguageCode"]) - textVal := extractString(transMap["Text"]) - if langCode != "" { - text.Translations[langCode] = textVal - } - } - } - } - - // Also try "Items" format (array of Translation objects) - if items := extractBsonArray(raw["Items"]); len(items) > 0 { - for _, item := range items { - if transMap, ok := item.(map[string]any); ok { - langCode := extractString(transMap["LanguageCode"]) - textVal := extractString(transMap["Text"]) - if langCode != "" { - text.Translations[langCode] = textVal - } - } - } - } - - return text -} - -func parsePageParameter(raw map[string]any) *pages.PageParameter { - param := &pages.PageParameter{} - - if id, ok := raw["$ID"].(string); ok { - param.ID = model.ID(id) - } - if name, ok := raw["Name"].(string); ok { - param.Name = name - } - if defaultValue, ok := raw["DefaultValue"].(string); ok { - param.DefaultValue = defaultValue - } - if isRequired, ok := raw["IsRequired"].(bool); ok { - param.IsRequired = isRequired - } - - // Entity can be in two places: - // 1. Old format: directly as "Entity" field (string ID) - // 2. New format: nested in ParameterType[0].Entity (qualified name) - if entityID, ok := raw["Entity"].(string); ok { - param.EntityID = model.ID(entityID) - } - - // Parse ParameterType to get entity name and/or primitive type - // ParameterType can be a map/bson.D (single object) or array (with version marker) - parseParamTypeDoc := func(doc bson.D) { - for _, elem := range doc { - switch elem.Key { - case "$Type": - if typeName, ok := elem.Value.(string); ok && typeName != "DataTypes$ObjectType" { - param.TypeName = typeName - } - case "Entity": - if entity, ok := elem.Value.(string); ok { - param.EntityName = entity - } - } - } - } - parseParamTypeMap := func(m map[string]any) { - if typeName, ok := m["$Type"].(string); ok && typeName != "DataTypes$ObjectType" { - param.TypeName = typeName - } - if entity, ok := m["Entity"].(string); ok { - param.EntityName = entity - } - } - - if paramType, ok := raw["ParameterType"].(bson.D); ok { - parseParamTypeDoc(paramType) - } else if paramType, ok := raw["ParameterType"].(map[string]any); ok { - parseParamTypeMap(paramType) - } else if paramTypeArr, ok := raw["ParameterType"].(bson.A); ok { - for _, item := range paramTypeArr { - if typeDoc, ok := item.(bson.D); ok { - parseParamTypeDoc(typeDoc) - } else if typeMap, ok := item.(map[string]any); ok { - parseParamTypeMap(typeMap) - } - } - } - - return param -} - -// parseLayout parses layout contents from BSON. -func (r *Reader) parseLayout(unitID, containerID string, contents []byte) (*pages.Layout, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - layout := &pages.Layout{} - layout.ID = model.ID(unitID) - layout.TypeName = "Pages$Layout" - layout.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - layout.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - layout.Documentation = doc - } - if layoutType, ok := raw["LayoutType"].(string); ok { - layout.LayoutType = pages.LayoutType(layoutType) - } - - return layout, nil -} - -// parseEnumeration parses enumeration contents from BSON. diff --git a/sdk/mpr/parser_queued_call_test.go b/sdk/mpr/parser_queued_call_test.go deleted file mode 100644 index adbdcf8287..0000000000 --- a/sdk/mpr/parser_queued_call_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -// TestParseQueueSettings covers the legacy engine's half of the queued-call -// round trip (FINDINGS #25). -// -// The legacy writer stored the binding correctly, but the legacy PARSER never -// read it back — so on `--engine legacy` a queued call described as an ordinary -// one, and a describe → exec cycle dropped the binding. The two engines -// disagreed about the same stored document, which is the shape of bug that -// survives longest: each looks self-consistent. -func TestParseQueueSettings(t *testing.T) { - call := map[string]any{ - "$Type": "Microflows$MicroflowCall", - "Microflow": "Q.Target", - "QueueSettings": map[string]any{ - "$Type": "Queues$QueueSettings", - "Queue": "Q.MyQueue", - "Retry": nil, - }, - } - - qs := parseQueueSettings(call) - if qs == nil { - t.Fatal("QueueSettings not read back — a describe→exec round trip drops the binding") - } - if qs.Queue != "Q.MyQueue" { - t.Errorf("Queue = %q, want Q.MyQueue", qs.Queue) - } - if qs.Retry != nil { - t.Errorf("Retry = %v, want nil for an explicit BSON null", qs.Retry) - } - - // An unqueued call must stay unqueued — the common case by far. - if got := parseQueueSettings(map[string]any{"QueueSettings": nil}); got != nil { - t.Errorf("unqueued call produced %+v, want nil", got) - } - if got := parseQueueSettings(map[string]any{}); got != nil { - t.Errorf("absent QueueSettings produced %+v, want nil", got) - } - - // A stored retry must survive the read, because checkNoQueuedCalls refuses - // the rewrite on its presence — losing it here would re-enable the reset. - withRetry := parseQueueSettings(map[string]any{"QueueSettings": map[string]any{ - "Queue": "Q.MyQueue", - "Retry": map[string]any{"$Type": "Queues$QueueFixedRetry"}, - }}) - if withRetry == nil || withRetry.Retry == nil { - t.Fatal("a stored Retry must be carried, or the guard that refuses resetting it goes blind") - } -} diff --git a/sdk/mpr/parser_range_test.go b/sdk/mpr/parser_range_test.go deleted file mode 100644 index 134997f898..0000000000 --- a/sdk/mpr/parser_range_test.go +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -// TestParseRange_StudioProShapes pins how Studio Pro actually stores a database -// retrieve's Range, measured rather than assumed. -// -// Source: ako/TestApp, MyFirstModule.RetrieveExamples on Mendix 11.13.0 — one -// retrieve per UI option, dumped with `mxcli bson dump --type microflow`: -// -// All Microflows$ConstantRange {SingleObject:false} -// First Microflows$ConstantRange {SingleObject:true} -// Custom (limit 4, off 2) Microflows$CustomRange {LimitExpression:"4", OffsetExpression:"2"} -// -// This matters beyond the parser. Range is a POLYMORPHIC child, the shape that -// has produced repeated data loss when a reader pulls one scalar out of it -// without dispatching on $Type (DomainModels$RuleInfo, and the import-mapping -// Range in #881). Pinning the real variants is what makes the dispatch here -// checkable instead of folkloric. -func TestParseRange_StudioProShapes(t *testing.T) { - tests := []struct { - name string - raw map[string]any - wantType microflows.RangeType - wantLimit string - wantOffset string - }{ - { - name: "All", - raw: map[string]any{"$Type": "Microflows$ConstantRange", "SingleObject": false}, - wantType: microflows.RangeTypeAll, - }, - { - name: "First", - raw: map[string]any{"$Type": "Microflows$ConstantRange", "SingleObject": true}, - wantType: microflows.RangeTypeFirst, - }, - { - name: "Custom", - raw: map[string]any{ - "$Type": "Microflows$CustomRange", - "LimitExpression": "4", - "OffsetExpression": "2", - }, - wantType: microflows.RangeTypeCustom, - wantLimit: "4", - wantOffset: "2", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := parseRange(tt.raw) - if got == nil { - t.Fatal("parseRange returned nil") - } - if got.RangeType != tt.wantType { - t.Errorf("RangeType = %v, want %v", got.RangeType, tt.wantType) - } - if got.Limit != tt.wantLimit { - t.Errorf("Limit = %q, want %q", got.Limit, tt.wantLimit) - } - if got.Offset != tt.wantOffset { - t.Errorf("Offset = %q, want %q", got.Offset, tt.wantOffset) - } - }) - } -} - -// TestParseRange_ConstantRangeWithLimitIsUnobserved documents the tolerance -// branch rather than endorsing it. -// -// No Studio Pro document has been seen storing Limit/Offset on a ConstantRange; -// the branch exists only for formats we have not sampled. modelsdk cannot read -// this shape at all (gen binds only SingleObject on ConstantRange), so if it -// ever turns up in a real project the engines diverge and the fix belongs in -// gen, not here. This test exists so that discovery lands on a named case. -func TestParseRange_ConstantRangeWithLimitIsUnobserved(t *testing.T) { - got := parseRange(map[string]any{ - "$Type": "Microflows$ConstantRange", - "SingleObject": false, - "LimitExpression": "10", - }) - if got.RangeType != microflows.RangeTypeCustom || got.Limit != "10" { - t.Errorf("legacy tolerance changed: got %v/%q — if this is now intended, "+ - "check whether modelsdk's rangeFromGen was taught to read it too", - got.RangeType, got.Limit) - } -} diff --git a/sdk/mpr/parser_rest.go b/sdk/mpr/parser_rest.go deleted file mode 100644 index 5aceea6e60..0000000000 --- a/sdk/mpr/parser_rest.go +++ /dev/null @@ -1,407 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parsePublishedRestService parses a published REST service from BSON. -func (r *Reader) parsePublishedRestService(unitID, containerID string, contents []byte) (*model.PublishedRestService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - svc := &model.PublishedRestService{} - svc.ID = model.ID(unitID) - svc.TypeName = "Rest$PublishedRestService" - svc.ContainerID = model.ID(containerID) - - svc.Name = extractString(raw["Name"]) - svc.Path = extractString(raw["Path"]) - svc.Version = extractString(raw["Version"]) - svc.ServiceName = extractString(raw["ServiceName"]) - svc.Excluded = extractBool(raw["Excluded"], false) - - // Parse allowed roles (BY_NAME references) - allowedRoles := extractBsonArray(raw["AllowedRoles"]) - for _, r := range allowedRoles { - if name, ok := r.(string); ok { - svc.AllowedRoles = append(svc.AllowedRoles, name) - } - } - - // Parse resources - resources := extractBsonArray(raw["Resources"]) - for _, res := range resources { - if resMap, ok := res.(map[string]any); ok { - resource := &model.PublishedRestResource{} - resource.ID = model.ID(extractBsonID(resMap["$ID"])) - resource.TypeName = extractString(resMap["$Type"]) - resource.Name = extractString(resMap["Name"]) - - // Parse operations - ops := extractBsonArray(resMap["Operations"]) - for _, op := range ops { - if opMap, ok := op.(map[string]any); ok { - operation := &model.PublishedRestOperation{} - operation.ID = model.ID(extractBsonID(opMap["$ID"])) - operation.TypeName = extractString(opMap["$Type"]) - operation.Path = extractString(opMap["Path"]) - operation.HTTPMethod = extractString(opMap["HttpMethod"]) - operation.Summary = extractString(opMap["Summary"]) - operation.Microflow = extractString(opMap["Microflow"]) - operation.Deprecated = extractBool(opMap["Deprecated"], false) - resource.Operations = append(resource.Operations, operation) - } - } - - svc.Resources = append(svc.Resources, resource) - } - } - - return svc, nil -} - -// parseConsumedRestService parses a consumed REST service from BSON. -func (r *Reader) parseConsumedRestService(unitID, containerID string, contents []byte) (*model.ConsumedRestService, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - svc := &model.ConsumedRestService{} - svc.ID = model.ID(unitID) - svc.TypeName = "Rest$ConsumedRestService" - svc.ContainerID = model.ID(containerID) - - svc.Name = extractString(raw["Name"]) - svc.Documentation = extractString(raw["Documentation"]) - svc.Excluded = extractBool(raw["Excluded"], false) - - // Parse BaseUrl from Rest$ValueTemplate - if baseUrlMap := extractBsonMap(raw["BaseUrl"]); baseUrlMap != nil { - svc.BaseUrl = extractString(baseUrlMap["Value"]) - } - - // Parse AuthenticationScheme (polymorphic: null or Rest$BasicAuthenticationScheme) - if authMap := extractBsonMap(raw["AuthenticationScheme"]); authMap != nil { - authType := extractString(authMap["$Type"]) - if authType == "Rest$BasicAuthenticationScheme" { - auth := &model.RestAuthentication{Scheme: "Basic"} - auth.Username = extractRestValue(authMap["Username"]) - auth.Password = extractRestValue(authMap["Password"]) - svc.Authentication = auth - } - } - - // Parse OpenApiFile (present when created from spec; stores the raw spec text). - // Field names are PascalCase matching Studio Pro serialization. - if openApiFile, ok := raw["OpenApiFile"].(map[string]any); ok && openApiFile != nil { - svc.OpenApiContent = extractString(openApiFile["Content"]) - } - - // Parse Operations - ops := extractBsonArray(raw["Operations"]) - for _, op := range ops { - opMap, ok := op.(map[string]any) - if !ok { - continue - } - operation := parseRestOperation(opMap) - svc.Operations = append(svc.Operations, operation) - } - - return svc, nil -} - -// parseRestOperation parses a single Rest$RestOperation from BSON. -func parseRestOperation(opMap map[string]any) *model.RestClientOperation { - op := &model.RestClientOperation{} - op.Name = extractString(opMap["Name"]) - op.Timeout = extractInt(opMap["Timeout"]) - - // Parse Tags (versioned string array: [versionInt, tag1, tag2, ...]) - for _, t := range extractBsonArray(opMap["Tags"]) { - if s, ok := t.(string); ok { - op.Tags = append(op.Tags, s) - } - } - - // Parse Method (polymorphic: WithBody or WithoutBody) - if methodMap := extractBsonMap(opMap["Method"]); methodMap != nil { - methodType := extractString(methodMap["$Type"]) - httpMethod := extractString(methodMap["HttpMethod"]) - op.HttpMethod = httpMethodToUpper(httpMethod) - - if methodType == "Rest$RestOperationMethodWithBody" { - parseRestBody(methodMap["Body"], op) - } - } - - // Parse Path from Rest$ValueTemplate - if pathMap := extractBsonMap(opMap["Path"]); pathMap != nil { - op.Path = extractString(pathMap["Value"]) - } - - // Parse Headers - headers := extractBsonArray(opMap["Headers"]) - for _, h := range headers { - if hMap, ok := h.(map[string]any); ok { - header := &model.RestClientHeader{ - Name: extractString(hMap["Name"]), - } - if valMap := extractBsonMap(hMap["Value"]); valMap != nil { - header.Value = extractString(valMap["Value"]) - } - op.Headers = append(op.Headers, header) - } - } - - // Parse Parameters (path parameters) - params := extractBsonArray(opMap["Parameters"]) - for _, p := range params { - if pMap, ok := p.(map[string]any); ok { - param := &model.RestClientParameter{ - Name: extractString(pMap["Name"]), - DataType: extractRestDataType(pMap["DataType"]), - } - op.Parameters = append(op.Parameters, param) - } - } - - // Parse QueryParameters - queryParams := extractBsonArray(opMap["QueryParameters"]) - for _, q := range queryParams { - if qMap, ok := q.(map[string]any); ok { - param := &model.RestClientParameter{ - Name: extractString(qMap["Name"]), - DataType: extractRestDataType(qMap["DataType"]), - } - op.QueryParameters = append(op.QueryParameters, param) - } - } - - // Parse ResponseHandling (polymorphic) - if respMap := extractBsonMap(opMap["ResponseHandling"]); respMap != nil { - respType := extractString(respMap["$Type"]) - switch respType { - case "Rest$NoResponseHandling": - // Detect response type from ContentType for roundtrip support - contentType := extractString(respMap["ContentType"]) - switch contentType { - case "application/json": - op.ResponseType = "JSON" - case "text/plain": - op.ResponseType = "STRING" - case "application/octet-stream": - op.ResponseType = "FILE" - default: - op.ResponseType = "NONE" - } - case "Rest$ImplicitMappingResponseHandling": - op.ResponseType = "MAPPING" - if rootMap := extractBsonMap(respMap["RootMappingElement"]); rootMap != nil { - op.ResponseEntity = extractString(rootMap["Entity"]) - op.ResponseMappings = parseMappingChildren(rootMap) - } - } - } - - return op -} - -// parseRestBody extracts body information from the Method's Body field. -func parseRestBody(bodyVal any, op *model.RestClientOperation) { - bodyMap := extractBsonMap(bodyVal) - if bodyMap == nil { - return - } - bodyType := extractString(bodyMap["$Type"]) - switch bodyType { - case "Rest$ImplicitMappingBody": - op.BodyType = "EXPORT_MAPPING" - if rootMap := extractBsonMap(bodyMap["RootMappingElement"]); rootMap != nil { - op.BodyVariable = extractString(rootMap["Entity"]) - op.BodyMappings = parseExportMappingChildren(rootMap) - } - case "Rest$JsonBody": - op.BodyType = "JSON" - op.BodyVariable = extractString(bodyMap["Value"]) - case "Rest$StringBody": - op.BodyType = "TEMPLATE" // String body with a value template (may contain {param} placeholders) - if vt := extractBsonMap(bodyMap["ValueTemplate"]); vt != nil { - op.BodyVariable = extractString(vt["Value"]) - } - } -} - -// parseMappingChildren recursively parses Children from an ImportMappings$ObjectMappingElement. -// Returns a flat/nested list of RestResponseMapping entries covering both value and object elements. -func parseMappingChildren(parentMap map[string]any) []*model.RestResponseMapping { - parentEntity := extractString(parentMap["Entity"]) - entityPrefix := parentEntity + "." - children := extractBsonArray(parentMap["Children"]) - - var mappings []*model.RestResponseMapping - for _, child := range children { - childMap, ok := child.(map[string]any) - if !ok { - continue - } - childType := extractString(childMap["$Type"]) - switch childType { - case "ImportMappings$ValueMappingElement": - attr := extractString(childMap["Attribute"]) - exposed := extractString(childMap["ExposedName"]) - if attr == "" || exposed == "" { - continue - } - mappings = append(mappings, &model.RestResponseMapping{ - Attribute: strings.TrimPrefix(attr, entityPrefix), - ExposedName: exposed, - JsonPath: extractString(childMap["JsonPath"]), - }) - case "ImportMappings$ObjectMappingElement": - entity := extractString(childMap["Entity"]) - assoc := extractString(childMap["Association"]) - exposed := extractString(childMap["ExposedName"]) - mappings = append(mappings, &model.RestResponseMapping{ - Entity: entity, - Association: assoc, - ExposedName: exposed, - JsonPath: extractString(childMap["JsonPath"]), - Children: parseMappingChildren(childMap), - }) - } - } - return mappings -} - -// parseExportMappingChildren recursively parses Children from an ExportMappings$ObjectMappingElement. -// Same structure as parseMappingChildren but for ExportMappings$ types. -func parseExportMappingChildren(parentMap map[string]any) []*model.RestResponseMapping { - parentEntity := extractString(parentMap["Entity"]) - entityPrefix := parentEntity + "." - children := extractBsonArray(parentMap["Children"]) - - var mappings []*model.RestResponseMapping - for _, child := range children { - childMap, ok := child.(map[string]any) - if !ok { - continue - } - childType := extractString(childMap["$Type"]) - switch childType { - case "ExportMappings$ValueMappingElement": - attr := extractString(childMap["Attribute"]) - exposed := extractString(childMap["ExposedName"]) - if attr == "" || exposed == "" { - continue - } - mappings = append(mappings, &model.RestResponseMapping{ - Attribute: strings.TrimPrefix(attr, entityPrefix), - ExposedName: exposed, - JsonPath: extractString(childMap["JsonPath"]), - }) - case "ExportMappings$ObjectMappingElement": - entity := extractString(childMap["Entity"]) - assoc := extractString(childMap["Association"]) - exposed := extractString(childMap["ExposedName"]) - mappings = append(mappings, &model.RestResponseMapping{ - Entity: entity, - Association: assoc, - ExposedName: exposed, - JsonPath: extractString(childMap["JsonPath"]), - Children: parseExportMappingChildren(childMap), - }) - } - } - return mappings -} - -// extractRestValue extracts a value from a polymorphic Rest$Value (StringValue or ConstantValue). -func extractRestValue(v any) string { - valMap := extractBsonMap(v) - if valMap == nil { - return "" - } - valType := extractString(valMap["$Type"]) - switch valType { - case "Rest$StringValue": - return extractString(valMap["Value"]) - case "Rest$ConstantValue": - // The BSON field is "Value" (QualifiedName of the constant). - // Historical code wrote "Constant" — try both for backward compat. - if v := extractString(valMap["Value"]); v != "" { - return "$" + v - } - if v := extractString(valMap["Constant"]); v != "" { - return "$" + v - } - return "" - } - return "" -} - -// extractRestDataType extracts a data type name from a DataTypes$DataType BSON object. -// Handles both DataTypes$IntegerType (consumed REST) and DataTypes$IntegerAttributeType formats. -func extractRestDataType(v any) string { - dtMap := extractBsonMap(v) - if dtMap == nil { - return "String" - } - dtType := extractString(dtMap["$Type"]) - switch dtType { - case "DataTypes$IntegerType", "DataTypes$IntegerAttributeType": - return "Integer" - case "DataTypes$LongType", "DataTypes$LongAttributeType": - return "Long" - case "DataTypes$DecimalType", "DataTypes$DecimalAttributeType": - return "Decimal" - case "DataTypes$BooleanType", "DataTypes$BooleanAttributeType": - return "Boolean" - case "DataTypes$StringType", "DataTypes$StringAttributeType": - return "String" - default: - return "String" - } -} - -// httpMethodToUpper converts Mendix HTTP method names to uppercase. -func httpMethodToUpper(method string) string { - switch method { - case "Get": - return "GET" - case "Post": - return "POST" - case "Put": - return "PUT" - case "Patch": - return "PATCH" - case "Delete": - return "DELETE" - case "Head": - return "HEAD" - case "Options": - return "OPTIONS" - default: - return method - } -} diff --git a/sdk/mpr/parser_rule.go b/sdk/mpr/parser_rule.go deleted file mode 100644 index 3ba1f01697..0000000000 --- a/sdk/mpr/parser_rule.go +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseRule parses a Microflows$Rule document. A rule shares a microflow's -// object collection, flows, parameters and return type, so this mirrors -// parseNanoflow with two differences measured against Studio Pro-authored rules -// (ako/TestApp, Mendix 11.13.0): -// -// - a rule stores no AllowedModuleRoles — it is not independently callable, so -// it has no module-role security; -// - gen declares a ReturnType string beside MicroflowReturnType, but Studio Pro -// does not write it and generated/metamodel does not list it, so it is not -// read and must not be written. -func (r *Reader) parseRule(unitID, containerID string, contents []byte) (*microflows.Rule, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - rule := µflows.Rule{} - rule.ID = model.ID(unitID) - rule.TypeName = "Microflows$Rule" - rule.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - rule.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - rule.Documentation = doc - } - if excluded, ok := raw["Excluded"].(bool); ok { - rule.Excluded = excluded - } - if markAsUsed, ok := raw["MarkAsUsed"].(bool); ok { - rule.MarkAsUsed = markAsUsed - } - if applyEntityAccess, ok := raw["ApplyEntityAccess"].(bool); ok { - rule.ApplyEntityAccess = applyEntityAccess - } - if returnVariableName, ok := raw["ReturnVariableName"].(string); ok { - rule.ReturnVariableName = returnVariableName - } - - // Return type — Boolean or an enumeration, under the microflow's BSON key. - if rt, ok := raw["MicroflowReturnType"].(map[string]any); ok { - rule.ReturnType = parseMicroflowDataType(rt) - } - - if oc := extractBsonMap(raw["ObjectCollection"]); oc != nil { - rule.ObjectCollection = parseMicroflowObjectCollection(oc) - for _, obj := range extractBsonSlice(oc["Objects"]) { - if objMap := extractBsonMap(obj); objMap != nil { - if typeName, _ := objMap["$Type"].(string); typeName == "Microflows$MicroflowParameter" { - rule.Parameters = append(rule.Parameters, parseMicroflowParameter(objMap, len(rule.Parameters))) - } - } - } - } - - if flowsRaw := raw["Flows"]; flowsRaw != nil { - if rule.ObjectCollection == nil { - rule.ObjectCollection = µflows.MicroflowObjectCollection{} - } - for _, f := range extractBsonSlice(flowsRaw) { - flowMap := extractBsonMap(f) - if flowMap == nil { - continue - } - typeName, _ := flowMap["$Type"].(string) - switch typeName { - case "Microflows$AnnotationFlow": - if af := parseAnnotationFlow(flowMap); af != nil { - rule.ObjectCollection.AnnotationFlows = append(rule.ObjectCollection.AnnotationFlows, af) - } - default: - if flow := parseSequenceFlow(flowMap); flow != nil { - rule.ObjectCollection.Flows = append(rule.ObjectCollection.Flows, flow) - } - } - } - } - - return rule, nil -} diff --git a/sdk/mpr/parser_rule_test.go b/sdk/mpr/parser_rule_test.go deleted file mode 100644 index d05343975c..0000000000 --- a/sdk/mpr/parser_rule_test.go +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -// studioProRuleBSON reproduces Rules.Rule2 from the reference app -// (ako/TestApp, Mendix 11.13.0) — an enumeration-returning rule with an entity -// parameter, in the key order Studio Pro stores. -// -// The two deliberate omissions are the point of the fixture: a real rule -// document carries no AllowedModuleRoles and no ReturnType, so a parser that -// reaches for either is reading a key Mendix never wrote. -func studioProRuleBSON(t *testing.T) []byte { - t.Helper() - doc := bson.D{ - {Key: "$ID", Value: "rule-1"}, - {Key: "$Type", Value: "Microflows$Rule"}, - {Key: "ApplyEntityAccess", Value: false}, - {Key: "Documentation", Value: "decides the outcome"}, - {Key: "Excluded", Value: false}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Flows", Value: bson.A{int32(3)}}, - {Key: "MarkAsUsed", Value: false}, - {Key: "MicroflowReturnType", Value: bson.D{ - {Key: "$ID", Value: "rt-1"}, - {Key: "$Type", Value: "DataTypes$EnumerationType"}, - {Key: "Enumeration", Value: "Rules.RuleResult"}, - }}, - {Key: "Name", Value: "Rule2"}, - {Key: "ObjectCollection", Value: bson.D{ - {Key: "$ID", Value: "oc-1"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$ID", Value: "p-1"}, - {Key: "$Type", Value: "Microflows$MicroflowParameter"}, - {Key: "Name", Value: "pName"}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: "vt-1"}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: "Pages.Bus"}, - }}, - }, - bson.D{ - {Key: "$ID", Value: "end-1"}, - {Key: "$Type", Value: "Microflows$EndEvent"}, - {Key: "ReturnValue", Value: "Rules.RuleResult.Approved"}, - }, - }}, - }}, - {Key: "ReturnVariableName", Value: "Variable"}, - } - b, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("marshal rule fixture: %v", err) - } - return b -} - -// The legacy engine reads a rule with the same fidelity as the codec engine: -// name, documentation, the enumeration return type, the entity parameter, and -// ReturnVariableName (which Studio Pro writes and a rewrite must not drop). -func TestParseRule_StudioProDocument(t *testing.T) { - rule, err := testReader().parseRule("rule-1", "container-1", studioProRuleBSON(t)) - if err != nil { - t.Fatalf("parseRule: %v", err) - } - - if rule.Name != "Rule2" { - t.Errorf("Name = %q, want Rule2", rule.Name) - } - if rule.Documentation != "decides the outcome" { - t.Errorf("Documentation = %q", rule.Documentation) - } - if rule.ReturnVariableName != "Variable" { - t.Errorf("ReturnVariableName = %q, want %q", rule.ReturnVariableName, "Variable") - } - if rule.TypeName != "Microflows$Rule" { - t.Errorf("TypeName = %q, want Microflows$Rule", rule.TypeName) - } - - enum, ok := rule.ReturnType.(*microflows.EnumerationType) - if !ok { - t.Fatalf("ReturnType = %T, want *microflows.EnumerationType — a rule may return an enumeration, not only Boolean", rule.ReturnType) - } - if enum.EnumerationQualifiedName != "Rules.RuleResult" { - t.Errorf("enumeration = %q, want Rules.RuleResult", enum.EnumerationQualifiedName) - } - - if len(rule.Parameters) != 1 { - t.Fatalf("Parameters = %d, want 1", len(rule.Parameters)) - } - if rule.Parameters[0].Name != "pName" { - t.Errorf("parameter = %q, want pName", rule.Parameters[0].Name) - } - if rule.ObjectCollection == nil || len(rule.ObjectCollection.Objects) == 0 { - t.Error("ObjectCollection did not come back") - } -} diff --git a/sdk/mpr/parser_scheduledevent_test.go b/sdk/mpr/parser_scheduledevent_test.go deleted file mode 100644 index 6e9c6a529e..0000000000 --- a/sdk/mpr/parser_scheduledevent_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" -) - -// Issue #585: parseScheduledEvent asserted `raw["Interval"].(int32)`. Studio -// Pro writes Interval as BSON int64, so the assertion failed silently and -// every scheduled event read from a Studio Pro-written MPR appeared with -// Interval=0 — the same misreport pattern fixed in #583 for -// StringAttributeType.Length. -func TestParseScheduledEvent_Interval_BsonNumericWidths(t *testing.T) { - cases := []struct { - name string - interval any - want int - }{ - {"int32 (mxcli writer)", int32(15), 15}, - {"int64 (Studio Pro writer)", int64(15), 15}, - {"int", int(15), 15}, - {"float64 (extended JSON)", float64(15), 15}, - {"missing field", nil, 0}, - } - - r := &Reader{version: MPRVersionV1} - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - doc := bson.M{ - "$Type": "ScheduledEvents$ScheduledEvent", - "Name": "MyEvent", - "Enabled": true, - "IntervalType": "Hour", - } - if tc.interval != nil { - doc["Interval"] = tc.interval - } - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("bson.Marshal: %v", err) - } - event, err := r.parseScheduledEvent("unit-id", "container-id", data) - if err != nil { - t.Fatalf("parseScheduledEvent: %v", err) - } - if event.Interval != tc.want { - t.Errorf("Interval = %d, want %d (input %T(%v))", event.Interval, tc.want, tc.interval, tc.interval) - } - }) - } -} diff --git a/sdk/mpr/parser_security.go b/sdk/mpr/parser_security.go deleted file mode 100644 index 8a49270307..0000000000 --- a/sdk/mpr/parser_security.go +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/security" - "go.mongodb.org/mongo-driver/bson" -) - -// parseProjectSecurity parses a Security$ProjectSecurity BSON document. -func (r *Reader) parseProjectSecurity(unitID, containerID string, contents []byte) (*security.ProjectSecurity, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ps := &security.ProjectSecurity{} - ps.ID = model.ID(unitID) - ps.TypeName = "Security$ProjectSecurity" - - ps.SecurityLevel = extractString(raw["SecurityLevel"]) - ps.AdminUserName = extractString(raw["AdminUserName"]) - ps.AdminPassword = extractString(raw["AdminPassword"]) - ps.AdminUserRole = extractString(raw["AdminUserRole"]) - ps.CheckSecurity = extractBool(raw["CheckSecurity"], false) - ps.StrictMode = extractBool(raw["StrictMode"], false) - ps.StrictPageUrlCheck = extractBool(raw["StrictPageUrlCheck"], false) - ps.EnableDemoUsers = extractBool(raw["EnableDemoUsers"], false) - ps.EnableGuestAccess = extractBool(raw["EnableGuestAccess"], false) - ps.GuestUserRole = extractString(raw["GuestUserRole"]) - - // Parse user roles - userRoles := extractBsonArray(raw["UserRoles"]) - for _, ur := range userRoles { - urMap := toMap(ur) - if urMap == nil { - continue - } - role := parseUserRole(urMap) - ps.UserRoles = append(ps.UserRoles, role) - } - - // Parse demo users - demoUsers := extractBsonArray(raw["DemoUsers"]) - for _, du := range demoUsers { - duMap := toMap(du) - if duMap == nil { - continue - } - user := parseDemoUser(duMap) - ps.DemoUsers = append(ps.DemoUsers, user) - } - - // Parse password policy - if ppRaw, ok := raw["PasswordPolicySettings"]; ok { - ppMap := toMap(ppRaw) - if ppMap != nil { - ps.PasswordPolicy = parsePasswordPolicy(ppMap) - } - } - - return ps, nil -} - -// parseUserRole parses a Security$UserRole from a BSON map. -func parseUserRole(raw map[string]any) *security.UserRole { - role := &security.UserRole{} - role.ID = model.ID(extractBsonID(raw["$ID"])) - role.TypeName = "Security$UserRole" - role.Name = extractString(raw["Name"]) - role.Description = extractString(raw["Description"]) - role.ManageAllRoles = extractBool(raw["ManageAllRoles"], false) - role.ManageUsersWithoutRoles = extractBool(raw["ManageUsersWithoutRoles"], false) - role.CheckSecurity = extractBool(raw["CheckSecurity"], false) - - // Module roles are BY_NAME references (qualified name strings) - moduleRoles := extractBsonArray(raw["ModuleRoles"]) - for _, mr := range moduleRoles { - if name, ok := mr.(string); ok { - role.ModuleRoles = append(role.ModuleRoles, name) - } - } - - // Manageable roles are BY_NAME references - manageableRoles := extractBsonArray(raw["ManageableRoles"]) - for _, mr := range manageableRoles { - if name, ok := mr.(string); ok { - role.ManageableRoles = append(role.ManageableRoles, name) - } - } - - return role -} - -// parseDemoUser parses a Security$DemoUserImpl from a BSON map. -func parseDemoUser(raw map[string]any) *security.DemoUser { - user := &security.DemoUser{} - user.ID = model.ID(extractBsonID(raw["$ID"])) - user.TypeName = "Security$DemoUserImpl" - user.UserName = extractString(raw["UserName"]) - user.Password = extractString(raw["Password"]) - user.Entity = extractString(raw["Entity"]) - - // User roles are BY_NAME references - userRoles := extractBsonArray(raw["UserRoles"]) - for _, ur := range userRoles { - if name, ok := ur.(string); ok { - user.UserRoles = append(user.UserRoles, name) - } - } - - return user -} - -// parsePasswordPolicy parses Security$PasswordPolicySettings from a BSON map. -func parsePasswordPolicy(raw map[string]any) *security.PasswordPolicy { - pp := &security.PasswordPolicy{} - pp.ID = model.ID(extractBsonID(raw["$ID"])) - pp.TypeName = "Security$PasswordPolicySettings" - pp.MinimumLength = extractInt(raw["MinimumLength"]) - pp.RequireDigit = extractBool(raw["RequireDigit"], false) - pp.RequireMixedCase = extractBool(raw["RequireMixedCase"], false) - pp.RequireSymbol = extractBool(raw["RequireSymbol"], false) - return pp -} - -// parseModuleSecurity parses a Security$ModuleSecurity BSON document. -func (r *Reader) parseModuleSecurity(unitID, containerID string, contents []byte) (*security.ModuleSecurity, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ms := &security.ModuleSecurity{} - ms.ID = model.ID(unitID) - ms.TypeName = "Security$ModuleSecurity" - ms.ContainerID = model.ID(containerID) - - // Parse module roles - roles := extractBsonArray(raw["ModuleRoles"]) - for _, r := range roles { - rMap := toMap(r) - if rMap == nil { - continue - } - role := &security.ModuleRole{} - role.ID = model.ID(extractBsonID(rMap["$ID"])) - role.TypeName = "Security$ModuleRole" - role.Name = extractString(rMap["Name"]) - role.Description = extractString(rMap["Description"]) - ms.ModuleRoles = append(ms.ModuleRoles, role) - } - - return ms, nil -} - -// toMap is defined in parser_javaactions.go diff --git a/sdk/mpr/parser_settings.go b/sdk/mpr/parser_settings.go deleted file mode 100644 index 3b02512f4a..0000000000 --- a/sdk/mpr/parser_settings.go +++ /dev/null @@ -1,225 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/settingsoverlay" - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// parseProjectSettings parses a Settings$ProjectSettings BSON document. -func (r *Reader) parseProjectSettings(unitID, containerID string, contents []byte) (*model.ProjectSettings, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - ps := &model.ProjectSettings{} - ps.ID = model.ID(unitID) - ps.TypeName = "Settings$ProjectSettings" - - // Parse Settings array (versioned: starts with int32(2)) - settingsArr := extractBsonArray(raw["Settings"]) - for _, s := range settingsArr { - partMap := extractBsonMap(s) - if partMap == nil { - continue - } - // Preserve raw part for round-trip serialization - ps.RawParts = append(ps.RawParts, partMap) - - typeName := extractString(partMap["$Type"]) - switch typeName { - case "Forms$WebUIProjectSettingsPart": - ps.WebUI = parseWebUISettings(partMap) - case "Settings$IntegrationProjectSettingsPart": - ps.Integration = &model.IntegrationSettings{} - ps.Integration.ID = model.ID(extractBsonID(partMap["$ID"])) - ps.Integration.TypeName = typeName - case "Settings$ConfigurationSettings": - ps.Configuration = parseConfigurationSettings(partMap) - case "Settings$ModelSettings": - ps.Model = parseModelSettings(partMap) - case "Settings$ConventionSettings": - ps.Convention = parseConventionSettings(partMap) - case "Settings$LanguageSettings": - ps.Language = parseLanguageSettings(partMap) - case "Settings$CertificateSettings": - ps.Certificate = &model.CertificateSettings{} - ps.Certificate.ID = model.ID(extractBsonID(partMap["$ID"])) - ps.Certificate.TypeName = typeName - case "Settings$WorkflowsProjectSettingsPart": - ps.Workflows = parseWorkflowsSettings(partMap) - case "Settings$JarDeploymentSettings": - ps.JarDeployment = &model.JarDeploymentSettings{} - ps.JarDeployment.ID = model.ID(extractBsonID(partMap["$ID"])) - ps.JarDeployment.TypeName = typeName - case "Settings$DistributionSettings": - ps.Distribution = parseDistributionSettings(partMap) - } - } - - return ps, nil -} - -func parseWebUISettings(raw map[string]any) *model.WebUISettings { - s := &model.WebUISettings{} - s.ID = model.ID(extractBsonID(raw["$ID"])) - s.TypeName = extractString(raw["$Type"]) - s.EnableMicroflowReachabilityAnalysis = extractBool(raw["EnableMicroflowReachabilityAnalysis"], false) - s.UseOptimizedClient = extractString(raw["UseOptimizedClient"]) - s.UrlPrefix = extractString(raw["UrlPrefix"]) - return s -} - -func parseConfigurationSettings(raw map[string]any) *model.ConfigurationSettings { - cs := &model.ConfigurationSettings{} - cs.ID = model.ID(extractBsonID(raw["$ID"])) - cs.TypeName = extractString(raw["$Type"]) - - configs := extractBsonArray(raw["Configurations"]) - for _, c := range configs { - if cMap := extractBsonMap(c); cMap != nil { - cs.Configurations = append(cs.Configurations, parseServerConfiguration(cMap)) - } - } - - return cs -} - -func parseServerConfiguration(raw map[string]any) *model.ServerConfiguration { - sc := &model.ServerConfiguration{} - sc.ID = model.ID(extractBsonID(raw["$ID"])) - sc.TypeName = extractString(raw["$Type"]) - sc.Name = extractString(raw["Name"]) - sc.DatabaseType = extractString(raw["DatabaseType"]) - sc.DatabaseUrl = extractString(raw["DatabaseUrl"]) - sc.DatabaseName = extractString(raw["DatabaseName"]) - sc.DatabaseUserName = extractString(raw["DatabaseUserName"]) - sc.DatabasePassword = extractString(raw["DatabasePassword"]) - sc.DatabaseUseIntegratedSecurity = extractBool(raw["DatabaseUseIntegratedSecurity"], false) - sc.HttpPortNumber = extractInt(raw["HttpPortNumber"]) - sc.ServerPortNumber = extractInt(raw["ServerPortNumber"]) - sc.ApplicationRootUrl = extractString(raw["ApplicationRootUrl"]) - sc.MaxJavaHeapSize = extractInt(raw["MaxJavaHeapSize"]) - sc.ExtraJvmParameters = extractString(raw["ExtraJvmParameters"]) - sc.OpenAdminPort = extractBool(raw["OpenAdminPort"], false) - sc.OpenHttpPort = extractBool(raw["OpenHttpPort"], false) - - // Parse ConstantValues - cvArr := extractBsonArray(raw["ConstantValues"]) - for _, cv := range cvArr { - if cvMap := extractBsonMap(cv); cvMap != nil { - sc.ConstantValues = append(sc.ConstantValues, parseConstantValue(cvMap)) - } - } - - return sc -} - -func parseConstantValue(raw map[string]any) *model.ConstantValue { - cv := &model.ConstantValue{} - cv.ID = model.ID(extractBsonID(raw["$ID"])) - cv.TypeName = extractString(raw["$Type"]) - cv.ConstantId = extractString(raw["ConstantId"]) - - // Value is nested in SharedOrPrivateValue → Value. A Settings$PrivateValue - // carries no value at all: it marks an override whose value lives on the - // developer's workstation, outside the shared model. - if spv := extractBsonMap(raw["SharedOrPrivateValue"]); spv != nil { - if extractString(spv["$Type"]) == settingsoverlay.PrivateValueType { - cv.IsPrivate = true - } else { - cv.Value = extractString(spv["Value"]) - } - } - - return cv -} - -func parseModelSettings(raw map[string]any) *model.ModelSettings { - ms := &model.ModelSettings{} - ms.ID = model.ID(extractBsonID(raw["$ID"])) - ms.TypeName = extractString(raw["$Type"]) - ms.AfterStartupMicroflow = extractString(raw["AfterStartupMicroflow"]) - ms.BeforeShutdownMicroflow = extractString(raw["BeforeShutdownMicroflow"]) - ms.HealthCheckMicroflow = extractString(raw["HealthCheckMicroflow"]) - ms.AllowUserMultipleSessions = extractBool(raw["AllowUserMultipleSessions"], true) - ms.HashAlgorithm = extractString(raw["HashAlgorithm"]) - ms.BcryptCost = extractInt(raw["BcryptCost"]) - ms.JavaVersion = settingsoverlay.JavaVersion(raw) - ms.RoundingMode = extractString(raw["RoundingMode"]) - ms.ScheduledEventTimeZoneCode = extractString(raw["ScheduledEventTimeZoneCode"]) - ms.DefaultTimeZoneCode = extractString(raw["DefaultTimeZoneCode"]) - ms.FirstDayOfWeek = extractString(raw["FirstDayOfWeek"]) - ms.DecimalScale = extractInt(raw["DecimalScale"]) - ms.EnableDataStorageOptimisticLocking = extractBool(raw["EnableDataStorageOptimisticLocking"], false) - // The defaults below are only reached when the key is absent, which happens on - // older Mendix versions that do not store the property (a blank 9.24 project - // has none of UseOQLVersion2 / UseDatabaseForeignKeyConstraints / DecimalScale / - // SslCertificateAlgorithm). The overlay is presence-gated, so a value read from - // a default here is never written back — see settingsoverlay.SetModelSettings. - ms.UseDatabaseForeignKeyConstraints = extractBool(raw["UseDatabaseForeignKeyConstraints"], true) - ms.UseOQLVersion2 = extractBool(raw["UseOQLVersion2"], true) - ms.UseSystemContextForBackgroundTasks = extractBool(raw["UseSystemContextForBackgroundTasks"], false) - ms.SslCertificateAlgorithm = extractString(raw["SslCertificateAlgorithm"]) - return ms -} - -func parseConventionSettings(raw map[string]any) *model.ConventionSettings { - cs := &model.ConventionSettings{} - cs.ID = model.ID(extractBsonID(raw["$ID"])) - cs.TypeName = extractString(raw["$Type"]) - cs.LowerCaseMicroflowVariables = extractBool(raw["LowerCaseMicroflowVariables"], false) - cs.DefaultAssociationStorage = extractString(raw["DefaultAssociationStorage"]) - return cs -} - -func parseLanguageSettings(raw map[string]any) *model.LanguageSettings { - ls := &model.LanguageSettings{} - ls.ID = model.ID(extractBsonID(raw["$ID"])) - ls.TypeName = extractString(raw["$Type"]) - ls.DefaultLanguageCode = extractString(raw["DefaultLanguageCode"]) - for _, item := range extractBsonArray(raw["Languages"]) { - langMap := extractBsonMap(item) - if langMap == nil { - continue - } - ls.Languages = append(ls.Languages, model.Language{ - Code: extractString(langMap["Code"]), - CheckCompleteness: extractBool(langMap["CheckCompleteness"], false), - CustomDateFormat: extractString(langMap["CustomDateFormat"]), - CustomDateTimeFormat: extractString(langMap["CustomDateTimeFormat"]), - CustomTimeFormat: extractString(langMap["CustomTimeFormat"]), - }) - } - return ls -} - -func parseWorkflowsSettings(raw map[string]any) *model.WorkflowsSettings { - ws := &model.WorkflowsSettings{} - ws.ID = model.ID(extractBsonID(raw["$ID"])) - ws.TypeName = extractString(raw["$Type"]) - ws.UserEntity = extractString(raw["UserEntity"]) - ws.DefaultTaskParallelism = extractInt(raw["DefaultTaskParallelism"]) - ws.WorkflowEngineParallelism = extractInt(raw["WorkflowEngineParallelism"]) - return ws -} - -func parseDistributionSettings(raw map[string]any) *model.DistributionSettings { - ds := &model.DistributionSettings{} - ds.ID = model.ID(extractBsonID(raw["$ID"])) - ds.TypeName = extractString(raw["$Type"]) - ds.IsDistributable = extractBool(raw["IsDistributable"], false) - ds.Version = extractString(raw["Version"]) - return ds -} diff --git a/sdk/mpr/parser_settings_test.go b/sdk/mpr/parser_settings_test.go deleted file mode 100644 index 00a94a6539..0000000000 --- a/sdk/mpr/parser_settings_test.go +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// TestParseLanguageSettings_Languages verifies that Languages array items stored -// as primitive.D (the BSON decoded type) are correctly parsed via extractBsonMap. -// This is the fix for issue #480: bare .(map[string]any) assertions always fail -// on primitive.D values, so extractBsonMap must be used instead. -func TestParseLanguageSettings_Languages(t *testing.T) { - raw := map[string]any{ - "$ID": "settings-lang-1", - "$Type": "Settings$LanguageSettings", - "DefaultLanguageCode": "en_US", - "Languages": primitive.A{ - int32(2), - primitive.D{ - {Key: "$ID", Value: "lang-1"}, - {Key: "$Type", Value: "Texts$Language"}, - {Key: "Code", Value: "en_US"}, - {Key: "CheckCompleteness", Value: true}, - {Key: "CustomDateFormat", Value: "MM/dd/yyyy"}, - {Key: "CustomDateTimeFormat", Value: "MM/dd/yyyy HH:mm"}, - {Key: "CustomTimeFormat", Value: "HH:mm"}, - }, - primitive.D{ - {Key: "$ID", Value: "lang-2"}, - {Key: "$Type", Value: "Texts$Language"}, - {Key: "Code", Value: "fr_FR"}, - {Key: "CheckCompleteness", Value: false}, - }, - }, - } - - ls := parseLanguageSettings(raw) - - if ls.DefaultLanguageCode != "en_US" { - t.Errorf("DefaultLanguageCode = %q, want %q", ls.DefaultLanguageCode, "en_US") - } - if len(ls.Languages) != 2 { - t.Fatalf("len(Languages) = %d, want 2", len(ls.Languages)) - } - - en := ls.Languages[0] - if en.Code != "en_US" { - t.Errorf("Languages[0].Code = %q, want %q", en.Code, "en_US") - } - if !en.CheckCompleteness { - t.Errorf("Languages[0].CheckCompleteness = false, want true") - } - if en.CustomDateFormat != "MM/dd/yyyy" { - t.Errorf("Languages[0].CustomDateFormat = %q, want %q", en.CustomDateFormat, "MM/dd/yyyy") - } - if en.CustomDateTimeFormat != "MM/dd/yyyy HH:mm" { - t.Errorf("Languages[0].CustomDateTimeFormat = %q, want %q", en.CustomDateTimeFormat, "MM/dd/yyyy HH:mm") - } - if en.CustomTimeFormat != "HH:mm" { - t.Errorf("Languages[0].CustomTimeFormat = %q, want %q", en.CustomTimeFormat, "HH:mm") - } - - fr := ls.Languages[1] - if fr.Code != "fr_FR" { - t.Errorf("Languages[1].Code = %q, want %q", fr.Code, "fr_FR") - } - if fr.CheckCompleteness { - t.Errorf("Languages[1].CheckCompleteness = true, want false") - } -} - -// TestParseLanguageSettings_EmptyLanguages verifies that an absent or empty -// Languages array results in a nil/empty slice without panicking. -func TestParseLanguageSettings_EmptyLanguages(t *testing.T) { - raw := map[string]any{ - "$ID": "settings-lang-2", - "$Type": "Settings$LanguageSettings", - "DefaultLanguageCode": "en_US", - "Languages": primitive.A{int32(2)}, - } - - ls := parseLanguageSettings(raw) - if len(ls.Languages) != 0 { - t.Errorf("len(Languages) = %d, want 0", len(ls.Languages)) - } -} diff --git a/sdk/mpr/parser_unknown.go b/sdk/mpr/parser_unknown.go deleted file mode 100644 index 1ac32d9984..0000000000 --- a/sdk/mpr/parser_unknown.go +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// newUnknownObject creates an UnknownElement that preserves raw BSON fields -// for unrecognized $Type values, preventing silent data loss. -// FieldKinds is populated by inferPropertyKind so callers can see the inferred -// Mendix property kind for each field without inspecting the SDK JS source. -func newUnknownObject(typeName string, raw map[string]any) *model.UnknownElement { - id := "" - if raw != nil { - id = extractBsonID(raw["$ID"]) - } - // convert map to bson.D for storage - doc := make(bson.D, 0, len(raw)) - for k, v := range raw { - doc = append(doc, bson.E{Key: k, Value: v}) - } - elem := &model.UnknownElement{ - BaseElement: model.BaseElement{ID: model.ID(id), TypeName: typeName}, - RawDoc: doc, - } - if raw != nil { - elem.Position = parsePoint(raw["RelativeMiddlePoint"]) - elem.Name = extractString(raw["Name"]) - elem.Caption = extractString(raw["Caption"]) - elem.FieldKinds = make(map[string]string, len(raw)) - for k, v := range raw { - elem.FieldKinds[k] = inferPropertyKind(k, v) - } - } - return elem -} - -// newUnknownObjectFromD creates an UnknownElement from a bson.D document, -// preserving field ordering for round-trip fidelity. -func newUnknownObjectFromD(typeName string, raw bson.D) *model.UnknownElement { - elem := &model.UnknownElement{ - BaseElement: model.BaseElement{TypeName: typeName}, - RawDoc: raw, - } - if len(raw) > 0 { - elem.FieldKinds = make(map[string]string, len(raw)) - for _, e := range raw { - switch e.Key { - case "$ID": - elem.ID = model.ID(extractBsonID(e.Value)) - case "Name": - elem.Name = extractString(e.Value) - case "Caption": - elem.Caption = extractString(e.Value) - case "RelativeMiddlePoint": - elem.Position = parsePoint(e.Value) - } - elem.FieldKinds[e.Key] = inferPropertyKind(e.Key, e.Value) - } - } - return elem -} diff --git a/sdk/mpr/parser_webservice_source_test.go b/sdk/mpr/parser_webservice_source_test.go deleted file mode 100644 index 6d4a8e3c24..0000000000 --- a/sdk/mpr/parser_webservice_source_test.go +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -// A mapping's SOAP binding must survive the read, because that is the only way -// a rewrite can be refused rather than silently dropping it (ako/mxcli#365). -// Before this, model.ImportMapping carried three source fields and a comment -// saying "at most one is set" — the fourth was not in the type at all. - -func TestParseWebServiceSourceReadsTheBinding(t *testing.T) { - got := parseWebServiceSource(map[string]any{ - "ImportedWebService": "Legacy.WS_Orders", - "ServiceName": "OrderService", - "OperationName": "GetOrder", - "RootElementName": "GetOrderResponse", - "ParameterName": "body", - "IsHeader": true, - }) - - if !got.IsSet() { - t.Fatal("IsSet false for a mapping with an imported web service") - } - if got.ImportedWebService != "Legacy.WS_Orders" { - t.Errorf("ImportedWebService = %q", got.ImportedWebService) - } - if got.ServiceName != "OrderService" || got.OperationName != "GetOrder" { - t.Errorf("service/operation = %q/%q", got.ServiceName, got.OperationName) - } - // RootElementName is stored under that key; the SDK calls it - // xsdRootElementName, which is what makes it easy to bind wrongly. - if got.RootElementName != "GetOrderResponse" { - t.Errorf("RootElementName = %q", got.RootElementName) - } - // Export-only, and carried for the same reason as the rest. - if got.ParameterName != "body" || !got.IsHeader { - t.Errorf("ParameterName/IsHeader = %q/%v", got.ParameterName, got.IsHeader) - } -} - -// TestParseWebServiceSourceIsEmptyForAnOrdinaryMapping is the control: every -// mapping mxcli can author reaches this with none of the keys present, and must -// come back not-set or the guard would refuse every rewrite. -func TestParseWebServiceSourceIsEmptyForAnOrdinaryMapping(t *testing.T) { - got := parseWebServiceSource(map[string]any{ - "Name": "IMM_Order", - "JsonStructure": "Shop.JSON_Order", - }) - if got.IsSet() { - t.Errorf("IsSet true for a JSON-sourced mapping: %+v", got) - } -} diff --git a/sdk/mpr/parser_workflow.go b/sdk/mpr/parser_workflow.go deleted file mode 100644 index 298e75488d..0000000000 --- a/sdk/mpr/parser_workflow.go +++ /dev/null @@ -1,750 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/workflows" - - "go.mongodb.org/mongo-driver/bson" -) - -func (r *Reader) parseWorkflow(unitID, containerID string, contents []byte) (*workflows.Workflow, error) { - contents, err := r.resolveContents(unitID, contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal workflow BSON: %w", err) - } - - w := &workflows.Workflow{} - w.ID = model.ID(unitID) - w.TypeName = "Workflows$Workflow" - w.ContainerID = model.ID(containerID) - - if name, ok := raw["Name"].(string); ok { - w.Name = name - } - if doc, ok := raw["Documentation"].(string); ok { - w.Documentation = doc - } - if excluded, ok := raw["Excluded"].(bool); ok { - w.Excluded = excluded - } - if exportLevel, ok := raw["ExportLevel"].(string); ok { - w.ExportLevel = exportLevel - } - - // Parse Annotation - if annotRaw := raw["Annotation"]; annotRaw != nil { - annotMap := toMap(annotRaw) - if annotMap != nil { - if desc, ok := annotMap["Description"].(string); ok { - w.Annotation = desc - } - } - } - - // Parse Parameter (PART — DomainModels$IndirectEntityRef or similar) - if paramRaw := raw["Parameter"]; paramRaw != nil { - w.Parameter = parseWorkflowParameter(toMap(paramRaw)) - } - - // Parse OverviewPage (BY_NAME reference to Pages$Page) - if overviewPage, ok := raw["OverviewPage"].(string); ok { - w.OverviewPage = overviewPage - } - - // Parse AdminPage (BY_NAME reference) - if adminPage, ok := raw["AdminPage"].(string); ok { - w.AdminPage = adminPage - } - - // Parse WorkflowName (StringTemplate — extract text) - w.WorkflowName = extractStringTemplate(raw["WorkflowName"]) - - // Parse WorkflowDescription (StringTemplate — extract text) - w.WorkflowDescription = extractStringTemplate(raw["WorkflowDescription"]) - - // Parse DueDate expression - if dueDate, ok := raw["DueDate"].(string); ok { - w.DueDate = dueDate - } - - // Parse Flow (PART — Workflows$Flow) - if flowRaw := raw["Flow"]; flowRaw != nil { - w.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - - w.EventHandlers = parseWorkflowEventHandlers(raw["OnWorkflowEvent"]) - - return w, nil -} - -// parseWorkflowEventHandlers reads a workflow's OnWorkflowEvent list. -func parseWorkflowEventHandlers(v any) []*workflows.WorkflowEventHandler { - var out []*workflows.WorkflowEventHandler - for _, item := range extractBsonArray(v) { - m := toMap(item) - if m == nil || extractString(m["$Type"]) != "Workflows$WorkflowEventHandler" { - continue - } - h := &workflows.WorkflowEventHandler{ - Description: extractString(m["Description"]), - Documentation: extractString(m["Documentation"]), - } - h.ID = model.ID(extractBsonID(m["$ID"])) - for _, t := range extractBsonArray(m["EventTypes"]) { - if s, ok := t.(string); ok { - h.EventTypes = append(h.EventTypes, s) - } - } - if mh := toMap(m["MicroflowEventHandler"]); mh != nil { - h.Microflow = extractString(mh["Microflow"]) - } - out = append(out, h) - } - return out -} - -// extractStringTemplate extracts the text from a Mendix StringTemplate BSON structure. -// StringTemplates have a "Text" field with the template string. -func extractStringTemplate(v any) string { - m := toMap(v) - if m == nil { - return "" - } - // Direct text field - if text, ok := m["Text"].(string); ok { - return text - } - // Try Translations for localized strings - if translations := m["Translations"]; translations != nil { - transMap := toMap(translations) - if transMap != nil { - // Look for "en_US" or first available - for _, val := range transMap { - if s, ok := val.(string); ok && s != "" { - return s - } - } - } - } - return "" -} - -// parseWorkflowParameter parses the workflow context parameter. -func parseWorkflowParameter(raw map[string]any) *workflows.WorkflowParameter { - if raw == nil { - return nil - } - - param := &workflows.WorkflowParameter{} - param.ID = model.ID(extractBsonID(raw["$ID"])) - - // EntityRef is typically stored as an IndirectEntityRef with "EntityQualifiedName" or within an Entity field - if entityRef := raw["EntityRef"]; entityRef != nil { - entityMap := toMap(entityRef) - if entityMap != nil { - // Try EntityQualifiedName (new format) - if eqn, ok := entityMap["EntityQualifiedName"].(string); ok { - param.EntityRef = eqn - } - // Try QualifiedName - if qn, ok := entityMap["QualifiedName"].(string); ok && param.EntityRef == "" { - param.EntityRef = qn - } - } - } - - // Also try Entity field directly (BY_NAME reference) - if entity, ok := raw["Entity"].(string); ok && param.EntityRef == "" { - param.EntityRef = entity - } - - // Try EntityQualifiedName at parameter level - if eqn, ok := raw["EntityQualifiedName"].(string); ok && param.EntityRef == "" { - param.EntityRef = eqn - } - - return param -} - -// parseWorkflowFlow parses a Workflows$Flow from raw BSON data. -func parseWorkflowFlow(raw map[string]any) *workflows.Flow { - if raw == nil { - return nil - } - - flow := &workflows.Flow{} - flow.ID = model.ID(extractBsonID(raw["$ID"])) - - // Parse activities array - activitiesRaw := extractBsonArray(raw["Activities"]) - for _, actRaw := range activitiesRaw { - actMap := toMap(actRaw) - if actMap == nil { - continue - } - if activity := parseWorkflowActivity(actMap); activity != nil { - flow.Activities = append(flow.Activities, activity) - } - } - - return flow -} - -// workflowActivityParsers maps Mendix $Type strings to their workflow activity parser functions. -// Initialized in init() to avoid initialization cycle (parseParallelSplitActivity → parseWorkflowFlow → parseWorkflowActivity). -var workflowActivityParsers map[string]func(map[string]any) workflows.WorkflowActivity - -func init() { - workflowActivityParsers = map[string]func(map[string]any) workflows.WorkflowActivity{ - "Workflows$EndWorkflowActivity": func(r map[string]any) workflows.WorkflowActivity { return parseEndWorkflowActivity(r) }, - "Workflows$UserTask": func(r map[string]any) workflows.WorkflowActivity { return parseUserTask(r) }, - "Workflows$SingleUserTaskActivity": func(r map[string]any) workflows.WorkflowActivity { return parseUserTask(r) }, - "Workflows$MultiUserTaskActivity": func(r map[string]any) workflows.WorkflowActivity { return parseMultiUserTask(r) }, - "Workflows$CallMicroflowTask": func(r map[string]any) workflows.WorkflowActivity { return parseCallMicroflowTask(r) }, - "Workflows$CallMicroflowActivity": func(r map[string]any) workflows.WorkflowActivity { return parseCallMicroflowTask(r) }, - "Workflows$AIAgentTaskActivity": func(r map[string]any) workflows.WorkflowActivity { - t := parseCallMicroflowTask(r) - t.IsAgent = true - return t - }, - "Workflows$CallWorkflowActivity": func(r map[string]any) workflows.WorkflowActivity { return parseCallWorkflowActivity(r) }, - "Workflows$ExclusiveSplitActivity": func(r map[string]any) workflows.WorkflowActivity { return parseExclusiveSplitActivity(r) }, - "Workflows$ParallelSplitActivity": func(r map[string]any) workflows.WorkflowActivity { return parseParallelSplitActivity(r) }, - "Workflows$JumpToActivity": func(r map[string]any) workflows.WorkflowActivity { return parseJumpToActivity(r) }, - "Workflows$WaitForTimerActivity": func(r map[string]any) workflows.WorkflowActivity { return parseWaitForTimerActivity(r) }, - "Workflows$WaitForNotificationActivity": func(r map[string]any) workflows.WorkflowActivity { return parseWaitForNotificationActivity(r) }, - "Workflows$StartWorkflowActivity": func(r map[string]any) workflows.WorkflowActivity { return parseStartWorkflowActivity(r) }, - "Workflows$EndOfParallelSplitPathActivity": func(r map[string]any) workflows.WorkflowActivity { - a := &workflows.EndOfParallelSplitPathActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, r) - return a - }, - "Workflows$EndOfBoundaryEventPathActivity": func(r map[string]any) workflows.WorkflowActivity { - a := &workflows.EndOfBoundaryEventPathActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, r) - return a - }, - "Workflows$Annotation": func(r map[string]any) workflows.WorkflowActivity { - a := &workflows.WorkflowAnnotationActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, r) - if desc, ok := r["Description"].(string); ok { - a.Description = desc - } - return a - }, - "Workflows$SystemTask": func(r map[string]any) workflows.WorkflowActivity { return parseSystemTask(r) }, - } -} - -// parseWorkflowActivity dispatches activity parsing based on $Type. -func parseWorkflowActivity(raw map[string]any) workflows.WorkflowActivity { - typeName := extractString(raw["$Type"]) - if fn, ok := workflowActivityParsers[typeName]; ok { - return fn(raw) - } - if typeName != "" { - return parseGenericWorkflowActivity(raw, typeName) - } - return nil -} - -// parseEndWorkflowActivity parses an EndWorkflowActivity. -func parseStartWorkflowActivity(raw map[string]any) *workflows.StartWorkflowActivity { - a := &workflows.StartWorkflowActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - return a -} - -func parseEndWorkflowActivity(raw map[string]any) *workflows.EndWorkflowActivity { - a := &workflows.EndWorkflowActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - return a -} - -// parseUserTask parses a UserTask activity. -func parseUserTask(raw map[string]any) *workflows.UserTask { - a := &workflows.UserTask{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Page (BY_NAME reference) - if page, ok := raw["Page"].(string); ok { - a.Page = page - } - // Also try TaskPage — may be a nested Workflows$PageReference object - if a.Page == "" { - if page, ok := raw["TaskPage"].(string); ok { - a.Page = page - } else if taskPageMap := toMap(raw["TaskPage"]); taskPageMap != nil { - if page, ok := taskPageMap["Page"].(string); ok { - a.Page = page - } - } - } - - // TaskName (StringTemplate) - a.TaskName = extractStringTemplate(raw["TaskName"]) - - // TaskDescription (StringTemplate) - a.TaskDescription = extractStringTemplate(raw["TaskDescription"]) - - // DueDate - if dueDate, ok := raw["DueDate"].(string); ok { - a.DueDate = dueDate - } - - // UserTaskEntity (BY_NAME reference) - if ute, ok := raw["UserTaskEntity"].(string); ok { - a.UserTaskEntity = ute - } - - // OnCreatedEvent is a part — Workflows$MicroflowBasedEvent carrying the - // microflow, or Workflows$NoEvent. Reading it as a string, as this did, never - // matched a stored document, so every on-created microflow read as none. - if ev := toMap(raw["OnCreatedEvent"]); ev != nil && extractString(ev["$Type"]) == "Workflows$MicroflowBasedEvent" { - a.OnCreated = extractString(ev["Microflow"]) - } - - // UserSource (PART) — legacy field name - if userSourceRaw := raw["UserSource"]; userSourceRaw != nil { - a.UserSource = parseUserSource(toMap(userSourceRaw)) - } - // UserTargeting (PART) — current field name (Mendix 10.12+) - if a.UserSource == nil { - if userTargetingRaw := raw["UserTargeting"]; userTargetingRaw != nil { - a.UserSource = parseUserSource(toMap(userTargetingRaw)) - } - } - - // Outcomes - outcomesRaw := extractBsonArray(raw["Outcomes"]) - for _, outcomeRaw := range outcomesRaw { - outcomeMap := toMap(outcomeRaw) - if outcomeMap == nil { - continue - } - outcome := parseUserTaskOutcome(outcomeMap) - if outcome != nil { - a.Outcomes = append(a.Outcomes, outcome) - } - } - - // BoundaryEvents - a.BoundaryEvents = parseBoundaryEvents(raw["BoundaryEvents"]) - - return a -} - -// parseSystemTask parses a SystemTask (older type name for CallMicroflowTask). -func parseSystemTask(raw map[string]any) *workflows.SystemTask { - a := &workflows.SystemTask{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Microflow (BY_NAME reference) - if mf, ok := raw["Microflow"].(string); ok { - a.Microflow = mf - } - if mf, ok := raw["MicroflowName"].(string); ok && a.Microflow == "" { - a.Microflow = mf - } - - // Outcomes - a.Outcomes = parseConditionOutcomes(raw["Outcomes"]) - - // ParameterMappings - a.ParameterMappings = parseParameterMappings(raw["ParameterMappings"]) - - return a -} - -// parseCallMicroflowTask parses a CallMicroflowTask activity. -func parseCallMicroflowTask(raw map[string]any) *workflows.CallMicroflowTask { - a := &workflows.CallMicroflowTask{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Microflow (BY_NAME reference) - if mf, ok := raw["Microflow"].(string); ok { - a.Microflow = mf - } - if mf, ok := raw["MicroflowName"].(string); ok && a.Microflow == "" { - a.Microflow = mf - } - - // Outcomes - a.Outcomes = parseConditionOutcomes(raw["Outcomes"]) - - // ParameterMappings - a.ParameterMappings = parseParameterMappings(raw["ParameterMappings"]) - - // BoundaryEvents - a.BoundaryEvents = parseBoundaryEvents(raw["BoundaryEvents"]) - - return a -} - -// parseCallWorkflowActivity parses a CallWorkflowActivity. -func parseCallWorkflowActivity(raw map[string]any) *workflows.CallWorkflowActivity { - a := &workflows.CallWorkflowActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Workflow (BY_NAME reference) - if wf, ok := raw["Workflow"].(string); ok { - a.Workflow = wf - } - if wf, ok := raw["WorkflowName"].(string); ok && a.Workflow == "" { - a.Workflow = wf - } - - // ParameterExpression - if expr, ok := raw["ParameterExpression"].(string); ok { - a.ParameterExpression = expr - } - - // BoundaryEvents - a.BoundaryEvents = parseBoundaryEvents(raw["BoundaryEvents"]) - - return a -} - -// parseExclusiveSplitActivity parses an ExclusiveSplitActivity (decision). -func parseExclusiveSplitActivity(raw map[string]any) *workflows.ExclusiveSplitActivity { - a := &workflows.ExclusiveSplitActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Expression - if expr, ok := raw["Expression"].(string); ok { - a.Expression = expr - } - - // Outcomes - a.Outcomes = parseConditionOutcomes(raw["Outcomes"]) - - return a -} - -// parseParallelSplitActivity parses a ParallelSplitActivity. -func parseParallelSplitActivity(raw map[string]any) *workflows.ParallelSplitActivity { - a := &workflows.ParallelSplitActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // Outcomes - outcomesRaw := extractBsonArray(raw["Outcomes"]) - for _, outcomeRaw := range outcomesRaw { - outcomeMap := toMap(outcomeRaw) - if outcomeMap == nil { - continue - } - outcome := &workflows.ParallelSplitOutcome{} - outcome.ID = model.ID(extractBsonID(outcomeMap["$ID"])) - if flowRaw := outcomeMap["Flow"]; flowRaw != nil { - outcome.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - a.Outcomes = append(a.Outcomes, outcome) - } - - return a -} - -// parseJumpToActivity parses a JumpToActivity. -func parseJumpToActivity(raw map[string]any) *workflows.JumpToActivity { - a := &workflows.JumpToActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // TargetActivity (LOCAL_BY_NAME reference) - if target, ok := raw["TargetActivity"].(string); ok { - a.TargetActivity = target - } - if target, ok := raw["TargetActivityName"].(string); ok && a.TargetActivity == "" { - a.TargetActivity = target - } - - return a -} - -// parseWaitForTimerActivity parses a WaitForTimerActivity. -func parseWaitForTimerActivity(raw map[string]any) *workflows.WaitForTimerActivity { - a := &workflows.WaitForTimerActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - if expr, ok := raw["Delay"].(string); ok { - a.DelayExpression = expr - } else if expr, ok := raw["DelayExpression"].(string); ok { - // Legacy fallback - a.DelayExpression = expr - } - - return a -} - -// parseWaitForNotificationActivity parses a WaitForNotificationActivity. -func parseWaitForNotificationActivity(raw map[string]any) *workflows.WaitForNotificationActivity { - a := &workflows.WaitForNotificationActivity{} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - - // BoundaryEvents - a.BoundaryEvents = parseBoundaryEvents(raw["BoundaryEvents"]) - - return a -} - -// parseGenericWorkflowActivity creates a fallback for unknown activity types. -func parseGenericWorkflowActivity(raw map[string]any, typeName string) *workflows.GenericWorkflowActivity { - a := &workflows.GenericWorkflowActivity{TypeString: typeName} - parseBaseActivity(&a.BaseWorkflowActivity, raw) - return a -} - -// parseBaseActivity extracts common fields for all workflow activities. -func parseBaseActivity(a *workflows.BaseWorkflowActivity, raw map[string]any) { - a.ID = model.ID(extractBsonID(raw["$ID"])) - a.TypeName = extractString(raw["$Type"]) - - if name, ok := raw["Name"].(string); ok { - a.Name = name - } - if caption, ok := raw["Caption"].(string); ok { - a.Caption = caption - } - - // Annotation (PART — Workflows$Annotation) - if annotRaw := raw["Annotation"]; annotRaw != nil { - annotMap := toMap(annotRaw) - if annotMap != nil { - if desc, ok := annotMap["Description"].(string); ok { - a.Annotation = desc - } - } - } -} - -// parseMultiUserTask parses a MultiUserTaskActivity, reusing parseUserTask with IsMulti flag. -func parseMultiUserTask(raw map[string]any) *workflows.UserTask { - task := parseUserTask(raw) - if task != nil { - task.IsMulti = true - } - return task -} - -// parseUserTaskOutcome parses a UserTaskOutcome. -func parseUserTaskOutcome(raw map[string]any) *workflows.UserTaskOutcome { - outcome := &workflows.UserTaskOutcome{} - outcome.ID = model.ID(extractBsonID(raw["$ID"])) - - if name, ok := raw["Name"].(string); ok { - outcome.Name = name - } - if caption, ok := raw["Caption"].(string); ok { - outcome.Caption = caption - } - if value, ok := raw["Value"].(string); ok { - outcome.Value = value - } - - if flowRaw := raw["Flow"]; flowRaw != nil { - outcome.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - - return outcome -} - -// parseConditionOutcomes parses an array of condition outcomes. -func parseConditionOutcomes(v any) []workflows.ConditionOutcome { - outcomesRaw := extractBsonArray(v) - var outcomes []workflows.ConditionOutcome - - for _, outcomeRaw := range outcomesRaw { - outcomeMap := toMap(outcomeRaw) - if outcomeMap == nil { - continue - } - - typeName := extractString(outcomeMap["$Type"]) - switch typeName { - case "Workflows$BooleanConditionOutcome": - o := &workflows.BooleanConditionOutcome{} - o.ID = model.ID(extractBsonID(outcomeMap["$ID"])) - if v, ok := outcomeMap["Value"].(bool); ok { - o.Value = v - } - if flowRaw := outcomeMap["Flow"]; flowRaw != nil { - o.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - outcomes = append(outcomes, o) - - case "Workflows$EnumerationValueConditionOutcome": - o := &workflows.EnumerationValueConditionOutcome{} - o.ID = model.ID(extractBsonID(outcomeMap["$ID"])) - if v, ok := outcomeMap["Value"].(string); ok { - o.Value = v - } - if flowRaw := outcomeMap["Flow"]; flowRaw != nil { - o.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - outcomes = append(outcomes, o) - - default: - // VoidConditionOutcome or unknown - o := &workflows.VoidConditionOutcome{} - o.ID = model.ID(extractBsonID(outcomeMap["$ID"])) - if flowRaw := outcomeMap["Flow"]; flowRaw != nil { - o.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - outcomes = append(outcomes, o) - } - } - - return outcomes -} - -// parseUserSource parses a UserSource from raw BSON data. -// Mendix versions before 10.12 use "UserSource" BSON field with $Type names like -// "Workflows$MicroflowBasedUserSource". Mendix 10.12+ uses "UserTargeting" field -// with $Type names like "Workflows$MicroflowUserTargeting". Both are supported. -func parseUserSource(raw map[string]any) workflows.UserSource { - if raw == nil { - return &workflows.NoUserSource{} - } - - typeName := extractString(raw["$Type"]) - switch typeName { - case "Workflows$NoUserSource", "Workflows$NoUserTargeting": - return &workflows.NoUserSource{} - - case "Workflows$MicroflowBasedUserSource", "Workflows$MicroflowUserTargeting": - source := &workflows.MicroflowBasedUserSource{} - if mf, ok := raw["Microflow"].(string); ok { - source.Microflow = mf - } - if mf, ok := raw["MicroflowName"].(string); ok && source.Microflow == "" { - source.Microflow = mf - } - return source - - case "Workflows$XPathBasedUserSource", "Workflows$XPathUserTargeting": - source := &workflows.XPathBasedUserSource{} - if xpath, ok := raw["XPathConstraint"].(string); ok { - source.XPath = xpath - } - if xpath, ok := raw["XPath"].(string); ok && source.XPath == "" { - source.XPath = xpath - } - return source - - case "Workflows$MicroflowGroupTargeting": - source := &workflows.MicroflowGroupSource{} - if mf, ok := raw["Microflow"].(string); ok { - source.Microflow = mf - } - return source - - case "Workflows$XPathGroupTargeting": - source := &workflows.XPathGroupSource{} - if xpath, ok := raw["XPathConstraint"].(string); ok { - source.XPath = xpath - } - if xpath, ok := raw["XPath"].(string); ok && source.XPath == "" { - source.XPath = xpath - } - return source - - default: - return &workflows.NoUserSource{} - } -} - -// parseBoundaryEvents parses boundary events from a BSON array. -func parseBoundaryEvents(v any) []*workflows.BoundaryEvent { - eventsRaw := extractBsonArray(v) - var events []*workflows.BoundaryEvent - - for _, eventRaw := range eventsRaw { - eventMap := toMap(eventRaw) - if eventMap == nil { - continue - } - event := &workflows.BoundaryEvent{} - event.ID = model.ID(extractBsonID(eventMap["$ID"])) - event.TypeName = extractString(eventMap["$Type"]) - - if caption, ok := eventMap["Caption"].(string); ok { - event.Caption = caption - } - - // Timer delay — BSON field is "FirstExecutionTime" for both boundary event types - if delay, ok := eventMap["FirstExecutionTime"].(string); ok { - event.TimerDelay = delay - } - // Legacy fallbacks - if event.TimerDelay == "" { - if delay, ok := eventMap["DelayExpression"].(string); ok { - event.TimerDelay = delay - } - } - if event.TimerDelay == "" { - if delay, ok := eventMap["Delay"].(string); ok { - event.TimerDelay = delay - } - } - - // Event type from $Type - typeName := extractString(eventMap["$Type"]) - switch typeName { - case "Workflows$InterruptingTimerBoundaryEvent": - event.EventType = "InterruptingTimer" - case "Workflows$NonInterruptingTimerBoundaryEvent": - event.EventType = "NonInterruptingTimer" - case "Workflows$TimerBoundaryEvent": - event.EventType = "Timer" - default: - if typeName != "" { - // Extract the event type from the type name - event.EventType = strings.TrimPrefix(typeName, "Workflows$") - } - } - - // Flow - if flowRaw := eventMap["Flow"]; flowRaw != nil { - event.Flow = parseWorkflowFlow(toMap(flowRaw)) - } - - events = append(events, event) - } - - return events -} - -// parseParameterMappings parses parameter mappings from an array. -func parseParameterMappings(v any) []*workflows.ParameterMapping { - mappingsRaw := extractBsonArray(v) - var mappings []*workflows.ParameterMapping - - for _, mappingRaw := range mappingsRaw { - mappingMap := toMap(mappingRaw) - if mappingMap == nil { - continue - } - mapping := &workflows.ParameterMapping{} - mapping.ID = model.ID(extractBsonID(mappingMap["$ID"])) - - if param, ok := mappingMap["Parameter"].(string); ok { - mapping.Parameter = param - } - if expr, ok := mappingMap["Expression"].(string); ok { - mapping.Expression = expr - } - - mappings = append(mappings, mapping) - } - - return mappings -} diff --git a/sdk/mpr/placeholder_test.go b/sdk/mpr/placeholder_test.go deleted file mode 100644 index 23146796c7..0000000000 --- a/sdk/mpr/placeholder_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" -) - -func TestValidateNoPlaceholderIDs_BinaryPattern(t *testing.T) { - // Simulate BSON contents containing the placeholder binary pattern. - // The GUID-swapped placeholder prefix is \x00\x00\x00\xaa followed by 9 zero bytes. - contents := []byte("some bson preamble") - contents = append(contents, 0x00, 0x00, 0x00, 0xaa) // GUID-swapped first 4 bytes - contents = append(contents, 0x00, 0x00, 0x00, 0x00, 0x00) // bytes 4-8 - contents = append(contents, 0x00, 0x00, 0x00, 0x00) // bytes 9-12 - contents = append(contents, 0x00, 0x00, 0x01) // counter bytes - contents = append(contents, []byte("more bson data")...) - - err := validateNoPlaceholderIDs("test-unit-id", contents) - if err == nil { - t.Fatal("expected error for placeholder binary pattern, got nil") - } - if got := err.Error(); got == "" { - t.Fatal("expected non-empty error message") - } -} - -func TestValidateNoPlaceholderIDs_StringPattern(t *testing.T) { - // Simulate BSON contents containing a placeholder as an ASCII string - contents := []byte("some bson preamble aa000000000000000000000000000003 more data") - - err := validateNoPlaceholderIDs("test-unit-id", contents) - if err == nil { - t.Fatal("expected error for placeholder string pattern, got nil") - } -} - -func TestValidateNoPlaceholderIDs_Clean(t *testing.T) { - // Normal BSON-like data with no placeholder patterns - contents := []byte{ - 0x1a, 0x00, 0x00, 0x00, // BSON document length - 0x02, // string type - 0x6e, 0x61, 0x6d, 0x65, 0x00, // "name\0" - 0x08, 0x00, 0x00, 0x00, // string length - 0x54, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x00, // "Testing\0" - 0x05, // binary type - 0x69, 0x64, 0x00, // "id\0" - 0x10, 0x00, 0x00, 0x00, 0x00, // binary length + subtype - 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, // legitimate UUID - 0x00, // document terminator - } - - err := validateNoPlaceholderIDs("test-unit-id", contents) - if err != nil { - t.Fatalf("expected no error for clean data, got: %v", err) - } -} diff --git a/sdk/mpr/queues.go b/sdk/mpr/queues.go deleted file mode 100644 index 907690da83..0000000000 --- a/sdk/mpr/queues.go +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "go.mongodb.org/mongo-driver/bson" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" -) - -// Task queues (Queues$Queue). The document is small and flat apart from a single -// nested Config node, so it is read and written as raw BSON here rather than -// through a dedicated element type. -// -// The shape follows four Studio Pro-authored queues from the Mendix Business -// Events module. Note that Queues$BasicQueueConfig declares an int32 -// `Parallelism` in addition to `ParallelismExpression`, and Studio Pro wrote it -// in none of them — so only the expression is read and written. - -const queueUnitType = "Queues$Queue" - -// ListQueues reads every task queue in the project. -func (r *Reader) ListQueues() ([]*types.Queue, error) { - units, err := r.ListRawUnitsByType(queueUnitType) - if err != nil { - return nil, err - } - out := make([]*types.Queue, 0, len(units)) - for _, u := range units { - var doc bson.M - if err := bson.Unmarshal(u.Contents, &doc); err != nil { - return nil, fmt.Errorf("unmarshal queue %s: %w", u.ID, err) - } - q := &types.Queue{ContainerID: model.ID(u.ContainerID)} - q.ID = model.ID(u.ID) - q.TypeName = queueUnitType - q.Name, _ = doc["Name"].(string) - q.Documentation, _ = doc["Documentation"].(string) - q.Excluded, _ = doc["Excluded"].(bool) - q.ExportLevel, _ = doc["ExportLevel"].(string) - if cfg, ok := doc["Config"].(bson.M); ok { - q.Parallelism, _ = cfg["ParallelismExpression"].(string) - q.ClusterWide, _ = cfg["ClusterWide"].(bool) - } - out = append(out, q) - } - return out, nil -} - -// CreateQueue inserts a new task queue document. -func (w *Writer) CreateQueue(q *types.Queue) error { - if q == nil { - return fmt.Errorf("CreateQueue: nil queue") - } - if q.ID == "" { - q.ID = model.ID(generateUUID()) - } - contents, err := serializeQueueUnit(q) - if err != nil { - return err - } - return w.insertUnit(string(q.ID), string(q.ContainerID), "Documents", queueUnitType, contents) -} - -// UpdateQueue rewrites an existing task queue in place. -func (w *Writer) UpdateQueue(q *types.Queue) error { - if q == nil { - return fmt.Errorf("UpdateQueue: nil queue") - } - contents, err := serializeQueueUnit(q) - if err != nil { - return err - } - return w.UpdateRawUnit(string(q.ID), contents) -} - -// DeleteQueue removes a task queue by ID. -func (w *Writer) DeleteQueue(id string) error { - return w.deleteUnit(id) -} - -func serializeQueueUnit(q *types.Queue) ([]byte, error) { - parallelism := q.Parallelism - if parallelism == "" { - parallelism = "1" - } - exportLevel := q.ExportLevel - if exportLevel == "" { - exportLevel = "Hidden" - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(q.ID))}, - {Key: "$Type", Value: queueUnitType}, - {Key: "Config", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Queues$BasicQueueConfig"}, - {Key: "ClusterWide", Value: q.ClusterWide}, - {Key: "ParallelismExpression", Value: parallelism}, - }}, - {Key: "Documentation", Value: q.Documentation}, - {Key: "Excluded", Value: q.Excluded}, - {Key: "ExportLevel", Value: exportLevel}, - {Key: "Name", Value: q.Name}, - } - return marshalUnitIDFirst(doc) -} diff --git a/sdk/mpr/reader.go b/sdk/mpr/reader.go deleted file mode 100644 index 7bb79fd53c..0000000000 --- a/sdk/mpr/reader.go +++ /dev/null @@ -1,269 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr provides functionality for reading and writing Mendix project files (.mpr). -package mpr - -import ( - "database/sql" - "encoding/hex" - "errors" - "fmt" - "os" - "path/filepath" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/mpr/version" - - _ "modernc.org/sqlite" -) - -// MPRVersion represents the MPR file format version. -type MPRVersion int - -const ( - // MPRVersionV1 is the original single-file format. - MPRVersionV1 MPRVersion = 1 - // MPRVersionV2 uses mprcontents folder (Mendix 10.18+). - MPRVersionV2 MPRVersion = 2 -) - -// Reader provides methods to read Mendix project files. -type Reader struct { - path string - db *sql.DB - version MPRVersion - contentsDir string - readOnly bool - projectVersion *version.ProjectVersion - - // Cache for unit metadata to avoid repeated file reads - unitCache []cachedUnit - unitCacheValid bool - - // Lazily-built index of (unit $Type + qualified name) → unit, so name - // lookups are O(1) instead of re-scanning and re-parsing every unit per - // call (the source-catalog build does thousands of such lookups). - nameIndex map[string]nameIndexEntry - nameIndexBuilt bool -} - -// cachedUnit stores metadata about a unit for fast filtering. -type cachedUnit struct { - ID string - ContainerID string - ContainmentName string - Type string -} - -// OpenOptions configures how the MPR file is opened. -type OpenOptions struct { - // ReadOnly opens the database in read-only mode. - ReadOnly bool -} - -// Open opens an MPR file for reading. -func Open(path string) (*Reader, error) { - return OpenWithOptions(path, OpenOptions{ReadOnly: true}) -} - -// OpenWithOptions opens an MPR file with the specified options. -func OpenWithOptions(path string, opts OpenOptions) (*Reader, error) { - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil, fmt.Errorf("mpr file not found: %s", path) - } - - r := &Reader{ - path: path, - readOnly: opts.ReadOnly, - } - - // Check for MPR v2 (mprcontents folder) - dir := filepath.Dir(path) - contentsDir := filepath.Join(dir, "mprcontents") - if stat, err := os.Stat(contentsDir); err == nil && stat.IsDir() { - r.version = MPRVersionV2 - r.contentsDir = contentsDir - } else { - r.version = MPRVersionV1 - } - - // Open SQLite database - dsn := path - if opts.ReadOnly { - dsn = fmt.Sprintf("file:%s?mode=ro", path) - } - - db, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - // Limit to single connection to avoid lock contention with SQLite - db.SetMaxOpenConns(1) - - // Set busy timeout to prevent SQLITE_BUSY errors during multi-statement - // script execution (e.g., 12+ CREATE PAGE commands in sequence) - if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil { - db.Close() - return nil, fmt.Errorf("failed to set busy_timeout: %w", err) - } - - r.db = db - - // Detect project version from metadata - pv, err := version.DetectFromDB(db) - if err != nil { - r.Close() - return nil, fmt.Errorf("failed to detect project version: %w", err) - } - r.projectVersion = pv - - // Reconcile version detection: the folder-based check can fail if the .mpr - // file was copied without the mprcontents/ folder. Check the actual DB schema - // to determine whether the Unit table has a Contents column. If it doesn't, - // we must use v2 code paths to avoid "no such column: Contents" errors. - if r.version == MPRVersionV1 && !r.unitTableHasContents() { - dir := filepath.Dir(path) - contentsDir := filepath.Join(dir, "mprcontents") - r.version = MPRVersionV2 - r.contentsDir = contentsDir - } - - // Verify it's a valid MPR file - if err := r.verify(); err != nil { - r.Close() - return nil, err - } - - return r, nil -} - -// Close closes the reader and releases resources. -func (r *Reader) Close() error { - if r.db != nil { - return r.db.Close() - } - return nil -} - -// unitTableHasContents checks whether the Unit table has a Contents column. -// MPR v2 schemas (Mendix 10.18+) drop this column; v1 schemas have it. -func (r *Reader) unitTableHasContents() bool { - rows, err := r.db.Query("PRAGMA table_info(Unit)") - if err != nil { - return false - } - defer rows.Close() - for rows.Next() { - var cid int - var name, colType string - var notNull, pk int - var dfltValue *string - if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { - continue - } - if name == "Contents" { - return true - } - } - return false -} - -// Path returns the path to the MPR file. -func (r *Reader) Path() string { - return r.path -} - -// Version returns the MPR file format version. -func (r *Reader) Version() MPRVersion { - return r.version -} - -// ContentsDir returns the path to the mprcontents directory for v2 format. -// Returns empty string for v1 format. -func (r *Reader) ContentsDir() string { - return r.contentsDir -} - -// ListAllUnitIDs returns all unit UUIDs from the Unit table. -func (r *Reader) ListAllUnitIDs() ([]string, error) { - rows, err := r.db.Query("SELECT UnitID FROM Unit") - if err != nil { - return nil, err - } - defer rows.Close() - var ids []string - for rows.Next() { - var unitID []byte - if err := rows.Scan(&unitID); err != nil { - return nil, fmt.Errorf("scanning unit ID: %w", err) - } - ids = append(ids, BlobToUUID(unitID)) - } - return ids, rows.Err() -} - -// ProjectVersion returns the Mendix project version information. -func (r *Reader) ProjectVersion() *version.ProjectVersion { - return r.projectVersion -} - -// verify checks that the file is a valid MPR database. -func (r *Reader) verify() error { - // Check for Unit table which is required - var count int - err := r.db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = 'Unit'").Scan(&count) - if err != nil { - return fmt.Errorf("failed to query tables: %w", err) - } - if count == 0 { - return errors.New("not a valid MPR file: Unit table not found") - } - return nil -} - -// GetProjectRootID returns the ID of the project root unit. -// The project root is the unit where UnitID equals ContainerID. -func (r *Reader) GetProjectRootID() (string, error) { - var unitID []byte - err := r.db.QueryRow("SELECT UnitID FROM Unit WHERE UnitID = ContainerID").Scan(&unitID) - if err != nil { - return "", fmt.Errorf("failed to get project root: %w", err) - } - return blobToUUID(unitID), nil -} - -// GetMendixVersion returns the Mendix version used to create the project. -func (r *Reader) GetMendixVersion() (string, error) { - var version string - // Try new schema first - err := r.db.QueryRow("SELECT _ProductVersion FROM _MetaData LIMIT 1").Scan(&version) - if err != nil { - // Try old schema - err = r.db.QueryRow("SELECT MendixVersion FROM _MetaData LIMIT 1").Scan(&version) - if err != nil { - return "", fmt.Errorf("failed to get Mendix version: %w", err) - } - } - return version, nil -} - -// blobToUUID delegates to types.BlobToUUID. -func blobToUUID(blob []byte) string { - return types.BlobToUUID(blob) -} - -// blobToUUIDSwapped converts a 16-byte blob to a UUID string using Microsoft GUID format. -// The first 3 groups are little-endian (byte-swapped), last 2 groups are big-endian. -// This is the format used by Mendix for file naming in mprcontents folder. -func blobToUUIDSwapped(blob []byte) string { - if len(blob) != 16 { - return hex.EncodeToString(blob) - } - return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", - blob[3], blob[2], blob[1], blob[0], - blob[5], blob[4], - blob[7], blob[6], - blob[8], blob[9], - blob[10], blob[11], blob[12], blob[13], blob[14], blob[15]) -} diff --git a/sdk/mpr/reader_agenteditor.go b/sdk/mpr/reader_agenteditor.go deleted file mode 100644 index 339b18f3e0..0000000000 --- a/sdk/mpr/reader_agenteditor.go +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Reader methods for agent-editor CustomBlobDocuments. -// -// Covers the four document types created by the Studio Pro Agent Editor -// extension: Agent, Model, Knowledge Base, Consumed MCP Service. Each -// shares the outer CustomBlobDocument BSON wrapper and is discriminated -// by CustomDocumentType. This file currently implements Model only; the -// other three will follow the same pattern. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/sdk/agenteditor" -) - -// ListAgentEditorModels returns all agent-editor Model documents in the -// project (CustomDocumentType == "agenteditor.model"). -func (r *Reader) ListAgentEditorModels() ([]*agenteditor.Model, error) { - units, err := r.listUnitsByType(customBlobDocType) - if err != nil { - return nil, err - } - - var result []*agenteditor.Model - for _, u := range units { - wrap, err := parseCustomBlobWrapper(u.Contents) - if err != nil { - // Skip units we can't decode; log to error list if useful later. - continue - } - if wrap.CustomDocumentType != agenteditor.CustomTypeModel { - continue - } - m, err := r.parseAgentEditorModel(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse agent-editor model %s: %w", u.ID, err) - } - result = append(result, m) - } - return result, nil -} - -// ListAgentEditorKnowledgeBases returns all agent-editor Knowledge Base -// documents in the project (CustomDocumentType == "agenteditor.knowledgebase"). -func (r *Reader) ListAgentEditorKnowledgeBases() ([]*agenteditor.KnowledgeBase, error) { - units, err := r.listUnitsByType(customBlobDocType) - if err != nil { - return nil, err - } - - var result []*agenteditor.KnowledgeBase - for _, u := range units { - wrap, err := parseCustomBlobWrapper(u.Contents) - if err != nil { - continue - } - if wrap.CustomDocumentType != agenteditor.CustomTypeKnowledgeBase { - continue - } - kb, err := r.parseAgentEditorKnowledgeBase(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse agent-editor knowledge base %s: %w", u.ID, err) - } - result = append(result, kb) - } - return result, nil -} - -// ListAgentEditorConsumedMCPServices returns all agent-editor Consumed MCP -// Service documents in the project (CustomDocumentType == -// "agenteditor.consumedMCPService"). -func (r *Reader) ListAgentEditorConsumedMCPServices() ([]*agenteditor.ConsumedMCPService, error) { - units, err := r.listUnitsByType(customBlobDocType) - if err != nil { - return nil, err - } - - var result []*agenteditor.ConsumedMCPService - for _, u := range units { - wrap, err := parseCustomBlobWrapper(u.Contents) - if err != nil { - continue - } - if wrap.CustomDocumentType != agenteditor.CustomTypeConsumedMCPService { - continue - } - c, err := r.parseAgentEditorConsumedMCPService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse agent-editor consumed MCP service %s: %w", u.ID, err) - } - result = append(result, c) - } - return result, nil -} - -// ListAgentEditorAgents returns all agent-editor Agent documents in the -// project (CustomDocumentType == "agenteditor.agent"). -func (r *Reader) ListAgentEditorAgents() ([]*agenteditor.Agent, error) { - units, err := r.listUnitsByType(customBlobDocType) - if err != nil { - return nil, err - } - - var result []*agenteditor.Agent - for _, u := range units { - wrap, err := parseCustomBlobWrapper(u.Contents) - if err != nil { - continue - } - if wrap.CustomDocumentType != agenteditor.CustomTypeAgent { - continue - } - a, err := r.parseAgentEditorAgent(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse agent-editor agent %s: %w", u.ID, err) - } - result = append(result, a) - } - return result, nil -} diff --git a/sdk/mpr/reader_documents.go b/sdk/mpr/reader_documents.go deleted file mode 100644 index 4fabd2e491..0000000000 --- a/sdk/mpr/reader_documents.go +++ /dev/null @@ -1,1105 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Document listing and retrieval methods for Reader. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/sdk/pages" - "github.com/mendixlabs/mxcli/sdk/security" - "github.com/mendixlabs/mxcli/sdk/workflows" - - "go.mongodb.org/mongo-driver/bson" -) - -// ListModules returns all modules in the project. -func (r *Reader) ListModules() ([]*model.Module, error) { - // Use Projects$ModuleImpl (not Projects$Module which also matches ModuleSettings) - units, err := r.listUnitsByType("Projects$ModuleImpl") - if err != nil { - return nil, err - } - - var modules []*model.Module - for _, u := range units { - module, err := r.parseModule(u.ID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse module %s: %w", u.ID, err) - } - modules = append(modules, module) - } - - // Append virtual System module - modules = append(modules, BuildSystemModule()) - - return modules, nil -} - -// GetModule retrieves a module by ID. -func (r *Reader) GetModule(id model.ID) (*model.Module, error) { - modules, err := r.ListModules() - if err != nil { - return nil, err - } - - for _, m := range modules { - if m.ID == id { - return m, nil - } - } - - return nil, fmt.Errorf("module not found: %s", id) -} - -// GetModuleByName retrieves a module by name. -func (r *Reader) GetModuleByName(name string) (*model.Module, error) { - modules, err := r.ListModules() - if err != nil { - return nil, err - } - - for _, m := range modules { - if m.Name == name { - return m, nil - } - } - - return nil, fmt.Errorf("module not found: %s", name) -} - -// ListDomainModels returns all domain models in the project. -func (r *Reader) ListDomainModels() ([]*domainmodel.DomainModel, error) { - units, err := r.listUnitsByType("DomainModels$DomainModel") - if err != nil { - return nil, err - } - - var domainModels []*domainmodel.DomainModel - for _, u := range units { - dm, err := r.parseDomainModel(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse domain model %s: %w", u.ID, err) - } - domainModels = append(domainModels, dm) - } - - // Load OQL queries for view entities - oqlMap, err := r.loadViewEntityOqlQueries() - if err != nil { - // Non-fatal error, just skip OQL population - return domainModels, nil - } - - // Populate OQL queries for view entities - for _, dm := range domainModels { - for _, entity := range dm.Entities { - if entity.SourceDocumentRef != "" { - if oql, ok := oqlMap[entity.SourceDocumentRef]; ok { - entity.OqlQuery = oql - } - } - } - } - - // Append virtual System module domain model - domainModels = append(domainModels, BuildSystemDomainModel()) - - return domainModels, nil -} - -// loadViewEntityOqlQueries loads all ViewEntitySourceDocuments and returns a map of qualified name -> OQL query. -func (r *Reader) loadViewEntityOqlQueries() (map[string]string, error) { - units, err := r.listUnitsByType("DomainModels$ViewEntitySourceDocument") - if err != nil { - return nil, err - } - - // Build module ID -> name map once (for efficiency) - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleNames := make(map[string]string) - for _, m := range modules { - moduleNames[string(m.ID)] = m.Name - } - - result := make(map[string]string) - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - name, _ := raw["Name"].(string) - oql, _ := raw["Oql"].(string) - - if name != "" { - // Build qualified name from module + name - moduleName := moduleNames[u.ContainerID] - qualifiedName := moduleName + "." + name - result[qualifiedName] = oql - } - } - - return result, nil -} - -// GetDomainModel retrieves a domain model by module ID. -func (r *Reader) GetDomainModel(moduleID model.ID) (*domainmodel.DomainModel, error) { - domainModels, err := r.ListDomainModels() - if err != nil { - return nil, err - } - - for _, dm := range domainModels { - if dm.ContainerID == moduleID { - return dm, nil - } - } - - return nil, fmt.Errorf("domain model not found for module: %s", moduleID) -} - -// GetDomainModelByID retrieves a domain model by its own ID. -func (r *Reader) GetDomainModelByID(id model.ID) (*domainmodel.DomainModel, error) { - domainModels, err := r.ListDomainModels() - if err != nil { - return nil, err - } - - for _, dm := range domainModels { - if dm.ID == id { - return dm, nil - } - } - - return nil, fmt.Errorf("domain model not found: %s", id) -} - -// ListMicroflows returns all microflows in the project. -func (r *Reader) ListMicroflows() ([]*microflows.Microflow, error) { - units, err := r.listUnitsByType("Microflows$Microflow") - if err != nil { - return nil, err - } - - var result []*microflows.Microflow - for _, u := range units { - mf, err := r.parseMicroflow(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse microflow %s: %w", u.ID, err) - } - result = append(result, mf) - } - - return result, nil -} - -// GetMicroflow retrieves a microflow by ID. -// Uses a direct unit lookup (O(1) for V1, O(cache) for V2) instead of loading all microflows. -func (r *Reader) GetMicroflow(id model.ID) (*microflows.Microflow, error) { - unit, err := r.getUnitByID(string(id)) - if err != nil { - return nil, err - } - if unit == nil { - return nil, fmt.Errorf("microflow not found: %s", id) - } - return r.parseMicroflow(unit.ID, unit.ContainerID, unit.Contents) -} - -// ListRules returns every rule document (Microflows$Rule). Rules are a distinct -// doctype and deliberately absent from ListMicroflows. -func (r *Reader) ListRules() ([]*microflows.Rule, error) { - units, err := r.listUnitsByType("Microflows$Rule") - if err != nil { - return nil, err - } - - var result []*microflows.Rule - for _, u := range units { - rule, err := r.parseRule(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse rule %s: %w", u.ID, err) - } - result = append(result, rule) - } - - return result, nil -} - -// GetRule retrieves a rule by ID. -func (r *Reader) GetRule(id model.ID) (*microflows.Rule, error) { - rules, err := r.ListRules() - if err != nil { - return nil, err - } - for _, rule := range rules { - if rule.ID == id { - return rule, nil - } - } - return nil, nil -} - -// IsRule reports whether the given qualified name refers to a rule -// (Microflows$Rule). Rules share the microflow namespace but are stored -// under a distinct BSON type — the flow-builder needs this distinction so -// it can emit RuleSplitCondition instead of ExpressionSplitCondition for -// rule-based IF statements. -func (r *Reader) IsRule(qualifiedName string) (bool, error) { - if qualifiedName == "" { - return false, nil - } - units, err := r.listUnitsByType("Microflows$Rule") - if err != nil { - return false, err - } - if len(units) == 0 { - return false, nil - } - modules, err := r.ListModules() - if err != nil { - return false, err - } - moduleMap := make(map[string]string, len(modules)) - for _, m := range modules { - moduleMap[string(m.ID)] = m.Name - } - containerParent, err := r.buildContainerParent() - if err != nil { - return false, err - } - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - name, _ := raw["Name"].(string) - if name == "" { - continue - } - moduleName := resolveModuleName(u.ContainerID, moduleMap, containerParent) - fullName := name - if moduleName != "" { - fullName = moduleName + "." + name - } - if fullName == qualifiedName { - return true, nil - } - } - return false, nil -} - -// ListNanoflows returns all nanoflows in the project. -func (r *Reader) ListNanoflows() ([]*microflows.Nanoflow, error) { - units, err := r.listUnitsByType("Microflows$Nanoflow") - if err != nil { - return nil, err - } - - var result []*microflows.Nanoflow - for _, u := range units { - nf, err := r.parseNanoflow(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse nanoflow %s: %w", u.ID, err) - } - result = append(result, nf) - } - - return result, nil -} - -// GetNanoflow retrieves a nanoflow by ID. -// Uses a direct unit lookup (O(1) for V1, O(cache) for V2) instead of loading all nanoflows. -func (r *Reader) GetNanoflow(id model.ID) (*microflows.Nanoflow, error) { - unit, err := r.getUnitByID(string(id)) - if err != nil { - return nil, err - } - if unit == nil { - return nil, fmt.Errorf("nanoflow not found: %s", id) - } - return r.parseNanoflow(unit.ID, unit.ContainerID, unit.Contents) -} - -// ListPages returns all pages in the project. -func (r *Reader) ListPages() ([]*pages.Page, error) { - // Try Forms$Page first (Mendix 10+), then Pages$Page (older versions) - units, err := r.listUnitsByType("Forms$Page") - if err != nil { - return nil, err - } - if len(units) == 0 { - units, err = r.listUnitsByType("Pages$Page") - if err != nil { - return nil, err - } - } - - var result []*pages.Page - for _, u := range units { - page, err := r.parsePage(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse page %s: %w", u.ID, err) - } - result = append(result, page) - } - - return result, nil -} - -// GetPage retrieves a page by ID. -func (r *Reader) GetPage(id model.ID) (*pages.Page, error) { - pagesList, err := r.ListPages() - if err != nil { - return nil, err - } - - for _, p := range pagesList { - if p.ID == id { - return p, nil - } - } - - return nil, fmt.Errorf("page not found: %s", id) -} - -// ListLayouts returns all layouts in the project. -func (r *Reader) ListLayouts() ([]*pages.Layout, error) { - // Try Forms$Layout first (Mendix 10+), then Pages$Layout (older versions) - units, err := r.listUnitsByType("Forms$Layout") - if err != nil { - return nil, err - } - if len(units) == 0 { - units, err = r.listUnitsByType("Pages$Layout") - if err != nil { - return nil, err - } - } - - var result []*pages.Layout - for _, u := range units { - layout, err := r.parseLayout(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse layout %s: %w", u.ID, err) - } - result = append(result, layout) - } - - return result, nil -} - -// GetLayout retrieves a layout by ID. -func (r *Reader) GetLayout(id model.ID) (*pages.Layout, error) { - layouts, err := r.ListLayouts() - if err != nil { - return nil, err - } - - for _, l := range layouts { - if l.ID == id { - return l, nil - } - } - - return nil, fmt.Errorf("layout not found: %s", id) -} - -// ListEnumerations returns all enumerations in the project. -func (r *Reader) ListEnumerations() ([]*model.Enumeration, error) { - units, err := r.listUnitsByType("Enumerations$Enumeration") - if err != nil { - return nil, err - } - - var result []*model.Enumeration - for _, u := range units { - enum, err := r.parseEnumeration(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse enumeration %s: %w", u.ID, err) - } - result = append(result, enum) - } - - return result, nil -} - -// GetEnumeration retrieves an enumeration by ID. -func (r *Reader) GetEnumeration(id model.ID) (*model.Enumeration, error) { - enums, err := r.ListEnumerations() - if err != nil { - return nil, err - } - - for _, e := range enums { - if e.ID == id { - return e, nil - } - } - - return nil, fmt.Errorf("enumeration not found: %s", id) -} - -// ListConstants returns all constants in the project. -func (r *Reader) ListConstants() ([]*model.Constant, error) { - units, err := r.listUnitsByType("Constants$Constant") - if err != nil { - return nil, err - } - - var result []*model.Constant - for _, u := range units { - constant, err := r.parseConstant(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse constant %s: %w", u.ID, err) - } - result = append(result, constant) - } - - return result, nil -} - -// GetConstant retrieves a constant by ID. -func (r *Reader) GetConstant(id model.ID) (*model.Constant, error) { - constants, err := r.ListConstants() - if err != nil { - return nil, err - } - - for _, c := range constants { - if c.ID == id { - return c, nil - } - } - - return nil, fmt.Errorf("constant not found: %s", id) -} - -// GetRawUnit retrieves raw BSON data for a unit by ID as a map. -func (r *Reader) GetRawUnit(id model.ID) (map[string]any, error) { - // Try to get raw contents for the unit - var contents []byte - var err error - - if r.version == MPRVersionV2 { - // V2: Read from mprcontents folder - contents, err = r.readMprContents(string(id)) - if err != nil { - return nil, fmt.Errorf("failed to read unit contents: %w", err) - } - } else { - // V1: Read from database — convert UUID to GUID blob for the query - unitIDBlob := types.UUIDToBlob(string(id)) - row := r.db.QueryRow("SELECT Contents FROM Unit WHERE UnitID = ?", unitIDBlob) - err = row.Scan(&contents) - if err != nil { - return nil, fmt.Errorf("failed to read unit from database: %w", err) - } - } - - contents, err = r.resolveContents(string(id), contents) - if err != nil { - return nil, err - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - return raw, nil -} - -// ListScheduledEvents returns all scheduled events in the project. -func (r *Reader) ListScheduledEvents() ([]*model.ScheduledEvent, error) { - units, err := r.listUnitsByType("ScheduledEvents$ScheduledEvent") - if err != nil { - return nil, err - } - - var result []*model.ScheduledEvent - for _, u := range units { - event, err := r.parseScheduledEvent(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse scheduled event %s: %w", u.ID, err) - } - result = append(result, event) - } - - return result, nil -} - -// GetScheduledEvent retrieves a scheduled event by ID. -func (r *Reader) GetScheduledEvent(id model.ID) (*model.ScheduledEvent, error) { - events, err := r.ListScheduledEvents() - if err != nil { - return nil, err - } - - for _, e := range events { - if e.ID == id { - return e, nil - } - } - - return nil, fmt.Errorf("scheduled event not found: %s", id) -} - -// ListSnippets returns all snippets in the project. -func (r *Reader) ListSnippets() ([]*pages.Snippet, error) { - // Try Forms$Snippet first (Mendix 10+), then Pages$Snippet (older versions) - units, err := r.listUnitsByType("Forms$Snippet") - if err != nil { - return nil, err - } - if len(units) == 0 { - units, err = r.listUnitsByType("Pages$Snippet") - if err != nil { - return nil, err - } - } - - var result []*pages.Snippet - for _, u := range units { - snippet, err := r.parseSnippet(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse snippet %s: %w", u.ID, err) - } - result = append(result, snippet) - } - - return result, nil -} - -// GetProjectSecurity returns the project security configuration. -func (r *Reader) GetProjectSecurity() (*security.ProjectSecurity, error) { - units, err := r.listUnitsByType("Security$ProjectSecurity") - if err != nil { - return nil, err - } - - if len(units) == 0 { - return nil, fmt.Errorf("project security not found") - } - - return r.parseProjectSecurity(units[0].ID, units[0].ContainerID, units[0].Contents) -} - -// ListModuleSecurity returns all module security configurations. -func (r *Reader) ListModuleSecurity() ([]*security.ModuleSecurity, error) { - units, err := r.listUnitsByType("Security$ModuleSecurity") - if err != nil { - return nil, err - } - - var result []*security.ModuleSecurity - for _, u := range units { - ms, err := r.parseModuleSecurity(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse module security %s: %w", u.ID, err) - } - result = append(result, ms) - } - - return result, nil -} - -// ListConsumedODataServices returns all consumed OData services in the project. -func (r *Reader) ListConsumedODataServices() ([]*model.ConsumedODataService, error) { - units, err := r.listUnitsByType("Rest$ConsumedODataService") - if err != nil { - return nil, err - } - - var result []*model.ConsumedODataService - for _, u := range units { - svc, err := r.parseConsumedODataService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse consumed OData service %s: %w", u.ID, err) - } - result = append(result, svc) - } - - return result, nil -} - -// ListPublishedODataServices returns all published OData services in the project. -func (r *Reader) ListPublishedODataServices() ([]*model.PublishedODataService, error) { - units, err := r.listUnitsByType("ODataPublish$PublishedODataService2") - if err != nil { - return nil, err - } - - var result []*model.PublishedODataService - for _, u := range units { - svc, err := r.parsePublishedODataService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse published OData service %s: %w", u.ID, err) - } - result = append(result, svc) - } - - return result, nil -} - -// ListPublishedRestServices returns all published REST services in the project. -func (r *Reader) ListPublishedRestServices() ([]*model.PublishedRestService, error) { - units, err := r.listUnitsByType("Rest$PublishedRestService") - if err != nil { - return nil, err - } - - var result []*model.PublishedRestService - for _, u := range units { - svc, err := r.parsePublishedRestService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse published REST service %s: %w", u.ID, err) - } - result = append(result, svc) - } - - return result, nil -} - -// ListDataTransformers returns all data transformers in the project. -func (r *Reader) ListDataTransformers() ([]*model.DataTransformer, error) { - units, err := r.listUnitsByType("DataTransformers$DataTransformer") - if err != nil { - return nil, err - } - - var result []*model.DataTransformer - for _, u := range units { - dt, err := r.parseDataTransformer(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse data transformer %s: %w", u.ID, err) - } - result = append(result, dt) - } - - return result, nil -} - -// ListConsumedRestServices returns all consumed REST services in the project. -func (r *Reader) ListConsumedRestServices() ([]*model.ConsumedRestService, error) { - units, err := r.listUnitsByType("Rest$ConsumedRestService") - if err != nil { - return nil, err - } - - var result []*model.ConsumedRestService - for _, u := range units { - svc, err := r.parseConsumedRestService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse consumed REST service %s: %w", u.ID, err) - } - result = append(result, svc) - } - - return result, nil -} - -// ListWorkflows returns all workflows in the project. -func (r *Reader) ListWorkflows() ([]*workflows.Workflow, error) { - units, err := r.listUnitsByType("Workflows$Workflow") - if err != nil { - return nil, err - } - - var result []*workflows.Workflow - for _, u := range units { - wf, err := r.parseWorkflow(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse workflow %s: %w", u.ID, err) - } - result = append(result, wf) - } - - return result, nil -} - -// GetWorkflow retrieves a workflow by ID. -func (r *Reader) GetWorkflow(id model.ID) (*workflows.Workflow, error) { - wfs, err := r.ListWorkflows() - if err != nil { - return nil, err - } - - for _, wf := range wfs { - if wf.ID == id { - return wf, nil - } - } - - return nil, fmt.Errorf("workflow not found: %s", id) -} - -// ListBusinessEventServices returns all business event services in the project. -func (r *Reader) ListBusinessEventServices() ([]*model.BusinessEventService, error) { - units, err := r.listUnitsByType("BusinessEvents$BusinessEventService") - if err != nil { - return nil, err - } - - var result []*model.BusinessEventService - for _, u := range units { - svc, err := r.parseBusinessEventService(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse business event service %s: %w", u.ID, err) - } - result = append(result, svc) - } - - return result, nil -} - -// ListDatabaseConnections returns all database connections in the project. -func (r *Reader) ListDatabaseConnections() ([]*model.DatabaseConnection, error) { - units, err := r.listUnitsByType("DatabaseConnector$DatabaseConnection") - if err != nil { - return nil, err - } - - var result []*model.DatabaseConnection - for _, u := range units { - conn, err := r.parseDBConnection(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse database connection %s: %w", u.ID, err) - } - result = append(result, conn) - } - - return result, nil -} - -// GetProjectSettings returns the project settings. -func (r *Reader) GetProjectSettings() (*model.ProjectSettings, error) { - units, err := r.listUnitsByType("Settings$ProjectSettings") - if err != nil { - return nil, err - } - - if len(units) == 0 { - return nil, fmt.Errorf("project settings not found") - } - - return r.parseProjectSettings(units[0].ID, units[0].ContainerID, units[0].Contents) -} - -// GetModuleSecurity returns the module security for a given module ID. -func (r *Reader) GetModuleSecurity(moduleID model.ID) (*security.ModuleSecurity, error) { - allMS, err := r.ListModuleSecurity() - if err != nil { - return nil, err - } - - for _, ms := range allMS { - if ms.ContainerID == moduleID { - return ms, nil - } - } - - return nil, fmt.Errorf("module security not found for module: %s", moduleID) -} - -// ListImportMappings returns all import mapping documents in the project. -func (r *Reader) ListImportMappings() ([]*model.ImportMapping, error) { - units, err := r.listUnitsByType("ImportMappings$ImportMapping") - if err != nil { - return nil, err - } - - var result []*model.ImportMapping - for _, u := range units { - im, err := r.parseImportMapping(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse import mapping %s: %w", u.ID, err) - } - result = append(result, im) - } - return result, nil -} - -// GetImportMappingByQualifiedName retrieves an import mapping by its qualified name (Module.Name). -func (r *Reader) GetImportMappingByQualifiedName(moduleName, name string) (*model.ImportMapping, error) { - all, err := r.ListImportMappings() - if err != nil { - return nil, err - } - - moduleMap, err := r.buildContainerModuleNameMap() - if err != nil { - return nil, err - } - - for _, im := range all { - if im.Name == name && moduleMap[im.ContainerID] == moduleName { - return im, nil - } - } - return nil, fmt.Errorf("import mapping %s.%s not found", moduleName, name) -} - -// ListExportMappings returns all export mapping documents in the project. -func (r *Reader) ListExportMappings() ([]*model.ExportMapping, error) { - units, err := r.listUnitsByType("ExportMappings$ExportMapping") - if err != nil { - return nil, err - } - - var result []*model.ExportMapping - for _, u := range units { - em, err := r.parseExportMapping(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse export mapping %s: %w", u.ID, err) - } - result = append(result, em) - } - return result, nil -} - -// GetExportMappingByQualifiedName retrieves an export mapping by its qualified name (Module.Name). -func (r *Reader) GetExportMappingByQualifiedName(moduleName, name string) (*model.ExportMapping, error) { - all, err := r.ListExportMappings() - if err != nil { - return nil, err - } - - moduleMap, err := r.buildContainerModuleNameMap() - if err != nil { - return nil, err - } - - for _, em := range all { - if em.Name == name && moduleMap[em.ContainerID] == moduleName { - return em, nil - } - } - return nil, fmt.Errorf("export mapping %s.%s not found", moduleName, name) -} - -// buildContainerModuleNameMap builds a map from any container ID (including folders) -// to the enclosing module name, by walking the containment hierarchy. -// This handles documents nested inside folders within modules. -func (r *Reader) buildContainerModuleNameMap() (map[model.ID]string, error) { - modules, err := r.ListModules() - if err != nil { - return nil, err - } - - // Build module ID → name and module ID set - moduleNames := make(map[model.ID]string, len(modules)) - for _, m := range modules { - moduleNames[m.ID] = m.Name - } - - // Build container → parent map from all units - units, err := r.ListUnits() - if err != nil { - return nil, err - } - parentOf := make(map[model.ID]model.ID, len(units)) - for _, u := range units { - parentOf[u.ID] = u.ContainerID - } - - // Walk up from any container ID to find the enclosing module name - result := make(map[model.ID]string) - var findModule func(id model.ID) string - findModule = func(id model.ID) string { - if cached, ok := result[id]; ok { - return cached - } - if name, ok := moduleNames[id]; ok { - result[id] = name - return name - } - parent, ok := parentOf[id] - if !ok || parent == id { - return "" - } - name := findModule(parent) - result[id] = name - return name - } - - // Pre-populate for all units so callers just do a single map lookup - for _, u := range units { - findModule(u.ContainerID) - } - - return result, nil -} - -// ListModuleSettings returns all Projects$ModuleSettings documents in the project. -func (r *Reader) ListModuleSettings() ([]*types.ModuleSettings, error) { - units, err := r.listUnitsByType("Projects$ModuleSettings") - if err != nil { - return nil, err - } - var result []*types.ModuleSettings - for _, u := range units { - ms, err := r.parseModuleSettings(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, err - } - result = append(result, ms) - } - return result, nil -} - -// GetModuleSettings returns the Projects$ModuleSettings for the given module ID. -func (r *Reader) GetModuleSettings(moduleID model.ID) (*types.ModuleSettings, error) { - units, err := r.listUnitsByType("Projects$ModuleSettings") - if err != nil { - return nil, err - } - for _, u := range units { - if u.ContainerID == string(moduleID) { - return r.parseModuleSettings(u.ID, u.ContainerID, u.Contents) - } - } - return nil, fmt.Errorf("module settings not found for module: %s", moduleID) -} - -func (r *Reader) parseModuleSettings(id, containerID string, contents []byte) (*types.ModuleSettings, error) { - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal module settings: %w", err) - } - - ms := &types.ModuleSettings{ - ID: model.ID(id), - ContainerID: model.ID(containerID), - ExportLevel: extractString(raw["ExportLevel"]), - ProtectedModuleType: extractString(raw["ProtectedModuleType"]), - Version: extractString(raw["Version"]), - BasedOnVersion: extractString(raw["BasedOnVersion"]), - ExtensionName: extractString(raw["ExtensionName"]), - SolutionIdentifier: extractString(raw["SolutionIdentifier"]), - } - if ms.ExportLevel == "" { - ms.ExportLevel = "Source" - } - if ms.ProtectedModuleType == "" { - ms.ProtectedModuleType = "AddOn" - } - if ms.Version == "" { - ms.Version = "1.0.0" - } - - if arr, ok := raw["JarDependencies"].(bson.A); ok { - for _, item := range arr { - if dep, ok := item.(map[string]any); ok { - jd := parseJarDependency(dep) - if jd != nil { - ms.JarDependencies = append(ms.JarDependencies, jd) - } - } - } - } - - return ms, nil -} - -func parseJarDependency(raw map[string]any) *types.JarDependency { - if raw["$Type"] == nil { - return nil - } - jd := &types.JarDependency{ - ID: model.ID(extractBsonID(raw["$ID"])), - GroupID: extractString(raw["GroupId"]), - ArtifactID: extractString(raw["ArtifactId"]), - Version: extractString(raw["Version"]), - IsIncluded: extractBool(raw["IsIncluded"], true), - } - if excArr, ok := raw["Exclusions"].(bson.A); ok { - for _, item := range excArr { - if excRaw, ok := item.(map[string]any); ok { - exc := parseJarDependencyExclusion(excRaw) - if exc != nil { - jd.Exclusions = append(jd.Exclusions, exc) - } - } - } - } - return jd -} - -func parseJarDependencyExclusion(raw map[string]any) *types.JarDependencyExclusion { - if raw["$Type"] == nil { - return nil - } - return &types.JarDependencyExclusion{ - ID: model.ID(extractBsonID(raw["$ID"])), - GroupID: extractString(raw["GroupId"]), - ArtifactID: extractString(raw["ArtifactId"]), - } -} - -// ListMenuDocuments returns all standalone Menus$MenuDocument documents. -// -// A menu document holds its entries in a Menus$MenuItemCollection rather than -// directly, but the entries themselves are ordinary Menus$MenuItem elements, so -// the recursive conversion reuses parseNavMenuItem. -func (r *Reader) ListMenuDocuments() ([]*types.MenuDocument, error) { - units, err := r.listUnitsByType("Menus$MenuDocument") - if err != nil { - return nil, err - } - - result := make([]*types.MenuDocument, 0, len(units)) - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - return nil, fmt.Errorf("failed to parse menu document %s: %w", u.ID, err) - } - md := &types.MenuDocument{ - ID: model.ID(u.ID), - ContainerID: model.ID(u.ContainerID), - Name: extractString(raw["Name"]), - Documentation: extractString(raw["Documentation"]), - ExportLevel: extractString(raw["ExportLevel"]), - } - if b, ok := raw["Excluded"].(bool); ok { - md.Excluded = b - } - if coll, ok := raw["ItemCollection"].(map[string]any); ok { - for _, item := range extractBsonArray(coll["Items"]) { - if m, ok := item.(map[string]any); ok { - if mi := parseNavMenuItem(m); mi != nil { - md.Items = append(md.Items, mi) - } - } - } - } - result = append(result, md) - } - return result, nil -} - -// GetMenuDocumentByQualifiedName finds a menu document by module + name. -func (r *Reader) GetMenuDocumentByQualifiedName(moduleName, name string) (*types.MenuDocument, error) { - all, err := r.ListMenuDocuments() - if err != nil { - return nil, err - } - moduleMap, err := r.buildContainerModuleNameMap() - if err != nil { - return nil, err - } - for _, md := range all { - if md.Name == name && moduleMap[md.ContainerID] == moduleName { - return md, nil - } - } - return nil, fmt.Errorf("menu not found: %s.%s", moduleName, name) -} diff --git a/sdk/mpr/reader_types.go b/sdk/mpr/reader_types.go deleted file mode 100644 index 9cbbaf54d7..0000000000 --- a/sdk/mpr/reader_types.go +++ /dev/null @@ -1,451 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Reader methods for listing and querying model units. -package mpr - -import ( - "encoding/json" - "fmt" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// Type aliases for backward compatibility — these types are now defined in mdl/types. -type ( - JavaAction = types.JavaAction - JavaScriptAction = types.JavaScriptAction - NavigationDocument = types.NavigationDocument - NavigationProfile = types.NavigationProfile - NavHomePage = types.NavHomePage - NavRoleBasedHome = types.NavRoleBasedHome - NavMenuItem = types.NavMenuItem - NavOfflineEntity = types.NavOfflineEntity - JsonStructure = types.JsonStructure - JsonElement = types.JsonElement - ImageCollection = types.ImageCollection - Image = types.Image - FolderInfo = types.FolderInfo - UnitInfo = types.UnitInfo - RawUnit = types.RawUnit - ProjectVersion = types.ProjectVersion -) - -// ListJavaActions returns all Java actions in the project, including virtual System module actions. -func (r *Reader) ListJavaActions() ([]*types.JavaAction, error) { - units, err := r.listUnitsByType("JavaActions$JavaAction") - if err != nil { - return nil, err - } - - var result []*types.JavaAction - for _, u := range units { - ja, err := r.parseJavaAction(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse java action %s: %w", u.ID, err) - } - result = append(result, ja) - } - - // Append virtual System module Java actions (not stored in the MPR database) - result = append(result, BuildSystemJavaActions()...) - - return result, nil -} - -// ListJavaScriptActions returns all JavaScript actions in the project. -func (r *Reader) ListJavaScriptActions() ([]*types.JavaScriptAction, error) { - units, err := r.listUnitsByType("JavaScriptActions$JavaScriptAction") - if err != nil { - return nil, err - } - - var result []*types.JavaScriptAction - for _, u := range units { - jsa, err := r.parseJavaScriptAction(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse javascript action %s: %w", u.ID, err) - } - result = append(result, jsa) - } - - return result, nil -} - -// ListBuildingBlocks returns all building blocks in the project. -func (r *Reader) ListBuildingBlocks() ([]*pages.BuildingBlock, error) { - // Try Pages$BuildingBlock first (current storage name), then Forms$BuildingBlock (older versions) - units, err := r.listUnitsByType("Pages$BuildingBlock") - if err != nil { - return nil, err - } - if len(units) == 0 { - units, err = r.listUnitsByType("Forms$BuildingBlock") - if err != nil { - return nil, err - } - } - - var result []*pages.BuildingBlock - for _, u := range units { - bb, err := r.parseBuildingBlock(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse building block %s: %w", u.ID, err) - } - result = append(result, bb) - } - - return result, nil -} - -// ListPageTemplates returns all page templates in the project. -func (r *Reader) ListPageTemplates() ([]*pages.PageTemplate, error) { - units, err := r.listUnitsByType("Forms$PageTemplate") - if err != nil { - return nil, err - } - - var result []*pages.PageTemplate - for _, u := range units { - pt, err := r.parsePageTemplate(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse page template %s: %w", u.ID, err) - } - result = append(result, pt) - } - - return result, nil -} - -// ListNavigationDocuments returns all navigation documents in the project. -func (r *Reader) ListNavigationDocuments() ([]*types.NavigationDocument, error) { - units, err := r.listUnitsByType("Navigation$NavigationDocument") - if err != nil { - return nil, err - } - - var result []*types.NavigationDocument - for _, u := range units { - nav, err := r.parseNavigationDocument(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse navigation document %s: %w", u.ID, err) - } - result = append(result, nav) - } - - return result, nil -} - -// GetNavigation returns the project's navigation document (singleton). -func (r *Reader) GetNavigation() (*types.NavigationDocument, error) { - docs, err := r.ListNavigationDocuments() - if err != nil { - return nil, err - } - if len(docs) == 0 { - return nil, fmt.Errorf("no navigation document found") - } - return docs[0], nil -} - -// ListImageCollections returns all image collections in the project. -func (r *Reader) ListImageCollections() ([]*types.ImageCollection, error) { - units, err := r.listUnitsByType("Images$ImageCollection") - if err != nil { - return nil, err - } - - var result []*types.ImageCollection - for _, u := range units { - ic, err := r.parseImageCollection(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse image collection %s: %w", u.ID, err) - } - result = append(result, ic) - } - - return result, nil -} - -// ListIconCollections returns all icon collections (CustomIcons$ -// CustomIconCollection) in the project — read-only, for SHOW / DESCRIBE ICON -// COLLECTION. Mirrors the modelsdk backend's reader. -func (r *Reader) ListIconCollections() ([]*types.IconCollection, error) { - units, err := r.listUnitsByType("CustomIcons$CustomIconCollection") - if err != nil { - return nil, err - } - var result []*types.IconCollection - for _, u := range units { - var doc bson.M - if err := bson.Unmarshal(u.Contents, &doc); err != nil { - return nil, fmt.Errorf("failed to parse icon collection %s: %w", u.ID, err) - } - ic := &types.IconCollection{ContainerID: model.ID(u.ContainerID)} - ic.ID = model.ID(u.ID) - ic.TypeName = "CustomIcons$CustomIconCollection" - ic.Name, _ = doc["Name"].(string) - ic.Prefix, _ = doc["Prefix"].(string) - ic.Documentation, _ = doc["Documentation"].(string) - ic.ExportLevel, _ = doc["ExportLevel"].(string) - if arr, ok := doc["Icons"].(bson.A); ok { - for _, el := range arr { - iconDoc, ok := el.(bson.M) - if !ok { - continue - } - item := types.IconItem{} - item.Name, _ = iconDoc["Name"].(string) - switch cc := iconDoc["CharacterCode"].(type) { - case int32: - item.CharacterCode = int(cc) - case int64: - item.CharacterCode = int(cc) - } - if tags, ok := iconDoc["Tags"].(bson.A); ok { - for _, t := range tags { - if s, ok := t.(string); ok { - item.Tags = append(item.Tags, s) - } - } - } - ic.Icons = append(ic.Icons, item) - } - } - result = append(result, ic) - } - return result, nil -} - -// ListXmlSchemas returns all XML schema documents in the project. -// -// Read directly from the raw unit rather than through a parser, because the two -// fields anything needs — Name and FilePath — are top-level strings and the -// element tree is not something mxcli reads. Verified against mxbuild 11.13.0 by -// planting a synthetic XmlSchemas$XmlSchema unit carrying exactly these keys: a -// mapping's `with xml schema` reference to it stopped being CE1613 "no longer -// exists" and became CE0292 "Please import an XSD file", which is mxbuild -// naming the document it found. -func (r *Reader) ListXmlSchemas() ([]*types.XmlSchema, error) { - units, err := r.listUnitsByType("XmlSchemas$XmlSchema") - if err != nil { - return nil, err - } - moduleMap, err := r.buildContainerModuleNameMap() - if err != nil { - return nil, err - } - result := make([]*types.XmlSchema, 0, len(units)) - for _, u := range units { - var raw map[string]interface{} - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - return nil, fmt.Errorf("failed to parse XML schema %s: %w", u.ID, err) - } - xs := &types.XmlSchema{ - ContainerID: model.ID(u.ContainerID), - Module: moduleMap[model.ID(u.ContainerID)], - } - xs.ID = model.ID(u.ID) - xs.TypeName = "XmlSchemas$XmlSchema" - if v, ok := raw["Name"].(string); ok { - xs.Name = v - } - if v, ok := raw["Documentation"].(string); ok { - xs.Documentation = v - } - if v, ok := raw["FilePath"].(string); ok { - xs.FilePath = v - } - result = append(result, xs) - } - return result, nil -} - -// ListJsonStructures returns all JSON structures in the project. -func (r *Reader) ListJsonStructures() ([]*types.JsonStructure, error) { - units, err := r.listUnitsByType("JsonStructures$JsonStructure") - if err != nil { - return nil, err - } - - var result []*types.JsonStructure - for _, u := range units { - js, err := r.parseJsonStructure(u.ID, u.ContainerID, u.Contents) - if err != nil { - return nil, fmt.Errorf("failed to parse JSON structure %s: %w", u.ID, err) - } - result = append(result, js) - } - - return result, nil -} - -// GetJsonStructureByQualifiedName retrieves a JSON structure by its qualified name (Module.Name). -// Resolves folder containment: the stored ContainerID may be a folder inside -// the module, not the module itself, so we map container IDs through the -// module hierarchy before matching on module name. -func (r *Reader) GetJsonStructureByQualifiedName(moduleName, name string) (*types.JsonStructure, error) { - all, err := r.ListJsonStructures() - if err != nil { - return nil, err - } - - moduleMap, err := r.buildContainerModuleNameMap() - if err != nil { - return nil, err - } - - for _, js := range all { - if js.Name == name && moduleMap[js.ContainerID] == moduleName { - return js, nil - } - } - return nil, fmt.Errorf("JSON structure %s.%s not found", moduleName, name) -} - -// ListRawUnitsByType returns all raw units matching the given type prefix, -// including their BSON contents. This is useful for scanning BSON directly -// without full parsing. -func (r *Reader) ListRawUnitsByType(typePrefix string) ([]*types.RawUnit, error) { - units, err := r.listUnitsByType(typePrefix) - if err != nil { - return nil, err - } - - var result []*types.RawUnit - for _, u := range units { - contents, err := r.resolveContents(u.ID, u.Contents) - if err != nil { - continue - } - result = append(result, &types.RawUnit{ - ID: model.ID(u.ID), - ContainerID: model.ID(u.ContainerID), - Type: u.Type, - Contents: contents, - }) - } - return result, nil -} - -// ListUnits returns all units with their IDs and types. -func (r *Reader) ListUnits() ([]*types.UnitInfo, error) { - units, err := r.listUnitsByType("") - if err != nil { - return nil, err - } - - var result []*types.UnitInfo - for _, u := range units { - result = append(result, &types.UnitInfo{ - ID: model.ID(u.ID), - ContainerID: model.ID(u.ContainerID), - ContainmentName: u.ContainmentName, - Type: u.Type, - }) - } - - return result, nil -} - -// ListFolders returns all project folders with their names. -func (r *Reader) ListFolders() ([]*types.FolderInfo, error) { - units, err := r.listUnitsByType("Projects$Folder") - if err != nil { - return nil, err - } - - var result []*types.FolderInfo - for _, u := range units { - name := "" - if len(u.Contents) > 0 { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err == nil { - if n, ok := raw["Name"].(string); ok { - name = n - } - } - } - result = append(result, &types.FolderInfo{ - ID: model.ID(u.ID), - ContainerID: model.ID(u.ContainerID), - Name: name, - }) - } - - return result, nil -} - -// ExportJSON exports the entire model as JSON. -func (r *Reader) ExportJSON() ([]byte, error) { - modules, err := r.ListModules() - if err != nil { - modules = nil // Continue even if modules fail - } - - domainModels, err := r.ListDomainModels() - if err != nil { - domainModels = nil - } - - microflowsList, err := r.ListMicroflows() - if err != nil { - microflowsList = nil - } - - nanoflows, err := r.ListNanoflows() - if err != nil { - nanoflows = nil - } - - pagesList, err := r.ListPages() - if err != nil { - pagesList = nil - } - - layouts, err := r.ListLayouts() - if err != nil { - layouts = nil - } - - enumerations, err := r.ListEnumerations() - if err != nil { - enumerations = nil - } - - constants, err := r.ListConstants() - if err != nil { - constants = nil - } - - export := map[string]any{ - "modules": modules, - "domainModels": domainModels, - "microflows": microflowsList, - "nanoflows": nanoflows, - "pages": pagesList, - "layouts": layouts, - "enumerations": enumerations, - "constants": constants, - } - - return json.MarshalIndent(export, "", " ") -} - -// GetUnitTypes returns a count of units by type. -func (r *Reader) GetUnitTypes() (map[string]int, error) { - units, err := r.listUnitsByType("") - if err != nil { - return nil, err - } - - counts := make(map[string]int) - for _, u := range units { - counts[u.Type]++ - } - - return counts, nil -} diff --git a/sdk/mpr/reader_units.go b/sdk/mpr/reader_units.go deleted file mode 100644 index 6f7c455f78..0000000000 --- a/sdk/mpr/reader_units.go +++ /dev/null @@ -1,684 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Unit listing infrastructure for Reader. -package mpr - -import ( - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "go.mongodb.org/mongo-driver/bson" -) - -// resolveModuleName walks the container hierarchy upward until it finds a module. -// This is necessary because in MPR v2 projects, documents live inside folders, -// so a document's direct ContainerID is a folder, not the module. -func resolveModuleName(containerID string, moduleMap map[string]string, containerParent map[string]string) string { - current := containerID - for range 20 { - if name, ok := moduleMap[current]; ok { - return name - } - parent, ok := containerParent[current] - if !ok || parent == current { - break - } - current = parent - } - return "" -} - -// buildContainerParent builds a map of unit ID → parent container ID for hierarchy walking. -func (r *Reader) buildContainerParent() (map[string]string, error) { - units, err := r.ListUnits() - if err != nil { - return nil, err - } - containerParent := make(map[string]string, len(units)) - for _, u := range units { - containerParent[string(u.ID)] = string(u.ContainerID) - } - return containerParent, nil -} - -// rawUnit holds raw unit data from the database. -type rawUnit struct { - ID string - ContainerID string - ContainmentName string - Type string - Contents []byte -} - -// listUnitsByType returns all units of exactly the given storage type. An empty -// typeName returns every unit. -// -// The match is exact, and that is load-bearing rather than incidental: this used -// to be a prefix match, and `Forms$Page` is a prefix of `Forms$PageTemplate`, so -// ListPages swept in all 46 of Atlas_Web_Content's page templates. They then -// described as pages with an empty body — the template's content hangs off -// LayoutCall, which the page path does not read — so `show modules` reported 46 -// pages for a module with none, and anything comparing describe output judged a -// template unchanged without having looked at it. -// -// Mendix storage names nest this way in general (`Forms$Page` / -// `Forms$PageTemplate`), so a prefix match here is a trap for every future type, -// not a one-off. -func (r *Reader) listUnitsByType(typeName string) ([]rawUnit, error) { - if r.version == MPRVersionV2 { - return r.listUnitsByTypeV2(typeName) - } - return r.listUnitsByTypeV1(typeName) -} - -// listUnitsByTypeV1 handles MPR v1 format (contents in database). -func (r *Reader) listUnitsByTypeV1(typeName string) ([]rawUnit, error) { - rows, err := r.db.Query(` - SELECT UnitID, ContainerID, ContainmentName, Contents - FROM Unit - `) - if err != nil { - return nil, fmt.Errorf("failed to query units: %w", err) - } - defer rows.Close() - - var units []rawUnit - for rows.Next() { - var unitID, containerID []byte - var containmentName string - var contents []byte - - if err := rows.Scan(&unitID, &containerID, &containmentName, &contents); err != nil { - return nil, fmt.Errorf("failed to scan unit row: %w", err) - } - - unitType := getTypeFromContents(contents) - if typeName == "" || unitType == typeName { - units = append(units, rawUnit{ - ID: blobToUUID(unitID), - ContainerID: blobToUUID(containerID), - ContainmentName: containmentName, - Type: unitType, - Contents: contents, - }) - } - } - - return units, nil -} - -// listUnitsByTypeV2 handles MPR v2 format (contents in mprcontents folder). -// Uses caching to avoid reading every file for each query. -func (r *Reader) listUnitsByTypeV2(typeName string) ([]rawUnit, error) { - // Build cache if not valid - if !r.unitCacheValid { - if err := r.buildUnitCache(); err != nil { - return nil, err - } - } - - // Filter by type using cache, only read contents for matching units - var units []rawUnit - for _, cu := range r.unitCache { - if typeName == "" || cu.Type == typeName { - // Read contents from mprcontents folder - // Note: cu.ID is already in the correct swapped format from blobToUUID - contents, err := r.readMprContents(cu.ID) - if err != nil { - // Skip units with missing content files - continue - } - - units = append(units, rawUnit{ - ID: cu.ID, - ContainerID: cu.ContainerID, - ContainmentName: cu.ContainmentName, - Type: cu.Type, - Contents: contents, - }) - } - } - - return units, nil -} - -// buildUnitCache reads all unit metadata once and caches it. -func (r *Reader) buildUnitCache() error { - rows, err := r.db.Query(` - SELECT UnitID, ContainerID, ContainmentName - FROM Unit - `) - if err != nil { - return fmt.Errorf("failed to query units: %w", err) - } - defer rows.Close() - - r.unitCache = nil - for rows.Next() { - var unitID, containerID []byte - var containmentName string - - if err := rows.Scan(&unitID, &containerID, &containmentName); err != nil { - return fmt.Errorf("failed to scan unit row: %w", err) - } - - // Convert UnitID to UUID string - unitUUID := blobToUUID(unitID) - - // Read contents to get type (only done once during cache build) - contents, err := r.readMprContents(unitUUID) - if err != nil { - // Skip units with missing content files - continue - } - - typeName := getTypeFromContents(contents) - r.unitCache = append(r.unitCache, cachedUnit{ - ID: blobToUUID(unitID), - ContainerID: blobToUUID(containerID), - ContainmentName: containmentName, - Type: typeName, - }) - } - - r.unitCacheValid = true - return nil -} - -// InvalidateCache marks the unit cache as invalid. -// Should be called after any write operation. -func (r *Reader) InvalidateCache() { - r.unitCacheValid = false - r.nameIndex = nil - r.nameIndexBuilt = false -} - -// readMprContents reads content from the mprcontents folder for v2 format. -// The path is: mprcontents/XX/YY/UUID.mxunit where XX and YY are first two chars of UUID. -func (r *Reader) readMprContents(unitUUID string) ([]byte, error) { - if len(unitUUID) < 4 { - return nil, fmt.Errorf("invalid unit UUID: %s", unitUUID) - } - - // Build path: mprcontents/XX/YY/UUID.mxunit - // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - // First two chars are positions 0-1, next two are positions 2-3 - path := filepath.Join( - r.contentsDir, - unitUUID[0:2], - unitUUID[2:4], - unitUUID+".mxunit", - ) - - return os.ReadFile(path) -} - -// getTypeFromContents extracts the $Type field from BSON contents. -func getTypeFromContents(contents []byte) string { - if len(contents) == 0 { - return "" - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - return "" - } - - if typeName, ok := raw["$Type"].(string); ok { - return typeName - } - return "" -} - -// nameIndexEntry records where a named unit lives. -type nameIndexEntry struct { - id string - moduleName string -} - -// nameHeader decodes only the unit's Name field. Decoding into this small -// struct is much cheaper than unmarshalling the whole document into -// map[string]any (no map allocation, far less reflection). -type nameHeader struct { - Name string `bson:"Name"` -} - -// buildUnitNameIndex parses every unit's name once and indexes units by -// "$Type\x00QualifiedName", so per-name lookups are O(1) instead of re-reading -// and re-parsing every unit on each call. Idempotent; invalidated by -// InvalidateCache after writes. -func (r *Reader) buildUnitNameIndex() error { - if r.nameIndexBuilt { - return nil - } - units, err := r.listUnitsByType("") - if err != nil { - return err - } - modules, err := r.ListModules() - if err != nil { - return err - } - moduleMap := make(map[string]string, len(modules)) - for _, m := range modules { - moduleMap[string(m.ID)] = m.Name - } - containerParent, err := r.buildContainerParent() - if err != nil { - return err - } - - idx := make(map[string]nameIndexEntry, len(units)) - for _, u := range units { - var h nameHeader - if err := bson.Unmarshal(u.Contents, &h); err != nil || h.Name == "" { - continue - } - moduleName := resolveModuleName(u.ContainerID, moduleMap, containerParent) - qn := h.Name - if moduleName != "" { - qn = moduleName + "." + h.Name - } - idx[u.Type+"\x00"+qn] = nameIndexEntry{id: u.ID, moduleName: moduleName} - } - r.nameIndex = idx - r.nameIndexBuilt = true - return nil -} - -// lookupUnitByName resolves a (type, qualified name) to its unit via the name -// index. Returns (nil, "", nil) when not found. -func (r *Reader) lookupUnitByName(typePrefix, qualifiedName string) (*rawUnit, string, error) { - if err := r.buildUnitNameIndex(); err != nil { - return nil, "", err - } - e, ok := r.nameIndex[typePrefix+"\x00"+qualifiedName] - if !ok { - return nil, "", nil - } - u, err := r.getUnitByID(e.id) - if err != nil { - return nil, "", err - } - return u, e.moduleName, nil -} - -// GetRawMicroflowByName returns the raw BSON contents for a microflow by qualified name. -// Used for debugging to compare serialized data. -func (r *Reader) GetRawMicroflowByName(qualifiedName string) ([]byte, error) { - u, _, err := r.lookupUnitByName("Microflows$Microflow", qualifiedName) - if err != nil { - return nil, err - } - if u == nil { - return nil, fmt.Errorf("microflow not found: %s", qualifiedName) - } - return u.Contents, nil -} - -// RawUnitInfo contains information about a raw unit for BSON debugging. -type RawUnitInfo struct { - ID string - QualifiedName string - Type string - ModuleName string - Contents []byte -} - -// GetRawUnitByName returns the raw BSON contents for a unit by qualified name. -// Supported types: page, entity, microflow, nanoflow, enumeration, association, snippet, constant. -// Used for debugging BSON serialization issues. -func (r *Reader) GetRawUnitByName(objectType, qualifiedName string) (*RawUnitInfo, error) { - var typePrefix string - switch strings.ToLower(objectType) { - case "page": - typePrefix = "Forms$Page" - case "entity": - typePrefix = "DomainModels$Entity" - case "association": - typePrefix = "DomainModels$Association" - case "microflow": - typePrefix = "Microflows$Microflow" - case "nanoflow": - typePrefix = "Microflows$Nanoflow" - case "enumeration": - typePrefix = "Enumerations$Enumeration" - case "snippet": - typePrefix = "Forms$Snippet" - case "layout": - typePrefix = "Forms$Layout" - case "constant": - typePrefix = "Constants$Constant" - case "workflow": - typePrefix = "Workflows$Workflow" - case "imagecollection": - typePrefix = "Images$ImageCollection" - case "javaaction": - typePrefix = "JavaActions$JavaAction" - case "javascriptaction": - typePrefix = "JavaScriptActions$JavaScriptAction" - default: - return nil, fmt.Errorf("unsupported object type: %s", objectType) - } - - // For entities and associations, we need to search within domain models - switch strings.ToLower(objectType) { - case "entity": - return r.getRawEntityByName(qualifiedName) - case "association": - return r.getRawAssociationByName(qualifiedName) - } - - u, moduleName, err := r.lookupUnitByName(typePrefix, qualifiedName) - if err != nil { - return nil, err - } - if u == nil { - return nil, fmt.Errorf("%s not found: %s", objectType, qualifiedName) - } - return &RawUnitInfo{ - ID: u.ID, - QualifiedName: qualifiedName, - Type: u.Type, - ModuleName: moduleName, - Contents: u.Contents, - }, nil -} - -// getRawEntityByName finds an entity within domain models. -func (r *Reader) getRawEntityByName(qualifiedName string) (*RawUnitInfo, error) { - // Split qualified name - parts := strings.Split(qualifiedName, ".") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid entity name: %s (expected Module.Entity)", qualifiedName) - } - targetModule := parts[0] - targetEntity := parts[1] - - // Get domain models - units, err := r.listUnitsByType("DomainModels$DomainModel") - if err != nil { - return nil, err - } - - // Build module name map - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleMap := make(map[string]string) - for _, m := range modules { - moduleMap[string(m.ID)] = m.Name - } - - for _, u := range units { - moduleName := moduleMap[u.ContainerID] - if moduleName != targetModule { - continue - } - - // Parse domain model to find entity. - // Unmarshal into bson.D so nested documents remain bson.D (not map[string]interface{}). - var rawD bson.D - if err := bson.Unmarshal(u.Contents, &rawD); err != nil { - continue - } - - var entitiesVal any - for _, field := range rawD { - if field.Key == "Entities" { - entitiesVal = field.Value - break - } - } - - entities, ok := entitiesVal.(bson.A) - if !ok { - continue - } - - // Skip version marker (first element is int32 array type indicator) - for i := 1; i < len(entities); i++ { - entity, ok := entities[i].(bson.D) - if !ok { - continue - } - - for _, field := range entity { - if field.Key == "Name" { - if name, ok := field.Value.(string); ok && name == targetEntity { - // Found the entity - serialize it back to BSON - entityBytes, err := bson.Marshal(entity) - if err != nil { - return nil, err - } - return &RawUnitInfo{ - ID: u.ID, - QualifiedName: qualifiedName, - Type: "DomainModels$Entity", - ModuleName: moduleName, - Contents: entityBytes, - }, nil - } - } - } - } - } - - return nil, fmt.Errorf("entity not found: %s", qualifiedName) -} - -// getRawAssociationByName finds an association within domain models. -func (r *Reader) getRawAssociationByName(qualifiedName string) (*RawUnitInfo, error) { - parts := strings.Split(qualifiedName, ".") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid association name: %s (expected Module.AssociationName)", qualifiedName) - } - targetModule := parts[0] - targetAssoc := parts[1] - - units, err := r.listUnitsByType("DomainModels$DomainModel") - if err != nil { - return nil, err - } - - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleMap := make(map[string]string) - for _, m := range modules { - moduleMap[string(m.ID)] = m.Name - } - - for _, u := range units { - moduleName := moduleMap[u.ContainerID] - if moduleName != targetModule { - continue - } - - // Unmarshal into bson.D so nested documents remain bson.D (not map[string]interface{}). - var rawD bson.D - if err := bson.Unmarshal(u.Contents, &rawD); err != nil { - continue - } - - var assocsVal any - for _, field := range rawD { - if field.Key == "Associations" { - assocsVal = field.Value - break - } - } - - assocs, ok := assocsVal.(bson.A) - if !ok { - continue - } - - // Skip version marker (first element is int32 array type indicator) - for i := 1; i < len(assocs); i++ { - assoc, ok := assocs[i].(bson.D) - if !ok { - continue - } - - for _, field := range assoc { - if field.Key == "Name" { - if name, ok := field.Value.(string); ok && name == targetAssoc { - assocBytes, err := bson.Marshal(assoc) - if err != nil { - return nil, err - } - return &RawUnitInfo{ - ID: u.ID, - QualifiedName: qualifiedName, - Type: "DomainModels$Association", - ModuleName: moduleName, - Contents: assocBytes, - }, nil - } - } - } - } - } - - return nil, fmt.Errorf("association not found: %s", qualifiedName) -} - -// ListRawUnits returns all units of a given type for BSON debugging. -func (r *Reader) ListRawUnits(objectType string) ([]*RawUnitInfo, error) { - var typePrefix string - switch strings.ToLower(objectType) { - case "page": - typePrefix = "Forms$Page" - case "microflow": - typePrefix = "Microflows$Microflow" - case "nanoflow": - typePrefix = "Microflows$Nanoflow" - case "enumeration": - typePrefix = "Enumerations$Enumeration" - case "snippet": - typePrefix = "Forms$Snippet" - case "layout": - typePrefix = "Forms$Layout" - case "workflow": - typePrefix = "Workflows$Workflow" - case "imagecollection": - typePrefix = "Images$ImageCollection" - case "": - typePrefix = "" - default: - return nil, fmt.Errorf("unsupported object type: %s", objectType) - } - - units, err := r.listUnitsByType(typePrefix) - if err != nil { - return nil, err - } - - // Build module name map and container hierarchy for MPR v2 folder support. - modules, err := r.ListModules() - if err != nil { - return nil, err - } - moduleMap := make(map[string]string) - for _, m := range modules { - moduleMap[string(m.ID)] = m.Name - } - containerParent, err := r.buildContainerParent() - if err != nil { - return nil, err - } - - var result []*RawUnitInfo - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - name, _ := raw["Name"].(string) - moduleName := resolveModuleName(u.ContainerID, moduleMap, containerParent) - fullName := name - if moduleName != "" { - fullName = moduleName + "." + name - } - - result = append(result, &RawUnitInfo{ - ID: u.ID, - QualifiedName: fullName, - Type: u.Type, - ModuleName: moduleName, - Contents: u.Contents, - }) - } - - return result, nil -} - -// getUnitByID fetches a single rawUnit by its UUID string without loading all units. -// Returns (nil, nil) when the ID is not found. -// V1: direct SQLite BLOB lookup — O(1). V2: cache lookup + single file read — O(cache size). -func (r *Reader) getUnitByID(id string) (*rawUnit, error) { - if r.version == MPRVersionV2 { - return r.getUnitByIDV2(id) - } - return r.getUnitByIDV1(id) -} - -func (r *Reader) getUnitByIDV1(id string) (*rawUnit, error) { - blob := types.UUIDToBlob(id) - if blob == nil { - return nil, fmt.Errorf("invalid unit ID: %s", id) - } - row := r.db.QueryRow( - "SELECT UnitID, ContainerID, ContainmentName, Contents FROM Unit WHERE UnitID = ?", - blob, - ) - var unitID, containerID []byte - var containmentName string - var contents []byte - if err := row.Scan(&unitID, &containerID, &containmentName, &contents); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("failed to query unit %s: %w", id, err) - } - return &rawUnit{ - ID: blobToUUID(unitID), - ContainerID: blobToUUID(containerID), - ContainmentName: containmentName, - Type: getTypeFromContents(contents), - Contents: contents, - }, nil -} - -func (r *Reader) getUnitByIDV2(id string) (*rawUnit, error) { - if !r.unitCacheValid { - if err := r.buildUnitCache(); err != nil { - return nil, err - } - } - for _, cu := range r.unitCache { - if cu.ID == id { - contents, err := r.readMprContents(id) - if err != nil { - return nil, err - } - return &rawUnit{ - ID: cu.ID, - ContainerID: cu.ContainerID, - ContainmentName: cu.ContainmentName, - Type: cu.Type, - Contents: contents, - }, nil - } - } - return nil, nil -} diff --git a/sdk/mpr/reader_units_type_test.go b/sdk/mpr/reader_units_type_test.go deleted file mode 100644 index 681d646b4d..0000000000 --- a/sdk/mpr/reader_units_type_test.go +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -// TestListUnitsByType_MatchesExactly is the regression guard for the page / -// page-template conflation. -// -// listUnitsByType used to match on a type *prefix*, and Mendix storage names -// nest: `Forms$Page` is a prefix of `Forms$PageTemplate`. So ListPages returned -// both, `show modules` reported the fixture's Atlas_Web_Content as having 46 -// pages when it has none, and every one of those templates described as a page -// with an empty body — the template's content hangs off LayoutCall, which the -// page path never reads. Anything comparing describe output therefore judged a -// template unchanged without having looked inside it. -// -// The assertion is deliberately about the *pair*: a test that only counted -// Forms$Page would pass against the prefix match too, because the miscount was -// caused by the other type being swept in. -func TestListUnitsByType_MatchesExactly(t *testing.T) { - r, err := Open(copyProject(t, "../../testdata/expr-checker", "minimal.mpr")) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer r.Close() - - pages, err := r.listUnitsByType("Forms$Page") - if err != nil { - t.Fatalf("listUnitsByType(Forms$Page): %v", err) - } - templates, err := r.listUnitsByType("Forms$PageTemplate") - if err != nil { - t.Fatalf("listUnitsByType(Forms$PageTemplate): %v", err) - } - - if len(pages) == 0 || len(templates) == 0 { - t.Fatalf("fixture should hold both types; got %d pages, %d templates", - len(pages), len(templates)) - } - - // Neither query may return a unit of the other type. - for _, u := range pages { - if u.Type != "Forms$Page" { - t.Fatalf("querying Forms$Page returned a %s — the match is by prefix, not exact", u.Type) - } - } - for _, u := range templates { - if u.Type != "Forms$PageTemplate" { - t.Fatalf("querying Forms$PageTemplate returned a %s", u.Type) - } - } - - // And the page query must not be the union of the two. - all, err := r.listUnitsByType("") - if err != nil { - t.Fatalf("listUnitsByType(\"\"): %v", err) - } - if len(all) <= len(pages)+len(templates) { - t.Fatalf("the empty type should return every unit; got %d, with %d pages + %d templates", - len(all), len(pages), len(templates)) - } -} - -// TestListPages_ExcludesPageTemplates checks the symptom the user actually sees, -// one layer up from the cause. -func TestListPages_ExcludesPageTemplates(t *testing.T) { - r, err := Open(copyProject(t, "../../testdata/expr-checker", "minimal.mpr")) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer r.Close() - - pageUnits, err := r.listUnitsByType("Forms$Page") - if err != nil { - t.Fatalf("listUnitsByType: %v", err) - } - pages, err := r.ListPages() - if err != nil { - t.Fatalf("ListPages: %v", err) - } - if len(pages) != len(pageUnits) { - t.Errorf("ListPages returned %d pages for %d Forms$Page units — page templates are being counted as pages", - len(pages), len(pageUnits)) - } - - templates, err := r.ListPageTemplates() - if err != nil { - t.Fatalf("ListPageTemplates: %v", err) - } - if len(templates) == 0 { - t.Fatal("page templates must still be readable under their own type") - } - byName := make(map[string]bool, len(pages)) - for _, p := range pages { - byName[p.Name] = true - } - for _, tpl := range templates { - if byName[tpl.Name] { - t.Errorf("%q is reported as both a page and a page template", tpl.Name) - } - } -} diff --git a/sdk/mpr/reader_widgets.go b/sdk/mpr/reader_widgets.go deleted file mode 100644 index 4b06051c8f..0000000000 --- a/sdk/mpr/reader_widgets.go +++ /dev/null @@ -1,747 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Widget template functionality for Reader. -package mpr - -import ( - "strings" - - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// RawCustomWidgetType holds the raw BSON data for a CustomWidgetType -// extracted from an existing widget in the project. -type RawCustomWidgetType struct { - WidgetID string // e.g., "com.mendix.widget.web.combobox.Combobox" - RawType bson.D // The full Type field as bson.D - RawObject bson.D // The full Object field as bson.D (WidgetObject with all properties) - UnitID string // ID of the unit where this was found - UnitName string // Name of the page/snippet (for identification) - WidgetName string // Name of the widget (from Name field) -} - -// FindCustomWidgetType searches for an existing CustomWidget with the given -// widgetID and returns its full Type definition as raw BSON. This can be used -// as a template for creating new widgets of the same type. -func (r *Reader) FindCustomWidgetType(widgetID string) (*RawCustomWidgetType, error) { - // Search through all pages for a CustomWidget with the matching widgetID - units, err := r.listUnitsByType("Forms$Page") - if err != nil { - return nil, err - } - - // Also check snippets - snippetUnits, err := r.listUnitsByType("Forms$Snippet") - if err == nil { - units = append(units, snippetUnits...) - } - - for _, u := range units { - contents, err := r.resolveContents(u.ID, u.Contents) - if err != nil { - continue - } - - // Quick check if this unit might contain the widget - if !containsWidgetID(contents, widgetID) { - continue - } - - // Parse and extract the widget type and object - rawType, rawObject := extractWidgetTypeAndObject(contents, widgetID) - if rawType != nil { - return &RawCustomWidgetType{ - WidgetID: widgetID, - RawType: rawType, - RawObject: rawObject, - UnitID: u.ID, - }, nil - } - } - - return nil, nil // Not found -} - -// FindAllCustomWidgetTypes searches for ALL CustomWidgets with the given -// widgetID and returns their full Type/Object definitions as raw BSON. -// This allows identification of different configurations of the same widget type. -func (r *Reader) FindAllCustomWidgetTypes(widgetID string) ([]*RawCustomWidgetType, error) { - var results []*RawCustomWidgetType - - // Search through all pages - units, err := r.listUnitsByType("Forms$Page") - if err != nil { - return nil, err - } - - // Also check snippets - snippetUnits, err := r.listUnitsByType("Forms$Snippet") - if err == nil { - units = append(units, snippetUnits...) - } - - for _, u := range units { - contents, err := r.resolveContents(u.ID, u.Contents) - if err != nil { - continue - } - - // Quick check if this unit might contain the widget - if !containsWidgetID(contents, widgetID) { - continue - } - - // Get the unit name for identification - unitName := extractUnitName(contents) - - // Parse and extract ALL widgets of this type from this unit - var doc bson.D - if err := bson.Unmarshal(contents, &doc); err != nil { - continue - } - - widgets := findAllCustomWidgets(doc, widgetID) - for _, w := range widgets { - results = append(results, &RawCustomWidgetType{ - WidgetID: widgetID, - RawType: w.rawType, - RawObject: w.rawObject, - UnitID: u.ID, - UnitName: unitName, - WidgetName: w.name, - }) - } - } - - return results, nil -} - -// extractUnitName extracts the Name field from a BSON document. -func extractUnitName(contents []byte) string { - var doc bson.D - if err := bson.Unmarshal(contents, &doc); err != nil { - return "" - } - for _, elem := range doc { - if elem.Key == "Name" { - if name, ok := elem.Value.(string); ok { - return name - } - } - } - return "" -} - -// widgetInfo holds extracted widget data. -type widgetInfo struct { - rawType bson.D - rawObject bson.D - name string -} - -// findAllCustomWidgets recursively searches for ALL CustomWidgets with the given widgetID. -func findAllCustomWidgets(doc bson.D, widgetID string) []widgetInfo { - var results []widgetInfo - - // Check if this document is a CustomWidget with matching widgetID - isCustomWidget := false - var typeDoc, objectDoc bson.D - var widgetName string - - for _, elem := range doc { - if elem.Key == "$Type" && elem.Value == "CustomWidgets$CustomWidget" { - isCustomWidget = true - } - if elem.Key == "Type" { - if t, ok := elem.Value.(bson.D); ok { - typeDoc = t - } - } - if elem.Key == "Object" { - if o, ok := elem.Value.(bson.D); ok { - objectDoc = o - } - } - if elem.Key == "Name" { - if n, ok := elem.Value.(string); ok { - widgetName = n - } - } - } - - // If this is a CustomWidget with matching widgetID, add to results - if isCustomWidget && typeDoc != nil && matchesWidgetID(typeDoc, widgetID) { - results = append(results, widgetInfo{ - rawType: typeDoc, - rawObject: objectDoc, - name: widgetName, - }) - } - - // Recursively search nested documents - for _, elem := range doc { - switch v := elem.Value.(type) { - case bson.D: - results = append(results, findAllCustomWidgets(v, widgetID)...) - case bson.A: - results = append(results, findAllCustomWidgetsInArray(v, widgetID)...) - } - } - - return results -} - -// findAllCustomWidgetsInArray searches an array for CustomWidgets. -func findAllCustomWidgetsInArray(arr bson.A, widgetID string) []widgetInfo { - var results []widgetInfo - for _, item := range arr { - switch v := item.(type) { - case bson.D: - results = append(results, findAllCustomWidgets(v, widgetID)...) - case bson.A: - results = append(results, findAllCustomWidgetsInArray(v, widgetID)...) - } - } - return results -} - -// GetPropertyValue extracts a property value from a RawObject by property key. -func (r *RawCustomWidgetType) GetPropertyValue(propertyKey string) string { - if r.RawObject == nil { - return "" - } - for _, elem := range r.RawObject { - if elem.Key == "Properties" { - if arr, ok := elem.Value.(bson.A); ok { - for _, item := range arr { - if prop, ok := item.(bson.D); ok { - propKey := getPropertyKey(prop) - if propKey == propertyKey { - return getPrimitiveValue(prop) - } - } - } - } - } - } - return "" -} - -// getPropertyKey extracts the property key from a WidgetProperty. -func getPropertyKey(prop bson.D) string { - for _, elem := range prop { - if elem.Key == "TypePointer" { - // We can't easily map TypePointer to PropertyKey without the Type - // So let's look for it differently - } - } - // Check Value for the property type info - for _, elem := range prop { - if elem.Key == "Value" { - if val, ok := elem.Value.(bson.D); ok { - for _, ve := range val { - if ve.Key == "$Type" { - // The type hints at what property this is - return ve.Value.(string) - } - } - } - } - } - return "" -} - -// getPrimitiveValue extracts the PrimitiveValue from a WidgetProperty. -func getPrimitiveValue(prop bson.D) string { - for _, elem := range prop { - if elem.Key == "Value" { - if val, ok := elem.Value.(bson.D); ok { - for _, ve := range val { - if ve.Key == "PrimitiveValue" { - if pv, ok := ve.Value.(string); ok { - return pv - } - } - } - } - } - } - return "" -} - -// GetAllPrimitiveValues returns all non-empty PrimitiveValue fields from the RawObject. -func (r *RawCustomWidgetType) GetAllPrimitiveValues() []string { - if r.RawObject == nil { - return nil - } - var values []string - for _, elem := range r.RawObject { - if elem.Key == "Properties" { - if arr, ok := elem.Value.(bson.A); ok { - for _, item := range arr { - if prop, ok := item.(bson.D); ok { - if pv := getPrimitiveValue(prop); pv != "" { - values = append(values, pv) - } - } - } - } - } - } - return values -} - -// containsWidgetID does a quick string check to see if the BSON might contain the widget. -func containsWidgetID(contents []byte, widgetID string) bool { - return strings.Contains(string(contents), widgetID) -} - -// extractWidgetTypeAndObject parses BSON and extracts both the CustomWidgetType and WidgetObject -// for the given widgetID. This allows cloning the complete widget with all its property values. -func extractWidgetTypeAndObject(contents []byte, widgetID string) (bson.D, bson.D) { - var doc bson.D - if err := bson.Unmarshal(contents, &doc); err != nil { - return nil, nil - } - - // Recursively search for CustomWidget with matching widgetID - return findCustomWidget(doc, widgetID) -} - -// findCustomWidget recursively searches for a CustomWidget with the given widgetID -// and returns both its Type and Object fields. -func findCustomWidget(doc bson.D, widgetID string) (bson.D, bson.D) { - // Check if this document is a CustomWidget with matching widgetID - isCustomWidget := false - var typeDoc, objectDoc bson.D - - for _, elem := range doc { - if elem.Key == "$Type" && elem.Value == "CustomWidgets$CustomWidget" { - isCustomWidget = true - } - if elem.Key == "Type" { - if t, ok := elem.Value.(bson.D); ok { - typeDoc = t - } - } - if elem.Key == "Object" { - if o, ok := elem.Value.(bson.D); ok { - objectDoc = o - } - } - } - - // If this is a CustomWidget with matching widgetID, return its Type and Object - if isCustomWidget && typeDoc != nil && matchesWidgetID(typeDoc, widgetID) { - return typeDoc, objectDoc - } - - // Recursively search nested documents - for _, elem := range doc { - switch v := elem.Value.(type) { - case bson.D: - if t, o := findCustomWidget(v, widgetID); t != nil { - return t, o - } - case bson.A: - if t, o := findCustomWidgetInArray(v, widgetID); t != nil { - return t, o - } - } - } - - return nil, nil -} - -// findCustomWidgetInArray searches an array for a CustomWidget. -func findCustomWidgetInArray(arr bson.A, widgetID string) (bson.D, bson.D) { - for _, item := range arr { - switch v := item.(type) { - case bson.D: - if t, o := findCustomWidget(v, widgetID); t != nil { - return t, o - } - case bson.A: - if t, o := findCustomWidgetInArray(v, widgetID); t != nil { - return t, o - } - } - } - return nil, nil -} - -// matchesWidgetID checks if a BSON document is a CustomWidgetType with the given widgetID. -func matchesWidgetID(doc bson.D, widgetID string) bool { - hasCorrectType := false - hasCorrectWidgetID := false - - for _, elem := range doc { - if elem.Key == "$Type" && elem.Value == "CustomWidgets$CustomWidgetType" { - hasCorrectType = true - } - if elem.Key == "WidgetId" && elem.Value == widgetID { - hasCorrectWidgetID = true - } - } - - return hasCorrectType && hasCorrectWidgetID -} - -// IDMapping tracks the mapping from old IDs to new IDs during cloning. -type IDMapping struct { - OldToNewID map[string]string // Maps old ID -> new ID for all elements - PropertyTypeIDs map[string]pages.PropertyTypeIDEntry // Maps PropertyKey -> PropertyTypeID/ValueTypeID - ObjectTypeID string // The cloned ObjectType ID -} - -// CloneWidgetType creates a deep copy of the widget type with all IDs regenerated. -// It returns a mapping from old PropertyType keys to new PropertyType IDs and ValueType IDs, -// as well as the ObjectType ID which is needed for the WidgetObject's TypePointer. -func CloneWidgetType(rawType bson.D) (cloned bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID string) { - mapping := &IDMapping{ - OldToNewID: make(map[string]string), - PropertyTypeIDs: make(map[string]pages.PropertyTypeIDEntry), - } - cloned = cloneDocWithNewIDs(rawType, mapping) - return cloned, mapping.PropertyTypeIDs, mapping.ObjectTypeID -} - -// CloneCustomWidgetType is an alias for CloneWidgetType for clarity. -func CloneCustomWidgetType(rawType bson.D) (cloned bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID string) { - return CloneWidgetType(rawType) -} - -// CloneWidgetObject creates a deep copy of a WidgetObject with all IDs regenerated. -// The idMapping is used to update TypePointers to reference the new IDs from the cloned Type. -func CloneWidgetObject(rawObject bson.D, idMapping map[string]string) bson.D { - if rawObject == nil { - return nil - } - return cloneObjectWithNewIDs(rawObject, idMapping) -} - -// CloneCustomWidget clones both the Type and Object of a CustomWidget. -// Returns the cloned Type, cloned Object, PropertyType IDs map, and ObjectType ID. -func CloneCustomWidget(rawType, rawObject bson.D) (clonedType, clonedObject bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID string) { - mapping := &IDMapping{ - OldToNewID: make(map[string]string), - PropertyTypeIDs: make(map[string]pages.PropertyTypeIDEntry), - } - - // Clone the Type first to build the ID mapping - clonedType = cloneDocWithNewIDs(rawType, mapping) - - // Clone the Object using the ID mapping to update TypePointers - if rawObject != nil { - clonedObject = cloneObjectWithNewIDs(rawObject, mapping.OldToNewID) - } - - return clonedType, clonedObject, mapping.PropertyTypeIDs, mapping.ObjectTypeID -} - -// ExtractPropertyTypeIDs extracts PropertyType IDs from a widget type WITHOUT regenerating IDs. -// This is used when creating new widget instances that reference an EXISTING widget type in the project. -// The TypePointers in the new instance must use the ORIGINAL IDs from the project's widget type. -func ExtractPropertyTypeIDs(rawType bson.D) (propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID string) { - propertyTypeIDs = make(map[string]pages.PropertyTypeIDEntry) - extractPropertyTypeIDsFromDoc(rawType, propertyTypeIDs, &objectTypeID) - return propertyTypeIDs, objectTypeID -} - -// extractPropertyTypeIDsFromDoc recursively extracts PropertyType/ValueType IDs without regenerating them. -func extractPropertyTypeIDsFromDoc(doc bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID *string) { - var currentPropertyKey string - var currentID string - var currentValueTypeID string - var currentDefaultValue string - var currentValueType string - var currentObjectTypeID string - var currentNestedPropertyIDs map[string]pages.PropertyTypeIDEntry - var docType string - - // First pass: collect all values from this document - for _, elem := range doc { - switch elem.Key { - case "$Type": - if t, ok := elem.Value.(string); ok { - docType = t - } - case "$ID": - if binID, ok := elem.Value.(primitive.Binary); ok { - currentID = blobToUUID(binID.Data) - } - case "PropertyKey": - if key, ok := elem.Value.(string); ok { - currentPropertyKey = key - } - case "ValueType": - if nested, ok := elem.Value.(bson.D); ok { - currentNestedPropertyIDs = make(map[string]pages.PropertyTypeIDEntry) - extractValueTypeInfo(nested, ¤tValueTypeID, ¤tDefaultValue, ¤tValueType, ¤tObjectTypeID, currentNestedPropertyIDs) - } - } - } - - // After collecting values, determine what type this is and record IDs - isPropertyType := docType == "CustomWidgets$WidgetPropertyType" - isObjectType := docType == "CustomWidgets$WidgetObjectType" - - if isObjectType && currentID != "" { - *objectTypeID = currentID - } - - // Record PropertyType entry - if isPropertyType && currentPropertyKey != "" { - propertyTypeIDs[currentPropertyKey] = pages.PropertyTypeIDEntry{ - PropertyTypeID: currentID, // Use the ID we collected - ValueTypeID: currentValueTypeID, - DefaultValue: currentDefaultValue, - ValueType: currentValueType, - ObjectTypeID: currentObjectTypeID, - NestedPropertyIDs: currentNestedPropertyIDs, - } - } - - // Second pass: recurse into nested documents and arrays - for _, elem := range doc { - if elem.Key == "ValueType" { - continue // Already processed - } - if nested, ok := elem.Value.(bson.D); ok { - extractPropertyTypeIDsFromDoc(nested, propertyTypeIDs, objectTypeID) - } - if arr, ok := elem.Value.(bson.A); ok { - for _, item := range arr { - if nested, ok := item.(bson.D); ok { - extractPropertyTypeIDsFromDoc(nested, propertyTypeIDs, objectTypeID) - } - } - } - } -} - -// extractValueTypeInfo extracts ValueType ID, default value, value type, and nested ObjectType info. -func extractValueTypeInfo(doc bson.D, valueTypeID, defaultValue, valueType *string, objectTypeID *string, nestedPropertyIDs map[string]pages.PropertyTypeIDEntry) { - for _, elem := range doc { - if elem.Key == "$ID" { - if binID, ok := elem.Value.(primitive.Binary); ok { - *valueTypeID = blobToUUID(binID.Data) - } - } - if elem.Key == "DefaultValue" { - if dv, ok := elem.Value.(string); ok { - *defaultValue = dv - } - } - if elem.Key == "Type" { - if vt, ok := elem.Value.(string); ok { - *valueType = vt - } - } - if elem.Key == "ObjectType" { - if nested, ok := elem.Value.(bson.D); ok { - extractObjectTypeInfo(nested, objectTypeID, nestedPropertyIDs) - } - } - } -} - -// extractObjectTypeInfo extracts ObjectType ID and its nested PropertyType IDs. -func extractObjectTypeInfo(doc bson.D, objectTypeID *string, nestedPropertyIDs map[string]pages.PropertyTypeIDEntry) { - var dummyObjectTypeID string - for _, elem := range doc { - if elem.Key == "$ID" { - if binID, ok := elem.Value.(primitive.Binary); ok { - *objectTypeID = blobToUUID(binID.Data) - } - } - if elem.Key == "PropertyTypes" { - if arr, ok := elem.Value.(bson.A); ok { - for _, item := range arr { - if propType, ok := item.(bson.D); ok { - extractPropertyTypeIDsFromDoc(propType, nestedPropertyIDs, &dummyObjectTypeID) - } - } - } - } - } -} - -// cloneDocWithNewIDs recursively clones a BSON document with regenerated IDs. -// It builds an ID mapping and tracks PropertyType/ValueType IDs. -func cloneDocWithNewIDs(doc bson.D, mapping *IDMapping) bson.D { - result := make(bson.D, 0, len(doc)) - - // First pass: check if this is a PropertyType or ObjectType and extract its key and old ID - var currentPropertyKey string - var currentPropertyTypeID string - var currentValueTypeID string - var oldID string - isPropertyType := false - isObjectType := false - isValueType := false - - for _, elem := range doc { - if elem.Key == "$Type" { - switch elem.Value { - case "CustomWidgets$WidgetPropertyType": - isPropertyType = true - case "CustomWidgets$WidgetObjectType": - isObjectType = true - case "CustomWidgets$WidgetValueType": - isValueType = true - } - } - if elem.Key == "PropertyKey" { - if key, ok := elem.Value.(string); ok { - currentPropertyKey = key - } - } - if elem.Key == "$ID" { - if binID, ok := elem.Value.(primitive.Binary); ok { - oldID = blobToUUID(binID.Data) - } - } - } - - // Clone each element - for _, elem := range doc { - newElem := bson.E{Key: elem.Key} - - if elem.Key == "$ID" { - // Generate new ID and record the mapping - newID := generateUUID() - newElem.Value = idToBsonBinary(newID) - - // Store the old -> new ID mapping - if oldID != "" { - mapping.OldToNewID[oldID] = newID - } - - // Track PropertyType and ValueType IDs - if isPropertyType { - currentPropertyTypeID = newID - } - if isValueType { - currentValueTypeID = newID - } - // Track ObjectType ID for WidgetObject reference - if isObjectType { - mapping.ObjectTypeID = newID - } - } else { - // Clone the value - switch v := elem.Value.(type) { - case bson.D: - // Recursively clone nested document - clonedNested := cloneDocWithNewIDs(v, mapping) - newElem.Value = clonedNested - - // If this is a ValueType, extract its new ID - if elem.Key == "ValueType" { - for _, e := range clonedNested { - if e.Key == "$ID" { - if binID, ok := e.Value.(primitive.Binary); ok { - currentValueTypeID = blobToUUID(binID.Data) - } - break - } - } - } - case bson.A: - newElem.Value = cloneArrayWithNewIDs(v, mapping) - default: - newElem.Value = v - } - } - - result = append(result, newElem) - } - - // Record PropertyType IDs - if isPropertyType && currentPropertyKey != "" { - mapping.PropertyTypeIDs[currentPropertyKey] = pages.PropertyTypeIDEntry{ - PropertyTypeID: currentPropertyTypeID, - ValueTypeID: currentValueTypeID, - } - } - - return result -} - -// cloneArrayWithNewIDs recursively clones a BSON array with regenerated IDs. -func cloneArrayWithNewIDs(arr bson.A, mapping *IDMapping) bson.A { - result := make(bson.A, len(arr)) - for i, item := range arr { - switch v := item.(type) { - case bson.D: - result[i] = cloneDocWithNewIDs(v, mapping) - case bson.A: - result[i] = cloneArrayWithNewIDs(v, mapping) - default: - result[i] = v - } - } - return result -} - -// cloneObjectWithNewIDs clones a WidgetObject with new IDs, updating TypePointers -// to reference the new IDs from the cloned Type. -func cloneObjectWithNewIDs(doc bson.D, idMapping map[string]string) bson.D { - result := make(bson.D, 0, len(doc)) - - for _, elem := range doc { - newElem := bson.E{Key: elem.Key} - - if elem.Key == "$ID" { - // Generate new ID for the object itself - newID := generateUUID() - newElem.Value = idToBsonBinary(newID) - } else if elem.Key == "TypePointer" { - // Update TypePointer to reference the new ID from the cloned Type - if binID, ok := elem.Value.(primitive.Binary); ok { - oldID := blobToUUID(binID.Data) - if newID, found := idMapping[oldID]; found { - newElem.Value = idToBsonBinary(newID) - } else { - // Keep the original if not found in mapping - newElem.Value = elem.Value - } - } else { - newElem.Value = elem.Value - } - } else { - // Clone the value - switch v := elem.Value.(type) { - case bson.D: - newElem.Value = cloneObjectWithNewIDs(v, idMapping) - case bson.A: - newElem.Value = cloneObjectArrayWithNewIDs(v, idMapping) - default: - newElem.Value = v - } - } - - result = append(result, newElem) - } - - return result -} - -// cloneObjectArrayWithNewIDs clones an array within a WidgetObject. -func cloneObjectArrayWithNewIDs(arr bson.A, idMapping map[string]string) bson.A { - result := make(bson.A, len(arr)) - for i, item := range arr { - switch v := item.(type) { - case bson.D: - result[i] = cloneObjectWithNewIDs(v, idMapping) - case bson.A: - result[i] = cloneObjectArrayWithNewIDs(v, idMapping) - default: - result[i] = v - } - } - return result -} diff --git a/sdk/mpr/reader_xmlschema_test.go b/sdk/mpr/reader_xmlschema_test.go deleted file mode 100644 index 759f676e95..0000000000 --- a/sdk/mpr/reader_xmlschema_test.go +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" -) - -// ListXmlSchemas reads the two fields a mapping's `with xml schema` reference -// needs — the document's Name and its owning module — from XmlSchemas$XmlSchema -// units (ako/mxcli#259). -// -// The type string and the Name key are not guesses. They were established -// against mxbuild 11.13.0 by planting a synthetic unit carrying exactly the keys -// this test writes: a mapping referring to it stopped being -// -// [error] [CE1613] "The selected XML schema 'XGap.Probe_Xsd' no longer exists." -// -// and became -// -// [error] [CE0292] "Please import an XSD file." at XML schema 'XGap.Probe_Xsd' -// -// — mxbuild naming, by module and name, the document it had just found. (The -// second error is expected: the synthetic schema carries no XSD contents.) The -// same string is what modelsdk/gen/xmlschemas registers with the codec and what -// modelsdk/gen/mappings/refs.go names as the reference target. -func TestListXmlSchemasReadsNameAndModule(t *testing.T) { - writer, _ := newTestWriterV1(t, unitTableSchemaV1) - - const moduleID = "22222222-2222-2222-2222-222222222222" - writeUnit(t, writer, moduleID, "", "Modules", "Projects$ModuleImpl", bson.D{ - {Key: "$Type", Value: "Projects$ModuleImpl"}, - {Key: "Name", Value: "XGap"}, - }) - writeUnit(t, writer, "33333333-3333-3333-3333-333333333333", moduleID, "Documents", - "XmlSchemas$XmlSchema", bson.D{ - {Key: "$Type", Value: "XmlSchemas$XmlSchema"}, - {Key: "Documentation", Value: "orders"}, - {Key: "Entries", Value: bson.A{int32(2)}}, - {Key: "Excluded", Value: false}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "FilePath", Value: "orders.xsd"}, - {Key: "Name", Value: "Orders_Xsd"}, - }) - - got, err := writer.reader.ListXmlSchemas() - if err != nil { - t.Fatalf("ListXmlSchemas: %v", err) - } - if len(got) != 1 { - t.Fatalf("got %d schemas, want 1", len(got)) - } - if got[0].Name != "Orders_Xsd" { - t.Errorf("Name = %q, want Orders_Xsd", got[0].Name) - } - // The module is what makes the reference check module-aware; without it, - // `A.Orders_Xsd` would resolve against `B.Orders_Xsd`. - if got[0].Module != "XGap" { - t.Errorf("Module = %q, want XGap", got[0].Module) - } - if got[0].FilePath != "orders.xsd" { - t.Errorf("FilePath = %q, want orders.xsd", got[0].FilePath) - } -} - -// TestListXmlSchemasIgnoresOtherDocuments is the control: the type filter has to -// be doing the work, not the fact that the fixture holds only one document. -func TestListXmlSchemasIgnoresOtherDocuments(t *testing.T) { - writer, _ := newTestWriterV1(t, unitTableSchemaV1) - - const moduleID = "22222222-2222-2222-2222-222222222222" - writeUnit(t, writer, moduleID, "", "Modules", "Projects$ModuleImpl", bson.D{ - {Key: "$Type", Value: "Projects$ModuleImpl"}, - {Key: "Name", Value: "XGap"}, - }) - writeUnit(t, writer, "44444444-4444-4444-4444-444444444444", moduleID, "Documents", - "JsonStructures$JsonStructure", bson.D{ - {Key: "$Type", Value: "JsonStructures$JsonStructure"}, - {Key: "Name", Value: "JSON_Orders"}, - }) - - got, err := writer.reader.ListXmlSchemas() - if err != nil { - t.Fatalf("ListXmlSchemas: %v", err) - } - if len(got) != 0 { - t.Fatalf("got %d schemas, want 0: %+v", len(got), got) - } -} - -const unitTableSchemaV1 = ` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT, - Contents BLOB - ) -` - -func writeUnit(t *testing.T, w *Writer, unitID, containerID, containment, unitType string, doc bson.D) { - t.Helper() - contents, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("marshal %s: %v", unitType, err) - } - if err := w.insertUnit(unitID, containerID, containment, unitType, contents); err != nil { - t.Fatalf("insertUnit %s: %v", unitType, err) - } -} diff --git a/sdk/mpr/regularexpressions.go b/sdk/mpr/regularexpressions.go deleted file mode 100644 index a4fcc87075..0000000000 --- a/sdk/mpr/regularexpressions.go +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "go.mongodb.org/mongo-driver/bson" - - regex "github.com/mendixlabs/mxcli/mdl/regularexpressions" - "github.com/mendixlabs/mxcli/model" -) - -// Regular expressions for the legacy engine. The document shape lives in -// mdl/regularexpressions so both engines write exactly the same bytes. - -// ListRegularExpressions reads every regular expression in the project. -func (r *Reader) ListRegularExpressions() ([]*model.RegularExpression, error) { - units, err := r.ListRawUnitsByType(regex.TypeName) - if err != nil { - return nil, err - } - out := make([]*model.RegularExpression, 0, len(units)) - for _, u := range units { - var doc bson.M - if err := bson.Unmarshal(u.Contents, &doc); err != nil { - return nil, fmt.Errorf("unmarshal regular expression %s: %w", u.ID, err) - } - out = append(out, regex.Parse(doc, model.ID(u.ID), model.ID(u.ContainerID))) - } - return out, nil -} - -// CreateRegularExpression inserts a new regular expression document. -func (w *Writer) CreateRegularExpression(re *model.RegularExpression) error { - if re == nil { - return fmt.Errorf("CreateRegularExpression: nil regular expression") - } - if re.ID == "" { - re.ID = model.ID(generateUUID()) - } - contents, err := regex.Serialize(re) - if err != nil { - return err - } - return w.insertUnit(string(re.ID), string(re.ContainerID), "Documents", regex.TypeName, contents) -} - -// UpdateRegularExpression rewrites an existing regular expression in place. -func (w *Writer) UpdateRegularExpression(re *model.RegularExpression) error { - if re == nil { - return fmt.Errorf("UpdateRegularExpression: nil regular expression") - } - contents, err := regex.Serialize(re) - if err != nil { - return err - } - return w.UpdateRawUnit(string(re.ID), contents) -} - -// DeleteRegularExpression removes a regular expression by ID. -func (w *Writer) DeleteRegularExpression(id string) error { - return w.deleteUnit(id) -} diff --git a/sdk/mpr/roundtrip_test.go b/sdk/mpr/roundtrip_test.go deleted file mode 100644 index 8d361d78cd..0000000000 --- a/sdk/mpr/roundtrip_test.go +++ /dev/null @@ -1,600 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "os" - "path/filepath" - "strings" - "testing" - - bsondebug "github.com/mendixlabs/mxcli/cmd/mxcli/bson" - "go.mongodb.org/mongo-driver/bson" -) - -// testReader creates a minimal Reader for roundtrip tests (no database needed). -func testReader() *Reader { - return &Reader{version: MPRVersionV1} -} - -// testWriter creates a minimal Writer for roundtrip tests (no database needed). -func testWriter() *Writer { - return &Writer{reader: testReader()} -} - -// toNDSL unmarshals raw BSON bytes and renders as Normalized DSL text. -func toNDSL(t *testing.T, data []byte) string { - t.Helper() - var doc bson.D - if err := bson.Unmarshal(data, &doc); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - return bsondebug.Render(doc, 0) -} - -// roundtripPage: baseline → parse → serialize → parse → serialize → compare two serializations. -// Verifies serialization idempotency. Original baseline is preserved as ground truth. -func roundtripPage(t *testing.T, baselineBytes []byte) { - t.Helper() - r := testReader() - w := testWriter() - - // First pass: baseline → parse → serialize - page1, err := r.parsePage("test-unit-id", "test-container-id", baselineBytes) - if err != nil { - t.Fatalf("parsePage (pass 1) failed: %v", err) - } - serialized1, err := w.serializePage(page1) - if err != nil { - t.Fatalf("serializePage (pass 1) failed: %v", err) - } - - // Second pass: serialized → parse → serialize - page2, err := r.parsePage("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parsePage (pass 2) failed: %v", err) - } - serialized2, err := w.serializePage(page2) - if err != nil { - t.Fatalf("serializePage (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent for page %q\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - page1.Name, ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } -} - -// roundtripMicroflow: baseline → parse → serialize → parse → serialize → compare two serializations. -func roundtripMicroflow(t *testing.T, baselineBytes []byte) { - t.Helper() - r := testReader() - w := testWriter() - - // First pass - mf1, err := r.parseMicroflow("test-unit-id", "test-container-id", baselineBytes) - if err != nil { - t.Fatalf("parseMicroflow (pass 1) failed: %v", err) - } - serialized1, err := w.serializeMicroflow(mf1) - if err != nil { - t.Fatalf("serializeMicroflow (pass 1) failed: %v", err) - } - - // Second pass - mf2, err := r.parseMicroflow("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseMicroflow (pass 2) failed: %v", err) - } - serialized2, err := w.serializeMicroflow(mf2) - if err != nil { - t.Fatalf("serializeMicroflow (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent for microflow %q\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - mf1.Name, ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } -} - -// roundtripNanoflow: baseline → parse → serialize → parse → serialize → compare two serializations. -func roundtripNanoflow(t *testing.T, baselineBytes []byte) { - t.Helper() - r := testReader() - w := testWriter() - - // First pass - nf1, err := r.parseNanoflow("test-unit-id", "test-container-id", baselineBytes) - if err != nil { - t.Fatalf("parseNanoflow (pass 1) failed: %v", err) - } - serialized1, err := w.serializeNanoflow(nf1) - if err != nil { - t.Fatalf("serializeNanoflow (pass 1) failed: %v", err) - } - - // Second pass - nf2, err := r.parseNanoflow("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseNanoflow (pass 2) failed: %v", err) - } - serialized2, err := w.serializeNanoflow(nf2) - if err != nil { - t.Fatalf("serializeNanoflow (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent for nanoflow %q\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - nf1.Name, ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } -} - -// roundtripSnippet: double roundtrip idempotency test. -func roundtripSnippet(t *testing.T, baselineBytes []byte) { - t.Helper() - r := testReader() - w := testWriter() - - snippet1, err := r.parseSnippet("test-unit-id", "test-container-id", baselineBytes) - if err != nil { - t.Fatalf("parseSnippet (pass 1) failed: %v", err) - } - serialized1, err := w.serializeSnippet(snippet1) - if err != nil { - t.Fatalf("serializeSnippet (pass 1) failed: %v", err) - } - - snippet2, err := r.parseSnippet("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseSnippet (pass 2) failed: %v", err) - } - serialized2, err := w.serializeSnippet(snippet2) - if err != nil { - t.Fatalf("serializeSnippet (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent for snippet %q\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - snippet1.Name, ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } -} - -// roundtripEnumeration: double roundtrip idempotency test. -func roundtripEnumeration(t *testing.T, baselineBytes []byte) { - t.Helper() - r := testReader() - w := testWriter() - - enum1, err := r.parseEnumeration("test-unit-id", "test-container-id", baselineBytes) - if err != nil { - t.Fatalf("parseEnumeration (pass 1) failed: %v", err) - } - serialized1, err := w.serializeEnumeration(enum1) - if err != nil { - t.Fatalf("serializeEnumeration (pass 1) failed: %v", err) - } - - enum2, err := r.parseEnumeration("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseEnumeration (pass 2) failed: %v", err) - } - serialized2, err := w.serializeEnumeration(enum2) - if err != nil { - t.Fatalf("serializeEnumeration (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent for enumeration %q\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - enum1.Name, ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } -} - -// TestRoundtrip_Pages runs roundtrip tests on all page baselines in testdata/. -func TestRoundtrip_Pages(t *testing.T) { - runRoundtripDir(t, "testdata/pages", roundtripPage) -} - -// TestRoundtrip_Microflows runs roundtrip tests on all microflow baselines. -func TestRoundtrip_Microflows(t *testing.T) { - runRoundtripDir(t, "testdata/microflows", roundtripMicroflow) -} - -// TestRoundtrip_Nanoflows runs roundtrip tests on all nanoflow baselines. -func TestRoundtrip_Nanoflows(t *testing.T) { - runRoundtripDir(t, "testdata/nanoflows", roundtripNanoflow) -} - -// TestRoundtrip_Nanoflow_Synthetic tests parse→serialize→parse idempotency -// using programmatically constructed BSON (no .mxunit baseline needed). -func TestRoundtrip_Nanoflow_Synthetic(t *testing.T) { - r := testReader() - w := testWriter() - - tests := []struct { - name string - doc bson.D - }{ - { - name: "minimal_void", - doc: bson.D{ - {Key: "$ID", Value: "nf-test-1"}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(3)}}, - {Key: "Documentation", Value: ""}, - {Key: "Excluded", Value: false}, - {Key: "Flows", Value: bson.A{int32(3)}}, - {Key: "MarkAsUsed", Value: false}, - {Key: "Name", Value: "NF_Minimal"}, - }, - }, - { - name: "with_return_type", - doc: bson.D{ - {Key: "$ID", Value: "nf-test-2"}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(3)}}, - {Key: "Documentation", Value: "A nanoflow that returns a string"}, - {Key: "Excluded", Value: false}, - {Key: "Flows", Value: bson.A{int32(3)}}, - {Key: "MarkAsUsed", Value: true}, - {Key: "MicroflowReturnType", Value: bson.D{ - {Key: "$ID", Value: "rt-1"}, - {Key: "$Type", Value: "Datatypes$StringType"}, - }}, - {Key: "Name", Value: "NF_WithReturn"}, - }, - }, - { - name: "with_parameters", - doc: bson.D{ - {Key: "$ID", Value: "nf-test-3"}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(3), "role-1", "role-2"}}, - {Key: "Documentation", Value: ""}, - {Key: "Excluded", Value: false}, - {Key: "Flows", Value: bson.A{int32(3)}}, - {Key: "MarkAsUsed", Value: false}, - {Key: "Name", Value: "NF_WithParams"}, - {Key: "ObjectCollection", Value: bson.D{ - {Key: "$ID", Value: "oc-1"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$ID", Value: "param-1"}, - {Key: "$Type", Value: "Microflows$MicroflowParameter"}, - {Key: "Name", Value: "Input"}, - {Key: "Documentation", Value: ""}, - {Key: "HasWidgetUsages", Value: false}, - {Key: "RelativeMiddlePoint", Value: bson.D{ - {Key: "$ID", Value: "rmp-1"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectRelativeMiddlePoint"}, - {Key: "X", Value: int32(0)}, - {Key: "Y", Value: int32(0)}, - }}, - {Key: "Size", Value: bson.D{ - {Key: "$ID", Value: "sz-1"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectSize"}, - {Key: "Width", Value: int32(30)}, - {Key: "Height", Value: int32(30)}, - }}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: "vt-1"}, - {Key: "$Type", Value: "Datatypes$StringType"}, - }}, - }, - }}, - }}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - baseline, err := bson.Marshal(tt.doc) - if err != nil { - t.Fatalf("failed to marshal synthetic BSON: %v", err) - } - - // First pass: parse → serialize - nf1, err := r.parseNanoflow("test-unit-id", "test-container-id", baseline) - if err != nil { - t.Fatalf("parseNanoflow (pass 1) failed: %v", err) - } - serialized1, err := w.serializeNanoflow(nf1) - if err != nil { - t.Fatalf("serializeNanoflow (pass 1) failed: %v", err) - } - - // Second pass: serialized → parse → serialize - nf2, err := r.parseNanoflow("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseNanoflow (pass 2) failed: %v", err) - } - serialized2, err := w.serializeNanoflow(nf2) - if err != nil { - t.Fatalf("serializeNanoflow (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent:\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } - - // Verify basic fields survived - if expectedName, ok := tt.doc.Map()["Name"].(string); ok { - if nf1.Name != expectedName { - t.Errorf("Name mismatch: got %q, want %q", nf1.Name, expectedName) - } - } - }) - } -} - -// TestRoundtrip_Nanoflow_WithActivities tests parse→serialize→parse idempotency -// for a nanoflow with ObjectCollection containing activities and flows. -func TestRoundtrip_Nanoflow_WithActivities(t *testing.T) { - r := testReader() - w := testWriter() - - doc := bson.D{ - {Key: "$ID", Value: "nf-act-1"}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(3), "role-admin", "role-user"}}, - {Key: "Documentation", Value: "Nanoflow with activities"}, - {Key: "Excluded", Value: false}, - {Key: "Flows", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$ID", Value: "sf-1"}, - {Key: "$Type", Value: "Microflows$SequenceFlow"}, - {Key: "OriginConnectionIndex", Value: int32(0)}, - {Key: "DestinationConnectionIndex", Value: int32(0)}, - {Key: "OriginBezierVector", Value: bson.D{ - {Key: "$ID", Value: "bv-1"}, - {Key: "$Type", Value: "Microflows$BezierVector"}, - {Key: "X", Value: 0.0}, - {Key: "Y", Value: 0.0}, - }}, - {Key: "DestinationBezierVector", Value: bson.D{ - {Key: "$ID", Value: "bv-2"}, - {Key: "$Type", Value: "Microflows$BezierVector"}, - {Key: "X", Value: 0.0}, - {Key: "Y", Value: 0.0}, - }}, - }, - }}, - {Key: "MarkAsUsed", Value: true}, - {Key: "MicroflowReturnType", Value: bson.D{ - {Key: "$ID", Value: "rt-act"}, - {Key: "$Type", Value: "Datatypes$IntegerType"}, - }}, - {Key: "Name", Value: "NF_WithActivities"}, - {Key: "ObjectCollection", Value: bson.D{ - {Key: "$ID", Value: "oc-act"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$ID", Value: "start-1"}, - {Key: "$Type", Value: "Microflows$StartEvent"}, - {Key: "RelativeMiddlePoint", Value: bson.D{ - {Key: "$ID", Value: "rmp-s"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectRelativeMiddlePoint"}, - {Key: "X", Value: int32(100)}, - {Key: "Y", Value: int32(100)}, - }}, - {Key: "Size", Value: bson.D{ - {Key: "$ID", Value: "sz-s"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectSize"}, - {Key: "Width", Value: int32(20)}, - {Key: "Height", Value: int32(20)}, - }}, - }, - bson.D{ - {Key: "$ID", Value: "end-1"}, - {Key: "$Type", Value: "Microflows$EndEvent"}, - {Key: "RelativeMiddlePoint", Value: bson.D{ - {Key: "$ID", Value: "rmp-e"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectRelativeMiddlePoint"}, - {Key: "X", Value: int32(400)}, - {Key: "Y", Value: int32(100)}, - }}, - {Key: "Size", Value: bson.D{ - {Key: "$ID", Value: "sz-e"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectSize"}, - {Key: "Width", Value: int32(20)}, - {Key: "Height", Value: int32(20)}, - }}, - {Key: "ReturnValue", Value: ""}, - }, - }}, - }}, - } - - baseline, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal synthetic BSON: %v", err) - } - - // First pass - nf1, err := r.parseNanoflow("test-unit-id", "test-container-id", baseline) - if err != nil { - t.Fatalf("parseNanoflow (pass 1) failed: %v", err) - } - serialized1, err := w.serializeNanoflow(nf1) - if err != nil { - t.Fatalf("serializeNanoflow (pass 1) failed: %v", err) - } - - // Second pass - nf2, err := r.parseNanoflow("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseNanoflow (pass 2) failed: %v", err) - } - serialized2, err := w.serializeNanoflow(nf2) - if err != nil { - t.Fatalf("serializeNanoflow (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent:\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s\n--- diff ---\n%s", - ndsl1, ndsl2, ndslDiff(ndsl1, ndsl2)) - } - - // Verify AllowedModuleRoles survived - if len(nf1.AllowedModuleRoles) != 2 { - t.Errorf("Expected 2 AllowedModuleRoles, got %d", len(nf1.AllowedModuleRoles)) - } - - // Verify ObjectCollection survived - if nf1.ObjectCollection == nil { - t.Error("Expected ObjectCollection to be parsed") - } - - // Verify name survived - if nf1.Name != "NF_WithActivities" { - t.Errorf("Name mismatch: got %q", nf1.Name) - } -} - -// TestRoundtrip_Nanoflow_EmptyObjectCollection tests a nanoflow with an empty ObjectCollection. -func TestRoundtrip_Nanoflow_EmptyObjectCollection(t *testing.T) { - r := testReader() - w := testWriter() - - doc := bson.D{ - {Key: "$ID", Value: "nf-empty-oc"}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(3)}}, - {Key: "Documentation", Value: ""}, - {Key: "Excluded", Value: false}, - {Key: "Flows", Value: bson.A{int32(3)}}, - {Key: "MarkAsUsed", Value: false}, - {Key: "Name", Value: "NF_EmptyOC"}, - {Key: "ObjectCollection", Value: bson.D{ - {Key: "$ID", Value: "oc-empty"}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: bson.A{int32(3)}}, - }}, - } - - baseline, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - nf1, err := r.parseNanoflow("test-unit-id", "test-container-id", baseline) - if err != nil { - t.Fatalf("parseNanoflow failed: %v", err) - } - serialized1, err := w.serializeNanoflow(nf1) - if err != nil { - t.Fatalf("serializeNanoflow failed: %v", err) - } - - nf2, err := r.parseNanoflow("test-unit-id", "test-container-id", serialized1) - if err != nil { - t.Fatalf("parseNanoflow (pass 2) failed: %v", err) - } - serialized2, err := w.serializeNanoflow(nf2) - if err != nil { - t.Fatalf("serializeNanoflow (pass 2) failed: %v", err) - } - - ndsl1 := toNDSL(t, serialized1) - ndsl2 := toNDSL(t, serialized2) - if ndsl1 != ndsl2 { - t.Errorf("serialization not idempotent:\n--- pass 1 ---\n%s\n--- pass 2 ---\n%s", ndsl1, ndsl2) - } -} - -// TestRoundtrip_Snippets runs roundtrip tests on all snippet baselines. -func TestRoundtrip_Snippets(t *testing.T) { - runRoundtripDir(t, "testdata/snippets", roundtripSnippet) -} - -// TestRoundtrip_Enumerations runs roundtrip tests on all enumeration baselines. -func TestRoundtrip_Enumerations(t *testing.T) { - runRoundtripDir(t, "testdata/enumerations", roundtripEnumeration) -} - -// runRoundtripDir loads all .mxunit files from a directory and runs the given roundtrip function. -func runRoundtripDir(t *testing.T, dir string, fn func(*testing.T, []byte)) { - t.Helper() - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - t.Skipf("no baseline directory: %s", dir) - return - } - t.Fatalf("failed to read directory %s: %v", dir, err) - } - - count := 0 - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".mxunit") { - continue - } - count++ - name := strings.TrimSuffix(entry.Name(), ".mxunit") - t.Run(name, func(t *testing.T) { - data, err := os.ReadFile(filepath.Join(dir, entry.Name())) - if err != nil { - t.Fatalf("failed to read baseline: %v", err) - } - fn(t, data) - }) - } - if count == 0 { - t.Skipf("no .mxunit baselines in %s", dir) - } -} - -// ndslDiff returns a simple line-by-line diff of two NDSL strings. -func ndslDiff(a, b string) string { - linesA := strings.Split(a, "\n") - linesB := strings.Split(b, "\n") - - var diffs []string - maxLen := len(linesA) - if len(linesB) > maxLen { - maxLen = len(linesB) - } - - for i := 0; i < maxLen; i++ { - la, lb := "", "" - if i < len(linesA) { - la = linesA[i] - } - if i < len(linesB) { - lb = linesB[i] - } - if la != lb { - diffs = append(diffs, "- "+la) - diffs = append(diffs, "+ "+lb) - } - } - return strings.Join(diffs, "\n") -} diff --git a/sdk/mpr/scheduledevents.go b/sdk/mpr/scheduledevents.go deleted file mode 100644 index 21aaa1fb01..0000000000 --- a/sdk/mpr/scheduledevents.go +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - sched "github.com/mendixlabs/mxcli/mdl/scheduledevents" - "github.com/mendixlabs/mxcli/model" -) - -// Scheduled events (ScheduledEvents$ScheduledEvent) for the legacy engine. -// -// The document shape lives in mdl/scheduledevents so both engines write exactly -// the same bytes — the schedule child has eight variants that differ in which -// fields they carry, and two copies of that dispatch would eventually disagree. -// -// The legacy READER for scheduled events is parseScheduledEvent (in -// parser_enumeration.go), which predates this and covers only the flat legacy -// fields; the write path here round-trips through the shared codec. - -// CreateScheduledEvent inserts a new scheduled event document. -func (w *Writer) CreateScheduledEvent(ev *model.ScheduledEvent) error { - if ev == nil { - return fmt.Errorf("CreateScheduledEvent: nil event") - } - if ev.ID == "" { - ev.ID = model.ID(generateUUID()) - } - contents, err := sched.Serialize(ev) - if err != nil { - return err - } - return w.insertUnit(string(ev.ID), string(ev.ContainerID), "Documents", sched.TypeName, contents) -} - -// UpdateScheduledEvent rewrites an existing scheduled event in place. -func (w *Writer) UpdateScheduledEvent(ev *model.ScheduledEvent) error { - if ev == nil { - return fmt.Errorf("UpdateScheduledEvent: nil event") - } - contents, err := sched.Serialize(ev) - if err != nil { - return err - } - return w.UpdateRawUnit(string(ev.ID), contents) -} - -// DeleteScheduledEvent removes a scheduled event by ID. -func (w *Writer) DeleteScheduledEvent(id string) error { - return w.deleteUnit(id) -} diff --git a/sdk/mpr/showpage_roundtrip_test.go b/sdk/mpr/showpage_roundtrip_test.go deleted file mode 100644 index 607669af81..0000000000 --- a/sdk/mpr/showpage_roundtrip_test.go +++ /dev/null @@ -1,261 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// TestShowPageAction_Roundtrip verifies that a ShowPageAction with parameters -// survives BSON serialization/deserialization. -func TestShowPageAction_Roundtrip(t *testing.T) { - // Build a ShowPageAction with parameter mappings - action := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Product_NewEdit", - PageParameterMappings: []*microflows.PageParameterMapping{ - { - BaseElement: model.BaseElement{ID: "test-mapping-id"}, - Parameter: "Sales.Product_NewEdit.Product", - Argument: "$Product", - }, - }, - } - - // Serialize to BSON using the writer - doc := serializeMicroflowAction(action) - - // Marshal to bytes (simulates writing to MPR) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - // Unmarshal back to map (simulates reading from MPR) - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - // Parse using the parser - parsed := parseShowPageAction(raw) - - // Verify page name - if parsed.PageName != "Sales.Product_NewEdit" { - t.Errorf("PageName = %q, want %q", parsed.PageName, "Sales.Product_NewEdit") - } - - // Verify parameter mappings - if len(parsed.PageParameterMappings) != 1 { - t.Fatalf("PageParameterMappings count = %d, want 1", len(parsed.PageParameterMappings)) - } - pm := parsed.PageParameterMappings[0] - if pm.Parameter != "Sales.Product_NewEdit.Product" { - t.Errorf("Parameter = %q, want %q", pm.Parameter, "Sales.Product_NewEdit.Product") - } - if pm.Argument != "$Product" { - t.Errorf("Argument = %q, want %q", pm.Argument, "$Product") - } -} - -func TestShowPageAction_WritesValidPageParameterMapping(t *testing.T) { - action := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Product_NewEdit", - PageParameterMappings: []*microflows.PageParameterMapping{ - { - BaseElement: model.BaseElement{ID: "test-mapping-id"}, - Parameter: "Sales.Product_NewEdit.Product", - Argument: "$Product", - }, - }, - } - - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - formSettings := toMap(raw["FormSettings"]) - if formSettings == nil { - t.Fatal("FormSettings missing") - } - - mappings, ok := formSettings["ParameterMappings"].(primitive.A) - if !ok { - t.Fatalf("ParameterMappings type = %T, want primitive.A", formSettings["ParameterMappings"]) - } - if len(mappings) != 2 { - t.Fatalf("ParameterMappings length = %d, want marker plus one mapping", len(mappings)) - } - if marker, ok := mappings[0].(int32); !ok || marker != 2 { - t.Fatalf("ParameterMappings marker = %#v, want int32(2)", mappings[0]) - } - - mapping := toMap(mappings[1]) - if mapping == nil { - t.Fatal("PageParameterMapping missing") - } - variable := toMap(mapping["Variable"]) - if variable == nil { - t.Fatal("Variable is nil; Studio Pro rejects null page parameter mapping variables") - } - if got := extractString(variable["$Type"]); got != "Forms$PageVariable" { - t.Fatalf("Variable $Type = %q, want Forms$PageVariable", got) - } -} - -// TestShowPageAction_RoundtripNoParams verifies that a ShowPageAction without parameters -// survives BSON serialization/deserialization. -func TestShowPageAction_RoundtripNoParams(t *testing.T) { - action := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Customer_Overview", - } - - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - parsed := parseShowPageAction(raw) - - if parsed.PageName != "Sales.Customer_Overview" { - t.Errorf("PageName = %q, want %q", parsed.PageName, "Sales.Customer_Overview") - } - if len(parsed.PageParameterMappings) != 0 { - t.Errorf("PageParameterMappings count = %d, want 0", len(parsed.PageParameterMappings)) - } -} - -// TestShowPageAction_TitleOverride_IsNull replaces an earlier test that asserted the -// opposite. That test reasoned by analogy — "same class of bug as -// FormSettings.ParameterMappings.Variable — issue #295" — but #295 was about -// Forms$PageVariable, a different field, and the conclusion was generalised to -// TitleOverride without ever being observed. -// -// The evidence runs the other way. Studio Pro writes TitleOverride null: a scan of one -// project found 58 correct popups (Studio Pro / marketplace) with null against 10 -// broken ones (mxcli) with an empty template, and this repo's own -// .claude/skills/debug-bson.md documents `{Key: "TitleOverride", Value: nil}` as the -// correct Forms$FormSettings shape. An empty Microflows$TextTemplate is not the -// absence of an override — it overrides the title with the empty string, so every such -// popup rendered with a blank caption and only the close button (#812). -func TestShowPageAction_TitleOverride_IsNull(t *testing.T) { - action := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Product_NewEdit", - } - - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - formSettings := toMap(raw["FormSettings"]) - if formSettings == nil { - t.Fatal("FormSettings missing") - } - - raw2, ok := formSettings["TitleOverride"] - if !ok { - t.Fatal("TitleOverride key missing entirely; Studio Pro writes it as an explicit null") - } - if raw2 != nil { - t.Fatalf("TitleOverride = %#v, want nil — an empty template overrides the page "+ - "title with the empty string, blanking the popup caption (#812)", raw2) - } - - // ...and when the action DOES override the title, the authored text must survive. - // Before #812 the empty template was written either way, so this half was silently - // dropped: OverridePageTitle was set by the builder and read by nothing. - withTitle := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Product_NewEdit", - OverridePageTitle: &model.Text{Translations: map[string]string{"en_US": "Edit Product"}}, - } - data2, err := bson.Marshal(serializeMicroflowAction(withTitle)) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - var rawDoc2 map[string]any - if err := bson.Unmarshal(data2, &rawDoc2); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - to := toMap(toMap(rawDoc2["FormSettings"])["TitleOverride"]) - if to == nil { - t.Fatal("an explicitly authored title override was dropped (#812)") - } - if got := extractString(to["$Type"]); got != "Microflows$TextTemplate" { - t.Fatalf("TitleOverride.$Type = %q, want %q", got, "Microflows$TextTemplate") - } -} - -// TestShowPageAction_RoundtripMultipleParams verifies multiple parameter mappings survive roundtrip. -func TestShowPageAction_RoundtripMultipleParams(t *testing.T) { - action := µflows.ShowPageAction{ - BaseElement: model.BaseElement{ID: "test-action-id"}, - PageName: "Sales.Order_Detail", - PageParameterMappings: []*microflows.PageParameterMapping{ - { - BaseElement: model.BaseElement{ID: "mapping-1"}, - Parameter: "Sales.Order_Detail.Order", - Argument: "$Order", - }, - { - BaseElement: model.BaseElement{ID: "mapping-2"}, - Parameter: "Sales.Order_Detail.Customer", - Argument: "$Customer", - }, - }, - } - - doc := serializeMicroflowAction(action) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("failed to marshal BSON: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("failed to unmarshal BSON: %v", err) - } - - parsed := parseShowPageAction(raw) - - if parsed.PageName != "Sales.Order_Detail" { - t.Errorf("PageName = %q, want %q", parsed.PageName, "Sales.Order_Detail") - } - if len(parsed.PageParameterMappings) != 2 { - t.Fatalf("PageParameterMappings count = %d, want 2", len(parsed.PageParameterMappings)) - } - if parsed.PageParameterMappings[0].Argument != "$Order" { - t.Errorf("first Argument = %q, want %q", parsed.PageParameterMappings[0].Argument, "$Order") - } - if parsed.PageParameterMappings[1].Argument != "$Customer" { - t.Errorf("second Argument = %q, want %q", parsed.PageParameterMappings[1].Argument, "$Customer") - } -} diff --git a/sdk/mpr/system_java_actions.go b/sdk/mpr/system_java_actions.go deleted file mode 100644 index e15ad6fefd..0000000000 --- a/sdk/mpr/system_java_actions.go +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -// The System module's built-in Java actions now live in modelsdk/meta, beside -// the virtual System module's entities and associations. This package keeps the -// two names it exported so its own callers are unaffected, and delegates — two -// copies of a hand-maintained platform list is exactly how the two readers -// would come to disagree about what the System module contains. - -import ( - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/modelsdk/meta" - "github.com/mendixlabs/mxcli/sdk/javaactions" -) - -// BuildSystemJavaActions returns lightweight types.JavaAction entries for the System module. -func BuildSystemJavaActions() []*types.JavaAction { return meta.BuildSystemJavaActions() } - -// BuildSystemJavaActionsFull returns fully-typed javaactions.JavaAction entries for the System module. -func BuildSystemJavaActionsFull() []*javaactions.JavaAction { - return meta.BuildSystemJavaActionsFull() -} diff --git a/sdk/mpr/system_module.go b/sdk/mpr/system_module.go deleted file mode 100644 index ac9903191c..0000000000 --- a/sdk/mpr/system_module.go +++ /dev/null @@ -1,454 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// System module constants — deterministic IDs for the virtual System module. -const ( - SystemModuleID = "00000000-0000-0000-0000-000000000001" - SystemDomainModelID = "00000000-0000-0000-0000-000000000002" -) - -// systemAttrDef defines an attribute in a System entity. -type systemAttrDef struct { - Name string - Type string // "String", "Integer", "Decimal", "Boolean", "DateTime", "Enumeration", "Long", "Binary", "HashedString", "AutoNumber" - Length int // for String type - EnumQN string // for Enumeration type, qualified name -} - -// systemAssocDef defines an association between System entities. -type systemAssocDef struct { - Name string - Parent string // parent entity name (without module prefix) - Child string // child entity name (without module prefix) - Type string // "Reference" or "ReferenceSet" - Owner string // "Default" or "Both" -} - -// systemEntityDef defines a System entity with name, persistability, and attributes. -type systemEntityDef struct { - Name string - Persistable bool - Generalization string // e.g. "System.FileDocument", "System.Error" - Attributes []systemAttrDef -} - -// systemEntities lists all entities in the System module. -// Extracted from Mendix Studio Pro 11.6.4 via DummySystem module. -var systemEntities = []systemEntityDef{ - {Name: "UserRole", Persistable: true, Attributes: []systemAttrDef{ - {Name: "ModelGUID", Type: "String"}, - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - }}, - {Name: "User", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Password", Type: "HashedString"}, - {Name: "LastLogin", Type: "DateTime"}, - {Name: "Blocked", Type: "Boolean"}, - {Name: "BlockedSince", Type: "DateTime"}, - {Name: "Active", Type: "Boolean"}, - {Name: "FailedLogins", Type: "Integer"}, - {Name: "WebServiceUser", Type: "Boolean"}, - {Name: "IsAnonymous", Type: "Boolean"}, - }}, - {Name: "FileDocument", Persistable: true, Attributes: []systemAttrDef{ - {Name: "FileID", Type: "AutoNumber"}, - {Name: "Name", Type: "String"}, - {Name: "DeleteAfterDownload", Type: "Boolean"}, - {Name: "Contents", Type: "Binary"}, - {Name: "HasContents", Type: "Boolean"}, - {Name: "Size", Type: "Long"}, - }}, - {Name: "Image", Persistable: true, Generalization: "System.FileDocument", Attributes: []systemAttrDef{ - {Name: "PublicThumbnailPath", Type: "String"}, - {Name: "EnableCaching", Type: "Boolean"}, - }}, - {Name: "XASInstance", Persistable: true, Attributes: []systemAttrDef{ - {Name: "XASId", Type: "String"}, - {Name: "LastUpdate", Type: "DateTime"}, - {Name: "AllowedNumberOfConcurrentUsers", Type: "Integer"}, - {Name: "PartnerName", Type: "String"}, - {Name: "CustomerName", Type: "String"}, - }}, - {Name: "Session", Persistable: true, Attributes: []systemAttrDef{ - {Name: "SessionId", Type: "String"}, - {Name: "CSRFToken", Type: "String"}, - {Name: "LastActive", Type: "DateTime"}, - }}, - {Name: "ScheduledEventInformation", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "Status", Type: "Enumeration", EnumQN: "System.EventStatus"}, - }}, - {Name: "Language", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Code", Type: "String"}, - {Name: "Description", Type: "String"}, - }}, - {Name: "TimeZone", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Code", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "RawOffset", Type: "Integer"}, - }}, - {Name: "Error", Persistable: false, Attributes: []systemAttrDef{ - {Name: "ErrorType", Type: "String"}, - {Name: "Message", Type: "String"}, - {Name: "Stacktrace", Type: "String"}, - }}, - {Name: "SoapFault", Persistable: true, Generalization: "System.Error", Attributes: []systemAttrDef{ - {Name: "Code", Type: "String"}, - {Name: "Reason", Type: "String"}, - {Name: "Node", Type: "String"}, - {Name: "Role", Type: "String"}, - {Name: "Detail", Type: "String"}, - }}, - {Name: "TokenInformation", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Token", Type: "HashedString"}, - {Name: "ExpiryDate", Type: "DateTime"}, - {Name: "UserAgent", Type: "String"}, - }}, - {Name: "HttpMessage", Persistable: false, Attributes: []systemAttrDef{ - {Name: "HttpVersion", Type: "String"}, - {Name: "Content", Type: "String"}, - }}, - {Name: "HttpHeader", Persistable: false, Attributes: []systemAttrDef{ - {Name: "Key", Type: "String"}, - {Name: "Value", Type: "String"}, - }}, - {Name: "UserReportInfo", Persistable: true, Attributes: []systemAttrDef{ - {Name: "UserType", Type: "Enumeration", EnumQN: "System.UserType"}, - {Name: "Hash", Type: "String"}, - }}, - {Name: "HttpRequest", Persistable: true, Generalization: "System.HttpMessage", Attributes: []systemAttrDef{ - {Name: "Uri", Type: "String"}, - }}, - {Name: "HttpResponse", Persistable: true, Generalization: "System.HttpMessage", Attributes: []systemAttrDef{ - {Name: "StatusCode", Type: "Integer"}, - {Name: "ReasonPhrase", Type: "String"}, - }}, - {Name: "Paging", Persistable: false, Attributes: []systemAttrDef{ - {Name: "PageNumber", Type: "Long"}, - {Name: "IsSortable", Type: "Boolean"}, - {Name: "SortAttribute", Type: "String"}, - {Name: "SortAscending", Type: "Boolean"}, - {Name: "HasMoreData", Type: "Boolean"}, - }}, - {Name: "SynchronizationError", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Reason", Type: "String"}, - {Name: "ObjectId", Type: "String"}, - {Name: "ObjectType", Type: "String"}, - {Name: "ObjectContent", Type: "String"}, - }}, - {Name: "SynchronizationErrorFile", Persistable: true, Generalization: "System.FileDocument"}, - {Name: "ProcessedQueueTask", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Sequence", Type: "Long"}, - {Name: "Status", Type: "Enumeration", EnumQN: "System.QueueTaskStatus"}, - {Name: "QueueId", Type: "String"}, - {Name: "QueueName", Type: "String"}, - {Name: "ContextType", Type: "Enumeration", EnumQN: "System.ContextType"}, - {Name: "ContextData", Type: "String"}, - {Name: "MicroflowName", Type: "String"}, - {Name: "UserActionName", Type: "String"}, - {Name: "Arguments", Type: "String"}, - {Name: "XASId", Type: "String"}, - {Name: "ThreadId", Type: "Long"}, - {Name: "Created", Type: "DateTime"}, - {Name: "StartAt", Type: "DateTime"}, - {Name: "Started", Type: "DateTime"}, - {Name: "Finished", Type: "DateTime"}, - {Name: "Duration", Type: "Long"}, - {Name: "Retried", Type: "Long"}, - {Name: "ErrorMessage", Type: "String"}, - {Name: "ScheduledEventName", Type: "String"}, - }}, - {Name: "QueuedTask", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Sequence", Type: "AutoNumber"}, - {Name: "Status", Type: "Enumeration", EnumQN: "System.QueueTaskStatus"}, - {Name: "QueueId", Type: "String"}, - {Name: "QueueName", Type: "String"}, - {Name: "ContextType", Type: "Enumeration", EnumQN: "System.ContextType"}, - {Name: "ContextData", Type: "String"}, - {Name: "MicroflowName", Type: "String"}, - {Name: "UserActionName", Type: "String"}, - {Name: "Arguments", Type: "String"}, - {Name: "XASId", Type: "String"}, - {Name: "ThreadId", Type: "Long"}, - {Name: "Created", Type: "DateTime"}, - {Name: "StartAt", Type: "DateTime"}, - {Name: "Started", Type: "DateTime"}, - {Name: "Retried", Type: "Long"}, - {Name: "Retry", Type: "String"}, - {Name: "ScheduledEventName", Type: "String"}, - }}, - {Name: "WorkflowDefinition", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Title", Type: "String"}, - {Name: "IsObsolete", Type: "Boolean"}, - {Name: "IsLocked", Type: "Boolean"}, - }}, - {Name: "WorkflowUserTaskDefinition", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "IsObsolete", Type: "Boolean"}, - }}, - {Name: "Workflow", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "DueDate", Type: "DateTime"}, - {Name: "CanBeRestarted", Type: "Boolean"}, - {Name: "CanBeContinued", Type: "Boolean"}, - {Name: "CanApplyJumpTo", Type: "Boolean"}, - {Name: "State", Type: "Enumeration", EnumQN: "System.WorkflowState"}, - {Name: "Reason", Type: "String"}, - }}, - {Name: "WorkflowUserTask", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "DueDate", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "Outcome", Type: "String"}, - {Name: "State", Type: "Enumeration", EnumQN: "System.WorkflowUserTaskState"}, - {Name: "CompletionType", Type: "Enumeration", EnumQN: "System.WorkflowUserTaskCompletionType"}, - }}, - {Name: "TaskQueueToken", Persistable: true, Attributes: []systemAttrDef{ - {Name: "QueueName", Type: "String"}, - {Name: "XASId", Type: "String"}, - {Name: "ValidUntil", Type: "DateTime"}, - }}, - {Name: "ODataResponse", Persistable: false, Attributes: []systemAttrDef{ - {Name: "Count", Type: "Long"}, - }}, - {Name: "WorkflowJumpToDetails", Persistable: false, Attributes: []systemAttrDef{ - {Name: "Error", Type: "String"}, - }}, - {Name: "WorkflowCurrentActivity", Persistable: false, Attributes: []systemAttrDef{ - {Name: "Action", Type: "Enumeration", EnumQN: "System.WorkflowCurrentActivityAction"}, - }}, - {Name: "WorkflowActivityDetails", Persistable: false, Attributes: []systemAttrDef{ - {Name: "ActivityId", Type: "String"}, - {Name: "ActivityCaption", Type: "String"}, - {Name: "ActivityType", Type: "Enumeration", EnumQN: "System.WorkflowActivityType"}, - {Name: "ExistsInCurrentVersion", Type: "Boolean"}, - }}, - {Name: "WorkflowUserTaskOutcome", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Outcome", Type: "String"}, - {Name: "Time", Type: "DateTime"}, - }}, - {Name: "WorkflowRecord", Persistable: false, Attributes: []systemAttrDef{ - {Name: "WorkflowKey", Type: "String"}, - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "State", Type: "Enumeration", EnumQN: "System.WorkflowState"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "DueDate", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "Reason", Type: "String"}, - }}, - {Name: "WorkflowActivityRecord", Persistable: false, Attributes: []systemAttrDef{ - {Name: "ModelGUID", Type: "String"}, - {Name: "ActivityKey", Type: "String"}, - {Name: "PreviousActivityKey", Type: "String"}, - {Name: "ActivityType", Type: "Enumeration", EnumQN: "System.WorkflowActivityType"}, - {Name: "Caption", Type: "String"}, - {Name: "State", Type: "Enumeration", EnumQN: "System.WorkflowActivityExecutionState"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "Outcome", Type: "String"}, - {Name: "MicroflowName", Type: "String"}, - {Name: "TaskName", Type: "String"}, - {Name: "TaskDescription", Type: "String"}, - {Name: "TaskDueDate", Type: "DateTime"}, - {Name: "TaskCompletionType", Type: "Enumeration", EnumQN: "System.WorkflowUserTaskCompletionType"}, - {Name: "TaskRequiredUsers", Type: "Integer"}, - {Name: "TaskKey", Type: "String"}, - {Name: "Reason", Type: "String"}, - }}, - {Name: "WorkflowEvent", Persistable: false, Attributes: []systemAttrDef{ - {Name: "EventTime", Type: "DateTime"}, - {Name: "EventType", Type: "Enumeration", EnumQN: "System.WorkflowEventType"}, - }}, - {Name: "ConsumedODataConfiguration", Persistable: false, Attributes: []systemAttrDef{ - {Name: "ServiceUrl", Type: "String"}, - {Name: "ProxyConfiguration", Type: "Enumeration", EnumQN: "System.ProxyConfiguration"}, - {Name: "ProxyHost", Type: "String"}, - {Name: "ProxyPort", Type: "Integer"}, - {Name: "ProxyUsername", Type: "String"}, - {Name: "ProxyPassword", Type: "String"}, - }}, - {Name: "WorkflowEndedUserTask", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - {Name: "StartTime", Type: "DateTime"}, - {Name: "DueDate", Type: "DateTime"}, - {Name: "EndTime", Type: "DateTime"}, - {Name: "Outcome", Type: "String"}, - {Name: "State", Type: "Enumeration", EnumQN: "System.WorkflowUserTaskState"}, - {Name: "CompletionType", Type: "Enumeration", EnumQN: "System.WorkflowUserTaskCompletionType"}, - {Name: "UserTaskKey", Type: "String"}, - }}, - {Name: "WorkflowEndedUserTaskOutcome", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Outcome", Type: "String"}, - {Name: "Time", Type: "DateTime"}, - }}, - {Name: "WorkflowGroup", Persistable: true, Attributes: []systemAttrDef{ - {Name: "Name", Type: "String"}, - {Name: "Description", Type: "String"}, - }}, -} - -// systemAssociations lists all associations in the System module. -// Extracted from Mendix Studio Pro 11.6.4 via DummySystem module. -var systemAssociations = []systemAssocDef{ - {Name: "grantableRoles", Parent: "UserRole", Child: "UserRole", Type: "ReferenceSet", Owner: "Default"}, - {Name: "UserRoles", Parent: "User", Child: "UserRole", Type: "ReferenceSet", Owner: "Default"}, - {Name: "Session_User", Parent: "Session", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "User_Language", Parent: "User", Child: "Language", Type: "Reference", Owner: "Default"}, - {Name: "User_TimeZone", Parent: "User", Child: "TimeZone", Type: "Reference", Owner: "Default"}, - {Name: "TokenInformation_User", Parent: "TokenInformation", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "HttpHeaders", Parent: "HttpHeader", Child: "HttpMessage", Type: "Reference", Owner: "Default"}, - {Name: "UserReportInfo_User", Parent: "UserReportInfo", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "ScheduledEventInformation_XASInstance", Parent: "ScheduledEventInformation", Child: "XASInstance", Type: "Reference", Owner: "Default"}, - {Name: "SynchronizationErrorFile_SynchronizationError", Parent: "SynchronizationErrorFile", Child: "SynchronizationError", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowUserTaskDefinition_WorkflowDefinition", Parent: "WorkflowUserTaskDefinition", Child: "WorkflowDefinition", Type: "Reference", Owner: "Default"}, - {Name: "Workflow_WorkflowDefinition", Parent: "Workflow", Child: "WorkflowDefinition", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowUserTask_TargetUsers", Parent: "WorkflowUserTask", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowUserTask_Assignees", Parent: "WorkflowUserTask", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowUserTask_Workflow", Parent: "WorkflowUserTask", Child: "Workflow", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowUserTask_WorkflowUserTaskDefinition", Parent: "WorkflowUserTask", Child: "WorkflowUserTaskDefinition", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowJumpToDetails_Workflow", Parent: "WorkflowJumpToDetails", Child: "Workflow", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowJumpToDetails_CurrentActivities", Parent: "WorkflowJumpToDetails", Child: "WorkflowCurrentActivity", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowCurrentActivity_ActivityDetails", Parent: "WorkflowCurrentActivity", Child: "WorkflowActivityDetails", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowCurrentActivity_ApplicableTargets", Parent: "WorkflowCurrentActivity", Child: "WorkflowActivityDetails", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowCurrentActivity_JumpToTarget", Parent: "WorkflowCurrentActivity", Child: "WorkflowActivityDetails", Type: "Reference", Owner: "Default"}, - {Name: "Workflow_ParentWorkflow", Parent: "Workflow", Child: "Workflow", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowUserTaskOutcome_WorkflowUserTask", Parent: "WorkflowUserTaskOutcome", Child: "WorkflowUserTask", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowUserTaskOutcome_User", Parent: "WorkflowUserTaskOutcome", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowRecord_Workflow", Parent: "WorkflowRecord", Child: "Workflow", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowRecord_Owner", Parent: "WorkflowRecord", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowRecord_WorkflowDefinition", Parent: "WorkflowRecord", Child: "WorkflowDefinition", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_PreviousActivity", Parent: "WorkflowActivityRecord", Child: "WorkflowActivityRecord", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_Actor", Parent: "WorkflowActivityRecord", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_SubWorkflow", Parent: "WorkflowActivityRecord", Child: "WorkflowRecord", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_UserTask", Parent: "WorkflowActivityRecord", Child: "WorkflowUserTask", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_WorkflowUserTaskDefinition", Parent: "WorkflowActivityRecord", Child: "WorkflowUserTaskDefinition", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowEvent_Initiator", Parent: "WorkflowEvent", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowActivityRecord_TaskTargetedUsers", Parent: "WorkflowActivityRecord", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowActivityRecord_TaskAssignedUsers", Parent: "WorkflowActivityRecord", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "HttpHeader_ConsumedODataConfiguration", Parent: "HttpHeader", Child: "ConsumedODataConfiguration", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowEndedUserTask_Assignees", Parent: "WorkflowEndedUserTask", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowEndedUserTask_TargetUsers", Parent: "WorkflowEndedUserTask", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowEndedUserTask_WorkflowUserTaskDefinition", Parent: "WorkflowEndedUserTask", Child: "WorkflowUserTaskDefinition", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowEndedUserTask_Workflow", Parent: "WorkflowEndedUserTask", Child: "Workflow", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowEndedUserTaskOutcome_User", Parent: "WorkflowEndedUserTaskOutcome", Child: "User", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowEndedUserTaskOutcome_WorkflowEndedUserTask", Parent: "WorkflowEndedUserTaskOutcome", Child: "WorkflowEndedUserTask", Type: "Reference", Owner: "Default"}, - {Name: "WorkflowGroup_User", Parent: "WorkflowGroup", Child: "User", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowUserTask_TargetGroups", Parent: "WorkflowUserTask", Child: "WorkflowGroup", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowEndedUserTask_TargetGroups", Parent: "WorkflowEndedUserTask", Child: "WorkflowGroup", Type: "ReferenceSet", Owner: "Default"}, - {Name: "WorkflowActivityRecord_TaskTargetedGroups", Parent: "WorkflowActivityRecord", Child: "WorkflowGroup", Type: "ReferenceSet", Owner: "Default"}, -} - -// BuildSystemDomainModel returns a virtual DomainModel for the System module. -func BuildSystemDomainModel() *domainmodel.DomainModel { - dm := &domainmodel.DomainModel{ - ContainerID: model.ID(SystemModuleID), - } - dm.ID = model.ID(SystemDomainModelID) - dm.TypeName = "DomainModels$DomainModel" - - // Build entity name -> ID map for association resolution - entityIDMap := make(map[string]model.ID, len(systemEntities)) - - for _, def := range systemEntities { - entityID := GenerateDeterministicID("System." + def.Name) - entity := &domainmodel.Entity{ - ContainerID: model.ID(SystemDomainModelID), - Name: def.Name, - Persistable: def.Persistable, - } - entity.ID = model.ID(entityID) - entityIDMap[def.Name] = entity.ID - - if def.Generalization != "" { - genID := GenerateDeterministicID("System." + def.Generalization + ".gen") - gen := domainmodel.GeneralizationBase{} - gen.ID = model.ID(genID) - gen.GeneralizationID = model.ID(GenerateDeterministicID(def.Generalization)) - entity.Generalization = gen - entity.GeneralizationRef = def.Generalization - } - - // Add attributes - for _, attrDef := range def.Attributes { - attrID := GenerateDeterministicID("System." + def.Name + "." + attrDef.Name) - attr := &domainmodel.Attribute{ - ContainerID: entity.ID, - Name: attrDef.Name, - } - attr.ID = model.ID(attrID) - - switch attrDef.Type { - case "String": - attr.Type = &domainmodel.StringAttributeType{Length: attrDef.Length} - case "Integer": - attr.Type = &domainmodel.IntegerAttributeType{} - case "Long": - attr.Type = &domainmodel.LongAttributeType{} - case "Decimal": - attr.Type = &domainmodel.DecimalAttributeType{} - case "Boolean": - attr.Type = &domainmodel.BooleanAttributeType{} - case "DateTime": - attr.Type = &domainmodel.DateTimeAttributeType{} - case "Enumeration": - attr.Type = &domainmodel.EnumerationAttributeType{ - EnumerationRef: attrDef.EnumQN, - } - case "AutoNumber": - attr.Type = &domainmodel.AutoNumberAttributeType{} - case "Binary": - attr.Type = &domainmodel.BinaryAttributeType{} - case "HashedString": - attr.Type = &domainmodel.HashedStringAttributeType{} - } - - entity.Attributes = append(entity.Attributes, attr) - } - - dm.Entities = append(dm.Entities, entity) - } - - // Add associations - for _, def := range systemAssociations { - assocID := GenerateDeterministicID("System." + def.Name) - assoc := &domainmodel.Association{ - ContainerID: model.ID(SystemDomainModelID), - Name: def.Name, - ParentID: entityIDMap[def.Parent], - ChildID: entityIDMap[def.Child], - Type: domainmodel.AssociationType(def.Type), - Owner: domainmodel.AssociationOwner(def.Owner), - } - assoc.ID = model.ID(assocID) - dm.Associations = append(dm.Associations, assoc) - } - - return dm -} - -// BuildSystemModule returns a virtual Module for the System module. -func BuildSystemModule() *model.Module { - m := &model.Module{ - Name: "System", - } - m.ID = model.ID(SystemModuleID) - return m -} diff --git a/sdk/mpr/testdata/enumerations/PictureQuality.mxunit b/sdk/mpr/testdata/enumerations/PictureQuality.mxunit deleted file mode 100644 index d9fa89f20f81fbd238d9b21513068d4b9efdcb13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1685 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 GE6OB0Uf|-LT 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

abp&R}1^v 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%0ADW<&fbxcppLFr%43{+&sl?Z`C9*x#}3alwOiLd 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 diff --git a/sdk/mpr/testdata/workflows/WorkflowBaseline.Workflow.bson b/sdk/mpr/testdata/workflows/WorkflowBaseline.Workflow.bson deleted file mode 100644 index 57106943856de763c8377854b9f22025d7961294..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13869 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 EKOrFxQvd(} diff --git a/sdk/mpr/text_language_test.go b/sdk/mpr/text_language_test.go deleted file mode 100644 index 1f95738804..0000000000 --- a/sdk/mpr/text_language_test.go +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// mendixlabs/mxcli#970, stage 2. The executor fixed the sites that CREATE a -// model.Text; these are the writer leaves that build a Texts$Text straight from -// a bare Go string, where the model never carried a language at all. A widget -// label is the reachable case: pages.TextBox.Label is a string, so -// serializeLabelTemplate is the only thing that can choose its LanguageCode. -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// languageCodesIn walks serialized BSON and collects every LanguageCode written. -func languageCodesIn(v any) []string { - var out []string - switch t := v.(type) { - case bson.D: - for _, e := range t { - if e.Key == "LanguageCode" { - if s, ok := e.Value.(string); ok { - out = append(out, s) - } - continue - } - out = append(out, languageCodesIn(e.Value)...) - } - case bson.A: - for _, e := range t { - out = append(out, languageCodesIn(e)...) - } - case []any: - for _, e := range t { - out = append(out, languageCodesIn(e)...) - } - } - return out -} - -func TestLabelTemplateUsesAuthoringLanguage(t *testing.T) { - orig := model.AuthoringLanguage() - t.Cleanup(func() { model.SetAuthoringLanguage(orig) }) - - for _, tc := range []struct{ set, want string }{ - {"nl_NL", "nl_NL"}, - {"en_US", "en_US"}, // the common case must not regress - {"", "en_US"}, // unset falls back to the pre-fix behaviour - } { - t.Run(tc.want+"/"+tc.set, func(t *testing.T) { - model.SetAuthoringLanguage(tc.set) - got := languageCodesIn(serializeLabelTemplate("Opslaan")) - if len(got) == 0 { - t.Fatal("no LanguageCode written for a label") - } - for _, code := range got { - if code != tc.want { - t.Errorf("label LanguageCode = %q, want %q (mendixlabs/mxcli#970)", code, tc.want) - } - } - }) - } -} diff --git a/sdk/mpr/utils.go b/sdk/mpr/utils.go deleted file mode 100644 index 17609ac99c..0000000000 --- a/sdk/mpr/utils.go +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "go.mongodb.org/mongo-driver/bson/primitive" - - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/mdl/types" -) - -// GenerateID generates a new unique ID for model elements. -func GenerateID() string { - return types.GenerateID() -} - -// GenerateDeterministicID generates a stable UUID from a seed string. -func GenerateDeterministicID(seed string) string { - return types.GenerateDeterministicID(seed) -} - -// BlobToUUID converts a binary ID blob to a UUID string. -func BlobToUUID(data []byte) string { - return types.BlobToUUID(data) -} - -// IDToBsonBinary converts a UUID string to a BSON binary value. -// For invalid or empty UUIDs (e.g. test placeholders), falls back to generating -// a random ID to maintain backward compatibility with existing serialization paths. -// For strict validation, use bsonutil.IDToBsonBinaryErr. -func IDToBsonBinary(id string) primitive.Binary { - return idToBsonBinary(id) -} - -// BsonBinaryToID converts a BSON binary value to a UUID string. -func BsonBinaryToID(bin primitive.Binary) string { - return bsonutil.BsonBinaryToID(bin) -} - -// ValidateID checks if an ID is valid. -func ValidateID(id string) bool { - return types.ValidateID(id) -} diff --git a/sdk/mpr/version/version.go b/sdk/mpr/version/version.go deleted file mode 100644 index 6e8d36df32..0000000000 --- a/sdk/mpr/version/version.go +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package version provides Mendix project version detection and handling. -package version - -import ( - "database/sql" - "fmt" - "strconv" - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/versions" -) - -// ProjectVersion is an alias for types.ProjectVersion. -// All version comparison methods (IsAtLeast, IsAtLeastFull, String, IsMPRv2) -// are defined on types.ProjectVersion directly. -type ProjectVersion = types.ProjectVersion - -// DefaultVersion returns the default version (11.6.0) used when detection fails. -func DefaultVersion() *ProjectVersion { - return &ProjectVersion{ - ProductVersion: "11.6.0", - BuildVersion: "11.6.0", - FormatVersion: 2, - MajorVersion: 11, - MinorVersion: 6, - PatchVersion: 0, - } -} - -// DetectFromDB reads version information from the MPR database. -func DetectFromDB(db *sql.DB) (*ProjectVersion, error) { - var formatVersion int - var productVersion, buildVersion, schemaHash string - - // Try the old schema first (with _FormatVersion) - row := db.QueryRow("SELECT _FormatVersion, _ProductVersion, _BuildVersion, _SchemaHash FROM _MetaData LIMIT 1") - err := row.Scan(&formatVersion, &productVersion, &buildVersion, &schemaHash) - if err != nil { - if err == sql.ErrNoRows { - // Return default if no metadata found - return DefaultVersion(), nil - } - // Try new schema without _FormatVersion (Mendix 11.6.2+) - row = db.QueryRow("SELECT _ProductVersion, _BuildVersion, _SchemaHash FROM _MetaData LIMIT 1") - err = row.Scan(&productVersion, &buildVersion, &schemaHash) - if err != nil { - if err == sql.ErrNoRows { - return DefaultVersion(), nil - } - return nil, fmt.Errorf("failed to read version metadata: %w", err) - } - // Default format version to 2 for newer schemas - formatVersion = 2 - } - - pv := &ProjectVersion{ - ProductVersion: productVersion, - BuildVersion: buildVersion, - FormatVersion: formatVersion, - SchemaHash: schemaHash, - } - - // Parse version components - pv.MajorVersion, pv.MinorVersion, pv.PatchVersion = parseVersion(productVersion) - - return pv, nil -} - -// parseVersion extracts major, minor, patch from a version string like "10.18.0" -func parseVersion(version string) (major, minor, patch int) { - parts := strings.Split(version, ".") - if len(parts) >= 1 { - major, _ = strconv.Atoi(parts[0]) - } - if len(parts) >= 2 { - minor, _ = strconv.Atoi(parts[1]) - } - if len(parts) >= 3 { - patch, _ = strconv.Atoi(parts[2]) - } - return -} - -// SupportedVersionRange defines the range of Mendix versions supported for read/write. -var SupportedVersionRange = struct { - MinMajor int - MaxMajor int -}{ - MinMajor: 9, - MaxMajor: 11, -} - -// IsSupported returns true if pv is within the supported range for writing. -func IsSupported(pv *ProjectVersion) bool { - return pv.MajorVersion >= SupportedVersionRange.MinMajor && - pv.MajorVersion <= SupportedVersionRange.MaxMajor -} - -// SupportsFeature checks if a specific feature is available in the given version. -// It first checks the YAML-based version registry, falling back to the -// hardcoded featureVersions map for features not yet in the registry. -func SupportsFeature(pv *ProjectVersion, feature Feature) bool { - // Try the YAML registry first via the feature-to-registry mapping. - if mapping, ok := featureRegistry[feature]; ok { - reg, err := versions.Load() - if err == nil { - sv := versions.SemVer{Major: pv.MajorVersion, Minor: pv.MinorVersion, Patch: pv.PatchVersion} - return reg.IsAvailable(mapping.Area, mapping.Name, sv) - } - } - - // Fallback to hardcoded map. - minVersion, ok := featureVersions[feature] - if !ok { - return false - } - return pv.IsAtLeast(minVersion.Major, minVersion.Minor) -} - -// Feature represents a Mendix feature that may or may not be available. -type Feature string - -// Known features with version requirements -const ( - FeatureViewEntities Feature = "ViewEntities" - FeatureAssociationStorage Feature = "AssociationStorageFormat" - FeatureMPRv2 Feature = "MPRv2Format" - FeatureBusinessEvents Feature = "BusinessEvents" - FeatureWorkflows Feature = "Workflows" - FeaturePortableApp Feature = "PortableApp" -) - -// registryMapping maps a Feature constant to its area.name in the YAML registry. -type registryMapping struct { - Area string - Name string -} - -// featureRegistry maps Feature constants to their YAML registry keys. -var featureRegistry = map[Feature]registryMapping{ - FeatureViewEntities: {Area: "domain_model", Name: "view_entities"}, - FeatureAssociationStorage: {Area: "mpr_format", Name: "association_storage"}, - FeatureMPRv2: {Area: "mpr_format", Name: "mpr_v2"}, - FeatureBusinessEvents: {Area: "integration", Name: "business_events"}, - FeatureWorkflows: {Area: "workflows", Name: "basic"}, - FeaturePortableApp: {Area: "mpr_format", Name: "portable_app"}, -} - -// MinVersion represents a minimum version requirement. -type MinVersion struct { - Major int - Minor int -} - -// featureVersions maps features to their minimum required versions. -// This is the fallback when the YAML registry is unavailable. -var featureVersions = map[Feature]MinVersion{ - FeatureViewEntities: {Major: 10, Minor: 18}, - FeatureAssociationStorage: {Major: 11, Minor: 0}, - FeatureMPRv2: {Major: 10, Minor: 18}, - FeatureBusinessEvents: {Major: 10, Minor: 0}, - FeatureWorkflows: {Major: 9, Minor: 0}, - FeaturePortableApp: {Major: 11, Minor: 6}, -} diff --git a/sdk/mpr/workflow_agent_test.go b/sdk/mpr/workflow_agent_test.go deleted file mode 100644 index 6e27dd8dac..0000000000 --- a/sdk/mpr/workflow_agent_test.go +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/workflows" -) - -// The legacy engine is being retired, but it must not turn an AI agent task into -// a plain call microflow on the way through: same document shape, agent $Type. -func TestWorkflowAgentTask_LegacySerializeAndParse(t *testing.T) { - doc := serializeCallMicroflowTask(&workflows.CallMicroflowTask{IsAgent: true, Microflow: "M.InvokeAgent"}) - if got := getBSONField(doc, "$Type"); got != "Workflows$AIAgentTaskActivity" { - t.Errorf("$Type = %v, want Workflows$AIAgentTaskActivity", got) - } - plain := serializeCallMicroflowTask(&workflows.CallMicroflowTask{Microflow: "M.Plain"}) - if got := getBSONField(plain, "$Type"); got != "Workflows$CallMicroflowTask" { - t.Errorf("plain $Type = %v", got) - } - - act := parseWorkflowActivity(map[string]any{ - "$Type": "Workflows$AIAgentTaskActivity", - "Name": "aiAgentTask1", - "Microflow": "M.InvokeAgent", - }) - cm, ok := act.(*workflows.CallMicroflowTask) - if !ok || !cm.IsAgent || cm.Microflow != "M.InvokeAgent" { - t.Errorf("parsed = %#v", act) - } -} diff --git a/sdk/mpr/workflow_endpath_serialize_test.go b/sdk/mpr/workflow_endpath_serialize_test.go deleted file mode 100644 index 5c742bf245..0000000000 --- a/sdk/mpr/workflow_endpath_serialize_test.go +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/workflows" - "go.mongodb.org/mongo-driver/bson" -) - -// The legacy serializer's activity switch returned nil for both end-of-path -// markers, so a path built with one lost it on write — the runtime then skips -// the path's contents (ako/view-entity-examples §6). -func TestSerializeEndOfPathMarkers(t *testing.T) { - for want, act := range map[string]workflows.WorkflowActivity{ - "Workflows$EndOfParallelSplitPathActivity": &workflows.EndOfParallelSplitPathActivity{}, - "Workflows$EndOfBoundaryEventPathActivity": &workflows.EndOfBoundaryEventPathActivity{}, - } { - doc := serializeWorkflowActivity(act) - if doc == nil { - t.Errorf("%s serialized to nil — the marker would be dropped", want) - continue - } - got := "" - for _, e := range doc { - if e.Key == "$Type" { - got, _ = e.Value.(string) - } - } - if got != want { - t.Errorf("$Type = %q, want %q", got, want) - } - } -} - -var _ = bson.D{} diff --git a/sdk/mpr/workflow_handlers_test.go b/sdk/mpr/workflow_handlers_test.go deleted file mode 100644 index 79b6b72c71..0000000000 --- a/sdk/mpr/workflow_handlers_test.go +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "reflect" - "testing" - - "github.com/mendixlabs/mxcli/sdk/workflows" - "go.mongodb.org/mongo-driver/bson" -) - -// The handler shape is ako/TestApp's workflow.Workflow1 (Studio Pro 11.14.0): -// OnWorkflowEvent is a marker-2 list, each handler's EventTypes a marker-1 list -// of strings, and the microflow sits in a nested MicroflowEventHandler. -func TestSerializeWorkflowEventHandlers_Shape(t *testing.T) { - arr := serializeWorkflowEventHandlers([]*workflows.WorkflowEventHandler{{ - Description: "Task audit", - EventTypes: []string{"UserTaskStarted", "UserTaskEnded"}, - Microflow: "M.ACT_Audit", - }}) - if len(arr) != 2 || arr[0] != int32(2) { - t.Fatalf("OnWorkflowEvent = %v, want marker 2 and one handler", arr) - } - h, ok := arr[1].(bson.D) - if !ok { - t.Fatalf("handler is %T", arr[1]) - } - var keys []string - for _, e := range h { - keys = append(keys, e.Key) - } - if want := []string{"$ID", "$Type", "Description", "Documentation", "EventTypes", "MicroflowEventHandler"}; !reflect.DeepEqual(keys, want) { - t.Errorf("handler keys = %v, want %v", keys, want) - } - if got := getBSONField(h, "$Type"); got != "Workflows$WorkflowEventHandler" { - t.Errorf("$Type = %v", got) - } - if got := getBSONField(h, "EventTypes"); !reflect.DeepEqual(got, bson.A{int32(1), "UserTaskStarted", "UserTaskEnded"}) { - t.Errorf("EventTypes = %v", got) - } - mh, ok := getBSONField(h, "MicroflowEventHandler").(bson.D) - if !ok || getBSONField(mh, "$Type") != "Workflows$MicroflowEventHandler" || getBSONField(mh, "Microflow") != "M.ACT_Audit" { - t.Errorf("MicroflowEventHandler = %v", mh) - } -} - -func TestSerializeWorkflowEventHandlers_NoneIsTheBareMarker(t *testing.T) { - if got := serializeWorkflowEventHandlers(nil); !reflect.DeepEqual(got, bson.A{int32(2)}) { - t.Errorf("OnWorkflowEvent = %v, want [2]", got) - } -} - -func TestSerializeOnCreatedEvent(t *testing.T) { - none := serializeOnCreatedEvent("") - if getBSONField(none, "$Type") != "Workflows$NoEvent" || getBSONField(none, "Microflow") != nil { - t.Errorf("no microflow = %v, want a bare NoEvent", none) - } - ev := serializeOnCreatedEvent("M.ACT_Assign") - if getBSONField(ev, "$Type") != "Workflows$MicroflowBasedEvent" || getBSONField(ev, "Microflow") != "M.ACT_Assign" { - t.Errorf("microflow = %v", ev) - } -} - -// The parser read OnCreatedEvent as a string, which a stored document never is, -// so every on-created microflow read back as none — and a describe of it lost -// it. Round-tripped through real BSON bytes, as the reader sees them. -func TestParseWorkflow_OnCreatedAndHandlersRoundTrip(t *testing.T) { - doc := bson.D{ - {Key: "$Type", Value: "Workflows$Workflow"}, - {Key: "OnWorkflowEvent", Value: serializeWorkflowEventHandlers([]*workflows.WorkflowEventHandler{ - {Description: "OnAnyEvent", Documentation: "kept", EventTypes: []string{"WorkflowCompleted"}, Microflow: "M.ACT_Log"}, - })}, - {Key: "Task", Value: bson.D{ - {Key: "$Type", Value: "Workflows$SingleUserTaskActivity"}, - {Key: "Name", Value: "userTask1"}, - {Key: "OnCreatedEvent", Value: serializeOnCreatedEvent("M.ACT_Assign")}, - }}, - } - data, err := bson.Marshal(doc) - if err != nil { - t.Fatal(err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatal(err) - } - - handlers := parseWorkflowEventHandlers(raw["OnWorkflowEvent"]) - if len(handlers) != 1 { - t.Fatalf("handlers = %d, want 1", len(handlers)) - } - h := handlers[0] - if h.Description != "OnAnyEvent" || h.Documentation != "kept" || h.Microflow != "M.ACT_Log" || - !reflect.DeepEqual(h.EventTypes, []string{"WorkflowCompleted"}) { - t.Errorf("handler = %+v", h) - } - - task := parseUserTask(toMap(raw["Task"])) - if task.OnCreated != "M.ACT_Assign" { - t.Errorf("OnCreated = %q, want M.ACT_Assign", task.OnCreated) - } -} diff --git a/sdk/mpr/workflow_parse_test.go b/sdk/mpr/workflow_parse_test.go deleted file mode 100644 index c5eb7cb6f0..0000000000 --- a/sdk/mpr/workflow_parse_test.go +++ /dev/null @@ -1,388 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "os" - "path/filepath" - "testing" - - "github.com/mendixlabs/mxcli/sdk/workflows" - "go.mongodb.org/mongo-driver/bson" -) - -// loadWorkflowBSON loads a workflow BSON fixture from testdata/workflows/.bson. -func loadWorkflowBSON(t *testing.T, name string) map[string]any { - t.Helper() - data, err := os.ReadFile(filepath.Join("testdata", "workflows", name+".bson")) - if err != nil { - t.Fatalf("load fixture %s: %v", name, err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal fixture %s: %v", name, err) - } - return raw -} - -// workflowActivities returns the activity slice (skipping the array marker) from a flow map. -func workflowActivities(t *testing.T, flowRaw map[string]any) []map[string]any { - t.Helper() - arr, ok := flowRaw["Activities"].(bson.A) - if !ok { - t.Fatalf("Activities is not bson.A, got %T", flowRaw["Activities"]) - } - var acts []map[string]any - for _, item := range arr[1:] { // skip marker at index 0 - m := toMap(item) - if m != nil { - acts = append(acts, m) - } - } - return acts -} - -func TestParseWorkflowParameter_FromFixture(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - paramRaw := toMap(raw["Parameter"]) - if paramRaw == nil { - t.Fatal("fixture has no Parameter") - } - - param := parseWorkflowParameter(paramRaw) - if param == nil { - t.Fatal("parseWorkflowParameter returned nil") - } - if param.EntityRef != "WorkflowBaseline.Entity" { - t.Errorf("EntityRef = %q, want %q", param.EntityRef, "WorkflowBaseline.Entity") - } -} - -func TestParseWorkflowFlow_FromFixture_ActivityCount(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - if flowRaw == nil { - t.Fatal("fixture has no Flow") - } - - flow := parseWorkflowFlow(flowRaw) - if flow == nil { - t.Fatal("parseWorkflowFlow returned nil") - } - // Fixture has: Start, SingleUserTask, MultiUserTask, CallMicroflow, ParallelSplit, ExclusiveSplit, End - if len(flow.Activities) != 7 { - t.Errorf("len(Activities) = %d, want 7", len(flow.Activities)) - } -} - -func TestParseWorkflowActivity_FromFixture_StartIsFirst(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - activity := parseWorkflowActivity(acts[0]) - if _, ok := activity.(*workflows.StartWorkflowActivity); !ok { - t.Errorf("activities[0] = %T, want *workflows.StartWorkflowActivity", activity) - } -} - -func TestParseWorkflowActivity_FromFixture_UserTask(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - // activities[1] is SingleUserTaskActivity in fixture - activity := parseWorkflowActivity(acts[1]) - userTask, ok := activity.(*workflows.UserTask) - if !ok { - t.Fatalf("activities[1] = %T, want *workflows.UserTask", activity) - } - if userTask.Name != "userTask1" { - t.Errorf("Name = %q, want %q", userTask.Name, "userTask1") - } -} - -func TestParseWorkflowActivity_FromFixture_CallMicroflow(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - // activities[3] is CallMicroflowTask in fixture - activity := parseWorkflowActivity(acts[3]) - callMf, ok := activity.(*workflows.CallMicroflowTask) - if !ok { - t.Fatalf("activities[3] = %T, want *workflows.CallMicroflowTask", activity) - } - if callMf.Microflow != "WorkflowBaseline.Microflow" { - t.Errorf("Microflow = %q, want %q", callMf.Microflow, "WorkflowBaseline.Microflow") - } -} - -func TestParseWorkflowActivity_FromFixture_EndIsLast(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - last := parseWorkflowActivity(acts[len(acts)-1]) - if _, ok := last.(*workflows.EndWorkflowActivity); !ok { - t.Errorf("last activity = %T, want *workflows.EndWorkflowActivity", last) - } -} - -func TestParseUserTaskOutcome_FromFixture(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - // activities[1] is SingleUserTaskActivity with one outcome - outcomesRaw := acts[1]["Outcomes"] - arr, ok := outcomesRaw.(bson.A) - if !ok || len(arr) < 2 { - t.Fatalf("expected Outcomes array with marker+1 element, got %T len=%d", outcomesRaw, len(arr)) - } - outcomeMap := toMap(arr[1]) // skip marker - if outcomeMap == nil { - t.Fatal("outcome element is nil") - } - - outcome := parseUserTaskOutcome(outcomeMap) - if outcome == nil { - t.Fatal("parseUserTaskOutcome returned nil") - } - if outcome.Value != "Outcome" { - t.Errorf("Value = %q, want %q", outcome.Value, "Outcome") - } -} - -func TestParseParameterMappings_FromFixture(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - acts := workflowActivities(t, flowRaw) - - // activities[3] is CallMicroflowTask with 1 parameter mapping - mappingsRaw := acts[3]["ParameterMappings"] - mappings := parseParameterMappings(mappingsRaw) - if len(mappings) != 1 { - t.Fatalf("len(mappings) = %d, want 1", len(mappings)) - } -} - -func TestParseWorkflowFlow_FromFixture_SubWorkflow(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Sub_Workflow") - flowRaw := toMap(raw["Flow"]) - if flowRaw == nil { - t.Fatal("Sub_Workflow fixture has no Flow") - } - - flow := parseWorkflowFlow(flowRaw) - if flow == nil { - t.Fatal("parseWorkflowFlow returned nil") - } - if len(flow.Activities) != 2 { - t.Errorf("Sub_Workflow has %d activities, want exactly 2 (Start+End)", len(flow.Activities)) - } -} - -func TestParseWorkflowParameter_Nil(t *testing.T) { - param := parseWorkflowParameter(nil) - if param != nil { - t.Errorf("parseWorkflowParameter(nil) = %v, want nil", param) - } -} - -func TestParseWorkflowActivity_UnknownType(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SomeUnknownFutureActivity", - "$ID": "abc123", - "Name": "mystery", - } - activity := parseWorkflowActivity(raw) - generic, ok := activity.(*workflows.GenericWorkflowActivity) - if !ok { - t.Fatalf("unknown type = %T, want *workflows.GenericWorkflowActivity", activity) - } - if generic.Name != "mystery" { - t.Errorf("Name = %q, want %q", generic.Name, "mystery") - } -} - -func TestParseUserTask_UserTargeting_XPath(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-001", - "Name": "reviewTask", - "Caption": "Review Request", - "UserTargeting": map[string]any{ - "$Type": "Workflows$XPathUserTargeting", - "$ID": "tgt-001", - "XPathConstraint": "[System.UserRoles = '[%UserRole_Manager%]']", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - xpathSource, ok := task.UserSource.(*workflows.XPathBasedUserSource) - if !ok { - t.Fatalf("UserSource = %T, want *workflows.XPathBasedUserSource", task.UserSource) - } - if xpathSource.XPath != "[System.UserRoles = '[%UserRole_Manager%]']" { - t.Errorf("XPath = %q, want %q", xpathSource.XPath, "[System.UserRoles = '[%UserRole_Manager%]']") - } -} - -func TestParseUserTask_UserTargeting_Microflow(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-002", - "Name": "approvalTask", - "Caption": "Approval", - "UserTargeting": map[string]any{ - "$Type": "Workflows$MicroflowUserTargeting", - "$ID": "tgt-002", - "Microflow": "MyModule.GetTargetUsers", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - mfSource, ok := task.UserSource.(*workflows.MicroflowBasedUserSource) - if !ok { - t.Fatalf("UserSource = %T, want *workflows.MicroflowBasedUserSource", task.UserSource) - } - if mfSource.Microflow != "MyModule.GetTargetUsers" { - t.Errorf("Microflow = %q, want %q", mfSource.Microflow, "MyModule.GetTargetUsers") - } -} - -func TestParseUserTask_UserTargeting_NoTargeting(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-003", - "Name": "simpleTask", - "Caption": "Simple", - "UserTargeting": map[string]any{ - "$Type": "Workflows$NoUserTargeting", - "$ID": "tgt-003", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - if _, ok := task.UserSource.(*workflows.NoUserSource); !ok { - t.Errorf("UserSource = %T, want *workflows.NoUserSource", task.UserSource) - } -} - -func TestParseUserTask_UserTargeting_GroupMicroflow(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-005", - "Name": "groupTask", - "Caption": "Group Review", - "UserTargeting": map[string]any{ - "$Type": "Workflows$MicroflowGroupTargeting", - "$ID": "tgt-005", - "Microflow": "MyModule.GetTargetGroups", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - groupSource, ok := task.UserSource.(*workflows.MicroflowGroupSource) - if !ok { - t.Fatalf("UserSource = %T, want *workflows.MicroflowGroupSource", task.UserSource) - } - if groupSource.Microflow != "MyModule.GetTargetGroups" { - t.Errorf("Microflow = %q, want %q", groupSource.Microflow, "MyModule.GetTargetGroups") - } -} - -func TestParseUserTask_UserTargeting_GroupXPath(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-006", - "Name": "groupXPathTask", - "Caption": "Group XPath Review", - "UserTargeting": map[string]any{ - "$Type": "Workflows$XPathGroupTargeting", - "$ID": "tgt-006", - "XPathConstraint": "[GroupType = 'Reviewers']", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - groupSource, ok := task.UserSource.(*workflows.XPathGroupSource) - if !ok { - t.Fatalf("UserSource = %T, want *workflows.XPathGroupSource", task.UserSource) - } - if groupSource.XPath != "[GroupType = 'Reviewers']" { - t.Errorf("XPath = %q, want %q", groupSource.XPath, "[GroupType = 'Reviewers']") - } -} - -func TestParseUserTask_LegacyUserSource_StillWorks(t *testing.T) { - raw := map[string]any{ - "$Type": "Workflows$SingleUserTaskActivity", - "$ID": "ut-004", - "Name": "legacyTask", - "Caption": "Legacy", - "UserSource": map[string]any{ - "$Type": "Workflows$MicroflowBasedUserSource", - "$ID": "src-001", - "Microflow": "OldModule.OldMicroflow", - }, - } - task := parseUserTask(raw) - if task == nil { - t.Fatal("parseUserTask returned nil") - } - mfSource, ok := task.UserSource.(*workflows.MicroflowBasedUserSource) - if !ok { - t.Fatalf("UserSource = %T, want *workflows.MicroflowBasedUserSource", task.UserSource) - } - if mfSource.Microflow != "OldModule.OldMicroflow" { - t.Errorf("Microflow = %q, want %q", mfSource.Microflow, "OldModule.OldMicroflow") - } -} - -func TestParseBoundaryEvents_EmptyArray(t *testing.T) { - // nil input - events := parseBoundaryEvents(nil) - if len(events) != 0 { - t.Errorf("parseBoundaryEvents(nil) len = %d, want 0", len(events)) - } - // marker-only array (bson.A with just the int32 marker) - events = parseBoundaryEvents(bson.A{int32(2)}) - if len(events) != 0 { - t.Errorf("parseBoundaryEvents(marker-only) len = %d, want 0", len(events)) - } -} - -func TestParseBoundaryEvents_TimerEvent(t *testing.T) { - eventMap := map[string]any{ - "$Type": "Workflows$InterruptingTimerBoundaryEvent", - "$ID": "be-001", - "Caption": "Timeout", - "FirstExecutionTime": "PT1H", - } - events := parseBoundaryEvents(bson.A{int32(2), eventMap}) - if len(events) != 1 { - t.Fatalf("len(events) = %d, want 1", len(events)) - } - ev := events[0] - if ev.EventType != "InterruptingTimer" { - t.Errorf("EventType = %q, want %q", ev.EventType, "InterruptingTimer") - } - if ev.TimerDelay != "PT1H" { - t.Errorf("TimerDelay = %q, want %q", ev.TimerDelay, "PT1H") - } - if ev.Caption != "Timeout" { - t.Errorf("Caption = %q, want %q", ev.Caption, "Timeout") - } -} diff --git a/sdk/mpr/workflow_write_test.go b/sdk/mpr/workflow_write_test.go deleted file mode 100644 index f167639906..0000000000 --- a/sdk/mpr/workflow_write_test.go +++ /dev/null @@ -1,404 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/workflows" - "go.mongodb.org/mongo-driver/bson" -) - -func getBSONField(doc bson.D, key string) any { - for _, e := range doc { - if e.Key == key { - return e.Value - } - } - return nil -} - -func assertArrayMarker(t *testing.T, doc bson.D, field string, wantMarker int32) { - t.Helper() - arr, ok := getBSONField(doc, field).(bson.A) - if !ok { - t.Fatalf("%s is not bson.A", field) - } - if len(arr) == 0 { - t.Fatalf("%s is empty", field) - } - marker, ok := arr[0].(int32) - if !ok { - t.Fatalf("%s[0] is %T, want int32", field, arr[0]) - } - if marker != wantMarker { - t.Errorf("%s[0] = %d, want %d", field, marker, wantMarker) - } -} - -// --- Array marker tests: verify correct int32 markers prevent CE errors --- - -func TestSerializeWorkflowFlow_ActivitiesMarker(t *testing.T) { - flow := &workflows.Flow{ - BaseElement: model.BaseElement{ID: "flow-1"}, - Activities: []workflows.WorkflowActivity{ - &workflows.StartWorkflowActivity{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "start-1"}, - Name: "Start", - }, - }, - }, - } - doc := serializeWorkflowFlow(flow) - assertArrayMarker(t, doc, "Activities", int32(3)) -} - -func TestSerializeWorkflowFlow_EmptyActivities(t *testing.T) { - flow := &workflows.Flow{BaseElement: model.BaseElement{ID: "flow-empty"}} - doc := serializeWorkflowFlow(flow) - assertArrayMarker(t, doc, "Activities", int32(3)) - arr := getBSONField(doc, "Activities").(bson.A) - if len(arr) != 1 { - t.Errorf("empty Activities length = %d, want 1 (marker only)", len(arr)) - } -} - -func TestSerializeUserTask_OutcomesMarker(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "ut-1"}, - Name: "ReviewTask", - }, - Outcomes: []*workflows.UserTaskOutcome{ - {BaseElement: model.BaseElement{ID: "out-1"}, Value: "Approve"}, - }, - } - doc := serializeUserTask(task) - assertArrayMarker(t, doc, "Outcomes", int32(3)) -} - -func TestSerializeUserTask_BoundaryEventsMarker(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "ut-2"}, - Name: "Task", - }, - } - doc := serializeUserTask(task) - assertArrayMarker(t, doc, "BoundaryEvents", int32(2)) -} - -func TestSerializeCallMicroflowTask_ParameterMappingsMarker(t *testing.T) { - task := &workflows.CallMicroflowTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "cmt-1"}, - Name: "CallMF", - }, - Microflow: "MyModule.DoSomething", - ParameterMappings: []*workflows.ParameterMapping{ - {BaseElement: model.BaseElement{ID: "pm-1"}, Parameter: "InputParam", Expression: "$WorkflowContext"}, - }, - } - doc := serializeCallMicroflowTask(task) - assertArrayMarker(t, doc, "ParameterMappings", int32(2)) -} - -func TestSerializeUserTaskOutcome_ValueField(t *testing.T) { - outcome := &workflows.UserTaskOutcome{ - BaseElement: model.BaseElement{ID: "uto-1"}, - Value: "Approve", - } - doc := serializeUserTaskOutcome(outcome) - - if getBSONField(doc, "Value") != "Approve" { - t.Errorf("Value = %v, want %q", getBSONField(doc, "Value"), "Approve") - } - if getBSONField(doc, "Caption") != nil { - t.Error("UserTaskOutcome must not have 'Caption' key") - } - if getBSONField(doc, "Name") != nil { - t.Error("UserTaskOutcome must not have 'Name' key") - } -} - -func TestSerializeWorkflowParameter_EntityAsString(t *testing.T) { - param := &workflows.WorkflowParameter{ - BaseElement: model.BaseElement{ID: "param-1"}, - EntityRef: "MyModule.Customer", - } - doc := serializeWorkflowParameter(param) - - entity, ok := getBSONField(doc, "Entity").(string) - if !ok { - t.Fatalf("Entity is %T, want string", getBSONField(doc, "Entity")) - } - if entity != "MyModule.Customer" { - t.Errorf("Entity = %q, want %q", entity, "MyModule.Customer") - } -} - -// --- P0 bug regression tests --- - -func TestSerializeUserTask_AutoAssignSingleTargetUserDefaultsFalse(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "ut-auto"}, - Name: "Task", - }, - } - doc := serializeUserTask(task) - val := getBSONField(doc, "AutoAssignSingleTargetUser") - if val != false { - t.Errorf("AutoAssignSingleTargetUser = %v, want false", val) - } -} - -func TestSerializeUserTask_DueDateUsedFromStruct(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "ut-due"}, - Name: "Task", - }, - DueDate: "addDays([%CurrentDateTime%], 7)", - } - doc := serializeUserTask(task) - val, _ := getBSONField(doc, "DueDate").(string) - if val != "addDays([%CurrentDateTime%], 7)" { - t.Errorf("DueDate = %q, want %q", val, "addDays([%CurrentDateTime%], 7)") - } -} - -func TestSerializeBoundaryEvents_NonInterruptingTimerHasRecurrenceNull(t *testing.T) { - events := []*workflows.BoundaryEvent{ - { - BaseElement: model.BaseElement{ID: "be-1"}, - EventType: "NonInterruptingTimer", - TimerDelay: "addDays([%CurrentDateTime%], 1)", - }, - } - arr := serializeBoundaryEvents(events) - // arr[0] is int32(2) marker, arr[1] is the event doc - if len(arr) < 2 { - t.Fatal("expected 2 elements in boundary events array") - } - doc, ok := arr[1].(bson.D) - if !ok { - t.Fatalf("arr[1] is %T, want bson.D", arr[1]) - } - // Recurrence must exist with nil value - found := false - for _, e := range doc { - if e.Key == "Recurrence" { - found = true - if e.Value != nil { - t.Errorf("Recurrence = %v, want nil", e.Value) - } - } - } - if !found { - t.Error("Recurrence field missing from NonInterruptingTimerBoundaryEvent") - } -} - -// --- P1: Multi-User Task missing fields --- - -func TestSerializeMultiUserTask_AwaitAllUsersPresentAndFalse(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "mut-1"}, - Name: "MultiTask", - }, - IsMulti: true, - } - doc := serializeUserTask(task) - val := getBSONField(doc, "AwaitAllUsers") - if val == nil { - t.Error("AwaitAllUsers field missing from MultiUserTaskActivity") - return - } - if val != false { - t.Errorf("AwaitAllUsers = %v, want false", val) - } -} - -func TestSerializeMultiUserTask_TargetUserInputPresent(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "mut-2"}, - Name: "MultiTask", - }, - IsMulti: true, - } - doc := serializeUserTask(task) - val := getBSONField(doc, "TargetUserInput") - if val == nil { - t.Error("TargetUserInput field missing from MultiUserTaskActivity") - return - } - tui, ok := val.(bson.D) - if !ok { - t.Fatalf("TargetUserInput is %T, want bson.D", val) - } - typeVal, _ := getBSONField(tui, "$Type").(string) - if typeVal != "Workflows$AllUserInput" { - t.Errorf("TargetUserInput.$Type = %q, want %q", typeVal, "Workflows$AllUserInput") - } -} - -func TestSerializeMultiUserTask_CompletionCriteriaPresent(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "mut-3"}, - Name: "MultiTask", - }, - IsMulti: true, - Outcomes: []*workflows.UserTaskOutcome{ - {BaseElement: model.BaseElement{ID: "out-a"}, Value: "Approve"}, - }, - } - doc := serializeUserTask(task) - val := getBSONField(doc, "CompletionCriteria") - if val == nil { - t.Error("CompletionCriteria field missing from MultiUserTaskActivity") - return - } - cc, ok := val.(bson.D) - if !ok { - t.Fatalf("CompletionCriteria is %T, want bson.D", val) - } - typeVal, _ := getBSONField(cc, "$Type").(string) - if typeVal != "Workflows$ConsensusCompletionCriteria" { - t.Errorf("CompletionCriteria.$Type = %q, want %q", typeVal, "Workflows$ConsensusCompletionCriteria") - } - // FallbackOutcomePointer must be a UUID binary - ptr := getBSONField(cc, "FallbackOutcomePointer") - if ptr == nil { - t.Error("CompletionCriteria.FallbackOutcomePointer missing") - } -} - -func TestSerializeSingleUserTask_NoMultiFields(t *testing.T) { - task := &workflows.UserTask{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "sut-1"}, - Name: "SingleTask", - }, - IsMulti: false, - } - doc := serializeUserTask(task) - if getBSONField(doc, "AwaitAllUsers") != nil { - t.Error("SingleUserTask must not have AwaitAllUsers field") - } - if getBSONField(doc, "CompletionCriteria") != nil { - t.Error("SingleUserTask must not have CompletionCriteria field") - } - if getBSONField(doc, "TargetUserInput") != nil { - t.Error("SingleUserTask must not have TargetUserInput field") - } -} - -// --- P2: CallWorkflowActivity must not emit ParameterExpression --- - -func TestSerializeCallWorkflowActivity_NoParameterExpressionField(t *testing.T) { - act := &workflows.CallWorkflowActivity{ - BaseWorkflowActivity: workflows.BaseWorkflowActivity{ - BaseElement: model.BaseElement{ID: "cwa-1"}, - Name: "callWorkflow1", - }, - Workflow: "MyModule.SubFlow", - ParameterExpression: "$WorkflowContext", - } - doc := serializeCallWorkflowActivity(act) - for _, e := range doc { - if e.Key == "ParameterExpression" { - t.Error("CallWorkflowActivity must not emit ParameterExpression field (not in Studio Pro BSON)") - return - } - } -} - -// --- Fixture-based roundtrip: parse real BSON → serialize → verify markers preserved --- - -func TestSerializeWorkflowFlow_RoundtripFromFixture(t *testing.T) { - raw := loadWorkflowBSON(t, "WorkflowBaseline.Workflow") - flowRaw := toMap(raw["Flow"]) - if flowRaw == nil { - t.Fatal("fixture has no Flow") - } - - // Parse real workflow from fixture - flow := parseWorkflowFlow(flowRaw) - if flow == nil { - t.Fatal("parseWorkflowFlow returned nil") - } - - // Serialize back to BSON - doc := serializeWorkflowFlow(flow) - - // Verify array markers survive the roundtrip - assertArrayMarker(t, doc, "Activities", int32(3)) - - // Re-marshal and re-parse to verify full roundtrip - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("marshal: %v", err) - } - var reparsedRaw map[string]any - if err := bson.Unmarshal(data, &reparsedRaw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - reparsed := parseWorkflowFlow(reparsedRaw) - if reparsed == nil { - t.Fatal("re-parse returned nil") - } - if len(reparsed.Activities) != len(flow.Activities) { - t.Errorf("roundtrip Activities count = %d, want %d", len(reparsed.Activities), len(flow.Activities)) - } - // Verify first activity type is preserved - if _, ok := reparsed.Activities[0].(*workflows.StartWorkflowActivity); !ok { - t.Errorf("roundtrip Activities[0] = %T, want *workflows.StartWorkflowActivity", reparsed.Activities[0]) - } - // Verify last activity type is preserved - last := reparsed.Activities[len(reparsed.Activities)-1] - if _, ok := last.(*workflows.EndWorkflowActivity); !ok { - t.Errorf("roundtrip last activity = %T, want *workflows.EndWorkflowActivity", last) - } -} - -// TestRenameCallMicroflowTypeBSON verifies the version-gated $Type rewrite in the -// legacy engine (FINDINGS #39): a CallMicroflowTask nested inside a Flow's -// activities array is renamed to CallMicroflowActivity only when useActivity is set. -func TestRenameCallMicroflowTypeBSON(t *testing.T) { - build := func() bson.D { - return bson.D{ - {Key: "$Type", Value: "Workflows$Workflow"}, - {Key: "Flow", Value: bson.D{ - {Key: "$Type", Value: "Workflows$Flow"}, - {Key: "Activities", Value: bson.A{ - int32(3), - bson.D{{Key: "$Type", Value: "Workflows$CallMicroflowTask"}, {Key: "Name", Value: "Call"}}, - bson.D{{Key: "$Type", Value: "Workflows$EndWorkflowActivity"}}, - }}, - }}, - } - } - typeOfActivity := func(d bson.D) string { - flow := d[1].Value.(bson.D) - acts := flow[1].Value.(bson.A) - return acts[1].(bson.D)[0].Value.(string) - } - - off := build() - renameCallMicroflowTypeBSON(off, false) - if got := typeOfActivity(off); got != "Workflows$CallMicroflowTask" { - t.Errorf("pre-11.9: activity $Type = %q, want Workflows$CallMicroflowTask", got) - } - - on := build() - renameCallMicroflowTypeBSON(on, true) - if got := typeOfActivity(on); got != "Workflows$CallMicroflowActivity" { - t.Errorf("11.9+: activity $Type = %q, want Workflows$CallMicroflowActivity", got) - } -} diff --git a/sdk/mpr/writer_agenteditor_agent.go b/sdk/mpr/writer_agenteditor_agent.go deleted file mode 100644 index d74d121158..0000000000 --- a/sdk/mpr/writer_agenteditor_agent.go +++ /dev/null @@ -1,184 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Writer for agent-editor Agent documents. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/agenteditor" -) - -// CreateAgentEditorAgent writes an Agent document. -func (w *Writer) CreateAgentEditorAgent(a *agenteditor.Agent) error { - if a == nil { - return fmt.Errorf("agent is nil") - } - if a.Name == "" { - return fmt.Errorf("agent name is required") - } - if a.ContainerID == "" { - return fmt.Errorf("agent container ID is required") - } - if a.ID == "" { - a.ID = model.ID(generateUUID()) - } - - // Ensure tool/KB entries have stable IDs. - for i := range a.Tools { - if a.Tools[i].ID == "" { - a.Tools[i].ID = generateUUID() - } - } - for i := range a.KBTools { - if a.KBTools[i].ID == "" { - a.KBTools[i].ID = generateUUID() - } - } - - contentsJSON, err := encodeAgentContents(a) - if err != nil { - return err - } - - return w.writeCustomBlobDocument(customBlobInput{ - UnitID: string(a.ID), - ContainerID: string(a.ContainerID), - Name: a.Name, - Documentation: a.Documentation, - Excluded: a.Excluded, - ExportLevel: a.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeAgent, - ReadableTypeName: agenteditor.ReadableAgent, - ContentsJSON: contentsJSON, - }) -} - -// UpdateAgentEditorAgent replaces an existing Agent document, preserving its UUID. -// Tool and KB entries without IDs get fresh stable IDs assigned. -func (w *Writer) UpdateAgentEditorAgent(a *agenteditor.Agent) error { - if a == nil { - return fmt.Errorf("agent is nil") - } - - for i := range a.Tools { - if a.Tools[i].ID == "" { - a.Tools[i].ID = generateUUID() - } - } - for i := range a.KBTools { - if a.KBTools[i].ID == "" { - a.KBTools[i].ID = generateUUID() - } - } - - contentsJSON, err := encodeAgentContents(a) - if err != nil { - return err - } - - return w.updateCustomBlobDocument(customBlobInput{ - UnitID: string(a.ID), - ContainerID: string(a.ContainerID), - Name: a.Name, - Documentation: a.Documentation, - Excluded: a.Excluded, - ExportLevel: a.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeAgent, - ReadableTypeName: agenteditor.ReadableAgent, - ContentsJSON: contentsJSON, - }) -} - -// DeleteAgentEditorAgent removes an Agent by ID. -func (w *Writer) DeleteAgentEditorAgent(id string) error { - return w.deleteUnit(id) -} - -func encodeAgentContents(a *agenteditor.Agent) (string, error) { - // Build the JSON shape matching what the agent editor extension produces. - // Optional fields are omitted when empty/nil (omitempty). - type toolEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - ToolType string `json:"toolType"` - Document *agenteditor.DocRef `json:"document,omitempty"` - } - type kbToolEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - ToolType string `json:"toolType"` - Document *agenteditor.DocRef `json:"document,omitempty"` - CollectionIdentifier string `json:"collectionIdentifier,omitempty"` - MaxResults int `json:"maxResults,omitempty"` - } - type contentsShape struct { - Description string `json:"description"` - SystemPrompt string `json:"systemPrompt"` - UserPrompt string `json:"userPrompt"` - UsageType string `json:"usageType"` - Variables []agenteditor.AgentVar `json:"variables"` - Tools []toolEntry `json:"tools"` - KnowledgebaseTools []kbToolEntry `json:"knowledgebaseTools"` - Model *agenteditor.DocRef `json:"model,omitempty"` - Entity *agenteditor.DocRef `json:"entity,omitempty"` - MaxTokens *int `json:"maxTokens,omitempty"` - ToolChoice string `json:"toolChoice,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"topP,omitempty"` - } - - // Convert typed slices (ensure non-nil so JSON emits [] not null). - tools := make([]toolEntry, 0, len(a.Tools)) - for _, t := range a.Tools { - tools = append(tools, toolEntry{ - ID: t.ID, - Name: t.Name, - Description: t.Description, - Enabled: t.Enabled, - ToolType: t.ToolType, - Document: t.Document, - }) - } - kbTools := make([]kbToolEntry, 0, len(a.KBTools)) - for _, kb := range a.KBTools { - kbTools = append(kbTools, kbToolEntry{ - ID: kb.ID, - Name: kb.Name, - Description: kb.Description, - Enabled: kb.Enabled, - ToolType: kb.ToolType, - Document: kb.Document, - CollectionIdentifier: kb.CollectionIdentifier, - MaxResults: kb.MaxResults, - }) - } - - vars := a.Variables - if vars == nil { - vars = []agenteditor.AgentVar{} - } - - payload := contentsShape{ - Description: a.Description, - SystemPrompt: a.SystemPrompt, - UserPrompt: a.UserPrompt, - UsageType: a.UsageType, - Variables: vars, - Tools: tools, - KnowledgebaseTools: kbTools, - Model: a.Model, - Entity: a.Entity, - MaxTokens: a.MaxTokens, - ToolChoice: a.ToolChoice, - Temperature: a.Temperature, - TopP: a.TopP, - } - - return marshalCanonicalJSON(payload) -} diff --git a/sdk/mpr/writer_agenteditor_kb.go b/sdk/mpr/writer_agenteditor_kb.go deleted file mode 100644 index f57f53b436..0000000000 --- a/sdk/mpr/writer_agenteditor_kb.go +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Writer for agent-editor Knowledge Base documents. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/agenteditor" -) - -// CreateAgentEditorKnowledgeBase writes a Knowledge Base document. -func (w *Writer) CreateAgentEditorKnowledgeBase(k *agenteditor.KnowledgeBase) error { - if k == nil { - return fmt.Errorf("knowledge base is nil") - } - if k.Name == "" { - return fmt.Errorf("knowledge base name is required") - } - if k.ContainerID == "" { - return fmt.Errorf("knowledge base container ID is required") - } - if k.Provider == "" { - k.Provider = "MxCloudGenAI" - } - if k.ID == "" { - k.ID = model.ID(generateUUID()) - } - - contentsJSON, err := encodeKnowledgeBaseContents(k) - if err != nil { - return err - } - - return w.writeCustomBlobDocument(customBlobInput{ - UnitID: string(k.ID), - ContainerID: string(k.ContainerID), - Name: k.Name, - Documentation: k.Documentation, - Excluded: k.Excluded, - ExportLevel: k.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeKnowledgeBase, - ReadableTypeName: agenteditor.ReadableKnowledgeBase, - ContentsJSON: contentsJSON, - }) -} - -// UpdateAgentEditorKnowledgeBase replaces an existing Knowledge Base document, preserving its UUID. -func (w *Writer) UpdateAgentEditorKnowledgeBase(k *agenteditor.KnowledgeBase) error { - if k == nil { - return fmt.Errorf("knowledge base is nil") - } - if k.Provider == "" { - k.Provider = "MxCloudGenAI" - } - - contentsJSON, err := encodeKnowledgeBaseContents(k) - if err != nil { - return err - } - - return w.updateCustomBlobDocument(customBlobInput{ - UnitID: string(k.ID), - ContainerID: string(k.ContainerID), - Name: k.Name, - Documentation: k.Documentation, - Excluded: k.Excluded, - ExportLevel: k.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeKnowledgeBase, - ReadableTypeName: agenteditor.ReadableKnowledgeBase, - ContentsJSON: contentsJSON, - }) -} - -// DeleteAgentEditorKnowledgeBase removes a Knowledge Base by ID. -func (w *Writer) DeleteAgentEditorKnowledgeBase(id string) error { - return w.deleteUnit(id) -} - -func encodeKnowledgeBaseContents(k *agenteditor.KnowledgeBase) (string, error) { - type providerFields struct { - Environment string `json:"environment"` - DeepLinkURL string `json:"deepLinkURL"` - KeyID string `json:"keyId"` - KeyName string `json:"keyName"` - ModelDisplayName string `json:"modelDisplayName"` - ModelName string `json:"modelName"` - Key *agenteditor.ConstantRef `json:"key,omitempty"` - } - type contentsShape struct { - Name string `json:"name"` - Provider string `json:"provider"` - ProviderFields providerFields `json:"providerFields"` - } - payload := contentsShape{ - Name: "", - Provider: k.Provider, - ProviderFields: providerFields{ - Environment: k.Environment, - DeepLinkURL: k.DeepLinkURL, - KeyID: k.KeyID, - KeyName: k.KeyName, - ModelDisplayName: k.ModelDisplayName, - ModelName: k.ModelName, - Key: k.Key, - }, - } - return marshalCanonicalJSON(payload) -} diff --git a/sdk/mpr/writer_agenteditor_mcpservice.go b/sdk/mpr/writer_agenteditor_mcpservice.go deleted file mode 100644 index 0207155453..0000000000 --- a/sdk/mpr/writer_agenteditor_mcpservice.go +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Writer for agent-editor Consumed MCP Service documents. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/agenteditor" -) - -// CreateAgentEditorConsumedMCPService writes a Consumed MCP Service document. -func (w *Writer) CreateAgentEditorConsumedMCPService(c *agenteditor.ConsumedMCPService) error { - if c == nil { - return fmt.Errorf("consumed MCP service is nil") - } - if c.Name == "" { - return fmt.Errorf("consumed MCP service name is required") - } - if c.ContainerID == "" { - return fmt.Errorf("consumed MCP service container ID is required") - } - if c.ID == "" { - c.ID = model.ID(generateUUID()) - } - - contentsJSON, err := encodeConsumedMCPServiceContents(c) - if err != nil { - return err - } - - return w.writeCustomBlobDocument(customBlobInput{ - UnitID: string(c.ID), - ContainerID: string(c.ContainerID), - Name: c.Name, - Documentation: c.Documentation, - Excluded: c.Excluded, - ExportLevel: c.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeConsumedMCPService, - ReadableTypeName: agenteditor.ReadableConsumedMCPService, - ContentsJSON: contentsJSON, - }) -} - -// UpdateAgentEditorConsumedMCPService replaces an existing Consumed MCP Service document, preserving its UUID. -func (w *Writer) UpdateAgentEditorConsumedMCPService(c *agenteditor.ConsumedMCPService) error { - if c == nil { - return fmt.Errorf("consumed MCP service is nil") - } - - contentsJSON, err := encodeConsumedMCPServiceContents(c) - if err != nil { - return err - } - - return w.updateCustomBlobDocument(customBlobInput{ - UnitID: string(c.ID), - ContainerID: string(c.ContainerID), - Name: c.Name, - Documentation: c.Documentation, - Excluded: c.Excluded, - ExportLevel: c.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeConsumedMCPService, - ReadableTypeName: agenteditor.ReadableConsumedMCPService, - ContentsJSON: contentsJSON, - }) -} - -// DeleteAgentEditorConsumedMCPService removes a Consumed MCP Service by ID. -func (w *Writer) DeleteAgentEditorConsumedMCPService(id string) error { - return w.deleteUnit(id) -} - -func encodeConsumedMCPServiceContents(c *agenteditor.ConsumedMCPService) (string, error) { - type contentsShape struct { - ProtocolVersion string `json:"protocolVersion"` - Documentation string `json:"documentation"` - Version string `json:"version"` - ConnectionTimeoutSeconds int `json:"connectionTimeoutSeconds"` - } - payload := contentsShape{ - ProtocolVersion: c.ProtocolVersion, - Documentation: c.InnerDocumentation, - Version: c.Version, - ConnectionTimeoutSeconds: c.ConnectionTimeoutSeconds, - } - return marshalCanonicalJSON(payload) -} diff --git a/sdk/mpr/writer_agenteditor_model.go b/sdk/mpr/writer_agenteditor_model.go deleted file mode 100644 index bf571a853e..0000000000 --- a/sdk/mpr/writer_agenteditor_model.go +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Writer for agent-editor Model documents. -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/agenteditor" -) - -// CreateAgentEditorModel writes a Model document to the project. The -// Model.ContainerID must be set (the module/folder to place it in). -// The Model.ID is auto-generated if empty. -// -// The Contents JSON shape mirrors what Studio Pro's agent-editor -// extension produces — see PROPOSAL_agent_document_support.md. -func (w *Writer) CreateAgentEditorModel(m *agenteditor.Model) error { - if m == nil { - return fmt.Errorf("model is nil") - } - if m.Name == "" { - return fmt.Errorf("model name is required") - } - if m.ContainerID == "" { - return fmt.Errorf("model container ID is required") - } - if m.Provider == "" { - // Only one provider is currently supported by the agent editor. - m.Provider = "MxCloudGenAI" - } - if m.ID == "" { - m.ID = model.ID(generateUUID()) - } - - contentsJSON, err := encodeAgentEditorModelContents(m) - if err != nil { - return err - } - - return w.writeCustomBlobDocument(customBlobInput{ - UnitID: string(m.ID), - ContainerID: string(m.ContainerID), - Name: m.Name, - Documentation: m.Documentation, - Excluded: m.Excluded, - ExportLevel: m.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeModel, - ReadableTypeName: agenteditor.ReadableModel, - ContentsJSON: contentsJSON, - }) -} - -// UpdateAgentEditorModel replaces an existing Model document, preserving its UUID. -func (w *Writer) UpdateAgentEditorModel(m *agenteditor.Model) error { - if m == nil { - return fmt.Errorf("model is nil") - } - if m.Provider == "" { - m.Provider = "MxCloudGenAI" - } - - contentsJSON, err := encodeAgentEditorModelContents(m) - if err != nil { - return err - } - - return w.updateCustomBlobDocument(customBlobInput{ - UnitID: string(m.ID), - ContainerID: string(m.ContainerID), - Name: m.Name, - Documentation: m.Documentation, - Excluded: m.Excluded, - ExportLevel: m.ExportLevel, - CustomDocumentType: agenteditor.CustomTypeModel, - ReadableTypeName: agenteditor.ReadableModel, - ContentsJSON: contentsJSON, - }) -} - -// DeleteAgentEditorModel removes a Model document by ID. -func (w *Writer) DeleteAgentEditorModel(id string) error { - return w.deleteUnit(id) -} - -// encodeAgentEditorModelContents produces the JSON payload stored in -// the Contents field of a Model CustomBlobDocument. -func encodeAgentEditorModelContents(m *agenteditor.Model) (string, error) { - // Provider-specific fields are nested under providerFields. Keys are - // emitted in the same order Studio Pro uses. - type providerFields struct { - Environment string `json:"environment"` - DeepLinkURL string `json:"deepLinkURL"` - KeyID string `json:"keyId"` - KeyName string `json:"keyName"` - ResourceName string `json:"resourceName"` - Key *agenteditor.ConstantRef `json:"key,omitempty"` - } - type contentsShape struct { - Type string `json:"type"` - Name string `json:"name"` - DisplayName string `json:"displayName"` - Provider string `json:"provider"` - ProviderFields providerFields `json:"providerFields"` - } - - payload := contentsShape{ - Type: m.Type, - Name: m.InnerName, - DisplayName: m.DisplayName, - Provider: m.Provider, - ProviderFields: providerFields{ - Environment: m.Environment, - DeepLinkURL: m.DeepLinkURL, - KeyID: m.KeyID, - KeyName: m.KeyName, - ResourceName: m.ResourceName, - Key: m.Key, - }, - } - - return marshalCanonicalJSON(payload) -} diff --git a/sdk/mpr/writer_businessevents.go b/sdk/mpr/writer_businessevents.go deleted file mode 100644 index 7a46df0a4d..0000000000 --- a/sdk/mpr/writer_businessevents.go +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateBusinessEventService creates a new business event service document. -func (w *Writer) CreateBusinessEventService(svc *model.BusinessEventService) error { - if svc.ID == "" { - svc.ID = model.ID(generateUUID()) - } - svc.TypeName = "BusinessEvents$BusinessEventService" - - contents, err := w.serializeBusinessEventService(svc) - if err != nil { - return fmt.Errorf("failed to serialize business event service: %w", err) - } - - return w.insertUnit(string(svc.ID), string(svc.ContainerID), "Documents", "BusinessEvents$BusinessEventService", contents) -} - -// UpdateBusinessEventService updates an existing business event service. -func (w *Writer) UpdateBusinessEventService(svc *model.BusinessEventService) error { - contents, err := w.serializeBusinessEventService(svc) - if err != nil { - return fmt.Errorf("failed to serialize business event service: %w", err) - } - - return w.updateUnit(string(svc.ID), contents) -} - -// DeleteBusinessEventService deletes a business event service by ID. -func (w *Writer) DeleteBusinessEventService(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// serializeBusinessEventService converts a BusinessEventService to BSON bytes. -func (w *Writer) serializeBusinessEventService(svc *model.BusinessEventService) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(svc.ID))}, - {Key: "$Type", Value: "BusinessEvents$BusinessEventService"}, - {Key: "Name", Value: svc.Name}, - {Key: "Documentation", Value: svc.Documentation}, - {Key: "Excluded", Value: svc.Excluded}, - {Key: "ExportLevel", Value: svc.ExportLevel}, - } - - // Serialize Definition - if svc.Definition != nil { - doc = append(doc, bson.E{Key: "Definition", Value: serializeBusinessEventDefinition(svc.Definition)}) - } else { - doc = append(doc, bson.E{Key: "Definition", Value: nil}) - } - - // Serialize OperationImplementations - opImpls := bson.A{int32(2)} // versioned array prefix - for _, op := range svc.OperationImplementations { - opImpls = append(opImpls, serializeServiceOperation(op)) - } - doc = append(doc, bson.E{Key: "OperationImplementations", Value: opImpls}) - - // SourceApi is null for service definitions - doc = append(doc, bson.E{Key: "SourceApi", Value: nil}) - - return marshalUnitIDFirst(doc) -} - -func serializeBusinessEventDefinition(def *model.BusinessEventDefinition) bson.D { - id := string(def.ID) - if id == "" { - id = generateUUID() - } - - // Serialize Channels - channels := bson.A{int32(2)} // versioned array prefix - for _, ch := range def.Channels { - channels = append(channels, serializeBusinessEventChannel(ch)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "BusinessEvents$BusinessEventDefinition"}, - {Key: "ServiceName", Value: def.ServiceName}, - {Key: "EventNamePrefix", Value: def.EventNamePrefix}, - {Key: "Description", Value: def.Description}, - {Key: "Summary", Value: def.Summary}, - {Key: "Channels", Value: channels}, - } -} - -func serializeBusinessEventChannel(ch *model.BusinessEventChannel) bson.D { - id := string(ch.ID) - if id == "" { - id = generateUUID() - } - - // Serialize Messages - messages := bson.A{int32(2)} // versioned array prefix - for _, msg := range ch.Messages { - messages = append(messages, serializeBusinessEventMessage(msg)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "BusinessEvents$Channel"}, - {Key: "ChannelName", Value: ch.ChannelName}, - {Key: "Description", Value: ch.Description}, - {Key: "Messages", Value: messages}, - } -} - -func serializeBusinessEventMessage(msg *model.BusinessEventMessage) bson.D { - id := string(msg.ID) - if id == "" { - id = generateUUID() - } - - // Serialize Attributes - attrs := bson.A{int32(2)} // versioned array prefix - for _, attr := range msg.Attributes { - attrs = append(attrs, serializeBusinessEventAttribute(attr)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "BusinessEvents$Message"}, - {Key: "MessageName", Value: msg.MessageName}, - {Key: "Description", Value: msg.Description}, - {Key: "CanPublish", Value: msg.CanPublish}, - {Key: "CanSubscribe", Value: msg.CanSubscribe}, - {Key: "Attributes", Value: attrs}, - } -} - -func serializeBusinessEventAttribute(attr *model.BusinessEventAttribute) bson.D { - id := string(attr.ID) - if id == "" { - id = generateUUID() - } - - // Convert attribute type to BSON format: "Long" → {"$Type": "DomainModels$LongAttributeType", "$ID": ...} - attrTypeDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: attributeTypeToBsonType(attr.AttributeType)}, - } - // Date and DateTime both use DateTimeAttributeType; distinguish via LocalizeDate - if attr.AttributeType == "DateTime" { - attrTypeDoc = append(attrTypeDoc, bson.E{Key: "LocalizeDate", Value: true}) - } else if attr.AttributeType == "Date" { - attrTypeDoc = append(attrTypeDoc, bson.E{Key: "LocalizeDate", Value: false}) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "BusinessEvents$MessageAttribute"}, - {Key: "AttributeName", Value: attr.AttributeName}, - {Key: "Description", Value: attr.Description}, - {Key: "AttributeType", Value: attrTypeDoc}, - } -} - -// attributeTypeToBsonType converts a simple type name to a BSON $Type string. -func attributeTypeToBsonType(typeName string) string { - switch typeName { - case "Long": - return "DomainModels$LongAttributeType" - case "String": - return "DomainModels$StringAttributeType" - case "Integer": - return "DomainModels$IntegerAttributeType" - case "Boolean": - return "DomainModels$BooleanAttributeType" - case "DateTime", "Date": - return "DomainModels$DateTimeAttributeType" - case "Decimal": - return "DomainModels$DecimalAttributeType" - case "AutoNumber": - return "DomainModels$AutoNumberAttributeType" - case "Binary": - return "DomainModels$BinaryAttributeType" - default: - return "DomainModels$StringAttributeType" - } -} - -func serializeServiceOperation(op *model.ServiceOperation) bson.D { - id := string(op.ID) - if id == "" { - id = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "BusinessEvents$ServiceOperation"}, - {Key: "MessageName", Value: op.MessageName}, - {Key: "Operation", Value: op.Operation}, - {Key: "Entity", Value: op.Entity}, - {Key: "Microflow", Value: op.Microflow}, - } -} diff --git a/sdk/mpr/writer_commit_rename_test.go b/sdk/mpr/writer_commit_rename_test.go deleted file mode 100644 index 3a5e18a405..0000000000 --- a/sdk/mpr/writer_commit_rename_test.go +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "os" - "path/filepath" - "strings" - "testing" - - _ "modernc.org/sqlite" -) - -// upstream #954, legacy engine. This copy of WriteTransaction has no callers -// today — the legacy write path is Writer.updateUnit, which has always failed -// hard when it could not put a unit's bytes on disk — but it is exported, so it -// is kept in step with modelsdk/mpr's copy rather than left holding the bug. -// These mirror the modelsdk tests against it. - -// newV2WriterForCommitTest builds a minimal MPR v2 writer over a temp SQLite DB -// and mprcontents folder, seeded with one unit holding stored. -func newV2WriterForCommitTest(t *testing.T, unitID string, stored []byte) (*Writer, string) { - t.Helper() - - root := t.TempDir() - dbPath := filepath.Join(root, "app.mpr") - contentsDir := filepath.Join(root, "mprcontents") - - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - if _, err := db.Exec(` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT - ) - `); err != nil { - t.Fatalf("create Unit table: %v", err) - } - - blob := uuidToBlob(unitID) - swapped := blobToUUIDSwapped(blob) - unitPath := filepath.Join(contentsDir, swapped[0:2], swapped[2:4], swapped+".mxunit") - if err := os.MkdirAll(filepath.Dir(unitPath), 0755); err != nil { - t.Fatalf("mkdir unit dir: %v", err) - } - if err := os.WriteFile(unitPath, stored, 0644); err != nil { - t.Fatalf("seed unit file: %v", err) - } - if _, err := db.Exec( - `INSERT INTO Unit (UnitID, ContentsHash) VALUES (?, ?)`, blob, contentHashBase64(stored), - ); err != nil { - t.Fatalf("insert unit row: %v", err) - } - - reader := &Reader{path: dbPath, db: db, version: MPRVersionV2, contentsDir: contentsDir} - return &Writer{reader: reader}, unitPath -} - -func commitTestStoredHash(t *testing.T, w *Writer, unitID string) string { - t.Helper() - var got string - if err := w.reader.db.QueryRow( - `SELECT ContentsHash FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID), - ).Scan(&got); err != nil { - t.Fatalf("read ContentsHash: %v", err) - } - return got -} - -func TestCommitFailsWhenAUnitFileCannotBeFinalized(t *testing.T) { - const unitID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - stored := []byte("stored bytes") - updated := []byte("updated bytes") - - w, unitPath := newV2WriterForCommitTest(t, unitID, stored) - - wt, err := w.BeginWriteTransaction() - if err != nil { - t.Fatalf("begin write transaction: %v", err) - } - if err := wt.WriteUnit(unitID, updated); err != nil { - t.Fatalf("write unit: %v", err) - } - - // Stand-in for the Windows lock: make the rename fail. What fails it is not - // the point, only that it can. - if err := os.Remove(wt.pendingFiles[0].tempPath); err != nil { - t.Fatalf("remove temp file: %v", err) - } - - if err := wt.Commit(); err == nil { - t.Fatal("Commit returned nil after failing to finalize a unit file") - } else if !strings.Contains(err.Error(), unitID) { - t.Errorf("Commit error %q does not name the unit %s", err, unitID) - } - - onDisk, err := os.ReadFile(unitPath) - if err != nil { - t.Fatalf("read unit file: %v", err) - } - if string(onDisk) != string(stored) { - t.Error("unit file changed despite the failed commit") - } - if got, want := commitTestStoredHash(t, w, unitID), contentHashBase64(stored); got != want { - t.Errorf("ContentsHash = %q, want %q: the database describes contents "+ - "that are not on disk", got, want) - } -} - -// TestCommitSucceedsWhenEveryUnitFileIsFinalized is the false-positive control. -func TestCommitSucceedsWhenEveryUnitFileIsFinalized(t *testing.T) { - const unitID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeef" - stored := []byte("stored bytes") - updated := []byte("updated bytes") - - w, unitPath := newV2WriterForCommitTest(t, unitID, stored) - - wt, err := w.BeginWriteTransaction() - if err != nil { - t.Fatalf("begin write transaction: %v", err) - } - if err := wt.WriteUnit(unitID, updated); err != nil { - t.Fatalf("write unit: %v", err) - } - if err := wt.Commit(); err != nil { - t.Fatalf("Commit: %v", err) - } - - onDisk, err := os.ReadFile(unitPath) - if err != nil { - t.Fatalf("read unit file: %v", err) - } - if string(onDisk) != string(updated) { - t.Error("unit file was not updated on the success path") - } - if got, want := commitTestStoredHash(t, w, unitID), contentHashBase64(updated); got != want { - t.Errorf("ContentsHash = %q, want %q", got, want) - } - - // No .tmp and no .bak: a successful commit leaves the folder as Mendix - // expects to find it. - entries, err := os.ReadDir(filepath.Dir(unitPath)) - if err != nil { - t.Fatalf("read dir: %v", err) - } - for _, e := range entries { - if !strings.HasSuffix(e.Name(), ".mxunit") { - t.Errorf("stray file left behind: %s", e.Name()) - } - } -} diff --git a/sdk/mpr/writer_core.go b/sdk/mpr/writer_core.go deleted file mode 100644 index c39e60a914..0000000000 --- a/sdk/mpr/writer_core.go +++ /dev/null @@ -1,321 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "fmt" - "os" - "path/filepath" - - "github.com/mendixlabs/mxcli/mdl/types" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// idToBsonBinary converts a UUID string to BSON Binary format. -// For invalid or empty UUIDs (e.g. test placeholders), generates a random ID -// to maintain backward compatibility with existing serialization paths. -// -// WARNING: an empty id here is almost always a bug (e.g. an unset pointer on a -// SequenceFlow) and produces a random UUID that references nothing — which -// Studio Pro surfaces as "KeyNotFoundException". Fix callers to pass a real ID. -func idToBsonBinary(id string) primitive.Binary { - blob := types.UUIDToBlob(id) - if blob == nil || len(blob) != 16 { - blob = types.UUIDToBlob(types.GenerateID()) - } - return primitive.Binary{ - Subtype: 0x00, - Data: blob, - } -} - -// Writer provides methods to write Mendix project files. -type Writer struct { - reader *Reader - - // writesOffered / writesLanded count what this session tried to persist and - // how much of it was not skipped as a no-op (ADR-0008). Both unit writes and - // generated source files count: a code action's body lives in - // javascriptsource/ rather than in its unit, so counting units alone would - // call a body-only edit unchanged. The executor reads these to tell - // "Modified X" from "X was already in sync" — without them, re-running a - // script that changes nothing still announces a write for every statement, - // which is how the churn in #910 was misdiagnosed. - writesOffered int - writesLanded int -} - -// WriteStats reports how many writes this session offered to storage and how -// many of them actually changed something. -func (w *Writer) WriteStats() (offered, written int) { - return w.writesOffered, w.writesLanded -} - -// NewWriter creates a new writer from a reader opened in read-write mode. -func NewWriter(path string) (*Writer, error) { - reader, err := OpenWithOptions(path, OpenOptions{ReadOnly: false}) - if err != nil { - return nil, err - } - return &Writer{reader: reader}, nil -} - -// Close closes the writer. -func (w *Writer) Close() error { - return w.reader.Close() -} - -// Reader returns the underlying reader. -func (w *Writer) Reader() *Reader { - return w.reader -} - -// Transaction support - -// Transaction represents a database transaction. -type Transaction struct { - tx *sql.Tx - writer *Writer -} - -// BeginTransaction starts a new transaction. -func (w *Writer) BeginTransaction() (*Transaction, error) { - tx, err := w.reader.db.Begin() - if err != nil { - return nil, err - } - return &Transaction{tx: tx, writer: w}, nil -} - -// Commit commits the transaction. -func (t *Transaction) Commit() error { - return t.tx.Commit() -} - -// Rollback rolls back the transaction. -func (t *Transaction) Rollback() error { - return t.tx.Rollback() -} - -// WriteTransaction provides atomic write operations for MPR v2 format. -// It coordinates database and file system changes to ensure consistency. -type WriteTransaction struct { - tx *sql.Tx - writer *Writer - pendingFiles []pendingFile - finalized []finalizedFile - committed bool -} - -type pendingFile struct { - unitID string - tempPath string - finalPath string -} - -// finalizedFile records a rename that has already happened, so it can be undone -// if a later step of the same Commit fails. backupPath is empty when the unit -// had no file on disk to preserve. -type finalizedFile struct { - pendingFile - backupPath string -} - -// BeginWriteTransaction starts a new write transaction. -// For v2 format, this coordinates both database and file writes. -func (w *Writer) BeginWriteTransaction() (*WriteTransaction, error) { - tx, err := w.reader.db.Begin() - if err != nil { - return nil, err - } - return &WriteTransaction{ - tx: tx, - writer: w, - pendingFiles: make([]pendingFile, 0), - }, nil -} - -// WriteUnit writes a unit within the transaction. -// The actual file write is deferred until Commit. -func (wt *WriteTransaction) WriteUnit(unitID string, contents []byte) error { - unitIDBlob := uuidToBlob(unitID) - - if wt.writer.reader.version == MPRVersionV2 { - swappedUUID := blobToUUIDSwapped(unitIDBlob) - - // Create directory if needed - dir := filepath.Join(wt.writer.reader.contentsDir, swappedUUID[0:2], swappedUUID[2:4]) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - // Write to temp file first - finalPath := filepath.Join(dir, swappedUUID+".mxunit") - tempPath := finalPath + ".tmp" - - if err := os.WriteFile(tempPath, contents, 0644); err != nil { - return fmt.Errorf("failed to write temp file: %w", err) - } - - wt.pendingFiles = append(wt.pendingFiles, pendingFile{ - unitID: unitID, - tempPath: tempPath, - finalPath: finalPath, - }) - - contentsHash := contentHashBase64(contents) - _, err := wt.tx.Exec(` - UPDATE Unit SET ContentsHash = ? WHERE UnitID = ? - `, contentsHash, unitIDBlob) - return err - } - - // V1: Update in database directly - contentsHash := contentHashBase64(contents) - _, err := wt.tx.Exec(` - UPDATE Unit SET Contents = ?, ContentsHash = ? WHERE UnitID = ? - `, contents, contentsHash, unitIDBlob) - if err != nil && isContentsHashSchemaError(err) { - // Older v1 schemas do not have ContentsHash; retry without it. - // Any other error (disk full, invalid UnitID, rolled-back tx) propagates. - _, err = wt.tx.Exec(` - UPDATE Unit SET Contents = ? WHERE UnitID = ? - `, contents, unitIDBlob) - } - return err -} - -// Commit commits the transaction. -// -// For v2 the unit files are renamed into place FIRST and the database is -// committed only once every rename has succeeded; any failure undoes the -// renames and rolls the transaction back, so the two either move together or -// not at all. The order matters: committing first and then renaming leaves the -// Unit table — ContentsHash included — describing bytes that are not on disk, -// and the old code warned about that on stdout and returned nil (upstream -// #954). A rename fails for ordinary environmental reasons — on Windows a -// .mxunit held open without FILE_SHARE_DELETE by an editor, an indexer or a -// sync client is enough. -// -// Kept in step with modelsdk/mpr's copy, which is the one storage path the -// codec engine reaches. -func (wt *WriteTransaction) Commit() error { - if wt.committed { - return fmt.Errorf("transaction already committed") - } - - if err := wt.finalizeFiles(); err != nil { - wt.undoFinalizedFiles() - wt.cleanupTempFiles() - _ = wt.tx.Rollback() - return err - } - - if err := wt.tx.Commit(); err != nil { - wt.undoFinalizedFiles() - wt.cleanupTempFiles() - return err - } - - wt.discardBackups() - wt.committed = true - return nil -} - -// finalizeFiles renames each pending temp file into place, first moving the file -// it replaces aside so undoFinalizedFiles can put it back. It stops at the first -// failure, leaving wt.finalized describing exactly what has to be undone. -func (wt *WriteTransaction) finalizeFiles() error { - seen := make(map[string]bool, len(wt.pendingFiles)) - for _, pf := range wt.pendingFiles { - // A unit written twice in one transaction shares a temp path, so the - // first rename already carried the latest bytes; a second would move the - // file just written aside as if it were the stored one. - if seen[pf.finalPath] { - continue - } - seen[pf.finalPath] = true - - backupPath := "" - if _, err := os.Stat(pf.finalPath); err == nil { - backupPath = pf.finalPath + ".bak" - if err := os.Rename(pf.finalPath, backupPath); err != nil { - return fmt.Errorf("finalize unit %s: cannot move %s aside: %w", - pf.unitID, pf.finalPath, err) - } - } - if err := os.Rename(pf.tempPath, pf.finalPath); err != nil { - if backupPath != "" { - _ = os.Rename(backupPath, pf.finalPath) - } - return fmt.Errorf("finalize unit %s: cannot write %s: %w", - pf.unitID, pf.finalPath, err) - } - wt.finalized = append(wt.finalized, finalizedFile{pendingFile: pf, backupPath: backupPath}) - } - return nil -} - -// undoFinalizedFiles reverses finalizeFiles, newest first: the new bytes go back -// to their temp path (for cleanupTempFiles to remove) and the file that was -// moved aside returns to its own name. Diagnostics go to stderr — stdout carries -// the CLI's own output and must stay parseable. -func (wt *WriteTransaction) undoFinalizedFiles() { - for i := len(wt.finalized) - 1; i >= 0; i-- { - f := wt.finalized[i] - if err := os.Rename(f.finalPath, f.tempPath); err != nil { - fmt.Fprintf(os.Stderr, "mpr: could not undo write of %s: %v\n", f.finalPath, err) - } - if f.backupPath != "" { - if err := os.Rename(f.backupPath, f.finalPath); err != nil { - fmt.Fprintf(os.Stderr, "mpr: could not restore %s: %v\n", f.finalPath, err) - } - } - } - wt.finalized = nil -} - -// discardBackups drops the moved-aside files once the commit has succeeded and -// they can no longer be needed. A leftover is inert — nothing reads a path that -// is not .mxunit — so a failure here is reported, not returned. -func (wt *WriteTransaction) discardBackups() { - for _, f := range wt.finalized { - if f.backupPath == "" { - continue - } - if err := os.Remove(f.backupPath); err != nil { - fmt.Fprintf(os.Stderr, "mpr: could not remove %s: %v\n", f.backupPath, err) - } - } - wt.finalized = nil -} - -// Rollback rolls back the transaction and cleans up temp files. -func (wt *WriteTransaction) Rollback() error { - if wt.committed { - return fmt.Errorf("transaction already committed") - } - - // Clean up temp files - wt.cleanupTempFiles() - - // Rollback database - return wt.tx.Rollback() -} - -func (wt *WriteTransaction) cleanupTempFiles() { - for _, pf := range wt.pendingFiles { - os.Remove(pf.tempPath) - } -} - -// generateUUID delegates to types.GenerateID. -func generateUUID() string { - return types.GenerateID() -} - -// uuidToBlob delegates to types.UUIDToBlob. -func uuidToBlob(uuid string) []byte { - return types.UUIDToBlob(uuid) -} diff --git a/sdk/mpr/writer_customblob.go b/sdk/mpr/writer_customblob.go deleted file mode 100644 index 30b3b19f2f..0000000000 --- a/sdk/mpr/writer_customblob.go +++ /dev/null @@ -1,130 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Generic writer for CustomBlobDocument units (the BSON -// wrapper used by all four agent-editor document types: Agent, Model, -// Knowledge Base, Consumed MCP Service). -// -// Type-specific Contents JSON encoders live in writer_agenteditor_*.go. -package mpr - -import ( - "encoding/json" - "fmt" - - "github.com/mendixlabs/mxcli/sdk/agenteditor" - - "go.mongodb.org/mongo-driver/bson" -) - -// customBlobInput holds the per-type payload for the wrapper writer. -type customBlobInput struct { - UnitID string // canonical UUID of the document - ContainerID string // canonical UUID of the parent container (module/folder) - Name string - Documentation string - Excluded bool - ExportLevel string // "Hidden" by default - CustomDocumentType string // e.g. "agenteditor.model" - ReadableTypeName string // e.g. "Model" - MetadataID string // canonical UUID for the embedded Metadata $ID - ContentsJSON string // type-specific JSON payload -} - -// writeCustomBlobDocument serializes a CustomBlobDocument BSON wrapper -// and inserts it as a Documents-containment unit in the project. -func (w *Writer) writeCustomBlobDocument(in customBlobInput) error { - if in.UnitID == "" { - return fmt.Errorf("CustomBlobDocument unit ID is required") - } - if in.ContainerID == "" { - return fmt.Errorf("CustomBlobDocument container ID is required") - } - if in.CustomDocumentType == "" { - return fmt.Errorf("CustomDocumentType is required") - } - if in.ReadableTypeName == "" { - return fmt.Errorf("ReadableTypeName is required") - } - if in.ExportLevel == "" { - in.ExportLevel = "Hidden" - } - if in.MetadataID == "" { - in.MetadataID = generateUUID() - } - - metadata := bson.D{ - {Key: "$ID", Value: idToBsonBinary(in.MetadataID)}, - {Key: "$Type", Value: "CustomBlobDocuments$CustomBlobDocumentMetadata"}, - {Key: "CreatedByExtension", Value: agenteditor.CreatedByExtensionID}, - {Key: "ReadableTypeName", Value: in.ReadableTypeName}, - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(in.UnitID)}, - {Key: "$Type", Value: customBlobDocType}, - {Key: "Contents", Value: in.ContentsJSON}, - {Key: "CustomDocumentType", Value: in.CustomDocumentType}, - {Key: "Documentation", Value: in.Documentation}, - {Key: "Excluded", Value: in.Excluded}, - {Key: "ExportLevel", Value: in.ExportLevel}, - {Key: "Metadata", Value: metadata}, - {Key: "Name", Value: in.Name}, - } - - contents, err := marshalUnitIDFirst(doc) - if err != nil { - return fmt.Errorf("failed to marshal CustomBlobDocument BSON: %w", err) - } - - return w.insertUnit(in.UnitID, in.ContainerID, "Documents", customBlobDocType, contents) -} - -// updateCustomBlobDocument serializes a CustomBlobDocument BSON wrapper and -// replaces the existing unit in the project, preserving its UUID. -func (w *Writer) updateCustomBlobDocument(in customBlobInput) error { - if in.UnitID == "" { - return fmt.Errorf("CustomBlobDocument unit ID is required for update") - } - if in.ExportLevel == "" { - in.ExportLevel = "Hidden" - } - if in.MetadataID == "" { - in.MetadataID = generateUUID() - } - - metadata := bson.D{ - {Key: "$ID", Value: idToBsonBinary(in.MetadataID)}, - {Key: "$Type", Value: "CustomBlobDocuments$CustomBlobDocumentMetadata"}, - {Key: "CreatedByExtension", Value: agenteditor.CreatedByExtensionID}, - {Key: "ReadableTypeName", Value: in.ReadableTypeName}, - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(in.UnitID)}, - {Key: "$Type", Value: customBlobDocType}, - {Key: "Contents", Value: in.ContentsJSON}, - {Key: "CustomDocumentType", Value: in.CustomDocumentType}, - {Key: "Documentation", Value: in.Documentation}, - {Key: "Excluded", Value: in.Excluded}, - {Key: "ExportLevel", Value: in.ExportLevel}, - {Key: "Metadata", Value: metadata}, - {Key: "Name", Value: in.Name}, - } - - contents, err := marshalUnitIDFirst(doc) - if err != nil { - return fmt.Errorf("failed to marshal CustomBlobDocument BSON: %w", err) - } - - return w.updateUnit(in.UnitID, contents) -} - -// marshalCanonicalJSON produces JSON without HTML escaping, matching the -// shape Studio Pro's agent-editor extension produces. -func marshalCanonicalJSON(v any) (string, error) { - b, err := json.Marshal(v) - if err != nil { - return "", err - } - return string(b), nil -} diff --git a/sdk/mpr/writer_datatransformer.go b/sdk/mpr/writer_datatransformer.go deleted file mode 100644 index 461d56741b..0000000000 --- a/sdk/mpr/writer_datatransformer.go +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateDataTransformer creates a new DataTransformers$DataTransformer document. -func (w *Writer) CreateDataTransformer(dt *model.DataTransformer) error { - if dt.ID == "" { - dt.ID = model.ID(generateUUID()) - } - dt.TypeName = "DataTransformers$DataTransformer" - - contents, err := serializeDataTransformer(dt) - if err != nil { - return fmt.Errorf("failed to serialize data transformer: %w", err) - } - - return w.insertUnit(string(dt.ID), string(dt.ContainerID), "Documents", "DataTransformers$DataTransformer", contents) -} - -// UpdateDataTransformer replaces an existing data transformer unit, preserving its UUID. -func (w *Writer) UpdateDataTransformer(dt *model.DataTransformer) error { - dt.TypeName = "DataTransformers$DataTransformer" - - contents, err := serializeDataTransformer(dt) - if err != nil { - return fmt.Errorf("failed to serialize data transformer: %w", err) - } - - return w.updateUnit(string(dt.ID), contents) -} - -// DeleteDataTransformer deletes a data transformer by ID. -func (w *Writer) DeleteDataTransformer(id model.ID) error { - return w.deleteUnit(string(id)) -} - -func serializeDataTransformer(dt *model.DataTransformer) ([]byte, error) { - // Root element - rootElemID := generateUUID() - rootElement := bson.D{ - {Key: "$ID", Value: idToBsonBinary(rootElemID)}, - {Key: "$Type", Value: "DataTransformers$StructureObject"}, - {Key: "Attributes", Value: bson.A{int32(2)}}, - } - - // Source - var source bson.D - switch strings.ToUpper(dt.SourceType) { - case "XML": - source = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$XmlSource"}, - {Key: "Content", Value: dt.SourceJSON}, - } - default: // JSON - source = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$JsonSource"}, - {Key: "Content", Value: dt.SourceJSON}, - } - } - - // Steps - steps := bson.A{int32(2)} - for _, step := range dt.Steps { - var action bson.D - switch strings.ToUpper(step.Technology) { - case "JSLT": - action = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$JsltAction"}, - {Key: "Jslt", Value: step.Expression}, - } - case "XSLT": - action = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$XsltAction"}, - {Key: "Xslt", Value: step.Expression}, - } - default: - action = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$JsltAction"}, - {Key: "Jslt", Value: step.Expression}, - } - } - - steps = append(steps, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTransformers$Step"}, - {Key: "Action", Value: action}, - {Key: "InputElementPointer", Value: idToBsonBinary(rootElemID)}, - {Key: "OutputElementPointer", Value: idToBsonBinary(rootElemID)}, - }) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dt.ID))}, - {Key: "$Type", Value: "DataTransformers$DataTransformer"}, - {Key: "Name", Value: dt.Name}, - {Key: "Documentation", Value: ""}, - {Key: "Excluded", Value: dt.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Source", Value: source}, - {Key: "Elements", Value: bson.A{int32(2), rootElement}}, - {Key: "RootElementPointer", Value: idToBsonBinary(rootElemID)}, - {Key: "Steps", Value: steps}, - } - - return marshalUnitIDFirst(doc) -} diff --git a/sdk/mpr/writer_dbconnection.go b/sdk/mpr/writer_dbconnection.go deleted file mode 100644 index 1eee2ce914..0000000000 --- a/sdk/mpr/writer_dbconnection.go +++ /dev/null @@ -1,213 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/dbconnector" - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// CreateDatabaseConnection creates a new DatabaseConnector$DatabaseConnection document. -func (w *Writer) CreateDatabaseConnection(conn *model.DatabaseConnection) error { - if conn.ID == "" { - conn.ID = model.ID(generateUUID()) - } - conn.TypeName = "DatabaseConnector$DatabaseConnection" - - contents, err := w.serializeDatabaseConnection(conn) - if err != nil { - return fmt.Errorf("failed to serialize database connection: %w", err) - } - - return w.insertUnit(string(conn.ID), string(conn.ContainerID), - "Documents", "DatabaseConnector$DatabaseConnection", contents) -} - -// UpdateDatabaseConnection updates an existing database connection. -func (w *Writer) UpdateDatabaseConnection(conn *model.DatabaseConnection) error { - contents, err := w.serializeDatabaseConnection(conn) - if err != nil { - return fmt.Errorf("failed to serialize database connection: %w", err) - } - - return w.updateUnit(string(conn.ID), contents) -} - -// MoveDatabaseConnection moves a database connection to a new container (module or folder). -func (w *Writer) MoveDatabaseConnection(conn *model.DatabaseConnection) error { - return w.moveUnitByID(string(conn.ID), string(conn.ContainerID)) -} - -// DeleteDatabaseConnection deletes a database connection by ID. -func (w *Writer) DeleteDatabaseConnection(id model.ID) error { - return w.deleteUnit(string(id)) -} - -func (w *Writer) serializeDatabaseConnection(conn *model.DatabaseConnection) ([]byte, error) { - // Build ConnectionInput — stores actual JDBC URL for Studio Pro development - connInput := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DatabaseConnector$ConnectionString"}, - {Key: "Value", Value: conn.ConnectionInputValue}, - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(conn.ID))}, - {Key: "$Type", Value: "DatabaseConnector$DatabaseConnection"}, - {Key: "Name", Value: conn.Name}, - {Key: "DatabaseType", Value: conn.DatabaseType}, - {Key: "ConnectionString", Value: conn.ConnectionString}, - {Key: "UserName", Value: conn.UserName}, - {Key: "Password", Value: conn.Password}, - {Key: "Documentation", Value: conn.Documentation}, - {Key: "Excluded", Value: conn.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "ConnectionInput", Value: connInput}, - } - - // Serialize Queries - queries := bson.A{int32(2)} // versioned array prefix - typeEnum := w.storesQueryTypeEnum() - for _, q := range conn.Queries { - queries = append(queries, serializeDBQuery(q, typeEnum)) - } - doc = append(doc, bson.E{Key: "Queries", Value: queries}) - - // AdditionalProperties (empty array) - doc = append(doc, bson.E{Key: "AdditionalProperties", Value: bson.A{int32(2)}}) - - // LastSelectedQuery (empty ref) - doc = append(doc, bson.E{Key: "LastSelectedQuery", Value: ""}) - - return marshalUnitIDFirst(doc) -} - -// storesQueryTypeEnum reports whether this project stores a query's type under the -// Mendix 11.13+ `Type` key. An unreadable version falls back to the legacy key. -func (w *Writer) storesQueryTypeEnum() bool { - if w.reader == nil { - return false - } - pv := w.reader.ProjectVersion() - if pv == nil { - return false - } - return dbconnector.StoresTypeEnum(pv.MajorVersion, pv.MinorVersion) -} - -func serializeDBQuery(q *model.DatabaseQuery, typeEnum bool) bson.D { - id := string(q.ID) - if id == "" { - id = generateUUID() - } - - // TableMappings - mappings := bson.A{int32(2)} - for _, m := range q.TableMappings { - mappings = append(mappings, serializeDBTableMapping(m)) - } - - // Parameters - params := bson.A{int32(2)} - for _, p := range q.Parameters { - params = append(params, serializeDBQueryParameter(p)) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DatabaseConnector$DatabaseQuery"}, - {Key: "Name", Value: q.Name}, - {Key: "Query", Value: q.SQL}, - } - // Exactly one of the two spellings — writing the other invents a property the - // target version's metamodel does not define. See mdl/dbconnector. - if typeEnum { - doc = append(doc, bson.E{Key: dbconnector.TypeKey, - Value: dbconnector.TypeToWrite(q.QueryTypeName, q.SQL)}) - } else { - doc = append(doc, bson.E{Key: dbconnector.QueryTypeKey, Value: int64(q.QueryType)}) - } - return append(doc, - bson.E{Key: "TableMappings", Value: mappings}, - bson.E{Key: "Parameters", Value: params}, - ) -} - -func serializeDBQueryParameter(p *model.DatabaseQueryParameter) bson.D { - id := string(p.ID) - if id == "" { - id = generateUUID() - } - - // DataType - dataType := p.DataType - if dataType == "" { - dataType = "DataTypes$StringType" - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DatabaseConnector$QueryParameter"}, - {Key: "ParameterName", Value: p.ParameterName}, - {Key: "DatabaseParameterName", Value: ""}, - {Key: "DefaultValue", Value: p.DefaultValue}, - {Key: "EmptyValueBecomesNull", Value: p.EmptyValueBecomesNull}, - {Key: "Mode", Value: "Unknown"}, - {Key: "TableMapping", Value: nil}, - {Key: "DataType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: dataType}, - }}, - {Key: "SqlDataType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DatabaseConnector$SimpleSqlDataType"}, - {Key: "DataTypeName", Value: ""}, - }}, - } -} - -func serializeDBTableMapping(m *model.DatabaseTableMapping) bson.D { - id := string(m.ID) - if id == "" { - id = generateUUID() - } - - // Columns - columns := bson.A{int32(2)} - for _, c := range m.Columns { - columns = append(columns, serializeDBColumnMapping(c)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DatabaseConnector$TableMapping"}, - {Key: "Entity", Value: m.Entity}, - {Key: "TableName", Value: m.TableName}, - {Key: "Columns", Value: columns}, - } -} - -func serializeDBColumnMapping(c *model.DatabaseColumnMapping) bson.D { - id := string(c.ID) - if id == "" { - id = generateUUID() - } - - // SqlDataType — use SimpleSqlDataType as default - cDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DatabaseConnector$ColumnMapping"}, - {Key: "Attribute", Value: c.Attribute}, - {Key: "ColumnName", Value: c.ColumnName}, - } - - cDoc = append(cDoc, bson.E{Key: "SqlDataType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DatabaseConnector$SimpleSqlDataType"}, - }}) - - return cDoc -} diff --git a/sdk/mpr/writer_dbconnection_querytype_test.go b/sdk/mpr/writer_dbconnection_querytype_test.go deleted file mode 100644 index c6443218dc..0000000000 --- a/sdk/mpr/writer_dbconnection_querytype_test.go +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/mdl/dbconnector" - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeDBQuery_QueryTypeSpellingFollowsVersion covers the Mendix 11.13 -// rename of the query-type property. 11.13 replaced the integer `QueryType` with -// the string enum `Type`; a query carrying only the legacy key reads as Unknown, -// which mxbuild reports as CE5277 ("Please re-run and save the query to fix the -// error") on every Execute-database-query activity pointing at it. -// -// Exactly one spelling must be written: the other is a property the target -// version's metamodel does not define, which is the shape Studio Pro refuses to -// open. -func TestSerializeDBQuery_QueryTypeSpellingFollowsVersion(t *testing.T) { - tests := []struct { - name string - typeEnum bool - wantKey string - wantValue any - absentKey string - }{ - { - name: "mendix_11_12_writes_legacy_int", - typeEnum: false, - wantKey: dbconnector.QueryTypeKey, - wantValue: int64(dbconnector.CustomSQLQueryType), - absentKey: dbconnector.TypeKey, - }, - { - name: "mendix_11_13_writes_type_enum", - typeEnum: true, - wantKey: dbconnector.TypeKey, - wantValue: dbconnector.TypeSelect, - absentKey: dbconnector.QueryTypeKey, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - q := &model.DatabaseQuery{ - Name: "GetAll", - SQL: "SELECT driverId FROM drivers", - QueryType: dbconnector.CustomSQLQueryType, - } - doc := docMap(serializeDBQuery(q, tc.typeEnum)) - - if got, ok := doc[tc.wantKey]; !ok || got != tc.wantValue { - t.Errorf("%s = %#v (present=%v), want %#v", tc.wantKey, got, ok, tc.wantValue) - } - if v, ok := doc[tc.absentKey]; ok { - t.Errorf("wrote %s = %#v; this Mendix version stores %s", - tc.absentKey, v, tc.wantKey) - } - }) - } -} - -// TestSerializeDBQuery_TypeFromStatement: mxcli never connects to the database, so -// it derives the 11.13 type from the statement rather than leaving it Unknown. -func TestSerializeDBQuery_TypeFromStatement(t *testing.T) { - tests := []struct { - sql string - stored string - want string - }{ - {sql: "SELECT 1", want: dbconnector.TypeSelect}, - {sql: "UPDATE drivers SET forename = 'x'", want: dbconnector.TypeNonSelect}, - // A value Studio Pro derived by running the query outranks the heuristic. - {sql: "EXEC dbo.GetRows", stored: dbconnector.TypeSelect, want: dbconnector.TypeSelect}, - } - for _, tc := range tests { - q := &model.DatabaseQuery{Name: "Q", SQL: tc.sql, QueryTypeName: tc.stored} - if got := docMap(serializeDBQuery(q, true))[dbconnector.TypeKey]; got != tc.want { - t.Errorf("Type for %q (stored %q) = %#v, want %q", tc.sql, tc.stored, got, tc.want) - } - } -} - -// TestParseDBQuery_ReadsEitherSpelling guards the read half: a project written by -// 11.13 has no QueryType at all, and reading 0 there would write Unknown straight -// back on the next ALTER. -func TestParseDBQuery_ReadsEitherSpelling(t *testing.T) { - legacy := parseDBQuery(map[string]any{ - "Name": "Q", - "Query": "SELECT 1", - dbconnector.QueryTypeKey: int32(dbconnector.CustomSQLQueryType), - "$Type": "DatabaseConnector$DatabaseQuery", - }) - if legacy.QueryType != dbconnector.CustomSQLQueryType || legacy.QueryTypeName != "" { - t.Errorf("legacy parse = %d/%q, want %d/\"\"", - legacy.QueryType, legacy.QueryTypeName, dbconnector.CustomSQLQueryType) - } - - modern := parseDBQuery(map[string]any{ - "Name": "Q", - "Query": "UPDATE t SET a = 1", - dbconnector.TypeKey: dbconnector.TypeNonSelect, - "$Type": "DatabaseConnector$DatabaseQuery", - }) - if modern.QueryTypeName != dbconnector.TypeNonSelect { - t.Errorf("QueryTypeName = %q, want %q", modern.QueryTypeName, dbconnector.TypeNonSelect) - } - if modern.QueryType != dbconnector.CustomSQLQueryType { - t.Errorf("QueryType = %d, want %d", modern.QueryType, dbconnector.CustomSQLQueryType) - } -} - -// docMap flattens a bson.D into a lookup keyed by property name. -func docMap(d bson.D) map[string]any { - out := make(map[string]any, len(d)) - for _, e := range d { - out[e.Key] = e.Value - } - return out -} diff --git a/sdk/mpr/writer_domainmodel.go b/sdk/mpr/writer_domainmodel.go deleted file mode 100644 index 8801abf665..0000000000 --- a/sdk/mpr/writer_domainmodel.go +++ /dev/null @@ -1,1572 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "bytes" - "fmt" - "sort" - "strings" - - "github.com/mendixlabs/mxcli/generated/metamodel" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - "github.com/mendixlabs/mxcli/sdk/mpr/version" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateEntity creates a new entity in a domain model. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) CreateEntity(domainModelID model.ID, entity *domainmodel.Entity) error { - // Load the domain model by its ID - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Assign ID if not set - if entity.ID == "" { - entity.ID = model.ID(generateUUID()) - } - entity.TypeName = "DomainModels$Entity" - entity.ContainerID = domainModelID - - // Assign IDs to attributes if not set - for _, attr := range entity.Attributes { - if attr.ID == "" { - attr.ID = model.ID(generateUUID()) - } - attr.TypeName = "DomainModels$Attribute" - attr.ContainerID = entity.ID - } - - // Add entity to domain model - dm.Entities = append(dm.Entities, entity) - - // Serialize and update - return w.updateDomainModel(dm) -} - -// UpdateEntity updates an existing entity. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) UpdateEntity(domainModelID model.ID, entity *domainmodel.Entity) error { - // Refuse rather than downgrade — see serializeRuleInfo. - if ruleType, ok := validationRulesAreReproducible(entity); !ok { - return fmt.Errorf( - "entity %s has a %s validation rule, which mxcli cannot rewrite without losing it — "+ - "change this entity in Studio Pro, or remove the rule first.\n"+ - " (Rewriting would silently turn it into a Required rule: the constraint would be gone "+ - "and the build would still pass.)", - entity.Name, ruleType) - } - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Find and replace the entity - for i, e := range dm.Entities { - if e.ID == entity.ID { - dm.Entities[i] = entity - return w.updateDomainModel(dm) - } - } - - return fmt.Errorf("entity not found: %s", entity.ID) -} - -// DeleteEntity deletes an entity from a domain model. -// domainModelID is the ID of the domain model itself (not the module ID). -// Cascade: any association in any module whose ParentID or ChildID matches -// entityID is also removed, preventing dangling unit-pointer errors. -func (w *Writer) DeleteEntity(domainModelID model.ID, entityID model.ID) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Find and remove the entity - found := false - for i, e := range dm.Entities { - if e.ID == entityID { - dm.Entities = append(dm.Entities[:i], dm.Entities[i+1:]...) - found = true - break - } - } - if !found { - return fmt.Errorf("entity not found: %s", entityID) - } - - // Remove associations referencing this entity from the same DM - var keptAssocs []*domainmodel.Association - for _, a := range dm.Associations { - if a.ParentID != entityID && a.ChildID != entityID { - keptAssocs = append(keptAssocs, a) - } - } - dm.Associations = keptAssocs - - if err := w.updateDomainModel(dm); err != nil { - return err - } - - // Cascade: remove associations referencing this entity from all other DMs - allDMs, err := w.reader.ListDomainModels() - if err != nil { - return fmt.Errorf("cascade cleanup: list domain models: %w", err) - } - for _, other := range allDMs { - if other.ID == domainModelID { - continue - } - changed := false - var kept []*domainmodel.Association - for _, a := range other.Associations { - if a.ParentID == entityID || a.ChildID == entityID { - changed = true - } else { - kept = append(kept, a) - } - } - if changed { - other.Associations = kept - if err := w.updateDomainModel(other); err != nil { - return fmt.Errorf("cascade cleanup: update domain model %s: %w", other.ID, err) - } - } - } - - return nil -} - -// MoveEntity moves an entity from one domain model to another. -// Associations referencing the moved entity are converted to CrossAssociations -// (cross-module associations with BY_NAME references to the remote entity). -// Validation rule attribute references are updated to reflect the new module name. -// Returns the names of converted associations (for caller to inform about). -func (w *Writer) MoveEntity(entity *domainmodel.Entity, sourceDMID, targetDMID model.ID, sourceModuleName, targetModuleName string) ([]string, error) { - // Load source domain model and remove the entity - sourceDM, err := w.reader.GetDomainModelByID(sourceDMID) - if err != nil { - return nil, fmt.Errorf("failed to load source domain model: %w", err) - } - - found := false - for i, e := range sourceDM.Entities { - if e.ID == entity.ID { - sourceDM.Entities = append(sourceDM.Entities[:i], sourceDM.Entities[i+1:]...) - found = true - break - } - } - if !found { - return nil, fmt.Errorf("entity not found in source domain model: %s", entity.ID) - } - - // Load target domain model - targetDM, err := w.reader.GetDomainModelByID(targetDMID) - if err != nil { - return nil, fmt.Errorf("failed to load target domain model: %w", err) - } - - // Convert associations referencing the moved entity to CrossAssociations. - // - If moved entity is the child: CrossAssoc stays in source DM (parent is local) - // - If moved entity is the parent: CrossAssoc goes to target DM (parent moves with entity) - var convertedAssocs []string - var keptAssocs []*domainmodel.Association - for _, a := range sourceDM.Associations { - if a.ChildID == entity.ID { - // Child is being moved → CrossAssoc stays in source DM - // ParentPointer = parent entity (stays local), Child = remote qualified name - ca := &domainmodel.CrossModuleAssociation{} - ca.ID = a.ID - ca.TypeName = "DomainModels$CrossAssociation" - ca.ContainerID = sourceDMID - ca.Name = a.Name - ca.Documentation = a.Documentation - ca.ParentID = a.ParentID - ca.ChildRef = targetModuleName + "." + entity.Name - ca.Type = a.Type - ca.Owner = a.Owner - ca.StorageFormat = a.StorageFormat - ca.ParentDeleteBehavior = a.ParentDeleteBehavior - ca.ChildDeleteBehavior = a.ChildDeleteBehavior - sourceDM.CrossAssociations = append(sourceDM.CrossAssociations, ca) - convertedAssocs = append(convertedAssocs, a.Name) - } else if a.ParentID == entity.ID { - // Parent is being moved → CrossAssoc goes to target DM - // ParentPointer = moved entity (will be local in target), Child = remote entity in source - var childEntityName string - for _, e := range sourceDM.Entities { - if e.ID == a.ChildID { - childEntityName = e.Name - break - } - } - ca := &domainmodel.CrossModuleAssociation{} - ca.ID = a.ID - ca.TypeName = "DomainModels$CrossAssociation" - ca.ContainerID = targetDMID - ca.Name = a.Name - ca.Documentation = a.Documentation - ca.ParentID = a.ParentID // parent entity ID (same, just moving to target DM) - ca.ChildRef = sourceModuleName + "." + childEntityName - ca.Type = a.Type - ca.Owner = a.Owner - ca.StorageFormat = a.StorageFormat - ca.ParentDeleteBehavior = a.ParentDeleteBehavior - ca.ChildDeleteBehavior = a.ChildDeleteBehavior - targetDM.CrossAssociations = append(targetDM.CrossAssociations, ca) - convertedAssocs = append(convertedAssocs, a.Name) - } else { - keptAssocs = append(keptAssocs, a) - } - } - sourceDM.Associations = keptAssocs - - // Update validation rule attribute references in the moved entity. - // These are BY_NAME qualified names like "OldModule.Entity.Attribute" that need - // to be updated to "NewModule.Entity.Attribute". - oldPrefix := sourceModuleName + "." - newPrefix := targetModuleName + "." - for _, vr := range entity.ValidationRules { - attrIDStr := string(vr.AttributeID) - if strings.HasPrefix(attrIDStr, oldPrefix) { - vr.AttributeID = model.ID(newPrefix + attrIDStr[len(oldPrefix):]) - } - } - - // Update SourceDocumentRef for view entities - if entity.Source == "DomainModels$OqlViewEntitySource" && entity.SourceDocumentRef != "" { - if strings.HasPrefix(entity.SourceDocumentRef, oldPrefix) { - entity.SourceDocumentRef = newPrefix + entity.SourceDocumentRef[len(oldPrefix):] - } - } - - // Save source domain model - if err := w.updateDomainModel(sourceDM); err != nil { - return nil, fmt.Errorf("failed to update source domain model: %w", err) - } - - // Add entity to target domain model and save - entity.ContainerID = targetDMID - targetDM.Entities = append(targetDM.Entities, entity) - - if err := w.updateDomainModel(targetDM); err != nil { - return nil, fmt.Errorf("failed to update target domain model: %w", err) - } - - return convertedAssocs, nil -} - -// UpdateEnumerationRefsInAllDomainModels updates enumeration references across all domain models. -// When an enumeration is moved to a different module, its qualified name changes and all -// EnumerationAttributeType references need to be updated. -func (w *Writer) UpdateEnumerationRefsInAllDomainModels(oldQualifiedName, newQualifiedName string) error { - dms, err := w.reader.ListDomainModels() - if err != nil { - return fmt.Errorf("failed to list domain models: %w", err) - } - - for _, dm := range dms { - changed := false - for _, entity := range dm.Entities { - for _, attr := range entity.Attributes { - if enumType, ok := attr.Type.(*domainmodel.EnumerationAttributeType); ok { - if enumType.EnumerationRef == oldQualifiedName { - enumType.EnumerationRef = newQualifiedName - enumType.EnumerationID = model.ID(newQualifiedName) - changed = true - } - } - } - } - if changed { - if err := w.updateDomainModel(dm); err != nil { - return fmt.Errorf("failed to update domain model %s: %w", dm.ID, err) - } - } - } - return nil -} - -// MoveViewEntitySourceDocument moves a ViewEntitySourceDocument to a new module. -func (w *Writer) MoveViewEntitySourceDocument(sourceModuleName string, targetModuleID model.ID, docName string) error { - docID, err := w.FindViewEntitySourceDocumentID(sourceModuleName, docName) - if err != nil { - return err - } - if docID == "" { - return nil // No document to move - } - - // Update ContainerID in database - return w.moveUnitByID(string(docID), string(targetModuleID)) -} - -// UpdateOqlQueriesForMovedEntity updates OQL queries in all ViewEntitySourceDocuments -// to reflect a moved entity's new qualified name. For example, when DmTest.Customer moves -// to DmTest2.Customer, all OQL references like "DmTest.Customer" are updated. -func (w *Writer) UpdateOqlQueriesForMovedEntity(oldQualifiedName, newQualifiedName string) (int, error) { - units, err := w.reader.listUnitsByType("DomainModels$ViewEntitySourceDocument") - if err != nil { - return 0, err - } - - updated := 0 - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - oql, _ := raw["Oql"].(string) - if oql == "" || !strings.Contains(oql, oldQualifiedName) { - continue - } - - // Replace entity references in OQL - newOql := strings.ReplaceAll(oql, oldQualifiedName, newQualifiedName) - raw["Oql"] = newOql - - // Re-serialize and update - contents, err := marshalUnitIDFirst(raw) - if err != nil { - continue - } - if err := w.updateUnit(u.ID, contents); err != nil { - return updated, fmt.Errorf("failed to update ViewEntitySourceDocument %s: %w", u.ID, err) - } - updated++ - } - return updated, nil -} - -// moveUnitByID changes a unit's ContainerID without modifying its contents. -// MoveUnitByID reparents any top-level document unit. Exported so backends can -// move doctypes that have no dedicated writer method of their own (Java actions, -// published OData services) — the containment row is all that changes. -func (w *Writer) MoveUnitByID(unitID string, newContainerID string) error { - return w.moveUnitByID(unitID, newContainerID) -} - -// Counted in WriteStats and elided when the unit already sits in that -// container, for the reasons on the modelsdk engine's MoveUnit: a move changes -// placement without changing contents, so it is invisible to both halves of -// ADR-0008 unless the row update accounts for itself. -func (w *Writer) moveUnitByID(unitID string, newContainerID string) error { - w.writesOffered++ - unitIDBlob := uuidToBlob(unitID) - containerIDBlob := uuidToBlob(newContainerID) - - var stored []byte - if err := w.reader.db.QueryRow(`SELECT ContainerID FROM Unit WHERE UnitID = ?`, unitIDBlob).Scan(&stored); err == nil { - if bytes.Equal(stored, containerIDBlob) { - return nil - } - } - - _, err := w.reader.db.Exec(`UPDATE Unit SET ContainerID = ? WHERE UnitID = ?`, containerIDBlob, unitIDBlob) - if err == nil { - w.writesLanded++ - w.reader.InvalidateCache() - } - return err -} - -// AddAttribute adds an attribute to an entity. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) AddAttribute(domainModelID model.ID, entityID model.ID, attr *domainmodel.Attribute) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Find the entity - for _, e := range dm.Entities { - if e.ID == entityID { - if attr.ID == "" { - attr.ID = model.ID(generateUUID()) - } - attr.TypeName = "DomainModels$Attribute" - attr.ContainerID = entityID - e.Attributes = append(e.Attributes, attr) - return w.updateDomainModel(dm) - } - } - - return fmt.Errorf("entity not found: %s", entityID) -} - -// UpdateAttribute updates an existing attribute in an entity. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) UpdateAttribute(domainModelID model.ID, entityID model.ID, attr *domainmodel.Attribute) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Find the entity - for _, e := range dm.Entities { - if e.ID == entityID { - // Find and update the attribute - for i, a := range e.Attributes { - if a.ID == attr.ID { - e.Attributes[i] = attr - return w.updateDomainModel(dm) - } - } - return fmt.Errorf("attribute not found: %s", attr.ID) - } - } - - return fmt.Errorf("entity not found: %s", entityID) -} - -// DeleteAttribute deletes an attribute from an entity. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) DeleteAttribute(domainModelID model.ID, entityID model.ID, attrID model.ID) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - // Find the entity - for _, e := range dm.Entities { - if e.ID == entityID { - // Find and remove the attribute - for i, a := range e.Attributes { - if a.ID == attrID { - e.Attributes = append(e.Attributes[:i], e.Attributes[i+1:]...) - return w.updateDomainModel(dm) - } - } - return fmt.Errorf("attribute not found: %s", attrID) - } - } - - return fmt.Errorf("entity not found: %s", entityID) -} - -// CreateAssociation creates a new association between entities. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) CreateAssociation(domainModelID model.ID, assoc *domainmodel.Association) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - if assoc.ID == "" { - assoc.ID = model.ID(generateUUID()) - } - assoc.TypeName = "DomainModels$Association" - assoc.ContainerID = domainModelID - - dm.Associations = append(dm.Associations, assoc) - return w.updateDomainModel(dm) -} - -// CreateCrossAssociation creates a cross-module association in a domain model. -// The parent entity must be local to this domain model; the child entity is -// referenced by qualified name (BY_NAME) since it lives in another module. -func (w *Writer) CreateCrossAssociation(domainModelID model.ID, ca *domainmodel.CrossModuleAssociation) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - if ca.ID == "" { - ca.ID = model.ID(generateUUID()) - } - ca.TypeName = "DomainModels$CrossAssociation" - ca.ContainerID = domainModelID - - dm.CrossAssociations = append(dm.CrossAssociations, ca) - return w.updateDomainModel(dm) -} - -// DeleteAssociation deletes an association. -// domainModelID is the ID of the domain model itself (not the module ID). -func (w *Writer) DeleteAssociation(domainModelID model.ID, assocID model.ID) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - for i, a := range dm.Associations { - if a.ID == assocID { - dm.Associations = append(dm.Associations[:i], dm.Associations[i+1:]...) - return w.updateDomainModel(dm) - } - } - - return fmt.Errorf("association not found: %s", assocID) -} - -// DeleteCrossAssociation removes a cross-module association from a domain model. -func (w *Writer) DeleteCrossAssociation(domainModelID model.ID, assocID model.ID) error { - dm, err := w.reader.GetDomainModelByID(domainModelID) - if err != nil { - return err - } - - for i, ca := range dm.CrossAssociations { - if ca.ID == assocID { - dm.CrossAssociations = append(dm.CrossAssociations[:i], dm.CrossAssociations[i+1:]...) - return w.updateDomainModel(dm) - } - } - - return fmt.Errorf("cross-module association not found: %s", assocID) -} - -// CreateViewEntitySourceDocument creates a ViewEntitySourceDocument for a view entity. -// This is a separate document that contains the OQL query for the view entity. -func (w *Writer) CreateViewEntitySourceDocument(moduleID model.ID, moduleName, docName, oqlQuery, documentation string) (model.ID, error) { - docID := model.ID(generateUUID()) - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(docID))}, - {Key: "$Type", Value: "DomainModels$ViewEntitySourceDocument"}, - {Key: "Documentation", Value: documentation}, - {Key: "Excluded", Value: false}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Name", Value: docName}, - {Key: "Oql", Value: oqlQuery}, - } - - contents, err := marshalUnitIDFirst(doc) - if err != nil { - return "", fmt.Errorf("failed to serialize ViewEntitySourceDocument: %w", err) - } - - if err := w.insertUnit(string(docID), string(moduleID), "Documents", "DomainModels$ViewEntitySourceDocument", contents); err != nil { - return "", fmt.Errorf("failed to insert ViewEntitySourceDocument: %w", err) - } - - return docID, nil -} - -// DeleteViewEntitySourceDocument deletes a ViewEntitySourceDocument. -func (w *Writer) DeleteViewEntitySourceDocument(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// FindViewEntitySourceDocumentID finds a ViewEntitySourceDocument by module and document name. -// Returns the document ID if found, empty string if not found. -func (w *Writer) FindViewEntitySourceDocumentID(moduleName, docName string) (model.ID, error) { - units, err := w.reader.listUnitsByType("DomainModels$ViewEntitySourceDocument") - if err != nil { - return "", err - } - - // Build module ID -> name map - modules, err := w.reader.ListModules() - if err != nil { - return "", err - } - moduleNames := make(map[string]string) - for _, m := range modules { - moduleNames[string(m.ID)] = m.Name - } - - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - name, _ := raw["Name"].(string) - modName := moduleNames[u.ContainerID] - - if modName == moduleName && name == docName { - return model.ID(u.ID), nil - } - } - - return "", nil // Not found -} - -// DeleteViewEntitySourceDocumentByName deletes ALL ViewEntitySourceDocuments matching the -// given module and document name. This handles cleanup of duplicate documents that may -// have accumulated from previous script runs or incomplete deletions. -// Returns nil if documents were deleted or none existed. -func (w *Writer) DeleteViewEntitySourceDocumentByName(moduleName, docName string) error { - docIDs, err := w.FindAllViewEntitySourceDocumentIDs(moduleName, docName) - if err != nil { - return err - } - for _, docID := range docIDs { - if err := w.deleteUnit(string(docID)); err != nil { - return err - } - } - return nil -} - -// FindAllViewEntitySourceDocumentIDs finds ALL ViewEntitySourceDocuments matching the -// given module and document name. Returns all matching IDs (not just the first). -func (w *Writer) FindAllViewEntitySourceDocumentIDs(moduleName, docName string) ([]model.ID, error) { - units, err := w.reader.listUnitsByType("DomainModels$ViewEntitySourceDocument") - if err != nil { - return nil, err - } - - // Build module ID -> name map - modules, err := w.reader.ListModules() - if err != nil { - return nil, err - } - moduleNames := make(map[string]string) - for _, m := range modules { - moduleNames[string(m.ID)] = m.Name - } - - var ids []model.ID - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - name, _ := raw["Name"].(string) - modName := moduleNames[u.ContainerID] - - if modName == moduleName && name == docName { - ids = append(ids, model.ID(u.ID)) - } - } - - return ids, nil -} -func (w *Writer) serializeDomainModel(dm *domainmodel.DomainModel) ([]byte, error) { - // Look up module name for qualified names in validation rules - moduleName := "" - if dm.ContainerID != "" { - module, err := w.reader.GetModule(dm.ContainerID) - if err == nil && module != nil { - moduleName = module.Name - } - } - - // Entities array with version prefix 3 - pv := w.reader.ProjectVersion() - entities := bson.A{int32(3)} - for _, e := range dm.Entities { - entities = append(entities, serializeEntity(e, moduleName, pv)) - } - - // Associations array with version prefix 3 - associations := bson.A{int32(3)} - for _, a := range dm.Associations { - associations = append(associations, serializeAssociation(a)) - } - - // Annotations array with version prefix 3. - // - // This used to be written as the bare empty array regardless of what the - // domain model held, so every rewrite deleted every note on the canvas — - // adding one entity to a blank app took its annotation count from 1 to 0. - // `mx check` reports 0 errors either way: an annotation is decorative, so - // nothing below Studio Pro can see it go. - annotations := bson.A{int32(3)} - for _, a := range dm.Annotations { - annotations = append(annotations, serializeDomainModelAnnotation(a)) - } - - // CrossAssociations array with version prefix 3 - crossAssociations := bson.A{int32(3)} - for _, ca := range dm.CrossAssociations { - crossAssociations = append(crossAssociations, serializeCrossAssociation(ca)) - } - - // Use bson.D (ordered) so $Type appears early — Mendix requires this for correct parsing - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dm.ID))}, - {Key: "$Type", Value: "DomainModels$DomainModel"}, - {Key: "Documentation", Value: ""}, - {Key: "Annotations", Value: annotations}, - {Key: "Entities", Value: entities}, - {Key: "Associations", Value: associations}, - {Key: "CrossAssociations", Value: crossAssociations}, - } - return marshalUnitIDFirst(doc) -} - -func serializeEntity(e *domainmodel.Entity, moduleName string, pv *version.ProjectVersion) bson.D { - // Any of the three OData source types means the attributes need OData - // mapped value serialization (Rest$ODataMappedValue or its primitive - // collection variant), not the regular DomainModels$StoredValue. - isExternal := e.Source == "Rest$ODataRemoteEntitySource" || - e.Source == "Rest$ODataEntityTypeSource" || - e.Source == "Rest$ODataPrimitiveCollectionEntitySource" - - // Attributes array with version prefix 3 - attrs := bson.A{int32(3)} - for _, a := range e.Attributes { - attrs = append(attrs, serializeAttribute(a, isExternal)) - } - - // Indexes array with version prefix 3 - indexes := bson.A{int32(3)} - for _, idx := range e.Indexes { - indexes = append(indexes, serializeIndex(idx)) - } - - // ValidationRules array with version prefix 3 - validationRules := bson.A{int32(3)} - for _, vr := range e.ValidationRules { - validationRules = append(validationRules, serializeValidationRule(vr, moduleName, e)) - } - - // Generate a GUID for the entity if not present (used for qualified name) - entityGUID := idToBsonBinary(string(e.ID)) - - // Location is stored as "x;y" string format - location := fmt.Sprintf("%d;%d", e.Location.X, e.Location.Y) - - // Serialize generalization: either a parent entity reference or NoGeneralization - var maybeGeneralization bson.D - if e.GeneralizationRef != "" { - maybeGeneralization = serializeGeneralization(e.GeneralizationRef) - } else { - maybeGeneralization = serializeNoGeneralization(e, pv) - } - - // AccessRules array with version prefix 3 - accessRules := bson.A{int32(3)} - for _, ar := range e.AccessRules { - accessRules = append(accessRules, serializeAccessRule(ar)) - } - - // Use bson.D (ordered document) to match Studio Pro field order - // Mendix 11.12 requires "$ID" to be the first property of every storage object - // ("$Type" conventionally second); it rejects the unit otherwise. Remaining - // fields keep Studio Pro's order. - // CRITICAL: Attributes MUST come before ValidationRules for attribute lookup to work - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(e.ID))}, - {Key: "$Type", Value: "DomainModels$EntityImpl"}, - {Key: "Name", Value: e.Name}, - {Key: "Documentation", Value: e.Documentation}, - {Key: "MaybeGeneralization", Value: maybeGeneralization}, - {Key: "Attributes", Value: attrs}, // Must come before ValidationRules! - {Key: "AccessRules", Value: accessRules}, - {Key: "ValidationRules", Value: validationRules}, // After Attributes - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "GUID", Value: entityGUID}, - {Key: "Location", Value: location}, - {Key: "Indexes", Value: indexes}, - {Key: "Events", Value: serializeEventHandlers(e.EventHandlers)}, - } - - // Add Source for view entities (references a ViewEntitySourceDocument) - if e.Source == "DomainModels$OqlViewEntitySource" && e.SourceDocumentRef != "" { - doc = append(doc, bson.E{Key: "Source", Value: serializeOqlViewEntitySource(e.SourceObjectID, e.SourceDocumentRef, e.OqlQuery, pv)}) - } - - // Add Source for external entities (OData remote entity source) - if e.Source == "Rest$ODataRemoteEntitySource" && e.RemoteServiceName != "" { - doc = append(doc, bson.E{Key: "Source", Value: serializeODataRemoteEntitySource(e)}) - } - - // Source for entity-type-only external entities (derived/abstract/contained types - // that have no entity set, e.g. PlanItem, Flight, Trip) - if e.Source == "Rest$ODataEntityTypeSource" && e.RemoteServiceName != "" { - doc = append(doc, bson.E{Key: "Source", Value: serializeODataEntityTypeSource(e)}) - } - - // Source for primitive collection NPEs (e.g. TripTag for Trip.Tags = Collection(Edm.String)) - if e.Source == "Rest$ODataPrimitiveCollectionEntitySource" && e.RemoteServiceName != "" { - doc = append(doc, bson.E{Key: "Source", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataPrimitiveCollectionEntitySource"}, - {Key: "SourceDocument", Value: e.RemoteServiceName}, - }}) - } - - return doc -} - -// serializeODataEntityTypeSource emits Rest$ODataEntityTypeSource for an entity -// that maps to an OData entity type but has no entity set (e.g. derived, -// abstract, or contained nav target). It carries only the type name, key, and -// SourceDocument — no CRUD or paging fields. -func serializeODataEntityTypeSource(e *domainmodel.Entity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataEntityTypeSource"}, - {Key: "EntityTypeName", Value: e.RemoteEntityName}, - {Key: "IsOpen", Value: e.IsOpen}, - } - - if len(e.RemoteKeyParts) > 0 { - parts := bson.A{int32(2)} - for _, kp := range e.RemoteKeyParts { - parts = append(parts, serializeODataKeyPart(kp)) - } - doc = append(doc, bson.E{Key: "Key", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataKey"}, - {Key: "Parts", Value: parts}, - }}) - } - - doc = append(doc, bson.E{Key: "SourceDocument", Value: e.RemoteServiceName}) - return doc -} - -// serializeEventHandlers serializes a list of EventHandlers to a BSON array. -// Returns [int32(3)] for empty (storageListType 3 = stored object list). -func serializeEventHandlers(handlers []*domainmodel.EventHandler) bson.A { - arr := bson.A{int32(3)} - for _, eh := range handlers { - arr = append(arr, serializeEventHandler(eh)) - } - return arr -} - -// serializeEventHandler serializes a single EventHandler to BSON. -// $Type is "DomainModels$EntityEvent". Microflow uses BY_NAME (string) reference. -func serializeEventHandler(eh *domainmodel.EventHandler) bson.D { - ehID := string(eh.ID) - if ehID == "" { - ehID = generateUUID() - } - moment := string(eh.Moment) - if moment == "" { - moment = "Before" - } - event := string(eh.Event) - if event == "" { - event = "Commit" - } - // BY_NAME reference for the microflow - var microflowRef interface{} - if eh.MicroflowName != "" { - microflowRef = eh.MicroflowName - } else if eh.MicroflowID != "" { - microflowRef = idToBsonBinary(string(eh.MicroflowID)) - } else { - microflowRef = "" - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(ehID)}, - {Key: "$Type", Value: "DomainModels$EntityEvent"}, - {Key: "Microflow", Value: microflowRef}, - {Key: "Moment", Value: moment}, - {Key: "RaiseErrorOnFalse", Value: eh.RaiseErrorOnFalse}, - {Key: "SendInputParameter", Value: eh.PassEventObject}, - {Key: "Type", Value: event}, - } -} - -func serializeAccessRule(ar *domainmodel.AccessRule) bson.D { - // AllowedModuleRoles: storageListType 1 (BY_NAME references) - roles := bson.A{int32(1)} - for _, name := range ar.ModuleRoleNames { - roles = append(roles, name) - } - - // MemberAccesses: storageListType 3 - memberAccesses := bson.A{int32(3)} - for _, ma := range ar.MemberAccesses { - memberAccesses = append(memberAccesses, serializeMemberAccess(ma)) - } - - ruleID := string(ar.ID) - if ruleID == "" { - ruleID = generateUUID() - } - - defaultMemberAccess := string(ar.DefaultMemberAccessRights) - if defaultMemberAccess == "" { - defaultMemberAccess = "None" - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(ruleID)}, - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowedModuleRoles", Value: roles}, - {Key: "AllowCreate", Value: ar.AllowCreate}, - {Key: "AllowDelete", Value: ar.AllowDelete}, - {Key: "DefaultMemberAccessRights", Value: defaultMemberAccess}, - {Key: "XPathConstraint", Value: ar.XPathConstraint}, - {Key: "XPathConstraintCaption", Value: ""}, - {Key: "Documentation", Value: ""}, - {Key: "MemberAccesses", Value: memberAccesses}, - } -} - -func serializeMemberAccess(ma *domainmodel.MemberAccess) bson.D { - maID := string(ma.ID) - if maID == "" { - maID = generateUUID() - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(maID)}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: string(ma.AccessRights)}, - } - - // Attribute reference (BY_NAME) - if ma.AttributeName != "" { - doc = append(doc, bson.E{Key: "Attribute", Value: ma.AttributeName}) - } - - // Association reference (BY_NAME) - if ma.AssociationName != "" { - doc = append(doc, bson.E{Key: "Association", Value: ma.AssociationName}) - } - - return doc -} - -func serializeNoGeneralization(e *domainmodel.Entity, pv *version.ProjectVersion) bson.D { - // Persistability rules for external entities, verified against Studio Pro - // reference projects: - // Rest$ODataRemoteEntitySource → Persistable=true - // Rest$ODataEntityTypeSource → Persistable=false - // Rest$ODataPrimitiveCollectionEntitySource → Persistable=false - persistable := e.Persistable - switch e.Source { - case "Rest$ODataRemoteEntitySource": - persistable = true - case "Rest$ODataEntityTypeSource", "Rest$ODataPrimitiveCollectionEntitySource": - persistable = false - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$NoGeneralization"}, - {Key: "Persistable", Value: persistable}, - } - // Mendix >= 11.9 renamed HasOwner → HasOwnerAttr, etc. - useAttrSuffix := pv != nil && pv.IsAtLeast(11, 9) - ownerKey, changedByKey, changedDateKey, createdDateKey := "HasOwner", "HasChangedBy", "HasChangedDate", "HasCreatedDate" - if useAttrSuffix { - ownerKey, changedByKey, changedDateKey, createdDateKey = "HasOwnerAttr", "HasChangedByAttr", "HasChangedDateAttr", "HasCreatedDateAttr" - } - if e.HasOwner { - doc = append(doc, bson.E{Key: ownerKey, Value: true}) - } - if e.HasChangedBy { - doc = append(doc, bson.E{Key: changedByKey, Value: true}) - } - if e.HasChangedDate { - doc = append(doc, bson.E{Key: changedDateKey, Value: true}) - } - if e.HasCreatedDate { - doc = append(doc, bson.E{Key: createdDateKey, Value: true}) - } - return doc -} - -func serializeGeneralization(parentRef string) bson.D { - // Generalization stores the parent entity as a BY_NAME qualified name string - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$Generalization"}, - {Key: "Generalization", Value: parentRef}, - } -} - -func serializeOqlViewEntitySource(sourceObjectID model.ID, sourceDocumentRef, oqlQuery string, pv *version.ProjectVersion) bson.D { - id := string(sourceObjectID) - if id == "" { - id = generateUUID() - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DomainModels$OqlViewEntitySource"}, - } - // Mendix 10.x stores the OQL query inline on the source object (reflection data: 10.21 has "Oql" property). - // Mendix 11.0+ removed this field; only the ViewEntitySourceDocument stores the OQL. - if !pv.IsAtLeast(11, 0) { - doc = append(doc, bson.E{Key: "Oql", Value: oqlQuery}) - } - doc = append(doc, bson.E{Key: "SourceDocument", Value: sourceDocumentRef}) - return doc -} - -func serializeODataRemoteEntitySource(e *domainmodel.Entity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataRemoteEntitySource"}, - {Key: "Countable", Value: e.Countable}, - {Key: "Creatable", Value: e.Creatable}, - {Key: "CreateChangeLocally", Value: e.CreateChangeLocally}, - {Key: "Deletable", Value: e.Deletable}, - {Key: "EntitySet", Value: e.RemoteEntitySet}, - } - - // Key with KeyParts (storageListType 2) - if len(e.RemoteKeyParts) > 0 { - parts := bson.A{int32(2)} - for _, kp := range e.RemoteKeyParts { - parts = append(parts, serializeODataKeyPart(kp)) - } - key := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataKey"}, - {Key: "Parts", Value: parts}, - } - doc = append(doc, bson.E{Key: "Key", Value: key}) - } - - doc = append(doc, - bson.E{Key: "RemoteName", Value: e.RemoteEntityName}, - bson.E{Key: "SkipSupported", Value: e.SkipSupported}, - bson.E{Key: "SourceDocument", Value: e.RemoteServiceName}, - bson.E{Key: "TopSupported", Value: e.TopSupported}, - ) - return doc -} - -func serializeODataKeyPart(kp *domainmodel.RemoteKeyPart) bson.D { - // Build the type sub-document, similar to serializeAttribute's NewType - typeName := "DomainModels$StringAttributeType" - if kp.Type != nil { - typeName = "DomainModels$" + kp.Type.GetTypeName() + "AttributeType" - } - typeDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: typeName}, - } - if t, ok := kp.Type.(*domainmodel.StringAttributeType); ok { - typeDoc = append(typeDoc, bson.E{Key: "Length", Value: t.Length}) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataKeyPart"}, - {Key: "EntityKeyPartName", Value: kp.Name}, - {Key: "Filterable", Value: true}, - {Key: "Name", Value: kp.RemoteName}, - {Key: "RemoteType", Value: kp.RemoteType}, - {Key: "Type", Value: typeDoc}, - } -} - -func serializeAttribute(a *domainmodel.Attribute, isExternalEntity bool) bson.D { - // Attribute type with its own ID - use bson.D for ordered fields - typeName := "DomainModels$StringAttributeType" - if a.Type != nil { - switch a.Type.(type) { - case *domainmodel.DateAttributeType: - // Date is stored as DateTimeAttributeType with LocalizeDate=false - typeName = "DomainModels$DateTimeAttributeType" - default: - typeName = "DomainModels$" + a.Type.GetTypeName() + "AttributeType" - } - } - - attrTypeID := generateUUID() - if a.Type != nil { - if elem, ok := a.Type.(model.Element); ok && elem.GetID() != "" { - attrTypeID = string(elem.GetID()) - } - } - attrType := bson.D{ - {Key: "$ID", Value: idToBsonBinary(attrTypeID)}, - {Key: "$Type", Value: typeName}, - } - // Add type-specific properties - if a.Type != nil { - switch t := a.Type.(type) { - case *domainmodel.StringAttributeType: - attrType = append(attrType, bson.E{Key: "Length", Value: t.Length}) - case *domainmodel.DateTimeAttributeType: - attrType = append(attrType, bson.E{Key: "LocalizeDate", Value: t.LocalizeDate}) - case *domainmodel.DateAttributeType: - attrType = append(attrType, bson.E{Key: "LocalizeDate", Value: false}) - case *domainmodel.EnumerationAttributeType: - // Enumeration uses BY_NAME_REFERENCE - store as qualified name string - enumRef := t.EnumerationRef - if enumRef == "" && t.EnumerationID != "" { - // Fall back to ID if no ref (though this shouldn't happen for new entities) - enumRef = string(t.EnumerationID) - } - attrType = append(attrType, bson.E{Key: "Enumeration", Value: enumRef}) - } - } - - // Determine value type: OqlViewValue, CalculatedValue, ODataMappedValue, or StoredValue - var valueDoc bson.D - valueID := "" - if a.Value != nil && a.Value.ID != "" { - valueID = string(a.Value.ID) - } - if valueID == "" { - valueID = generateUUID() - } - if a.Value != nil && a.Value.ViewReference != "" { - // View entity attribute - use OqlViewValue - valueDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(valueID)}, - {Key: "$Type", Value: "DomainModels$OqlViewValue"}, - {Key: "Reference", Value: a.Value.ViewReference}, - } - } else if a.Value != nil && a.Value.Type == "CalculatedValue" { - // Calculated attribute - use CalculatedValue (Microflow is ByNameReference → string) - microflowRef := a.Value.MicroflowName - valueDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$CalculatedValue"}, - {Key: "Microflow", Value: microflowRef}, - {Key: "PassEntity", Value: microflowRef != ""}, - } - } else if isExternalEntity && a.IsPrimitiveCollection { - // Single attribute of a primitive collection NPE (e.g. TripTag.Tag) - defaultValue := "" - if a.Value != nil && a.Value.DefaultValue != "" { - defaultValue = a.Value.DefaultValue - } - valueDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(valueID)}, - {Key: "$Type", Value: "Rest$ODataMappedPrimitiveCollectionValue"}, - {Key: "DefaultValueDesignTime", Value: defaultValue}, - {Key: "RemoteName", Value: a.RemoteName}, - {Key: "RemoteType", Value: a.RemoteType}, - } - } else if isExternalEntity && a.RemoteName != "" { - // External entity attribute backed by an OData property - use ODataMappedValue - defaultValue := "" - if a.Value != nil && a.Value.DefaultValue != "" { - defaultValue = a.Value.DefaultValue - } - valueDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(valueID)}, - {Key: "$Type", Value: "Rest$ODataMappedValue"}, - {Key: "Creatable", Value: a.Creatable}, - {Key: "DefaultValueDesignTime", Value: defaultValue}, - {Key: "Filterable", Value: a.Filterable}, - {Key: "RemoteName", Value: a.RemoteName}, - {Key: "RemoteType", Value: a.RemoteType}, - {Key: "RepresentsStream", Value: false}, - {Key: "Sortable", Value: a.Sortable}, - {Key: "Updatable", Value: a.Updatable}, - } - } else { - // Regular entity attribute - use StoredValue - defaultValue := "" - if a.Value != nil && a.Value.DefaultValue != "" { - defaultValue = a.Value.DefaultValue - } - valueDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(valueID)}, - {Key: "$Type", Value: "DomainModels$StoredValue"}, - {Key: "DefaultValue", Value: defaultValue}, - } - } - - // Mendix 11.12 requires "$ID" first, "$Type" second; remaining fields keep - // Studio Pro's order (Name, Documentation, ExportLevel, GUID, NewType, Value). - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "DomainModels$Attribute"}, - {Key: "Name", Value: a.Name}, - {Key: "Documentation", Value: a.Documentation}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "GUID", Value: idToBsonBinary(string(a.ID))}, - {Key: "NewType", Value: attrType}, - {Key: "Value", Value: valueDoc}, - } -} - -func serializeAssociation(a *domainmodel.Association) bson.D { - storageFormat := string(a.StorageFormat) - if storageFormat == "" { - storageFormat = "Column" - } - - var source any - switch a.Source { - case "Rest$ODataRemoteAssociationSource": - nav := a.Navigability2 - if nav == "" { - nav = "ParentToChild" - } - source = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataRemoteAssociationSource"}, - {Key: "CreatableFromChild", Value: a.CreatableFromChild}, - {Key: "CreatableFromParent", Value: a.CreatableFromParent}, - {Key: "Navigability2", Value: nav}, - {Key: "RemoteChildNavigationProperty", Value: a.RemoteChildNavigationProperty}, - {Key: "RemoteParentNavigationProperty", Value: a.RemoteParentNavigationProperty}, - {Key: "UpdatableFromChild", Value: a.UpdatableFromChild}, - {Key: "UpdatableFromParent", Value: a.UpdatableFromParent}, - } - case "Rest$ODataPrimitiveCollectionAssociationSource": - // Studio Pro emits this with no extra fields — it's a marker that - // pairs with Rest$ODataPrimitiveCollectionEntitySource on the child. - source = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ODataPrimitiveCollectionAssociationSource"}, - } - case domainmodel.OqlViewAssociationSource: - source = oqlViewAssociationSourceDoc(a.ViewSourceReference) - default: - source = nil - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "DomainModels$Association"}, - {Key: "Name", Value: a.Name}, - {Key: "Documentation", Value: a.Documentation}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "GUID", Value: idToBsonBinary(string(a.ID))}, - {Key: "ParentPointer", Value: idToBsonBinary(string(a.ParentID))}, - {Key: "ChildPointer", Value: idToBsonBinary(string(a.ChildID))}, - {Key: "Type", Value: string(a.Type)}, - {Key: "Owner", Value: string(a.Owner)}, - {Key: "ParentConnection", Value: domainmodel.FormatConnectionPoint(a.ParentConnection, domainmodel.DefaultParentConnection)}, - {Key: "ChildConnection", Value: domainmodel.FormatConnectionPoint(a.ChildConnection, domainmodel.DefaultChildConnection)}, - {Key: "StorageFormat", Value: storageFormat}, - {Key: "DeleteBehavior", Value: serializeDeleteBehavior(a.ParentDeleteBehavior, a.ChildDeleteBehavior)}, - {Key: "Source", Value: source}, - } -} - -func serializeCrossAssociation(ca *domainmodel.CrossModuleAssociation) bson.D { - storageFormat := string(ca.StorageFormat) - if storageFormat == "" { - storageFormat = "Column" - } - // CrossAssociation does NOT have ParentConnection/ChildConnection properties - // (unlike Association). Writing them causes Studio Pro to crash with - // InvalidOperationException in MprProperty..ctor. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ca.ID))}, - {Key: "$Type", Value: "DomainModels$CrossAssociation"}, - {Key: "Name", Value: ca.Name}, - {Key: "Documentation", Value: ca.Documentation}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "GUID", Value: idToBsonBinary(string(ca.ID))}, - {Key: "ParentPointer", Value: idToBsonBinary(string(ca.ParentID))}, - {Key: "Child", Value: ca.ChildRef}, - {Key: "Type", Value: string(ca.Type)}, - {Key: "Owner", Value: string(ca.Owner)}, - {Key: "StorageFormat", Value: storageFormat}, - {Key: "Source", Value: crossAssociationSource(ca)}, - {Key: "DeleteBehavior", Value: serializeDeleteBehavior(ca.ParentDeleteBehavior, ca.ChildDeleteBehavior)}, - } -} - -// oqlViewAssociationSourceDoc builds that subdocument. Three keys and no more — -// the shape is pinned against a Studio Pro document (ako/TestApp, 11.14) and -// re-measured here on 11.13.0. -func oqlViewAssociationSourceDoc(reference string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: domainmodel.OqlViewAssociationSource}, - {Key: "Reference", Value: reference}, - } -} - -func crossAssociationSource(ca *domainmodel.CrossModuleAssociation) any { - if ca.Source == domainmodel.OqlViewAssociationSource { - return oqlViewAssociationSourceDoc(ca.ViewSourceReference) - } - // A CrossAssociation has never carried an OData source — those live between - // external entities, which are not cross-module — so nil stays the default - // rather than being widened speculatively. - return nil -} - -func serializeDeleteBehavior(parentBehavior, childBehavior *domainmodel.DeleteBehavior) bson.D { - parentType := "DeleteMeButKeepReferences" - childType := "DeleteMeButKeepReferences" - - if parentBehavior != nil && parentBehavior.Type != "" { - parentType = string(parentBehavior.Type) - } - if childBehavior != nil && childBehavior.Type != "" { - childType = string(childBehavior.Type) - } - - // A "delete me if no references" child side carries the message the user sees - // when the delete is refused; every other behaviour leaves it null. Both were - // hardcoded null here, and for that one behaviour that produces a model whose - // RUNTIME will not start — `None.get` in SchemeFactory, with `mx check` - // reporting 0 errors either way (CapTrackV2 §1). - // - // Shape measured on a Studio Pro reference (ako/TestApp, - // Mappings.Order_Customer): an ordinary Texts$Text, which serializeText - // already produces with the typed-array marker 3 this needs. - var childMessage any - if childType == string(domainmodel.DeleteBehaviorTypeDeleteMeIfNoReferences) { - msg := "" - if childBehavior != nil { - msg = childBehavior.ErrorMessage - } - childMessage = serializeText(&model.Text{Translations: map[string]string{"en_US": msg}}) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DeleteBehavior"}, - {Key: "ChildDeleteBehavior", Value: childType}, - {Key: "ChildErrorMessage", Value: childMessage}, - {Key: "ParentDeleteBehavior", Value: parentType}, - {Key: "ParentErrorMessage", Value: nil}, - } -} - -// zeroGUID is the all-zero UUID Studio Pro writes for an unset GUID reference. -const zeroGUID = "00000000-0000-0000-0000-000000000000" - -func serializeIndex(idx *domainmodel.Index) bson.D { - // IndexedAttribute lists use typed-array marker 2 (NOT the domain-model - // default of 3) — verified against real Studio-Pro 11.x BSON - // (mx-test-projects/test7-app: IdxProbe). - attrs := bson.A{int32(2)} - for _, ia := range idx.Attributes { - attrs = append(attrs, serializeIndexAttribute(ia)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(idx.ID))}, - {Key: "$Type", Value: "DomainModels$EntityIndex"}, - {Key: "Attributes", Value: attrs}, - {Key: "GUID", Value: idToBsonBinary(string(idx.ID))}, - {Key: "IncludeInOffline", Value: false}, - } -} - -// serializeIndexAttribute emits the Studio-Pro 11.x index-segment shape: -// Ascending(bool)+Type("Normal")+AttributePointer, plus an all-zero -// AssociationPointer for an attribute-based segment. This replaces the stale -// "SortOrder" string the writer previously emitted — the legacy parser already -// reads Ascending (with a SortOrder fallback), so writer and parser are now -// aligned, and the output matches what Studio Pro produces. -func serializeIndexAttribute(ia *domainmodel.IndexAttribute) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ia.ID))}, - {Key: "$Type", Value: "DomainModels$IndexedAttribute"}, - {Key: "AttributePointer", Value: idToBsonBinary(string(ia.AttributeID))}, // BSON Binary like $ID - {Key: "AssociationPointer", Value: idToBsonBinary(zeroGUID)}, // zero GUID: attribute-based segment - {Key: "Ascending", Value: ia.Ascending}, - {Key: "Type", Value: "Normal"}, - } -} - -func serializeValidationRule(vr *domainmodel.ValidationRule, moduleName string, entity *domainmodel.Entity) bson.D { - // Look up attribute name from the entity's attributes using AttributeID - // The Attribute field uses BY_NAME_REFERENCE, so it must be a qualified name STRING - // Format: "ModuleName.EntityName.AttributeName" - // - // NOTE: AttributeID can be either: - // 1. A UUID (when entity was just created) - compare with attr.ID - // 2. A qualified name string (when entity was read from disk) - extract attr name and compare - attributeQualifiedName := "" - attrIDStr := string(vr.AttributeID) - - // Check if AttributeID is already a qualified name (contains dots) - if strings.Contains(attrIDStr, ".") { - // It's already a qualified name - use it directly - attributeQualifiedName = attrIDStr - } else { - // It's a UUID - look up the attribute name - for _, attr := range entity.Attributes { - if attr.ID == vr.AttributeID { - attributeQualifiedName = fmt.Sprintf("%s.%s.%s", moduleName, entity.Name, attr.Name) - break - } - } - } - - // Use bson.D (ordered document) to match Studio Pro's field order: - // $ID, $Type, Attribute, Message, RuleInfo - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(vr.ID))}, - {Key: "$Type", Value: "DomainModels$ValidationRule"}, - {Key: "Attribute", Value: attributeQualifiedName}, // BY_NAME_REFERENCE: qualified name STRING - } - - // Message comes before RuleInfo in Studio Pro's format - if vr.ErrorMessage != nil && len(vr.ErrorMessage.Translations) > 0 { - doc = append(doc, bson.E{Key: "Message", Value: serializeText(vr.ErrorMessage)}) - } - - // RuleInfo comes last - doc = append(doc, bson.E{Key: "RuleInfo", Value: serializeRuleInfo(vr)}) - - return doc -} - -// serializeRuleInfo returns the RuleInfo child for a rule type, or nil for a -// type this writer cannot reproduce. -// -// A nil return is a REFUSAL, not a default. The previous code fell back to -// RequiredRuleInfo for anything it did not recognise, which made an entity -// rewrite a silent downgrade: a RegEx rule came back as Required, the pattern -// reference gone and the field merely mandatory, with mxbuild none the wiser -// because both are valid rules. Callers must check reproducibleRuleType first. -// It takes the whole rule rather than its type, because the type alone does not -// determine the document: a RegEx rule IS its reference and a Range rule IS its -// bounds. Writing a bare RuleInfo for either produces a rule Mendix accepts and -// that constrains nothing — the same silent downgrade wearing the right type -// name — so a rule whose payload did not survive the read is refused too. -// -// Keys are STORAGE names. The regex reference is "RegExIdentifier", not the SDK -// name "RegularExpression": writing the latter makes mxbuild report CE0135 "No -// regular expression specified" (measured on 11.13.0). -func serializeRuleInfo(vr *domainmodel.ValidationRule) bson.D { - if vr == nil { - return nil - } - // Use bson.D (ordered document) - Studio Pro uses $ID first, then $Type - switch vr.Type { - case "Required", "": - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$RequiredRuleInfo"}, - } - case "Unique": - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$UniqueRuleInfo"}, - } - - case "RegEx": - info, ok := vr.Rule.(*domainmodel.RegexValidationRuleInfo) - if !ok || info.RegularExpressionQualifiedName == "" { - return nil - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$RegExRuleInfo"}, - {Key: "RegExIdentifier", Value: info.RegularExpressionQualifiedName}, - } - - case "Range": - info, ok := vr.Rule.(*domainmodel.RangeValidationRuleInfo) - if !ok { - return nil - } - typeOfRange, ok := rangeKindFor(info) - if !ok { - return nil - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$RangeRuleInfo"}, - {Key: "TypeOfRange", Value: typeOfRange}, - {Key: "UseMinValue", Value: info.UseMinValue}, - {Key: "UseMaxValue", Value: info.UseMaxValue}, - } - if info.MinValue != nil { - doc = append(doc, bson.E{Key: "MinValue", Value: *info.MinValue}) - } - if info.MaxValue != nil { - doc = append(doc, bson.E{Key: "MaxValue", Value: *info.MaxValue}) - } - // A bound may point at another attribute instead of a literal. MDL - // cannot author that, but a stored rule must survive the rewrite. - if info.MinAttributeQualifiedName != "" { - doc = append(doc, bson.E{Key: "MinAttribute", Value: info.MinAttributeQualifiedName}) - } - if info.MaxAttributeQualifiedName != "" { - doc = append(doc, bson.E{Key: "MaxAttribute", Value: info.MaxAttributeQualifiedName}) - } - return doc - - default: - // MaxLength, EqualsTo — no model payload type, so a rewrite would lose - // them. Refuse instead. - return nil - } -} - -// rangeKindFor derives the TypeOfRange enum from which bounds are in use. -// Mendix has exactly three values and no strict inequality, so a range using -// neither bound has no representation and is refused. -func rangeKindFor(info *domainmodel.RangeValidationRuleInfo) (string, bool) { - switch { - case info.UseMinValue && info.UseMaxValue: - return string(metamodel.DomainModelsTypeOfRangeBetween), true - case info.UseMinValue: - return string(metamodel.DomainModelsTypeOfRangeGreaterThanOrEqualTo), true - case info.UseMaxValue: - return string(metamodel.DomainModelsTypeOfRangeSmallerThanOrEqualTo), true - default: - return "", false - } -} - -// reproducibleRule reports whether this writer can serialize a validation rule. -func reproducibleRule(vr *domainmodel.ValidationRule) bool { - return serializeRuleInfo(vr) != nil -} - -// validationRulesAreReproducible returns the first rule type this writer cannot -// serialize, so the caller can refuse the write instead of downgrading it. -func validationRulesAreReproducible(e *domainmodel.Entity) (string, bool) { - for _, vr := range e.ValidationRules { - if !reproducibleRule(vr) { - return vr.Type, false - } - } - return "", true -} - -func serializeText(text *model.Text) bson.D { - // Translations as Items array with version prefix 3 - // Use bson.D for ordered documents to match Studio Pro format - items := bson.A{int32(3)} - // Sort language keys for deterministic output - langs := make([]string, 0, len(text.Translations)) - for lang := range text.Translations { - langs = append(langs, lang) - } - sort.Strings(langs) - for _, lang := range langs { - value := text.Translations[lang] - items = append(items, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: lang}, - {Key: "Text", Value: value}, - }) - } - - // Studio Pro order: $ID, $Type, Items - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(text.ID))}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: items}, - } -} - -// serializeDomainModelAnnotation writes one canvas note in the shape Studio Pro -// stores it — pinned against the annotation a blank 11.13.0 app ships with in -// MyFirstModule. -// -// The position is the string "x;y", NOT a sub-document: that is the same -// convention an entity's Location follows, and writing a sub-document is what -// the parser used to (wrongly) expect. ExportLevel is "Hidden" on every Studio -// Pro-authored annotation. -// -// There is no colour property. A domain model holds exactly four child -// collections — Annotations, Associations, CrossAssociations and Entities — so -// the "coloured section box" a modeller sees is this element, drawn in Studio -// Pro's own styling, and nothing about that styling is stored in the model. -func serializeDomainModelAnnotation(a *domainmodel.Annotation) bson.D { - id := string(a.ID) - if id == "" { - id = GenerateID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DomainModels$Annotation"}, - {Key: "Caption", Value: a.Caption}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Location", Value: fmt.Sprintf("%d;%d", a.Location.X, a.Location.Y)}, - {Key: "Width", Value: int32(a.Width)}, - } -} diff --git a/sdk/mpr/writer_domainmodel_test.go b/sdk/mpr/writer_domainmodel_test.go deleted file mode 100644 index dac37dbba6..0000000000 --- a/sdk/mpr/writer_domainmodel_test.go +++ /dev/null @@ -1,309 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================= -// Issue #50: CrossAssociation must NOT include ParentConnection/ChildConnection -// ============================================================================= - -// TestSerializeCrossAssociation_NoConnectionFields verifies that -// serializeCrossAssociation does NOT emit ParentConnection or ChildConnection. -// These properties only exist on DomainModels$Association, not on -// DomainModels$CrossAssociation. Writing them causes Studio Pro to crash with -// System.InvalidOperationException: Sequence contains no matching element. -func TestSerializeCrossAssociation_NoConnectionFields(t *testing.T) { - ca := &domainmodel.CrossModuleAssociation{ - Name: "Child_Parent", - ParentID: "parent-entity-id", - ChildRef: "OtherModule.Parent", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - } - ca.ID = "test-cross-assoc-id" - - result := dToM(serializeCrossAssociation(ca)) - - // Must NOT contain these fields - for key := range result { - if key == "ParentConnection" { - t.Error("serializeCrossAssociation must NOT include ParentConnection (only valid for Association)") - } - if key == "ChildConnection" { - t.Error("serializeCrossAssociation must NOT include ChildConnection (only valid for Association)") - } - } - - // Must contain all expected fields (exhaustive structural contract) - expectedKeys := []string{"$ID", "$Type", "Name", "Child", "ParentPointer", "Type", "Owner", - "Documentation", "ExportLevel", "GUID", "StorageFormat", "Source", "DeleteBehavior"} - for _, key := range expectedKeys { - if _, ok := result[key]; !ok { - t.Errorf("serializeCrossAssociation missing expected field %q", key) - } - } - - // $Type must be CrossAssociation - if got := result["$Type"]; got != "DomainModels$CrossAssociation" { - t.Errorf("$Type = %q, want %q", got, "DomainModels$CrossAssociation") - } -} - -// TestSerializeODataRemoteEntitySource_HasKeyAndMappedValues verifies that -// external entities serialized via serializeEntity produce: -// - Rest$ODataKey with Parts (fixes CE6010 "Key cannot be empty") -// - Rest$ODataMappedValue on each attribute (fixes CE6612 "attribute not supported") -// -// Regression guard for the bugs that caused 51+ Studio Pro errors when opening -// a project with external entities created by `CREATE EXTERNAL ENTITIES FROM`. -func TestSerializeODataRemoteEntitySource_HasKeyAndMappedValues(t *testing.T) { - entity := &domainmodel.Entity{ - Name: "Airlines", - Source: "Rest$ODataRemoteEntitySource", - RemoteServiceName: "TripPinTest.TripPinRW", - RemoteEntityName: "Airline", - RemoteEntitySet: "Airlines", - Persistable: true, - Creatable: true, - Countable: true, - SkipSupported: true, - TopSupported: true, - RemoteKeyParts: []*domainmodel.RemoteKeyPart{ - { - Name: "AirlineCode", - RemoteName: "AirlineCode", - RemoteType: "Edm.String", - Type: &domainmodel.StringAttributeType{Length: 100}, - }, - }, - Attributes: []*domainmodel.Attribute{ - { - BaseElement: model.BaseElement{ID: "attr-airlinecode"}, - Name: "AirlineCode", - RemoteName: "AirlineCode", - RemoteType: "Edm.String", - Filterable: true, - Sortable: true, - Creatable: true, - Type: &domainmodel.StringAttributeType{Length: 100}, - }, - { - BaseElement: model.BaseElement{ID: "attr-name"}, - Name: "Name", - RemoteName: "Name", - RemoteType: "Edm.String", - Filterable: true, - Sortable: true, - Type: &domainmodel.StringAttributeType{Length: 0}, - }, - }, - } - entity.ID = "entity-test-id" - - doc := serializeEntity(entity, "TripPinTest", nil) - - // Marshal to BSON map for inspection - raw, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("Marshal failed: %v", err) - } - var m map[string]any - if err := bson.Unmarshal(raw, &m); err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - - // Source must be Rest$ODataRemoteEntitySource - sourceRaw, ok := m["Source"] - if !ok { - t.Fatal("Source field missing from entity BSON") - } - source, ok := sourceRaw.(map[string]any) - if !ok { - t.Fatalf("Source: expected map, got %T", sourceRaw) - } - if got := source["$Type"]; got != "Rest$ODataRemoteEntitySource" { - t.Errorf("Source.$Type = %v, want Rest$ODataRemoteEntitySource", got) - } - - // CE6010: Key must be present with Rest$ODataKey type - keyRaw, ok := source["Key"] - if !ok { - t.Fatal("CE6010: Source.Key missing — Studio Pro reports 'Key cannot be empty'") - } - key, ok := keyRaw.(map[string]any) - if !ok { - t.Fatalf("Source.Key: expected map, got %T", keyRaw) - } - if got := key["$Type"]; got != "Rest$ODataKey" { - t.Errorf("Source.Key.$Type = %v, want Rest$ODataKey", got) - } - if key["Parts"] == nil { - t.Error("Source.Key.Parts is nil") - } - - // CE6612: Each attribute Value must be Rest$ODataMappedValue - attrItems := extractBsonArray(m["Attributes"]) - if len(attrItems) == 0 { - t.Fatal("Attributes array is empty") - } - for i, item := range attrItems { - attrMap, ok := item.(map[string]any) - if !ok { - continue - } - valueMap, ok := attrMap["Value"].(map[string]any) - if !ok { - t.Errorf("Attribute[%d].Value: expected map, got %T", i, attrMap["Value"]) - continue - } - if vType := valueMap["$Type"]; vType != "Rest$ODataMappedValue" { - t.Errorf("CE6612: Attribute[%d].Value.$Type = %v, want Rest$ODataMappedValue", i, vType) - } - } -} - -// TestSerializeAssociation_HasConnectionFields verifies that the regular -// serializeAssociation DOES include ParentConnection and ChildConnection -// (to ensure we didn't accidentally remove them from the wrong function). -func TestSerializeAssociation_HasConnectionFields(t *testing.T) { - a := &domainmodel.Association{ - Name: "Child_Parent", - ParentID: "parent-entity-id", - ChildID: "child-entity-id", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - } - a.ID = "test-assoc-id" - - result := dToM(serializeAssociation(a)) - - hasParentConn := false - hasChildConn := false - for key := range result { - if key == "ParentConnection" { - hasParentConn = true - } - if key == "ChildConnection" { - hasChildConn = true - } - } - - if !hasParentConn { - t.Error("serializeAssociation must include ParentConnection") - } - if !hasChildConn { - t.Error("serializeAssociation must include ChildConnection") - } -} - -// upstream #872: the legacy writer hardcoded the association's line anchors, so -// running any association write on the legacy engine destroyed whatever the -// developer had dragged the connector to in Studio Pro — exactly as the modelsdk -// engine did. Both engines share the semantic model, so both had to change; a -// fix in one is invisible to a user on the other (`--engine`/`MXCLI_ENGINE`). -func TestSerializeAssociation_PreservesConnectionPoints(t *testing.T) { - base := func() *domainmodel.Association { - a := &domainmodel.Association{ - Name: "Child_Parent", - ParentID: "parent-entity-id", - ChildID: "child-entity-id", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - } - a.ID = "test-assoc-id" - return a - } - - // Nothing stored → mxcli's defaults, so a brand-new association still gets a - // sensible connector rather than one pinned to the box's top-left corner. - plain := dToM(serializeAssociation(base())) - if got := plain["ParentConnection"]; got != domainmodel.DefaultParentConnection { - t.Errorf("ParentConnection = %v, want the default %q", got, domainmodel.DefaultParentConnection) - } - if got := plain["ChildConnection"]; got != domainmodel.DefaultChildConnection { - t.Errorf("ChildConnection = %v, want the default %q", got, domainmodel.DefaultChildConnection) - } - - // A read anchor goes back verbatim. {0,0} is included deliberately: it is a - // real anchor (top-left) and must not be mistaken for "unset". - tuned := base() - tuned.ParentConnection = &model.Point{X: 50, Y: 100} - tuned.ChildConnection = &model.Point{X: 0, Y: 0} - got := dToM(serializeAssociation(tuned)) - if got["ParentConnection"] != "50;100" { - t.Errorf("ParentConnection = %v, want \"50;100\" — a hand-tuned anchor was reset", got["ParentConnection"]) - } - if got["ChildConnection"] != "0;0" { - t.Errorf("ChildConnection = %v, want \"0;0\" — the zero point is a value, not an absence", got["ChildConnection"]) - } -} - -// TestSerializeAssociation_OqlViewSource pins the one field that makes an -// association to a VIEW ENTITY legal. Measured on Mendix 11.13.0: without it -// mxbuild reports CE6771 "It is not possible to create associations to/from -// View Entities" AND CE6770 on the view entity; adding exactly this three-key -// subdocument takes the same project to 0 errors. -func TestSerializeAssociation_OqlViewSource(t *testing.T) { - a := &domainmodel.Association{ - Name: "MeterRef", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - StorageFormat: domainmodel.StorageFormatColumn, - Source: domainmodel.OqlViewAssociationSource, - ViewSourceReference: "MeterRef", - } - got := dToM(serializeAssociation(a)) - m, ok := got["Source"].(bson.M) - if !ok { - t.Fatalf("Source is %T, want a subdocument", got["Source"]) - } - if m["$Type"] != domainmodel.OqlViewAssociationSource { - t.Errorf("$Type = %v", m["$Type"]) - } - if m["Reference"] != "MeterRef" { - t.Errorf("Reference = %v, want the OQL select alias", m["Reference"]) - } - // Three keys and no more — the shape is pinned against a Studio Pro document. - if len(m) != 3 { - t.Errorf("Source has %d keys, want exactly $ID/$Type/Reference: %v", len(m), m) - } - - // Control: an ordinary association still writes a null Source. Widening the - // switch must not start decorating every association. - plain := dToM(serializeAssociation(&domainmodel.Association{Name: "Plain"})) - if plain["Source"] != nil { - t.Errorf("a plain association got Source = %v, want nil", plain["Source"]) - } -} - -// A view entity pointing at an entity in another module is stored as a -// CrossAssociation, which is the shape the defect was reported in — so the two -// serializers have to carry the field together. -func TestSerializeCrossAssociation_OqlViewSource(t *testing.T) { - ca := &domainmodel.CrossModuleAssociation{ - Name: "persistent_order", - ChildRef: "Mappings.Order", - Source: domainmodel.OqlViewAssociationSource, - ViewSourceReference: "persistent_order", - } - m, ok := dToM(serializeCrossAssociation(ca))["Source"].(bson.M) - if !ok { - t.Fatalf("Source is %T, want a subdocument", dToM(serializeCrossAssociation(ca))["Source"]) - } - if m["$Type"] != domainmodel.OqlViewAssociationSource || m["Reference"] != "persistent_order" { - t.Errorf("cross-association Source = %v", m) - } - - // Control. - plain := dToM(serializeCrossAssociation(&domainmodel.CrossModuleAssociation{Name: "Plain"})) - if plain["Source"] != nil { - t.Errorf("a plain cross-association got Source = %v, want nil", plain["Source"]) - } -} diff --git a/sdk/mpr/writer_elision_test.go b/sdk/mpr/writer_elision_test.go deleted file mode 100644 index 63e46c0232..0000000000 --- a/sdk/mpr/writer_elision_test.go +++ /dev/null @@ -1,206 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "bytes" - "os" - "path/filepath" - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// The legacy engine shares the modelsdk engine's no-op elision (ADR-0008 -// decision 1) rather than reimplementing it, because which engine ran is an -// --engine flag and must not be visible in a user's diff. -// -// These tests also pin the assumption the wiring rests on — that the unit id the -// writer is handed is the same form GetRawUnitBytes expects. That is invisible by -// inspection, and getting it wrong would fail silently as "unreadable, so write -// it": no elision, no error, no test failure. -// -// Two fixtures, deliberately: -// -// v1-project Mendix 9.24, MPR v1 — unit contents live in SQLite, a -// different branch of updateUnit from everything else. It has -// no microflows, so it carries the elision half only. -// expr-checker Mendix 11.6, MPR v2 — has microflows with a StableId, so it -// carries the identity half. -// -// Neither test may skip. A skipped test reports success and proves nothing, -// which is how #808 stayed green while broken. - -func copyProject(t *testing.T, srcDir, mprName string) string { - t.Helper() - dst := t.TempDir() - if err := os.CopyFS(dst, os.DirFS(srcDir)); err != nil { - t.Fatalf("copy %s: %v", srcDir, err) - } - return filepath.Join(dst, mprName) -} - -// aUnit returns the lowest-id unit of the given type, so the choice is stable -// across runs. An empty typeName accepts any unit. -func aUnit(t *testing.T, r *Reader, typeName string) (model.ID, []byte) { - t.Helper() - units, err := r.ListUnits() - if err != nil { - t.Fatalf("ListUnits: %v", err) - } - var bestID model.ID - var bestRaw []byte - for _, u := range units { - if typeName != "" && u.Type != typeName { - continue - } - raw, err := r.GetRawUnitBytes(u.ID) - if err != nil || len(raw) == 0 { - continue - } - if bestID == "" || u.ID < bestID { - bestID, bestRaw = u.ID, append([]byte(nil), raw...) - } - } - if bestID == "" { - t.Fatalf("fixture has no readable unit of type %q — this test cannot prove anything", typeName) - } - return bestID, bestRaw -} - -// reordered returns the same document with its top-level fields in reverse -// order: byte-different, canonically identical. It stands in for a rebuild -// without having to renumber a graph of element IDs by hand. -func reordered(t *testing.T, raw []byte) []byte { - t.Helper() - var d bson.D - if err := bson.Unmarshal(raw, &d); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(d) < 2 { - t.Fatalf("document has %d top-level fields; reordering cannot make it byte-different", len(d)) - } - rev := make(bson.D, 0, len(d)) - for i := len(d) - 1; i >= 0; i-- { - rev = append(rev, d[i]) - } - out, err := bson.Marshal(rev) - if err != nil { - t.Fatalf("marshal: %v", err) - } - if bytes.Equal(out, raw) { - t.Fatal("reordering produced identical bytes; this fixture cannot exercise canonical elision") - } - return out -} - -func stableIDBytes(raw []byte) []byte { - var d bson.M - if err := bson.Unmarshal(raw, &d); err != nil { - return nil - } - b, ok := d["StableId"].(primitive.Binary) - if !ok { - return nil - } - return b.Data -} - -// TestLegacyUpdateUnit_ElidesCanonicallyEqualWrite covers the MPR v1 branch: -// contents in SQLite rather than .mxunit files. -func TestLegacyUpdateUnit_ElidesCanonicallyEqualWrite(t *testing.T) { - w, err := NewWriter(copyProject(t, "testdata/v1-project", "App.mpr")) - if err != nil { - t.Fatalf("NewWriter: %v", err) - } - defer w.Close() - - id, before := aUnit(t, w.reader, "") - if err := w.UpdateRawUnit(string(id), reordered(t, before)); err != nil { - t.Fatalf("UpdateRawUnit: %v", err) - } - after, err := w.reader.GetRawUnitBytes(id) - if err != nil { - t.Fatalf("GetRawUnitBytes: %v", err) - } - if !bytes.Equal(before, after) { - t.Errorf("a canonically-equal write was not elided: %d bytes -> %d bytes", len(before), len(after)) - } -} - -// The control: with elision off the same write must land, otherwise the test -// above is passing for a reason that has nothing to do with elision. -func TestLegacyUpdateUnit_ControlWritesWhenElisionOff(t *testing.T) { - t.Setenv("MXCLI_ALWAYS_WRITE", "1") - w, err := NewWriter(copyProject(t, "testdata/v1-project", "App.mpr")) - if err != nil { - t.Fatalf("NewWriter: %v", err) - } - defer w.Close() - - id, before := aUnit(t, w.reader, "") - if err := w.UpdateRawUnit(string(id), reordered(t, before)); err != nil { - t.Fatalf("UpdateRawUnit: %v", err) - } - after, err := w.reader.GetRawUnitBytes(id) - if err != nil { - t.Fatalf("GetRawUnitBytes: %v", err) - } - if bytes.Equal(before, after) { - t.Fatal("with elision disabled the write did not land — the elision test proves nothing") - } -} - -// TestLegacyUpdateUnit_StableIdOnlyDifferenceIsElided is the identity half, and -// the exact shape of re-running an unchanged microflow: the incoming document -// differs from storage only in a freshly minted StableId. The stored identity is -// carried in first, what remains compares equal, and nothing is written. -func TestLegacyUpdateUnit_StableIdOnlyDifferenceIsElided(t *testing.T) { - w, err := NewWriter(copyProject(t, "../../testdata/expr-checker", "minimal.mpr")) - if err != nil { - t.Fatalf("NewWriter: %v", err) - } - defer w.Close() - - id, before := aUnit(t, w.reader, "Microflows$Microflow") - original := stableIDBytes(before) - if len(original) != 16 { - t.Fatalf("fixture microflow %s has no 16-byte StableId; this test cannot prove preservation", id) - } - - var d bson.D - if err := bson.Unmarshal(before, &d); err != nil { - t.Fatalf("unmarshal: %v", err) - } - fresh := bytes.Repeat([]byte{0x5A}, 16) - replaced := false - for i := range d { - if d[i].Key == "StableId" { - d[i].Value = primitive.Binary{Subtype: 0x00, Data: fresh} - replaced = true - } - } - if !replaced { - t.Fatal("StableId not found as a top-level field") - } - mutated, err := bson.Marshal(d) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - if err := w.UpdateRawUnit(string(id), mutated); err != nil { - t.Fatalf("UpdateRawUnit: %v", err) - } - after, err := w.reader.GetRawUnitBytes(id) - if err != nil { - t.Fatalf("GetRawUnitBytes: %v", err) - } - if got := stableIDBytes(after); !bytes.Equal(got, original) { - t.Errorf("StableId = %x, want the stored %x", got, original) - } - if !bytes.Equal(before, after) { - t.Errorf("a write differing only in StableId should have been elided; stored bytes changed") - } -} diff --git a/sdk/mpr/writer_enumeration.go b/sdk/mpr/writer_enumeration.go deleted file mode 100644 index c03f861f2b..0000000000 --- a/sdk/mpr/writer_enumeration.go +++ /dev/null @@ -1,228 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "sort" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateEnumeration creates a new enumeration. -func (w *Writer) CreateEnumeration(enum *model.Enumeration) error { - if enum.ID == "" { - enum.ID = model.ID(generateUUID()) - } - enum.TypeName = "Enumerations$Enumeration" - - contents, err := w.serializeEnumeration(enum) - if err != nil { - return fmt.Errorf("failed to serialize enumeration: %w", err) - } - - return w.insertUnit(string(enum.ID), string(enum.ContainerID), "Documents", "Enumerations$Enumeration", contents) -} - -// UpdateEnumeration updates an existing enumeration. -func (w *Writer) UpdateEnumeration(enum *model.Enumeration) error { - contents, err := w.serializeEnumeration(enum) - if err != nil { - return fmt.Errorf("failed to serialize enumeration: %w", err) - } - - return w.updateUnit(string(enum.ID), contents) -} - -// MoveEnumeration moves an enumeration to a new container (module or folder). -// Only updates the ContainerID in the database, preserving all BSON content as-is. -func (w *Writer) MoveEnumeration(enum *model.Enumeration) error { - return w.moveUnitByID(string(enum.ID), string(enum.ContainerID)) -} - -// DeleteEnumeration deletes an enumeration. -func (w *Writer) DeleteEnumeration(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MoveConstant moves a constant to a new container (module or folder). -func (w *Writer) MoveConstant(constant *model.Constant) error { - return w.moveUnitByID(string(constant.ID), string(constant.ContainerID)) -} - -// CreateConstant creates a new constant. -func (w *Writer) CreateConstant(constant *model.Constant) error { - if constant.ID == "" { - constant.ID = model.ID(generateUUID()) - } - constant.TypeName = "Constants$Constant" - - contents, err := w.serializeConstant(constant) - if err != nil { - return fmt.Errorf("failed to serialize constant: %w", err) - } - - return w.insertUnit(string(constant.ID), string(constant.ContainerID), "Documents", "Constants$Constant", contents) -} - -// UpdateConstant updates an existing constant. -func (w *Writer) UpdateConstant(constant *model.Constant) error { - contents, err := w.serializeConstant(constant) - if err != nil { - return fmt.Errorf("failed to serialize constant: %w", err) - } - - return w.updateUnit(string(constant.ID), contents) -} - -// DeleteConstant deletes a constant. -func (w *Writer) DeleteConstant(id model.ID) error { - return w.deleteUnit(string(id)) -} -func (w *Writer) serializeEnumeration(enum *model.Enumeration) ([]byte, error) { - values := bson.A{int32(3)} // Version prefix - for _, v := range enum.Values { - valueID := string(v.ID) - if valueID == "" { - valueID = generateUUID() - } - captionID := generateUUID() - - // Build translation items (sorted for deterministic output) - translationItems := bson.A{int32(3)} - if v.Caption != nil { - langs := make([]string, 0, len(v.Caption.Translations)) - for lang := range v.Caption.Translations { - langs = append(langs, lang) - } - sort.Strings(langs) - for _, langCode := range langs { - translationItems = append(translationItems, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: langCode}, - {Key: "Text", Value: v.Caption.Translations[langCode]}, - }) - } - } - - // Use bson.D (ordered) so $Type appears first — Mendix requires this for correct parsing - valueDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(valueID)}, - {Key: "$Type", Value: "Enumerations$EnumerationValue"}, - {Key: "Name", Value: v.Name}, - {Key: "Caption", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(captionID)}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: translationItems}, - }}, - {Key: "Image", Value: ""}, - {Key: "RemoteValue", Value: nil}, - } - values = append(values, valueDoc) - } - - // Use bson.D (ordered) so $Type appears early — Mendix requires this for correct parsing - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(enum.ID))}, - {Key: "$Type", Value: "Enumerations$Enumeration"}, - {Key: "Name", Value: enum.Name}, - {Key: "Documentation", Value: enum.Documentation}, - {Key: "Excluded", Value: enum.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "RemoteSource", Value: nil}, - {Key: "Values", Value: values}, - } - return marshalUnitIDFirst(doc) -} - -func (w *Writer) serializeConstant(constant *model.Constant) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(constant.ID))}, - {Key: "$Type", Value: "Constants$Constant"}, - {Key: "Name", Value: constant.Name}, - {Key: "Documentation", Value: constant.Documentation}, - {Key: "Type", Value: serializeConstantDataType(constant.Type)}, - {Key: "DefaultValue", Value: constant.DefaultValue}, - {Key: "ExposedToClient", Value: constant.ExposedToClient}, - {Key: "Excluded", Value: constant.Excluded}, - {Key: "ExportLevel", Value: constant.ExportLevel}, - } - return marshalUnitIDFirst(doc) -} - -// serializeConstantDataType converts a ConstantDataType to BSON. -func serializeConstantDataType(dt model.ConstantDataType) bson.D { - typeID := idToBsonBinary(GenerateID()) - - switch dt.Kind { - case "String": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$StringType"}, - } - case "Integer": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$IntegerType"}, - } - case "Long": - // Mendix uses IntegerType for both Integer and Long in BSON storage. - // DataTypes$LongType does not exist in the metamodel type cache. - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$IntegerType"}, - } - case "Decimal": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$DecimalType"}, - } - case "Boolean": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$BooleanType"}, - } - case "DateTime", "Date": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$DateTimeType"}, - } - case "Binary": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$BinaryType"}, - } - case "Float": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$FloatType"}, - } - case "Enumeration": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$EnumerationType"}, - {Key: "Enumeration", Value: dt.EnumRef}, - } - case "Object": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: dt.EntityRef}, - } - case "List": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$ListType"}, - {Key: "Entity", Value: dt.EntityRef}, - } - default: - // Default to string type - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$StringType"}, - } - } -} diff --git a/sdk/mpr/writer_export_mapping.go b/sdk/mpr/writer_export_mapping.go deleted file mode 100644 index a1e7d767f3..0000000000 --- a/sdk/mpr/writer_export_mapping.go +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateExportMapping creates a new export mapping document. -func (w *Writer) CreateExportMapping(em *model.ExportMapping) error { - if em.ID == "" { - em.ID = model.ID(generateUUID()) - } - em.TypeName = "ExportMappings$ExportMapping" - - contents, err := w.serializeExportMapping(em) - if err != nil { - return fmt.Errorf("failed to serialize export mapping: %w", err) - } - - return w.insertUnit(string(em.ID), string(em.ContainerID), "Documents", "ExportMappings$ExportMapping", contents) -} - -// UpdateExportMapping updates an existing export mapping document. -func (w *Writer) UpdateExportMapping(em *model.ExportMapping) error { - contents, err := w.serializeExportMapping(em) - if err != nil { - return fmt.Errorf("failed to serialize export mapping: %w", err) - } - return w.updateUnit(string(em.ID), contents) -} - -// DeleteExportMapping deletes an export mapping document. -func (w *Writer) DeleteExportMapping(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MoveExportMapping moves an export mapping to a new container. -func (w *Writer) MoveExportMapping(em *model.ExportMapping) error { - return w.moveUnitByID(string(em.ID), string(em.ContainerID)) -} - -func (w *Writer) serializeExportMapping(em *model.ExportMapping) ([]byte, error) { - elements := bson.A{int32(2)} - for _, elem := range em.Elements { - elements = append(elements, serializeExportMappingElement(elem, "(Object)")) - } - - exportLevel := em.ExportLevel - if exportLevel == "" { - exportLevel = "Hidden" - } - - nullValueOption := em.NullValueOption - if nullValueOption == "" { - nullValueOption = "LeaveOutElement" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(em.ID))}, - {Key: "$Type", Value: "ExportMappings$ExportMapping"}, - {Key: "Name", Value: em.Name}, - {Key: "Documentation", Value: em.Documentation}, - {Key: "Excluded", Value: em.Excluded}, - {Key: "ExportLevel", Value: exportLevel}, - {Key: "JsonStructure", Value: em.JsonStructure}, - {Key: "XmlSchema", Value: em.XmlSchema}, - {Key: "MessageDefinition", Value: em.MessageDefinition}, - {Key: "NullValueOption", Value: nullValueOption}, - {Key: "Elements", Value: elements}, - // Required fields with defaults — verified against Studio Pro-created BSON - {Key: "PublicName", Value: ""}, // Studio Pro writes "" not the mapping name - {Key: "XsdRootElementName", Value: ""}, - {Key: "IsHeaderParameter", Value: false}, - {Key: "ParameterName", Value: ""}, - {Key: "OperationName", Value: ""}, - {Key: "ServiceName", Value: ""}, - {Key: "WsdlFile", Value: ""}, - {Key: "MappingSourceReference", Value: nil}, - } - // MessageDefinition2 is version-introduced (11.10+) and CARRIED, never - // invented: adding the key to a document written before then is the shape - // mxbuild tolerates and Studio Pro refuses to open. nil means absent, which - // is not the same as present-and-empty (ako/mxcli#279). - if em.MessageDefinition2 != nil { - doc = append(doc, bson.E{Key: "MessageDefinition2", Value: *em.MessageDefinition2}) - } - return marshalUnitIDFirst(doc) -} - -func serializeExportMappingElement(elem *model.ExportMappingElement, parentPath string) bson.D { - id := string(elem.ID) - if id == "" { - id = generateUUID() - } - - if isMappingObjectKind(elem.Kind) { - return serializeExportObjectElement(id, elem, parentPath) - } - return serializeExportValueElement(id, elem, parentPath) -} - -func serializeExportObjectElement(id string, elem *model.ExportMappingElement, parentPath string) bson.D { - // Use pre-computed JsonPath from the executor (which knows the JSON structure element types). - // Fall back to a simple parentPath + "|" + ExposedName only when JsonPath was not set. - jsonPath := elem.JsonPath - if jsonPath == "" { - if elem.ExposedName == "" { - jsonPath = parentPath - } else { - jsonPath = parentPath + "|" + elem.ExposedName - } - } - - children := bson.A{int32(2)} - for _, child := range elem.Children { - children = append(children, serializeExportMappingElement(child, jsonPath)) - } - - // IMPORTANT: The correct $Type is "ExportMappings$ObjectMappingElement" (no "Export" prefix in the element name). - // The generated metamodel (ExportMappingsExportObjectMappingElement) is misleading — Studio Pro will throw - // TypeCacheUnknownTypeException if you use "ExportMappings$ExportObjectMappingElement". - // Same convention as ImportMappings: element types do NOT repeat the namespace prefix. - objectHandling := elem.ObjectHandling - if objectHandling == "" { - objectHandling = "Parameter" - } - - maxOccurs := int32(elem.MaxOccurs) - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "ExportMappings$ObjectMappingElement"}, - {Key: "Entity", Value: elem.Entity}, - {Key: "ExposedName", Value: elem.ExposedName}, - {Key: "JsonPath", Value: jsonPath}, - {Key: "XmlPath", Value: elem.XmlPath}, - {Key: "ObjectHandling", Value: objectHandling}, - // Every export object element in the demo apps stores Error (537 of - // 537), and the handling values are not in the backup enum (#261). - {Key: "ObjectHandlingBackup", Value: "Error"}, - {Key: "ObjectHandlingBackupAllowOverride", Value: false}, - {Key: "Association", Value: elem.Association}, - {Key: "Children", Value: children}, - // A schema ROOT has MinOccurs 1; hardcoding 0 lost that (#279). - {Key: "MinOccurs", Value: int32(elem.MinOccurs)}, - {Key: "MaxOccurs", Value: maxOccurs}, - {Key: "Nillable", Value: true}, - {Key: "IsDefaultType", Value: false}, - {Key: "ElementType", Value: elementTypeForKind(elem.Kind)}, - {Key: "Documentation", Value: ""}, - {Key: "CustomHandlerCall", Value: nil}, - } -} - -func serializeExportValueElement(id string, elem *model.ExportMappingElement, parentPath string) bson.D { - dataType := serializeImportValueDataType(elem.DataType) // reuse — same DataTypes$* types - // Use pre-computed JsonPath when available, otherwise derive from parentPath. - jsonPath := elem.JsonPath - if jsonPath == "" { - jsonPath = parentPath + "|" + elem.ExposedName - } - - // IMPORTANT: "ExportMappings$ValueMappingElement" — no "Export" prefix. See comment in serializeExportObjectElement. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "ExportMappings$ValueMappingElement"}, - {Key: "Attribute", Value: elem.Attribute}, - {Key: "ExposedName", Value: elem.ExposedName}, - {Key: "JsonPath", Value: jsonPath}, - {Key: "XmlPath", Value: elem.XmlPath}, - {Key: "Type", Value: dataType}, - // A schema ROOT has MinOccurs 1; hardcoding 0 lost that (#279). - {Key: "MinOccurs", Value: int32(elem.MinOccurs)}, - // Mirror the bound schema element: Mendix cross-validates the two and - // reports CE5015 on any mismatch. Hardcoding 0 only worked while the - // JSON structure also wrote 0 for every element (#841). - {Key: "MaxOccurs", Value: int32(elem.MaxOccurs)}, - {Key: "Nillable", Value: true}, - // IsDefaultType is NOT written here: it belongs to the OBJECT element type - // only. The generated metamodel declares it on Import/ExportObjectMappingElement - // and on neither ValueMappingElement, and Studio Pro's own mappings in a blank - // app carry it on the object element alone. A property the type does not own is - // the shape mxbuild accepts and Studio Pro refuses to open. (issue #882) - {Key: "ElementType", Value: "Value"}, - {Key: "Documentation", Value: ""}, - {Key: "Converter", Value: elem.Converter}, - {Key: "FractionDigits", Value: int32(-1)}, - {Key: "TotalDigits", Value: int32(-1)}, - // Mirrors the bound schema element, like MaxOccurs: Studio Pro stores 0 for - // a string element and -1 for a numeric one (#277). - {Key: "MaxLength", Value: int32(elem.MaxLength)}, - // Studio Pro writes IsKey on export value elements too; omitting it was a - // divergence from the import twin (#277). - {Key: "IsKey", Value: elem.IsKey}, - {Key: "IsContent", Value: false}, - {Key: "IsXmlAttribute", Value: false}, - {Key: "OriginalValue", Value: elem.OriginalValue}, - {Key: "XmlPrimitiveType", Value: xmlPrimitiveTypeName(elem.DataType)}, - } -} diff --git a/sdk/mpr/writer_export_mapping_properties_test.go b/sdk/mpr/writer_export_mapping_properties_test.go deleted file mode 100644 index 3885032594..0000000000 --- a/sdk/mpr/writer_export_mapping_properties_test.go +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// The export writers hardcoded three properties the import twin already read -// off the element, so no export mapping mxcli wrote matched its Studio Pro -// original — which kept every one of them in #260's silent-loss set even once -// its source kind was authorable. -// -// Studio Pro's values, measured on FeedbackModule.EXM_PostFeedback and -// MxGenAIConnector.EM_CohereEmbed_Request (11.13): -// -// object root MinOccurs 1 hardcoded 0 (#279) -// value element MaxLength 0 / -1 hardcoded 0 (#277) -// value element IsKey false not written (#277) -// -// MaxLength is the one to watch: it is 0 for a STRING element and -1 for a -// numeric one, mirroring the bound schema element exactly as MaxOccurs does, so -// a single hardcoded value cannot be right for both. - -// assertVal compares a BSON field of any scalar type; the package's assertField -// only handles strings. -func assertVal(t *testing.T, m map[string]any, key string, want any) { - t.Helper() - got, ok := m[key] - if !ok { - t.Errorf("field %q: missing", key) - return - } - if got != want { - t.Errorf("field %q = %v (%T), want %v (%T)", key, got, got, want, want) - } -} - -func exportDoc(t *testing.T, em *model.ExportMapping) map[string]any { - t.Helper() - w := &Writer{} - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - return raw -} - -func TestExportMappingMirrorsSchemaFacets(t *testing.T) { - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "em-1", TypeName: "ExportMappings$ExportMapping"}, - Name: "EXM_Probe", - Elements: []*model.ExportMappingElement{{ - Kind: "Object", Entity: "M.E", ObjectHandling: "Parameter", - MinOccurs: 1, MaxOccurs: 1, JsonPath: "(Object)", - Children: []*model.ExportMappingElement{ - {Kind: "Value", Attribute: "M.E.Name", DataType: "String", - MinOccurs: 0, MaxOccurs: 1, MaxLength: 0, JsonPath: "(Object)|name"}, - {Kind: "Value", Attribute: "M.E.Width", DataType: "Integer", - MinOccurs: 0, MaxOccurs: 1, MaxLength: -1, JsonPath: "(Object)|width"}, - }, - }}, - } - - root, ok := extractBsonArray(exportDoc(t, em)["Elements"])[0].(map[string]any) - if !ok { - t.Fatal("root element is not a document") - } - // A schema root has MinOccurs 1; the writer hardcoded 0. - assertVal(t, root, "MinOccurs", int32(1)) - - children := extractBsonArray(root["Children"]) - if len(children) != 2 { - t.Fatalf("got %d children, want 2", len(children)) - } - str, _ := children[0].(map[string]any) - num, _ := children[1].(map[string]any) - - // Both were hardcoded to 0, which is right for the string and wrong for the - // number — the reason a single constant cannot work here. - assertVal(t, str, "MaxLength", int32(0)) - assertVal(t, num, "MaxLength", int32(-1)) - - // Studio Pro writes IsKey on export value elements; it was not written. - assertVal(t, str, "IsKey", false) - assertVal(t, num, "IsKey", false) -} - -// MessageDefinition2 is version-introduced (11.10+). It is CARRIED, never -// invented: writing it onto an older document is the shape mxbuild tolerates -// and Studio Pro refuses to open. nil means absent, which is not the same as -// present-and-empty — hence the pointer. -func TestExportMappingCarriesMessageDefinition2(t *testing.T) { - base := func() *model.ExportMapping { - return &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "em-2", TypeName: "ExportMappings$ExportMapping"}, - Name: "EXM_Probe", - } - } - - absent := exportDoc(t, base()) - if _, ok := absent["MessageDefinition2"]; ok { - t.Error("nil carried the key through — a pre-11.10 document must not gain it") - } - - em := base() - empty := "" - em.MessageDefinition2 = &empty - present := exportDoc(t, em) - v, ok := present["MessageDefinition2"] - if !ok { - t.Fatal("present-and-empty was dropped — that is what a blank 11.13 app stores") - } - if v != "" { - t.Errorf("MessageDefinition2 = %v, want the empty string", v) - } -} diff --git a/sdk/mpr/writer_export_mapping_test.go b/sdk/mpr/writer_export_mapping_test.go deleted file mode 100644 index a7e88a4f42..0000000000 --- a/sdk/mpr/writer_export_mapping_test.go +++ /dev/null @@ -1,201 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeExportMapping_TypeNames verifies the critical $Type naming convention. -// The correct names are "ExportMappings$ObjectMappingElement" and -// "ExportMappings$ValueMappingElement" — the namespace prefix is never repeated. -// Using "ExportMappings$ExportObjectMappingElement" causes TypeCacheUnknownTypeException. -func TestSerializeExportMapping_TypeNames(t *testing.T) { - w := &Writer{} - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ - ID: "test-em-id", - TypeName: "ExportMappings$ExportMapping", - }, - ContainerID: "test-module-id", - Name: "ExportPetRequest", - ExportLevel: "Hidden", - NullValueOption: "LeaveOutElement", - Elements: []*model.ExportMappingElement{ - { - BaseElement: model.BaseElement{ID: "obj-elem-id"}, - Kind: "Object", - ExposedName: "Root", - Entity: "MyModule.Pet", - JsonPath: "(Object)", - Children: []*model.ExportMappingElement{ - { - BaseElement: model.BaseElement{ID: "val-id-elem"}, - Kind: "Value", - ExposedName: "id", - Attribute: "MyModule.Pet.Id", - DataType: "Integer", - JsonPath: "(Object)|id", - }, - { - BaseElement: model.BaseElement{ID: "val-name-elem"}, - Kind: "Value", - ExposedName: "name", - Attribute: "MyModule.Pet.Name", - DataType: "String", - JsonPath: "(Object)|name", - }, - }, - }, - }, - } - - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "$Type", "ExportMappings$ExportMapping") - assertField(t, raw, "Name", "ExportPetRequest") - assertField(t, raw, "NullValueOption", "LeaveOutElement") - - elems := extractBsonArray(raw["Elements"]) - if len(elems) != 1 { - t.Fatalf("Elements: expected 1, got %d", len(elems)) - } - - objElem, ok := elems[0].(map[string]any) - if !ok { - t.Fatalf("Elements[0]: expected map, got %T", elems[0]) - } - // CRITICAL: must NOT be "ExportMappings$ExportObjectMappingElement" - assertField(t, objElem, "$Type", "ExportMappings$ObjectMappingElement") - assertField(t, objElem, "Entity", "MyModule.Pet") - assertField(t, objElem, "ObjectHandling", "Parameter") - // The backup is a member of {Create, Error, Ignore} — "Parameter" never was. - // The writer used to echo the HANDLING into it, which is how an off-enum - // value reached disk; Studio Pro writes "Error" on every export element, - // since an export has nothing to find (#261). - assertField(t, objElem, "ObjectHandlingBackup", "Error") - - children := extractBsonArray(objElem["Children"]) - if len(children) != 2 { - t.Fatalf("Children: expected 2, got %d", len(children)) - } - - valElem, ok := children[0].(map[string]any) - if !ok { - t.Fatalf("Children[0]: expected map, got %T", children[0]) - } - // CRITICAL: must NOT be "ExportMappings$ExportValueMappingElement" - assertField(t, valElem, "$Type", "ExportMappings$ValueMappingElement") -} - -func TestSerializeExportMapping_DefaultNullValueOption(t *testing.T) { - w := &Writer{} - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "test-em-default-null"}, - ContainerID: "test-module-id", - Name: "DefaultNullMapping", - // NullValueOption intentionally omitted — should default to "LeaveOutElement" - } - - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "NullValueOption", "LeaveOutElement") - assertField(t, raw, "ExportLevel", "Hidden") -} - -func TestSerializeExportMapping_RequiredFields(t *testing.T) { - w := &Writer{} - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "test-em-required"}, - ContainerID: "test-module-id", - Name: "MinimalExportMapping", - } - - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - // These fields must be present — verified against Studio Pro-created BSON. - for _, field := range []string{ - "PublicName", - "XsdRootElementName", - "IsHeaderParameter", - "ParameterName", - "OperationName", - "ServiceName", - "WsdlFile", - } { - if _, ok := raw[field]; !ok { - t.Errorf("missing required field: %s", field) - } - } -} - -func TestSerializeExportMapping_WithJsonStructureRef(t *testing.T) { - w := &Writer{} - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "test-em-js-ref"}, - ContainerID: "test-module-id", - Name: "ExportWithSchema", - JsonStructure: "MyModule.PetJsonStructure", - } - - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "JsonStructure", "MyModule.PetJsonStructure") -} - -func TestSerializeExportMapping_NullValueOptionSendAsNil(t *testing.T) { - w := &Writer{} - em := &model.ExportMapping{ - BaseElement: model.BaseElement{ID: "test-em-send-nil"}, - ContainerID: "test-module-id", - Name: "SendNilMapping", - NullValueOption: "SendAsNil", - } - - data, err := w.serializeExportMapping(em) - if err != nil { - t.Fatalf("serializeExportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "NullValueOption", "SendAsNil") -} diff --git a/sdk/mpr/writer_external_action_returntype_test.go b/sdk/mpr/writer_external_action_returntype_test.go deleted file mode 100644 index d924790787..0000000000 --- a/sdk/mpr/writer_external_action_returntype_test.go +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import "testing" - -// TestSerializeExternalActionReturnType covers the DataTypes$ element written -// into CallExternalAction.VariableDataType. -// -// Object and List were unreachable before: the resolver mapped only EDM -// primitives and returned "" for anything else, so an action returning an entity -// (or a collection of them) got NO VariableDataType at all, and Mendix reported -// CE7269 "The return type for remote action '' has changed" -// (mendixlabs/mxcli#1020). Both carry an Entity — a DataTypes$ObjectType without -// one is as unaligned as no type at all. -func TestSerializeExternalActionReturnType(t *testing.T) { - tests := []struct { - name string - kind string - entity string - wantType string - wantEntity string // "" = the key must be absent - }{ - {name: "object return", kind: "Object", entity: "Trippin.Airport", - wantType: "DataTypes$ObjectType", wantEntity: "Trippin.Airport"}, - {name: "list return", kind: "List", entity: "Trippin.Person", - wantType: "DataTypes$ListType", wantEntity: "Trippin.Person"}, - {name: "boolean", kind: "Boolean", wantType: "DataTypes$BooleanType"}, - {name: "string", kind: "String", wantType: "DataTypes$StringType"}, - {name: "integer", kind: "Integer", wantType: "DataTypes$IntegerType"}, - {name: "long is an integer", kind: "Long", wantType: "DataTypes$IntegerType"}, - {name: "decimal", kind: "Decimal", wantType: "DataTypes$DecimalType"}, - {name: "datetime", kind: "DateTime", wantType: "DataTypes$DateTimeType"}, - {name: "binary", kind: "Binary", wantType: "DataTypes$BinaryType"}, - {name: "void", kind: "Void", wantType: "DataTypes$VoidType"}, - {name: "empty is void", kind: "", wantType: "DataTypes$VoidType"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - doc := serializeExternalActionReturnType(tt.kind, tt.entity) - - var gotType, gotEntity string - var hasEntity, hasID bool - for _, e := range doc { - switch e.Key { - case "$Type": - gotType, _ = e.Value.(string) - case "Entity": - gotEntity, _ = e.Value.(string) - hasEntity = true - case "$ID": - hasID = true - } - } - - if gotType != tt.wantType { - t.Errorf("$Type = %q, want %q", gotType, tt.wantType) - } - if !hasID { - t.Error("every DataTypes$ element needs its own $ID") - } - if tt.wantEntity == "" { - if hasEntity { - t.Errorf("a primitive return must not carry an Entity (got %q)", gotEntity) - } - return - } - if gotEntity != tt.wantEntity { - t.Errorf("Entity = %q, want %q", gotEntity, tt.wantEntity) - } - }) - } -} diff --git a/sdk/mpr/writer_formattinginfo_test.go b/sdk/mpr/writer_formattinginfo_test.go deleted file mode 100644 index 21fe56bbd5..0000000000 --- a/sdk/mpr/writer_formattinginfo_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeClientTemplateParameter_FormattingInfoNoTimeFormat -// guards against the CE0463 "widget definition changed" regression we -// hit on Mendix 11.9: Forms$FormattingInfo's reflection schema does not -// declare a TimeFormat property, but our writer was emitting -// `"TimeFormat": "HoursMinutes"` for every parameter. Studio Pro then -// treated every pluggable widget that embeds FormattingInfo (gallery, -// datagrid2 captions, dynamictext) as having a stale widget definition, -// which cascaded into CE3637 on master-detail pages. -func TestSerializeClientTemplateParameter_FormattingInfoNoTimeFormat(t *testing.T) { - param := &pages.ClientTemplateParameter{Expression: "'hello'"} - doc := serializeClientTemplateParameter(param) - - fi, ok := getBSONField(doc, "FormattingInfo").(bson.D) - if !ok { - t.Fatalf("FormattingInfo is not bson.D, got %T", getBSONField(doc, "FormattingInfo")) - } - for _, e := range fi { - if e.Key == "TimeFormat" { - t.Fatalf("FormattingInfo unexpectedly contains TimeFormat=%q — schema only declares CustomDateFormat/DateFormat/DecimalPrecision/EnumFormat/GroupDigits", e.Value) - } - } - // Sanity: the five schema-declared keys are present. - for _, want := range []string{"CustomDateFormat", "DateFormat", "DecimalPrecision", "EnumFormat", "GroupDigits"} { - if getBSONField(fi, want) == nil { - t.Errorf("FormattingInfo missing required field %q", want) - } - } -} diff --git a/sdk/mpr/writer_id_order_test.go b/sdk/mpr/writer_id_order_test.go deleted file mode 100644 index a24967c5d0..0000000000 --- a/sdk/mpr/writer_id_order_test.go +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "testing" - - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/mdl/settingsoverlay" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - - "go.mongodb.org/mongo-driver/bson" -) - -// Mendix 11.12+ rejects any storage object whose first BSON property is not -// "$ID" (System.InvalidOperationException: "Expected '$ID' as the first -// property of a storage object, but got '...'"). The writer historically built -// many objects as bson.M (a Go map), which bson.Marshal serializes in random -// key order, so "$ID" only landed first by luck. These tests pin the invariant: -// every serialized storage object must have "$ID" first, with no duplicate keys. - -// validateStorageOrder walks a decoded BSON value (bson.D / bson.A, nesting -// preserved by unmarshalling into bson.D) and asserts that every document which -// looks like a storage object ($ID and/or $Type present) lists "$ID" first, and -// that no document contains duplicate keys (the hazard when a literal default is -// later "overwritten" via append). -func validateStorageOrder(t *testing.T, label string, v any) { - t.Helper() - switch d := v.(type) { - case bson.D: - seen := make(map[string]bool, len(d)) - hasID, hasType := false, false - for i, e := range d { - if seen[e.Key] { - t.Errorf("%s: duplicate key %q in storage object", label, e.Key) - } - seen[e.Key] = true - switch e.Key { - case "$ID": - hasID = true - if i != 0 { - t.Errorf("%s: $ID is at index %d, must be the first property", label, i) - } - case "$Type": - hasType = true - } - validateStorageOrder(t, label+"."+e.Key, e.Value) - } - if (hasID || hasType) && (len(d) == 0 || d[0].Key != "$ID") { - t.Errorf("%s: storage object does not start with $ID", label) - } - case bson.A: - for i, e := range d { - validateStorageOrder(t, fmt.Sprintf("%s[%d]", label, i), e) - } - } -} - -// marshalAndValidate round-trips a value through BSON bytes (the real on-the-wire -// form) and validates ordering of the decoded document. -func marshalAndValidate(t *testing.T, label string, v any) { - t.Helper() - raw, err := bson.Marshal(v) - if err != nil { - t.Fatalf("%s: marshal failed: %v", label, err) - } - var decoded bson.D - if err := bson.Unmarshal(raw, &decoded); err != nil { - t.Fatalf("%s: unmarshal failed: %v", label, err) - } - validateStorageOrder(t, label, decoded) -} - -func TestStorageObjects_IDIsFirstProperty(t *testing.T) { - w := &Writer{} - - // Module-tree serializers: created on every project/module create and - // shared by both engines — the universal offenders in the 11.12 nightly. - t.Run("Module", func(t *testing.T) { - mod := &model.Module{Name: "MyModule"} - mod.ID = "mod-1" - b, err := w.serializeModule(mod) - if err != nil { - t.Fatal(err) - } - var d bson.D - if err := bson.Unmarshal(b, &d); err != nil { - t.Fatal(err) - } - validateStorageOrder(t, "Module", d) - }) - - t.Run("Folder", func(t *testing.T) { - folder := &model.Folder{Name: "Pages"} - folder.ID = "f-1" - b, err := w.serializeFolder(folder) - if err != nil { - t.Fatal(err) - } - var d bson.D - if err := bson.Unmarshal(b, &d); err != nil { - t.Fatal(err) - } - validateStorageOrder(t, "Folder", d) - }) - - t.Run("ModuleSecurity", func(t *testing.T) { - b, err := w.serializeModuleSecurity("ms-1") - if err != nil { - t.Fatal(err) - } - var d bson.D - if err := bson.Unmarshal(b, &d); err != nil { - t.Fatal(err) - } - validateStorageOrder(t, "ModuleSecurity", d) - }) - - t.Run("ModuleSettings", func(t *testing.T) { - b, err := w.serializeModuleSettings("set-1") - if err != nil { - t.Fatal(err) - } - var d bson.D - if err := bson.Unmarshal(b, &d); err != nil { - t.Fatal(err) - } - validateStorageOrder(t, "ModuleSettings", d) - }) - - // Domain-model associations (lossy CREATE OR MODIFY had wiped these before). - t.Run("Association", func(t *testing.T) { - a := &domainmodel.Association{ - Name: "Order_Customer", - ParentID: "child-id", - ChildID: "parent-id", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - } - a.ID = "assoc-1" - marshalAndValidate(t, "Association", serializeAssociation(a)) - }) - - t.Run("CrossAssociation", func(t *testing.T) { - ca := &domainmodel.CrossModuleAssociation{ - Name: "Order_Customer", - ParentID: "child-id", - ChildRef: "Other.Customer", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - } - ca.ID = "xassoc-1" - marshalAndValidate(t, "CrossAssociation", serializeCrossAssociation(ca)) - }) - - // Entity + Attribute: these were bson.D but in Studio Pro field order - // (Name-first, $ID mid-document), which 11.12 rejects. validateStorageOrder - // recurses, so this also covers the nested AccessRule and MemberAccess (which - // were $Type-first). Regression guard for the 11.12 nightly `got 'Name'`. - t.Run("Entity", func(t *testing.T) { - attr := &domainmodel.Attribute{Name: "Amount", Type: &domainmodel.IntegerAttributeType{}} - attr.ID = "attr-1" - ma := &domainmodel.MemberAccess{AttributeName: "Amount", AccessRights: domainmodel.MemberAccessRightsReadWrite} - ma.ID = "ma-1" - ar := &domainmodel.AccessRule{ - ModuleRoleNames: []string{"Mod.User"}, - AllowRead: true, - MemberAccesses: []*domainmodel.MemberAccess{ma}, - } - ar.ID = "ar-1" - e := &domainmodel.Entity{ - Name: "Order", - Persistable: true, - Attributes: []*domainmodel.Attribute{attr}, - AccessRules: []*domainmodel.AccessRule{ar}, - } - e.ID = "ent-1" - marshalAndValidate(t, "Entity", serializeEntity(e, "Mod", nil)) - }) - - t.Run("Attribute", func(t *testing.T) { - attr := &domainmodel.Attribute{Name: "Amount", Type: &domainmodel.IntegerAttributeType{}} - attr.ID = "attr-2" - marshalAndValidate(t, "Attribute", serializeAttribute(attr, false)) - }) - - // Business-event tree: $ID was added dynamically after a $Type-first literal. - t.Run("BusinessEventDefinition", func(t *testing.T) { - def := &model.BusinessEventDefinition{ - ServiceName: "Svc", - Channels: []*model.BusinessEventChannel{{ - ChannelName: "Ch", - Messages: []*model.BusinessEventMessage{{ - MessageName: "Msg", - Attributes: []*model.BusinessEventAttribute{ - {AttributeName: "A1", AttributeType: "String"}, - {AttributeName: "A2", AttributeType: "DateTime"}, - }, - }}, - }}, - } - marshalAndValidate(t, "BusinessEventDefinition", serializeBusinessEventDefinition(def)) - }) - - // Database-connector query tree: $ID added dynamically; nested DataType/SqlDataType. - t.Run("DBQuery", func(t *testing.T) { - q := &model.DatabaseQuery{ - Name: "Q1", - SQL: "SELECT 1", - TableMappings: []*model.DatabaseTableMapping{{ - Entity: "Mod.Ent", - TableName: "ent", - Columns: []*model.DatabaseColumnMapping{{Attribute: "Name", ColumnName: "name"}}, - }}, - Parameters: []*model.DatabaseQueryParameter{{ParameterName: "p1"}}, - } - marshalAndValidate(t, "DBQuery", serializeDBQuery(q, false)) - }) - - // Server configuration: $ID added dynamically; nested ConstantValue list. - t.Run("ServerConfiguration", func(t *testing.T) { - cfg := &model.ServerConfiguration{ - Name: "Default", - ConstantValues: []*model.ConstantValue{ - {ConstantId: "Mod.C1", Value: "v"}, - }, - } - marshalAndValidate(t, "ServerConfiguration", - bsonutil.OrderStorageValue(settingsoverlay.ServerConfiguration(cfg, nil, nil))) - }) -} diff --git a/sdk/mpr/writer_imagecollection.go b/sdk/mpr/writer_imagecollection.go deleted file mode 100644 index 54787dbf37..0000000000 --- a/sdk/mpr/writer_imagecollection.go +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// CreateImageCollection creates a new empty image collection unit in the MPR. -func (w *Writer) CreateImageCollection(ic *ImageCollection) error { - if ic.ID == "" { - ic.ID = model.ID(generateUUID()) - } - if ic.ExportLevel == "" { - ic.ExportLevel = "Hidden" - } - - contents, err := serializeImageCollection(ic) - if err != nil { - return err - } - - return w.insertUnit(string(ic.ID), string(ic.ContainerID), - "Documents", "Images$ImageCollection", contents) -} - -// UpdateImageCollection re-serializes an existing image collection in-place, preserving its ID. -func (w *Writer) UpdateImageCollection(ic *ImageCollection) error { - contents, err := serializeImageCollection(ic) - if err != nil { - return err - } - return w.updateUnit(string(ic.ID), contents) -} - -// DeleteImageCollection deletes an image collection by ID. -func (w *Writer) DeleteImageCollection(id string) error { - return w.deleteUnit(id) -} - -func serializeImageCollection(ic *ImageCollection) ([]byte, error) { - // Images array always starts with the array marker int32(3) - images := bson.A{int32(3)} - for i := range ic.Images { - img := &ic.Images[i] - if img.ID == "" { - img.ID = model.ID(generateUUID()) - } - images = append(images, bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(img.ID))}, - {Key: "$Type", Value: "Images$Image"}, - {Key: "Image", Value: primitive.Binary{Subtype: 0, Data: img.Data}}, - {Key: "ImageFormat", Value: img.Format}, - {Key: "Name", Value: img.Name}, - }) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ic.ID))}, - {Key: "$Type", Value: "Images$ImageCollection"}, - {Key: "Documentation", Value: ic.Documentation}, - {Key: "Excluded", Value: false}, - {Key: "ExportLevel", Value: ic.ExportLevel}, - {Key: "Images", Value: images}, - {Key: "Name", Value: ic.Name}, - } - - return marshalUnitIDFirst(doc) -} diff --git a/sdk/mpr/writer_imagecollection_test.go b/sdk/mpr/writer_imagecollection_test.go deleted file mode 100644 index d7e0442ed8..0000000000 --- a/sdk/mpr/writer_imagecollection_test.go +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeImageCollection_EmptyImages(t *testing.T) { - ic := &ImageCollection{ - BaseElement: model.BaseElement{ID: "ic-test-1"}, - ContainerID: model.ID("module-id-1"), - Name: "TestIcons", - ExportLevel: "Hidden", - } - - data, err := serializeImageCollection(ic) - if err != nil { - t.Fatalf("serializeImageCollection: %v", err) - } - - var doc bson.D - if err := bson.Unmarshal(data, &doc); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - // Verify $Type - if got := getBSONField(doc, "$Type"); got != "Images$ImageCollection" { - t.Errorf("$Type = %q, want %q", got, "Images$ImageCollection") - } - - // Verify Name - if got := getBSONField(doc, "Name"); got != "TestIcons" { - t.Errorf("Name = %q, want %q", got, "TestIcons") - } - - // Verify ExportLevel - if got := getBSONField(doc, "ExportLevel"); got != "Hidden" { - t.Errorf("ExportLevel = %q, want %q", got, "Hidden") - } - - // Verify Excluded - if got := getBSONField(doc, "Excluded"); got != false { - t.Errorf("Excluded = %v, want false", got) - } - - // Images array must start with marker int32(3) - assertArrayMarker(t, doc, "Images", int32(3)) - - // Images should be empty (marker only) - arr := getBSONField(doc, "Images").(bson.A) - if len(arr) != 1 { - t.Errorf("Images length = %d, want 1 (marker only)", len(arr)) - } -} - -func TestSerializeImageCollection_DefaultExportLevel(t *testing.T) { - ic := &ImageCollection{ - BaseElement: model.BaseElement{ID: "ic-test-2"}, - ContainerID: model.ID("module-id-1"), - Name: "Icons", - // ExportLevel intentionally omitted to test CreateImageCollection default - } - - // CreateImageCollection sets default, but serializeImageCollection doesn't — - // test that empty ExportLevel serializes as empty string (caller's responsibility) - data, err := serializeImageCollection(ic) - if err != nil { - t.Fatalf("serializeImageCollection: %v", err) - } - - var doc bson.D - if err := bson.Unmarshal(data, &doc); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertArrayMarker(t, doc, "Images", int32(3)) -} diff --git a/sdk/mpr/writer_import_mapping.go b/sdk/mpr/writer_import_mapping.go deleted file mode 100644 index 362275ac6c..0000000000 --- a/sdk/mpr/writer_import_mapping.go +++ /dev/null @@ -1,290 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateImportMapping creates a new import mapping document. -func (w *Writer) CreateImportMapping(im *model.ImportMapping) error { - if im.ID == "" { - im.ID = model.ID(generateUUID()) - } - im.TypeName = "ImportMappings$ImportMapping" - - contents, err := w.serializeImportMapping(im) - if err != nil { - return fmt.Errorf("failed to serialize import mapping: %w", err) - } - - return w.insertUnit(string(im.ID), string(im.ContainerID), "Documents", "ImportMappings$ImportMapping", contents) -} - -// UpdateImportMapping updates an existing import mapping document. -func (w *Writer) UpdateImportMapping(im *model.ImportMapping) error { - contents, err := w.serializeImportMapping(im) - if err != nil { - return fmt.Errorf("failed to serialize import mapping: %w", err) - } - return w.updateUnit(string(im.ID), contents) -} - -// DeleteImportMapping deletes an import mapping document. -func (w *Writer) DeleteImportMapping(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MoveImportMapping moves an import mapping to a new container. -func (w *Writer) MoveImportMapping(im *model.ImportMapping) error { - return w.moveUnitByID(string(im.ID), string(im.ContainerID)) -} - -func (w *Writer) serializeImportMapping(im *model.ImportMapping) ([]byte, error) { - elements := bson.A{int32(2)} - for _, elem := range im.Elements { - elements = append(elements, serializeImportMappingElement(elem, "(Object)")) - } - - exportLevel := im.ExportLevel - if exportLevel == "" { - exportLevel = "Hidden" - } - - // ParameterType is a required sub-document even when unused: an - // unparameterised mapping stores the DataTypes$UnknownType marker, and one - // declaring an input object stores a DataTypes$ObjectType naming it (#265). - // Without the property Studio Pro fails to render the schema source and - // mapping elements correctly. - parameterType := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$UnknownType"}, - } - if im.ParameterEntity != "" { - parameterType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: im.ParameterEntity}, - } - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(im.ID))}, - {Key: "$Type", Value: "ImportMappings$ImportMapping"}, - {Key: "Name", Value: im.Name}, - {Key: "Documentation", Value: im.Documentation}, - {Key: "Excluded", Value: im.Excluded}, - {Key: "ExportLevel", Value: exportLevel}, - {Key: "JsonStructure", Value: im.JsonStructure}, - {Key: "XmlSchema", Value: im.XmlSchema}, - {Key: "MessageDefinition", Value: im.MessageDefinition}, - {Key: "Elements", Value: elements}, - // Required fields with defaults — verified against Studio Pro-created BSON - {Key: "UseSubtransactionsForMicroflows", Value: false}, - {Key: "PublicName", Value: ""}, // Studio Pro writes "" not the mapping name - {Key: "XsdRootElementName", Value: ""}, - {Key: "MappingSourceReference", Value: nil}, - {Key: "ParameterType", Value: parameterType}, - {Key: "OperationName", Value: ""}, - {Key: "ServiceName", Value: ""}, - {Key: "WsdlFile", Value: ""}, - } - // MessageDefinition2 is version-introduced (11.10+) and CARRIED, never - // invented: adding the key to a document written before then is the shape - // mxbuild tolerates and Studio Pro refuses to open. nil means absent, which - // is not the same as present-and-empty (ako/mxcli#279). - if im.MessageDefinition2 != nil { - doc = append(doc, bson.E{Key: "MessageDefinition2", Value: *im.MessageDefinition2}) - } - return marshalUnitIDFirst(doc) -} - -func serializeImportMappingElement(elem *model.ImportMappingElement, parentPath string) bson.D { - id := string(elem.ID) - if id == "" { - id = generateUUID() - } - - if isMappingObjectKind(elem.Kind) { - return serializeImportObjectElement(id, elem, parentPath) - } - return serializeImportValueElement(id, elem, parentPath) -} - -func serializeImportObjectElement(id string, elem *model.ImportMappingElement, parentPath string) bson.D { - // Use pre-computed JsonPath from the executor when available. - // The executor aligns JsonPath with the JSON structure element paths. - jsonPath := elem.JsonPath - if jsonPath == "" { - if elem.ExposedName == "" { - jsonPath = parentPath - } else { - jsonPath = parentPath + "|" + elem.ExposedName - } - } - - children := bson.A{int32(2)} - for _, child := range elem.Children { - children = append(children, serializeImportMappingElement(child, jsonPath)) - } - - objectHandling := elem.ObjectHandling - if objectHandling == "" { - objectHandling = "Create" - } - // The backup takes {Create, Error, Ignore} only; copying the HANDLING into - // it wrote "Find"/"Custom", which occur in 0 of 1,261 real elements (#261). - objectHandlingBackup := "Create" - switch elem.ObjectHandlingBackup { - case "Create", "Error", "Ignore": - objectHandlingBackup = elem.ObjectHandlingBackup - } - if objectHandling == "FindOrCreate" { - objectHandling = "Find" - } - - // IMPORTANT: The correct $Type is "ImportMappings$ObjectMappingElement" (no "Import" prefix in the element name). - // The generated metamodel (ImportMappingsImportObjectMappingElement) is misleading — Studio Pro will throw - // TypeCacheUnknownTypeException if you use "ImportMappings$ImportObjectMappingElement". - // Rule: MappingElement $Type names do NOT repeat the namespace prefix (same for ExportMappings). - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "ImportMappings$ObjectMappingElement"}, - {Key: "Entity", Value: elem.Entity}, - {Key: "ExposedName", Value: elem.ExposedName}, - {Key: "JsonPath", Value: jsonPath}, - {Key: "XmlPath", Value: elem.XmlPath}, - {Key: "ObjectHandling", Value: objectHandling}, - {Key: "ObjectHandlingBackup", Value: objectHandlingBackup}, - {Key: "ObjectHandlingBackupAllowOverride", Value: elem.BackupAllowOverride}, - {Key: "Association", Value: elem.Association}, - {Key: "Children", Value: children}, - {Key: "MinOccurs", Value: int32(elem.MinOccurs)}, - {Key: "MaxOccurs", Value: int32(elem.MaxOccurs)}, - {Key: "Nillable", Value: elem.Nillable}, - {Key: "IsDefaultType", Value: false}, - {Key: "ElementType", Value: elementTypeForKind(elem.Kind)}, - {Key: "Documentation", Value: ""}, - {Key: "CustomHandlerCall", Value: nil}, - } -} - -func serializeImportValueElement(id string, elem *model.ImportMappingElement, parentPath string) bson.D { - dataType := serializeImportValueDataType(elem.DataType) - jsonPath := elem.JsonPath - if jsonPath == "" { - jsonPath = parentPath + "|" + elem.ExposedName - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "ImportMappings$ValueMappingElement"}, - {Key: "Attribute", Value: elem.Attribute}, - {Key: "ExposedName", Value: elem.ExposedName}, - {Key: "JsonPath", Value: jsonPath}, - {Key: "XmlPath", Value: elem.XmlPath}, - {Key: "IsKey", Value: elem.IsKey}, - {Key: "Type", Value: dataType}, - {Key: "MinOccurs", Value: int32(elem.MinOccurs)}, - {Key: "MaxOccurs", Value: int32(elem.MaxOccurs)}, - {Key: "Nillable", Value: elem.Nillable}, - // IsDefaultType is NOT written here: it belongs to the OBJECT element type - // only. The generated metamodel declares it on Import/ExportObjectMappingElement - // and on neither ValueMappingElement, and Studio Pro's own mappings in a blank - // app carry it on the object element alone. A property the type does not own is - // the shape mxbuild accepts and Studio Pro refuses to open. (issue #882) - {Key: "ElementType", Value: "Value"}, - {Key: "Documentation", Value: ""}, - {Key: "Converter", Value: elem.Converter}, - {Key: "FractionDigits", Value: int32(elem.FractionDigits)}, - {Key: "TotalDigits", Value: int32(elem.TotalDigits)}, - {Key: "MaxLength", Value: int32(elem.MaxLength)}, - {Key: "IsContent", Value: false}, - {Key: "IsXmlAttribute", Value: false}, - {Key: "OriginalValue", Value: elem.OriginalValue}, - {Key: "XmlPrimitiveType", Value: xmlPrimitiveTypeName(elem.DataType)}, - } -} - -func xmlPrimitiveTypeName(dataType string) string { - switch dataType { - case "Integer", "Long": - return "Integer" - case "Decimal": - return "Decimal" - case "Boolean": - return "Boolean" - case "DateTime": - return "DateTime" - default: - return "String" - } -} - -// elementTypeForKind maps model Kind to BSON ElementType. -func elementTypeForKind(kind string) string { - if kind == "Array" { - return "Array" - } - if kind == "Wrapper" { - // An array of PRIMITIVES: one entity per item, with the value on an - // attribute (#268). - return "Wrapper" - } - if kind == "Value" { - return "Value" - } - return "Object" -} - -func serializeImportValueDataType(typeName string) bson.D { - typeID := idToBsonBinary(GenerateID()) - switch typeName { - case "Integer", "Long": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$IntegerType"}, - } - case "Decimal": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$DecimalType"}, - } - case "Boolean": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$BooleanType"}, - } - case "DateTime": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$DateTimeType"}, - } - case "Binary": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$BinaryType"}, - } - default: // String - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$StringType"}, - } - } -} - -// isMappingObjectKind — see the note in mdl/backend/modelsdk/mapping_write.go. -// Duplicated rather than shared: the two engines' writers are independent by -// design (ADR-0002/0004). -func isMappingObjectKind(kind string) bool { - switch kind { - case "Object", "Array", "Wrapper": - return true - default: - return false - } -} diff --git a/sdk/mpr/writer_import_mapping_test.go b/sdk/mpr/writer_import_mapping_test.go deleted file mode 100644 index a571763910..0000000000 --- a/sdk/mpr/writer_import_mapping_test.go +++ /dev/null @@ -1,255 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeImportMapping_TypeNames verifies the critical $Type naming convention. -// The correct names are "ImportMappings$ObjectMappingElement" and -// "ImportMappings$ValueMappingElement" — the namespace prefix is never repeated in the -// element name. Using the wrong name causes TypeCacheUnknownTypeException in Studio Pro. -func TestSerializeImportMapping_TypeNames(t *testing.T) { - w := &Writer{} - im := &model.ImportMapping{ - BaseElement: model.BaseElement{ - ID: "test-im-id", - TypeName: "ImportMappings$ImportMapping", - }, - ContainerID: "test-module-id", - Name: "ImportPetResponse", - ExportLevel: "Hidden", - Elements: []*model.ImportMappingElement{ - { - BaseElement: model.BaseElement{ID: "obj-elem-id"}, - Kind: "Object", - ExposedName: "", - Entity: "MyModule.Pet", - Children: []*model.ImportMappingElement{ - { - BaseElement: model.BaseElement{ID: "val-id-elem"}, - Kind: "Value", - ExposedName: "id", - Attribute: "MyModule.Pet.Id", - DataType: "Integer", - IsKey: true, - }, - { - BaseElement: model.BaseElement{ID: "val-name-elem"}, - Kind: "Value", - ExposedName: "name", - Attribute: "MyModule.Pet.Name", - DataType: "String", - }, - }, - }, - }, - } - - data, err := w.serializeImportMapping(im) - if err != nil { - t.Fatalf("serializeImportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "$Type", "ImportMappings$ImportMapping") - assertField(t, raw, "Name", "ImportPetResponse") - - elems := extractBsonArray(raw["Elements"]) - if len(elems) != 1 { - t.Fatalf("Elements: expected 1, got %d", len(elems)) - } - - objElem, ok := elems[0].(map[string]any) - if !ok { - t.Fatalf("Elements[0]: expected map, got %T", elems[0]) - } - // CRITICAL: must NOT be "ImportMappings$ImportObjectMappingElement" - assertField(t, objElem, "$Type", "ImportMappings$ObjectMappingElement") - assertField(t, objElem, "Entity", "MyModule.Pet") - assertField(t, objElem, "ObjectHandling", "Create") - - children := extractBsonArray(objElem["Children"]) - if len(children) != 2 { - t.Fatalf("Children: expected 2, got %d", len(children)) - } - - valElem, ok := children[0].(map[string]any) - if !ok { - t.Fatalf("Children[0]: expected map, got %T", children[0]) - } - // CRITICAL: must NOT be "ImportMappings$ImportValueMappingElement" - assertField(t, valElem, "$Type", "ImportMappings$ValueMappingElement") - assertField(t, valElem, "Attribute", "MyModule.Pet.Id") - - // IsKey must be true on the first (key) element - if valElem["IsKey"] != true { - t.Errorf("IsKey: expected true, got %v", valElem["IsKey"]) - } -} - -func TestSerializeImportMapping_RequiredFields(t *testing.T) { - w := &Writer{} - im := &model.ImportMapping{ - BaseElement: model.BaseElement{ID: "test-im-required"}, - ContainerID: "test-module-id", - Name: "MinimalMapping", - } - - data, err := w.serializeImportMapping(im) - if err != nil { - t.Fatalf("serializeImportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - // These fields must be present with defaults — verified against Studio Pro-created BSON. - // Missing fields cause CE errors when opening in Studio Pro. - for _, field := range []string{ - "UseSubtransactionsForMicroflows", - "PublicName", - "XsdRootElementName", - "OperationName", - "ServiceName", - "WsdlFile", - } { - if _, ok := raw[field]; !ok { - t.Errorf("missing required field: %s", field) - } - } - - // ParameterType must be a sub-document with $Type DataTypes$UnknownType - pt, ok := raw["ParameterType"].(map[string]any) - if !ok { - t.Fatalf("ParameterType: expected map, got %T", raw["ParameterType"]) - } - assertField(t, pt, "$Type", "DataTypes$UnknownType") -} - -func TestSerializeImportMapping_DefaultExportLevel(t *testing.T) { - w := &Writer{} - im := &model.ImportMapping{ - BaseElement: model.BaseElement{ID: "test-im-default-export"}, - ContainerID: "test-module-id", - Name: "DefaultExportLevelMapping", - // ExportLevel intentionally omitted - } - - data, err := w.serializeImportMapping(im) - if err != nil { - t.Fatalf("serializeImportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "ExportLevel", "Hidden") -} - -func TestSerializeImportMapping_WithJsonStructureRef(t *testing.T) { - w := &Writer{} - im := &model.ImportMapping{ - BaseElement: model.BaseElement{ID: "test-im-js-ref"}, - ContainerID: "test-module-id", - Name: "MappingWithSchema", - JsonStructure: "MyModule.PetJsonStructure", - } - - data, err := w.serializeImportMapping(im) - if err != nil { - t.Fatalf("serializeImportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - assertField(t, raw, "JsonStructure", "MyModule.PetJsonStructure") -} - -func TestSerializeImportMapping_FindOrCreateUsesFindWithCreateBackup(t *testing.T) { - w := &Writer{} - im := &model.ImportMapping{ - BaseElement: model.BaseElement{ID: "test-im-upsert"}, - ContainerID: "test-module-id", - Name: "UpsertMapping", - Elements: []*model.ImportMappingElement{ - { - BaseElement: model.BaseElement{ID: "root-id"}, - Kind: "Object", - Entity: "MyModule.Pet", - ObjectHandling: "FindOrCreate", - }, - }, - } - - data, err := w.serializeImportMapping(im) - if err != nil { - t.Fatalf("serializeImportMapping: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal: %v", err) - } - - elems := extractBsonArray(raw["Elements"]) - if len(elems) != 1 { - t.Fatalf("Elements: expected 1, got %d", len(elems)) - } - - objElem, ok := elems[0].(map[string]any) - if !ok { - t.Fatalf("Elements[0]: expected map, got %T", elems[0]) - } - assertField(t, objElem, "ObjectHandling", "Find") - assertField(t, objElem, "ObjectHandlingBackup", "Create") -} - -// TestSerializeImportValueDataType_AllTypes verifies that all supported data types -// map to the correct DataTypes$* BSON $Type values. -func TestSerializeImportValueDataType_AllTypes(t *testing.T) { - tests := []struct { - input string - wantType string - }{ - {"String", "DataTypes$StringType"}, - {"Integer", "DataTypes$IntegerType"}, - {"Long", "DataTypes$IntegerType"}, - {"Decimal", "DataTypes$DecimalType"}, - {"Boolean", "DataTypes$BooleanType"}, - {"DateTime", "DataTypes$DateTimeType"}, - {"Binary", "DataTypes$BinaryType"}, - {"", "DataTypes$StringType"}, // unknown falls back to String - } - - for _, tc := range tests { - result := serializeImportValueDataType(tc.input) - - var found string - for _, kv := range result { - if kv.Key == "$Type" { - found, _ = kv.Value.(string) - break - } - } - if found != tc.wantType { - t.Errorf("serializeImportValueDataType(%q): $Type = %q, want %q", - tc.input, found, tc.wantType) - } - } -} diff --git a/sdk/mpr/writer_javaactions.go b/sdk/mpr/writer_javaactions.go deleted file mode 100644 index 98d5c9a147..0000000000 --- a/sdk/mpr/writer_javaactions.go +++ /dev/null @@ -1,433 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - Java action writer support. -package mpr - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// emptyBinary is the BSON subtype-0 binary Studio Pro writes for an unset -// toolbox bitmap. It must never be BSON null: the MicroflowActionInfo *Data -// properties are mandatory binaries and a null crashes Studio Pro's UnitWriter -// on re-serialize (issue #656). -func bsonBinary(b []byte) primitive.Binary { - if b == nil { - b = []byte{} - } - return primitive.Binary{Subtype: 0x00, Data: b} -} - -// microflowActionInfoBSON serializes a MicroflowActionInfo in the current -// metamodel shape: $Type CodeActions$MicroflowActionInfo, with all four icon/ -// image bitmaps always present as (possibly empty) binaries and never null. -// The legacy JavaActions$ alias and the removed `Icon` key are not emitted. -func microflowActionInfoBSON(mai *javaactions.MicroflowActionInfo) bson.D { - maiID := string(mai.ID) - if maiID == "" { - maiID = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(maiID)}, - {Key: "$Type", Value: "CodeActions$MicroflowActionInfo"}, - {Key: "Caption", Value: mai.Caption}, - {Key: "Category", Value: mai.Category}, - {Key: "IconData", Value: bsonBinary(mai.IconData)}, - {Key: "IconDataDark", Value: bsonBinary(mai.IconDataDark)}, - {Key: "ImageData", Value: bsonBinary(mai.ImageData)}, - {Key: "ImageDataDark", Value: bsonBinary(mai.ImageDataDark)}, - } -} - -// CreateJavaAction creates a new Java action in the MPR. -func (w *Writer) CreateJavaAction(ja *javaactions.JavaAction) error { - if ja.ID == "" { - ja.ID = model.ID(generateUUID()) - } - ja.TypeName = "JavaActions$JavaAction" - - contents, err := w.serializeJavaAction(ja) - if err != nil { - return fmt.Errorf("failed to serialize java action: %w", err) - } - - return w.insertUnit(string(ja.ID), string(ja.ContainerID), "Documents", "JavaActions$JavaAction", contents) -} - -// UpdateJavaAction updates an existing Java action. -func (w *Writer) UpdateJavaAction(ja *javaactions.JavaAction) error { - contents, err := w.serializeJavaAction(ja) - if err != nil { - return fmt.Errorf("failed to serialize java action: %w", err) - } - - return w.updateUnit(string(ja.ID), contents) -} - -// DeleteJavaAction deletes a Java action by ID. -func (w *Writer) DeleteJavaAction(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// WriteJavaSourceFile writes the Java source file to the javasource directory. -// moduleName is the lowercase module name (e.g., "mymodule") -// actionName is the action name (e.g., "ValidateEmail") -// javaCode is the executeAction() body code -// params is the list of parameters with their types -// returnType is the return type (can be nil for void) -func (w *Writer) WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string) error { - // Get project root directory (parent of .mpr file) - projectRoot := filepath.Dir(w.reader.path) - - // Build the javasource path - moduleNameLower := strings.ToLower(moduleName) - javaDir := filepath.Join(projectRoot, "javasource", moduleNameLower, "actions") - - // Create directory if it doesn't exist - if err := os.MkdirAll(javaDir, 0755); err != nil { - return fmt.Errorf("failed to create javasource directory: %w", err) - } - - // Generate Java source (shared with the modelsdk engine) - source := javaactions.GenerateSource(moduleName, actionName, javaCode, params, returnType, extraImports, extraCode) - - // Write the file, unless it already says exactly this. The counters feed the - // executor's "Modified" vs "Unchanged" reporting: a code action's body lives - // here rather than in its unit, so judging the statement on unit writes alone - // would call a body-only edit unchanged. - filePath := filepath.Join(javaDir, actionName+".java") - w.writesOffered++ - changed, err := javaactions.WriteSourceIfChanged(filePath, source) - if err != nil { - return fmt.Errorf("failed to write Java source file: %w", err) - } - if changed { - w.writesLanded++ - } - - return nil -} - -// DeleteJavaSourceFile removes the Java source file for a dropped Java action. -func (w *Writer) DeleteJavaSourceFile(moduleName, actionName string) error { - projectRoot := filepath.Dir(w.reader.path) - filePath := filepath.Join(projectRoot, "javasource", strings.ToLower(moduleName), "actions", actionName+".java") - if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to delete Java source file: %w", err) - } - return nil -} - -// RenameJavaSourceFile renames the Java source file when a Java action is renamed. -func (w *Writer) RenameJavaSourceFile(moduleName, oldName, newName string) error { - projectRoot := filepath.Dir(w.reader.path) - dir := filepath.Join(projectRoot, "javasource", strings.ToLower(moduleName), "actions") - oldPath := filepath.Join(dir, oldName+".java") - newPath := filepath.Join(dir, newName+".java") - if err := os.Rename(oldPath, newPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to rename Java source file: %w", err) - } - return nil -} - -// ReadJavaSourceFile reads the Java source file for a Java action. -func (w *Writer) ReadJavaSourceFile(moduleName, actionName string) (string, error) { - projectRoot := filepath.Dir(w.reader.path) - moduleNameLower := strings.ToLower(moduleName) - filePath := filepath.Join(projectRoot, "javasource", moduleNameLower, "actions", actionName+".java") - - content, err := os.ReadFile(filePath) - if err != nil { - return "", fmt.Errorf("failed to read Java source file: %w", err) - } - - return string(content), nil -} - -// serializeJavaAction serializes a Java action to BSON. -func (w *Writer) serializeJavaAction(ja *javaactions.JavaAction) ([]byte, error) { - // Build parameters array (storageListType: 2) - params := bson.A{int32(2)} // Array type marker for storageListType: 2 - for _, param := range ja.Parameters { - paramDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(param.ID))}, - {Key: "$Type", Value: param.TypeName}, - {Key: "Category", Value: param.Category}, - {Key: "Description", Value: param.Description}, - {Key: "IsRequired", Value: param.IsRequired}, - {Key: "Name", Value: param.Name}, - } - if param.ParameterType != nil { - paramDoc = append(paramDoc, bson.E{Key: "ParameterType", Value: serializeParameterType(param.ParameterType)}) - } - params = append(params, paramDoc) - } - - // Build type parameters array (storageListType: 2) - typeParams := bson.A{int32(2)} // Array type marker for storageListType: 2 - for _, tp := range ja.TypeParameters { - tpID := string(tp.ID) - if tpID == "" { - tpID = generateUUID() - } - typeParams = append(typeParams, bson.D{ - {Key: "$ID", Value: idToBsonBinary(tpID)}, - {Key: "$Type", Value: "CodeActions$TypeParameter"}, - {Key: "Name", Value: tp.Name}, - }) - } - - // Build MicroflowActionInfo - var maiValue any - if ja.MicroflowActionInfo != nil { - maiValue = microflowActionInfoBSON(ja.MicroflowActionInfo) - } - - // Build main document - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ja.ID))}, - {Key: "$Type", Value: "JavaActions$JavaAction"}, - {Key: "ActionDefaultReturnName", Value: stringOrDefault(ja.ActionDefaultReturnName, "")}, - {Key: "Documentation", Value: ja.Documentation}, - {Key: "Excluded", Value: ja.Excluded}, - {Key: "ExportLevel", Value: stringOrDefault(ja.ExportLevel, "Hidden")}, - {Key: "MicroflowActionInfo", Value: maiValue}, - {Key: "Name", Value: ja.Name}, - {Key: "Parameters", Value: params}, - {Key: "TypeParameters", Value: typeParams}, - } - - // Add return type - if ja.ReturnType != nil { - doc = append(doc, bson.E{Key: "JavaReturnType", Value: serializeReturnType(ja.ReturnType)}) - } else { - // Default to void type - doc = append(doc, bson.E{Key: "JavaReturnType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$VoidType"}, - }}) - } - - return marshalUnitIDFirst(doc) -} - -// serializeReturnType serializes a CodeActionReturnType to BSON. -func serializeReturnType(t javaactions.CodeActionReturnType) bson.D { - if t == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$VoidType"}, - } - } - - switch v := t.(type) { - case *javaactions.VoidType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$VoidType"}, - } - case *javaactions.BooleanType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$BooleanType"}, - } - case *javaactions.IntegerType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$IntegerType"}, - } - case *javaactions.LongType: - // Mendix uses IntegerType for 64-bit integers (Long in Java) - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$IntegerType"}, - } - case *javaactions.DecimalType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$DecimalType"}, - } - case *javaactions.StringType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$StringType"}, - } - case *javaactions.DateTimeType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$DateTimeType"}, - } - case *javaactions.EnumerationType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$EnumerationType"}, - {Key: "Enumeration", Value: v.Enumeration}, - } - case *javaactions.EntityType: - // Use ConcreteEntityType for return types - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ConcreteEntityType"}, - {Key: "Entity", Value: v.Entity}, - } - case *javaactions.ListType: - // ListType contains a Parameter which is a ConcreteEntityType - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ListType"}, - {Key: "Parameter", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$ConcreteEntityType"}, - {Key: "Entity", Value: v.Entity}, - }}, - } - case *javaactions.TypeParameter: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ParameterizedEntityType"}, - {Key: "TypeParameterPointer", Value: idToBsonBinary(string(v.TypeParameterID))}, - } - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$VoidType"}, - } - } -} - -// serializeParameterType serializes a CodeActionParameterType to BSON. -// Parameter types are wrapped in BasicParameterType with a nested Type property. -func serializeParameterType(t javaactions.CodeActionParameterType) bson.D { - if t == nil { - // Default to String type wrapped in BasicParameterType - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$BasicParameterType"}, - {Key: "Type", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$StringType"}, - }}, - } - } - - // Special case for StringTemplateParameterType - not wrapped in BasicParameterType - if v, ok := t.(*javaactions.StringTemplateParameterType); ok { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$StringTemplateParameterType"}, - {Key: "Grammar", Value: v.Grammar}, - } - } - - // Special case for EntityTypeParameterType - not wrapped in BasicParameterType - if v, ok := t.(*javaactions.EntityTypeParameterType); ok { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$EntityTypeParameterType"}, - {Key: "TypeParameterPointer", Value: idToBsonBinary(string(v.TypeParameterID))}, - } - } - - // Special case for TypeParameter (ParameterizedEntityType) - wrapped in BasicParameterType - if v, ok := t.(*javaactions.TypeParameter); ok { - innerType := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ParameterizedEntityType"}, - {Key: "TypeParameterPointer", Value: idToBsonBinary(string(v.TypeParameterID))}, - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$BasicParameterType"}, - {Key: "Type", Value: innerType}, - } - } - - // All other types are wrapped in BasicParameterType - innerType := serializeInnerType(t) - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$BasicParameterType"}, - {Key: "Type", Value: innerType}, - } -} - -// serializeInnerType serializes the inner type for BasicParameterType. -func serializeInnerType(t javaactions.CodeActionParameterType) bson.D { - switch v := t.(type) { - case *javaactions.BooleanType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$BooleanType"}, - } - case *javaactions.IntegerType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$IntegerType"}, - } - case *javaactions.LongType: - // Mendix uses IntegerType for 64-bit integers (Long in Java) - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$IntegerType"}, - } - case *javaactions.DecimalType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$DecimalType"}, - } - case *javaactions.StringType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$StringType"}, - } - case *javaactions.DateTimeType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$DateTimeType"}, - } - case *javaactions.EnumerationType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$EnumerationType"}, - {Key: "Enumeration", Value: v.Enumeration}, - } - case *javaactions.EntityType: - // Use ConcreteEntityType for entity parameters - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ConcreteEntityType"}, - {Key: "Entity", Value: v.Entity}, - } - case *javaactions.ListType: - // ListType contains a Parameter which is a ConcreteEntityType - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ListType"}, - {Key: "Parameter", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$ConcreteEntityType"}, - {Key: "Entity", Value: v.Entity}, - }}, - } - case *javaactions.TypeParameter: - // ParameterizedEntityType - references a type parameter by ID - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(v.ID))}, - {Key: "$Type", Value: "CodeActions$ParameterizedEntityType"}, - {Key: "TypeParameterPointer", Value: idToBsonBinary(string(v.TypeParameterID))}, - } - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$StringType"}, - } - } -} diff --git a/sdk/mpr/writer_javaactions_enum_680_test.go b/sdk/mpr/writer_javaactions_enum_680_test.go deleted file mode 100644 index c7792fad96..0000000000 --- a/sdk/mpr/writer_javaactions_enum_680_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" -) - -// Issue #680: an EnumerationType parameter/return must serialize as -// CodeActions$EnumerationType with the Enumeration qualified name — never as an -// entity reference. - -func TestSerializeInnerType_Enumeration(t *testing.T) { - d := serializeInnerType(&javaactions.EnumerationType{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Enumeration: "Barcode.BarcodeFormat", - }) - m := map[string]any{} - for _, e := range d { - m[e.Key] = e.Value - } - if m["$Type"] != "CodeActions$EnumerationType" { - t.Errorf("$Type = %v, want CodeActions$EnumerationType", m["$Type"]) - } - if m["Enumeration"] != "Barcode.BarcodeFormat" { - t.Errorf("Enumeration = %v", m["Enumeration"]) - } -} - -func TestSerializeReturnType_Enumeration(t *testing.T) { - d := serializeReturnType(&javaactions.EnumerationType{ - BaseElement: model.BaseElement{ID: "22222222-2222-2222-2222-222222222222"}, - Enumeration: "M.Status", - }) - m := map[string]any{} - for _, e := range d { - m[e.Key] = e.Value - } - if m["$Type"] != "CodeActions$EnumerationType" { - t.Errorf("$Type = %v, want CodeActions$EnumerationType", m["$Type"]) - } - if m["Enumeration"] != "M.Status" { - t.Errorf("Enumeration = %v", m["Enumeration"]) - } -} diff --git a/sdk/mpr/writer_javascriptactions.go b/sdk/mpr/writer_javascriptactions.go deleted file mode 100644 index 52c23b640a..0000000000 --- a/sdk/mpr/writer_javascriptactions.go +++ /dev/null @@ -1,174 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package mpr - JavaScript action writer support. -package mpr - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "go.mongodb.org/mongo-driver/bson" -) - -// CreateJavaScriptAction serializes and inserts a new JavaScript action unit. -func (w *Writer) CreateJavaScriptAction(jsa *JavaScriptAction) error { - if jsa.ID == "" { - jsa.ID = model.ID(generateUUID()) - } - jsa.TypeName = "JavaScriptActions$JavaScriptAction" - - contents, err := w.serializeJavaScriptAction(jsa) - if err != nil { - return fmt.Errorf("failed to serialize javascript action: %w", err) - } - return w.insertUnit(string(jsa.ID), string(jsa.ContainerID), "Documents", "JavaScriptActions$JavaScriptAction", contents) -} - -// UpdateJavaScriptAction rewrites an existing JavaScript action unit. -func (w *Writer) UpdateJavaScriptAction(jsa *JavaScriptAction) error { - jsa.TypeName = "JavaScriptActions$JavaScriptAction" - contents, err := w.serializeJavaScriptAction(jsa) - if err != nil { - return fmt.Errorf("failed to serialize javascript action: %w", err) - } - return w.updateUnit(string(jsa.ID), contents) -} - -// DeleteJavaScriptAction removes a JavaScript action unit. -func (w *Writer) DeleteJavaScriptAction(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// serializeJavaScriptAction serializes a JavaScript action to BSON. The shape -// mirrors a Java action (shared parameter/return-type/MicroflowActionInfo -// serialization) with $Type JavaScriptActions$JavaScriptAction, JavaScript -// parameter $Types, and an added Platform field. -func (w *Writer) serializeJavaScriptAction(jsa *JavaScriptAction) ([]byte, error) { - params := bson.A{int32(2)} // typed-array marker - for _, param := range jsa.Parameters { - paramType := param.TypeName - if paramType == "" { - paramType = "JavaScriptActions$JavaScriptActionParameter" - } - paramDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(param.ID))}, - {Key: "$Type", Value: paramType}, - {Key: "Category", Value: param.Category}, - {Key: "Description", Value: param.Description}, - {Key: "IsRequired", Value: param.IsRequired}, - {Key: "Name", Value: param.Name}, - } - if param.ParameterType != nil { - paramDoc = append(paramDoc, bson.E{Key: "ParameterType", Value: serializeParameterType(param.ParameterType)}) - } - params = append(params, paramDoc) - } - - typeParams := bson.A{int32(2)} - for _, tp := range jsa.TypeParameters { - tpID := string(tp.ID) - if tpID == "" { - tpID = generateUUID() - } - typeParams = append(typeParams, bson.D{ - {Key: "$ID", Value: idToBsonBinary(tpID)}, - {Key: "$Type", Value: "CodeActions$TypeParameter"}, - {Key: "Name", Value: tp.Name}, - }) - } - - var maiValue any - if jsa.MicroflowActionInfo != nil { - maiValue = microflowActionInfoBSON(jsa.MicroflowActionInfo) - } - - var returnType bson.D - if jsa.ReturnType != nil { - returnType = serializeReturnType(jsa.ReturnType) - } else { - returnType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CodeActions$VoidType"}, - } - } - - platform := jsa.Platform - if platform == "" { - platform = "Web" - } - - // Key order follows what Studio Pro writes (alphabetical). - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(jsa.ID))}, - {Key: "$Type", Value: "JavaScriptActions$JavaScriptAction"}, - {Key: "ActionDefaultReturnName", Value: stringOrDefault(jsa.ActionDefaultReturnName, "ReturnValueName")}, - {Key: "Documentation", Value: jsa.Documentation}, - {Key: "Excluded", Value: jsa.Excluded}, - {Key: "ExportLevel", Value: stringOrDefault(jsa.ExportLevel, "Hidden")}, - {Key: "JavaReturnType", Value: returnType}, - {Key: "MicroflowActionInfo", Value: maiValue}, - {Key: "Name", Value: jsa.Name}, - {Key: "Parameters", Value: params}, - {Key: "Platform", Value: platform}, - {Key: "TypeParameters", Value: typeParams}, - } - - return marshalUnitIDFirst(doc) -} - -// jsActionSourceDir returns javascriptsource//actions with the module -// name LOWERCASED, which is where Mendix looks: a blank Mendix 11 app ships -// javascriptsource/nanoflowcommons/, /datawidgets/ and /webactions/ for modules -// named NanoflowCommons, DataWidgets and WebActions. -// -// Writing the original casing instead is silent and total: mxbuild finds no -// source at the path it reads, generates a stub whose body throws -// "JavaScript action was not implemented", and bundles that. The action parses, -// passes `mxcli check` and builds cleanly, then throws when a user clicks it. -// Only reproduces on a case-sensitive filesystem — on macOS and Windows the two -// spellings are the same directory, which is why it went unnoticed. -func (w *Writer) jsActionSourceDir(moduleName string) string { - return filepath.Join(filepath.Dir(w.reader.path), "javascriptsource", strings.ToLower(moduleName), "actions") -} - -// WriteJavaScriptSourceFile writes javascriptsource//actions/.js. -func (w *Writer) WriteJavaScriptSourceFile(moduleName, actionName string, jsCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType) error { - dir := w.jsActionSourceDir(moduleName) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("failed to create javascriptsource directory: %w", err) - } - source := javaactions.GenerateJavaScriptSource(actionName, jsCode, params, returnType) - w.writesOffered++ - changed, err := javaactions.WriteSourceIfChanged(filepath.Join(dir, actionName+".js"), source) - if err != nil { - return fmt.Errorf("failed to write JavaScript source file: %w", err) - } - if changed { - w.writesLanded++ - } - return nil -} - -// DeleteJavaScriptSourceFile removes the .js file for a dropped JavaScript action. -func (w *Writer) DeleteJavaScriptSourceFile(moduleName, actionName string) error { - filePath := filepath.Join(w.jsActionSourceDir(moduleName), actionName+".js") - if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to delete JavaScript source file: %w", err) - } - return nil -} - -// RenameJavaScriptSourceFile renames the .js file when a JavaScript action is renamed. -func (w *Writer) RenameJavaScriptSourceFile(moduleName, oldName, newName string) error { - dir := w.jsActionSourceDir(moduleName) - oldPath := filepath.Join(dir, oldName+".js") - newPath := filepath.Join(dir, newName+".js") - if err := os.Rename(oldPath, newPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to rename JavaScript source file: %w", err) - } - return nil -} diff --git a/sdk/mpr/writer_javascriptactions_test.go b/sdk/mpr/writer_javascriptactions_test.go deleted file mode 100644 index 14894a61e2..0000000000 --- a/sdk/mpr/writer_javascriptactions_test.go +++ /dev/null @@ -1,93 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/javaactions" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeJavaScriptAction_Shape asserts the serialized JS action uses the -// JavaScriptActions$ document and parameter $Type names, carries a Platform -// field, and reuses the CodeActions$ inner parameter/return types. -func TestSerializeJavaScriptAction_Shape(t *testing.T) { - w := &Writer{} - jsa := &JavaScriptAction{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Name: "JSA", - Platform: "All", - Parameters: []*javaactions.JavaActionParameter{ - { - BaseElement: model.BaseElement{ID: "22222222-2222-2222-2222-222222222222", TypeName: "JavaScriptActions$JavaScriptActionParameter"}, - Name: "Input", - IsRequired: true, - ParameterType: &javaactions.StringType{BaseElement: model.BaseElement{ID: "33333333-3333-3333-3333-333333333333", TypeName: "CodeActions$StringType"}}, - }, - }, - ReturnType: &javaactions.BooleanType{BaseElement: model.BaseElement{ID: "44444444-4444-4444-4444-444444444444", TypeName: "CodeActions$BooleanType"}}, - } - - raw, err := w.serializeJavaScriptAction(jsa) - if err != nil { - t.Fatal(err) - } - var doc bson.D - if err := bson.Unmarshal(raw, &doc); err != nil { - t.Fatal(err) - } - m := map[string]any{} - for _, e := range doc { - m[e.Key] = e.Value - } - - if m["$Type"] != "JavaScriptActions$JavaScriptAction" { - t.Errorf("$Type = %v", m["$Type"]) - } - if m["Platform"] != "All" { - t.Errorf("Platform = %v, want All", m["Platform"]) - } - if m["ActionDefaultReturnName"] != "ReturnValueName" { - t.Errorf("ActionDefaultReturnName = %v", m["ActionDefaultReturnName"]) - } - - params, ok := m["Parameters"].(bson.A) - if !ok || len(params) < 2 { - t.Fatalf("Parameters = %v", m["Parameters"]) - } - if marker, _ := params[0].(int32); marker != 2 { - t.Errorf("param array marker = %v, want 2", params[0]) - } - p0 := params[1].(bson.D) - var pType string - for _, e := range p0 { - if e.Key == "$Type" { - pType, _ = e.Value.(string) - } - } - if pType != "JavaScriptActions$JavaScriptActionParameter" { - t.Errorf("param $Type = %q", pType) - } -} - -// TestSerializeJavaScriptAction_DefaultPlatform asserts an unset platform -// defaults to Web. -func TestSerializeJavaScriptAction_DefaultPlatform(t *testing.T) { - w := &Writer{} - raw, err := w.serializeJavaScriptAction(&JavaScriptAction{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Name: "JSA", - }) - if err != nil { - t.Fatal(err) - } - var doc bson.D - _ = bson.Unmarshal(raw, &doc) - for _, e := range doc { - if e.Key == "Platform" && e.Value != "Web" { - t.Errorf("default Platform = %v, want Web", e.Value) - } - } -} diff --git a/sdk/mpr/writer_jsonstructure.go b/sdk/mpr/writer_jsonstructure.go deleted file mode 100644 index a1622e1068..0000000000 --- a/sdk/mpr/writer_jsonstructure.go +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// PrettyPrintJSON delegates to types.PrettyPrintJSON. -func PrettyPrintJSON(s string) string { return types.PrettyPrintJSON(s) } - -// BuildJsonElementsFromSnippet delegates to types.BuildJsonElementsFromSnippet. -func BuildJsonElementsFromSnippet(snippet string, customNameMap, itemNameMap map[string]string) ([]*JsonElement, error) { - return types.BuildJsonElementsFromSnippet(snippet, customNameMap, itemNameMap) -} - -// CreateJsonStructure creates a new JSON structure unit in the MPR. -func (w *Writer) CreateJsonStructure(js *JsonStructure) error { - if js.ID == "" { - js.ID = model.ID(generateUUID()) - } - if js.ExportLevel == "" { - js.ExportLevel = "Hidden" - } - - contents, err := serializeJsonStructure(js) - if err != nil { - return err - } - - return w.insertUnit(string(js.ID), string(js.ContainerID), - "Documents", "JsonStructures$JsonStructure", contents) -} - -// UpdateJsonStructure re-serializes an existing JSON structure in-place, preserving its ID. -func (w *Writer) UpdateJsonStructure(js *JsonStructure) error { - contents, err := serializeJsonStructure(js) - if err != nil { - return err - } - return w.updateUnit(string(js.ID), contents) -} - -// DeleteJsonStructure deletes a JSON structure by ID. -func (w *Writer) DeleteJsonStructure(id string) error { - return w.deleteUnit(id) -} - -func serializeJsonStructure(js *JsonStructure) ([]byte, error) { - elements := bson.A{int32(2)} - for _, elem := range js.Elements { - elements = append(elements, serializeJsonElement(elem)) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(js.ID))}, - {Key: "$Type", Value: "JsonStructures$JsonStructure"}, - {Key: "Documentation", Value: js.Documentation}, - {Key: "Elements", Value: elements}, - {Key: "Excluded", Value: js.Excluded}, - {Key: "ExportLevel", Value: js.ExportLevel}, - {Key: "JsonSnippet", Value: js.JsonSnippet}, - {Key: "Name", Value: js.Name}, - } - - return marshalUnitIDFirst(doc) -} - -// serializeJsonElement serializes a single JsonElement to BSON. -// Note: JsonStructures$JsonElement uses int32 for numeric properties (MinOccurs, MaxOccurs, etc.), -// unlike most other Mendix document types which use int64. Verified against Studio Pro-generated BSON. -func serializeJsonElement(elem *JsonElement) bson.D { - children := bson.A{int32(2)} - for _, child := range elem.Children { - children = append(children, serializeJsonElement(child)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "JsonStructures$JsonElement"}, - {Key: "Children", Value: children}, - {Key: "ElementType", Value: elem.ElementType}, - {Key: "ErrorMessage", Value: ""}, - {Key: "ExposedItemName", Value: elem.ExposedItemName}, - {Key: "ExposedName", Value: elem.ExposedName}, - {Key: "FractionDigits", Value: int32(elem.FractionDigits)}, - {Key: "IsDefaultType", Value: elem.IsDefaultType}, - {Key: "MaxLength", Value: int32(elem.MaxLength)}, - {Key: "MaxOccurs", Value: int32(elem.MaxOccurs)}, - {Key: "MinOccurs", Value: int32(elem.MinOccurs)}, - {Key: "Nillable", Value: elem.Nillable}, - {Key: "OriginalValue", Value: elem.OriginalValue}, - {Key: "Path", Value: elem.Path}, - {Key: "PrimitiveType", Value: elem.PrimitiveType}, - {Key: "TotalDigits", Value: int32(elem.TotalDigits)}, - {Key: "WarningMessage", Value: ""}, - } -} diff --git a/sdk/mpr/writer_listoperation_test.go b/sdk/mpr/writer_listoperation_test.go deleted file mode 100644 index 5285256218..0000000000 --- a/sdk/mpr/writer_listoperation_test.go +++ /dev/null @@ -1,174 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeListOperation_FindByAttribute(t *testing.T) { - doc := serializeListOperation(µflows.FindByAttributeOperation{ - BaseElement: model.BaseElement{ID: "operation-id"}, - ListVariable: "Items", - Attribute: "Demo.Item.Code", - Expression: "$IteratorItem/ExternalCode", - }) - fields := listOperationDocMap(doc) - - if got := fields["$Type"]; got != "Microflows$Find" { - t.Fatalf("$Type = %v, want Microflows$Find", got) - } - if got := fields["Attribute"]; got != "Demo.Item.Code" { - t.Fatalf("Attribute = %v, want Demo.Item.Code", got) - } - if got := fields["Expression"]; got != "$IteratorItem/ExternalCode" { - t.Fatalf("Expression = %v, want $IteratorItem/ExternalCode", got) - } - if got := fields["ListName"]; got != "Items" { - t.Fatalf("ListName = %v, want Items", got) - } -} - -func TestSerializeListOperation_FilterByAssociation(t *testing.T) { - doc := serializeListOperation(µflows.FilterByAttributeOperation{ - BaseElement: model.BaseElement{ID: "operation-id"}, - ListVariable: "Items", - Association: "Demo.Item_Category", - Expression: "$Category", - }) - fields := listOperationDocMap(doc) - - if got := fields["$Type"]; got != "Microflows$Filter" { - t.Fatalf("$Type = %v, want Microflows$Filter", got) - } - if got := fields["Association"]; got != "Demo.Item_Category" { - t.Fatalf("Association = %v, want Demo.Item_Category", got) - } - if got := fields["Expression"]; got != "$Category" { - t.Fatalf("Expression = %v, want $Category", got) - } -} - -// upstream #966, the legacy engine's half. -// -// serializeListOperation had no ListRangeOperation case at all, so it fell -// through to `return nil` — and serializeListOperationAction appends that nil -// under "NewOperation" without checking, which lands in the file as an empty -// sub-document. The result is not a range that lost its bounds; it is a project -// Mendix cannot OPEN. Measured on mxbuild 11.13.0: -// -// ERROR: System.AggregateException: … (Expected '$ID' as the first property -// of a storage object, but got 'NewOperation'.) -// at StreamingBsonUnitReader.ConstructObject(…) -// -// The control was the same script with `filter` in place of `range`: that one -// wrote a well-formed NewOperation and loaded fine, so the Range case is what -// produced the empty document. -// -// The parser has read the nested CustomRange since it was written (see -// TestParseListOperation_Range), so the writer is the only side that was -// missing — which is why `--engine legacy` was never a workaround for #966. -func TestSerializeListOperation_Range(t *testing.T) { - doc := serializeListOperation(µflows.ListRangeOperation{ - BaseElement: model.BaseElement{ID: "operation-id"}, - ListVariable: "Items", - OffsetExpression: "$Skip", - LimitExpression: "$Take", - }) - if doc == nil { - t.Fatal("serializeListOperation returned nil — the action is written with an empty NewOperation and Mendix cannot load the project") - } - fields := listOperationDocMap(doc) - - if got := fields["$Type"]; got != "Microflows$ListRange" { - t.Fatalf("$Type = %v, want Microflows$ListRange", got) - } - if got := fields["ListName"]; got != "Items" { - t.Errorf("ListName = %v, want Items", got) - } - // The bounds live one level down, in a Microflows$CustomRange child — the - // shape the parser beside this file already expects. Flat keys build a - // model mxbuild rejects with CE6520. - cr, ok := fields["CustomRange"].(bson.D) - if !ok { - t.Fatalf("CustomRange = %#v, want a bson.D child document", fields["CustomRange"]) - } - crFields := listOperationDocMap(cr) - if got := crFields["$Type"]; got != "Microflows$CustomRange" { - t.Errorf("CustomRange $Type = %v, want Microflows$CustomRange", got) - } - if got := crFields["OffsetExpression"]; got != "$Skip" { - t.Errorf("CustomRange.OffsetExpression = %v, want $Skip", got) - } - if got := crFields["LimitExpression"]; got != "$Take" { - t.Errorf("CustomRange.LimitExpression = %v, want $Take", got) - } -} - -// The write→read pairing within the legacy engine. The parser was already -// right, so this asserts the writer now speaks the same shape the parser reads -// — the property that was missing when `range` was the one list operation -// legacy could parse but not write. -func TestSerializeListOperation_RangeRoundTrips(t *testing.T) { - doc := serializeListOperation(µflows.ListRangeOperation{ - BaseElement: model.BaseElement{ID: "operation-id"}, - ListVariable: "Items", - OffsetExpression: "$Skip", - LimitExpression: "$Take", - }) - - // Re-present the document the way the parser receives it. - raw := map[string]any{} - for _, e := range doc { - if child, ok := e.Value.(bson.D); ok { - m := map[string]any{} - for _, ce := range child { - m[ce.Key] = ce.Value - } - raw[e.Key] = m - continue - } - raw[e.Key] = e.Value - } - - op, ok := parseListOperation(raw).(*microflows.ListRangeOperation) - if !ok { - t.Fatalf("parseListOperation → %T, want *microflows.ListRangeOperation", parseListOperation(raw)) - } - if op.OffsetExpression != "$Skip" || op.LimitExpression != "$Take" { - t.Errorf("round trip: offset=%q limit=%q, want $Skip/$Take", op.OffsetExpression, op.LimitExpression) - } -} - -// An operation the writer has no case for must not reach the file as an empty -// NewOperation: that is the unloadable-project shape above, and it is worse -// than the honest alternative (no action → mxbuild's CE0008 "No action -// defined", which names the activity). unknownListOperation stands in for a -// model type a future metamodel adds before this writer learns it. -type unknownListOperation struct{ microflows.HeadOperation } - -func TestSerializeListOperationAction_OmitsAnUnserializableOperation(t *testing.T) { - doc := serializeListOperationAction(µflows.ListOperationAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - Operation: &unknownListOperation{}, - OutputVariable: "Out", - }) - for _, e := range doc { - if e.Key == "NewOperation" { - t.Fatalf("NewOperation = %#v; an operation the writer cannot serialize must be omitted, "+ - "not written as an empty document (Mendix: \"Expected '$ID' as the first property of a storage object\")", e.Value) - } - } -} - -func listOperationDocMap(doc bson.D) map[string]any { - fields := make(map[string]any, len(doc)) - for _, elem := range doc { - fields[elem.Key] = elem.Value - } - return fields -} diff --git a/sdk/mpr/writer_listview_source_test.go b/sdk/mpr/writer_listview_source_test.go deleted file mode 100644 index 53c1a07c10..0000000000 --- a/sdk/mpr/writer_listview_source_test.go +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" - - "github.com/mendixlabs/mxcli/sdk/pages" -) - -func dLookup(d bson.D, key string) (any, bool) { - for _, e := range d { - if e.Key == key { - return e.Value, true - } - } - return nil, false -} - -// A ListView database source (legacy engine) must serialize with the same -// metamodel-valid shape as the pluggable CustomWidgetXPathSource: a -// Forms$GridSortBar with SortItems, and a Forms$ListViewSearch with SearchRefs. -// The old code emitted a bogus Forms$ListViewSort and a `Paths` key (the search -// list was renamed to SearchRefs in 7.11.0), producing a client model that -// omitted the arrays the Mendix client reads .length of → runtime crash in -// retrieveByXPath/processResult. -func TestSerializeListViewDataSource_Database(t *testing.T) { - doc := serializeListViewDataSource(&pages.DatabaseSource{ - EntityName: "M.Item", - Sorting: []*pages.GridSort{{AttributePath: "M.Item.Name", Direction: "Ascending"}}, - }) - - if v, _ := dLookup(doc, "$Type"); v != "Forms$ListViewXPathSource" { - t.Fatalf("$Type = %v", v) - } - // Must NOT carry the bogus keys. - if _, ok := dLookup(doc, "Sort"); ok { - t.Error("legacy ListView source must not emit a `Sort` key (Forms$ListViewSort is not a property of ListViewXPathSource)") - } - - // SortBar → GridSortBar with a SortItems list holding the GridSortItem. - sortBarV, ok := dLookup(doc, "SortBar") - if !ok { - t.Fatal("SortBar missing") - } - sortBar := sortBarV.(bson.D) - if v, _ := dLookup(sortBar, "$Type"); v != "Forms$GridSortBar" { - t.Errorf("SortBar $Type = %v", v) - } - items, ok := dLookup(sortBar, "SortItems") - if !ok { - t.Fatal("SortBar.SortItems missing") - } - if a, _ := items.(bson.A); len(a) < 2 { - t.Errorf("SortItems should contain the sort item, got %v", items) - } - - // Search → ListViewSearch with SearchRefs (not `Paths`). - searchV, ok := dLookup(doc, "Search") - if !ok { - t.Fatal("Search missing") - } - search := searchV.(bson.D) - if _, ok := dLookup(search, "SearchRefs"); !ok { - t.Error("Search.SearchRefs missing") - } - if _, ok := dLookup(search, "Paths"); ok { - t.Error("Search must not emit the obsolete `Paths` key (renamed SearchRefs in 7.11.0)") - } -} - -// The empty-datasource fallback (nil source) must produce the same valid shape. -func TestEmptyListViewXPathSource_Shape(t *testing.T) { - doc := emptyListViewXPathSource() - if _, ok := dLookup(doc, "SortBar"); !ok { - t.Error("fallback source missing SortBar") - } - searchV, ok := dLookup(doc, "Search") - if !ok { - t.Fatal("fallback source missing Search") - } - if _, ok := dLookup(searchV.(bson.D), "SearchRefs"); !ok { - t.Error("fallback Search missing SearchRefs") - } -} diff --git a/sdk/mpr/writer_listview_template_test.go b/sdk/mpr/writer_listview_template_test.go deleted file mode 100644 index c0724e4275..0000000000 --- a/sdk/mpr/writer_listview_template_test.go +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeListViewTemplateCarriesTheSpecialization pins the legacy engine's -// half of #940. -// -// The writer already emitted Forms$ListViewTemplate elements, but with only -// {$ID, $Type, Widgets} — no entity — so every template it wrote matched nothing -// and rendered never. Studio Pro's own documents (ako/TestApp, -// Pages.Vehicle_Overview) carry the entity under the storage name "Entity", not -// the SDK name "Specialization". -func TestSerializeListViewTemplateCarriesTheSpecialization(t *testing.T) { - lv := &pages.ListView{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ID: model.ID("lv"), TypeName: "Forms$ListView"}, - Name: "vehicleListView", - }, - Templates: []*pages.ListViewTemplate{ - {BaseElement: model.BaseElement{ID: model.ID("t1")}, Specialization: "Pages.Bus"}, - {BaseElement: model.BaseElement{ID: model.ID("t2")}, Specialization: "Pages.Truck"}, - }, - } - - doc := serializeListView(lv) - - var templates bson.A - for _, e := range doc { - if e.Key == "Templates" { - templates, _ = e.Value.(bson.A) - } - } - // The first element is the typed-array marker, not a template. - if len(templates) != 3 { - t.Fatalf("Templates has %d element(s) (marker + templates), want 3", len(templates)) - } - - want := []string{"Pages.Bus", "Pages.Truck"} - for i, wantEntity := range want { - tpl, ok := templates[i+1].(bson.D) - if !ok { - t.Fatalf("template %d is %T, want bson.D", i, templates[i+1]) - } - var got string - var keys []string - for _, e := range tpl { - keys = append(keys, e.Key) - if e.Key == "Entity" { - got, _ = e.Value.(string) - } - } - if got != wantEntity { - t.Errorf("template %d Entity = %q, want %q (keys present: %v)", i, got, wantEntity, keys) - } - // Key order matches Studio Pro's documents. - if len(keys) != 4 || keys[0] != "$ID" || keys[1] != "$Type" || keys[2] != "Entity" || keys[3] != "Widgets" { - t.Errorf("template %d keys = %v, want [$ID $Type Entity Widgets]", i, keys) - } - } -} diff --git a/sdk/mpr/writer_microflow.go b/sdk/mpr/writer_microflow.go deleted file mode 100644 index 55bdd607ad..0000000000 --- a/sdk/mpr/writer_microflow.go +++ /dev/null @@ -1,864 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "sort" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/sdk/mpr/version" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateMicroflow creates a new microflow. -func (w *Writer) CreateMicroflow(mf *microflows.Microflow) error { - if mf.ID == "" { - mf.ID = model.ID(generateUUID()) - } - mf.TypeName = "Microflows$Microflow" - - contents, err := w.serializeMicroflow(mf) - if err != nil { - return fmt.Errorf("failed to serialize microflow: %w", err) - } - - return w.insertUnit(string(mf.ID), string(mf.ContainerID), "Documents", "Microflows$Microflow", contents) -} - -// UpdateMicroflow updates an existing microflow. -func (w *Writer) UpdateMicroflow(mf *microflows.Microflow) error { - contents, err := w.serializeMicroflow(mf) - if err != nil { - return fmt.Errorf("failed to serialize microflow: %w", err) - } - - return w.updateUnit(string(mf.ID), contents) -} - -// DeleteMicroflow deletes a microflow. -func (w *Writer) DeleteMicroflow(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MoveMicroflow moves a microflow to a new container (folder or module). -// Only updates the ContainerID in the database, preserving all BSON content -// (layout positions, flow connections, etc.) as-is. -func (w *Writer) MoveMicroflow(mf *microflows.Microflow) error { - return w.moveUnitByID(string(mf.ID), string(mf.ContainerID)) -} - -// CreateNanoflow creates a new nanoflow. -func (w *Writer) CreateNanoflow(nf *microflows.Nanoflow) error { - if nf.ID == "" { - nf.ID = model.ID(generateUUID()) - } - nf.TypeName = "Microflows$Nanoflow" - - contents, err := w.serializeNanoflow(nf) - if err != nil { - return fmt.Errorf("failed to serialize nanoflow: %w", err) - } - - return w.insertUnit(string(nf.ID), string(nf.ContainerID), "Documents", "Microflows$Nanoflow", contents) -} - -// UpdateNanoflow updates an existing nanoflow. -func (w *Writer) UpdateNanoflow(nf *microflows.Nanoflow) error { - contents, err := w.serializeNanoflow(nf) - if err != nil { - return fmt.Errorf("failed to serialize nanoflow: %w", err) - } - - return w.updateUnit(string(nf.ID), contents) -} - -// DeleteNanoflow deletes a nanoflow. -func (w *Writer) DeleteNanoflow(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MoveNanoflow moves a nanoflow to a new container (folder or module). -// Only updates the ContainerID in the database, preserving all BSON content as-is. -func (w *Writer) MoveNanoflow(nf *microflows.Nanoflow) error { - return w.moveUnitByID(string(nf.ID), string(nf.ContainerID)) -} - -func (w *Writer) serializeMicroflow(mf *microflows.Microflow) ([]byte, error) { - // Build main document with required fields in correct order - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(mf.ID))}, - {Key: "$Type", Value: "Microflows$Microflow"}, - {Key: "AllowConcurrentExecution", Value: mf.AllowConcurrentExecution}, - {Key: "AllowedModuleRoles", Value: allowedModuleRolesArray(mf.AllowedModuleRoles)}, - // Carried, not hardcoded — see the modelsdk twin. A hardcoded false - // turned "apply entity access" OFF on every rewrite, widening what the - // microflow may read and write. - {Key: "ApplyEntityAccess", Value: mf.ApplyEntityAccess}, - {Key: "ConcurrencyErrorMicroflow", Value: ""}, - {Key: "ConcurrenyErrorMessage", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, // Empty array marker - }}, - {Key: "Documentation", Value: mf.Documentation}, - {Key: "Excluded", Value: mf.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - } - - // Add Flows array (SequenceFlows and AnnotationFlows go here, not in ObjectCollection) - // The serialized shape depends on the project's Mendix major version. - // Fall back to the project default when no MPR is attached (in-memory tests). - majorVersion := version.DefaultVersion().MajorVersion - if pv := w.reader.ProjectVersion(); pv != nil { - majorVersion = pv.MajorVersion - } - flows := bson.A{int32(3)} // Start with array type marker - if mf.ObjectCollection != nil { - for _, flow := range mf.ObjectCollection.Flows { - flows = append(flows, serializeSequenceFlow(flow, majorVersion)) - } - for _, af := range mf.ObjectCollection.AnnotationFlows { - flows = append(flows, serializeAnnotationFlow(af, majorVersion)) - } - } - doc = append(doc, bson.E{Key: "Flows", Value: flows}) - - // Add remaining fields - doc = append(doc, bson.E{Key: "MarkAsUsed", Value: mf.MarkAsUsed}) - doc = append(doc, bson.E{Key: "MicroflowActionInfo", Value: nil}) - - // Note: Parameters are NOT stored in MicroflowParameterCollection - // They go in ObjectCollection.Objects as Microflows$MicroflowParameter entries - - // Add return type - if mf.ReturnType != nil { - doc = append(doc, bson.E{Key: "MicroflowReturnType", Value: serializeMicroflowDataType(mf.ReturnType)}) - } - - doc = append(doc, bson.E{Key: "Name", Value: mf.Name}) - - // Add object collection (without flows - they're in Flows array) - // Parameters go in ObjectCollection.Objects, pass them here - if mf.ObjectCollection != nil { - doc = append(doc, bson.E{Key: "ObjectCollection", Value: serializeMicroflowObjectCollectionWithoutFlows(mf.ObjectCollection, mf.Parameters, majorVersion)}) - } - - // ReturnVariableName, StableId, Url, and UrlSearchParameters were added in - // Mendix 10; Mendix 9 projects do not know about these fields and Studio Pro - // raises metamodel errors if they're present. - if majorVersion >= 10 { - // ReturnVariableName is "" by default (Studio Pro convention). - // Only set a custom name when explicitly specified via "RETURNS xxx AS $VarName". - doc = append(doc, bson.E{Key: "ReturnVariableName", Value: mf.ReturnVariableName}) - doc = append(doc, bson.E{Key: "StableId", Value: idToBsonBinary(generateUUID())}) - doc = append(doc, bson.E{Key: "Url", Value: ""}) - doc = append(doc, bson.E{Key: "UrlSearchParameters", Value: bson.A{int32(1)}}) - } - doc = append(doc, bson.E{Key: "WorkflowActionInfo", Value: nil}) - - return marshalUnitIDFirst(doc) -} - -// serializeSequenceFlow serializes a SequenceFlow to BSON with correct structure. -// -// The case value shape is version-specific: -// - Mendix 9: inline `NewCaseValue` document (NoCase for non-decision flows, -// EnumerationCase for decision branches). `CaseValues` is omitted. -// - Mendix 10+: `CaseValues = [marker, case]` where the case is always present -// (at minimum a NoCase object). Studio Pro rejects `CaseValues = [marker]` -// alone with CE0079/CE0773 "condition value must be configured". -func serializeSequenceFlow(flow *microflows.SequenceFlow, majorVersion int) bson.D { - // Build the case document. Every sequence flow needs a case — NoCase is the - // default when no branch condition has been set. - caseDoc := buildSequenceFlowCase(flow.CaseValue) - - originCV := flow.OriginControlVector - if originCV == "" { - originCV = "0;0" - } - destCV := flow.DestinationControlVector - if destCV == "" { - destCV = "0;0" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(flow.ID))}, - {Key: "$Type", Value: "Microflows$SequenceFlow"}, - } - - if majorVersion <= 9 { - // Legacy Mendix 9 shape: - // - inline NewCaseValue (no CaseValues array) - // - OriginBezierVector / DestinationBezierVector are top-level strings - // (no nested Line: Microflows$BezierCurve document) - doc = append(doc, bson.E{Key: "DestinationBezierVector", Value: destCV}) - doc = append(doc, bson.E{Key: "DestinationConnectionIndex", Value: int32(flow.DestinationConnectionIndex)}) - doc = append(doc, bson.E{Key: "DestinationPointer", Value: idToBsonBinary(string(flow.DestinationID))}) - doc = append(doc, bson.E{Key: "IsErrorHandler", Value: flow.IsErrorHandler}) - doc = append(doc, bson.E{Key: "NewCaseValue", Value: caseDoc}) - doc = append(doc, bson.E{Key: "OriginBezierVector", Value: originCV}) - doc = append(doc, bson.E{Key: "OriginConnectionIndex", Value: int32(flow.OriginConnectionIndex)}) - doc = append(doc, bson.E{Key: "OriginPointer", Value: idToBsonBinary(string(flow.OriginID))}) - return doc - } - - // Modern format (Mx 10+): CaseValues = [marker, caseDoc]. - doc = append(doc, bson.E{Key: "CaseValues", Value: bson.A{int32(2), caseDoc}}) - doc = append(doc, bson.E{Key: "DestinationConnectionIndex", Value: int32(flow.DestinationConnectionIndex)}) - doc = append(doc, bson.E{Key: "DestinationPointer", Value: idToBsonBinary(string(flow.DestinationID))}) - doc = append(doc, bson.E{Key: "IsErrorHandler", Value: flow.IsErrorHandler}) - doc = append(doc, bson.E{Key: "Line", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$BezierCurve"}, - {Key: "DestinationControlVector", Value: destCV}, - {Key: "OriginControlVector", Value: originCV}, - }}) - doc = append(doc, bson.E{Key: "OriginConnectionIndex", Value: int32(flow.OriginConnectionIndex)}) - doc = append(doc, bson.E{Key: "OriginPointer", Value: idToBsonBinary(string(flow.OriginID))}) - return doc -} - -// buildSequenceFlowCase renders the case document for a sequence flow. -// When no case has been set on the flow, a NoCase document is synthesised — -// Studio Pro requires every SequenceFlow to carry an explicit case object. -func buildSequenceFlowCase(cv microflows.CaseValue) bson.D { - // Normalise value receivers to pointers so each case is handled once. - switch c := cv.(type) { - case microflows.EnumerationCase: - cv = &c - case microflows.NoCase: - cv = &c - case microflows.ExpressionCase: - cv = &c - case microflows.InheritanceCase: - cv = &c - } - - switch c := cv.(type) { - case *microflows.EnumerationCase: - id := string(c.ID) - if id == "" { - id = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Microflows$EnumerationCase"}, - {Key: "Value", Value: c.Value}, - } - case *microflows.NoCase: - id := string(c.ID) - if id == "" { - id = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Microflows$NoCase"}, - } - case *microflows.ExpressionCase: - id := string(c.ID) - if id == "" { - id = generateUUID() - } - // Studio Pro always uses EnumerationCase with Value="true"/"false" on the - // SequenceFlow; the expression itself lives on ExclusiveSplit.SplitCondition. - // This applies to all Mendix versions — Microflows$ExpressionCase was a - // mxcli-only type that Studio Pro has never recognised. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Microflows$EnumerationCase"}, - {Key: "Value", Value: c.Expression}, - } - case *microflows.InheritanceCase: - id := string(c.ID) - if id == "" { - id = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Microflows$InheritanceCase"}, - {Key: "Value", Value: c.EntityQualifiedName}, - } - } - // Default: synthesise a NoCase document with a fresh ID. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$NoCase"}, - } -} - -// serializeAnnotationFlow serializes an AnnotationFlow to BSON. -// The line shape is version-specific: Mendix 9 stores OriginBezierVector / -// DestinationBezierVector as top-level strings, while Mendix 10+ nests them -// inside a Microflows$BezierCurve document under `Line`. -func serializeAnnotationFlow(af *microflows.AnnotationFlow, majorVersion int) bson.D { - if majorVersion <= 9 { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(af.ID))}, - {Key: "$Type", Value: "Microflows$AnnotationFlow"}, - {Key: "DestinationBezierVector", Value: "0;0"}, - {Key: "DestinationConnectionIndex", Value: int32(0)}, - {Key: "DestinationPointer", Value: idToBsonBinary(string(af.DestinationID))}, - {Key: "OriginBezierVector", Value: "0;0"}, - {Key: "OriginConnectionIndex", Value: int32(0)}, - {Key: "OriginPointer", Value: idToBsonBinary(string(af.OriginID))}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(af.ID))}, - {Key: "$Type", Value: "Microflows$AnnotationFlow"}, - {Key: "DestinationConnectionIndex", Value: int32(0)}, - {Key: "DestinationPointer", Value: idToBsonBinary(string(af.DestinationID))}, - {Key: "Line", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$BezierCurve"}, - {Key: "DestinationControlVector", Value: "0;0"}, - {Key: "OriginControlVector", Value: "0;0"}, - }}, - {Key: "OriginConnectionIndex", Value: int32(0)}, - {Key: "OriginPointer", Value: idToBsonBinary(string(af.OriginID))}, - } -} - -// serializeMicroflowParameter serializes a MicroflowParameter to BSON. -// Parameters go in ObjectCollection.Objects, not in a separate collection. -// -// DefaultValue and IsRequired were introduced in Mendix 10; emitting them on a -// Mendix 9 project trips the Studio Pro metamodel checker, so they are gated. -func serializeMicroflowParameter(p *microflows.MicroflowParameter, posX int, majorVersion int) bson.D { - // An authored position is written as given; without one the parameter goes - // where the layout puts it — a row of boxes along the top of the canvas. - pos := microflows.DerivedParameterPosition(posX) - if p.Position != nil { - pos = *p.Position - } - relativeMiddlePoint := pointToString(pos) - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(p.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowParameter"}, - } - if majorVersion >= 10 { - doc = append(doc, bson.E{Key: "DefaultValue", Value: ""}) - } - doc = append(doc, bson.E{Key: "Documentation", Value: p.Documentation}) - doc = append(doc, bson.E{Key: "HasVariableNameBeenChanged", Value: false}) - if majorVersion >= 10 { - doc = append(doc, bson.E{Key: "IsRequired", Value: true}) - } - doc = append(doc, bson.E{Key: "Name", Value: p.Name}) - doc = append(doc, bson.E{Key: "RelativeMiddlePoint", Value: relativeMiddlePoint}) - doc = append(doc, bson.E{Key: "Size", Value: "30;30"}) - if p.Type != nil { - doc = append(doc, bson.E{Key: "VariableType", Value: serializeMicroflowDataType(p.Type)}) - } - return doc -} - -// serializeMicroflowDataType serializes a microflow data type to BSON. -func serializeMicroflowDataType(dt microflows.DataType) bson.D { - if dt == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$VoidType"}, - } - } - - switch t := dt.(type) { - case *microflows.BooleanType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$BooleanType"}, - } - case *microflows.IntegerType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$IntegerType"}, - } - case *microflows.LongType: - // Mendix uses IntegerType for 64-bit integers (Long in Java) - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$IntegerType"}, - } - case *microflows.DecimalType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$DecimalType"}, - } - case *microflows.StringType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$StringType"}, - } - case *microflows.DateTimeType, *microflows.DateType: // Both map to DataTypes$DateTimeType in BSON; Date is distinguished by LocalizeDate=false at the attribute level - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$DateTimeType"}, - } - case *microflows.BinaryType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$BinaryType"}, - } - case *microflows.VoidType: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$VoidType"}, - } - case *microflows.ObjectType: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - } - // Entity is a BY_NAME_REFERENCE - stored as qualified name string, not binary GUID - if t.EntityQualifiedName != "" { - doc = append(doc, bson.E{Key: "Entity", Value: t.EntityQualifiedName}) - } - return doc - case *microflows.ListType: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$ListType"}, - } - // Entity is a BY_NAME_REFERENCE - stored as qualified name string, not binary GUID - if t.EntityQualifiedName != "" { - doc = append(doc, bson.E{Key: "Entity", Value: t.EntityQualifiedName}) - } - return doc - case *microflows.EnumerationType: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$EnumerationType"}, - } - // Enumeration is a BY_NAME_REFERENCE - stored as qualified name string, not binary GUID - if t.EnumerationQualifiedName != "" { - doc = append(doc, bson.E{Key: "Enumeration", Value: t.EnumerationQualifiedName}) - } - return doc - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$VoidType"}, - } - } -} - -// serializeMicroflowObjectCollectionWithoutFlows serializes the object collection to BSON (flows are in separate Flows array). -// Parameters are also included in the Objects array. -func serializeMicroflowObjectCollectionWithoutFlows(oc *microflows.MicroflowObjectCollection, params []*microflows.MicroflowParameter, majorVersion int) bson.D { - // Start with array type marker, then serialize objects (NOT flows) - objects := bson.A{int32(3)} // Array type marker - - // Add parameters first (they appear at the top of the microflow) - for i, p := range params { - objects = append(objects, serializeMicroflowParameter(p, i, majorVersion)) - } - - // Add regular microflow objects - for _, obj := range oc.Objects { - if objDoc := serializeMicroflowObject(obj); objDoc != nil { - objects = append(objects, objDoc) - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(oc.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: objects}, - } -} - -// serializeMicroflowObjectCollection serializes the object collection for nested collections (like in LoopedActivity). -// Note: Flows are NOT included here - in Mendix, all flows are stored at the top-level microflow, -// not inside nested ObjectCollections. SequenceFlow's container must be a Microflow, not a MicroflowObjectCollection. -func serializeMicroflowObjectCollection(oc *microflows.MicroflowObjectCollection) bson.D { - objects := bson.A{int32(3)} // Array type marker - - for _, obj := range oc.Objects { - if objDoc := serializeMicroflowObject(obj); objDoc != nil { - objects = append(objects, objDoc) - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(oc.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, - {Key: "Objects", Value: objects}, - } -} - -// serializeMicroflowObject serializes a single microflow object. -func serializeMicroflowObject(obj microflows.MicroflowObject) bson.D { - switch o := obj.(type) { - case *microflows.StartEvent: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$StartEvent"}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *microflows.EndEvent: - // Pristine Mx 9 EndEvents carry `ReturnValue` but not a synthetic trailing - // line break. Adding one can make Studio Pro reject list-return EndEvents - // with CE0117 even though mxcli's parser accepts the expression. - returnValue := o.ReturnValue - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$EndEvent"}, - {Key: "Documentation", Value: ""}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "ReturnValue", Value: returnValue}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - return doc - - case *microflows.ErrorEvent: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ErrorEvent"}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *microflows.ActionActivity: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ActionActivity"}, - } - if o.Action != nil { - doc = append(doc, bson.E{Key: "Action", Value: serializeMicroflowAction(o.Action)}) - } - bgColor := o.BackgroundColor - if bgColor == "" { - bgColor = "Default" - } - doc = append(doc, bson.E{Key: "AutoGenerateCaption", Value: o.AutoGenerateCaption}) - doc = append(doc, bson.E{Key: "BackgroundColor", Value: bgColor}) - doc = append(doc, bson.E{Key: "Caption", Value: o.Caption}) - doc = append(doc, bson.E{Key: "Disabled", Value: o.Disabled}) - doc = append(doc, bson.E{Key: "Documentation", Value: o.Documentation}) - doc = append(doc, bson.E{Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}) - doc = append(doc, bson.E{Key: "Size", Value: sizeToString(o.Size)}) - return doc - - case *microflows.ExclusiveSplit: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ExclusiveSplit"}, - {Key: "Caption", Value: o.Caption}, - {Key: "Documentation", Value: o.Documentation}, - {Key: "ErrorHandlingType", Value: string(o.ErrorHandlingType)}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - // Serialize SplitCondition - if o.SplitCondition != nil { - switch sc := o.SplitCondition.(type) { - case *microflows.ExpressionSplitCondition: - doc = append(doc, bson.E{Key: "SplitCondition", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(sc.ID))}, - {Key: "$Type", Value: "Microflows$ExpressionSplitCondition"}, - {Key: "Expression", Value: sc.Expression}, - }}) - case *microflows.RuleSplitCondition: - // Mendix nests the rule reference under a RuleCall sub-document - // whose Microflow field holds the rule's qualified name - // (rules share the microflow namespace). ParameterMappings are - // scoped inside RuleCall too — see parser_microflow.go. - ruleCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$RuleCall"}, - {Key: "Microflow", Value: sc.RuleQualifiedName}, - } - if len(sc.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(2)) // Array marker - for _, pm := range sc.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$RuleCallParameterMapping"}, - {Key: "Parameter", Value: pm.ParameterName}, - {Key: "Argument", Value: pm.Argument}, - } - mappings = append(mappings, mapping) - } - ruleCall = append(ruleCall, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - ruleCall = append(ruleCall, bson.E{Key: "ParameterMappings", Value: bson.A{int32(2)}}) - } - doc = append(doc, bson.E{Key: "SplitCondition", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(sc.ID))}, - {Key: "$Type", Value: "Microflows$RuleSplitCondition"}, - {Key: "RuleCall", Value: ruleCall}, - }}) - } - } - return doc - - case *microflows.ExclusiveMerge: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ExclusiveMerge"}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *microflows.InheritanceSplit: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$InheritanceSplit"}, - {Key: "Caption", Value: o.Caption}, - {Key: "Documentation", Value: o.Documentation}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(o.ErrorHandlingType), "Rollback")}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - {Key: "SplitVariableName", Value: o.VariableName}, - } - - case *microflows.LoopedActivity: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$LoopedActivity"}, - {Key: "ErrorHandlingType", Value: string(o.ErrorHandlingType)}, - } - // Serialize LoopSource (IterableList or WhileLoopCondition) - if o.LoopSource != nil { - switch ls := o.LoopSource.(type) { - case *microflows.IterableList: - loopSource := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ls.ID))}, - {Key: "$Type", Value: "Microflows$IterableList"}, - {Key: "ListVariableName", Value: ls.ListVariableName}, - {Key: "VariableName", Value: ls.VariableName}, - } - doc = append(doc, bson.E{Key: "LoopSource", Value: loopSource}) - case *microflows.WhileLoopCondition: - loopSource := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ls.ID))}, - {Key: "$Type", Value: "Microflows$WhileLoopCondition"}, - {Key: "WhileExpression", Value: ls.WhileExpression}, - } - doc = append(doc, bson.E{Key: "LoopSource", Value: loopSource}) - } - } - // Serialize nested ObjectCollection - if o.ObjectCollection != nil { - doc = append(doc, bson.E{Key: "ObjectCollection", Value: serializeMicroflowObjectCollection(o.ObjectCollection)}) - } - doc = append(doc, - bson.E{Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - bson.E{Key: "Size", Value: sizeToString(o.Size)}, - ) - return doc - - case *microflows.BreakEvent: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$BreakEvent"}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *microflows.ContinueEvent: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ContinueEvent"}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *microflows.Annotation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Annotation"}, - {Key: "Caption", Value: o.Caption}, - {Key: "RelativeMiddlePoint", Value: pointToString(o.Position)}, - {Key: "Size", Value: sizeToString(o.Size)}, - } - - case *model.UnknownElement: - // Write-through: serialize RawDoc back as-is so unknown activities - // are not silently dropped when the MPR is saved. - if o.RawDoc == nil { - return nil - } - return o.RawDoc - - default: - return nil - } -} - -// serializePoint serializes a Point to BSON (nested object format). -func serializePoint(pt model.Point) bson.D { - return bson.D{ - {Key: "$Type", Value: "Common$Point"}, - {Key: "X", Value: int64(pt.X)}, - {Key: "Y", Value: int64(pt.Y)}, - } -} - -// serializeSize serializes a Size to BSON (nested object format). -func serializeSize(sz model.Size) bson.D { - return bson.D{ - {Key: "$Type", Value: "Common$Size"}, - {Key: "Width", Value: int64(sz.Width)}, - {Key: "Height", Value: int64(sz.Height)}, - } -} - -// pointToString converts a Point to string format "X;Y" for microflows. -func pointToString(pt model.Point) string { - return fmt.Sprintf("%d;%d", pt.X, pt.Y) -} - -// sizeToString converts a Size to string format "Width;Height" for microflows. -func sizeToString(sz model.Size) string { - return fmt.Sprintf("%d;%d", sz.Width, sz.Height) -} - -// serializeStringTemplate serializes a Text to BSON as a Microflows$StringTemplate. -// This is used for LOG message templates, not Texts$Text. -func serializeStringTemplate(text *model.Text, params []string) bson.D { - // Get the text from the first translation (usually en_US) - var textValue string - for _, value := range text.Translations { - textValue = value - break - } - - // Build parameters array - var paramsVal any - if len(params) > 0 { - paramArr := bson.A{int32(3)} // Array with items marker - for _, p := range params { - paramArr = append(paramArr, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$TemplateParameter"}, - {Key: "Expression", Value: p}, - }) - } - paramsVal = paramArr - } else { - paramsVal = bson.A{int32(2)} // Empty array marker - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Parameters", Value: paramsVal}, - {Key: "Text", Value: textValue}, - } -} - -// serializeTextTemplate serializes a Text as a Microflows$TextTemplate with nested Texts$Text. -// This is required for ValidationFeedbackAction.FeedbackTemplate. -func serializeTextTemplate(text *model.Text, params []string) bson.D { - // Build parameters array - var paramsVal any - if len(params) > 0 { - paramArr := bson.A{int32(3)} // Array with items marker - for _, p := range params { - paramArr = append(paramArr, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$TemplateParameter"}, - {Key: "Expression", Value: p}, - }) - } - paramsVal = paramArr - } else { - paramsVal = bson.A{int32(2)} // Empty array marker - } - - // Build the nested Texts$Text object - textDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - } - if len(text.Translations) > 0 { - var transArray bson.A - transArray = append(transArray, int32(3)) // items marker (3 = has items) - // Sort language keys for deterministic output - langs := make([]string, 0, len(text.Translations)) - for lang := range text.Translations { - langs = append(langs, lang) - } - sort.Strings(langs) - for _, lang := range langs { - transArray = append(transArray, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: lang}, - {Key: "Text", Value: text.Translations[lang]}, - }) - } - textDoc = append(textDoc, bson.E{Key: "Items", Value: transArray}) - } else { - textDoc = append(textDoc, bson.E{Key: "Items", Value: bson.A{int32(2)}}) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$TextTemplate"}, - {Key: "Parameters", Value: paramsVal}, - {Key: "Text", Value: textDoc}, - } -} - -func (w *Writer) serializeNanoflow(nf *microflows.Nanoflow) ([]byte, error) { - // Determine project major version for version-specific serialization. - majorVersion := version.DefaultVersion().MajorVersion - if pv := w.reader.ProjectVersion(); pv != nil { - majorVersion = pv.MajorVersion - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(nf.ID))}, - {Key: "$Type", Value: "Microflows$Nanoflow"}, - {Key: "AllowedModuleRoles", Value: allowedModuleRolesArray(nf.AllowedModuleRoles)}, - {Key: "Documentation", Value: nf.Documentation}, - {Key: "Excluded", Value: nf.Excluded}, - } - - // Add Flows array (SequenceFlows and AnnotationFlows at root level) - flows := bson.A{int32(3)} // Array type marker - if nf.ObjectCollection != nil { - for _, flow := range nf.ObjectCollection.Flows { - flows = append(flows, serializeSequenceFlow(flow, majorVersion)) - } - for _, af := range nf.ObjectCollection.AnnotationFlows { - flows = append(flows, serializeAnnotationFlow(af, majorVersion)) - } - } - doc = append(doc, bson.E{Key: "Flows", Value: flows}) - - doc = append(doc, bson.E{Key: "MarkAsUsed", Value: nf.MarkAsUsed}) - - // Add return type - if nf.ReturnType != nil { - doc = append(doc, bson.E{Key: "MicroflowReturnType", Value: serializeMicroflowDataType(nf.ReturnType)}) - } - - doc = append(doc, bson.E{Key: "Name", Value: nf.Name}) - - // Add object collection (without flows — they're in Flows array) - if nf.ObjectCollection != nil { - doc = append(doc, bson.E{Key: "ObjectCollection", Value: serializeMicroflowObjectCollectionWithoutFlows(nf.ObjectCollection, nf.Parameters, majorVersion)}) - } - - // Parameters stored inside ObjectCollection.Objects, not as a separate key. - - return marshalUnitIDFirst(doc) -} - -// stringOrDefault returns the value if non-empty, otherwise the default. -func stringOrDefault(value, defaultValue string) string { - if value == "" { - return defaultValue - } - return value -} diff --git a/sdk/mpr/writer_microflow_action_items_test.go b/sdk/mpr/writer_microflow_action_items_test.go deleted file mode 100644 index 8fce7db631..0000000000 --- a/sdk/mpr/writer_microflow_action_items_test.go +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeCreateObjectActionItemsUseStorageListMarker(t *testing.T) { - action := µflows.CreateObjectAction{ - BaseElement: model.BaseElement{ID: "create-1"}, - EntityQualifiedName: "SampleModule.Order", - OutputVariable: "Order", - Commit: microflows.CommitTypeNo, - InitialMembers: []*microflows.MemberChange{ - { - BaseElement: model.BaseElement{ID: "member-1"}, - AttributeQualifiedName: "SampleModule.Order.Name", - Type: microflows.MemberChangeTypeSet, - Value: "'Sample'", - }, - }, - } - - doc := serializeMicroflowAction(action) - - items, ok := getBSONField(doc, "Items").(bson.A) - if !ok { - t.Fatalf("Items is %T, want bson.A", getBSONField(doc, "Items")) - } - if len(items) != 2 { - t.Fatalf("Items length = %d, want marker plus one item", len(items)) - } - if marker, ok := items[0].(int32); !ok || marker != 2 { - t.Fatalf("Items marker = %#v, want int32(2)", items[0]) - } -} - -func TestSerializeChangeObjectActionItemsUseStorageListMarkerAndDefaultErrorHandling(t *testing.T) { - action := µflows.ChangeObjectAction{ - BaseElement: model.BaseElement{ID: "change-1"}, - ChangeVariable: "Order", - Commit: microflows.CommitTypeNo, - Changes: []*microflows.MemberChange{ - { - BaseElement: model.BaseElement{ID: "member-1"}, - AttributeQualifiedName: "SampleModule.Order.Status", - Type: microflows.MemberChangeTypeSet, - Value: "'Processed'", - }, - }, - } - - doc := serializeMicroflowAction(action) - - if got := getBSONField(doc, "ErrorHandlingType"); got != "Rollback" { - t.Fatalf("ErrorHandlingType = %#v, want Rollback", got) - } - items, ok := getBSONField(doc, "Items").(bson.A) - if !ok { - t.Fatalf("Items is %T, want bson.A", getBSONField(doc, "Items")) - } - if len(items) != 2 { - t.Fatalf("Items length = %d, want marker plus one item", len(items)) - } - if marker, ok := items[0].(int32); !ok || marker != 2 { - t.Fatalf("Items marker = %#v, want int32(2)", items[0]) - } -} - -func TestSerializeCommitActionAlwaysWritesDefaultErrorHandling(t *testing.T) { - action := µflows.CommitObjectsAction{ - BaseElement: model.BaseElement{ID: "commit-1"}, - CommitVariable: "Order", - } - - doc := serializeMicroflowAction(action) - - if got := getBSONField(doc, "ErrorHandlingType"); got != "Rollback" { - t.Fatalf("ErrorHandlingType = %#v, want Rollback", got) - } -} diff --git a/sdk/mpr/writer_microflow_actions.go b/sdk/mpr/writer_microflow_actions.go deleted file mode 100644 index 2299c55693..0000000000 --- a/sdk/mpr/writer_microflow_actions.go +++ /dev/null @@ -1,1841 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" -) - -// serializeMicroflowAction serializes a microflow action to BSON. -// -// IMPORTANT: Mendix uses different "storage names" vs "qualified names" for many types. -// The $Type field in BSON must use the STORAGE NAME, not the qualified name from the -// TypeScript SDK or metamodel documentation. Examples: -// -// Qualified Name (SDK/docs) Storage Name (BSON $Type) -// ------------------------- ------------------------- -// CreateObjectAction CreateChangeAction -// ChangeObjectAction ChangeAction -// DeleteObjectAction DeleteAction -// CommitObjectsAction CommitAction -// RollbackObjectAction RollbackAction -// AggregateListAction AggregateAction -// ListOperationAction ListOperationsAction -// ShowPageAction ShowFormAction (Page was originally called Form) -// ClosePageAction CloseFormAction (Page was originally called Form) -// -// Using the wrong type name causes "TypeCacheUnknownTypeException" when opening in Studio Pro. -// When adding new action types, check existing MPR files or reflection data for the storage name. -func serializeMicroflowAction(action microflows.MicroflowAction) bson.D { - switch a := action.(type) { - case *microflows.CastAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CastAction"}, - {Key: "ErrorHandlingType", Value: "Rollback"}, - {Key: "VariableName", Value: a.OutputVariable}, - } - - case *microflows.CreateVariableAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CreateVariableAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "VariableName", Value: a.VariableName}, - {Key: "InitialValue", Value: a.InitialValue}, - } - if a.DataType != nil { - doc = append(doc, bson.E{Key: "VariableType", Value: serializeMicroflowDataType(a.DataType)}) - } - return doc - - case *microflows.ChangeVariableAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ChangeVariableAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ChangeVariableName", Value: a.VariableName}, - {Key: "Value", Value: a.Value}, - } - - case *microflows.CreateObjectAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CreateChangeAction"}, // storageName differs from qualifiedName - {Key: "Commit", Value: string(a.Commit)}, - } - // Entity is BY_NAME_REFERENCE - use qualified name string - if a.EntityQualifiedName != "" { - doc = append(doc, bson.E{Key: "Entity", Value: a.EntityQualifiedName}) - } - doc = append(doc, bson.E{Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}) - // Serialize Items (ChangeActionItem) for InitialMembers. Mendix stores - // this list with storage-list marker 2, not with the item count. - items := bson.A{int32(2)} - for _, change := range a.InitialMembers { - item := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(change.ID))}, - {Key: "$Type", Value: "Microflows$ChangeActionItem"}, - } - // Association or Attribute as BY_NAME_REFERENCE (mutually exclusive) - if change.AssociationQualifiedName != "" { - item = append(item, bson.E{Key: "Association", Value: change.AssociationQualifiedName}) - } else { - item = append(item, bson.E{Key: "Association", Value: ""}) // Empty for attributes - if change.AttributeQualifiedName != "" { - item = append(item, bson.E{Key: "Attribute", Value: change.AttributeQualifiedName}) - } - } - item = append(item, bson.E{Key: "Type", Value: string(change.Type)}) - item = append(item, bson.E{Key: "Value", Value: change.Value}) - items = append(items, item) - } - doc = append(doc, bson.E{Key: "Items", Value: items}) - // RefreshInClient is required - doc = append(doc, bson.E{Key: "RefreshInClient", Value: a.RefreshInClient}) - // outputVariableName has storageName "VariableName" - doc = append(doc, bson.E{Key: "VariableName", Value: a.OutputVariable}) - return doc - - case *microflows.ChangeObjectAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ChangeAction"}, // storageName differs from qualifiedName - {Key: "ChangeVariableName", Value: a.ChangeVariable}, - {Key: "Commit", Value: string(a.Commit)}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - } - // Serialize Items (ChangeActionItem). Mendix stores this list with - // storage-list marker 2, not with the item count. - items := bson.A{int32(2)} - for _, change := range a.Changes { - item := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(change.ID))}, - {Key: "$Type", Value: "Microflows$ChangeActionItem"}, - } - // Association or Attribute as BY_NAME_REFERENCE (mutually exclusive) - if change.AssociationQualifiedName != "" { - item = append(item, bson.E{Key: "Association", Value: change.AssociationQualifiedName}) - } else { - item = append(item, bson.E{Key: "Association", Value: ""}) // Empty for attributes - if change.AttributeQualifiedName != "" { - item = append(item, bson.E{Key: "Attribute", Value: change.AttributeQualifiedName}) - } - } - item = append(item, bson.E{Key: "Type", Value: string(change.Type)}) - item = append(item, bson.E{Key: "Value", Value: change.Value}) - items = append(items, item) - } - doc = append(doc, bson.E{Key: "Items", Value: items}) - doc = append(doc, bson.E{Key: "RefreshInClient", Value: a.RefreshInClient}) - return doc - - case *microflows.CommitObjectsAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CommitAction"}, - {Key: "CommitVariableName", Value: a.CommitVariable}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "RefreshInClient", Value: a.RefreshInClient}, - {Key: "WithEvents", Value: a.WithEvents}, - } - - case *microflows.DeleteObjectAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$DeleteAction"}, - {Key: "DeleteVariableName", Value: a.DeleteVariable}, - {Key: "RefreshInClient", Value: a.RefreshInClient}, - } - // Studio Pro writes no ErrorHandlingType on a delete, so the key is added - // only when the author asked for one — an un-annotated delete keeps the - // document it has always had. - if a.ErrorHandlingType != "" { - doc = append(doc, bson.E{Key: "ErrorHandlingType", Value: string(a.ErrorHandlingType)}) - } - return doc - - case *microflows.RollbackObjectAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$RollbackAction"}, - {Key: "RollbackVariableName", Value: a.RollbackVariable}, - {Key: "RefreshInClient", Value: a.RefreshInClient}, - } - - case *microflows.LogMessageAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$LogMessageAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "IncludeLatestStackTrace", Value: false}, - {Key: "Level", Value: string(a.LogLevel)}, - {Key: "Node", Value: a.LogNodeName}, // Already stored as expression (e.g., "'TEST'") - } - if a.MessageTemplate != nil { - doc = append(doc, bson.E{Key: "MessageTemplate", Value: serializeStringTemplate(a.MessageTemplate, a.TemplateParameters)}) - } - return doc - - case *microflows.CallExternalAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CallExternalAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ConsumedODataService", Value: a.ConsumedODataService}, - {Key: "Name", Value: a.Name}, - {Key: "VariableName", Value: a.ResultVariableName}, - } - // Issue: Mendix's CallExternalAction.Check raises CE7269 ("return type - // for remote action has changed") when the stored VariableDataType - // doesn't match the cached schema's return type. Always emit - // VariableDataType when we know the schema kind — the executor - // resolves it from the consumed service's cached $metadata. - if a.ResultDataType != "" { - doc = append(doc, bson.E{Key: "VariableDataType", Value: serializeExternalActionReturnType(a.ResultDataType, a.ResultEntity)}) - } - // Serialize parameter mappings - if len(a.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(3)) // Array marker (storageListType 3) - for _, pm := range a.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$ExternalActionParameterMapping"}, - {Key: "ParameterName", Value: pm.ParameterName}, - {Key: "Argument", Value: pm.Argument}, - {Key: "CanBeEmpty", Value: pm.CanBeEmpty}, - } - // generated/metamodel declares ParameterType without omitempty. - // Omitting it is CE7252 + a CE0117 per argument. - if pm.ParameterDataType != "" { - mapping = append(mapping, bson.E{ - Key: "ParameterType", - Value: serializeExternalActionReturnType(pm.ParameterDataType, pm.ParameterEntity), - }) - } - mappings = append(mappings, mapping) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - doc = append(doc, bson.E{Key: "ParameterMappings", Value: bson.A{int32(3)}}) - } - return doc - - case *microflows.MicroflowCallAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowCallAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - } - // Serialize nested MicroflowCall structure - if a.MicroflowCall != nil { - mfCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.MicroflowCall.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowCall"}, - {Key: "Microflow", Value: a.MicroflowCall.Microflow}, - } - // Serialize parameter mappings within MicroflowCall - if len(a.MicroflowCall.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(2)) // Array marker - for _, pm := range a.MicroflowCall.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowCallParameterMapping"}, - {Key: "Argument", Value: pm.Argument}, - {Key: "Parameter", Value: pm.Parameter}, - } - mappings = append(mappings, mapping) - } - mfCall = append(mfCall, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - mfCall = append(mfCall, bson.E{Key: "ParameterMappings", Value: bson.A{int32(2)}}) // Empty array with marker - } - mfCall = append(mfCall, bson.E{Key: "QueueSettings", Value: serializeQueueSettings(a.MicroflowCall.QueueSettings)}) - doc = append(doc, bson.E{Key: "MicroflowCall", Value: mfCall}) - } - doc = append(doc, - bson.E{Key: "ResultVariableName", Value: a.ResultVariableName}, - bson.E{Key: "UseReturnVariable", Value: a.UseReturnVariable}, - ) - return doc - - case *microflows.NanoflowCallAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$NanoflowCallAction"}, - // Mendix metamodel defaults to "Rollback" for all call actions, including nanoflow calls. - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "UseReturnVariable", Value: a.UseReturnVariable}, - } - if a.NanoflowCall != nil { - nfCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.NanoflowCall.ID))}, - {Key: "$Type", Value: "Microflows$NanoflowCall"}, - {Key: "Nanoflow", Value: a.NanoflowCall.Nanoflow}, - } - if len(a.NanoflowCall.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(2)) - for _, pm := range a.NanoflowCall.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$NanoflowCallParameterMapping"}, - {Key: "Parameter", Value: pm.Parameter}, - {Key: "Argument", Value: pm.Argument}, - } - mappings = append(mappings, mapping) - } - nfCall = append(nfCall, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - nfCall = append(nfCall, bson.E{Key: "ParameterMappings", Value: bson.A{int32(2)}}) - } - doc = append(doc, bson.E{Key: "NanoflowCall", Value: nfCall}) - } - return doc - - case *microflows.JavaActionCallAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$JavaActionCallAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "JavaAction", Value: a.JavaAction}, - {Key: "QueueSettings", Value: serializeQueueSettings(a.QueueSettings)}, - {Key: "ResultVariableName", Value: a.ResultVariableName}, - {Key: "UseReturnVariable", Value: a.UseReturnVariable}, - } - // Serialize parameter mappings - if len(a.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(2)) // Array marker - for _, pm := range a.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$JavaActionParameterMapping"}, - {Key: "Parameter", Value: pm.Parameter}, - } - // Serialize Value (CodeActionParameterValue) - if pm.Value != nil { - mapping = append(mapping, bson.E{Key: "Value", Value: serializeCodeActionParameterValue(pm.Value)}) - } - mappings = append(mappings, mapping) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - doc = append(doc, bson.E{Key: "ParameterMappings", Value: bson.A{int32(2)}}) // Empty array with marker - } - return doc - - case *microflows.JavaScriptActionCallAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$JavaScriptActionCallAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "JavaScriptAction", Value: a.JavaScriptAction}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "UseReturnVariable", Value: a.UseReturnVariable}, - } - // Serialize parameter mappings - if len(a.ParameterMappings) > 0 { - var mappings bson.A - mappings = append(mappings, int32(2)) // Array marker - for _, pm := range a.ParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Microflows$JavaScriptActionParameterMapping"}, - {Key: "Parameter", Value: pm.Parameter}, - } - // Serialize ParameterValue (CodeActionParameterValue) — JS uses "ParameterValue" key, not "Value" - if pm.Value != nil { - mapping = append(mapping, bson.E{Key: "ParameterValue", Value: serializeCodeActionParameterValue(pm.Value)}) - } - mappings = append(mappings, mapping) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: mappings}) - } else { - doc = append(doc, bson.E{Key: "ParameterMappings", Value: bson.A{int32(2)}}) // Empty array with marker - } - return doc - - case *microflows.RetrieveAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$RetrieveAction"}, - // Only an explicit ON ERROR clause moves this off the literal that has - // always been written here (ako/CapTrackV3 FINDINGS §11). - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ResultVariableName", Value: a.OutputVariable}, // storageName differs from qualifiedName - } - if a.Source != nil { - switch src := a.Source.(type) { - case *microflows.DatabaseRetrieveSource: - doc = append(doc, bson.E{Key: "RetrieveSource", Value: serializeDatabaseRetrieveSource(src)}) - case *microflows.AssociationRetrieveSource: - doc = append(doc, bson.E{Key: "RetrieveSource", Value: serializeAssociationRetrieveSource(src)}) - } - } - return doc - - case *microflows.ListOperationAction: - return serializeListOperationAction(a) - - case *microflows.AggregateListAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$AggregateAction"}, // storageName differs from qualifiedName - {Key: "ErrorHandlingType", Value: "Rollback"}, - } - doc = append(doc, bson.E{Key: "AggregateFunction", Value: string(a.Function)}) - doc = append(doc, bson.E{Key: "AggregateVariableName", Value: a.InputVariable}) // storageName for inputListVariableName - if a.UseExpression { - doc = append(doc, bson.E{Key: "UseExpression", Value: true}) - doc = append(doc, bson.E{Key: "Expression", Value: a.Expression}) - } - // Attribute is BY_NAME_REFERENCE, and is written even when unused: every - // Studio Pro reference document carries it as "". Omitting it made a - // freshly described Studio Pro aggregate rewrite on its first execution - // for no semantic reason. - doc = append(doc, bson.E{Key: "Attribute", Value: a.AttributeQualifiedName}) - // Reduce's fold. Written for the functions a reference document shows - // Mendix storing them on, and otherwise only to carry back what the - // stored document already had (#1004). - if a.Function.WritesReduceProperties() || a.ReduceInitialValue != "" || a.ReduceReturnType != nil { - doc = append(doc, bson.E{Key: "ReduceInitialValueExpression", Value: a.ReduceInitialValue}) - doc = append(doc, bson.E{Key: "ReduceReturnDataType", Value: serializeMicroflowDataType(a.ReduceReturnType)}) - } - doc = append(doc, bson.E{Key: "VariableName", Value: a.OutputVariable}) // storageName for outputVariableName - return doc - - case *microflows.CreateListAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CreateListAction"}, - {Key: "ErrorHandlingType", Value: "Rollback"}, - } - // Entity is BY_NAME_REFERENCE - if a.EntityQualifiedName != "" { - doc = append(doc, bson.E{Key: "Entity", Value: a.EntityQualifiedName}) - } - doc = append(doc, bson.E{Key: "VariableName", Value: a.OutputVariable}) // storageName for outputVariableName - return doc - - case *microflows.ChangeListAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ChangeListAction"}, - {Key: "ErrorHandlingType", Value: "Rollback"}, - {Key: "ChangeVariableName", Value: a.ChangeVariable}, - {Key: "Type", Value: string(a.Type)}, - } - if a.Value != "" { - doc = append(doc, bson.E{Key: "Value", Value: a.Value}) - } - return doc - - case *microflows.ShowPageAction: - // ShowFormAction uses FormSettings with Form as BY_NAME_REFERENCE (not Page as BY_ID_REFERENCE) - // This is the modern format used by Mendix 10+ - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ShowFormAction"}, // storageName differs from qualifiedName - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - } - - // FormSettings contains Form (BY_NAME_REFERENCE) and ParameterMappings - formSettingsID := a.FormSettingsID - if formSettingsID == "" { - formSettingsID = model.ID(generateUUID()) - } - - // Build ParameterMappings inside FormSettings. Mendix storage lists use - // a marker as the first element; it is not the number of mappings. - paramMappings := bson.A{int32(2)} - for _, pm := range a.PageParameterMappings { - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(pm.ID))}, - {Key: "$Type", Value: "Forms$PageParameterMapping"}, // Forms$, not Microflows$ - {Key: "Argument", Value: pm.Argument}, - {Key: "Parameter", Value: pm.Parameter}, // BY_NAME_REFERENCE - {Key: "Variable", Value: emptyPageVariable()}, - } - paramMappings = append(paramMappings, mapping) - } - - // Build FormSettings - formSettings := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(formSettingsID))}, - {Key: "$Type", Value: "Forms$FormSettings"}, - {Key: "Form", Value: a.PageName}, // BY_NAME_REFERENCE (page qualified name) - {Key: "ParameterMappings", Value: paramMappings}, - {Key: "TitleOverride", Value: titleOverrideValue(a.OverridePageTitle)}, - } - doc = append(doc, bson.E{Key: "FormSettings", Value: formSettings}) - doc = append(doc, bson.E{Key: "NumberOfPagesToClose", Value: ""}) - - return doc - - case *microflows.ClosePageAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CloseFormAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - // The storage name is "NumberOfPages" (matches the metamodel/codec). The - // old "NumberOfPagesToClose" was tolerated by Mendix <= 11.6 but rejected - // by 11.12 (CE0117 "Error(s) in expression" — the real NumberOfPages field - // is then absent and defaults to an empty expression). - {Key: "NumberOfPages", Value: int32(a.NumberOfPages)}, - } - return doc - - case *microflows.ShowHomePageAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ShowHomePageAction"}, - {Key: "ErrorHandlingType", Value: "Rollback"}, - } - return doc - - case *microflows.ShowMessageAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ShowMessageAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "Type", Value: string(a.Type)}, - {Key: "Blocking", Value: a.Blocking}, - {Key: "Template", Value: serializeTextTemplate(a.Template, a.TemplateParameters)}, - } - return doc - - case *microflows.DownloadFileAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$DownloadFileAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "FileDocumentVariableName", Value: a.FileDocument}, - {Key: "ShowInBrowser", Value: a.ShowInBrowser}, - } - - case *microflows.ValidationFeedbackAction: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ValidationFeedbackAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ValidationVariableName", Value: a.ObjectVariable}, - } - // Always write both Attribute and Association fields — they are mutually - // exclusive but Mendix expects both present (empty string when not set). - // Follows the same pattern as ChangeObjectAction serialization. - if a.AssociationName != "" { - doc = append(doc, bson.E{Key: "Association", Value: a.AssociationName}) - doc = append(doc, bson.E{Key: "Attribute", Value: ""}) - } else { - doc = append(doc, bson.E{Key: "Association", Value: ""}) - doc = append(doc, bson.E{Key: "Attribute", Value: a.AttributeName}) - } - // Serialize FeedbackTemplate as Microflows$TextTemplate - if a.Template != nil { - doc = append(doc, bson.E{Key: "FeedbackTemplate", Value: serializeTextTemplate(a.Template, a.TemplateParameters)}) - } - return doc - - case *microflows.RestCallAction: - return serializeRestCallAction(a) - - case *microflows.WebServiceCallAction: - return serializeWebServiceCallAction(a) - - case *microflows.RestOperationCallAction: - return serializeRestOperationCallAction(a) - - case *microflows.ExecuteDatabaseQueryAction: - return serializeExecuteDatabaseQueryAction(a) - - case *microflows.ImportXmlAction: - return serializeImportXmlAction(a) - - case *microflows.ExportXmlAction: - return serializeExportXmlAction(a) - - case *microflows.TransformJsonAction: - return serializeTransformJsonAction(a) - - // Workflow actions - case *microflows.WorkflowCallAction: - return serializeWorkflowCallAction(a) - case *microflows.GetWorkflowDataAction: - return serializeGetWorkflowDataAction(a) - case *microflows.GetWorkflowsAction: - return serializeGetWorkflowsAction(a) - case *microflows.GetWorkflowActivityRecordsAction: - return serializeGetWorkflowActivityRecordsAction(a) - case *microflows.WorkflowOperationAction: - return serializeWorkflowOperationAction(a) - case *microflows.SetTaskOutcomeAction: - return serializeSetTaskOutcomeAction(a) - case *microflows.OpenUserTaskAction: - return serializeOpenUserTaskAction(a) - case *microflows.NotifyWorkflowAction: - return serializeNotifyWorkflowAction(a) - case *microflows.OpenWorkflowAction: - return serializeOpenWorkflowAction(a) - case *microflows.LockWorkflowAction: - return serializeLockWorkflowAction(a) - case *microflows.UnlockWorkflowAction: - return serializeUnlockWorkflowAction(a) - - default: - return nil - } -} - -func emptyPageVariable() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$PageVariable"}, - {Key: "PageParameter", Value: ""}, - {Key: "SnippetParameter", Value: ""}, - {Key: "UseAllPages", Value: false}, - {Key: "Widget", Value: ""}, - } -} - -// titleOverrideValue renders FormSettings.TitleOverride: the page's own title is -// used unless the action overrides it, and "no override" is nil — NOT an empty -// Microflows$TextTemplate. -// -// An empty template is not the absence of an override, it *is* an override, to the -// empty string: every popup opened by such an action showed a blank caption with -// only the close button (mendixlabs/mxcli#812). The writers had been emitting one -// unconditionally on a mistaken "must be non-nil" reading of PR #338 / issue #295 — -// which was about Forms$PageVariable, a different field. This repo's own -// .claude/skills/debug-bson.md already documented the correct Forms$FormSettings -// shape as `TitleOverride: nil`. -// -// The same bug hid a second one: an override the author *did* ask for -// (`show page M.P with title = 'X'`) was dropped, because the empty template was -// written regardless of OverridePageTitle. Both cases now round-trip. -func titleOverrideValue(override *model.Text) any { - if override == nil { - return nil - } - return serializeTextTemplate(override, nil) -} - -// emptyTextTemplate returns an empty Microflows$TextTemplate embedded object. -// Retained for callers that genuinely need an initialized template; do NOT use it -// for TitleOverride — see titleOverrideValue. -func emptyTextTemplate() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$TextTemplate"}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - {Key: "Text", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(2)}}, - }}, - } -} - -// serializeRestCallAction serializes a RestCallAction to BSON. -// Storage name is "Microflows$RestCallAction" (same as qualified name). -func serializeRestCallAction(a *microflows.RestCallAction) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$RestCallAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ErrorResultHandlingType", Value: "HttpResponse"}, - } - - // Serialize HttpConfiguration - if a.HttpConfiguration != nil { - httpConfig := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.HttpConfiguration.ID))}, - {Key: "$Type", Value: "Microflows$HttpConfiguration"}, - {Key: "ClientCertificate", Value: ""}, - {Key: "CustomLocation", Value: ""}, - } - // Serialize CustomLocationTemplate as StringTemplate - if a.HttpConfiguration.LocationTemplate != "" { - customLocTemplate := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Text", Value: a.HttpConfiguration.LocationTemplate}, - } - // Add parameters if present - each must be wrapped in TemplateParameter object - if len(a.HttpConfiguration.LocationParams) > 0 { - var params bson.A - params = append(params, int32(2)) // Array marker - for _, p := range a.HttpConfiguration.LocationParams { - templateParam := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$TemplateParameter"}, - {Key: "Expression", Value: p}, - } - params = append(params, templateParam) - } - customLocTemplate = append(customLocTemplate, bson.E{Key: "Parameters", Value: params}) - } else { - customLocTemplate = append(customLocTemplate, bson.E{Key: "Parameters", Value: bson.A{int32(2)}}) - } - httpConfig = append(httpConfig, bson.E{Key: "CustomLocationTemplate", Value: customLocTemplate}) - } - httpConfig = append(httpConfig, - bson.E{Key: "HttpAuthenticationPassword", Value: a.HttpConfiguration.Password}, - bson.E{Key: "HttpAuthenticationUserName", Value: a.HttpConfiguration.Username}, - ) - // Serialize HttpHeaderEntries - if len(a.HttpConfiguration.CustomHeaders) > 0 { - var headers bson.A - headers = append(headers, int32(2)) // Array marker - for _, h := range a.HttpConfiguration.CustomHeaders { - header := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$HttpHeaderEntry"}, - {Key: "Key", Value: h.Name}, - {Key: "Value", Value: h.Value}, - } - headers = append(headers, header) - } - httpConfig = append(httpConfig, bson.E{Key: "HttpHeaderEntries", Value: headers}) - } else { - httpConfig = append(httpConfig, bson.E{Key: "HttpHeaderEntries", Value: bson.A{int32(2)}}) - } - httpConfig = append(httpConfig, - bson.E{Key: "HttpMethod", Value: string(a.HttpConfiguration.HttpMethod)}, - bson.E{Key: "OverrideLocation", Value: true}, - bson.E{Key: "UseHttpAuthentication", Value: a.HttpConfiguration.UseAuthentication}, - ) - doc = append(doc, bson.E{Key: "HttpConfiguration", Value: httpConfig}) - } - - doc = append(doc, bson.E{Key: "ProxyConfiguration", Value: nil}) - - // Serialize RequestHandling - if a.RequestHandling != nil { - doc = append(doc, bson.E{Key: "RequestHandling", Value: serializeRestRequestHandling(a.RequestHandling)}) - } - - // RequestHandlingType and RequestProxyType are at action level. The type must - // agree with the sub-element: it was hardcoded to "Custom", which is wrong for - // a binary body. Only the Binary case is derived — the others are unchanged, - // having no measured Studio Pro reference. - requestHandlingType := restRequestHandlingTypeOf(a.RequestHandling) - doc = append(doc, - bson.E{Key: "RequestHandlingType", Value: requestHandlingType}, - bson.E{Key: "RequestProxyType", Value: "DefaultProxy"}, - ) - - // Serialize ResultHandling - resultHandlingType := "String" // default - if a.ResultHandling != nil { - doc = append(doc, bson.E{Key: "ResultHandling", Value: serializeRestResultHandling(a.ResultHandling, a.OutputVariable)}) - switch a.ResultHandling.(type) { - case *microflows.ResultHandlingString: - resultHandlingType = "String" - case *microflows.ResultHandlingHttpResponse: - resultHandlingType = "HttpResponse" - case *microflows.ResultHandlingMapping: - resultHandlingType = "Mapping" - case *microflows.ResultHandlingFileDocument: - resultHandlingType = "FileDocument" - case *microflows.ResultHandlingNone: - resultHandlingType = "None" - } - } - doc = append(doc, bson.E{Key: "ResultHandlingType", Value: resultHandlingType}) - - // Timeout - if a.TimeoutExpression != "" { - doc = append(doc, - bson.E{Key: "TimeOutExpression", Value: a.TimeoutExpression}, - bson.E{Key: "UseRequestTimeOut", Value: true}, - ) - } else { - doc = append(doc, - bson.E{Key: "TimeOutExpression", Value: "300"}, - bson.E{Key: "UseRequestTimeOut", Value: true}, - ) - } - - return doc -} - -func serializeWebServiceCallAction(a *microflows.WebServiceCallAction) bson.D { - if len(a.RawBSON) > 0 { - var raw bson.D - if err := bson.Unmarshal(a.RawBSON, &raw); err == nil { - return raw - } - } - - // ServiceName is the WSDL , which Mendix resolves the - // operation within — NOT the local part of the qualified document name. The - // executor reads the real one off the imported service document. Deriving it - // (the fallback here, and what this writer always did) is right only when the - // document happens to be named after the service; otherwise the call fails - // with CE0386 "Operation … does not exist in consumed web service …". - serviceName := a.ServiceName - if serviceName == "" { - serviceName = string(a.ServiceID) - if idx := strings.LastIndex(serviceName, "."); idx >= 0 { - serviceName = serviceName[idx+1:] - } - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$CallWebServiceAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "HttpConfiguration", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$HttpConfiguration"}, - {Key: "ClientCertificate", Value: ""}, - {Key: "CustomLocation", Value: ""}, - {Key: "CustomLocationTemplate", Value: nil}, - {Key: "HttpAuthenticationPassword", Value: ""}, - {Key: "HttpAuthenticationUserName", Value: ""}, - {Key: "HttpHeaderEntries", Value: bson.A{int32(3)}}, - {Key: "HttpMethod", Value: "Post"}, - {Key: "OverrideLocation", Value: false}, - {Key: "UseHttpAuthentication", Value: false}, - }}, - // ImportedService is a BY_NAME_REFERENCE qualified name string, not a binary UUID. - {Key: "ImportedService", Value: string(a.ServiceID)}, - {Key: "IsValidationRequired", Value: false}, - } - - // NewResultHandling uses Microflows$ResultHandling (same type as REST result handling). - bind := a.OutputVariable != "" - resultHandling := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: bind}, - } - if a.ReceiveMappingID != "" { - // ReturnValueMapping is a BY_NAME_REFERENCE string, not a binary UUID. - resultHandling = append(resultHandling, bson.E{Key: "ImportMappingCall", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ImportMappingCall"}, - {Key: "Commit", Value: "YesWithoutEvents"}, - // Xml, not Json: a SOAP response IS XML. Studio Pro writes "Xml" - // here in both reference calls that carry an import mapping - // (ako/TestApp, Clients.GetOrders and GetCustomerOrders, 11.14.0). - // This is the receive side only — the REST and import-from-mapping - // ImportMappingCalls elsewhere in this file are unrelated. - {Key: "ContentType", Value: "Xml"}, - {Key: "ForceSingleOccurrence", Value: false}, - {Key: "ObjectHandlingBackup", Value: "Create"}, - {Key: "ParameterVariableName", Value: ""}, - {Key: "Range", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ConstantRange"}, - {Key: "SingleObject", Value: true}, - }}, - {Key: "ReturnValueMapping", Value: string(a.ReceiveMappingID)}, - }}) - } else { - resultHandling = append(resultHandling, bson.E{Key: "ImportMappingCall", Value: nil}) - } - // VariableType is the type the call RETURNS — the entity the receive mapping - // produces. VoidType says it returns nothing, which contradicts the mapping - // (CE0243) and makes assigning the result an error too (CE0366). It stays the - // fallback for a mapping mxcli could not resolve. - variableType := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$VoidType"}, - } - if a.ResultEntity != "" { - variableType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: a.ResultEntity}, - } - } - resultHandling = append(resultHandling, - bson.E{Key: "ResultVariableName", Value: a.OutputVariable}, - bson.E{Key: "VariableType", Value: variableType}, - ) - doc = append(doc, bson.E{Key: "NewResultHandling", Value: resultHandling}) - - doc = append(doc, bson.E{Key: "OperationName", Value: a.OperationName}) - doc = append(doc, bson.E{Key: "ProxyConfiguration", Value: nil}) - - // RequestBodyHandling holds EITHER the operation's arguments or an export - // mapping — one polymorphic child, never both, which is why the executor - // refuses a statement asking for each (MDL-SOAP01). - // - // This used to be an unconditional empty SimpleRequestHandling. Both halves - // of that were wrong against ako/TestApp: an operation taking parameters - // needs them (CE0178 "Body parameter mapping needs to be refreshed"), and a - // send mapping is a Microflows$MappingRequestHandling — NOT the - // Mendix$AdvancedRequestHandling this comment used to name, a type that - // appears in none of the three reference documents. Writing Simple regardless - // dropped the mapping silently and gave CE0369 "Cannot use simple request - // body, as the operation's body is complex". - doc = append(doc, bson.E{Key: "RequestBodyHandling", Value: webServiceRequestBody(a)}) - - // RequestHeaderHandling is always SimpleRequestHandling: MDL cannot author - // SOAP headers, and all three reference calls carry the bare form. - doc = append(doc, bson.E{Key: "RequestHeaderHandling", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$SimpleRequestHandling"}, - {Key: "NullValueOption", Value: "LeaveOutElement"}, - {Key: "ParameterMappings", Value: bson.A{int32(2)}}, - }}) - - doc = append(doc, bson.E{Key: "RequestProxyType", Value: "DefaultProxy"}) - doc = append(doc, bson.E{Key: "ServiceName", Value: serviceName}) - doc = append(doc, - bson.E{Key: "TimeOutExpression", Value: stringOrDefault(a.TimeoutExpression, "300")}, - bson.E{Key: "UseRequestTimeOut", Value: true}, - ) - return doc -} - -// webServiceRequestBody builds a SOAP call's RequestBodyHandling — the arguments -// form or the export-mapping form. Mirrors -// modelsdkbackend.webServiceRequestBodyToGen key for key. -func webServiceRequestBody(a *microflows.WebServiceCallAction) bson.D { - if a.SendMappingID != "" { - // MappingId / MappingVariableName are the STORAGE names. modelsdk/gen - // binds the same two properties as Mapping and - // MappingArgumentVariableName (its key audit lists both), and a document - // written under those is one mxbuild tolerates and Studio Pro cannot - // open — so the legacy writer, which names keys directly, is the easier - // of the two engines to get right here. - contentType := a.SendMappingContentType - if contentType == "" { - // What Studio Pro wrote on the one reference document - // (ako/TestApp Clients.SaveOrder) — surprising on an XML protocol, - // hence preserved on a rewrite rather than derived. - contentType = "Json" - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$MappingRequestHandling"}, - {Key: "ContentType", Value: contentType}, - {Key: "MappingId", Value: string(a.SendMappingID)}, - {Key: "MappingVariableName", Value: a.SendMappingVariable}, - } - } - - // Marker 2, measured on all three reference calls. - mappings := bson.A{int32(2)} - for _, arg := range a.Arguments { - mappings = append(mappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$WebServiceOperationSimpleParameterMapping"}, - {Key: "Argument", Value: arg.Expression}, - {Key: "IsChecked", Value: arg.Checked}, - // "" in both reference mappings; what fills it is unmeasured, so it - // is written empty rather than guessed at from the argument's name. - {Key: "ParameterName", Value: ""}, - {Key: "ParameterPath", Value: arg.Path}, - }) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$SimpleRequestHandling"}, - {Key: "NullValueOption", Value: "LeaveOutElement"}, - {Key: "ParameterMappings", Value: mappings}, - } -} - -// serializeRestOperationCallAction serializes a Microflows$RestOperationCallAction to BSON. -// Note: RestOperationCallAction does not support custom ErrorHandlingType (CE6035). -func serializeRestOperationCallAction(a *microflows.RestOperationCallAction) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$RestOperationCallAction"}, - {Key: "Operation", Value: a.Operation}, - } - - // OutputVariable - if a.OutputVariable != nil { - ov := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.OutputVariable.ID))}, - {Key: "$Type", Value: "Microflows$OutputVariable"}, - {Key: "VariableName", Value: a.OutputVariable.VariableName}, - } - doc = append(doc, bson.E{Key: "OutputVariable", Value: ov}) - } else { - doc = append(doc, bson.E{Key: "OutputVariable", Value: nil}) - } - - // BodyVariable - if a.BodyVariable != nil { - bv := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.BodyVariable.ID))}, - {Key: "$Type", Value: "Microflows$BodyVariable"}, - {Key: "VariableName", Value: a.BodyVariable.VariableName}, - } - doc = append(doc, bson.E{Key: "BodyVariable", Value: bv}) - } else { - doc = append(doc, bson.E{Key: "BodyVariable", Value: nil}) - } - - doc = append(doc, bson.E{Key: "BaseUrlParameterMapping", Value: nil}) - - // ParameterMappings (path params) - paramMappings := bson.A{int32(3)} - for _, pm := range a.ParameterMappings { - paramMappings = append(paramMappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - // STORAGE-NAME OVERRIDE — see the note in - // mdl/backend/modelsdk/microflow_rest_write.go. There is no - // Microflows$ParameterMapping; writing it makes the project - // impossible to OPEN, not merely invalid. - {Key: "$Type", Value: "Microflows$RestOperationParameterMapping"}, - {Key: "Parameter", Value: pm.Parameter}, - {Key: "Value", Value: pm.Value}, - }) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: paramMappings}) - - // QueryParameterMappings - queryMappings := bson.A{int32(3)} - for _, qm := range a.QueryParameterMappings { - queryMappings = append(queryMappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$QueryParameterMapping"}, - {Key: "QueryParameter", Value: qm.Parameter}, - {Key: "Value", Value: qm.Value}, - {Key: "Included", Value: qm.Included}, - }) - } - doc = append(doc, bson.E{Key: "QueryParameterMappings", Value: queryMappings}) - - return doc -} - -// serializeRestRequestHandling serializes RequestHandling to BSON. -// restRequestHandlingTypeOf is the action-level discriminator, which must agree -// with the RequestHandling sub-element. Measured against Studio Pro microflows -// (ako/TestApp, 11.13.0); Simple follows the same name rule but has no measured -// reference. Mirrors requestHandlingTypeOf in the modelsdk engine. -func restRequestHandlingTypeOf(rh microflows.RequestHandling) string { - switch rh.(type) { - case *microflows.MappingRequestHandling: - return "Mapping" - case *microflows.BinaryRequestHandling: - return "Binary" - case *microflows.FormDataRequestHandling: - return "FormData" - case *microflows.SimpleRequestHandling: - return "Simple" - default: - return "Custom" - } -} - -func serializeRestRequestHandling(rh microflows.RequestHandling) bson.D { - switch h := rh.(type) { - case *microflows.BinaryRequestHandling: - // Binary request body. Studio Pro stores the expression yielding the - // bytes — a FileDocument's Contents member — and pairs it with an - // action-level RequestHandlingType of "Binary". - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$BinaryRequestHandling"}, - {Key: "Expression", Value: h.Expression}, - } - case *microflows.CustomRequestHandling: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$CustomRequestHandling"}, - } - // Serialize Template as StringTemplate - template := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Text", Value: h.Template}, - } - // Add parameters - each must be wrapped in TemplateParameter object - if len(h.TemplateParams) > 0 { - var params bson.A - params = append(params, int32(2)) // Array marker - for _, p := range h.TemplateParams { - templateParam := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$TemplateParameter"}, - {Key: "Expression", Value: p}, - } - params = append(params, templateParam) - } - template = append(template, bson.E{Key: "Parameters", Value: params}) - } else { - template = append(template, bson.E{Key: "Parameters", Value: bson.A{int32(2)}}) - } - doc = append(doc, bson.E{Key: "Template", Value: template}) - return doc - - case *microflows.MappingRequestHandling: - // generated/metamodel gives this type exactly three properties: - // contentType (Json|Xml), mappingId, mappingVariableName. - // "ParameterVariable" is not one of them — an unknown property is the - // shape mxbuild tolerates and Studio Pro refuses to open — and an empty - // ContentType is not a member of the enum. - contentType := h.ContentType - if contentType == "" { - contentType = "Json" - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$MappingRequestHandling"}, - {Key: "MappingId", Value: idToBsonBinary(string(h.MappingID))}, - {Key: "ContentType", Value: contentType}, - {Key: "MappingVariableName", Value: h.ParameterVariable}, - } - - case *microflows.SimpleRequestHandling: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$SimpleRequestHandling"}, - } - - default: - // Default to empty custom request handling - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$CustomRequestHandling"}, - {Key: "RequestHandlingType", Value: "Custom"}, - {Key: "Template", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Text", Value: ""}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - }}, - {Key: "RequestProxyType", Value: "DefaultProxy"}, - } - } -} - -// serializeRestResultHandling serializes ResultHandling to BSON. -// Note: ResultHandlingType is serialized at the action level, not here. -func serializeRestResultHandling(rh microflows.ResultHandling, outputVar string) bson.D { - switch h := rh.(type) { - case *microflows.ResultHandlingString: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: outputVar != ""}, - {Key: "ImportMappingCall", Value: nil}, - } - if outputVar != "" { - doc = append(doc, - bson.E{Key: "ResultVariableName", Value: outputVar}, - bson.E{Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$StringType"}, - }}, - ) - } else { - doc = append(doc, - bson.E{Key: "ResultVariableName", Value: ""}, - bson.E{Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$StringType"}, - }}, - ) - } - return doc - - case *microflows.ResultHandlingMapping: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: true}, - } - // ImportMappingCall uses ReturnValueMapping (Studio Pro field name) with - // all required fields to make the mapping link visible in Studio Pro. - forceSingleOccurrence := h.SingleObject - if h.ForceSingleOccurrence != nil { - forceSingleOccurrence = *h.ForceSingleOccurrence - } - importCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ImportMappingCall"}, - {Key: "Commit", Value: "YesWithoutEvents"}, - {Key: "ContentType", Value: "Json"}, - {Key: "ForceSingleOccurrence", Value: forceSingleOccurrence}, - {Key: "ObjectHandlingBackup", Value: "Create"}, - {Key: "ParameterVariableName", Value: ""}, - {Key: "Range", Value: importMappingRange(h)}, - {Key: "ReturnValueMapping", Value: string(h.MappingID)}, - } - doc = append(doc, bson.E{Key: "ImportMappingCall", Value: importCall}) - // VariableType: ObjectType for single-object mappings, ListType for multi-object. - varTypeID := idToBsonBinary(GenerateID()) - var varType bson.D - if h.SingleObject { - varType = bson.D{ - {Key: "$ID", Value: varTypeID}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - } - } else { - varType = bson.D{ - {Key: "$ID", Value: varTypeID}, - {Key: "$Type", Value: "DataTypes$ListType"}, - } - } - if h.ResultEntityID != "" { - varType = append(varType, bson.E{Key: "Entity", Value: string(h.ResultEntityID)}) - } - doc = append(doc, - bson.E{Key: "ResultVariableName", Value: h.ResultVariable}, - bson.E{Key: "VariableType", Value: varType}, - ) - return doc - - case *microflows.ResultHandlingHttpResponse: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: outputVar != ""}, - {Key: "ImportMappingCall", Value: nil}, - {Key: "ResultVariableName", Value: outputVar}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: "System.HttpResponse"}, - }}, - } - - case *microflows.ResultHandlingFileDocument: - // Same shape as HttpResponse, but the entity is authored rather than - // fixed: it is always a System.FileDocument specialization (CE0362 - // rejects the base). Issue #922. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: outputVar != ""}, - {Key: "ImportMappingCall", Value: nil}, - {Key: "ResultVariableName", Value: outputVar}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: h.EntityRef}, - }}, - } - - case *microflows.ResultHandlingNone: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(h.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: false}, - {Key: "ImportMappingCall", Value: nil}, - {Key: "ResultVariableName", Value: ""}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$VoidType"}, - }}, - } - - default: - // Default to string result handling - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: outputVar != ""}, - {Key: "ImportMappingCall", Value: nil}, - {Key: "ResultVariableName", Value: outputVar}, - {Key: "VariableType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$StringType"}, - }}, - } - } -} - -// serializeListOperationAction serializes a ListOperationAction to BSON. -func serializeListOperationAction(a *microflows.ListOperationAction) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ListOperationsAction"}, // storageName differs from qualifiedName - {Key: "ErrorHandlingType", Value: "Rollback"}, - } - - // Serialize the operation - storage name is "NewOperation" - // - // The nil guard is not defensive tidiness: serializeListOperation returns - // nil for an operation it has no case for, and appending that writes an - // EMPTY sub-document, which Mendix's loader refuses outright — "Expected - // '$ID' as the first property of a storage object, but got 'NewOperation'" - // — so the project cannot be opened at all. Omitting the key instead leaves - // an activity with no action, which mxbuild reports as CE0008 "No action - // defined." naming the activity. A missing action is recoverable; an - // unloadable file is not. (issue #966, where the Range operation was the - // case that fell through) - if op := serializeListOperation(a.Operation); op != nil { - doc = append(doc, bson.E{Key: "NewOperation", Value: op}) - } - doc = append(doc, bson.E{Key: "ResultVariableName", Value: a.OutputVariable}) // storageName differs - return doc -} - -// serializeListOperation serializes a ListOperation to BSON. -// Storage names differ from qualified names in Mendix metamodel. -func serializeListOperation(op microflows.ListOperation) bson.D { - switch o := op.(type) { - case *microflows.HeadOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Head"}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - } - case *microflows.TailOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Tail"}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - } - case *microflows.FindOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$FindByExpression"}, // storageName differs - {Key: "Expression", Value: o.Expression}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - } - case *microflows.FilterOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$FilterByExpression"}, // storageName differs - {Key: "Expression", Value: o.Expression}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - } - case *microflows.FindByAttributeOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Find"}, - {Key: "Association", Value: o.Association}, - {Key: "Attribute", Value: o.Attribute}, - {Key: "Expression", Value: o.Expression}, - {Key: "ListName", Value: o.ListVariable}, - } - case *microflows.FilterByAttributeOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Filter"}, - {Key: "Association", Value: o.Association}, - {Key: "Attribute", Value: o.Attribute}, - {Key: "Expression", Value: o.Expression}, - {Key: "ListName", Value: o.ListVariable}, - } - case *microflows.SortOperation: - // Build sorting items - sortings := bson.A{int32(3)} // Array with items marker - for _, item := range o.Sorting { - sortItem := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(item.ID))}, - {Key: "$Type", Value: "Microflows$RetrieveSorting"}, // storageName for SortItem - {Key: "SortOrder", Value: string(item.Direction)}, - } - // AttributeRef is a nested DomainModels$AttributeRef object - if item.AttributeQualifiedName != "" { - attrRef := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: item.AttributeQualifiedName}, // BY_NAME_REFERENCE stored as string - } - if len(item.EntityRefSteps) > 0 { - attrRef = append(attrRef, bson.E{Key: "EntityRef", Value: serializeIndirectEntityRef(item.EntityRefSteps)}) - } - sortItem = append(sortItem, bson.E{Key: "AttributeRef", Value: attrRef}) - } - sortings = append(sortings, sortItem) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Sort"}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - {Key: "Sortings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$SortingsList"}, // storageName for SortItemList - {Key: "Sortings", Value: sortings}, - }}, - } - case *microflows.UnionOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Union"}, - {Key: "ListName", Value: o.ListVariable1}, // storageName: ListName - {Key: "SecondListOrObjectName", Value: o.ListVariable2}, // storageName differs - } - case *microflows.IntersectOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Intersect"}, - {Key: "ListName", Value: o.ListVariable1}, // storageName: ListName - {Key: "SecondListOrObjectName", Value: o.ListVariable2}, // storageName differs - } - case *microflows.SubtractOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Subtract"}, - {Key: "ListName", Value: o.ListVariable1}, // storageName: ListName - {Key: "SecondListOrObjectName", Value: o.ListVariable2}, // storageName differs - } - case *microflows.ContainsOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Contains"}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - {Key: "SecondListOrObjectName", Value: o.ObjectVariable}, // storageName differs - } - case *microflows.EqualsOperation: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$Equals"}, // storageName for ListEquals - {Key: "ListName", Value: o.ListVariable1}, // storageName: ListName - {Key: "SecondListOrObjectName", Value: o.ListVariable2}, // storageName differs - } - case *microflows.ListRangeOperation: - // `range($List, $offset, $amount)`. This case was absent, which made the - // Range the one list operation the legacy engine could PARSE (see - // parseListOperation) but not write — and the fall-through to nil is - // what produced an unloadable project, not merely a lost range. (#966) - // - // The bounds are nested in a Microflows$CustomRange child, the shape the - // parser beside this file already reads. Emitted only when there is a - // bound to carry: Mendix requires at least one (CE6520), so an empty - // child would be a well-formed way to store an invalid range. - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(o.ID))}, - {Key: "$Type", Value: "Microflows$ListRange"}, - {Key: "ListName", Value: o.ListVariable}, // storageName: ListName - } - if o.LimitExpression != "" || o.OffsetExpression != "" { - doc = append(doc, bson.E{Key: "CustomRange", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$CustomRange"}, - {Key: "LimitExpression", Value: o.LimitExpression}, - {Key: "OffsetExpression", Value: o.OffsetExpression}, - }}) - } - return doc - default: - return nil - } -} - -// serializeDatabaseRetrieveSource serializes a DatabaseRetrieveSource to BSON. -func serializeDatabaseRetrieveSource(source *microflows.DatabaseRetrieveSource) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(source.ID))}, - {Key: "$Type", Value: "Microflows$DatabaseRetrieveSource"}, - } - - // Entity is BY_NAME_REFERENCE - use qualified name string - if source.EntityQualifiedName != "" { - doc = append(doc, bson.E{Key: "Entity", Value: source.EntityQualifiedName}) - } - - // NewSortings (storageName) wraps a Microflows$SortingsList with Sortings array - sortItems := bson.A{int32(2)} // storageListType: 2 array marker - for _, sortItem := range source.Sorting { - sortItems = append(sortItems, serializeSortItem(sortItem)) - } - sortingsList := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$SortingsList"}, - {Key: "Sortings", Value: sortItems}, - } - doc = append(doc, bson.E{Key: "NewSortings", Value: sortingsList}) - - // Range for limiting results - always include for Studio Pro compatibility - if source.Range != nil { - doc = append(doc, bson.E{Key: "Range", Value: serializeRange(source.Range)}) - } else { - // Create default Range (retrieve all objects) - defaultRange := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$ConstantRange"}, - {Key: "SingleObject", Value: false}, - } - doc = append(doc, bson.E{Key: "Range", Value: defaultRange}) - } - - // XPath constraint - note: BSON field name uses lowercase 'p' (XpathConstraint) - if source.XPathConstraint != "" { - doc = append(doc, bson.E{Key: "XpathConstraint", Value: source.XPathConstraint}) - } - - return doc -} - -// serializeAssociationRetrieveSource serializes an AssociationRetrieveSource to BSON. -func serializeAssociationRetrieveSource(source *microflows.AssociationRetrieveSource) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(source.ID))}, - {Key: "$Type", Value: "Microflows$AssociationRetrieveSource"}, - } - if source.StartVariable != "" { - doc = append(doc, bson.E{Key: "StartVariableName", Value: source.StartVariable}) - } - // AssociationId is BY_NAME_REFERENCE - use qualified name string - if source.AssociationQualifiedName != "" { - doc = append(doc, bson.E{Key: "AssociationId", Value: source.AssociationQualifiedName}) - } - return doc -} - -// serializeRange serializes a Range to BSON. -// ConstantRange only has SingleObject; CustomRange has LimitExpression/OffsetExpression. -func serializeRange(r *microflows.Range) bson.D { - if r.RangeType == microflows.RangeTypeCustom { - // CustomRange: expression-based LIMIT/OFFSET - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(r.ID))}, - {Key: "$Type", Value: "Microflows$CustomRange"}, - } - if r.Limit != "" { - doc = append(doc, bson.E{Key: "LimitExpression", Value: r.Limit}) - } - if r.Offset != "" { - doc = append(doc, bson.E{Key: "OffsetExpression", Value: r.Offset}) - } - return doc - } - - // ConstantRange: SingleObject=true (LIMIT 1) or retrieve all - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(r.ID))}, - {Key: "$Type", Value: "Microflows$ConstantRange"}, - {Key: "SingleObject", Value: r.RangeType == microflows.RangeTypeFirst}, - } -} - -// serializeSortItem serializes a SortItem to BSON. -// Storage name is Microflows$RetrieveSorting (qualified: Microflows$SortItem). -func serializeSortItem(s *microflows.SortItem) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(s.ID))}, - {Key: "$Type", Value: "Microflows$RetrieveSorting"}, - } - - // AttributeRef is a DomainModels$AttributeRef object containing Attribute as BY_NAME_REFERENCE - if s.AttributeQualifiedName != "" { - attrRef := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: s.AttributeQualifiedName}, // BY_NAME_REFERENCE stored as string - } - if len(s.EntityRefSteps) > 0 { - attrRef = append(attrRef, bson.E{Key: "EntityRef", Value: serializeIndirectEntityRef(s.EntityRefSteps)}) - } - doc = append(doc, bson.E{Key: "AttributeRef", Value: attrRef}) - } else if s.AttributeID != "" { - // Legacy fallback: binary ID reference - doc = append(doc, bson.E{Key: "AttributeRef", Value: idToBsonBinary(string(s.AttributeID))}) - } - - doc = append(doc, bson.E{Key: "SortOrder", Value: string(s.Direction)}) - return doc -} - -func serializeIndirectEntityRef(steps []microflows.EntityRefStep) bson.D { - items := bson.A{int32(2)} - for _, step := range steps { - items = append(items, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$EntityRefStep"}, - {Key: "Association", Value: step.Association}, - {Key: "DestinationEntity", Value: step.DestinationEntity}, - }) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$IndirectEntityRef"}, - {Key: "Steps", Value: items}, - } -} - -// serializeCodeActionParameterValue serializes a CodeActionParameterValue to BSON. -func serializeCodeActionParameterValue(v microflows.CodeActionParameterValue) bson.D { - switch value := v.(type) { - case *microflows.StringTemplateParameterValue: - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.ID))}, - {Key: "$Type", Value: "Microflows$StringTemplateParameterValue"}, - } - if value.TypedTemplate != nil { - tt := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.TypedTemplate.ID))}, - {Key: "$Type", Value: "Microflows$TypedTemplate"}, - {Key: "Arguments", Value: bson.A{int32(2)}}, // Empty array marker - {Key: "Text", Value: value.TypedTemplate.Text}, - } - doc = append(doc, bson.E{Key: "TypedTemplate", Value: tt}) - } - return doc - case *microflows.ExpressionBasedCodeActionParameterValue: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.ID))}, - {Key: "$Type", Value: "Microflows$ExpressionBasedCodeActionParameterValue"}, - {Key: "Expression", Value: value.Expression}, - } - case *microflows.BasicCodeActionParameterValue: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.ID))}, - {Key: "$Type", Value: "Microflows$BasicCodeActionParameterValue"}, - {Key: "Argument", Value: value.Argument}, - } - case *microflows.MicroflowParameterValue: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.ID))}, - {Key: "$Type", Value: "Microflows$MicroflowParameterValue"}, - {Key: "Microflow", Value: value.Microflow}, - } - case *microflows.EntityTypeCodeActionParameterValue: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(value.ID))}, - {Key: "$Type", Value: "Microflows$EntityTypeCodeActionParameterValue"}, - {Key: "Entity", Value: value.Entity}, - } - } - return nil -} - -func serializeExecuteDatabaseQueryAction(a *microflows.ExecuteDatabaseQueryAction) bson.D { - // ConnectionParameterMappings - connMappings := bson.A{int32(2)} - for _, cm := range a.ConnectionParameterMappings { - cmDoc := bson.D{ - {Key: "$Type", Value: "DatabaseConnector$ConnectionParameterMapping"}, - {Key: "ParameterName", Value: cm.ParameterName}, - {Key: "Value", Value: cm.Value}, - } - if cm.ID != "" { - cmDoc = append(bson.D{{Key: "$ID", Value: idToBsonBinary(string(cm.ID))}}, cmDoc...) - } else { - cmDoc = append(bson.D{{Key: "$ID", Value: idToBsonBinary(generateUUID())}}, cmDoc...) - } - connMappings = append(connMappings, cmDoc) - } - - // ParameterMappings - paramMappings := bson.A{int32(2)} - for _, pm := range a.ParameterMappings { - pmDoc := bson.D{ - {Key: "$Type", Value: "DatabaseConnector$QueryParameterMapping"}, - {Key: "ParameterName", Value: pm.ParameterName}, - {Key: "Value", Value: pm.Value}, - } - if pm.ID != "" { - pmDoc = append(bson.D{{Key: "$ID", Value: idToBsonBinary(string(pm.ID))}}, pmDoc...) - } else { - pmDoc = append(bson.D{{Key: "$ID", Value: idToBsonBinary(generateUUID())}}, pmDoc...) - } - paramMappings = append(paramMappings, pmDoc) - } - - // Fields in alphabetical order (matches Studio Pro BSON layout) - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "DatabaseConnector$ExecuteDatabaseQueryAction"}, - {Key: "ConnectionParameterMappings", Value: connMappings}, - {Key: "DynamicQuery", Value: a.DynamicQuery}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "ParameterMappings", Value: paramMappings}, - {Key: "Query", Value: a.Query}, - } - - return doc -} - -func serializeImportXmlAction(a *microflows.ImportXmlAction) bson.D { - forceSingleOccurrence := false - if a.ResultHandling.ForceSingleOccurrence != nil { - forceSingleOccurrence = *a.ResultHandling.ForceSingleOccurrence - } - - // Build ImportMappingCall - importCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ImportMappingCall"}, - {Key: "Commit", Value: "YesWithoutEvents"}, - {Key: "ContentType", Value: "Json"}, - {Key: "ForceSingleOccurrence", Value: forceSingleOccurrence}, - {Key: "ObjectHandlingBackup", Value: "Create"}, - {Key: "ParameterVariableName", Value: ""}, - {Key: "Range", Value: importMappingRange(a.ResultHandling)}, - {Key: "ReturnValueMapping", Value: string(a.ResultHandling.MappingID)}, - } - - // Build VariableType - var varType bson.D - if a.ResultHandling.SingleObject { - varType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: string(a.ResultHandling.ResultEntityID)}, - } - } else { - varType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "DataTypes$ListType"}, - {Key: "Entity", Value: string(a.ResultHandling.ResultEntityID)}, - } - } - - bind := a.ResultHandling.ResultVariable != "" - - resultHandling := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ResultHandling.ID))}, - {Key: "$Type", Value: "Microflows$ResultHandling"}, - {Key: "Bind", Value: bind}, - {Key: "ImportMappingCall", Value: importCall}, - {Key: "ResultVariableName", Value: a.ResultHandling.ResultVariable}, - {Key: "VariableType", Value: varType}, - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ImportXmlAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "IsValidationRequired", Value: a.IsValidationRequired}, - {Key: "ResultHandling", Value: resultHandling}, - {Key: "XmlDocumentVariableName", Value: a.XmlDocumentVariable}, - } -} - -func serializeTransformJsonAction(a *microflows.TransformJsonAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$TransformJsonAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "InputVariableName", Value: a.InputVariableName}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "Transformation", Value: a.Transformation}, - } -} - -func serializeExportXmlAction(a *microflows.ExportXmlAction) bson.D { - // OutputMethod: ExportXmlAction$StringExport - outputMethod := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "ExportXmlAction$StringExport"}, - {Key: "OutputVariableName", Value: a.OutputVariable}, - } - - // ResultHandling: MappingRequestHandling - mappingID := "" - paramVar := "" - if a.RequestHandling != nil { - mappingID = string(a.RequestHandling.MappingID) - paramVar = a.RequestHandling.ParameterVariable - } - - resultHandling := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$MappingRequestHandling"}, - {Key: "ContentType", Value: "Json"}, - {Key: "MappingId", Value: mappingID}, - {Key: "MappingVariableName", Value: paramVar}, - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$ExportXmlAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "IsValidationRequired", Value: a.IsValidationRequired}, - {Key: "OutputMethod", Value: outputMethod}, - {Key: "ResultHandling", Value: resultHandling}, - } -} - -// serializeExternalActionReturnType maps a Mendix kind name (resolved from a -// consumed OData service's cached $metadata) to a DataTypes$* BSON sub-doc -// suitable for ODataPublish$CallExternalAction.VariableDataType. Mendix's -// CE7269 fires when this field's $Type doesn't match what the cached schema -// declares for the action's return. -// An Object or List return also carries the entity it is typed on: both -// DataTypes$ObjectType and DataTypes$ListType store an Entity by qualified -// name, and one without it is as unaligned as no type at all. -func serializeExternalActionReturnType(kind, entity string) bson.D { - typeID := idToBsonBinary(generateUUID()) - bsonType := "DataTypes$VoidType" - switch kind { - case "Boolean": - bsonType = "DataTypes$BooleanType" - case "String": - bsonType = "DataTypes$StringType" - case "Integer", "Long": - bsonType = "DataTypes$IntegerType" - case "Decimal", "Float": - bsonType = "DataTypes$DecimalType" - case "DateTime": - bsonType = "DataTypes$DateTimeType" - case "Binary": - bsonType = "DataTypes$BinaryType" - case "Object": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: entity}, - } - case "List": - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: "DataTypes$ListType"}, - {Key: "Entity", Value: entity}, - } - case "Void", "": - bsonType = "DataTypes$VoidType" - } - return bson.D{ - {Key: "$ID", Value: typeID}, - {Key: "$Type", Value: bsonType}, - } -} - -// importMappingRange builds the Range child of a Microflows$ImportMappingCall. -// -// Mendix has two variants and mxcli only ever wrote the first, so the "Custom" -// setting — a bounded list — was not merely undescribed but unrepresentable: -// -// Microflows$ConstantRange{SingleObject} All (false) / First (true) -// Microflows$CustomRange{LimitExpression, OffsetExpression} Custom -// -// A limit or an offset selects CustomRange; SingleObject has no meaning there, -// because a bounded range is always a list. (issue #881) -func importMappingRange(h *microflows.ResultHandlingMapping) bson.D { - if h.LimitExpression != "" || h.OffsetExpression != "" { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$CustomRange"}, - {Key: "LimitExpression", Value: h.LimitExpression}, - {Key: "OffsetExpression", Value: h.OffsetExpression}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$ConstantRange"}, - {Key: "SingleObject", Value: microflows.RangeSingleObjectOf(h)}, - } -} - -// serializeQueueSettings renders the Queues$QueueSettings child that binds a call -// activity to a task queue, or nil for an unqueued call (which is what Studio Pro -// stores). Retry has no MDL surface and is always null here; a stored retry is -// never overwritten, because checkNoQueuedCalls refuses the rewrite instead. -func serializeQueueSettings(qs *microflows.QueueSettings) any { - if qs == nil || qs.Queue == "" { - return nil - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(qs.ID))}, - {Key: "$Type", Value: "Queues$QueueSettings"}, - {Key: "Queue", Value: qs.Queue}, - {Key: "Retry", Value: nil}, - } -} diff --git a/sdk/mpr/writer_microflow_flags_test.go b/sdk/mpr/writer_microflow_flags_test.go deleted file mode 100644 index 8d449b18cc..0000000000 --- a/sdk/mpr/writer_microflow_flags_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -// TestMicroflowApplyEntityAccessRoundTrip is the legacy half of the security -// fix, and exists to keep the two engines from drifting — the modelsdk twin is -// TestMicroflowRoundTrip_ApplyEntityAccess. -// -// This writer wrote `{Key: "ApplyEntityAccess", Value: false}` unconditionally, -// so a microflow that ran under the user's entity access rules came back running -// with full access. Nothing reported it: the model is valid either way. -func TestMicroflowApplyEntityAccessRoundTrip(t *testing.T) { - for _, want := range []bool{true, false} { - mf := µflows.Microflow{Name: "ACT_Secured", ApplyEntityAccess: want} - mf.ID = model.ID("mf-1") - - w := testWriter() - raw, err := w.serializeMicroflow(mf) - if err != nil { - t.Fatalf("serialize: %v", err) - } - var doc map[string]any - if err := bson.Unmarshal(raw, &doc); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if got, ok := doc["ApplyEntityAccess"].(bool); !ok || got != want { - t.Errorf("written ApplyEntityAccess = %#v, want %v", doc["ApplyEntityAccess"], want) - } - - // And the parser has to read it back, or the value never reaches the - // writer on a rewrite in the first place. - back := ParseMicroflowFromRaw(doc, "mf-1", "mod-1") - if back.ApplyEntityAccess != want { - t.Errorf("parsed ApplyEntityAccess = %v, want %v", back.ApplyEntityAccess, want) - } - } -} diff --git a/sdk/mpr/writer_microflow_version_test.go b/sdk/mpr/writer_microflow_version_test.go deleted file mode 100644 index 60e772717f..0000000000 --- a/sdk/mpr/writer_microflow_version_test.go +++ /dev/null @@ -1,184 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" -) - -// bsonHasKey returns true when the top-level BSON document contains the key. -func bsonHasKey(doc bson.D, key string) bool { - for _, e := range doc { - if e.Key == key { - return true - } - } - return false -} - -// bsonGetKey returns the value of a key or nil if absent. -func bsonGetKey(doc bson.D, key string) any { - for _, e := range doc { - if e.Key == key { - return e.Value - } - } - return nil -} - -func TestSerializeSequenceFlow_Mx9_UsesLegacyShape(t *testing.T) { - flow := µflows.SequenceFlow{ - BaseElement: model.BaseElement{ID: "flow-1"}, - OriginID: "orig-1", - DestinationID: "dest-1", - CaseValue: µflows.NoCase{BaseElement: model.BaseElement{ID: "case-1"}}, - } - - doc := serializeSequenceFlow(flow, 9) - - if !bsonHasKey(doc, "NewCaseValue") { - t.Error("Mx 9 sequence flow must include NewCaseValue") - } - if bsonHasKey(doc, "CaseValues") { - t.Error("Mx 9 sequence flow must NOT include CaseValues") - } - if !bsonHasKey(doc, "OriginBezierVector") || !bsonHasKey(doc, "DestinationBezierVector") { - t.Error("Mx 9 sequence flow must include top-level {Origin,Destination}BezierVector") - } - if bsonHasKey(doc, "Line") { - t.Error("Mx 9 sequence flow must NOT nest vectors under Line") - } -} - -func TestSerializeSequenceFlow_Mx10_UsesModernShape(t *testing.T) { - flow := µflows.SequenceFlow{ - BaseElement: model.BaseElement{ID: "flow-1"}, - OriginID: "orig-1", - DestinationID: "dest-1", - CaseValue: µflows.NoCase{BaseElement: model.BaseElement{ID: "case-1"}}, - } - - doc := serializeSequenceFlow(flow, 10) - - if !bsonHasKey(doc, "CaseValues") { - t.Error("Mx 10 sequence flow must include CaseValues") - } - if bsonHasKey(doc, "NewCaseValue") { - t.Error("Mx 10 sequence flow must NOT include legacy NewCaseValue") - } - if !bsonHasKey(doc, "Line") { - t.Error("Mx 10 sequence flow must nest vectors under Line") - } - if bsonHasKey(doc, "OriginBezierVector") || bsonHasKey(doc, "DestinationBezierVector") { - t.Error("Mx 10 sequence flow must NOT include top-level BezierVector fields") - } -} - -func TestSerializeEndEvent_EmptyReturnValueHasNoTrailingLineBreak(t *testing.T) { - end := µflows.EndEvent{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: "end-empty"}, - Position: model.Point{X: 10, Y: 20}, - Size: model.Size{Width: 20, Height: 20}, - }, - ReturnValue: "", - } - - doc := serializeMicroflowObject(end) - if got := bsonGetKey(doc, "ReturnValue"); got != "" { - t.Fatalf("ReturnValue = %q, want empty string", got) - } -} - -func TestSerializeEndEvent_NonEmptyReturnValueHasNoSyntheticLineBreak(t *testing.T) { - end := µflows.EndEvent{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: "end-result"}, - Position: model.Point{X: 10, Y: 20}, - Size: model.Size{Width: 20, Height: 20}, - }, - ReturnValue: "$Result", - } - - doc := serializeMicroflowObject(end) - if got := bsonGetKey(doc, "ReturnValue"); got != "$Result" { - t.Fatalf("ReturnValue = %q, want %q", got, "$Result") - } -} - -func TestSerializeAnnotationFlow_VersionShapes(t *testing.T) { - af := µflows.AnnotationFlow{ - BaseElement: model.BaseElement{ID: "af-1"}, - OriginID: "orig-1", - DestinationID: "dest-1", - } - - mx9 := serializeAnnotationFlow(af, 9) - if !bsonHasKey(mx9, "OriginBezierVector") || !bsonHasKey(mx9, "DestinationBezierVector") { - t.Error("Mx 9 annotation flow must use top-level BezierVector fields") - } - if bsonHasKey(mx9, "Line") { - t.Error("Mx 9 annotation flow must NOT nest under Line") - } - - mx10 := serializeAnnotationFlow(af, 10) - if !bsonHasKey(mx10, "Line") { - t.Error("Mx 10 annotation flow must nest vectors under Line") - } - if bsonHasKey(mx10, "OriginBezierVector") { - t.Error("Mx 10 annotation flow must NOT include top-level BezierVector") - } -} - -func TestSerializeMicroflowParameter_Mx9_OmitsMx10OnlyKeys(t *testing.T) { - p := µflows.MicroflowParameter{ - BaseElement: model.BaseElement{ID: "p-1"}, - Name: "Customer", - Type: µflows.StringType{}, - } - - mx9 := serializeMicroflowParameter(p, 0, 9) - if bsonHasKey(mx9, "DefaultValue") { - t.Error("Mx 9 parameter must NOT emit DefaultValue") - } - if bsonHasKey(mx9, "IsRequired") { - t.Error("Mx 9 parameter must NOT emit IsRequired") - } - - mx10 := serializeMicroflowParameter(p, 0, 10) - if !bsonHasKey(mx10, "DefaultValue") { - t.Error("Mx 10 parameter must emit DefaultValue") - } - if !bsonHasKey(mx10, "IsRequired") { - t.Error("Mx 10 parameter must emit IsRequired") - } -} - -func TestBuildSequenceFlowCase_NormalisesValueReceiver(t *testing.T) { - // A value-receiver NoCase must produce the same shape as a pointer. - fromValue := buildSequenceFlowCase(microflows.NoCase{BaseElement: model.BaseElement{ID: "x"}}) - fromPointer := buildSequenceFlowCase(µflows.NoCase{BaseElement: model.BaseElement{ID: "x"}}) - - if bsonGetKey(fromValue, "$Type") != bsonGetKey(fromPointer, "$Type") { - t.Error("value and pointer NoCase must produce identical $Type") - } -} - -func TestBuildSequenceFlowCase_ExpressionCase_UsesEnumerationCase(t *testing.T) { - doc := buildSequenceFlowCase(microflows.ExpressionCase{ - BaseElement: model.BaseElement{ID: "case-false"}, - Expression: "false", - }) - - if got := bsonGetKey(doc, "$Type"); got != "Microflows$EnumerationCase" { - t.Fatalf("$Type = %v, want Microflows$EnumerationCase", got) - } - if got := bsonGetKey(doc, "Value"); got != "false" { - t.Fatalf("Value = %v, want false", got) - } -} diff --git a/sdk/mpr/writer_microflow_workflow.go b/sdk/mpr/writer_microflow_workflow.go deleted file mode 100644 index 7c65312cd1..0000000000 --- a/sdk/mpr/writer_microflow_workflow.go +++ /dev/null @@ -1,190 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/sdk/microflows" - - "go.mongodb.org/mongo-driver/bson" -) - -func serializeWorkflowCallAction(a *microflows.WorkflowCallAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$WorkflowCallAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "UseReturnVariable", Value: a.UseReturnVariable}, - {Key: "Workflow", Value: a.Workflow}, - {Key: "WorkflowContextVariable", Value: a.WorkflowContextVariable}, - } -} - -func serializeGetWorkflowDataAction(a *microflows.GetWorkflowDataAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$GetWorkflowDataAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "Workflow", Value: a.Workflow}, - {Key: "WorkflowVariable", Value: a.WorkflowVariable}, - } -} - -func serializeGetWorkflowsAction(a *microflows.GetWorkflowsAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$GetWorkflowsAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "WorkflowContextVariableName", Value: a.WorkflowContextVariableName}, - } -} - -func serializeGetWorkflowActivityRecordsAction(a *microflows.GetWorkflowActivityRecordsAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$GetWorkflowActivityRecordsAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "WorkflowVariable", Value: a.WorkflowVariable}, - } -} - -func serializeWorkflowOperationAction(a *microflows.WorkflowOperationAction) bson.D { - var opDoc bson.D - if a.Operation != nil { - switch op := a.Operation.(type) { - case *microflows.AbortOperation: - reasonDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Text", Value: op.Reason}, - } - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$AbortOperation"}, - {Key: "Reason", Value: reasonDoc}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - case *microflows.ContinueOperation: - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$ContinueOperation"}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - case *microflows.PauseOperation: - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$PauseOperation"}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - case *microflows.RestartOperation: - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$RestartOperation"}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - case *microflows.RetryOperation: - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$RetryOperation"}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - case *microflows.UnpauseOperation: - opDoc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(op.ID))}, - {Key: "$Type", Value: "Microflows$UnpauseOperation"}, - {Key: "WorkflowVariable", Value: op.WorkflowVariable}, - } - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$WorkflowOperationAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "Operation", Value: opDoc}, - } -} - -func serializeSetTaskOutcomeAction(a *microflows.SetTaskOutcomeAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$SetTaskOutcomeAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutcomeValue", Value: a.OutcomeValue}, - {Key: "WorkflowTaskVariable", Value: a.WorkflowTaskVariable}, - } -} - -func serializeOpenUserTaskAction(a *microflows.OpenUserTaskAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$OpenUserTaskAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "UserTaskVariable", Value: a.UserTaskVariable}, - } -} - -func serializeNotifyWorkflowAction(a *microflows.NotifyWorkflowAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$NotifyWorkflowAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "OutputVariableName", Value: a.OutputVariableName}, - {Key: "WorkflowVariable", Value: a.WorkflowVariable}, - } -} - -func serializeOpenWorkflowAction(a *microflows.OpenWorkflowAction) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$OpenWorkflowAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "WorkflowVariable", Value: a.WorkflowVariable}, - } -} - -func serializeLockWorkflowAction(a *microflows.LockWorkflowAction) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$LockWorkflowAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "PauseAllWorkflows", Value: a.PauseAllWorkflows}, - } - if !a.PauseAllWorkflows { - selDoc := serializeWorkflowSelection(a.Workflow, a.WorkflowVariable) - doc = append(doc, bson.E{Key: "WorkflowSelection", Value: selDoc}) - } - return doc -} - -func serializeUnlockWorkflowAction(a *microflows.UnlockWorkflowAction) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Microflows$UnlockWorkflowAction"}, - {Key: "ErrorHandlingType", Value: stringOrDefault(string(a.ErrorHandlingType), "Rollback")}, - {Key: "ResumeAllPausedWorkflows", Value: a.ResumeAllPausedWorkflows}, - } - if !a.ResumeAllPausedWorkflows { - selDoc := serializeWorkflowSelection(a.Workflow, a.WorkflowVariable) - doc = append(doc, bson.E{Key: "WorkflowSelection", Value: selDoc}) - } - return doc -} - -func serializeWorkflowSelection(workflow, workflowVariable string) bson.D { - if workflow != "" { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Workflows$WorkflowDefinitionNameSelection"}, - {Key: "Workflow", Value: workflow}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Workflows$WorkflowDefinitionObjectSelection"}, - {Key: "WorkflowDefinitionVariable", Value: workflowVariable}, - } -} diff --git a/sdk/mpr/writer_modules.go b/sdk/mpr/writer_modules.go deleted file mode 100644 index 10b2d2cde4..0000000000 --- a/sdk/mpr/writer_modules.go +++ /dev/null @@ -1,347 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateModule creates a new module in the project. -// This also creates the associated domain model for the module. -func (w *Writer) CreateModule(module *model.Module) error { - if module.ID == "" { - module.ID = model.ID(generateUUID()) - } - module.TypeName = "Projects$ModuleImpl" - - // Get project root ID - modules are contained in the project root - projectRootID, err := w.reader.GetProjectRootID() - if err != nil { - return fmt.Errorf("failed to get project root: %w", err) - } - - // Serialize and insert module - contents, err := w.serializeModule(module) - if err != nil { - return fmt.Errorf("failed to serialize module: %w", err) - } - - if err := w.insertUnit(string(module.ID), projectRootID, "Modules", "Projects$ModuleImpl", contents); err != nil { - return fmt.Errorf("failed to insert module unit: %w", err) - } - - // Create empty domain model for the module - dmID := generateUUID() - dm := &domainmodel.DomainModel{ - ContainerID: module.ID, - } - dm.ID = model.ID(dmID) - dm.TypeName = "DomainModels$DomainModel" - - dmContents, err := w.serializeDomainModel(dm) - if err != nil { - return fmt.Errorf("failed to serialize domain model: %w", err) - } - - if err := w.insertUnit(dmID, string(module.ID), "DomainModel", "DomainModels$DomainModel", dmContents); err != nil { - return fmt.Errorf("failed to insert domain model unit: %w", err) - } - - // Create empty module security for the module - msID := generateUUID() - msContents, err := w.serializeModuleSecurity(msID) - if err != nil { - return fmt.Errorf("failed to serialize module security: %w", err) - } - - if err := w.insertUnit(msID, string(module.ID), "ModuleSecurity", "Security$ModuleSecurity", msContents); err != nil { - return fmt.Errorf("failed to insert module security unit: %w", err) - } - - // Create module settings for the module - settingsID := generateUUID() - settingsContents, err := w.serializeModuleSettings(settingsID) - if err != nil { - return fmt.Errorf("failed to serialize module settings: %w", err) - } - - if err := w.insertUnit(settingsID, string(module.ID), "ModuleSettings", "Projects$ModuleSettings", settingsContents); err != nil { - return fmt.Errorf("failed to insert module settings unit: %w", err) - } - - return nil -} - -// UpdateModule updates an existing module. -func (w *Writer) UpdateModule(module *model.Module) error { - contents, err := w.serializeModule(module) - if err != nil { - return fmt.Errorf("failed to serialize module: %w", err) - } - - return w.updateUnit(string(module.ID), contents) -} - -// DeleteModule deletes a module and all its child units (DomainModel, ModuleSecurity, -// ModuleSettings, Folders, Documents). This prevents orphaned units which cause -// Studio Pro to crash with KeyNotFoundException in UnitLoader.LoadChildUnits. -func (w *Writer) DeleteModule(id model.ID) error { - if err := w.deleteChildUnits(string(id)); err != nil { - return fmt.Errorf("failed to delete child units: %w", err) - } - return w.deleteUnit(string(id)) -} - -// DeleteModuleWithCleanup deletes a module and also removes its themesource directory. -// The moduleName is needed because the themesource directory name is derived from -// the module name (lowercased), not the module ID. -func (w *Writer) DeleteModuleWithCleanup(id model.ID, moduleName string) error { - if err := w.DeleteModule(id); err != nil { - return err - } - - // Remove the module's generated source directories. themesource and - // javasource use the lowercased module name; javascriptsource uses the - // original casing (with a lowercase fallback). Studio Pro deletes these when a - // module is removed; leaving them strands proxies/actions for a module that no - // longer exists in the model. - projectDir := filepath.Dir(w.reader.path) - removeModuleSourceDirs(projectDir, moduleName) - - return nil -} - -// removeModuleSourceDirs deletes the themesource/javasource/javascriptsource -// directories belonging to a module. Mirrored by the modelsdk backend's -// DeleteModuleWithCleanup. -func removeModuleSourceDirs(projectDir, moduleName string) { - lower := strings.ToLower(moduleName) - dirs := []string{ - filepath.Join(projectDir, "themesource", lower), - filepath.Join(projectDir, "javasource", lower), - filepath.Join(projectDir, "javascriptsource", moduleName), - filepath.Join(projectDir, "javascriptsource", lower), - } - for _, dir := range dirs { - if stat, err := os.Stat(dir); err == nil && stat.IsDir() { - os.RemoveAll(dir) - } - } -} - -// deleteChildUnits recursively deletes all units whose ContainerID matches the given parent. -func (w *Writer) deleteChildUnits(parentID string) error { - parentBlob := uuidToBlob(parentID) - if parentBlob == nil { - return fmt.Errorf("invalid parent ID: %s", parentID) - } - - // Find all child units - rows, err := w.reader.db.Query("SELECT UnitID FROM Unit WHERE ContainerID = ? AND UnitID != ContainerID", parentBlob) - if err != nil { - return err - } - defer rows.Close() - - var childIDs []string - for rows.Next() { - var childBlob []byte - if err := rows.Scan(&childBlob); err != nil { - return err - } - childIDs = append(childIDs, blobToUUID(childBlob)) - } - - // Recursively delete children of children first (depth-first) - for _, childID := range childIDs { - if err := w.deleteChildUnits(childID); err != nil { - return err - } - if err := w.deleteUnit(childID); err != nil { - return err - } - } - - return nil -} - -// CreateFolder creates a new folder in the project. -func (w *Writer) CreateFolder(folder *model.Folder) error { - if folder.ID == "" { - folder.ID = model.ID(generateUUID()) - } - folder.TypeName = "Projects$Folder" - - // Serialize and insert folder - contents, err := w.serializeFolder(folder) - if err != nil { - return fmt.Errorf("failed to serialize folder: %w", err) - } - - if err := w.insertUnit(string(folder.ID), string(folder.ContainerID), "Folders", "Projects$Folder", contents); err != nil { - return fmt.Errorf("failed to insert folder unit: %w", err) - } - - return nil -} - -// serializeFolder serializes a folder to BSON. -func (w *Writer) serializeFolder(folder *model.Folder) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(folder.ID))}, - {Key: "$Type", Value: "Projects$Folder"}, - {Key: "Name", Value: folder.Name}, - } - - return marshalUnitIDFirst(doc) -} - -// DeleteFolder deletes a folder unit if it is empty. -// Returns an error if the folder contains any child units. -func (w *Writer) DeleteFolder(id model.ID) error { - idStr := string(id) - blob := uuidToBlob(idStr) - if blob == nil { - return fmt.Errorf("invalid folder ID: %s", idStr) - } - - var count int - err := w.reader.db.QueryRow( - "SELECT COUNT(*) FROM Unit WHERE ContainerID = ? AND UnitID != ContainerID", - blob, - ).Scan(&count) - if err != nil { - return fmt.Errorf("failed to check folder contents: %w", err) - } - if count > 0 { - return fmt.Errorf("folder is not empty: contains %d child unit(s)", count) - } - - return w.deleteUnit(idStr) -} - -// MoveFolder moves a folder to a new container (folder or module root). -func (w *Writer) MoveFolder(id model.ID, newContainerID model.ID) error { - return w.moveUnitByID(string(id), string(newContainerID)) -} - -func (w *Writer) serializeModuleSecurity(id string) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Security$ModuleSecurity"}, - {Key: "ModuleRoles", Value: bson.A{int32(1)}}, - } - return marshalUnitIDFirst(doc) -} - -func (w *Writer) serializeModuleSettings(id string) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Projects$ModuleSettings"}, - {Key: "BasedOnVersion", Value: ""}, - {Key: "ExportLevel", Value: "Source"}, - {Key: "ExtensionName", Value: ""}, - {Key: "JarDependencies", Value: bson.A{int32(2)}}, - {Key: "ProtectedModuleType", Value: "AddOn"}, - {Key: "SolutionIdentifier", Value: ""}, - {Key: "Version", Value: "1.0.0"}, - } - return marshalUnitIDFirst(doc) -} - -// UpdateModuleSettings persists the full Projects$ModuleSettings document, -// including all JarDependencies and their Exclusions. -func (w *Writer) UpdateModuleSettings(ms *types.ModuleSettings) error { - contents, err := w.serializeModuleSettingsFull(ms) - if err != nil { - return fmt.Errorf("failed to serialize module settings: %w", err) - } - return w.updateUnit(string(ms.ID), contents) -} - -// serializeModuleSettingsFull serializes a ModuleSettings with actual dependencies. -func (w *Writer) serializeModuleSettingsFull(ms *types.ModuleSettings) ([]byte, error) { - exportLevel := ms.ExportLevel - if exportLevel == "" { - exportLevel = "Source" - } - protectedType := ms.ProtectedModuleType - if protectedType == "" { - protectedType = "AddOn" - } - ver := ms.Version - if ver == "" { - ver = "1.0.0" - } - - deps := bson.A{int32(2)} // listType marker - for _, d := range ms.JarDependencies { - depID := string(d.ID) - if depID == "" { - depID = generateUUID() - } - depDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(depID)}, - {Key: "$Type", Value: "Projects$JarDependency"}, - {Key: "GroupId", Value: d.GroupID}, - {Key: "ArtifactId", Value: d.ArtifactID}, - {Key: "Version", Value: d.Version}, - {Key: "IsIncluded", Value: d.IsIncluded}, - } - if len(d.Exclusions) > 0 { - excArr := bson.A{int32(2)} - for _, e := range d.Exclusions { - excID := string(e.ID) - if excID == "" { - excID = generateUUID() - } - excArr = append(excArr, bson.D{ - {Key: "$ID", Value: idToBsonBinary(excID)}, - {Key: "$Type", Value: "Projects$JarDependencyExclusion"}, - {Key: "GroupId", Value: e.GroupID}, - {Key: "ArtifactId", Value: e.ArtifactID}, - }) - } - depDoc = append(depDoc, bson.E{Key: "Exclusions", Value: excArr}) - } - deps = append(deps, depDoc) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ms.ID))}, - {Key: "$Type", Value: "Projects$ModuleSettings"}, - {Key: "BasedOnVersion", Value: ms.BasedOnVersion}, - {Key: "ExportLevel", Value: exportLevel}, - {Key: "ExtensionName", Value: ms.ExtensionName}, - {Key: "JarDependencies", Value: deps}, - {Key: "ProtectedModuleType", Value: protectedType}, - {Key: "SolutionIdentifier", Value: ms.SolutionIdentifier}, - {Key: "Version", Value: ver}, - } - return marshalUnitIDFirst(doc) -} - -func (w *Writer) serializeModule(module *model.Module) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(module.ID))}, - {Key: "$Type", Value: "Projects$ModuleImpl"}, - {Key: "Name", Value: module.Name}, - {Key: "FromAppStore", Value: module.FromAppStore}, - {Key: "AppStoreGuid", Value: module.AppStoreGuid}, - {Key: "AppStorePackageIdString", Value: ""}, - {Key: "AppStoreVersion", Value: module.AppStoreVersion}, - {Key: "AppStoreVersionGuid", Value: ""}, - {Key: "IsThemeModule", Value: false}, - {Key: "NewSortIndex", Value: int64(0)}, - } - return marshalUnitIDFirst(doc) -} diff --git a/sdk/mpr/writer_navigation.go b/sdk/mpr/writer_navigation.go deleted file mode 100644 index 317322d738..0000000000 --- a/sdk/mpr/writer_navigation.go +++ /dev/null @@ -1,472 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// NavigationProfileSpec describes the desired state for a navigation profile. -// Aliased from mdl/types to avoid duplicate definitions. -type NavigationProfileSpec = types.NavigationProfileSpec -type NavOfflineEntitySpec = types.NavOfflineEntitySpec - -// NavHomePageSpec describes a home page entry. -type NavHomePageSpec = types.NavHomePageSpec - -// NavMenuItemSpec describes a menu item. -type NavMenuItemSpec = types.NavMenuItemSpec - -// UpdateNavigationProfile patches a navigation profile's home pages, login page, and menu. -// Typed-array markers for the lists these writers emit. -// -// The leading int32 of a Mendix array is a per-FIELD constant, not a function of -// the list's contents: Forms$FormSettings.ParameterMappings is 2 in 816 empty -// and 306 non-empty real occurrences alike. So each list below takes the value -// Studio Pro writes for that field, censused over 19,078 unit files in 54 -// projects on this machine: -// -// Forms$FormSettings.ParameterMappings 2 (1122 documents) -// Forms$FormAction.PagesForSpecializations 2 (357) -// Menus$MenuItemCollection.Items 3 (153) -// Menus$MenuItem.Items 3 (459) -// Texts$Text.Items 3 (169,486 vs 7 at 2) -// Navigation$NavigationProfile.HomeItems 2 (51) -// -// These writers previously emitted 1 for all of them. Note what that was NOT: -// 1 is a perfectly legitimate Mendix marker -- a Marketplace .mpk mxcli has -// never touched carries it on CustomWidgets$WidgetValueType.AllowedTypes (212k -// occurrences) and on Forms$Page.AllowedModuleRoles. debug-bson.md's rule that -// "any other value is invalid and Studio Pro ignores the array" is too strong -// and is corrected there. The defect is narrower: for THESE fields, no -// Studio Pro document uses 1, and mxcli's own menu-document codec path already -// writes 3 for the same Menus$ item collections, so the two paths disagreed. -// -// HomeItems needed a second source, because all 51 census observations are empty -// lists and navigation_profile_add.go wrote 3 there from a PED session that -// cannot be re-run here. ako/TestApp settles it: its Studio Pro-authored profile -// carries HomeItems [marker 2] holding two Navigation$RoleBasedHomePage -// elements -- a NON-empty list, which is the case the census could not reach. -// navigation_profile_add.go now writes 2 as well. -const ( - navMarkerItems = int32(3) - navMarkerParameterMappings = int32(2) - navMarkerHomeItems = int32(2) -) - -func (w *Writer) UpdateNavigationProfile(navDocID model.ID, profileName string, spec NavigationProfileSpec) error { - return w.readPatchWrite(navDocID, func(doc bson.D) (bson.D, error) { - profiles := getBsonArray(doc, "Profiles") - if profiles == nil { - return doc, fmt.Errorf("no Profiles array found in navigation document") - } - - found := false - for i, item := range profiles { - profDoc, ok := item.(bson.D) - if !ok { - continue - } - - // Match profile by name (case-insensitive) - name := "" - for _, f := range profDoc { - if f.Key == "Name" { - name, _ = f.Value.(string) - break - } - } - if !strings.EqualFold(name, profileName) { - continue - } - found = true - - // Determine if this is a native profile - isNative := false - for _, f := range profDoc { - if f.Key == "$Type" { - typeName, _ := f.Value.(string) - isNative = typeName == "Navigation$NativeNavigationProfile" - break - } - } - - if isNative { - profDoc = patchNativeProfile(profDoc, spec) - } else { - profDoc = patchWebProfile(profDoc, spec) - } - - profiles[i] = profDoc - break - } - - if !found { - return doc, fmt.Errorf("navigation profile not found: %s", profileName) - } - - return setBsonField(doc, "Profiles", profiles), nil - }) -} - -// patchWebProfile applies the spec to a web navigation profile. -func patchWebProfile(doc bson.D, spec NavigationProfileSpec) bson.D { - // --- HomePage (default home) --- - var defaultHome *NavHomePageSpec - var roleHomes []NavHomePageSpec - for _, hp := range spec.HomePages { - if hp.ForRole == "" { - h := hp - defaultHome = &h - } else { - roleHomes = append(roleHomes, hp) - } - } - - if defaultHome != nil { - doc = setBsonField(doc, "HomePage", buildHomePageBson(defaultHome)) - } else { - // Clear default home page - doc = setBsonField(doc, "HomePage", bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$HomePage"}, - {Key: "Microflow", Value: ""}, - {Key: "Page", Value: ""}, - }) - } - - // --- HomeItems (role-based homes) --- - homeItems := bson.A{navMarkerHomeItems} - for _, rh := range roleHomes { - homeItems = append(homeItems, buildRoleBasedHomeBson(rh)) - } - doc = setBsonField(doc, "HomeItems", homeItems) - - // --- LoginPageSettings --- - if spec.LoginPage != "" { - doc = setBsonField(doc, "LoginPageSettings", buildFormSettingsBson(spec.LoginPage)) - } else { - doc = setBsonField(doc, "LoginPageSettings", buildFormSettingsBson("")) - } - - // --- NotFoundHomepage --- - if spec.NotFoundPage != "" { - doc = setBsonField(doc, "NotFoundHomepage", bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - // Studio Pro's "Fallback page". The $Type is - // Navigation$NotFoundHomePage, not the Navigation$HomePage the home - // page slot takes -- measured on ako/TestApp, whose fallback page - // Studio Pro stored as Navigation$NotFoundHomePage/Page. - // - // The wrong $Type here is not cosmetic: Mendix cannot LOAD the - // project. Both `mx check` and `mxbuild --target=deploy` exit 1 with - // "Object of type '...Navigation.HomePage' cannot be converted to - // type '...Navigation.NotFoundHomePage'" (measured on 11.13, against - // a build of this file emitting the old spelling). Nothing caught it - // because nothing ever BUILT a project with a fallback page set -- - // the automated mx-check coverage runs doctype-tests/ only, and no - // script there sets one. - {Key: "$Type", Value: "Navigation$NotFoundHomePage"}, - {Key: "Microflow", Value: ""}, - {Key: "Page", Value: spec.NotFoundPage}, - }) - } else { - // Mendix uses null when not set - doc = setBsonField(doc, "NotFoundHomepage", nil) - } - - // --- Menu --- - if spec.HasMenu { - menuItems := bson.A{navMarkerItems} - for _, mi := range spec.MenuItems { - menuItems = append(menuItems, buildMenuItemBson(mi)) - } - doc = setBsonField(doc, "Menu", bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Menus$MenuItemCollection"}, - {Key: "Items", Value: menuItems}, - }) - } - - // --- Offline synchronization --- - if spec.HasSync { - doc = setBsonField(doc, "OfflineEntityConfigs", - buildOfflineConfigsBson(getBsonArray(doc, "OfflineEntityConfigs"), spec.OfflineEntities)) - } - - // Kept identical to the modelsdk engine: nil leaves the stored flag alone, - // because neither generated source declares the property and a non-pointer - // would reset it on every rewrite that never mentions it. - if spec.ThrowSyncError != nil { - doc = setBsonField(doc, "ThrowPartialSyncError", *spec.ThrowSyncError) - } - - return doc -} - -// buildOfflineConfigsBson rebuilds OfflineEntityConfigs from the spec, carrying -// forward the properties MDL cannot express. -// -// Kept deliberately identical in behaviour to the modelsdk engine's -// navOfflineConfigs: CompatibilityMode is preserved per entity, and -// DownloadMode/ShouldDownload are not written at all — they occur zero times in -// ako/TestApp's configs, and a property absent from every real document is one -// Studio Pro fills in on load. A cross-engine test asserts the two agree, -// because two writers drifting apart is how an engine-specific defect hides. -func buildOfflineConfigsBson(stored bson.A, specs []NavOfflineEntitySpec) bson.A { - compat := map[string]bool{} - for _, item := range stored { - var cfg map[string]any - switch v := item.(type) { - case bson.D: - cfg = v.Map() - case map[string]any: - cfg = v - default: - continue // the leading typed-array marker - } - if e := extractString(cfg["Entity"]); e != "" { - compat[e] = extractBool(cfg["CompatibilityMode"], false) - } - } - - out := bson.A{navMarkerItems} - for _, sp := range specs { - out = append(out, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$OfflineEntityConfig"}, - {Key: "CompatibilityMode", Value: compat[sp.Entity]}, - {Key: "Constraint", Value: sp.Constraint}, - {Key: "Entity", Value: sp.Entity}, - {Key: "SyncMode", Value: sp.SyncMode}, - }) - } - return out -} - -// patchNativeProfile applies the spec to a native navigation profile. -func patchNativeProfile(doc bson.D, spec NavigationProfileSpec) bson.D { - var defaultHome *NavHomePageSpec - var roleHomes []NavHomePageSpec - for _, hp := range spec.HomePages { - if hp.ForRole == "" { - h := hp - defaultHome = &h - } else { - roleHomes = append(roleHomes, hp) - } - } - - if defaultHome != nil { - page := "" - nanoflow := "" - if defaultHome.IsPage { - page = defaultHome.Target - } else { - nanoflow = defaultHome.Target - } - doc = setBsonField(doc, "NativeHomePage", bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$NativeHomePage"}, - {Key: "HomePagePage", Value: page}, - {Key: "HomePageNanoflow", Value: nanoflow}, - }) - } - - // Role-based native home pages - roleItems := bson.A{navMarkerHomeItems} - for _, rh := range roleHomes { - page := "" - nanoflow := "" - if rh.IsPage { - page = rh.Target - } else { - nanoflow = rh.Target - } - roleItems = append(roleItems, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$RoleBasedNativeHomePage"}, - {Key: "UserRole", Value: rh.ForRole}, - {Key: "HomePagePage", Value: page}, - {Key: "HomePageNanoflow", Value: nanoflow}, - }) - } - doc = setBsonField(doc, "RoleBasedNativeHomePages", roleItems) - - return doc -} - -// buildHomePageBson builds a Navigation$HomePage BSON document. -func buildHomePageBson(hp *NavHomePageSpec) bson.D { - page := "" - mf := "" - if hp.IsPage { - page = hp.Target - } else { - mf = hp.Target - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$HomePage"}, - {Key: "Microflow", Value: mf}, - {Key: "Page", Value: page}, - } -} - -// buildRoleBasedHomeBson builds a Navigation$RoleBasedHomePage BSON document. -func buildRoleBasedHomeBson(rh NavHomePageSpec) bson.D { - page := "" - mf := "" - if rh.IsPage { - page = rh.Target - } else { - mf = rh.Target - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Navigation$RoleBasedHomePage"}, - {Key: "Microflow", Value: mf}, - {Key: "Page", Value: page}, - {Key: "UserRole", Value: rh.ForRole}, - } -} - -// buildFormSettingsBson builds a Forms$FormSettings BSON document with required fields. -func buildFormSettingsBson(formName string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormSettings"}, - {Key: "Form", Value: formName}, - {Key: "ParameterMappings", Value: bson.A{navMarkerParameterMappings}}, - // No override is an explicit null. An empty template overrides the page - // title with "" and produces CW0263 for every authored menu item (#812). - {Key: "TitleOverride", Value: nil}, - } -} - -// buildMenuItemBson builds a Menus$MenuItem BSON document recursively. -func buildMenuItemBson(mi NavMenuItemSpec) bson.D { - item := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Menus$MenuItem"}, - {Key: "Action", Value: buildMenuAction(mi)}, - {Key: "AlternativeText", Value: nil}, - {Key: "Caption", Value: buildCaptionBson(mi.Caption)}, - {Key: "Icon", Value: buildMenuIconBson(mi)}, - } - - // Sub-items - subItems := bson.A{navMarkerItems} - for _, sub := range mi.Items { - subItems = append(subItems, buildMenuItemBson(sub)) - } - item = append(item, bson.E{Key: "Items", Value: subItems}) - - return item -} - -// buildMenuIconBson builds a menu item's Icon, or nil when none is set. -// -// The metamodel calls this Pages$IconCollectionIcon, but the storage name is -// Forms$IconCollectionIcon — the same "Form was the original term for Page" -// rename CLAUDE.md documents for ShowFormAction. Verified against a Studio -// Pro-authored navigation document (ako/mxcli-ledger), whose menu icons are all -// Forms$IconCollectionIcon{Image: "Atlas_Core.Atlas.align-center"}, and matching -// the widget icon path already proven in issue #602. -// -// Two sibling variants exist in the same document — Forms$GlyphIcon{Code: int} -// and Forms$ImageIcon{Image: QN}. They used to be excluded because a name alone -// cannot tell an image icon from a collection icon without resolving which -// document it lands in, and guessing between polymorphic variants is the failure -// mode that produces a document mxbuild accepts and Studio Pro cannot open. -// -// Nothing is guessed now: the KIND is carried explicitly, from the author's own -// `icon image …` / `icon glyph …` or from the kind the reader saw in storage. So -// all three are emitted, and the branch is a dispatch rather than an inference. -// -// Excluding them was not neutral. `create or replace navigation` is a full -// replacement, so an icon the writer would not emit was an icon the statement -// DELETED — measured on testdata/expr-checker, exec of DESCRIBE's own output -// destroyed a glyph icon at exit 0. -func buildMenuIconBson(spec NavMenuItemSpec) interface{} { - kind := spec.IconKind - if kind == types.MenuIconNone && spec.Icon != "" { - // A spec built before the kind existed carries a name and nothing else, - // and that name has only ever meant an icon-collection icon. - kind = types.MenuIconCollection - } - storage := types.MenuIconStorageType(kind) - if storage == "" { - return nil - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: storage}, - } - if kind == types.MenuIconGlyph { - // A glyph with no code identifies no glyph. Emit no icon rather than an - // element nobody can see. - if spec.IconCode == 0 { - return nil - } - return append(doc, bson.E{Key: "Code", Value: int32(spec.IconCode)}) - } - if spec.Icon == "" { - return nil - } - return append(doc, bson.E{Key: "Image", Value: spec.Icon}) -} - -// buildCaptionBson builds a Texts$Text BSON document with a single en_US translation. -func buildCaptionBson(text string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{ - navMarkerItems, - bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: model.AuthoringLanguage()}, - {Key: "Text", Value: text}, - }, - }}, - } -} - -// buildMenuAction builds the Action BSON for a menu item based on its target. -func buildMenuAction(mi NavMenuItemSpec) bson.D { - if mi.Page != "" { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormAction"}, - {Key: "DisabledDuringExecution", Value: false}, - {Key: "FormSettings", Value: buildFormSettingsBson(mi.Page)}, - {Key: "NumberOfPagesToClose2", Value: ""}, - {Key: "PagesForSpecializations", Value: bson.A{navMarkerParameterMappings}}, - } - } - if mi.Microflow != "" { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowAction"}, - {Key: "DisabledDuringExecution", Value: false}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Microflow", Value: mi.Microflow}, - }}, - } - } - // No action (sub-menu container or plain item) - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NoAction"}, - } -} diff --git a/sdk/mpr/writer_navigation_icon_test.go b/sdk/mpr/writer_navigation_icon_test.go deleted file mode 100644 index 970ec70b97..0000000000 --- a/sdk/mpr/writer_navigation_icon_test.go +++ /dev/null @@ -1,272 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/mdl/types" - "testing" - - "go.mongodb.org/mongo-driver/bson" -) - -// navIconEntry returns the value of a key in a bson.D and whether it was -// present. Unlike the package's bsonLookup it separates an absent key from a -// null one — the distinction a null Icon turns on. -func navIconEntry(d bson.D, key string) (interface{}, bool) { - for _, e := range d { - if e.Key == key { - return e.Value, true - } - } - return nil, false -} - -// The storage name is the whole point of this test. -// -// The metamodel calls the element Pages$IconCollectionIcon, but Mendix stores it -// as Forms$IconCollectionIcon — the "Form was the original term for Page" rename -// CLAUDE.md documents for ShowFormAction. Getting a polymorphic child's $Type -// wrong yields a document mxbuild accepts (its deserializer tolerates unknown -// properties) and Studio Pro cannot open, so the name is pinned against a -// Studio Pro-authored reference: every menu icon in ako/mxcli-ledger's -// navigation document is Forms$IconCollectionIcon{Image: "Atlas_Core.Atlas.…"}. -func TestBuildMenuIconBson_UsesTheFormsStorageName(t *testing.T) { - got := buildMenuIconBson(NavMenuItemSpec{Icon: "Atlas_Core.Atlas.align-center"}) - d, ok := got.(bson.D) - if !ok { - t.Fatalf("expected a bson.D, got %T", got) - } - typ, _ := navIconEntry(d, "$Type") - if typ != "Forms$IconCollectionIcon" { - t.Errorf("$Type = %v, want Forms$IconCollectionIcon (NOT the metamodel's Pages$…)", typ) - } - img, present := navIconEntry(d, "Image") - if !present { - t.Fatal("the icon carries its name in Image; the key is missing") - } - if img != "Atlas_Core.Atlas.align-center" { - t.Errorf("Image = %v", img) - } - if _, present := navIconEntry(d, "$ID"); !present { - t.Error("every stored element needs its own $ID") - } - // Studio Pro writes exactly these three keys. A fourth would be a property - // the type does not declare, which is what makes a document unopenable. - if len(d) != 3 { - t.Errorf("icon has %d keys, want exactly $ID/$Type/Image: %v", len(d), d) - } -} - -// No icon must stay a null, not an empty element: an IconCollectionIcon with a -// blank Image is a dangling reference, where absent is the modelled default. -func TestBuildMenuIconBson_EmptyNameStaysNull(t *testing.T) { - if got := buildMenuIconBson(NavMenuItemSpec{}); got != nil { - t.Errorf("buildMenuIconBson(empty spec) = %v, want nil", got) - } -} - -// The regression this fixes: buildMenuItemBson hardcoded Icon to nil, so an icon -// the author wrote was dropped on the way to the file. Assert on the encoded -// item, not just the helper, so the wiring is covered too. -func TestBuildMenuItemBson_CarriesTheIconThrough(t *testing.T) { - item := buildMenuItemBson(NavMenuItemSpec{ - Caption: "Dashboard", - Page: "M.Dash", - Icon: "Atlas_Core.Atlas.align-center", - }) - icon, present := navIconEntry(item, "Icon") - if !present { - t.Fatal("the Icon key must always be written, even when null") - } - d, ok := icon.(bson.D) - if !ok { - t.Fatalf("Icon = %#v; the authored icon was dropped on the way to BSON", icon) - } - if img, _ := navIconEntry(d, "Image"); img != "Atlas_Core.Atlas.align-center" { - t.Errorf("Image = %v", img) - } -} - -// A sub-menu is built by the same recursion, so its icon has to survive it. -func TestBuildMenuItemBson_CarriesTheIconThroughSubItems(t *testing.T) { - item := buildMenuItemBson(NavMenuItemSpec{ - Caption: "Reports", - Items: []NavMenuItemSpec{{ - Caption: "Monthly", Page: "M.Monthly", Icon: "Atlas_Core.Atlas.folder", - }}, - }) - items, _ := navIconEntry(item, "Items") - arr, ok := items.(bson.A) - if !ok || len(arr) != 2 { // [list-marker, one child] - t.Fatalf("Items = %#v", items) - } - sub, ok := arr[1].(bson.D) - if !ok { - t.Fatalf("sub-item = %#v", arr[1]) - } - icon, _ := navIconEntry(sub, "Icon") - d, ok := icon.(bson.D) - if !ok { - t.Fatalf("sub-item Icon = %#v; the recursion dropped it", icon) - } - if img, _ := navIconEntry(d, "Image"); img != "Atlas_Core.Atlas.folder" { - t.Errorf("sub-item Image = %v", img) - } -} - -// An item written without an icon keeps the null it had before this change. -func TestBuildMenuItemBson_NoIconStillWritesNull(t *testing.T) { - item := buildMenuItemBson(NavMenuItemSpec{Caption: "Dashboard", Page: "M.Dash"}) - icon, present := navIconEntry(item, "Icon") - if !present { - t.Fatal("the Icon key must be written even with no icon") - } - if icon != nil { - t.Errorf("Icon = %#v, want nil", icon) - } -} - -// Studio Pro stores the absence of a page-title override as an explicit null. -// An empty TextTemplate is a real override to "" and raises CW0263. -func TestBuildFormSettingsBson_NoTitleOverrideStaysNull(t *testing.T) { - settings := buildFormSettingsBson("M.Dash") - title, present := navIconEntry(settings, "TitleOverride") - if !present { - t.Fatal("TitleOverride key missing; Studio Pro writes an explicit null") - } - if title != nil { - t.Fatalf("TitleOverride = %#v, want nil", title) - } -} - -// The read side has to recognise all three variants, because a project authored -// in Studio Pro contains all three. The fixtures are the literal shapes dumped -// from ako/mxcli-ledger's navigation document. -func TestParseNavMenuItem_ReadsEachIconVariant(t *testing.T) { - for _, tc := range []struct { - name string - icon map[string]any - wantType, want string - }{ - { - name: "icon collection", - icon: map[string]any{"$Type": "Forms$IconCollectionIcon", "Image": "Atlas_Core.Atlas.align-center"}, - wantType: "Forms$IconCollectionIcon", want: "Atlas_Core.Atlas.align-center", - }, - { - name: "image", - icon: map[string]any{"$Type": "Forms$ImageIcon", "Image": "System.Images.Close"}, - wantType: "Forms$ImageIcon", want: "System.Images.Close", - }, - { - // A glyph carries a numeric Code and no name at all. Reporting an - // empty Icon here is load-bearing: it is what stops DESCRIBE from - // emitting an ICON clause that would convert the variant on replay. - name: "glyph", - icon: map[string]any{"$Type": "Forms$GlyphIcon", "Code": int32(9999)}, - wantType: "Forms$GlyphIcon", want: "", - }, - } { - t.Run(tc.name, func(t *testing.T) { - mi := parseNavMenuItem(map[string]any{ - "Caption": map[string]any{}, - "Icon": tc.icon, - "Action": map[string]any{ - "$Type": "Forms$FormAction", - "FormSettings": map[string]any{"Form": "M.Dash"}, - }, - }) - if mi == nil { - t.Fatal("the item did not parse") - } - if mi.IconType != tc.wantType { - t.Errorf("IconType = %q, want %q", mi.IconType, tc.wantType) - } - if mi.Icon != tc.want { - t.Errorf("Icon = %q, want %q", mi.Icon, tc.want) - } - }) - } -} - -// A menu item with no icon must read back as no icon, not as an empty-named one. -func TestParseNavMenuItem_NoIconReadsAsNone(t *testing.T) { - mi := parseNavMenuItem(map[string]any{ - "Caption": map[string]any{}, - "Action": map[string]any{ - "$Type": "Forms$FormAction", "FormSettings": map[string]any{"Form": "M.Dash"}, - }, - }) - if mi == nil { - t.Fatal("the item did not parse") - } - if mi.Icon != "" || mi.IconType != "" { - t.Errorf("Icon/IconType = (%q, %q), want both empty", mi.Icon, mi.IconType) - } -} - -// All three icon elements are written now. Only the collection variant used to -// be, and because `create or replace navigation` is a full replacement, an icon -// the writer would not emit was an icon the statement DELETED — measured on -// testdata/expr-checker, exec of DESCRIBE's own output destroyed a glyph icon at -// exit 0. -func TestBuildMenuIconBson_Glyph(t *testing.T) { - got, ok := buildMenuIconBson(NavMenuItemSpec{IconKind: types.MenuIconGlyph, IconCode: 57345}).(bson.D) - if !ok { - t.Fatal("a glyph icon produced no document") - } - m := bsonDToMap(got) - if m["$Type"] != "Forms$GlyphIcon" { - t.Errorf("$Type = %v, want Forms$GlyphIcon", m["$Type"]) - } - if m["Code"] != int32(57345) { - t.Errorf("Code = %#v, want int32(57345) — Mendix stores the character code as an int32", m["Code"]) - } - if _, hasImage := m["Image"]; hasImage { - t.Error("a glyph icon must not carry an Image; it has no qualified name") - } -} - -func TestBuildMenuIconBson_Image(t *testing.T) { - got, ok := buildMenuIconBson(NavMenuItemSpec{IconKind: types.MenuIconImage, Icon: "MyMod.Images.logo"}).(bson.D) - if !ok { - t.Fatal("an image icon produced no document") - } - m := bsonDToMap(got) - if m["$Type"] != "Forms$ImageIcon" { - t.Errorf("$Type = %v, want Forms$ImageIcon", m["$Type"]) - } - if m["Image"] != "MyMod.Images.logo" { - t.Errorf("Image = %v", m["Image"]) - } -} - -// The control that keeps the dispatch honest: a name with NO kind still means an -// icon-collection icon. Every script written before the kind existed carries -// exactly that, so treating it as "unknown" would silently drop every icon in -// the corpus. -func TestBuildMenuIconBson_BareNameIsStillACollectionIcon(t *testing.T) { - got, ok := buildMenuIconBson(NavMenuItemSpec{Icon: "Atlas_Core.Atlas.home"}).(bson.D) - if !ok { - t.Fatal("a bare name produced no document") - } - if bsonDToMap(got)["$Type"] != "Forms$IconCollectionIcon" { - t.Errorf("$Type = %v, want Forms$IconCollectionIcon", bsonDToMap(got)["$Type"]) - } -} - -// A glyph with no code identifies no glyph, and an element with no Code renders -// as a blank where an icon should be. Emit nothing instead. -func TestBuildMenuIconBson_GlyphWithoutACodeIsNoIcon(t *testing.T) { - if got := buildMenuIconBson(NavMenuItemSpec{IconKind: types.MenuIconGlyph}); got != nil { - t.Errorf("got %v, want nil", got) - } -} - -func bsonDToMap(d bson.D) map[string]any { - m := make(map[string]any, len(d)) - for _, e := range d { - m[e.Key] = e.Value - } - return m -} diff --git a/sdk/mpr/writer_navigation_notfound_test.go b/sdk/mpr/writer_navigation_notfound_test.go deleted file mode 100644 index 7f977d4259..0000000000 --- a/sdk/mpr/writer_navigation_notfound_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" -) - -// navNotFoundEntry returns the value of a key and whether it was present, -// separating an absent key from an explicitly null one. -func navNotFoundEntry(d bson.D, key string) (interface{}, bool) { - for _, e := range d { - if e.Key == key { - return e.Value, true - } - } - return nil, false -} - -// notFoundHomepageOf patches a bare web profile with the given spec and returns -// the NotFoundHomepage it wrote. -func notFoundHomepageOf(t *testing.T, spec NavigationProfileSpec) (interface{}, bool) { - t.Helper() - return navNotFoundEntry(patchWebProfile(bson.D{}, spec), "NotFoundHomepage") -} - -// Studio Pro's "Fallback page" is its own type. The NotFoundHomepage property is -// declared Navigation$NotFoundHomePage, NOT the Navigation$HomePage the home-page -// slot takes, and .NET refuses the assignment on load: -// -// System.ArgumentException: Object of type -// 'Mendix.Modeler.WebUI.Navigation.HomePage' cannot be converted to type -// 'Mendix.Modeler.WebUI.Navigation.NotFoundHomePage'. -// -// The project is then unopenable — and because the failure happens while LOADING -// the model, `mx check` prints that trace INSTEAD OF its "The app contains: N -// errors" line, so a caller reading the count rather than the exit status sees a -// run that reported nothing at all (mendixlabs/mxcli#1000). -// -// This is the writer the default engine uses, and the one that produced the -// unopenable project in #1000's report. It had no test: reverting just this -// $Type left the whole suite green. -func TestPatchWebProfile_NotFoundPageUsesItsOwnType(t *testing.T) { - nfp, present := notFoundHomepageOf(t, NavigationProfileSpec{ - NotFoundPage: "MyFirstModule.NotFound", - }) - if !present { - t.Fatal("NotFoundHomepage key missing") - } - d, ok := nfp.(bson.D) - if !ok { - t.Fatalf("NotFoundHomepage = %#v, want a document", nfp) - } - if typ, _ := navNotFoundEntry(d, "$Type"); typ != "Navigation$NotFoundHomePage" { - t.Errorf("$Type = %v, want Navigation$NotFoundHomePage (NOT Navigation$HomePage)", typ) - } - if page, _ := navNotFoundEntry(d, "Page"); page != "MyFirstModule.NotFound" { - t.Errorf("Page = %v", page) - } - if _, present := navNotFoundEntry(d, "$ID"); !present { - t.Error("every stored element needs its own $ID") - } -} - -// The two slots are adjacent, take the same Page/Microflow pair, and differ only -// in $Type — so a fix applied one `sed` too wide silently converts the home page -// as well. HomePage keeps Navigation$HomePage. -func TestPatchWebProfile_HomePageKeepsTheHomePageType(t *testing.T) { - doc := patchWebProfile(bson.D{}, NavigationProfileSpec{ - HomePages: []NavHomePageSpec{{IsPage: true, Target: "MyFirstModule.Home"}}, - NotFoundPage: "MyFirstModule.NotFound", - }) - hp, present := navNotFoundEntry(doc, "HomePage") - if !present { - t.Fatal("HomePage key missing") - } - d, ok := hp.(bson.D) - if !ok { - t.Fatalf("HomePage = %#v, want a document", hp) - } - if typ, _ := navNotFoundEntry(d, "$Type"); typ != "Navigation$HomePage" { - t.Errorf("HomePage $Type = %v, want Navigation$HomePage", typ) - } -} - -// No fallback page is an explicit null, not an element with a blank Page: a -// NotFoundHomePage pointing at "" is a dangling reference where absent is the -// modelled default. -func TestPatchWebProfile_NoNotFoundPageStaysNull(t *testing.T) { - nfp, present := notFoundHomepageOf(t, NavigationProfileSpec{}) - if !present { - t.Fatal("the NotFoundHomepage key must be written even when unset") - } - if nfp != nil { - t.Errorf("NotFoundHomepage = %#v, want nil", nfp) - } -} diff --git a/sdk/mpr/writer_navigation_offline_test.go b/sdk/mpr/writer_navigation_offline_test.go deleted file mode 100644 index 73c5e4489e..0000000000 --- a/sdk/mpr/writer_navigation_offline_test.go +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" -) - -// The legacy engine must behave identically to modelsdk here. Two writers that -// drift apart is how an engine-specific defect hides: a project written on one -// engine and rewritten on the other would lose the property on exactly one of -// the two paths, and nothing reports it. -func TestLegacyOfflineWriteCarriesCompatibilityMode(t *testing.T) { - stored := bson.A{ - navMarkerItems, - bson.D{ - {Key: "$Type", Value: "Navigation$OfflineEntityConfig"}, - {Key: "CompatibilityMode", Value: true}, - {Key: "Entity", Value: "Rules.RuleAction"}, - {Key: "SyncMode", Value: "All"}, - }, - } - out := buildOfflineConfigsBson(stored, []NavOfflineEntitySpec{ - {Entity: "Rules.RuleAction", SyncMode: "Never"}, - }) - if len(out) != 2 { - t.Fatalf("expected marker + 1 config, got %d", len(out)) - } - got := out[1].(bson.D).Map() - if got["CompatibilityMode"] != true { - t.Error("legacy dropped CompatibilityMode on a rewrite that never mentioned it") - } - if got["SyncMode"] != "Never" { - t.Errorf("SyncMode = %v, want Never", got["SyncMode"]) - } -} - -// The reader hands back map[string]any rather than bson.D on some paths, and a -// carry that only understood one shape would silently default to false for the -// other — which is the shape the legacy parser actually produces. -func TestLegacyOfflineWriteReadsEitherStoredShape(t *testing.T) { - for name, stored := range map[string]bson.A{ - "bson.D": {navMarkerItems, bson.D{ - {Key: "CompatibilityMode", Value: true}, {Key: "Entity", Value: "Mod.E"}}}, - "map": {navMarkerItems, map[string]any{ - "CompatibilityMode": true, "Entity": "Mod.E"}}, - } { - t.Run(name, func(t *testing.T) { - out := buildOfflineConfigsBson(stored, []NavOfflineEntitySpec{{Entity: "Mod.E", SyncMode: "All"}}) - if got := out[1].(bson.D).Map(); got["CompatibilityMode"] != true { - t.Errorf("carry failed for a stored config shaped as %s", name) - } - }) - } -} - -func TestLegacyOfflineWriteEmitsTheSamePropertiesAsModelsdk(t *testing.T) { - out := buildOfflineConfigsBson(bson.A{navMarkerItems}, - []NavOfflineEntitySpec{{Entity: "Mod.E", SyncMode: "All"}}) - if out[0] != navMarkerItems { - t.Errorf("marker = %v, want %v", out[0], navMarkerItems) - } - got := out[1].(bson.D).Map() - for _, absent := range []string{"DownloadMode", "ShouldDownload"} { - if _, present := got[absent]; present { - t.Errorf("%s must not be written", absent) - } - } - if len(got) != 6 { - t.Errorf("wrote %d properties (%v), want $ID + $Type + the four Studio Pro writes", len(got), got) - } -} diff --git a/sdk/mpr/writer_odata.go b/sdk/mpr/writer_odata.go deleted file mode 100644 index 96e6fe1d5d..0000000000 --- a/sdk/mpr/writer_odata.go +++ /dev/null @@ -1,636 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================ -// Consumed OData Service (OData Client) — Rest$ConsumedODataService -// ============================================================================ - -// CreateConsumedODataService creates a new consumed OData service (client) document. -func (w *Writer) CreateConsumedODataService(svc *model.ConsumedODataService) error { - if svc.ID == "" { - svc.ID = model.ID(generateUUID()) - } - svc.TypeName = "Rest$ConsumedODataService" - - contents, err := w.serializeConsumedODataService(svc) - if err != nil { - return fmt.Errorf("failed to serialize consumed OData service: %w", err) - } - - return w.insertUnit(string(svc.ID), string(svc.ContainerID), "Documents", "Rest$ConsumedODataService", contents) -} - -// UpdateConsumedODataService updates an existing consumed OData service. -func (w *Writer) UpdateConsumedODataService(svc *model.ConsumedODataService) error { - contents, err := w.serializeConsumedODataService(svc) - if err != nil { - return fmt.Errorf("failed to serialize consumed OData service: %w", err) - } - - return w.updateUnit(string(svc.ID), contents) -} - -// DeleteConsumedODataService deletes a consumed OData service by ID. -func (w *Writer) DeleteConsumedODataService(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// serializeConsumedODataService converts a ConsumedODataService to BSON bytes. -func (w *Writer) serializeConsumedODataService(svc *model.ConsumedODataService) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(svc.ID))}, - {Key: "$Type", Value: "Rest$ConsumedODataService"}, - {Key: "Name", Value: svc.Name}, - {Key: "Documentation", Value: svc.Documentation}, - {Key: "Version", Value: svc.Version}, - {Key: "ServiceName", Value: svc.ServiceName}, - {Key: "ODataVersion", Value: svc.ODataVersion}, - {Key: "MetadataUrl", Value: svc.MetadataUrl}, - {Key: "TimeoutExpression", Value: svc.TimeoutExpression}, - {Key: "ProxyType", Value: svc.ProxyType}, - {Key: "Description", Value: svc.Description}, - {Key: "Validated", Value: svc.Validated}, - {Key: "Excluded", Value: svc.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Metadata", Value: svc.Metadata}, - {Key: "MetadataHash", Value: svc.MetadataHash}, - {Key: "MetadataReferences", Value: bson.A{int32(0)}}, // empty BSON array marker - {Key: "ValidatedEntities", Value: bson.A{int32(0)}}, // empty BSON array marker - {Key: "LastUpdated", Value: ""}, - {Key: "UseQuerySegment", Value: false}, - {Key: "MinimumMxVersion", Value: ""}, - {Key: "RecommendedMxVersion", Value: ""}, - } - - // Microflow reference (BY_NAME). Mendix renamed this storage field across - // versions: `ConfigurationMicroflow` (10.12–11.10) → `ConfigurationEntity- - // Microflow` (11.10+). Writing the wrong key makes Studio Pro ignore it and - // fall back to "Constants only" (issue #728), so gate on the project version. - configKey, headersKey := "ConfigurationMicroflow", "ConfigurationMicroflow" - if w.reader != nil { - if pv := w.reader.ProjectVersion(); pv != nil { - configKey = model.ODataConfigMicroflowBSONKey(pv.MajorVersion, pv.MinorVersion) - headersKey = model.ODataHeadersMicroflowBSONKey(pv.MajorVersion, pv.MinorVersion) - } - } - if svc.ConfigurationMicroflow != "" { - doc = append(doc, bson.E{Key: configKey, Value: svc.ConfigurationMicroflow}) - } - if svc.HeadersMicroflow != "" { - doc = append(doc, bson.E{Key: headersKey, Value: svc.HeadersMicroflow}) - } - if svc.ErrorHandlingMicroflow != "" { - doc = append(doc, bson.E{Key: "ErrorHandlingMicroflow", Value: svc.ErrorHandlingMicroflow}) - } - - // Proxy constant references (BY_NAME) - if svc.ProxyHost != "" { - doc = append(doc, bson.E{Key: "ProxyHost", Value: svc.ProxyHost}) - } - if svc.ProxyPort != "" { - doc = append(doc, bson.E{Key: "ProxyPort", Value: svc.ProxyPort}) - } - if svc.ProxyUsername != "" { - doc = append(doc, bson.E{Key: "ProxyUsername", Value: svc.ProxyUsername}) - } - if svc.ProxyPassword != "" { - doc = append(doc, bson.E{Key: "ProxyPassword", Value: svc.ProxyPassword}) - } - - // Mendix Catalog integration (optional) - if svc.ApplicationId != "" { - doc = append(doc, bson.E{Key: "ApplicationId", Value: svc.ApplicationId}) - } - if svc.EndpointId != "" { - doc = append(doc, bson.E{Key: "EndpointId", Value: svc.EndpointId}) - } - if svc.CatalogUrl != "" { - doc = append(doc, bson.E{Key: "CatalogUrl", Value: svc.CatalogUrl}) - } - if svc.EnvironmentType != "" { - doc = append(doc, bson.E{Key: "EnvironmentType", Value: svc.EnvironmentType}) - } - - // HTTP configuration (required nested part) - doc = append(doc, bson.E{Key: "HttpConfiguration", Value: serializeHttpConfiguration(svc.HttpConfiguration)}) - - return marshalUnitIDFirst(doc) -} - -// serializeHttpConfiguration converts an HttpConfiguration to a BSON map. -// If cfg is nil, a minimal default configuration is created. -func serializeHttpConfiguration(cfg *model.HttpConfiguration) bson.D { - cfgID := generateUUID() - if cfg != nil && cfg.ID != "" { - cfgID = string(cfg.ID) - } - - // Field defaults; overridden below when cfg is provided. These are - // resolved before building the ordered document so that providing a - // cfg replaces (rather than duplicates) the default entries. - useHttpAuthentication := false - httpAuthenticationUserName := "" - httpAuthenticationPassword := "" - httpMethod := "Post" - overrideLocation := false - customLocation := "" - clientCertificate := "" - var httpHeaderEntries bson.A - - if cfg != nil { - useHttpAuthentication = cfg.UseAuthentication - httpAuthenticationUserName = cfg.Username - httpAuthenticationPassword = cfg.Password - if cfg.HttpMethod != "" { - httpMethod = cfg.HttpMethod - } - overrideLocation = cfg.OverrideLocation - customLocation = cfg.CustomLocation - clientCertificate = cfg.ClientCertificate - - // Serialize header entries - if len(cfg.HeaderEntries) > 0 { - headers := bson.A{int32(3)} - for _, h := range cfg.HeaderEntries { - hID := string(h.ID) - if hID == "" { - hID = generateUUID() - } - headers = append(headers, bson.D{ - {Key: "$ID", Value: idToBsonBinary(hID)}, - {Key: "$Type", Value: "Microflows$HttpHeaderEntry"}, - {Key: "Key", Value: h.Key}, - {Key: "Value", Value: h.Value}, - }) - } - httpHeaderEntries = headers - } - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(cfgID)}, - {Key: "$Type", Value: "Microflows$HttpConfiguration"}, - {Key: "UseHttpAuthentication", Value: useHttpAuthentication}, - {Key: "HttpAuthenticationUserName", Value: httpAuthenticationUserName}, - {Key: "HttpAuthenticationPassword", Value: httpAuthenticationPassword}, - {Key: "HttpMethod", Value: httpMethod}, - {Key: "OverrideLocation", Value: overrideLocation}, - {Key: "CustomLocation", Value: customLocation}, - {Key: "ClientCertificate", Value: clientCertificate}, - } - - if httpHeaderEntries != nil { - doc = append(doc, bson.E{Key: "HttpHeaderEntries", Value: httpHeaderEntries}) - } - - return doc -} - -// ============================================================================ -// Published OData Service — ODataPublish$PublishedODataService2 -// ============================================================================ - -// CreatePublishedODataService creates a new published OData service document. -func (w *Writer) CreatePublishedODataService(svc *model.PublishedODataService) error { - if svc.ID == "" { - svc.ID = model.ID(generateUUID()) - } - svc.TypeName = "ODataPublish$PublishedODataService2" - - contents, err := w.serializePublishedODataService(svc) - if err != nil { - return fmt.Errorf("failed to serialize published OData service: %w", err) - } - - return w.insertUnit(string(svc.ID), string(svc.ContainerID), "Documents", "ODataPublish$PublishedODataService2", contents) -} - -// UpdatePublishedODataService updates an existing published OData service. -func (w *Writer) UpdatePublishedODataService(svc *model.PublishedODataService) error { - contents, err := w.serializePublishedODataService(svc) - if err != nil { - return fmt.Errorf("failed to serialize published OData service: %w", err) - } - - return w.updateUnit(string(svc.ID), contents) -} - -// DeletePublishedODataService deletes a published OData service by ID. -func (w *Writer) DeletePublishedODataService(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// serializePublishedODataService converts a PublishedODataService to BSON bytes. -func (w *Writer) serializePublishedODataService(svc *model.PublishedODataService) ([]byte, error) { - // Authentication types array (versioned: starts with int32(3)) - authTypes := bson.A{int32(3)} - for _, at := range svc.AuthenticationTypes { - authTypes = append(authTypes, at) - } - - // AllowedModuleRoles: BY_NAME references, storage marker 1 — the same array - // shape the working GRANT path writes (makeMendixStringArray). - // - // This document is serialized wholesale and written with updateUnit, so a - // field the serializer omits is not left alone: it is deleted. Omitting it - // silently revoked a service's access on every `create or modify`, and the - // next build failed with "At least one allowed role must be selected for the - // published OData service to be accessible." Grants are made by a separate - // statement (`grant access on odata service …`) and cannot be re-stated in - // the create script, so nothing in the script could put them back - // (mxcli-formula1 §26). - // Published microflows — OData actions. Mendix turns each into an - // ActionImport in $metadata; without them a parameterised resource has to be - // modelled as an entity set echoing its own arguments back as columns - // (mxcli-formula1 §47). - publishedMicroflows := bson.A{int32(3)} - for _, pm := range svc.Microflows { - publishedMicroflows = append(publishedMicroflows, serializePublishedMicroflow(pm)) - } - - allowedRoles := bson.A{int32(1)} - for _, name := range svc.AllowedModuleRoles { - allowedRoles = append(allowedRoles, name) - } - - // Serialize entity types and build ID map for entity set pointers. - // Issue #595: key by qualified entity name (et.Entity), not ExposedName. - // PublishedEntitySet.EntityTypeName holds the qualified name, so keying - // by ExposedName made the lookup return "" and EntityTypePointer was - // never written. Studio Pro's EntitySet.Check then NREs dereferencing - // the missing pointer and aborts the whole project checker. - // - // Versioned BSON arrays in Mendix start with an int32 storage marker - // (typically 3). Without it Mendix treats the array as malformed and - // silently drops elements after the first — observed in CE6585 firing - // on the second entity in a multi-entity service. - entityTypeIDMap := make(map[string]string) // qualified entity name -> entity type ID - entityTypes := bson.A{int32(3)} - for _, et := range svc.EntityTypes { - etID := string(et.ID) - if etID == "" { - etID = generateUUID() - et.ID = model.ID(etID) - } - entityTypeIDMap[et.Entity] = etID - entityTypes = append(entityTypes, serializePublishedEntityType(et)) - } - - // Serialize entity sets with BY_ID pointers to entity types - entitySets := bson.A{int32(3)} - for _, es := range svc.EntitySets { - esID := string(es.ID) - if esID == "" { - esID = generateUUID() - es.ID = model.ID(esID) - } - // Resolve EntityTypeName to EntityType ID - entityTypeID := entityTypeIDMap[es.EntityTypeName] - entitySets = append(entitySets, serializePublishedEntitySet(es, entityTypeID)) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(svc.ID))}, - {Key: "$Type", Value: "ODataPublish$PublishedODataService2"}, - {Key: "Name", Value: svc.Name}, - {Key: "Documentation", Value: svc.Documentation}, - {Key: "Path", Value: svc.Path}, - {Key: "Namespace", Value: svc.Namespace}, - {Key: "ServiceName", Value: svc.ServiceName}, - {Key: "Version", Value: svc.Version}, - {Key: "ODataVersion", Value: svc.ODataVersion}, - {Key: "Summary", Value: svc.Summary}, - {Key: "Description", Value: svc.Description}, - {Key: "PublishAssociations", Value: svc.PublishAssociations}, - {Key: "SupportsGraphQL", Value: svc.SupportsGraphQL}, - {Key: "UseGeneralization", Value: svc.UseGeneralization}, - {Key: "AuthenticationMicroflow", Value: svc.AuthMicroflow}, - {Key: "AllowedModuleRoles", Value: allowedRoles}, - {Key: "AuthenticationTypes", Value: authTypes}, - {Key: "EntityTypes", Value: entityTypes}, - {Key: "EntitySets", Value: entitySets}, - {Key: "Excluded", Value: svc.Excluded}, - // Empty collection markers required by Studio Pro 11.10. Without - // these fields Mendix can resolve the first entity's key but fails - // to resolve the second's (CE6585) — observed when comparing a - // Studio Pro-authored multi-entity service against ours. - {Key: "Enumerations", Value: bson.A{int32(3)}}, - {Key: "Microflows", Value: publishedMicroflows}, - {Key: "IncludeMetadataByDefault", Value: true}, - {Key: "ReplaceIllegalChars", Value: false}, - {Key: "SupportsGraphQL", Value: false}, - } - return marshalUnitIDFirst(doc) -} - -// boolOrTrue resolves a tri-state query option: nil keeps Mendix's default of -// true, and only an explicit false turns the capability off. -func boolOrTrue(p *bool) bool { return p == nil || *p } - -// serializePublishedEntityType converts a PublishedEntityType to a BSON map. -func serializePublishedEntityType(et *model.PublishedEntityType) bson.D { - // Serialize child members. Pass the owning entity's qualified name so - // the writer can emit fully-qualified Attribute / Association BSON - // references (Module.Entity.AttributeName) — Studio Pro and mx check - // require these to be qualified, and using bare names made the second - // entity's members silently fail to link in a multi-entity service. - // Like EntityTypes / EntitySets, ChildMembers is a Mendix versioned - // array and must start with the int32(3) storage marker. - members := bson.A{int32(3)} - for _, m := range et.Members { - members = append(members, serializePublishedMember(m, et.Entity)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(et.ID))}, - {Key: "$Type", Value: "ODataPublish$EntityType"}, - {Key: "Entity", Value: et.Entity}, - {Key: "ExposedName", Value: et.ExposedName}, - {Key: "Summary", Value: et.Summary}, - {Key: "Description", Value: et.Description}, - {Key: "ChildMembers", Value: members}, - } -} - -// serializePublishedEntitySet converts a PublishedEntitySet to a BSON map. -func serializePublishedEntitySet(es *model.PublishedEntitySet, entityTypeID string) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(es.ID))}, - {Key: "$Type", Value: "ODataPublish$EntitySet"}, - {Key: "ExposedName", Value: es.ExposedName}, - {Key: "AlternativeExposedName", Value: ""}, - {Key: "UsePaging", Value: es.UsePaging}, - {Key: "PageSize", Value: int64(es.PageSize)}, - // QueryOptions is required by Studio Pro's BSON shape for the - // entity set to be considered valid. Without it the second - // published entity in a multi-entity service fails to resolve - // its key (CE6585) — see Studio Pro reference dump. - // nil means "not specified" and keeps Mendix's own default of true; only - // an explicit false turns one off. These were hardcoded true, so - // `publish entity … (TopSupported: No)` parsed, described back as No, and - // was published as Yes — mxcli asserting a capability the author had - // explicitly disowned. For a microflow-backed resource the claim is - // especially load-bearing: Mendix applies no query options itself, so the - // annotation is the only thing a client has to go on (mxcli-formula1 §20). - {Key: "QueryOptions", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "ODataPublish$QueryOptions"}, - {Key: "Countable", Value: boolOrTrue(es.Countable)}, - {Key: "SkipSupported", Value: boolOrTrue(es.SkipSupported)}, - {Key: "TopSupported", Value: boolOrTrue(es.TopSupported)}, - }}, - } - - // EntityTypePointer is a BY_ID reference - if entityTypeID != "" { - doc = append(doc, bson.E{Key: "EntityTypePointer", Value: idToBsonBinary(entityTypeID)}) - } - - // Serialize mode objects - if es.ReadMode != "" { - doc = append(doc, bson.E{Key: "ReadMode", Value: serializeReadMode(es.ReadMode)}) - } - if es.InsertMode != "" { - doc = append(doc, bson.E{Key: "InsertMode", Value: serializeChangeMode(es.InsertMode)}) - } - if es.UpdateMode != "" { - doc = append(doc, bson.E{Key: "UpdateMode", Value: serializeChangeMode(es.UpdateMode)}) - } - if es.DeleteMode != "" { - doc = append(doc, bson.E{Key: "DeleteMode", Value: serializeChangeMode(es.DeleteMode)}) - } - - return doc -} - -// serializePublishedMember converts a PublishedMember to a BSON map. -// `ownerQN` is the qualified name (Module.Entity) of the EntityType this -// member belongs to. Mendix expects PublishedAttribute.Attribute and -// PublishedAssociationEnd.Association BSON values to be fully qualified — -// "Module.Entity.AttributeName" for attributes and "Module.AssociationName" -// for associations. If the AST already supplied a qualified name (contains -// a dot), it's used as-is; otherwise the owner is prepended. -func serializePublishedMember(m *model.PublishedMember, ownerQN string) bson.D { - memberID := string(m.ID) - if memberID == "" { - memberID = generateUUID() - } - - // Base fields written by Studio Pro for both attribute and association - // members. Description/Summary stay empty in this writer; CanBeEmpty - // defaults to !IsPartOfKey (keys are required to have a value) which - // matches Studio Pro's convention and is required for Mendix to - // recognise the attribute as a valid OData key (CE6585). - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(memberID)}, - {Key: "ExposedName", Value: m.ExposedName}, - {Key: "CanBeEmpty", Value: !m.IsPartOfKey}, - {Key: "Description", Value: ""}, - {Key: "Summary", Value: ""}, - } - - switch m.Kind { - case "attribute": - doc = append(doc, bson.E{Key: "$Type", Value: "ODataPublish$PublishedAttribute"}) - doc = append(doc, bson.E{Key: "Attribute", Value: qualifyMemberName(m.Name, ownerQN)}) - // EdmType is the published OData type; without it Studio Pro reports - // CE5016 ("published as ."). Verified against Studio Pro's corrected BSON. - doc = append(doc, bson.E{Key: "EdmType", Value: m.EdmType}) - doc = append(doc, bson.E{Key: "Filterable", Value: m.Filterable}) - doc = append(doc, bson.E{Key: "Sortable", Value: m.Sortable}) - doc = append(doc, bson.E{Key: "IsPartOfKey", Value: m.IsPartOfKey}) - doc = append(doc, bson.E{Key: "EnumerationAsString", Value: m.EnumerationAsString}) - doc = append(doc, bson.E{Key: "StringAsGuid", Value: false}) - case "association": - doc = append(doc, bson.E{Key: "$Type", Value: "ODataPublish$PublishedAssociationEnd"}) - // Associations live at module scope (Module.AssocName), so prepend - // only the module portion of the owner. - doc = append(doc, bson.E{Key: "Association", Value: qualifyAssociationName(m.Name, ownerQN)}) - // AssociationEnd carries the target entity and a separate - // ExposedAssociationName (typically the bare assoc name). Both - // are required by Studio Pro's BSON shape. - doc = append(doc, bson.E{Key: "Entity", Value: m.AssociationTargetEntity}) - // IsMany is the exposed navigation's multiplicity; without it Studio Pro - // reports CE5022 ("changed multiplicity"). Verified against Studio Pro BSON. - doc = append(doc, bson.E{Key: "IsMany", Value: m.IsMany}) - doc = append(doc, bson.E{Key: "ExposedAssociationName", Value: m.ExposedAssociationName}) - case "id": - doc = append(doc, bson.E{Key: "$Type", Value: "ODataPublish$PublishedId"}) - doc = append(doc, bson.E{Key: "Attribute", Value: qualifyMemberName(m.Name, ownerQN)}) - doc = append(doc, bson.E{Key: "Filterable", Value: m.Filterable}) - doc = append(doc, bson.E{Key: "Sortable", Value: m.Sortable}) - doc = append(doc, bson.E{Key: "IsPartOfKey", Value: m.IsPartOfKey}) - default: - // Default to attribute for unknown kinds - doc = append(doc, bson.E{Key: "$Type", Value: "ODataPublish$PublishedAttribute"}) - doc = append(doc, bson.E{Key: "Attribute", Value: qualifyMemberName(m.Name, ownerQN)}) - doc = append(doc, bson.E{Key: "Filterable", Value: m.Filterable}) - doc = append(doc, bson.E{Key: "Sortable", Value: m.Sortable}) - doc = append(doc, bson.E{Key: "IsPartOfKey", Value: m.IsPartOfKey}) - doc = append(doc, bson.E{Key: "EnumerationAsString", Value: false}) - doc = append(doc, bson.E{Key: "StringAsGuid", Value: false}) - } - - return doc -} - -// qualifyMemberName prepends the owning entity's qualified name (Module.Entity) -// to a bare attribute name. If `name` already contains a dot (already qualified) -// or `ownerQN` is empty, the original is returned unchanged. -func qualifyMemberName(name, ownerQN string) string { - if name == "" || ownerQN == "" || strings.Contains(name, ".") { - return name - } - return ownerQN + "." + name -} - -// qualifyAssociationName prepends the owning entity's module to a bare -// association name. Associations live at module scope, so only the module -// portion of `ownerQN` is used. -func qualifyAssociationName(name, ownerQN string) string { - if name == "" || ownerQN == "" || strings.Contains(name, ".") { - return name - } - if idx := strings.IndexByte(ownerQN, '.'); idx > 0 { - return ownerQN[:idx] + "." + name - } - return name -} - -// serializeReadMode converts a read mode string to a BSON mode object. -// Accepts both parsed format ("ReadFromDatabase") and MDL format ("SOURCE"). -func serializeReadMode(mode string) bson.D { - modeID := idToBsonBinary(generateUUID()) - - switch { - case strings.EqualFold(mode, "ReadFromDatabase") || strings.EqualFold(mode, "SOURCE"): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$ReadSource"}, - } - case strings.HasPrefix(mode, "CallMicroflow:"): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$CallMicroflowToRead"}, - {Key: "Microflow", Value: strings.TrimPrefix(mode, "CallMicroflow:")}, - } - case strings.HasPrefix(mode, "MICROFLOW "): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$CallMicroflowToRead"}, - {Key: "Microflow", Value: strings.TrimPrefix(mode, "MICROFLOW ")}, - } - default: - // Unknown mode — store as ReadSource - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$ReadSource"}, - } - } -} - -// serializeChangeMode converts a change mode string to a BSON mode object. -// Accepts both parsed format ("ChangeFromDatabase", "NotSupported") and MDL format ("SOURCE", "NOT_SUPPORTED"). -func serializeChangeMode(mode string) bson.D { - modeID := idToBsonBinary(generateUUID()) - - switch { - case strings.EqualFold(mode, "ChangeFromDatabase") || strings.EqualFold(mode, "SOURCE"): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$ChangeSource"}, - } - case strings.EqualFold(mode, "NotSupported") || strings.EqualFold(mode, "NOT_SUPPORTED"): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$ChangeNotSupported"}, - } - case strings.HasPrefix(mode, "CallMicroflow:"): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$CallMicroflowToChange"}, - {Key: "Microflow", Value: strings.TrimPrefix(mode, "CallMicroflow:")}, - } - case strings.HasPrefix(mode, "MICROFLOW "): - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$CallMicroflowToChange"}, - {Key: "Microflow", Value: strings.TrimPrefix(mode, "MICROFLOW ")}, - } - default: - // Unknown mode — store as ChangeNotSupported - return bson.D{ - {Key: "$ID", Value: modeID}, - {Key: "$Type", Value: "ODataPublish$ChangeNotSupported"}, - } - } -} - -// serializePublishedMicroflow serializes an ODataPublish$PublishedMicroflow. -// -// Property names come from the generated metamodel; the MicroflowParameter ref -// is Module.Microflow.Param, the shape the published-REST writer already ships. -// DataTypes$* elements are built by the same rules serializeMicroflowDataType -// uses for a microflow's own return type. -func serializePublishedMicroflow(pm *model.PublishedMicroflow) bson.D { - params := bson.A{int32(3)} - for _, p := range pm.Parameters { - params = append(params, serializePublishedMicroflowParameter(p)) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "ODataPublish$PublishedMicroflow"}, - {Key: "ExposedName", Value: pm.ExposedName}, - {Key: "AlternativeExposedName", Value: ""}, - {Key: "Microflow", Value: pm.Microflow}, - {Key: "Parameters", Value: params}, - {Key: "ReturnType", Value: serializeODataDataType(pm.ReturnTypeKind, pm.ReturnTypeRef)}, - {Key: "Summary", Value: pm.Summary}, - {Key: "Description", Value: pm.Description}, - } -} - -func serializePublishedMicroflowParameter(p *model.PublishedMicroflowParameter) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "ODataPublish$PublishedMicroflowParameter"}, - {Key: "ExposedName", Value: p.ExposedName}, - {Key: "MicroflowParameter", Value: p.MicroflowParameter}, - {Key: "DataType", Value: serializeODataDataType(p.DataTypeKind, p.DataTypeRef)}, - {Key: "CanBeEmpty", Value: p.CanBeEmpty}, - {Key: "Summary", Value: p.Summary}, - {Key: "Description", Value: p.Description}, - } -} - -// serializeODataDataType builds a DataTypes$* element from a kind and, for the -// three kinds that name something, its qualified name. Object/List carry -// `Entity`, Enumeration carries `Enumeration` — verified against -// serializeMicroflowDataType, which writes the same family for microflow return -// types and is already proven in the build. -func serializeODataDataType(kind, ref string) interface{} { - if kind == "" { - return nil - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$" + kind + "Type"}, - } - switch kind { - case "Object", "List": - return append(doc, bson.E{Key: "Entity", Value: ref}) - case "Enumeration": - return append(doc, bson.E{Key: "Enumeration", Value: ref}) - } - return doc -} diff --git a/sdk/mpr/writer_odata_test.go b/sdk/mpr/writer_odata_test.go deleted file mode 100644 index 8d2b3ea112..0000000000 --- a/sdk/mpr/writer_odata_test.go +++ /dev/null @@ -1,528 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func TestSerializeConsumedODataService(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedODataService{ - BaseElement: model.BaseElement{ - ID: "test-consumed-id", - TypeName: "Rest$ConsumedODataService", - }, - ContainerID: "test-module-id", - Name: "SalesforceAPI", - Documentation: "Connects to Salesforce", - Version: "1.0", - ODataVersion: "OData4", - MetadataUrl: "https://api.salesforce.com/odata/v4/$metadata", - TimeoutExpression: "300", - ProxyType: "DefaultProxy", - Description: "Salesforce OData API", - Validated: true, - } - - data, err := w.serializeConsumedODataService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - // Deserialize and verify fields - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - assertField(t, raw, "$Type", "Rest$ConsumedODataService") - assertField(t, raw, "Name", "SalesforceAPI") - assertField(t, raw, "Documentation", "Connects to Salesforce") - assertField(t, raw, "Version", "1.0") - assertField(t, raw, "ODataVersion", "OData4") - assertField(t, raw, "MetadataUrl", "https://api.salesforce.com/odata/v4/$metadata") - assertField(t, raw, "TimeoutExpression", "300") - assertField(t, raw, "ProxyType", "DefaultProxy") - assertField(t, raw, "Description", "Salesforce OData API") - - if v, ok := raw["Validated"].(bool); !ok || !v { - t.Errorf("Validated: expected true, got %v", raw["Validated"]) - } -} - -func TestSerializeConsumedODataServiceWithHttpConfig(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedODataService{ - BaseElement: model.BaseElement{ - ID: "test-consumed-full-id", - TypeName: "Rest$ConsumedODataService", - }, - ContainerID: "test-module-id", - Name: "FullAPI", - ODataVersion: "OData4", - MetadataUrl: "https://api.example.com/odata/$metadata", - TimeoutExpression: "300", - ConfigurationMicroflow: "MyModule.ConfigureMF", - ErrorHandlingMicroflow: "MyModule.HandleErrorMF", - ProxyHost: "MyModule.ProxyHostConst", - HttpConfiguration: &model.HttpConfiguration{ - BaseElement: model.BaseElement{ - ID: "test-http-cfg-id", - TypeName: "Microflows$HttpConfiguration", - }, - UseAuthentication: true, - Username: "'admin'", - Password: "'secret'", - HttpMethod: "Get", - OverrideLocation: true, - CustomLocation: "'https://api.example.com/odata'", - ClientCertificate: "my-cert", - HeaderEntries: []*model.HttpHeaderEntry{ - { - BaseElement: model.BaseElement{ID: "header-1"}, - Key: "X-Api-Key", - Value: "'abc123'", - }, - }, - }, - } - - data, err := w.serializeConsumedODataService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - // Microflow reference. Studio Pro stores both the "Configuration - // microflow" and "Headers microflow" dropdown options in the single - // `ConfigurationMicroflow` BSON field — it picks the dropdown label - // from the microflow's return type, not from which field carries the - // reference. Older mxcli fixes tried `ConfigurationEntityMicroflow` / - // `HeaderListMicroflow` / a separate `HeadersMicroflow`; Studio Pro - // silently ignores all three, leaving the dropdown stuck on - // "Constants only". - assertField(t, raw, "ConfigurationMicroflow", "MyModule.ConfigureMF") - assertField(t, raw, "ErrorHandlingMicroflow", "MyModule.HandleErrorMF") - assertField(t, raw, "ProxyHost", "MyModule.ProxyHostConst") - if _, exists := raw["HeadersMicroflow"]; exists { - t.Errorf("HeadersMicroflow leaked into BSON — Studio Pro stores both microflow dropdown options under ConfigurationMicroflow") - } - - // HTTP Configuration - httpCfg, ok := raw["HttpConfiguration"].(map[string]any) - if !ok { - t.Fatalf("HttpConfiguration: expected map, got %T", raw["HttpConfiguration"]) - } - assertField(t, httpCfg, "$Type", "Microflows$HttpConfiguration") - - if v, ok := httpCfg["UseHttpAuthentication"].(bool); !ok || !v { - t.Errorf("UseHttpAuthentication: expected true, got %v", httpCfg["UseHttpAuthentication"]) - } - assertField(t, httpCfg, "HttpAuthenticationUserName", "'admin'") - assertField(t, httpCfg, "HttpAuthenticationPassword", "'secret'") - assertField(t, httpCfg, "HttpMethod", "Get") - assertField(t, httpCfg, "CustomLocation", "'https://api.example.com/odata'") - assertField(t, httpCfg, "ClientCertificate", "my-cert") - - if v, ok := httpCfg["OverrideLocation"].(bool); !ok || !v { - t.Errorf("OverrideLocation: expected true, got %v", httpCfg["OverrideLocation"]) - } - - // Header entries - headers := extractBsonArray(httpCfg["HttpHeaderEntries"]) - if len(headers) != 1 { - t.Fatalf("HttpHeaderEntries: expected 1, got %d", len(headers)) - } - h0, ok := headers[0].(map[string]any) - if !ok { - t.Fatalf("HttpHeaderEntries[0]: expected map, got %T", headers[0]) - } - assertField(t, h0, "Key", "X-Api-Key") - assertField(t, h0, "Value", "'abc123'") -} - -func TestSerializePublishedODataService(t *testing.T) { - w := &Writer{} - svc := &model.PublishedODataService{ - BaseElement: model.BaseElement{ - ID: "test-published-id", - TypeName: "ODataPublish$PublishedODataService2", - }, - ContainerID: "test-module-id", - Name: "CustomerAPI", - Path: "/odata/customers", - Version: "1.0.0", - ODataVersion: "OData4", - Namespace: "MyApp.Customers", - ServiceName: "Customer Service", - Summary: "API for customers", - PublishAssociations: true, - AuthenticationTypes: []string{"Basic", "Session"}, - EntityTypes: []*model.PublishedEntityType{ - { - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Entity: "MyModule.Customer", - ExposedName: "Customers", - Members: []*model.PublishedMember{ - { - BaseElement: model.BaseElement{ID: "m-1"}, - Kind: "attribute", - Name: "Name", - ExposedName: "CustomerName", - Filterable: true, - Sortable: true, - }, - { - BaseElement: model.BaseElement{ID: "m-2"}, - Kind: "id", - Name: "ID", - ExposedName: "Id", - IsPartOfKey: true, - }, - }, - }, - }, - EntitySets: []*model.PublishedEntitySet{ - { - BaseElement: model.BaseElement{ID: "es-1"}, - ExposedName: "Customers", - EntityTypeName: "MyModule.Customer", - ReadMode: "ReadFromDatabase", - InsertMode: "ChangeFromDatabase", - UpdateMode: "ChangeFromDatabase", - DeleteMode: "NotSupported", - UsePaging: true, - PageSize: 100, - }, - }, - } - - data, err := w.serializePublishedODataService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - // Top-level fields - assertField(t, raw, "$Type", "ODataPublish$PublishedODataService2") - assertField(t, raw, "Name", "CustomerAPI") - assertField(t, raw, "Path", "/odata/customers") - assertField(t, raw, "Version", "1.0.0") - assertField(t, raw, "ODataVersion", "OData4") - assertField(t, raw, "Namespace", "MyApp.Customers") - assertField(t, raw, "ServiceName", "Customer Service") - - if v, ok := raw["PublishAssociations"].(bool); !ok || !v { - t.Errorf("PublishAssociations: expected true, got %v", raw["PublishAssociations"]) - } - - // Authentication types (versioned array: [int32(3), "Basic", "Session"]) - authArr := extractBsonArray(raw["AuthenticationTypes"]) - if len(authArr) != 2 { - t.Errorf("AuthenticationTypes: expected 2 items, got %d", len(authArr)) - } - if len(authArr) >= 2 { - if authArr[0] != "Basic" { - t.Errorf("AuthenticationTypes[0]: expected Basic, got %v", authArr[0]) - } - if authArr[1] != "Session" { - t.Errorf("AuthenticationTypes[1]: expected Session, got %v", authArr[1]) - } - } - - // Entity types array - entityTypes := extractBsonArray(raw["EntityTypes"]) - if len(entityTypes) != 1 { - t.Fatalf("EntityTypes: expected 1, got %d", len(entityTypes)) - } - etMap, ok := entityTypes[0].(map[string]any) - if !ok { - t.Fatalf("EntityTypes[0]: expected map, got %T", entityTypes[0]) - } - assertField(t, etMap, "$Type", "ODataPublish$EntityType") - assertField(t, etMap, "Entity", "MyModule.Customer") - assertField(t, etMap, "ExposedName", "Customers") - - // Child members - members := extractBsonArray(etMap["ChildMembers"]) - if len(members) != 2 { - t.Fatalf("ChildMembers: expected 2, got %d", len(members)) - } - m0, ok := members[0].(map[string]any) - if !ok { - t.Fatalf("ChildMembers[0]: expected map, got %T", members[0]) - } - assertField(t, m0, "$Type", "ODataPublish$PublishedAttribute") - // Attribute is the fully-qualified Module.Entity.AttributeName — Studio - // Pro requires qualified references, and using bare names made the - // second entity in a multi-entity service silently fail to resolve. - assertField(t, m0, "Attribute", "MyModule.Customer.Name") - assertField(t, m0, "ExposedName", "CustomerName") - if v, ok := m0["Filterable"].(bool); !ok || !v { - t.Errorf("Member Filterable: expected true, got %v", m0["Filterable"]) - } - - m1, ok := members[1].(map[string]any) - if !ok { - t.Fatalf("ChildMembers[1]: expected map, got %T", members[1]) - } - assertField(t, m1, "$Type", "ODataPublish$PublishedId") - if v, ok := m1["IsPartOfKey"].(bool); !ok || !v { - t.Errorf("Member IsPartOfKey: expected true, got %v", m1["IsPartOfKey"]) - } - - // Entity sets - entitySets := extractBsonArray(raw["EntitySets"]) - if len(entitySets) != 1 { - t.Fatalf("EntitySets: expected 1, got %d", len(entitySets)) - } - esMap, ok := entitySets[0].(map[string]any) - if !ok { - t.Fatalf("EntitySets[0]: expected map, got %T", entitySets[0]) - } - assertField(t, esMap, "$Type", "ODataPublish$EntitySet") - assertField(t, esMap, "ExposedName", "Customers") - - // Issue #595: EntityTypePointer must reference the owning EntityType. - // Without it, Studio Pro's EntitySet.Check NREs (it can't navigate from - // the set to its type). The map lookup in serializePublishedODataService - // was previously keyed by ExposedName instead of the qualified entity - // name, so the resolved ID was always empty and the pointer was omitted. - etID := etMap["$ID"].(primitive.Binary) - esPointer, ok := esMap["EntityTypePointer"].(primitive.Binary) - if !ok { - t.Fatalf("EntityTypePointer: expected primitive.Binary, got %T (%v)", esMap["EntityTypePointer"], esMap["EntityTypePointer"]) - } - if string(esPointer.Data) != string(etID.Data) { - t.Errorf("EntityTypePointer = %x, want %x (entity type $ID)", esPointer.Data, etID.Data) - } - - if v, ok := esMap["UsePaging"].(bool); !ok || !v { - t.Errorf("UsePaging: expected true, got %v", esMap["UsePaging"]) - } - - // Mode objects - readMode, ok := esMap["ReadMode"].(map[string]any) - if !ok { - t.Fatalf("ReadMode: expected map, got %T", esMap["ReadMode"]) - } - assertField(t, readMode, "$Type", "ODataPublish$ReadSource") - - deleteMode, ok := esMap["DeleteMode"].(map[string]any) - if !ok { - t.Fatalf("DeleteMode: expected map, got %T", esMap["DeleteMode"]) - } - assertField(t, deleteMode, "$Type", "ODataPublish$ChangeNotSupported") -} - -func TestSerializeModeRoundTrip(t *testing.T) { - tests := []struct { - name string - mode string - isRead bool - expected string - }{ - {"ReadSource", "ReadFromDatabase", true, "ODataPublish$ReadSource"}, - {"ReadSourceMDL", "SOURCE", true, "ODataPublish$ReadSource"}, - {"ChangeSource", "ChangeFromDatabase", false, "ODataPublish$ChangeSource"}, - {"ChangeSourceMDL", "SOURCE", false, "ODataPublish$ChangeSource"}, - {"NotSupported", "NotSupported", false, "ODataPublish$ChangeNotSupported"}, - {"NotSupportedMDL", "NOT_SUPPORTED", false, "ODataPublish$ChangeNotSupported"}, - {"CallMicroflowRead", "CallMicroflow:MyModule.ReadMF", true, "ODataPublish$CallMicroflowToRead"}, - {"CallMicroflowChange", "CallMicroflow:MyModule.WriteMF", false, "ODataPublish$CallMicroflowToChange"}, - {"MicroflowMDLRead", "MICROFLOW MyModule.ReadMF", true, "ODataPublish$CallMicroflowToRead"}, - {"MicroflowMDLChange", "MICROFLOW MyModule.WriteMF", false, "ODataPublish$CallMicroflowToChange"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - var result bson.M - if tc.isRead { - result = dToM(serializeReadMode(tc.mode)) - } else { - result = dToM(serializeChangeMode(tc.mode)) - } - - typeName, ok := result["$Type"].(string) - if !ok { - t.Fatalf("$Type: expected string, got %T", result["$Type"]) - } - if typeName != tc.expected { - t.Errorf("$Type: expected %s, got %s", tc.expected, typeName) - } - - // Verify $ID is present - if _, ok := result["$ID"]; !ok { - t.Error("$ID: expected to be present") - } - }) - } -} - -// assertField checks a string field in a BSON map. -func assertField(t *testing.T, m map[string]any, key, expected string) { - t.Helper() - val, ok := m[key] - if !ok { - t.Errorf("field %q: missing", key) - return - } - s, ok := val.(string) - if !ok { - t.Errorf("field %q: expected string, got %T", key, val) - return - } - if s != expected { - t.Errorf("field %q: expected %q, got %q", key, expected, s) - } -} - -// mxcli-formula1 §26: `create or modify odata service` silently revoked the -// service's access, and the next build failed with "At least one allowed role -// must be selected for the published OData service to be accessible." -// -// The grants were read correctly and carried through the executor — and then -// dropped here. This document is serialized wholesale and written with -// updateUnit, so a field the serializer omits is not left alone, it is deleted. -// Because grants are made by a separate statement (`grant access on odata -// service …`) and cannot be re-stated in the create script, nothing in the -// script could put them back. -func TestSerializePublishedODataService_KeepsAllowedModuleRoles(t *testing.T) { - w := &Writer{} - svc := &model.PublishedODataService{ - BaseElement: model.BaseElement{ID: "svc-roles"}, - Name: "CustomerAPI", - ServiceName: "CustomerAPI", - AllowedModuleRoles: []string{"MyModule.User", "MyModule.Admin"}, - } - - data, err := w.serializePublishedODataService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - // Storage marker 1 (BY_NAME references) — the same array shape the working - // GRANT path writes via makeMendixStringArray, and extractBsonArray only - // strips markers 2 and 3, so the marker is still element 0 here. - roles := extractBsonArray(raw["AllowedModuleRoles"]) - if len(roles) != 3 { - t.Fatalf("AllowedModuleRoles: expected marker + 2 grants, got %v — the service is now inaccessible and the build fails", roles) - } - if m, _ := roles[0].(int32); m != 1 { - t.Errorf("storage marker = %v, want 1 (BY_NAME)", roles[0]) - } - for i, want := range []string{"MyModule.User", "MyModule.Admin"} { - if got, _ := roles[i+1].(string); got != want { - t.Errorf("role %d = %q, want %q", i, got, want) - } - } -} - -// A service with no grants must still carry the field, as an empty versioned -// array — the absence of the key and an empty list are different documents. -func TestSerializePublishedODataService_EmptyRolesStillWritesTheField(t *testing.T) { - w := &Writer{} - data, err := w.serializePublishedODataService(&model.PublishedODataService{ - BaseElement: model.BaseElement{ID: "svc-noroles"}, - Name: "Bare", - }) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if _, present := raw["AllowedModuleRoles"]; !present { - t.Error("AllowedModuleRoles must be present even when empty") - } - if arr := extractBsonArray(raw["AllowedModuleRoles"]); len(arr) != 1 { - t.Errorf("expected the bare marker and no grants, got %v", arr) - } -} - -// The query-option annotations were hardcoded true, so `publish entity … -// (TopSupported: No)` parsed, described back as No, and published as Yes. -// -// For a microflow-backed resource this claim is load-bearing rather than -// decorative: Mendix applies no query options itself, so the annotation is the -// only thing a client has to go on — and a client that believes $top works, when -// nothing implements it, silently reads a whole collection as though it were a -// page (mxcli-formula1 §20). -func TestSerializePublishedODataService_HonoursQueryOptionOptOut(t *testing.T) { - no := false - yes := true - w := &Writer{} - svc := &model.PublishedODataService{ - BaseElement: model.BaseElement{ID: "svc-qo"}, - Name: "LiveAPI", - EntitySets: []*model.PublishedEntitySet{ - { - BaseElement: model.BaseElement{ID: "es-off"}, - ExposedName: "Drivers", - EntityTypeName: "M.Driver", - Countable: &no, - SkipSupported: &no, - TopSupported: &no, - }, - { - BaseElement: model.BaseElement{ID: "es-mixed"}, - ExposedName: "Races", - EntityTypeName: "M.Race", - Countable: &yes, - // SkipSupported/TopSupported unspecified: Mendix's default of true. - }, - }, - } - - data, err := w.serializePublishedODataService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - sets := extractBsonArray(raw["EntitySets"]) - if len(sets) != 2 { - t.Fatalf("expected 2 entity sets, got %d", len(sets)) - } - opts := func(i int) map[string]any { - set, _ := sets[i].(map[string]any) - qo, _ := set["QueryOptions"].(map[string]any) - if qo == nil { - t.Fatalf("entity set %d has no QueryOptions", i) - } - return qo - } - - for _, key := range []string{"Countable", "SkipSupported", "TopSupported"} { - if v, _ := opts(0)[key].(bool); v { - t.Errorf("Drivers.%s = true, but the author said No — mxcli is advertising a capability nothing implements", key) - } - } - // Unspecified must still mean Mendix's default, not false. - for _, key := range []string{"Countable", "SkipSupported", "TopSupported"} { - if v, _ := opts(1)[key].(bool); !v { - t.Errorf("Races.%s = false; unspecified must keep Mendix's default of true", key) - } - } -} diff --git a/sdk/mpr/writer_order.go b/sdk/mpr/writer_order.go deleted file mode 100644 index c337ec10c6..0000000000 --- a/sdk/mpr/writer_order.go +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/mdl/bsonutil" - - "go.mongodb.org/mongo-driver/bson" -) - -// marshalUnitIDFirst normalizes a unit document so every nested storage object -// leads with "$ID" (and "$Type" second), then marshals it — the 11.12-safe -// replacement for a bare bson.Marshal(doc) at a unit-serialization boundary. -// -// Mendix 11.12+ rejects any storage object whose first BSON property is not -// "$ID". Several legacy writers preserve round-trip fidelity by carrying parsed -// subtrees as Go maps and marshalling them back, but bson.Marshal emits map keys -// in random order — so "$ID" only lands first by luck. bsonutil.HoistStorageID -// lifts "$ID"/"$Type" to the front while preserving the original order of every -// other key (a blind sort corrupts template-derived pluggable-widget trees). -func marshalUnitIDFirst(doc any) ([]byte, error) { - return bson.Marshal(bsonutil.HoistStorageID(doc)) -} diff --git a/sdk/mpr/writer_pages.go b/sdk/mpr/writer_pages.go deleted file mode 100644 index b04abdd02d..0000000000 --- a/sdk/mpr/writer_pages.go +++ /dev/null @@ -1,355 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "sort" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreatePage creates a new page. -func (w *Writer) CreatePage(page *pages.Page) error { - if page.ID == "" { - page.ID = model.ID(generateUUID()) - } - page.TypeName = "Forms$Page" - - contents, err := w.serializePage(page) - if err != nil { - return fmt.Errorf("failed to serialize page: %w", err) - } - - return w.insertUnit(string(page.ID), string(page.ContainerID), "Documents", "Forms$Page", contents) -} - -// UpdatePage updates an existing page. -func (w *Writer) UpdatePage(page *pages.Page) error { - contents, err := w.serializePage(page) - if err != nil { - return fmt.Errorf("failed to serialize page: %w", err) - } - - return w.updateUnit(string(page.ID), contents) -} - -// DeletePage deletes a page. -func (w *Writer) DeletePage(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// MovePage moves a page to a new container (folder or module). -// Only updates the ContainerID in the database, preserving all BSON content as-is. -func (w *Writer) MovePage(page *pages.Page) error { - return w.moveUnitByID(string(page.ID), string(page.ContainerID)) -} - -// errLayoutAuthoringIsModelsdkOnly is returned by both legacy layout writers. -// -// The legacy serializer wrote four top-level keys — a string $ID (Studio Pro -// stores binary), Name, Documentation, and a LayoutType on the layout element, -// which is not where Mendix keeps it. There was no Content wrapper at all, so -// the widget tree had nowhere to go. Nothing ever called it: CREATE LAYOUT did -// not exist until the modelsdk codec could produce the real document. Refusing -// is the honest outcome — the alternative is a unit `mx check` may well accept -// and Studio Pro cannot render. -var errLayoutAuthoringIsModelsdkOnly = fmt.Errorf( - "authoring layouts needs the modelsdk engine (MXCLI_ENGINE=modelsdk); the legacy writer cannot produce a layout's Content wrapper") - -// CreateLayout is refused on the legacy engine. See -// errLayoutAuthoringIsModelsdkOnly. -func (w *Writer) CreateLayout(_ *pages.Layout) error { - return errLayoutAuthoringIsModelsdkOnly -} - -// UpdateLayout is refused on the legacy engine. See -// errLayoutAuthoringIsModelsdkOnly. -func (w *Writer) UpdateLayout(_ *pages.Layout) error { - return errLayoutAuthoringIsModelsdkOnly -} - -// DeleteLayout deletes a layout. -func (w *Writer) DeleteLayout(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// CreateSnippet creates a new snippet. -func (w *Writer) CreateSnippet(snippet *pages.Snippet) error { - if snippet.ID == "" { - snippet.ID = model.ID(generateUUID()) - } - snippet.TypeName = "Forms$Snippet" - - contents, err := w.serializeSnippet(snippet) - if err != nil { - return fmt.Errorf("failed to serialize snippet: %w", err) - } - - return w.insertUnit(string(snippet.ID), string(snippet.ContainerID), "Documents", "Forms$Snippet", contents) -} - -// DeleteSnippet deletes a snippet. -func (w *Writer) DeleteSnippet(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// UpdateSnippet updates an existing snippet. -func (w *Writer) UpdateSnippet(snippet *pages.Snippet) error { - contents, err := w.serializeSnippet(snippet) - if err != nil { - return fmt.Errorf("failed to serialize snippet: %w", err) - } - - return w.updateUnit(string(snippet.ID), contents) -} - -// MoveSnippet moves a snippet to a new container (folder or module). -// Only updates the ContainerID in the database, preserving all BSON content as-is. -func (w *Writer) MoveSnippet(snippet *pages.Snippet) error { - return w.moveUnitByID(string(snippet.ID), string(snippet.ContainerID)) -} - -// popupDimension returns the pop-up width/height as the int64 BSON value Studio -// Pro uses. Studio Pro's own default is 0 (auto-size), so 0 is a valid value and -// is written through verbatim (issue #713); only a stray negative is clamped to 0. -func popupDimension(n int) int64 { - if n < 0 { - return 0 - } - return int64(n) -} - -func (w *Writer) serializePage(page *pages.Page) ([]byte, error) { - // Build document with Mendix 10+ format (Forms$Page) - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(page.ID))}, - {Key: "$Type", Value: "Forms$Page"}, - {Key: "AllowedModuleRoles", Value: allowedModuleRolesArray(page.AllowedRoles)}, - {Key: "Appearance", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$Appearance"}, - {Key: "Class", Value: page.Class}, - {Key: "DesignProperties", Value: bson.A{int32(3)}}, - {Key: "DynamicClasses", Value: ""}, - {Key: "Style", Value: page.Style}, - }}, - {Key: "Autofocus", Value: "DesktopOnly"}, - {Key: "CanvasHeight", Value: int64(600)}, - {Key: "CanvasWidth", Value: int64(1200)}, - {Key: "Documentation", Value: page.Documentation}, - {Key: "Excluded", Value: page.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - } - - // Add FormCall (LayoutCall) if present - if page.LayoutCall != nil { - // Build arguments array - // Format: [3] for empty, [2, {arg1}, {arg2}...] for non-empty - // Each argument is a bson.D document - args := bson.A{int32(3)} // Start with empty marker - hasItems := false - for _, arg := range page.LayoutCall.Arguments { - // Parameter uses a qualified name string (e.g., "Atlas_Core.Atlas_TopBar.Main") - // not a binary ID - argDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(arg.ID))}, - {Key: "$Type", Value: "Forms$FormCallArgument"}, - {Key: "Parameter", Value: string(arg.ParameterID)}, // Qualified name string - } - // Add widgets if present - if len(arg.Widgets) > 0 { - argDoc = append(argDoc, bson.E{Key: "Widgets", Value: serializeWidgetArray(arg.Widgets)}) - } else { - argDoc = append(argDoc, bson.E{Key: "Widgets", Value: bson.A{int32(3)}}) - } - if !hasItems { - // First item: change version marker from 3 to 2 - args = bson.A{int32(2)} - hasItems = true - } - // Append the argument document directly - args = append(args, argDoc) - } - - formCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(page.LayoutCall.ID))}, - {Key: "$Type", Value: "Forms$LayoutCall"}, - {Key: "Arguments", Value: args}, - {Key: "Form", Value: page.LayoutCall.LayoutName}, // Qualified name string, not binary ID - } - doc = append(doc, bson.E{Key: "FormCall", Value: formCall}) - } - - doc = append(doc, bson.E{Key: "MarkAsUsed", Value: page.MarkAsUsed}) - doc = append(doc, bson.E{Key: "Name", Value: page.Name}) - - // Add Parameters array - // Format: [3] for empty, [3, {param1}, {param2}...] for non-empty - // Each parameter is a bson.D document (which serializes as array of key-value pairs) - params := bson.A{int32(3)} // Start with version marker - for _, p := range page.Parameters { - paramID := string(p.ID) - if paramID == "" { - paramID = generateUUID() - } - - // Build ParameterType — entity params use DataTypes$ObjectType, - // primitive params use DataTypes$StringType, DataTypes$IntegerType, etc. - paramTypeID := generateUUID() - var paramType bson.D - if p.TypeName != "" { - // Primitive type parameter - paramType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(paramTypeID)}, - {Key: "$Type", Value: p.TypeName}, - } - } else { - // Entity type parameter (default) - paramType = bson.D{ - {Key: "$ID", Value: idToBsonBinary(paramTypeID)}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: p.EntityName}, - } - } - - paramDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(paramID)}, - {Key: "$Type", Value: "Forms$PageParameter"}, - {Key: "DefaultValue", Value: p.DefaultValue}, - {Key: "IsRequired", Value: p.IsRequired}, - {Key: "Name", Value: p.Name}, - {Key: "ParameterType", Value: paramType}, - } - // Append the parameter document directly (bson.D serializes as array of {Key, Value}) - params = append(params, paramDoc) - } - doc = append(doc, bson.E{Key: "Parameters", Value: params}) - - doc = append(doc, bson.E{Key: "PopupCloseAction", Value: ""}) - doc = append(doc, bson.E{Key: "PopupHeight", Value: popupDimension(page.PopupHeight)}) - doc = append(doc, bson.E{Key: "PopupResizable", Value: page.PopupResizable}) - doc = append(doc, bson.E{Key: "PopupWidth", Value: popupDimension(page.PopupWidth)}) - - // Add Title - // Mendix uses [3] for empty arrays, [2, item1, item2, ...] for non-empty arrays - // Items go directly after the version marker, NOT nested in another array - if page.Title != nil { - titleItems := bson.A{int32(3)} // Start with empty marker - if len(page.Title.Translations) > 0 { - titleItems = bson.A{int32(2)} // version 2 for non-empty - langs := make([]string, 0, len(page.Title.Translations)) - for lang := range page.Title.Translations { - langs = append(langs, lang) - } - sort.Strings(langs) - for _, langCode := range langs { - titleItems = append(titleItems, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: langCode}, - {Key: "Text", Value: page.Title.Translations[langCode]}, - }) - } - } - titleDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(page.Title.ID))}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: titleItems}, - } - doc = append(doc, bson.E{Key: "Title", Value: titleDoc}) - } else { - // Empty title - doc = append(doc, bson.E{Key: "Title", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}) - } - - doc = append(doc, bson.E{Key: "Url", Value: page.URL}) - doc = append(doc, bson.E{Key: "Variables", Value: serializeLocalVariables(page.Variables)}) - - return bson.Marshal(doc) -} - -func (w *Writer) serializeSnippet(snippet *pages.Snippet) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(snippet.ID))}, - {Key: "$Type", Value: "Forms$Snippet"}, - {Key: "CanvasHeight", Value: int64(600)}, - {Key: "CanvasWidth", Value: int64(800)}, - {Key: "Documentation", Value: snippet.Documentation}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Name", Value: snippet.Name}, - } - - // Add parameters - params := bson.A{int32(3)} // Version prefix - for _, param := range snippet.Parameters { - paramDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(param.ID))}, - {Key: "$Type", Value: "Forms$SnippetParameter"}, - {Key: "Name", Value: param.Name}, - } - if param.EntityName != "" { - paramDoc = append(paramDoc, bson.E{Key: "ParameterType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$ObjectType"}, - {Key: "Entity", Value: param.EntityName}, - }}) - } - params = append(params, paramDoc) - } - doc = append(doc, bson.E{Key: "Parameters", Value: params}) - - // Add fields to match Studio Pro format - doc = append(doc, bson.E{Key: "Type", Value: ""}) - doc = append(doc, bson.E{Key: "Variables", Value: serializeLocalVariables(snippet.Variables)}) - doc = append(doc, bson.E{Key: "Excluded", Value: false}) - - // Use "Widgets" (plural) array, matching Studio Pro format - doc = append(doc, bson.E{Key: "Widgets", Value: serializeWidgetArray(snippet.Widgets)}) - - return bson.Marshal(doc) -} - -// serializeLocalVariables serializes page/snippet local variables to BSON array format. -// Returns [3] for empty, [3, {var1}, {var2}...] for non-empty. -func serializeLocalVariables(vars []*pages.LocalVariable) bson.A { - result := bson.A{int32(3)} // Version marker - for _, v := range vars { - varID := string(v.ID) - if varID == "" { - varID = generateUUID() - } - - varTypeID := generateUUID() - varType := bson.D{ - {Key: "$ID", Value: idToBsonBinary(varTypeID)}, - {Key: "$Type", Value: v.VariableType}, - } - // For ObjectType, include the Entity field - if v.VariableType == "DataTypes$ObjectType" { - varType = append(varType, bson.E{Key: "Entity", Value: v.Name}) - } - // An enumeration points at one by name. Without this the type was written - // with nothing to resolve, so it was flattened to a String instead (#977). - if v.VariableType == "DataTypes$EnumerationType" && v.EnumerationRef != "" { - varType = append(varType, bson.E{Key: "Enumeration", Value: v.EnumerationRef}) - } - - varDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(varID)}, - {Key: "$Type", Value: "Forms$LocalVariable"}, - {Key: "DefaultValue", Value: v.DefaultValue}, - {Key: "Name", Value: v.Name}, - {Key: "VariableType", Value: varType}, - } - result = append(result, varDoc) - } - return result -} diff --git a/sdk/mpr/writer_pages_placeholder_test.go b/sdk/mpr/writer_pages_placeholder_test.go deleted file mode 100644 index 07e903058f..0000000000 --- a/sdk/mpr/writer_pages_placeholder_test.go +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// mendixlabs/mxcli#760: every mxcli-authored page gained a container nobody asked -// for. The builder wrapped each non-empty layout placeholder in a synthetic -// Forms$DivContainer named "conditionalVisibilityWidget", so creating a single -// button produced a button *and* a container. -// -// The wrapper was never a BSON requirement. Forms$FormCallArgument carries a -// `Widgets` array and a Studio Pro page fills it with its top-level widgets -// directly — verified against Mendix's own output: Administration.Account_Overview in -// a `mx create-project` app has two top-level widgets in one placeholder and zero -// wrappers. The wrapper existed only because pages.LayoutCallArgument declared a -// single `Widget` field. -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func placeholderArg(widgets ...pages.Widget) *pages.Page { - return &pages.Page{ - BaseElement: model.BaseElement{ID: "page1"}, - Name: "P", - LayoutCall: &pages.LayoutCall{ - BaseElement: model.BaseElement{ID: "lc1"}, - LayoutName: "Atlas_Core.Atlas_Default", - Arguments: []*pages.LayoutCallArgument{{ - BaseElement: model.BaseElement{ID: "arg1"}, - ParameterID: model.ID("Atlas_Core.Atlas_Default.Main"), - Widgets: widgets, - }}, - }, - } -} - -func argWidgets(t *testing.T, page *pages.Page) primitive.A { - t.Helper() - w := &Writer{} - raw, err := w.serializePage(page) - if err != nil { - t.Fatalf("serializePage: %v", err) - } - var m map[string]any - if err := bson.Unmarshal(raw, &m); err != nil { - t.Fatalf("unmarshal: %v", err) - } - fc := toMap(m["FormCall"]) - if fc == nil { - t.Fatal("FormCall missing") - } - args, _ := fc["Arguments"].(primitive.A) - if len(args) < 2 { - t.Fatalf("Arguments = %v, want a marker plus one argument", args) - } - arg := toMap(args[1]) - ws, _ := arg["Widgets"].(primitive.A) - return ws -} - -func btn(name string) *pages.ActionButton { - return &pages.ActionButton{BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ID: model.ID(name), TypeName: "Forms$ActionButton"}, - Name: name, - }} -} - -// TestLayoutPlaceholder_WidgetsSerializedDirectly is the regression: whatever widgets -// a placeholder holds must reach BSON as-is, with no synthetic container inserted. -func TestLayoutPlaceholder_WidgetsSerializedDirectly(t *testing.T) { - tests := []struct { - name string - widgets []pages.Widget - want int - }{ - {"single widget", []pages.Widget{btn("b1")}, 1}, - // The case the wrapper was introduced for: the array holds them side by side. - {"several widgets", []pages.Widget{btn("b1"), btn("b2"), btn("b3")}, 3}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ws := argWidgets(t, placeholderArg(tc.widgets...)) - // First element is the array version marker. - if got := len(ws) - 1; got != tc.want { - t.Fatalf("placeholder holds %d widget(s), want %d — a wrapper would collapse them to 1 (#760)", got, tc.want) - } - for _, w := range ws[1:] { - m := toMap(w) - if m == nil { - continue - } - if ty := extractString(m["$Type"]); ty == "Forms$DivContainer" { - t.Errorf("a synthetic DivContainer wrapper is back (#760): %v", m["Name"]) - } - } - }) - } -} - -// An empty placeholder must still emit the empty Widgets array Mendix expects. -func TestLayoutPlaceholder_EmptyStillEmitsWidgets(t *testing.T) { - ws := argWidgets(t, placeholderArg()) - if len(ws) != 1 { - t.Fatalf("empty placeholder Widgets = %v, want just the array marker", ws) - } -} diff --git a/sdk/mpr/writer_placement.go b/sdk/mpr/writer_placement.go deleted file mode 100644 index fa4ef8b89e..0000000000 --- a/sdk/mpr/writer_placement.go +++ /dev/null @@ -1,115 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// MoveDocument reparents a top-level document unit, whatever its type. -// The idempotence and write accounting live in moveUnitByID. -func (w *Writer) MoveDocument(unitID, containerID model.ID) error { - if unitID == "" || containerID == "" { - return fmt.Errorf("MoveDocument: unit and container are both required") - } - return w.moveUnitByID(string(unitID), string(containerID)) -} - -// FindDocumentUnit locates a document by module and name through the unit -// table, whatever its type. Mirrors the modelsdk engine's implementation. -// -// Only units contained as "Documents" are considered: a module also holds its -// domain model, security and settings, and folders share the table, so the -// containment filter is what stops a folder named like a document from being -// returned as one. -func (w *Writer) FindDocumentUnit(moduleName, name string) (*types.DocumentUnit, error) { - modules, err := w.reader.ListModules() - if err != nil { - return nil, fmt.Errorf("FindDocumentUnit: list modules: %w", err) - } - var moduleID string - for _, m := range modules { - if m.Name == moduleName { - moduleID = string(m.ID) - break - } - } - if moduleID == "" { - return nil, nil - } - containers := buildContainerSet(w.reader, moduleID) - - var found *types.DocumentUnit - err = w.eachDocumentUnit(func(doc *types.DocumentUnit) bool { - if doc.Name != name || !containers[string(doc.ContainerID)] { - return true - } - found = doc - return false - }) - if err != nil { - return nil, fmt.Errorf("FindDocumentUnit: %w", err) - } - return found, nil -} - -// ListDocumentUnits returns every top-level document in the project. -func (w *Writer) ListDocumentUnits() ([]*types.DocumentUnit, error) { - var out []*types.DocumentUnit - if err := w.eachDocumentUnit(func(doc *types.DocumentUnit) bool { - out = append(out, doc) - return true - }); err != nil { - return nil, fmt.Errorf("ListDocumentUnits: %w", err) - } - return out, nil -} - -// eachDocumentUnit walks every "Documents" unit, decoding just enough of each -// to name it, and stops early when visit returns false. A unit whose contents -// will not decode is skipped rather than failing the whole walk. -func (w *Writer) eachDocumentUnit(visit func(*types.DocumentUnit) bool) error { - units, err := w.reader.listUnitsByType("") - if err != nil { - return fmt.Errorf("list units: %w", err) - } - for _, unit := range units { - if unit.ContainmentName != "Documents" { - continue - } - contents, err := w.reader.resolveContents(unit.ID, unit.Contents) - if err != nil || len(contents) == 0 { - continue - } - var raw bson.D - if err := bson.Unmarshal(contents, &raw); err != nil { - continue - } - name := "" - for _, elem := range raw { - if elem.Key == "Name" { - if s, ok := elem.Value.(string); ok { - name = s - } - break - } - } - if name == "" { - continue - } - if !visit(&types.DocumentUnit{ - ID: model.ID(unit.ID), - ContainerID: model.ID(unit.ContainerID), - Name: name, - Type: unit.Type, - Kind: types.DocumentKind(unit.Type), - }) { - return nil - } - } - return nil -} diff --git a/sdk/mpr/writer_refs.go b/sdk/mpr/writer_refs.go deleted file mode 100644 index f7d7d1b1dc..0000000000 --- a/sdk/mpr/writer_refs.go +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// UpdateQualifiedNameInAllUnits replaces all occurrences of oldName with newName -// in string values across all BSON documents in the project. Handles both exact -// matches and prefix matches (e.g., "Module.Name.Param" when renaming "Module.Name"). -// Returns the number of documents that were updated. -func (w *Writer) UpdateQualifiedNameInAllUnits(oldName, newName string) (int, error) { - units, err := w.reader.listUnitsByType("") - if err != nil { - return 0, err - } - - updated := 0 - for _, u := range units { - var raw map[string]any - if err := bson.Unmarshal(u.Contents, &raw); err != nil { - continue - } - - if replaceStringsInMap(raw, oldName, newName) { - contents, err := marshalUnitIDFirst(raw) - if err != nil { - continue - } - if err := w.updateUnit(u.ID, contents); err != nil { - return updated, err - } - updated++ - } - } - - return updated, nil -} - -// replaceStringsInMap recursively walks a map and replaces string values that -// match oldName exactly or have oldName as a prefix (followed by "."). -// Returns true if any replacement was made. -func replaceStringsInMap(m map[string]any, oldName, newName string) bool { - changed := false - for k, v := range m { - if replaced, ok := replaceInValue(v, oldName, newName); ok { - m[k] = replaced - changed = true - } - } - return changed -} - -// replaceInValue recursively processes a value and returns the replacement and -// whether any change was made. -func replaceInValue(v any, oldName, newName string) (any, bool) { - switch val := v.(type) { - case string: - if newStr, ok := replaceQualifiedName(val, oldName, newName); ok { - return newStr, true - } - case map[string]any: - if replaceStringsInMap(val, oldName, newName) { - return val, true - } - case primitive.M: - m := map[string]any(val) - if replaceStringsInMap(m, oldName, newName) { - return val, true - } - case primitive.A: - changed := false - for i, elem := range val { - if replaced, ok := replaceInValue(elem, oldName, newName); ok { - val[i] = replaced - changed = true - } - } - if changed { - return val, true - } - case []any: - changed := false - for i, elem := range val { - if replaced, ok := replaceInValue(elem, oldName, newName); ok { - val[i] = replaced - changed = true - } - } - if changed { - return val, true - } - case primitive.D: - changed := false - for i, elem := range val { - if replaced, ok := replaceInValue(elem.Value, oldName, newName); ok { - val[i].Value = replaced - changed = true - } - } - if changed { - return val, true - } - } - return v, false -} - -// replaceQualifiedName checks if s matches oldName exactly or as a prefix -// (e.g., "OldModule.Microflow.Param") and returns the replacement. -func replaceQualifiedName(s, oldName, newName string) (string, bool) { - if s == oldName { - return newName, true - } - // Prefix match: "OldModule.Microflow.Param" → "NewModule.Microflow.Param" - if strings.HasPrefix(s, oldName+".") { - return newName + s[len(oldName):], true - } - return "", false -} diff --git a/sdk/mpr/writer_rename.go b/sdk/mpr/writer_rename.go deleted file mode 100644 index 671ecfddcd..0000000000 --- a/sdk/mpr/writer_rename.go +++ /dev/null @@ -1,240 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "github.com/mendixlabs/mxcli/mdl/types" - "strings" - - "go.mongodb.org/mongo-driver/bson" -) - -// RenameHit describes a document that contains references to a renamed element. -type RenameHit struct { - UnitID string // Document UUID - UnitType string // e.g., "Microflows$Microflow" - Name string // Document name (if found) - Count int // Number of string replacements in this document -} - -// RenameReferences scans all documents in the project and replaces qualified name -// strings matching oldName with newName. Returns the list of affected documents. -// -// Matching rules: -// - Exact match: "Module.OldName" → "Module.NewName" -// - Prefix match: "Module.OldName.Attr" → "Module.NewName.Attr" -// -// If dryRun is true, no modifications are written — only the hit list is returned. -func (w *Writer) RenameReferences(oldName, newName string, dryRun bool) ([]RenameHit, error) { - // List all units (empty type prefix = all) - units, err := w.reader.listUnitsByType("") - if err != nil { - return nil, fmt.Errorf("failed to list units: %w", err) - } - - var hits []RenameHit - - for _, unit := range units { - contents, err := w.reader.resolveContents(unit.ID, unit.Contents) - if err != nil { - continue - } - if len(contents) == 0 { - continue - } - - var raw bson.D - if err := bson.Unmarshal(contents, &raw); err != nil { - continue - } - - count := 0 - updated := replaceStringsInDoc(raw, oldName, newName, &count) - - if count > 0 { - // Extract document name for reporting - docName := "" - for _, elem := range updated { - if elem.Key == "Name" { - if s, ok := elem.Value.(string); ok { - docName = s - } - } - } - - hits = append(hits, RenameHit{ - UnitID: unit.ID, - UnitType: unit.Type, - Name: docName, - Count: count, - }) - - if !dryRun { - newContents, err := marshalUnitIDFirst(updated) - if err != nil { - return hits, fmt.Errorf("failed to marshal updated document %s: %w", unit.ID, err) - } - if err := w.updateUnit(unit.ID, newContents); err != nil { - return hits, fmt.Errorf("failed to write updated document %s: %w", unit.ID, err) - } - } - } - } - - return hits, nil -} - -// RenameDocumentByName finds a document by module and name, then updates its Name field. -// This works for any document type (microflow, nanoflow, page, constant, enumeration, etc.) -// by doing a raw BSON scan of all units in the module. -func (w *Writer) RenameDocumentByName(moduleName, oldName, newName string) error { - // Find all modules to get the module ID - modules, err := w.reader.ListModules() - if err != nil { - return fmt.Errorf("failed to list modules: %w", err) - } - - var moduleID string - for _, m := range modules { - if m.Name == moduleName { - moduleID = string(m.ID) - break - } - } - if moduleID == "" { - return fmt.Errorf("module not found: %s", moduleName) - } - - // Build container hierarchy to find documents in this module (including folders) - hierarchy := buildContainerSet(w.reader, moduleID) - - // Scan all units looking for the document with matching Name - units, err := w.reader.listUnitsByType("") - if err != nil { - return fmt.Errorf("failed to list units: %w", err) - } - - for _, unit := range units { - // Check if this unit belongs to the target module (direct or via folder) - if !hierarchy[unit.ContainerID] { - continue - } - - contents, err := w.reader.resolveContents(unit.ID, unit.Contents) - if err != nil || len(contents) == 0 { - continue - } - - var raw bson.D - if err := bson.Unmarshal(contents, &raw); err != nil { - continue - } - - // Check if this document has Name == oldName - for i, elem := range raw { - if elem.Key == "Name" { - if s, ok := elem.Value.(string); ok && s == oldName { - raw[i].Value = newName - newContents, err := marshalUnitIDFirst(raw) - if err != nil { - return fmt.Errorf("failed to marshal: %w", err) - } - return w.updateUnit(unit.ID, newContents) - } - } - } - } - - return fmt.Errorf("document '%s.%s' not found", moduleName, oldName) -} - -// buildContainerSet returns a set of container IDs that belong to a module -// (the module ID itself plus all folder IDs nested under it). -func buildContainerSet(r *Reader, moduleID string) map[string]bool { - set := map[string]bool{moduleID: true} - - folders, err := r.ListFolders() - if err != nil { - return set - } - - // Iteratively expand: if a folder's container is in the set, add the folder - changed := true - for changed { - changed = false - for _, f := range folders { - if set[string(f.ContainerID)] && !set[string(f.ID)] { - set[string(f.ID)] = true - changed = true - } - } - } - - return set -} - -// replaceStringsInDoc recursively walks a bson.D document and replaces string -// values that match oldName exactly or start with oldName + ".". -func replaceStringsInDoc(doc bson.D, oldName, newName string, count *int) bson.D { - result := make(bson.D, len(doc)) - for i, elem := range doc { - // A view entity's OQL holds the qualified name EMBEDDED in the query, so - // the whole-string match in replaceStringsInValue never sees it and the - // view kept pointing at the old name after a rename (CE0174 "Cannot - // resolve object name"). Same fix in the modelsdk engine's - // replaceQNInDocCounted; the rewrite itself is shared. - if elem.Key == oqlPropertyKey { - if q, ok := elem.Value.(string); ok { - rewritten, n := types.RewriteOQLQualifiedName(q, oldName, newName) - *count += n - result[i] = bson.E{Key: elem.Key, Value: rewritten} - continue - } - } - result[i] = bson.E{ - Key: elem.Key, - Value: replaceStringsInValue(elem.Value, oldName, newName, count), - } - } - return result -} - -// oqlPropertyKey is where DomainModels$OqlViewEntitySource keeps the query. -const oqlPropertyKey = "Oql" - -// replaceStringsInValue replaces qualified name strings in any BSON value type. -func replaceStringsInValue(val any, oldName, newName string, count *int) any { - switch v := val.(type) { - case string: - if v == oldName { - *count++ - return newName - } - if strings.HasPrefix(v, oldName+".") { - *count++ - return newName + v[len(oldName):] - } - return v - - case bson.D: - return replaceStringsInDoc(v, oldName, newName, count) - - case bson.A: - result := make(bson.A, len(v)) - for i, item := range v { - result[i] = replaceStringsInValue(item, oldName, newName, count) - } - return result - - case []any: - result := make([]any, len(v)) - for i, item := range v { - result[i] = replaceStringsInValue(item, oldName, newName, count) - } - return result - - default: - return v - } -} diff --git a/sdk/mpr/writer_rest.go b/sdk/mpr/writer_rest.go deleted file mode 100644 index f0b7f4ad0c..0000000000 --- a/sdk/mpr/writer_rest.go +++ /dev/null @@ -1,635 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateConsumedRestService creates a new consumed REST service document. -func (w *Writer) CreateConsumedRestService(svc *model.ConsumedRestService) error { - if svc.ID == "" { - svc.ID = model.ID(generateUUID()) - } - svc.TypeName = "Rest$ConsumedRestService" - - contents, err := w.serializeConsumedRestService(svc) - if err != nil { - return fmt.Errorf("failed to serialize consumed REST service: %w", err) - } - - return w.insertUnit(string(svc.ID), string(svc.ContainerID), "Documents", "Rest$ConsumedRestService", contents) -} - -// UpdateConsumedRestService updates an existing consumed REST service. -func (w *Writer) UpdateConsumedRestService(svc *model.ConsumedRestService) error { - contents, err := w.serializeConsumedRestService(svc) - if err != nil { - return fmt.Errorf("failed to serialize consumed REST service: %w", err) - } - - return w.updateUnit(string(svc.ID), contents) -} - -// DeleteConsumedRestService deletes a consumed REST service by ID. -func (w *Writer) DeleteConsumedRestService(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// serializeConsumedRestService converts a ConsumedRestService to BSON bytes. -func (w *Writer) serializeConsumedRestService(svc *model.ConsumedRestService) ([]byte, error) { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(svc.ID))}, - {Key: "$Type", Value: "Rest$ConsumedRestService"}, - {Key: "Name", Value: svc.Name}, - {Key: "Documentation", Value: svc.Documentation}, - {Key: "Excluded", Value: svc.Excluded}, - // ExportLevel: whether the document is exposed to other modules/projects. - // Studio Pro defaults to "Hidden". Missing this field has been observed - // to cause runtime auth issues (#200). - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "BaseUrlParameter", Value: nil}, - } - - // OpenApiFile: only present when the service was created from an OpenAPI spec. - // Field name and subfield are PascalCase to match Studio Pro serialization. - // Do NOT write a null entry for manually-created services — Studio Pro omits this field entirely. - if svc.OpenApiContent != "" { - doc = append(doc, bson.E{Key: "OpenApiFile", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$OpenApiFile"}, - {Key: "Content", Value: svc.OpenApiContent}, - }}) - } - - // BaseUrl as Rest$ValueTemplate - doc = append(doc, bson.E{Key: "BaseUrl", Value: serializeValueTemplate(svc.BaseUrl)}) - - // AuthenticationScheme: polymorphic (null or Rest$BasicAuthenticationScheme) - if svc.Authentication == nil { - doc = append(doc, bson.E{Key: "AuthenticationScheme", Value: nil}) - } else { - doc = append(doc, bson.E{Key: "AuthenticationScheme", Value: serializeRestAuthScheme(svc.Authentication)}) - } - - // Operations: versioned array - ops := bson.A{int32(2)} - for _, op := range svc.Operations { - ops = append(ops, serializeRestOperation(op)) - } - doc = append(doc, bson.E{Key: "Operations", Value: ops}) - - return marshalUnitIDFirst(doc) -} - -// serializeValueTemplate creates a Rest$ValueTemplate BSON object. -func serializeValueTemplate(value string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ValueTemplate"}, - {Key: "Value", Value: value}, - } -} - -// serializeRestAuthScheme converts authentication config to a BSON map. -func serializeRestAuthScheme(auth *model.RestAuthentication) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$BasicAuthenticationScheme"}, - } - - doc = append(doc, bson.E{Key: "Username", Value: serializeRestValue(auth.Username)}) - doc = append(doc, bson.E{Key: "Password", Value: serializeRestValue(auth.Password)}) - - return doc -} - -// serializeRestValue creates a polymorphic Rest$Value (StringValue or ConstantValue). -// Values starting with "$" are treated as constant references; others as string literals. -func serializeRestValue(value string) bson.D { - if strings.HasPrefix(value, "$") { - // Constant reference — the BSON field is "Value" (QualifiedName of the constant). - constRef := strings.TrimPrefix(value, "$") - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ConstantValue"}, - {Key: "Value", Value: constRef}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$StringValue"}, - {Key: "Value", Value: value}, - } -} - -// serializeRestOperation converts a RestClientOperation to a BSON map. -func serializeRestOperation(op *model.RestClientOperation) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RestOperation"}, - {Key: "Name", Value: op.Name}, - } - - // Timeout: Studio Pro always writes this field; default is 300 seconds. - timeout := int64(op.Timeout) - if timeout <= 0 { - timeout = 300 - } - doc = append(doc, bson.E{Key: "Timeout", Value: timeout}) - - // Tags: versioned string array; used by Studio Pro as resource group labels. - tags := bson.A{int32(1)} - for _, t := range op.Tags { - tags = append(tags, t) - } - doc = append(doc, bson.E{Key: "Tags", Value: tags}) - - // Method: polymorphic (WithBody or WithoutBody) - doc = append(doc, bson.E{Key: "Method", Value: serializeRestMethod(op)}) - - // Path as Rest$ValueTemplate - doc = append(doc, bson.E{Key: "Path", Value: serializeValueTemplate(op.Path)}) - - // Headers: versioned array of Rest$HeaderWithValueTemplate - headers := bson.A{int32(2)} - hasAccept := false - for _, h := range op.Headers { - headers = append(headers, serializeRestHeader(h)) - if strings.EqualFold(h.Name, "Accept") { - hasAccept = true - } - } - // Mendix requires an Accept header on every consumed REST operation (CE7062) - if !hasAccept { - headers = append(headers, serializeRestHeader(&model.RestClientHeader{Name: "Accept", Value: "*/*"})) - } - doc = append(doc, bson.E{Key: "Headers", Value: headers}) - - // Parameters: versioned array of Rest$RestOperationParameter (path params) - params := bson.A{int32(2)} - for _, p := range op.Parameters { - params = append(params, serializeRestParameter(p)) - } - doc = append(doc, bson.E{Key: "Parameters", Value: params}) - - // QueryParameters: versioned array of Rest$QueryParameter - queryParams := bson.A{int32(2)} - for _, q := range op.QueryParameters { - queryParams = append(queryParams, serializeRestQueryParameter(q)) - } - doc = append(doc, bson.E{Key: "QueryParameters", Value: queryParams}) - - // ResponseHandling: polymorphic - if op.ResponseType == "MAPPING" && op.ResponseEntity != "" && len(op.ResponseMappings) > 0 { - doc = append(doc, bson.E{Key: "ResponseHandling", Value: serializeRestImplicitMappingResponse(op.ResponseEntity, op.ResponseMappings)}) - } else { - doc = append(doc, bson.E{Key: "ResponseHandling", Value: serializeRestResponseHandling(op.ResponseType)}) - } - - return doc -} - -// serializeRestMethod creates the polymorphic Method field. -// Methods with bodies (POST, PUT, PATCH) use Rest$RestOperationMethodWithBody; -// others use Rest$RestOperationMethodWithoutBody. -func serializeRestMethod(op *model.RestClientOperation) bson.D { - httpMethod := httpMethodToMendix(op.HttpMethod) - - if op.BodyType != "" { - // Method with explicit body - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RestOperationMethodWithBody"}, - {Key: "HttpMethod", Value: httpMethod}, - } - if op.BodyType == "EXPORT_MAPPING" && len(op.BodyMappings) > 0 { - doc = append(doc, bson.E{Key: "Body", Value: serializeRestImplicitMappingBody(op.BodyVariable, op.BodyMappings)}) - } else { - doc = append(doc, bson.E{Key: "Body", Value: serializeRestBody(op.BodyType, op.BodyVariable)}) - } - return doc - } - - // POST, PUT, PATCH must include a body even if not explicitly specified (CE7064) - methodUpper := strings.ToUpper(op.HttpMethod) - if methodUpper == "POST" || methodUpper == "PUT" || methodUpper == "PATCH" { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RestOperationMethodWithBody"}, - {Key: "HttpMethod", Value: httpMethod}, - } - doc = append(doc, bson.E{Key: "Body", Value: serializeRestBody("JSON", op.BodyVariable)}) - return doc - } - - // Method without body - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RestOperationMethodWithoutBody"}, - {Key: "HttpMethod", Value: httpMethod}, - } -} - -// serializeRestBody creates a polymorphic Body field. -// Uses Rest$JsonBody instead of Rest$ImplicitMappingBody to avoid CE7247/CE0061 -// (ImplicitMappingBody requires entity mapping which isn't supported yet). -// -// bodyExpr is a Mendix expression (typically "$variableName") that produces -// the JSON or file body at call time. It is stored verbatim in the BSON Value -// field so a roundtrip preserves it. -func serializeRestBody(bodyType, bodyExpr string) bson.D { - switch strings.ToUpper(bodyType) { - case "JSON": - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$JsonBody"}, - {Key: "Value", Value: bodyExpr}, - } - case "FILE", "TEMPLATE": - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$StringBody"}, - {Key: "ValueTemplate", Value: serializeValueTemplate(bodyExpr)}, - } - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$JsonBody"}, - {Key: "Value", Value: bodyExpr}, - } - } -} - -// serializeRestImplicitMappingBody creates a Rest$ImplicitMappingBody with an inline -// export mapping tree (ExportMappings$ObjectMappingElement). Used for Body: MAPPING Entity { ... }. -func serializeRestImplicitMappingBody(entity string, mappings []*model.RestResponseMapping) bson.D { - rootElement := serializeInlineMappingElement(entity, "", "", "(Object)", mappings, "ExportMappings", "Parameter") - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ImplicitMappingBody"}, - {Key: "RootMappingElement", Value: rootElement}, - {Key: "TestValue", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$StringValue"}, - {Key: "Value", Value: ""}, - }}, - } -} - -// serializeRestImplicitMappingResponse creates a Rest$ImplicitMappingResponseHandling with an -// inline import mapping tree (ImportMappings$ObjectMappingElement). Used for Response: MAPPING Entity { ... }. -func serializeRestImplicitMappingResponse(entity string, mappings []*model.RestResponseMapping) bson.D { - rootElement := serializeInlineMappingElement(entity, "", "", "(Object)", mappings, "ImportMappings", "Create") - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$ImplicitMappingResponseHandling"}, - {Key: "ContentType", Value: "application/json"}, - {Key: "RootMappingElement", Value: rootElement}, - {Key: "StatusCode", Value: int32(200)}, - } -} - -// nestedInlineHandling is the ObjectHandling a nested inline mapping element -// gets. It is NOT the same in both directions, and the export answer is not a -// preference: mxbuild refuses to LOAD a project whose export object element is -// "Create" — "Export Object Mappings cannot have ObjectHandling set to -// 'Create'", thrown as an AggregateException before any check runs, so the -// project cannot be opened at all. The serializer used to hardcode "Create" for -// every nested child regardless of namespace. -// -// "Find" is what Studio Pro stores on a nested object element of an export -// mapping DOCUMENT; the demo corpus contains no inline export body to pin it -// against directly (0 Rest$ImplicitMappingBody in 9 packages), so the document -// form is the reference and mxbuild is the control. -func nestedInlineHandling(namespace string) string { - if namespace == "ExportMappings" { - return "Find" - } - return "Create" -} - -// nestedInlineBackup pairs with it: an export element has nothing to create, so -// Studio Pro stores "Error" (see the ObjectHandlingBackup enum, #261). -func nestedInlineBackup(namespace string) string { - if namespace == "ExportMappings" { - return "Error" - } - return "Create" -} - -// serializeInlineMappingElement creates a single ObjectMappingElement with children for inline REST mappings. -// namespace is "ImportMappings" or "ExportMappings". objectHandling is "Create" or "Parameter". -func serializeInlineMappingElement(entity, association, exposedName, jsonPath string, mappings []*model.RestResponseMapping, namespace, objectHandling string) bson.D { - children := bson.A{int32(2)} - for _, m := range mappings { - if m.Entity != "" { - // Nested object mapping - childJsonPath := model.InlineMappingPath(jsonPath, m.ExposedName) - child := serializeInlineMappingElement(m.Entity, m.Association, - model.InlineMappingExposedName(m.ExposedName), childJsonPath, m.Children, - namespace, nestedInlineHandling(namespace)) - children = append(children, child) - } else { - // Value mapping - valueJsonPath := m.JsonPath - if valueJsonPath == "" { - valueJsonPath = model.InlineMappingPath(jsonPath, m.ExposedName) - } - attrQN := entity + "." + m.Attribute - children = append(children, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: namespace + "$ValueMappingElement"}, - {Key: "Attribute", Value: attrQN}, - {Key: "ExposedName", Value: model.InlineMappingExposedName(m.ExposedName)}, - {Key: "JsonPath", Value: valueJsonPath}, - {Key: "XmlPath", Value: ""}, - {Key: "IsKey", Value: false}, - {Key: "Type", Value: bson.D{{Key: "$ID", Value: idToBsonBinary(generateUUID())}, {Key: "$Type", Value: "DataTypes$StringType"}}}, - {Key: "MinOccurs", Value: int32(0)}, - {Key: "MaxOccurs", Value: int32(1)}, - {Key: "Nillable", Value: true}, - {Key: "IsDefaultType", Value: false}, - {Key: "ElementType", Value: "Value"}, - {Key: "Documentation", Value: ""}, - {Key: "Converter", Value: ""}, - {Key: "FractionDigits", Value: int32(-1)}, - {Key: "TotalDigits", Value: int32(-1)}, - {Key: "MaxLength", Value: int32(0)}, - {Key: "IsContent", Value: false}, - {Key: "IsXmlAttribute", Value: false}, - {Key: "OriginalValue", Value: ""}, - {Key: "XmlPrimitiveType", Value: "String"}, - }) - } - } - - minOccurs := int32(1) - if association != "" { - minOccurs = 0 - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: namespace + "$ObjectMappingElement"}, - {Key: "Entity", Value: entity}, - {Key: "ExposedName", Value: exposedName}, - {Key: "JsonPath", Value: jsonPath}, - {Key: "XmlPath", Value: ""}, - {Key: "ObjectHandling", Value: objectHandling}, - {Key: "ObjectHandlingBackup", Value: nestedInlineBackup(namespace)}, - {Key: "ObjectHandlingBackupAllowOverride", Value: false}, - {Key: "Association", Value: association}, - {Key: "Children", Value: children}, - {Key: "MinOccurs", Value: minOccurs}, - {Key: "MaxOccurs", Value: int32(1)}, - {Key: "Nillable", Value: true}, - {Key: "IsDefaultType", Value: false}, - {Key: "ElementType", Value: "Object"}, - {Key: "Documentation", Value: ""}, - {Key: "CustomHandlerCall", Value: nil}, - } -} - -// serializeRestHeader creates a Rest$HeaderWithValueTemplate BSON object. -func serializeRestHeader(h *model.RestClientHeader) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$HeaderWithValueTemplate"}, - {Key: "Name", Value: h.Name}, - {Key: "Value", Value: serializeValueTemplate(h.Value)}, - } -} - -// serializeRestParameter creates a Rest$OperationParameter BSON object. -// This is the correct type for consumed REST operation parameters -// (distinct from Rest$RestOperationParameter used in published REST services). -func serializeRestParameter(p *model.RestClientParameter) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$OperationParameter"}, - {Key: "Name", Value: p.Name}, - {Key: "DataType", Value: serializeRestDataType(p.DataType)}, - } -} - -// serializeRestQueryParameter creates a Rest$QueryParameter BSON object. -func serializeRestQueryParameter(p *model.RestClientParameter) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$QueryParameter"}, - {Key: "Name", Value: p.Name}, - {Key: "ParameterUsage", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RequiredQueryParameterUsage"}, - }}, - } -} - -// serializeRestResponseHandling creates a polymorphic ResponseHandling BSON object. -// Uses Rest$NoResponseHandling for all types to avoid CE0061 (ImplicitMappingResponseHandling -// requires entity mapping which isn't supported yet). ContentType is set to enable roundtripping. -func serializeRestResponseHandling(responseType string) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$NoResponseHandling"}, - } - switch strings.ToUpper(responseType) { - case "JSON": - doc = append(doc, bson.E{Key: "ContentType", Value: "application/json"}) - case "STRING": - doc = append(doc, bson.E{Key: "ContentType", Value: "text/plain"}) - case "FILE": - doc = append(doc, bson.E{Key: "ContentType", Value: "application/octet-stream"}) - } - return doc -} - -// serializeRestDataType converts a simple type name to a BSON DataType object. -// REST operation parameters use the DataTypes$ namespace with simple type names -// (e.g., DataTypes$IntegerType, not DataTypes$IntegerAttributeType). -func serializeRestDataType(typeName string) bson.D { - bsonType := "DataTypes$StringType" - switch typeName { - case "Integer": - bsonType = "DataTypes$IntegerType" - case "Long": - bsonType = "DataTypes$IntegerType" // Long maps to IntegerType in DataTypes - case "Decimal": - bsonType = "DataTypes$DecimalType" - case "Boolean": - bsonType = "DataTypes$BooleanType" - case "String": - bsonType = "DataTypes$StringType" - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: bsonType}, - } -} - -// CreatePublishedRestService creates a new published REST service document. -func (w *Writer) CreatePublishedRestService(svc *model.PublishedRestService) error { - if svc.ID == "" { - svc.ID = model.ID(generateUUID()) - } - svc.TypeName = "Rest$PublishedRestService" - - contents, err := w.serializePublishedRestService(svc) - if err != nil { - return fmt.Errorf("failed to serialize published REST service: %w", err) - } - - return w.insertUnit(string(svc.ID), string(svc.ContainerID), "Documents", "Rest$PublishedRestService", contents) -} - -// DeletePublishedRestService deletes a published REST service by ID. -func (w *Writer) DeletePublishedRestService(id model.ID) error { - return w.deleteUnit(string(id)) -} - -// UpdatePublishedRestService re-serializes an existing published REST -// service. Used by ALTER PUBLISHED REST SERVICE. -func (w *Writer) UpdatePublishedRestService(svc *model.PublishedRestService) error { - contents, err := w.serializePublishedRestService(svc) - if err != nil { - return fmt.Errorf("failed to serialize published REST service: %w", err) - } - return w.updateUnit(string(svc.ID), contents) -} - -func (w *Writer) serializePublishedRestService(svc *model.PublishedRestService) ([]byte, error) { - resources := bson.A{int32(2)} - for _, res := range svc.Resources { - ops := bson.A{int32(2)} - for _, op := range res.Operations { - opDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Rest$PublishedRestServiceOperation"}, - {Key: "HttpMethod", Value: httpMethodToMendix(op.HTTPMethod)}, - {Key: "Path", Value: op.Path}, - {Key: "Microflow", Value: op.Microflow}, - {Key: "Summary", Value: op.Summary}, - {Key: "Deprecated", Value: op.Deprecated}, - {Key: "Commit", Value: "Yes"}, - {Key: "Documentation", Value: ""}, - {Key: "ExportMapping", Value: ""}, - {Key: "ImportMapping", Value: ""}, - {Key: "ObjectHandlingBackup", Value: "Create"}, - {Key: "Parameters", Value: serializePublishedRestParams(op.Path, op.Microflow, op.Parameters)}, - } - ops = append(ops, opDoc) - } - resDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Rest$PublishedRestServiceResource"}, - {Key: "Name", Value: res.Name}, - {Key: "Documentation", Value: ""}, - {Key: "Operations", Value: ops}, - } - resources = append(resources, resDoc) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(svc.ID))}, - {Key: "$Type", Value: "Rest$PublishedRestService"}, - {Key: "Name", Value: svc.Name}, - {Key: "Documentation", Value: ""}, - {Key: "Excluded", Value: svc.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Path", Value: svc.Path}, - {Key: "Version", Value: svc.Version}, - {Key: "ServiceName", Value: svc.ServiceName}, - {Key: "AllowedRoles", Value: makeMendixStringArray(svc.AllowedRoles)}, - {Key: "AuthenticationTypes", Value: bson.A{int32(2)}}, - {Key: "AuthenticationMicroflow", Value: ""}, - {Key: "CorsConfiguration", Value: nil}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - {Key: "Resources", Value: resources}, - } - - return marshalUnitIDFirst(doc) -} - -// serializePublishedRestParams builds the Parameters array for a published REST operation. -// It auto-extracts path parameters from {paramName} placeholders in the path string, -// then appends any explicitly declared parameters. -// -// Each parameter must include: -// - Type: a structured DataTypes$StringType object (not a bare string) -// - ParameterType: "Path" (vs Query/Header/Body) -// - MicroflowParameter: qualified name of the matching microflow parameter, -// so Mendix wires the path value to the handler. Without this, mx check -// reports CE6538 "Parameter is not passed to a microflow parameter" and -// CE0350 "Microflow has a parameter that is not a parameter of the operation". -func serializePublishedRestParams(path string, microflowQN string, _ []string) bson.A { - params := bson.A{int32(2)} - // Extract {paramName} from path - for _, name := range extractPathParams(path) { - // MicroflowParameter format: "Module.Microflow.parameterName" - mfParam := "" - if microflowQN != "" { - mfParam = microflowQN + "." + name - } - params = append(params, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Rest$RestOperationParameter"}, - {Key: "Name", Value: name}, - {Key: "Type", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DataTypes$StringType"}, - }}, - {Key: "ParameterType", Value: "Path"}, - {Key: "MicroflowParameter", Value: mfParam}, - {Key: "Description", Value: ""}, - }) - } - return params -} - -// extractPathParams returns parameter names from {param} placeholders in a path. -func extractPathParams(path string) []string { - var names []string - for { - start := strings.Index(path, "{") - if start < 0 { - break - } - end := strings.Index(path[start:], "}") - if end < 0 { - break - } - names = append(names, path[start+1:start+end]) - path = path[start+end+1:] - } - return names -} - -// httpMethodToMendix converts uppercase HTTP method names to Mendix casing. -func httpMethodToMendix(method string) string { - switch strings.ToUpper(method) { - case "GET": - return "Get" - case "POST": - return "Post" - case "PUT": - return "Put" - case "PATCH": - return "Patch" - case "DELETE": - return "Delete" - case "HEAD": - return "Head" - case "OPTIONS": - return "Options" - default: - return method - } -} diff --git a/sdk/mpr/writer_rest_httpresponse_test.go b/sdk/mpr/writer_rest_httpresponse_test.go deleted file mode 100644 index 6488587e18..0000000000 --- a/sdk/mpr/writer_rest_httpresponse_test.go +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeRestResultHandlingHttpResponseUsesObjectType(t *testing.T) { - handling := µflows.ResultHandlingHttpResponse{ - BaseElement: model.BaseElement{ID: "result-1"}, - VariableName: "HttpResponse", - } - - doc := serializeRestResultHandling(handling, "HttpResponse") - - if got := getBSONField(doc, "ResultVariableName"); got != "HttpResponse" { - t.Fatalf("ResultVariableName = %#v, want HttpResponse", got) - } - variableType, ok := getBSONField(doc, "VariableType").(bson.D) - if !ok { - t.Fatalf("VariableType is %T, want bson.D", getBSONField(doc, "VariableType")) - } - if got := getBSONField(variableType, "$Type"); got != "DataTypes$ObjectType" { - t.Fatalf("VariableType.$Type = %#v, want DataTypes$ObjectType", got) - } - if got := getBSONField(variableType, "Entity"); got != "System.HttpResponse" { - t.Fatalf("VariableType.Entity = %#v, want System.HttpResponse", got) - } -} diff --git a/sdk/mpr/writer_rest_inline_mapping_test.go b/sdk/mpr/writer_rest_inline_mapping_test.go deleted file mode 100644 index 666bd54475..0000000000 --- a/sdk/mpr/writer_rest_inline_mapping_test.go +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// The INLINE REST mapping is a separate serializer from the mapping-DOCUMENT -// one, and two defects lived here that the document work never touched -// (reported by the mxcli-rest project, findings #36 and #37): -// -// 1. A multi-segment member (`Title = fields/Title`) was stored as ONE member -// whose name contains a slash — "(Object)|fields/Title" — instead of -// "(Object)|fields|Title". Every gate passed and the value was empty at -// runtime, which is the worst possible failure mode for this. -// 2. Every nested child was hardcoded ObjectHandling "Create", including in an -// EXPORT body, where mxbuild refuses to LOAD the project at all. -// -// Four Studio Pro-authored inline response mappings in the demo apps confirm -// the stored form is a full pipe path (e.g. -// "(Object)|results|bindings|(Object)|caseId|value"). - -func inlineRoot(t *testing.T, doc bson.D) map[string]any { - t.Helper() - for _, e := range doc { - if e.Key == "RootMappingElement" { - raw, err := bson.Marshal(e.Value) - if err != nil { - t.Fatalf("marshal root: %v", err) - } - var m map[string]any - if err := bson.Unmarshal(raw, &m); err != nil { - t.Fatalf("unmarshal root: %v", err) - } - return m - } - } - t.Fatal("no RootMappingElement") - return nil -} - -func inlineChildren(t *testing.T, elem map[string]any) []map[string]any { - t.Helper() - arr, ok := elem["Children"].(bson.A) - if !ok { - return nil - } - var out []map[string]any - for _, v := range arr { - m, ok := v.(map[string]any) - if !ok { - continue // the int32 typed-array marker - } - out = append(out, m) - } - return out -} - -// TestInlineResponseMappingMultiSegmentPath is finding #36. -func TestInlineResponseMappingMultiSegmentPath(t *testing.T) { - doc := serializeRestImplicitMappingResponse("RestLab.FlatProbe", []*model.RestResponseMapping{ - {Attribute: "ItemId", ExposedName: "id"}, - {Attribute: "Title", ExposedName: "fields/Title"}, - }) - - children := inlineChildren(t, inlineRoot(t, doc)) - if len(children) != 2 { - t.Fatalf("got %d children, want 2", len(children)) - } - if got := children[0]["JsonPath"]; got != "(Object)|id" { - t.Errorf("single-segment JsonPath = %q, want (Object)|id — unchanged behaviour", got) - } - // The defect: "(Object)|fields/Title" is one member with a slash in its - // name, so nothing binds and the column is silently empty. - if got := children[1]["JsonPath"]; got != "(Object)|fields|Title" { - t.Errorf("multi-segment JsonPath = %q, want (Object)|fields|Title", got) - } - // ExposedName is a label, and Studio Pro stores the last segment. - if got := children[1]["ExposedName"]; got != "Title" { - t.Errorf("ExposedName = %q, want Title", got) - } -} - -// TestInlineExportBodyNestedHandling is finding #37. "Create" on an export -// object element is not a check error — mxbuild throws before the check, and -// the project cannot be opened. -func TestInlineExportBodyNestedHandling(t *testing.T) { - doc := serializeRestImplicitMappingBody("RestLab.Task", []*model.RestResponseMapping{{ - Entity: "RestLab.TaskFields", - Association: "RestLab.Task_TaskFields", - ExposedName: "fields", - Children: []*model.RestResponseMapping{{Attribute: "Title", ExposedName: "Title"}}, - }}) - - root := inlineRoot(t, doc) - if got := root["ObjectHandling"]; got != "Parameter" { - t.Errorf("export root ObjectHandling = %q, want Parameter", got) - } - if got := root["$Type"]; got != "ExportMappings$ObjectMappingElement" { - t.Fatalf("export root $Type = %q", got) - } - - nested := inlineChildren(t, root) - if len(nested) != 1 { - t.Fatalf("got %d nested elements, want 1", len(nested)) - } - if got := nested[0]["ObjectHandling"]; got != "Find" { - t.Errorf("nested export ObjectHandling = %q, want Find — mxbuild refuses to LOAD "+ - "a project whose export object element is Create", got) - } - // An export element has nothing to create, so the backup is Error. - if got := nested[0]["ObjectHandlingBackup"]; got != "Error" { - t.Errorf("nested export ObjectHandlingBackup = %q, want Error", got) - } -} - -// TestInlineImportNestedHandlingUnchanged is the control for the one above: the -// import direction legitimately creates, and must not be changed by the fix. -func TestInlineImportNestedHandlingUnchanged(t *testing.T) { - doc := serializeRestImplicitMappingResponse("RestLab.Task", []*model.RestResponseMapping{{ - Entity: "RestLab.TaskFields", - Association: "RestLab.Task_TaskFields", - ExposedName: "fields", - Children: []*model.RestResponseMapping{{Attribute: "Title", ExposedName: "Title"}}, - }}) - - nested := inlineChildren(t, inlineRoot(t, doc)) - if len(nested) != 1 { - t.Fatalf("got %d nested elements, want 1", len(nested)) - } - if got := nested[0]["ObjectHandling"]; got != "Create" { - t.Errorf("nested import ObjectHandling = %q, want Create", got) - } - if got := nested[0]["JsonPath"]; got != "(Object)|fields" { - t.Errorf("nested JsonPath = %q", got) - } - // The value under it resolves against the nested path, not the root. - values := inlineChildren(t, nested[0]) - if len(values) != 1 || values[0]["JsonPath"] != "(Object)|fields|Title" { - t.Errorf("nested value JsonPath = %v", values) - } -} - -// TestInlineNestedObjectMultiSegmentPath: an OBJECT element can carry a -// multi-segment member too, and it was broken the same way. -func TestInlineNestedObjectMultiSegmentPath(t *testing.T) { - doc := serializeRestImplicitMappingResponse("RestLab.Root", []*model.RestResponseMapping{{ - Entity: "RestLab.Binding", - Association: "RestLab.Binding_Root", - ExposedName: "results/bindings", - Children: []*model.RestResponseMapping{{Attribute: "Value", ExposedName: "caseId/value"}}, - }}) - - nested := inlineChildren(t, inlineRoot(t, doc)) - if len(nested) != 1 { - t.Fatalf("got %d nested elements, want 1", len(nested)) - } - if got := nested[0]["JsonPath"]; got != "(Object)|results|bindings" { - t.Errorf("nested object JsonPath = %q, want (Object)|results|bindings", got) - } - if got := nested[0]["ExposedName"]; got != "bindings" { - t.Errorf("nested object ExposedName = %q, want bindings", got) - } - values := inlineChildren(t, nested[0]) - if len(values) != 1 { - t.Fatalf("got %d values, want 1", len(values)) - } - if got := values[0]["JsonPath"]; got != "(Object)|results|bindings|caseId|value" { - t.Errorf("value JsonPath = %q", got) - } -} diff --git a/sdk/mpr/writer_rest_test.go b/sdk/mpr/writer_rest_test.go deleted file mode 100644 index fc442c3765..0000000000 --- a/sdk/mpr/writer_rest_test.go +++ /dev/null @@ -1,447 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeConsumedRestServiceBasic(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedRestService{ - BaseElement: model.BaseElement{ - ID: "test-rest-id", - TypeName: "Rest$ConsumedRestService", - }, - ContainerID: "test-module-id", - Name: "PetStoreAPI", - BaseUrl: "https://petstore.swagger.io/v2", - } - - data, err := w.serializeConsumedRestService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - assertField(t, raw, "$Type", "Rest$ConsumedRestService") - assertField(t, raw, "Name", "PetStoreAPI") - - // BaseUrl should be a ValueTemplate - baseUrl, ok := raw["BaseUrl"].(map[string]any) - if !ok { - t.Fatalf("BaseUrl: expected map, got %T", raw["BaseUrl"]) - } - assertField(t, baseUrl, "$Type", "Rest$ValueTemplate") - assertField(t, baseUrl, "Value", "https://petstore.swagger.io/v2") - - // AuthenticationScheme should be nil - if raw["AuthenticationScheme"] != nil { - t.Errorf("AuthenticationScheme: expected nil, got %v", raw["AuthenticationScheme"]) - } -} - -func TestSerializeConsumedRestServiceWithAuth(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedRestService{ - BaseElement: model.BaseElement{ - ID: "test-rest-auth-id", - }, - ContainerID: "test-module-id", - Name: "SecureAPI", - BaseUrl: "https://api.example.com", - Authentication: &model.RestAuthentication{ - Scheme: "Basic", - Username: "admin", - Password: "secret", - }, - } - - data, err := w.serializeConsumedRestService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - // AuthenticationScheme should be BasicAuthenticationScheme - authScheme, ok := raw["AuthenticationScheme"].(map[string]any) - if !ok { - t.Fatalf("AuthenticationScheme: expected map, got %T", raw["AuthenticationScheme"]) - } - assertField(t, authScheme, "$Type", "Rest$BasicAuthenticationScheme") - - // Username should be StringValue (literal) - username, ok := authScheme["Username"].(map[string]any) - if !ok { - t.Fatalf("Username: expected map, got %T", authScheme["Username"]) - } - assertField(t, username, "$Type", "Rest$StringValue") - assertField(t, username, "Value", "admin") - - // Password should be StringValue (literal) - password, ok := authScheme["Password"].(map[string]any) - if !ok { - t.Fatalf("Password: expected map, got %T", authScheme["Password"]) - } - assertField(t, password, "$Type", "Rest$StringValue") - assertField(t, password, "Value", "secret") -} - -func TestSerializeConsumedRestServiceWithConstantAuth(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedRestService{ - BaseElement: model.BaseElement{ - ID: "test-rest-const-auth", - }, - ContainerID: "test-module-id", - Name: "ConstAuthAPI", - BaseUrl: "https://api.example.com", - Authentication: &model.RestAuthentication{ - Scheme: "Basic", - Username: "$MyModule.ApiUser", - Password: "$MyModule.ApiPass", - }, - } - - data, err := w.serializeConsumedRestService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - authScheme, ok := raw["AuthenticationScheme"].(map[string]any) - if !ok { - t.Fatalf("AuthenticationScheme: expected map, got %T", raw["AuthenticationScheme"]) - } - - // Username should be ConstantValue - username, ok := authScheme["Username"].(map[string]any) - if !ok { - t.Fatalf("Username: expected map, got %T", authScheme["Username"]) - } - assertField(t, username, "$Type", "Rest$ConstantValue") - assertField(t, username, "Value", "MyModule.ApiUser") -} - -func TestSerializeRestOperationGetWithParams(t *testing.T) { - op := &model.RestClientOperation{ - Name: "GetPet", - HttpMethod: "GET", - Path: "/pet/{petId}", - Parameters: []*model.RestClientParameter{ - {Name: "petId", DataType: "Integer"}, - }, - Headers: []*model.RestClientHeader{ - {Name: "Accept", Value: "application/json"}, - }, - ResponseType: "JSON", - Timeout: 30, - } - - result := dToM(serializeRestOperation(op)) - - assertField(t, result, "$Type", "Rest$RestOperation") - assertField(t, result, "Name", "GetPet") - - // Timeout - if v, ok := result["Timeout"].(int64); !ok || v != 30 { - t.Errorf("Timeout: expected 30, got %v", result["Timeout"]) - } - - // Method should be WithoutBody (GET) - method, ok := result["Method"].(bson.M) - if !ok { - t.Fatalf("Method: expected bson.M, got %T", result["Method"]) - } - if method["$Type"] != "Rest$RestOperationMethodWithoutBody" { - t.Errorf("Method.$Type: expected WithoutBody, got %v", method["$Type"]) - } - if method["HttpMethod"] != "Get" { - t.Errorf("Method.HttpMethod: expected Get, got %v", method["HttpMethod"]) - } - - // Path should be ValueTemplate - path, ok := result["Path"].(bson.M) - if !ok { - t.Fatalf("Path: expected bson.M, got %T", result["Path"]) - } - if path["Value"] != "/pet/{petId}" { - t.Errorf("Path.Value: expected /pet/{petId}, got %v", path["Value"]) - } - - // Parameters - params := extractBsonArray(result["Parameters"]) - if len(params) != 1 { - t.Fatalf("Parameters: expected 1, got %d", len(params)) - } - p0, ok := params[0].(bson.M) - if !ok { - t.Fatalf("Parameters[0]: expected bson.M, got %T", params[0]) - } - if p0["Name"] != "petId" { - t.Errorf("Parameter Name: expected petId, got %v", p0["Name"]) - } - dataType, ok := p0["DataType"].(bson.M) - if !ok { - t.Fatalf("Parameter DataType: expected bson.M, got %T", p0["DataType"]) - } - if dataType["$Type"] != "DataTypes$IntegerType" { - t.Errorf("Parameter DataType.$Type: expected IntegerAttributeType, got %v", dataType["$Type"]) - } - - // Headers - headers := extractBsonArray(result["Headers"]) - if len(headers) != 1 { - t.Fatalf("Headers: expected 1, got %d", len(headers)) - } - - // ResponseHandling (JSON uses NoResponseHandling with ContentType for compatibility) - respHandling, ok := result["ResponseHandling"].(bson.M) - if !ok { - t.Fatalf("ResponseHandling: expected bson.M, got %T", result["ResponseHandling"]) - } - if respHandling["$Type"] != "Rest$NoResponseHandling" { - t.Errorf("ResponseHandling.$Type: expected NoResponseHandling, got %v", respHandling["$Type"]) - } - if respHandling["ContentType"] != "application/json" { - t.Errorf("ResponseHandling.ContentType: expected application/json, got %v", respHandling["ContentType"]) - } -} - -func TestSerializeRestOperationPostWithBody(t *testing.T) { - op := &model.RestClientOperation{ - Name: "AddPet", - HttpMethod: "POST", - Path: "/pet", - BodyType: "JSON", - ResponseType: "JSON", - } - - result := dToM(serializeRestOperation(op)) - - // Method should be WithBody (POST) - method, ok := result["Method"].(bson.M) - if !ok { - t.Fatalf("Method: expected bson.M, got %T", result["Method"]) - } - if method["$Type"] != "Rest$RestOperationMethodWithBody" { - t.Errorf("Method.$Type: expected WithBody, got %v", method["$Type"]) - } - if method["HttpMethod"] != "Post" { - t.Errorf("Method.HttpMethod: expected Post, got %v", method["HttpMethod"]) - } - - // Body should be JsonBody (used instead of ImplicitMappingBody to avoid CE7247/CE0061) - body, ok := method["Body"].(bson.M) - if !ok { - t.Fatalf("Body: expected bson.M, got %T", method["Body"]) - } - if body["$Type"] != "Rest$JsonBody" { - t.Errorf("Body.$Type: expected JsonBody, got %v", body["$Type"]) - } -} - -func TestSerializeRestOperationNoResponse(t *testing.T) { - op := &model.RestClientOperation{ - Name: "DeletePet", - HttpMethod: "DELETE", - Path: "/pet/{petId}", - ResponseType: "NONE", - } - - result := dToM(serializeRestOperation(op)) - - respHandling, ok := result["ResponseHandling"].(bson.M) - if !ok { - t.Fatalf("ResponseHandling: expected bson.M, got %T", result["ResponseHandling"]) - } - if respHandling["$Type"] != "Rest$NoResponseHandling" { - t.Errorf("ResponseHandling.$Type: expected NoResponseHandling, got %v", respHandling["$Type"]) - } -} - -func TestSerializeRestOperationQueryParams(t *testing.T) { - op := &model.RestClientOperation{ - Name: "SearchPets", - HttpMethod: "GET", - Path: "/pet/findByStatus", - QueryParameters: []*model.RestClientParameter{ - {Name: "status", DataType: "String"}, - }, - ResponseType: "JSON", - } - - result := dToM(serializeRestOperation(op)) - - queryParams := extractBsonArray(result["QueryParameters"]) - if len(queryParams) != 1 { - t.Fatalf("QueryParameters: expected 1, got %d", len(queryParams)) - } - q0, ok := queryParams[0].(bson.M) - if !ok { - t.Fatalf("QueryParameters[0]: expected bson.M, got %T", queryParams[0]) - } - if q0["Name"] != "status" { - t.Errorf("QueryParam Name: expected status, got %v", q0["Name"]) - } - if q0["$Type"] != "Rest$QueryParameter" { - t.Errorf("QueryParam $Type: expected Rest$QueryParameter, got %v", q0["$Type"]) - } - - // ParameterUsage - usage, ok := q0["ParameterUsage"].(bson.M) - if !ok { - t.Fatalf("ParameterUsage: expected bson.M, got %T", q0["ParameterUsage"]) - } - if usage["$Type"] != "Rest$RequiredQueryParameterUsage" { - t.Errorf("ParameterUsage.$Type: expected RequiredQueryParameterUsage, got %v", usage["$Type"]) - } -} - -func TestHttpMethodToMendix(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"GET", "Get"}, - {"POST", "Post"}, - {"PUT", "Put"}, - {"PATCH", "Patch"}, - {"DELETE", "Delete"}, - {"HEAD", "Head"}, - {"OPTIONS", "Options"}, - } - for _, tc := range tests { - result := httpMethodToMendix(tc.input) - if result != tc.expected { - t.Errorf("httpMethodToMendix(%q): expected %q, got %q", tc.input, tc.expected, result) - } - } -} - -func TestSerializeConsumedRestServiceFullRoundtrip(t *testing.T) { - w := &Writer{} - svc := &model.ConsumedRestService{ - BaseElement: model.BaseElement{ - ID: "test-roundtrip-id", - }, - ContainerID: "test-module-id", - Name: "PetStoreAPI", - Documentation: "Swagger Pet Store API", - BaseUrl: "https://petstore.swagger.io/v2", - Operations: []*model.RestClientOperation{ - { - Name: "ListPets", - HttpMethod: "GET", - Path: "/pet/findByStatus", - QueryParameters: []*model.RestClientParameter{ - {Name: "status", DataType: "String"}, - }, - Headers: []*model.RestClientHeader{ - {Name: "Accept", Value: "application/json"}, - }, - ResponseType: "JSON", - Timeout: 30, - }, - { - Name: "GetPet", - HttpMethod: "GET", - Path: "/pet/{petId}", - Parameters: []*model.RestClientParameter{ - {Name: "petId", DataType: "Integer"}, - }, - ResponseType: "JSON", - }, - { - Name: "AddPet", - HttpMethod: "POST", - Path: "/pet", - BodyType: "JSON", - ResponseType: "JSON", - }, - { - Name: "RemovePet", - HttpMethod: "DELETE", - Path: "/pet/{petId}", - ResponseType: "NONE", - Parameters: []*model.RestClientParameter{ - {Name: "petId", DataType: "Integer"}, - }, - }, - }, - } - - data, err := w.serializeConsumedRestService(svc) - if err != nil { - t.Fatalf("serialize failed: %v", err) - } - - // Verify the BSON can be deserialized - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - // Verify top-level structure - assertField(t, raw, "$Type", "Rest$ConsumedRestService") - assertField(t, raw, "Name", "PetStoreAPI") - assertField(t, raw, "Documentation", "Swagger Pet Store API") - - // Verify operations count - ops := extractBsonArray(raw["Operations"]) - if len(ops) != 4 { - t.Fatalf("Operations: expected 4, got %d", len(ops)) - } - - // Verify first operation - op0, ok := ops[0].(map[string]any) - if !ok { - t.Fatalf("Operations[0]: expected map, got %T", ops[0]) - } - assertField(t, op0, "Name", "ListPets") - - // Verify POST operation has WithBody method - op2, ok := ops[2].(map[string]any) - if !ok { - t.Fatalf("Operations[2]: expected map, got %T", ops[2]) - } - assertField(t, op2, "Name", "AddPet") - method2, ok := op2["Method"].(map[string]any) - if !ok { - t.Fatalf("Operations[2].Method: expected map, got %T", op2["Method"]) - } - assertField(t, method2, "$Type", "Rest$RestOperationMethodWithBody") - - // Verify Body is JsonBody - body2, ok := method2["Body"].(map[string]any) - if !ok { - t.Fatalf("Operations[2].Method.Body: expected map, got %T", method2["Body"]) - } - assertField(t, body2, "$Type", "Rest$JsonBody") - - // Verify DELETE operation has WithoutBody method - op3, ok := ops[3].(map[string]any) - if !ok { - t.Fatalf("Operations[3]: expected map, got %T", ops[3]) - } - method3, ok := op3["Method"].(map[string]any) - if !ok { - t.Fatalf("Operations[3].Method: expected map, got %T", op3["Method"]) - } - assertField(t, method3, "$Type", "Rest$RestOperationMethodWithoutBody") -} diff --git a/sdk/mpr/writer_rule_split_test.go b/sdk/mpr/writer_rule_split_test.go deleted file mode 100644 index fab267c06b..0000000000 --- a/sdk/mpr/writer_rule_split_test.go +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSerializeExclusiveSplit_RuleSplitCondition_Roundtrip verifies that an -// ExclusiveSplit whose SplitCondition is a RuleSplitCondition survives -// serialize → BSON → parse without losing the rule reference or its parameter -// mappings. This is the BSON-level regression guard for the CE0117 Studio Pro -// error that appears when a rule-based decision is stored as an expression. -func TestSerializeExclusiveSplit_RuleSplitCondition_Roundtrip(t *testing.T) { - split := µflows.ExclusiveSplit{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Position: model.Point{X: 100, Y: 200}, - Size: model.Size{Width: 50, Height: 50}, - }, - Caption: "Module.IsEligible($Customer)", - ErrorHandlingType: microflows.ErrorHandlingTypeRollback, - SplitCondition: µflows.RuleSplitCondition{ - BaseElement: model.BaseElement{ID: "22222222-2222-2222-2222-222222222222"}, - RuleQualifiedName: "Module.IsEligible", - ParameterMappings: []*microflows.RuleCallParameterMapping{ - { - BaseElement: model.BaseElement{ID: "33333333-3333-3333-3333-333333333333"}, - ParameterName: "Module.IsEligible.Customer", - Argument: "$Customer", - }, - }, - }, - } - - doc := serializeMicroflowObject(split) - if doc == nil { - t.Fatal("serializeMicroflowObject returned nil") - } - - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("bson.Marshal failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal failed: %v", err) - } - - parsed := parseMicroflowObject(raw) - roundtripSplit, ok := parsed.(*microflows.ExclusiveSplit) - if !ok { - t.Fatalf("parsed object: got %T, want *microflows.ExclusiveSplit", parsed) - } - - ruleCond, ok := roundtripSplit.SplitCondition.(*microflows.RuleSplitCondition) - if !ok { - t.Fatalf("split condition after roundtrip: got %T, want *microflows.RuleSplitCondition", roundtripSplit.SplitCondition) - } - if ruleCond.RuleQualifiedName != "Module.IsEligible" { - t.Errorf("rule qualified name: got %q, want %q", ruleCond.RuleQualifiedName, "Module.IsEligible") - } - if len(ruleCond.ParameterMappings) != 1 { - t.Fatalf("parameter mappings: got %d, want 1", len(ruleCond.ParameterMappings)) - } - pm := ruleCond.ParameterMappings[0] - if pm.ParameterName != "Module.IsEligible.Customer" { - t.Errorf("parameter name: got %q, want %q", pm.ParameterName, "Module.IsEligible.Customer") - } - if pm.Argument != "$Customer" { - t.Errorf("argument: got %q, want %q", pm.Argument, "$Customer") - } -} - -// TestSerializeExclusiveSplit_ExpressionSplitCondition_Roundtrip is the -// complementary baseline that ensures the existing expression path still -// roundtrips correctly after the Rule branch was added to the writer switch. -func TestSerializeExclusiveSplit_ExpressionSplitCondition_Roundtrip(t *testing.T) { - split := µflows.ExclusiveSplit{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: "44444444-4444-4444-4444-444444444444"}, - Position: model.Point{X: 100, Y: 200}, - Size: model.Size{Width: 50, Height: 50}, - }, - Caption: "$Var = 'x'", - ErrorHandlingType: microflows.ErrorHandlingTypeRollback, - SplitCondition: µflows.ExpressionSplitCondition{ - BaseElement: model.BaseElement{ID: "55555555-5555-5555-5555-555555555555"}, - Expression: "$Var = 'x'", - }, - } - - doc := serializeMicroflowObject(split) - data, err := bson.Marshal(doc) - if err != nil { - t.Fatalf("bson.Marshal failed: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - t.Fatalf("bson.Unmarshal failed: %v", err) - } - - parsed := parseMicroflowObject(raw) - roundtripSplit := parsed.(*microflows.ExclusiveSplit) - - exprCond, ok := roundtripSplit.SplitCondition.(*microflows.ExpressionSplitCondition) - if !ok { - t.Fatalf("split condition after roundtrip: got %T, want *microflows.ExpressionSplitCondition", roundtripSplit.SplitCondition) - } - if exprCond.Expression != "$Var = 'x'" { - t.Errorf("expression: got %q, want %q", exprCond.Expression, "$Var = 'x'") - } -} diff --git a/sdk/mpr/writer_security.go b/sdk/mpr/writer_security.go deleted file mode 100644 index d51ba97554..0000000000 --- a/sdk/mpr/writer_security.go +++ /dev/null @@ -1,1843 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// GetRawUnitBytes reads the raw BSON bytes for a unit by ID. -// This returns unprocessed bytes suitable for raw BSON patching. -func (r *Reader) GetRawUnitBytes(id model.ID) ([]byte, error) { - var contents []byte - var err error - - if r.version == MPRVersionV2 { - contents, err = r.readMprContents(string(id)) - if err != nil { - return nil, fmt.Errorf("failed to read unit contents: %w", err) - } - } else { - unitIDBlob := uuidToBlob(string(id)) - row := r.db.QueryRow("SELECT Contents FROM Unit WHERE UnitID = ?", unitIDBlob) - err = row.Scan(&contents) - if err != nil { - return nil, fmt.Errorf("failed to read unit from database: %w", err) - } - } - - contents, err = r.resolveContents(string(id), contents) - if err != nil { - return nil, err - } - - return contents, nil -} - -// readPatchWrite is the core helper: reads raw BSON, applies a patch function, writes back. -func (w *Writer) readPatchWrite(unitID model.ID, patchFn func(doc bson.D) (bson.D, error)) error { - rawBytes, err := w.reader.GetRawUnitBytes(unitID) - if err != nil { - return fmt.Errorf("failed to read unit %s: %w", unitID, err) - } - - var doc bson.D - if err := bson.Unmarshal(rawBytes, &doc); err != nil { - return fmt.Errorf("failed to unmarshal BSON: %w", err) - } - - doc, err = patchFn(doc) - if err != nil { - return err - } - - newBytes, err := marshalUnitIDFirst(doc) - if err != nil { - return fmt.Errorf("failed to marshal BSON: %w", err) - } - - return w.updateUnit(string(unitID), newBytes) -} - -// setBsonField sets a top-level field in a bson.D, adding it if not found. -func setBsonField(doc bson.D, key string, value any) bson.D { - for i, elem := range doc { - if elem.Key == key { - doc[i].Value = value - return doc - } - } - return append(doc, bson.E{Key: key, Value: value}) -} - -// bsonStringField returns a top-level string field of a document, or "" when the -// field is absent (which is how an unconstrained access rule reads). -func bsonStringField(doc bson.D, key string) string { - for _, elem := range doc { - if elem.Key == key { - return bsonutil.String(elem.Value, key) - } - } - return "" -} - -// getBsonArray returns the Mendix-style array for a field (skipping the int32 marker). -func getBsonArray(doc bson.D, key string) bson.A { - for _, elem := range doc { - if elem.Key == key { - if arr, ok := elem.Value.(bson.A); ok { - return arr - } - } - } - return nil -} - -// makeMendixArray builds a Mendix-style array: int32(1) marker followed by items. -func makeMendixArray(items ...any) bson.A { - arr := bson.A{int32(1)} - arr = append(arr, items...) - return arr -} - -// makeMendixStringArray builds a Mendix-style array of strings. -func makeMendixStringArray(items []string) bson.A { - arr := bson.A{int32(1)} - for _, s := range items { - arr = append(arr, s) - } - return arr -} - -// allowedModuleRolesArray builds a Mendix-style AllowedModuleRoles BSON array -// from a slice of model.IDs. Returns the empty array marker if no roles are set. -func allowedModuleRolesArray(roles []model.ID) bson.A { - arr := bson.A{int32(1)} - for _, r := range roles { - arr = append(arr, string(r)) - } - return arr -} - -// ============================================================================ -// Microflow/Page: AllowedModuleRoles -// ============================================================================ - -// UpdateAllowedRoles patches the AllowedModuleRoles BSON field on a unit (microflow or page). -// roles should be qualified name strings like "Module.RoleName". -func (w *Writer) UpdateAllowedRoles(unitID model.ID, roles []string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - return setBsonField(doc, "AllowedModuleRoles", makeMendixStringArray(roles)), nil - }) -} - -// UpdatePublishedRestServiceRoles patches the AllowedRoles BSON field on a -// Rest$PublishedRestService unit. Note: REST uses "AllowedRoles" while -// microflows/pages/OData use "AllowedModuleRoles". -func (w *Writer) UpdatePublishedRestServiceRoles(unitID model.ID, roles []string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - return setBsonField(doc, "AllowedRoles", makeMendixStringArray(roles)), nil - }) -} - -// RemoveFromAllowedRoles removes a role from the AllowedModuleRoles BSON field on a unit. -// Returns true if the role was found and removed. -func (w *Writer) RemoveFromAllowedRoles(unitID model.ID, roleName string) (bool, error) { - removed := false - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - for _, f := range doc { - if f.Key != "AllowedModuleRoles" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - return doc, nil - } - var remaining bson.A - for _, item := range arr { - if s, ok := item.(string); ok && s == roleName { - removed = true - continue - } - remaining = append(remaining, item) - } - if removed { - return setBsonField(doc, "AllowedModuleRoles", remaining), nil - } - return doc, nil - } - return doc, nil - }) - return removed, err -} - -// ============================================================================ -// Module Roles: CREATE/DROP on Security$ModuleSecurity -// ============================================================================ - -// AddModuleRole adds a new module role to the module's Security$ModuleSecurity unit. -// If a role with the same name (case-insensitive) already exists, the existing role's -// Name is overwritten with the caller-supplied casing and Description is updated. -// Mendix Studio Pro rejects case-insensitive duplicate role names with CE0123, so -// merging into the existing entry matches runtime semantics — and preserves the -// caller's casing for downstream case-sensitive lookups (e.g., GRANT ACCESS TO x.user). -func (w *Writer) AddModuleRole(unitID model.ID, roleName, description string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - // Get existing ModuleRoles array - existing := getBsonArray(doc, "ModuleRoles") - if existing == nil { - existing = bson.A{int32(1)} - } - - // If a case-insensitive duplicate already exists, overwrite its Name and - // Description with the caller's values. This keeps the ID stable (any - // references to it remain valid) while adopting the newly-requested casing. - for i, item := range existing { - role, ok := item.(bson.D) - if !ok { - continue - } - matched := false - for _, field := range role { - if field.Key == "Name" { - if name, ok := field.Value.(string); ok && strings.EqualFold(name, roleName) { - matched = true - } - break - } - } - if !matched { - continue - } - for j, field := range role { - switch field.Key { - case "Name": - role[j].Value = roleName - case "Description": - if description != "" { - role[j].Value = description - } - } - } - existing[i] = role - return setBsonField(doc, "ModuleRoles", existing), nil - } - - // Build the new role BSON document - newRole := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Security$ModuleRole"}, - {Key: "Name", Value: roleName}, - {Key: "Description", Value: description}, - } - - existing = append(existing, newRole) - return setBsonField(doc, "ModuleRoles", existing), nil - }) -} - -// RemoveModuleRole removes a module role by name from the module's Security$ModuleSecurity unit. -func (w *Writer) RemoveModuleRole(unitID model.ID, roleName string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - existing := getBsonArray(doc, "ModuleRoles") - if existing == nil { - return doc, nil - } - - var filtered bson.A - for _, item := range existing { - // Keep the int32 marker - if _, ok := item.(int32); ok { - filtered = append(filtered, item) - continue - } - // Check if this role matches the name - if roleDoc, ok := item.(bson.D); ok { - name := "" - for _, f := range roleDoc { - if f.Key == "Name" { - name = bsonutil.String(f.Value, "Name") - break - } - } - if name == roleName { - continue // Skip this role (remove it) - } - } - filtered = append(filtered, item) - } - - return setBsonField(doc, "ModuleRoles", filtered), nil - }) -} - -// ============================================================================ -// Project Security: ALTER, User Roles, Demo Users -// ============================================================================ - -// SetProjectSecurityLevel patches the SecurityLevel field on Security$ProjectSecurity. -func (w *Writer) SetProjectSecurityLevel(unitID model.ID, level string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - return setBsonField(doc, "SecurityLevel", level), nil - }) -} - -// SetProjectDemoUsersEnabled patches the EnableDemoUsers field on Security$ProjectSecurity. -func (w *Writer) SetProjectDemoUsersEnabled(unitID model.ID, enabled bool) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - return setBsonField(doc, "EnableDemoUsers", enabled), nil - }) -} - -// SetProjectGuestAccess patches EnableGuestAccess — and, when guestUserRole is -// non-empty, GuestUserRole — on Security$ProjectSecurity. An empty role leaves -// the stored one alone so that toggling access off and on does not lose it. -func (w *Writer) SetProjectGuestAccess(unitID model.ID, enabled bool, guestUserRole string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - doc = setBsonField(doc, "EnableGuestAccess", enabled) - if guestUserRole != "" { - doc = setBsonField(doc, "GuestUserRole", guestUserRole) - } - return doc, nil - }) -} - -// AddUserRole adds a new user role to Security$ProjectSecurity. -func (w *Writer) AddUserRole(unitID model.ID, name string, moduleRoles []string, manageAllRoles bool) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - newRole := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Security$UserRole"}, - {Key: "Name", Value: name}, - {Key: "Description", Value: ""}, - {Key: "ModuleRoles", Value: makeMendixStringArray(moduleRoles)}, - {Key: "ManageAllRoles", Value: manageAllRoles}, - {Key: "ManageUsersWithoutRoles", Value: false}, - {Key: "ManageableRoles", Value: makeMendixArray()}, - {Key: "CheckSecurity", Value: false}, - } - - existing := getBsonArray(doc, "UserRoles") - if existing == nil { - existing = bson.A{int32(1)} - } - existing = append(existing, newRole) - return setBsonField(doc, "UserRoles", existing), nil - }) -} - -// AlterUserRoleModuleRoles adds or removes module roles from a user role in Security$ProjectSecurity. -func (w *Writer) AlterUserRoleModuleRoles(unitID model.ID, userRoleName string, add bool, moduleRoles []string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - existing := getBsonArray(doc, "UserRoles") - if existing == nil { - return doc, fmt.Errorf("no UserRoles array found") - } - - found := false - for i, item := range existing { - roleDoc, ok := item.(bson.D) - if !ok { - continue - } - name := "" - for _, f := range roleDoc { - if f.Key == "Name" { - name = bsonutil.String(f.Value, "Name") - break - } - } - if name != userRoleName { - continue - } - found = true - - // Get current module roles - var currentRoles []string - for _, f := range roleDoc { - if f.Key == "ModuleRoles" { - if arr, ok := f.Value.(bson.A); ok { - for _, r := range arr { - if s, ok := r.(string); ok { - currentRoles = append(currentRoles, s) - } - } - } - break - } - } - - if add { - // Add new roles, skip duplicates - existingSet := make(map[string]bool) - for _, r := range currentRoles { - existingSet[r] = true - } - for _, r := range moduleRoles { - if !existingSet[r] { - currentRoles = append(currentRoles, r) - } - } - } else { - // Remove specified roles - removeSet := make(map[string]bool) - for _, r := range moduleRoles { - removeSet[r] = true - } - var filtered []string - for _, r := range currentRoles { - if !removeSet[r] { - filtered = append(filtered, r) - } - } - currentRoles = filtered - } - - // Update the ModuleRoles field in the role document - for j, f := range roleDoc { - if f.Key == "ModuleRoles" { - roleDoc[j].Value = makeMendixStringArray(currentRoles) - break - } - } - existing[i] = roleDoc - break - } - - if !found { - return doc, fmt.Errorf("user role not found: %s", userRoleName) - } - - return setBsonField(doc, "UserRoles", existing), nil - }) -} - -// RemoveModuleRoleFromAllUserRoles removes a qualified module role (e.g., "Module.RoleName") -// from every user role's ModuleRoles list in Security$ProjectSecurity. -// Returns the number of user roles that were modified. -func (w *Writer) RemoveModuleRoleFromAllUserRoles(unitID model.ID, qualifiedRole string) (int, error) { - modified := 0 - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - existing := getBsonArray(doc, "UserRoles") - if existing == nil { - return doc, nil - } - - for i, item := range existing { - roleDoc, ok := item.(bson.D) - if !ok { - continue - } - - // Find and filter ModuleRoles - for j, f := range roleDoc { - if f.Key != "ModuleRoles" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - break - } - var filtered bson.A - found := false - for _, r := range arr { - if s, ok := r.(string); ok && s == qualifiedRole { - found = true - continue // Remove this role - } - filtered = append(filtered, r) - } - if found { - if len(filtered) == 0 { - roleDoc[j].Value = bson.A{int32(1)} // Empty Mendix array - } else { - roleDoc[j].Value = makeMendixStringArray(bsonAToStrings(filtered)) - } - existing[i] = roleDoc - modified++ - } - break - } - } - - return setBsonField(doc, "UserRoles", existing), nil - }) - return modified, err -} - -// bsonAToStrings converts a bson.A of strings to []string. -func bsonAToStrings(a bson.A) []string { - var result []string - for _, v := range a { - if s, ok := v.(string); ok { - result = append(result, s) - } - } - return result -} - -// RemoveUserRole removes a user role by name from Security$ProjectSecurity. -func (w *Writer) RemoveUserRole(unitID model.ID, name string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - existing := getBsonArray(doc, "UserRoles") - if existing == nil { - return doc, nil - } - - var filtered bson.A - for _, item := range existing { - if _, ok := item.(int32); ok { - filtered = append(filtered, item) - continue - } - if roleDoc, ok := item.(bson.D); ok { - roleName := "" - for _, f := range roleDoc { - if f.Key == "Name" { - roleName = bsonutil.String(f.Value, "Name") - break - } - } - if roleName == name { - continue // Remove this one - } - } - filtered = append(filtered, item) - } - - return setBsonField(doc, "UserRoles", filtered), nil - }) -} - -// AddDemoUser adds a new demo user to Security$ProjectSecurity. -func (w *Writer) AddDemoUser(unitID model.ID, userName, password, entity string, userRoles []string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - newUser := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Security$DemoUserImpl"}, - {Key: "UserName", Value: userName}, - {Key: "Password", Value: password}, - {Key: "Entity", Value: entity}, - {Key: "UserRoles", Value: makeMendixStringArray(userRoles)}, - } - - existing := getBsonArray(doc, "DemoUsers") - if existing == nil { - existing = bson.A{int32(1)} - } - existing = append(existing, newUser) - return setBsonField(doc, "DemoUsers", existing), nil - }) -} - -// RemoveDemoUser removes a demo user by name from Security$ProjectSecurity. -func (w *Writer) RemoveDemoUser(unitID model.ID, userName string) error { - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - existing := getBsonArray(doc, "DemoUsers") - if existing == nil { - return doc, nil - } - - var filtered bson.A - for _, item := range existing { - if _, ok := item.(int32); ok { - filtered = append(filtered, item) - continue - } - if userDoc, ok := item.(bson.D); ok { - name := "" - for _, f := range userDoc { - if f.Key == "UserName" { - name = bsonutil.String(f.Value, "UserName") - break - } - } - if name == userName { - continue // Remove - } - } - filtered = append(filtered, item) - } - - return setBsonField(doc, "DemoUsers", filtered), nil - }) -} - -// ============================================================================ -// Entity Access: GRANT/REVOKE on DomainModels$DomainModel -// ============================================================================ - -// EntityMemberAccess describes per-member access rights for an access rule. -type EntityMemberAccess struct { - AttributeRef string // "Module.Entity.AttrName" or "" - AssociationRef string // "Module.AssocName" or "" - AccessRights string // "None", "ReadOnly", "ReadWrite" -} - -// AddEntityAccessRule adds or updates an access rule for the given roles on an entity. -// If an existing rule with the same AllowedModuleRoles is found, it is updated in place. -// If memberAccesses is non-nil, explicit per-member access entries are created; -// otherwise an empty MemberAccesses array is used (DefaultMemberAccessRights applies to all). -// Note: Mendix does not have AllowRead/AllowWrite properties on AccessRule — read/write -// access is determined entirely by DefaultMemberAccessRights and MemberAccesses. -func (w *Writer) AddEntityAccessRule(unitID model.ID, entityName string, roleNames []string, - allowCreate, allowDelete bool, - defaultMemberAccess string, xpathConstraint string, - memberAccesses []EntityMemberAccess) error { - - return w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - entitiesArr := getBsonArray(doc, "Entities") - if entitiesArr == nil { - return doc, fmt.Errorf("no Entities array found in domain model") - } - - found := false - for i, item := range entitiesArr { - entityDoc, ok := item.(bson.D) - if !ok { - continue - } - name := "" - for _, f := range entityDoc { - if f.Key == "Name" { - name = bsonutil.String(f.Value, "Name") - break - } - } - if name != entityName { - continue - } - found = true - - // Build MemberAccesses BSON - var memberAccessesBson bson.A - if len(memberAccesses) > 0 { - memberAccessesBson = bson.A{int32(3)} // storageListType 3 - for _, ma := range memberAccesses { - maDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: ma.AccessRights}, - } - if ma.AttributeRef != "" { - maDoc = append(maDoc, bson.E{Key: "Attribute", Value: ma.AttributeRef}) - } - if ma.AssociationRef != "" { - maDoc = append(maDoc, bson.E{Key: "Association", Value: ma.AssociationRef}) - } - memberAccessesBson = append(memberAccessesBson, maDoc) - } - } else { - memberAccessesBson = bson.A{int32(3)} // empty — DefaultMemberAccessRights applies - } - - // Get existing AccessRules - var accessRules bson.A - accessRulesIdx := -1 - for j, f := range entityDoc { - if f.Key == "AccessRules" { - if arr, ok := f.Value.(bson.A); ok { - accessRules = arr - } - accessRulesIdx = j - break - } - } - if accessRules == nil { - accessRules = bson.A{int32(3)} // storageListType 3 - } - - // Check for an existing rule with the same AllowedModuleRoles AND the - // same XPathConstraint — upsert. - // - // The constraint belongs in the key because Mendix combines the rights - // of every rule naming a given module role ("Rules are additive", - // refguide/access-rules), so two constraints for one role are two - // legitimate rules. Matching on roles alone folded the second GRANT - // onto the first rule and overwrote its constraint, destroying it - // silently (mendixlabs/mxcli#936). An empty constraint is a value - // here, not a wildcard. - existingIdx := -1 - existingID := "" - for ri, ruleItem := range accessRules { - ruleDoc, ok := ruleItem.(bson.D) - if !ok { - continue - } - if rolesMatch(ruleDoc, roleNames) && - bsonStringField(ruleDoc, "XPathConstraint") == xpathConstraint { - existingIdx = ri - // Preserve the existing rule's $ID - for _, rf := range ruleDoc { - if rf.Key == "$ID" { - existingID = extractBsonIDValue(rf.Value) - break - } - } - break - } - } - - // Build the rule - ruleID := generateUUID() - if existingID != "" { - ruleID = existingID - } - newRule := bson.D{ - {Key: "$ID", Value: idToBsonBinary(ruleID)}, - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowedModuleRoles", Value: makeMendixStringArray(roleNames)}, - {Key: "AllowCreate", Value: allowCreate}, - {Key: "AllowDelete", Value: allowDelete}, - {Key: "DefaultMemberAccessRights", Value: defaultMemberAccess}, - {Key: "XPathConstraint", Value: xpathConstraint}, - {Key: "XPathConstraintCaption", Value: ""}, - {Key: "Documentation", Value: ""}, - {Key: "MemberAccesses", Value: memberAccessesBson}, - } - - if existingIdx >= 0 { - // Merge additively: keep the higher access level for each member - // and OR the structural permissions (Create/Delete). - existingRule, _ := accessRules[existingIdx].(bson.D) - newRule = mergeAccessRule(existingRule, newRule) - accessRules[existingIdx] = newRule - } else { - // Append new rule - accessRules = append(accessRules, newRule) - } - - if accessRulesIdx >= 0 { - entityDoc[accessRulesIdx].Value = accessRules - } else { - entityDoc = append(entityDoc, bson.E{Key: "AccessRules", Value: accessRules}) - } - entitiesArr[i] = entityDoc - break - } - - if !found { - return doc, fmt.Errorf("entity not found: %s", entityName) - } - - return setBsonField(doc, "Entities", entitiesArr), nil - }) -} - -// rolesMatch checks if a rule's AllowedModuleRoles matches the given role names (order-independent). -func rolesMatch(ruleDoc bson.D, roleNames []string) bool { - for _, f := range ruleDoc { - if f.Key != "AllowedModuleRoles" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - return false - } - // Extract role strings from the Mendix array (skip int32 markers) - var existing []string - for _, item := range arr { - if s, ok := item.(string); ok { - existing = append(existing, s) - } - } - if len(existing) != len(roleNames) { - return false - } - // Build set for comparison - set := make(map[string]bool, len(existing)) - for _, s := range existing { - set[s] = true - } - for _, rn := range roleNames { - if !set[rn] { - return false - } - } - return true - } - return false -} - -// accessRightsLevel returns a numeric level for access rights comparison. -// None=0 < ReadOnly=1 < ReadWrite=2. -// accessRightsLevel ranks member access rights. The lattice is shared with the -// codec engine (mdl/types) so the two cannot drift: both merge a GRANT by taking -// the higher of the stored and incoming rights. -func accessRightsLevel(s string) int { - return types.AccessRightsLevel(s) -} - -// mergeAccessRule merges a new access rule into an existing one additively. -// AllowCreate/AllowDelete are OR'd. MemberAccesses keep the higher access level. -// XPathConstraint is replaced only if the new rule specifies one. -func mergeAccessRule(existing, newRule bson.D) bson.D { - // Extract existing MemberAccesses keyed by attribute/association ref - existingMembers := make(map[string]string) // ref -> access rights - for _, f := range existing { - if f.Key != "MemberAccesses" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - break - } - for _, item := range arr { - maDoc, ok := item.(bson.D) - if !ok { - continue - } - var ref, rights string - for _, mf := range maDoc { - switch mf.Key { - case "Attribute": - ref = bsonutil.String(mf.Value, "Attribute") - case "Association": - ref = bsonutil.String(mf.Value, "Association") - case "AccessRights": - rights = bsonutil.String(mf.Value, "AccessRights") - } - } - if ref != "" { - existingMembers[ref] = rights - } - } - } - - // Extract existing AllowCreate/AllowDelete and DefaultMemberAccessRights - var existCreate, existDelete bool - var existDefault string - for _, f := range existing { - switch f.Key { - case "AllowCreate": - existCreate = bsonutil.Bool(f.Value, "AllowCreate") - case "AllowDelete": - existDelete = bsonutil.Bool(f.Value, "AllowDelete") - case "DefaultMemberAccessRights": - existDefault = bsonutil.String(f.Value, "DefaultMemberAccessRights") - } - } - - // Merge into newRule - for i, f := range newRule { - switch f.Key { - case "AllowCreate": - newVal := bsonutil.Bool(f.Value, "AllowCreate") - newRule[i].Value = newVal || existCreate - case "AllowDelete": - newVal := bsonutil.Bool(f.Value, "AllowDelete") - newRule[i].Value = newVal || existDelete - case "DefaultMemberAccessRights": - newVal := bsonutil.String(f.Value, "DefaultMemberAccessRights") - if accessRightsLevel(existDefault) > accessRightsLevel(newVal) { - newRule[i].Value = existDefault - } - // XPathConstraint is not merged: it is part of the key that selected this - // rule, so the stored and incoming values are equal by construction. It - // used to be inherited from the stored rule when the new GRANT had no - // WHERE, which quietly constrained access the user had asked to be - // unconstrained; such a GRANT now matches (or creates) the unconstrained - // rule instead. - case "MemberAccesses": - arr, ok := f.Value.(bson.A) - if !ok { - break - } - for j, item := range arr { - maDoc, ok := item.(bson.D) - if !ok { - continue - } - var ref, newRights string - for _, mf := range maDoc { - switch mf.Key { - case "Attribute": - ref = bsonutil.String(mf.Value, "Attribute") - case "Association": - ref = bsonutil.String(mf.Value, "Association") - case "AccessRights": - newRights = bsonutil.String(mf.Value, "AccessRights") - } - } - if ref == "" { - continue - } - if existRights, ok := existingMembers[ref]; ok { - if accessRightsLevel(existRights) > accessRightsLevel(newRights) { - // Upgrade to existing higher level - for k, mf := range maDoc { - if mf.Key == "AccessRights" { - maDoc[k].Value = existRights - break - } - } - arr[j] = maDoc - } - } - } - newRule[i].Value = arr - } - } - - return newRule -} - -// RemoveEntityAccessRule removes the given roles from access rules on an entity. -// For multi-role rules, only the specified roles are removed from the rule's role list. -// If a rule has no remaining roles after removal, the entire rule is deleted. -// Returns the number of rules that were modified or removed. -func (w *Writer) RemoveEntityAccessRule(unitID model.ID, entityName string, roleNames []string) (int, error) { - modified := 0 - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - entitiesArr := getBsonArray(doc, "Entities") - if entitiesArr == nil { - return doc, fmt.Errorf("no Entities array found in domain model") - } - - removeRoles := make(map[string]bool) - for _, r := range roleNames { - removeRoles[r] = true - } - - found := false - for i, item := range entitiesArr { - entityDoc, ok := item.(bson.D) - if !ok { - continue - } - name := "" - for _, f := range entityDoc { - if f.Key == "Name" { - name = bsonutil.String(f.Value, "Name") - break - } - } - if name != entityName { - continue - } - found = true - - for j, f := range entityDoc { - if f.Key != "AccessRules" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - break - } - - var filtered bson.A - for _, ruleItem := range arr { - if _, ok := ruleItem.(int32); ok { - filtered = append(filtered, ruleItem) - continue - } - ruleDoc, ok := ruleItem.(bson.D) - if !ok { - filtered = append(filtered, ruleItem) - continue - } - - keepRule, wasModified := removeRolesFromAccessRule(ruleDoc, removeRoles) - if wasModified { - modified++ - } - if keepRule { - filtered = append(filtered, ruleDoc) - } - } - - entityDoc[j].Value = filtered - break - } - - entitiesArr[i] = entityDoc - break - } - - if !found { - return doc, fmt.Errorf("entity not found: %s", entityName) - } - - return setBsonField(doc, "Entities", entitiesArr), nil - }) - return modified, err -} - -// removeRolesFromAccessRule removes the specified roles from a rule's AllowedModuleRoles. -// Returns (keepRule, wasModified). keepRule is false if no roles remain (rule should be deleted). -func removeRolesFromAccessRule(ruleDoc bson.D, removeRoles map[string]bool) (bool, bool) { - for k, rf := range ruleDoc { - if rf.Key != "AllowedModuleRoles" { - continue - } - rolesArr, ok := rf.Value.(bson.A) - if !ok { - return true, false - } - - var remaining bson.A - removed := false - roleCount := 0 - for _, rr := range rolesArr { - if _, ok := rr.(int32); ok { - remaining = append(remaining, rr) // keep array marker - continue - } - if s, ok := rr.(string); ok { - if removeRoles[s] { - removed = true - } else { - remaining = append(remaining, rr) - roleCount++ - } - } - } - - if !removed { - return true, false // no change - } - if roleCount == 0 { - return false, true // delete entire rule - } - ruleDoc[k].Value = remaining - return true, true // keep rule with fewer roles - } - return true, false -} - -// EntityAccessRevocation describes what to revoke from an entity access rule. -type EntityAccessRevocation struct { - RevokeCreate bool - RevokeDelete bool - // Members to fully revoke (set to None) - RevokeReadMembers []string // attribute/association refs to set to None - // Members to downgrade from ReadWrite to ReadOnly - RevokeWriteMembers []string // attribute/association refs to downgrade - // Revoke all read/write access - RevokeReadAll bool - RevokeWriteAll bool -} - -// RevokeEntityMemberAccess performs a partial revoke on an existing access rule. -// It downgrades or removes specific rights without deleting the entire rule. -// Returns the number of rules modified. -func (w *Writer) RevokeEntityMemberAccess(unitID model.ID, entityName string, roleNames []string, revocation EntityAccessRevocation) (int, error) { - modified := 0 - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - entitiesArr := getBsonArray(doc, "Entities") - if entitiesArr == nil { - return doc, fmt.Errorf("no Entities array found in domain model") - } - - found := false - for i, item := range entitiesArr { - entityDoc, ok := item.(bson.D) - if !ok { - continue - } - name := "" - for _, f := range entityDoc { - if f.Key == "Name" { - name = bsonutil.String(f.Value, "Name") - break - } - } - if name != entityName { - continue - } - found = true - - for j, f := range entityDoc { - if f.Key != "AccessRules" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - break - } - - for ri, ruleItem := range arr { - ruleDoc, ok := ruleItem.(bson.D) - if !ok { - continue - } - if !rolesMatch(ruleDoc, roleNames) { - continue - } - - // Found matching rule — apply revocations - ruleModified := false - - // Build sets for quick lookup - revokeReadSet := make(map[string]bool) - for _, ref := range revocation.RevokeReadMembers { - revokeReadSet[ref] = true - } - revokeWriteSet := make(map[string]bool) - for _, ref := range revocation.RevokeWriteMembers { - revokeWriteSet[ref] = true - } - - for k, rf := range ruleDoc { - switch rf.Key { - case "AllowCreate": - if revocation.RevokeCreate { - ruleDoc[k].Value = false - ruleModified = true - } - case "AllowDelete": - if revocation.RevokeDelete { - ruleDoc[k].Value = false - ruleModified = true - } - case "DefaultMemberAccessRights": - if revocation.RevokeReadAll { - ruleDoc[k].Value = "None" - ruleModified = true - } else if revocation.RevokeWriteAll { - cur := bsonutil.String(rf.Value, "DefaultMemberAccessRights") - if cur == "ReadWrite" { - ruleDoc[k].Value = "ReadOnly" - ruleModified = true - } - } - case "MemberAccesses": - maArr, ok := rf.Value.(bson.A) - if !ok { - break - } - for mi, maItem := range maArr { - maDoc, ok := maItem.(bson.D) - if !ok { - continue - } - var ref, rights string - for _, mf := range maDoc { - switch mf.Key { - case "Attribute": - ref = bsonutil.String(mf.Value, "Attribute") - case "Association": - ref = bsonutil.String(mf.Value, "Association") - case "AccessRights": - rights = bsonutil.String(mf.Value, "AccessRights") - } - } - if ref == "" { - continue - } - - newRights := rights - if revocation.RevokeReadAll || revokeReadSet[ref] { - newRights = "None" - } else if revocation.RevokeWriteAll || revokeWriteSet[ref] { - if rights == "ReadWrite" { - newRights = "ReadOnly" - } - } - - if newRights != rights { - for mk, mf := range maDoc { - if mf.Key == "AccessRights" { - maDoc[mk].Value = newRights - break - } - } - maArr[mi] = maDoc - ruleModified = true - } - } - ruleDoc[k].Value = maArr - } - } - - if ruleModified { - arr[ri] = ruleDoc - modified++ - } - break - } - - entityDoc[j].Value = arr - break - } - - entitiesArr[i] = entityDoc - break - } - - if !found { - return doc, fmt.Errorf("entity not found: %s", entityName) - } - - return setBsonField(doc, "Entities", entitiesArr), nil - }) - return modified, err -} - -// RemoveRoleFromAllEntities removes the given role from all entity access rules in a domain model. -// Used by DROP MODULE ROLE cascade. Returns the number of rules modified/removed. -func (w *Writer) RemoveRoleFromAllEntities(unitID model.ID, roleName string) (int, error) { - modified := 0 - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - entitiesArr := getBsonArray(doc, "Entities") - if entitiesArr == nil { - return doc, nil // no entities, nothing to do - } - - removeRoles := map[string]bool{roleName: true} - - for i, item := range entitiesArr { - entityDoc, ok := item.(bson.D) - if !ok { - continue - } - - for j, f := range entityDoc { - if f.Key != "AccessRules" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - break - } - - var filtered bson.A - for _, ruleItem := range arr { - if _, ok := ruleItem.(int32); ok { - filtered = append(filtered, ruleItem) - continue - } - ruleDoc, ok := ruleItem.(bson.D) - if !ok { - filtered = append(filtered, ruleItem) - continue - } - - keepRule, wasModified := removeRolesFromAccessRule(ruleDoc, removeRoles) - if wasModified { - modified++ - } - if keepRule { - filtered = append(filtered, ruleDoc) - } - } - - entityDoc[j].Value = filtered - break - } - - entitiesArr[i] = entityDoc - } - - return setBsonField(doc, "Entities", entitiesArr), nil - }) - return modified, err -} - -// ReconcileMemberAccesses reconciles MemberAccesses on all AccessRules within a domain model -// to match the current entity structure. It adds entries for new attributes/associations and -// removes entries for deleted ones. Returns the number of rules modified. -func (w *Writer) ReconcileMemberAccesses(unitID model.ID, moduleName string) (int, error) { - modified := 0 - - err := w.readPatchWrite(unitID, func(doc bson.D) (bson.D, error) { - entitiesArr := getBsonArray(doc, "Entities") - if entitiesArr == nil { - return doc, nil - } - - // Collect all association names in this module (from Associations + CrossAssociations) - assocNames := map[string]bool{} - assocArr := getBsonArray(doc, "Associations") - for _, item := range assocArr { - assocDoc, ok := item.(bson.D) - if !ok { - continue - } - for _, f := range assocDoc { - if f.Key == "Name" { - if name, ok := f.Value.(string); ok { - assocNames[name] = true - } - break - } - } - } - crossArr := getBsonArray(doc, "CrossAssociations") - for _, item := range crossArr { - crossDoc, ok := item.(bson.D) - if !ok { - continue - } - for _, f := range crossDoc { - if f.Key == "Name" { - if name, ok := f.Value.(string); ok { - assocNames[name] = true - } - break - } - } - } - - for i, item := range entitiesArr { - entityDoc, ok := item.(bson.D) - if !ok { - continue - } - - // Get entity name - entityName := "" - for _, f := range entityDoc { - if f.Key == "Name" { - entityName = bsonutil.String(f.Value, "Name") - break - } - } - if entityName == "" { - continue - } - - // The attributes this entity's rules must cover: its OWN and those it - // INHERITS from a generalization in this module, each qualified - // against the entity that DECLARES it — which is what Mendix stores, - // and what makes an inherited entry's reference name an ancestor - // rather than this entity. - // - // Collecting only the entity's own attributes left every - // specialization's rule short of a member as soon as the - // generalization gained one, which Mendix reports as CE0066 "Entity - // access is out of date" — and `UPDATE SECURITY`, the command that - // exists to repair it, found nothing missing and reported "All entity - // access rules are up to date" over a project mx check rejects - // (mendixlabs/mxcli#1047, reported against 0.21.0). The codec engine - // had the same defect in the same shape; both are fixed together, - // because a fix in one of these parallel writers leaves the other - // latent until something switches engines. - // - // Keyed by full reference rather than bare name: the compare pass - // below preserves any reference not qualified against this entity, so - // a bare-name key would mark an inherited entry uncovered and ADD a - // second copy of a member the rule already has. - expectedAttrs := entityAttrsInChain(entitiesArr, entityName, moduleName) - expectedAttrRefs := map[string]bool{} - calculatedAttrRefs := map[string]bool{} - for _, ea := range expectedAttrs { - expectedAttrRefs[ea.ref] = true - if ea.calculated { - calculatedAttrRefs[ea.ref] = true - } - } - - // Collect associations where this entity is the FROM entity (ParentPointer). - // In Mendix BSON, ParentPointer = FROM entity (FK owner), ChildPointer = TO entity. - // MemberAccess for associations is only required on the FROM (owner) side. - entityID := "" - for _, f := range entityDoc { - if f.Key == "$ID" { - entityID = extractBsonIDValue(f.Value) - break - } - } - entityAssocNames := map[string]bool{} - - // Check for system associations (HasOwner, HasChangedBy) in NoGeneralization. - // These add implicit System.owner / System.changedBy associations that - // require MemberAccess entries. Stored as full refs (e.g., "System.owner"). - systemAssocRefs := map[string]bool{} - for _, f := range entityDoc { - if f.Key == "Generalization" || f.Key == "MaybeGeneralization" { - if genDoc, ok := f.Value.(bson.D); ok { - for _, gf := range genDoc { - if gf.Key == "$Type" { - if gt, ok := gf.Value.(string); ok && gt == "DomainModels$NoGeneralization" { - for _, ngf := range genDoc { - switch ngf.Key { - case "HasOwner": - if v, ok := ngf.Value.(bool); ok && v { - systemAssocRefs["System.owner"] = true - } - case "HasChangedBy": - if v, ok := ngf.Value.(bool); ok && v { - systemAssocRefs["System.changedBy"] = true - } - } - } - } - } - } - } - break - } - } - for _, aItem := range assocArr { - aDoc, ok := aItem.(bson.D) - if !ok { - continue - } - aParentID := "" - aName := "" - for _, f := range aDoc { - switch f.Key { - case "ParentPointer": - aParentID = extractBsonIDValue(f.Value) - case "Name": - aName = bsonutil.String(f.Value, "Name") - } - } - if aParentID == entityID && aName != "" { - entityAssocNames[aName] = true - } - } - for _, caItem := range crossArr { - caDoc, ok := caItem.(bson.D) - if !ok { - continue - } - parentID := "" - caName := "" - for _, f := range caDoc { - if f.Key == "ParentPointer" { - parentID = extractBsonIDValue(f.Value) - } - if f.Key == "Name" { - caName = bsonutil.String(f.Value, "Name") - } - } - if parentID == entityID && caName != "" { - entityAssocNames[caName] = true - } - } - - // Process AccessRules - for j, f := range entityDoc { - if f.Key != "AccessRules" { - continue - } - rulesArr, ok := f.Value.(bson.A) - if !ok { - break - } - - for k, ruleItem := range rulesArr { - ruleDoc, ok := ruleItem.(bson.D) - if !ok { - continue - } - - // Strip invalid properties (AllowRead, AllowWrite) that - // old mxcli versions wrote. These crash Studio Pro with - // "Sequence contains no matching element" in MprProperty..ctor. - ruleDoc, stripped := stripInvalidAccessRuleProps(ruleDoc) - if stripped { - rulesArr[k] = ruleDoc - modified++ - } - - // Find MemberAccesses - for m, rf := range ruleDoc { - if rf.Key != "MemberAccesses" { - continue - } - maArr, ok := rf.Value.(bson.A) - if !ok { - break - } - - // A list holding only the storage marker is NOT a reason to - // skip: a rule with no member entries on an entity that has - // members is precisely the out-of-date state CE0066 names, and - // topping it up is what this function is for. The early break - // that used to be here made the legacy engine disagree with the - // codec engine, which fills such a rule in — and it surfaced the - // moment `create or modify entity` started PRESERVING rules - // instead of deleting them: a rewrite that drops every attribute - // a rule covered empties the list, and the entity's new - // attributes then never got an entry (CE0066 on the module). - // An entity with no members at all still lands here and still - // changes nothing, since the add loops below find nothing to add. - if len(maArr) == 0 { - break // no storage marker; not a list this writer produced - } - - // Get DefaultMemberAccessRights for new entries - defaultRights := "ReadWrite" - for _, drf := range ruleDoc { - if drf.Key == "DefaultMemberAccessRights" { - if dr, ok := drf.Value.(string); ok { - defaultRights = dr - } - break - } - } - - // Build set of covered attributes and associations - coveredAttrs := map[string]bool{} - coveredAssocs := map[string]bool{} - changed := false - var filtered bson.A - // Preserve the storage marker - if len(maArr) > 0 { - filtered = bson.A{maArr[0]} - } - - coveredSystemAssocs := map[string]bool{} - for _, maItem := range maArr[1:] { - maDoc, ok := maItem.(bson.D) - if !ok { - continue - } - attrRef := "" - assocRef := "" - for _, mf := range maDoc { - if mf.Key == "Attribute" { - attrRef = bsonutil.String(mf.Value, "Attribute") - } - if mf.Key == "Association" { - assocRef = bsonutil.String(mf.Value, "Association") - } - } - - if attrRef != "" { - switch { - case expectedAttrRefs[attrRef]: - // A member the entity has — its own, or one inherited - // from a generalization in this module. - coveredAttrs[attrRef] = true - // Downgrade write rights on calculated attributes (CE6592) - if calculatedAttrRefs[attrRef] { - maDoc = downgradeCalculatedAttrRights(maDoc) - } - filtered = append(filtered, maDoc) - case !attrRefBelongsToEntity(attrRef, moduleName, entityName): - // An inherited member's reference is qualified against the - // entity that DECLARES it, so it does not match this - // entity's own attribute list and used to be deleted as - // stale (mendixlabs/mxcli#758). The ancestor may live in - // another module or in System, neither loaded here, so an - // inherited reference cannot be validated at this layer — - // preserve what cannot be checked. Mirrors the codec engine - // (mdl/backend/modelsdk.attrRefBelongsTo). - filtered = append(filtered, maDoc) - default: - changed = true // stale attribute entry removed - } - } else if assocRef != "" { - // Check if it's a system association (e.g., "System.owner") - if systemAssocRefs[assocRef] { - coveredSystemAssocs[assocRef] = true - filtered = append(filtered, maItem) - } else { - // Extract association name from Module.AssocName - parts := splitAssocRef(assocRef) - if parts != "" && entityAssocNames[parts] { - coveredAssocs[parts] = true - filtered = append(filtered, maItem) - } else { - changed = true // stale association entry removed - } - } - } else { - filtered = append(filtered, maItem) - } - } - - // Add missing attributes, in declaration order (own first, - // then each ancestor's). Iterating the map instead made the - // order of new entries vary between runs, so two identical - // reconciles could produce different bytes. - for _, ea := range expectedAttrs { - if !coveredAttrs[ea.ref] { - rights := defaultRights - // Calculated attributes cannot have write rights (CE6592) - if ea.calculated && (rights == "ReadWrite" || rights == "WriteOnly") { - rights = "ReadOnly" - } - newMA := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: rights}, - {Key: "Attribute", Value: ea.ref}, - } - filtered = append(filtered, newMA) - changed = true - } - } - - // Add missing module associations - for aName := range entityAssocNames { - if !coveredAssocs[aName] { - newMA := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: defaultRights}, - {Key: "Association", Value: moduleName + "." + aName}, - } - filtered = append(filtered, newMA) - changed = true - } - } - - // Add missing system associations (e.g., System.owner) - for sysRef := range systemAssocRefs { - if !coveredSystemAssocs[sysRef] { - newMA := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: defaultRights}, - {Key: "Association", Value: sysRef}, - } - filtered = append(filtered, newMA) - changed = true - } - } - - if changed { - ruleDoc[m].Value = filtered - rulesArr[k] = ruleDoc - modified++ - } - - break - } - } - - entityDoc[j].Value = rulesArr - break - } - - entitiesArr[i] = entityDoc - } - - return setBsonField(doc, "Entities", entitiesArr), nil - }) - - return modified, err -} - -// downgradeCalculatedAttrRights changes ReadWrite/WriteOnly to ReadOnly on a MemberAccess doc. -func downgradeCalculatedAttrRights(doc bson.D) bson.D { - for i, f := range doc { - if f.Key == "AccessRights" { - if rights, ok := f.Value.(string); ok && (rights == "ReadWrite" || rights == "WriteOnly") { - doc[i].Value = "ReadOnly" - } - } - } - return doc -} - -// extractBsonIDValue extracts a string ID from various BSON ID representations. -func extractBsonIDValue(v any) string { - switch val := v.(type) { - case string: - return val - case primitive.Binary: - return blobToUUID(val.Data) - default: - return fmt.Sprintf("%v", v) - } -} - -// splitQualifiedRef extracts the last component from "Module.Entity.AttrName". -func splitQualifiedRef(ref string) string { - parts := splitByDot(ref) - if len(parts) >= 3 { - return parts[len(parts)-1] - } - return "" -} - -// splitAssocRef extracts the association name from "Module.AssocName". -func splitAssocRef(ref string) string { - parts := splitByDot(ref) - if len(parts) >= 2 { - return parts[len(parts)-1] - } - return "" -} - -// splitByDot splits a string by "." - simple helper to avoid importing strings. -func splitByDot(s string) []string { - var parts []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == '.' { - parts = append(parts, s[start:i]) - start = i + 1 - } - } - parts = append(parts, s[start:]) - return parts -} - -// invalidAccessRuleProps lists BSON keys that are NOT valid Mendix metamodel -// properties on DomainModels$AccessRule. Old mxcli versions wrote these; -// Studio Pro crashes with "Sequence contains no matching element" if present. -var invalidAccessRuleProps = map[string]bool{ - "AllowRead": true, - "AllowWrite": true, -} - -// stripInvalidAccessRuleProps removes invalid properties from an AccessRule BSON document. -// Returns the cleaned document and true if any properties were removed. -func stripInvalidAccessRuleProps(doc bson.D) (bson.D, bool) { - cleaned := make(bson.D, 0, len(doc)) - stripped := false - for _, f := range doc { - if invalidAccessRuleProps[f.Key] { - stripped = true - continue - } - cleaned = append(cleaned, f) - } - return cleaned, stripped -} - -// ensure primitive import is used -var _ = primitive.Binary{} - -// chainAttr is one attribute of an entity's access surface: the reference -// Mendix stores for it, and whether it is calculated (which caps its rights). -type chainAttr struct { - ref string // "Module.DeclaringEntity.Attribute" - calculated bool -} - -// entityAttrsInChain returns the attributes an entity's access rules must -// cover — its own, then those of each generalization that lives in THIS module, -// nearest ancestor first — each qualified against the entity that declares it. -// -// A nearer entity's attribute SHADOWS an ancestor's of the same name, matching -// the executor's own member walk (EntityMembersFor): emitting both would put two -// entries in the rule for one member the modeller sees. -// -// The walk stops at the first ancestor outside this module (or one that cannot -// be found), because only this module's domain model is loaded here. Those -// members are neither added nor pruned — the compare pass preserves the entries -// that already reference them. -func entityAttrsInChain(entitiesArr bson.A, entityName, moduleName string) []chainAttr { - byName := map[string]bson.D{} - for _, item := range entitiesArr { - ed, ok := item.(bson.D) - if !ok { - continue - } - for _, f := range ed { - if f.Key == "Name" { - if n := bsonutil.String(f.Value, "Name"); n != "" { - byName[n] = ed - } - break - } - } - } - - var out []chainAttr - claimed := map[string]bool{} // bare attribute name -> already taken by a nearer entity - seen := map[string]bool{} // cycle guard - - for name := entityName; name != ""; { - ed, ok := byName[name] - if !ok || seen[name] { - break - } - seen[name] = true - - for _, ca := range ownAttrsOf(ed, moduleName, name) { - bare := ca.ref[strings.LastIndex(ca.ref, ".")+1:] - if claimed[bare] { - continue - } - claimed[bare] = true - out = append(out, ca) - } - - // Step to the generalization, if it is in this module. - genRef := generalizationRefOf(ed) - idx := strings.LastIndex(genRef, ".") - if idx < 0 || !strings.EqualFold(genRef[:idx], moduleName) { - break - } - name = genRef[idx+1:] - } - return out -} - -// ownAttrsOf reads one entity document's own attributes. -func ownAttrsOf(entityDoc bson.D, moduleName, entityName string) []chainAttr { - var out []chainAttr - for _, attrItem := range getBsonArray(entityDoc, "Attributes") { - attrDoc, ok := attrItem.(bson.D) - if !ok { - continue - } - attrName := "" - isCalculated := false - for _, f := range attrDoc { - if f.Key == "Name" { - attrName = bsonutil.String(f.Value, "Name") - } - if f.Key == "Value" { - if valueDoc, ok := f.Value.(bson.D); ok { - for _, vf := range valueDoc { - if vf.Key == "$Type" { - if vt, ok := vf.Value.(string); ok && vt == "DomainModels$CalculatedValue" { - isCalculated = true - } - } - } - } - } - } - if attrName != "" { - out = append(out, chainAttr{ - ref: moduleName + "." + entityName + "." + attrName, - calculated: isCalculated, - }) - } - } - return out -} - -// generalizationRefOf returns the qualified name of an entity's generalization -// ("Module.Entity"), or "" when it has none. Newer formats store the field as -// MaybeGeneralization; a NoGeneralization carries no reference. -func generalizationRefOf(entityDoc bson.D) string { - for _, f := range entityDoc { - if f.Key != "Generalization" && f.Key != "MaybeGeneralization" { - continue - } - gd, ok := f.Value.(bson.D) - if !ok { - return "" - } - for _, gf := range gd { - if gf.Key == "Generalization" { - return bsonutil.String(gf.Value, "Generalization") - } - } - return "" - } - return "" -} - -// attrRefBelongsToEntity reports whether a MemberAccess attribute reference -// ("Module.Entity.Attribute") names one of the given entity's OWN attributes, -// rather than one inherited from an ancestor. Only an own reference can be -// validated from a single domain model. -func attrRefBelongsToEntity(attrRef, moduleName, entityName string) bool { - idx := strings.LastIndex(attrRef, ".") - if idx < 0 { - return false - } - return strings.EqualFold(attrRef[:idx], moduleName+"."+entityName) -} diff --git a/sdk/mpr/writer_security_inherited_test.go b/sdk/mpr/writer_security_inherited_test.go deleted file mode 100644 index 57f4a37f8d..0000000000 --- a/sdk/mpr/writer_security_inherited_test.go +++ /dev/null @@ -1,229 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// mendixlabs/mxcli#1047: adding an attribute to a GENERALIZATION left the -// project at CE0066 "Entity access is out of date", and `UPDATE SECURITY` -// reported "All entity access rules are up to date" without changing anything. -// -// ReconcileMemberAccesses computed a specialization's expected member set from -// the entity's OWN attributes, so an inherited one was never missing and never -// added. Both engines had it, in the same shape; this is the legacy half. - -// seedGeneralizationChain inserts a domain model holding Gen (attribute Name) -// and Spec (extends Gen, attribute Extra), each with one access rule. The rules -// list only what the entity declares itself, which is the state a project -// reaches when the generalization gains an attribute afterwards. -func seedGeneralizationChain(t *testing.T, db *sql.DB) model.ID { - t.Helper() - - const ( - unitIDStr = "11111111-1111-1111-1111-111111111111" - containerIDStr = "22222222-2222-2222-2222-222222222222" - genIDStr = "33333333-3333-3333-3333-333333333333" - specIDStr = "55555555-5555-5555-5555-555555555555" - ) - - attr := func(id, name string) bson.D { - return bson.D{ - {Key: "$Type", Value: "DomainModels$StoredValue"}, - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "Name", Value: name}, - } - } - rule := func(id string, members bson.A) bson.D { - return bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(1), "MyModule.Administrator"}}, - {Key: "DefaultMemberAccessRights", Value: "ReadWrite"}, - {Key: "MemberAccesses", Value: members}, - } - } - member := func(id, ref string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "DomainModels$MemberAccess"}, - {Key: "AccessRights", Value: "ReadWrite"}, - {Key: "Attribute", Value: ref}, - } - } - - dmBSON := bson.D{ - {Key: "$Type", Value: "DomainModels$DomainModel"}, - {Key: "$ID", Value: idToBsonBinary(unitIDStr)}, - {Key: "Entities", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$Type", Value: "DomainModels$Entity"}, - {Key: "$ID", Value: idToBsonBinary(genIDStr)}, - {Key: "Name", Value: "Gen"}, - {Key: "Attributes", Value: bson.A{int32(3), attr(attrIDForIndex(0), "Name")}}, - {Key: "AccessRules", Value: bson.A{int32(3), - rule("44444444-4444-4444-4444-444444444444", bson.A{ - int32(3), member("66666666-6666-6666-6666-666666666666", "MyModule.Gen.Name"), - })}}, - }, - bson.D{ - {Key: "$Type", Value: "DomainModels$Entity"}, - {Key: "$ID", Value: idToBsonBinary(specIDStr)}, - {Key: "Name", Value: "Spec"}, - {Key: "Attributes", Value: bson.A{int32(3), attr(attrIDForIndex(1), "Extra")}}, - {Key: "Generalization", Value: bson.D{ - {Key: "$Type", Value: "DomainModels$Generalization"}, - {Key: "$ID", Value: idToBsonBinary("77777777-7777-7777-7777-777777777777")}, - {Key: "Generalization", Value: "MyModule.Gen"}, - }}, - {Key: "AccessRules", Value: bson.A{int32(3), - rule("88888888-8888-8888-8888-888888888888", bson.A{ - int32(3), member("99999999-9999-9999-9999-999999999999", "MyModule.Spec.Extra"), - })}}, - }, - }}, - {Key: "Associations", Value: bson.A{int32(3)}}, - } - - contents, err := bson.Marshal(dmBSON) - if err != nil { - t.Fatalf("marshal domain model: %v", err) - } - if _, err := db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, 'DomainModel', 0, ?, '', ?)`, - uuidToBlob(unitIDStr), uuidToBlob(containerIDStr), - contentHashBase64(contents), contents, - ); err != nil { - t.Fatalf("insert domain model unit: %v", err) - } - return model.ID(unitIDStr) -} - -// memberRefsOfEntity reads one named entity's first rule's attribute references. -func memberRefsOfEntity(t *testing.T, db *sql.DB, unitID model.ID, entityName string) []string { - t.Helper() - var contents []byte - if err := db.QueryRow(`SELECT Contents FROM Unit WHERE UnitID = ?`, - uuidToBlob(string(unitID))).Scan(&contents); err != nil { - t.Fatalf("read unit: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - for _, e := range extractBsonArray(raw["Entities"]) { - ent, ok := e.(map[string]any) - if !ok || extractString(ent["Name"]) != entityName { - continue - } - rules := extractBsonArray(ent["AccessRules"]) - if len(rules) == 0 { - t.Fatalf("entity %s has no access rules", entityName) - } - var refs []string - for _, ma := range extractBsonArray(rules[0].(map[string]any)["MemberAccesses"]) { - if m, ok := ma.(map[string]any); ok { - refs = append(refs, extractString(m["Attribute"])) - } - } - return refs - } - t.Fatalf("entity %s not found", entityName) - return nil -} - -func hasString(vals []string, want string) bool { - for _, v := range vals { - if v == want { - return true - } - } - return false -} - -func TestReconcileMemberAccesses_AddsAnInheritedAttribute(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedGeneralizationChain(t, db) - - // Read the seed back before asking anything of it: a fixture that failed to - // store the chain would make the assertions below meaningless. - if refs := memberRefsOfEntity(t, db, unitID, "Spec"); len(refs) != 1 || refs[0] != "MyModule.Spec.Extra" { - t.Fatalf("fixture did not store the specialization's rule as expected: %v", refs) - } - - modified, err := w.ReconcileMemberAccesses(unitID, "MyModule") - if err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - - refs := memberRefsOfEntity(t, db, unitID, "Spec") - if !hasString(refs, "MyModule.Gen.Name") { - t.Fatalf("the inherited attribute was not added: %v\n"+ - "this is the CE0066 in mendixlabs/mxcli#1047 — and the reference must name Gen, "+ - "the entity that DECLARES it, not Spec", refs) - } - if !hasString(refs, "MyModule.Spec.Extra") { - t.Errorf("the specialization's own attribute was dropped: %v", refs) - } - // The count is what `update security` turns into its message. Reporting 0 - // while adding a member is how "All entity access rules are up to date" came - // to be printed over a project mx check rejects. - if modified == 0 { - t.Error("a member was added but 0 modified was reported") - } -} - -// Running it twice must not add a second copy. The compare pass keys on the -// full reference; keying on the bare attribute name instead would leave the -// inherited entry looking uncovered on every later run. -func TestReconcileMemberAccesses_InheritedAttributeIsNotDuplicated(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedGeneralizationChain(t, db) - - if _, err := w.ReconcileMemberAccesses(unitID, "MyModule"); err != nil { - t.Fatalf("first reconcile: %v", err) - } - first := memberRefsOfEntity(t, db, unitID, "Spec") - - modified, err := w.ReconcileMemberAccesses(unitID, "MyModule") - if err != nil { - t.Fatalf("second reconcile: %v", err) - } - second := memberRefsOfEntity(t, db, unitID, "Spec") - - if len(second) != len(first) { - t.Errorf("a second reconcile changed the member list: %v -> %v", first, second) - } - if modified != 0 { - t.Errorf("a second reconcile reported %d modified; an in-sync rule must be quiet", modified) - } -} - -// The generalization's own rule is already complete, so it must not change — -// otherwise the test above would pass against a fix that rewrites everything. -func TestReconcileMemberAccesses_LeavesTheGeneralizationsRuleAlone(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedGeneralizationChain(t, db) - - before := memberRefsOfEntity(t, db, unitID, "Gen") - if _, err := w.ReconcileMemberAccesses(unitID, "MyModule"); err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - after := memberRefsOfEntity(t, db, unitID, "Gen") - - if len(before) != len(after) || !hasString(after, "MyModule.Gen.Name") { - t.Errorf("the generalization's rule changed: %v -> %v", before, after) - } - for _, r := range after { - if r != "MyModule.Gen.Name" { - t.Errorf("the generalization gained a member it does not declare: %v", after) - } - } -} diff --git a/sdk/mpr/writer_security_reconcile_test.go b/sdk/mpr/writer_security_reconcile_test.go deleted file mode 100644 index e83d9c30bd..0000000000 --- a/sdk/mpr/writer_security_reconcile_test.go +++ /dev/null @@ -1,230 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "testing" - - "github.com/mendixlabs/mxcli/model" - "go.mongodb.org/mongo-driver/bson" -) - -// ReconcileMemberAccesses used to SKIP any rule whose MemberAccesses list held -// only the storage marker: -// -// // If empty (just the storage marker), skip -// if len(maArr) <= 1 { break } -// -// A rule with no member entries on an entity that HAS members is precisely the -// out-of-date state CE0066 names, so the skip left behind the one thing this -// function exists to prevent. It also made the legacy engine disagree with the -// codec engine, which fills such a rule in. -// -// Nothing reached that state until `create or modify entity` started PRESERVING -// access rules instead of deleting them: a rewrite that drops every attribute a -// rule covered empties the list, and the entity's new attributes then never got -// an entry. Measured on the BusinessEvents 3.12.0 marketplace module against -// mxbuild 11.14.0 — `create or modify persistent entity -// BusinessEvents.PublishedBusinessEvent ( EventId: long )` over the real module -// took its Administrator rule from 5 members to 0 on legacy and 1 on modelsdk, -// and mx check reported CE0066 at "Domain model of module 'BusinessEvents'". - -// seedRuleWithEmptyMembers inserts a domain model with one entity, two -// attributes, and one access rule whose MemberAccesses list is bare. -func seedRuleWithEmptyMembers(t *testing.T, db *sql.DB, attrNames ...string) model.ID { - t.Helper() - - const ( - unitIDStr = "11111111-1111-1111-1111-111111111111" - containerIDStr = "22222222-2222-2222-2222-222222222222" - entityIDStr = "33333333-3333-3333-3333-333333333333" - ) - - attrs := bson.A{int32(3)} - for i, name := range attrNames { - attrs = append(attrs, bson.D{ - {Key: "$Type", Value: "DomainModels$StoredValue"}, - {Key: "$ID", Value: idToBsonBinary(attrIDForIndex(i))}, - {Key: "Name", Value: name}, - }) - } - - dmBSON := bson.D{ - {Key: "$Type", Value: "DomainModels$DomainModel"}, - {Key: "$ID", Value: idToBsonBinary(unitIDStr)}, - {Key: "Entities", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$Type", Value: "DomainModels$Entity"}, - {Key: "$ID", Value: idToBsonBinary(entityIDStr)}, - {Key: "Name", Value: "Order"}, - {Key: "Attributes", Value: attrs}, - {Key: "AccessRules", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "$ID", Value: idToBsonBinary("44444444-4444-4444-4444-444444444444")}, - {Key: "AllowedModuleRoles", Value: bson.A{int32(1), "MyModule.Administrator"}}, - {Key: "DefaultMemberAccessRights", Value: "ReadOnly"}, - // The state a preserving rewrite leaves behind: the rule - // survives, every member it named is gone. - {Key: "MemberAccesses", Value: bson.A{int32(3)}}, - }, - }}, - }, - }}, - {Key: "Associations", Value: bson.A{int32(3)}}, - } - - contents, err := bson.Marshal(dmBSON) - if err != nil { - t.Fatalf("marshal domain model: %v", err) - } - if _, err := db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, 'DomainModel', 0, ?, '', ?)`, - uuidToBlob(unitIDStr), uuidToBlob(containerIDStr), - contentHashBase64(contents), contents, - ); err != nil { - t.Fatalf("insert domain model unit: %v", err) - } - return model.ID(unitIDStr) -} - -func attrIDForIndex(i int) string { - return string(rune('a'+i)) + "aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" -} - -// entityAttrNames reads back the seeded entity's attribute names. -func entityAttrNames(t *testing.T, db *sql.DB, unitID model.ID) []string { - t.Helper() - entity := readSeededEntity(t, db, unitID) - var names []string - for _, a := range extractBsonArray(entity["Attributes"]) { - if m, ok := a.(map[string]any); ok { - names = append(names, extractString(m["Name"])) - } - } - return names -} - -// memberAttrRefs reads back the rule's member entries. -func memberAttrRefs(t *testing.T, db *sql.DB, unitID model.ID) []string { - t.Helper() - entity := readSeededEntity(t, db, unitID) - rules := extractBsonArray(entity["AccessRules"]) - if len(rules) == 0 { - t.Fatal("no access rules") - } - rule := rules[0].(map[string]any) - - var refs []string - for _, ma := range extractBsonArray(rule["MemberAccesses"]) { - m, ok := ma.(map[string]any) - if !ok { - continue - } - refs = append(refs, extractString(m["Attribute"])) - } - return refs -} - -// readSeededEntity returns the single entity of the seeded domain model unit. -func readSeededEntity(t *testing.T, db *sql.DB, unitID model.ID) map[string]any { - t.Helper() - var contents []byte - if err := db.QueryRow(`SELECT Contents FROM Unit WHERE UnitID = ?`, - uuidToBlob(string(unitID))).Scan(&contents); err != nil { - t.Fatalf("read unit: %v", err) - } - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - entities := extractBsonArray(raw["Entities"]) - if len(entities) == 0 { - t.Fatal("no entities") - } - entity, ok := entities[0].(map[string]any) - if !ok { - t.Fatalf("entity is %T, want map[string]any", entities[0]) - } - return entity -} - -func TestReconcileMemberAccesses_FillsARuleWhoseMembersAreAllGone(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedRuleWithEmptyMembers(t, db, "EventId") - - // Read the seed back before asking anything of it. Without this, a fixture - // that failed to round-trip is indistinguishable from the reconcile - // declining to act, and the failure message would blame the wrong code. - if attrs := entityAttrNames(t, db, unitID); len(attrs) != 1 || attrs[0] != "EventId" { - t.Fatalf("fixture did not round-trip: entity attributes = %v, want [EventId]", attrs) - } - - count, err := w.ReconcileMemberAccesses(unitID, "MyModule") - if err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - if count == 0 { - t.Fatal("reconcile reported no change — a rule with zero member entries on " + - "an entity that has attributes is CE0066 \"Entity access is out of date\", " + - "and it is exactly the state a preserving entity rewrite leaves behind") - } - - refs := memberAttrRefs(t, db, unitID) - if len(refs) != 1 || refs[0] != "MyModule.Order.EventId" { - t.Errorf("member entries = %v, want [MyModule.Order.EventId]", refs) - } -} - -// CONTROL: an entity with NO members must still come out unchanged. The old -// early break covered this case by accident; removing it must not turn every -// member-less entity into a write. -func TestReconcileMemberAccesses_LeavesAMemberlessEntityAlone(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedRuleWithEmptyMembers(t, db) // no attributes - - count, err := w.ReconcileMemberAccesses(unitID, "MyModule") - if err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - if count != 0 { - t.Errorf("reconcile rewrote %d rule(s) on an entity with no members", count) - } - if refs := memberAttrRefs(t, db, unitID); len(refs) != 0 { - t.Errorf("member entries = %v, want none", refs) - } -} - -// CONTROL: the ordinary case — some members covered, one not — must keep working. -// This is the path the early break never reached, so a fix that broke it would -// otherwise go unnoticed by the test above. -func TestReconcileMemberAccesses_StillTopsUpAPartiallyCoveredRule(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID := seedRuleWithEmptyMembers(t, db, "Ref", "Amount") - - // Give the rule an entry for Ref only. - if err := w.AddEntityAccessRule(unitID, "Order", - []string{"MyModule.Administrator"}, false, false, "ReadOnly", "", - []EntityMemberAccess{{AttributeRef: "MyModule.Order.Ref", AccessRights: "ReadOnly"}}, - ); err != nil { - t.Fatalf("AddEntityAccessRule: %v", err) - } - - if _, err := w.ReconcileMemberAccesses(unitID, "MyModule"); err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - - got := map[string]bool{} - for _, r := range memberAttrRefs(t, db, unitID) { - got[r] = true - } - for _, want := range []string{"MyModule.Order.Ref", "MyModule.Order.Amount"} { - if !got[want] { - t.Errorf("missing member entry %s (got %v)", want, got) - } - } -} diff --git a/sdk/mpr/writer_security_test.go b/sdk/mpr/writer_security_test.go deleted file mode 100644 index bebb81c97e..0000000000 --- a/sdk/mpr/writer_security_test.go +++ /dev/null @@ -1,384 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "io" - "log" - "path/filepath" - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - "go.mongodb.org/mongo-driver/bson" - _ "modernc.org/sqlite" -) - -// ============================================================================= -// removeRolesFromAccessRule — unit tests for multi-role handling -// ============================================================================= - -func makeAccessRule(roleNames ...string) bson.D { - roles := bson.A{int32(1)} // Mendix array marker - for _, r := range roleNames { - roles = append(roles, r) - } - return bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowedModuleRoles", Value: roles}, - {Key: "AllowCreate", Value: true}, - } -} - -func getRoleNames(rule bson.D) []string { - for _, f := range rule { - if f.Key != "AllowedModuleRoles" { - continue - } - arr, ok := f.Value.(bson.A) - if !ok { - return nil - } - var names []string - for _, item := range arr { - if s, ok := item.(string); ok { - names = append(names, s) - } - } - return names - } - return nil -} - -func TestRemoveRolesFromAccessRule_SingleRole_ExactMatch(t *testing.T) { - rule := makeAccessRule("Mod.RoleA") - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.RoleA": true}) - if keep { - t.Error("expected rule to be deleted (no roles remaining)") - } - if !modified { - t.Error("expected modified=true") - } -} - -func TestRemoveRolesFromAccessRule_MultiRole_RemoveOne(t *testing.T) { - rule := makeAccessRule("Mod.User", "Mod.Admin") - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.User": true}) - if !keep { - t.Error("expected rule to be kept (Admin still present)") - } - if !modified { - t.Error("expected modified=true") - } - names := getRoleNames(rule) - if len(names) != 1 || names[0] != "Mod.Admin" { - t.Errorf("expected [Mod.Admin], got %v", names) - } -} - -func TestRemoveRolesFromAccessRule_MultiRole_RemoveAll(t *testing.T) { - rule := makeAccessRule("Mod.User", "Mod.Admin") - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.User": true, "Mod.Admin": true}) - if keep { - t.Error("expected rule to be deleted (no roles remaining)") - } - if !modified { - t.Error("expected modified=true") - } -} - -func TestRemoveRolesFromAccessRule_NoMatch(t *testing.T) { - rule := makeAccessRule("Mod.User", "Mod.Admin") - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.Other": true}) - if !keep { - t.Error("expected rule to be kept") - } - if modified { - t.Error("expected modified=false") - } - names := getRoleNames(rule) - if len(names) != 2 { - t.Errorf("expected 2 roles unchanged, got %v", names) - } -} - -func TestRemoveRolesFromAccessRule_NoAllowedModuleRoles(t *testing.T) { - rule := bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowCreate", Value: true}, - } - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.User": true}) - if !keep { - t.Error("expected rule to be kept (no AllowedModuleRoles field)") - } - if modified { - t.Error("expected modified=false") - } -} - -func TestRemoveRolesFromAccessRule_ThreeRoles_RemoveMiddle(t *testing.T) { - rule := makeAccessRule("Mod.A", "Mod.B", "Mod.C") - keep, modified := removeRolesFromAccessRule(rule, map[string]bool{"Mod.B": true}) - if !keep { - t.Error("expected rule to be kept") - } - if !modified { - t.Error("expected modified=true") - } - names := getRoleNames(rule) - if len(names) != 2 || names[0] != "Mod.A" || names[1] != "Mod.C" { - t.Errorf("expected [Mod.A, Mod.C], got %v", names) - } -} - -// ============================================================================= -// mergeAccessRule — malformed BSON must not panic -// ============================================================================= - -// Not parallel-safe: redirects global log output. -func TestMergeAccessRule_UnexpectedTypes_NoPanic(t *testing.T) { - origOutput := log.Writer() - log.SetOutput(io.Discard) - defer log.SetOutput(origOutput) - - existing := bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowCreate", Value: 42}, // wrong type: int instead of bool - {Key: "AllowDelete", Value: "not-a-bool"}, // wrong type: string instead of bool - {Key: "DefaultMemberAccessRights", Value: 99}, - } - newRule := bson.D{ - {Key: "$Type", Value: "DomainModels$AccessRule"}, - {Key: "AllowCreate", Value: true}, - {Key: "AllowDelete", Value: false}, - {Key: "DefaultMemberAccessRights", Value: "ReadWrite"}, - } - - // Must not panic - result := mergeAccessRule(existing, newRule) - if result == nil { - t.Error("expected non-nil result") - } -} - -// ============================================================================= -// AddEntityAccessRule — XPath constraint preserved and rights readable (#431) -// ============================================================================= - -// newTestWriterSecurity creates an in-memory SQLite writer for security tests. -func newTestWriterSecurity(t *testing.T) (*Writer, *sql.DB) { - t.Helper() - dbPath := filepath.Join(t.TempDir(), "sec.mpr") - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - if _, err := db.Exec(` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT, - Contents BLOB - ) - `); err != nil { - t.Fatalf("create Unit table: %v", err) - } - - reader := &Reader{db: db, version: MPRVersionV1} - return &Writer{reader: reader}, db -} - -// seedDomainModelUnit inserts a minimal domain model BSON with one entity+attribute. -// Returns the unit ID and the domain model BSON (as bson.D). -func seedDomainModelUnit(t *testing.T, w *Writer, db *sql.DB) (unitID model.ID, entityID model.ID) { - t.Helper() - - unitIDStr := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - containerIDStr := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" - entityIDStr := "cccccccc-cccc-cccc-cccc-cccccccccccc" - - dmBSON := bson.D{ - {Key: "$Type", Value: "DomainModels$DomainModel"}, - {Key: "$ID", Value: idToBsonBinary(unitIDStr)}, - {Key: "Entities", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$Type", Value: "DomainModels$Entity"}, - {Key: "$ID", Value: idToBsonBinary(entityIDStr)}, - {Key: "Name", Value: "Order"}, - {Key: "Attributes", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$Type", Value: "DomainModels$StoredValue"}, - {Key: "$ID", Value: idToBsonBinary("dddddddd-dddd-dddd-dddd-dddddddddddd")}, - {Key: "Name", Value: "Status"}, - }, - }}, - {Key: "AccessRules", Value: bson.A{int32(3)}}, - }, - }}, - {Key: "Associations", Value: bson.A{int32(3)}}, - } - - contents, err := bson.Marshal(dmBSON) - if err != nil { - t.Fatalf("marshal domain model: %v", err) - } - - if _, err := db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, 'DomainModel', 0, ?, '', ?)`, - uuidToBlob(unitIDStr), - uuidToBlob(containerIDStr), - contentHashBase64(contents), - contents, - ); err != nil { - t.Fatalf("insert domain model unit: %v", err) - } - - return model.ID(unitIDStr), model.ID(entityIDStr) -} - -// TestAddEntityAccessRule_XPathConstraint_FullRoundtrip verifies the complete -// flow for issue #431: AddEntityAccessRule + ReconcileMemberAccesses + parseDomainModel. -// Ensures that XPath and read/write rights survive the full write-then-read cycle. -func TestAddEntityAccessRule_XPathConstraint_FullRoundtrip(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID, _ := seedDomainModelUnit(t, w, db) - containerIDStr := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" - - err := w.AddEntityAccessRule( - unitID, "Order", - []string{"MyModule.User"}, - false, false, - "ReadWrite", - "[Status = 'Open']", - []EntityMemberAccess{ - {AttributeRef: "MyModule.Order.Status", AccessRights: "ReadWrite"}, - }, - ) - if err != nil { - t.Fatalf("AddEntityAccessRule: %v", err) - } - - // ReconcileMemberAccesses is called by execGrantEntityAccess right after - count, err := w.ReconcileMemberAccesses(unitID, "MyModule") - if err != nil { - t.Fatalf("ReconcileMemberAccesses: %v", err) - } - _ = count - - // Read back via parseDomainModel (same path as GetDomainModel) - dm, err := w.reader.GetDomainModel(model.ID(containerIDStr)) - if err != nil { - t.Fatalf("GetDomainModel: %v", err) - } - - if len(dm.Entities) == 0 { - t.Fatal("no entities found in domain model") - } - - var order *domainmodel.Entity - for _, e := range dm.Entities { - if e.Name == "Order" { - order = e - break - } - } - if order == nil { - t.Fatal("Order entity not found") - } - - if len(order.AccessRules) == 0 { - t.Fatal("AccessRules empty after AddEntityAccessRule + ReconcileMemberAccesses (issue #431)") - } - - rule := order.AccessRules[0] - if rule.XPathConstraint != "[Status = 'Open']" { - t.Errorf("XPathConstraint = %q, want %q", rule.XPathConstraint, "[Status = 'Open']") - } - if rule.DefaultMemberAccessRights != domainmodel.MemberAccessRightsReadWrite { - t.Errorf("DefaultMemberAccessRights = %q, want ReadWrite", rule.DefaultMemberAccessRights) - } - if len(rule.ModuleRoleNames) == 0 || rule.ModuleRoleNames[0] != "MyModule.User" { - t.Errorf("ModuleRoleNames = %v, want [MyModule.User]", rule.ModuleRoleNames) - } - if len(rule.MemberAccesses) == 0 { - t.Error("MemberAccesses empty after reconciliation") - } -} - -// TestAddEntityAccessRule_XPathConstraint_PreservesRights verifies that granting -// entity access with an XPath WHERE clause correctly persists both the XPath -// and the read/write rights (issue #431: rights were silently dropped). -func TestAddEntityAccessRule_XPathConstraint_PreservesRights(t *testing.T) { - w, db := newTestWriterSecurity(t) - unitID, _ := seedDomainModelUnit(t, w, db) - - err := w.AddEntityAccessRule( - unitID, "Order", - []string{"MyModule.User"}, - false, false, - "ReadWrite", - "[Status = 'Open']", - []EntityMemberAccess{ - {AttributeRef: "MyModule.Order.Status", AccessRights: "ReadWrite"}, - }, - ) - if err != nil { - t.Fatalf("AddEntityAccessRule: %v", err) - } - - // Read back via parseDomainModel - row := db.QueryRow(`SELECT Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(string(unitID))) - var contents []byte - if err := row.Scan(&contents); err != nil { - t.Fatalf("read unit contents: %v", err) - } - - var raw map[string]any - if err := bson.Unmarshal(contents, &raw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - entities := extractBsonArray(raw["Entities"]) - if len(entities) == 0 { - t.Fatal("no entities found after AddEntityAccessRule") - } - - entityMap, ok := entities[0].(map[string]any) - if !ok { - t.Fatalf("entity is %T, want map[string]any", entities[0]) - } - - rules := extractBsonArray(entityMap["AccessRules"]) - if len(rules) == 0 { - t.Fatal("AccessRules is empty after grant — rights not persisted (issue #431)") - } - - ruleMap, ok := rules[0].(map[string]any) - if !ok { - t.Fatalf("rule is %T, want map[string]any", rules[0]) - } - - xpath := extractString(ruleMap["XPathConstraint"]) - if xpath != "[Status = 'Open']" { - t.Errorf("XPathConstraint = %q, want %q", xpath, "[Status = 'Open']") - } - - defaultAccess := extractString(ruleMap["DefaultMemberAccessRights"]) - if defaultAccess != "ReadWrite" { - t.Errorf("DefaultMemberAccessRights = %q, want %q", defaultAccess, "ReadWrite") - } - - memberAccesses := extractBsonArray(ruleMap["MemberAccesses"]) - if len(memberAccesses) == 0 { - t.Error("MemberAccesses is empty after grant") - } -} diff --git a/sdk/mpr/writer_settings.go b/sdk/mpr/writer_settings.go deleted file mode 100644 index 03e3c1b123..0000000000 --- a/sdk/mpr/writer_settings.go +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/mdl/settingsoverlay" - "github.com/mendixlabs/mxcli/model" - - "go.mongodb.org/mongo-driver/bson" -) - -// safeInt64 converts an int to int64. -func safeInt64(v int) int64 { - return int64(v) -} - -// UpdateProjectSettings updates the project settings document. -// The project settings document always exists, so this only needs update, not create/delete. -func (w *Writer) UpdateProjectSettings(ps *model.ProjectSettings) error { - contents, err := w.serializeProjectSettings(ps) - if err != nil { - return fmt.Errorf("failed to serialize project settings: %w", err) - } - - return w.updateUnit(string(ps.ID), contents) -} - -// serializeProjectSettings converts ProjectSettings to BSON bytes. -// It uses the RawParts for round-trip fidelity, updating only the parts -// that have been parsed and modified. -func (w *Writer) serializeProjectSettings(ps *model.ProjectSettings) ([]byte, error) { - // Without the raw parts there is nothing to overlay onto, and writing the - // document anyway would replace every settings part with an empty array — the - // whole Project Settings dialog silently reset. Refuse instead. - if len(ps.RawParts) == 0 { - return nil, fmt.Errorf("no raw settings parts captured on read; " + - "refusing to write a settings document that would drop every part") - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ps.ID))}, - {Key: "$Type", Value: "Settings$ProjectSettings"}, - } - - // Rebuild the Settings array from RawParts, overwriting modified parts - settings := bson.A{int32(2)} // versioned array prefix - - for _, rawPart := range ps.RawParts { - typeName, _ := rawPart["$Type"].(string) - switch typeName { - case "Settings$ModelSettings": - if ps.Model != nil { - settings = append(settings, serializeModelSettings(ps.Model, rawPart)) - } else { - settings = append(settings, rawPart) - } - case "Settings$ConfigurationSettings": - if ps.Configuration != nil { - settings = append(settings, serializeConfigurationSettings(ps.Configuration, rawPart)) - } else { - settings = append(settings, rawPart) - } - case "Settings$LanguageSettings": - if ps.Language != nil { - settings = append(settings, serializeLanguageSettings(ps.Language, rawPart)) - } else { - settings = append(settings, rawPart) - } - case "Settings$WorkflowsProjectSettingsPart": - if ps.Workflows != nil { - settings = append(settings, serializeWorkflowsSettings(ps.Workflows, rawPart)) - } else { - settings = append(settings, rawPart) - } - default: - // Preserve raw part as-is (WebUI, Integration, Certificate, JarDeployment, Distribution, Convention) - settings = append(settings, rawPart) - } - } - - doc = append(doc, bson.E{Key: "Settings", Value: settings}) - // The Settings array carries parsed parts as Go maps (RawParts); marshalling - // a map randomizes key order, so hoist "$ID"/"$Type" first per 11.12 (#nightly). - return marshalUnitIDFirst(doc) -} - -// serializeModelSettings overlays the modified model settings onto the raw BSON -// part. The overlay is shared with the codec engine so the two write paths cannot -// drift (see mdl/settingsoverlay), and is presence-gated so a write never -// introduces a property this Mendix version does not store. -func serializeModelSettings(ms *model.ModelSettings, raw map[string]any) map[string]any { - return settingsoverlay.SetModelSettings(ms, raw) -} - -// serializeConfigurationSettings overlays the modified configuration settings onto -// the raw BSON part. The overlay is shared with the codec engine so the two write -// paths cannot drift (see mdl/settingsoverlay and mendixlabs/mxcli#801). -func serializeConfigurationSettings(cs *model.ConfigurationSettings, raw map[string]any) map[string]any { - return settingsoverlay.Configurations(cs, raw) -} - -// serializeLanguageSettings updates the raw BSON map with modified language settings. -func serializeLanguageSettings(ls *model.LanguageSettings, raw map[string]any) map[string]any { - raw["DefaultLanguageCode"] = ls.DefaultLanguageCode - return raw -} - -// serializeWorkflowsSettings updates the raw BSON map with modified workflow settings. -func serializeWorkflowsSettings(ws *model.WorkflowsSettings, raw map[string]any) map[string]any { - raw["UserEntity"] = ws.UserEntity - raw["DefaultTaskParallelism"] = safeInt64(ws.DefaultTaskParallelism) - raw["WorkflowEngineParallelism"] = safeInt64(ws.WorkflowEngineParallelism) - return raw -} diff --git a/sdk/mpr/writer_units.go b/sdk/mpr/writer_units.go deleted file mode 100644 index c89e66c66a..0000000000 --- a/sdk/mpr/writer_units.go +++ /dev/null @@ -1,293 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/modelsdk/canon" - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// isContentsHashSchemaError returns true when the error looks like SQLite complaining -// about the absence of the ContentsHash column (i.e. an old MPR v1 schema from pre-Mx -// versions that predate ContentsHash). Anything else — a disk-full error, a missing -// UnitID, a rolled-back transaction — must propagate so writes don't silently succeed -// without updating Contents. -func isContentsHashSchemaError(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return strings.Contains(msg, "ContentsHash") -} - -// updateTransactionID updates the _Transaction table with a new UUID. -// Studio Pro uses this to detect external changes during F4 sync. -// Only applies to MPR v2 projects (Mendix >= 10.18). -func (w *Writer) updateTransactionID() error { - if w.reader.version != MPRVersionV2 { - return nil - } - newID := generateUUID() - _, err := w.reader.db.Exec(`UPDATE _Transaction SET LastTransactionID = ?`, newID) - return err -} - -// placeholderBinaryPrefix is the GUID-swapped byte pattern for placeholder IDs generated -// by sdk/widgets/augment.go placeholderID(). These are "aa000000000000000000000000XXXXXX" -// hex strings which, after hex decode + GUID byte-swap, produce 16-byte blobs whose first -// 13 bytes are \x00\x00\x00\xaa followed by 9 zero bytes. -var placeholderBinaryPrefix = []byte{0x00, 0x00, 0x00, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} - -// placeholderStringPrefix is the ASCII prefix of a placeholder ID that leaked as a string. -var placeholderStringBytes = []byte("aa000000000000000000000000") - -// validateNoPlaceholderIDs scans raw BSON bytes for leaked placeholder IDs. -// Returns an error if any placeholder pattern is found. -func validateNoPlaceholderIDs(unitID string, contents []byte) error { - if bytes.Contains(contents, placeholderBinaryPrefix) { - return fmt.Errorf("placeholder ID leak detected in unit %s: binary aa000000-prefix ID found in BSON contents", unitID) - } - if bytes.Contains(contents, placeholderStringBytes) { - return fmt.Errorf("placeholder ID leak detected in unit %s: string aa000000-prefix ID found in BSON contents", unitID) - } - return nil -} - -func contentHashBase64(contents []byte) string { - hash := sha256.Sum256(contents) - return base64.StdEncoding.EncodeToString(hash[:]) -} - -func (w *Writer) insertUnit(unitID, containerID, containmentName, unitType string, contents []byte) error { - if err := validateNoPlaceholderIDs(unitID, contents); err != nil { - return err - } - // Two elements sharing an $ID make the whole project unopenable. Checked on - // both engines from the same function: which engine ran is an --engine flag, - // not something a user should be able to see in their diff — and least of - // all in whether a corrupt write was caught. (ako/mxcli-captrack #2) - if err := canon.DuplicateElementIDError(unitID, contents); err != nil { - return err - } - - // Convert UUID strings to 16-byte blobs for database - unitIDBlob := uuidToBlob(unitID) - containerIDBlob := uuidToBlob(containerID) - - if w.reader.version == MPRVersionV2 { - // Get swapped UUID for file path - swappedUUID := blobToUUIDSwapped(unitIDBlob) - - // Create directory structure: mprcontents/XX/YY/ - dir := filepath.Join(w.reader.contentsDir, swappedUUID[0:2], swappedUUID[2:4]) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } - - // Write content file - filePath := filepath.Join(dir, swappedUUID+".mxunit") - if err := os.WriteFile(filePath, contents, 0644); err != nil { - return fmt.Errorf("failed to write unit file: %w", err) - } - - contentsHash := contentHashBase64(contents) - - // Insert reference to database - _, err := w.reader.db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts) - VALUES (?, ?, ?, 0, ?, '') - `, unitIDBlob, containerIDBlob, containmentName, contentsHash) - if err != nil { - // Clean up the file we just wrote — otherwise it becomes an orphan - os.Remove(filePath) - return err - } - w.reader.InvalidateCache() - if err := w.updateTransactionID(); err != nil { - return fmt.Errorf("failed to update transaction ID: %w", err) - } - return nil - } - - // MPR v1: Store directly in database - contentsHash := contentHashBase64(contents) - - // Try new schema first (without Type column - Mendix 11.6.2+) - _, err := w.reader.db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, ?, 0, ?, '', ?) - `, unitIDBlob, containerIDBlob, containmentName, contentsHash, contents) - if err != nil { - // Try old schema with Type column - _, err = w.reader.db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Type, Contents) - VALUES (?, ?, ?, ?, ?) - `, unitIDBlob, containerIDBlob, containmentName, unitType, contents) - } - if err == nil { - w.reader.InvalidateCache() - } - return err -} - -func (w *Writer) updateUnit(unitID string, contents []byte) error { - if err := validateNoPlaceholderIDs(unitID, contents); err != nil { - return err - } - // Two elements sharing an $ID make the whole project unopenable. Checked on - // both engines from the same function: which engine ran is an --engine flag, - // not something a user should be able to see in their diff — and least of - // all in whether a corrupt write was caught. (ako/mxcli-captrack #2) - if err := canon.DuplicateElementIDError(unitID, contents); err != nil { - return err - } - - // No-op elision and identity preservation (ADR-0008 decision 1), sharing the - // modelsdk engine's policy rather than reimplementing it. The two engines - // must agree here: which one ran is an --engine flag, not something a user - // should be able to see in their diff. - w.writesOffered++ - if stored, err := w.reader.GetRawUnitBytes(model.ID(unitID)); err == nil { - var unchanged bool - if contents, unchanged = canon.Reconcile(contents, stored); unchanged { - return nil - } - } - w.writesLanded++ - - // Convert UUID string to 16-byte blob - unitIDBlob := uuidToBlob(unitID) - - if w.reader.version == MPRVersionV2 { - // Get swapped UUID for file path - swappedUUID := blobToUUIDSwapped(unitIDBlob) - - // Build file path: mprcontents/XX/YY/UUID.mxunit - filePath := filepath.Join( - w.reader.contentsDir, - swappedUUID[0:2], - swappedUUID[2:4], - swappedUUID+".mxunit", - ) - - // Write updated content - if err := os.WriteFile(filePath, contents, 0644); err != nil { - return fmt.Errorf("failed to write unit file: %w", err) - } - - contentsHash := contentHashBase64(contents) - _, err := w.reader.db.Exec(` - UPDATE Unit SET ContentsHash = ? WHERE UnitID = ? - `, contentsHash, unitIDBlob) - if err == nil { - w.reader.InvalidateCache() - if txErr := w.updateTransactionID(); txErr != nil { - return fmt.Errorf("failed to update transaction ID: %w", txErr) - } - } - return err - } - - // MPR v1: Update in database - contentsHash := contentHashBase64(contents) - _, err := w.reader.db.Exec(` - UPDATE Unit SET Contents = ?, ContentsHash = ? WHERE UnitID = ? - `, contents, contentsHash, unitIDBlob) - if err != nil && isContentsHashSchemaError(err) { - // Older v1 schemas do not have ContentsHash; retry without it. - // Any other error (disk full, invalid UnitID, rolled-back tx) propagates. - _, err = w.reader.db.Exec(` - UPDATE Unit SET Contents = ? WHERE UnitID = ? - `, contents, unitIDBlob) - } - return err -} - -// UpdateRawUnit saves raw BSON bytes for a unit, bypassing deserialization. -// Used by ALTER PAGE to modify the BSON widget tree directly. -// AddRawUnit inserts a unit verbatim: same contents, same containment name, no -// re-encoding. It is the primitive a module transplant is built from. -// -// Copying a unit wholesale is safe because element `$ID` pointers do not cross -// unit boundaries — measured at 0 of 9,910 in a real project (PROPOSAL -// marketplace_module_upgrade §4) — and cross-unit references are qualified-name -// strings. Rewriting the contents, by contrast, would risk exactly the -// intra-unit pointer inconsistency ADR-0008 forbids. -// -// The caller owns uniqueness of unitID. Inserting an ID the project already -// holds is a caller error, not something this can repair. -func (w *Writer) AddRawUnit(unitID, containerID, containmentName, unitType string, contents []byte) error { - return w.insertUnit(unitID, containerID, containmentName, unitType, contents) -} - -func (w *Writer) UpdateRawUnit(unitID string, contents []byte) error { - return w.updateUnit(unitID, contents) -} - -func (w *Writer) deleteUnit(unitID string) error { - // Convert UUID string to 16-byte blob - unitIDBlob := uuidToBlob(unitID) - if unitIDBlob == nil { - return fmt.Errorf("invalid unit ID: %s", unitID) - } - - if w.reader.version == MPRVersionV2 { - // Get swapped UUID for file path - swappedUUID := blobToUUIDSwapped(unitIDBlob) - - // Delete external file - subDir1 := swappedUUID[0:2] - subDir2 := swappedUUID[2:4] - filePath := filepath.Join(w.reader.contentsDir, subDir1, subDir2, swappedUUID+".mxunit") - os.Remove(filePath) // Ignore error if file doesn't exist - - // Clean up empty parent directories (YY/, then XX/) - dir2 := filepath.Join(w.reader.contentsDir, subDir1, subDir2) - os.Remove(dir2) // Only succeeds if empty - dir1 := filepath.Join(w.reader.contentsDir, subDir1) - os.Remove(dir1) // Only succeeds if empty - } - - result, err := w.reader.db.Exec(`DELETE FROM Unit WHERE UnitID = ?`, unitIDBlob) - if err != nil { - return err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get rows affected: %w", err) - } - - if rowsAffected == 0 { - return fmt.Errorf("unit not found in database: %s", unitID) - } - - w.reader.InvalidateCache() - if err := w.updateTransactionID(); err != nil { - return fmt.Errorf("failed to update transaction ID after deleting unit: %w", err) - } - return nil -} - -func (w *Writer) updateDomainModel(dm *domainmodel.DomainModel) error { - contents, err := w.serializeDomainModel(dm) - if err != nil { - return fmt.Errorf("failed to serialize domain model: %w", err) - } - - return w.updateUnit(string(dm.ID), contents) -} - -// UpdateDomainModel serializes and saves a domain model back to the MPR file. -func (w *Writer) UpdateDomainModel(dm *domainmodel.DomainModel) error { - return w.updateDomainModel(dm) -} diff --git a/sdk/mpr/writer_units_test.go b/sdk/mpr/writer_units_test.go deleted file mode 100644 index 0094e08893..0000000000 --- a/sdk/mpr/writer_units_test.go +++ /dev/null @@ -1,154 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "database/sql" - "path/filepath" - "testing" - - _ "modernc.org/sqlite" -) - -func newTestWriterV1(t *testing.T, unitSchema string) (*Writer, *sql.DB) { - t.Helper() - - dbPath := filepath.Join(t.TempDir(), "test.mpr") - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("failed to open sqlite database: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - if _, err := db.Exec(unitSchema); err != nil { - t.Fatalf("failed to create Unit table: %v", err) - } - - reader := &Reader{ - db: db, - version: MPRVersionV1, - } - return &Writer{reader: reader}, db -} - -func TestInsertUnitV1_PopulatesContentsHash(t *testing.T) { - writer, db := newTestWriterV1(t, ` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT, - Contents BLOB - ) - `) - - unitID := "11111111-1111-1111-1111-111111111111" - containerID := "22222222-2222-2222-2222-222222222222" - contents := []byte("new microflow bytes") - if err := writer.insertUnit(unitID, containerID, "Documents", "Microflows$Microflow", contents); err != nil { - t.Fatalf("insertUnit failed: %v", err) - } - - var gotHash string - var gotContents []byte - err := db.QueryRow(`SELECT ContentsHash, Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID)).Scan(&gotHash, &gotContents) - if err != nil { - t.Fatalf("failed to read inserted row: %v", err) - } - - if gotHash == "" { - t.Fatal("insertUnit wrote empty ContentsHash") - } - if want := contentHashBase64(contents); gotHash != want { - t.Fatalf("ContentsHash = %q, want %q", gotHash, want) - } - if string(gotContents) != string(contents) { - t.Fatalf("Contents = %q, want %q", string(gotContents), string(contents)) - } -} - -func TestUpdateUnitV1_UpdatesContentsHash(t *testing.T) { - writer, db := newTestWriterV1(t, ` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - TreeConflict LONG, - ContentsHash TEXT, - ContentsConflicts TEXT, - Contents BLOB - ) - `) - - unitID := "33333333-3333-3333-3333-333333333333" - containerID := "44444444-4444-4444-4444-444444444444" - oldContents := []byte("old bytes") - newContents := []byte("updated bytes") - if _, err := db.Exec(` - INSERT INTO Unit (UnitID, ContainerID, ContainmentName, TreeConflict, ContentsHash, ContentsConflicts, Contents) - VALUES (?, ?, 'Documents', 0, ?, '', ?) - `, uuidToBlob(unitID), uuidToBlob(containerID), contentHashBase64(oldContents), oldContents); err != nil { - t.Fatalf("failed to seed row: %v", err) - } - - if err := writer.updateUnit(unitID, newContents); err != nil { - t.Fatalf("updateUnit failed: %v", err) - } - - var gotHash string - var gotContents []byte - err := db.QueryRow(`SELECT ContentsHash, Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID)).Scan(&gotHash, &gotContents) - if err != nil { - t.Fatalf("failed to read updated row: %v", err) - } - - if gotHash == "" { - t.Fatal("updateUnit wrote empty ContentsHash") - } - if want := contentHashBase64(newContents); gotHash != want { - t.Fatalf("ContentsHash = %q, want %q", gotHash, want) - } - if string(gotContents) != string(newContents) { - t.Fatalf("Contents = %q, want %q", string(gotContents), string(newContents)) - } -} - -func TestUnitV1_OldSchemaWithoutContentsHashStillWorks(t *testing.T) { - writer, db := newTestWriterV1(t, ` - CREATE TABLE Unit ( - UnitID BLOB PRIMARY KEY NOT NULL, - ContainerID BLOB, - ContainmentName TEXT, - Type TEXT, - Contents BLOB - ) - `) - - unitID := "55555555-5555-5555-5555-555555555555" - containerID := "66666666-6666-6666-6666-666666666666" - initialContents := []byte("initial bytes") - updatedContents := []byte("updated old schema bytes") - - if err := writer.insertUnit(unitID, containerID, "Documents", "Microflows$Microflow", initialContents); err != nil { - t.Fatalf("insertUnit failed on old schema: %v", err) - } - if err := writer.updateUnit(unitID, updatedContents); err != nil { - t.Fatalf("updateUnit failed on old schema: %v", err) - } - - var gotType string - var gotContents []byte - err := db.QueryRow(`SELECT Type, Contents FROM Unit WHERE UnitID = ?`, uuidToBlob(unitID)).Scan(&gotType, &gotContents) - if err != nil { - t.Fatalf("failed to read old-schema row: %v", err) - } - - if gotType != "Microflows$Microflow" { - t.Fatalf("Type = %q, want %q", gotType, "Microflows$Microflow") - } - if string(gotContents) != string(updatedContents) { - t.Fatalf("Contents = %q, want %q", string(gotContents), string(updatedContents)) - } -} diff --git a/sdk/mpr/writer_validationrule_test.go b/sdk/mpr/writer_validationrule_test.go deleted file mode 100644 index 06d6fbd69e..0000000000 --- a/sdk/mpr/writer_validationrule_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - "testing" - - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// TestSerializeRuleInfo_RefusesUnreproducibleTypes is the data-loss guard. -// -// serializeRuleInfo used to fall back to RequiredRuleInfo for any type it did -// not recognise. Because ALTER ENTITY round-trips an entity through the writer, -// a stored RegEx rule was read as "RegEx" and written back as Required — the -// pattern reference gone, the field merely mandatory — and mxbuild reported -// nothing, because a Required rule is perfectly valid. -// MaxLength and EqualsTo stay refused: the model carries no payload type for -// either, so a rewrite would lose them. RegEx and Range are now writable, but -// only WITH their payload — see TestSerializeRuleInfo_RefusesPayloadlessRules. -func TestSerializeRuleInfo_RefusesUnreproducibleTypes(t *testing.T) { - for _, ruleType := range []string{"MaxLength", "EqualsTo"} { - t.Run(ruleType, func(t *testing.T) { - vr := &domainmodel.ValidationRule{Type: ruleType} - if got := serializeRuleInfo(vr); got != nil { - t.Errorf("serializeRuleInfo(%q) = %v, want nil (refusal) — a fallback silently downgrades the rule", ruleType, got) - } - if reproducibleRule(vr) { - t.Errorf("reproducibleRule(%q) = true", ruleType) - } - }) - } -} - -// TestSerializeRuleInfo_RefusesPayloadlessRules: a rule TYPE is not a rule. A -// bare RegExRuleInfo with no reference, or a Range with no bounds, is a document -// Mendix accepts that constrains nothing — the same silent downgrade wearing the -// right type name. -func TestSerializeRuleInfo_RefusesPayloadlessRules(t *testing.T) { - for _, vr := range []*domainmodel.ValidationRule{ - {Type: "RegEx"}, - {Type: "RegEx", Rule: &domainmodel.RegexValidationRuleInfo{}}, - {Type: "Range"}, - {Type: "Range", Rule: &domainmodel.RangeValidationRuleInfo{}}, - } { - if got := serializeRuleInfo(vr); got != nil { - t.Errorf("%s with rule %#v serialized to %v, want nil", vr.Type, vr.Rule, got) - } - } -} - -func TestSerializeRuleInfo_ReproducibleTypes(t *testing.T) { - for ruleType, wantType := range map[string]string{ - "Required": "DomainModels$RequiredRuleInfo", - "Unique": "DomainModels$UniqueRuleInfo", - // An empty type is what the attribute-constraint path produces for - // `not null`; it must keep working. - "": "DomainModels$RequiredRuleInfo", - } { - doc := serializeRuleInfo(&domainmodel.ValidationRule{Type: ruleType}) - if doc == nil { - t.Fatalf("serializeRuleInfo(%q) = nil, want a document", ruleType) - } - if doc[0].Key != "$ID" { - t.Errorf("%q: first key = %q, want $ID (Mendix rejects any other order)", ruleType, doc[0].Key) - } - if doc[1].Value != wantType { - t.Errorf("%q: $Type = %v, want %s", ruleType, doc[1].Value, wantType) - } - } -} - -// TestSerializeRuleInfo_RegExUsesStorageName pins the key both engines must -// write. The SDK name is "RegularExpression"; Studio Pro stores -// "RegExIdentifier", and writing the SDK name makes mxbuild report CE0135 -// "No regular expression specified" (measured on 11.13.0). -func TestSerializeRuleInfo_RegExUsesStorageName(t *testing.T) { - doc := serializeRuleInfo(&domainmodel.ValidationRule{ - Type: "RegEx", - Rule: &domainmodel.RegexValidationRuleInfo{RegularExpressionQualifiedName: "Val.EmailAddress"}, - }) - if doc == nil { - t.Fatal("a RegEx rule with a reference must serialize") - } - if doc[1].Value != "DomainModels$RegExRuleInfo" { - t.Errorf("$Type = %v", doc[1].Value) - } - if doc[2].Key != "RegExIdentifier" { - t.Errorf("reference key = %q, want RegExIdentifier — the SDK name yields CE0135", doc[2].Key) - } - if doc[2].Value != "Val.EmailAddress" { - t.Errorf("reference = %v", doc[2].Value) - } -} - -func TestSerializeRuleInfo_RangeKinds(t *testing.T) { - lo, hi := "1", "100" - tests := []struct { - name string - info *domainmodel.RangeValidationRuleInfo - want string - }{ - {"between", &domainmodel.RangeValidationRuleInfo{MinValue: &lo, MaxValue: &hi, UseMinValue: true, UseMaxValue: true}, "Between"}, - {"min only", &domainmodel.RangeValidationRuleInfo{MinValue: &lo, UseMinValue: true}, "GreaterThanOrEqualTo"}, - {"max only", &domainmodel.RangeValidationRuleInfo{MaxValue: &hi, UseMaxValue: true}, "SmallerThanOrEqualTo"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - doc := serializeRuleInfo(&domainmodel.ValidationRule{Type: "Range", Rule: tt.info}) - if doc == nil { - t.Fatal("a Range rule with bounds must serialize") - } - if doc[2].Key != "TypeOfRange" || doc[2].Value != tt.want { - t.Errorf("TypeOfRange = %v %v, want %q", doc[2].Key, doc[2].Value, tt.want) - } - }) - } -} - -func TestValidationRulesAreReproducible(t *testing.T) { - e := &domainmodel.Entity{ValidationRules: []*domainmodel.ValidationRule{ - {Type: "Required"}, {Type: "Unique"}, - }} - if _, ok := validationRulesAreReproducible(e); !ok { - t.Error("Required/Unique should be reproducible") - } - - e.ValidationRules = append(e.ValidationRules, &domainmodel.ValidationRule{Type: "EqualsTo"}) - got, ok := validationRulesAreReproducible(e) - if ok { - t.Fatal("an entity with an EqualsTo rule must not be reported reproducible") - } - if got != "EqualsTo" { - t.Errorf("reported %q, want EqualsTo", got) - } -} - -func TestUpdateEntity_RefusalNamesTheRuleAndTheConsequence(t *testing.T) { - // The message has to say what would be lost, not just that it refused — - // a bare "cannot rewrite" sends the user looking for a bug in their script. - e := &domainmodel.Entity{Name: "Person", ValidationRules: []*domainmodel.ValidationRule{{Type: "EqualsTo"}}} - ruleType, _ := validationRulesAreReproducible(e) - msg := "entity " + e.Name + " has a " + ruleType + " validation rule" - for _, want := range []string{"Person", "EqualsTo"} { - if !strings.Contains(msg, want) { - t.Errorf("message missing %q", want) - } - } -} diff --git a/sdk/mpr/writer_webservice_body_test.go b/sdk/mpr/writer_webservice_body_test.go deleted file mode 100644 index 4e95a5a438..0000000000 --- a/sdk/mpr/writer_webservice_body_test.go +++ /dev/null @@ -1,275 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/microflows" - "go.mongodb.org/mongo-driver/bson" -) - -func bodyGet(doc bson.D, key string) any { - for _, e := range doc { - if e.Key == key { - return e.Value - } - } - return nil -} - -// TestWebServiceRequestBody_Arguments is the legacy half of the CE0178 fix, and -// exists to keep the two engines from drifting: the modelsdk twin -// (TestWebServiceCallAction_ArgumentsAreSimpleParameterMappings) asserts the -// same keys, values and marker. -func TestWebServiceRequestBody_Arguments(t *testing.T) { - doc := webServiceRequestBody(µflows.WebServiceCallAction{ - Arguments: []microflows.WebServiceArgument{{ - Name: "OrderId", - Path: "http%3A//www.example.com/:GetOrder|OrderId", - Expression: "$Customer/OrderId", - Checked: true, - }}, - }) - - if got := bodyGet(doc, "$Type"); got != "Microflows$SimpleRequestHandling" { - t.Fatalf("$Type = %#v", got) - } - arr, ok := bodyGet(doc, "ParameterMappings").(bson.A) - if !ok || len(arr) != 2 { - t.Fatalf("ParameterMappings = %#v, want the marker plus one mapping", bodyGet(doc, "ParameterMappings")) - } - if got, isInt := arr[0].(int32); !isInt || got != 2 { - t.Errorf("marker = %#v, want int32(2)", arr[0]) - } - pm, ok := arr[1].(bson.D) - if !ok { - t.Fatalf("mapping = %#v", arr[1]) - } - for _, want := range []struct { - key string - val any - }{ - {"$Type", "Microflows$WebServiceOperationSimpleParameterMapping"}, - {"Argument", "$Customer/OrderId"}, - {"IsChecked", true}, - {"ParameterName", ""}, - {"ParameterPath", "http%3A//www.example.com/:GetOrder|OrderId"}, - } { - if got := bodyGet(pm, want.key); got != want.val { - t.Errorf("%s = %#v, want %#v", want.key, got, want.val) - } - } -} - -// TestWebServiceRequestBody_SendMapping is the legacy half of the CE0369 fix. -// -// MappingId / MappingVariableName are the STORAGE names; modelsdk/gen binds the -// same two properties as Mapping / MappingArgumentVariableName, which mxbuild -// tolerates and Studio Pro cannot open. -func TestWebServiceRequestBody_SendMapping(t *testing.T) { - doc := webServiceRequestBody(µflows.WebServiceCallAction{ - SendMappingID: "Clients.SoapOrderExportMapping", - SendMappingVariable: "NewSaveOrder", - }) - for _, want := range []struct { - key string - val any - }{ - {"$Type", "Microflows$MappingRequestHandling"}, - {"ContentType", "Json"}, - {"MappingId", "Clients.SoapOrderExportMapping"}, - {"MappingVariableName", "NewSaveOrder"}, - } { - if got := bodyGet(doc, want.key); got != want.val { - t.Errorf("%s = %#v, want %#v", want.key, got, want.val) - } - } -} - -// TestWebServiceRequestBody_EmptyIsTheBareSimpleForm — a call with neither -// clause still writes the empty Simple body every reference call carries, so -// this change does not alter what already shipped. -func TestWebServiceRequestBody_EmptyIsTheBareSimpleForm(t *testing.T) { - doc := webServiceRequestBody(µflows.WebServiceCallAction{}) - if got := bodyGet(doc, "$Type"); got != "Microflows$SimpleRequestHandling" { - t.Fatalf("$Type = %#v", got) - } - arr, ok := bodyGet(doc, "ParameterMappings").(bson.A) - if !ok || len(arr) != 1 { - t.Fatalf("ParameterMappings = %#v, want just the marker", bodyGet(doc, "ParameterMappings")) - } -} - -// TestParseWebServiceRequestBody round-trips both variants back into the model, -// and pins that the parameter NAME comes from the path's last segment — the only -// part MDL spells. -func TestParseWebServiceRequestBody(t *testing.T) { - action := µflows.WebServiceCallAction{} - parseWebServiceRequestBody(map[string]any{ - "RequestBodyHandling": map[string]any{ - "$Type": "Microflows$SimpleRequestHandling", - "NullValueOption": "LeaveOutElement", - "ParameterMappings": []any{int32(2), map[string]any{ - "$Type": "Microflows$WebServiceOperationSimpleParameterMapping", - "Argument": "2", - "IsChecked": true, - "ParameterName": "", - "ParameterPath": "http%3A//www.example.com/:GetOrder|OrderId", - }}, - }, - }, action) - if len(action.Arguments) != 1 { - t.Fatalf("read %d arguments, want 1", len(action.Arguments)) - } - got := action.Arguments[0] - if got.Name != "OrderId" || got.Expression != "2" || !got.Checked || - got.Path != "http%3A//www.example.com/:GetOrder|OrderId" { - t.Errorf("argument = %+v", got) - } - - mapped := µflows.WebServiceCallAction{} - parseWebServiceRequestBody(map[string]any{ - "RequestBodyHandling": map[string]any{ - "$Type": "Microflows$MappingRequestHandling", - "ContentType": "Json", - "MappingId": "Clients.SoapOrderExportMapping", - "MappingVariableName": "NewSaveOrder", - }, - }, mapped) - if string(mapped.SendMappingID) != "Clients.SoapOrderExportMapping" || - mapped.SendMappingVariable != "NewSaveOrder" || - mapped.SendMappingContentType != "Json" { - t.Errorf("send mapping = %+v", mapped) - } - // The two variants differ in ARITY, so dispatching on $Type rather than on - // which fields are present is what stops one being read as the other. - if len(mapped.Arguments) != 0 { - t.Errorf("a mapping body produced %d arguments", len(mapped.Arguments)) - } -} - -// referenceSoapAction builds the fifteen-key action shape every ako/TestApp SOAP -// call carries, with mxcli's own values for the six boilerplate keys. -func referenceSoapAction() map[string]any { - return map[string]any{ - "$ID": "a", "$Type": "Microflows$CallWebServiceAction", - "ErrorHandlingType": "Rollback", - "HttpConfiguration": map[string]any{ - "$Type": "Microflows$HttpConfiguration", - "ClientCertificate": "", "CustomLocation": "", - "CustomLocationTemplate": nil, - "HttpAuthenticationPassword": "", "HttpAuthenticationUserName": "", - "HttpHeaderEntries": []any{int32(3)}, - "HttpMethod": "Post", - "OverrideLocation": false, "UseHttpAuthentication": false, - }, - "ImportedService": "Clients.OrderSoapClient", "IsValidationRequired": false, - "NewResultHandling": map[string]any{ - "$Type": "Microflows$ResultHandling", "Bind": true, - "ImportMappingCall": map[string]any{ - "$Type": "Microflows$ImportMappingCall", "Commit": "YesWithoutEvents", - "ContentType": "Xml", "ForceSingleOccurrence": false, - "ObjectHandlingBackup": "Create", "ParameterVariableName": "", - "Range": map[string]any{"$Type": "Microflows$ConstantRange", "SingleObject": true}, - "ReturnValueMapping": "Clients.SoapOrdersImportMapping", - }, - "ResultVariableName": "Orders", - "VariableType": map[string]any{"$Type": "DataTypes$ObjectType", "Entity": "Clients.Order"}, - }, - "OperationName": "GetOrder", "ProxyConfiguration": nil, - "RequestBodyHandling": map[string]any{ - "$Type": "Microflows$SimpleRequestHandling", "NullValueOption": "LeaveOutElement", - "ParameterMappings": []any{int32(2), map[string]any{ - "$Type": "Microflows$WebServiceOperationSimpleParameterMapping", - "Argument": "2", "IsChecked": true, "ParameterName": "", - "ParameterPath": "http%3A//www.example.com/:GetOrder|OrderId", - }}, - }, - "RequestHeaderHandling": map[string]any{ - "$Type": "Microflows$SimpleRequestHandling", "NullValueOption": "LeaveOutElement", - "ParameterMappings": []any{int32(2)}, - }, - "RequestProxyType": "DefaultProxy", "ServiceName": "OrdersWS", - "TimeOutExpression": "300", "UseRequestTimeOut": true, - } -} - -// TestWebServiceActionRequiresRawBSON_StructuredWhenReproducible — an action -// mxcli itself would write describes structurally rather than as base64. -// -// Before the request body was authorable this could never happen: a real call -// carries fifteen keys and only nine were admitted, so EVERY SOAP call in every -// project — Studio Pro's and mxcli's — rendered as `call web service raw '<…>'`. -func TestWebServiceActionRequiresRawBSON_StructuredWhenReproducible(t *testing.T) { - if webServiceActionRequiresRawBSON(referenceSoapAction()) { - t.Error("an action mxcli would write itself still falls back to raw") - } -} - -// TestWebServiceActionRequiresRawBSON_ValueSensitive is the regression test for -// what a describe -> exec round trip over ako/TestApp actually caught. -// -// Admitting the six boilerplate keys BY NAME would have silently normalised a -// call the moment anyone round-tripped it. Each case below is a document mxcli -// would write differently, so each must keep the byte-exact raw fallback. -func TestWebServiceActionRequiresRawBSON_ValueSensitive(t *testing.T) { - for _, tc := range []struct { - name string - mutit func(map[string]any) - }{ - // Measured: Clients.GetOrders stores SingleObject FALSE where mxcli - // writes true. No error comes of it, which is exactly why writing it - // back must not happen silently — the round trip would change the - // user's document with nothing to show for it. - {"Range.SingleObject differs", func(m map[string]any) { - rh := m["NewResultHandling"].(map[string]any) - imc := rh["ImportMappingCall"].(map[string]any) - imc["Range"] = map[string]any{"$Type": "Microflows$ConstantRange", "SingleObject": false} - }}, - // Measured: Clients.SaveOrder binds $IsSaved with NO import mapping and - // a DataTypes$BooleanType — the OPERATION's return type, which lives in - // the WSDL. Written back as VoidType it is CE0366 + CE6011. - {"result type comes from the WSDL, not a mapping", func(m map[string]any) { - m["NewResultHandling"] = map[string]any{ - "$Type": "Microflows$ResultHandling", "Bind": true, - "ImportMappingCall": nil, - "ResultVariableName": "IsSaved", - "VariableType": map[string]any{"$Type": "DataTypes$BooleanType"}, - } - }}, - {"HTTP authentication configured", func(m map[string]any) { - m["HttpConfiguration"].(map[string]any)["UseHttpAuthentication"] = true - }}, - {"custom location", func(m map[string]any) { - m["HttpConfiguration"].(map[string]any)["CustomLocation"] = "https://elsewhere/" - }}, - {"a SOAP header is configured", func(m map[string]any) { - m["RequestHeaderHandling"].(map[string]any)["ParameterMappings"] = []any{int32(2), - map[string]any{"$Type": "Microflows$WebServiceOperationSimpleParameterMapping"}} - }}, - {"validation required", func(m map[string]any) { m["IsValidationRequired"] = true }}, - {"non-default proxy", func(m map[string]any) { m["RequestProxyType"] = "NoProxy" }}, - {"timeout disabled", func(m map[string]any) { m["UseRequestTimeOut"] = false }}, - // A per-parameter export mapping has no MDL spelling at all. - {"advanced parameter mapping", func(m map[string]any) { - m["RequestBodyHandling"].(map[string]any)["ParameterMappings"] = []any{int32(2), - map[string]any{"$Type": "Microflows$WebServiceOperationAdvancedParameterMapping"}} - }}, - // Without a "|" the parameter name MDL spells cannot be recovered, so - // the write path could not rebuild the same path. - {"parameter path with no name segment", func(m map[string]any) { - pms := m["RequestBodyHandling"].(map[string]any)["ParameterMappings"].([]any) - pms[1].(map[string]any)["ParameterPath"] = "http%3A//www.example.com/:GetOrder" - }}, - {"unknown key entirely", func(m map[string]any) { m["SomethingNew"] = 1 }}, - } { - t.Run(tc.name, func(t *testing.T) { - m := referenceSoapAction() - tc.mutit(m) - if !webServiceActionRequiresRawBSON(m) { - t.Error("describes structurally, so a round trip would silently rewrite it") - } - }) - } -} diff --git a/sdk/mpr/writer_widgets.go b/sdk/mpr/writer_widgets.go deleted file mode 100644 index 4fd71c086d..0000000000 --- a/sdk/mpr/writer_widgets.go +++ /dev/null @@ -1,727 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================ -// Widget Serialization — Dispatch -// ============================================================================ - -// serializeWidgetArray serializes a slice of widgets to a BSON array with version prefix. -// Mendix uses [3] for empty arrays, [2, item1, item2, ...] for non-empty arrays. -// Items go directly after the version marker, NOT nested in another array. -func serializeWidgetArray(widgets []pages.Widget) bson.A { - arr := bson.A{int32(3)} // Start with empty marker - hasItems := false - for _, w := range widgets { - if w != nil { - if !hasItems { - arr = bson.A{int32(2)} // First item: change to version 2 - hasItems = true - } - arr = append(arr, serializeWidget(w)) - } - } - return arr -} - -// SerializeWidget serializes a single widget to BSON. -// This is the public entry point for widget serialization. -func SerializeWidget(w pages.Widget) bson.D { - return serializeWidget(w) -} - -// serializeWidget serializes a single widget to BSON. -func serializeWidget(w pages.Widget) bson.D { - var doc bson.D - switch widget := w.(type) { - case *pages.Container: - doc = serializeContainer(widget) - case *pages.GroupBox: - return serializeGroupBox(widget) - case *pages.TabContainer: - return serializeTabContainer(widget) - case *pages.LayoutGrid: - doc = serializeLayoutGrid(widget) - case *pages.DynamicText: - doc = serializeDynamicText(widget) - case *pages.ActionButton: - doc = serializeActionButton(widget) - case *pages.Text: - doc = serializeStaticText(widget) - case *pages.Title: - doc = serializeTitle(widget) - case *pages.SnippetCallWidget: - doc = serializeSnippetCall(widget) - case *pages.Gallery: - doc = serializeGallery(widget) - case *pages.CustomWidget: - doc = serializeCustomWidget(widget) - case *pages.DataView: - doc = serializeDataView(widget) - case *pages.DataGrid: - doc = serializeDataGrid(widget) - case *pages.TextBox: - doc = serializeTextBox(widget) - case *pages.TextArea: - doc = serializeTextArea(widget) - case *pages.DatePicker: - doc = serializeDatePicker(widget) - case *pages.CheckBox: - doc = serializeCheckBox(widget) - case *pages.RadioButtons: - doc = serializeRadioButtons(widget) - case *pages.DropDown: - doc = serializeDropDown(widget) - case *pages.NavigationList: - doc = serializeNavigationList(widget) - case *pages.ListView: - doc = serializeListView(widget) - case *pages.StaticImage: - doc = serializeStaticImage(widget) - case *pages.DynamicImage: - doc = serializeDynamicImage(widget) - default: - // Fallback for unknown widget types - doc = bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(w.GetID()))}, - {Key: "$Type", Value: w.GetTypeName()}, - {Key: "Name", Value: w.GetName()}, - } - } - - // Patch conditional settings from BaseWidget if set - doc = patchConditionalSettings(doc, w) - return doc -} - -// patchConditionalSettings replaces nil ConditionalVisibilitySettings/ConditionalEditabilitySettings -// in the serialized BSON with actual values from the widget's BaseWidget fields. -func patchConditionalSettings(doc bson.D, w pages.Widget) bson.D { - type baseWidgetGetter interface { - GetBaseWidget() *pages.BaseWidget - } - bwg, ok := w.(baseWidgetGetter) - if !ok { - return doc - } - bw := bwg.GetBaseWidget() - if bw.ConditionalVisibility == nil && bw.ConditionalEditability == nil { - return doc - } - - for i, elem := range doc { - if elem.Key == "ConditionalVisibilitySettings" && bw.ConditionalVisibility != nil { - doc[i].Value = serializeConditionalVisibility(bw.ConditionalVisibility) - } - if elem.Key == "ConditionalEditabilitySettings" && bw.ConditionalEditability != nil { - doc[i].Value = serializeConditionalEditability(bw.ConditionalEditability) - } - // Editability: the conditional settings element wins, then an explicit - // `editable:`. Only ever narrowed from the template's own value when the - // author actually said something, so a widget that never mentions - // editability keeps whatever the template had. - if elem.Key == "Editable" && (bw.ConditionalEditability != nil || bw.Editable != "") { - doc[i].Value = pages.WidgetEditability(bw) - } - } - return doc -} - -func serializeConditionalVisibility(cvs *pages.ConditionalVisibilitySettings) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(cvs.ID))}, - {Key: "$Type", Value: "Forms$ConditionalVisibilitySettings"}, - // Attribute is a BY_NAME AttributeIdentifier; Studio Pro writes "" (not null) - // when there is no attribute-based condition. 11.12's reader rejects the null. - {Key: "Attribute", Value: ""}, - {Key: "Conditions", Value: bson.A{int32(3)}}, - {Key: "Expression", Value: cvs.Expression}, - {Key: "IgnoreSecurity", Value: false}, - {Key: "ModuleRoles", Value: bson.A{int32(3)}}, - {Key: "SourceVariable", Value: nil}, - } -} - -func serializeConditionalEditability(ces *pages.ConditionalEditabilitySettings) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ces.ID))}, - {Key: "$Type", Value: "Forms$ConditionalEditabilitySettings"}, - {Key: "Attribute", Value: ""}, // "" not null — see serializeConditionalVisibility - {Key: "Conditions", Value: bson.A{int32(3)}}, - {Key: "Expression", Value: ces.Expression}, - {Key: "SourceVariable", Value: nil}, - } -} - -// ============================================================================ -// DataSource Serialization -// ============================================================================ - -// serializeDataSource serializes a datasource for DataView widgets (Forms$*Source types). -// NOTE: DataViews do not support database sources in Mendix. If a DatabaseSource is passed, -// it is serialized as a Forms$DataViewSource with entity reference as a best-effort fallback. -func serializeDataSource(ds pages.DataSource) bson.D { - if ds == nil { - return nil - } - - switch d := ds.(type) { - case *pages.DatabaseSource: - // DataViews cannot have a database source in Mendix. Serialize as - // Forms$DataViewSource with entity ref as the closest valid alternative. - var entityRef any - if d.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: d.EntityName}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$DataViewSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SourceVariable", Value: nil}, - } - case *pages.MicroflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$MicroflowSource"}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Asynchronous", Value: false}, - {Key: "ConfirmationInfo", Value: nil}, - {Key: "FormValidations", Value: "All"}, - {Key: "Microflow", Value: d.Microflow}, // Qualified name (e.g., "Module.MicroflowName") - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: nil}, - }}, - } - case *pages.NanoflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$NanoflowSource"}, - {Key: "NanoflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NanoflowSettings"}, - {Key: "Nanoflow", Value: d.Nanoflow}, // Qualified name (e.g., "Module.NanoflowName") - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - }}, - } - default: - return nil - } -} - -// SerializeCustomWidgetDataSource serializes a datasource for custom widgets. -// Exported for use by page builders. -func SerializeCustomWidgetDataSource(ds pages.DataSource) bson.D { - if ds == nil { - return nil - } - - switch d := ds.(type) { - case *pages.DatabaseSource: - // EntityRef needs to be serialized with the entity qualified name - var entityRef any - if d.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: d.EntityName}, - } - } - - // Build SortItems array from Sorting field - sortItems := bson.A{int32(2)} // Version marker for non-empty array - for _, sort := range d.Sorting { - sortItem := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortItem"}, - {Key: "AttributeRef", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: sort.AttributePath}, - {Key: "EntityRef", Value: nil}, - }}, - // Forms$GridSortItem stores its direction under SortDirection, NOT - // SortOrder (that key belongs to Microflows$SortItem / document - // templates). Studio Pro ignores the misnamed field → sort silently - // reverts to ascending. Bug 8. - {Key: "SortDirection", Value: string(sort.Direction)}, - } - sortItems = append(sortItems, sortItem) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "CustomWidgets$CustomWidgetXPathSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SortBar", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: sortItems}, - }}, - {Key: "SourceVariable", Value: nil}, - {Key: "XPathConstraint", Value: d.XPathConstraint}, - } - case *pages.MicroflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$MicroflowSource"}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Asynchronous", Value: false}, - {Key: "ConfirmationInfo", Value: nil}, - {Key: "FormValidations", Value: "All"}, - {Key: "Microflow", Value: d.Microflow}, - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: nil}, - }}, - } - case *pages.NanoflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$NanoflowSource"}, - {Key: "NanoflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NanoflowSettings"}, - {Key: "Nanoflow", Value: d.Nanoflow}, - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - }}, - } - case *pages.AssociationSource: - return serializeAssociationSource(d) - default: - return nil - } -} - -// serializeAssociationSource builds a Forms$AssociationSource BSON document. -// EntityPath is "Module.Assoc" or "Module.Assoc/Module.DestEntity". -// When DestinationEntity is omitted, it's left empty — Studio Pro will resolve it. -func serializeAssociationSource(d *pages.AssociationSource) bson.D { - parts := strings.Split(d.EntityPath, "/") - association := parts[0] - destEntity := "" - if len(parts) >= 2 { - destEntity = parts[1] - } - - step := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$EntityRefStep"}, - {Key: "Association", Value: association}, - {Key: "DestinationEntity", Value: destEntity}, - } - - entityRef := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$IndirectEntityRef"}, - {Key: "Steps", Value: bson.A{int32(2), step}}, - } - - var sourceVar any - if d.ContextVariable != "" { - sourceVar = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$PageVariable"}, - {Key: "LocalVariable", Value: ""}, - {Key: "PageParameter", Value: d.ContextVariable}, - {Key: "SnippetParameter", Value: ""}, - {Key: "SubKey", Value: ""}, - {Key: "UseAllPages", Value: false}, - {Key: "Widget", Value: ""}, - } - } - - id := string(d.ID) - if id == "" { - id = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Forms$AssociationSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SourceVariable", Value: sourceVar}, - } -} - -// ============================================================================ -// Reference Serialization -// ============================================================================ - -// serializeAttributeRef serializes an attribute reference for input widgets. -// The attrPath MUST be a fully qualified name (Module.Entity.Attribute) with at least 2 dots. -// If the path is not fully qualified, returns nil to avoid Mendix resolution errors. -func serializeAttributeRef(attrPath string) any { - if attrPath == "" { - return nil - } - // Attribute path must be fully qualified: Module.Entity.Attribute (at least 2 dots) - dotCount := strings.Count(attrPath, ".") - if dotCount < 2 { - // Not fully qualified - cannot serialize as Mendix won't be able to resolve it - return nil - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: attrPath}, - {Key: "EntityRef", Value: nil}, - } -} - -// serializeEntityRef serializes an entity reference. -func serializeEntityRef(entityPath string) any { - if entityPath == "" { - return nil - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: entityPath}, - } -} - -// ============================================================================ -// Appearance Serialization -// ============================================================================ - -// serializeAppearance creates a standard Appearance object for widgets. -func serializeAppearance(class, style, dynamicClasses string, designProps []pages.DesignPropertyValue) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$Appearance"}, - {Key: "Class", Value: class}, - {Key: "DesignProperties", Value: serializeDesignProperties(designProps)}, - {Key: "DynamicClasses", Value: dynamicClasses}, - {Key: "Style", Value: style}, - } -} - -// serializeDesignProperties serializes design property values to a BSON array. -// Both empty and non-empty use version marker int64(3). -func serializeDesignProperties(props []pages.DesignPropertyValue) bson.A { - if len(props) == 0 { - return bson.A{int32(3)} - } - - arr := bson.A{int32(3)} - for _, p := range props { - var valueBson bson.D - switch p.ValueType { - case "toggle": - valueBson = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ToggleDesignPropertyValue"}, - } - case "option": - valueBson = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$OptionDesignPropertyValue"}, - {Key: "Option", Value: p.Option}, - } - case "custom": - // ToggleButtonGroup and ColorPicker properties use CustomDesignPropertyValue - valueBson = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$CustomDesignPropertyValue"}, - {Key: "Value", Value: p.Option}, - } - case "compound": - // A property whose value is itself a set of sub-properties (e.g. Atlas - // "Spacing" → margin-top/-bottom/…). Forms$CompoundDesignPropertyValue holds - // the sub-entries in a Properties list with the SAME marker-prefixed - // Forms$DesignPropertyValue shape as the outer array, so recurse. Without - // this case a compound design property was silently dropped on write (the - // old `default: continue`), while toggle/option survived. - valueBson = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$CompoundDesignPropertyValue"}, - {Key: "Properties", Value: serializeDesignProperties(p.Compound)}, - } - default: - continue - } - arr = append(arr, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$DesignPropertyValue"}, - {Key: "Key", Value: p.Key}, - {Key: "Value", Value: valueBson}, - }) - } - return arr -} - -// ============================================================================ -// Input Widget Helpers -// ============================================================================ - -// serializeWidgetValidation creates the required WidgetValidation object for input widgets. -func serializeWidgetValidation() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$WidgetValidation"}, - {Key: "Expression", Value: ""}, - {Key: "Message", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - } -} - -// serializeFormattingInfo creates a default FormattingInfo object for input widgets. -func serializeFormattingInfo() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormattingInfo"}, - {Key: "CustomDateFormat", Value: ""}, - {Key: "DateFormat", Value: "Date"}, - {Key: "DecimalPrecision", Value: int64(2)}, - {Key: "EnumFormat", Value: "Text"}, - {Key: "GroupDigits", Value: false}, - } -} - -// ============================================================================ -// Text/Template Helpers -// ============================================================================ - -// serializeEmptyText creates an empty Texts$Text object. -// Required for properties like CounterMessage that cannot be null. -func serializeEmptyText() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - } -} - -// serializeEmptyPlaceholderTemplate creates an empty ClientTemplate for placeholder text. -func serializeEmptyPlaceholderTemplate() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "Parameters", Value: bson.A{int32(3)}}, - {Key: "Template", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - } -} - -// serializePlaceholderTemplate creates a placeholder ClientTemplate from a Text, -// or an empty one when nil. Same Forms$ClientTemplate shape as the label template. -func serializePlaceholderTemplate(t *model.Text) bson.D { - if t == nil { - return serializeEmptyPlaceholderTemplate() - } - text := "" - for _, v := range t.Translations { - text = v - break - } - if text == "" { - return serializeEmptyPlaceholderTemplate() - } - return serializeLabelTemplate(text) -} - -// serializeLabelTemplate creates a standard label template for input widgets. -func serializeLabelTemplate(label string) bson.D { - if label == "" { - return nil - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "Parameters", Value: bson.A{int32(3)}}, - {Key: "Template", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: model.AuthoringLanguage()}, - {Key: "Text", Value: label}, - }}}, - }}, - } -} - -// serializeClientTemplate serializes a ClientTemplate with parameters. -func serializeClientTemplate(ct *pages.ClientTemplate, fallbackText *model.Text, defaultText string) bson.D { - captionID := generateUUID() - captionTransID := generateUUID() - captionText := defaultText - - // Get text from ClientTemplate or fallback Text - if ct != nil && ct.Template != nil { - for _, text := range ct.Template.Translations { - captionText = text - break - } - } else if fallbackText != nil { - for _, text := range fallbackText.Translations { - captionText = text - break - } - } - - // Build the template document - // Mendix uses [3] as version marker, followed by array items - template := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: idToBsonBinary(captionTransID)}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: model.AuthoringLanguage()}, - {Key: "Text", Value: captionText}, - }}}, - } - - // Build Fallback as a Texts$Text object (not a string) - fallback := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, // Empty fallback - } - - // Build parameters array - use [3] for empty, [2, items...] for non-empty - params := bson.A{int32(3)} // Empty array with version marker 3 - if ct != nil && len(ct.Parameters) > 0 { - params = bson.A{int32(2)} // Non-empty array uses version marker 2 - for _, param := range ct.Parameters { - params = append(params, serializeClientTemplateParameter(param)) - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(captionID)}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: fallback}, // Must be Fallback object, not FallbackValue string - {Key: "Parameters", Value: params}, - {Key: "Template", Value: template}, - } -} - -// serializeClientTemplateParameter serializes a ClientTemplateParameter. -func serializeClientTemplateParameter(param *pages.ClientTemplateParameter) bson.D { - paramID := generateUUID() - if param.ID != "" { - paramID = string(param.ID) - } - - // Build AttributeRef if present - use serializeAttributeRef for validation - attrRef := serializeAttributeRef(param.AttributeRef) - - // Build FormattingInfo — schema-aligned with Forms$FormattingInfo - // reflection (CustomDateFormat / DateFormat / DecimalPrecision / - // EnumFormat / GroupDigits). Writing TimeFormat here triggers Studio - // Pro CE0463 "widget definition changed" on pluggable widgets that - // embed this struct (e.g. Gallery / DataGrid2 column captions). - // - // Use the parameter's per-parameter formatting when present; a nil - // FormattingInfo reproduces the previous hardcoded defaults, so every - // unformatted parameter is byte-identical to before. - dateFormat, customDateFormat, enumFormat := "Date", "", "Text" - decimalPrecision := int64(2) - groupDigits := false - if fi := param.FormattingInfo; fi != nil { - if fi.DateFormat != "" { - dateFormat = fi.DateFormat - } - customDateFormat = fi.CustomDateFormat - if fi.EnumFormat != "" { - enumFormat = fi.EnumFormat - } - decimalPrecision = int64(fi.DecimalPrecision) - groupDigits = fi.GroupDigits - } - formattingInfo := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormattingInfo"}, - {Key: "CustomDateFormat", Value: customDateFormat}, - {Key: "DateFormat", Value: dateFormat}, - {Key: "DecimalPrecision", Value: decimalPrecision}, - {Key: "EnumFormat", Value: enumFormat}, - {Key: "GroupDigits", Value: groupDigits}, - } - - // Build SourceVariable if present. Studio Pro distinguishes three bindings on - // the same Forms$PageVariable — LocalVariable (a page `Variables:` entry), - // SnippetParameter, and PageParameter — and exactly one is populated. This - // wrote PageParameter for all three, so a binding to a page-level variable - // named a page parameter that does not exist (upstream #977). The modelsdk - // writer already branched; the two now agree. - var sourceVariable any - if param.SourceVariable != "" { - fields := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$PageVariable"}, - } - switch param.SourceVariableKind { - case "local": - fields = append(fields, - bson.E{Key: "LocalVariable", Value: param.SourceVariable}, - bson.E{Key: "PageParameter", Value: ""}, - bson.E{Key: "SnippetParameter", Value: ""}, - ) - case "snippet": - fields = append(fields, - bson.E{Key: "LocalVariable", Value: ""}, - bson.E{Key: "PageParameter", Value: ""}, - bson.E{Key: "SnippetParameter", Value: param.SourceVariable}, - ) - default: - fields = append(fields, - bson.E{Key: "LocalVariable", Value: ""}, - bson.E{Key: "PageParameter", Value: param.SourceVariable}, - bson.E{Key: "SnippetParameter", Value: ""}, - ) - } - sourceVariable = append(fields, - bson.E{Key: "UseAllPages", Value: false}, - bson.E{Key: "Widget", Value: ""}, - ) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(paramID)}, - {Key: "$Type", Value: "Forms$ClientTemplateParameter"}, - {Key: "AttributeRef", Value: attrRef}, - {Key: "Expression", Value: param.Expression}, - {Key: "FormattingInfo", Value: formattingInfo}, - {Key: "SourceVariable", Value: sourceVariable}, - } -} diff --git a/sdk/mpr/writer_widgets_action.go b/sdk/mpr/writer_widgets_action.go deleted file mode 100644 index 1274b331ba..0000000000 --- a/sdk/mpr/writer_widgets_action.go +++ /dev/null @@ -1,275 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================ -// Client Action Serialization -// ============================================================================ - -// SerializeClientAction serializes a ClientAction to BSON. -// This is the exported version for use by the pluggable widget engine. -func SerializeClientAction(action pages.ClientAction) bson.D { - return serializeClientAction(action) -} - -// serializeClientAction serializes a ClientAction. -func serializeClientAction(action pages.ClientAction) bson.D { - if action == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - } - } - - switch a := action.(type) { - case *pages.SaveChangesClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$SaveChangesClientAction"}, - {Key: "ClosePage", Value: a.ClosePage}, - {Key: "SyncAutomatically", Value: true}, - } - case *pages.CancelChangesClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$CancelChangesClientAction"}, - {Key: "ClosePage", Value: a.ClosePage}, - } - case *pages.ClosePageClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$ClosePageClientAction"}, - } - case *pages.DeleteClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$DeleteClientAction"}, - {Key: "ClosePage", Value: a.ClosePage}, - } - case *pages.LinkClientAction: - // OPEN_LINK fell through to the default below and was written as - // Forms$NoAction, exactly as SIGN_OUT was — the button rendered and did - // nothing (CapTrackV2 FINDINGS §10). - // - // The storage name is Forms$OpenLinkClientAction, NOT the - // "Forms$LinkClientAction" the semantic type carries. Pinned against 31 - // Studio Pro-authored link buttons: five keys, LinkType "Web" in all 31, - // address nested as a Forms$StaticOrDynamicString whose AttributeRef is - // null for the static form MDL authors. - linkType := string(a.LinkType) - if linkType == "" { - linkType = "Web" - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$OpenLinkClientAction"}, - {Key: "Address", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$StaticOrDynamicString"}, - {Key: "AttributeRef", Value: nil}, - {Key: "IsDynamic", Value: false}, - {Key: "Value", Value: a.Address}, - }}, - {Key: "DisabledDuringExecution", Value: true}, - {Key: "LinkType", Value: linkType}, - } - case *pages.SignOutClientAction: - // Until this case existed, SIGN_OUT fell through to the default below - // and was written as Forms$NoAction — so the button rendered, said - // "Sign out", and did nothing, with `mxcli check`, `exec` and `mx check` - // all clean. That made the documented workaround for the modelsdk - // engine's refusal ("rerun with MXCLI_ENGINE=legacy") the more dangerous - // of the two paths (CapTrackV2 FINDINGS §10). - // - // One property, pinned against a Studio Pro-authored button (ako/TestApp, - // Mendix 11): DisabledDuringExecution, true. - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$SignOutClientAction"}, - {Key: "DisabledDuringExecution", Value: true}, - } - case *pages.CreateObjectClientAction: - // Build EntityRef if entity is specified - var entityRef any - if a.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: a.EntityName}, - } - } - // Build PageSettings (Forms$FormSettings) - always required, even if no page specified - pageSettings := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormSettings"}, - {Key: "Form", Value: a.PageName}, // BY_NAME_REFERENCE - qualified name, or empty string if no page - {Key: "ParameterMappings", Value: bson.A{int32(2)}}, - {Key: "TitleOverride", Value: nil}, // no override: the page keeps its own title (#812) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$CreateObjectClientAction"}, - {Key: "DisabledDuringExecution", Value: true}, - {Key: "EntityRef", Value: entityRef}, - {Key: "NumberOfPagesToClose2", Value: ""}, - {Key: "PageSettings", Value: pageSettings}, - } - case *pages.PageClientAction: - // Studio Pro stores ParameterMappings as an empty initialized array [2] and - // infers $currentObject from the enclosing widget context (DataGrid, DataView, etc.). - // Storing explicit inline Forms$PageParameterMapping objects with an Argument of - // "$currentObject" makes Studio Pro report CE0115 "parameters do not match" — a - // widget's current-row object is represented by an inferred WidgetValue, not an - // Argument expression (issue #296; re-confirmed against mxbuild 11.12.1 for - // FINDINGS #56). - // - // The other half of this decision lives in DESCRIBE, which must put the - // argument back from the target page's own parameters — see - // pageActionParameters. It did not, for a long time, and this comment - // asserted that it did (mxcli-formula1 §39). - formSettings := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$FormSettings"}, - {Key: "Form", Value: a.PageName}, // BY_NAME_REFERENCE - qualified name - {Key: "ParameterMappings", Value: bson.A{int32(2)}}, - {Key: "TitleOverride", Value: nil}, // no override: the page keeps its own title (#812) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$FormAction"}, - {Key: "DisabledDuringExecution", Value: true}, - {Key: "FormSettings", Value: formSettings}, - {Key: "NumberOfPagesToClose2", Value: ""}, - {Key: "PagesForSpecializations", Value: bson.A{int32(2)}}, - } - case *pages.MicroflowClientAction: - // Build ParameterMappings if any - paramMappings := bson.A{int32(len(a.ParameterMappings))} - for _, pm := range a.ParameterMappings { - // Parameter is BY_NAME_REFERENCE: MicroflowName.ParameterName - paramRef := a.MicroflowName + "." + pm.ParameterName - - // Determine the expression value - var expression string - if pm.Variable != "" { - expression = pm.Variable // e.g., "$Customer" - } else if pm.Expression != "" { - expression = pm.Expression - } - - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowParameterMapping"}, - {Key: "Expression", Value: expression}, - {Key: "Parameter", Value: paramRef}, // BY_NAME_REFERENCE - {Key: "Variable", Value: nil}, - } - paramMappings = append(paramMappings, mapping) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$MicroflowAction"}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Microflow", Value: a.MicroflowName}, - {Key: "ParameterMappings", Value: paramMappings}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "Asynchronous", Value: false}, - {Key: "FormValidations", Value: "All"}, - {Key: "ConfirmationInfo", Value: nil}, - }}, - {Key: "DisabledDuringExecution", Value: true}, - } - case *pages.NanoflowClientAction: - // Build ParameterMappings if any - nfParamMappings := bson.A{int32(len(a.ParameterMappings))} - for _, pm := range a.ParameterMappings { - // Parameter is BY_NAME_REFERENCE: NanoflowName.ParameterName - paramRef := a.NanoflowName + "." + pm.ParameterName - - // Determine the expression value - var expression string - if pm.Variable != "" { - expression = pm.Variable // e.g., "$Customer" - } else if pm.Expression != "" { - expression = pm.Expression - } - - mapping := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NanoflowParameterMapping"}, - {Key: "Expression", Value: expression}, - {Key: "Parameter", Value: paramRef}, // BY_NAME_REFERENCE - {Key: "Variable", Value: nil}, - } - nfParamMappings = append(nfParamMappings, mapping) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$CallNanoflowClientAction"}, - {Key: "Nanoflow", Value: a.NanoflowName}, - {Key: "ParameterMappings", Value: nfParamMappings}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "ConfirmationInfo", Value: nil}, - {Key: "DisabledDuringExecution", Value: true}, - } - case *pages.SetTaskOutcomeClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$SetTaskOutcomeClientAction"}, - {Key: "ClosePage", Value: a.ClosePage}, - {Key: "Commit", Value: a.Commit}, - {Key: "DisabledDuringExecution", Value: true}, - {Key: "OutcomeValue", Value: a.OutcomeValue}, - } - case *pages.NoClientAction: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(a.ID))}, - {Key: "$Type", Value: "Forms$NoAction"}, - } - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NoAction"}, - } - } -} - -// buildFormPageVariable returns a Forms$PageVariable BSON document. -// pageParam is the page parameter name that supplies the value (without leading $). -// For Forms$PageParameterMapping (show-page button), all sub-fields are empty and -// the variable is carried in the sibling Argument field. -// For Forms$SnippetParameterMapping, pageParam is set and Argument is empty. -func buildFormPageVariable(pageParam string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$PageVariable"}, - {Key: "LocalVariable", Value: ""}, - {Key: "PageParameter", Value: pageParam}, - {Key: "SnippetParameter", Value: ""}, - {Key: "SubKey", Value: ""}, - {Key: "UseAllPages", Value: false}, - {Key: "Widget", Value: ""}, - } -} diff --git a/sdk/mpr/writer_widgets_action_test.go b/sdk/mpr/writer_widgets_action_test.go deleted file mode 100644 index aaafcc660d..0000000000 --- a/sdk/mpr/writer_widgets_action_test.go +++ /dev/null @@ -1,286 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// getFormSettings extracts FormSettings from a serialized Forms$FormAction document. -func getFormSettings(t *testing.T, doc bson.D) bson.D { - t.Helper() - for _, e := range doc { - if e.Key == "FormSettings" { - fs, ok := e.Value.(bson.D) - if !ok { - t.Fatalf("FormSettings is not bson.D, got %T", e.Value) - } - return fs - } - } - t.Fatal("FormSettings not found") - return nil -} - -// getParamMappings extracts ParameterMappings from a FormSettings document. -func getParamMappings(t *testing.T, formSettings bson.D) primitive.A { - t.Helper() - for _, e := range formSettings { - if e.Key == "ParameterMappings" { - arr, ok := e.Value.(primitive.A) - if !ok { - t.Fatalf("ParameterMappings is not primitive.A, got %T", e.Value) - } - return arr - } - } - t.Fatal("ParameterMappings not found") - return nil -} - -// TestPageClientAction_ParameterMappings_TypeIndicator verifies that -// Forms$FormAction always serializes ParameterMappings as [2] (type indicator -// only, no inline mapping objects), matching Studio Pro's native format. -// -// Studio Pro infers $currentObject from the enclosing widget context at runtime -// rather than reading explicit Forms$PageParameterMapping objects from BSON. -// Using int32(len) as the array's first element produces an invalid type -// indicator that Studio Pro cannot read, causing CE0115 (issue #296). -func TestPageClientAction_ParameterMappings_TypeIndicator(t *testing.T) { - action := &pages.PageClientAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - PageName: "AuditTrail.Log_View", - ParameterMappings: []*pages.PageClientParameterMapping{ - { - BaseElement: model.BaseElement{ID: "mapping-id"}, - ParameterName: "Log", - Variable: "$currentObject", - }, - }, - } - - doc := serializeClientAction(action) - if doc == nil { - t.Fatal("serializeClientAction returned nil") - } - - formSettings := getFormSettings(t, doc) - mappings := getParamMappings(t, formSettings) - - // Must be exactly [int32(2)] — type indicator only, no inline objects. - // Studio Pro's reader skips the type indicator (2 or 3) and reads the rest - // as items; any other first-element value is treated as invalid. - if len(mappings) != 1 { - t.Fatalf("ParameterMappings: want exactly 1 element (type indicator), got %d", len(mappings)) - } - indicator, ok := mappings[0].(int32) - if !ok { - t.Fatalf("ParameterMappings[0] is not int32, got %T", mappings[0]) - } - if indicator != 2 { - t.Errorf("ParameterMappings type indicator = %d, want 2", indicator) - } -} - -// TestPageClientAction_NoParams_TypeIndicator verifies that a PageClientAction -// without parameter mappings still serializes ParameterMappings as [2]. -func TestPageClientAction_NoParams_TypeIndicator(t *testing.T) { - action := &pages.PageClientAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - PageName: "Sales.Customer_Overview", - } - - doc := serializeClientAction(action) - if doc == nil { - t.Fatal("serializeClientAction returned nil") - } - - var bsonType string - for _, e := range doc { - if e.Key == "$Type" { - bsonType, _ = e.Value.(string) - } - } - if bsonType != "Forms$FormAction" { - t.Errorf("$Type = %q, want %q", bsonType, "Forms$FormAction") - } - - formSettings := getFormSettings(t, doc) - mappings := getParamMappings(t, formSettings) - if len(mappings) != 1 { - t.Fatalf("ParameterMappings: want [2], got %v", mappings) - } -} - -// TestPageClientAction_RequiredFields verifies that Forms$FormAction includes -// all fields required by Studio Pro: NumberOfPagesToClose2, PagesForSpecializations, -// and FormSettings.TitleOverride. -func TestPageClientAction_RequiredFields(t *testing.T) { - action := &pages.PageClientAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - PageName: "Sales.Order_Detail", - ParameterMappings: []*pages.PageClientParameterMapping{ - {ParameterName: "Order", Variable: "$Order"}, - {ParameterName: "Customer", Variable: "$Customer"}, - }, - } - - doc := serializeClientAction(action) - - fields := map[string]bool{} - for _, e := range doc { - fields[e.Key] = true - } - for _, required := range []string{"NumberOfPagesToClose2", "PagesForSpecializations"} { - if !fields[required] { - t.Errorf("Forms$FormAction missing required field %q", required) - } - } - - formSettings := getFormSettings(t, doc) - fsFields := map[string]bool{} - for _, e := range formSettings { - fsFields[e.Key] = true - } - if !fsFields["TitleOverride"] { - t.Errorf("FormSettings missing required field %q", "TitleOverride") - } - - // TitleOverride must be null. A button opening a page has no MDL syntax for - // overriding the opened page's title, so the page always keeps its own. - // - // This assertion was previously inverted, on the reasoning that Studio Pro rejects - // null embedded objects ("same class of bug as issue #295"). #295 was about - // Forms$PageVariable; the conclusion was generalised to TitleOverride without being - // observed. An empty Microflows$TextTemplate is not "no override" — it overrides - // with the empty string, so every popup opened by an mxcli-authored button showed a - // blank caption and only the close button (#812). - found := false - for _, e := range formSettings { - if e.Key != "TitleOverride" { - continue - } - found = true - if e.Value != nil { - t.Fatalf("TitleOverride = %#v, want nil (#812)", e.Value) - } - } - if !found { - t.Error("TitleOverride key missing entirely; Studio Pro writes it as an explicit null") - } -} - -// CapTrackV2 FINDINGS §10 — `ACTIONBUTTON … (Action: SIGN_OUT)` was refused by -// the default modelsdk engine with "client action *pages.SignOutClientAction -// not yet supported … rerun with MXCLI_ENGINE=legacy". -// -// That advice was the more dangerous of the two paths. The legacy writer had no -// case for the action either, so it fell through to the default below and wrote -// Forms$NoAction: the button rendered, said "Sign out", and did nothing, with -// `mxcli check`, `exec` and `mx check` all clean. Measured on Mendix 11.13 — -// `describe page` came back `actionbutton btnOut (Caption: 'Sign out')`, no -// action at all, and the stored BSON held Forms$NoAction. -// -// The shape is pinned against a Studio Pro-authored sign-out button -// (ako/TestApp), which is provably Studio Pro's rather than mxcli's: until this -// change NEITHER engine could emit the type. -func TestSignOutClientAction_IsNotSilentlyDroppedToNoAction(t *testing.T) { - doc := serializeClientAction(&pages.SignOutClientAction{ - BaseElement: model.BaseElement{ID: "action-id"}, - }) - if doc == nil { - t.Fatal("serializeClientAction returned nil") - } - - got := map[string]any{} - for _, e := range doc { - got[e.Key] = e.Value - } - - if got["$Type"] == "Forms$NoAction" { - t.Fatal("SIGN_OUT was written as Forms$NoAction — the button renders and does nothing, " + - "which check, exec and mx check all report as fine") - } - if got["$Type"] != "Forms$SignOutClientAction" { - t.Errorf("$Type = %v, want Forms$SignOutClientAction", got["$Type"]) - } - if got["DisabledDuringExecution"] != true { - t.Errorf("DisabledDuringExecution = %v, want true (the Studio Pro reference's only property)", - got["DisabledDuringExecution"]) - } - // The reference carries exactly these three keys and no more. An extra - // property is what Studio Pro refuses to open even when mxbuild accepts it. - if len(doc) != 3 { - t.Errorf("the action has %d keys, want 3 ($ID, $Type, DisabledDuringExecution): %v", len(doc), doc) - } -} - -// CONTROL: the quiet default still exists and still yields Forms$NoAction, so -// the tests here prove something about the actions they name rather than about -// the fallback having been removed. -// -// ShowHomePage is the stand-in: no MDL statement builds one, so it is a -// semantic type nothing writes. (An earlier draft used LinkClientAction, which -// stopped being a valid control the moment OPEN_LINK was implemented — a -// control has to name something still genuinely unhandled.) -func TestUnhandledClientActionStillFallsBackToNoAction(t *testing.T) { - doc := serializeClientAction(&pages.ShowHomePageClientAction{ - BaseElement: model.BaseElement{ID: "home-id"}, - }) - var typeName string - for _, e := range doc { - if e.Key == "$Type" { - typeName, _ = e.Value.(string) - } - } - if typeName != "Forms$NoAction" { - t.Errorf("$Type = %q; this control pins the fallback SIGN_OUT and OPEN_LINK used to hit, "+ - "so the tests above cannot pass for the wrong reason", typeName) - } -} - -// OPEN_LINK on the legacy engine, which fell to that same NoAction default. -// Pinned against the 31 Studio Pro references: five keys, and the address a -// nested Forms$StaticOrDynamicString whose AttributeRef is null for the static -// form MDL authors. -func TestOpenLinkClientAction_IsNotSilentlyDroppedToNoAction(t *testing.T) { - doc := serializeClientAction(&pages.LinkClientAction{ - BaseElement: model.BaseElement{ID: "link-id"}, - LinkType: pages.LinkTypeWeb, - Address: "https://example.com", - }) - got := map[string]any{} - for _, e := range doc { - got[e.Key] = e.Value - } - if got["$Type"] != "Forms$OpenLinkClientAction" { - t.Fatalf("$Type = %v, want Forms$OpenLinkClientAction (NOT Forms$LinkClientAction, "+ - "which is the SDK name and not what Mendix stores)", got["$Type"]) - } - if got["LinkType"] != "Web" { - t.Errorf("LinkType = %v, want Web", got["LinkType"]) - } - if len(doc) != 5 { - t.Errorf("the action has %d keys, want 5: %v", len(doc), doc) - } - addr, ok := got["Address"].(bson.D) - if !ok { - t.Fatalf("Address is %T, want a nested document", got["Address"]) - } - a := map[string]any{} - for _, e := range addr { - a[e.Key] = e.Value - } - if a["$Type"] != "Forms$StaticOrDynamicString" || a["IsDynamic"] != false || - a["Value"] != "https://example.com" { - t.Errorf("Address = %v", addr) - } - if _, present := a["AttributeRef"]; !present { - t.Error("AttributeRef is absent; all 31 references carry it as null") - } -} diff --git a/sdk/mpr/writer_widgets_container_test.go b/sdk/mpr/writer_widgets_container_test.go deleted file mode 100644 index 663f010a49..0000000000 --- a/sdk/mpr/writer_widgets_container_test.go +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Issue #603: a Container (Forms$DivContainer) is clickable via its -// OnClickAction. serializeContainer must wire the configured action through -// instead of always emitting Forms$NoAction. - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// bsonLookup returns the value of key in doc, or nil if absent. -func bsonLookup(doc bson.D, key string) any { - for _, e := range doc { - if e.Key == key { - return e.Value - } - } - return nil -} - -// bsonSubDoc returns doc[key] as a bson.D, failing the test if it is missing or -// not a sub-document. -func bsonSubDoc(t *testing.T, doc bson.D, key string) bson.D { - t.Helper() - v := bsonLookup(doc, key) - sub, ok := v.(bson.D) - if !ok { - t.Fatalf("field %q: want bson.D, got %T", key, v) - } - return sub -} - -// TestSerializeContainer_DynamicClasses locks in the DynamicClasses serialization fix: a widget's -// DynamicClasses expression is serialized into its Forms$Appearance -// (previously the field was hardcoded to ""). -func TestSerializeContainer_DynamicClasses(t *testing.T) { - c := &pages.Container{} - c.Name = "box" - c.Class = "ss-box" - c.DynamicClasses = "if $currentObject/Name = '' then 'ss-box--empty' else ''" - - doc := serializeContainer(c) - - appearance := bsonSubDoc(t, doc, "Appearance") - if got := bsonLookup(appearance, "DynamicClasses"); got != c.DynamicClasses { - t.Errorf("Appearance.DynamicClasses = %v, want %q", got, c.DynamicClasses) - } - if got := bsonLookup(appearance, "Class"); got != "ss-box" { - t.Errorf("Appearance.Class = %v, want %q", got, "ss-box") - } -} - -func TestSerializeContainer_OnClickActionDefaultsToNoAction(t *testing.T) { - c := &pages.Container{} - c.Name = "box" - - doc := serializeContainer(c) - - action := bsonSubDoc(t, doc, "OnClickAction") - if got := bsonLookup(action, "$Type"); got != "Forms$NoAction" { - t.Errorf("default OnClickAction $Type = %v, want Forms$NoAction", got) - } -} - -func TestSerializeContainer_OnClickActionMicroflow(t *testing.T) { - c := &pages.Container{ - OnClickAction: &pages.MicroflowClientAction{ - MicroflowName: "MyFirstModule.MyFirstLogic", - }, - } - c.Name = "box" - - doc := serializeContainer(c) - - action := bsonSubDoc(t, doc, "OnClickAction") - if got := bsonLookup(action, "$Type"); got != "Forms$MicroflowAction" { - t.Fatalf("OnClickAction $Type = %v, want Forms$MicroflowAction", got) - } - settings := bsonSubDoc(t, action, "MicroflowSettings") - if got := bsonLookup(settings, "Microflow"); got != "MyFirstModule.MyFirstLogic" { - t.Errorf("Microflow = %v, want MyFirstModule.MyFirstLogic", got) - } -} diff --git a/sdk/mpr/writer_widgets_custom.go b/sdk/mpr/writer_widgets_custom.go deleted file mode 100644 index 6e63b3f2a4..0000000000 --- a/sdk/mpr/writer_widgets_custom.go +++ /dev/null @@ -1,472 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================ -// Custom/Pluggable Widget Serialization -// ============================================================================ - -// serializeCustomWidget serializes a CustomWidget (pluggable widget) to BSON. -// If the widget has a RawType (cloned from existing widget), use that instead. -func serializeCustomWidget(cw *pages.CustomWidget) bson.D { - // Check if we have a raw type definition to use - if cw.RawType != nil { - return serializeCustomWidgetWithRawType(cw) - } - - // Build widget type from structured data - widgetType := serializeCustomWidgetType(cw.WidgetType) - - // Build widget object (properties) - widgetObject := serializeWidgetObject(cw.WidgetObject) - - editable := cw.Editable - if editable == "" { - editable = "Always" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(cw.ID))}, - {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, - {Key: "Appearance", Value: serializeAppearance(cw.Class, cw.Style, cw.DynamicClasses, cw.DesignProperties)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: editable}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(cw.Label)}, - {Key: "Name", Value: cw.Name}, - {Key: "Object", Value: widgetObject}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Type", Value: widgetType}, - } - - return doc -} - -// serializeCustomWidgetWithRawType serializes a CustomWidget using a pre-cloned raw type definition. -func serializeCustomWidgetWithRawType(cw *pages.CustomWidget) bson.D { - // Use the cloned RawObject if available (contains all property values) - // Otherwise fall back to building from WidgetObject with PropertyTypeIDMap - var widgetObject any - if cw.RawObject != nil { - widgetObject = cw.RawObject - } else { - // Build widget object (properties) - this still needs to match the raw type's PropertyType IDs - // The ObjectTypeID is used to set the TypePointer on the WidgetObject - widgetObject = serializeWidgetObjectForRawType(cw.WidgetObject, cw.PropertyTypeIDMap, cw.ObjectTypeID) - } - - editable := cw.Editable - if editable == "" { - editable = "Always" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(cw.ID))}, - {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, - {Key: "Appearance", Value: serializeAppearance(cw.Class, cw.Style, cw.DynamicClasses, cw.DesignProperties)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: editable}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(cw.Label)}, - {Key: "Name", Value: cw.Name}, - {Key: "Object", Value: widgetObject}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Type", Value: cw.RawType}, - } - - return doc -} - -// serializeWidgetObjectForRawType serializes WidgetObject using the PropertyType IDs from a cloned type. -// The objectTypeID parameter is used to set the TypePointer which references the WidgetObjectType. -func serializeWidgetObjectForRawType(wo *pages.WidgetObject, propTypeIDMap map[string]pages.PropertyTypeIDEntry, objectTypeID string) bson.D { - if wo == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: bson.A{int32(3)}}, - {Key: "TypePointer", Value: nil}, - } - } - - id := string(wo.ID) - if id == "" { - id = generateUUID() - } - - var properties bson.A - if len(wo.Properties) == 0 { - properties = bson.A{int32(3)} - } else { - properties = bson.A{int32(2)} // Version marker for non-empty array - for _, prop := range wo.Properties { - // Look up the PropertyType IDs from the map - var propertyTypeID, valueTypeID string - if propTypeIDMap != nil && prop.PropertyKey != "" { - if ids, ok := propTypeIDMap[prop.PropertyKey]; ok { - propertyTypeID = ids.PropertyTypeID - valueTypeID = ids.ValueTypeID - } - } - properties = append(properties, serializeWidgetPropertyForRawType(prop, propertyTypeID, valueTypeID)) - } - } - - // Build TypePointer - references the WidgetObjectType from the cloned CustomWidgetType - var typePointer any - if objectTypeID != "" { - typePointer = idToBsonBinary(objectTypeID) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: properties}, - {Key: "TypePointer", Value: typePointer}, - } -} - -// serializeWidgetPropertyForRawType serializes a widget property using specific PropertyType and ValueType IDs. -func serializeWidgetPropertyForRawType(prop *pages.WidgetProperty, propertyTypeID, valueTypeID string) bson.D { - if prop == nil { - return nil - } - - id := string(prop.ID) - if id == "" { - id = generateUUID() - } - - // Use the provided IDs, or fall back to the property's TypePointer - ptID := propertyTypeID - if ptID == "" { - ptID = string(prop.TypePointer) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: idToBsonBinary(ptID)}, - {Key: "Value", Value: serializeWidgetValueForRawType(prop.Value, valueTypeID)}, - } -} - -// serializeWidgetValueForRawType serializes a widget value using a specific ValueType ID. -func serializeWidgetValueForRawType(val *pages.WidgetValue, valueTypeID string) bson.D { - if val == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: serializeClientAction(nil)}, - {Key: "AttributeRef", Value: nil}, - {Key: "DataSource", Value: nil}, - {Key: "EntityRef", Value: nil}, - {Key: "Expression", Value: ""}, - {Key: "Form", Value: ""}, - {Key: "Icon", Value: nil}, - {Key: "Image", Value: ""}, - {Key: "Microflow", Value: ""}, - {Key: "Nanoflow", Value: ""}, - {Key: "Objects", Value: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: ""}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: idToBsonBinary(valueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - } - } - - id := string(val.ID) - if id == "" { - id = generateUUID() - } - - // Serialize DataSource if present - var dataSource any - if val.DataSource != nil { - dataSource = SerializeCustomWidgetDataSource(val.DataSource) - } - - // Serialize widgets if present - widgets := bson.A{int32(2)} - for _, w := range val.Widgets { - widgets = append(widgets, serializeWidget(w)) - } - - // Use the provided ValueType ID - var typePointer any - if valueTypeID != "" { - typePointer = idToBsonBinary(valueTypeID) - } else if val.TypePointer != "" { - typePointer = idToBsonBinary(string(val.TypePointer)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: serializeClientAction(val.Action)}, - {Key: "AttributeRef", Value: serializeAttributeRef(val.AttributeRef)}, - {Key: "DataSource", Value: dataSource}, - {Key: "EntityRef", Value: serializeEntityRef(val.EntityRef)}, - {Key: "Expression", Value: val.Expression}, - {Key: "Form", Value: val.Form}, - {Key: "Icon", Value: nil}, - {Key: "Image", Value: val.Image}, - {Key: "Microflow", Value: val.Microflow}, - {Key: "Nanoflow", Value: val.Nanoflow}, - {Key: "Objects", Value: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: val.PrimitiveValue}, - {Key: "Selection", Value: val.Selection}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: typePointer}, - {Key: "Widgets", Value: widgets}, - } -} - -// serializeCustomWidgetType serializes the CustomWidgetType. -func serializeCustomWidgetType(wt *pages.CustomWidgetType) bson.D { - if wt == nil { - return nil - } - - id := string(wt.ID) - if id == "" { - id = generateUUID() - } - - objectTypeID := generateUUID() - - supportedPlatform := wt.SupportedPlatform - if supportedPlatform == "" { - supportedPlatform = "Web" - } - - // Use the ObjectType ID from wt if available - otID := objectTypeID - if wt.ObjectType != nil && wt.ObjectType.ID != "" { - otID = string(wt.ObjectType.ID) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$CustomWidgetType"}, - {Key: "HelpUrl", Value: wt.HelpURL}, - {Key: "ObjectType", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(otID)}, - {Key: "$Type", Value: "CustomWidgets$WidgetObjectType"}, - {Key: "PropertyTypes", Value: serializePropertyTypes(wt.ObjectType)}, - }}, - {Key: "OfflineCapable", Value: wt.OfflineCapable}, - {Key: "StudioCategory", Value: ""}, - {Key: "StudioProCategory", Value: ""}, - {Key: "SupportedPlatform", Value: supportedPlatform}, - {Key: "WidgetDescription", Value: wt.Description}, - {Key: "WidgetId", Value: wt.WidgetID}, - {Key: "WidgetName", Value: wt.Name}, - {Key: "WidgetNeedsEntityContext", Value: wt.NeedsEntityContext}, - {Key: "WidgetPluginWidget", Value: wt.PluginWidget}, - } - - return doc -} - -// serializePropertyTypes serializes the property types for a widget. -func serializePropertyTypes(ot *pages.WidgetObjectType) bson.A { - if ot == nil || len(ot.PropertyTypes) == 0 { - return bson.A{int32(3)} - } - - arr := bson.A{int32(2)} // Version marker for non-empty array - - for _, pt := range ot.PropertyTypes { - id := string(pt.ID) - if id == "" { - id = generateUUID() - } - // Use the ValueTypeID if provided, otherwise generate a new one - vtID := string(pt.ValueTypeID) - if vtID == "" { - vtID = generateUUID() - } - arr = append(arr, bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetPropertyType"}, - {Key: "Caption", Value: pt.Caption}, - {Key: "Category", Value: ""}, - {Key: "Description", Value: pt.Description}, - {Key: "IsDefault", Value: pt.IsDefault}, - {Key: "PropertyKey", Value: pt.Key}, - {Key: "ValueType", Value: serializeWidgetValueType(vtID, pt.ValueType)}, - }) - } - - return arr -} - -// serializeWidgetValueType serializes a WidgetValueType for a property type. -// The id parameter is the ValueTypeID that WidgetValue.TypePointer should reference. -// The valueType string is converted to the appropriate Type enum value. -func serializeWidgetValueType(id string, valueType string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetValueType"}, - {Key: "ActionVariables", Value: bson.A{int32(2)}}, - {Key: "AllowedTypes", Value: bson.A{int32(1)}}, - {Key: "AllowNonPersistableEntities", Value: false}, - {Key: "AllowUpload", Value: false}, - {Key: "AssociationTypes", Value: bson.A{int32(1)}}, - {Key: "DataSourceProperty", Value: ""}, - {Key: "DefaultType", Value: "None"}, - {Key: "DefaultValue", Value: ""}, - {Key: "EntityProperty", Value: ""}, - {Key: "EnumerationValues", Value: bson.A{int32(2)}}, - {Key: "IsList", Value: false}, - {Key: "IsPath", Value: "No"}, - {Key: "LinkableEntityTypes", Value: bson.A{int32(1)}}, - {Key: "MicroflowActionInfo", Value: nil}, - {Key: "ObjectType", Value: nil}, - {Key: "OnChangeProperty", Value: ""}, - {Key: "PathType", Value: "None"}, - {Key: "ReturnType", Value: nil}, - {Key: "SelectableObjectsProperty", Value: ""}, - {Key: "SelectionTypes", Value: bson.A{int32(1)}}, - {Key: "SetLabel", Value: false}, - {Key: "Translations", Value: bson.A{int32(2)}}, - {Key: "Type", Value: valueType}, - } -} - -// serializeWidgetObject serializes the WidgetObject (property values). -func serializeWidgetObject(wo *pages.WidgetObject) bson.D { - if wo == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: bson.A{int32(3)}}, - } - } - - id := string(wo.ID) - if id == "" { - id = generateUUID() - } - - var properties bson.A - if len(wo.Properties) == 0 { - properties = bson.A{int32(3)} - } else { - properties = bson.A{int32(2)} // Version marker for non-empty array - for _, prop := range wo.Properties { - properties = append(properties, serializeWidgetProperty(prop)) - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: properties}, - } -} - -// serializeWidgetProperty serializes a single widget property. -func serializeWidgetProperty(prop *pages.WidgetProperty) bson.D { - if prop == nil { - return nil - } - - id := string(prop.ID) - if id == "" { - id = generateUUID() - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: idToBsonBinary(string(prop.TypePointer))}, - {Key: "Value", Value: serializeWidgetValue(prop.Value)}, - } -} - -// serializeWidgetValue serializes a widget property value. -func serializeWidgetValue(val *pages.WidgetValue) bson.D { - if val == nil { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: serializeClientAction(nil)}, - {Key: "AttributeRef", Value: nil}, - {Key: "DataSource", Value: nil}, - {Key: "EntityRef", Value: nil}, - {Key: "Expression", Value: ""}, - {Key: "Form", Value: ""}, - {Key: "Icon", Value: nil}, - {Key: "Image", Value: ""}, - {Key: "Microflow", Value: ""}, - {Key: "Nanoflow", Value: ""}, - {Key: "Objects", Value: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: ""}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: nil}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - } - } - - id := string(val.ID) - if id == "" { - id = generateUUID() - } - - // Serialize DataSource if present - var dataSource any - if val.DataSource != nil { - dataSource = SerializeCustomWidgetDataSource(val.DataSource) - } - - // Serialize widgets if present - widgets := bson.A{int32(2)} - for _, w := range val.Widgets { - widgets = append(widgets, serializeWidget(w)) - } - - // TypePointer should be null when not set - var typePointer any - if val.TypePointer != "" { - typePointer = idToBsonBinary(string(val.TypePointer)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: serializeClientAction(val.Action)}, - {Key: "AttributeRef", Value: serializeAttributeRef(val.AttributeRef)}, - {Key: "DataSource", Value: dataSource}, - {Key: "EntityRef", Value: serializeEntityRef(val.EntityRef)}, - {Key: "Expression", Value: val.Expression}, - {Key: "Form", Value: val.Form}, - {Key: "Icon", Value: nil}, - {Key: "Image", Value: val.Image}, - {Key: "Microflow", Value: val.Microflow}, - {Key: "Nanoflow", Value: val.Nanoflow}, - {Key: "Objects", Value: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: val.PrimitiveValue}, - {Key: "Selection", Value: val.Selection}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: typePointer}, - {Key: "Widgets", Value: widgets}, - } -} diff --git a/sdk/mpr/writer_widgets_display.go b/sdk/mpr/writer_widgets_display.go deleted file mode 100644 index 0e4106374d..0000000000 --- a/sdk/mpr/writer_widgets_display.go +++ /dev/null @@ -1,970 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// serializeSnippetCall serializes a SnippetCallWidget. -func serializeSnippetCall(s *pages.SnippetCallWidget) bson.D { - // Build parameter mappings array. - // Format: [count, mapping1, mapping2, ...] where count is the Mendix array version marker. - // Type is Forms$SnippetParameterMapping (not Forms$PageParameterMapping). - // The variable reference goes in Variable.PageParameter; Argument is always empty. - paramMappings := bson.A{int32(len(s.ParameterMappings))} - for _, pm := range s.ParameterMappings { - // Parameter is BY_NAME_REFERENCE: SnippetQualifiedName.ParameterName - paramRef := s.SnippetName + "." + pm.ParamName - // Strip leading $ from the variable name for PageParameter sub-field - varName := strings.TrimPrefix(pm.Argument, "$") - paramMappings = append(paramMappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$SnippetParameterMapping"}, - {Key: "Argument", Value: ""}, - {Key: "Parameter", Value: paramRef}, - {Key: "Variable", Value: buildFormPageVariable(varName)}, - }) - } - - // Build the inner SnippetCall object - snippetCallID := generateUUID() - snippetCall := bson.D{ - {Key: "$ID", Value: idToBsonBinary(snippetCallID)}, - {Key: "$Type", Value: "Forms$SnippetCall"}, - {Key: "ParameterMappings", Value: paramMappings}, - } - - // Add snippet reference - prefer qualified name (BY_NAME_REFERENCE) over binary ID - if s.SnippetName != "" { - snippetCall = append(snippetCall, bson.E{Key: "Form", Value: s.SnippetName}) - } else if s.SnippetID != "" { - snippetCall = append(snippetCall, bson.E{Key: "Form", Value: idToBsonBinary(string(s.SnippetID))}) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(s.ID))}, - {Key: "$Type", Value: "Forms$SnippetCallWidget"}, - {Key: "Appearance", Value: serializeAppearance(s.Class, s.Style, s.DynamicClasses, s.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "FormCall", Value: snippetCall}, - {Key: "Name", Value: s.Name}, - {Key: "TabIndex", Value: int64(0)}, - } - - return doc -} - -// serializeGallery serializes a Gallery widget as Forms$ListView. -// Note: Forms$Gallery is not available in all Mendix versions, so we use ListView as a fallback. -// ListView provides similar grid-based item display functionality. -func serializeGallery(g *pages.Gallery) bson.D { - // Default values - pageSize := g.PageSize - if pageSize == 0 { - pageSize = 20 - } - numberOfColumns := g.DesktopItems - if numberOfColumns == 0 { - numberOfColumns = 4 - } - - // Serialize datasource - Gallery (as ListView) requires a non-null DataSource - var dataSource any - if g.DataSource != nil { - dataSource = serializeListViewDataSource(g.DataSource) - } - // Fallback: provide empty ListViewXPathSource to prevent Studio Pro crash - if dataSource == nil { - dataSource = emptyListViewXPathSource() - } - - // Build content widgets - contentWidgets := bson.A{int32(3)} - if g.ContentWidget != nil { - contentWidgets = append(contentWidgets, serializeWidget(g.ContentWidget)) - } - - // Templates array (empty for basic ListView) - templates := bson.A{int32(3)} - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(g.ID))}, - {Key: "$Type", Value: "Forms$ListView"}, - {Key: "Appearance", Value: serializeAppearance(g.Class, g.Style, g.DynamicClasses, g.DesignProperties)}, - {Key: "ClickAction", Value: serializeClientAction(nil)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DataSource", Value: dataSource}, - {Key: "Editable", Value: false}, - {Key: "Name", Value: g.Name}, - {Key: "NumberOfColumns", Value: int64(numberOfColumns)}, - {Key: "PageSize", Value: int64(pageSize)}, - {Key: "PullDownAction", Value: serializeClientAction(nil)}, - {Key: "ScrollDirection", Value: "Vertical"}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Templates", Value: templates}, - {Key: "Widgets", Value: contentWidgets}, - } - - return doc -} - -// serializeListView serializes a ListView widget. -func serializeListView(lv *pages.ListView) bson.D { - // Default values - pageSize := lv.PageSize - if pageSize == 0 { - pageSize = 20 - } - - // Serialize datasource - ListView requires a non-null DataSource (EntityWidget) - var dataSource any - if lv.DataSource != nil { - dataSource = serializeListViewDataSource(lv.DataSource) - } - // Fallback: provide empty ListViewXPathSource to prevent Studio Pro crash - if dataSource == nil { - dataSource = emptyListViewXPathSource() - } - - // Build content widgets - contentWidgets := serializeWidgetArray(lv.Widgets) - - // Templates array - templates := bson.A{int32(3)} - if len(lv.Templates) > 0 { - templates = bson.A{int32(2)} - for _, t := range lv.Templates { - templateWidgets := bson.A{int32(3)} - if len(t.Widgets) > 0 { - templateWidgets = bson.A{int32(2)} - for _, w := range t.Widgets { - templateWidgets = append(templateWidgets, serializeWidget(w)) - } - } - // Key order and names match Studio Pro's own documents: $ID, $Type, - // Entity, Widgets. "Entity" is the storage name of the SDK's - // Specialization property — see pages.ListViewTemplate. - template := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(t.ID))}, - {Key: "$Type", Value: "Forms$ListViewTemplate"}, - {Key: "Entity", Value: t.Specialization}, - {Key: "Widgets", Value: templateWidgets}, - } - templates = append(templates, template) - } - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(lv.ID))}, - {Key: "$Type", Value: "Forms$ListView"}, - {Key: "Appearance", Value: serializeAppearance(lv.Class, lv.Style, lv.DynamicClasses, lv.DesignProperties)}, - {Key: "ClickAction", Value: serializeClientAction(lv.ClickAction)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DataSource", Value: dataSource}, - {Key: "Editable", Value: lv.Editable}, - {Key: "Name", Value: lv.Name}, - {Key: "NumberOfColumns", Value: int64(1)}, - {Key: "PageSize", Value: int64(pageSize)}, - {Key: "PullDownAction", Value: serializeClientAction(nil)}, - {Key: "ScrollDirection", Value: "Vertical"}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Templates", Value: templates}, - {Key: "Widgets", Value: contentWidgets}, - } - - return doc -} - -// emptyListViewXPathSource is the fallback empty database source used when a -// ListView (or Gallery-as-ListView) has no datasource yet. It must carry the same -// metamodel-valid shape as a populated source — a Forms$GridSortBar with SortItems -// and a Forms$ListViewSearch with SearchRefs — so the Mendix client can read -// .length of those lists instead of crashing on an absent/misnamed array. -func emptyListViewXPathSource() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ListViewXPathSource"}, - {Key: "ForceFullObjects", Value: false}, - {Key: "EntityRef", Value: nil}, - {Key: "SortBar", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: bson.A{int32(2)}}, - }}, - {Key: "Search", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ListViewSearch"}, - {Key: "SearchRefs", Value: bson.A{int32(3)}}, - }}, - {Key: "XPathConstraint", Value: ""}, - } -} - -// serializeListViewDataSource serializes a datasource for ListView widgets. -// Supports DatabaseSource (XPath), MicroflowSource, NanoflowSource, and AssociationSource. -func serializeListViewDataSource(ds pages.DataSource) bson.D { - if ds == nil { - return nil - } - - switch d := ds.(type) { - case *pages.DatabaseSource: - // EntityRef for database source - use EntityName (qualified name) not EntityID - var entityRef any - if d.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: d.EntityName}, - } - } - // Sorting lives on a Forms$GridSortBar / Forms$GridSortItem list (SortItems), - // exactly like the pluggable CustomWidgetXPathSource — NOT a Forms$ListViewSort. - // ListViewXPathSource has no `Sort` property; emitting one (and a `Paths` key on - // Search) produced a datasource whose client model omitted the arrays the Mendix - // client reads .length of, crashing retrieveByXPath/processResult. Mirror the - // GridSortBar shape and use the metamodel's SearchRefs list (searchPaths was - // removed in 7.11.0). - sortItems := bson.A{int32(2)} // typed-array marker, matches Studio Pro even when empty - for _, sort := range d.Sorting { - sortItems = append(sortItems, bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortItem"}, - {Key: "AttributeRef", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: sort.AttributePath}, - {Key: "EntityRef", Value: nil}, - }}, - {Key: "SortDirection", Value: string(sort.Direction)}, - }) - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$ListViewXPathSource"}, - {Key: "ForceFullObjects", Value: false}, - {Key: "EntityRef", Value: entityRef}, - {Key: "SortBar", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: sortItems}, - }}, - {Key: "Search", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ListViewSearch"}, - {Key: "SearchRefs", Value: bson.A{int32(3)}}, - }}, - {Key: "XPathConstraint", Value: d.XPathConstraint}, - } - case *pages.MicroflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$MicroflowSource"}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Asynchronous", Value: false}, - {Key: "ConfirmationInfo", Value: nil}, - {Key: "FormValidations", Value: "All"}, - {Key: "Microflow", Value: d.Microflow}, - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: nil}, - }}, - } - case *pages.NanoflowSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$NanoflowSource"}, - {Key: "NanoflowSettings", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NanoflowSettings"}, - {Key: "Nanoflow", Value: d.Nanoflow}, - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - }}, - } - case *pages.AssociationSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$AssociationSource"}, - {Key: "EntityRef", Value: nil}, - } - default: - return nil - } -} - -// serializeDynamicText serializes a DynamicText widget. -func serializeDynamicText(dt *pages.DynamicText) bson.D { - renderMode := string(dt.RenderMode) - if renderMode == "" { - renderMode = "Text" - } - - // Create fallback text from AttributePath for backward compatibility - var fallbackText *model.Text - if dt.AttributePath != "" && dt.Content == nil { - fallbackText = &model.Text{ - Translations: map[string]string{model.AuthoringLanguage(): dt.AttributePath}, - } - } - - // Build content as ClientTemplate - content := serializeClientTemplate(dt.Content, fallbackText, "Dynamic Text") - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dt.ID))}, - {Key: "$Type", Value: "Forms$DynamicText"}, - {Key: "Appearance", Value: serializeAppearance(dt.Class, dt.Style, dt.DynamicClasses, dt.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Content", Value: content}, - {Key: "Name", Value: dt.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "NativeTextStyle", Value: "Text"}, - {Key: "RenderMode", Value: renderMode}, - {Key: "TabIndex", Value: int64(0)}, - } - return doc -} - -// serializeActionButton serializes an ActionButton widget. -func serializeActionButton(ab *pages.ActionButton) bson.D { - buttonStyle := string(ab.ButtonStyle) - if buttonStyle == "" { - buttonStyle = "Default" - } - - // RenderType distinguishes a normal action button ("Button") from a - // link-rendered one ("Link", authored as `linkbutton`). - renderType := string(ab.RenderMode) - if renderType == "" { - renderType = "Button" - } - - // Build caption as ClientTemplate - caption := serializeClientTemplate(ab.CaptionTemplate, ab.Caption, "Button") - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ab.ID))}, - {Key: "$Type", Value: "Forms$ActionButton"}, - {Key: "Action", Value: serializeClientAction(ab.Action)}, - {Key: "Appearance", Value: serializeAppearance(ab.Class, ab.Style, ab.DynamicClasses, ab.DesignProperties)}, - {Key: "AriaRole", Value: "Button"}, - {Key: "ButtonStyle", Value: buttonStyle}, - {Key: "CaptionTemplate", Value: caption}, // Must be CaptionTemplate, not Caption - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Icon", Value: buildWidgetIconBson(ab.Icon)}, - {Key: "Name", Value: ab.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "RenderType", Value: renderType}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Tooltip", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - } - return doc -} - -// buildWidgetIconBson serializes a widget's icon element, or nil when there is -// none. -// -// This key was hardcoded to nil, so under `--engine legacy` a button's icon was -// dropped on every write — silently, since a null Icon is what an iconless -// button stores and nothing downstream could tell the two apart. The icon- -// collection form has been authorable since #602 and was only ever written by -// the modelsdk engine. -// -// It dispatches on the kind for the same reason buildMenuIconBson does: an -// icon-collection icon and an image icon are both a qualified name, into -// different documents, so nothing in the payload distinguishes them and a writer -// that guesses turns one into the other (mendixlabs/mxcli#1059). -func buildWidgetIconBson(icon *pages.Icon) interface{} { - if icon == nil { - return nil - } - storage := types.MenuIconStorageType(icon.Kind) - if storage == "" { - return nil - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: storage}, - } - if icon.Kind == types.MenuIconGlyph { - // A glyph with no code identifies no glyph. Emit no icon rather than an - // element nobody can see. - if icon.Code == 0 { - return nil - } - return append(doc, bson.E{Key: "Code", Value: int32(icon.Code)}) - } - if icon.Image == "" { - return nil - } - return append(doc, bson.E{Key: "Image", Value: icon.Image}) -} - -// serializeStaticText serializes a static Text widget. -func serializeStaticText(t *pages.Text) bson.D { - textValue := "Text" - if t.Caption != nil { - for _, text := range t.Caption.Translations { - textValue = text - break - } - } - - renderMode := string(t.RenderMode) - if renderMode == "" { - renderMode = "Text" - } - - // Mendix uses [3] as version marker, followed by array items - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(t.ID))}, - {Key: "$Type", Value: "Forms$Text"}, - {Key: "Appearance", Value: serializeAppearance(t.Class, t.Style, t.DynamicClasses, t.DesignProperties)}, - {Key: "Caption", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: model.AuthoringLanguage()}, - {Key: "Text", Value: textValue}, - }}}, - }}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: t.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "NativeTextStyle", Value: "Text"}, - {Key: "RenderMode", Value: renderMode}, - {Key: "TabIndex", Value: int64(0)}, - } - return doc -} - -// serializeTitle serializes a Title widget. -func serializeTitle(t *pages.Title) bson.D { - textValue := "Title" - if t.Caption != nil { - for _, text := range t.Caption.Translations { - textValue = text - break - } - } - - // Mendix uses [3] as version marker, followed by array items - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(t.ID))}, - {Key: "$Type", Value: "Forms$Title"}, - {Key: "Appearance", Value: serializeAppearance(t.Class, t.Style, t.DynamicClasses, t.DesignProperties)}, - {Key: "Caption", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: model.AuthoringLanguage()}, - {Key: "Text", Value: textValue}, - }}}, - }}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: t.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "TabIndex", Value: int64(0)}, - } - return doc -} - -// dataViewLabelWidth resolves the LabelWidth to write to BSON. The rule lives on -// the model (pages.DataView.ResolvedLabelWidth) so this writer and the modelsdk one -// cannot drift — only this one used to translate FormOrientation, which is how -// `FormOrientation: Vertical` came to be silently dropped on the default engine -// (mendixlabs/mxcli#762). -func dataViewLabelWidth(dv *pages.DataView) int64 { - return int64(dv.ResolvedLabelWidth()) -} - -// serializeDataView serializes a DataView widget with all required properties. -func serializeDataView(dv *pages.DataView) bson.D { - // Build NoEntityMessage as Texts$Text - noEntityMessage := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - } - - // Build data source - DataView requires a non-null DataSource (EntityWidget) - var dataSource any - if dv.DataSource != nil { - dataSource = serializeDataViewDataSource(dv.DataSource) - } - // Fallback: provide empty DataViewSource to prevent Studio Pro crash - if dataSource == nil { - dataSource = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$DataViewSource"}, - {Key: "EntityRef", Value: nil}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SourceVariable", Value: nil}, - } - } - - // Build widgets - widgets := serializeWidgetArray(dv.Widgets) - - // Build footer widgets - footerWidgets := serializeWidgetArray(dv.FooterWidgets) - - // Determine editability - editability := "Always" - if dv.ReadOnly { - editability = "Never" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dv.ID))}, - {Key: "$Type", Value: "Forms$DataView"}, - {Key: "Appearance", Value: serializeAppearance(dv.Class, dv.Style, dv.DynamicClasses, dv.DesignProperties)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DataSource", Value: dataSource}, - {Key: "Editability", Value: editability}, - {Key: "FooterWidgets", Value: footerWidgets}, - {Key: "LabelWidth", Value: dataViewLabelWidth(dv)}, - {Key: "Name", Value: dv.Name}, - {Key: "NoEntityMessage", Value: noEntityMessage}, - {Key: "ReadOnlyStyle", Value: "Control"}, - {Key: "ShowFooter", Value: dv.ShowFooter}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Widgets", Value: widgets}, - } - - return doc -} - -// serializeDataViewDataSource serializes a data source for DataView widgets. -// DataView requires Forms$DataViewSource with EntityRef and SourceVariable for parameter references. -func serializeDataViewDataSource(ds pages.DataSource) any { - if ds == nil { - return nil - } - - switch d := ds.(type) { - case *pages.DataViewSource: - // DataView using page parameter - needs Forms$DataViewSource with EntityRef and SourceVariable - var entityRef any - if d.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: d.EntityName}, - } - } - - // Build SourceVariable as Forms$PageVariable - var sourceVariable any - if d.ParameterName != "" { - // Determine if this is a snippet parameter or page parameter - pageParam := d.ParameterName - snippetParam := "" - if d.IsSnippetParameter { - pageParam = "" - snippetParam = d.ParameterName - } - sourceVariable = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$PageVariable"}, - {Key: "LocalVariable", Value: ""}, - {Key: "PageParameter", Value: pageParam}, - {Key: "SnippetParameter", Value: snippetParam}, - {Key: "SubKey", Value: ""}, - {Key: "UseAllPages", Value: false}, - {Key: "Widget", Value: ""}, - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$DataViewSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SourceVariable", Value: sourceVariable}, - } - case *pages.DatabaseSource: - // For database source in DataView, use standard serialization - return serializeDataSource(d) - case *pages.MicroflowSource: - return serializeDataSource(d) - case *pages.NanoflowSource: - return serializeDataSource(d) - case *pages.ListenToWidgetSource: - // ListenTargetSource - listens to another widget's selection - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$ListenTargetSource"}, - {Key: "ForceFullObjects", Value: false}, - {Key: "ListenTarget", Value: d.WidgetName}, - } - case *pages.AssociationSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$AssociationSource"}, - {Key: "EntityRef", Value: nil}, - } - default: - // Fallback to generic datasource serialization - return nil - } -} - -// serializeDataGrid serializes a DataGrid widget with columns. -func serializeDataGrid(dg *pages.DataGrid) bson.D { - // Build data source - DataGrid requires a non-null DataSource (EntityWidget) - var dataSource any - if dg.DataSource != nil { - dataSource = serializeDataGridDataSource(dg.DataSource) - } - // Fallback: provide empty NewGridDatabaseSource to prevent Studio Pro crash - if dataSource == nil { - dataSource = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$NewGridDatabaseSource"}, - {Key: "EntityRef", Value: nil}, - {Key: "SortBar", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: bson.A{int32(3)}}, - }}, - {Key: "XPathConstraint", Value: ""}, - } - } - - // Build columns - columns := bson.A{int32(3)} // Start with empty marker - if len(dg.Columns) > 0 { - columns = bson.A{int32(2)} - for _, col := range dg.Columns { - columns = append(columns, serializeDataGridColumn(col)) - } - } - - // Build control bar widgets - controlBarWidgets := serializeWidgetArray(dg.ControlBarWidgets) - - // Selection mode - selectionMode := "Single" - switch dg.SelectionMode { - case pages.SelectionModeMulti: - selectionMode = "Multi" - case pages.SelectionModeNone: - selectionMode = "No" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dg.ID))}, - {Key: "$Type", Value: "Forms$DataGrid"}, - {Key: "Appearance", Value: serializeAppearance(dg.Class, dg.Style, dg.DynamicClasses, dg.DesignProperties)}, - {Key: "ClickAction", Value: serializeClientAction(nil)}, - {Key: "Columns", Value: columns}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "ControlBar", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ControlBar"}, - {Key: "DefaultButton", Value: nil}, - {Key: "Widgets", Value: controlBarWidgets}, - }}, - {Key: "DataSource", Value: dataSource}, - {Key: "IsControlBarVisible", Value: len(dg.ControlBarWidgets) > 0}, - {Key: "Name", Value: dg.Name}, - {Key: "NumberOfRows", Value: int64(20)}, - {Key: "RefreshTime", Value: int64(0)}, - {Key: "SelectFirst", Value: dg.SelectFirst}, - {Key: "SelectionMode", Value: selectionMode}, - {Key: "ShowEmptyRows", Value: dg.ShowEmptyRows}, - {Key: "ShowPagingBar", Value: "YesWithTotalCount"}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "TooltipForm", Value: nil}, - {Key: "WidthUnit", Value: "Percentage"}, - } - - return doc -} - -// serializeDataGridColumn serializes a DataGridColumn. -func serializeDataGridColumn(col *pages.DataGridColumn) bson.D { - // Build caption text - var caption any - if col.Caption != nil { - caption = serializeText(col.Caption) - } else { - caption = serializeEmptyText() - } - - // Build attribute reference - var attrRef any - if col.AttributePath != "" { - attrRef = serializeAttributeRef(col.AttributePath) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(col.ID))}, - {Key: "$Type", Value: "Forms$DataGridColumn"}, - {Key: "AggregateCaption", Value: serializeEmptyText()}, - {Key: "AggregateFunction", Value: "None"}, - {Key: "Appearance", Value: serializeAppearance("", "", "", nil)}, - {Key: "AttributeRef", Value: attrRef}, - {Key: "Caption", Value: caption}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "Editable", Value: col.Editable}, - {Key: "FormatType", Value: "Attribute"}, - {Key: "Name", Value: col.Name}, - {Key: "ShowTooltip", Value: true}, - {Key: "Width", Value: int64(100)}, - } - - return doc -} - -// serializeDataGridDataSource serializes a data source for DataGrid widgets. -func serializeDataGridDataSource(ds pages.DataSource) any { - if ds == nil { - return nil - } - - switch d := ds.(type) { - case *pages.DatabaseSource: - // Build entity reference - var entityRef any - if d.EntityName != "" { - entityRef = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "DomainModels$IndirectEntityRef"}, - {Key: "Entity", Value: d.EntityName}, - } - } - - // Build sort bar - var sortBar any - if len(d.Sorting) > 0 { - sortItems := bson.A{int32(2)} - for _, sort := range d.Sorting { - sortDir := "Ascending" - if sort.Direction == pages.SortDirectionDescending { - sortDir = "Descending" - } - sortItem := bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSort"}, - {Key: "AttributeRef", Value: serializeAttributeRef(sort.AttributePath)}, - {Key: "SortOrder", Value: sortDir}, - } - sortItems = append(sortItems, sortItem) - } - sortBar = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: sortItems}, - } - } else { - sortBar = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$GridSortBar"}, - {Key: "SortItems", Value: bson.A{int32(3)}}, - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(d.ID))}, - {Key: "$Type", Value: "Forms$NewGridDatabaseSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "SortBar", Value: sortBar}, - {Key: "XPathConstraint", Value: d.XPathConstraint}, - } - default: - return nil - } -} - -// serializeNavigationList serializes a NavigationList widget. -func serializeNavigationList(nl *pages.NavigationList) bson.D { - // Build items array - items := bson.A{int32(3)} // Empty marker - hasItems := false - for _, item := range nl.Items { - if !hasItems { - items = bson.A{int32(2)} // First item: change to version 2 - hasItems = true - } - items = append(items, serializeNavigationListItem(item)) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(nl.ID))}, - {Key: "$Type", Value: "Forms$NavigationList"}, - {Key: "Appearance", Value: serializeAppearance(nl.Class, nl.Style, nl.DynamicClasses, nl.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Items", Value: items}, - {Key: "Name", Value: nl.Name}, - {Key: "TabIndex", Value: int64(0)}, - } - return doc -} - -// serializeNavigationListItem serializes a NavigationListItem. -func serializeNavigationListItem(item *pages.NavigationListItem) bson.D { - var widgets bson.A - - if len(item.Widgets) > 0 { - // Item has explicit child widgets - serialize them directly - widgets = bson.A{int32(2)} - for _, w := range item.Widgets { - widgetDoc := serializeWidget(w) - if widgetDoc != nil { - widgets = append(widgets, widgetDoc) - } - } - } else { - // No explicit widgets - create a DynamicText from the Caption field - captionText := "Item" - if item.Caption != nil { - for _, text := range item.Caption.Translations { - captionText = text - break - } - } - - dt := &pages.DynamicText{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: model.ID(generateUUID()), - TypeName: "Forms$DynamicText", - }, - Name: "text_" + item.Name, - }, - Content: &pages.ClientTemplate{ - BaseElement: model.BaseElement{ - ID: model.ID(generateUUID()), - TypeName: "Forms$ClientTemplate", - }, - Template: &model.Text{ - BaseElement: model.BaseElement{ - ID: model.ID(generateUUID()), - TypeName: "Texts$Text", - }, - Translations: map[string]string{model.AuthoringLanguage(): captionText}, - }, - }, - RenderMode: pages.TextRenderModeText, - } - widgets = bson.A{int32(2), serializeDynamicText(dt)} - } - - // Build action - action := serializeClientAction(item.Action) - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(item.ID))}, - {Key: "$Type", Value: "Forms$NavigationListItem"}, - {Key: "Action", Value: action}, - {Key: "Appearance", Value: serializeAppearance("", "", "", nil)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: item.Name}, - {Key: "Widgets", Value: widgets}, - } -} - -// serializeStaticImage serializes a StaticImage widget. -func serializeStaticImage(img *pages.StaticImage) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(img.ID))}, - {Key: "$Type", Value: "Forms$StaticImageViewer"}, - // AlternativeText is not optional — generated/metamodel declares it - // without omitempty and all three Studio-Pro-authored static images in - // ako/TestApp carry it. It used to be omitted here. - {Key: "AlternativeText", Value: emptyAlternativeText()}, - {Key: "Appearance", Value: serializeAppearance(img.Class, img.Style, img.DynamicClasses, img.DesignProperties)}, - {Key: "ClickAction", Value: serializeClientAction(img.OnClickAction)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Height", Value: int64(img.Height)}, - {Key: "HeightUnit", Value: "Auto"}, - // An unset by-name reference is "", never null: measured 0 nulls against - // 4,400+ empty strings over 40 (type, property) pairs in ako/TestApp. - {Key: "Image", Value: ""}, - {Key: "Name", Value: img.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "Responsive", Value: img.Responsive}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Width", Value: int64(img.Width)}, - {Key: "WidthUnit", Value: "Auto"}, - } - return doc -} - -// emptyAlternativeText is the Forms$ClientTemplate an image widget carries when -// no alternative text has been set — an empty Template, an empty Fallback and no -// parameters. -// -// Pinned to the three Studio-Pro-authored Forms$StaticImageViewer widgets in -// ako/TestApp (FeedbackModule). The dynamic image used to build its own version -// of this carrying a "FallbackValue" string instead: Forms$ClientTemplate has no -// such property (generated/metamodel: Fallback / Parameters / Template), and an -// invented key is the failure Studio Pro reports as "Sequence contains no -// matching element" while mxbuild builds it at 0 errors. Note the empty -// Parameters list takes marker 2, not the 3 an empty Texts$Text takes. -func emptyAlternativeText() bson.D { - emptyText := func() bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: emptyText()}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - {Key: "Template", Value: emptyText()}, - } -} - -// serializeDynamicImage serializes a DynamicImage widget. -func serializeDynamicImage(img *pages.DynamicImage) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(img.ID))}, - {Key: "$Type", Value: "Forms$ImageViewer"}, - {Key: "AlternativeText", Value: emptyAlternativeText()}, - {Key: "Appearance", Value: serializeAppearance(img.Class, img.Style, img.DynamicClasses, img.DesignProperties)}, - {Key: "ClickAction", Value: serializeClientAction(img.OnClickAction)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DataSource", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Forms$ImageViewerSource"}, - {Key: "EntityRef", Value: nil}, - }}, - // "" not null — an unset by-name reference; see serializeStaticImage. - {Key: "DefaultImage", Value: ""}, - {Key: "Height", Value: int64(img.Height)}, - {Key: "HeightUnit", Value: "Auto"}, - {Key: "Name", Value: img.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnClickEnlarge", Value: false}, - {Key: "Responsive", Value: img.Responsive}, - {Key: "ShowAsThumbnail", Value: false}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Width", Value: int64(img.Width)}, - {Key: "WidthUnit", Value: "Auto"}, - } - return doc -} diff --git a/sdk/mpr/writer_widgets_icon_test.go b/sdk/mpr/writer_widgets_icon_test.go deleted file mode 100644 index 42516b2f0e..0000000000 --- a/sdk/mpr/writer_widgets_icon_test.go +++ /dev/null @@ -1,127 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "go.mongodb.org/mongo-driver/bson" - - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/pages" -) - -// The legacy engine wrote `Icon: nil` on every action button, unconditionally. -// A button's icon has been authorable since #602 and only the modelsdk engine -// ever wrote one, so under `--engine legacy` the icon was dropped on every -// write — silently, because a null Icon is exactly what an iconless button -// stores and nothing downstream could tell the two apart (mendixlabs/mxcli#1059). -// -// These assert on the encoded document, which is the layer the defect lived in: -// the model carried the icon correctly the whole time. - -// iconOf serializes a button and returns its Icon element as a plain map, or -// nil when the icon was written as null. -func iconOf(t *testing.T, icon *pages.Icon) map[string]any { - t.Helper() - doc := serializeActionButton(&pages.ActionButton{ - BaseWidget: pages.BaseWidget{Name: "btnEdit"}, - Icon: icon, - }) - for _, e := range doc { - if e.Key != "Icon" { - continue - } - if e.Value == nil { - return nil - } - nested, ok := e.Value.(bson.D) - if !ok { - t.Fatalf("Icon is a %T, want bson.D", e.Value) - } - out := make(map[string]any, len(nested)) - for _, f := range nested { - out[f.Key] = f.Value - } - return out - } - t.Fatal("serialized button has no Icon key at all") - return nil -} - -func TestSerializeActionButton_WritesEachIconElement(t *testing.T) { - cases := []struct { - name string - icon *pages.Icon - wantType string - wantImage string - wantCode int32 - }{{ - name: "collection", - icon: &pages.Icon{Kind: types.MenuIconCollection, Image: "Atlas_Core.Atlas_Filled.pencil"}, - wantType: "Forms$IconCollectionIcon", - wantImage: "Atlas_Core.Atlas_Filled.pencil", - }, { - name: "image", - icon: &pages.Icon{Kind: types.MenuIconImage, Image: "DesignSystem.Icons_SVG.edit"}, - wantType: "Forms$ImageIcon", - wantImage: "DesignSystem.Icons_SVG.edit", - }, { - name: "glyph", - icon: &pages.Icon{Kind: types.MenuIconGlyph, Code: 57377}, - wantType: "Forms$GlyphIcon", - wantCode: 57377, - }} - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := iconOf(t, tc.icon) - if got == nil { - t.Fatal("the icon was written as null — the legacy drop") - } - if got["$Type"] != tc.wantType { - t.Errorf("$Type = %v, want %q", got["$Type"], tc.wantType) - } - if tc.wantImage != "" && got["Image"] != tc.wantImage { - t.Errorf("Image = %v, want %q", got["Image"], tc.wantImage) - } - if tc.wantCode != 0 && got["Code"] != tc.wantCode { - t.Errorf("Code = %v (%T), want %d as int32", got["Code"], got["Code"], tc.wantCode) - } - // A glyph has no name and a named icon has no code. Writing the - // other variant's payload alongside is how a reader would then have - // to guess which one the element really is. - if tc.wantCode == 0 && got["Code"] != nil { - t.Errorf("a named icon carries Code = %v", got["Code"]) - } - if tc.wantImage == "" && got["Image"] != nil { - t.Errorf("a glyph icon carries Image = %v", got["Image"]) - } - }) - } -} - -// CONTROL: an iconless button must still write a null Icon — that is what -// Studio Pro stores, and it is the TypeDefault the rest of the document expects. -func TestSerializeActionButton_NoIconStaysNull(t *testing.T) { - if got := iconOf(t, nil); got != nil { - t.Errorf("an iconless button wrote an icon element: %v", got) - } -} - -// CONTROL: an icon that identifies nothing is written as no icon rather than as -// an element nobody can see. The executor refuses these before they get here, so -// this pins the writer's own behaviour for the paths that build a pages.Icon -// directly. -func TestSerializeActionButton_AnIconIdentifyingNothingIsNotWritten(t *testing.T) { - for _, icon := range []*pages.Icon{ - {Kind: types.MenuIconGlyph}, // no code - {Kind: types.MenuIconImage}, // no name - {Kind: types.MenuIconCollection}, // no name - {Kind: types.MenuIconKind("Forms$SomeFuture")}, // a kind this build does not know - } { - if got := iconOf(t, icon); got != nil { - t.Errorf("%+v was written as %v, want no icon", icon, got) - } - } -} diff --git a/sdk/mpr/writer_widgets_image_test.go b/sdk/mpr/writer_widgets_image_test.go deleted file mode 100644 index 3403bc0786..0000000000 --- a/sdk/mpr/writer_widgets_image_test.go +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// The image widgets, pinned to the shape Mendix stores. -// -// This is the legacy half of the pair; the modelsdk half is -// mdl/backend/modelsdk/widget_write_legacy_gaps_test.go and asserts the same -// things about the same widgets. Keeping both is the point: the two engines had -// silently drifted apart here, and only one of them was right. -// -// Ground truth is the three Studio-Pro-authored Forms$StaticImageViewer widgets -// ako/TestApp inherits from FeedbackModule, plus generated/metamodel (the -// arbiter per CLAUDE.md) for Forms$ImageViewer, which no reference project in -// reach carries. - -package mpr - -import ( - "sort" - "strings" - "testing" - - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -func imgKeys(doc bson.D) string { - out := make([]string, 0, len(doc)) - for _, e := range doc { - out = append(out, e.Key) - } - sort.Strings(out) - return strings.Join(out, ",") -} - -// TestStaticImageMatchesStudioPro — legacy used to omit AlternativeText, which -// generated/metamodel declares without omitempty and all three references carry, -// and to write BSON null for the unset Image. An unset by-name reference is the -// empty string: measured 0 nulls against 4,400+ empty strings over 40 -// (type, property) pairs in ako/TestApp. -func TestStaticImageMatchesStudioPro(t *testing.T) { - img := &pages.StaticImage{Responsive: true} - img.Name = "i1" - doc := serializeStaticImage(img) - - want := "$ID,$Type,AlternativeText,Appearance,ClickAction," + - "ConditionalVisibilitySettings,Height,HeightUnit,Image,Name," + - "NativeAccessibilitySettings,Responsive,TabIndex,Width,WidthUnit" - if got := imgKeys(doc); got != want { - t.Errorf("keys\n got %s\n want %s", got, want) - } - if got := bsonLookup(doc, "Image"); got != "" { - t.Errorf("Image = %#v, want the empty string", got) - } - assertEmptyClientTemplateBSON(t, doc, "AlternativeText") -} - -// TestDynamicImageMatchesMetamodel — legacy's AlternativeText here was -// hand-rolled and carried a FallbackValue key that Forms$ClientTemplate does not -// have, four lines after a comment in the shared serializer saying exactly that -// ("Must be Fallback object, not FallbackValue string"). -func TestDynamicImageMatchesMetamodel(t *testing.T) { - img := &pages.DynamicImage{Responsive: true} - img.Name = "i2" - doc := serializeDynamicImage(img) - - want := "$ID,$Type,AlternativeText,Appearance,ClickAction," + - "ConditionalVisibilitySettings,DataSource,DefaultImage,Height,HeightUnit," + - "Name,NativeAccessibilitySettings,OnClickEnlarge,Responsive," + - "ShowAsThumbnail,TabIndex,Width,WidthUnit" - if got := imgKeys(doc); got != want { - t.Errorf("keys\n got %s\n want %s", got, want) - } - if got := bsonLookup(doc, "DefaultImage"); got != "" { - t.Errorf("DefaultImage = %#v, want the empty string", got) - } - assertEmptyClientTemplateBSON(t, doc, "AlternativeText") -} - -func assertEmptyClientTemplateBSON(t *testing.T, parent bson.D, key string) { - t.Helper() - ct := bsonSubDoc(t, parent, key) - if got := bsonLookup(ct, "$Type"); got != "Forms$ClientTemplate" { - t.Errorf("%s.$Type = %v", key, got) - } - if bsonLookup(ct, "FallbackValue") != nil { - t.Errorf("%s carries a FallbackValue; Forms$ClientTemplate has no such property "+ - "(metamodel: Fallback / Parameters / Template). Studio Pro refuses to open a "+ - "document with an unknown property; mxbuild builds it at 0 errors", key) - } - for _, sub := range []string{"Fallback", "Template"} { - txt := bsonSubDoc(t, ct, sub) - if got := bsonLookup(txt, "$Type"); got != "Texts$Text" { - t.Errorf("%s.%s.$Type = %v", key, sub, got) - } - items, ok := bsonLookup(txt, "Items").(bson.A) - if !ok || len(items) != 1 || items[0] != int32(3) { - t.Errorf("%s.%s.Items = %#v, want [3]", key, sub, bsonLookup(txt, "Items")) - } - } - params, ok := bsonLookup(ct, "Parameters").(bson.A) - if !ok || len(params) != 1 || params[0] != int32(2) { - t.Errorf("%s.Parameters = %#v, want [2]", key, bsonLookup(ct, "Parameters")) - } -} diff --git a/sdk/mpr/writer_widgets_input.go b/sdk/mpr/writer_widgets_input.go deleted file mode 100644 index 8757bcc7e1..0000000000 --- a/sdk/mpr/writer_widgets_input.go +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// serializeTextBox serializes a TextBox widget. -func serializeTextBox(tb *pages.TextBox) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(tb.ID))}, - {Key: "$Type", Value: "Forms$TextBox"}, - {Key: "Appearance", Value: serializeAppearance(tb.Class, tb.Style, tb.DynamicClasses, tb.DesignProperties)}, - {Key: "AriaRequired", Value: false}, - {Key: "AttributeRef", Value: serializeAttributeRef(tb.AttributePath)}, - {Key: "AutoFocus", Value: false}, - {Key: "Autocomplete", Value: true}, - {Key: "AutocompletePurpose", Value: "On"}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: "Always"}, - {Key: "FormattingInfo", Value: serializeFormattingInfo()}, - {Key: "InputMask", Value: ""}, - {Key: "IsPasswordBox", Value: tb.IsPassword}, - {Key: "KeyboardType", Value: "Default"}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(tb.Label)}, - {Key: "MaxLengthCode", Value: int64(-1)}, - {Key: "Name", Value: tb.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnChangeAction", Value: serializeClientAction(tb.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(tb.OnEnterAction)}, - {Key: "OnEnterKeyPressAction", Value: serializeClientAction(nil)}, - {Key: "OnLeaveAction", Value: serializeClientAction(nil)}, - {Key: "PlaceholderTemplate", Value: serializePlaceholderTemplate(tb.Placeholder)}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "SubmitBehaviour", Value: "OnEndEditing"}, - {Key: "SubmitOnInputDelay", Value: int64(300)}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} - -// serializeTextArea serializes a TextArea widget. -func serializeTextArea(ta *pages.TextArea) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(ta.ID))}, - {Key: "$Type", Value: "Forms$TextArea"}, - {Key: "Appearance", Value: serializeAppearance(ta.Class, ta.Style, ta.DynamicClasses, ta.DesignProperties)}, - {Key: "AriaRequired", Value: false}, - {Key: "AttributeRef", Value: serializeAttributeRef(ta.AttributePath)}, - {Key: "AutoFocus", Value: false}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "CounterMessage", Value: serializeEmptyText()}, - {Key: "Editable", Value: "Always"}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(ta.Label)}, - {Key: "MaxLengthCode", Value: int64(-1)}, - {Key: "Name", Value: ta.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "NumberOfLines", Value: int64(5)}, - {Key: "OnChangeAction", Value: serializeClientAction(ta.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(nil)}, - {Key: "OnLeaveAction", Value: serializeClientAction(nil)}, - {Key: "PlaceholderTemplate", Value: serializeEmptyPlaceholderTemplate()}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "SubmitBehaviour", Value: "OnEndEditing"}, - {Key: "SubmitOnInputDelay", Value: int64(300)}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} - -// serializeDatePicker serializes a DatePicker widget. -func serializeDatePicker(dp *pages.DatePicker) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dp.ID))}, - {Key: "$Type", Value: "Forms$DatePicker"}, - {Key: "Appearance", Value: serializeAppearance(dp.Class, dp.Style, dp.DynamicClasses, dp.DesignProperties)}, - {Key: "AriaRequired", Value: false}, - {Key: "AttributeRef", Value: serializeAttributeRef(dp.AttributePath)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DateFormat", Value: "Date"}, - {Key: "Editable", Value: "Always"}, - {Key: "FormattingInfo", Value: serializeFormattingInfo()}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(dp.Label)}, - {Key: "Name", Value: dp.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnChangeAction", Value: serializeClientAction(dp.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(nil)}, - {Key: "PlaceholderTemplate", Value: serializeEmptyPlaceholderTemplate()}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} - -// serializeCheckBox serializes a CheckBox widget. -func serializeCheckBox(cb *pages.CheckBox) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(cb.ID))}, - {Key: "$Type", Value: "Forms$CheckBox"}, - {Key: "Appearance", Value: serializeAppearance(cb.Class, cb.Style, cb.DynamicClasses, cb.DesignProperties)}, - {Key: "AttributeRef", Value: serializeAttributeRef(cb.AttributePath)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: "Always"}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(cb.Label)}, - {Key: "Name", Value: cb.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnChangeAction", Value: serializeClientAction(cb.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(nil)}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} - -// serializeRadioButtons serializes a RadioButtons widget. -func serializeRadioButtons(rb *pages.RadioButtons) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(rb.ID))}, - {Key: "$Type", Value: "Forms$RadioButtonGroup"}, - {Key: "Appearance", Value: serializeAppearance(rb.Class, rb.Style, rb.DynamicClasses, rb.DesignProperties)}, - {Key: "AriaRequired", Value: false}, - {Key: "AttributeRef", Value: serializeAttributeRef(rb.AttributePath)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: "Always"}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(rb.Label)}, - {Key: "Name", Value: rb.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnChangeAction", Value: serializeClientAction(rb.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(nil)}, - {Key: "Orientation", Value: "Horizontal"}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} - -// serializeDropDown serializes a DropDown widget. -func serializeDropDown(dd *pages.DropDown) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(dd.ID))}, - {Key: "$Type", Value: "Forms$DropDown"}, - {Key: "Appearance", Value: serializeAppearance(dd.Class, dd.Style, dd.DynamicClasses, dd.DesignProperties)}, - {Key: "AriaRequired", Value: false}, - {Key: "AttributeRef", Value: serializeAttributeRef(dd.AttributePath)}, - {Key: "ConditionalEditabilitySettings", Value: nil}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Editable", Value: "Always"}, - {Key: "EmptyOptionCaption", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "LabelTemplate", Value: serializeLabelTemplate(dd.Label)}, - {Key: "Name", Value: dd.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnChangeAction", Value: serializeClientAction(dd.OnChangeAction)}, - {Key: "OnEnterAction", Value: serializeClientAction(nil)}, - {Key: "OnLeaveAction", Value: serializeClientAction(nil)}, - {Key: "ReadOnlyStyle", Value: "Inherit"}, - {Key: "ScreenReaderLabel", Value: nil}, - {Key: "SourceVariable", Value: nil}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Validation", Value: serializeWidgetValidation()}, - } -} diff --git a/sdk/mpr/writer_widgets_layout.go b/sdk/mpr/writer_widgets_layout.go deleted file mode 100644 index 28a34733e1..0000000000 --- a/sdk/mpr/writer_widgets_layout.go +++ /dev/null @@ -1,209 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - - "go.mongodb.org/mongo-driver/bson" -) - -// serializeContainer serializes a Container widget. -func serializeContainer(c *pages.Container) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(c.ID))}, - {Key: "$Type", Value: "Forms$DivContainer"}, - {Key: "Appearance", Value: serializeAppearance(c.Class, c.Style, c.DynamicClasses, c.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: c.Name}, - {Key: "NativeAccessibilitySettings", Value: nil}, - {Key: "OnClickAction", Value: serializeClientAction(c.OnClickAction)}, - {Key: "RenderMode", Value: "Div"}, - {Key: "ScreenReaderHidden", Value: false}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Widgets", Value: serializeWidgetArray(c.Widgets)}, - } - return doc -} - -// serializeGroupBox serializes a GroupBox widget. -func serializeGroupBox(gb *pages.GroupBox) bson.D { - collapsible := gb.Collapsible - if collapsible == "" { - collapsible = "No" - } - headerMode := gb.HeaderMode - if headerMode == "" { - headerMode = "Div" - } - - // Serialize CaptionTemplate - var captionTemplate bson.D - if gb.Caption != nil { - captionTemplate = serializeClientTemplate(gb.Caption, nil, "") - } else { - captionTemplate = serializeClientTemplate(nil, nil, "") - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(gb.ID))}, - {Key: "$Type", Value: "Forms$GroupBox"}, - {Key: "Appearance", Value: serializeAppearance(gb.Class, gb.Style, gb.DynamicClasses, gb.DesignProperties)}, - {Key: "CaptionTemplate", Value: captionTemplate}, - {Key: "Collapsible", Value: collapsible}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "HeaderMode", Value: headerMode}, - {Key: "Name", Value: gb.Name}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Widgets", Value: serializeWidgetArray(gb.Widgets)}, - } - return doc -} - -// serializeTabContainer serializes a TabContainer widget. -func serializeTabContainer(tc *pages.TabContainer) bson.D { - tabPages := bson.A{int32(3)} // marker=3 for TabPages array - var defaultPageID []byte - for i, tp := range tc.TabPages { - tpDoc := serializeTabPage(tp) - tabPages = append(tabPages, tpDoc) - if i == 0 { - // Default to first tab - defaultPageID = idToBsonBinary(string(tp.ID)).Data - } - } - if tc.DefaultPageID != "" { - defaultPageID = idToBsonBinary(string(tc.DefaultPageID)).Data - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(tc.ID))}, - {Key: "$Type", Value: "Forms$TabControl"}, - {Key: "ActivePageAttributeRef", Value: nil}, - {Key: "ActivePageOnChangeAction", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(GenerateID())}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "ActivePageSourceVariable", Value: nil}, - {Key: "Appearance", Value: serializeAppearance(tc.Class, tc.Style, tc.DynamicClasses, tc.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "DefaultPagePointer", Value: defaultPageID}, - {Key: "Name", Value: tc.Name}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "TabPages", Value: tabPages}, - } - return doc -} - -// serializeTabPage serializes a TabPage within a TabContainer. -func serializeTabPage(tp *pages.TabPage) bson.D { - // Caption - var caption bson.D - if tp.Caption != nil { - caption = serializeText(tp.Caption) - } else { - caption = serializeText(&model.Text{ - BaseElement: model.BaseElement{ - ID: model.ID(GenerateID()), - TypeName: "Texts$Text", - }, - Translations: map[string]string{model.AuthoringLanguage(): tp.Name}, - }) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(tp.ID))}, - {Key: "$Type", Value: "Forms$TabPage"}, - {Key: "Badge", Value: nil}, - {Key: "Caption", Value: caption}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: tp.Name}, - {Key: "RefreshOnShow", Value: tp.RefreshOnShow}, - {Key: "Widgets", Value: serializeWidgetArray(tp.Widgets)}, - } - return doc -} - -// serializeLayoutGrid serializes a LayoutGrid widget. -func serializeLayoutGrid(lg *pages.LayoutGrid) bson.D { - // Mendix uses [3] for empty arrays, [2, item1, item2, ...] for non-empty arrays - // Items go directly after the version marker, NOT nested in another array - rows := bson.A{int32(3)} // Start with empty marker - hasRows := false - for _, row := range lg.Rows { - if !hasRows { - rows = bson.A{int32(2)} // First item: change to version 2 - hasRows = true - } - rows = append(rows, serializeLayoutGridRow(row)) - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(lg.ID))}, - {Key: "$Type", Value: "Forms$LayoutGrid"}, - {Key: "Appearance", Value: serializeAppearance(lg.Class, lg.Style, lg.DynamicClasses, lg.DesignProperties)}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "Name", Value: lg.Name}, - {Key: "Rows", Value: rows}, - {Key: "TabIndex", Value: int64(0)}, - {Key: "Width", Value: "FullWidth"}, - } - return doc -} - -// serializeLayoutGridRow serializes a LayoutGridRow. -func serializeLayoutGridRow(row *pages.LayoutGridRow) bson.D { - // Mendix uses [3] for empty arrays, [2, item1, item2, ...] for non-empty arrays - // Items go directly after the version marker, NOT nested in another array - cols := bson.A{int32(3)} // Start with empty marker - hasCols := false - for _, col := range row.Columns { - if !hasCols { - cols = bson.A{int32(2)} // First item: change to version 2 - hasCols = true - } - cols = append(cols, serializeLayoutGridColumn(col)) - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(row.ID))}, - {Key: "$Type", Value: "Forms$LayoutGridRow"}, - {Key: "Appearance", Value: serializeAppearance("", "", "", nil)}, - {Key: "Columns", Value: cols}, - {Key: "ConditionalVisibilitySettings", Value: nil}, - {Key: "HorizontalAlignment", Value: "None"}, - {Key: "SpacingBetweenColumns", Value: true}, - {Key: "VerticalAlignment", Value: "None"}, - } -} - -// columnWeight returns the column weight, defaulting to -1 (auto) if 0. -func columnWeight(w int) int { - if w == 0 { - return -1 - } - return w -} - -// serializeLayoutGridColumn serializes a LayoutGridColumn. -func serializeLayoutGridColumn(col *pages.LayoutGridColumn) bson.D { - // Weight for column width: -1 means auto-fill, 1-12 are explicit widths - weight := col.Weight - if weight == 0 { - weight = -1 // Default to auto-fill - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(col.ID))}, - {Key: "$Type", Value: "Forms$LayoutGridColumn"}, - {Key: "Appearance", Value: serializeAppearance("", "", "", nil)}, - {Key: "PhoneWeight", Value: int64(columnWeight(col.PhoneWeight))}, - {Key: "PreviewWidth", Value: int64(-1)}, // Default preview width - {Key: "TabletWeight", Value: int64(columnWeight(col.TabletWeight))}, - {Key: "VerticalAlignment", Value: "None"}, - {Key: "Weight", Value: int64(weight)}, // Desktop weight - {Key: "Widgets", Value: serializeWidgetArray(col.Widgets)}, - } -} diff --git a/sdk/mpr/writer_widgets_linkbutton_test.go b/sdk/mpr/writer_widgets_linkbutton_test.go deleted file mode 100644 index 593f783156..0000000000 --- a/sdk/mpr/writer_widgets_linkbutton_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" -) - -// TestSerializeActionButton_RenderType verifies that a link-rendered action -// button (authored as `linkbutton`) serializes with RenderType "Link", while a -// normal action button keeps "Button". Previously RenderType was hardcoded to -// "Button", so linkbutton output was indistinguishable from actionbutton. -func TestSerializeActionButton_RenderType(t *testing.T) { - cases := []struct { - name string - render pages.ButtonRenderMode - want string - }{ - {"linkbutton", pages.ButtonRenderModeLink, "Link"}, - {"actionbutton", pages.ButtonRenderModeButton, "Button"}, - {"default empty", "", "Button"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - ab := &pages.ActionButton{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ID: "11111111-1111-1111-1111-111111111111"}, - Name: "btn", - }, - RenderMode: tc.render, - } - doc := serializeActionButton(ab) - got := "" - for _, e := range doc { - if e.Key == "RenderType" { - got, _ = e.Value.(string) - } - } - if got != tc.want { - t.Errorf("RenderType = %q, want %q", got, tc.want) - } - if doc[0].Key != "$ID" { - t.Errorf("first key = %q, want $ID", doc[0].Key) - } - }) - } -} diff --git a/sdk/mpr/writer_widgets_snippet_test.go b/sdk/mpr/writer_widgets_snippet_test.go deleted file mode 100644 index 6bdb99aa99..0000000000 --- a/sdk/mpr/writer_widgets_snippet_test.go +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -// TestSnippetCall_ParameterMapping_CorrectBSONType verifies that -// Forms$SnippetParameterMapping (not Forms$PageParameterMapping) is written -// for snippet call parameter mappings (issue #291 / #295 follow-up). -// Studio Pro throws InvalidOperationException when it finds PageParameterMapping -// inside a SnippetCall container. -func TestSnippetCall_ParameterMapping_CorrectBSONType(t *testing.T) { - sc := &pages.SnippetCallWidget{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ID: "sc-id"}, - Name: "snippetCall1", - }, - SnippetName: "Mod.MySnippet", - ParameterMappings: []pages.SnippetParamMapping{ - {ParamName: "Asset", Argument: "$Asset"}, - }, - } - - doc := serializeSnippetCall(sc) - if doc == nil { - t.Fatal("serializeSnippetCall returned nil") - } - - // Navigate to FormCall.ParameterMappings - var formCall bson.D - for _, e := range doc { - if e.Key == "FormCall" { - formCall, _ = e.Value.(bson.D) - } - } - if formCall == nil { - t.Fatal("FormCall is nil") - } - - var paramMappings bson.A - for _, e := range formCall { - if e.Key == "ParameterMappings" { - paramMappings, _ = e.Value.(bson.A) - } - } - if len(paramMappings) < 2 { - t.Fatalf("ParameterMappings: want count+1 elements, got %d", len(paramMappings)) - } - - // Element 0 is int32 count; element 1 is the first mapping - mapping, ok := paramMappings[1].(bson.D) - if !ok { - t.Fatalf("ParameterMappings[1] is not bson.D, got %T", paramMappings[1]) - } - - var bsonType, argument, parameter string - var variable any - for _, e := range mapping { - switch e.Key { - case "$Type": - bsonType, _ = e.Value.(string) - case "Argument": - argument, _ = e.Value.(string) - case "Parameter": - parameter, _ = e.Value.(string) - case "Variable": - variable = e.Value - } - } - - if bsonType != "Forms$SnippetParameterMapping" { - t.Errorf("$Type = %q, want %q (PageParameterMapping is wrong for snippet context)", bsonType, "Forms$SnippetParameterMapping") - } - if argument != "" { - t.Errorf("Argument = %q, want %q (variable belongs in Variable.PageParameter)", argument, "") - } - if parameter != "Mod.MySnippet.Asset" { - t.Errorf("Parameter = %q, want %q", parameter, "Mod.MySnippet.Asset") - } - if variable == nil { - t.Fatal("Variable is nil — Forms$SnippetParameterMapping requires non-null Forms$PageVariable") - } - - varDoc, ok := variable.(bson.D) - if !ok { - t.Fatalf("Variable is not bson.D, got %T", variable) - } - - var varType, pageParam string - for _, e := range varDoc { - switch e.Key { - case "$Type": - varType, _ = e.Value.(string) - case "PageParameter": - pageParam, _ = e.Value.(string) - } - } - if varType != "Forms$PageVariable" { - t.Errorf("Variable.$Type = %q, want %q", varType, "Forms$PageVariable") - } - if pageParam != "Asset" { - t.Errorf("Variable.PageParameter = %q, want %q (stripped $)", pageParam, "Asset") - } -} diff --git a/sdk/mpr/writer_widgets_test.go b/sdk/mpr/writer_widgets_test.go deleted file mode 100644 index fdf1be7475..0000000000 --- a/sdk/mpr/writer_widgets_test.go +++ /dev/null @@ -1,465 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "testing" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -func TestSerializeDataView(t *testing.T) { - // Create a DataView with a DataViewSource (parameter reference) - dataView := &pages.DataView{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: "test-dataview-id", - TypeName: "Forms$DataView", - }, - Name: "customerForm", - }, - DataSource: &pages.DataViewSource{ - BaseElement: model.BaseElement{ - ID: "test-datasource-id", - TypeName: "Forms$DataViewSource", - }, - EntityID: "test-entity-id", - EntityName: "TestModule.Customer", - ParameterName: "Customer", - }, - ShowFooter: true, - Editable: true, - } - - result := serializeDataView(dataView) - - // Check that result is a BSON document - if result == nil { - t.Fatal("serializeDataView returned nil") - } - - // Check $Type - var foundType string - for _, elem := range result { - if elem.Key == "$Type" { - foundType = elem.Value.(string) - } - } - if foundType != "Forms$DataView" { - t.Errorf("Expected $Type to be 'Forms$DataView', got '%s'", foundType) - } - - // Check DataSource is present - var foundDataSource any - for _, elem := range result { - if elem.Key == "DataSource" { - foundDataSource = elem.Value - } - } - if foundDataSource == nil { - t.Error("DataSource is nil, expected it to be set") - } - - // Check DataSource type - if ds, ok := foundDataSource.(bson.D); ok { - var dsType string - for _, elem := range ds { - if elem.Key == "$Type" { - dsType = elem.Value.(string) - } - } - if dsType != "Forms$DataViewSource" { - t.Errorf("Expected DataSource.$Type to be 'Forms$DataViewSource', got '%s'", dsType) - } - - // Check EntityRef is present - var entityRef any - for _, elem := range ds { - if elem.Key == "EntityRef" { - entityRef = elem.Value - } - } - if entityRef == nil { - t.Error("EntityRef is nil, expected it to be set") - } - - // Check SourceVariable is present - var sourceVar any - for _, elem := range ds { - if elem.Key == "SourceVariable" { - sourceVar = elem.Value - } - } - if sourceVar == nil { - t.Error("SourceVariable is nil, expected it to be set") - } - - // Check SourceVariable contains PageParameter - if sv, ok := sourceVar.(bson.D); ok { - var pageParam string - var svType string - for _, elem := range sv { - if elem.Key == "PageParameter" { - pageParam = elem.Value.(string) - } - if elem.Key == "$Type" { - svType = elem.Value.(string) - } - } - if svType != "Forms$PageVariable" { - t.Errorf("Expected SourceVariable.$Type to be 'Forms$PageVariable', got '%s'", svType) - } - if pageParam != "Customer" { - t.Errorf("Expected PageParameter to be 'Customer', got '%s'", pageParam) - } - } else { - t.Error("SourceVariable is not a bson.D") - } - - // Check EntityRef structure - if er, ok := entityRef.(bson.D); ok { - var erType string - var entity string - for _, elem := range er { - if elem.Key == "$Type" { - erType = elem.Value.(string) - } - if elem.Key == "Entity" { - entity = elem.Value.(string) - } - } - if erType != "DomainModels$DirectEntityRef" { - t.Errorf("Expected EntityRef.$Type to be 'DomainModels$DirectEntityRef', got '%s'", erType) - } - if entity != "TestModule.Customer" { - t.Errorf("Expected Entity to be 'TestModule.Customer', got '%s'", entity) - } - } else { - t.Error("EntityRef is not a bson.D") - } - } else { - t.Error("DataSource is not a bson.D") - } -} - -func TestSerializeDataViewDataSource(t *testing.T) { - ds := &pages.DataViewSource{ - BaseElement: model.BaseElement{ - ID: "test-ds-id", - TypeName: "Forms$DataViewSource", - }, - EntityID: "entity-123", - EntityName: "MyModule.MyEntity", - ParameterName: "MyParam", - } - - result := serializeDataViewDataSource(ds) - if result == nil { - t.Fatal("serializeDataViewDataSource returned nil") - } - - bsonResult, ok := result.(bson.D) - if !ok { - t.Fatalf("Expected bson.D, got %T", result) - } - - // Check structure - var foundType, foundEntityRef, foundSourceVar bool - for _, elem := range bsonResult { - switch elem.Key { - case "$Type": - if elem.Value.(string) != "Forms$DataViewSource" { - t.Errorf("Expected $Type 'Forms$DataViewSource', got '%v'", elem.Value) - } - foundType = true - case "EntityRef": - if elem.Value != nil { - foundEntityRef = true - } - case "SourceVariable": - if elem.Value != nil { - foundSourceVar = true - } - } - } - - if !foundType { - t.Error("$Type not found in result") - } - if !foundEntityRef { - t.Error("EntityRef not found or is nil") - } - if !foundSourceVar { - t.Error("SourceVariable not found or is nil") - } -} - -func TestSerializeTextBox(t *testing.T) { - tb := &pages.TextBox{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: "test-textbox-id", - TypeName: "Forms$TextBox", - }, - Name: "txtEmail", - }, - AttributePath: "MyModule.Customer.Email", - } - - result := serializeTextBox(tb) - - // Check $Type - var foundType, foundAttrRef, foundName bool - for _, elem := range result { - switch elem.Key { - case "$Type": - if elem.Value.(string) != "Forms$TextBox" { - t.Errorf("Expected $Type 'Forms$TextBox', got '%v'", elem.Value) - } - foundType = true - case "AttributeRef": - if elem.Value != nil { - foundAttrRef = true - // Check AttributeRef structure - if ar, ok := elem.Value.(bson.D); ok { - var attrType, attrValue string - for _, arElem := range ar { - if arElem.Key == "$Type" { - attrType = arElem.Value.(string) - } - if arElem.Key == "Attribute" { - attrValue = arElem.Value.(string) - } - } - if attrType != "DomainModels$AttributeRef" { - t.Errorf("Expected AttributeRef.$Type 'DomainModels$AttributeRef', got '%s'", attrType) - } - if attrValue != "MyModule.Customer.Email" { - t.Errorf("Expected Attribute 'MyModule.Customer.Email', got '%s'", attrValue) - } - } - } - case "Name": - if elem.Value.(string) == "txtEmail" { - foundName = true - } - } - } - - if !foundType { - t.Error("$Type not found") - } - if !foundAttrRef { - t.Error("AttributeRef not found or is nil") - } - if !foundName { - t.Error("Name not found or incorrect") - } -} - -// TestSerializeTextBox_PlaceholderAndOnChange guards finding #9: placeholder and -// onchange were hardcoded to empty on the legacy write path (silently dropped). -// They must now serialize from the model's Placeholder / OnChangeAction. -func TestSerializeTextBox_PlaceholderAndOnChange(t *testing.T) { - tb := &pages.TextBox{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ID: "tb-id", TypeName: "Forms$TextBox"}, - Name: "txtQuery", - }, - AttributePath: "M.Filter.Query", - Placeholder: &model.Text{ - BaseElement: model.BaseElement{ID: "ph-id", TypeName: "Texts$Text"}, - Translations: map[string]string{"en_US": "Search all articles"}, - }, - OnChangeAction: &pages.MicroflowClientAction{ - MicroflowName: "M.ACT_Search", - }, - } - - result := serializeTextBox(tb) - - // PlaceholderTemplate must carry the placeholder text (not the empty template). - var placeholderText string - var onChangeType string - for _, elem := range result { - switch elem.Key { - case "PlaceholderTemplate": - if d, ok := elem.Value.(bson.D); ok { - placeholderText = extractTemplateText(d) - } - case "OnChangeAction": - if d, ok := elem.Value.(bson.D); ok { - for _, e := range d { - if e.Key == "$Type" { - onChangeType, _ = e.Value.(string) - } - } - } - } - } - if placeholderText != "Search all articles" { - t.Errorf("PlaceholderTemplate text = %q, want %q", placeholderText, "Search all articles") - } - if onChangeType == "" || onChangeType == "Forms$NoAction" { - t.Errorf("OnChangeAction should be a real action, got $Type = %q", onChangeType) - } -} - -// extractTemplateText pulls the first Translation Text out of a Forms$ClientTemplate. -func extractTemplateText(ct bson.D) string { - for _, e := range ct { - if e.Key != "Template" { - continue - } - tmpl, ok := e.Value.(bson.D) - if !ok { - continue - } - for _, te := range tmpl { - if te.Key != "Items" { - continue - } - items, ok := te.Value.(bson.A) - if !ok { - continue - } - for _, it := range items { - trans, ok := it.(bson.D) - if !ok { - continue - } - for _, tr := range trans { - if tr.Key == "Text" { - if s, ok := tr.Value.(string); ok { - return s - } - } - } - } - } - } - return "" -} - -func TestSerializeDataViewLabelWidth(t *testing.T) { - five := 5 - zero := 0 - cases := []struct { - name string - dv *pages.DataView - want int64 - }{ - {"default is Horizontal=3", &pages.DataView{}, 3}, - {"FormOrientation Vertical -> 0", &pages.DataView{FormOrientation: pages.FormOrientationVertical}, 0}, - {"FormOrientation Horizontal -> 3", &pages.DataView{FormOrientation: pages.FormOrientationHorizontal}, 3}, - {"explicit LabelWidth=5", &pages.DataView{LabelWidth: &five}, 5}, - {"explicit LabelWidth=0 wins over Horizontal", &pages.DataView{LabelWidth: &zero, FormOrientation: pages.FormOrientationHorizontal}, 0}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := serializeDataView(tc.dv) - var lw int64 = -1 - for _, elem := range got { - if elem.Key == "LabelWidth" { - lw = elem.Value.(int64) - } - } - if lw != tc.want { - t.Errorf("LabelWidth = %d, want %d", lw, tc.want) - } - }) - } -} - -func TestSerializeRadioButtons(t *testing.T) { - rb := &pages.RadioButtons{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: "test-radio-id", - TypeName: "Forms$RadioButtonGroup", - }, - Name: "rbIsActive", - }, - AttributePath: "MyModule.Customer.IsActive", - } - - result := serializeRadioButtons(rb) - - // Check $Type - var foundType string - for _, elem := range result { - if elem.Key == "$Type" { - foundType = elem.Value.(string) - } - } - if foundType != "Forms$RadioButtonGroup" { - t.Errorf("Expected $Type 'Forms$RadioButtonGroup', got '%s'", foundType) - } -} - -// dgetForTest returns the value of the first field in d with the given key. -func dgetForTest(d bson.D, key string) any { - for _, e := range d { - if e.Key == key { - return e.Value - } - } - return nil -} - -// TestSerializeDesignProperties_Compound guards the WRITE side of compound -// (nested) design properties. Before the fix, serializeDesignProperties handled -// only toggle/option/custom and dropped a "compound" value via `default: continue`, -// so authoring e.g. Atlas `Spacing: [margin-top: Large]` (or `use building block` -// on a block that uses it) silently lost the nested property. Verified valid by -// `mx check` (0 errors) on a real 11.12.1 project. -func TestSerializeDesignProperties_Compound(t *testing.T) { - props := []pages.DesignPropertyValue{ - {Key: "Card style", ValueType: "toggle"}, - {Key: "Spacing", ValueType: "compound", Compound: []pages.DesignPropertyValue{ - {Key: "margin-top", ValueType: "option", Option: "Large"}, - {Key: "margin-bottom", ValueType: "option", Option: "Medium"}, - }}, - } - - arr := serializeDesignProperties(props) - // marker + toggle + compound - if len(arr) != 3 { - t.Fatalf("expected 3 elements (marker + 2 props), got %d", len(arr)) - } - - var compound bson.D - for _, e := range arr[1:] { - d, ok := e.(bson.D) - if !ok { - continue - } - if dgetForTest(d, "Key") == "Spacing" { - compound, _ = dgetForTest(d, "Value").(bson.D) - } - } - if compound == nil { - t.Fatal("Spacing compound entry was dropped, not serialized") - } - if got := dgetForTest(compound, "$Type"); got != "Forms$CompoundDesignPropertyValue" { - t.Fatalf("compound $Type = %v, want Forms$CompoundDesignPropertyValue", got) - } - sub, ok := dgetForTest(compound, "Properties").(bson.A) - if !ok { - t.Fatalf("Properties is not a bson.A: %T", dgetForTest(compound, "Properties")) - } - if len(sub) != 3 { // marker + 2 sub-entries - t.Fatalf("expected 3 sub-elements (marker + 2), got %d", len(sub)) - } - subKeys := map[string]bool{} - for _, s := range sub[1:] { - if d, ok := s.(bson.D); ok { - subKeys[dgetForTest(d, "Key").(string)] = true - } - } - if !subKeys["margin-top"] || !subKeys["margin-bottom"] { - t.Errorf("sub-properties missing, got keys %v", subKeys) - } -} diff --git a/sdk/mpr/writer_workflow.go b/sdk/mpr/writer_workflow.go deleted file mode 100644 index b4a34acda9..0000000000 --- a/sdk/mpr/writer_workflow.go +++ /dev/null @@ -1,876 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mpr - -import ( - "fmt" - - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/workflows" - - "go.mongodb.org/mongo-driver/bson" -) - -// CreateWorkflow creates a new workflow in the MPR. -func (w *Writer) CreateWorkflow(wf *workflows.Workflow) error { - if wf.ID == "" { - wf.ID = model.ID(generateUUID()) - } - wf.TypeName = "Workflows$Workflow" - - contents, err := w.serializeWorkflow(wf) - if err != nil { - return fmt.Errorf("failed to serialize workflow: %w", err) - } - - return w.insertUnit(string(wf.ID), string(wf.ContainerID), "Documents", "Workflows$Workflow", contents) -} - -// UpdateWorkflow replaces an existing workflow unit in the MPR, preserving its UUID. -func (w *Writer) UpdateWorkflow(wf *workflows.Workflow) error { - wf.TypeName = "Workflows$Workflow" - - contents, err := w.serializeWorkflow(wf) - if err != nil { - return fmt.Errorf("failed to serialize workflow: %w", err) - } - - return w.updateUnit(string(wf.ID), contents) -} - -// DeleteWorkflow deletes a workflow from the MPR. -func (w *Writer) DeleteWorkflow(id model.ID) error { - return w.deleteUnit(string(id)) -} - -func (w *Writer) serializeWorkflow(wf *workflows.Workflow) ([]byte, error) { - // AdminPage is a PartProperty (object or null), not a string. - // When empty, it must be null, not "". - var adminPageValue any - if wf.AdminPage != "" { - adminPageValue = bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$PageReference"}, - {Key: "Page", Value: wf.AdminPage}, - } - } - - // Annotation is a PartProperty (object or null). - var annotationValue any - if wf.Annotation != "" { - annotationValue = serializeAnnotation(wf.Annotation) - } - - // Flow - var flowValue bson.D - if wf.Flow != nil { - flowValue = serializeWorkflowFlow(wf.Flow) - } else { - emptyFlow := &workflows.Flow{} - emptyFlow.ID = model.ID(generateUUID()) - flowValue = serializeWorkflowFlow(emptyFlow) - } - - // Title defaults to workflow display name or Name - title := wf.WorkflowName - if title == "" { - title = wf.Name - } - - // Build doc in alphabetical key order matching Studio Pro BSON layout - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(string(wf.ID))}, - {Key: "$Type", Value: "Workflows$Workflow"}, - {Key: "AdminPage", Value: adminPageValue}, - {Key: "Annotation", Value: annotationValue}, - {Key: "Documentation", Value: wf.Documentation}, - {Key: "DueDate", Value: wf.DueDate}, - {Key: "Excluded", Value: wf.Excluded}, - {Key: "ExportLevel", Value: "Hidden"}, - {Key: "Flow", Value: flowValue}, - {Key: "Name", Value: wf.Name}, - {Key: "OnWorkflowEvent", Value: serializeWorkflowEventHandlers(wf.EventHandlers)}, - } - - // Parameter - if wf.Parameter != nil { - doc = append(doc, bson.E{Key: "Parameter", Value: serializeWorkflowParameter(wf.Parameter)}) - } - - doc = append(doc, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "Title", Value: title}, - bson.E{Key: "WorkflowDescription", Value: serializeWorkflowStringTemplate(wf.WorkflowDescription)}, - bson.E{Key: "WorkflowMetaData", Value: nil}, - bson.E{Key: "WorkflowName", Value: serializeWorkflowStringTemplate(wf.WorkflowName)}, - bson.E{Key: "WorkflowV2", Value: false}, - ) - - // NOTE: OverviewPage was deleted in Mendix 9.11.0 — do not serialize it. - // NOTE: AllowedModuleRoles is not present in Studio Pro BSON — omitted. - - pv := w.reader.ProjectVersion() - renameCallMicroflowTypeBSON(doc, pv != nil && pv.IsAtLeast(11, 9)) - return marshalUnitIDFirst(doc) -} - -// renameCallMicroflowTypeBSON rewrites every "Workflows$CallMicroflowTask" $Type -// in a serialized workflow tree to the 11.9+ "Workflows$CallMicroflowActivity" -// name when useActivity is set. Mendix 11.9 (WOR-2802) split MicroflowBasedActivity -// into CallMicroflowActivity + AIAgentTaskActivity; writing the pre-11.9 name to an -// 11.9+ project makes the runtime fail to load the whole model (FINDINGS #39). The -// modelsdk engine does the same via applyCallMicroflowStorageName. -func renameCallMicroflowTypeBSON(v any, useActivity bool) { - if !useActivity { - return - } - renameCallMicroflowWalk(v) -} - -func renameCallMicroflowWalk(v any) { - switch t := v.(type) { - case bson.D: - for i := range t { - if t[i].Key == "$Type" { - if s, ok := t[i].Value.(string); ok && s == "Workflows$CallMicroflowTask" { - t[i].Value = "Workflows$CallMicroflowActivity" - } - continue - } - renameCallMicroflowWalk(t[i].Value) - } - case bson.A: - for i := range t { - renameCallMicroflowWalk(t[i]) - } - } -} - -// serializeWorkflowEventHandlers writes OnWorkflowEvent: a marker-2 list of -// Workflows$WorkflowEventHandler, each with its event types as a marker-1 string -// list — the shape ako/TestApp (11.14.0) stores. -func serializeWorkflowEventHandlers(handlers []*workflows.WorkflowEventHandler) bson.A { - out := bson.A{int32(2)} - for _, h := range handlers { - types := bson.A{int32(1)} - for _, t := range h.EventTypes { - types = append(types, t) - } - id := string(h.ID) - if id == "" { - id = generateUUID() - } - out = append(out, bson.D{ - {Key: "$ID", Value: idToBsonBinary(id)}, - {Key: "$Type", Value: "Workflows$WorkflowEventHandler"}, - {Key: "Description", Value: h.Description}, - {Key: "Documentation", Value: h.Documentation}, - {Key: "EventTypes", Value: types}, - {Key: "MicroflowEventHandler", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$MicroflowEventHandler"}, - {Key: "Microflow", Value: h.Microflow}, - }}, - }) - } - return out -} - -// serializeOnCreatedEvent writes a user task's OnCreatedEvent part: the microflow -// when there is one, the NoEvent marker otherwise. -func serializeOnCreatedEvent(microflow string) bson.D { - if microflow == "" { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$NoEvent"}, - } - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$MicroflowBasedEvent"}, - {Key: "Microflow", Value: microflow}, - } -} - -// serializeWorkflowStringTemplate creates a minimal Mendix StringTemplate BSON structure for workflows. -func serializeWorkflowStringTemplate(text string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Microflows$StringTemplate"}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - {Key: "Text", Value: text}, - } -} - -// serializeWorkflowParameter serializes a workflow parameter. -// Since Mendix 9.10.0, EntityRef (PartProperty) was replaced by Entity (ByNameReferenceProperty). -func serializeWorkflowParameter(param *workflows.WorkflowParameter) bson.D { - paramID := string(param.ID) - if paramID == "" { - paramID = generateUUID() - } - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(paramID)}, - {Key: "$Type", Value: "Workflows$Parameter"}, - {Key: "Entity", Value: param.EntityRef}, - {Key: "Name", Value: "WorkflowContext"}, - } -} - -// serializeAnnotation serializes a workflow annotation if non-empty. -func serializeAnnotation(annotation string) bson.D { - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$Annotation"}, - {Key: "Description", Value: annotation}, - } -} - -// appendActivityBaseFields appends common activity fields to a BSON doc. -// If annotation is non-empty, it serializes as an object; otherwise null. -func appendActivityBaseFields(doc bson.D, annotation string) bson.D { - var annotationValue any - if annotation != "" { - annotationValue = serializeAnnotation(annotation) - } - return append(doc, - bson.E{Key: "Annotation", Value: annotationValue}, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "RelativeMiddlePoint", Value: ""}, - bson.E{Key: "Size", Value: ""}, - ) -} - -// serializeBoundaryEvents serializes boundary events for workflow activities. -func serializeBoundaryEvents(events []*workflows.BoundaryEvent) bson.A { - arr := bson.A{int32(2)} // array type marker (BoundaryEvents use marker 2) - for _, event := range events { - eventID := string(event.ID) - if eventID == "" { - eventID = generateUUID() - } - - typeName := "Workflows$InterruptingTimerBoundaryEvent" - switch event.EventType { - case "NonInterruptingTimer": - typeName = "Workflows$NonInterruptingTimerBoundaryEvent" - case "Timer": - typeName = "Workflows$TimerBoundaryEvent" - case "InterruptingTimer": - typeName = "Workflows$InterruptingTimerBoundaryEvent" - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(eventID)}, - {Key: "$Type", Value: typeName}, - {Key: "Caption", Value: event.Caption}, - } - - if event.TimerDelay != "" { - doc = append(doc, bson.E{Key: "FirstExecutionTime", Value: event.TimerDelay}) - } - - if event.Flow != nil { - doc = append(doc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(event.Flow)}) - } - - doc = append(doc, bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}) - - if typeName == "Workflows$NonInterruptingTimerBoundaryEvent" { - doc = append(doc, bson.E{Key: "Recurrence", Value: nil}) - } - - arr = append(arr, doc) - } - return arr -} - -// emptyBoundaryEvents returns an empty boundary events array marker. -func emptyBoundaryEvents() bson.A { - return bson.A{int32(2)} -} - -// serializeWorkflowFlow serializes a workflow flow with its activities. -func serializeWorkflowFlow(flow *workflows.Flow) bson.D { - flowID := string(flow.ID) - if flowID == "" { - flowID = generateUUID() - } - - activities := bson.A{int32(3)} // array type marker - for _, act := range flow.Activities { - actDoc := serializeWorkflowActivity(act) - if actDoc != nil { - activities = append(activities, actDoc) - } - } - - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(flowID)}, - {Key: "$Type", Value: "Workflows$Flow"}, - {Key: "Activities", Value: activities}, - } -} - -// SerializeWorkflowActivity dispatches to the correct activity serializer. -// Exported for use by the ALTER WORKFLOW executor. -func SerializeWorkflowActivity(act workflows.WorkflowActivity, useCallMicroflowActivityName bool) bson.D { - d := serializeWorkflowActivity(act) - renameCallMicroflowTypeBSON(d, useCallMicroflowActivityName) - return d -} - -// serializeWorkflowActivity dispatches to the correct serializer. -func serializeWorkflowActivity(act workflows.WorkflowActivity) bson.D { - switch a := act.(type) { - case *workflows.UserTask: - return serializeUserTask(a) - case *workflows.CallMicroflowTask: - return serializeCallMicroflowTask(a) - case *workflows.CallWorkflowActivity: - return serializeCallWorkflowActivity(a) - case *workflows.ExclusiveSplitActivity: - return serializeExclusiveSplit(a) - case *workflows.ParallelSplitActivity: - return serializeParallelSplit(a) - case *workflows.JumpToActivity: - return serializeJumpTo(a) - case *workflows.WaitForTimerActivity: - return serializeWaitForTimer(a) - case *workflows.WaitForNotificationActivity: - return serializeWaitForNotification(a) - case *workflows.StartWorkflowActivity: - return serializeStartWorkflow(a) - case *workflows.EndWorkflowActivity: - return serializeEndWorkflow(a) - case *workflows.EndOfParallelSplitPathActivity: - return serializeEndOfPath("Workflows$EndOfParallelSplitPathActivity", &a.BaseWorkflowActivity) - case *workflows.EndOfBoundaryEventPathActivity: - return serializeEndOfPath("Workflows$EndOfBoundaryEventPathActivity", &a.BaseWorkflowActivity) - case *workflows.WorkflowAnnotationActivity: - return serializeWorkflowAnnotationActivity(a) - default: - return nil - } -} - -func activityID(a *workflows.BaseWorkflowActivity) string { - if string(a.ID) != "" { - return string(a.ID) - } - return generateUUID() -} - -func serializeUserTask(a *workflows.UserTask) bson.D { - // UserTask was deleted in Mendix 10.12.0, replaced by SingleUserTaskActivity. - typeName := "Workflows$SingleUserTaskActivity" - if a.IsMulti { - typeName = "Workflows$MultiUserTaskActivity" - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: typeName}, - } - - // Annotation (null or object) - var annotationValue any - if a.Annotation != "" { - annotationValue = serializeAnnotation(a.Annotation) - } - doc = append(doc, bson.E{Key: "Annotation", Value: annotationValue}) - - // AutoAssignSingleTargetUser - doc = append(doc, bson.E{Key: "AutoAssignSingleTargetUser", Value: false}) - - // AwaitAllUsers (MultiUserTaskActivity only) - if a.IsMulti { - doc = append(doc, bson.E{Key: "AwaitAllUsers", Value: false}) - } - - // BoundaryEvents (always present, even if empty) - if len(a.BoundaryEvents) > 0 { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: serializeBoundaryEvents(a.BoundaryEvents)}) - } else { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: emptyBoundaryEvents()}) - } - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - ) - - // CompletionCriteria (MultiUserTaskActivity only) — must reference first outcome ID - if a.IsMulti { - // Pre-assign ID to first outcome so FallbackOutcomePointer can reference it - if len(a.Outcomes) > 0 && a.Outcomes[0].ID == "" { - a.Outcomes[0].ID = model.ID(generateUUID()) - } - fallbackID := "" - if len(a.Outcomes) > 0 { - fallbackID = string(a.Outcomes[0].ID) - } else { - fallbackID = generateUUID() - } - doc = append(doc, bson.E{Key: "CompletionCriteria", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$ConsensusCompletionCriteria"}, - {Key: "FallbackOutcomePointer", Value: idToBsonBinary(fallbackID)}, - }}) - } - - doc = append(doc, - bson.E{Key: "DueDate", Value: a.DueDate}, - bson.E{Key: "Name", Value: a.Name}, - ) - - doc = append(doc, bson.E{Key: "OnCreatedEvent", Value: serializeOnCreatedEvent(a.OnCreated)}) - - // Outcomes - outcomes := bson.A{int32(3)} - for _, outcome := range a.Outcomes { - outcomes = append(outcomes, serializeUserTaskOutcome(outcome)) - } - doc = append(doc, bson.E{Key: "Outcomes", Value: outcomes}) - - doc = append(doc, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "RelativeMiddlePoint", Value: ""}, - bson.E{Key: "Size", Value: ""}, - ) - - // TaskDescription - doc = append(doc, bson.E{Key: "TaskDescription", Value: serializeWorkflowStringTemplate(a.TaskDescription)}) - - // TaskName - taskName := a.TaskName - if taskName == "" { - taskName = a.Caption - } - doc = append(doc, bson.E{Key: "TaskName", Value: serializeWorkflowStringTemplate(taskName)}) - - // TaskPage (PageReference - required, never null) - doc = append(doc, bson.E{Key: "TaskPage", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$PageReference"}, - {Key: "Page", Value: a.Page}, - }}) - - // TargetUserInput (MultiUserTaskActivity only) — always AllUserInput - if a.IsMulti { - doc = append(doc, bson.E{Key: "TargetUserInput", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$AllUserInput"}, - }}) - } - - // UserTargeting (NoUserTargeting when not specified) - if a.UserSource != nil { - doc = append(doc, bson.E{Key: "UserTargeting", Value: serializeUserTargeting(a.UserSource)}) - } else { - doc = append(doc, bson.E{Key: "UserTargeting", Value: bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$NoUserTargeting"}, - }}) - } - - return doc -} - -func serializeUserTargeting(source workflows.UserSource) bson.D { - switch s := source.(type) { - case *workflows.MicroflowBasedUserSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$MicroflowUserTargeting"}, - {Key: "Microflow", Value: s.Microflow}, - } - case *workflows.XPathBasedUserSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$XPathUserTargeting"}, - {Key: "XPathConstraint", Value: s.XPath}, - } - case *workflows.MicroflowGroupSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$MicroflowGroupTargeting"}, - {Key: "Microflow", Value: s.Microflow}, - } - case *workflows.XPathGroupSource: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$XPathGroupTargeting"}, - {Key: "XPathConstraint", Value: s.XPath}, - } - default: - return bson.D{ - {Key: "$ID", Value: idToBsonBinary(generateUUID())}, - {Key: "$Type", Value: "Workflows$NoUserTargeting"}, - } - } -} - -func serializeUserTaskOutcome(outcome *workflows.UserTaskOutcome) bson.D { - outcomeID := string(outcome.ID) - if outcomeID == "" { - outcomeID = generateUUID() - } - - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(outcomeID)}, - {Key: "$Type", Value: "Workflows$UserTaskOutcome"}, - } - - if outcome.Flow != nil { - doc = append(doc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(outcome.Flow)}) - } - - doc = append(doc, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "Value", Value: outcome.Value}, - ) - - return doc -} - -func serializeCallMicroflowTask(a *workflows.CallMicroflowTask) bson.D { - typeName := "Workflows$CallMicroflowTask" - if a.IsAgent { - typeName = "Workflows$AIAgentTaskActivity" // same shape, 11.9+ - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: typeName}, - } - - // Annotation - var annotationValue any - if a.Annotation != "" { - annotationValue = serializeAnnotation(a.Annotation) - } - doc = append(doc, bson.E{Key: "Annotation", Value: annotationValue}) - - // BoundaryEvents (always present) - if len(a.BoundaryEvents) > 0 { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: serializeBoundaryEvents(a.BoundaryEvents)}) - } else { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: emptyBoundaryEvents()}) - } - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Microflow", Value: a.Microflow}, - bson.E{Key: "Name", Value: a.Name}, - ) - - // Outcomes - outcomes := bson.A{int32(3)} - for _, outcome := range a.Outcomes { - outcomes = append(outcomes, serializeConditionOutcome(outcome)) - } - doc = append(doc, bson.E{Key: "Outcomes", Value: outcomes}) - - // ParameterMappings (always present) - mappings := bson.A{int32(2)} - for _, pm := range a.ParameterMappings { - pmID := string(pm.ID) - if pmID == "" { - pmID = generateUUID() - } - mappings = append(mappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(pmID)}, - {Key: "$Type", Value: "Workflows$MicroflowCallParameterMapping"}, - {Key: "Expression", Value: pm.Expression}, - {Key: "Parameter", Value: pm.Parameter}, - }) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: mappings}) - - doc = append(doc, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "RelativeMiddlePoint", Value: ""}, - bson.E{Key: "Size", Value: ""}, - ) - - return doc -} - -func serializeCallWorkflowActivity(a *workflows.CallWorkflowActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$CallWorkflowActivity"}, - } - - // Annotation - var annotationValue any - if a.Annotation != "" { - annotationValue = serializeAnnotation(a.Annotation) - } - doc = append(doc, bson.E{Key: "Annotation", Value: annotationValue}) - - // BoundaryEvents (always present) - if len(a.BoundaryEvents) > 0 { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: serializeBoundaryEvents(a.BoundaryEvents)}) - } else { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: emptyBoundaryEvents()}) - } - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "ExecuteAsync", Value: false}, - bson.E{Key: "Name", Value: a.Name}, - ) - - // ParameterMappings (always present, marker int32(2)) - paramMappings := bson.A{int32(2)} - for _, pm := range a.ParameterMappings { - pmID := string(pm.ID) - if pmID == "" { - pmID = generateUUID() - } - paramMappings = append(paramMappings, bson.D{ - {Key: "$ID", Value: idToBsonBinary(pmID)}, - {Key: "$Type", Value: "Workflows$WorkflowCallParameterMapping"}, - {Key: "Expression", Value: pm.Expression}, - {Key: "Parameter", Value: pm.Parameter}, - }) - } - doc = append(doc, bson.E{Key: "ParameterMappings", Value: paramMappings}) - - doc = append(doc, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "RelativeMiddlePoint", Value: ""}, - bson.E{Key: "Size", Value: ""}, - bson.E{Key: "Workflow", Value: a.Workflow}, - ) - - return doc -} - -func serializeExclusiveSplit(a *workflows.ExclusiveSplitActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$ExclusiveSplitActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Expression", Value: a.Expression}, - bson.E{Key: "Name", Value: a.Name}, - ) - - outcomes := bson.A{int32(3)} - for _, outcome := range a.Outcomes { - outcomes = append(outcomes, serializeConditionOutcome(outcome)) - } - doc = append(doc, bson.E{Key: "Outcomes", Value: outcomes}) - - return doc -} - -func serializeConditionOutcome(outcome workflows.ConditionOutcome) bson.D { - switch o := outcome.(type) { - case *workflows.BooleanConditionOutcome: - outcomeID := string(o.ID) - if outcomeID == "" { - outcomeID = generateUUID() - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(outcomeID)}, - {Key: "$Type", Value: "Workflows$BooleanConditionOutcome"}, - {Key: "Value", Value: o.Value}, - } - if o.Flow != nil { - doc = append(doc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(o.Flow)}) - } - return doc - case *workflows.EnumerationValueConditionOutcome: - outcomeID := string(o.ID) - if outcomeID == "" { - outcomeID = generateUUID() - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(outcomeID)}, - {Key: "$Type", Value: "Workflows$EnumerationValueConditionOutcome"}, - {Key: "Value", Value: o.Value}, - } - if o.Flow != nil { - doc = append(doc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(o.Flow)}) - } - return doc - case *workflows.VoidConditionOutcome: - outcomeID := string(o.ID) - if outcomeID == "" { - outcomeID = generateUUID() - } - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(outcomeID)}, - {Key: "$Type", Value: "Workflows$VoidConditionOutcome"}, - } - if o.Flow != nil { - doc = append(doc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(o.Flow)}) - } - return doc - default: - return nil - } -} - -func serializeParallelSplit(a *workflows.ParallelSplitActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$ParallelSplitActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - ) - - outcomes := bson.A{int32(3)} - for _, outcome := range a.Outcomes { - outcomeID := string(outcome.ID) - if outcomeID == "" { - outcomeID = generateUUID() - } - outDoc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(outcomeID)}, - {Key: "$Type", Value: "Workflows$ParallelSplitOutcome"}, - } - if outcome.Flow != nil { - outDoc = append(outDoc, bson.E{Key: "Flow", Value: serializeWorkflowFlow(outcome.Flow)}) - } - outDoc = append(outDoc, bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}) - outcomes = append(outcomes, outDoc) - } - doc = append(doc, bson.E{Key: "Outcomes", Value: outcomes}) - - return doc -} - -func serializeJumpTo(a *workflows.JumpToActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$JumpToActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - bson.E{Key: "TargetActivity", Value: a.TargetActivity}, - ) - - return doc -} - -func serializeWaitForTimer(a *workflows.WaitForTimerActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$WaitForTimerActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Delay", Value: a.DelayExpression}, - bson.E{Key: "Name", Value: a.Name}, - ) - - return doc -} - -func serializeWaitForNotification(a *workflows.WaitForNotificationActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$WaitForNotificationActivity"}, - } - - // Annotation - var annotationValue any - if a.Annotation != "" { - annotationValue = serializeAnnotation(a.Annotation) - } - doc = append(doc, bson.E{Key: "Annotation", Value: annotationValue}) - - // BoundaryEvents (always present) - if len(a.BoundaryEvents) > 0 { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: serializeBoundaryEvents(a.BoundaryEvents)}) - } else { - doc = append(doc, bson.E{Key: "BoundaryEvents", Value: emptyBoundaryEvents()}) - } - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}, - bson.E{Key: "RelativeMiddlePoint", Value: ""}, - bson.E{Key: "Size", Value: ""}, - ) - - return doc -} - -func serializeStartWorkflow(a *workflows.StartWorkflowActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$StartWorkflowActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - ) - - return doc -} - -func serializeEndWorkflow(a *workflows.EndWorkflowActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$EndWorkflowActivity"}, - } - - doc = appendActivityBaseFields(doc, a.Annotation) - - doc = append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - ) - - return doc -} - -// serializeEndOfPath writes the end-of-path marker Mendix stores as the last -// activity of a parallel split path or a boundary event path. Same shape as -// serializeEndWorkflow; only the $Type differs. -func serializeEndOfPath(typeName string, a *workflows.BaseWorkflowActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(a))}, - {Key: "$Type", Value: typeName}, - } - doc = appendActivityBaseFields(doc, a.Annotation) - return append(doc, - bson.E{Key: "Caption", Value: a.Caption}, - bson.E{Key: "Name", Value: a.Name}, - ) -} - -func serializeWorkflowAnnotationActivity(a *workflows.WorkflowAnnotationActivity) bson.D { - doc := bson.D{ - {Key: "$ID", Value: idToBsonBinary(activityID(&a.BaseWorkflowActivity))}, - {Key: "$Type", Value: "Workflows$Annotation"}, - {Key: "Description", Value: a.Description}, - } - doc = append(doc, bson.E{Key: "PersistentId", Value: idToBsonBinary(generateUUID())}) - doc = append(doc, bson.E{Key: "RelativeMiddlePoint", Value: ""}) - doc = append(doc, bson.E{Key: "Size", Value: ""}) - return doc -} From 7209ea8a8dae1643132762d196a0474e0d150dfe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 11:40:50 +0000 Subject: [PATCH 06/12] fix(describe): keep a DataGrid2 column's filter beside its custom content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A column has two Widgets-typed slots — `content` and `filter` — and the write path fills both. The reader took the FIRST widgets-typed property it met instead of keying on the resolved property key, and the writer emits column properties alphabetically, so `content` won and the filter was dropped. `describe → exec`, which is how a page is copied or edited, then deleted the filter, with mxcli check, exec and mx check clean at both ends. A filter-only column round-tripped by accident: with `content` empty the filter landed in the content list and was re-emitted in the column body, where the builder routes it back to the filter slot by widget type. That is why the common shape looked correct and only the combination broke. Route on the property key instead, keeping the first-wins heuristic only for a document whose keys do not resolve, and emit both lists in the column body. Measured on 11.6.6: the round trip now reports `Unchanged page` — the description reproduces the stored document — where it reported `Replaced page` and lost the filter before. Fixes ako/mxcli#489. Reported upstream as mendixlabs/mxcli#1111, where the describe gap read as a missing DataGrid2 capability; the capability is there — the same column renders checkbox cells and a working filter in a browser on 11.6.6. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G5JMoL3NegeVcn5SwSCqFK --- .../fix-issue/findings/mdl-executor.jsonl | 1 + ...datagrid-489-column-content-and-filter.mdl | 59 ++++++ mdl/executor/cmd_pages_describe.go | 3 +- .../cmd_pages_describe_column_filter_test.go | 191 ++++++++++++++++++ mdl/executor/cmd_pages_describe_output.go | 9 +- mdl/executor/cmd_pages_describe_pluggable.go | 41 +++- 6 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 mdl-examples/bug-tests/datagrid-489-column-content-and-filter.mdl create mode 100644 mdl/executor/cmd_pages_describe_column_filter_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 4bfe200a91..55d204a2b8 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -628,3 +628,4 @@ {"area":"mdl/executor","date":"2026-09-15","symptom":"TestRoundtripPage_MicroflowButtonWithCurrentObject failed on main and on every branch cut from it: 'Expected Target: $currentObject parameter mapping in describe output', while the printed output plainly contained the mapping as \"Target\": $currentObject. Unit tests were green; only the integration suite (-tags integration) caught it.","cause":"Not a describe regression at all. mdl/executor/identifier_quoting.go's mdlIdent quotes any identifier that does not LEX as a bare identifier, running the real ANTLR lexer. #476 (notify workflow ... TARGET) added `TARGET: T A R G E T;` to MDLLexer.g4, so the parameter named Target began lexing as a keyword token and DESCRIBE started quoting it. The output became MORE correct; the test's exact-substring assertion went stale.","file":"mdl/executor/roundtrip_page_test.go","fix":"Made the assertion quoting-agnostic (accepts Target: or \\\"Target\\\":). Controlled by renaming the expected parameter to a name that is absent, which still fails — so the assertion continues to detect a genuinely dropped mapping rather than passing on anything.","insight":"Adding a keyword to MDLLexer.g4 silently reformats DESCRIBE output for every existing element whose NAME matches that keyword, anywhere mdlIdent is used — the grammar change and the broken test are in different packages with no compile-time link, so nothing points from one to the other. When adding a token, grep the test tree for exact-substring assertions containing that word: here `grep -rn '\"Target: '` found the single collision in seconds, where reading the #476 diff never would have. The deeper rule is that an exact-substring assertion on DESCRIBE output encodes a quoting decision the test does not care about; assert the mapping quoting-agnostically, or re-parse the output, since what a roundtrip test means to check is that the mapping survived. Note the input side did NOT break: TARGET was added to the non-reserved-keyword rule, so scripts writing `Target:` unquoted still parse — which is why check-mdl's 544 scripts stayed green and only this one output assertion moved."} {"area": "mdl/executor", "date": "2026-09-15", "symptom": "check --references rejected a page that mxbuild builds at 0 errors: 'the constraint on Administration.Account names \"System.UserRoles\", which is neither an attribute nor an association of it'. Administration.Account extends System.User and System.UserRoles is declared from System.User, so it IS an association of it, by inheritance. Every inherited association was a false positive, and separately so was every cross-module one. Inherited ATTRIBUTES were fine throughout, which is what made it look like a narrow bug rather than a whole axis.", "cause": "associationTargetFrom matched the start entity against the association's two ends by exact equality and scanned only dm.Associations. Its doc comment said a specialisation deliberately returns false, 'the cost of being wrong is a false error on a working script' - sound while its only caller (resolveMemberOnEntity, typing an association retrieve) treated false as silence. The new XPath constraint checker's noteQualified then called the same helper and treated false as EVIDENCE, so the exact case the comment declined to chase became the finding. noteQualified did carry a three-valued guard, but it tested whether the BASE ENTITY was known - the wrong axis; Administration.Account is known.", "file": "mdl/executor/validate_member_refs.go", "fix": "Replaced the boolean with assocResolution (resolved / missing / notAnEnd / unknown). The start entity is matched through its generalization chain (generalizationChain, built on findEntityByQN), and a chain that could not be walked to its root yields assocUnknown = silence. associationEndsByName also reads dm.CrossAssociations, whose far end is held by NAME not by element ID. noteQualified reports only assocMissing and assocNotAnEnd.", "insight": "A helper whose contract is 'false when it cannot establish the answer' is safe only while every caller treats false as silence; the moment one caller treats it as evidence, the comment promising restraint becomes the specification of a false positive. A boolean cannot carry 'no' and 'don't know' to two callers that need to tell them apart - if the codebase already has a three-valued resolver next door (memberResolution, ten lines up), reusing the two-valued sibling is the smell. Two cheap guards would have caught it: a fixture entity with a GENERALIZATION carrying an association, and one CROSS-MODULE association - a fixture of flat single-module entities cannot distinguish a correct resolver from one comparing two names. Establish the verdict with the build, not by reading: mx check on the rejected page said 0 errors, which settles it in one run."} {"area": "mdl/executor", "date": "2026-09-16", "symptom": "mendixlabs/mxcli#1103's real defect: `retrieve $reqs from Mod.E where … limit 1;` followed by `head($reqs)` passed `mxcli check --references` and failed the build with CE0097 'The selected reqs variable must be of type List'. Inside a .test.mdl file it was worse — the injected test just failed to build, with no error text at all on the --attach path.", "cause": "cmd_microflows_builder_actions.go maps `limit \"1\"` with no offset to microflows.RangeTypeFirst — Mendix's 'First object' range — so the output variable is an OBJECT, not a one-element list. That is deliberate and documented (MDL_QUICK_REFERENCE), but nothing between the author and mxbuild said so: describe re-emits `limit 1`, so an object retrieve and a list retrieve are byte-identical MDL.", "file": "mdl/executor/validate_microflow_retrieve_single.go", "fix": "MDL-RETRIEVE01: track variables bound by a limit-1-no-offset retrieve in statement order (a rebinding clears them) and flag a later list use — list operation, aggregate, loop, ADD/REMOVE. Keyed on exactly the writer's condition. The message names CE0097 and both working spellings.", "insight": "A silent type change is the expensive kind, and this one had every property that makes it hard: the source text is identical on both sides, DESCRIBE round-trips it unchanged, and the only signal is a CE code from a tool at the far end of a build. When a clause changes a variable's CARDINALITY rather than its value, the check that catches it has to key on exactly the same condition as the writer — `limit == \"1\" && offset == \"\"` here, copied from the builder — or the diagnostic and the model disagree, which is worse than neither. The confusion is also structural, not carelessness: the SAME word means the opposite elsewhere in MDL, since `import from mapping … first` binds an object and `… limit 1` a one-element list. Where a language contradicts itself, the message must name the working spelling rather than only refuse. Cheap control worth copying: run the whole mdl-examples corpus (`make check-mdl`, 558 scripts) after adding a rule — zero new failures is a real statement about false positives that unit tests cannot make."} +{"area": "mdl/executor", "date": "2026-09-16", "symptom": "A DataGrid2 column holding BOTH a custom-content widget and a filter widget (`column IsActive { checkbox cbActive (Editable: Never) dropdownfilter ddfActive }`) is written correctly \u2014 measured on 11.6.6: showContentAs=customContent, content=[cbActive], filter=[ddfActive], mx check 0 errors, checkbox cells AND a working Yes/No filter in the browser \u2014 but `describe page` emits only the checkbox, so describe -> exec DELETES the filter. Every signal is green at both ends. Reported upstream (mendixlabs/mxcli#1111) as 'DataGrid2 cannot combine content and a filter', i.e. as a missing capability rather than a describe bug.", "cause": "extractDataGrid2Column took the FIRST Widgets-typed property it met (`if len(col.ContentWidgets) == 0`) instead of keying on the resolved propKey, and rawDataGridColumn had no filter field at all. A column has TWO Widgets-typed slots; the writer emits column properties alphabetically, so `content` precedes `filter` and won. A filter-only column round-tripped by ACCIDENT: with content empty the filter landed in ContentWidgets and was re-emitted in the column body, where the builder routes it back to the filter slot by widget type (itemSlotAcceptedChildTypes) \u2014 which is why the common shape looked correct.", "file": "mdl/executor/cmd_pages_describe_pluggable.go", "fix": "rawDataGridColumn gains FilterWidgets; route on propKey (content -> ContentWidgets, filter -> FilterWidgets) with the first-wins heuristic kept only for propKey == \"\" (no key map); outputDataGrid2ColumnV3 opens a body when either list is non-empty and emits content widgets then filter widgets. ako/mxcli#489.", "insight": "Two sibling slots of the same TYPE need routing by KEY, not by shape \u2014 a reader that asks 'is this a Widgets array?' cannot tell `content` from `filter`, and the writer's alphabetical order silently decides which one survives. Worth checking wherever a describer matches on value shape: the key map is already in hand (WidgetProperty.TypePointer -> PropertyType $ID; note the inner WidgetValue.TypePointer points at the ValueType $ID instead, so probing at the wrong node level makes the map look broken when it is not). The accidental-success case is the real trap: filter-only columns worked, so the gap only appeared in the combination, and the reporter rationalised it as a platform limitation \u2014 the widget XML says otherwise (`content` and `filter` are independent `widgets` properties with no dependency on showContentAs). Control that settles it in one step: re-exec the describe output and read the verb \u2014 `Unchanged page` means the description reproduces the document, `Replaced page` means it does not."} diff --git a/mdl-examples/bug-tests/datagrid-489-column-content-and-filter.mdl b/mdl-examples/bug-tests/datagrid-489-column-content-and-filter.mdl new file mode 100644 index 0000000000..93ec405d4d --- /dev/null +++ b/mdl-examples/bug-tests/datagrid-489-column-content-and-filter.mdl @@ -0,0 +1,59 @@ +-- ============================================================================ +-- ako/mxcli#489 — a DataGrid2 column with BOTH custom content and a filter +-- ============================================================================ +-- +-- Reported upstream as a missing capability (mendixlabs/mxcli#1111: "the column +-- block accepts either a content widget or a filter widget, not both"). The +-- write path already supported it; DESCRIBE did not, so a describe → exec round +-- trip deleted the filter. +-- +-- Measured on 11.6.6 before the fix: +-- * stored BSON: showContentAs=customContent, content=[cbActive], +-- filter=[ddfActive] -- both slots filled +-- * `mx check`: 0 errors +-- * browser: checkbox cells render AND the Yes/No/(all) filter works +-- * `describe page`: the dropdownfilter is ABSENT +-- * re-exec of that description: the `filter` slot is gone +-- +-- A filter-only column round-tripped by accident: with `content` empty the +-- filter landed in the content list and was re-emitted in the column body, where +-- the builder routes it back to the filter slot by widget type. That accident is +-- why this went unnoticed — the common shape looked fine. +-- +-- After the fix, describing this page and re-executing the output reports +-- `Unchanged page`: the description reproduces the stored document exactly. +-- +-- Note for the "visual checkbox" half of the upstream report: a read-only check +-- box renders as the text "Yes"/"No" unless ReadOnlyStyle is Control — see +-- ako/mxcli#490 and readonlystyle-490-checkbox-control.mdl. +-- ============================================================================ + +create entity BugTests.Customer ( + Name: string(200), + IsActive: boolean +); + +create or replace page BugTests.P_489_ColumnContentAndFilter ( + Title: 'Column with content and filter', + Layout: Atlas_Core.Atlas_Default +) { + datagrid dgCustomers ( + DataSource: database from BugTests.Customer, + Selection: None + ) { + column Name (Attribute: Name, Caption: 'Name') { + textfilter tfName + } + -- The column under test: a custom-content cell AND a filter in one block. + column IsActive (Attribute: IsActive, Caption: 'Active') { + checkbox cbActive (Attribute: IsActive, Editable: Never, ReadOnlyStyle: Control) + dropdownfilter ddfActive + } + } +}; + +-- Verify by hand: +-- mxcli exec datagrid-489-column-content-and-filter.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe page BugTests.P_489_ColumnContentAndFilter" +-- -> the column body must list BOTH cbActive and ddfActive +-- re-exec that description -> "Unchanged page", not "Replaced page" diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index e1c0f6c06b..c8ef55b83b 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -577,7 +577,8 @@ type rawDataGridColumn struct { Caption string CaptionParams []string // Parameters for template placeholders in caption ShowContentAs string // "attribute", "customContent", or "dynamicText" - ContentWidgets []rawWidget // Widgets inside the column (for custom content) + ContentWidgets []rawWidget // Widgets in the column's `content` slot (custom content) + FilterWidgets []rawWidget // Widgets in the column's `filter` slot (text/number/date/dropdown filter) DynamicText string // Template text for dynamicText mode DynamicTextParams []string // Parameters for dynamicText template Alignment string // "left", "center", or "right" (empty = default "left") diff --git a/mdl/executor/cmd_pages_describe_column_filter_test.go b/mdl/executor/cmd_pages_describe_column_filter_test.go new file mode 100644 index 0000000000..a4cf7c30aa --- /dev/null +++ b/mdl/executor/cmd_pages_describe_column_filter_test.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#489: DESCRIBE PAGE drops a DataGrid2 column's filter widget when the +// column also carries custom content. +// +// A column's `content` and `filter` are two independent Widgets-typed slots, and +// the write path fills both (measured on 11.6.6: showContentAs=customContent, +// content=[cbActive], filter=[ddfActive], mx check 0 errors, both working in the +// browser). The reader took the FIRST widgets-typed property it met instead of +// keying on the resolved property key, and the writer emits column properties +// alphabetically — so `content` won and the filter was dropped. A filter-only +// column survived by accident: with `content` empty the filter landed in the +// content list and was re-emitted in the column body, where the builder routes +// it back to the filter slot by widget type. +// +// The round trip is what makes this more than cosmetic: describing such a page +// and re-executing the output deletes the filter, with mxcli check, exec and +// mx check clean at both ends. +package executor + +import ( + "bytes" + "strings" + "testing" +) + +// buildDataGridWithContentAndFilterColumn mirrors the shape Mendix stores for a +// DataGrid2 whose single column holds BOTH a custom-content widget and a filter +// widget. Property keys resolve through the widget's own PropertyTypes, keyed by +// the WidgetProperty's TypePointer (the PropertyType $ID — the inner WidgetValue +// points at the ValueType $ID instead, which is a different node). +func buildDataGridWithContentAndFilterColumn() map[string]any { + const ( + idColumns = "type-id-columns" + idHeader = "type-id-header" + idShowContentAs = "type-id-showcontentas" + idContent = "type-id-content" + idFilter = "type-id-filter" + ) + + colProp := func(typePointer string, value map[string]any) map[string]any { + return map[string]any{"TypePointer": typePointer, "Value": value} + } + + return map[string]any{ + "Name": "dgTest", + "Type": map[string]any{ + "WidgetId": "com.mendix.widget.web.datagrid.Datagrid", + "ObjectType": map[string]any{ + "PropertyTypes": []any{ + map[string]any{ + "$ID": idColumns, "PropertyKey": "columns", + "ValueType": map[string]any{ + "ObjectType": map[string]any{ + "PropertyTypes": []any{ + map[string]any{"$ID": idHeader, "PropertyKey": "header", + "ValueType": map[string]any{"Type": "TextTemplate"}}, + map[string]any{"$ID": idShowContentAs, "PropertyKey": "showContentAs", + "ValueType": map[string]any{"Type": "Enumeration"}}, + map[string]any{"$ID": idContent, "PropertyKey": "content", + "ValueType": map[string]any{"Type": "Widgets"}}, + map[string]any{"$ID": idFilter, "PropertyKey": "filter", + "ValueType": map[string]any{"Type": "Widgets"}}, + }, + }, + }, + }, + }, + }, + }, + "Object": map[string]any{ + "Properties": []any{ + map[string]any{ + "TypePointer": idColumns, + "Value": map[string]any{ + "Objects": []any{ + map[string]any{ + // Alphabetical, as the writer emits them: content before filter. + "Properties": []any{ + colProp(idContent, map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$CheckBox", + "Name": "cbActive", + }, + }, + }), + colProp(idFilter, map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "CustomWidgets$CustomWidget", + "Name": "ddfActive", + "Type": map[string]any{ + "WidgetId": "com.mendix.widget.web.datagriddropdownfilter.DatagridDropdownFilter", + }, + }, + }, + }), + colProp(idHeader, map[string]any{ + "TextTemplate": map[string]any{ + "Template": map[string]any{ + "Items": []any{ + map[string]any{"Text": "Active"}, + }, + }, + }, + }), + colProp(idShowContentAs, map[string]any{ + "PrimitiveValue": "customContent", + }), + }, + }, + }, + }, + }, + }, + }, + } +} + +// The read half: both slots must survive extraction, in their own lists. +func TestDataGrid2Column_KeepsContentAndFilterWidgets(t *testing.T) { + cols := extractDataGrid2Columns(nil, buildDataGridWithContentAndFilterColumn()) + if len(cols) != 1 { + t.Fatalf("expected 1 column, got %d", len(cols)) + } + col := cols[0] + if len(col.ContentWidgets) != 1 || col.ContentWidgets[0].Name != "cbActive" { + t.Errorf("content widgets = %+v, want one widget named cbActive", col.ContentWidgets) + } + if len(col.FilterWidgets) != 1 || col.FilterWidgets[0].Name != "ddfActive" { + t.Fatalf("filter widgets = %+v, want one widget named ddfActive — the column's "+ + "filter was dropped, so describe→exec deletes it (ako/mxcli#489)", col.FilterWidgets) + } +} + +// A filter-only column keeps working: its filter belongs in FilterWidgets now +// rather than riding along in the content list, and DESCRIBE must still emit it. +func TestDataGrid2Column_FilterOnlyColumnStillRoundTrips(t *testing.T) { + w := buildDataGridWithContentAndFilterColumn() + // Drop the content property, leaving filter + header + showContentAs. + cols := w["Object"].(map[string]any)["Properties"].([]any)[0].(map[string]any) + objects := cols["Value"].(map[string]any)["Objects"].([]any) + colProps := objects[0].(map[string]any)["Properties"].([]any) + objects[0].(map[string]any)["Properties"] = colProps[1:] + + got := extractDataGrid2Columns(nil, w) + if len(got) != 1 { + t.Fatalf("expected 1 column, got %d", len(got)) + } + if len(got[0].ContentWidgets) != 0 { + t.Errorf("content widgets = %+v, want none", got[0].ContentWidgets) + } + if len(got[0].FilterWidgets) != 1 || got[0].FilterWidgets[0].Name != "ddfActive" { + t.Errorf("filter widgets = %+v, want one widget named ddfActive", got[0].FilterWidgets) + } +} + +// The emit half: both widgets must appear in the column body. Testing through +// outputWidgetMDLV3 rather than the column helper means removing the emit change +// fails this test — asserting on the helper alone would prove the helper works +// and nothing about the wiring. +func TestDataGrid2Column_EmitsContentAndFilterWidgets(t *testing.T) { + cols := extractDataGrid2Columns(nil, buildDataGridWithContentAndFilterColumn()) + if len(cols) == 0 { + t.Fatal("fixture produced no columns") + } + + var buf bytes.Buffer + ctx := &ExecContext{Output: &buf} + outputWidgetMDLV3(ctx, rawWidget{ + Type: "CustomWidgets$CustomWidget", + RenderMode: "datagrid2", + Name: "dgTest", + WidgetID: "com.mendix.widget.web.datagrid.Datagrid", + DataGridColumns: cols, + }, 0) + + out := buf.String() + if !strings.Contains(out, "cbActive") { + t.Errorf("custom-content widget missing from DESCRIBE output:\n%s", out) + } + if !strings.Contains(out, "ddfActive") { + t.Errorf("filter widget missing from DESCRIBE output — re-executing this output "+ + "deletes the filter (ako/mxcli#489):\n%s", out) + } + // A body, not a bare column line, or the output cannot re-parse. + if !strings.Contains(out, "column Active") || !strings.Contains(out, "{") { + t.Errorf("column should be emitted with a body:\n%s", out) + } +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 5dc16b38df..25ca8c3c44 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -978,7 +978,11 @@ func outputDataGrid2ColumnV3(ctx *ExecContext, prefix, colName string, col rawDa // named Title/Description), mirroring the general widget-name path so DESCRIBE // output re-parses. #619 added mdlIdent for widgets but missed columns (#638). header := fmt.Sprintf("column %s", mdlIdent(colName)) - hasContent := len(col.ContentWidgets) > 0 + // A column body carries the `content` slot's widgets AND the `filter` slot's; + // the builder routes a filter back to its own slot by widget type. Emitting + // only the content widgets deleted the filter of every custom-content column + // on a describe→exec round trip (ako/mxcli#489). + hasContent := len(col.ContentWidgets) > 0 || len(col.FilterWidgets) > 0 if hasContent { // Output column with content block @@ -986,6 +990,9 @@ func outputDataGrid2ColumnV3(ctx *ExecContext, prefix, colName string, col rawDa for _, widget := range col.ContentWidgets { outputWidgetMDLV3(ctx, widget, len(prefix)/2+1) } + for _, widget := range col.FilterWidgets { + outputWidgetMDLV3(ctx, widget, len(prefix)/2+1) + } fmt.Fprintf(ctx.Output, "%s}\n", prefix) } else { // Output simple column line diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 28add25efa..3479741627 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -481,16 +481,25 @@ func extractDataGrid2Column(ctx *ExecContext, colObj map[string]any, colPropKeyM } } - // Check for Widgets array (content property for custom widgets) - if len(col.ContentWidgets) == 0 { - widgets := getBsonArrayElements(value["Widgets"]) - if len(widgets) > 0 { - for _, w := range widgets { - if wMap, ok := w.(map[string]any); ok { - col.ContentWidgets = append(col.ContentWidgets, parseRawWidget(ctx, wMap, entityContext)...) - } - } + // A column has TWO Widgets-typed slots — `content` (custom content) and + // `filter` — so route on the property key. Taking the first Widgets array + // instead dropped the filter of any column that also had custom content: + // the writer emits properties alphabetically, so `content` came first and + // won, and describe→exec then deleted the filter (ako/mxcli#489). + if propKey == "content" || propKey == "filter" { + widgets := parseColumnSlotWidgets(ctx, value, entityContext) + if propKey == "filter" { + col.FilterWidgets = append(col.FilterWidgets, widgets...) + } else { + col.ContentWidgets = append(col.ContentWidgets, widgets...) } + continue + } + + // Fallback for a document whose property keys did not resolve (no key map): + // the first Widgets array is the column's content. + if propKey == "" && len(col.ContentWidgets) == 0 { + col.ContentWidgets = append(col.ContentWidgets, parseColumnSlotWidgets(ctx, value, entityContext)...) } // Check for TextTemplate (could be header or dynamicText property) @@ -1387,3 +1396,17 @@ func anyCustomWidgetDataSource(w map[string]any) *rawDataSource { } return nil } + +// parseColumnSlotWidgets reads the widgets stored in one of a DataGrid2 column's +// Widgets-typed slots (`content` or `filter`). +func parseColumnSlotWidgets(ctx *ExecContext, value map[string]any, entityContext string) []rawWidget { + var out []rawWidget + for _, w := range getBsonArrayElements(value["Widgets"]) { + wMap, ok := w.(map[string]any) + if !ok { + continue + } + out = append(out, parseRawWidget(ctx, wMap, entityContext)...) + } + return out +} From fc61736834bb3ad2c2e320b5025cd77a99a1bbcc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 11:49:57 +0000 Subject: [PATCH 07/12] Point CLAUDE.md at the engine that exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting sdk/mpr left the always-in-context routing document pointing at files that are gone. CLAUDE.md is where a session looks first, so a dead path there does not just go stale — it sends the next reader somewhere that cannot be opened, and every one of these was a routing instruction rather than prose. Eight sites. The architecture tree listed sdk/mpr's reader/writer/parser/utils and no modelsdk at all, which had it describing the deleted engine as the format layer and omitting the real one. The BSON storage-name procedure said to verify against sdk/mpr/parser_microflow.go; that now points at modelsdk/codec and modelsdk/gen. The write-choke-point list named both engines' files — there is one engine, so the list is now one entry and says so. The test-first checklist sent a parser test to sdk/mpr/ and a backend mutation test to mdl/backend/mpr/, neither of which exists. Useful Files offered sdk/mpr/parser.go and writer_widgets.go. Two checklist items were rules about a package that no longer exists, so they are restated as rules about the engine that does: "no sdk/mpr write imports in the executor" becomes "no engine internals in the executor" (modelsdk/mpr, modelsdk/codec, modelsdk/gen), with the note that a missing backend method gets implemented rather than bypassed — which is what the last five slices kept running into. The shared-types rule described sdk/mpr re-exporting aliases; it now states the rule directly and cites modelsdk/mpr/version.ProjectVersion as the cautionary case, since that one duplicates types.ProjectVersion instead of aliasing it and the two print under the same name. Every path the file now names was checked to exist. Docs under docs/ are deliberately untouched: they describe what the code used to do, and ADRs are immutable by convention. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ --- CLAUDE.md | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f7d98d22a..8a8e62533c 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 @@ -891,8 +895,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 From f2be323ceaa54793068fb476ead86c9f46af4126 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 11:54:07 +0000 Subject: [PATCH 08/12] fix(pages): write a check box's ReadOnlyStyle instead of a constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ReadOnlyStyle:` parsed, passed check, and reached a writer that hardcoded "Inherit". DESCRIBE reads and emits the property, so describe → exec on a Studio Pro-authored page silently downgraded Control to Inherit. Not cosmetic: the value decides how a READ-ONLY check box renders — Text gives the words "Yes"/"No", Control gives the disabled checkbox glyph. In a DataGrid2 cell that is the whole of "show a Boolean as a checkbox", and it was unreachable from MDL. pages.CheckBox carries the value, the builder canonicalises it (Inherit / Control / Text, case-insensitive in, Mendix casing out) and refuses an unknown member — mxbuild tolerates one and Studio Pro then cannot open the project — and the codec writes Inherit when unset, so a script that never mentions it produces the document it always did. Verified on 11.6.6: the page mxcli writes from `ReadOnlyStyle: Control` is semantically identical to the hand-patched document that was checked in a browser (re-authoring it reports `Unchanged page`), renders 9 disabled checkbox cells, and the column's drop-down filter still cuts 9 rows to 3. `mx check` 0 errors; 562 MDL example scripts unchanged. Scope is the check box, matching what DESCRIBE reads back today. The other input widgets still write a hardcoded Inherit and are not read back either; the retired sdk/mpr serializer (no live callers) is left alone. Fixes ako/mxcli#490. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G5JMoL3NegeVcn5SwSCqFK --- .../fix-issue/findings/mdl-executor.jsonl | 1 + .../readonlystyle-490-checkbox-control.mdl | 65 ++++++++++++++++++ .../modelsdk/widget_readonlystyle_test.go | 41 ++++++++++++ mdl/backend/modelsdk/widget_write.go | 5 +- .../cmd_pages_builder_readonlystyle_test.go | 67 +++++++++++++++++++ mdl/executor/cmd_pages_builder_v3_widgets.go | 30 +++++++++ sdk/pages/pages_widgets_input.go | 15 ++++- 7 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 mdl-examples/bug-tests/readonlystyle-490-checkbox-control.mdl create mode 100644 mdl/backend/modelsdk/widget_readonlystyle_test.go create mode 100644 mdl/executor/cmd_pages_builder_readonlystyle_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 55d204a2b8..48e81585b1 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -629,3 +629,4 @@ {"area": "mdl/executor", "date": "2026-09-15", "symptom": "check --references rejected a page that mxbuild builds at 0 errors: 'the constraint on Administration.Account names \"System.UserRoles\", which is neither an attribute nor an association of it'. Administration.Account extends System.User and System.UserRoles is declared from System.User, so it IS an association of it, by inheritance. Every inherited association was a false positive, and separately so was every cross-module one. Inherited ATTRIBUTES were fine throughout, which is what made it look like a narrow bug rather than a whole axis.", "cause": "associationTargetFrom matched the start entity against the association's two ends by exact equality and scanned only dm.Associations. Its doc comment said a specialisation deliberately returns false, 'the cost of being wrong is a false error on a working script' - sound while its only caller (resolveMemberOnEntity, typing an association retrieve) treated false as silence. The new XPath constraint checker's noteQualified then called the same helper and treated false as EVIDENCE, so the exact case the comment declined to chase became the finding. noteQualified did carry a three-valued guard, but it tested whether the BASE ENTITY was known - the wrong axis; Administration.Account is known.", "file": "mdl/executor/validate_member_refs.go", "fix": "Replaced the boolean with assocResolution (resolved / missing / notAnEnd / unknown). The start entity is matched through its generalization chain (generalizationChain, built on findEntityByQN), and a chain that could not be walked to its root yields assocUnknown = silence. associationEndsByName also reads dm.CrossAssociations, whose far end is held by NAME not by element ID. noteQualified reports only assocMissing and assocNotAnEnd.", "insight": "A helper whose contract is 'false when it cannot establish the answer' is safe only while every caller treats false as silence; the moment one caller treats it as evidence, the comment promising restraint becomes the specification of a false positive. A boolean cannot carry 'no' and 'don't know' to two callers that need to tell them apart - if the codebase already has a three-valued resolver next door (memberResolution, ten lines up), reusing the two-valued sibling is the smell. Two cheap guards would have caught it: a fixture entity with a GENERALIZATION carrying an association, and one CROSS-MODULE association - a fixture of flat single-module entities cannot distinguish a correct resolver from one comparing two names. Establish the verdict with the build, not by reading: mx check on the rejected page said 0 errors, which settles it in one run."} {"area": "mdl/executor", "date": "2026-09-16", "symptom": "mendixlabs/mxcli#1103's real defect: `retrieve $reqs from Mod.E where … limit 1;` followed by `head($reqs)` passed `mxcli check --references` and failed the build with CE0097 'The selected reqs variable must be of type List'. Inside a .test.mdl file it was worse — the injected test just failed to build, with no error text at all on the --attach path.", "cause": "cmd_microflows_builder_actions.go maps `limit \"1\"` with no offset to microflows.RangeTypeFirst — Mendix's 'First object' range — so the output variable is an OBJECT, not a one-element list. That is deliberate and documented (MDL_QUICK_REFERENCE), but nothing between the author and mxbuild said so: describe re-emits `limit 1`, so an object retrieve and a list retrieve are byte-identical MDL.", "file": "mdl/executor/validate_microflow_retrieve_single.go", "fix": "MDL-RETRIEVE01: track variables bound by a limit-1-no-offset retrieve in statement order (a rebinding clears them) and flag a later list use — list operation, aggregate, loop, ADD/REMOVE. Keyed on exactly the writer's condition. The message names CE0097 and both working spellings.", "insight": "A silent type change is the expensive kind, and this one had every property that makes it hard: the source text is identical on both sides, DESCRIBE round-trips it unchanged, and the only signal is a CE code from a tool at the far end of a build. When a clause changes a variable's CARDINALITY rather than its value, the check that catches it has to key on exactly the same condition as the writer — `limit == \"1\" && offset == \"\"` here, copied from the builder — or the diagnostic and the model disagree, which is worse than neither. The confusion is also structural, not carelessness: the SAME word means the opposite elsewhere in MDL, since `import from mapping … first` binds an object and `… limit 1` a one-element list. Where a language contradicts itself, the message must name the working spelling rather than only refuse. Cheap control worth copying: run the whole mdl-examples corpus (`make check-mdl`, 558 scripts) after adding a rule — zero new failures is a real statement about false positives that unit tests cannot make."} {"area": "mdl/executor", "date": "2026-09-16", "symptom": "A DataGrid2 column holding BOTH a custom-content widget and a filter widget (`column IsActive { checkbox cbActive (Editable: Never) dropdownfilter ddfActive }`) is written correctly \u2014 measured on 11.6.6: showContentAs=customContent, content=[cbActive], filter=[ddfActive], mx check 0 errors, checkbox cells AND a working Yes/No filter in the browser \u2014 but `describe page` emits only the checkbox, so describe -> exec DELETES the filter. Every signal is green at both ends. Reported upstream (mendixlabs/mxcli#1111) as 'DataGrid2 cannot combine content and a filter', i.e. as a missing capability rather than a describe bug.", "cause": "extractDataGrid2Column took the FIRST Widgets-typed property it met (`if len(col.ContentWidgets) == 0`) instead of keying on the resolved propKey, and rawDataGridColumn had no filter field at all. A column has TWO Widgets-typed slots; the writer emits column properties alphabetically, so `content` precedes `filter` and won. A filter-only column round-tripped by ACCIDENT: with content empty the filter landed in ContentWidgets and was re-emitted in the column body, where the builder routes it back to the filter slot by widget type (itemSlotAcceptedChildTypes) \u2014 which is why the common shape looked correct.", "file": "mdl/executor/cmd_pages_describe_pluggable.go", "fix": "rawDataGridColumn gains FilterWidgets; route on propKey (content -> ContentWidgets, filter -> FilterWidgets) with the first-wins heuristic kept only for propKey == \"\" (no key map); outputDataGrid2ColumnV3 opens a body when either list is non-empty and emits content widgets then filter widgets. ako/mxcli#489.", "insight": "Two sibling slots of the same TYPE need routing by KEY, not by shape \u2014 a reader that asks 'is this a Widgets array?' cannot tell `content` from `filter`, and the writer's alphabetical order silently decides which one survives. Worth checking wherever a describer matches on value shape: the key map is already in hand (WidgetProperty.TypePointer -> PropertyType $ID; note the inner WidgetValue.TypePointer points at the ValueType $ID instead, so probing at the wrong node level makes the map look broken when it is not). The accidental-success case is the real trap: filter-only columns worked, so the gap only appeared in the combination, and the reporter rationalised it as a platform limitation \u2014 the widget XML says otherwise (`content` and `filter` are independent `widgets` properties with no dependency on showContentAs). Control that settles it in one step: re-exec the describe output and read the verb \u2014 `Unchanged page` means the description reproduces the document, `Replaced page` means it does not."} +{"area": "mdl/executor", "date": "2026-09-16", "symptom": "`checkbox cb (Attribute: A, Editable: Never, ReadOnlyStyle: Control)` parses, passes `mxcli check`, is reported as executed \u2014 and the stored document keeps ReadOnlyStyle \"Inherit\". DESCRIBE *does* read and emit the property, so describe -> exec on a Studio Pro-authored page silently downgraded Control to Inherit. Visible only in the browser: with Inherit a read-only check box renders the TEXT \"Yes\"/\"No\", with Control the (disabled) checkbox glyph \u2014 measured on 11.6.6 by patching the stored string by hand ('Inherit' and 'Control' are both 7 bytes, so a byte substitution in the .mxunit is a valid document; mx check 0 errors both ways).", "cause": "Three silent layers. sdk/pages.CheckBox had no ReadOnlyStyle field, so buildCheckBoxV3 had nowhere to put the property; the codec writer hardcoded g.SetReadOnlyStyle(\"Inherit\") (and a per-type constant for TextBox/TextArea/DatePicker/RadioButtons, \"Control\" for DataView); and `ReadOnlyStyle` sits in validate_widgets.go's known-property allowlist under the comment \"vocabulary describe page emits\", which silenced the MDL-WIDGET check that flags a property no builder consumes.", "file": "mdl/executor/cmd_pages_builder_v3_widgets.go", "fix": "Add ReadOnlyStyle to pages.CheckBox; read and canonicalise it in buildCheckBoxV3 (Inherit/Control/Text, case-insensitive in, Mendix casing out, unknown value refused); write orDefaultStr(x.ReadOnlyStyle, \"Inherit\") in the codec so an omitted property still produces the document it always did. ako/mxcli#490.", "insight": "An allowlist added so DESCRIBE output re-parses will also silence the warning that a property is going nowhere \u2014 the two uses are indistinguishable from inside the checker, so entries added for the first reason need a consuming builder or they become a licence to drop. The asymmetry is the tell: a describer that READS a property whose writer hardcodes a constant is a round trip that quietly rewrites the user's model, and it is worth grepping for that pairing directly (`grep SetX(\"literal\")` against what extract*/describe reads). Measurement trick worth reusing: when the model layer cannot express a value, patch the stored BSON to the candidate value and boot \u2014 a same-length string substitution keeps the document valid, which settles 'is this the property that changes the rendering?' before writing any Go. The end-to-end control afterwards is the elision verb: re-authoring through MDL over the hand-patched document reported `Unchanged page`, i.e. what mxcli now writes is byte-for-byte the document that was verified in the browser."} diff --git a/mdl-examples/bug-tests/readonlystyle-490-checkbox-control.mdl b/mdl-examples/bug-tests/readonlystyle-490-checkbox-control.mdl new file mode 100644 index 0000000000..c9cfba5876 --- /dev/null +++ b/mdl-examples/bug-tests/readonlystyle-490-checkbox-control.mdl @@ -0,0 +1,65 @@ +-- ============================================================================ +-- ako/mxcli#490 — ReadOnlyStyle on a check box reaches the document +-- ============================================================================ +-- +-- `ReadOnlyStyle:` parsed, passed `mxcli check` (it is in the known-property +-- allowlist as "vocabulary describe page emits"), was reported as executed — +-- and the stored document kept "Inherit". DESCRIBE *did* read and emit it, so +-- describe → exec on a Studio Pro-authored page silently downgraded Control to +-- Inherit. +-- +-- Not cosmetic. Measured on 11.6.6, same document otherwise, `mx check` 0 +-- errors both ways: +-- ReadOnlyStyle: Inherit -> a read-only check box renders the TEXT "Yes"/"No" +-- ReadOnlyStyle: Control -> it renders the (disabled) CHECKBOX glyph +-- +-- That is the whole of "show a Boolean as a checkbox" in a DataGrid2 cell, and +-- it is why mendixlabs/mxcli#1111 asked Mendix for a new column type. +-- +-- Scope: check box only, matching what DESCRIBE reads back today. The other +-- input widgets (textbox, textarea, datepicker, radiobuttons) still write a +-- hardcoded Inherit and are not read back either. +-- ============================================================================ + +create entity BugTests.Task ( + Description: string(200), + Done: boolean +); + +create or replace page BugTests.P_490_ReadOnlyStyle ( + Title: 'Read-only style', + Layout: Atlas_Core.Atlas_Default +) { + layoutgrid lgMain { + row rowMain { + column colMain (DesktopWidth: 12) { + datagrid dgTasks (DataSource: database from BugTests.Task, Selection: None) { + column Description (Attribute: Description, Caption: 'Task') { + textfilter tfDescription + } + -- Control: the cell renders a disabled checkbox, and the column still + -- filters (Yes / No) — the two halves ako/mxcli#489 and #490 together. + column Done (Attribute: Done, Caption: 'Done') { + checkbox cbDoneControl (Attribute: Done, Editable: Never, ReadOnlyStyle: Control) + dropdownfilter ddfDone + } + } + } + } + } +}; + +-- Verify by hand: +-- mxcli exec readonlystyle-490-checkbox-control.mdl -p app.mpr +-- mxcli bson dump page -p app.mpr --object BugTests.P_490_ReadOnlyStyle \ +-- | grep -A1 '"ReadOnlyStyle"' -> Control (was Inherit before the fix) +-- mxcli -p app.mpr -c "describe page BugTests.P_490_ReadOnlyStyle" +-- -> ReadOnlyStyle: Control on cbDoneControl; re-exec reports +-- "Unchanged page", so the description reproduces the document. +-- Drop the `ReadOnlyStyle:` clause and re-exec -> "Replaced page", and the +-- stored value goes back to Inherit: the control that shows the value is the +-- authored one rather than a constant. +-- +-- An unknown value is refused rather than written — a member Studio Pro cannot +-- resolve is a project that will not open, and mxbuild does not complain: +-- checkbox cbBad (Attribute: Done, ReadOnlyStyle: ReadOnly) -- refused diff --git a/mdl/backend/modelsdk/widget_readonlystyle_test.go b/mdl/backend/modelsdk/widget_readonlystyle_test.go new file mode 100644 index 0000000000..f0d82ca0dd --- /dev/null +++ b/mdl/backend/modelsdk/widget_readonlystyle_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#490 — `ReadOnlyStyle:` parsed, passed check, was emitted by DESCRIBE, +// and was never written: the codec hardcoded "Inherit" on every check box. +// +// Not cosmetic. With "Inherit" a read-only check box in a DataGrid2 cell renders +// as the text "Yes"/"No"; with "Control" it renders the (disabled) checkbox +// glyph — measured on 11.6.6 by patching the stored string by hand. +func TestCheckBoxReadOnlyStyle_Written(t *testing.T) { + tests := []struct { + name string + style string + want string + }{ + // An omitted property keeps the stored default, so scripts that never + // mention it produce the same document as before. + {"unset keeps the default", "", "Inherit"}, + {"control", "Control", "Control"}, + {"text", "Text", "Text"}, + {"inherit", "Inherit", "Inherit"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cb := &pages.CheckBox{ReadOnlyStyle: tc.style} + cb.Name = "cbActive" + doc := encodeWidget(t, cb) + if got := docGet(doc, "ReadOnlyStyle"); got != tc.want { + t.Errorf("ReadOnlyStyle = %v, want %q — the authored value never reached "+ + "the document (ako/mxcli#490)", got, tc.want) + } + }) + } +} diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 242dac4242..cba094814c 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -462,7 +462,10 @@ func widgetToGen(w pages.Widget) (element.Element, error) { } g.SetOnChangeAction(onChangeCB) g.SetOnEnterAction(noActionGen()) - g.SetReadOnlyStyle("Inherit") + // Unset keeps Mendix's default; an authored Control/Text is what decides + // whether a read-only check box renders as the glyph or as "Yes"/"No" + // text (ako/mxcli#490). The value is canonicalised at build time. + g.SetReadOnlyStyle(orDefaultStr(x.ReadOnlyStyle, "Inherit")) g.SetValidation(widgetValidationToGen()) return g, nil diff --git a/mdl/executor/cmd_pages_builder_readonlystyle_test.go b/mdl/executor/cmd_pages_builder_readonlystyle_test.go new file mode 100644 index 0000000000..5803ba5f41 --- /dev/null +++ b/mdl/executor/cmd_pages_builder_readonlystyle_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#490 — the builder half: `ReadOnlyStyle:` on a checkbox has to reach +// the semantic model, or the codec has nothing to write. The property parsed, +// `mxcli check` accepted it (it is in the known-property allowlist as +// "vocabulary describe page emits") and every layer below dropped it. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" +) + +func TestBuildCheckBox_ReadOnlyStyle(t *testing.T) { + tests := []struct { + name string + value any + want string + }{ + {"unset stays unset (writer keeps the stored default)", nil, ""}, + {"control", "Control", "Control"}, + {"text", "Text", "Text"}, + {"inherit", "Inherit", "Inherit"}, + // MDL property values are matched case-insensitively everywhere else; + // the enum value stored must still be Mendix's own casing, since an + // unknown member is a document Studio Pro cannot load. + {"lowercase is canonicalised", "control", "Control"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pb := &pageBuilder{widgetScope: map[string]model.ID{}} + w := &ast.WidgetV3{Name: "cbActive", Type: "checkbox", Properties: map[string]any{}} + if tc.value != nil { + w.Properties["ReadOnlyStyle"] = tc.value + } + cb, err := pb.buildCheckBoxV3(w) + if err != nil { + t.Fatalf("buildCheckBoxV3: %v", err) + } + if cb.ReadOnlyStyle != tc.want { + t.Errorf("ReadOnlyStyle = %q, want %q (ako/mxcli#490)", cb.ReadOnlyStyle, tc.want) + } + }) + } +} + +// An unknown member must be refused rather than written: a value outside +// Inherit/Control/Text is a property Studio Pro cannot resolve, and mxbuild +// tolerates it — so the build stays green and the project will not open. +func TestBuildCheckBox_ReadOnlyStyleRejectsUnknownValue(t *testing.T) { + pb := &pageBuilder{widgetScope: map[string]model.ID{}} + w := &ast.WidgetV3{ + Name: "cbActive", + Type: "checkbox", + Properties: map[string]any{"ReadOnlyStyle": "ReadOnly"}, + } + _, err := pb.buildCheckBoxV3(w) + if err == nil { + t.Fatal("an unknown ReadOnlyStyle was accepted — it would be written into the document") + } + if !strings.Contains(err.Error(), "Control") { + t.Errorf("the error should name the accepted values, got: %v", err) + } +} diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index 0aee2b63b1..8dd2940427 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -594,6 +594,16 @@ func (pb *pageBuilder) buildCheckBoxV3(w *ast.WidgetV3) (*pages.CheckBox, error) cb.Label = label } + // Handle ReadOnlyStyle ("Read-only style": Inherit / Control / Text). An + // omitted property stays empty and the writer keeps the stored default — + // what decides whether a read-only check box renders as "Yes"/"No" text or + // as the checkbox glyph (ako/mxcli#490). + style, err := readOnlyStyleValue(w.GetStringProp("ReadOnlyStyle"), w.Name) + if err != nil { + return nil, err + } + cb.ReadOnlyStyle = style + // Handle OnChange (the "On change" client action) if err := pb.applyOnChangeV3(w, &cb.OnChangeAction); err != nil { return nil, err @@ -606,6 +616,26 @@ func (pb *pageBuilder) buildCheckBoxV3(w *ast.WidgetV3) (*pages.CheckBox, error) return cb, nil } +// readOnlyStyleValue canonicalises an authored `ReadOnlyStyle:` to the member +// Mendix stores. MDL matches property values case-insensitively, but the value +// written has to be one of the metamodel's members (generated/metamodel's +// PagesReadOnlyStyle): an unknown one is a property Studio Pro cannot resolve, +// and mxbuild tolerates it — so the build stays green and the project does not +// open. Empty in, empty out: unset keeps the stored default. +func readOnlyStyleValue(raw, widgetName string) (string, error) { + if raw == "" { + return "", nil + } + for _, member := range []string{"Inherit", "Control", "Text"} { + if strings.EqualFold(raw, member) { + return member, nil + } + } + return "", mdlerrors.NewValidationf( + "checkbox %q: ReadOnlyStyle %q is not a Mendix read-only style — use Inherit, Control or Text", + widgetName, raw) +} + // buildRadioButtonsV3 creates RadioButtons from V3 syntax. func (pb *pageBuilder) buildRadioButtonsV3(w *ast.WidgetV3) (*pages.RadioButtons, error) { rb := &pages.RadioButtons{ diff --git a/sdk/pages/pages_widgets_input.go b/sdk/pages/pages_widgets_input.go index 167cf7b75c..e9880e802e 100644 --- a/sdk/pages/pages_widgets_input.go +++ b/sdk/pages/pages_widgets_input.go @@ -112,9 +112,18 @@ type ReferenceSetSelector struct { // CheckBox represents a checkbox widget. type CheckBox struct { BaseWidget - Label string `json:"label,omitempty"` - AttributePath string `json:"attributePath,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` + Label string `json:"label,omitempty"` + AttributePath string `json:"attributePath,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + // ReadOnlyStyle is Mendix's "Read-only style": Inherit, Control or Text. + // Empty means unset — the writer keeps the stored default (Inherit), so a + // script that never mentions it produces the document it always did. + // + // It decides how a READ-ONLY check box renders, and the difference is not + // cosmetic: Text renders the words "Yes"/"No", Control renders the (disabled) + // checkbox glyph. That is the whole of "show a Boolean as a checkbox" in a + // DataGrid2 cell (ako/mxcli#490). + ReadOnlyStyle string `json:"readOnlyStyle,omitempty"` OnChangeAction ClientAction `json:"onChangeAction,omitempty"` } From d7ed65231ab9aa05dfbd39b12e377bb18a9aecdd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 11:54:16 +0000 Subject: [PATCH 09/12] docs(pages): correct the DataGrid2 column filter guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims in the syntax topic and the quick reference were wrong, and both pushed users away from a combination that works: - "A Boolean column takes no filter at all." The drop-down filter's own widget XML declares its attribute types as Enum AND Boolean, and a Boolean column filters Yes/No — measured in a browser on 11.6.6. - Neither said a column can hold a custom-content widget and a filter at the same time. `content` and `filter` are separate Widgets-typed slots with no dependency on `showContentAs`, so a read-only checkbox cell and a drop-down filter live in the same braces. Also documents `ReadOnlyStyle:` where it matters — it is what makes a read-only check box render as the glyph rather than the text Yes/No. The first claim is the likely origin of mendixlabs/mxcli#1111, which asked Mendix for a new Boolean column type to do something mxcli could already do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G5JMoL3NegeVcn5SwSCqFK --- cmd/mxcli/syntax/features_page.go | 11 ++++++++++- docs/01-project/MDL_QUICK_REFERENCE.md | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index acd97a1e4c..0d8e938b64 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -127,7 +127,16 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "-- Data grid 2 column filters go INSIDE the column's own braces\nDATAGRID dg (...) { COLUMN c (Attribute: A) { TEXTFILTER tf (Attribute: A) } }\nTEXTFILTER | NUMBERFILTER | DATEFILTER | DROPDOWNFILTER | DROPDOWNSORT\n" + "-- Match the filter to the column's type, or MxBuild refuses it: String ->\n" + "-- TEXTFILTER, Integer/Long/Decimal -> NUMBERFILTER, Date and time -> DATEFILTER,\n" + - "-- Enumeration -> DROPDOWNFILTER. A Boolean column takes no filter at all.\n" + + "-- Enumeration AND Boolean -> DROPDOWNFILTER (the drop-down filter's own\n" + + "-- attribute types are Enum and Boolean; a Boolean column filters Yes/No).\n" + + "-- A column may carry BOTH a custom-content widget and a filter — `content`\n" + + "-- and `filter` are separate slots, so a read-only CHECKBOX cell and a\n" + + "-- DROPDOWNFILTER live in the same braces:\n" + + "DATAGRID dg (...) { COLUMN Active (Attribute: IsActive) {\n" + + " CHECKBOX cb (Attribute: IsActive, Editable: Never, ReadOnlyStyle: Control)\n" + + " DROPDOWNFILTER ddf } }\n" + + "-- ReadOnlyStyle (Inherit | Control | Text) is what makes a read-only check\n" + + "-- box render as the checkbox glyph instead of the text Yes/No.\n" + "-- The grid-wide filter bar is CONTROLBAR; a GALLERY spells that same slot\n" + "-- FILTER, so `FILTER f { ... }` belongs to a gallery and not to a datagrid:\n" + "GALLERY g (...) { FILTER f { TEXTFILTER tf (Attribute: A) } }\n" + diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 7fee3b4567..69a0fe0abf 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1377,7 +1377,7 @@ MDL uses explicit property declarations for pages: | Page CSS class / style | `Class: 'css-class', Style: 'css: rule'` | `(Title: 'Home', Class: 'container-fluid bg-light', Style: 'min-height: 100vh')` — the page's Appearance | | Page variables | `variables: { $name: type = 'expr' }` | `variables: { $show: boolean = 'true' }` | | Repeated widget entries | ` ( … )` **in the widget body** | A repeatable property (FileUploader `allowedFileFormats`, HTML Element `attributes`, a chart's `series`) is a block, never a property value. `attributes: [(attributeName: 'x')]` is **MDL-WIDGET27** — it used to check clean, exec, and vanish from storage. `describe widget -p app.mpr` lists the container keywords | -| Data grid 2 column filter | `column c (attribute: A) { textfilter f }` | **Inside the column's braces.** `column c (…) filter f { … }` is the GALLERY form — the grammar reads it as a column with no body plus a sibling `filter` widget, which the grid has nowhere to put; it used to be dropped on write and is now **MDL-WIDGET30**. A grid-wide filter bar is `controlbar`; a gallery spells that same slot `filter`. Match the filter to the column's type (String → `textfilter`, number → `numberfilter`, DateTime → `datefilter`, Enumeration → `dropdownfilter`, Boolean → none) | +| Data grid 2 column filter | `column c (attribute: A) { textfilter f }` | **Inside the column's braces.** `column c (…) filter f { … }` is the GALLERY form — the grammar reads it as a column with no body plus a sibling `filter` widget, which the grid has nowhere to put; it used to be dropped on write and is now **MDL-WIDGET30**. A grid-wide filter bar is `controlbar`; a gallery spells that same slot `filter`. Match the filter to the column's type (String → `textfilter`, number → `numberfilter`, DateTime → `datefilter`, Enumeration **and Boolean** → `dropdownfilter` — the drop-down filter's own attribute types are Enum and Boolean, and a Boolean column filters Yes/No). A column may carry a **custom-content widget AND a filter**: `content` and `filter` are separate slots, so `column Active (attribute: IsActive) { checkbox cb (Editable: Never, ReadOnlyStyle: Control) dropdownfilter ddf }` renders checkbox cells and still filters | | Widget with nowhere to go | any widget in a pluggable widget's body | A child matching no container, slot or `template` catch-all is **MDL-WIDGET30** at check time and refused by `exec`. `describe widget -p app.mpr` lists what the parent declares. Needs the parent's definition, so it is silent without `-p` | | Inspect a widget | `describe widget ;` | `describe widget combobox;` — properties, enum values, defaults and the editor rules that HIDE properties under some configurations. **Body containers** names what the widget's body takes, and for an object list the widgets-typed slots *inside one item* plus the widget types that route into each — that is where `column … { textfilter }` is spelled out. Works with no project open; with one, reads the installed `.mpk` (version-accurate, and the only place a Marketplace widget appears). Same output as `mxcli widget describe` | | Widget name | Required after type | `textbox txtName (...)` | From fb6e35bdb9d4e7dd42f35f38f2cb5bb215526e4c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 12:07:01 +0000 Subject: [PATCH 10/12] fix(check): refuse a list operation nested inside another MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$n = COUNT(FILTER($reqs, $currentObject/Status = Mod.E.Approved))` passed `mxcli check`, execed with "Created microflow", wrote zero list properties, and then failed the build with CE0012 "The 'List' property is required." (mendixlabs/mxcli#1101). MDL's expression grammar makes list operations look composable. Mendix's model is not: each is a separate ACTIVITY whose list is stored as a VARIABLE reference, with no slot for a nested computation. buildSetStatementNode took every list operand through extractVariableName, which reads *ast.VariableExpr and *ast.IdentifierExpr and returns "" for anything else with no default branch — so the inner call was discarded, list and predicate together, and the activity written with an empty AggregateVariableName. `describe` read the result back as `$n = count($)`. Measured on mxbuild 11.6.6, one microflow per project: count(filter(...)) CE0012 head/tail/filter(...) CE0096, the list-operation flavour of the same sum(filter(...), 1) CE0012 + CE0117 union($l, filter(...)) the SECOND operand lost the same way sort(filter(...), Name) mxbuild ABORTS with InvalidOperationException — the sort attribute resolves against the absent list's entity, so the document cannot be loaded at all. No error code, no line. count('nonsense') CE0012 — nesting is not required to lose the list The conversion now records what it could not reduce to a variable (ast.UnresolvedOperand) and MDL-LISTOP02 refuses it at check time, naming the operand and printing the two-statement rewrite. Keying on the conversion's RESULT rather than on the argument's node type is what makes it uniform: buildSetAggregate resolves an attribute path that extractVariableName cannot, so a node-type predicate would differ per arm and drift apart again — which that function's own comment already warns about. The switch moved into buildListOrAggregateStatement so all fourteen arms share one tail. Refusing rather than materialising an implicit variable covers every spelling from one rule, including the literal operand that no materialising would fix, and needs no project, so plain `mxcli check` reports it. Verified: - the printed remedy, pasted verbatim: check clean, exec clean, mxbuild 0 errors - the reporter's workaround and the nested form in ONE project: exactly 1 error - 584 corpus scripts: 1 flagged, and it is the new bug test (positive control) - guard stubbed: all 9 new tests fail, the accept-control stays green Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ --- .../fix-issue/findings/mdl-visitor.jsonl | 1 + .../mendix/patterns-data-processing/SKILL.md | 31 ++ CLAUDE.md | 7 +- cmd/mxcli/syntax/features_microflow.go | 4 +- docs-site/src/appendixes/error-messages.md | 30 ++ .../1101-nested-list-operand-dropped.fail.mdl | 75 +++++ mdl/ast/ast_microflow.go | 24 ++ mdl/executor/validate_microflow.go | 1 + .../validate_microflow_listop_source.go | 147 +++++++++ .../validate_microflow_listop_source_test.go | 148 +++++++++ mdl/visitor/visitor_microflow_statements.go | 307 +++++++++++------- 11 files changed, 648 insertions(+), 127 deletions(-) create mode 100644 mdl-examples/bug-tests/1101-nested-list-operand-dropped.fail.mdl create mode 100644 mdl/executor/validate_microflow_listop_source.go create mode 100644 mdl/executor/validate_microflow_listop_source_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl index 9c40768ba2..7c861ed290 100644 --- a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl @@ -30,3 +30,4 @@ {"area": "mdl/visitor", "date": "2026-09-08", "symptom": "`call microflow M.ACT with (Ctx = $WorkflowContext)` — an UNQUOTED value in a workflow parameter mapping — crashed the binary with SIGSEGV (nil pointer) in buildWorkflowCallMicroflow, on `check`, `check --references` and `exec` alike, with no diagnostic beyond the Go panic. Reported as mendixlabs/mxcli#1023.", "cause": "The grammar rule workflowParameterMapping requires STRING_LITERAL, but visitor.Build() walks the parse tree even when the parse FAILED (deliberately — that is what lets check report more than the first error). Under ANTLR error recovery the rule context exists with a nil STRING_LITERAL child, and the visitor read it unguarded. Same bug at the CALL WORKFLOW site.", "fix": "Factor both sites into buildWorkflowParameterMappings, nil-checking QualifiedName() and STRING_LITERAL() and skipping the mapping. The syntax error the listener already recorded ('mismatched input ... expecting STRING_LITERAL') becomes what the author sees.", "file": "mdl/visitor/visitor_workflow.go", "insight": "In this codebase a required grammar child is NOT a guarantee inside the visitor, because Build() walks a failed parse on purpose. Every ctx.X().GetText() on a required child is therefore a latent crash reachable from ordinary malformed input — grep 'STRING_LITERAL().GetText()' for the ones still unguarded. The other half of the finding is documentation-shaped: `mxcli syntax workflow call-microflow` omitted the WITH clause entirely, so an author had nothing to copy and reached for the bare-variable spelling used everywhere else in MDL. A crash on input the tool's own docs do not cover is a docs bug with a segfault attached."} {"area": "mdl/visitor", "date": "2026-09-07", "symptom": "`create or modify snippet M.S (params: { $T: Mod.\"Thing\" })` failed at execution with `failed to resolve entity Mod.\"Thing\": entity not found`, while the identical quoted form in a PAGE parameter resolved fine (ako/CapTrackV4 019).", "cause": "buildSnippetParameterListAsPage re-split the parse node's TEXT (`parseQualifiedName(dt.GetText())`), and GetText() returns the source verbatim, quotes included. The page path walks the parse tree, where buildQualifiedName unquotes each part via identifierOrKeywordText. Fixed by walking the tree; the dead duplicate buildSnippetParameters — a correct implementation nothing called — was removed.", "file": "`mdl/visitor/visitor_page_v3.go` (buildSnippetParameterListAsPage); `mdl/visitor/visitor_page.go` (removed buildSnippetParameters); tests `mdl/visitor/snippet_param_quoted_entity_test.go`", "insight": "GetText() on an ANTLR context is the source text, not the resolved value, so any conversion built on it silently keeps quoting, whitespace and casing that the tree-walking helpers strip. Grep for `parseQualifiedName(.*GetText())` when a name resolves in one statement and not in a sibling. The asymmetry is also the diagnosis: when two statements accept the same syntax and only one works, compare their VISITORS before their executors — here both executor paths were identical and called the same resolveEntity. Two copies of one conversion with one of them dead is how they drifted, so the dead one is deleted rather than fixed."} {"area": "mdl/visitor", "date": "2026-09-15", "symptom": "`placeholder Main { }` inside a CREATE LAYOUT passes `mxcli check`, then fails at exec with `layout \"X\" declares no placeholder` \u2014 a message that flatly contradicts the script, which says `placeholder Main`. The failed exec has already created the module.", "cause": "One grammar rule (placeholderBlockV3) serves two opposite jobs, told apart by shape: `if c.LBRACE() == nil` makes a bodiless placeholder a DECLARATION widget, and the braced form is routed to buildPagePlaceholdersV3 \u2014 the page-side job of FILLING a layout slot. In a layout there is no such job, so the braced form was dropped on the floor and the layout ended up with zero placeholders.", "fix": "Builder gained `inLayout` (saved/restored around the layout body build, since the same body builder serves pages) and collects the dropped names into ast.CreateLayoutStmt.BracedPlaceholders; MDL083 reports them at check time. The braced form is still dropped \u2014 recording it is a diagnostic, not a decision to honour it.", "insight": "When one parse rule serves two documents and is disambiguated by SHAPE rather than by context, the wrong shape has no error path by construction \u2014 it just silently means the other thing. The tell is a runtime message that contradicts the source text. Note the mistake is the natural one: every other layout element takes a body, and `alter page` uses the braced form for real, so the author is generalising correctly from the rest of the language. DESCRIBE emits the bodiless form, so round-tripping never produces it and no existing test covered it.", "controls": "A page's braced placeholder must still fill a slot (TestBuildPageV3_BracedPlaceholderStillFillsASlot) and a layout's bodiless form must still produce a real widget \u2014 the flag is save/restored precisely so a page later in the same script is not flagged.", "refs": "mendixlabs/mxcli#1063", "file": "mdl/visitor/visitor_page_v3.go, mdl/visitor/visitor.go, mdl/ast/ast_page_v3.go"} +{"area": "mdl/visitor", "date": "2026-09-16", "symptom": "`$n = COUNT(FILTER($reqs, $currentObject/Status = Mod.E.Approved))` passed `mxcli check`, execed with \"Created microflow\", and then failed the build with CE0012 \"The 'List' property is required.\" at Aggregate list activity 'Count' (mendixlabs/mxcli#1101). `describe` read the stored flow back as `$n = count($)`.", "cause": "buildSetStatementNode converts a SET whose value is a list/aggregate call into an activity statement, and every arm took its list operand through extractVariableName, which handles *ast.VariableExpr and *ast.IdentifierExpr and returns \"\" for anything else with no default branch. A nested call is 'anything else', so the inner call was discarded (list AND predicate) and the activity written with an empty AggregateVariableName. Fixed by recording the dropped operand on the statement (ast.UnresolvedOperand) and refusing it at check time as MDL-LISTOP02.", "file": "`mdl/visitor/visitor_microflow_statements.go` (buildListOrAggregateStatement, recordUnresolvedOperands); `mdl/ast/ast_microflow.go` (UnresolvedOperand); `mdl/executor/validate_microflow_listop_source.go`; tests `mdl/executor/validate_microflow_listop_source_test.go`, `mdl-examples/bug-tests/1101-nested-list-operand-dropped.fail.mdl`", "insight": "A type switch with no default over AST nodes is a silent data-loss site, and `extractVariableName`-shaped helpers ('return the name, or \"\"') hide it behind a value that reads as absence. The measurement that mapped the blast radius was not reading code: exec each spelling into its own copy of a project and run mxbuild, one microflow per project so the error count is unambiguous. That turned a COUNT bug into six — head/tail/filter/sort/union and a bare string literal — and found one strictly worse than the report: `sort(filter(…), Name)` makes mxbuild ABORT with InvalidOperationException rather than report an error, because the sort attribute resolves against the absent list's entity, so the document cannot be loaded at all. Put the control in the SAME project as the defect where possible: the reporter's two-statement workaround next to the nested form gave `The app contains: 1 errors`, which proves the rule's scope in one build. When several arms each do the same bookkeeping inline, move them to one tail rather than adding the bookkeeping N times — this file's own buildSetAggregate comment already says that is how the attribute went missing before.", "refs": "mendixlabs/mxcli#1101; sibling rule MDL-LISTOP01 (#1002); related surface-syntax issue mendixlabs/mxcli#750 (expressions vs. what the model can store)"} diff --git a/.claude/skills/mendix/patterns-data-processing/SKILL.md b/.claude/skills/mendix/patterns-data-processing/SKILL.md index ac6fa53476..8512289c96 100644 --- a/.claude/skills/mendix/patterns-data-processing/SKILL.md +++ b/.claude/skills/mendix/patterns-data-processing/SKILL.md @@ -232,6 +232,37 @@ $MaxPrice = maximum($Products.Price); ## List Operations +### One statement per operation — they do not nest + +Every list operation and aggregate is a separate **activity** in Mendix, and an +activity stores its list as a **variable reference**. There is no slot for a +nested computation, so this is not a shorter spelling — it is a list argument the +model cannot hold: + +```mdl +-- WRONG. mxcli check refuses this as MDL-LISTOP02. +$n = count(filter($Requests, $currentObject/Status = Module.ENUM_Status.Approved)); +``` + +Before the rule existed it parsed, passed `check`, and execed with +`Created microflow` — then dropped the inner call entirely and wrote an activity +with an empty list, which mxbuild rejected with **CE0012** (`The 'List' property +is required.`) for an aggregate or **CE0096** for a list operation. The +`sort(filter(…), Attr)` shape was worse still: with the list gone the sort +attribute has no entity to resolve against, and mxbuild aborts rather than +reporting an error. + +Give the inner operation its own statement and pass the variable: + +```mdl +-- RIGHT +$Approved = filter($Requests, $currentObject/Status = Module.ENUM_Status.Approved); +$n = count($Approved); +``` + +The same applies to both operands of `union`/`intersect`/`subtract`, and to any +non-variable list argument — `count('nonsense')` fails the same way. + ### Add to List ```mdl diff --git a/CLAUDE.md b/CLAUDE.md index 5f7d98d22a..0bb8b5cd3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -808,9 +808,10 @@ These rules apply whenever generating microflow or nanoflow MDL. Violations are 1. **NEVER create empty list variables as loop sources.** If processing imported data, accept the list as a microflow parameter — `declare $Items list of ... = empty` followed by `loop $item in $Items` is always wrong. 2. **NEVER use nested LOOPs for list matching.** Loop over the primary list and use `$match = FIND($TargetList, key = $item/key)` for an O(N) in-memory lookup. A plain `retrieve … where` **cannot** filter a list variable (only a database/association source), so `retrieve $match from $TargetList where …` is a parse error — use `FIND`/`FILTER`. Nested loops are O(N^2). The `$item` there is the enclosing loop's iterator and stays valid — MDL-LISTOP01 flags a predicate variable that is *not in scope*, not the name. Inside the predicate itself, the item under test is `$currentObject` (a bare attribute name resolves to it). -3. **Use append logic when merging**, not overwrite: `$Existing/Field + '\n' + $New/Field` inside an `if $New/Field != empty` guard. -4. **`retrieve … limit 1` binds a single OBJECT, not a one-element list** — it is Mendix's "First object" range, so `head()`, `count()` or a `loop` over that variable is **CE0097**. Drop the `limit` for a list; `limit 1 offset n` and every other `limit` ARE lists. Note the same word means the opposite on `import from mapping`, where `first` binds the object and `limit 1` a one-element list. `describe` re-emits `limit 1` either way, so the source of an object retrieve and a list retrieve are identical text and only MDL-RETRIEVE01 distinguishes them before a build (mendixlabs/mxcli#1103). -5. **Read `.claude/skills/patterns-data-processing.md`** for delta merge, batch processing, and list operation patterns. +3. **NEVER nest one list operation inside another.** Each of HEAD/TAIL/FIND/FILTER/SORT/UNION/INTERSECT/SUBTRACT/RANGE and the aggregates is a separate **activity**, and an activity stores its list as a **variable reference** — there is no slot for a nested computation. `$n = COUNT(FILTER($reqs, …))` parses, and used to drop the inner call entirely and write an activity with an empty list: `check` clean, `exec` reporting "Created microflow", then CE0012 / CE0096 at build time — and `sort(filter(…), Attr)` made mxbuild abort outright, because the sort attribute resolves against the now-absent list's entity. One statement each: `$approved = FILTER($reqs, …); $n = COUNT($approved);`. `mxcli check` now refuses the nested form as MDL-LISTOP02 (mendixlabs/mxcli#1101). +4. **Use append logic when merging**, not overwrite: `$Existing/Field + '\n' + $New/Field` inside an `if $New/Field != empty` guard. +5. **`retrieve … limit 1` binds a single OBJECT, not a one-element list** — it is Mendix's "First object" range, so `head()`, `count()` or a `loop` over that variable is **CE0097**. Drop the `limit` for a list; `limit 1 offset n` and every other `limit` ARE lists. Note the same word means the opposite on `import from mapping`, where `first` binds the object and `limit 1` a one-element list. `describe` re-emits `limit 1` either way, so the source of an object retrieve and a list retrieve are identical text and only MDL-RETRIEVE01 distinguishes them before a build (mendixlabs/mxcli#1103). +6. **Read `.claude/skills/patterns-data-processing.md`** for delta merge, batch processing, and list operation patterns. **Always validate before presenting to user:** ```bash diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index a745ead830..bd8198162b 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -317,8 +317,8 @@ func init() { // debugging the build error back to the right topic (issue #1002). "$currentObject", "predicate", "CE0117", "CE0109", "MDL-LISTOP01", }, - Syntax: "$List = CREATE LIST OF Module.Entity;\nADD $Item TO $List;\nREMOVE $Item FROM $List;\n$Result = HEAD($List);\n$Result = TAIL($List);\n$Result = FIND($List, predicate);\n$Result = FILTER($List, predicate);\n$Result = SORT($List, attr ASC);\n$Result = UNION($L1, $L2);\n$Result = INTERSECT($L1, $L2);\n$Result = SUBTRACT($L1, $L2);\n$Result = RANGE($List, offset, amount);\n$Result = RANGE($List, offset);\n\n-- Aggregates. Mendix has eight; each takes an attribute or an expression\n-- over $currentObject.\n$Count = COUNT($List);\n$Sum = SUM($List.Attr);\n$Sum = SUM($List, expression);\n$Avg = AVERAGE($List.Attr);\n$Min = MINIMUM($List.Attr);\n$Max = MAXIMUM($List.Attr);\n$AllMatch = ALL($List, boolean-expression);\n$AnyMatch = ANY($List, boolean-expression);\n\n-- REDUCE folds the list into one value. $currentResult is the running\n-- total; both INITIAL and RETURNS are required and cannot be inferred.\n$Folded = REDUCE($List, expression, initial: value, returns: Type);\n\n-- RANGE takes OFFSET first, then AMOUNT, and needs at least ONE of them:\n-- RANGE($L, $Offset, $Amount) page: skip $Offset, take $Amount\n-- RANGE($L, 0, $Amount) first $Amount\n-- RANGE($L, $Offset) skip $Offset, take the rest\n-- RANGE($L) with no bound is CE6520 at build time (mxcli check: MDL068).\n\nA FIND/FILTER predicate is evaluated once per item, and Mendix binds the\nitem to $currentObject -- the same variable the aggregate expressions above\nuse, and the only iterator name there is:\n\n FILTER($Orders, $currentObject/Amount > 0)\n\nA bare attribute name means the same thing; mxcli resolves it against the\nlist's entity and writes $currentObject/Attr. A name that is not a member\nof that entity is refused, and naming any other variable is MDL-LISTOP01.\n\nSORT is not an expression -- it takes attribute names directly, so a bare\nattribute is the only spelling there.", - Example: "$AllOrders = CREATE LIST OF MyModule.Order;\nADD $NewOrder TO $AllOrders;\n$First = HEAD($AllOrders);\n\n-- The item under test is $currentObject\n$Pending = FILTER($AllOrders, $currentObject/Status = 'Pending');\n$Large = FILTER($AllOrders, $currentObject/Amount > 1000);\n\n-- A bare attribute name is resolved against the list's entity\n$Open = FILTER($AllOrders, Status != 'Closed');\n\n-- SORT takes attribute names, not an expression\n$Sorted = SORT($Pending, CreateDate DESC);\n$Page = RANGE($Sorted, $Offset, $PageSize);\n$Total = SUM($AllOrders.Amount);\n$AllPaid = ALL($AllOrders, $currentObject/Paid);\n$AnyLate = ANY($AllOrders, $currentObject/DueDate < [%CurrentDateTime%]);\n$Discounted = REDUCE(\n $AllOrders,\n $currentResult + $currentObject/Amount * 0.9,\n initial: 0,\n returns: Decimal\n);", + Syntax: "$List = CREATE LIST OF Module.Entity;\nADD $Item TO $List;\nREMOVE $Item FROM $List;\n$Result = HEAD($List);\n$Result = TAIL($List);\n$Result = FIND($List, predicate);\n$Result = FILTER($List, predicate);\n$Result = SORT($List, attr ASC);\n$Result = UNION($L1, $L2);\n$Result = INTERSECT($L1, $L2);\n$Result = SUBTRACT($L1, $L2);\n$Result = RANGE($List, offset, amount);\n$Result = RANGE($List, offset);\n\n-- Aggregates. Mendix has eight; each takes an attribute or an expression\n-- over $currentObject.\n$Count = COUNT($List);\n$Sum = SUM($List.Attr);\n$Sum = SUM($List, expression);\n$Avg = AVERAGE($List.Attr);\n$Min = MINIMUM($List.Attr);\n$Max = MAXIMUM($List.Attr);\n$AllMatch = ALL($List, boolean-expression);\n$AnyMatch = ANY($List, boolean-expression);\n\n-- REDUCE folds the list into one value. $currentResult is the running\n-- total; both INITIAL and RETURNS are required and cannot be inferred.\n$Folded = REDUCE($List, expression, initial: value, returns: Type);\n\n-- RANGE takes OFFSET first, then AMOUNT, and needs at least ONE of them:\n-- RANGE($L, $Offset, $Amount) page: skip $Offset, take $Amount\n-- RANGE($L, 0, $Amount) first $Amount\n-- RANGE($L, $Offset) skip $Offset, take the rest\n-- RANGE($L) with no bound is CE6520 at build time (mxcli check: MDL068).\n\nA FIND/FILTER predicate is evaluated once per item, and Mendix binds the\nitem to $currentObject -- the same variable the aggregate expressions above\nuse, and the only iterator name there is:\n\n FILTER($Orders, $currentObject/Amount > 0)\n\nA bare attribute name means the same thing; mxcli resolves it against the\nlist's entity and writes $currentObject/Attr. A name that is not a member\nof that entity is refused, and naming any other variable is MDL-LISTOP01.\n\nSORT is not an expression -- it takes attribute names directly, so a bare\nattribute is the only spelling there.\n\nEvery list operation above is a separate ACTIVITY, and an activity stores\nits list as a VARIABLE. They do not nest: COUNT(FILTER($L, ...)) is not a\nshorter spelling of two statements, it is a list argument Mendix cannot\nstore. mxcli refuses it as MDL-LISTOP02; give the inner operation its own\nstatement and pass the variable:\n\n $Approved = FILTER($Orders, $currentObject/Status = 'Approved');\n $Count = COUNT($Approved);", + Example: "$AllOrders = CREATE LIST OF MyModule.Order;\nADD $NewOrder TO $AllOrders;\n$First = HEAD($AllOrders);\n\n-- The item under test is $currentObject\n$Pending = FILTER($AllOrders, $currentObject/Status = 'Pending');\n$Large = FILTER($AllOrders, $currentObject/Amount > 1000);\n\n-- A bare attribute name is resolved against the list's entity\n$Open = FILTER($AllOrders, Status != 'Closed');\n\n-- SORT takes attribute names, not an expression\n$Sorted = SORT($Pending, CreateDate DESC);\n$Page = RANGE($Sorted, $Offset, $PageSize);\n$Total = SUM($AllOrders.Amount);\n$AllPaid = ALL($AllOrders, $currentObject/Paid);\n$AnyLate = ANY($AllOrders, $currentObject/DueDate < [%CurrentDateTime%]);\n\n-- List operations do not nest -- one statement each (MDL-LISTOP02)\n-- WRONG: $Count = COUNT(FILTER($AllOrders, $currentObject/Paid));\n$Paid = FILTER($AllOrders, $currentObject/Paid);\n$Count = COUNT($Paid);\n$Discounted = REDUCE(\n $AllOrders,\n $currentResult + $currentObject/Amount * 0.9,\n initial: 0,\n returns: Decimal\n);", SeeAlso: []string{"microflow.retrieve"}, }) diff --git a/docs-site/src/appendixes/error-messages.md b/docs-site/src/appendixes/error-messages.md index fe9a642e2e..6244249eb7 100644 --- a/docs-site/src/appendixes/error-messages.md +++ b/docs-site/src/appendixes/error-messages.md @@ -144,6 +144,36 @@ The rule keys on **scope, not on the name**. `$item` is perfectly valid in a pre ## mxcli Parser Errors + +### MDL-LISTOP02: A list operation nested inside another one + +``` +count(…): the list argument is `filter($reqs, $currentObject/Status = Mod.E.Approved)`, +which is not a variable. A Mendix aggregate list activity stores its list as a +variable reference and has no slot for a nested computation, so the argument is +dropped and the activity is written with an empty list — mxbuild then rejects it +with CE0012 "The 'List' property is required.". [MDL-LISTOP02] +``` + +**Cause:** Every list operation and aggregate — `HEAD`, `TAIL`, `FIND`, `FILTER`, `SORT`, `UNION`, `INTERSECT`, `SUBTRACT`, `RANGE`, `COUNT`, `SUM`, `AVERAGE`, `MINIMUM`, `MAXIMUM`, `REDUCE`, `ALL`, `ANY` — is a separate **activity** in Mendix, and an activity stores its list as a **variable reference**. MDL's expression grammar makes them look composable, but there is nowhere in the model to put a nested call. + +Before this rule existed the inner call was dropped, list and predicate together, and the activity was written with an empty list. That passes `mxcli check`, execs with `Created microflow`, and fails only at build time — `CE0012 "The 'List' property is required."` for an aggregate, `CE0096` for a list operation. `sort(filter(…), Attr)` was worse: with the list gone the sort attribute has no entity to resolve against, and mxbuild aborts with an `InvalidOperationException` instead of reporting an error. + +The rule keys on the operand not reducing to a variable, so it also covers a non-list argument: `count('nonsense')` failed the same way. + +**Solution:** Give the inner operation its own statement and pass the variable. + +```mdl +-- WRONG +$n = count(filter($Requests, $currentObject/Status = Module.ENUM_Status.Approved)); + +-- RIGHT +$Approved = filter($Requests, $currentObject/Status = Module.ENUM_Status.Approved); +$n = count($Approved); +``` + +The same applies to both operands of `union`/`intersect`/`subtract`. + ### Mismatched input ``` diff --git a/mdl-examples/bug-tests/1101-nested-list-operand-dropped.fail.mdl b/mdl-examples/bug-tests/1101-nested-list-operand-dropped.fail.mdl new file mode 100644 index 0000000000..e8a99d0def --- /dev/null +++ b/mdl-examples/bug-tests/1101-nested-list-operand-dropped.fail.mdl @@ -0,0 +1,75 @@ +-- mendixlabs/mxcli#1101 — a list operation nested inside another one. +-- +-- This file is expected to FAIL `mxcli check` (.fail.mdl). Every statement below +-- used to pass check, exec with "Created microflow", and write an activity whose +-- List property was empty. +-- +-- MDL's expression grammar makes list operations look composable. Mendix's model +-- is not: each one is a separate ACTIVITY whose list is stored as a VARIABLE +-- reference, with no slot for a nested computation. The inner call therefore had +-- nowhere to go and was dropped — list and predicate together — and the describe +-- of the result reads `$n = count($)`. +-- +-- Measured on mxbuild 11.6.6, one microflow per project. The reporter's own +-- workaround (the two-statement form) is the control and passes at 0 errors: +-- +-- count(filter(…)) CE0012 "The 'List' property is required." +-- head(filter(…)) CE0096 — the list-operation flavour of the same +-- sum(filter(…), 1) CE0012 + CE0117 +-- sort(filter(…), Name) mxbuild ABORTS with InvalidOperationException. The +-- sort attribute resolves against the (now absent) +-- list's entity, so the document cannot be loaded at +-- all — no error code, no line. +-- count('nonsense') CE0012 — nesting is not required to lose the list +-- +-- Expected: MDL-LISTOP02 (error) on each of the six microflows below. + +-- 1. The reporter's exact form. +create or replace microflow BugTest1101.CountOfFilter() returns Integer +begin + retrieve $reqs from BugTest1101.Request; + $n = count(filter($reqs, $currentObject/Name != '')); + return $n; +end; + +-- 2. Same hole reached through a list operation rather than an aggregate. +create or replace microflow BugTest1101.HeadOfFilter() returns BugTest1101.Request +begin + retrieve $reqs from BugTest1101.Request; + $h = head(filter($reqs, $currentObject/Name != '')); + return $h; +end; + +-- 3. The nesting can go the other way round — an inner sort under a filter. +create or replace microflow BugTest1101.FilterOfSort() returns List of BugTest1101.Request +begin + retrieve $reqs from BugTest1101.Request; + $f = filter(sort($reqs, Name), $currentObject/Name != ''); + return $f; +end; + +-- 4. The worst one: with the list gone, the sort attribute has no entity to +-- resolve against and mxbuild cannot load the document at all. +create or replace microflow BugTest1101.SortOfFilter() returns List of BugTest1101.Request +begin + retrieve $reqs from BugTest1101.Request; + $s = sort(filter($reqs, $currentObject/Name != ''), Name); + return $s; +end; + +-- 5. The dropped operand can be the SECOND list of a two-list operation. +create or replace microflow BugTest1101.UnionOfFilter() returns List of BugTest1101.Request +begin + retrieve $reqs from BugTest1101.Request; + retrieve $others from BugTest1101.Request; + $u = union($others, filter($reqs, $currentObject/Name != '')); + return $u; +end; + +-- 6. Nesting is not required. Anything that is not a variable is dropped the +-- same way, so the rule keys on the operand not reducing to one. +create or replace microflow BugTest1101.CountOfLiteral() returns Integer +begin + $n = count('nonsense'); + return $n; +end; diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index d0e5dcd735..84b423b918 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -834,10 +834,31 @@ type ListOperationStmt struct { // ListOperationsAction has no ErrorHandlingType, so an ON ERROR here has // nowhere to go; parsing it and reporting it beats dropping it silently. ErrorHandling *ErrorHandlingClause + // UnresolvedOperands holds the list operands that did not reduce to a + // variable name — see UnresolvedOperand. Empty on every well-formed statement. + UnresolvedOperands []UnresolvedOperand } func (s *ListOperationStmt) isMicroflowStatement() {} +// UnresolvedOperand is a list operand that the visitor could not reduce to a +// variable name. +// +// A Mendix list-operation or aggregate activity stores its list as a VARIABLE +// REFERENCE — there is no slot for a nested computation. So MDL's expression +// grammar accepts `count(filter($l, …))`, which looks composable, but the model +// has nowhere to put the inner call. The conversion used to drop it silently and +// write the activity with an empty List, which passes `check`, execs with a +// success message, and fails the build with CE0012 / CE0096 (mendixlabs/mxcli#1101). +// +// Recording what was dropped — rather than leaving an empty InputVariable behind +// — is what lets the validator name the operand and print the two-statement +// rewrite. Expr is nil when the operand was absent altogether. +type UnresolvedOperand struct { + Index int // 0 = the list; 1 = the second list of UNION/INTERSECT/SUBTRACT/CONTAINS/EQUALS + Expr Expression // what was written there, for the diagnostic +} + // AggregateListOperationType represents the type of aggregate operation. type AggregateListOperationType int @@ -897,6 +918,9 @@ type AggregateListStmt struct { // ErrorHandling is recorded only so the clause can be REFUSED — Mendix's // AggregateAction has no ErrorHandlingType. See ListOperationStmt. ErrorHandling *ErrorHandlingClause + // UnresolvedOperands holds the list operand that did not reduce to a variable + // name — see UnresolvedOperand. Empty on every well-formed statement. + UnresolvedOperands []UnresolvedOperand } func (s *AggregateListStmt) isMicroflowStatement() {} diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index b4eb1c4091..7e83520479 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -102,6 +102,7 @@ func (v *microflowValidator) addViolation(ruleID string, severity linter.Severit func (v *microflowValidator) validate(body []ast.MicroflowStatement) { v.checkListOperationIterator(body) v.checkRetrieveLimitOneAsList(body) + v.checkListOperationSource(body) v.checkMergeJoinLabels(body) v.checkAnnotationLabels(body) diff --git a/mdl/executor/validate_microflow_listop_source.go b/mdl/executor/validate_microflow_listop_source.go new file mode 100644 index 0000000000..313a68d335 --- /dev/null +++ b/mdl/executor/validate_microflow_listop_source.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// checkListOperationSource flags a list-operation or aggregate whose list +// operand is not a variable — MDL-LISTOP02, mendixlabs/mxcli#1101. +// +// MDL's expression grammar makes list operations look composable: +// +// $n = count(filter($reqs, $currentObject/Status = Mod.E.Approved)); +// +// Mendix's model is not. Each of those is a separate ACTIVITY, and an activity +// stores its list as a variable reference — Microflows$AggregateAction's +// AggregateVariableName, Microflows$ListOperationsAction's list property. There +// is no slot for a nested computation, so the inner call had nowhere to go and +// was dropped, list and predicate together. The activity was then written with +// an empty list, which is the defect's whole signature: `check` clean, `exec` +// printing "Created microflow", and the failure only at build time. +// +// Measured on mxbuild 11.6.6, one microflow per project: +// +// count(filter(…)) CE0012 "The 'List' property is required." +// head(filter(…)) CE0096, the list-operation flavour of the same +// sum(filter(…), 1) CE0012 + CE0117 +// sort(filter(…), Name) mxbuild ABORTS — InvalidOperationException on the +// sort attribute, which resolves against the (now +// absent) list's entity. No error code, no line: the +// document cannot be loaded at all. +// count('nonsense') CE0012 — nesting is not required to lose the list +// +// The control for all of them is the reporter's own workaround, which builds +// the same two activities explicitly and passes at 0 errors. +// +// Why refuse rather than materialise an implicit variable: the refusal covers +// every spelling from one rule, including the literal operand, which no amount +// of materialising would fix. And it needs no project — the answer is in the +// statement's own text — so plain `mxcli check` reports it. +func (v *microflowValidator) checkListOperationSource(body []ast.MicroflowStatement) { + forEachMicroflowStatement(body, func(s ast.MicroflowStatement) { + switch stmt := s.(type) { + case *ast.ListOperationStmt: + op := strings.ToLower(stmt.Operation.String()) + for _, u := range stmt.UnresolvedOperands { + v.reportUnresolvedListOperand(op, stmt.OutputVariable, u, "CE0096") + } + case *ast.AggregateListStmt: + op := strings.ToLower(stmt.Operation.String()) + for _, u := range stmt.UnresolvedOperands { + v.reportUnresolvedListOperand(op, stmt.OutputVariable, u, "CE0012") + } + } + }) +} + +// reportUnresolvedListOperand emits one MDL-LISTOP02 violation, naming what was +// written where a list variable belongs and printing the rewrite that works. +func (v *microflowValidator) reportUnresolvedListOperand(op, outputVar string, u ast.UnresolvedOperand, ce string) { + which := "list argument" + if u.Index == 1 { + which = "second list argument" + } + + src := microflowExprSource(u.Expr) + if src == "" { + v.addViolation("MDL-LISTOP02", linter.SeverityError, + fmt.Sprintf("%s(…): the %s is missing. A Mendix %s activity stores its list as a "+ + "variable reference, so mxbuild rejects an empty one with %s "+ + "\"The 'List' property is required.\".", op, which, activityNoun(op), ce), + fmt.Sprintf("Pass a list variable, e.g. $%s = %s($MyList).", displayVar(outputVar), op)) + return + } + + v.addViolation("MDL-LISTOP02", linter.SeverityError, + fmt.Sprintf("%s(…): the %s is `%s`, which is not a variable. A Mendix %s activity stores "+ + "its list as a variable reference and has no slot for a nested computation, so the "+ + "argument is dropped and the activity is written with an empty list — mxbuild then "+ + "rejects it with %s \"The 'List' property is required.\".", + op, which, src, activityNoun(op), ce), + unresolvedListOperandRemedy(op, outputVar, u, src)) +} + +// unresolvedListOperandRemedy prints the rewrite. It names the variable to +// introduce and which argument to put it in, rather than reconstructing the whole +// corrected statement: the operand's position differs per operation (filter and +// sort carry a predicate or a sort spec after the list, union carries a second +// list), so a reconstructed example would be wrong for most of them. +func unresolvedListOperandRemedy(op, outputVar string, u ast.UnresolvedOperand, src string) string { + name := "$" + displayVar(outputVar) + "_source" + if u.Index == 1 { + name = "$" + displayVar(outputVar) + "_second" + } + which := "list argument" + if u.Index == 1 { + which = "second list argument" + } + if isListOperationCall(u.Expr) { + return fmt.Sprintf("Give the inner operation its own statement and pass its variable: "+ + "`%s = %s;` then use `%s` as the %s of %s(…). Each list operation is a separate "+ + "activity in Mendix, so they cannot be nested in one expression.", + name, src, name, which, op) + } + return fmt.Sprintf("Assign a list to a variable and pass the variable as the %s of %s(…), "+ + "e.g. `%s = ;`.", which, op, name) +} + +// isListOperationCall reports whether an expression is a call to one of the list +// operations — the case where the remedy can name the exact rewrite. +func isListOperationCall(expr ast.Expression) bool { + call, ok := expr.(*ast.FunctionCallExpr) + if !ok { + return false + } + switch strings.ToUpper(call.Name) { + case "HEAD", "TAIL", "FIND", "FILTER", "SORT", "UNION", "INTERSECT", + "SUBTRACT", "RANGE", "COUNT", "SUM", "AVERAGE", "MINIMUM", "MAXIMUM": + return true + } + return false +} + +// activityNoun names the activity Mendix would build, for the diagnostic. +func activityNoun(op string) string { + switch op { + case "count", "sum", "average", "minimum", "maximum", "reduce", "all", "any": + return "aggregate list" + default: + return "list operation" + } +} + +// displayVar keeps the message readable when the statement has no output +// variable (a parse that got far enough to build the activity but not to name +// its result). +func displayVar(outputVar string) string { + if outputVar == "" { + return "Result" + } + return outputVar +} diff --git a/mdl/executor/validate_microflow_listop_source_test.go b/mdl/executor/validate_microflow_listop_source_test.go new file mode 100644 index 0000000000..582fa3d41c --- /dev/null +++ b/mdl/executor/validate_microflow_listop_source_test.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// listopSourceViolations parses real MDL, validates the microflow it declares +// and returns only the MDL-LISTOP02 messages. +// +// It goes through the visitor rather than hand-building the AST on purpose: the +// defect in mendixlabs/mxcli#1101 IS the conversion, so a test that writes the +// AST it wants would assert against the wrong side of the bug. +func listopSourceViolations(t *testing.T, src string) []string { + t.Helper() + prog := parseMDL(t, src) + var out []string + for _, stmt := range prog.Statements { + mf, ok := stmt.(*ast.CreateMicroflowStmt) + if !ok { + continue + } + for _, v := range ValidateMicroflow(mf) { + if v.RuleID == "MDL-LISTOP02" { + out = append(out, v.Message) + } + } + } + return out +} + +// microflowSrc wraps a body in a microflow that retrieves two lists, so each +// case below is only the statement under test. +func microflowSrc(name, body string) string { + return "create or replace microflow Shop." + name + `() +begin + retrieve $reqs from Shop.Request; + retrieve $others from Shop.Request; + ` + body + ` +end;` +} + +// TestNestedListOperandIsReported is mendixlabs/mxcli#1101 exactly as reported: +// COUNT over an inline FILTER. Measured before the fix — `check` clean, `exec` +// printed "Created microflow", and mxbuild 11.6.6 then failed the build with +// CE0012 "The 'List' property is required." at Aggregate list activity 'Count'. +func TestNestedListOperandIsReported(t *testing.T) { + got := listopSourceViolations(t, microflowSrc("CountFilter", + `$n = COUNT(FILTER($reqs, $currentObject/Status = Shop.ENUM_Status.Approved));`)) + if len(got) != 1 { + t.Fatalf("expected 1 MDL-LISTOP02 violation, got %d: %v", len(got), got) + } + for _, want := range []string{"count", "filter($reqs", "CE0012"} { + if !strings.Contains(got[0], want) { + t.Errorf("message %q does not mention %q", got[0], want) + } + } +} + +// TestNestedListOperandInEveryShape covers the spellings measured against +// mxbuild 11.6.6 while investigating #1101. The list operand is dropped by the +// same line in every one of them, so a fix that only handles COUNT leaves the +// rest silent. +func TestNestedListOperandInEveryShape(t *testing.T) { + cases := []struct { + name string + body string + // buildSymptom is what mxbuild did with the document before the fix. + buildSymptom string + }{ + {"CountOfFilter", `$n = COUNT(FILTER($reqs, $currentObject/Name != ''));`, "CE0012"}, + {"HeadOfFilter", `$h = HEAD(FILTER($reqs, $currentObject/Name != ''));`, "CE0096"}, + {"SumOfFilter", `$n = SUM(FILTER($reqs, $currentObject/Name != ''), 1);`, "CE0012 + CE0117"}, + {"SortOfFilter", `$s = SORT(FILTER($reqs, $currentObject/Name != ''), Name);`, "mxbuild abort"}, + {"FilterOfSort", `$f = FILTER(SORT($reqs, Name), $currentObject/Name != '');`, "CE0096"}, + {"TailOfFilter", `$t = TAIL(FILTER($reqs, $currentObject/Name != ''));`, "CE0096"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := listopSourceViolations(t, microflowSrc(tc.name, tc.body)) + if len(got) != 1 { + t.Fatalf("%s (mxbuild: %s): expected 1 MDL-LISTOP02 violation, got %d: %v", + tc.body, tc.buildSymptom, len(got), got) + } + }) + } +} + +// TestLiteralListOperandIsReported: nesting is not required to lose the list. +// `COUNT('nonsense')` reached mxbuild as an aggregate with an empty List and the +// same CE0012 — so the rule keys on "did not reduce to a variable", not on the +// operand being a call. +func TestLiteralListOperandIsReported(t *testing.T) { + got := listopSourceViolations(t, microflowSrc("CountLiteral", `$n = COUNT('nonsense');`)) + if len(got) != 1 { + t.Fatalf("expected 1 MDL-LISTOP02 violation, got %d: %v", len(got), got) + } +} + +// TestSecondListOperandIsReported covers the two-list operations, where the +// dropped operand is the SECOND one — `union($others, filter(…))` stored +// $others and an empty second list. +func TestSecondListOperandIsReported(t *testing.T) { + got := listopSourceViolations(t, microflowSrc("UnionFilter", + `$u = UNION($others, FILTER($reqs, $currentObject/Name != ''));`)) + if len(got) != 1 { + t.Fatalf("expected 1 MDL-LISTOP02 violation, got %d: %v", len(got), got) + } + if !strings.Contains(got[0], "second") { + t.Errorf("message %q does not say which operand was dropped", got[0]) + } +} + +// TestResolvedListOperandsAreNotReported is the control. Every one of these is a +// spelling mxbuild accepts at 0 errors (the two-variable form is the workaround +// the reporter found), so a rule that fires here would be worse than the bug. +func TestResolvedListOperandsAreNotReported(t *testing.T) { + bodies := []string{ + // The reporter's own workaround. + `$approved = FILTER($reqs, $currentObject/Name != ''); + $n = COUNT($approved);`, + `$n = COUNT($reqs);`, + `$h = HEAD($reqs);`, + `$s = SORT($reqs, Name);`, + `$u = UNION($reqs, $others);`, + `$i = INTERSECT($reqs, $others);`, + `$r = RANGE($reqs, 0, 10);`, + `$f = FILTER($reqs, $currentObject/Name != '');`, + // Aggregates over an attribute path and over a per-item expression: + // buildSetAggregate resolves both, so neither is a dropped operand. + `$n = SUM($reqs.Amount);`, + `$n = SUM($reqs, $currentObject/Amount * 2);`, + // String functions that share a name with a list operation must stay + // value expressions — they never become a list activity at all. + `$p = find('haystack', 'needle');`, + `$b = contains($reqs/Name, 'x');`, + } + for i, body := range bodies { + got := listopSourceViolations(t, microflowSrc("Control", body)) + if len(got) != 0 { + t.Errorf("case %d %q: expected no MDL-LISTOP02 violation, got %v", i, body, got) + } + } +} diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 5c42501419..db4eed1f9d 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -802,146 +802,209 @@ func buildSetStatementNode(ctx parser.ISetStatementContext) ast.MicroflowStateme valueExpr = buildExpression(expr) } - // Check if the expression is a list operation or aggregate function + // Check if the expression is a list operation or aggregate function. if funcCall, ok := valueExpr.(*ast.FunctionCallExpr); ok { - funcName := strings.ToUpper(funcCall.Name) + if stmt := buildListOrAggregateStatement(targetVar, funcCall); stmt != nil { + return recordUnresolvedOperands(stmt, funcCall.Arguments) + } + } - // Check for list operations: HEAD, TAIL, FIND, FILTER, SORT, UNION, INTERSECT, SUBTRACT, CONTAINS, EQUALS - switch funcName { - case "HEAD": - return &ast.ListOperationStmt{ - OutputVariable: targetVar, - Operation: ast.ListOpHead, - InputVariable: extractVariableName(funcCall.Arguments, 0), - } - case "TAIL": - return &ast.ListOperationStmt{ - OutputVariable: targetVar, - Operation: ast.ListOpTail, - InputVariable: extractVariableName(funcCall.Arguments, 0), - } - case "FIND": - // `find` is overloaded: the LIST operation find(list, condition) — which - // filters a list by a boolean condition — and the STRING function - // find(haystack, needle) → the index of a substring. A STRING-LITERAL - // second argument is unambiguously the string function (you never filter - // a list by a bare string literal); it must stay a value expression, not - // a lossy List operation activity whose output variable collides - // (CE0111). Ledger #63. When both arguments are plain variables the kind - // is ambiguous here; the flow builder disambiguates String-typed inputs. - if !isStringLiteralArg(funcCall.Arguments, 1) { - return &ast.ListOperationStmt{ - OutputVariable: targetVar, - Operation: ast.ListOpFind, - InputVariable: extractVariableName(funcCall.Arguments, 0), - Condition: getArgumentExpression(funcCall.Arguments, 1), - } - } - // Falls through to the default MfSetStmt (string find expression). - case "FILTER": + if valueExprCtx != nil { + valueExpr = buildSourceExpression(valueExprCtx) + valueExpr = appendStatementExpressionTrailingWhitespace(valueExprCtx, valueExpr) + } + + // Default: regular SET statement + return &ast.MfSetStmt{ + Target: targetVar, + Value: valueExpr, + } +} + +// buildListOrAggregateStatement converts a SET whose value is a list-operation +// or aggregate call into the matching activity statement, or returns nil when +// the call is not one of those (or is the string-function reading of an +// overloaded name, which must stay a value expression). +// +// It is a separate function so that every arm funnels through one tail in the +// caller — recordUnresolvedOperands. Doing the same bookkeeping inline in each +// arm is how the list operand went missing in the first place; see the note on +// buildSetAggregate about two conversions for one syntax. +func buildListOrAggregateStatement(targetVar string, funcCall *ast.FunctionCallExpr) ast.MicroflowStatement { + funcName := strings.ToUpper(funcCall.Name) + + // Check for list operations: HEAD, TAIL, FIND, FILTER, SORT, UNION, INTERSECT, SUBTRACT, CONTAINS, EQUALS + switch funcName { + case "HEAD": + return &ast.ListOperationStmt{ + OutputVariable: targetVar, + Operation: ast.ListOpHead, + InputVariable: extractVariableName(funcCall.Arguments, 0), + } + case "TAIL": + return &ast.ListOperationStmt{ + OutputVariable: targetVar, + Operation: ast.ListOpTail, + InputVariable: extractVariableName(funcCall.Arguments, 0), + } + case "FIND": + // `find` is overloaded: the LIST operation find(list, condition) — which + // filters a list by a boolean condition — and the STRING function + // find(haystack, needle) → the index of a substring. A STRING-LITERAL + // second argument is unambiguously the string function (you never filter + // a list by a bare string literal); it must stay a value expression, not + // a lossy List operation activity whose output variable collides + // (CE0111). Ledger #63. When both arguments are plain variables the kind + // is ambiguous here; the flow builder disambiguates String-typed inputs. + if !isStringLiteralArg(funcCall.Arguments, 1) { return &ast.ListOperationStmt{ OutputVariable: targetVar, - Operation: ast.ListOpFilter, + Operation: ast.ListOpFind, InputVariable: extractVariableName(funcCall.Arguments, 0), Condition: getArgumentExpression(funcCall.Arguments, 1), } - case "SORT": - stmt := &ast.ListOperationStmt{ - OutputVariable: targetVar, - Operation: ast.ListOpSort, - InputVariable: extractVariableName(funcCall.Arguments, 0), - } - // Parse sort specifications from remaining arguments - stmt.SortSpecs = extractSortSpecs(funcCall.Arguments[1:]) - return stmt - case "UNION": - return &ast.ListOperationStmt{ - OutputVariable: targetVar, - Operation: ast.ListOpUnion, - InputVariable: extractVariableName(funcCall.Arguments, 0), - SecondVariable: extractVariableName(funcCall.Arguments, 1), - } - case "INTERSECT": - return &ast.ListOperationStmt{ - OutputVariable: targetVar, - Operation: ast.ListOpIntersect, - InputVariable: extractVariableName(funcCall.Arguments, 0), - SecondVariable: extractVariableName(funcCall.Arguments, 1), - } - case "SUBTRACT": - return &ast.ListOperationStmt{ - OutputVariable: targetVar, - Operation: ast.ListOpSubtract, - InputVariable: extractVariableName(funcCall.Arguments, 0), - SecondVariable: extractVariableName(funcCall.Arguments, 1), - } - case "CONTAINS": - // `contains` is overloaded: the LIST operation contains(list, object) - // and the STRING function contains(haystack, needle). A List operation - // activity requires two plain list/object variables; if either argument - // is a literal or a computed expression it is unambiguously the string - // function, which must stay a value expression (a Change Variable - // action) — serializing it as a List operation fails the build - // (CE0023/CE0097/CE0111). Ledger finding #53. When both arguments are - // plain variables the kind is still ambiguous here (no type info); the - // flow builder disambiguates String-typed inputs downstream. - if isPlainVariableArg(funcCall.Arguments, 0) && isPlainVariableArg(funcCall.Arguments, 1) { - return &ast.ListOperationStmt{ - OutputVariable: targetVar, - Operation: ast.ListOpContains, - InputVariable: extractVariableName(funcCall.Arguments, 0), - SecondVariable: extractVariableName(funcCall.Arguments, 1), - } - } - // Falls through to the default MfSetStmt (string contains expression). - case "EQUALS": + } + // Falls through to the default MfSetStmt (string find expression). + case "FILTER": + return &ast.ListOperationStmt{ + OutputVariable: targetVar, + Operation: ast.ListOpFilter, + InputVariable: extractVariableName(funcCall.Arguments, 0), + Condition: getArgumentExpression(funcCall.Arguments, 1), + } + case "SORT": + stmt := &ast.ListOperationStmt{ + OutputVariable: targetVar, + Operation: ast.ListOpSort, + InputVariable: extractVariableName(funcCall.Arguments, 0), + } + // Parse sort specifications from remaining arguments + stmt.SortSpecs = extractSortSpecs(funcCall.Arguments[1:]) + return stmt + case "UNION": + return &ast.ListOperationStmt{ + OutputVariable: targetVar, + Operation: ast.ListOpUnion, + InputVariable: extractVariableName(funcCall.Arguments, 0), + SecondVariable: extractVariableName(funcCall.Arguments, 1), + } + case "INTERSECT": + return &ast.ListOperationStmt{ + OutputVariable: targetVar, + Operation: ast.ListOpIntersect, + InputVariable: extractVariableName(funcCall.Arguments, 0), + SecondVariable: extractVariableName(funcCall.Arguments, 1), + } + case "SUBTRACT": + return &ast.ListOperationStmt{ + OutputVariable: targetVar, + Operation: ast.ListOpSubtract, + InputVariable: extractVariableName(funcCall.Arguments, 0), + SecondVariable: extractVariableName(funcCall.Arguments, 1), + } + case "CONTAINS": + // `contains` is overloaded: the LIST operation contains(list, object) + // and the STRING function contains(haystack, needle). A List operation + // activity requires two plain list/object variables; if either argument + // is a literal or a computed expression it is unambiguously the string + // function, which must stay a value expression (a Change Variable + // action) — serializing it as a List operation fails the build + // (CE0023/CE0097/CE0111). Ledger finding #53. When both arguments are + // plain variables the kind is still ambiguous here (no type info); the + // flow builder disambiguates String-typed inputs downstream. + if isPlainVariableArg(funcCall.Arguments, 0) && isPlainVariableArg(funcCall.Arguments, 1) { return &ast.ListOperationStmt{ OutputVariable: targetVar, - Operation: ast.ListOpEquals, + Operation: ast.ListOpContains, InputVariable: extractVariableName(funcCall.Arguments, 0), SecondVariable: extractVariableName(funcCall.Arguments, 1), } - case "RANGE": - stmt := &ast.ListOperationStmt{ - OutputVariable: targetVar, - Operation: ast.ListOpRange, - InputVariable: extractVariableName(funcCall.Arguments, 0), - } - if len(funcCall.Arguments) > 1 { - stmt.OffsetExpr = funcCall.Arguments[1] - } - if len(funcCall.Arguments) > 2 { - stmt.LimitExpr = funcCall.Arguments[2] - } - return stmt - // Check for aggregate operations: COUNT, SUM, AVERAGE, MINIMUM, MAXIMUM - case "COUNT": - return &ast.AggregateListStmt{ - OutputVariable: targetVar, - Operation: ast.AggregateCount, - InputVariable: extractVariableName(funcCall.Arguments, 0), - } - case "SUM": - return buildSetAggregate(targetVar, ast.AggregateSum, funcCall.Arguments) - case "AVERAGE": - return buildSetAggregate(targetVar, ast.AggregateAverage, funcCall.Arguments) - case "MINIMUM": - return buildSetAggregate(targetVar, ast.AggregateMinimum, funcCall.Arguments) - case "MAXIMUM": - return buildSetAggregate(targetVar, ast.AggregateMaximum, funcCall.Arguments) } + // Falls through to the default MfSetStmt (string contains expression). + case "EQUALS": + return &ast.ListOperationStmt{ + OutputVariable: targetVar, + Operation: ast.ListOpEquals, + InputVariable: extractVariableName(funcCall.Arguments, 0), + SecondVariable: extractVariableName(funcCall.Arguments, 1), + } + case "RANGE": + stmt := &ast.ListOperationStmt{ + OutputVariable: targetVar, + Operation: ast.ListOpRange, + InputVariable: extractVariableName(funcCall.Arguments, 0), + } + if len(funcCall.Arguments) > 1 { + stmt.OffsetExpr = funcCall.Arguments[1] + } + if len(funcCall.Arguments) > 2 { + stmt.LimitExpr = funcCall.Arguments[2] + } + return stmt + // Check for aggregate operations: COUNT, SUM, AVERAGE, MINIMUM, MAXIMUM + case "COUNT": + return &ast.AggregateListStmt{ + OutputVariable: targetVar, + Operation: ast.AggregateCount, + InputVariable: extractVariableName(funcCall.Arguments, 0), + } + case "SUM": + return buildSetAggregate(targetVar, ast.AggregateSum, funcCall.Arguments) + case "AVERAGE": + return buildSetAggregate(targetVar, ast.AggregateAverage, funcCall.Arguments) + case "MINIMUM": + return buildSetAggregate(targetVar, ast.AggregateMinimum, funcCall.Arguments) + case "MAXIMUM": + return buildSetAggregate(targetVar, ast.AggregateMaximum, funcCall.Arguments) } + return nil +} - if valueExprCtx != nil { - valueExpr = buildSourceExpression(valueExprCtx) - valueExpr = appendStatementExpressionTrailingWhitespace(valueExprCtx, valueExpr) +// recordUnresolvedOperands notes every list operand that did not reduce to a +// variable name, so the validator can refuse the statement instead of writing an +// activity with an empty List (mendixlabs/mxcli#1101). +// +// It keys on the RESULT of the conversion rather than on the argument's node +// type, which is what makes it uniform across the arms: buildSetAggregate reads +// an attribute path (`sum($List.Price)`) that extractVariableName cannot, so a +// predicate written over node types would have to differ per arm and would drift +// apart again. An empty InputVariable means the conversion found nothing to +// store, whatever the reason. +func recordUnresolvedOperands(stmt ast.MicroflowStatement, args []ast.Expression) ast.MicroflowStatement { + operand := func(index int) ast.UnresolvedOperand { + op := ast.UnresolvedOperand{Index: index} + if index < len(args) { + op.Expr = args[index] + } + return op + } + switch s := stmt.(type) { + case *ast.ListOperationStmt: + if s.InputVariable == "" { + s.UnresolvedOperands = append(s.UnresolvedOperands, operand(0)) + } + if listOperationTakesSecondList(s.Operation) && s.SecondVariable == "" { + s.UnresolvedOperands = append(s.UnresolvedOperands, operand(1)) + } + case *ast.AggregateListStmt: + if s.InputVariable == "" { + s.UnresolvedOperands = append(s.UnresolvedOperands, operand(0)) + } } + return stmt +} - // Default: regular SET statement - return &ast.MfSetStmt{ - Target: targetVar, - Value: valueExpr, +// listOperationTakesSecondList reports whether the operation's SECOND argument is +// another list. It is not "has a second argument": SORT's is a sort spec, +// FILTER/FIND's is a predicate and RANGE's is an offset, none of which belong in +// the list-operand check. +func listOperationTakesSecondList(op ast.ListOperationType) bool { + switch op { + case ast.ListOpUnion, ast.ListOpIntersect, ast.ListOpSubtract, + ast.ListOpContains, ast.ListOpEquals: + return true } + return false } // extractVariableName extracts a variable name from an argument at the given index. From 9e29bcad93cfd0b4bb2002656af29d9469bf9ca8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 12:15:15 +0000 Subject: [PATCH 11/12] fix(enumerations): expose the System module's enumerations, read-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `describe enumeration System.WorkflowActivityType` answered "enumeration not found" while `describe entity System.WorkflowActivityRecord` printed `ActivityType: Enumeration(System.WorkflowActivityType)` in the same session, so a System enum's values could only be guessed at until the build rejected a guess with CE1613 (mendixlabs/mxcli#1102). `modelsdk/meta.SystemEnumerations` — all 15 enumerations with their values — had been in the tree since #889 with zero non-test consumers. The System module is not stored in the .mpr (the string `WorkflowActivityType` occurs in 0 of 370 mprcontents units), so its entities, associations and Java actions each have a Build* helper appended to a listing; the enumeration half had the data and no wiring. Adding `BuildSystemEnumerations` and appending it in `ListEnumerations` fixes four reported symptoms at once, since the catalog builder reads through the same backend: - `describe enumeration System.X` reports the values - `show enumerations` lists them (15 rows) - `check --references` no longer rejects an attribute typed against one — a false positive that blocked valid scripts, the #1071 direction of this gap - `CATALOG.attributes ⋈ CATALOG.enumerations` resolves 19 of 19 on the expr-checker fixture, where it resolved 0 Making them visible also made them addressable by the write paths, and the System module has no stored unit for a write to live in. `CREATE ENUMERATION System.BrandNewThing` already reported success and wrote a unit whose ContainerID was the synthetic module id 00000000-…-0001, present in no Unit row — an orphan with a dangling parent, measured at 369 → 370 units. So every enumeration write naming System is now refused before the backend is touched: create, alter, drop, move (both ends), rename, and `DROP MODULE System`, whose cascade would otherwise warn once per synthesized document. DESCRIBE emits `--` comment lines for these rather than a `create or modify` statement the guard would reject, as DESCRIBE BUILDING BLOCK does for the other read-only doctype. The system-module skill's enumeration section had drifted into wrong casing (`created`, `end`, `single`, `microflow`, `error`, `user`, `external`) and was missing three enumerations including WorkflowActivityState — so a developer copying `created` out of it hit the very CE1613 the section existed to prevent. Regenerated from the table and pinned by a test. The write-workflows skill documented the bug as a permanent limitation with a workaround; updated. Tests: the resolvability guard the entity half has had all along (TestModelerSystemEntities_HaveResolvableGeneralizations) now has its enumeration sibling. Each half of the fix was reverted to confirm its tests fail with the reported symptom, and the doc guard was checked against the original drift. Controls cover stored enumerations still listing, resolving by ID, user enumeration writes still working, and a user enumeration still describing as re-executable MDL. Not fixed, filed separately: `search` still does not match these (its index is built from value captions, and the meta table carries names only — defaulting a caption to the value name would be inventing text indistinguishable from a developer's own), and enum-split CASE values are validated against the enumeration for no module, user or System. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DHM1in4g85vBtHnbjSejSd --- .../fix-issue/findings/mdl-backend.jsonl | 1 + .../fix-issue/findings/mdl-executor.jsonl | 1 + .claude/skills/mendix/system-module/SKILL.md | 71 +++-- .../skills/mendix/write-workflows/SKILL.md | 25 +- cmd/mxcli/syntax/features_domain_model.go | 2 +- docs/01-project/MDL_QUICK_REFERENCE.md | 4 +- .../1102-system-enumerations-readable.mdl | 44 +++ mdl/backend/modelsdk/enumeration.go | 20 +- .../modelsdk/enumeration_system_test.go | 145 +++++++++ mdl/backend/modelsdk/enumeration_test.go | 22 +- mdl/executor/cmd_enumerations.go | 62 ++++ mdl/executor/cmd_enumerations_system_test.go | 274 ++++++++++++++++++ mdl/executor/cmd_modules.go | 12 + mdl/executor/cmd_move.go | 9 + mdl/executor/cmd_rename.go | 5 + modelsdk/meta/system_enumerations.go | 64 ++++ modelsdk/meta/system_enumerations_test.go | 190 ++++++++++++ 17 files changed, 911 insertions(+), 40 deletions(-) create mode 100644 mdl-examples/bug-tests/1102-system-enumerations-readable.mdl create mode 100644 mdl/backend/modelsdk/enumeration_system_test.go create mode 100644 mdl/executor/cmd_enumerations_system_test.go create mode 100644 modelsdk/meta/system_enumerations.go create mode 100644 modelsdk/meta/system_enumerations_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend.jsonl b/.claude/skills/fix-issue/findings/mdl-backend.jsonl index 5cb3649c7e..30d3e9afce 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/modelsdk", "date": "2026-09-16", "symptom": "`describe enumeration System.WorkflowActivityType` -> \"enumeration not found\" while `describe entity System.WorkflowActivityRecord` prints `ActivityType: Enumeration(System.WorkflowActivityType)` in the same session. `show enumerations` omits every System enum; `check --references` REJECTS a valid attribute typed against one (a false positive that blocks correct scripts); `CATALOG.attributes LEFT JOIN CATALOG.enumerations` resolves 0 of 19 on a blank app. Values could only be guessed at until the build rejected one with CE1613", "cause": "`modelsdk/meta.SystemEnumerations` — all 15 System enumerations with their values — had been in the tree since #889 with ZERO non-test consumers. The System module is not stored in the .mpr at all (measured: the string `WorkflowActivityType` occurs in 0 of 370 mprcontents units and 0 bytes of the .mpr sqlite), so its entities/associations/Java actions are each synthesized by a `Build*` helper and appended to a listing. The enumeration half had the data table and neither the helper nor the wiring, so the entity attribute printed a type naming a document nothing could produce", "file": "`modelsdk/meta/system_enumerations.go` (new, `BuildSystemEnumerations`); wired in `mdl/backend/modelsdk/enumeration.go` (`ListEnumerations`, `GetEnumeration`)", "insight": "**A data table with no consumer looks exactly like a missing feature.** Before opening any file, `grep -rn --include=*.go | grep -v _test` — zero hits is the whole diagnosis, and it took one command. The virtual System module has one wiring point PER LISTING, so the question for any new System doctype is \"which listings must it appear in\", not \"is the data there\". **One append fixed four of the five reported symptoms at once** (describe, show, check --references, catalog) because the catalog builder takes `ctx.Backend` as its reader — so `ListEnumerations` is the single choke point. Two traps. (1) `search` was NOT fixed by it: the strings index is built from value CAPTIONS (`builder_modules.go`), and the meta table carries names only, so System enums produce 0 rows in `CATALOG.strings` while user enums produce 36. Defaulting a caption to the value name would be inventing text indistinguishable from a developer's own — left unfixed and filed instead. (2) A stale `.mxcli/catalog.db` made the catalog look unfixed for three measurements; delete it before concluding anything about catalog output. Diagnosis tip: the sibling guard `TestModelerSystemEntities_HaveResolvableGeneralizations` existed and its enumeration twin did not — when one half of a synthesized module has a resolvability test, check whether the others do", "refs": "mendixlabs/mxcli#1102, mendixlabs/mxcli#1071, #889", "ce": "CE1613"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 4bfe200a91..1f97a1b022 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -628,3 +628,4 @@ {"area":"mdl/executor","date":"2026-09-15","symptom":"TestRoundtripPage_MicroflowButtonWithCurrentObject failed on main and on every branch cut from it: 'Expected Target: $currentObject parameter mapping in describe output', while the printed output plainly contained the mapping as \"Target\": $currentObject. Unit tests were green; only the integration suite (-tags integration) caught it.","cause":"Not a describe regression at all. mdl/executor/identifier_quoting.go's mdlIdent quotes any identifier that does not LEX as a bare identifier, running the real ANTLR lexer. #476 (notify workflow ... TARGET) added `TARGET: T A R G E T;` to MDLLexer.g4, so the parameter named Target began lexing as a keyword token and DESCRIBE started quoting it. The output became MORE correct; the test's exact-substring assertion went stale.","file":"mdl/executor/roundtrip_page_test.go","fix":"Made the assertion quoting-agnostic (accepts Target: or \\\"Target\\\":). Controlled by renaming the expected parameter to a name that is absent, which still fails — so the assertion continues to detect a genuinely dropped mapping rather than passing on anything.","insight":"Adding a keyword to MDLLexer.g4 silently reformats DESCRIBE output for every existing element whose NAME matches that keyword, anywhere mdlIdent is used — the grammar change and the broken test are in different packages with no compile-time link, so nothing points from one to the other. When adding a token, grep the test tree for exact-substring assertions containing that word: here `grep -rn '\"Target: '` found the single collision in seconds, where reading the #476 diff never would have. The deeper rule is that an exact-substring assertion on DESCRIBE output encodes a quoting decision the test does not care about; assert the mapping quoting-agnostically, or re-parse the output, since what a roundtrip test means to check is that the mapping survived. Note the input side did NOT break: TARGET was added to the non-reserved-keyword rule, so scripts writing `Target:` unquoted still parse — which is why check-mdl's 544 scripts stayed green and only this one output assertion moved."} {"area": "mdl/executor", "date": "2026-09-15", "symptom": "check --references rejected a page that mxbuild builds at 0 errors: 'the constraint on Administration.Account names \"System.UserRoles\", which is neither an attribute nor an association of it'. Administration.Account extends System.User and System.UserRoles is declared from System.User, so it IS an association of it, by inheritance. Every inherited association was a false positive, and separately so was every cross-module one. Inherited ATTRIBUTES were fine throughout, which is what made it look like a narrow bug rather than a whole axis.", "cause": "associationTargetFrom matched the start entity against the association's two ends by exact equality and scanned only dm.Associations. Its doc comment said a specialisation deliberately returns false, 'the cost of being wrong is a false error on a working script' - sound while its only caller (resolveMemberOnEntity, typing an association retrieve) treated false as silence. The new XPath constraint checker's noteQualified then called the same helper and treated false as EVIDENCE, so the exact case the comment declined to chase became the finding. noteQualified did carry a three-valued guard, but it tested whether the BASE ENTITY was known - the wrong axis; Administration.Account is known.", "file": "mdl/executor/validate_member_refs.go", "fix": "Replaced the boolean with assocResolution (resolved / missing / notAnEnd / unknown). The start entity is matched through its generalization chain (generalizationChain, built on findEntityByQN), and a chain that could not be walked to its root yields assocUnknown = silence. associationEndsByName also reads dm.CrossAssociations, whose far end is held by NAME not by element ID. noteQualified reports only assocMissing and assocNotAnEnd.", "insight": "A helper whose contract is 'false when it cannot establish the answer' is safe only while every caller treats false as silence; the moment one caller treats it as evidence, the comment promising restraint becomes the specification of a false positive. A boolean cannot carry 'no' and 'don't know' to two callers that need to tell them apart - if the codebase already has a three-valued resolver next door (memberResolution, ten lines up), reusing the two-valued sibling is the smell. Two cheap guards would have caught it: a fixture entity with a GENERALIZATION carrying an association, and one CROSS-MODULE association - a fixture of flat single-module entities cannot distinguish a correct resolver from one comparing two names. Establish the verdict with the build, not by reading: mx check on the rejected page said 0 errors, which settles it in one run."} {"area": "mdl/executor", "date": "2026-09-16", "symptom": "mendixlabs/mxcli#1103's real defect: `retrieve $reqs from Mod.E where … limit 1;` followed by `head($reqs)` passed `mxcli check --references` and failed the build with CE0097 'The selected reqs variable must be of type List'. Inside a .test.mdl file it was worse — the injected test just failed to build, with no error text at all on the --attach path.", "cause": "cmd_microflows_builder_actions.go maps `limit \"1\"` with no offset to microflows.RangeTypeFirst — Mendix's 'First object' range — so the output variable is an OBJECT, not a one-element list. That is deliberate and documented (MDL_QUICK_REFERENCE), but nothing between the author and mxbuild said so: describe re-emits `limit 1`, so an object retrieve and a list retrieve are byte-identical MDL.", "file": "mdl/executor/validate_microflow_retrieve_single.go", "fix": "MDL-RETRIEVE01: track variables bound by a limit-1-no-offset retrieve in statement order (a rebinding clears them) and flag a later list use — list operation, aggregate, loop, ADD/REMOVE. Keyed on exactly the writer's condition. The message names CE0097 and both working spellings.", "insight": "A silent type change is the expensive kind, and this one had every property that makes it hard: the source text is identical on both sides, DESCRIBE round-trips it unchanged, and the only signal is a CE code from a tool at the far end of a build. When a clause changes a variable's CARDINALITY rather than its value, the check that catches it has to key on exactly the same condition as the writer — `limit == \"1\" && offset == \"\"` here, copied from the builder — or the diagnostic and the model disagree, which is worse than neither. The confusion is also structural, not carelessness: the SAME word means the opposite elsewhere in MDL, since `import from mapping … first` binds an object and `… limit 1` a one-element list. Where a language contradicts itself, the message must name the working spelling rather than only refuse. Cheap control worth copying: run the whole mdl-examples corpus (`make check-mdl`, 558 scripts) after adding a rule — zero new failures is a real statement about false positives that unit tests cannot make."} +{"area": "mdl/executor", "date": "2026-09-16", "symptom": "`CREATE ENUMERATION System.BrandNewThing (A 'a');` prints **\"Created enumeration: System.BrandNewThing\"** and writes an ORPHANED unit — measured 369 -> 370 units, the new unit's ContainerID is the synthetic module id `00000000-0000-0000-0000-000000000001`, which is present in no Unit row. Silent model corruption on a success message. `ALTER`/`DROP`/`MOVE ENUMERATION System.X` instead leak a raw `open …/00000000-…-0002.mxunit: no such file or directory`", "cause": "The System module is virtual — synthesized in code, no stored unit — but `findOrCreateModule(\"System\")` resolves it out of `ListModules` and hands its SYNTHETIC id over as the new enumeration's container. Entities happen to fail safe because they must load the virtual domain-model unit first; an enumeration is a unit in its own right, so nothing stopped the write. Making the System enums readable (#1102) also made them addressable by the four write verbs, which is what forced the guard", "file": "`mdl/executor/cmd_enumerations.go` (`refuseSystemEnumerationWrite`, called from create/alter/drop); `mdl/executor/cmd_move.go` (`moveEnumeration`, both ends); `mdl/executor/cmd_rename.go` (`execRenameEnumeration`); `mdl/executor/cmd_modules.go` (`execDropModule` refuses System outright)", "insight": "**Making a synthesized element readable makes it writable — audit EVERY write verb in the same change.** The read fix is one append; enumerating what it exposed took three passes and kept growing: create, alter, drop, MOVE (both ends — guarding only the source still lets a user enum be moved INTO System, the same orphan by another route), rename, and DROP MODULE System, whose cascade walked the newly-visible documents and printed \"unit not found\" 15 times. `grep -rn 'Backend.CreateX\\|Backend.UpdateX\\|Backend.DeleteX\\|Backend.MoveX'` over the executor is the census that ends the guessing — do it BEFORE writing the guard, not after each test failure. **Key the guard on the module NAME, not the container**: a brand-new enumeration has no container yet, and that is exactly the case that corrupted. This is the opposite of `isMarketplaceModule`, which deliberately distrusts names because a user may name a module Atlas_Core — `System` is platform-reserved and cannot be a user module, so the name IS the signal. Assert the refusal never reached the backend, not just that an error came back: a refusal that still wrote would leave the orphan. **A read-only doctype must not DESCRIBE as re-executable MDL** — emitting `create or modify enumeration System.X` hands the reader (or an LLM) a statement the guard rejects, so System enums describe as `--` comment lines the way DESCRIBE BUILDING BLOCK does, with a control test that USER enums still round-trip. And do not name the statement's own element in the hint: on a CREATE that name does not exist, so \"use `describe enumeration System.BrandNewThing`\" sends the reader after nothing", "refs": "mendixlabs/mxcli#1102"} diff --git a/.claude/skills/mendix/system-module/SKILL.md b/.claude/skills/mendix/system-module/SKILL.md index cdc9e3da2a..709c004c6d 100644 --- a/.claude/skills/mendix/system-module/SKILL.md +++ b/.claude/skills/mendix/system-module/SKILL.md @@ -353,43 +353,68 @@ HTTP proxy settings (internal use). ## 8. Enumerations -### WorkflowState -`InProgress`, `Paused`, `Completed`, `Aborted`, `Incompatible`, `Failed` +Inspect any of these from the CLI rather than copying from here — the values are +**case-sensitive**, and a wrong one is only caught at build time as **CE1613** +"The selected enumeration value no longer exists": -### WorkflowUserTaskState -`created`, `InProgress`, `Completed`, `Paused`, `Aborted`, `Failed` +```bash +mxcli -p app.mpr describe enumeration System.WorkflowActivityType +mxcli -p app.mpr show enumerations # includes the System module +``` -### WorkflowUserTaskCompletionType -`single`, `Veto`, `Consensus`, `Majority`, `Threshold`, `microflow` +Two of these names are easy to confuse, and mixing them up is the mistake that +CE1613 usually reports: **`WorkflowActivityState`** has `Finished`, while +**`WorkflowActivityExecutionState`** has `Completed`. They are different +enumerations on different entities. -### WorkflowActivityType -`Start`, `end`, `ExclusiveSplit`, `ParallelSplit`, `ParallelSplitBranchStopper`, `ParallelSplitMerge`, `UserTask`, `CallMicroflow`, `CallWorkflow`, `JumpTo`, `MultiInputUserTask`, `WaitForNotification`, `WaitForTimer`, `EndOfBoundaryEventPath`, `NonInterruptingTimerEvent`, `InterruptingTimerEvent` +System enumerations are **read-only** — they are built into the platform, not +stored in the project, so `create`/`alter`/`drop`/`move enumeration System.…` is +refused. `describe` prints them as `--` comment lines for that reason. -### WorkflowActivityExecutionState -`created`, `InProgress`, `Completed`, `Paused`, `Aborted`, `Failed` +### ContextType +`System`, `User`, `Anonymous`, `ScheduledEvent` -### WorkflowCurrentActivityAction -`DoNothing`, `JumpTo` +### DeviceType +`Phone`, `Tablet`, `Desktop` -### WorkflowEventType -`WorkflowCompleted`, `WorkflowInitiated`, `WorkflowRestarted`, `WorkflowFailed`, `WorkflowAborted`, `WorkflowPaused`, `WorkflowUnpaused`, `WorkflowRetried`, `WorkflowUpdated`, `WorkflowUpgraded`, `WorkflowConflicted`, `WorkflowResolved`, `WorkflowJumpToOptionApplied`, `StartEventExecuted`, `EndEventExecuted`, `DecisionExecuted`, `JumpExecuted`, `ParallelSplitExecuted`, `ParallelMergeExecuted`, `CallWorkflowStarted`, `CallWorkflowEnded`, `CallMicroflowStarted`, `CallMicroflowEnded`, `WaitForNotificationStarted`, `WaitForNotificationEnded`, `WaitForTimerStarted`, `WaitForTimerEnded`, `UserTaskStarted`, `MultiUserTaskOutcomeSelected`, `UserTaskEnded`, `NonInterruptingTimerEventExecuted`, `InterruptingTimerEventExecuted` +### EventStatus +`Running`, `Completed`, `Error`, `Stopped` + +### ProxyConfiguration +`UseAppSettings`, `Override`, `NoProxy` ### QueueTaskStatus `Idle`, `Running`, `Completed`, `Failed`, `Retrying`, `Aborted`, `Incompatible` -### EventStatus -`Running`, `Completed`, `error`, `Stopped` - -### ContextType -`System`, `user`, `Anonymous`, `ScheduledEvent` +### UnreferencedFileState +`New`, `Obsolete`, `Deleted` ### UserType -`Internal`, `external` +`Internal`, `External` -### DeviceType -`Phone`, `Tablet`, `Desktop` +### WorkflowActivityExecutionState +`Created`, `InProgress`, `Completed`, `Paused`, `Aborted`, `Failed` ---- +### WorkflowActivityState +`Started`, `Suspended`, `Finished`, `Replaced`, `Aborted`, `Failed` + +### WorkflowActivityType +`Start`, `End`, `ExclusiveSplit`, `ParallelSplit`, `ParallelSplitBranchStopper`, `ParallelSplitMerge`, `UserTask`, `CallMicroflow`, `CallWorkflow`, `JumpTo`, `MultiInputUserTask`, `WaitForNotification`, `WaitForTimer`, `EndOfBoundaryEventPath`, `NonInterruptingTimerEvent`, `InterruptingTimerEvent` + +### WorkflowCurrentActivityAction +`DoNothing`, `JumpTo` + +### WorkflowEventType +`WorkflowCompleted`, `WorkflowInitiated`, `WorkflowRestarted`, `WorkflowFailed`, `WorkflowAborted`, `WorkflowPaused`, `WorkflowUnpaused`, `WorkflowRetried`, `WorkflowUpdated`, `WorkflowUpgraded`, `WorkflowConflicted`, `WorkflowResolved`, `WorkflowJumpToOptionApplied`, `StartEventExecuted`, `EndEventExecuted`, `DecisionExecuted`, `JumpExecuted`, `ParallelSplitExecuted`, `ParallelMergeExecuted`, `CallWorkflowStarted`, `CallWorkflowEnded`, `CallMicroflowStarted`, `CallMicroflowEnded`, `WaitForNotificationStarted`, `WaitForNotificationEnded`, `WaitForTimerStarted`, `WaitForTimerEnded`, `UserTaskStarted`, `MultiUserTaskOutcomeSelected`, `UserTaskEnded`, `NonInterruptingTimerEventExecuted`, `InterruptingTimerEventExecuted` + +### WorkflowState +`InProgress`, `Paused`, `Completed`, `Aborted`, `Incompatible`, `Failed` + +### WorkflowUserTaskCompletionType +`Single`, `Veto`, `Consensus`, `Majority`, `Threshold`, `Microflow` + +### WorkflowUserTaskState +`Created`, `InProgress`, `Completed`, `Paused`, `Aborted`, `Failed` ## 9. Inheritance Hierarchies diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index 74cc5e743f..da0f9d7e1c 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -369,13 +369,26 @@ name.** A task declared `user task "ReviewAndPlan" 'Review and plan'` stores `Name = 'Review and plan'`, so routing an inbox on the activity name silently never matches. Route on your own entity's status instead. -## System-module documents are read from the runtime, not the .mpr +## System-module enumerations are synthesized, not stored -`describe enumeration System.WorkflowUserTaskState` and `show enumerations in System` -return nothing — the System module's **enumerations** are not in the project file, so -mxcli cannot resolve them. Constrain on an attribute instead (`[EndTime = empty]` -selects open tasks) rather than naming a System enum value. System **entities** are -documented in `system-module`. +The System module's enumerations are **not in the project file** — Mendix ships +them with the platform — so mxcli synthesizes them from its own table of platform +definitions. `describe enumeration System.WorkflowUserTaskState` and +`show enumerations` report them, read-only: + +```bash +mxcli -p app.mpr describe enumeration System.WorkflowUserTaskState +``` + +They used to return nothing, which is why guessing a value and hitting **CE1613** +"The selected enumeration value no longer exists" was the only way to find out +(mendixlabs/mxcli#1102). Check the values before branching on one — they are +case-sensitive, and `WorkflowActivityState` (`Finished`) is a different +enumeration from `WorkflowActivityExecutionState` (`Completed`). + +Constraining on an attribute (`[EndTime = empty]` selects open tasks) is still +often the better XPath, but it is no longer a workaround for not knowing the +values. The full list and the System **entities** are in `system-module`. ## Platform rules diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index b96ed8a90e..f32ca52817 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -241,7 +241,7 @@ func init() { "caption", "show enumerations", "describe enumeration", "drop enumeration", }, - Syntax: "CREATE ENUMERATION Module.Name (\n ValueName 'Display Caption',\n ...\n);\n\nALTER ENUMERATION Module.Name ADD VALUE [IF NOT EXISTS] NewValue [CAPTION 'Display Caption'];\nALTER ENUMERATION Module.Name RENAME VALUE OldName TO NewName;\nALTER ENUMERATION Module.Name MODIFY VALUE ValueName CAPTION 'New Caption';\nALTER ENUMERATION Module.Name DROP VALUE [IF EXISTS] ValueName;\n\nIF NOT EXISTS / IF EXISTS make a script RE-RUNNABLE. Without them the second\nrun errors and exec STOPS THERE, so one already-present value leaves every\nlater statement unapplied. A defensive drop-then-add is not a substitute: the\ndrop fails when the value is absent and the add when it is present.\n\nSHOW ENUMERATIONS;\nSHOW ENUMERATIONS IN ;\nDESCRIBE ENUMERATION Module.Name;\nDROP ENUMERATION Module.Name;\n\nUsing in entity:\n AttrName: Enumeration(Module.EnumName)", + Syntax: "CREATE ENUMERATION Module.Name (\n ValueName 'Display Caption',\n ...\n);\n\nALTER ENUMERATION Module.Name ADD VALUE [IF NOT EXISTS] NewValue [CAPTION 'Display Caption'];\nALTER ENUMERATION Module.Name RENAME VALUE OldName TO NewName;\nALTER ENUMERATION Module.Name MODIFY VALUE ValueName CAPTION 'New Caption';\nALTER ENUMERATION Module.Name DROP VALUE [IF EXISTS] ValueName;\n\nIF NOT EXISTS / IF EXISTS make a script RE-RUNNABLE. Without them the second\nrun errors and exec STOPS THERE, so one already-present value leaves every\nlater statement unapplied. A defensive drop-then-add is not a substitute: the\ndrop fails when the value is absent and the add when it is present.\n\nSHOW ENUMERATIONS;\nSHOW ENUMERATIONS IN ;\nDESCRIBE ENUMERATION Module.Name;\nDROP ENUMERATION Module.Name;\n\nUsing in entity:\n AttrName: Enumeration(Module.EnumName)\n\nThe System module's enumerations are platform built-ins with no stored\nunit. SHOW ENUMERATIONS and DESCRIBE ENUMERATION report them anyway, so\ntheir values can be read instead of guessed at until the build rejects one\nwith CE1613. They are READ-ONLY: DESCRIBE prints them as -- comment lines,\nand CREATE / ALTER / DROP / MOVE naming System is refused.\n mxcli -p app.mpr describe enumeration System.WorkflowActivityType", Example: "CREATE ENUMERATION MyModule.OrderStatus (\n Pending 'Pending Approval',\n Processing 'Being Processed',\n Shipped 'Shipped to Customer'\n);\n\n-- Using in an entity\nCREATE PERSISTENT ENTITY MyModule.Order (\n OrderNumber: String(20) NOT NULL,\n Status: Enumeration(MyModule.OrderStatus)\n);", SeeAlso: []string{"domain-model.enumeration", "domain-model.entity.attributes"}, }) diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 7fee3b4567..9353d76da4 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -68,14 +68,14 @@ create persistent entity Module.Photo ( | Create external entities | `create [or modify] external entities from Module.Client [into module] [entities (...)];` | Bulk from $metadata | | Drop entity | `drop entity Module.Name;` | | | Describe entity | `describe entity Module.Name;` | Full MDL output | -| Describe enumeration | `describe enumeration Module.Name;` | Full MDL output | +| Describe enumeration | `describe enumeration Module.Name;` | Full MDL output. **`System.*` enumerations are included** — they are platform built-ins with no stored unit, synthesized so their values are discoverable instead of guessed at until **CE1613**. They are read-only: `describe` prints them as `--` comment lines, and `create`/`alter`/`drop`/`move` naming the System module is refused | | Rename entity | `rename entity Module.Old to New;` | Updates all references | | Rename enumeration | `rename enumeration Module.Old to New;` | Updates attribute type refs | | Rename association | `rename association Module.Old to New;` | Updates all references | | Show entities | `show entities [in module];` | List all or filter by module | | Create enumeration | `create [or modify] enumeration Module.Name (Value1 'caption', ...);` | | | Alter enumeration values | `alter enumeration Module.Name add value [if not exists] X [caption '..'] \| rename value X to Y \| modify value X caption '..' \| drop value [if exists] X;` | `modify value … caption` re-captions in place (works while referenced). `if not exists` / `if exists` make the script re-runnable — the bare forms error and stop the run | -| Drop enumeration | `drop enumeration Module.Name;` | | +| Drop enumeration | `drop enumeration Module.Name;` | Refused for `System.*` (read-only platform module) | | Create association | `create [or modify] association Module.Name from Parent to Child type reference\|ReferenceSet [owner default\|both] [delete_behavior ...];` | OR MODIFY updates existing association in-place. **The FROM entity must live in `Module`** — Mendix stores an association in its FROM entity's module, so a remote FROM writes a dangling pointer and the project stops OPENING (**MDL070**). The TO entity may be remote; that direction is stored BY NAME | | Drop association | `drop association Module.Name;` | | | Association line anchors | `@anchor(from: (0, 54), to: (100, 54))` above `create association …` | Where the connector attaches to each entity box, as a **percentage** of the box (0..100, whole numbers). `from` = the FROM entity's box, `to` = the TO entity's. Omitting an end preserves what is stored, so a `create or modify` about something else never flattens a hand-tuned line. Cross-module associations have no anchors — Mendix stores none | diff --git a/mdl-examples/bug-tests/1102-system-enumerations-readable.mdl b/mdl-examples/bug-tests/1102-system-enumerations-readable.mdl new file mode 100644 index 0000000000..c5ce92fce5 --- /dev/null +++ b/mdl-examples/bug-tests/1102-system-enumerations-readable.mdl @@ -0,0 +1,44 @@ +-- mendixlabs/mxcli#1102 — System enumerations are invisible to show/describe/search +-- +-- `describe entity` printed attributes typed against System enumerations while +-- the enumerations themselves could not be inspected through any command, so +-- their values could only be guessed at until the build rejected a guess with +-- CE1613 "The selected enumeration value no longer exists". +-- +-- $ mxcli -p app.mpr describe enumeration System.WorkflowActivityType +-- Error: enumeration not found: System.WorkflowActivityType +-- +-- Cause: modelsdk/meta.SystemEnumerations (15 enumerations, values and all) had +-- no consumer. The System module is not stored in the .mpr — its entities, +-- associations and Java actions are each synthesized and appended to a listing; +-- the enumeration half had the data table and no wiring. +-- +-- Run against any project (the System module is present in all of them): +-- mxcli exec mdl-examples/bug-tests/1102-system-enumerations-readable.mdl -p app.mpr + +-- The statement from the report. Before the fix: "enumeration not found". +DESCRIBE ENUMERATION System.WorkflowActivityType; + +-- The reporter's CE1613 was `System.WorkflowActivityExecutionState.Finished`. +-- Finished is real — but on System.WorkflowActivityState, a different +-- enumeration with a confusingly similar name. These two statements are what +-- makes that mistake obvious in one step instead of one build. +DESCRIBE ENUMERATION System.WorkflowActivityExecutionState; +DESCRIBE ENUMERATION System.WorkflowActivityState; + +-- Both of these are read-only: System enumerations describe as `--` comment +-- lines rather than as `create or modify …`, because every enumeration write +-- naming the System module is refused. The refusals are covered by unit tests +-- (mdl/executor/cmd_enumerations_system_test.go), not here: this file is checked +-- without a project, and a guard that needs a model cannot decide anything at +-- check time. Before the guard, the first of these REPORTED SUCCESS and wrote a +-- unit whose parent does not exist: +-- +-- CREATE ENUMERATION System.BrandNewThing (A 'a'); -- orphaned unit +-- ALTER ENUMERATION System.WorkflowActivityType ADD VALUE Invented CAPTION 'x'; +-- DROP ENUMERATION System.WorkflowActivityType; + +-- System enumerations are also listed now, so an attribute typed against one +-- resolves under `mxcli check --references` instead of being reported missing +-- (the #1071 direction of the same gap). +SHOW ENUMERATIONS; diff --git a/mdl/backend/modelsdk/enumeration.go b/mdl/backend/modelsdk/enumeration.go index 6b83034350..a6206e33b5 100644 --- a/mdl/backend/modelsdk/enumeration.go +++ b/mdl/backend/modelsdk/enumeration.go @@ -4,6 +4,7 @@ package modelsdkbackend import ( genEnum "github.com/mendixlabs/mxcli/modelsdk/gen/enumerations" + "github.com/mendixlabs/mxcli/modelsdk/meta" "github.com/mendixlabs/mxcli/modelsdk/mprread" "github.com/mendixlabs/mxcli/model" @@ -18,21 +19,30 @@ func (b *Backend) ListEnumerations() ([]*model.Enumeration, error) { if err != nil { return nil, err } - out := make([]*model.Enumeration, 0, len(units)) + out := make([]*model.Enumeration, 0, len(units)+len(meta.SystemEnumerations)) for _, u := range units { out = append(out, enumToModel(u.Element, u.ContainerID)) } + // The System module's enumerations are platform built-ins with no stored + // unit, so they have to be synthesized or they vanish — same reason as + // ListJavaActions. Without them `describe enumeration System.X` reports + // "not found" and `check --references` rejects an attribute typed against + // one, while `describe entity` prints that very type (#1102). + out = append(out, meta.BuildSystemEnumerations()...) return out, nil } func (b *Backend) GetEnumeration(id model.ID) (*model.Enumeration, error) { - units, err := mprread.ListUnitsWithContainer[*genEnum.Enumeration](b.reader) + // Resolved against the same set ListEnumerations returns, so a caller + // holding an ID from the listing can always look it up again — the System + // module's synthesized enumerations included. + enums, err := b.ListEnumerations() if err != nil { return nil, err } - for _, u := range units { - if model.ID(u.Element.ID()) == id { - return enumToModel(u.Element, u.ContainerID), nil + for _, e := range enums { + if e.ID == id { + return e, nil } } return nil, nil diff --git a/mdl/backend/modelsdk/enumeration_system_test.go b/mdl/backend/modelsdk/enumeration_system_test.go new file mode 100644 index 0000000000..a22c31a027 --- /dev/null +++ b/mdl/backend/modelsdk/enumeration_system_test.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/modelsdk/meta" +) + +// The System module is not stored in the project — its enumerations exist only +// in modelsdk/meta — so a reader that decodes stored units alone reports them as +// absent. That is what made `describe enumeration System.WorkflowActivityType` +// fail, `show enumerations` omit them, and `check --references` reject a valid +// attribute typed against one (mendixlabs/mxcli#1102). Same shape as the System +// Java actions synthesized in java.go. + +// valuesOf returns an enumeration's value names. +func valuesOf(e *model.Enumeration) []string { + out := make([]string, 0, len(e.Values)) + for _, v := range e.Values { + out = append(out, v.Name) + } + return out +} + +func hasValue(e *model.Enumeration, name string) bool { + for _, v := range e.Values { + if v.Name == name { + return true + } + } + return false +} + +// findSystemEnum returns the synthesized System enumeration of that local name. +func findSystemEnum(enums []*model.Enumeration, name string) *model.Enumeration { + for _, e := range enums { + if e.Name == name && string(e.ContainerID) == meta.SystemModuleID { + return e + } + } + return nil +} + +func TestListEnumerations_IncludesSystemModule(t *testing.T) { + b := New() + if err := b.Connect(fixture); err != nil { + t.Fatalf("Connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + + enums, err := b.ListEnumerations() + if err != nil { + t.Fatalf("ListEnumerations: %v", err) + } + + // The control: the STORED enumerations must still come back. Without it this + // passes against a build that returned the synthesized ones only. + stored := 0 + for _, e := range enums { + if string(e.ContainerID) != meta.SystemModuleID { + stored++ + } + } + if stored != 7 { + t.Errorf("stored enumerations = %d, want 7 (the fixture's own)", stored) + } + + if got, want := len(enums)-stored, len(meta.SystemEnumerations); got != want { + t.Errorf("synthesized System enumerations = %d, want %d", got, want) + } + + activityType := findSystemEnum(enums, "WorkflowActivityType") + if activityType == nil { + t.Fatal("System.WorkflowActivityType not returned by ListEnumerations") + } + if !hasValue(activityType, "UserTask") { + t.Errorf("System.WorkflowActivityType values = %v, want UserTask among them", valuesOf(activityType)) + } + + // The reporter's CE1613 was `System.WorkflowActivityExecutionState.Finished`. + // Finished is real, but on System.WorkflowActivityState — this one has + // Completed. Pinning both directions is what makes the listing answer the + // question that was actually being asked. + execState := findSystemEnum(enums, "WorkflowActivityExecutionState") + if execState == nil { + t.Fatal("System.WorkflowActivityExecutionState not returned by ListEnumerations") + } + if hasValue(execState, "Finished") { + t.Error("System.WorkflowActivityExecutionState must not carry Finished (that is WorkflowActivityState)") + } + if !hasValue(execState, "Completed") { + t.Errorf("System.WorkflowActivityExecutionState values = %v, want Completed", valuesOf(execState)) + } + if activityState := findSystemEnum(enums, "WorkflowActivityState"); activityState == nil { + t.Error("System.WorkflowActivityState not returned by ListEnumerations") + } else if !hasValue(activityState, "Finished") { + t.Errorf("System.WorkflowActivityState values = %v, want Finished", valuesOf(activityState)) + } +} + +// TestGetEnumeration_FindsSystemModule keeps the by-ID lookup consistent with +// the listing: a caller holding an ID from ListEnumerations must be able to +// resolve it again. +func TestGetEnumeration_FindsSystemModule(t *testing.T) { + b := New() + if err := b.Connect(fixture); err != nil { + t.Fatalf("Connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + + enums, err := b.ListEnumerations() + if err != nil { + t.Fatalf("ListEnumerations: %v", err) + } + want := findSystemEnum(enums, "WorkflowActivityType") + if want == nil { + t.Fatal("System.WorkflowActivityType not in ListEnumerations") + } + + got, err := b.GetEnumeration(want.ID) + if err != nil { + t.Fatalf("GetEnumeration(%q): %v", want.ID, err) + } + if got == nil { + t.Fatalf("GetEnumeration(%q) = nil — the listing offers an ID the getter cannot resolve", want.ID) + } + if got.Name != "WorkflowActivityType" { + t.Errorf("GetEnumeration returned %q, want WorkflowActivityType", got.Name) + } + + // Control: a stored enumeration still resolves by ID. + for _, e := range enums { + if string(e.ContainerID) == meta.SystemModuleID { + continue + } + stored, err := b.GetEnumeration(e.ID) + if err != nil || stored == nil { + t.Fatalf("GetEnumeration(%q) for stored %s: %v / nil=%v", e.ID, e.Name, err, stored == nil) + } + break + } +} diff --git a/mdl/backend/modelsdk/enumeration_test.go b/mdl/backend/modelsdk/enumeration_test.go index 9b9ea7b2a0..928bbee049 100644 --- a/mdl/backend/modelsdk/enumeration_test.go +++ b/mdl/backend/modelsdk/enumeration_test.go @@ -2,11 +2,21 @@ package modelsdkbackend -import "testing" +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/modelsdk/meta" +) // TestReadSlice_Enumerations checks the enum adapter: values are converted (for // the Values count) and captions decode via textElementToModel. SHOW // ENUMERATIONS is cross-checked byte-for-byte against legacy in the plan. +// +// The count is of STORED enumerations. ListEnumerations also returns the System +// module's synthesized ones (#1102), which have no stored unit and no caption to +// decode, so counting the whole listing here would stop testing the adapter and +// start tracking the size of a hardcoded table. func TestReadSlice_Enumerations(t *testing.T) { b := New() if err := b.Connect(fixture); err != nil { @@ -14,12 +24,18 @@ func TestReadSlice_Enumerations(t *testing.T) { } t.Cleanup(func() { _ = b.Disconnect() }) - enums, err := b.ListEnumerations() + all, err := b.ListEnumerations() if err != nil { t.Fatalf("ListEnumerations: %v", err) } + var enums []*model.Enumeration + for _, e := range all { + if string(e.ContainerID) != meta.SystemModuleID { + enums = append(enums, e) + } + } if len(enums) != 7 { - t.Fatalf("ListEnumerations count = %d, want 7", len(enums)) + t.Fatalf("stored enumeration count = %d, want 7", len(enums)) } for _, e := range enums { if e.Name == "Filter_Operators" { diff --git a/mdl/executor/cmd_enumerations.go b/mdl/executor/cmd_enumerations.go index 683b5968c3..0542ea51b0 100644 --- a/mdl/executor/cmd_enumerations.go +++ b/mdl/executor/cmd_enumerations.go @@ -15,6 +15,38 @@ import ( "github.com/mendixlabs/mxcli/model" ) +// refuseSystemEnumerationWrite rejects any write that names the System module. +// +// The System module's enumerations are platform built-ins that the backend +// SYNTHESIZES so they can be read (#1102) — there is no stored unit for the +// module, so there is nothing for a write to live in. Before this guard the four +// write verbs did not fail cleanly: CREATE ENUMERATION System.X reported +// "Created enumeration: System.X" and wrote a unit whose ContainerID was the +// synthetic module ID 00000000-…-0001, which is not a unit in the project — an +// orphan with a dangling parent, on disk, with no error. The others surfaced a +// raw .mxunit path instead. +// +// The signal is the module NAME, not the container: a brand-new enumeration has +// no container yet, and that is precisely the case that used to corrupt (the +// module lookup resolves "System" to the virtual module and hands its synthetic +// ID over as the parent). Unlike the Marketplace guard, which deliberately does +// not trust a name because a user may name a module Atlas_Core, "System" is +// reserved by the platform and cannot be a user module. +func refuseSystemEnumerationWrite(verb string, name ast.QualifiedName) error { + if name.Module != "System" { + return nil + } + // The hint must not name the statement's own enumeration: on a CREATE that + // name usually does not exist, and telling the reader to describe it would + // send them after nothing. + return mdlerrors.NewValidation(fmt.Sprintf( + "cannot %s %s: the System module is owned by the Mendix platform and is read-only — "+ + "its enumerations are built in, not stored in the project. "+ + "Define your own enumeration in one of your modules; "+ + "`show enumerations` lists the built-in ones and `describe enumeration System.` reports their values.", + verb, name.String())) +} + // execCreateEnumeration handles CREATE ENUMERATION statements. func execCreateEnumeration(ctx *ExecContext, s *ast.CreateEnumerationStmt) error { @@ -22,6 +54,14 @@ func execCreateEnumeration(ctx *ExecContext, s *ast.CreateEnumerationStmt) error return mdlerrors.NewNotConnected() } + verb := "create enumeration" + if s.CreateOrModify { + verb = "create or modify enumeration" + } + if err := refuseSystemEnumerationWrite(verb, s.Name); err != nil { + return err + } + // Validate enumeration values for reserved words if violations := ValidateEnumeration(s); len(violations) > 0 { var msgs []string @@ -145,6 +185,9 @@ func findEnumeration(ctx *ExecContext, moduleName, enumName string) *model.Enume // execAlterEnumeration handles ALTER ENUMERATION ADD/DROP/RENAME VALUE by // read-modify-writing the enumeration through the backend (engine-agnostic). func execAlterEnumeration(ctx *ExecContext, s *ast.AlterEnumerationStmt) error { + if err := refuseSystemEnumerationWrite("alter enumeration", s.Name); err != nil { + return err + } enum := findEnumeration(ctx, s.Name.Module, s.Name.Name) if enum == nil { return mdlerrors.NewNotFound("enumeration", s.Name.String()) @@ -240,6 +283,10 @@ func execDropEnumeration(ctx *ExecContext, s *ast.DropEnumerationStmt) error { return mdlerrors.NewNotConnected() } + if err := refuseSystemEnumerationWrite("drop enumeration", s.Name); err != nil { + return err + } + // Find enumeration enums, err := ctx.Backend.ListEnumerations() if err != nil { @@ -367,6 +414,21 @@ func describeEnumeration(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprintf(ctx.Output, "/**\n * %s\n */\n", enum.Documentation) } + // A System enumeration is a platform built-in: the write paths refuse + // it, so emitting `create or modify …` would hand the reader a + // statement mxcli rejects. Report it informationally instead, the way + // DESCRIBE BUILDING BLOCK does for the other read-only doctype. + if modName == "System" { + fmt.Fprintf(ctx.Output, "-- Enumeration: %s.%s\n", modName, enum.Name) + fmt.Fprintf(ctx.Output, "-- Values (%d):\n", len(enum.Values)) + for _, v := range enum.Values { + fmt.Fprintf(ctx.Output, "-- %s\n", v.Name) + } + fmt.Fprintf(ctx.Output, "-- The System module is owned by the Mendix platform: this enumeration is\n") + fmt.Fprintf(ctx.Output, "-- built in and read-only, so this output is informational, not re-executable.\n") + return nil + } + fmt.Fprintf(ctx.Output, "create or modify enumeration %s.%s (\n", modName, enum.Name) for i, v := range enum.Values { comma := "," diff --git a/mdl/executor/cmd_enumerations_system_test.go b/mdl/executor/cmd_enumerations_system_test.go new file mode 100644 index 0000000000..ab0128c81b --- /dev/null +++ b/mdl/executor/cmd_enumerations_system_test.go @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/modelsdk/meta" +) + +// The System module's enumerations are platform built-ins with no stored unit: +// the backend synthesizes them so they can be READ (mendixlabs/mxcli#1102). +// Making them visible also makes them addressable by the write paths, and the +// System module has no stored unit to contain anything — so every write has to +// be refused at the statement, not discovered as a disk error underneath. +// +// On the enumeration path the pre-fix behaviour was worse than a bad message: +// `CREATE ENUMERATION System.BrandNewThing` REPORTED SUCCESS and wrote a unit +// whose ContainerID was the synthetic module ID 00000000-…-0001, which is not a +// unit in the project — an orphan with a dangling parent. (Measured on the +// expr-checker fixture: 369 → 370 units, container present in no Unit row.) +// Entities happen to fail safe because they need the virtual domain-model unit +// loaded first; enumerations are units in their own right, so nothing stopped +// them. + +// systemEnumCtx builds a context whose backend exposes a user module plus the +// virtual System module and one synthesized System enumeration, and records +// whether any write reached the backend. +func systemEnumCtx(t *testing.T) (*ExecContext, *[]string) { + t.Helper() + user := mkModule("Sales") + system := &model.Module{ + BaseElement: model.BaseElement{ID: model.ID(meta.SystemModuleID)}, + Name: "System", + } + + userEnum := mkEnumeration(user.ID, "OrderStatus", "Draft", "Shipped") + sysEnum := mkEnumeration(system.ID, "WorkflowActivityType", "UserTask", "CallMicroflow") + + h := mkHierarchy(user, system) + withContainer(h, userEnum.ContainerID, user.ID) + withContainer(h, sysEnum.ContainerID, system.ID) + + var writes []string + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { + return []*model.Module{user, system}, nil + }, + ListEnumerationsFunc: func() ([]*model.Enumeration, error) { + return []*model.Enumeration{userEnum, sysEnum}, nil + }, + CreateEnumerationFunc: func(e *model.Enumeration) error { + writes = append(writes, "create:"+e.Name) + return nil + }, + UpdateEnumerationFunc: func(e *model.Enumeration) error { + writes = append(writes, "update:"+e.Name) + return nil + }, + DeleteEnumerationFunc: func(id model.ID) error { + writes = append(writes, "delete:"+string(id)) + return nil + }, + MoveEnumerationFunc: func(e *model.Enumeration) error { + writes = append(writes, "move:"+e.Name) + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + return ctx, &writes +} + +func sysQN(name string) ast.QualifiedName { + return ast.QualifiedName{Module: "System", Name: name} +} + +// TestDescribeEnumeration_System is the symptom from the issue: the values have +// to be reportable, because nothing else in mxcli can tell you what they are. +func TestDescribeEnumeration_System(t *testing.T) { + ctx, _ := systemEnumCtx(t) + var out strings.Builder + ctx.Output = &out + + if err := describeEnumeration(ctx, sysQN("WorkflowActivityType")); err != nil { + t.Fatalf("describe enumeration System.WorkflowActivityType: %v", err) + } + got := out.String() + for _, want := range []string{"System.WorkflowActivityType", "UserTask", "CallMicroflow"} { + if !strings.Contains(got, want) { + t.Errorf("describe output missing %q:\n%s", want, got) + } + } + // The write paths refuse System, so DESCRIBE must not emit a statement that + // mxcli would reject if pasted back — a describe → exec round trip that + // cannot work is worse than one that is plainly marked read-only. + if strings.Contains(got, "create or modify enumeration") { + t.Errorf("describe emits a CREATE statement for a read-only System enumeration:\n%s", got) + } + if !strings.Contains(got, "read-only") { + t.Errorf("describe output does not say the enumeration is read-only:\n%s", got) + } +} + +// TestDescribeEnumeration_UserStillRoundTrips is the control for the branch +// above: an ordinary enumeration must still describe as re-executable MDL. +func TestDescribeEnumeration_UserStillRoundTrips(t *testing.T) { + ctx, _ := systemEnumCtx(t) + var out strings.Builder + ctx.Output = &out + + if err := describeEnumeration(ctx, ast.QualifiedName{Module: "Sales", Name: "OrderStatus"}); err != nil { + t.Fatalf("describe enumeration Sales.OrderStatus: %v", err) + } + got := out.String() + if !strings.Contains(got, "create or modify enumeration Sales.OrderStatus") { + t.Errorf("user enumeration no longer describes as re-executable MDL:\n%s", got) + } +} + +// TestSystemEnumerationWrites_AreRefused covers all four write verbs. Each one +// must refuse BEFORE the backend is touched — the assertion on `writes` is the +// point, since a refusal that still wrote would leave the orphan behind. +func TestSystemEnumerationWrites_AreRefused(t *testing.T) { + cases := []struct { + name string + run func(ctx *ExecContext) error + }{ + {"create", func(ctx *ExecContext) error { + return execCreateEnumeration(ctx, &ast.CreateEnumerationStmt{ + Name: sysQN("BrandNewThing"), + Values: []ast.EnumValue{{Name: "A", Caption: "a"}}, + }) + }}, + {"create or modify", func(ctx *ExecContext) error { + return execCreateEnumeration(ctx, &ast.CreateEnumerationStmt{ + Name: sysQN("WorkflowActivityType"), + CreateOrModify: true, + Values: []ast.EnumValue{{Name: "A", Caption: "a"}}, + }) + }}, + {"alter add value", func(ctx *ExecContext) error { + return execAlterEnumeration(ctx, &ast.AlterEnumerationStmt{ + Name: sysQN("WorkflowActivityType"), + Operation: ast.AlterEnumAdd, + ValueName: "Invented", + Caption: "Invented", + }) + }}, + {"drop", func(ctx *ExecContext) error { + return execDropEnumeration(ctx, &ast.DropEnumerationStmt{ + Name: sysQN("WorkflowActivityType"), + }) + }}, + {"move out", func(ctx *ExecContext) error { + return moveEnumeration(ctx, sysQN("WorkflowActivityType"), model.ID("mod-sales"), "Sales") + }}, + {"rename", func(ctx *ExecContext) error { + return execRenameEnumeration(ctx, &ast.RenameStmt{ + Name: sysQN("WorkflowActivityType"), + NewName: "Renamed", + }) + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, writes := systemEnumCtx(t) + err := tc.run(ctx) + if err == nil { + t.Fatalf("%s on a System enumeration was accepted, want a refusal", tc.name) + } + msg := err.Error() + if !strings.Contains(msg, "System") { + t.Errorf("refusal does not name the System module: %q", msg) + } + // The pre-fix failures were a success message (create) or a raw + // mxunit path (the rest). Neither is an explanation. + if strings.Contains(msg, ".mxunit") || strings.Contains(msg, "no such file") { + t.Errorf("refusal leaks a storage error instead of explaining: %q", msg) + } + if len(*writes) != 0 { + t.Errorf("refusal still reached the backend: %v", *writes) + } + }) + } +} + +// TestMoveEnumerationIntoSystem_IsRefused covers the other end of MOVE: the +// enumeration being moved is an ordinary one, but the destination is System. +// Guarding only the source would let a user enumeration be moved INTO a module +// with no stored unit, which is the same orphan by another route. +func TestMoveEnumerationIntoSystem_IsRefused(t *testing.T) { + ctx, writes := systemEnumCtx(t) + err := moveEnumeration(ctx, + ast.QualifiedName{Module: "Sales", Name: "OrderStatus"}, + model.ID(meta.SystemModuleID), "System") + if err == nil { + t.Fatal("moving a user enumeration into System was accepted, want a refusal") + } + if !strings.Contains(err.Error(), "System") { + t.Errorf("refusal does not name the System module: %q", err) + } + if len(*writes) != 0 { + t.Errorf("refusal still reached the backend: %v", *writes) + } +} + +// TestUserEnumerationWrites_StillWork is the control. Without it the guard could +// be refusing every enumeration write and every test above would still pass. +func TestUserEnumerationWrites_StillWork(t *testing.T) { + userQN := ast.QualifiedName{Module: "Sales", Name: "OrderStatus"} + + t.Run("create or modify", func(t *testing.T) { + ctx, writes := systemEnumCtx(t) + if err := execCreateEnumeration(ctx, &ast.CreateEnumerationStmt{ + Name: userQN, + CreateOrModify: true, + Values: []ast.EnumValue{{Name: "Draft", Caption: "Draft"}}, + }); err != nil { + t.Fatalf("create or modify on a user enumeration: %v", err) + } + if len(*writes) == 0 { + t.Error("user enumeration write did not reach the backend") + } + }) + + t.Run("alter add value", func(t *testing.T) { + ctx, writes := systemEnumCtx(t) + if err := execAlterEnumeration(ctx, &ast.AlterEnumerationStmt{ + Name: userQN, + Operation: ast.AlterEnumAdd, + ValueName: "Cancelled", + Caption: "Cancelled", + }); err != nil { + t.Fatalf("alter on a user enumeration: %v", err) + } + if len(*writes) == 0 { + t.Error("user enumeration alter did not reach the backend") + } + }) + + t.Run("drop", func(t *testing.T) { + ctx, writes := systemEnumCtx(t) + if err := execDropEnumeration(ctx, &ast.DropEnumerationStmt{Name: userQN}); err != nil { + t.Fatalf("drop on a user enumeration: %v", err) + } + if len(*writes) == 0 { + t.Error("user enumeration drop did not reach the backend") + } + }) +} + +// TestDropModuleSystem_IsRefused: DROP MODULE cascades over the module's +// documents, and the System module's are all synthesized. Before the refusal it +// reported "unit not found" once per document — 15 warnings and no change — which +// is noise the enumeration fix would otherwise have introduced (#1102). +func TestDropModuleSystem_IsRefused(t *testing.T) { + ctx, writes := systemEnumCtx(t) + err := execDropModule(ctx, &ast.DropModuleStmt{Name: "System"}) + if err == nil { + t.Fatal("DROP MODULE System was accepted, want a refusal") + } + if !strings.Contains(err.Error(), "System") { + t.Errorf("refusal does not name the module: %q", err) + } + if len(*writes) != 0 { + t.Errorf("refusal still reached the backend: %v", *writes) + } +} diff --git a/mdl/executor/cmd_modules.go b/mdl/executor/cmd_modules.go index 99cb3e454e..f84de602d9 100644 --- a/mdl/executor/cmd_modules.go +++ b/mdl/executor/cmd_modules.go @@ -85,6 +85,18 @@ func execDropModule(ctx *ExecContext, s *ast.DropModuleStmt) error { return mdlerrors.NewNotFound("module", s.Name) } + // The System module is virtual: it is synthesized from modelsdk/meta, not + // stored, so there is nothing here to drop. The cascade below would walk its + // synthesized documents and report a "unit not found" warning for each one + // (15 of them once the enumerations became visible — #1102) while changing + // nothing. Refusing says that in one line instead. + if targetModule.Name == "System" { + return mdlerrors.NewValidation( + "cannot drop module System: it is owned by the Mendix platform and is not stored in the " + + "project — its entities, associations, enumerations and Java actions are built in. " + + "Every Mendix app has it and no app can remove it.") + } + // Build set of all container IDs belonging to this module (including nested folders) moduleContainers := getModuleContainers(ctx, targetModule.ID) diff --git a/mdl/executor/cmd_move.go b/mdl/executor/cmd_move.go index 99ab6458e9..57c970799e 100644 --- a/mdl/executor/cmd_move.go +++ b/mdl/executor/cmd_move.go @@ -357,6 +357,15 @@ func moveEntity(ctx *ExecContext, name ast.QualifiedName, sourceModule, targetMo // moveEnumeration moves an enumeration to a new container. // For cross-module moves, updates all EnumerationAttributeType references across all domain models. func moveEnumeration(ctx *ExecContext, name ast.QualifiedName, targetContainerID model.ID, targetModuleName string) error { + // Neither end may be System: its enumerations are platform built-ins with no + // stored unit, so there is nothing to move out and nowhere to move in (#1102). + if err := refuseSystemEnumerationWrite("move enumeration", name); err != nil { + return err + } + if targetModuleName == "System" { + return refuseSystemEnumerationWrite("move enumeration into", + ast.QualifiedName{Module: "System", Name: name.Name}) + } enum := findEnumeration(ctx, name.Module, name.Name) if enum == nil { return mdlerrors.NewNotFound("enumeration", name.String()) diff --git a/mdl/executor/cmd_rename.go b/mdl/executor/cmd_rename.go index bdb6e774ba..863e9b32d4 100644 --- a/mdl/executor/cmd_rename.go +++ b/mdl/executor/cmd_rename.go @@ -277,6 +277,11 @@ func execRenameDocument(ctx *ExecContext, s *ast.RenameStmt, docType string) err // execRenameEnumeration renames an enumeration and updates all references. func execRenameEnumeration(ctx *ExecContext, s *ast.RenameStmt) error { + // Platform built-in, no stored unit to rename (#1102). + if err := refuseSystemEnumerationWrite("rename enumeration", s.Name); err != nil { + return err + } + oldQualifiedName := s.Name.Module + "." + s.Name.Name newQualifiedName := s.Name.Module + "." + s.NewName diff --git a/modelsdk/meta/system_enumerations.go b/modelsdk/meta/system_enumerations.go new file mode 100644 index 0000000000..896a7aa26c --- /dev/null +++ b/modelsdk/meta/system_enumerations.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package meta + +// The System module's built-in enumerations, as model elements. +// +// Like the entities, associations and Java actions beside them, these are NOT +// stored in the .mpr — Mendix ships them with the platform — so a reader that +// only decodes stored units reports them as absent. The definitions in +// SystemEnumerations had been here since #889 with no consumer at all, which is +// why `describe entity` could print `ActivityType: Enumeration(System. +// WorkflowActivityType)` while `describe enumeration System.WorkflowActivityType` +// answered "enumeration not found": the entity half was wired and the +// enumeration half never was (mendixlabs/mxcli#1102). +// +// Read-only. The System module has no stored unit to contain anything, so the +// executor refuses every write that names it — see refuseSystemEnumerationWrite +// in mdl/executor/cmd_enumerations.go, which exists because making these +// visible also makes them addressable. + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" +) + +// BuildSystemEnumerations returns the System module's enumerations as semantic +// model elements, ready to append to a backend's enumeration listing. +// +// IDs are deterministic, so two readers describing the same project agree about +// an enumeration's identity even though neither read it from storage, and the +// catalog (which keys enumerations_data on Id) stays stable run to run. Mirrors +// BuildSystemJavaActions. +// +// Captions are deliberately left unset: SystemEnumerations carries value NAMES +// only, and Mendix's own captions for these are not recorded here. Defaulting a +// caption to the value name would put invented text in DESCRIBE output and in +// the catalog's translation rows, where nothing could tell it from a caption a +// developer actually wrote. The cost is that `search` — which indexes captions — +// still does not match these; that needs real caption data, not a guess. +func BuildSystemEnumerations() []*model.Enumeration { + out := make([]*model.Enumeration, 0, len(SystemEnumerations)) + for _, def := range SystemEnumerations { + e := &model.Enumeration{ + ContainerID: model.ID(SystemModuleID), + // The model carries the LOCAL name; the module comes from + // ContainerID, exactly as it does for a stored enumeration. Keeping + // the qualified name here would make DESCRIBE emit + // "System.System.WorkflowActivityType". + Name: strings.TrimPrefix(def.Name, "System."), + } + e.ID = model.ID(types.GenerateDeterministicID(def.Name)) + e.TypeName = "Enumerations$Enumeration" + for _, v := range def.Values { + ev := model.EnumerationValue{Name: v} + ev.ID = model.ID(types.GenerateDeterministicID(def.Name + "." + v)) + ev.TypeName = "Enumerations$EnumerationValue" + e.Values = append(e.Values, ev) + } + out = append(out, e) + } + return out +} diff --git a/modelsdk/meta/system_enumerations_test.go b/modelsdk/meta/system_enumerations_test.go new file mode 100644 index 0000000000..c84ab5de1a --- /dev/null +++ b/modelsdk/meta/system_enumerations_test.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 + +package meta + +import ( + "os" + "slices" + "strings" + "testing" +) + +// The System module's enumerations were defined in SystemEnumerations and then +// never exposed: `describe enumeration System.WorkflowActivityType` reported +// "enumeration not found" while `describe entity` happily printed attributes +// typed against it, so the values could only be guessed at until the build +// rejected one with CE1613 (mendixlabs/mxcli#1102). +// +// The entity and Java-action halves of the virtual System module each have a +// Build* helper; this is the enumeration one, plus the resolvability guard that +// the entity half has had all along (TestModelerSystemEntities_ +// HaveResolvableGeneralizations) and this half did not. + +// TestModelerSystemEntities_HaveResolvableEnumerations is the sibling of +// TestModelerSystemEntities_HaveResolvableGeneralizations: an attribute typed +// against a System enumeration mxcli cannot produce is an attribute whose valid +// values nothing can report. It passes on today's table — the 15 definitions +// cover all 14 enumerations the modeler-view entities reference — and exists so +// that adding an enum-typed System attribute without its enumeration fails here +// rather than at a user's build. +func TestModelerSystemEntities_HaveResolvableEnumerations(t *testing.T) { + known := make(map[string]bool, len(SystemEnumerations)) + for _, e := range SystemEnumerations { + known[e.Name] = true + } + for _, ent := range ModelerSystemEntities() { + for _, a := range ent.Attributes { + if a.Type != "Enumeration" { + continue + } + if a.EnumQN == "" { + t.Errorf("System.%s.%s is an Enumeration with no EnumQN", ent.Name, a.Name) + continue + } + if !known[a.EnumQN] { + t.Errorf("System.%s.%s references %s, which is not in SystemEnumerations — "+ + "describe enumeration %s will report it missing", + ent.Name, a.Name, a.EnumQN, a.EnumQN) + } + } + } +} + +// TestBuildSystemEnumerations_CoversTheTable checks the helper exposes every +// definition, with the module stripped off the Name (the model carries the local +// name; the module comes from ContainerID) and every value carried over. +func TestBuildSystemEnumerations_CoversTheTable(t *testing.T) { + built := BuildSystemEnumerations() + if len(built) != len(SystemEnumerations) { + t.Fatalf("BuildSystemEnumerations() = %d enumerations, want %d", len(built), len(SystemEnumerations)) + } + byName := make(map[string]int, len(built)) + for _, e := range built { + if string(e.ContainerID) != SystemModuleID { + t.Errorf("%s ContainerID = %q, want the System module ID", e.Name, e.ContainerID) + } + if e.TypeName != "Enumerations$Enumeration" { + t.Errorf("%s TypeName = %q", e.Name, e.TypeName) + } + byName[e.Name] = len(e.Values) + } + for _, def := range SystemEnumerations { + local := def.Name[len("System."):] + got, ok := byName[local] + if !ok { + t.Errorf("%s missing from BuildSystemEnumerations() (looked for local name %q)", def.Name, local) + continue + } + if got != len(def.Values) { + t.Errorf("%s value count = %d, want %d", def.Name, got, len(def.Values)) + } + } +} + +// TestBuildSystemEnumerations_IDsAreDeterministicAndUnique matters because the +// catalog keys enumerations_data on Id: a colliding or run-varying ID either +// breaks the insert or makes the catalog differ run to run. Mirrors the Java +// action helper's scheme. +func TestBuildSystemEnumerations_IDsAreDeterministicAndUnique(t *testing.T) { + first, second := BuildSystemEnumerations(), BuildSystemEnumerations() + seen := map[string]string{} + for i, e := range first { + if e.ID != second[i].ID { + t.Errorf("%s ID varies between calls: %q vs %q", e.Name, e.ID, second[i].ID) + } + if e.ID == "" { + t.Errorf("%s has an empty ID", e.Name) + } + if prev, dup := seen[string(e.ID)]; dup { + t.Errorf("%s and %s share ID %q", prev, e.Name, e.ID) + } + seen[string(e.ID)] = e.Name + vals := map[string]bool{} + for _, v := range e.Values { + if v.ID == "" { + t.Errorf("%s.%s has an empty value ID", e.Name, v.Name) + } + if vals[string(v.ID)] { + t.Errorf("%s has duplicate value ID %q", e.Name, v.ID) + } + vals[string(v.ID)] = true + } + } +} + +// TestSystemEnumerations_NamesAreQualified guards the assumption the builder +// makes when it strips the prefix. +func TestSystemEnumerations_NamesAreQualified(t *testing.T) { + for _, def := range SystemEnumerations { + if len(def.Name) <= len("System.") || def.Name[:len("System.")] != "System." { + t.Errorf("SystemEnumerations entry %q is not a System-qualified name", def.Name) + } + if len(def.Values) == 0 { + t.Errorf("%s has no values", def.Name) + } + } +} + +// TestSkillDocumentsTheSameValues pins the system-module skill's enumeration +// section to this table. +// +// It exists because the section had DRIFTED into wrong casing — `created`, +// `end`, `single`, `microflow`, `error`, `user`, `external` — and was missing +// three enumerations entirely, WorkflowActivityState among them. Enumeration +// value names are case-sensitive and a wrong one is only caught at build time as +// CE1613, so a developer copying `created` out of the skill hit exactly the +// failure the skill was there to prevent (mendixlabs/mxcli#1102). A hand-kept +// list of platform values is only as good as the thing that compares it. +func TestSkillDocumentsTheSameValues(t *testing.T) { + const skill = "../../.claude/skills/mendix/system-module/SKILL.md" + raw, err := os.ReadFile(skill) + if err != nil { + t.Skipf("skill not readable (%v) — nothing to compare", err) + } + + // Section 8 lists one `### ` heading per enumeration, followed by + // its values as `backticked`, comma-separated names. + body := string(raw) + start := strings.Index(body, "## 8. Enumerations") + if start < 0 { + t.Fatalf("%s no longer has an '## 8. Enumerations' section — update this test with it", skill) + } + section := body[start:] + if end := strings.Index(section, "\n## 9."); end > 0 { + section = section[:end] + } + + documented := map[string][]string{} + var current string + for _, line := range strings.Split(section, "\n") { + if after, ok := strings.CutPrefix(line, "### "); ok { + current = strings.TrimSpace(after) + continue + } + if current == "" || !strings.HasPrefix(strings.TrimSpace(line), "`") { + continue + } + for _, part := range strings.Split(line, ",") { + if v := strings.Trim(strings.TrimSpace(part), "`"); v != "" { + documented[current] = append(documented[current], v) + } + } + current = "" + } + + for _, def := range SystemEnumerations { + local := strings.TrimPrefix(def.Name, "System.") + got, ok := documented[local] + if !ok { + t.Errorf("%s is not documented in the system-module skill's section 8", def.Name) + continue + } + if !slices.Equal(got, def.Values) { + t.Errorf("%s: skill documents %v, table has %v", def.Name, got, def.Values) + } + delete(documented, local) + } + for extra := range documented { + t.Errorf("the skill documents System.%s, which is not in SystemEnumerations", extra) + } +} From adfbceaf4bfbc9384ad1cacb771103e93275876c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 13:12:17 +0000 Subject: [PATCH 12/12] fix(exprcheck): type LOOP variables and declared variables (#1100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$out = $out + $r/Status` inside `LOOP $r IN $reqs` — an Enumeration concatenated into a String — passed `check --references`, was written by `exec`, and failed the build with CE0117 at the Change variable activity, while the same mistake on a parameter was refused as E004. The report reads as "the checker is skipped inside a LOOP body". It is not, and the control says so: `'status=' + $T/Status` on a *parameter*, written one line inside the loop, was refused before this change. The body was always walked; the variable scope had two holes, and both had to close before the reported script reported anything. 1. `buildVarEntityScope` recorded CREATE, database RETRIEVE and parameters but never `LoopStmt.LoopVariable`, so `$r/Status` resolved to no attribute and inferred KindUnknown — which every rule tolerates by design. 2. `CheckAdapter` never set `Context.Scope` at all, so a `DECLARE $out String` was Unknown too. E004 needs BOTH operands typed, so closing (1) alone still reported nothing. That is also why `$out = $out + $Req/Status` was silent with no loop in sight; the report's case A hides it by putting a literal on the left. The list sources a loop can iterate are typed with it: a database retrieve, an association retrieve (far end resolved through the association index, which is why parameters are seeded before the body walk), a CREATE LIST, and the list operations that carry their input's element type through. Two other block-scoped positions the report asked about are covered: an ON ERROR handler body, which was not walked at all, and a FIND/FILTER predicate, where `$currentObject` is bound to the element type of the list under test. A bare attribute name in a predicate still resolves to nothing — binding bare names would change what a bare identifier means everywhere in an expression. The DataTypeKind and ON ERROR tables now live once in adapters with mdl/executor delegating: `validate_member_refs.go` already typed loop variables from the list and the expression checker's walk did not, which is the drift these two copies invite. Measured: exec-then-type-check over 591 mdl-examples scripts gives 11 violations before and 11 after, same rules on the same lines. The sweep earned its keep — a first cut reported Mendix's STRING `find($Hay, $Needle)` as a non-Boolean predicate, because the visitor still builds it as a list operation and the flow builder disambiguates it later (ledger #63); a predicate is now checked only when the input list's element entity is known, applying the same disambiguation. Controls: each of the four scope sources reverted in turn fails a distinct test with the reported symptom (empty violations). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012af91Z2nhirBJyY9MLRJqp --- .../skills/fix-issue/findings/mdl-other.jsonl | 1 + CHANGELOG.md | 8 + .../bug-tests/1100-loop-variable-typing.mdl | 124 +++++++ mdl/executor/typecheck_test.go | 237 ++++++++++++++ mdl/executor/validate_microflow.go | 80 +---- mdl/exprcheck/adapters/adapter_scope.go | 304 ++++++++++++++++-- mdl/exprcheck/adapters/adapter_scope_test.go | 179 +++++++++++ mdl/exprcheck/adapters/check.go | 73 ++++- mdl/exprcheck/slot_resolver.go | 8 +- mdl/exprcheck/slot_to_context.go | 1 + 10 files changed, 914 insertions(+), 101 deletions(-) create mode 100644 mdl-examples/bug-tests/1100-loop-variable-typing.mdl create mode 100644 mdl/exprcheck/adapters/adapter_scope_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other.jsonl b/.claude/skills/fix-issue/findings/mdl-other.jsonl index df5b3e0db7..3ced6780e2 100644 --- a/.claude/skills/fix-issue/findings/mdl-other.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-other.jsonl @@ -63,3 +63,4 @@ {"area": "mdl/catalog", "date": "2026-09-09", "symptom": "A new CATALOG view is queryable by name but absent from `show catalog tables`, so nobody can discover it — caught by TestTables_CoversAllViews, not by any query", "cause": "Adding a view to tables.go creates it in SQLite, but Catalog.Tables() is a separate hand-maintained list and is what SHOW CATALOG TABLES prints. The two are not derived from each other", "file": "`mdl/catalog/catalog.go` (Tables), `mdl/catalog/tables.go`", "insight": "Two hand-maintained lists of the same thing, with a guard test comparing them — the guard is the only reason this is a five-second fix instead of a view nobody finds for months. When adding a catalog view, expect to touch both. The general shape recurs in this codebase (stmtCreateInfo vs projectNameSets.setFor under-reported conflicts for the same reason), so the question to ask of any list is what compares it to its twin", "refs": ["ako/mxcli#413"]} {"area":"mdl/catalog","date":"2026-09-13","symptom":"A user asks 'are there circular dependencies between my modules?', reads CATALOG.GRAPH_CYCLES, gets 0 rows, and concludes there are none — while CATALOG.GRAPH_MODULE_COUPLING lists the same module pair in both directions (mendixlabs/mxcli#1060)","cause":"GRAPH_CYCLES is SCCs of the ASSET graph. Modules A and B reference each other through DIFFERENT documents, which form no cycle among themselves, so the asset table is correctly empty for a genuinely circular module pair. Compounded by scope: the asset graph admits only graphRefKinds, while coupling counts every kind — 110 of 316 edges on a stock 11.14 app, and Administration -> Atlas_Core there is `layout`-only","file":"`mdl/catalog/builder_graph.go` (`buildModuleCycles`, `loadModuleEdges`, `graphRefKindsSQL`), `mdl/catalog/tables.go` (`graph_module_cycles_data`, `graph_analysis_scope`), `mdl/linter/starlark_graph.go` (`module_cycles()`)","insight":"**When a report says two tables disagree, check whether they answer different questions before checking whether one is broken — then make the difference queryable.** Tarjan was fine; the granularity and the edge filter were both undocumented and invisible from SQL. Two traps in the implementation: (1) the new module pass sat after `if len(edges) == 0 { return }` on the STRUCTURAL edge set, so a project whose only cross-module refs are navigational — the reported case exactly — got nothing; a test with only `layout` edges caught it. (2) A module-cycle table built on the structural subset would have shipped green and still answered 'none' for the reported pair, so the edge set has to match graph_module_coupling, the table it is read beside. Generate the scope view's IN list from graphRefKinds rather than restating it, or the documentation of the filter drifts from the filter","fix":"Add graph_module_cycles (module SCCs, all kinds, with a RefKinds column naming the edges inside the cycle) and graph_analysis_scope (per-kind edge counts + InAssetGraph), plus a module_cycles() Starlark builtin"} {"area": "mdl/ast", "date": "2026-09-15", "symptom": "Rewriting a microflow from its own `describe microflow` output moves its workflow actions: after `create or modify`, `open workflow` / `notify workflow` (and every other workflow action) sit ~800px further right, the first one on top of the end event; a second rewrite is stable. Five `log` statements round-trip identically", "cause": "`@position` (and `@caption`, `@color`, `@anchor`) was parsed and dropped for these statements: the visitor's `setStatementAnnotations` and the builder's `getStatementAnnotations` were hand-written type switches with no case for any of the eleven workflow statements or the three mapping statements (import/export mapping, transform json). The setter also had an EMPTY `case *ast.EnumSplitStmt:` — a no-op in Go, which does not fall through — and the getter lacked `SendRestRequestStmt`. With no annotation the builder auto-placed each action after the start event, which the stored start position had moved right", "file": "`mdl/ast/annotations.go` (new `SetStatementAnnotations`), `mdl/visitor/visitor_microflow_statements.go` (`setStatementAnnotations`), `mdl/executor/cmd_microflows_builder_annotations.go` (`getStatementAnnotations`)", "insight": "**This is #884's lesson, unapplied one level up.** #884 introduced the reflective `ast.StatementAnnotations` precisely because a type switch over the annotated statements silently skips the one added later — but only the validator used it, while the two switches that actually carry `@position` from source to BSON stayed hand-written and had already missed 15 types. When a class-level fix lands, grep for every other copy of the pattern it replaces. Both now delegate to reflection, so a new statement type with an `Annotations` field is covered on declaration; `TestEveryAnnotatedStatementIsReachable` pins the field shape. **Diagnose with the source, not the symptom list**: a go/parser scan of the AST structs against the switch case labels found all 15 gaps (and the empty case) in one run, where the report named two statements. Control: with both switches restored, `TestPositionAnnotationPlacesEveryActionStatement` fails 17 subtests at the builder default (100,100) while its `log` subtest passes. Measured on mx-test-projects/i956 (11.13): describe→rewrite→describe diffs every position before, identical after. Tests `mdl/executor/microflow_statement_position_test.go`, `mdl/ast/annotations_coverage_test.go`", "refs": [], "rules": []} +{"area": "mdl/exprcheck", "date": "2026-09-16", "symptom": "`$out = $out + $r/Status` inside `LOOP $r IN $reqs` (Enumeration into a String) passes `mxcli check -p --references`, is written by `exec`, and fails the native build with **CE0117** at the Change variable activity. The same mistake on a PARAMETER is refused as E004, so the checker looks like it is skipped inside LOOP bodies (mendixlabs/mxcli#1100)", "cause": "The loop BODY was walked and checked all along \u2014 the control that proves it is `'status=' + $T/Status` on a parameter written one line INSIDE the loop, which was refused before the fix. Two holes in the variable scope, in series, produced the asymmetry: (a) `buildVarEntityScope` recorded CREATE, database RETRIEVE and parameters but never `LoopStmt.LoopVariable`, so `$r/Status` resolved to no attribute and inferred KindUnknown, which every rule tolerates by design; (b) `CheckAdapter` never set `Context.Scope` at all, so a DECLARE'd `$out String` was Unknown too \u2014 and E004 needs BOTH operands typed, so closing (a) alone still reported nothing on the reported script. (b) also meant `$out = $out + $Req/Status` with no loop in sight was equally silent; the report's own case A hides that by using a string literal on the left", "file": "`mdl/exprcheck/adapters/adapter_scope.go` (`buildFlowScope` replacing `buildVarEntityScope`, `recordRetrieve`/`recordListOperation`/`recordDeclare`, `kindScope`, `StatementErrorHandling`, `DataTypeKind`), `mdl/exprcheck/adapters/check.go` (`walkFlow` passes Scope; `checkListOperationCondition`; ON ERROR bodies walked), `mdl/exprcheck/slot_resolver.go` + `slot_to_context.go` (`ListOperation.Condition`), `mdl/executor/validate_microflow.go` (delegates `astKindToExprKind` and `stmtErrorHandling`)", "insight": "**Separate \"was the walk there\" from \"did the variable resolve\" before believing a skipped-construct report.** The title said LOOP bodies were not checked; one control \u2014 the same expression on a parameter, one line deeper \u2014 showed the walk was fine and the scope was not, which changed the fix from a walk to a resolver. **A silence can need two fixes to break**: typing the loop variable alone left the reported script still reporting nothing, because the rule needs both operands. Fix one, re-measure, and do not conclude the fix failed. **The same walk already existed, correct, next door**: `mdl/executor/validate_member_refs.go` typed loop variables from the list; the expression checker's walk did not \u2014 duplicate-resolver drift, which is why `stmtErrorHandling` and the DataTypeKind table are now single copies in adapters with the executor delegating. **Order is load-bearing and silent when wrong**: parameters must seed the scope BEFORE the body walk, or an association retrieve off a parameter (and every loop over its result) stays untyped \u2014 this was written the old way first and only a test caught it. **False-positive control**: exec-then-type-check over 591 mdl-examples scripts, 11 violations before and 11 after, same rules. It earned its keep \u2014 the first cut fired E009 on `set $At = find($Hay, $Needle)`, Mendix's STRING find, which the visitor still builds as a ListOperationStmt (the flow builder disambiguates it later, ledger #63). Requiring a KNOWN element entity before checking a FIND/FILTER predicate applies the same disambiguation. Controls: each of the four scope sources reverted in turn fails a distinct test with the reported symptom (empty violations). **Still open**: a bare attribute name in a FILTER predicate resolves to nothing, `retrieve \u2026 limit 1` is typed as a list like any other retrieve, and `LOOP $r IN $T/Mod.Assoc` cannot be typed because the visitor drops the association path (`ListVariable` is empty)"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 55189900cd..1424974070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **The expression type checker was silent on a LOOP variable, and on every locally declared variable** (mendixlabs/mxcli#1100). `$out = $out + $r/Status` inside `LOOP $r IN $reqs` — an Enumeration concatenated into a String — passed `check --references`, was written by `exec`, and failed the build with **CE0117** at the Change variable activity, while the same mistake on a parameter was refused as **E004**. + + The report reads as "the checker is skipped inside a `LOOP` body". It is not, and the control says so: `'status=' + $T/Status` on a *parameter*, written one line **inside** the loop, was refused before this change. The body was always walked; the **variable scope** had two holes, and both had to close before the reported script reported anything. `LOOP $r IN $reqs` never recorded `$r`, so `$r/Status` resolved to no attribute and inferred Unknown — which every rule tolerates by design. And `Context.Scope` was never set at all, so a `DECLARE $out String` was Unknown too; E004 needs **both** operands typed, so typing the iterator alone still reported nothing. That second hole is why `$out = $out + $Req/Status` was equally silent with no loop in sight — the report's case A hides it by putting a string literal on the left. + + The list sources a loop can iterate are typed with it: a database retrieve, an **association** retrieve (the far end resolved through the association index, which needs parameters seeded before the body walk), a `CREATE LIST`, and the list operations that carry their input's element type through. Two other block-scoped positions the report asked about are covered: an **ON ERROR handler body**, which was not walked at all, so moving a statement into one exempted it from every rule; and a **FIND/FILTER predicate**, where `$currentObject` is bound to the element type of the list under test. A bare attribute name in a predicate (`FILTER($L, Status = 'Open')`) still resolves to nothing — binding bare names would change what a bare identifier means everywhere in an expression. + + Measured: exec-then-type-check over 591 `mdl-examples/` scripts gives **11 violations before and 11 after**, the same rules on the same lines. The sweep earned its keep — a first cut reported Mendix's **string** `find($Hay, $Needle)` as a non-Boolean predicate, because the visitor still builds it as a list operation and the flow builder disambiguates it later (ledger #63); a FIND/FILTER predicate is now checked only when the input list's element entity is known, which applies the same disambiguation. + - **`mxcli check` and the editor could not parse a `.test.mdl` file at all** (mendixlabs/mxcli#1103). A test block is a **microflow body** — that is what the runner turns it into — and both were handing the file to the top-level grammar instead. `DECLARE` is not a top-level statement, so the parser resynced; `RETRIEVE` is a non-reserved keyword, so it was swallowed as an identifier; and the leftover `FROM …` started an OQL query, whose follow set is `{GROUP_BY, SELECT, HAVING}`. The reported error therefore told the author their `RETRIEVE` needed a `SELECT`, on a statement `mxcli syntax microflow.retrieve` prints as its own example. This was not a corner: the VS Code extension binds MDL to `.mdl`, which `.test.mdl` matches, so every test file open in the editor was a wall of squiggles — 9 of this repository's 10 test files reported errors, one of them 392, now all 0. Each block is rendered as the microflow it becomes, padded so it keeps its **source line numbers**, which is what lets every existing rule apply with no remapping: `mxcli check suite.test.mdl` now reports an uncompilable body, an unusable `@expect` or `@verify` (`MDL-TEST01`), and everything else, at the line the author wrote it on. `make check-mdl` sweeps test files too, with `.fail.test.mdl` for one whose annotations are deliberately unusable. diff --git a/mdl-examples/bug-tests/1100-loop-variable-typing.mdl b/mdl-examples/bug-tests/1100-loop-variable-typing.mdl new file mode 100644 index 0000000000..72115f757f --- /dev/null +++ b/mdl-examples/bug-tests/1100-loop-variable-typing.mdl @@ -0,0 +1,124 @@ +-- mendixlabs/mxcli#1100 — a LOOP variable was untyped, so every expression rule +-- was silently off inside the construct where list processing happens. +-- +-- REPORTED SHAPE. The same mistake fires outside a loop and is silent inside it: +-- +-- -- A: refused, E004 +-- $out = 'status=' + $Req/Status; +-- +-- -- B: passes check, execs, fails the build +-- LOOP $r IN $reqs BEGIN +-- $out = $out + $r/Status; -- [CE0117] at Change variable activity +-- END LOOP; +-- +-- WHAT WAS ACTUALLY BROKEN — not what the title says. The loop BODY was walked +-- and its expressions were checked all along: the same `'status=' + $T/Status` +-- written against a PARAMETER inside the loop was refused before the fix. Two +-- separate holes in the variable scope produced the asymmetry, and both had to +-- close before the reported script reported anything: +-- +-- 1. `LOOP $r IN $reqs` never recorded `$r`, so `$r/Status` resolved to no +-- attribute and inferred Unknown — which every rule tolerates by design. +-- 2. `DECLARE $out String` never recorded `$out`, so the accumulator was +-- Unknown too. E004 needs BOTH operands typed, so fixing (1) alone still +-- reported nothing on `$out + $r/Status`. +-- +-- Fixing (2) also closed the same silence one level up: `$out = $out + $T/Status` +-- on a plain parameter, with no loop anywhere, was equally unreported. +-- +-- The list sources a loop can iterate are typed with it — a database retrieve, +-- an association retrieve (the far end resolved through the association index), +-- a CREATE LIST, and the list operations that carry their input's element type +-- through (FILTER, SORT, RANGE, UNION, …). +-- +-- REPRODUCING: these rules are the catalog-backed tier, so they need a project: +-- +-- mxcli check 1100-loop-variable-typing.mdl -p app.mpr --references +-- +-- `mxcli check` with no project (what `make check-mdl` runs) cannot resolve an +-- attribute's type at all, so this file is written in its CORRECTED form and +-- must PASS. The failing forms are in the comments above and the regression +-- coverage is TestTypeCheckProgramTypesLoopVariables in mdl/executor. + +create module Probe; + +create enumeration Probe.ENUM_Status ( + Open 'Open', + Closed 'Closed' +); + +create persistent entity Probe.Request ( + Code: String(20), + Status: Enumeration(Probe.ENUM_Status) +); + +create persistent entity Probe.Reporter ( + Email: String(200) +); + +create association Probe.Request_Reporter + from Probe.Request to Probe.Reporter; + +-- The reported microflow, written correctly. Before the fix the toString() was +-- optional as far as mxcli was concerned; now leaving it out is E004. +create or replace microflow Probe.SUB_LoopNormal () +returns String +begin + declare $out String = ''; + retrieve $reqs from Probe.Request; + loop $r in $reqs begin + $out = $out + toString($r/Status); + end loop; + return $out; +end; + +-- The control from the report: the same expression on a parameter. It was +-- refused before the fix and must stay refused after it. +create or replace microflow Probe.SUB_Param ($Req: Probe.Request) +returns String +begin + declare $out String = ''; + $out = 'status=' + toString($Req/Status); + return $out; +end; + +-- A loop over an association retrieve. The iterator is typed from the entity at +-- the far end of Request_Reporter, which is resolved rather than read off the +-- statement — an expression path does not spell its intermediate entity. +create or replace microflow Probe.SUB_LoopAssoc ($Req: Probe.Request) +returns String +begin + declare $emails String = ''; + retrieve $reps from $Req/Probe.Request_Reporter; + loop $rep in $reps begin + $emails = $emails + $rep/Email; + end loop; + return $emails; +end; + +-- A loop over a filtered list: FILTER carries the element type through, so the +-- iterator is still a Request. +create or replace microflow Probe.SUB_LoopFiltered () +returns String +begin + declare $codes String = ''; + retrieve $reqs from Probe.Request; + $open = FILTER($reqs, $currentObject/Status = Probe.ENUM_Status.Open); + loop $r in $open begin + $codes = $codes + $r/Code; + end loop; + return $codes; +end; + +-- An ON ERROR handler body was not walked at all, so moving a statement into +-- one exempted it from every rule. +create or replace microflow Probe.SUB_Handler () +returns String +begin + declare $out String = ''; + retrieve $reqs from Probe.Request + on error { + $out = $out + 'retrieve failed'; + }; + return $out; +end; diff --git a/mdl/executor/typecheck_test.go b/mdl/executor/typecheck_test.go index eb989d6a0c..9c1967d496 100644 --- a/mdl/executor/typecheck_test.go +++ b/mdl/executor/typecheck_test.go @@ -287,3 +287,240 @@ END; t.Errorf("an untypeable variable produced %+v", got) } } + +// TestTypeCheckProgramTypesLoopVariables pins mendixlabs/mxcli#1100. +// +// The report's title says the checker is skipped inside a LOOP body. It is not: +// the body is walked, and the same expression written against a PARAMETER +// inside the loop was refused before the fix — that control is the third case +// below, and it distinguishes "the walk does not reach here" from "the variable +// resolves to nothing". It was the second: `LOOP $r IN $reqs` never recorded +// `$r`, so `$r/Status` inferred Unknown, and Unknown is tolerated by every rule +// by design. +func TestTypeCheckProgramTypesLoopVariables(t *testing.T) { + exec := typeCheckFixture(t) + + // The reported script, verbatim in shape. Before the fix: Check passed!, + // exec wrote it, mxbuild reported CE0117 at the Change variable activity. + loopVar := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_LoopNormal () RETURNS String +BEGIN + DECLARE $out String = ''; + RETRIEVE $reqs FROM MyFirstModule.Ticket; + LOOP $r IN $reqs BEGIN + $out = $out + $r/Status; + END LOOP; + RETURN $out; +END; +`) + if len(loopVar) != 1 || loopVar[0].RuleID != "E004" { + t.Errorf("an Enumeration concatenated inside a LOOP produced %+v, want one E004", loopVar) + } + + // The report's case A, which already worked and must keep working. + param := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_Param ($Req: MyFirstModule.Ticket) RETURNS String +BEGIN + DECLARE $out String = ''; + $out = 'status=' + $Req/Status; + RETURN $out; +END; +`) + if len(param) != 1 || param[0].RuleID != "E004" { + t.Errorf("the parameter control produced %+v, want one E004", param) + } + + // The control that says the LOOP BODY was never the problem: a parameter + // referenced one line deeper is checked, and was before the fix too. + paramInLoop := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_ParamInLoop ($Req: MyFirstModule.Ticket) +BEGIN + RETRIEVE $reqs FROM MyFirstModule.Ticket; + LOOP $r IN $reqs BEGIN + LOG 'x {1}' WITH ({1} = 'status=' + $Req/Status); + END LOOP; +END; +`) + if len(paramInLoop) != 1 || paramInLoop[0].RuleID != "E004" { + t.Errorf("a parameter inside a LOOP produced %+v, want one E004", paramInLoop) + } + + // Every rule was off for a loop variable, not just E004. + enumCompare := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_LoopEnumCompare () +BEGIN + RETRIEVE $reqs FROM MyFirstModule.Ticket; + LOOP $r IN $reqs BEGIN + IF $r/Status = 'Open' THEN + LOG 'x'; + END IF; + END LOOP; +END; +`) + if len(enumCompare) != 1 || enumCompare[0].RuleID != "E001" { + t.Errorf("an enum compared to a string inside a LOOP produced %+v, want one E001", enumCompare) + } + + // The failure direction. A correct loop must stay silent — a checker that + // reports the fixed form is worse than one that reported nothing. + clean := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_LoopClean () RETURNS String +BEGIN + DECLARE $out String = ''; + RETRIEVE $reqs FROM MyFirstModule.Ticket; + LOOP $r IN $reqs BEGIN + $out = $out + toString($r/Status) + $r/Title; + IF $r/Status = MyFirstModule.OrderStatus.Open THEN + LOG 'open'; + END IF; + END LOOP; + RETURN $out; +END; +`) + if len(clean) != 0 { + t.Errorf("a correct loop produced %+v, want none", clean) + } +} + +// TestTypeCheckProgramTypesDeclaredVariables pins the second half of #1100. +// +// Typing the loop variable alone does NOT make the reported script report +// anything: E004 needs both operands known, and the accumulator `$out` was +// Unknown too. That is also why the same mistake was silent with no loop in +// sight — `$out = $out + $Req/Status` on a parameter was unreported before the +// fix, which the report's own case A hides by using a string literal. +func TestTypeCheckProgramTypesDeclaredVariables(t *testing.T) { + exec := typeCheckFixture(t) + + got := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_Accumulate ($Req: MyFirstModule.Ticket) RETURNS String +BEGIN + DECLARE $out String = ''; + $out = $out + $Req/Status; + RETURN $out; +END; +`) + if len(got) != 1 || got[0].RuleID != "E004" { + t.Errorf("a declared String accumulator produced %+v, want one E004", got) + } + + // Mendix auto-converts a numeric operand in a String concat, so a declared + // Integer must not be reported. This is the boundary the rule already had; + // typing the variable is what puts it in reach of being crossed. + numeric := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_Numeric () RETURNS String +BEGIN + DECLARE $n Integer = 1; + DECLARE $out String = 'n=' + $n; + RETURN $out; +END; +`) + if len(numeric) != 0 { + t.Errorf("a numeric concat produced %+v, want none (Mendix auto-converts)", numeric) + } +} + +// TestTypeCheckProgramTypesDerivedLists pins the list sources a LOOP can +// iterate. The iterator is only as typed as the list it walks, so a retrieve +// over an association and a list operation have to carry their element type or +// the fix above covers one spelling of the same loop. +func TestTypeCheckProgramTypesDerivedLists(t *testing.T) { + exec := typeCheckFixture(t) + + // An association retrieve names the association, not the entity, so the far + // end is resolved through the association index. + assoc := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_LoopAssoc ($T: MyFirstModule.Ticket) +BEGIN + RETRIEVE $reps FROM $T/MyFirstModule.Ticket_Reporter; + LOOP $r IN $reps BEGIN + LOG 'x {1}' WITH ({1} = $r); + END LOOP; +END; +`) + if len(assoc) != 1 || assoc[0].RuleID != "E009" { + t.Errorf("a loop over an association retrieve produced %+v, want one E009", assoc) + } + + // FILTER carries the input's element type through. + filtered := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_LoopFiltered () +BEGIN + RETRIEVE $reqs FROM MyFirstModule.Ticket; + $open = FILTER($reqs, $currentObject/Title != ''); + LOOP $r IN $open BEGIN + LOG 'x {1}' WITH ({1} = 'status=' + $r/Status); + END LOOP; +END; +`) + if len(filtered) != 1 || filtered[0].RuleID != "E004" { + t.Errorf("a loop over a FILTER result produced %+v, want one E004", filtered) + } +} + +// TestTypeCheckProgramChecksBlockScopedBodies covers the two other block-scoped +// positions the report asked about: an ON ERROR handler's body, which was not +// walked at all, and a FIND/FILTER predicate, where $currentObject is now bound +// to the element type of the list under test. +func TestTypeCheckProgramChecksBlockScopedBodies(t *testing.T) { + exec := typeCheckFixture(t) + + handler := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_Handler ($T: MyFirstModule.Ticket) RETURNS String +BEGIN + DECLARE $out String = ''; + RETRIEVE $reqs FROM MyFirstModule.Ticket + ON ERROR { + $out = $out + $T/Status; + }; + RETURN $out; +END; +`) + if len(handler) != 1 || handler[0].RuleID != "E004" { + t.Errorf("an ON ERROR handler body produced %+v, want one E004", handler) + } + + predicate := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_Predicate () +BEGIN + RETRIEVE $reqs FROM MyFirstModule.Ticket; + $open = FILTER($reqs, $currentObject/Status = 'Open'); +END; +`) + if len(predicate) != 1 || predicate[0].RuleID != "E001" { + t.Errorf("a FILTER predicate produced %+v, want one E001", predicate) + } + + // The control: the same predicate written correctly stays silent, and so + // does the bare-attribute spelling the skills recommend, which resolves to + // nothing rather than to a wrong answer. + clean := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_PredicateClean () +BEGIN + RETRIEVE $reqs FROM MyFirstModule.Ticket; + $open = FILTER($reqs, $currentObject/Status = MyFirstModule.OrderStatus.Open); + $named = FILTER($reqs, "Title" != ''); +END; +`) + if len(clean) != 0 { + t.Errorf("a correct FILTER predicate produced %+v, want none", clean) + } + + // Mendix's STRING find(haystack, needle) still arrives as a + // ListOperationStmt — the visitor does not disambiguate it, the flow + // builder does, by looking at whether the input is a declared String + // (mdl-examples/bug-tests/ledger-63-string-find.mdl). Checking its second + // argument as a Boolean predicate reported the needle on a script that + // builds at 0 errors, which the corpus sweep caught and this pins. + stringFind := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.SUB_StringFind ($Hay: String, $Needle: String) RETURNS Integer +BEGIN + DECLARE $At Integer = 0; + SET $At = find($Hay, $Needle); + RETURN $At; +END; +`) + if len(stringFind) != 0 { + t.Errorf("Mendix's string find() produced %+v, want none", stringFind) + } +} diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index b4eb1c4091..f1de3b12eb 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -9,6 +9,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/exprcheck" + "github.com/mendixlabs/mxcli/mdl/exprcheck/adapters" "github.com/mendixlabs/mxcli/mdl/linter" ) @@ -890,27 +891,11 @@ func microflowExprSource(expr ast.Expression) string { // astKindToExprKind maps an MDL primitive data-type kind to an exprcheck kind. // Returns false for non-primitive / unmappable kinds (entities, lists, void). +// +// The table lives in mdl/exprcheck/adapters because the catalog-backed checker +// needs the same answer; a second copy here is how the two would drift apart. func astKindToExprKind(k ast.DataTypeKind) (exprcheck.TypeKind, bool) { - switch k { - case ast.TypeString, ast.TypeStringTemplate: - return exprcheck.KindString, true - case ast.TypeInteger, ast.TypeAutoNumber: - return exprcheck.KindInteger, true - case ast.TypeLong: - return exprcheck.KindLong, true - case ast.TypeDecimal: - return exprcheck.KindDecimal, true - case ast.TypeBoolean: - return exprcheck.KindBoolean, true - case ast.TypeDateTime, ast.TypeDate: - return exprcheck.KindDateTime, true - case ast.TypeBinary: - return exprcheck.KindBinary, true - case ast.TypeEnumeration: - return exprcheck.KindEnumeration, true - default: - return exprcheck.KindUnknown, false - } + return adapters.DataTypeKind(k) } // checkErrorHandlingInLoop warns if custom error handling is used inside a loop. @@ -1341,57 +1326,12 @@ func exprVarRefs(expr ast.Expression) []string { } // stmtErrorHandling returns the ErrorHandlingClause for statements that support it. +// +// The table lives in mdl/exprcheck/adapters so the expression checker's walk and +// this one cannot disagree about which statements carry a handler: a statement +// missing from one copy is silently skipped by whichever walk holds it. func stmtErrorHandling(stmt ast.MicroflowStatement) *ast.ErrorHandlingClause { - switch s := stmt.(type) { - case *ast.CreateObjectStmt: - return s.ErrorHandling - case *ast.DeleteObjectStmt: - return s.ErrorHandling - case *ast.MfCommitStmt: - return s.ErrorHandling - case *ast.RetrieveStmt: - return s.ErrorHandling - case *ast.CallMicroflowStmt: - return s.ErrorHandling - case *ast.CallNanoflowStmt: - return s.ErrorHandling - case *ast.CallJavaActionStmt: - return s.ErrorHandling - case *ast.DownloadFileStmt: - return s.ErrorHandling - case *ast.SynchronizeStmt: - return s.ErrorHandling - case *ast.CallJavaScriptActionStmt: - return s.ErrorHandling - case *ast.CallWebServiceStmt: - return s.ErrorHandling - case *ast.ExecuteDatabaseQueryStmt: - return s.ErrorHandling - // The eight statements #1078 gave an onErrorClause. Without them here, MDL076 - // cannot see a clause these statements now accept, and MDL077 cannot refuse - // one on a list operation or aggregate. - case *ast.DeclareStmt: - return s.ErrorHandling - case *ast.MfSetStmt: - return s.ErrorHandling - case *ast.ChangeObjectStmt: - return s.ErrorHandling - case *ast.LogStmt: - return s.ErrorHandling - case *ast.ShowPageStmt: - return s.ErrorHandling - case *ast.ClosePageStmt: - return s.ErrorHandling - case *ast.ShowMessageStmt: - return s.ErrorHandling - case *ast.ValidationFeedbackStmt: - return s.ErrorHandling - case *ast.ListOperationStmt: - return s.ErrorHandling - case *ast.AggregateListStmt: - return s.ErrorHandling - } - return nil + return adapters.StatementErrorHandling(stmt) } // isEmptyInit checks if a variable initializer is empty/nil (used to detect "DECLARE $List List of ... = empty"). diff --git a/mdl/exprcheck/adapters/adapter_scope.go b/mdl/exprcheck/adapters/adapter_scope.go index 53b3c68c2a..87e32511d9 100644 --- a/mdl/exprcheck/adapters/adapter_scope.go +++ b/mdl/exprcheck/adapters/adapter_scope.go @@ -9,41 +9,277 @@ import ( "github.com/mendixlabs/mxcli/mdl/exprcheck" ) -// buildVarEntityScope walks a microflow body and records every variable -// known to hold an entity instance, mapping varName → entity QN. +// flowScope is one flow's variable type environment: which entity a variable +// holds, and — for a variable holding a primitive — which kind. // -// Sources covered: -// - CreateObjectStmt (Variable ← EntityType) -// - RetrieveStmt with $var = retrieve … from (Variable ← EntityType) +// The two halves are separate because exprcheck consumes them through separate +// seams (EntityScope and Scope), and because the questions differ: an object +// variable is resolved against the catalog per attribute, while a primitive is +// its own answer. +type flowScope struct { + entities map[string]string + kinds map[string]exprcheck.TypeKind +} + +func newFlowScope() *flowScope { + return &flowScope{ + entities: map[string]string{}, + kinds: map[string]exprcheck.TypeKind{}, + } +} + +// buildFlowScope walks a microflow body in statement order and records every +// variable it can type. +// +// Order matters: a variable is typed from what introduced it, and what +// introduced it is always an earlier statement — `LOOP $r IN $reqs` can only +// type `$r` once `RETRIEVE $reqs` has been seen. The walk descends into nested +// bodies at the point they appear, so an inner loop over a list built inside an +// outer loop resolves too. // -// The map is best-effort. An empty entry means "unknown" and the caller -// should fall back to a slot path without entity.attr enrichment. -func buildVarEntityScope(body []ast.MicroflowStatement) map[string]string { - scope := map[string]string{} +// The map is best-effort: an absent entry means "unknown", and every exprcheck +// rule tolerates Unknown by design. It is never a licence to guess — a wrong +// entry produces a false positive on code that builds, which costs more than +// the silence it replaces. +func buildFlowScope(body []ast.MicroflowStatement, params []ast.MicroflowParam, assoc associationResolver) *flowScope { + s := newFlowScope() + // Parameters are seeded BEFORE the walk, not after it. They are in scope + // from the first statement, and a body statement can be typed FROM one — + // `RETRIEVE $reps FROM $T/Mod.Ticket_Reporter` resolves only if `$T` is + // already known. Appending them afterwards left every such retrieve, and + // every loop over its result, untyped. + addParamTypes(s, params) var walk func([]ast.MicroflowStatement) walk = func(stmts []ast.MicroflowStatement) { - for _, s := range stmts { - switch n := s.(type) { + for _, st := range stmts { + switch n := st.(type) { case *ast.CreateObjectStmt: if n.Variable != "" { - scope[n.Variable] = n.EntityType.String() + s.entities[n.Variable] = n.EntityType.String() + } + case *ast.CreateListStmt: + if n.Variable != "" { + s.entities[n.Variable] = n.EntityType.String() } case *ast.RetrieveStmt: - if n.Variable != "" && n.StartVariable == "" && n.Source.Name != "" { - scope[n.Variable] = n.Source.String() + s.recordRetrieve(n, assoc) + case *ast.ListOperationStmt: + s.recordListOperation(n) + case *ast.DeclareStmt: + s.recordDeclare(n) + case *ast.LoopStmt: + // The iterator's type is the element type of the list it walks. + // Without this every rule the checker enforces is silently off + // for `$r/Attr` inside the body — not because the body is + // skipped (it is walked), but because the variable resolves to + // nothing and Unknown is tolerated everywhere + // (mendixlabs/mxcli#1100). + if n.LoopVariable != "" && n.ListVariable != "" { + if qn, ok := s.entities[strings.TrimPrefix(n.ListVariable, "$")]; ok && qn != "" { + s.entities[n.LoopVariable] = qn + } } + walk(n.Body) case *ast.IfStmt: walk(n.ThenBody) walk(n.ElseBody) case *ast.WhileStmt: walk(n.Body) - case *ast.LoopStmt: - walk(n.Body) + } + // A custom ON ERROR body is a block of ordinary statements, so the + // variables it introduces are typed the same way. It is walked last + // because it runs after the statement that carries it. + if eb := errorHandlerBody(st); eb != nil { + walk(eb) } } } walk(body) - return scope + return s +} + +// recordRetrieve types a RETRIEVE's output variable. +// +// A database retrieve names its entity outright. An association retrieve +// (`RETRIEVE $reps FROM $T/Mod.Ticket_Reporter`) names only the association, so +// the entity at the far end has to be resolved through the association index — +// the same hop an expression path makes, and the reason the resolver is passed +// down here rather than being consulted only at expression level. +func (s *flowScope) recordRetrieve(n *ast.RetrieveStmt, assoc associationResolver) { + if n.Variable == "" || n.Source.Name == "" { + return + } + if n.StartVariable == "" { + s.entities[n.Variable] = n.Source.String() + return + } + if assoc == nil { + return + } + from, ok := s.entities[strings.TrimPrefix(n.StartVariable, "$")] + if !ok || from == "" { + return + } + if target, ok := assoc.AssociationTarget(n.Source.String(), from); ok && target != "" { + s.entities[n.Variable] = target + } +} + +// recordListOperation types a list operation's output. +// +// The operations split three ways: most carry the input's element type through +// (a filtered list of Orders is still Orders), CONTAINS and EQUALS answer a +// Boolean, and the rest are left alone. Nothing is inferred for an operation +// whose input was never typed. +func (s *flowScope) recordListOperation(n *ast.ListOperationStmt) { + if n.OutputVariable == "" { + return + } + switch n.Operation { + case ast.ListOpContains, ast.ListOpEquals: + s.kinds[n.OutputVariable] = exprcheck.KindBoolean + return + case ast.ListOpHead, ast.ListOpTail, ast.ListOpFind, ast.ListOpFilter, + ast.ListOpSort, ast.ListOpUnion, ast.ListOpIntersect, ast.ListOpSubtract, + ast.ListOpRange: + if n.InputVariable == "" { + return + } + if qn, ok := s.entities[strings.TrimPrefix(n.InputVariable, "$")]; ok && qn != "" { + s.entities[n.OutputVariable] = qn + } + } +} + +// recordDeclare types a DECLARE'd variable from the type it was written with. +// +// This is what makes `$out + $Order/Status` reportable: E004 and the slot rules +// need BOTH operands typed, and a locally declared String was Unknown, so the +// most ordinary shape in the language — accumulate into a String — was exempt +// from every rule (mendixlabs/mxcli#1100). +func (s *flowScope) recordDeclare(n *ast.DeclareStmt) { + if n.Variable == "" { + return + } + switch { + case n.Type.EntityRef != nil: + s.entities[n.Variable] = n.Type.EntityRef.String() + case n.Type.Kind == ast.TypeEnumeration && n.Type.EnumRef != nil: + // A bare qualified name parses as TypeEnumeration with EnumRef set and + // cannot be told from an entity (see CLAUDE.md). The ENTITY guess is + // free — a name that is really an enumeration resolves no attributes — + // but the KIND is not, so it is recorded only for the unambiguous + // spelling ExplicitEnum marks. Calling an entity variable an + // Enumeration would put the wrong type name in a rule's message. + s.entities[n.Variable] = n.Type.EnumRef.String() + if n.Type.ExplicitEnum { + s.kinds[n.Variable] = exprcheck.KindEnumeration + } + default: + if k, ok := DataTypeKind(n.Type.Kind); ok { + s.kinds[n.Variable] = k + } + } +} + +// StatementErrorHandling returns the ON ERROR clause a statement carries, or +// nil when it carries none. +// +// The clause is declared per statement type rather than on an interface, so +// this is a type switch — and a statement missing from it is invisible to every +// caller at once, which is why there is one table rather than one per walk. +// mdl/executor's validators call it too. +func StatementErrorHandling(stmt ast.MicroflowStatement) *ast.ErrorHandlingClause { + switch s := stmt.(type) { + case *ast.CreateObjectStmt: + return s.ErrorHandling + case *ast.DeleteObjectStmt: + return s.ErrorHandling + case *ast.MfCommitStmt: + return s.ErrorHandling + case *ast.RetrieveStmt: + return s.ErrorHandling + case *ast.CallMicroflowStmt: + return s.ErrorHandling + case *ast.CallNanoflowStmt: + return s.ErrorHandling + case *ast.CallJavaActionStmt: + return s.ErrorHandling + case *ast.DownloadFileStmt: + return s.ErrorHandling + case *ast.SynchronizeStmt: + return s.ErrorHandling + case *ast.CallJavaScriptActionStmt: + return s.ErrorHandling + case *ast.CallWebServiceStmt: + return s.ErrorHandling + case *ast.ExecuteDatabaseQueryStmt: + return s.ErrorHandling + // The eight statements #1078 gave an onErrorClause. Without them here, MDL076 + // cannot see a clause these statements now accept, and MDL077 cannot refuse + // one on a list operation or aggregate. + case *ast.DeclareStmt: + return s.ErrorHandling + case *ast.MfSetStmt: + return s.ErrorHandling + case *ast.ChangeObjectStmt: + return s.ErrorHandling + case *ast.LogStmt: + return s.ErrorHandling + case *ast.ShowPageStmt: + return s.ErrorHandling + case *ast.ClosePageStmt: + return s.ErrorHandling + case *ast.ShowMessageStmt: + return s.ErrorHandling + case *ast.ValidationFeedbackStmt: + return s.ErrorHandling + case *ast.ListOperationStmt: + return s.ErrorHandling + case *ast.AggregateListStmt: + return s.ErrorHandling + } + return nil +} + +// errorHandlerBody returns a statement's custom ON ERROR body, or nil when it +// has no clause or the clause is one of the bodyless forms (CONTINUE/ROLLBACK). +func errorHandlerBody(stmt ast.MicroflowStatement) []ast.MicroflowStatement { + eh := StatementErrorHandling(stmt) + if eh == nil || len(eh.Body) == 0 { + return nil + } + return eh.Body +} + +// DataTypeKind maps an MDL primitive data-type kind to an exprcheck kind, +// reporting false for kinds that have no primitive answer (entities, lists, +// void, type parameters). +// +// It lives here rather than in mdl/executor because both the scope-local +// validator and this adapter need the same answer, and two copies of a type +// mapping is how a resolver drifts. +func DataTypeKind(k ast.DataTypeKind) (exprcheck.TypeKind, bool) { + switch k { + case ast.TypeString, ast.TypeStringTemplate: + return exprcheck.KindString, true + case ast.TypeInteger, ast.TypeAutoNumber: + return exprcheck.KindInteger, true + case ast.TypeLong: + return exprcheck.KindLong, true + case ast.TypeDecimal: + return exprcheck.KindDecimal, true + case ast.TypeBoolean: + return exprcheck.KindBoolean, true + case ast.TypeDateTime, ast.TypeDate: + return exprcheck.KindDateTime, true + case ast.TypeBinary: + return exprcheck.KindBinary, true + case ast.TypeEnumeration: + return exprcheck.KindEnumeration, true + default: + return exprcheck.KindUnknown, false + } } // entityScope adapts a variable→entity map plus an association resolver to @@ -74,9 +310,19 @@ func (e entityScope) AssociationTarget(assocQN, fromEntityQN string) (string, bo return e.assoc.AssociationTarget(assocQN, fromEntityQN) } -// addParamEntities records the entity a parameter holds. +// kindScope adapts a variable→kind map to exprcheck.Scope. +type kindScope map[string]exprcheck.TypeKind + +var _ exprcheck.Scope = kindScope{} + +func (k kindScope) Lookup(name string) (exprcheck.TypeKind, bool) { + v, ok := k[strings.TrimPrefix(name, "$")] + return v, ok && v != exprcheck.KindUnknown +} + +// addParamTypes records what a parameter holds. // -// buildVarEntityScope walks only the body, so it sees a variable a CREATE or +// buildFlowScope walks only the body, so it sees a variable a CREATE or // RETRIEVE introduced and misses every parameter — and a microflow that takes // its object as a parameter is the ordinary case, not an edge one. // @@ -85,16 +331,30 @@ func (e entityScope) AssociationTarget(assocQN, fromEntityQN string) (string, bo // CLAUDE.md). Both spellings are recorded rather than guessed between — a name // that turns out to be an enumeration simply resolves no attributes, so the // wrong guess costs nothing. -func addParamEntities(scope map[string]string, params []ast.MicroflowParam) { +// +// It is called from buildFlowScope before the body walk; see the note there on +// why the order is load-bearing. +// +// A `list of Mod.Entity` parameter records the ELEMENT entity, which is what a +// LOOP over it needs; the list itself is not an object and nothing resolves an +// attribute against it. +func addParamTypes(s *flowScope, params []ast.MicroflowParam) { for _, p := range params { if p.Name == "" { continue } switch { case p.Type.EntityRef != nil: - scope[p.Name] = p.Type.EntityRef.String() + s.entities[p.Name] = p.Type.EntityRef.String() case p.Type.Kind == ast.TypeEnumeration && p.Type.EnumRef != nil: - scope[p.Name] = p.Type.EnumRef.String() + s.entities[p.Name] = p.Type.EnumRef.String() + if p.Type.ExplicitEnum { + s.kinds[p.Name] = exprcheck.KindEnumeration + } + default: + if k, ok := DataTypeKind(p.Type.Kind); ok { + s.kinds[p.Name] = k + } } } } diff --git a/mdl/exprcheck/adapters/adapter_scope_test.go b/mdl/exprcheck/adapters/adapter_scope_test.go new file mode 100644 index 0000000000..8019ea03a8 --- /dev/null +++ b/mdl/exprcheck/adapters/adapter_scope_test.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 + +package adapters + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/exprcheck" +) + +func qn(mod, name string) ast.QualifiedName { return ast.QualifiedName{Module: mod, Name: name} } + +// assocStub answers one association, in one direction. +type assocStub struct{ assoc, from, to string } + +func (a assocStub) AssociationTarget(assocQN, fromEntityQN string) (string, bool) { + if assocQN == a.assoc && fromEntityQN == a.from { + return a.to, true + } + return "", false +} + +// TestBuildFlowScopeTypesLoopVariable is the unit-level statement of +// mendixlabs/mxcli#1100: the iterator takes the element type of the list it +// walks, or nothing downstream of it can be typed. +func TestBuildFlowScopeTypesLoopVariable(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.RetrieveStmt{Variable: "reqs", Source: qn("Probe", "Request")}, + &ast.LoopStmt{LoopVariable: "r", ListVariable: "reqs"}, + } + s := buildFlowScope(body, nil, nil) + if got := s.entities["r"]; got != "Probe.Request" { + t.Errorf("loop variable typed %q, want Probe.Request", got) + } +} + +// TestBuildFlowScopeSeedsParametersFirst pins an ordering that is easy to get +// wrong and silent when it is: parameters must be in scope before the body +// walk, because a body statement can be typed FROM one. Appending them +// afterwards left every association retrieve off a parameter untyped, and with +// it every loop over the result. +func TestBuildFlowScopeSeedsParametersFirst(t *testing.T) { + params := []ast.MicroflowParam{ + {Name: "T", Type: ast.DataType{Kind: ast.TypeEntity, EntityRef: &ast.QualifiedName{Module: "Probe", Name: "Request"}}}, + } + body := []ast.MicroflowStatement{ + &ast.RetrieveStmt{Variable: "reps", Source: qn("Probe", "Request_Reporter"), StartVariable: "T"}, + &ast.LoopStmt{LoopVariable: "rep", ListVariable: "reps"}, + } + s := buildFlowScope(body, params, assocStub{"Probe.Request_Reporter", "Probe.Request", "Probe.Reporter"}) + if got := s.entities["reps"]; got != "Probe.Reporter" { + t.Errorf("association retrieve typed %q, want Probe.Reporter", got) + } + if got := s.entities["rep"]; got != "Probe.Reporter" { + t.Errorf("loop over an association retrieve typed %q, want Probe.Reporter", got) + } +} + +// TestBuildFlowScopeTypesDeclaredPrimitives pins the other half of #1100: a +// rule that needs both operands typed (E004) was silent on `$out + ` +// because a locally declared String was Unknown. +func TestBuildFlowScopeTypesDeclaredPrimitives(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.DeclareStmt{Variable: "out", Type: ast.DataType{Kind: ast.TypeString}}, + &ast.DeclareStmt{Variable: "n", Type: ast.DataType{Kind: ast.TypeInteger}}, + &ast.DeclareStmt{Variable: "obj", Type: ast.DataType{Kind: ast.TypeEntity, EntityRef: &ast.QualifiedName{Module: "Probe", Name: "Request"}}}, + } + s := buildFlowScope(body, nil, nil) + if got := s.kinds["out"]; got != exprcheck.KindString { + t.Errorf("declared String typed %v, want KindString", got) + } + if got := s.kinds["n"]; got != exprcheck.KindInteger { + t.Errorf("declared Integer typed %v, want KindInteger", got) + } + // An entity-typed DECLARE belongs to the entity half, not the kind half. + if got := s.entities["obj"]; got != "Probe.Request" { + t.Errorf("declared entity typed %q, want Probe.Request", got) + } + if _, ok := s.kinds["obj"]; ok { + t.Errorf("an entity-typed DECLARE should have no primitive kind") + } +} + +// TestBuildFlowScopeCarriesElementTypeThroughListOperations pins which +// operations preserve the element type and which answer a Boolean instead. A +// loop is only as typed as the list it walks, so a FILTER that lost the entity +// would leave the fix covering one spelling of the same loop. +func TestBuildFlowScopeCarriesElementTypeThroughListOperations(t *testing.T) { + for _, tc := range []struct { + op ast.ListOperationType + wantQN string + wantKind exprcheck.TypeKind + hasEntity bool + }{ + {ast.ListOpFilter, "Probe.Request", exprcheck.KindUnknown, true}, + {ast.ListOpSort, "Probe.Request", exprcheck.KindUnknown, true}, + {ast.ListOpHead, "Probe.Request", exprcheck.KindUnknown, true}, + {ast.ListOpRange, "Probe.Request", exprcheck.KindUnknown, true}, + {ast.ListOpContains, "", exprcheck.KindBoolean, false}, + {ast.ListOpEquals, "", exprcheck.KindBoolean, false}, + } { + body := []ast.MicroflowStatement{ + &ast.RetrieveStmt{Variable: "reqs", Source: qn("Probe", "Request")}, + &ast.ListOperationStmt{OutputVariable: "out", Operation: tc.op, InputVariable: "reqs"}, + } + s := buildFlowScope(body, nil, nil) + if got := s.entities["out"]; got != tc.wantQN { + t.Errorf("%v: entity %q, want %q", tc.op, got, tc.wantQN) + } + if tc.wantKind != exprcheck.KindUnknown { + if got := s.kinds["out"]; got != tc.wantKind { + t.Errorf("%v: kind %v, want %v", tc.op, got, tc.wantKind) + } + } + } +} + +// TestBuildFlowScopeWalksErrorHandlerBodies pins that a variable introduced in +// an ON ERROR handler is typed like any other. The handler's body is ordinary +// statements; leaving it out made moving a statement into one an exemption. +func TestBuildFlowScopeWalksErrorHandlerBodies(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.RetrieveStmt{ + Variable: "reqs", Source: qn("Probe", "Request"), + ErrorHandling: &ast.ErrorHandlingClause{Body: []ast.MicroflowStatement{ + &ast.CreateObjectStmt{Variable: "fallback", EntityType: qn("Probe", "Request")}, + }}, + }, + } + s := buildFlowScope(body, nil, nil) + if got := s.entities["fallback"]; got != "Probe.Request" { + t.Errorf("a variable created in an ON ERROR body typed %q, want Probe.Request", got) + } +} + +// TestBuildFlowScopeLeavesAnUnresolvableLoopAlone is the failure direction. A +// loop over a list nothing typed, or over an association path the AST does not +// record (`LOOP $r IN $T/Mod.Assoc` leaves ListVariable empty), must produce no +// entry — a guess here is a false positive on code that builds. +func TestBuildFlowScopeLeavesAnUnresolvableLoopAlone(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.LoopStmt{LoopVariable: "r", ListVariable: "unknown"}, + &ast.LoopStmt{LoopVariable: "p", ListVariable: ""}, + } + s := buildFlowScope(body, nil, nil) + for _, v := range []string{"r", "p"} { + if qn, ok := s.entities[v]; ok { + t.Errorf("an unresolvable loop variable %q was typed %q", v, qn) + } + } +} + +// TestBuildFlowScopeDoesNotCallAnAmbiguousNameAnEnumeration pins the one place +// a guess would be wrong in a way that shows. `DECLARE $o Mod.Person` parses as +// TypeEnumeration with EnumRef set — the visitor cannot tell it from an +// enumeration (see CLAUDE.md) — so the ENTITY guess is recorded (it resolves no +// attributes if wrong, costing nothing) but the KIND is not, or a rule would +// report an object as an "Enumeration". `ENUM Mod.Status` sets ExplicitEnum and +// is unambiguous, so it does get a kind. +func TestBuildFlowScopeDoesNotCallAnAmbiguousNameAnEnumeration(t *testing.T) { + ambiguous := ast.DataType{Kind: ast.TypeEnumeration, EnumRef: &ast.QualifiedName{Module: "Probe", Name: "Person"}} + explicit := ast.DataType{Kind: ast.TypeEnumeration, EnumRef: &ast.QualifiedName{Module: "Probe", Name: "Status"}, ExplicitEnum: true} + + s := buildFlowScope([]ast.MicroflowStatement{ + &ast.DeclareStmt{Variable: "maybe", Type: ambiguous}, + &ast.DeclareStmt{Variable: "sure", Type: explicit}, + }, nil, nil) + + if got := s.entities["maybe"]; got != "Probe.Person" { + t.Errorf("the entity guess was dropped: %q", got) + } + if k, ok := s.kinds["maybe"]; ok { + t.Errorf("an ambiguous bare name was typed %v; it must have no kind", k) + } + if got := s.kinds["sure"]; got != exprcheck.KindEnumeration { + t.Errorf("an explicit ENUM typed %v, want KindEnumeration", got) + } +} diff --git a/mdl/exprcheck/adapters/check.go b/mdl/exprcheck/adapters/check.go index 13410cdbcd..a2982bcd8e 100644 --- a/mdl/exprcheck/adapters/check.go +++ b/mdl/exprcheck/adapters/check.go @@ -19,8 +19,9 @@ type CheckAdapter struct { // assoc is the catalog when it can also answer association questions; the // interface does not require it, so this is nil for a reader that cannot. assoc associationResolver - // entities is set for the duration of one flow's walk. + // entities and kinds are set for the duration of one flow's walk. entities exprcheck.EntityScope + kinds exprcheck.Scope } // Option configures a CheckAdapter. @@ -95,13 +96,16 @@ func (c *CheckAdapter) CheckNanoflow(stmt *ast.CreateNanoflowStmt) *Result { // The variable→entity map used to be built here and used only to label a // CHANGE's slot path; it was never handed to the checker, so `$obj/Attr` had // nothing to resolve against and every rule that depends on an attribute path -// stayed quiet. It is now also the EntityScope for the whole walk. +// stayed quiet. It is now the EntityScope for the whole walk, and the primitive +// half is the Scope beside it — a locally declared String was Unknown until +// then, and a rule that needs both operands typed (E004) stayed quiet on the +// most ordinary shape in the language (mendixlabs/mxcli#1100). func (c *CheckAdapter) walkFlow(body []ast.MicroflowStatement, params []ast.MicroflowParam, mf string, r *Result) { - scope := buildVarEntityScope(body) - addParamEntities(scope, params) - c.entities = entityScope{vars: scope, assoc: c.assoc} - defer func() { c.entities = nil }() - c.walkBodyWithScope(body, mf, scope, r) + scope := buildFlowScope(body, params, c.assoc) + c.entities = entityScope{vars: scope.entities, assoc: c.assoc} + c.kinds = kindScope(scope.kinds) + defer func() { c.entities, c.kinds = nil, nil }() + c.walkBodyWithScope(body, mf, scope.entities, r) } func (c *CheckAdapter) walkBodyWithScope(body []ast.MicroflowStatement, mf string, scope map[string]string, r *Result) { @@ -116,6 +120,8 @@ func (c *CheckAdapter) walkBodyWithScope(body []ast.MicroflowStatement, mf strin c.walkBodyWithScope(n.Body, mf, scope, r) case *ast.LoopStmt: c.walkBodyWithScope(n.Body, mf, scope, r) + case *ast.ListOperationStmt: + c.checkListOperationCondition(n, mf, scope, r) case *ast.ReturnStmt: c.checkExpr(n.Value, "ReturnStmt.Value", mf, r) case *ast.DeclareStmt: @@ -153,7 +159,59 @@ func (c *CheckAdapter) walkBodyWithScope(body []ast.MicroflowStatement, mf strin c.checkExpr(a.Value, "CallArgument.Value", mf, r) } } + // A custom ON ERROR body is a block of ordinary statements and its + // expressions are as checkable as any other. It was not walked at all, + // so moving a statement into a handler exempted it from every rule. It + // is walked after the statement that carries it, which is when it runs. + if eb := errorHandlerBody(s); eb != nil { + c.walkBodyWithScope(eb, mf, scope, r) + } + } +} + +// checkListOperationCondition checks a FIND/FILTER predicate with +// $currentObject bound to the element type of the list under test. +// +// The predicate is block-scoped in the same way a loop body is: $currentObject +// exists only inside it, and it is named per statement rather than once per +// flow — two FILTERs over different lists in one microflow mean two different +// entities — so the binding is made for the duration of this one expression +// rather than folded into the flow scope. +// +// A BARE attribute name in the predicate (`FILTER($L, Status = 'Open')`, the +// spelling the skills recommend) still resolves to nothing: the parser reads it +// as a variable, not as an attribute of the item. That gap is deliberate here — +// binding bare names to the element entity would change what a bare identifier +// means everywhere in an expression, which is a larger decision than this one. +// +// A KNOWN element entity is the condition for checking at all, not just for +// binding $currentObject. `set $At = find($Hay, $Needle)` is Mendix's STRING +// find, and the visitor still builds it as a ListOperationStmt — the ambiguity +// is resolved later, in the flow builder, by looking at whether the input is a +// declared String (mdl-examples/bug-tests/ledger-63-string-find.mdl). Checking +// its second argument as a predicate reported the needle as a non-Boolean, on a +// script that builds at 0 errors. Requiring the entity applies the same +// disambiguation the builder already makes. +func (c *CheckAdapter) checkListOperationCondition(n *ast.ListOperationStmt, mf string, scope map[string]string, r *Result) { + if n.Condition == nil { + return + } + if n.Operation != ast.ListOpFind && n.Operation != ast.ListOpFilter { + return + } + elem := scope[strings.TrimPrefix(n.InputVariable, "$")] + if elem == "" { + return + } + vars := make(map[string]string, len(scope)+1) + for k, v := range scope { + vars[k] = v } + vars["currentObject"] = elem + saved := c.entities + c.entities = entityScope{vars: vars, assoc: c.assoc} + c.checkExpr(n.Condition, "ListOperation.Condition", mf, r) + c.entities = saved } func (c *CheckAdapter) checkExpr(expr ast.Expression, slot, mf string, r *Result) { @@ -170,6 +228,7 @@ func (c *CheckAdapter) checkExpr(expr ast.Expression, slot, mf string, r *Result Slots: c.slots, Catalog: c.catalog, Entities: c.entities, + Scope: c.kinds, }) r.Hints = append(r.Hints, hints...) } diff --git a/mdl/exprcheck/slot_resolver.go b/mdl/exprcheck/slot_resolver.go index 7a243e489d..05512e33a5 100644 --- a/mdl/exprcheck/slot_resolver.go +++ b/mdl/exprcheck/slot_resolver.go @@ -6,8 +6,12 @@ package exprcheck // Add a new entry whenever a new MDL statement slot is added to the executor. // Slot paths mirror the AST node + field name, e.g. "IfStmt.Condition". var staticExpectations = map[string]SlotConstraint{ - "IfStmt.Condition": {Kind: KindBoolean}, - "WhileStmt.Condition": {Kind: KindBoolean}, + "IfStmt.Condition": {Kind: KindBoolean}, + "WhileStmt.Condition": {Kind: KindBoolean}, + // A FIND/FILTER predicate is a Boolean expression over the item under test, + // the same shape as a WHILE condition. Mendix reports a non-Boolean one as + // CE0117 on the list-operation activity. + "ListOperation.Condition": {Kind: KindBoolean}, "RetrieveStmt.LimitExpr": {Kind: KindInteger}, "RetrieveStmt.OffsetExpr": {Kind: KindInteger}, "ChangeItem.Value": {Kind: KindUnknown, ResolveBy: "AttributeOf:Parent"}, diff --git a/mdl/exprcheck/slot_to_context.go b/mdl/exprcheck/slot_to_context.go index ac9dd32489..03705921f8 100644 --- a/mdl/exprcheck/slot_to_context.go +++ b/mdl/exprcheck/slot_to_context.go @@ -12,6 +12,7 @@ func SlotToContext(slotPath string) string { var slotContext = map[string]string{ "IfStmt.Condition": "IF condition", "WhileStmt.Condition": "WHILE condition", + "ListOperation.Condition": "FIND/FILTER predicate", "ChangeItem.Value": "field of CHANGE", "CreateItem.Value": "field of CREATE", "ReturnStmt.Value": "RETURN value",