From da33a98904f9dab9723ed5898dda1910ecab231b Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 27 Sep 2026 08:21:29 +0000 Subject: [PATCH 01/37] fix(security): keep Studio Pro's list marker on UserRoles and DemoUsers Rewriting a user role or demo user re-encoded Security$ProjectSecurity's UserRoles/DemoUsers lists with marker 3; Studio Pro writes 2 (PedApp, TestApp and expr-checker agree). Running unchanged describe user role output therefore wrote the unit (#731). Co-Authored-By: Claude Opus 5.5 --- mdl/backend/modelsdk/security_markers_test.go | 67 +++++++++++++++++++ mdl/backend/modelsdk/security_write.go | 11 +++ 2 files changed, 78 insertions(+) create mode 100644 mdl/backend/modelsdk/security_markers_test.go diff --git a/mdl/backend/modelsdk/security_markers_test.go b/mdl/backend/modelsdk/security_markers_test.go new file mode 100644 index 000000000..7bba02aa1 --- /dev/null +++ b/mdl/backend/modelsdk/security_markers_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +// Studio Pro writes Security$ProjectSecurity's UserRoles and DemoUsers lists +// with typed-array marker 2 (measured: PedApp 11.13.0, TestApp 11.14.0 and the +// expr-checker fixture all agree). The encoder's default is 3, so rewriting the +// document for `create or modify user role` with an unchanged definition, which +// is what running `describe user role` output does, changed the marker and +// wrote the unit (#731). The control re-reads the untouched document. +func TestProjectSecurity_UserRoleAndDemoUserListMarkers(t *testing.T) { + proj := copyFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + + ps, err := b.GetProjectSecurity() + if err != nil { + t.Fatalf("GetProjectSecurity: %v", err) + } + if len(ps.UserRoles) == 0 { + t.Fatal("fixture has no user roles") + } + markers := func() (int32, int32) { + raw, err := b.GetRawUnitBytes(ps.ID) + if err != nil { + t.Fatalf("GetRawUnitBytes: %v", err) + } + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return markerOf(t, d, "UserRoles"), markerOf(t, d, "DemoUsers") + } + + // Control: the Studio Pro-authored document carries marker 2 for both. + if u, d := markers(); u != 2 || d != 2 { + t.Fatalf("fixture markers UserRoles=%d DemoUsers=%d; want 2 and 2 — the control does not hold", u, d) + } + + ur := ps.UserRoles[0] + if err := b.AddUserRole(ps.ID, ur.Name, append([]string{}, ur.ModuleRoles...), !ur.ManageAllRoles); err != nil { + t.Fatalf("AddUserRole: %v", err) + } + if u, d := markers(); u != 2 || d != 2 { + t.Errorf("after rewriting user role %s: markers UserRoles=%d DemoUsers=%d; want 2 and 2", ur.Name, u, d) + } + + if len(ps.DemoUsers) == 0 { + t.Fatal("fixture has no demo users") + } + du := ps.DemoUsers[0] + if err := b.AddDemoUser(ps.ID, du.UserName, du.Password+"x", du.Entity, append([]string{}, du.UserRoles...)); err != nil { + t.Fatalf("AddDemoUser: %v", err) + } + if u, d := markers(); u != 2 || d != 2 { + t.Errorf("after rewriting demo user %s: markers UserRoles=%d DemoUsers=%d; want 2 and 2", du.UserName, u, d) + } +} diff --git a/mdl/backend/modelsdk/security_write.go b/mdl/backend/modelsdk/security_write.go index 522520d26..102cee7c6 100644 --- a/mdl/backend/modelsdk/security_write.go +++ b/mdl/backend/modelsdk/security_write.go @@ -12,6 +12,17 @@ import ( genSec "github.com/mendixlabs/mxcli/modelsdk/gen/security" ) +// Studio Pro writes Security$ProjectSecurity's UserRoles and DemoUsers lists +// with typed-array marker 2, not the encoder's default 3 (measured on PedApp +// 11.13.0, TestApp 11.14.0 and the expr-checker fixture: every document +// agrees). Without these, rewriting a user role or demo user with an unchanged +// definition changed the marker, so running `describe user role` output wrote +// the unit (#731). +func init() { + codec.RegisterPropertyListMarker("Security$ProjectSecurity", "UserRoles", 2) + codec.RegisterPropertyListMarker("Security$ProjectSecurity", "DemoUsers", 2) +} + // loadModuleSecurityGen decodes a Security$ModuleSecurity unit by ID. func (b *Backend) loadModuleSecurityGen(unitID model.ID) (*genSec.ModuleSecurity, error) { raw, err := b.reader.GetRawUnitBytes(string(unitID)) From bcdf34fcc89df734960a26c2fe350d3ffe69dcf1 Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 27 Sep 2026 08:22:08 +0000 Subject: [PATCH 02/37] fix(javascript action): a rewrite carries what describe cannot print With describe emitting create or modify, its output re-executes. Two things it cannot express were lost on that rewrite, as the Java twin already handled: - the placeholder body printed when the .js source is unreadable was written as the action's source; - parameter Description/Category (printed as a comment) were dropped. Co-Authored-By: Claude Opus 5.5 --- mdl/executor/cmd_javascript_actions.go | 7 +- .../cmd_javascript_actions_mock_test.go | 99 +++++++++++++++++++ mdl/executor/cmd_javascript_actions_write.go | 29 +++++- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/mdl/executor/cmd_javascript_actions.go b/mdl/executor/cmd_javascript_actions.go index b14eacfb3..6bf5e2670 100644 --- a/mdl/executor/cmd_javascript_actions.go +++ b/mdl/executor/cmd_javascript_actions.go @@ -64,6 +64,11 @@ func listJavaScriptActions(ctx *ExecContext, moduleName string) error { return writeResult(ctx, result) } +// jsSourceOmittedBody is the body DESCRIBE prints for an action whose .js source +// it cannot read. exec recognises it and writes no source file (#731), as it +// does for the Java twin, javaSourceOmittedBody. +const jsSourceOmittedBody = "// JavaScript source not available from this project; body omitted by DESCRIBE." + // describeJavaScriptAction handles DESCRIBE JAVASCRIPT ACTION command. func describeJavaScriptAction(ctx *ExecContext, name ast.QualifiedName) error { qualifiedName := name.Module + "." + name.Name @@ -176,7 +181,7 @@ func describeJavaScriptAction(ctx *ExecContext, name ast.QualifiedName) error { if userCode != "" { sb.WriteString(userCode) } else { - sb.WriteString("// JavaScript source not available from this project; body omitted by DESCRIBE.") + sb.WriteString(jsSourceOmittedBody) } sb.WriteString("\n$$;") diff --git a/mdl/executor/cmd_javascript_actions_mock_test.go b/mdl/executor/cmd_javascript_actions_mock_test.go index f7d7dc0d1..9667d38fe 100644 --- a/mdl/executor/cmd_javascript_actions_mock_test.go +++ b/mdl/executor/cmd_javascript_actions_mock_test.go @@ -170,3 +170,102 @@ func TestCreateJavaScriptAction_DuplicateRejected(t *testing.T) { assertError(t, err) assertContainsStr(t, err.Error(), "already exists") } + +// With describe emitting `create or modify javascript action`, its output now +// re-executes (#731). Where describe could not read the .js source it prints a +// placeholder body; that is not code, and writing it would replace the real +// source (or add a stub file to a project that ships none). The Java twin is +// TestJavaActionBody_PlaceholderIsNotWritten. +func TestJavaScriptActionBody_PlaceholderIsNotWritten(t *testing.T) { + mod := mkModule("WebMod") + h := mkHierarchy(mod) + wrote := false + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListJavaScriptActionsFunc: func() ([]*types.JavaScriptAction, error) { return nil, nil }, + CreateJavaScriptActionFunc: func(*types.JavaScriptAction) error { return nil }, + WriteJavaScriptSourceFileFunc: func(string, string, string, []*types.JavaActionParameter, types.CodeActionReturnType) error { + wrote = true + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + stmt := func(code string) *ast.CreateJavaScriptActionStmt { + return &ast.CreateJavaScriptActionStmt{ + Name: ast.QualifiedName{Module: "WebMod", Name: "DoThing"}, + ReturnType: ast.DataType{Kind: ast.TypeBoolean}, + JavaScriptCode: code, + } + } + + assertNoError(t, execCreateJavaScriptAction(ctx, stmt("\n"+jsSourceOmittedBody+"\n"))) + if wrote { + t.Error("the DESCRIBE placeholder body was written as the action's JavaScript source") + } + + // Control: an authored body is still written. + assertNoError(t, execCreateJavaScriptAction(ctx, stmt("return Promise.resolve(true);"))) + if !wrote { + t.Error("an authored body was not written — the guard is too broad") + } +} + +// A parameter's Description and Category have no MDL spelling — describe +// prints the description as a `--` comment — so `create or modify` carries them +// from the stored parameter of the same name, as the Java twin does. Dropping +// them made running unchanged describe output rewrite the action (#731). +func TestJavaScriptActionRewrite_CarriesParameterDescription(t *testing.T) { + mod := mkModule("WebMod") + h := mkHierarchy(mod) + stored := &types.JavaScriptAction{ + BaseElement: model.BaseElement{ID: nextID("jsa")}, + ContainerID: mod.ID, + Name: "Wait", + Parameters: []*types.JavaActionParameter{{ + Name: "Delay", + Description: "The number of milliseconds to wait.", + Category: "Timing", + IsRequired: true, + ParameterType: &types.IntegerType{}, + }}, + } + withContainer(h, stored.ContainerID, mod.ID) + var captured *types.JavaScriptAction + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListJavaScriptActionsFunc: func() ([]*types.JavaScriptAction, error) { return []*types.JavaScriptAction{stored}, nil }, + ReadJavaScriptActionByNameFunc: func(string) (*types.JavaScriptAction, error) { + return stored, nil + }, + UpdateJavaScriptActionFunc: func(jsa *types.JavaScriptAction) error { + captured = jsa + return nil + }, + WriteJavaScriptSourceFileFunc: func(string, string, string, []*types.JavaActionParameter, types.CodeActionReturnType) error { + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, execCreateJavaScriptAction(ctx, &ast.CreateJavaScriptActionStmt{ + Name: ast.QualifiedName{Module: "WebMod", Name: "Wait"}, + CreateOrModify: true, + Parameters: []ast.JavaActionParam{ + {Name: "Delay", Type: ast.DataType{Kind: ast.TypeInteger}, IsRequired: true}, + {Name: "Fresh", Type: ast.DataType{Kind: ast.TypeString}}, + }, + ReturnType: ast.DataType{Kind: ast.TypeVoid}, + JavaScriptCode: "\n" + jsSourceOmittedBody + "\n", + })) + if captured == nil || len(captured.Parameters) != 2 { + t.Fatalf("UpdateJavaScriptAction not called with two parameters: %+v", captured) + } + if p := captured.Parameters[0]; p.Description != "The number of milliseconds to wait." || p.Category != "Timing" { + t.Errorf("Delay: description %q, category %q — not carried from the stored parameter", p.Description, p.Category) + } + // Control: a parameter with no stored twin gets none. + if p := captured.Parameters[1]; p.Description != "" || p.Category != "" { + t.Errorf("Fresh: description %q, category %q — a new parameter inherited a description", p.Description, p.Category) + } +} diff --git a/mdl/executor/cmd_javascript_actions_write.go b/mdl/executor/cmd_javascript_actions_write.go index a3ff3102f..098dc2e12 100644 --- a/mdl/executor/cmd_javascript_actions_write.go +++ b/mdl/executor/cmd_javascript_actions_write.go @@ -5,6 +5,7 @@ package executor import ( "fmt" + "strings" "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" @@ -54,6 +55,10 @@ func execCreateJavaScriptAction(ctx *ExecContext, s *ast.CreateJavaScriptActionS var existingActionInfo *types.MicroflowActionInfo var existingJSDoc string haveExistingJS := false + // A parameter's Description and Category have no MDL spelling — DESCRIBE + // prints the description as a comment — so a rewrite carries them from the + // stored parameter of the same name, as the Java twin does (#731). + storedParams := map[string]*types.JavaActionParameter{} // No MDL spelling for either, so a rewrite carries them; the defaults are // Studio Pro's. "Public", which this hardcoded, is not a member of // JavaScriptActionsExportLevel (API | Hidden) — see the Java twin. @@ -79,6 +84,16 @@ func execCreateJavaScriptAction(ctx *ExecContext, s *ast.CreateJavaScriptActionS exportLevel = ex.ExportLevel } defaultReturnName = ex.ActionDefaultReturnName + // The list read may not carry parameter details; the full read does. + full := ex + if r, err := ctx.Backend.ReadJavaScriptActionByName(s.Name.Module + "." + s.Name.Name); err == nil && r != nil { + full = r + } + for _, p := range full.Parameters { + if p != nil { + storedParams[p.Name] = p + } + } } moduleID := containerID @@ -147,6 +162,10 @@ func execCreateJavaScriptAction(ctx *ExecContext, s *ast.CreateJavaScriptActionS default: p.ParameterType = astDataTypeToJavaActionParamType(param.Type) } + if sp := storedParams[param.Name]; sp != nil { + p.Description = sp.Description + p.Category = sp.Category + } jsa.Parameters = append(jsa.Parameters, p) } @@ -180,8 +199,14 @@ func execCreateJavaScriptAction(ctx *ExecContext, s *ast.CreateJavaScriptActionS } } - if err := ctx.Backend.WriteJavaScriptSourceFile(moduleName, s.Name.Name, s.JavaScriptCode, jsa.Parameters, jsa.ReturnType); err != nil { - return mdlerrors.NewBackend("write javascript source file", err) + // The placeholder DESCRIBE prints when it could not read the source is not + // code: writing it would overwrite the real implementation, or add a stub + // file to a project that ships none. The statement says nothing about the + // code, so the file is left alone (#731; the Java twin is #637). + if strings.TrimSpace(s.JavaScriptCode) != jsSourceOmittedBody { + if err := ctx.Backend.WriteJavaScriptSourceFile(moduleName, s.Name.Name, s.JavaScriptCode, jsa.Parameters, jsa.ReturnType); err != nil { + return mdlerrors.NewBackend("write javascript source file", err) + } } ctx.InvalidateCache() From 24053f2ce616136ba296b67f93b8dd3fa8f3b37d Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 27 Sep 2026 08:22:08 +0000 Subject: [PATCH 03/37] fix(executor): a doc comment in normal form keeps the stored documentation A /** */ comment cannot spell blank lines, trailing whitespace or CRLF, so describe output of Studio Pro prose restated it in normal form and the rewrite overwrote the stored bytes with nothing changed. When the stated text is exactly the stored text's normal form, keep the stored bytes. Co-Authored-By: Claude Opus 5.5 --- mdl/executor/documentation_carry.go | 23 +++++++++++++++ mdl/executor/documentation_carry_test.go | 37 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 mdl/executor/documentation_carry_test.go diff --git a/mdl/executor/documentation_carry.go b/mdl/executor/documentation_carry.go index 0c154d5ef..879f5b85b 100644 --- a/mdl/executor/documentation_carry.go +++ b/mdl/executor/documentation_carry.go @@ -2,6 +2,8 @@ package executor +import "strings" + // carriedDocumentation returns the documentation a rewrite should store. // // A statement that carried a `/** … */` comment — even an empty one — states its @@ -18,9 +20,30 @@ package executor // only in the domain-model grammar — there is no ALTER for a microflow, a queue // or a workflow — so an explicitly empty comment is the only clearing spelling // most doctypes have. +// +// A stated comment that is exactly the stored text's doc-comment normal form +// (see docCommentNormalForm) says the same thing in the only spelling MDL has, +// so the stored bytes are kept: overwriting them rewrote the document when +// describe output was run unchanged (#731). func carriedDocumentation(set bool, stated, stored string) string { if set { + if stated != stored && stated == docCommentNormalForm(stored) { + return stored + } return stated } return stored } + +// docCommentNormalForm is what a `/** … */` comment can carry of text: the +// visitor keeps only its trimmed, non-empty lines (extractDocComment), so blank +// lines, trailing whitespace and CRLF line ends have no spelling. +func docCommentNormalForm(s string) string { + var kept []string + for line := range strings.SplitSeq(strings.ReplaceAll(s, "\r\n", "\n"), "\n") { + if t := strings.TrimSpace(line); t != "" { + kept = append(kept, t) + } + } + return strings.Join(kept, "\n") +} diff --git a/mdl/executor/documentation_carry_test.go b/mdl/executor/documentation_carry_test.go new file mode 100644 index 000000000..d0ba62b2a --- /dev/null +++ b/mdl/executor/documentation_carry_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import "testing" + +// A doc comment cannot spell a blank line, trailing whitespace or a CRLF: the +// visitor keeps only the trimmed, non-empty lines. So describe output of +// Studio Pro prose ("line\r\n\r\nline \r\n") re-executed as `create or modify` +// states the same text in that normal form, and overwriting the stored value +// with it rewrote the document with nothing changed (#731: 7 JavaScript actions +// in PedApp). When the stated text is exactly the stored text's normal form, the +// stored bytes are kept; any real edit still wins. +func TestCarriedDocumentation_KeepsStoredWhenOnlyTheSpellingDiffers(t *testing.T) { + stored := "What does this JavaScript action do?\r\n\r\nWhen you upload a screenshot manually. \r\n\r\nReturn Type:\r\nWill return base 64 image string\r\n" + cases := []struct { + name string + set bool + stated string + want string + }{ + {"no comment keeps stored", false, "", stored}, + {"normal form keeps stored bytes", true, + "What does this JavaScript action do?\nWhen you upload a screenshot manually.\nReturn Type:\nWill return base 64 image string", + stored}, + // Controls: a real change is written, and an empty comment still clears. + {"edited text wins", true, "What does this JavaScript action do?\nSomething else.", "What does this JavaScript action do?\nSomething else."}, + {"empty comment clears", true, "", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := carriedDocumentation(c.set, c.stated, stored); got != c.want { + t.Errorf("carriedDocumentation = %q, want %q", got, c.want) + } + }) + } +} From ebcebc6e4c004119250188d54e97388d176368f1 Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 27 Sep 2026 08:24:49 +0000 Subject: [PATCH 04/37] feat(describe): emit create or modify for every document type (#731) describe printed a plain create for JavaScript actions, user roles, layouts, REST/OData clients, OData services, external entities, database connections, data transformers, agent-editor documents, workflows, validation rules and modules, and create or replace (the deprecated spelling) for navigation. A plain create fails with "already exists" on the document it describes, so describe output did not run unchanged. Strikes the #721 F class (45 JavaScript actions, 2 user roles) from the round-trip allowlist, plus the putget law of Administration.NewWebServiceAccount, fixed by the doc-comment carry. Co-Authored-By: Claude Opus 5.5 --- mdl/executor/cmd_agenteditor_agents.go | 2 +- mdl/executor/cmd_agenteditor_kbs.go | 2 +- mdl/executor/cmd_agenteditor_mcpservices.go | 2 +- mdl/executor/cmd_agenteditor_mock_test.go | 8 +- mdl/executor/cmd_agenteditor_models.go | 2 +- mdl/executor/cmd_datatransformer.go | 2 +- mdl/executor/cmd_datatransformer_mock_test.go | 2 +- mdl/executor/cmd_dbconnection.go | 2 +- mdl/executor/cmd_dbconnection_mock_test.go | 2 +- mdl/executor/cmd_javascript_actions.go | 2 +- .../cmd_javascript_actions_mock_test.go | 2 +- mdl/executor/cmd_misc.go | 2 +- mdl/executor/cmd_modules.go | 2 +- mdl/executor/cmd_modules_security_test.go | 2 +- mdl/executor/cmd_navigation.go | 2 +- mdl/executor/cmd_navigation_mock_test.go | 2 +- mdl/executor/cmd_odata.go | 6 +- mdl/executor/cmd_odata_mock_test.go | 4 +- mdl/executor/cmd_pages_describe.go | 2 +- mdl/executor/cmd_rest_clients.go | 2 +- mdl/executor/cmd_rest_clients_mock_test.go | 2 +- mdl/executor/cmd_rest_openapi_mock_test.go | 2 +- mdl/executor/cmd_security.go | 2 +- mdl/executor/cmd_security_mock_test.go | 2 +- mdl/executor/cmd_validationrules.go | 2 +- mdl/executor/cmd_workflows.go | 2 +- mdl/executor/cmd_workflows_mock_test.go | 2 +- .../describe_create_or_modify_test.go | 74 ++++++++++++++++++- mdl/roundtrip/allowlist_test.go | 49 +----------- 29 files changed, 106 insertions(+), 83 deletions(-) diff --git a/mdl/executor/cmd_agenteditor_agents.go b/mdl/executor/cmd_agenteditor_agents.go index 008c51e0a..8722638a7 100644 --- a/mdl/executor/cmd_agenteditor_agents.go +++ b/mdl/executor/cmd_agenteditor_agents.go @@ -85,7 +85,7 @@ func describeAgentEditorAgent(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprintf(ctx.Output, "/**\n * %s\n */\n", a.Documentation) } - fmt.Fprintf(ctx.Output, "create agent %s%s (\n", qualifiedName, describeFolderClause(ctx, a.ContainerID)) + fmt.Fprintf(ctx.Output, "create or modify agent %s%s (\n", qualifiedName, describeFolderClause(ctx, a.ContainerID)) // Build property lines. User-set properties are emitted in a stable // order; empty values are omitted. diff --git a/mdl/executor/cmd_agenteditor_kbs.go b/mdl/executor/cmd_agenteditor_kbs.go index f2c9bbf2f..b69ecab23 100644 --- a/mdl/executor/cmd_agenteditor_kbs.go +++ b/mdl/executor/cmd_agenteditor_kbs.go @@ -83,7 +83,7 @@ func describeAgentEditorKnowledgeBase(ctx *ExecContext, name ast.QualifiedName) fmt.Fprintf(ctx.Output, "/**\n * %s\n */\n", k.Documentation) } - fmt.Fprintf(ctx.Output, "create knowledge base %s%s (\n", qualifiedName, describeFolderClause(ctx, k.ContainerID)) + fmt.Fprintf(ctx.Output, "create or modify knowledge base %s%s (\n", qualifiedName, describeFolderClause(ctx, k.ContainerID)) var lines []string if k.Provider != "" { diff --git a/mdl/executor/cmd_agenteditor_mcpservices.go b/mdl/executor/cmd_agenteditor_mcpservices.go index eb9b159c1..28afba922 100644 --- a/mdl/executor/cmd_agenteditor_mcpservices.go +++ b/mdl/executor/cmd_agenteditor_mcpservices.go @@ -79,7 +79,7 @@ func describeAgentEditorConsumedMCPService(ctx *ExecContext, name ast.QualifiedN fmt.Fprintf(ctx.Output, "/**\n * %s\n */\n", c.Documentation) } - fmt.Fprintf(ctx.Output, "create consumed mcp service %s%s (\n", qualifiedName, describeFolderClause(ctx, c.ContainerID)) + fmt.Fprintf(ctx.Output, "create or modify consumed mcp service %s%s (\n", qualifiedName, describeFolderClause(ctx, c.ContainerID)) var lines []string if c.ProtocolVersion != "" { diff --git a/mdl/executor/cmd_agenteditor_mock_test.go b/mdl/executor/cmd_agenteditor_mock_test.go index 2ac1af137..a2cb58ab9 100644 --- a/mdl/executor/cmd_agenteditor_mock_test.go +++ b/mdl/executor/cmd_agenteditor_mock_test.go @@ -344,7 +344,7 @@ func TestDescribeAgentEditorModel_Mock(t *testing.T) { assertNoError(t, describeAgentEditorModel(ctx, ast.QualifiedName{Module: "M", Name: "GPT4"})) out := buf.String() - assertContainsStr(t, out, "create model") + assertContainsStr(t, out, "create or modify model") assertContainsStr(t, out, "Provider") assertContainsStr(t, out, "Key") } @@ -405,7 +405,7 @@ func TestDescribeAgentEditorAgent_Mock(t *testing.T) { assertNoError(t, describeAgentEditorAgent(ctx, ast.QualifiedName{Module: "M", Name: "MyAgent"})) out := buf.String() - assertContainsStr(t, out, "create agent") + assertContainsStr(t, out, "create or modify agent") assertContainsStr(t, out, "UsageType") assertContainsStr(t, out, "Model") } @@ -465,7 +465,7 @@ func TestDescribeAgentEditorKnowledgeBase_Mock(t *testing.T) { assertNoError(t, describeAgentEditorKnowledgeBase(ctx, ast.QualifiedName{Module: "M", Name: "MyKB"})) out := buf.String() - assertContainsStr(t, out, "create knowledge base") + assertContainsStr(t, out, "create or modify knowledge base") assertContainsStr(t, out, "Provider") } @@ -524,7 +524,7 @@ func TestDescribeAgentEditorConsumedMCPService_Mock(t *testing.T) { assertNoError(t, describeAgentEditorConsumedMCPService(ctx, ast.QualifiedName{Module: "M", Name: "MySvc"})) out := buf.String() - assertContainsStr(t, out, "create consumed mcp service") + assertContainsStr(t, out, "create or modify consumed mcp service") // ProtocolVersion with a date value must be quoted so the output is re-parseable (issue #435) assertContainsStr(t, out, "ProtocolVersion: '2025-03-26'") diff --git a/mdl/executor/cmd_agenteditor_models.go b/mdl/executor/cmd_agenteditor_models.go index 743937a0d..dad7ce463 100644 --- a/mdl/executor/cmd_agenteditor_models.go +++ b/mdl/executor/cmd_agenteditor_models.go @@ -87,7 +87,7 @@ func describeAgentEditorModel(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprintf(ctx.Output, "/**\n * %s\n */\n", m.Documentation) } - fmt.Fprintf(ctx.Output, "create model %s%s (\n", qualifiedName, describeFolderClause(ctx, m.ContainerID)) + fmt.Fprintf(ctx.Output, "create or modify model %s%s (\n", qualifiedName, describeFolderClause(ctx, m.ContainerID)) // Emit properties in stable order. User-set properties (Provider, Key) // come first; Portal-populated metadata comes last and only if non-empty. diff --git a/mdl/executor/cmd_datatransformer.go b/mdl/executor/cmd_datatransformer.go index 913d08572..cd20343f5 100644 --- a/mdl/executor/cmd_datatransformer.go +++ b/mdl/executor/cmd_datatransformer.go @@ -76,7 +76,7 @@ func describeDataTransformer(ctx *ExecContext, name ast.QualifiedName) error { w := ctx.Output // Emit re-executable MDL - fmt.Fprintf(w, "create data transformer %s.%s%s\n", modName, dt.Name, describeFolderClause(ctx, dt.ContainerID)) + fmt.Fprintf(w, "create or modify data transformer %s.%s%s\n", modName, dt.Name, describeFolderClause(ctx, dt.ContainerID)) // Source — collapse newlines into spaces for single-line string sourceContent := strings.ReplaceAll(dt.SourceJSON, "\n", " ") diff --git a/mdl/executor/cmd_datatransformer_mock_test.go b/mdl/executor/cmd_datatransformer_mock_test.go index 696152b84..55ea1931c 100644 --- a/mdl/executor/cmd_datatransformer_mock_test.go +++ b/mdl/executor/cmd_datatransformer_mock_test.go @@ -103,7 +103,7 @@ func TestDescribeDataTransformer_Mock(t *testing.T) { assertNoError(t, describeDataTransformer(ctx, ast.QualifiedName{Module: "ETL", Name: "TransformOrders"})) out := buf.String() - assertContainsStr(t, out, "create data transformer") + assertContainsStr(t, out, "create or modify data transformer") } func TestCreateDataTransformer_OrModify_PreservesID(t *testing.T) { diff --git a/mdl/executor/cmd_dbconnection.go b/mdl/executor/cmd_dbconnection.go index 78b67b6e2..f64fdac37 100644 --- a/mdl/executor/cmd_dbconnection.go +++ b/mdl/executor/cmd_dbconnection.go @@ -237,7 +237,7 @@ func describeDatabaseConnection(ctx *ExecContext, name ast.QualifiedName) error // outputDatabaseConnectionMDL outputs a database connection definition in MDL format. func outputDatabaseConnectionMDL(ctx *ExecContext, conn *model.DatabaseConnection, moduleName string) error { - fmt.Fprintf(ctx.Output, "create database connection %s.%s%s\n", moduleName, conn.Name, describeFolderClause(ctx, conn.ContainerID)) + fmt.Fprintf(ctx.Output, "create or modify database connection %s.%s%s\n", moduleName, conn.Name, describeFolderClause(ctx, conn.ContainerID)) fmt.Fprintf(ctx.Output, "type '%s'\n", conn.DatabaseType) // Connection string diff --git a/mdl/executor/cmd_dbconnection_mock_test.go b/mdl/executor/cmd_dbconnection_mock_test.go index 03b81e676..ec1314608 100644 --- a/mdl/executor/cmd_dbconnection_mock_test.go +++ b/mdl/executor/cmd_dbconnection_mock_test.go @@ -102,5 +102,5 @@ func TestDescribeDatabaseConnection_Mock(t *testing.T) { assertNoError(t, describeDatabaseConnection(ctx, ast.QualifiedName{Module: "DataMod", Name: "MyDB"})) out := buf.String() - assertContainsStr(t, out, "create database connection") + assertContainsStr(t, out, "create or modify database connection") } diff --git a/mdl/executor/cmd_javascript_actions.go b/mdl/executor/cmd_javascript_actions.go index 6bf5e2670..c771659cf 100644 --- a/mdl/executor/cmd_javascript_actions.go +++ b/mdl/executor/cmd_javascript_actions.go @@ -93,7 +93,7 @@ func describeJavaScriptAction(ctx *ExecContext, name ast.QualifiedName) error { } // Type parameters - sb.WriteString("create javascript action ") + sb.WriteString("create or modify javascript action ") sb.WriteString(qualifiedName) if len(jsa.TypeParameters) > 0 { sb.WriteString("<") diff --git a/mdl/executor/cmd_javascript_actions_mock_test.go b/mdl/executor/cmd_javascript_actions_mock_test.go index 9667d38fe..8b7e10170 100644 --- a/mdl/executor/cmd_javascript_actions_mock_test.go +++ b/mdl/executor/cmd_javascript_actions_mock_test.go @@ -56,7 +56,7 @@ func TestDescribeJavaScriptAction_Mock(t *testing.T) { assertNoError(t, describeJavaScriptAction(ctx, ast.QualifiedName{Module: "WebMod", Name: "ShowAlert"})) out := buf.String() - assertContainsStr(t, out, "create javascript action") + assertContainsStr(t, out, "create or modify javascript action") } func TestDescribeJavaScriptAction_NotFound(t *testing.T) { diff --git a/mdl/executor/cmd_misc.go b/mdl/executor/cmd_misc.go index 9c36cba2b..346384f52 100644 --- a/mdl/executor/cmd_misc.go +++ b/mdl/executor/cmd_misc.go @@ -271,7 +271,7 @@ Navigation: show navigation menu [Profile]; show navigation homes; describe navigation Profile; - create or replace navigation Profile + create or modify navigation Profile home page Module.Page [home page Module.Page for UserRole] [login page Module.Page] diff --git a/mdl/executor/cmd_modules.go b/mdl/executor/cmd_modules.go index 5a84ce892..4d3c466d9 100644 --- a/mdl/executor/cmd_modules.go +++ b/mdl/executor/cmd_modules.go @@ -612,7 +612,7 @@ func describeModule(ctx *ExecContext, moduleName string, withAll bool) error { } // Output basic CREATE MODULE statement - fmt.Fprintf(ctx.Output, "create module %s;\n", targetModule.Name) + fmt.Fprintf(ctx.Output, "create or modify module %s;\n", targetModule.Name) // Module roles live in the module's own Security$ModuleSecurity unit rather // than in any document, so a sweep that describes every document in a module diff --git a/mdl/executor/cmd_modules_security_test.go b/mdl/executor/cmd_modules_security_test.go index 1ee2928a9..af2b278f5 100644 --- a/mdl/executor/cmd_modules_security_test.go +++ b/mdl/executor/cmd_modules_security_test.go @@ -81,5 +81,5 @@ func TestDescribeModule_SurvivesUnreadableSecurity(t *testing.T) { ctx, buf := newMockCtx(t, withBackend(mb)) assertNoError(t, describeModule(ctx, "Administration", false)) - assertContainsStr(t, buf.String(), "create module Administration;") + assertContainsStr(t, buf.String(), "create or modify module Administration;") } diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index 1052d87b3..02f929b00 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -314,7 +314,7 @@ func outputNavigationProfile(ctx *ExecContext, p *types.NavigationProfile) { fmt.Fprintf(ctx.Output, "-- Native: Yes\n") } - fmt.Fprintf(ctx.Output, "create or replace navigation %s\n", p.Name) + fmt.Fprintf(ctx.Output, "create or modify navigation %s\n", p.Name) // Home page if p.HomePage != nil { diff --git a/mdl/executor/cmd_navigation_mock_test.go b/mdl/executor/cmd_navigation_mock_test.go index a25d98d81..806b3d77a 100644 --- a/mdl/executor/cmd_navigation_mock_test.go +++ b/mdl/executor/cmd_navigation_mock_test.go @@ -88,7 +88,7 @@ func TestDescribeNavigation_Mock(t *testing.T) { } ctx, buf := newMockCtx(t, withBackend(mb)) assertNoError(t, describeNavigation(ctx, ast.QualifiedName{Name: "Responsive"})) - assertContainsStr(t, buf.String(), "create or replace navigation") + assertContainsStr(t, buf.String(), "create or modify navigation") } func TestDescribeNavigation_NotFound(t *testing.T) { diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index 6040e4c6a..873b7a05e 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -134,7 +134,7 @@ func outputConsumedODataServiceMDL(ctx *ExecContext, svc *model.ConsumedODataSer outputJavadoc(ctx.Output, svc.Description) } - fmt.Fprintf(ctx.Output, "create odata client %s.%s (\n", moduleName, svc.Name) + fmt.Fprintf(ctx.Output, "create or modify odata client %s.%s (\n", moduleName, svc.Name) var props []string if folderPath != "" { @@ -328,7 +328,7 @@ func outputPublishedODataServiceMDL(ctx *ExecContext, svc *model.PublishedODataS outputJavadoc(ctx.Output, svc.Description) } - fmt.Fprintf(ctx.Output, "create odata service %s.%s (\n", moduleName, svc.Name) + fmt.Fprintf(ctx.Output, "create or modify odata service %s.%s (\n", moduleName, svc.Name) var props []string if folderPath != "" { @@ -757,7 +757,7 @@ func outputExternalEntityMDL(ctx *ExecContext, entity *domainmodel.Entity, modul outputJavadoc(ctx.Output, entity.Documentation) } - fmt.Fprintf(ctx.Output, "create external entity %s.%s\n", moduleName, entity.Name) + fmt.Fprintf(ctx.Output, "create or modify external entity %s.%s\n", moduleName, entity.Name) fmt.Fprintf(ctx.Output, "from odata client %s\n", entity.RemoteServiceName) fmt.Fprintln(ctx.Output, "(") diff --git a/mdl/executor/cmd_odata_mock_test.go b/mdl/executor/cmd_odata_mock_test.go index dd2214b15..03b135d50 100644 --- a/mdl/executor/cmd_odata_mock_test.go +++ b/mdl/executor/cmd_odata_mock_test.go @@ -95,7 +95,7 @@ func TestDescribeODataClient_Mock(t *testing.T) { assertNoError(t, describeODataClient(ctx, ast.QualifiedName{Module: "MyModule", Name: "PetStoreClient"})) out := buf.String() - assertContainsStr(t, out, "create odata client") + assertContainsStr(t, out, "create or modify odata client") assertContainsStr(t, out, "MyModule.PetStoreClient") assertContainsStr(t, out, "https://example.com/$metadata") assertContainsStr(t, out, "2.0") @@ -191,7 +191,7 @@ func TestDescribeODataService_Mock(t *testing.T) { assertNoError(t, describeODataService(ctx, ast.QualifiedName{Module: "MyModule", Name: "CatalogService"})) out := buf.String() - assertContainsStr(t, out, "create odata service") + assertContainsStr(t, out, "create or modify odata service") assertContainsStr(t, out, "MyModule.CatalogService") } diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 05b519b0d..ff4ff39f4 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -366,7 +366,7 @@ func describeLayout(ctx *ExecContext, name ast.QualifiedName) error { if foundLayout.Class != "" { header += fmt.Sprintf(",\n class: %s", mdlQuote(foundLayout.Class)) } - fmt.Fprintf(ctx.Output, "create layout %s.%s (\n%s\n) {\n", + fmt.Fprintf(ctx.Output, "create or modify layout %s.%s (\n%s\n) {\n", modName, mdlIdent(foundLayout.Name), header) for _, w := range getLayoutWidgetsFromRaw(ctx, foundLayout.ID) { diff --git a/mdl/executor/cmd_rest_clients.go b/mdl/executor/cmd_rest_clients.go index 1075b6334..5ad7f3925 100644 --- a/mdl/executor/cmd_rest_clients.go +++ b/mdl/executor/cmd_rest_clients.go @@ -121,7 +121,7 @@ func outputConsumedRestServiceMDL(ctx *ExecContext, svc *model.ConsumedRestServi outputJavadoc(w, svc.Documentation) } - fmt.Fprintf(w, "create rest client %s.%s (\n", moduleName, svc.Name) + fmt.Fprintf(w, "create or modify rest client %s.%s (\n", moduleName, svc.Name) fmt.Fprintf(w, " BaseUrl: %s,\n", mdlQuoted(svc.BaseUrl)) if svc.Authentication == nil { fmt.Fprintln(w, " Authentication: none") diff --git a/mdl/executor/cmd_rest_clients_mock_test.go b/mdl/executor/cmd_rest_clients_mock_test.go index 3c58332e0..b29721220 100644 --- a/mdl/executor/cmd_rest_clients_mock_test.go +++ b/mdl/executor/cmd_rest_clients_mock_test.go @@ -59,7 +59,7 @@ func TestDescribeRestClient_Mock(t *testing.T) { assertNoError(t, describeRestClient(ctx, ast.QualifiedName{Module: "MyModule", Name: "WeatherAPI"})) out := buf.String() - assertContainsStr(t, out, "create rest client") + assertContainsStr(t, out, "create or modify rest client") assertContainsStr(t, out, "MyModule.WeatherAPI") } diff --git a/mdl/executor/cmd_rest_openapi_mock_test.go b/mdl/executor/cmd_rest_openapi_mock_test.go index b611cbb89..20d3120d6 100644 --- a/mdl/executor/cmd_rest_openapi_mock_test.go +++ b/mdl/executor/cmd_rest_openapi_mock_test.go @@ -289,7 +289,7 @@ func TestDescribeContractFromOpenAPI_Mock(t *testing.T) { assertNoError(t, describeContractFromOpenAPI(ctx, stmt)) out := buf.String() - assertContainsStr(t, out, "create rest client") + assertContainsStr(t, out, "create or modify rest client") assertContainsStr(t, out, "https://api.example.com/v1") assertContainsStr(t, out, "listPets") } diff --git a/mdl/executor/cmd_security.go b/mdl/executor/cmd_security.go index 371b99e39..502242324 100644 --- a/mdl/executor/cmd_security.go +++ b/mdl/executor/cmd_security.go @@ -854,7 +854,7 @@ func describeUserRole(ctx *ExecContext, name ast.QualifiedName) error { for _, ur := range ps.UserRoles { if ur.Name == name.Name { - fmt.Fprintf(ctx.Output, "create user role %s", ur.Name) + fmt.Fprintf(ctx.Output, "create or modify user role %s", ur.Name) // Module roles if len(ur.ModuleRoles) > 0 { diff --git a/mdl/executor/cmd_security_mock_test.go b/mdl/executor/cmd_security_mock_test.go index 7b50c3345..317f64039 100644 --- a/mdl/executor/cmd_security_mock_test.go +++ b/mdl/executor/cmd_security_mock_test.go @@ -158,7 +158,7 @@ func TestDescribeUserRole_Mock(t *testing.T) { } ctx, buf := newMockCtx(t, withBackend(mb)) assertNoError(t, describeUserRole(ctx, ast.QualifiedName{Name: "Administrator"})) - assertContainsStr(t, buf.String(), "create user role") + assertContainsStr(t, buf.String(), "create or modify user role") } func TestDescribeDemoUser_Mock(t *testing.T) { diff --git a/mdl/executor/cmd_validationrules.go b/mdl/executor/cmd_validationrules.go index 1fcf142e1..d41573b39 100644 --- a/mdl/executor/cmd_validationrules.go +++ b/mdl/executor/cmd_validationrules.go @@ -165,7 +165,7 @@ func outputEntityValidationRules(ctx *ExecContext, entity *domainmodel.Entity, m } feedback := pickTextTranslation(vr.ErrorMessage, lang) - fmt.Fprintf(ctx.Output, "\ncreate validation rule for %s\n %s\n feedback '%s';\n", + fmt.Fprintf(ctx.Output, "\ncreate or modify validation rule for %s\n %s\n feedback '%s';\n", target, constraint, escapeMDLString(feedback)) } } diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index ecdaa3a85..f900201a7 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -185,7 +185,7 @@ func describeWorkflowToString(ctx *ExecContext, name ast.QualifiedName) (string, } lines = append(lines, "") - lines = append(lines, fmt.Sprintf("create workflow %s", qualifiedName)) + lines = append(lines, fmt.Sprintf("create or modify workflow %s", qualifiedName)) if clause := describeFolderClause(ctx, targetWf.ContainerID); clause != "" { lines = append(lines, " "+strings.TrimSpace(clause)) } diff --git a/mdl/executor/cmd_workflows_mock_test.go b/mdl/executor/cmd_workflows_mock_test.go index e3de13446..5c8b614eb 100644 --- a/mdl/executor/cmd_workflows_mock_test.go +++ b/mdl/executor/cmd_workflows_mock_test.go @@ -48,7 +48,7 @@ func TestDescribeWorkflow_Mock(t *testing.T) { assertNoError(t, describeWorkflow(ctx, ast.QualifiedName{Module: "Sales", Name: "ApproveOrder"})) out := buf.String() - assertContainsStr(t, out, "create workflow") + assertContainsStr(t, out, "create or modify workflow") assertContainsStr(t, out, "Sales.ApproveOrder") // Roundtrip: DESCRIBE output must be parseable as valid MDL (issue #478) diff --git a/mdl/executor/describe_create_or_modify_test.go b/mdl/executor/describe_create_or_modify_test.go index dc11fbd84..31fd6a67c 100644 --- a/mdl/executor/describe_create_or_modify_test.go +++ b/mdl/executor/describe_create_or_modify_test.go @@ -8,6 +8,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/javaactions" @@ -17,8 +18,11 @@ import ( // DESCRIBE describes a document that exists, so its output has to re-execute // against the project it came from. A plain `create` fails there with "already // exists" — the round trip dies at the first statement instead of reporting -// what changed (ako/mxcli#705 item 5). Every other describer already emits -// `create or modify`; these three did not. +// what changed (ako/mxcli#705 item 5). #731 moved the rest of the describers +// over (JavaScript action, user role, navigation, layout, REST and OData +// clients, OData service, external entity, database connection, data +// transformer, agent-editor documents, workflow, validation rule, module); their +// mock tests assert the spelling, and the three below also parse it. // // Each test parses the output rather than grepping it: what matters is the // statement the parser builds, not how the verb is spelled. @@ -111,6 +115,72 @@ func TestDescribeModule_RolesEmitCreateOrModify(t *testing.T) { } } +func TestDescribeJavaScriptAction_EmitsCreateOrModify(t *testing.T) { + mod := mkModule("WebMod") + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ReadJavaScriptActionByNameFunc: func(string) (*types.JavaScriptAction, error) { + return &types.JavaScriptAction{ + BaseElement: model.BaseElement{ID: nextID("jsa")}, + ContainerID: mod.ID, + Name: "ShowAlert", + }, nil + }, + } + ctx, buf := newMockCtx(t, withBackend(mb)) + assertNoError(t, describeJavaScriptAction(ctx, ast.QualifiedName{Module: "WebMod", Name: "ShowAlert"})) + + stmt := firstStatement[*ast.CreateJavaScriptActionStmt](t, buf.String()) + if !stmt.CreateOrModify { + t.Errorf("describe javascript action emitted a plain create, which fails on the action it describes:\n%s", buf.String()) + } +} + +func TestDescribeUserRole_EmitsCreateOrModify(t *testing.T) { + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSecurityFunc: func() (*security.ProjectSecurity, error) { + return &security.ProjectSecurity{UserRoles: []*security.UserRole{ + {Name: "Administrator", ModuleRoles: []string{"MyModule.Admin"}}, + }}, nil + }, + } + ctx, buf := newMockCtx(t, withBackend(mb)) + assertNoError(t, describeUserRole(ctx, ast.QualifiedName{Name: "Administrator"})) + + stmt := firstStatement[*ast.CreateUserRoleStmt](t, buf.String()) + if !stmt.CreateOrModify { + t.Errorf("describe user role emitted a plain create, which fails on the role it describes:\n%s", buf.String()) + } +} + +// Navigation printed `create or replace`, the deprecated spelling (MDL-DEPR001): +// describe output must be canonical, so it warns on nothing. +func TestDescribeNavigation_EmitsCreateOrModify(t *testing.T) { + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetNavigationFunc: func() (*types.NavigationDocument, error) { + return &types.NavigationDocument{Profiles: []*types.NavigationProfile{{ + Name: "Responsive", Kind: "Responsive", HomePage: &types.NavHomePage{Page: "MyModule.Home"}, + }}}, nil + }, + } + ctx, buf := newMockCtx(t, withBackend(mb)) + assertNoError(t, describeNavigation(ctx, ast.QualifiedName{Name: "Responsive"})) + + prog, errs := visitor.Build(buf.String()) + if len(errs) > 0 { + t.Fatalf("describe navigation emitted MDL the parser rejects: %v\n%s", errs, buf.String()) + } + if len(prog.Deprecations) != 0 { + t.Errorf("describe navigation emitted a deprecated spelling %+v:\n%s", prog.Deprecations, buf.String()) + } + stmt := firstStatement[*ast.AlterNavigationStmt](t, buf.String()) + if !stmt.CreateOrModify { + t.Errorf("describe navigation is not an idempotent create:\n%s", buf.String()) + } +} + func firstStatement[T ast.Statement](t *testing.T, mdl string) T { t.Helper() prog, errs := visitor.Build(mdl) diff --git a/mdl/roundtrip/allowlist_test.go b/mdl/roundtrip/allowlist_test.go index 9b6176918..019cc914b 100644 --- a/mdl/roundtrip/allowlist_test.go +++ b/mdl/roundtrip/allowlist_test.go @@ -102,53 +102,6 @@ var knownFailures = map[string]knownFailure{ "entity NanoflowCommons.Geolocation": {laws: []law{lawGetPut}, issue: "#721", why: "access-rule member pointers and generalization flags dropped (#721 B)"}, "entity NanoflowCommons.Position": {laws: []law{lawGetPut}, issue: "#721", why: "access-rule member pointers and generalization flags dropped (#721 B)"}, - // #721 F: plain `create` for user roles and JavaScript actions. - "javascript action DataWidgets.Export_To_Excel": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action FeedbackModule.JS_GetSingleLocalStorageObjectItem": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action FeedbackModule.JS_PopulateFeedbackMetadata": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action FeedbackModule.JS_Recalculate_MendixModal_Error_PopUp_Zindex": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action FeedbackModule.JS_RevokeUploadedFileFromMemory": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action FeedbackModule.JS_SetFeedbackStorageObject": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action FeedbackModule.JS_SetSingleLocalStorageObjectItem": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action FeedbackModule.JS_ToggleFeedbackAnnotateWidget": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action FeedbackModule.JS_ToggleFeedbackScreenshotWidget": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action FeedbackModule.JS_UploadAndConvertToFileBlobURL": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action FeedbackModule.JS_isStrictMode": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.Base64Decode": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.Base64DecodeToImage": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.Base64Encode": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.CallPhoneNumber": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.ClearCachedSessionData": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.ClearLocalStorage": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.GenerateUniqueID": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.GetPlatform": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.GetRemoteUrl": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.GetStorageItemString": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.GetStraightLineDistance": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.HideProgress": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.IsConnectedToServer": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.NavigateTo": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.OpenMap": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.OpenURL": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.Reload": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.RemoveStorageItem": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.RequestLocationPermission": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.SendTextMessage": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.SignOut": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.StorageItemExists": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.TimeBetween": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.ToggleSidebar": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action NanoflowCommons.Wait": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action WebActions.FocusNext": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action WebActions.FocusPrevious": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action WebActions.ReadCookie": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action WebActions.ScrollTo": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action WebActions.SetCookie": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action WebActions.SetFavicon": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "javascript action WebActions.SetFocus": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "user role 'Administrator'": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - "user role 'User'": {laws: []law{lawExec}, issue: "#721", why: "describe prints a plain create (#721 F)"}, - // #721 E: menus. "menu Atlas_Core.Phone_Menu": {laws: []law{lawGetPut}, issue: "#721", why: "nested menu items dropped (#721 E)"}, "menu Atlas_Core.Tablet_Menu": {laws: []law{lawGetPut}, issue: "#721", why: "nested menu items dropped (#721 E)"}, @@ -158,7 +111,7 @@ var knownFailures = map[string]knownFailure{ "microflow Administration.ChangePassword": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "whole-document rebuild: curves, merges, case values (#721 A)"}, "microflow Administration.ManageMyAccount": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "whole-document rebuild: curves, merges, case values (#721 A)"}, "microflow Administration.NewAccount": {laws: []law{lawGetPut}, issue: "#721", why: "whole-document rebuild: curves, merges, case values (#721 A)"}, - "microflow Administration.NewWebServiceAccount": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "whole-document rebuild: curves, merges, case values (#721 A)"}, + "microflow Administration.NewWebServiceAccount": {laws: []law{lawGetPut}, issue: "#721", why: "whole-document rebuild: curves, merges, case values (#721 A)"}, "microflow Administration.SaveNewAccount": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "whole-document rebuild: curves, merges, case values (#721 A)"}, "microflow Administration.ShowMyPasswordForm": {laws: []law{lawGetPut}, issue: "#721", why: "whole-document rebuild: curves, merges, case values (#721 A)"}, "microflow Administration.ShowPasswordForm": {laws: []law{lawGetPut}, issue: "#721", why: "whole-document rebuild: curves, merges, case values (#721 A)"}, From c8aef80c9ec8004a3ab220a6c39fdeecadf59aad Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 27 Sep 2026 08:28:03 +0000 Subject: [PATCH 05/37] feat(mdl 1): create or replace means create or modify on every type (#731) Under mdl 1, create or replace on a view entity is the identity-carrying rewrite create or modify uses, instead of delete and recreate, and on a user role or demo user it is create or modify instead of a plain create. Each is then a plain MDL-DEPR001 alias. Under mdl 0 (no header) the alpha meaning is kept and MDL-V1-REPLACE01/02 warns (ADR-0011). The GUID test runs on testdata/testapp-views, a trimmed copy of ako/TestApp whose view entity MyFirstModule.VCar was authored in Studio Pro (GUID != $ID); mdl 0 create or replace is the control that re-mints the GUID. Co-Authored-By: Claude Opus 5.5 --- mdl/deprecation/deprecation.go | 8 +- mdl/roundtrip/viewentity_identity_test.go | 181 ++++++++++++++++++ mdl/visitor/visitor_deprecations.go | 69 +++++-- mdl/visitor/visitor_deprecations_test.go | 106 ++++++---- mdl/visitor/visitor_entity.go | 7 +- mdl/visitor/visitor_security.go | 6 +- testdata/testapp-views/README.md | 28 +++ testdata/testapp-views/TestApp.mpr | Bin 0 -> 20480 bytes ...6747dc6-b6b7-42ae-aabf-255dca2aeeaf.mxunit | Bin 0 -> 236 bytes ...fcc2823-c303-5a27-b6c7-78b04c96c88f.mxunit | Bin 0 -> 14224 bytes ...5d3e460-c53d-419e-a2a9-e1428f40b14f.mxunit | Bin 0 -> 2585 bytes ...4f92135-5c65-40c1-9af9-166186ecf501.mxunit | Bin 0 -> 1137 bytes ...f64baa2-b85c-4999-9a3c-3cc38382eb54.mxunit | Bin 0 -> 189 bytes ...a8475b3-14cb-4100-8963-58f2fd3a0be8.mxunit | Bin 0 -> 1817 bytes ...e70063d-53e1-477a-ab92-6b763330c293.mxunit | Bin 0 -> 3035 bytes ...586af73-672c-4a18-9ce4-8a9c0f092ef3.mxunit | Bin 0 -> 1793 bytes ...7d2ecb5-6c2f-4ffa-a0b3-a37a40da3adf.mxunit | Bin 0 -> 57322 bytes ...b276f2e-eed5-4fa9-903b-3483792cbea7.mxunit | Bin 0 -> 5638 bytes ...c6805fc-cadf-4d72-a5ef-f25da7cf4c97.mxunit | Bin 0 -> 4934 bytes ...52106e9-3f38-46c7-bcc4-2b4e4ad39160.mxunit | Bin 0 -> 168159 bytes ...c1ceba7-34e0-4d4c-b6ae-c54b487a3562.mxunit | Bin 0 -> 2382 bytes ...e16f1f7-1207-4d70-8847-c369a7dd360a.mxunit | Bin 0 -> 2066 bytes ...844f052-e1ef-4335-a833-4fe39c4b63a5.mxunit | Bin 0 -> 77 bytes ...d06ff15-6fef-4887-a15a-4230d851a87d.mxunit | Bin 0 -> 2601 bytes ...4776610-13b2-48cb-a265-d5e984d63129.mxunit | Bin 0 -> 3409 bytes ...62986ab-1ec9-5c84-9d33-1f0829ffce9b.mxunit | Bin 0 -> 9165 bytes ...d6f65bd-2393-539d-9388-4684a54d0646.mxunit | Bin 0 -> 1864 bytes ...121ee37-f102-56ae-b644-160f1d340296.mxunit | Bin 0 -> 402 bytes ...1e8b130-26ba-43bd-b2e7-0f6b61a61008.mxunit | Bin 0 -> 4727 bytes ...928bb10-1832-49c8-9415-7e24495ec970.mxunit | Bin 0 -> 239 bytes testdata/testapp-views/mprcontents/mprname | 1 + 31 files changed, 349 insertions(+), 57 deletions(-) create mode 100644 mdl/roundtrip/viewentity_identity_test.go create mode 100644 testdata/testapp-views/README.md create mode 100644 testdata/testapp-views/TestApp.mpr create mode 100644 testdata/testapp-views/mprcontents/16/74/16747dc6-b6b7-42ae-aabf-255dca2aeeaf.mxunit create mode 100644 testdata/testapp-views/mprcontents/1f/cc/1fcc2823-c303-5a27-b6c7-78b04c96c88f.mxunit create mode 100644 testdata/testapp-views/mprcontents/25/d3/25d3e460-c53d-419e-a2a9-e1428f40b14f.mxunit create mode 100644 testdata/testapp-views/mprcontents/34/f9/34f92135-5c65-40c1-9af9-166186ecf501.mxunit create mode 100644 testdata/testapp-views/mprcontents/3f/64/3f64baa2-b85c-4999-9a3c-3cc38382eb54.mxunit create mode 100644 testdata/testapp-views/mprcontents/4a/84/4a8475b3-14cb-4100-8963-58f2fd3a0be8.mxunit create mode 100644 testdata/testapp-views/mprcontents/4e/70/4e70063d-53e1-477a-ab92-6b763330c293.mxunit create mode 100644 testdata/testapp-views/mprcontents/55/86/5586af73-672c-4a18-9ce4-8a9c0f092ef3.mxunit create mode 100644 testdata/testapp-views/mprcontents/57/d2/57d2ecb5-6c2f-4ffa-a0b3-a37a40da3adf.mxunit create mode 100644 testdata/testapp-views/mprcontents/5b/27/5b276f2e-eed5-4fa9-903b-3483792cbea7.mxunit create mode 100644 testdata/testapp-views/mprcontents/5c/68/5c6805fc-cadf-4d72-a5ef-f25da7cf4c97.mxunit create mode 100644 testdata/testapp-views/mprcontents/65/21/652106e9-3f38-46c7-bcc4-2b4e4ad39160.mxunit create mode 100644 testdata/testapp-views/mprcontents/6c/1c/6c1ceba7-34e0-4d4c-b6ae-c54b487a3562.mxunit create mode 100644 testdata/testapp-views/mprcontents/6e/16/6e16f1f7-1207-4d70-8847-c369a7dd360a.mxunit create mode 100644 testdata/testapp-views/mprcontents/78/44/7844f052-e1ef-4335-a833-4fe39c4b63a5.mxunit create mode 100644 testdata/testapp-views/mprcontents/7d/06/7d06ff15-6fef-4887-a15a-4230d851a87d.mxunit create mode 100644 testdata/testapp-views/mprcontents/84/77/84776610-13b2-48cb-a265-d5e984d63129.mxunit create mode 100644 testdata/testapp-views/mprcontents/b6/29/b62986ab-1ec9-5c84-9d33-1f0829ffce9b.mxunit create mode 100644 testdata/testapp-views/mprcontents/bd/6f/bd6f65bd-2393-539d-9388-4684a54d0646.mxunit create mode 100644 testdata/testapp-views/mprcontents/d1/21/d121ee37-f102-56ae-b644-160f1d340296.mxunit create mode 100644 testdata/testapp-views/mprcontents/e1/e8/e1e8b130-26ba-43bd-b2e7-0f6b61a61008.mxunit create mode 100644 testdata/testapp-views/mprcontents/f9/28/f928bb10-1832-49c8-9415-7e24495ec970.mxunit create mode 100644 testdata/testapp-views/mprcontents/mprname diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 50d5c47eb..5bfe641b3 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -87,10 +87,10 @@ var entries = []Entry{ Canonical: "create or modify …", Rewrite: Rewrite{Token: "replace", Replacement: "modify"}, RemovedIn: 2, - Note: "Not reported where `or replace` means something else today: " + - "`create or replace view entity` (drops and recreates), " + - "`create or replace translations` (replaces the whole set), and " + - "`create or replace user role` / `demo user` (the `replace` is ignored).", + Note: "Not reported for `create or replace translations`, which replaces the " + + "whole set. Under mdl 0 (no header) it is not reported for a view entity " + + "(drops and recreates) or a user role / demo user (a plain create) either: " + + "those warn MDL-V1-REPLACE01/02 instead, and are aliases from `mdl 1;` on.", Example: "create or replace enumeration M.Color (Red 'Red');", CanonicalExample: "create or modify enumeration M.Color (Red 'Red');", }, diff --git a/mdl/roundtrip/viewentity_identity_test.go b/mdl/roundtrip/viewentity_identity_test.go new file mode 100644 index 000000000..fc6fa615f --- /dev/null +++ b/mdl/roundtrip/viewentity_identity_test.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package roundtrip + +import ( + "bytes" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/executor" + "github.com/mendixlabs/mxcli/mdl/visitor" + mmpr "github.com/mendixlabs/mxcli/modelsdk/mpr" +) + +// viewsFixtureDir holds MyFirstModule.VCar, a view entity authored in Studio +// Pro 11.14.0, so its GUIDs differ from its $IDs (testdata/testapp-views/README.md). +// PedApp has no view entity, and on one mxcli created GUID == $ID, so a +// re-minted GUID reproduces the same value and the test could not fail. +const ( + viewsFixtureDir = "../../testdata/testapp-views" + viewsFixtureMPR = "TestApp.mpr" +) + +const vcarBody = `view entity MyFirstModule.VCar ( + ModelID: Long, + Brand: String(200) +) as ( + from MyFirstModule.Car as c + select c.ModelId as ModelID + , c.Brand as Brand +); +` + +// R1 (#731): `create or replace` means `create or modify` on every document +// type, and for a view entity that is the identity-carrying rewrite rather than +// a delete and recreate. Under mdl 0 the old meaning is kept, which is the +// control: it re-mints the GUID, so the assertion can detect a lost one. +func TestViewEntityCreateOrModifyKeepsGUID(t *testing.T) { + cases := []struct { + name string + script string + keepGUID bool + }{ + {"create or modify", "create or modify " + vcarBody, true}, + {"mdl 1 create or replace", "mdl 1;\ncreate or replace " + vcarBody, true}, + // Control: mdl 0 keeps delete-and-recreate, so the GUID changes. + {"mdl 0 create or replace (control)", "create or replace " + vcarBody, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + mpr := copyViewsFixture(t) + before := vcarGUIDs(t, mpr) + for name, id := range before { + if id[0] == id[1] { + t.Fatalf("fixture %s has GUID == $ID; the test cannot detect a re-minted GUID", name) + } + } + + runScript(t, mpr, c.script) + + after := vcarGUIDs(t, mpr) + for name, want := range before { + got, ok := after[name] + kept := ok && got[1] == want[1] + if c.keepGUID && !kept { + t.Errorf("%s: GUID %s -> %s; the rewrite must keep the stored GUID", name, want[1], got[1]) + } + if !c.keepGUID && kept { + t.Errorf("%s: GUID kept under the control; the test cannot tell a carried GUID from a lost one", name) + } + } + }) + } +} + +// vcarGUIDs maps VCar and each of its attributes to [$ID, GUID] as hex. +func vcarGUIDs(t *testing.T, mprPath string) map[string][2]string { + t.Helper() + r, err := mmpr.Open(mprPath) + if err != nil { + t.Fatalf("open: %v", err) + } + defer r.Close() + units, err := r.ListUnits() + if err != nil { + t.Fatalf("list units: %v", err) + } + for _, u := range units { + b, err := r.GetRawUnitBytes(u.ID) + if err != nil { + t.Fatalf("read unit: %v", err) + } + if typ, _ := typeAndName(b); typ != "DomainModels$DomainModel" { + continue + } + var dm bson.D + if err := bson.Unmarshal(b, &dm); err != nil { + t.Fatalf("decode: %v", err) + } + ents, _ := field(dm, "Entities").(bson.A) + for _, e := range ents { + ent, ok := e.(bson.D) + if !ok || field(ent, "Name") != "VCar" { + continue + } + out := map[string][2]string{"entity VCar": ids(ent)} + attrs, _ := field(ent, "Attributes").(bson.A) + for _, a := range attrs { + if attr, ok := a.(bson.D); ok { + name, _ := field(attr, "Name").(string) + out["attribute "+name] = ids(attr) + } + } + return out + } + } + t.Fatal("MyFirstModule.VCar not found") + return nil +} + +func field(d bson.D, key string) any { + for _, e := range d { + if e.Key == key { + return e.Value + } + } + return nil +} + +func ids(el bson.D) [2]string { + h := func(v any) string { + if b, ok := v.(bson.Binary); ok { + return hex.EncodeToString(b.Data) + } + return "" + } + return [2]string{h(field(el, "$ID")), h(field(el, "GUID"))} +} + +func copyViewsFixture(t *testing.T) string { + t.Helper() + src, err := filepath.Abs(viewsFixtureDir) + if err != nil { + t.Fatal(err) + } + dst := t.TempDir() + if err := os.CopyFS(dst, os.DirFS(src)); err != nil { + t.Fatalf("copy fixture: %v", err) + } + return filepath.Join(dst, viewsFixtureMPR) +} + +// runScript runs a script the way `mxcli exec` does after its preflight. +func runScript(t *testing.T, mprPath, script string) { + t.Helper() + var out bytes.Buffer + exe := executor.New(&out) + exe.SetQuiet(true) + exe.SetBackendFactory(func() backend.FullBackend { return modelsdkbackend.New() }) + if err := exe.Execute(&ast.ConnectStmt{Path: mprPath}); err != nil { + t.Fatalf("connect: %v", err) + } + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + if err := exe.ExecuteProgram(prog); err != nil { + t.Fatalf("exec: %v\n%s", err, out.String()) + } + _ = exe.Execute(&ast.DisconnectStmt{}) + _ = exe.Close() +} diff --git a/mdl/visitor/visitor_deprecations.go b/mdl/visitor/visitor_deprecations.go index 72e44bc22..e93c5ec6e 100644 --- a/mdl/visitor/visitor_deprecations.go +++ b/mdl/visitor/visitor_deprecations.go @@ -6,6 +6,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/deprecation" "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/langver" "github.com/antlr4-go/antlr/v4" ) @@ -29,24 +30,70 @@ func (b *Builder) recordDeprecation(code string, tok antlr.Token, subject string } // createOrReplaceIsNotAnAlias lists the create kinds (createStatementKind's -// words) where `or replace` does NOT mean `or modify`, so rewriting it would -// change the script. Measured against the visitors, and pinned by -// TestCreateOrReplaceMatchesModifyExceptExemptKinds: +// words) where `or replace` does NOT mean `or modify` under any language +// version, so rewriting it would change the script. Measured against the +// visitors, and pinned by TestCreateOrReplaceMatchesModifyExceptExemptKinds: // // - translations: `or replace` replaces the whole set, `or modify` merges. -// - userrole, demouser: the visitor reads only `or modify`; `or replace` is -// a plain create, which fails on an existing role or user. // -// `create or replace view entity` (kind "entity") drops and recreates the view -// entity, and is exempted in recordCreateOrReplace. +// Three more kinds differ only under mdl 0 and are gated below: a view entity, +// a user role and a demo user. var createOrReplaceIsNotAnAlias = map[string]bool{ "translations": true, - "userrole": true, - "demouser": true, +} + +// viewEntityReplaceIsModify is R1 for view entities (ADR-0010; #731). Under +// mdl 0, `create or replace view entity` deletes the entity and creates a new +// one: a new $ID and GUID, and the access rules and associations that pointed +// at the old one are gone with it. From mdl 1 it is `create or modify`, the +// identity-carrying rewrite, like `or replace` on every other document type. +var viewEntityReplaceIsModify = langver.Change{ + Code: "MDL-V1-REPLACE01", + Since: langver.V1, + Old: "`create or replace view entity` deletes the view entity and creates a new one (a new identity; its access rules and associations are lost)", + New: "`create or modify view entity`, which rewrites it in place and keeps its identity", +} + +// roleReplaceIsModify is R1 for user roles and demo users (#731). Under mdl 0 +// their visitors read only `or modify`, so `or replace` is a plain create that +// fails on an existing role or user. From mdl 1 it is `create or modify`. +var roleReplaceIsModify = langver.Change{ + Code: "MDL-V1-REPLACE02", + Since: langver.V1, + Old: "`create or replace` on a user role or demo user is a plain create, which fails when it already exists", + New: "`create or modify`", +} + +// replaceGate returns the language change `or replace` goes through for this +// create statement, and false when its meaning does not depend on the version. +func replaceGate(ctx *parser.CreateStatementContext) (langver.Change, bool) { + switch createStatementKind(ctx) { + case "userrole", "demouser": + return roleReplaceIsModify, true + case "entity": + if ent, ok := ctx.CreateEntityStatement().(*parser.CreateEntityStatementContext); ok && ent.VIEW() != nil { + return viewEntityReplaceIsModify, true + } + } + return langver.Change{}, false +} + +// replaceMeansModify reports whether `create or replace` on createStmt builds +// `create or modify` under the script's language version. The document +// builders ask it; recordCreateOrReplace reports what it decided. +func (b *Builder) replaceMeansModify(createStmt *parser.CreateStatementContext) bool { + if createStmt == nil || createStmt.OR() == nil || createStmt.REPLACE() == nil { + return false + } + if c, gated := replaceGate(createStmt); gated { + return c.Applies(b.langVersion) + } + return true } // recordCreateOrReplace records MDL-DEPR001 for a `create or replace` whose -// meaning is exactly `create or modify`. +// meaning is exactly `create or modify`, and the language change for one whose +// meaning is kept at mdl 0. func (b *Builder) recordCreateOrReplace(ctx *parser.CreateStatementContext) { if ctx.OR() == nil || ctx.REPLACE() == nil { return @@ -55,7 +102,7 @@ func (b *Builder) recordCreateOrReplace(ctx *parser.CreateStatementContext) { if kind == "" || createOrReplaceIsNotAnAlias[kind] { return } - if ent, ok := ctx.CreateEntityStatement().(*parser.CreateEntityStatementContext); ok && ent.VIEW() != nil { + if c, gated := replaceGate(ctx); gated && !b.gate(c, ctx) { return } b.recordDeprecation(deprecation.CreateOrReplace, ctx.REPLACE().GetSymbol(), kind) diff --git a/mdl/visitor/visitor_deprecations_test.go b/mdl/visitor/visitor_deprecations_test.go index 6bb425c07..ebcdd2bf2 100644 --- a/mdl/visitor/visitor_deprecations_test.go +++ b/mdl/visitor/visitor_deprecations_test.go @@ -154,6 +154,12 @@ func createStatementKinds(t *testing.T) []string { // it is not an alias, and reporting it would tell the user to make a change // that alters their script. Both directions are asserted, so the exemption list // in the visitor cannot drift from what the visitors actually do. +// +// Which kinds are exempt depends on the language version (#731, ADR-0011): +// under mdl 1, `or replace` on a view entity, user role or demo user means +// `or modify` like everywhere else, so only translations stay exempt. Under +// mdl 0 those three keep their alpha meaning and report the language change +// that would alter it instead of the alias. func TestCreateOrReplaceMatchesModifyExceptExemptKinds(t *testing.T) { for _, kind := range createStatementKinds(t) { if _, ok := createOrReplaceCases[kind]; !ok { @@ -165,48 +171,74 @@ func TestCreateOrReplaceMatchesModifyExceptExemptKinds(t *testing.T) { for k, v := range createOrReplaceCases { cases[k] = v } - // A view entity is kind "entity" too, and its `or replace` drops and recreates. + // A view entity is kind "entity" too. cases["entity (view)"] = "view entity M.V (Name: String(100)) as (select c.Name as Name from M.Customer as c);" - for name, body := range cases { - t.Run(name, func(t *testing.T) { - rep := mustBuild(t, "create or replace "+body) - mod := mustBuild(t, "create or modify "+body) - if len(rep.Statements) != 1 || len(mod.Statements) != 1 { - t.Fatalf("want one statement each, got %d and %d — the case does not exercise the visitor", - len(rep.Statements), len(mod.Statements)) - } - foldPageModeFlags(rep.Statements) - foldPageModeFlags(mod.Statements) - same := reflect.DeepEqual(rep.Statements, mod.Statements) - exempt := createOrReplaceIsNotAnAlias[name] || name == "entity (view)" + // gatedChange names the language change a kind's `or replace` goes through; + // a kind absent here means the same thing under every version. + gatedChange := map[string]string{ + "entity (view)": viewEntityReplaceIsModify.Code, + "userrole": roleReplaceIsModify.Code, + "demouser": roleReplaceIsModify.Code, + } - switch { - case exempt && same: - t.Errorf("exempt, but `or replace` and `or modify` build the same statements: " + - "it is an alias after all — remove the exemption") - case !exempt && !same: - t.Errorf("`or replace` and `or modify` build different statements, so rewriting one "+ - "to the other changes the script — exempt this kind or fix the visitor:\n"+ - " replace: %#v\n modify: %#v", rep.Statements, mod.Statements) - } + for _, version := range []struct { + name, header string + v1 bool + }{{"mdl 0", "", false}, {"mdl 1", "mdl 1;\n", true}} { + for name, body := range cases { + t.Run(version.name+"/"+name, func(t *testing.T) { + rep := mustBuild(t, version.header+"create or replace "+body) + mod := mustBuild(t, version.header+"create or modify "+body) + if len(rep.Statements) != 1 || len(mod.Statements) != 1 { + t.Fatalf("want one statement each, got %d and %d — the case does not exercise the visitor", + len(rep.Statements), len(mod.Statements)) + } + foldPageModeFlags(rep.Statements) + foldPageModeFlags(mod.Statements) + same := reflect.DeepEqual(rep.Statements, mod.Statements) + gate, gated := gatedChange[name] + exempt := createOrReplaceIsNotAnAlias[name] || (gated && !version.v1) - got := deprecationCodes(rep) - if exempt { - if len(got) != 0 { - t.Errorf("exempt kind recorded %v", got) + switch { + case exempt && same: + t.Errorf("exempt, but `or replace` and `or modify` build the same statements: " + + "it is an alias after all — remove the exemption") + case !exempt && !same: + t.Errorf("`or replace` and `or modify` build different statements, so rewriting one "+ + "to the other changes the script — exempt this kind or fix the visitor:\n"+ + " replace: %#v\n modify: %#v", rep.Statements, mod.Statements) } - return - } - if !reflect.DeepEqual(got, []string{deprecation.CreateOrReplace}) { - t.Errorf("recorded %v, want [%s]", got, deprecation.CreateOrReplace) - } else if rep.Deprecations[0].Subject != name { - t.Errorf("subject = %q, want %q", rep.Deprecations[0].Subject, name) - } - if got := deprecationCodes(mod); len(got) != 0 { - t.Errorf("`create or modify` recorded %v", got) - } - }) + + var notes []string + for _, n := range rep.LanguageNotes { + notes = append(notes, n.Code) + } + wantNotes := []string(nil) + if gated && !version.v1 { + wantNotes = []string{gate} + } + if !reflect.DeepEqual(notes, wantNotes) { + t.Errorf("language notes %v, want %v", notes, wantNotes) + } + + got := deprecationCodes(rep) + if exempt { + if len(got) != 0 { + t.Errorf("exempt kind recorded %v", got) + } + return + } + if !reflect.DeepEqual(got, []string{deprecation.CreateOrReplace}) { + t.Errorf("recorded %v, want [%s]", got, deprecation.CreateOrReplace) + } else if want := strings.TrimSuffix(name, " (view)"); rep.Deprecations[0].Subject != want { + t.Errorf("subject = %q, want %q", rep.Deprecations[0].Subject, want) + } + if got := deprecationCodes(mod); len(got) != 0 { + t.Errorf("`create or modify` recorded %v", got) + } + }) + } } } diff --git a/mdl/visitor/visitor_entity.go b/mdl/visitor/visitor_entity.go index 41cbc4b99..043903e12 100644 --- a/mdl/visitor/visitor_entity.go +++ b/mdl/visitor/visitor_entity.go @@ -150,11 +150,12 @@ func (b *Builder) buildViewEntity(ctx *parser.CreateEntityStatementContext) { createStmt := findParentCreateStatement(ctx) if createStmt != nil { // Check for CREATE OR MODIFY / CREATE OR REPLACE + // `or replace` means `or modify` from mdl 1 on; under mdl 0 it keeps + // its delete-and-recreate meaning (viewEntityReplaceIsModify). if createStmt.OR() != nil { - if createStmt.MODIFY() != nil { + if createStmt.MODIFY() != nil || b.replaceMeansModify(createStmt) { stmt.CreateOrModify = true - } - if createStmt.REPLACE() != nil { + } else if createStmt.REPLACE() != nil { stmt.CreateOrReplace = true } } diff --git a/mdl/visitor/visitor_security.go b/mdl/visitor/visitor_security.go index 4a3b156c7..713fdcaa6 100644 --- a/mdl/visitor/visitor_security.go +++ b/mdl/visitor/visitor_security.go @@ -47,8 +47,9 @@ func (b *Builder) ExitCreateUserRoleStatement(ctx *parser.CreateUserRoleStatemen } // Check parent createStatement for OR MODIFY + // `or replace` means `or modify` from mdl 1 on (roleReplaceIsModify). if createStmt := findParentCreateStatement(ctx); createStmt != nil { - if createStmt.OR() != nil && createStmt.MODIFY() != nil { + if createStmt.OR() != nil && (createStmt.MODIFY() != nil || b.replaceMeansModify(createStmt)) { stmt.CreateOrModify = true } } @@ -446,8 +447,9 @@ func (b *Builder) ExitCreateDemoUserStatement(ctx *parser.CreateDemoUserStatemen } // Check parent createStatement for OR MODIFY + // `or replace` means `or modify` from mdl 1 on (roleReplaceIsModify). if createStmt := findParentCreateStatement(ctx); createStmt != nil { - if createStmt.OR() != nil && createStmt.MODIFY() != nil { + if createStmt.OR() != nil && (createStmt.MODIFY() != nil || b.replaceMeansModify(createStmt)) { stmt.CreateOrModify = true } } diff --git a/testdata/testapp-views/README.md b/testdata/testapp-views/README.md new file mode 100644 index 000000000..c35eb924c --- /dev/null +++ b/testdata/testapp-views/README.md @@ -0,0 +1,28 @@ +# testapp-views: a Studio Pro-authored view entity + +A trimmed copy of `ako/TestApp`, a Mendix **11.14.0** app (MPR v2) authored in +Studio Pro. It exists for one thing PedApp (`testdata/pedapp`) does not have: a +**view entity mxcli did not write**, `MyFirstModule.VCar`, over the persistent +entity `MyFirstModule.Car`. + +On an element mxcli created, `GUID == $ID` from birth, so a test that re-mints +the GUID from the `$ID` reproduces the same value and cannot fail (CLAUDE.md, "A +`GUID` Is the Database's Identity"). VCar and its attributes have `GUID != $ID`. +It is used by `mdl/roundtrip/viewentity_identity_test.go` (#731). + +## What was kept, and what was left out + +- **Kept verbatim:** every unit file that belongs to the project root or to + `MyFirstModule` (its domain model, security, pages, microflows, the view-entity + source document, and the project-level security, navigation and settings). + Do not edit them. +- **Left out:** the units of every other module (Administration, Atlas_*, + WorkflowCommons, …) with their rows in `TestApp.mpr`'s `Unit` table, the + 17 MB `Projects$ModuleGuidMapping` unit, and `javasource/`, + `javascriptsource/`, `theme/`, `themesource/`, `widgets/`. mxcli opens and + describes the result; it is not meant to build with mxbuild. + +## Provenance + +Copied from the `ako/TestApp` clone on 2026-09-27; the trim removed rows from the +`Unit` table and the matching files under `mprcontents/`, and nothing else. diff --git a/testdata/testapp-views/TestApp.mpr b/testdata/testapp-views/TestApp.mpr new file mode 100644 index 0000000000000000000000000000000000000000..9cef8019c5d65d13081e7bf739ce6c819d126dd8 GIT binary patch literal 20480 zcmeI2d5jxZ9mhTEY_dtZc4)hsN(JH&axA-vJ>zrQnr3{DJ%>FWd)Mx^oAJ!}TAwrH zlLo;iY776AHYJFXpb{u)snpU|1QbX)LT^L`p(sK^A)<(uRw*q)kWy4;5^tJJYw{;3 zdQWTn&CkF2{^ot=*nQu($pleSL`%LdRXN3CH>@*2kYT6AVlY5k3tQ3p}4Ae?w~&;jg>@ERm)dOyka3}D*PciS~XQhj&O2u zn9g#}vX*=<@zNuo+GBN&P^t-HS8i6+Y48HqQtIcn7c>qFu=cJ^dB?OX^YaX} zYW;={(2{)q^pT>%1vrIUSniBmg8M@F{kl5d3!!tf?zAY$>Lp;oDK;1mGP*G@#HG5> z;+5qr?R@J~e63PN_-_eGz9?3?3&`F-qN#CpV37HULHz*@5C8-K0YCr{00aO5KmZT` z1ONd*01yBK{;vo`pi$`h>suo*yaRUZunj&wlZ;@9)3v*f`p~4OQkT*w6L1zXsLxe~ zbJ2zaBW6ABmZK(iYLbZO7_8vc=Kqtc)cOA-=8);M>2}ll@uTCnT=ni%2e10{+EZ&k zw|3*2pRAc*{m<3+tVYI8j45LiYBFem03ZMe00MvjAn-pRFn{Ods+pM$W8(OZ`qo=s zKJzgBr5hc4dNWTQerC5a`TZl0`o8ny&9}brsWXoS>U>LEc9F?hs+s2Eb5bIc^&pX! z3yb$FwAEVo_hWRgNFjp18;rZ{bjstMzjIP>&PUyW25hfe+ueFJV6Q}dN-%-Quo$oksi-sHQ01>3$WOeta`&6{J0su7`fMi? z5A7Z*@58bYDnEylqQ8(OYo1yhb)twwhcY53S(QFqL47%?*(_0-{51o4`LR1vGllD- z2VVK&0rTn|e;X>#2N*@@D13*lN>ba!R3*~o$r1KC(WRv@u^7qW*;H?l>Ts&k6sz`Wd2SuHcAmN|4( zxOx5uU&Zgg>89g*mR@J|qafAgr;3;q`Ib~tdIc_~2uR;P9ZmHWTZ%x~Vz^@~IpL}+ z&`E`(okaz+72Swi;nf(TU)z@+(Oj*1zb)Rdtn89gSFlWosk^``c|tWwskkH!5!0mC87xC67Q8VO&Bga=nZ(gjmJA^GgM_ zfOf0>$glh1wzJ4}&*Aq?eD;(0ZO=D`%I6DCmPy3pb9}U7W07w49H@F&~%A7gI9ccHqvrSVol>2J+60XC31FPj}vbc9Of}jlV-f@?C$5N?E#u$FqmB~oFr8%58} z&xU^V^Jyx2;+|bYY(%^6d z`6pX@qc2`-z`nrGz5VYS*1kGaKJD)1iE7Jlb^97vw$YKTo}Q=B5#6}cR%p`%6%Gfj zB##p!=FKkcQsu8#FHF6h{y}m64wf0-*ImqoeO4vr6e4h*?S%q1I1uDoVr|*CX!Md ztriapC`ZgvHJJ#;q)aJ*1>?PJAWkaL*|Ka6IwN`9%Nqwp9=T)c$nziHJAcr=VQlKX zUw-X!Mf{0bPr)H8LL^|X_VTGRtT>&Ft59L%G$(dhU#{awVZNvnS8p@T+jiT}_Fb+>T52a98LHIl78*{Zjj@fWry6jROiC2o5)HRziEvbk_(cclO+sps zarK<&MfKZo+~e~o*;dS1#2JC@1ZzTt4>Fx*PH~Bpi}V-BmRPhl3uwx_M*Zt$()0;~ z`7h?*n4dB~WWLvYyE$);n{PC4HGjnPFVmk)FPV;-zGb@G)Hewx%H&a#K?4K;0YCr{ z00aO5KmZT`1ONd*01&tW2}~GgM)dXz#A{K#AqEw+sNPF~a#~dHQ9!${HO@eKcVScu z>fL}9+WvYA1>&?+{hZ#YrRr^2jrVC$y_*Q-&qZ|}DO5cF z|Nkcq=95?8r3;1x2mk_r03ZMe00MvjAOHve0)PM@00>;+1hzmUsl3aU6FnYbJBp&> Yb_Ze?wsRuSZ5MK|3+503=1}K<0JNOZy8r+H literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/16/74/16747dc6-b6b7-42ae-aabf-255dca2aeeaf.mxunit b/testdata/testapp-views/mprcontents/16/74/16747dc6-b6b7-42ae-aabf-255dca2aeeaf.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..bd87fe168276f2c83d4e9622229ae268faf8d185 GIT binary patch literal 236 zcmaFEz`($&;_1R50Aw&6t1S`RzHOb;s{N|5r?lR!XHp5NEJ$S#0ZIiFvp6IpH8&OF1fXSpiMgo^d_Ze`E8Q}Sic6ppjDD%*!TCic Po_Q&$6(CoEfCB>n5UWJQ literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/1f/cc/1fcc2823-c303-5a27-b6c7-78b04c96c88f.mxunit b/testdata/testapp-views/mprcontents/1f/cc/1fcc2823-c303-5a27-b6c7-78b04c96c88f.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..f533e24e35ee075944f3523a0c1d5d7e1f8b5a9a GIT binary patch literal 14224 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 b!yb{~p)`u9m)-TN4lsl0o|kHjNKyX-2BK{@ literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/25/d3/25d3e460-c53d-419e-a2a9-e1428f40b14f.mxunit b/testdata/testapp-views/mprcontents/25/d3/25d3e460-c53d-419e-a2a9-e1428f40b14f.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..961a4fca96ce26bebb68b7b4d710ff85251ce155 GIT binary patch literal 2585 zcmd5;T}&KR6h6!@Wu+!oEDG9~wAu31AU^?HYt?0UTeh++>~@imG<2A`z{qfCduL{0 z=?k@*7%h@WQe)8uu{Mg*N^MO|48h+Nqt+Hl!-E=slD=4*^ucJ8Hl8~(>@pM*-*l7x zyZ8K`?|f%U3jy-|kr1o{eC_*fZu5>Wjs?cw`MGZRiK&?2-;+rZD8{R(A~CH~)%tXQ z_9yrPDxL&2Dw&KCYJ~fVWEzS_AKrb^Py7(^$@vrQ^xoJJ+Zsb`h9Bd$}Y&e1GWU`J3gjQ)P3hh2z7gog~(x z&>qrn=zjhQ-iSeZb)a~PA$jo2Qwi-ZL&-}=io!=n8%NRyE^N75z3dn&${AuJ)m70- zSZEMwmx<8?HJQK~lp(Y|nE=7^W`&PuLP(R$UaXX@WLF~GFR7+XWV{adr!;0XkTg-D z0OjiyStb;`J9*(@J%*VJ_8@BKvVQ%pKX2I81OVCKRin>2R_M3!C zgxdpeP}E6_sG7Of2q+FkA)*@!wL-*-QzG#UBUC2+R-w2|{t}rcO`!&1HiX7LA9^O1 zzcKVi+2ol_?bXA+h;FsAUZk>$Lf~moD1kw4Ba6Gn?>0O?8J-Qkxi((;^|ejgoZ#<6 zp(VlBkpYFUpvlq%3+al{V4B*3acT^_s8q4N0)!Y-x)h2DtZ)fM*;`k7i+#$suU(lP zzW&0c`SM$_sebLpuF(@i;Hd(44~nHn4*fkhu=}OV(dN!;iK_pq2ZOKVja&@fh`sgT zh8ux3=ZgPT53ZQIpy8GrcyV^`%=ya~r~j8dSdK2_k*LUe0(w&C^HZ2bOTfWwrv{$v zVVoEyqZXwl-S`_%o-Ld04m~?^^5HXI9{I(|{T>twiH68Ebbo`Q8!e%s9mTlSu@&x&nH%x$VTl1N#E+3Rf>2{JrEaC!kI4+&F|fkZPBi zS`9{?M$(tSEr8ML(2AyVwVa;OADf!qRzLP>&4FJ(*!!*HZ#l_3$`Loa`Jx^HHYhD+V)AfxJrNDbS9FM)BY#6}+q3au&8=(c2- zhWW~uMKWJDkPa|*WQc2 g5=3NDcazAFpm0^YAH@ov(pV3Fw6iBBLXkA#AHH?`-2eap literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/34/f9/34f92135-5c65-40c1-9af9-166186ecf501.mxunit b/testdata/testapp-views/mprcontents/34/f9/34f92135-5c65-40c1-9af9-166186ecf501.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..f1536c7890a640652cfa4d330d6cf910118fe0fe GIT binary patch literal 1137 zcmZ`&O-vI(6n^waOTvMmLC_1t>PbyO)EGiyX^S*z3u%#)kPO|24h}OgyITwKKs^v| zXo8v;4`RHjG4ZO2@#4vN=0f7ZlXx;QMq`X`wo73F_p-b5-hA)--uvEs1RxsErlA$+ zIoJKG53jzz^ypVRTmJT=DHI=Dm_s;Bp*$~ZH7(RlJ6>SY`+`VP&;eR`Ww`>2N}vrV zDYrENP4o)&z#(&UVTfznsVAMeVxy)|T9w^dln!&al3q5%GS#whEwm_{sm>|wJ6Fa^RrP@}(X3F67e-z(oAB$wSUe@dN4yL<^9hRC*r63)9Q z%V_W*l43TFGvN~b7Rn>&5WS!+jkd$3M=_5w|Fm6LAE z>e|odRP66 z<6d;hmubwBW)3qq?D)9imUkCI1vZP&Owoq(!Xr~NBr~Ois80pv0xp=CI6qTepBb)Q z9=P+O{q1MJ!U@7?(6i%>&pl|7`iTu40jtdiWbAH)U!*-)q~tK|WGdu!2o+I~n<|(m zI}7x>3NcFWNhJDv6Ev$5Uqm=SC%xu?n%N8Y12uQs%CEU=Lo?FyEAF#r|7CGui$#5K t-bU0{a}O_C(XAxS*_1$UM4oG6WP=>Hol&M~Y_t!93q_npZPty@{sAqJMS%bS literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/3f/64/3f64baa2-b85c-4999-9a3c-3cc38382eb54.mxunit b/testdata/testapp-views/mprcontents/3f/64/3f64baa2-b85c-4999-9a3c-3cc38382eb54.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..79016927365c456b76bf4a4c8c7d4961a777bcd0 GIT binary patch literal 189 zcmdnXz`($&;_1R50Aw&M+LdA-vty>`EE}7{%}uXEm{dY43sM;*fKtJ!$)!b^C6y|^ z`6;D2sW2`B3xpq(pOae5kOx#KV8Fl(q?ipE;(-*xZ0@3~%QUtpxc71MZi;%?*$g#X q1k-GgSqw}rsl~}fnFS@8`FRYCKt&8peu=rM46Hy_XmM&0NCW`a(=$K- literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/4a/84/4a8475b3-14cb-4100-8963-58f2fd3a0be8.mxunit b/testdata/testapp-views/mprcontents/4a/84/4a8475b3-14cb-4100-8963-58f2fd3a0be8.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..aa30e8a9fd972d3bc72e5b914aa083ecf8c42780 GIT binary patch literal 1817 zcmZ`(OKcle6n(LsVmXb7Liu!oGz$|xB2JvhZDPa1*bZ@Uk}6+5?P5H0ojiB)p8MX6 zV@E6?d?Yqdg!oCQ;2muQ|_svZ0OfgyHjo-cR z+;czY?!SSM?szdz`Uw7QIZsc0wU1<6kll8fZU9xX=(^wvG}@-w?0WU zYvE0fGs6)Am|bXq;nv0#o{AjtsY^bM0PJVHrwlR|AyP$6y4DX}O) z5CpB&tY(4P)NW=C1EfUvRt%$A5Q2*~2Zct$ExT{VSNaN&Nwl@oOLTv7>D~QDa?hVV zUp@Uw^{Ss<4>~PC!ql>Rxv0E?Q_9mScj6=0c;! zg7x!rgtL*{!Ee_Wbbd zgD*sj((Tiv&P+>nHC&<$$Yy}x-^)AIUoNB`J)1tlHmctq@Cn`;B=`VqP!Kss)B*bt z;AjhOEg?@X$R2z&m`r7`7PEq`P=+b&GDpxnj7HMmvIY`#r$~|W%z@m>_Nm(+`EIlH z=Hg`Koo9}xu=UfrIGU9>uh{%pt4~?KViiupt;;#E9l=~3-LC?)#I{=eD7N?eA9E*v zzWBwb+Tl?@dnJp!ZtLoWj4yF&x^GEnMqdNci&kGWCB8xp5}qwru>v`6Szg1)*EkIA zCNSx_*fv7>nr33xg{86-qwX( zsH2KVXbvnKv^q?qP%LoEO`_;NI5w6^j*pYjJlz1dsUyRg5$s!dguEPxdHuxsG4};y z7MSTr?4aQPWigUG=&|sc=W5MeTOD|flE(sq{yaamb?M5V(GpP(k@_ literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/4e/70/4e70063d-53e1-477a-ab92-6b763330c293.mxunit b/testdata/testapp-views/mprcontents/4e/70/4e70063d-53e1-477a-ab92-6b763330c293.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..4df5938f1d57be37ea1dda9cc4698d89e0444dfe GIT binary patch literal 3035 zcmZ`*TWnNC7@nTp+6@vUjiI1`CM+m5u{SP-Ai6i$mfh~!vu&jiaC*+{?$C2t&dlj{ zt6(HN2sB_&Bl3U}kr*Tfqg10lsqw);NF*j2V}iy@RD3W|Uu@KW&SfvP?8BaO{`v3o z{ol;jwy?nDx32fEOTrh3mGe)Z(*IKOf9Y^JyE+2{Yg>3O~fbhnw7&cY*PY^ZOJ z?<}@B3MC9YrjayNEHkf}>W2vCI5VD7Y1Moo7 zkfmiUlBbhS14uU9E-ZM-4M!$)RkA{S;NueGGvzDuh=^`fk(PhBR~1hE>NA}LZT zX;g8xV$d`>G!V6cQIYe~=8g`{RIpYw8Q_D;KNw6p@;R&55{c9O}(0a z{hAcV9FJi}2Cz<02m+`ik8O>k>Pi+fRyHja0c2q(eFF#kEg(s<{hB&fTM(H#sv%z$ z_S)YaqRK?EEkI|TD_FOj_-=&6B)4$?gbo0Qq&Tqv!6I~P5^B$y79X(nF#y;JF&{#n z#*_Vc0w)PB$$%$U3jb2IsJK^DC{rRvMAfMw`~bwox9=8!Cq8pT10-{+g7!k#8eWn& zq|Z;?d^~l*_~6|=|NQ3TB@BW5)`lDG8#mOOK^4+U7;4F+fcPa~`@aoc*VEOD0x7e= z&~8|-C6M~@!jkM?-z6{Y`1{~=VdWq5O+iBf!pAL=XhM=e9aY?oUjTv9#8BxZzzzj+ z7FGa6dm6&GUroH2et&KMd!xhX?82G5z3}azG=KfBms=))T3y`N?dVJ1j)Kd;IstoC2Gok@o3gD#y z?6jm2nJ@;l2Uu&3DpsY$V=!IL96VYh+MR}YL<;T-df{Cp*q%N7)p1F92*aH zIjWOU(;zX(&WrA{wwMo@n-(s3T*x?8Ql>KQFsihTT8zhWC6gZ^WZW4}AIDM>vtmRm zm@r929f>iF#zfLcf+^5=F;*nX_?#>yF(hfc&$NbNrUbm>rYX+t4j=slG2wLsa0UT+B z7K|OF0@z?M>o5x9kOGy=qbV=9ZdV{z7$-XDaWculFbYRe!4m1_FdK)n0tMEi8}O(B zrMzg>(yjLQyIwxM_0ZDQm9-vFmV=THjW9SA1wjvd5eh~W@h$!*fS$Zg$4t!x{%NF4 zmrNpznF@6X+S^2@bzouZ2GF6aC6oO&F%+^5;Fh8WacEPxi;^h{q;CWDqfp8$!#ifR zgM1iURGjQii5t+W1}gga?e1!rFOu9=>Yb4RkNp7fiRsXTFFXJNCnh4PNLwKq&`X#KOKC6ml>a zGj$j)4%Z3>rATvvO777F$ROgPgMpI45vVyQmJkcf&9gLD3`T*C2*ab6>fyn6(mT&I z5517n=#{7NaWB<|qqh#V)kX#DgpFH|0=;v_=#CC#kpFjc-31rw8sX1lEFO^}l!T!1^#0kE&_)#dX?e%Uh8GVW8N;VE2S1@cR#g^51c+)_2e1DDmKA(I literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/55/86/5586af73-672c-4a18-9ce4-8a9c0f092ef3.mxunit b/testdata/testapp-views/mprcontents/55/86/5586af73-672c-4a18-9ce4-8a9c0f092ef3.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..14bb9614ac942eb7422c8ba8cafd3ce3eaf97418 GIT binary patch literal 1793 zcmah~U1%It6h4{Vt!ry+i2aAw2iJ-sx@M^o;)DB_W@C1f?q(wuB;L%NY^Ryro7|aM zcY{cx_(Soh_DO8%Loq&xwmuYtFTM!AXrW3)#S$tgflw(WBGL!(%ui-^>xv8ech9-! zeBb%bIn#9qKrB(nK@ZT&yzpA};o6?zGryiVv;EFTHk8D8a}HrAnTon9#k3(_FcZN~ zh^GxY$qKI8QX;qV4ODGQ7aY2xt_iiG&};+C!ax)5qBuQC=yFoU({po1GtVvEYNk~c zO%p%~BB{;1ltPA?+&huL?wjcrt08#3+ zOgNtqEm$~f0;f}g6rr!kV=&nk=-u@L8XjF6{$iti?T>RkC;ULYVIax4X)u?tGQxPx zCNx>0A#5QZkIW#SuyAY}3994&5fos@s3+4F4T71TY&MlN)3-!LS_ksDCefa#oE$Y2L zy?jr+V7gKcF|HZp1d$P)BP=>Gg1&pGc{9_W=+1uHGp?OFYdz?L-%U;};4`?Oqs-c} zj#S7tjFuF}1mP4CqI4-akOXB^>NTAcfeuFK4XkbSHg$@5<~C;gdl#?&uybkn^ZwJj zkN^2CQHd9soJpHi)6s5gfHdvbYg#}gI4q-OOYV--Q*GQY&Rlx6^xQ^a<@_hlUte8b z^||jNryR4GbHhwz1Pu`Lu7gryv))iXyHKRw(Z{5i!b#r;BBN8ssT5gZ7@l%)mShc! zo!OCkV(jF{x#e52!&mm#NBo5MQ^Ejk%aH#oTGTr+VUWUx?Rc=Q9hcu+`{h2d_vwq> zr!KGGy!y4DcP40IKCN3AcJ95|E$rJ^r*^P5Tzq-Zm2X6YbZ zwfXytr{CUIdhgPcYe!z%_2K_Z2a!IQCn%vBw|7-bj21O27L!)KPK%>6fjGr<2++HQ zL7#I#6PJSb+j8DMqkhgELu@0mO(qiCoaU{j%?!@BhB><6ouM8_P#a2S4jps?zY literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/57/d2/57d2ecb5-6c2f-4ffa-a0b3-a37a40da3adf.mxunit b/testdata/testapp-views/mprcontents/57/d2/57d2ecb5-6c2f-4ffa-a0b3-a37a40da3adf.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..d5b42c0e0e6649c123ca20031882a4848c593034 GIT binary patch literal 57322 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 LLijMj6A1nX-e4Ht literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/5b/27/5b276f2e-eed5-4fa9-903b-3483792cbea7.mxunit b/testdata/testapp-views/mprcontents/5b/27/5b276f2e-eed5-4fa9-903b-3483792cbea7.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..de16901ec7b33369148dddb951dc643e008a8724 GIT binary patch literal 5638 zcmdT|Yj6}*7QS>(NX$|Q5e&M5i4F$KRLn3*0B6xmW+usKCP^_#piql+W^O`Dr+ad{ zJ0^h3!vY~Z48AZ(5CIiZwTns$@sSu5WNS;MP_#;RU2A#N3W%_%_}bDvx4Y-j%}~64 zfT_w%av$fObG~!W`OY1DJwhpVzZVTd@RKLy-2dq}TdF!;1&_Ap_P@;7YuY0ijfANH zA0kqtAU!PGV}GJFw*Vs^NerokU{Ue4;gG8El86S`v?&}8NMTjLQzQY)Xb8v|mX8L} zqte`wh|um66<%p~heBAE5n^JJLhU|6BoaSK+r!4@jcM4$HDS~P^5RBZJ8j$C4Nuvt zUK#V6iyblIX2Xa-z@i%5rpR{siEMraH_J$bQBaA6FV=x(m;~hw&snhL)C%8bY3Ow) z7R|lE5R?%YL^x3vIL%YN^yxK;$}WK2ajSC%ew(&z`QyIsdeJc? z-F26t>?jZt^P+4I;uaN)Axz~Vn}?He9Ve)9Xc$wNRy5Vk%T(XQzFO)oAO=xh^Zoc z6EA`RNhVQ7Y}RC66sHt@+GN?c!$;5C;dra^(atgbBj=5CA$B}EV8S5vZ=Y@2TK9;% zD>7u~zuMS>i^Kqf<`9b%1Bk^s_=@EEgBm+0R}D;+}&J<_sl>2{$<o%B_{jxyxUzGQbc0SQP?8lLab)wHr;C6a4zPxGoo~nf_MpZ9ax#_!$1Q%|1 z0=Ts%aA$2U?_T)Dkw8~ZXZsC>Ov6Qin*yQ~qFy$(y{En3E8*q1<0H{4@_G<^ry_+iVZpH{`vQl|6L@uMqRu749VFodZ`T2G;08Y@r2%SLY5ImHSnC@$%!3-Tlmtdn&IK2G&e) zLHD4sD;Uh~`o3(D{ERa2&7zd65(8^#Mr@G z_rH30a^6!qFYF-486S z2@`EQVk)_2j3~pJK05#el&>aI1D;VKNs(Yg;1$GHH9Q22$|DJaP5{w^KtJP*7B2~ z%rT6z!=i-9gch8@y8>*t2&2upF{8D*Rtv$6g-A7>XwCNLabo}zc+EH`ev`mR^BpY6 zcNI|hicl83V8ku8vaRIj(p-=F7Snsj_V?~eU0>Gy<%%P@8J==O z1zOI$?r6$eXNs8Mn$!QCx8;C$;fEhJ zkNSK;yCEtI7A3@}UZSEdiKaaBs3nP=HzDeokiL@HwX~OYN0x&=(_MjNrKP6AeAmS2 z4NuHfko3-#7A8Vka27^4X=MvioQUntfncKl1KOf}X#fBK literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/5c/68/5c6805fc-cadf-4d72-a5ef-f25da7cf4c97.mxunit b/testdata/testapp-views/mprcontents/5c/68/5c6805fc-cadf-4d72-a5ef-f25da7cf4c97.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..d67cb634fd9ca220f90d58e4967e64a97c8cd303 GIT binary patch literal 4934 zcmc&&d2ka|7=PUajRh(OtY3_}%Bipn71m?nWnbD5@8hofvaugOj~yX)>t+Y}KI zMXNZ9pyCYRD7Iin#0v#lg+V+JR8-J$#9JA`gRvsa*aCiUce~q#(uxP}Kgqkk>wVwv z`@T21KSI5R2Yjd>g0H<-YWkP&D4~V}tut1=AAFP<9?!}c^@gR%l9E=3N7*DsPLCi+ zH8>HH5*Y!~qiR#i1#$mzo>pA@5tBx8l}AW0uM0+Kl3 z2Qj{^$fkKtI{<4`Jj9fctbq0Zi1B2IR4H*8{UA`QB9a^tg)DM=S+SZ`i!q-}5p)wn z&!UdmGCo04=sBl8$@qXm8jFM_yd>(RUH)25$RuzAIQna4Ng+X8jRkZj94Y1#2`nN9 zRn5x^0-6Tqw~Vp8dfB_>cz)=W(z#z0-v3hY1Y-o*tdt?zNvB6iYOGcC+u54tR8Yql z^c{>WlA&3Xo?8_0cprQ4?MDv3w!WW1av<#0?b{(r2RXz<;iA%*z`dOhyF3JpUhb6? zJX-%7m*gT=869GI5jnveNla8E+C(U~iE#N_zeILEBV7ORHH`yg_t4Zyw3?#u37eyM z;MOcf7Jql=+C8~Dy)D7Q+J=jF{dNMCzw9V)fmVj2fVtMg$l|g(u;sv$sr$qA%NMnM zy7lH9gUb-pTsF7MPGyA*M$8R3i(IfJCe`F8Ss6L>`RO*&N7REiE!}Wwq~(J5My&5O z^df_FzDYVFQzz<@fxd*!fW1Lhfl!36XCKe?N@9YiL|I`fuksbVz>{nVCWIH0Dq_NL z&05$9np@Ee7>k^sgo;St&MtMJS7#d?{P@RThTeQ%Xyv#!CiHHrUu-CHuBnLbrIZ}d zvR$b?_6MyPF=^bUz5YA4KHl;~Nz0DEf2F4u`%bSUWYVIF8X%0p6O6!l1V)x8ycd1^ zjHX1)-xR6&Xz8K3ZbLXLa4ao#a@{EUX8*y@pV>Y#_{`x+SGT})E!MpA}-T` z?3nSnqYAF(agB~-=4_m;5HLAzFEf>bE5`~R0l=059RgQCVW5iK$L%$-AOsL>S@l{N zSyq*L@1Hq-^CsWj*Z$D#oZi}GSi)`kjse_pTuZt!Xl34~_v|hWMiw4k?(l7l4Ln^2 z?IEHZ5O9PVX^#!M<{a|>aa-P^(7cPn(eayC9W|sq&s5qg@Ng=Q)3U%44Cw(u#8IR2P{=(1x*bsQA^2m~fgHziLIy6gDd#S@Ut2qM%r|szwIZaGA7qqnIFnJc( zBwlvQ(K^?j#n;W7jRNKCo@(rt)6c-)!c$0lokV`%$ibz-ns0(@+m;s$9yMu?L5SYB zAu<#NDrim9Clh4FWCkh?uav+V*oi`UIEWZcFA9EfT%{7oa)qeI#^(7IEwKULOq}v; z`}rGp<<(W3A{#80mzEf81_R+TEO1g9=MyO(@~x7ef`LpcAo}UBNu?oR^f4$m4ANI) z565A(D~zR!Q~|yEpy8vb!^E(dzzUQ}aN#gnJO;TT1_Llz3q$Mc!RMG_N#X0j@45_9 zSBcDWfc8b0)L<-j0AEgbflbY;LbYN!-cO%wYa&1H^ei~i@cG!IedC6CPaW}R74+S@ zrl9cpN8hP^ab|sEAA`_;UhSEPUjX-JVbc&8Sz0c~X-g>$e{eKYu16v;5V#}j*mVI>3*%R74OA66btM+}{Y*>PJ z3Pyp?(cmx25CRb3@Mt3>+2H8IadWw{OSesF{&mOhH4{DL44hz>Z^?mAhy87e4*<9rA$IR}Ef+PUK}0aCy|M z$fXoXl%SnJG+--vC9Q#4K&+GyLp6n(kWXlHDgp~3Xr>TjN`q)N2(hwEOS92|H0>L7 zAa_PGM9z?oHCvx!;rj8*8oV>m9wilkB`Daf#GTtQ8-f)b)lS}@&AV(|;{`)wMNP-r zu82+cPdi%n*Z z>&|&;Y`uHb5JQPmPQV-ET;9ZyOT&-7AhoW1f9-sO&cBj?Uj}t7EIKr3`re$c`$is^ zbJd#d%bxtKTTXRw)JnirHbSGWI@X*X_GZzVhRypg>3{odgV0}1z@0NAv;;_Z%zR!e zF<`7YdpA8sZh)hSUF$URU_09>*n3KydVE7&iqHxAxwOd` zJjK*exJ5boE=x|j$r0xXti1q#EeHSkyjVDzJ$K5gX{%?m!`6M(?d?>w$#`V*OJh&2EJ literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/65/21/652106e9-3f38-46c7-bcc4-2b4e4ad39160.mxunit b/testdata/testapp-views/mprcontents/65/21/652106e9-3f38-46c7-bcc4-2b4e4ad39160.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..8d2c8f7b26ae025e8bd0b8499730e4d734618887 GIT binary patch literal 168159 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

@AzrUVKzSVYe{*!fC@BMM62x&YfQ6?-nt#ddjR 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 literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/6e/16/6e16f1f7-1207-4d70-8847-c369a7dd360a.mxunit b/testdata/testapp-views/mprcontents/6e/16/6e16f1f7-1207-4d70-8847-c369a7dd360a.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..7bfc1e2c2678df043a65c71dc3071d54dbb81a55 GIT binary patch literal 2066 zcmbVNU1%It6h4{VG(!!N#8PciB+}T5;3k`fDncLjuiLHJ%_iIS!KdDxImuqVJ2TF` z6SF}?eGv6gEm%RNpzVW5(T7reu#`}I5#OXD=u=t|1QirPQ9O5MvfD|w&^j+ObAP_? z-0z(4+%XeiaH>*-5uoSK-*!7gV_x;O@&-Tu{h^)4)bWmoFhx@fMW$FiXbLV{Q}G+^ zaI}H`Dz|VJ(m5gA=h3dZb|7%c6)0gp?H!qc0eTr(xQ|}d&JpfQ6)nn~km%D?E>JCW z6t|e-u2bcf?=BNoxQ}oXvpOaTdlJAq=r7MCw+O3<((<{dfuj9J(Y1m$!H3{6W{KET zr-4esX(Nm=MHs0pyh?vPNuIva{N`$Ivi7;R*ZVYg@7J*;_tV;hBwaWJuu@A#W?{nQU< z&(6QG>r6*wQxh@MI4w%(oPaP3Za`UW3R2f>Jj=bFCIjYi_vT+nEg{pDiI7ha2S0uG z&hX{Kr3*XKYonJRyc82Zu$@?MZ@7VPAsys|XS2Rpw69OTy>H`{%A4cv>A^>SIe0gM zndAl1j5{mD4e4G^CH$SJ(4P|(dP|a{!nx%DJv>MkT3pywY&Vh8UrvDUC9C$*a`dHt zKi;?eXyMGoXI>wgc>L7u&eQD(Ii-Bw3>1^>zA_sT!p4qtT?~ z+6Ub@=9Mb8=F;77vSAQbjJICy1;f1g;u9pSnqCXe~lEyPNLPY&Y4-%OJ+tnVapH$(`+; znRGW=NbwPcHrSM=AZQXJMPgbIO2r=q@rSXB)wYO*{*WSCEl9MA(n8TQkL+f)3HVPJ z_I2;M-#w4-d}rz_0m=jM2vh^UHvPTs;F2~To9%B6Ug>=AAQ$M$X9;Y=t%M>mZBW$? z=z+pd@P|}f32Rg`86(t)=7?k(ibkQ#XKxWXp~K`67X@`tinH6Nvh zV&p@TM06d1Dx2DAdvkg zp%St1z!w&E(j%&7u{0c-LrsL}hC*!@G2@g-R)ldXlboF>%cgvVO^2pXgD{6eOLQXo z$!CYc3;7Kv*DpO<;g9QfDPtm)RWt&=U7-ZVEQ|v0+OC_bwD@CuM!Gcp!Z*h{UFRP_ zqZQ7#l2L`Qu*pUUCeq`h!8El8{nQxv@KA795V#~$G77~2R@sD_!mcO0I{g;j=4p;SmF(6GDZtRxsMMOiiPUwMT`gvrSeVTK0%N8^t zucMM|LkE!D++J~*KrNo@A5CCeD;-QrlL6P9cOb%=syaOd^~ly@8&qBV>*Mjcy4cj^ zJFz>{dfs(oE5a3GSRuc6q4}H@9WHu=V!tQWQX0FL-0!_aFhpiVM^LGNOA$-sMo0q1 zNF{bpfFI2pgTY<+22M~$9m#977O=$|VF8t-7+7i9+&dri-s#lL`W(Tb0KD@bE_igV zH!W{V&hLy({XFwqMZ;GcSBJt%GX+)Dh2V-3q|l6A!V0__r;?f$r6DUs-BgVpktt$Y zC3X>UFhZNcbi=MJzlDp}3wF##2Z!x{4`sv1Rwe^ZVj8fTckX45Inr>{jdjy-t z5hHv2g7s_?D3a~2hQd&n&DUgTIkqgOB`!O2xGPQn{B`J~@UhvAm0P~6)wph^IyLa& hFM)_m+Seg6lq($F-bb^_UX2Y|k9tn`#3_m>`~xM``HTPn literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/84/77/84776610-13b2-48cb-a265-d5e984d63129.mxunit b/testdata/testapp-views/mprcontents/84/77/84776610-13b2-48cb-a265-d5e984d63129.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..2f32df0d608cf86e8c4ce55934457b727e6cfc12 GIT binary patch literal 3409 zcmWIKWnf@c@pNGjU;qMvwDOis!lykJrCxp6a?MbaNhPGRAeBK9DCLr$o0ysBo1c=J zQ>=o-VPJ90%gZlGEXmBzD`wyU8DhY|3^I(xvA8%tITM?Vb5VYAF-~bFm;B_?+|)dX zp$v>*!(8)9GD|X3iy7tt?SNXrY{-zsj_iJa`-k73FY)qiIoaa(qWtZ0sQV>=CLlSv zSOsiYrDtwI4$zg!$*IM~L8UoBvr(O2Qc{$eR9XTQFK0!zGXUr!M3@PtzD-+T_|>(q z(ph=Vl`|49=r?Y zXBULJP!+?4r6u`(rMXF|MR2!*O=DmVOUx-vWe7(xwO^^=y4WKN&l&f2aymriy5dSJ z!6o@csVNW>m|RlR5=(PRpgf>?hCs}0#1M%Rq#tCZ?+4w!;kvLkR7ARYaWKt-G_K>X zv|&lD_qv)-(tUYWA&?+t1G>qnC@~KyK%-H--om0jpJ5TNO9t-@&*w(qnLN;ggZ>$Ea2E%0vE?LUaH1t(WTVnq$x zy9=52RM|;-%-GBB%dMZlOZ~95)62PkJcQk8-E$4m7XK@-*uyB~a3@??*qSiJqNLkO z8Np1~lerF)j1J3w&wnjJv#=Ga|Jx$Db)M6Vbq9?f+3XC0ge?~^34y~FoNM8Q0c!YW zUCC;Fe^k_ckX^>lV@&XdMc!DVFj~Y5=#;xIg-UQFD11CtvX=x$xjAX zW#AIm(8$2r$OKr?`6gB-rMjo)r4}XTWL7~GEJDf3$6l`3Ji(dOX}6tZ+p+H#-a&&y z4yXvFu<*-AGls(>vDi5yF)uwe#iQ+3goAt3i_7=ods1BkXlrn zSzMBslmqfKB)zZzo#32U#K0AtUs{x$%D@6FmNQepe!#2_L8Y`3uo^^^QF}w3ewf}C z^q6?E@5qe_5e!g|QC!*XVM4BK{ZMjPlk}5$)$bKuR=ki3UN-H>eyE+)uWa2--*cF( z`R}!I>oSeMrcX;DA%>`IT@dA$CrWe|z2iMCuKCet2bUsm=7IMPP#4OhN4HNtdd&c< zW>NF&w4G1hKJk0w(Xjh*^K?6xbf|e!=;rws=7eRYmP2dSpwzU~qSQQKq#`>RT-C;) z1n35pJi7;h_gxxFYDyZ-FRrFpfS!2aI(xc)x_9ryz3(}0*O(xx1H4u38?z&Yx4r#* zra$m=kYC~30(GAvy7x(`Y9mo1Z2$TDUfY(h@m_yvr%c4nKm$?(7|~h)8jG!IM`nq2 zDER%qL-sk&{WbgV8*%r@P*`Fan*mEKQ=&?&lxDf(l?~5*y4MT2|FjqMhla=?D6xR$ z4?`HJ$YO>TS->(8sYJ5yJQlvA=ZoK>vwQYvmzT`Kl~|y81=0(En#2U5VOkYr#00I1HtCIA2c literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/b6/29/b62986ab-1ec9-5c84-9d33-1f0829ffce9b.mxunit b/testdata/testapp-views/mprcontents/b6/29/b62986ab-1ec9-5c84-9d33-1f0829ffce9b.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..573b766ade9f1aff774301d14c93fbc0a95c5aa4 GIT binary patch literal 9165 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 literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/bd/6f/bd6f65bd-2393-539d-9388-4684a54d0646.mxunit b/testdata/testapp-views/mprcontents/bd/6f/bd6f65bd-2393-539d-9388-4684a54d0646.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..bda52ddcb114ef458824855937c2bf59955fa353 GIT binary patch literal 1864 zcmb_cNoW*76#dhaWejTEH3uQ;Tto?3j5xTEn3+kUX&o~O@ur>ba#E(dE9t6^8JB<( zFbGj0>P-dFg9p)rf(nX=2QeZZTn>8iB7)#8qJrY@Z5Crt!M-&0m-_F$U;nq21C&PF z<1i2KyGPi)iB;#*6JyEID{@&WA{$LvY(G&&a0aPaGpLzTI6uHF z4J3@M^Nrn{SUVJhKm?8Qup3z5>SQ{Bx#)5olnYA{%z{WuA5r^r_lHX2#9+=<9L|LR z=GD42!gVl-sd?;`kE+Y6CZXv})$XISVwInK>`O2xxGNgm)NTyuASFzJgkWhg0?N#q zW>sajDL~56nu9@&*+FZ2$A)gLzF`QY9Jz()(h%Yv#StP$qr?etC~{bV5eOT>gH-n$ z#sJ}ZxP#t28?t4?sb?cytz*|dXie{*RQ!K7iMGsOtSiRUJ{h}~V^a~Zu@OELkXTkk zq7+X~4*npLFqK}N1mbcC`Ic6jaDiG9&XHo0VhmGlgP4I)$Lu`k$k4~}_ID?e7r#Us z*FS%A-RHa-gK{05eo3dTlNM$Qg_;gvubgA#FdH*b_b~oYc^6I46t@`=Za0(+@evXX z%7fXJIefZ&>8|*tCvSf|WaqZ{U{+#K4opGz-=YWyFDk`!s%Fzf2x?|HOcWLWexz*} zXex1c>haCXW5;g$#j6X8&v4NK8Q-8`8y&>Vh(1>W%z{WK8MLWI;xt1AHpoB9beL!@ z3MY9}I&1VYr!i8%vx5u~9i0BVlbK|e!JAD@rmDsE>FfZ9NLpA_6*ALMME{UNWxFli zO@UCsLwbQLB0t0mPDNhzk6$a-$J8TD6DP~ozK%^D_lYmS+5wtqu7$rY3Mo#wYS96K zBkc9Jw6!}}ZQs_s93L?@CT^A0ZmQUPHd7)iCL(kmt~aFqV{wKQBA&BZ*F9gzH#ntv z4;6jP7M=%6kTgCv?{o-r^Mk$|PX(*ZT`WZ_LaK+w7?aL&L5!M$P~3u212~IC&L*kF z9c8gdjXQDDI(LPni8VxVv^*n7?xLN(O`X+JtZ~osy$9dC{k--5&i-!~=4`t7A2oh8 zmA+~WZo4r*vExDK(+~foaUlAiG#)L|c+qr?J(23caU}`SdKI<5NWTVmDty%gNoNwf Kab~d#ci>O1?ieTl literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/d1/21/d121ee37-f102-56ae-b644-160f1d340296.mxunit b/testdata/testapp-views/mprcontents/d1/21/d121ee37-f102-56ae-b644-160f1d340296.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..3fb28c32aeda71daff6abf824405e549238aa783 GIT binary patch literal 402 zcmY*V%TB{E5L_%?LP+$4xNtx!$5cs_D&YvEha#YrTJ>)1r8Q0M$abpo8yxryj{FBd zg2W%NA+1pNHaojBqj}o_*lYK5XaP<7eeuP9J|>^}>B)J*-dTJ6S|XegHMHghg?H@- zBdY>O=)KZY2keGBlmk6M>y*(zdI0QphGOoj#ReL^MC(y*?+<&LKNXmx=c+(Cwx%NJ zX2#UoB-2}!#`=3)VgU!FJ2I6OSnoaR+U}7ru<_Wv<2J_G7Y*IcLN@WNzl!r6yq$3c#;64c09F% nvN8H=ZJ)lcB~=4#WQxjD+&+c#>RF*(du>{)Z=+7w2?2fqXhLrb literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/e1/e8/e1e8b130-26ba-43bd-b2e7-0f6b61a61008.mxunit b/testdata/testapp-views/mprcontents/e1/e8/e1e8b130-26ba-43bd-b2e7-0f6b61a61008.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..0b4af267100d194fbf13aef197bc580ef047a73a GIT binary patch literal 4727 zcmd5=c~BE)6#qJ^Bq$N2$LK7{7hG9(!|n!x(;~Ev z$Ba7CB3dg}rx)1K0grl6kG9@ATC`)WTBo*k>a<>MXP9bRr*99EY=n+iRsP6?ec!(K zd++_e@BQ-CL?aZEU^b!w2>y~gE?wC1+)l&BpCc=Xj|ON}2~%s`7!3xKm2|L-i(+cH z1m9Pr)l=}qz|am4%VL^0d9lO8lMIdeX+#rtS{bK@!bJ>)ITQhY1|*|?LMW}??WSr? zG*9xidWQpZ96~CeCr7P`Wf|5VNqk4`v&&|9izvq^5B&L)=uTV9EsVG2H*K0e^zcgy zx5!C^!=kC!%X10BC(@XCT$4JZ7Z^ zj~;otqdWVMrTIPk9~BEiPHN4ZSm->0c2ckisVyXp(VGy*5B~LQh6C5XTADle$EvIP zkdq^1@I#c~bMR~uvjz`ajiHbVj6BOwC4iG*5!47Jr6!M0Myf)VEGKD5A;260=9F}> zqG+F4k)g-~0i9%4B$v46JLXzdi=}=WGa-9XK^Ic%g4!@=5Flqc-f$NeT_QJl4G6;*Aq!p6#pt=QfBUwV|WX7;(OFwQc>y z$cy`@eK6&5Z9_1}kU9-DC=MlcT~uV&`w4|RPu`j|e2jC}qiO@2x%ics6)W<$yqy$# zY*}ktA8n`?w!Lpu6p(z#tHdr7qyiP?KB>ca~}wcj08^z zZ*U1E4Cxf#MJgLniBS|-!6Qq;_%eBkO1ChP)rDoV;9xc|6eZ0ObTu@v$}Q~Pt*3O1 zt#kFhTgDSVeJw{y1YbUw0fOK*ZyMFT*MnIM$O9Z-Bk}GWEU#G8X)O%kv`Bk1oDk4BODb%Lb=su+GacgHhtOFy10@>WXbolcTD+@P7*JmD2{IW++j8rBmFu4pQ zP>?H?BpH#QKJ?B?l7^71D285;K_Ima*VsX*K?WQug?0Y=);@EgyKG{%aYJeQp?^Nf zjF*cNti$A&g>;a{=Sr5Zq6&i)2^yqlk;47HsMbwKK6B^nAiG*~R$l%>b|0tA3?vW7 z6ErV*(Aohfg{6%G3st3Vf}bg_VF{3K5_JT@?BSV7n8uJTHV|$}qN;MBE#)i}-wB%- z1eU-+BX$wcCD7CIGNFe)x@e6gCp1q`w0Fbnlq_Aq?h^EG3T&?oVSA;a{R$cf&z|17 zCvnb)tB1zlE66fq!juAn824hwbhQj{!8!C0S_xe-ruiw6lvk zZHFye^yl*CN2xnyY!8FTS@W!$HJytMt*g(3hX0ZgCx?DeM2>O|cWr;UTi=qnYuJdG z#ZSrZ?}Etv9$zA-X7)+sbaYukzO(-_o;G4Q7MfouW4I3BM4m?=eC1ZRTg#LZOdQw%@ znD=RF>z`p`8VAXt@14*}jd5z&p$|1hMpL6|+o*NBn|RrM&~I3ShvS(_>0DNc6@#Dr zEZi~(R~pbwlmvme-ZP1QrkK6pLs+gsu!K8t2c1*C2nUe_?bS7Li_-<81Phry-wnDf#l6Hxi I291mU1!;(Nod5s; literal 0 HcmV?d00001 diff --git a/testdata/testapp-views/mprcontents/f9/28/f928bb10-1832-49c8-9415-7e24495ec970.mxunit b/testdata/testapp-views/mprcontents/f9/28/f928bb10-1832-49c8-9415-7e24495ec970.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..a93955a2957dd944a79e7f0c39b53b0addcb7e43 GIT binary patch literal 239 zcmXwz!D<3A6h*HAvAA+maGAwWx{z7;33jG*Kx?7mQV7Wt0-0pYWYpRC75y83K)_H-EXVJSIeaes0G>R0!@?+(EnA4sN2GMj zu;tF+D{fowO1@~{wXZ{b-55%v=c49vSd$?cxJ Date: Sun, 27 Sep 2026 08:31:11 +0000 Subject: [PATCH 06/37] feat(security): create module role is a createStatement kind; drop module role if exists (#731) create module role was its own securityStatement rule with only an or modify prefix, so it took none of the prefixes other document types get: or replace was a parse error and a doc comment did not attach. It is now a createStatement kind (or replace is the MDL-DEPR001 alias there), and drop module role takes if exists like drop user role. Co-Authored-By: Claude Opus 5.5 --- .../skills/mendix/manage-security/SKILL.md | 3 +- .../01-language-reference.md | 9 ++- mdl/ast/ast_security.go | 6 +- mdl/executor/cmd_drop_module_role_test.go | 35 ++++++++ mdl/executor/cmd_misc.go | 4 +- mdl/executor/cmd_security_write.go | 4 + mdl/grammar/MDLParser.g4 | 6 +- mdl/grammar/domains/MDLSecurity.g4 | 11 +-- .../module_role_create_statement_test.go | 79 +++++++++++++++++++ mdl/visitor/visitor_deprecations_test.go | 1 + mdl/visitor/visitor_security.go | 13 +-- 11 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 mdl/executor/cmd_drop_module_role_test.go create mode 100644 mdl/visitor/module_role_create_statement_test.go diff --git a/.claude/skills/mendix/manage-security/SKILL.md b/.claude/skills/mendix/manage-security/SKILL.md index a1b69e207..9a839c4b5 100644 --- a/.claude/skills/mendix/manage-security/SKILL.md +++ b/.claude/skills/mendix/manage-security/SKILL.md @@ -178,8 +178,9 @@ create module role MyModule.Viewer description 'Read-only access'; -- whole security script stays re-runnable rather than needing a run-once file. create or modify module role MyModule.ApiUser description 'API consumer'; --- Remove a module role +-- Remove a module role (`if exists` makes it a no-op when the role is gone) drop module role MyModule.Viewer; +drop module role if exists MyModule.Legacy; ``` ### Microflow Access diff --git a/docs/05-mdl-specification/01-language-reference.md b/docs/05-mdl-specification/01-language-reference.md index fc788f49a..246db1671 100644 --- a/docs/05-mdl-specification/01-language-reference.md +++ b/docs/05-mdl-specification/01-language-reference.md @@ -988,9 +988,12 @@ Creates a new module role within a module. **Syntax:** ```sql -create module role . [description ''] +create [or modify] module role . [description ''] ``` +`or modify` updates the description of a role that already exists instead of +failing. + **Example:** ```sql create module role Shop.Admin description 'Full administrative access'; @@ -1003,9 +1006,11 @@ Removes a module role. **Syntax:** ```sql -drop module role . +drop module role [if exists] . ``` +`if exists` makes dropping a role that is not there a no-op. + ### GRANT EXECUTE ON MICROFLOW Grants execute access on a microflow to one or more module roles. diff --git a/mdl/ast/ast_security.go b/mdl/ast/ast_security.go index 3aa17531a..4f152b76a 100644 --- a/mdl/ast/ast_security.go +++ b/mdl/ast/ast_security.go @@ -18,9 +18,11 @@ type CreateModuleRoleStmt struct { func (s *CreateModuleRoleStmt) isStatement() {} -// DropModuleRoleStmt represents: DROP MODULE ROLE Module.RoleName +// DropModuleRoleStmt represents: DROP MODULE ROLE [IF EXISTS] Module.RoleName type DropModuleRoleStmt struct { - Name QualifiedName + // IfExists downgrades "not found" to a no-op; see DropUserRoleStmt. + IfExists bool + Name QualifiedName } func (s *DropModuleRoleStmt) isStatement() {} diff --git a/mdl/executor/cmd_drop_module_role_test.go b/mdl/executor/cmd_drop_module_role_test.go new file mode 100644 index 000000000..eff9a96bb --- /dev/null +++ b/mdl/executor/cmd_drop_module_role_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/security" +) + +// `drop module role if exists` is a no-op for a role that is not there, the +// same as `drop user role if exists` (#731). The control is the same statement +// without IF EXISTS, which still refuses. +func TestDropModuleRole_IfExistsSkipsMissingRole(t *testing.T) { + mod := mkModule("M") + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetModuleSecurityFunc: func(model.ID) (*security.ModuleSecurity, error) { + return &security.ModuleSecurity{ModuleRoles: []*security.ModuleRole{{Name: "User"}}}, nil + }, + } + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + name := ast.QualifiedName{Module: "M", Name: "Gone"} + + assertNoError(t, execDropModuleRole(ctx, &ast.DropModuleRoleStmt{Name: name, IfExists: true})) + assertContainsStr(t, buf.String(), "does not exist, skipping") + + err := execDropModuleRole(ctx, &ast.DropModuleRoleStmt{Name: name}) + assertError(t, err) + assertContainsStr(t, err.Error(), "not found") +} diff --git a/mdl/executor/cmd_misc.go b/mdl/executor/cmd_misc.go index 346384f52..5b0cf3e15 100644 --- a/mdl/executor/cmd_misc.go +++ b/mdl/executor/cmd_misc.go @@ -231,8 +231,8 @@ Code Search (requires refresh catalog full): show context of Module.Element [depth n]; -- Assemble context for LLM Security - Roles: - create module role Module.Role [description 'text']; - drop module role Module.Role; + create [or modify] module role Module.Role [description 'text']; + drop module role [if exists] Module.Role; create user role Name (Module.Role [, ...]) [manage all roles]; alter user role Name add module roles (Module.Role [, ...]); alter user role Name remove module roles (Module.Role [, ...]); diff --git a/mdl/executor/cmd_security_write.go b/mdl/executor/cmd_security_write.go index 0092a89a8..31249e5f5 100644 --- a/mdl/executor/cmd_security_write.go +++ b/mdl/executor/cmd_security_write.go @@ -111,6 +111,10 @@ func execDropModuleRole(ctx *ExecContext, s *ast.DropModuleRoleStmt) error { } } if !found { + if s.IfExists { + fmt.Fprintf(ctx.Output, "Module role '%s' does not exist, skipping\n", s.Name) + return nil + } return mdlerrors.NewNotFound("module role", s.Name.Module+"."+s.Name.Name) } diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 472d38961..de7692bbd 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -110,6 +110,9 @@ createStatement CREATE (OR (MODIFY | REPLACE /* @alias MDL-DEPR001 */))? ( createEntityStatement | createAssociationStatement + // Before createModuleStatement: `create module role M.R` is a role, and + // `create module Role;` (no qualified name follows) is still a module. + | createModuleRoleStatement | createModuleStatement | createMicroflowStatement | createJavaActionStatement @@ -651,8 +654,7 @@ moveDocumentType // ============================================================================= securityStatement - : createModuleRoleStatement - | dropModuleRoleStatement + : dropModuleRoleStatement | alterUserRoleStatement | dropUserRoleStatement | grantEntityAccessStatement diff --git a/mdl/grammar/domains/MDLSecurity.g4 b/mdl/grammar/domains/MDLSecurity.g4 index 840068fa2..f5a06f137 100644 --- a/mdl/grammar/domains/MDLSecurity.g4 +++ b/mdl/grammar/domains/MDLSecurity.g4 @@ -10,15 +10,16 @@ options { tokenVocab = MDLLexer; } // SECURITY STATEMENTS // ============================================================================= -// OR MODIFY makes a security script re-runnable. Without it, re-executing the -// script that sets up roles fails on the first role that already exists, so -// role creation had to live in its own run-once file. +// A createStatement kind (MDLParser.g4), so it takes the same prefixes as every +// other document type: OR MODIFY makes a security script re-runnable (without +// it, re-executing the script that sets up roles fails on the first role that +// already exists), and a doc comment attaches to it (#731). createModuleRoleStatement - : CREATE (OR MODIFY)? MODULE ROLE qualifiedName (DESCRIPTION STRING_LITERAL)? + : MODULE ROLE qualifiedName (DESCRIPTION STRING_LITERAL)? ; dropModuleRoleStatement - : DROP MODULE ROLE qualifiedName + : DROP MODULE ROLE ifExists? qualifiedName ; createUserRoleStatement diff --git a/mdl/visitor/module_role_create_statement_test.go b/mdl/visitor/module_role_create_statement_test.go new file mode 100644 index 000000000..abdc78eab --- /dev/null +++ b/mdl/visitor/module_role_create_statement_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// `create module role` was its own securityStatement rule with only an +// `or modify` prefix, so it took none of the prefixes every other document type +// gets from createStatement (#731): `or replace` was a parse error, and a doc +// comment in front of it was not attached. It is a createStatement kind now. +func TestCreateModuleRole_IsACreateStatementKind(t *testing.T) { + for _, tc := range []struct { + src string + orModify bool + desc string + deprecated bool + }{ + {"create module role M.Admin;", false, "", false}, + {"create or modify module role M.Admin description 'Full access';", true, "Full access", false}, + {"create or replace module role M.Admin;", true, "", true}, + {"/** doc */ create or modify module role M.Admin;", true, "", false}, + } { + prog, errs := Build(tc.src) + if len(errs) > 0 { + t.Fatalf("%s: parse: %v", tc.src, errs[0]) + } + if len(prog.Statements) != 1 { + t.Fatalf("%s: got %d statements", tc.src, len(prog.Statements)) + } + s, ok := prog.Statements[0].(*ast.CreateModuleRoleStmt) + if !ok { + t.Fatalf("%s: got %T", tc.src, prog.Statements[0]) + } + if s.Name.String() != "M.Admin" || s.CreateOrModify != tc.orModify || s.Description != tc.desc { + t.Errorf("%s: got %+v", tc.src, s) + } + if got := len(prog.Deprecations) > 0; got != tc.deprecated { + t.Errorf("%s: deprecations %v", tc.src, prog.Deprecations) + } + } +} + +// `create module Role;` names a module called Role; moving module role into +// createStatement must not take that spelling away. +func TestCreateModuleNamedRole_StillAModule(t *testing.T) { + prog, errs := Build("create module Role;") + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + if s, ok := prog.Statements[0].(*ast.CreateModuleStmt); !ok || s.Name != "Role" { + t.Fatalf("got %#v", prog.Statements[0]) + } +} + +func TestDropModuleRole_IfExistsIsParsed(t *testing.T) { + for _, tc := range []struct { + src string + want bool + }{ + {"drop module role if exists M.Admin;", true}, + {"drop module role M.Admin;", false}, + } { + prog, errs := Build(tc.src) + if len(errs) > 0 { + t.Fatalf("%s: parse: %v", tc.src, errs[0]) + } + s, ok := prog.Statements[0].(*ast.DropModuleRoleStmt) + if !ok { + t.Fatalf("%s: got %T", tc.src, prog.Statements[0]) + } + if s.IfExists != tc.want || s.Name.String() != "M.Admin" { + t.Errorf("%s: got %+v", tc.src, s) + } + } +} diff --git a/mdl/visitor/visitor_deprecations_test.go b/mdl/visitor/visitor_deprecations_test.go index ebcdd2bf2..6ef4bf5be 100644 --- a/mdl/visitor/visitor_deprecations_test.go +++ b/mdl/visitor/visitor_deprecations_test.go @@ -67,6 +67,7 @@ var createOrReplaceCases = map[string]string{ "entity": "persistent entity M.Customer (Name: String(200));", "association": "association M.Order_Customer from M.Order to M.Customer type Reference;", "module": "module M;", + "modulerole": "module role M.Admin description 'Full access';", "microflow": "microflow M.ACT_Recalculate () begin return; end;", "javaaction": "java action M.FormatCurrency(Amount: Decimal not null) returns String as $$return \"\";$$;", "javascriptaction": "javascript action M.IsStrictMode() returns Boolean platform Web as $$return true;$$;", diff --git a/mdl/visitor/visitor_security.go b/mdl/visitor/visitor_security.go index 713fdcaa6..638c6ca79 100644 --- a/mdl/visitor/visitor_security.go +++ b/mdl/visitor/visitor_security.go @@ -7,15 +7,17 @@ import ( "github.com/mendixlabs/mxcli/mdl/grammar/parser" ) -// ExitCreateModuleRoleStatement handles CREATE MODULE ROLE Module.RoleName [DESCRIPTION '...'] +// ExitCreateModuleRoleStatement handles CREATE [OR MODIFY] MODULE ROLE Module.RoleName [DESCRIPTION '...'] func (b *Builder) ExitCreateModuleRoleStatement(ctx *parser.CreateModuleRoleStatementContext) { qn := ctx.QualifiedName() if qn == nil { return } stmt := &ast.CreateModuleRoleStmt{ - Name: buildQualifiedName(qn), - CreateOrModify: ctx.MODIFY() != nil, + Name: buildQualifiedName(qn), + } + if createStmt := findParentCreateStatement(ctx); createStmt != nil { + stmt.CreateOrModify = createStmt.OR() != nil && (createStmt.MODIFY() != nil || b.replaceMeansModify(createStmt)) } if ctx.DESCRIPTION() != nil { if sl := ctx.STRING_LITERAL(); sl != nil { @@ -25,11 +27,12 @@ func (b *Builder) ExitCreateModuleRoleStatement(ctx *parser.CreateModuleRoleStat b.statements = append(b.statements, stmt) } -// ExitDropModuleRoleStatement handles DROP MODULE ROLE Module.RoleName +// ExitDropModuleRoleStatement handles DROP MODULE ROLE [IF EXISTS] Module.RoleName func (b *Builder) ExitDropModuleRoleStatement(ctx *parser.DropModuleRoleStatementContext) { if qn := ctx.QualifiedName(); qn != nil { b.statements = append(b.statements, &ast.DropModuleRoleStmt{ - Name: buildQualifiedName(qn), + IfExists: ctx.IfExists() != nil, + Name: buildQualifiedName(qn), }) } } From c72e326c4c8043a84b2858e24d9b9f602aaf0e50 Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 27 Sep 2026 08:33:45 +0000 Subject: [PATCH 07/37] mdl: list operations and aggregates as one statement per activity; set mandatory under mdl 1 Each Studio Pro List operation / Aggregate list activity is one statement whose operand is a variable, so nesting cannot be written: $Open = filter $Orders by Status = M.Status.Open; $Big = filter $Orders where $currentObject/Total > 1000; $N = count $Open; $Csv = reduce $Orders from '' as String using $currentResult + $currentObject/Name; The statement forms parse under every version. The call forms keep parsing: a respelling everywhere except find/contains, registered as MDL-DEPR003 (list operations) and MDL-DEPR004 (aggregates), both building the same AST. find(...)/contains(...) clash with the string functions, so they, a list call after `set`, and a nested call are version-gated (MDL-V1-LIST): kept and warned under mdl 0, refused under mdl 1, where `set $x = find(...)` is always the string function. A reassignment without `set` is refused under mdl 1 and warned under mdl 0 (MDL-V1-SET). `where` is always Find/Filter by expression; `by` and the call form keep the by-member reading when the condition is `Member = value` (ast.IsMemberEquality, shared by visitor and flow builder). Part of #733. Co-Authored-By: Claude Opus 5.5 --- cmd/mxcli/lsp_completions_gen.go | 1 + mdl/ast/ast_microflow.go | 64 +++- mdl/deprecation/deprecation.go | 44 ++- mdl/deprecation/deprecation_test.go | 7 +- .../cmd_microflows_builder_actions.go | 6 +- ...d_microflows_builder_list_activity_test.go | 54 +++ mdl/executor/validate_deprecations.go | 11 +- .../validate_microflow_listop_source.go | 5 + mdl/grammar/MDLLexer.g4 | 2 + mdl/grammar/domains/MDLMicroflow.g4 | 54 ++- mdl/grammar/domains/MDLSettings.g4 | 2 +- mdl/visitor/visitor_deprecations_test.go | 4 + mdl/visitor/visitor_list_activities.go | 330 ++++++++++++++++ mdl/visitor/visitor_list_activity_test.go | 359 ++++++++++++++++++ mdl/visitor/visitor_microflow_actions.go | 23 +- mdl/visitor/visitor_microflow_statements.go | 14 +- 16 files changed, 944 insertions(+), 36 deletions(-) create mode 100644 mdl/executor/cmd_microflows_builder_list_activity_test.go create mode 100644 mdl/visitor/visitor_list_activities.go create mode 100644 mdl/visitor/visitor_list_activity_test.go diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index 739fd48d8..19568e7d7 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -166,6 +166,7 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "REDUCE", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "ANY", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "INITIAL", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, + {Label: "USING", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "LIST", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "REMOVE", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "EQUALS", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 277f7c5f8..ade57f74e 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -851,21 +851,30 @@ type SortSpec struct { Ascending bool // True for ASC, false for DESC } -// ListOperationStmt represents list operations like HEAD, TAIL, FIND, etc. -// $Var = HEAD($List) -// $Var = FIND($List, condition) -// $Var = SORT($List, attr ASC) -// $Var = UNION($List1, $List2) +// ListOperationStmt is one Studio Pro List operation activity: +// $Var = head $List +// $Var = find $List by Member = value | where condition +// $Var = sort $List by attr asc +// $Var = union $List1 with $List2 +// The call forms ($Var = HEAD($List), …) build the same node. type ListOperationStmt struct { - OutputVariable string // Output variable name - Operation ListOperationType // Operation type - InputVariable string // Input list variable (first operand) - SecondVariable string // Second operand for UNION, INTERSECT, SUBTRACT, CONTAINS, EQUALS - Condition Expression // Condition for FIND/FILTER - SortSpecs []SortSpec // Sort specifications for SORT - OffsetExpr Expression // Offset expression for RANGE - LimitExpr Expression // Limit expression for RANGE - Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation + OutputVariable string // Output variable name + Operation ListOperationType // Operation type + InputVariable string // Input list variable (first operand) + SecondVariable string // Second operand for UNION, INTERSECT, SUBTRACT, CONTAINS, EQUALS + Condition Expression // Condition for FIND/FILTER + // ByExpression is set for FIND/FILTER whose condition is an expression over + // $currentObject (Studio Pro's "Find by expression" / "Filter by + // expression"). When false, a condition of the shape `Member = value` (see + // IsMemberEquality) is the "by member" operation, and any other condition + // falls back to the expression operation, which is what the function form + // has always done. The statement form sets it from the linking word: `where` + // sets it, `by` does not. + ByExpression bool + SortSpecs []SortSpec // Sort specifications for SORT + OffsetExpr Expression // Offset expression for RANGE + LimitExpr Expression // Limit expression for RANGE + Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation // ErrorHandling is recorded only so the clause can be REFUSED. Mendix's // ListOperationsAction has no ErrorHandlingType, so an ON ERROR here has // nowhere to go; parsing it and reporting it beats dropping it silently. @@ -877,6 +886,24 @@ type ListOperationStmt struct { func (s *ListOperationStmt) isMicroflowStatement() {} +// IsMemberEquality reports whether a FIND/FILTER condition names a member of +// the list's entity and a value, `Member = value` — the shape Studio Pro's Find +// and Filter operations take (an attribute or association, and the value it must +// have). It is the single test both the visitor (which linking word a function +// form is a respelling of) and the flow builder (which operation to write) use, +// so the two cannot disagree. +func IsMemberEquality(cond Expression) bool { + binary, ok := cond.(*BinaryExpr) + if !ok || binary.Operator != "=" { + return false + } + switch binary.Left.(type) { + case *IdentifierExpr, *QualifiedNameExpr: + return true + } + return false +} + // UnresolvedOperand is a list operand that the visitor could not reduce to a // variable name. // @@ -932,10 +959,11 @@ func (t AggregateListOperationType) String() string { } } -// AggregateListStmt represents aggregate operations: COUNT, SUM, AVERAGE, etc. -// $Count = COUNT($List) -// $Sum = SUM($List/Attr) -// $Sum = SUM($List, $currentObject/Price * 2) // expression form +// AggregateListStmt is one Studio Pro Aggregate list activity: +// $Count = count $List +// $Sum = sum $List by Attr +// $Sum = sum $List of $currentObject/Price * 2 // expression form +// The call forms ($Count = COUNT($List), …) build the same node. type AggregateListStmt struct { OutputVariable string // Output variable name Operation AggregateListOperationType // Operation type diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 50d5c47eb..217a682be 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -62,20 +62,33 @@ type Entry struct { CanonicalExample string } -// Rewrite replaces one keyword token of the deprecated form with another. It is -// the only shape the seeded entries need; a richer one is added with the first -// entry that needs it. +// Rewrite is the mechanical rewrite from the deprecated form to the canonical +// one. It is either a keyword swap (Token and Replacement) or, where the two +// forms differ in shape rather than in one word, a structural rewrite that +// Structural names. A structural rewrite is implemented against the parse tree, +// never as text substitution, and its correctness rests on the same test as a +// swap: Example and CanonicalExample must build the same statements. type Rewrite struct { // Token is the keyword to replace, lower-case. Token string // Replacement is the keyword written in its place, lower-case. Replacement string + // Structural describes a rewrite that is not a keyword swap, e.g. + // "call form to statement form". Empty for a keyword swap. + Structural string } // Codes of the registered entries, for the visitor to record. const ( CreateOrReplace = "MDL-DEPR001" Show = "MDL-DEPR002" + // ListOperationFunctionForm is `$x = head($L)` and the other list + // operations written as calls; find and contains are excluded, because the + // call form clashes with the string functions (see mdl/visitor, MDL-V1-LIST). + ListOperationFunctionForm = "MDL-DEPR003" + // AggregateFunctionForm is `$n = count($L)` and the other aggregates + // written as calls. + AggregateFunctionForm = "MDL-DEPR004" ) // entries is the registry. Append only: a code is never reused or renumbered, @@ -108,6 +121,31 @@ var entries = []Entry{ Example: "show entities in M;", CanonicalExample: "list entities in M;", }, + { + Code: ListOperationFunctionForm, + Old: "$x = ($List, …)", + Canonical: "$x = $List …", + Rewrite: Rewrite{Structural: "call form to statement form: head/tail $L; filter/find $L by Member = v " + + "(when the condition has that shape) or where ; sort $L by …; union/intersect $A with $B; " + + "subtract($A, $B) -> subtract $B from $A; equals $A and $B; range($L, o, n) -> range $L offset o limit n"}, + RemovedIn: 2, + Note: "A list operation is one Studio Pro activity whose operand is a variable, so the statement form " + + "cannot nest. find(…) and contains(…) are not reported here: the call form is also the string " + + "function, so they are version-gated instead (MDL-V1-LIST).", + Example: "create microflow M.F ($L: List of M.E) begin $H = head($L); end;", + CanonicalExample: "create microflow M.F ($L: List of M.E) begin $H = head $L; end;", + }, + { + Code: AggregateFunctionForm, + Old: "$n = ($List, …)", + Canonical: "$n = $List …", + Rewrite: Rewrite{Structural: "call form to statement form: count $L; sum|average|minimum|maximum " + + "$L by Attr (for $L.Attr) or of ; all|any $L where ; reduce $L from as using "}, + RemovedIn: 2, + Note: "An aggregate is one Studio Pro Aggregate list activity whose operand is a variable.", + Example: "create microflow M.F ($L: List of M.E) begin $N = count($L); end;", + CanonicalExample: "create microflow M.F ($L: List of M.E) begin $N = count $L; end;", + }, } // All returns every registered entry, in code order. diff --git a/mdl/deprecation/deprecation_test.go b/mdl/deprecation/deprecation_test.go index 8084f5c43..de608703f 100644 --- a/mdl/deprecation/deprecation_test.go +++ b/mdl/deprecation/deprecation_test.go @@ -27,8 +27,11 @@ func TestRegistryEntriesAreWellFormed(t *testing.T) { if got, ok := Lookup(e.Code); !ok || got.Code != e.Code { t.Errorf("Lookup(%q) = %v, %v", e.Code, got.Code, ok) } - if e.Old == "" || e.Canonical == "" || e.Rewrite.Token == "" || e.Rewrite.Replacement == "" || - e.Example == "" || e.CanonicalExample == "" { + swap := e.Rewrite.Token != "" && e.Rewrite.Replacement != "" + if swap == (e.Rewrite.Structural != "") { + t.Errorf("%s: a rewrite is either a keyword swap or structural, exactly one: %+v", e.Code, e.Rewrite) + } + if e.Old == "" || e.Canonical == "" || e.Example == "" || e.CanonicalExample == "" { t.Errorf("%s is incomplete: %+v", e.Code, e) } // ADR-0011: an alias warns under the version that deprecates it (1) diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index ec6316e90..49248739b 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -1803,10 +1803,12 @@ func (fb *flowBuilder) addListOperationAction(s *ast.ListOperationStmt) model.ID } func (fb *flowBuilder) listAttributeOperation(s *ast.ListOperationStmt, filter bool) microflows.ListOperation { - binary, ok := s.Condition.(*ast.BinaryExpr) - if !ok || binary.Operator != "=" { + // `find $L where …` / `filter $L where …` is always the by-expression + // operation, even when the expression happens to read `Member = value`. + if s.ByExpression || !ast.IsMemberEquality(s.Condition) { return nil } + binary := s.Condition.(*ast.BinaryExpr) fieldName, ok := listOperationFieldName(binary.Left) if !ok || fieldName == "" { return nil diff --git a/mdl/executor/cmd_microflows_builder_list_activity_test.go b/mdl/executor/cmd_microflows_builder_list_activity_test.go new file mode 100644 index 000000000..90faa8766 --- /dev/null +++ b/mdl/executor/cmd_microflows_builder_list_activity_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// `filter $L where Status = 'Open'` is Studio Pro's "Filter by expression" +// even though its expression reads `Member = value`; `filter $L by …` with the +// same condition is "Filter" by member (#733). The linking word decides, not +// the shape of the condition. +func TestFilterWhereIsAlwaysByExpression(t *testing.T) { + build := func(byExpression bool) microflows.ListOperation { + fb := predicateBuilder(t) + stmt := &ast.ListOperationStmt{ + Operation: ast.ListOpFilter, + InputVariable: "L", + OutputVariable: "R", + Condition: bare("Status", "=", "'Open'"), + ByExpression: byExpression, + } + oc := fb.buildFlowGraph([]ast.MicroflowStatement{stmt}, + &ast.MicroflowReturnType{Type: ast.DataType{Kind: ast.TypeBoolean}}) + if errs := fb.GetErrors(); len(errs) > 0 { + t.Fatalf("build errors: %v", errs) + } + for _, obj := range oc.Objects { + if act, ok := obj.(*microflows.ActionActivity); ok { + if lo, ok := act.Action.(*microflows.ListOperationAction); ok { + return lo.Operation + } + } + } + t.Fatal("no list operation built") + return nil + } + + op, ok := build(true).(*microflows.FilterOperation) + if !ok { + t.Fatalf("where: built %T, want *microflows.FilterOperation", build(true)) + } + if !strings.Contains(op.Expression, "$currentObject/Status") { + t.Errorf("where: expression %q does not read the member off $currentObject", op.Expression) + } + // Control: the same condition after `by` (or in the call form) is by member. + if got, ok := build(false).(*microflows.FilterByAttributeOperation); !ok || got.Attribute != "Shop.Order.Status" { + t.Errorf("by: built %#v, want Filter by attribute Shop.Order.Status", build(false)) + } +} diff --git a/mdl/executor/validate_deprecations.go b/mdl/executor/validate_deprecations.go index cd2723846..5464a3445 100644 --- a/mdl/executor/validate_deprecations.go +++ b/mdl/executor/validate_deprecations.go @@ -33,7 +33,7 @@ func ValidateDeprecations(prog *ast.Program) []linter.Violation { Severity: linter.SeverityWarning, Message: fmt.Sprintf("line %d: `%s`%s is deprecated; write `%s` — same meaning. "+ "Refused from `mdl %d`.", d.Line, e.Old, on, e.Canonical, e.RemovedIn), - Suggestion: fmt.Sprintf("Replace `%s` with `%s`. %s", e.Rewrite.Token, e.Rewrite.Replacement, e.Note), + Suggestion: deprecationSuggestion(e), }) } return out @@ -54,3 +54,12 @@ func ApplyDeprecationPolicy(violations []linter.Violation, policy deprecation.Po } return out } + +// deprecationSuggestion says how to rewrite a deprecated spelling: the keyword +// to swap, or for a structural rewrite, the rewrite itself. +func deprecationSuggestion(e deprecation.Entry) string { + if e.Rewrite.Structural != "" { + return fmt.Sprintf("Rewrite the %s. %s", e.Rewrite.Structural, e.Note) + } + return fmt.Sprintf("Replace `%s` with `%s`. %s", e.Rewrite.Token, e.Rewrite.Replacement, e.Note) +} diff --git a/mdl/executor/validate_microflow_listop_source.go b/mdl/executor/validate_microflow_listop_source.go index 313a68d33..f984b9ea8 100644 --- a/mdl/executor/validate_microflow_listop_source.go +++ b/mdl/executor/validate_microflow_listop_source.go @@ -39,6 +39,11 @@ import ( // The control for all of them is the reporter's own workaround, which builds // the same two activities explicitly and passes at 0 errors. // +// Retired for `mdl 1;` scripts (#733): there a list operation is one statement +// per activity whose operand is a VARIABLE, so a nested call does not parse and +// the visitor refuses the call forms that could nest (MDL-V1-LIST). This rule is +// reachable only from mdl 0 scripts, and goes with them. +// // 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 diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index cf037486c..027bd425e 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -257,6 +257,8 @@ MAXIMUM: M A X I M U M; REDUCE: R E D U C E; ANY: A N Y; INITIAL: I N I T I A L; +// reduce $L from as using (#733). +USING: U S I N G; LIST: L I S T; REMOVE: R E M O V E; EQUALS_OP: E Q U A L S; diff --git a/mdl/grammar/domains/MDLMicroflow.g4 b/mdl/grammar/domains/MDLMicroflow.g4 index f96b8fd3d..427c59914 100644 --- a/mdl/grammar/domains/MDLMicroflow.g4 +++ b/mdl/grammar/domains/MDLMicroflow.g4 @@ -878,10 +878,46 @@ transformJsonStatement // ============================================================================= /** - * List operations that return a single item or a modified list. + * List operations that return a single item or a modified list: one statement + * per Studio Pro "List operation" activity (PROPOSAL_mdl_beta_syntax_freeze.md + * §4, #733). The operand is always a variable, as it is in the activity's + * dialog, so one activity cannot be nested inside another. */ listOperationStatement - : VARIABLE EQUALS listOperation + : VARIABLE EQUALS listOperationActivity + // The call form. A respelling for every operation but find and contains, + // whose call form is also the string function: the visitor version-gates + // those instead (MDL-V1-LIST), since no rewrite can know which was meant. + | VARIABLE EQUALS listOperation /* @alias MDL-DEPR003 */ + ; + +listOperationActivity + : HEAD VARIABLE // $x = head $L + | TAIL VARIABLE // $x = tail $L + | FIND VARIABLE listOperationCondition // $x = find $L by Number = 3 + | FILTER VARIABLE listOperationCondition // $x = filter $L where $currentObject/Paid + | SORT VARIABLE BY listSortItem (COMMA listSortItem)* // $x = sort $L by Date desc, Number + | UNION VARIABLE WITH VARIABLE // $x = union $A with $B + | INTERSECT VARIABLE WITH VARIABLE // $x = intersect $A with $B + | SUBTRACT VARIABLE FROM VARIABLE // $x = subtract $B from $A (A minus B) + | CONTAINS VARIABLE IN VARIABLE // $b = contains $Object in $L + | EQUALS_OP VARIABLE AND VARIABLE // $b = equals $A and $B + | RANGE VARIABLE (OFFSET expression)? (LIMIT expression)? // $x = range $L offset 20 limit 10 + ; + +// `by` picks a member (Studio Pro's Find / Filter: an attribute or association +// and the value it must have), written `Member = value`; the visitor refuses +// any other shape. `where` takes an expression over $currentObject (Find by +// expression / Filter by expression). +listOperationCondition + : BY expression + | WHERE expression + ; + +// A sort attribute may be any word, so an attribute called Count or Date needs +// no quotes here. +listSortItem + : identifierOrKeyword (ASC | DESC)? ; listOperation @@ -910,7 +946,19 @@ sortSpec * Aggregate operations on lists. */ aggregateListStatement - : VARIABLE EQUALS listAggregateOperation + : VARIABLE EQUALS aggregateListActivity + | VARIABLE EQUALS listAggregateOperation /* @alias MDL-DEPR004 */ + ; + +/** + * One Studio Pro "Aggregate list" activity. `by` aggregates an attribute and + * `of` an expression (the dialog's "Aggregate with: Attribute / Expression"). + */ +aggregateListActivity + : COUNT VARIABLE // $n = count $L + | (SUM | AVERAGE | MINIMUM | MAXIMUM) VARIABLE (BY identifierOrKeyword | OF expression) // $t = sum $L by Amount + | (ALL | ANY) VARIABLE WHERE expression // $b = all $L where $currentObject/Paid + | REDUCE VARIABLE FROM expression AS dataType USING expression // $s = reduce $L from '' as String using … ; listAggregateOperation diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index 87da00016..e09bfa3c5 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -643,7 +643,7 @@ keyword | COUNT | SUM | AVG | MIN | MAX | DISTINCT | ALL | ASC | DESC | UNION | INTERSECT | SUBTRACT | EXISTS | CAST | COALESCE | TRIM | LENGTH | CONTAINS | MATCH - | AVERAGE | MINIMUM | MAXIMUM | REDUCE | ANY | INITIAL + | AVERAGE | MINIMUM | MAXIMUM | REDUCE | ANY | INITIAL | USING | IS_NULL | IS_NOT_NULL | NOT_NULL | HEAD | TAIL | FIND | SORT | EMPTY | LIST_OF | LIST_KW | EQUALS_OP diff --git a/mdl/visitor/visitor_deprecations_test.go b/mdl/visitor/visitor_deprecations_test.go index 6bb425c07..300b8850d 100644 --- a/mdl/visitor/visitor_deprecations_test.go +++ b/mdl/visitor/visitor_deprecations_test.go @@ -50,6 +50,10 @@ func TestRegistryExamplesRecordTheirCode(t *testing.T) { t.Errorf("Example and CanonicalExample build different statements:\n old: %#v\n canon: %#v", old.Statements, canon.Statements) } + // A structural rewrite is proven by the AST comparison above alone. + if e.Rewrite.Structural != "" { + return + } // The rewrite is a token swap; the canonical example must be exactly it. re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(e.Rewrite.Token) + `\b`) if got := re.ReplaceAllString(e.Example, e.Rewrite.Replacement); got != e.CanonicalExample { diff --git a/mdl/visitor/visitor_list_activities.go b/mdl/visitor/visitor_list_activities.go new file mode 100644 index 000000000..06c06bfa5 --- /dev/null +++ b/mdl/visitor/visitor_list_activities.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "fmt" + "strconv" + "strings" + + "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/langver" +) + +// List operations and aggregates are statements that mirror Studio Pro's +// activities: one "List operation" or "Aggregate list" activity per statement, +// named after the operation and taking the inputs its dialog asks for +// (PROPOSAL_mdl_beta_syntax_freeze.md §4 and §5 item 3, #733): +// +// $Open = filter $Orders by Status = Shop.Status.Open; +// $N = count $Open; +// +// The operand is always a variable, so one activity cannot be nested inside +// another. The call form (`filter($Orders, …)`) keeps parsing: +// +// - for every operation except find and contains it is a respelling, a +// deprecated alias (MDL-DEPR003 / MDL-DEPR004) that builds the same AST; +// - `find(…)` and `contains(…)` are also Mendix's string functions, and which +// one a call means is guessed today from its arguments. That is not a +// respelling, so it is version-gated (listCallForm): kept and warned under +// mdl 0, refused under mdl 1; +// - so is a call after `set`, or nested in another call: under mdl 0 it is +// turned into an activity (and a nested operand is refused by MDL-LISTOP02), +// under mdl 1 `set` always assigns an expression and such a call is refused. +// +// `set` is mandatory for reassignment under mdl 1 (setIsMandatory), which is +// what removes the ambiguity: `$x = …` without it is only ever an activity. + +// listCallForm is the change of meaning for list operations and aggregates +// written as calls where the call is not a plain respelling of one activity. +var listCallForm = langver.Change{ + Code: "MDL-V1-LIST", + Since: langver.V1, + Old: "a list operation or aggregate written as a call — `find(…)` or `contains(…)`, " + + "a call after `set`, or one call nested in another — is turned into a List operation " + + "or Aggregate list activity, guessing from its arguments whether a string function was meant,", + New: "an error: a List operation or Aggregate list is one statement per activity whose operand is " + + "a variable (`$x = find $L where …;`, `$n = count $L;`), and after `set` a call is always " + + "the expression function", +} + +// setIsMandatory is the new rejection of a reassignment without `set`. +var setIsMandatory = langver.Change{ + Code: "MDL-V1-SET", + Since: langver.V1, + Old: "`$x = ` without `set` changes the variable", + New: "an error: a reassignment is written `set $x = ;`", +} + +// scriptLanguageVersion reads the `mdl ;` header of the script that tree is +// part of. The header can only be the first statement, so it is on the root. +// +// It exists for the statement builders, which are free functions without the +// Builder: they must build the AST of the version the script is written in. +// A missing or malformed header is mdl 0; a malformed one is reported by +// ExitLanguageHeader. +func scriptLanguageVersion(tree antlr.Tree) langver.Version { + for tree != nil { + if prog, ok := tree.(*parser.ProgramContext); ok { + h, ok := prog.LanguageHeader().(*parser.LanguageHeaderContext) + if !ok || h == nil || h.IDENTIFIER() == nil || h.NUMBER_LITERAL() == nil || + !strings.EqualFold(h.IDENTIFIER().GetText(), "mdl") { + return langver.V0 + } + n, err := strconv.Atoi(h.NUMBER_LITERAL().GetText()) + if v := langver.Version(n); err == nil && v.Known() { + return v + } + return langver.V0 + } + tree = tree.GetParent() + } + return langver.V0 +} + +// buildListOperationActivity fills stmt from the statement form. +func buildListOperationActivity(act *parser.ListOperationActivityContext, stmt *ast.ListOperationStmt) { + vars := act.AllVARIABLE() + varName := func(i int) string { + if i < len(vars) { + return strings.TrimPrefix(vars[i].GetText(), "$") + } + return "" + } + stmt.InputVariable = varName(0) + condition := func() { + c, ok := act.ListOperationCondition().(*parser.ListOperationConditionContext) + if !ok || c == nil { + return + } + stmt.Condition = buildSourceExpression(c.Expression()) + stmt.ByExpression = c.WHERE() != nil + } + switch { + case act.HEAD() != nil: + stmt.Operation = ast.ListOpHead + case act.TAIL() != nil: + stmt.Operation = ast.ListOpTail + case act.FIND() != nil: + stmt.Operation = ast.ListOpFind + condition() + case act.FILTER() != nil: + stmt.Operation = ast.ListOpFilter + condition() + case act.SORT() != nil: + stmt.Operation = ast.ListOpSort + for _, item := range act.AllListSortItem() { + it := item.(*parser.ListSortItemContext) + stmt.SortSpecs = append(stmt.SortSpecs, ast.SortSpec{ + Attribute: identifierOrKeywordText(it.IdentifierOrKeyword()), + Ascending: it.DESC() == nil, + }) + } + case act.UNION() != nil: + stmt.Operation, stmt.SecondVariable = ast.ListOpUnion, varName(1) + case act.INTERSECT() != nil: + stmt.Operation, stmt.SecondVariable = ast.ListOpIntersect, varName(1) + case act.SUBTRACT() != nil: + // subtract $B from $A: the result is A minus B, and A is the first list. + stmt.Operation, stmt.InputVariable, stmt.SecondVariable = ast.ListOpSubtract, varName(1), varName(0) + case act.CONTAINS() != nil: + // contains $Object in $List: the list is the activity's first operand. + stmt.Operation, stmt.InputVariable, stmt.SecondVariable = ast.ListOpContains, varName(1), varName(0) + case act.EQUALS_OP() != nil: + stmt.Operation, stmt.SecondVariable = ast.ListOpEquals, varName(1) + case act.RANGE() != nil: + stmt.Operation = ast.ListOpRange + if act.OFFSET() != nil { + stmt.OffsetExpr = buildSourceExpression(act.Expression(0)) + } + if act.LIMIT() != nil { + stmt.LimitExpr = buildSourceExpression(act.Expression(len(act.AllExpression()) - 1)) + } + } +} + +// buildAggregateListActivity fills stmt from the statement form. +func buildAggregateListActivity(act *parser.AggregateListActivityContext, stmt *ast.AggregateListStmt) { + if v := act.VARIABLE(); v != nil { + stmt.InputVariable = strings.TrimPrefix(v.GetText(), "$") + } + exprs := act.AllExpression() + switch { + case act.COUNT() != nil: + stmt.Operation = ast.AggregateCount + return + case act.SUM() != nil: + stmt.Operation = ast.AggregateSum + case act.AVERAGE() != nil: + stmt.Operation = ast.AggregateAverage + case act.MINIMUM() != nil: + stmt.Operation = ast.AggregateMinimum + case act.MAXIMUM() != nil: + stmt.Operation = ast.AggregateMaximum + case act.ALL() != nil, act.ANY() != nil: + stmt.Operation = ast.AggregateAll + if act.ANY() != nil { + stmt.Operation = ast.AggregateAny + } + stmt.IsExpression = true + if len(exprs) > 0 { + stmt.Expression = buildSourceExpression(exprs[0]) + } + return + case act.REDUCE() != nil: + // reduce $L from as using + stmt.Operation = ast.AggregateReduce + stmt.IsExpression = true + if len(exprs) > 0 { + stmt.InitialValue = buildSourceExpression(exprs[0]) + } + if len(exprs) > 1 { + stmt.Expression = buildSourceExpression(exprs[1]) + } + if dt := act.DataType(); dt != nil { + t := buildDataType(dt) + stmt.ReturnType = &t + } + return + } + // sum / average / minimum / maximum: `by Attribute` or `of `. + if act.OF() != nil && len(exprs) > 0 { + stmt.IsExpression = true + stmt.Expression = buildSourceExpression(exprs[0]) + } else if id := act.IdentifierOrKeyword(); id != nil { + stmt.Attribute = identifierOrKeywordText(id) + } +} + +// unwrapSource returns the expression a SourceExpr carries. +func unwrapSource(e ast.Expression) ast.Expression { + if s, ok := e.(*ast.SourceExpr); ok { + return s.Expression + } + return e +} + +// listCallName returns the upper-cased name of a list-operation or aggregate +// call, or "" when value is not one. +func listCallName(value ast.Expression) string { + call, ok := unwrapSource(value).(*ast.FunctionCallExpr) + if !ok { + return "" + } + switch name := strings.ToUpper(call.Name); name { + case "HEAD", "TAIL", "FIND", "FILTER", "SORT", "UNION", "INTERSECT", "SUBTRACT", + "CONTAINS", "EQUALS", "RANGE", "COUNT", "SUM", "AVERAGE", "MINIMUM", "MAXIMUM": + return name + } + return "" +} + +func isStringOverload(name string) bool { return name == "FIND" || name == "CONTAINS" } + +// ExitListOperationStatement reports the call form: a deprecated alias, or for +// find/contains a version-gated construct; and checks that `by` names a member. +func (b *Builder) ExitListOperationStatement(ctx *parser.ListOperationStatementContext) { + target := "" + if v := ctx.VARIABLE(); v != nil { + target = v.GetText() + } + if op, ok := ctx.ListOperation().(*parser.ListOperationContext); ok && op != nil { + if op.FIND() != nil || op.CONTAINS() != nil { + if b.gate(listCallForm, ctx) { + b.addError(findContainsCallError(ctx.GetStart().GetLine(), target, op.FIND() != nil)) + } + return + } + b.recordDeprecation(deprecation.ListOperationFunctionForm, op.GetStart(), strings.ToLower(op.GetStart().GetText())) + return + } + act, ok := ctx.ListOperationActivity().(*parser.ListOperationActivityContext) + if !ok || act == nil { + return + } + c, ok := act.ListOperationCondition().(*parser.ListOperationConditionContext) + if !ok || c == nil || c.BY() == nil { + return + } + if !ast.IsMemberEquality(unwrapSource(buildExpression(c.Expression()))) { + b.addError(fmt.Errorf("line %d: `by` names a member and the value it must have, `by Member = value`; "+ + "`%s` is not of that shape. For any other condition write `where `, which is "+ + "Studio Pro's \"by expression\" operation and uses $currentObject", + c.GetStart().GetLine(), strings.TrimSpace(extractExpressionText(c.Expression())))) + } +} + +func findContainsCallError(line int, target string, find bool) error { + if find { + return fmt.Errorf("line %d: `%s = find(…)` is refused under mdl 1: the call is both the string function "+ + "and the List operation, one statement per activity. For the string function write "+ + "`set %s = find(…);` (the variable is declared first); for the List operation write "+ + "`%s = find $List by Member = value;` or `%s = find $List where ;`", + line, target, target, target, target) + } + return fmt.Errorf("line %d: `%s = contains(…)` is refused under mdl 1: the call is both the string function "+ + "and the List operation, one statement per activity. For the string function write "+ + "`set %s = contains(…);` (the variable is declared first); for the List operation write "+ + "`%s = contains $Object in $List;`", line, target, target, target) +} + +// ExitAggregateListStatement reports the call form, a deprecated alias. +func (b *Builder) ExitAggregateListStatement(ctx *parser.AggregateListStatementContext) { + if op, ok := ctx.ListAggregateOperation().(*parser.ListAggregateOperationContext); ok && op != nil { + b.recordDeprecation(deprecation.AggregateFunctionForm, op.GetStart(), strings.ToLower(op.GetStart().GetText())) + } +} + +// ExitSetStatement gates the two constructs whose meaning mdl 1 changes: an +// assignment without `set`, and a list-operation or aggregate call as the value. +func (b *Builder) ExitSetStatement(ctx *parser.SetStatementContext) { + v := ctx.VARIABLE() + if v == nil || ctx.Expression() == nil { + // An attribute target (`$o/A = v`) is a respelling of `change`, not a + // reassignment; it is left to that alias. + return + } + target := v.GetText() + line := ctx.GetStart().GetLine() + value := buildExpression(ctx.Expression()) + name := listCallName(value) + valueText := strings.TrimSpace(extractExpressionText(ctx.Expression())) + + switch { + case name != "" && !(isStringOverload(name) && ctx.SET() != nil): + // A call the mdl 0 builder turns into an activity (when it does). + if call, ok := unwrapSource(value).(*ast.FunctionCallExpr); ok && + buildListOrAggregateStatement(strings.TrimPrefix(target, "$"), call) == nil { + // The string reading of find/contains: an expression either way. + break + } + if b.gate(listCallForm, ctx) { + if isStringOverload(name) { + b.addError(findContainsCallError(line, target, name == "FIND")) + return + } + b.addError(fmt.Errorf("line %d: `%s = %s` is refused under mdl 1: a list operation or aggregate is "+ + "one statement per activity, and its operand is a variable, never another call or an "+ + "expression. Write `%s = %s $List …;`, with each inner call as a statement of its own "+ + "(e.g. `$Open = filter $Orders where …;` then `$N = count $Open;`)", + line, target, valueText, target, strings.ToLower(name))) + } + return + case name != "" && ctx.SET() != nil: + // `set $x = find(…)` / `contains(…)`: under mdl 1 always the string + // function; under mdl 0 an activity when the arguments look like one. + if call, ok := unwrapSource(value).(*ast.FunctionCallExpr); ok && + buildListOrAggregateStatement(strings.TrimPrefix(target, "$"), call) != nil { + b.gate(listCallForm, ctx) + } + return + } + if ctx.SET() == nil && b.gate(setIsMandatory, ctx) { + b.addError(fmt.Errorf("line %d: a reassignment says `set` under mdl 1: write `set %s = %s;`. "+ + "A List operation or Aggregate list activity is written without it, as its own statement "+ + "(`%s = filter $List where …;`)", line, target, valueText, target)) + } +} diff --git a/mdl/visitor/visitor_list_activity_test.go b/mdl/visitor/visitor_list_activity_test.go new file mode 100644 index 000000000..2d1ade9f9 --- /dev/null +++ b/mdl/visitor/visitor_list_activity_test.go @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// List operations and aggregates are statements that mirror Studio Pro's +// activities (PROPOSAL_mdl_beta_syntax_freeze.md §4, ADR-0010 R2; #733). + +const listActivityParams = "$Orders: List of M.Order, $A: List of M.Order, $B: List of M.Order, " + + "$Order: M.Order, $Number: Integer, $S: String" + +// listActivityMicroflow wraps body in a microflow whose parameters every case +// below can name. +func listActivityMicroflow(header, body string) string { + return header + "create microflow M.F (" + listActivityParams + ")\nbegin\n " + body + "\nend;" +} + +// buildListActivity parses one statement inside a microflow and returns the +// program and the microflow's first body statement. +func buildListActivity(t *testing.T, header, body string) (*ast.Program, ast.MicroflowStatement, []error) { + t.Helper() + prog, errs := Build(listActivityMicroflow(header, body)) + if prog == nil || len(prog.Statements) == 0 { + return prog, nil, errs + } + mf, ok := prog.Statements[0].(*ast.CreateMicroflowStmt) + if !ok || len(mf.Body) == 0 { + return prog, nil, errs + } + return prog, mf.Body[0], errs +} + +func noteCodes(prog *ast.Program) []string { + var out []string + for _, n := range prog.LanguageNotes { + out = append(out, n.Code) + } + return out +} + +// Every row of the §4 table parses under both versions and builds the activity +// it names, with no deprecation and no language note. +func TestListActivityStatementsBuild(t *testing.T) { + cases := []struct { + src string + check func(t *testing.T, s ast.MicroflowStatement) + }{ + {"$Open = filter $Orders by Status = M.Status.Open;", func(t *testing.T, s ast.MicroflowStatement) { + lo := s.(*ast.ListOperationStmt) + if lo.Operation != ast.ListOpFilter || lo.InputVariable != "Orders" || lo.ByExpression || !ast.IsMemberEquality(lo.Condition) { + t.Errorf("got %#v", lo) + } + }}, + {"$Big = filter $Orders where $currentObject/Total > 1000;", func(t *testing.T, s ast.MicroflowStatement) { + lo := s.(*ast.ListOperationStmt) + if lo.Operation != ast.ListOpFilter || !lo.ByExpression || lo.Condition == nil { + t.Errorf("got %#v", lo) + } + }}, + {"$Match = find $Orders by Number = $Number;", func(t *testing.T, s ast.MicroflowStatement) { + lo := s.(*ast.ListOperationStmt) + if lo.Operation != ast.ListOpFind || lo.ByExpression || lo.OutputVariable != "Match" { + t.Errorf("got %#v", lo) + } + }}, + {"$Late = find $Orders where $currentObject/Number < 3;", func(t *testing.T, s ast.MicroflowStatement) { + lo := s.(*ast.ListOperationStmt) + if lo.Operation != ast.ListOpFind || !lo.ByExpression { + t.Errorf("got %#v", lo) + } + }}, + {"$Sorted = sort $Orders by Date desc, Number asc, Count;", func(t *testing.T, s ast.MicroflowStatement) { + lo := s.(*ast.ListOperationStmt) + want := []ast.SortSpec{{Attribute: "Date"}, {Attribute: "Number", Ascending: true}, {Attribute: "Count", Ascending: true}} + if lo.Operation != ast.ListOpSort || !reflect.DeepEqual(lo.SortSpecs, want) { + t.Errorf("got %#v", lo) + } + }}, + {"$First = head $Orders;", func(t *testing.T, s ast.MicroflowStatement) { + lo := s.(*ast.ListOperationStmt) + if lo.Operation != ast.ListOpHead || lo.InputVariable != "Orders" { + t.Errorf("got %#v", lo) + } + }}, + {"$Rest = tail $Orders;", func(t *testing.T, s ast.MicroflowStatement) { + if lo := s.(*ast.ListOperationStmt); lo.Operation != ast.ListOpTail { + t.Errorf("got %#v", lo) + } + }}, + {"$Page = range $Orders offset 20 limit 10;", func(t *testing.T, s ast.MicroflowStatement) { + lo := s.(*ast.ListOperationStmt) + if lo.Operation != ast.ListOpRange || lo.OffsetExpr == nil || lo.LimitExpr == nil { + t.Errorf("got %#v", lo) + } + }}, + {"$All = union $A with $B;", listPair(ast.ListOpUnion, "A", "B")}, + {"$Both = intersect $A with $B;", listPair(ast.ListOpIntersect, "A", "B")}, + // subtract $B from $A is A minus B: the first list is A. + {"$Left = subtract $B from $A;", listPair(ast.ListOpSubtract, "A", "B")}, + // contains $Order in $Orders: the list is the first operand. + {"$Has = contains $Order in $Orders;", listPair(ast.ListOpContains, "Orders", "Order")}, + {"$Same = equals $A and $B;", listPair(ast.ListOpEquals, "A", "B")}, + {"$N = count $Orders;", func(t *testing.T, s ast.MicroflowStatement) { + ag := s.(*ast.AggregateListStmt) + if ag.Operation != ast.AggregateCount || ag.InputVariable != "Orders" { + t.Errorf("got %#v", ag) + } + }}, + {"$Total = sum $Orders by Amount;", func(t *testing.T, s ast.MicroflowStatement) { + ag := s.(*ast.AggregateListStmt) + if ag.Operation != ast.AggregateSum || ag.Attribute != "Amount" || ag.IsExpression || ag.InputVariable != "Orders" { + t.Errorf("got %#v", ag) + } + }}, + {"$Total = sum $Orders of $currentObject/Price * $currentObject/Quantity;", func(t *testing.T, s ast.MicroflowStatement) { + ag := s.(*ast.AggregateListStmt) + if ag.Operation != ast.AggregateSum || !ag.IsExpression || ag.Expression == nil { + t.Errorf("got %#v", ag) + } + }}, + {"$Avg = average $Orders by Amount;", aggregateOp(ast.AggregateAverage)}, + {"$Min = minimum $Orders by Amount;", aggregateOp(ast.AggregateMinimum)}, + {"$Max = maximum $Orders of $currentObject/Amount;", aggregateOp(ast.AggregateMaximum)}, + {"$AllPaid = all $Orders where $currentObject/Paid;", aggregateOp(ast.AggregateAll)}, + {"$AnyLate = any $Orders where $currentObject/Late;", aggregateOp(ast.AggregateAny)}, + {"$Csv = reduce $Orders from '' as String using $currentResult + $currentObject/Name;", func(t *testing.T, s ast.MicroflowStatement) { + ag := s.(*ast.AggregateListStmt) + if ag.Operation != ast.AggregateReduce || ag.InitialValue == nil || ag.ReturnType == nil || + ag.ReturnType.Kind != ast.TypeString || ag.Expression == nil { + t.Errorf("got %#v", ag) + } + }}, + } + for _, header := range []string{"", "mdl 1;\n"} { + for _, tc := range cases { + t.Run(strings.TrimSpace(header+" "+tc.src), func(t *testing.T) { + prog, stmt, errs := buildListActivity(t, header, tc.src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if stmt == nil { + t.Fatal("no statement built") + } + tc.check(t, stmt) + if d := deprecationCodes(prog); len(d) != 0 { + t.Errorf("canonical form recorded deprecations %v", d) + } + if n := noteCodes(prog); len(n) != 0 { + t.Errorf("canonical form recorded language notes %v", n) + } + }) + } + } +} + +func listPair(op ast.ListOperationType, first, second string) func(*testing.T, ast.MicroflowStatement) { + return func(t *testing.T, s ast.MicroflowStatement) { + lo := s.(*ast.ListOperationStmt) + if lo.Operation != op || lo.InputVariable != first || lo.SecondVariable != second { + t.Errorf("got %#v, want %v(%s, %s)", lo, op, first, second) + } + } +} + +func aggregateOp(op ast.AggregateListOperationType) func(*testing.T, ast.MicroflowStatement) { + return func(t *testing.T, s ast.MicroflowStatement) { + ag := s.(*ast.AggregateListStmt) + if ag.Operation != op || ag.InputVariable != "Orders" { + t.Errorf("got %#v", ag) + } + } +} + +// The function form is a deprecated respelling of the statement: both build the +// same AST, and only the function form records the registry code. find and +// contains are not here — they clash with the string functions (see +// TestFindContainsFunctionFormIsVersionGated). +func TestListFunctionFormIsAnAlias(t *testing.T) { + cases := []struct{ old, canon, code string }{ + {"$H = head($Orders);", "$H = head $Orders;", deprecation.ListOperationFunctionForm}, + {"$T = tail($Orders);", "$T = tail $Orders;", deprecation.ListOperationFunctionForm}, + {"$F = filter($Orders, Status = M.Status.Open);", "$F = filter $Orders by Status = M.Status.Open;", deprecation.ListOperationFunctionForm}, + {"$F = filter($Orders, $currentObject/Total > 10);", "$F = filter $Orders where $currentObject/Total > 10;", deprecation.ListOperationFunctionForm}, + {"$F = sort($Orders, OrderDate desc, Number);", "$F = sort $Orders by OrderDate desc, Number;", deprecation.ListOperationFunctionForm}, + {"$U = union($A, $B);", "$U = union $A with $B;", deprecation.ListOperationFunctionForm}, + {"$U = intersect($A, $B);", "$U = intersect $A with $B;", deprecation.ListOperationFunctionForm}, + {"$U = subtract($A, $B);", "$U = subtract $B from $A;", deprecation.ListOperationFunctionForm}, + {"$E = equals($A, $B);", "$E = equals $A and $B;", deprecation.ListOperationFunctionForm}, + {"$R = range($Orders, 20, 10);", "$R = range $Orders offset 20 limit 10;", deprecation.ListOperationFunctionForm}, + {"$N = count($Orders);", "$N = count $Orders;", deprecation.AggregateFunctionForm}, + {"$N = sum($Orders.Amount);", "$N = sum $Orders by Amount;", deprecation.AggregateFunctionForm}, + {"$N = average($Orders, $currentObject/Amount * 2);", "$N = average $Orders of $currentObject/Amount * 2;", deprecation.AggregateFunctionForm}, + {"$N = all($Orders, $currentObject/Paid);", "$N = all $Orders where $currentObject/Paid;", deprecation.AggregateFunctionForm}, + {"$N = reduce($Orders, $currentResult + 1, initial: 0, returns: Integer);", "$N = reduce $Orders from 0 as Integer using $currentResult + 1;", deprecation.AggregateFunctionForm}, + } + for _, header := range []string{"", "mdl 1;\n"} { + for _, tc := range cases { + t.Run(strings.TrimSpace(header+" "+tc.old), func(t *testing.T) { + oldProg, oldStmt, errs := buildListActivity(t, header, tc.old) + if len(errs) > 0 { + t.Fatalf("old form: %v", errs) + } + canonProg, canonStmt, errs := buildListActivity(t, header, tc.canon) + if len(errs) > 0 { + t.Fatalf("canonical form: %v", errs) + } + if !reflect.DeepEqual(oldStmt, canonStmt) { + t.Errorf("forms build different statements:\n old: %#v\n canon: %#v", oldStmt, canonStmt) + } + if got := deprecationCodes(oldProg); !reflect.DeepEqual(got, []string{tc.code}) { + t.Errorf("old form recorded %v, want [%s]", got, tc.code) + } + if got := deprecationCodes(canonProg); len(got) != 0 { + t.Errorf("canonical form recorded %v", got) + } + if n := noteCodes(oldProg); len(n) != 0 { + t.Errorf("an alias is not a change of meaning, but recorded notes %v", n) + } + }) + } + } +} + +// `$x = find(a, b)` and `$x = contains(a, b)` are either the list operation or +// the string function, decided today by what the arguments look like. Under +// mdl 0 that meaning is kept and warned; under mdl 1 the form is refused. +func TestFindContainsFunctionFormIsVersionGated(t *testing.T) { + for _, src := range []string{ + "$M = find($Orders, Number = $Number);", + "$M = contains($Orders, $Order);", + } { + t.Run("mdl 0 "+src, func(t *testing.T) { + prog, stmt, errs := buildListActivity(t, "", src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if _, ok := stmt.(*ast.ListOperationStmt); !ok { + t.Errorf("mdl 0 must keep the list operation, got %T", stmt) + } + if got := noteCodes(prog); !reflect.DeepEqual(got, []string{listCallForm.Code}) { + t.Errorf("notes = %v, want [%s]", got, listCallForm.Code) + } + if d := deprecationCodes(prog); len(d) != 0 { + t.Errorf("find/contains are not aliases, but recorded %v", d) + } + }) + t.Run("mdl 1 "+src, func(t *testing.T) { + _, _, errs := buildListActivity(t, "mdl 1;\n", src) + if len(errs) == 0 { + t.Fatal("accepted under mdl 1") + } + if msg := errs[0].Error(); !strings.Contains(msg, "set $M =") || !strings.Contains(msg, "List operation") { + t.Errorf("the error should name both readings: %v", msg) + } + }) + } +} + +// Under mdl 1 `set` always assigns an expression, so `set $x = find(…)` is the +// string function whatever its arguments look like. +func TestSetFindIsTheStringFunctionUnderMdl1(t *testing.T) { + body := "declare $I Integer = 0;\n set $I = find($S, $S);" + prog, errs := Build(listActivityMicroflow("mdl 1;\n", body)) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + stmt := prog.Statements[0].(*ast.CreateMicroflowStmt).Body[1] + if _, ok := stmt.(*ast.MfSetStmt); !ok { + t.Errorf("mdl 1: set $I = find($S, $S) built %T, want *ast.MfSetStmt", stmt) + } + + // Control: under mdl 0 the same text keeps today's list-operation reading + // and warns that its meaning differs under mdl 1. + prog, errs = Build(listActivityMicroflow("", body)) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if _, ok := prog.Statements[0].(*ast.CreateMicroflowStmt).Body[1].(*ast.ListOperationStmt); !ok { + t.Errorf("mdl 0 must keep the list operation reading") + } + if got := noteCodes(prog); !reflect.DeepEqual(got, []string{listCallForm.Code}) { + t.Errorf("notes = %v, want [%s]", got, listCallForm.Code) + } +} + +// Nesting one activity inside another cannot be written under mdl 1; under +// mdl 0 it keeps parsing (MDL-LISTOP02 refuses it at check time) and warns. +func TestNestedListOperationRefusedUnderMdl1(t *testing.T) { + for _, src := range []string{ + "$N = count(filter($Orders, Status = M.Status.Open));", + "set $N = count(filter($Orders, Status = M.Status.Open));", + "set $H = head($Orders);", + } { + t.Run("mdl 1 "+src, func(t *testing.T) { + _, _, errs := buildListActivity(t, "mdl 1;\n", src) + if len(errs) == 0 { + t.Fatal("accepted under mdl 1") + } + if msg := errs[0].Error(); !strings.Contains(msg, "one statement per activity") { + t.Errorf("error should explain the statement form: %v", msg) + } + }) + t.Run("mdl 0 "+src, func(t *testing.T) { + prog, _, errs := buildListActivity(t, "", src) + if len(errs) > 0 { + t.Fatalf("mdl 0 must keep parsing: %v", errs) + } + if got := noteCodes(prog); !reflect.DeepEqual(got, []string{listCallForm.Code}) { + t.Errorf("notes = %v, want [%s]", got, listCallForm.Code) + } + }) + } +} + +// `set` is mandatory for reassignment under mdl 1. +func TestSetIsMandatoryUnderMdl1(t *testing.T) { + body := "declare $I Integer = 0;\n $I = 5;" + _, errs := Build(listActivityMicroflow("mdl 1;\n", body)) + if len(errs) == 0 || !strings.Contains(errs[0].Error(), "set $I = 5") { + t.Fatalf("mdl 1 must refuse a reassignment without set, got %v", errs) + } + + prog, errs := Build(listActivityMicroflow("", body)) + if len(errs) > 0 { + t.Fatalf("mdl 0 must keep parsing: %v", errs) + } + if got := noteCodes(prog); !reflect.DeepEqual(got, []string{setIsMandatory.Code}) { + t.Errorf("notes = %v, want [%s]", got, setIsMandatory.Code) + } + + // Control: with `set`, neither version says anything. + for _, header := range []string{"", "mdl 1;\n"} { + prog, errs := Build(listActivityMicroflow(header, "declare $I Integer = 0;\n set $I = 5;")) + if len(errs) > 0 || len(prog.LanguageNotes) > 0 { + t.Errorf("%q: set $I = 5 gave errors %v, notes %v", header, errs, prog.LanguageNotes) + } + } + // An attribute target is `change`'s territory (plan item 3.x), not this rule. + prog, errs = Build(listActivityMicroflow("mdl 1;\n", "$Order/Number = 5;")) + if len(errs) > 0 || len(prog.LanguageNotes) > 0 { + t.Errorf("$Order/Number = 5 gave errors %v, notes %v", errs, prog.LanguageNotes) + } +} + +// `by` picks a member: anything but `Member = value` belongs after `where`. +func TestFilterByNeedsMemberEquality(t *testing.T) { + _, _, errs := buildListActivity(t, "", "$F = filter $Orders by $currentObject/Total > 3;") + if len(errs) == 0 || !strings.Contains(errs[0].Error(), "where") { + t.Fatalf("want an error pointing at `where`, got %v", errs) + } +} diff --git a/mdl/visitor/visitor_microflow_actions.go b/mdl/visitor/visitor_microflow_actions.go index 0df0fd391..534fb67ac 100644 --- a/mdl/visitor/visitor_microflow_actions.go +++ b/mdl/visitor/visitor_microflow_actions.go @@ -803,7 +803,20 @@ func buildListOperationStatement(ctx parser.IListOperationStatementContext) *ast stmt.OutputVariable = strings.TrimPrefix(v.GetText(), "$") } - // Get the list operation + // The statement form: one Studio Pro activity (#733). + if act, ok := listOpCtx.ListOperationActivity().(*parser.ListOperationActivityContext); ok && act != nil { + buildListOperationActivity(act, stmt) + return stmt + } + + // The call form. For find/filter it is a respelling of `by` when the + // condition reads `Member = value` and of `where` otherwise, so it records + // which, exactly as the flow builder has always decided (ast.IsMemberEquality). + defer func() { + if stmt.Operation == ast.ListOpFind || stmt.Operation == ast.ListOpFilter { + stmt.ByExpression = !ast.IsMemberEquality(stmt.Condition) + } + }() if opCtx := listOpCtx.ListOperation(); opCtx != nil { op := opCtx.(*parser.ListOperationContext) @@ -947,7 +960,13 @@ func buildAggregateListStatement(ctx parser.IAggregateListStatementContext) *ast stmt.OutputVariable = strings.TrimPrefix(v.GetText(), "$") } - // Get the aggregate operation + // The statement form: one Studio Pro Aggregate list activity (#733). + if act, ok := aggrCtx.AggregateListActivity().(*parser.AggregateListActivityContext); ok && act != nil { + buildAggregateListActivity(act, stmt) + return stmt + } + + // The call form, a deprecated alias of the above. if opCtx := aggrCtx.ListAggregateOperation(); opCtx != nil { op := opCtx.(*parser.ListAggregateOperationContext) diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 34cca43af..38bd1709b 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -802,8 +802,10 @@ func buildSetStatementNode(ctx parser.ISetStatementContext) ast.MicroflowStateme valueExpr = buildExpression(expr) } - // Check if the expression is a list operation or aggregate function. - if funcCall, ok := valueExpr.(*ast.FunctionCallExpr); ok { + // Check if the expression is a list operation or aggregate function. Under + // mdl 1 `set` always assigns an expression: a list-operation call there is + // refused (ExitSetStatement), and find/contains are the string functions. + if funcCall, ok := valueExpr.(*ast.FunctionCallExpr); ok && !listCallForm.Applies(scriptLanguageVersion(setCtx)) { if stmt := buildListOrAggregateStatement(targetVar, funcCall); stmt != nil { return recordUnresolvedOperands(stmt, funcCall.Arguments) } @@ -857,20 +859,24 @@ func buildListOrAggregateStatement(targetVar string, funcCall *ast.FunctionCallE // (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) { + cond := getArgumentExpression(funcCall.Arguments, 1) return &ast.ListOperationStmt{ OutputVariable: targetVar, Operation: ast.ListOpFind, InputVariable: extractVariableName(funcCall.Arguments, 0), - Condition: getArgumentExpression(funcCall.Arguments, 1), + Condition: cond, + ByExpression: !ast.IsMemberEquality(cond), } } // Falls through to the default MfSetStmt (string find expression). case "FILTER": + cond := getArgumentExpression(funcCall.Arguments, 1) return &ast.ListOperationStmt{ OutputVariable: targetVar, Operation: ast.ListOpFilter, InputVariable: extractVariableName(funcCall.Arguments, 0), - Condition: getArgumentExpression(funcCall.Arguments, 1), + Condition: cond, + ByExpression: !ast.IsMemberEquality(cond), } case "SORT": stmt := &ast.ListOperationStmt{ From 48c43d509cec2f63207bd59e23b8c571d582af25 Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 27 Sep 2026 08:33:45 +0000 Subject: [PATCH 08/37] describe: print list activities as statements under mdl 1 describe writes the language of the newest frozen version, or of the script it runs in when that is newer. While mdl 1 is a preview a plain describe keeps the call form; inside an `mdl 1;` script it prints the statement form, which executes back to the same microflow (PedApp test). Part of #733. Co-Authored-By: Claude Opus 5.5 --- mdl/executor/cmd_microflows_format_action.go | 9 ++ .../cmd_microflows_format_list_activity.go | 127 ++++++++++++++++++ ...md_microflows_format_list_activity_test.go | 109 +++++++++++++++ mdl/roundtrip/list_activity_test.go | 98 ++++++++++++++ 4 files changed, 343 insertions(+) create mode 100644 mdl/executor/cmd_microflows_format_list_activity.go create mode 100644 mdl/executor/cmd_microflows_format_list_activity_test.go create mode 100644 mdl/roundtrip/list_activity_test.go diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index e8de99f43..c1bd5c00b 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -10,6 +10,7 @@ import ( "sort" "strings" + "github.com/mendixlabs/mxcli/mdl/langver" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" @@ -441,6 +442,9 @@ func formatAction( attrName = parts[len(parts)-1] } } + if describeLanguage(ctx) >= langver.V1 { + return formatAggregateActivity(ctx, a, fn, attrName, outputVar, entityNames) + } // REDUCE carries the fold Mendix stores beside the expression. Both parts // are required, so they are rendered even when empty rather than dropped — // a reduce that describes without them cannot be executed back (#1004). @@ -1136,6 +1140,11 @@ func formatListOperation(ctx *ExecContext, op microflows.ListOperation, outputVa if op == nil { return fmt.Sprintf("$%s = list operation ...;", outputVar) } + if describeLanguage(ctx) >= langver.V1 { + if stmt, ok := formatListActivity(op, outputVar); ok { + return stmt + } + } switch o := op.(type) { case *microflows.HeadOperation: diff --git a/mdl/executor/cmd_microflows_format_list_activity.go b/mdl/executor/cmd_microflows_format_list_activity.go new file mode 100644 index 000000000..80f79a5c5 --- /dev/null +++ b/mdl/executor/cmd_microflows_format_list_activity.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// describeLanguage is the MDL language version describe writes: the newest +// frozen version (the one whose header describe emits, langver.HeaderLine), +// or the version of the script the describe runs in when that is newer. +// +// While mdl 1 is a preview, a plain `describe` therefore keeps writing the +// mdl 0 forms, because its output carries no header that would make the newer +// forms mean what they say (ADR-0011). Inside an `mdl 1;` script it writes the +// mdl 1 forms, which the same script can execute back. +func describeLanguage(ctx *ExecContext) langver.Version { + v := langver.Frozen + if ctx != nil && ctx.LanguageVersion > v { + v = ctx.LanguageVersion + } + return v +} + +// formatListActivity renders a List operation activity as the statement that +// mirrors it (PROPOSAL_mdl_beta_syntax_freeze.md §4, #733). It returns false +// for an activity the statement form cannot express, which then falls back to +// the call form. +func formatListActivity(op microflows.ListOperation, outputVar string) (string, bool) { + out := "$" + outputVar + switch o := op.(type) { + case *microflows.HeadOperation: + return fmt.Sprintf("%s = head $%s;", out, o.ListVariable), true + case *microflows.TailOperation: + return fmt.Sprintf("%s = tail $%s;", out, o.ListVariable), true + case *microflows.FindOperation: + return fmt.Sprintf("%s = find $%s where %s;", out, o.ListVariable, describeExpr(o.Expression)), true + case *microflows.FilterOperation: + return fmt.Sprintf("%s = filter $%s where %s;", out, o.ListVariable, describeExpr(o.Expression)), true + case *microflows.FindByAttributeOperation: + return formatMemberListActivity("find", out, o.ListVariable, o.Attribute, o.Association, o.Expression) + case *microflows.FilterByAttributeOperation: + return formatMemberListActivity("filter", out, o.ListVariable, o.Attribute, o.Association, o.Expression) + case *microflows.SortOperation: + if len(o.Sorting) == 0 { + return "", false + } + items := make([]string, 0, len(o.Sorting)) + for _, s := range o.Sorting { + dir := "asc" + if s.Direction == microflows.SortDirectionDescending { + dir = "desc" + } + name := s.AttributeQualifiedName + if i := strings.LastIndex(name, "."); i >= 0 { + name = name[i+1:] + } + if name == "" { + return "", false + } + items = append(items, mdlIdent(name)+" "+dir) + } + return fmt.Sprintf("%s = sort $%s by %s;", out, o.ListVariable, strings.Join(items, ", ")), true + case *microflows.UnionOperation: + return fmt.Sprintf("%s = union $%s with $%s;", out, o.ListVariable1, o.ListVariable2), true + case *microflows.IntersectOperation: + return fmt.Sprintf("%s = intersect $%s with $%s;", out, o.ListVariable1, o.ListVariable2), true + case *microflows.SubtractOperation: + // Studio Pro's Subtract is the first list minus the second. + return fmt.Sprintf("%s = subtract $%s from $%s;", out, o.ListVariable2, o.ListVariable1), true + case *microflows.ContainsOperation: + return fmt.Sprintf("%s = contains $%s in $%s;", out, o.ObjectVariable, o.ListVariable), true + case *microflows.EqualsOperation: + return fmt.Sprintf("%s = equals $%s and $%s;", out, o.ListVariable1, o.ListVariable2), true + case *microflows.ListRangeOperation: + stmt := fmt.Sprintf("%s = range $%s", out, o.ListVariable) + if o.OffsetExpression != "" { + stmt += " offset " + describeExpr(o.OffsetExpression) + } + if o.LimitExpression != "" { + stmt += " limit " + describeExpr(o.LimitExpression) + } + return stmt + ";", true + } + return "", false +} + +// formatMemberListActivity renders Find / Filter by member: `by Member = value`. +func formatMemberListActivity(verb, out, list, attribute, association, value string) (string, bool) { + field := extractFieldName(attribute, association) + if value == "" { + return "", false + } + if field == "" { + return fmt.Sprintf("%s = %s $%s where %s;", out, verb, list, describeExpr(value)), true + } + return fmt.Sprintf("%s = %s $%s by %s = %s;", out, verb, list, field, describeExpr(value)), true +} + +// formatAggregateActivity renders an Aggregate list activity as the statement +// that mirrors it. fn is the function's MDL keyword and attrName the short +// name of the aggregated attribute, both already resolved by the caller. +func formatAggregateActivity(ctx *ExecContext, a *microflows.AggregateListAction, fn, attrName, outputVar string, entityNames map[model.ID]string) string { + out, list := "$"+outputVar, "$"+a.InputVariable + switch a.Function { + case microflows.AggregateFunctionCount: + return fmt.Sprintf("%s = count %s;", out, list) + case microflows.AggregateFunctionReduce: + initial := describeExpr(a.ReduceInitialValue) + if initial == "" { + initial = "empty" + } + return fmt.Sprintf("%s = reduce %s from %s as %s using %s;", out, list, initial, + formatMicroflowDataType(ctx, a.ReduceReturnType, entityNames), describeExpr(a.Expression)) + case microflows.AggregateFunctionAll, microflows.AggregateFunctionAny: + return fmt.Sprintf("%s = %s %s where %s;", out, fn, list, describeExpr(a.Expression)) + } + if a.UseExpression && a.Expression != "" { + return fmt.Sprintf("%s = %s %s of %s;", out, fn, list, describeExpr(a.Expression)) + } + return fmt.Sprintf("%s = %s %s by %s;", out, fn, list, mdlIdent(attrName)) +} diff --git a/mdl/executor/cmd_microflows_format_list_activity_test.go b/mdl/executor/cmd_microflows_format_list_activity_test.go new file mode 100644 index 000000000..7a76cebae --- /dev/null +++ b/mdl/executor/cmd_microflows_format_list_activity_test.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// describe prints a List operation / Aggregate list activity as the statement +// that mirrors it when it describes under mdl 1, and keeps the call form while +// it describes under mdl 0 — which, while mdl 1 is a preview, is every describe +// outside an `mdl 1;` script (#733). +func TestDescribeListActivityUnderMdl1(t *testing.T) { + cases := []struct { + action microflows.MicroflowAction + mdl1 string + mdl0 string + wantKind string // the statement type the mdl 1 output parses back to + }{ + {listOp(µflows.HeadOperation{ListVariable: "Orders"}, "First"), + "$First = head $Orders;", "$First = head($Orders);", "list"}, + {listOp(µflows.TailOperation{ListVariable: "Orders"}, "Rest"), + "$Rest = tail $Orders;", "$Rest = tail($Orders);", "list"}, + {listOp(µflows.FilterOperation{ListVariable: "Orders", Expression: "$currentObject/Total > 1000"}, "Big"), + "$Big = filter $Orders where $currentObject/Total > 1000;", "$Big = filter($Orders, $currentObject/Total > 1000);", "list"}, + {listOp(µflows.FindOperation{ListVariable: "Orders", Expression: "$currentObject/Number < 3"}, "Late"), + "$Late = find $Orders where $currentObject/Number < 3;", "$Late = find($Orders, $currentObject/Number < 3);", "list"}, + {listOp(µflows.FilterByAttributeOperation{ListVariable: "Orders", Attribute: "M.Order.Status", Expression: "M.Status.Open"}, "Open"), + "$Open = filter $Orders by Status = M.Status.Open;", "$Open = filter($Orders, Status = M.Status.Open);", "list"}, + {listOp(µflows.FindByAttributeOperation{ListVariable: "Orders", Association: "M.Order_Customer", Expression: "$Customer"}, "Match"), + "$Match = find $Orders by Order_Customer = $Customer;", "$Match = find($Orders, Order_Customer = $Customer);", "list"}, + {listOp(µflows.SortOperation{ListVariable: "Orders", Sorting: []*microflows.SortItem{ + {AttributeQualifiedName: "M.Order.Date", Direction: microflows.SortDirectionDescending}, + {AttributeQualifiedName: "M.Order.Number", Direction: microflows.SortDirectionAscending}, + }}, "Sorted"), + `$Sorted = sort $Orders by "Date" desc, Number asc;`, `$Sorted = sort($Orders, "Date" desc, Number asc);`, "list"}, + {listOp(µflows.ListRangeOperation{ListVariable: "Orders", OffsetExpression: "20", LimitExpression: "10"}, "Page"), + "$Page = range $Orders offset 20 limit 10;", "$Page = range($Orders, 20, 10);", "list"}, + {listOp(µflows.ListRangeOperation{ListVariable: "Orders", LimitExpression: "10"}, "Page"), + "$Page = range $Orders limit 10;", "$Page = range($Orders, 0, 10);", "list"}, + {listOp(µflows.UnionOperation{ListVariable1: "A", ListVariable2: "B"}, "All"), + "$All = union $A with $B;", "$All = union($A, $B);", "list"}, + {listOp(µflows.IntersectOperation{ListVariable1: "A", ListVariable2: "B"}, "Both"), + "$Both = intersect $A with $B;", "$Both = intersect($A, $B);", "list"}, + {listOp(µflows.SubtractOperation{ListVariable1: "A", ListVariable2: "B"}, "Left"), + "$Left = subtract $B from $A;", "$Left = subtract($A, $B);", "list"}, + {listOp(µflows.ContainsOperation{ListVariable: "Orders", ObjectVariable: "Order"}, "Has"), + "$Has = contains $Order in $Orders;", "$Has = contains($Orders, $Order);", "list"}, + {listOp(µflows.EqualsOperation{ListVariable1: "A", ListVariable2: "B"}, "Same"), + "$Same = equals $A and $B;", "$Same = equals($A, $B);", "list"}, + {µflows.AggregateListAction{InputVariable: "Orders", OutputVariable: "N", Function: microflows.AggregateFunctionCount}, + "$N = count $Orders;", "$N = count($Orders);", "aggregate"}, + {µflows.AggregateListAction{InputVariable: "Orders", OutputVariable: "Total", Function: microflows.AggregateFunctionSum, AttributeQualifiedName: "M.Order.Amount"}, + "$Total = sum $Orders by Amount;", "$Total = sum($Orders.Amount);", "aggregate"}, + {µflows.AggregateListAction{InputVariable: "Orders", OutputVariable: "Total", Function: microflows.AggregateFunctionAverage, UseExpression: true, Expression: "$currentObject/Price * 2"}, + "$Total = average $Orders of $currentObject/Price * 2;", "$Total = average($Orders, $currentObject/Price * 2);", "aggregate"}, + {µflows.AggregateListAction{InputVariable: "Orders", OutputVariable: "Paid", Function: microflows.AggregateFunctionAll, UseExpression: true, Expression: "$currentObject/Paid"}, + "$Paid = all $Orders where $currentObject/Paid;", "$Paid = all($Orders, $currentObject/Paid);", "aggregate"}, + {µflows.AggregateListAction{InputVariable: "Orders", OutputVariable: "Late", Function: microflows.AggregateFunctionAny, UseExpression: true, Expression: "$currentObject/Late"}, + "$Late = any $Orders where $currentObject/Late;", "$Late = any($Orders, $currentObject/Late);", "aggregate"}, + {µflows.AggregateListAction{InputVariable: "Orders", OutputVariable: "Csv", Function: microflows.AggregateFunctionReduce, UseExpression: true, + Expression: "$currentResult + $currentObject/Name", ReduceInitialValue: "''", ReduceReturnType: µflows.StringType{}}, + "$Csv = reduce $Orders from '' as String using $currentResult + $currentObject/Name;", + "$Csv = reduce($Orders, $currentResult + $currentObject/Name, initial: '', returns: String);", "aggregate"}, + } + for _, tc := range cases { + t.Run(tc.mdl1, func(t *testing.T) { + if got := formatAction(&ExecContext{LanguageVersion: langver.V1}, tc.action, nil, nil); got != tc.mdl1 { + t.Errorf("mdl 1 describe:\n got %s\n want %s", got, tc.mdl1) + } + // Control: outside an mdl 1 script describe keeps the call form. + for _, ctx := range []*ExecContext{nil, {LanguageVersion: langver.V0}} { + if got := formatAction(ctx, tc.action, nil, nil); got != tc.mdl0 { + t.Errorf("mdl 0 describe:\n got %s\n want %s", got, tc.mdl0) + } + } + + // The mdl 1 output parses back under the header, with nothing to warn about. + src := "mdl 1;\ncreate microflow M.A ($Orders: List of M.Order, $A: List of M.Order, $B: List of M.Order, " + + "$Order: M.Order, $Customer: M.Customer)\nbegin\n " + tc.mdl1 + "\nend;" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("the mdl 1 describe output does not parse: %v", errs) + } + if len(prog.Deprecations) > 0 || len(prog.LanguageNotes) > 0 { + t.Errorf("the mdl 1 describe output warns: %v %v", prog.Deprecations, prog.LanguageNotes) + } + }) + } +} + +func listOp(op microflows.ListOperation, out string) *microflows.ListOperationAction { + return µflows.ListOperationAction{Operation: op, OutputVariable: out} +} + +// The describe language is the newest frozen version unless the describe runs +// inside a script whose header names a newer one. +func TestDescribeLanguage(t *testing.T) { + if got := describeLanguage(nil); got != langver.Frozen { + t.Errorf("describeLanguage(nil) = %v, want the frozen %v", got, langver.Frozen) + } + if got := describeLanguage(&ExecContext{LanguageVersion: langver.V1}); got != langver.V1 { + t.Errorf("under mdl 1 = %v", got) + } +} diff --git a/mdl/roundtrip/list_activity_test.go b/mdl/roundtrip/list_activity_test.go new file mode 100644 index 000000000..cb3b55fc2 --- /dev/null +++ b/mdl/roundtrip/list_activity_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package roundtrip + +import ( + "regexp" + "strings" + "testing" +) + +// listActivityRows is one statement per row of the §4 Microflows table of +// PROPOSAL_mdl_beta_syntax_freeze.md, written against PedApp's +// FeedbackModule.Feedback. Each is also the exact line describe must print +// back under mdl 1 (#733). +var listActivityRows = []string{ + "$Open = filter $Feedbacks by Subject = 'Open';", + "$Wide = filter $Feedbacks where $currentObject/ScreenWidth > 1000;", + "$Match = find $Feedbacks by ScreenWidth = 3;", + "$Tall = find $Feedbacks where $currentObject/ScreenHeight > 2;", + "$Sorted = sort $Feedbacks by Subject desc, ScreenWidth asc;", + "$First = head $Feedbacks;", + "$Rest = tail $Feedbacks;", + "$Page = range $Feedbacks offset 20 limit 10;", + "$All = union $Feedbacks with $Others;", + "$Both = intersect $Feedbacks with $Others;", + "$Left = subtract $Others from $Feedbacks;", + "$Has = contains $One in $Feedbacks;", + "$Same = equals $Feedbacks and $Others;", + "$N = count $Feedbacks;", + "$Total = sum $Feedbacks by ScreenWidth;", + "$Double = sum $Feedbacks of $currentObject/ScreenWidth * 2;", + "$Avg = average $Feedbacks by ScreenWidth;", + "$Min = minimum $Feedbacks by ScreenHeight;", + "$Max = maximum $Feedbacks by ScreenHeight;", + "$AllShown = all $Feedbacks where $currentObject/_showEmail;", + "$AnyShown = any $Feedbacks where $currentObject/_showEmail;", + "$Csv = reduce $Feedbacks from '' as String using $currentResult + $currentObject/Subject;", +} + +const listActivityTarget = "microflow MyFirstModule.ListActivities" + +func listActivityScript() string { + return "mdl 1;\ncreate microflow MyFirstModule.ListActivities (\n" + + " $Feedbacks: List of FeedbackModule.Feedback,\n" + + " $Others: List of FeedbackModule.Feedback,\n" + + " $One: FeedbackModule.Feedback\n)\nreturns Boolean\nbegin\n " + + strings.Join(listActivityRows, "\n ") + "\n return $Has;\nend;\n" +} + +var positionLine = regexp.MustCompile(`(?m)^\s*@(position|anchor|curve|merge)\b.*\n`) + +// Every row of the §4 table parses, executes on the Studio Pro-authored +// fixture, and reads back through describe under the header as the same +// statement; the described microflow executes back to the same description. +func TestPedAppListActivitiesUnderMdl1(t *testing.T) { + h := newHarness(t) + defer h.close() + + if err := h.exec(listActivityScript()); err != nil { + t.Fatalf("exec under mdl 1: %v\n%s", err, h.out.String()) + } + first := h.describeUnder("mdl 1;", listActivityTarget) + body := positionLine.ReplaceAllString(first, "") + for _, row := range listActivityRows { + if !strings.Contains(body, " "+row+"\n") { + t.Errorf("describe under mdl 1 does not print %q:\n%s", row, body) + } + } + + // PutGet: executing the mdl 1 description describes back to itself. + if err := h.exec("mdl 1;\n" + first); err != nil { + t.Fatalf("exec the mdl 1 description: %v\n%s", err, first) + } + if again := h.describeUnder("mdl 1;", listActivityTarget); again != first { + t.Errorf("describe -> exec -> describe changed the microflow:\n%s", lineDiff(first, again)) + } + + // Control: outside an mdl 1 script describe keeps the call form while mdl 1 + // is a preview, so the rows above are not what a plain describe prints. + plain, err := h.describe(listActivityTarget) + if err != nil { + t.Fatal(err) + } + if strings.Contains(plain, "= head $Feedbacks;") || !strings.Contains(plain, "= head($Feedbacks);") { + t.Errorf("a plain describe must keep the call form while mdl 1 is a preview:\n%s", plain) + } +} + +// describeUnder runs describe inside a script with the given header. +func (h *harness) describeUnder(header, target string) string { + h.t.Helper() + if err := h.exec(header + "\ndescribe " + target + ";"); err != nil { + h.t.Fatalf("describe %s under %q: %v", target, header, err) + } + return h.out.String() +} From d80cefeeeffc193ecc746a93ab76171db02a03dc Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 27 Sep 2026 08:33:45 +0000 Subject: [PATCH 09/37] docs: write-microflows skill and syntax topic use the list statement forms Part of #733. Co-Authored-By: Claude Opus 5.5 --- .../skills/mendix/write-microflows/SKILL.md | 12 +- .../reference/data-operations.md | 157 ++++++++++++------ cmd/mxcli/syntax/features_microflow.go | 66 +++++++- 3 files changed, 177 insertions(+), 58 deletions(-) diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index 1bb0fe887..1c5397194 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -283,6 +283,14 @@ declare $status Enumeration(Module.OrderStatus) = Module.OrderStatus.Open; > The `calendar*Between` functions (`calendarMonthsBetween`, `calendarYearsBetween`) > return whole units (Integer) and are fine to assign directly. +### Changing a variable: always `set` + +`set $Counter = $Counter + 1;` changes a variable. `$x = …` without `set` is an +activity that creates `$x` — required under `mdl 1;` (`MDL-V1-SET` otherwise). +List operations and aggregates are one statement per activity and never nest: +`$Open = filter $Orders by Status = M.Status.Open;` then `$N = count $Open;` — +see [`reference/data-operations.md`](reference/data-operations.md#one-statement-per-activity). + ### ❌ INCORRECT Syntax ```mdl @@ -676,8 +684,8 @@ close page on error { return; }; of them; `continue` is fine on `declare`, `set`, `retrieve`, `delete` and `call microflow`. Measured on 11.14.0 — note that create-*variable* and change-*variable* accept `continue` while change-*object* does not. -- **The list-operation and aggregate forms of `set`** (`$x = head($l)`, - `$n = count($l)`) have no error handling in Mendix at all — **MDL077**. +- **List operations and aggregates** (`$x = head $l;`, `$n = count $l;`) + have no error handling in Mendix at all — **MDL077**. **In a nanoflow, almost none of them take a clause at all.** `change`, `log`, `show page`, `close page`, `show message` and `validation feedback` are CE6035 diff --git a/.claude/skills/mendix/write-microflows/reference/data-operations.md b/.claude/skills/mendix/write-microflows/reference/data-operations.md index 6ed4590d2..e10bf2cff 100644 --- a/.claude/skills/mendix/write-microflows/reference/data-operations.md +++ b/.claude/skills/mendix/write-microflows/reference/data-operations.md @@ -117,18 +117,65 @@ add head($SourceItems) to $Items; Use expression-valued `add` only when the expression returns an object compatible with the target list element type. +### One statement per activity + +Every list operation and aggregate is **one Studio Pro activity**, and it is +written as one statement that mirrors it: the keyword is the operation's name +in the activity's dialog, and the inputs are the ones the dialog asks for. The +operand is always a **variable**, because the dialog selects a variable — so +one activity cannot be nested inside another, just as it cannot be drawn. + +| Studio Pro activity: operation | MDL statement | +|---|---| +| List operation: Filter | `$Open = filter $Orders by Status = Shop.Status.Open;` | +| List operation: Filter by expression | `$Big = filter $Orders where $currentObject/Total > 1000;` | +| List operation: Find | `$Order = find $Orders by Number = $Number;` | +| List operation: Find by expression | `$Late = find $Orders where $currentObject/DueDate < [%CurrentDateTime%];` | +| List operation: Sort | `$Sorted = sort $Orders by OrderDate desc, Number asc;` | +| List operation: Head / Tail | `$First = head $Orders;` / `$Rest = tail $Orders;` | +| List operation: Range | `$Page = range $Orders offset 20 limit 10;` | +| List operation: Union / Intersect / Subtract | `$All = union $A with $B;` / `$Both = intersect $A with $B;` / `$Left = subtract $B from $A;` | +| List operation: Contains / Equals | `$Has = contains $Order in $Orders;` / `$Same = equals $A and $B;` | +| Aggregate list: Count | `$N = count $Orders;` | +| Aggregate list: Sum / Average / Minimum / Maximum | `$Total = sum $Orders by Amount;` or `$Total = sum $Orders of $currentObject/Amount * 1.21;` | +| Aggregate list: All / Any | `$AllPaid = all $Orders where $currentObject/Paid;` | +| Aggregate list: Reduce | `$Csv = reduce $Orders from '' as String using $currentResult + $currentObject/Name;` | + +`by` picks a member (the dialog's attribute or association selector) and `where` +/ `of` take an expression — the "… by expression" variants. `subtract $B from $A` +is A minus B. + +Two statements, never one nested call: + +```mdl +$Approved = filter $Orders where $currentObject/Status = Shop.Status.Approved; +$Count = count $Approved; +``` + +The older **call forms** (`$x = filter($L, …)`, `$n = count($L)`, `sum($L.Attr)`) +still parse and build the same activity, but they are deprecated +(`MDL-DEPR003`, `MDL-DEPR004`) — do not write them. Under the `mdl 1;` header: + +- `set` is **mandatory** to change a variable: `set $Total = $Total + 1;`. A + statement without `set` is only ever an activity (`MDL-V1-SET` under mdl 0). +- `$x = find(…)` and `$x = contains(…)` are refused: the call is also Mendix's + **string** function. `set $Pos = find($Text, 'a');` is the string function; + `$Match = find $L where …;` is the list operation (`MDL-V1-LIST`). +- a nested call, or a list operation after `set`, is refused (`MDL-V1-LIST`). + Without the header a nested operand is refused at check time as `MDL-LISTOP02`. + ### `range` — paging a list -`range` takes the **offset first, then the amount**, and Mendix requires at -least one of them: +`range` takes an `offset` and a `limit` (Studio Pro's *Offset* and *Amount*), and +Mendix requires at least one of them: ```mdl -$Page = range($Sorted, $Offset, $PageSize); -- skip $Offset, take $PageSize -$First = range($Sorted, 0, 10); -- first 10 -$Rest = range($Sorted, $Offset); -- skip $Offset, take the rest +$Page = range $Sorted offset $Offset limit $PageSize; -- skip $Offset, take $PageSize +$First = range $Sorted limit 10; -- first 10 +$Rest = range $Sorted offset $Offset; -- skip $Offset, take the rest ``` -`range($List)` with no bound builds nothing useful and fails with **CE6520** +`range $List;` with no bound builds nothing useful and fails with **CE6520** ("Amount and offset are not specified. Either amount or offset or both must be specified."); `mxcli check` refuses it first as **MDL068**. To use the whole list, drop the activity and use the list variable directly. @@ -142,89 +189,95 @@ retrieve $Page from Sales.Order where [Status = 'Open'] sort by OrderDate desc limit $PageSize offset $Offset; ``` -Note the clause order there is `limit` then `offset` — the reverse of `range`'s -argument order, because each mirrors the Mendix editor it comes from. - -### `filter` / `find` test one item at a time — `$currentObject` +### `filter` / `find` — `by` a member, or `where` an expression over `$currentObject` -A FILTER/FIND predicate is an expression Mendix evaluates once per item, with -the item bound to **`$currentObject`**. That is the only iterator name there is: +`by Member = value` is Studio Pro's *Filter* / *Find*: an attribute or +association of the list's entity and the value it must have. Anything else is +the *by expression* operation, written after `where`, which Mendix evaluates +once per item with the item bound to **`$currentObject`** — the only iterator +name there is: ```mdl -$Pending = filter($Orders, $currentObject/Status = 'Pending'); -$Large = filter($Orders, $currentObject/Amount > 1000); -$Match = find($Orders, $currentObject/OrderNumber = $Wanted); +$Pending = filter $Orders by Status = Shop.Status.Pending; +$Large = filter $Orders where $currentObject/Amount > 1000; +$Match = find $Orders by OrderNumber = $Wanted; ``` -A **bare attribute name** means the same thing — mxcli resolves it against the -list's entity and writes `$currentObject/Attr`: +A **bare attribute name** after `where` means the same thing — mxcli resolves it +against the list's entity and writes `$currentObject/Attr`: ```mdl -$Open = filter($Orders, Status != 'Closed'); -- stored as $currentObject/Status +$Open = filter $Orders where Status != 'Closed'; -- stored as $currentObject/Status ``` -Two things are refused rather than passed through to the build: +`by` accepts only `Member = value`; `filter $L by Amount > 3` is refused with a +pointer to `where`. Two things are refused after `where` rather than passed +through to the build: - a bare name that is **not** a member of the list's entity (this used to reach mxbuild as `CE0117 "Error(s) in expression."`); -- any **other** iterator name — `filter($L, $item/Amount > 0)` is `MDL-LISTOP01`, - pre-empting `CE0109 "Undefined variable 'item'"`. +- any **other** iterator name — `filter $L where $item/Amount > 0` is + `MDL-LISTOP01`, pre-empting `CE0109 "Undefined variable 'item'"`. `$item` is still fine when it is genuinely in scope, which is how the O(N) lookup -idiom is written: inside `loop $item in $L`, `find($Others, Key = $item/Key)` +idiom is written: inside `loop $item in $L`, `find $Others by Key = $item/Key` navigates the **loop's** variable on the right-hand side. -**`sort` is not an expression.** It takes attribute names directly, so a bare -attribute is the only spelling — `sort($Orders, CreateDate desc)`. Writing -`$currentObject/` there is wrong. - +**`sort` is not an expression.** It takes attribute names directly — +`sort $Orders by CreateDate desc` — and any word works as a name there, so an +attribute called `Count` or `Date` needs no quotes. Writing `$currentObject/` +there is wrong. -### `contains` is overloaded — string vs list +### `contains` — string function vs list operation -`contains(a, b)` is both a **string** function (`contains(haystack, needle)` → substring test) and a **list** operation (`contains(list, object)` → membership test). mxcli picks the right serialization automatically: +The list operation is `contains $Object in $List`, and it creates its own +Boolean output variable, so do not declare it first. The **string** function +`contains(haystack, needle)` is an expression, assigned with `set` to a variable +declared first: ```mdl +-- LIST contains — the statement creates $Found +$Found = contains $Item in $Items; + -- STRING contains — assign to a PRE-DECLARED Boolean (a Change Variable action) declare $HasAt Boolean = false; set $HasAt = contains($Email, '@'); - --- LIST contains — do NOT pre-declare the output (the list op creates it) -set $Found = contains($Items, $Item); ``` -The distinction: a **literal or computed** second argument is always the string function. When both arguments are plain variables, the input variable's declared type decides — a **String** input becomes the string function (Change Variable, so declare the Boolean first), anything else stays a list operation (which creates its own output variable, so leave it undeclared). Getting the declare wrong is what triggers `CE0111 "Duplicate variable name"`. +Under `mdl 1;` that is the whole rule. Without the header, the call form +`$Found = contains($Items, $Item)` is still read as the list operation when both +arguments are plain variables and the first is not a declared String — the +guess that `mdl 1` removes. Getting the declare wrong is what triggers `CE0111 +"Duplicate variable name"`. ### Aggregates — all eight, including `reduce`, `all` and `any` An Aggregate list activity folds a list into one value. `count` takes only the -list; the rest take either an **attribute** or an **expression** over -`$currentObject`. +list; `sum`, `average`, `minimum` and `maximum` aggregate an attribute (`by`) or +an expression over `$currentObject` (`of`) — the dialog's *Aggregate with*. ```mdl -$Count = count($Orders); -$Total = sum($Orders.Amount); -- attribute form -$Total = sum($Orders, $currentObject/Amount * 1.21); -- expression form -$Avg = average($Orders.Amount); -$Min = minimum($Orders.Amount); -$Max = maximum($Orders.Amount); +$Count = count $Orders; +$Total = sum $Orders by Amount; -- attribute +$Total = sum $Orders of $currentObject/Amount * 1.21; -- expression +$Avg = average $Orders by Amount; +$Min = minimum $Orders by Amount; +$Max = maximum $Orders by Amount; -- Boolean predicates over every item. No seed, always Boolean. -$AllPaid = all($Orders, $currentObject/Paid); -$AnyLate = any($Orders, $currentObject/DueDate < [%CurrentDateTime%]); +$AllPaid = all $Orders where $currentObject/Paid; +$AnyLate = any $Orders where $currentObject/DueDate < [%CurrentDateTime%]; -- REDUCE folds with a running total. $currentResult is the accumulator. -$Discounted = reduce( - $Orders, - $currentResult + $currentObject/Amount * 0.9, - initial: 0, - returns: Decimal -); +$Discounted = reduce $Orders from 0 as Decimal + using $currentResult + $currentObject/Amount * 0.9; ``` -**`reduce` needs `initial:` and `returns:` and neither can be inferred.** Mendix -stores both beside the expression, and the fold is meaningless without a seed -and a result type — so MDL makes them mandatory rather than guessing. `all` and -`any` take neither: they never accumulate, and always fold to Boolean. +**`reduce` needs the initial value (`from`) and the return type (`as`), and +neither can be inferred.** Mendix stores both beside the expression, and the +fold is meaningless without a seed and a result type — so MDL makes them +mandatory rather than guessing. `all` and `any` take neither: they never +accumulate, and always fold to Boolean. Do not reach for `reduce` where `sum` will do. It exists for folds Mendix has no dedicated function for — running a string together, or carrying a value forward diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index bc7dc3a74..8c3054735 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -176,8 +176,8 @@ func init() { "-- FEEDBACK -> MDL076. A custom handler IS accepted on all of them, and\n" + "-- CONTINUE is fine on DECLARE, SET, RETRIEVE, DELETE and CALL MICROFLOW.\n" + "--\n" + - "-- The list-operation and aggregate forms of SET ($x = head($l),\n" + - "-- $n = count($l)) have no error handling in Mendix at all -> MDL077.\n" + + "-- List operations and aggregates ($x = head $l, $n = count $l) have\n" + + "-- no error handling in Mendix at all -> MDL077.\n" + "--\n" + "-- IN A NANOFLOW only DECLARE and SET take a clause at all. CHANGE, LOG,\n" + "-- SHOW PAGE, CLOSE PAGE, SHOW MESSAGE and VALIDATION FEEDBACK are CE6035\n" + @@ -383,9 +383,67 @@ func init() { // A CE number in the cached `syntax --json` index leads an agent // debugging the build error back to the right topic (issue #1002). "$currentObject", "predicate", "CE0117", "CE0109", "MDL-LISTOP01", + "contains", "equals", "by", "where", "MDL-DEPR003", "MDL-DEPR004", "MDL-V1-LIST", }, - 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);", + Syntax: "$List = CREATE LIST OF Module.Entity;\nADD $Item TO $List;\nREMOVE $Item FROM $List;\n\n" + + "-- One statement per Studio Pro activity. The keyword is the operation's\n" + + "-- name and the operand is always a variable, as in the activity's dialog.\n" + + "-- List operation:\n" + + "$Result = HEAD $List;\n$Result = TAIL $List;\n" + + "$Result = FIND $List BY Member = value; -- Find (attribute or association)\n" + + "$Result = FIND $List WHERE expression; -- Find by expression\n" + + "$Result = FILTER $List BY Member = value; -- Filter\n" + + "$Result = FILTER $List WHERE expression; -- Filter by expression\n" + + "$Result = SORT $List BY Attr DESC, Attr2 ASC;\n" + + "$Result = UNION $L1 WITH $L2;\n$Result = INTERSECT $L1 WITH $L2;\n" + + "$Result = SUBTRACT $L2 FROM $L1; -- $L1 minus $L2\n" + + "$Bool = CONTAINS $Object IN $List;\n$Bool = EQUALS $L1 AND $L2;\n" + + "$Result = RANGE $List OFFSET offset LIMIT amount;\n\n" + + "-- Aggregate list: BY an attribute, or OF an expression over $currentObject.\n" + + "$Count = COUNT $List;\n$Sum = SUM $List BY Attr;\n$Sum = SUM $List OF expression;\n" + + "$Avg = AVERAGE $List BY Attr;\n$Min = MINIMUM $List BY Attr;\n$Max = MAXIMUM $List BY Attr;\n" + + "$AllMatch = ALL $List WHERE boolean-expression;\n$AnyMatch = ANY $List WHERE boolean-expression;\n\n" + + "-- REDUCE folds the list into one value. $currentResult is the running\n" + + "-- total; the initial value and the type are required and cannot be inferred.\n" + + "$Folded = REDUCE $List FROM initial AS Type USING expression;\n\n" + + "-- RANGE needs at least ONE bound:\n" + + "-- RANGE $L OFFSET $Offset LIMIT $Amount page: skip $Offset, take $Amount\n" + + "-- RANGE $L LIMIT $Amount first $Amount\n" + + "-- RANGE $L OFFSET $Offset skip $Offset, take the rest\n" + + "-- RANGE with no bound is CE6520 at build time (mxcli check: MDL068).\n\n" + + "BY names a member and the value it must have; anything else goes after\n" + + "WHERE, which is evaluated once per item with the item bound to\n" + + "$currentObject -- the same variable the aggregate expressions use:\n\n" + + " FILTER $Orders WHERE $currentObject/Amount > 0\n\n" + + "A bare attribute name after WHERE means the same thing; mxcli resolves it\n" + + "against the list's entity and writes $currentObject/Attr. Naming any\n" + + "other variable there is MDL-LISTOP01.\n\n" + + "Because the operand is a variable, activities cannot nest. Give each\n" + + "its own statement:\n\n" + + " $Approved = FILTER $Orders WHERE $currentObject/Status = 'Approved';\n" + + " $Count = COUNT $Approved;\n\n" + + "The call forms ($x = FILTER($L, ...), $n = COUNT($L)) are deprecated\n" + + "aliases (MDL-DEPR003, MDL-DEPR004). $x = FIND(...) and CONTAINS(...) are\n" + + "also Mendix's string functions: under `mdl 1;` the call form is refused\n" + + "and `SET $x = find($Text, 'a')` is always the string function\n" + + "(MDL-V1-LIST). Without the header a nested call is refused as\n" + + "MDL-LISTOP02.", + Example: "$AllOrders = CREATE LIST OF MyModule.Order;\nADD $NewOrder TO $AllOrders;\n$First = HEAD $AllOrders;\n\n" + + "-- Filter by member, and by expression over $currentObject\n" + + "$Pending = FILTER $AllOrders BY Status = MyModule.OrderStatus.Pending;\n" + + "$Large = FILTER $AllOrders WHERE $currentObject/Amount > 1000;\n" + + "$Match = FIND $AllOrders BY OrderNumber = $Number;\n\n" + + "-- SORT takes attribute names, not an expression\n" + + "$Sorted = SORT $Pending BY CreateDate DESC;\n" + + "$Page = RANGE $Sorted OFFSET $Offset LIMIT $PageSize;\n" + + "$Total = SUM $AllOrders BY Amount;\n" + + "$AllPaid = ALL $AllOrders WHERE $currentObject/Paid;\n" + + "$AnyLate = ANY $AllOrders WHERE $currentObject/DueDate < [%CurrentDateTime%];\n\n" + + "-- One statement per activity\n" + + "$Paid = FILTER $AllOrders WHERE $currentObject/Paid;\n" + + "$Count = COUNT $Paid;\n" + + "$Discounted = REDUCE $AllOrders FROM 0 AS Decimal\n" + + " USING $currentResult + $currentObject/Amount * 0.9;", SeeAlso: []string{"microflow.retrieve"}, }) From 66fabe342ca6df1e76b69c863efe95bec00ab18a Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 27 Sep 2026 08:35:42 +0000 Subject: [PATCH 10/37] findings: #731 security list markers and JavaScript action rewrite carries Co-Authored-By: Claude Opus 5.5 --- .claude/skills/fix-issue/findings/mdl-backend.jsonl | 1 + .claude/skills/fix-issue/findings/mdl-executor.jsonl | 1 + 2 files changed, 2 insertions(+) diff --git a/.claude/skills/fix-issue/findings/mdl-backend.jsonl b/.claude/skills/fix-issue/findings/mdl-backend.jsonl index 6abb5c2cc..0678e5408 100644 --- a/.claude/skills/fix-issue/findings/mdl-backend.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-backend.jsonl @@ -136,3 +136,4 @@ {"area": "mdl/backend", "date": "2026-09-26", "symptom": "`ALTER PAGE … SET ImageUrl = '…' ON img1` (pluggable Image) prints \"Altered page\" and changes nothing: DESCRIBE still shows the old URL. Same silent success for SET on any pluggable widget property of kind Expression, Image, Icon, Action, DataSource, Attribute or Widgets", "cause": "setPluggableWidgetPropertyMut (mdl/backend/pagemutator/mutator.go) wrote every value to Value.PrimitiveValue. imageUrl is a TextTemplate; readers take Value.TextTemplate. The DataGrid 2 column setter had the identical defect fixed on 2026-08-18 (columnValueField) — the widget-level setter sitting next to it was never given the same schema dispatch.", "file": "mdl/backend/pagemutator/mutator.go (setPluggableWidgetPropertyMut, buildPropKindMap), test pluggable_property_kind_test.go, example mdl-examples/bug-tests/1201-alter-page-set-pluggable-texttemplate.mdl", "insight": "When a fix lands on one setter for 'write the field the schema declares', grep the sibling setters in the same file for the same always-PrimitiveValue write — a WidgetValue carries every variant field, so the wrong write never errors and every signal stays green. The live control is cheap without mxbuild: copy testdata/expr-checker, create an image with URL A, ALTER to B, describe (pre-fix binary shows A). A null TextTemplate means the slot is hidden (#574); ALTER does not re-run visibility, so refuse rather than build an envelope there. Not fixed here: a pluggable boolean SET stores \"yes\"/\"no\" — check what CREATE stores before changing it.", "refs": ["mendixlabs/mxcli#1201", "mendixlabs/mxcli#1069", "mendixlabs/mxcli#750", "mendixlabs/mxcli#574"], "rules": []} {"area": "mdl/backend", "date": "2026-09-26", "symptom": "describe → exec of a snippet turns its Type 'Web' into '' (and would reset a non-default ExportLevel or canvas size)", "cause": "snippetToGen wrote Type, ExportLevel, CanvasWidth and CanvasHeight as constants on every write; the Type constant was \"\"", "file": "mdl/backend/modelsdk/snippet_write.go", "insight": "The snippet twin of #541 (carryStoredPageHeader). \"\" is not a member of PagesType (Native | Web), so every snippet mxcli created was also invalid. Carry the stored header on UpdateSnippet (width-agnostic canvas read — Studio Pro stores int64); a new snippet gets Web/Hidden/800×600, measured on all four Blank-template snippets", "refs": ["ako/mxcli#705", "#541"]} {"area": "mdl/backend", "date": "2026-09-26", "symptom": "describe → exec of a nanoflow detaches every annotation from its activity (AnnotationFlows 3 → 0, so the next describe prints the notes as free-floating) and deletes ExportLevel, UseListParameterByReference and ReturnVariableName; MarkAsUsed is cleared. Rules drop their annotation flows too", "cause": "nanoflowToGen and ruleToGen never wrote ObjectCollection.AnnotationFlows (microflowToGen did), and nanoflowToGen never set the three header keys; nanoflowFromGen did not read ReturnVariableName or MarkAsUsed and the builder hardcoded MarkAsUsed false", "file": "mdl/backend/modelsdk/nanoflow_write.go, rule_write.go, microflow.go; mdl/executor/cmd_microflows_build.go", "insight": "Three flow flavours share the object/flow converters but each has its own top-level writer, and each dropped a different subset — grep every *ToGen for the same list loop, not just the reported one. For keys with no MDL spelling and unknown version floors (Nanoflow.ExportLevel, UseListParameterByReference), carry ONLY the keys the stored unit has: nothing was ever written on create, Studio Pro fills an absent key, and an undeclared one makes the unit unopenable", "refs": ["ako/mxcli#705"]} +{"area": "mdl/backend", "date": "2026-09-27", "symptom": "Running unchanged `describe user role` output (create or modify user role) rewrites Security$ProjectSecurity: /UserRoles[0] marker 2 -> 3; same for DemoUsers on a demo-user rewrite", "cause": "The encoder's default PartList marker is 3; Studio Pro writes 2 for ProjectSecurity.UserRoles and .DemoUsers (PedApp, TestApp, expr-checker agree). An untouched list passes through raw, so it only shows once an element in it is rewritten", "file": "mdl/backend/modelsdk/security_write.go", "insight": "A marker bug hides until the first rewrite of an element in that list; census the stored marker per (owner $Type, key) across Studio Pro fixtures and register it with RegisterPropertyListMarker", "refs": ["ako/mxcli#731"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 92a698438..4b6cfcc49 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -728,3 +728,4 @@ {"area": "mdl/executor", "date": "2026-09-26", "symptom": "`describe fragment from page M.P widget w` (and `from snippet`) fails for every container and every widget — including ones `describe page` prints — with `not found in page M.P`, a message that does not even name the widget", "cause": "Visitor stores DescribeFragmentFromStmt.ContainerType as \"PAGE\"/\"SNIPPET\"; describeFragmentFrom switched on \"page\"/\"snippet\" with no default, so neither branch ran, the widget list stayed empty, and the fall-through reported the widget missing. Third instance of this split: ALTER PAGE (#402), DESCRIBE/ALTER STYLING (#631)", "file": "`mdl/executor/cmd_fragments.go` (`describeFragmentFrom`)", "insight": "The mismatch hid behind a plausible error because a switch on the discriminator had no default: an unmatched container type looked like an empty container, and an empty container looks like a missing widget. Normalise with strings.ToLower where the discriminator is consumed (the house convention — cmd_styling, cmd_alter_page, validate_alter_* all do) AND make the default an error, so the next casing drift fails loudly instead of reporting the wrong thing. The existing mock tests hand-built the AST and so agreed with the handler; only a test that goes visitor.Build → NewRegistry().Dispatch pins the contract between the two layers (cmd_fragments_from_test.go). Verified on Evora: Administration.Account_Edit/textBox6 and AgentCommons.Snippet_Agent_Details/dataView7 now describe", "refs": ["#402", "#631"]} {"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe output that does not re-parse or loses data (ako/mxcli#707): an entity string default or validation message containing ' was emitted unescaped; so were module-role descriptions, published OData/REST Path/Version/Namespace/Summary/Folder, and REST client BaseUrl/Path/header values; an agent `mcp service` block with a Description lacked the comma after `Enabled`; workflow decision / parallel split captions came back only as `-- caption` comments (replay reset them to 'Decision' / 'Parallel split'); `describe demo user` emitted `password '***'`, which replay stored as the password; `describe settings` printed `DatabasePassword = '

'`.", "cause": "Hand-rolled `'%s'` emit sites that put the quotes and the escaping in different places (the #1006 source-scan guard covered only cmd_workflows.go); a block emitter with no separator logic, unlike its sibling; captions treated as commentary although the grammar has `comment '…'` for both activities; secrets printed as data, with a placeholder the writer took literally.", "file": "mdl/executor/cmd_entities_describe.go, cmd_security.go, cmd_security_write.go, cmd_odata.go, cmd_published_rest.go, cmd_rest_clients.go, cmd_agenteditor_agents.go, cmd_workflows.go, cmd_settings.go", "fix": "Every emit site uses mdlQuoted; TestDescribers_HaveNoHandRolledStringLiterals now scans all seven describer files. MCP block writes the comma like the tool block. workflowCaptionClauses emits `comment '…'` for a non-default caption and computes the name clause against the caption the writer will store. DatabasePassword is omitted with a comment (create or modify is a patch, so replay keeps it). Demo users are described as `create or modify … password '***'`, and the executor treats '***' as 'keep the stored password', refusing it for a user that does not exist.", "insight": "Assert round trips by reparsing describe output with the real visitor and comparing the AST value to the stored one, not by substring. For secrets the right placeholder is one the WRITER understands: omission works where the create is a patch (configuration); where the grammar requires the value (demo user) give the placeholder a meaning (keep stored) and refuse it where that meaning is empty, so a replay can neither leak nor silently set a credential.", "test": "mdl/executor/issue707_describe_roundtrip_test.go"} {"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe microflow \u2026 with handles (ako/mxcli#713) printed no handle for an activity inside an `on error { \u2026 }` block, and the alter-target resolver counted such activities after the whole main flow: on SUB_Feedback_PostToAppInsights `return * @1` picked `return $Response`, although describe prints the handler's `return empty` first. Also `$Response` did not address a REST call whose output is on its result handling (and cast, create list, web service, workflow, XML/JSON, database-query outputs).", "cause": "Error-handler bodies are rendered by collectErrorHandlerStatements, a second describer that returned bare strings and never wrote the source map, so those nodes had no line to rank or print a handle at; unranked candidates were appended last. The output-variable switch was copied from actionOutputVariableName, which had drifted from the formatter.", "file": "mdl/executor/cmd_microflows_show_helpers.go; mdl/backend/mfmutator/target.go", "fix": "collectErrorHandlerStatementSpans reports each handler-body object's statement span; emitActivityStatement and emitCommentedErrorHandler record them in the source map (additive entries in ELK sourceMap too). mfmutator.OutputVariable reads the variable where the formatter does, for every action it prints as `$X = \u2026`. A comment rendering (`-- Unsupported \u2026`) is no statement, so it never becomes a handle.", "insight": "Any node the describer prints through a side path must enter the source map, or everything keyed on print order (ordinals, handles, ELK highlighting) silently disagrees with the text. Check ranking against a Studio Pro flow that has a handler body, not just VAL_Feedback.", "test": "TestDescribeWithHandles_ErrorHandlerBody, TestMicroflowTargets_PedAppEveryFlowRanksAndResolves, TestOutputVariable_EveryActionDescribePrintsAnAssignmentFor, TestCandidate_CommentRenderingIsNoStatement"} +{"area": "mdl/executor", "date": "2026-09-27", "symptom": "Once describe emitted create or modify javascript action, re-running it wrote a .js file holding the describe placeholder body, dropped parameter descriptions, and rewrote Studio Pro doc comments (CRLF, blank lines) \u2014 getput and putget failures on 45 PedApp actions", "cause": "The JS write path lacked the Java twin's carries: placeholder guard (javaSourceOmittedBody) and stored-parameter Description/Category; and carriedDocumentation overwrote stored prose with its doc-comment normal form", "file": "mdl/executor/cmd_javascript_actions_write.go, documentation_carry.go", "insight": "Turning a plain create into create or modify is what makes describe output execute at all \u2014 expect the next layer of losses to surface immediately; diff the twin (Java vs JavaScript) write paths for every carry one has and the other lacks", "refs": ["ako/mxcli#731"]} From 4580e851ceae9e0a30ab1b7c752f4a21dc2f2c1b Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 08:49:04 +0000 Subject: [PATCH 11/37] canon: a patch of the stored unit owns its element $IDs (ContentsOwnElementIDs, Writer.UpdateRawUnitPatch) TransplantIDs pairs elements by type and position, which re-pairs the surviving flows of a patched microflow onto their neighbours' $IDs after a drop. A write that started from the stored bytes skips it; elision and the storage-GUID guard still apply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- modelsdk/canon/identity.go | 21 +++++++++++- modelsdk/canon/patch_ids_test.go | 56 ++++++++++++++++++++++++++++++++ modelsdk/mpr/writer_core.go | 11 +++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 modelsdk/canon/patch_ids_test.go diff --git a/modelsdk/canon/identity.go b/modelsdk/canon/identity.go index 8c9143d9b..8a8cb0c96 100644 --- a/modelsdk/canon/identity.go +++ b/modelsdk/canon/identity.go @@ -36,6 +36,23 @@ type Option func(*reconcileOpts) type reconcileOpts struct { contentsOwnTranslations bool contentsOwnStorageGUIDs bool + contentsOwnElementIDs bool +} + +// ContentsOwnElementIDs tells Reconcile that the write is a PATCH of the stored +// document, not a rebuild: every element that survived kept its stored $ID, and +// every new element carries a fresh one on purpose. So the structural transplant +// must not run. +// +// The transplant exists for rebuilds, whose elements all arrive with random +// $IDs and are paired back by type and position. On a patch that pairing is not +// a no-op but a hazard: drop one sequence flow and every flow after it pairs with +// its predecessor, taking that flow's $ID — identities move onto other nodes, +// which is what ADR-0012 measured the microflow rebuild doing (51 of 161). The +// graph splice of `alter microflow` (mfmutator) is the caller; it has its own +// guard that no reference is left dangling. Elision still applies. +func ContentsOwnElementIDs() Option { + return func(o *reconcileOpts) { o.contentsOwnElementIDs = true } } // ContentsOwnTranslations tells Reconcile that the write already accounts for @@ -114,7 +131,9 @@ func Reconcile(contents, stored []byte, opts ...Option) (out []byte, unchanged b // version control as a whole-document replacement. TransplantIDs puts the // stored ids back on the elements that still correspond, rewriting every // reference with them. - contents = TransplantIDs(contents, stored) + if !o.contentsOwnElementIDs { + contents = TransplantIDs(contents, stored) + } // And the nested identity property the transplant does not cover: every // Workflows$* element carries a PersistentId that both engines re-mint on diff --git a/modelsdk/canon/patch_ids_test.go b/modelsdk/canon/patch_ids_test.go new file mode 100644 index 000000000..cae29e2f3 --- /dev/null +++ b/modelsdk/canon/patch_ids_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +package canon + +import ( + "bytes" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// seqFlows builds a flow document whose Flows list holds one sequence flow per +// id in ids, each from origin i to origin i+1. Every flow has the same $Type +// and shape, which is exactly what makes the structural pairing positional. +func seqFlows(t *testing.T, ids ...byte) []byte { + t.Helper() + flows := bson.A{int32(3)} + for i, id := range ids { + flows = append(flows, bson.D{ + {Key: "$Type", Value: "Microflows$SequenceFlow"}, + {Key: "$ID", Value: bin(id)}, + {Key: "OriginPointer", Value: bin(byte(100 + i))}, + {Key: "DestinationPointer", Value: bin(byte(101 + i))}, + }) + } + return marshal(t, bson.D{ + {Key: "$Type", Value: "Microflows$Microflow"}, + {Key: "$ID", Value: bin(1)}, + {Key: "Flows", Value: flows}, + }) +} + +// A patch of the stored document — an `alter microflow` drop — keeps every +// surviving element's $ID already. The structural transplant must not then +// re-pair those elements: with one flow gone, the flows after it pair +// positionally with their predecessors and each takes its neighbour's $ID, +// moving identities onto other nodes (ADR-0012's "51 of 161 $IDs changed"). +func TestReconcile_PatchOwnsElementIDs(t *testing.T) { + stored := seqFlows(t, 10, 20, 30, 40) + patched := seqFlows(t, 10, 30, 40) // flow 20 dropped; 30 and 40 keep their $IDs + + // Control: the rebuild path pairs by position and moves $IDs, which is + // the defect the option exists for. + rebuilt, _ := Reconcile(patched, stored) + if ids := idSet(t, rebuilt); ids[blobToUUID(bin(40).Data)] { + t.Fatalf("control: expected the transplant to move $ID 40 onto another flow; the test would prove nothing") + } + + out, unchanged := Reconcile(patched, stored, ContentsOwnElementIDs()) + if unchanged { + t.Fatal("a patch that dropped a flow reported no change") + } + if !bytes.Equal(out, patched) { + t.Errorf("a patch that owns its element $IDs was rewritten by Reconcile") + } +} diff --git a/modelsdk/mpr/writer_core.go b/modelsdk/mpr/writer_core.go index 9d9e0b23c..ec92cb7b8 100644 --- a/modelsdk/mpr/writer_core.go +++ b/modelsdk/mpr/writer_core.go @@ -828,6 +828,17 @@ func (w *Writer) UpdateRawUnitOwningTranslations(unitID string, contents []byte) return w.updateUnit(unitID, contents, canon.ContentsOwnTranslations()) } +// UpdateRawUnitPatch is UpdateRawUnit for a write that PATCHED the stored bytes +// in place rather than rebuilding them — the graph splice of `alter microflow`. +// Such a write already carries every stored $ID and translation it means to +// keep, so neither is carried back: re-pairing element $IDs structurally would +// move identities onto other elements after a drop (canon.ContentsOwnElementIDs), +// and carrying translations would undo a deliberate removal. Elision and the +// storage-GUID guard apply as for every write. +func (w *Writer) UpdateRawUnitPatch(unitID string, contents []byte) error { + return w.updateUnit(unitID, contents, canon.ContentsOwnElementIDs(), canon.ContentsOwnTranslations()) +} + // UpdateRawUnitOwningStorageGUIDs is UpdateRawUnit for a write that deliberately // transplants storage GUIDs onto elements that keep their $ID — the marketplace // module update, which carries a module's existing GUIDs onto the documents From 85ac9cd9e7585af60e03ead5556457a93c7d2e07 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 08:49:04 +0000 Subject: [PATCH 12/37] mfmutator: graph splice into the stored microflow (insert after/before, replace, drop) Splices a fragment into the raw stored unit: appends only the fragment's objects and flows, rewires the flow around the target by its pointers and the rewired end, moves nodes past the insertion point to make room, and never rewrites an $ID. Save refuses a unit in which anything still points at a removed element or two elements share an $ID. Refuses what it cannot do safely: after a decision, before a join, inside a loop body, drop/replace of a decision or of an activity with an error handler, and a placement that would overlap an object. The modelsdk backend writes through UpdateRawUnitPatch (canon.Reconcile). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/backend/backend.go | 1 + mdl/backend/mfmutator/splice.go | 939 ++++++++++++++++++ mdl/backend/mfmutator/splice_test.go | 354 +++++++ mdl/backend/microflow_mutation.go | 49 + mdl/backend/mock/backend.go | 3 + mdl/backend/mock/mock_mutation.go | 11 + .../modelsdk/microflow_mutator_write.go | 91 ++ mdl/backend/modelsdk/unimplemented_gen.go | 14 + 8 files changed, 1462 insertions(+) create mode 100644 mdl/backend/mfmutator/splice.go create mode 100644 mdl/backend/mfmutator/splice_test.go create mode 100644 mdl/backend/microflow_mutation.go create mode 100644 mdl/backend/modelsdk/microflow_mutator_write.go diff --git a/mdl/backend/backend.go b/mdl/backend/backend.go index a57e80e3a..f9a0cd760 100644 --- a/mdl/backend/backend.go +++ b/mdl/backend/backend.go @@ -37,5 +37,6 @@ type FullBackend interface { AgentEditorBackend PageMutationBackend WorkflowMutationBackend + MicroflowMutationBackend WidgetBuilderBackend } diff --git a/mdl/backend/mfmutator/splice.go b/mdl/backend/mfmutator/splice.go new file mode 100644 index 000000000..afc303b30 --- /dev/null +++ b/mdl/backend/mfmutator/splice.go @@ -0,0 +1,939 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +// # The graph splice (plan item 4.2b) +// +// An `alter microflow` operation edits the STORED document, never a rebuild of +// it. The rebuild (`UpdateMicroflow`, then re-pairing IDs by type and position) +// is what deleted merges, reset curves and moved $IDs onto other nodes on a +// Studio Pro-authored flow (ADR-0012, Context). So the splice works on the raw +// BSON tree of the unit as read from storage: +// +// - the fragment's objects are the only elements it builds, and they are +// appended to the collection that holds the target; +// - the sequence flows around the target are rewired by changing their +// pointers, connection sides and the control vector at the rewired end — +// every other property of a rewired flow (its $ID, case value, error-handler +// flag, the curve at its other end) is kept; +// - nodes are moved only to make room, and only by a translation; +// - nothing else is touched, and no $ID is ever rewritten. A removed element +// leaves no reference behind: Save refuses a document in which any binary +// still names an element that was removed (CLAUDE.md rule 1), and one in +// which two elements share an $ID. +// +// What the splice cannot do safely it refuses, with the reason: an insert +// after a decision (which branch?), before an activity several flows enter +// (which path?), a drop or replace of an activity with an error handler (the +// handler would be orphaned), and — for now — anything inside a loop body, +// whose coordinates are relative to the loop box. + +import ( + "bytes" + "fmt" + "strconv" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// Deps supplies the engine-specific steps: turning a new object or flow into +// its stored BSON form, and writing the unit back. Everything between — which +// flows to rewire, where to place, what to remove — is decided here, once, for +// every storage engine. +type Deps interface { + SerializeObject(obj microflows.MicroflowObject) (bson.D, error) + SerializeSequenceFlow(f *microflows.SequenceFlow) (bson.D, error) + SerializeAnnotationFlow(f *microflows.AnnotationFlow) (bson.D, error) + // SaveUnit writes the patched unit. The implementation must go through the + // storage engine's reconciling write (canon.Reconcile), like every write. + SaveUnit(unitID string, contents []byte) error +} + +// Mutator splices fragments into one microflow or nanoflow unit. +type Mutator struct { + deps Deps + unitID model.ID + doc bson.D + // removed holds the $IDs of every element an operation took out, so Save + // can prove that nothing still points at one of them. + removed map[string]bool +} + +var _ backend.MicroflowMutator = (*Mutator)(nil) + +// New returns a Mutator over a decoded Microflows$Microflow or +// Microflows$Nanoflow unit. +func New(doc bson.D, unitID model.ID, deps Deps) (*Mutator, error) { + switch t := dString(doc, "$Type"); t { + case "Microflows$Microflow", "Microflows$Nanoflow": + default: + return nil, fmt.Errorf("unit %s is a %s, not a microflow or nanoflow", unitID, t) + } + if dDoc(doc, "ObjectCollection") == nil { + return nil, fmt.Errorf("unit %s has no object collection", unitID) + } + return &Mutator{deps: deps, unitID: unitID, doc: doc, removed: map[string]bool{}}, nil +} + +// Bytes returns the patched unit, after the integrity checks Save applies. +func (m *Mutator) Bytes() ([]byte, error) { + if err := m.checkIntegrity(); err != nil { + return nil, err + } + return bson.Marshal(m.doc) +} + +// Save writes the patched unit through Deps. +func (m *Mutator) Save() error { + out, err := m.Bytes() + if err != nil { + return err + } + return m.deps.SaveUnit(string(m.unitID), out) +} + +// --------------------------------------------------------------------------- +// The graph view +// --------------------------------------------------------------------------- + +// node is one object of the flow as stored. +type node struct { + id string // model ID form (types.BlobToUUID of the $ID) + typ string + doc bson.D + pos point + size point + // loop is the $ID of the loop whose body holds the node, "" at top level. + loop string +} + +// flowRef is one entry of the unit's Flows list. +type flowRef struct { + id string + typ string + doc bson.D + origin string + dest string + isErr bool +} + +func (f flowRef) isSequence() bool { return f.typ == "Microflows$SequenceFlow" } + +type graph struct { + nodes map[string]*node + // order is the nodes in storage order, so a shift visits them the same + // way every run. + order []*node + flows []flowRef +} + +func (m *Mutator) graph() *graph { + g := &graph{nodes: map[string]*node{}} + var walk func(oc bson.D, loop string) + walk = func(oc bson.D, loop string) { + for _, el := range arrayElements(dGet(oc, "Objects")) { + d, ok := el.(bson.D) + if !ok { + continue + } + n := &node{ + id: binaryID(dGet(d, "$ID")), + typ: dString(d, "$Type"), + doc: d, + pos: parsePoint(dString(d, "RelativeMiddlePoint")), + size: parsePoint(dString(d, "Size")), + loop: loop, + } + if n.id != "" { + g.nodes[n.id] = n + g.order = append(g.order, n) + } + if n.typ == "Microflows$LoopedActivity" { + if inner := dDoc(d, "ObjectCollection"); inner != nil { + walk(inner, n.id) + } + } + } + } + walk(dDoc(m.doc, "ObjectCollection"), "") + for _, el := range arrayElements(dGet(m.doc, "Flows")) { + d, ok := el.(bson.D) + if !ok { + continue + } + isErr, _ := dGet(d, "IsErrorHandler").(bool) + g.flows = append(g.flows, flowRef{ + id: binaryID(dGet(d, "$ID")), + typ: dString(d, "$Type"), + doc: d, + origin: binaryID(dGet(d, "OriginPointer")), + dest: binaryID(dGet(d, "DestinationPointer")), + isErr: isErr, + }) + } + return g +} + +// outgoing returns the sequence flows leaving id, split into normal and +// error-handler flows. +func (g *graph) outgoing(id string) (normal, errs []flowRef) { + for _, f := range g.flows { + if f.isSequence() && f.origin == id { + if f.isErr { + errs = append(errs, f) + } else { + normal = append(normal, f) + } + } + } + return normal, errs +} + +// incoming returns the sequence flows entering id. +func (g *graph) incoming(id string) []flowRef { + var out []flowRef + for _, f := range g.flows { + if f.isSequence() && f.dest == id { + out = append(out, f) + } + } + return out +} + +// annotationFlows returns the annotation flows attached to id. +func (g *graph) annotationFlows(id string) []flowRef { + var out []flowRef + for _, f := range g.flows { + if !f.isSequence() && (f.origin == id || f.dest == id) { + out = append(out, f) + } + } + return out +} + +func (g *graph) node(id model.ID) (*node, error) { + n, ok := g.nodes[string(id)] + if !ok { + return nil, fmt.Errorf("activity %s is not in the stored flow (was it dropped by an earlier operation?)", id) + } + if n.loop != "" { + return nil, fmt.Errorf("%s is inside a loop body; alter does not splice inside a loop yet — "+ + "address the loop itself, or rewrite the loop with create or modify", describeNode(n)) + } + return n, nil +} + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +// InsertAfter splices frag onto the flow leaving target. +func (m *Mutator) InsertAfter(target model.ID, frag *backend.MicroflowFragment) error { + g := m.graph() + x, err := g.node(target) + if err != nil { + return err + } + normal, _ := g.outgoing(x.id) + switch { + case len(normal) == 0: + return fmt.Errorf("nothing follows %s: it ends the flow; insert before it instead", describeNode(x)) + case len(normal) > 1: + return fmt.Errorf("%s has %d outgoing flows (it is a decision): insert after it would not say which branch; "+ + "insert before the first activity of the branch instead", describeNode(x), len(normal)) + } + return m.spliceOnFlow(g, normal[0], frag) +} + +// InsertBefore splices frag onto the flow entering target. +func (m *Mutator) InsertBefore(target model.ID, frag *backend.MicroflowFragment) error { + g := m.graph() + y, err := g.node(target) + if err != nil { + return err + } + in := g.incoming(y.id) + switch { + case len(in) == 0: + return fmt.Errorf("no flow enters %s; there is no path to insert on", describeNode(y)) + case len(in) > 1: + return fmt.Errorf("%d flows enter %s: insert before it would not say on which path; "+ + "insert after one of its predecessors instead", len(in), describeNode(y)) + } + return m.spliceOnFlow(g, in[0], frag) +} + +// spliceOnFlow puts frag on flow f (origin X, destination Y): f keeps its +// origin end and now enters the fragment's entry; a new flow leaves the +// fragment's exit and enters Y where f used to. +func (m *Mutator) spliceOnFlow(g *graph, f flowRef, frag *backend.MicroflowFragment) error { + x, y := g.nodes[f.origin], g.nodes[f.dest] + if x == nil || y == nil { + return fmt.Errorf("flow %s points at an object that is not in the flow", f.id) + } + if x.loop != "" || y.loop != "" { + return fmt.Errorf("the flow runs inside a loop body; alter does not splice inside a loop yet") + } + fb, err := fragmentGeometry(frag) + if err != nil { + return err + } + ax, s, err := flowAxis(x, y) + if err != nil { + return err + } + + // Make room: the fragment plus a gap on either side has to fit between + // X's and Y's facing edges; what is missing is added by moving everything + // past the midpoint of that gap further along the axis. + a := x.pos.get(ax) + s*x.size.get(ax)/2 + b := y.pos.get(ax) - s*y.size.get(ax)/2 + need := fb.length(ax) + 2*minGap + if deficit := need - s*(b-a); deficit > 0 { + m.shift(g, ax, s, float64(a+b)/2, s*deficit, "") + b += s * deficit + } + + // Place the fragment centred in the gap, its entry level with the flow. + cross := ax.other() + mid := point{} + mid = mid.set(ax, (a+b)/2) + mid = mid.set(cross, (x.pos.get(cross)+y.pos.get(cross))/2) + fb.placeCentred(ax, mid) + if err := fb.checkRoom(g, ""); err != nil { + return err + } + + entrySide, exitSide := side(ax, -s), side(ax, s) + destIdx, destVec := flowEnd(f.doc, "Destination") + if err := m.addFragment(frag); err != nil { + return err + } + // f now enters the fragment. + setPointer(f.doc, "DestinationPointer", frag.Entry) + setInt(f.doc, "DestinationConnectionIndex", entrySide) + setVector(f.doc, "DestinationControlVector", sideVector(entrySide)) + // And a new flow carries on from the fragment to Y. + return m.addFlow(&microflows.SequenceFlow{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + OriginID: frag.Exit, + DestinationID: model.ID(y.id), + OriginConnectionIndex: exitSide, + DestinationConnectionIndex: destIdx, + OriginControlVector: sideVector(exitSide), + DestinationControlVector: destVec, + }) +} + +// Replace puts frag where target is: every flow that entered target enters +// the fragment, the flow that left it leaves the fragment's exit, and +// annotations attached to target are attached to the fragment's entry. +func (m *Mutator) Replace(target model.ID, frag *backend.MicroflowFragment) error { + g := m.graph() + x, err := g.node(target) + if err != nil { + return err + } + out, err := removable(g, x, "replace") + if err != nil { + return err + } + y := g.nodes[out.dest] + if y == nil { + return fmt.Errorf("flow %s points at an object that is not in the flow", out.id) + } + fb, err := fragmentGeometry(frag) + if err != nil { + return err + } + ax, s, err := flowAxis(x, y) + if err != nil { + return err + } + // The fragment starts where target started; if it is longer, everything + // past target moves along by the difference. + near := x.pos.get(ax) - s*x.size.get(ax)/2 + if extra := fb.length(ax) - x.size.get(ax); extra > 0 { + m.shift(g, ax, s, float64(x.pos.get(ax)), s*extra, x.id) + } + centre := point{} + centre = centre.set(ax, near+s*fb.length(ax)/2) + centre = centre.set(ax.other(), x.pos.get(ax.other())) + fb.placeCentred(ax, centre) + if err := fb.checkRoom(g, x.id); err != nil { + return err + } + + if err := m.addFragment(frag); err != nil { + return err + } + for _, in := range g.incoming(x.id) { + setPointer(in.doc, "DestinationPointer", frag.Entry) + } + setPointer(out.doc, "OriginPointer", frag.Exit) + for _, af := range g.annotationFlows(x.id) { + key := "DestinationPointer" + if af.origin == x.id { + key = "OriginPointer" + } + setPointer(af.doc, key, frag.Entry) + } + return m.removeObject(x) +} + +// Drop removes target and joins the flows that entered it to the object it +// led to. Annotation lines attached to it go with it; the annotations stay. +func (m *Mutator) Drop(target model.ID) error { + g := m.graph() + x, err := g.node(target) + if err != nil { + return err + } + out, err := removable(g, x, "drop") + if err != nil { + return err + } + destIdx, destVec := flowEnd(out.doc, "Destination") + destPtr := dGet(out.doc, "DestinationPointer") + for _, in := range g.incoming(x.id) { + if in.origin == out.dest { + return fmt.Errorf("dropping %s would leave a flow from an object to itself", describeNode(x)) + } + } + for _, in := range g.incoming(x.id) { + setBinary(in.doc, "DestinationPointer", destPtr) + setInt(in.doc, "DestinationConnectionIndex", destIdx) + setVector(in.doc, "DestinationControlVector", destVec) + } + drop := map[string]bool{out.id: true} + for _, af := range g.annotationFlows(x.id) { + drop[af.id] = true + } + m.removeFlows(drop) + return m.removeObject(x) +} + +// removable checks that x can be taken out of the flow and returns the one +// flow that leaves it. +func removable(g *graph, x *node, verb string) (flowRef, error) { + switch x.typ { + case "Microflows$ActionActivity", "Microflows$LoopedActivity": + default: + return flowRef{}, fmt.Errorf("cannot %s %s: only an activity or a loop can be, because a decision or an end event "+ + "changes the shape of the flow", verb, describeNode(x)) + } + normal, errs := g.outgoing(x.id) + if len(errs) > 0 { + return flowRef{}, fmt.Errorf("cannot %s %s: it has an error handler, which would be left with no activity; "+ + "rewrite the flow with create or modify instead", verb, describeNode(x)) + } + if len(normal) != 1 { + return flowRef{}, fmt.Errorf("cannot %s %s: it has %d outgoing flows, not one", verb, describeNode(x), len(normal)) + } + return normal[0], nil +} + +// --------------------------------------------------------------------------- +// Tree edits +// --------------------------------------------------------------------------- + +// addFragment serializes the fragment and appends it: its objects to the top +// level collection (appended, so no stored object changes list position), its +// flows to the Flows list. +func (m *Mutator) addFragment(frag *backend.MicroflowFragment) error { + oc := dDoc(m.doc, "ObjectCollection") + objs := arrayElements(dGet(oc, "Objects")) + for _, obj := range frag.Objects { + d, err := m.deps.SerializeObject(obj) + if err != nil { + return fmt.Errorf("serialize %T: %w", obj, err) + } + if d == nil { + return fmt.Errorf("cannot write a %T into a flow", obj) + } + objs = append(objs, d) + } + if !setArray(oc, "Objects", objs) { + return fmt.Errorf("the object collection has no Objects list") + } + for _, f := range frag.Flows { + if err := m.addFlow(f); err != nil { + return err + } + } + for _, af := range frag.AnnotationFlows { + d, err := m.deps.SerializeAnnotationFlow(af) + if err != nil { + return fmt.Errorf("serialize annotation flow: %w", err) + } + if err := m.appendFlowDoc(d); err != nil { + return err + } + } + return nil +} + +func (m *Mutator) addFlow(f *microflows.SequenceFlow) error { + d, err := m.deps.SerializeSequenceFlow(f) + if err != nil { + return fmt.Errorf("serialize sequence flow: %w", err) + } + return m.appendFlowDoc(d) +} + +func (m *Mutator) appendFlowDoc(d bson.D) error { + if d == nil { + return fmt.Errorf("a flow serialized to nothing") + } + flows := arrayElements(dGet(m.doc, "Flows")) + flows = append(flows, d) + if !setArray(m.doc, "Flows", flows) { + return fmt.Errorf("the unit has no Flows list") + } + return nil +} + +func (m *Mutator) removeFlows(ids map[string]bool) { + var keep []any + for _, el := range arrayElements(dGet(m.doc, "Flows")) { + if d, ok := el.(bson.D); ok && ids[binaryID(dGet(d, "$ID"))] { + m.markRemoved(d) + continue + } + keep = append(keep, el) + } + setArray(m.doc, "Flows", keep) +} + +// removeObject takes x out of the top-level collection. +func (m *Mutator) removeObject(x *node) error { + oc := dDoc(m.doc, "ObjectCollection") + var keep []any + found := false + for _, el := range arrayElements(dGet(oc, "Objects")) { + if d, ok := el.(bson.D); ok && binaryID(dGet(d, "$ID")) == x.id { + m.markRemoved(d) + found = true + continue + } + keep = append(keep, el) + } + if !found { + return fmt.Errorf("%s is not in the top-level collection", describeNode(x)) + } + setArray(oc, "Objects", keep) + return nil +} + +// markRemoved records the $ID of d and of every element nested in it. +func (m *Mutator) markRemoved(v any) { + switch t := v.(type) { + case bson.D: + for _, e := range t { + if e.Key == "$ID" { + if b, ok := e.Value.(primitive.Binary); ok { + m.removed[string(b.Data)] = true + } + continue + } + m.markRemoved(e.Value) + } + case bson.A: + for _, el := range t { + m.markRemoved(el) + } + } +} + +// shift moves every top-level node past the cut (along axis ax, in the +// direction of s) by delta. The sweep keeps everything on either side of the +// cut exactly as it was relative to its neighbours, so only the flows that +// cross the cut get longer; a flow's control vectors are relative to its ends, +// so no curve changes. skip is a node that is about to be removed. +func (m *Mutator) shift(g *graph, ax axis, s int, cut float64, delta int, skip string) { + for _, n := range g.order { + if n.loop != "" || n.id == skip { + continue + } + if float64(s)*(float64(n.pos.get(ax))-cut) <= 0 { + continue + } + n.pos = n.pos.set(ax, n.pos.get(ax)+delta) + dSet(n.doc, "RelativeMiddlePoint", n.pos.String()) + } +} + +// checkIntegrity is the rule-1 guard: no binary anywhere in the unit may +// still name an element that was removed, and no two elements may share an +// $ID. Either would make the document unopenable, and neither is caught by +// anything cheaper than Studio Pro. +func (m *Mutator) checkIntegrity() error { + seen := map[string]bool{} + var dup, dangling string + var walk func(v any, key string) + walk = func(v any, key string) { + switch t := v.(type) { + case bson.D: + for _, e := range t { + walk(e.Value, e.Key) + } + case bson.A: + for _, el := range t { + walk(el, key) + } + case primitive.Binary: + if len(t.Data) != 16 { + return + } + k := string(t.Data) + if key == "$ID" { + if seen[k] && dup == "" { + dup = types.BlobToUUID(t.Data) + } + seen[k] = true + return + } + if m.removed[k] && dangling == "" { + dangling = key + " -> " + types.BlobToUUID(t.Data) + } + } + } + walk(m.doc, "") + if dup != "" { + return fmt.Errorf("refusing to write: two elements would share $ID %s", dup) + } + if dangling != "" { + return fmt.Errorf("refusing to write: a reference still points at a removed element (%s)", dangling) + } + return nil +} + +// --------------------------------------------------------------------------- +// Geometry +// --------------------------------------------------------------------------- + +// minGap is the free space kept on each side of an inserted fragment, the +// edge-to-edge distance mxcli's own layout leaves between activities. +const minGap = 40 + +type axis int + +const ( + axisX axis = iota + axisY +) + +func (a axis) other() axis { return 1 - a } + +type point struct{ X, Y int } + +func (p point) get(a axis) int { + if a == axisX { + return p.X + } + return p.Y +} + +func (p point) set(a axis, v int) point { + if a == axisX { + p.X = v + } else { + p.Y = v + } + return p +} + +func (p point) String() string { return strconv.Itoa(p.X) + ";" + strconv.Itoa(p.Y) } + +func parsePoint(s string) point { + x, y, ok := strings.Cut(s, ";") + if !ok { + return point{} + } + px, _ := strconv.Atoi(strings.TrimSpace(x)) + py, _ := strconv.Atoi(strings.TrimSpace(y)) + return point{px, py} +} + +// flowAxis is the direction a flow from x to y runs: the axis along which the +// centres are further apart, and the sign along it. +func flowAxis(x, y *node) (axis, int, error) { + dx, dy := y.pos.X-x.pos.X, y.pos.Y-x.pos.Y + switch { + case dx == 0 && dy == 0: + return 0, 0, fmt.Errorf("%s and %s are drawn on the same spot; cannot tell which way the flow runs", describeNode(x), describeNode(y)) + case abs(dx) >= abs(dy): + return axisX, sign(dx), nil + default: + return axisY, sign(dy), nil + } +} + +// Connection indexes, as Mendix numbers the sides of a box. +const ( + sideTop = 0 + sideRight = 1 + sideBottom = 2 + sideLeft = 3 +) + +// side is the side of a box that faces direction s along ax. +func side(ax axis, s int) int { + switch { + case ax == axisX && s > 0: + return sideRight + case ax == axisX: + return sideLeft + case s > 0: + return sideBottom + default: + return sideTop + } +} + +// sideVector is the control vector Studio Pro draws a straight flow end with: +// perpendicular to the side, pointing out of the box. +func sideVector(sd int) string { + switch sd { + case sideTop: + return "0;-15" + case sideRight: + return "15;0" + case sideBottom: + return "0;15" + default: + return "-15;0" + } +} + +// fragmentBox is the fragment's layout as the builder produced it, to be +// translated into place. +type fragmentBox struct { + frag *backend.MicroflowFragment + min, max point +} + +func fragmentGeometry(frag *backend.MicroflowFragment) (*fragmentBox, error) { + if frag == nil || len(frag.Objects) == 0 || frag.Entry == "" || frag.Exit == "" { + return nil, fmt.Errorf("the fragment is empty") + } + fb := &fragmentBox{frag: frag} + first := true + for _, obj := range frag.Objects { + p, sz := obj.GetPosition(), objectSize(obj) + lo := point{p.X - sz.X/2, p.Y - sz.Y/2} + hi := point{p.X + sz.X/2, p.Y + sz.Y/2} + if first { + fb.min, fb.max, first = lo, hi, false + continue + } + fb.min = point{min(fb.min.X, lo.X), min(fb.min.Y, lo.Y)} + fb.max = point{max(fb.max.X, hi.X), max(fb.max.Y, hi.Y)} + } + return fb, nil +} + +func (fb *fragmentBox) length(ax axis) int { return fb.max.get(ax) - fb.min.get(ax) } + +// placeCentred translates the fragment so that its box is centred on c along +// ax, and its entry object sits on c across it. +func (fb *fragmentBox) placeCentred(ax axis, c point) { + var entry point + for _, obj := range fb.frag.Objects { + if obj.GetID() == fb.frag.Entry { + entry = point{obj.GetPosition().X, obj.GetPosition().Y} + } + } + d := point{} + d = d.set(ax, c.get(ax)-(fb.min.get(ax)+fb.max.get(ax))/2) + d = d.set(ax.other(), c.get(ax.other())-entry.get(ax.other())) + for _, obj := range fb.frag.Objects { + p := obj.GetPosition() + obj.SetPosition(model.Point{X: p.X + d.X, Y: p.Y + d.Y}) + } + fb.min = point{fb.min.X + d.X, fb.min.Y + d.Y} + fb.max = point{fb.max.X + d.X, fb.max.Y + d.Y} +} + +// checkRoom refuses a placement that would draw the fragment over an object +// that stays where it is. The shift clears the space past the cut; an object +// that already sat inside the gap (a branch drawn below the main line, say) +// is not moved, and drawing over it would hide it in Studio Pro. skip is the +// object a replace removes. +func (fb *fragmentBox) checkRoom(g *graph, skip string) error { + for _, n := range g.order { + if n.loop != "" || n.id == skip { + continue + } + lo := point{n.pos.X - n.size.X/2, n.pos.Y - n.size.Y/2} + hi := point{n.pos.X + n.size.X/2, n.pos.Y + n.size.Y/2} + if lo.X < fb.max.X && fb.min.X < hi.X && lo.Y < fb.max.Y && fb.min.Y < hi.Y { + return fmt.Errorf("there is no free room for the fragment: at (%d, %d)-(%d, %d) it would be drawn over %s; "+ + "move that object aside in Studio Pro first", fb.min.X, fb.min.Y, fb.max.X, fb.max.Y, describeNode(n)) + } + } + return nil +} + +func objectSize(obj microflows.MicroflowObject) point { + if s, ok := obj.(interface{ GetSize() model.Size }); ok { + sz := s.GetSize() + return point{sz.Width, sz.Height} + } + return point{} +} + +// flowEnd reads a flow's connection index and control vector at one end +// ("Origin" or "Destination"). +func flowEnd(d bson.D, end string) (int, string) { + idx := 0 + switch v := dGet(d, end+"ConnectionIndex").(type) { + case int32: + idx = int(v) + case int64: + idx = int(v) + case int: + idx = v + } + vec := "" + if line := dDoc(d, "Line"); line != nil { + vec = dString(line, end+"ControlVector") + } + return idx, vec +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +func sign(v int) int { + if v < 0 { + return -1 + } + return 1 +} + +func describeNode(n *node) string { + name := strings.TrimPrefix(n.typ, "Microflows$") + if c := dString(n.doc, "Caption"); c != "" && name != "ActionActivity" { + name += " '" + c + "'" + } + return fmt.Sprintf("the %s at (%d, %d)", name, n.pos.X, n.pos.Y) +} + +// --------------------------------------------------------------------------- +// bson.D helpers. The unit is decoded with bson v1 into bson.D, whose nested +// documents are bson.D and whose arrays are bson.A with Mendix's leading +// int32 list marker. +// --------------------------------------------------------------------------- + +func dGet(d bson.D, key string) any { + for _, e := range d { + if e.Key == key { + return e.Value + } + } + return nil +} + +func dDoc(d bson.D, key string) bson.D { + v, _ := dGet(d, key).(bson.D) + return v +} + +func dString(d bson.D, key string) string { + v, _ := dGet(d, key).(string) + return v +} + +func dSet(d bson.D, key string, v any) bool { + for i := range d { + if d[i].Key == key { + d[i].Value = v + return true + } + } + return false +} + +// arrayElements returns the elements of a Mendix list, without its marker. +func arrayElements(v any) []any { + a, ok := v.(bson.A) + if !ok || len(a) == 0 { + return nil + } + if _, isMarker := a[0].(int32); isMarker { + return append([]any(nil), a[1:]...) + } + return append([]any(nil), a...) +} + +// setArray replaces a list's elements, keeping its stored marker. +func setArray(d bson.D, key string, elements []any) bool { + a, ok := dGet(d, key).(bson.A) + if !ok { + return false + } + out := bson.A{} + if len(a) > 0 { + if marker, isMarker := a[0].(int32); isMarker { + out = append(out, marker) + } + } + return dSet(d, key, append(out, elements...)) +} + +func binaryID(v any) string { + b, ok := v.(primitive.Binary) + if !ok || len(b.Data) != 16 { + return "" + } + return types.BlobToUUID(b.Data) +} + +// setPointer points key at the element id, keeping the binary subtype the +// stored pointer uses. +func setPointer(d bson.D, key string, id model.ID) { + sub := byte(0) + if b, ok := dGet(d, key).(primitive.Binary); ok { + sub = b.Subtype + } + dSet(d, key, primitive.Binary{Subtype: sub, Data: types.UUIDToBlob(string(id))}) +} + +func setBinary(d bson.D, key string, v any) { + if b, ok := v.(primitive.Binary); ok { + dSet(d, key, primitive.Binary{Subtype: b.Subtype, Data: bytes.Clone(b.Data)}) + } +} + +// setInt writes an integer property with the width it is stored in. +func setInt(d bson.D, key string, v int) { + switch dGet(d, key).(type) { + case int64: + dSet(d, key, int64(v)) + default: + dSet(d, key, int32(v)) + } +} + +// setVector sets a control vector on the flow's line. A flow without a +// Bezier line (a pre-10 document) has nothing to set. +func setVector(d bson.D, key, v string) { + if v == "" { + return + } + if line := dDoc(d, "Line"); line != nil { + dSet(line, key, v) + } +} diff --git a/mdl/backend/mfmutator/splice_test.go b/mdl/backend/mfmutator/splice_test.go new file mode 100644 index 000000000..34bc09358 --- /dev/null +++ b/mdl/backend/mfmutator/splice_test.go @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// These tests run the splice on hand-built documents, for the shapes the +// Studio Pro fixture does not have (loops, error handlers, vertical flows). +// The acceptance test on a Studio Pro-drawn flow is in the executor package +// (cmd_alter_flow_pedapp_test.go). + +// uid returns a deterministic element id for a short name. +func uid(name string) string { + b := make([]byte, 16) + copy(b, name) + return types.BlobToUUID(b) +} + +func bin(name string) primitive.Binary { + return primitive.Binary{Subtype: 0, Data: types.UUIDToBlob(uid(name))} +} + +func obj(name, typ string, x, y int) bson.D { + w, h := 120, 60 + if typ != "Microflows$ActionActivity" && typ != "Microflows$LoopedActivity" { + w, h = 20, 20 + } + return bson.D{ + {Key: "$ID", Value: bin(name)}, + {Key: "$Type", Value: typ}, + {Key: "RelativeMiddlePoint", Value: fmt.Sprintf("%d;%d", x, y)}, + {Key: "Size", Value: fmt.Sprintf("%d;%d", w, h)}, + } +} + +func flow(name, from, to string, fromSide, toSide int32, isErr bool) bson.D { + return bson.D{ + {Key: "$ID", Value: bin(name)}, + {Key: "$Type", Value: "Microflows$SequenceFlow"}, + {Key: "CaseValues", Value: bson.A{int32(2), bson.D{{Key: "$ID", Value: bin(name + "c")}, {Key: "$Type", Value: "Microflows$NoCase"}}}}, + {Key: "DestinationConnectionIndex", Value: toSide}, + {Key: "DestinationPointer", Value: bin(to)}, + {Key: "IsErrorHandler", Value: isErr}, + {Key: "Line", Value: bson.D{ + {Key: "$ID", Value: bin(name + "l")}, + {Key: "$Type", Value: "Microflows$BezierCurve"}, + {Key: "DestinationControlVector", Value: "-30;0"}, + {Key: "OriginControlVector", Value: "30;0"}, + }}, + {Key: "OriginConnectionIndex", Value: fromSide}, + {Key: "OriginPointer", Value: bin(from)}, + } +} + +func unit(objects []bson.D, flows []bson.D) bson.D { + objs := bson.A{int32(3)} + for _, o := range objects { + objs = append(objs, o) + } + fl := bson.A{int32(3)} + for _, f := range flows { + fl = append(fl, f) + } + return bson.D{ + {Key: "$ID", Value: bin("unit")}, + {Key: "$Type", Value: "Microflows$Microflow"}, + {Key: "Flows", Value: fl}, + {Key: "ObjectCollection", Value: bson.D{ + {Key: "$ID", Value: bin("oc")}, + {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, + {Key: "Objects", Value: objs}, + }}, + } +} + +// fakeDeps serializes the way the codec does for the properties the splice +// reads, and records what was saved. +type fakeDeps struct{ saved []byte } + +func (d *fakeDeps) SerializeObject(o microflows.MicroflowObject) (bson.D, error) { + typ := "Microflows$ActionActivity" + if _, ok := o.(*microflows.ExclusiveMerge); ok { + typ = "Microflows$ExclusiveMerge" + } + p := o.GetPosition() + sz := objectSize(o) + return bson.D{ + {Key: "$ID", Value: primitive.Binary{Data: types.UUIDToBlob(string(o.GetID()))}}, + {Key: "$Type", Value: typ}, + {Key: "RelativeMiddlePoint", Value: fmt.Sprintf("%d;%d", p.X, p.Y)}, + {Key: "Size", Value: fmt.Sprintf("%d;%d", sz.X, sz.Y)}, + }, nil +} + +func (d *fakeDeps) SerializeSequenceFlow(f *microflows.SequenceFlow) (bson.D, error) { + return bson.D{ + {Key: "$ID", Value: primitive.Binary{Data: types.UUIDToBlob(string(f.ID))}}, + {Key: "$Type", Value: "Microflows$SequenceFlow"}, + {Key: "DestinationConnectionIndex", Value: int32(f.DestinationConnectionIndex)}, + {Key: "DestinationPointer", Value: primitive.Binary{Data: types.UUIDToBlob(string(f.DestinationID))}}, + {Key: "IsErrorHandler", Value: f.IsErrorHandler}, + {Key: "Line", Value: bson.D{ + {Key: "DestinationControlVector", Value: f.DestinationControlVector}, + {Key: "OriginControlVector", Value: f.OriginControlVector}, + }}, + {Key: "OriginConnectionIndex", Value: int32(f.OriginConnectionIndex)}, + {Key: "OriginPointer", Value: primitive.Binary{Data: types.UUIDToBlob(string(f.OriginID))}}, + }, nil +} + +func (d *fakeDeps) SerializeAnnotationFlow(f *microflows.AnnotationFlow) (bson.D, error) { + return bson.D{ + {Key: "$ID", Value: primitive.Binary{Data: types.UUIDToBlob(string(f.ID))}}, + {Key: "$Type", Value: "Microflows$AnnotationFlow"}, + {Key: "DestinationPointer", Value: primitive.Binary{Data: types.UUIDToBlob(string(f.DestinationID))}}, + {Key: "OriginPointer", Value: primitive.Binary{Data: types.UUIDToBlob(string(f.OriginID))}}, + }, nil +} + +func (d *fakeDeps) SaveUnit(_ string, contents []byte) error { + d.saved = contents + return nil +} + +func newMutator(t *testing.T, doc bson.D) (*Mutator, *fakeDeps) { + t.Helper() + raw, err := bson.Marshal(doc) + if err != nil { + t.Fatal(err) + } + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + deps := &fakeDeps{} + m, err := New(d, "unit", deps) + if err != nil { + t.Fatal(err) + } + return m, deps +} + +// oneActivity is a single-activity fragment in builder coordinates. +func oneActivity() *backend.MicroflowFragment { + id := model.ID(types.GenerateID()) + act := &microflows.ActionActivity{BaseActivity: microflows.BaseActivity{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: id}, + Position: model.Point{X: 360, Y: 200}, + Size: model.Size{Width: 120, Height: 60}, + }}} + return &backend.MicroflowFragment{Objects: []microflows.MicroflowObject{act}, Entry: id, Exit: id} +} + +func line() []bson.D { + return []bson.D{ + obj("start", "Microflows$StartEvent", 100, 200), + obj("a", "Microflows$ActionActivity", 250, 200), + obj("b", "Microflows$ActionActivity", 420, 200), + obj("end", "Microflows$EndEvent", 600, 200), + } +} + +func lineFlows() []bson.D { + return []bson.D{ + flow("f1", "start", "a", 1, 3, false), + flow("f2", "a", "b", 1, 3, false), + flow("f3", "b", "end", 1, 3, false), + } +} + +// The control every splice test leans on: decoding a unit and encoding it +// again with nothing spliced gives back the stored bytes exactly. +func TestSplice_UntouchedUnitRoundTripsExactly(t *testing.T) { + raw, _ := bson.Marshal(unit(line(), lineFlows())) + m, _ := newMutator(t, unit(line(), lineFlows())) + out, err := m.Bytes() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, raw) { + t.Error("an unmodified unit did not round-trip byte for byte") + } +} + +func TestSplice_InsertAfterKeepsTheErrorHandlerFlow(t *testing.T) { + objs := append(line(), obj("handler", "Microflows$ActionActivity", 250, 350)) + flows := append(lineFlows(), flow("err", "a", "handler", 2, 0, true)) + m, _ := newMutator(t, unit(objs, flows)) + errBefore, _ := bson.Marshal(flow("err", "a", "handler", 2, 0, true)) + + frag := oneActivity() + if err := m.InsertAfter(model.ID(uid("a")), frag); err != nil { + t.Fatal(err) + } + g := m.graph() + for _, f := range g.flows { + switch f.id { + case uid("err"): + got, _ := bson.Marshal(f.doc) + if !bytes.Equal(got, errBefore) { + t.Error("the error-handler flow changed") + } + case uid("f2"): + if f.dest != string(frag.Entry) { + t.Error("the normal flow out of a was not rewired to the fragment") + } + } + } +} + +func TestSplice_VerticalFlowUsesTopAndBottom(t *testing.T) { + objs := []bson.D{ + obj("a", "Microflows$ActionActivity", 200, 100), + obj("b", "Microflows$ActionActivity", 200, 200), + obj("below", "Microflows$ActionActivity", 200, 300), + obj("beside", "Microflows$ActionActivity", 500, 100), + } + flows := []bson.D{flow("f", "a", "b", 2, 0, false), flow("g", "b", "below", 2, 0, false)} + m, _ := newMutator(t, unit(objs, flows)) + frag := oneActivity() + if err := m.InsertAfter(model.ID(uid("a")), frag); err != nil { + t.Fatal(err) + } + g := m.graph() + if p := g.nodes[uid("b")].pos; p.X != 200 || p.Y <= 200 { + t.Errorf("b should have moved down, is at %v", p) + } + if p := g.nodes[uid("beside")].pos; p.X != 500 || p.Y != 100 { + t.Errorf("an object above the cut moved to %v", p) + } + for _, f := range g.flows { + if f.id == uid("f") { + if idx, _ := flowEnd(f.doc, "Destination"); idx != sideTop { + t.Errorf("the rewired flow enters the fragment on side %d, want top", idx) + } + } + if f.origin == string(frag.Exit) { + if idx, _ := flowEnd(f.doc, "Origin"); idx != sideBottom { + t.Errorf("the new flow leaves the fragment on side %d, want bottom", idx) + } + } + } +} + +func TestSplice_Refusals(t *testing.T) { + loop := obj("loop", "Microflows$LoopedActivity", 420, 200) + loop = append(loop, bson.E{Key: "ObjectCollection", Value: bson.D{ + {Key: "$ID", Value: bin("loopoc")}, + {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, + {Key: "Objects", Value: bson.A{int32(3), obj("inner", "Microflows$ActionActivity", 100, 60), obj("inner2", "Microflows$ActionActivity", 260, 60)}}, + }}) + withLoop := []bson.D{obj("start", "Microflows$StartEvent", 100, 200), obj("a", "Microflows$ActionActivity", 250, 200), loop} + loopFlows := []bson.D{flow("f1", "start", "a", 1, 3, false), flow("f2", "a", "loop", 1, 3, false), flow("fi", "inner", "inner2", 1, 3, false)} + + twoIn := append(line(), obj("c", "Microflows$ActionActivity", 250, 350)) + twoInFlows := append(lineFlows(), flow("f4", "c", "b", 0, 2, false)) + + withHandler := append(line(), obj("handler", "Microflows$ActionActivity", 250, 350)) + handlerFlows := append(lineFlows(), flow("err", "a", "handler", 2, 0, true)) + + cases := []struct { + name string + objs []bson.D + flows []bson.D + op func(m *Mutator) error + want string + }{ + {"insert inside a loop", withLoop, loopFlows, + func(m *Mutator) error { return m.InsertAfter(model.ID(uid("inner")), oneActivity()) }, "inside a loop"}, + {"drop inside a loop", withLoop, loopFlows, + func(m *Mutator) error { return m.Drop(model.ID(uid("inner"))) }, "inside a loop"}, + {"insert before a join", twoIn, twoInFlows, + func(m *Mutator) error { return m.InsertBefore(model.ID(uid("b")), oneActivity()) }, "2 flows enter"}, + {"insert after the end", line(), lineFlows(), + func(m *Mutator) error { return m.InsertAfter(model.ID(uid("end")), oneActivity()) }, "ends the flow"}, + {"drop an activity with an error handler", withHandler, handlerFlows, + func(m *Mutator) error { return m.Drop(model.ID(uid("a"))) }, "error handler"}, + {"replace an activity with an error handler", withHandler, handlerFlows, + func(m *Mutator) error { return m.Replace(model.ID(uid("a")), oneActivity()) }, "error handler"}, + {"drop the end event", line(), lineFlows(), + func(m *Mutator) error { return m.Drop(model.ID(uid("end"))) }, "cannot drop"}, + {"an empty fragment", line(), lineFlows(), + func(m *Mutator) error { return m.InsertAfter(model.ID(uid("a")), &backend.MicroflowFragment{}) }, "empty"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m, _ := newMutator(t, unit(tc.objs, tc.flows)) + before, _ := bson.Marshal(m.doc) + err := tc.op(m) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want an error containing %q, got %v", tc.want, err) + } + after, _ := bson.Marshal(m.doc) + if !bytes.Equal(before, after) { + t.Error("a refused operation changed the document") + } + }) + } +} + +// CLAUDE.md rule 1: an element is never removed while anything still points +// at it. A pointer the splice does not know about (here, a made-up property +// on another object) is found by value, and the write is refused. +func TestSplice_DropRefusesADanglingReference(t *testing.T) { + objs := line() + objs[3] = append(objs[3], bson.E{Key: "SomePointer", Value: bin("a")}) + m, deps := newMutator(t, unit(objs, lineFlows())) + if err := m.Drop(model.ID(uid("a"))); err != nil { + t.Fatalf("drop: %v", err) + } + err := m.Save() + if err == nil || !strings.Contains(err.Error(), "still points at a removed element") { + t.Fatalf("want the dangling-reference refusal, got %v", err) + } + if deps.saved != nil { + t.Error("a unit with a dangling reference was saved") + } +} + +// Control for the test above: the same drop without the stray pointer saves. +func TestSplice_DropJoinsTheFlows(t *testing.T) { + m, deps := newMutator(t, unit(line(), lineFlows())) + if err := m.Drop(model.ID(uid("a"))); err != nil { + t.Fatalf("drop: %v", err) + } + if err := m.Save(); err != nil { + t.Fatalf("save: %v", err) + } + if bytes.Contains(deps.saved, types.UUIDToBlob(uid("a"))) { + t.Error("the dropped activity's $ID is still in the unit") + } + g := m.graph() + for _, f := range g.flows { + if f.id == uid("f1") && f.dest != uid("b") { + t.Errorf("the flow into the dropped activity now enters %s, want b", f.dest) + } + if f.id == uid("f2") { + t.Error("the flow out of the dropped activity is still there") + } + } +} diff --git a/mdl/backend/microflow_mutation.go b/mdl/backend/microflow_mutation.go new file mode 100644 index 000000000..2737d66c9 --- /dev/null +++ b/mdl/backend/microflow_mutation.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// MicroflowFragment is what an `alter microflow` insert or replace splices in: +// the objects and flows a fragment of MDL builds to, written exactly as +// `create microflow` writes the same statements, and the two objects the +// surrounding flow connects to. Entry is where the flow into the fragment +// ends; Exit is where the flow out of it starts. For a one-activity fragment +// they are the same object. +// +// Positions are the builder's own; the mutator translates the fragment into +// place (plan item 4.2c) before writing it. +type MicroflowFragment struct { + Objects []microflows.MicroflowObject + Flows []*microflows.SequenceFlow + AnnotationFlows []*microflows.AnnotationFlow + Entry, Exit model.ID +} + +// MicroflowMutator splices into one stored microflow or nanoflow (ADR-0012 +// decision 3). Targets are activity IDs, already resolved from their content +// address by mfmutator.Resolve. Every operation edits the stored document in +// place: untouched elements stay byte-identical, and nothing is rebuilt. +// Call Save to persist. +type MicroflowMutator interface { + // InsertAfter splices frag onto the one flow that leaves target. + InsertAfter(target model.ID, frag *MicroflowFragment) error + // InsertBefore splices frag onto the one flow that enters target. + InsertBefore(target model.ID, frag *MicroflowFragment) error + // Replace puts frag in target's place and removes target. + Replace(target model.ID, frag *MicroflowFragment) error + // Drop removes target and joins its incoming flows to its successor. + Drop(target model.ID) error + // Save writes the patched unit. + Save() error +} + +// MicroflowMutationBackend opens a microflow or nanoflow for splicing. +type MicroflowMutationBackend interface { + // OpenMicroflowForMutation loads a Microflows$Microflow or + // Microflows$Nanoflow unit and returns a mutator over its stored form. + OpenMicroflowForMutation(unitID model.ID) (MicroflowMutator, error) +} diff --git a/mdl/backend/mock/backend.go b/mdl/backend/mock/backend.go index b5fa2477a..cef40cf4a 100644 --- a/mdl/backend/mock/backend.go +++ b/mdl/backend/mock/backend.go @@ -341,6 +341,9 @@ type MockBackend struct { // WorkflowMutationBackend OpenWorkflowForMutationFunc func(unitID model.ID) (backend.WorkflowMutator, error) + // MicroflowMutationBackend + OpenMicroflowForMutationFunc func(unitID model.ID) (backend.MicroflowMutator, error) + // WidgetSerializationBackend // WidgetBuilderBackend diff --git a/mdl/backend/mock/mock_mutation.go b/mdl/backend/mock/mock_mutation.go index f9d9f53c6..a0ee642bc 100644 --- a/mdl/backend/mock/mock_mutation.go +++ b/mdl/backend/mock/mock_mutation.go @@ -32,6 +32,17 @@ func (m *MockBackend) OpenWorkflowForMutation(unitID model.ID) (backend.Workflow return nil, fmt.Errorf("MockBackend.OpenWorkflowForMutation not configured") } +// --------------------------------------------------------------------------- +// MicroflowMutationBackend +// --------------------------------------------------------------------------- + +func (m *MockBackend) OpenMicroflowForMutation(unitID model.ID) (backend.MicroflowMutator, error) { + if m.OpenMicroflowForMutationFunc != nil { + return m.OpenMicroflowForMutationFunc(unitID) + } + return nil, fmt.Errorf("MockBackend.OpenMicroflowForMutation not configured") +} + // --------------------------------------------------------------------------- // WidgetSerializationBackend // --------------------------------------------------------------------------- diff --git a/mdl/backend/modelsdk/microflow_mutator_write.go b/mdl/backend/modelsdk/microflow_mutator_write.go new file mode 100644 index 000000000..21f8a2810 --- /dev/null +++ b/mdl/backend/modelsdk/microflow_mutator_write.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "fmt" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/modelsdk/codec" + "github.com/mendixlabs/mxcli/modelsdk/element" + genMf "github.com/mendixlabs/mxcli/modelsdk/gen/microflows" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// OpenMicroflowForMutation loads a microflow or nanoflow unit and returns the +// shared graph splice (mfmutator) over its stored bytes. New objects and flows +// are encoded with the same converters `create microflow` uses, so a fragment +// is written exactly as the same statements would be in a create; the patched +// unit is written through the reconciling writer as a patch. +func (b *Backend) OpenMicroflowForMutation(unitID model.ID) (backend.MicroflowMutator, error) { + if b.writer == nil { + return nil, fmt.Errorf("OpenMicroflowForMutation: not connected for writing") + } + raw, err := b.reader.GetRawUnitBytes(string(unitID)) + if err != nil { + return nil, fmt.Errorf("OpenMicroflowForMutation: load unit: %w", err) + } + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + return nil, fmt.Errorf("OpenMicroflowForMutation: unmarshal: %w", err) + } + return mfmutator.New(d, unitID, codecMicroflowDeps{b: b}) +} + +// codecMicroflowDeps implements mfmutator.Deps for the modelsdk (codec) backend. +type codecMicroflowDeps struct{ b *Backend } + +var _ mfmutator.Deps = codecMicroflowDeps{} + +func (d codecMicroflowDeps) SerializeObject(obj microflows.MicroflowObject) (bson.D, error) { + el := microflowObjectToGen(obj) + if el == nil { + return nil, nil + } + assignFlowObjectIDs(el) + return encodeFlowElementToD(el) +} + +func (d codecMicroflowDeps) SerializeSequenceFlow(f *microflows.SequenceFlow) (bson.D, error) { + el := sequenceFlowToGen(f, d.b.majorVersion()) + assignID(el) + if sf, ok := el.(*genMf.SequenceFlow); ok { + for _, cv := range sf.CaseValuesItems() { + assignID(cv) + } + assignID(sf.Line()) + } + return encodeFlowElementToD(el) +} + +func (d codecMicroflowDeps) SerializeAnnotationFlow(f *microflows.AnnotationFlow) (bson.D, error) { + el := annotationFlowToGen(f, d.b.majorVersion()) + assignID(el) + if af, ok := el.(*genMf.AnnotationFlow); ok { + assignID(af.Line()) + } + return encodeFlowElementToD(el) +} + +// SaveUnit writes the spliced unit as a patch: the reconciling writer elides +// an unchanged unit and guards storage GUIDs as for every write, but does not +// re-pair element $IDs the splice deliberately kept (canon.ContentsOwnElementIDs). +func (d codecMicroflowDeps) SaveUnit(unitID string, contents []byte) error { + return d.b.writer.UpdateRawUnitPatch(unitID, contents) +} + +func encodeFlowElementToD(el element.Element) (bson.D, error) { + raw, err := (&codec.Encoder{}).Encode(el) + if err != nil { + return nil, err + } + var out bson.D + if err := bson.Unmarshal(raw, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/mdl/backend/modelsdk/unimplemented_gen.go b/mdl/backend/modelsdk/unimplemented_gen.go index 232eed4a2..b7b3f4f78 100644 --- a/mdl/backend/modelsdk/unimplemented_gen.go +++ b/mdl/backend/modelsdk/unimplemented_gen.go @@ -891,6 +891,11 @@ func (unimplemented) MoveViewEntitySourceDocument(_ string, _ model.ID, _ string return errUnimplemented("MoveViewEntitySourceDocument") } +func (unimplemented) OpenMicroflowForMutation(_ model.ID) (backend.MicroflowMutator, error) { + var r0 backend.MicroflowMutator + return r0, errUnimplemented("OpenMicroflowForMutation") +} + func (unimplemented) OpenPageForMutation(_ model.ID) (backend.PageMutator, error) { var r0 backend.PageMutator return r0, errUnimplemented("OpenPageForMutation") @@ -1118,6 +1123,10 @@ func (unimplemented) UpdateJsonStructure(_ *types.JsonStructure) error { return errUnimplemented("UpdateJsonStructure") } +func (unimplemented) UpdateLayout(_ *pages.Layout) error { + return errUnimplemented("UpdateLayout") +} + func (unimplemented) UpdateMenuDocument(_ *types.MenuDocument) error { return errUnimplemented("UpdateMenuDocument") } @@ -1224,3 +1233,8 @@ func (unimplemented) WriteJavaScriptSourceFile(_ string, _ string, _ string, _ [ func (unimplemented) WriteJavaSourceFile(_ string, _ string, _ string, _ []*types.JavaActionParameter, _ types.CodeActionReturnType, _ []string, _ string) error { return errUnimplemented("WriteJavaSourceFile") } + +func (unimplemented) WriteViewEntitySourceDocument(_ model.ID, _ string, _ string, _ string, _ string) (model.ID, error) { + var r0 model.ID + return r0, errUnimplemented("WriteViewEntitySourceDocument") +} From ab4e9e2dd59fada4fde4f892702a4ea5d4d37f0f Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 08:49:04 +0000 Subject: [PATCH 13/37] alter microflow/nanoflow: grammar, fragment building and scope checks (#736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alter microflow|nanoflow M.F { insert after|before <target> { … } replace <target> with { … } drop <target>; }. Targets are the #713 content addresses, resolved against the stored flow before any operation runs. Fragments are built with the create-microflow builder, seeded with the flow's variables, and cut out of their start and end events. A fragment variable that clashes with one the flow has, or one it reads that is not declared upstream of the insertion point, is an error; so is dropping an activity whose output is still read. Acceptance on PedApp VAL_Feedback: only the new log, the two flows around it and the shifted positions differ; an empty alter writes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/mxcli/syntax/features_microflow.go | 35 ++ docs/01-project/MDL_QUICK_REFERENCE.md | 2 + mdl/ast/ast_alter_flow.go | 54 ++ mdl/executor/cmd_alter_flow.go | 445 ++++++++++++++++ mdl/executor/cmd_alter_flow_pedapp_test.go | 571 +++++++++++++++++++++ mdl/executor/register_stubs.go | 3 + mdl/executor/registry_test.go | 1 + mdl/executor/stmt_summary.go | 3 + mdl/grammar/MDLParser.g4 | 35 ++ mdl/visitor/visitor_alter.go | 4 + mdl/visitor/visitor_alter_flow.go | 53 ++ mdl/visitor/visitor_alter_flow_test.go | 60 +++ 12 files changed, 1266 insertions(+) create mode 100644 mdl/ast/ast_alter_flow.go create mode 100644 mdl/executor/cmd_alter_flow.go create mode 100644 mdl/executor/cmd_alter_flow_pedapp_test.go create mode 100644 mdl/visitor/visitor_alter_flow.go create mode 100644 mdl/visitor/visitor_alter_flow_test.go diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index bc7dc3a74..118337dc1 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -400,6 +400,41 @@ func init() { Example: "LOG INFO NODE 'OrderService' 'Order created successfully';\nLOG WARNING 'Customer not found';\nLOG ERROR 'Failed to process {1}' WITH (\n {1} = $OrderNumber\n);", }) + Register(SyntaxFeature{ + Path: "microflow.alter", + Summary: "Patch a stored microflow or nanoflow: insert, replace or drop activities in place", + Keywords: []string{ + "alter microflow", "alter nanoflow", "insert after", "insert before", + "replace", "drop activity", "patch microflow", "splice", "handle", + }, + Syntax: "ALTER MICROFLOW|NANOFLOW Module.Name {\n" + + " INSERT AFTER|BEFORE <target> { <statements> }\n" + + " REPLACE <target> WITH { <statements> }\n" + + " DROP <target>;\n" + + "};\n\n" + + "-- <target> addresses one activity by content, as `describe microflow ... with handles` prints it:\n" + + "-- $Var the activity that outputs $Var\n" + + "-- 'Caption' a decision or an activity with a custom caption\n" + + "-- <statement> a statement pattern; * matches any run of tokens\n" + + "-- followed by @n when it matches more than one. Targets are resolved against the\n" + + "-- stored flow before any operation runs; an ambiguous or unknown target is an error.\n" + + "-- Only the new activities, the rewired flows and the objects moved to make room change;\n" + + "-- every other element keeps its $ID, position and curve.\n" + + "-- Refused: insert after a decision, insert before an activity several flows enter,\n" + + "-- drop/replace of a decision or of an activity with an error handler, anything inside\n" + + "-- a loop body, a fragment that returns, and a fragment variable that clashes with one\n" + + "-- the flow has or reads one not declared on the path. Over --mcp only insert is supported.", + Example: "alter microflow FeedbackModule.VAL_Feedback {\n" + + " insert after $IsValidEmail { log info node 'Feedback' 'Email checked'; }\n" + + " replace set $ValidFeedback = false @3 with {\n" + + " set $ValidFeedback = false;\n" + + " log warning node 'Feedback' 'Email rejected';\n" + + " }\n" + + " drop log debug node 'Feedback' *;\n" + + "};", + SeeAlso: []string{"microflow"}, + }) + Register(SyntaxFeature{ Path: "microflow.show-page", Summary: "Open and close pages from microflows", diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 1abf19730..6ef196712 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -483,6 +483,8 @@ rather than updating the first. | Describe microflow | `describe microflow Module.Name;` | Full MDL with activities | | Describe microflow (normalized) | `describe microflow Module.Name normalized;` | Folds crossed branches into one condition instead of flattening them. Opt-in: the output re-executes to an equivalent graph with fewer nodes and a different layout | | Describe microflow (with handles) | `describe microflow Module.Name with handles;` | Prints `-- handle: <target>` above each activity: its content address for `alter microflow` — output `$Var`, `'Caption'`, or a statement pattern with `*` wildcards (anchored at both ends), plus `@n` when several match. Comments only; cannot be combined with `normalized` | +| Insert into a stored microflow | `alter microflow Module.Name { insert after <target> { <statements> } };` | Also `insert before`, and `alter nanoflow`. A graph splice into the stored flow, not a rebuild: only the new activities, the two rewired flows and the objects moved to make room change; every other element keeps its `$ID`, position and curve. `<target>` is a handle from `describe … with handles`, resolved before any operation runs. Refused: after a decision, before an activity several flows enter, inside a loop body, a fragment that returns, a variable the flow already has or one not declared on the path | +| Replace or drop an activity | `alter microflow Module.Name { replace <target> with { <statements> } drop <target>; };` | Flows into the activity are re-pointed at the replacement (or at its successor, for `drop`). Refused for a decision, an end event, an activity with an error handler, and an activity whose output variable is still read. Over `--mcp` only `insert` is supported | | Describe nanoflow | `describe nanoflow Module.Name;` | Full MDL with activities | | Rename microflow | `rename microflow Module.Old to New;` | Updates all references | | Rename nanoflow | `rename nanoflow Module.Old to New;` | Updates all references | diff --git a/mdl/ast/ast_alter_flow.go b/mdl/ast/ast_alter_flow.go new file mode 100644 index 000000000..b6534dbd4 --- /dev/null +++ b/mdl/ast/ast_alter_flow.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package ast + +// ============================================================================ +// ALTER MICROFLOW / ALTER NANOFLOW — a graph splice into the stored flow +// ============================================================================ + +// AlterFlowStmt represents: +// +// alter microflow|nanoflow Module.Name { +// insert after|before <target> { <statements> } +// replace <target> with { <statements> } +// drop <target>; +// } +// +// (ADR-0012 decision 3). Targets are content addresses, resolved against the +// flow as stored before any operation applies. +type AlterFlowStmt struct { + Nanoflow bool + Name QualifiedName + Operations []*AlterFlowOperation +} + +func (s *AlterFlowStmt) isStatement() {} + +// Kind is "microflow" or "nanoflow". +func (s *AlterFlowStmt) Kind() string { + if s.Nanoflow { + return "nanoflow" + } + return "microflow" +} + +// AlterFlowOpKind names an operation of an AlterFlowStmt. +type AlterFlowOpKind string + +const ( + AlterFlowInsertAfter AlterFlowOpKind = "insert after" + AlterFlowInsertBefore AlterFlowOpKind = "insert before" + AlterFlowReplace AlterFlowOpKind = "replace" + AlterFlowDrop AlterFlowOpKind = "drop" +) + +// AlterFlowOperation is one operation of an AlterFlowStmt. +type AlterFlowOperation struct { + Op AlterFlowOpKind + // Target is the content address as written (`$IsValidEmail`, + // `'Email is Valid?'`, `log * node 'Debug' *`, with an optional `@n`). + // mfmutator.ParseTarget reads it. + Target string + // Body is the fragment, for insert and replace. + Body []MicroflowStatement +} diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go new file mode 100644 index 000000000..69f5897a2 --- /dev/null +++ b/mdl/executor/cmd_alter_flow.go @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// execAlterFlow handles `alter microflow|nanoflow Module.Name { … }`: a patch +// of the stored flow (ADR-0012 decision 3), never a rebuild. +// +// Every target is resolved against the flow AS STORED, before any operation +// runs, so an address means what `describe … with handles` showed: an +// ambiguity or a miss refuses the whole statement before anything changes, and +// a later operation cannot address what an earlier one inserted. +func execAlterFlow(ctx *ExecContext, s *ast.AlterFlowStmt) error { + if !ctx.Connected() { + return mdlerrors.NewNotConnected() + } + if !ctx.ConnectedForWrite() { + return mdlerrors.NewNotConnectedWrite() + } + a, err := loadAlterFlow(ctx, s) + if err != nil { + return err + } + + targets := make([]mfmutator.Candidate, len(s.Operations)) + for i, op := range s.Operations { + c, err := mfmutator.ResolveText(a.cands, op.Target) + if err != nil { + return mdlerrors.NewValidation(fmt.Sprintf("alter %s %s: %s %s: %v", s.Kind(), s.Name, op.Op, op.Target, err)) + } + targets[i] = c + } + + mut, err := ctx.Backend.OpenMicroflowForMutation(a.mf.ID) + if err != nil { + return mdlerrors.NewBackend("open "+s.Kind()+" for alter", err) + } + for i, op := range s.Operations { + target := targets[i] + fail := func(err error) error { + return mdlerrors.NewValidation(fmt.Sprintf("alter %s %s: %s %s: %v", s.Kind(), s.Name, op.Op, op.Target, err)) + } + if op.Op == ast.AlterFlowDrop { + if err := a.checkOutputUnused(target, nil); err != nil { + return fail(err) + } + if err := mut.Drop(target.ID); err != nil { + return fail(err) + } + continue + } + frag, err := a.buildFragment(ctx, op.Body) + if err != nil { + return fail(err) + } + if err := a.checkFragmentScope(ctx, op, target, frag); err != nil { + return fail(err) + } + switch op.Op { + case ast.AlterFlowInsertAfter: + err = mut.InsertAfter(target.ID, frag) + case ast.AlterFlowInsertBefore: + err = mut.InsertBefore(target.ID, frag) + case ast.AlterFlowReplace: + if err = a.checkOutputUnused(target, frag); err == nil { + err = mut.Replace(target.ID, frag) + } + default: + err = fmt.Errorf("unknown operation") + } + if err != nil { + return fail(err) + } + } + if err := mut.Save(); err != nil { + return mdlerrors.NewBackend("save altered "+s.Kind(), err) + } + fmt.Fprintf(ctx.Output, "Altered %s %s\n", s.Kind(), s.Name) + return nil +} + +// alterFlowContext is what the operations of one statement share: the stored +// flow (a nanoflow wrapped as a microflow, the way describe renders one), its +// addressable activities, and the name maps rendering needs. +type alterFlowContext struct { + stmt *ast.AlterFlowStmt + mf *microflows.Microflow + cands []mfmutator.Candidate + entityNames map[model.ID]string + microflowNames map[model.ID]string +} + +func loadAlterFlow(ctx *ExecContext, s *ast.AlterFlowStmt) (*alterFlowContext, error) { + h, err := getHierarchy(ctx) + if err != nil { + return nil, mdlerrors.NewBackend("build hierarchy", err) + } + a := &alterFlowContext{stmt: s, entityNames: getEntityNames(ctx, h)} + // A copy: nanoflow names are added below, and the cached map is shared. + a.microflowNames = map[model.ID]string{} + for id, n := range getMicroflowNames(ctx, h) { + a.microflowNames[id] = n + } + inModule := func(container model.ID, name string) bool { + return h.GetModuleName(h.FindModuleID(container)) == s.Name.Module && name == s.Name.Name + } + if s.Nanoflow { + nfs, err := ctx.Backend.ListNanoflows() + if err != nil { + return nil, mdlerrors.NewBackend("list nanoflows", err) + } + for _, nf := range nfs { + a.microflowNames[nf.ID] = h.GetQualifiedName(nf.ContainerID, nf.Name) + } + nf, ok := pickLive(nfs, + func(nf *microflows.Nanoflow) bool { return inModule(nf.ContainerID, nf.Name) }, + func(nf *microflows.Nanoflow) bool { return nf.Excluded }) + if !ok { + return nil, mdlerrors.NewNotFound("nanoflow", s.Name.String()) + } + a.mf = &microflows.Microflow{ + BaseElement: nf.BaseElement, + ContainerID: nf.ContainerID, + Name: nf.Name, + Parameters: nf.Parameters, + ReturnType: nf.ReturnType, + ReturnVariableName: nf.ReturnVariableName, + ObjectCollection: nf.ObjectCollection, + } + } else { + mfs, err := ctx.Backend.ListMicroflows() + if err != nil { + return nil, mdlerrors.NewBackend("list microflows", err) + } + mf, ok := pickLive(mfs, + func(mf *microflows.Microflow) bool { return inModule(mf.ContainerID, mf.Name) }, + func(mf *microflows.Microflow) bool { return mf.Excluded }) + if !ok { + return nil, mdlerrors.NewNotFound("microflow", s.Name.String()) + } + a.mf = mf + } + if a.mf.ObjectCollection == nil { + return nil, mdlerrors.NewValidation(fmt.Sprintf("%s %s has no flow to alter", s.Kind(), s.Name)) + } + a.cands, _, _, _ = microflowTargets(ctx, a.mf, a.entityNames, a.microflowNames) + return a, nil +} + +// buildFragment builds a fragment's statements with the builder `create +// microflow` uses, seeded with the variables the stored flow declares, and +// cuts it out of the start and end events the builder wraps it in. +func (a *alterFlowContext) buildFragment(ctx *ExecContext, body []ast.MicroflowStatement) (*backend.MicroflowFragment, error) { + if len(body) == 0 { + return nil, fmt.Errorf("the fragment is empty; use drop to remove an activity") + } + varTypes, declared := a.storedVariables(ctx) + hierarchy, _ := getHierarchy(ctx) + restServices, _ := loadRestServices(ctx) + fb := &flowBuilder{ + textLang: authoringLanguage(ctx), + posX: 200, + posY: 200, + baseY: 200, + spacing: HorizontalSpacing, + varTypes: varTypes, + declaredVars: declared, + measurer: &layoutMeasurer{varTypes: varTypes}, + backend: ctx.Backend, + hierarchy: hierarchy, + restServices: restServices, + isNanoflow: a.stmt.Nanoflow, + } + oc := fb.buildFlowGraph(body, nil) + if errs := fb.GetErrors(); len(errs) > 0 { + return nil, fmt.Errorf("the fragment has errors:\n - %s", strings.Join(errs, "\n - ")) + } + if fb.endsWithReturn { + return nil, fmt.Errorf("the fragment ends the flow with a return, so nothing would lead on to the rest of it; " + + "a return inside an inserted fragment is not supported yet") + } + return cutFragment(oc) +} + +// cutFragment removes the builder's start event and final end event, and says +// where the fragment is entered and left. Several paths reaching the end (an +// if without a merge before it, an error handler that rejoins at the end) are +// joined by a merge, which becomes the exit. +func cutFragment(oc *microflows.MicroflowObjectCollection) (*backend.MicroflowFragment, error) { + var start, end microflows.MicroflowObject + ends := 0 + for _, obj := range oc.Objects { + switch obj.(type) { + case *microflows.StartEvent: + start = obj + case *microflows.EndEvent: + ends++ + end = obj + } + } + if start == nil || end == nil { + return nil, fmt.Errorf("the fragment does not continue: its last statement ends the flow, so nothing would lead on to the rest of it") + } + if ends > 1 { + return nil, fmt.Errorf("the fragment returns; a return inside an inserted fragment is not supported yet") + } + frag := &backend.MicroflowFragment{} + var intoEnd []*microflows.SequenceFlow + for _, f := range oc.Flows { + switch { + case f.OriginID == start.GetID(): + if frag.Entry != "" { + return nil, fmt.Errorf("the fragment starts with more than one flow") + } + frag.Entry = f.DestinationID + case f.DestinationID == end.GetID(): + intoEnd = append(intoEnd, f) + default: + frag.Flows = append(frag.Flows, f) + } + } + for _, obj := range oc.Objects { + if obj != start && obj != end { + frag.Objects = append(frag.Objects, obj) + } + } + frag.AnnotationFlows = oc.AnnotationFlows + switch { + case frag.Entry == "" || frag.Entry == end.GetID() || len(frag.Objects) == 0: + return nil, fmt.Errorf("the fragment builds no activity") + case len(intoEnd) == 0: + return nil, fmt.Errorf("no path through the fragment leads on to the rest of the flow") + case len(intoEnd) == 1: + frag.Exit = intoEnd[0].OriginID + default: + p := end.GetPosition() + merge := &microflows.ExclusiveMerge{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: p, + Size: model.Size{Width: MergeSize, Height: MergeSize}, + }} + for _, f := range intoEnd { + f.DestinationID = merge.ID + frag.Flows = append(frag.Flows, f) + } + frag.Objects = append(frag.Objects, merge) + frag.Exit = merge.ID + } + return frag, nil +} + +// storedVariables returns the variables the stored flow declares, in the two +// maps the builder keeps: entity-typed ones with their entity (a change or a +// member access resolves attributes through it), and the rest as declared. +func (a *alterFlowContext) storedVariables(ctx *ExecContext) (varTypes, declared map[string]string) { + varTypes, declared = map[string]string{}, map[string]string{} + add := func(name string, dt microflows.DataType) { + if name == "" { + return + } + t := "Unknown" + if dt != nil { + t = formatMicroflowDataType(ctx, dt, a.entityNames) + } + switch dt.(type) { + case *microflows.ObjectType, *microflows.ListType: + varTypes[name] = t + default: + declared[name] = t + } + } + for _, p := range a.mf.Parameters { + add(p.Name, p.Type) + } + for _, c := range a.cands { + act, ok := c.Object.(*microflows.ActionActivity) + if !ok || c.OutputVariable == "" { + continue + } + switch x := act.Action.(type) { + case *microflows.CreateVariableAction: + add(c.OutputVariable, x.DataType) + case *microflows.CreateObjectAction: + if x.EntityQualifiedName != "" { + varTypes[c.OutputVariable] = x.EntityQualifiedName + } else if n, ok := a.entityNames[x.EntityID]; ok { + varTypes[c.OutputVariable] = n + } else { + declared[c.OutputVariable] = "Object" + } + default: + declared[c.OutputVariable] = "Unknown" + } + } + return varTypes, declared +} + +// systemVariables are in scope everywhere they exist at all; the platform +// reports a misuse (a $latestError outside an error handler) itself. +var systemVariables = map[string]bool{ + "currentUser": true, "currentSession": true, "currentObject": true, "currentDeviceType": true, + "latestError": true, "latestHttpResponse": true, "latestSoapFault": true, +} + +var variableRef = regexp.MustCompile(`\$([A-Za-z_][A-Za-z0-9_]*)`) + +// checkFragmentScope is plan item 4.2d: the fragment is checked in the scope +// of its insertion point. A variable it declares that the flow already has is +// an error (it would shadow or clash with the stored one); a variable it uses +// that is not declared upstream of where it goes, nor by the fragment itself, +// is an error too, since the fragment would read something that does not exist +// yet on that path. +func (a *alterFlowContext) checkFragmentScope(ctx *ExecContext, op *ast.AlterFlowOperation, target mfmutator.Candidate, frag *backend.MicroflowFragment) error { + existing := map[string]bool{} + for _, p := range a.mf.Parameters { + existing[p.Name] = true + } + for _, c := range a.cands { + if c.OutputVariable != "" { + existing[c.OutputVariable] = true + } + } + own := map[string]bool{} + for _, obj := range frag.Objects { + act, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + v := mfmutator.OutputVariable(act.Action) + if v == "" { + continue + } + replacingSame := op.Op == ast.AlterFlowReplace && v == target.OutputVariable + if existing[v] && !replacingSame { + return fmt.Errorf("the fragment declares $%s, which the %s already has; choose another name", v, a.stmt.Kind()) + } + own[v] = true + } + + inScope := map[string]bool{} + for _, p := range a.mf.Parameters { + inScope[p.Name] = true + } + for id := range a.upstreamOf(target.ID, op.Op == ast.AlterFlowInsertAfter) { + for _, c := range a.cands { + if c.ID == id && c.OutputVariable != "" { + inScope[c.OutputVariable] = true + } + } + } + var missing []string + seen := map[string]bool{} + for _, obj := range frag.Objects { + for _, m := range variableRef.FindAllStringSubmatch(formatActivity(ctx, obj, a.entityNames, a.microflowNames), -1) { + v := m[1] + if seen[v] || systemVariables[v] || inScope[v] || own[v] { + continue + } + seen[v] = true + missing = append(missing, "$"+v) + } + } + if len(missing) > 0 { + sort.Strings(missing) + where := "before " + op.Target + if op.Op == ast.AlterFlowInsertAfter { + where = "after " + op.Target + } + return fmt.Errorf("the fragment uses %s, which is not declared on the path %s", strings.Join(missing, ", "), where) + } + return nil +} + +// upstreamOf returns every object from which id can be reached along the +// stored flows — the activities whose outputs exist when the flow gets there. +// id itself is included only when including says so (an insert after it runs +// once it has). +func (a *alterFlowContext) upstreamOf(id model.ID, including bool) map[model.ID]bool { + preds := map[model.ID][]model.ID{} + for _, f := range a.mf.ObjectCollection.Flows { + preds[f.DestinationID] = append(preds[f.DestinationID], f.OriginID) + } + out := map[model.ID]bool{} + queue := append([]model.ID(nil), preds[id]...) + for len(queue) > 0 { + n := queue[0] + queue = queue[1:] + if out[n] { + continue + } + out[n] = true + queue = append(queue, preds[n]...) + } + if including { + out[id] = true + } + return out +} + +// checkOutputUnused refuses to take away an activity whose output variable +// another activity still reads — unless the replacement declares it again. +func (a *alterFlowContext) checkOutputUnused(target mfmutator.Candidate, replacement *backend.MicroflowFragment) error { + v := target.OutputVariable + if v == "" { + return nil + } + if replacement != nil { + for _, obj := range replacement.Objects { + if act, ok := obj.(*microflows.ActionActivity); ok && mfmutator.OutputVariable(act.Action) == v { + return nil + } + } + } + ref := regexp.MustCompile(`\$` + regexp.QuoteMeta(v) + `\b`) + var users []string + for _, c := range a.cands { + if c.ID == target.ID { + continue + } + for _, text := range append([]string{c.Statement}, c.Alternates...) { + if ref.MatchString(text) { + users = append(users, c.Statement) + break + } + } + } + if len(users) > 0 { + return fmt.Errorf("$%s is still used by: %s", v, strings.Join(users, "; ")) + } + return nil +} diff --git a/mdl/executor/cmd_alter_flow_pedapp_test.go b/mdl/executor/cmd_alter_flow_pedapp_test.go new file mode 100644 index 000000000..fc14786ea --- /dev/null +++ b/mdl/executor/cmd_alter_flow_pedapp_test.go @@ -0,0 +1,571 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// The acceptance test of plan item 4.2 (ako/mxcli#736) runs on the Studio +// Pro-authored PedApp fixture, because only a flow Studio Pro drew can show +// what a rebuild loses: its merges, its curves, its object order, its $IDs. + +// openPedAppFixture opens a private copy of testdata/pedapp. +func openPedAppFixture(t *testing.T) (*Executor, *bytes.Buffer) { + t.Helper() + src := filepath.Join("..", "..", "testdata", "pedapp") + if _, err := os.Stat(filepath.Join(src, "PedApp.mpr")); err != nil { + t.Skipf("PedApp fixture not found: %v", err) + } + dir := t.TempDir() + if err := copyPedAppFile(filepath.Join(src, "PedApp.mpr"), filepath.Join(dir, "PedApp.mpr")); err != nil { + t.Fatal(err) + } + if err := copyPedAppTree(filepath.Join(src, "mprcontents"), filepath.Join(dir, "mprcontents")); err != nil { + t.Fatal(err) + } + out := &bytes.Buffer{} + exec := New(out) + exec.SetBackendFactory(func() backend.FullBackend { return modelsdkbackend.New() }) + if err := exec.Execute(&ast.ConnectStmt{Path: filepath.Join(dir, "PedApp.mpr")}); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = exec.Execute(&ast.DisconnectStmt{}) }) + return exec, out +} + +func afRun(t *testing.T, exec *Executor, src string) error { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse %q: %v", src, errs[0]) + } + for _, s := range prog.Statements { + if err := exec.Execute(s); err != nil { + return err + } + } + return nil +} + +// valFeedbackUnit returns VAL_Feedback's unit ID and stored bytes. +func valFeedbackUnit(t *testing.T, exec *Executor) (model.ID, []byte) { + t.Helper() + ctx := exec.newExecContext(context.Background()) + h, err := getHierarchy(ctx) + if err != nil { + t.Fatal(err) + } + all, err := ctx.Backend.ListMicroflows() + if err != nil { + t.Fatal(err) + } + for _, m := range all { + if m.Name == "VAL_Feedback" && h.GetModuleName(h.FindModuleID(m.ContainerID)) == "FeedbackModule" { + raw, err := ctx.Backend.GetRawUnitBytes(m.ID) + if err != nil { + t.Fatal(err) + } + return m.ID, append([]byte(nil), raw...) + } + } + t.Fatal("FeedbackModule.VAL_Feedback not found") + return "", nil +} + +// flowView indexes a stored flow's top-level objects and flows by $ID. +type flowView struct { + doc bson.D + objs map[string]bson.D + flows map[string]bson.D + order []string // object ids in storage order +} + +func parseFlowView(t *testing.T, raw []byte) flowView { + t.Helper() + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + v := flowView{doc: d, objs: map[string]bson.D{}, flows: map[string]bson.D{}} + oc, _ := afGet(d, "ObjectCollection").(bson.D) + for _, el := range afList(afGet(oc, "Objects")) { + o := el.(bson.D) + id := afIDOf(afGet(o, "$ID")) + v.objs[id] = o + v.order = append(v.order, id) + } + for _, el := range afList(afGet(d, "Flows")) { + f := el.(bson.D) + v.flows[afIDOf(afGet(f, "$ID"))] = f + } + return v +} + +func afGet(d bson.D, k string) any { + for _, e := range d { + if e.Key == k { + return e.Value + } + } + return nil +} + +func afList(v any) []any { + a, _ := v.(bson.A) + if len(a) > 0 { + if _, ok := a[0].(int32); ok { + return a[1:] + } + } + return a +} + +func afIDOf(v any) string { + b, _ := v.(primitive.Binary) + return types.BlobToUUID(b.Data) +} + +func afMarshal(t *testing.T, v any) []byte { + t.Helper() + b, err := bson.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +// without returns d minus the named keys, for comparing the rest. +func afWithout(d bson.D, keys ...string) bson.D { + var out bson.D + for _, e := range d { + skip := false + for _, k := range keys { + skip = skip || e.Key == k + } + if !skip { + out = append(out, e) + } + } + return out +} + +// changedKeys lists the keys whose values differ between two elements (one +// level deep, plus the Line's vectors, which is where a flow keeps its curve). +func afChangedKeys(t *testing.T, a, b bson.D) []string { + t.Helper() + var out []string + keys := map[string]bool{} + for _, e := range a { + keys[e.Key] = true + } + for _, e := range b { + keys[e.Key] = true + } + for k := range keys { + av, bv := afGet(a, k), afGet(b, k) + if k == "Line" { + al, _ := av.(bson.D) + bl, _ := bv.(bson.D) + for _, lk := range []string{"OriginControlVector", "DestinationControlVector"} { + if fmt.Sprint(afGet(al, lk)) != fmt.Sprint(afGet(bl, lk)) { + out = append(out, "Line."+lk) + } + } + if !bytes.Equal(afMarshal(t, afWithout(al, "OriginControlVector", "DestinationControlVector")), + afMarshal(t, afWithout(bl, "OriginControlVector", "DestinationControlVector"))) { + out = append(out, "Line") + } + continue + } + if !bytes.Equal(afMarshal(t, bson.D{{Key: "v", Value: av}}), afMarshal(t, bson.D{{Key: "v", Value: bv}})) { + out = append(out, k) + } + } + return afSort(out) +} + +func afSort(s []string) []string { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } + return s +} + +func afPoint(d bson.D) (int, int) { + s, _ := afGet(d, "RelativeMiddlePoint").(string) + x, y, _ := strings.Cut(s, ";") + px, _ := strconv.Atoi(x) + py, _ := strconv.Atoi(y) + return px, py +} + +func afSize(d bson.D) (int, int) { + s, _ := afGet(d, "Size").(string) + x, y, _ := strings.Cut(s, ";") + px, _ := strconv.Atoi(x) + py, _ := strconv.Atoi(y) + return px, py +} + +// objectAt returns the id of the stored top-level object at (x, y). +func (v flowView) objectAt(t *testing.T, x, y int, typ string) string { + t.Helper() + for _, id := range v.order { + o := v.objs[id] + if px, py := afPoint(o); px == x && py == y && afGet(o, "$Type") == typ { + return id + } + } + t.Fatalf("no %s at (%d, %d)", typ, x, y) + return "" +} + +// The acceptance test of plan item 4.2 (ako/mxcli#736): one `log` inserted +// after $IsValidEmail in the Studio Pro-drawn VAL_Feedback. Only the new +// activity, the two flows around it and the positions moved to make room may +// differ; every other element — its $ID, its curve, every merge — must come +// through byte-identical. +func TestAlterMicroflow_PedApp_InsertAfterChangesOnlyTheSplice(t *testing.T) { + exec, _ := openPedAppFixture(t) + _, raw := valFeedbackUnit(t, exec) + before := parseFlowView(t, raw) + javaCall := before.objectAt(t, 980, 200, "Microflows$ActionActivity") + split := before.objectAt(t, 1155, 200, "Microflows$ExclusiveSplit") + + script := "alter microflow FeedbackModule.VAL_Feedback {\n" + + " insert after $IsValidEmail { log info node 'Feedback' 'Email checked'; }\n" + + "};" + if n := strings.Count(script, "\n") + 1; n > 5 { + t.Fatalf("the acceptance script is %d lines; the plan allows 5", n) + } + if err := afRun(t, exec, script); err != nil { + t.Fatalf("alter: %v", err) + } + _, rawAfter := valFeedbackUnit(t, exec) + after := parseFlowView(t, rawAfter) + + // The document around the flow is untouched. + if !bytes.Equal(afMarshal(t, afWithout(before.doc, "ObjectCollection", "Flows")), + afMarshal(t, afWithout(after.doc, "ObjectCollection", "Flows"))) { + t.Error("a property of the microflow document itself changed") + } + ocBefore, _ := afGet(before.doc, "ObjectCollection").(bson.D) + ocAfter, _ := afGet(after.doc, "ObjectCollection").(bson.D) + if !bytes.Equal(afMarshal(t, afWithout(ocBefore, "Objects")), afMarshal(t, afWithout(ocAfter, "Objects"))) { + t.Error("a property of the object collection changed") + } + + // Objects: every stored one survives with its $ID and in its place in the + // list; exactly one is new, a log activity; the rest differ at most in + // position, and only by the shift that made room. + for i, id := range before.order { + if after.order[i] != id { + t.Fatalf("stored object %d moved in the list: %s became %s", i, id, after.order[i]) + } + } + if got := len(after.order) - len(before.order); got != 1 { + t.Fatalf("want exactly one new object, got %d", got) + } + newID := after.order[len(after.order)-1] + newObj := after.objs[newID] + if action, _ := afGet(newObj, "Action").(bson.D); afGet(newObj, "$Type") != "Microflows$ActionActivity" || + afGet(action, "$Type") != "Microflows$LogMessageAction" { + t.Fatalf("the new object is not a log activity: %v", afGet(newObj, "$Type")) + } + shifted := 0 + javaX, _ := afPoint(before.objs[javaCall]) + for _, id := range before.order { + b, a := before.objs[id], after.objs[id] + changed := afChangedKeys(t, b, a) + if len(changed) == 0 { + continue + } + if len(changed) != 1 || changed[0] != "RelativeMiddlePoint" { + t.Errorf("object %s (%v) changed more than its position: %v", id, afGet(b, "$Type"), changed) + continue + } + bx, by := afPoint(b) + ax, ay := afPoint(a) + if ay != by || ax <= bx || bx <= javaX { + t.Errorf("object %s moved from (%d,%d) to (%d,%d): only objects past the insertion point may move, and only along the flow", + id, bx, by, ax, ay) + } + shifted++ + } + if shifted == 0 { + t.Error("nothing was shifted, yet the gap after $IsValidEmail is too narrow for an activity: placement did not run") + } + // The new activity overlaps nothing. + nx, ny := afPoint(newObj) + nw, nh := afSize(newObj) + for _, id := range after.order { + if id == newID { + continue + } + o := after.objs[id] + ox, oy := afPoint(o) + ow, oh := afSize(o) + if abs(nx-ox)*2 < nw+ow && abs(ny-oy)*2 < nh+oh { + t.Errorf("the new activity at (%d,%d) overlaps %v at (%d,%d)", nx, ny, afGet(o, "$Type"), ox, oy) + } + } + + // Flows: every stored flow survives; one is new (log -> split); one is + // rewired (java call -> log), and only at its destination end. + var added []string + for id := range after.flows { + if _, ok := before.flows[id]; !ok { + added = append(added, id) + } + } + if len(added) != 1 { + t.Fatalf("want exactly one new flow, got %d", len(added)) + } + nf := after.flows[added[0]] + if afIDOf(afGet(nf, "OriginPointer")) != newID || afIDOf(afGet(nf, "DestinationPointer")) != split { + t.Error("the new flow does not run from the log to 'Email is Valid?'") + } + rewired := 0 + for id, b := range before.flows { + a, ok := after.flows[id] + if !ok { + t.Errorf("stored flow %s is gone", id) + continue + } + changed := afChangedKeys(t, b, a) + if len(changed) == 0 { + continue + } + rewired++ + if afIDOf(afGet(b, "OriginPointer")) != javaCall || afIDOf(afGet(a, "DestinationPointer")) != newID { + t.Errorf("flow %s changed but is not the flow out of $IsValidEmail: %v", id, changed) + } + for _, k := range changed { + switch k { + case "DestinationPointer", "DestinationConnectionIndex", "Line.DestinationControlVector": + default: + t.Errorf("the rewired flow changed %s; only its destination end may change", k) + } + } + } + if rewired != 1 { + t.Errorf("want exactly one rewired flow, got %d", rewired) + } + // The new flow ends where the rewired one used to. + var oldIn bson.D + for id, b := range before.flows { + if afIDOf(afGet(b, "OriginPointer")) == javaCall { + oldIn = b + _ = id + } + } + if fmt.Sprint(afGet(nf, "DestinationConnectionIndex")) != fmt.Sprint(afGet(oldIn, "DestinationConnectionIndex")) { + t.Error("the new flow does not enter 'Email is Valid?' on the side the old flow did") + } + + // And the result reads back: describe shows the log between the two. + var buf bytes.Buffer + exec.output = &buf + if err := afRun(t, exec, "describe microflow FeedbackModule.VAL_Feedback;"); err != nil { + t.Fatal(err) + } + body := buf.String() + iCall := strings.Index(body, "$IsValidEmail = call java action") + iLog := strings.Index(body, "log info node 'Feedback' 'Email checked';") + iSplit := strings.Index(body, "@caption 'Email is Valid?'") + if iCall < 0 || iLog < iCall || iSplit < iLog { + t.Errorf("describe does not show the log between the call and the decision:\n%s", body) + } +} + +// The control: an alter with no operations reads the unit, patches nothing +// and writes nothing — the stored bytes survive the decode/encode round trip +// exactly, so any difference the test above sees is the splice's. +func TestAlterMicroflow_PedApp_EmptyAlterChangesNothing(t *testing.T) { + exec, _ := openPedAppFixture(t) + _, raw := valFeedbackUnit(t, exec) + if err := afRun(t, exec, "alter microflow FeedbackModule.VAL_Feedback { };"); err != nil { + t.Fatalf("alter: %v", err) + } + _, rawAfter := valFeedbackUnit(t, exec) + if !bytes.Equal(raw, rawAfter) { + t.Error("an empty alter changed the stored unit") + } +} + +// Drop joins the flow into the dropped activity to the one after it, and +// takes away only the activity and the flow that left it. +func TestAlterMicroflow_PedApp_Drop(t *testing.T) { + exec, _ := openPedAppFixture(t) + _, raw := valFeedbackUnit(t, exec) + before := parseFlowView(t, raw) + target := before.objectAt(t, 1305, 460, "Microflows$ActionActivity") + mergeAfter := before.objectAt(t, 1460, 460, "Microflows$ExclusiveMerge") + + if err := afRun(t, exec, "alter microflow FeedbackModule.VAL_Feedback { drop set $ValidFeedback = false @3; };"); err != nil { + t.Fatalf("alter: %v", err) + } + _, rawAfter := valFeedbackUnit(t, exec) + after := parseFlowView(t, rawAfter) + + if _, ok := after.objs[target]; ok { + t.Fatal("the dropped activity is still there") + } + if len(after.objs) != len(before.objs)-1 || len(after.flows) != len(before.flows)-1 { + t.Fatalf("want one object and one flow fewer, got %d->%d objects, %d->%d flows", + len(before.objs), len(after.objs), len(before.flows), len(after.flows)) + } + for id, b := range before.objs { + if id == target { + continue + } + if !bytes.Equal(afMarshal(t, b), afMarshal(t, after.objs[id])) { + t.Errorf("object %s changed", id) + } + } + rewired := 0 + for id, b := range before.flows { + a, ok := after.flows[id] + if !ok { + if afIDOf(afGet(b, "OriginPointer")) != target { + t.Errorf("flow %s is gone but did not leave the dropped activity", id) + } + continue + } + if changed := afChangedKeys(t, b, a); len(changed) > 0 { + rewired++ + if afIDOf(afGet(b, "DestinationPointer")) != target || afIDOf(afGet(a, "DestinationPointer")) != mergeAfter { + t.Errorf("flow %s changed but is not the flow into the dropped activity: %v", id, changed) + } + } + } + if rewired != 1 { + t.Errorf("want one rewired flow, got %d", rewired) + } + if bytes.Contains(rawAfter, uuidBytes(target)) { + t.Error("the unit still contains the dropped activity's $ID") + } +} + +func uuidBytes(id string) []byte { return types.UUIDToBlob(id) } + +// Replace puts a two-activity fragment where one activity was: the flows in +// and out are re-pointed, everything past it moves along to make room. +func TestAlterMicroflow_PedApp_Replace(t *testing.T) { + exec, _ := openPedAppFixture(t) + _, raw := valFeedbackUnit(t, exec) + before := parseFlowView(t, raw) + target := before.objectAt(t, 1305, 460, "Microflows$ActionActivity") + + err := afRun(t, exec, `alter microflow FeedbackModule.VAL_Feedback { + replace set $ValidFeedback = false @3 with { + set $ValidFeedback = false; + log warning node 'Feedback' 'Email rejected'; + } + };`) + if err != nil { + t.Fatalf("alter: %v", err) + } + _, rawAfter := valFeedbackUnit(t, exec) + after := parseFlowView(t, rawAfter) + if _, ok := after.objs[target]; ok { + t.Fatal("the replaced activity is still there") + } + if got := len(after.objs) - len(before.objs); got != 1 { + t.Errorf("want one object more (two in, one out), got %+d", got) + } + if got := len(after.flows) - len(before.flows); got != 1 { + t.Errorf("want one flow more (the fragment's own), got %+d", got) + } + for id := range before.flows { + if _, ok := after.flows[id]; !ok { + t.Errorf("stored flow %s is gone; replace keeps the flows in and out", id) + } + } + if bytes.Contains(rawAfter, uuidBytes(target)) { + t.Error("the unit still contains the replaced activity's $ID") + } + var buf bytes.Buffer + exec.output = &buf + if err := afRun(t, exec, "describe microflow FeedbackModule.VAL_Feedback;"); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "log warning node 'Feedback' 'Email rejected';") { + t.Errorf("describe does not show the replacement:\n%s", buf.String()) + } +} + +// What the splice cannot do safely, it refuses — before writing anything. +func TestAlterMicroflow_PedApp_Refusals(t *testing.T) { + cases := []struct{ name, op, want string }{ + {"insert after a decision", `insert after 'Email is Valid?' { log info 'x'; }`, "which branch"}, + {"drop a decision", `drop 'Email is Valid?';`, "cannot drop"}, + {"drop a variable still read", `drop $IsValidEmail;`, "still used"}, + {"drop the end", `drop return $ValidFeedback;`, "cannot drop"}, + {"declare an existing variable", `insert after $IsValidEmail { declare $ValidFeedback Boolean = true; }`, "already has"}, + {"use a variable not yet declared", `insert after $ValidFeedback { log info 'x {1}' with ({1} = toString($IsValidEmail)); }`, "not declared on the path"}, + {"ambiguous target", `drop set $ValidFeedback = false;`, "add an ordinal"}, + {"unknown target", `drop $Nope;`, "no activity matches"}, + {"a fragment that returns", `insert after $IsValidEmail { return false; }`, "ends the flow"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + exec, _ := openPedAppFixture(t) + _, raw := valFeedbackUnit(t, exec) + err := afRun(t, exec, "alter microflow FeedbackModule.VAL_Feedback { "+tc.op+" };") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want an error containing %q, got %v", tc.want, err) + } + if _, rawAfter := valFeedbackUnit(t, exec); !bytes.Equal(raw, rawAfter) { + t.Error("a refused alter changed the stored unit") + } + }) + } +} + +var _ = microflows.ActionActivity{} + +// alter nanoflow goes through the same splice: the unit is a +// Microflows$Nanoflow with the same object collection and flows. +func TestAlterNanoflow_PedApp_InsertBefore(t *testing.T) { + exec, _ := openPedAppFixture(t) + err := afRun(t, exec, `alter nanoflow FeedbackModule.ACT_Feedback_ClearImage { + insert before call javascript action * { declare $Cleared Boolean = true; } + };`) + if err != nil { + t.Fatalf("alter: %v", err) + } + var buf bytes.Buffer + exec.output = &buf + if err := afRun(t, exec, "describe nanoflow FeedbackModule.ACT_Feedback_ClearImage;"); err != nil { + t.Fatal(err) + } + body := buf.String() + iChange := strings.Index(body, "change $Feedback") + iDeclare := strings.Index(body, "declare $Cleared Boolean = true;") + iCall := strings.Index(body, "call javascript action") + if iChange < 0 || iDeclare < iChange || iCall < iDeclare { + t.Errorf("describe does not show the declare between the change and the call:\n%s", body) + } +} diff --git a/mdl/executor/register_stubs.go b/mdl/executor/register_stubs.go index 130cefb93..aac52f5ba 100644 --- a/mdl/executor/register_stubs.go +++ b/mdl/executor/register_stubs.go @@ -519,6 +519,9 @@ func registerLintHandlers(r *Registry) { } func registerAlterPageHandlers(r *Registry) { + r.Register(&ast.AlterFlowStmt{}, func(ctx *ExecContext, stmt ast.Statement) error { + return execAlterFlow(ctx, stmt.(*ast.AlterFlowStmt)) + }) r.Register(&ast.AlterPageStmt{}, func(ctx *ExecContext, stmt ast.Statement) error { return execAlterPage(ctx, stmt.(*ast.AlterPageStmt)) }) diff --git a/mdl/executor/registry_test.go b/mdl/executor/registry_test.go index 9340826a3..a59e8e787 100644 --- a/mdl/executor/registry_test.go +++ b/mdl/executor/registry_test.go @@ -177,6 +177,7 @@ func allKnownStatements() []ast.Statement { &ast.AlterODataClientStmt{}, &ast.AlterODataServiceStmt{}, &ast.AlterPageStmt{}, + &ast.AlterFlowStmt{}, &ast.AlterPagesLayoutStmt{}, &ast.AlterPagesStylingStmt{}, &ast.AlterProjectSecurityStmt{}, diff --git a/mdl/executor/stmt_summary.go b/mdl/executor/stmt_summary.go index 362f91603..bcb135d79 100644 --- a/mdl/executor/stmt_summary.go +++ b/mdl/executor/stmt_summary.go @@ -198,6 +198,9 @@ func stmtSummary(stmt ast.Statement) string { case *ast.AlterStylingStmt: return fmt.Sprintf("alter styling on %s %s widget %s", s.ContainerType, s.ContainerName, s.WidgetName) + case *ast.AlterFlowStmt: + return fmt.Sprintf("alter %s %s", s.Kind(), s.Name) + // ALTER PAGE / ALTER SNIPPET case *ast.AlterPageStmt: ct := s.ContainerType diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 472d38961..2aab0c61f 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -173,6 +173,11 @@ alterStatement // there; a scroll-container region is addressed as `layoutContainer.top`, // because a region has no Name of its own. | ALTER alterDocumentType qualifiedName LBRACE alterOperation+ RBRACE + // The generic ALTER on a microflow or nanoflow (ADR-0012 decision 3): a + // graph splice into the stored flow. Its targets are content addresses + // (`$Var`, `'Caption'`, a statement pattern) and its fragments are + // microflow statements, so it has its own operation rule. + | ALTER (MICROFLOW | NANOFLOW) qualifiedName LBRACE alterFlowOperation* RBRACE | alterPagesLayoutStatement | alterPagesStylingStatement | ALTER WORKFLOW qualifiedName alterWorkflowAction+ SEMICOLON? @@ -312,6 +317,36 @@ alterTarget | STRING_LITERAL (AT NUMBER_LITERAL)? ; +/** + * `alter microflow` / `alter nanoflow` operations (ADR-0012 decision 3, + * ako/mxcli#736): + * + * ```mdl + * alter microflow FeedbackModule.VAL_Feedback { + * insert after $IsValidEmail { log info node 'Feedback' 'Email checked'; } + * insert before 'Email is Valid?' { … } + * replace commit $Order with { commit $Order with events; } + * drop log * node 'Debug' *; + * } + * ``` + * + * A fragment is written exactly as the same statements are in `create + * microflow`. A target is a content address, resolved by mfmutator: `$Var` + * (the activity that outputs it), `'Caption'`, or a statement pattern with `*` + * wildcards, each optionally followed by `@n`. A pattern is any run of tokens, + * so the target is taken as raw text up to the `{`, `with` or `;` that ends it; + * that is why `drop` needs its semicolon. + */ +alterFlowOperation + : INSERT (AFTER | BEFORE) alterFlowTarget LBRACE microflowBody RBRACE SEMICOLON? + | REPLACE alterFlowTarget WITH LBRACE microflowBody RBRACE SEMICOLON? + | DROP alterFlowTarget SEMICOLON + ; + +alterFlowTarget + : ~(LBRACE | RBRACE | SEMICOLON | WITH)+ + ; + // ALTER PAGES [IN <module>] SET LAYOUT = Module.Layout [MAP (...)] [WHERE LAYOUT = Module.Old] // // The bulk form is the real one: an app has one layout and many pages, so diff --git a/mdl/visitor/visitor_alter.go b/mdl/visitor/visitor_alter.go index c878412ef..859174a7f 100644 --- a/mdl/visitor/visitor_alter.go +++ b/mdl/visitor/visitor_alter.go @@ -17,6 +17,10 @@ func (b *Builder) ExitAlterStatement(ctx *parser.AlterStatementContext) { b.exitAlterDocumentStatement(ctx) return } + if ctx.MICROFLOW() != nil || ctx.NANOFLOW() != nil { + b.exitAlterFlowStatement(ctx) + return + } // Handle ALTER PAGES … SET LAYOUT (the bulk repoint) if sub := ctx.AlterPagesStylingStatement(); sub != nil { diff --git a/mdl/visitor/visitor_alter_flow.go b/mdl/visitor/visitor_alter_flow.go new file mode 100644 index 000000000..784848fda --- /dev/null +++ b/mdl/visitor/visitor_alter_flow.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" +) + +// exitAlterFlowStatement builds an AlterFlowStmt from +// ALTER MICROFLOW|NANOFLOW Module.Name { insert / replace / drop }. +func (b *Builder) exitAlterFlowStatement(ctx *parser.AlterStatementContext) { + stmt := &ast.AlterFlowStmt{Nanoflow: ctx.NANOFLOW() != nil} + if qn := ctx.QualifiedName(); qn != nil { + stmt.Name = buildQualifiedName(qn) + } + for _, opCtx := range ctx.AllAlterFlowOperation() { + op := opCtx.(*parser.AlterFlowOperationContext) + o := &ast.AlterFlowOperation{Target: alterFlowTargetText(op.AlterFlowTarget())} + switch { + case op.INSERT() != nil && op.BEFORE() != nil: + o.Op = ast.AlterFlowInsertBefore + case op.INSERT() != nil: + o.Op = ast.AlterFlowInsertAfter + case op.REPLACE() != nil: + o.Op = ast.AlterFlowReplace + default: + o.Op = ast.AlterFlowDrop + } + if body := op.MicroflowBody(); body != nil { + o.Body = buildMicroflowBody(body) + } + stmt.Operations = append(stmt.Operations, o) + } + b.statements = append(b.statements, stmt) +} + +// alterFlowTargetText returns a target as the author wrote it — whitespace and +// quoting included — since a statement pattern is matched token by token +// against describe's rendering, and the grammar only knows it as a run of +// arbitrary tokens. +func alterFlowTargetText(ctx parser.IAlterFlowTargetContext) string { + if ctx == nil { + return "" + } + start, stop := ctx.GetStart(), ctx.GetStop() + if start == nil || stop == nil { + return strings.TrimSpace(ctx.GetText()) + } + return strings.TrimSpace(start.GetInputStream().GetText(start.GetStart(), stop.GetStop())) +} diff --git a/mdl/visitor/visitor_alter_flow_test.go b/mdl/visitor/visitor_alter_flow_test.go new file mode 100644 index 000000000..2391ffc99 --- /dev/null +++ b/mdl/visitor/visitor_alter_flow_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// alter microflow / nanoflow (ADR-0012 decision 3, ako/mxcli#736): targets are +// content addresses kept as written, fragments are microflow statements. +func TestAlterFlow_OperationsAndTargets(t *testing.T) { + prog, errs := Build(`alter microflow FeedbackModule.VAL_Feedback { + insert after $IsValidEmail { log info node 'Feedback' 'Email checked'; } + insert before 'Email is Valid?' @2 { declare $n Integer = 1; set $n = 2; } + replace log * node 'Debug' * with { log warning node 'Debug' 'x'; } + drop set $ValidFeedback = false @3; + };`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + stmt, ok := prog.Statements[0].(*ast.AlterFlowStmt) + if !ok { + t.Fatalf("want *ast.AlterFlowStmt, got %T", prog.Statements[0]) + } + if stmt.Nanoflow || stmt.Name.String() != "FeedbackModule.VAL_Feedback" { + t.Errorf("header: nanoflow=%v name=%s", stmt.Nanoflow, stmt.Name) + } + want := []struct { + op ast.AlterFlowOpKind + target string + body int + }{ + {ast.AlterFlowInsertAfter, "$IsValidEmail", 1}, + {ast.AlterFlowInsertBefore, "'Email is Valid?' @2", 2}, + {ast.AlterFlowReplace, "log * node 'Debug' *", 1}, + {ast.AlterFlowDrop, "set $ValidFeedback = false @3", 0}, + } + if len(stmt.Operations) != len(want) { + t.Fatalf("want %d operations, got %d", len(want), len(stmt.Operations)) + } + for i, w := range want { + got := stmt.Operations[i] + if got.Op != w.op || got.Target != w.target || len(got.Body) != w.body { + t.Errorf("op %d: got %q %q body=%d, want %q %q body=%d", i, got.Op, got.Target, len(got.Body), w.op, w.target, w.body) + } + } +} + +func TestAlterFlow_Nanoflow(t *testing.T) { + prog, errs := Build(`alter nanoflow M.NF { drop $X; }`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + stmt := prog.Statements[0].(*ast.AlterFlowStmt) + if !stmt.Nanoflow || stmt.Operations[0].Op != ast.AlterFlowDrop || stmt.Operations[0].Target != "$X" { + t.Errorf("got %+v %+v", stmt, stmt.Operations[0]) + } +} From e184384d34e7290dfa48c919805641802470d0fc Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 08:49:04 +0000 Subject: [PATCH 14/37] describe with handles: the error-handler block's { is not part of the handle A handle has to parse as an alter target, and a target ends at the { of a fragment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../skills/fix-issue/findings/mdl-executor.jsonl | 1 + mdl/executor/cmd_microflows_handles.go | 8 +++++++- mdl/executor/cmd_microflows_handles_test.go | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 92a698438..129a64784 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -728,3 +728,4 @@ {"area": "mdl/executor", "date": "2026-09-26", "symptom": "`describe fragment from page M.P widget w` (and `from snippet`) fails for every container and every widget — including ones `describe page` prints — with `not found in page M.P`, a message that does not even name the widget", "cause": "Visitor stores DescribeFragmentFromStmt.ContainerType as \"PAGE\"/\"SNIPPET\"; describeFragmentFrom switched on \"page\"/\"snippet\" with no default, so neither branch ran, the widget list stayed empty, and the fall-through reported the widget missing. Third instance of this split: ALTER PAGE (#402), DESCRIBE/ALTER STYLING (#631)", "file": "`mdl/executor/cmd_fragments.go` (`describeFragmentFrom`)", "insight": "The mismatch hid behind a plausible error because a switch on the discriminator had no default: an unmatched container type looked like an empty container, and an empty container looks like a missing widget. Normalise with strings.ToLower where the discriminator is consumed (the house convention — cmd_styling, cmd_alter_page, validate_alter_* all do) AND make the default an error, so the next casing drift fails loudly instead of reporting the wrong thing. The existing mock tests hand-built the AST and so agreed with the handler; only a test that goes visitor.Build → NewRegistry().Dispatch pins the contract between the two layers (cmd_fragments_from_test.go). Verified on Evora: Administration.Account_Edit/textBox6 and AgentCommons.Snippet_Agent_Details/dataView7 now describe", "refs": ["#402", "#631"]} {"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe output that does not re-parse or loses data (ako/mxcli#707): an entity string default or validation message containing ' was emitted unescaped; so were module-role descriptions, published OData/REST Path/Version/Namespace/Summary/Folder, and REST client BaseUrl/Path/header values; an agent `mcp service` block with a Description lacked the comma after `Enabled`; workflow decision / parallel split captions came back only as `-- caption` comments (replay reset them to 'Decision' / 'Parallel split'); `describe demo user` emitted `password '***'`, which replay stored as the password; `describe settings` printed `DatabasePassword = '<plaintext>'`.", "cause": "Hand-rolled `'%s'` emit sites that put the quotes and the escaping in different places (the #1006 source-scan guard covered only cmd_workflows.go); a block emitter with no separator logic, unlike its sibling; captions treated as commentary although the grammar has `comment '…'` for both activities; secrets printed as data, with a placeholder the writer took literally.", "file": "mdl/executor/cmd_entities_describe.go, cmd_security.go, cmd_security_write.go, cmd_odata.go, cmd_published_rest.go, cmd_rest_clients.go, cmd_agenteditor_agents.go, cmd_workflows.go, cmd_settings.go", "fix": "Every emit site uses mdlQuoted; TestDescribers_HaveNoHandRolledStringLiterals now scans all seven describer files. MCP block writes the comma like the tool block. workflowCaptionClauses emits `comment '…'` for a non-default caption and computes the name clause against the caption the writer will store. DatabasePassword is omitted with a comment (create or modify is a patch, so replay keeps it). Demo users are described as `create or modify … password '***'`, and the executor treats '***' as 'keep the stored password', refusing it for a user that does not exist.", "insight": "Assert round trips by reparsing describe output with the real visitor and comparing the AST value to the stored one, not by substring. For secrets the right placeholder is one the WRITER understands: omission works where the create is a patch (configuration); where the grammar requires the value (demo user) give the placeholder a meaning (keep stored) and refuse it where that meaning is empty, so a replay can neither leak nor silently set a credential.", "test": "mdl/executor/issue707_describe_roundtrip_test.go"} {"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe microflow \u2026 with handles (ako/mxcli#713) printed no handle for an activity inside an `on error { \u2026 }` block, and the alter-target resolver counted such activities after the whole main flow: on SUB_Feedback_PostToAppInsights `return * @1` picked `return $Response`, although describe prints the handler's `return empty` first. Also `$Response` did not address a REST call whose output is on its result handling (and cast, create list, web service, workflow, XML/JSON, database-query outputs).", "cause": "Error-handler bodies are rendered by collectErrorHandlerStatements, a second describer that returned bare strings and never wrote the source map, so those nodes had no line to rank or print a handle at; unranked candidates were appended last. The output-variable switch was copied from actionOutputVariableName, which had drifted from the formatter.", "file": "mdl/executor/cmd_microflows_show_helpers.go; mdl/backend/mfmutator/target.go", "fix": "collectErrorHandlerStatementSpans reports each handler-body object's statement span; emitActivityStatement and emitCommentedErrorHandler record them in the source map (additive entries in ELK sourceMap too). mfmutator.OutputVariable reads the variable where the formatter does, for every action it prints as `$X = \u2026`. A comment rendering (`-- Unsupported \u2026`) is no statement, so it never becomes a handle.", "insight": "Any node the describer prints through a side path must enter the source map, or everything keyed on print order (ordinals, handles, ELK highlighting) silently disagrees with the text. Check ranking against a Studio Pro flow that has a handler body, not just VAL_Feedback.", "test": "TestDescribeWithHandles_ErrorHandlerBody, TestMicroflowTargets_PedAppEveryFlowRanksAndResolves, TestOutputVariable_EveryActionDescribePrintsAnAssignmentFor, TestCandidate_CommentRenderingIsNoStatement"} +{"area": "mdl/executor", "date": "2026-09-27", "symptom": "describe microflow \u2026 with handles printed `-- handle: commit $Order on error {` for an activity with a custom error handler (TestApp Services.SaveOrder). The handle could not be used: an `alter microflow` target ends at the `{` that opens a fragment, so `insert after commit $Order on error { \u2026 }` parsed the handler brace as the fragment.", "cause": "printedStatement ended an action's statement at a line ending in `;` or `{` and kept the `{`, which belongs to the error-handler block describe opens, not to the statement.", "file": "mdl/executor/cmd_microflows_handles.go", "fix": "printedStatement strips the trailing `{` of an error-handler block opener, so the handle is `commit $Order on error`, which parses as a target and still matches the activity.", "insight": "A handle is only useful if it can be written back as a target in the grammar that consumes it; test a printed handle by parsing it, not only by resolving it.", "test": "TestPrintedStatement_ErrorHandlerBlockOpenerIsNotPartOfTheStatement"} diff --git a/mdl/executor/cmd_microflows_handles.go b/mdl/executor/cmd_microflows_handles.go index 2172169fa..1c871e241 100644 --- a/mdl/executor/cmd_microflows_handles.go +++ b/mdl/executor/cmd_microflows_handles.go @@ -88,9 +88,15 @@ func printedStatement(obj microflows.MicroflowObject, body []string, r elkSource return strings.Join(parts, " ") } default: - if strings.HasSuffix(line, ";") || strings.HasSuffix(line, "{") { + if strings.HasSuffix(line, ";") { return strings.Join(parts, " ") } + if strings.HasSuffix(line, "{") { + // The `{` opens the error handler block. It is not part of + // the statement: a handle is written as an alter target, and a + // target ends where a fragment's `{` begins. + return strings.TrimSpace(strings.TrimSuffix(strings.Join(parts, " "), "{")) + } } if len(parts) >= 50 { break diff --git a/mdl/executor/cmd_microflows_handles_test.go b/mdl/executor/cmd_microflows_handles_test.go index 056cdd56e..10ae2ce01 100644 --- a/mdl/executor/cmd_microflows_handles_test.go +++ b/mdl/executor/cmd_microflows_handles_test.go @@ -308,3 +308,18 @@ func TestDescribeWithHandles_ErrorHandlerBody(t *testing.T) { t.Errorf("with handles minus the handle lines differs from plain describe:\n%s\n---\n%s", strings.Join(stripped, "\n"), strings.Join(plain, "\n")) } } + +// A handle has to be writable as an `alter microflow` target, and a target +// ends at the `{` that opens a fragment. So the `{` describe prints after an +// activity with a custom error handler is not part of its statement. +func TestPrintedStatement_ErrorHandlerBlockOpenerIsNotPartOfTheStatement(t *testing.T) { + body := []string{" commit $Order on error {", " return false;", " };"} + obj := &microflows.ActionActivity{} + got := printedStatement(obj, body, elkSourceRange{StartLine: 0, EndLine: 2}) + if got != "commit $Order on error" { + t.Errorf("printed statement %q, want %q", got, "commit $Order on error") + } + if _, err := mfmutator.ParseTarget(got); err != nil { + t.Errorf("the handle does not parse as a target: %v", err) + } +} From 02979d99b1263f04cadf7857c9cdfc928c66507a Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 08:49:04 +0000 Subject: [PATCH 15/37] mcp: alter microflow insert over the Studio Pro MCP backend Runs the shared splice on the stored flow and sends the difference as ped_update_document path operations in one update, after checking the live document still matches the .mpr (PED addresses entries by index). Drop and replace are refused: PED does not roll back a removal when an update fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/backend/mcp/microflow_mutator.go | 499 ++++++++++++++++++++++ mdl/backend/mcp/microflow_mutator_test.go | 207 +++++++++ mdl/backend/mcp/unsupported_gen.go | 5 + 3 files changed, 711 insertions(+) create mode 100644 mdl/backend/mcp/microflow_mutator.go create mode 100644 mdl/backend/mcp/microflow_mutator_test.go diff --git a/mdl/backend/mcp/microflow_mutator.go b/mdl/backend/mcp/microflow_mutator.go new file mode 100644 index 000000000..4bbe61ad1 --- /dev/null +++ b/mdl/backend/mcp/microflow_mutator.go @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// ALTER MICROFLOW / NANOFLOW over MCP (plan item 4.2f). +// +// The splice itself is the shared engine (mfmutator), run on the stored form of +// the flow as the local .mpr holds it — the same decisions, placement and +// refusals as on the modelsdk backend. What differs is the write: Studio Pro +// takes no unit bytes, so Save diffs the spliced document against the stored +// one and sends the difference as ped_update_document path operations on the +// live document: positions set, new objects and flows added, rewired flow ends +// set, removed elements removed. PED addresses list entries by index, and those +// indexes are the stored order — so before anything is sent, the live document +// is read back and compared with the local one, and a mismatch (the .mpr is +// behind Studio Pro) refuses the write rather than patching the wrong element. + +// OpenMicroflowForMutation opens a microflow or nanoflow for splicing. +func (b *Backend) OpenMicroflowForMutation(unitID model.ID) (backend.MicroflowMutator, error) { + raw, err := b.reader.GetRawUnitBytes(unitID) + if err != nil { + return nil, fmt.Errorf("alter over MCP reads the stored flow from the local project: %w", err) + } + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + return nil, err + } + docType, _ := docValue(d, "$Type").(string) + name, _ := docValue(d, "Name").(string) + qn, err := b.flowQualifiedName(unitID, docType, name) + if err != nil { + return nil, err + } + deps := &mcpFlowDeps{b: b, docType: docType, qn: qn, stored: d, objects: map[string]microflows.MicroflowObject{}, + flows: map[string]*microflows.SequenceFlow{}, annotations: map[string]*microflows.AnnotationFlow{}} + var work bson.D + if err := bson.Unmarshal(raw, &work); err != nil { + return nil, err + } + m, err := mfmutator.New(work, unitID, deps) + if err != nil { + return nil, err + } + return &mcpFlowMutator{Mutator: m, qn: qn}, nil +} + +// mcpFlowMutator is the shared splice, limited to what PED applies safely: +// inserts. A PED update that fails is rolled back EXCEPT for its removals, +// which persist (PED's own warning, and measured: a failed update left the +// removed activity gone together with the flows attached to it). A drop or a +// replace needs a removal, so over MCP they are refused rather than risked on +// the live model; run them against the .mpr instead. +type mcpFlowMutator struct { + *mfmutator.Mutator + qn string +} + +func (m *mcpFlowMutator) Replace(model.ID, *backend.MicroflowFragment) error { + return fmt.Errorf("replace is not supported by the MCP backend yet: Studio Pro does not roll back a removal when an update fails; run without --mcp to alter %s in the .mpr", m.qn) +} + +func (m *mcpFlowMutator) Drop(model.ID) error { + return fmt.Errorf("drop is not supported by the MCP backend yet: Studio Pro does not roll back a removal when an update fails; run without --mcp to alter %s in the .mpr", m.qn) +} + +func (b *Backend) flowQualifiedName(id model.ID, docType, name string) (string, error) { + var container model.ID + switch docType { + case microflowDocType: + mf, err := b.GetMicroflow(id) + if err != nil { + return "", err + } + container = mf.ContainerID + case "Microflows$Nanoflow": + nfs, err := b.reader.ListNanoflows() + if err != nil { + return "", err + } + for _, nf := range nfs { + if nf.ID == id { + container = nf.ContainerID + } + } + default: + return "", fmt.Errorf("unit %s is a %s, not a microflow or nanoflow", id, docType) + } + mod, err := b.moduleNameForContainer(container) + if err != nil { + return "", err + } + return mod + "." + name, nil +} + +// mcpFlowDeps is mfmutator.Deps for the MCP backend. Serialize produces only +// what the splice reads (identity, type, geometry, pointers) and keeps the +// domain object, which is what PED is sent; SaveUnit turns the spliced +// document into PED operations. +type mcpFlowDeps struct { + b *Backend + docType string + qn string + stored bson.D + + objects map[string]microflows.MicroflowObject + flows map[string]*microflows.SequenceFlow + annotations map[string]*microflows.AnnotationFlow +} + +func binaryOf(id model.ID) primitive.Binary { + return primitive.Binary{Subtype: 0, Data: types.UUIDToBlob(string(id))} +} + +func (d *mcpFlowDeps) SerializeObject(obj microflows.MicroflowObject) (bson.D, error) { + d.objects[string(obj.GetID())] = obj + p := obj.GetPosition() + w, h := 0, 0 + if s, ok := obj.(interface{ GetSize() model.Size }); ok { + w, h = s.GetSize().Width, s.GetSize().Height + } + return bson.D{ + {Key: "$ID", Value: binaryOf(obj.GetID())}, + {Key: "$Type", Value: "mcp-new-object"}, + {Key: "RelativeMiddlePoint", Value: fmt.Sprintf("%d;%d", p.X, p.Y)}, + {Key: "Size", Value: fmt.Sprintf("%d;%d", w, h)}, + }, nil +} + +func (d *mcpFlowDeps) SerializeSequenceFlow(f *microflows.SequenceFlow) (bson.D, error) { + d.flows[string(f.ID)] = f + return bson.D{ + {Key: "$ID", Value: binaryOf(f.ID)}, + {Key: "$Type", Value: "Microflows$SequenceFlow"}, + {Key: "DestinationConnectionIndex", Value: int32(f.DestinationConnectionIndex)}, + {Key: "DestinationPointer", Value: binaryOf(f.DestinationID)}, + {Key: "IsErrorHandler", Value: f.IsErrorHandler}, + {Key: "Line", Value: bson.D{ + {Key: "$Type", Value: "Microflows$BezierCurve"}, + {Key: "DestinationControlVector", Value: f.DestinationControlVector}, + {Key: "OriginControlVector", Value: f.OriginControlVector}, + }}, + {Key: "OriginConnectionIndex", Value: int32(f.OriginConnectionIndex)}, + {Key: "OriginPointer", Value: binaryOf(f.OriginID)}, + }, nil +} + +func (d *mcpFlowDeps) SerializeAnnotationFlow(f *microflows.AnnotationFlow) (bson.D, error) { + d.annotations[string(f.ID)] = f + return bson.D{ + {Key: "$ID", Value: binaryOf(f.ID)}, + {Key: "$Type", Value: "Microflows$AnnotationFlow"}, + {Key: "DestinationPointer", Value: binaryOf(f.DestinationID)}, + {Key: "OriginPointer", Value: binaryOf(f.OriginID)}, + }, nil +} + +// SaveUnit sends the difference between the stored and the spliced document. +func (d *mcpFlowDeps) SaveUnit(_ string, contents []byte) error { + var spliced bson.D + if err := bson.Unmarshal(contents, &spliced); err != nil { + return err + } + ops, err := d.b.flowPatchOps(d, spliced) + if err != nil { + return err + } + if len(ops) == 0 { + return nil + } + if err := d.b.checkLiveFlowMatches(d.docType, d.qn, d.stored); err != nil { + return err + } + // Only inserts reach here (mcpFlowMutator refuses the rest), so there is + // nothing to remove; a removal would mean the splice did something this + // path was not built for, and PED would not roll it back on a failure. + for _, op := range ops { + if op.Operation.Type == "remove" { + return fmt.Errorf("%s: refusing to send a removal over MCP", d.qn) + } + } + // One update: PED applies it all or, on a failure, nothing. + if err := d.b.pedUpdateDoc(d.docType, d.qn, ops...); err != nil { + return err + } + return d.b.pedCheckDocument(d.docType, d.qn) +} + +// flowList returns a unit's top-level objects or flows, in stored order, with +// their $IDs. +func flowList(d bson.D, objects bool) (ids []string, docs []bson.D) { + list := docValue(d, "Flows") + if objects { + oc, _ := docValue(d, "ObjectCollection").(bson.D) + list = docValue(oc, "Objects") + } + a, _ := list.(bson.A) + for i, el := range a { + if i == 0 { + if _, marker := el.(int32); marker { + continue + } + } + e, ok := el.(bson.D) + if !ok { + continue + } + b, _ := docValue(e, "$ID").(primitive.Binary) + ids = append(ids, types.BlobToUUID(b.Data)) + docs = append(docs, e) + } + return ids, docs +} + +func docValue(d bson.D, key string) any { + for _, e := range d { + if e.Key == key { + return e.Value + } + } + return nil +} + +func pointerID(d bson.D, key string) string { + b, _ := docValue(d, key).(primitive.Binary) + return types.BlobToUUID(b.Data) +} + +func storedPointToPED(s string) map[string]int { + x, y, _ := strings.Cut(s, ";") + px, _ := strconv.Atoi(x) + py, _ := strconv.Atoi(y) + return map[string]int{"x": px, "y": py} +} + +func pedVector(s string) map[string]int { + p := storedPointToPED(s) + return map[string]int{"width": p["x"], "height": p["y"]} +} + +var pedSides = []string{"Top", "Right", "Bottom", "Left"} + +// pedQualifiedFlowType maps the storage $Type of a flow object to the +// qualified name PED reports for it, where the two differ (CLAUDE.md, "BSON +// Storage Names vs Qualified Names"). +var pedQualifiedFlowType = map[string]string{ + "Microflows$MicroflowParameter": "Microflows$MicroflowParameterObject", +} + +// flowPatchOps derives the PED operations that turn the stored document into +// the spliced one. Order matters, because PED addresses entries by index: +// everything that uses a stored index (positions, rewired flows) and every +// append goes first, while indexes are still the stored ones; removals go +// last, highest index first. +func (b *Backend) flowPatchOps(d *mcpFlowDeps, spliced bson.D) ([]pedOpEntry, error) { + var ops []pedOpEntry + set := func(path string, v any) { + ops = append(ops, pedOpEntry{Path: path, Operation: pedOperation{Type: "set", Value: v}}) + } + oldObjIDs, oldObjs := flowList(d.stored, true) + newObjIDs, newObjs := flowList(spliced, true) + oldFlowIDs, oldFlows := flowList(d.stored, false) + newFlowIDs, newFlows := flowList(spliced, false) + + path := map[string]string{} + oldObjIndex := map[string]int{} + for i, id := range oldObjIDs { + oldObjIndex[id] = i + path[id] = fmt.Sprintf("/objectCollection/objects/%d", i) + } + survivingObj := map[string]bool{} + var added []string + for i, id := range newObjIDs { + if idx, ok := oldObjIndex[id]; ok { + survivingObj[id] = true + before, _ := docValue(oldObjs[idx], "RelativeMiddlePoint").(string) + after, _ := docValue(newObjs[i], "RelativeMiddlePoint").(string) + if before != after { + set(path[id]+"/relativeMiddlePoint", storedPointToPED(after)) + } + continue + } + added = append(added, id) + } + for j, id := range added { + path[id] = fmt.Sprintf("/objectCollection/objects/%d", len(oldObjIDs)+j) + } + for _, id := range added { + obj, ok := d.objects[id] + if !ok { + return nil, fmt.Errorf("internal: spliced object %s was not serialized", id) + } + idPath := map[model.ID]string{} + m, err := b.mapObjectTree(obj, path[id], idPath) + if err != nil { + return nil, err + } + var sets []pedOpEntry + skeletonObject(m, path[id], func(p string, v any) { + sets = append(sets, pedOpEntry{Path: p, Operation: pedOperation{Type: "set", Value: v}}) + }) + ops = append(ops, pedOpEntry{Path: "/objectCollection/objects", Operation: pedOperation{Type: "add", Value: m}}) + ops = append(ops, sets...) + // The skeleton constructor ignores a position; set it on the stored element. + p := obj.GetPosition() + set(path[id]+"/relativeMiddlePoint", map[string]int{"x": p.X, "y": p.Y}) + for nested, np := range idPath { + path[string(nested)] = np + } + } + ref := func(id string) (string, error) { + p, ok := path[id] + if !ok { + return "", fmt.Errorf("a flow points at %s, which is not a top-level object PED can address", id) + } + return "$id(" + p + ")", nil + } + + oldFlowIndex := map[string]int{} + for i, id := range oldFlowIDs { + oldFlowIndex[id] = i + } + survivingFlow := map[string]bool{} + var addedFlows []string + for i, id := range newFlowIDs { + idx, ok := oldFlowIndex[id] + if !ok { + addedFlows = append(addedFlows, id) + continue + } + survivingFlow[id] = true + fp := fmt.Sprintf("/flows/%d", idx) + o, n := oldFlows[idx], newFlows[i] + for _, end := range []string{"Origin", "Destination"} { + key := strings.ToLower(end) + if pointerID(o, end+"Pointer") != pointerID(n, end+"Pointer") { + r, err := ref(pointerID(n, end+"Pointer")) + if err != nil { + return nil, err + } + set(fp+"/"+key, r) + } + if fmt.Sprint(docValue(o, end+"ConnectionIndex")) != fmt.Sprint(docValue(n, end+"ConnectionIndex")) { + set(fp+"/"+key+"ConnectionIndex", docValue(n, end+"ConnectionIndex")) + } + ol, _ := docValue(o, "Line").(bson.D) + nl, _ := docValue(n, "Line").(bson.D) + if ov, nv := fmt.Sprint(docValue(ol, end+"ControlVector")), fmt.Sprint(docValue(nl, end+"ControlVector")); ov != nv && nv != "" { + set(fp+"/line/"+key+"ControlVector", pedVector(nv)) + } + } + } + for j, id := range addedFlows { + fp := fmt.Sprintf("/flows/%d", len(oldFlowIDs)+j) + if af, ok := d.annotations[id]; ok { + o, err := ref(string(af.OriginID)) + if err != nil { + return nil, err + } + dst, err := ref(string(af.DestinationID)) + if err != nil { + return nil, err + } + ops = append(ops, pedOpEntry{Path: "/flows", Operation: pedOperation{Type: "add", Value: map[string]any{ + "$Type": "Microflows$AnnotationFlow", "originId": o, "destinationId": dst}}}) + continue + } + f, ok := d.flows[id] + if !ok { + return nil, fmt.Errorf("internal: spliced flow %s was not serialized", id) + } + o, err := ref(string(f.OriginID)) + if err != nil { + return nil, err + } + dst, err := ref(string(f.DestinationID)) + if err != nil { + return nil, err + } + v := map[string]any{"$Type": "Microflows$SequenceFlow", "originId": o, "destinationId": dst} + if f.OriginConnectionIndex >= 0 && f.OriginConnectionIndex < 4 { + v["originConnectionSide"] = pedSides[f.OriginConnectionIndex] + } + if f.DestinationConnectionIndex >= 0 && f.DestinationConnectionIndex < 4 { + v["destinationConnectionSide"] = pedSides[f.DestinationConnectionIndex] + } + cv, err := mapCaseValue(f.CaseValue) + if err != nil { + return nil, err + } + if cv != nil { + v["caseValue"] = cv + } + ops = append(ops, pedOpEntry{Path: "/flows", Operation: pedOperation{Type: "add", Value: v}}) + if f.IsErrorHandler { + set(fp+"/isErrorHandler", true) + } + if f.OriginControlVector != "" { + set(fp+"/line/originControlVector", pedVector(f.OriginControlVector)) + } + if f.DestinationControlVector != "" { + set(fp+"/line/destinationControlVector", pedVector(f.DestinationControlVector)) + } + } + + for i := len(oldFlowIDs) - 1; i >= 0; i-- { + if !survivingFlow[oldFlowIDs[i]] { + idx := i + ops = append(ops, pedOpEntry{Path: "/flows", Operation: pedOperation{Type: "remove", Index: &idx}}) + } + } + for i := len(oldObjIDs) - 1; i >= 0; i-- { + if !survivingObj[oldObjIDs[i]] { + idx := i + ops = append(ops, pedOpEntry{Path: "/objectCollection/objects", Operation: pedOperation{Type: "remove", Index: &idx}}) + } + } + return ops, nil +} + +// checkLiveFlowMatches compares the live document's objects and flows with the +// stored ones the splice ran on: the same count, and each object the same type +// at the same position. PED addresses them by index, so a live document that +// has moved on since the .mpr was saved would have the patch applied to other +// elements than the ones the splice chose. +func (b *Backend) checkLiveFlowMatches(docType, qn string, stored bson.D) error { + res, err := b.client.CallTool("ped_read_document", map[string]any{ + "documentType": docType, + "documentName": qn, + "paths": []string{"/objectCollection/objects", "/flows"}, + }) + if err != nil { + return err + } + if res.IsError { + return fmt.Errorf("ped_read_document %s: %s", qn, pedStripReminder(res.Text)) + } + var body struct { + Results []struct { + Path string `json:"path"` + Result json.RawMessage `json:"result"` + } `json:"results"` + } + if err := json.Unmarshal([]byte(pedStripReminder(res.Text)), &body); err != nil { + return fmt.Errorf("read %s from Studio Pro: %w", qn, err) + } + stale := func(what string) error { + return fmt.Errorf("%s in Studio Pro no longer matches the local project (%s); save in Studio Pro so the .mpr is current, then retry", qn, what) + } + _, objs := flowList(stored, true) + _, flows := flowList(stored, false) + for _, r := range body.Results { + var live []map[string]any + if err := json.Unmarshal(r.Result, &live); err != nil { + return fmt.Errorf("read %s %s from Studio Pro: %w", qn, r.Path, err) + } + switch r.Path { + case "/flows": + if len(live) != len(flows) { + return stale(fmt.Sprintf("%d flows live, %d stored", len(live), len(flows))) + } + case "/objectCollection/objects": + if len(live) != len(objs) { + return stale(fmt.Sprintf("%d objects live, %d stored", len(live), len(objs))) + } + for i, o := range objs { + typ, _ := docValue(o, "$Type").(string) + if q, ok := pedQualifiedFlowType[typ]; ok { + typ = q + } + if live[i]["$Type"] != typ { + return stale(fmt.Sprintf("object %d is a %v live, a %s stored", i, live[i]["$Type"], typ)) + } + rp, _ := docValue(o, "RelativeMiddlePoint").(string) + want := storedPointToPED(rp) + pt, _ := live[i]["relativeMiddlePoint"].(map[string]any) + if fmt.Sprint(pt["x"]) != strconv.Itoa(want["x"]) || fmt.Sprint(pt["y"]) != strconv.Itoa(want["y"]) { + return stale(fmt.Sprintf("object %d is at %v live, at %s stored", i, pt, rp)) + } + } + } + } + return nil +} diff --git a/mdl/backend/mcp/microflow_mutator_test.go b/mdl/backend/mcp/microflow_mutator_test.go new file mode 100644 index 000000000..7fad8b990 --- /dev/null +++ b/mdl/backend/mcp/microflow_mutator_test.go @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +func mfID(name string) primitive.Binary { + b := make([]byte, 16) + copy(b, name) + return primitive.Binary{Data: b} +} + +func mfUUID(name string) string { return types.BlobToUUID(mfID(name).Data) } + +func mfObj(name, typ string, x int) bson.D { + return bson.D{ + {Key: "$ID", Value: mfID(name)}, + {Key: "$Type", Value: typ}, + {Key: "RelativeMiddlePoint", Value: fmt.Sprintf("%d;200", x)}, + {Key: "Size", Value: "120;60"}, + } +} + +func mfFlow(name, from, to string) bson.D { + return bson.D{ + {Key: "$ID", Value: mfID(name)}, + {Key: "$Type", Value: "Microflows$SequenceFlow"}, + {Key: "DestinationConnectionIndex", Value: int32(3)}, + {Key: "DestinationPointer", Value: mfID(to)}, + {Key: "IsErrorHandler", Value: false}, + {Key: "Line", Value: bson.D{{Key: "$Type", Value: "Microflows$BezierCurve"}, + {Key: "DestinationControlVector", Value: "-30;0"}, {Key: "OriginControlVector", Value: "30;0"}}}, + {Key: "OriginConnectionIndex", Value: int32(1)}, + {Key: "OriginPointer", Value: mfID(from)}, + } +} + +// storedReduce is the shape of TestApp's Microflows.MicroflowReduce, the flow +// the live check ran on: a start, three activities 170 apart, an end. +func storedReduce() bson.D { + return bson.D{ + {Key: "$ID", Value: mfID("unit")}, + {Key: "$Type", Value: "Microflows$Microflow"}, + {Key: "Flows", Value: bson.A{int32(3), mfFlow("f0", "start", "a"), mfFlow("f1", "a", "b"), mfFlow("f2", "b", "end")}}, + {Key: "Name", Value: "Reduce"}, + {Key: "ObjectCollection", Value: bson.D{ + {Key: "$ID", Value: mfID("oc")}, + {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, + {Key: "Objects", Value: bson.A{int32(3), + mfObj("start", "Microflows$StartEvent", -98), + mfObj("end", "Microflows$EndEvent", 564), + mfObj("a", "Microflows$ActionActivity", 214), + mfObj("b", "Microflows$ActionActivity", 404), + }}, + }}, + } +} + +func openFlowMutator(t *testing.T, b *Backend) (*mcpFlowMutator, *mcpFlowDeps) { + t.Helper() + stored := storedReduce() + raw, _ := bson.Marshal(stored) + var storedD, work bson.D + _ = bson.Unmarshal(raw, &storedD) + _ = bson.Unmarshal(raw, &work) + deps := &mcpFlowDeps{b: b, docType: microflowDocType, qn: "M.Reduce", stored: storedD, + objects: map[string]microflows.MicroflowObject{}, flows: map[string]*microflows.SequenceFlow{}, + annotations: map[string]*microflows.AnnotationFlow{}} + m, err := mfmutator.New(work, "unit", deps) + if err != nil { + t.Fatal(err) + } + return &mcpFlowMutator{Mutator: m, qn: "M.Reduce"}, deps +} + +func logFragment() *backend.MicroflowFragment { + id := model.ID(types.GenerateID()) + act := &microflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: id}, + Position: model.Point{X: 360, Y: 200}, + Size: model.Size{Width: 120, Height: 60}, + }}, + Action: &microflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'Reduce'", + MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "reduced"}}}, + } + return &backend.MicroflowFragment{Objects: []microflows.MicroflowObject{act}, Entry: id, Exit: id} +} + +// The splice becomes PED path operations addressed by the stored indexes: +// the moved end, the new activity (added bare, then its action and position +// set, as PED's skeleton constructor requires), the rewired flow's end, and +// the new flow — and no removal. +func TestFlowPatchOps_InsertAfter(t *testing.T) { + var sent []any + f := newFakePED(t, func(name string, args map[string]any) (string, bool) { + switch name { + case "ped_read_document": + return `{"results":[{"path":"/objectCollection/objects","result":[` + + `{"$Type":"Microflows$StartEvent","relativeMiddlePoint":{"x":-98,"y":200}},` + + `{"$Type":"Microflows$EndEvent","relativeMiddlePoint":{"x":564,"y":200}},` + + `{"$Type":"Microflows$ActionActivity","relativeMiddlePoint":{"x":214,"y":200}},` + + `{"$Type":"Microflows$ActionActivity","relativeMiddlePoint":{"x":404,"y":200}}]},` + + `{"path":"/flows","result":[{},{},{}]}]}`, false + case "ped_update_document": + sent = append(sent, args["operations"]) + return "SUCCESS: All operations have been performed successfully.", false + case "ped_check_errors": + return "No errors found.", false + } + return "SUCCESS", false + }) + b := &Backend{client: f.connectClient(t)} + m, _ := openFlowMutator(t, b) + if err := m.InsertAfter(model.ID(mfUUID("a")), logFragment()); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatalf("save: %v", err) + } + if len(sent) != 1 { + t.Fatalf("want one ped_update_document, got %d", len(sent)) + } + js, _ := json.Marshal(sent[0]) + got := string(js) + var ops []pedOpEntry + if err := json.Unmarshal(js, &ops); err != nil { + t.Fatal(err) + } + has := func(path, typ, value string) bool { + for _, op := range ops { + v, _ := json.Marshal(op.Operation.Value) + if op.Path == path && op.Operation.Type == typ && strings.Contains(string(v), value) { + return true + } + } + return false + } + for _, w := range []struct{ path, typ, value string }{ + {"/objectCollection/objects/3/relativeMiddlePoint", "set", `"x":534`}, // b moved along + {"/objectCollection/objects", "add", `"Microflows$ActionActivity"`}, + {"/objectCollection/objects/4/action", "set", `"Microflows$LogMessageAction"`}, + {"/flows/1/destination", "set", `$id(/objectCollection/objects/4)`}, + {"/flows", "add", `"destinationId":"$id(/objectCollection/objects/3)"`}, + {"/flows", "add", `"originId":"$id(/objectCollection/objects/4)"`}, + } { + if !has(w.path, w.typ, w.value) { + t.Errorf("no %s %s with %s in:\n%s", w.typ, w.path, w.value, got) + } + } + if strings.Contains(got, `"remove"`) { + t.Errorf("an insert sent a removal:\n%s", got) + } +} + +// PED addresses by index, so a live document that differs from the stored one +// must refuse the write before anything is sent. +func TestFlowPatchOps_StaleLiveDocumentIsRefused(t *testing.T) { + updates := 0 + f := newFakePED(t, func(name string, _ map[string]any) (string, bool) { + switch name { + case "ped_read_document": + return `{"results":[{"path":"/objectCollection/objects","result":[{},{},{},{},{}]},{"path":"/flows","result":[{},{},{}]}]}`, false + case "ped_update_document": + updates++ + } + return "SUCCESS", false + }) + b := &Backend{client: f.connectClient(t)} + m, _ := openFlowMutator(t, b) + if err := m.InsertAfter(model.ID(mfUUID("a")), logFragment()); err != nil { + t.Fatal(err) + } + err := m.Save() + if err == nil || !strings.Contains(err.Error(), "no longer matches the local project") { + t.Fatalf("want the stale-document refusal, got %v", err) + } + if updates != 0 { + t.Error("an update was sent to a document that did not match") + } +} + +// Drop and replace need a removal, which PED does not roll back when an +// update fails; over MCP they are refused. +func TestMCPFlowMutator_RefusesRemovals(t *testing.T) { + m, _ := openFlowMutator(t, &Backend{}) + if err := m.Drop(model.ID(mfUUID("a"))); err == nil || !strings.Contains(err.Error(), "not supported by the MCP backend") { + t.Errorf("drop: %v", err) + } + if err := m.Replace(model.ID(mfUUID("a")), logFragment()); err == nil || !strings.Contains(err.Error(), "not supported by the MCP backend") { + t.Errorf("replace: %v", err) + } +} diff --git a/mdl/backend/mcp/unsupported_gen.go b/mdl/backend/mcp/unsupported_gen.go index a1dc96a9c..abae5e3a3 100644 --- a/mdl/backend/mcp/unsupported_gen.go +++ b/mdl/backend/mcp/unsupported_gen.go @@ -992,6 +992,11 @@ func (unsupportedBackend) MoveViewEntitySourceDocument(_ string, _ model.ID, _ s return } +func (unsupportedBackend) OpenMicroflowForMutation(_ model.ID) (r0 backend.MicroflowMutator, err1 error) { + err1 = errUnsupported("OpenMicroflowForMutation") + return +} + func (unsupportedBackend) OpenPageForMutation(_ model.ID) (r0 backend.PageMutator, err1 error) { err1 = errUnsupported("OpenPageForMutation") return From a9d617b542e76ddbf636c89f2c598dba53b7f33e Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 08:49:49 +0000 Subject: [PATCH 16/37] fix(javascript action): unchanged describe output leaves the .js source alone describe now emits create or modify javascript action (#731), so its output reaches the rewrite, which regenerated the .js file from the statement and dropped the import list and the EXTRA CODE section. On TestApp, NanoflowCommons.GetStraightLineDistance lost import { Big } and the deg2rad helpers its user code calls. When the statement restates the stored user code and parameter names, the file is left as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../fix-issue/findings/mdl-executor.jsonl | 1 + ...cmd_javascript_actions_source_keep_test.go | 115 ++++++++++++++++++ mdl/executor/cmd_javascript_actions_write.go | 29 ++++- 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 mdl/executor/cmd_javascript_actions_source_keep_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 4b6cfcc49..9d4416417 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -729,3 +729,4 @@ {"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe output that does not re-parse or loses data (ako/mxcli#707): an entity string default or validation message containing ' was emitted unescaped; so were module-role descriptions, published OData/REST Path/Version/Namespace/Summary/Folder, and REST client BaseUrl/Path/header values; an agent `mcp service` block with a Description lacked the comma after `Enabled`; workflow decision / parallel split captions came back only as `-- caption` comments (replay reset them to 'Decision' / 'Parallel split'); `describe demo user` emitted `password '***'`, which replay stored as the password; `describe settings` printed `DatabasePassword = '<plaintext>'`.", "cause": "Hand-rolled `'%s'` emit sites that put the quotes and the escaping in different places (the #1006 source-scan guard covered only cmd_workflows.go); a block emitter with no separator logic, unlike its sibling; captions treated as commentary although the grammar has `comment '…'` for both activities; secrets printed as data, with a placeholder the writer took literally.", "file": "mdl/executor/cmd_entities_describe.go, cmd_security.go, cmd_security_write.go, cmd_odata.go, cmd_published_rest.go, cmd_rest_clients.go, cmd_agenteditor_agents.go, cmd_workflows.go, cmd_settings.go", "fix": "Every emit site uses mdlQuoted; TestDescribers_HaveNoHandRolledStringLiterals now scans all seven describer files. MCP block writes the comma like the tool block. workflowCaptionClauses emits `comment '…'` for a non-default caption and computes the name clause against the caption the writer will store. DatabasePassword is omitted with a comment (create or modify is a patch, so replay keeps it). Demo users are described as `create or modify … password '***'`, and the executor treats '***' as 'keep the stored password', refusing it for a user that does not exist.", "insight": "Assert round trips by reparsing describe output with the real visitor and comparing the AST value to the stored one, not by substring. For secrets the right placeholder is one the WRITER understands: omission works where the create is a patch (configuration); where the grammar requires the value (demo user) give the placeholder a meaning (keep stored) and refuse it where that meaning is empty, so a replay can neither leak nor silently set a credential.", "test": "mdl/executor/issue707_describe_roundtrip_test.go"} {"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe microflow \u2026 with handles (ako/mxcli#713) printed no handle for an activity inside an `on error { \u2026 }` block, and the alter-target resolver counted such activities after the whole main flow: on SUB_Feedback_PostToAppInsights `return * @1` picked `return $Response`, although describe prints the handler's `return empty` first. Also `$Response` did not address a REST call whose output is on its result handling (and cast, create list, web service, workflow, XML/JSON, database-query outputs).", "cause": "Error-handler bodies are rendered by collectErrorHandlerStatements, a second describer that returned bare strings and never wrote the source map, so those nodes had no line to rank or print a handle at; unranked candidates were appended last. The output-variable switch was copied from actionOutputVariableName, which had drifted from the formatter.", "file": "mdl/executor/cmd_microflows_show_helpers.go; mdl/backend/mfmutator/target.go", "fix": "collectErrorHandlerStatementSpans reports each handler-body object's statement span; emitActivityStatement and emitCommentedErrorHandler record them in the source map (additive entries in ELK sourceMap too). mfmutator.OutputVariable reads the variable where the formatter does, for every action it prints as `$X = \u2026`. A comment rendering (`-- Unsupported \u2026`) is no statement, so it never becomes a handle.", "insight": "Any node the describer prints through a side path must enter the source map, or everything keyed on print order (ordinals, handles, ELK highlighting) silently disagrees with the text. Check ranking against a Studio Pro flow that has a handler body, not just VAL_Feedback.", "test": "TestDescribeWithHandles_ErrorHandlerBody, TestMicroflowTargets_PedAppEveryFlowRanksAndResolves, TestOutputVariable_EveryActionDescribePrintsAnAssignmentFor, TestCandidate_CommentRenderingIsNoStatement"} {"area": "mdl/executor", "date": "2026-09-27", "symptom": "Once describe emitted create or modify javascript action, re-running it wrote a .js file holding the describe placeholder body, dropped parameter descriptions, and rewrote Studio Pro doc comments (CRLF, blank lines) \u2014 getput and putget failures on 45 PedApp actions", "cause": "The JS write path lacked the Java twin's carries: placeholder guard (javaSourceOmittedBody) and stored-parameter Description/Category; and carriedDocumentation overwrote stored prose with its doc-comment normal form", "file": "mdl/executor/cmd_javascript_actions_write.go, documentation_carry.go", "insight": "Turning a plain create into create or modify is what makes describe output execute at all \u2014 expect the next layer of losses to surface immediately; diff the twin (Java vs JavaScript) write paths for every carry one has and the other lacks", "refs": ["ako/mxcli#731"]} +{"area": "mdl/executor", "date": "2026-09-27", "symptom": "Running unchanged `describe javascript action` output (now `create or modify`) on a project with the .js sources regenerated the file: the import list (`import { Big } from \"big.js\"`) and the EXTRA CODE helpers (deg2rad) were dropped and the signature re-spelled, so NanoflowCommons.GetStraightLineDistance's user code called functions that no longer existed", "cause": "execCreateJavaScriptAction always called WriteJavaScriptSourceFile, and GenerateJavaScriptSource has no slot for the imports or EXTRA CODE (the Java twin retains both, #705). A plain `create` refused on an existing action, which is what had hidden it", "file": "mdl/executor/cmd_javascript_actions_write.go (jsSourceUnchanged)", "insight": "The PedApp round trip could not see this: PedApp ships no javascriptsource, so describe printed the placeholder body and the file path was never exercised. Snapshot the source tree as well as mprcontents when round-tripping a doctype that owns files. Leaving the file alone when the statement restates the stored user code and parameter names fixes describe->exec; an edited body still regenerates without the retained sections", "refs": ["ako/mxcli#731"], "test": "TestJavaScriptActionRewrite_UnchangedCodeKeepsSourceFile"} diff --git a/mdl/executor/cmd_javascript_actions_source_keep_test.go b/mdl/executor/cmd_javascript_actions_source_keep_test.go new file mode 100644 index 000000000..e79d5219d --- /dev/null +++ b/mdl/executor/cmd_javascript_actions_source_keep_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "path/filepath" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" +) + +// A Studio Pro .js source holds three things MDL cannot print: the import +// list, the EXTRA CODE section (describe shows it only as `--` comments) and +// the function signature Studio Pro generated. Regenerating the file from the +// statement drops all three — NanoflowCommons.GetStraightLineDistance lost +// `import { Big }` and the deg2rad helpers its user code calls. +const storedGetDistanceJS = `// This file was generated by Mendix Studio Pro. +import { Big } from "big.js"; + +// BEGIN EXTRA CODE +function deg2rad(deg) { + return deg.times(Math.PI / 180); +} +// END EXTRA CODE + +/** + * @param {Big} latitude + * @returns {Promise.<Big>} + */ +export async function GetDistance(latitude) { + // BEGIN USER CODE + return deg2rad(latitude); + // END USER CODE +} +` + +// Since describe emits `create or modify javascript action` (#731), running its +// output unchanged reaches the rewrite. A statement whose code is the stored +// user code, with the stored parameters, says nothing new about the source, so +// the file is left alone. Changing the code or the parameter list still +// regenerates it (the controls). +func TestJavaScriptActionRewrite_UnchangedCodeKeepsSourceFile(t *testing.T) { + dir := t.TempDir() + mprPath := filepath.Join(dir, "App.mpr") + jsDir := filepath.Join(dir, "javascriptsource", "webmod", "actions") + if err := os.MkdirAll(jsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(jsDir, "GetDistance.js"), []byte(storedGetDistanceJS), 0o644); err != nil { + t.Fatal(err) + } + + mod := mkModule("WebMod") + h := mkHierarchy(mod) + stored := &types.JavaScriptAction{ + BaseElement: model.BaseElement{ID: nextID("jsa")}, + ContainerID: mod.ID, + Name: "GetDistance", + Parameters: []*types.JavaActionParameter{{ + Name: "latitude", IsRequired: true, ParameterType: &types.DecimalType{}, + }}, + } + withContainer(h, stored.ContainerID, mod.ID) + wrote := 0 + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListJavaScriptActionsFunc: func() ([]*types.JavaScriptAction, error) { return []*types.JavaScriptAction{stored}, nil }, + ReadJavaScriptActionByNameFunc: func(string) (*types.JavaScriptAction, error) { return stored, nil }, + UpdateJavaScriptActionFunc: func(*types.JavaScriptAction) error { return nil }, + WriteJavaScriptSourceFileFunc: func(string, string, string, []*types.JavaActionParameter, types.CodeActionReturnType) error { + wrote++ + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h), withMprPath(mprPath)) + + // The body exactly as describe prints it. + userCode, _ := readJavaScriptActionSource(mprPath, "WebMod", "GetDistance") + if userCode == "" { + t.Fatal("precondition: the stored user code was not read") + } + stmt := func(code string, params ...string) *ast.CreateJavaScriptActionStmt { + s := &ast.CreateJavaScriptActionStmt{ + Name: ast.QualifiedName{Module: "WebMod", Name: "GetDistance"}, + CreateOrModify: true, + ReturnType: ast.DataType{Kind: ast.TypeDecimal}, + JavaScriptCode: code, + } + for _, p := range params { + s.Parameters = append(s.Parameters, ast.JavaActionParam{Name: p, Type: ast.DataType{Kind: ast.TypeDecimal}, IsRequired: true}) + } + return s + } + + assertNoError(t, execCreateJavaScriptAction(ctx, stmt("\n"+userCode+"\n", "latitude"))) + if wrote != 0 { + t.Fatal("unchanged describe output regenerated the .js source, dropping its imports and EXTRA CODE") + } + + // Control: edited code is written. + assertNoError(t, execCreateJavaScriptAction(ctx, stmt("return latitude;", "latitude"))) + if wrote != 1 { + t.Errorf("edited code was not written (writes: %d)", wrote) + } + // Control: a changed parameter list regenerates the signature. + assertNoError(t, execCreateJavaScriptAction(ctx, stmt(userCode, "latitude", "longitude"))) + if wrote != 2 { + t.Errorf("a changed parameter list was not written (writes: %d)", wrote) + } +} diff --git a/mdl/executor/cmd_javascript_actions_write.go b/mdl/executor/cmd_javascript_actions_write.go index 098dc2e12..16973201e 100644 --- a/mdl/executor/cmd_javascript_actions_write.go +++ b/mdl/executor/cmd_javascript_actions_write.go @@ -59,6 +59,8 @@ func execCreateJavaScriptAction(ctx *ExecContext, s *ast.CreateJavaScriptActionS // prints the description as a comment — so a rewrite carries them from the // stored parameter of the same name, as the Java twin does (#731). storedParams := map[string]*types.JavaActionParameter{} + // The stored parameter names in order, for sourceUnchanged below. + var storedParamNames []string // No MDL spelling for either, so a rewrite carries them; the defaults are // Studio Pro's. "Public", which this hardcoded, is not a member of // JavaScriptActionsExportLevel (API | Hidden) — see the Java twin. @@ -92,6 +94,7 @@ func execCreateJavaScriptAction(ctx *ExecContext, s *ast.CreateJavaScriptActionS for _, p := range full.Parameters { if p != nil { storedParams[p.Name] = p + storedParamNames = append(storedParamNames, p.Name) } } } @@ -203,7 +206,15 @@ func execCreateJavaScriptAction(ctx *ExecContext, s *ast.CreateJavaScriptActionS // code: writing it would overwrite the real implementation, or add a stub // file to a project that ships none. The statement says nothing about the // code, so the file is left alone (#731; the Java twin is #637). - if strings.TrimSpace(s.JavaScriptCode) != jsSourceOmittedBody { + // + // Likewise when the statement restates the stored user code with the stored + // parameters, as unchanged describe output does: regenerating the file from + // it drops the import list, the EXTRA CODE section and Studio Pro's + // signature, none of which MDL can print, so user code that calls an EXTRA + // CODE helper stops working (#731). + keep := strings.TrimSpace(s.JavaScriptCode) == jsSourceOmittedBody || + (existingID != "" && jsSourceUnchanged(ctx, s, storedParamNames)) + if !keep { if err := ctx.Backend.WriteJavaScriptSourceFile(moduleName, s.Name.Name, s.JavaScriptCode, jsa.Parameters, jsa.ReturnType); err != nil { return mdlerrors.NewBackend("write javascript source file", err) } @@ -257,3 +268,19 @@ func platformOrDefault(p string) string { } return p } + +// jsSourceUnchanged reports whether s restates the existing action's .js source: +// the same user code (as DESCRIBE reads it) and the same parameter names in +// order, so a regenerated file would differ only in what MDL cannot carry. +func jsSourceUnchanged(ctx *ExecContext, s *ast.CreateJavaScriptActionStmt, storedParamNames []string) bool { + if len(s.Parameters) != len(storedParamNames) { + return false + } + for i, p := range s.Parameters { + if p.Name != storedParamNames[i] { + return false + } + } + stored, _ := readJavaScriptActionSource(ctx.MprPath, s.Name.Module, s.Name.Name) + return stored != "" && strings.TrimSpace(stored) == strings.TrimSpace(s.JavaScriptCode) +} From 884f8a721d5821f7a1bfac26f15001401e4eee49 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:02:56 +0000 Subject: [PATCH 17/37] mdl: a trailing comma is allowed in every bracketed list (#732) ADR-0010 R11, additive, so under every language version. One lexer rule drops a comma whose next significant character closes a (), {} or [], instead of a COMMA? in each of ~70 list rules: that kept every list's error messages LL(1). Written into the parser, an unknown item after a comma is reported as 'no viable alternative at input ,X' rather than naming what the list expects, which lost e.g. the annotation-property hint (#1014). A comma still needs an item before it: (,) and (a,,) stay errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/grammar/MDLLexer.g4 | 56 +++++++++++++++++++++ mdl/grammar/keyword_coverage_test.go | 2 +- mdl/visitor/trailing_comma_test.go | 74 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 mdl/visitor/trailing_comma_test.go diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index cf037486c..91636b109 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -8,6 +8,55 @@ */ lexer grammar MDLLexer; +// Hand-written lexer helpers. In the Go target `@members` is package-level +// code, so these are methods on the generated lexer; the predicates below call +// them through the receiver `p` the generator names. +@lexer::members { +// isTrailingComma reports whether the ',' just matched ends a bracketed list: +// the next significant character closes a (), {} or [] (whitespace and +// comments skipped), and the one before it neither opens a list nor is another +// comma, so `()` stays the only spelling of an empty list and `(a,,)` stays an +// error. +func (l *MDLLexer) isTrailingComma() bool { + in := l.GetInputStream() + // The predicate runs with the comma consumed: LA(1) is the character + // after it and LA(-1) the comma itself. + for i := 1; ; i++ { + switch c := in.LA(i); c { + case ' ', '\t', '\r', '\n', '\v', '\f': + case '-': + if in.LA(i+1) != '-' { + return false + } + for i++; in.LA(i+1) != '\n' && in.LA(i+1) != antlr.TokenEOF; i++ { + } + case '/': + if in.LA(i+1) != '*' { + return false + } + for i += 2; !(in.LA(i) == '*' && in.LA(i+1) == '/'); i++ { + if in.LA(i) == antlr.TokenEOF { + return false + } + } + i++ + case ')', '}', ']': + for j := -2; ; j-- { + switch in.LA(j) { + case ' ', '\t', '\r', '\n', '\v', '\f': + continue + case '(', '{', '[', ',', antlr.TokenEOF, 0: + return false + } + return true + } + default: + return false + } + } +} +} + // ============================================================================= // WHITESPACE AND COMMENTS // ============================================================================= @@ -872,6 +921,13 @@ DIV: D I V; // ============================================================================= SEMICOLON: ';'; +// A trailing comma is allowed in every bracketed list, under every language +// version (ADR-0010 R11): `(A: String(200),)`, `{ Method: get, }`. It is +// dropped here rather than written into each list rule, so it is one rule for +// every list, and so a list's error messages stay LL(1): with `(COMMA x)* +// COMMA?` in the parser, an unknown item after a comma is reported as "no +// viable alternative" instead of naming what the list expects. +TRAILING_COMMA: ',' {p.isTrailingComma()}? -> skip; COMMA: ','; DOT: '.'; LPAREN: '('; diff --git a/mdl/grammar/keyword_coverage_test.go b/mdl/grammar/keyword_coverage_test.go index 88ee8b834..a05f24622 100644 --- a/mdl/grammar/keyword_coverage_test.go +++ b/mdl/grammar/keyword_coverage_test.go @@ -27,7 +27,7 @@ func TestKeywordRuleCoverage(t *testing.T) { // Literals "STRING_LITERAL": true, "DOLLAR_STRING": true, "NUMBER_LITERAL": true, "MENDIX_TOKEN": true, // Punctuation - "SEMICOLON": true, "COMMA": true, "DOT": true, + "SEMICOLON": true, "COMMA": true, "TRAILING_COMMA": true, "DOT": true, "LPAREN": true, "RPAREN": true, "LBRACE": true, "RBRACE": true, "LBRACKET": true, "RBRACKET": true, diff --git a/mdl/visitor/trailing_comma_test.go b/mdl/visitor/trailing_comma_test.go new file mode 100644 index 000000000..882465099 --- /dev/null +++ b/mdl/visitor/trailing_comma_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "strings" + "testing" +) + +// trailingCommaCases pairs a statement with a trailing comma in one of its +// lists (marked `,@`) against the same statement without it. ADR-0010 R11: +// trailing commas are allowed in every bracketed list, under every language +// version — it is additive, so no header is needed (#732). +// +// The `,@` marker is replaced by `,` for the trailing form and removed for the +// plain form, so the two sources differ in exactly that one comma. +var trailingCommaCases = map[string]string{ + "entity attributes": "create persistent entity M.Note (Text: String(200),@);", + "entity index": "create persistent entity M.Note (A: String(20), B: String(20)) index (A, B,@);", + "enumeration values": "create enumeration M.Color (Red 'Red', Green 'Green',@);", + "microflow parameters": "create microflow M.F ($A: String, $B: Integer,@) begin end;", + "java action parameters": "create java action M.J (A: String,@) returns Boolean as $$return true;$$;", + "call arguments": "create microflow M.F () begin call microflow M.G(A = 1, B = 2,@); end;", + "page parameters": "create page M.P (Params: { $A: M.E,@ }, Title: 'P', Layout: Atlas_Core.Atlas_Default) {}", + "rest client properties": "create rest client M.Api (BaseUrl: 'https://x', Authentication: none,@) { operation Get { Method: get, Path: '/x', Response: none,@ } };", + "rest client headers": "create rest client M.Api (BaseUrl: 'https://x', Authentication: none) { operation Get { Method: get, Path: '/x', Headers: ('A' = 'b',@), Response: none } };", + "published rest": "create published rest service M.S (Path: 'rest/s', Version: '1.0',@) { };", + "odata client": "create odata client M.C (ODataVersion: OData4, MetadataUrl: 'https://x/$metadata',@);", + "business events": "create business event service M.BE (ServiceName: 'S', EventNamePrefix: '',@) { message Changed (Id: Long,@) publish; };", + "agent model": "create model M.GPT (Provider: MxCloudGenAI, Key: M.Key,@);", + "agent": "create agent M.A (UsageType: Task, Model: M.GPT,@) { tool T { Enabled: true,@ } };", + "module role grant": "grant M.User on M.E (read *, write *,@);", + "line comment after it": "create enumeration M.Color (\n Red 'Red',\n Green 'Green',@ -- the last one\n);", + "block comment after it": "create enumeration M.Color (Red 'Red', Green 'Green',@ /* last */ );", +} + +func TestTrailingCommaInEveryList(t *testing.T) { + for name, src := range trailingCommaCases { + for _, header := range []string{"", "mdl 1;\n"} { + t.Run(name+"/"+strings.TrimSpace(header), func(t *testing.T) { + plain := header + strings.ReplaceAll(src, ",@", "") + trailing := header + strings.ReplaceAll(src, ",@", ",") + want, errs := Build(plain) + if len(errs) > 0 { + t.Fatalf("the plain form must parse: %v\n%s", errs, plain) + } + got, errs := Build(trailing) + if len(errs) > 0 { + t.Fatalf("a trailing comma must be accepted: %v\n%s", errs, trailing) + } + if !reflect.DeepEqual(got.Statements, want.Statements) { + t.Errorf("a trailing comma changed the statement:\n got %#v\n want %#v", got.Statements, want.Statements) + } + }) + } + } +} + +// A comma needs an item before it: `()` stays the only empty list, and `(,)` +// and `(a,,)` stay errors. +func TestTrailingCommaNeedsAnItem(t *testing.T) { + for _, src := range []string{ + "create persistent entity M.Note (,);", + "create enumeration M.Color (,);", + "create microflow M.F (,) begin end;", + "create enumeration M.Color (Red 'Red',,);", + "create enumeration M.Color (Red 'Red', , );", + } { + if _, errs := Build(src); len(errs) == 0 { + t.Errorf("a list holding only a comma was accepted: %s", src) + } + } +} From 534a59542676a9d3b6b358d0a72fc68ef94c61e4 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:04:00 +0000 Subject: [PATCH 18/37] mfmutator: drop/replace of a loop removes its body flows Loop body flows are stored in the unit's Flows list, not in the loop, so removing a Studio Pro loop with two or more body activities left them pointing at removed objects and Save refused the unit (TestApp ACT_ConflictedWorkflowHelper_ApplyJumpTo). mx check 11.14 on the dropped and replaced copies gives the baseline error list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/backend/mfmutator/splice.go | 25 +++++++++++++- mdl/backend/mfmutator/splice_test.go | 50 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/mdl/backend/mfmutator/splice.go b/mdl/backend/mfmutator/splice.go index afc303b30..011cf3e1e 100644 --- a/mdl/backend/mfmutator/splice.go +++ b/mdl/backend/mfmutator/splice.go @@ -385,6 +385,7 @@ func (m *Mutator) Replace(target model.ID, frag *backend.MicroflowFragment) erro } setPointer(af.doc, key, frag.Entry) } + m.removeFlows(g.bodyFlows(x.id)) return m.removeObject(x) } @@ -412,7 +413,8 @@ func (m *Mutator) Drop(target model.ID) error { setInt(in.doc, "DestinationConnectionIndex", destIdx) setVector(in.doc, "DestinationControlVector", destVec) } - drop := map[string]bool{out.id: true} + drop := g.bodyFlows(x.id) + drop[out.id] = true for _, af := range g.annotationFlows(x.id) { drop[af.id] = true } @@ -420,6 +422,27 @@ func (m *Mutator) Drop(target model.ID) error { return m.removeObject(x) } +// bodyFlows returns the flows that run inside loop's body, at any depth. They +// are stored in the unit's Flows list, not in the loop, so taking the loop out +// has to take them too; left behind they would point at removed objects. +func (g *graph) bodyFlows(loop string) map[string]bool { + inside := func(id string) bool { + for n := g.nodes[id]; n != nil && n.loop != ""; n = g.nodes[n.loop] { + if n.loop == loop { + return true + } + } + return false + } + out := map[string]bool{} + for _, f := range g.flows { + if inside(f.origin) || inside(f.dest) { + out[f.id] = true + } + } + return out +} + // removable checks that x can be taken out of the flow and returns the one // flow that leaves it. func removable(g *graph, x *node, verb string) (flowRef, error) { diff --git a/mdl/backend/mfmutator/splice_test.go b/mdl/backend/mfmutator/splice_test.go index 34bc09358..a665bff52 100644 --- a/mdl/backend/mfmutator/splice_test.go +++ b/mdl/backend/mfmutator/splice_test.go @@ -352,3 +352,53 @@ func TestSplice_DropJoinsTheFlows(t *testing.T) { } } } + +// A loop's body flows are stored in the unit's Flows list, not in the loop. +// Dropping or replacing the loop takes them with it; left behind, they point at +// the removed body objects and Save refuses the unit (a Studio Pro-authored +// loop with two body activities, ACT_ConflictedWorkflowHelper_ApplyJumpTo in +// TestApp, hit exactly that). +func TestSplice_DropOrReplaceALoopTakesItsBodyFlows(t *testing.T) { + build := func() ([]bson.D, []bson.D) { + loop := obj("loop", "Microflows$LoopedActivity", 420, 200) + loop = append(loop, bson.E{Key: "ObjectCollection", Value: bson.D{ + {Key: "$ID", Value: bin("loopoc")}, + {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, + {Key: "Objects", Value: bson.A{int32(3), obj("inner", "Microflows$ActionActivity", 100, 60), obj("inner2", "Microflows$ActionActivity", 260, 60)}}, + }}) + objs := []bson.D{ + obj("start", "Microflows$StartEvent", 100, 200), + obj("a", "Microflows$ActionActivity", 250, 200), + loop, + obj("end", "Microflows$EndEvent", 700, 200), + } + flows := []bson.D{ + flow("f1", "start", "a", 1, 3, false), + flow("f2", "a", "loop", 1, 3, false), + flow("fi", "inner", "inner2", 1, 3, false), + flow("f3", "loop", "end", 1, 3, false), + } + return objs, flows + } + ops := map[string]func(m *Mutator) error{ + "drop": func(m *Mutator) error { return m.Drop(model.ID(uid("loop"))) }, + "replace": func(m *Mutator) error { return m.Replace(model.ID(uid("loop")), oneActivity()) }, + } + for name, op := range ops { + t.Run(name, func(t *testing.T) { + objs, flows := build() + m, deps := newMutator(t, unit(objs, flows)) + if err := op(m); err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := m.Save(); err != nil { + t.Fatalf("save: %v", err) + } + for _, gone := range []string{"loop", "inner", "inner2", "fi"} { + if bytes.Contains(deps.saved, types.UUIDToBlob(uid(gone))) { + t.Errorf("%s is still in the unit", gone) + } + } + }) + } +} From 7abe76bb6a212bafb59b85fa970821aa1233d22f Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:04:00 +0000 Subject: [PATCH 19/37] alter microflow: scope checks span the whole statement Each operation was checked against the flow as stored only. Measured with mx check 11.14 on TestApp: two fragments declaring the same variable gave CE0111, and a fragment reading a variable a drop in the same statement removed gave CE0109, after "Altered microflow". The context now tracks what earlier operations declared, read and removed. Also count a fragment loop's iterator as the fragment's own, so loop fragments are no longer refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../fix-issue/findings/mdl-executor.jsonl | 1 + mdl/executor/cmd_alter_flow.go | 77 ++++++++++++++++--- mdl/executor/cmd_alter_flow_pedapp_test.go | 74 ++++++++++++++++++ 3 files changed, 143 insertions(+), 9 deletions(-) diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 129a64784..4947fb831 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -729,3 +729,4 @@ {"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe output that does not re-parse or loses data (ako/mxcli#707): an entity string default or validation message containing ' was emitted unescaped; so were module-role descriptions, published OData/REST Path/Version/Namespace/Summary/Folder, and REST client BaseUrl/Path/header values; an agent `mcp service` block with a Description lacked the comma after `Enabled`; workflow decision / parallel split captions came back only as `-- caption` comments (replay reset them to 'Decision' / 'Parallel split'); `describe demo user` emitted `password '***'`, which replay stored as the password; `describe settings` printed `DatabasePassword = '<plaintext>'`.", "cause": "Hand-rolled `'%s'` emit sites that put the quotes and the escaping in different places (the #1006 source-scan guard covered only cmd_workflows.go); a block emitter with no separator logic, unlike its sibling; captions treated as commentary although the grammar has `comment '…'` for both activities; secrets printed as data, with a placeholder the writer took literally.", "file": "mdl/executor/cmd_entities_describe.go, cmd_security.go, cmd_security_write.go, cmd_odata.go, cmd_published_rest.go, cmd_rest_clients.go, cmd_agenteditor_agents.go, cmd_workflows.go, cmd_settings.go", "fix": "Every emit site uses mdlQuoted; TestDescribers_HaveNoHandRolledStringLiterals now scans all seven describer files. MCP block writes the comma like the tool block. workflowCaptionClauses emits `comment '…'` for a non-default caption and computes the name clause against the caption the writer will store. DatabasePassword is omitted with a comment (create or modify is a patch, so replay keeps it). Demo users are described as `create or modify … password '***'`, and the executor treats '***' as 'keep the stored password', refusing it for a user that does not exist.", "insight": "Assert round trips by reparsing describe output with the real visitor and comparing the AST value to the stored one, not by substring. For secrets the right placeholder is one the WRITER understands: omission works where the create is a patch (configuration); where the grammar requires the value (demo user) give the placeholder a meaning (keep stored) and refuse it where that meaning is empty, so a replay can neither leak nor silently set a credential.", "test": "mdl/executor/issue707_describe_roundtrip_test.go"} {"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe microflow \u2026 with handles (ako/mxcli#713) printed no handle for an activity inside an `on error { \u2026 }` block, and the alter-target resolver counted such activities after the whole main flow: on SUB_Feedback_PostToAppInsights `return * @1` picked `return $Response`, although describe prints the handler's `return empty` first. Also `$Response` did not address a REST call whose output is on its result handling (and cast, create list, web service, workflow, XML/JSON, database-query outputs).", "cause": "Error-handler bodies are rendered by collectErrorHandlerStatements, a second describer that returned bare strings and never wrote the source map, so those nodes had no line to rank or print a handle at; unranked candidates were appended last. The output-variable switch was copied from actionOutputVariableName, which had drifted from the formatter.", "file": "mdl/executor/cmd_microflows_show_helpers.go; mdl/backend/mfmutator/target.go", "fix": "collectErrorHandlerStatementSpans reports each handler-body object's statement span; emitActivityStatement and emitCommentedErrorHandler record them in the source map (additive entries in ELK sourceMap too). mfmutator.OutputVariable reads the variable where the formatter does, for every action it prints as `$X = \u2026`. A comment rendering (`-- Unsupported \u2026`) is no statement, so it never becomes a handle.", "insight": "Any node the describer prints through a side path must enter the source map, or everything keyed on print order (ordinals, handles, ELK highlighting) silently disagrees with the text. Check ranking against a Studio Pro flow that has a handler body, not just VAL_Feedback.", "test": "TestDescribeWithHandles_ErrorHandlerBody, TestMicroflowTargets_PedAppEveryFlowRanksAndResolves, TestOutputVariable_EveryActionDescribePrintsAnAssignmentFor, TestCandidate_CommentRenderingIsNoStatement"} {"area": "mdl/executor", "date": "2026-09-27", "symptom": "describe microflow \u2026 with handles printed `-- handle: commit $Order on error {` for an activity with a custom error handler (TestApp Services.SaveOrder). The handle could not be used: an `alter microflow` target ends at the `{` that opens a fragment, so `insert after commit $Order on error { \u2026 }` parsed the handler brace as the fragment.", "cause": "printedStatement ended an action's statement at a line ending in `;` or `{` and kept the `{`, which belongs to the error-handler block describe opens, not to the statement.", "file": "mdl/executor/cmd_microflows_handles.go", "fix": "printedStatement strips the trailing `{` of an error-handler block opener, so the handle is `commit $Order on error`, which parses as a target and still matches the activity.", "insight": "A handle is only useful if it can be written back as a target in the grammar that consumes it; test a printed handle by parsing it, not only by resolving it.", "test": "TestPrintedStatement_ErrorHandlerBlockOpenerIsNotPartOfTheStatement"} +{"area": "mdl/executor", "date": "2026-09-27", "symptom": "alter microflow (ako/mxcli#736) reported \"Altered microflow\" for statements mx check then rejected: two fragments in one statement declaring the same variable gave CE0111 Duplicate variable name; a fragment reading $X in the same statement as `drop $X` (either order) gave CE0109 Undefined variable. A loop fragment was refused as reading its own iterator, and drop/replace of a Studio Pro loop with more than one body activity was refused by the dangling-reference guard.", "cause": "The scope checks compared each operation with the flow as stored, never with what earlier operations of the same statement had declared, read or removed. The iterator of a fragment's loop is on its LoopSource, not an action output. Loop body flows are stored in the unit's Flows list, not in the loop, so removing the loop left them pointing at removed objects.", "file": "mdl/executor/cmd_alter_flow.go; mdl/backend/mfmutator/splice.go", "fix": "alterFlowContext tracks declaredByOps / readByOps / removedByOps across the statement's operations and checks each later operation against them; checkFragmentScope counts a fragment loop's iterator as its own; Drop and Replace remove graph.bodyFlows of a loop with it.", "insight": "A per-operation check against the stored document is only sound for a one-operation statement; every multi-operation test needs a case where operation 2 depends on operation 1. mx check on a copied TestApp is the cheap oracle: the CE numbers appear the moment a hygiene hole is hit.", "test": "TestAlterMicroflow_PedApp_ScopeSpansTheStatement; TestAlterMicroflow_PedApp_LoopFragmentDeclaresItsIterator; TestSplice_DropOrReplaceALoopTakesItsBodyFlows"} diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go index 69f5897a2..d8aac4309 100644 --- a/mdl/executor/cmd_alter_flow.go +++ b/mdl/executor/cmd_alter_flow.go @@ -61,6 +61,7 @@ func execAlterFlow(ctx *ExecContext, s *ast.AlterFlowStmt) error { if err := mut.Drop(target.ID); err != nil { return fail(err) } + a.noteRemoved(target, nil) continue } frag, err := a.buildFragment(ctx, op.Body) @@ -85,6 +86,10 @@ func execAlterFlow(ctx *ExecContext, s *ast.AlterFlowStmt) error { if err != nil { return fail(err) } + if op.Op == ast.AlterFlowReplace { + a.noteRemoved(target, frag) + } + a.noteFragment(ctx, frag) } if err := mut.Save(); err != nil { return mdlerrors.NewBackend("save altered "+s.Kind(), err) @@ -102,6 +107,52 @@ type alterFlowContext struct { cands []mfmutator.Candidate entityNames map[model.ID]string microflowNames map[model.ID]string + + // What the statement's earlier operations did to the variables, so a later + // one is checked against the flow as it will be written, not as stored: + // the variables their fragments declare, the ones their fragments read + // (with who reads them), and the stored outputs they took away. + declaredByOps map[string]bool + readByOps map[string][]string + removedByOps map[string]bool +} + +// noteRemoved records that target's output is gone, unless the fragment that +// replaces it declares it again. +func (a *alterFlowContext) noteRemoved(target mfmutator.Candidate, replacement *backend.MicroflowFragment) { + v := target.OutputVariable + if v == "" || fragmentDeclares(replacement, v) { + return + } + a.removedByOps[v] = true +} + +// noteFragment records what an inserted or replacing fragment declares and +// reads. +func (a *alterFlowContext) noteFragment(ctx *ExecContext, frag *backend.MicroflowFragment) { + for _, obj := range frag.Objects { + if act, ok := obj.(*microflows.ActionActivity); ok { + if v := mfmutator.OutputVariable(act.Action); v != "" { + a.declaredByOps[v] = true + } + } + text := formatActivity(ctx, obj, a.entityNames, a.microflowNames) + for _, m := range variableRef.FindAllStringSubmatch(text, -1) { + a.readByOps[m[1]] = append(a.readByOps[m[1]], text) + } + } +} + +func fragmentDeclares(frag *backend.MicroflowFragment, v string) bool { + if frag == nil { + return false + } + for _, obj := range frag.Objects { + if act, ok := obj.(*microflows.ActionActivity); ok && mfmutator.OutputVariable(act.Action) == v { + return true + } + } + return false } func loadAlterFlow(ctx *ExecContext, s *ast.AlterFlowStmt) (*alterFlowContext, error) { @@ -109,7 +160,8 @@ func loadAlterFlow(ctx *ExecContext, s *ast.AlterFlowStmt) (*alterFlowContext, e if err != nil { return nil, mdlerrors.NewBackend("build hierarchy", err) } - a := &alterFlowContext{stmt: s, entityNames: getEntityNames(ctx, h)} + a := &alterFlowContext{stmt: s, entityNames: getEntityNames(ctx, h), + declaredByOps: map[string]bool{}, readByOps: map[string][]string{}, removedByOps: map[string]bool{}} // A copy: nanoflow names are added below, and the cached map is shared. a.microflowNames = map[model.ID]string{} for id, n := range getMicroflowNames(ctx, h) { @@ -336,6 +388,14 @@ func (a *alterFlowContext) checkFragmentScope(ctx *ExecContext, op *ast.AlterFlo } own := map[string]bool{} for _, obj := range frag.Objects { + // A loop's iterator exists only inside the loop, which the fragment + // brings along; it reads it there, so it is the fragment's own. + if loop, ok := obj.(*microflows.LoopedActivity); ok { + if src, ok := loop.LoopSource.(*microflows.IterableList); ok && src.VariableName != "" { + own[src.VariableName] = true + } + continue + } act, ok := obj.(*microflows.ActionActivity) if !ok { continue @@ -348,6 +408,9 @@ func (a *alterFlowContext) checkFragmentScope(ctx *ExecContext, op *ast.AlterFlo if existing[v] && !replacingSame { return fmt.Errorf("the fragment declares $%s, which the %s already has; choose another name", v, a.stmt.Kind()) } + if a.declaredByOps[v] { + return fmt.Errorf("the fragment declares $%s, which an earlier operation of this alter already declares; choose another name", v) + } own[v] = true } @@ -367,7 +430,7 @@ func (a *alterFlowContext) checkFragmentScope(ctx *ExecContext, op *ast.AlterFlo for _, obj := range frag.Objects { for _, m := range variableRef.FindAllStringSubmatch(formatActivity(ctx, obj, a.entityNames, a.microflowNames), -1) { v := m[1] - if seen[v] || systemVariables[v] || inScope[v] || own[v] { + if seen[v] || systemVariables[v] || own[v] || (inScope[v] && !a.removedByOps[v]) { continue } seen[v] = true @@ -415,15 +478,11 @@ func (a *alterFlowContext) upstreamOf(id model.ID, including bool) map[model.ID] // another activity still reads — unless the replacement declares it again. func (a *alterFlowContext) checkOutputUnused(target mfmutator.Candidate, replacement *backend.MicroflowFragment) error { v := target.OutputVariable - if v == "" { + if v == "" || fragmentDeclares(replacement, v) { return nil } - if replacement != nil { - for _, obj := range replacement.Objects { - if act, ok := obj.(*microflows.ActionActivity); ok && mfmutator.OutputVariable(act.Action) == v { - return nil - } - } + if readers := a.readByOps[v]; len(readers) > 0 { + return fmt.Errorf("$%s is read by what an earlier operation of this alter adds: %s", v, strings.Join(readers, "; ")) } ref := regexp.MustCompile(`\$` + regexp.QuoteMeta(v) + `\b`) var users []string diff --git a/mdl/executor/cmd_alter_flow_pedapp_test.go b/mdl/executor/cmd_alter_flow_pedapp_test.go index fc14786ea..791677917 100644 --- a/mdl/executor/cmd_alter_flow_pedapp_test.go +++ b/mdl/executor/cmd_alter_flow_pedapp_test.go @@ -569,3 +569,77 @@ func TestAlterNanoflow_PedApp_InsertBefore(t *testing.T) { t.Errorf("describe does not show the declare between the change and the call:\n%s", body) } } + +// The scope check covers the whole statement, not each operation against the +// stored flow alone. Measured with mx check 11.14 on TestApp before the fix: +// two inserts declaring the same variable gave CE0111 "Duplicate variable +// name", and an insert reading a variable a drop in the same statement took +// away gave CE0109 "Undefined variable" — both after "Altered microflow". +func TestAlterMicroflow_PedApp_ScopeSpansTheStatement(t *testing.T) { + t.Run("two fragments declare the same variable", func(t *testing.T) { + exec, _ := openPedAppFixture(t) + _, raw := valFeedbackUnit(t, exec) + err := afRun(t, exec, `alter microflow FeedbackModule.VAL_Feedback { + insert after $IsValidEmail { declare $Dup Boolean = true; } + insert after $ValidFeedback { declare $Dup Boolean = false; } + };`) + if err == nil || !strings.Contains(err.Error(), "$Dup") { + t.Fatalf("want the clash on $Dup refused, got %v", err) + } + if _, after := valFeedbackUnit(t, exec); !bytes.Equal(raw, after) { + t.Error("a refused alter changed the stored unit") + } + }) + + // SUB_Feedback_Sanitize reads its nine $Sanitized* outputs in one change; + // replacing that change first leaves $SanitizedPageName unread, so only a + // fragment of the second statement reads it. + const unread = `alter microflow FeedbackModule.SUB_Feedback_Sanitize { + replace change $Feedback (Subject = $SanitizedSubject, Description = $SanitizedDescription, SubmitterUUID = $SanitizedSubmitterUUID, SubmitterEmail = $SanitizedSubmitterEmail, SubmitterDisplayName = $SanitizedSubmitterDisplayName, ActiveUserRoles = $SanitizedActiveUserRoles, PageName = $SanitizedPageName, Browser = $SanitizedBrowser, EnvironmentURL = $SanitizedEnvironmentURL) with { log info 'sanitized'; } + };` + const use = `insert after $SanitizedSubmitterUUID { log info 'page {1}' with ({1} = $SanitizedPageName); }` + const drop = `drop $SanitizedPageName;` + for name, ops := range map[string]string{ + "insert a reader, then drop the producer": use + "\n" + drop, + "drop the producer, then insert a reader": drop + "\n" + use, + } { + t.Run(name, func(t *testing.T) { + exec, _ := openPedAppFixture(t) + if err := afRun(t, exec, unread); err != nil { + t.Fatalf("setup: %v", err) + } + err := afRun(t, exec, "alter microflow FeedbackModule.SUB_Feedback_Sanitize {\n"+ops+"\n};") + if err == nil || !strings.Contains(err.Error(), "$SanitizedPageName") { + t.Fatalf("want the read of a dropped $SanitizedPageName refused, got %v", err) + } + }) + } + // Control: each operation on its own is accepted. + t.Run("control", func(t *testing.T) { + for _, op := range []string{use, drop} { + exec, _ := openPedAppFixture(t) + if err := afRun(t, exec, unread); err != nil { + t.Fatalf("setup: %v", err) + } + if err := afRun(t, exec, "alter microflow FeedbackModule.SUB_Feedback_Sanitize {\n"+op+"\n};"); err != nil { + t.Errorf("%s: %v", op, err) + } + } + }) +} + +// A loop in the fragment declares its iterator; the scope check must count it +// as the fragment's own, or every loop fragment is refused as reading an +// undeclared variable. +func TestAlterMicroflow_PedApp_LoopFragmentDeclaresItsIterator(t *testing.T) { + exec, _ := openPedAppFixture(t) + err := afRun(t, exec, `alter microflow FeedbackModule.VAL_Feedback { + insert after $IsValidEmail { + $Items = create list of FeedbackModule.Feedback; + loop $Item in $Items begin log info 'item'; end loop; + } + };`) + if err != nil { + t.Fatalf("alter: %v", err) + } +} From bba6bd2c82c1555ebdc82f9a7ce276e4246d8026 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:06:23 +0000 Subject: [PATCH 20/37] mdl 1: `;` terminates every statement and `/` is not a terminator (#732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0010 R11. Both are new rejections, so they apply only under the `mdl 1` header (ADR-0011): a headerless script parses as before and check warns MDL-V1-SEMI / MDL-V1-SLASH for each occurrence. A statement rule that consumes its own `;` (create java action … as $$…$$;) counts as terminated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/executor/strict_parsing_test.go | 77 +++++++++++++++++++++++ mdl/visitor/strict_terminators_test.go | 75 ++++++++++++++++++++++ mdl/visitor/trailing_comma_test.go | 2 +- mdl/visitor/visitor_strict_terminators.go | 67 ++++++++++++++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 mdl/executor/strict_parsing_test.go create mode 100644 mdl/visitor/strict_terminators_test.go create mode 100644 mdl/visitor/visitor_strict_terminators.go diff --git a/mdl/executor/strict_parsing_test.go b/mdl/executor/strict_parsing_test.go new file mode 100644 index 000000000..c8c8f0e11 --- /dev/null +++ b/mdl/executor/strict_parsing_test.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "io" + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// The R11 strictness changes (#732), end to end through check and exec. Under +// mdl 0 each script keeps running, and `check` names the construct with its +// registered code as a warning; under mdl 1 the same script does not get past +// the parser, so exec never starts. +var strictParsingCases = []struct { + name, src, code string + refusal string // what the mdl 1 error says +}{ + {"missing semicolon", "show modules\nshow entities;", "MDL-V1-SEMI", "no terminating `;`"}, + {"slash terminator", "show modules;\n/\nshow entities;", "MDL-V1-SLASH", "`/` is not a statement terminator"}, +} + +func TestStrictParsingWarnsUnderMdl0AndRunsTheScript(t *testing.T) { + for _, tc := range strictParsingCases { + t.Run(tc.name, func(t *testing.T) { + prog, errs := visitor.Build(tc.src) + if len(errs) > 0 { + t.Fatalf("mdl 0 must keep parsing it: %v", errs) + } + var warned bool + for _, v := range ValidateProgram(prog, "") { + if v.RuleID == tc.code { + warned = true + if v.Severity != linter.SeverityWarning { + t.Errorf("%s must be a warning under mdl 0, got %v", tc.code, v.Severity) + } + } + } + if !warned { + t.Errorf("check did not report %s", tc.code) + } + + e := New(io.Discard) + var ran int + e.registry.handlers[reflect.TypeOf(&ast.ShowStmt{})] = func(*ExecContext, ast.Statement) error { + ran++ + return nil + } + if err := e.ExecuteProgram(prog); err != nil { + t.Fatal(err) + } + if ran != 2 { + t.Errorf("mdl 0: ran %d statements, want 2", ran) + } + }) + } +} + +func TestStrictParsingRefusedUnderMdl1(t *testing.T) { + for _, tc := range strictParsingCases { + t.Run(tc.name, func(t *testing.T) { + _, errs := visitor.Build("mdl 1;\n" + tc.src) + var refused bool + for _, err := range errs { + refused = refused || strings.Contains(err.Error(), tc.refusal) + } + if !refused { + t.Fatalf("mdl 1 must refuse it with %q, got %v\n%s", tc.refusal, errs, tc.src) + } + }) + } +} diff --git a/mdl/visitor/strict_terminators_test.go b/mdl/visitor/strict_terminators_test.go new file mode 100644 index 000000000..5cbf42668 --- /dev/null +++ b/mdl/visitor/strict_terminators_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + "testing" +) + +func notesWithCode(t *testing.T, src, code string) []string { + t.Helper() + prog, errs := Build(src) + if len(errs) > 0 { + t.Fatalf("mdl 0 must keep accepting it, got errors: %v\n%s", errs, src) + } + var out []string + for _, n := range prog.LanguageNotes { + if n.Code == code { + out = append(out, n.Message) + } + } + return out +} + +// Under mdl 0 a missing `;` keeps parsing and warns (MDL-V1-SEMI); under mdl 1 +// it is an error. A terminated script is the control: no note, no error. +func TestSemicolonRequiredUnderMdl1(t *testing.T) { + src := "show entities\nshow modules;" + if got := notesWithCode(t, src, "MDL-V1-SEMI"); len(got) != 1 { + t.Fatalf("mdl 0: want one MDL-V1-SEMI warning, for line 1, got %v", got) + } + _, errs := Build("mdl 1;\n" + src) + if len(errs) != 1 || !strings.Contains(errs[0].Error(), "no terminating `;`") { + t.Fatalf("mdl 1: want one missing-`;` error, got %v", errs) + } + if !strings.Contains(errs[0].Error(), "line 2:") { + t.Errorf("mdl 1: the error must name the statement's line, got %v", errs[0]) + } + + // Control: every statement terminated. + if got := notesWithCode(t, "show entities;\nshow modules;", "MDL-V1-SEMI"); len(got) != 0 { + t.Fatalf("mdl 0: a terminated script warned: %v", got) + } + if _, errs := Build("mdl 1;\nshow entities;\nshow modules;"); len(errs) != 0 { + t.Fatalf("mdl 1: a terminated script was refused: %v", errs) + } +} + +// A statement rule that consumes its own `;` (a java action body) is +// terminated; it must not be reported as missing one. +func TestSemicolonConsumedByTheStatementRule(t *testing.T) { + src := "mdl 1;\ncreate java action M.J () returns Boolean as $$return true;$$;\nshow modules;" + if _, errs := Build(src); len(errs) != 0 { + t.Fatalf("a statement ending in its own `;` was refused: %v", errs) + } +} + +// The SQL*Plus `/` terminator: accepted with a warning under mdl 0, an error +// under mdl 1. +func TestSlashTerminatorRejectedUnderMdl1(t *testing.T) { + src := "create persistent entity M.Note (Text: String(200))\n/\nshow modules;" + if got := notesWithCode(t, src, "MDL-V1-SLASH"); len(got) != 1 { + t.Fatalf("mdl 0: want one MDL-V1-SLASH warning, got %v", got) + } + _, errs := Build("mdl 1;\n" + src) + var slash int + for _, e := range errs { + if strings.Contains(e.Error(), "`/` is not a statement terminator") { + slash++ + } + } + if slash != 1 { + t.Fatalf("mdl 1: want one `/` error, got %v", errs) + } +} diff --git a/mdl/visitor/trailing_comma_test.go b/mdl/visitor/trailing_comma_test.go index 882465099..309c345df 100644 --- a/mdl/visitor/trailing_comma_test.go +++ b/mdl/visitor/trailing_comma_test.go @@ -22,7 +22,7 @@ var trailingCommaCases = map[string]string{ "microflow parameters": "create microflow M.F ($A: String, $B: Integer,@) begin end;", "java action parameters": "create java action M.J (A: String,@) returns Boolean as $$return true;$$;", "call arguments": "create microflow M.F () begin call microflow M.G(A = 1, B = 2,@); end;", - "page parameters": "create page M.P (Params: { $A: M.E,@ }, Title: 'P', Layout: Atlas_Core.Atlas_Default) {}", + "page parameters": "create page M.P (Params: { $A: M.E,@ }, Title: 'P', Layout: Atlas_Core.Atlas_Default) {};", "rest client properties": "create rest client M.Api (BaseUrl: 'https://x', Authentication: none,@) { operation Get { Method: get, Path: '/x', Response: none,@ } };", "rest client headers": "create rest client M.Api (BaseUrl: 'https://x', Authentication: none) { operation Get { Method: get, Path: '/x', Headers: ('A' = 'b',@), Response: none } };", "published rest": "create published rest service M.S (Path: 'rest/s', Version: '1.0',@) { };", diff --git a/mdl/visitor/visitor_strict_terminators.go b/mdl/visitor/visitor_strict_terminators.go new file mode 100644 index 000000000..3e9d18490 --- /dev/null +++ b/mdl/visitor/visitor_strict_terminators.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "fmt" + + "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/langver" +) + +// ADR-0010 R11: `;` terminates every statement, and the SQL*Plus `/` line is +// not a terminator. The grammar keeps both optional (`SEMICOLON? SLASH?`) so a +// headerless script parses exactly as before; the version decides here. + +// semicolonRequired is the new rejection of a statement without `;`. +var semicolonRequired = langver.Change{ + Code: "MDL-V1-SEMI", + Since: langver.V1, + Old: "a statement without a terminating `;` is accepted", + New: "an error: every statement ends with `;`", +} + +// slashIsNotATerminator is the new rejection of the SQL*Plus `/` line. +var slashIsNotATerminator = langver.Change{ + Code: "MDL-V1-SLASH", + Since: langver.V1, + Old: "a `/` after a statement is accepted as a terminator (SQL*Plus style)", + New: "an error: `;` is the only statement terminator", +} + +// ExitStatement applies R11's terminator rules to one top-level statement. +func (b *Builder) ExitStatement(ctx *parser.StatementContext) { + if !endsWithSemicolon(ctx) { + if at := ctx.GetStop(); at != nil && b.gate(semicolonRequired, ctx) { + b.addError(fmt.Errorf("line %d: the statement ending at %q has no terminating `;`: "+ + "under %s every statement ends with `;`", at.GetLine(), at.GetText(), b.langVersion)) + } + } + if s := ctx.SLASH(); s != nil && b.gate(slashIsNotATerminator, ctx) { + b.addError(fmt.Errorf("line %d: `/` is not a statement terminator under %s; end the statement "+ + "with `;` and delete the `/` line", s.GetSymbol().GetLine(), b.langVersion)) + } +} + +// endsWithSemicolon reports whether the statement is terminated by `;`: its own +// SEMICOLON, or one a statement rule consumed itself (`create java action … +// as $$…$$;` ends in `SEMICOLON?` inside its rule, before the statement's). +func endsWithSemicolon(ctx *parser.StatementContext) bool { + if ctx.SEMICOLON() != nil { + return true + } + for i := ctx.GetChildCount() - 1; i >= 0; i-- { + switch c := ctx.GetChild(i).(type) { + case antlr.TerminalNode: + if c.GetSymbol().GetTokenType() == parser.MDLParserSLASH { + continue + } + return c.GetSymbol().GetTokenType() == parser.MDLParserSEMICOLON + case antlr.ParserRuleContext: + stop := c.GetStop() + return stop != nil && stop.GetTokenType() == parser.MDLParserSEMICOLON + } + } + return false +} From 381043627c0c4da2b733b448c66a450633007856 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:11:32 +0000 Subject: [PATCH 21/37] mdl 1: '' is the only string escape; a backslash is an ordinary character (#732) ADR-0010 R11. Under mdl 0 \n, \t, \r, \\ and \' in a string literal are escapes, which contradicts Mendix expressions and makes 'C:\temp' a tab. It changes what text means, so it is tied to the header (ADR-0011): a headerless script keeps the old value, and check warns MDL-V1-ESCAPE for each literal whose value would change. The rule decides where a literal ends ('C:\' is complete under mdl 1, unterminated under mdl 0), so it is fixed before lexing: langver.ScanHeader reads the header from the source, and an mdl 1 script is lexed from a StrictEscapeStream, which a lexer predicate on STRING_LITERAL checks. Every token keeps its stream, so the visitor reads each literal under the rule it was lexed with: the 242 unquoteString(x.GetText()) calls become unquoteStringLit(x), and a test keeps a new call from reading token text with mdl 0's escapes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/executor/strict_parsing_test.go | 45 +++++- mdl/grammar/MDLLexer.g4 | 24 +++- mdl/langver/langver.go | 72 ++++++++++ mdl/langver/langver_test.go | 22 +++ mdl/visitor/string_escapes_test.go | 138 +++++++++++++++++++ mdl/visitor/visitor.go | 4 +- mdl/visitor/visitor_agenteditor.go | 24 ++-- mdl/visitor/visitor_alter_agenteditor.go | 2 +- mdl/visitor/visitor_alter_page.go | 12 +- mdl/visitor/visitor_annotation.go | 2 +- mdl/visitor/visitor_association.go | 4 +- mdl/visitor/visitor_connection.go | 2 +- mdl/visitor/visitor_datatransformer.go | 6 +- mdl/visitor/visitor_dbconnection.go | 18 +-- mdl/visitor/visitor_entity.go | 18 +-- mdl/visitor/visitor_enumeration.go | 12 +- mdl/visitor/visitor_helpers.go | 18 +-- mdl/visitor/visitor_imagecollection.go | 8 +- mdl/visitor/visitor_import_export_mapping.go | 4 +- mdl/visitor/visitor_javaaction.go | 12 +- mdl/visitor/visitor_jsonstructure.go | 10 +- mdl/visitor/visitor_menu.go | 2 +- mdl/visitor/visitor_messagedefinition.go | 6 +- mdl/visitor/visitor_microflow.go | 16 +-- mdl/visitor/visitor_microflow_actions.go | 12 +- mdl/visitor/visitor_microflow_expression.go | 2 +- mdl/visitor/visitor_microflow_statements.go | 4 +- mdl/visitor/visitor_microflow_workflow.go | 2 +- mdl/visitor/visitor_module_settings.go | 12 +- mdl/visitor/visitor_navigation.go | 4 +- mdl/visitor/visitor_odata.go | 12 +- mdl/visitor/visitor_page_v3.go | 44 +++--- mdl/visitor/visitor_query.go | 22 +-- mdl/visitor/visitor_queue.go | 4 +- mdl/visitor/visitor_regularexpression.go | 4 +- mdl/visitor/visitor_rest.go | 30 ++-- mdl/visitor/visitor_scheduledevent.go | 4 +- mdl/visitor/visitor_security.go | 12 +- mdl/visitor/visitor_settings.go | 20 +-- mdl/visitor/visitor_sql.go | 4 +- mdl/visitor/visitor_string_escapes.go | 100 ++++++++++++++ mdl/visitor/visitor_styling.go | 8 +- mdl/visitor/visitor_translations.go | 4 +- mdl/visitor/visitor_validationrule.go | 2 +- mdl/visitor/visitor_widgets.go | 6 +- mdl/visitor/visitor_workflow.go | 96 ++++++------- mdl/visitor/visitor_xpath.go | 2 +- 47 files changed, 640 insertions(+), 251 deletions(-) create mode 100644 mdl/visitor/string_escapes_test.go create mode 100644 mdl/visitor/visitor_string_escapes.go diff --git a/mdl/executor/strict_parsing_test.go b/mdl/executor/strict_parsing_test.go index c8c8f0e11..af597d525 100644 --- a/mdl/executor/strict_parsing_test.go +++ b/mdl/executor/strict_parsing_test.go @@ -23,6 +23,21 @@ var strictParsingCases = []struct { }{ {"missing semicolon", "show modules\nshow entities;", "MDL-V1-SEMI", "no terminating `;`"}, {"slash terminator", "show modules;\n/\nshow entities;", "MDL-V1-SLASH", "`/` is not a statement terminator"}, + // A change of meaning, not a rejection: see TestStringEscapeUnderEachVersion. + {"backslash escape", "show modules;\ncreate persistent entity M.N (T: String(20) default 'C:\\temp');", "MDL-V1-ESCAPE", ""}, +} + +// countStatements registers handlers that count what exec runs. +func countStatements(e *Executor, ran *int, defaults *[]any) { + e.registry.handlers[reflect.TypeOf(&ast.ShowStmt{})] = func(*ExecContext, ast.Statement) error { + *ran++ + return nil + } + e.registry.handlers[reflect.TypeOf(&ast.CreateEntityStmt{})] = func(_ *ExecContext, s ast.Statement) error { + *ran++ + *defaults = append(*defaults, s.(*ast.CreateEntityStmt).Attributes[0].DefaultValue) + return nil + } } func TestStrictParsingWarnsUnderMdl0AndRunsTheScript(t *testing.T) { @@ -47,10 +62,8 @@ func TestStrictParsingWarnsUnderMdl0AndRunsTheScript(t *testing.T) { e := New(io.Discard) var ran int - e.registry.handlers[reflect.TypeOf(&ast.ShowStmt{})] = func(*ExecContext, ast.Statement) error { - ran++ - return nil - } + var defaults []any + countStatements(e, &ran, &defaults) if err := e.ExecuteProgram(prog); err != nil { t.Fatal(err) } @@ -63,6 +76,9 @@ func TestStrictParsingWarnsUnderMdl0AndRunsTheScript(t *testing.T) { func TestStrictParsingRefusedUnderMdl1(t *testing.T) { for _, tc := range strictParsingCases { + if tc.refusal == "" { + continue + } t.Run(tc.name, func(t *testing.T) { _, errs := visitor.Build("mdl 1;\n" + tc.src) var refused bool @@ -75,3 +91,24 @@ func TestStrictParsingRefusedUnderMdl1(t *testing.T) { }) } } + +// Under mdl 0 exec stores the backslash escape's old value (a tab); under +// mdl 1 the backslash is the character written. +func TestStringEscapeUnderEachVersion(t *testing.T) { + for header, want := range map[string]string{"": "C:\temp", "mdl 1;\n": `C:\temp`} { + prog, errs := visitor.Build(header + "create persistent entity M.N (T: String(20) default 'C:\\temp');") + if len(errs) > 0 { + t.Fatal(errs) + } + e := New(io.Discard) + var ran int + var defaults []any + countStatements(e, &ran, &defaults) + if err := e.ExecuteProgram(prog); err != nil { + t.Fatal(err) + } + if len(defaults) != 1 || defaults[0] != want { + t.Errorf("%q: exec saw default %q, want %q", header, defaults, want) + } + } +} diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index 91636b109..ca6cc6bb5 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -12,6 +12,21 @@ lexer grammar MDLLexer; // code, so these are methods on the generated lexer; the predicates below call // them through the receiver `p` the generator names. @lexer::members { +// StrictEscapeStream is the character stream of a script written in mdl 1 or +// later, where `''` is the only escape in a string literal and a backslash is +// an ordinary character (ADR-0010 R11). The escape rule changes where a +// literal ENDS — `'C:\'` is complete under mdl 1 and unterminated under mdl +// 0 — so it has to be decided before lexing, from the `mdl <n>;` header, and +// travel with the stream: every token keeps its stream, which is how the +// visitor reads a literal's value under the same rule it was lexed with. +type StrictEscapeStream struct{ antlr.CharStream } + +// HasStrictEscapes reports whether in is lexed under mdl 1's string rules. +func HasStrictEscapes(in antlr.CharStream) bool { + _, ok := in.(*StrictEscapeStream) + return ok +} + // isTrailingComma reports whether the ',' just matched ends a bracketed list: // the next significant character closes a (), {} or [] (whitespace and // comments skipped), and the one before it neither opens a list nor is another @@ -951,9 +966,14 @@ HASH: '#'; // Mendix token: [%TokenName%] or [%'literal'%] MENDIX_TOKEN: '[%' .*? '%]'; -// String literals (single-quoted, with escape support) +// String literals, single-quoted. `''` is an apostrophe under every language +// version. Under mdl 0 a backslash also escapes the next character (`\'`, +// `\n`, `\\`, …); from mdl 1 it is an ordinary character, as in a Mendix +// expression, so `'C:\temp'` is the path it looks like (ADR-0010 R11). Which +// rule applies is fixed by the stream the script is lexed from: see +// StrictEscapeStream. STRING_LITERAL - : '\'' ( ~['\\] | '\\' . | '\'\'' )* '\'' + : '\'' ( ~['\\] | '\'\'' | '\\' {!HasStrictEscapes(p.GetInputStream())}? . | '\\' {HasStrictEscapes(p.GetInputStream())}? )* '\'' ; // Dollar-quoted string literal (PostgreSQL style) for embedding code blocks diff --git a/mdl/langver/langver.go b/mdl/langver/langver.go index 2a814feac..07ee98bc7 100644 --- a/mdl/langver/langver.go +++ b/mdl/langver/langver.go @@ -22,6 +22,8 @@ package langver import ( "fmt" "regexp" + "strconv" + "strings" ) // Version is an MDL language version, the number in `mdl <n>;`. @@ -132,3 +134,73 @@ func (c Change) Warning(v Version) string { "Start the script with `%s;` to opt in, or keep this meaning explicitly.", c.Old, v, c.Since, c.New, c.Since) } + +// ScanHeader reads the language version from the `mdl <n>;` header src starts +// with, before it is parsed. It is for the one decision that has to be made +// before lexing: under mdl 1 a backslash in a string literal is an ordinary +// character, which changes where the literal ends (ADR-0010 R11). +// +// It reads the header exactly as the grammar does — the first statement, +// after any whitespace, `--` and `/* */` comments, with the same between its +// tokens — and returns V0 when there is none. A header naming a version this +// mxcli does not know also returns V0: the parser refuses the script anyway. +func ScanHeader(src string) Version { + i := skipTrivia(src, 0) + j := i + for j < len(src) && isIdentByte(src[j]) { + j++ + } + if !strings.EqualFold(src[i:j], "mdl") { + return V0 + } + i = skipTrivia(src, j) + j = i + for j < len(src) && src[j] >= '0' && src[j] <= '9' { + j++ + } + if j == i || (j < len(src) && (isIdentByte(src[j]) || src[j] == '.')) { + return V0 + } + n, err := strconv.Atoi(src[i:j]) + if err != nil { + return V0 + } + if k := skipTrivia(src, j); k >= len(src) || src[k] != ';' { + return V0 + } + if v := Version(n); v.Known() { + return v + } + return V0 +} + +// skipTrivia returns the index of the first byte at or after i that is not +// whitespace or inside a `--` or `/* */` comment. A `/** */` doc comment is a +// token, not trivia, so it stops the scan. +func skipTrivia(src string, i int) int { + for i < len(src) { + switch { + case src[i] == ' ' || src[i] == '\t' || src[i] == '\r' || src[i] == '\n' || src[i] == '\v' || src[i] == '\f': + i++ + case strings.HasPrefix(src[i:], "--"): + if nl := strings.IndexByte(src[i:], '\n'); nl >= 0 { + i += nl + 1 + } else { + return len(src) + } + case strings.HasPrefix(src[i:], "/*") && !strings.HasPrefix(src[i:], "/**"): + end := strings.Index(src[i+2:], "*/") + if end < 0 { + return len(src) + } + i += 2 + end + 2 + default: + return i + } + } + return i +} + +func isIdentByte(c byte) bool { + return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c >= 0x80 +} diff --git a/mdl/langver/langver_test.go b/mdl/langver/langver_test.go index 869257a0c..659bc87ba 100644 --- a/mdl/langver/langver_test.go +++ b/mdl/langver/langver_test.go @@ -64,3 +64,25 @@ func TestIsHeaderLine(t *testing.T) { } } } + +func TestScanHeader(t *testing.T) { + for src, want := range map[string]Version{ + "mdl 1;\nshow entities;": V1, + "MDL 1 ;": V1, + " -- a note\n/* block */\nmdl\n1\n;": V1, + "mdl /* between */ 1 -- and here\n;": V1, + "show entities;": V0, + "": V0, + "mdl 1": V0, // no `;`: not a header + "mdl 1.5;": V0, + "mdl 99;": V0, // unknown: the parser refuses it + "mdlx 1;": V0, + "/** doc */ mdl 1;": V0, // a doc comment is a token + "create entity mdl.X ( A: String );\nmdl 1;": V0, // only the first statement + "mdl 0;": V0, + } { + if got := ScanHeader(src); got != want { + t.Errorf("ScanHeader(%q) = %v, want %v", src, got, want) + } + } +} diff --git a/mdl/visitor/string_escapes_test.go b/mdl/visitor/string_escapes_test.go new file mode 100644 index 000000000..3a756affa --- /dev/null +++ b/mdl/visitor/string_escapes_test.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// defaultOf builds a one-attribute entity whose default is lit and returns the +// default's value and the MDL-V1-ESCAPE notes. +func defaultOf(t *testing.T, header, lit string) (any, []ast.LanguageNote, []error) { + t.Helper() + prog, errs := Build(header + "create persistent entity M.Note (Text: String(200) default " + lit + ");") + if len(errs) > 0 { + return nil, nil, errs + } + var notes []ast.LanguageNote + for _, n := range prog.LanguageNotes { + if n.Code == "MDL-V1-ESCAPE" { + notes = append(notes, n) + } + } + e := prog.Statements[len(prog.Statements)-1].(*ast.CreateEntityStmt) + return e.Attributes[0].DefaultValue, notes, nil +} + +// A doubled apostrophe is the only escape under mdl 1; under mdl 0 a backslash still escapes +// and each literal whose value would change warns MDL-V1-ESCAPE (#732). +func TestStringEscapesByVersion(t *testing.T) { + for _, tc := range []struct { + lit string + mdl0, mdl1 string + warns bool // under mdl 0 + }{ + {`'C:\temp'`, "C:\temp", `C:\temp`, true}, + {`'a\nb'`, "a\nb", `a\nb`, true}, + {`'one\\two'`, `one\two`, `one\\two`, true}, + // Control: no backslash, or one before a character mdl 0 keeps as + // written, means the same under both, so nothing to warn about. + {`'it''s'`, "it's", "it's", false}, + {`'C:\data'`, `C:\data`, `C:\data`, false}, + } { + got, notes, errs := defaultOf(t, "", tc.lit) + if len(errs) > 0 { + t.Fatalf("mdl 0 %s: %v", tc.lit, errs) + } + if got != tc.mdl0 { + t.Errorf("mdl 0 %s: got %q, want %q", tc.lit, got, tc.mdl0) + } + if (len(notes) == 1) != tc.warns || len(notes) > 1 { + t.Errorf("mdl 0 %s: want warning=%v, got %v", tc.lit, tc.warns, notes) + } + got, notes, errs = defaultOf(t, "mdl 1;\n", tc.lit) + if len(errs) > 0 { + t.Fatalf("mdl 1 %s: %v", tc.lit, errs) + } + if got != tc.mdl1 { + t.Errorf("mdl 1 %s: got %q, want %q", tc.lit, got, tc.mdl1) + } + if len(notes) != 0 { + t.Errorf("mdl 1 %s: the new meaning must not warn: %v", tc.lit, notes) + } + } +} + +// The escape rule decides where a literal ends. Under mdl 1 `'C:\'` is a +// complete literal and `\'` does not escape the quote; under mdl 0 it is the +// other way round. +func TestStringEscapesDecideWhereALiteralEnds(t *testing.T) { + got, _, errs := defaultOf(t, "mdl 1;\n", `'C:\'`) + if len(errs) > 0 || got != `C:\` { + t.Fatalf("mdl 1: 'C:\\' must be the string C:\\, got %q %v", got, errs) + } + if _, _, errs := defaultOf(t, "", `'C:\'`); len(errs) == 0 { + t.Fatal(`mdl 0: 'C:\' must stay unterminated`) + } + + got, notes, errs := defaultOf(t, "", `'it\'s'`) + if len(errs) > 0 || got != "it's" || len(notes) != 1 { + t.Fatalf("mdl 0: 'it\\'s' must stay it's and warn, got %q %v %v", got, notes, errs) + } + _, _, errs = defaultOf(t, "mdl 1;\n", `'it\'s'`) + if len(errs) == 0 { + t.Fatal(`mdl 1: 'it\'s' must be refused — the literal ends at \'`) + } +} + +// The warning names the line of the literal, in source order among the other +// language notes. +func TestStringEscapeWarningLine(t *testing.T) { + prog, errs := Build("show modules\n;\ncreate persistent entity M.Note (\n Text: String(200) default 'a\\tb'\n);") + if len(errs) > 0 { + t.Fatal(errs) + } + var lines []int + for _, n := range prog.LanguageNotes { + if n.Code == "MDL-V1-ESCAPE" { + lines = append(lines, n.Line) + if !strings.Contains(n.Message, `'a\tb'`) { + t.Errorf("the warning must quote the literal: %s", n.Message) + } + } + } + if len(lines) != 1 || lines[0] != 4 { + t.Fatalf("want one MDL-V1-ESCAPE on line 4, got %v", lines) + } +} + +// Every string literal the visitor reads goes through unquoteStringLit, which +// knows the escape rule the literal was lexed with. A call to unquoteString on +// a token's text reads an mdl 1 literal with mdl 0's escapes, silently: the +// script parses and the value is wrong. Only the two helpers may call it. +func TestStringLiteralsAreReadUnderTheirEscapeRule(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + allowed := map[string]bool{"visitor_helpers.go": true, "visitor_string_escapes.go": true} + for _, f := range files { + if strings.HasSuffix(f, "_test.go") || allowed[f] { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(src), "\n") { + if strings.Contains(line, "unquoteString(") { + t.Errorf("%s:%d: read a string literal with unquoteStringLit(node), not unquoteString(text)", f, i+1) + } + } + } +} diff --git a/mdl/visitor/visitor.go b/mdl/visitor/visitor.go index c4c4f6bb7..6675b6e75 100644 --- a/mdl/visitor/visitor.go +++ b/mdl/visitor/visitor.go @@ -542,8 +542,7 @@ func build(input string, listen func(*Builder) antlr.ParseTreeListener) (*ast.Pr errListener.source = strings.Split(input, "\n") // Create lexer with custom error listener - is := antlr.NewInputStream(input) - lexer := parser.NewMDLLexer(is) + lexer := parser.NewMDLLexer(newScriptStream(input)) lexer.RemoveErrorListeners() lexer.AddErrorListener(errListener) @@ -557,6 +556,7 @@ func build(input string, listen func(*Builder) antlr.ParseTreeListener) (*ast.Pr builder := NewBuilder() tree := p.Program() antlr.ParseTreeWalkerDefault.Walk(listen(builder), tree) + builder.noteBackslashEscapes(stream.GetAllTokens()) // Combine syntax errors and builder errors allErrors := append(errListener.errors, builder.errors...) diff --git a/mdl/visitor/visitor_agenteditor.go b/mdl/visitor/visitor_agenteditor.go index 1d152eb1f..7e8420d05 100644 --- a/mdl/visitor/visitor_agenteditor.go +++ b/mdl/visitor/visitor_agenteditor.go @@ -20,7 +20,7 @@ func (b *Builder) ExitCreateModelStatement(ctx *parser.CreateModelStatementConte } stmt.Documentation, stmt.DocumentationSet = findDocComment(ctx) if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } for _, p := range ctx.AllModelProperty() { @@ -44,27 +44,27 @@ func (b *Builder) ExitCreateModelStatement(ctx *parser.CreateModelStatementConte } case "displayname": if lit := propCtx.STRING_LITERAL(); lit != nil { - stmt.DisplayName = unquoteString(lit.GetText()) + stmt.DisplayName = unquoteStringLit(lit) } case "keyname": if lit := propCtx.STRING_LITERAL(); lit != nil { - stmt.KeyName = unquoteString(lit.GetText()) + stmt.KeyName = unquoteStringLit(lit) } case "keyid": if lit := propCtx.STRING_LITERAL(); lit != nil { - stmt.KeyID = unquoteString(lit.GetText()) + stmt.KeyID = unquoteStringLit(lit) } case "environment": if lit := propCtx.STRING_LITERAL(); lit != nil { - stmt.Environment = unquoteString(lit.GetText()) + stmt.Environment = unquoteStringLit(lit) } case "resourcename": if lit := propCtx.STRING_LITERAL(); lit != nil { - stmt.ResourceName = unquoteString(lit.GetText()) + stmt.ResourceName = unquoteStringLit(lit) } case "deeplinkurl": if lit := propCtx.STRING_LITERAL(); lit != nil { - stmt.DeepLinkURL = unquoteString(lit.GetText()) + stmt.DeepLinkURL = unquoteStringLit(lit) } } } @@ -93,7 +93,7 @@ func parseModelProps(props []parser.IModelPropertyContext) map[string]string { if qn := pc.QualifiedName(); qn != nil { m[key] = getQualifiedNameText(qn) } else if lit := pc.STRING_LITERAL(); lit != nil { - m[key] = unquoteString(lit.GetText()) + m[key] = unquoteStringLit(lit) } else if num := pc.NUMBER_LITERAL(); num != nil { m[key] = num.GetText() } else if bl := pc.BooleanLiteral(); bl != nil { @@ -131,7 +131,7 @@ func parseVariableDefsFromProps(props []parser.IModelPropertyContext) []ast.Agen // Key is STRING_LITERAL or QUOTED_IDENTIFIER var key string if sl := vdc.STRING_LITERAL(); sl != nil { - key = unquoteString(sl.GetText()) + key = unquoteStringLit(sl) } else if qi := vdc.QUOTED_IDENTIFIER(); qi != nil { key = unquoteIdentifier(qi.GetText()) } @@ -160,7 +160,7 @@ func (b *Builder) ExitCreateConsumedMCPServiceStatement(ctx *parser.CreateConsum } stmt.OuterDocumentation, stmt.DocumentationSet = findDocComment(ctx) if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } props := parseModelProps(ctx.AllModelProperty()) @@ -186,7 +186,7 @@ func (b *Builder) ExitCreateKnowledgeBaseStatement(ctx *parser.CreateKnowledgeBa } stmt.Documentation, stmt.DocumentationSet = findDocComment(ctx) if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } props := parseModelProps(ctx.AllModelProperty()) @@ -217,7 +217,7 @@ func (b *Builder) ExitCreateAgentStatement(ctx *parser.CreateAgentStatementConte } stmt.Documentation, stmt.DocumentationSet = findDocComment(ctx) if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } props := parseModelProps(ctx.AllModelProperty()) diff --git a/mdl/visitor/visitor_alter_agenteditor.go b/mdl/visitor/visitor_alter_agenteditor.go index 377a59fc2..8ed18de9a 100644 --- a/mdl/visitor/visitor_alter_agenteditor.go +++ b/mdl/visitor/visitor_alter_agenteditor.go @@ -149,7 +149,7 @@ func parseAgentEditorAlterAssignments(props []parser.IAgentEditorAlterAssignment func agentEditorAlterValueText(ctx *parser.AgentEditorAlterValueContext) string { if sl := ctx.STRING_LITERAL(); sl != nil { - return unquoteString(sl.GetText()) + return unquoteStringLit(sl) } if num := ctx.NUMBER_LITERAL(); num != nil { return num.GetText() diff --git a/mdl/visitor/visitor_alter_page.go b/mdl/visitor/visitor_alter_page.go index cbc79b2dc..26168005f 100644 --- a/mdl/visitor/visitor_alter_page.go +++ b/mdl/visitor/visitor_alter_page.go @@ -171,7 +171,7 @@ func (b *Builder) buildAlterPageAssignment(ctx *parser.AlterPageAssignmentContex return identifierOrKeywordText(id), buildActionV3(acCtx) } if sl := ctx.STRING_LITERAL(); sl != nil { - return unquoteString(sl.GetText()), buildActionV3(acCtx) + return unquoteStringLit(sl), buildActionV3(acCtx) } return "Action", buildActionV3(acCtx) } @@ -194,7 +194,7 @@ func (b *Builder) buildAlterPageAssignment(ctx *parser.AlterPageAssignmentContex if id := ctx.IdentifierOrKeyword(); id != nil { name = identifierOrKeywordText(id) } else if sl := ctx.STRING_LITERAL(); sl != nil { - name = unquoteString(sl.GetText()) + name = unquoteStringLit(sl) } value := buildPropertyValueV3(ctx.PropertyValueV3()) @@ -275,7 +275,7 @@ func (b *Builder) buildAlterTarget(ctx parser.IAlterTargetContext) ast.WidgetRef } } if sl := tc.STRING_LITERAL(); sl != nil { - ref.Caption = unquoteString(sl.GetText()) + ref.Caption = unquoteStringLit(sl) return ref } ids := tc.AllIdentifierOrKeyword() @@ -357,7 +357,7 @@ func (b *Builder) exitAlterPagesStylingStatement(ctx *parser.AlterPagesStylingSt } if lit := ctx.STRING_LITERAL(); lit != nil { // The WHERE value as a quoted string — a full widget id. - stmt.WidgetType = unquoteString(lit.GetText()) + stmt.WidgetType = unquoteStringLit(lit) } else if len(ids) > 0 { stmt.WidgetType = identifierOrKeywordText(ids[0]) } @@ -368,14 +368,14 @@ func (b *Builder) exitAlterPagesStylingStatement(ctx *parser.AlterPagesStylingSt if len(lits) == 0 { continue } - assignment := ast.StylingAssignment{Property: unquoteString(lits[0].GetText())} + assignment := ast.StylingAssignment{Property: unquoteStringLit(lits[0])} switch { case ac.ON() != nil: assignment.IsToggle, assignment.ToggleOn = true, true case ac.OFF() != nil: assignment.IsToggle, assignment.ToggleOn = true, false case len(lits) > 1: - assignment.Value = unquoteString(lits[1].GetText()) + assignment.Value = unquoteStringLit(lits[1]) } stmt.Assignments = append(stmt.Assignments, assignment) } diff --git a/mdl/visitor/visitor_annotation.go b/mdl/visitor/visitor_annotation.go index da6379959..6c7b7aeab 100644 --- a/mdl/visitor/visitor_annotation.go +++ b/mdl/visitor/visitor_annotation.go @@ -27,7 +27,7 @@ func (b *Builder) ExitCreateAnnotationStatement(ctx *parser.CreateAnnotationStat // single-quoted and single-line, so a dollar-quoted block is accepted // for the caption as it is for Java and JavaScript source. if lit := propCtx.STRING_LITERAL(); lit != nil { - stmt.Caption = unquoteString(lit.GetText()) + stmt.Caption = unquoteStringLit(lit) } else if dollar := propCtx.DOLLAR_STRING(); dollar != nil { stmt.Caption = unquoteDollarString(dollar.GetText()) } diff --git a/mdl/visitor/visitor_association.go b/mdl/visitor/visitor_association.go index b0a68f662..d059f06ad 100644 --- a/mdl/visitor/visitor_association.go +++ b/mdl/visitor/visitor_association.go @@ -86,7 +86,7 @@ func (b *Builder) ExitCreateAssociationStatement(ctx *parser.CreateAssociationSt // COMMENT if optCtx.COMMENT() != nil && optCtx.STRING_LITERAL() != nil { - stmt.Comment = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.Comment = unquoteStringLit(optCtx.STRING_LITERAL()) } } } @@ -283,7 +283,7 @@ func (b *Builder) ExitAlterAssociationAction(ctx *parser.AlterAssociationActionC b.statements = append(b.statements, &ast.AlterAssociationStmt{ Name: name, Operation: ast.AlterAssociationSetComment, - Comment: unquoteString(ctx.STRING_LITERAL().GetText()), + Comment: unquoteStringLit(ctx.STRING_LITERAL()), }) return } diff --git a/mdl/visitor/visitor_connection.go b/mdl/visitor/visitor_connection.go index d3856f859..584738b66 100644 --- a/mdl/visitor/visitor_connection.go +++ b/mdl/visitor/visitor_connection.go @@ -17,7 +17,7 @@ func (b *Builder) ExitConnectStatement(ctx *parser.ConnectStatementContext) { // CONNECT LOCAL 'path' strings := ctx.AllSTRING_LITERAL() if len(strings) > 0 { - path := unquoteString(strings[0].GetText()) + path := unquoteStringLit(strings[0]) b.statements = append(b.statements, &ast.ConnectStmt{Path: path}) } } diff --git a/mdl/visitor/visitor_datatransformer.go b/mdl/visitor/visitor_datatransformer.go index d5d952368..169b20ab9 100644 --- a/mdl/visitor/visitor_datatransformer.go +++ b/mdl/visitor/visitor_datatransformer.go @@ -29,10 +29,10 @@ func (b *Builder) ExitCreateDataTransformerStatement(ctx *parser.CreateDataTrans // direct STRING_LITERALs, so an index would depend on whether a folder was // given. if tok := ctx.GetSource(); tok != nil { - stmt.SourceJSON = unquoteString(tok.GetText()) + stmt.SourceJSON = unquoteStringLit(tok) } if tok := ctx.GetFolder(); tok != nil { - stmt.Folder = unquoteString(tok.GetText()) + stmt.Folder = unquoteStringLit(tok) } // Steps @@ -48,7 +48,7 @@ func (b *Builder) ExitCreateDataTransformerStatement(ctx *parser.CreateDataTrans step.Technology = "XSLT" } if sl := sc.STRING_LITERAL(); sl != nil { - step.Expression = unquoteString(sl.GetText()) + step.Expression = unquoteStringLit(sl) } else if ds := sc.DOLLAR_STRING(); ds != nil { step.Expression = unquoteDollarString(ds.GetText()) } diff --git a/mdl/visitor/visitor_dbconnection.go b/mdl/visitor/visitor_dbconnection.go index 6864c5efb..8de82bc51 100644 --- a/mdl/visitor/visitor_dbconnection.go +++ b/mdl/visitor/visitor_dbconnection.go @@ -17,7 +17,7 @@ func (b *Builder) ExitCreateDatabaseConnectionStatement(ctx *parser.CreateDataba Name: buildQualifiedName(ctx.QualifiedName()), } if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } // Parse options @@ -25,7 +25,7 @@ func (b *Builder) ExitCreateDatabaseConnectionStatement(ctx *parser.CreateDataba opt := optCtx.(*parser.DatabaseConnectionOptionContext) if opt.TYPE() != nil && opt.STRING_LITERAL() != nil { - stmt.DatabaseType = unquoteString(opt.STRING_LITERAL().GetText()) + stmt.DatabaseType = unquoteStringLit(opt.STRING_LITERAL()) } if opt.CONNECTION() != nil && opt.STRING_TYPE() != nil { // CONNECTION STRING <value> @@ -35,17 +35,17 @@ func (b *Builder) ExitCreateDatabaseConnectionStatement(ctx *parser.CreateDataba stmt.ConnectionString = qn.String() stmt.ConnectionStringIsRef = true } else if opt.STRING_LITERAL() != nil { - stmt.ConnectionString = unquoteString(opt.STRING_LITERAL().GetText()) + stmt.ConnectionString = unquoteStringLit(opt.STRING_LITERAL()) } } if opt.HOST() != nil && opt.STRING_LITERAL() != nil { - stmt.Host = unquoteString(opt.STRING_LITERAL().GetText()) + stmt.Host = unquoteStringLit(opt.STRING_LITERAL()) } if opt.PORT() != nil && opt.NUMBER_LITERAL() != nil { stmt.Port, _ = strconv.Atoi(opt.NUMBER_LITERAL().GetText()) } if opt.DATABASE() != nil && opt.STRING_LITERAL() != nil { - stmt.Database = unquoteString(opt.STRING_LITERAL().GetText()) + stmt.Database = unquoteStringLit(opt.STRING_LITERAL()) } if opt.USERNAME() != nil { if opt.AT() != nil && opt.QualifiedName() != nil { @@ -53,7 +53,7 @@ func (b *Builder) ExitCreateDatabaseConnectionStatement(ctx *parser.CreateDataba stmt.UserName = qn.String() stmt.UserNameIsRef = true } else if opt.STRING_LITERAL() != nil { - stmt.UserName = unquoteString(opt.STRING_LITERAL().GetText()) + stmt.UserName = unquoteStringLit(opt.STRING_LITERAL()) } } if opt.PASSWORD() != nil { @@ -62,7 +62,7 @@ func (b *Builder) ExitCreateDatabaseConnectionStatement(ctx *parser.CreateDataba stmt.Password = qn.String() stmt.PasswordIsRef = true } else if opt.STRING_LITERAL() != nil { - stmt.Password = unquoteString(opt.STRING_LITERAL().GetText()) + stmt.Password = unquoteStringLit(opt.STRING_LITERAL()) } } } @@ -86,7 +86,7 @@ func (b *Builder) ExitCreateDatabaseConnectionStatement(ctx *parser.CreateDataba if ds := qc.DOLLAR_STRING(); ds != nil { q.SQL = unquoteDollarString(ds.GetText()) } else if sl := qc.STRING_LITERAL(0); sl != nil { - q.SQL = unquoteString(sl.GetText()) + q.SQL = unquoteStringLit(sl) } // RETURNS entity @@ -122,7 +122,7 @@ func (b *Builder) ExitCreateDatabaseConnectionStatement(ctx *parser.CreateDataba slIdx = defaultIdx // SQL used DOLLAR_STRING } if sl := qc.STRING_LITERAL(slIdx); sl != nil { - paramDef.DefaultValue = unquoteString(sl.GetText()) + paramDef.DefaultValue = unquoteStringLit(sl) } defaultIdx++ } else if hasNull { diff --git a/mdl/visitor/visitor_entity.go b/mdl/visitor/visitor_entity.go index 41cbc4b99..c4ade50eb 100644 --- a/mdl/visitor/visitor_entity.go +++ b/mdl/visitor/visitor_entity.go @@ -675,12 +675,12 @@ func (b *Builder) ExitAlterEntityAction(ctx *parser.AlterEntityActionContext) { case c.NOT_NULL() != nil || (c.NOT() != nil && c.NULL() != nil) || c.REQUIRED() != nil: stmt.ModifyNotNull = &true_ if c.ERROR() != nil && c.STRING_LITERAL() != nil { - stmt.ModifyNotNullError = unquoteString(c.STRING_LITERAL().GetText()) + stmt.ModifyNotNullError = unquoteStringLit(c.STRING_LITERAL()) } case c.UNIQUE() != nil: stmt.ModifyUnique = &true_ if c.ERROR() != nil && c.STRING_LITERAL() != nil { - stmt.ModifyUniqueError = unquoteString(c.STRING_LITERAL().GetText()) + stmt.ModifyUniqueError = unquoteStringLit(c.STRING_LITERAL()) } case c.DEFAULT() != nil: stmt.ModifyHasDefault = true @@ -728,7 +728,7 @@ func (b *Builder) ExitAlterEntityAction(ctx *parser.AlterEntityActionContext) { b.statements = append(b.statements, &ast.AlterEntityStmt{ Name: name, Operation: ast.AlterEntitySetDocumentation, - Documentation: unquoteString(ctx.STRING_LITERAL().GetText()), + Documentation: unquoteStringLit(ctx.STRING_LITERAL()), }) return } @@ -738,7 +738,7 @@ func (b *Builder) ExitAlterEntityAction(ctx *parser.AlterEntityActionContext) { b.statements = append(b.statements, &ast.AlterEntityStmt{ Name: name, Operation: ast.AlterEntitySetComment, - Comment: unquoteString(ctx.STRING_LITERAL().GetText()), + Comment: unquoteStringLit(ctx.STRING_LITERAL()), }) return } @@ -876,7 +876,7 @@ func (b *Builder) ExitDropStatement(ctx *parser.DropStatementContext) { if ctx.CONFIGURATION() != nil { if sl := ctx.STRING_LITERAL(); sl != nil { b.statements = append(b.statements, &ast.DropConfigurationStmt{ - Name: unquoteString(sl.GetText()), + Name: unquoteStringLit(sl), }) } return @@ -898,7 +898,7 @@ func (b *Builder) ExitDropStatement(ctx *parser.DropStatementContext) { stmt.Position = &ast.Position{X: x, Y: y} } } else if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Title = unquoteString(lit.GetText()) + stmt.Title = unquoteStringLit(lit) } b.statements = append(b.statements, stmt) return @@ -1044,7 +1044,7 @@ func (b *Builder) ExitDropStatement(ctx *parser.DropStatementContext) { Name: buildQualifiedName(names[0]), }) } else if ctx.FOLDER() != nil { - folderPath := unquoteString(ctx.STRING_LITERAL().GetText()) + folderPath := unquoteStringLit(ctx.STRING_LITERAL()) // Module can be a qualifiedName or IDENTIFIER var moduleName string if len(names) > 0 { @@ -1141,7 +1141,7 @@ func (b *Builder) ExitMoveStatement(ctx *parser.MoveStatementContext) { // Parse folder path if specified if len(ctx.AllFOLDER()) > 0 && ctx.STRING_LITERAL() != nil { - stmt.Folder = unquoteString(ctx.STRING_LITERAL().GetText()) + stmt.Folder = unquoteStringLit(ctx.STRING_LITERAL()) } // Parse target module if specified (IN Module or just Module) @@ -1168,7 +1168,7 @@ func (b *Builder) exitMoveFolderStatement(ctx *parser.MoveStatementContext, name // Parse target: either FOLDER 'path' [IN Module] or just Module if ctx.STRING_LITERAL() != nil { // MOVE FOLDER ... TO FOLDER 'path' [IN Module] - stmt.TargetFolder = unquoteString(ctx.STRING_LITERAL().GetText()) + stmt.TargetFolder = unquoteStringLit(ctx.STRING_LITERAL()) if len(names) > 1 { stmt.TargetModule = getQualifiedNameText(names[1]) } else if ctx.IDENTIFIER() != nil { diff --git a/mdl/visitor/visitor_enumeration.go b/mdl/visitor/visitor_enumeration.go index 19bace1aa..520702714 100644 --- a/mdl/visitor/visitor_enumeration.go +++ b/mdl/visitor/visitor_enumeration.go @@ -19,7 +19,7 @@ func (b *Builder) ExitCreateEnumerationStatement(ctx *parser.CreateEnumerationSt for _, opt := range optsCtx.AllEnumerationOption() { optCtx := opt.(*parser.EnumerationOptionContext) if optCtx.FOLDER() != nil && optCtx.STRING_LITERAL() != nil { - stmt.Folder = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.Folder = unquoteStringLit(optCtx.STRING_LITERAL()) } } } @@ -54,7 +54,7 @@ func (b *Builder) ExitAlterEnumerationAction(ctx *parser.AlterEnumerationActionC if ctx.ADD() != nil && len(ids) >= 1 { caption := "" if ctx.STRING_LITERAL() != nil { - caption = unquoteString(ctx.STRING_LITERAL().GetText()) + caption = unquoteStringLit(ctx.STRING_LITERAL()) } b.statements = append(b.statements, &ast.AlterEnumerationStmt{ Name: name, @@ -82,7 +82,7 @@ func (b *Builder) ExitAlterEnumerationAction(ctx *parser.AlterEnumerationActionC Name: name, Operation: ast.AlterEnumModifyCaption, ValueName: ids[0].GetText(), - Caption: unquoteString(ctx.STRING_LITERAL().GetText()), + Caption: unquoteStringLit(ctx.STRING_LITERAL()), }) } } @@ -114,12 +114,12 @@ func (b *Builder) ExitCreateConstantStatement(ctx *parser.CreateConstantStatemen for _, opt := range optsCtx.AllConstantOption() { optCtx := opt.(*parser.ConstantOptionContext) if optCtx.COMMENT() != nil && optCtx.STRING_LITERAL() != nil { - stmt.Comment = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.Comment = unquoteStringLit(optCtx.STRING_LITERAL()) } if optCtx.FOLDER() != nil && optCtx.STRING_LITERAL() != nil { - stmt.Folder = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.Folder = unquoteStringLit(optCtx.STRING_LITERAL()) } else if optCtx.FOLDER() != nil && optCtx.STRING_LITERAL() != nil { - stmt.Folder = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.Folder = unquoteStringLit(optCtx.STRING_LITERAL()) } else if optCtx.EXPOSED() != nil { stmt.ExposedToClient = true } diff --git a/mdl/visitor/visitor_helpers.go b/mdl/visitor/visitor_helpers.go index a7c49b09c..4a3a52f57 100644 --- a/mdl/visitor/visitor_helpers.go +++ b/mdl/visitor/visitor_helpers.go @@ -145,7 +145,7 @@ func buildEnumValues(ctx parser.IEnumerationValueListContext, b *Builder) []ast. enumVal.Documentation = extractDocComment(docCtx.GetText()) } if ev.STRING_LITERAL() != nil { - enumVal.Caption = unquoteString(ev.STRING_LITERAL().GetText()) + enumVal.Caption = unquoteStringLit(ev.STRING_LITERAL()) } values = append(values, enumVal) } @@ -192,14 +192,14 @@ func buildAttributes(ctx parser.IAttributeDefinitionListContext, b *Builder) []a attr.NotNull = true // Extract error message if present if c.ERROR() != nil && c.STRING_LITERAL() != nil { - attr.NotNullError = unquoteString(c.STRING_LITERAL().GetText()) + attr.NotNullError = unquoteStringLit(c.STRING_LITERAL()) } } if c.UNIQUE() != nil { attr.Unique = true // Extract error message if present if c.ERROR() != nil && c.STRING_LITERAL() != nil { - attr.UniqueError = unquoteString(c.STRING_LITERAL().GetText()) + attr.UniqueError = unquoteStringLit(c.STRING_LITERAL()) } } if c.DEFAULT() != nil { @@ -215,7 +215,7 @@ func buildAttributes(ctx parser.IAttributeDefinitionListContext, b *Builder) []a attr.NotNull = true // Extract error message if present if c.ERROR() != nil && c.STRING_LITERAL() != nil { - attr.NotNullError = unquoteString(c.STRING_LITERAL().GetText()) + attr.NotNullError = unquoteStringLit(c.STRING_LITERAL()) } } if c.CALCULATED() != nil { @@ -253,13 +253,13 @@ func buildSingleAttribute(a *parser.AttributeDefinitionContext) *ast.Attribute { if c.NOT() != nil && c.NULL() != nil || c.NOT_NULL() != nil { attr.NotNull = true if c.ERROR() != nil && c.STRING_LITERAL() != nil { - attr.NotNullError = unquoteString(c.STRING_LITERAL().GetText()) + attr.NotNullError = unquoteStringLit(c.STRING_LITERAL()) } } if c.UNIQUE() != nil { attr.Unique = true if c.ERROR() != nil && c.STRING_LITERAL() != nil { - attr.UniqueError = unquoteString(c.STRING_LITERAL().GetText()) + attr.UniqueError = unquoteStringLit(c.STRING_LITERAL()) } } if c.DEFAULT() != nil { @@ -273,7 +273,7 @@ func buildSingleAttribute(a *parser.AttributeDefinitionContext) *ast.Attribute { if c.REQUIRED() != nil { attr.NotNull = true if c.ERROR() != nil && c.STRING_LITERAL() != nil { - attr.NotNullError = unquoteString(c.STRING_LITERAL().GetText()) + attr.NotNullError = unquoteStringLit(c.STRING_LITERAL()) } } if c.CALCULATED() != nil { @@ -522,7 +522,7 @@ func extractLiteralValue(ctx parser.ILiteralContext) any { // Check for different literal types if lit.STRING_LITERAL() != nil { - return unquoteString(lit.STRING_LITERAL().GetText()) + return unquoteStringLit(lit.STRING_LITERAL()) } if lit.NUMBER_LITERAL() != nil { text := lit.NUMBER_LITERAL().GetText() @@ -765,7 +765,7 @@ func buildErrorMessage(ctx parser.IErrorMessageClauseContext) string { if !ok || emc.STRING_LITERAL() == nil { return "" } - return unquoteString(emc.STRING_LITERAL().GetText()) + return unquoteStringLit(emc.STRING_LITERAL()) } // expressionSourceText is an expression as the author wrote it — whitespace kept, diff --git a/mdl/visitor/visitor_imagecollection.go b/mdl/visitor/visitor_imagecollection.go index c39f5cc72..59c5cd458 100644 --- a/mdl/visitor/visitor_imagecollection.go +++ b/mdl/visitor/visitor_imagecollection.go @@ -14,7 +14,7 @@ func (b *Builder) ExitCreateImageCollectionStatement(ctx *parser.CreateImageColl ExportLevel: "Hidden", } if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } // Extract /** ... */ doc comment (same as other create statements) @@ -25,10 +25,10 @@ func (b *Builder) ExitCreateImageCollectionStatement(ctx *parser.CreateImageColl for _, opt := range optsCtx.AllImageCollectionOption() { optCtx := opt.(*parser.ImageCollectionOptionContext) if optCtx.EXPORT() != nil && optCtx.LEVEL() != nil && optCtx.STRING_LITERAL() != nil { - stmt.ExportLevel = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.ExportLevel = unquoteStringLit(optCtx.STRING_LITERAL()) } if optCtx.COMMENT() != nil && optCtx.STRING_LITERAL() != nil { - stmt.Comment = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.Comment = unquoteStringLit(optCtx.STRING_LITERAL()) } } } @@ -44,7 +44,7 @@ func (b *Builder) ExitCreateImageCollectionStatement(ctx *parser.CreateImageColl } stmt.Images = append(stmt.Images, ast.ImageItem{ Name: name, - FilePath: unquoteString(itemCtx.GetPath().GetText()), + FilePath: unquoteStringLit(itemCtx.GetPath()), }) } } diff --git a/mdl/visitor/visitor_import_export_mapping.go b/mdl/visitor/visitor_import_export_mapping.go index ea51cc9ce..d378ceb58 100644 --- a/mdl/visitor/visitor_import_export_mapping.go +++ b/mdl/visitor/visitor_import_export_mapping.go @@ -17,7 +17,7 @@ func (b *Builder) ExitCreateImportMappingStatement(ctx *parser.CreateImportMappi Name: buildQualifiedName(ctx.QualifiedName()), } if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } // Parse WITH clause @@ -157,7 +157,7 @@ func (b *Builder) ExitCreateExportMappingStatement(ctx *parser.CreateExportMappi Name: buildQualifiedName(ctx.QualifiedName()), } if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } // Parse WITH clause diff --git a/mdl/visitor/visitor_javaaction.go b/mdl/visitor/visitor_javaaction.go index a6be0a27d..95a3749db 100644 --- a/mdl/visitor/visitor_javaaction.go +++ b/mdl/visitor/visitor_javaaction.go @@ -18,7 +18,7 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat stmt.Name = buildQualifiedName(qn) } if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } // Get parameters @@ -71,8 +71,8 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat // it, so removal has to be said out loud. stmt.NotExposed = true case len(allStrings) >= 2: - stmt.ExposedCaption = unquoteString(allStrings[0].GetText()) - stmt.ExposedCategory = unquoteString(allStrings[1].GetText()) + stmt.ExposedCaption = unquoteStringLit(allStrings[0]) + stmt.ExposedCategory = unquoteStringLit(allStrings[1]) stmt.ExposedBitmaps = buildExposeBitmaps(exposed.AllExposeBitmapClause()) } } @@ -123,7 +123,7 @@ func (b *Builder) ExitCreateJavaScriptActionStatement(ctx *parser.CreateJavaScri stmt.Name = buildQualifiedName(qn) } if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } if paramList := ctx.JavaActionParameterList(); paramList != nil { @@ -172,8 +172,8 @@ func (b *Builder) ExitCreateJavaScriptActionStatement(ctx *parser.CreateJavaScri // it, so removal has to be said out loud. stmt.NotExposed = true case len(allStrings) >= 2: - stmt.ExposedCaption = unquoteString(allStrings[0].GetText()) - stmt.ExposedCategory = unquoteString(allStrings[1].GetText()) + stmt.ExposedCaption = unquoteStringLit(allStrings[0]) + stmt.ExposedCategory = unquoteStringLit(allStrings[1]) stmt.ExposedBitmaps = buildExposeBitmaps(exposed.AllExposeBitmapClause()) } } diff --git a/mdl/visitor/visitor_jsonstructure.go b/mdl/visitor/visitor_jsonstructure.go index 0a3f5bb45..1f789701d 100644 --- a/mdl/visitor/visitor_jsonstructure.go +++ b/mdl/visitor/visitor_jsonstructure.go @@ -20,11 +20,11 @@ func (b *Builder) ExitCreateJsonStructureStatement(ctx *parser.CreateJsonStructu allStrings := ctx.AllSTRING_LITERAL() strIdx := 0 if ctx.FOLDER() != nil && strIdx < len(allStrings) { - stmt.Folder = unquoteString(allStrings[strIdx].GetText()) + stmt.Folder = unquoteStringLit(allStrings[strIdx]) strIdx++ } if ctx.COMMENT() != nil && strIdx < len(allStrings) { - stmt.Documentation = unquoteString(allStrings[strIdx].GetText()) + stmt.Documentation = unquoteStringLit(allStrings[strIdx]) } // Parse SNIPPET value — can be STRING_LITERAL or DOLLAR_STRING. @@ -34,7 +34,7 @@ func (b *Builder) ExitCreateJsonStructureStatement(ctx *parser.CreateJsonStructu // SNIPPET is a STRING_LITERAL; it's the last one (after COMMENT if present) idx := len(allStrings) - 1 if idx >= 0 { - stmt.JsonSnippet = unquoteString(allStrings[idx].GetText()) + stmt.JsonSnippet = unquoteStringLit(allStrings[idx]) } } @@ -48,8 +48,8 @@ func (b *Builder) ExitCreateJsonStructureStatement(ctx *parser.CreateJsonStructu if len(strings) != 2 { continue } - jsonKey := unquoteString(strings[0].GetText()) - customName := unquoteString(strings[1].GetText()) + jsonKey := unquoteStringLit(strings[0]) + customName := unquoteStringLit(strings[1]) // `item of 'key' as 'Name'` names the array's ITEM element; the bare // form names the element the key itself reaches. if mappingCtx.ITEM() != nil { diff --git a/mdl/visitor/visitor_menu.go b/mdl/visitor/visitor_menu.go index 6353c5e36..dcb14830f 100644 --- a/mdl/visitor/visitor_menu.go +++ b/mdl/visitor/visitor_menu.go @@ -24,7 +24,7 @@ func (b *Builder) ExitCreateMenuStatement(ctx *parser.CreateMenuStatementContext // the rewrite gap of #1018, found while testing the latter. stmt.Documentation, stmt.DocumentationSet = findDocComment(ctx) if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } for _, itemCtx := range ctx.AllNavMenuItemDef() { stmt.Items = append(stmt.Items, buildNavMenuItemDef(itemCtx)) diff --git a/mdl/visitor/visitor_messagedefinition.go b/mdl/visitor/visitor_messagedefinition.go index 960e38504..c10602e96 100644 --- a/mdl/visitor/visitor_messagedefinition.go +++ b/mdl/visitor/visitor_messagedefinition.go @@ -20,7 +20,7 @@ func (b *Builder) ExitCreateMessageDefinitionCollectionStatement(ctx *parser.Cre } if ctx.FOLDER() != nil { if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } } for _, d := range ctx.AllMessageDefinitionDef() { @@ -84,7 +84,7 @@ func (b *Builder) buildMessageMember(c parser.IMessageMemberContext) *ast.Messag mem.Attribute = identifierOrKeywordText(ctx.IdentifierOrKeyword()) if ex, ok := ctx.MessageExample().(*parser.MessageExampleContext); ok && ex != nil { if lit := ex.STRING_LITERAL(); lit != nil { - mem.Example = unquoteString(lit.GetText()) + mem.Example = unquoteStringLit(lit) } } return mem @@ -97,7 +97,7 @@ func exposedNameOf(c parser.IMessageExposedNameContext) string { return "" } if lit := ctx.STRING_LITERAL(); lit != nil { - return unquoteString(lit.GetText()) + return unquoteStringLit(lit) } return "" } diff --git a/mdl/visitor/visitor_microflow.go b/mdl/visitor/visitor_microflow.go index 464870e17..524a9a95d 100644 --- a/mdl/visitor/visitor_microflow.go +++ b/mdl/visitor/visitor_microflow.go @@ -32,7 +32,7 @@ func (b *Builder) ExitCreateMicroflowStatement(ctx *parser.CreateMicroflowStatem for _, opt := range optsCtx.AllMicroflowOption() { optCtx := opt.(*parser.MicroflowOptionContext) if optCtx.FOLDER() != nil && optCtx.STRING_LITERAL() != nil { - stmt.Folder = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.Folder = unquoteStringLit(optCtx.STRING_LITERAL()) } if exposed := optCtx.MicroflowExposedClause(); exposed != nil { stmt.Expose = append(stmt.Expose, buildExposeActionClause(exposed)) @@ -86,7 +86,7 @@ func (b *Builder) ExitCreateNanoflowStatement(ctx *parser.CreateNanoflowStatemen for _, opt := range optsCtx.AllMicroflowOption() { optCtx := opt.(*parser.MicroflowOptionContext) if optCtx.FOLDER() != nil && optCtx.STRING_LITERAL() != nil { - stmt.Folder = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.Folder = unquoteStringLit(optCtx.STRING_LITERAL()) } if exposed := optCtx.MicroflowExposedClause(); exposed != nil { stmt.Expose = append(stmt.Expose, buildExposeActionClause(exposed)) @@ -138,7 +138,7 @@ func (b *Builder) ExitCreateRuleStatement(ctx *parser.CreateRuleStatementContext for _, opt := range optsCtx.AllMicroflowOption() { optCtx := opt.(*parser.MicroflowOptionContext) if optCtx.FOLDER() != nil && optCtx.STRING_LITERAL() != nil { - stmt.Folder = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.Folder = unquoteStringLit(optCtx.STRING_LITERAL()) } if exposed := optCtx.MicroflowExposedClause(); exposed != nil { stmt.Expose = append(stmt.Expose, buildExposeActionClause(exposed)) @@ -392,8 +392,8 @@ func buildExposeActionClause(ctx parser.IMicroflowExposedClauseContext) ast.Expo return out } if strs := ctx.AllSTRING_LITERAL(); len(strs) >= 2 { - out.Caption = unquoteString(strs[0].GetText()) - out.Category = unquoteString(strs[1].GetText()) + out.Caption = unquoteStringLit(strs[0]) + out.Category = unquoteStringLit(strs[1]) } out.Bitmaps = buildExposeBitmaps(ctx.AllExposeBitmapClause()) return out @@ -412,7 +412,7 @@ func buildExposeBitmaps(clauses []parser.IExposeBitmapClauseContext) []ast.Expos Clear: c.DROP() != nil, } if lit := c.STRING_LITERAL(); lit != nil { - b.Path = unquoteString(lit.GetText()) + b.Path = unquoteStringLit(lit) } out = append(out, b) } @@ -476,7 +476,7 @@ func applyMicroflowDocumentProperty(stmt *ast.CreateMicroflowStmt, optCtx *parse } stmt.URLSearchParameters = &names case uc.STRING_LITERAL() != nil: - url := unquoteString(uc.STRING_LITERAL().GetText()) + url := unquoteStringLit(uc.STRING_LITERAL()) stmt.URL = &url } } @@ -498,7 +498,7 @@ func applyMicroflowDocumentProperty(stmt *ast.CreateMicroflowStmt, optCtx *parse if e := cCtx.MicroflowConcurrencyError(); e != nil { eCtx := e.(*parser.MicroflowConcurrencyErrorContext) if eCtx.ERROR_MESSAGE() != nil && eCtx.STRING_LITERAL() != nil { - clause.ErrorMessage = unquoteString(eCtx.STRING_LITERAL().GetText()) + clause.ErrorMessage = unquoteStringLit(eCtx.STRING_LITERAL()) clause.ErrorMessageSet = true } if qn := eCtx.QualifiedName(); qn != nil { diff --git a/mdl/visitor/visitor_microflow_actions.go b/mdl/visitor/visitor_microflow_actions.go index 0df0fd391..ce5d895d7 100644 --- a/mdl/visitor/visitor_microflow_actions.go +++ b/mdl/visitor/visitor_microflow_actions.go @@ -543,7 +543,7 @@ func buildCallWebServiceStatement(ctx parser.ICallWebServiceStatementContext) *a if callCtx.RAW() != nil { if lit := callCtx.STRING_LITERAL(); lit != nil { - stmt.RawBSONBase64 = unquoteString(lit.GetText()) + stmt.RawBSONBase64 = unquoteStringLit(lit) } if errClause := callCtx.OnErrorClause(); errClause != nil { stmt.ErrorHandling = buildOnErrorClause(errClause) @@ -593,7 +593,7 @@ func webServiceReferenceText(ctx parser.IWebServiceReferenceContext) string { } refCtx := ctx.(*parser.WebServiceReferenceContext) if lit := refCtx.STRING_LITERAL(); lit != nil { - return unquoteString(lit.GetText()) + return unquoteStringLit(lit) } return getQualifiedNameText(refCtx.QualifiedName()) } @@ -620,7 +620,7 @@ func buildExecuteDatabaseQueryStatement(ctx parser.IExecuteDatabaseQueryStatemen // Get dynamic query if present if execCtx.DYNAMIC() != nil { if sl := execCtx.STRING_LITERAL(); sl != nil { - stmt.DynamicQuery = unquoteString(sl.GetText()) + stmt.DynamicQuery = unquoteStringLit(sl) } else if ds := execCtx.DOLLAR_STRING(); ds != nil { stmt.DynamicQuery = unquoteDollarString(ds.GetText()) } else if expr := execCtx.Expression(); expr != nil { @@ -1493,7 +1493,7 @@ func buildRestCallStatement(ctx parser.IRestCallStatementContext) *ast.RestCallS if strLit := urlC.STRING_LITERAL(); strLit != nil { stmt.URL = &ast.LiteralExpr{ Kind: ast.LiteralString, - Value: unquoteString(strLit.GetText()), + Value: unquoteStringLit(strLit), } } else if expr := urlC.Expression(); expr != nil { stmt.URL = buildSourceExpression(expr) @@ -1516,7 +1516,7 @@ func buildRestCallStatement(ctx parser.IRestCallStatementContext) *ast.RestCallS header.Name = id.GetText() } else if strLit := hdrCtx.STRING_LITERAL(); strLit != nil { // Handle quoted header names like 'Content-Type' - header.Name = unquoteString(strLit.GetText()) + header.Name = unquoteStringLit(strLit) } if expr := hdrCtx.Expression(); expr != nil { header.Value = buildSourceExpression(expr) @@ -1564,7 +1564,7 @@ func buildRestCallStatement(ctx parser.IRestCallStatementContext) *ast.RestCallS if strLit := bodyCtx.STRING_LITERAL(); strLit != nil { body.Template = &ast.LiteralExpr{ Kind: ast.LiteralString, - Value: unquoteString(strLit.GetText()), + Value: unquoteStringLit(strLit), } } else if expr := bodyCtx.Expression(); expr != nil { body.Template = buildSourceExpression(expr) diff --git a/mdl/visitor/visitor_microflow_expression.go b/mdl/visitor/visitor_microflow_expression.go index 98563f14b..3d32c4efd 100644 --- a/mdl/visitor/visitor_microflow_expression.go +++ b/mdl/visitor/visitor_microflow_expression.go @@ -706,7 +706,7 @@ func buildLiteralExpression(ctx parser.ILiteralContext) *ast.LiteralExpr { // String literal if str := litCtx.STRING_LITERAL(); str != nil { return &ast.LiteralExpr{ - Value: unquoteString(str.GetText()), + Value: unquoteStringLit(str), Kind: ast.LiteralString, } } diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 34cca43af..8619d73c2 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -598,14 +598,14 @@ func extractAnnotationValueString(ctx parser.IAnnotationValueContext) string { if lit := valCtx.Literal(); lit != nil { litCtx := lit.(*parser.LiteralContext) if litCtx.STRING_LITERAL() != nil { - return unquoteString(litCtx.STRING_LITERAL().GetText()) + return unquoteStringLit(litCtx.STRING_LITERAL()) } } // Also try expression — it might be a string literal parsed as expression if expr := valCtx.Expression(); expr != nil { text := expr.GetText() if len(text) >= 2 && text[0] == '\'' && text[len(text)-1] == '\'' { - return unquoteString(text) + return unquoteStringLit(expr) } } return "" diff --git a/mdl/visitor/visitor_microflow_workflow.go b/mdl/visitor/visitor_microflow_workflow.go index 00b291954..49d561f8a 100644 --- a/mdl/visitor/visitor_microflow_workflow.go +++ b/mdl/visitor/visitor_microflow_workflow.go @@ -159,7 +159,7 @@ func buildSetTaskOutcomeStatement(ctx parser.ISetTaskOutcomeStatementContext) *a stmt.WorkflowTaskVariable = strings.TrimPrefix(v.GetText(), "$") } if str := c.STRING_LITERAL(); str != nil { - stmt.OutcomeValue = unquoteString(str.GetText()) + stmt.OutcomeValue = unquoteStringLit(str) } if errClause := c.OnErrorClause(); errClause != nil { stmt.ErrorHandling = buildOnErrorClause(errClause) diff --git a/mdl/visitor/visitor_module_settings.go b/mdl/visitor/visitor_module_settings.go index be8b0aeb9..6ebddac9f 100644 --- a/mdl/visitor/visitor_module_settings.go +++ b/mdl/visitor/visitor_module_settings.go @@ -54,7 +54,7 @@ func parseJarDepAction(ctx *parser.AlterModuleJarDepActionContext) ast.JarDepAct var boolVal bool isBool := false if sl := propCtx.STRING_LITERAL(); sl != nil { - val = unquoteString(sl.GetText()) + val = unquoteStringLit(sl) } else if bl := propCtx.BooleanLiteral(); bl != nil { boolVal = strings.EqualFold(bl.GetText(), "true") isBool = true @@ -81,13 +81,13 @@ func parseJarDepAction(ctx *parser.AlterModuleJarDepActionContext) ast.JarDepAct if len(strs) == 0 { return nil } - coordinate := unquoteString(strs[0].GetText()) + coordinate := unquoteStringLit(strs[0]) if ctx.VERSION() != nil && len(strs) >= 2 { // SET JAR DEPENDENCY 'coord' VERSION 'version' return &ast.SetJarDepVersionAction{ Coordinate: coordinate, - Version: unquoteString(strs[1].GetText()), + Version: unquoteStringLit(strs[1]), } } @@ -104,7 +104,7 @@ func parseJarDepAction(ctx *parser.AlterModuleJarDepActionContext) ast.JarDepAct // SET JAR DEPENDENCY 'coord' ADD EXCLUSION 'excl' return &ast.AddJarDepExclusionAction{ Coordinate: coordinate, - Exclusion: unquoteString(strs[1].GetText()), + Exclusion: unquoteStringLit(strs[1]), } } @@ -112,7 +112,7 @@ func parseJarDepAction(ctx *parser.AlterModuleJarDepActionContext) ast.JarDepAct // SET JAR DEPENDENCY 'coord' DROP EXCLUSION 'excl' return &ast.DropJarDepExclusionAction{ Coordinate: coordinate, - Exclusion: unquoteString(strs[1].GetText()), + Exclusion: unquoteStringLit(strs[1]), } } @@ -125,7 +125,7 @@ func parseJarDepAction(ctx *parser.AlterModuleJarDepActionContext) ast.JarDepAct if len(strs) == 0 { return nil } - return &ast.DropJarDepAction{Coordinate: unquoteString(strs[0].GetText())} + return &ast.DropJarDepAction{Coordinate: unquoteStringLit(strs[0])} } return nil diff --git a/mdl/visitor/visitor_navigation.go b/mdl/visitor/visitor_navigation.go index 13a83ad7d..b9686cd6f 100644 --- a/mdl/visitor/visitor_navigation.go +++ b/mdl/visitor/visitor_navigation.go @@ -148,7 +148,7 @@ func buildNavSyncDef(ctx parser.INavSyncDefContext) ast.NavSyncDef { } } else if lit := mc.STRING_LITERAL(); lit != nil { // Legacy quoted form: the '' pairs are MDL escaping and come off here. - def.Constraint = unquoteString(lit.GetText()) + def.Constraint = unquoteStringLit(lit) } } return def @@ -160,7 +160,7 @@ func buildNavMenuItemDef(ctx parser.INavMenuItemDefContext) ast.NavMenuItemDef { caption := "" if sl := c.STRING_LITERAL(); sl != nil { - caption = unquoteString(sl.GetText()) + caption = unquoteStringLit(sl) } item := ast.NavMenuItemDef{Caption: caption} diff --git a/mdl/visitor/visitor_odata.go b/mdl/visitor/visitor_odata.go index 6f3a9561f..acde9fa4e 100644 --- a/mdl/visitor/visitor_odata.go +++ b/mdl/visitor/visitor_odata.go @@ -279,7 +279,7 @@ func (b *Builder) ExitCreateExternalEntitiesStatement(ctx *parser.CreateExternal // odataValueText extracts the string value from an OData property value context. func odataValueText(val *parser.OdataPropertyValueContext) string { if sl := val.STRING_LITERAL(); sl != nil { - return unquoteString(sl.GetText()) + return unquoteStringLit(sl) } if nl := val.NUMBER_LITERAL(); nl != nil { return nl.GetText() @@ -357,7 +357,7 @@ func parsePublishEntityBlock(ctx parser.IPublishEntityBlockContext) *ast.Publish // Optional AS 'ExposedName' if sl := block.STRING_LITERAL(); sl != nil { - entity.ExposedName = unquoteString(sl.GetText()) + entity.ExposedName = unquoteStringLit(sl) } // Parse entity-level properties (ReadMode, InsertMode, etc.) @@ -407,7 +407,7 @@ func parseODataHeaders(ctx parser.IOdataHeadersClauseContext) []ast.HeaderDef { for _, entryCtx := range clause.AllOdataHeaderEntry() { entry := entryCtx.(*parser.OdataHeaderEntryContext) - key := unquoteString(entry.STRING_LITERAL().GetText()) + key := unquoteStringLit(entry.STRING_LITERAL()) // A header value is a Mendix expression, kept as written. value, isLiteral := odataExpressionValue(entry.OdataPropertyValue(), entry.Expression()) headers = append(headers, ast.HeaderDef{Key: key, Value: value, ValueIsLiteral: isLiteral}) @@ -428,7 +428,7 @@ func parsePublishMicroflowBlock(ctx parser.IPublishMicroflowBlockContext) *ast.P Microflow: buildQualifiedName(block.QualifiedName()), } if sl := block.STRING_LITERAL(); sl != nil { - def.ExposedName = unquoteString(sl.GetText()) + def.ExposedName = unquoteStringLit(sl) } if exposeCtx := block.ExposeClause(); exposeCtx != nil { expose := exposeCtx.(*parser.ExposeClauseContext) @@ -442,7 +442,7 @@ func parsePublishMicroflowBlock(ctx parser.IPublishMicroflowBlockContext) *ast.P } p := &ast.PublishedParamDef{Name: member.IdentifierOrKeyword().GetText()} if sl := member.STRING_LITERAL(); sl != nil { - p.ExposedName = unquoteString(sl.GetText()) + p.ExposedName = unquoteStringLit(sl) } if opts := member.ExposeMemberOptions(); opts != nil { optsCtx := opts.(*parser.ExposeMemberOptionsContext) @@ -486,7 +486,7 @@ func parseExposeMembers(ctx parser.IExposeClauseContext) []*ast.PublishedMemberD // Optional AS 'ExposedName' if sl := member.STRING_LITERAL(); sl != nil { - m.ExposedName = unquoteString(sl.GetText()) + m.ExposedName = unquoteStringLit(sl) } // Optional options (Filterable, Sortable, IsPartOfKey) diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index 5dfddafe6..4fa63666c 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -103,34 +103,34 @@ func (b *Builder) parsePageHeaderV3(ctx parser.IPageHeaderV3Context, stmt *ast.C } else if prop.TITLE() != nil { // Title: 'My Page' if str := prop.STRING_LITERAL(); str != nil { - stmt.Title = unquoteString(str.GetText()) + stmt.Title = unquoteStringLit(str) } } else if prop.LAYOUT() != nil { // Layout: Atlas_Core.Atlas_Default or 'Layout Name' if qn := prop.QualifiedName(); qn != nil { stmt.Layout = getQualifiedNameText(qn) } else if str := prop.STRING_LITERAL(); str != nil { - stmt.Layout = unquoteString(str.GetText()) + stmt.Layout = unquoteStringLit(str) } } else if prop.URL() != nil { // Url: 'my-page' if str := prop.STRING_LITERAL(); str != nil { - stmt.URL = unquoteString(str.GetText()) + stmt.URL = unquoteStringLit(str) } } else if prop.FOLDER() != nil { // Folder: 'Pages/Admin' if str := prop.STRING_LITERAL(); str != nil { - stmt.Folder = unquoteString(str.GetText()) + stmt.Folder = unquoteStringLit(str) } } else if prop.CLASS() != nil { // Class: 'my-page' — page-level CSS class (issue #714) if str := prop.STRING_LITERAL(); str != nil { - stmt.Class = unquoteString(str.GetText()) + stmt.Class = unquoteStringLit(str) } } else if prop.STYLE() != nil { // Style: 'padding: 10px' — page-level inline CSS (issue #714) if str := prop.STRING_LITERAL(); str != nil { - stmt.Style = unquoteString(str.GetText()) + stmt.Style = unquoteStringLit(str) } } else if id := prop.IDENTIFIER(); id != nil { // Generic page header property (e.g. PopupWidth: 800). Only the @@ -227,7 +227,7 @@ func (b *Builder) buildSnippetV3(ctx *parser.CreateSnippetStatementContext) *ast for _, opt := range optsCtx.AllSnippetOption() { optCtx := opt.(*parser.SnippetOptionContext) if optCtx.FOLDER() != nil && optCtx.STRING_LITERAL() != nil { - stmt.Folder = unquoteString(optCtx.STRING_LITERAL().GetText()) + stmt.Folder = unquoteStringLit(optCtx.STRING_LITERAL()) } } } @@ -263,7 +263,7 @@ func (b *Builder) parseSnippetHeaderV3(ctx parser.ISnippetHeaderV3Context, stmt } else if prop.FOLDER() != nil { // Folder: 'Snippets/Common' if str := prop.STRING_LITERAL(); str != nil { - stmt.Folder = unquoteString(str.GetText()) + stmt.Folder = unquoteStringLit(str) } } } @@ -305,7 +305,7 @@ func buildSingleVariableDeclaration(vdCtx *parser.VariableDeclarationContext) as } if str := vdCtx.STRING_LITERAL(); str != nil { - v.DefaultValue = unquoteString(str.GetText()) + v.DefaultValue = unquoteStringLit(str) } return v @@ -536,10 +536,10 @@ func buildWidgetV3(ctx parser.IWidgetV3Context, b *Builder) *ast.WidgetV3 { // Get widget type if wCtx.PLUGGABLEWIDGET() != nil { widget.Type = "pluggablewidget" - widget.Properties["WidgetType"] = unquoteString(wCtx.STRING_LITERAL().GetText()) + widget.Properties["WidgetType"] = unquoteStringLit(wCtx.STRING_LITERAL()) } else if wCtx.CUSTOMWIDGET() != nil { widget.Type = "customwidget" - widget.Properties["WidgetType"] = unquoteString(wCtx.STRING_LITERAL().GetText()) + widget.Properties["WidgetType"] = unquoteStringLit(wCtx.STRING_LITERAL()) } else if typeCtx := wCtx.WidgetTypeV3(); typeCtx != nil { widget.Type = strings.ToLower(typeCtx.GetText()) // Which alternative matched, taken from the parse tree rather than by @@ -643,7 +643,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg // Placeholder: 'text' (input widgets) if propCtx.PLACEHOLDER() != nil { if str := propCtx.STRING_LITERAL(); str != nil { - widget.Properties["Placeholder"] = unquoteString(str.GetText()) + widget.Properties["Placeholder"] = unquoteStringLit(str) } return } @@ -659,7 +659,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg // Label: ... if propCtx.LABEL() != nil { if str := propCtx.STRING_LITERAL(); str != nil { - widget.Properties["Label"] = unquoteString(str.GetText()) + widget.Properties["Label"] = unquoteStringLit(str) } return } @@ -722,7 +722,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg // Class: ... if propCtx.CLASS() != nil { if str := propCtx.STRING_LITERAL(); str != nil { - widget.Properties["Class"] = unquoteString(str.GetText()) + widget.Properties["Class"] = unquoteStringLit(str) } return } @@ -730,7 +730,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg // Style: ... if propCtx.STYLE() != nil { if str := propCtx.STRING_LITERAL(); str != nil { - widget.Properties["Style"] = unquoteString(str.GetText()) + widget.Properties["Style"] = unquoteStringLit(str) } return } @@ -1146,7 +1146,7 @@ func buildActionV3(ctx parser.IActionExprV3Context) *ast.ActionV3 { } else if actCtx.OPEN_LINK() != nil { action.Type = "openLink" if str := actCtx.STRING_LITERAL(); str != nil { - action.LinkURL = unquoteString(str.GetText()) + action.LinkURL = unquoteStringLit(str) } // A dynamic address: `open_link $currentObject/URL`. if v := actCtx.VARIABLE(); v != nil { @@ -1160,7 +1160,7 @@ func buildActionV3(ctx parser.IActionExprV3Context) *ast.ActionV3 { } else if actCtx.COMPLETE_TASK() != nil { action.Type = "completeTask" if str := actCtx.STRING_LITERAL(); str != nil { - action.OutcomeValue = unquoteString(str.GetText()) + action.OutcomeValue = unquoteStringLit(str) } } @@ -1261,7 +1261,7 @@ func buildStringExprV3(ctx parser.IStringExprV3Context) string { // String literal: 'Hello {1}' if str := strCtx.STRING_LITERAL(); str != nil { - return unquoteString(str.GetText()) + return unquoteStringLit(str) } // Attribute path: Name or Entity/Attr @@ -1592,7 +1592,7 @@ func buildPropertyValueV3(ctx parser.IPropertyValueV3Context) any { pvCtx := ctx.(*parser.PropertyValueV3Context) if str := pvCtx.STRING_LITERAL(); str != nil { - return unquoteString(str.GetText()) + return unquoteStringLit(str) } // "AttrName" — a double-quoted value, used for pluggable-widget attribute // sub-properties like chart series `staticXAttribute: "StatusValue"`. Strip @@ -1679,7 +1679,7 @@ func buildDesignPropertyEntryV3(ctx parser.IDesignPropertyEntryV3Context) *ast.D return nil } - key := unquoteString(allStrings[0].GetText()) + key := unquoteStringLit(allStrings[0]) // Compound (nested): 'Spacing': ['margin-top': 'Large', 'margin-bottom': 'Medium'] if listCtx := entryCtx.DesignPropertyListV3(); listCtx != nil { @@ -1694,7 +1694,7 @@ func buildDesignPropertyEntryV3(ctx parser.IDesignPropertyEntryV3Context) *ast.D return &ast.DesignPropertyEntryV3{Key: key, Value: "off"} } if len(allStrings) >= 2 { - return &ast.DesignPropertyEntryV3{Key: key, Value: unquoteString(allStrings[1].GetText())} + return &ast.DesignPropertyEntryV3{Key: key, Value: unquoteStringLit(allStrings[1])} } return nil @@ -2024,7 +2024,7 @@ func widgetIconName(c *parser.WidgetIconV3Context) string { return buildQualifiedName(qn).String() } if str := c.STRING_LITERAL(); str != nil { - return unquoteString(str.GetText()) + return unquoteStringLit(str) } return "" } diff --git a/mdl/visitor/visitor_query.go b/mdl/visitor/visitor_query.go index 90c0f078d..4153d1434 100644 --- a/mdl/visitor/visitor_query.go +++ b/mdl/visitor/visitor_query.go @@ -597,7 +597,7 @@ func (b *Builder) ExitShowStatement(ctx *parser.ShowStatementContext) { // document, so there is no IN clause. stmt := &ast.ShowStmt{ObjectType: ast.ShowGlyphs} if sl := ctx.STRING_LITERAL(); sl != nil { - stmt.Like = unquoteString(sl.GetText()) + stmt.Like = unquoteStringLit(sl) } b.statements = append(b.statements, stmt) } else if ctx.ICON() != nil && ctx.COLLECTION() != nil { @@ -825,7 +825,7 @@ func (b *Builder) ExitDescribeStatement(ctx *parser.DescribeStatementContext) { if n := ctx.NUMBER_LITERAL(); n != nil { stmt.Qualifier = n.GetText() } else if sl := ctx.STRING_LITERAL(); sl != nil { - stmt.Qualifier = unquoteString(sl.GetText()) + stmt.Qualifier = unquoteStringLit(sl) } if stmt.Qualifier != "" { b.statements = append(b.statements, stmt) @@ -872,7 +872,7 @@ func (b *Builder) ExitDescribeStatement(ctx *parser.DescribeStatementContext) { // Handle DESCRIBE DEMO USER 'name' (uses STRING_LITERAL) if ctx.DEMO() != nil && ctx.USER() != nil { if sl := ctx.STRING_LITERAL(); sl != nil { - userName := unquoteString(sl.GetText()) + userName := unquoteStringLit(sl) b.statements = append(b.statements, &ast.DescribeStmt{ ObjectType: ast.DescribeDemoUser, Name: ast.QualifiedName{Name: userName}, @@ -886,7 +886,7 @@ func (b *Builder) ExitDescribeStatement(ctx *parser.DescribeStatementContext) { if ctx.USER() != nil && ctx.ROLE() != nil { roleName := "" if sl := ctx.STRING_LITERAL(); sl != nil { - roleName = unquoteString(sl.GetText()) + roleName = unquoteStringLit(sl) } else if iok := ctx.IdentifierOrKeyword(0); iok != nil { roleName = identifierOrKeywordText(iok) } @@ -981,7 +981,7 @@ func (b *Builder) ExitDescribeStatement(ctx *parser.DescribeStatementContext) { // parse error is the wrong lesson (mxcli-formula1 findings #8). if ctx.CONFIGURATION() != nil { if sl := ctx.STRING_LITERAL(); sl != nil { - stmt.Qualifier = unquoteString(sl.GetText()) + stmt.Qualifier = unquoteStringLit(sl) } } b.statements = append(b.statements, stmt) @@ -1045,7 +1045,7 @@ func (b *Builder) ExitDescribeStatement(ctx *parser.DescribeStatementContext) { if ctx.WIDGET() != nil && ctx.STYLING() == nil { name := "" if lit := ctx.STRING_LITERAL(); lit != nil { - name = unquoteString(lit.GetText()) + name = unquoteStringLit(lit) } else if id := ctx.IdentifierOrKeyword(0); id != nil { name = id.GetText() } @@ -1095,7 +1095,7 @@ func (b *Builder) ExitDescribeStatement(ctx *parser.DescribeStatementContext) { } coordinate := "" if sl := ctx.STRING_LITERAL(); sl != nil { - coordinate = unquoteString(sl.GetText()) + coordinate = unquoteStringLit(sl) } b.statements = append(b.statements, &ast.DescribeStmt{ ObjectType: ast.DescribeJarDependency, @@ -1110,7 +1110,7 @@ func (b *Builder) ExitDescribeStatement(ctx *parser.DescribeStatementContext) { if ctx.CONTRACT() != nil && ctx.OPERATION() != nil && ctx.FROM() != nil && ctx.OPENAPI() != nil { if sl := ctx.STRING_LITERAL(); sl != nil { b.statements = append(b.statements, &ast.DescribeContractFromOpenAPIStmt{ - SpecPath: unquoteString(sl.GetText()), + SpecPath: unquoteStringLit(sl), }) } return @@ -1306,7 +1306,7 @@ func (b *Builder) ExitDescribeStatement(ctx *parser.DescribeStatementContext) { func (b *Builder) ExitSearchStatement(ctx *parser.SearchStatementContext) { if ctx.STRING_LITERAL() != nil { b.statements = append(b.statements, &ast.SearchStmt{ - Query: unquoteString(ctx.STRING_LITERAL().GetText()), + Query: unquoteStringLit(ctx.STRING_LITERAL()), }) } } @@ -1319,7 +1319,7 @@ func (b *Builder) ExitSearchStatement(ctx *parser.SearchStatementContext) { func (b *Builder) ExitExecuteScriptStatement(ctx *parser.ExecuteScriptStatementContext) { if ctx.STRING_LITERAL() != nil { b.statements = append(b.statements, &ast.ExecuteScriptStmt{ - Path: unquoteString(ctx.STRING_LITERAL().GetText()), + Path: unquoteStringLit(ctx.STRING_LITERAL()), }) } } @@ -1346,7 +1346,7 @@ func (b *Builder) ExitSessionSetStatement(ctx *parser.SessionSetStatementContext value = valCtx.NUMBER_LITERAL().GetText() } case valCtx.STRING_LITERAL() != nil: - value = unquoteString(valCtx.STRING_LITERAL().GetText()) + value = unquoteStringLit(valCtx.STRING_LITERAL()) case valCtx.IdentifierOrKeyword() != nil: value = valCtx.IdentifierOrKeyword().GetText() } diff --git a/mdl/visitor/visitor_queue.go b/mdl/visitor/visitor_queue.go index a90fdebb0..46a26af7e 100644 --- a/mdl/visitor/visitor_queue.go +++ b/mdl/visitor/visitor_queue.go @@ -17,7 +17,7 @@ func (b *Builder) ExitCreateQueueStatement(ctx *parser.CreateQueueStatementConte Documentation: findDocCommentText(ctx), } if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } if createStmt := findParentCreateStatement(ctx); createStmt != nil { if createStmt.OR() != nil && (createStmt.MODIFY() != nil || createStmt.REPLACE() != nil) { @@ -63,7 +63,7 @@ func queuePropertyText(pc *parser.QueuePropertyContext) string { return n.GetText() } if s := pc.STRING_LITERAL(); s != nil { - return unquoteString(s.GetText()) + return unquoteStringLit(s) } if bl := pc.BooleanLiteral(); bl != nil { return bl.GetText() diff --git a/mdl/visitor/visitor_regularexpression.go b/mdl/visitor/visitor_regularexpression.go index 3007746c9..a02df975f 100644 --- a/mdl/visitor/visitor_regularexpression.go +++ b/mdl/visitor/visitor_regularexpression.go @@ -17,7 +17,7 @@ func (b *Builder) ExitCreateRegularExpressionStatement(ctx *parser.CreateRegular Documentation: findDocCommentText(ctx), } if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } if createStmt := findParentCreateStatement(ctx); createStmt != nil { if createStmt.OR() != nil && (createStmt.MODIFY() != nil || createStmt.REPLACE() != nil) { @@ -56,7 +56,7 @@ func (b *Builder) ExitCreateRegularExpressionStatement(ctx *parser.CreateRegular // reading index 0 would echo the key back as the value. func regularExpressionPropertyText(pc *parser.RegularExpressionPropertyContext) string { if s := pc.STRING_LITERAL(); s != nil { - return unquoteString(s.GetText()) + return unquoteStringLit(s) } if bl := pc.BooleanLiteral(); bl != nil { return bl.GetText() diff --git a/mdl/visitor/visitor_rest.go b/mdl/visitor/visitor_rest.go index 8d33a7358..b7c0cbb4d 100644 --- a/mdl/visitor/visitor_rest.go +++ b/mdl/visitor/visitor_rest.go @@ -34,15 +34,15 @@ func (b *Builder) ExitCreateRestClientStatement(ctx *parser.CreateRestClientStat switch key { case "baseurl": if sl := pc.STRING_LITERAL(); sl != nil { - stmt.BaseUrl = unquoteString(sl.GetText()) + stmt.BaseUrl = unquoteStringLit(sl) } case "folder": if sl := pc.STRING_LITERAL(); sl != nil { - stmt.Folder = unquoteString(sl.GetText()) + stmt.Folder = unquoteStringLit(sl) } case "openapi": if sl := pc.STRING_LITERAL(); sl != nil { - stmt.OpenApiPath = unquoteString(sl.GetText()) + stmt.OpenApiPath = unquoteStringLit(sl) } case "authentication": if pc.BASIC() != nil { @@ -55,7 +55,7 @@ func (b *Builder) ExitCreateRestClientStatement(ctx *parser.CreateRestClientStat subKey := strings.ToLower(identifierOrKeywordText(sp.IdentifierOrKeyword().(*parser.IdentifierOrKeywordContext))) var val string if sl := sp.STRING_LITERAL(); sl != nil { - val = unquoteString(sl.GetText()) + val = unquoteStringLit(sl) } else if v := sp.VARIABLE(); v != nil { // $Constant reference (legacy) — keep $ prefix val = v.GetText() @@ -109,7 +109,7 @@ func parseRestClientOperation(ctx *parser.RestClientOperationContext) *ast.RestO if iok := ctx.IdentifierOrKeyword(); iok != nil { op.Name = identifierOrKeywordText(iok) } else if sl := ctx.STRING_LITERAL(); sl != nil { - op.Name = unquoteString(sl.GetText()) + op.Name = unquoteStringLit(sl) } // Documentation @@ -170,7 +170,7 @@ func parseRestClientOpProp(ctx *parser.RestClientOpPropContext, op *ast.RestOper if ctx.TEMPLATE() != nil { if sl := ctx.STRING_LITERAL(); sl != nil { op.BodyType = "template" - op.BodyVariable = unquoteString(sl.GetText()) + op.BodyVariable = unquoteStringLit(sl) } return } @@ -243,12 +243,12 @@ func parseRestClientOpProp(ctx *parser.RestClientOpPropContext, op *ast.RestOper header := ast.RestHeaderDef{} allSL := hic.AllSTRING_LITERAL() if len(allSL) >= 1 { - header.Name = unquoteString(allSL[0].GetText()) + header.Name = unquoteStringLit(allSL[0]) } if hic.PLUS() != nil { // 'prefix' + $Variable if len(allSL) >= 2 { - header.Prefix = unquoteString(allSL[1].GetText()) + header.Prefix = unquoteStringLit(allSL[1]) } if v := hic.VARIABLE(); v != nil { header.Variable = v.GetText() @@ -256,7 +256,7 @@ func parseRestClientOpProp(ctx *parser.RestClientOpPropContext, op *ast.RestOper } else if hic.VARIABLE() != nil { header.Variable = hic.VARIABLE().GetText() } else if len(allSL) >= 2 { - header.Value = unquoteString(allSL[1].GetText()) + header.Value = unquoteStringLit(allSL[1]) } op.Headers = append(op.Headers, header) } @@ -267,7 +267,7 @@ func parseRestClientOpProp(ctx *parser.RestClientOpPropContext, op *ast.RestOper if sl := ctx.STRING_LITERAL(); sl != nil { switch key { case "path": - op.Path = unquoteString(sl.GetText()) + op.Path = unquoteStringLit(sl) } return } @@ -357,7 +357,7 @@ func (b *Builder) ExitCreatePublishedRestServiceStatement(ctx *parser.CreatePubl for _, propCtx := range ctx.AllPublishedRestProperty() { pc := propCtx.(*parser.PublishedRestPropertyContext) key := identifierOrKeywordText(pc.IdentifierOrKeyword().(*parser.IdentifierOrKeywordContext)) - val := unquoteString(pc.STRING_LITERAL().GetText()) + val := unquoteStringLit(pc.STRING_LITERAL()) switch strings.ToLower(key) { case "path": stmt.Path = val @@ -389,7 +389,7 @@ func buildPublishedRestResourceDef(rc *parser.PublishedRestResourceContext) *ast return nil } resDef := &ast.PublishedRestResourceDef{ - Name: unquoteString(rc.STRING_LITERAL().GetText()), + Name: unquoteStringLit(rc.STRING_LITERAL()), } for _, opCtx := range rc.AllPublishedRestOperation() { @@ -405,7 +405,7 @@ func buildPublishedRestResourceDef(rc *parser.PublishedRestResourceContext) *ast if pCtx := oc.PublishedRestOpPath(); pCtx != nil { pc := pCtx.(*parser.PublishedRestOpPathContext) if pc.STRING_LITERAL() != nil { - opDef.Path = strings.Trim(unquoteString(pc.STRING_LITERAL().GetText()), "/") + opDef.Path = strings.Trim(unquoteStringLit(pc.STRING_LITERAL()), "/") } } @@ -468,7 +468,7 @@ func (b *Builder) exitAlterPublishedRestServiceStatement(ctx *parser.AlterStatem for _, asnCtx := range ac.AllPublishedRestAlterAssignment() { asn := asnCtx.(*parser.PublishedRestAlterAssignmentContext) key := identifierOrKeywordText(asn.IdentifierOrKeyword().(*parser.IdentifierOrKeywordContext)) - val := unquoteString(asn.STRING_LITERAL().GetText()) + val := unquoteStringLit(asn.STRING_LITERAL()) changes[key] = val } stmt.Actions = append(stmt.Actions, &ast.PublishedRestSetAction{Changes: changes}) @@ -487,7 +487,7 @@ func (b *Builder) exitAlterPublishedRestServiceStatement(ctx *parser.AlterStatem // DROP RESOURCE 'name' if ac.DROP() != nil && ac.RESOURCE() != nil { - name := unquoteString(ac.STRING_LITERAL().GetText()) + name := unquoteStringLit(ac.STRING_LITERAL()) stmt.Actions = append(stmt.Actions, &ast.PublishedRestDropResourceAction{Name: name}) continue } diff --git a/mdl/visitor/visitor_scheduledevent.go b/mdl/visitor/visitor_scheduledevent.go index 8c945376c..d412bc95c 100644 --- a/mdl/visitor/visitor_scheduledevent.go +++ b/mdl/visitor/visitor_scheduledevent.go @@ -23,7 +23,7 @@ func (b *Builder) ExitCreateScheduledEventStatement(ctx *parser.CreateScheduledE Documentation: findDocCommentText(ctx), } if lit := ctx.STRING_LITERAL(); lit != nil { - stmt.Folder = unquoteString(lit.GetText()) + stmt.Folder = unquoteStringLit(lit) } if createStmt := findParentCreateStatement(ctx); createStmt != nil { if createStmt.OR() != nil && (createStmt.MODIFY() != nil || createStmt.REPLACE() != nil) { @@ -102,7 +102,7 @@ func scheduledEventPropertyText(pc *parser.ScheduledEventPropertyContext) string return n.GetText() } if s := pc.STRING_LITERAL(); s != nil { - return unquoteString(s.GetText()) + return unquoteStringLit(s) } if bl := pc.BooleanLiteral(); bl != nil { return bl.GetText() diff --git a/mdl/visitor/visitor_security.go b/mdl/visitor/visitor_security.go index 4a3b156c7..ade18222c 100644 --- a/mdl/visitor/visitor_security.go +++ b/mdl/visitor/visitor_security.go @@ -19,7 +19,7 @@ func (b *Builder) ExitCreateModuleRoleStatement(ctx *parser.CreateModuleRoleStat } if ctx.DESCRIPTION() != nil { if sl := ctx.STRING_LITERAL(); sl != nil { - stmt.Description = unquoteString(sl.GetText()) + stmt.Description = unquoteStringLit(sl) } } b.statements = append(b.statements, stmt) @@ -91,7 +91,7 @@ func (b *Builder) ExitDropUserRoleStatement(ctx *parser.DropUserRoleStatementCon } else if sl := ctx.STRING_LITERAL(); sl != nil { // Quoted form (FINDINGS #5): DROP USER ROLE 'User' — matches DESCRIBE, which // also accepts quotes. Bare and quoted are now consistent across both. - name = unquoteString(sl.GetText()) + name = unquoteStringLit(sl) } if name != "" { b.statements = append(b.statements, &ast.DropUserRoleStmt{ @@ -129,7 +129,7 @@ func (b *Builder) ExitGrantEntityAccessStatement(ctx *parser.GrantEntityAccessSt // Parse WHERE clause if ctx.WHERE() != nil { if sl := ctx.STRING_LITERAL(); sl != nil { - stmt.XPathConstraint = unquoteString(sl.GetText()) + stmt.XPathConstraint = unquoteStringLit(sl) } } @@ -441,8 +441,8 @@ func (b *Builder) ExitCreateDemoUserStatement(ctx *parser.CreateDemoUserStatemen } stmt := &ast.CreateDemoUserStmt{ - UserName: unquoteString(sls[0].GetText()), - Password: unquoteString(sls[1].GetText()), + UserName: unquoteStringLit(sls[0]), + Password: unquoteStringLit(sls[1]), } // Check parent createStatement for OR MODIFY @@ -469,7 +469,7 @@ func (b *Builder) ExitCreateDemoUserStatement(ctx *parser.CreateDemoUserStatemen func (b *Builder) ExitDropDemoUserStatement(ctx *parser.DropDemoUserStatementContext) { if sl := ctx.STRING_LITERAL(); sl != nil { b.statements = append(b.statements, &ast.DropDemoUserStmt{ - UserName: unquoteString(sl.GetText()), + UserName: unquoteStringLit(sl), IfExists: ctx.IfExists() != nil, }) } diff --git a/mdl/visitor/visitor_settings.go b/mdl/visitor/visitor_settings.go index a9463908b..708e26a26 100644 --- a/mdl/visitor/visitor_settings.go +++ b/mdl/visitor/visitor_settings.go @@ -21,17 +21,17 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext stmt.DropConstant = true allStrings := ctx.AllSTRING_LITERAL() if len(allStrings) > 0 { - stmt.ConstantId = unquoteString(allStrings[0].GetText()) + stmt.ConstantId = unquoteStringLit(allStrings[0]) } if ctx.IN() != nil && ctx.CONFIGURATION() != nil && len(allStrings) > 1 { - stmt.ConfigName = unquoteString(allStrings[1].GetText()) + stmt.ConfigName = unquoteStringLit(allStrings[1]) } } else if ctx.CONSTANT() != nil { // ALTER SETTINGS CONSTANT 'name' (VALUE 'value' | DROP) [IN CONFIGURATION 'cfg'] stmt.Section = "constant" allStrings := ctx.AllSTRING_LITERAL() if len(allStrings) > 0 { - stmt.ConstantId = unquoteString(allStrings[0].GetText()) + stmt.ConstantId = unquoteStringLit(allStrings[0]) } if ctx.DROP() != nil { stmt.DropConstant = true @@ -40,14 +40,14 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext } // Check for IN CONFIGURATION 'name' if ctx.IN() != nil && ctx.CONFIGURATION() != nil && len(allStrings) > 1 { - stmt.ConfigName = unquoteString(allStrings[1].GetText()) + stmt.ConfigName = unquoteStringLit(allStrings[1]) } } else if ctx.CONFIGURATION() != nil { // ALTER SETTINGS CONFIGURATION 'name' Key = Value, ... stmt.Section = "configuration" allStrings := ctx.AllSTRING_LITERAL() if len(allStrings) > 0 { - stmt.ConfigName = unquoteString(allStrings[0].GetText()) + stmt.ConfigName = unquoteStringLit(allStrings[0]) } for _, assignCtx := range ctx.AllSettingsAssignment() { assign, ok := assignCtx.(*parser.SettingsAssignmentContext) @@ -75,7 +75,7 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext stmt.ModifyGroup = ctx.MODIFY() != nil && !stmt.UpsertGroup stmt.RemoveGroup = ctx.REMOVE() != nil if all := ctx.AllSTRING_LITERAL(); len(all) > 0 { - stmt.GroupName = unquoteString(all[0].GetText()) + stmt.GroupName = unquoteStringLit(all[0]) } collectSettingsItemOptions(ctx.SettingsItemOptions(), stmt.Properties) } else if ctx.SettingsSection() != nil && (ctx.ADD() != nil || ctx.MODIFY() != nil || ctx.REMOVE() != nil) { @@ -88,7 +88,7 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext stmt.ModifyLanguage = ctx.MODIFY() != nil && !stmt.UpsertLanguage stmt.RemoveLanguage = ctx.REMOVE() != nil if all := ctx.AllSTRING_LITERAL(); len(all) > 0 { - stmt.LanguageCode = unquoteString(all[0].GetText()) + stmt.LanguageCode = unquoteStringLit(all[0]) } collectSettingsItemOptions(ctx.SettingsItemOptions(), stmt.Properties) } else if ctx.SettingsSection() != nil { @@ -122,7 +122,7 @@ func (b *Builder) ExitCreateConfigurationStatement(ctx *parser.CreateConfigurati } if sl := ctx.STRING_LITERAL(); sl != nil { - stmt.Name = unquoteString(sl.GetText()) + stmt.Name = unquoteStringLit(sl) } // CREATE OR MODIFY / OR REPLACE, read off the shared create wrapper. @@ -178,7 +178,7 @@ func collectSettingsItemOptions(opts parser.ISettingsItemOptionsContext, into ma // settingsValueText extracts the string value from a SettingsValue context. func settingsValueText(ctx *parser.SettingsValueContext) string { if sl := ctx.STRING_LITERAL(); sl != nil { - return unquoteString(sl.GetText()) + return unquoteStringLit(sl) } if nl := ctx.NUMBER_LITERAL(); nl != nil { return nl.GetText() @@ -195,7 +195,7 @@ func settingsValueText(ctx *parser.SettingsValueContext) string { // settingsValueToInterface extracts a typed value from a SettingsValue context. func settingsValueToInterface(ctx *parser.SettingsValueContext) any { if sl := ctx.STRING_LITERAL(); sl != nil { - return unquoteString(sl.GetText()) + return unquoteStringLit(sl) } if nl := ctx.NUMBER_LITERAL(); nl != nil { if v, err := strconv.ParseInt(nl.GetText(), 10, 64); err == nil { diff --git a/mdl/visitor/visitor_sql.go b/mdl/visitor/visitor_sql.go index 160f9298d..6fa0e98d9 100644 --- a/mdl/visitor/visitor_sql.go +++ b/mdl/visitor/visitor_sql.go @@ -42,7 +42,7 @@ func (b *Builder) ExitSqlConnect(ctx *parser.SqlConnectContext) { } b.statements = append(b.statements, &ast.SQLConnectStmt{ Driver: words[0], - DSN: unquoteString(ctx.STRING_LITERAL().GetText()), + DSN: unquoteStringLit(ctx.STRING_LITERAL()), Alias: words[1], }) } @@ -105,7 +105,7 @@ func (b *Builder) ExitImportFromQuery(ctx *parser.ImportFromQueryContext) { } var query string if sl := ctx.STRING_LITERAL(); sl != nil { - query = unquoteString(sl.GetText()) + query = unquoteStringLit(sl) } else if ds := ctx.DOLLAR_STRING(); ds != nil { s := ds.GetText() if len(s) >= 4 && strings.HasPrefix(s, "$$") && strings.HasSuffix(s, "$$") { diff --git a/mdl/visitor/visitor_string_escapes.go b/mdl/visitor/visitor_string_escapes.go new file mode 100644 index 000000000..f3a5cb69b --- /dev/null +++ b/mdl/visitor/visitor_string_escapes.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "sort" + "strings" + + "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/langver" +) + +// backslashIsLiteral is ADR-0010 R11's string rule: a doubled apostrophe is the only escape in +// a string literal. Under mdl 0 a backslash also escapes — `\n` is a newline, +// `\'` an apostrophe, `\\` one backslash — which contradicts Mendix's own +// expressions and makes `'C:\temp'` a tab. It changes what text means, so it is +// tied to the header (#732). +var backslashIsLiteral = langver.Change{ + Code: "MDL-V1-ESCAPE", + Since: langver.V1, + Old: "a backslash in a string literal is an escape (`\\n` is a newline, `\\t` a tab, `\\'` an apostrophe, `\\\\` one backslash)", + New: "an ordinary character, as in a Mendix expression; `''` is the only escape", +} + +// newScriptStream is the character stream a script is lexed from. Whether a +// backslash escapes decides where a string literal ends, so the rule is fixed +// here, from the header, before the first token (see StrictEscapeStream). +func newScriptStream(input string) antlr.CharStream { + var is antlr.CharStream = antlr.NewInputStream(input) + if backslashIsLiteral.Applies(langver.ScanHeader(input)) { + is = &parser.StrictEscapeStream{CharStream: is} + } + return is +} + +// unquoteStringLit is the value of a STRING_LITERAL (a terminal node, or a +// rule whose text is one), read under the escape rule it was lexed with. +func unquoteStringLit(n interface{ GetText() string }) string { + text := n.GetText() + if !lexedWithStrictEscapes(n) { + return unquoteString(text) + } + if len(text) >= 2 && text[0] == '\'' && text[len(text)-1] == '\'' { + text = text[1 : len(text)-1] + } + return strings.ReplaceAll(text, "''", "'") +} + +func lexedWithStrictEscapes(n any) bool { + var tok antlr.Token + switch x := n.(type) { + case antlr.TerminalNode: + tok = x.GetSymbol() + case antlr.ParserRuleContext: + tok = x.GetStart() + } + return tok != nil && parser.HasStrictEscapes(tok.GetInputStream()) +} + +// noteBackslashEscapes records MDL-V1-ESCAPE for every string literal of an +// mdl 0 script whose value would differ under mdl 1: one holding an escape +// unquoteString interprets. A backslash before any other character is kept +// as written under both, so it is not reported. +func (b *Builder) noteBackslashEscapes(tokens []antlr.Token) { + if backslashIsLiteral.Applies(b.langVersion) { + return + } + added := false + for _, t := range tokens { + if t.GetTokenType() != parser.MDLLexerSTRING_LITERAL || !hasInterpretedEscape(t.GetText()) { + continue + } + b.langNotes = append(b.langNotes, ast.LanguageNote{ + Line: t.GetLine(), + Code: backslashIsLiteral.Code, + Message: "the string " + t.GetText() + ": " + backslashIsLiteral.Warning(b.langVersion), + }) + added = true + } + if added { + sort.SliceStable(b.langNotes, func(i, j int) bool { return b.langNotes[i].Line < b.langNotes[j].Line }) + } +} + +// hasInterpretedEscape reports whether an mdl 0 string literal's text holds a +// backslash escape unquoteString turns into something else. +func hasInterpretedEscape(lit string) bool { + for i := 0; i+1 < len(lit); i++ { + if lit[i] != '\\' { + continue + } + switch lit[i+1] { + case 'n', 'r', 't', '\\', '\'': + return true + } + } + return false +} diff --git a/mdl/visitor/visitor_styling.go b/mdl/visitor/visitor_styling.go index d667e075b..54c25be95 100644 --- a/mdl/visitor/visitor_styling.go +++ b/mdl/visitor/visitor_styling.go @@ -58,21 +58,21 @@ func parseStylingAssignment(ctx *parser.AlterStylingAssignmentContext) ast.Styli assignment.Property = "Class" assignment.IsCSS = true if sl := ctx.STRING_LITERAL(0); sl != nil { - assignment.Value = unquoteString(sl.GetText()) + assignment.Value = unquoteStringLit(sl) } } else if ctx.STYLE() != nil { // STYLE = 'value' (CSS appearance, not a design property) assignment.Property = "Style" assignment.IsCSS = true if sl := ctx.STRING_LITERAL(0); sl != nil { - assignment.Value = unquoteString(sl.GetText()) + assignment.Value = unquoteStringLit(sl) } } else { // STRING_LITERAL = STRING_LITERAL | ON | OFF (design property; the key // may itself be 'Class' or 'Style' — that is a design property, not CSS) literals := ctx.AllSTRING_LITERAL() if len(literals) > 0 { - assignment.Property = unquoteString(literals[0].GetText()) + assignment.Property = unquoteStringLit(literals[0]) } if ctx.ON() != nil { assignment.IsToggle = true @@ -81,7 +81,7 @@ func parseStylingAssignment(ctx *parser.AlterStylingAssignmentContext) ast.Styli assignment.IsToggle = true assignment.ToggleOn = false } else if len(literals) > 1 { - assignment.Value = unquoteString(literals[1].GetText()) + assignment.Value = unquoteStringLit(literals[1]) } } diff --git a/mdl/visitor/visitor_translations.go b/mdl/visitor/visitor_translations.go index 0f7c714b7..6271d4507 100644 --- a/mdl/visitor/visitor_translations.go +++ b/mdl/visitor/visitor_translations.go @@ -47,8 +47,8 @@ func (b *Builder) ExitCreateTranslationsStatement(ctx *parser.CreateTranslations continue } stmt.Entries = append(stmt.Entries, ast.TranslationEntry{ - Source: unquoteString(lits[0].GetText()), - Target: unquoteString(lits[1].GetText()), + Source: unquoteStringLit(lits[0]), + Target: unquoteStringLit(lits[1]), }) } diff --git a/mdl/visitor/visitor_validationrule.go b/mdl/visitor/visitor_validationrule.go index 54d98790d..0a34b03a5 100644 --- a/mdl/visitor/visitor_validationrule.go +++ b/mdl/visitor/visitor_validationrule.go @@ -14,7 +14,7 @@ func (b *Builder) ExitCreateValidationRuleStatement(ctx *parser.CreateValidation Attribute: buildQualifiedName(ctx.QualifiedName()), } if s := ctx.STRING_LITERAL(); s != nil { - stmt.Feedback = unquoteString(s.GetText()) + stmt.Feedback = unquoteStringLit(s) } constraint, ok := ctx.ValidationRuleConstraint().(*parser.ValidationRuleConstraintContext) diff --git a/mdl/visitor/visitor_widgets.go b/mdl/visitor/visitor_widgets.go index 160051ef4..15e0940f2 100644 --- a/mdl/visitor/visitor_widgets.go +++ b/mdl/visitor/visitor_widgets.go @@ -32,7 +32,7 @@ func parseWidgetConditions(conditions []parser.IWidgetConditionContext) []ast.Wi // Get value if cond.STRING_LITERAL() != nil { - filter.Value = unquoteString(cond.STRING_LITERAL().GetText()) + filter.Value = unquoteStringLit(cond.STRING_LITERAL()) } filters = append(filters, filter) @@ -53,7 +53,7 @@ func (b *Builder) ExitUpdateWidgetsStatement(ctx *parser.UpdateWidgetsStatementC // Get property path (the first STRING_LITERAL) if assignCtx.STRING_LITERAL() != nil { - assignment.PropertyPath = unquoteString(assignCtx.STRING_LITERAL().GetText()) + assignment.PropertyPath = unquoteStringLit(assignCtx.STRING_LITERAL()) } // Get value from widgetPropertyValue @@ -85,7 +85,7 @@ func (b *Builder) ExitUpdateWidgetsStatement(ctx *parser.UpdateWidgetsStatementC // parseWidgetPropertyValue parses a widget property value (string, number, bool, null). func parseWidgetPropertyValue(ctx parser.IWidgetPropertyValueContext) any { if ctx.STRING_LITERAL() != nil { - return unquoteString(ctx.STRING_LITERAL().GetText()) + return unquoteStringLit(ctx.STRING_LITERAL()) } if ctx.NUMBER_LITERAL() != nil { numStr := ctx.NUMBER_LITERAL().GetText() diff --git a/mdl/visitor/visitor_workflow.go b/mdl/visitor/visitor_workflow.go index 8d688f398..f236c3397 100644 --- a/mdl/visitor/visitor_workflow.go +++ b/mdl/visitor/visitor_workflow.go @@ -38,7 +38,7 @@ func (b *Builder) ExitCreateWorkflowStatement(ctx *parser.CreateWorkflowStatemen switch { case hc.FOLDER() != nil: if tok := hc.GetFolder(); tok != nil { - stmt.Folder = unquoteString(tok.GetText()) + stmt.Folder = unquoteStringLit(tok) } case hc.PARAMETER() != nil: if v := hc.VARIABLE(); v != nil { @@ -49,11 +49,11 @@ func (b *Builder) ExitCreateWorkflowStatement(ctx *parser.CreateWorkflowStatemen } case hc.DISPLAY() != nil: if tok := hc.GetDisplay(); tok != nil { - stmt.DisplayName = unquoteString(tok.GetText()) + stmt.DisplayName = unquoteStringLit(tok) } case hc.DESCRIPTION() != nil: if tok := hc.GetDescription(); tok != nil { - stmt.Description = unquoteString(tok.GetText()) + stmt.Description = unquoteStringLit(tok) } case hc.EXPORT() != nil && hc.LEVEL() != nil: // HIDDEN_KW is read alongside IDENTIFIER because `Hidden` was an @@ -75,7 +75,7 @@ func (b *Builder) ExitCreateWorkflowStatement(ctx *parser.CreateWorkflowStatemen } case hc.DUE() != nil && hc.DATE_TYPE() != nil: if tok := hc.GetDueDate(); tok != nil { - stmt.DueDate = unquoteString(tok.GetText()) + stmt.DueDate = unquoteStringLit(tok) } case hc.WorkflowEventHandlerClause() != nil: h, ok := hc.WorkflowEventHandlerClause().(*parser.WorkflowEventHandlerClauseContext) @@ -90,7 +90,7 @@ func (b *Builder) ExitCreateWorkflowStatement(ctx *parser.CreateWorkflowStatemen node.EventTypes = append(node.EventTypes, id.GetText()) } if str := h.STRING_LITERAL(); str != nil { - node.Description = unquoteString(str.GetText()) + node.Description = unquoteStringLit(str) } stmt.EventHandlers = append(stmt.EventHandlers, node) } @@ -209,7 +209,7 @@ func buildAlterWorkflowAction(ctx *parser.AlterWorkflowActionContext) ast.AlterW return nil } ref, atPos := parseAlterActivityRef(ctx.AlterActivityRef().(*parser.AlterActivityRefContext)) - outcomeName := unquoteString(ctx.STRING_LITERAL().GetText()) + outcomeName := unquoteStringLit(ctx.STRING_LITERAL()) var activities []ast.WorkflowActivityNode if body := ctx.WorkflowBody(); body != nil { activities = buildWorkflowBody(body) @@ -245,7 +245,7 @@ func buildAlterWorkflowAction(ctx *parser.AlterWorkflowActionContext) ast.AlterW return nil } ref, atPos := parseAlterActivityRef(ctx.AlterActivityRef().(*parser.AlterActivityRefContext)) - outcomeName := unquoteString(ctx.STRING_LITERAL().GetText()) + outcomeName := unquoteStringLit(ctx.STRING_LITERAL()) return &ast.DropOutcomeOp{ OutcomeName: outcomeName, ActivityRef: ref, @@ -259,7 +259,7 @@ func buildAlterWorkflowAction(ctx *parser.AlterWorkflowActionContext) ast.AlterW return nil } ref, atPos := parseAlterActivityRef(ctx.AlterActivityRef().(*parser.AlterActivityRefContext)) - pathCaption := unquoteString(ctx.STRING_LITERAL().GetText()) + pathCaption := unquoteStringLit(ctx.STRING_LITERAL()) return &ast.DropPathOp{ PathCaption: pathCaption, ActivityRef: ref, @@ -301,7 +301,7 @@ func buildAlterWorkflowAction(ctx *parser.AlterWorkflowActionContext) ast.AlterW return nil } ref, atPos := parseAlterActivityRef(ctx.AlterActivityRef().(*parser.AlterActivityRefContext)) - condition := unquoteString(ctx.STRING_LITERAL().GetText()) + condition := unquoteStringLit(ctx.STRING_LITERAL()) var activities []ast.WorkflowActivityNode if body := ctx.WorkflowBody(); body != nil { activities = buildWorkflowBody(body) @@ -320,7 +320,7 @@ func buildAlterWorkflowAction(ctx *parser.AlterWorkflowActionContext) ast.AlterW return nil } ref, atPos := parseAlterActivityRef(ctx.AlterActivityRef().(*parser.AlterActivityRefContext)) - branchName := unquoteString(ctx.STRING_LITERAL().GetText()) + branchName := unquoteStringLit(ctx.STRING_LITERAL()) return &ast.DropBranchOp{ BranchName: branchName, ActivityRef: ref, @@ -337,10 +337,10 @@ func buildWorkflowSetPropertyOp(ctx *parser.WorkflowSetPropertyContext) *ast.Set if ctx.DISPLAY() != nil { op.Property = "display" - op.Value = unquoteString(ctx.STRING_LITERAL().GetText()) + op.Value = unquoteStringLit(ctx.STRING_LITERAL()) } else if ctx.DESCRIPTION() != nil { op.Property = "description" - op.Value = unquoteString(ctx.STRING_LITERAL().GetText()) + op.Value = unquoteStringLit(ctx.STRING_LITERAL()) } else if ctx.EXPORT() != nil { op.Property = "export_level" // HIDDEN_KW alongside IDENTIFIER — see the CREATE side above. @@ -354,7 +354,7 @@ func buildWorkflowSetPropertyOp(ctx *parser.WorkflowSetPropertyContext) *ast.Set } } else if ctx.DUE() != nil { op.Property = "due_date" - op.Value = unquoteString(ctx.STRING_LITERAL().GetText()) + op.Value = unquoteStringLit(ctx.STRING_LITERAL()) } else if ctx.OVERVIEW() != nil { op.Property = "overview_page" if qn := ctx.QualifiedName(); qn != nil { @@ -385,7 +385,7 @@ func buildActivitySetPropertyOp(ctx *parser.ActivitySetPropertyContext, ref stri } } else if ctx.DESCRIPTION() != nil { op.Property = "description" - op.Value = unquoteString(ctx.STRING_LITERAL().GetText()) + op.Value = unquoteStringLit(ctx.STRING_LITERAL()) } else if ctx.TARGETING() != nil && ctx.MICROFLOW() != nil { op.Property = "targeting_microflow" if qn := ctx.QualifiedName(); qn != nil { @@ -393,10 +393,10 @@ func buildActivitySetPropertyOp(ctx *parser.ActivitySetPropertyContext, ref stri } } else if ctx.TARGETING() != nil && ctx.XPATH() != nil { op.Property = "targeting_xpath" - op.Value = unquoteString(ctx.STRING_LITERAL().GetText()) + op.Value = unquoteStringLit(ctx.STRING_LITERAL()) } else if ctx.DUE() != nil { op.Property = "due_date" - op.Value = unquoteString(ctx.STRING_LITERAL().GetText()) + op.Value = unquoteStringLit(ctx.STRING_LITERAL()) } return op @@ -411,7 +411,7 @@ func parseAlterActivityRef(ctx *parser.AlterActivityRefContext) (string, int) { if iok := ctx.IdentifierOrKeyword(); iok != nil { name = identifierOrKeywordText(iok) } else if ctx.STRING_LITERAL() != nil { - name = unquoteString(ctx.STRING_LITERAL().GetText()) + name = unquoteStringLit(ctx.STRING_LITERAL()) } atPos := 0 @@ -468,7 +468,7 @@ func buildWorkflowStatements(children []antlr.Tree) []ast.WorkflowActivityNode { func buildWorkflowEnd(ctx *parser.WorkflowEndStmtContext) *ast.WorkflowEndNode { node := &ast.WorkflowEndNode{} if ctx.COMMENT() != nil && ctx.STRING_LITERAL() != nil { - node.Caption = unquoteString(ctx.STRING_LITERAL().GetText()) + node.Caption = unquoteStringLit(ctx.STRING_LITERAL()) } return node } @@ -540,7 +540,7 @@ func buildWorkflowUserTask(ctx parser.IWorkflowUserTaskStmtContext) *ast.Workflo // The caption is the statement's own string literal; every other string now // belongs to a clause. if caption := utCtx.STRING_LITERAL(); caption != nil { - node.Caption = unquoteString(caption.GetText()) + node.Caption = unquoteStringLit(caption) } for _, clause := range utCtx.AllWorkflowUserTaskClause() { @@ -601,7 +601,7 @@ func applyWorkflowUserTaskClause(node *ast.WorkflowUserTaskNode, clause parser.I node.Targeting.Kind = "xpath" } if str := c.STRING_LITERAL(); str != nil { - node.Targeting.XPath = unquoteString(str.GetText()) + node.Targeting.XPath = unquoteStringLit(str) } case c.ON() != nil && c.CREATED() != nil: if qn := c.QualifiedName(); qn != nil { @@ -613,11 +613,11 @@ func applyWorkflowUserTaskClause(node *ast.WorkflowUserTaskNode, clause parser.I } case c.DUE() != nil && c.DATE_TYPE() != nil: if str := c.STRING_LITERAL(); str != nil { - node.DueDate = unquoteString(str.GetText()) + node.DueDate = unquoteStringLit(str) } case c.DESCRIPTION() != nil: if str := c.STRING_LITERAL(); str != nil { - node.TaskDescription = unquoteString(str.GetText()) + node.TaskDescription = unquoteStringLit(str) } case c.OUTCOMES() != nil: for _, outcomeCtx := range c.AllWorkflowUserTaskOutcome() { @@ -634,7 +634,7 @@ func applyWorkflowUserTaskClause(node *ast.WorkflowUserTaskNode, clause parser.I func buildWorkflowUserTaskOutcome(ctx parser.IWorkflowUserTaskOutcomeContext) ast.WorkflowUserTaskOutcomeNode { outCtx := ctx.(*parser.WorkflowUserTaskOutcomeContext) outcome := ast.WorkflowUserTaskOutcomeNode{ - Caption: unquoteString(outCtx.STRING_LITERAL().GetText()), + Caption: unquoteStringLit(outCtx.STRING_LITERAL()), } if body := outCtx.WorkflowBody(); body != nil { outcome.Activities = buildWorkflowBody(body) @@ -668,7 +668,7 @@ func buildWorkflowCallMicroflow(ctx parser.IWorkflowCallMicroflowStmtContext) *a } if cmCtx.COMMENT() != nil && cmCtx.STRING_LITERAL() != nil { - node.Caption = unquoteString(cmCtx.STRING_LITERAL().GetText()) + node.Caption = unquoteStringLit(cmCtx.STRING_LITERAL()) } for _, outcomeCtx := range cmCtx.AllWorkflowConditionOutcome() { @@ -724,7 +724,7 @@ func buildWorkflowParameterMappings(ctxs []parser.IWorkflowParameterMappingConte } out = append(out, ast.WorkflowParameterMappingNode{ Parameter: bareWorkflowParameterName(pmCtx2.QualifiedName().GetText()), - Expression: unquoteString(pmCtx2.STRING_LITERAL().GetText()), + Expression: unquoteStringLit(pmCtx2.STRING_LITERAL()), }) } return out @@ -739,7 +739,7 @@ func buildWorkflowCallWorkflow(ctx parser.IWorkflowCallWorkflowStmtContext) *ast } if cwCtx.COMMENT() != nil && cwCtx.STRING_LITERAL() != nil { - node.Caption = unquoteString(cwCtx.STRING_LITERAL().GetText()) + node.Caption = unquoteStringLit(cwCtx.STRING_LITERAL()) } // Parameter mappings @@ -761,16 +761,16 @@ func buildWorkflowDecision(ctx parser.IWorkflowDecisionStmtContext) *ast.Workflo // First STRING_LITERAL is the expression (if present and COMMENT is not present or expression comes first) if len(allStrings) > 0 && dCtx.COMMENT() == nil { // All strings are expression - node.Expression = unquoteString(allStrings[0].GetText()) + node.Expression = unquoteStringLit(allStrings[0]) stringIdx = 1 } else if len(allStrings) > 0 && dCtx.COMMENT() != nil { // Distinguish expression from comment if len(allStrings) >= 2 { - node.Expression = unquoteString(allStrings[0].GetText()) - node.Caption = unquoteString(allStrings[1].GetText()) + node.Expression = unquoteStringLit(allStrings[0]) + node.Caption = unquoteStringLit(allStrings[1]) } else { // Only one string with COMMENT - it's the caption - node.Caption = unquoteString(allStrings[0].GetText()) + node.Caption = unquoteStringLit(allStrings[0]) } stringIdx = len(allStrings) } @@ -796,7 +796,7 @@ func buildWorkflowConditionOutcome(ctx parser.IWorkflowConditionOutcomeContext) } else if coCtx.DEFAULT() != nil { outcome.Value = "Default" } else if coCtx.STRING_LITERAL() != nil { - outcome.Value = unquoteString(coCtx.STRING_LITERAL().GetText()) + outcome.Value = unquoteStringLit(coCtx.STRING_LITERAL()) } if body := coCtx.WorkflowBody(); body != nil { @@ -814,7 +814,7 @@ func buildWorkflowParallelSplit(ctx parser.IWorkflowParallelSplitStmtContext) *a } if psCtx.COMMENT() != nil && psCtx.STRING_LITERAL() != nil { - node.Caption = unquoteString(psCtx.STRING_LITERAL().GetText()) + node.Caption = unquoteStringLit(psCtx.STRING_LITERAL()) } for _, pathCtx := range psCtx.AllWorkflowParallelPath() { @@ -857,7 +857,7 @@ func buildWorkflowJumpTo(ctx parser.IWorkflowJumpToStmtContext) *ast.WorkflowJum } if jtCtx.COMMENT() != nil && jtCtx.STRING_LITERAL() != nil { - node.Caption = unquoteString(jtCtx.STRING_LITERAL().GetText()) + node.Caption = unquoteStringLit(jtCtx.STRING_LITERAL()) } return node @@ -872,12 +872,12 @@ func buildWorkflowWaitForTimer(ctx parser.IWorkflowWaitForTimerStmtContext) *ast allStrings := wtCtx.AllSTRING_LITERAL() if len(allStrings) > 0 && wtCtx.COMMENT() == nil { - node.DelayExpression = unquoteString(allStrings[0].GetText()) + node.DelayExpression = unquoteStringLit(allStrings[0]) } else if len(allStrings) >= 2 && wtCtx.COMMENT() != nil { - node.DelayExpression = unquoteString(allStrings[0].GetText()) - node.Caption = unquoteString(allStrings[1].GetText()) + node.DelayExpression = unquoteStringLit(allStrings[0]) + node.Caption = unquoteStringLit(allStrings[1]) } else if len(allStrings) == 1 && wtCtx.COMMENT() != nil { - node.Caption = unquoteString(allStrings[0].GetText()) + node.Caption = unquoteStringLit(allStrings[0]) } return node @@ -891,7 +891,7 @@ func buildWorkflowWaitForNotification(ctx parser.IWorkflowWaitForNotificationStm } if wnCtx.COMMENT() != nil && wnCtx.STRING_LITERAL() != nil { - node.Caption = unquoteString(wnCtx.STRING_LITERAL().GetText()) + node.Caption = unquoteStringLit(wnCtx.STRING_LITERAL()) } // BoundaryEvents (Issue #7) @@ -922,9 +922,9 @@ func buildBoundaryEventNode(beCtx parser.IWorkflowBoundaryEventClauseContext) as // A timer's string is its delay; a notification event's is its caption. if s := beCtx2.STRING_LITERAL(); s != nil { if notification { - be.Caption = unquoteString(s.GetText()) + be.Caption = unquoteStringLit(s) } else { - be.Delay = unquoteString(s.GetText()) + be.Delay = unquoteStringLit(s) } } if notification { @@ -941,7 +941,7 @@ func buildWorkflowNotification(ctx parser.IWorkflowNotificationStmtContext) *ast c := ctx.(*parser.WorkflowNotificationStmtContext) node := &ast.WorkflowNotificationNode{Name: workflowActivityNameText(c.WorkflowActivityName())} if c.COMMENT() != nil && c.STRING_LITERAL() != nil { - node.Caption = unquoteString(c.STRING_LITERAL().GetText()) + node.Caption = unquoteStringLit(c.STRING_LITERAL()) } return node } @@ -956,7 +956,7 @@ func buildWorkflowEventSubProcess(ctx parser.IWorkflowEventSubProcessContext) as Interrupting: c.NON() == nil, } if s := c.STRING_LITERAL(); s != nil { - node.Caption = unquoteString(s.GetText()) + node.Caption = unquoteStringLit(s) } if t, ok := c.WorkflowEventSubProcessTrigger().(*parser.WorkflowEventSubProcessTriggerContext); ok && t != nil { node.Timer = t.TIMER() != nil @@ -965,13 +965,13 @@ func buildWorkflowEventSubProcess(ctx parser.IWorkflowEventSubProcessContext) as switch { case node.Timer: if len(strs) > 0 { - node.FirstExecutionTime = unquoteString(strs[0].GetText()) + node.FirstExecutionTime = unquoteStringLit(strs[0]) } if t.COMMENT() != nil && len(strs) > 1 { - node.StartCaption = unquoteString(strs[1].GetText()) + node.StartCaption = unquoteStringLit(strs[1]) } case len(strs) > 0: - node.StartCaption = unquoteString(strs[0].GetText()) + node.StartCaption = unquoteStringLit(strs[0]) } } if body := c.WorkflowBody(); body != nil { @@ -985,7 +985,7 @@ func buildWorkflowAnnotation(ctx parser.IWorkflowAnnotationStmtContext) *ast.Wor annCtx := ctx.(*parser.WorkflowAnnotationStmtContext) node := &ast.WorkflowAnnotationActivityNode{} if annCtx.STRING_LITERAL() != nil { - node.Text = unquoteString(annCtx.STRING_LITERAL().GetText()) + node.Text = unquoteStringLit(annCtx.STRING_LITERAL()) } return node } @@ -1040,7 +1040,7 @@ func buildWorkflowCompletionRule(ctx *parser.WorkflowCompletionClauseContext) *a case ctx.VETO() != nil: r.Rule = "veto" if s := ctx.STRING_LITERAL(); s != nil { - r.Veto = unquoteString(s.GetText()) + r.Veto = unquoteStringLit(s) } case ctx.MICROFLOW() != nil: r.Rule = "microflow" @@ -1049,7 +1049,7 @@ func buildWorkflowCompletionRule(ctx *parser.WorkflowCompletionClauseContext) *a } } if fb, ok := ctx.WorkflowFallbackClause().(*parser.WorkflowFallbackClauseContext); ok && fb != nil && fb.STRING_LITERAL() != nil { - r.Fallback = unquoteString(fb.STRING_LITERAL().GetText()) + r.Fallback = unquoteStringLit(fb.STRING_LITERAL()) r.HasFallback = true } return r diff --git a/mdl/visitor/visitor_xpath.go b/mdl/visitor/visitor_xpath.go index c31972ea2..6d11448f5 100644 --- a/mdl/visitor/visitor_xpath.go +++ b/mdl/visitor/visitor_xpath.go @@ -205,7 +205,7 @@ func buildXPathStepValue(ctx parser.IXpathStepValueContext) ast.Expression { return &ast.VariableExpr{Name: strings.TrimPrefix(v.GetText(), "$")} } if sl := svCtx.STRING_LITERAL(); sl != nil { - return &ast.LiteralExpr{Value: unquoteString(sl.GetText()), Kind: ast.LiteralString} + return &ast.LiteralExpr{Value: unquoteStringLit(sl), Kind: ast.LiteralString} } if nl := svCtx.NUMBER_LITERAL(); nl != nil { text := nl.GetText() From b3516b54e1bbde269a73df500384f3b01ebd4d0e Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:13:21 +0000 Subject: [PATCH 22/37] refactor: move the OData property did-you-mean into mdl/suggest The visitor needs the same suggestion for unknown property keys (#732) and cannot import the executor. No change in behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/executor/validate_odata_properties.go | 51 ++------------------ mdl/suggest/suggest.go | 57 +++++++++++++++++++++++ 2 files changed, 60 insertions(+), 48 deletions(-) create mode 100644 mdl/suggest/suggest.go diff --git a/mdl/executor/validate_odata_properties.go b/mdl/executor/validate_odata_properties.go index 069f86aa8..052fa0dc6 100644 --- a/mdl/executor/validate_odata_properties.go +++ b/mdl/executor/validate_odata_properties.go @@ -18,6 +18,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/suggest" ) // Known property names, in the spelling the syntax help uses. These are for the @@ -119,54 +120,8 @@ func unknownODataProps(location string, unknown, known []string) []linter.Violat } // closestProperty returns the known property a misspelling most likely meant, -// or "" when nothing is close enough to be worth guessing. Case-insensitive -// prefix/substring first, then a single edit. -func closestProperty(name string, known []string) string { - lower := strings.ToLower(name) - for _, k := range known { - lk := strings.ToLower(k) - if strings.HasPrefix(lk, lower) || strings.HasPrefix(lower, lk) || strings.Contains(lk, lower) { - return k - } - } - for _, k := range known { - if withinOneEdit(lower, strings.ToLower(k)) { - return k - } - } - return "" -} - -// withinOneEdit reports whether a and b differ by at most one insertion, -// deletion or substitution. -func withinOneEdit(a, b string) bool { - if a == b { - return true - } - if len(a) > len(b) { - a, b = b, a - } - if len(b)-len(a) > 1 { - return false - } - i, j, edits := 0, 0, 0 - for i < len(a) && j < len(b) { - if a[i] == b[j] { - i++ - j++ - continue - } - edits++ - if edits > 1 { - return false - } - if len(a) == len(b) { - i++ - } - j++ - } - return true -} +// or "" when nothing is close enough to be worth guessing. +func closestProperty(name string, known []string) string { return suggest.Closest(name, known) } // quotedConstantRef matches the text of a string literal that is really a // constant reference: `@Module.Name`. diff --git a/mdl/suggest/suggest.go b/mdl/suggest/suggest.go new file mode 100644 index 000000000..a13f8e56a --- /dev/null +++ b/mdl/suggest/suggest.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package suggest finds the name a misspelling most likely meant, for the +// "did you mean" part of an error about an unknown name. +package suggest + +import "strings" + +// Closest returns the known name a misspelling most likely meant, +// or "" when nothing is close enough to be worth guessing. Case-insensitive +// prefix/substring first, then a single edit. +func Closest(name string, known []string) string { + lower := strings.ToLower(name) + for _, k := range known { + lk := strings.ToLower(k) + if strings.HasPrefix(lk, lower) || strings.HasPrefix(lower, lk) || strings.Contains(lk, lower) { + return k + } + } + for _, k := range known { + if WithinOneEdit(lower, strings.ToLower(k)) { + return k + } + } + return "" +} + +// WithinOneEdit reports whether a and b differ by at most one insertion, +// deletion or substitution. +func WithinOneEdit(a, b string) bool { + if a == b { + return true + } + if len(a) > len(b) { + a, b = b, a + } + if len(b)-len(a) > 1 { + return false + } + i, j, edits := 0, 0, 0 + for i < len(a) && j < len(b) { + if a[i] == b[j] { + i++ + j++ + continue + } + edits++ + if edits > 1 { + return false + } + if len(a) == len(b) { + i++ + } + j++ + } + return true +} From 7e18dbd016b63d3033f14e3383e9d36af0d6bdb2 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:17:50 +0000 Subject: [PATCH 23/37] mdl 1: an unknown property key or a mis-shaped value is an error (#732) ADR-0010 R11. The REST client (service, basic auth and operation lists), published REST service, business event service, and the agent editor's model, knowledge base, consumed MCP service, agent and agent body blocks accepted any `Key: value` and read only the keys they knew, so `pathh: '/users'` parsed, checked and executed with the path missing. A value was also read by its shape rather than its key: `Response: json from $X` set the request body. Each list now has a schema (the keys its visitor reads, the value shapes each takes, and what describe writes). Under mdl 1 a property outside it is an error naming the key, the list and the nearest known key; under mdl 0 the list is read as before and check warns MDL-V1-PROP / MDL-V1-PROPVALUE. suggest.Closest now also tries two edits, counting a neighbour swap as one, for names of five letters or more (Verison, Passwd), which the OData did-you-mean gets too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/executor/strict_parsing_test.go | 8 + mdl/suggest/suggest.go | 41 ++- mdl/suggest/suggest_test.go | 22 ++ mdl/visitor/strict_properties_test.go | 225 +++++++++++++ mdl/visitor/visitor_agenteditor.go | 5 + mdl/visitor/visitor_alter_agenteditor.go | 1 + mdl/visitor/visitor_businessevents.go | 1 + mdl/visitor/visitor_rest.go | 15 +- mdl/visitor/visitor_strict_properties.go | 393 +++++++++++++++++++++++ 9 files changed, 707 insertions(+), 4 deletions(-) create mode 100644 mdl/suggest/suggest_test.go create mode 100644 mdl/visitor/strict_properties_test.go create mode 100644 mdl/visitor/visitor_strict_properties.go diff --git a/mdl/executor/strict_parsing_test.go b/mdl/executor/strict_parsing_test.go index af597d525..02de49751 100644 --- a/mdl/executor/strict_parsing_test.go +++ b/mdl/executor/strict_parsing_test.go @@ -23,6 +23,8 @@ var strictParsingCases = []struct { }{ {"missing semicolon", "show modules\nshow entities;", "MDL-V1-SEMI", "no terminating `;`"}, {"slash terminator", "show modules;\n/\nshow entities;", "MDL-V1-SLASH", "`/` is not a statement terminator"}, + {"unknown property key", "show modules;\ncreate published rest service M.S (Path: 'rest/s', Verison: '1.0') { };", "MDL-V1-PROP", "did you mean 'Version'"}, + {"mis-shaped property value", "show modules;\ncreate rest client M.Api (BaseUrl: 'https://x', Authentication: none) { operation Post { Method: post, Path: '/u', Response: json from $X } };", "MDL-V1-PROPVALUE", "takes none or json as $var"}, // A change of meaning, not a rejection: see TestStringEscapeUnderEachVersion. {"backslash escape", "show modules;\ncreate persistent entity M.N (T: String(20) default 'C:\\temp');", "MDL-V1-ESCAPE", ""}, } @@ -33,6 +35,12 @@ func countStatements(e *Executor, ran *int, defaults *[]any) { *ran++ return nil } + for _, s := range []ast.Statement{&ast.CreatePublishedRestServiceStmt{}, &ast.CreateRestClientStmt{}} { + e.registry.handlers[reflect.TypeOf(s)] = func(*ExecContext, ast.Statement) error { + *ran++ + return nil + } + } e.registry.handlers[reflect.TypeOf(&ast.CreateEntityStmt{})] = func(_ *ExecContext, s ast.Statement) error { *ran++ *defaults = append(*defaults, s.(*ast.CreateEntityStmt).Attributes[0].DefaultValue) diff --git a/mdl/suggest/suggest.go b/mdl/suggest/suggest.go index a13f8e56a..35bea2bb0 100644 --- a/mdl/suggest/suggest.go +++ b/mdl/suggest/suggest.go @@ -8,7 +8,9 @@ import "strings" // Closest returns the known name a misspelling most likely meant, // or "" when nothing is close enough to be worth guessing. Case-insensitive -// prefix/substring first, then a single edit. +// prefix/substring first, then a single edit, then — for a name of five +// letters or more — the nearest within two edits, counting a swap of two +// neighbouring letters as one (`Verison`, `Passwd`). func Closest(name string, known []string) string { lower := strings.ToLower(name) for _, k := range known { @@ -22,7 +24,42 @@ func Closest(name string, known []string) string { return k } } - return "" + if len(lower) < 5 { + return "" + } + best, bestDist := "", 3 + for _, k := range known { + if d := editDistance(lower, strings.ToLower(k)); d < bestDist { + best, bestDist = k, d + } + } + return best +} + +// editDistance is the optimal string alignment distance: insertions, +// deletions, substitutions and swaps of two neighbouring bytes. +func editDistance(a, b string) int { + d := make([][]int, len(a)+1) + for i := range d { + d[i] = make([]int, len(b)+1) + d[i][0] = i + } + for j := 0; j <= len(b); j++ { + d[0][j] = j + } + for i := 1; i <= len(a); i++ { + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost) + if i > 1 && j > 1 && a[i-1] == b[j-2] && a[i-2] == b[j-1] { + d[i][j] = min(d[i][j], d[i-2][j-2]+1) + } + } + } + return d[len(a)][len(b)] } // WithinOneEdit reports whether a and b differ by at most one insertion, diff --git a/mdl/suggest/suggest_test.go b/mdl/suggest/suggest_test.go new file mode 100644 index 000000000..e836b629d --- /dev/null +++ b/mdl/suggest/suggest_test.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 + +package suggest + +import "testing" + +func TestClosest(t *testing.T) { + known := []string{"Path", "Version", "ServiceName", "Password", "Enabled", "DisplayName"} + for in, want := range map[string]string{ + "pathh": "Path", // prefix + "Verison": "Version", // swapped neighbours + "Passwd": "Password", // two deletions + "Enabeld": "Enabled", // swapped neighbours + "DisplayNmae": "DisplayName", // swapped neighbours + "Colour": "", // nothing close + "Xyz": "", // too short to guess past one edit + } { + if got := Closest(in, known); got != want { + t.Errorf("Closest(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/mdl/visitor/strict_properties_test.go b/mdl/visitor/strict_properties_test.go new file mode 100644 index 000000000..08055bf20 --- /dev/null +++ b/mdl/visitor/strict_properties_test.go @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// A property list whose key is unknown, or whose value has a shape its key +// does not take, parses under mdl 0 as it always has — the property is +// ignored or read by its shape — and warns; under mdl 1 it is an error that +// names the key and, for a misspelling, the key meant (#732, ADR-0010 R11). +var strictPropertyCases = []struct { + name string + src string + code string + // what the mdl 1 error must say + want []string +}{ + { + "rest client operation key", + "create rest client M.Api (BaseUrl: 'https://x', Authentication: none) { operation GetUser { Method: get, pathh: '/users', Response: none } };", + "MDL-V1-PROP", []string{"unknown property 'pathh'", "REST client operation", "did you mean 'Path'"}, + }, + { + "rest client key", + "create rest client M.Api (BaseUrll: 'https://x', Authentication: none) { operation GetUser { Method: get, Path: '/u', Response: none } };", + "MDL-V1-PROP", []string{"unknown property 'BaseUrll'", "did you mean 'BaseUrl'"}, + }, + { + "rest client basic auth key", + "create rest client M.Api (BaseUrl: 'https://x', Authentication: basic (Username: 'u', Passwd: 'p')) { operation GetUser { Method: get, Path: '/u', Response: none } };", + "MDL-V1-PROP", []string{"unknown property 'Passwd'", "did you mean 'Password'"}, + }, + { + "response written as a body", + "create rest client M.Api (BaseUrl: 'https://x', Authentication: none) { operation Post { Method: post, Path: '/u', Response: json from $X } };", + "MDL-V1-PROPVALUE", []string{"'Response'", "json as $var"}, + }, + { + "body written as a response", + "create rest client M.Api (BaseUrl: 'https://x', Authentication: none) { operation Post { Method: post, Path: '/u', Body: status as $Y, Response: none } };", + "MDL-V1-PROPVALUE", []string{"'Body'", "json from $var"}, + }, + { + "rest client path as a number", + "create rest client M.Api (BaseUrl: 'https://x', Authentication: none) { operation Get { Method: get, Path: 42, Response: none } };", + "MDL-V1-PROPVALUE", []string{"'Path'", "a string"}, + }, + { + "published rest key", + "create published rest service M.S (Path: 'rest/s', Verison: '1.0') { };", + "MDL-V1-PROP", []string{"unknown property 'Verison'", "published REST service", "did you mean 'Version'"}, + }, + { + "business event service key", + "create business event service M.BE (ServiceNme: 'S', EventNamePrefix: '') { message Changed (Id: Long) publish; };", + "MDL-V1-PROP", []string{"unknown property 'ServiceNme'", "did you mean 'ServiceName'"}, + }, + { + "model key", + "create model M.GPT (Provider: MxCloudGenAI, Key: M.Key, DisplayNmae: 'GPT');", + "MDL-V1-PROP", []string{"unknown property 'DisplayNmae'", "model", "did you mean 'DisplayName'"}, + }, + { + "model provider as a string", + "create model M.GPT (Provider: 'MxCloudGenAI', Key: M.Key);", + "MDL-V1-PROPVALUE", []string{"'Provider'", "a name"}, + }, + { + "knowledge base key", + "create knowledge base M.KB (Provider: MxCloudGenAI, Key: M.Key, Enviroment: 'x');", + "MDL-V1-PROP", []string{"unknown property 'Enviroment'", "did you mean 'Environment'"}, + }, + { + "consumed mcp service key", + "create consumed mcp service M.Mcp (ProtocolVersion: v2025_03_26, Version: '1', Timeout: 30);", + "MDL-V1-PROP", []string{"unknown property 'Timeout'", "did you mean 'ConnectionTimeoutSeconds'"}, + }, + { + "agent key", + "create agent M.A (UsageType: Task, Model: M.GPT, Temprature: 0.5);", + "MDL-V1-PROP", []string{"unknown property 'Temprature'", "agent", "did you mean 'Temperature'"}, + }, + { + "agent model as a name", + "create agent M.A (UsageType: Task, Model: GPT);", + "MDL-V1-PROPVALUE", []string{"'Model'", "a qualified name"}, + }, + { + "agent tool block key", + "create agent M.A (UsageType: Task, Model: M.GPT) { tool T { Enabeld: true } };", + "MDL-V1-PROP", []string{"unknown property 'Enabeld'", "tool", "did you mean 'Enabled'"}, + }, +} + +func TestStrictPropertiesByVersion(t *testing.T) { + for _, tc := range strictPropertyCases { + t.Run(tc.name, func(t *testing.T) { + prog, errs := Build(tc.src) + if len(errs) > 0 { + t.Fatalf("mdl 0 must keep accepting it: %v", errs) + } + var notes []ast.LanguageNote + for _, n := range prog.LanguageNotes { + if strings.HasPrefix(n.Code, "MDL-V1-PROP") { + notes = append(notes, n) + } + } + if len(notes) != 1 || notes[0].Code != tc.code || notes[0].Line != 1 { + t.Fatalf("mdl 0: want one %s warning on line 1, got %+v", tc.code, notes) + } + + _, errs = Build("mdl 1;\n" + tc.src) + if len(errs) != 1 { + t.Fatalf("mdl 1: want one error, got %v", errs) + } + for _, w := range tc.want { + if !strings.Contains(errs[0].Error(), w) { + t.Errorf("mdl 1: the error does not say %q:\n%v", w, errs[0]) + } + } + if !strings.HasPrefix(errs[0].Error(), "line 2:") { + t.Errorf("mdl 1: the error must name the line: %v", errs[0]) + } + }) + } +} + +// Control: the forms describe writes, and every key each list reads, are +// accepted under mdl 1 without an error or a warning. +func TestStrictPropertiesAcceptWhatDescribeWrites(t *testing.T) { + for _, src := range []string{ + `create rest client M.Api ( + BaseUrl: 'https://x', + Authentication: basic (Username: @M.User, Password: $Pwd), + Folder: 'Integrations' +) +{ + operation GetUser { + Method: get, + Path: '/users/{id}', + Parameters: ($id: String), + Query: ($q: String), + Headers: ('Accept' = 'application/json', 'Auth' = 'Bearer ' + $Token), + Body: json from $Payload, + Timeout: 30, + Response: json as $Result + } + operation Put { Method: put, Path: '/x', Body: file from $F, Response: status as $S } + operation Tpl { Method: post, Path: '/x', Body: template '{}', Response: string as $S } + operation Map { Method: post, Path: '/x', Body: mapping M.Req { Name = name }, Response: mapping M.Res { Name = name } } + operation Del { Method: delete, Path: '/x', Response: file as $F } +};`, + "create rest client M.Open (OpenApi: 'spec.json', BaseUrl: 'https://x', Authentication: none);", + "create published rest service M.S (Path: 'rest/s', Version: '1.0', ServiceName: 'S', Folder: 'F') { };", + "create business event service M.BE (ServiceName: 'S', EventNamePrefix: '', Folder: 'F') { message Changed (Id: Long) publish; };", + "create model M.GPT (Provider: MxCloudGenAI, Key: M.Key, DisplayName: 'GPT', KeyName: 'k', KeyId: 'i', Environment: 'e', ResourceName: 'r', DeepLinkURL: 'u');", + "create knowledge base M.KB (Provider: MxCloudGenAI, Key: M.Key, ModelDisplayName: 'd', ModelName: 'n', KeyName: 'k', KeyId: 'i', Environment: 'e', DeepLinkURL: 'u');", + "create consumed mcp service M.Mcp (ProtocolVersion: 'v2025_03_26', Version: '1', ConnectionTimeoutSeconds: 30, Documentation: 'd');", + `create agent M.A ( + UsageType: Task, + Description: 'd', + Model: M.GPT, + Entity: M.Ctx, + Variables: ("Name": EntityAttribute, "Other": String), + MaxTokens: 100, + ToolChoice: Auto, + Temperature: 0.5, + TopP: 1, + SystemPrompt: $$You help.$$, + UserPrompt: 'Hi' +) +{ + mcp service M.Mcp { + Enabled: true, + Description: 'd' + } + tool T { + ToolType: Microflow, + Document: M.DoIt, + Enabled: false, + Description: 'd' + } + knowledge base KB { + Source: M.KB, + Collection: 'c', + MaxResults: 5, + Description: 'd', + Enabled: true + } +};`, + } { + prog, errs := Build("mdl 1;\n" + src) + if len(errs) > 0 { + t.Errorf("mdl 1 refused a well-formed property list: %v\n%s", errs, src) + continue + } + for _, n := range prog.LanguageNotes { + t.Errorf("mdl 1 warned on a well-formed property list: %+v", n) + } + prog, _ = Build(src) + for _, n := range prog.LanguageNotes { + if strings.HasPrefix(n.Code, "MDL-V1-PROP") { + t.Errorf("mdl 0 warned on a well-formed property list: %+v", n) + } + } + } +} + +// Under mdl 0 a mis-shaped value keeps its old reading: `Response: json from +// $X` sets the request body, as it always has. +func TestStrictPropertiesKeepTheOldReadingUnderMdl0(t *testing.T) { + prog, errs := Build(strictPropertyCases[3].src) + if len(errs) > 0 { + t.Fatal(errs) + } + op := prog.Statements[0].(*ast.CreateRestClientStmt).Operations[0] + if op.BodyType != "json" || op.BodyVariable != "$X" || op.ResponseType != "" { + t.Fatalf("mdl 0 changed what `Response: json from $X` builds: %+v", op) + } +} diff --git a/mdl/visitor/visitor_agenteditor.go b/mdl/visitor/visitor_agenteditor.go index 7e8420d05..6c3847bbc 100644 --- a/mdl/visitor/visitor_agenteditor.go +++ b/mdl/visitor/visitor_agenteditor.go @@ -23,6 +23,7 @@ func (b *Builder) ExitCreateModelStatement(ctx *parser.CreateModelStatementConte stmt.Folder = unquoteStringLit(lit) } + b.checkModelProperties(ctx.AllModelProperty(), &modelSchema) for _, p := range ctx.AllModelProperty() { propCtx := p.(*parser.ModelPropertyContext) // The first identifierOrKeyword is the property name; the second @@ -163,6 +164,7 @@ func (b *Builder) ExitCreateConsumedMCPServiceStatement(ctx *parser.CreateConsum stmt.Folder = unquoteStringLit(lit) } + b.checkModelProperties(ctx.AllModelProperty(), &consumedMCPServiceSchema) props := parseModelProps(ctx.AllModelProperty()) stmt.ProtocolVersion = props["protocolversion"] stmt.Version = props["version"] @@ -189,6 +191,7 @@ func (b *Builder) ExitCreateKnowledgeBaseStatement(ctx *parser.CreateKnowledgeBa stmt.Folder = unquoteStringLit(lit) } + b.checkModelProperties(ctx.AllModelProperty(), &knowledgeBaseSchema) props := parseModelProps(ctx.AllModelProperty()) stmt.Provider = props["provider"] if k, ok := props["key"]; ok { @@ -220,6 +223,7 @@ func (b *Builder) ExitCreateAgentStatement(ctx *parser.CreateAgentStatementConte stmt.Folder = unquoteStringLit(lit) } + b.checkModelProperties(ctx.AllModelProperty(), &agentSchema) props := parseModelProps(ctx.AllModelProperty()) stmt.UsageType = props["usagetype"] stmt.Description = props["description"] @@ -259,6 +263,7 @@ func (b *Builder) ExitCreateAgentStatement(ctx *parser.CreateAgentStatementConte bodyCtx := body.(*parser.AgentBodyContext) for _, block := range bodyCtx.AllAgentBodyBlock() { blk := block.(*parser.AgentBodyBlockContext) + b.checkAgentBodyBlock(blk) blockProps := parseModelProps(blk.AllModelProperty()) if blk.MCP() != nil && blk.SERVICE() != nil { diff --git a/mdl/visitor/visitor_alter_agenteditor.go b/mdl/visitor/visitor_alter_agenteditor.go index 8ed18de9a..94d43273b 100644 --- a/mdl/visitor/visitor_alter_agenteditor.go +++ b/mdl/visitor/visitor_alter_agenteditor.go @@ -76,6 +76,7 @@ func (b *Builder) applyAlterAgentAction(stmt *ast.AlterAgentStmt, ctx *parser.Al // KNOWLEDGE BASE block to the matching field on the ALTER AGENT stmt. // Mirrors the CREATE AGENT body-block parsing in ExitCreateAgentStatement. func (b *Builder) appendAgentBodyBlock(stmt *ast.AlterAgentStmt, blk *parser.AgentBodyBlockContext) { + b.checkAgentBodyBlock(blk) blockProps := parseModelProps(blk.AllModelProperty()) switch { diff --git a/mdl/visitor/visitor_businessevents.go b/mdl/visitor/visitor_businessevents.go index ee4a57bd4..89e7a9812 100644 --- a/mdl/visitor/visitor_businessevents.go +++ b/mdl/visitor/visitor_businessevents.go @@ -20,6 +20,7 @@ func (b *Builder) ExitCreateBusinessEventServiceStatement(ctx *parser.CreateBusi prop := propCtx.(*parser.OdataPropertyAssignmentContext) name := identifierOrKeywordText(prop.IdentifierOrKeyword()) value := odataAssignmentValueText(prop) + b.checkProperty(prop, &businessEventServiceSchema, name, odataPropertyShape(prop)) switch strings.ToLower(name) { case "servicename": diff --git a/mdl/visitor/visitor_rest.go b/mdl/visitor/visitor_rest.go index b7c0cbb4d..459469bac 100644 --- a/mdl/visitor/visitor_rest.go +++ b/mdl/visitor/visitor_rest.go @@ -30,7 +30,9 @@ func (b *Builder) ExitCreateRestClientStatement(ctx *parser.CreateRestClientStat if iok == nil { continue } - key := strings.ToLower(identifierOrKeywordText(iok.(*parser.IdentifierOrKeywordContext))) + rawKey := identifierOrKeywordText(iok.(*parser.IdentifierOrKeywordContext)) + b.checkProperty(pc, &restClientSchema, rawKey, restClientPropertyShape(pc)) + key := strings.ToLower(rawKey) switch key { case "baseurl": if sl := pc.STRING_LITERAL(); sl != nil { @@ -52,7 +54,9 @@ func (b *Builder) ExitCreateRestClientStatement(ctx *parser.CreateRestClientStat if !spOk || sp == nil || sp.IdentifierOrKeyword() == nil { continue } - subKey := strings.ToLower(identifierOrKeywordText(sp.IdentifierOrKeyword().(*parser.IdentifierOrKeywordContext))) + rawSubKey := identifierOrKeywordText(sp.IdentifierOrKeyword().(*parser.IdentifierOrKeywordContext)) + b.checkProperty(sp, &restClientBasicAuthSchema, rawSubKey, restClientPropertyShape(sp)) + subKey := strings.ToLower(rawSubKey) var val string if sl := sp.STRING_LITERAL(); sl != nil { val = unquoteStringLit(sl) @@ -85,6 +89,12 @@ func (b *Builder) ExitCreateRestClientStatement(ctx *parser.CreateRestClientStat if !ok || oc == nil { continue } + for _, p := range oc.AllRestClientOpProp() { + if pc, ok := p.(*parser.RestClientOpPropContext); ok && pc != nil && pc.IdentifierOrKeyword() != nil { + key := identifierOrKeywordText(pc.IdentifierOrKeyword().(*parser.IdentifierOrKeywordContext)) + b.checkProperty(pc, &restClientOperationSchema, key, restClientOpPropShape(pc)) + } + } opDef := parseRestClientOperation(oc) stmt.Operations = append(stmt.Operations, opDef) } @@ -357,6 +367,7 @@ func (b *Builder) ExitCreatePublishedRestServiceStatement(ctx *parser.CreatePubl for _, propCtx := range ctx.AllPublishedRestProperty() { pc := propCtx.(*parser.PublishedRestPropertyContext) key := identifierOrKeywordText(pc.IdentifierOrKeyword().(*parser.IdentifierOrKeywordContext)) + b.checkProperty(pc, &publishedRestSchema, key, shapeString) val := unquoteStringLit(pc.STRING_LITERAL()) switch strings.ToLower(key) { case "path": diff --git a/mdl/visitor/visitor_strict_properties.go b/mdl/visitor/visitor_strict_properties.go new file mode 100644 index 000000000..75c0ae8ea --- /dev/null +++ b/mdl/visitor/visitor_strict_properties.go @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "fmt" + "strings" + + "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/suggest" +) + +// ADR-0010 R11: an unknown property key, or a value whose shape its key does +// not take, is an error. The grammar accepts any `Key: value` in these lists, +// and the visitors read the keys they know and drop the rest, so a typo such +// as `pathh: '/users'` parsed, checked and executed with the path missing. +// A value was also read by its shape rather than its key: `Response: json from +// $X` set the request body. +// +// Both are new rejections, tied to the header (ADR-0011): under mdl 0 the list +// is read as before and each property warns. The OData lists already refuse +// an unknown key (MDL-ODATA01); these are the lists that did not (#732). + +var unknownPropertyKey = langver.Change{ + Code: "MDL-V1-PROP", + Since: langver.V1, + Old: "an unknown property key is accepted and ignored", + New: "an error", +} + +var misshapedPropertyValue = langver.Change{ + Code: "MDL-V1-PROPVALUE", + Since: langver.V1, + Old: "a value its key does not take is accepted, and ignored or read by its shape", + New: "an error", +} + +// propShape is one shape a property value can have, in the words an error +// message uses for it. +type propShape string + +const ( + shapeString propShape = "a string ('…')" + shapeDollar propShape = "a $$…$$ block" + shapeName propShape = "a name" + shapeQName propShape = "a qualified name (Module.Name)" + shapeNumber propShape = "a number" + shapeBool propShape = "true or false" + shapeVarList propShape = `a variable list ("Key": String, …)` + shapeVariable propShape = "a $variable" + shapeConstant propShape = "a constant (@Module.Name)" + shapeNone propShape = "none" + shapeBasic propShape = "basic (Username: …, Password: …)" + shapeMethod propShape = "an HTTP method (get, post, put, patch, delete)" + shapeParamList propShape = "a parameter list ($name: Type, …)" + shapeHeaderList propShape = "a header list ('Name' = value, …)" + shapeTemplate propShape = "template '…'" + shapeMapping propShape = "mapping Module.Entity { … }" + shapeJSONFrom propShape = "json from $var" + shapeFileFrom propShape = "file from $var" + shapeJSONAs propShape = "json as $var" + shapeStringAs propShape = "string as $var" + shapeFileAs propShape = "file as $var" + shapeStatusAs propShape = "status as $var" + shapeOther propShape = "some other value" +) + +// propKey is one key a property list reads, and the shapes it takes. +type propKey struct { + name string // the spelling the syntax help and describe use + shapes []propShape +} + +// propSchema is the keys one property list reads. +type propSchema struct { + on string // what the list belongs to, for the message: "a REST client operation" + keys []propKey +} + +func (s *propSchema) lookup(key string) (propKey, bool) { + for _, k := range s.keys { + if strings.EqualFold(k.name, key) { + return k, true + } + } + return propKey{}, false +} + +func (s *propSchema) names() []string { + out := make([]string, len(s.keys)) + for i, k := range s.keys { + out[i] = k.name + } + return out +} + +// checkProperty reports a property whose key s does not read, or whose value +// has a shape the key does not take: an error under mdl 1, a warning under +// mdl 0. It reports nothing for a well-formed property. The caller reads the +// property as it always has either way; under mdl 1 the error stops the script. +func (b *Builder) checkProperty(at antlr.ParserRuleContext, s *propSchema, key string, shape propShape) { + k, ok := s.lookup(key) + if !ok { + detail := fmt.Sprintf("unknown property '%s' on %s", key, s.on) + if near := suggest.Closest(key, s.names()); near != "" { + detail += fmt.Sprintf(" — did you mean '%s'?", near) + } else { + detail += "." + } + detail += " Known properties: " + strings.Join(s.names(), ", ") + b.strict(unknownPropertyKey, at, detail) + return + } + for _, want := range k.shapes { + if want == shape { + return + } + } + takes := make([]string, len(k.shapes)) + for i, sh := range k.shapes { + takes[i] = string(sh) + } + b.strict(misshapedPropertyValue, at, fmt.Sprintf("property '%s' on %s takes %s, not %s", + k.name, s.on, strings.Join(takes, " or "), shape)) +} + +// strict is gate for a new rejection whose message names the construct: an +// error from c.Since on, a warning with that detail before it. +func (b *Builder) strict(c langver.Change, at antlr.ParserRuleContext, detail string) { + line := 0 + if at != nil && at.GetStart() != nil { + line = at.GetStart().GetLine() + } + if c.Applies(b.langVersion) { + b.addError(fmt.Errorf("line %d: %s", line, detail)) + return + } + b.langNotes = append(b.langNotes, ast.LanguageNote{ + Line: line, + Code: c.Code, + Message: detail + "; " + c.Warning(b.langVersion), + }) +} + +// --------------------------------------------------------------------------- +// The schemas. Each lists exactly what its visitor reads, plus what describe +// writes for it, so describe output is accepted under mdl 1. +// --------------------------------------------------------------------------- + +var ( + textShapes = []propShape{shapeString, shapeDollar} + nameShapes = []propShape{shapeName, shapeString} +) + +var restClientSchema = propSchema{on: "a REST client", keys: []propKey{ + {"BaseUrl", []propShape{shapeString}}, + {"Authentication", []propShape{shapeNone, shapeBasic}}, + {"OpenApi", []propShape{shapeString}}, + {"Folder", []propShape{shapeString}}, +}} + +var restClientBasicAuthSchema = propSchema{on: "REST client basic authentication", keys: []propKey{ + {"Username", []propShape{shapeString, shapeConstant, shapeVariable}}, + {"Password", []propShape{shapeString, shapeConstant, shapeVariable}}, +}} + +var restClientOperationSchema = propSchema{on: "a REST client operation", keys: []propKey{ + {"Method", []propShape{shapeMethod}}, + {"Path", []propShape{shapeString}}, + {"Parameters", []propShape{shapeParamList}}, + {"Query", []propShape{shapeParamList}}, + {"Headers", []propShape{shapeHeaderList}}, + {"Body", []propShape{shapeJSONFrom, shapeFileFrom, shapeTemplate, shapeMapping}}, + {"Timeout", []propShape{shapeNumber}}, + {"Response", []propShape{shapeNone, shapeJSONAs, shapeStringAs, shapeFileAs, shapeStatusAs, shapeMapping}}, +}} + +var publishedRestSchema = propSchema{on: "a published REST service", keys: []propKey{ + {"Path", []propShape{shapeString}}, + {"Version", []propShape{shapeString}}, + {"ServiceName", []propShape{shapeString}}, + {"Folder", []propShape{shapeString}}, +}} + +var businessEventServiceSchema = propSchema{on: "a business event service", keys: []propKey{ + {"ServiceName", []propShape{shapeString}}, + {"EventNamePrefix", []propShape{shapeString}}, + {"Folder", []propShape{shapeString}}, +}} + +// The model visitor reads each value by one alternative only: a Provider +// written as a string was dropped. +var modelSchema = propSchema{on: "a model", keys: []propKey{ + {"Provider", []propShape{shapeName}}, + {"Key", []propShape{shapeQName}}, + {"DisplayName", []propShape{shapeString}}, + {"KeyName", []propShape{shapeString}}, + {"KeyId", []propShape{shapeString}}, + {"Environment", []propShape{shapeString}}, + {"ResourceName", []propShape{shapeString}}, + {"DeepLinkURL", []propShape{shapeString}}, +}} + +var knowledgeBaseSchema = propSchema{on: "a knowledge base", keys: []propKey{ + {"Provider", nameShapes}, + {"Key", []propShape{shapeQName}}, + {"ModelDisplayName", []propShape{shapeString}}, + {"ModelName", []propShape{shapeString}}, + {"KeyName", []propShape{shapeString}}, + {"KeyId", []propShape{shapeString}}, + {"Environment", []propShape{shapeString}}, + {"DeepLinkURL", []propShape{shapeString}}, +}} + +var consumedMCPServiceSchema = propSchema{on: "a consumed MCP service", keys: []propKey{ + {"ProtocolVersion", nameShapes}, + {"Version", []propShape{shapeString}}, + {"ConnectionTimeoutSeconds", []propShape{shapeNumber}}, + {"Documentation", textShapes}, +}} + +var agentSchema = propSchema{on: "an agent", keys: []propKey{ + {"UsageType", nameShapes}, + {"Description", textShapes}, + {"Model", []propShape{shapeQName}}, + {"Entity", []propShape{shapeQName}}, + {"Variables", []propShape{shapeVarList}}, + {"MaxTokens", []propShape{shapeNumber}}, + {"ToolChoice", nameShapes}, + {"Temperature", []propShape{shapeNumber}}, + {"TopP", []propShape{shapeNumber}}, + {"SystemPrompt", textShapes}, + {"UserPrompt", textShapes}, +}} + +var agentMCPServiceSchema = propSchema{on: "an agent's mcp service block", keys: []propKey{ + {"Enabled", []propShape{shapeBool}}, + {"Description", textShapes}, +}} + +// ToolType and Document are what describe writes for a tool; create does not +// read them yet, the tool's kind and target coming from the block itself. +var agentToolSchema = propSchema{on: "an agent's tool block", keys: []propKey{ + {"Enabled", []propShape{shapeBool}}, + {"Description", textShapes}, + {"ToolType", []propShape{shapeName}}, + {"Document", []propShape{shapeQName}}, +}} + +var agentKnowledgeBaseSchema = propSchema{on: "an agent's knowledge base block", keys: []propKey{ + {"Source", []propShape{shapeQName}}, + {"Collection", []propShape{shapeString}}, + {"Description", textShapes}, + {"MaxResults", []propShape{shapeNumber}}, + {"Enabled", []propShape{shapeBool}}, +}} + +// --------------------------------------------------------------------------- +// Value shapes, read off the parse tree. +// --------------------------------------------------------------------------- + +// modelPropertyShape is the shape of a `Key: value` in an agent-editor list. +func modelPropertyShape(pc *parser.ModelPropertyContext) propShape { + idents := pc.AllIdentifierOrKeyword() + switch { + case pc.QualifiedName() != nil: + return shapeQName + case pc.STRING_LITERAL() != nil: + return shapeString + case pc.DOLLAR_STRING() != nil: + return shapeDollar + case pc.NUMBER_LITERAL() != nil: + return shapeNumber + case pc.BooleanLiteral() != nil: + return shapeBool + case pc.VariableDefList() != nil: + return shapeVarList + case len(idents) > 1: + // `Enabled: true` can reach the name alternative, and reads as true. + if t := strings.ToLower(idents[1].GetText()); t == "true" || t == "false" { + return shapeBool + } + return shapeName + } + return shapeOther +} + +// checkModelProperties checks every property of an agent-editor list. +func (b *Builder) checkModelProperties(props []parser.IModelPropertyContext, s *propSchema) { + for _, p := range props { + pc, ok := p.(*parser.ModelPropertyContext) + if !ok || pc == nil { + continue + } + idents := pc.AllIdentifierOrKeyword() + if len(idents) == 0 { + continue + } + b.checkProperty(pc, s, idents[0].GetText(), modelPropertyShape(pc)) + } +} + +// checkAgentBodyBlock checks a TOOL, MCP SERVICE or KNOWLEDGE BASE block of +// an agent, in create agent and in alter agent … add. +func (b *Builder) checkAgentBodyBlock(blk *parser.AgentBodyBlockContext) { + switch { + case blk.MCP() != nil && blk.SERVICE() != nil: + b.checkModelProperties(blk.AllModelProperty(), &agentMCPServiceSchema) + case blk.KNOWLEDGE() != nil && blk.BASE() != nil: + b.checkModelProperties(blk.AllModelProperty(), &agentKnowledgeBaseSchema) + case blk.TOOL() != nil: + b.checkModelProperties(blk.AllModelProperty(), &agentToolSchema) + } +} + +// restClientPropertyShape is the shape of a REST client's `Key: value`. +func restClientPropertyShape(pc *parser.RestClientPropertyContext) propShape { + switch { + case pc.BASIC() != nil: + return shapeBasic + case pc.NONE() != nil: + return shapeNone + case pc.STRING_LITERAL() != nil: + return shapeString + case pc.VARIABLE() != nil: + return shapeVariable + case pc.AT() != nil: + return shapeConstant + } + return shapeOther +} + +// restClientOpPropShape is the shape of a REST client operation's `Key: value`. +func restClientOpPropShape(pc *parser.RestClientOpPropContext) propShape { + switch { + case pc.RestHttpMethod() != nil: + return shapeMethod + case pc.MAPPING() != nil: + return shapeMapping + case pc.TEMPLATE() != nil: + return shapeTemplate + case pc.VARIABLE() != nil: + kind := "json" + switch { + case pc.FILE_KW() != nil: + kind = "file" + case pc.STRING_TYPE() != nil: + kind = "string" + case pc.STATUS() != nil: + kind = "status" + } + if pc.FROM() != nil { + return propShape(kind + " from $var") + } + return propShape(kind + " as $var") + case pc.NONE() != nil: + return shapeNone + case len(pc.AllRestClientParamItem()) > 0: + return shapeParamList + case len(pc.AllRestClientHeaderItem()) > 0: + return shapeHeaderList + case pc.STRING_LITERAL() != nil: + return shapeString + case pc.NUMBER_LITERAL() != nil: + return shapeNumber + } + return shapeOther +} + +// odataPropertyShape is the shape of a `Key: value` in an OData-style list, +// which the business event service shares. +func odataPropertyShape(pc *parser.OdataPropertyAssignmentContext) propShape { + v, ok := pc.OdataPropertyValue().(*parser.OdataPropertyValueContext) + if !ok || v == nil { + return shapeOther + } + switch { + case v.STRING_LITERAL() != nil: + return shapeString + case v.NUMBER_LITERAL() != nil: + return shapeNumber + case v.TRUE() != nil || v.FALSE() != nil: + return shapeBool + case v.AT() != nil: + return shapeConstant + case v.QualifiedName() != nil && v.MICROFLOW() == nil: + return shapeQName + } + return shapeOther +} From 0a98db1b05954a768cb444f5685191adf0961f34 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:19:29 +0000 Subject: [PATCH 24/37] =?UTF-8?q?mdl:=20retrieve=20=E2=80=A6=20first=20is?= =?UTF-8?q?=20the=20object;=20limit=201=20is=20a=20list=20of=20one=20under?= =?UTF-8?q?=20mdl=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `first` is Mendix's "First object" range in every language version. Under the mdl 1 header a bare `limit 1` is a Custom range, a list of one; without it the alpha meaning (the object) is kept and warns MDL-V1-LIMIT1. The visitor resolves the meaning into RetrieveStmt.First, so the writer, the variable typing and MDL-RETRIEVE01 no longer read limit text. describe prints the object range as `first`. `first` on an association retrieve is refused (that source has no range). MDL-RETRIEVE01 keys on the object range and names CE0100 for a loop, as measured with mx check 11.13. Tests: visitor per version, builder range per version, check per version, describe spelling, and a PedApp round trip of both forms with a headerless control. Part of #734. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/ast/ast_microflow.go | 21 ++- .../cmd_microflows_builder_actions.go | 24 +-- .../cmd_microflows_builder_validate.go | 2 +- mdl/executor/cmd_microflows_format_action.go | 6 +- .../cmd_microflows_format_action_test.go | 4 +- .../cmd_microflows_retrieve_first_test.go | 148 ++++++++++++++++ mdl/executor/roundtrip_microflow_test.go | 4 +- .../validate_microflow_retrieve_single.go | 62 +++---- ...validate_microflow_retrieve_single_test.go | 32 +++- mdl/grammar/domains/MDLMicroflow.g4 | 14 +- mdl/roundtrip/retrieve_range_test.go | 165 ++++++++++++++++++ mdl/visitor/retrieve_first_test.go | 119 +++++++++++++ mdl/visitor/visitor_microflow_statements.go | 8 +- mdl/visitor/visitor_retrieve_range.go | 105 +++++++++++ mdl/visitor/visitor_test.go | 7 +- 15 files changed, 653 insertions(+), 68 deletions(-) create mode 100644 mdl/executor/cmd_microflows_retrieve_first_test.go create mode 100644 mdl/roundtrip/retrieve_range_test.go create mode 100644 mdl/visitor/retrieve_first_test.go create mode 100644 mdl/visitor/visitor_retrieve_range.go diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 277f7c5f8..d8e41f029 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -551,16 +551,21 @@ type RollbackStmt struct { func (s *RollbackStmt) isMicroflowStatement() {} -// RetrieveStmt represents: RETRIEVE $Var FROM Entity [WHERE condition] [SORT BY ...] [LIMIT n] [OFFSET n] [ON ERROR ...] +// RetrieveStmt represents: RETRIEVE $Var FROM Entity [WHERE condition] [SORT BY ...] [FIRST | [LIMIT n] [OFFSET n]] [ON ERROR ...] // or: RETRIEVE $Var FROM $Parent/Module.Association (association retrieve) type RetrieveStmt struct { - Variable string // Output variable - Source QualifiedName // Entity (database) or Association (association retrieve) - StartVariable string // Non-empty for association retrieve: the starting variable name - Where Expression // Optional WHERE condition - SortColumns []SortColumnDef // Optional SORT BY columns - Limit string // Optional LIMIT expression (empty = no limit) - Offset string // Optional OFFSET expression (empty = no offset) + Variable string // Output variable + Source QualifiedName // Entity (database) or Association (association retrieve) + StartVariable string // Non-empty for association retrieve: the starting variable name + Where Expression // Optional WHERE condition + SortColumns []SortColumnDef // Optional SORT BY columns + // First is Mendix's "First object" range: the output is ONE object, not a + // list. It is set by `first`, and by `limit 1` without offset in an mdl 0 + // script (the alpha meaning, ako/mxcli#734); Limit and Offset are then empty. + // Everything downstream reads this, never the limit text. + First bool + Limit string // Optional LIMIT expression of the Custom range (empty = no limit) + Offset string // Optional OFFSET expression of the Custom range (empty = no offset) ErrorHandling *ErrorHandlingClause // Optional ON ERROR clause Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation } diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index ec6316e90..f0a00581c 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -1182,16 +1182,18 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { EntityQualifiedName: entityQN, } - // Set range if LIMIT is specified - if s.Limit != "" { - rangeType := microflows.RangeTypeCustom - // LIMIT 1 with no offset uses RangeTypeFirst for single object retrieval - if s.Limit == "1" && s.Offset == "" { - rangeType = microflows.RangeTypeFirst + // The range. The visitor has already resolved what `limit 1` means in + // the script's language version (ako/mxcli#734): First is the object + // range, a limit or offset the Custom range, which is always a list. + if s.First { + dbSource.Range = &microflows.Range{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + RangeType: microflows.RangeTypeFirst, } + } else if s.Limit != "" { dbSource.Range = &microflows.Range{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, - RangeType: rangeType, + RangeType: microflows.RangeTypeCustom, Limit: s.Limit, Offset: s.Offset, } @@ -1306,14 +1308,12 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { source = dbSource - // Register variable type for CHANGE statements - // RETRIEVE with LIMIT 1 returns a single entity, otherwise returns a List + // Register variable type for CHANGE statements: the object range binds + // one object, every other range a list. if fb.varTypes != nil { - if s.Limit == "1" { - // LIMIT 1 returns a single entity + if s.First { fb.varTypes[s.Variable] = entityQN } else { - // No LIMIT or LIMIT > 1 returns a list fb.varTypes[s.Variable] = "List of " + entityQN } } diff --git a/mdl/executor/cmd_microflows_builder_validate.go b/mdl/executor/cmd_microflows_builder_validate.go index edef1c2b2..376806600 100644 --- a/mdl/executor/cmd_microflows_builder_validate.go +++ b/mdl/executor/cmd_microflows_builder_validate.go @@ -302,7 +302,7 @@ func (fb *flowBuilder) validateStatement(stmt ast.MicroflowStatement) { if s.StartVariable != "" { // Association retrieve always returns a list fb.varTypes[s.Variable] = "List of " + s.Source.Module + "." + s.Source.Name - } else if s.Limit == "1" { + } else if s.First { fb.varTypes[s.Variable] = s.Source.Module + "." + s.Source.Name } else { fb.varTypes[s.Variable] = "List of " + s.Source.Module + "." + s.Source.Name diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index e8de99f43..a578ed47c 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -543,7 +543,11 @@ func formatAction( if dbSource.Range != nil { switch dbSource.Range.RangeType { case microflows.RangeTypeFirst: - stmt += "\n limit 1" + // `first`, not `limit 1`: under mdl 1 `limit 1` is a list of + // one, and `first` means the object in every version, so the + // output reads back to this range whatever the reader's + // header (ako/mxcli#734). + stmt += "\n first" case microflows.RangeTypeCustom: if dbSource.Range.Limit != "" { stmt += fmt.Sprintf("\n limit %s", dbSource.Range.Limit) diff --git a/mdl/executor/cmd_microflows_format_action_test.go b/mdl/executor/cmd_microflows_format_action_test.go index 427f7337b..792db3f71 100644 --- a/mdl/executor/cmd_microflows_format_action_test.go +++ b/mdl/executor/cmd_microflows_format_action_test.go @@ -791,7 +791,7 @@ func TestFormatAction_Retrieve_WithLimit(t *testing.T) { }, } got := e.formatAction(action, nil, nil) - want := "retrieve $First from MyModule.Customer\n limit 1;" + want := "retrieve $First from MyModule.Customer\n first;" // the object range (#734) if got != want { t.Errorf("got %q, want %q", got, want) } @@ -868,7 +868,7 @@ func TestFormatAction_Retrieve_ReverseAssociationRequiresSimpleAllRange(t *testi } got := e.formatAction(action, nil, nil) - want := "retrieve $Domains from SampleRuntime.Domain\n where SampleRuntime.Domain_Runtime = $Runtime\n limit 1;" + want := "retrieve $Domains from SampleRuntime.Domain\n where SampleRuntime.Domain_Runtime = $Runtime\n first;" if got != want { t.Errorf("got %q, want %q", got, want) } diff --git a/mdl/executor/cmd_microflows_retrieve_first_test.go b/mdl/executor/cmd_microflows_retrieve_first_test.go new file mode 100644 index 000000000..d98724834 --- /dev/null +++ b/mdl/executor/cmd_microflows_retrieve_first_test.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// `retrieve … first` vs `limit 1` (ako/mxcli#734): the writer stores the range +// the visitor resolved, and describe spells the object range `first`, which +// means the same in every language version. + +// retrieveRangeOf builds a retrieve from MDL source and returns the range the +// writer stores and the type the builder gives the output variable. +func retrieveRangeOf(t *testing.T, src string) (*microflows.Range, string) { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("%q: %v", src, errs) + } + var mf *ast.CreateMicroflowStmt + for _, s := range prog.Statements { + if m, ok := s.(*ast.CreateMicroflowStmt); ok { + mf = m + } + } + if mf == nil { + t.Fatalf("%q: no microflow", src) + } + r := mf.Body[0].(*ast.RetrieveStmt) + fb := &flowBuilder{varTypes: map[string]string{}} + fb.addRetrieveAction(r) + if len(fb.errors) > 0 { + t.Fatalf("%q: builder errors: %v", src, fb.errors) + } + act := fb.objects[0].(*microflows.ActionActivity).Action.(*microflows.RetrieveAction) + return act.Source.(*microflows.DatabaseRetrieveSource).Range, fb.varTypes[r.Variable] +} + +func TestRetrieveRange_WrittenPerLanguageVersion(t *testing.T) { + const body = "create microflow M.F () begin\n retrieve $O from M.Order where [Status = 'Open'] %s;\nend;" + cases := []struct { + name, src string + wantType microflows.RangeType + wantLimit string + wantObjectTyped bool + }{ + {"first, mdl 0", strings.Replace(body, "%s", "first", 1), microflows.RangeTypeFirst, "", true}, + {"first, mdl 1", "mdl 1;\n" + strings.Replace(body, "%s", "first", 1), microflows.RangeTypeFirst, "", true}, + {"limit 1, mdl 0 (alpha meaning)", strings.Replace(body, "%s", "limit 1", 1), microflows.RangeTypeFirst, "", true}, + {"limit 1, mdl 1 (a list of one)", "mdl 1;\n" + strings.Replace(body, "%s", "limit 1", 1), microflows.RangeTypeCustom, "1", false}, + {"limit 2, mdl 0", strings.Replace(body, "%s", "limit 2", 1), microflows.RangeTypeCustom, "2", false}, + // The offset makes it a Custom range; the variable type used to say + // object here while the writer stored a list. + {"limit 1 offset 3, mdl 0", strings.Replace(body, "%s", "limit 1 offset 3", 1), microflows.RangeTypeCustom, "1", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rng, varType := retrieveRangeOf(t, tc.src) + if rng == nil || rng.RangeType != tc.wantType || rng.Limit != tc.wantLimit { + t.Fatalf("range = %+v, want %s with limit %q", rng, tc.wantType, tc.wantLimit) + } + if isObject := !strings.HasPrefix(varType, "List of "); isObject != tc.wantObjectTyped { + t.Errorf("output typed %q; want object=%v", varType, tc.wantObjectTyped) + } + }) + } +} + +// describe prints the object range as `first` (not `limit 1`, which is a list +// under mdl 1) and a Custom range as limit/offset, so its output reads back to +// the same range. +func TestFormatAction_Retrieve_RangeSpelling(t *testing.T) { + e := newTestExecutor() + cases := []struct { + rng *microflows.Range + want string + }{ + {&microflows.Range{RangeType: microflows.RangeTypeFirst}, "retrieve $O from M.Order\n first;"}, + {&microflows.Range{RangeType: microflows.RangeTypeCustom, Limit: "1"}, "retrieve $O from M.Order\n limit 1;"}, + {&microflows.Range{RangeType: microflows.RangeTypeCustom, Limit: "10", Offset: "20"}, "retrieve $O from M.Order\n limit 10\n offset 20;"}, + } + for _, tc := range cases { + got := e.formatAction(&microflows.RetrieveAction{ + OutputVariable: "O", + Source: &microflows.DatabaseRetrieveSource{EntityQualifiedName: "M.Order", Range: tc.rng}, + }, nil, nil) + if got != tc.want { + t.Errorf("%+v: got %q, want %q", tc.rng, got, tc.want) + } + } +} + +// What check reports for each spelling in each version: the MDL-V1-LIMIT1 +// warning on an mdl 0 `limit 1`, and MDL-RETRIEVE01 wherever the variable is an +// object used as a list. +func TestRetrieveRange_CheckPerLanguageVersion(t *testing.T) { + // A loop rather than count(): it is a list use in both versions, where the + // call form of an aggregate is not (ako/mxcli#733). + const body = `create microflow M.F () +begin + retrieve $O from M.Order where [Status = 'Open'] %s; + loop $Item in $O + begin + log info node 'F' 'item'; + end loop; +end;` + cases := []struct { + name, header, clause string + wantLimitOneWarning bool + wantRetrieve01 bool + }{ + {"mdl 0 limit 1: object, warned, and looping over it is CE0097", "", "limit 1", true, true}, + {"mdl 1 limit 1: a list of one, nothing to report", "mdl 1;\n", "limit 1", false, false}, + {"mdl 0 first: object, looping over it is CE0097", "", "first", false, true}, + {"mdl 1 first: object, looping over it is CE0097", "mdl 1;\n", "first", false, true}, + {"mdl 0 limit 2: a list", "", "limit 2", false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src := tc.header + strings.Replace(body, "%s", tc.clause, 1) + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + gotWarn, gotR01 := false, false + for _, v := range ValidateProgram(prog, "") { + switch v.RuleID { + case "MDL-V1-LIMIT1": + gotWarn = true + case retrieveSingleRule: + gotR01 = true + } + } + if gotWarn != tc.wantLimitOneWarning { + t.Errorf("MDL-V1-LIMIT1 reported = %v, want %v", gotWarn, tc.wantLimitOneWarning) + } + if gotR01 != tc.wantRetrieve01 { + t.Errorf("%s reported = %v, want %v", retrieveSingleRule, gotR01, tc.wantRetrieve01) + } + }) + } +} diff --git a/mdl/executor/roundtrip_microflow_test.go b/mdl/executor/roundtrip_microflow_test.go index 1fced12f6..c7e4fbecb 100644 --- a/mdl/executor/roundtrip_microflow_test.go +++ b/mdl/executor/roundtrip_microflow_test.go @@ -369,7 +369,9 @@ begin end;` assertMicroflowContains(t, env, mfName, createMDL, - []string{"retrieve", "RoundtripTest.MfTestItem", "limit 1", "return"}, + // No header, so `limit 1` is the object range, which describe + // prints as `first` (ako/mxcli#734). + []string{"retrieve", "RoundtripTest.MfTestItem", "first", "return"}, nil, ) } diff --git a/mdl/executor/validate_microflow_retrieve_single.go b/mdl/executor/validate_microflow_retrieve_single.go index d9edf2a2c..24d397bbc 100644 --- a/mdl/executor/validate_microflow_retrieve_single.go +++ b/mdl/executor/validate_microflow_retrieve_single.go @@ -9,45 +9,47 @@ import ( "github.com/mendixlabs/mxcli/mdl/linter" ) -// retrieveSingleRule is the rule ID for "a LIMIT 1 retrieve used as a list". +// retrieveSingleRule is the rule ID for "an object-range 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. +// checkRetrieveLimitOneAsList flags a variable that a retrieve 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. +// The object range is Mendix's "First object" range, written `retrieve … first` +// in every language version, and `retrieve … limit 1` in a script without the +// `mdl 1;` header, where `limit 1` keeps its alpha meaning (ako/mxcli#734). The +// visitor resolves both to RetrieveStmt.First, which is what the writer stores +// too, so the rule keys on it and never on limit text: a check that disagrees +// with the writer it describes is worse than no check. // -// 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. +// Before #734 this rule was the only sign of the object: describe re-emitted +// `limit 1` for it, so an object retrieve and a list retrieve were identical text, +// and the first thing the author saw was CE0097 from mxbuild — inside a +// .test.mdl, not even that (mendixlabs/mxcli#1103). describe now prints `first` +// and an mdl 0 `limit 1` warns MDL-V1-LIMIT1, so the spelling says it; the rule +// stays for the mistake that is left, treating an object as a list, which +// check --references does not otherwise catch before the build. 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 holds the variables currently bound to one object by an + // object-range 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] { + // Measured on 11.13: a loop reports CE0100, a list activity CE0097. + rejection := fmt.Sprintf("CE0097 \"The selected '%s' variable must be of type List\"", name) + if op == "a loop" { + rejection = fmt.Sprintf("CE0100 \"'%s' is of type …, but should be of type List\"", 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)) + fmt.Sprintf("$%s was retrieved as a single object (`first`, which is also what `limit 1` "+ + "means in a script without the `mdl 1;` header), not a list, so %s cannot take it — "+ + "mxbuild rejects this with %s.", name, op, rejection), + fmt.Sprintf("Retrieve a list instead (no range, or under `mdl 1;` `limit 1` for a list of "+ + "one) and keep %s, or keep the object range and use $%s as the object it already is.", + op, name)) } // Rebinding first, so a statement that both consumes and produces the @@ -55,7 +57,7 @@ func (v *microflowValidator) checkRetrieveLimitOneAsList(body []ast.MicroflowSta for _, p := range statementProducedVars(s) { delete(single, p.name) } - if r, ok := s.(*ast.RetrieveStmt); ok && r.Limit == "1" && r.Offset == "" && r.Variable != "" { + if r, ok := s.(*ast.RetrieveStmt); ok && r.First && r.StartVariable == "" && r.Variable != "" { single[r.Variable] = true } }) diff --git a/mdl/executor/validate_microflow_retrieve_single_test.go b/mdl/executor/validate_microflow_retrieve_single_test.go index e034d0165..6a24002d7 100644 --- a/mdl/executor/validate_microflow_retrieve_single_test.go +++ b/mdl/executor/validate_microflow_retrieve_single_test.go @@ -27,6 +27,16 @@ func mfWith(body ...ast.MicroflowStatement) *ast.CreateMicroflowStmt { } } +// retrieveFirst is the object range: `first`, or `limit 1` in an mdl 0 script +// (the visitor resolves both to First, ako/mxcli#734). +func retrieveFirst(variable string) *ast.RetrieveStmt { + return &ast.RetrieveStmt{ + Variable: variable, + Source: ast.QualifiedName{Module: "Probe", Name: "Request"}, + First: true, + } +} + func retrieveLimit(variable, limit, offset string) *ast.RetrieveStmt { return &ast.RetrieveStmt{ Variable: variable, @@ -47,13 +57,13 @@ func retrieveLimit(variable, limit, offset string) *ast.RetrieveStmt { 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", ""), + retrieveFirst("reqs"), &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"} { + for _, want := range []string{"$reqs", "`first`", "CE0097"} { if !strings.Contains(got[0], want) { t.Errorf("message %q does not mention %q", got[0], want) } @@ -62,17 +72,21 @@ func TestRetrieveLimitOneUsedAsList(t *testing.T) { t.Run("looping over a LIMIT 1 retrieve is flagged", func(t *testing.T) { got := retrieveSingleViolations(t, mfWith( - retrieveLimit("reqs", "1", ""), + retrieveFirst("reqs"), &ast.LoopStmt{LoopVariable: "r", ListVariable: "reqs"}, )) if len(got) != 1 { t.Fatalf("got %d violations %q, want 1", len(got), got) } + // mxbuild 11.13 reports a loop over an object as CE0100, not CE0097. + if !strings.Contains(got[0], "CE0100") { + t.Errorf("message %q should name CE0100, what mxbuild reports for a loop", got[0]) + } }) t.Run("aggregating a LIMIT 1 retrieve is flagged", func(t *testing.T) { got := retrieveSingleViolations(t, mfWith( - retrieveLimit("reqs", "1", ""), + retrieveFirst("reqs"), &ast.AggregateListStmt{Operation: ast.AggregateCount, InputVariable: "reqs", OutputVariable: "n"}, )) if len(got) != 1 { @@ -104,13 +118,19 @@ func TestRetrieveLimitOneControls(t *testing.T) { &ast.ListOperationStmt{Operation: ast.ListOpHead, InputVariable: "reqs", OutputVariable: "req"}, ), // Using it as an object is exactly right and must stay silent. + // `limit 1` under mdl 1: the visitor leaves it a Custom range, a list + // of one, so every list use is right. + "limit 1 under mdl 1": mfWith( + retrieveLimit("reqs", "1", ""), + &ast.ListOperationStmt{Operation: ast.ListOpHead, InputVariable: "reqs", OutputVariable: "req"}, + ), "used as an object": mfWith( - retrieveLimit("req", "1", ""), + retrieveFirst("req"), &ast.MfCommitStmt{Variable: "req"}, ), // Rebound to a real list before the list use. "rebound to a list": mfWith( - retrieveLimit("reqs", "1", ""), + retrieveFirst("reqs"), &ast.CreateListStmt{Variable: "reqs", EntityType: ast.QualifiedName{Module: "Probe", Name: "Request"}}, &ast.ListOperationStmt{Operation: ast.ListOpHead, InputVariable: "reqs", OutputVariable: "req"}, ), diff --git a/mdl/grammar/domains/MDLMicroflow.g4 b/mdl/grammar/domains/MDLMicroflow.g4 index f96b8fd3d..a63751b7e 100644 --- a/mdl/grammar/domains/MDLMicroflow.g4 +++ b/mdl/grammar/domains/MDLMicroflow.g4 @@ -419,13 +419,21 @@ rollbackStatement : ROLLBACK VARIABLE REFRESH? ; -// RETRIEVE $ProductList FROM MfTest.Product WHERE Code = $SearchCode SORT BY Name ASC LIMIT 1; +// RETRIEVE $Product FROM MfTest.Product WHERE Code = $SearchCode SORT BY Name ASC FIRST; +// RETRIEVE $Top FROM MfTest.Product SORT BY Price DESC LIMIT 10 OFFSET 20; +// +// FIRST is Mendix's "First object" range: it binds ONE object, and Mendix gives +// it no offset. LIMIT/OFFSET is the Custom range and always binds a list. +// `LIMIT 1` without OFFSET is version-gated (ako/mxcli#734): under `mdl 1;` it is +// a list of one, without the header it keeps its alpha meaning, the object, and +// warns MDL-V1-LIMIT1. The same split as `import from mapping … first | limit n`. retrieveStatement : RETRIEVE VARIABLE FROM retrieveSource (WHERE (xpathConstraint (andOrXpath? xpathConstraint)* | expression))? (SORT_BY sortColumn (COMMA sortColumn)*)? - (LIMIT limitExpr=expression)? - (OFFSET offsetExpr=expression)? + ( FIRST + | (LIMIT limitExpr=expression)? (OFFSET offsetExpr=expression)? + ) onErrorClause? ; diff --git a/mdl/roundtrip/retrieve_range_test.go b/mdl/roundtrip/retrieve_range_test.go new file mode 100644 index 000000000..e090932b5 --- /dev/null +++ b/mdl/roundtrip/retrieve_range_test.go @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package roundtrip + +import ( + "fmt" + "sort" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/executor" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// TestPedAppRoundTrip_RetrieveRange is ako/mxcli#734 on Studio Pro-authored +// content: `retrieve … first` is the object range in every language version, +// and under `mdl 1;` `retrieve … limit 1` is a list of one. describe → exec +// must keep each range as stored, for both forms. +// +// FeedbackModule.PopulateUserAttributes holds PedApp's only retrieve with a +// range: Studio Pro's "First object" (a ConstantRange with SingleObject). The +// whole microflow does not pass GetPut yet (#721, allowlisted), so this test +// judges the range element itself rather than the unit's bytes. +func TestPedAppRoundTrip_RetrieveRange(t *testing.T) { + h := newHarness(t) + defer h.close() + + const target = "microflow FeedbackModule.PopulateUserAttributes" + const objectRange = "Microflows$ConstantRange SingleObject=true" + const listOfOne = "Microflows$CustomRange Limit=\"1\" Offset=\"\"" + + if got := h.retrieveRanges(t, "PopulateUserAttributes"); strings.Join(got, "; ") != objectRange { + t.Fatalf("fixture changed: the retrieve's stored range is %q, want the one object range %q", got, objectRange) + } + + // --- The object form ------------------------------------------------- + described, err := h.describe(target) + if err != nil { + t.Fatalf("describe: %v", err) + } + if !strings.Contains(described, "\n first;") || strings.Contains(described, "limit 1") { + t.Fatalf("describe must print the object range as `first` (never `limit 1`, a list under mdl 1):\n%s", described) + } + for _, header := range []string{"", "mdl 1;\n"} { + script := header + described + if warned := limitOneWarnings(t, script); warned != 0 { + t.Errorf("header %q: `first` warned MDL-V1-LIMIT1 %d time(s); it means the same in every version", header, warned) + } + if err := h.exec(script); err != nil { + t.Fatalf("header %q: exec describe output: %v", header, err) + } + if got := h.retrieveRanges(t, "PopulateUserAttributes"); strings.Join(got, "; ") != objectRange { + t.Errorf("header %q: object form stored as %q, want %q", header, got, objectRange) + } + if again, _ := h.describe(target); again != described { + t.Errorf("header %q: PutGet — describe changed after exec:\n%s", header, lineDiff(described, again)) + } + } + + // --- The list form, under mdl 1 ---------------------------------------- + listScript := "mdl 1;\n" + strings.Replace(described, "\n first;", "\n limit 1;", 1) + if err := h.exec(listScript); err != nil { + t.Fatalf("exec the list form under mdl 1: %v", err) + } + if got := h.retrieveRanges(t, "PopulateUserAttributes"); strings.Join(got, "; ") != listOfOne { + t.Fatalf("`limit 1` under mdl 1 stored as %q, want a list of one %q", got, listOfOne) + } + listDescribed, err := h.describe(target) + if err != nil { + t.Fatalf("describe the list form: %v", err) + } + if !strings.Contains(listDescribed, "\n limit 1;") || strings.Contains(listDescribed, "first;") { + t.Fatalf("describe must print a Custom range with limit 1 as `limit 1`:\n%s", listDescribed) + } + if err := h.exec("mdl 1;\n" + listDescribed); err != nil { + t.Fatalf("exec the list form's describe output under mdl 1: %v", err) + } + if got := h.retrieveRanges(t, "PopulateUserAttributes"); strings.Join(got, "; ") != listOfOne { + t.Errorf("list form's describe output under mdl 1 stored %q, want %q", got, listOfOne) + } + if again, _ := h.describe(target); again != listDescribed { + t.Errorf("PutGet under mdl 1 — describe changed after exec:\n%s", lineDiff(listDescribed, again)) + } + + // --- Control: the same text without the header keeps the alpha meaning -- + // and says so. Without this, a writer that ignored the version would pass + // both halves above. + if warned := limitOneWarnings(t, listDescribed); warned != 1 { + t.Errorf("headerless `limit 1` warned MDL-V1-LIMIT1 %d time(s), want 1", warned) + } + if err := h.exec(listDescribed); err != nil { + t.Fatalf("exec the list form's describe output without a header: %v", err) + } + if got := h.retrieveRanges(t, "PopulateUserAttributes"); strings.Join(got, "; ") != objectRange { + t.Errorf("headerless `limit 1` stored %q, want the alpha meaning, the object range %q", got, objectRange) + } +} + +// limitOneWarnings counts the MDL-V1-LIMIT1 warnings check reports for script. +func limitOneWarnings(t *testing.T, script string) int { + t.Helper() + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + n := 0 + for _, v := range executor.ValidateLanguageVersion(prog) { + if v.RuleID == "MDL-V1-LIMIT1" { + n++ + } + } + return n +} + +// retrieveRanges returns the range of every retrieve in the named microflow, as +// stored: the range's $Type and the properties that decide object or list. +func (h *harness) retrieveRanges(t *testing.T, microflow string) []string { + t.Helper() + snap := h.snapshot() + var found []string + for id, b := range snap.units { + typ, name := typeAndName(b) + if typ != "Microflows$Microflow" || name != microflow { + continue + } + var doc bson.D + if err := bson.Unmarshal(b, &doc); err != nil { + t.Fatalf("decode %s: %v", id, err) + } + collectRanges(doc, &found) + sort.Strings(found) + return found + } + t.Fatalf("microflow %s not found", microflow) + return nil +} + +func collectRanges(v any, out *[]string) { + switch x := v.(type) { + case bson.D: + m := map[string]any{} + for _, e := range x { + m[e.Key] = e.Value + } + switch m["$Type"] { + case "Microflows$ConstantRange": + *out = append(*out, fmt.Sprintf("Microflows$ConstantRange SingleObject=%v", m["SingleObject"])) + case "Microflows$CustomRange": + // An absent expression reads as empty, the way Mendix treats it. + str := func(k string) string { s, _ := m[k].(string); return s } + *out = append(*out, fmt.Sprintf("Microflows$CustomRange Limit=%q Offset=%q", str("LimitExpression"), str("OffsetExpression"))) + } + for _, e := range x { + collectRanges(e.Value, out) + } + case bson.A: + for _, e := range x { + collectRanges(e, out) + } + } +} diff --git a/mdl/visitor/retrieve_first_test.go b/mdl/visitor/retrieve_first_test.go new file mode 100644 index 000000000..997306578 --- /dev/null +++ b/mdl/visitor/retrieve_first_test.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// `retrieve … first` binds one object and `retrieve … limit 1` a list of one +// under mdl 1; without the header `limit 1` keeps its alpha meaning (an object) +// and warns MDL-V1-LIMIT1 (ako/mxcli#734, ADR-0011). + +func retrieveOf(t *testing.T, src string) (*ast.RetrieveStmt, *ast.Program) { + t.Helper() + prog, errs := Build(src) + if len(errs) > 0 { + t.Fatalf("%q: unexpected errors: %v", src, errs) + } + var mf *ast.CreateMicroflowStmt + for _, s := range prog.Statements { + if m, ok := s.(*ast.CreateMicroflowStmt); ok { + mf = m + } + } + if mf == nil || len(mf.Body) == 0 { + t.Fatalf("%q: no microflow body", src) + } + r, ok := mf.Body[0].(*ast.RetrieveStmt) + if !ok { + t.Fatalf("%q: body[0] = %T, want *ast.RetrieveStmt", src, mf.Body[0]) + } + return r, prog +} + +func retrieveMicroflow(rangeClause string) string { + return "create microflow M.F () begin\n retrieve $O from M.Order where [Status = 'Open'] " + + rangeClause + ";\nend;" +} + +func limitOneNotes(prog *ast.Program) int { + n := 0 + for _, note := range prog.LanguageNotes { + if note.Code == "MDL-V1-LIMIT1" { + n++ + } + } + return n +} + +func TestRetrieveFirst_IsAnObjectUnderBothVersions(t *testing.T) { + for _, header := range []string{"", "mdl 1;\n"} { + r, prog := retrieveOf(t, header+retrieveMicroflow("first")) + if !r.First || r.Limit != "" || r.Offset != "" { + t.Errorf("header %q: first → First=%v Limit=%q Offset=%q; want the object range and no limit", + header, r.First, r.Limit, r.Offset) + } + if n := limitOneNotes(prog); n != 0 { + t.Errorf("header %q: `first` means the same in every version, yet %d MDL-V1-LIMIT1 note(s)", header, n) + } + } +} + +func TestRetrieveLimitOne_Mdl0IsAnObjectAndWarns(t *testing.T) { + r, prog := retrieveOf(t, retrieveMicroflow("limit 1")) + if !r.First || r.Limit != "" { + t.Errorf("mdl 0 `limit 1` → First=%v Limit=%q; want the alpha meaning, the object range", r.First, r.Limit) + } + if n := limitOneNotes(prog); n != 1 { + t.Fatalf("mdl 0 `limit 1`: got %d MDL-V1-LIMIT1 notes, want 1: %+v", n, prog.LanguageNotes) + } + if msg := prog.LanguageNotes[0].Message; !strings.Contains(msg, "first") { + t.Errorf("the warning should name the spelling that keeps the object meaning, `first`: %s", msg) + } +} + +func TestRetrieveLimitOne_Mdl1IsAListOfOne(t *testing.T) { + r, prog := retrieveOf(t, "mdl 1;\n"+retrieveMicroflow("limit 1")) + if r.First || r.Limit != "1" { + t.Errorf("mdl 1 `limit 1` → First=%v Limit=%q; want a Custom range with limit 1 (a list)", r.First, r.Limit) + } + if n := limitOneNotes(prog); n != 0 { + t.Errorf("mdl 1 `limit 1` has the new meaning, nothing to warn about; got %d note(s)", n) + } +} + +// Every other range was a list before and stays one; none of them warns. +func TestRetrieveOtherRanges_AreListsInBothVersionsWithoutWarning(t *testing.T) { + for _, header := range []string{"", "mdl 1;\n"} { + for _, clause := range []string{"limit 2", "limit 1 offset 3", "offset 3", "limit $N"} { + r, prog := retrieveOf(t, header+retrieveMicroflow(clause)) + if r.First { + t.Errorf("header %q, %q: bound as an object; every range other than the first object is a list", header, clause) + } + if n := limitOneNotes(prog); n != 0 { + t.Errorf("header %q, %q: %d MDL-V1-LIMIT1 note(s), want none", header, clause, n) + } + } + } +} + +// The object range has no offset in Mendix, so `first` does not take one. +func TestRetrieveFirst_RefusesLimitAndOffset(t *testing.T) { + for _, clause := range []string{"first offset 2", "first limit 1", "limit 1 first"} { + if _, errs := Build(retrieveMicroflow(clause)); len(errs) == 0 { + t.Errorf("%q parsed; `first` is the whole range and combines with neither limit nor offset", clause) + } + } +} + +// An association retrieve has no range in Mendix; `first` there would be dropped. +func TestRetrieveFirst_RefusedOnAnAssociation(t *testing.T) { + src := "create microflow M.F ($P: M.Parent) begin\n retrieve $C from $P/M.Child_Parent first;\nend;" + if _, errs := Build(src); len(errs) == 0 { + t.Errorf("`first` on an association retrieve was accepted") + } +} diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 34cca43af..e5a672d87 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -1392,8 +1392,12 @@ func buildRetrieveStatement(ctx parser.IRetrieveStatementContext) *ast.RetrieveS } } - // Get LIMIT and OFFSET expressions - if limitExpr := retrCtx.GetLimitExpr(); limitExpr != nil { + // The range. `first` is the object range in every version; a bare + // `limit 1` is too in an mdl 0 script, and a list of one under mdl 1 + // (limitOneIsAList). Resolved here so nothing downstream reads limit text. + if retrCtx.FIRST() != nil || retrieveLimitOneIsObject(retrCtx) { + stmt.First = true + } else if limitExpr := retrCtx.GetLimitExpr(); limitExpr != nil { stmt.Limit = retrieveRangeExpressionSource(limitExpr) + retrieveLimitTrailingWhitespace(retrCtx, limitExpr) } if offsetExpr := retrCtx.GetOffsetExpr(); offsetExpr != nil { diff --git a/mdl/visitor/visitor_retrieve_range.go b/mdl/visitor/visitor_retrieve_range.go new file mode 100644 index 000000000..1ce1f8238 --- /dev/null +++ b/mdl/visitor/visitor_retrieve_range.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "fmt" + "strconv" + "strings" + + "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/langver" +) + +// limitOneIsAList is the change of meaning of `retrieve … limit 1` (ako/mxcli#734, +// PROPOSAL_mdl_beta_syntax_freeze.md §5 item 2). +// +// In the alpha language `limit 1` without an offset binds ONE object: the writer +// stores Mendix's "First object" range. Every SQL reader expects a list of one, +// and `import from mapping` already spells the object `first` and a list of one +// `limit 1`. Under mdl 1 `limit 1` is the Custom range, a list; the object is +// spelled `first` in both versions. +var limitOneIsAList = langver.Change{ + Code: "MDL-V1-LIMIT1", + Since: langver.V1, + Old: "`retrieve … limit 1` binds a single object (Mendix's \"First object\" range, " + + "so a loop over it is CE0100 and count() or head() CE0097); write `retrieve … first` to say so", + New: "a list of one (a Custom range), and the object is written `first`", +} + +// ExitRetrieveStatement warns on `limit 1` kept at its alpha meaning. The +// statement builder, a free function, applies the same decision through +// retrieveLimitOneIsObject; this is where the note is recorded, once. +// +// It also refuses `first` on an association retrieve: Mendix gives that source +// no range, so the keyword would be dropped on the way to the model, and +// whether the result is an object or a list follows from the association. +func (b *Builder) ExitRetrieveStatement(ctx *parser.RetrieveStatementContext) { + if ctx.FIRST() != nil && isAssociationRetrieve(ctx) { + b.addError(fmt.Errorf("line %d: `first` does not apply to a retrieve over an association: Mendix gives "+ + "that source no range, and whether it binds an object or a list follows from the association. "+ + "Drop `first`, or retrieve from the database (`retrieve $x from Module.Entity where … first`)", + ctx.GetStart().GetLine())) + return + } + if isBareLimitOne(ctx) { + b.gate(limitOneIsAList, ctx) + } +} + +// isAssociationRetrieve reports whether the retrieve's source is `$Var/Assoc`. +func isAssociationRetrieve(ctx *parser.RetrieveStatementContext) bool { + src, ok := ctx.RetrieveSource().(*parser.RetrieveSourceContext) + return ok && src != nil && src.VARIABLE() != nil +} + +// retrieveLimitOneIsObject reports whether ctx is a `limit 1` that binds an +// object: a bare `limit 1` in a script written before mdl 1. +func retrieveLimitOneIsObject(ctx *parser.RetrieveStatementContext) bool { + return isBareLimitOne(ctx) && !limitOneIsAList.Applies(languageVersionOf(ctx)) +} + +// isBareLimitOne reports whether a retrieve's range is exactly `limit 1` with no +// offset, on a source that is not an association: the one spelling whose +// meaning depends on the language version. +// +// The limit is compared as the builder stores it (source text plus the +// whitespace it keeps before `;`), which is the condition the alpha writer has +// always used to choose the object range: `limit 1` is an object, `limit (1)` +// or `limit $One` a list. +func isBareLimitOne(ctx *parser.RetrieveStatementContext) bool { + if ctx == nil || ctx.FIRST() != nil || ctx.GetOffsetExpr() != nil || ctx.GetLimitExpr() == nil { + return false + } + if isAssociationRetrieve(ctx) { + return false // an association retrieve has no range + } + limit := ctx.GetLimitExpr() + return retrieveRangeExpressionSource(limit)+retrieveLimitTrailingWhitespace(ctx, limit) == "1" +} + +// languageVersionOf reads the `mdl <n>;` header of the script tree is part of. +// The header can only be the first statement, so it is on the root. It exists +// for the statement builders, which are free functions without the Builder and +// must still build the AST of the version the script is written in. A missing +// or malformed header is mdl 0 (ExitLanguageHeader reports a malformed one). +func languageVersionOf(tree antlr.Tree) langver.Version { + for ; tree != nil; tree = tree.GetParent() { + prog, ok := tree.(*parser.ProgramContext) + if !ok { + continue + } + h, ok := prog.LanguageHeader().(*parser.LanguageHeaderContext) + if !ok || h == nil || h.IDENTIFIER() == nil || h.NUMBER_LITERAL() == nil || + !strings.EqualFold(h.IDENTIFIER().GetText(), "mdl") { + return langver.V0 + } + n, err := strconv.Atoi(h.NUMBER_LITERAL().GetText()) + if v := langver.Version(n); err == nil && v.Known() { + return v + } + return langver.V0 + } + return langver.V0 +} diff --git a/mdl/visitor/visitor_test.go b/mdl/visitor/visitor_test.go index 9ffbcaab7..273fbeade 100644 --- a/mdl/visitor/visitor_test.go +++ b/mdl/visitor/visitor_test.go @@ -1041,8 +1041,11 @@ END;` t.Fatal("Expected to find RETRIEVE statement") } - if retrieveStmt.Limit != "1" { - t.Errorf("Expected Limit '1', got %q", retrieveStmt.Limit) + // Without the `mdl 1;` header `limit 1` keeps its alpha meaning, the object + // range (ako/mxcli#734); retrieve_first_test.go covers both versions. + if !retrieveStmt.First || retrieveStmt.Limit != "" { + t.Errorf("Expected the object range (First) for an mdl 0 `limit 1`, got First=%v Limit=%q", + retrieveStmt.First, retrieveStmt.Limit) } t.Log("RETRIEVE with LIMIT parsed correctly") From ebe0282c027d845681e425fad43641ea643baf7d Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:19:31 +0000 Subject: [PATCH 25/37] =?UTF-8?q?docs:=20teach=20retrieve=20=E2=80=A6=20fi?= =?UTF-8?q?rst=20for=20an=20object;=20limit=201=20changes=20meaning=20unde?= =?UTF-8?q?r=20mdl=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md idiom 5, the quick reference, the syntax topic, the skills and docs-site pages that taught `limit 1` for an object now write `first` and say what `limit 1` means per language version. CHANGELOG entry. Part of #734. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../skills/mendix/cheatsheet-errors/SKILL.md | 2 +- .../mendix/cheatsheet-variables/SKILL.md | 6 +++--- .claude/skills/mendix/check-syntax/SKILL.md | 4 ++-- .../skills/mendix/test-microflows/SKILL.md | 7 ++++--- .../skills/mendix/write-microflows/SKILL.md | 4 ++-- .../reference/data-operations.md | 7 ++++--- .../write-microflows/reference/pitfalls.md | 16 ++++++++------ CHANGELOG.md | 2 ++ CLAUDE.md | 2 +- cmd/mxcli/syntax/features_microflow.go | 21 ++++++++++--------- docs-site/src/appendixes/quick-reference.md | 2 +- docs-site/src/language/activity-types.md | 4 ++-- .../reference/microflow/create-microflow.md | 4 ++-- docs/01-project/MDL_QUICK_REFERENCE.md | 15 ++++++------- ...3-retrieve-limit-one-is-an-object.fail.mdl | 12 +++++------ 15 files changed, 59 insertions(+), 49 deletions(-) diff --git a/.claude/skills/mendix/cheatsheet-errors/SKILL.md b/.claude/skills/mendix/cheatsheet-errors/SKILL.md index 7a97e60e2..8565ba180 100644 --- a/.claude/skills/mendix/cheatsheet-errors/SKILL.md +++ b/.claude/skills/mendix/cheatsheet-errors/SKILL.md @@ -51,7 +51,7 @@ declare $Products list of Module.Product = empty; ```mdl -- object: a microflow parameter, a retrieve, a create, or a loop iterator -retrieve $Product from Module.Product where Code = $Code limit 1; -- retrieve +retrieve $Product from Module.Product where Code = $Code first; -- retrieve $Product = create Module.Product (Name = $Name); -- create -- or: create microflow M.Save ($Product: Module.Product) ... / loop $Product in $Products ... diff --git a/.claude/skills/mendix/cheatsheet-variables/SKILL.md b/.claude/skills/mendix/cheatsheet-variables/SKILL.md index d3558a53b..083ea3e47 100644 --- a/.claude/skills/mendix/cheatsheet-variables/SKILL.md +++ b/.claude/skills/mendix/cheatsheet-variables/SKILL.md @@ -23,7 +23,7 @@ Quick reference for variable declarations in MDL microflows. ## Key Rules 1. **Primitives** (String/Integer/Long/Decimal/Boolean/DateTime/Enumeration): Use `declare $var type = value;` — these are the *only* types `declare` (a Create Variable activity) accepts. -2. **Objects (entities)**: Never `declare` an object. Mendix forbids the Create Variable activity from holding an object (CE0053/CE0038, plus CE7247 on a later `set`; flagged as **MDL043** by `mxcli check`) — bare *or* initialized. Get the object from a microflow parameter, a `retrieve … limit 1`, `$var = create Module.Entity(...)`, or a loop iterator. There is no "empty object variable" and no aliasing activity — reuse the variable you already have. +2. **Objects (entities)**: Never `declare` an object. Mendix forbids the Create Variable activity from holding an object (CE0053/CE0038, plus CE7247 on a later `set`; flagged as **MDL043** by `mxcli check`) — bare *or* initialized. Get the object from a microflow parameter, a `retrieve … first`, `$var = create Module.Entity(...)`, or a loop iterator. There is no "empty object variable" and no aliasing activity — reuse the variable you already have. 3. **Lists**: Never `declare` a list — same Create Variable restriction (CE0053/CE0038, flagged as **MDL040**). Get the list from a microflow parameter, a `retrieve`, or `$var = create list of Module.Entity;` 4. **SET requires DECLARE**: Always declare primitive variables before using SET. 5. **Parameters are pre-declared**: Microflow parameters don't need DECLARE (and a parameter *may* be an object/list type — that restriction is only on `declare`). @@ -40,7 +40,7 @@ declare $Product as Module.Product; -- the AS keyword is also a parse er -- CORRECT: get the object from a source that produces one $Product = create Module.Product (Name = $n); -- create -retrieve $Product from Module.Product where Code = $c limit 1; -- retrieve +retrieve $Product from Module.Product where Code = $c first; -- retrieve -- or accept it as a parameter, or use a loop iterator: loop $Product in $Products ... ``` @@ -93,7 +93,7 @@ begin declare $Result boolean = true; -- Local primitive: must declare -- Need a local object? Don't declare it — create/retrieve it: - $Order = create Module.Order (Reference = $Input); -- or retrieve … limit 1 + $Order = create Module.Order (Reference = $Input); -- or retrieve … first return $Result; end; diff --git a/.claude/skills/mendix/check-syntax/SKILL.md b/.claude/skills/mendix/check-syntax/SKILL.md index 973912c2e..dbed9603e 100644 --- a/.claude/skills/mendix/check-syntax/SKILL.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -239,7 +239,7 @@ Before writing any MDL, verify these requirements: **Supported in Microflows:** - `declare $Var type = value;` (primitives only: String/Integer/Long/Decimal/Boolean/DateTime/Enumeration) -- `$entity = create Module.Entity (...);` / `retrieve $entity from ... limit 1;` (objects — **never** `declare` an object; that fails CE0053/CE0038 and is flagged MDL043) +- `$entity = create Module.Entity (...);` / `retrieve $entity from ... first;` (objects — **never** `declare` an object; that fails CE0053/CE0038 and is flagged MDL043) - `$list = create list of Module.Entity;` (lists — **never** `declare` a list; that fails CE0053/CE0038 and is flagged MDL040) - `set $Var = expression;` - `$Var = create Module.Entity (attr = value);` @@ -260,7 +260,7 @@ Before writing any MDL, verify these requirements: **Now Supported (previously not):** - `rollback $entity [refresh];` - Reverts uncommitted changes -- `retrieve ... limit n` - Returns single entity when `limit 1` +- `retrieve ... first` - Returns a single entity; `limit n [offset n]` returns a list (a bare `limit 1` is the object only without the `mdl 1;` header, and warns MDL-V1-LIMIT1) - `boolean` without `default` - Auto-defaults to `false` - `buttonstyle: warning` and `buttonstyle: info` - Now parse correctly - Keywords as attribute names - `caption`, `label`, `title`, `text`, `content`, `format`, `range`, `source`, `check`, etc. all work unquoted diff --git a/.claude/skills/mendix/test-microflows/SKILL.md b/.claude/skills/mendix/test-microflows/SKILL.md index 7157a2f37..dd02d8405 100644 --- a/.claude/skills/mendix/test-microflows/SKILL.md +++ b/.claude/skills/mendix/test-microflows/SKILL.md @@ -405,9 +405,10 @@ with nothing but "the project cannot be deployed". An `@expect` or `@verify` tha 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. +in tests: `retrieve $x … first` — and `retrieve $x … limit 1` in a script without +the `mdl 1;` header — 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 +range to get a list, or use the variable as the object it is. --- diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index 1bb0fe887..76a7f9752 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -257,7 +257,7 @@ declare $status Enumeration(Module.OrderStatus) = Module.OrderStatus.Open; > not you give it an initializer. `mxcli check` now flags it as **MDL043**. There > is **no** "empty object variable" activity. Get objects from one of these: > - a microflow **parameter**: `create microflow M.Save ($Product: Test.Product) ...` -> - a **retrieve**: `retrieve $Product from Test.Product where Code = $c limit 1;` +> - a **retrieve**: `retrieve $Product from Test.Product where Code = $c first;` > - a **create object**: `$Product = create Test.Product (Name = $n);` > - a **loop iterator**: `loop $Product in $Products ...` @@ -579,7 +579,7 @@ Before executing a microflow script, verify: ```mdl declare $primitive type = value; -- Primitives (String/Integer/Decimal/Boolean/DateTime) declare $status Enumeration(Module.Enum) = …; -- Enumerations are primitives too --- Objects: never declare. Use a parameter, retrieve (limit 1), `$obj = create Module.Entity(...)`, or a loop iterator. +-- Objects: never declare. Use a parameter, retrieve (… first), `$obj = create Module.Entity(...)`, or a loop iterator. -- Lists: never declare. Use a parameter, retrieve, or `$list = create list of Module.Entity;` ``` diff --git a/.claude/skills/mendix/write-microflows/reference/data-operations.md b/.claude/skills/mendix/write-microflows/reference/data-operations.md index 6ed4590d2..24c4a0659 100644 --- a/.claude/skills/mendix/write-microflows/reference/data-operations.md +++ b/.claude/skills/mendix/write-microflows/reference/data-operations.md @@ -254,9 +254,10 @@ retrieve $Product from Test.Product **Important**: - Use `from Module.Entity` (fully qualified) -- RETRIEVE with `limit 1` returns a **single entity** -- RETRIEVE without `limit 1` returns a **list** (`list of Module.Entity`) -- Use `limit 1` when you expect exactly one result (e.g., lookup by unique key) +- RETRIEVE with `first` returns a **single entity** +- RETRIEVE without a range, or with `limit n [offset n]`, returns a **list** (`list of Module.Entity`) +- Use `first` when you expect exactly one result (e.g., lookup by unique key). `limit 1` + is a list of one under `mdl 1;` and the object without the header (warning `MDL-V1-LIMIT1`) **Sorting and paging** — use `sort by`, **not** `order by`: diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index be632b05e..01e0dd4ab 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -22,7 +22,7 @@ declare $Product as Test.Product; -- AS keyword also not supported create microflow Test.Save ($Product: Test.Product) returns boolean as $ok ... -- from a retrieve (single object) -retrieve $Product from Test.Product where Code = $Code limit 1; +retrieve $Product from Test.Product where Code = $Code first; -- from a create object $Product = create Test.Product (Name = $Name); @@ -361,17 +361,21 @@ rollback $Order refresh; **Use Case**: Revert uncommitted changes to an object. Useful when validation fails and you want to restore the object to its database state. -### RETRIEVE with LIMIT (Supported!) +### RETRIEVE: `first` for an object, `limit` for a list ```mdl --- CORRECT: LIMIT is supported -retrieve $Product from Module.Product where IsActive = true limit 1; +-- FIRST binds a single object (Mendix's "First object" range) +retrieve $Product from Module.Product where IsActive = true first; --- LIMIT 1 returns a single entity (not a list) --- Without LIMIT, returns a list +-- Without a range, or with LIMIT/OFFSET, it binds a list retrieve $ProductList from Module.Product where IsActive = true; +retrieve $Page from Module.Product sort by Name asc limit 20 offset 40; ``` +`limit 1` without `offset` changes meaning with the language version: a list of +one under `mdl 1;`, but in a script without the header the object, with warning +`MDL-V1-LIMIT1`. Write `first` whenever you mean the object. + ### WHILE Loop ```mdl diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7f46a67..a3a71284c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **`retrieve … first` binds one object; under `mdl 1;` `retrieve … limit 1` is a list of one** (ako/mxcli#734, ADR-0011) — `first` is Mendix's "First object" range in every language version, the same split as `import from mapping … first | limit n`. Under the `mdl 1;` preview header a bare `limit 1` is a Custom range, a list of one. Without the header it keeps its old meaning, the object, and `check` / `exec` warn **MDL-V1-LIMIT1**. `describe` prints the object range as `first`, so its output means the same whatever header runs it; measured on the Studio Pro-authored PedApp fixture (describe → exec keeps both ranges as stored) and with `mx check` 11.13 on a copy of a Studio Pro project (a loop over an `mdl 1` `limit 1` builds at 0 errors; the headerless script is CE0100). **MDL-RETRIEVE01** now keys on the object range the writer stores and names CE0100 for a loop. `first` on an association retrieve is refused: Mendix gives that source no range. **Migrating a script:** nothing to do — without the header every script behaves as before; replace an object-meaning `limit 1` with `first` to silence the warning and to keep the meaning under `mdl 1;`. + - **`alter page`, `alter snippet` and `alter layout` are the first document types on the generic ALTER** (ako/mxcli#712, ADR-0012) — one grammar, `alter <type> Module.Name { set (Key: value) on <target>; insert before|after|into <target> { … } replace <target> with { … } drop <target>; }`, whose target is resolved by the document type (a new `backend.AlterTargetResolver`, implemented by the page mutator on the modelsdk and `--mcp` backends). Properties are written as in `create`: `set (Caption: 'Save') on btnSave`, `set (Title: 'Edit')` for the page itself. A target is a widget name, `grid.Column` or `layoutContainer.top`; a quoted-caption target or `@n` is refused on a page, whose elements have names — by `check` too, as **MDL-ALTER01**. **Migrating a script:** nothing breaks — `set Key = value`, `set Key: value` without parentheses and `drop widget a` still run and build the identical change, and `check` / `exec` warn with **MDL-DEPR101**, **MDL-DEPR102** and **MDL-DEPR103** naming the rewrite. - **`DynamicClasses` and a column's `DynamicCellClass` are written as Mendix expressions** (mendixlabs/mxcli#750) — the expression is written as-is, so the doubled-quote spelling is gone: `dynamicclasses: if $currentObject/Featured then 'is-featured' else ''`, and `dynamicclasses: 'is-featured'` is the string — the class `is-featured`. The same rule as the OData client's credentials. `create page`, `alter page … set` and `describe` all use it, and a describe → exec round trip stores identical values (measured on a Mendix 11.14.0 project). **Migrating a script:** the old spelling, the expression's text in quotes (`'if … then ''a'' else '''''`), still parses but would now store that text as a class name, so `check` and `exec` refuse it as **MDL-WIDGET33** and give the unquoted expression. An expression in any other widget property is an error rather than an empty value; a pluggable property whose schema kind is Expression (a column's `Visible`, for one) keeps the quoted form until a following change. diff --git a/CLAUDE.md b/CLAUDE.md index 738439104..6afee35f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -363,7 +363,7 @@ These rules apply whenever generating microflow or nanoflow MDL. Violations are 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. **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). +5. **Write `retrieve … first` for a single OBJECT** — Mendix's "First object" range; `head()`/`count()` over it is **CE0097** and a `loop` **CE0100** (MDL-RETRIEVE01 at check time). `limit n [offset n]` is a list. **`limit 1` without `offset` changes meaning with the language version** (ako/mxcli#734): a list of one under `mdl 1;`, the object in a headerless script, where it warns MDL-V1-LIMIT1. `describe` prints `first` for the object range, matching `import from mapping … first | limit n`. 6. **Read `.claude/skills/patterns-data-processing.md`** for delta merge, batch processing, and list operation patterns. **Always validate before presenting to user:** diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index bc7dc3a74..b24a6aa39 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -84,23 +84,24 @@ 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-- Sort over an association: one `/` per hop, the last segment is the attribute\nRETRIEVE $Var FROM Module.Entity\n SORT BY Module.Assoc/Module.Other.Attr ASC;\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" + + Syntax: "-- From the database\nRETRIEVE $Var FROM Module.Entity\n [WHERE condition]\n [SORT BY attr ASC|DESC]\n [FIRST | [LIMIT n] [OFFSET n]];\n\n-- Sort over an association: one `/` per hop, the last segment is the attribute\nRETRIEVE $Var FROM Module.Entity\n SORT BY Module.Assoc/Module.Other.Attr ASC;\n\n-- Over an association, from an object you already have\nRETRIEVE $Var FROM $Object/Module.Association;", + Example: "-- FIRST 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 $Customer FROM MyModule.Customer\n WHERE Code = $CustomerCode\n FIRST;\n\n" + + "-- LIMIT/OFFSET 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" + "-- Sort on an attribute of an associated entity. Name the association\n" + "-- when two of them reach the same entity.\n" + "RETRIEVE $Orders FROM MyModule.Order\n SORT BY MyModule.Order_BillTo/MyModule.Address.City ASC;\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.\n" + + "-- * FIRST is the one form that binds an object, in every language version.\n" + + "-- HEAD() or COUNT() over it is CE0097 at build time and a LOOP CE0100;\n" + + "-- mxcli reports both as MDL-RETRIEVE01 at check time.\n" + + "-- * LIMIT 1 without OFFSET depends on the language version: under `mdl 1;`\n" + + "-- it is a list of one, as in `import from mapping … limit 1`. Without the\n" + + "-- header it keeps its old meaning, the object, and warns MDL-V1-LIMIT1 —\n" + + "-- write FIRST for the object. LIMIT 1 OFFSET n is always a list.\n" + "-- * SORT BY may navigate associations. Name the hop when more than one\n" + "-- reaches the same entity — mxcli infers a single hop, but it cannot\n" + "-- tell Order_ShipTo from Order_BillTo, and the wrong one builds\n" + diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 564e37586..5747f21e2 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -180,7 +180,7 @@ AUTHENTICATION Basic, Session | `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 ... FIRST` returns a single entity; `RETRIEVE ... LIMIT n [OFFSET n]` returns a list. A bare `LIMIT 1` is a list of one under `mdl 1;`, and without the header the single entity, with warning `MDL-V1-LIMIT1`. - `ROLLBACK $Entity [REFRESH];` IS supported. Rolls back uncommitted changes to an object. ## Project Organization diff --git a/docs-site/src/language/activity-types.md b/docs-site/src/language/activity-types.md index f8ec49841..9ea237b1d 100644 --- a/docs-site/src/language/activity-types.md +++ b/docs-site/src/language/activity-types.md @@ -78,7 +78,7 @@ Retrieves objects from the database using an optional XPath-style WHERE clause: -- Retrieve a single object RETRIEVE $Customer FROM Sales.Customer WHERE Email = $InputEmail - LIMIT 1; + FIRST; -- Retrieve a list of objects RETRIEVE $ActiveOrders FROM Sales.Order @@ -94,7 +94,7 @@ RETRIEVE $RecentOrders FROM Sales.Order LIMIT 50; ``` -When `LIMIT 1` is specified, the result is a single entity object. Otherwise, the result is a list. +With `FIRST` the result is a single entity object (Mendix's "First object" range). Otherwise the result is a list, including with `LIMIT`/`OFFSET`. A bare `LIMIT 1` depends on the language version: a list of one under `mdl 1;`, and without the header the object, with warning `MDL-V1-LIMIT1`. `describe` prints the object range as `FIRST`. ### RETRIEVE by Association diff --git a/docs-site/src/reference/microflow/create-microflow.md b/docs-site/src/reference/microflow/create-microflow.md index 041689bda..24d4e4be4 100644 --- a/docs-site/src/reference/microflow/create-microflow.md +++ b/docs-site/src/reference/microflow/create-microflow.md @@ -65,13 +65,13 @@ Every one of these modifiers is optional, and leaving one out always means Mendi ```sql -- Database retrieve with optional XPath constraint -RETRIEVE $Var FROM Module.Entity [ WHERE condition ] [ LIMIT n ]; +RETRIEVE $Var FROM Module.Entity [ WHERE condition ] [ FIRST | [ LIMIT n ] [ OFFSET n ] ]; -- Retrieve by association RETRIEVE $List FROM $Parent/Module.AssocName; ``` -`RETRIEVE ... LIMIT 1` returns a single entity. Without `LIMIT` or with `LIMIT` greater than 1, it returns a list. Retrieve by association traverses an association from a known object. +`RETRIEVE ... FIRST` returns a single entity. Without a range, or with `LIMIT`/`OFFSET`, it returns a list. A bare `LIMIT 1` is a list of one under `mdl 1;`; without the header it keeps its old meaning, a single entity, and warns `MDL-V1-LIMIT1`. Retrieve by association traverses an association from a known object. **Calls** diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 1abf19730..04b39bd39 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -524,7 +524,7 @@ it is for pages. | Commit | `commit $entity [without events] [refresh];` | **Omitted = with events**, matching Studio Pro's default. `without events` is the deviation and the only form that changes the stored value; `with events` still parses and means the default | | Delete | `delete $entity [refresh];` | | | Rollback | `rollback $entity [refresh];` | Reverts uncommitted changes | -| Retrieve (DB) | `retrieve $Var from Module.Entity [where condition] [sort by Attr asc\|desc, ...] [limit n [offset n]];` | Database XPath retrieve. `limit 1` with no `offset` binds a single **object**, not a one-element list (MDL-RETRIEVE01) | +| Retrieve (DB) | `retrieve $Var from Module.Entity [where condition] [sort by Attr asc\|desc, ...] [first \| [limit n] [offset n]];` | Database XPath retrieve. `first` binds a single **object** (Mendix's "First object" range); `limit`/`offset` bind a list. `limit 1` without `offset` is a list of one under `mdl 1;`, and without the header keeps its old meaning, the object, with warning MDL-V1-LIMIT1 | | Retrieve (DB), sorted | `sort by Attr asc` / `sort by Module.Other.Attr asc` / `sort by Module.Assoc/Module.Other.Attr asc` | A bare name is qualified with the entity **declaring** it, which may be an ancestor. A sort may also navigate associations — one `/` per hop, the last segment is the attribute — and mxcli stores the hops as the `EntityRef` Mendix needs; without them the build is **CE7247**. **Name the hop when more than one association reaches the same entity**: a bare `Module.Other.Attr` is resolved by inference, which walks the generalization chain across modules (`Administration.Account` reaches `System.Language.Code` through `System.User_Language`) but cannot tell `Order_ShipTo` from `Order_BillTo` — measured, a sort on the billing address round-tripped into one on the shipping address at 0 errors both sides (mendixlabs/mxcli#1152). The same spelling works in a page datasource's `sort by` | | Retrieve (Assoc) | `retrieve $list from $Parent/Module.AssocName;` | Retrieve by association | | Add to list | `add expression to $list;` | Also accepts existing `add $item to $list;` form | @@ -617,12 +617,13 @@ 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` 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. +- `retrieve ... first` binds a single OBJECT: Mendix's "First object" range. `retrieve ... limit n + [offset n]` is a bounded range, which is a list — the same split as the import-mapping clause above. + **`limit 1` without `offset` depends on the language version** (ako/mxcli#734): under `mdl 1;` + it is a list of one; without the header it keeps its old meaning, the object, and warns + `MDL-V1-LIMIT1`. Write `first` for an object. Using an object as a list — `head()`, `count()` + (**CE0097**) or a `loop` (**CE0100**) — is **MDL-RETRIEVE01** at check time. `describe` prints + the object range as `first`. - `rollback $entity [refresh];` IS supported. Rolls back uncommitted changes to an object. ## Project Organization 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 index 467ffe1c1..e0484e505 100644 --- 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 @@ -1,11 +1,11 @@ -- 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. +-- This script has no `mdl 1;` header, so `limit 1` with no offset keeps its +-- alpha meaning, Mendix's "First object" range (and warns MDL-V1-LIMIT1, +-- ako/mxcli#734): $Requests is an object and head() cannot take it. mxbuild +-- rejects that as CE0097 — and inside a .test.mdl file, not even that: the +-- injected test simply failed to build. `describe` now prints the object +-- range as `first`; under `mdl 1;` `limit 1` is a list of one. -- -- MDL-RETRIEVE01 reports it at check time. This script must FAIL check. create module Probe; From 7be826bb6bc36a46212fde74b5705e60ae764050 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:21:08 +0000 Subject: [PATCH 26/37] mdl 1: read a statement's terminators off its last tokens (#732) The microflow, nanoflow and workflow rules end in `SEMICOLON? SLASH?` themselves, so `end;` followed by `/` left the statement's own SLASH empty: the `/` was not reported, and the missing `;` was reported at the `/`. Found by checking PedApp's describe output under mdl 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/visitor/strict_terminators_test.go | 21 ++++++++ mdl/visitor/visitor_strict_terminators.go | 59 ++++++++++++++--------- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/mdl/visitor/strict_terminators_test.go b/mdl/visitor/strict_terminators_test.go index 5cbf42668..c1010d0f3 100644 --- a/mdl/visitor/strict_terminators_test.go +++ b/mdl/visitor/strict_terminators_test.go @@ -73,3 +73,24 @@ func TestSlashTerminatorRejectedUnderMdl1(t *testing.T) { t.Fatalf("mdl 1: want one `/` error, got %v", errs) } } + +// The microflow, nanoflow and workflow rules end in `SEMICOLON? SLASH?` +// themselves, so their terminators are the inner rule's: `end;` followed by +// `/` is the `/` form, and `end;` alone is terminated. +func TestTerminatorsOfRulesThatEndInTheirOwn(t *testing.T) { + mf := "create microflow M.F () begin end;" + if got := notesWithCode(t, mf+"\n/\nshow modules;", "MDL-V1-SLASH"); len(got) != 1 { + t.Fatalf("mdl 0: want one MDL-V1-SLASH for a microflow's `/`, got %v", got) + } + _, errs := Build("mdl 1;\n" + mf + "\n/\nshow modules;") + if len(errs) != 1 || !strings.Contains(errs[0].Error(), "`/` is not a statement terminator") { + t.Fatalf("mdl 1: want only the `/` error for a microflow's `/`, got %v", errs) + } + if _, errs := Build("mdl 1;\n" + mf + "\nshow modules;"); len(errs) != 0 { + t.Fatalf("mdl 1: a microflow ending `end;` was refused: %v", errs) + } + _, errs = Build("mdl 1;\ncreate microflow M.F () begin end\nshow modules;") + if len(errs) != 1 || !strings.Contains(errs[0].Error(), `ending at "end"`) { + t.Fatalf("mdl 1: want a missing-`;` error naming `end`, got %v", errs) + } +} diff --git a/mdl/visitor/visitor_strict_terminators.go b/mdl/visitor/visitor_strict_terminators.go index 3e9d18490..1d9b28852 100644 --- a/mdl/visitor/visitor_strict_terminators.go +++ b/mdl/visitor/visitor_strict_terminators.go @@ -31,37 +31,48 @@ var slashIsNotATerminator = langver.Change{ } // ExitStatement applies R11's terminator rules to one top-level statement. +// +// The terminators are read off the statement's last tokens rather than its +// own SEMICOLON and SLASH: the microflow, nanoflow and workflow rules end in +// `SEMICOLON? SLASH?` themselves, and `create java action … as $$…$$;` in +// `SEMICOLON?`, so either can belong to the inner rule. func (b *Builder) ExitStatement(ctx *parser.StatementContext) { - if !endsWithSemicolon(ctx) { - if at := ctx.GetStop(); at != nil && b.gate(semicolonRequired, ctx) { - b.addError(fmt.Errorf("line %d: the statement ending at %q has no terminating `;`: "+ - "under %s every statement ends with `;`", at.GetLine(), at.GetText(), b.langVersion)) - } + last := lastTerminals(ctx, 2) + if len(last) == 0 { + return + } + var slash antlr.Token + if last[0].GetTokenType() == parser.MDLParserSLASH { + slash, last = last[0], last[1:] + } + if len(last) > 0 && last[0].GetTokenType() != parser.MDLParserSEMICOLON && b.gate(semicolonRequired, ctx) { + b.addError(fmt.Errorf("line %d: the statement ending at %q has no terminating `;`: "+ + "under %s every statement ends with `;`", last[0].GetLine(), last[0].GetText(), b.langVersion)) } - if s := ctx.SLASH(); s != nil && b.gate(slashIsNotATerminator, ctx) { + if slash != nil && b.gate(slashIsNotATerminator, ctx) { b.addError(fmt.Errorf("line %d: `/` is not a statement terminator under %s; end the statement "+ - "with `;` and delete the `/` line", s.GetSymbol().GetLine(), b.langVersion)) + "with `;` and delete the `/` line", slash.GetLine(), b.langVersion)) } } -// endsWithSemicolon reports whether the statement is terminated by `;`: its own -// SEMICOLON, or one a statement rule consumed itself (`create java action … -// as $$…$$;` ends in `SEMICOLON?` inside its rule, before the statement's). -func endsWithSemicolon(ctx *parser.StatementContext) bool { - if ctx.SEMICOLON() != nil { - return true - } - for i := ctx.GetChildCount() - 1; i >= 0; i-- { - switch c := ctx.GetChild(i).(type) { - case antlr.TerminalNode: - if c.GetSymbol().GetTokenType() == parser.MDLParserSLASH { - continue +// lastTerminals returns up to n of the tree's last tokens, the last first. +func lastTerminals(tree antlr.Tree, n int) []antlr.Token { + var out []antlr.Token + var walk func(antlr.Tree) + walk = func(t antlr.Tree) { + if len(out) == n { + return + } + if tn, ok := t.(antlr.TerminalNode); ok { + if tok := tn.GetSymbol(); tok != nil && tok.GetTokenType() != antlr.TokenEOF { + out = append(out, tok) } - return c.GetSymbol().GetTokenType() == parser.MDLParserSEMICOLON - case antlr.ParserRuleContext: - stop := c.GetStop() - return stop != nil && stop.GetTokenType() == parser.MDLParserSEMICOLON + return + } + for i := t.GetChildCount() - 1; i >= 0 && len(out) < n; i-- { + walk(t.GetChild(i)) } } - return false + walk(tree) + return out } From 17ba552ad371bc65a9a287ec67695e8cf735fc6e Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:22:09 +0000 Subject: [PATCH 27/37] docs: what mdl 1 makes strict, and trailing commas (#732) Statement termination no longer recommends the / terminator, and the string-literal section no longer claims backslash escapes are unsupported: they are, until mdl 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/mxcli/syntax/features_misc.go | 7 +++++++ docs-site/src/language/basics.md | 22 +++++++++++++++------ docs-site/src/language/lexical-structure.md | 4 +++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 3bb1e41eb..051a45f32 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -25,6 +25,13 @@ func init() { "-- describe and fmt do not emit it.\n" + "-- mdl 2; refused: this mxcli does not know that version.\n" + "--\n" + + "-- Under mdl 1 parsing is strict (each is a warning without the header):\n" + + "-- every statement ends with ';' and '/' is not a terminator; '' is the\n" + + "-- only string escape, so a backslash is an ordinary character; and an\n" + + "-- unknown or mis-shaped property key in a REST, business event or agent\n" + + "-- property list is an error. A trailing comma is allowed in every\n" + + "-- bracketed list, with or without the header.\n" + + "--\n" + "-- A script's meaning never depends on which mxcli release runs it: a\n" + "-- change of meaning applies only under the version that introduces it.\n" + "-- The header is independent of the Mendix version the project targets.", diff --git a/docs-site/src/language/basics.md b/docs-site/src/language/basics.md index cd09b8336..a15a7196f 100644 --- a/docs-site/src/language/basics.md +++ b/docs-site/src/language/basics.md @@ -18,22 +18,23 @@ INDEX (Name); ## Statement Termination -Statements are terminated with a semicolon (`;`) or a forward slash (`/`) on its own line (Oracle-style, useful for multi-line statements): +Every statement ends with a semicolon (`;`): ```sql --- Semicolon terminator CREATE MODULE OrderManagement; --- Forward-slash terminator (useful for long statements) CREATE PERSISTENT ENTITY Sales.Order ( OrderId: AutoNumber NOT NULL UNIQUE, - OrderDate: DateTime NOT NULL + OrderDate: DateTime NOT NULL, ) INDEX (OrderDate DESC); -/ ``` -Simple commands such as `HELP`, `EXIT`, `STATUS`, `SHOW`, and `DESCRIBE` do not require a terminator. +Under `mdl 1;` a missing `;` is an error, and so is the Oracle SQL*Plus-style `/` on its own line. A script without the header still accepts both, and `check` warns `MDL-V1-SEMI` / `MDL-V1-SLASH` for each one. At the REPL, where there is no header, a single command such as `SHOW ENTITIES` still needs no terminator. + +## Trailing Commas + +A trailing comma is allowed in every bracketed list — attributes, enumeration values, parameters, property lists, `{ … }` blocks — under every language version. `()` is the only way to write an empty list: `(,)` and `(a,,)` are errors. ## Language Version Header @@ -52,6 +53,15 @@ create persistent entity Sales.Customer ( - The header must be the **first** statement. A version this mxcli does not know is refused. - It is independent of the Mendix version your project targets. +What `mdl 1` makes strict (each is a warning without the header, with the code shown): + +| Under `mdl 1;` | Without the header | +|---|---| +| A statement without `;` is an error. | Accepted; `MDL-V1-SEMI`. | +| A `/` terminator line is an error. | Accepted; `MDL-V1-SLASH`. | +| `''` is the only string escape; a backslash is an ordinary character, so `'C:\temp'` is that path. | `\n`, `\r`, `\t`, `\\` and `\'` are escapes; `MDL-V1-ESCAPE` for each literal whose value would change. | +| In a REST client, published REST service, business event service, model, knowledge base, consumed MCP service or agent, an unknown property key is an error that names the key it most likely meant, and so is a value its key does not take (`Response: json from $X`). | The property is ignored, or read by its shape as before; `MDL-V1-PROP` / `MDL-V1-PROPVALUE`. | + The design is in [ADR-0011](https://github.com/mendixlabs/mxcli/blob/main/docs/13-decisions/0011-mdl-language-versioning.md); `mxcli syntax language-header` has the details. ## Case Insensitivity diff --git a/docs-site/src/language/lexical-structure.md b/docs-site/src/language/lexical-structure.md index 6b9e34604..0034bb0c7 100644 --- a/docs-site/src/language/lexical-structure.md +++ b/docs-site/src/language/lexical-structure.md @@ -58,7 +58,9 @@ String literals use single quotes: 'it''s here' -- doubled single quote to escape ``` -The only escape sequence is `''` (two single quotes) to represent a literal single quote. Backslash escaping is **not** supported. +The only escape sequence is `''` (two single quotes) to represent a literal single quote, as in a Mendix expression. Under `mdl 1;` a backslash is an ordinary character: `'C:\temp'` is that path. + +A script without the header (`mdl 0`) still reads a backslash before `n`, `r`, `t`, `\` or `'` as an escape — `'C:\temp'` holds a tab — and `check` warns `MDL-V1-ESCAPE` for each literal whose value changes under `mdl 1`. Write the backslash-free form (`'it''s'`) to mean the same under both. ### Numeric Literals From 20c2af735d8c262928232afe2907a8ead642e378 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:23:12 +0000 Subject: [PATCH 28/37] mdl 1: hint at the doubled apostrophe when \' ends a string literal (#732) Under mdl 1 'it\'s' ends at the quote, and the errors that follow point at the rest of the line. Name the cause. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/visitor/string_escapes_test.go | 9 +++++++++ mdl/visitor/visitor.go | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/mdl/visitor/string_escapes_test.go b/mdl/visitor/string_escapes_test.go index 3a756affa..5783a9f34 100644 --- a/mdl/visitor/string_escapes_test.go +++ b/mdl/visitor/string_escapes_test.go @@ -136,3 +136,12 @@ func TestStringLiteralsAreReadUnderTheirEscapeRule(t *testing.T) { } } } + +// Under mdl 1 `\'` ends the literal and the parse error that follows is far +// from the cause; the hint names it. +func TestBackslashQuoteHintUnderMdl1(t *testing.T) { + _, _, errs := defaultOf(t, "mdl 1;\n", `'it\'s'`) + if len(errs) == 0 || !strings.Contains(errsText(errs), "Double the apostrophe") { + t.Fatalf("want the doubled-apostrophe hint, got %v", errs) + } +} diff --git a/mdl/visitor/visitor.go b/mdl/visitor/visitor.go index 6675b6e75..d163f3df8 100644 --- a/mdl/visitor/visitor.go +++ b/mdl/visitor/visitor.go @@ -121,6 +121,16 @@ func enhanceErrorMessage(msg, offendingLine string) string { " mdl 1;\n"+ " create entity Shop.Customer ( Name: String(200) ); (correct)", msg) } + // A `\'` in a string literal under mdl 1, where a backslash no longer + // escapes: the literal ends at the quote, and the rest of the line is read + // as MDL. Under mdl 0 the same text parses, so the hint only fires on an + // error. (#732) + if strings.Contains(offendingLine, `\'`) { + return fmt.Sprintf("%s\n\n Under mdl 1 a backslash is an ordinary character, so `\\'` ends the string\n"+ + " literal. Double the apostrophe instead, as in a Mendix expression:\n"+ + " default 'it''s' (correct)\n"+ + " default 'it\\'s' (mdl 0 only)", msg) + } // A bare `not $x` — Mendix requires `not(expr)`. The parse error surfaces // downstream (e.g. "missing THEN at '$x'"), so key off the source line, which // is unambiguous for `not $…`. (sudoku findings #3) From 1fc3d963da7c37ac8d396c00a8a113eafc30c763 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:35:54 +0000 Subject: [PATCH 29/37] mdl/upgrade: rewrite deprecated spellings, with a hook for header-gated constructs Upgrade rewrites every use the visitor records on Program.Deprecations to its registry entry's canonical spelling, in place and in the token's letter case, leaving comments and layout untouched. An entry without a rewrite is reported, not guessed at. With AddHeader it also adds mdl 1; after rewriting each header-gated construct through gatedRewriters; a construct with no rewrite blocks the header rather than change the script's meaning. The output is re-parsed: it must parse, keep every statement and record no rewritable deprecation, so a second run is a no-op. Part of #735. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/deprecation/deprecation.go | 4 + mdl/upgrade/examples_test.go | 156 ++++++++++++++++++ mdl/upgrade/gated.go | 29 ++++ mdl/upgrade/upgrade.go | 290 +++++++++++++++++++++++++++++++++ mdl/upgrade/upgrade_test.go | 227 ++++++++++++++++++++++++++ 5 files changed, 706 insertions(+) create mode 100644 mdl/upgrade/examples_test.go create mode 100644 mdl/upgrade/gated.go create mode 100644 mdl/upgrade/upgrade.go create mode 100644 mdl/upgrade/upgrade_test.go diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 50d5c47eb..3c552c573 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -72,6 +72,10 @@ type Rewrite struct { Replacement string } +// IsZero reports whether r is empty: the entry has no mechanical rewrite, and +// `fmt --upgrade` reports its uses instead of guessing at one. +func (r Rewrite) IsZero() bool { return r.Token == "" && r.Replacement == "" } + // Codes of the registered entries, for the visitor to record. const ( CreateOrReplace = "MDL-DEPR001" diff --git a/mdl/upgrade/examples_test.go b/mdl/upgrade/examples_test.go new file mode 100644 index 000000000..082458371 --- /dev/null +++ b/mdl/upgrade/examples_test.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/formatter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// examplesDir is the corpus the upgrade is proven on. The execute-both half of +// the proof (same model written) is mdl/roundtrip's upgrade property test; this +// is the half that needs no project: every script upgrades to one that parses, +// records no deprecation, builds the same statements, and is a fixed point of +// `fmt --upgrade`. +const examplesDir = "../../mdl-examples" + +func exampleScripts(t *testing.T) []string { + t.Helper() + var out []string + err := filepath.Walk(examplesDir, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(p, ".mdl") { + out = append(out, p) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(out) + if len(out) < 100 { + t.Fatalf("found only %d scripts under %s — the walk is broken", len(out), examplesDir) + } + return out +} + +// fmtUpgrade is what `mxcli fmt --upgrade` writes: the upgrade rewrites and +// nothing else. It deliberately does not run formatter.Format, whose keyword +// upper-casing changes identifiers (see TestFormatterChangesMeaning). +func fmtUpgrade(src string, opts Options) (string, Result, error) { + res, err := Upgrade(src, opts) + return res.Source, res, err +} + +// TestFormatterChangesMeaning records why `fmt --upgrade` does not compose with +// the heuristic formatter: formatter.Format upper-cases every word on its +// keyword list, identifiers included (`Issue64.User` becomes `Issue64.USER`), +// so it changes what a script builds. Plan item 3.2 (lowercase canonical) +// replaces it. When this test starts failing, the formatter is safe and +// fmt --upgrade may format as well. +func TestFormatterChangesMeaning(t *testing.T) { + changed := 0 + for _, path := range exampleScripts(t) { + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want, errs := visitor.Build(string(b)) + if len(errs) > 0 { + continue + } + got, errs := visitor.Build(formatter.Format(string(b))) + if len(errs) > 0 || !reflect.DeepEqual(want.Statements, got.Statements) { + changed++ + } + } + t.Logf("formatter.Format changes what %d example scripts build", changed) + if changed == 0 { + t.Error("formatter.Format no longer changes any example's statements: let fmt --upgrade format too, and drop this test") + } +} + +// foldModeFlags erases the one AST difference between `or replace` and +// `or modify` that means nothing: pages, snippets and layouts keep both flags, +// and every reader in the executor tests `IsModify || IsReplace` (pinned by +// mdl/visitor's TestCreateOrReplaceMatchesModifyExceptExemptKinds). +func foldModeFlags(stmts []ast.Statement) { + for _, s := range stmts { + switch n := s.(type) { + case *ast.CreatePageStmtV3: + n.IsModify, n.IsReplace = n.IsModify || n.IsReplace, false + case *ast.CreateSnippetStmtV3: + n.IsModify, n.IsReplace = n.IsModify || n.IsReplace, false + case *ast.CreateLayoutStmt: + n.IsModify, n.IsReplace = n.IsModify || n.IsReplace, false + } + } +} + +func TestUpgrade_ExamplesKeepTheirStatements(t *testing.T) { + var parsed, upgraded, skipped int + for _, path := range exampleScripts(t) { + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + src := string(b) + if _, errs := visitor.Build(src); len(errs) > 0 { + skipped++ // a negative test, or a script that no longer parses: not upgradable + continue + } + parsed++ + for _, opts := range []Options{{}, {AddHeader: true}} { + out, res, err := fmtUpgrade(src, opts) + if err != nil { + t.Errorf("%s (%+v): %v", path, opts, err) + continue + } + if res.Changed() && !opts.AddHeader { + upgraded++ + } + got, errs := visitor.Build(out) + if len(errs) > 0 { + t.Errorf("%s (%+v): upgraded output does not parse: %v", path, opts, errs[0]) + continue + } + if len(got.Deprecations) > 0 { + t.Errorf("%s (%+v): upgraded output still records %d deprecation(s), first %s at line %d", + path, opts, len(got.Deprecations), got.Deprecations[0].Code, got.Deprecations[0].Line) + } + want, _ := visitor.Build(src) + foldModeFlags(want.Statements) + foldModeFlags(got.Statements) + if len(want.Statements) != len(got.Statements) { + t.Errorf("%s (%+v): %d statements became %d", path, opts, len(want.Statements), len(got.Statements)) + continue + } + for i := range want.Statements { + if !reflect.DeepEqual(want.Statements[i], got.Statements[i]) { + t.Errorf("%s (%+v): statement %d builds differently after the upgrade:\n before: %#v\n after: %#v", + path, opts, i+1, want.Statements[i], got.Statements[i]) + break + } + } + // Idempotent: fmt --upgrade over its own output changes nothing. + again, res2, err := fmtUpgrade(out, opts) + if err != nil || again != out || res2.Changed() { + t.Errorf("%s (%+v): fmt --upgrade is not idempotent (err=%v)", path, opts, err) + } + } + } + t.Logf("%d scripts parse, %d of them upgraded, %d do not parse and are out of scope", parsed, upgraded, skipped) + if upgraded == 0 { + t.Error("no example needed an upgrade — the corpus no longer exercises the rewrites") + } +} diff --git a/mdl/upgrade/gated.go b/mdl/upgrade/gated.go new file mode 100644 index 000000000..31218ee40 --- /dev/null +++ b/mdl/upgrade/gated.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import "github.com/mendixlabs/mxcli/mdl/ast" + +// GatedRewriter rewrites one header-gated construct so that, once the script +// carries the new header, it still means what it meant without one. +// +// note is the ast.LanguageNote the visitor recorded through Builder.gate when +// it parsed the headerless script (its Code is the langver.Change's rule ID); +// prog is that parse. The rewriter returns the source edits, in the parser's +// coordinates. When a construct needs more than the note's line to locate it, +// extend ast.LanguageNote with the position rather than re-finding it in text. +type GatedRewriter func(src *Source, prog *ast.Program, note ast.LanguageNote) ([]Edit, error) + +// gatedRewriters holds one rewrite per langver.Change code: a change of meaning +// or new rejection that applies only under `mdl 1` (ADR-0011 decision 2). +// +// A change lands with its rewrite here (proposal §9, "Each change lands with a +// registry entry, an fmt --upgrade rewrite where one is possible"). A change +// without one is reported by `fmt --upgrade --header`, which then refuses to +// add the header to a script that uses it: adding the header over it would be +// exactly the silent change of meaning the header exists to prevent. +// +// Empty while no change of meaning is on main; the first ones are the mdl 1 +// constructs of ako/mxcli#732 (strict parsing), #733 (list operations as +// statements, mandatory `set`) and #734 (`limit 1` is a list). +var gatedRewriters = map[string]GatedRewriter{} diff --git a/mdl/upgrade/upgrade.go b/mdl/upgrade/upgrade.go new file mode 100644 index 000000000..5f15b2d18 --- /dev/null +++ b/mdl/upgrade/upgrade.go @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package upgrade rewrites an MDL script to the canonical language: the engine +// behind `mxcli fmt --upgrade` (ADR-0011 decision 1, plan item 1.3 of +// PROPOSAL_mdl_beta_syntax_freeze.md). +// +// It does two kinds of rewrite, and nothing else: +// +// - Every use of a deprecated spelling registered in mdl/deprecation becomes +// its canonical spelling. A deprecated spelling is a respelling, so the +// rewrite never changes meaning. The visitor records where each use is +// (ast.Program.Deprecations); the entry's Rewrite says what replaces it. +// - When the caller asks for the language header, every construct whose +// meaning differs under the new version (ast.Program.LanguageNotes, one per +// langver.Change) is rewritten to the spelling that keeps its OLD meaning +// under the new header, and `mdl <n>;` is added. The rewrites live in +// gatedRewriters (gated.go). +// +// Anything it cannot rewrite is reported, never guessed at: a deprecated use +// whose entry has no rewrite is left in place and listed in Result.Unrewritten, +// and a header-gated construct without a rewrite blocks the header, because +// adding the header over it would silently change the script's meaning. +// +// The rewrite works on the source text, not on the AST, so comments, layout +// and every spelling it does not own survive byte for byte. The output is +// re-parsed before it is returned: it must parse, record no deprecation the +// upgrade knows how to rewrite, and keep every statement. +// +// The proof that a rewrite is safe is the execute-both property test in +// mdl/roundtrip (upgrade_property_test.go): each mdl-examples script and its +// upgrade run on two copies of the PedApp fixture and must write the same +// model, compared as canonical BSON per unit. +package upgrade + +import ( + "fmt" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" + "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// Options says what Upgrade may do beyond the alias rewrites. +type Options struct { + // AddHeader adds the `mdl <Latest>;` header to a headerless script, after + // rewriting every construct whose meaning the header would change. While + // mdl 1 is a preview this is opt-in (`fmt --upgrade --header`); at beta it + // becomes the default (langver.Frozen is the single switch). + AddHeader bool +} + +// DefaultOptions is what `fmt --upgrade` does when no header flag is given: +// add the header only once the version it names is frozen. +func DefaultOptions() Options { return Options{AddHeader: langver.Latest <= langver.Frozen} } + +// Result is an upgraded script and what the upgrade could not do. +type Result struct { + // Source is the upgraded script. It equals the input when nothing applied. + Source string + // Rewritten counts the deprecated uses rewritten, by registry code. + Rewritten map[string]int + // GatedRewritten counts the header-gated constructs rewritten, by code. + GatedRewritten map[string]int + // HeaderAdded reports whether the language header was added. + HeaderAdded bool + // Unrewritten lists the deprecated uses left in place because their + // registry entry carries no rewrite. + Unrewritten []ast.DeprecatedSpelling +} + +// Changed reports whether the upgrade rewrote anything. +func (r Result) Changed() bool { + return r.HeaderAdded || len(r.Rewritten) > 0 || len(r.GatedRewritten) > 0 +} + +// Upgrade rewrites src to the canonical language. It returns an error when src +// does not parse, when the header was asked for but a construct under it has no +// rewrite, or when the rewritten script fails its re-parse check. +func Upgrade(src string, opts Options) (Result, error) { + return upgrader{lookup: deprecation.Lookup, gated: gatedRewriters}.upgrade(src, opts) +} + +// upgrader carries the two registries so a test can substitute them. +type upgrader struct { + lookup func(code string) (deprecation.Entry, bool) + gated map[string]GatedRewriter +} + +func (u upgrader) upgrade(src string, opts Options) (Result, error) { + return u.upgradeProg(src, opts, parse) +} + +func parse(src string) (*ast.Program, error) { + prog, errs := visitor.Build(src) + if len(errs) > 0 { + return nil, errs[0] + } + return prog, nil +} + +// upgradeProg is upgrade with the parser as a parameter, so a test can hand it +// a Program carrying constructs the visitor does not produce yet. +func (u upgrader) upgradeProg(src string, opts Options, parse func(string) (*ast.Program, error)) (Result, error) { + res := Result{Source: src, Rewritten: map[string]int{}, GatedRewritten: map[string]int{}} + prog, err := parse(src) + if err != nil { + return res, fmt.Errorf("the script does not parse, so it cannot be upgraded: %w", err) + } + text := newSource(src) + + var edits []Edit + for _, d := range prog.Deprecations { + e, ok := u.lookup(d.Code) + if !ok { + return res, fmt.Errorf("line %d: the visitor recorded %s, which is not in the deprecation registry", d.Line, d.Code) + } + if e.Rewrite.IsZero() { + res.Unrewritten = append(res.Unrewritten, d) + continue + } + ed, err := tokenSwap(text, d, e.Rewrite) + if err != nil { + return res, err + } + edits = append(edits, ed) + res.Rewritten[d.Code]++ + } + + addHeader := opts.AddHeader && prog.LanguageHeaderLine == 0 + if addHeader { + var missing []string + for _, n := range prog.LanguageNotes { + rw, ok := u.gated[n.Code] + if !ok { + missing = append(missing, fmt.Sprintf("line %d: %s", n.Line, n.Code)) + continue + } + ed, err := rw(text, prog, n) + if err != nil { + return res, fmt.Errorf("line %d: rewriting %s: %w", n.Line, n.Code, err) + } + edits = append(edits, ed...) + res.GatedRewritten[n.Code]++ + } + if len(missing) > 0 { + return res, fmt.Errorf("cannot add the `%s;` header: %d construct(s) would change meaning under it "+ + "and have no rewrite yet, so the script is left at %s:\n %s", + langver.Latest, len(missing), prog.LanguageVersion, strings.Join(missing, "\n ")) + } + } + + out, err := text.apply(edits) + if err != nil { + return res, err + } + if addHeader { + out = langver.Latest.String() + ";\n" + out + res.HeaderAdded = true + } + if err := u.verify(prog, out, parse); err != nil { + return res, err + } + res.Source = out + return res, nil +} + +// verify re-parses the upgraded script. It must parse, keep every statement, +// and record no deprecated use the upgrade knows how to rewrite: a use that +// survives means a rewrite did not produce the canonical spelling, and a +// second run would rewrite again, so fmt would not be idempotent. +// +// A header-gated construct that survives is caught the same way: under the +// header the parse records no LanguageNote, but the construct must now be +// spelled so that it means what it meant before, which only the execute-both +// test can judge. +func (u upgrader) verify(orig *ast.Program, out string, parse func(string) (*ast.Program, error)) error { + prog, err := parse(out) + if err != nil { + return fmt.Errorf("the upgraded script does not parse (an upgrade rewrite is wrong): %w", err) + } + if len(prog.Statements) != len(orig.Statements) { + return fmt.Errorf("the upgraded script has %d statements, the original %d (an upgrade rewrite is wrong)", + len(prog.Statements), len(orig.Statements)) + } + for _, d := range prog.Deprecations { + if e, ok := u.lookup(d.Code); ok && !e.Rewrite.IsZero() { + return fmt.Errorf("line %d: the upgraded script still uses a deprecated spelling (%s); its rewrite did not produce %q", + d.Line, d.Code, e.Canonical) + } + } + return nil +} + +// Edit replaces Len runes at (Line, Column) of the source with Text. Line is +// 1-based and Column 0-based, in runes, as ANTLR reports token positions. +type Edit struct { + Line int + Column int + Len int + Text string +} + +// Source is a script split into lines of runes, the coordinates the parser +// reports positions in. +type Source struct { + lines [][]rune +} + +func newSource(src string) *Source { + parts := strings.Split(src, "\n") + s := &Source{lines: make([][]rune, len(parts))} + for i, p := range parts { + s.lines[i] = []rune(p) + } + return s +} + +// At returns the n runes at (line, col), or false when they are out of range. +func (s *Source) At(line, col, n int) (string, bool) { + if line < 1 || line > len(s.lines) || col < 0 || n < 0 || col+n > len(s.lines[line-1]) { + return "", false + } + return string(s.lines[line-1][col : col+n]), true +} + +// apply returns the source with edits applied. Edits may not overlap. +func (s *Source) apply(edits []Edit) (string, error) { + sorted := append([]Edit(nil), edits...) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Line != sorted[j].Line { + return sorted[i].Line < sorted[j].Line + } + return sorted[i].Column < sorted[j].Column + }) + for i := 1; i < len(sorted); i++ { + a, b := sorted[i-1], sorted[i] + if a.Line == b.Line && a.Column+a.Len > b.Column { + return "", fmt.Errorf("line %d: two upgrade rewrites overlap at columns %d and %d", a.Line, a.Column, b.Column) + } + } + lines := make([][]rune, len(s.lines)) + copy(lines, s.lines) + // Right to left, so an earlier edit's column is still valid after a later + // one on the same line changed its length. + for i := len(sorted) - 1; i >= 0; i-- { + e := sorted[i] + if _, ok := s.At(e.Line, e.Column, e.Len); !ok { + return "", fmt.Errorf("line %d column %d: upgrade rewrite is outside the source", e.Line, e.Column) + } + l := lines[e.Line-1] + nl := make([]rune, 0, len(l)-e.Len+len(e.Text)) + nl = append(nl, l[:e.Column]...) + nl = append(nl, []rune(e.Text)...) + nl = append(nl, l[e.Column+e.Len:]...) + lines[e.Line-1] = nl + } + out := make([]string, len(lines)) + for i, l := range lines { + out[i] = string(l) + } + return strings.Join(out, "\n"), nil +} + +// tokenSwap is the edit for a deprecation.Rewrite: the recorded token, which +// must be the rewrite's Token, becomes its Replacement in the same letter case. +func tokenSwap(s *Source, d ast.DeprecatedSpelling, rw deprecation.Rewrite) (Edit, error) { + n := len([]rune(rw.Token)) + got, ok := s.At(d.Line, d.Column, n) + if !ok || !strings.EqualFold(got, rw.Token) { + return Edit{}, fmt.Errorf("line %d column %d: %s is recorded here, but the source has %q where %q was expected", + d.Line, d.Column, d.Code, got, rw.Token) + } + return Edit{Line: d.Line, Column: d.Column, Len: n, Text: matchCase(got, rw.Replacement)}, nil +} + +// matchCase spells word in the letter case of like: all upper, capitalised, or +// lower. A script written in upper-case keywords stays upper-case. +func matchCase(like, word string) string { + switch { + case like == strings.ToUpper(like) && like != strings.ToLower(like): + return strings.ToUpper(word) + case len(like) > 1 && like[:1] == strings.ToUpper(like[:1]) && like[1:] == strings.ToLower(like[1:]): + return strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) + default: + return strings.ToLower(word) + } +} diff --git a/mdl/upgrade/upgrade_test.go b/mdl/upgrade/upgrade_test.go new file mode 100644 index 000000000..eab2ab99e --- /dev/null +++ b/mdl/upgrade/upgrade_test.go @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" + "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +func mustUpgrade(t *testing.T, src string, opts Options) Result { + t.Helper() + res, err := Upgrade(src, opts) + if err != nil { + t.Fatalf("Upgrade(%q): %v", src, err) + } + return res +} + +// Every registry entry's own example upgrades to exactly its canonical example. +// A new entry is covered the moment it is registered. +func TestUpgrade_EveryRegistryExample(t *testing.T) { + for _, e := range deprecation.All() { + t.Run(e.Code, func(t *testing.T) { + res := mustUpgrade(t, e.Example, Options{}) + if res.Source != e.CanonicalExample { + t.Errorf("upgrade of %q\n got: %q\nwant: %q", e.Example, res.Source, e.CanonicalExample) + } + if res.Rewritten[e.Code] != 1 { + t.Errorf("Rewritten = %v, want one %s", res.Rewritten, e.Code) + } + }) + } +} + +func TestUpgrade_RewritesInPlaceKeepingCaseCommentsAndLayout(t *testing.T) { + src := "-- create or replace in a comment stays\n" + + "CREATE OR REPLACE ENUMERATION M.Color (Red 'show me'); -- trailing\n" + + " Show entities in M; show associations;\n" + + "create or modify enumeration M.Size (S 's');\n" + + "show entity M.Customer;\n" + want := "-- create or replace in a comment stays\n" + + "CREATE OR MODIFY ENUMERATION M.Color (Red 'show me'); -- trailing\n" + + " List entities in M; list associations;\n" + + "create or modify enumeration M.Size (S 's');\n" + + // `show entity X` becomes `describe`, which is not registered yet: untouched. + "show entity M.Customer;\n" + res := mustUpgrade(t, src, Options{}) + if res.Source != want { + t.Fatalf("got:\n%s\nwant:\n%s", res.Source, want) + } + if res.Rewritten[deprecation.CreateOrReplace] != 1 || res.Rewritten[deprecation.Show] != 2 { + t.Errorf("Rewritten = %v", res.Rewritten) + } +} + +// Non-ASCII text before the token on the same line: ANTLR columns count runes, +// and a byte-indexed rewrite would land in the wrong place. +func TestUpgrade_ColumnsAreRunes(t *testing.T) { + src := "/* café ☕ */ create or replace enumeration M.Color (Red 'Rood ☕');" + res := mustUpgrade(t, src, Options{}) + want := "/* café ☕ */ create or modify enumeration M.Color (Red 'Rood ☕');" + if res.Source != want { + t.Fatalf("got %q\nwant %q", res.Source, want) + } +} + +// Where `or replace` does not mean `or modify` it is not an alias, is not +// recorded, and must not be rewritten. +func TestUpgrade_LeavesNonAliasesAlone(t *testing.T) { + for _, src := range []string{ + "create or replace view entity M.V (Name: String(100)) as (select c.Name as Name from M.Customer as c);", + "create or replace translations in Administration for nl_NL ('Save' as 'Opslaan');", + "create or replace user role Clerk (M.User);", + "show version;", + } { + res := mustUpgrade(t, src, Options{}) + if res.Source != src || res.Changed() { + t.Errorf("%q was rewritten to %q", src, res.Source) + } + } +} + +func TestUpgrade_IsIdempotent(t *testing.T) { + src := "create or replace enumeration M.Color (Red 'Red');\nshow entities;\n" + for _, opts := range []Options{{}, {AddHeader: true}} { + once := mustUpgrade(t, src, opts) + twice := mustUpgrade(t, once.Source, opts) + if twice.Source != once.Source || twice.Changed() { + t.Errorf("opts %+v: second upgrade changed %q to %q", opts, once.Source, twice.Source) + } + } +} + +func TestUpgrade_HeaderOnlyWhenAsked(t *testing.T) { + src := "show entities;\n" + if res := mustUpgrade(t, src, Options{}); res.HeaderAdded || strings.HasPrefix(res.Source, "mdl") { + t.Fatalf("header added without being asked: %q", res.Source) + } + res := mustUpgrade(t, src, Options{AddHeader: true}) + if !res.HeaderAdded || res.Source != "mdl 1;\nlist entities;\n" { + t.Fatalf("got %q (HeaderAdded=%v)", res.Source, res.HeaderAdded) + } + prog, errs := visitor.Build(res.Source) + if len(errs) > 0 || prog.LanguageVersion != langver.Latest { + t.Fatalf("upgraded script is not %s: %v", langver.Latest, errs) + } + // An existing header is kept, not doubled. + again := mustUpgrade(t, res.Source, Options{AddHeader: true}) + if again.HeaderAdded || again.Source != res.Source { + t.Fatalf("header added twice: %q", again.Source) + } +} + +// While mdl 1 is a preview the header is opt-in; DefaultOptions turns it on by +// itself once langver.Frozen reaches Latest, with no edit here. +func TestDefaultOptions_FollowsFrozen(t *testing.T) { + if got, want := DefaultOptions().AddHeader, langver.Latest <= langver.Frozen; got != want { + t.Fatalf("DefaultOptions().AddHeader = %v, want %v", got, want) + } +} + +func TestUpgrade_RefusesUnparseableInput(t *testing.T) { + if _, err := Upgrade("create or replace enumeration (", Options{}); err == nil { + t.Fatal("upgraded a script that does not parse") + } +} + +// An entry without a rewrite is reported, not guessed at, and the use is left. +func TestUpgrade_EntryWithoutRewriteIsReported(t *testing.T) { + u := upgrader{ + lookup: func(code string) (deprecation.Entry, bool) { + e, ok := deprecation.Lookup(code) + if code == deprecation.Show { + e.Rewrite = deprecation.Rewrite{} + } + return e, ok + }, + gated: map[string]GatedRewriter{}, + } + src := "show entities;\ncreate or replace enumeration M.Color (Red 'Red');" + res, err := u.upgrade(src, Options{}) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(res.Source, "show entities;\ncreate or modify") { + t.Fatalf("got %q", res.Source) + } + if len(res.Unrewritten) != 1 || res.Unrewritten[0].Code != deprecation.Show || res.Unrewritten[0].Line != 1 { + t.Fatalf("Unrewritten = %+v, want the show on line 1", res.Unrewritten) + } +} + +// A rewrite that does not produce the canonical spelling is caught by the +// re-parse, before anything is returned. +func TestUpgrade_WrongRewriteIsCaught(t *testing.T) { + u := upgrader{ + lookup: func(code string) (deprecation.Entry, bool) { + e, ok := deprecation.Lookup(code) + e.Rewrite.Replacement = e.Rewrite.Token // rewrites to itself + return e, ok + }, + gated: map[string]GatedRewriter{}, + } + if _, err := u.upgrade("show entities;", Options{}); err == nil || !strings.Contains(err.Error(), "still uses") { + t.Fatalf("a rewrite that changes nothing was accepted: %v", err) + } + u.lookup = func(code string) (deprecation.Entry, bool) { + e, ok := deprecation.Lookup(code) + e.Rewrite.Replacement = "frobnicate" // not a statement: ANTLR drops it silently + return e, ok + } + if _, err := u.upgrade("show entities;", Options{}); err == nil || !strings.Contains(err.Error(), "rewrite is wrong") { + t.Fatalf("a rewrite to unparseable text was accepted: %v", err) + } +} + +// The header-gated hook. A construct whose meaning the header would change +// must be rewritten before the header goes on; without a rewrite the header is +// refused. The visitor has no gated construct yet, so the test drives the hook +// through a Program the way Builder.gate would fill it. +func TestUpgrade_GatedConstructs(t *testing.T) { + const code = "MDL-V1-TEST" + src := "show entities;\nshow modules;\n" + withNote := func(src string) (*ast.Program, error) { + prog, errs := visitor.Build(src) + if len(errs) > 0 { + return nil, errs[0] + } + if prog.LanguageHeaderLine == 0 { + prog.LanguageNotes = append(prog.LanguageNotes, ast.LanguageNote{Line: 2, Code: code}) + } + return prog, nil + } + + // No rewriter: the header is refused and nothing is returned. + u := upgrader{lookup: deprecation.Lookup, gated: map[string]GatedRewriter{}} + res, err := u.upgradeProg(src, Options{AddHeader: true}, withNote) + if err == nil || !strings.Contains(err.Error(), code) { + t.Fatalf("header added over a gated construct with no rewrite: %v %q", err, res.Source) + } + // Without the header nothing changes meaning, so the aliases still upgrade. + res, err = u.upgradeProg(src, Options{}, withNote) + if err != nil || res.Source != "list entities;\nlist modules;\n" { + t.Fatalf("got %q, %v", res.Source, err) + } + + // With a rewriter: its edits are applied along with the alias rewrites. + u.gated = map[string]GatedRewriter{code: func(s *Source, _ *ast.Program, n ast.LanguageNote) ([]Edit, error) { + got, _ := s.At(n.Line, 5, 7) + if got != "modules" { + t.Errorf("note does not point at the construct: %q", got) + } + return []Edit{{Line: n.Line, Column: 5, Len: 7, Text: "microflows"}}, nil + }} + res, err = u.upgradeProg(src, Options{AddHeader: true}, withNote) + if err != nil { + t.Fatal(err) + } + if want := "mdl 1;\nlist entities;\nlist microflows;\n"; res.Source != want || res.GatedRewritten[code] != 1 { + t.Fatalf("got %q (%v), want %q", res.Source, res.GatedRewritten, want) + } +} From 0ddd41850b6814cbaaa5dbecb64fc1e3ff0ab6e2 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:35:54 +0000 Subject: [PATCH 30/37] mxcli fmt --upgrade [--header] --upgrade applies only the upgrade rewrites and not the heuristic keyword formatter, which changes what 374 of the 701 parseable mdl-examples scripts build (it upper-cases identifiers such as M.User). --header adds mdl 1; and is opt-in while mdl 1 is a preview; DefaultOptions turns it on once langver.Frozen reaches it. Part of #735. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/mxcli/cmd_fmt.go | 79 ++++++++++++++++++++++++++++++- cmd/mxcli/cmd_fmt_upgrade_test.go | 73 ++++++++++++++++++++++++++++ cmd/mxcli/syntax/features_misc.go | 8 +++- docs-site/src/language/basics.md | 12 +++++ 4 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 cmd/mxcli/cmd_fmt_upgrade_test.go diff --git a/cmd/mxcli/cmd_fmt.go b/cmd/mxcli/cmd_fmt.go index 928c364a1..28b14ed20 100644 --- a/cmd/mxcli/cmd_fmt.go +++ b/cmd/mxcli/cmd_fmt.go @@ -6,10 +6,13 @@ import ( "fmt" "io" "os" + "sort" "strings" + "github.com/mendixlabs/mxcli/mdl/deprecation" "github.com/mendixlabs/mxcli/mdl/formatter" "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/upgrade" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/spf13/cobra" ) @@ -35,10 +38,31 @@ Examples: # Format from stdin (pipe) mxcli describe microflow Mod.MF | mxcli fmt mxcli describe microflow Mod.MF | mxcli fmt - + +Upgrading (--upgrade): + Rewrites every deprecated spelling registered in the deprecation registry to + its canonical form (create or replace -> create or modify, show -> list, ...) + and changes nothing else: comments, layout and keyword case are kept, and the + heuristic formatting above is not applied. A deprecated use that has no + mechanical rewrite is reported on stderr and left in place. + + --header also adds the language header (mdl 1;), after rewriting every + construct whose meaning the header would change. A construct without such a + rewrite blocks the header, and fmt fails rather than change the script's + meaning. While mdl 1 is a preview the header is added only when asked. + + # Upgrade in place + mxcli fmt --upgrade -w script.mdl + mxcli fmt --upgrade --header -w script.mdl `, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { writeInPlace, _ := cmd.Flags().GetBool("write") + doUpgrade, _ := cmd.Flags().GetBool("upgrade") + addHeader, _ := cmd.Flags().GetBool("header") + if cmd.Flags().Changed("header") && !doUpgrade { + return fmt.Errorf("--header needs --upgrade") + } // Determine source: stdin when no arg or "-" is passed. fromStdin := len(args) == 0 || args[0] == "-" @@ -84,13 +108,34 @@ Examples: return fmt.Errorf("no valid MDL statements found in %s", label) } - formatted := formatter.Format(string(data)) + var formatted string + if doUpgrade { + opts := upgrade.DefaultOptions() + if cmd.Flags().Changed("header") { + opts.AddHeader = addHeader + } + res, err := upgrade.Upgrade(string(data), opts) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + reportUpgrade(os.Stderr, label, res) + formatted = res.Source + } else { + formatted = formatter.Format(string(data)) + } if writeInPlace { + if doUpgrade && formatted == string(data) { + return nil // nothing to upgrade: leave the file untouched + } if err := os.WriteFile(filePath, []byte(formatted), 0644); err != nil { return fmt.Errorf("failed to write file: %w", err) } - fmt.Fprintf(os.Stderr, "Formatted %s\n", filePath) + verb := "Formatted" + if doUpgrade { + verb = "Upgraded" + } + fmt.Fprintf(os.Stderr, "%s %s\n", verb, filePath) } else { fmt.Print(formatted) } @@ -101,6 +146,36 @@ Examples: func init() { fmtCmd.Flags().BoolP("write", "w", false, "Write result to source file instead of stdout") + fmtCmd.Flags().Bool("upgrade", false, "Rewrite deprecated spellings to their canonical form, changing nothing else") + fmtCmd.Flags().Bool("header", false, "With --upgrade: add the mdl 1 language header (opt-in while mdl 1 is a preview)") +} + +// reportUpgrade prints what an upgrade did, and what it left, on w. +func reportUpgrade(w io.Writer, label string, res upgrade.Result) { + codes := func(m map[string]int) string { + var parts []string + for c, n := range m { + parts = append(parts, fmt.Sprintf("%s x%d", c, n)) + } + sort.Strings(parts) + return strings.Join(parts, ", ") + } + if len(res.Rewritten) > 0 { + fmt.Fprintf(w, "%s: rewrote %s\n", label, codes(res.Rewritten)) + } + if len(res.GatedRewritten) > 0 { + fmt.Fprintf(w, "%s: rewrote for the header %s\n", label, codes(res.GatedRewritten)) + } + if res.HeaderAdded { + fmt.Fprintf(w, "%s: added the language header\n", label) + } + for _, d := range res.Unrewritten { + msg := d.Code + if e, ok := deprecation.Lookup(d.Code); ok { + msg = fmt.Sprintf("%s (%s -> %s)", d.Code, e.Old, e.Canonical) + } + fmt.Fprintf(w, "%s:%d:%d: not upgraded, no mechanical rewrite: %s\n", label, d.Line, d.Column+1, msg) + } } // hasSubstantiveContent reports whether s contains at least one non-blank, diff --git a/cmd/mxcli/cmd_fmt_upgrade_test.go b/cmd/mxcli/cmd_fmt_upgrade_test.go new file mode 100644 index 000000000..a56e15760 --- /dev/null +++ b/cmd/mxcli/cmd_fmt_upgrade_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func runFmt(t *testing.T, args ...string) (string, error) { + t.Helper() + // cobra keeps flag values between runs in one process. + for _, f := range []string{"write", "upgrade", "header"} { + _ = fmtCmd.Flags().Set(f, "false") + fmtCmd.Flags().Lookup(f).Changed = false + } + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs(append([]string{"fmt"}, args...)) + err := rootCmd.ExecuteContext(context.Background()) + return out.String(), err +} + +func TestFmtUpgrade_WritesOnlyTheRewrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.mdl") + src := "-- keep me\nCREATE OR REPLACE entity M.User (Name: String);\n show entities;\n" + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runFmt(t, "--upgrade", "-w", path); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(path) + // Only the deprecated keywords change: no heuristic upper-casing (which + // would turn M.User into M.USER), no re-indentation, and no header while + // mdl 1 is a preview. + want := "-- keep me\nCREATE OR MODIFY entity M.User (Name: String);\n list entities;\n" + if string(got) != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } + + if _, err := runFmt(t, "--upgrade", "--header", "-w", path); err != nil { + t.Fatal(err) + } + got, _ = os.ReadFile(path) + if !strings.HasPrefix(string(got), "mdl 1;\n-- keep me\n") { + t.Fatalf("--header did not add the header:\n%s", got) + } + + // Idempotent: a second run leaves the file as it is. + before := string(got) + if _, err := runFmt(t, "--upgrade", "--header", "-w", path); err != nil { + t.Fatal(err) + } + if got, _ = os.ReadFile(path); string(got) != before { + t.Fatalf("second fmt --upgrade changed the file:\n%s", got) + } +} + +func TestFmtUpgrade_HeaderNeedsUpgrade(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.mdl") + if err := os.WriteFile(path, []byte("show entities;\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runFmt(t, "--header", path); err == nil || !strings.Contains(err.Error(), "--upgrade") { + t.Fatalf("--header without --upgrade: %v", err) + } +} diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 3bb1e41eb..989105f7d 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -12,7 +12,7 @@ func init() { Summary: "mdl <n>; — the MDL language version a script is written in", Keywords: []string{ "mdl 1", "mdl 0", "language version", "header", "edition", - "preview", "beta", "version-gated", "meaning", + "preview", "beta", "version-gated", "meaning", "fmt --upgrade", "upgrade", }, Syntax: "mdl <n>;\n\n" + "-- Optional, and only as the FIRST statement of a script. It declares the\n" + @@ -27,7 +27,11 @@ func init() { "--\n" + "-- A script's meaning never depends on which mxcli release runs it: a\n" + "-- change of meaning applies only under the version that introduces it.\n" + - "-- The header is independent of the Mendix version the project targets.", + "-- The header is independent of the Mendix version the project targets.\n" + + "--\n" + + "-- `mxcli fmt --upgrade --header` adds it, after rewriting every construct\n" + + "-- whose meaning it would change; it refuses when one has no rewrite.\n" + + "-- `mxcli fmt --upgrade` alone rewrites deprecated spellings (MDL-DEPRnnn).", Example: "mdl 1;\n\ncreate persistent entity MyModule.Customer (\n Name: String(200)\n);", SeeAlso: []string{"create-modifiers"}, }) diff --git a/docs-site/src/language/basics.md b/docs-site/src/language/basics.md index cd09b8336..841c601c8 100644 --- a/docs-site/src/language/basics.md +++ b/docs-site/src/language/basics.md @@ -52,6 +52,18 @@ create persistent entity Sales.Customer ( - The header must be the **first** statement. A version this mxcli does not know is refused. - It is independent of the Mendix version your project targets. +### Upgrading a script: `mxcli fmt --upgrade` + +`mxcli fmt --upgrade` rewrites every deprecated spelling (the `MDL-DEPRnnn` warnings) to its canonical form — `create or replace` becomes `create or modify`, `show entities` becomes `list entities` — and changes nothing else: comments, layout and keyword case are kept. A deprecated use with no mechanical rewrite is reported and left in place. + +```bash +mxcli fmt --upgrade script.mdl # print the upgraded script +mxcli fmt --upgrade -w script.mdl # upgrade in place +mxcli fmt --upgrade --header -w script.mdl # also add `mdl 1;` +``` + +`--header` adds `mdl 1;` after rewriting every construct whose meaning the header would change, so the script keeps doing what it did. If such a construct has no rewrite yet, `fmt` refuses to add the header rather than change the script's meaning. While `mdl 1` is a preview, the header is added only when asked. Running `fmt --upgrade` on its own output changes nothing. + The design is in [ADR-0011](https://github.com/mendixlabs/mxcli/blob/main/docs/13-decisions/0011-mdl-language-versioning.md); `mxcli syntax language-header` has the details. ## Case Insensitivity From 6a72daba3502a3d0b413b9944eee356fb252e3a8 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 09:49:20 +0000 Subject: [PATCH 31/37] roundtrip: execute-both property test for fmt --upgrade Every mdl-examples script and its upgrade (with the header) run on two copies of the PedApp fixture; the models are compared unit by unit as canonical BSON, with units matched by their place in the project tree and freshly minted identities (GUID, StableId, string and embedded UUIDs) masked only when the fixture does not contain them, so a re-minted Studio Pro identity still registers. Controls: the same script twice is equal, a changed caption and a meaning-changing translations rewrite are not, and a re-minted fixture GUID is visible. 587 scripts execute on PedApp and upgrade to the same model; 114 whose original does not execute cleanly are listed as out of scope. Closes #735. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/roundtrip/doc.go | 4 + mdl/roundtrip/upgrade_property_test.go | 482 +++++++++++++++++++++++++ 2 files changed, 486 insertions(+) create mode 100644 mdl/roundtrip/upgrade_property_test.go diff --git a/mdl/roundtrip/doc.go b/mdl/roundtrip/doc.go index b677c5058..8ab8e8305 100644 --- a/mdl/roundtrip/doc.go +++ b/mdl/roundtrip/doc.go @@ -9,6 +9,10 @@ // nothing. // - PutGet: describing the document again returns what was described before. // +// It also holds the execute-both property test of `mxcli fmt --upgrade` +// (upgrade_property_test.go): every mdl-examples script and its upgrade run on +// two copies of the fixture and must write the same model. +// // Every other round-trip test in the repo runs on mxcli-authored content, where // an element's GUID equals its $ID, so identity loss cannot show up there. This // fixture was authored by Studio Pro and the marketplace, so it can. diff --git a/mdl/roundtrip/upgrade_property_test.go b/mdl/roundtrip/upgrade_property_test.go new file mode 100644 index 000000000..66fd701c6 --- /dev/null +++ b/mdl/roundtrip/upgrade_property_test.go @@ -0,0 +1,482 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package roundtrip + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/upgrade" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/modelsdk/canon" + mmpr "github.com/mendixlabs/mxcli/modelsdk/mpr" +) + +// The safety property of `mxcli fmt --upgrade` (ako/mxcli#735, ADR-0011 +// decision 1): an upgraded script executes to the same model as the original. +// A wrong rewrite is a silent change of meaning — the script still parses and +// still runs, it just builds something else — so the only proof is to run both +// and compare what they wrote. +// +// For every mdl-examples script: +// +// 1. upgrade it, with the language header, which is the strongest form: every +// alias rewritten and the script run under mdl 1; +// 2. require zero deprecated spellings in the result; +// 3. execute the original on one copy of the PedApp fixture and the upgrade on +// another; +// 4. compare the two models unit by unit, as canonical BSON (canon.Digest, +// which numbers element $IDs by containment so freshly minted IDs do not +// count as a difference), with units matched by their place in the project +// tree rather than by unit ID, which is fresh for every created document. +// +// A script whose original does not execute cleanly on PedApp is out of scope +// and listed; the models are still compared, so a rewrite that changes how far +// a failing script gets is caught too. +// +// MXCLI_UPGRADE_EXAMPLES=<regexp> limits the run to matching script paths. +func TestUpgradeExecutesToTheSameModel(t *testing.T) { + a, b := newHarness(t), newHarness(t) + defer a.close() + defer b.close() + + scripts := upgradeExampleScripts(t) + var same, outOfScope, unparsed []string + for _, path := range scripts { + src, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + rel, _ := filepath.Rel("../..", path) + if _, errs := visitor.Build(string(src)); len(errs) > 0 { + unparsed = append(unparsed, rel) + continue + } + t.Run(rel, func(t *testing.T) { + res, err := upgrade.Upgrade(string(src), upgrade.Options{AddHeader: true}) + if err != nil { + t.Fatalf("upgrade: %v", err) + } + prog, errs := visitor.Build(res.Source) + if len(errs) > 0 { + t.Fatalf("upgraded script does not parse: %v", errs[0]) + } + if n := len(prog.Deprecations); n > 0 { + t.Fatalf("upgraded script still has %d deprecated spelling(s), first %s at line %d", + n, prog.Deprecations[0].Code, prog.Deprecations[0].Line) + } + + errA, errB, diff := executeBoth(t, a, b, string(src), res.Source) + if len(diff) > 0 { + t.Errorf("the upgrade changed what the script writes (rewrote %v):\n %s", + res.Rewritten, strings.Join(diff, "\n ")) + } + if (errA == nil) != (errB == nil) { + t.Errorf("original error: %v\nupgraded error: %v", errA, errB) + } + if errA != nil { + outOfScope = append(outOfScope, fmt.Sprintf("%s: %s", rel, firstLine(errA.Error()))) + return + } + if !t.Failed() { + same = append(same, rel) + } + }) + } + + t.Logf("%d scripts execute on PedApp and upgrade to the same model", len(same)) + t.Logf("%d scripts are out of scope: their original does not execute cleanly on PedApp:\n %s", + len(outOfScope), strings.Join(outOfScope, "\n ")) + t.Logf("%d scripts do not parse (negative tests) and cannot be upgraded:\n %s", + len(unparsed), strings.Join(unparsed, "\n ")) + if os.Getenv("MXCLI_UPGRADE_EXAMPLES") == "" && len(same) < 50 { + // Hundreds execute today; a handful means the harness broke, and a + // property checked on nothing passes. + t.Errorf("only %d scripts executed cleanly — the harness is not exercising the property", len(same)) + } +} + +// TestUpgradeExecuteBoth_Controls are the controls CLAUDE.md requires of a +// "nothing changed" assertion. The comparison must see a real difference, and +// must not see the fresh IDs two runs of the same script mint. +func TestUpgradeExecuteBoth_Controls(t *testing.T) { + a, b := newHarness(t), newHarness(t) + defer a.close() + defer b.close() + + const script = `create module UpgradeControl; +create or modify enumeration UpgradeControl.Color (Red 'Red', Green 'Green'); +create or modify persistent entity UpgradeControl.Customer (Name: String(200), Color: Enumeration(UpgradeControl.Color)); +create or modify microflow UpgradeControl.ACT_Touch ($c: UpgradeControl.Customer) begin + change $c (Name = 'x'); + commit $c; +end; +` + // The same script twice: every created element gets a fresh $ID and GUID + // on each copy, and that must not register as a difference. + if errA, errB, diff := executeBoth(t, a, b, script, script); errA != nil || errB != nil || len(diff) > 0 { + t.Fatalf("the same script on both copies differs (errA=%v errB=%v):\n %s", errA, errB, strings.Join(diff, "\n ")) + } + + // One changed value MUST register, or every pass above is meaningless. + changed := strings.Replace(script, "'Green'", "'Groen'", 1) + _, _, diff := executeBoth(t, a, b, script, changed) + if len(diff) == 0 { + t.Fatal("a changed enumeration caption registered as the same model — the comparison cannot see a difference") + } + t.Logf("changed caption registered as: %s", strings.Join(diff, "; ")) + + // And a rewrite that changes meaning is caught. `create or replace + // translations` replaces the whole set where `or modify` merges; the + // registry exempts it, so a rewrite that ignored the exemption would write + // a different model. + const tr = `create or modify translations in Administration for nl_NL ('Save' as 'Opslaan'); +` + merged := tr + "create or modify translations in Administration for nl_NL ('Cancel' as 'Annuleren');\n" + replaced := tr + "create or replace translations in Administration for nl_NL ('Cancel' as 'Annuleren');\n" + errA, errB, diff := executeBoth(t, a, b, replaced, merged) + if errA != nil || errB != nil { + t.Fatalf("translations control did not execute: %v / %v", errA, errB) + } + if len(diff) == 0 { + t.Fatal("rewriting `create or replace translations` to `or modify` registered as the same model — " + + "the property test cannot catch a rewrite that changes meaning") + } + t.Logf("a meaning-changing rewrite registered as: %s", strings.Join(diff, "; ")) + + // A re-minted identity on a Studio Pro-authored element must register: + // maskFresh masks only values the fixture does not contain. + known := fixtureIdentities(a) + for id, raw := range a.orig.units { + var doc bson.D + if bson.Unmarshal(raw, &doc) != nil { + continue + } + hit := false + reminted := walkIdentities(doc, func(key string, v any) any { + b, isBin := v.(bson.Binary) + if key != "GUID" || !isBin || hit { + return v + } + hit = true + return bson.Binary{Subtype: b.Subtype, Data: []byte("re-minted guid!!")} + }).(bson.D) + if !hit { + continue + } + out, err := bson.Marshal(reminted) + if err != nil { + t.Fatal(err) + } + d1, err1 := canon.Digest(maskFresh(raw, known, a.dir)) + d2, err2 := canon.Digest(maskFresh(out, known, a.dir)) + if err1 != nil || err2 != nil || d1 == d2 { + t.Fatalf("a re-minted GUID in unit %s is invisible to the comparison (%v %v)", id, err1, err2) + } + return + } + t.Fatal("the fixture has no unit with a GUID — the identity control has nothing to change") +} + +// executeBoth runs one script on each harness, from the committed fixture, and +// returns each run's error and the differences between the models they wrote. +func executeBoth(t *testing.T, a, b *harness, srcA, srcB string) (errA, errB error, diff []string) { + t.Helper() + a.restore() + b.restore() + errA = a.exec(srcA) + errB = b.exec(srcB) + return errA, errB, compareModels(canonicalModel(t, a), canonicalModel(t, b)) +} + +// canonicalModel is a working copy as a map from a unit's place in the project +// tree to its canonical digest, plus the files outside the model. +// +// A unit is keyed by the chain of container names down to it and its own +// "$Type Name", not by its unit ID: a document created by the script gets a +// fresh unit ID on each copy. Units that share a key (unnamed units of one type +// in one container) are told apart by their sorted digests. +// +// Identity values minted for a created element — its StableId, its GUID where +// that is not its $ID, string ids — are fresh on each copy too, so they are +// masked by maskFresh. Only values the fixture does not contain are masked: a +// Studio Pro-authored element's GUID or StableId is compared as it is, so a +// rewrite that made the executor re-mint one still registers. +func canonicalModel(t *testing.T, h *harness) map[string]string { + t.Helper() + known := fixtureIdentities(h) + r, err := mmpr.Open(h.mpr) + if err != nil { + t.Fatalf("open working copy: %v", err) + } + defer r.Close() + units, err := r.ListUnits() + if err != nil { + t.Fatalf("list units: %v", err) + } + label := map[string]string{} + parent := map[string]string{} + digest := map[string]string{} + for _, u := range units { + raw, err := r.GetRawUnitBytes(u.ID) + if err != nil { + t.Fatalf("read unit %s: %v", u.ID, err) + } + typ, name := typeAndName(raw) + label[u.ID] = strings.TrimSpace(typ + " " + name) + parent[u.ID] = u.ContainerID + "/" + u.ContainmentName + d, err := canon.Digest(maskFresh(raw, known, h.dir)) + if err != nil { + sum := sha256.Sum256(raw) + d = "raw:" + hex.EncodeToString(sum[:]) + } + digest[u.ID] = d + } + var path func(id string, depth int) string + path = func(id string, depth int) string { + p, ok := parent[id] + if !ok || depth > 64 { + return "" + } + container, containment, _ := strings.Cut(p, "/") + if _, known := label[container]; !known || container == id { + return containment + ":" + label[id] + } + return path(container, depth+1) + " > " + containment + ":" + label[id] + } + byKey := map[string][]string{} + for id := range label { + k := path(id, 0) + byKey[k] = append(byKey[k], digest[id]) + } + out := map[string]string{} + for k, ds := range byKey { + sort.Strings(ds) + for i, d := range ds { + key := k + if len(ds) > 1 { + key = fmt.Sprintf("%s #%d", k, i+1) + } + out[key] = d + } + } + for rel, b := range h.snapshot().files { + sum := sha256.Sum256(b) + out["file "+rel] = hex.EncodeToString(sum[:]) + } + return out +} + +// fixtureIdentities is every identity-shaped value in the committed fixture: +// each 16-byte binary, and each string that reads as a UUID. +func fixtureIdentities(h *harness) map[string]bool { + if knownIdentities != nil { + return knownIdentities + } + knownIdentities = map[string]bool{} + for _, raw := range h.orig.units { + var doc bson.D + if bson.Unmarshal(raw, &doc) != nil { + continue + } + walkIdentities(doc, func(_ string, v any) any { + if s, ok := v.(string); ok { + for _, u := range uuidInString.FindAllString(s, -1) { + knownIdentities[identityKey(u)] = true + } + return v + } + knownIdentities[identityKey(v)] = true + return v + }) + } + return knownIdentities +} + +var knownIdentities map[string]bool + +// uuidInString matches the string spellings of a UUID mxcli and Mendix +// store, on their own or inside a larger text such as a JSON document: 32 hex +// digits, or the dashed 8-4-4-4-12 form. +var uuidInString = regexp.MustCompile(`(?i)\b(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b`) + +func identityKey(v any) string { + switch t := v.(type) { + case bson.Binary: + return "b:" + string(t.Data) + case string: + return "s:" + strings.ToLower(t) + } + return "" +} + +// maskFresh prepares a unit for canon.Digest when it may have been written by +// a script, not only by Studio Pro. +// +// Every identity value the fixture does not contain, and that is not one of +// the unit's own element $IDs (canon numbers those, references included), is +// freshly minted — a created element's GUID or StableId, a string id such as +// a business event channel name, or a UUID inside a text such as an agent +// document's JSON — and is replaced by a placeholder +// numbered by first appearance in a key-sorted walk. Two runs that minted the +// same number of identities in the same places are then equal. dir, the +// working copy's path, is replaced too: a statement that stores a local file +// URL stores a path that differs between the two copies. +func maskFresh(raw []byte, known map[string]bool, dir string) []byte { + var doc bson.D + if bson.Unmarshal(raw, &doc) != nil { + return raw + } + ids := map[string]bool{} + walkIdentities(doc, func(key string, v any) any { + if b, ok := v.(bson.Binary); ok && key == "$ID" { + ids[identityKey(b)] = true + ids[identityKey(uuidOf(b.Data))] = true // canon also numbers its string form + } + return v + }) + fresh := map[string]int{} + placeholder := func(k string) int { + n, ok := fresh[k] + if !ok { + n = len(fresh) + 1 + fresh[k] = n + } + return n + } + masked := walkIdentities(doc, func(key string, v any) any { + if str, ok := v.(string); ok { + if dir != "" { + str = strings.ReplaceAll(str, dir, "<working copy>") + } + return uuidInString.ReplaceAllStringFunc(str, func(u string) string { + k := identityKey(u) + if known[k] || ids[k] { + return u + } + return fmt.Sprintf("<fresh %d>", placeholder(k)) + }) + } + b := v.(bson.Binary) + k := identityKey(b) + if known[k] || ids[k] { + return v + } + data := make([]byte, 16) + copy(data, fmt.Sprintf("fresh%011d", placeholder(k))) + return bson.Binary{Subtype: b.Subtype, Data: data} + }).(bson.D) + out, err := bson.Marshal(masked) + if err != nil { + return raw + } + return out +} + +// uuidOf renders a 16-byte Mendix GUID blob as a UUID string, the way canon +// does (Microsoft layout: first three groups little-endian). +func uuidOf(b []byte) string { + return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + b[3], b[2], b[1], b[0], b[5], b[4], b[7], b[6], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]) +} + +// walkIdentities visits every 16-byte binary and every string in v, +// documents in key order, and returns v with each replaced by what f returns. +func walkIdentities(v any, f func(key string, v any) any) any { + return walkIdentitiesKey("", v, f) +} + +func walkIdentitiesKey(key string, v any, f func(string, any) any) any { + switch t := v.(type) { + case bson.D: + order := make([]int, len(t)) + for i := range order { + order[i] = i + } + sort.SliceStable(order, func(i, j int) bool { return t[order[i]].Key < t[order[j]].Key }) + out := make(bson.D, len(t)) + copy(out, t) + for _, i := range order { + out[i].Value = walkIdentitiesKey(t[i].Key, t[i].Value, f) + } + return out + case bson.A: + out := make(bson.A, len(t)) + for i, e := range t { + out[i] = walkIdentitiesKey(key, e, f) + } + return out + case bson.Binary: + if len(t.Data) == 16 { + return f(key, t) + } + case string: + return f(key, t) + } + return v +} + +func compareModels(a, b map[string]string) []string { + var out []string + for k, da := range a { + db, ok := b[k] + switch { + case !ok: + out = append(out, "only the original wrote "+k) + case da != db: + out = append(out, "different content: "+k) + } + } + for k := range b { + if _, ok := a[k]; !ok { + out = append(out, "only the upgrade wrote "+k) + } + } + sort.Strings(out) + return out +} + +func upgradeExampleScripts(t *testing.T) []string { + t.Helper() + var filter *regexp.Regexp + if f := os.Getenv("MXCLI_UPGRADE_EXAMPLES"); f != "" { + filter = regexp.MustCompile(f) + } + var out []string + err := filepath.Walk("../../mdl-examples", func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(p, ".mdl") && (filter == nil || filter.MatchString(p)) { + out = append(out, p) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(out) + if len(out) == 0 { + t.Fatal("no mdl-examples scripts found") + } + return out +} + +func firstLine(s string) string { + s, _, _ = strings.Cut(s, "\n") + if r := []rune(s); len(r) > 160 { + return string(r[:160]) + "…" + } + return s +} From f35637a3425ccde51d3a0aba48e4c5027f387907 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 10:37:28 +0000 Subject: [PATCH 32/37] roundtrip: execute only scripts the upgrade rewrites, by default The execute-both property test ran all 701 parseable mdl-examples scripts on two PedApp copies, about 15 minutes locally. On CI it pushed the Integration tests step past its 30-minute timeout (PR #742, run 36310586491), where main's roundtrip package takes 85s. 478 of those scripts only gain the header: both runs execute the same statements, so they test langver's gating rather than any rewrite. They are now listed and skipped unless MXCLI_UPGRADE_ALL=1. The 223 rewritten scripts still execute (202 same model, 21 out of scope), in about 5 minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/roundtrip/doc.go | 3 ++- mdl/roundtrip/upgrade_property_test.go | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/mdl/roundtrip/doc.go b/mdl/roundtrip/doc.go index 8ab8e8305..bc1abc523 100644 --- a/mdl/roundtrip/doc.go +++ b/mdl/roundtrip/doc.go @@ -11,7 +11,8 @@ // // It also holds the execute-both property test of `mxcli fmt --upgrade` // (upgrade_property_test.go): every mdl-examples script and its upgrade run on -// two copies of the fixture and must write the same model. +// two copies of the fixture and must write the same model (by default only the +// scripts it rewrites beyond the header; MXCLI_UPGRADE_ALL=1 runs all of them). // // Every other round-trip test in the repo runs on mxcli-authored content, where // an element's GUID equals its $ID, so identity loss cannot show up there. This diff --git a/mdl/roundtrip/upgrade_property_test.go b/mdl/roundtrip/upgrade_property_test.go index 66fd701c6..d0ce3e630 100644 --- a/mdl/roundtrip/upgrade_property_test.go +++ b/mdl/roundtrip/upgrade_property_test.go @@ -17,6 +17,7 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" + "github.com/mendixlabs/mxcli/mdl/langver" "github.com/mendixlabs/mxcli/mdl/upgrade" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/mendixlabs/mxcli/modelsdk/canon" @@ -46,13 +47,22 @@ import ( // a failing script gets is caught too. // // MXCLI_UPGRADE_EXAMPLES=<regexp> limits the run to matching script paths. +// +// By default only scripts the upgrade rewrites beyond adding the header are +// executed: with the header as the only edit, the two runs execute the same +// statements, and what they compare is langver's gating rather than a rewrite. +// Executing the whole corpus takes about 15 minutes, which on its own exceeds +// what the CI integration step has left (ako/mxcli#742 timed out there). +// MXCLI_UPGRADE_ALL=1 executes every script, header-only ones included; run it +// when a langver.Change or a gated rewrite lands. func TestUpgradeExecutesToTheSameModel(t *testing.T) { a, b := newHarness(t), newHarness(t) defer a.close() defer b.close() scripts := upgradeExampleScripts(t) - var same, outOfScope, unparsed []string + all := os.Getenv("MXCLI_UPGRADE_ALL") != "" + var same, outOfScope, unparsed, headerOnly []string for _, path := range scripts { src, err := os.ReadFile(path) if err != nil { @@ -76,6 +86,10 @@ func TestUpgradeExecutesToTheSameModel(t *testing.T) { t.Fatalf("upgraded script still has %d deprecated spelling(s), first %s at line %d", n, prog.Deprecations[0].Code, prog.Deprecations[0].Line) } + if !all && onlyHeaderAdded(string(src), res) { + headerOnly = append(headerOnly, rel) + return + } errA, errB, diff := executeBoth(t, a, b, string(src), res.Source) if len(diff) > 0 { @@ -98,6 +112,9 @@ func TestUpgradeExecutesToTheSameModel(t *testing.T) { t.Logf("%d scripts execute on PedApp and upgrade to the same model", len(same)) t.Logf("%d scripts are out of scope: their original does not execute cleanly on PedApp:\n %s", len(outOfScope), strings.Join(outOfScope, "\n ")) + if !all { + t.Logf("%d scripts only gain the header and were not executed (MXCLI_UPGRADE_ALL=1 executes them)", len(headerOnly)) + } t.Logf("%d scripts do not parse (negative tests) and cannot be upgraded:\n %s", len(unparsed), strings.Join(unparsed, "\n ")) if os.Getenv("MXCLI_UPGRADE_EXAMPLES") == "" && len(same) < 50 { @@ -107,6 +124,12 @@ func TestUpgradeExecutesToTheSameModel(t *testing.T) { } } +// onlyHeaderAdded reports whether the upgrade's single edit was the header. +func onlyHeaderAdded(src string, res upgrade.Result) bool { + return len(res.Rewritten) == 0 && len(res.GatedRewritten) == 0 && + strings.TrimPrefix(res.Source, langver.Latest.String()+";\n") == src +} + // TestUpgradeExecuteBoth_Controls are the controls CLAUDE.md requires of a // "nothing changed" assertion. The comparison must see a real difference, and // must not see the fresh IDs two runs of the same script mint. From b71aaff0250ac2c09520fd22fc4d1492dfc22e21 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 11:15:29 +0000 Subject: [PATCH 33/37] describe: keep plain create for workflow, external entity, OData client/service (ADR-0012) Their create or modify rewrites do not yet carry what describe cannot print (review of #738; details in #743), so re-running describe output on Studio Pro content would silently lose it. A plain create refuses instead. Tests pin the verb until #743 proves each carry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/executor/cmd_odata.go | 13 ++++++++++--- mdl/executor/cmd_odata_mock_test.go | 6 ++++-- mdl/executor/cmd_workflows.go | 7 ++++++- mdl/executor/cmd_workflows_mock_test.go | 3 ++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index 873b7a05e..53a54d72b 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -134,7 +134,12 @@ func outputConsumedODataServiceMDL(ctx *ExecContext, svc *model.ConsumedODataSer outputJavadoc(ctx.Output, svc.Description) } - fmt.Fprintf(ctx.Output, "create or modify odata client %s.%s (\n", moduleName, svc.Name) + // describe keeps a plain `create` here, not `create or modify` (ADR-0012: carry or refuse, + // never silently drop). A `create or modify` rewrite of this type does not yet carry what + // describe cannot print, so re-running this output on an existing document would silently + // lose Studio Pro-authored content; a plain `create` refuses instead. Switch the verb only + // once the rewrite's carry is proven by the round-trip harness (see #743). + fmt.Fprintf(ctx.Output, "create odata client %s.%s (\n", moduleName, svc.Name) var props []string if folderPath != "" { @@ -328,7 +333,8 @@ func outputPublishedODataServiceMDL(ctx *ExecContext, svc *model.PublishedODataS outputJavadoc(ctx.Output, svc.Description) } - fmt.Fprintf(ctx.Output, "create or modify odata service %s.%s (\n", moduleName, svc.Name) + // Plain `create` on purpose; see describeODataClient. + fmt.Fprintf(ctx.Output, "create odata service %s.%s (\n", moduleName, svc.Name) var props []string if folderPath != "" { @@ -757,7 +763,8 @@ func outputExternalEntityMDL(ctx *ExecContext, entity *domainmodel.Entity, modul outputJavadoc(ctx.Output, entity.Documentation) } - fmt.Fprintf(ctx.Output, "create or modify external entity %s.%s\n", moduleName, entity.Name) + // Plain `create` on purpose; see describeODataClient. + fmt.Fprintf(ctx.Output, "create external entity %s.%s\n", moduleName, entity.Name) fmt.Fprintf(ctx.Output, "from odata client %s\n", entity.RemoteServiceName) fmt.Fprintln(ctx.Output, "(") diff --git a/mdl/executor/cmd_odata_mock_test.go b/mdl/executor/cmd_odata_mock_test.go index 03b135d50..21364836c 100644 --- a/mdl/executor/cmd_odata_mock_test.go +++ b/mdl/executor/cmd_odata_mock_test.go @@ -95,7 +95,8 @@ func TestDescribeODataClient_Mock(t *testing.T) { assertNoError(t, describeODataClient(ctx, ast.QualifiedName{Module: "MyModule", Name: "PetStoreClient"})) out := buf.String() - assertContainsStr(t, out, "create or modify odata client") + assertContainsStr(t, out, "create odata client") + assertNotContainsStr(t, out, "create or modify odata client") // safe refusal until its carry is proven (ADR-0012) assertContainsStr(t, out, "MyModule.PetStoreClient") assertContainsStr(t, out, "https://example.com/$metadata") assertContainsStr(t, out, "2.0") @@ -191,7 +192,8 @@ func TestDescribeODataService_Mock(t *testing.T) { assertNoError(t, describeODataService(ctx, ast.QualifiedName{Module: "MyModule", Name: "CatalogService"})) out := buf.String() - assertContainsStr(t, out, "create or modify odata service") + assertContainsStr(t, out, "create odata service") + assertNotContainsStr(t, out, "create or modify odata service") // safe refusal until its carry is proven (ADR-0012) assertContainsStr(t, out, "MyModule.CatalogService") } diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index f900201a7..0542a0b49 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -185,7 +185,12 @@ func describeWorkflowToString(ctx *ExecContext, name ast.QualifiedName) (string, } lines = append(lines, "") - lines = append(lines, fmt.Sprintf("create or modify workflow %s", qualifiedName)) + // describe keeps a plain `create` here, not `create or modify` (ADR-0012: carry or refuse, + // never silently drop). A `create or modify` rewrite of this type does not yet carry what + // describe cannot print, so re-running this output on an existing document would silently + // lose Studio Pro-authored content; a plain `create` refuses instead. Switch the verb only + // once the rewrite's carry is proven by the round-trip harness (see #743). + lines = append(lines, fmt.Sprintf("create workflow %s", qualifiedName)) if clause := describeFolderClause(ctx, targetWf.ContainerID); clause != "" { lines = append(lines, " "+strings.TrimSpace(clause)) } diff --git a/mdl/executor/cmd_workflows_mock_test.go b/mdl/executor/cmd_workflows_mock_test.go index 5c8b614eb..ef451148b 100644 --- a/mdl/executor/cmd_workflows_mock_test.go +++ b/mdl/executor/cmd_workflows_mock_test.go @@ -48,7 +48,8 @@ func TestDescribeWorkflow_Mock(t *testing.T) { assertNoError(t, describeWorkflow(ctx, ast.QualifiedName{Module: "Sales", Name: "ApproveOrder"})) out := buf.String() - assertContainsStr(t, out, "create or modify workflow") + assertContainsStr(t, out, "create workflow") + assertNotContainsStr(t, out, "create or modify workflow") // safe refusal until its carry is proven (ADR-0012) assertContainsStr(t, out, "Sales.ApproveOrder") // Roundtrip: DESCRIBE output must be parseable as valid MDL (issue #478) From 0dd61fe3cb74ae6d3c0dc0968aaf151c77987887 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 11:39:53 +0000 Subject: [PATCH 34/37] describe: end every statement with `;` and never print a `/` line (#744) ADR-0010 R11/R12: describe output must mean the same under mdl 0 and mdl 1, and mdl 1 makes `;` the only terminator. About twenty document types printed a SQL*Plus `/` line after their statement, and pages, snippets, layouts and some OData statements ended without `;`. - Drop every `/` line from describe (agents, knowledge bases, MCP services, models, constants, contracts, associations, DB connections, entities, enumerations, image collections, modules, OData, published REST, security, microflows, nanoflows, rules). - Pages, snippets and layouts end with `};`; a workflow with `end workflow;`. - A published OData service ends with `;` after its property list, authentication clause or entity block, whichever is last; an external entity without attributes after its property list. - A published REST service with no resources prints an empty `{ };` block: the block is mandatory in the grammar, so the bare `;` it printed before did not parse. Tests: TestPedAppDescribeIsValidMdl1 checks the terminators of every PedApp document type (plus each module, with and without `with all`, navigation and settings) on the parse tree and re-parses the output under an `mdl 1;` header; executor tests cover the types PedApp does not contain, and every Describe*_Mock / #707 re-parse test now asserts the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../fix-issue/findings/mdl-executor.jsonl | 1 + mdl/executor/cmd_agenteditor_agents.go | 1 - mdl/executor/cmd_agenteditor_kbs.go | 1 - mdl/executor/cmd_agenteditor_mcpservices.go | 1 - mdl/executor/cmd_agenteditor_mock_test.go | 4 + mdl/executor/cmd_agenteditor_models.go | 1 - mdl/executor/cmd_associations.go | 2 - mdl/executor/cmd_constants.go | 1 - mdl/executor/cmd_contract.go | 1 - mdl/executor/cmd_dbconnection.go | 1 - mdl/executor/cmd_entities_describe.go | 1 - mdl/executor/cmd_enumerations.go | 1 - mdl/executor/cmd_imagecollections.go | 2 - .../cmd_imagecollections_mock_test.go | 1 + mdl/executor/cmd_microflows_show.go | 6 - mdl/executor/cmd_modules.go | 2 - mdl/executor/cmd_odata.go | 62 ++++--- .../cmd_odata_client_describe_quoting_test.go | 1 + mdl/executor/cmd_pages_describe.go | 8 +- mdl/executor/cmd_published_rest.go | 5 +- mdl/executor/cmd_published_rest_mock_test.go | 1 + mdl/executor/cmd_security.go | 3 - mdl/executor/cmd_security_mock_test.go | 3 + mdl/executor/cmd_workflows.go | 3 +- mdl/executor/cmd_workflows_mock_test.go | 1 + mdl/executor/describe_terminators_test.go | 133 +++++++++++++++ .../issue707_describe_roundtrip_test.go | 1 + mdl/roundtrip/describe_terminators_test.go | 156 ++++++++++++++++++ 28 files changed, 345 insertions(+), 59 deletions(-) create mode 100644 mdl/executor/describe_terminators_test.go create mode 100644 mdl/roundtrip/describe_terminators_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 4947fb831..c0609ab33 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -730,3 +730,4 @@ {"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe microflow \u2026 with handles (ako/mxcli#713) printed no handle for an activity inside an `on error { \u2026 }` block, and the alter-target resolver counted such activities after the whole main flow: on SUB_Feedback_PostToAppInsights `return * @1` picked `return $Response`, although describe prints the handler's `return empty` first. Also `$Response` did not address a REST call whose output is on its result handling (and cast, create list, web service, workflow, XML/JSON, database-query outputs).", "cause": "Error-handler bodies are rendered by collectErrorHandlerStatements, a second describer that returned bare strings and never wrote the source map, so those nodes had no line to rank or print a handle at; unranked candidates were appended last. The output-variable switch was copied from actionOutputVariableName, which had drifted from the formatter.", "file": "mdl/executor/cmd_microflows_show_helpers.go; mdl/backend/mfmutator/target.go", "fix": "collectErrorHandlerStatementSpans reports each handler-body object's statement span; emitActivityStatement and emitCommentedErrorHandler record them in the source map (additive entries in ELK sourceMap too). mfmutator.OutputVariable reads the variable where the formatter does, for every action it prints as `$X = \u2026`. A comment rendering (`-- Unsupported \u2026`) is no statement, so it never becomes a handle.", "insight": "Any node the describer prints through a side path must enter the source map, or everything keyed on print order (ordinals, handles, ELK highlighting) silently disagrees with the text. Check ranking against a Studio Pro flow that has a handler body, not just VAL_Feedback.", "test": "TestDescribeWithHandles_ErrorHandlerBody, TestMicroflowTargets_PedAppEveryFlowRanksAndResolves, TestOutputVariable_EveryActionDescribePrintsAnAssignmentFor, TestCandidate_CommentRenderingIsNoStatement"} {"area": "mdl/executor", "date": "2026-09-27", "symptom": "describe microflow \u2026 with handles printed `-- handle: commit $Order on error {` for an activity with a custom error handler (TestApp Services.SaveOrder). The handle could not be used: an `alter microflow` target ends at the `{` that opens a fragment, so `insert after commit $Order on error { \u2026 }` parsed the handler brace as the fragment.", "cause": "printedStatement ended an action's statement at a line ending in `;` or `{` and kept the `{`, which belongs to the error-handler block describe opens, not to the statement.", "file": "mdl/executor/cmd_microflows_handles.go", "fix": "printedStatement strips the trailing `{` of an error-handler block opener, so the handle is `commit $Order on error`, which parses as a target and still matches the activity.", "insight": "A handle is only useful if it can be written back as a target in the grammar that consumes it; test a printed handle by parsing it, not only by resolving it.", "test": "TestPrintedStatement_ErrorHandlerBlockOpenerIsNotPartOfTheStatement"} {"area": "mdl/executor", "date": "2026-09-27", "symptom": "alter microflow (ako/mxcli#736) reported \"Altered microflow\" for statements mx check then rejected: two fragments in one statement declaring the same variable gave CE0111 Duplicate variable name; a fragment reading $X in the same statement as `drop $X` (either order) gave CE0109 Undefined variable. A loop fragment was refused as reading its own iterator, and drop/replace of a Studio Pro loop with more than one body activity was refused by the dangling-reference guard.", "cause": "The scope checks compared each operation with the flow as stored, never with what earlier operations of the same statement had declared, read or removed. The iterator of a fragment's loop is on its LoopSource, not an action output. Loop body flows are stored in the unit's Flows list, not in the loop, so removing the loop left them pointing at removed objects.", "file": "mdl/executor/cmd_alter_flow.go; mdl/backend/mfmutator/splice.go", "fix": "alterFlowContext tracks declaredByOps / readByOps / removedByOps across the statement's operations and checks each later operation against them; checkFragmentScope counts a fragment loop's iterator as its own; Drop and Replace remove graph.bodyFlows of a loop with it.", "insight": "A per-operation check against the stored document is only sound for a one-operation statement; every multi-operation test needs a case where operation 2 depends on operation 1. mx check on a copied TestApp is the cheap oracle: the CE numbers appear the moment a hygiene hole is hit.", "test": "TestAlterMicroflow_PedApp_ScopeSpansTheStatement; TestAlterMicroflow_PedApp_LoopFragmentDeclaresItsIterator; TestSplice_DropOrReplaceALoopTakesItsBodyFlows"} +{"area": "mdl/executor", "date": "2026-09-27", "symptom": "describe of a published REST service with no resources printed `create or modify published rest service M.S ( \u2026 )` followed by a bare `;`, which does not parse (`mismatched input ';' expecting '{'`); found while making describe end every statement with `;` (ako/mxcli#744).", "cause": "The resource block `{ \u2026 }` is mandatory in the grammar (publishedRestService: \u2026 RPAREN LBRACE publishedRestResource* RBRACE), but the describer only printed it when the service had resources and otherwise closed the statement with `;` alone.", "file": "mdl/executor/cmd_published_rest.go", "fix": "Always print the block: `{\\n};` when there are no resources.", "insight": "A describe test that asserts substrings never parses the output; feeding every Describe*_Mock output through the parser (assertTerminated, #744) found this at once. The empty-collection branch of a describer is the one no fixture exercises.", "test": "TestDescribePublishedRestService_Mock (assertTerminated)"} diff --git a/mdl/executor/cmd_agenteditor_agents.go b/mdl/executor/cmd_agenteditor_agents.go index 008c51e0a..a9b1a0266 100644 --- a/mdl/executor/cmd_agenteditor_agents.go +++ b/mdl/executor/cmd_agenteditor_agents.go @@ -163,7 +163,6 @@ func describeAgentEditorAgent(ctx *ExecContext, name ast.QualifiedName) error { } else { fmt.Fprintln(ctx.Output, ");") } - fmt.Fprintln(ctx.Output, "/") return nil } diff --git a/mdl/executor/cmd_agenteditor_kbs.go b/mdl/executor/cmd_agenteditor_kbs.go index f2c9bbf2f..4b5cbb353 100644 --- a/mdl/executor/cmd_agenteditor_kbs.go +++ b/mdl/executor/cmd_agenteditor_kbs.go @@ -120,7 +120,6 @@ func describeAgentEditorKnowledgeBase(ctx *ExecContext, name ast.QualifiedName) } fmt.Fprintln(ctx.Output, ");") - fmt.Fprintln(ctx.Output, "/") return nil } diff --git a/mdl/executor/cmd_agenteditor_mcpservices.go b/mdl/executor/cmd_agenteditor_mcpservices.go index eb9b159c1..dbee3861b 100644 --- a/mdl/executor/cmd_agenteditor_mcpservices.go +++ b/mdl/executor/cmd_agenteditor_mcpservices.go @@ -104,7 +104,6 @@ func describeAgentEditorConsumedMCPService(ctx *ExecContext, name ast.QualifiedN } fmt.Fprintln(ctx.Output, ");") - fmt.Fprintln(ctx.Output, "/") return nil } diff --git a/mdl/executor/cmd_agenteditor_mock_test.go b/mdl/executor/cmd_agenteditor_mock_test.go index 2ac1af137..05424572a 100644 --- a/mdl/executor/cmd_agenteditor_mock_test.go +++ b/mdl/executor/cmd_agenteditor_mock_test.go @@ -344,6 +344,7 @@ func TestDescribeAgentEditorModel_Mock(t *testing.T) { assertNoError(t, describeAgentEditorModel(ctx, ast.QualifiedName{Module: "M", Name: "GPT4"})) out := buf.String() + assertTerminated(t, out) // #744 assertContainsStr(t, out, "create model") assertContainsStr(t, out, "Provider") assertContainsStr(t, out, "Key") @@ -405,6 +406,7 @@ func TestDescribeAgentEditorAgent_Mock(t *testing.T) { assertNoError(t, describeAgentEditorAgent(ctx, ast.QualifiedName{Module: "M", Name: "MyAgent"})) out := buf.String() + assertTerminated(t, out) // #744 assertContainsStr(t, out, "create agent") assertContainsStr(t, out, "UsageType") assertContainsStr(t, out, "Model") @@ -465,6 +467,7 @@ func TestDescribeAgentEditorKnowledgeBase_Mock(t *testing.T) { assertNoError(t, describeAgentEditorKnowledgeBase(ctx, ast.QualifiedName{Module: "M", Name: "MyKB"})) out := buf.String() + assertTerminated(t, out) // #744 assertContainsStr(t, out, "create knowledge base") assertContainsStr(t, out, "Provider") } @@ -524,6 +527,7 @@ func TestDescribeAgentEditorConsumedMCPService_Mock(t *testing.T) { assertNoError(t, describeAgentEditorConsumedMCPService(ctx, ast.QualifiedName{Module: "M", Name: "MySvc"})) out := buf.String() + assertTerminated(t, out) // #744 assertContainsStr(t, out, "create consumed mcp service") // ProtocolVersion with a date value must be quoted so the output is re-parseable (issue #435) assertContainsStr(t, out, "ProtocolVersion: '2025-03-26'") diff --git a/mdl/executor/cmd_agenteditor_models.go b/mdl/executor/cmd_agenteditor_models.go index 743937a0d..ce4be7ff3 100644 --- a/mdl/executor/cmd_agenteditor_models.go +++ b/mdl/executor/cmd_agenteditor_models.go @@ -127,7 +127,6 @@ func describeAgentEditorModel(ctx *ExecContext, name ast.QualifiedName) error { } fmt.Fprintln(ctx.Output, ");") - fmt.Fprintln(ctx.Output, "/") return nil } diff --git a/mdl/executor/cmd_associations.go b/mdl/executor/cmd_associations.go index 34561bc3a..4a245ae8d 100644 --- a/mdl/executor/cmd_associations.go +++ b/mdl/executor/cmd_associations.go @@ -594,7 +594,6 @@ func describeAssociation(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprintf(ctx.Output, "create or modify association %s.%s\n", module.Name, assoc.Name) fmt.Fprintf(ctx.Output, "from %s to %s\n", fromEntity, toEntity) formatAssocDetails(assoc.Type, assoc.Owner, assoc.StorageFormat, assoc.ChildDeleteBehavior) - fmt.Fprintln(ctx.Output, "/") return nil } } @@ -612,7 +611,6 @@ func describeAssociation(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprintf(ctx.Output, "create or modify association %s.%s\n", module.Name, ca.Name) fmt.Fprintf(ctx.Output, "from %s to %s\n", fromEntity, ca.ChildRef) formatAssocDetails(ca.Type, ca.Owner, ca.StorageFormat, ca.ChildDeleteBehavior) - fmt.Fprintln(ctx.Output, "/") return nil } } diff --git a/mdl/executor/cmd_constants.go b/mdl/executor/cmd_constants.go index 06cdc1713..172da9355 100644 --- a/mdl/executor/cmd_constants.go +++ b/mdl/executor/cmd_constants.go @@ -132,7 +132,6 @@ func outputConstantMDL(ctx *ExecContext, c *model.Constant, moduleName string) e } fmt.Fprintln(ctx.Output, ";") - fmt.Fprintln(ctx.Output, "/") return nil } diff --git a/mdl/executor/cmd_contract.go b/mdl/executor/cmd_contract.go index 40ec17fdd..eb76b4915 100644 --- a/mdl/executor/cmd_contract.go +++ b/mdl/executor/cmd_contract.go @@ -331,7 +331,6 @@ func outputContractEntityMDL(ctx *ExecContext, et *types.EdmEntityType, svcQN st } fmt.Fprintln(ctx.Output, ");") - fmt.Fprintln(ctx.Output, "/") return nil } diff --git a/mdl/executor/cmd_dbconnection.go b/mdl/executor/cmd_dbconnection.go index 78b67b6e2..a9a497d97 100644 --- a/mdl/executor/cmd_dbconnection.go +++ b/mdl/executor/cmd_dbconnection.go @@ -303,7 +303,6 @@ func outputDatabaseConnectionMDL(ctx *ExecContext, conn *model.DatabaseConnectio } fmt.Fprintln(ctx.Output, ";") - fmt.Fprintln(ctx.Output, "/") return nil } diff --git a/mdl/executor/cmd_entities_describe.go b/mdl/executor/cmd_entities_describe.go index 60288a822..2a0f97788 100644 --- a/mdl/executor/cmd_entities_describe.go +++ b/mdl/executor/cmd_entities_describe.go @@ -422,7 +422,6 @@ func describeEntity(ctx *ExecContext, name ast.QualifiedName) error { // Output access rule GRANT statements outputEntityAccessGrants(ctx, entity, name.Module, name.Name) - fmt.Fprintln(ctx.Output, "/") return nil } } diff --git a/mdl/executor/cmd_enumerations.go b/mdl/executor/cmd_enumerations.go index 5e0aa8ec9..0a803a7f9 100644 --- a/mdl/executor/cmd_enumerations.go +++ b/mdl/executor/cmd_enumerations.go @@ -454,7 +454,6 @@ func describeEnumeration(ctx *ExecContext, name ast.QualifiedName) error { folderClause = fmt.Sprintf(" FOLDER '%s'", fp) } fmt.Fprintf(ctx.Output, ")%s;\n", folderClause) - fmt.Fprintln(ctx.Output, "/") return nil } } diff --git a/mdl/executor/cmd_imagecollections.go b/mdl/executor/cmd_imagecollections.go index 233b3d7cf..447368995 100644 --- a/mdl/executor/cmd_imagecollections.go +++ b/mdl/executor/cmd_imagecollections.go @@ -151,7 +151,6 @@ func describeImageCollection(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprintf(ctx.Output, " export level '%s'", exportLevel) } fmt.Fprintln(ctx.Output, ";") - fmt.Fprintln(ctx.Output, "/") return nil } @@ -184,7 +183,6 @@ func describeImageCollection(ctx *ExecContext, name ast.QualifiedName) error { } fmt.Fprintln(ctx.Output, ");") - fmt.Fprintln(ctx.Output, "/") return nil } diff --git a/mdl/executor/cmd_imagecollections_mock_test.go b/mdl/executor/cmd_imagecollections_mock_test.go index 116c6df99..8848ae2f8 100644 --- a/mdl/executor/cmd_imagecollections_mock_test.go +++ b/mdl/executor/cmd_imagecollections_mock_test.go @@ -103,6 +103,7 @@ func TestDescribeImageCollection_Mock(t *testing.T) { assertNoError(t, describeImageCollection(ctx, ast.QualifiedName{Module: "Icons", Name: "AppIcons"})) out := buf.String() + assertTerminated(t, out) // #744 assertContainsStr(t, out, "create or modify image collection") } diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index 4ef3c8b83..f0a32b6e0 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -368,8 +368,6 @@ func describeMicroflowMode(ctx *ExecContext, name ast.QualifiedName, opts descri name.Module, name.Name, strings.Join(roles, ", "))) } - lines = append(lines, "/") - // Output fmt.Fprintln(ctx.Output, strings.Join(lines, "\n")) return nil @@ -501,7 +499,6 @@ func describeNanoflow(ctx *ExecContext, name ast.QualifiedName) error { } lines = append(lines, "end;") - lines = append(lines, "/") fmt.Fprintln(ctx.Output, strings.Join(lines, "\n")) return nil @@ -717,8 +714,6 @@ func renderMicroflowMDL( flowType, name.Module, name.Name, strings.Join(roles, ", "))) } - lines = append(lines, "/") - return strings.Join(lines, "\n") } @@ -1682,7 +1677,6 @@ func describeRule(ctx *ExecContext, name ast.QualifiedName) error { } lines = append(lines, "end;") - lines = append(lines, "/") fmt.Fprintln(ctx.Output, strings.Join(lines, "\n")) return nil diff --git a/mdl/executor/cmd_modules.go b/mdl/executor/cmd_modules.go index 5a84ce892..40bfe867b 100644 --- a/mdl/executor/cmd_modules.go +++ b/mdl/executor/cmd_modules.go @@ -621,7 +621,6 @@ func describeModule(ctx *ExecContext, moduleName string, withAll bool) error { describeModuleRoles(ctx, targetModule) if !withAll { - fmt.Fprintln(ctx.Output, "/") return nil } @@ -832,7 +831,6 @@ func describeModule(ctx *ExecContext, moduleName string, withAll bool) error { } } - fmt.Fprintln(ctx.Output, "/") return nil } diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index 6040e4c6a..45e245df8 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -230,8 +230,6 @@ func outputConsumedODataServiceMDL(ctx *ExecContext, svc *model.ConsumedODataSer fmt.Fprintln(ctx.Output, ");") } - fmt.Fprintln(ctx.Output, "/") - return nil } @@ -362,21 +360,34 @@ func outputPublishedODataServiceMDL(ctx *ExecContext, svc *model.PublishedODataS } fmt.Fprintln(ctx.Output, strings.Join(props, ",\n")) - fmt.Fprintln(ctx.Output, ")") + // The statement ends with `;` after whichever clause comes last: the + // property list, the authentication clause or the entity block (ADR-0010 + // R11). + hasBlock := len(svc.EntityTypes) > 0 || len(svc.EntitySets) > 0 || len(svc.Microflows) > 0 + hasAuth := len(svc.AuthenticationTypes) > 0 || svc.AuthMicroflow != "" + if hasBlock || hasAuth { + fmt.Fprintln(ctx.Output, ")") + } else { + fmt.Fprintln(ctx.Output, ");") + } // Authentication types. The custom-authentication microflow is part of the // clause, not a comment beside it: emitted as a comment the output looked // complete but replayed into a service Mendix rejects with CE0333 // (mxcli-formula1 §40). + authEnd := "\n" + if !hasBlock { + authEnd = ";\n" + } if len(svc.AuthenticationTypes) > 0 { - fmt.Fprintf(ctx.Output, "authentication %s\n", odataAuthClause(svc)) + fmt.Fprintf(ctx.Output, "authentication %s%s", odataAuthClause(svc), authEnd) } else if svc.AuthMicroflow != "" { // A microflow with no type recorded still has to survive the round trip. - fmt.Fprintf(ctx.Output, "authentication microflow %s\n", svc.AuthMicroflow) + fmt.Fprintf(ctx.Output, "authentication microflow %s%s", svc.AuthMicroflow, authEnd) } // Published entities block - if len(svc.EntityTypes) > 0 || len(svc.EntitySets) > 0 || len(svc.Microflows) > 0 { + if hasBlock { fmt.Fprintln(ctx.Output, "{") // Build entity set lookup by exposed name and entity type name for merging @@ -501,7 +512,7 @@ func outputPublishedODataServiceMDL(ctx *ExecContext, svc *model.PublishedODataS printPublishedMicroflowMDL(ctx.Output, pm) } - fmt.Fprintln(ctx.Output, "}") + fmt.Fprintln(ctx.Output, "};") } // Output GRANT statements for allowed module roles @@ -511,8 +522,6 @@ func outputPublishedODataServiceMDL(ctx *ExecContext, svc *model.PublishedODataS moduleName, svc.Name, strings.Join(svc.AllowedModuleRoles, ", ")) } - fmt.Fprintln(ctx.Output, "/") - return nil } @@ -781,27 +790,26 @@ func outputExternalEntityMDL(ctx *ExecContext, entity *domainmodel.Entity, modul props = append(props, fmt.Sprintf(" AllowCreateChangeLocally: %s", boolStr(entity.CreateChangeLocally))) fmt.Fprintln(ctx.Output, strings.Join(props, ",\n")) + // Output attributes. Without them the statement ends at the property list, + // and still ends with `;` (ADR-0010 R11). + if len(entity.Attributes) == 0 { + fmt.Fprintln(ctx.Output, ");") + return nil + } fmt.Fprintln(ctx.Output, ")") - - // Output attributes - if len(entity.Attributes) > 0 { - fmt.Fprintln(ctx.Output, "(") - for i, attr := range entity.Attributes { - typeName := "Unknown" - if attr.Type != nil { - typeName = attr.Type.GetTypeName() - } - comma := "," - if i == len(entity.Attributes)-1 { - comma = "" - } - fmt.Fprintf(ctx.Output, " %s: %s%s\n", attr.Name, typeName, comma) + fmt.Fprintln(ctx.Output, "(") + for i, attr := range entity.Attributes { + typeName := "Unknown" + if attr.Type != nil { + typeName = attr.Type.GetTypeName() } - fmt.Fprintln(ctx.Output, ");") + comma := "," + if i == len(entity.Attributes)-1 { + comma = "" + } + fmt.Fprintf(ctx.Output, " %s: %s%s\n", attr.Name, typeName, comma) } - - fmt.Fprintln(ctx.Output, "/") - + fmt.Fprintln(ctx.Output, ");") return nil } diff --git a/mdl/executor/cmd_odata_client_describe_quoting_test.go b/mdl/executor/cmd_odata_client_describe_quoting_test.go index abc0c1e7e..42119659d 100644 --- a/mdl/executor/cmd_odata_client_describe_quoting_test.go +++ b/mdl/executor/cmd_odata_client_describe_quoting_test.go @@ -35,6 +35,7 @@ func describeAndReparse(t *testing.T, stored *model.ConsumedODataService, folder t.Fatalf("describe: %v", err) } prog := parseMDL(t, out.String()) + assertTerminated(t, out.String()) // #744 for _, s := range prog.Statements { if c, ok := s.(*ast.CreateODataClientStmt); ok { return c, out.String() diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 05b519b0d..1259f5bf7 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -190,7 +190,7 @@ func describePage(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprint(ctx.Output, " }\n") } } - fmt.Fprint(ctx.Output, "}") + fmt.Fprint(ctx.Output, "};") // Add GRANT VIEW if roles are assigned if len(foundPage.AllowedRoles) > 0 { @@ -295,10 +295,10 @@ func describeSnippet(ctx *ExecContext, name ast.QualifiedName) error { for _, w := range rawWidgets { outputWidgetMDLV3(ctx, w, 1) } - fmt.Fprint(ctx.Output, "}") + fmt.Fprint(ctx.Output, "};") } else { // A widget-less snippet still needs an (empty) body block to re-parse (#626). - fmt.Fprint(ctx.Output, " {\n}") + fmt.Fprint(ctx.Output, " {\n};") } fmt.Fprint(ctx.Output, "\n") @@ -373,7 +373,7 @@ func describeLayout(ctx *ExecContext, name ast.QualifiedName) error { outputWidgetMDLV3(ctx, w, 1) } - fmt.Fprint(ctx.Output, "}\n\n") + fmt.Fprint(ctx.Output, "};\n\n") return nil } diff --git a/mdl/executor/cmd_published_rest.go b/mdl/executor/cmd_published_rest.go index 8d9848d5f..59e561ce5 100644 --- a/mdl/executor/cmd_published_rest.go +++ b/mdl/executor/cmd_published_rest.go @@ -141,9 +141,10 @@ func describePublishedRestService(ctx *ExecContext, name ast.QualifiedName) erro } fmt.Fprintln(ctx.Output, "};") } else { - fmt.Fprintln(ctx.Output, ";") + // The resource block is not optional in the grammar: a service + // with no resources still needs an empty one to re-parse (#744). + fmt.Fprintln(ctx.Output, "{\n};") } - fmt.Fprintln(ctx.Output, "/") // Emit GRANT statements for any module roles with access. if len(svc.AllowedRoles) > 0 { diff --git a/mdl/executor/cmd_published_rest_mock_test.go b/mdl/executor/cmd_published_rest_mock_test.go index 99cc241bd..ce5801c06 100644 --- a/mdl/executor/cmd_published_rest_mock_test.go +++ b/mdl/executor/cmd_published_rest_mock_test.go @@ -65,6 +65,7 @@ func TestDescribePublishedRestService_Mock(t *testing.T) { assertNoError(t, describePublishedRestService(ctx, ast.QualifiedName{Module: "MyModule", Name: "OrderAPI"})) out := buf.String() + assertTerminated(t, out) // #744 assertContainsStr(t, out, "create or modify published rest service") assertContainsStr(t, out, "MyModule.OrderAPI") } diff --git a/mdl/executor/cmd_security.go b/mdl/executor/cmd_security.go index 371b99e39..2d92dbe07 100644 --- a/mdl/executor/cmd_security.go +++ b/mdl/executor/cmd_security.go @@ -787,7 +787,6 @@ func describeModuleRole(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprintf(ctx.Output, " description %s", mdlQuoted(mr.Description)) } fmt.Fprintln(ctx.Output, ";") - fmt.Fprintln(ctx.Output, "/") // Show which user roles include this module role qualifiedRole := modName + "." + mr.Name @@ -837,7 +836,6 @@ func describeDemoUser(ctx *ExecContext, userName string) error { fmt.Fprintf(ctx.Output, " (%s)", strings.Join(du.UserRoles, ", ")) } fmt.Fprintln(ctx.Output, ";") - fmt.Fprintln(ctx.Output, "/") return nil } } @@ -866,7 +864,6 @@ func describeUserRole(ctx *ExecContext, name ast.QualifiedName) error { } fmt.Fprintln(ctx.Output, ";") - fmt.Fprintln(ctx.Output, "/") // Show description if present if ur.Description != "" { diff --git a/mdl/executor/cmd_security_mock_test.go b/mdl/executor/cmd_security_mock_test.go index 7b50c3345..a30ef88c8 100644 --- a/mdl/executor/cmd_security_mock_test.go +++ b/mdl/executor/cmd_security_mock_test.go @@ -143,6 +143,7 @@ func TestDescribeModuleRole_Mock(t *testing.T) { ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) assertNoError(t, describeModuleRole(ctx, ast.QualifiedName{Module: "MyModule", Name: "Admin"})) assertContainsStr(t, buf.String(), "create or modify module role") + assertTerminated(t, buf.String()) // #744 } func TestDescribeUserRole_Mock(t *testing.T) { @@ -159,6 +160,7 @@ func TestDescribeUserRole_Mock(t *testing.T) { ctx, buf := newMockCtx(t, withBackend(mb)) assertNoError(t, describeUserRole(ctx, ast.QualifiedName{Name: "Administrator"})) assertContainsStr(t, buf.String(), "create user role") + assertTerminated(t, buf.String()) // #744 } func TestDescribeDemoUser_Mock(t *testing.T) { @@ -178,6 +180,7 @@ func TestDescribeDemoUser_Mock(t *testing.T) { // `create or modify` so the output replays onto a project that has the // user (ako/mxcli#707). assertContainsStr(t, buf.String(), "create or modify demo user") + assertTerminated(t, buf.String()) // #744 } func TestShowModuleRoles_Mock_FilterByModule(t *testing.T) { diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index ecdaa3a85..f4873c2f8 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -232,8 +232,7 @@ func describeWorkflowToString(ctx *ExecContext, name ast.QualifiedName) (string, } lines = append(lines, formatEventSubProcesses(targetWf.EventSubProcesses, " ")...) - lines = append(lines, "end workflow") - lines = append(lines, "/") + lines = append(lines, "end workflow;") return strings.Join(lines, "\n"), nil, nil } diff --git a/mdl/executor/cmd_workflows_mock_test.go b/mdl/executor/cmd_workflows_mock_test.go index e3de13446..4ba493d29 100644 --- a/mdl/executor/cmd_workflows_mock_test.go +++ b/mdl/executor/cmd_workflows_mock_test.go @@ -48,6 +48,7 @@ func TestDescribeWorkflow_Mock(t *testing.T) { assertNoError(t, describeWorkflow(ctx, ast.QualifiedName{Module: "Sales", Name: "ApproveOrder"})) out := buf.String() + assertTerminated(t, out) // #744 assertContainsStr(t, out, "create workflow") assertContainsStr(t, out, "Sales.ApproveOrder") diff --git a/mdl/executor/describe_terminators_test.go b/mdl/executor/describe_terminators_test.go new file mode 100644 index 000000000..8c7aaa5f7 --- /dev/null +++ b/mdl/executor/describe_terminators_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "fmt" + "testing" + + "github.com/antlr4-go/antlr/v4" + + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// ako/mxcli#744 — describe ends every statement with `;` and never prints a +// SQL*Plus `/` line (ADR-0010 R11/R12), so its output means the same under +// mdl 0 and mdl 1. testdata/pedapp covers the document types it contains +// (mdl/roundtrip TestPedAppDescribeIsValidMdl1); the tests here cover the rest. + +// assertTerminated fails the test when a statement of describe output does not +// end with `;`, is followed by `/`, or when the output does not parse under an +// `mdl 1;` header. +func assertTerminated(t *testing.T, out string) { + t.Helper() + for _, p := range terminatorProblems(out) { + t.Errorf("%s\n--- describe output ---\n%s", p, out) + } + if _, errs := visitor.Build("mdl 1;\n" + out); len(errs) > 0 { + t.Errorf("describe output is not valid under mdl 1: %v\n--- describe output ---\n%s", errs, out) + } +} + +// terminatorProblems reports each top-level statement that does not end with +// `;` or is followed by `/`. Microflow, nanoflow and workflow rules end in +// `SEMICOLON? SLASH?` of their own, so the terminators are read off the +// statement's last tokens rather than the statement rule's own. +func terminatorProblems(script string) []string { + lexer := parser.NewMDLLexer(antlr.NewInputStream(script)) + lexer.RemoveErrorListeners() + p := parser.NewMDLParser(antlr.NewCommonTokenStream(lexer, antlr.TokenDefaultChannel)) + p.RemoveErrorListeners() + var problems []string + for _, stmt := range p.Program().AllStatement() { + last := lastTokens(stmt, 2) + if len(last) == 0 { + continue + } + if last[0].GetTokenType() == parser.MDLParserSLASH { + problems = append(problems, fmt.Sprintf("line %d: a `/` line follows the statement", last[0].GetLine())) + last = last[1:] + } + if len(last) > 0 && last[0].GetTokenType() != parser.MDLParserSEMICOLON { + problems = append(problems, fmt.Sprintf("line %d: the statement ending at %q has no `;`", last[0].GetLine(), last[0].GetText())) + } + } + return problems +} + +// lastTokens returns up to n of the tree's last tokens, the last first. +func lastTokens(tree antlr.Tree, n int) []antlr.Token { + var out []antlr.Token + var walk func(antlr.Tree) + walk = func(t antlr.Tree) { + if len(out) == n { + return + } + if tn, ok := t.(antlr.TerminalNode); ok { + if tok := tn.GetSymbol(); tok != nil && tok.GetTokenType() != antlr.TokenEOF { + out = append(out, tok) + } + return + } + for i := t.GetChildCount() - 1; i >= 0 && len(out) < n; i-- { + walk(t.GetChild(i)) + } + } + walk(tree) + return out +} + +// The control: without it a check that never fires passes every test below. +func TestTerminatorProblems_Control(t *testing.T) { + for script, want := range map[string]int{ + "create constant M.C type String default 'x';\n": 0, + "create constant M.C type String default 'x';\n/\n": 1, + "create constant M.C type String default 'x'\n": 1, + "create microflow M.F ()\nbegin\n return;\nend;\n/\n": 1, + "create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) {\n}\n": 1, + "create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) {\n};\n": 0, + } { + if got := terminatorProblems(script); len(got) != want { + t.Errorf("%q: %d problems %v, want %d", script, len(got), got, want) + } + } +} + +// A published OData service ends at its property list, its authentication +// clause or its entity block, whichever comes last; each must carry the `;`. +func TestDescribePublishedODataService_Terminated(t *testing.T) { + for name, svc := range map[string]*model.PublishedODataService{ + "properties only": {Name: "S", Path: "odata/s/v1", Version: "1.0.0", ODataVersion: "OData4"}, + "authentication": {Name: "S", Path: "odata/s/v1", ODataVersion: "OData4", AuthenticationTypes: []string{"Basic"}}, + "block": {Name: "S", Path: "odata/s/v1", ODataVersion: "OData4", AuthenticationTypes: []string{"Basic"}, + Microflows: []*model.PublishedMicroflow{{Microflow: "Shop.Act", ExposedName: "Act"}}}, + "grant": {Name: "S", Path: "odata/s/v1", ODataVersion: "OData4", AllowedModuleRoles: []string{"Shop.User"}}, + } { + t.Run(name, func(t *testing.T) { + var out bytes.Buffer + assertNoError(t, outputPublishedODataServiceMDL(&ExecContext{Output: &out}, svc, "Shop", "")) + reparse(t, out.String()) + assertTerminated(t, out.String()) + }) + } +} + +func TestDescribeExternalEntity_Terminated(t *testing.T) { + attr := &domainmodel.Attribute{Name: "Code", Type: &domainmodel.StringAttributeType{Length: 20}} + for name, attrs := range map[string][]*domainmodel.Attribute{ + "no attributes": nil, + "attributes": {attr}, + } { + t.Run(name, func(t *testing.T) { + e := &domainmodel.Entity{Name: "Order", Source: "Rest$ODataRemoteEntitySource", RemoteServiceName: "Shop.Remote", RemoteEntitySet: "Orders", Attributes: attrs} + var out bytes.Buffer + assertNoError(t, outputExternalEntityMDL(&ExecContext{Output: &out}, e, "Shop")) + reparse(t, out.String()) + assertTerminated(t, out.String()) + }) + } +} diff --git a/mdl/executor/issue707_describe_roundtrip_test.go b/mdl/executor/issue707_describe_roundtrip_test.go index 595ec0f6c..ac74cae7d 100644 --- a/mdl/executor/issue707_describe_roundtrip_test.go +++ b/mdl/executor/issue707_describe_roundtrip_test.go @@ -35,6 +35,7 @@ func reparse(t *testing.T, out string) *ast.Program { if len(errs) > 0 { t.Fatalf("describe output does not re-parse: %v\n--- output ---\n%s", errs, out) } + assertTerminated(t, out) // #744 return prog } diff --git a/mdl/roundtrip/describe_terminators_test.go b/mdl/roundtrip/describe_terminators_test.go new file mode 100644 index 000000000..ceb279d3a --- /dev/null +++ b/mdl/roundtrip/describe_terminators_test.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package roundtrip + +import ( + "fmt" + "sort" + "strings" + "testing" + + "github.com/antlr4-go/antlr/v4" + + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// TestPedAppDescribeIsValidMdl1 holds describe to ADR-0010 R11/R12 (#744): +// every statement it prints ends with `;` and none is followed by a SQL*Plus +// `/` line, so the output of every document type means the same under mdl 0 +// and mdl 1. It checks the terminators on the parse tree itself, so it does not +// depend on the parser enforcing them, and then parses the output again under +// an `mdl 1;` header, which must report nothing. +func TestPedAppDescribeIsValidMdl1(t *testing.T) { + h := newHarness(t) + defer h.close() + + targets := describeTargets(h) + if len(targets) < 100 { + t.Fatalf("only %d describe targets — the enumeration is broken", len(targets)) + } + checked := 0 + for _, target := range targets { + t.Run(target, func(t *testing.T) { + out, err := h.describe(target) + if err != nil || strings.TrimSpace(out) == "" { + t.Skipf("describe %s: err=%v (judged by TestPedAppRoundTrip)", target, err) + } + if _, errs := visitor.Build(out); len(errs) > 0 { + if hasKnownLaw(target, lawParse) { + t.Skipf("does not parse under mdl 0 either (allowlisted): %v", errs[0]) + } + t.Fatalf("describe output does not parse: %v\n%s", errs[0], out) + } + checked++ + for _, problem := range terminatorProblems(out) { + t.Errorf("%s\n--- describe output ---\n%s", problem, out) + } + if _, errs := visitor.Build("mdl 1;\n" + out); len(errs) > 0 { + t.Errorf("describe output is not valid under mdl 1: %v\n--- describe output ---\n%s", errs, out) + } + }) + } + if checked == 0 { + t.Fatal("no describe output was checked — the test proves nothing") + } +} + +// TestDescribeTerminatorCheck is the control for terminatorProblems: without +// it, a check that never fires would make TestPedAppDescribeIsValidMdl1 pass +// on the output #744 was filed against. +func TestDescribeTerminatorCheck(t *testing.T) { + for script, want := range map[string]int{ + "create constant M.C type String default 'x';\n": 0, + "create constant M.C type String default 'x';\n/\n": 1, + "create constant M.C type String default 'x'\n/\n": 2, + "create constant M.C type String default 'x'\n": 1, + "create microflow M.F ()\nbegin\n return;\nend;\n": 0, + "create microflow M.F ()\nbegin\n return;\nend;\n/\n": 1, + "create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) {\n}\n": 1, + } { + if got := terminatorProblems(script); len(got) != want { + t.Errorf("%q: %d problems %v, want %d", script, len(got), got, want) + } + } +} + +// describeTargets is every document the round trip enumerates, plus each +// module (alone and with all its objects), navigation and settings. +func describeTargets(h *harness) []string { + var out []string + modules := map[string]bool{} + for _, d := range h.documents() { + out = append(out, d.target()) + if mod, _, ok := strings.Cut(d.name, "."); ok && !strings.HasPrefix(d.name, "'") { + modules[mod] = true + } + } + var mods []string + for m := range modules { + mods = append(mods, m) + } + sort.Strings(mods) + for _, m := range mods { + out = append(out, "module "+m, "module "+m+" with all") + } + return append(out, "navigation", "settings") +} + +func hasKnownLaw(key string, l law) bool { + for _, k := range knownFailures[key].laws { + if k == l { + return true + } + } + return false +} + +// terminatorProblems reports each top-level statement of script that does not +// end with `;` or is followed by `/`. It reads the terminators off the +// statement's last tokens: microflow, nanoflow and workflow bodies end in +// `SEMICOLON? SLASH?` of their own, so the statement rule's may be empty. +func terminatorProblems(script string) []string { + lexer := parser.NewMDLLexer(antlr.NewInputStream(script)) + lexer.RemoveErrorListeners() + p := parser.NewMDLParser(antlr.NewCommonTokenStream(lexer, antlr.TokenDefaultChannel)) + p.RemoveErrorListeners() + var problems []string + for _, stmt := range p.Program().AllStatement() { + last := lastTokens(stmt, 2) + if len(last) == 0 { + continue + } + if last[0].GetTokenType() == parser.MDLParserSLASH { + problems = append(problems, fmt.Sprintf("line %d: a `/` line follows the statement", last[0].GetLine())) + last = last[1:] + } + if len(last) > 0 && last[0].GetTokenType() != parser.MDLParserSEMICOLON { + problems = append(problems, fmt.Sprintf("line %d: the statement ending at %q has no `;`", last[0].GetLine(), last[0].GetText())) + } + } + return problems +} + +// lastTokens returns up to n of the tree's last tokens, the last first. +func lastTokens(tree antlr.Tree, n int) []antlr.Token { + var out []antlr.Token + var walk func(antlr.Tree) + walk = func(t antlr.Tree) { + if len(out) == n { + return + } + if tn, ok := t.(antlr.TerminalNode); ok { + if tok := tn.GetSymbol(); tok != nil && tok.GetTokenType() != antlr.TokenEOF { + out = append(out, tok) + } + return + } + for i := t.GetChildCount() - 1; i >= 0 && len(out) < n; i-- { + walk(t.GetChild(i)) + } + } + walk(tree) + return out +} From 458361e434d291a8cb5645ca2221ca2bc6945365 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 11:39:53 +0000 Subject: [PATCH 35/37] diff: render statements without the `/` line, like describe (#744) `diff` and `diff-local` render MDL for the compared side; with describe no longer printing `/`, keeping it here would show a spurious line on every statement and render text that is invalid under mdl 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/executor/cmd_diff_local.go | 7 ------- mdl/executor/cmd_diff_mdl.go | 8 -------- 2 files changed, 15 deletions(-) diff --git a/mdl/executor/cmd_diff_local.go b/mdl/executor/cmd_diff_local.go index f60bb8025..4be788f3e 100644 --- a/mdl/executor/cmd_diff_local.go +++ b/mdl/executor/cmd_diff_local.go @@ -393,7 +393,6 @@ func entityBsonToMDL(ctx *ExecContext, raw map[string]any, qualifiedName string) } lines = append(lines, ");") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -573,7 +572,6 @@ func nanoflowBsonToMDL(_ *ExecContext, raw map[string]any, qualifiedName string) lines = append(lines, "begin") lines = append(lines, " -- (nanoflow body)") lines = append(lines, "end;") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -607,7 +605,6 @@ func enumerationBsonToMDL(_ *ExecContext, raw map[string]any, qualifiedName stri } lines = append(lines, ");") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -638,7 +635,6 @@ func pageBsonToMDL(_ *ExecContext, raw map[string]any, qualifiedName string) str lines = append(lines, "begin") lines = append(lines, " -- (page widgets)") lines = append(lines, "end;") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -657,7 +653,6 @@ func snippetBsonToMDL(_ *ExecContext, raw map[string]any, qualifiedName string) lines = append(lines, "begin") lines = append(lines, " -- (snippet widgets)") lines = append(lines, "end;") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -676,7 +671,6 @@ func layoutBsonToMDL(_ *ExecContext, raw map[string]any, qualifiedName string) s lines = append(lines, "begin") lines = append(lines, " -- (layout structure)") lines = append(lines, "end;") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -693,7 +687,6 @@ func moduleBsonToMDL(_ *ExecContext, raw map[string]any) string { } lines = append(lines, fmt.Sprintf("create module %s;", name)) - lines = append(lines, "/") return strings.Join(lines, "\n") } diff --git a/mdl/executor/cmd_diff_mdl.go b/mdl/executor/cmd_diff_mdl.go index 175143e98..77b1f2d54 100644 --- a/mdl/executor/cmd_diff_mdl.go +++ b/mdl/executor/cmd_diff_mdl.go @@ -92,7 +92,6 @@ func entityStmtToMDL(ctx *ExecContext, s *ast.CreateEntityStmt) string { } lines = append(lines, ";") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -128,7 +127,6 @@ func viewEntityStmtToMDL(ctx *ExecContext, s *ast.CreateViewEntityStmt) string { lines = append(lines, " "+line) } lines = append(lines, ");") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -154,7 +152,6 @@ func enumerationStmtToMDL(ctx *ExecContext, s *ast.CreateEnumerationStmt) string } lines = append(lines, ");") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -192,7 +189,6 @@ func associationStmtToMDL(ctx *ExecContext, s *ast.CreateAssociationStmt) string deleteBehavior = "DELETE_IF_NO_REFERENCES" } lines = append(lines, fmt.Sprintf("delete_behavior %s;", deleteBehavior)) - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -307,7 +303,6 @@ func entityToMDL(ctx *ExecContext, moduleName string, entity *domainmodel.Entity } lines = append(lines, ";") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -341,7 +336,6 @@ func viewEntityFromProjectToMDL(ctx *ExecContext, moduleName string, entity *dom } } lines = append(lines, ");") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -373,7 +367,6 @@ func enumerationToMDL(ctx *ExecContext, moduleName string, enum *model.Enumerati } lines = append(lines, ");") - lines = append(lines, "/") return strings.Join(lines, "\n") } @@ -422,7 +415,6 @@ func associationToMDL(ctx *ExecContext, moduleName string, assoc *domainmodel.As } } lines = append(lines, fmt.Sprintf("delete_behavior %s;", deleteBehavior)) - lines = append(lines, "/") return strings.Join(lines, "\n") } From bdfcdfb730221d54cee7522906e36759e07634d8 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 11:45:53 +0000 Subject: [PATCH 36/37] test: statements in the or-replace/or-modify parity test end with ';' (valid under mdl 0 and mdl 1, required by #741) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- mdl/visitor/visitor_deprecations_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mdl/visitor/visitor_deprecations_test.go b/mdl/visitor/visitor_deprecations_test.go index ebb5c5dd3..a75e35ce0 100644 --- a/mdl/visitor/visitor_deprecations_test.go +++ b/mdl/visitor/visitor_deprecations_test.go @@ -193,8 +193,8 @@ func TestCreateOrReplaceMatchesModifyExceptExemptKinds(t *testing.T) { }{{"mdl 0", "", false}, {"mdl 1", "mdl 1;\n", true}} { for name, body := range cases { t.Run(version.name+"/"+name, func(t *testing.T) { - rep := mustBuild(t, version.header+"create or replace "+body) - mod := mustBuild(t, version.header+"create or modify "+body) + rep := mustBuild(t, version.header+"create or replace "+strings.TrimSuffix(body, ";")+";") // every statement ends with ; (valid under mdl 0 and mdl 1) + mod := mustBuild(t, version.header+"create or modify "+strings.TrimSuffix(body, ";")+";") if len(rep.Statements) != 1 || len(mod.Statements) != 1 { t.Fatalf("want one statement each, got %d and %d — the case does not exercise the visitor", len(rep.Statements), len(mod.Statements)) From e20739241ff82ad07588829d22f756ab2d9a4e39 Mon Sep 17 00:00:00 2001 From: Ako <andrej@koelewijn.net> Date: Sun, 27 Sep 2026 12:33:58 +0000 Subject: [PATCH 37/37] mdl/upgrade: rewrite the mdl 1 header-gated constructs and list call forms On top of #740/#745/#741/#738, `fmt --upgrade --header` refused almost every example script: those PRs gated new constructs on the header without a rewrite. Each construct with a mechanical, meaning-preserving rewrite now has one, computed from the parse tree by the visitor that records it (ast.Fix, rune-offset edits, mdl/visitor/visitor_upgrade_fixes.go): - MDL-V1-SEMI: add the missing `;`. MDL-V1-SLASH: delete the `/` line. - MDL-V1-ESCAPE: write the literal's mdl 0 value with '' as the only escape; no edit inside an expression stored as written (mdl 0 already passed the backslash through); an escaped line break in a re-rendered expression is reported, since writing it into the literal changes what the builder stores (measured: a log message becomes a `{1}` template parameter). - MDL-V1-LIMIT1: `limit 1` -> `first`. MDL-V1-SET: add `set`. - MDL-DEPR003/004 and MDL-V1-LIST: call form -> statement form; find and contains on a declared String keep the call and gain `set`; a nested call, or an operand whose type the script does not state, is reported. - MDL-V1-REPLACE02: `create or replace user role|demo user` -> `create`. MDL-V1-PROP, MDL-V1-PROPVALUE and MDL-V1-REPLACE01 have no mechanical rewrite; they are listed in `unrewritable` (may only shrink) and block the header with HeaderBlockedError, which names each construct and why. TestGatedRegistryIsComplete holds every visitor.LanguageChanges() code to exactly one of the two lists. The examples test lists the two corpus scripts that keep mdl 0 (keepsItsVersion) and the two whose AST differs but whose model is the same (buildsTheSameModelNotTheSameAST), both may only shrink. The execute-both property test upgrades a blocked script without the header, and by default skips scripts whose only edits are the header and terminators, which never reach the AST: 249 scripts execute (6 min); MXCLI_UPGRADE_ALL=1 runs all 701, 587 of them executing to the same model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/mxcli/cmd_fmt.go | 3 + cmd/mxcli/cmd_fmt_upgrade_test.go | 17 + cmd/mxcli/syntax/features_misc.go | 10 +- docs-site/src/language/basics.md | 14 +- mdl/ast/ast.go | 27 ++ mdl/deprecation/deprecation.go | 6 +- mdl/roundtrip/upgrade_property_test.go | 51 ++- mdl/upgrade/examples_test.go | 101 +++++- mdl/upgrade/gated.go | 59 +++- mdl/upgrade/gated_test.go | 205 +++++++++++ mdl/upgrade/upgrade.go | 190 +++++++---- mdl/upgrade/upgrade_test.go | 3 +- mdl/visitor/language_changes_test.go | 64 ++++ mdl/visitor/visitor.go | 3 + mdl/visitor/visitor_deprecations.go | 23 ++ mdl/visitor/visitor_list_activities.go | 84 ++++- mdl/visitor/visitor_retrieve_range.go | 10 +- mdl/visitor/visitor_strict_terminators.go | 21 +- mdl/visitor/visitor_string_escapes.go | 61 ++++ mdl/visitor/visitor_upgrade_fixes.go | 399 ++++++++++++++++++++++ 20 files changed, 1239 insertions(+), 112 deletions(-) create mode 100644 mdl/upgrade/gated_test.go create mode 100644 mdl/visitor/language_changes_test.go create mode 100644 mdl/visitor/visitor_upgrade_fixes.go diff --git a/cmd/mxcli/cmd_fmt.go b/cmd/mxcli/cmd_fmt.go index 28b14ed20..1bf949c83 100644 --- a/cmd/mxcli/cmd_fmt.go +++ b/cmd/mxcli/cmd_fmt.go @@ -174,6 +174,9 @@ func reportUpgrade(w io.Writer, label string, res upgrade.Result) { if e, ok := deprecation.Lookup(d.Code); ok { msg = fmt.Sprintf("%s (%s -> %s)", d.Code, e.Old, e.Canonical) } + if d.NoFix != "" { + msg += ": " + d.NoFix + } fmt.Fprintf(w, "%s:%d:%d: not upgraded, no mechanical rewrite: %s\n", label, d.Line, d.Column+1, msg) } } diff --git a/cmd/mxcli/cmd_fmt_upgrade_test.go b/cmd/mxcli/cmd_fmt_upgrade_test.go index a56e15760..4ea239e94 100644 --- a/cmd/mxcli/cmd_fmt_upgrade_test.go +++ b/cmd/mxcli/cmd_fmt_upgrade_test.go @@ -71,3 +71,20 @@ func TestFmtUpgrade_HeaderNeedsUpgrade(t *testing.T) { t.Fatalf("--header without --upgrade: %v", err) } } + +// A construct with no mechanical rewrite is reported with its reason, the +// header is refused, and the file is left as it was. +func TestFmtUpgrade_ReportsWhatItCannotRewrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.mdl") + src := "create microflow M.F ($L: List of M.E) begin\n $n = count(filter($L, Name = 'x'));\nend\n/\n" + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + _, err := runFmt(t, "--upgrade", "--header", "-w", path) + if err == nil || !strings.Contains(err.Error(), "MDL-V1-LIST") || !strings.Contains(err.Error(), "nested call") { + t.Fatalf("want the nested list operation reported with its reason, got %v", err) + } + if got, _ := os.ReadFile(path); string(got) != src { + t.Fatalf("the file was changed although the header was refused:\n%s", got) + } +} diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 73568867e..2334c55fa 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -37,7 +37,15 @@ func init() { "-- The header is independent of the Mendix version the project targets.\n" + "--\n" + "-- `mxcli fmt --upgrade --header` adds it, after rewriting every construct\n" + - "-- whose meaning it would change; it refuses when one has no rewrite.\n" + + "-- whose meaning it would change: it adds each missing `;`, deletes `/`\n" + + "-- lines, writes backslash escapes as the characters they stood for,\n" + + "-- turns `retrieve … limit 1` into `first`, adds `set` to reassignments,\n" + + "-- and writes list operations one statement per activity. It refuses, and\n" + + "-- says why, when a construct has no rewrite: an unknown or mis-shaped\n" + + "-- property (MDL-V1-PROP/PROPVALUE), `create or replace view entity`\n" + + "-- (MDL-V1-REPLACE01), a nested list operation, find/contains on a variable\n" + + "-- whose type the script does not state, and an escaped line break inside\n" + + "-- an expression.\n" + "-- `mxcli fmt --upgrade` alone rewrites deprecated spellings (MDL-DEPRnnn).", Example: "mdl 1;\n\ncreate persistent entity MyModule.Customer (\n Name: String(200)\n);", SeeAlso: []string{"create-modifiers"}, diff --git a/docs-site/src/language/basics.md b/docs-site/src/language/basics.md index 4f9aa7ceb..b33d43e2e 100644 --- a/docs-site/src/language/basics.md +++ b/docs-site/src/language/basics.md @@ -72,7 +72,19 @@ mxcli fmt --upgrade -w script.mdl # upgrade in place mxcli fmt --upgrade --header -w script.mdl # also add `mdl 1;` ``` -`--header` adds `mdl 1;` after rewriting every construct whose meaning the header would change, so the script keeps doing what it did. If such a construct has no rewrite yet, `fmt` refuses to add the header rather than change the script's meaning. While `mdl 1` is a preview, the header is added only when asked. Running `fmt --upgrade` on its own output changes nothing. +`--header` adds `mdl 1;` after rewriting every construct whose meaning the header would change, so the script keeps doing what it did: + +| Code | Rewrite | +|---|---| +| `MDL-V1-SEMI` | adds the missing `;` | +| `MDL-V1-SLASH` | deletes the `/` line | +| `MDL-V1-ESCAPE` | writes the string's value with `''` as the only escape (`'it\'s\t'` becomes `'it''s` + a tab + `'`) | +| `MDL-V1-LIMIT1` | `retrieve … limit 1` (one object) becomes `retrieve … first` | +| `MDL-V1-SET` | `$x = …` becomes `set $x = …` | +| `MDL-V1-LIST`, `MDL-DEPR003`, `MDL-DEPR004` | a list operation or aggregate call becomes its statement form (`$x = filter($L, …)` → `$x = filter $L where …`); `find`/`contains` on a declared String keeps the call and gains `set` | +| `MDL-V1-REPLACE02` | `create or replace user role` / `demo user` becomes a plain `create` | + +A construct with no mechanical rewrite is reported with the reason, and `fmt` refuses to add the header rather than change the script's meaning: an unknown or mis-shaped property (`MDL-V1-PROP`, `MDL-V1-PROPVALUE`), `create or replace view entity` (`MDL-V1-REPLACE01`), a nested list operation such as `count(filter(…))`, `find`/`contains` on a variable whose type the script does not state, and an escaped line break (`\n`) inside an expression. While `mdl 1` is a preview, the header is added only when asked. Running `fmt --upgrade` on its own output changes nothing. The design is in [ADR-0011](https://github.com/mendixlabs/mxcli/blob/main/docs/13-decisions/0011-mdl-language-versioning.md); `mxcli syntax language-header` has the details. diff --git a/mdl/ast/ast.go b/mdl/ast/ast.go index 223a8ef2d..6def502fe 100644 --- a/mdl/ast/ast.go +++ b/mdl/ast/ast.go @@ -87,6 +87,11 @@ type DeprecatedSpelling struct { // Subject says what the spelling was used on, in MDL's own words ("entity", // "microflow", …); empty when there is nothing more specific to say. Subject string + // Fix is the structural rewrite of this use to the canonical form, for an + // entry whose rewrite is not a keyword swap (deprecation.Rewrite.Structural). + // Nil when this use has none; NoFix then says why. + Fix *Fix + NoFix string } // LanguageNote is one construct whose meaning depends on the language version, @@ -95,6 +100,28 @@ type LanguageNote struct { Line int // 1-based source line of the construct Code string // the langver.Change's rule ID Message string + // Fix rewrites the construct so that under the new version it still means + // what it means here — what `fmt --upgrade --header` applies before adding + // the header. Nil when there is no mechanical rewrite; NoFix then says why. + // A Fix with no edits is a construct already spelled so that it keeps its + // meaning under the new version. + Fix *Fix + NoFix string +} + +// Fix is a mechanical source rewrite: edits in the coordinates the parser read +// the script in, computed from the parse tree by the visitor that recorded the +// construct. Edits may not overlap. +type Fix struct { + Edits []TextEdit +} + +// TextEdit replaces the runes [Start, Stop) of the script with Text. Offsets +// count runes (code points) from the start of the script, as ANTLR's character +// stream does; Start == Stop is an insertion. +type TextEdit struct { + Start, Stop int + Text string } // DocumentAnnotation is one annotation written before a CREATE statement. diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 585a6bf16..853c6a9fc 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -79,8 +79,10 @@ type Rewrite struct { } // IsZero reports whether r is empty: the entry has no mechanical rewrite, and -// `fmt --upgrade` reports its uses instead of guessing at one. -func (r Rewrite) IsZero() bool { return r.Token == "" && r.Replacement == "" } +// `fmt --upgrade` reports its uses instead of guessing at one. A structural +// rewrite is computed per use by the visitor that records it (ast.Fix), and a +// use it cannot rewrite is reported the same way. +func (r Rewrite) IsZero() bool { return r.Token == "" && r.Replacement == "" && r.Structural == "" } // Codes of the registered entries, for the visitor to record. const ( diff --git a/mdl/roundtrip/upgrade_property_test.go b/mdl/roundtrip/upgrade_property_test.go index d0ce3e630..0ef29a964 100644 --- a/mdl/roundtrip/upgrade_property_test.go +++ b/mdl/roundtrip/upgrade_property_test.go @@ -7,6 +7,7 @@ package roundtrip import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "os" "path/filepath" @@ -17,7 +18,6 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" - "github.com/mendixlabs/mxcli/mdl/langver" "github.com/mendixlabs/mxcli/mdl/upgrade" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/mendixlabs/mxcli/modelsdk/canon" @@ -33,7 +33,8 @@ import ( // For every mdl-examples script: // // 1. upgrade it, with the language header, which is the strongest form: every -// alias rewritten and the script run under mdl 1; +// alias rewritten and the script run under mdl 1 (a script the header is +// refused for, HeaderBlockedError, is upgraded without it); // 2. require zero deprecated spellings in the result; // 3. execute the original on one copy of the PedApp fixture and the upgrade on // another; @@ -48,9 +49,10 @@ import ( // // MXCLI_UPGRADE_EXAMPLES=<regexp> limits the run to matching script paths. // -// By default only scripts the upgrade rewrites beyond adding the header are -// executed: with the header as the only edit, the two runs execute the same -// statements, and what they compare is langver's gating rather than a rewrite. +// By default only scripts the upgrade rewrites beyond adding the header and +// statement terminators are executed: with those as the only edits, the two +// runs execute the same statements (mdl/upgrade's examples test proves it), +// and what they compare is langver's gating rather than a rewrite. // Executing the whole corpus takes about 15 minutes, which on its own exceeds // what the CI integration step has left (ako/mxcli#742 timed out there). // MXCLI_UPGRADE_ALL=1 executes every script, header-only ones included; run it @@ -62,7 +64,7 @@ func TestUpgradeExecutesToTheSameModel(t *testing.T) { scripts := upgradeExampleScripts(t) all := os.Getenv("MXCLI_UPGRADE_ALL") != "" - var same, outOfScope, unparsed, headerOnly []string + var same, outOfScope, unparsed, headerOnly, keptVersion []string for _, path := range scripts { src, err := os.ReadFile(path) if err != nil { @@ -75,6 +77,14 @@ func TestUpgradeExecutesToTheSameModel(t *testing.T) { } t.Run(rel, func(t *testing.T) { res, err := upgrade.Upgrade(string(src), upgrade.Options{AddHeader: true}) + var hb *upgrade.HeaderBlockedError + if errors.As(err, &hb) { + // A construct with no mechanical rewrite keeps the script at + // mdl 0 (mdl/upgrade's keepsItsVersion lists which); its alias + // rewrites are still proven here. + keptVersion = append(keptVersion, rel) + res, err = upgrade.Upgrade(string(src), upgrade.Options{}) + } if err != nil { t.Fatalf("upgrade: %v", err) } @@ -86,7 +96,7 @@ func TestUpgradeExecutesToTheSameModel(t *testing.T) { t.Fatalf("upgraded script still has %d deprecated spelling(s), first %s at line %d", n, prog.Deprecations[0].Code, prog.Deprecations[0].Line) } - if !all && onlyHeaderAdded(string(src), res) { + if !all && onlyTerminatorsAndHeader(res) { headerOnly = append(headerOnly, rel) return } @@ -113,8 +123,11 @@ func TestUpgradeExecutesToTheSameModel(t *testing.T) { t.Logf("%d scripts are out of scope: their original does not execute cleanly on PedApp:\n %s", len(outOfScope), strings.Join(outOfScope, "\n ")) if !all { - t.Logf("%d scripts only gain the header and were not executed (MXCLI_UPGRADE_ALL=1 executes them)", len(headerOnly)) + t.Logf("%d scripts only gain the header and statement terminators and were not executed "+ + "(MXCLI_UPGRADE_ALL=1 executes them)", len(headerOnly)) } + t.Logf("%d scripts keep mdl 0 (a construct with no rewrite) and were upgraded without the header:\n %s", + len(keptVersion), strings.Join(keptVersion, "\n ")) t.Logf("%d scripts do not parse (negative tests) and cannot be upgraded:\n %s", len(unparsed), strings.Join(unparsed, "\n ")) if os.Getenv("MXCLI_UPGRADE_EXAMPLES") == "" && len(same) < 50 { @@ -124,10 +137,24 @@ func TestUpgradeExecutesToTheSameModel(t *testing.T) { } } -// onlyHeaderAdded reports whether the upgrade's single edit was the header. -func onlyHeaderAdded(src string, res upgrade.Result) bool { - return len(res.Rewritten) == 0 && len(res.GatedRewritten) == 0 && - strings.TrimPrefix(res.Source, langver.Latest.String()+";\n") == src +// terminatorCodes are the gated rewrites that only add a `;` or delete a `/` +// line. They never reach the AST — mdl/upgrade's examples test proves every +// script builds the same statements with and without them — so executing a +// script whose only edits they are compares langver's gating, not a rewrite. +var terminatorCodes = map[string]bool{"MDL-V1-SEMI": true, "MDL-V1-SLASH": true} + +// onlyTerminatorsAndHeader reports whether the upgrade's edits were the +// header and statement terminators alone. +func onlyTerminatorsAndHeader(res upgrade.Result) bool { + if len(res.Rewritten) > 0 { + return false + } + for code := range res.GatedRewritten { + if !terminatorCodes[code] { + return false + } + } + return true } // TestUpgradeExecuteBoth_Controls are the controls CLAUDE.md requires of a diff --git a/mdl/upgrade/examples_test.go b/mdl/upgrade/examples_test.go index 082458371..6187a4afa 100644 --- a/mdl/upgrade/examples_test.go +++ b/mdl/upgrade/examples_test.go @@ -3,6 +3,7 @@ package upgrade import ( + "errors" "os" "path/filepath" "reflect" @@ -97,13 +98,77 @@ func foldModeFlags(stmts []ast.Statement) { } } +// keepsItsVersion lists the example scripts `fmt --upgrade --header` refuses, +// with the codes of the constructs that block it: each has no mechanical +// rewrite (HeaderBlockedError says why), so the script keeps mdl 0. The list +// may only shrink — a script that starts upgrading must be removed, and one +// that stops upgrading is a regression. +var keepsItsVersion = map[string][]string{ + // Nested list operations (`count(filter(…))`) and a non-variable operand: + // one activity takes a variable, so the inner call must become a statement + // of its own, which needs a variable name nobody chose. + "bug-tests/1101-nested-list-operand-dropped.fail.mdl": {"MDL-V1-LIST"}, + // `log … 'line 1\nline 2'`: under mdl 1 the line break is written into the + // literal, which makes the message a stored expression (a `{1}` template + // parameter) instead of the template text. Measured by the execute-both + // test before this entry was made. + "bug-tests/264-log-node-expression-roundtrip.mdl": {"MDL-V1-ESCAPE"}, +} + +// buildsTheSameModelNotTheSameAST lists the example scripts whose upgrade +// builds a different AST that the executor writes identically, so the AST +// comparison is skipped for them; the execute-both test in mdl/roundtrip is +// the proof. May only shrink. +var buildsTheSameModelNotTheSameAST = map[string]string{ + // `set $x = contains($Hay, $Needle)` / `find(…)` on a String: mdl 0 builds + // a List operation statement, which the flow builder turns into a Change + // variable with the string function because $Hay is declared String; under + // mdl 1 it is that Change variable directly. + "bug-tests/ledger-53-string-contains.mdl": "string contains: a List operation turned into the string function", + "bug-tests/ledger-63-string-find.mdl": "string find: a List operation turned into the string function", +} + +// foldSourceText erases the one part of an ast.SourceExpr the executor never +// writes: the parsed expression kept beside the source text for checks +// (expressionToString writes Source whenever it is set). A string escape in a +// multi-line expression changes that parsed value and nothing it writes. +func foldSourceText(v reflect.Value) { + switch v.Kind() { + case reflect.Pointer, reflect.Interface: + if v.IsNil() { + return + } + if se, ok := v.Interface().(*ast.SourceExpr); ok && se.Source != "" { + se.Expression = nil + return + } + foldSourceText(v.Elem()) + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + if v.Type().Field(i).IsExported() { + foldSourceText(v.Field(i)) + } + } + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + foldSourceText(v.Index(i)) + } + case reflect.Map: + for _, k := range v.MapKeys() { + foldSourceText(v.MapIndex(k)) + } + } +} + func TestUpgrade_ExamplesKeepTheirStatements(t *testing.T) { var parsed, upgraded, skipped int + blocked := map[string]bool{} for _, path := range exampleScripts(t) { b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } + rel := filepath.ToSlash(strings.TrimPrefix(path, examplesDir+"/")) src := string(b) if _, errs := visitor.Build(src); len(errs) > 0 { skipped++ // a negative test, or a script that no longer parses: not upgradable @@ -112,6 +177,21 @@ func TestUpgrade_ExamplesKeepTheirStatements(t *testing.T) { parsed++ for _, opts := range []Options{{}, {AddHeader: true}} { out, res, err := fmtUpgrade(src, opts) + var hb *HeaderBlockedError + if opts.AddHeader && errors.As(err, &hb) { + blocked[rel] = true + allowed := map[string]bool{} + for _, c := range keepsItsVersion[rel] { + allowed[c] = true + } + for _, c := range hb.Constructs { + if !allowed[c.Code] { + t.Errorf("%s: the header is refused over %s at line %d (%s), which is not listed in keepsItsVersion", + path, c.Code, c.Line, c.Reason) + } + } + continue + } if err != nil { t.Errorf("%s (%+v): %v", path, opts, err) continue @@ -131,17 +211,26 @@ func TestUpgrade_ExamplesKeepTheirStatements(t *testing.T) { want, _ := visitor.Build(src) foldModeFlags(want.Statements) foldModeFlags(got.Statements) + foldSourceText(reflect.ValueOf(want.Statements)) + foldSourceText(reflect.ValueOf(got.Statements)) if len(want.Statements) != len(got.Statements) { t.Errorf("%s (%+v): %d statements became %d", path, opts, len(want.Statements), len(got.Statements)) continue } + differs := false for i := range want.Statements { if !reflect.DeepEqual(want.Statements[i], got.Statements[i]) { - t.Errorf("%s (%+v): statement %d builds differently after the upgrade:\n before: %#v\n after: %#v", - path, opts, i+1, want.Statements[i], got.Statements[i]) + differs = true + if _, known := buildsTheSameModelNotTheSameAST[rel]; !known { + t.Errorf("%s (%+v): statement %d builds differently after the upgrade:\n before: %#v\n after: %#v", + path, opts, i+1, want.Statements[i], got.Statements[i]) + } break } } + if _, known := buildsTheSameModelNotTheSameAST[rel]; known && opts.AddHeader && !differs { + t.Errorf("%s now builds the same AST after the upgrade: remove it from buildsTheSameModelNotTheSameAST", rel) + } // Idempotent: fmt --upgrade over its own output changes nothing. again, res2, err := fmtUpgrade(out, opts) if err != nil || again != out || res2.Changed() { @@ -149,7 +238,13 @@ func TestUpgrade_ExamplesKeepTheirStatements(t *testing.T) { } } } - t.Logf("%d scripts parse, %d of them upgraded, %d do not parse and are out of scope", parsed, upgraded, skipped) + for rel := range keepsItsVersion { + if !blocked[rel] { + t.Errorf("%s now takes the header: remove it from keepsItsVersion", rel) + } + } + t.Logf("%d scripts parse, %d of them upgraded, %d do not parse and are out of scope, %d keep mdl 0", + parsed, upgraded, skipped, len(blocked)) if upgraded == 0 { t.Error("no example needed an upgrade — the corpus no longer exercises the rewrites") } diff --git a/mdl/upgrade/gated.go b/mdl/upgrade/gated.go index 31218ee40..9d58b9ff0 100644 --- a/mdl/upgrade/gated.go +++ b/mdl/upgrade/gated.go @@ -2,28 +2,61 @@ package upgrade -import "github.com/mendixlabs/mxcli/mdl/ast" +import ( + "errors" + + "github.com/mendixlabs/mxcli/mdl/ast" +) // GatedRewriter rewrites one header-gated construct so that, once the script // carries the new header, it still means what it meant without one. // // note is the ast.LanguageNote the visitor recorded through Builder.gate when // it parsed the headerless script (its Code is the langver.Change's rule ID); -// prog is that parse. The rewriter returns the source edits, in the parser's -// coordinates. When a construct needs more than the note's line to locate it, -// extend ast.LanguageNote with the position rather than re-finding it in text. +// prog is that parse. The rewriter returns the source edits. An error says why +// this occurrence has no mechanical rewrite; the upgrade then reports it and +// refuses the header (HeaderBlockedError). type GatedRewriter func(src *Source, prog *ast.Program, note ast.LanguageNote) ([]Edit, error) // gatedRewriters holds one rewrite per langver.Change code: a change of meaning // or new rejection that applies only under `mdl 1` (ADR-0011 decision 2). // // A change lands with its rewrite here (proposal §9, "Each change lands with a -// registry entry, an fmt --upgrade rewrite where one is possible"). A change -// without one is reported by `fmt --upgrade --header`, which then refuses to -// add the header to a script that uses it: adding the header over it would be -// exactly the silent change of meaning the header exists to prevent. -// -// Empty while no change of meaning is on main; the first ones are the mdl 1 -// constructs of ako/mxcli#732 (strict parsing), #733 (list operations as -// statements, mandatory `set`) and #734 (`limit 1` is a list). -var gatedRewriters = map[string]GatedRewriter{} +// registry entry, an fmt --upgrade rewrite where one is possible"), or in +// unrewritable with the reason it has none. TestGatedRegistryIsComplete holds +// every change the visitor gates to exactly one of the two. +var gatedRewriters = map[string]GatedRewriter{ + "MDL-V1-SEMI": visitorFix, // insert the missing `;` + "MDL-V1-SLASH": visitorFix, // delete the `/` line + "MDL-V1-ESCAPE": visitorFix, // write the string's mdl 0 value with `''` as the only escape + "MDL-V1-LIMIT1": visitorFix, // `limit 1` (an object) -> `first` + "MDL-V1-SET": visitorFix, // `$x = e` -> `set $x = e` + "MDL-V1-LIST": visitorFix, // call form -> statement form, or `set` for the string function + "MDL-V1-REPLACE02": visitorFix, // `create or replace user role` (a plain create) -> `create user role` +} + +// unrewritable lists the changes with no mechanical rewrite at all, and why. +// A script that uses one keeps its version: `fmt --upgrade --header` reports +// each use and refuses the header. This list may only shrink. +var unrewritable = map[string]string{ + "MDL-V1-PROP": "an unknown property key is ignored under mdl 0 and an error under mdl 1; " + + "which key was meant cannot be guessed, so correct or delete it by hand", + "MDL-V1-PROPVALUE": "a value its key does not take is ignored or read by its shape under mdl 0; " + + "what was meant cannot be guessed, so correct it by hand", + "MDL-V1-REPLACE01": "`create or replace view entity` drops and recreates the view entity under mdl 0, " + + "which no mdl 1 statement does: write `create or modify view entity` to keep its identity, or " + + "`drop entity` then `create view entity` to discard it", +} + +// visitorFix applies the rewrite the visitor computed from the parse tree when +// it recorded the note (mdl/visitor/visitor_upgrade_fixes.go). An occurrence +// without one carries the reason. +func visitorFix(_ *Source, _ *ast.Program, n ast.LanguageNote) ([]Edit, error) { + if n.Fix == nil { + if n.NoFix != "" { + return nil, errors.New(n.NoFix) + } + return nil, errors.New("no mechanical rewrite for this occurrence") + } + return n.Fix.Edits, nil +} diff --git a/mdl/upgrade/gated_test.go b/mdl/upgrade/gated_test.go new file mode 100644 index 000000000..017862f61 --- /dev/null +++ b/mdl/upgrade/gated_test.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// sameStatements requires two scripts to build the same statements, with the +// folds TestUpgrade_ExamplesKeepTheirStatements applies. +func sameStatements(t *testing.T, before, after string) { + t.Helper() + a, errs := visitor.Build(before) + if len(errs) > 0 { + t.Fatalf("original does not parse: %v", errs[0]) + } + b, errs := visitor.Build(after) + if len(errs) > 0 { + t.Fatalf("upgrade does not parse: %v\n%s", errs[0], after) + } + foldModeFlags(a.Statements) + foldModeFlags(b.Statements) + foldSourceText(reflect.ValueOf(a.Statements)) + foldSourceText(reflect.ValueOf(b.Statements)) + if !reflect.DeepEqual(a.Statements, b.Statements) { + t.Errorf("the upgrade builds different statements:\n before: %#v\n after: %#v", a.Statements, b.Statements) + } +} + +const mf = "create microflow M.F ($L: List of M.E, $S: String, $o: M.E) begin\n" + +// Each header-gated construct, rewritten so that under `mdl 1;` it means what +// it meant under mdl 0. The execute-both test in mdl/roundtrip proves the same +// on the example corpus against a real project. +func TestUpgrade_GatedRewrites(t *testing.T) { + for _, c := range []struct { + name, src, want string + sameAST bool // false where the AST differs but the executor writes the same (see buildsTheSameModelNotTheSameAST) + }{ + {"missing semicolon", "create module M\ncreate module N -- note\n", + "create module M;\ncreate module N; -- note\n", true}, + {"slash line", "create module M;\n/\ncreate module N;\n / \n", + "create module M;\ncreate module N;\n", true}, + {"slash and no semicolon", "create module M\n/\n", "create module M;\n", true}, + {"slash on the statement's line", "create module M; /\n", "create module M; \n", true}, + {"flow ending in a slash", mf + " log info 'x';\nend;\n/\n", mf + " log info 'x';\nend;\n", true}, + {"escapes in a plain literal", "create enumeration M.C (A 'it\\'s\\ta\\\\b');\n", + "create enumeration M.C (A 'it''s\ta\\b');\n", true}, + {"escaped line break outside an expression", "create enumeration M.C (A 'a\\nb');\n", + "create enumeration M.C (A 'a\nb');\n", true}, + {"escape in an expression stored as written", mf + " declare $s String = 'a' +\n '\\n';\nend;\n", + mf + " declare $s String = 'a' +\n '\\n';\nend;\n", true}, + {"escaped tab in an expression", mf + " declare $s String = 'a\\tb';\nend;\n", + mf + " declare $s String = 'a\tb';\nend;\n", true}, + {"limit 1 is an object", mf + " retrieve $X from M.E where Name = 'a' limit 1;\nend;\n", + mf + " retrieve $X from M.E where Name = 'a' first;\nend;\n", true}, + {"LIMIT 1 in upper case", mf + " RETRIEVE $X FROM M.E LIMIT 1;\nend;\n", + mf + " RETRIEVE $X FROM M.E FIRST;\nend;\n", true}, + {"reassignment says set", mf + " declare $n Integer = 1;\n $n = 2;\nend;\n", + mf + " declare $n Integer = 1;\n set $n = 2;\nend;\n", true}, + {"set with a list call", mf + " set $T = head($L);\nend;\n", mf + " $T = head $L;\nend;\n", true}, + {"set with an aggregate", mf + " set $N = sum($L.Amount);\nend;\n", mf + " $N = sum $L by Amount;\nend;\n", true}, + {"find on a list, by member", mf + " $F = find($L, Name = 'x');\nend;\n", mf + " $F = find $L by Name = 'x';\nend;\n", true}, + {"find on a list, by expression", mf + " $F = FIND($L, $currentObject/Name = 'x');\nend;\n", + mf + " $F = FIND $L WHERE $currentObject/Name = 'x';\nend;\n", true}, + {"contains on a list", mf + " $b = contains($L, $o);\nend;\n", mf + " $b = contains $o in $L;\nend;\n", true}, + {"find on a retrieved list", mf + " retrieve $R from M.E;\n $F = find($R, Name = 'x');\nend;\n", + mf + " retrieve $R from M.E;\n $F = find $R by Name = 'x';\nend;\n", true}, + {"find on a String is the string function", mf + " $i = find($S, 'x');\nend;\n", + mf + " set $i = find($S, 'x');\nend;\n", false}, + {"set contains on a String", mf + " declare $b Boolean = false;\n set $b = contains($S, $S);\nend;\n", + mf + " declare $b Boolean = false;\n set $b = contains($S, $S);\nend;\n", false}, + {"create or replace user role is a plain create", "create or replace user role R (M.Admin);\n", + "create user role R (M.Admin);\n", true}, + } { + t.Run(c.name, func(t *testing.T) { + res, err := Upgrade(c.src, Options{AddHeader: true}) + if err != nil { + t.Fatal(err) + } + if want := "mdl 1;\n" + c.want; res.Source != want { + t.Fatalf("got:\n%s\nwant:\n%s", res.Source, want) + } + if len(res.GatedRewritten) == 0 { + t.Errorf("no gated rewrite recorded") + } + if c.sameAST { + sameStatements(t, c.src, res.Source) + } + again, err := Upgrade(res.Source, Options{AddHeader: true}) + if err != nil || again.Source != res.Source || again.Changed() { + t.Errorf("not idempotent: %v %q", err, again.Source) + } + }) + } +} + +// A construct with no mechanical rewrite is reported with its reason and +// blocks the header; nothing is written. +func TestUpgrade_UnrewritableBlocksTheHeader(t *testing.T) { + for _, c := range []struct{ name, src, code, reason string }{ + {"nested list operation", mf + " $n = count(filter($L, Name = 'x'));\nend;\n", "MDL-V1-LIST", "nested call"}, + {"find on a call's result", mf + " $R = call microflow M.G();\n $F = find($R, Name = 'x');\nend;\n", + "MDL-V1-LIST", "does not state"}, + {"escaped line break in an expression", mf + " declare $s String = 'a\\nb';\nend;\n", + "MDL-V1-ESCAPE", "line break"}, + {"view entity replace", "create or replace view entity M.V (Name: String(100)) as (select c.Name as Name from M.Customer as c);\n", + "MDL-V1-REPLACE01", "drops and recreates"}, + {"unknown property", "create rest client M.Api (BaseUrl: 'https://x', Authentication: none) " + + "{ operation GetUser { Method: get, pathh: '/users', Response: none } };\n", "MDL-V1-PROP", "cannot be guessed"}, + } { + t.Run(c.name, func(t *testing.T) { + res, err := Upgrade(c.src, Options{AddHeader: true}) + var hb *HeaderBlockedError + if !errors.As(err, &hb) { + t.Fatalf("header added over a construct with no rewrite: %v\n%s", err, res.Source) + } + found := false + for _, b := range hb.Constructs { + if b.Code == c.code && strings.Contains(b.Reason, c.reason) { + found = true + } + } + if !found { + t.Errorf("want %s (%q) reported, got %+v", c.code, c.reason, hb.Constructs) + } + // Without the header the script keeps its meaning, so it upgrades. + if _, err := Upgrade(c.src, Options{}); err != nil { + t.Errorf("without the header: %v", err) + } + }) + } +} + +// The call form of every list operation and aggregate becomes its statement +// form without the header (MDL-DEPR003/004 are aliases under every version), +// building the same activity. +func TestUpgrade_ListCallFormToStatementForm(t *testing.T) { + for _, c := range [][2]string{ + {"$X = head($L);", "$X = head $L;"}, + {"$X = TAIL( $L );", "$X = TAIL $L;"}, + {"$X = filter($L, Name = 'x');", "$X = filter $L by Name = 'x';"}, + {"$X = filter($L, Qty > 0 and Name != 'a');", "$X = filter $L where Qty > 0 and Name != 'a';"}, + {"$X = sort($L, \"Status\" asc, Name desc);", "$X = sort $L by \"Status\" asc, Name desc;"}, + {"$X = union($L, $L2);", "$X = union $L with $L2;"}, + {"$X = intersect($L, $L2);", "$X = intersect $L with $L2;"}, + {"$X = subtract($L, $L2);", "$X = subtract $L2 from $L;"}, + {"$X = equals($L, $L2);", "$X = equals $L and $L2;"}, + {"$X = range($L, 5);", "$X = range $L offset 5;"}, + {"$X = range($L, $o2, 10);", "$X = range $L offset $o2 limit 10;"}, + {"$X = count($L);", "$X = count $L;"}, + {"$X = sum($L.Amount);", "$X = sum $L by Amount;"}, + {"$X = average($L/Amount);", "$X = average $L by Amount;"}, + {"$X = minimum($L, $currentObject/Amount * 2);", "$X = minimum $L of $currentObject/Amount * 2;"}, + {"$X = MAXIMUM($L.Amount);", "$X = MAXIMUM $L BY Amount;"}, + {"$X = all($L, $currentObject/Paid);", "$X = all $L where $currentObject/Paid;"}, + {"$X = any($L, not($currentObject/Paid));", "$X = any $L where not($currentObject/Paid);"}, + {"$X = reduce($L, $currentResult + $currentObject/Amount, initial: 0, returns: Decimal);", + "$X = reduce $L from 0 as Decimal using $currentResult + $currentObject/Amount;"}, + } { + src := "create microflow M.F ($L: List of M.E, $L2: List of M.E, $o2: Integer) begin\n " + c[0] + "\nend;\n" + want := "create microflow M.F ($L: List of M.E, $L2: List of M.E, $o2: Integer) begin\n " + c[1] + "\nend;\n" + t.Run(c[0], func(t *testing.T) { + res, err := Upgrade(src, Options{}) + if err != nil { + t.Fatal(err) + } + if res.Source != want { + t.Fatalf("got:\n%s\nwant:\n%s", res.Source, want) + } + sameStatements(t, src, res.Source) + }) + } +} + +// Every change of meaning the visitor gates has a rewrite or is listed as +// having none, never both. The list of changes with none may only shrink. +func TestGatedRegistryIsComplete(t *testing.T) { + pinned := map[string]bool{"MDL-V1-PROP": true, "MDL-V1-PROPVALUE": true, "MDL-V1-REPLACE01": true} + known := map[string]bool{} + for _, c := range visitor.LanguageChanges() { + known[c.Code] = true + _, rw := gatedRewriters[c.Code] + _, none := unrewritable[c.Code] + if rw == none { + t.Errorf("%s: needs exactly one of a rewrite in gatedRewriters or a reason in unrewritable (rewrite=%v, reason=%v)", + c.Code, rw, none) + } + } + for code := range gatedRewriters { + if !known[code] { + t.Errorf("gatedRewriters has %s, which the visitor does not gate", code) + } + } + for code := range unrewritable { + if !pinned[code] { + t.Errorf("%s was added to unrewritable: the list may only shrink — give the change a rewrite", code) + } + } +} diff --git a/mdl/upgrade/upgrade.go b/mdl/upgrade/upgrade.go index 5f15b2d18..9efa14016 100644 --- a/mdl/upgrade/upgrade.go +++ b/mdl/upgrade/upgrade.go @@ -14,12 +14,18 @@ // meaning differs under the new version (ast.Program.LanguageNotes, one per // langver.Change) is rewritten to the spelling that keeps its OLD meaning // under the new header, and `mdl <n>;` is added. The rewrites live in -// gatedRewriters (gated.go). +// gatedRewriters (gated.go); the changes with none, in unrewritable. +// +// A rewrite that is more than a keyword swap — a structural deprecation such as +// a list operation's call form, or a header-gated construct — is computed from +// the parse tree by the visitor that records the construct, as an ast.Fix of +// rune-offset edits (mdl/visitor/visitor_upgrade_fixes.go). An occurrence the +// visitor cannot rewrite carries the reason instead. // // Anything it cannot rewrite is reported, never guessed at: a deprecated use -// whose entry has no rewrite is left in place and listed in Result.Unrewritten, -// and a header-gated construct without a rewrite blocks the header, because -// adding the header over it would silently change the script's meaning. +// with no rewrite is left in place and listed in Result.Unrewritten, and a +// header-gated construct without one blocks the header (HeaderBlockedError), +// because adding the header over it would silently change the script's meaning. // // The rewrite works on the source text, not on the AST, so comments, layout // and every spelling it does not own survive byte for byte. The output is @@ -117,38 +123,47 @@ func (u upgrader) upgradeProg(src string, opts Options, parse func(string) (*ast if !ok { return res, fmt.Errorf("line %d: the visitor recorded %s, which is not in the deprecation registry", d.Line, d.Code) } - if e.Rewrite.IsZero() { + switch { + case e.Rewrite.Token != "": + ed, err := tokenSwap(text, d, e.Rewrite) + if err != nil { + return res, err + } + edits = append(edits, ed) + case e.Rewrite.Structural != "" && d.Fix != nil: + // A structural rewrite is computed from the parse tree by the + // visitor that recorded the use (mdl/visitor/visitor_upgrade_fixes.go). + edits = append(edits, d.Fix.Edits...) + default: res.Unrewritten = append(res.Unrewritten, d) continue } - ed, err := tokenSwap(text, d, e.Rewrite) - if err != nil { - return res, err - } - edits = append(edits, ed) res.Rewritten[d.Code]++ } addHeader := opts.AddHeader && prog.LanguageHeaderLine == 0 if addHeader { - var missing []string + var blocked []Blocked for _, n := range prog.LanguageNotes { rw, ok := u.gated[n.Code] if !ok { - missing = append(missing, fmt.Sprintf("line %d: %s", n.Line, n.Code)) + reason := unrewritable[n.Code] + if reason == "" { + reason = "no rewrite is registered for it" + } + blocked = append(blocked, Blocked{Line: n.Line, Code: n.Code, Reason: reason}) continue } ed, err := rw(text, prog, n) if err != nil { - return res, fmt.Errorf("line %d: rewriting %s: %w", n.Line, n.Code, err) + blocked = append(blocked, Blocked{Line: n.Line, Code: n.Code, Reason: err.Error()}) + continue } edits = append(edits, ed...) res.GatedRewritten[n.Code]++ } - if len(missing) > 0 { - return res, fmt.Errorf("cannot add the `%s;` header: %d construct(s) would change meaning under it "+ - "and have no rewrite yet, so the script is left at %s:\n %s", - langver.Latest, len(missing), prog.LanguageVersion, strings.Join(missing, "\n ")) + if len(blocked) > 0 { + return res, &HeaderBlockedError{From: prog.LanguageVersion, Constructs: blocked} } } @@ -167,6 +182,32 @@ func (u upgrader) upgradeProg(src string, opts Options, parse func(string) (*ast return res, nil } +// Blocked is one construct that keeps a script from taking the header: its +// meaning would change under it, and it has no mechanical rewrite. +type Blocked struct { + Line int + Code string // the langver.Change's rule ID + Reason string // why there is no rewrite, and what to write instead +} + +// HeaderBlockedError is Upgrade's refusal to add the language header over +// constructs whose meaning it would change and that it cannot rewrite. The +// script is left at its version; nothing is written. +type HeaderBlockedError struct { + From langver.Version + Constructs []Blocked +} + +func (e *HeaderBlockedError) Error() string { + lines := make([]string, len(e.Constructs)) + for i, c := range e.Constructs { + lines[i] = fmt.Sprintf("line %d: %s: %s", c.Line, c.Code, c.Reason) + } + return fmt.Sprintf("cannot add the `%s;` header: %d construct(s) would change meaning under it "+ + "and have no mechanical rewrite, so the script is left at %s:\n %s", + langver.Latest, len(e.Constructs), e.From, strings.Join(lines, "\n ")) +} + // verify re-parses the upgraded script. It must parse, keep every statement, // and record no deprecated use the upgrade knows how to rewrite: a use that // survives means a rewrite did not produce the canonical spelling, and a @@ -186,7 +227,7 @@ func (u upgrader) verify(orig *ast.Program, out string, parse func(string) (*ast len(prog.Statements), len(orig.Statements)) } for _, d := range prog.Deprecations { - if e, ok := u.lookup(d.Code); ok && !e.Rewrite.IsZero() { + if e, ok := u.lookup(d.Code); ok && (e.Rewrite.Token != "" || d.Fix != nil) { return fmt.Errorf("line %d: the upgraded script still uses a deprecated spelling (%s); its rewrite did not produce %q", d.Line, d.Code, e.Canonical) } @@ -194,74 +235,92 @@ func (u upgrader) verify(orig *ast.Program, out string, parse func(string) (*ast return nil } -// Edit replaces Len runes at (Line, Column) of the source with Text. Line is -// 1-based and Column 0-based, in runes, as ANTLR reports token positions. -type Edit struct { - Line int - Column int - Len int - Text string -} +// Edit replaces the runes [Start, Stop) of the source with Text; Start == +// Stop inserts. Offsets count runes from the start of the script, the +// coordinates ANTLR's character stream reports. +type Edit = ast.TextEdit -// Source is a script split into lines of runes, the coordinates the parser -// reports positions in. +// Source is a script as runes, the coordinates the parser reports positions +// in, with the offset each line starts at. type Source struct { - lines [][]rune + runes []rune + lineStarts []int } func newSource(src string) *Source { - parts := strings.Split(src, "\n") - s := &Source{lines: make([][]rune, len(parts))} - for i, p := range parts { - s.lines[i] = []rune(p) + s := &Source{runes: []rune(src), lineStarts: []int{0}} + for i, r := range s.runes { + if r == '\n' { + s.lineStarts = append(s.lineStarts, i+1) + } } return s } -// At returns the n runes at (line, col), or false when they are out of range. +// Offset returns the rune offset of (line, col): line 1-based, col 0-based in +// runes, as ANTLR reports token positions. False when out of range. +func (s *Source) Offset(line, col int) (int, bool) { + if line < 1 || line > len(s.lineStarts) || col < 0 { + return 0, false + } + end := len(s.runes) + if line < len(s.lineStarts) { + end = s.lineStarts[line] - 1 + } + off := s.lineStarts[line-1] + col + if off > end { + return 0, false + } + return off, true +} + +// At returns the n runes at (line, col), or false when they are out of range +// or cross a line end. func (s *Source) At(line, col, n int) (string, bool) { - if line < 1 || line > len(s.lines) || col < 0 || n < 0 || col+n > len(s.lines[line-1]) { + off, ok := s.Offset(line, col) + if !ok || n < 0 { + return "", false + } + if end, ok := s.Offset(line, col+n); !ok || end != off+n { return "", false } - return string(s.lines[line-1][col : col+n]), true + return string(s.runes[off : off+n]), true } -// apply returns the source with edits applied. Edits may not overlap. +// apply returns the source with edits applied. Edits may not overlap; two at +// the same offset apply in the order given, an insertion before a replacement. func (s *Source) apply(edits []Edit) (string, error) { sorted := append([]Edit(nil), edits...) - sort.Slice(sorted, func(i, j int) bool { - if sorted[i].Line != sorted[j].Line { - return sorted[i].Line < sorted[j].Line + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].Start != sorted[j].Start { + return sorted[i].Start < sorted[j].Start } - return sorted[i].Column < sorted[j].Column + return sorted[i].Stop-sorted[i].Start < sorted[j].Stop-sorted[j].Start }) - for i := 1; i < len(sorted); i++ { - a, b := sorted[i-1], sorted[i] - if a.Line == b.Line && a.Column+a.Len > b.Column { - return "", fmt.Errorf("line %d: two upgrade rewrites overlap at columns %d and %d", a.Line, a.Column, b.Column) + var b strings.Builder + at := 0 + for _, e := range sorted { + if e.Start < 0 || e.Stop < e.Start || e.Stop > len(s.runes) { + return "", fmt.Errorf("%s: upgrade rewrite is outside the source", s.where(e.Start)) } - } - lines := make([][]rune, len(s.lines)) - copy(lines, s.lines) - // Right to left, so an earlier edit's column is still valid after a later - // one on the same line changed its length. - for i := len(sorted) - 1; i >= 0; i-- { - e := sorted[i] - if _, ok := s.At(e.Line, e.Column, e.Len); !ok { - return "", fmt.Errorf("line %d column %d: upgrade rewrite is outside the source", e.Line, e.Column) + if e.Start < at { + return "", fmt.Errorf("%s: two upgrade rewrites overlap", s.where(e.Start)) } - l := lines[e.Line-1] - nl := make([]rune, 0, len(l)-e.Len+len(e.Text)) - nl = append(nl, l[:e.Column]...) - nl = append(nl, []rune(e.Text)...) - nl = append(nl, l[e.Column+e.Len:]...) - lines[e.Line-1] = nl + b.WriteString(string(s.runes[at:e.Start])) + b.WriteString(e.Text) + at = e.Stop } - out := make([]string, len(lines)) - for i, l := range lines { - out[i] = string(l) + b.WriteString(string(s.runes[at:])) + return b.String(), nil +} + +// where names a rune offset as "line L column C" (column 1-based). +func (s *Source) where(off int) string { + line := sort.Search(len(s.lineStarts), func(i int) bool { return s.lineStarts[i] > off }) + if line == 0 { + return fmt.Sprintf("offset %d", off) } - return strings.Join(out, "\n"), nil + return fmt.Sprintf("line %d column %d", line, off-s.lineStarts[line-1]+1) } // tokenSwap is the edit for a deprecation.Rewrite: the recorded token, which @@ -273,7 +332,8 @@ func tokenSwap(s *Source, d ast.DeprecatedSpelling, rw deprecation.Rewrite) (Edi return Edit{}, fmt.Errorf("line %d column %d: %s is recorded here, but the source has %q where %q was expected", d.Line, d.Column, d.Code, got, rw.Token) } - return Edit{Line: d.Line, Column: d.Column, Len: n, Text: matchCase(got, rw.Replacement)}, nil + off, _ := s.Offset(d.Line, d.Column) + return Edit{Start: off, Stop: off + n, Text: matchCase(got, rw.Replacement)}, nil } // matchCase spells word in the letter case of like: all upper, capitalised, or diff --git a/mdl/upgrade/upgrade_test.go b/mdl/upgrade/upgrade_test.go index eab2ab99e..2a99fd148 100644 --- a/mdl/upgrade/upgrade_test.go +++ b/mdl/upgrade/upgrade_test.go @@ -215,7 +215,8 @@ func TestUpgrade_GatedConstructs(t *testing.T) { if got != "modules" { t.Errorf("note does not point at the construct: %q", got) } - return []Edit{{Line: n.Line, Column: 5, Len: 7, Text: "microflows"}}, nil + off, _ := s.Offset(n.Line, 5) + return []Edit{{Start: off, Stop: off + 7, Text: "microflows"}}, nil }} res, err = u.upgradeProg(src, Options{AddHeader: true}, withNote) if err != nil { diff --git a/mdl/visitor/language_changes_test.go b/mdl/visitor/language_changes_test.go new file mode 100644 index 000000000..aff2f13b9 --- /dev/null +++ b/mdl/visitor/language_changes_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "strings" + "testing" +) + +// LanguageChanges must list every langver.Change this package declares: the +// upgrade's registry test holds each listed change to a rewrite or a reason it +// has none, so a change left off the list would escape it. +func TestLanguageChangesListsEveryDeclaredChange(t *testing.T) { + listed := map[string]bool{} + for _, c := range LanguageChanges() { + listed[c.Code] = true + } + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + declared := 0 + fset := token.NewFileSet() + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") { + continue + } + f, err := parser.ParseFile(fset, e.Name(), nil, 0) + if err != nil { + t.Fatal(err) + } + ast.Inspect(f, func(n ast.Node) bool { + lit, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + sel, ok := lit.Type.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Change" || len(lit.Elts) == 0 { + return true + } + for _, el := range lit.Elts { + kv, ok := el.(*ast.KeyValueExpr) + if k, _ := kv.Key.(*ast.Ident); !ok || k == nil || k.Name != "Code" { + continue + } + if v, ok := kv.Value.(*ast.BasicLit); ok { + declared++ + code := strings.Trim(v.Value, `"`) + if !listed[code] { + t.Errorf("%s declares the language change %s, which LanguageChanges does not list", e.Name(), code) + } + } + } + return true + }) + } + if declared != len(listed) { + t.Errorf("%d changes declared, %d listed", declared, len(listed)) + } +} diff --git a/mdl/visitor/visitor.go b/mdl/visitor/visitor.go index d163f3df8..768244711 100644 --- a/mdl/visitor/visitor.go +++ b/mdl/visitor/visitor.go @@ -507,6 +507,9 @@ type Builder struct { langVersion langver.Version langHeaderLine int langNotes []ast.LanguageNote + // stringLits are the string literals of the parse tree, by token index, + // for the string-escape rewrite to find the expression each is in. + stringLits map[int]antlr.TerminalNode } // NewBuilder creates a new AST builder. diff --git a/mdl/visitor/visitor_deprecations.go b/mdl/visitor/visitor_deprecations.go index e93c5ec6e..491ab7a6d 100644 --- a/mdl/visitor/visitor_deprecations.go +++ b/mdl/visitor/visitor_deprecations.go @@ -78,6 +78,27 @@ func replaceGate(ctx *parser.CreateStatementContext) (langver.Change, bool) { return langver.Change{}, false } +// replaceGateFix is the rewrite that keeps an mdl 0 `create or replace` on a +// gated kind meaning what it means there. +// +// For a user role or demo user that is a plain create, so `or replace` is +// dropped. A view entity's drop-and-recreate has no mdl 1 spelling: `create or +// modify` keeps the identity the old meaning discards, and a `drop` in front +// would fail where the old statement created the entity. +func replaceGateFix(ctx *parser.CreateStatementContext, c langver.Change) (*ast.Fix, string) { + if c.Code != roleReplaceIsModify.Code { + return nil, "`create or replace view entity` drops and recreates the view entity under mdl 0, which no " + + "mdl 1 statement does: write `create or modify view entity` to keep its identity, or `drop entity` " + + "then `create view entity` to discard it" + } + or, next := ctx.OR().GetSymbol(), ctx.REPLACE().GetSymbol() + stop := next.GetStop() + 1 + if is := next.GetInputStream(); stop < is.Size() && is.GetText(stop, stop) == " " { + stop++ + } + return &ast.Fix{Edits: []ast.TextEdit{{Start: or.GetStart(), Stop: stop}}}, "" +} + // replaceMeansModify reports whether `create or replace` on createStmt builds // `create or modify` under the script's language version. The document // builders ask it; recordCreateOrReplace reports what it decided. @@ -103,6 +124,8 @@ func (b *Builder) recordCreateOrReplace(ctx *parser.CreateStatementContext) { return } if c, gated := replaceGate(ctx); gated && !b.gate(c, ctx) { + fix, why := replaceGateFix(ctx, c) + b.fixLastNote(c.Code, fix, why) return } b.recordDeprecation(deprecation.CreateOrReplace, ctx.REPLACE().GetSymbol(), kind) diff --git a/mdl/visitor/visitor_list_activities.go b/mdl/visitor/visitor_list_activities.go index 06c06bfa5..61f868b5a 100644 --- a/mdl/visitor/visitor_list_activities.go +++ b/mdl/visitor/visitor_list_activities.go @@ -232,13 +232,30 @@ func (b *Builder) ExitListOperationStatement(ctx *parser.ListOperationStatementC target = v.GetText() } if op, ok := ctx.ListOperation().(*parser.ListOperationContext); ok && op != nil { + byExpression := buildListOperationStatement(ctx).ByExpression if op.FIND() != nil || op.CONTAINS() != nil { if b.gate(listCallForm, ctx) { b.addError(findContainsCallError(ctx.GetStart().GetLine(), target, op.FIND() != nil)) + return + } + // Under mdl 0 the flow builder turns a String operand into the + // string function: `set` says so under mdl 1. Any other operand + // is the List operation, the statement form. + fn := strings.ToLower(op.GetStart().GetText()) + switch isString, known := operandKind(ctx, op.VARIABLE(0).GetText()); { + case !known: + b.fixLastNote(listCallForm.Code, nil, operandKindUnknown(op.VARIABLE(0).GetText(), fn)) + case isString: + b.fixLastNote(listCallForm.Code, &ast.Fix{Edits: []ast.TextEdit{insertAt(ctx.GetStart().GetStart(), "set ")}}, "") + default: + fix, why := fixOrReason(callFormFix(op, byExpression)) + b.fixLastNote(listCallForm.Code, fix, why) } return } b.recordDeprecation(deprecation.ListOperationFunctionForm, op.GetStart(), strings.ToLower(op.GetStart().GetText())) + fix, why := fixOrReason(callFormFix(op, byExpression)) + b.fixLastDeprecation(deprecation.ListOperationFunctionForm, fix, why) return } act, ok := ctx.ListOperationActivity().(*parser.ListOperationActivityContext) @@ -275,6 +292,8 @@ func findContainsCallError(line int, target string, find bool) error { func (b *Builder) ExitAggregateListStatement(ctx *parser.AggregateListStatementContext) { if op, ok := ctx.ListAggregateOperation().(*parser.ListAggregateOperationContext); ok && op != nil { b.recordDeprecation(deprecation.AggregateFunctionForm, op.GetStart(), strings.ToLower(op.GetStart().GetText())) + fix, why := fixOrReason(callFormFix(op, false)) + b.fixLastDeprecation(deprecation.AggregateFunctionForm, fix, why) } } @@ -311,20 +330,73 @@ func (b *Builder) ExitSetStatement(ctx *parser.SetStatementContext) { "expression. Write `%s = %s $List …;`, with each inner call as a statement of its own "+ "(e.g. `$Open = filter $Orders where …;` then `$N = count $Open;`)", line, target, valueText, target, strings.ToLower(name))) + return } + fix, why := setCallFix(ctx, value, isStringOverload(name)) + b.fixLastNote(listCallForm.Code, fix, why) return case name != "" && ctx.SET() != nil: // `set $x = find(…)` / `contains(…)`: under mdl 1 always the string // function; under mdl 0 an activity when the arguments look like one. if call, ok := unwrapSource(value).(*ast.FunctionCallExpr); ok && - buildListOrAggregateStatement(strings.TrimPrefix(target, "$"), call) != nil { - b.gate(listCallForm, ctx) + buildListOrAggregateStatement(strings.TrimPrefix(target, "$"), call) != nil && !b.gate(listCallForm, ctx) { + fix, why := setCallFix(ctx, value, true) + b.fixLastNote(listCallForm.Code, fix, why) } return } - if ctx.SET() == nil && b.gate(setIsMandatory, ctx) { - b.addError(fmt.Errorf("line %d: a reassignment says `set` under mdl 1: write `set %s = %s;`. "+ - "A List operation or Aggregate list activity is written without it, as its own statement "+ - "(`%s = filter $List where …;`)", line, target, valueText, target)) + if ctx.SET() == nil { + if b.gate(setIsMandatory, ctx) { + b.addError(fmt.Errorf("line %d: a reassignment says `set` under mdl 1: write `set %s = %s;`. "+ + "A List operation or Aggregate list activity is written without it, as its own statement "+ + "(`%s = filter $List where …;`)", line, target, valueText, target)) + return + } + b.fixLastNote(setIsMandatory.Code, &ast.Fix{Edits: []ast.TextEdit{insertAt(ctx.GetStart().GetStart(), "set ")}}, "") + } +} + +// setCallFix is the rewrite of an assignment whose value is a list-operation or +// aggregate call that mdl 0 turns into an activity (MDL-V1-LIST): the statement +// form, without `set`. For find and contains the activity is what mdl 0 builds +// only when the operand is not a String; for a String it is the string +// function, which is what `set $x = find(…)` means under mdl 1, so a script +// already written that way needs no edit. +func setCallFix(ctx *parser.SetStatementContext, value ast.Expression, overloaded bool) (*ast.Fix, string) { + op := singleListCall(ctx.Expression()) + if op == nil { + return nil, "the operand is not a variable (a nested call or an expression), and one activity takes a " + + "variable: write each inner call as a statement of its own" + } + if overloaded { + lo, ok := op.(*parser.ListOperationContext) + if !ok || lo.VARIABLE(0) == nil { + return nil, "the call has no list operand" + } + v, fn := lo.VARIABLE(0), strings.ToLower(op.GetStart().GetText()) + isString, known := operandKind(ctx, v.GetText()) + if !known { + return nil, operandKindUnknown(v.GetText(), fn) + } + if isString { + if ctx.SET() == nil { + return &ast.Fix{Edits: []ast.TextEdit{insertAt(ctx.GetStart().GetStart(), "set ")}}, "" + } + return &ast.Fix{}, "" + } + } + byExpression := false + if call, ok := unwrapSource(value).(*ast.FunctionCallExpr); ok { + if lo, ok := buildListOrAggregateStatement(strings.TrimPrefix(ctx.VARIABLE().GetText(), "$"), call).(*ast.ListOperationStmt); ok { + byExpression = lo.ByExpression + } + } + edits, why := callFormFix(op, byExpression) + if why != "" { + return nil, why + } + if set := ctx.SET(); set != nil { + edits = append(edits, ast.TextEdit{Start: set.GetSymbol().GetStart(), Stop: ctx.VARIABLE().GetSymbol().GetStart()}) } + return &ast.Fix{Edits: edits}, "" } diff --git a/mdl/visitor/visitor_retrieve_range.go b/mdl/visitor/visitor_retrieve_range.go index 1ce1f8238..40dcfc605 100644 --- a/mdl/visitor/visitor_retrieve_range.go +++ b/mdl/visitor/visitor_retrieve_range.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/grammar/parser" "github.com/mendixlabs/mxcli/mdl/langver" ) @@ -43,8 +44,13 @@ func (b *Builder) ExitRetrieveStatement(ctx *parser.RetrieveStatementContext) { ctx.GetStart().GetLine())) return } - if isBareLimitOne(ctx) { - b.gate(limitOneIsAList, ctx) + if isBareLimitOne(ctx) && !b.gate(limitOneIsAList, ctx) { + // `limit 1` -> `first`: the object range, spelled the way that means + // it under every version. + limit := ctx.LIMIT().GetSymbol() + b.fixLastNote(limitOneIsAList.Code, &ast.Fix{Edits: []ast.TextEdit{ + replaceSpan(limit, ctx.GetLimitExpr().GetStop(), keywordLike(limit.GetText(), "first")), + }}, "") } } diff --git a/mdl/visitor/visitor_strict_terminators.go b/mdl/visitor/visitor_strict_terminators.go index 1d9b28852..f5cf67455 100644 --- a/mdl/visitor/visitor_strict_terminators.go +++ b/mdl/visitor/visitor_strict_terminators.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/grammar/parser" "github.com/mendixlabs/mxcli/mdl/langver" ) @@ -45,13 +46,21 @@ func (b *Builder) ExitStatement(ctx *parser.StatementContext) { if last[0].GetTokenType() == parser.MDLParserSLASH { slash, last = last[0], last[1:] } - if len(last) > 0 && last[0].GetTokenType() != parser.MDLParserSEMICOLON && b.gate(semicolonRequired, ctx) { - b.addError(fmt.Errorf("line %d: the statement ending at %q has no terminating `;`: "+ - "under %s every statement ends with `;`", last[0].GetLine(), last[0].GetText(), b.langVersion)) + if len(last) > 0 && last[0].GetTokenType() != parser.MDLParserSEMICOLON { + if b.gate(semicolonRequired, ctx) { + b.addError(fmt.Errorf("line %d: the statement ending at %q has no terminating `;`: "+ + "under %s every statement ends with `;`", last[0].GetLine(), last[0].GetText(), b.langVersion)) + } else { + b.fixLastNote(semicolonRequired.Code, &ast.Fix{Edits: []ast.TextEdit{insertAt(last[0].GetStop()+1, ";")}}, "") + } } - if slash != nil && b.gate(slashIsNotATerminator, ctx) { - b.addError(fmt.Errorf("line %d: `/` is not a statement terminator under %s; end the statement "+ - "with `;` and delete the `/` line", slash.GetLine(), b.langVersion)) + if slash != nil { + if b.gate(slashIsNotATerminator, ctx) { + b.addError(fmt.Errorf("line %d: `/` is not a statement terminator under %s; end the statement "+ + "with `;` and delete the `/` line", slash.GetLine(), b.langVersion)) + } else { + b.fixLastNote(slashIsNotATerminator.Code, &ast.Fix{Edits: []ast.TextEdit{slashLineFix(slash)}}, "") + } } } diff --git a/mdl/visitor/visitor_string_escapes.go b/mdl/visitor/visitor_string_escapes.go index f3a5cb69b..ceb08eb24 100644 --- a/mdl/visitor/visitor_string_escapes.go +++ b/mdl/visitor/visitor_string_escapes.go @@ -77,6 +77,7 @@ func (b *Builder) noteBackslashEscapes(tokens []antlr.Token) { Code: backslashIsLiteral.Code, Message: "the string " + t.GetText() + ": " + backslashIsLiteral.Warning(b.langVersion), }) + b.langNotes[len(b.langNotes)-1].Fix, b.langNotes[len(b.langNotes)-1].NoFix = b.escapeFix(t) added = true } if added { @@ -98,3 +99,63 @@ func hasInterpretedEscape(lit string) bool { } return false } + +// VisitTerminal collects the string literals of the tree for escapeFix. +func (b *Builder) VisitTerminal(node antlr.TerminalNode) { + if t := node.GetSymbol(); t != nil && t.GetTokenType() == parser.MDLLexerSTRING_LITERAL { + if b.stringLits == nil { + b.stringLits = map[int]antlr.TerminalNode{} + } + b.stringLits[t.GetTokenIndex()] = node + } +} + +// escapeFix is the rewrite that keeps an mdl 0 string literal's meaning under +// mdl 1, where a backslash is an ordinary character. +// +// What the literal means under mdl 0 depends on where it is. On its own (a +// name, a caption) and in an expression the builder re-renders, it is its +// unescaped value, so the rewrite writes that value with a doubled apostrophe as the only +// escape. In an expression the builder stores as written — one that spans +// lines (shouldPreserveExpressionSource) — mdl 0 already passes the backslash +// through to Mendix, exactly as mdl 1 does, so nothing changes. +// +// An escaped line break in a re-rendered expression has no rewrite: writing the +// break into the source makes the builder store the expression as written, +// which is not always what it wrote before. A log message is the measured case +// (mdl-examples/bug-tests/264-log-node-expression-roundtrip.mdl): a lone +// literal is the message template, the stored expression a `{1}` parameter. +func (b *Builder) escapeFix(t antlr.Token) (*ast.Fix, string) { + requoted := requoteForV1(t.GetText()) + rewrite := &ast.Fix{Edits: []ast.TextEdit{replaceSpan(t, t, requoted)}} + node := b.stringLits[t.GetTokenIndex()] + if node == nil { + return rewrite, "" + } + var top antlr.ParserRuleContext + for p := node.GetParent(); p != nil; p = p.GetParent() { + if e, ok := p.(*parser.ExpressionContext); ok { + top = e + } + } + if top == nil { + return rewrite, "" + } + source := strings.TrimSpace(extractExpressionText(top)) + if shouldPreserveExpressionSource(source) { + return &ast.Fix{}, "" + } + if strings.ContainsAny(requoted, "\r\n") { + return nil, "under mdl 1 the line break is written into the string itself, which makes the expression " + + "one that is stored as written rather than re-rendered, and that can change what it builds " + + "(a log message becomes a `{1}` parameter); rewrite it by hand" + } + return rewrite, "" +} + +// requoteForV1 writes an mdl 0 string literal so that it has the same value +// under mdl 1, where a backslash is an ordinary character and a doubled apostrophe the only +// escape. +func requoteForV1(lit string) string { + return "'" + strings.ReplaceAll(unquoteString(lit), "'", "''") + "'" +} diff --git a/mdl/visitor/visitor_upgrade_fixes.go b/mdl/visitor/visitor_upgrade_fixes.go new file mode 100644 index 000000000..1f590647c --- /dev/null +++ b/mdl/visitor/visitor_upgrade_fixes.go @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "fmt" + "regexp" + "strings" + + "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/langver" +) + +// The mechanical rewrites `mxcli fmt --upgrade` applies (mdl/upgrade) are +// computed here, where the construct is recorded, because the parse tree is the +// only place its tokens and their positions are still at hand. Each rewrite is +// an ast.Fix on the recorded ast.DeprecatedSpelling or ast.LanguageNote; a +// construct with no mechanical rewrite gets a NoFix reason instead, and the +// upgrade reports it rather than guessing. +// +// A Fix edits as little as it can — the tokens that differ between the two +// spellings — so comments, layout and nested constructs survive, and a nested +// fix (a string escape inside a list operation's condition) never overlaps it. + +// LanguageChanges returns every change of meaning the visitor gates on the +// language header. The upgrade's registry test requires each to have a +// rewrite or to be listed as having none. +func LanguageChanges() []langver.Change { + return []langver.Change{ + semicolonRequired, slashIsNotATerminator, backslashIsLiteral, limitOneIsAList, + listCallForm, setIsMandatory, viewEntityReplaceIsModify, roleReplaceIsModify, + unknownPropertyKey, misshapedPropertyValue, + } +} + +// fixLastNote attaches a rewrite, or the reason there is none, to the note +// gate just recorded for code. +func (b *Builder) fixLastNote(code string, fix *ast.Fix, noFix string) { + if n := len(b.langNotes); n > 0 && b.langNotes[n-1].Code == code { + b.langNotes[n-1].Fix, b.langNotes[n-1].NoFix = fix, noFix + } +} + +// fixLastDeprecation attaches a structural rewrite, or the reason there is +// none, to the deprecated use just recorded for code. +func (b *Builder) fixLastDeprecation(code string, fix *ast.Fix, noFix string) { + if n := len(b.deprecations); n > 0 && b.deprecations[n-1].Code == code { + b.deprecations[n-1].Fix, b.deprecations[n-1].NoFix = fix, noFix + } +} + +// fixOrReason turns an edit list and the reason there is none into the pair +// fixLastNote and fixLastDeprecation take. +func fixOrReason(edits []ast.TextEdit, reason string) (*ast.Fix, string) { + if reason != "" { + return nil, reason + } + return &ast.Fix{Edits: edits}, "" +} + +// insertAt inserts text at rune offset pos. +func insertAt(pos int, text string) ast.TextEdit { + return ast.TextEdit{Start: pos, Stop: pos, Text: text} +} + +// replaceSpan replaces from the start of first to the end of last. +func replaceSpan(first, last antlr.Token, text string) ast.TextEdit { + return ast.TextEdit{Start: first.GetStart(), Stop: last.GetStop() + 1, Text: text} +} + +// replaceGap replaces the runes after aStop and before bStart. +func replaceGap(aStop, bStart int, text string) ast.TextEdit { + return ast.TextEdit{Start: aStop + 1, Stop: bStart, Text: text} +} + +// keywordLike spells word in the case of like: upper-case when like is, else +// lower-case (the canonical case, ADR-0010). +func keywordLike(like, word string) string { + if like == strings.ToUpper(like) && like != strings.ToLower(like) { + return strings.ToUpper(word) + } + return strings.ToLower(word) +} + +// nodeSpan is the rune span of a terminal or a rule context, inclusive. +func nodeSpan(n antlr.Tree) (start, stop int) { + switch x := n.(type) { + case antlr.TerminalNode: + t := x.GetSymbol() + return t.GetStart(), t.GetStop() + case antlr.ParserRuleContext: + if x.GetStart() != nil && x.GetStop() != nil { + return x.GetStart().GetStart(), x.GetStop().GetStop() + } + } + return -1, -1 +} + +// nodeText is the source text of a node, as written. +func nodeText(n antlr.Tree) string { + switch x := n.(type) { + case antlr.TerminalNode: + return x.GetText() + case antlr.ParserRuleContext: + return extractOriginalText(x) + } + return "" +} + +// holdsInterpretedEscape reports whether a subtree has an mdl 0 string literal +// whose value differs under mdl 1. Text a rewrite moves must not hold one, or +// the string-escape rewrite would edit text that is no longer there. +func holdsInterpretedEscape(n antlr.Tree) bool { + if tn, ok := n.(antlr.TerminalNode); ok { + t := tn.GetSymbol() + return t.GetTokenType() == parser.MDLLexerSTRING_LITERAL && hasInterpretedEscape(t.GetText()) && + !parser.HasStrictEscapes(t.GetInputStream()) + } + for i := 0; i < n.GetChildCount(); i++ { + if holdsInterpretedEscape(n.GetChild(i)) { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// List operations and aggregates: call form -> statement form (MDL-DEPR003, +// MDL-DEPR004, MDL-V1-LIST). +// --------------------------------------------------------------------------- + +// simpleAttribute is an attribute name the statement form's `by` takes as is. +var simpleAttribute = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// callFormFix rewrites a list-operation or aggregate call (a +// ListOperationContext or ListAggregateOperationContext) to the statement form +// that builds the same activity. byExpression says, for find and filter, which +// condition the call form built: `where` (an expression) or `by` (a member). +// It returns the edits, or the reason there are none. +func callFormFix(op antlr.ParserRuleContext, byExpression bool) ([]ast.TextEdit, string) { + n := op.GetChildCount() + if n < 4 { + return nil, "the call is incomplete" + } + kwNode, ok := op.GetChild(0).(antlr.TerminalNode) + if !ok { + return nil, "the call has no operation keyword" + } + rpNode, ok := op.GetChild(n - 1).(antlr.TerminalNode) + if !ok || rpNode.GetSymbol().GetTokenType() != parser.MDLParserRPAREN { + return nil, "the call is incomplete" + } + kw, rp := kwNode.GetSymbol(), rpNode.GetSymbol() + var args []antlr.Tree + for i := 2; i < n-1; i++ { + c := op.GetChild(i) + if tn, ok := c.(antlr.TerminalNode); ok && tn.GetSymbol().GetTokenType() == parser.MDLParserCOMMA { + continue + } + args = append(args, c) + } + if len(args) == 0 { + return nil, "the call has no operand" + } + word := func(w string) string { return keywordLike(kw.GetText(), w) } + + // infix keeps every argument where it is and replaces only the + // punctuation: `op(` -> `op `, each `,` -> its keyword, `)` -> nothing. + infix := func(seps ...string) ([]ast.TextEdit, string) { + if len(seps) != len(args)-1 { + return nil, "the call has an unexpected number of arguments" + } + first, _ := nodeSpan(args[0]) + edits := []ast.TextEdit{replaceGap(kw.GetStop(), first, " ")} + for i, sep := range seps { + _, prevStop := nodeSpan(args[i]) + nextStart, _ := nodeSpan(args[i+1]) + edits = append(edits, replaceGap(prevStop, nextStart, " "+word(sep)+" ")) + } + _, lastStop := nodeSpan(args[len(args)-1]) + return append(edits, replaceGap(lastStop, rp.GetStop()+1, "")), "" + } + // whole replaces everything after the keyword, for the forms that reorder + // their operands. + whole := func(text string) ([]ast.TextEdit, string) { + for _, a := range args { + if holdsInterpretedEscape(a) { + return nil, "its operands move, and hold a string with a backslash escape" + } + } + return []ast.TextEdit{{Start: kw.GetStop() + 1, Stop: rp.GetStop() + 1, Text: text}}, "" + } + + switch kw.GetTokenType() { + case parser.MDLParserHEAD, parser.MDLParserTAIL, parser.MDLParserCOUNT: + return infix() + case parser.MDLParserFIND, parser.MDLParserFILTER: + if byExpression { + return infix("where") + } + return infix("by") + case parser.MDLParserSORT: + return infix("by") + case parser.MDLParserUNION, parser.MDLParserINTERSECT: + return infix("with") + case parser.MDLParserEQUALS_OP: + return infix("and") + case parser.MDLParserRANGE: + if len(args) > 3 { + return nil, "the call has an unexpected number of arguments" + } + return infix([]string{"offset", "limit"}[:len(args)-1]...) + case parser.MDLParserSUBTRACT: + // subtract($A, $B) is A minus B, written `subtract $B from $A`. + if len(args) != 2 { + return nil, "the call has an unexpected number of arguments" + } + return whole(" " + nodeText(args[1]) + " " + word("from") + " " + nodeText(args[0])) + case parser.MDLParserCONTAINS: + // contains($List, $Object) is written `contains $Object in $List`. + if len(args) != 2 { + return nil, "the call has an unexpected number of arguments" + } + return whole(" " + nodeText(args[1]) + " " + word("in") + " " + nodeText(args[0])) + case parser.MDLParserSUM, parser.MDLParserAVERAGE, parser.MDLParserMINIMUM, parser.MDLParserMAXIMUM: + if path, ok := args[0].(*parser.AttributePathContext); ok && len(args) == 1 { + // sum($L.Attr) is `sum $L by Attr`: the builder keeps the last + // segment of the path as the attribute. + v, attr := parseAttributePath(path.GetText()) + if !simpleAttribute.MatchString(attr) { + return nil, fmt.Sprintf("the attribute `%s` is not a plain name, which is all the statement form's `by` takes", attr) + } + return whole(" $" + v + " " + word("by") + " " + attr) + } + return infix("of") + case parser.MDLParserALL, parser.MDLParserANY: + return infix("where") + case parser.MDLParserREDUCE: + // reduce($L, expr, initial: seed, returns: T) is + // `reduce $L from seed as T using expr`: the fold body stays in place, + // the seed and the type move in front of it. + opts, ok := args[len(args)-1].(*parser.ReduceFoldOptionsContext) + if !ok || len(args) != 3 || opts.Expression() == nil || opts.DataType() == nil { + return nil, "the call has an unexpected shape" + } + if holdsInterpretedEscape(opts) { + return nil, "its seed moves, and holds a string with a backslash escape" + } + bodyStart, bodyStop := nodeSpan(args[1]) + return []ast.TextEdit{ + {Start: kw.GetStop() + 1, Stop: bodyStart, Text: " " + nodeText(args[0]) + " " + word("from") + " " + + extractOriginalText(opts.Expression()) + " " + word("as") + " " + extractOriginalText(opts.DataType()) + + " " + word("using") + " "}, + replaceGap(bodyStop, rp.GetStop()+1, ""), + }, "" + } + return nil, "there is no statement form for `" + kw.GetText() + "`" +} + +// singleListCall returns the list-operation or aggregate call an expression +// consists of, or nil when it is anything else: a nested call, an operator, or +// a call the call-form grammar does not match. +func singleListCall(e antlr.Tree) antlr.ParserRuleContext { + for e != nil { + switch x := e.(type) { + case *parser.ListOperationContext: + return x + case *parser.ListAggregateOperationContext: + return x + } + if e.GetChildCount() != 1 { + return nil + } + e = e.GetChild(0) + } + return nil +} + +// operandKind says whether a find/contains operand is a String (the call is +// the string function) or not (the List operation), as the mdl 0 flow builder +// decides it from the variable's declared type. known is false when the script +// does not say, e.g. the variable holds a call's result, whose type only the +// project knows. +func operandKind(at antlr.Tree, variable string) (isString, known bool) { + name := strings.TrimPrefix(variable, "$") + var flow antlr.Tree + for t := at; t != nil && flow == nil; t = t.GetParent() { + switch t.(type) { + case *parser.CreateMicroflowStatementContext, *parser.CreateNanoflowStatementContext, *parser.CreateRuleStatementContext: + flow = t + } + } + if flow == nil { + return false, false + } + // A parameter's or declare's type is authoritative: a variable keeps its type. + var declared []bool + var otherDefs, unknownDefs int + var walk func(antlr.Tree) + walk = func(t antlr.Tree) { + switch x := t.(type) { + case antlr.TerminalNode: + return + case *parser.MicroflowParameterContext: + pname := "" + if pn := x.ParameterName(); pn != nil { + pname = parameterNameText(pn) + } else if v := x.VARIABLE(); v != nil { + pname = strings.TrimPrefix(v.GetText(), "$") + } + if pname == name && x.DataType() != nil { + declared = append(declared, buildMicroflowDataType(x.DataType()).Kind == ast.TypeString) + } + return + case *parser.DeclareStatementContext: + if v := x.VARIABLE(); v != nil && v.GetText() == variable && x.DataType() != nil { + declared = append(declared, buildMicroflowDataType(x.DataType()).Kind == ast.TypeString) + } + case *parser.RetrieveStatementContext: + if v := x.VARIABLE(); v != nil && v.GetText() == variable { + otherDefs++ + } + case *parser.CreateListStatementContext: + if v := x.VARIABLE(); v != nil && v.GetText() == variable { + otherDefs++ + } + case *parser.ListOperationStatementContext: + if v := x.VARIABLE(); v != nil && v.GetText() == variable { + otherDefs++ + } + case *parser.LoopStatementContext: + if v := x.VARIABLE(0); v != nil && v.GetText() == variable { + otherDefs++ + } + default: + // Any other `$name = …` gives it a type the script may not state + // (a call's result, a REST response, an aggregate). A set only + // reassigns. + if prc, ok := t.(antlr.ParserRuleContext); ok && prc.GetChildCount() >= 2 { + if _, isSet := prc.(*parser.SetStatementContext); !isSet { + v, ok1 := prc.GetChild(0).(antlr.TerminalNode) + eq, ok2 := prc.GetChild(1).(antlr.TerminalNode) + if ok1 && ok2 && v.GetText() == variable && eq.GetSymbol().GetTokenType() == parser.MDLParserEQUALS { + unknownDefs++ + } + } + } + } + for i := 0; i < t.GetChildCount(); i++ { + walk(t.GetChild(i)) + } + } + walk(flow) + if len(declared) > 0 { + for _, d := range declared[1:] { + if d != declared[0] { + return false, false + } + } + return declared[0], true + } + return false, otherDefs > 0 && unknownDefs == 0 +} + +func operandKindUnknown(variable, fn string) string { + return fmt.Sprintf("whether %s is a String (the string function %s) or a list (the List operation) "+ + "depends on a type the script does not state; write `set $x = %s(…);` or `$x = %s %s …;` by hand", + variable, fn, fn, fn, variable) +} + +// --------------------------------------------------------------------------- +// Terminators and strings. +// --------------------------------------------------------------------------- + +// slashLineFix deletes a `/` terminator: its whole line when the `/` is alone +// on it, else the token. +func slashLineFix(slash antlr.Token) ast.TextEdit { + is := slash.GetInputStream() + start, stop := slash.GetStart(), slash.GetStop()+1 + size := is.Size() + at := func(i int) string { return is.GetText(i, i) } + ls := start + for ls > 0 && (at(ls-1) == " " || at(ls-1) == "\t") { + ls-- + } + le := stop + for le < size && (at(le) == " " || at(le) == "\t" || at(le) == "\r") { + le++ + } + if (ls == 0 || at(ls-1) == "\n") && (le == size || at(le) == "\n") { + if le < size { + le++ // and its newline + } + return ast.TextEdit{Start: ls, Stop: le} + } + return ast.TextEdit{Start: start, Stop: stop} +}