From 94a3b6bb4dbff189f1a7fd2a413b8c5482f041a3 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 05:08:17 +0000 Subject: [PATCH 01/35] mdl: R2 brackets for integration documents (properties in ( ), children in { }) REST client operations and agent attachments take their properties in ( ); image collections and message definition collections put their children in { }. The old spellings keep parsing as registered deprecated aliases MDL-DEPR070..073, recorded once per statement, with fmt --upgrade rewrites. describe emits the canonical forms. Part of #754. Co-Authored-By: Claude Opus 5.5 --- mdl/deprecation/deprecation.go | 69 ++++++++ mdl/executor/cmd_agenteditor_agents.go | 18 +- mdl/executor/cmd_imagecollections.go | 12 +- mdl/executor/cmd_messagedefinitions.go | 27 ++- mdl/executor/cmd_misc.go | 3 +- mdl/executor/cmd_rest_clients.go | 6 +- mdl/executor/r2_children_describe_test.go | 141 +++++++++++++++ mdl/grammar/domains/MDLAgent.g4 | 23 ++- mdl/grammar/domains/MDLDomainModel.g4 | 50 ++++-- mdl/grammar/domains/MDLService.g4 | 6 +- mdl/upgrade/r2_children_test.go | 84 +++++++++ mdl/visitor/r2_children_test.go | 205 ++++++++++++++++++++++ mdl/visitor/visitor_deprecations_test.go | 2 +- mdl/visitor/visitor_imagecollection.go | 44 ++++- mdl/visitor/visitor_messagedefinition.go | 32 ++-- mdl/visitor/visitor_r2_children.go | 106 +++++++++++ 16 files changed, 751 insertions(+), 77 deletions(-) create mode 100644 mdl/executor/r2_children_describe_test.go create mode 100644 mdl/upgrade/r2_children_test.go create mode 100644 mdl/visitor/r2_children_test.go create mode 100644 mdl/visitor/visitor_r2_children.go diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 36f7f253e2..c3be1bfcc1 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -157,6 +157,22 @@ const ( // on a text template: the placeholders bound by position (R4, // ako/mxcli#751). PositionalTemplateArguments = "MDL-DEPR009" + + // Codes 070-079 are R2's integration documents (ako/mxcli#754): properties + // in ( ), declarative children in { }. + + // RestOperationBraces is a consumed REST service's `operation X { … }`: + // the operation's properties in braces. + RestOperationBraces = "MDL-DEPR070" + // AgentAttachmentBraces is an agent's `tool X { … }`, `mcp service M.S + // { … }` or `knowledge base K { … }`: the attachment's properties in braces. + AgentAttachmentBraces = "MDL-DEPR071" + // ImageCollectionParens is an image collection's images in parentheses, + // each written `image X from file '…'`. + ImageCollectionParens = "MDL-DEPR072" + // MessageTreeParens is a message definition collection's definitions and + // member trees in parentheses. + MessageTreeParens = "MDL-DEPR073" ) // entries is the registry. Append only: a code is never reused or renumbered, @@ -374,6 +390,59 @@ var entries = []Entry{ func init() { entries = append(entries, r8Entries...) + entries = append(entries, r2Entries...) +} + +// r2Entries are R2's integration-document brackets (ako/mxcli#754). +var r2Entries = []Entry{ + { + Code: RestOperationBraces, + Old: "operation X { Method: get, … }", + Canonical: "operation X ( Method: get, … )", + Rewrite: Rewrite{Structural: "the operation's braces become parentheses"}, + RemovedIn: 2, + Note: "An operation is a child of the service: its properties are in ( ) like every child's, " + + "and { } holds children (R2). A body or response mapping keeps its { } tree.", + Example: "create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) " + + "{ operation GetUser { Method: get, Path: '/u', Response: none } };", + CanonicalExample: "create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) " + + "{ operation GetUser ( Method: get, Path: '/u', Response: none ) };", + }, + { + Code: AgentAttachmentBraces, + Old: "tool X { … } / mcp service M.S { … } / knowledge base K { … }", + Canonical: "tool X ( … ) / mcp service M.S ( … ) / knowledge base K ( … )", + Rewrite: Rewrite{Structural: "the attachment's braces become parentheses"}, + RemovedIn: 2, + Note: "In create agent and in alter agent … add. An attachment is a child of the agent: its properties are in ( ) (R2).", + Example: "create agent M.A (UsageType: Task, Model: M.Gpt, SystemPrompt: 'x') " + + "{ tool Lookup { Description: 'Find', Enabled: true } };", + CanonicalExample: "create agent M.A (UsageType: Task, Model: M.Gpt, SystemPrompt: 'x') " + + "{ tool Lookup ( Description: 'Find', Enabled: true ) };", + }, + { + Code: ImageCollectionParens, + Old: "image collection M.C ( image X from file '…', … )", + Canonical: "image collection M.C { image X ( File: '…' ) … }", + Rewrite: Rewrite{Structural: "the images move into { } without commas, and `from file '…'` " + + "becomes `( File: '…' )`"}, + RemovedIn: 2, + Note: "The images are the collection's children, so they are in { }, each with its properties in ( ) (R2).", + Example: "create image collection M.Icons (image Logo from file 'logo.png', image Home from file 'home.png');", + CanonicalExample: "create image collection M.Icons {image Logo ( File: 'logo.png' ) image Home ( File: 'home.png' )};", + }, + { + Code: MessageTreeParens, + Old: "message definition collection M.C ( definition D for M.E ( A, M.E_B/M.B ( C ) ) )", + Canonical: "message definition collection M.C { definition D for M.E { A, M.E_B/M.B { C } } }", + Rewrite: Rewrite{Structural: "each parenthesised definition list and member tree moves into { }"}, + RemovedIn: 2, + Note: "The definitions and members are children, so they are in { }, as in an import or export " + + "mapping (R2). Also in `alter message definition collection … add definition` and " + + "`alter message definition … add member`. One warning per statement.", + Example: "create message definition collection M.Msgs (definition Order for M.Order (Number, M.Order_Line/M.Line (Sku)));", + CanonicalExample: "create message definition collection M.Msgs {definition Order for M.Order {Number, M.Order_Line/M.Line {Sku}}};", + }, } // r8Entries are R8's spellings (ako/mxcli#752). Kept apart from the list above diff --git a/mdl/executor/cmd_agenteditor_agents.go b/mdl/executor/cmd_agenteditor_agents.go index c2766868c4..4ffe33a692 100644 --- a/mdl/executor/cmd_agenteditor_agents.go +++ b/mdl/executor/cmd_agenteditor_agents.go @@ -166,7 +166,8 @@ func describeAgentEditorAgent(ctx *ExecContext, name ast.QualifiedName) error { return nil } -// emitToolBlock writes one TOOL or MCP SERVICE block for the agent body. +// emitToolBlock writes one tool or mcp service child of the agent body, its +// properties in ( ) (R2). func emitToolBlock(ctx *ExecContext, t agenteditor.AgentTool) { switch t.ToolType { case "mcp": @@ -174,7 +175,7 @@ func emitToolBlock(ctx *ExecContext, t agenteditor.AgentTool) { // malformed — skip return } - fmt.Fprintf(ctx.Output, " mcp service %s {\n", t.Document.QualifiedName) + fmt.Fprintf(ctx.Output, " mcp service %s (\n", t.Document.QualifiedName) // The comma belongs to Enabled when a Description follows it; without // it the block was a parse error (ako/mxcli#707). Same shape as the // generic tool block below. @@ -185,14 +186,14 @@ func emitToolBlock(ctx *ExecContext, t agenteditor.AgentTool) { } else { fmt.Fprintln(ctx.Output) } - fmt.Fprintln(ctx.Output, " }") + fmt.Fprintln(ctx.Output, " )") default: // Microflow or unknown tool type — emit generic TOOL block. name := t.Name if name == "" { name = "Tool_" + strings.ReplaceAll(t.ID, "-", "")[:8] } - fmt.Fprintf(ctx.Output, " tool %s {\n", name) + fmt.Fprintf(ctx.Output, " tool %s (\n", name) if t.ToolType != "" { fmt.Fprintf(ctx.Output, " ToolType: %s,\n", t.ToolType) } @@ -206,17 +207,18 @@ func emitToolBlock(ctx *ExecContext, t agenteditor.AgentTool) { } else { fmt.Fprintln(ctx.Output) } - fmt.Fprintln(ctx.Output, " }") + fmt.Fprintln(ctx.Output, " )") } } -// emitKBBlock writes one KNOWLEDGE BASE block for the agent body. +// emitKBBlock writes one knowledge base child of the agent body, its +// properties in ( ) (R2). func emitKBBlock(ctx *ExecContext, kb agenteditor.AgentKBTool) { name := kb.Name if name == "" { name = "KB_" + strings.ReplaceAll(kb.ID, "-", "")[:8] } - fmt.Fprintf(ctx.Output, " knowledge base %s {\n", name) + fmt.Fprintf(ctx.Output, " knowledge base %s (\n", name) if kb.Document != nil && kb.Document.QualifiedName != "" { fmt.Fprintf(ctx.Output, " Source: %s,\n", kb.Document.QualifiedName) } @@ -230,7 +232,7 @@ func emitKBBlock(ctx *ExecContext, kb agenteditor.AgentKBTool) { fmt.Fprintf(ctx.Output, " Description: %s,\n", mdlQuoted(kb.Description)) } fmt.Fprintf(ctx.Output, " Enabled: %t\n", kb.Enabled) - fmt.Fprintln(ctx.Output, " }") + fmt.Fprintln(ctx.Output, " )") } // findAgentEditorAgent looks up an agent by module and name. diff --git a/mdl/executor/cmd_imagecollections.go b/mdl/executor/cmd_imagecollections.go index 4473689951..a723840ffc 100644 --- a/mdl/executor/cmd_imagecollections.go +++ b/mdl/executor/cmd_imagecollections.go @@ -164,9 +164,9 @@ func describeImageCollection(ctx *ExecContext, name ast.QualifiedName) error { if exportLevel != "Hidden" { fmt.Fprintf(ctx.Output, " export level '%s'", exportLevel) } - fmt.Fprintln(ctx.Output, " (") + fmt.Fprintln(ctx.Output, " {") - for i, img := range ic.Images { + for _, img := range ic.Images { ext := imageFormatToExt(img.Format) filePath := filepath.Join(previewDir, img.Name+ext) if len(img.Data) > 0 { @@ -175,14 +175,10 @@ func describeImageCollection(ctx *ExecContext, name ast.QualifiedName) error { } } - comma := "," - if i == len(ic.Images)-1 { - comma = "" - } - fmt.Fprintf(ctx.Output, " image %s from file '%s'%s\n", img.Name, filePath, comma) + fmt.Fprintf(ctx.Output, " image %s ( File: '%s' )\n", img.Name, filePath) } - fmt.Fprintln(ctx.Output, ");") + fmt.Fprintln(ctx.Output, "};") return nil } diff --git a/mdl/executor/cmd_messagedefinitions.go b/mdl/executor/cmd_messagedefinitions.go index c1d0142f81..fb0040c79e 100644 --- a/mdl/executor/cmd_messagedefinitions.go +++ b/mdl/executor/cmd_messagedefinitions.go @@ -561,26 +561,25 @@ func execDescribeMessageDefinitionCollection(ctx *ExecContext, name ast.Qualifie fmt.Fprintf(ctx.Output, " folder '%s'\n", folder) } } - fmt.Fprintln(ctx.Output, "(") - for i, def := range c.Definitions { - sep := "," - if i == len(c.Definitions)-1 { - sep = "" - } - describeMessageDefinition(ctx, def, sep) + fmt.Fprintln(ctx.Output, "{") + for _, def := range c.Definitions { + describeMessageDefinition(ctx, def) } - fmt.Fprintln(ctx.Output, ");") + fmt.Fprintln(ctx.Output, "};") return nil } -func describeMessageDefinition(ctx *ExecContext, def *model.MessageDefinition, sep string) { +// describeMessageDefinition prints one definition. Definitions and member +// trees are children, so they are in { } (R2); a definition ends in `}`, so the +// definitions need no separator. +func describeMessageDefinition(ctx *ExecContext, def *model.MessageDefinition) { if def == nil || def.Root == nil { return } - fmt.Fprintf(ctx.Output, " definition %s for %s%s (\n", + fmt.Fprintf(ctx.Output, " definition %s for %s%s {\n", def.Name, def.Root.Entity, exposedClause(def.Root, shortEntityName(def.Root.Entity))) describeMessageMembers(ctx, def.Root.Children, " ") - fmt.Fprintf(ctx.Output, " )%s\n", sep) + fmt.Fprintln(ctx.Output, " }") } func describeMessageMembers(ctx *ExecContext, members []*model.MessageDefinitionElement, indent string) { @@ -594,13 +593,13 @@ func describeMessageMembers(ctx *ExecContext, members []*model.MessageDefinition fmt.Fprintf(ctx.Output, "%s%s%s%s%s\n", indent, m.OriginalName, exposedClause(m, m.OriginalName), exampleClause(m), sep) case len(m.Children) == 0: - fmt.Fprintf(ctx.Output, "%s%s/%s%s ()%s\n", indent, m.Association, m.Entity, + fmt.Fprintf(ctx.Output, "%s%s/%s%s { }%s\n", indent, m.Association, m.Entity, exposedClause(m, shortEntityName(m.Entity)), sep) default: - fmt.Fprintf(ctx.Output, "%s%s/%s%s (\n", indent, m.Association, m.Entity, + fmt.Fprintf(ctx.Output, "%s%s/%s%s {\n", indent, m.Association, m.Entity, exposedClause(m, shortEntityName(m.Entity))) describeMessageMembers(ctx, m.Children, indent+" ") - fmt.Fprintf(ctx.Output, "%s)%s\n", indent, sep) + fmt.Fprintf(ctx.Output, "%s}%s\n", indent, sep) } } } diff --git a/mdl/executor/cmd_misc.go b/mdl/executor/cmd_misc.go index 5bf1f1395e..a67ea623be 100644 --- a/mdl/executor/cmd_misc.go +++ b/mdl/executor/cmd_misc.go @@ -338,8 +338,7 @@ Image Collections: create image collection Module.Name [export level 'Hidden'|'Public'] [comment 'description'] - [(image Name from file 'path', ...)]; - / + [{ image Name ( File: 'path' ) ... }]; drop image collection Module.Name; show image collection [in Module]; diff --git a/mdl/executor/cmd_rest_clients.go b/mdl/executor/cmd_rest_clients.go index 7d50dd84dc..5235d58e19 100644 --- a/mdl/executor/cmd_rest_clients.go +++ b/mdl/executor/cmd_rest_clients.go @@ -145,13 +145,13 @@ func outputConsumedRestServiceMDL(ctx *ExecContext, svc *model.ConsumedRestServi return nil } -// outputRestOperation writes a single operation in the new { Key: Value } format. +// outputRestOperation writes a single operation, its properties in ( ) (R2). func outputRestOperation(w io.Writer, op *model.RestClientOperation) { if op.Documentation != "" { outputJavadocIndented(w, op.Documentation, " ") } - fmt.Fprintf(w, " operation %s {\n", op.Name) + fmt.Fprintf(w, " operation %s (\n", op.Name) fmt.Fprintf(w, " Method: %s,\n", strings.ToLower(op.HttpMethod)) fmt.Fprintf(w, " Path: %s,\n", mdlQuoted(op.Path)) @@ -235,7 +235,7 @@ func outputRestOperation(w io.Writer, op *model.RestClientOperation) { fmt.Fprintln(w, " Response: none") } - fmt.Fprintln(w, " }") + fmt.Fprintln(w, " )") } // restParamTypeOrDefault supplies the type describe prints for a REST parameter. diff --git a/mdl/executor/r2_children_describe_test.go b/mdl/executor/r2_children_describe_test.go new file mode 100644 index 0000000000..f982ac9d1f --- /dev/null +++ b/mdl/executor/r2_children_describe_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/agenteditor" +) + +// R2 (ako/mxcli#754): describe prints the canonical brackets — properties in +// ( ), children in { } — so its output re-parses without recording any +// deprecated spelling (MDL-DEPR070..073). + +func assertCanonicalDescribe(t *testing.T, out string, wantInOutput ...string) { + t.Helper() + prog := reparse(t, out) + if len(prog.Deprecations) != 0 { + t.Errorf("describe output uses a deprecated spelling: %+v\n--- output ---\n%s", prog.Deprecations, out) + } + for _, w := range wantInOutput { + if !strings.Contains(out, w) { + t.Errorf("describe output lacks %q:\n%s", w, out) + } + } +} + +func TestDescribeRestOperation_PropertiesInParens(t *testing.T) { + svc := &model.ConsumedRestService{ + Name: "Api", + BaseUrl: "https://api.example.com", + Operations: []*model.RestClientOperation{ + {Name: "GetUser", HttpMethod: "GET", Path: "/users/{id}", ResponseType: "NONE", + Parameters: []*model.RestClientParameter{{Name: "id", DataType: "Integer"}}}, + {Name: "Ping", HttpMethod: "GET", Path: "/ping", ResponseType: "NONE"}, + }, + } + ctx, buf := newMockCtx(t) + assertNoError(t, outputConsumedRestServiceMDL(ctx, svc, "M")) + assertCanonicalDescribe(t, buf.String(), "operation GetUser (", "operation Ping (") +} + +func TestDescribeAgentAttachments_PropertiesInParens(t *testing.T) { + mod := mkModule("M") + a := &agenteditor.Agent{ + BaseElement: model.BaseElement{ID: nextID("aea")}, + ContainerID: mod.ID, + Name: "Helper", + UsageType: "Task", + Model: &agenteditor.DocRef{QualifiedName: "M.GPT4"}, + Tools: []agenteditor.AgentTool{ + {Name: "Weather", ToolType: "mcp", Enabled: true, Document: &agenteditor.DocRef{QualifiedName: "M.WeatherMcp"}}, + {Name: "Lookup", ToolType: "Microflow", Enabled: true, Description: "Find", + Document: &agenteditor.DocRef{QualifiedName: "M.Lookup"}}, + }, + KBTools: []agenteditor.AgentKBTool{ + {Name: "Docs", Enabled: true, Document: &agenteditor.DocRef{QualifiedName: "M.KB"}, + CollectionIdentifier: "docs", MaxResults: 3}, + }, + } + h := mkHierarchy(mod) + withContainer(h, a.ContainerID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListAgentEditorAgentsFunc: func() ([]*agenteditor.Agent, error) { return []*agenteditor.Agent{a}, nil }, + } + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, describeAgentEditorAgent(ctx, ast.QualifiedName{Module: "M", Name: "Helper"})) + assertCanonicalDescribe(t, buf.String(), "mcp service M.WeatherMcp (", "tool Lookup (", "knowledge base Docs (") +} + +func TestDescribeImageCollection_ImagesInBraces(t *testing.T) { + mod := mkModule("Icons") + ic := &types.ImageCollection{ + BaseElement: model.BaseElement{ID: nextID("ic")}, + ContainerID: mod.ID, + Name: "AppIcons", + ExportLevel: "Public", + Images: []types.Image{{Name: "Logo", Format: "Png"}, {Name: "Home", Format: "Svg"}}, + } + h := mkHierarchy(mod) + withContainer(h, ic.ContainerID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListImageCollectionsFunc: func() ([]*types.ImageCollection, error) { return []*types.ImageCollection{ic}, nil }, + } + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, describeImageCollection(ctx, ast.QualifiedName{Module: "Icons", Name: "AppIcons"})) + out := buf.String() + assertCanonicalDescribe(t, out, "export level 'Public' {", "image Logo ( File: ", "image Home ( File: ") + s := findStmt[*ast.CreateImageCollectionStmt](t, reparse(t, out), out) + if len(s.Images) != 2 || s.Images[0].Name != "Logo" || s.Images[1].Name != "Home" { + t.Errorf("images = %+v", s.Images) + } +} + +func TestDescribeMessageDefinitionCollection_TreesInBraces(t *testing.T) { + mod := mkModule("Sales") + c := &model.MessageDefinitionCollection{ + BaseElement: model.BaseElement{ID: nextID("mdc")}, + ContainerID: mod.ID, + Name: "MD_Order", + Definitions: []*model.MessageDefinition{ + {Name: "Order", Root: &model.MessageDefinitionElement{Kind: "Entity", Entity: "Sales.Order", ExposedName: "Order", + Children: []*model.MessageDefinitionElement{ + {Kind: "Attribute", Attribute: "OrderId", OriginalName: "OrderId", ExposedName: "OrderId"}, + {Kind: "Entity", Association: "Sales.Order_Line", Entity: "Sales.Line", ExposedName: "Lines", + Children: []*model.MessageDefinitionElement{ + {Kind: "Attribute", Attribute: "Sku", OriginalName: "Sku", ExposedName: "Sku"}, + }}, + {Kind: "Entity", Association: "Sales.Order_Customer", Entity: "Sales.Customer", ExposedName: "Customer"}, + }}}, + {Name: "Customer", Root: &model.MessageDefinitionElement{Kind: "Entity", Entity: "Sales.Customer", ExposedName: "Customer", + Children: []*model.MessageDefinitionElement{ + {Kind: "Attribute", Attribute: "Name", OriginalName: "Name", ExposedName: "Name"}, + }}}, + }, + } + h := mkHierarchy(mod) + withContainer(h, c.ContainerID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListMessageDefinitionCollectionsFunc: func() ([]*model.MessageDefinitionCollection, error) { + return []*model.MessageDefinitionCollection{c}, nil + }, + } + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, execDescribeMessageDefinitionCollection(ctx, ast.QualifiedName{Module: "Sales", Name: "MD_Order"})) + out := buf.String() + assertCanonicalDescribe(t, out, "definition Order for Sales.Order {", "Sales.Order_Line/Sales.Line as 'Lines' {", + "Sales.Order_Customer/Sales.Customer { }") + s := findStmt[*ast.CreateMessageDefinitionCollectionStmt](t, reparse(t, out), out) + if len(s.Definitions) != 2 || len(s.Definitions[0].Members) != 3 || len(s.Definitions[0].Members[1].Members) != 1 { + t.Errorf("definitions = %+v", s.Definitions) + } +} diff --git a/mdl/grammar/domains/MDLAgent.g4 b/mdl/grammar/domains/MDLAgent.g4 index fb3cfc61b8..ff39f295e3 100644 --- a/mdl/grammar/domains/MDLAgent.g4 +++ b/mdl/grammar/domains/MDLAgent.g4 @@ -75,7 +75,7 @@ createKnowledgeBaseStatement // SystemPrompt: '...', // ... // ) -// [ { TOOL ... | MCP SERVICE ... | KNOWLEDGE BASE ... } ] +// [ { tool X ( ... ) | mcp service M.X ( ... ) | knowledge base KB ( ... ) } ] // ; createAgentStatement : AGENT qualifiedName @@ -88,10 +88,21 @@ agentBody : LBRACE agentBodyBlock* RBRACE ; +// An attachment is a child of the agent, so its properties are in ( ) like +// every other child's (R2, ako/mxcli#754). The brace form is the old spelling. agentBodyBlock - : MCP SERVICE qualifiedName LBRACE modelProperty (COMMA modelProperty)* RBRACE // MCP SERVICE Mod.Name { ... } - | KNOWLEDGE BASE identifierOrKeyword LBRACE modelProperty (COMMA modelProperty)* RBRACE // KNOWLEDGE BASE MyKB { ... } - | TOOL identifierOrKeyword LBRACE modelProperty (COMMA modelProperty)* RBRACE // TOOL ToolName { ... } + : MCP SERVICE qualifiedName + ( LPAREN modelProperty (COMMA modelProperty)* COMMA? RPAREN // mcp service Mod.Name ( ... ) + | LBRACE /* @alias MDL-DEPR071 */ modelProperty (COMMA modelProperty)* COMMA? RBRACE + ) + | KNOWLEDGE BASE identifierOrKeyword + ( LPAREN modelProperty (COMMA modelProperty)* COMMA? RPAREN // knowledge base MyKB ( ... ) + | LBRACE /* @alias MDL-DEPR071 */ modelProperty (COMMA modelProperty)* COMMA? RBRACE + ) + | TOOL identifierOrKeyword + ( LPAREN modelProperty (COMMA modelProperty)* COMMA? RPAREN // tool ToolName ( ... ) + | LBRACE /* @alias MDL-DEPR071 */ modelProperty (COMMA modelProperty)* COMMA? RBRACE + ) ; // ============================================================================= @@ -120,8 +131,8 @@ agentEditorAlterValue // ```mdl // ALTER AGENT MyModule.Helper // SET SystemPrompt = 'New prompt', Temperature = 0.5 -// ADD TOOL DoSomething { Description: '...', Enabled: true } -// ADD MCP SERVICE MyModule.Weather { Description: '...', Enabled: true } +// ADD TOOL DoSomething ( Description: '...', Enabled: true ) +// ADD MCP SERVICE MyModule.Weather ( Description: '...', Enabled: true ) // DROP KNOWLEDGE BASE OldKB // ; // ``` diff --git a/mdl/grammar/domains/MDLDomainModel.g4 b/mdl/grammar/domains/MDLDomainModel.g4 index 65ae1ff81e..c5afc251b8 100644 --- a/mdl/grammar/domains/MDLDomainModel.g4 +++ b/mdl/grammar/domains/MDLDomainModel.g4 @@ -513,8 +513,23 @@ imageCollectionOption | COMMENT STRING_LITERAL ; +// The images are the collection's children, so they are in { }, each with its +// properties in ( ) (R2, ako/mxcli#754): +// +// create image collection M.Icons { image Logo ( File: 'assets/logo.png' ) }; +// +// The parenthesised list of `image X from file '…'` is the old spelling. imageCollectionBody - : LPAREN imageCollectionItem (COMMA imageCollectionItem)* RPAREN + : LBRACE imageCollectionChild* RBRACE + | LPAREN /* @alias MDL-DEPR072 */ imageCollectionItem (COMMA imageCollectionItem)* RPAREN + ; + +imageCollectionChild + : IMAGE imageName LPAREN imageProperty (COMMA imageProperty)* COMMA? RPAREN // image Logo ( File: 'logo.png' ) + ; + +imageProperty + : identifierOrKeyword COLON STRING_LITERAL ; imageCollectionItem @@ -568,12 +583,12 @@ customNameMapping /** * CREATE [OR MODIFY] MESSAGE DEFINITION COLLECTION Module.Name * FOLDER 'Private/Messages' - * ( - * definition Order for Sales.Order as 'Orders' ( + * { + * definition Order for Sales.Order as 'Orders' { * OrderId, - * Sales.Order_Line/Sales.Line as 'Lines' ( Sku, Quantity ) - * ) - * ); + * Sales.Order_Line/Sales.Line as 'Lines' { Sku, Quantity } + * } + * }; * * A message definition is a SELECTION OVER THE DOMAIN MODEL — every element * names an entity, an attribute or an association — which is what makes it @@ -588,18 +603,27 @@ customNameMapping createMessageDefinitionCollectionStatement : MESSAGE DEFINITION COLLECTION qualifiedName (FOLDER STRING_LITERAL)? - LPAREN messageDefinitionDef (COMMA messageDefinitionDef)* COMMA? RPAREN + ( LBRACE messageDefinitionDef (COMMA? messageDefinitionDef)* COMMA? RBRACE + | LPAREN /* @alias MDL-DEPR073 */ messageDefinitionDef (COMMA messageDefinitionDef)* COMMA? RPAREN + ) ; /** - * `definition for [as ''] ( members )` + * `definition for [as ''] { members }` * * The definition's Name and its root element's exposed name are independent — * measured, 19 of 56 definitions are named something other than their entity. */ messageDefinitionDef - : DEFINITION identifierOrKeyword FOR qualifiedName messageExposedName? - LPAREN messageMember (COMMA messageMember)* COMMA? RPAREN + : DEFINITION identifierOrKeyword FOR qualifiedName messageExposedName? messageMemberTree + ; + +// A member tree is children, so it is in { }, as in an import or export +// mapping (R2, ako/mxcli#754). The parenthesised tree is the old spelling. An +// association may select no member of its target. +messageMemberTree + : LBRACE (messageMember (COMMA messageMember)* COMMA?)? RBRACE + | LPAREN /* @alias MDL-DEPR073 */ messageMember (COMMA messageMember)* COMMA? RPAREN ; /** @@ -616,8 +640,7 @@ messageDefinitionDef * something a reader has to work out. */ messageMember - : qualifiedName SLASH qualifiedName messageExposedName? - LPAREN messageMember (COMMA messageMember)* COMMA? RPAREN // association + : qualifiedName SLASH qualifiedName messageExposedName? messageMemberTree // association | identifierOrKeyword messageExposedName? messageExample? // attribute ; @@ -644,8 +667,7 @@ alterMessageDefinitionCollectionStatement ; alterMessageCollectionOperation - : ADD DEFINITION (IF NOT EXISTS)? identifierOrKeyword FOR qualifiedName messageExposedName? - LPAREN messageMember (COMMA messageMember)* COMMA? RPAREN + : ADD DEFINITION (IF NOT EXISTS)? identifierOrKeyword FOR qualifiedName messageExposedName? messageMemberTree | DROP DEFINITION (IF EXISTS)? identifierOrKeyword | RENAME DEFINITION identifierOrKeyword TO identifierOrKeyword ; diff --git a/mdl/grammar/domains/MDLService.g4 b/mdl/grammar/domains/MDLService.g4 index a44bb27775..4fcd188592 100644 --- a/mdl/grammar/domains/MDLService.g4 +++ b/mdl/grammar/domains/MDLService.g4 @@ -63,10 +63,14 @@ restClientProperty | identifierOrKeyword COLON BASIC LPAREN restClientProperty (COMMA restClientProperty)* RPAREN ; +// An operation is a child of the service, so its properties are in ( ) like +// every other child's (R2, ako/mxcli#754). The brace form is the old spelling. restClientOperation : docComment? OPERATION (identifierOrKeyword | STRING_LITERAL) - LBRACE restClientOpProp (COMMA restClientOpProp)* RBRACE + ( LPAREN restClientOpProp (COMMA restClientOpProp)* COMMA? RPAREN + | LBRACE /* @alias MDL-DEPR070 */ restClientOpProp (COMMA restClientOpProp)* COMMA? RBRACE + ) ; restClientOpProp diff --git a/mdl/upgrade/r2_children_test.go b/mdl/upgrade/r2_children_test.go new file mode 100644 index 0000000000..182cc14dd2 --- /dev/null +++ b/mdl/upgrade/r2_children_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// R2 (ako/mxcli#754): the integration documents' old brackets are rewritten to +// properties in ( ) and children in { }, every pair in a statement at once, +// with the body mappings, headers and comments left as written. +func TestUpgrade_R2IntegrationDocumentBrackets(t *testing.T) { + src := `create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) +{ + /** Fetch one user */ + operation GetUser { + Method: get, + Path: '/u/{id}', + Headers: ('Accept' = 'application/json'), + Response: mapping M.User { Name = name } + } + operation Ping { Method: get, Path: '/ping', Response: none } +}; +CREATE AGENT M.A (UsageType: Task, Model: M.Gpt, SystemPrompt: 'x') { + TOOL Lookup { Description: 'Find' } -- the lookup tool +}; +alter agent M.A add knowledge base Docs { Source: M.KB }; +create image collection M.Icons export level 'Public' ( + image Logo from file 'logo.png', + image Home from file 'home.png' +); +create message definition collection M.Msgs ( + definition Order for M.Order ( + Number, + M.Order_Line/M.Line as 'Lines' ( Sku ) + ) +); +alter message definition M.Msgs.Order add member M.Order_Tag/M.Tag ( Label ); +` + want := `create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) +{ + /** Fetch one user */ + operation GetUser ( + Method: get, + Path: '/u/{id}', + Headers: ('Accept' = 'application/json'), + Response: mapping M.User { Name = name } + ) + operation Ping ( Method: get, Path: '/ping', Response: none ) +}; +CREATE AGENT M.A (UsageType: Task, Model: M.Gpt, SystemPrompt: 'x') { + TOOL Lookup ( Description: 'Find' ) -- the lookup tool +}; +alter agent M.A add knowledge base Docs ( Source: M.KB ); +create image collection M.Icons export level 'Public' { + image Logo ( File: 'logo.png' ) + image Home ( File: 'home.png' ) +}; +create message definition collection M.Msgs { + definition Order for M.Order { + Number, + M.Order_Line/M.Line as 'Lines' { Sku } + } +}; +alter message definition M.Msgs.Order add member M.Order_Tag/M.Tag { Label }; +` + res := mustUpgrade(t, src, Options{}) + if res.Source != want { + t.Fatalf("got:\n%s\nwant:\n%s", res.Source, want) + } + for code, n := range map[string]int{ + deprecation.RestOperationBraces: 1, deprecation.AgentAttachmentBraces: 2, + deprecation.ImageCollectionParens: 1, deprecation.MessageTreeParens: 2, + } { + if res.Rewritten[code] != n { + t.Errorf("Rewritten[%s] = %d, want %d (all: %v)", code, res.Rewritten[code], n, res.Rewritten) + } + } + if again := mustUpgrade(t, res.Source, Options{}); again.Changed() { + t.Errorf("second upgrade changed the script again: %v", again.Rewritten) + } +} diff --git a/mdl/visitor/r2_children_test.go b/mdl/visitor/r2_children_test.go new file mode 100644 index 0000000000..c6f95b2475 --- /dev/null +++ b/mdl/visitor/r2_children_test.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// R2 (ako/mxcli#754): properties in ( ), declarative children in { }. For each +// integration document the canonical form must build without a warning under +// both versions, and the old form must build the SAME statement, record its +// code once, and carry a rewrite. + +type r2Case struct { + name string + code string + old string + canonical string +} + +var r2Cases = []r2Case{ + { + name: "rest operation", + code: deprecation.RestOperationBraces, + old: `create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) { + operation GetUser { Method: get, Path: '/u/{id}', Parameters: ($id: Integer), Headers: ('Accept' = 'application/json'), Response: none } + operation Ping { Method: get, Path: '/ping', Response: none } +};`, + canonical: `create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) { + operation GetUser ( Method: get, Path: '/u/{id}', Parameters: ($id: Integer), Headers: ('Accept' = 'application/json'), Response: none, ) + operation Ping ( Method: get, Path: '/ping', Response: none ) +};`, + }, + { + name: "agent attachments", + code: deprecation.AgentAttachmentBraces, + old: `create agent M.A (UsageType: Task, Model: M.Gpt, SystemPrompt: 'x') { + mcp service M.Weather { Enabled: true } + knowledge base Docs { Source: M.KB, Collection: 'docs', MaxResults: 3 } + tool Lookup { Description: 'Find', Enabled: true } +};`, + canonical: `create agent M.A (UsageType: Task, Model: M.Gpt, SystemPrompt: 'x') { + mcp service M.Weather ( Enabled: true ) + knowledge base Docs ( Source: M.KB, Collection: 'docs', MaxResults: 3, ) + tool Lookup ( Description: 'Find', Enabled: true ) +};`, + }, + { + name: "alter agent add", + code: deprecation.AgentAttachmentBraces, + old: `alter agent M.A add tool Lookup { Description: 'Find' };`, + canonical: `alter agent M.A add tool Lookup ( Description: 'Find' );`, + }, + { + name: "image collection", + code: deprecation.ImageCollectionParens, + old: `create image collection M.Icons export level 'Public' ( + image Logo from file 'assets/logo.png', + image "Home" from file 'assets/home.png' +);`, + canonical: `create image collection M.Icons export level 'Public' { + image Logo ( File: 'assets/logo.png' ) + image "Home" ( File: 'assets/home.png', ) +};`, + }, + { + name: "message definition collection", + code: deprecation.MessageTreeParens, + old: `create message definition collection M.Msgs folder 'Messages' ( + definition Order for M.Order as 'Orders' ( + Number, + M.Order_Line/M.Line as 'Lines' ( Sku, Quantity example '3' ) + ), + definition Customer for M.Customer ( Name ) +);`, + canonical: `create message definition collection M.Msgs folder 'Messages' { + definition Order for M.Order as 'Orders' { + Number, + M.Order_Line/M.Line as 'Lines' { Sku, Quantity example '3' } + } + definition Customer for M.Customer { Name } +};`, + }, + { + name: "add definition", + code: deprecation.MessageTreeParens, + old: `alter message definition collection M.Msgs add definition X for M.X as 'Xs' ( A, M.X_Y/M.Y ( B ) );`, + canonical: `alter message definition collection M.Msgs add definition X for M.X as 'Xs' { A, M.X_Y/M.Y { B } };`, + }, + { + name: "add member", + code: deprecation.MessageTreeParens, + old: `alter message definition M.Msgs.Order add member M.Order_Line/M.Line ( Sku );`, + canonical: `alter message definition M.Msgs.Order add member M.Order_Line/M.Line { Sku };`, + }, +} + +func buildNoErrors(t *testing.T, src string) *ast.Program { + t.Helper() + prog, errs := Build(src) + if len(errs) > 0 { + t.Fatalf("%q: unexpected errors: %v", src, errs) + } + return prog +} + +func TestR2Children_CanonicalFormBuildsWithoutWarning(t *testing.T) { + for _, c := range r2Cases { + for _, header := range []string{"", "mdl 1;\n"} { + prog := buildNoErrors(t, header+c.canonical) + if len(prog.Statements) != 1 { + t.Errorf("%s, header %q: %d statements", c.name, header, len(prog.Statements)) + } + if len(prog.Deprecations) != 0 { + t.Errorf("%s, header %q: the canonical form recorded %+v", c.name, header, prog.Deprecations) + } + } + } +} + +func TestR2Children_OldFormIsADeprecatedAlias(t *testing.T) { + for _, c := range r2Cases { + for _, header := range []string{"", "mdl 1;\n"} { + old := buildNoErrors(t, header+c.old) + canon := buildNoErrors(t, header+c.canonical) + if n := countDeprecations(old, c.code); n != 1 { + t.Errorf("%s, header %q: recorded %s %d times, want once per statement (%+v)", c.name, header, c.code, n, old.Deprecations) + } + for _, d := range old.Deprecations { + if d.Code == c.code && (d.Fix == nil || len(d.Fix.Edits) == 0) { + t.Errorf("%s: %s carries no rewrite", c.name, c.code) + } + } + if !reflect.DeepEqual(old.Statements, canon.Statements) { + t.Errorf("%s, header %q: the two forms build different statements:\nold: %#v\ncanon: %#v", + c.name, header, old.Statements[0], canon.Statements[0]) + } + } + } +} + +// The recorded rewrite, applied to the old form, gives a script that records +// nothing and builds the same statement. +func TestR2Children_RewriteReachesTheCanonicalForm(t *testing.T) { + for _, c := range r2Cases { + prog := buildNoErrors(t, c.old) + out := applyDeprecationFixes(t, c.old, prog, c.code) + again := buildNoErrors(t, out) + if len(again.Deprecations) != 0 { + t.Errorf("%s: the rewrite still records %+v:\n%s", c.name, again.Deprecations, out) + } + if !reflect.DeepEqual(prog.Statements, again.Statements) { + t.Errorf("%s: the rewrite changed the statement:\n%s", c.name, out) + } + } +} + +func applyDeprecationFixes(t *testing.T, src string, prog *ast.Program, code string) string { + t.Helper() + runes := []rune(src) + var edits []ast.TextEdit + for _, d := range prog.Deprecations { + if d.Code == code && d.Fix != nil { + edits = append(edits, d.Fix.Edits...) + } + } + // Apply from the end so earlier offsets stay valid. + for i := 0; i < len(edits); i++ { + for j := i + 1; j < len(edits); j++ { + if edits[j].Start > edits[i].Start { + edits[i], edits[j] = edits[j], edits[i] + } + } + } + for _, e := range edits { + runes = append(runes[:e.Start], append([]rune(e.Text), runes[e.Stop:]...)...) + } + return string(runes) +} + +func TestR2Children_ImageRejectsUnknownOrMissingFile(t *testing.T) { + for src, want := range map[string]string{ + `create image collection M.I { image Logo ( Path: 'x.png' ) };`: "unknown property 'Path'", + `create image collection M.I { image Logo ( File: 'x.png', Size: 'big' ) };`: "unknown property 'Size'", + } { + _, errs := Build(src) + if len(errs) == 0 || !strings.Contains(errs[0].Error(), want) { + t.Errorf("%q: errors %v, want one containing %q", src, errs, want) + } + } +} + +func TestR2Children_EmptyAssociationTree(t *testing.T) { + prog := buildNoErrors(t, `create message definition collection M.Msgs { definition O for M.O { M.O_L/M.L { } } };`) + s := prog.Statements[0].(*ast.CreateMessageDefinitionCollectionStmt) + m := s.Definitions[0].Members[0] + if m.Entity.Name != "L" || len(m.Members) != 0 { + t.Errorf("member = %+v", m) + } +} diff --git a/mdl/visitor/visitor_deprecations_test.go b/mdl/visitor/visitor_deprecations_test.go index 34aa6436c7..b70b23a4de 100644 --- a/mdl/visitor/visitor_deprecations_test.go +++ b/mdl/visitor/visitor_deprecations_test.go @@ -99,7 +99,7 @@ var createOrReplaceCases = map[string]string{ "scheduledevent": "scheduled event M.NightlyCleanup (Microflow: M.SE_Cleanup, Repeat: Daily, HourOfDay: 4, MinuteOfHour: 0, TimeZone: Server, Enabled: true);", "regularexpression": "regular expression M.Email (Expression: '.+@.+');", "jsonstructure": "json structure M.JSON_Pet snippet '{\"id\": 1}';", - "messagedefinitioncollection": "message definition collection M.MD_Order (definition OrderMessage for M.Order as 'Orders' (OrderId));", + "messagedefinitioncollection": "message definition collection M.MD_Order {definition OrderMessage for M.Order as 'Orders' {OrderId}};", "importmapping": "import mapping M.IMM_Order with json structure M.JSON_Order { create M.Order { Id = id } };", "exportmapping": "export mapping M.EMM_Order with json structure M.JSON_Order { M.Order { orderId = OrderId } };", "configuration": "configuration 'Default';", diff --git a/mdl/visitor/visitor_imagecollection.go b/mdl/visitor/visitor_imagecollection.go index 59c5cd4582..2c9d3ce1b3 100644 --- a/mdl/visitor/visitor_imagecollection.go +++ b/mdl/visitor/visitor_imagecollection.go @@ -3,6 +3,9 @@ package visitor import ( + "fmt" + "strings" + "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/grammar/parser" ) @@ -35,15 +38,34 @@ func (b *Builder) ExitCreateImageCollectionStatement(ctx *parser.CreateImageColl if body := ctx.ImageCollectionBody(); body != nil { bodyCtx := body.(*parser.ImageCollectionBodyContext) + // { image Name ( File: '…' ) } — the canonical form. + for _, child := range bodyCtx.AllImageCollectionChild() { + childCtx := child.(*parser.ImageCollectionChildContext) + name := imageNameText(childCtx.ImageName()) + item := ast.ImageItem{Name: name} + hasFile := false + for _, p := range childCtx.AllImageProperty() { + pc := p.(*parser.ImagePropertyContext) + key := identifierOrKeywordText(pc.IdentifierOrKeyword()) + if !strings.EqualFold(key, "File") { + b.addError(fmt.Errorf("line %d: unknown property '%s' on image %s: an image takes only File: ''", + pc.GetStart().GetLine(), key, name)) + continue + } + item.FilePath = unquoteStringLit(pc.STRING_LITERAL()) + hasFile = true + } + if !hasFile { + b.addError(fmt.Errorf("line %d: image %s has no File: an image is `image %s ( File: '' )`", + childCtx.GetStart().GetLine(), name, name)) + } + stmt.Images = append(stmt.Images, item) + } + // ( image Name from file '…', … ) — the old spelling (MDL-DEPR072). for _, item := range bodyCtx.AllImageCollectionItem() { itemCtx := item.(*parser.ImageCollectionItemContext) - name := itemCtx.ImageName().GetText() - // Strip quotes from quoted identifiers ("Name" or `Name`) - if len(name) >= 2 && (name[0] == '"' || name[0] == '`') { - name = name[1 : len(name)-1] - } stmt.Images = append(stmt.Images, ast.ImageItem{ - Name: name, + Name: imageNameText(itemCtx.ImageName()), FilePath: unquoteStringLit(itemCtx.GetPath()), }) } @@ -56,3 +78,13 @@ func (b *Builder) ExitCreateImageCollectionStatement(ctx *parser.CreateImageColl b.statements = append(b.statements, stmt) } + +// imageNameText is an image's name, without the quotes of a quoted identifier +// ("Name" or `Name`). +func imageNameText(ctx parser.IImageNameContext) string { + name := ctx.GetText() + if len(name) >= 2 && (name[0] == '"' || name[0] == '`') { + name = name[1 : len(name)-1] + } + return name +} diff --git a/mdl/visitor/visitor_messagedefinition.go b/mdl/visitor/visitor_messagedefinition.go index c10602e967..560d93e7b9 100644 --- a/mdl/visitor/visitor_messagedefinition.go +++ b/mdl/visitor/visitor_messagedefinition.go @@ -13,7 +13,7 @@ import ( // // CREATE [OR MODIFY] MESSAGE DEFINITION COLLECTION Module.Name // [FOLDER 'path'] -// ( definition Name for Module.Entity [as 'Exposed'] ( members ), ... ); +// { definition Name for Module.Entity [as 'Exposed'] { members } ... }; func (b *Builder) ExitCreateMessageDefinitionCollectionStatement(ctx *parser.CreateMessageDefinitionCollectionStatementContext) { stmt := &ast.CreateMessageDefinitionCollectionStmt{ Name: buildQualifiedName(ctx.QualifiedName()), @@ -47,12 +47,24 @@ func (b *Builder) buildMessageDefinitionDef(c parser.IMessageDefinitionDefContex Entity: buildQualifiedName(ctx.QualifiedName()), ExposedName: exposedNameOf(ctx.MessageExposedName()), } + def.Members = b.buildMessageMemberTree(ctx.MessageMemberTree()) + return def +} + +// buildMessageMemberTree builds the members of a `{ … }` tree, or of the old +// `( … )` one (MDL-DEPR073). +func (b *Builder) buildMessageMemberTree(c parser.IMessageMemberTreeContext) []*ast.MessageMemberDef { + ctx, ok := c.(*parser.MessageMemberTreeContext) + if ctx == nil || !ok { + return nil + } + var out []*ast.MessageMemberDef for _, m := range ctx.AllMessageMember() { if mem := b.buildMessageMember(m); mem != nil { - def.Members = append(def.Members, mem) + out = append(out, mem) } } - return def + return out } // buildMessageMember builds an exposed attribute or an exposed association. @@ -68,16 +80,12 @@ func (b *Builder) buildMessageMember(c parser.IMessageMemberContext) *ast.Messag mem := &ast.MessageMemberDef{ExposedName: exposedNameOf(ctx.MessageExposedName())} if qns := ctx.AllQualifiedName(); len(qns) == 2 { - // Association: Assoc/Module.Entity ( members ). The target entity is + // Association: Assoc/Module.Entity { members }. The target entity is // spelled out because MaxOccurs tracks the DIRECTION of traversal, not // the association's type — see the AST doc comment. mem.Association = buildQualifiedName(qns[0]) mem.Entity = buildQualifiedName(qns[1]) - for _, sub := range ctx.AllMessageMember() { - if child := b.buildMessageMember(sub); child != nil { - mem.Members = append(mem.Members, child) - } - } + mem.Members = b.buildMessageMemberTree(ctx.MessageMemberTree()) return mem } @@ -125,11 +133,7 @@ func (b *Builder) ExitAlterMessageDefinitionCollectionStatement(ctx *parser.Alte if len(ids) > 0 { def.Name = identifierOrKeywordText(ids[0]) } - for _, m := range op.AllMessageMember() { - if mem := b.buildMessageMember(m); mem != nil { - def.Members = append(def.Members, mem) - } - } + def.Members = b.buildMessageMemberTree(op.MessageMemberTree()) stmt.Definition = def case op.RENAME() != nil: stmt.Op = "RENAME" diff --git a/mdl/visitor/visitor_r2_children.go b/mdl/visitor/visitor_r2_children.go new file mode 100644 index 0000000000..1b7e505b01 --- /dev/null +++ b/mdl/visitor/visitor_r2_children.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "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" +) + +// R2 (ADR-0010, ako/mxcli#754): `( Key: value, … )` holds an element's +// properties and `{ … }` its declarative children, each shaped +// ` [Name] ( props ) [ { children } ]`. The integration documents had the +// brackets the other way round: +// +// - a REST client operation and an agent's tool, MCP service and knowledge +// base put their properties in braces (MDL-DEPR070, MDL-DEPR071); +// - an image collection put its images in parentheses, each written +// `image X from file '…'` (MDL-DEPR072); +// - a message definition collection put its definitions, and every member +// tree, in parentheses (MDL-DEPR073). +// +// Each old form is a respelling that builds the same statement, so it keeps +// parsing under every language version, warns, and `fmt --upgrade` rewrites it. + +// EnterStatement records each of the four codes at most once per statement: +// a service with twenty operations in braces is one warning, not twenty. The +// rewrite of that one record respells every old bracket pair in the statement. +func (b *Builder) EnterStatement(ctx *parser.StatementContext) { + uses := map[string]*r2Use{} + var order []string + use := func(code string, at antlr.Token) *r2Use { + u := uses[code] + if u == nil { + u = &r2Use{first: at} + uses[code] = u + order = append(order, code) + } + return u + } + var walk func(antlr.Tree) + walk = func(n antlr.Tree) { + switch x := n.(type) { + case *parser.RestClientOperationContext: + if x.LBRACE() != nil && x.RBRACE() != nil { + use(deprecation.RestOperationBraces, x.LBRACE().GetSymbol()).swap(x.LBRACE(), x.RBRACE(), "(", ")") + } + case *parser.AgentBodyBlockContext: + if x.LBRACE() != nil && x.RBRACE() != nil { + use(deprecation.AgentAttachmentBraces, x.LBRACE().GetSymbol()).swap(x.LBRACE(), x.RBRACE(), "(", ")") + } + case *parser.ImageCollectionBodyContext: + if x.LPAREN() != nil && x.RPAREN() != nil { + use(deprecation.ImageCollectionParens, x.LPAREN().GetSymbol()).imageItems(x) + } + case *parser.CreateMessageDefinitionCollectionStatementContext: + if x.LPAREN() != nil && x.RPAREN() != nil { + use(deprecation.MessageTreeParens, x.LPAREN().GetSymbol()).swap(x.LPAREN(), x.RPAREN(), "{", "}") + } + case *parser.MessageMemberTreeContext: + if x.LPAREN() != nil && x.RPAREN() != nil { + use(deprecation.MessageTreeParens, x.LPAREN().GetSymbol()).swap(x.LPAREN(), x.RPAREN(), "{", "}") + } + } + for _, c := range n.GetChildren() { + walk(c) + } + } + walk(ctx) + for _, code := range order { + u := uses[code] + b.recordDeprecation(code, u.first, "") + b.fixLastDeprecation(code, &ast.Fix{Edits: u.edits}, "") + } +} + +// r2Use is the uses of one code in one statement: where the first is, and the +// edits that rewrite all of them. +type r2Use struct { + first antlr.Token + edits []ast.TextEdit +} + +// swap respells the bracket pair open … closing as newOpen … newClose. +func (u *r2Use) swap(open, closing antlr.TerminalNode, newOpen, newClose string) { + o, c := open.GetSymbol(), closing.GetSymbol() + u.edits = append(u.edits, replaceSpan(o, o, newOpen), replaceSpan(c, c, newClose)) +} + +// imageItems rewrites `( image X from file '…', … )` as +// `{ image X ( File: '…' ) … }`. +func (u *r2Use) imageItems(ctx *parser.ImageCollectionBodyContext) { + u.swap(ctx.LPAREN(), ctx.RPAREN(), "{", "}") + for _, c := range ctx.AllCOMMA() { + u.edits = append(u.edits, replaceSpan(c.GetSymbol(), c.GetSymbol(), "")) + } + for _, it := range ctx.AllImageCollectionItem() { + item, ok := it.(*parser.ImageCollectionItemContext) + if !ok || item.FROM() == nil || item.GetPath() == nil { + continue + } + path := item.GetPath() + u.edits = append(u.edits, replaceSpan(item.FROM().GetSymbol(), path, "( File: "+path.GetText()+" )")) + } +} From e03a05966d3245eed755354067f8207cfd930f9f Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 05:23:27 +0000 Subject: [PATCH 02/35] =?UTF-8?q?mdl:=20alter=20microflow=20fragments=20ar?= =?UTF-8?q?e=20begin=20=E2=80=A6=20end=20(R2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fragment is imperative flow, written as the body of create microflow is, so insert after/before and replace … with take begin … end. The brace fragment keeps parsing as MDL-DEPR074 with an fmt --upgrade rewrite. BEGIN now ends an alter target, as { does; describe's handles never contain it. Part of #754. Co-Authored-By: Claude Opus 5.5 --- mdl/deprecation/deprecation.go | 14 ++++++++++++++ mdl/grammar/MDLParser.g4 | 26 +++++++++++++++++++------- mdl/upgrade/r2_children_test.go | 13 +++++++++++++ mdl/visitor/r2_children_test.go | 16 ++++++++++++++++ mdl/visitor/visitor_alter_flow.go | 6 ++++-- mdl/visitor/visitor_r2_children.go | 14 +++++++++++++- 6 files changed, 79 insertions(+), 10 deletions(-) diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index c3be1bfcc1..0316633214 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -173,6 +173,9 @@ const ( // MessageTreeParens is a message definition collection's definitions and // member trees in parentheses. MessageTreeParens = "MDL-DEPR073" + // AlterFlowFragmentBraces is an `alter microflow` / `alter nanoflow` + // fragment in braces: `insert after $X { … }`, `replace … with { … }`. + AlterFlowFragmentBraces = "MDL-DEPR074" ) // entries is the registry. Append only: a code is never reused or renumbered, @@ -443,6 +446,17 @@ var r2Entries = []Entry{ Example: "create message definition collection M.Msgs (definition Order for M.Order (Number, M.Order_Line/M.Line (Sku)));", CanonicalExample: "create message definition collection M.Msgs {definition Order for M.Order {Number, M.Order_Line/M.Line {Sku}}};", }, + { + Code: AlterFlowFragmentBraces, + Old: "alter microflow M.F { insert after $X { … } }", + Canonical: "alter microflow M.F { insert after $X begin … end; }", + Rewrite: Rewrite{Structural: "the fragment's `{` becomes `begin` and its `}` becomes `end`"}, + RemovedIn: 2, + Note: "A fragment is imperative flow, written exactly as the body of `create microflow`, so it is " + + "`begin … end` (R2). The operations around it are the alter's children and stay in its { }.", + Example: "alter microflow M.F { insert after $X { log info 'x'; } };", + CanonicalExample: "alter microflow M.F { insert after $X begin log info 'x'; end };", + }, } // r8Entries are R8's spellings (ako/mxcli#752). Kept apart from the list above diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 28fe0f0230..c98f720ff5 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -334,9 +334,9 @@ alterTarget * * ```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; } + * insert after $IsValidEmail begin log info node 'Feedback' 'Email checked'; end; + * insert before 'Email is Valid?' begin … end; + * replace commit $Order with begin commit $Order with events; end; * drop log * node 'Debug' *; * } * ``` @@ -345,17 +345,29 @@ alterTarget * 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; + * so the target is taken as raw text up to the `begin`, `{`, `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? + : INSERT (AFTER | BEFORE) alterFlowTarget alterFlowFragment SEMICOLON? + | REPLACE alterFlowTarget WITH alterFlowFragment SEMICOLON? | DROP alterFlowTarget SEMICOLON ; +// A fragment is imperative flow, so it is `begin … end` like the body of the +// `create microflow` it is copied from (R2, ako/mxcli#754). The operations +// around it are the alter's declarative children and stay in the alter's { }. +// The brace fragment is the old spelling. +alterFlowFragment + : BEGIN microflowBody END + | LBRACE /* @alias MDL-DEPR074 */ microflowBody RBRACE + ; + +// BEGIN ends a target as `{` does. describe's handles never contain it: a loop +// prints `begin` on a line of its own, and an error handler's `begin` is +// stripped from its activity's handle. alterFlowTarget - : ~(LBRACE | RBRACE | SEMICOLON | WITH)+ + : ~(LBRACE | RBRACE | SEMICOLON | WITH | BEGIN)+ ; // ALTER PAGES [IN ] SET LAYOUT = Module.Layout [MAP (...)] [WHERE LAYOUT = Module.Old] diff --git a/mdl/upgrade/r2_children_test.go b/mdl/upgrade/r2_children_test.go index 182cc14dd2..b629f902c2 100644 --- a/mdl/upgrade/r2_children_test.go +++ b/mdl/upgrade/r2_children_test.go @@ -38,6 +38,12 @@ create message definition collection M.Msgs ( ) ); alter message definition M.Msgs.Order add member M.Order_Tag/M.Tag ( Label ); +ALTER NANOFLOW M.N { + INSERT AFTER $X { LOG INFO 'x'; } + replace commit $O with { + commit $O with events; + }; +}; ` want := `create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) { @@ -65,6 +71,12 @@ create message definition collection M.Msgs { } }; alter message definition M.Msgs.Order add member M.Order_Tag/M.Tag { Label }; +ALTER NANOFLOW M.N { + INSERT AFTER $X BEGIN LOG INFO 'x'; END + replace commit $O with begin + commit $O with events; + end; +}; ` res := mustUpgrade(t, src, Options{}) if res.Source != want { @@ -73,6 +85,7 @@ alter message definition M.Msgs.Order add member M.Order_Tag/M.Tag { Label }; for code, n := range map[string]int{ deprecation.RestOperationBraces: 1, deprecation.AgentAttachmentBraces: 2, deprecation.ImageCollectionParens: 1, deprecation.MessageTreeParens: 2, + deprecation.AlterFlowFragmentBraces: 1, } { if res.Rewritten[code] != n { t.Errorf("Rewritten[%s] = %d, want %d (all: %v)", code, res.Rewritten[code], n, res.Rewritten) diff --git a/mdl/visitor/r2_children_test.go b/mdl/visitor/r2_children_test.go index c6f95b2475..821b02f24f 100644 --- a/mdl/visitor/r2_children_test.go +++ b/mdl/visitor/r2_children_test.go @@ -92,6 +92,22 @@ var r2Cases = []r2Case{ old: `alter message definition collection M.Msgs add definition X for M.X as 'Xs' ( A, M.X_Y/M.Y ( B ) );`, canonical: `alter message definition collection M.Msgs add definition X for M.X as 'Xs' { A, M.X_Y/M.Y { B } };`, }, + { + name: "alter microflow fragments", + code: deprecation.AlterFlowFragmentBraces, + old: `alter microflow M.F { + insert after $IsValid { log info node 'F' 'checked'; } + insert before 'Save order' { if $X then log info 'y'; end if; }; + replace commit $Order with { commit $Order with events; } + drop log * node 'Debug' *; +};`, + canonical: `alter microflow M.F { + insert after $IsValid begin log info node 'F' 'checked'; end + insert before 'Save order' begin if $X then log info 'y'; end if; end; + replace commit $Order with begin commit $Order with events; end; + drop log * node 'Debug' *; +};`, + }, { name: "add member", code: deprecation.MessageTreeParens, diff --git a/mdl/visitor/visitor_alter_flow.go b/mdl/visitor/visitor_alter_flow.go index 784848fdaf..181a6a2eb5 100644 --- a/mdl/visitor/visitor_alter_flow.go +++ b/mdl/visitor/visitor_alter_flow.go @@ -29,8 +29,10 @@ func (b *Builder) exitAlterFlowStatement(ctx *parser.AlterStatementContext) { default: o.Op = ast.AlterFlowDrop } - if body := op.MicroflowBody(); body != nil { - o.Body = buildMicroflowBody(body) + if frag, ok := op.AlterFlowFragment().(*parser.AlterFlowFragmentContext); ok && frag != nil { + if body := frag.MicroflowBody(); body != nil { + o.Body = buildMicroflowBody(body) + } } stmt.Operations = append(stmt.Operations, o) } diff --git a/mdl/visitor/visitor_r2_children.go b/mdl/visitor/visitor_r2_children.go index 1b7e505b01..25a317e85a 100644 --- a/mdl/visitor/visitor_r2_children.go +++ b/mdl/visitor/visitor_r2_children.go @@ -19,7 +19,9 @@ import ( // - an image collection put its images in parentheses, each written // `image X from file '…'` (MDL-DEPR072); // - a message definition collection put its definitions, and every member -// tree, in parentheses (MDL-DEPR073). +// tree, in parentheses (MDL-DEPR073); +// - an `alter microflow` fragment, which is imperative flow, was in braces +// rather than `begin … end` (MDL-DEPR074). // // Each old form is a respelling that builds the same statement, so it keeps // parsing under every language version, warns, and `fmt --upgrade` rewrites it. @@ -58,6 +60,16 @@ func (b *Builder) EnterStatement(ctx *parser.StatementContext) { if x.LPAREN() != nil && x.RPAREN() != nil { use(deprecation.MessageTreeParens, x.LPAREN().GetSymbol()).swap(x.LPAREN(), x.RPAREN(), "{", "}") } + case *parser.AlterFlowFragmentContext: + if x.LBRACE() != nil && x.RBRACE() != nil { + // In the case of the operation's verb: `INSERT … BEGIN … END`. + like := "insert" + if op, ok := x.GetParent().(antlr.ParserRuleContext); ok && op.GetStart() != nil { + like = op.GetStart().GetText() + } + use(deprecation.AlterFlowFragmentBraces, x.LBRACE().GetSymbol()).swap(x.LBRACE(), x.RBRACE(), + keywordLike(like, "begin"), keywordLike(like, "end")) + } case *parser.MessageMemberTreeContext: if x.LPAREN() != nil && x.RPAREN() != nil { use(deprecation.MessageTreeParens, x.LPAREN().GetSymbol()).swap(x.LPAREN(), x.RPAREN(), "{", "}") From ab9c5ebadc9bf876a7c3b12a5e280e1abeca2492 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 05:23:29 +0000 Subject: [PATCH 03/35] docs: move skills, docs, examples and syntax help to the R2 brackets Converted with the visitor's own MDL-DEPR070..074 rewrites (no other spelling touched), plus the syntax-pattern blocks by hand. Records the alter-fragment decision in the beta syntax proposal. Part of #754. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/design-mdl-syntax.md | 2 +- .claude/skills/mendix/agents/SKILL.md | 18 +- .../json-structures-and-mappings/SKILL.md | 10 +- .../reference/message-definitions.md | 14 +- .claude/skills/mendix/rest-client/SKILL.md | 12 +- CHANGELOG.md | 1 + cmd/mxcli/syntax/features_integration.go | 26 +-- cmd/mxcli/syntax/features_microflow.go | 14 +- docs-site/src/examples/rest-integration.md | 12 +- docs-site/src/language/image-collections.md | 28 +-- docs-site/src/reference/agent/create-agent.md | 19 +- .../create-image-collection.md | 30 +-- .../create-message-definition-collection.md | 20 +- docs/01-project/MDL_QUICK_REFERENCE.md | 26 +-- .../PROPOSAL_mdl_beta_syntax_freeze.md | 1 + .../bug-tests/1059-widget-icon-elements.mdl | 6 +- .../bug-tests/200-basic-auth-rest-client.mdl | 4 +- .../bug-tests/464-alter-agent-editor.mdl | 4 +- .../556-reexec-identity-stability.mdl | 4 +- ...843-rest-response-mapping-no-body.fail.mdl | 4 +- .../bug-tests/843-rest-response-mapping.mdl | 4 +- .../messagedef-referenceset-cardinality.mdl | 24 +-- .../bug-tests/rest-file-request-body.fail.mdl | 4 +- .../bug-tests/rest-inline-mapping-paths.mdl | 8 +- .../doctype-tests/06-rest-client-examples.mdl | 172 +++++++++--------- .../19-image-collection-examples.mdl | 16 +- .../28-agent-editor-examples.mdl | 12 +- .../40-message-definition-examples.mdl | 34 ++-- .../use-cases/ai-agent-platform-demo.mdl | 8 +- 29 files changed, 275 insertions(+), 262 deletions(-) diff --git a/.claude/skills/design-mdl-syntax.md b/.claude/skills/design-mdl-syntax.md index 7a7f3259af..4e702f797a 100644 --- a/.claude/skills/design-mdl-syntax.md +++ b/.claude/skills/design-mdl-syntax.md @@ -73,7 +73,7 @@ Does an existing pattern cover this? If yes, extend it. Don't invent new syntax. ``` New feature: "image collections" Existing pattern: create/alter/drop/list/describe -design: create image collection Module.Name (...) +design: create image collection Module.Name { image X ( File: '…' ) } describe image collection Module.Name list image COLLECTIONS [in module] ``` diff --git a/.claude/skills/mendix/agents/SKILL.md b/.claude/skills/mendix/agents/SKILL.md index 218f74ea3d..1d8e070975 100644 --- a/.claude/skills/mendix/agents/SKILL.md +++ b/.claude/skills/mendix/agents/SKILL.md @@ -96,22 +96,22 @@ prompt here.$$, UserPrompt: 'Single line prompt.' ) { - mcp service Module.WebSearch { + mcp service Module.WebSearch ( Enabled: true - } + ) - knowledge base KBAlias { + knowledge base KBAlias ( source: Module.ProductDocs, collection: 'product-docs', MaxResults: 5, description: 'Product docs', Enabled: true - } + ) - tool MyMicroflowTool { + tool MyMicroflowTool ( description: 'Fetch customer data', Enabled: true - } + ) }; ``` @@ -131,9 +131,9 @@ alter consumed mcp service Module.MyMCP -- Agent: SET for scalars, ADD/DROP for tools, MCP services, knowledge bases alter agent Module.MyAgent set SystemPrompt = 'New prompt', Temperature = 0.7, MaxTokens = 4096 - add tool MyMicroflow { Description: '...', Enabled: true } - add mcp service Module.WeatherSvc { Description: '...', Enabled: true } - add knowledge base Docs { Source: Module.MyKB, Collection: 'docs', MaxResults: 5 } + add tool MyMicroflow ( Description: '...', Enabled: true ) + add mcp service Module.WeatherSvc ( Description: '...', Enabled: true ) + add knowledge base Docs ( Source: Module.MyKB, Collection: 'docs', MaxResults: 5 ) drop tool OldTool drop mcp service Module.OldSvc drop knowledge base OldKB; diff --git a/.claude/skills/mendix/json-structures-and-mappings/SKILL.md b/.claude/skills/mendix/json-structures-and-mappings/SKILL.md index ca84f5b6c8..e2304071ed 100644 --- a/.claude/skills/mendix/json-structures-and-mappings/SKILL.md +++ b/.claude/skills/mendix/json-structures-and-mappings/SKILL.md @@ -163,12 +163,12 @@ in the demo corpus (22.6%), and the only non-JSON source MDL can create. It holds nothing external: it is a **selection over the domain model**. ```sql -create message definition collection Sales.MD_Order ( - definition OrderMessage for Sales.Order as 'Orders' ( +create message definition collection Sales.MD_Order { + definition OrderMessage for Sales.Order as 'Orders' { OrderId, - Sales.Order_Customer/Sales.Customer ( FirstName ) - ) -); + Sales.Order_Customer/Sales.Customer { FirstName } + } +}; ``` A bare name is an attribute; `Assoc/Module.Entity` is an association. **Name the diff --git a/.claude/skills/mendix/json-structures-and-mappings/reference/message-definitions.md b/.claude/skills/mendix/json-structures-and-mappings/reference/message-definitions.md index 7db5877958..8a9fc07095 100644 --- a/.claude/skills/mendix/json-structures-and-mappings/reference/message-definitions.md +++ b/.claude/skills/mendix/json-structures-and-mappings/reference/message-definitions.md @@ -10,14 +10,14 @@ external: it is a **selection over the domain model**. ```sql create message definition collection Sales.MD_Order folder 'Messages' -( - definition OrderMessage for Sales.Order as 'Orders' ( +{ + definition OrderMessage for Sales.Order as 'Orders' { OrderId, Total as 'GrandTotal', - Sales.OrderLine_Order/Sales.OrderLine as 'Lines' ( Sku, Quantity ), - Sales.Order_Customer/Sales.Customer ( FirstName, Address example 'Kerstraat 5' ) - ) -); + Sales.OrderLine_Order/Sales.OrderLine as 'Lines' { Sku, Quantity }, + Sales.Order_Customer/Sales.Customer { FirstName, Address example 'Kerstraat 5' } + } +}; ``` A **bare name is an attribute**; `Assoc/Module.Entity` is an **association** with @@ -62,7 +62,7 @@ alter message definition Sales.MD_Order.OrderMessage add member LastName in Cust alter message definition Sales.MD_Order.OrderMessage set member Total as 'GrandTotal'; alter message definition Sales.MD_Order.OrderMessage drop member Sku in Lines; -alter message definition collection Sales.MD_Order add definition Line for Sales.Line ( Sku ); +alter message definition collection Sales.MD_Order add definition Line for Sales.Line { Sku }; alter message definition collection Sales.MD_Order rename definition Line to OrderLine; alter message definition collection Sales.MD_Order drop definition if exists OrderLine; ``` diff --git a/.claude/skills/mendix/rest-client/SKILL.md b/.claude/skills/mendix/rest-client/SKILL.md index 8e3658de8f..9d86f8315d 100644 --- a/.claude/skills/mendix/rest-client/SKILL.md +++ b/.claude/skills/mendix/rest-client/SKILL.md @@ -81,22 +81,22 @@ create consumed rest service Module.OpenMeteoAPI ( authentication: none ) { - operation GetForecast { + operation GetForecast ( method: get, path: '/forecast', query: ($latitude: decimal, $longitude: decimal, $current: string), headers: ('Accept' = 'application/json'), timeout: 30, response: json as $WeatherJson - } + ) - operation PostData { + operation PostData ( method: post, path: '/submit', headers: ('Content-Type' = 'application/json'), body: json from $JsonPayload, response: none - } + ) }; ``` @@ -417,13 +417,13 @@ create consumed rest service Module.WeatherAPI ( authentication: none ) { - operation GetCurrent { + operation GetCurrent ( method: get, path: '/forecast', query: ($latitude: decimal, $longitude: decimal, $current: string), headers: ('Accept' = 'application/json'), response: json as $Result - } + ) }; -- 5. Microflow (REST Client → Transform → Import) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0e773a25..791a8f4c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Document types are named as Studio Pro names them** (ako/mxcli#755, R10) — `consumed rest service` (was `rest client`), `consumed odata service` (was `odata client`), `published odata service` (was `odata service`), `task queue` (was `queue`), `alter app security` (was `alter project security`) and `alter settings runtime` (was `alter settings model`), in every statement that names the type: create, alter, drop, describe, list, move, grant/revoke on a published OData service, and an external entity's `from consumed odata service`. `describe` writes the new names. **Migrating a script:** nothing breaks — the old names still parse and build the identical statement, `check` / `exec` warn **MDL-DEPR550** to **MDL-DEPR555**, and `mxcli fmt --upgrade` rewrites them. `call microflow … in queue M.Q` is a call option, not the type name, and is unchanged. +- **Integration documents put properties in `( )` and children in `{ }`; an `alter microflow` fragment is `begin … end`** (ako/mxcli#754, R2) — a consumed REST service's operations are `operation GetUser ( Method: get, Path: '/u/{id}', Response: none )`; an agent's attachments are `tool X ( … )`, `mcp service M.S ( … )` and `knowledge base K ( … )`, in `create agent` and `alter agent … add`; an image collection is `create image collection M.Icons { image Logo ( File: 'logo.png' ) }`; a message definition collection is `{ definition D for M.E { A, M.E_B/M.B { C } } }`, and an association may select no member (`M.E_B/M.B { }`, which describe already printed as the unparseable `()`); and `alter microflow` / `alter nanoflow` take `insert after begin … end;` and `replace with begin … end;`, the fragment written as the body of `create microflow` is. `describe` prints the new forms. **Migrating a script:** nothing breaks — the old brackets still parse and build the identical statement, `check` / `exec` warn **MDL-DEPR070** (operation), **MDL-DEPR071** (agent attachment), **MDL-DEPR072** (image collection), **MDL-DEPR073** (message definitions) and **MDL-DEPR074** (alter fragment), once per statement, and `mxcli fmt --upgrade` rewrites them. - **An entity grant names the rights first, and every XPath is written in `[ ]`** (ako/mxcli#753, R5) — `grant read *, write (Email), create on entity Shop.Order to Shop.User where [Status = 'Open'];` takes the word order of every other grant, and the constraint is bracketed like a datasource or retrieve `where`, so the quotes inside it are written once instead of doubled. A workflow user task's targeting is the same: `targeting users xpath [System.UserRoles = '[%UserRole_Manager%]']`, and `alter workflow … set activity … targeting xpath [ … ]`. Sibling predicate groups (`[a][b]`) are one constraint, kept as written. `describe entity` and `describe workflow` print the new forms; a stored constraint the bracketed grammar does not read (none in the Studio Pro-authored PedApp and TestApp fixtures) is printed in the old quoted form so the output stays re-executable. **Migrating a script:** nothing breaks — `grant Shop.User on Shop.Order (read *) where '[…]'` and `targeting xpath '[…]'` still parse and build the same rule, and `check` / `exec` warn **MDL-DEPR030** and **MDL-DEPR031**; `mxcli fmt --upgrade` rewrites both. A quoted value that is not a bracketed XPath is left in place and reported. `revoke` keeps its current form for now. - **A microflow error handler is `on error [without rollback] begin … end error`; `while` takes `begin … end while` like `loop`** (ako/mxcli#754, R2) — braces hold declarative children and imperative flow is `begin … end `, and the error handler was the only brace block inside a microflow. `describe microflow` / `describe nanoflow` now print `commit $O on error begin … end error;` (an empty handler `… on error without rollback begin end error;`), and so do the generated test flows of `mxcli test`. Under the `mdl 1;` preview header a `while` without `begin`, or ending in a bare `end`, is an error; without the header it keeps parsing and warns **MDL-V1-WHILE**, and `mxcli fmt --upgrade --header` inserts the missing words. Measured on the Studio Pro-authored PedApp and TestApp fixtures: the round-trip harness stays green. **Migrating a script:** nothing breaks — `on error { … }` still parses, builds the identical handler, and `check` / `exec` warn **MDL-DEPR540**; `mxcli fmt --upgrade` rewrites it. - **One argument form at every call site: `Param = expression`; one text-template form: `with ({1} = …)`** (ako/mxcli#751, R3/R4) — `call microflow`/`nanoflow`/`java action`/…, `execute database query`, `send rest request`, `show page`, and page/button actions and microflow/nanoflow data sources all bind an argument as `Order = $Order`, with no `$` on the parameter name and `=` rather than `:`. A workflow's `call microflow` / `call workflow` takes its arguments the same way, right after the callee, as bare expressions: `call microflow Shop.ACT_Process(Order = $WorkflowContext) as act1 comment 'Process'`. `show message` and `validation feedback` take `with ({1} = $a, {2} = $b)`, like `log` and the REST templates. `describe` writes these forms; it also writes a validation feedback's template arguments, which it used to drop, so describe → exec lost them. Measured on a copy of the Studio Pro-authored TestApp (Mendix 11.14.0): a script in the new forms and the same script in the old forms describe identically, and `mx check` reports 0 errors. **Migrating a script:** nothing breaks — `$Param = e` (**MDL-DEPR006**), `Param: e` (**MDL-DEPR007**), a workflow's `with (Param = '')` (**MDL-DEPR008**, same meaning: the string's content is the expression) and `objects [..]` / `parameters [..]` (**MDL-DEPR009**) still parse and build the same model, `check` / `exec` warn, and `mxcli fmt --upgrade` rewrites them. A workflow string whose content does not read back as the same bare expression is left in place and reported, and `describe` keeps the string form for such a stored expression. diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index e1a662c6ec..82cbb12ca8 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -299,8 +299,8 @@ func init() { "body", "response", "mapping", "authentication", "json structure", "import mapping", "export mapping", }, - Syntax: "CREATE [OR MODIFY] CONSUMED REST SERVICE Module.Name (\n BaseUrl: 'https://...',\n Authentication: NONE | BASIC (...)\n)\n{\n OPERATION Name {\n Method: GET|POST|PUT|DELETE|PATCH,\n Path: '/path/{param}',\n Parameters: ($param: Type),\n Query: ($param: Type),\n Headers: ('Key' = 'Value'),\n Timeout: 30,\n Body: JSON FROM $var | MAPPING Entity { jsonField = Attribute, ... },\n Response: JSON AS $var | MAPPING Entity { Attribute = jsonField, ... }\n }\n};\n\n-- MAPPING takes a target ENTITY plus a body listing the JSON fields; Mendix\n-- stores it inline on the operation. An existing import/export mapping\n-- document cannot be referenced here (rejected as MDL-REST01).\n-- There is no FILE request body: Mendix's consumed operation stores one of\n-- Rest$JsonBody, Rest$StringBody or Rest$ImplicitMappingBody, so a file\n-- document has nowhere to go. `Body: FILE FROM $Doc` is rejected as\n-- MDL-REST02 rather than sent as the literal text \"$Doc\" (it used to be,\n-- returning 200 with a 4-byte payload). Binary POST lives on the\n-- microflow activity: `rest call post '' body binary $Doc/Contents`.\n-- `Response: FILE AS $Doc` is unaffected — downloads work.", - Example: "CREATE CONSUMED REST SERVICE Module.PetStore (\n BaseUrl: 'https://petstore.example.com/api',\n Authentication: NONE\n)\n{\n OPERATION GetPet {\n Method: GET,\n Path: '/pets/{id}',\n Parameters: ($id: String),\n Query: ($verbose: String),\n Response: MAPPING Module.Pet {\n Name = name,\n Status = status\n }\n }\n};", + Syntax: "CREATE [OR MODIFY] CONSUMED REST SERVICE Module.Name (\n BaseUrl: 'https://...',\n Authentication: NONE | BASIC (...)\n)\n{\n OPERATION Name (\n Method: GET|POST|PUT|DELETE|PATCH,\n Path: '/path/{param}',\n Parameters: ($param: Type),\n Query: ($param: Type),\n Headers: ('Key' = 'Value'),\n Timeout: 30,\n Body: JSON FROM $var | MAPPING Entity { jsonField = Attribute, ... },\n Response: JSON AS $var | MAPPING Entity { Attribute = jsonField, ... }\n )\n};\n\n-- An operation is a child of the service, so its properties are in ( ).\n-- `OPERATION Name { ... }` is the deprecated spelling (MDL-DEPR070).\n\n-- MAPPING takes a target ENTITY plus a body listing the JSON fields; Mendix\n-- stores it inline on the operation. An existing import/export mapping\n-- document cannot be referenced here (rejected as MDL-REST01).\n-- There is no FILE request body: Mendix's consumed operation stores one of\n-- Rest$JsonBody, Rest$StringBody or Rest$ImplicitMappingBody, so a file\n-- document has nowhere to go. `Body: FILE FROM $Doc` is rejected as\n-- MDL-REST02 rather than sent as the literal text \"$Doc\" (it used to be,\n-- returning 200 with a 4-byte payload). Binary POST lives on the\n-- microflow activity: `rest call post '' body binary $Doc/Contents`.\n-- `Response: FILE AS $Doc` is unaffected — downloads work.", + Example: "CREATE CONSUMED REST SERVICE Module.PetStore (\n BaseUrl: 'https://petstore.example.com/api',\n Authentication: NONE\n)\n{\n OPERATION GetPet (\n Method: GET,\n Path: '/pets/{id}',\n Parameters: ($id: String),\n Query: ($verbose: String),\n Response: MAPPING Module.Pet {\n Name = name,\n Status = status\n }\n )\n};", SeeAlso: []string{"rest", "rest.published"}, }) @@ -602,9 +602,9 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "message definition", "message definition collection", "create message definition", "exposed entity", "exposed attribute", "exposed association", }, - Syntax: "SHOW MESSAGE DEFINITION COLLECTIONS [IN Module];\nDESCRIBE MESSAGE DEFINITION COLLECTION Module.Name;\nCREATE [OR MODIFY] MESSAGE DEFINITION COLLECTION Module.Name [FOLDER 'path']\n(\n DEFINITION Name FOR Module.Entity [AS 'Exposed'] (\n AttributeName [AS 'Exposed'] [EXAMPLE 'text'],\n Module.Assoc/Module.TargetEntity [AS 'Exposed'] ( ... )\n )\n);\nDROP MESSAGE DEFINITION COLLECTION Module.Name;\n\n" + + Syntax: "SHOW MESSAGE DEFINITION COLLECTIONS [IN Module];\nDESCRIBE MESSAGE DEFINITION COLLECTION Module.Name;\nCREATE [OR MODIFY] MESSAGE DEFINITION COLLECTION Module.Name [FOLDER 'path']\n{\n DEFINITION Name FOR Module.Entity [AS 'Exposed'] {\n AttributeName [AS 'Exposed'] [EXAMPLE 'text'],\n Module.Assoc/Module.TargetEntity [AS 'Exposed'] { ... }\n }\n};\nDROP MESSAGE DEFINITION COLLECTION Module.Name;\n\n" + "ALTER MESSAGE DEFINITION COLLECTION Module.Name\n" + - " ADD DEFINITION [IF NOT EXISTS] Name FOR Module.Entity [AS 'X'] ( ... )\n" + + " ADD DEFINITION [IF NOT EXISTS] Name FOR Module.Entity [AS 'X'] { ... }\n" + " | DROP DEFINITION [IF EXISTS] Name\n" + " | RENAME DEFINITION Old TO New;\n\n" + "ALTER MESSAGE DEFINITION Module.Collection.Definition\n" + @@ -626,8 +626,10 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "pluralises a repeating element's exposed name; mxcli defaults to the\n" + "entity's own name and lets AS say otherwise.\n\n" + "IN reaches a nested member, in exposed names. SET changes only the\n" + - "exposed name — it is not a model rename. Authoring is modelsdk-only.", - Example: "CREATE MESSAGE DEFINITION COLLECTION Sales.MD_Order\n(\n DEFINITION OrderMessage FOR Sales.Order AS 'Orders' (\n OrderId,\n Sales.OrderLine_Order/Sales.OrderLine AS 'Lines' ( Sku, Quantity ),\n Sales.Order_Customer/Sales.Customer ( FirstName )\n )\n);\n\nALTER MESSAGE DEFINITION Sales.MD_Order.OrderMessage ADD MEMBER LastName IN Customer;\n\nCREATE IMPORT MAPPING Sales.IMM_Order\n WITH MESSAGE DEFINITION Sales.MD_Order.OrderMessage\n{ create Sales.Order { OrderId = OrderId } };", + "exposed name — it is not a model rename. Authoring is modelsdk-only.\n\n" + + "Definitions and member trees are children, so they are in { } (R2); the\n" + + "parenthesised form is the deprecated spelling MDL-DEPR073.", + Example: "CREATE MESSAGE DEFINITION COLLECTION Sales.MD_Order\n{\n DEFINITION OrderMessage FOR Sales.Order AS 'Orders' {\n OrderId,\n Sales.OrderLine_Order/Sales.OrderLine AS 'Lines' { Sku, Quantity },\n Sales.Order_Customer/Sales.Customer { FirstName }\n }\n};\n\nALTER MESSAGE DEFINITION Sales.MD_Order.OrderMessage ADD MEMBER LastName IN Customer;\n\nCREATE IMPORT MAPPING Sales.IMM_Order\n WITH MESSAGE DEFINITION Sales.MD_Order.OrderMessage\n{ create Sales.Order { OrderId = OrderId } };", }) Register(SyntaxFeature{ @@ -657,8 +659,8 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "image collection", "create image collection", "drop image collection", "export level", "image", "icon", "logo", }, - Syntax: "SHOW IMAGE COLLECTION [IN Module];\nDESCRIBE IMAGE COLLECTION Module.Name;\nCREATE IMAGE COLLECTION Module.Name [FOLDER 'path']\n [EXPORT LEVEL 'Hidden'|'Public']\n [COMMENT 'text']\n [(IMAGE name FROM FILE 'path', ...)];\nCREATE OR MODIFY IMAGE COLLECTION Module.Name [...];\nDROP IMAGE COLLECTION Module.Name;", - Example: "CREATE OR MODIFY IMAGE COLLECTION MyModule.AppIcons\n EXPORT LEVEL 'Public'\n COMMENT 'Application icons' (\n IMAGE logo FROM FILE 'assets/logo.png',\n IMAGE \"favicon\" FROM FILE 'assets/favicon.ico'\n);\n\nDESCRIBE IMAGE COLLECTION MyModule.AppIcons;", + Syntax: "SHOW IMAGE COLLECTION [IN Module];\nDESCRIBE IMAGE COLLECTION Module.Name;\nCREATE IMAGE COLLECTION Module.Name [FOLDER 'path']\n [EXPORT LEVEL 'Hidden'|'Public']\n [COMMENT 'text']\n [{ IMAGE name ( File: 'path' ) ... }];\nCREATE OR MODIFY IMAGE COLLECTION Module.Name [...];\nDROP IMAGE COLLECTION Module.Name;\n\n-- The images are the collection's children, so they are in { }, each with\n-- its properties in ( ). `( IMAGE name FROM FILE 'path', ... )` is the\n-- deprecated spelling (MDL-DEPR072).", + Example: "CREATE OR MODIFY IMAGE COLLECTION MyModule.AppIcons\n EXPORT LEVEL 'Public'\n COMMENT 'Application icons' {\n IMAGE logo ( File: 'assets/logo.png' )\n IMAGE \"favicon\" ( File: 'assets/favicon.ico' )\n};\n\nDESCRIBE IMAGE COLLECTION MyModule.AppIcons;", SeeAlso: []string{"integration", "icon-collection"}, }) @@ -868,11 +870,11 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, [UserPrompt: 'prompt'] ) { - [MCP SERVICE Module.ServiceName { Enabled: true }] - [KNOWLEDGE BASE AliaName { Source: Module.KB, Collection: 'col', MaxResults: 5, Enabled: true }] - [TOOL MicroflowName { Description: 'desc', Enabled: true }] + [MCP SERVICE Module.ServiceName ( Enabled: true )] + [KNOWLEDGE BASE AliaName ( Source: Module.KB, Collection: 'col', MaxResults: 5, Enabled: true )] + [TOOL MicroflowName ( Description: 'desc', Enabled: true )] };`, - Example: "create agent MyModule.Assistant (\n UsageType: Chat,\n Model: MyModule.GPT4,\n SystemPrompt: $$You are a helpful assistant.$$,\n UserPrompt: 'Ask me anything.'\n)\n{\n MCP SERVICE MyModule.WebSearch { Enabled: true }\n};", + Example: "create agent MyModule.Assistant (\n UsageType: Chat,\n Model: MyModule.GPT4,\n SystemPrompt: $$You are a helpful assistant.$$,\n UserPrompt: 'Ask me anything.'\n)\n{\n MCP SERVICE MyModule.WebSearch ( Enabled: true )\n};", SeeAlso: []string{"agents", "agents.model", "agents.knowledge-base", "agents.mcp-service"}, }) } diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index 660db845fc..5a723477c7 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -474,8 +474,8 @@ func init() { "replace", "drop activity", "patch microflow", "splice", "handle", }, Syntax: "ALTER MICROFLOW|NANOFLOW Module.Name {\n" + - " INSERT AFTER|BEFORE { }\n" + - " REPLACE WITH { }\n" + + " INSERT AFTER|BEFORE BEGIN END;\n" + + " REPLACE WITH BEGIN END;\n" + " DROP ;\n" + "};\n\n" + "-- addresses one activity by content, as `describe microflow ... with handles` prints it:\n" + @@ -489,13 +489,15 @@ func init() { "-- 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.", + "-- the flow has or reads one not declared on the path. Over --mcp only insert is supported.\n" + + "-- A fragment is imperative flow, written as the body of `create microflow` is: BEGIN … END.\n" + + "-- The brace fragment `{ }` is the deprecated spelling MDL-DEPR074.", Example: "alter microflow FeedbackModule.VAL_Feedback {\n" + - " insert after $IsValidEmail { log info node 'Feedback' 'Email checked'; }\n" + - " replace set $ValidFeedback = false @3 with {\n" + + " insert after $IsValidEmail begin log info node 'Feedback' 'Email checked'; end;\n" + + " replace set $ValidFeedback = false @3 with begin\n" + " set $ValidFeedback = false;\n" + " log warning node 'Feedback' 'Email rejected';\n" + - " }\n" + + " end;\n" + " drop log debug node 'Feedback' *;\n" + "};", SeeAlso: []string{"microflow"}, diff --git a/docs-site/src/examples/rest-integration.md b/docs-site/src/examples/rest-integration.md index 80eaee30cc..95bd08604e 100644 --- a/docs-site/src/examples/rest-integration.md +++ b/docs-site/src/examples/rest-integration.md @@ -251,16 +251,16 @@ CREATE CONSUMED REST SERVICE Integration.OrdersApi ( Authentication: NONE ) { - OPERATION GetOrder { + OPERATION GetOrder ( Method: GET, Path: '/orders/{id}', Parameters: ($id: String), Headers: ('Accept' = 'application/json'), Timeout: 30, Response: JSON AS $Result - } + ) - OPERATION CreateOrder { + OPERATION CreateOrder ( Method: POST, Path: '/orders', Headers: ('Content-Type' = 'application/json'), @@ -274,7 +274,7 @@ CREATE CONSUMED REST SERVICE Integration.OrdersApi ( Status = status, CreatedAt = createdAt, } - } + ) }; ``` @@ -286,14 +286,14 @@ CREATE OR MODIFY CONSUMED REST SERVICE Integration.OrdersApi ( Authentication: BASIC (Username: 'apiuser', Password: 'secret') ) { - OPERATION GetOrder { + OPERATION GetOrder ( Method: GET, Path: '/orders/{id}', Parameters: ($id: String), Headers: ('Accept' = 'application/json'), Timeout: 60, Response: JSON AS $Result - } + ) }; ``` diff --git a/docs-site/src/language/image-collections.md b/docs-site/src/language/image-collections.md index 8ffe7fb4a5..6ab04ebc1c 100644 --- a/docs-site/src/language/image-collections.md +++ b/docs-site/src/language/image-collections.md @@ -15,7 +15,7 @@ SHOW IMAGE COLLECTION IN MyModule; DESCRIBE IMAGE COLLECTION MyModule.AppIcons; ``` -The `DESCRIBE` output includes the full `CREATE` statement. If the collection contains images, they are shown with `IMAGE 'name' FROM FILE 'path'` syntax that can be copied and re-executed. In the TUI, images are rendered inline when the terminal supports it (Kitty, iTerm2, Sixel). +The `DESCRIBE` output includes the full `CREATE` statement. If the collection contains images, they are shown as `image Name ( File: 'path' )` children that can be copied and re-executed. In the TUI, images are rendered inline when the terminal supports it (Kitty, iTerm2, Sixel). ## CREATE IMAGE COLLECTION @@ -23,17 +23,19 @@ The `DESCRIBE` output includes the full `CREATE` statement. If the collection co CREATE IMAGE COLLECTION . [EXPORT LEVEL 'Hidden'|'Public'] [COMMENT ''] - [( - IMAGE FROM FILE '', + [{ + IMAGE ( File: '' ) ... - )]; + }]; ``` | Option | Description | Default | |--------|-------------|---------| | `EXPORT LEVEL` | `'Hidden'` (internal to module) or `'Public'` (accessible from other modules) | `'Hidden'` | | `COMMENT` | Documentation for the collection | (none) | -| `IMAGE Name FROM FILE` | Load an image from the filesystem into the collection | (none) | +| `IMAGE Name ( File: '…' )` | Load an image from the filesystem into the collection | (none) | + +The images are the collection's children, so they are in `{ }`, each with its properties in `( )`. The older form `( IMAGE Name FROM FILE '', … )` still parses but warns (MDL-DEPR072); `mxcli fmt --upgrade` rewrites it. The image format is detected automatically from the file extension. Relative paths are resolved from the current working directory. Supported formats: PNG, SVG, GIF, JPEG, BMP, WebP. @@ -51,18 +53,18 @@ CREATE IMAGE COLLECTION MyModule.StatusIcons COMMENT 'Icons for order and task status indicators'; -- With images from files -CREATE IMAGE COLLECTION MyModule.NavigationIcons ( - IMAGE home FROM FILE 'assets/home.png', - IMAGE settings FROM FILE 'assets/settings.svg' -); +CREATE IMAGE COLLECTION MyModule.NavigationIcons { + IMAGE home ( File: 'assets/home.png' ) + IMAGE settings ( File: 'assets/settings.svg' ) +}; -- All options combined CREATE IMAGE COLLECTION MyModule.BrandAssets EXPORT LEVEL 'Public' - COMMENT 'Company branding assets' ( - IMAGE logo_dark FROM FILE 'assets/logo-dark.png', - IMAGE logo_light FROM FILE 'assets/logo-light.png' -); + COMMENT 'Company branding assets' { + IMAGE logo_dark ( File: 'assets/logo-dark.png' ) + IMAGE logo_light ( File: 'assets/logo-light.png' ) +}; ``` ## DROP IMAGE COLLECTION diff --git a/docs-site/src/reference/agent/create-agent.md b/docs-site/src/reference/agent/create-agent.md index 3b67fe9549..b5be74921f 100644 --- a/docs-site/src/reference/agent/create-agent.md +++ b/docs-site/src/reference/agent/create-agent.md @@ -15,16 +15,16 @@ CREATE [ OR MODIFY ] AGENT module.Name ( [, ToolChoice: { Auto | None | Required } ] ) [ { - [ MCP SERVICE module.McpServiceName { + [ MCP SERVICE module.McpServiceName ( Enabled: true - } ] - [ KNOWLEDGE BASE alias { + ) ] + [ KNOWLEDGE BASE alias ( source: module.KBName, collection: 'collection-name', MaxResults: 5, description: 'description', Enabled: true - } ] + ) ] } ] ; @@ -63,6 +63,9 @@ The optional body block (inside `{ }`) attaches tools and knowledge bases to the - **MCP SERVICE** — attaches a consumed MCP service by its qualified name - **KNOWLEDGE BASE** — attaches a knowledge base with retrieval settings +- **TOOL** — attaches a tool + +Each attachment is a child of the agent, so its properties are in `( )`. The older brace form (`TOOL Name { … }`) still parses but warns (MDL-DEPR071); `mxcli fmt --upgrade` rewrites it. ### OR MODIFY @@ -161,17 +164,17 @@ CREATE AGENT MyModule."ResearchAssistant" ( UserPrompt: 'Find information about quantum computing.' ) { - MCP SERVICE MyModule.WebSearch { + MCP SERVICE MyModule.WebSearch ( Enabled: true - } + ) - KNOWLEDGE BASE ProductKB { + KNOWLEDGE BASE ProductKB ( source: MyModule.ProductDocs, collection: 'product-docs', MaxResults: 5, description: 'Product documentation', Enabled: true - } + ) }; / ``` diff --git a/docs-site/src/reference/image-collection/create-image-collection.md b/docs-site/src/reference/image-collection/create-image-collection.md index f4ea3f697c..ab9f17ff76 100644 --- a/docs-site/src/reference/image-collection/create-image-collection.md +++ b/docs-site/src/reference/image-collection/create-image-collection.md @@ -5,10 +5,10 @@ CREATE [OR MODIFY] IMAGE COLLECTION module.name [EXPORT LEVEL 'Hidden' | 'Public'] [COMMENT 'description'] - [( - IMAGE 'image_name' FROM FILE 'path', + [{ + IMAGE image_name ( File: 'path' ) ... - )]; + }]; ## Description @@ -25,8 +25,10 @@ Creates a new image collection in the specified module. `OR MODIFY` updates an e **COMMENT** : Documentation text for the collection. -**IMAGE 'name' FROM FILE 'path'** -: Loads an image from a file on disk. The path is relative to the current working directory. Supported formats: PNG, SVG, GIF, JPEG, BMP, WebP. +**IMAGE name ( File: 'path' )** +: Loads an image from a file on disk. The path is relative to the current working directory. Supported formats: PNG, SVG, GIF, JPEG, BMP, WebP. A name that is not a plain identifier is written as a quoted identifier (`"logo-dark"`). + +The images are the collection's children, so they are in `{ }`, each with its properties in `( )`. The older form `( IMAGE name FROM FILE 'path', … )` still parses but warns (MDL-DEPR072); `mxcli fmt --upgrade` rewrites it. ## Examples @@ -47,11 +49,11 @@ CREATE IMAGE COLLECTION MyModule.SharedIcons ### Collection with images ```sql -CREATE IMAGE COLLECTION MyModule.NavigationIcons ( - IMAGE 'home' FROM FILE 'assets/home.png', - IMAGE 'settings' FROM FILE 'assets/settings.svg', - IMAGE 'profile' FROM FILE 'assets/profile.png' -); +CREATE IMAGE COLLECTION MyModule.NavigationIcons { + IMAGE home ( File: 'assets/home.png' ) + IMAGE settings ( File: 'assets/settings.svg' ) + IMAGE profile ( File: 'assets/profile.png' ) +}; ``` ### All options combined @@ -59,10 +61,10 @@ CREATE IMAGE COLLECTION MyModule.NavigationIcons ( ```sql CREATE IMAGE COLLECTION MyModule.BrandAssets EXPORT LEVEL 'Public' - COMMENT 'Company branding assets' ( - IMAGE 'logo-dark' FROM FILE 'assets/logo-dark.png', - IMAGE 'logo-light' FROM FILE 'assets/logo-light.png' -); + COMMENT 'Company branding assets' { + IMAGE "logo-dark" ( File: 'assets/logo-dark.png' ) + IMAGE "logo-light" ( File: 'assets/logo-light.png' ) +}; ``` ## See Also diff --git a/docs-site/src/reference/integration/create-message-definition-collection.md b/docs-site/src/reference/integration/create-message-definition-collection.md index 11d8d840a6..43527eba7f 100644 --- a/docs-site/src/reference/integration/create-message-definition-collection.md +++ b/docs-site/src/reference/integration/create-message-definition-collection.md @@ -76,18 +76,18 @@ guesses. ```sql CREATE MESSAGE DEFINITION COLLECTION Sales.MD_Order FOLDER 'Messages' -( - DEFINITION OrderMessage FOR Sales.Order AS 'Orders' ( +{ + DEFINITION OrderMessage FOR Sales.Order AS 'Orders' { OrderId, TotalAmount AS 'Total', - Sales.OrderLine_Order/Sales.OrderLine AS 'Lines' ( Sku, Quantity ), - Sales.Order_Customer/Sales.Customer ( FirstName, LastName ) - ), - DEFINITION CustomerOrders FOR Sales.Customer AS 'Customers' ( + Sales.OrderLine_Order/Sales.OrderLine AS 'Lines' { Sku, Quantity }, + Sales.Order_Customer/Sales.Customer { FirstName, LastName } + }, + DEFINITION CustomerOrders FOR Sales.Customer AS 'Customers' { FirstName, - Sales.Order_Customer/Sales.Order AS 'Orders' ( OrderId ) - ) -); + Sales.Order_Customer/Sales.Order AS 'Orders' { OrderId } + } +}; CREATE IMPORT MAPPING Sales.IMM_Order WITH MESSAGE DEFINITION Sales.MD_Order.OrderMessage @@ -105,7 +105,7 @@ ALTER MESSAGE DEFINITION Sales.MD_Order.OrderMessage ADD MEMBER LastName IN Cust ALTER MESSAGE DEFINITION Sales.MD_Order.OrderMessage SET MEMBER TotalAmount AS 'GrandTotal'; ALTER MESSAGE DEFINITION Sales.MD_Order.OrderMessage DROP MEMBER Sku IN Lines; -ALTER MESSAGE DEFINITION COLLECTION Sales.MD_Order ADD DEFINITION Line FOR Sales.Line ( Sku ); +ALTER MESSAGE DEFINITION COLLECTION Sales.MD_Order ADD DEFINITION Line FOR Sales.Line { Sku }; ALTER MESSAGE DEFINITION COLLECTION Sales.MD_Order RENAME DEFINITION Line TO OrderLine; ALTER MESSAGE DEFINITION COLLECTION Sales.MD_Order DROP DEFINITION IF EXISTS OrderLine; ``` diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index fad6388442..6fa0220aa4 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -483,8 +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: ` 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 { } };` | 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. `` 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 with { } drop ; };` | 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 | +| Insert into a stored microflow | `alter microflow Module.Name { insert after begin end; };` | 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. `` 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. The fragment is `begin … end` like a microflow body; `{ … }` is the deprecated spelling MDL-DEPR074 | +| Replace or drop an activity | `alter microflow Module.Name { replace with begin end; drop ; };` | 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 | @@ -1061,22 +1061,22 @@ Respond in {{Language}}.$$, UserPrompt: 'Ask me anything.' ) { - mcp service Module.WebSearch { + mcp service Module.WebSearch ( Enabled: true - } + ) - knowledge base KBAlias { + knowledge base KBAlias ( source: Module.ProductDocs, collection: 'product-docs', MaxResults: 5, description: 'Product documentation', Enabled: true - } + ) - tool MyMicroflowTool { + tool MyMicroflowTool ( description: 'Fetch customer data', Enabled: true - } + ) }; ``` @@ -1092,7 +1092,7 @@ Respond in {{Language}}.$$, |-----------|--------|-------| | Show collections | `show image collection [in module];` | List all or filter by module | | Describe collection | `describe image collection Module.Name;` | Full MDL output with embedded images | -| Create collection | `create image collection Module.Name [folder 'path'] [export level 'Hidden'\|'Public'] [comment 'text'] [(image Name from file 'path', ...)];` | With or without images | +| Create collection | `create image collection Module.Name [folder 'path'] [export level 'Hidden'\|'Public'] [comment 'text'] [{ image Name ( File: 'path' ) ... }];` | With or without images | | Create or modify | `create or modify image collection Module.Name [...];` | Preserves UUID — preferred for AI agents | | Drop collection | `drop image collection [if exists] Module.Name;` | Removes collection and all embedded images | | Show an image on a page | `image imgLogo (Image: 'Module.Collection.ImageName');` | Three-part name, like an icon reference. `describe image collection` lists the names | @@ -1153,7 +1153,7 @@ create consumed rest service Module.Api ( authentication: none ) { - operation GetItems { + operation GetItems ( method: get, path: '/items/{id}', parameters: ($id: string), @@ -1161,9 +1161,9 @@ create consumed rest service Module.Api ( headers: ('Accept' = 'application/json'), timeout: 30, response: json as $Result - } + ) - operation CreateItem { + operation CreateItem ( method: post, path: '/items', headers: ('Content-Type' = 'application/json'), @@ -1175,7 +1175,7 @@ create consumed rest service Module.Api ( Id = id, status = status, } - } + ) }; ``` diff --git a/docs/11-proposals/PROPOSAL_mdl_beta_syntax_freeze.md b/docs/11-proposals/PROPOSAL_mdl_beta_syntax_freeze.md index 0277686199..d17f698abc 100644 --- a/docs/11-proposals/PROPOSAL_mdl_beta_syntax_freeze.md +++ b/docs/11-proposals/PROPOSAL_mdl_beta_syntax_freeze.md @@ -150,6 +150,7 @@ What would change: - `on error { … }` is the only brace block inside a microflow. It becomes `on error [without rollback] begin … end error;`. - `while` makes both `begin` and `end while` optional. Make both required, the same as `loop`. - The `split type` body becomes mandatory. +- *Decided in ako/mxcli#754:* an `alter microflow` / `alter nanoflow` fragment is imperative content, so it is `begin … end`, not `{ … }`: `insert after $X begin … end;`, `replace with begin … end;`. The fragment is written exactly as the body of `create microflow` is (ADR-0012). The operations around it are the alter's declarative children and stay in the alter's `{ }`. The brace fragment is a deprecated alias (MDL-DEPR074). **Before** — properties in braces, children in parentheses, menu items separated by `;`, braces inside a microflow: diff --git a/mdl-examples/bug-tests/1059-widget-icon-elements.mdl b/mdl-examples/bug-tests/1059-widget-icon-elements.mdl index 81bad527d6..0c22fee7e2 100644 --- a/mdl-examples/bug-tests/1059-widget-icon-elements.mdl +++ b/mdl-examples/bug-tests/1059-widget-icon-elements.mdl @@ -26,9 +26,9 @@ -- MARK: Setup -create or modify image collection IconProbe.Images ( - image marker from file '../../docs/images/mxcli-cli.png' -); +create or modify image collection IconProbe.Images { + image marker ( File: '../../docs/images/mxcli-cli.png' ) +}; -- MARK: The three icon elements diff --git a/mdl-examples/bug-tests/200-basic-auth-rest-client.mdl b/mdl-examples/bug-tests/200-basic-auth-rest-client.mdl index c41e95582d..46535c4f3f 100644 --- a/mdl-examples/bug-tests/200-basic-auth-rest-client.mdl +++ b/mdl-examples/bug-tests/200-basic-auth-rest-client.mdl @@ -46,13 +46,13 @@ create rest client AuthTest.HttpBinAuthAPI ( ) { /** Calls /basic-auth/testuser/testpass123! — returns 200 if auth header sent */ - operation CheckAuth { + operation CheckAuth ( method: get, path: '/basic-auth/testuser/testpass123!', headers: ('Accept' = 'application/json'), timeout: 30, response: none - } + ) }; -- ############################################################################ diff --git a/mdl-examples/bug-tests/464-alter-agent-editor.mdl b/mdl-examples/bug-tests/464-alter-agent-editor.mdl index 43d3d55475..3a2f26acc5 100644 --- a/mdl-examples/bug-tests/464-alter-agent-editor.mdl +++ b/mdl-examples/bug-tests/464-alter-agent-editor.mdl @@ -64,5 +64,5 @@ ALTER CONSUMED MCP SERVICE Bug464.MyMCP SET ConnectionTimeoutSeconds = 60, Versi -- ALTER AGENT — combines SET, ADD, and DROP in one statement. ALTER AGENT Bug464.MyAgent SET SystemPrompt = 'updated prompt', Temperature = 0.7, MaxTokens = 4096 - ADD KNOWLEDGE BASE Docs { Source: Bug464.MyKB, Collection: 'docs', MaxResults: 5 } - ADD MCP SERVICE Bug464.MyMCP { Description: 'weather', Enabled: true }; + ADD KNOWLEDGE BASE Docs ( Source: Bug464.MyKB, Collection: 'docs', MaxResults: 5 ) + ADD MCP SERVICE Bug464.MyMCP ( Description: 'weather', Enabled: true ); diff --git a/mdl-examples/bug-tests/556-reexec-identity-stability.mdl b/mdl-examples/bug-tests/556-reexec-identity-stability.mdl index 2e43100498..91295aa965 100644 --- a/mdl-examples/bug-tests/556-reexec-identity-stability.mdl +++ b/mdl-examples/bug-tests/556-reexec-identity-stability.mdl @@ -53,9 +53,9 @@ CREATE OR MODIFY REST CLIENT BugReexecIdentity.PartsAvailabilityAPI ( Authentication: NONE ) { - OPERATION GetPart { + OPERATION GetPart ( Method: GET, Path: '/parts/{id}', Parameters: ($id: String) - } + ) }; diff --git a/mdl-examples/bug-tests/843-rest-response-mapping-no-body.fail.mdl b/mdl-examples/bug-tests/843-rest-response-mapping-no-body.fail.mdl index c797a52c97..ddb253b58e 100644 --- a/mdl-examples/bug-tests/843-rest-response-mapping-no-body.fail.mdl +++ b/mdl-examples/bug-tests/843-rest-response-mapping-no-body.fail.mdl @@ -41,11 +41,11 @@ create rest client ZZB."MESSlice" ( Authentication: none ) { - operation "SearchRoutes" { + operation "SearchRoutes" ( Method: get, Path: '/routes', Query: ($page: String, $pageSize: String), Timeout: 300, Response: mapping ZZB."IMM_R10" - } + ) }; diff --git a/mdl-examples/bug-tests/843-rest-response-mapping.mdl b/mdl-examples/bug-tests/843-rest-response-mapping.mdl index a6f587f8b2..c1d0bdb048 100644 --- a/mdl-examples/bug-tests/843-rest-response-mapping.mdl +++ b/mdl-examples/bug-tests/843-rest-response-mapping.mdl @@ -41,7 +41,7 @@ create rest client ZZB."MESSlice" ( Authentication: none ) { - operation "SearchRoutes" { + operation "SearchRoutes" ( Method: get, Path: '/routes/{id}', Parameters: ($id: String), @@ -55,5 +55,5 @@ create rest client ZZB."MESSlice" ( RoutingCode = routing_code, RoutingName = routing_name } - } + ) }; diff --git a/mdl-examples/bug-tests/messagedef-referenceset-cardinality.mdl b/mdl-examples/bug-tests/messagedef-referenceset-cardinality.mdl index 98bfb66c13..d74d05c0a4 100644 --- a/mdl-examples/bug-tests/messagedef-referenceset-cardinality.mdl +++ b/mdl-examples/bug-tests/messagedef-referenceset-cardinality.mdl @@ -65,26 +65,26 @@ create or modify association MsgCard.Order_Tag / create or modify message definition collection MsgCard.MD_Orders -( +{ -- The forward traversal of each. Before the fix both stored MaxOccurs 1; -- only Tags was wrong. - definition "OrderMsg" for MsgCard.Order as 'Order' ( + definition "OrderMsg" for MsgCard.Order as 'Order' { "OrderNo", - MsgCard.Order_Customer/MsgCard.Customer as 'Customer' ( + MsgCard.Order_Customer/MsgCard.Customer as 'Customer' { "Name" - ), - MsgCard.Order_Tag/MsgCard.Tag as 'Tags' ( + }, + MsgCard.Order_Tag/MsgCard.Tag as 'Tags' { "Label" - ) - ), + } + }, -- The reverse of the set, which was already right and must stay right. - definition "TagMsg" for MsgCard.Tag as 'Tag' ( + definition "TagMsg" for MsgCard.Tag as 'Tag' { "Label", - MsgCard.Order_Tag/MsgCard.Order as 'Orders' ( + MsgCard.Order_Tag/MsgCard.Order as 'Orders' { "OrderNo" - ) - ) -); + } + } +}; / -- The mapping is where CE0295 lands: an object mapping element bound to an diff --git a/mdl-examples/bug-tests/rest-file-request-body.fail.mdl b/mdl-examples/bug-tests/rest-file-request-body.fail.mdl index 514799603d..2c35c3853e 100644 --- a/mdl-examples/bug-tests/rest-file-request-body.fail.mdl +++ b/mdl-examples/bug-tests/rest-file-request-body.fail.mdl @@ -36,9 +36,9 @@ create rest client FileBody.RC_Upload ( Authentication: NONE ) { - OPERATION PostFile { + OPERATION PostFile ( Method: POST, Path: '/post', Body: FILE FROM $Doc - } + ) }; diff --git a/mdl-examples/bug-tests/rest-inline-mapping-paths.mdl b/mdl-examples/bug-tests/rest-inline-mapping-paths.mdl index 2b83875f80..c7d3a8621a 100644 --- a/mdl-examples/bug-tests/rest-inline-mapping-paths.mdl +++ b/mdl-examples/bug-tests/rest-inline-mapping-paths.mdl @@ -76,7 +76,7 @@ create or modify rest client RestInline.RC_Probe ( { -- #36: the nested member must reach (Object)|fields|Title, not -- (Object)|fields/Title. - OPERATION GetItem { + OPERATION GetItem ( Method: GET, Path: '/items/{id}', Parameters: ($id: String), @@ -84,11 +84,11 @@ create or modify rest client RestInline.RC_Probe ( ItemId = "id", Title = "fields/Title" } - } + ) -- #37: the nested element here is an EXPORT object element, so it must not -- be ObjectHandling "Create". - OPERATION PostTask { + OPERATION PostTask ( Method: POST, Path: '/tasks', Body: MAPPING RestInline.Task { @@ -96,5 +96,5 @@ create or modify rest client RestInline.RC_Probe ( "Title" = Title } } - } + ) }; diff --git a/mdl-examples/doctype-tests/06-rest-client-examples.mdl b/mdl-examples/doctype-tests/06-rest-client-examples.mdl index e4069092d1..3edea9948a 100644 --- a/mdl-examples/doctype-tests/06-rest-client-examples.mdl +++ b/mdl-examples/doctype-tests/06-rest-client-examples.mdl @@ -59,12 +59,12 @@ create rest client RestTest.RC001_SimpleAPI ( authentication: none ) { - operation GetStatus { + operation GetStatus ( method: get, path: '/echo', headers: ('Accept' = '*/*'), response: none - } + ) }; -- ============================================================================ @@ -78,12 +78,12 @@ create rest client RestTest.RC002_JsonAPI ( authentication: none ) { - operation GetPosts { + operation GetPosts ( method: get, path: '/posts', headers: ('Accept' = 'application/json'), response: json as $Posts - } + ) }; -- ============================================================================ @@ -97,13 +97,13 @@ create rest client RestTest.RC003_UserAPI ( authentication: none ) { - operation GetUser { + operation GetUser ( method: get, path: '/users/{userId}', parameters: ($userId: integer), headers: ('Accept' = 'application/json'), response: json as $user - } + ) }; -- ############################################################################ @@ -124,46 +124,46 @@ create rest client RestTest.RC004_CrudAPI ( ) { /** Retrieve all resources */ - operation GetAll { + operation GetAll ( method: get, path: '/get', headers: ('Accept' = 'application/json'), response: json as $Resources - } + ) /** Create a new resource */ - operation CreateResource { + operation CreateResource ( method: post, path: '/post', headers: ('Accept' = 'application/json'), body: json from $NewResource, response: json as $CreatedResource - } + ) /** Fully update an existing resource */ - operation UpdateResource { + operation UpdateResource ( method: put, path: '/put', headers: ('Accept' = 'application/json'), body: json from $UpdatedResource, response: json as $resource - } + ) /** Partially update an existing resource */ - operation "PatchResource" { + operation "PatchResource" ( method: patch, path: '/patch', headers: ('Accept' = 'application/json'), body: json from $PatchData, response: json as $resource - } + ) /** Delete a resource */ - operation "DeleteResource" { + operation "DeleteResource" ( method: delete, path: '/delete', response: none - } + ) }; -- ############################################################################ @@ -181,12 +181,12 @@ create rest client RestTest.RC005_SearchAPI ( authentication: none ) { - operation search { + operation search ( method: get, path: '/anything/search', query: ($q: string), response: json as $Results - } + ) }; -- ============================================================================ @@ -201,12 +201,12 @@ create rest client RestTest.RC006_PaginatedAPI ( ) { /** Search with pagination */ - operation SearchPaginated { + operation SearchPaginated ( method: get, path: '/anything/items', query: ($search: string, $page: integer, $limit: integer, $sortBy: string, $ascending: boolean), response: json as $Items - } + ) }; -- ============================================================================ @@ -219,13 +219,13 @@ create rest client RestTest.RC007_FilteredAPI ( ) { /** Get items in a category with filters */ - operation GetCategoryItems { + operation GetCategoryItems ( method: get, path: '/anything/categories/{categoryId}/items', parameters: ($categoryId: integer), query: ($status: string, $minPrice: decimal, $maxPrice: decimal), response: json as $Items - } + ) }; -- ############################################################################ @@ -245,12 +245,12 @@ create rest client RestTest.RC008_HeaderAPI ( authentication: none ) { - operation GetData { + operation GetData ( method: get, path: '/headers', headers: ('Accept' = 'application/json', 'X-API-Version' = '2.0', 'X-Client-ID' = 'mendix-repl'), response: json as $data - } + ) }; -- ============================================================================ @@ -264,12 +264,12 @@ create rest client RestTest.RC009_AuthHeaderAPI ( authentication: none ) { - operation GetSecureData { + operation GetSecureData ( method: get, path: '/headers', headers: ('Accept' = 'application/json', 'Authorization' = 'Bearer my-api-token', 'X-Request-ID' = 'request-001'), response: json as $data - } + ) }; -- ============================================================================ @@ -281,13 +281,13 @@ create rest client RestTest.RC010_ContentTypeAPI ( authentication: none ) { - operation PostJson { + operation PostJson ( method: post, path: '/post', headers: ('Content-Type' = 'application/json', 'Accept' = 'application/json'), body: json from $Payload, response: json as $Result - } + ) }; -- ############################################################################ @@ -305,11 +305,11 @@ create rest client RestTest.RC011_PublicAPI ( authentication: none ) { - operation GetPublicData { + operation GetPublicData ( method: get, path: '/get', response: json as $data - } + ) }; -- ============================================================================ @@ -321,11 +321,11 @@ create rest client RestTest.RC012_BasicAuthAPI ( authentication: basic (username: 'api_user', password: 'api_secret') ) { - operation GetProtectedData { + operation GetProtectedData ( method: get, path: '/basic-auth/api_user/api_secret', response: json as $data - } + ) }; -- ============================================================================ @@ -339,12 +339,12 @@ create rest client RestTest.RC013_SecureAuthAPI ( authentication: basic (username: 'testuser', password: 'testpass') ) { - operation GetSecureData { + operation GetSecureData ( method: get, path: '/hidden-basic-auth/testuser/testpass', headers: ('Accept' = 'application/json'), response: json as $data - } + ) }; -- ############################################################################ @@ -362,13 +362,13 @@ create rest client RestTest.RC014_JsonBodyAPI ( authentication: none ) { - operation AddItem { + operation AddItem ( method: post, path: '/post', headers: ('Content-Type' = 'application/json'), body: json from $ItemData, response: json as $CreatedItem - } + ) }; -- ============================================================================ @@ -392,11 +392,11 @@ create rest client RestTest.RC015_FileDownloadAPI ( authentication: none ) { - operation DownloadFile { + operation DownloadFile ( method: get, path: '/image/png', response: file as $Downloaded - } + ) }; -- ############################################################################ @@ -417,40 +417,40 @@ create rest client RestTest.RC016_ResponseTypesAPI ( ) { /** JSON response mapped to entity */ - operation GetJson { + operation GetJson ( method: get, path: '/json', response: json as $JsonData - } + ) /** Raw string response */ - operation GetString { + operation GetString ( method: get, path: '/robots.txt', response: string as $TextContent - } + ) /** Binary file download */ - operation DownloadFile { + operation DownloadFile ( method: get, path: '/bytes/{fileId}', parameters: ($fileId: string), response: file as $FileDocument - } + ) /** HTTP status code only */ - operation CheckStatus { + operation CheckStatus ( method: get, path: '/status/200', response: status as $StatusCode - } + ) /** No response expected */ - operation Ping { + operation Ping ( method: get, path: '/status/204', response: none - } + ) }; -- ############################################################################ @@ -471,21 +471,21 @@ create rest client RestTest.RC017_LongRunningAPI ( ) { /** Quick health check with short timeout */ - operation HealthCheck { + operation HealthCheck ( method: get, path: '/status/200', timeout: 5, response: status as $status - } + ) /** Report generation with extended timeout (delay/10 waits 10 seconds) */ - operation GenerateReport { + operation GenerateReport ( method: post, path: '/delay/10', body: json from $ReportParams, timeout: 600, response: json as $ReportResult - } + ) }; -- ############################################################################ @@ -513,63 +513,63 @@ create rest client RestTest.RC018_PetStoreAPI ( * @param status Filter by pet status (available, pending, sold) * @param limit Maximum number of pets to return */ - operation ListPets { + operation ListPets ( method: get, path: '/pet/findByStatus', query: ($status: string), headers: ('Accept' = 'application/json'), timeout: 30, response: json as $PetList - } + ) /** * Get a single pet by ID * @param petId The unique identifier of the pet */ - operation GetPet { + operation GetPet ( method: get, path: '/pet/{petId}', parameters: ($petId: integer), headers: ('Accept' = 'application/json'), response: json as $Pet - } + ) /** * Create a new pet * Adds a new pet to the store inventory. */ - operation AddPet { + operation AddPet ( method: post, path: '/pet', headers: ('Content-Type' = 'application/json', 'Accept' = 'application/json'), body: json from $NewPet, response: json as $CreatedPet - } + ) /** * Update an existing pet * Modifies all fields of an existing pet record. */ - operation ModifyPet { + operation ModifyPet ( method: put, path: '/pet', headers: ('Content-Type' = 'application/json', 'Accept' = 'application/json'), body: json from $UpdatedPet, response: json as $Pet - } + ) /** * Delete a pet * Removes a pet from the store inventory. * @param petId The unique identifier of the pet to delete */ - operation RemovePet { + operation RemovePet ( method: delete, path: '/pet/{petId}', parameters: ($petId: integer), headers: ('api_key' = 'special-key'), response: none - } + ) -- Upload pet image: not expressible HERE. `body: file from $ImageFile` is -- refused as MDL-REST02 — a consumed operation has no binary request body. @@ -590,41 +590,41 @@ create rest client RestTest.RC019_GitHubAPI ( ) { /** Get repository information */ - operation GetRepository { + operation GetRepository ( method: get, path: '/repos/{owner}/{repo}', parameters: ($owner: string, $repo: string), headers: ('Accept' = 'application/vnd.github.v3+json', 'User-Agent' = 'Mendix-REPL'), response: json as $Repository - } + ) /** List repository issues */ - operation ListIssues { + operation ListIssues ( method: get, path: '/repos/{owner}/{repo}/issues', parameters: ($owner: string, $repo: string), query: ($state: string, $labels: string, $page: integer, $per_page: integer), headers: ('Accept' = 'application/vnd.github.v3+json', 'User-Agent' = 'Mendix-REPL'), response: json as $Issues - } + ) /** Create an issue (requires authentication) */ - operation AddIssue { + operation AddIssue ( method: post, path: '/repos/{owner}/{repo}/issues', parameters: ($owner: string, $repo: string), headers: ('Accept' = 'application/vnd.github.v3+json', 'User-Agent' = 'Mendix-REPL', 'Authorization' = 'token ghp_exampletoken123'), body: json from $IssueData, response: json as $CreatedIssue - } + ) /** Get authenticated user */ - operation GetAuthenticatedUser { + operation GetAuthenticatedUser ( method: get, path: '/user', headers: ('Accept' = 'application/vnd.github.v3+json', 'User-Agent' = 'Mendix-REPL', 'Authorization' = 'token ghp_exampletoken123'), response: json as $user - } + ) }; -- ============================================================================ @@ -641,31 +641,31 @@ create rest client RestTest.RC020_WeatherAPI ( ) { /** Get current weather for a city */ - operation GetCurrentWeather { + operation GetCurrentWeather ( method: get, path: '/weather', query: ($q: string, $appid: string, $units: string), headers: ('Accept' = 'application/json'), response: json as $Weather - } + ) /** Get 5-day forecast */ - operation GetForecast { + operation GetForecast ( method: get, path: '/forecast', query: ($q: string, $appid: string, $units: string, $cnt: integer), headers: ('Accept' = 'application/json'), response: json as $Forecast - } + ) /** Get weather by coordinates */ - operation GetWeatherByCoords { + operation GetWeatherByCoords ( method: get, path: '/weather', query: ($lat: decimal, $lon: decimal, $appid: string, $units: string), headers: ('Accept' = 'application/json'), response: json as $Weather - } + ) }; -- ============================================================================ @@ -682,48 +682,48 @@ create rest client RestTest.RC021_PaymentAPI ( ) { /** Create a payment intent */ - operation InitiatePayment { + operation InitiatePayment ( method: post, path: '/payment_intents', headers: ('Content-Type' = 'application/json', 'Accept' = 'application/json', 'Idempotency-Key' = 'idem-key-001'), body: json from $PaymentData, timeout: 30, response: json as $PaymentIntent - } + ) /** Retrieve a payment intent */ - operation GetPaymentIntent { + operation GetPaymentIntent ( method: get, path: '/payment_intents/{paymentIntentId}', parameters: ($paymentIntentId: string), response: json as $PaymentIntent - } + ) /** Confirm a payment */ - operation ConfirmPayment { + operation ConfirmPayment ( method: post, path: '/payment_intents/{paymentIntentId}/confirm', parameters: ($paymentIntentId: string), headers: ('Content-Type' = 'application/json'), body: json from $ConfirmData, response: json as $PaymentIntent - } + ) /** Cancel a payment */ - operation CancelPayment { + operation CancelPayment ( method: post, path: '/payment_intents/{paymentIntentId}/cancel', parameters: ($paymentIntentId: string), response: json as $PaymentIntent - } + ) /** List all payments */ - operation ListPayments { + operation ListPayments ( method: get, path: '/payment_intents', query: ($limit: integer, $starting_after: string, $created: integer), response: json as $PaymentList - } + ) }; -- ############################################################################ diff --git a/mdl-examples/doctype-tests/19-image-collection-examples.mdl b/mdl-examples/doctype-tests/19-image-collection-examples.mdl index a916549f3c..f55536cf21 100644 --- a/mdl-examples/doctype-tests/19-image-collection-examples.mdl +++ b/mdl-examples/doctype-tests/19-image-collection-examples.mdl @@ -73,10 +73,10 @@ create image collection ImgTest.BrandAssets * * Supported formats: PNG, SVG, GIF, JPEG, BMP, WebP */ -create image collection ImgTest.NavigationIcons ( - image cli_screenshot from file '../../docs/images/mxcli-cli.png', - image repl_screenshot from file '../../docs/images/mxcli-repl.png' -); +create image collection ImgTest.NavigationIcons { + image cli_screenshot ( File: '../../docs/images/mxcli-cli.png' ) + image repl_screenshot ( File: '../../docs/images/mxcli-repl.png' ) +}; / /** @@ -84,10 +84,10 @@ create image collection ImgTest.NavigationIcons ( */ create image collection ImgTest.AppLogos export level 'Public' - comment 'Application logos for branding' ( - image vscode_ext from file '../../docs/images/mxcli-vscode-ext.png', - image catalog_view from file '../../docs/images/mxcli-catalog.png' -); + comment 'Application logos for branding' { + image vscode_ext ( File: '../../docs/images/mxcli-vscode-ext.png' ) + image catalog_view ( File: '../../docs/images/mxcli-catalog.png' ) +}; / -- MARK: Show diff --git a/mdl-examples/doctype-tests/28-agent-editor-examples.mdl b/mdl-examples/doctype-tests/28-agent-editor-examples.mdl index a83629ea4a..d0c66d1df5 100644 --- a/mdl-examples/doctype-tests/28-agent-editor-examples.mdl +++ b/mdl-examples/doctype-tests/28-agent-editor-examples.mdl @@ -132,17 +132,17 @@ create agent AgentTest."ResearchAssistant" ( UserPrompt: 'Find information about quantum computing.' ) { - mcp service AgentTest.WebSearch { + mcp service AgentTest.WebSearch ( Enabled: true - } + ) - knowledge base ProductKB { + knowledge base ProductKB ( source: AgentTest.ProductDocs, collection: 'product-docs', MaxResults: 5, description: 'Product documentation', Enabled: true - } + ) }; / @@ -224,8 +224,8 @@ alter consumed mcp service AgentTest.WebSearch -- Add a TOOL (Microflow), MCP SERVICE, and KNOWLEDGE BASE in one statement. alter agent AgentTest.ResearchAssistant set SystemPrompt = 'Updated assistant prompt.', Temperature = 0.7, MaxTokens = 4096 - add knowledge base ExtraDocs { Source: AgentTest.ProductDocs, Collection: 'extra', MaxResults: 3 } - add mcp service AgentTest.WebSearch { Description: 'web search', Enabled: true }; + add knowledge base ExtraDocs ( Source: AgentTest.ProductDocs, Collection: 'extra', MaxResults: 3 ) + add mcp service AgentTest.WebSearch ( Description: 'web search', Enabled: true ); -- ALTER AGENT: drop collection entries (TOOL by name, MCP SERVICE by -- qualified name, KNOWLEDGE BASE by per-agent name). diff --git a/mdl-examples/doctype-tests/40-message-definition-examples.mdl b/mdl-examples/doctype-tests/40-message-definition-examples.mdl index 8e22aef60a..99dac979e0 100644 --- a/mdl-examples/doctype-tests/40-message-definition-examples.mdl +++ b/mdl-examples/doctype-tests/40-message-definition-examples.mdl @@ -57,22 +57,22 @@ create association MsgTest.OrderLine_Order from MsgTest.OrderLine to MsgTest.Ord create message definition collection MsgTest.MD_Order folder 'Messages' -( - definition OrderMessage for MsgTest.Order as 'Orders' ( +{ + definition OrderMessage for MsgTest.Order as 'Orders' { OrderId, OrderDate, TotalAmount as 'Total', - MsgTest.OrderLine_Order/MsgTest.OrderLine as 'Lines' ( + MsgTest.OrderLine_Order/MsgTest.OrderLine as 'Lines' { Sku, Quantity - ), - MsgTest.Order_Customer/MsgTest.Customer ( + }, + MsgTest.Order_Customer/MsgTest.Customer { FirstName, LastName, Address example 'Kerstraat 5, Leiden, Netherlands' - ) - ) -); + } + } +}; -- ============================================================================ -- Level 2: several definitions, and the same association both ways @@ -84,16 +84,16 @@ create message definition collection MsgTest.MD_Order create or modify message definition collection MsgTest.MD_Order folder 'Messages' -( - definition OrderMessage for MsgTest.Order as 'Orders' ( +{ + definition OrderMessage for MsgTest.Order as 'Orders' { OrderId, - MsgTest.Order_Customer/MsgTest.Customer ( FirstName ) - ), - definition CustomerOrders for MsgTest.Customer as 'Customers' ( + MsgTest.Order_Customer/MsgTest.Customer { FirstName } + }, + definition CustomerOrders for MsgTest.Customer as 'Customers' { FirstName, - MsgTest.Order_Customer/MsgTest.Order as 'Orders' ( OrderId, OrderDate ) - ) -); + MsgTest.Order_Customer/MsgTest.Order as 'Orders' { OrderId, OrderDate } + } +}; -- ============================================================================ -- Level 3: targeted edits @@ -118,7 +118,7 @@ alter message definition MsgTest.MD_Order.OrderMessage drop member LastName in C -- Definitions within the collection. alter message definition collection MsgTest.MD_Order - add definition LineMessage for MsgTest.OrderLine ( Sku, Quantity ); + add definition LineMessage for MsgTest.OrderLine { Sku, Quantity }; alter message definition collection MsgTest.MD_Order rename definition LineMessage to Lines; alter message definition collection MsgTest.MD_Order drop definition if exists Lines; diff --git a/mdl-examples/use-cases/ai-agent-platform-demo.mdl b/mdl-examples/use-cases/ai-agent-platform-demo.mdl index 528c8a20c1..2d5e70b6fa 100644 --- a/mdl-examples/use-cases/ai-agent-platform-demo.mdl +++ b/mdl-examples/use-cases/ai-agent-platform-demo.mdl @@ -377,17 +377,17 @@ Guidelines: UserPrompt: 'What is the customer''s issue and how can I help?' ) { - knowledge base SupportKB { + knowledge base SupportKB ( source: AICustSupport.SupportKnowledgeBase, collection: 'support-docs', MaxResults: 5, description: 'Internal knowledge base: product docs, known issues, runbooks', Enabled: true - } + ) - mcp service AICustSupport.WebSearchMCP { + mcp service AICustSupport.WebSearchMCP ( Enabled: true - } + ) }; -- Guardrail agent: validates AI responses for safety and policy compliance From c380d6126e001f827bc759a128d929a6bf0279b1 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 05:44:53 +0000 Subject: [PATCH 04/35] mdl: alter sets properties in create's ( Key: value ) list; colons where R3 puts them (#751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 of the beta syntax freeze: `:` sets a model property, so every `alter` takes the same `( Key: value, … )` list its `create` does, and a colon is written in a property list or an attribute definition and nowhere else. Canonical forms, parsed under mdl 0 and mdl 1; the old spellings keep parsing as registered aliases that warn and that `fmt --upgrade` rewrites: - MDL-DEPR060 alter settings
/ configuration 'X' and create configuration: `Key = v, …` -> `( Key: v, … )` - MDL-DEPR061 alter consumed|published odata service X set Key = v -> set ( Key: v, … ), exactly create's property list - MDL-DEPR062 alter styling … set Class = 'x', 'P' = on -> set ( … ) - MDL-DEPR063 alter entity … set allow_create_change_locally = v -> set ( AllowCreateChangeLocally: v ) - MDL-DEPR064 association `type: Reference` / owner: / storage: -> no colon - MDL-DEPR065 modify attribute A T -> modify attribute A: T - MDL-DEPR101..103, the generic alter page/snippet/layout aliases, move from the executor's provisional table into the registry, with rewrites. describe settings now writes the canonical lists, and the MDL mxcli generates (testrunner, new-project hints) uses them. Docs, skills, `mxcli syntax` and the quick reference follow. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/mendix/bootstrap-app/SKILL.md | 5 +- .claude/skills/mendix/create-page/SKILL.md | 6 +- .claude/skills/mendix/demo-data/SKILL.md | 15 +- .../mendix/migrate-design-prototype/SKILL.md | 2 +- .../reference/walkthroughs.md | 4 +- .../skills/mendix/project-settings/SKILL.md | 104 +++--- .../mendix/regular-expressions/SKILL.md | 2 +- .claude/skills/mendix/write-layouts/SKILL.md | 4 +- cmd/mxcli/cmd_new.go | 2 +- cmd/mxcli/cmd_new_java.go | 4 +- cmd/mxcli/docker/detect.go | 2 +- cmd/mxcli/syntax/features_domain_model.go | 4 +- cmd/mxcli/syntax/features_integration.go | 7 +- cmd/mxcli/syntax/features_misc.go | 44 +-- cmd/mxcli/syntax/features_page.go | 10 +- .../testrunner/generator_endpoint_test.go | 8 +- cmd/mxcli/testrunner/host.go | 2 +- cmd/mxcli/testrunner/runner.go | 6 +- cmd/mxcli/testrunner/runner_cleanup_test.go | 10 +- cmd/mxcli/testrunner/runner_endpoint.go | 4 +- docs-site/src/appendixes/quick-reference.md | 26 +- docs-site/src/examples/alter-page.md | 16 +- docs-site/src/language/alter-entity.md | 4 +- docs-site/src/language/project-settings.md | 22 +- docs-site/src/language/snippets.md | 2 +- docs-site/src/reference/page/alter-layout.md | 8 +- .../src/reference/settings/alter-settings.md | 33 +- docs-site/src/tools/docker-run.md | 4 +- docs/01-project/MDL_QUICK_REFERENCE.md | 20 +- mdl/ast/ast_alter_page.go | 17 - mdl/deprecation/deprecation.go | 137 +++++++- mdl/executor/alter_aliases.go | 149 --------- mdl/executor/cmd_settings.go | 66 ++-- .../cmd_settings_configuration_test.go | 2 +- .../cmd_settings_describe_canonical_test.go | 67 ++++ mdl/executor/cmd_settings_languages.go | 2 +- mdl/executor/validate_alter_page_addresses.go | 47 +++ ... => validate_alter_page_addresses_test.go} | 52 +-- mdl/executor/validate_program.go | 3 - mdl/grammar/MDLParser.g4 | 30 +- mdl/grammar/domains/MDLDomainModel.g4 | 16 +- mdl/grammar/domains/MDLService.g4 | 15 +- mdl/grammar/domains/MDLSettings.g4 | 22 +- mdl/upgrade/document_names_test.go | 4 +- mdl/upgrade/r3_property_lists_test.go | 60 ++++ mdl/visitor/r3_property_lists_test.go | 140 ++++++++ mdl/visitor/r8_spellings_test.go | 4 +- mdl/visitor/visitor_alter.go | 26 +- .../visitor_alter_alias_equivalence_test.go | 40 +-- mdl/visitor/visitor_alter_generic_test.go | 58 ++-- mdl/visitor/visitor_alter_page.go | 18 +- mdl/visitor/visitor_document_names_test.go | 10 +- mdl/visitor/visitor_entity.go | 3 + mdl/visitor/visitor_odata_expression.go | 6 + mdl/visitor/visitor_r3_property_lists.go | 300 ++++++++++++++++++ mdl/visitor/visitor_r8_spellings.go | 1 + mdl/visitor/visitor_settings.go | 81 +++-- 57 files changed, 1158 insertions(+), 598 deletions(-) delete mode 100644 mdl/executor/alter_aliases.go create mode 100644 mdl/executor/cmd_settings_describe_canonical_test.go create mode 100644 mdl/executor/validate_alter_page_addresses.go rename mdl/executor/{alter_aliases_test.go => validate_alter_page_addresses_test.go} (63%) create mode 100644 mdl/upgrade/r3_property_lists_test.go create mode 100644 mdl/visitor/r3_property_lists_test.go create mode 100644 mdl/visitor/visitor_r3_property_lists.go diff --git a/.claude/skills/mendix/bootstrap-app/SKILL.md b/.claude/skills/mendix/bootstrap-app/SKILL.md index 683b8e0e81..a2a4fca33e 100644 --- a/.claude/skills/mendix/bootstrap-app/SKILL.md +++ b/.claude/skills/mendix/bootstrap-app/SKILL.md @@ -253,8 +253,9 @@ with these deltas: against the host name rather than the listen address: ```sql - alter settings configuration 'Default' - ApplicationRootUrl = 'http://backend.local:8080/'; + alter settings configuration 'Default' ( + ApplicationRootUrl: 'http://backend.local:8080/' + ); ``` `run --local` picks that up at boot and prints which configuration it came from. diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 3f944f0807..65dd872670 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -47,11 +47,11 @@ create [or replace] page Module.PageName page is opened in a pop-up. They are optional — omitting them uses the Mendix defaults (600 × 600, not resizable). Unlike the other header keywords, these property names are **case-sensitive** and must be written exactly as shown. They can also be changed later -with `alter page … { set PopupWidth = …; }` (see the alter-page skill). +with `alter page … { set (PopupWidth: …); }` (see the alter-page skill). **Page CSS class / style** (`Class` / `Style`) set the page's Appearance — a CSS class and inline style applied to the whole page (e.g. `Class: 'container-fluid bg-light'`). -Both are optional and can be changed later with `alter page … { set Class = '…'; }`. +Both are optional and can be changed later with `alter page … { set (Class: '…'); }`. **Page Variables**: Local variables at the page level for use in expressions (e.g., column visibility). - DataType: `boolean`, `string`, `integer`, `decimal`, `datetime` @@ -243,7 +243,7 @@ To make targeted changes to an existing page (change a label, add a field, remov ```sql -- Change a button caption and add a field alter page Module.Customer_Edit { - set caption = 'Save & Close' on btnSave; + set (caption: 'Save & Close') on btnSave; insert after txtEmail { textbox txtPhone (label: 'Phone', attribute: Phone) } diff --git a/.claude/skills/mendix/demo-data/SKILL.md b/.claude/skills/mendix/demo-data/SKILL.md index faead4d4bf..74975483ba 100644 --- a/.claude/skills/mendix/demo-data/SKILL.md +++ b/.claude/skills/mendix/demo-data/SKILL.md @@ -41,13 +41,14 @@ For full credentials (username, password): Example output: ```sql -alter settings configuration 'Default' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'localhost:5434', - DatabaseName = 'mxcli2-dev', - DatabaseUserName = 'mendix', - DatabasePassword = 'mendix', - HttpPortNumber = 8080; +alter settings configuration 'Default' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'localhost:5434', + DatabaseName: 'mxcli2-dev', + DatabaseUserName: 'mendix', + DatabasePassword: 'mendix', + HttpPortNumber: 8080 +); ``` --- diff --git a/.claude/skills/mendix/migrate-design-prototype/SKILL.md b/.claude/skills/mendix/migrate-design-prototype/SKILL.md index 423769afbd..d270f7a2ed 100644 --- a/.claude/skills/mendix/migrate-design-prototype/SKILL.md +++ b/.claude/skills/mendix/migrate-design-prototype/SKILL.md @@ -538,7 +538,7 @@ Use `alter page` to attach a class without rewriting the page (see `alter-page`) ```sql alter page ResourceScheduling.Approvals { - set Class = 'ss-appr-card ss-appr-card--conflict' on queueCard; + set (Class: 'ss-appr-card ss-appr-card--conflict') on queueCard; } ``` diff --git a/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md b/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md index 54b37a4edd..661a110a20 100644 --- a/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md +++ b/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md @@ -309,8 +309,8 @@ from consumed odata service ShopClient.ShopApiClient ); -- Toggle the flag without recreating the entity. -alter entity ShopClient.Product set allow_create_change_locally = true; -alter entity ShopClient.Product set allow_create_change_locally = false; +alter entity ShopClient.Product set ( AllowCreateChangeLocally: true ); +alter entity ShopClient.Product set ( AllowCreateChangeLocally: false ); ``` ## Publishing a Non-Persistable Entity (no copy of the data) diff --git a/.claude/skills/mendix/project-settings/SKILL.md b/.claude/skills/mendix/project-settings/SKILL.md index bcf305d913..529d8486ab 100644 --- a/.claude/skills/mendix/project-settings/SKILL.md +++ b/.claude/skills/mendix/project-settings/SKILL.md @@ -30,22 +30,22 @@ describe settings; ### Modify Model Settings ```sql -alter settings runtime AfterStartupMicroflow = 'Module.MF_Startup'; -- must return Boolean (CE0142) -alter settings runtime BeforeShutdownMicroflow = 'Module.MF_Shutdown'; -alter settings runtime HealthCheckMicroflow = 'Module.MF_HealthCheck'; -alter settings runtime HashAlgorithm = 'BCrypt'; -alter settings runtime BcryptCost = 12; -alter settings runtime JavaVersion = 'Java21'; -- or '21'; see note below -alter settings runtime RoundingMode = 'HalfUp'; -alter settings runtime AllowUserMultipleSessions = true; -alter settings runtime ScheduledEventTimeZoneCode = 'Etc/UTC'; -alter settings runtime DefaultTimeZoneCode = 'Europe/Amsterdam'; -alter settings runtime FirstDayOfWeek = 'Monday'; -- Default, Monday..Sunday -alter settings runtime DecimalScale = 8; -alter settings runtime EnableDataStorageOptimisticLocking = true; -alter settings runtime UseDatabaseForeignKeyConstraints = true; -alter settings runtime UseOQLVersion2 = true; -alter settings runtime SslCertificateAlgorithm = 'PKIX'; -- PKIX or SunX509 +alter settings runtime ( AfterStartupMicroflow: 'Module.MF_Startup' ); -- must return Boolean (CE0142) +alter settings runtime ( BeforeShutdownMicroflow: 'Module.MF_Shutdown' ); +alter settings runtime ( HealthCheckMicroflow: 'Module.MF_HealthCheck' ); +alter settings runtime ( HashAlgorithm: 'BCrypt' ); +alter settings runtime ( BcryptCost: 12 ); +alter settings runtime ( JavaVersion: 'Java21' ); -- or '21'; see note below +alter settings runtime ( RoundingMode: 'HalfUp' ); +alter settings runtime ( AllowUserMultipleSessions: true ); +alter settings runtime ( ScheduledEventTimeZoneCode: 'Etc/UTC' ); +alter settings runtime ( DefaultTimeZoneCode: 'Europe/Amsterdam' ); +alter settings runtime ( FirstDayOfWeek: 'Monday' ); -- Default, Monday..Sunday +alter settings runtime ( DecimalScale: 8 ); +alter settings runtime ( EnableDataStorageOptimisticLocking: true ); +alter settings runtime ( UseDatabaseForeignKeyConstraints: true ); +alter settings runtime ( UseOQLVersion2: true ); +alter settings runtime ( SslCertificateAlgorithm: 'PKIX' ); -- PKIX or SunX509 ``` **Not every project stores every setting.** Mendix adds model settings over time — @@ -97,18 +97,20 @@ configuration, add `DatabasePassword = '…'` yourself. ```sql -- Full database configuration -alter settings configuration 'Default' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'localhost:5432', - DatabaseName = 'mydb', - DatabaseUserName = 'mendix', - DatabasePassword = 'mendix', - HttpPortNumber = 8080, - ServerPortNumber = 8090; +alter settings configuration 'Default' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'localhost:5432', + DatabaseName: 'mydb', + DatabaseUserName: 'mendix', + DatabasePassword: 'mendix', + HttpPortNumber: 8080, + ServerPortNumber: 8090 +); -- Update a single field -alter settings configuration 'Default' - DatabaseUrl = 'newhost:5432'; +alter settings configuration 'Default' ( + DatabaseUrl: 'newhost:5432' +); ``` `HttpPortNumber`, `ServerPortNumber`, `BcryptCost`, `DefaultTaskParallelism` and @@ -169,10 +171,11 @@ on shared values only: create configuration 'Staging'; -- Create with properties -create configuration 'Production' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'prod-db:5432', - HttpPortNumber = 8080; +create configuration 'Production' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'prod-db:5432', + HttpPortNumber: 8080 +); -- Drop a configuration drop configuration 'Staging'; @@ -197,7 +200,7 @@ alter settings LANGUAGE modify 'de_DE' (CheckCompleteness: true); alter settings LANGUAGE remove 'de_DE'; -- the default must already be enabled -alter settings LANGUAGE DefaultLanguageCode = 'en_US'; +alter settings LANGUAGE ( DefaultLanguageCode: 'en_US' ); ``` A language is identified by its **code** — "Arabic, Sudan" is derived from @@ -213,7 +216,7 @@ A language is identified by its **code** — "Arabic, Sudan" is derived from **Set the default language BEFORE authoring content.** The default is not only a fallback — it is the language a new caption is stored under, because Mendix has no -language-neutral text. `alter settings LANGUAGE DefaultLanguageCode = 'nl_NL'` +language-neutral text. `alter settings LANGUAGE (DefaultLanguageCode: 'nl_NL')` *after* creating a page leaves that page's texts in the old language, and nothing reports it: `mx check` is 0 errors either way and the symptom shows up only in Studio Pro, as the empty-caption placeholder plus a "no translation" warning. @@ -231,9 +234,10 @@ list. ```sql -alter settings workflows - UserEntity = 'System.User', - DefaultTaskParallelism = 3; +alter settings workflows ( + UserEntity: 'System.User', + DefaultTaskParallelism: 3 +); ``` ### Workflow Groups (Mendix 11.2+) @@ -281,24 +285,26 @@ Four things worth knowing: ### PostgreSQL Configuration ```sql -alter settings configuration 'Default' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'localhost:5432', - DatabaseName = 'myapp', - DatabaseUserName = 'mendix', - DatabasePassword = 'mendix', - HttpPortNumber = 8080; +alter settings configuration 'Default' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'localhost:5432', + DatabaseName: 'myapp', + DatabaseUserName: 'mendix', + DatabasePassword: 'mendix', + HttpPortNumber: 8080 +); ``` ### SQL Server Configuration ```sql -alter settings configuration 'Default' - DatabaseType = 'SqlServer', - DatabaseUrl = 'localhost:1433', - DatabaseName = 'myapp', - DatabaseUserName = 'sa', - DatabasePassword = 'MyPassword', - HttpPortNumber = 8080; +alter settings configuration 'Default' ( + DatabaseType: 'SqlServer', + DatabaseUrl: 'localhost:1433', + DatabaseName: 'myapp', + DatabaseUserName: 'sa', + DatabasePassword: 'MyPassword', + HttpPortNumber: 8080 +); ``` ## Checklist diff --git a/.claude/skills/mendix/regular-expressions/SKILL.md b/.claude/skills/mendix/regular-expressions/SKILL.md index 0f70dd8847..9ce048de96 100644 --- a/.claude/skills/mendix/regular-expressions/SKILL.md +++ b/.claude/skills/mendix/regular-expressions/SKILL.md @@ -102,7 +102,7 @@ create entity Val.Person ( Code: String(20) unique error message 'Code must be unique' ); -alter entity Val.Person modify attribute Email String(200) +alter entity Val.Person modify attribute Email: String(200) not null error message 'Email is required'; ``` diff --git a/.claude/skills/mendix/write-layouts/SKILL.md b/.claude/skills/mendix/write-layouts/SKILL.md index 8a9055d5f1..2e17bccd5a 100644 --- a/.claude/skills/mendix/write-layouts/SKILL.md +++ b/.claude/skills/mendix/write-layouts/SKILL.md @@ -96,8 +96,8 @@ survive. Same operations as `ALTER PAGE`: ```sql alter layout MyModule.App_Default { insert into layoutContainer.top { snippetcall bar (snippet: MyModule.SNIPPET_ThemeBar) }; - set Content = 'My App' on brandText; - drop widget oldBanner; + set (Content: 'My App') on brandText; + drop oldBanner; }; ``` diff --git a/cmd/mxcli/cmd_new.go b/cmd/mxcli/cmd_new.go index 0304d1bb37..62b88cf40a 100644 --- a/cmd/mxcli/cmd_new.go +++ b/cmd/mxcli/cmd_new.go @@ -266,7 +266,7 @@ Examples: if lowered, err := alignJavaVersion(mprPath, os.Stdout); err != nil { fmt.Fprintf(os.Stderr, " Warning: could not align the project's Java version: %v\n", err) fmt.Fprintln(os.Stderr, " If the first build fails with 'release version NN not supported',") - fmt.Fprintln(os.Stderr, " run: mxcli -p .mpr -c \"alter settings runtime JavaVersion = '21'\"") + fmt.Fprintln(os.Stderr, " run: mxcli -p .mpr -c \"alter settings runtime ( JavaVersion: '21' )\"") } else if lowered { fmt.Println() } diff --git a/cmd/mxcli/cmd_new_java.go b/cmd/mxcli/cmd_new_java.go index 608ac3efad..19ad99fce9 100644 --- a/cmd/mxcli/cmd_new_java.go +++ b/cmd/mxcli/cmd_new_java.go @@ -56,13 +56,13 @@ func alignJavaVersion(projectPath string, out io.Writer) (bool, error) { return false, nil } - script := fmt.Sprintf("alter settings runtime JavaVersion = '%d';\n", fallback) + script := fmt.Sprintf("alter settings runtime ( JavaVersion: '%d' );\n", fallback) if err := runMDL(projectPath, script, io.Discard); err != nil { return false, fmt.Errorf("lowering JavaVersion from %d to %d: %w", major, fallback, err) } fmt.Fprintf(out, " Java %d → %d: no JDK %d on this machine, and the project would not build.\n", major, fallback, major) fmt.Fprintf(out, " Install a JDK %d and set JavaVersion back to %d to build for it:\n", major, major) - fmt.Fprintf(out, " mxcli -p .mpr -c \"alter settings runtime JavaVersion = '%d'\"\n", major) + fmt.Fprintf(out, " mxcli -p .mpr -c \"alter settings runtime ( JavaVersion: '%d' )\"\n", major) return true, nil } diff --git a/cmd/mxcli/docker/detect.go b/cmd/mxcli/docker/detect.go index 723be44742..9bba775481 100644 --- a/cmd/mxcli/docker/detect.go +++ b/cmd/mxcli/docker/detect.go @@ -350,7 +350,7 @@ func resolveJDK(major int) (string, error) { // be noise. if major != DefaultJavaMajor { msg += fmt.Sprintf("\n This is the project's own Settings > Model > JavaVersion. To build for a"+ - "\n release you already have instead: mxcli -p .mpr -c \"alter settings runtime JavaVersion = '%d'\"", DefaultJavaMajor) + "\n release you already have instead: mxcli -p .mpr -c \"alter settings runtime ( JavaVersion: '%d' )\"", DefaultJavaMajor) } return "", fmt.Errorf("%s", msg) } diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index 8f094e8420..aac434a206 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -96,8 +96,8 @@ func init() { "if not exists", "if exists", "idempotent", "alter entities", "bulk", "every entity", "all entities", "where persistent", }, - Syntax: "ALTER ENTITY Module.Name ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [constraints];\nALTER ENTITY Module.Name DROP ATTRIBUTE [IF EXISTS] AttrName;\nALTER ENTITY Module.Name RENAME ATTRIBUTE OldName TO NewName;\nALTER ENTITY Module.Name MODIFY ATTRIBUTE AttrName Type [DEFAULT val];\nALTER ENTITY Module.Name DROP DEFAULT ON ATTRIBUTE AttrName;\nALTER ENTITY Module.Name ADD INDEX [name] [ON] (attr1, attr2);\nALTER ENTITY Module.Name SET DOCUMENTATION 'text';\nALTER ENTITY Module.Name SET POSITION (x, y);\nALTER ENTITY Module.Name ADD EVENT HANDLER ON BEFORE COMMIT CALL Module.MF RAISE ERROR;\nALTER ENTITIES [IN Module] ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [, ...]\n [WHERE PERSISTENT | WHERE NON-PERSISTENT];\n\nALTER ENTITIES is the bulk form: one statement applied to every entity in a\nmodule instead of one statement per entity. Only ADD ATTRIBUTE is offered --\nDROP and RENAME aimed at a set are destructive by a typo, and SET POSITION on\nevery entity is meaningless. Pair it with IF NOT EXISTS so the script re-runs.\n\nWHERE filters by persistence, using the same words CREATE ENTITY uses. A VIEW\nentity matches NEITHER: its rows come from an OQL query, so it is not the\npersistent/non-persistent distinction this filter means.\n\nWITHOUT IN, the sweep covers the whole project but SKIPS System and every\nMarketplace module, reporting which -- an upgrade replaces those modules and\nwould take the attribute with it. Naming a module with IN is taken as meaning\nit, so a deliberate edit there is still possible.\n\nSET POSITION places the entity in the domain-model editor, and CREATE ENTITY\ntakes the same thing as an @Position(x, y) annotation. Both are the box's\nCENTRE, not its top-left corner. An entity created without one takes the next\nslot in a wrapping grid, which is a default rather than a layout: to arrange a\nwhole module from its association graph, run 'mxcli layout -p app.mpr'\n(--dry-run first; it replaces positions you set by hand).\n\nMODIFY ATTRIBUTE always takes a type — restate it even when you are only\nchanging the default. There is no 'MODIFY ATTRIBUTE X SET DEFAULT v' form:\nSET would be read as the type name. Use DROP DEFAULT to clear one.\n\nIF NOT EXISTS / IF EXISTS make the add/drop a no-op (skipped, not an error)\nwhen the attribute is already present / already gone — so a domain script\nre-runs cleanly. For a whole script, 'mxcli exec --continue-on-error' reports\neach failed statement and keeps going instead of halting at the first.\n\nRENAME ATTRIBUTE also rewrites every reference to the attribute: the stored\nqualified names (microflow create/change members, page widgets, the entity's own\nvalidation and access rules) AND the bare steps inside XPath constraints, which\nare resolved to their owning entity first so another entity's identically-named\nattribute is left alone. A constraint that cannot be resolved is reported and\nleft unchanged, never guessed at. Uses inside microflow expressions ($obj/Attr)\nare free text and are NOT rewritten; mxbuild reports those as CE0117.", - Example: "ALTER ENTITY Shop.Customer ADD ATTRIBUTE Phone: String(20);\nALTER ENTITY Shop.Customer ADD ATTRIBUTE IF NOT EXISTS Phone: String(20); -- re-runnable\nALTER ENTITY Shop.Customer DROP ATTRIBUTE IF EXISTS OldField; -- re-runnable\nALTER ENTITY Shop.Customer RENAME ATTRIBUTE Email TO EmailAddress;\nALTER ENTITY Shop.Customer MODIFY ATTRIBUTE Phone String(30) DEFAULT ''; -- type restated\nALTER ENTITY Shop.Customer DROP DEFAULT ON ATTRIBUTE Phone; -- clear a default\nALTER ENTITY Shop.Customer ADD INDEX ON (EmailAddress);\nALTER ENTITY Shop.Customer\n ADD EVENT HANDLER ON BEFORE COMMIT CALL Shop.Validate($currentObject) RAISE ERROR;\n\n-- give every persistent entity in a module an audit trail, in one statement\nALTER ENTITIES IN Shop\n ADD ATTRIBUTE IF NOT EXISTS CreatedDate: AutoCreatedDate,\n ADD ATTRIBUTE IF NOT EXISTS ChangedDate: AutoChangedDate\n WHERE PERSISTENT;", + Syntax: "ALTER ENTITY Module.Name ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [constraints];\nALTER ENTITY Module.Name DROP ATTRIBUTE [IF EXISTS] AttrName;\nALTER ENTITY Module.Name RENAME ATTRIBUTE OldName TO NewName;\nALTER ENTITY Module.Name MODIFY ATTRIBUTE AttrName: Type [DEFAULT val];\nALTER ENTITY Module.Name DROP DEFAULT ON ATTRIBUTE AttrName;\nALTER ENTITY Module.Name ADD INDEX [name] [ON] (attr1, attr2);\nALTER ENTITY Module.Name SET DOCUMENTATION 'text';\nALTER ENTITY Module.Name SET POSITION (x, y);\nALTER ENTITY Module.Name SET (AllowCreateChangeLocally: true|false); -- external entity\nALTER ENTITY Module.Name ADD EVENT HANDLER ON BEFORE COMMIT CALL Module.MF RAISE ERROR;\nALTER ENTITIES [IN Module] ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [, ...]\n [WHERE PERSISTENT | WHERE NON-PERSISTENT];\n\nALTER ENTITIES is the bulk form: one statement applied to every entity in a\nmodule instead of one statement per entity. Only ADD ATTRIBUTE is offered --\nDROP and RENAME aimed at a set are destructive by a typo, and SET POSITION on\nevery entity is meaningless. Pair it with IF NOT EXISTS so the script re-runs.\n\nWHERE filters by persistence, using the same words CREATE ENTITY uses. A VIEW\nentity matches NEITHER: its rows come from an OQL query, so it is not the\npersistent/non-persistent distinction this filter means.\n\nWITHOUT IN, the sweep covers the whole project but SKIPS System and every\nMarketplace module, reporting which -- an upgrade replaces those modules and\nwould take the attribute with it. Naming a module with IN is taken as meaning\nit, so a deliberate edit there is still possible.\n\nSET POSITION places the entity in the domain-model editor, and CREATE ENTITY\ntakes the same thing as an @Position(x, y) annotation. Both are the box's\nCENTRE, not its top-left corner. An entity created without one takes the next\nslot in a wrapping grid, which is a default rather than a layout: to arrange a\nwhole module from its association graph, run 'mxcli layout -p app.mpr'\n(--dry-run first; it replaces positions you set by hand).\n\nMODIFY ATTRIBUTE always takes a type — restate it even when you are only\nchanging the default. There is no 'MODIFY ATTRIBUTE X SET DEFAULT v' form:\nSET would be read as the type name. Use DROP DEFAULT to clear one.\n\nIF NOT EXISTS / IF EXISTS make the add/drop a no-op (skipped, not an error)\nwhen the attribute is already present / already gone — so a domain script\nre-runs cleanly. For a whole script, 'mxcli exec --continue-on-error' reports\neach failed statement and keeps going instead of halting at the first.\n\nRENAME ATTRIBUTE also rewrites every reference to the attribute: the stored\nqualified names (microflow create/change members, page widgets, the entity's own\nvalidation and access rules) AND the bare steps inside XPath constraints, which\nare resolved to their owning entity first so another entity's identically-named\nattribute is left alone. A constraint that cannot be resolved is reported and\nleft unchanged, never guessed at. Uses inside microflow expressions ($obj/Attr)\nare free text and are NOT rewritten; mxbuild reports those as CE0117.", + Example: "ALTER ENTITY Shop.Customer ADD ATTRIBUTE Phone: String(20);\nALTER ENTITY Shop.Customer ADD ATTRIBUTE IF NOT EXISTS Phone: String(20); -- re-runnable\nALTER ENTITY Shop.Customer DROP ATTRIBUTE IF EXISTS OldField; -- re-runnable\nALTER ENTITY Shop.Customer RENAME ATTRIBUTE Email TO EmailAddress;\nALTER ENTITY Shop.Customer MODIFY ATTRIBUTE Phone: String(30) DEFAULT ''; -- type restated\nALTER ENTITY Shop.Customer DROP DEFAULT ON ATTRIBUTE Phone; -- clear a default\nALTER ENTITY Shop.Customer ADD INDEX ON (EmailAddress);\nALTER ENTITY Shop.Customer\n ADD EVENT HANDLER ON BEFORE COMMIT CALL Shop.Validate($currentObject) RAISE ERROR;\n\n-- give every persistent entity in a module an audit trail, in one statement\nALTER ENTITIES IN Shop\n ADD ATTRIBUTE IF NOT EXISTS CreatedDate: AutoCreatedDate,\n ADD ATTRIBUTE IF NOT EXISTS ChangedDate: AutoChangedDate\n WHERE PERSISTENT;", SeeAlso: []string{"domain-model.entity.create", "domain-model.entity.attributes"}, }) diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index e1a662c6ec..7830d1ff95 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -55,12 +55,17 @@ func init() { " ErrorHandlingMicroflow: microflow Module.HandleError\n" + ")\n" + "[HEADERS ('Key': 'Value')];\n\n" + + "ALTER CONSUMED ODATA SERVICE Module.Name SET (Key: value, ...); -- CREATE's keys\n" + + "ALTER PUBLISHED ODATA SERVICE Module.Name SET (Key: value, ...);\n\n" + "CREATE EXTERNAL ENTITY Module.Name\n" + " FROM CONSUMED ODATA SERVICE Module.Client\n" + " (EntitySet: 'Name', RemoteName: 'Name')\n" + " (Attr: Type, ...);\n\n" + "CREATE EXTERNAL ENTITIES FROM Module.Client\n" + - " [INTO Module] [ENTITIES (Name1, Name2)];", + " [INTO Module] [ENTITIES (Name1, Name2)];\n\n" + + "ALTER ENTITY Module.Name SET (AllowCreateChangeLocally: true);\n\n" + + "-- `SET Key = value, ...` (no parentheses) still runs and warns MDL-DEPR061;\n" + + "-- `SET allow_create_change_locally = true` warns MDL-DEPR063.", Example: "CREATE CONSTANT MyModule.SvcUrl TYPE String DEFAULT 'https://api.example.com/odata/v4/';\n\nCREATE CONSUMED ODATA SERVICE MyModule.SalesforceAPI (\n Version: '1.0',\n ODataVersion: OData4,\n MetadataUrl: 'https://api.example.com/odata/$metadata',\n Timeout: 300,\n ServiceUrl: MyModule.SvcUrl\n);\n\nCREATE EXTERNAL ENTITIES FROM MyModule.SalesforceAPI INTO Integration;", SeeAlso: []string{"odata", "odata.publish", "odata.show"}, }) diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 50f6410ae4..aae68a05a2 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -369,8 +369,8 @@ CREATE OR REPLACE NAVIGATION TabletOffline "settings", "project settings", "configuration", "startup", "shutdown", "hash algorithm", "java version", }, - Syntax: "SHOW SETTINGS;\nDESCRIBE SETTINGS;\nDESCRIBE SETTINGS CONFIGURATION ''; -- just one configuration\nALTER SETTINGS RUNTIME = ; -- MODEL is a deprecated alias\nALTER SETTINGS CONFIGURATION '' = ;", - Example: "SHOW SETTINGS;\nALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Module.MF_Startup';", + Syntax: "SHOW SETTINGS;\nDESCRIBE SETTINGS;\nDESCRIBE SETTINGS CONFIGURATION ''; -- just one configuration\nALTER SETTINGS RUNTIME (: , ...); -- MODEL is a deprecated alias\nALTER SETTINGS CONFIGURATION '' (: , ...);", + Example: "SHOW SETTINGS;\nALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Module.MF_Startup');", SeeAlso: []string{"settings.show", "settings.alter"}, }) @@ -460,33 +460,37 @@ create or modify translations in Administration for nl_NL ( "optimistic locking", "concurrency", "lost update", "workflow group", "workflow groups", "add group", "task assignment", }, - Syntax: `ALTER SETTINGS RUNTIME = ; -ALTER SETTINGS CONFIGURATION '' = , ...; + Syntax: `ALTER SETTINGS RUNTIME (: , ...); +ALTER SETTINGS CONFIGURATION '' (: , ...); ALTER SETTINGS CONSTANT '' VALUE '' IN CONFIGURATION ''; ALTER SETTINGS DROP CONSTANT '' IN CONFIGURATION ''; -ALTER SETTINGS LANGUAGE DefaultLanguageCode = ''; +ALTER SETTINGS LANGUAGE (DefaultLanguageCode: ''); ALTER SETTINGS LANGUAGE ADD '' [(CheckCompleteness: true, CustomDateFormat: '')]; ALTER SETTINGS LANGUAGE ADD OR MODIFY '' [(...)]; ALTER SETTINGS LANGUAGE MODIFY '' (CheckCompleteness: true, ...); ALTER SETTINGS LANGUAGE REMOVE ''; -ALTER SETTINGS WORKFLOWS UserEntity = ''; +ALTER SETTINGS WORKFLOWS (UserEntity: ''); ALTER SETTINGS WORKFLOWS ADD [OR MODIFY] GROUP '' [(Description: '')]; ALTER SETTINGS WORKFLOWS MODIFY GROUP '' (Description: ''); ALTER SETTINGS WORKFLOWS REMOVE GROUP ''; -CREATE [OR MODIFY] CONFIGURATION '' [ = , ...]; -DROP CONFIGURATION '';`, - Example: `ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Module.MF_Startup'; -ALTER SETTINGS RUNTIME HashAlgorithm = 'BCrypt'; -ALTER SETTINGS RUNTIME EnableDataStorageOptimisticLocking = true; -ALTER SETTINGS CONFIGURATION 'Default' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'localhost:5432', - DatabaseName = 'mydb'; +CREATE [OR MODIFY] CONFIGURATION '' [(: , ...)]; +DROP CONFIGURATION ''; + +-- A property is Key: value in a ( … ) list, as everywhere else in MDL (R3). +-- Key = value, … without the parentheses still runs and warns MDL-DEPR060.`, + Example: `ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Module.MF_Startup'); +ALTER SETTINGS RUNTIME (HashAlgorithm: 'BCrypt', EnableDataStorageOptimisticLocking: true); +ALTER SETTINGS CONFIGURATION 'Default' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'localhost:5432', + DatabaseName: 'mydb' +); ALTER SETTINGS CONSTANT 'BusinessEvents.ServerUrl' VALUE 'kafka:9092' IN CONFIGURATION 'Default'; -CREATE CONFIGURATION 'Production' - DatabaseType = 'PostgreSql', - HttpPortNumber = 8080; +CREATE CONFIGURATION 'Production' ( + DatabaseType: 'PostgreSql', + HttpPortNumber: 8080 +); -- LANGUAGE ADD/REMOVE change the ENABLED languages — the list under App -- Settings > Languages, and the only languages a build emits anything for. A @@ -719,9 +723,9 @@ CE0135 "No regular expression specified" at build time. REQUIRED and UNIQUE rules are written as attribute constraints instead, on CREATE ENTITY or ALTER ENTITY: - ALTER ENTITY Shop.Product MODIFY ATTRIBUTE Email string(200) + ALTER ENTITY Shop.Product MODIFY ATTRIBUTE Email: string(200) NOT NULL ERROR MESSAGE 'Email is required'; - ALTER ENTITY Shop.Product MODIFY ATTRIBUTE Code string(20) + ALTER ENTITY Shop.Product MODIFY ATTRIBUTE Code: string(20) UNIQUE ERROR MESSAGE 'Code must be unique';`, Example: `CREATE REGULAR EXPRESSION Shop.EmailPattern ( Expression: '^[^@\s]+@[^@\s]+\.[^@\s]+$' diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 81e6b357bd..d19944a8c7 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -306,21 +306,23 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { " DesignProperties: ['Full width': ON]\n\n" + "ON A PAGE THAT ALREADY EXISTS, without rewriting it:\n\n" + " ALTER STYLING ON PAGE|SNIPPET Module.Name WIDGET \n" + - " SET Class = 'css-class', Style = 'css', 'Design property' = 'Value'|ON|OFF;\n\n" + + " SET (Class: 'css-class', Style: 'css', 'Design property': 'Value'|ON|OFF);\n\n" + " ALTER STYLING ON PAGE|SNIPPET Module.Name WIDGET \n" + " CLEAR DESIGN PROPERTIES;\n\n" + "The widget is named by its MDL NAME — the identifier after the widget\n" + "keyword (`ACTIONBUTTON btnSave`), not its caption. `DESCRIBE PAGE` prints\n" + "the names.\n\n" + - "A bare `Class =` REPLACES the widget's classes rather than adding to them.\n" + + "A bare `Class:` REPLACES the widget's classes rather than adding to them.\n" + "Read the current value first if you meant to append.\n\n" + "Reach for ALTER STYLING rather than CREATE OR REPLACE PAGE whenever only\n" + "the look changes: replacing the page rewrites every widget in it, so the\n" + - "diff is the whole document and anything MDL cannot yet spell is lost.", + "diff is the whole document and anything MDL cannot yet spell is lost.\n\n" + + "`SET Class = 'x', 'P' = ON` (no parentheses, `=`) still runs and warns\n" + + "MDL-DEPR062.", Example: "CONTAINER ctn (\n Class: 'my-card',\n DynamicClasses: if $currentObject/Priority = 'High' then 'card-danger' else 'card-normal'\n) {\n DYNAMICTEXT txt (Content: 'Styled text')\n}\n\n" + "-- Restyle one widget on a page that already exists\n" + "alter styling on page Sales.OrderOverview widget btnSave\n" + - " set Class = 'btn-primary', 'Spacing top' = 'Large';\n\n" + + " set (Class: 'btn-primary', 'Spacing top': 'Large');\n\n" + "-- Back to Atlas defaults\n" + "alter styling on snippet Sales.OrderRow widget ctnMain\n" + " clear design properties;", diff --git a/cmd/mxcli/testrunner/generator_endpoint_test.go b/cmd/mxcli/testrunner/generator_endpoint_test.go index 976513c798..1b695d3cf5 100644 --- a/cmd/mxcli/testrunner/generator_endpoint_test.go +++ b/cmd/mxcli/testrunner/generator_endpoint_test.go @@ -171,7 +171,7 @@ func TestEndpointCleanupCommands(t *testing.T) { state: projectState{afterStartup: "Mod.ASU", createdMxTest: true}, present: true, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Mod.ASU'", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Mod.ASU')", "DROP MODULE MxTest", }, }, @@ -180,7 +180,7 @@ func TestEndpointCleanupCommands(t *testing.T) { state: projectState{createdMxTest: false}, present: true, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = ''", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: '')", "DROP MICROFLOW MxTest.Test_test_1", "DROP MICROFLOW MxTest.Test_test_2", "DROP MICROFLOW " + endpointStartupFlow, @@ -191,7 +191,7 @@ func TestEndpointCleanupCommands(t *testing.T) { name: "drops nothing when the module never landed", state: projectState{afterStartup: "Mod.ASU", createdMxTest: true}, present: false, - want: []string{"ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Mod.ASU'"}, + want: []string{"ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Mod.ASU')"}, }, } @@ -220,7 +220,7 @@ func TestEndpointCleanupRestoreIsAlwaysFirst(t *testing.T) { {afterStartup: "Mod.ASU", createdMxTest: true}, } { cmds := endpointCleanupCommands(st, suiteTestFlowNames(suite), true) - if !strings.HasPrefix(cmds[0], "ALTER SETTINGS RUNTIME AfterStartupMicroflow") { + if !strings.HasPrefix(cmds[0], "ALTER SETTINGS RUNTIME (AfterStartupMicroflow") { t.Errorf("state %+v: first command is %q, want the after-startup restore", st, cmds[0]) } } diff --git a/cmd/mxcli/testrunner/host.go b/cmd/mxcli/testrunner/host.go index 20b02bf531..e6ba9c5629 100644 --- a/cmd/mxcli/testrunner/host.go +++ b/cmd/mxcli/testrunner/host.go @@ -61,7 +61,7 @@ func InstallHostedEndpoint(projectPath string, w io.Writer) (*HostedEndpoint, er h.Remove() return nil, fmt.Errorf("injecting the test endpoint: %w", err) } - if err := execMxcliCmd(projectPath, "ALTER SETTINGS RUNTIME AfterStartupMicroflow = "+quoteMDLString(endpointStartupFlow)); err != nil { + if err := execMxcliCmd(projectPath, "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: "+quoteMDLString(endpointStartupFlow)+")"); err != nil { h.Remove() return nil, fmt.Errorf("pointing after-startup at the endpoint: %w", err) } diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index b14a996f9b..fae3d0ea33 100644 --- a/cmd/mxcli/testrunner/runner.go +++ b/cmd/mxcli/testrunner/runner.go @@ -667,7 +667,7 @@ func moduleExists(projectPath, name string) (bool, error) { // changing projects that run at another level (mendixlabs/mxcli#802). func setupCommands(startupFlow string) []string { return []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = " + quoteMDLString(startupFlow), + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: " + quoteMDLString(startupFlow) + ")", } } @@ -714,9 +714,9 @@ func execMDLScript(projectPath, mdl, namePattern string) error { // injection landed does not report a spurious cleanup failure. func cleanupCommands(st projectState, mxTestPresent bool) []string { // Restore the original after-startup microflow, or clear it if there was none. - restore := "ALTER SETTINGS RUNTIME AfterStartupMicroflow = ''" + restore := "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: '')" if st.afterStartup != "" { - restore = "ALTER SETTINGS RUNTIME AfterStartupMicroflow = " + quoteMDLString(st.afterStartup) + restore = "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: " + quoteMDLString(st.afterStartup) + ")" } cmds := []string{restore} if !mxTestPresent { diff --git a/cmd/mxcli/testrunner/runner_cleanup_test.go b/cmd/mxcli/testrunner/runner_cleanup_test.go index d5f0f5e460..03f5642fa1 100644 --- a/cmd/mxcli/testrunner/runner_cleanup_test.go +++ b/cmd/mxcli/testrunner/runner_cleanup_test.go @@ -134,7 +134,7 @@ func TestCleanupCommands(t *testing.T) { state: projectState{afterStartup: "MyFirstModule.ASU_Startup", createdMxTest: true}, present: true, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'MyFirstModule.ASU_Startup'", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'MyFirstModule.ASU_Startup')", "DROP MODULE MxTest", }, }, @@ -143,7 +143,7 @@ func TestCleanupCommands(t *testing.T) { state: projectState{createdMxTest: true}, present: true, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = ''", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: '')", "DROP MODULE MxTest", }, }, @@ -154,7 +154,7 @@ func TestCleanupCommands(t *testing.T) { state: projectState{afterStartup: "Mod.Flow"}, present: true, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Mod.Flow'", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Mod.Flow')", "DROP MICROFLOW MxTest.TestRunner", }, }, @@ -165,7 +165,7 @@ func TestCleanupCommands(t *testing.T) { state: projectState{afterStartup: "Mod.Flow", createdMxTest: true}, present: false, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Mod.Flow'", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Mod.Flow')", }, }, } @@ -189,7 +189,7 @@ func TestCleanupCommands(t *testing.T) { func TestCleanupCommands_RestoreIsWellFormed(t *testing.T) { parsed := parseSettingValue(" AfterStartupMicroflow = 'MyFirstModule.ASU_Startup',") restore := cleanupCommands(projectState{afterStartup: parsed}, true)[0] - want := "ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'MyFirstModule.ASU_Startup'" + want := "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'MyFirstModule.ASU_Startup')" if restore != want { t.Errorf("restore command = %q, want %q", restore, want) } diff --git a/cmd/mxcli/testrunner/runner_endpoint.go b/cmd/mxcli/testrunner/runner_endpoint.go index 74eb3883a6..e80204045d 100644 --- a/cmd/mxcli/testrunner/runner_endpoint.go +++ b/cmd/mxcli/testrunner/runner_endpoint.go @@ -211,9 +211,9 @@ func endpointReadyTimeout(suiteTimeout time.Duration) time.Duration { // when the module was already the user's, each generated document is named // explicitly so nothing of theirs is touched. func endpointCleanupCommands(st projectState, flows []string, mxTestPresent bool) []string { - restore := "ALTER SETTINGS RUNTIME AfterStartupMicroflow = ''" + restore := "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: '')" if st.afterStartup != "" { - restore = "ALTER SETTINGS RUNTIME AfterStartupMicroflow = " + quoteMDLString(st.afterStartup) + restore = "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: " + quoteMDLString(st.afterStartup) + ")" } cmds := []string{restore} if !mxTestPresent { diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 9ced9a4d4a..7be500653b 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -83,12 +83,12 @@ CREATE CONSTANT MyModule.EnableLogging TYPE Boolean DEFAULT true; | List consumed odata services | `LIST CONSUMED ODATA SERVICES [IN Module];` | Consumed OData services | | Describe consumed odata service | `DESCRIBE CONSUMED ODATA SERVICE Module.Name;` | Full MDL output | | Create consumed odata service | `CREATE [OR MODIFY] CONSUMED ODATA SERVICE Module.Name (...);` | Version, MetadataUrl, Timeout, etc. | -| Alter consumed odata service | `ALTER CONSUMED ODATA SERVICE Module.Name SET Key = Value;` | | +| Alter consumed odata service | `ALTER CONSUMED ODATA SERVICE Module.Name SET (Key: Value, ...);` | | | Drop consumed odata service | `DROP CONSUMED ODATA SERVICE Module.Name;` | | | List published odata services | `LIST PUBLISHED ODATA SERVICES [IN Module];` | Published OData services | | Describe published odata service | `DESCRIBE PUBLISHED ODATA SERVICE Module.Name;` | Full MDL output | | Create published odata service | `CREATE [OR MODIFY] PUBLISHED ODATA SERVICE Module.Name (...) AUTHENTICATION ... { PUBLISH ENTITY ... };` | | -| Alter published odata service | `ALTER PUBLISHED ODATA SERVICE Module.Name SET Key = Value;` | | +| Alter published odata service | `ALTER PUBLISHED ODATA SERVICE Module.Name SET (Key: Value, ...);` | | | Drop published odata service | `DROP PUBLISHED ODATA SERVICE Module.Name;` | | | List external entities | `LIST EXTERNAL ENTITIES [IN Module];` | OData-backed entities | | List external actions | `LIST EXTERNAL ACTIONS [IN Module];` | Actions used in microflows | @@ -300,11 +300,11 @@ CREATE OR REPLACE NAVIGATION Responsive |-----------|--------|-------| | List settings | `LIST SETTINGS;` | Overview of all settings parts | | Describe settings | `DESCRIBE SETTINGS;` | Full MDL output (round-trippable) | -| Alter model settings | `ALTER SETTINGS RUNTIME Key = Value;` | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. | -| Alter configuration | `ALTER SETTINGS CONFIGURATION 'Name' Key = Value;` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | +| Alter model settings | `ALTER SETTINGS RUNTIME (Key: Value, ...);` | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. | +| Alter configuration | `ALTER SETTINGS CONFIGURATION 'Name' (Key: Value, ...);` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | | Alter constant | `ALTER SETTINGS CONSTANT 'Name' VALUE 'val' IN CONFIGURATION 'cfg';` | Override constant per configuration | -| Alter language | `ALTER SETTINGS LANGUAGE Key = Value;` | DefaultLanguageCode | -| Alter workflows | `ALTER SETTINGS WORKFLOWS Key = Value;` | UserEntity, DefaultTaskParallelism | +| Alter language | `ALTER SETTINGS LANGUAGE (Key: Value);` | DefaultLanguageCode | +| Alter workflows | `ALTER SETTINGS WORKFLOWS (Key: Value, ...);` | UserEntity, DefaultTaskParallelism | ## Business Events @@ -421,15 +421,15 @@ Modify an existing page or snippet's widget tree in-place without full `CREATE O | Operation | Syntax | Notes | |-----------|--------|-------| -| Set property | `SET Caption = 'New' ON widgetName` | Single property on a widget | +| Set property | `SET (Caption: 'New') ON widgetName` | Single property on a widget | | Set multiple | `SET (Caption = 'Save', ButtonStyle = Success) ON btn` | Multiple properties at once | -| Page-level set | `SET Title = 'New Title'` | No ON clause for page properties | +| Page-level set | `SET (Title: 'New Title')` | No ON clause for page properties | | Insert after | `INSERT AFTER widgetName { widgets }` | Add widgets after target | | Insert before | `INSERT BEFORE widgetName { widgets }` | Add widgets before target | | Insert into | `INSERT INTO containerName { widgets }` | Append as the container's last child (fills an empty container) | -| Drop widgets | `DROP WIDGET name1, name2` | Remove widgets by name | +| Drop widgets | `DROP name1, name2` | Remove widgets by name | | Replace widget | `REPLACE widgetName WITH { widgets }` | Replace widget subtree | -| Pluggable prop | `SET 'showLabel' = false ON cbStatus` | Quoted name for pluggable widgets | +| Pluggable prop | `SET ('showLabel': false) ON cbStatus` | Quoted name for pluggable widgets | | Add variable | `ADD Variables $name: Type = 'expr'` | Add a page variable | | Drop variable | `DROP Variables $name` | Remove a page variable | @@ -438,15 +438,15 @@ Modify an existing page or snippet's widget tree in-place without full `CREATE O **Example:** ```sql ALTER PAGE Module.EditPage { - SET (Caption = 'Save & Close', ButtonStyle = Success) ON btnSave; - DROP WIDGET txtUnused; + SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave; + DROP txtUnused; INSERT AFTER txtEmail { TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone) } }; ALTER SNIPPET Module.NavMenu { - SET Caption = 'Dashboard' ON btnHome + SET (Caption: 'Dashboard') ON btnHome }; ``` diff --git a/docs-site/src/examples/alter-page.md b/docs-site/src/examples/alter-page.md index 2afd6c931d..4cb0f50b67 100644 --- a/docs-site/src/examples/alter-page.md +++ b/docs-site/src/examples/alter-page.md @@ -6,7 +6,7 @@ ```sql ALTER PAGE CRM.Customer_Edit { - SET (Caption = 'Save & Close', ButtonStyle = Success) ON btnSave + SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave }; ``` @@ -36,7 +36,7 @@ ALTER PAGE CRM.Customer_Overview { ```sql ALTER PAGE CRM.Customer_Edit { - DROP WIDGET txtLegacyField, lblOldNote + DROP txtLegacyField, lblOldNote }; ``` @@ -58,12 +58,12 @@ ALTER PAGE CRM.Customer_Edit { ```sql ALTER PAGE CRM.Customer_Edit { - SET Title = 'Edit Customer Details'; - SET Label = 'Email Address' ON txtEmail; + SET (Title: 'Edit Customer Details'); + SET (Label: 'Email Address') ON txtEmail; INSERT AFTER txtPhone { TEXTBOX txtWebsite (Label: 'Website', Attribute: Website) }; - DROP WIDGET lblInternalRef + DROP lblInternalRef }; ``` @@ -108,12 +108,12 @@ ALTER PAGE CRM.Customer_List { -- Remove a column ALTER PAGE CRM.Customer_List { - DROP WIDGET dgCustomers.OldNotes + DROP dgCustomers.OldNotes }; -- Rename a column header ALTER PAGE CRM.Customer_List { - SET Caption = 'E-mail Address' ON dgCustomers.Email + SET (Caption: 'E-mail Address') ON dgCustomers.Email }; ``` @@ -123,7 +123,7 @@ Use `DESCRIBE PAGE CRM.Customer_List` to discover column names. ```sql ALTER SNIPPET CRM.NavigationMenu { - SET Caption = 'Dashboard' ON btnHome; + SET (Caption: 'Dashboard') ON btnHome; INSERT AFTER btnHome { ACTIONBUTTON btnReports ( Caption: 'Reports', diff --git a/docs-site/src/language/alter-entity.md b/docs-site/src/language/alter-entity.md index e554339c0b..f436111636 100644 --- a/docs-site/src/language/alter-entity.md +++ b/docs-site/src/language/alter-entity.md @@ -49,10 +49,10 @@ a clause written where the type belongs is read *as* the type: ```mdl -- WRONG: `SET` is read as the type name, not as a keyword -ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Discount SET DEFAULT 0; +ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Discount: SET DEFAULT 0; -- Right: restate the type -ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Discount Decimal DEFAULT 0; +ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Discount: Decimal DEFAULT 0; ``` mxcli refuses the first form and names the alternatives. Before it did, that diff --git a/docs-site/src/language/project-settings.md b/docs-site/src/language/project-settings.md index 661230d847..41a3ae27d1 100644 --- a/docs-site/src/language/project-settings.md +++ b/docs-site/src/language/project-settings.md @@ -27,9 +27,9 @@ ALTER SETTINGS RUNTIME = ; Examples: ```sql -ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'MyModule.ACT_Startup'; -ALTER SETTINGS RUNTIME HashAlgorithm = 'BCrypt'; -ALTER SETTINGS RUNTIME JavaVersion = '17'; +ALTER SETTINGS RUNTIME ( AfterStartupMicroflow: 'MyModule.ACT_Startup' ); +ALTER SETTINGS RUNTIME ( HashAlgorithm: 'BCrypt' ); +ALTER SETTINGS RUNTIME ( JavaVersion: '17' ); ``` Mendix renamed the Java version property between versions — up to 11.6 it is stored @@ -48,9 +48,9 @@ ALTER SETTINGS CONFIGURATION '' = ; Examples: ```sql -ALTER SETTINGS CONFIGURATION 'default' DatabaseType = 'POSTGRESQL'; -ALTER SETTINGS CONFIGURATION 'default' DatabaseUrl = 'jdbc:postgresql://localhost:5432/myapp'; -ALTER SETTINGS CONFIGURATION 'default' HttpPortNumber = '8080'; +ALTER SETTINGS CONFIGURATION 'default' ( DatabaseType: 'POSTGRESQL' ); +ALTER SETTINGS CONFIGURATION 'default' ( DatabaseUrl: 'jdbc:postgresql://localhost:5432/myapp' ); +ALTER SETTINGS CONFIGURATION 'default' ( HttpPortNumber: '8080' ); ``` ### Constant Overrides @@ -92,7 +92,7 @@ ALTER SETTINGS LANGUAGE ADD 'ar_SD' (CheckCompleteness: true, CustomDateFormat: ALTER SETTINGS LANGUAGE MODIFY 'de_DE' (CheckCompleteness: true); -- make it the default (it must already be enabled) -ALTER SETTINGS LANGUAGE DefaultLanguageCode = 'de_DE'; +ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'de_DE' ); -- disable it ALTER SETTINGS LANGUAGE REMOVE 'de_DE'; @@ -131,13 +131,13 @@ So the order of these two statements changes the result: ```sql -- right: the page's texts are stored as nl_NL ALTER SETTINGS LANGUAGE ADD 'nl_NL'; -ALTER SETTINGS LANGUAGE DefaultLanguageCode = 'nl_NL'; +ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'nl_NL' ); CREATE PAGE MyModule.Opslaan ( Title: 'Opslaanpagina', ... ) { ... } -- wrong: the page is authored while en_US is still the default, so its texts -- are stored as en_US and stay there CREATE PAGE MyModule.Opslaan ( Title: 'Opslaanpagina', ... ) { ... } -ALTER SETTINGS LANGUAGE DefaultLanguageCode = 'nl_NL'; +ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'nl_NL' ); ``` Changing `DefaultLanguageCode` **does not move text that already exists** — it @@ -173,8 +173,8 @@ ALTER SETTINGS WORKFLOWS = ; Examples: ```sql -ALTER SETTINGS WORKFLOWS UserEntity = 'Administration.Account'; -ALTER SETTINGS WORKFLOWS DefaultTaskParallelism = '5'; +ALTER SETTINGS WORKFLOWS ( UserEntity: 'Administration.Account' ); +ALTER SETTINGS WORKFLOWS ( DefaultTaskParallelism: '5' ); ``` ## See Also diff --git a/docs-site/src/language/snippets.md b/docs-site/src/language/snippets.md index 8469362269..ffcf71ce16 100644 --- a/docs-site/src/language/snippets.md +++ b/docs-site/src/language/snippets.md @@ -94,7 +94,7 @@ Snippets support the same in-place modification operations as pages. See [ALTER ```sql ALTER SNIPPET MyModule.CustomerCard { - SET Caption = 'View Details' ON btnEdit; + SET (Caption: 'View Details') ON btnEdit; INSERT AFTER txtEmail { DYNAMICTEXT txtPhone (Content: '{1}', Attribute: Phone) }; diff --git a/docs-site/src/reference/page/alter-layout.md b/docs-site/src/reference/page/alter-layout.md index 6bb19eef02..f3419800f7 100644 --- a/docs-site/src/reference/page/alter-layout.md +++ b/docs-site/src/reference/page/alter-layout.md @@ -23,10 +23,10 @@ INSERT BEFORE widgetName { widget_definitions }; INSERT AFTER widgetName { widget_definitions }; -- Set a property on a widget -SET property = value ON widgetName; +SET (property: value) ON widgetName; -- Remove widgets -DROP WIDGET widgetName1, widgetName2; +DROP widgetName1, widgetName2; -- Replace a widget with new widgets REPLACE widgetName WITH { widget_definitions }; @@ -114,11 +114,11 @@ Change it, then remove it: ```sql ALTER LAYOUT MyModule.App_Default { - SET Content = 'Renamed' ON brand; + SET (Content: 'Renamed') ON brand; }; ALTER LAYOUT MyModule.App_Default { - DROP WIDGET brand; + DROP brand; }; ``` diff --git a/docs-site/src/reference/settings/alter-settings.md b/docs-site/src/reference/settings/alter-settings.md index c8593588b5..bd5957e1b3 100644 --- a/docs-site/src/reference/settings/alter-settings.md +++ b/docs-site/src/reference/settings/alter-settings.md @@ -2,31 +2,31 @@ ## Synopsis - ALTER SETTINGS RUNTIME key = value + ALTER SETTINGS RUNTIME ( key: value, ... ) - ALTER SETTINGS CONFIGURATION 'name' key = value + ALTER SETTINGS CONFIGURATION 'name' ( key: value, ... ) ALTER SETTINGS CONSTANT 'name' VALUE 'value' IN CONFIGURATION 'config' ALTER SETTINGS DROP CONSTANT 'name' IN CONFIGURATION 'config' - CREATE CONFIGURATION 'name' [key = value, ...] + CREATE CONFIGURATION 'name' [( key: value, ... )] DROP CONFIGURATION 'name' - ALTER SETTINGS LANGUAGE key = value + ALTER SETTINGS LANGUAGE ( key: value, ... ) ALTER SETTINGS LANGUAGE ADD [OR MODIFY] 'code' [( option: value, ... )] ALTER SETTINGS LANGUAGE MODIFY 'code' ( option: value, ... ) ALTER SETTINGS LANGUAGE REMOVE 'code' - ALTER SETTINGS WORKFLOWS key = value + ALTER SETTINGS WORKFLOWS ( key: value, ... ) ALTER SETTINGS WORKFLOWS ADD [OR MODIFY] GROUP 'name' [( Description: 'text' )] ALTER SETTINGS WORKFLOWS MODIFY GROUP 'name' ( Description: 'text' ) ALTER SETTINGS WORKFLOWS REMOVE GROUP 'name' ## Description -Modifies project settings by category. Each category has its own syntax and available keys. +Modifies project settings by category. Each category has its own syntax and available keys. Properties are written `Key: value` in a parenthesised list, as in every other MDL statement; the older `key = value, ...` without parentheses still runs and warns `MDL-DEPR060`, and `mxcli fmt --upgrade` rewrites it. **MODEL** settings control application-level behavior such as the after-startup microflow, hashing algorithm, and Java version. @@ -61,7 +61,7 @@ task's group targeting selects from. Groups need Mendix **11.2** or later. ### Set the after-startup microflow ```sql -ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'MyModule.ACT_Startup'; +ALTER SETTINGS RUNTIME ( AfterStartupMicroflow: 'MyModule.ACT_Startup' ); ``` > **The after-startup microflow must return `Boolean`.** Mendix build fails with **CE0142** @@ -71,13 +71,13 @@ ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'MyModule.ACT_Startup'; ### Configure database type ```sql -ALTER SETTINGS CONFIGURATION 'default' DatabaseType = 'POSTGRESQL'; +ALTER SETTINGS CONFIGURATION 'default' ( DatabaseType: 'POSTGRESQL' ); ``` ### Set database URL for a configuration ```sql -ALTER SETTINGS CONFIGURATION 'production' DatabaseUrl = 'jdbc:postgresql://dbhost:5432/myapp'; +ALTER SETTINGS CONFIGURATION 'production' ( DatabaseUrl: 'jdbc:postgresql://dbhost:5432/myapp' ); ``` ### Override a constant in a configuration @@ -104,7 +104,7 @@ The code must already be enabled; `ALTER SETTINGS LANGUAGE DefaultLanguageCode` is validated against the enabled list. ```sql -ALTER SETTINGS LANGUAGE DefaultLanguageCode = 'en_US'; +ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'en_US' ); ``` ### Enable, change and disable a language @@ -132,7 +132,7 @@ the run reports how many source strings are affected. ### Configure workflow user entity ```sql -ALTER SETTINGS WORKFLOWS UserEntity = 'Administration.Account'; +ALTER SETTINGS WORKFLOWS ( UserEntity: 'Administration.Account' ); ``` ### Manage workflow groups @@ -170,7 +170,7 @@ List the groups with [`SHOW WORKFLOW GROUPS`](show-settings.md). ### Set Java version ```sql -ALTER SETTINGS RUNTIME JavaVersion = '17'; +ALTER SETTINGS RUNTIME ( JavaVersion: '17' ); ``` ### Remove a constant override from a configuration @@ -182,10 +182,11 @@ ALTER SETTINGS DROP CONSTANT 'MyModule.ApiBaseUrl' IN CONFIGURATION 'staging'; ### Create a new configuration ```sql -CREATE CONFIGURATION 'Staging' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'staging-db:5432', - HttpPortNumber = 8080; +CREATE CONFIGURATION 'Staging' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'staging-db:5432', + HttpPortNumber: 8080 +); ``` `DatabaseType` must name a Mendix database type — `Db2`, `Hsqldb`, `MySql`, diff --git a/docs-site/src/tools/docker-run.md b/docs-site/src/tools/docker-run.md index d0cd7a9dd2..ff621ddd51 100644 --- a/docs-site/src/tools/docker-run.md +++ b/docs-site/src/tools/docker-run.md @@ -28,8 +28,8 @@ The runtime uses configuration from the project's settings. You can view and mod ```sql SHOW SETTINGS; DESCRIBE SETTINGS; -ALTER SETTINGS CONFIGURATION 'default' DatabaseType = 'POSTGRESQL'; -ALTER SETTINGS CONFIGURATION 'default' HttpPortNumber = '8080'; +ALTER SETTINGS CONFIGURATION 'default' ( DatabaseType: 'POSTGRESQL' ); +ALTER SETTINGS CONFIGURATION 'default' ( HttpPortNumber: '8080' ); ``` ## Checking Project Health diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index fad6388442..c039e9a29b 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -102,7 +102,7 @@ Modifies an existing entity without full replacement. |-----------|--------|-------| | Add attribute | `alter entity Module.Name add attribute [if not exists] attr: type [constraints];` | Comma-separate the whole action to add several: `add attribute A: integer, add attribute B: string(20)`. `if not exists` skips instead of erroring, so the script re-runs | | Drop attribute | `alter entity Module.Name drop attribute [if exists] AttrName;` | `if exists` skips when it is already gone | -| Modify attributes | `alter entity Module.Name modify (attr: NewType [constraints]);` | Change type/constraints | +| Modify attribute | `alter entity Module.Name modify attribute Attr: NewType [constraints];` | Change type/constraints. Always `Name: Type`; without the colon warns MDL-DEPR065 | | Rename attribute | `alter entity Module.Name rename attribute OldName to NewName;` | Also rewrites stored references (microflow members, page widgets, validation/access rules) and XPath constraints. Microflow expressions are free text and are **not** rewritten | | Add index | `alter entity Module.Name add index [if not exists] [name] [on] (Col1 [asc\|desc], ...);` | `on` is optional (SQL-like). **Without `if not exists`, re-running is an error** — a second identical index fails the build with CE0072 | | Document an association | `/** What it links. */`
`create association Mod.C_P from Mod.C to Mod.P;`
or `... to Mod.P comment 'What it links.';` | Both spellings work on create; the doc comment wins when both are present. `comment` survives here — and only here among the CREATE statements — because it is an association's **only inline** spelling | @@ -256,7 +256,7 @@ expression specified". ```sql create entity Shop.Product ( Email: String(200) not null error message 'Required' ); -alter entity Shop.Product modify attribute Code String(20) unique error message 'Unique'; +alter entity Shop.Product modify attribute Code: String(20) unique error message 'Unique'; ``` A range bounded by another *attribute* cannot be authored in MDL, but survives a @@ -337,18 +337,19 @@ create scheduled event Ops.WeeklyReport ( | Show consumed odata services | `show consumed odata services [in module];` | Consumed OData services | | Describe consumed odata service | `describe consumed odata service Module.Name;` | Full MDL output | | Create consumed odata service | `create [or modify] consumed odata service Module.Name (...);` | Version, MetadataUrl, Timeout, etc. | -| Alter consumed odata service | `alter consumed odata service Module.Name set key = value;` | | +| Alter consumed odata service | `alter consumed odata service Module.Name set (Key: value, ...);` | | | Drop consumed odata service | `drop consumed odata service [if exists] Module.Name;` | | | Show published odata services | `show published odata services [in module];` | Published OData services | | Describe published odata service | `describe published odata service Module.Name;` | Full MDL output | | Create published odata service | `create [or modify] published odata service Module.Name (...) authentication ... { publish entity ... };` | | | Publish as GraphQL too | `create published odata service Module.Name (SupportsGraphQL: Yes) {...};` | Mendix 10.14+. Same location, clients POST a query. Exposed names must be unique beyond case (CE2881); query fields are camelCased | -| Alter published odata service | `alter published odata service Module.Name set key = value;` | | +| Alter published odata service | `alter published odata service Module.Name set (Key: value, ...);` | | | Drop published odata service | `drop published odata service [if exists] Module.Name;` | | | Show external entities | `show external entities [in module];` | OData-backed entities | | Show external actions | `show external actions [in module];` | Actions used in microflows | | Create external entity | `create [or modify] external entity Module.Name from consumed odata service Module.Client (...) (attrs);` | | | Create external entities | `create [or modify] external entities from Module.Client [into module] [entities (...)];` | Bulk from $metadata | +| Allow local create/change | `alter entity Module.Name set (AllowCreateChangeLocally: true);` | `create external entity`'s key. `set allow_create_change_locally = true` warns MDL-DEPR063 | | Grant OData access | `grant access on published odata service Module.Name to Module.Role, ...;` | | | Revoke OData access | `revoke access on published odata service Module.Name from Module.Role, ...;` | | | Show contract entities | `show contract entities from Module.Client;` | Browse cached $metadata | @@ -973,19 +974,19 @@ still flagged rather than guessed at. |-----------|--------|-------| | Show settings | `show settings;` | Overview of all settings parts | | Describe settings | `describe settings;` | Full MDL output (round-trippable) | -| Alter model settings | `alter settings runtime key = value;` | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. | -| Alter configuration | `alter settings configuration 'Name' key = value;` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | +| Alter model settings | `alter settings runtime (Key: value, ...);` | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. | +| Alter configuration | `alter settings configuration 'Name' (Key: value, ...);` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | | Alter constant | `alter settings constant 'Name' value 'val' in configuration 'cfg';` | Override constant per configuration | | Drop constant override | `alter settings drop constant 'Name' in configuration 'cfg';` | Reset to default value | | Create or modify configuration | `create or modify configuration 'Name' [key = value, ...];` | Upsert — what `describe settings` emits, so a described project replays onto a target that already has `Default` | | Create configuration | `create configuration 'Name' [key = value, ...];` | New server configuration. `DatabaseType` must be `Db2`, `Hsqldb`, `MySql`, `Oracle`, `PostgreSql`, `SapHana` or `SqlServer` (case-insensitive) | | Drop configuration | `drop configuration [if exists] 'Name';` | Remove a configuration | -| Alter language | `alter settings LANGUAGE key = value;` | DefaultLanguageCode (must already be enabled). Set it **before** creating pages — it decides what language their captions are stored in | +| Alter language | `alter settings LANGUAGE (Key: value);` | DefaultLanguageCode (must already be enabled). Set it **before** creating pages — it decides what language their captions are stored in | | Enable a language | `alter settings LANGUAGE add 'de_DE' [(CheckCompleteness: true, CustomDateFormat: 'yyyy-MM-dd')];` | Adds to the enabled list — the only languages a build emits translations for. A language is identified by its code; Studio Pro's "German, Germany" is derived for display and not stored | | Enable or modify (upsert) | `alter settings LANGUAGE add or modify 'de_DE' (CheckCompleteness: true);` | What `describe settings` emits, so a described project replays onto itself or onto one that already has the language | | Modify a language | `alter settings LANGUAGE modify 'de_DE' (CheckCompleteness: true);` | Changes only the options it names. `CheckCompleteness` turns on error reporting for texts with no translation in that language (the default language is always checked regardless) | | Disable a language | `alter settings LANGUAGE remove 'de_DE';` | The **default** language is refused (every missing translation falls back on it). Translations are NOT deleted — they stay in the model and stop being built; the run reports how many | -| Alter workflows | `alter settings workflows key = value;` | UserEntity, DefaultTaskParallelism, WorkflowEngineParallelism | +| Alter workflows | `alter settings workflows (Key: value, ...);` | UserEntity, DefaultTaskParallelism, WorkflowEngineParallelism | | Add a workflow group | `alter settings workflows add group 'Approvers' [(Description: 'Primary approval group')];` | The buckets under App Settings > Workflows > Groups that a user task's group targeting selects from. Mendix **11.2+**. `Description` is the only option — a `Settings$WorkflowGroup` stores Name and Description and nothing else, so the **name is the identity** and a second group differing only in case is refused | | Add or modify (upsert) | `alter settings workflows add or modify group 'Approvers' (Description: '...');` | What `describe settings` emits, so a described project replays onto itself | | Modify a workflow group | `alter settings workflows modify group 'Approvers' (Description: '...');` | Changes only the options it names, and keeps the group's element id — which is the **runtime's identity** for it (Mendix materialises one `System.WorkflowGroup` row per entry, keyed on that id), so an edit updates the row instead of replacing it | @@ -1490,6 +1491,7 @@ MDL uses explicit property declarations for pages: | Declare a placeholder | `placeholder Main` | **No body.** Exactly one must be named `Main` — mxbuild enforces it (**CE0848**/**CE0849**), and names must be unique (**CE0495**). `placeholder X { … }` is the page-side form and declares nothing (MDL083) | | Alter layout | `alter layout Module.Name { };` | Edits the stored document, so widgets MDL cannot spell survive. Refused for a Marketplace target | | Set a design property | `alter page Module.Page { set ('Row size': 'Small') on lvOrders; };` | An Atlas design property of that widget's **type** — quoted, case-sensitive; `show design properties for ` lists them. `on`/`off` for a toggle, where `off` removes the entry. Same document `alter styling` writes. A **multi-select** (`Hide on`) or **compound** (`Spacing`) property needs the inline `DesignProperties: [...]` form, since a `set` assignment carries one value | +| Restyle one widget | `alter styling on page Module.Page widget w set (Class: 'card', 'Full width': on);` | `set Class = …, 'P' = on` (no parentheses, `=`) warns MDL-DEPR062 | | Repoint one page | `alter page Module.Page { set Layout = Module.Layout [map (Old as New, …)]; };` | Rewrites the layout reference **and** every placeholder binding | | Set a design property on every widget of a type | `alter pages [in ] set 'Compact' = on, 'Striped' = on where widgettype = datagrid [dry run];` | The house-style sweep. `widgettype` takes the **MDL keyword**, which resolves to exactly one widget id — a `like '%datagrid%'` predicate also matches the data grid's *filter* widgets. Never a widget **name**: a name is unique only within its page. `dry run` previews against a discardable copy. A sweep that matches widgets and writes none of them exits non-zero | | Repoint many pages | `alter pages [in ] set layout = Module.Layout [map (…)] [where layout = Module.Old];` | The migration form. Marketplace pages are skipped and named. A `where layout` that names no real layout is an error, not a 0-page success | @@ -1816,7 +1818,7 @@ Bulk translation of every user-visible string, one file per language. Entries us | Replace | `create or replace translations ...` | The file is authoritative: a translation whose source it does not name is **REMOVED**, and the run says which. `in Module` **bounds** the deletion | | Remove a language's translations | `create or replace translations [in Module] for ( );` | An empty file is authoritative over nothing, so everything in scope goes — the only way to take a language's translations out of the model | | Show languages | `show languages;` | ⚠️ languages that **have translations**, not enabled ones — a stock app reports 8 while 1 is enabled. The enabled list is in `describe settings`. Needs `refresh catalog full` | -| Default language | `alter settings LANGUAGE DefaultLanguageCode = 'en_US';` | The language a translation file's left column is written in — **and the language a new `Caption:`/`Title:` is stored under**, so set it before authoring content. Changing it later does not move existing text and nothing warns | +| Default language | `alter settings LANGUAGE (DefaultLanguageCode: 'en_US');` | The language a translation file's left column is written in — **and the language a new `Caption:`/`Title:` is stored under**, so set it before authoring content. Changing it later does not move existing text and nothing warns | **A translation for a language the project has not enabled is discarded at build time** — it is stored in the model, passes `mx check`, and produces no diff --git a/mdl/ast/ast_alter_page.go b/mdl/ast/ast_alter_page.go index 602a06abd7..3656f62fdf 100644 --- a/mdl/ast/ast_alter_page.go +++ b/mdl/ast/ast_alter_page.go @@ -81,21 +81,6 @@ func (r WidgetRef) Name() string { return s } -// Spellings of the generic ALTER that are aliases of its canonical form -// (ADR-0011: an old form warns, and is rewritten mechanically). The visitor -// records which one a statement used; the executor maps it to a deprecation -// code. Nothing downstream of the validator may branch on these: both spellings -// build the identical operation. -const ( - // `set Key = value …` / `set (Key = value, …) …` — R3 puts `:` between a - // property and its value; `=` is comparison. - AlterAliasSetEquals = "set-equals" - // `set Key: value …` — properties are a parenthesised list (R2), even one. - AlterAliasSetUnparenthesised = "set-unparenthesised" - // `drop widget a, b` — the target names the element; the kind is its own. - AlterAliasDropWidget = "drop-widget" -) - // IsColumn returns true if this addresses a member of a widget: a grid column // (`dg.Name` or `dg column(Name)`) or a scroll-container region. func (r WidgetRef) IsColumn() bool { @@ -107,7 +92,6 @@ func (r WidgetRef) IsColumn() bool { type SetPropertyOp struct { Target WidgetRef // empty Widget for page-level SET Properties map[string]interface{} // property name -> value - Legacy string // AlterAlias* when an old spelling was used, else "" } func (s *SetPropertyOp) isAlterPageOperation() {} @@ -124,7 +108,6 @@ func (s *InsertWidgetOp) isAlterPageOperation() {} // DropWidgetOp represents: DROP WIDGET ref1, ref2, ... type DropWidgetOp struct { Targets []WidgetRef - Legacy string // AlterAliasDropWidget when written `drop widget …`, else "" } func (s *DropWidgetOp) isAlterPageOperation() {} diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 36f7f253e2..e19836106c 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -157,6 +157,40 @@ const ( // on a text template: the placeholders bound by position (R4, // ako/mxcli#751). PositionalTemplateArguments = "MDL-DEPR009" + + // Codes 060-069 and 101-103 are R3's (ako/mxcli#751, + // PROPOSAL_mdl_beta_syntax_freeze.md §3 R3): `:` sets a model property, so + // an `alter` sets properties in create's `( Key: value, … )` list, and a + // colon is written where a property list or an attribute definition has + // one and nowhere else. + + // AlterPageSetEquals is the generic alter's `set Key = value` / + // `set (Key = value, …)`. Numbered from 101 because it shipped with the + // generic alter (ako/mxcli#712) before the registry existed; the code is + // published, so it is kept. + AlterPageSetEquals = "MDL-DEPR101" + // AlterPageSetUnparenthesised is `set Key: value` without the list's + // parentheses. + AlterPageSetUnparenthesised = "MDL-DEPR102" + // AlterPageDropWidget is `drop widget a, b`. + AlterPageDropWidget = "MDL-DEPR103" + // SettingsAssignment is a settings property written `Key = value`, outside + // a list: `alter settings
`, `alter settings configuration` and + // `create configuration`. + SettingsAssignment = "MDL-DEPR060" + // ODataAlterAssignment is `alter … odata service X set Key = value, …`. + ODataAlterAssignment = "MDL-DEPR061" + // StylingAssignment is `alter styling … set Class = 'x', 'Prop' = on`. + StylingAssignment = "MDL-DEPR062" + // AllowCreateChangeLocally is `alter entity … set allow_create_change_locally + // = true`, the only snake-case `=` alter action. + AllowCreateChangeLocally = "MDL-DEPR063" + // AssociationClauseColon is `type: Reference` (also `owner:`, `storage:`) + // on an association: a clause, which takes no colon. + AssociationClauseColon = "MDL-DEPR064" + // ModifyAttributeColon is `modify attribute A T`: an attribute definition + // is always `Name: Type`. + ModifyAttributeColon = "MDL-DEPR065" ) // entries is the registry. Append only: a code is never reused or renumbered, @@ -283,8 +317,8 @@ var entries = []Entry{ Rewrite: Rewrite{Structural: "section name: `model` becomes `runtime`"}, RemovedIn: 2, Note: "`runtime` is the App Settings tab that holds these values in Studio Pro (R10); `model` also collided with the agent-editor document type.", - Example: "alter settings model AfterStartupMicroflow = 'M.Startup';", - CanonicalExample: "alter settings runtime AfterStartupMicroflow = 'M.Startup';", + Example: "alter settings model ( AfterStartupMicroflow: 'M.Startup' );", + CanonicalExample: "alter settings runtime ( AfterStartupMicroflow: 'M.Startup' );", }, { Code: ReversedEntityGrant, @@ -374,6 +408,105 @@ var entries = []Entry{ func init() { entries = append(entries, r8Entries...) + entries = append(entries, r3Entries...) +} + +// r3Entries are R3's spellings (ako/mxcli#751): `:` sets a model property. An +// `alter` takes exactly create's `( Key: value, … )` list, so a fragment of +// describe output pastes into an alter unchanged. +var r3Entries = []Entry{ + { + Code: AlterPageSetEquals, + Old: "set Key = value [on target] / set (Key = value, …) [on target]", + Canonical: "set (Key: value, …) [on target]", + Rewrite: Rewrite{Structural: "each `=` as `:`, and the assignments in parentheses when they are not"}, + RemovedIn: 2, + Note: "In `alter page`, `alter snippet` and `alter layout`. `set layout = M.L` is a separate form " + + "and is not reported.", + Example: "alter page M.P { set Caption = 'Save' on btnSave; };", + CanonicalExample: "alter page M.P { set (Caption: 'Save') on btnSave; };", + }, + { + Code: AlterPageSetUnparenthesised, + Old: "set Key: value [on target]", + Canonical: "set (Key: value) [on target]", + Rewrite: Rewrite{Structural: "assignment in parentheses"}, + RemovedIn: 2, + Note: "Properties are a parenthesised list, even when there is one.", + Example: "alter page M.P { set Caption: 'Save' on btnSave; };", + CanonicalExample: "alter page M.P { set (Caption: 'Save') on btnSave; };", + }, + { + Code: AlterPageDropWidget, + Old: "drop widget a, b", + Canonical: "drop a, b", + Rewrite: Rewrite{Structural: "`drop widget a` as `drop a`"}, + RemovedIn: 2, + Note: "The target names the element; the kind is its own.", + Example: "alter page M.P { drop widget txtOld; };", + CanonicalExample: "alter page M.P { drop txtOld; };", + }, + { + Code: SettingsAssignment, + Old: "alter settings runtime Key = value, … / create configuration 'X' Key = value, …", + Canonical: "alter settings runtime ( Key: value, … ) / create configuration 'X' ( Key: value, … )", + Rewrite: Rewrite{Structural: "assignments in parentheses, each `=` as `:`"}, + RemovedIn: 2, + Note: "Every settings section (runtime, language, workflows, configuration 'X') and `create configuration`. " + + "`alter settings constant 'C' value 'v'` is a clause, not a property, and is unchanged.", + Example: "alter settings runtime AfterStartupMicroflow = 'M.Startup', BcryptCost = 11;", + CanonicalExample: "alter settings runtime ( AfterStartupMicroflow: 'M.Startup', BcryptCost: 11 );", + }, + { + Code: ODataAlterAssignment, + Old: "alter consumed|published odata service X set Key = value, …", + Canonical: "alter consumed|published odata service X set ( Key: value, … )", + Rewrite: Rewrite{Structural: "assignments in parentheses, each `=` as `:`"}, + RemovedIn: 2, + Note: "The list takes exactly the keys and values of the service's `create` statement.", + Example: "alter consumed odata service M.Crm set Version = '2.0', Timeout = 30;", + CanonicalExample: "alter consumed odata service M.Crm set ( Version: '2.0', Timeout: 30 );", + }, + { + Code: StylingAssignment, + Old: "alter styling on page P widget w set Class = 'x', 'Full width' = on", + Canonical: "alter styling on page P widget w set ( Class: 'x', 'Full width': on )", + Rewrite: Rewrite{Structural: "assignments in parentheses, each `=` as `:`"}, + RemovedIn: 2, + Note: "The same list `alter page … set ( … ) on w` takes for a widget's class, style and design properties.", + Example: "alter styling on page M.P widget ctn1 set Class = 'card', 'Full width' = on;", + CanonicalExample: "alter styling on page M.P widget ctn1 set ( Class: 'card', 'Full width': on );", + }, + { + Code: AllowCreateChangeLocally, + Old: "alter entity M.E set allow_create_change_locally = true", + Canonical: "alter entity M.E set ( AllowCreateChangeLocally: true )", + Rewrite: Rewrite{Structural: "property as create's list: `set ( AllowCreateChangeLocally: )`"}, + RemovedIn: 2, + Note: "The key `create external entity` takes for the same property.", + Example: "alter entity M.Remote set allow_create_change_locally = true;", + CanonicalExample: "alter entity M.Remote set ( AllowCreateChangeLocally: true );", + }, + { + Code: AssociationClauseColon, + Old: "type: Reference / owner: Both / storage: Table", + Canonical: "type Reference / owner Both / storage Table", + Rewrite: Rewrite{Structural: "clause without its colon: `type: Reference` as `type Reference` (also `owner`, `storage`)"}, + RemovedIn: 2, + Note: "A clause outside a property list takes no colon, as describe writes it.", + Example: "create association M.Order_Customer from M.Order to M.Customer type: Reference;", + CanonicalExample: "create association M.Order_Customer from M.Order to M.Customer type Reference;", + }, + { + Code: ModifyAttributeColon, + Old: "alter entity M.E modify attribute A Type", + Canonical: "alter entity M.E modify attribute A: Type", + Rewrite: Rewrite{Structural: "attribute definition with its colon: `A Type` as `A: Type`"}, + RemovedIn: 2, + Note: "An attribute definition is always `Name: Type`, as in `create entity` and `add attribute`.", + Example: "alter entity M.E modify attribute Code String(20);", + CanonicalExample: "alter entity M.E modify attribute Code: String(20);", + }, } // r8Entries are R8's spellings (ako/mxcli#752). Kept apart from the list above diff --git a/mdl/executor/alter_aliases.go b/mdl/executor/alter_aliases.go deleted file mode 100644 index 647668eefe..0000000000 --- a/mdl/executor/alter_aliases.go +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/mdl/ast" - "github.com/mendixlabs/mxcli/mdl/backend" - "github.com/mendixlabs/mxcli/mdl/linter" -) - -// The old spellings of ALTER PAGE / SNIPPET / LAYOUT are aliases of the generic -// ALTER (ADR-0012 decision 2): they parse to the identical operation and warn -// (ADR-0011: an old form keeps working through the alias window, and says what -// replaces it). -// -// Each entry has the shape the deprecation registry (ako/mxcli#709) takes — -// code, old form, canonical form, mechanical rewrite, language version it is -// removed in — so the registry absorbs this table rather than re-deriving it. -// Until it lands the codes are provisional and numbered from 101, clear of the -// registry's seed entries. The grammar marks each alias alternative with -// `// alias: `; TestAlterAliasGrammarMarkersMatchTable pins the two -// together, in both directions. -type alterAlias struct { - Code string // MDL-DEPRnnn - Spelling string // ast.AlterAlias* - Old string // the old form, as written - Canonical string // what replaces it - Rewrite string // the mechanical rewrite, for `fmt --upgrade` - RemovedIn string // the MDL language version that drops the alias -} - -var alterAliases = []alterAlias{ - { - Code: "MDL-DEPR101", - Spelling: ast.AlterAliasSetEquals, - Old: "set Key = value [on target] / set (Key = value, …) [on target]", - Canonical: "set (Key: value, …) [on target]", - Rewrite: "put the assignments in parentheses and write each `=` as `:`", - RemovedIn: "mdl 2", - }, - { - Code: "MDL-DEPR102", - Spelling: ast.AlterAliasSetUnparenthesised, - Old: "set Key: value [on target]", - Canonical: "set (Key: value) [on target]", - Rewrite: "put the assignment in parentheses", - RemovedIn: "mdl 2", - }, - { - Code: "MDL-DEPR103", - Spelling: ast.AlterAliasDropWidget, - Old: "drop widget a, b", - Canonical: "drop a, b", - Rewrite: "remove the word `widget`", - RemovedIn: "mdl 2", - }, -} - -func alterAliasFor(spelling string) (alterAlias, bool) { - for _, a := range alterAliases { - if a.Spelling == spelling { - return a, true - } - } - return alterAlias{}, false -} - -// validateAlterAliases warns on every old ALTER spelling a statement uses, once -// per operation. A warning, never an error: both spellings build the identical -// operation, and scripts in the wild use the old ones. -func validateAlterAliases(stmt ast.Statement) []linter.Violation { - s, ok := stmt.(*ast.AlterPageStmt) - if !ok { - return nil - } - kind := strings.ToLower(s.ContainerType) - if kind == "" { - kind = "page" - } - var out []linter.Violation - for _, op := range s.Operations { - var spelling string - switch o := op.(type) { - case *ast.SetPropertyOp: - spelling = o.Legacy - case *ast.DropWidgetOp: - spelling = o.Legacy - } - if spelling == "" { - continue - } - a, known := alterAliasFor(spelling) - if !known { - continue - } - out = append(out, linter.Violation{ - RuleID: a.Code, - Severity: linter.SeverityWarning, - Message: fmt.Sprintf("alter %s %s: `%s` is the old spelling of `%s`", - kind, s.PageName.String(), a.Old, a.Canonical), - Location: linter.Location{ - Module: s.PageName.Module, - DocumentType: kind, - DocumentName: s.PageName.Name, - }, - Suggestion: fmt.Sprintf("Write `%s` (%s). Both build the identical change; the old form is removed in %s.", - a.Canonical, a.Rewrite, a.RemovedIn), - }) - } - return out -} - -// validateAlterPageAddresses refuses, with no project needed, a target whose -// address FORM a page cannot use: a quoted caption, an @n, a path of more than -// two names. The generic grammar accepts every address form any document type -// uses, and the page family's resolver refuses these whatever the page holds — -// so `check` can say so up front instead of `exec` stopping mid-script. The -// rule is the resolver's own (backend.CheckPageAlterTarget), not a copy. -func validateAlterPageAddresses(stmt ast.Statement) []linter.Violation { - s, ok := stmt.(*ast.AlterPageStmt) - if !ok { - return nil - } - kind := strings.ToLower(s.ContainerType) - if kind == "" { - kind = "page" - } - var out []linter.Violation - for _, op := range s.Operations { - for _, ref := range alterPageOperationTargets(op) { - if err := backend.CheckPageAlterTarget(alterTargetOf(ref)); err != nil { - out = append(out, linter.Violation{ - RuleID: "MDL-ALTER01", - Severity: linter.SeverityError, - Message: fmt.Sprintf("alter %s %s: %v", kind, s.PageName.String(), err), - Location: linter.Location{ - Module: s.PageName.Module, - DocumentType: kind, - DocumentName: s.PageName.Name, - }, - }) - } - } - } - return out -} diff --git a/mdl/executor/cmd_settings.go b/mdl/executor/cmd_settings.go index 93bf8e21a8..520780ba33 100644 --- a/mdl/executor/cmd_settings.go +++ b/mdl/executor/cmd_settings.go @@ -148,27 +148,31 @@ func describeSettings(ctx *ExecContext, configName string) error { } } - addIfSet("AfterStartupMicroflow", "AfterStartupMicroflow = '%s'", ms.AfterStartupMicroflow) - addIfSet("BeforeShutdownMicroflow", "BeforeShutdownMicroflow = '%s'", ms.BeforeShutdownMicroflow) - addIfSet("HealthCheckMicroflow", "HealthCheckMicroflow = '%s'", ms.HealthCheckMicroflow) - add("HashAlgorithm", "HashAlgorithm = '%s'", ms.HashAlgorithm) - add("BcryptCost", "BcryptCost = %d", ms.BcryptCost) + addIfSet("AfterStartupMicroflow", "AfterStartupMicroflow: '%s'", ms.AfterStartupMicroflow) + addIfSet("BeforeShutdownMicroflow", "BeforeShutdownMicroflow: '%s'", ms.BeforeShutdownMicroflow) + addIfSet("HealthCheckMicroflow", "HealthCheckMicroflow: '%s'", ms.HealthCheckMicroflow) + add("HashAlgorithm", "HashAlgorithm: '%s'", ms.HashAlgorithm) + add("BcryptCost", "BcryptCost: %d", ms.BcryptCost) // JavaVersion is stored under either JavaVersion or JavaMajorVersion; emit it // when the document carries whichever spelling, in mxcli's single input name. if settingsoverlay.JavaVersionKey(stored) != "" || stored == nil { - parts = append(parts, fmt.Sprintf(" JavaVersion = '%s'", ms.JavaVersion)) + parts = append(parts, fmt.Sprintf(" JavaVersion: '%s'", ms.JavaVersion)) + } + add("RoundingMode", "RoundingMode: '%s'", ms.RoundingMode) + add("AllowUserMultipleSessions", "AllowUserMultipleSessions: %t", ms.AllowUserMultipleSessions) + add("EnableDataStorageOptimisticLocking", "EnableDataStorageOptimisticLocking: %t", ms.EnableDataStorageOptimisticLocking) + add("UseDatabaseForeignKeyConstraints", "UseDatabaseForeignKeyConstraints: %t", ms.UseDatabaseForeignKeyConstraints) + add("UseOQLVersion2", "UseOQLVersion2: %t", ms.UseOQLVersion2) + add("DecimalScale", "DecimalScale: %d", ms.DecimalScale) + addIfSet("FirstDayOfWeek", "FirstDayOfWeek: '%s'", ms.FirstDayOfWeek) + addIfSet("SslCertificateAlgorithm", "SslCertificateAlgorithm: '%s'", ms.SslCertificateAlgorithm) + addIfSet("ScheduledEventTimeZoneCode", "ScheduledEventTimeZoneCode: '%s'", ms.ScheduledEventTimeZoneCode) + addIfSet("DefaultTimeZoneCode", "DefaultTimeZoneCode: '%s'", ms.DefaultTimeZoneCode) + // R3: the ( Key: value, … ) list every alter takes (ako/mxcli#751). An + // empty list does not parse, so nothing is written for none. + if len(parts) > 0 { + fmt.Fprintf(ctx.Output, "alter settings runtime (\n%s\n);\n\n", strings.Join(parts, ",\n")) } - add("RoundingMode", "RoundingMode = '%s'", ms.RoundingMode) - add("AllowUserMultipleSessions", "AllowUserMultipleSessions = %t", ms.AllowUserMultipleSessions) - add("EnableDataStorageOptimisticLocking", "EnableDataStorageOptimisticLocking = %t", ms.EnableDataStorageOptimisticLocking) - add("UseDatabaseForeignKeyConstraints", "UseDatabaseForeignKeyConstraints = %t", ms.UseDatabaseForeignKeyConstraints) - add("UseOQLVersion2", "UseOQLVersion2 = %t", ms.UseOQLVersion2) - add("DecimalScale", "DecimalScale = %d", ms.DecimalScale) - addIfSet("FirstDayOfWeek", "FirstDayOfWeek = '%s'", ms.FirstDayOfWeek) - addIfSet("SslCertificateAlgorithm", "SslCertificateAlgorithm = '%s'", ms.SslCertificateAlgorithm) - addIfSet("ScheduledEventTimeZoneCode", "ScheduledEventTimeZoneCode = '%s'", ms.ScheduledEventTimeZoneCode) - addIfSet("DefaultTimeZoneCode", "DefaultTimeZoneCode = '%s'", ms.DefaultTimeZoneCode) - fmt.Fprintf(ctx.Output, "alter settings runtime\n%s;\n\n", strings.Join(parts, ",\n")) } // Configuration settings @@ -211,7 +215,7 @@ func describeSettings(ctx *ExecContext, configName string) error { mdlQuoted(l.Code), l.CheckCompleteness, mdlQuoted(l.CustomDateFormat), mdlQuoted(l.CustomTimeFormat), mdlQuoted(l.CustomDateTimeFormat)) } - fmt.Fprintf(ctx.Output, "alter settings LANGUAGE\n DefaultLanguageCode = '%s';\n", ps.Language.DefaultLanguageCode) + fmt.Fprintf(ctx.Output, "alter settings LANGUAGE (\n DefaultLanguageCode: '%s'\n);\n", ps.Language.DefaultLanguageCode) fmt.Fprintln(ctx.Output) } @@ -220,16 +224,16 @@ func describeSettings(ctx *ExecContext, configName string) error { ws := ps.Workflows var parts []string if ws.UserEntity != "" { - parts = append(parts, fmt.Sprintf(" UserEntity = '%s'", ws.UserEntity)) + parts = append(parts, fmt.Sprintf(" UserEntity: '%s'", ws.UserEntity)) } if ws.DefaultTaskParallelism > 0 { - parts = append(parts, fmt.Sprintf(" DefaultTaskParallelism = %d", ws.DefaultTaskParallelism)) + parts = append(parts, fmt.Sprintf(" DefaultTaskParallelism: %d", ws.DefaultTaskParallelism)) } if ws.WorkflowEngineParallelism > 0 { - parts = append(parts, fmt.Sprintf(" WorkflowEngineParallelism = %d", ws.WorkflowEngineParallelism)) + parts = append(parts, fmt.Sprintf(" WorkflowEngineParallelism: %d", ws.WorkflowEngineParallelism)) } if len(parts) > 0 { - fmt.Fprintf(ctx.Output, "alter settings workflows\n%s;\n\n", strings.Join(parts, ",\n")) + fmt.Fprintf(ctx.Output, "alter settings workflows (\n%s\n);\n\n", strings.Join(parts, ",\n")) } // The groups, in stored order. `add or modify` so a described project // re-executes against a project that already has some of them. @@ -972,31 +976,31 @@ func settingsValueToString(val any) string { // writeSettingsConfiguration emits one configuration as re-executable MDL. func writeSettingsConfiguration(ctx *ExecContext, cfg *model.ServerConfiguration) { var parts []string - parts = append(parts, " DatabaseType = "+mdlQuoted(cfg.DatabaseType)) - parts = append(parts, " DatabaseUrl = "+mdlQuoted(cfg.DatabaseUrl)) - parts = append(parts, " DatabaseName = "+mdlQuoted(cfg.DatabaseName)) - parts = append(parts, " DatabaseUserName = "+mdlQuoted(cfg.DatabaseUserName)) + parts = append(parts, " DatabaseType: "+mdlQuoted(cfg.DatabaseType)) + parts = append(parts, " DatabaseUrl: "+mdlQuoted(cfg.DatabaseUrl)) + parts = append(parts, " DatabaseName: "+mdlQuoted(cfg.DatabaseName)) + parts = append(parts, " DatabaseUserName: "+mdlQuoted(cfg.DatabaseUserName)) // DatabasePassword is deliberately not printed (ako/mxcli#707): describe // output is what gets committed and reviewed, and printing the stored value // put a database credential into PR diffs. Omitting the key is lossless for // the replay that matters — `create or modify` on an existing configuration // is a patch, so the stored password is left as it is. On a project without // this configuration the password starts empty, which the comment says. - parts = append(parts, fmt.Sprintf(" HttpPortNumber = %d", cfg.HttpPortNumber)) - parts = append(parts, fmt.Sprintf(" ServerPortNumber = %d", cfg.ServerPortNumber)) + parts = append(parts, fmt.Sprintf(" HttpPortNumber: %d", cfg.HttpPortNumber)) + parts = append(parts, fmt.Sprintf(" ServerPortNumber: %d", cfg.ServerPortNumber)) if cfg.ApplicationRootUrl != "" { - parts = append(parts, " ApplicationRootUrl = "+mdlQuoted(cfg.ApplicationRootUrl)) + parts = append(parts, " ApplicationRootUrl: "+mdlQuoted(cfg.ApplicationRootUrl)) } if cfg.DatabasePassword != "" { fmt.Fprintf(ctx.Output, "-- DatabasePassword is set in configuration %s and is not printed; "+ - "add `DatabasePassword = '…'` to set it on a project that lacks it.\n", mdlQuoted(cfg.Name)) + "add `DatabasePassword: '…'` to set it on a project that lacks it.\n", mdlQuoted(cfg.Name)) } // CREATE OR MODIFY, not ALTER: a described project has to replay onto a // target that does not have this configuration yet. ALTER answered // "configuration not found: Acceptance" and stopped the whole file, which is // the same shape as the language list emitting a comment — output that reads // correctly and cannot be run. - fmt.Fprintf(ctx.Output, "create or modify configuration %s\n%s;\n\n", mdlQuoted(cfg.Name), strings.Join(parts, ",\n")) + fmt.Fprintf(ctx.Output, "create or modify configuration %s (\n%s\n);\n\n", mdlQuoted(cfg.Name), strings.Join(parts, ",\n")) // Output constant overrides. A private override has no value in the // model — emitting `value ''` would round-trip into a *shared* empty diff --git a/mdl/executor/cmd_settings_configuration_test.go b/mdl/executor/cmd_settings_configuration_test.go index 0366077a24..5bb9af51c3 100644 --- a/mdl/executor/cmd_settings_configuration_test.go +++ b/mdl/executor/cmd_settings_configuration_test.go @@ -51,7 +51,7 @@ func TestDescribeSettingsConfiguration_ByName(t *testing.T) { if !strings.Contains(out, "create or modify configuration 'Default'") { t.Errorf("expected the named configuration in its replayable form, got:\n%s", out) } - if !strings.Contains(out, "ApplicationRootUrl = 'http://backend.local:8080/'") { + if !strings.Contains(out, "ApplicationRootUrl: 'http://backend.local:8080/'") { t.Errorf("expected the root URL, got:\n%s", out) } // Naming one configuration means one configuration, not all of them. diff --git a/mdl/executor/cmd_settings_describe_canonical_test.go b/mdl/executor/cmd_settings_describe_canonical_test.go new file mode 100644 index 0000000000..e9ee8f3830 --- /dev/null +++ b/mdl/executor/cmd_settings_describe_canonical_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" +) + +// R3 (ako/mxcli#751): describe settings writes every section as the +// ( Key: value, … ) list `alter` takes, so its output replays with no +// deprecation warning, under no header and under `mdl 1;` alike. The control +// is the property count: output that dropped the properties would parse +// cleanly too. +func TestDescribeSettings_EmitsCanonicalPropertyLists(t *testing.T) { + ps := &model.ProjectSettings{ + Model: &model.ModelSettings{ + AfterStartupMicroflow: "M.ASU", HashAlgorithm: "BCrypt", BcryptCost: 11, + JavaVersion: "Java21", RoundingMode: "HalfEven", + }, + Configuration: &model.ConfigurationSettings{ + Configurations: []*model.ServerConfiguration{ + {Name: "Default", DatabaseType: "Hsqldb", DatabaseName: "default", HttpPortNumber: 8080, + ApplicationRootUrl: "http://localhost:8080/"}, + }, + }, + Language: &model.LanguageSettings{DefaultLanguageCode: "en_US", Languages: []model.Language{{Code: "en_US"}}}, + Workflows: &model.WorkflowsSettings{UserEntity: "System.User", DefaultTaskParallelism: 3, + Groups: []model.WorkflowGroup{{Name: "Approvers", Description: "First line"}}}, + } + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { return ps, nil }, + } + ctx, buf := newMockCtx(t, withBackend(mb)) + if err := describeSettings(ctx, ""); err != nil { + t.Fatal(err) + } + out := buf.String() + + for _, header := range []string{"", "mdl 1;\n"} { + prog, errs := visitor.Build(header + out) + if len(errs) > 0 { + t.Fatalf("describe output does not parse (header %q): %v\n%s", header, errs, out) + } + if len(prog.Deprecations) > 0 { + t.Errorf("describe output uses a deprecated spelling (header %q): %+v\n%s", header, prog.Deprecations, out) + } + } + for _, want := range []string{ + "alter settings runtime (\n AfterStartupMicroflow: 'M.ASU',", + "create or modify configuration 'Default' (\n DatabaseType: 'Hsqldb',", + "alter settings LANGUAGE (\n DefaultLanguageCode: 'en_US'\n);", + "alter settings workflows (\n UserEntity: 'System.User',\n DefaultTaskParallelism: 3\n);", + } { + if !strings.Contains(out, want) { + t.Errorf("describe output lacks %q:\n%s", want, out) + } + } + if strings.Contains(out, " = ") { + t.Errorf("describe output still assigns with `=`:\n%s", out) + } +} diff --git a/mdl/executor/cmd_settings_languages.go b/mdl/executor/cmd_settings_languages.go index 634ba1814d..72ab7e90fe 100644 --- a/mdl/executor/cmd_settings_languages.go +++ b/mdl/executor/cmd_settings_languages.go @@ -251,7 +251,7 @@ func alterSettingsLanguageRemove(ctx *ExecContext, ps *model.ProjectSettings, st if strings.EqualFold(ps.Language.DefaultLanguageCode, code) { return mdlerrors.NewValidationf( "%s is the project's DEFAULT language and cannot be removed — every missing translation falls back on it. "+ - "Make another language the default first: `alter settings LANGUAGE DefaultLanguageCode = ''`", code) + "Make another language the default first: `alter settings LANGUAGE (DefaultLanguageCode: '')`", code) } stored := ps.Language.Languages[idx].Code diff --git a/mdl/executor/validate_alter_page_addresses.go b/mdl/executor/validate_alter_page_addresses.go new file mode 100644 index 0000000000..91d430acb1 --- /dev/null +++ b/mdl/executor/validate_alter_page_addresses.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// validateAlterPageAddresses refuses, with no project needed, a target whose +// address FORM a page cannot use: a quoted caption, an @n, a path of more than +// two names. The generic grammar accepts every address form any document type +// uses, and the page family's resolver refuses these whatever the page holds — +// so `check` can say so up front instead of `exec` stopping mid-script. The +// rule is the resolver's own (backend.CheckPageAlterTarget), not a copy. +func validateAlterPageAddresses(stmt ast.Statement) []linter.Violation { + s, ok := stmt.(*ast.AlterPageStmt) + if !ok { + return nil + } + kind := strings.ToLower(s.ContainerType) + if kind == "" { + kind = "page" + } + var out []linter.Violation + for _, op := range s.Operations { + for _, ref := range alterPageOperationTargets(op) { + if err := backend.CheckPageAlterTarget(alterTargetOf(ref)); err != nil { + out = append(out, linter.Violation{ + RuleID: "MDL-ALTER01", + Severity: linter.SeverityError, + Message: fmt.Sprintf("alter %s %s: %v", kind, s.PageName.String(), err), + Location: linter.Location{ + Module: s.PageName.Module, + DocumentType: kind, + DocumentName: s.PageName.Name, + }, + }) + } + } + } + return out +} diff --git a/mdl/executor/alter_aliases_test.go b/mdl/executor/validate_alter_page_addresses_test.go similarity index 63% rename from mdl/executor/alter_aliases_test.go rename to mdl/executor/validate_alter_page_addresses_test.go index 2ef32eae80..1975c8619b 100644 --- a/mdl/executor/alter_aliases_test.go +++ b/mdl/executor/validate_alter_page_addresses_test.go @@ -3,10 +3,6 @@ package executor import ( - "os" - "path/filepath" - "regexp" - "sort" "strings" "testing" @@ -30,7 +26,8 @@ func aliasWarnings(t *testing.T, src string) []linter.Violation { } // The old ALTER PAGE spellings still run, and warn with the code that names -// their rewrite (ako/mxcli#712). The canonical script is the control: it must +// their rewrite (ako/mxcli#712; registered in mdl/deprecation since +// ako/mxcli#751). The canonical script is the control: it must // produce no deprecation warning at all, or a warning on every ALTER would pass. func TestAlterAliases_OldSpellingsWarn(t *testing.T) { old := aliasWarnings(t, `alter page M.P { @@ -50,8 +47,8 @@ func TestAlterAliases_OldSpellingsWarn(t *testing.T) { if strings.Join(codes, ",") != strings.Join(want, ",") { t.Errorf("codes: got %v, want %v", codes, want) } - if len(old) > 0 && !strings.Contains(old[0].Suggestion, "set (Key: value") { - t.Errorf("suggestion should name the canonical form: %q", old[0].Suggestion) + if len(old) > 0 && !strings.Contains(old[0].Message, "set (Key: value") { + t.Errorf("the warning should name the canonical form: %q", old[0].Message) } canonical := aliasWarnings(t, `alter page M.P { @@ -66,47 +63,6 @@ func TestAlterAliases_OldSpellingsWarn(t *testing.T) { } } -// Every grammar alternative marked `// alias: ` has an entry in the -// alias table, and every entry is marked somewhere in the grammar — so an alias -// cannot be added to one without the other. The deprecation registry -// (ako/mxcli#709) generalises this check. -func TestAlterAliasGrammarMarkersMatchTable(t *testing.T) { - marker := regexp.MustCompile(`//\s*alias:\s*(MDL-DEPR\d+)`) - files, _ := filepath.Glob("../grammar/*.g4") - more, _ := filepath.Glob("../grammar/domains/*.g4") - files = append(files, more...) - if len(files) == 0 { - t.Fatal("no grammar files found") - } - marked := map[string]bool{} - for _, f := range files { - b, err := os.ReadFile(f) - if err != nil { - t.Fatal(err) - } - for _, m := range marker.FindAllStringSubmatch(string(b), -1) { - marked[m[1]] = true - } - } - table := map[string]bool{} - for _, a := range alterAliases { - table[a.Code] = true - if !marked[a.Code] { - t.Errorf("%s is in the alias table but no grammar alternative is marked `// alias: %s`", a.Code, a.Code) - } - } - var missing []string - for code := range marked { - if !table[code] { - missing = append(missing, code) - } - } - sort.Strings(missing) - for _, code := range missing { - t.Errorf("grammar marks an alias %s with no entry in alterAliases", code) - } -} - // A caption or @n target is a form a page does not use. The generic grammar // parses it (other document types need it), so check must refuse it — with no // project — rather than leave exec to stop partway through a script. A name diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index 8997177628..fb2b710ddf 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -110,9 +110,6 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { if awfStmt, ok := stmt.(*ast.AlterWorkflowStmt); ok { violations = append(violations, ValidateAlterWorkflow(awfStmt)...) } - // The old ALTER PAGE / SNIPPET / LAYOUT spellings are aliases of the - // generic ALTER and warn with their deprecation code (MDL-DEPR101..103). - violations = append(violations, validateAlterAliases(stmt)...) // A page element is addressed by name; a caption or @n target is // refused before exec would stop on it (MDL-ALTER01). violations = append(violations, validateAlterPageAddresses(stmt)...) diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 28fe0f0230..6a43211cc9 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -163,7 +163,11 @@ alterStatement | alterEntitiesStatement | ALTER ASSOCIATION qualifiedName alterAssociationAction+ | ALTER ENUMERATION qualifiedName alterEnumerationAction+ + // R3 (ako/mxcli#751): `set ( Key: value, … )`, create's property list. + // The unparenthesised `set Key = value, …` is the old spelling. + | ALTER consumedODataServiceKw qualifiedName SET odataAlterPropertyList | ALTER consumedODataServiceKw qualifiedName SET odataAlterAssignment (COMMA odataAlterAssignment)* + | ALTER publishedODataServiceKw qualifiedName SET odataAlterPropertyList | ALTER publishedODataServiceKw qualifiedName SET odataAlterAssignment (COMMA odataAlterAssignment)* | ALTER STYLING ON (PAGE | SNIPPET) qualifiedName WIDGET IDENTIFIER alterStylingAction+ | ALTER SETTINGS alterSettingsClause @@ -226,16 +230,24 @@ publishedRestAlterAssignment * ``` */ alterStylingAction - : SET alterStylingAssignment (COMMA alterStylingAssignment)* + : SET LPAREN alterStylingAssignment (COMMA alterStylingAssignment)* RPAREN // set ( Class: 'x', 'Full width': on ) + | SET alterStylingAssignment (COMMA alterStylingAssignment)* /* @alias MDL-DEPR062 */ // set Class = 'x' | CLEAR DESIGN PROPERTIES ; +// `Key: value` is canonical (R3: `:` sets a model property); `=` is the old +// spelling, still accepted. +alterStylingAssignOp + : COLON + | EQUALS /* @alias MDL-DEPR062 */ + ; + alterStylingAssignment - : CLASS EQUALS STRING_LITERAL // Class = 'my-class' - | STYLE EQUALS STRING_LITERAL // Style = 'color: red;' - | STRING_LITERAL EQUALS STRING_LITERAL // 'Spacing top' = 'Large' - | STRING_LITERAL EQUALS ON // 'Full width' = ON - | STRING_LITERAL EQUALS OFF // 'Full width' = OFF + : CLASS alterStylingAssignOp STRING_LITERAL // Class: 'my-class' + | STYLE alterStylingAssignOp STRING_LITERAL // Style: 'color: red;' + | STRING_LITERAL alterStylingAssignOp STRING_LITERAL // 'Spacing top': 'Large' + | STRING_LITERAL alterStylingAssignOp ON // 'Full width': ON + | STRING_LITERAL alterStylingAssignOp OFF // 'Full width': OFF ; /** @@ -282,7 +294,7 @@ alterOperation alterSet : SET LAYOUT EQUALS qualifiedName (MAP LPAREN alterLayoutMapping (COMMA alterLayoutMapping)* RPAREN)? // SET Layout = Atlas_Core.TopBar MAP (Main AS Content) | SET LPAREN alterPageAssignment (COMMA alterPageAssignment)* RPAREN (ON alterTarget)? // set (Caption: 'Save', ButtonStyle: Success) on btnSave - | SET alterPageAssignment (ON alterTarget)? // alias: MDL-DEPR102 — set Caption: 'Save' on btnSave + | SET alterPageAssignment (ON alterTarget)? /* @alias MDL-DEPR102 */ // set Caption: 'Save' on btnSave ; alterLayoutMapping @@ -299,7 +311,7 @@ alterReplace alterDrop : DROP alterTarget (COMMA alterTarget)* - | DROP WIDGET alterTarget (COMMA alterTarget)* // alias: MDL-DEPR103 — drop widget a, b + | DROP WIDGET /* @alias MDL-DEPR103 */ alterTarget (COMMA alterTarget)* // drop widget a, b ; // A fragment is written exactly as `create` writes the same content. Only the @@ -408,7 +420,7 @@ alterPagesStylingAssignment // the old spelling, still accepted. alterAssignOp : COLON - | EQUALS // alias: MDL-DEPR101 — set (Caption = 'Save') / set Caption = 'Save' + | EQUALS /* @alias MDL-DEPR101 */ // set (Caption = 'Save') / set Caption = 'Save' ; alterPageAssignment diff --git a/mdl/grammar/domains/MDLDomainModel.g4 b/mdl/grammar/domains/MDLDomainModel.g4 index 65ae1ff81e..ad40400cdd 100644 --- a/mdl/grammar/domains/MDLDomainModel.g4 +++ b/mdl/grammar/domains/MDLDomainModel.g4 @@ -189,9 +189,10 @@ associationOptions ; associationOption - : TYPE COLON? (REFERENCE | REFERENCE_SET) - | OWNER COLON? (DEFAULT | BOTH) - | STORAGE COLON? (COLUMN | TABLE) + // A clause takes no colon (R3): `type Reference`. The colon is an alias. + : TYPE (COLON /* @alias MDL-DEPR064 */)? (REFERENCE | REFERENCE_SET) + | OWNER (COLON /* @alias MDL-DEPR064 */)? (DEFAULT | BOTH) + | STORAGE (COLON /* @alias MDL-DEPR064 */)? (COLUMN | TABLE) | DELETE_BEHAVIOR /* @alias MDL-DEPR022 */ deleteBehavior errorMessageClause? | onDeleteClause | COMMENT STRING_LITERAL @@ -268,15 +269,18 @@ alterEntityAction | docComment? ADD COLUMN ifNotExists? attributeDefinition | RENAME ATTRIBUTE attributeName TO attributeName | RENAME COLUMN attributeName TO attributeName - | MODIFY ATTRIBUTE attributeName COLON? dataType attributeConstraint* - | MODIFY COLUMN attributeName COLON? dataType attributeConstraint* + // An attribute definition is always `Name: Type` (R3). The colon is + // optional only so the old spelling keeps parsing: its ABSENCE is the alias. + | MODIFY ATTRIBUTE attributeName COLON? /* @alias MDL-DEPR065 */ dataType attributeConstraint* + | MODIFY COLUMN attributeName COLON? /* @alias MDL-DEPR065 */ dataType attributeConstraint* | DROP ATTRIBUTE ifExists? attributeName | DROP COLUMN ifExists? attributeName | DROP DEFAULT ON ATTRIBUTE attributeName // clear an attribute's default value | SET DOCUMENTATION STRING_LITERAL | SET COMMENT STRING_LITERAL | SET POSITION LPAREN NUMBER_LITERAL COMMA NUMBER_LITERAL RPAREN - | SET ALLOW_CREATE_CHANGE_LOCALLY EQUALS (TRUE | FALSE) + | SET LPAREN ALLOW_CREATE_CHANGE_LOCALLY COLON (TRUE | FALSE) RPAREN // set ( AllowCreateChangeLocally: true ) + | SET ALLOW_CREATE_CHANGE_LOCALLY EQUALS /* @alias MDL-DEPR063 */ (TRUE | FALSE) | ADD INDEX ifNotExists? indexDefinition | DROP INDEX ifExists? indexDefinition | DROP INDEX ifExists? IDENTIFIER diff --git a/mdl/grammar/domains/MDLService.g4 b/mdl/grammar/domains/MDLService.g4 index a44bb27775..1dc8826580 100644 --- a/mdl/grammar/domains/MDLService.g4 +++ b/mdl/grammar/domains/MDLService.g4 @@ -42,8 +42,9 @@ databaseQueryMapping ; createConfigurationStatement - : CONFIGURATION STRING_LITERAL - (settingsAssignment (COMMA settingsAssignment)*)? + : CONFIGURATION STRING_LITERAL settingsItemOptions? // configuration 'X' ( Key: value, … ) + | CONFIGURATION STRING_LITERAL + settingsAssignment (COMMA settingsAssignment)* // old spelling: Key = value, … (MDL-DEPR060) ; /** @@ -215,9 +216,15 @@ odataPropertyAssignment | identifierOrKeyword COLON expression ; +// ALTER … SET ( Key: value, … ): exactly create's property list (R3). +odataAlterPropertyList + : LPAREN odataPropertyAssignment (COMMA odataPropertyAssignment)* RPAREN + ; + +// The old spelling of the alter list: `set Key = value, …` (R3). odataAlterAssignment - : identifierOrKeyword EQUALS odataPropertyValue - | identifierOrKeyword EQUALS expression + : identifierOrKeyword EQUALS /* @alias MDL-DEPR061 */ odataPropertyValue + | identifierOrKeyword EQUALS /* @alias MDL-DEPR061 */ expression ; odataAuthenticationClause diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index f3c3ba2651..e2533004ba 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -11,14 +11,17 @@ options { tokenVocab = MDLLexer; } // ============================================================================= /** - * ALTER SETTINGS RUNTIME Key = Value, ...; (MODEL is a deprecated alias) - * ALTER SETTINGS CONFIGURATION 'name' Key = Value, ...; + * ALTER SETTINGS RUNTIME ( Key: Value, ... ); (MODEL is a deprecated alias) + * ALTER SETTINGS CONFIGURATION 'name' ( Key: Value, ... ); * ALTER SETTINGS CONSTANT 'name' VALUE 'value' [IN CONFIGURATION 'name']; - * ALTER SETTINGS LANGUAGE Key = Value, ...; + * ALTER SETTINGS LANGUAGE ( Key: Value, ... ); * ALTER SETTINGS LANGUAGE ADD [OR MODIFY] 'ar_SD' [(Key: Value, ...)]; * ALTER SETTINGS LANGUAGE MODIFY 'ar_SD' (Key: Value, ...); * ALTER SETTINGS LANGUAGE REMOVE 'ar_SD'; - * ALTER SETTINGS WORKFLOWS Key = Value, ...; + * ALTER SETTINGS WORKFLOWS ( Key: Value, ... ); + * + * `Key = Value, …` without the parentheses is the old spelling of the property + * list (MDL-DEPR060, R3). * ALTER SETTINGS WORKFLOWS ADD [OR MODIFY] GROUP 'Approvers' [(Description: '...')]; * ALTER SETTINGS WORKFLOWS MODIFY GROUP 'Approvers' (Description: '...'); * ALTER SETTINGS WORKFLOWS REMOVE GROUP 'Approvers'; @@ -45,10 +48,12 @@ alterSettingsClause | settingsSection ADD STRING_LITERAL settingsItemOptions? | settingsSection MODIFY STRING_LITERAL settingsItemOptions | settingsSection REMOVE STRING_LITERAL - | settingsSection settingsAssignment (COMMA settingsAssignment)* + | settingsSection settingsItemOptions // runtime ( Key: value, … ) + | settingsSection settingsAssignment (COMMA settingsAssignment)* // old spelling (MDL-DEPR060) | CONSTANT STRING_LITERAL (VALUE settingsValue | DROP) (IN CONFIGURATION STRING_LITERAL)? | DROP CONSTANT STRING_LITERAL (IN CONFIGURATION STRING_LITERAL)? - | CONFIGURATION STRING_LITERAL settingsAssignment (COMMA settingsAssignment)* + | CONFIGURATION STRING_LITERAL settingsItemOptions // configuration 'X' ( Key: value, … ) + | CONFIGURATION STRING_LITERAL settingsAssignment (COMMA settingsAssignment)* // old spelling (MDL-DEPR060) ; // RUNTIME is Studio Pro's tab for Settings$ModelSettings (R10); MODEL, the @@ -60,8 +65,11 @@ settingsSection | WORKFLOWS ; +// The old spelling of a settings property: `Key = value`, outside a list. R3 +// (ako/mxcli#751): `:` sets a model property, in the ( Key: value, … ) list +// every other statement uses. settingsAssignment - : IDENTIFIER EQUALS settingsValue + : IDENTIFIER EQUALS /* @alias MDL-DEPR060 */ settingsValue ; // The optional properties of an added language or workflow group, in the diff --git a/mdl/upgrade/document_names_test.go b/mdl/upgrade/document_names_test.go index 7079f4f1b1..5c7998a732 100644 --- a/mdl/upgrade/document_names_test.go +++ b/mdl/upgrade/document_names_test.go @@ -16,14 +16,14 @@ func TestUpgrade_DocumentTypeNames(t *testing.T) { "grant access on odata service M.Api to M.User;\n" + "create or modify queue M.Jobs (Parallelism: 2);\nlist queues;\n" + "alter project security demo users off;\n" + - "alter settings model BcryptCost = 11;\n" + + "alter settings model ( BcryptCost: 11 );\n" + "create microflow M.F () begin call microflow M.G() in queue M.Jobs; end;\n" want := "CREATE CONSUMED REST SERVICE M.Api (BaseUrl: 'https://x', Authentication: none) { };\n" + "list consumed odata services;\nlist published odata services in M;\n" + "grant access on published odata service M.Api to M.User;\n" + "create or modify task queue M.Jobs (Parallelism: 2);\nlist task queues;\n" + "alter app security demo users off;\n" + - "alter settings runtime BcryptCost = 11;\n" + + "alter settings runtime ( BcryptCost: 11 );\n" + // `in queue` is a call option, not the document type name. "create microflow M.F () begin call microflow M.G() in queue M.Jobs; end;\n" res := mustUpgrade(t, src, Options{}) diff --git a/mdl/upgrade/r3_property_lists_test.go b/mdl/upgrade/r3_property_lists_test.go new file mode 100644 index 0000000000..cf7e655b53 --- /dev/null +++ b/mdl/upgrade/r3_property_lists_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "testing" +) + +// R3 (ako/mxcli#751): every old property-list spelling upgrades to exactly +// create's `( Key: value, … )` list, and the colons to where R3 puts them. The +// visitor test (mdl/visitor/r3_property_lists_test.go) proves each pair builds +// the same statements; this one proves the rewrite produces that pair, in the +// layout it was written in. +func TestUpgrade_R3PropertyLists(t *testing.T) { + cases := []struct{ old, want string }{ + {"alter settings runtime AfterStartupMicroflow = 'M.Startup', BcryptCost = 11;\n", + "alter settings runtime ( AfterStartupMicroflow: 'M.Startup', BcryptCost: 11 );\n"}, + // describe's old layout: one property per line. + {"alter settings runtime\n HashAlgorithm = 'BCrypt',\n BcryptCost = 11;\n", + "alter settings runtime (\n HashAlgorithm: 'BCrypt',\n BcryptCost: 11\n);\n"}, + {"ALTER SETTINGS MODEL BcryptCost = 11;\n", "ALTER SETTINGS RUNTIME ( BcryptCost: 11 );\n"}, + // Indented: the closing parenthesis lines up with the statement. + {" alter settings configuration 'Default'\n HttpPortNumber = 8080;\n", + " alter settings configuration 'Default' (\n HttpPortNumber: 8080\n );\n"}, + {"alter settings LANGUAGE\n DefaultLanguageCode = 'en_US';\n", + "alter settings LANGUAGE (\n DefaultLanguageCode: 'en_US'\n);\n"}, + {"alter settings configuration 'Default' HttpPortNumber = 8080;\n", + "alter settings configuration 'Default' ( HttpPortNumber: 8080 );\n"}, + {"create or modify configuration 'Acc'\n DatabaseType = 'HSQLDB',\n HttpPortNumber = 8081;\n", + "create or modify configuration 'Acc' (\n DatabaseType: 'HSQLDB',\n HttpPortNumber: 8081\n);\n"}, + {"alter odata client M.Crm set Version = '2.0', HttpUsername = 'Bearer ' + @M.Token;\n", + "alter consumed odata service M.Crm set ( Version: '2.0', HttpUsername: 'Bearer ' + @M.Token );\n"}, + {"ALTER PUBLISHED ODATA SERVICE M.Api SET Version='2.0.0';\n", + "ALTER PUBLISHED ODATA SERVICE M.Api SET ( Version:'2.0.0' );\n"}, + {"alter styling on page M.P widget ctn1 set Class = 'card', 'Full width' = on;\n", + "alter styling on page M.P widget ctn1 set ( Class: 'card', 'Full width': on );\n"}, + {"alter styling on page M.P widget ctn1 set ( Class = 'card' ) clear design properties;\n", + "alter styling on page M.P widget ctn1 set ( Class: 'card' ) clear design properties;\n"}, + {"alter entity M.Remote set allow_create_change_locally = true;\n", + "alter entity M.Remote set ( AllowCreateChangeLocally: true );\n"}, + {"alter entity M.E modify attribute Code String(20), modify column Amount Decimal;\n", + "alter entity M.E modify attribute Code: String(20), modify column Amount: Decimal;\n"}, + {"create association M.A_B from M.A to M.B type: Reference owner:Both storage :Table;\n", + "create association M.A_B from M.A to M.B type Reference owner Both storage Table;\n"}, + {"alter page M.P {\n set Caption = 'Save' on btnSave;\n set (Caption = 'x', ButtonStyle = Success) on b2;\n set Title: 'T';\n drop widget a, b;\n};\n", + "alter page M.P {\n set (Caption: 'Save') on btnSave;\n set (Caption: 'x', ButtonStyle: Success) on b2;\n set (Title: 'T');\n drop a, b;\n};\n"}, + // A nested respelling inside the value is rewritten with the list. + {"alter page M.P { set Action = show_page M.Q(Item: $currentObject) on b; };\n", + "alter page M.P { set (Action: show page M.Q(Item = $currentObject)) on b; };\n"}, + } + for _, c := range cases { + res := mustUpgrade(t, c.old, Options{}) + if res.Source != c.want { + t.Errorf("upgrade of\n%s got:\n%s want:\n%s", c.old, res.Source, c.want) + } + if again := mustUpgrade(t, res.Source, Options{}); again.Changed() { + t.Errorf("upgrade is not idempotent on\n%s", res.Source) + } + } +} diff --git a/mdl/visitor/r3_property_lists_test.go b/mdl/visitor/r3_property_lists_test.go new file mode 100644 index 0000000000..3696dcc8e0 --- /dev/null +++ b/mdl/visitor/r3_property_lists_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// r3Pairs are R3's old spellings and their canonical forms (ako/mxcli#751): an +// `alter` sets properties in create's `( Key: value, … )` list, a clause takes +// no colon and an attribute definition always has one. Both spellings must +// parse and build the same statements; only the old one may record the code, +// and it must carry the rewrite `fmt --upgrade` applies. +var r3Pairs = []r8Pair{ + // alter settings, every section, and configurations. + {"settings runtime", "alter settings runtime AfterStartupMicroflow = 'M.Startup', BcryptCost = 11, UseOQLVersion2 = true;", + "alter settings runtime ( AfterStartupMicroflow: 'M.Startup', BcryptCost: 11, UseOQLVersion2: true );", deprecation.SettingsAssignment}, + {"settings runtime qualified name", "alter settings runtime AfterStartupMicroflow = M.Startup;", + "alter settings runtime ( AfterStartupMicroflow: M.Startup );", deprecation.SettingsAssignment}, + {"settings language", "alter settings language DefaultLanguageCode = 'en_US';", + "alter settings language ( DefaultLanguageCode: 'en_US' );", deprecation.SettingsAssignment}, + {"settings workflows", "alter settings workflows UserEntity = 'System.User', DefaultTaskParallelism = 3;", + "alter settings workflows ( UserEntity: 'System.User', DefaultTaskParallelism: 3 );", deprecation.SettingsAssignment}, + {"settings configuration", "alter settings configuration 'Default' DatabaseType = 'POSTGRESQL', HttpPortNumber = 8080;", + "alter settings configuration 'Default' ( DatabaseType: 'POSTGRESQL', HttpPortNumber: 8080 );", deprecation.SettingsAssignment}, + {"create configuration", "create or modify configuration 'Acc' DatabaseType = 'HSQLDB', HttpPortNumber = 8081;", + "create or modify configuration 'Acc' ( DatabaseType: 'HSQLDB', HttpPortNumber: 8081 );", deprecation.SettingsAssignment}, + + // alter odata service / client. + {"odata client", "alter consumed odata service M.Crm set Version = '2.0', MetadataUrl = 'https://x.org/$metadata';", + "alter consumed odata service M.Crm set ( Version: '2.0', MetadataUrl: 'https://x.org/$metadata' );", deprecation.ODataAlterAssignment}, + {"odata client expression", "alter consumed odata service M.Crm set HttpUsername = 'Bearer ' + @M.Token;", + "alter consumed odata service M.Crm set ( HttpUsername: 'Bearer ' + @M.Token );", deprecation.ODataAlterAssignment}, + {"odata service", "alter published odata service M.Api set Version = '2.0.0', Summary = 'Orders';", + "alter published odata service M.Api set ( Version: '2.0.0', Summary: 'Orders' );", deprecation.ODataAlterAssignment}, + + // alter styling. + {"styling equals", "alter styling on page M.P widget ctn1 set Class = 'card', Style = 'margin: 0;', 'Spacing top' = 'Large', 'Full width' = on;", + "alter styling on page M.P widget ctn1 set ( Class: 'card', Style: 'margin: 0;', 'Spacing top': 'Large', 'Full width': on );", deprecation.StylingAssignment}, + {"styling colon unparenthesised", "alter styling on snippet M.S widget ctn1 set 'Full width': off;", + "alter styling on snippet M.S widget ctn1 set ( 'Full width': off );", deprecation.StylingAssignment}, + {"styling parenthesised equals", "alter styling on page M.P widget ctn1 set ( Class = 'card' ) clear design properties;", + "alter styling on page M.P widget ctn1 set ( Class: 'card' ) clear design properties;", deprecation.StylingAssignment}, + + // alter entity. + {"allow create change locally", "alter entity M.Remote set allow_create_change_locally = false;", + "alter entity M.Remote set ( AllowCreateChangeLocally: false );", deprecation.AllowCreateChangeLocally}, + {"allow create change locally camel", "alter entity M.Remote set AllowCreateChangeLocally = true;", + "alter entity M.Remote set ( AllowCreateChangeLocally: true );", deprecation.AllowCreateChangeLocally}, + {"modify attribute", "alter entity M.E modify attribute Code String(20) default 'x';", + "alter entity M.E modify attribute Code: String(20) default 'x';", deprecation.ModifyAttributeColon}, + {"modify column", "alter entity M.E modify column Amount Decimal;", + "alter entity M.E modify column Amount: Decimal;", deprecation.ModifyAttributeColon}, + + // association clauses. + {"association type", "create association M.A_B from M.A to M.B type: Reference owner: Both storage: Table;", + "create association M.A_B from M.A to M.B type Reference owner Both storage Table;", deprecation.AssociationClauseColon}, + + // alter page / snippet / layout (MDL-DEPR101..103, folded into the registry). + {"page set equals", "alter page M.P { set Caption = 'Save' on btnSave; set (Caption = 'x', ButtonStyle = Success) on b2; };", + "alter page M.P { set (Caption: 'Save') on btnSave; set (Caption: 'x', ButtonStyle: Success) on b2; };", deprecation.AlterPageSetEquals}, + {"page set unparenthesised", "alter snippet M.S { set Title: 'T'; };", + "alter snippet M.S { set (Title: 'T'); };", deprecation.AlterPageSetUnparenthesised}, + {"page drop widget", "alter layout M.L { drop widget a, b; };", + "alter layout M.L { drop a, b; };", deprecation.AlterPageDropWidget}, +} + +func TestR3OldSpellingsAreAliases(t *testing.T) { + for _, p := range r3Pairs { + t.Run(p.name, func(t *testing.T) { + old := mustBuild(t, p.old) + canon := mustBuild(t, p.canon) + if got := deprecationCodes(canon); len(got) != 0 { + t.Errorf("canonical %q recorded %v, want none", p.canon, got) + } + got := deprecationCodes(old) + if len(got) == 0 { + t.Fatalf("old %q recorded nothing, want %s", p.old, p.code) + } + for _, d := range old.Deprecations { + if d.Code != p.code { + t.Errorf("old %q recorded %v, want only %s", p.old, got, p.code) + } + if d.Fix == nil { + t.Errorf("old %q: %s recorded without a rewrite (%s)", p.old, d.Code, d.NoFix) + } + } + if len(old.Statements) == 0 { + t.Fatalf("old %q built no statement", p.old) + } + if !reflect.DeepEqual(old.Statements, canon.Statements) { + t.Errorf("old and canonical build different statements:\n old: %#v\n canon: %#v", old.Statements, canon.Statements) + } + }) + } +} + +// The canonical list is read, not merely parsed: each form must build the +// values the executor acts on. Guards against a list alternative the visitor +// never looks at, which would pass the pair test above only if both halves +// built nothing — so the values are pinned here. +func TestR3CanonicalListsCarryTheirValues(t *testing.T) { + settings := mustBuild(t, "alter settings runtime ( BcryptCost: 11, UseOQLVersion2: true );").Statements[0].(*ast.AlterSettingsStmt) + if settings.Properties["BcryptCost"] != int64(11) || settings.Properties["UseOQLVersion2"] != true { + t.Errorf("settings properties: %#v", settings.Properties) + } + cfg := mustBuild(t, "create configuration 'Acc' ( HttpPortNumber: 8081 );").Statements[0].(*ast.CreateConfigurationStmt) + if cfg.Properties["HttpPortNumber"] != "8081" { + t.Errorf("configuration properties: %#v", cfg.Properties) + } + client := mustBuild(t, "alter consumed odata service M.Crm set ( Version: '2.0', HttpUsername: 'Bearer ' + @M.Token );").Statements[0].(*ast.AlterODataClientStmt) + if client.Changes["Version"] != "2.0" || client.Changes["HttpUsername"] != "'Bearer ' + @M.Token" { + t.Errorf("odata client changes: %#v", client.Changes) + } + styling := mustBuild(t, "alter styling on page M.P widget c set ( Class: 'card', 'Full width': on );").Statements[0].(*ast.AlterStylingStmt) + if len(styling.Assignments) != 2 || styling.Assignments[0].Value != "card" || !styling.Assignments[1].ToggleOn { + t.Errorf("styling assignments: %#v", styling.Assignments) + } + entity := mustBuild(t, "alter entity M.Remote set ( AllowCreateChangeLocally: true );").Statements[0].(*ast.AlterEntityStmt) + if entity.Operation != ast.AlterEntitySetAllowCreateChangeLocally || !entity.BoolValue { + t.Errorf("alter entity: %#v", entity) + } +} + +// An expression is refused on an OData property that takes a plain value, in +// the canonical list as in the old spelling. +func TestR3ODataListRefusesExpressionOnPlainProperty(t *testing.T) { + for _, src := range []string{ + "alter published odata service M.Api set ( Version: '1' + '2' );", + "alter consumed odata service M.Crm set ( Version: '1' + '2' );", + } { + if _, errs := Build(src); len(errs) == 0 { + t.Errorf("%q: want an error for an expression on Version", src) + } + } +} diff --git a/mdl/visitor/r8_spellings_test.go b/mdl/visitor/r8_spellings_test.go index 612bb81d2e..a2060ab230 100644 --- a/mdl/visitor/r8_spellings_test.go +++ b/mdl/visitor/r8_spellings_test.go @@ -38,8 +38,8 @@ var r8Pairs = []r8Pair{ {"complete task", pageWith("complete_task 'Approve'"), pageWith("complete task 'Approve'"), deprecation.PageActionWord}, {"menu sign out", "create navigation Responsive home page M.Home menu (menu item 'Out' sign_out;);", "create navigation Responsive home page M.Home menu (menu item 'Out' sign out;);", deprecation.PageActionWord}, - {"alter page set action", "alter page M.P { set Action = show_page M.Q on b };", - "alter page M.P { set Action = show page M.Q on b };", deprecation.PageActionWord}, + {"alter page set action", "alter page M.P { set (Action: show_page M.Q) on b };", + "alter page M.P { set (Action: show page M.Q) on b };", deprecation.PageActionWord}, {"not null error", "create entity M.E (Name: String(100) not null error 'Required');", "create entity M.E (Name: String(100) not null error message 'Required');", deprecation.ErrorMessageKeyword}, diff --git a/mdl/visitor/visitor_alter.go b/mdl/visitor/visitor_alter.go index 5c54bc6bba..8c56e611f1 100644 --- a/mdl/visitor/visitor_alter.go +++ b/mdl/visitor/visitor_alter.go @@ -68,19 +68,29 @@ func (b *Builder) ExitAlterStatement(ctx *parser.AlterStatementContext) { } changes := make(map[string]any) - for _, propCtx := range ctx.AllOdataAlterAssignment() { - prop := propCtx.(*parser.OdataAlterAssignmentContext) - name := identifierOrKeywordText(prop.IdentifierOrKeyword()) + // One property, in either spelling: `set ( Key: value, … )` or the old + // `set Key = value, …` (MDL-DEPR061). Both build the same statement. + set := func(name string, value parser.IOdataPropertyValueContext, expr parser.IExpressionContext) { if ctx.ConsumedODataServiceKw() != nil && isODataClientExpressionProp(name) { // Expression-typed: the expression as written (see visitor_odata_expression.go). - changes[name], _ = odataExpressionValue(prop.OdataPropertyValue(), prop.Expression()) - continue + changes[name], _ = odataExpressionValue(value, expr) + return } - val := prop.OdataPropertyValue() - if val != nil { - changes[name] = odataValueText(val.(*parser.OdataPropertyValueContext)) + if value != nil { + changes[name] = odataValueText(value.(*parser.OdataPropertyValueContext)) + } + } + if pl, ok := ctx.OdataAlterPropertyList().(*parser.OdataAlterPropertyListContext); ok && pl != nil { + for _, propCtx := range pl.AllOdataPropertyAssignment() { + prop := propCtx.(*parser.OdataPropertyAssignmentContext) + set(identifierOrKeywordText(prop.IdentifierOrKeyword()), prop.OdataPropertyValue(), prop.Expression()) } } + for _, propCtx := range ctx.AllOdataAlterAssignment() { + prop := propCtx.(*parser.OdataAlterAssignmentContext) + set(identifierOrKeywordText(prop.IdentifierOrKeyword()), prop.OdataPropertyValue(), prop.Expression()) + } + b.recordODataAlterAssignments(ctx) if ctx.ConsumedODataServiceKw() != nil { b.statements = append(b.statements, &ast.AlterODataClientStmt{ diff --git a/mdl/visitor/visitor_alter_alias_equivalence_test.go b/mdl/visitor/visitor_alter_alias_equivalence_test.go index d48d43d73b..1908417a4c 100644 --- a/mdl/visitor/visitor_alter_alias_equivalence_test.go +++ b/mdl/visitor/visitor_alter_alias_equivalence_test.go @@ -5,13 +5,11 @@ package visitor import ( "reflect" "testing" - - "github.com/mendixlabs/mxcli/mdl/ast" ) // ADR-0011: an alias parses to the IDENTICAL operation as its canonical form — -// the only difference allowed is the Legacy marker that drives the -// deprecation warning. Without this, an alias could silently build a different +// the only difference allowed is the deprecation the visitor records for the +// old spelling. Without this, an alias could silently build a different // change (a value parsed through another rule, a target dropped) and the // warning would tell the user the two are interchangeable when they are not. // `fmt --upgrade` relies on the same equivalence (ADR-0011, Negative). @@ -22,8 +20,8 @@ func TestGenericAlter_AliasesBuildTheIdenticalOperation(t *testing.T) { {"set unparenthesised colon", `set Caption: 'Save' on btnSave`, `set (Caption: 'Save') on btnSave`}, {"page-level set", `set Title = 'Edit'`, `set (Title: 'Edit')`}, {"quoted key", `set 'showLabel' = false on w1`, `set ('showLabel': false) on w1`}, - {"action", `set Action = microflow M.ACT on btnGo`, `set (Action: microflow M.ACT) on btnGo`}, - {"named action slot", `set 'createFileAction' = microflow M.F on up1`, `set ('createFileAction': microflow M.F) on up1`}, + {"action", `set Action = call microflow M.ACT on btnGo`, `set (Action: call microflow M.ACT) on btnGo`}, + {"named action slot", `set 'createFileAction' = call microflow M.F on up1`, `set ('createFileAction': call microflow M.F) on up1`}, {"datasource", `set DataSource = $Param on dv1`, `set (DataSource: $Param) on dv1`}, {"visible", `set Visible = [Name != ''] on txt1`, `set (Visible: [Name != '']) on txt1`}, {"expression", `set DynamicClasses = if $x/F then 'a' else '' on c1`, `set (DynamicClasses: if $x/F then 'a' else '') on c1`}, @@ -32,40 +30,22 @@ func TestGenericAlter_AliasesBuildTheIdenticalOperation(t *testing.T) { } for _, p := range pairs { t.Run(p.name, func(t *testing.T) { + oldProg := mustBuild(t, "alter page M.P { "+p.old+"; };") + canonProg := mustBuild(t, "alter page M.P { "+p.canonical+"; };") old := buildAlterPage(t, "alter page M.P { "+p.old+"; };").Operations canon := buildAlterPage(t, "alter page M.P { "+p.canonical+"; };").Operations if len(old) != 1 || len(canon) != 1 { t.Fatalf("want one operation each, got %d and %d", len(old), len(canon)) } - if legacyOf(old[0]) == "" { - t.Fatalf("old spelling %q not flagged as an alias", p.old) + if len(oldProg.Deprecations) != 1 { + t.Fatalf("old spelling %q recorded %v, want one deprecation", p.old, deprecationCodes(oldProg)) } - if legacyOf(canon[0]) != "" { - t.Fatalf("canonical spelling %q flagged as alias %q", p.canonical, legacyOf(canon[0])) + if got := deprecationCodes(canonProg); len(got) != 0 { + t.Fatalf("canonical spelling %q recorded %v", p.canonical, got) } - clearLegacy(old[0]) if !reflect.DeepEqual(old[0], canon[0]) { t.Errorf("alias builds a different operation:\n old: %#v\n canonical: %#v", old[0], canon[0]) } }) } } - -func legacyOf(op ast.AlterPageOperation) string { - switch o := op.(type) { - case *ast.SetPropertyOp: - return o.Legacy - case *ast.DropWidgetOp: - return o.Legacy - } - return "" -} - -func clearLegacy(op ast.AlterPageOperation) { - switch o := op.(type) { - case *ast.SetPropertyOp: - o.Legacy = "" - case *ast.DropWidgetOp: - o.Legacy = "" - } -} diff --git a/mdl/visitor/visitor_alter_generic_test.go b/mdl/visitor/visitor_alter_generic_test.go index 81de337312..10cea2c380 100644 --- a/mdl/visitor/visitor_alter_generic_test.go +++ b/mdl/visitor/visitor_alter_generic_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" ) // The generic ALTER (ADR-0012 decision 2, ako/mxcli#712): one grammar rule @@ -31,10 +32,14 @@ func buildAlterPage(t *testing.T, input string) *ast.AlterPageStmt { } func TestGenericAlter_CanonicalSetIsParenthesisedAndColon(t *testing.T) { - stmt := buildAlterPage(t, `alter page Module.Page { + const src = `alter page Module.Page { set (Caption: 'Save', ButtonStyle: Success) on btnSave; set (Title: 'Edit order'); - };`) + };` + stmt := buildAlterPage(t, src) + if got := deprecationCodes(mustBuild(t, src)); len(got) != 0 { + t.Errorf("canonical set must not be reported as an alias, got %v", got) + } if len(stmt.Operations) != 2 { t.Fatalf("want 2 operations, got %d", len(stmt.Operations)) } @@ -45,22 +50,20 @@ func TestGenericAlter_CanonicalSetIsParenthesisedAndColon(t *testing.T) { if onWidget.Properties["Caption"] != "Save" || onWidget.Properties["ButtonStyle"] != "Success" { t.Errorf("properties: got %v", onWidget.Properties) } - if onWidget.Legacy != "" { - t.Errorf("canonical set must not be flagged as an alias, got %q", onWidget.Legacy) - } pageLevel := stmt.Operations[1].(*ast.SetPropertyOp) if pageLevel.Target.Widget != "" || pageLevel.Properties["Title"] != "Edit order" { t.Errorf("page-level set: target %q, properties %v", pageLevel.Target.Widget, pageLevel.Properties) } - if pageLevel.Legacy != "" { - t.Errorf("canonical page-level set flagged as alias: %q", pageLevel.Legacy) - } } func TestGenericAlter_CanonicalDropNamesTargetsWithoutKeyword(t *testing.T) { - stmt := buildAlterPage(t, `alter snippet Module.Snip { + const src = `alter snippet Module.Snip { drop txtOld, dgOrders.Total; - };`) + };` + stmt := buildAlterPage(t, src) + if got := deprecationCodes(mustBuild(t, src)); len(got) != 0 { + t.Errorf("canonical drop reported as an alias: %v", got) + } if stmt.ContainerType != "SNIPPET" { t.Errorf("container type: got %q", stmt.ContainerType) } @@ -69,18 +72,16 @@ func TestGenericAlter_CanonicalDropNamesTargetsWithoutKeyword(t *testing.T) { drop.Targets[1].Widget != "dgOrders" || drop.Targets[1].Column != "Total" { t.Errorf("targets: got %+v", drop.Targets) } - if drop.Legacy != "" { - t.Errorf("canonical drop flagged as alias: %q", drop.Legacy) - } } // A widget may be NAMED like a keyword the old forms use; the canonical drop // of it must still parse as a drop of that name. func TestGenericAlter_DropOfWidgetNamedLikeAKeyword(t *testing.T) { - stmt := buildAlterPage(t, `alter page Module.Page { drop widget; };`) + const src = `alter page Module.Page { drop widget; };` + stmt := buildAlterPage(t, src) drop := stmt.Operations[0].(*ast.DropWidgetOp) - if len(drop.Targets) != 1 || drop.Targets[0].Widget != "widget" || drop.Legacy != "" { - t.Errorf("got %+v legacy=%q", drop.Targets, drop.Legacy) + if got := deprecationCodes(mustBuild(t, src)); len(drop.Targets) != 1 || drop.Targets[0].Widget != "widget" || len(got) != 0 { + t.Errorf("got %+v, deprecations %v", drop.Targets, got) } } @@ -114,26 +115,17 @@ func TestGenericAlter_OldSpellingsAreFlaggedAliases(t *testing.T) { name, op string legacy string }{ - {"set without parentheses", `set Caption = 'Save' on btnSave`, ast.AlterAliasSetEquals}, - {"page-level set without parentheses", `set Title = 'Edit'`, ast.AlterAliasSetEquals}, - {"parenthesised set with =", `set (Caption = 'Save', ButtonStyle = Success) on btnSave`, ast.AlterAliasSetEquals}, - {"set without parentheses, with colon", `set Caption: 'Save' on btnSave`, ast.AlterAliasSetUnparenthesised}, - {"drop widget", `drop widget txtOld, txtUnused`, ast.AlterAliasDropWidget}, + {"set without parentheses", `set Caption = 'Save' on btnSave`, deprecation.AlterPageSetEquals}, + {"page-level set without parentheses", `set Title = 'Edit'`, deprecation.AlterPageSetEquals}, + {"parenthesised set with =", `set (Caption = 'Save', ButtonStyle = Success) on btnSave`, deprecation.AlterPageSetEquals}, + {"set without parentheses, with colon", `set Caption: 'Save' on btnSave`, deprecation.AlterPageSetUnparenthesised}, + {"drop widget", `drop widget txtOld, txtUnused`, deprecation.AlterPageDropWidget}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - stmt := buildAlterPage(t, "alter page Module.Page { "+c.op+"; };") - var got string - switch o := stmt.Operations[0].(type) { - case *ast.SetPropertyOp: - got = o.Legacy - case *ast.DropWidgetOp: - got = o.Legacy - default: - t.Fatalf("unexpected op %T", o) - } - if got != c.legacy { - t.Errorf("legacy spelling: got %q, want %q", got, c.legacy) + got := deprecationCodes(mustBuild(t, "alter page Module.Page { "+c.op+"; };")) + if len(got) != 1 || got[0] != c.legacy { + t.Errorf("recorded %v, want [%s]", got, c.legacy) } }) } diff --git a/mdl/visitor/visitor_alter_page.go b/mdl/visitor/visitor_alter_page.go index 326905bffa..063696b1ec 100644 --- a/mdl/visitor/visitor_alter_page.go +++ b/mdl/visitor/visitor_alter_page.go @@ -86,26 +86,14 @@ func (b *Builder) buildAlterSet(ctx *parser.AlterSetContext) ast.AlterPageOperat } } - usedEquals := false for _, assignCtx := range ctx.AllAlterPageAssignment() { assign := assignCtx.(*parser.AlterPageAssignmentContext) - if ao := assign.AlterAssignOp(); ao != nil && ao.(*parser.AlterAssignOpContext).EQUALS() != nil { - usedEquals = true - } name, value := b.buildAlterPageAssignment(assign) if name != "" { op.Properties[name] = value } } - - // Which alias, if any. `=` is reported first: its rewrite — the - // parenthesised, colon form — also fixes a missing parenthesis. - switch { - case usedEquals: - op.Legacy = ast.AlterAliasSetEquals - case ctx.LPAREN() == nil: - op.Legacy = ast.AlterAliasSetUnparenthesised - } + b.recordAlterPageSet(ctx) return op } @@ -225,9 +213,7 @@ func (b *Builder) buildAlterInsert(ctx *parser.AlterInsertContext) *ast.InsertWi // buildAlterDrop builds a DropWidgetOp from the parse tree. func (b *Builder) buildAlterDrop(ctx *parser.AlterDropContext) *ast.DropWidgetOp { op := &ast.DropWidgetOp{} - if ctx.WIDGET() != nil { - op.Legacy = ast.AlterAliasDropWidget - } + b.recordAlterPageDropWidget(ctx) for _, tr := range ctx.AllAlterTarget() { op.Targets = append(op.Targets, b.buildAlterTarget(tr)) } diff --git a/mdl/visitor/visitor_document_names_test.go b/mdl/visitor/visitor_document_names_test.go index 4b00ed67a6..46b26d3f34 100644 --- a/mdl/visitor/visitor_document_names_test.go +++ b/mdl/visitor/visitor_document_names_test.go @@ -31,7 +31,7 @@ func TestDocumentTypeNamesFollowStudioPro(t *testing.T) { {deprecation.ConsumedODataService, "create odata client M.Crm (ODataVersion: OData4, MetadataUrl: 'https://x/$metadata');", "create consumed odata service M.Crm (ODataVersion: OData4, MetadataUrl: 'https://x/$metadata');"}, - {deprecation.ConsumedODataService, "alter odata client M.Crm set Version = '2';", "alter consumed odata service M.Crm set Version = '2';"}, + {deprecation.ConsumedODataService, "alter odata client M.Crm set ( Version: '2' );", "alter consumed odata service M.Crm set ( Version: '2' );"}, {deprecation.ConsumedODataService, "drop odata client M.Crm;", "drop consumed odata service M.Crm;"}, {deprecation.ConsumedODataService, "describe odata client M.Crm;", "describe consumed odata service M.Crm;"}, {deprecation.ConsumedODataService, "list odata clients;", "list consumed odata services;"}, @@ -43,7 +43,7 @@ func TestDocumentTypeNamesFollowStudioPro(t *testing.T) { {deprecation.PublishedODataService, "create odata service M.Api (Path: 'odata/v1', Namespace: 'M');", "create published odata service M.Api (Path: 'odata/v1', Namespace: 'M');"}, - {deprecation.PublishedODataService, "alter odata service M.Api set Version = '2';", "alter published odata service M.Api set Version = '2';"}, + {deprecation.PublishedODataService, "alter odata service M.Api set ( Version: '2' );", "alter published odata service M.Api set ( Version: '2' );"}, {deprecation.PublishedODataService, "drop odata service M.Api;", "drop published odata service M.Api;"}, {deprecation.PublishedODataService, "describe odata service M.Api;", "describe published odata service M.Api;"}, {deprecation.PublishedODataService, "list odata services in M;", "list published odata services in M;"}, @@ -61,8 +61,8 @@ func TestDocumentTypeNamesFollowStudioPro(t *testing.T) { {deprecation.AppSecurity, "alter project security guest access on role Guest;", "alter app security guest access on role Guest;"}, {deprecation.AppSecurity, "alter project security strict mode on;", "alter app security strict mode on;"}, // settings runtime - {deprecation.SettingsRuntime, "alter settings model BcryptCost = 11, HashAlgorithm = 'BCrypt';", "alter settings runtime BcryptCost = 11, HashAlgorithm = 'BCrypt';"}, - {deprecation.SettingsRuntime, "ALTER SETTINGS MODEL BcryptCost = 11;", "ALTER SETTINGS RUNTIME BcryptCost = 11;"}, + {deprecation.SettingsRuntime, "alter settings model ( BcryptCost: 11, HashAlgorithm: 'BCrypt' );", "alter settings runtime ( BcryptCost: 11, HashAlgorithm: 'BCrypt' );"}, + {deprecation.SettingsRuntime, "ALTER SETTINGS MODEL (BcryptCost: 11);", "ALTER SETTINGS RUNTIME (BcryptCost: 11);"}, } for _, c := range cases { t.Run(c.old, func(t *testing.T) { @@ -94,7 +94,7 @@ func TestDocumentTypeNamesLeaveOtherUsesAlone(t *testing.T) { "drop published rest service M.Api;", "list consumed mcp services;", "show project security;", - "alter settings workflows UserEntity = 'System.User';", + "alter settings workflows ( UserEntity: 'System.User' );", } { t.Run(src, func(t *testing.T) { if got := deprecationCodes(mustBuild(t, src)); len(got) != 0 { diff --git a/mdl/visitor/visitor_entity.go b/mdl/visitor/visitor_entity.go index 1811689b7f..711ac31a6c 100644 --- a/mdl/visitor/visitor_entity.go +++ b/mdl/visitor/visitor_entity.go @@ -604,6 +604,9 @@ func parseAnnotationParamInt(ctx parser.IAnnotationParamContext) int { // ExitAlterEntityAction handles ALTER ENTITY ... ADD/DROP/RENAME/MODIFY ATTRIBUTE ... func (b *Builder) ExitAlterEntityAction(ctx *parser.AlterEntityActionContext) { + // R3 respellings (visitor_r3_property_lists.go). + b.recordAllowCreateChangeLocally(ctx) + b.recordModifyAttributeColon(ctx) // Walk up to the parent AlterStatement to get the entity's qualified name parent := ctx.GetParent() for parent != nil { diff --git a/mdl/visitor/visitor_odata_expression.go b/mdl/visitor/visitor_odata_expression.go index 1ab3f31044..7862d5ac87 100644 --- a/mdl/visitor/visitor_odata_expression.go +++ b/mdl/visitor/visitor_odata_expression.go @@ -66,6 +66,12 @@ func (b *Builder) ExitOdataPropertyAssignment(ctx *parser.OdataPropertyAssignmen if _, onClient := ctx.GetParent().(*parser.CreateODataClientStatementContext); onClient && isODataClientExpressionProp(name) { return } + // `alter consumed odata service X set ( Key: expr )` takes the same list. + if list, ok := ctx.GetParent().(*parser.OdataAlterPropertyListContext); ok && isODataClientExpressionProp(name) { + if alter, ok := list.GetParent().(*parser.AlterStatementContext); ok && alter.ConsumedODataServiceKw() != nil { + return + } + } b.addError(odataExpressionNotAllowed(name, ctx.Expression())) } diff --git a/mdl/visitor/visitor_r3_property_lists.go b/mdl/visitor/visitor_r3_property_lists.go new file mode 100644 index 0000000000..7087deca97 --- /dev/null +++ b/mdl/visitor/visitor_r3_property_lists.go @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "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" +) + +// R3 (PROPOSAL_mdl_beta_syntax_freeze.md §3, ako/mxcli#751): `:` sets a model +// property. An `alter` sets properties in exactly the `( Key: value, … )` list +// its `create` takes, so a fragment of describe output pastes into an alter +// unchanged; a clause outside a property list takes no colon, and an attribute +// definition always has one. Every old spelling here is a respelling — the +// grammar builds the same statement from both — so each use is recorded as a +// deprecation with the edits that rewrite it, and nothing is gated on the +// language version. + +// oldAssignment is one `Key = value` of an old-spelling list, by the rune +// offset where its key ends and the `=` token. +type oldAssignment struct { + keyStop int + op antlr.Token +} + +// assignmentAt reads an assignment whose key is child 0 of ctx and whose +// operator is the node op (a terminal or a one-token rule). +func assignmentAt(ctx antlr.ParserRuleContext, op antlr.Tree) (oldAssignment, bool) { + if ctx == nil || op == nil || ctx.GetChildCount() == 0 { + return oldAssignment{}, false + } + _, keyStop := nodeSpan(ctx.GetChild(0)) + var tok antlr.Token + switch o := op.(type) { + case antlr.TerminalNode: + tok = o.GetSymbol() + case antlr.ParserRuleContext: + tok = o.GetStart() + } + if keyStop < 0 || tok == nil { + return oldAssignment{}, false + } + return oldAssignment{keyStop: keyStop, op: tok}, true +} + +// colonEdits writes each assignment's `=` as `:`, directly after the key: +// `Caption = 'x'` becomes `Caption: 'x'`. +func colonEdits(as []oldAssignment) []ast.TextEdit { + out := make([]ast.TextEdit, 0, len(as)) + for _, a := range as { + out = append(out, ast.TextEdit{Start: a.keyStop + 1, Stop: a.op.GetStop() + 1, Text: ":"}) + } + return out +} + +// wrapEdits puts the list from first to last in parentheses. A list that +// starts on a new line after before (describe's layout) opens its parenthesis +// on before's line and closes it on a line of its own, indented as that line +// is; otherwise the parentheses hug the list, padded with a space when pad is +// set. +func wrapEdits(before, first, last antlr.Token, pad bool) []ast.TextEdit { + if before != nil && first != nil && before.GetInputStream() != nil { + is := before.GetInputStream() + gap := is.GetText(before.GetStop()+1, first.GetStart()-1) + if strings.Contains(gap, "\n") { + return []ast.TextEdit{ + insertAt(before.GetStop()+1, " ("), + insertAt(last.GetStop()+1, "\n"+lineIndent(is, before.GetStart())+")"), + } + } + } + open, closing := "(", ")" + if pad { + open, closing = "( ", " )" + } + return []ast.TextEdit{insertAt(first.GetStart(), open), insertAt(last.GetStop()+1, closing)} +} + +// recordOldList records code for an old-spelling assignment list written +// after before, with the rewrite to `( Key: value, … )`. +func (b *Builder) recordOldList(code string, before antlr.Token, first, last antlr.ParserRuleContext, as []oldAssignment, subject string) { + if first == nil || last == nil || first.GetStart() == nil || last.GetStop() == nil { + return + } + b.recordDeprecation(code, first.GetStart(), subject) + edits := append(colonEdits(as), wrapEdits(before, first.GetStart(), last.GetStop(), true)...) + b.fixLastDeprecation(code, &ast.Fix{Edits: edits}, "") +} + +// recordAlterPageSet reports the generic alter's old `set` spellings: +// `set Key = value` / `set (Key = value)` (MDL-DEPR101) and `set Key: value` +// (MDL-DEPR102). `=` is reported first: its rewrite, the parenthesised colon +// form, also adds a missing parenthesis. +func (b *Builder) recordAlterPageSet(ctx *parser.AlterSetContext) { + assigns := ctx.AllAlterPageAssignment() + if len(assigns) == 0 { + return + } + var as []oldAssignment + for _, a := range assigns { + ac := a.(*parser.AlterPageAssignmentContext) + if op, ok := ac.AlterAssignOp().(*parser.AlterAssignOpContext); ok && op != nil && op.EQUALS() != nil { + if oa, ok := assignmentAt(ac, op); ok { + as = append(as, oa) + } + } + } + var edits []ast.TextEdit + code := deprecation.AlterPageSetEquals + switch { + case len(as) > 0: + edits = colonEdits(as) + case ctx.LPAREN() == nil: + code = deprecation.AlterPageSetUnparenthesised + default: + return + } + if ctx.LPAREN() == nil { + first := assigns[0].(*parser.AlterPageAssignmentContext).GetStart() + last := assigns[len(assigns)-1].(*parser.AlterPageAssignmentContext).GetStop() + edits = append(edits, insertAt(first.GetStart(), "("), insertAt(last.GetStop()+1, ")")) + } + b.recordDeprecation(code, ctx.SET().GetSymbol(), "alter set") + b.fixLastDeprecation(code, &ast.Fix{Edits: edits}, "") +} + +// recordAlterPageDropWidget reports `drop widget a, b` (MDL-DEPR103). +func (b *Builder) recordAlterPageDropWidget(ctx *parser.AlterDropContext) { + w := ctx.WIDGET() + targets := ctx.AllAlterTarget() + if w == nil || len(targets) == 0 { + return + } + tok := w.GetSymbol() + b.recordDeprecation(deprecation.AlterPageDropWidget, tok, "drop widget") + edit := ast.TextEdit{Start: tok.GetStart(), Stop: targets[0].GetStart().GetStart(), Text: ""} + b.fixLastDeprecation(deprecation.AlterPageDropWidget, &ast.Fix{Edits: []ast.TextEdit{edit}}, "") +} + +// recordSettingsAssignments reports a settings list written `Key = value, …` +// (MDL-DEPR060), in `alter settings` and `create configuration`. +func (b *Builder) recordSettingsAssignments(assigns []parser.ISettingsAssignmentContext) { + if len(assigns) == 0 { + return + } + var as []oldAssignment + for _, a := range assigns { + ac := a.(*parser.SettingsAssignmentContext) + if oa, ok := assignmentAt(ac, ac.EQUALS()); ok { + as = append(as, oa) + } + } + first := assigns[0].(*parser.SettingsAssignmentContext) + b.recordOldList(deprecation.SettingsAssignment, tokenBefore(first), first, + assigns[len(assigns)-1].(*parser.SettingsAssignmentContext), as, "settings") +} + +// recordODataAlterAssignments reports `alter … odata service X set Key = +// value, …` (MDL-DEPR061). +func (b *Builder) recordODataAlterAssignments(ctx *parser.AlterStatementContext) { + assigns := ctx.AllOdataAlterAssignment() + if len(assigns) == 0 { + return + } + var as []oldAssignment + for _, a := range assigns { + ac := a.(*parser.OdataAlterAssignmentContext) + if oa, ok := assignmentAt(ac, ac.EQUALS()); ok { + as = append(as, oa) + } + } + b.recordOldList(deprecation.ODataAlterAssignment, ctx.SET().GetSymbol(), + assigns[0].(*parser.OdataAlterAssignmentContext), assigns[len(assigns)-1].(*parser.OdataAlterAssignmentContext), + as, "odata service") +} + +// ExitAlterStylingAction reports `alter styling … set Class = 'x', 'P' = on` +// (MDL-DEPR062): an `=`, or a list without its parentheses. +func (b *Builder) ExitAlterStylingAction(ctx *parser.AlterStylingActionContext) { + assigns := ctx.AllAlterStylingAssignment() + if ctx.SET() == nil || len(assigns) == 0 { + return + } + var as []oldAssignment + for _, a := range assigns { + ac := a.(*parser.AlterStylingAssignmentContext) + if op, ok := ac.AlterStylingAssignOp().(*parser.AlterStylingAssignOpContext); ok && op != nil && op.EQUALS() != nil { + if oa, ok := assignmentAt(ac, op); ok { + as = append(as, oa) + } + } + } + parenthesised := ctx.LPAREN() != nil + if len(as) == 0 && parenthesised { + return + } + edits := colonEdits(as) + first := assigns[0].(*parser.AlterStylingAssignmentContext) + last := assigns[len(assigns)-1].(*parser.AlterStylingAssignmentContext) + if !parenthesised { + edits = append(edits, wrapEdits(ctx.SET().GetSymbol(), first.GetStart(), last.GetStop(), true)...) + } + b.recordDeprecation(deprecation.StylingAssignment, ctx.SET().GetSymbol(), "alter styling") + b.fixLastDeprecation(deprecation.StylingAssignment, &ast.Fix{Edits: edits}, "") +} + +// recordAllowCreateChangeLocally reports `set allow_create_change_locally = +// v` (MDL-DEPR063), rewritten to `set ( AllowCreateChangeLocally: v )`. +func (b *Builder) recordAllowCreateChangeLocally(ctx *parser.AlterEntityActionContext) { + key, eq := ctx.ALLOW_CREATE_CHANGE_LOCALLY(), ctx.EQUALS() + if ctx.SET() == nil || key == nil || eq == nil { + return + } + var value antlr.TerminalNode = ctx.TRUE() + if value == nil { + value = ctx.FALSE() + } + if value == nil { + return + } + kt := key.GetSymbol() + b.recordDeprecation(deprecation.AllowCreateChangeLocally, kt, "alter entity") + b.fixLastDeprecation(deprecation.AllowCreateChangeLocally, &ast.Fix{Edits: []ast.TextEdit{ + {Start: kt.GetStart(), Stop: eq.GetSymbol().GetStop() + 1, Text: "( AllowCreateChangeLocally:"}, + insertAt(value.GetSymbol().GetStop()+1, " )"), + }}, "") +} + +// recordModifyAttributeColon reports `modify attribute A Type` (MDL-DEPR065), +// rewritten to `modify attribute A: Type`. +func (b *Builder) recordModifyAttributeColon(ctx *parser.AlterEntityActionContext) { + if ctx.MODIFY() == nil || ctx.COLON() != nil || ctx.DataType() == nil { + return + } + names := ctx.AllAttributeName() + if len(names) == 0 || names[0].GetStop() == nil { + return + } + stop := names[0].GetStop() + b.recordDeprecation(deprecation.ModifyAttributeColon, stop, "modify attribute") + b.fixLastDeprecation(deprecation.ModifyAttributeColon, + &ast.Fix{Edits: []ast.TextEdit{insertAt(stop.GetStop()+1, ":")}}, "") +} + +// recordAssociationClauseColon reports `type: Reference`, `owner: Both` and +// `storage: Table` (MDL-DEPR064): the colon goes, and the clause keeps one +// space before its value. +func (b *Builder) recordAssociationClauseColon(ctx *parser.AssociationOptionContext) { + colon := ctx.COLON() + if colon == nil || ctx.GetChildCount() < 3 { + return + } + _, kwStop := nodeSpan(ctx.GetChild(0)) + valueStart, _ := nodeSpan(ctx.GetChild(2)) + if kwStop < 0 || valueStart < 0 { + return + } + b.recordDeprecation(deprecation.AssociationClauseColon, colon.GetSymbol(), "association") + b.fixLastDeprecation(deprecation.AssociationClauseColon, + &ast.Fix{Edits: []ast.TextEdit{replaceGap(kwStop, valueStart, " ")}}, "") +} + +// lineIndent is the leading whitespace of the line holding rune offset pos. +func lineIndent(is antlr.CharStream, pos int) string { + if pos <= 0 { + return "" + } + head := is.GetText(0, pos-1) + line := head[strings.LastIndex(head, "\n")+1:] + return line[:len(line)-len(strings.TrimLeft(line, " \t"))] +} + +// tokenBefore is the token directly before ctx in the token stream (on the +// default channel), or nil. +func tokenBefore(ctx antlr.ParserRuleContext) antlr.Token { + p, ok := ctx.GetParent().(antlr.ParserRuleContext) + if !ok || p == nil { + return nil + } + var prev antlr.Token + for i := 0; i < p.GetChildCount(); i++ { + c := p.GetChild(i) + if c == antlr.Tree(ctx) { + return prev + } + switch x := c.(type) { + case antlr.TerminalNode: + prev = x.GetSymbol() + case antlr.ParserRuleContext: + if x.GetStop() != nil { + prev = x.GetStop() + } + } + } + return nil +} diff --git a/mdl/visitor/visitor_r8_spellings.go b/mdl/visitor/visitor_r8_spellings.go index 21b9b44ffb..bd7e1a0feb 100644 --- a/mdl/visitor/visitor_r8_spellings.go +++ b/mdl/visitor/visitor_r8_spellings.go @@ -165,6 +165,7 @@ func (b *Builder) recordDeleteBehavior(kw antlr.TerminalNode, behaviour parser.I // ExitAssociationOption reports `delete_behavior …` and `reference_set`. func (b *Builder) ExitAssociationOption(ctx *parser.AssociationOptionContext) { + b.recordAssociationClauseColon(ctx) // R3: `type: Reference` (visitor_r3_property_lists.go) b.recordDeleteBehavior(ctx.DELETE_BEHAVIOR(), ctx.DeleteBehavior()) if n := ctx.REFERENCE_SET(); n != nil && strings.Contains(n.GetText(), "_") { b.recordRespelling(deprecation.ReferenceSetUnderscore, n.GetSymbol(), "ReferenceSet") diff --git a/mdl/visitor/visitor_settings.go b/mdl/visitor/visitor_settings.go index 2d601cb6d3..02f0ffd67e 100644 --- a/mdl/visitor/visitor_settings.go +++ b/mdl/visitor/visitor_settings.go @@ -49,22 +49,9 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext if len(allStrings) > 0 { stmt.ConfigName = unquoteStringLit(allStrings[0]) } - for _, assignCtx := range ctx.AllSettingsAssignment() { - assign, ok := assignCtx.(*parser.SettingsAssignmentContext) - if !ok || assign == nil { - continue - } - if assign.IDENTIFIER() == nil || assign.SettingsValue() == nil { - continue - } - key := assign.IDENTIFIER().GetText() - svCtx, ok := assign.SettingsValue().(*parser.SettingsValueContext) - if !ok || svCtx == nil { - continue - } - val := settingsValueText(svCtx) - stmt.Properties[key] = val - } + eachSettingsProperty(ctx.SettingsItemOptions(), ctx.AllSettingsAssignment(), func(key string, sv *parser.SettingsValueContext) { + stmt.Properties[key] = settingsValueText(sv) + }) } else if ctx.SettingsSection() != nil && ctx.GROUP() != nil { // ALTER SETTINGS WORKFLOWS ADD [OR MODIFY] GROUP 'Approvers' [( Description: '…' )] // ALTER SETTINGS WORKFLOWS MODIFY GROUP 'Approvers' ( Description: '…' ) @@ -92,25 +79,13 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext } collectSettingsItemOptions(ctx.SettingsItemOptions(), stmt.Properties) } else if ctx.SettingsSection() != nil { - // ALTER SETTINGS MODEL|LANGUAGE|WORKFLOWS Key = Value, ... + // ALTER SETTINGS RUNTIME|LANGUAGE|WORKFLOWS ( Key: Value, ... ) stmt.Section = settingsSectionName(ctx.SettingsSection()) - for _, assignCtx := range ctx.AllSettingsAssignment() { - assign, ok := assignCtx.(*parser.SettingsAssignmentContext) - if !ok || assign == nil { - continue - } - if assign.IDENTIFIER() == nil || assign.SettingsValue() == nil { - continue - } - key := assign.IDENTIFIER().GetText() - svCtx, ok := assign.SettingsValue().(*parser.SettingsValueContext) - if !ok || svCtx == nil { - continue - } - val := settingsValueToInterface(svCtx) - stmt.Properties[key] = val - } + eachSettingsProperty(ctx.SettingsItemOptions(), ctx.AllSettingsAssignment(), func(key string, sv *parser.SettingsValueContext) { + stmt.Properties[key] = settingsValueToInterface(sv) + }) } + b.recordSettingsAssignments(ctx.AllSettingsAssignment()) b.statements = append(b.statements, stmt) } @@ -132,23 +107,39 @@ func (b *Builder) ExitCreateConfigurationStatement(ctx *parser.CreateConfigurati } } - for _, assignCtx := range ctx.AllSettingsAssignment() { - assign, ok := assignCtx.(*parser.SettingsAssignmentContext) - if !ok || assign == nil { - continue + eachSettingsProperty(ctx.SettingsItemOptions(), ctx.AllSettingsAssignment(), func(key string, sv *parser.SettingsValueContext) { + stmt.Properties[key] = settingsValueText(sv) + }) + b.recordSettingsAssignments(ctx.AllSettingsAssignment()) + + b.statements = append(b.statements, stmt) +} + +// eachSettingsProperty calls f for every property of a settings list, in +// either spelling: the canonical ( Key: value, … ) list, or the old +// `Key = value, …` assignments (MDL-DEPR060). Both build the same statement. +func eachSettingsProperty(opts parser.ISettingsItemOptionsContext, assigns []parser.ISettingsAssignmentContext, + f func(key string, sv *parser.SettingsValueContext)) { + if oc, ok := opts.(*parser.SettingsItemOptionsContext); ok && oc != nil { + for _, o := range oc.AllSettingsItemOption() { + so, ok := o.(*parser.SettingsItemOptionContext) + if !ok || so == nil || so.IdentifierOrKeyword() == nil || so.SettingsValue() == nil { + continue + } + if sv, ok := so.SettingsValue().(*parser.SettingsValueContext); ok && sv != nil { + f(unquoteIdentifier(so.IdentifierOrKeyword().GetText()), sv) + } } - if assign.IDENTIFIER() == nil || assign.SettingsValue() == nil { + } + for _, a := range assigns { + assign, ok := a.(*parser.SettingsAssignmentContext) + if !ok || assign == nil || assign.IDENTIFIER() == nil || assign.SettingsValue() == nil { continue } - key := assign.IDENTIFIER().GetText() - svCtx, ok := assign.SettingsValue().(*parser.SettingsValueContext) - if !ok || svCtx == nil { - continue + if sv, ok := assign.SettingsValue().(*parser.SettingsValueContext); ok && sv != nil { + f(assign.IDENTIFIER().GetText(), sv) } - stmt.Properties[key] = settingsValueText(svCtx) } - - b.statements = append(b.statements, stmt) } // collectSettingsItemOptions reads a ( key: value, … ) option list — the shared From 85737c99d6b11b884494abe491dd4435db21bd72 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 05:48:41 +0000 Subject: [PATCH 05/35] R5: bare workflow and widget expressions, one constant reference, revoke mirrors grant (#753) The rest of R5 after #775, each old spelling a registered deprecated alias with an fmt --upgrade rewrite (codes 080-085): - workflow decision / wait for timer / timer boundary event / timer event sub-process / due date take a bare expression; the string form keeps its meaning (MDL-DEPR080). describe writes the bare form, forcing the name of a decision or wait for timer when the expression would read as the name. - page Visible: / Editable: take a bare client expression, stored as written, in create page and alter page set; [expr] is MDL-DEPR081 and its rewrite is the expression it stored. A constant condition ([true]/[false]) has no bare spelling and is not reported. - revoke rights|all on entity M.E from M.Role; the reversed form is MDL-DEPR082. - @Module.Const is the one constant reference: REST $Const (083), agent Key: M.C (084), alter settings constant 'M.C' (085). Co-Authored-By: Claude Opus 5.5 --- cmd/mxcli/runconstants.go | 2 +- mdl/ast/ast_agenteditor.go | 4 +- mdl/deprecation/deprecation.go | 111 +++++++++++++ mdl/executor/cmd_agenteditor_kbs.go | 2 +- mdl/executor/cmd_agenteditor_mock_test.go | 3 +- mdl/executor/cmd_agenteditor_models.go | 2 +- .../cmd_pages_builder_visible_when.go | 2 +- mdl/executor/cmd_pages_describe_output.go | 20 ++- mdl/executor/cmd_settings.go | 2 +- mdl/executor/cmd_workflows.go | 49 +++++- .../describe_widget_conditions_test.go | 40 +++++ .../describe_workflow_expressions_test.go | 97 ++++++++++++ mdl/executor/validate_settings_refs.go | 2 +- mdl/executor/validate_widget_editability.go | 2 +- mdl/executor/validate_workflow_esp.go | 2 +- mdl/executor/validate_workflow_rewrite.go | 4 +- mdl/executor/workflow_esp_test.go | 2 +- mdl/grammar/MDLParser.g4 | 9 +- mdl/grammar/domains/MDLAgent.g4 | 10 +- mdl/grammar/domains/MDLPage.g4 | 12 +- mdl/grammar/domains/MDLSecurity.g4 | 6 +- mdl/grammar/domains/MDLService.g4 | 4 +- mdl/grammar/domains/MDLSettings.g4 | 13 +- mdl/grammar/domains/MDLWorkflow.g4 | 37 +++-- mdl/upgrade/constant_refs_test.go | 46 ++++++ mdl/upgrade/revoke_entity_test.go | 26 +++ mdl/upgrade/widget_conditions_test.go | 58 +++++++ mdl/upgrade/workflow_expressions_test.go | 72 +++++++++ mdl/visitor/visitor_alter_page.go | 12 ++ mdl/visitor/visitor_constant_refs.go | 108 +++++++++++++ mdl/visitor/visitor_constant_refs_test.go | 68 ++++++++ mdl/visitor/visitor_deprecations_test.go | 4 +- mdl/visitor/visitor_page_v3.go | 8 +- mdl/visitor/visitor_rest.go | 6 +- mdl/visitor/visitor_revoke_entity_test.go | 55 +++++++ mdl/visitor/visitor_security.go | 9 +- mdl/visitor/visitor_settings.go | 35 ++--- mdl/visitor/visitor_strict_properties.go | 6 +- mdl/visitor/visitor_widget_conditions.go | 94 +++++++++++ mdl/visitor/visitor_widget_conditions_test.go | 114 ++++++++++++++ mdl/visitor/visitor_workflow.go | 85 +++------- mdl/visitor/visitor_workflow_expressions.go | 148 ++++++++++++++++++ .../visitor_workflow_expressions_test.go | 124 +++++++++++++++ mdl/visitor/visitor_xpath_brackets.go | 31 ++++ 44 files changed, 1407 insertions(+), 139 deletions(-) create mode 100644 mdl/executor/describe_widget_conditions_test.go create mode 100644 mdl/executor/describe_workflow_expressions_test.go create mode 100644 mdl/upgrade/constant_refs_test.go create mode 100644 mdl/upgrade/revoke_entity_test.go create mode 100644 mdl/upgrade/widget_conditions_test.go create mode 100644 mdl/upgrade/workflow_expressions_test.go create mode 100644 mdl/visitor/visitor_constant_refs.go create mode 100644 mdl/visitor/visitor_constant_refs_test.go create mode 100644 mdl/visitor/visitor_revoke_entity_test.go create mode 100644 mdl/visitor/visitor_widget_conditions.go create mode 100644 mdl/visitor/visitor_widget_conditions_test.go create mode 100644 mdl/visitor/visitor_workflow_expressions.go create mode 100644 mdl/visitor/visitor_workflow_expressions_test.go diff --git a/cmd/mxcli/runconstants.go b/cmd/mxcli/runconstants.go index 05649256c6..abb7e8f8b9 100644 --- a/cmd/mxcli/runconstants.go +++ b/cmd/mxcli/runconstants.go @@ -17,7 +17,7 @@ import ( // value, and that map is what the standalone runtime is handed as // MicroflowConstants — the configuration's overrides are not in it. So // -// alter settings constant 'Encryption.EncryptionKey' value '…' in configuration 'Default'; +// alter settings constant @Encryption.EncryptionKey value '…' in configuration 'Default'; // // executed, reported success, survived a round-trip through `describe settings`, // and then did nothing: the app ran with the constant's default. Measured in diff --git a/mdl/ast/ast_agenteditor.go b/mdl/ast/ast_agenteditor.go index 9fe3bc84c0..46b6060adc 100644 --- a/mdl/ast/ast_agenteditor.go +++ b/mdl/ast/ast_agenteditor.go @@ -6,7 +6,7 @@ package ast // // CREATE MODEL Module.Name ( // Provider: MxCloudGenAI, -// Key: Module.SomeConstant +// Key: @Module.SomeConstant // -- optional Portal-populated fields: // [, DisplayName: '...'] // [, KeyName: '...'] @@ -92,7 +92,7 @@ func (s *AlterConsumedMCPServiceStmt) isStatement() {} // // CREATE KNOWLEDGE BASE Module.Name ( // Provider: MxCloudGenAI, -// Key: Module.SomeConstant +// Key: @Module.SomeConstant // ); type CreateKnowledgeBaseStmt struct { Folder string // Folder path within module (empty = leave placement alone) diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 36f7f253e2..1c0edd2486 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -157,6 +157,31 @@ const ( // on a text template: the placeholders bound by position (R4, // ako/mxcli#751). PositionalTemplateArguments = "MDL-DEPR009" + + // Codes 080–089 are the rest of R5 (ako/mxcli#753): expressions bare, one + // constant reference, and the revoke that mirrors the grant. + + // WorkflowStringExpression is a workflow decision's condition, a timer's + // delay or first execution time, or a due date written inside a string: + // `decision '$WorkflowContext/Total > 1000'`. + WorkflowStringExpression = "MDL-DEPR080" + // BracketedWidgetCondition is a page widget's conditional `Visible: [expr]` + // / `Editable: [expr]`: a client expression written in the brackets of an + // XPath constraint. + BracketedWidgetCondition = "MDL-DEPR081" + // ReversedEntityRevoke is `revoke M.Role on M.E [(rights)]`, the revoke + // with the role first — the mirror of MDL-DEPR030's grant. + ReversedEntityRevoke = "MDL-DEPR082" + // DollarConstant is a consumed REST service credential written `$Const`: + // a constant named like a variable, and without its module. + DollarConstant = "MDL-DEPR083" + // BareConstantKey is an agent-editor model's or knowledge base's `Key: + // Module.Const` (and `set Key = Module.Const`): a constant written as a + // plain document name. + BareConstantKey = "MDL-DEPR084" + // QuotedSettingsConstant is `alter settings [drop] constant 'Module.Const'`: + // a constant named in a string. + QuotedSettingsConstant = "MDL-DEPR085" ) // entries is the registry. Append only: a code is never reused or renumbered, @@ -374,6 +399,92 @@ var entries = []Entry{ func init() { entries = append(entries, r8Entries...) + entries = append(entries, r5Entries...) +} + +// r5Entries are the rest of R5's spellings (ako/mxcli#753), kept apart for the +// same reason as r8Entries. +var r5Entries = []Entry{ + { + Code: WorkflowStringExpression, + Old: "decision '' / timer '' / due date ''", + Canonical: "decision / timer / due date ", + Rewrite: Rewrite{Structural: "the expression out of its string: `decision '$Ctx/Total > 1000'` becomes `decision $Ctx/Total > 1000`"}, + RemovedIn: 2, + Note: "Expressions are bare everywhere (R5): a workflow decision, `wait for timer`, a timer boundary " + + "event, a timer event sub-process and a due date (workflow, user task, alter workflow). The string " + + "form keeps its meaning — its content is the expression — so it is an alias, not a change of " + + "meaning. A string whose content does not read back as the same bare expression is left in place " + + "and reported by fmt --upgrade.", + Example: "create workflow M.W parameter $WorkflowContext: M.E begin " + + "decision '$WorkflowContext/Total > 1000' outcomes true -> { } false -> { }; end workflow;", + CanonicalExample: "create workflow M.W parameter $WorkflowContext: M.E begin " + + "decision $WorkflowContext/Total > 1000 outcomes true -> { } false -> { }; end workflow;", + }, + { + Code: BracketedWidgetCondition, + Old: "Visible: [] / Editable: []", + Canonical: "Visible: / Editable: ", + Rewrite: Rewrite{Structural: "the brackets become the expression they store: `Visible: [Active]` becomes " + + "`Visible: $currentObject/Active`"}, + RemovedIn: 2, + Note: "A conditional visibility or editability is a client expression, not XPath, so it is written bare " + + "like every other expression (R5) and stored as written — name an attribute as " + + "`$currentObject/Attr`. Also in `alter page … set (Visible: …)`. The bracketed form rooted a bare " + + "attribute in $currentObject; the rewrite writes the expression it stored. One whose stored text " + + "would not read back as the same bare expression is left in place and reported by fmt --upgrade.", + Example: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { dataview dv (DataSource: $E) { " + + "textbox t (Attribute: Name, Visible: [Active and $currentObject/Name != empty]) } };", + CanonicalExample: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { dataview dv (DataSource: $E) { " + + "textbox t (Attribute: Name, Visible: $currentObject/Active and $currentObject/Name != empty) } };", + }, + { + Code: ReversedEntityRevoke, + Old: "revoke M.Role on M.E [(rights)]", + Canonical: "revoke rights|all on entity M.E from M.Role", + Rewrite: Rewrite{Structural: "rights (or `all` when none are listed) before `on entity`, roles after `from`: " + + "`revoke R on M.E (write *)` becomes `revoke write * on entity M.E from R`, `revoke R on M.E` becomes " + + "`revoke all on entity M.E from R`"}, + RemovedIn: 2, + Note: "The revoke mirrors the grant (MDL-DEPR030). `all` removes the roles' access rule; a rights list " + + "takes those rights away and keeps the rule.", + Example: "revoke M.User, M.Admin on M.Order (write *, delete);", + CanonicalExample: "revoke write *, delete on entity M.Order from M.User, M.Admin;", + }, + { + Code: DollarConstant, + Old: "Username: $Const", + Canonical: "Username: @Module.Const", + Rewrite: Rewrite{Structural: "`$Const` becomes `@.Const`"}, + RemovedIn: 2, + Note: "A constant is referred to one way everywhere: `@Module.Const` (R5). `$` names a variable, and " + + "`$Const` meant a constant of the consumed REST service's own module.", + Example: "create consumed rest service M.Api (BaseUrl: 'https://example.com', " + + "Authentication: basic (Username: $ApiUser, Password: @M.ApiPassword)) { };", + CanonicalExample: "create consumed rest service M.Api (BaseUrl: 'https://example.com', " + + "Authentication: basic (Username: @M.ApiUser, Password: @M.ApiPassword)) { };", + }, + { + Code: BareConstantKey, + Old: "Key: Module.Const", + Canonical: "Key: @Module.Const", + Rewrite: Rewrite{Structural: "`@` before the constant's name"}, + RemovedIn: 2, + Note: "A constant is referred to one way everywhere: `@Module.Const` (R5). Also in `alter model|knowledge base … set Key = …`.", + Example: "create model M.GPT (Provider: MxCloudGenAI, Key: M.ApiKey);", + CanonicalExample: "create model M.GPT (Provider: MxCloudGenAI, Key: @M.ApiKey);", + }, + { + Code: QuotedSettingsConstant, + Old: "alter settings constant 'Module.Const' …", + Canonical: "alter settings constant @Module.Const …", + Rewrite: Rewrite{Structural: "the constant's name out of its string, with `@`: `constant 'M.ApiUrl'` becomes `constant @M.ApiUrl`"}, + RemovedIn: 2, + Note: "A constant is referred to one way everywhere: `@Module.Const` (R5). Also in `alter settings drop " + + "constant`. A string that is not a qualified name is left in place and reported by fmt --upgrade.", + Example: "alter settings constant 'M.ApiUrl' value 'https://test.example.com' in configuration 'Default';", + CanonicalExample: "alter settings constant @M.ApiUrl value 'https://test.example.com' in configuration 'Default';", + }, } // r8Entries are R8's spellings (ako/mxcli#752). Kept apart from the list above diff --git a/mdl/executor/cmd_agenteditor_kbs.go b/mdl/executor/cmd_agenteditor_kbs.go index 26eeacd8f8..4705850f12 100644 --- a/mdl/executor/cmd_agenteditor_kbs.go +++ b/mdl/executor/cmd_agenteditor_kbs.go @@ -90,7 +90,7 @@ func describeAgentEditorKnowledgeBase(ctx *ExecContext, name ast.QualifiedName) lines = append(lines, fmt.Sprintf(" Provider: %s", k.Provider)) } if k.Key != nil && k.Key.QualifiedName != "" { - lines = append(lines, fmt.Sprintf(" Key: %s", k.Key.QualifiedName)) + lines = append(lines, fmt.Sprintf(" Key: @%s", k.Key.QualifiedName)) } if k.ModelDisplayName != "" { lines = append(lines, fmt.Sprintf(" ModelDisplayName: '%s'", escapeSQLString(k.ModelDisplayName))) diff --git a/mdl/executor/cmd_agenteditor_mock_test.go b/mdl/executor/cmd_agenteditor_mock_test.go index 0a273b8fe2..6985c6a1ed 100644 --- a/mdl/executor/cmd_agenteditor_mock_test.go +++ b/mdl/executor/cmd_agenteditor_mock_test.go @@ -347,7 +347,7 @@ func TestDescribeAgentEditorModel_Mock(t *testing.T) { assertContainsStr(t, out, "create or modify model") assertTerminated(t, out) // #744 assertContainsStr(t, out, "Provider") - assertContainsStr(t, out, "Key") + assertContainsStr(t, out, "Key: @M.APIKey") // R5: the one constant reference (ako/mxcli#753) } func TestShowAgentEditorAgents_Mock(t *testing.T) { @@ -470,6 +470,7 @@ func TestDescribeAgentEditorKnowledgeBase_Mock(t *testing.T) { assertContainsStr(t, out, "create or modify knowledge base") assertTerminated(t, out) // #744 assertContainsStr(t, out, "Provider") + assertContainsStr(t, out, "Key: @M.KBKey") // R5: the one constant reference (ako/mxcli#753) } func TestShowAgentEditorConsumedMCPServices_Mock(t *testing.T) { diff --git a/mdl/executor/cmd_agenteditor_models.go b/mdl/executor/cmd_agenteditor_models.go index 071bdaece7..2d81638f6d 100644 --- a/mdl/executor/cmd_agenteditor_models.go +++ b/mdl/executor/cmd_agenteditor_models.go @@ -96,7 +96,7 @@ func describeAgentEditorModel(ctx *ExecContext, name ast.QualifiedName) error { lines = append(lines, fmt.Sprintf(" Provider: %s", m.Provider)) } if m.Key != nil && m.Key.QualifiedName != "" { - lines = append(lines, fmt.Sprintf(" Key: %s", m.Key.QualifiedName)) + lines = append(lines, fmt.Sprintf(" Key: @%s", m.Key.QualifiedName)) } // Portal-populated fields — round-tripped but flagged read-only in MDL. if m.DisplayName != "" { diff --git a/mdl/executor/cmd_pages_builder_visible_when.go b/mdl/executor/cmd_pages_builder_visible_when.go index cf6060b5c9..fa04caac91 100644 --- a/mdl/executor/cmd_pages_builder_visible_when.go +++ b/mdl/executor/cmd_pages_builder_visible_when.go @@ -78,7 +78,7 @@ func (pb *pageBuilder) applyVisibleWhen(widget pages.Widget, w *ast.WidgetV3) er } all = append(values, emptyConditionValue) default: - return mdlerrors.NewValidationf("%s: %s is not a Boolean or enumeration attribute — use an expression instead: `Visible: [...]`", where, attrQN) + return mdlerrors.NewValidationf("%s: %s is not a Boolean or enumeration attribute — use an expression instead: `Visible: $currentObject/…`", where, attrQN) } visible := map[string]bool{} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 86bbe9296d..1d5d91c2e1 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -117,16 +117,28 @@ func appendDataGridPagingProps(props []string, w rawWidget) []string { return props } +// widgetConditionMDL writes a conditional Visible / Editable as the bare +// expression it stores (R5, ako/mxcli#753). One that would not read back bare +// as itself — `true`, a lone name, text the MDL expression grammar cannot +// parse — is written in the deprecated bracketed form, as before. +func widgetConditionMDL(key, expr string) string { + expr = describeExpr(expr) + if visitor.BareWidgetCondition(key, expr) { + return key + ": " + expr + } + return fmt.Sprintf("%s: [%s]", key, expr) +} + // appendConditionalProps appends VISIBLE IF and EDITABLE IF if present. func appendConditionalProps(props []string, w rawWidget) []string { if w.VisibleIf != "" { - props = append(props, fmt.Sprintf("Visible: [%s]", describeExpr(w.VisibleIf))) + props = append(props, widgetConditionMDL("Visible", w.VisibleIf)) } if prop := visibleWhenProp(w); prop != "" { props = append(props, prop) } if w.EditableIf != "" { - props = append(props, fmt.Sprintf("Editable: [%s]", describeExpr(w.EditableIf))) + props = append(props, widgetConditionMDL("Editable", w.EditableIf)) } return props } @@ -188,13 +200,13 @@ func appendAppearanceProps(props []string, w rawWidget) []string { props = append(props, formatDesignPropertiesMDL(w.DesignProperties)) } if w.VisibleIf != "" { - props = append(props, fmt.Sprintf("Visible: [%s]", describeExpr(w.VisibleIf))) + props = append(props, widgetConditionMDL("Visible", w.VisibleIf)) } if prop := visibleWhenProp(w); prop != "" { props = append(props, prop) } if w.EditableIf != "" { - props = append(props, fmt.Sprintf("Editable: [%s]", describeExpr(w.EditableIf))) + props = append(props, widgetConditionMDL("Editable", w.EditableIf)) } return props } diff --git a/mdl/executor/cmd_settings.go b/mdl/executor/cmd_settings.go index 93bf8e21a8..8847872c72 100644 --- a/mdl/executor/cmd_settings.go +++ b/mdl/executor/cmd_settings.go @@ -748,7 +748,7 @@ func alterSettingsConstant(ctx *ExecContext, ps *model.ProjectSettings, stmt *as "constant '%s' has a private value in configuration '%s'; "+ "its value is stored on the developer's workstation, not in the shared model. "+ "Change the constant to a shared value in Studio Pro first, "+ - "or use `alter settings drop constant '%s' in configuration '%s'` to remove the override", + "or use `alter settings drop constant @%s in configuration '%s'` to remove the override", stmt.ConstantId, targetConfig, stmt.ConstantId, targetConfig) } cv.Value = stmt.Value diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index 241ea23493..182252ad30 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -223,7 +223,7 @@ func describeWorkflowToString(ctx *ExecContext, name ast.QualifiedName) (string, // Due date if targetWf.DueDate != "" { - lines = append(lines, fmt.Sprintf(" due date %s", mdlQuoted(targetWf.DueDate))) + lines = append(lines, fmt.Sprintf(" due date %s", workflowExpressionMDL(targetWf.DueDate))) } lines = append(lines, formatWorkflowEventHandlers(ctx, targetWf.EventHandlers)...) @@ -326,7 +326,7 @@ func formatBoundaryEvents(events []*workflows.BoundaryEvent, indent string) []st } lines = append(lines, header) } else if event.TimerDelay != "" { - lines = append(lines, fmt.Sprintf("%s%s %s", indent, keyword, mdlQuoted(event.TimerDelay))) + lines = append(lines, fmt.Sprintf("%s%s %s", indent, keyword, workflowExpressionMDL(event.TimerDelay))) } else { lines = append(lines, fmt.Sprintf("%s%s", indent, keyword)) } @@ -362,7 +362,7 @@ func formatEventSubProcesses(esps []*workflows.EventSubProcess, indent string) [ trigger = "interrupting" } if start.Timer { - header += fmt.Sprintf(" on %s timer %s", trigger, mdlQuoted(start.FirstExecutionTime)) + header += fmt.Sprintf(" on %s timer %s", trigger, workflowExpressionMDL(start.FirstExecutionTime)) if start.Name != "" { header += " as " + mdlIdent(start.Name) } @@ -452,7 +452,9 @@ func formatFlowActivities(flow *workflows.Flow, indent string, mainFlow bool) [] } nameClause := workflowActivityNameClause(a.Name, caption) if a.DelayExpression != "" { - actLines = append(actLines, fmt.Sprintf("%swait for timer%s %s comment %s", indent, nameClause, mdlQuoted(a.DelayExpression), mdlQuoted(caption))) + var delay string + nameClause, delay = namedWorkflowExpressionMDL(a.Name, nameClause, a.DelayExpression, visitor.WorkflowWaitForTimerReadsBack) + actLines = append(actLines, fmt.Sprintf("%swait for timer%s %s comment %s", indent, nameClause, delay, mdlQuoted(caption))) } else { actLines = append(actLines, fmt.Sprintf("%swait for timer%s comment %s", indent, nameClause, mdlQuoted(caption))) } @@ -603,7 +605,7 @@ func formatUserTask(a *workflows.UserTask, indent string) []string { // Due date (task-level) if a.DueDate != "" { - lines = append(lines, fmt.Sprintf("%s due date %s", indent, mdlQuoted(a.DueDate))) + lines = append(lines, fmt.Sprintf("%s due date %s", indent, workflowExpressionMDL(a.DueDate))) } // Task description @@ -927,7 +929,9 @@ func formatExclusiveSplit(a *workflows.ExclusiveSplitActivity, indent string) [] nameClause, captionClause := workflowCaptionClauses(a.Name, a.Caption, "Decision") if a.Expression != "" { - lines = append(lines, fmt.Sprintf("%sdecision%s %s%s", indent, nameClause, mdlQuoted(a.Expression), captionClause)) + var expr string + nameClause, expr = namedWorkflowExpressionMDL(a.Name, nameClause, a.Expression, visitor.WorkflowDecisionReadsBack) + lines = append(lines, fmt.Sprintf("%sdecision%s %s%s", indent, nameClause, expr, captionClause)) } else { lines = append(lines, fmt.Sprintf("%sdecision%s%s", indent, nameClause, captionClause)) } @@ -998,6 +1002,39 @@ func targetingXPathMDL(xpath string) string { return mdlQuoted(xpath) } +// workflowExpressionMDL writes a stored workflow expression — a due date, a +// timer's delay or first execution time — bare (R5, ako/mxcli#753). One that +// does not read back as itself when written bare (surrounding whitespace, text +// the MDL expression grammar cannot parse) is written in the deprecated string +// form, which carries it byte for byte: describe must never alter a stored +// expression. +func workflowExpressionMDL(expr string) string { + if visitor.BareExpression(expr) { + return expr + } + return mdlQuoted(expr) +} + +// namedWorkflowExpressionMDL is workflowExpressionMDL for the expression of a +// decision or `wait for timer`, which follows the activity's optional name: an +// expression that starts with a word could be read as the name. When it +// would, the name is written even where the writer would derive it, which +// stores the same name; when even that does not read back, the string form. +func namedWorkflowExpressionMDL(name, nameClause, expr string, readsBack func(nameClause, expr string) bool) (string, string) { + if !visitor.BareExpression(expr) { + return nameClause, mdlQuoted(expr) + } + if readsBack(nameClause, expr) { + return nameClause, expr + } + if nameClause == "" && name != "" { + if forced := " " + mdlIdent(name); readsBack(forced, expr) { + return forced, expr + } + } + return nameClause, mdlQuoted(expr) +} + // workflowCallArguments renders a workflow call's parameter mappings in R4's // argument form, `(Param = expression)` right after the callee // (ako/mxcli#751). diff --git a/mdl/executor/describe_widget_conditions_test.go b/mdl/executor/describe_widget_conditions_test.go new file mode 100644 index 0000000000..231181bcad --- /dev/null +++ b/mdl/executor/describe_widget_conditions_test.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// R5 (ako/mxcli#753): describe page writes a conditional Visible / Editable as +// the bare expression it stores; one that would read back as a plain value is +// bracketed, as before. +func TestDescribePage_WidgetConditionIsBare(t *testing.T) { + for _, tc := range []struct{ key, expr, want string }{ + {"Visible", "$currentObject/Active", "Visible: $currentObject/Active"}, + {"Editable", "$currentObject/Status = 'Open'", "Editable: $currentObject/Status = 'Open'"}, + {"Visible", "true", "Visible: [true]"}, + } { + if got := widgetConditionMDL(tc.key, tc.expr); got != tc.want { + t.Errorf("widgetConditionMDL(%q, %q) = %q, want %q", tc.key, tc.expr, got, tc.want) + } + } + props := appendAppearanceProps(nil, rawWidget{VisibleIf: "$currentObject/A and $currentObject/B", EditableIf: "$currentObject/C"}) + src := "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { dataview dv (DataSource: $E) { textbox t (Attribute: Name, " + + strings.Join(props, ", ") + ") } };" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("does not re-parse: %v\n%s", errs, src) + } + if len(prog.Deprecations) != 0 { + t.Errorf("deprecations %v", prog.Deprecations) + } + w := prog.Statements[0].(*ast.CreatePageStmtV3).Widgets[0].Children[0] + if w.Properties["VisibleIf"] != "$currentObject/A and $currentObject/B" || w.Properties["EditableIf"] != "$currentObject/C" { + t.Errorf("re-parsed as %#v", w.Properties) + } +} diff --git a/mdl/executor/describe_workflow_expressions_test.go b/mdl/executor/describe_workflow_expressions_test.go new file mode 100644 index 0000000000..8a5ff455e1 --- /dev/null +++ b/mdl/executor/describe_workflow_expressions_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// R5 (ako/mxcli#753): describe writes a workflow's expressions bare. The +// output must re-parse to the stored expression byte for byte; one that would +// not is written in the deprecated string form. + +func describedWorkflowActivity(t *testing.T, act workflows.WorkflowActivity) (string, *ast.Program) { + t.Helper() + out := strings.Join(formatWorkflowActivities(&workflows.Flow{Activities: []workflows.WorkflowActivity{act}}, " "), "\n") + prog, errs := visitor.Build("create workflow M.WF\n parameter $WorkflowContext: M.E\nbegin\n" + out + "\nend workflow;") + if len(errs) > 0 { + t.Fatalf("does not re-parse: %v\n%s", errs, out) + } + return out, prog +} + +func onlyWorkflowStringExpression(prog *ast.Program) bool { + for _, d := range prog.Deprecations { + if d.Code != deprecation.WorkflowStringExpression { + return false + } + } + return len(prog.Deprecations) > 0 +} + +func TestDescribeWorkflow_DecisionExpressionIsBare(t *testing.T) { + for _, tc := range []struct { + name, caption, expr, want string + quoted bool + }{ + {"decision1", "Large order?", "$WorkflowContext/Total > 1000", + "decision decision1 $WorkflowContext/Total > 1000 comment 'Large order?'", false}, + {"BigOrder", "BigOrder", "toLowerCase($WorkflowContext/Name) = 'big'", + "decision toLowerCase($WorkflowContext/Name) = 'big'", false}, + // Written after no name, `Total` would be read as the name: the name + // is written, which stores the same name. + {"BigOrder", "BigOrder", "Total + 1 > 3", "decision BigOrder Total + 1 > 3", false}, + // Not an MDL expression: the string carries it as stored. + {"decision1", "Decision", "${PT1H}", "decision decision1 '${PT1H}'", true}, + } { + split := &workflows.ExclusiveSplitActivity{Expression: tc.expr} + split.Name, split.Caption = tc.name, tc.caption + out, prog := describedWorkflowActivity(t, split) + if !strings.Contains(out, tc.want) { + t.Errorf("describe wrote:\n%s\nwant a line containing %q", out, tc.want) + } + d := prog.Statements[0].(*ast.CreateWorkflowStmt).Activities[0].(*ast.WorkflowDecisionNode) + // A name the writer derives from the caption is not written. + if d.Expression != tc.expr || (d.Name != tc.name && !(d.Name == "" && tc.name == tc.caption)) { + t.Errorf("%q re-parsed as name %q expression %q", tc.want, d.Name, d.Expression) + } + if got := onlyWorkflowStringExpression(prog); got != tc.quoted { + t.Errorf("%q: deprecations %v", tc.want, prog.Deprecations) + } + } +} + +func TestDescribeWorkflow_TimersAndDueDatesAreBare(t *testing.T) { + timer := &workflows.WaitForTimerActivity{DelayExpression: "addHours([%CurrentDateTime%], 1)"} + timer.Name, timer.Caption = "wait1", "Wait" + out, prog := describedWorkflowActivity(t, timer) + if !strings.Contains(out, "wait for timer wait1 addHours([%CurrentDateTime%], 1) comment 'Wait'") { + t.Errorf("describe wrote:\n%s", out) + } + if len(prog.Deprecations) != 0 { + t.Errorf("deprecations %v", prog.Deprecations) + } + + task := &workflows.UserTask{DueDate: "addDays([%CurrentDateTime%], 3)"} + task.Name, task.Caption = "Review", "Review" + task.BoundaryEvents = []*workflows.BoundaryEvent{{EventType: "InterruptingTimer", TimerDelay: "addHours([%CurrentDateTime%], 8)"}} + out, prog = describedWorkflowActivity(t, task) + for _, want := range []string{"due date addDays([%CurrentDateTime%], 3)", "boundary event interrupting timer addHours([%CurrentDateTime%], 8)"} { + if !strings.Contains(out, want) { + t.Errorf("describe wrote:\n%s\nwant %q", out, want) + } + } + if len(prog.Deprecations) != 0 { + t.Errorf("deprecations %v", prog.Deprecations) + } + ut := prog.Statements[0].(*ast.CreateWorkflowStmt).Activities[0].(*ast.WorkflowUserTaskNode) + if ut.DueDate != task.DueDate || len(ut.BoundaryEvents) != 1 || ut.BoundaryEvents[0].Delay != "addHours([%CurrentDateTime%], 8)" { + t.Errorf("re-parsed as %#v", ut) + } +} diff --git a/mdl/executor/validate_settings_refs.go b/mdl/executor/validate_settings_refs.go index 7b8598579c..86680a24e7 100644 --- a/mdl/executor/validate_settings_refs.go +++ b/mdl/executor/validate_settings_refs.go @@ -132,7 +132,7 @@ func checkAfterStartupReturnsBoolean(key, ref string, returnTypes map[string]str // validateSettingsConstantRef resolves the constant an override names. // -// This one is not merely a dangling pointer: `alter settings constant 'Typo' +// This one is not merely a dangling pointer: `alter settings constant @Typo // value 'x'` CREATES the override, so a misspelled name leaves dead // configuration data behind and the build fails. Dropping an override is exempt // — removing one whose constant is already gone is legitimate cleanup, and diff --git a/mdl/executor/validate_widget_editability.go b/mdl/executor/validate_widget_editability.go index da7b4edfb0..bbb68a7d53 100644 --- a/mdl/executor/validate_widget_editability.go +++ b/mdl/executor/validate_widget_editability.go @@ -112,7 +112,7 @@ func validateWidgetEditability(w *ast.WidgetV3, locationPrefix string) []linter. "widget stays enabled", locationPrefix, widgetLabel(w.Name, w.Type), key, w.Type, ), - Suggestion: "Use `visible: [ ... ]` to hide it conditionally (buttons do support conditional " + + Suggestion: "Use `visible: ` to hide it conditionally (buttons do support conditional " + "visibility), or move the condition into the microflow the button calls", }} } diff --git a/mdl/executor/validate_workflow_esp.go b/mdl/executor/validate_workflow_esp.go index 0b3bf9e457..e3fae43b73 100644 --- a/mdl/executor/validate_workflow_esp.go +++ b/mdl/executor/validate_workflow_esp.go @@ -114,7 +114,7 @@ func ValidateWorkflowEventSubProcesses(stmt *ast.CreateWorkflowStmt) []linter.Vi Severity: linter.SeverityError, Location: loc, Message: fmt.Sprintf("event subprocess %s starts on a timer with no first execution time — the build fails CE0126", esp.Name), - Suggestion: "Give the timer an expression, e.g. `on interrupting timer 'addDays([%CurrentDateTime%], 1)'`.", + Suggestion: "Give the timer an expression, e.g. `on interrupting timer addDays([%CurrentDateTime%], 1)`.", }) } } diff --git a/mdl/executor/validate_workflow_rewrite.go b/mdl/executor/validate_workflow_rewrite.go index ba0c6c8f21..0f512d4c96 100644 --- a/mdl/executor/validate_workflow_rewrite.go +++ b/mdl/executor/validate_workflow_rewrite.go @@ -26,7 +26,7 @@ import ( // (issue #948). That is the same shape as a dropped queue binding // (checkNoQueuedCalls) — guard-don't-drop, ADR-0005. // -// Boundary events ARE authorable (`boundary event interrupting timer '…' { … }`), +// Boundary events ARE authorable (`boundary event interrupting timer … { … }`), // so a script that restates them is allowed straight through — that is the normal // way to edit a workflow that has one. So are event sub-processes and // notification activities now; only a sub-process with no start event, which MDL @@ -173,7 +173,7 @@ func checkNoDroppedWorkflowConstructs(ctx *ExecContext, workflowID model.ID, qua return mdlerrors.NewUnsupported(fmt.Sprintf( "workflow %s has %d stored boundary event(s) but this statement declares %d — "+ "rewriting it would delete the difference, along with each one's handler flow.\n"+ - " Restate them (`boundary event interrupting timer '' { … }`), which "+ + " Restate them (`boundary event interrupting timer { … }`), which "+ "`describe workflow %s` now emits, or use ALTER WORKFLOW to change one activity at a time.", qualifiedName, storedBE, authored, qualifiedName)) } diff --git a/mdl/executor/workflow_esp_test.go b/mdl/executor/workflow_esp_test.go index 77f996dba4..be91980807 100644 --- a/mdl/executor/workflow_esp_test.go +++ b/mdl/executor/workflow_esp_test.go @@ -108,7 +108,7 @@ func TestWorkflowESP_DescribeRoundTrips(t *testing.T) { "boundary event non interrupting notification nudge 'Nudge'", "event subprocess ESP_Cancel 'Cancel request' on interrupting notification cancelStart 'Cancel received' {", "event subprocess ESP_Loop on non interrupting notification loopStart 'loopStart' {", - "event subprocess ESP_Expire 'Expire' on interrupting timer 'addDays([%CurrentDateTime%], 30)' as expireStart comment 'After 30 days' {", + "event subprocess ESP_Expire 'Expire' on interrupting timer addDays([%CurrentDateTime%], 30) as expireStart comment 'After 30 days' {", "jump to logIt;", } { if !strings.Contains(first, want) { diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 28fe0f0230..ee4de53c4f 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -414,8 +414,13 @@ alterAssignOp alterPageAssignment : DATASOURCE alterAssignOp dataSourceExprV3 // DataSource: selection widgetName | ACTION alterAssignOp actionExprV3 // Action: MICROFLOW Module.MF | SHOW_PAGE Module.Page | SAVE_CHANGES CLOSE_PAGE - | VISIBLE alterAssignOp xpathConstraint // Visible: [Name != ''] (conditional visibility) - | EDITABLE alterAssignOp xpathConstraint // Editable: [Status = 'Open'] (conditional editability) + // R5 (ako/mxcli#753): the condition is a bare expression; the bracketed + // form is the deprecated alias. The plain value keeps its reading, ahead of + // the expression, as in widgetPropertyV3. + | VISIBLE alterAssignOp xpathConstraint /* @alias MDL-DEPR081 */ // Visible: [Name != ''] (conditional visibility) + | EDITABLE alterAssignOp xpathConstraint /* @alias MDL-DEPR081 */ // Editable: [Status = 'Open'] (conditional editability) + | (VISIBLE | EDITABLE) alterAssignOp propertyValueV3 // Visible: false, Editable: Never + | (VISIBLE | EDITABLE) alterAssignOp expression // Visible: $currentObject/Name != '' // A pluggable widget's NAMED action slot, addressed by the widget's own key: // `set 'createFileAction' = microflow M.F on fileUploader1`. The ALTER-level // twin of widgetPropertyV3's `key: actionExprV3` (#956); without it the value diff --git a/mdl/grammar/domains/MDLAgent.g4 b/mdl/grammar/domains/MDLAgent.g4 index fb3cfc61b8..ea647a8955 100644 --- a/mdl/grammar/domains/MDLAgent.g4 +++ b/mdl/grammar/domains/MDLAgent.g4 @@ -11,7 +11,7 @@ options { tokenVocab = MDLLexer; } // ============================================================================= // CREATE MODEL Module.Name ( // Provider: MxCloudGenAI, -// Key: Module.SomeConstant +// Key: @Module.SomeConstant // [, DisplayName: '...', KeyName: '...', etc. — Portal-populated metadata] // ); createModelStatement @@ -22,7 +22,10 @@ createModelStatement modelProperty : identifierOrKeyword COLON identifierOrKeyword // Provider: MxCloudGenAI - | identifierOrKeyword COLON qualifiedName // Key: Module.Constant + | identifierOrKeyword COLON AT qualifiedName // Key: @Module.Constant (the one constant reference, R5) + // A document name (Model: Module.Model). For Key, a constant, the bare name + // is the deprecated spelling of Key: @Module.Constant. + | identifierOrKeyword COLON qualifiedName /* @alias MDL-DEPR084 */ | identifierOrKeyword COLON STRING_LITERAL // DisplayName: 'GPT-4 Turbo' etc. | identifierOrKeyword COLON NUMBER_LITERAL // ConnectionTimeoutSeconds: 30 | identifierOrKeyword COLON booleanLiteral // Enabled: true @@ -58,7 +61,7 @@ createConsumedMCPServiceStatement // ============================================================================= // CREATE KNOWLEDGE BASE Module.Name ( // Provider: MxCloudGenAI, -// Key: Module.SomeConstant +// Key: @Module.SomeConstant // ); createKnowledgeBaseStatement : KNOWLEDGE BASE qualifiedName @@ -109,6 +112,7 @@ agentEditorAlterValue | NUMBER_LITERAL | DOLLAR_STRING | booleanLiteral + | AT qualifiedName // Key = @Module.Constant | qualifiedName | identifierOrKeyword ; diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index e22901c397..3829c27af4 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -523,11 +523,19 @@ widgetPropertyV3 | DESIGNPROPERTIES COLON designPropertyListV3 // DesignProperties: [...] | WIDTH COLON NUMBER_LITERAL // Width: 200 | HEIGHT COLON NUMBER_LITERAL // Height: 100 - | VISIBLE COLON xpathConstraint // Visible: [IsActive = true] + // R5 (ako/mxcli#753): a conditional Visible / Editable is a client + // expression, written bare like every other expression and stored as + // written. The bracketed form is the deprecated alias; it roots a bare + // attribute in $currentObject on the way in. The plain values keep their + // alternative, ahead of the expression, so `Visible: false` and `Editable: + // Never` mean what they did. + | VISIBLE COLON xpathConstraint /* @alias MDL-DEPR081 */ // Visible: [IsActive = true] | VISIBLE COLON qualifiedName IN LPAREN visibleValueV3 (COMMA visibleValueV3)* RPAREN // Visible: Status in (Running, empty) | Mod.Entity.Attr in (…) | VISIBLE COLON propertyValueV3 // Visible: false - | EDITABLE COLON xpathConstraint // Editable: [Status != 'Closed'] + | VISIBLE COLON expression // Visible: $currentObject/Status = 'Open' + | EDITABLE COLON xpathConstraint /* @alias MDL-DEPR081 */ // Editable: [Status != 'Closed'] | EDITABLE COLON propertyValueV3 // Editable: Never | Always + | EDITABLE COLON expression // Editable: $currentObject/Status != 'Closed' | TOOLTIP COLON propertyValueV3 // Tooltip: 'text' // Generic datasource-typed property (e.g. chart series `staticDataSource: // database Module.View`, `dynamicDataSource: $var`). Placed before the diff --git a/mdl/grammar/domains/MDLSecurity.g4 b/mdl/grammar/domains/MDLSecurity.g4 index 95dcfedb9a..7e0c36fe2e 100644 --- a/mdl/grammar/domains/MDLSecurity.g4 +++ b/mdl/grammar/domains/MDLSecurity.g4 @@ -56,8 +56,12 @@ grantEntityAccessStatement (WHERE STRING_LITERAL)? ; +// R5 (ako/mxcli#753): the revoke mirrors the grant — rights first, then +// `on entity`, the roles after `from`. `all` removes the roles' access rule +// altogether; a rights list takes those rights away and keeps the rule. revokeEntityAccessStatement - : REVOKE moduleRoleList ON qualifiedName + : REVOKE (ALL | entityAccessRightList) ON ENTITY qualifiedName FROM moduleRoleList + | REVOKE moduleRoleList ON qualifiedName /* @alias MDL-DEPR082 */ (LPAREN entityAccessRightList RPAREN)? ; diff --git a/mdl/grammar/domains/MDLService.g4 b/mdl/grammar/domains/MDLService.g4 index a44bb27775..ee4d706041 100644 --- a/mdl/grammar/domains/MDLService.g4 +++ b/mdl/grammar/domains/MDLService.g4 @@ -57,8 +57,8 @@ createRestClientStatement restClientProperty : identifierOrKeyword COLON STRING_LITERAL // BaseUrl: '...', Username: '...' - | identifierOrKeyword COLON VARIABLE // Username: $Constant (legacy, stored as Rest$ConstantValue) - | identifierOrKeyword COLON AT qualifiedName // Username: @Module.Constant (preferred Mendix convention) + | identifierOrKeyword COLON VARIABLE /* @alias MDL-DEPR083 */ // Username: $Constant (a constant of the client's own module) + | identifierOrKeyword COLON AT qualifiedName // Username: @Module.Constant (the one constant reference, R5) | identifierOrKeyword COLON NONE // Authentication: NONE | identifierOrKeyword COLON BASIC LPAREN restClientProperty (COMMA restClientProperty)* RPAREN ; diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index f3c3ba2651..b1e6ef8525 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -13,7 +13,7 @@ options { tokenVocab = MDLLexer; } /** * ALTER SETTINGS RUNTIME Key = Value, ...; (MODEL is a deprecated alias) * ALTER SETTINGS CONFIGURATION 'name' Key = Value, ...; - * ALTER SETTINGS CONSTANT 'name' VALUE 'value' [IN CONFIGURATION 'name']; + * ALTER SETTINGS CONSTANT @Module.Name VALUE 'value' [IN CONFIGURATION 'name']; * ALTER SETTINGS LANGUAGE Key = Value, ...; * ALTER SETTINGS LANGUAGE ADD [OR MODIFY] 'ar_SD' [(Key: Value, ...)]; * ALTER SETTINGS LANGUAGE MODIFY 'ar_SD' (Key: Value, ...); @@ -46,11 +46,18 @@ alterSettingsClause | settingsSection MODIFY STRING_LITERAL settingsItemOptions | settingsSection REMOVE STRING_LITERAL | settingsSection settingsAssignment (COMMA settingsAssignment)* - | CONSTANT STRING_LITERAL (VALUE settingsValue | DROP) (IN CONFIGURATION STRING_LITERAL)? - | DROP CONSTANT STRING_LITERAL (IN CONFIGURATION STRING_LITERAL)? + | CONSTANT settingsConstantRef (VALUE settingsValue | DROP) (IN CONFIGURATION STRING_LITERAL)? + | DROP CONSTANT settingsConstantRef (IN CONFIGURATION STRING_LITERAL)? | CONFIGURATION STRING_LITERAL settingsAssignment (COMMA settingsAssignment)* ; +// R5 (ako/mxcli#753): a constant is referred to one way everywhere, +// `@Module.Const`. The quoted name is the deprecated spelling. +settingsConstantRef + : AT qualifiedName + | STRING_LITERAL /* @alias MDL-DEPR085 */ + ; + // RUNTIME is Studio Pro's tab for Settings$ModelSettings (R10); MODEL, the // old name, is an alias. The visitor stores both as the "model" section. settingsSection diff --git a/mdl/grammar/domains/MDLWorkflow.g4 b/mdl/grammar/domains/MDLWorkflow.g4 index 296f414957..bfa5695241 100644 --- a/mdl/grammar/domains/MDLWorkflow.g4 +++ b/mdl/grammar/domains/MDLWorkflow.g4 @@ -41,7 +41,7 @@ workflowHeaderClause // rule bypasses by taking IDENTIFIER directly. | EXPORT LEVEL (IDENTIFIER | API | HIDDEN_KW) | OVERVIEW PAGE qualifiedName - | DUE DATE_TYPE dueDate=STRING_LITERAL + | DUE DATE_TYPE dueDate=workflowExpression | workflowEventHandlerClause ; @@ -68,7 +68,7 @@ workflowEventSubProcess */ workflowEventSubProcessTrigger : NOTIFICATION workflowActivityName? STRING_LITERAL? - | TIMER STRING_LITERAL (AS workflowActivityName)? (COMMENT STRING_LITERAL)? + | TIMER workflowExpression (AS workflowActivityName)? (COMMENT STRING_LITERAL)? ; /** @@ -143,6 +143,23 @@ workflowActivityName | QUOTED_IDENTIFIER ; +/** + * A Mendix expression a workflow stores: a decision's condition, a timer's + * delay or first execution time, a due date. R5 (ako/mxcli#753): written bare, + * like every other expression in MDL — `decision $WorkflowContext/Total > 1000`. + * + * The string form (`decision '$WorkflowContext/Total > 1000'`) is the + * deprecated spelling of the same thing: its CONTENT is the expression, as it + * always was. It is listed first so a lone string keeps that reading; no slot + * here takes a string-valued expression (a condition is Boolean or an + * enumeration, a timer or due date a DateTime), so nothing written bare means + * a string either. + */ +workflowExpression + : STRING_LITERAL /* @alias MDL-DEPR080 */ + | expression + ; + /** * A user task. Its clauses are a SET, not a sequence — see * `workflowHeaderClause` for why, and `checkWorkflowClausesAtMostOnce` for the @@ -167,7 +184,7 @@ workflowUserTaskClause | TARGETING (USERS | GROUPS)? XPATH STRING_LITERAL /* @alias MDL-DEPR031 */ | ON CREATED MICROFLOW qualifiedName | ENTITY qualifiedName - | DUE DATE_TYPE STRING_LITERAL + | DUE DATE_TYPE workflowExpression | DESCRIPTION STRING_LITERAL | OUTCOMES workflowUserTaskOutcome+ | BOUNDARY EVENT workflowBoundaryEventClause ((BOUNDARY EVENT)? workflowBoundaryEventClause)* @@ -218,9 +235,9 @@ workflowFallbackClause * with two boundary events did not parse. */ workflowBoundaryEventClause - : INTERRUPTING TIMER STRING_LITERAL? (LBRACE workflowBody RBRACE)? - | NON INTERRUPTING TIMER STRING_LITERAL? (LBRACE workflowBody RBRACE)? - | TIMER STRING_LITERAL? (LBRACE workflowBody RBRACE)? + : INTERRUPTING TIMER workflowExpression? (LBRACE workflowBody RBRACE)? + | NON INTERRUPTING TIMER workflowExpression? (LBRACE workflowBody RBRACE)? + | TIMER workflowExpression? (LBRACE workflowBody RBRACE)? // A notification boundary event is triggered by `notify workflow … target // `, so its name is what matters; the string is its caption. | INTERRUPTING NOTIFICATION workflowActivityName? STRING_LITERAL? (LBRACE workflowBody RBRACE)? @@ -266,7 +283,7 @@ workflowCallWorkflowStmt ; workflowDecisionStmt - : DECISION workflowActivityName? STRING_LITERAL? (COMMENT STRING_LITERAL)? + : DECISION workflowActivityName? workflowExpression? (COMMENT STRING_LITERAL)? (OUTCOMES workflowConditionOutcome+)? ; @@ -288,7 +305,7 @@ workflowJumpToStmt ; workflowWaitForTimerStmt - : WAIT FOR TIMER workflowActivityName? STRING_LITERAL? (COMMENT STRING_LITERAL)? + : WAIT FOR TIMER workflowActivityName? workflowExpression? (COMMENT STRING_LITERAL)? ; workflowWaitForNotificationStmt @@ -332,7 +349,7 @@ workflowSetProperty : DISPLAY STRING_LITERAL | DESCRIPTION STRING_LITERAL | EXPORT LEVEL (IDENTIFIER | API | HIDDEN_KW) - | DUE DATE_TYPE STRING_LITERAL + | DUE DATE_TYPE workflowExpression | OVERVIEW PAGE qualifiedName | PARAMETER VARIABLE COLON qualifiedName ; @@ -343,7 +360,7 @@ activitySetProperty | TARGETING MICROFLOW qualifiedName | TARGETING XPATH xpathConstraint+ | TARGETING XPATH STRING_LITERAL /* @alias MDL-DEPR031 */ - | DUE DATE_TYPE STRING_LITERAL + | DUE DATE_TYPE workflowExpression ; alterActivityRef diff --git a/mdl/upgrade/constant_refs_test.go b/mdl/upgrade/constant_refs_test.go new file mode 100644 index 0000000000..0500761fb4 --- /dev/null +++ b/mdl/upgrade/constant_refs_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// R5 (ako/mxcli#753): every constant reference becomes `@Module.Const`. +func TestUpgrade_ConstantReferences(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + {"rest credentials qualified with the service's module", + "create consumed rest service Shop.Api (BaseUrl: 'https://x', Authentication: basic (Username: $ApiUser, Password: $ApiPass)) { };", + "create consumed rest service Shop.Api (BaseUrl: 'https://x', Authentication: basic (Username: @Shop.ApiUser, Password: @Shop.ApiPass)) { };"}, + {"model and alter key", + "create model M.GPT (Provider: MxCloudGenAI, Key: M.ApiKey);\nalter knowledge base M.KB set Key = M.K2;", + "create model M.GPT (Provider: MxCloudGenAI, Key: @M.ApiKey);\nalter knowledge base M.KB set Key = @M.K2;"}, + {"settings", + "ALTER SETTINGS CONSTANT 'M.ApiUrl' VALUE 'https://x';\nalter settings drop constant 'M.Old' in configuration 'Default';", + "ALTER SETTINGS CONSTANT @M.ApiUrl VALUE 'https://x';\nalter settings drop constant @M.Old in configuration 'Default';"}, + } { + t.Run(tc.name, func(t *testing.T) { + res := mustUpgrade(t, tc.src, Options{}) + if res.Source != tc.want { + t.Errorf("got:\n%s\nwant:\n%s", res.Source, tc.want) + } + if len(res.Unrewritten) != 0 { + t.Errorf("Unrewritten = %+v", res.Unrewritten) + } + }) + } +} + +// A settings constant string that is not a Module.Constant name is reported. +func TestUpgrade_SettingsConstantNotANameIsReported(t *testing.T) { + src := "alter settings constant 'NoModule' value 'x';" + res := mustUpgrade(t, src, Options{}) + if res.Source != src { + t.Errorf("rewritten to %q", res.Source) + } + if len(res.Unrewritten) != 1 || res.Unrewritten[0].Code != deprecation.QuotedSettingsConstant { + t.Errorf("Unrewritten = %+v", res.Unrewritten) + } +} diff --git a/mdl/upgrade/revoke_entity_test.go b/mdl/upgrade/revoke_entity_test.go new file mode 100644 index 0000000000..7f0346197b --- /dev/null +++ b/mdl/upgrade/revoke_entity_test.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import "testing" + +// R5 (ako/mxcli#753): the reversed entity revoke is rewritten to the form that +// mirrors the grant, keeping names, rights and case as written. +func TestUpgrade_RevokeEntityMirrorsGrant(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + {"partial, several roles", "revoke Shop.User, Shop.Admin on Shop.Order (write (Email, \"Status\"), delete); -- keep\n", + "revoke write (Email, \"Status\"), delete on entity Shop.Order from Shop.User, Shop.Admin; -- keep\n"}, + {"full", "revoke Shop.User on Shop.Order;", "revoke all on entity Shop.Order from Shop.User;"}, + {"upper case", "REVOKE Shop.User ON Shop.Order (READ *);", "REVOKE READ * ON ENTITY Shop.Order FROM Shop.User;"}, + } { + t.Run(tc.name, func(t *testing.T) { + res := mustUpgrade(t, tc.src, Options{}) + if res.Source != tc.want { + t.Errorf("got:\n%s\nwant:\n%s", res.Source, tc.want) + } + if len(res.Unrewritten) != 0 { + t.Errorf("Unrewritten = %+v", res.Unrewritten) + } + }) + } +} diff --git a/mdl/upgrade/widget_conditions_test.go b/mdl/upgrade/widget_conditions_test.go new file mode 100644 index 0000000000..101dbf7860 --- /dev/null +++ b/mdl/upgrade/widget_conditions_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// R5 (ako/mxcli#753): `Visible: [expr]` / `Editable: [expr]` become the bare +// expression the brackets stored — a bare attribute rooted in $currentObject. +func TestUpgrade_WidgetConditionsBare(t *testing.T) { + page := func(props string) string { + return "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) {\n dataview dv (DataSource: $E) {\n textbox t (Attribute: Name, " + + props + ") -- keep\n }\n};" + } + for _, tc := range []struct{ name, src, want string }{ + {"visible, attribute rooted", page("Visible: [Active]"), page("Visible: $currentObject/Active")}, + {"editable", page("Editable: [$currentObject/Status = 'Open']"), page("Editable: $currentObject/Status = 'Open'")}, + {"both", page("Visible: [Active], Editable: [not(Locked)]"), page("Visible: $currentObject/Active, Editable: not($currentObject/Locked)")}, + {"alter page set", "alter page M.P { set (Visible: [Active]) on t };", "alter page M.P { set (Visible: $currentObject/Active) on t };"}, + } { + t.Run(tc.name, func(t *testing.T) { + res := mustUpgrade(t, tc.src, Options{}) + if res.Source != tc.want { + t.Errorf("got:\n%s\nwant:\n%s", res.Source, tc.want) + } + if len(res.Unrewritten) != 0 { + t.Errorf("Unrewritten = %+v", res.Unrewritten) + } + }) + } +} + +// A condition the rewrite cannot carry — here one holding an mdl 0 backslash +// escape, whose value the string-escape rewrite owns — is reported, not +// rewritten. +func TestUpgrade_WidgetConditionNotBareIsReported(t *testing.T) { + src := `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { dataview dv (DataSource: $E) { textbox t (Attribute: Name, Visible: [$currentObject/Name = 'a\tb']) } };` + res := mustUpgrade(t, src, Options{}) + if res.Source != src { + t.Errorf("rewritten to %q", res.Source) + } + if len(res.Unrewritten) != 1 || res.Unrewritten[0].Code != deprecation.BracketedWidgetCondition { + t.Errorf("Unrewritten = %+v, want one %s", res.Unrewritten, deprecation.BracketedWidgetCondition) + } +} + +// A constant condition has no bare spelling — bare, `true` is the plain value, +// stored differently — so its brackets are not reported at all. +func TestUpgrade_ConstantWidgetConditionIsNotAnAlias(t *testing.T) { + src := "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { dataview dv (DataSource: $E) { textbox t (Attribute: Name, Editable: [true]) } };" + res := mustUpgrade(t, src, Options{}) + if res.Source != src || len(res.Unrewritten) != 0 { + t.Errorf("rewritten to %q, Unrewritten %+v", res.Source, res.Unrewritten) + } +} diff --git a/mdl/upgrade/workflow_expressions_test.go b/mdl/upgrade/workflow_expressions_test.go new file mode 100644 index 0000000000..2a6da5f526 --- /dev/null +++ b/mdl/upgrade/workflow_expressions_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// R5 (ako/mxcli#753): a workflow expression in a string is rewritten to the +// bare expression, keeping everything around it. +func TestUpgrade_WorkflowExpressionsBare(t *testing.T) { + head := "create workflow M.WF\n parameter $WorkflowContext: M.E\n" + for _, tc := range []struct{ name, src, want string }{ + { + "decision, timers and due dates", + head + " due date 'addDays([%CurrentDateTime%], 2)'\nbegin\n" + + " user task Review 'Review'\n due date 'addHours([%CurrentDateTime%], 4)'\n outcomes 'Done' { }\n" + + " boundary event interrupting timer 'addHours([%CurrentDateTime%], 8)' { };\n" + + " decision '$WorkflowContext/Total > 1000' comment 'Large?' -- keep\n outcomes true -> { } false -> { };\n" + + " wait for timer 'addHours([%CurrentDateTime%], 1)' comment 'Wait';\n" + + " event subprocess Esp on interrupting timer 'addDays([%CurrentDateTime%], 30)' as Start { };\nend workflow;", + head + " due date addDays([%CurrentDateTime%], 2)\nbegin\n" + + " user task Review 'Review'\n due date addHours([%CurrentDateTime%], 4)\n outcomes 'Done' { }\n" + + " boundary event interrupting timer addHours([%CurrentDateTime%], 8) { };\n" + + " decision $WorkflowContext/Total > 1000 comment 'Large?' -- keep\n outcomes true -> { } false -> { };\n" + + " wait for timer addHours([%CurrentDateTime%], 1) comment 'Wait';\n" + + " event subprocess Esp on interrupting timer addDays([%CurrentDateTime%], 30) as Start { };\nend workflow;", + }, + { + "a string with no space around it", + "alter workflow M.WF set activity Review due date'addDays([%CurrentDateTime%], 1)';", + "alter workflow M.WF set activity Review due date addDays([%CurrentDateTime%], 1);", + }, + { + "upper case, doubled quotes inside", + "ALTER WORKFLOW M.WF SET DUE DATE 'if $x = ''a'' then [%CurrentDateTime%] else [%BeginOfCurrentDay%]';", + "ALTER WORKFLOW M.WF SET DUE DATE if $x = 'a' then [%CurrentDateTime%] else [%BeginOfCurrentDay%];", + }, + } { + t.Run(tc.name, func(t *testing.T) { + res := mustUpgrade(t, tc.src, Options{}) + if res.Source != tc.want { + t.Errorf("got:\n%s\nwant:\n%s", res.Source, tc.want) + } + if len(res.Unrewritten) != 0 { + t.Errorf("Unrewritten = %+v", res.Unrewritten) + } + }) + } +} + +// A string whose content is not a bare expression that reads back as itself +// is reported, not rewritten: unquoting it would change what is stored. So is +// a decision whose bare expression would be read as the decision's name. +func TestUpgrade_WorkflowExpressionNotBareIsReported(t *testing.T) { + head := "create workflow M.WF\n parameter $WorkflowContext: M.E\nbegin\n" + for _, src := range []string{ + "alter workflow M.WF set due date '${PT1H}';", + "alter workflow M.WF set due date ' addDays([%CurrentDateTime%], 1)';", + head + " decision 'Total + 1 > 3'\n outcomes true -> { } false -> { };\nend workflow;", + } { + res := mustUpgrade(t, src, Options{}) + if res.Source != src { + t.Errorf("%q was rewritten to %q", src, res.Source) + } + if len(res.Unrewritten) != 1 || res.Unrewritten[0].Code != deprecation.WorkflowStringExpression { + t.Errorf("%q: Unrewritten = %+v, want one %s", src, res.Unrewritten, deprecation.WorkflowStringExpression) + } + } +} diff --git a/mdl/visitor/visitor_alter_page.go b/mdl/visitor/visitor_alter_page.go index 326905bffa..cba4d1b35a 100644 --- a/mdl/visitor/visitor_alter_page.go +++ b/mdl/visitor/visitor_alter_page.go @@ -188,6 +188,18 @@ func (b *Builder) buildAlterPageAssignment(ctx *parser.AlterPageAssignmentContex return "EditableIf", buildConditionalExpression(xc) } } + // Visible: / Editable: — the canonical form, the + // expression stored as written (R5); a plain value keeps the key it had + // when it reached the generic alternative below. + if kw := visibleOrEditable(ctx.VISIBLE(), ctx.EDITABLE()); kw != nil { + if e := ctx.Expression(); e != nil { + if ctx.VISIBLE() != nil { + return "VisibleIf", bareArgumentText(e) + } + return "EditableIf", bareArgumentText(e) + } + return kw.GetText(), buildPropertyValueV3(ctx.PropertyValueV3()) + } var name string diff --git a/mdl/visitor/visitor_constant_refs.go b/mdl/visitor/visitor_constant_refs.go new file mode 100644 index 0000000000..44351bd1ec --- /dev/null +++ b/mdl/visitor/visitor_constant_refs.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "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" +) + +// R5 (ADR-0010; ako/mxcli#753): a constant is referred to one way everywhere, +// `@Module.Const` — the spelling a Mendix expression uses. The other spellings +// are deprecated aliases whose use is recorded here with the rewrite to `@`. + +// dollarConstantName is the qualified name a consumed REST service's `$Const` +// credential names: a constant of the service's own module, unless the name is +// already qualified. +func dollarConstantName(module, variable string) string { + name := strings.TrimPrefix(variable, "$") + if strings.Contains(name, ".") || module == "" { + return name + } + return module + "." + name +} + +// recordDollarConstant records MDL-DEPR083 on a `$Const` credential, with the +// rewrite to `@Module.Const`. +func (b *Builder) recordDollarConstant(v antlr.TerminalNode, module string) { + tok := v.GetSymbol() + b.recordDeprecation(deprecation.DollarConstant, tok, "constant reference") + name := dollarConstantName(module, tok.GetText()) + if !strings.Contains(name, ".") { + b.fixLastDeprecation(deprecation.DollarConstant, nil, "the service has no module to qualify "+tok.GetText()+" with") + return + } + b.fixLastDeprecation(deprecation.DollarConstant, + &ast.Fix{Edits: []ast.TextEdit{replaceSpan(tok, tok, "@"+name)}}, "") +} + +// ExitModelProperty records an agent-editor `Key: Module.Const` written +// without its `@`. +func (b *Builder) ExitModelProperty(ctx *parser.ModelPropertyContext) { + idents := ctx.AllIdentifierOrKeyword() + if len(idents) == 0 || !strings.EqualFold(idents[0].GetText(), "key") || ctx.AT() != nil { + return + } + b.recordBareConstantKey(ctx.QualifiedName()) +} + +// ExitAgentEditorAlterAssignment records `set Key = Module.Const` written +// without its `@`. +func (b *Builder) ExitAgentEditorAlterAssignment(ctx *parser.AgentEditorAlterAssignmentContext) { + iok := ctx.IdentifierOrKeyword() + val, ok := ctx.AgentEditorAlterValue().(*parser.AgentEditorAlterValueContext) + if iok == nil || !ok || val == nil || !strings.EqualFold(iok.GetText(), "key") || val.AT() != nil { + return + } + b.recordBareConstantKey(val.QualifiedName()) +} + +// recordBareConstantKey records MDL-DEPR084 on a constant named without `@`, +// with the rewrite that adds it. +func (b *Builder) recordBareConstantKey(qn parser.IQualifiedNameContext) { + if qn == nil || qn.GetStart() == nil { + return + } + b.recordDeprecation(deprecation.BareConstantKey, qn.GetStart(), "constant reference") + b.fixLastDeprecation(deprecation.BareConstantKey, + &ast.Fix{Edits: []ast.TextEdit{insertAt(qn.GetStart().GetStart(), "@")}}, "") +} + +// settingsConstantRefText is the qualified name a settings constant +// reference names: `@M.C` or, deprecated, `'M.C'`. +func settingsConstantRefText(ctx parser.ISettingsConstantRefContext) string { + if ctx == nil { + return "" + } + if qn := ctx.QualifiedName(); qn != nil { + return getQualifiedNameText(qn) + } + if lit := ctx.STRING_LITERAL(); lit != nil { + return unquoteStringLit(lit) + } + return "" +} + +// ExitSettingsConstantRef records MDL-DEPR085 on a constant named in a string, +// with the rewrite to `@Module.Const` when the string holds a qualified name +// that reads back as itself. +func (b *Builder) ExitSettingsConstantRef(ctx *parser.SettingsConstantRefContext) { + lit := ctx.STRING_LITERAL() + if lit == nil { + return + } + b.recordDeprecation(deprecation.QuotedSettingsConstant, lit.GetSymbol(), "constant reference") + name := unquoteStringLit(lit) + qn, ok := parseRule(name, func(p *parser.MDLParser) antlr.ParserRuleContext { return p.QualifiedName() }) + if holdsInterpretedEscape(lit) || !ok || getQualifiedNameText(qn.(parser.IQualifiedNameContext)) != name || !strings.Contains(name, ".") { + b.fixLastDeprecation(deprecation.QuotedSettingsConstant, nil, + "the string "+lit.GetText()+" is not a Module.Constant name; write it as @Module.Constant by hand") + return + } + b.fixLastDeprecation(deprecation.QuotedSettingsConstant, + &ast.Fix{Edits: []ast.TextEdit{replaceSpan(lit.GetSymbol(), lit.GetSymbol(), "@"+name)}}, "") +} diff --git a/mdl/visitor/visitor_constant_refs_test.go b/mdl/visitor/visitor_constant_refs_test.go new file mode 100644 index 0000000000..29bdb950a6 --- /dev/null +++ b/mdl/visitor/visitor_constant_refs_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// R5 (ako/mxcli#753): `@Module.Const` is the one constant reference. The +// other spellings are deprecated aliases that build the same statement. +func TestConstantReference_OneSpelling(t *testing.T) { + for _, tc := range []struct{ name, old, canonical, code string }{ + {"rest credential $Const", + "create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: basic (Username: $ApiUser, Password: $ApiPass)) { };", + "create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: basic (Username: @M.ApiUser, Password: @M.ApiPass)) { };", + deprecation.DollarConstant}, + {"model key", + "create model M.GPT (Provider: MxCloudGenAI, Key: M.ApiKey);", + "create model M.GPT (Provider: MxCloudGenAI, Key: @M.ApiKey);", + deprecation.BareConstantKey}, + {"knowledge base key", + "create knowledge base M.KB (Provider: MxCloudGenAI, Key: M.KbKey);", + "create knowledge base M.KB (Provider: MxCloudGenAI, Key: @M.KbKey);", + deprecation.BareConstantKey}, + {"alter model key", + "alter model M.GPT set Key = M.OtherKey;", + "alter model M.GPT set Key = @M.OtherKey;", + deprecation.BareConstantKey}, + {"settings constant value", + "alter settings constant 'M.ApiUrl' value 'https://x' in configuration 'Default';", + "alter settings constant @M.ApiUrl value 'https://x' in configuration 'Default';", + deprecation.QuotedSettingsConstant}, + {"settings drop constant", + "alter settings drop constant 'M.ApiUrl' in configuration 'Default';", + "alter settings drop constant @M.ApiUrl in configuration 'Default';", + deprecation.QuotedSettingsConstant}, + } { + t.Run(tc.name, func(t *testing.T) { + old, canon := mustBuild(t, tc.old), mustBuild(t, tc.canonical) + if !reflect.DeepEqual(old.Statements, canon.Statements) { + t.Errorf("old built %#v\ncanonical %#v", old.Statements[0], canon.Statements[0]) + } + for _, c := range deprecationCodes(old) { + if c != tc.code { + t.Errorf("old form recorded %s, want only %s", c, tc.code) + } + } + if len(old.Deprecations) == 0 { + t.Errorf("old form recorded nothing, want %s", tc.code) + } + if got := deprecationCodes(canon); len(got) != 0 { + t.Errorf("canonical form recorded %v", got) + } + }) + } +} + +// A model's other qualified-name properties name documents, not constants, +// and are not reported. +func TestConstantReference_DocumentNamesAreNotConstants(t *testing.T) { + prog := mustBuild(t, "create agent M.Helper (UsageType: Task, Model: M.GPT, SystemPrompt: 'x');") + if got := deprecationCodes(prog); len(got) != 0 { + t.Errorf("recorded %v", got) + } +} diff --git a/mdl/visitor/visitor_deprecations_test.go b/mdl/visitor/visitor_deprecations_test.go index 34aa6436c7..e2a1a42ca3 100644 --- a/mdl/visitor/visitor_deprecations_test.go +++ b/mdl/visitor/visitor_deprecations_test.go @@ -105,9 +105,9 @@ var createOrReplaceCases = map[string]string{ "configuration": "configuration 'Default';", "publishedrestservice": "published rest service M.OrderAPI (Path: 'rest/orders/v1', Version: '1.0.0', ServiceName: 'Order API') { };", "datatransformer": "data transformer M.Flatten source json '{\"id\": 1}' { jslt '{\"id\": .id}'; };", - "model": "model M.GPT4 (Provider: MxCloudGenAI, Key: M.ModelApiKey);", + "model": "model M.GPT4 (Provider: MxCloudGenAI, Key: @M.ModelApiKey);", "consumedmcpservice": "consumed mcp service M.WebSearch (ProtocolVersion: v2025_03_26, Version: '1.0');", - "knowledgebase": "knowledge base M.Docs (Provider: MxCloudGenAI, Key: M.KBApiKey);", + "knowledgebase": "knowledge base M.Docs (Provider: MxCloudGenAI, Key: @M.KBApiKey);", "agent": "agent M.Summarizer (UsageType: Task, Model: M.GPT4, SystemPrompt: 'Summarize.', UserPrompt: 'Text.');", "nanoflow": "nanoflow M.NF_Validate () begin return; end;", "rule": "rule M.Rule_IsSolvent ($c: M.Customer) returns Boolean begin return true; end;", diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index 9baa27d7f5..e6ea42c565 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -845,7 +845,9 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg return } if xc := propCtx.XpathConstraint(); xc != nil { - widget.Properties["VisibleIf"] = buildConditionalExpression(xc) + widget.Properties["VisibleIf"] = buildConditionalExpression(xc) // MDL-DEPR081 + } else if e := propCtx.Expression(); e != nil { + widget.Properties["VisibleIf"] = bareArgumentText(e) } else if valCtx := propCtx.PropertyValueV3(); valCtx != nil { widget.Properties["Visible"] = buildPropertyValueV3(valCtx) } @@ -855,7 +857,9 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg // Editable: [expression] (conditional editability) or Editable: Never (static) if propCtx.EDITABLE() != nil { if xc := propCtx.XpathConstraint(); xc != nil { - widget.Properties["EditableIf"] = buildConditionalExpression(xc) + widget.Properties["EditableIf"] = buildConditionalExpression(xc) // MDL-DEPR081 + } else if e := propCtx.Expression(); e != nil { + widget.Properties["EditableIf"] = bareArgumentText(e) } else if valCtx := propCtx.PropertyValueV3(); valCtx != nil { widget.Properties["Editable"] = buildPropertyValueV3(valCtx) } diff --git a/mdl/visitor/visitor_rest.go b/mdl/visitor/visitor_rest.go index 459469bac1..cda84ac66e 100644 --- a/mdl/visitor/visitor_rest.go +++ b/mdl/visitor/visitor_rest.go @@ -61,8 +61,10 @@ func (b *Builder) ExitCreateRestClientStatement(ctx *parser.CreateRestClientStat if sl := sp.STRING_LITERAL(); sl != nil { val = unquoteStringLit(sl) } else if v := sp.VARIABLE(); v != nil { - // $Constant reference (legacy) — keep $ prefix - val = v.GetText() + // $Constant (MDL-DEPR083): a constant of the service's own + // module, stored qualified as `@Module.Const` stores it. + val = "$" + dollarConstantName(stmt.Name.Module, v.GetText()) + b.recordDollarConstant(v, stmt.Name.Module) } else if sp.AT() != nil { // @Module.Constant reference (preferred Mendix convention) // Store with $ prefix so the writer serializes as Rest$ConstantValue diff --git a/mdl/visitor/visitor_revoke_entity_test.go b/mdl/visitor/visitor_revoke_entity_test.go new file mode 100644 index 0000000000..2dc73d8ddc --- /dev/null +++ b/mdl/visitor/visitor_revoke_entity_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// R5 (ako/mxcli#753): the entity revoke mirrors the grant — rights (or `all`) +// first, `on entity`, the roles after `from`. The reversed form is the +// deprecated alias MDL-DEPR082 and builds the same statement. + +func revokeStmt(t *testing.T, src string) (*ast.RevokeEntityAccessStmt, *ast.Program) { + t.Helper() + prog := mustBuild(t, src) + if len(prog.Statements) != 1 { + t.Fatalf("%q: %d statements, want 1", src, len(prog.Statements)) + } + r, ok := prog.Statements[0].(*ast.RevokeEntityAccessStmt) + if !ok { + t.Fatalf("%q: built %T", src, prog.Statements[0]) + } + return r, prog +} + +func TestRevokeEntity_CanonicalFormAndAlias(t *testing.T) { + for _, tc := range []struct { + old, canonical string + partial bool + }{ + {"revoke Shop.User, Shop.Admin on Shop.Order (write (Email), delete);", + "revoke write (Email), delete on entity Shop.Order from Shop.User, Shop.Admin;", true}, + {"revoke Shop.User on Shop.Order;", "revoke all on entity Shop.Order from Shop.User;", false}, + {"REVOKE Shop.User ON Shop.Order (READ *);", "REVOKE READ * ON ENTITY Shop.Order FROM Shop.User;", true}, + } { + old, oldProg := revokeStmt(t, tc.old) + canon, canonProg := revokeStmt(t, tc.canonical) + if !reflect.DeepEqual(old, canon) { + t.Errorf("%s built %#v\n%s built %#v", tc.old, old, tc.canonical, canon) + } + if (len(canon.Rights) > 0) != tc.partial || len(canon.Roles) == 0 || canon.Entity.Name != "Order" { + t.Errorf("%s built %#v", tc.canonical, canon) + } + if got := deprecationCodes(oldProg); !reflect.DeepEqual(got, []string{deprecation.ReversedEntityRevoke}) { + t.Errorf("%s recorded %v, want [%s]", tc.old, got, deprecation.ReversedEntityRevoke) + } + if got := deprecationCodes(canonProg); len(got) != 0 { + t.Errorf("%s recorded %v, want none", tc.canonical, got) + } + } +} diff --git a/mdl/visitor/visitor_security.go b/mdl/visitor/visitor_security.go index f6e4ca7409..6cac255ea9 100644 --- a/mdl/visitor/visitor_security.go +++ b/mdl/visitor/visitor_security.go @@ -147,7 +147,8 @@ func (b *Builder) ExitGrantEntityAccessStatement(ctx *parser.GrantEntityAccessSt b.statements = append(b.statements, stmt) } -// ExitRevokeEntityAccessStatement handles REVOKE role1, role2 ON Module.Entity [(rights...)] +// ExitRevokeEntityAccessStatement handles REVOKE rights|ALL ON ENTITY Module.Entity FROM role1, role2, +// and its deprecated alias REVOKE role1, role2 ON Module.Entity [(rights...)]. func (b *Builder) ExitRevokeEntityAccessStatement(ctx *parser.RevokeEntityAccessStatementContext) { qn := ctx.QualifiedName() if qn == nil { @@ -164,13 +165,17 @@ func (b *Builder) ExitRevokeEntityAccessStatement(ctx *parser.RevokeEntityAccess } } - // Parse optional rights list for partial revoke + // Parse optional rights list for partial revoke; `all` (or, in the old + // form, no list) is the full revoke. if earl := ctx.EntityAccessRightList(); earl != nil { for _, ear := range earl.AllEntityAccessRight() { right := parseEntityAccessRight(ear) stmt.Rights = append(stmt.Rights, right) } } + if ctx.ENTITY() == nil { + b.recordReversedEntityRevoke(ctx) + } b.statements = append(b.statements, stmt) } diff --git a/mdl/visitor/visitor_settings.go b/mdl/visitor/visitor_settings.go index 2d601cb6d3..a7f8d92bdc 100644 --- a/mdl/visitor/visitor_settings.go +++ b/mdl/visitor/visitor_settings.go @@ -16,38 +16,31 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext } if ctx.DROP() != nil && ctx.CONSTANT() != nil { - // ALTER SETTINGS DROP CONSTANT 'name' [IN CONFIGURATION 'cfg'] + // ALTER SETTINGS DROP CONSTANT @name [IN CONFIGURATION 'cfg'] stmt.Section = "constant" stmt.DropConstant = true - allStrings := ctx.AllSTRING_LITERAL() - if len(allStrings) > 0 { - stmt.ConstantId = unquoteStringLit(allStrings[0]) - } - if ctx.IN() != nil && ctx.CONFIGURATION() != nil && len(allStrings) > 1 { - stmt.ConfigName = unquoteStringLit(allStrings[1]) + stmt.ConstantId = settingsConstantRefText(ctx.SettingsConstantRef()) + if ctx.IN() != nil && ctx.CONFIGURATION() != nil && ctx.STRING_LITERAL() != nil { + stmt.ConfigName = unquoteStringLit(ctx.STRING_LITERAL()) } } else if ctx.CONSTANT() != nil { - // ALTER SETTINGS CONSTANT 'name' (VALUE 'value' | DROP) [IN CONFIGURATION 'cfg'] + // ALTER SETTINGS CONSTANT @name (VALUE 'value' | DROP) [IN CONFIGURATION 'cfg'] stmt.Section = "constant" - allStrings := ctx.AllSTRING_LITERAL() - if len(allStrings) > 0 { - stmt.ConstantId = unquoteStringLit(allStrings[0]) - } + stmt.ConstantId = settingsConstantRefText(ctx.SettingsConstantRef()) if ctx.DROP() != nil { stmt.DropConstant = true } else if ctx.SettingsValue() != nil { stmt.Value = settingsValueText(ctx.SettingsValue().(*parser.SettingsValueContext)) } // Check for IN CONFIGURATION 'name' - if ctx.IN() != nil && ctx.CONFIGURATION() != nil && len(allStrings) > 1 { - stmt.ConfigName = unquoteStringLit(allStrings[1]) + if ctx.IN() != nil && ctx.CONFIGURATION() != nil && ctx.STRING_LITERAL() != nil { + stmt.ConfigName = unquoteStringLit(ctx.STRING_LITERAL()) } } else if ctx.CONFIGURATION() != nil { // ALTER SETTINGS CONFIGURATION 'name' Key = Value, ... stmt.Section = "configuration" - allStrings := ctx.AllSTRING_LITERAL() - if len(allStrings) > 0 { - stmt.ConfigName = unquoteStringLit(allStrings[0]) + if lit := ctx.STRING_LITERAL(); lit != nil { + stmt.ConfigName = unquoteStringLit(lit) } for _, assignCtx := range ctx.AllSettingsAssignment() { assign, ok := assignCtx.(*parser.SettingsAssignmentContext) @@ -74,8 +67,8 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext stmt.AddGroup = ctx.ADD() != nil && !stmt.UpsertGroup stmt.ModifyGroup = ctx.MODIFY() != nil && !stmt.UpsertGroup stmt.RemoveGroup = ctx.REMOVE() != nil - if all := ctx.AllSTRING_LITERAL(); len(all) > 0 { - stmt.GroupName = unquoteStringLit(all[0]) + if lit := ctx.STRING_LITERAL(); lit != nil { + stmt.GroupName = unquoteStringLit(lit) } collectSettingsItemOptions(ctx.SettingsItemOptions(), stmt.Properties) } else if ctx.SettingsSection() != nil && (ctx.ADD() != nil || ctx.MODIFY() != nil || ctx.REMOVE() != nil) { @@ -87,8 +80,8 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext stmt.AddLanguage = ctx.ADD() != nil && !stmt.UpsertLanguage stmt.ModifyLanguage = ctx.MODIFY() != nil && !stmt.UpsertLanguage stmt.RemoveLanguage = ctx.REMOVE() != nil - if all := ctx.AllSTRING_LITERAL(); len(all) > 0 { - stmt.LanguageCode = unquoteStringLit(all[0]) + if lit := ctx.STRING_LITERAL(); lit != nil { + stmt.LanguageCode = unquoteStringLit(lit) } collectSettingsItemOptions(ctx.SettingsItemOptions(), stmt.Properties) } else if ctx.SettingsSection() != nil { diff --git a/mdl/visitor/visitor_strict_properties.go b/mdl/visitor/visitor_strict_properties.go index 75c0ae8eae..2c5d42162d 100644 --- a/mdl/visitor/visitor_strict_properties.go +++ b/mdl/visitor/visitor_strict_properties.go @@ -195,7 +195,7 @@ var businessEventServiceSchema = propSchema{on: "a business event service", keys // written as a string was dropped. var modelSchema = propSchema{on: "a model", keys: []propKey{ {"Provider", []propShape{shapeName}}, - {"Key", []propShape{shapeQName}}, + {"Key", []propShape{shapeConstant, shapeQName}}, {"DisplayName", []propShape{shapeString}}, {"KeyName", []propShape{shapeString}}, {"KeyId", []propShape{shapeString}}, @@ -206,7 +206,7 @@ var modelSchema = propSchema{on: "a model", keys: []propKey{ var knowledgeBaseSchema = propSchema{on: "a knowledge base", keys: []propKey{ {"Provider", nameShapes}, - {"Key", []propShape{shapeQName}}, + {"Key", []propShape{shapeConstant, shapeQName}}, {"ModelDisplayName", []propShape{shapeString}}, {"ModelName", []propShape{shapeString}}, {"KeyName", []propShape{shapeString}}, @@ -266,6 +266,8 @@ var agentKnowledgeBaseSchema = propSchema{on: "an agent's knowledge base block", func modelPropertyShape(pc *parser.ModelPropertyContext) propShape { idents := pc.AllIdentifierOrKeyword() switch { + case pc.AT() != nil: + return shapeConstant case pc.QualifiedName() != nil: return shapeQName case pc.STRING_LITERAL() != nil: diff --git a/mdl/visitor/visitor_widget_conditions.go b/mdl/visitor/visitor_widget_conditions.go new file mode 100644 index 0000000000..d07b2145e4 --- /dev/null +++ b/mdl/visitor/visitor_widget_conditions.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "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" +) + +// R5 (ADR-0010; ako/mxcli#753): a page widget's conditional visibility and +// editability are client expressions, written bare — `Visible: +// $currentObject/Status = 'Open'` — and stored as written. `Visible: [expr]` +// wrote one in the brackets of an XPath constraint and rooted a bare +// attribute in $currentObject on the way in; it is the deprecated alias +// MDL-DEPR081, whose rewrite writes the expression the brackets stored. + +func isBooleanLiteral(s string) bool { + return strings.EqualFold(s, "true") || strings.EqualFold(s, "false") +} + +func visibleOrEditable(visible, editable antlr.TerminalNode) antlr.TerminalNode { + if visible != nil { + return visible + } + return editable +} + +// BareWidgetCondition reports whether `: ` (key Visible or +// Editable) reads back as a conditional expression storing exactly expr. +// describe asks it before writing a stored condition bare; an expression the +// plain-value reading claims (`true`, a lone name) or the `Attr in (…)` form +// claims does not, and is written in brackets instead. +func BareWidgetCondition(key, expr string) bool { + if !BareExpression(expr) { + return false + } + ctx, ok := parseRule(key+": "+expr, func(p *parser.MDLParser) antlr.ParserRuleContext { + return p.WidgetPropertyV3() + }) + if !ok { + return false + } + w := ctx.(*parser.WidgetPropertyV3Context) + if visibleOrEditable(w.VISIBLE(), w.EDITABLE()) == nil || w.Expression() == nil { + return false + } + return bareArgumentText(w.Expression()) == expr +} + +// ExitWidgetPropertyV3 records a bracketed Visible / Editable in a widget's +// properties. +func (b *Builder) ExitWidgetPropertyV3(ctx *parser.WidgetPropertyV3Context) { + b.recordBracketedWidgetCondition(visibleOrEditable(ctx.VISIBLE(), ctx.EDITABLE()), ctx.XpathConstraint()) +} + +// ExitAlterPageAssignment records a bracketed Visible / Editable in `alter +// page … set (…)`. +func (b *Builder) ExitAlterPageAssignment(ctx *parser.AlterPageAssignmentContext) { + b.recordBracketedWidgetCondition(visibleOrEditable(ctx.VISIBLE(), ctx.EDITABLE()), ctx.XpathConstraint()) +} + +// recordBracketedWidgetCondition records MDL-DEPR081 with the rewrite that +// replaces the brackets by the expression they store. There is none when that +// expression would not read back bare as itself. +func (b *Builder) recordBracketedWidgetCondition(kw antlr.TerminalNode, xc parser.IXpathConstraintContext) { + if kw == nil || xc == nil { + return + } + stored := buildConditionalExpression(xc) + if isBooleanLiteral(stored) { + // `Editable: [false]` stores a constant CONDITION; bare, `false` is the + // plain value, which Studio Pro stores differently. A constant + // condition has no bare spelling, so the brackets are not an alias + // for one here. + return + } + b.recordDeprecation(deprecation.BracketedWidgetCondition, kw.GetSymbol(), kw.GetText()) + key := "Visible" + if kw.GetSymbol().GetTokenType() == parser.MDLLexerEDITABLE { + key = "Editable" + } + if holdsInterpretedEscape(xc) || !BareWidgetCondition(key, stored) { + b.fixLastDeprecation(deprecation.BracketedWidgetCondition, nil, + "the condition "+nodeText(xc)+" stores "+stored+", which does not read back as the same bare expression; "+ + "write it bare by hand") + return + } + b.fixLastDeprecation(deprecation.BracketedWidgetCondition, + &ast.Fix{Edits: []ast.TextEdit{replaceSpan(xc.GetStart(), xc.GetStop(), stored)}}, "") +} diff --git a/mdl/visitor/visitor_widget_conditions_test.go b/mdl/visitor/visitor_widget_conditions_test.go new file mode 100644 index 0000000000..4240921274 --- /dev/null +++ b/mdl/visitor/visitor_widget_conditions_test.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// R5 (ako/mxcli#753): a conditional Visible / Editable is a bare expression, +// stored as written. `Visible: [expr]` is the deprecated alias MDL-DEPR081. + +func textboxProps(t *testing.T, props string) (map[string]any, *ast.Program) { + t.Helper() + prog := mustBuild(t, "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { dataview dv (DataSource: $E) { textbox t (Attribute: Name, "+props+") } };") + return prog.Statements[0].(*ast.CreatePageStmtV3).Widgets[0].Children[0].Properties, prog +} + +func TestWidgetCondition_BareIsCanonical(t *testing.T) { + for _, tc := range []struct{ bracketed, bare string }{ + {"Visible: [Active]", "Visible: $currentObject/Active"}, + {"Visible: [$currentObject/Price > 3 and Active]", "Visible: $currentObject/Price > 3 and $currentObject/Active"}, + {"Editable: [$currentObject/Status = 'Open']", "Editable: $currentObject/Status = 'Open'"}, + {"Visible: [not($currentObject/Active)]", "Visible: not($currentObject/Active)"}, + } { + old, oldProg := textboxProps(t, tc.bracketed) + canon, canonProg := textboxProps(t, tc.bare) + if !reflect.DeepEqual(old, canon) { + t.Errorf("%s built %#v\n%s built %#v", tc.bracketed, old, tc.bare, canon) + } + if got := deprecationCodes(oldProg); !reflect.DeepEqual(got, []string{deprecation.BracketedWidgetCondition}) { + t.Errorf("%s recorded %v, want [%s]", tc.bracketed, got, deprecation.BracketedWidgetCondition) + } + if got := deprecationCodes(canonProg); len(got) != 0 { + t.Errorf("%s recorded %v, want none", tc.bare, got) + } + } +} + +// The bare form stores the expression as written: nothing is rooted, spacing +// is kept. +func TestWidgetCondition_BareIsStoredAsWritten(t *testing.T) { + props, _ := textboxProps(t, "Visible: $currentObject/Status = 'Open' or $Flag") + if got := props["VisibleIf"]; got != "$currentObject/Status = 'Open' or $Flag" { + t.Errorf("VisibleIf = %#v", got) + } +} + +// A plain value keeps its old reading: it is not a conditional expression. +func TestWidgetCondition_PlainValuesUnchanged(t *testing.T) { + for _, tc := range []struct { + props, key string + want any + }{ + {"Visible: false", "Visible", false}, + {"Visible: Active", "Visible", "Active"}, + {"Editable: Never", "Editable", "Never"}, + } { + props, prog := textboxProps(t, tc.props) + if props[tc.key] != tc.want || props["VisibleIf"] != nil || props["EditableIf"] != nil { + t.Errorf("%s built %#v", tc.props, props) + } + if got := deprecationCodes(prog); len(got) != 0 { + t.Errorf("%s recorded %v", tc.props, got) + } + } +} + +func alterSet(t *testing.T, set string) (map[string]any, *ast.Program) { + t.Helper() + prog := mustBuild(t, "alter page M.P { set ("+set+") on t };") + return prog.Statements[0].(*ast.AlterPageStmt).Operations[0].(*ast.SetPropertyOp).Properties, prog +} + +func TestWidgetCondition_AlterPageSet(t *testing.T) { + old, oldProg := alterSet(t, "Visible: [Active]") + canon, canonProg := alterSet(t, "Visible: $currentObject/Active") + if !reflect.DeepEqual(old, canon) || canon["VisibleIf"] != "$currentObject/Active" { + t.Errorf("bracketed built %#v, bare %#v", old, canon) + } + if got := deprecationCodes(oldProg); !reflect.DeepEqual(got, []string{deprecation.BracketedWidgetCondition}) { + t.Errorf("bracketed recorded %v", got) + } + if got := deprecationCodes(canonProg); len(got) != 0 { + t.Errorf("bare recorded %v", got) + } + if p, _ := alterSet(t, "Editable: $currentObject/Status != 'Closed'"); p["EditableIf"] != "$currentObject/Status != 'Closed'" { + t.Errorf("Editable built %#v", p) + } + if p, _ := alterSet(t, "Visible: false"); p["Visible"] != false { + t.Errorf("Visible: false built %#v", p) + } +} + +func TestBareWidgetCondition(t *testing.T) { + for _, tc := range []struct { + expr string + want bool + }{ + {"$currentObject/Active", true}, + {"$currentObject/Status = 'Open'", true}, + {"true", false}, // the plain value + {"Active", false}, // a plain name + {"Status in (A, B)", false}, // `based on attribute value` + {"", false}, + } { + if got := BareWidgetCondition("Visible", tc.expr); got != tc.want { + t.Errorf("BareWidgetCondition(%q) = %v, want %v", tc.expr, got, tc.want) + } + } +} diff --git a/mdl/visitor/visitor_workflow.go b/mdl/visitor/visitor_workflow.go index 85e0ef1823..4ed84a9a92 100644 --- a/mdl/visitor/visitor_workflow.go +++ b/mdl/visitor/visitor_workflow.go @@ -74,9 +74,7 @@ func (b *Builder) ExitCreateWorkflowStatement(ctx *parser.CreateWorkflowStatemen stmt.OverviewPage = buildQualifiedName(qn) } case hc.DUE() != nil && hc.DATE_TYPE() != nil: - if tok := hc.GetDueDate(); tok != nil { - stmt.DueDate = unquoteStringLit(tok) - } + stmt.DueDate = workflowExpressionText(hc.GetDueDate()) case hc.WorkflowEventHandlerClause() != nil: h, ok := hc.WorkflowEventHandlerClause().(*parser.WorkflowEventHandlerClauseContext) if !ok { @@ -354,7 +352,7 @@ func buildWorkflowSetPropertyOp(ctx *parser.WorkflowSetPropertyContext) *ast.Set } } else if ctx.DUE() != nil { op.Property = "due_date" - op.Value = unquoteStringLit(ctx.STRING_LITERAL()) + op.Value = workflowExpressionText(ctx.WorkflowExpression()) } else if ctx.OVERVIEW() != nil { op.Property = "overview_page" if qn := ctx.QualifiedName(); qn != nil { @@ -400,7 +398,7 @@ func buildActivitySetPropertyOp(ctx *parser.ActivitySetPropertyContext, ref stri } } else if ctx.DUE() != nil { op.Property = "due_date" - op.Value = unquoteStringLit(ctx.STRING_LITERAL()) + op.Value = workflowExpressionText(ctx.WorkflowExpression()) } return op @@ -618,9 +616,7 @@ func applyWorkflowUserTaskClause(node *ast.WorkflowUserTaskNode, clause parser.I node.Entity = buildQualifiedName(qn) } case c.DUE() != nil && c.DATE_TYPE() != nil: - if str := c.STRING_LITERAL(); str != nil { - node.DueDate = unquoteStringLit(str) - } + node.DueDate = workflowExpressionText(c.WorkflowExpression()) case c.DESCRIPTION() != nil: if str := c.STRING_LITERAL(); str != nil { node.TaskDescription = unquoteStringLit(str) @@ -763,30 +759,11 @@ func buildWorkflowCallWorkflow(ctx parser.IWorkflowCallWorkflowStmtContext) *ast // buildWorkflowDecision builds a WorkflowDecisionNode. func buildWorkflowDecision(ctx parser.IWorkflowDecisionStmtContext) *ast.WorkflowDecisionNode { dCtx := ctx.(*parser.WorkflowDecisionStmtContext) - node := &ast.WorkflowDecisionNode{ - Name: workflowActivityNameText(dCtx.WorkflowActivityName()), - } - - allStrings := dCtx.AllSTRING_LITERAL() - stringIdx := 0 - - // 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 = unquoteStringLit(allStrings[0]) - stringIdx = 1 - } else if len(allStrings) > 0 && dCtx.COMMENT() != nil { - // Distinguish expression from comment - if len(allStrings) >= 2 { - node.Expression = unquoteStringLit(allStrings[0]) - node.Caption = unquoteStringLit(allStrings[1]) - } else { - // Only one string with COMMENT - it's the caption - node.Caption = unquoteStringLit(allStrings[0]) - } - stringIdx = len(allStrings) + node := &ast.WorkflowDecisionNode{} + node.Name, node.Expression = activityNameAndExpression(dCtx.WorkflowActivityName(), dCtx.WorkflowExpression()) + if dCtx.COMMENT() != nil && dCtx.STRING_LITERAL() != nil { + node.Caption = unquoteStringLit(dCtx.STRING_LITERAL()) } - _ = stringIdx for _, outcomeCtx := range dCtx.AllWorkflowConditionOutcome() { outcome := buildWorkflowConditionOutcome(outcomeCtx) @@ -878,18 +855,10 @@ func buildWorkflowJumpTo(ctx parser.IWorkflowJumpToStmtContext) *ast.WorkflowJum // buildWorkflowWaitForTimer builds a WorkflowWaitForTimerNode. func buildWorkflowWaitForTimer(ctx parser.IWorkflowWaitForTimerStmtContext) *ast.WorkflowWaitForTimerNode { wtCtx := ctx.(*parser.WorkflowWaitForTimerStmtContext) - node := &ast.WorkflowWaitForTimerNode{ - Name: workflowActivityNameText(wtCtx.WorkflowActivityName()), - } - - allStrings := wtCtx.AllSTRING_LITERAL() - if len(allStrings) > 0 && wtCtx.COMMENT() == nil { - node.DelayExpression = unquoteStringLit(allStrings[0]) - } else if len(allStrings) >= 2 && wtCtx.COMMENT() != nil { - node.DelayExpression = unquoteStringLit(allStrings[0]) - node.Caption = unquoteStringLit(allStrings[1]) - } else if len(allStrings) == 1 && wtCtx.COMMENT() != nil { - node.Caption = unquoteStringLit(allStrings[0]) + node := &ast.WorkflowWaitForTimerNode{} + node.Name, node.DelayExpression = activityNameAndExpression(wtCtx.WorkflowActivityName(), wtCtx.WorkflowExpression()) + if wtCtx.COMMENT() != nil && wtCtx.STRING_LITERAL() != nil { + node.Caption = unquoteStringLit(wtCtx.STRING_LITERAL()) } return node @@ -931,13 +900,13 @@ func buildBoundaryEventNode(beCtx parser.IWorkflowBoundaryEventClauseContext) as default: be.EventType = "Timer" } - // 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 = unquoteStringLit(s) - } else { - be.Delay = unquoteStringLit(s) - } + // A timer's expression is its delay; a notification event's string is its + // caption. + if s := beCtx2.STRING_LITERAL(); s != nil && notification { + be.Caption = unquoteStringLit(s) + } + if !notification { + be.Delay = workflowExpressionText(beCtx2.WorkflowExpression()) } if notification { be.Name = workflowActivityNameText(beCtx2.WorkflowActivityName()) @@ -973,17 +942,11 @@ func buildWorkflowEventSubProcess(ctx parser.IWorkflowEventSubProcessContext) as if t, ok := c.WorkflowEventSubProcessTrigger().(*parser.WorkflowEventSubProcessTriggerContext); ok && t != nil { node.Timer = t.TIMER() != nil node.StartName = workflowActivityNameText(t.WorkflowActivityName()) - strs := t.AllSTRING_LITERAL() - switch { - case node.Timer: - if len(strs) > 0 { - node.FirstExecutionTime = unquoteStringLit(strs[0]) - } - if t.COMMENT() != nil && len(strs) > 1 { - node.StartCaption = unquoteStringLit(strs[1]) - } - case len(strs) > 0: - node.StartCaption = unquoteStringLit(strs[0]) + if node.Timer { + node.FirstExecutionTime = workflowExpressionText(t.WorkflowExpression()) + } + if s := t.STRING_LITERAL(); s != nil { + node.StartCaption = unquoteStringLit(s) } } if body := c.WorkflowBody(); body != nil { diff --git a/mdl/visitor/visitor_workflow_expressions.go b/mdl/visitor/visitor_workflow_expressions.go new file mode 100644 index 0000000000..5a1b5b40a4 --- /dev/null +++ b/mdl/visitor/visitor_workflow_expressions.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + "unicode" + + "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" +) + +// R5 (ADR-0010; ako/mxcli#753): an expression is written bare, never in a +// string. A workflow stores five of them — a decision's condition, `wait for +// timer`'s delay, a timer boundary event's delay, a timer event sub-process's +// first execution time and a due date — and each took a string whose CONTENT +// was the expression. The bare form stores its source text, as a bare workflow +// call argument does (#751); the string form is the deprecated alias +// MDL-DEPR080 and keeps its meaning under every language version, since none +// of these slots takes a string-valued expression. + +// workflowExpressionText is the expression a workflowExpression stores: the +// string's content for the deprecated form, the source text for the bare one. +func workflowExpressionText(ctx parser.IWorkflowExpressionContext) string { + if ctx == nil { + return "" + } + if lit := ctx.STRING_LITERAL(); lit != nil { + return unquoteStringLit(lit) + } + if e := ctx.Expression(); e != nil { + return bareArgumentText(e) + } + return "" +} + +// activityNameAndExpression reads the `name? expression?` slot pair of a +// decision and a `wait for timer`. An identifier directly followed by `(` is +// a function call, not a name and a parenthesised expression: `decision +// length($s) > 0` is one expression. The grammar alone reads it as the name +// `length` and the expression `($s) > 0`, since both parses are valid and the +// name comes first. +func activityNameAndExpression(name parser.IWorkflowActivityNameContext, expr parser.IWorkflowExpressionContext) (string, string) { + if name == nil || expr == nil || expr.Expression() == nil { + return workflowActivityNameText(name), workflowExpressionText(expr) + } + nameStop, exprStart := name.GetStop(), expr.GetStart() + if nameStop != nil && exprStart != nil && exprStart.GetTokenType() == parser.MDLLexerLPAREN && + nameStop.GetStop()+1 == exprStart.GetStart() { + joined := extractExpressionText(name) + extractExpressionText(expr) + if ctx, ok := parseRule(joined, func(p *parser.MDLParser) antlr.ParserRuleContext { return p.Expression() }); ok { + return "", bareArgumentText(ctx.(parser.IExpressionContext)) + } + } + return workflowActivityNameText(name), workflowExpressionText(expr) +} + +// WorkflowDecisionReadsBack reports whether `decision ` +// builds a decision with exactly that expression, and a name when nameClause +// (empty, or a space and the name as describe writes it) has one. describe +// asks it before writing a decision's condition bare: an expression that +// starts with a word can otherwise be read as the activity's name. +func WorkflowDecisionReadsBack(nameClause, expr string) bool { + ctx, ok := parseRule("decision"+nameClause+" "+expr, func(p *parser.MDLParser) antlr.ParserRuleContext { + return p.WorkflowDecisionStmt() + }) + if !ok { + return false + } + n := buildWorkflowDecision(ctx.(parser.IWorkflowDecisionStmtContext)) + return n.Expression == expr && (n.Name == "") == (nameClause == "") +} + +// WorkflowWaitForTimerReadsBack is WorkflowDecisionReadsBack for `wait for +// timer `. +func WorkflowWaitForTimerReadsBack(nameClause, expr string) bool { + ctx, ok := parseRule("wait for timer"+nameClause+" "+expr, func(p *parser.MDLParser) antlr.ParserRuleContext { + return p.WorkflowWaitForTimerStmt() + }) + if !ok { + return false + } + n := buildWorkflowWaitForTimer(ctx.(parser.IWorkflowWaitForTimerStmtContext)) + return n.DelayExpression == expr && (n.Name == "") == (nameClause == "") +} + +// ExitWorkflowExpression records MDL-DEPR080 on an expression written in a +// string, with the rewrite that takes it out. +func (b *Builder) ExitWorkflowExpression(ctx *parser.WorkflowExpressionContext) { + lit := ctx.STRING_LITERAL() + if lit == nil || unquoteStringLit(lit) == "" { + // `''` is no expression at all — `alter workflow … set due date ''` + // clears it — so it is not one written in a string. It has no bare + // spelling and stays as it is. + return + } + b.recordDeprecation(deprecation.WorkflowStringExpression, lit.GetSymbol(), "workflow expression") + fix, why := workflowStringExpressionFix(ctx, lit) + b.fixLastDeprecation(deprecation.WorkflowStringExpression, fix, why) +} + +// workflowStringExpressionFix replaces the string by its content. It has none +// when the content is not a bare expression that reads back as itself, or — +// for a decision or `wait for timer` with no name — when the bare expression +// would be read as the activity's name. +func workflowStringExpressionFix(ctx *parser.WorkflowExpressionContext, lit antlr.TerminalNode) (*ast.Fix, string) { + if holdsInterpretedEscape(lit) { + return nil, "the expression string holds a backslash escape; write the expression bare by hand" + } + expr := unquoteStringLit(lit) + why := "the string " + lit.GetText() + " does not read back as the same bare expression; write it bare by hand" + if !BareExpression(expr) { + return nil, why + } + switch p := ctx.GetParent().(type) { + case *parser.WorkflowDecisionStmtContext: + if p.WorkflowActivityName() == nil && !WorkflowDecisionReadsBack("", expr) { + return nil, why + } + case *parser.WorkflowWaitForTimerStmtContext: + if p.WorkflowActivityName() == nil && !WorkflowWaitForTimerReadsBack("", expr) { + return nil, why + } + } + t := lit.GetSymbol() + // A string needs no space to part it from its neighbours; a bare + // expression does: `timer'x'comment 'c'`. + if is := t.GetInputStream(); is != nil { + if t.GetStart() > 0 && gluesToWord(is.GetText(t.GetStart()-1, t.GetStart()-1)) { + expr = " " + expr + } + if t.GetStop()+1 < is.Size() && gluesToWord(is.GetText(t.GetStop()+1, t.GetStop()+1)) { + expr += " " + } + } + return &ast.Fix{Edits: []ast.TextEdit{{Start: t.GetStart(), Stop: t.GetStop() + 1, Text: expr}}}, "" +} + +// gluesToWord reports whether a neighbouring character would lex as part of +// a bare expression's first or last token. +func gluesToWord(s string) bool { + for _, r := range s { + return unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("_$'\"`%", r) + } + return false +} diff --git a/mdl/visitor/visitor_workflow_expressions_test.go b/mdl/visitor/visitor_workflow_expressions_test.go new file mode 100644 index 0000000000..824b0e9d59 --- /dev/null +++ b/mdl/visitor/visitor_workflow_expressions_test.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// R5 (ako/mxcli#753): a workflow's expressions — a decision's condition, a +// timer's delay or first execution time, a due date — are written bare. The +// string form is the deprecated alias MDL-DEPR080 and builds exactly what the +// bare form builds: its content is the expression. + +const wfHead = "create workflow M.WF\n parameter $WorkflowContext: M.E\n" + +func TestWorkflowExpression_BareFormIsCanonical(t *testing.T) { + for _, tc := range []struct{ name, quoted, bare string }{ + {"decision", + wfHead + "begin\n decision '$WorkflowContext/Total > 1000' comment 'Large?'\n outcomes true -> { } false -> { };\nend workflow;", + wfHead + "begin\n decision $WorkflowContext/Total > 1000 comment 'Large?'\n outcomes true -> { } false -> { };\nend workflow;"}, + {"named decision", + wfHead + "begin\n decision Big 'toLowerCase($WorkflowContext/Name) = ''big'''\n outcomes true -> { } false -> { };\nend workflow;", + wfHead + "begin\n decision Big toLowerCase($WorkflowContext/Name) = 'big'\n outcomes true -> { } false -> { };\nend workflow;"}, + {"decision starting with a function call", + wfHead + "begin\n decision 'toLowerCase($WorkflowContext/Name) = ''big'''\n outcomes true -> { } false -> { };\nend workflow;", + wfHead + "begin\n decision toLowerCase($WorkflowContext/Name) = 'big'\n outcomes true -> { } false -> { };\nend workflow;"}, + {"wait for timer", + wfHead + "begin\n wait for timer 'addHours([%CurrentDateTime%], 1)' comment 'Wait';\nend workflow;", + wfHead + "begin\n wait for timer addHours([%CurrentDateTime%], 1) comment 'Wait';\nend workflow;"}, + {"named wait for timer", + wfHead + "begin\n wait for timer T1 'addHours([%CurrentDateTime%], 1)';\nend workflow;", + wfHead + "begin\n wait for timer T1 addHours([%CurrentDateTime%], 1);\nend workflow;"}, + {"workflow and user task due dates, boundary timers", + wfHead + " due date 'addDays([%CurrentDateTime%], 2)'\nbegin\n user task Review 'Review'\n due date 'addHours([%CurrentDateTime%], 4)'\n" + + " outcomes 'Done' { }\n boundary event interrupting timer 'addHours([%CurrentDateTime%], 8)' { }\n" + + " boundary event non interrupting timer 'addHours([%CurrentDateTime%], 9)'\n boundary event timer 'addHours([%CurrentDateTime%], 10)';\nend workflow;", + wfHead + " due date addDays([%CurrentDateTime%], 2)\nbegin\n user task Review 'Review'\n due date addHours([%CurrentDateTime%], 4)\n" + + " outcomes 'Done' { }\n boundary event interrupting timer addHours([%CurrentDateTime%], 8) { }\n" + + " boundary event non interrupting timer addHours([%CurrentDateTime%], 9)\n boundary event timer addHours([%CurrentDateTime%], 10);\nend workflow;"}, + {"timer event sub-process", + wfHead + "begin\n event subprocess Esp on interrupting timer 'addDays([%CurrentDateTime%], 30)' as Start comment 'After 30 days' { };\nend workflow;", + wfHead + "begin\n event subprocess Esp on interrupting timer addDays([%CurrentDateTime%], 30) as Start comment 'After 30 days' { };\nend workflow;"}, + {"alter workflow due date", + "alter workflow M.WF set due date 'addDays([%CurrentDateTime%], 1)';", + "alter workflow M.WF set due date addDays([%CurrentDateTime%], 1);"}, + {"alter activity due date", + "alter workflow M.WF set activity Review due date 'addDays([%CurrentDateTime%], 1)';", + "alter workflow M.WF set activity Review due date addDays([%CurrentDateTime%], 1);"}, + {"inserted wait for timer", + "alter workflow M.WF insert after Review wait for timer 'addHours([%CurrentDateTime%], 1)' comment 'W';", + "alter workflow M.WF insert after Review wait for timer addHours([%CurrentDateTime%], 1) comment 'W';"}, + } { + t.Run(tc.name, func(t *testing.T) { + old, canon := mustBuild(t, tc.quoted), mustBuild(t, tc.bare) + if !reflect.DeepEqual(old.Statements, canon.Statements) { + t.Errorf("string form built %#v\nbare form %#v", old.Statements, canon.Statements) + } + for _, d := range old.Deprecations { + if d.Code != deprecation.WorkflowStringExpression { + t.Errorf("string form recorded %s", d.Code) + } + } + if len(old.Deprecations) == 0 { + t.Errorf("string form recorded no %s", deprecation.WorkflowStringExpression) + } + if got := deprecationCodes(canon); len(got) != 0 { + t.Errorf("bare form recorded %v, want none", got) + } + }) + } +} + +func decisionOf(t *testing.T, body string) *ast.WorkflowDecisionNode { + t.Helper() + prog := mustBuild(t, wfHead+"begin\n "+body+"\n outcomes true -> { } false -> { };\nend workflow;") + wf := prog.Statements[0].(*ast.CreateWorkflowStmt) + return wf.Activities[0].(*ast.WorkflowDecisionNode) +} + +// The name slot before a decision's expression: a name followed by a space is +// a name; a word directly followed by `(` is a function call. +func TestWorkflowExpression_DecisionNameOrCall(t *testing.T) { + for _, tc := range []struct{ body, name, expr string }{ + {"decision toLowerCase($WorkflowContext/Name) = 'big'", "", "toLowerCase($WorkflowContext/Name) = 'big'"}, + {"decision BigOrder toLowerCase($WorkflowContext/Name) = 'big'", "BigOrder", "toLowerCase($WorkflowContext/Name) = 'big'"}, + {"decision BigOrder $WorkflowContext/Big", "BigOrder", "$WorkflowContext/Big"}, + {"decision BigOrder comment 'Is it big?'", "BigOrder", ""}, + {"decision $WorkflowContext/Big", "", "$WorkflowContext/Big"}, + } { + d := decisionOf(t, tc.body) + if d.Name != tc.name || d.Expression != tc.expr { + t.Errorf("%q: name %q expression %q, want %q %q", tc.body, d.Name, d.Expression, tc.name, tc.expr) + } + } +} + +// `set due date ”` clears the due date: it is no expression at all, so it is +// not an expression written in a string. +func TestWorkflowExpression_EmptyStringIsNotDeprecated(t *testing.T) { + prog := mustBuild(t, "alter workflow M.WF set due date '';") + if got := deprecationCodes(prog); len(got) != 0 { + t.Errorf("recorded %v, want none", got) + } +} + +func TestWorkflowDecisionReadsBack(t *testing.T) { + for _, tc := range []struct { + nameClause, expr string + want bool + }{ + {"", "$WorkflowContext/Total > 1000", true}, + {"", "toLowerCase($WorkflowContext/Name) = 'big'", true}, + {"", "Total + 1 > 3", false}, // `Total` would be the name + {" decision1", "Total + 1 > 3", true}, + } { + if got := WorkflowDecisionReadsBack(tc.nameClause, tc.expr); got != tc.want { + t.Errorf("WorkflowDecisionReadsBack(%q, %q) = %v, want %v", tc.nameClause, tc.expr, got, tc.want) + } + } +} diff --git a/mdl/visitor/visitor_xpath_brackets.go b/mdl/visitor/visitor_xpath_brackets.go index b1f46488ac..51efc8f235 100644 --- a/mdl/visitor/visitor_xpath_brackets.go +++ b/mdl/visitor/visitor_xpath_brackets.go @@ -141,6 +141,37 @@ func (b *Builder) recordReversedEntityGrant(ctx *parser.GrantEntityAccessStateme b.fixLastDeprecation(deprecation.ReversedEntityGrant, &ast.Fix{Edits: edits}, "") } +// recordReversedEntityRevoke records MDL-DEPR082 on the reversed revoke and +// computes its rewrite: `revoke R1, R2 on M.E [(rights)]` becomes `revoke +// rights|all on entity M.E from R1, R2`. Roles, entity and rights are moved as +// written. +func (b *Builder) recordReversedEntityRevoke(ctx *parser.RevokeEntityAccessStatementContext) { + revoke := ctx.REVOKE().GetSymbol() + b.recordDeprecation(deprecation.ReversedEntityRevoke, revoke, "") + + roles, ok1 := ctx.ModuleRoleList().(antlr.ParserRuleContext) + entity, ok2 := ctx.QualifiedName().(antlr.ParserRuleContext) + if !ok1 || !ok2 || entity.GetStop() == nil { + b.fixLastDeprecation(deprecation.ReversedEntityRevoke, nil, "the statement is incomplete") + return + } + like := revoke.GetText() + rights := keywordLike(like, "all") + last := entity.GetStop() + if list, ok := ctx.EntityAccessRightList().(antlr.ParserRuleContext); ok && list != nil { + if ctx.RPAREN() == nil { + b.fixLastDeprecation(deprecation.ReversedEntityRevoke, nil, "the statement is incomplete") + return + } + rights = nodeText(list) + last = ctx.RPAREN().GetSymbol() + } + text := rights + " " + keywordLike(like, "on") + " " + keywordLike(like, "entity") + " " + + nodeText(entity) + " " + keywordLike(like, "from") + " " + nodeText(roles) + b.fixLastDeprecation(deprecation.ReversedEntityRevoke, + &ast.Fix{Edits: []ast.TextEdit{replaceSpan(roles.GetStart(), last, text)}}, "") +} + // recordQuotedTargetingXPath records MDL-DEPR031 on a quoted targeting XPath, // with the rewrite that takes the XPath out of its string. func (b *Builder) recordQuotedTargetingXPath(lit antlr.TerminalNode) { From 8af84328170412acf24ab71e60ac8208d1674f23 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 05:48:48 +0000 Subject: [PATCH 06/35] docs: R5 bare expressions, one constant reference, canonical revoke (#753) Skills, docs-site, mxcli syntax, MDL_QUICK_REFERENCE, the spec docs, the CHANGELOG and the mdl-examples doctype tests in the canonical forms. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/mendix/agents/SKILL.md | 6 +-- .claude/skills/mendix/alter-page/SKILL.md | 16 +++--- .../mendix/connect-rapidminer-graph/SKILL.md | 4 +- .claude/skills/mendix/create-page/SKILL.md | 53 +++++++++---------- .../mendix/create-page/reference/widgets.md | 2 +- .../skills/mendix/manage-security/SKILL.md | 8 +-- .../skills/mendix/project-settings/SKILL.md | 6 +-- .../skills/mendix/write-workflows/SKILL.md | 17 +++--- CHANGELOG.md | 6 ++- cmd/mxcli/syntax/features_domain_model.go | 2 +- cmd/mxcli/syntax/features_integration.go | 12 ++--- cmd/mxcli/syntax/features_misc.go | 6 +-- cmd/mxcli/syntax/features_page.go | 8 ++- cmd/mxcli/syntax/features_security.go | 12 +++-- cmd/mxcli/syntax/features_workflow.go | 32 +++++------ docs-site/src/appendixes/quick-reference.md | 4 +- .../src/appendixes/version-compatibility.md | 4 +- docs-site/src/examples/security.md | 6 +-- docs-site/src/language/entity-access.md | 6 +-- docs-site/src/language/grant-revoke.md | 8 +-- docs-site/src/language/pages.md | 8 +-- docs-site/src/language/project-settings.md | 4 +- docs-site/src/language/workflow-activities.md | 22 ++++---- docs-site/src/language/workflow-structure.md | 6 +-- .../reference/agent/create-knowledge-base.md | 6 +-- docs-site/src/reference/agent/create-model.md | 8 +-- .../reference/domain-model/create-constant.md | 2 +- docs-site/src/reference/security/revoke.md | 14 ++--- .../src/reference/settings/alter-settings.md | 17 +++--- .../src/reference/workflow/create-workflow.md | 16 +++--- docs/01-project/MDL_QUICK_REFERENCE.md | 32 +++++------ .../01-language-reference.md | 12 ++--- docs/05-mdl-specification/03-domain-model.md | 2 +- .../workflow-586-clause-order-canonical.mdl | 8 +-- .../bug-tests/workflow-586-clause-order.mdl | 8 +-- .../doctype-tests/03-page-examples.mdl | 2 +- .../doctype-tests/08-security-examples.mdl | 14 ++--- .../14-project-settings-examples.mdl | 8 +-- .../doctype-tests/24-workflow-examples.mdl | 22 ++++---- .../27-agent-editor-model-examples.mdl | 12 ++--- .../28-agent-editor-examples.mdl | 8 +-- .../use-cases/ai-agent-platform-demo.mdl | 6 +-- 42 files changed, 240 insertions(+), 215 deletions(-) diff --git a/.claude/skills/mendix/agents/SKILL.md b/.claude/skills/mendix/agents/SKILL.md index 218f74ea3d..ce87df5eb2 100644 --- a/.claude/skills/mendix/agents/SKILL.md +++ b/.claude/skills/mendix/agents/SKILL.md @@ -39,7 +39,7 @@ register `ASU_AgentEditor` as an after-startup microflow. ```sql create model Module.MyModel ( Provider: MxCloudGenAI, -- default, can omit - key: Module.ApiKeyConst -- must be a String constant + key: @Module.ApiKeyConst -- must be a String constant ); ``` @@ -64,7 +64,7 @@ document Studio Pro created**, rather than from memory or from this file. ```sql create knowledge base Module.ProductDocs ( Provider: MxCloudGenAI, - key: Module.KBKeyConst + key: @Module.KBKeyConst ); ``` @@ -173,7 +173,7 @@ The feature uses `CustomBlobDocument` BSON type with a `Contents` field holding ### Minimal agent (no tools) ```sql -create model Module.M (Provider: MxCloudGenAI, key: Module.K); +create model Module.M (Provider: MxCloudGenAI, key: @Module.K); create agent Module.A ( UsageType: task, model: Module.M, diff --git a/.claude/skills/mendix/alter-page/SKILL.md b/.claude/skills/mendix/alter-page/SKILL.md index 861a4fa7a7..318564a70c 100644 --- a/.claude/skills/mendix/alter-page/SKILL.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -193,16 +193,16 @@ so a silent write would build cleanly and then fail to open. | `PopupResizable` | Page-level only (case-sensitive) | Boolean | `set (PopupResizable: true)` | | `Class` | Page-level (case-sensitive, no ON) | CSS class string | `set (Class: 'container-fluid bg-light')` | | `Style` | Page-level (case-sensitive, no ON) | Inline CSS string | `set (Style: 'min-height: 100vh')` | -| `Visible` (conditional) | Any widget | `[expression]` | `set (Visible: [Name != '']) on ctnDetails` | -| `Editable` (conditional) | Input widgets | `[expression]` | `set (Editable: [Active]) on txtName` | +| `Visible` (conditional) | Any widget | expression | `set (Visible: $currentObject/Name != '') on ctnDetails` | +| `Editable` (conditional) | Input widgets | expression | `set (Editable: $currentObject/Active) on txtName` | | `'quotedProp'` | Pluggable widgets | String, Boolean, Number | `set ('showLabel': false) on cbStatus` | -> **Conditional visibility/editability** — `set (Visible: [expr]) on widget` (and -> `Editable`) attach a per-object expression. Bare attributes are rooted in the -> widget data context automatically: `[Name != '']` becomes -> `$currentObject/Name != ''` (paths you write with `$currentObject/…`/`$Param/…` -> pass through). Setting `Editable` on a non-input widget is rejected. This mirrors -> CREATE PAGE's `visible: [...]` — see the create-page skill for enum-value rules. +> **Conditional visibility/editability** — `set (Visible: ) on widget` (and +> `Editable`) attach a per-object client expression, stored as written: name +> attributes as `$currentObject/Name`. The bracketed `set (Visible: [Name != ''])`, +> which roots a bare attribute for you, still works and warns MDL-DEPR081. Setting +> `Editable` on a non-input widget is rejected. This mirrors CREATE PAGE's +> `visible:` — see the create-page skill for enum-value rules. **Pluggable widget properties** use quoted names to set values in the widget's `Object.Properties[]`. Boolean values are stored as `"yes"`/`"no"` in BSON. diff --git a/.claude/skills/mendix/connect-rapidminer-graph/SKILL.md b/.claude/skills/mendix/connect-rapidminer-graph/SKILL.md index 9cfd0b7303..3cb0ea1b27 100644 --- a/.claude/skills/mendix/connect-rapidminer-graph/SKILL.md +++ b/.claude/skills/mendix/connect-rapidminer-graph/SKILL.md @@ -301,9 +301,7 @@ where { ## Credential management -For demos, literal credentials inline in the microflow are the simplest and most reliable. For anything else, put them in a project constant and reference it from the microflow via `$ConstantName` (requires a non-trivial amount of setup — see the project settings skill). - -**Do not** use `$ConstantName` in `create consumed rest service ... authentication: basic (username: $C, password: $C)` — the MDL parser rejects the `$` prefix there, and the skill files' claim of `rest$ConstantValue` serialization isn't reachable. +For demos, literal credentials inline in the microflow are the simplest and most reliable. For anything else, put them in a project constant and reference it as `@Module.ConstantName` — the one way MDL refers to a constant, in a microflow expression and in `create consumed rest service ... authentication: basic (username: @Module.User, password: @Module.Password)` alike. The older `$ConstantName` spelling in a REST credential still parses and warns MDL-DEPR083. ## Related skills diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 3f944f0807..7d2dbd3c30 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -259,43 +259,43 @@ See the dedicated skill file: [ALTER PAGE/SNIPPET](../alter-page/SKILL.md) ## Conditional Visibility and Editability -Any widget (including CONTAINER) can have conditional visibility. Input widgets can also have conditional editability. Use bracket syntax `[expression]`: +Any widget (including CONTAINER) can have conditional visibility. Input widgets can also have conditional editability. The condition is a Mendix client expression, written bare like every expression in MDL and stored exactly as written — so name the context object's attributes as `$currentObject/Attr`: ```sql -- Conditionally visible widget (boolean attribute) -textbox txtName (label: 'Name', attribute: Name, visible: [IsActive]) +textbox txtName (label: 'Name', attribute: Name, visible: $currentObject/IsActive) -- Conditionally visible container -container ctnDetails (visible: [Name != '']) { dynamictext t (content: '...') } +container ctnDetails (visible: $currentObject/Name != '') { dynamictext t (content: '...') } -- Conditionally editable input (boolean) -textbox txtStatus (label: 'Status', attribute: status, editable: [CanEdit]) +textbox txtStatus (label: 'Status', attribute: status, editable: $currentObject/CanEdit) --- Enum comparison: use the QUALIFIED enum value. Attributes are rooted for you, --- but a bare enum VALUE would be treated as an attribute — always qualify it. +-- Enum comparison: use the QUALIFIED enum value. textbox txtNotes (label: 'Notes', attribute: Notes, - visible: [Status = MES.EquipmentStatus.Running]) + visible: $currentObject/Status = MES.EquipmentStatus.Running) -- Combined textbox txtEmail (label: 'Email', attribute: Email, - visible: [ShowEmail], - editable: [CanEdit]) + visible: $currentObject/ShowEmail, + editable: $currentObject/CanEdit) --- Static values still work +-- Static values still work — a plain value is not a condition textbox txtReadOnly (label: 'Read Only', attribute: Name, editable: Never) textbox txtHidden (label: 'Hidden', attribute: Name, visible: false) --- A quoted-string expression is also accepted (CREATE and ALTER). Unlike the --- bracket form, it is NOT auto-rooted — write $currentObject/ yourself. -dynamictext ovChip (content: 'chip', visible: '$currentObject/Name != empty') - --- Function calls work in the bracket form, including functions whose name is --- also an MDL keyword (trim, length, find). Arguments are rooted like any other --- reference. -dynamictext tTrim (content: 'x', visible: [trim($currentObject/Slug) != '']) -textbox txtSlug (label: 'Slug', attribute: Slug, editable: [length(Slug) > 0]) +-- Function calls work, including functions whose name is also an MDL keyword +-- (trim, length, find). +dynamictext tTrim (content: 'x', visible: trim($currentObject/Slug) != '') +textbox txtSlug (label: 'Slug', attribute: Slug, editable: length($currentObject/Slug) > 0) ``` +The older bracketed form, `visible: [IsActive]`, still parses: it roots a bare +attribute in `$currentObject` for you, and warns **MDL-DEPR081**. `mxcli fmt +--upgrade` rewrites it to the expression it stores (`visible: $currentObject/IsActive`). +A constant condition (`editable: [false]`, stored as the condition `false`, not as +`Never`) has no bare spelling and keeps its brackets. + **Visible based on an attribute value** (Studio Pro's "Visible: based on attribute value") — list the Boolean/enumeration values that SHOW the widget; `empty` is "(empty)". Only an attribute of the enclosing data container's own entity: @@ -307,7 +307,7 @@ textbox txtPassword (label: 'Password', attribute: Password, visible: IsLocalUse > **`visible:`/`editable:` is a Mendix *expression*, not XPath** — a different -> function set from a datasource `where` clause, even though both use `[ ... ]`: +> function set from a datasource `where [ ... ]` clause: > > | | `visible:` / `editable:` (client expression) | `where [ … ]` (XPath) | > |---|---|---| @@ -323,23 +323,20 @@ textbox txtPassword (label: 'Password', attribute: Password, visible: IsLocalUse > [XPath keywords](https://docs.mendix.com/refguide/xpath-keywords-and-system-variables/). > **An unparseable conditional is an error, not a silent drop.** If the -> expression inside `visible: [ ... ]` / `editable: [ ... ]` can't be parsed, the +> expression in `visible:` / `editable:` can't be parsed, the > property has nowhere to go and would vanish on write — leaving the widget > unconditionally visible/editable, which looks identical to a specificity bug in > the running app. `mxcli check` reports this as **MDL-WIDGET19** and fails the > command instead. Until v0.16.x, `trim(…)` and `length(…)` hit exactly this path > and disappeared without a word (issue #852). -> **Attribute rooting is automatic** — a bare attribute in a widget -> visibility/editability expression (`[Name != '']`, `[IsActive]`) is rooted in the -> widget data context as `$currentObject/Name != ''` for you, so it no longer -> triggers CE0117. Paths you write with an explicit `$currentObject/…` or `$Param/…` -> prefix pass through unchanged. +> **Attributes are not rooted for you in the bare form** — the expression is +> stored as written, so a bare `Name` is CE0117. Write `$currentObject/Name` (or +> `$Param/…`). Only the deprecated bracketed form roots a bare attribute. > > **Enum comparison differs by context:** > - **Widget visibility/editability expression** (per-object): qualified enum value — -> `[Status = MES.EquipmentStatus.Running]` (the `Status` attribute is rooted for you; -> the *value* must stay qualified, or it would be mistaken for an attribute). +> `$currentObject/Status = MES.EquipmentStatus.Running`. > - **XPath datasource constraint** (`where […]`): the string key works — > `where [Status = 'Running']` (see [xpath-constraints](../xpath-constraints/SKILL.md)). > - **Microflow expression**: qualified value — diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 8cfd2ee1b4..7e75d8e763 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -842,7 +842,7 @@ conditional visibility — or put the condition in the microflow it calls: ```sql actionbutton btnSubmit ( caption: 'Submit', action: call microflow Mod.ACT_Submit, - visible: [$currentObject/Status = Mod.Status.Draft] + visible: $currentObject/Status = Mod.Status.Draft ) ``` diff --git a/.claude/skills/mendix/manage-security/SKILL.md b/.claude/skills/mendix/manage-security/SKILL.md index 2c0cafa6ca..7cc0901808 100644 --- a/.claude/skills/mendix/manage-security/SKILL.md +++ b/.claude/skills/mendix/manage-security/SKILL.md @@ -262,16 +262,16 @@ grant read (Phone) on entity MyModule.Customer to MyModule.User; grant read *, write * on entity MyModule.Order to MyModule.User where [Status = 'Open']; -- Revoke entity access entirely -revoke MyModule.Viewer on MyModule.Customer; +revoke all on entity MyModule.Customer from MyModule.Viewer; -- Partial revoke: remove read on specific attribute -revoke MyModule.User on MyModule.Customer (read (Phone)); +revoke read (Phone) on entity MyModule.Customer from MyModule.User; -- Partial revoke: downgrade write to read-only -revoke MyModule.User on MyModule.Customer (write (Email)); +revoke write (Email) on entity MyModule.Customer from MyModule.User; -- Partial revoke: remove structural permission -revoke MyModule.User on MyModule.Customer (delete); +revoke delete on entity MyModule.Customer from MyModule.User; ``` #### Members added later diff --git a/.claude/skills/mendix/project-settings/SKILL.md b/.claude/skills/mendix/project-settings/SKILL.md index bcf305d913..485a5dad9d 100644 --- a/.claude/skills/mendix/project-settings/SKILL.md +++ b/.claude/skills/mendix/project-settings/SKILL.md @@ -131,14 +131,14 @@ show constant values; show constant values in MyModule; -- Filter by module -- Override a constant value in a configuration -alter settings constant 'BusinessEvents.ServerUrl' value 'kafka:9092' +alter settings constant @BusinessEvents.ServerUrl value 'kafka:9092' in configuration 'Default'; -- Without IN CONFIGURATION (uses first configuration) -alter settings constant 'MyModule.ApiKey' value 'abc123'; +alter settings constant @MyModule.ApiKey value 'abc123'; -- Remove a constant override (reset to default) -alter settings drop constant 'MyModule.ApiKey' in configuration 'Default'; +alter settings drop constant @MyModule.ApiKey in configuration 'Default'; ``` #### Shared vs private values diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index bce603c6df..f218c29d6a 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -87,6 +87,11 @@ build with `CE0117 "Error(s) in expression."`. ## Activities +Expressions are bare, as everywhere in MDL: a decision's condition, a timer's +delay, a due date (`decision $WorkflowContext/Total > 1000`, `due date +addDays([%CurrentDateTime%], 3)`). The older string form (`decision '…'`) still +parses and warns `MDL-DEPR080`; `mxcli fmt --upgrade` rewrites it. + Every activity statement ends with `;`. Blocks `{ … }` nest a sub-flow. ```sql @@ -113,14 +118,14 @@ begin -- Decision: a boolean or enum exclusive split. The name is optional; give one -- when a `jump to` targets it. - decision decision1 '$WorkflowContext/Total > 1000' + decision decision1 $WorkflowContext/Total > 1000 outcomes true -> { call microflow Module.ACT_Escalate; } false -> { call microflow Module.ACT_AutoApprove; }; -- An enum decision: each outcome is a FULLY QUALIFIED enumeration value -- (Module.Enumeration.Value), plus one '' outcome for "none of the above". - decision decision2 '$WorkflowContext/Status' + decision decision2 $WorkflowContext/Status outcomes 'Module.ENUM_Status.Approved' -> { } 'Module.ENUM_Status.Rejected' -> { } @@ -132,7 +137,7 @@ begin path 2 { call microflow Module.ACT_Log; }; -- Wait for a timer, then continue (duration is a Mendix expression) - wait for timer timer1 'addHours([%CurrentDateTime%], 1)'; + wait for timer timer1 addHours([%CurrentDateTime%], 1); -- Wait for an external notification (e.g. an event) wait for notification waitForNotification1; @@ -173,7 +178,7 @@ begin user task Review 'Review' page Module.ReviewPage outcomes 'Done' { } - boundary event interrupting timer 'addDays([%CurrentDateTime%], 3)' { + boundary event interrupting timer addDays([%CurrentDateTime%], 3) { call microflow Module.ACT_Escalate; }; end workflow; @@ -219,7 +224,7 @@ begin call microflow HR.ACT_LogCancel; }; event subprocess ESP_Reminder 'Daily reminder' - on non interrupting timer 'addDays([%CurrentDateTime%], 1)' as espReminderStart { + on non interrupting timer addDays([%CurrentDateTime%], 1) as espReminderStart { call microflow HR.ACT_Remind; }; end workflow; @@ -272,7 +277,7 @@ typed, and each op writes exactly one outcome type into it: | `insert outcome '' on X { }` | `UserTaskOutcome` | a user task | | `insert condition '' on X { }` | `…ConditionOutcome` | a decision, a call microflow | | `insert path on X { }` | `ParallelSplitOutcome` | a parallel split | -| `insert boundary event on X interrupting timer '' { }` | a boundary event | user task, call microflow, call workflow, wait for notification | +| `insert boundary event on X interrupting timer { }` | a boundary event | user task, call microflow, call workflow, wait for notification | Aim one at the wrong kind and the outcome lands in a list that cannot hold it, which is **not** a build error: the project stops **loading**, so Studio Pro will diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0e773a25..0fe4742f02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **One way to refer to a constant: `@Module.Const`** (ako/mxcli#753, R5) — the spelling a Mendix expression already uses is now the only one: a consumed REST service's credentials (`Username: @Shop.ApiUser`), an agent-editor model's or knowledge base's `Key: @Shop.ApiKey` (also `alter model … set Key = @Shop.ApiKey`), and `alter settings [drop] constant @Shop.ApiUrl … in configuration 'Default'`. `describe model` and `describe knowledge base` print `Key: @…`. **Migrating a script:** nothing breaks — a REST credential's `$ApiUser` (a constant of the service's own module) warns **MDL-DEPR083**, a bare `Key: Shop.ApiKey` **MDL-DEPR084**, and `alter settings constant 'Shop.ApiUrl'` **MDL-DEPR085**; each builds the same statement and `mxcli fmt --upgrade` rewrites it. A settings string that is not a `Module.Name` is left in place and reported. +- **An entity revoke mirrors the grant** (ako/mxcli#753, R5) — `revoke write (Email), delete on entity Shop.Order from Shop.User;` takes rights away and keeps the rule, `revoke all on entity Shop.Order from Shop.User;` removes the rule. **Migrating a script:** nothing breaks — `revoke Shop.User on Shop.Order [(rights)]` still parses and builds the same statement, `check` / `exec` warn **MDL-DEPR082**, and `mxcli fmt --upgrade` rewrites it. +- **A page widget's conditional `Visible:` / `Editable:` is a bare expression** (ako/mxcli#753, R5) — `textbox t (Attribute: Name, Visible: $currentObject/IsActive, Editable: $currentObject/Status != 'Closed')`, and the same in `alter page … set (Visible: …) on t`. It is a client expression, not XPath, so it is written like every other expression and stored exactly as written: an attribute is `$currentObject/Attr`. A plain value keeps its meaning (`Visible: false`, `Editable: Never`). `describe page` prints the bare form. `Visible: $currentObject/Active` used to parse as a data source and fail check (MDL-WIDGET19); it now stores the condition. **Migrating a script:** nothing breaks — `Visible: [IsActive]` still parses, roots a bare attribute in `$currentObject` as before, and warns **MDL-DEPR081**; `mxcli fmt --upgrade` rewrites it to the expression it stores (`Visible: $currentObject/IsActive`). A constant condition (`Editable: [false]`, stored as the condition `false` rather than as `Never`) has no bare spelling, keeps its brackets and is not reported. +- **A workflow's expressions are written bare** (ako/mxcli#753, R5) — a decision's condition, `wait for timer`'s delay, a timer boundary event, a timer event sub-process's first execution time and a due date (workflow, user task, `alter workflow … set [activity …] due date`) are MDL expressions like every other: `decision $WorkflowContext/Total > 1000 comment 'Large order?'`, `due date addDays([%CurrentDateTime%], 3)`. The expression is stored as written. `describe workflow` prints the bare form; a stored expression that would not read back as itself (one the MDL expression grammar cannot parse, or with surrounding whitespace) keeps the string form, and a decision or `wait for timer` whose expression would otherwise read as the activity's name gets its name written. **Migrating a script:** nothing breaks — the string form still parses with the same meaning (its content is the expression; no slot takes a string-valued expression), `check` / `exec` warn **MDL-DEPR080**, and `mxcli fmt --upgrade` rewrites it. A string that does not read back as the same bare expression is left in place and reported. `set due date ''` still clears a due date and is not reported. - **Document types are named as Studio Pro names them** (ako/mxcli#755, R10) — `consumed rest service` (was `rest client`), `consumed odata service` (was `odata client`), `published odata service` (was `odata service`), `task queue` (was `queue`), `alter app security` (was `alter project security`) and `alter settings runtime` (was `alter settings model`), in every statement that names the type: create, alter, drop, describe, list, move, grant/revoke on a published OData service, and an external entity's `from consumed odata service`. `describe` writes the new names. **Migrating a script:** nothing breaks — the old names still parse and build the identical statement, `check` / `exec` warn **MDL-DEPR550** to **MDL-DEPR555**, and `mxcli fmt --upgrade` rewrites them. `call microflow … in queue M.Q` is a call option, not the type name, and is unchanged. -- **An entity grant names the rights first, and every XPath is written in `[ ]`** (ako/mxcli#753, R5) — `grant read *, write (Email), create on entity Shop.Order to Shop.User where [Status = 'Open'];` takes the word order of every other grant, and the constraint is bracketed like a datasource or retrieve `where`, so the quotes inside it are written once instead of doubled. A workflow user task's targeting is the same: `targeting users xpath [System.UserRoles = '[%UserRole_Manager%]']`, and `alter workflow … set activity … targeting xpath [ … ]`. Sibling predicate groups (`[a][b]`) are one constraint, kept as written. `describe entity` and `describe workflow` print the new forms; a stored constraint the bracketed grammar does not read (none in the Studio Pro-authored PedApp and TestApp fixtures) is printed in the old quoted form so the output stays re-executable. **Migrating a script:** nothing breaks — `grant Shop.User on Shop.Order (read *) where '[…]'` and `targeting xpath '[…]'` still parse and build the same rule, and `check` / `exec` warn **MDL-DEPR030** and **MDL-DEPR031**; `mxcli fmt --upgrade` rewrites both. A quoted value that is not a bracketed XPath is left in place and reported. `revoke` keeps its current form for now. +- **An entity grant names the rights first, and every XPath is written in `[ ]`** (ako/mxcli#753, R5) — `grant read *, write (Email), create on entity Shop.Order to Shop.User where [Status = 'Open'];` takes the word order of every other grant, and the constraint is bracketed like a datasource or retrieve `where`, so the quotes inside it are written once instead of doubled. A workflow user task's targeting is the same: `targeting users xpath [System.UserRoles = '[%UserRole_Manager%]']`, and `alter workflow … set activity … targeting xpath [ … ]`. Sibling predicate groups (`[a][b]`) are one constraint, kept as written. `describe entity` and `describe workflow` print the new forms; a stored constraint the bracketed grammar does not read (none in the Studio Pro-authored PedApp and TestApp fixtures) is printed in the old quoted form so the output stays re-executable. **Migrating a script:** nothing breaks — `grant Shop.User on Shop.Order (read *) where '[…]'` and `targeting xpath '[…]'` still parse and build the same rule, and `check` / `exec` warn **MDL-DEPR030** and **MDL-DEPR031**; `mxcli fmt --upgrade` rewrites both. A quoted value that is not a bracketed XPath is left in place and reported. - **A microflow error handler is `on error [without rollback] begin … end error`; `while` takes `begin … end while` like `loop`** (ako/mxcli#754, R2) — braces hold declarative children and imperative flow is `begin … end `, and the error handler was the only brace block inside a microflow. `describe microflow` / `describe nanoflow` now print `commit $O on error begin … end error;` (an empty handler `… on error without rollback begin end error;`), and so do the generated test flows of `mxcli test`. Under the `mdl 1;` preview header a `while` without `begin`, or ending in a bare `end`, is an error; without the header it keeps parsing and warns **MDL-V1-WHILE**, and `mxcli fmt --upgrade --header` inserts the missing words. Measured on the Studio Pro-authored PedApp and TestApp fixtures: the round-trip harness stays green. **Migrating a script:** nothing breaks — `on error { … }` still parses, builds the identical handler, and `check` / `exec` warn **MDL-DEPR540**; `mxcli fmt --upgrade` rewrites it. - **One argument form at every call site: `Param = expression`; one text-template form: `with ({1} = …)`** (ako/mxcli#751, R3/R4) — `call microflow`/`nanoflow`/`java action`/…, `execute database query`, `send rest request`, `show page`, and page/button actions and microflow/nanoflow data sources all bind an argument as `Order = $Order`, with no `$` on the parameter name and `=` rather than `:`. A workflow's `call microflow` / `call workflow` takes its arguments the same way, right after the callee, as bare expressions: `call microflow Shop.ACT_Process(Order = $WorkflowContext) as act1 comment 'Process'`. `show message` and `validation feedback` take `with ({1} = $a, {2} = $b)`, like `log` and the REST templates. `describe` writes these forms; it also writes a validation feedback's template arguments, which it used to drop, so describe → exec lost them. Measured on a copy of the Studio Pro-authored TestApp (Mendix 11.14.0): a script in the new forms and the same script in the old forms describe identically, and `mx check` reports 0 errors. **Migrating a script:** nothing breaks — `$Param = e` (**MDL-DEPR006**), `Param: e` (**MDL-DEPR007**), a workflow's `with (Param = '')` (**MDL-DEPR008**, same meaning: the string's content is the expression) and `objects [..]` / `parameters [..]` (**MDL-DEPR009**) still parse and build the same model, `check` / `exec` warn, and `mxcli fmt --upgrade` rewrites them. A workflow string whose content does not read back as the same bare expression is left in place and reported, and `describe` keeps the string form for such a stored expression. - **`describe page` no longer invents names Mendix does not store; `alter page` addresses a DataGrid 2 column by what it shows** (ako/mxcli#749, R12) — a layout grid's rows and columns, a data grid's columns and control bar, a gallery's template and filter, a chart's series and other object-list items have no Name in the model, and describe used to print one it made up (`row row1`, `column col3`, `controlbar controlBar1`, and a data-grid column named after its attribute, so two columns over `FullName` were both `column FullName`). They now print without one — `row { column (DesktopWidth: 6) { … } }`, `column (Attribute: FullName, Caption: 'Full name')` — and the widget name is optional in the grammar for exactly those elements. A column is addressed as `grid column(Attr)`, written as describe writes the attribute (`Owner/Name` over an association), or `grid column('Caption')`; an address two columns share is refused, listing the matches, unless `@n` picks one (`drop dg column(FullName)@2`). A widget Mendix does store a name for still needs one: `check` reports a missing one as **MDL-WIDGET35** and the page builder refuses it. Measured on the Studio Pro-authored PedApp fixture: the round-trip harness stays green, and `mx check` 11.13 shows no new error after `alter page … column(…)` edits and a create with nameless rows, grid columns and gallery slots. **Migrating a script:** nothing breaks — a name written on one of those elements still parses, is ignored as before, and `check` / `exec` warn **MDL-DEPR005**; `mxcli fmt --upgrade` removes it. The older `grid.Column` address keeps working. The info-level MDL-WIDGET16 note about dropped column names is gone, replaced by that warning. diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index 8f094e8420..ae9a6dc1cb 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -418,7 +418,7 @@ func init() { "show constants", "constant values", "modify constant", "string constant", "integer constant", "boolean constant", }, - Syntax: "CREATE CONSTANT Module.Name\n TYPE String|Integer|Long|Decimal|Boolean|DateTime\n DEFAULT value\n [COMMENT 'description'];\n\nCREATE OR MODIFY CONSTANT Module.Name\n TYPE DataType DEFAULT value [COMMENT 'text'];\n\nSHOW CONSTANTS;\nSHOW CONSTANTS IN ;\nSHOW CONSTANT VALUES;\nDESCRIBE CONSTANT Module.Name;\nDROP CONSTANT Module.Name;\n\nRemove override:\n ALTER SETTINGS DROP CONSTANT 'Module.Name' IN CONFIGURATION 'cfg';\n\n" + + Syntax: "CREATE CONSTANT Module.Name\n TYPE String|Integer|Long|Decimal|Boolean|DateTime\n DEFAULT value\n [COMMENT 'description'];\n\nCREATE OR MODIFY CONSTANT Module.Name\n TYPE DataType DEFAULT value [COMMENT 'text'];\n\nSHOW CONSTANTS;\nSHOW CONSTANTS IN ;\nSHOW CONSTANT VALUES;\nDESCRIBE CONSTANT Module.Name;\nDROP CONSTANT Module.Name;\n\nRemove override:\n ALTER SETTINGS DROP CONSTANT @Module.Name IN CONFIGURATION 'cfg';\n\n" + "Shared vs private values:\n" + " A per-configuration override holds either a SHARED value (stored in the\n" + " model, so in version control — every developer gets it) or a PRIVATE one\n" + diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index e1a662c6ec..00ca59a952 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -816,8 +816,8 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "agent", "agents", "model", "knowledge base", "mcp service", "agent editor", "llm", "ai", "genai", "mxcloudgenai", }, - Syntax: "LIST MODELS [IN Module];\nLIST KNOWLEDGE BASES [IN Module];\nLIST CONSUMED MCP SERVICES [IN Module];\nLIST AGENTS [IN Module];\nDESCRIBE MODEL Module.Name;\nCREATE MODEL Module.Name (Provider: MxCloudGenAI, Key: Module.ApiKey);\nCREATE KNOWLEDGE BASE Module.Name (Provider: MxCloudGenAI, Key: Module.KBKey);\nCREATE CONSUMED MCP SERVICE Module.Name (ProtocolVersion: v2025_03_26, ...);\nCREATE AGENT Module.Name (UsageType: Task|Chat, Model: Module.MyModel, SystemPrompt: '...') { ... };\nDROP AGENT Module.Name;", - Example: "CREATE MODEL MyModule.GPT4 (\n Provider: MxCloudGenAI,\n Key: MyModule.ModelApiKey\n);\n\nCREATE AGENT MyModule.Summarizer (\n UsageType: Task,\n Model: MyModule.GPT4,\n SystemPrompt: 'Summarize in 3 sentences.',\n UserPrompt: 'Enter text.'\n);", + Syntax: "LIST MODELS [IN Module];\nLIST KNOWLEDGE BASES [IN Module];\nLIST CONSUMED MCP SERVICES [IN Module];\nLIST AGENTS [IN Module];\nDESCRIBE MODEL Module.Name;\nCREATE MODEL Module.Name (Provider: MxCloudGenAI, Key: @Module.ApiKey);\nCREATE KNOWLEDGE BASE Module.Name (Provider: MxCloudGenAI, Key: @Module.KBKey);\nCREATE CONSUMED MCP SERVICE Module.Name (ProtocolVersion: v2025_03_26, ...);\nCREATE AGENT Module.Name (UsageType: Task|Chat, Model: Module.MyModel, SystemPrompt: '...') { ... };\nDROP AGENT Module.Name;", + Example: "CREATE MODEL MyModule.GPT4 (\n Provider: MxCloudGenAI,\n Key: @MyModule.ModelApiKey\n);\n\nCREATE AGENT MyModule.Summarizer (\n UsageType: Task,\n Model: MyModule.GPT4,\n SystemPrompt: 'Summarize in 3 sentences.',\n UserPrompt: 'Enter text.'\n);", SeeAlso: []string{"agents.model", "agents.knowledge-base", "agents.mcp-service", "agents.agent"}, }) @@ -825,8 +825,8 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, Path: "agents.model", Summary: "CREATE/DROP MODEL documents for AI agents", Keywords: []string{"create model", "drop model", "describe model", "list models", "provider", "mxcloudgenai"}, - Syntax: "CREATE [OR MODIFY] MODEL Module.Name [FOLDER 'path'] (\n Provider: MxCloudGenAI,\n Key: Module.ApiKeyConstant\n);\nDESCRIBE MODEL Module.Name;\nLIST MODELS [IN Module];\nDROP MODEL Module.Name;", - Example: "create model MyModule.GPT4 (\n Provider: MxCloudGenAI,\n Key: MyModule.ModelApiKey\n);", + Syntax: "CREATE [OR MODIFY] MODEL Module.Name [FOLDER 'path'] (\n Provider: MxCloudGenAI,\n Key: @Module.ApiKeyConstant\n);\nDESCRIBE MODEL Module.Name;\nLIST MODELS [IN Module];\nDROP MODEL Module.Name;", + Example: "create model MyModule.GPT4 (\n Provider: MxCloudGenAI,\n Key: @MyModule.ModelApiKey\n);", SeeAlso: []string{"agents"}, }) @@ -834,8 +834,8 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, Path: "agents.knowledge-base", Summary: "CREATE/DROP KNOWLEDGE BASE documents for AI agents", Keywords: []string{"create knowledge base", "drop knowledge base", "knowledge base", "kb", "rag"}, - Syntax: "CREATE [OR MODIFY] KNOWLEDGE BASE Module.Name [FOLDER 'path'] (\n Provider: MxCloudGenAI,\n Key: Module.KBApiKeyConstant\n);\nDESCRIBE KNOWLEDGE BASE Module.Name;\nLIST KNOWLEDGE BASES [IN Module];\nDROP KNOWLEDGE BASE Module.Name;", - Example: "create knowledge base MyModule.ProductDocs (\n Provider: MxCloudGenAI,\n Key: MyModule.KBApiKey\n);", + Syntax: "CREATE [OR MODIFY] KNOWLEDGE BASE Module.Name [FOLDER 'path'] (\n Provider: MxCloudGenAI,\n Key: @Module.KBApiKeyConstant\n);\nDESCRIBE KNOWLEDGE BASE Module.Name;\nLIST KNOWLEDGE BASES [IN Module];\nDROP KNOWLEDGE BASE Module.Name;", + Example: "create knowledge base MyModule.ProductDocs (\n Provider: MxCloudGenAI,\n Key: @MyModule.KBApiKey\n);", SeeAlso: []string{"agents"}, }) diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 50f6410ae4..2adceeb49c 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -462,8 +462,8 @@ create or modify translations in Administration for nl_NL ( }, Syntax: `ALTER SETTINGS RUNTIME = ; ALTER SETTINGS CONFIGURATION '' = , ...; -ALTER SETTINGS CONSTANT '' VALUE '' IN CONFIGURATION ''; -ALTER SETTINGS DROP CONSTANT '' IN CONFIGURATION ''; +ALTER SETTINGS CONSTANT @ VALUE '' IN CONFIGURATION ''; +ALTER SETTINGS DROP CONSTANT @ IN CONFIGURATION ''; ALTER SETTINGS LANGUAGE DefaultLanguageCode = ''; ALTER SETTINGS LANGUAGE ADD '' [(CheckCompleteness: true, CustomDateFormat: '')]; ALTER SETTINGS LANGUAGE ADD OR MODIFY '' [(...)]; @@ -482,7 +482,7 @@ ALTER SETTINGS CONFIGURATION 'Default' DatabaseType = 'PostgreSql', DatabaseUrl = 'localhost:5432', DatabaseName = 'mydb'; -ALTER SETTINGS CONSTANT 'BusinessEvents.ServerUrl' VALUE 'kafka:9092' +ALTER SETTINGS CONSTANT @BusinessEvents.ServerUrl VALUE 'kafka:9092' IN CONFIGURATION 'Default'; CREATE CONFIGURATION 'Production' DatabaseType = 'PostgreSql', diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 81e6b357bd..0378124d96 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -120,7 +120,7 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "image", "filter", "textfilter", "numberfilter", "datefilter", "dropdownfilter", "dropdownsort", "customwidget", "pluggablewidget", "statictext", "staticimage", "dynamicimage", "dropdown", "referenceselector", - "legacydatagrid", + "legacydatagrid", "visible", "editable", "conditional visibility", }, Syntax: "-- Containers\nLAYOUTGRID name { ROW r { COLUMN c (DesktopWidth: 6) { ... } } }\nCONTAINER name (Class: 'cls') { ... }\nCONTAINER name (OnClick: CALL MICROFLOW Module.MF) { ... } -- clickable container\nCUSTOMCONTAINER name (Class: 'cls') { ... }\nGROUPBOX name (Caption: 'C') { ... }\nTABCONTAINER name { TABPAGE tp (Caption: 'One') { ... } TABPAGE tp2 (Caption: 'Two') { ... } }\n\n" + "-- Data widgets\nDATAVIEW name (DataSource: $Param) { ... FOOTER f { ... } }\nDATAGRID name (DataSource: DATABASE Module.Entity) { COLUMN c (Attribute: A) }\nGALLERY name (DataSource: DATABASE Module.Entity, DesktopColumns: 3) { ... }\nLISTVIEW name (DataSource: DATABASE Module.Entity) { ... }\nLISTVIEW name (...) { ... TEMPLATE FOR Module.Specialization { ... } }\n\n" + @@ -144,6 +144,12 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "-- container the grid declares — it used to be dropped on write with no\n" + "-- diagnostic, and is now refused (MDL-WIDGET30).\n\n" + "-- Inputs\nTEXTBOX name (Label: 'L', Attribute: Attr)\nTEXTAREA | DATEPICKER | COMBOBOX | CHECKBOX | RADIOBUTTONS\n\n" + + "-- Conditional visibility / editability: a bare client expression, stored as\n" + + "-- written (name attributes as $currentObject/Attr). A plain value is static.\n" + + "TEXTBOX name (Attribute: Attr, Visible: $currentObject/IsActive, Editable: $currentObject/Status != 'Closed')\n" + + "TEXTBOX name (Attribute: Attr, Visible: false, Editable: Never)\n" + + "-- The bracketed Visible: [IsActive] (attributes rooted for you) is the deprecated\n" + + "-- spelling, MDL-DEPR081.\n\n" + "-- Actions\nACTIONBUTTON name (Caption: 'C', Action: SAVE CHANGES, ButtonStyle: Primary)\nLINKBUTTON name (Caption: 'C', Action: ...)\n\n" + "-- Display\nDYNAMICTEXT name (Content: 'Hello, {1}!', ContentParams: [{1} = Name])\nTITLE name (Content: 'Heading')\nIMAGE name (Image: 'Module.Collection.ImageName')\nIMAGE name (ImageType: imageUrl, ImageUrl: 'https://…')\n" + "-- IMAGE needs a source. Its default, `ImageType: image`, shows an entry from an\n" + diff --git a/cmd/mxcli/syntax/features_security.go b/cmd/mxcli/syntax/features_security.go index 0008f1c306..23e6d86736 100644 --- a/cmd/mxcli/syntax/features_security.go +++ b/cmd/mxcli/syntax/features_security.go @@ -34,13 +34,15 @@ func init() { "create", "delete", "xpath", "row-level security", }, Syntax: "GRANT ON ENTITY . TO . [, ...] [WHERE []];\n" + - "REVOKE . ON .;\n" + - "REVOKE . ON . ();\n\n" + + "REVOKE ALL ON ENTITY . FROM . [, ...]; -- removes the rule\n" + + "REVOKE ON ENTITY . FROM . [, ...]; -- takes rights away\n\n" + "Rights: CREATE, DELETE, READ *, READ (,...), WRITE *, WRITE (,...)\n\n" + "The XPath is written in [ ], as everywhere else, so quotes inside it are\n" + "not doubled; sibling groups ([a][b]) are one constraint. The old order,\n" + "GRANT ON () WHERE '', still parses and\n" + - "warns MDL-DEPR030; `mxcli fmt --upgrade` rewrites it.\n\n" + + "warns MDL-DEPR030; `mxcli fmt --upgrade` rewrites it. The revoke mirrors\n" + + "the grant; its old order, REVOKE ON [()], warns\n" + + "MDL-DEPR082 and is rewritten the same way.\n\n" + "A module role is always Module.Role. A bare role name parses but is\n" + "refused (MDL-GRANT02) — mxcli cannot tell which module it belongs to.\n\n" + "Members added later:\n" + @@ -66,7 +68,9 @@ func init() { "-- Contract extends DocumentBase: DocName is inherited, ContractNumber is own\n" + "GRANT READ (DocName, ContractNumber) ON ENTITY Docs.Contract TO Docs.Viewer;\n\n" + "-- Attachment extends System.FileDocument: Name and Size are inherited\n" + - "GRANT READ (Category, \"Name\", Size) ON ENTITY Docs.Attachment TO Docs.Viewer;", + "GRANT READ (Category, \"Name\", Size) ON ENTITY Docs.Attachment TO Docs.Viewer;\n\n" + + "REVOKE WRITE (Email) ON ENTITY Shop.Customer FROM Shop.User;\n" + + "REVOKE ALL ON ENTITY Shop.Order FROM Shop.User;", SeeAlso: []string{"security.module-role", "security.microflow-access"}, }) diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 2c813ce03c..4ce92b0fab 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -39,7 +39,7 @@ func init() { " [DESCRIPTION '']\n" + " [EXPORT LEVEL Hidden | API]\n" + " [OVERVIEW PAGE Module.OverviewPage]\n" + - " [DUE DATE '']\n" + + " [DUE DATE ]\n" + " [ON WORKFLOW EVENTS (, ...) MICROFLOW Module.Handler [AS '']]...\n" + " [ON ANY WORKFLOW EVENT MICROFLOW Module.Handler [AS '']]...\n" + "BEGIN\n \nEND WORKFLOW;\n\n" + @@ -126,7 +126,7 @@ func init() { " [TARGETING [USERS | GROUPS] MICROFLOW Module.MF | TARGETING [USERS | GROUPS] XPATH []]\n" + " [ON CREATED MICROFLOW Module.MF] -- (System.WorkflowUserTask, ), returns nothing\n" + " [ENTITY Module.Entity]\n" + - " [DUE DATE '']\n" + + " [DUE DATE ]\n" + " [DESCRIPTION '']\n" + " OUTCOMES '' { } '' { };\n\n" + "-- The clauses are a SET: write them in ANY order, each at most once\n" + "-- (ako/mxcli#586). The two TARGETING spellings are ONE clause — a task\n" + @@ -196,7 +196,7 @@ func init() { " [TARGETING [USERS | GROUPS] MICROFLOW Module.MF | TARGETING [USERS | GROUPS] XPATH []]\n" + " [ON CREATED MICROFLOW Module.MF] -- (System.WorkflowUserTask, ), returns nothing\n" + " [ENTITY Module.Entity]\n" + - " [DUE DATE '']\n" + + " [DUE DATE ]\n" + " [DESCRIPTION '']\n" + " [PARTICIPANTS ALL | | PERCENT]\n" + " [DECIDE BY ]\n" + @@ -237,12 +237,14 @@ func init() { // cannot open. This entry taught 'Under 1000' / 'Over 1000' — both of // which corrupt the model (ako/mxcli#1031, ako/mxcli#1065). Syntax: "-- Boolean decision:\n" + - "DECISION [] '' [COMMENT '']\n OUTCOMES TRUE -> { } FALSE -> { };\n\n" + + "DECISION [] [COMMENT '']\n OUTCOMES TRUE -> { } FALSE -> { };\n\n" + "-- Enumeration decision — each outcome is a QUALIFIED enum value,\n" + "-- plus one '' outcome for 'none of the above' (without it: CE6686):\n" + - "DECISION [] '' [COMMENT '']\n OUTCOMES 'Module.Enumeration.Value' -> { } ... '' -> { };", - Example: "-- Boolean\nDECISION decision1 '$WorkflowContext/Amount > 1000'\n OUTCOMES\n TRUE -> {\n USER TASK ManagerApproval 'Manager must approve'\n OUTCOMES 'OK' { };\n }\n FALSE -> { };\n\n" + - "-- Enumeration: the value must be Module.Enumeration.Value.\n-- A bare 'Approved' makes the project UNLOADABLE, not merely invalid.\nDECISION decision2 '$WorkflowContext/Status'\n OUTCOMES\n 'Sales.ENUM_Status.Approved' -> { }\n 'Sales.ENUM_Status.Rejected' -> { }\n '' -> { };", + "DECISION [] [COMMENT '']\n OUTCOMES 'Module.Enumeration.Value' -> { } ... '' -> { };\n\n" + + "-- The expression is bare (R5). The older string form, DECISION '',\n" + + "-- is the deprecated alias MDL-DEPR080, as it is for timers and due dates.", + Example: "-- Boolean\nDECISION decision1 $WorkflowContext/Amount > 1000\n OUTCOMES\n TRUE -> {\n USER TASK ManagerApproval 'Manager must approve'\n OUTCOMES 'OK' { };\n }\n FALSE -> { };\n\n" + + "-- Enumeration: the value must be Module.Enumeration.Value.\n-- A bare 'Approved' makes the project UNLOADABLE, not merely invalid.\nDECISION decision2 $WorkflowContext/Status\n OUTCOMES\n 'Sales.ENUM_Status.Approved' -> { }\n 'Sales.ENUM_Status.Rejected' -> { }\n '' -> { };", SeeAlso: []string{"workflow.create", "workflow.parallel-split"}, }) @@ -329,7 +331,7 @@ func init() { "DECISION [''] ...\nPARALLEL SPLIT ...\n" + "WAIT FOR TIMER ...\nWAIT FOR NOTIFICATION \n" + "CALL MICROFLOW Module.MF AS \nCALL WORKFLOW Module.WF AS ", - Example: "DECISION decision1 '$WorkflowContext/Total > 1000'\n" + + Example: "DECISION decision1 $WorkflowContext/Total > 1000\n" + " OUTCOMES\n true -> { }\n false -> { };\n\n" + "PARALLEL SPLIT split1\n PATH 1 { JUMP TO decision1; }\n PATH 2 { };", SeeAlso: []string{"workflow.create", "workflow.decision", "workflow.parallel-split", "workflow.end"}, @@ -407,8 +409,8 @@ func init() { // 'Workflows$TimerBoundaryEvent' could not be found"). This entry showed // the bare form, with 'P3D' — not a valid timer expression — as its delay. // ako/view-entity-examples FINDINGS §7. - Syntax: "-- inline, as a clause of a USER TASK (after OUTCOMES):\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER '' { }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [''] { }\n\n-- A notification boundary event (Mendix 11.11+) is triggered by `notify workflow`\n-- targeting its name, so the name is unique in the workflow. ALTER cannot insert\n-- one yet; restate the workflow instead.\n\n-- or add a timer to an existing task:\nALTER WORKFLOW INSERT BOUNDARY EVENT ON (INTERRUPTING | NON INTERRUPTING) TIMER '' { }\n\n-- Name the kind: a bare TIMER is refused on Mendix 11 (MDL-WF07) — it writes a\n-- type the runtime cannot load. The delay is an expression that yields a\n-- DateTime, e.g. 'addDays([%CurrentDateTime%], 3)'. mxcli ends every boundary\n-- path with Mendix's end-of-path marker, so a path may end in a call; use\n-- JUMP TO to return to the task instead.", - Example: "user task ReviewTask 'Review'\n page Module.WF_Review\n outcomes 'Done' { }\n boundary event interrupting timer 'addDays([%CurrentDateTime%], 3)' {\n call microflow Module.WF_Escalate;\n };", + Syntax: "-- inline, as a clause of a USER TASK (after OUTCOMES):\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER { }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [''] { }\n\n-- A notification boundary event (Mendix 11.11+) is triggered by `notify workflow`\n-- targeting its name, so the name is unique in the workflow. ALTER cannot insert\n-- one yet; restate the workflow instead.\n\n-- or add a timer to an existing task:\nALTER WORKFLOW INSERT BOUNDARY EVENT ON (INTERRUPTING | NON INTERRUPTING) TIMER { }\n\n-- Name the kind: a bare TIMER is refused on Mendix 11 (MDL-WF07) — it writes a\n-- type the runtime cannot load. The delay is an expression that yields a\n-- DateTime, e.g. addDays([%CurrentDateTime%], 3). mxcli ends every boundary\n-- path with Mendix's end-of-path marker, so a path may end in a call; use\n-- JUMP TO to return to the task instead.", + Example: "user task ReviewTask 'Review'\n page Module.WF_Review\n outcomes 'Done' { }\n boundary event interrupting timer addDays([%CurrentDateTime%], 3) {\n call microflow Module.WF_Escalate;\n };", MinVersion: "10.6.0", SeeAlso: []string{"workflow.user-task", "workflow.notification", "workflow.event-subprocess"}, }) @@ -425,7 +427,7 @@ func init() { " ON (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [] ['']\n" + " { };\n" + "EVENT SUBPROCESS ['']\n" + - " ON (INTERRUPTING | NON INTERRUPTING) TIMER '' [AS ] [COMMENT '']\n" + + " ON (INTERRUPTING | NON INTERRUPTING) TIMER [AS ] [COMMENT '']\n" + " { };\n\n" + "-- Interrupting cancels every active path first; non-interrupting runs alongside.\n" + "-- A notification start is what `notify workflow` targets. The body's End is\n" + @@ -440,7 +442,7 @@ func init() { " call microflow HR.ACT_LogCancel;\n" + " };\n" + " event subprocess ESP_Reminder 'Daily reminder'\n" + - " on non interrupting timer 'addDays([%CurrentDateTime%], 1)' as espReminderStart {\n" + + " on non interrupting timer addDays([%CurrentDateTime%], 1) as espReminderStart {\n" + " call microflow HR.ACT_Remind;\n" + " };\n" + "end workflow;", @@ -489,7 +491,7 @@ func init() { "add activity", "drop activity", "replace activity", }, // SET properties are keyword-led phrases, not `name = value` assignments: - // `SET DUE DATE ''`, `SET DISPLAY ''`, `SET OVERVIEW PAGE + // `SET DUE DATE `, `SET DISPLAY ''`, `SET OVERVIEW PAGE // Module.Page`. The `= ` this entry used to show does not parse. // INSERT names the anchor first and the activity second — INSERT AFTER // — and there is no BEFORE. DROP and REPLACE take the @@ -504,8 +506,8 @@ func init() { // the wrong kind used to produce a project Mendix could not LOAD // (ako/mxcli#415); it is refused now, but the entry documented only two // of the ops, which is how an author reached for the wrong one. - Syntax: "ALTER WORKFLOW Module.Name SET DISPLAY '';\nALTER WORKFLOW Module.Name SET DUE DATE '';\nALTER WORKFLOW Module.Name SET OVERVIEW PAGE Module.Page;\nALTER WORKFLOW Module.Name SET ACTIVITY ;\nALTER WORKFLOW Module.Name INSERT AFTER ;\nALTER WORKFLOW Module.Name DROP ACTIVITY ;\nALTER WORKFLOW Module.Name REPLACE ACTIVITY WITH ;\nALTER WORKFLOW Module.Name INSERT OUTCOME '' ON { };\nALTER WORKFLOW Module.Name DROP OUTCOME '' ON ;\nALTER WORKFLOW Module.Name INSERT CONDITION '' ON { };\nALTER WORKFLOW Module.Name INSERT PATH ON { };\nALTER WORKFLOW Module.Name INSERT BOUNDARY EVENT ON (INTERRUPTING | NON INTERRUPTING) TIMER '' { };", - Example: "ALTER WORKFLOW HR.LeaveApproval SET DUE DATE 'addDays([%CurrentDateTime%], 7)';\nALTER WORKFLOW HR.LeaveApproval INSERT AFTER ReviewTask\n CALL MICROFLOW HR.NotifyHR;\nALTER WORKFLOW HR.LeaveApproval DROP ACTIVITY ObsoleteStep;\n\n-- The INSERT op has to match the activity kind: an outcome list is typed,\n-- and the wrong one is refused (it would leave a project Mendix cannot open).\nALTER WORKFLOW HR.LeaveApproval INSERT OUTCOME 'Rejected' ON ReviewTask { };\nALTER WORKFLOW HR.LeaveApproval INSERT CONDITION 'HR.Status.Urgent' ON Triage { };\nALTER WORKFLOW HR.LeaveApproval INSERT PATH ON NotifyAll { };", + Syntax: "ALTER WORKFLOW Module.Name SET DISPLAY '';\nALTER WORKFLOW Module.Name SET DUE DATE ;\nALTER WORKFLOW Module.Name SET OVERVIEW PAGE Module.Page;\nALTER WORKFLOW Module.Name SET ACTIVITY ;\nALTER WORKFLOW Module.Name INSERT AFTER ;\nALTER WORKFLOW Module.Name DROP ACTIVITY ;\nALTER WORKFLOW Module.Name REPLACE ACTIVITY WITH ;\nALTER WORKFLOW Module.Name INSERT OUTCOME '' ON { };\nALTER WORKFLOW Module.Name DROP OUTCOME '' ON ;\nALTER WORKFLOW Module.Name INSERT CONDITION '' ON { };\nALTER WORKFLOW Module.Name INSERT PATH ON { };\nALTER WORKFLOW Module.Name INSERT BOUNDARY EVENT ON (INTERRUPTING | NON INTERRUPTING) TIMER { };", + Example: "ALTER WORKFLOW HR.LeaveApproval SET DUE DATE addDays([%CurrentDateTime%], 7);\nALTER WORKFLOW HR.LeaveApproval INSERT AFTER ReviewTask\n CALL MICROFLOW HR.NotifyHR;\nALTER WORKFLOW HR.LeaveApproval DROP ACTIVITY ObsoleteStep;\n\n-- The INSERT op has to match the activity kind: an outcome list is typed,\n-- and the wrong one is refused (it would leave a project Mendix cannot open).\nALTER WORKFLOW HR.LeaveApproval INSERT OUTCOME 'Rejected' ON ReviewTask { };\nALTER WORKFLOW HR.LeaveApproval INSERT CONDITION 'HR.Status.Urgent' ON Triage { };\nALTER WORKFLOW HR.LeaveApproval INSERT PATH ON NotifyAll { };", SeeAlso: []string{"workflow.create", "workflow.drop"}, }) } diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 9ced9a4d4a..a8babb9b69 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -220,7 +220,7 @@ Nested folders use `/` separator: `'Parent/Child/Grandchild'`. Missing folders a | Grant page access | `GRANT VIEW ON PAGE Mod.Page TO Mod.Role, ...;` | | | Revoke page access | `REVOKE VIEW ON PAGE Mod.Page FROM Mod.Role, ...;` | | | Grant entity access | `GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY Mod.Entity TO Mod.Role;` | Supports member lists and WHERE | -| Revoke entity access | `REVOKE Mod.Role ON Mod.Entity;` | | +| Revoke entity access | `REVOKE ALL ON ENTITY Mod.Entity FROM Mod.Role;` | | | Set security level | `ALTER APP SECURITY LEVEL OFF\|PROTOTYPE\|PRODUCTION;` | | | Toggle demo users | `ALTER APP SECURITY DEMO USERS ON\|OFF;` | | | Toggle guest access | `ALTER APP SECURITY GUEST ACCESS ON ROLE UserRole\|OFF;` | Anonymous users; role required (CE0133) | @@ -302,7 +302,7 @@ CREATE OR REPLACE NAVIGATION Responsive | Describe settings | `DESCRIBE SETTINGS;` | Full MDL output (round-trippable) | | Alter model settings | `ALTER SETTINGS RUNTIME Key = Value;` | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. | | Alter configuration | `ALTER SETTINGS CONFIGURATION 'Name' Key = Value;` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | -| Alter constant | `ALTER SETTINGS CONSTANT 'Name' VALUE 'val' IN CONFIGURATION 'cfg';` | Override constant per configuration | +| Alter constant | `ALTER SETTINGS CONSTANT @Module.Name VALUE 'val' IN CONFIGURATION 'cfg';` | Override constant per configuration | | Alter language | `ALTER SETTINGS LANGUAGE Key = Value;` | DefaultLanguageCode | | Alter workflows | `ALTER SETTINGS WORKFLOWS Key = Value;` | UserEntity, DefaultTaskParallelism | diff --git a/docs-site/src/appendixes/version-compatibility.md b/docs-site/src/appendixes/version-compatibility.md index 7e4e521b85..bb9e199d23 100644 --- a/docs-site/src/appendixes/version-compatibility.md +++ b/docs-site/src/appendixes/version-compatibility.md @@ -75,8 +75,8 @@ The tables below show exactly which features are available on each Mendix versio | Basic pages | `CREATE PAGE ... { ... }` | Yes | Yes | Yes | Yes | | ALTER PAGE | `ALTER PAGE ... SET/INSERT/DROP` | -- | Yes | Yes | Yes | | Pluggable widgets | `DATAGRID`, `GALLERY`, `COMBOBOX`, `IMAGE` | -- | Yes | Yes | Yes | -| Conditional visibility | `Visible: [xpath]` | -- | -- | -- | Yes | -| Conditional editability | `Editable: [xpath]` | -- | -- | -- | Yes | +| Conditional visibility | `Visible: ` | -- | -- | -- | Yes | +| Conditional editability | `Editable: ` | -- | -- | -- | Yes | | Responsive column widths | `TabletWidth: 6, PhoneWidth: 12` | -- | -- | -- | Yes | | Page parameters (entity) | `Params: { $Item: Module.Entity }` | 9.4+ | Yes | Yes | Yes | | Page parameters (primitive) | `Params: { $Qty: Integer }` | -- | -- | -- | 11.6+ | diff --git a/docs-site/src/examples/security.md b/docs-site/src/examples/security.md index 655ea0ffc0..10ddba5055 100644 --- a/docs-site/src/examples/security.md +++ b/docs-site/src/examples/security.md @@ -87,13 +87,13 @@ GRANT READ (Phone) ON ENTITY Sales.Customer TO Sales.Viewer; ```sql -- Remove all access for a role -REVOKE Sales.Viewer ON Sales.Customer; +REVOKE ALL ON ENTITY Sales.Customer FROM Sales.Viewer; -- Partial revoke: remove read on a specific attribute -REVOKE Sales.User ON Sales.Customer (READ (Phone)); +REVOKE READ (Phone) ON ENTITY Sales.Customer FROM Sales.User; -- Partial revoke: downgrade write to read-only -REVOKE Sales.User ON Sales.Customer (WRITE (Email)); +REVOKE WRITE (Email) ON ENTITY Sales.Customer FROM Sales.User; -- Remove microflow access REVOKE EXECUTE ON MICROFLOW Sales.ACT_Order_Delete FROM Sales.User; diff --git a/docs-site/src/language/entity-access.md b/docs-site/src/language/entity-access.md index 36983d2518..ff6c58ea94 100644 --- a/docs-site/src/language/entity-access.md +++ b/docs-site/src/language/entity-access.md @@ -157,13 +157,13 @@ Examples: ```sql -- Remove all access for Viewer -REVOKE Shop.Viewer ON Shop.Customer; +REVOKE ALL ON ENTITY Shop.Customer FROM Shop.Viewer; -- Remove read access on a specific attribute -REVOKE Shop.User ON Shop.Customer (READ (Notes)); +REVOKE READ (Notes) ON ENTITY Shop.Customer FROM Shop.User; -- Downgrade write to read-only on Email -REVOKE Shop.User ON Shop.Customer (WRITE (Email)); +REVOKE WRITE (Email) ON ENTITY Shop.Customer FROM Shop.User; ``` A full `REVOKE` (without rights list) removes the entire access rule. A partial `REVOKE` downgrades specific rights: `REVOKE READ (x)` sets member x to no access, `REVOKE WRITE (x)` downgrades from ReadWrite to ReadOnly. diff --git a/docs-site/src/language/grant-revoke.md b/docs-site/src/language/grant-revoke.md index 7cb3652c12..8bd921be68 100644 --- a/docs-site/src/language/grant-revoke.md +++ b/docs-site/src/language/grant-revoke.md @@ -61,16 +61,16 @@ Examples: ```sql -- Remove all access -REVOKE Shop.Viewer ON Shop.Customer; +REVOKE ALL ON ENTITY Shop.Customer FROM Shop.Viewer; -- Remove read access on a specific member -REVOKE Shop.User ON Shop.Customer (READ (Notes)); +REVOKE READ (Notes) ON ENTITY Shop.Customer FROM Shop.User; -- Downgrade write to read-only -REVOKE Shop.User ON Shop.Customer (WRITE (Email)); +REVOKE WRITE (Email) ON ENTITY Shop.Customer FROM Shop.User; -- Remove delete permission only -REVOKE Shop.User ON Shop.Customer (DELETE); +REVOKE DELETE ON ENTITY Shop.Customer FROM Shop.User; ``` ## Microflow Access diff --git a/docs-site/src/language/pages.md b/docs-site/src/language/pages.md index 05b8715dd1..9fc102e51e 100644 --- a/docs-site/src/language/pages.md +++ b/docs-site/src/language/pages.md @@ -93,14 +93,16 @@ Values are 1-12 (grid units) or `AutoFill`. TabletWidth and PhoneWidth default t ### Conditional Visibility -Any widget can be conditionally visible using an XPath expression in brackets: +Any widget can be conditionally visible. The condition is a Mendix client expression, written bare and stored as written, so an attribute of the context object is `$currentObject/Attr`: ```sql -TEXTBOX txtName (Label: 'Name', Attribute: Name, Visible: [IsActive]) +TEXTBOX txtName (Label: 'Name', Attribute: Name, Visible: $currentObject/IsActive) ``` Static values also work: `Visible: false` hides the widget unconditionally. +The older bracketed form, `Visible: [IsActive]`, still parses — it roots a bare attribute in `$currentObject` — and warns `MDL-DEPR081`; `mxcli fmt --upgrade` rewrites it to the expression it stores. A constant condition such as `Editable: [false]` has no bare spelling and keeps its brackets. + Studio Pro's **"based on attribute value"** form lists the values of a Boolean or enumeration attribute (of the enclosing data container's entity) that show the widget; `empty` is Studio Pro's "(empty)" choice: @@ -119,7 +121,7 @@ set up that way in Studio Pro. Input widgets can be conditionally editable: ```sql -TEXTBOX txtStatus (Label: 'Status', Attribute: Status, Editable: [Status != 'Closed']) +TEXTBOX txtStatus (Label: 'Status', Attribute: Status, Editable: $currentObject/Status != 'Closed') ``` Static values: `Editable: Never`, `Editable: Always`. diff --git a/docs-site/src/language/project-settings.md b/docs-site/src/language/project-settings.md index 661230d847..0c450ef835 100644 --- a/docs-site/src/language/project-settings.md +++ b/docs-site/src/language/project-settings.md @@ -58,13 +58,13 @@ ALTER SETTINGS CONFIGURATION 'default' HttpPortNumber = '8080'; Override a constant value within a specific configuration: ```sql -ALTER SETTINGS CONSTANT '' VALUE '' IN CONFIGURATION ''; +ALTER SETTINGS CONSTANT @ VALUE '' IN CONFIGURATION ''; ``` Example: ```sql -ALTER SETTINGS CONSTANT 'MyModule.ApiBaseUrl' VALUE 'https://staging.example.com' IN CONFIGURATION 'default'; +ALTER SETTINGS CONSTANT @MyModule.ApiBaseUrl VALUE 'https://staging.example.com' IN CONFIGURATION 'default'; ``` ### Language Settings diff --git a/docs-site/src/language/workflow-activities.md b/docs-site/src/language/workflow-activities.md index bc2e58e63b..35b7927e93 100644 --- a/docs-site/src/language/workflow-activities.md +++ b/docs-site/src/language/workflow-activities.md @@ -142,24 +142,26 @@ CALL WORKFLOW HR.BackgroundCheck COMMENT 'Run background check sub-process'; ## Decision -Branch the workflow based on a condition. Each outcome contains a block of activities: +Branch the workflow based on a condition. The condition is a bare expression, Boolean or enumeration; each outcome contains a block of activities: ```sql -DECISION [''] - OUTCOMES '' { } ['' { }] ...; +DECISION [] [COMMENT ''] + OUTCOMES TRUE -> { } FALSE -> { }; ``` Example: ```sql -DECISION 'Order value over $1000?' - OUTCOMES 'Yes' { +DECISION $WorkflowContext/Total > 1000 COMMENT 'Order value over $1000?' + OUTCOMES TRUE -> { USER TASK ManagerApproval 'Manager must approve' PAGE Shop.ApprovalPage - OUTCOMES 'Approved' { } 'Rejected' { END; }; - } 'No' { }; + OUTCOMES 'Approved' { } 'Rejected' { END WORKFLOW; }; + } FALSE -> { }; ``` +An enumeration decision has one outcome per qualified enumeration value, plus `''` for "none of the above". The expression used to be written in a string (`DECISION '$WorkflowContext/Total > 1000'`); that form still parses and warns `MDL-DEPR080`, and `mxcli fmt --upgrade` rewrites it. + ## Parallel Split Execute multiple paths concurrently. The workflow continues after all paths complete: @@ -206,13 +208,13 @@ JUMP TO ReviewTask; Pause the workflow until a timer expression evaluates: ```sql -WAIT FOR TIMER ['']; +WAIT FOR TIMER [] [] [COMMENT '']; ``` Example: ```sql -WAIT FOR TIMER 'addDays([%CurrentDateTime%], 3)'; +WAIT FOR TIMER addDays([%CurrentDateTime%], 3); ``` ## Wait for Notification @@ -262,7 +264,7 @@ EVENT SUBPROCESS [''] { }; EVENT SUBPROCESS [''] - ON (INTERRUPTING | NON INTERRUPTING) TIMER '' [AS ] [COMMENT ''] + ON (INTERRUPTING | NON INTERRUPTING) TIMER [AS ] [COMMENT ''] { }; ``` diff --git a/docs-site/src/language/workflow-structure.md b/docs-site/src/language/workflow-structure.md index 0a647bb91e..b6f33ee555 100644 --- a/docs-site/src/language/workflow-structure.md +++ b/docs-site/src/language/workflow-structure.md @@ -43,15 +43,15 @@ BEGIN }; -- After both paths complete, manager reviews - DECISION 'Manager approval required?' - OUTCOMES 'Yes' { + DECISION $WorkflowContext/NeedsManagerApproval COMMENT 'Manager approval required?' + OUTCOMES TRUE -> { USER TASK ManagerReview 'Review onboarding' PAGE HR.ManagerReviewPage OUTCOMES 'Approve' { } 'Reject' { CALL MICROFLOW HR.ACT_RejectOnboarding; END WORKFLOW; }; - } 'No' { }; + } FALSE -> { }; CALL MICROFLOW HR.ACT_CompleteOnboarding; END WORKFLOW; diff --git a/docs-site/src/reference/agent/create-knowledge-base.md b/docs-site/src/reference/agent/create-knowledge-base.md index 4a1d5832b3..72b16c3cbf 100644 --- a/docs-site/src/reference/agent/create-knowledge-base.md +++ b/docs-site/src/reference/agent/create-knowledge-base.md @@ -5,7 +5,7 @@ ```sql CREATE [ OR MODIFY ] KNOWLEDGE BASE module.Name ( Provider: MxCloudGenAI, - key: module.KeyConstant + key: @module.KeyConstant ); DROP KNOWLEDGE BASE module.Name @@ -44,7 +44,7 @@ CREATE CONSTANT MyModule."KBKey" CREATE KNOWLEDGE BASE MyModule."ProductDocs" ( Provider: MxCloudGenAI, - key: MyModule.KBKey + key: @MyModule.KBKey ); / ``` @@ -54,7 +54,7 @@ CREATE KNOWLEDGE BASE MyModule."ProductDocs" ( ```sql CREATE OR MODIFY KNOWLEDGE BASE MyModule."ProductDocs" ( Provider: MxCloudGenAI, - key: MyModule.KBKey + key: @MyModule.KBKey ); / ``` diff --git a/docs-site/src/reference/agent/create-model.md b/docs-site/src/reference/agent/create-model.md index 15e29e0584..3a6be9641d 100644 --- a/docs-site/src/reference/agent/create-model.md +++ b/docs-site/src/reference/agent/create-model.md @@ -5,7 +5,7 @@ ```sql CREATE [ OR MODIFY ] MODEL module.Name ( Provider: MxCloudGenAI, - key: module.KeyConstant + key: @module.KeyConstant [, DisplayName: 'display name' ] [, KeyName: 'portal key name' ] [, Environment: 'environment' ] @@ -58,7 +58,7 @@ CREATE CONSTANT MyModule."ModelKey" CREATE MODEL MyModule."GPT4Model" ( Provider: MxCloudGenAI, - key: MyModule.ModelKey + key: @MyModule.ModelKey ); / ``` @@ -68,7 +68,7 @@ CREATE MODEL MyModule."GPT4Model" ( ```sql CREATE MODEL MyModule."ConfiguredModel" ( Provider: MxCloudGenAI, - key: MyModule.ModelKey, + key: @MyModule.ModelKey, DisplayName: 'GPT-4 Turbo (128K)', KeyName: 'prod-gpt4-turbo', Environment: 'production' @@ -81,7 +81,7 @@ CREATE MODEL MyModule."ConfiguredModel" ( ```sql CREATE OR MODIFY MODEL MyModule."GPT4Model" ( Provider: MxCloudGenAI, - key: MyModule.ModelKey, + key: @MyModule.ModelKey, DisplayName: 'GPT-4 Turbo (Updated)' ); / diff --git a/docs-site/src/reference/domain-model/create-constant.md b/docs-site/src/reference/domain-model/create-constant.md index a3a6ef5fa0..9ca7e8fcdc 100644 --- a/docs-site/src/reference/domain-model/create-constant.md +++ b/docs-site/src/reference/domain-model/create-constant.md @@ -72,7 +72,7 @@ CREATE CONSTANT MyModule.DatabasePassword TYPE String DEFAULT ''; CREATE CONSTANT MyModule.ApiBaseUrl TYPE String DEFAULT 'https://api.example.com'; -- Override in a specific runtime configuration -ALTER SETTINGS CONSTANT 'MyModule.ApiBaseUrl' VALUE 'https://staging.example.com' IN CONFIGURATION 'Staging'; +ALTER SETTINGS CONSTANT @MyModule.ApiBaseUrl VALUE 'https://staging.example.com' IN CONFIGURATION 'Staging'; ``` ### Shared and private values diff --git a/docs-site/src/reference/security/revoke.md b/docs-site/src/reference/security/revoke.md index a499b549b2..203db6dd7f 100644 --- a/docs-site/src/reference/security/revoke.md +++ b/docs-site/src/reference/security/revoke.md @@ -4,10 +4,10 @@ ```sql -- Entity access (full -- removes entire rule) -REVOKE module.Role ON module.Entity +REVOKE ALL ON ENTITY module.Entity FROM module.Role [, ...] -- Entity access (partial -- downgrades specific rights) -REVOKE module.Role ON module.Entity ( rights ) +REVOKE rights ON ENTITY module.Entity FROM module.Role [, ...] -- Microflow access REVOKE EXECUTE ON MICROFLOW module.Name FROM module.Role [, ...] @@ -25,7 +25,7 @@ Removes previously granted access rights from module roles. Each form is the cou ### Entity Access -Without a rights list, removes the entire entity access rule for the specified module role on the entity. +The entity revoke mirrors the entity grant: rights first, then `ON ENTITY`, then the roles after `FROM`. `REVOKE ALL` removes the entire entity access rule for the specified module roles on the entity. The older form with the role first, `REVOKE module.Role ON module.Entity [( rights )]`, still parses and warns `MDL-DEPR082`; `mxcli fmt --upgrade` rewrites it. With a rights list, performs a **partial revoke**: `REVOKE READ (x)` sets member x to no access. `REVOKE WRITE (x)` downgrades member x from ReadWrite to ReadOnly. `REVOKE CREATE` and `REVOKE DELETE` remove the structural permission. The access rule itself is preserved. @@ -69,25 +69,25 @@ Removes execute permission on a nanoflow from one or more module roles. Remove all entity access for a role: ```sql -REVOKE Shop.Viewer ON Shop.Customer; +REVOKE ALL ON ENTITY Shop.Customer FROM Shop.Viewer; ``` Partial revoke -- remove read access on a specific attribute: ```sql -REVOKE Shop.User ON Shop.Customer (READ (Notes)); +REVOKE READ (Notes) ON ENTITY Shop.Customer FROM Shop.User; ``` Partial revoke -- downgrade write to read-only: ```sql -REVOKE Shop.User ON Shop.Customer (WRITE (Email)); +REVOKE WRITE (Email) ON ENTITY Shop.Customer FROM Shop.User; ``` Partial revoke -- remove structural permission: ```sql -REVOKE Shop.User ON Shop.Customer (DELETE); +REVOKE DELETE ON ENTITY Shop.Customer FROM Shop.User; ``` Remove microflow execution from multiple roles: diff --git a/docs-site/src/reference/settings/alter-settings.md b/docs-site/src/reference/settings/alter-settings.md index c8593588b5..c52ec1f356 100644 --- a/docs-site/src/reference/settings/alter-settings.md +++ b/docs-site/src/reference/settings/alter-settings.md @@ -6,9 +6,9 @@ ALTER SETTINGS CONFIGURATION 'name' key = value - ALTER SETTINGS CONSTANT 'name' VALUE 'value' IN CONFIGURATION 'config' + ALTER SETTINGS CONSTANT @Module.Name VALUE 'value' IN CONFIGURATION 'config' - ALTER SETTINGS DROP CONSTANT 'name' IN CONFIGURATION 'config' + ALTER SETTINGS DROP CONSTANT @Module.Name IN CONFIGURATION 'config' CREATE CONFIGURATION 'name' [key = value, ...] @@ -32,7 +32,7 @@ Modifies project settings by category. Each category has its own syntax and avai **CONFIGURATION** settings control named runtime configurations. Each project can have multiple configurations (e.g., `default`, `staging`, `production`). Settings include database type, database URL, HTTP port number, and other runtime parameters. The configuration name must be enclosed in single quotes. -**CONSTANT** settings override the default value of a project constant within a specific runtime configuration. Both the constant name and the configuration name must be enclosed in single quotes. +**CONSTANT** settings override the default value of a project constant within a specific runtime configuration. The constant is named as MDL names a constant everywhere, `@Module.Name`; the configuration name is enclosed in single quotes. The older quoted constant name (`CONSTANT 'Module.Name'`) still parses and warns `MDL-DEPR085`; `mxcli fmt --upgrade` rewrites it. **LANGUAGE** settings control localization: the default language code, and the list of **enabled** languages — the only ones a build emits translations for. @@ -50,8 +50,11 @@ task's group targeting selects from. Groups need Mendix **11.2** or later. **value** : The new value for the setting. String values must be enclosed in single quotes. -**name** (CONFIGURATION, CONSTANT) -: The name of the configuration or constant, enclosed in single quotes. +**name** (CONFIGURATION) +: The name of the configuration, enclosed in single quotes. + +**Module.Name** (CONSTANT) +: The constant, written `@Module.Name`. **config** (CONSTANT only) : The name of the runtime configuration where the constant override applies, enclosed in single quotes. @@ -83,7 +86,7 @@ ALTER SETTINGS CONFIGURATION 'production' DatabaseUrl = 'jdbc:postgresql://dbhos ### Override a constant in a configuration ```sql -ALTER SETTINGS CONSTANT 'MyModule.ApiBaseUrl' VALUE 'https://api.staging.example.com' IN CONFIGURATION 'staging'; +ALTER SETTINGS CONSTANT @MyModule.ApiBaseUrl VALUE 'https://api.staging.example.com' IN CONFIGURATION 'staging'; ``` An override's value is either **shared** — stored in the model, and so in version @@ -176,7 +179,7 @@ ALTER SETTINGS RUNTIME JavaVersion = '17'; ### Remove a constant override from a configuration ```sql -ALTER SETTINGS DROP CONSTANT 'MyModule.ApiBaseUrl' IN CONFIGURATION 'staging'; +ALTER SETTINGS DROP CONSTANT @MyModule.ApiBaseUrl IN CONFIGURATION 'staging'; ``` ### Create a new configuration diff --git a/docs-site/src/reference/workflow/create-workflow.md b/docs-site/src/reference/workflow/create-workflow.md index 9073fd1b06..f12c450a47 100644 --- a/docs-site/src/reference/workflow/create-workflow.md +++ b/docs-site/src/reference/workflow/create-workflow.md @@ -32,7 +32,7 @@ USER TASK name 'caption' [ TARGETING MICROFLOW module.MicroflowName ] [ TARGETING [ USERS | GROUPS ] XPATH [ xpath ] ] [ ENTITY module.Entity ] - [ DUE DATE 'expression' ] + [ DUE DATE expression ] [ DESCRIPTION 'text' ] OUTCOMES 'OutcomeName' { activities } [ 'OutcomeName' { activities } ... ] ``` @@ -63,10 +63,12 @@ CALL WORKFLOW module.Name [ 'caption' ] : A conditional branch based on an expression. Each outcome maps to a different execution path. ```sql -DECISION [ 'caption' ] - OUTCOMES 'OutcomeName' { activities } [ 'OutcomeName' { activities } ... ] +DECISION [ name ] expression [ COMMENT 'caption' ] + OUTCOMES TRUE -> { activities } FALSE -> { activities } ``` +Every workflow expression — a decision's condition, a timer's delay, a due date — is written bare: `DECISION $Context/Total > 1000`, `DUE DATE addDays([%CurrentDateTime%], 3)`. The string form (`DECISION '$Context/Total > 1000'`) still parses and warns `MDL-DEPR080`; `mxcli fmt --upgrade` rewrites it. + **PARALLEL SPLIT** : Splits the workflow into parallel paths that execute concurrently and rejoin before continuing. @@ -88,7 +90,7 @@ JUMP TO activity_name [ 'caption' ] : Pauses the workflow until a timer expression elapses. ```sql -WAIT FOR TIMER [ 'duration_expression' ] +WAIT FOR TIMER [ name ] [ datetime_expression ] [ COMMENT 'caption' ] ``` **WAIT FOR NOTIFICATION** @@ -113,8 +115,8 @@ User tasks, call microflow activities, and wait-for-notification activities supp USER TASK name 'caption' PAGE module.Page OUTCOMES 'Done' { } - BOUNDARY EVENT InterruptingTimer '${PT1H}' { activities } - BOUNDARY EVENT NonInterruptingTimer '${PT30M}' { activities } + BOUNDARY EVENT INTERRUPTING TIMER addHours([%CurrentDateTime%], 1) { activities } + BOUNDARY EVENT NON INTERRUPTING TIMER addMinutes([%CurrentDateTime%], 30) { activities } ``` ## Parameters @@ -185,7 +187,7 @@ BEGIN USER TASK AssignTask 'Handle ticket' PAGE Support.TicketPage OUTCOMES 'Resolved' { } - BOUNDARY EVENT InterruptingTimer '${PT24H}' { + BOUNDARY EVENT INTERRUPTING TIMER addHours([%CurrentDateTime%], 24) { CALL MICROFLOW Support.ACT_EscalateTicket; }; END WORKFLOW; diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index fad6388442..750314028e 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -671,8 +671,8 @@ Nested folders use `/` separator: `'Parent/Child/Grandchild'`. Missing folders a | Revoke page access | `revoke view on page Mod.Page from Mod.Role, ...;` | | | Grant entity access | `grant create, delete, read *, write * on entity Mod.Entity to Mod.Role;` / `grant read * on entity Mod.Entity to Mod.Role where [Status = 'Open'];` | The XPath is in brackets, quotes written once; the old `grant Mod.Role on Mod.Entity (…) where '…'` warns MDL-DEPR030 (`fmt --upgrade` rewrites it). Additive — merges with existing. A module role must be qualified: a bare `Role` parses but is refused (MDL-GRANT02). Inherited members are named like the entity's own (`read *` covers them); an unknown name is an error. Entities extending `System.User` are the exception — their platform members must not be granted | | Access for members added later | — | A rule's default for new members is derived from the grant: `write *` → ReadWrite, `read *` → ReadOnly, member lists alone → **None**. So an attribute added later is granted None on a member-listed rule — clean build, blank field. `alter entity … add attribute` warns and prints the widening grant. The rule's *default* decides this, not how narrow its member list is | -| Revoke entity access | `revoke Mod.Role on Mod.Entity;` | Full revoke — removes entire rule | -| Revoke entity access (partial) | `revoke Mod.Role on Mod.Entity (read (attr));` | Partial — downgrades specific rights | +| Revoke entity access | `revoke all on entity Mod.Entity from Mod.Role;` | Full revoke — removes entire rule | +| Revoke entity access (partial) | `revoke read (attr) on entity Mod.Entity from Mod.Role;` | Partial — downgrades specific rights | | Set security level | `alter app security level off\|prototype\|production;` | | | Toggle demo users | `alter app security demo users on\|off;` | | | Enable guest access | `alter app security guest access on role UserRole;` | Anonymous users. The role is what visitors get — its entity access is the public surface. Mendix fails the build without one (CE0133), so `on` is refused unless a role is given or already stored. mxcli validates the role exists; Mendix does not | @@ -708,7 +708,7 @@ mandatory and a misplaced clause failed with a token error (`mismatched input 'ON' expecting ';'`) that named neither the clause nor the rule. **Workflow Activity Types:** -- `[multi] user task '' [page Mod.Page] [targeting [users|groups] microflow Mod.MF] [targeting [users|groups] xpath []] [on created microflow Mod.MF] [entity Mod.Entity] [due date ''] [description ''] [participants all|| percent] [decide by ] [await all users] [outcomes '' { } ...] [boundary event …];` +- `[multi] user task '' [page Mod.Page] [targeting [users|groups] microflow Mod.MF] [targeting [users|groups] xpath []] [on created microflow Mod.MF] [entity Mod.Entity] [due date ] [description ''] [participants all|| percent] [decide by ] [await all users] [outcomes '' { } ...] [boundary event …];` - **Multi-user only:** `decide by consensus|majority more than half|majority most chosen|threshold percent|votes fallback ''`, `decide by veto ''`, `decide by microflow Mod.MF`. A fallback is required for consensus, majority and threshold (CE1866), a veto needs its outcome (CE1867), and a decision microflow returns String (CE5012) — all `MDL-WF13` / check. Omitted: all participants, consensus on the first outcome, not waiting. - The **task page** must take a `System.WorkflowUserTask` parameter — none at all is CE7410, none of that type is CE7412; extra parameters are allowed. - A **targeting microflow** takes exactly `System.Workflow` + the context entity (or a generalization of it), in either order — anything else is CE6677. Users targeting returns a list of `System.User`, groups a list of `System.WorkflowGroup`. @@ -717,18 +717,18 @@ mandatory and a misplaced clause failed with a token error - `call microflow Mod.MF[( = , ...)] [as ] [comment ''] [outcomes '' -> { } ...];` - `call agent microflow Mod.MF [as ] [comment ''] [with ( = '', ...)] [outcomes … -> { } ...];` — an **AI agent task** (Mendix 11.9+): the call-microflow statement stored as `Workflows$AIAgentTaskActivity`. Its microflow must take at least one parameter (CE1590). - `call workflow Mod.WF[( = , ...)] [as ] [comment ''];` -- `decision [] [''] outcomes -> { } ...;` +- `decision [] [] [comment ''] outcomes -> { } ...;` — the expression is bare; a decision's condition, a timer and a due date written in a string (`decision ''`) warn MDL-DEPR080 - `parallel split [] path 1 { } path 2 { };` - `jump to ;` -- `wait for timer [] [''];` +- `wait for timer [] [] [comment ''];` - `wait for notification [];` - `notification [] [comment ''];` — an intermediate notification event (Mendix 11.11+) - `end workflow [comment ''];` — only inside a `{ }` block; ends the whole workflow -- Boundary events, after `outcomes`: `boundary event [non] interrupting timer '' { … }` or `boundary event [non] interrupting notification [''] { … }` (11.11+). One interrupting event per activity (CE6697, MDL-WF15). +- Boundary events, after `outcomes`: `boundary event [non] interrupting timer { … }` or `boundary event [non] interrupting notification [''] { … }` (11.11+). One interrupting event per activity (CE6697, MDL-WF15). **Notifying a workflow** (a microflow statement): `[$Notified =] notify workflow $Workflow target Module.Workflow.ElementName;` — the element is a notification-started event sub-process's start, a notification activity, a notification boundary event or a wait for notification, and mxcli resolves which. The target is required (CE0166, MDL-WF16). -**Event sub-processes**, after the main body: `event subprocess [''] on [non] interrupting notification [] [''] { … };` (11.8+) or `… on [non] interrupting timer '' [as ] [comment ''] { … };` (11.13+). The body's End is implicit; a `jump to` stays in its own sub-process (CE6682, MDL-WF05); a timer needs its expression (CE0126, MDL-WF14). +**Event sub-processes**, after the main body: `event subprocess [''] on [non] interrupting notification [] [''] { … };` (11.8+) or `… on [non] interrupting timer [as ] [comment ''] { … };` (11.13+). The body's End is implicit; a `jump to` stays in its own sub-process (CE6682, MDL-WF05); a timer needs its expression (CE0126, MDL-WF14). **Workflow event handlers.** `on workflow events (UserTaskStarted, UserTaskEnded) microflow Mod.MF as 'Task audit'` in the header runs the microflow for each listed @@ -780,7 +780,7 @@ an enumeration return as well, and a required (`not null`) attribute does **not* exempt it. Boolean decisions (`true`/`false`) do not take one. ```sql - decision '$WorkflowContext/Kind' + decision $WorkflowContext/Kind outcomes 'Module.Kind.Standard' -> { } 'Module.Kind.Priority' -> { } @@ -809,14 +809,14 @@ Modify an existing workflow's properties, activities, outcomes, paths, condition | Set display name | `set display 'name'` | Workflow-level display name | | Set description | `set description 'text'` | Workflow-level description | | Set export level | `set export level api\|Hidden` | Visibility level | -| Set due date | `set due date 'expr'` | Workflow-level due date expression | +| Set due date | `set due date ` | Workflow-level due date expression | | Set overview page | `set overview page Module.Page` | Workflow overview page | | Set parameter | `set parameter $Var: Module.Entity` | Workflow context parameter | | Set activity page | `set activity name page Module.Page` | Change user task page | | Set activity description | `set activity name description 'text'` | Activity description | | Set activity targeting | `set activity name targeting [users\|groups] microflow Module.MF` | Target user/group assignment | | Set activity XPath | `set activity name targeting xpath []` | XPath targeting, in brackets; the quoted `xpath '[…]'` warns MDL-DEPR031 | -| Set activity due date | `set activity name due date 'expr'` | Activity-level due date | +| Set activity due date | `set activity name due date ` | Activity-level due date | | Insert activity | `insert after name call microflow Module.MF` | Insert after named activity | | Drop activity | `drop activity name` | Remove activity by name | | Replace activity | `replace activity name with activity` | Replace activity in-place | @@ -826,7 +826,7 @@ Modify an existing workflow's properties, activities, outcomes, paths, condition | Drop path | `drop path 'name' on activity` | Remove parallel split path | | Insert condition | `insert condition 'name' on activity { body }` | Add decision branch | | Drop condition | `drop condition 'name' on activity` | Remove decision branch | -| Insert boundary event | `insert boundary event on activity interrupting timer ['expr'] { body }` | Add boundary timer | +| Insert boundary event | `insert boundary event on activity interrupting timer [] { body }` | Add boundary timer | | Drop boundary event | `drop boundary event on activity` | Remove boundary event | **Activity references** can be identifiers (`ReviewOrder`) or string literals (`'Review the order'`). Use `@N` suffix for positional disambiguation when multiple activities share a name (e.g., `ACT_Process@2`). @@ -860,7 +860,7 @@ alter workflow Module.OrderApproval -- Boundary events alter workflow Module.OrderApproval - insert boundary event on ReviewOrder interrupting timer 'addHours([%CurrentDateTime%], 2)' { + insert boundary event on ReviewOrder interrupting timer addHours([%CurrentDateTime%], 2) { call microflow Module.ACT_BoundaryHandler; jump to ReviewOrder; }; @@ -975,8 +975,8 @@ still flagged rather than guessed at. | Describe settings | `describe settings;` | Full MDL output (round-trippable) | | Alter model settings | `alter settings runtime key = value;` | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. | | Alter configuration | `alter settings configuration 'Name' key = value;` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | -| Alter constant | `alter settings constant 'Name' value 'val' in configuration 'cfg';` | Override constant per configuration | -| Drop constant override | `alter settings drop constant 'Name' in configuration 'cfg';` | Reset to default value | +| Alter constant | `alter settings constant @Module.Name value 'val' in configuration 'cfg';` | Override constant per configuration | +| Drop constant override | `alter settings drop constant @Module.Name in configuration 'cfg';` | Reset to default value | | Create or modify configuration | `create or modify configuration 'Name' [key = value, ...];` | Upsert — what `describe settings` emits, so a described project replays onto a target that already has `Default` | | Create configuration | `create configuration 'Name' [key = value, ...];` | New server configuration. `DatabaseType` must be `Db2`, `Hsqldb`, `MySql`, `Oracle`, `PostgreSql`, `SapHana` or `SqlServer` (case-insensitive) | | Drop configuration | `drop configuration [if exists] 'Name';` | Remove a configuration | @@ -1568,8 +1568,8 @@ create page MyModule.Customer_Edit | DesktopWidth | `column (desktopwidth: 8)` | 1-12 or AutoFill | | TabletWidth | `column (tabletwidth: 6)` | 1-12 or AutoFill (default: auto) | | PhoneWidth | `column (phonewidth: 12)` | 1-12 or AutoFill (default: auto) | -| Visible | `textbox txt (visible: [IsActive])` | Conditional visibility (XPath expression) | -| Editable | `textbox txt (editable: [status != 'Closed'])` | Conditional editability (XPath expression) | +| Visible | `textbox txt (visible: $currentObject/IsActive)` | Conditional visibility: a client expression, stored as written; `visible: [IsActive]` warns MDL-DEPR081 | +| Editable | `textbox txt (editable: $currentObject/Status != 'Closed')` | Conditional editability: a client expression, stored as written | | Image | `staticimage img (Image: 'Mod.Images.logo')` | Image-collection entry, `Module.Collection.Image`. Omitted → CE0436 "No image selected." | | DataSource (dynamicimage) | `dynamicimage img (DataSource: database from Mod.Photo)` | The entity holding the image. Omitted → CE0489 "Select an entity for the data source of this dynamic image." | | DefaultImage | `dynamicimage img (DefaultImage: 'Mod.Images.placeholder')` | Fallback when the object has no image | diff --git a/docs/05-mdl-specification/01-language-reference.md b/docs/05-mdl-specification/01-language-reference.md index 1bafd64bff..a15095ebfe 100644 --- a/docs/05-mdl-specification/01-language-reference.md +++ b/docs/05-mdl-specification/01-language-reference.md @@ -1121,13 +1121,13 @@ Partial revoke semantics: `revoke read (x)` sets member x to no access. `revoke **Examples:** ```sql -- Remove all access -revoke Shop.Viewer on Shop.Customer; +revoke all on entity Shop.Customer from Shop.Viewer; -- Remove read on specific attribute -revoke Shop.User on Shop.Customer (read (Phone)); +revoke read (Phone) on entity Shop.Customer from Shop.User; -- Downgrade write to read-only -revoke Shop.User on Shop.Customer (write (Email)); +revoke write (Email) on entity Shop.Customer from Shop.User; ``` ### CREATE USER ROLE @@ -1272,8 +1272,8 @@ Displays one row per constant per configuration. Shows the default value followe ```sql alter settings runtime key = value; alter settings configuration 'Name' key = value; -alter settings constant 'Name' value 'val' in configuration 'cfg'; -alter settings drop constant 'Name' in configuration 'cfg'; +alter settings constant @Module.Name value 'val' in configuration 'cfg'; +alter settings drop constant @Module.Name in configuration 'cfg'; alter settings LANGUAGE key = value; alter settings workflows key = value; ``` @@ -1298,7 +1298,7 @@ show constant values; create configuration 'Staging' DatabaseType = 'POSTGRESQL', DatabaseUrl = 'staging-db:5432'; -- Remove a constant override -alter settings drop constant 'MyModule.ApiKey' in configuration 'Default'; +alter settings drop constant @MyModule.ApiKey in configuration 'Default'; -- Drop a configuration drop configuration 'Staging'; diff --git a/docs/05-mdl-specification/03-domain-model.md b/docs/05-mdl-specification/03-domain-model.md index 459f935dfd..c542d848bf 100644 --- a/docs/05-mdl-specification/03-domain-model.md +++ b/docs/05-mdl-specification/03-domain-model.md @@ -398,7 +398,7 @@ grant read (Name, Email), write (Email) on entity Sales.Customer to Sales.User; grant read *, write * on entity Sales.Order to Sales.User where [Status = 'Open']; -- Revoke -revoke Sales.User on Sales.Order; +revoke all on entity Sales.Order from Sales.User; ``` ### Access Rule Properties diff --git a/mdl-examples/bug-tests/workflow-586-clause-order-canonical.mdl b/mdl-examples/bug-tests/workflow-586-clause-order-canonical.mdl index caaa7e8322..165dd72f9e 100644 --- a/mdl-examples/bug-tests/workflow-586-clause-order-canonical.mdl +++ b/mdl-examples/bug-tests/workflow-586-clause-order-canonical.mdl @@ -93,7 +93,7 @@ create or replace workflow WF586.Review description 'Reviews a request' export level Hidden overview page WF586.Overview - due date '${P2D}' + due date addDays([%CurrentDateTime%], 2) on any workflow event microflow WF586.ACT_OnEvent as 'all events' begin user task Review 'Review the request' @@ -101,17 +101,17 @@ begin targeting microflow WF586.ACT_GetUsers on created microflow WF586.ACT_OnCreated entity WF586.Request - due date '${PT4H}' + due date addHours([%CurrentDateTime%], 4) description 'Check the request and decide' outcomes 'Approve' { call microflow WF586.ACT_Audit; } 'Reject' { } - boundary event interrupting timer '${PT8H}'; + boundary event interrupting timer addHours([%CurrentDateTime%], 8); multi user task Confirm 'Confirm the decision' page WF586.TaskPage targeting groups xpath '[Name = ''Administrator'']' - due date '${PT2H}' + due date addHours([%CurrentDateTime%], 2) participants 60 percent decide by majority more than half fallback 'Confirmed' await all users diff --git a/mdl-examples/bug-tests/workflow-586-clause-order.mdl b/mdl-examples/bug-tests/workflow-586-clause-order.mdl index a931f66b58..2a6b07ad98 100644 --- a/mdl-examples/bug-tests/workflow-586-clause-order.mdl +++ b/mdl-examples/bug-tests/workflow-586-clause-order.mdl @@ -108,7 +108,7 @@ end; -- grammar demanded: the event handler first, `folder` last. create or replace workflow WF586.Review on any workflow event microflow WF586.ACT_OnEvent as 'all events' - due date '${P2D}' + due date addDays([%CurrentDateTime%], 2) description 'Reviews a request' overview page WF586.Overview display 'Request review' @@ -121,12 +121,12 @@ begin user task Review 'Review the request' on created microflow WF586.ACT_OnCreated description 'Check the request and decide' - boundary event interrupting timer '${PT8H}' + boundary event interrupting timer addHours([%CurrentDateTime%], 8) entity WF586.Request outcomes 'Approve' { call microflow WF586.ACT_Audit; } 'Reject' { } - due date '${PT4H}' + due date addHours([%CurrentDateTime%], 4) page WF586.TaskPage targeting microflow WF586.ACT_GetUsers; @@ -140,6 +140,6 @@ begin page WF586.TaskPage participants 60 percent targeting groups xpath '[Name = ''Administrator'']' - due date '${PT2H}'; + due date addHours([%CurrentDateTime%], 2); end workflow; / diff --git a/mdl-examples/doctype-tests/03-page-examples.mdl b/mdl-examples/doctype-tests/03-page-examples.mdl index cba76478b4..f137e8efe3 100644 --- a/mdl-examples/doctype-tests/03-page-examples.mdl +++ b/mdl-examples/doctype-tests/03-page-examples.mdl @@ -2672,7 +2672,7 @@ create page PgTest.P040_ConditionalProps row row1 { column col1 (desktopwidth: 8, tabletwidth: 6, phonewidth: 12) { dataview dv1 (datasource: microflow PgTest.DS_GetFirstCustomer) { - textbox txtName (label: 'Name', attribute: Name, visible: [$currentObject/Name != empty]) + textbox txtName (label: 'Name', attribute: Name, visible: $currentObject/Name != empty) textbox txtPhone (label: 'Phone', attribute: Phone, editable: [true]) } } diff --git a/mdl-examples/doctype-tests/08-security-examples.mdl b/mdl-examples/doctype-tests/08-security-examples.mdl index 0ee6568d48..1caa821446 100644 --- a/mdl-examples/doctype-tests/08-security-examples.mdl +++ b/mdl-examples/doctype-tests/08-security-examples.mdl @@ -414,13 +414,13 @@ grant read (Notes) on entity SecTest.Customer to SecTest.User; * Remove read access on a specific attribute (Notes). * Other permissions are preserved. */ -revoke SecTest.User on SecTest.Customer (read (Notes)); +revoke read (Notes) on entity SecTest.Customer from SecTest.User; / /** * Downgrade write to read-only on Email. */ -revoke SecTest.User on SecTest.Customer (write (Email)); +revoke write (Email) on entity SecTest.Customer from SecTest.User; / -- ============================================================================ @@ -430,7 +430,7 @@ revoke SecTest.User on SecTest.Customer (write (Email)); /** * Remove the Viewer role's access rule from Customer entirely. */ -revoke SecTest.Viewer on SecTest.Customer; +revoke all on entity SecTest.Customer from SecTest.Viewer; / -- Verify after revoke @@ -670,9 +670,9 @@ show access on page SecTest.Order_Overview; -- Revoke entity access to avoid CE0066 "entity access out of date". -- When a module has partial access rules (not all entities covered by all -- roles), MxBuild reports CE0066 regardless of security level. -revoke SecTest.Administrator on SecTest.Customer; -revoke SecTest.User on SecTest.Customer; -revoke SecTest.User on SecTest.Order; -revoke SecTest.Manager on SecTest.Customer; +revoke all on entity SecTest.Customer from SecTest.Administrator; +revoke all on entity SecTest.Customer from SecTest.User; +revoke all on entity SecTest.Order from SecTest.User; +revoke all on entity SecTest.Customer from SecTest.Manager; diff --git a/mdl-examples/doctype-tests/14-project-settings-examples.mdl b/mdl-examples/doctype-tests/14-project-settings-examples.mdl index 43e88e4c44..435ff47776 100644 --- a/mdl-examples/doctype-tests/14-project-settings-examples.mdl +++ b/mdl-examples/doctype-tests/14-project-settings-examples.mdl @@ -149,21 +149,21 @@ alter settings configuration 'Default' * Example 3.1: Override a constant value in a specific configuration */ create constant MyModule.ServerUrl type string default 'localhost:9092'; -alter settings constant 'MyModule.ServerUrl' value 'kafka:9092' +alter settings constant @MyModule.ServerUrl value 'kafka:9092' in configuration 'Default'; /** * Example 3.2: Remove a constant override (reset to default) */ -alter settings drop constant 'MyModule.ServerUrl' in configuration 'Default'; +alter settings drop constant @MyModule.ServerUrl in configuration 'Default'; /** * Example 3.3: Alternative syntax for removing a constant override * (Re-set the value first so there is something to drop) */ -alter settings constant 'MyModule.ServerUrl' value 'kafka:9092' +alter settings constant @MyModule.ServerUrl value 'kafka:9092' in configuration 'Default'; -alter settings constant 'MyModule.ServerUrl' drop in configuration 'Default'; +alter settings constant @MyModule.ServerUrl drop in configuration 'Default'; -- MARK: Create / Drop Configuration diff --git a/mdl-examples/doctype-tests/24-workflow-examples.mdl b/mdl-examples/doctype-tests/24-workflow-examples.mdl index 7372777181..d74400f887 100644 --- a/mdl-examples/doctype-tests/24-workflow-examples.mdl +++ b/mdl-examples/doctype-tests/24-workflow-examples.mdl @@ -158,7 +158,7 @@ begin call microflow WFTest.ACT_Validate; - decision '$WorkflowContext/Total > 1000' + decision $WorkflowContext/Total > 1000 outcomes true -> { call microflow WFTest.ACT_Process; } false -> { call microflow WFTest.ACT_Notify; } @@ -238,7 +238,7 @@ alter workflow WFTest.OrderApproval -- INSERT / DROP boundary event alter workflow WFTest.OrderApproval - insert boundary event on ReviewOrder interrupting timer 'addHours([%CurrentDateTime%], 2)' { + insert boundary event on ReviewOrder interrupting timer addHours([%CurrentDateTime%], 2) { call microflow WFTest.ACT_BoundaryHandler; jump to ReviewOrder; }; @@ -259,7 +259,7 @@ alter workflow WFTest.OrderApproval -- All remaining activity types alter workflow WFTest.OrderApproval - insert after ACT_Escalate@1 wait for timer 'addHours([%CurrentDateTime%], 1)' comment 'WaitOneHour'; + insert after ACT_Escalate@1 wait for timer addHours([%CurrentDateTime%], 1) comment 'WaitOneHour'; alter workflow WFTest.OrderApproval insert after WaitOneHour wait for notification comment 'WaitForApproval'; @@ -277,7 +277,7 @@ alter workflow WFTest.OrderApproval -- NON INTERRUPTING TIMER boundary event (compare with INTERRUPTING above) alter workflow WFTest.OrderApproval - insert boundary event on GroupApproval non interrupting timer 'addHours([%CurrentDateTime%], 48)' { + insert boundary event on GroupApproval non interrupting timer addHours([%CurrentDateTime%], 48) { call microflow WFTest.ACT_Notify; }; @@ -507,7 +507,7 @@ create workflow WFTest.NamedActivities begin call microflow WFTest.ACT_Validate as callMicroflow1; - decision decision1 '$WorkflowContext/Total > 1000' + decision decision1 $WorkflowContext/Total > 1000 outcomes true -> { user task Rework 'Fix and resubmit' @@ -525,7 +525,7 @@ begin path 2 { call microflow WFTest.ACT_Review as callMicroflow4; } ; - wait for timer timer1 'addHours([%CurrentDateTime%], 1)'; + wait for timer timer1 addHours([%CurrentDateTime%], 1) ; wait for notification waitForNotification1; end workflow; / @@ -544,7 +544,7 @@ end workflow; create workflow WFTest.EndInBranches parameter $OrderContext: WFTest.OrderContext begin - decision decision1 '$WorkflowContext/Total > 1000' + decision decision1 $WorkflowContext/Total > 1000 outcomes true -> { user task ManagerReview 'Manager review' @@ -560,11 +560,11 @@ begin outcomes 'Cancel' { end workflow; } 'Confirm' { } - boundary event interrupting timer 'addDays([%CurrentDateTime%], 3)' { + boundary event interrupting timer addDays([%CurrentDateTime%], 3) { end workflow comment 'Expired'; }; - wait for timer timer1 'addHours([%CurrentDateTime%], 1)'; + wait for timer timer1 addHours([%CurrentDateTime%], 1) ; end workflow; / @@ -783,11 +783,11 @@ begin outcomes 'Approve' { } 'Reject' { }; event subprocess ESP_Expire 'Expire' - on interrupting timer 'addDays([%CurrentDateTime%], 30)' as ExpireStart comment 'After 30 days' { + on interrupting timer addDays([%CurrentDateTime%], 30) as ExpireStart comment 'After 30 days' { }; event subprocess ESP_Remind 'Daily reminder' - on non interrupting timer 'addDays([%CurrentDateTime%], 1)' as RemindStart { + on non interrupting timer addDays([%CurrentDateTime%], 1) as RemindStart { call microflow WFTest.ACT_LogCancel with (OrderContext = '$WorkflowContext'); }; end workflow; diff --git a/mdl-examples/doctype-tests/27-agent-editor-model-examples.mdl b/mdl-examples/doctype-tests/27-agent-editor-model-examples.mdl index 167d916843..83f3e7c539 100644 --- a/mdl-examples/doctype-tests/27-agent-editor-model-examples.mdl +++ b/mdl-examples/doctype-tests/27-agent-editor-model-examples.mdl @@ -65,7 +65,7 @@ create constant ModelTest."ClaudeKey" */ create model ModelTest."GPT4Model" ( Provider: MxCloudGenAI, - key: ModelTest.GPT4Key + key: @ModelTest.GPT4Key ); / @@ -77,7 +77,7 @@ create model ModelTest."GPT4Model" ( */ create model ModelTest."ClaudeModel" ( Provider: MxCloudGenAI, - key: ModelTest.ClaudeKey + key: @ModelTest.ClaudeKey ); / @@ -95,7 +95,7 @@ create model ModelTest."ClaudeModel" ( */ create model ModelTest."ConfiguredModel" ( Provider: MxCloudGenAI, - key: ModelTest.GPT4Key, + key: @ModelTest.GPT4Key, DisplayName: 'GPT-4 Turbo (128K)', KeyName: 'prod-gpt4-turbo', Environment: 'production' @@ -153,11 +153,11 @@ list models in ModelTest; -- These statements produce errors (commented out to not block the script): -- Duplicate model name: --- CREATE MODEL ModelTest.GPT4Model (Provider: MxCloudGenAI, Key: ModelTest.GPT4Key); +-- CREATE MODEL ModelTest.GPT4Model (Provider: MxCloudGenAI, Key: @ModelTest.GPT4Key); -- → Error: model already exists: ModelTest.GPT4Model -- Missing constant: --- CREATE MODEL ModelTest.BadModel (Provider: MxCloudGenAI, Key: ModelTest.NoSuchConst); +-- CREATE MODEL ModelTest.BadModel (Provider: MxCloudGenAI, Key: @ModelTest.NoSuchConst); -- → Error: CREATE MODEL ModelTest.BadModel: constant not found: ModelTest.NoSuchConst -- Drop non-existent model: @@ -176,7 +176,7 @@ list models in ModelTest; create or modify model ModelTest."GPT4Model" ( Provider: MxCloudGenAI, - key: ModelTest.GPT4Key, + key: @ModelTest.GPT4Key, DisplayName: 'GPT-4 Turbo (Updated)' ); / diff --git a/mdl-examples/doctype-tests/28-agent-editor-examples.mdl b/mdl-examples/doctype-tests/28-agent-editor-examples.mdl index a83629ea4a..4347e0cd3b 100644 --- a/mdl-examples/doctype-tests/28-agent-editor-examples.mdl +++ b/mdl-examples/doctype-tests/28-agent-editor-examples.mdl @@ -46,7 +46,7 @@ create constant AgentTest."KBKey" create model AgentTest."TestModel" ( Provider: MxCloudGenAI, - key: AgentTest.ModelKey + key: @AgentTest.ModelKey ); / @@ -56,7 +56,7 @@ create model AgentTest."TestModel" ( create knowledge base AgentTest."ProductDocs" ( Provider: MxCloudGenAI, - key: AgentTest.KBKey + key: @AgentTest.KBKey ); / @@ -170,14 +170,14 @@ describe consumed mcp service AgentTest.WebSearch; create or modify model AgentTest."TestModel" ( Provider: MxCloudGenAI, - key: AgentTest.ModelKey, + key: @AgentTest.ModelKey, DisplayName: 'Updated Model Name' ); / create or modify knowledge base AgentTest."ProductDocs" ( Provider: MxCloudGenAI, - key: AgentTest.KBKey + key: @AgentTest.KBKey ); / diff --git a/mdl-examples/use-cases/ai-agent-platform-demo.mdl b/mdl-examples/use-cases/ai-agent-platform-demo.mdl index 528c8a20c1..d490b5da53 100644 --- a/mdl-examples/use-cases/ai-agent-platform-demo.mdl +++ b/mdl-examples/use-cases/ai-agent-platform-demo.mdl @@ -310,7 +310,7 @@ end workflow; -- Boundary timer: if the agent task sits unreviewed for 4 hours → auto-escalate alter workflow AICustSupport.CaseResolution insert boundary event on ReviewAIResponse interrupting - timer 'addHours([%CurrentDateTime%], 4)' { + timer addHours([%CurrentDateTime%], 4) { call microflow AICustSupport.ACT_Case_Escalate; jump to ReviewAIResponse; }; @@ -334,7 +334,7 @@ create constant AICustSupport."KBKey" -- Model document (visible in Studio Pro Agent Editor — developer UX) create model AICustSupport."GPT4TurboModel" ( Provider: MxCloudGenAI, - key: AICustSupport.LLMKey, + key: @AICustSupport.LLMKey, DisplayName: 'GPT-4 Turbo (128K context)', Environment: 'production' ); @@ -342,7 +342,7 @@ create model AICustSupport."GPT4TurboModel" ( -- Knowledge Base for RAG: product docs and known-solution articles create knowledge base AICustSupport."SupportKnowledgeBase" ( Provider: MxCloudGenAI, - key: AICustSupport.KBKey + key: @AICustSupport.KBKey ); -- Consumed MCP service: real-time web search tool (MCP 2025-03-26 / A2A) From 6f0b0224e658a2011412b5f30c4e722bf008beaf Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 05:56:25 +0000 Subject: [PATCH 07/35] mdl/visitor: keep MDL-DEPR074's begin/end apart from touching text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fmt --upgrade rewrote `insert after $X{ … }drop …` to `$Xbegin … enddrop`, which lexes as a variable and an identifier; the upgrade's re-parse refused it. The fragment's words now get a space where the source had none. Co-Authored-By: Claude Opus 5.5 --- mdl/upgrade/r2_children_test.go | 23 +++++++++++++++++++ mdl/visitor/visitor_r2_children.go | 37 +++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/mdl/upgrade/r2_children_test.go b/mdl/upgrade/r2_children_test.go index b629f902c2..8b3cb4ffb7 100644 --- a/mdl/upgrade/r2_children_test.go +++ b/mdl/upgrade/r2_children_test.go @@ -95,3 +95,26 @@ ALTER NANOFLOW M.N { t.Errorf("second upgrade changed the script again: %v", again.Rewritten) } } + +// A brace touches the text around it: `$X{ … }drop`. The braces are +// punctuation, `begin` and `end` are words, so the rewrite must not glue them +// to their neighbours (`$Xbegin` is a variable name, `enddrop` an identifier). +func TestUpgrade_R2AlterFragmentBracesTouchingTheirNeighbours(t *testing.T) { + src := `alter microflow M.F { + insert after $IsValid{log info node 'X' 'y';}drop log * node 'Debug' *; + replace 'Cap' with{log info node 'X' 'z';}; +}; +` + want := `alter microflow M.F { + insert after $IsValid begin log info node 'X' 'y'; end drop log * node 'Debug' *; + replace 'Cap' with begin log info node 'X' 'z'; end; +}; +` + res := mustUpgrade(t, src, Options{}) + if res.Source != want { + t.Fatalf("got:\n%s\nwant:\n%s", res.Source, want) + } + if res.Rewritten[deprecation.AlterFlowFragmentBraces] != 1 { + t.Errorf("Rewritten = %v", res.Rewritten) + } +} diff --git a/mdl/visitor/visitor_r2_children.go b/mdl/visitor/visitor_r2_children.go index 25a317e85a..64faca7369 100644 --- a/mdl/visitor/visitor_r2_children.go +++ b/mdl/visitor/visitor_r2_children.go @@ -3,6 +3,8 @@ package visitor import ( + "strings" + "github.com/antlr4-go/antlr/v4" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/deprecation" @@ -67,7 +69,7 @@ func (b *Builder) EnterStatement(ctx *parser.StatementContext) { if op, ok := x.GetParent().(antlr.ParserRuleContext); ok && op.GetStart() != nil { like = op.GetStart().GetText() } - use(deprecation.AlterFlowFragmentBraces, x.LBRACE().GetSymbol()).swap(x.LBRACE(), x.RBRACE(), + use(deprecation.AlterFlowFragmentBraces, x.LBRACE().GetSymbol()).swapWords(x.LBRACE(), x.RBRACE(), keywordLike(like, "begin"), keywordLike(like, "end")) } case *parser.MessageMemberTreeContext: @@ -100,6 +102,39 @@ func (u *r2Use) swap(open, closing antlr.TerminalNode, newOpen, newClose string) u.edits = append(u.edits, replaceSpan(o, o, newOpen), replaceSpan(c, c, newClose)) } +// swapWords respells a brace pair as the words newOpen … newClose. A brace is +// punctuation and may touch its neighbours (`$X{ … }drop`); a word may not, or +// it lexes as part of them (`$Xbegin`, `enddrop`), so each word is set apart +// by a space where the source has none. +func (u *r2Use) swapWords(open, closing antlr.TerminalNode, newOpen, newClose string) { + o, c := open.GetSymbol(), closing.GetSymbol() + u.edits = append(u.edits, + replaceSpan(o, o, padWord(o, newOpen)), + replaceSpan(c, c, padWord(c, newClose))) +} + +// padWord is word, with a space on each side where the text next to tok is +// not whitespace. A `;` after it needs none: `end;` is the canonical spelling. +func padWord(tok antlr.Token, word string) string { + in := tok.GetInputStream() + if in == nil { + return word + } + if start := tok.GetStart(); start > 0 && !isSpaceText(in.GetText(start-1, start-1)) { + word = " " + word + } + if stop := tok.GetStop(); stop+1 < in.Size() { + if next := in.GetText(stop+1, stop+1); next != ";" && !isSpaceText(next) { + word += " " + } + } + return word +} + +func isSpaceText(s string) bool { + return s == "" || strings.TrimSpace(s) == "" +} + // imageItems rewrites `( image X from file '…', … )` as // `{ image X ( File: '…' ) … }`. func (u *r2Use) imageItems(ctx *parser.ImageCollectionBodyContext) { From e39520b9d0beba4557929428da266455c3fc7036 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 05:56:26 +0000 Subject: [PATCH 08/35] mdl/deprecation: structural rewrites are noun phrases after "Rewrite the" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warning prints "Rewrite the "; MDL-DEPR031 and 070-074 began with "the" (or "each"), so it read "Rewrite the the fragment's …". A registry test now refuses a leading "the". Co-Authored-By: Claude Opus 5.5 --- mdl/deprecation/deprecation.go | 12 ++++++------ mdl/deprecation/deprecation_test.go | 6 ++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 0316633214..b1ce04eb34 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -322,7 +322,7 @@ var entries = []Entry{ Code: QuotedTargetingXPath, Old: "targeting [users|groups] xpath '[xpath]'", Canonical: "targeting [users|groups] xpath [xpath]", - Rewrite: Rewrite{Structural: "the XPath out of its string: `xpath '[Name = ''Admin'']'` becomes `xpath [Name = 'Admin']`"}, + Rewrite: Rewrite{Structural: "XPath out of its string: `xpath '[Name = ''Admin'']'` becomes `xpath [Name = 'Admin']`"}, RemovedIn: 2, Note: "XPath is written in [ ] everywhere (R5), so the quotes inside it are no longer doubled. " + "A string whose value is not a bracketed XPath is left in place and reported by `fmt --upgrade`.", @@ -402,7 +402,7 @@ var r2Entries = []Entry{ Code: RestOperationBraces, Old: "operation X { Method: get, … }", Canonical: "operation X ( Method: get, … )", - Rewrite: Rewrite{Structural: "the operation's braces become parentheses"}, + Rewrite: Rewrite{Structural: "operation's braces: `operation X { … }` becomes `operation X ( … )`"}, RemovedIn: 2, Note: "An operation is a child of the service: its properties are in ( ) like every child's, " + "and { } holds children (R2). A body or response mapping keeps its { } tree.", @@ -415,7 +415,7 @@ var r2Entries = []Entry{ Code: AgentAttachmentBraces, Old: "tool X { … } / mcp service M.S { … } / knowledge base K { … }", Canonical: "tool X ( … ) / mcp service M.S ( … ) / knowledge base K ( … )", - Rewrite: Rewrite{Structural: "the attachment's braces become parentheses"}, + Rewrite: Rewrite{Structural: "attachment's braces: `tool X { … }` becomes `tool X ( … )`"}, RemovedIn: 2, Note: "In create agent and in alter agent … add. An attachment is a child of the agent: its properties are in ( ) (R2).", Example: "create agent M.A (UsageType: Task, Model: M.Gpt, SystemPrompt: 'x') " + @@ -427,7 +427,7 @@ var r2Entries = []Entry{ Code: ImageCollectionParens, Old: "image collection M.C ( image X from file '…', … )", Canonical: "image collection M.C { image X ( File: '…' ) … }", - Rewrite: Rewrite{Structural: "the images move into { } without commas, and `from file '…'` " + + Rewrite: Rewrite{Structural: "image list: the images move into { } without commas, and `from file '…'` " + "becomes `( File: '…' )`"}, RemovedIn: 2, Note: "The images are the collection's children, so they are in { }, each with its properties in ( ) (R2).", @@ -438,7 +438,7 @@ var r2Entries = []Entry{ Code: MessageTreeParens, Old: "message definition collection M.C ( definition D for M.E ( A, M.E_B/M.B ( C ) ) )", Canonical: "message definition collection M.C { definition D for M.E { A, M.E_B/M.B { C } } }", - Rewrite: Rewrite{Structural: "each parenthesised definition list and member tree moves into { }"}, + Rewrite: Rewrite{Structural: "message trees: each parenthesised definition list and member tree moves into { }"}, RemovedIn: 2, Note: "The definitions and members are children, so they are in { }, as in an import or export " + "mapping (R2). Also in `alter message definition collection … add definition` and " + @@ -450,7 +450,7 @@ var r2Entries = []Entry{ Code: AlterFlowFragmentBraces, Old: "alter microflow M.F { insert after $X { … } }", Canonical: "alter microflow M.F { insert after $X begin … end; }", - Rewrite: Rewrite{Structural: "the fragment's `{` becomes `begin` and its `}` becomes `end`"}, + Rewrite: Rewrite{Structural: "fragment's braces: `{` becomes `begin` and `}` becomes `end`"}, RemovedIn: 2, Note: "A fragment is imperative flow, written exactly as the body of `create microflow`, so it is " + "`begin … end` (R2). The operations around it are the alter's children and stay in its { }.", diff --git a/mdl/deprecation/deprecation_test.go b/mdl/deprecation/deprecation_test.go index de608703f2..e21746ba9b 100644 --- a/mdl/deprecation/deprecation_test.go +++ b/mdl/deprecation/deprecation_test.go @@ -4,6 +4,7 @@ package deprecation import ( "regexp" + "strings" "testing" ) @@ -31,6 +32,11 @@ func TestRegistryEntriesAreWellFormed(t *testing.T) { if swap == (e.Rewrite.Structural != "") { t.Errorf("%s: a rewrite is either a keyword swap or structural, exactly one: %+v", e.Code, e.Rewrite) } + // The warning prints "Rewrite the ", so a Structural that + // starts with "the" reads "Rewrite the the …". + if strings.HasPrefix(strings.ToLower(e.Rewrite.Structural), "the ") { + t.Errorf("%s: Structural %q starts with \"the\"; it is printed after \"Rewrite the\"", e.Code, e.Rewrite.Structural) + } if e.Old == "" || e.Canonical == "" || e.Example == "" || e.CanonicalExample == "" { t.Errorf("%s is incomplete: %+v", e.Code, e) } From 7eabdd2602642f8a7913a5b87043131ef1461dac Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 06:32:16 +0000 Subject: [PATCH 09/35] fix(grammar): a bare timer delay does not take the 'non' of the next boundary event (#753) With the delay optional and the next boundary event allowed without a repeated 'boundary event', 'interrupting timer non interrupting timer x' read 'non' as the first timer's bare delay and made the second event interrupting. A predicate keeps a bare delay from starting with NON, so the script builds what it built when only a string could be the delay. Co-Authored-By: Claude Opus 5.5 --- mdl/grammar/domains/MDLWorkflow.g4 | 18 ++++++++-- mdl/upgrade/workflow_expressions_test.go | 9 +++++ mdl/visitor/visitor_workflow.go | 4 ++- .../visitor_workflow_expressions_test.go | 35 +++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/mdl/grammar/domains/MDLWorkflow.g4 b/mdl/grammar/domains/MDLWorkflow.g4 index bfa5695241..04816a6466 100644 --- a/mdl/grammar/domains/MDLWorkflow.g4 +++ b/mdl/grammar/domains/MDLWorkflow.g4 @@ -160,6 +160,18 @@ workflowExpression | expression ; +/** + * A timer boundary event's delay. The delay is optional and the next boundary + * event may follow without repeating `boundary event`, so a bare delay must not + * start with the `non` of `non interrupting timer`, a word the expression + * grammar admits as a name: `interrupting timer non interrupting timer 'x'` is + * two events, the first without a delay, as it was when only a string could be + * the delay (ako/mxcli#753). + */ +workflowTimerDelay + : {p.GetTokenStream().LA(1) != MDLParserNON}? workflowExpression + ; + /** * A user task. Its clauses are a SET, not a sequence — see * `workflowHeaderClause` for why, and `checkWorkflowClausesAtMostOnce` for the @@ -235,9 +247,9 @@ workflowFallbackClause * with two boundary events did not parse. */ workflowBoundaryEventClause - : INTERRUPTING TIMER workflowExpression? (LBRACE workflowBody RBRACE)? - | NON INTERRUPTING TIMER workflowExpression? (LBRACE workflowBody RBRACE)? - | TIMER workflowExpression? (LBRACE workflowBody RBRACE)? + : INTERRUPTING TIMER workflowTimerDelay? (LBRACE workflowBody RBRACE)? + | NON INTERRUPTING TIMER workflowTimerDelay? (LBRACE workflowBody RBRACE)? + | TIMER workflowTimerDelay? (LBRACE workflowBody RBRACE)? // A notification boundary event is triggered by `notify workflow … target // `, so its name is what matters; the string is its caption. | INTERRUPTING NOTIFICATION workflowActivityName? STRING_LITERAL? (LBRACE workflowBody RBRACE)? diff --git a/mdl/upgrade/workflow_expressions_test.go b/mdl/upgrade/workflow_expressions_test.go index 2a6da5f526..4a6e3fbb99 100644 --- a/mdl/upgrade/workflow_expressions_test.go +++ b/mdl/upgrade/workflow_expressions_test.go @@ -28,6 +28,15 @@ func TestUpgrade_WorkflowExpressionsBare(t *testing.T) { " wait for timer addHours([%CurrentDateTime%], 1) comment 'Wait';\n" + " event subprocess Esp on interrupting timer addDays([%CurrentDateTime%], 30) as Start { };\nend workflow;", }, + { + // The first timer has no delay, and `non` must stay the start of + // the second event rather than become the first one's delay. + "a delayless timer before a non-interrupting one", + head + "begin\n user task Review 'Review'\n outcomes 'Done' { }\n" + + " boundary event interrupting timer non interrupting timer 'addHours([%CurrentDateTime%], 1)';\nend workflow;", + head + "begin\n user task Review 'Review'\n outcomes 'Done' { }\n" + + " boundary event interrupting timer non interrupting timer addHours([%CurrentDateTime%], 1);\nend workflow;", + }, { "a string with no space around it", "alter workflow M.WF set activity Review due date'addDays([%CurrentDateTime%], 1)';", diff --git a/mdl/visitor/visitor_workflow.go b/mdl/visitor/visitor_workflow.go index 4ed84a9a92..41798aefa4 100644 --- a/mdl/visitor/visitor_workflow.go +++ b/mdl/visitor/visitor_workflow.go @@ -906,7 +906,9 @@ func buildBoundaryEventNode(beCtx parser.IWorkflowBoundaryEventClauseContext) as be.Caption = unquoteStringLit(s) } if !notification { - be.Delay = workflowExpressionText(beCtx2.WorkflowExpression()) + if d := beCtx2.WorkflowTimerDelay(); d != nil { + be.Delay = workflowExpressionText(d.WorkflowExpression()) + } } if notification { be.Name = workflowActivityNameText(beCtx2.WorkflowActivityName()) diff --git a/mdl/visitor/visitor_workflow_expressions_test.go b/mdl/visitor/visitor_workflow_expressions_test.go index 824b0e9d59..3f27774a06 100644 --- a/mdl/visitor/visitor_workflow_expressions_test.go +++ b/mdl/visitor/visitor_workflow_expressions_test.go @@ -122,3 +122,38 @@ func TestWorkflowDecisionReadsBack(t *testing.T) { } } } + +// A timer boundary event's delay is optional, and the next clause may follow +// without repeating `boundary event`. `non` of `non interrupting timer` is +// also a word the expression grammar admits as a bare name, so a bare delay +// must not take it: `interrupting timer non interrupting timer 'x'` is an +// interrupting timer with no delay and a non-interrupting one, as it was +// before the delay could be written bare — not an interrupting timer whose +// delay is `non` followed by a second interrupting one. +func TestWorkflowExpression_BoundaryDelayDoesNotTakeNon(t *testing.T) { + for _, activity := range []string{ + "user task R 'R'\n outcomes 'Done' { }", + "multi user task R 'R'\n outcomes 'Done' { }", + "call microflow M.F", + } { + for _, second := range []string{"'x'", "x"} { + src := wfHead + "begin\n " + activity + "\n boundary event interrupting timer non interrupting timer " + second + ";\nend workflow;" + prog := mustBuild(t, src) + wf := prog.Statements[0].(*ast.CreateWorkflowStmt) + var events []ast.WorkflowBoundaryEventNode + switch a := wf.Activities[0].(type) { + case *ast.WorkflowUserTaskNode: + events = a.BoundaryEvents + case *ast.WorkflowCallMicroflowNode: + events = a.BoundaryEvents + } + want := []ast.WorkflowBoundaryEventNode{ + {EventType: "InterruptingTimer"}, + {EventType: "NonInterruptingTimer", Delay: "x"}, + } + if !reflect.DeepEqual(events, want) { + t.Errorf("%s\nbuilt %+v\nwant %+v", src, events, want) + } + } + } +} From 3125713d326907d996aea89320d5379f32e4e7ae Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 06:32:16 +0000 Subject: [PATCH 10/35] test(executor): the workflow round trip expects a bare decision condition (#753) describe writes a decision's condition bare since R5; the integration test still asserted the string form and failed in CI. Co-Authored-By: Claude Opus 5.5 --- mdl/executor/roundtrip_workflow_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mdl/executor/roundtrip_workflow_test.go b/mdl/executor/roundtrip_workflow_test.go index 7805430388..3f3920586b 100644 --- a/mdl/executor/roundtrip_workflow_test.go +++ b/mdl/executor/roundtrip_workflow_test.go @@ -145,7 +145,7 @@ begin true -> { } false -> { }; - decision '$WorkflowContext/IsApproved' + decision $WorkflowContext/IsApproved outcomes true -> { wait for timer '${PT2H}'; @@ -192,7 +192,8 @@ end workflow;` {"call microflow args", "call microflow " + mod + ".ScoreCalc(Score = $WorkflowContext/Score)"}, {"outcomes true", "true ->"}, {"outcomes false", "false ->"}, - {"decision", "decision '$WorkflowContext/IsApproved'"}, + // R5 (#753): describe writes a decision's condition bare. + {"decision", "decision $WorkflowContext/IsApproved"}, {"wait for timer", "wait for timer '${PT2H}'"}, {"jump to", "jump to ReviewTask"}, {"parallel split", "parallel split"}, From 3621c63949fcd9fe2cdb915e249deac4f921450b Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 06:51:38 +0000 Subject: [PATCH 11/35] mdl/visitor: R3 rewrites keep a comment between the tokens they touch (#751) fmt --upgrade promises to keep comments, but the MDL-DEPR103, -064, -060..063 rewrites replaced whole gaps: 'drop widget -- c\n a' became 'drop a', 'type: /* c */ Reference' became 'type Reference'. A gap that holds more than whitespace now keeps its text; only the old token goes. Co-Authored-By: Claude Opus 5.5 --- mdl/upgrade/r3_property_lists_test.go | 29 ++++++++++++ mdl/visitor/visitor_r3_property_lists.go | 60 +++++++++++++++++++++--- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/mdl/upgrade/r3_property_lists_test.go b/mdl/upgrade/r3_property_lists_test.go index cf7e655b53..6f43935051 100644 --- a/mdl/upgrade/r3_property_lists_test.go +++ b/mdl/upgrade/r3_property_lists_test.go @@ -58,3 +58,32 @@ func TestUpgrade_R3PropertyLists(t *testing.T) { } } } + +// A comment between the tokens a rewrite touches survives it: the rewrite +// removes the old token and the blank space after it, never the text between +// two tokens (upgrade.go: "comments, layout … are kept"). +func TestUpgrade_R3KeepsCommentsInsideTheRewrite(t *testing.T) { + cases := []struct{ old, want string }{ + {"alter page M.P { drop widget -- old\n a; };\n", + "alter page M.P { drop -- old\n a; };\n"}, + {"alter page M.P { drop widget /* x */ a; };\n", + "alter page M.P { drop /* x */ a; };\n"}, + {"create association M.A_B from M.A to M.B type: /* c */ Reference;\n", + "create association M.A_B from M.A to M.B type /* c */ Reference;\n"}, + {"create association M.A_B from M.A to M.B type /* c */ : Reference;\n", + "create association M.A_B from M.A to M.B type /* c */ Reference;\n"}, + {"alter settings runtime BcryptCost /* c */ = 11;\n", + "alter settings runtime ( BcryptCost /* c */ : 11 );\n"}, + {"alter entity M.Remote set allow_create_change_locally /* c */ = true;\n", + "alter entity M.Remote set ( AllowCreateChangeLocally /* c */ : true );\n"}, + } + for _, c := range cases { + res := mustUpgrade(t, c.old, Options{}) + if res.Source != c.want { + t.Errorf("upgrade of\n%s got:\n%s want:\n%s", c.old, res.Source, c.want) + } + if again := mustUpgrade(t, res.Source, Options{}); again.Changed() { + t.Errorf("upgrade is not idempotent on\n%s", res.Source) + } + } +} diff --git a/mdl/visitor/visitor_r3_property_lists.go b/mdl/visitor/visitor_r3_property_lists.go index 7087deca97..1117112ea1 100644 --- a/mdl/visitor/visitor_r3_property_lists.go +++ b/mdl/visitor/visitor_r3_property_lists.go @@ -49,14 +49,50 @@ func assignmentAt(ctx antlr.ParserRuleContext, op antlr.Tree) (oldAssignment, bo // colonEdits writes each assignment's `=` as `:`, directly after the key: // `Caption = 'x'` becomes `Caption: 'x'`. +// +// A comment between the key and the `=` stays where it is: only the `=` is +// replaced then (`Key /* c */: 'x'`). func colonEdits(as []oldAssignment) []ast.TextEdit { out := make([]ast.TextEdit, 0, len(as)) for _, a := range as { - out = append(out, ast.TextEdit{Start: a.keyStop + 1, Stop: a.op.GetStop() + 1, Text: ":"}) + start := a.keyStop + 1 + if !blankBetween(a.op.GetInputStream(), start, a.op.GetStart()) { + start = a.op.GetStart() + } + out = append(out, ast.TextEdit{Start: start, Stop: a.op.GetStop() + 1, Text: ":"}) } return out } +// blankBetween reports whether the runes in [start, stop) are whitespace only +// — no comment the rewrite would delete. A missing stream counts as blank. +func blankBetween(is antlr.CharStream, start, stop int) bool { + if is == nil || stop <= start { + return true + } + return strings.TrimSpace(is.GetText(start, stop-1)) == "" +} + +// afterBlank is the offset of the first non-blank rune after tok: deleting +// [tok.start, afterBlank(tok)) removes the token and the space after it, and +// keeps a comment that follows. +func afterBlank(tok antlr.Token) int { + end := tok.GetStop() + 1 + is := tok.GetInputStream() + if is == nil { + return end + } + for end < is.Size() { + switch is.GetText(end, end) { + case " ", "\t", "\n", "\r": + end++ + continue + } + break + } + return end +} + // wrapEdits puts the list from first to last in parentheses. A list that // starts on a new line after before (describe's layout) opens its parenthesis // on before's line and closes it on a line of its own, indented as that line @@ -137,7 +173,7 @@ func (b *Builder) recordAlterPageDropWidget(ctx *parser.AlterDropContext) { } tok := w.GetSymbol() b.recordDeprecation(deprecation.AlterPageDropWidget, tok, "drop widget") - edit := ast.TextEdit{Start: tok.GetStart(), Stop: targets[0].GetStart().GetStart(), Text: ""} + edit := ast.TextEdit{Start: tok.GetStart(), Stop: afterBlank(tok), Text: ""} b.fixLastDeprecation(deprecation.AlterPageDropWidget, &ast.Fix{Edits: []ast.TextEdit{edit}}, "") } @@ -225,7 +261,8 @@ func (b *Builder) recordAllowCreateChangeLocally(ctx *parser.AlterEntityActionCo kt := key.GetSymbol() b.recordDeprecation(deprecation.AllowCreateChangeLocally, kt, "alter entity") b.fixLastDeprecation(deprecation.AllowCreateChangeLocally, &ast.Fix{Edits: []ast.TextEdit{ - {Start: kt.GetStart(), Stop: eq.GetSymbol().GetStop() + 1, Text: "( AllowCreateChangeLocally:"}, + {Start: kt.GetStart(), Stop: kt.GetStop() + 1, Text: "( AllowCreateChangeLocally"}, + colonEdits([]oldAssignment{{keyStop: kt.GetStop(), op: eq.GetSymbol()}})[0], insertAt(value.GetSymbol().GetStop()+1, " )"), }}, "") } @@ -259,9 +296,20 @@ func (b *Builder) recordAssociationClauseColon(ctx *parser.AssociationOptionCont if kwStop < 0 || valueStart < 0 { return } - b.recordDeprecation(deprecation.AssociationClauseColon, colon.GetSymbol(), "association") - b.fixLastDeprecation(deprecation.AssociationClauseColon, - &ast.Fix{Edits: []ast.TextEdit{replaceGap(kwStop, valueStart, " ")}}, "") + ct := colon.GetSymbol() + is := ct.GetInputStream() + edit := replaceGap(kwStop, valueStart, " ") + if !blankBetween(is, kwStop+1, valueStart) { + // A comment sits in the gap: delete only the colon and the space + // after it, keeping one space between the keyword and what follows. + text := "" + if kwStop+1 == ct.GetStart() { + text = " " + } + edit = ast.TextEdit{Start: ct.GetStart(), Stop: afterBlank(ct), Text: text} + } + b.recordDeprecation(deprecation.AssociationClauseColon, ct, "association") + b.fixLastDeprecation(deprecation.AssociationClauseColon, &ast.Fix{Edits: []ast.TextEdit{edit}}, "") } // lineIndent is the leading whitespace of the line holding rune offset pos. From 11a9ff598f33e1670bd2ad75b2ebbd58261ffaac Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 06:51:39 +0000 Subject: [PATCH 12/35] mdl/executor: renamed design property suggests the canonical styling list (#751) The MDL-WIDGET renamed-property suggestion told users to write `set 'Key' = 'Value'`, the spelling this PR deprecates as MDL-DEPR062. Co-Authored-By: Claude Opus 5.5 --- mdl/executor/validate_alter_styling.go | 8 ++++---- mdl/executor/validate_alter_styling_renamed_test.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mdl/executor/validate_alter_styling.go b/mdl/executor/validate_alter_styling.go index 63a91e81ed..2b66d9dd30 100644 --- a/mdl/executor/validate_alter_styling.go +++ b/mdl/executor/validate_alter_styling.go @@ -19,7 +19,7 @@ import ( // *ast.AlterStylingStmt is not among them, so an unsupported key was silent // until mxbuild: // -// alter styling on page … widget lvThings set 'Remove empty text' = on; +// alter styling on page … widget lvThings set ( 'Remove empty text': on ); // mxcli check --references -> Check passed! // mxcli exec -> Updated styling on widget "lvThings" // mxcli docker check -> [CE6083] "Design property Remove empty text is @@ -243,9 +243,9 @@ func renamedStylingSuggestion(r *designPropRename, value string) string { return fmt.Sprintf("ALTER STYLING cannot write its current form, which is a compound: set "+ "`DesignProperties: [%s]` on the widget in CREATE PAGE, or in an ALTER PAGE REPLACE.", r.Replacement) } - // 'Key': 'Value' → set 'Key' = 'Value' - if i := strings.Index(r.Replacement, "': "); i > 0 { - return fmt.Sprintf("Write it as `set %s' = %s`.", r.Replacement[:i], r.Replacement[i+3:]) + // 'Key': 'Value' → set ( 'Key': 'Value' ), the canonical list (R3) + if strings.Contains(r.Replacement, "': ") { + return fmt.Sprintf("Write it as `set ( %s )`.", r.Replacement) } return renamedDesignPropSuggestion(r, value) } diff --git a/mdl/executor/validate_alter_styling_renamed_test.go b/mdl/executor/validate_alter_styling_renamed_test.go index 409ca92af8..f808db2b01 100644 --- a/mdl/executor/validate_alter_styling_renamed_test.go +++ b/mdl/executor/validate_alter_styling_renamed_test.go @@ -24,8 +24,8 @@ func TestAlterStyling_RenamedKey_NamesTheCurrentProperty(t *testing.T) { }{ { name: "renamed property, value mapped through old option names", - set: `set 'Align content' = 'Left align as column'`, - want: []string{"renamed", "Align content (deprecated)", "CE6087", `set 'Align content (deprecated)' = 'Left align as a column'`}, + set: `set ( 'Align content': 'Left align as column' )`, + want: []string{"renamed", "Align content (deprecated)", "CE6087", `set ( 'Align content (deprecated)': 'Left align as a column' )`}, wantNot: []string{"CE6083"}, }, { @@ -33,13 +33,13 @@ func TestAlterStyling_RenamedKey_NamesTheCurrentProperty(t *testing.T) { // cannot write (one flat value — the MDL-WIDGET12 limit): point at // the inline form instead. name: "spacing side renamed into a compound", - set: `set 'Spacing bottom' = 'Outer medium'`, + set: `set ( 'Spacing bottom': 'Outer medium' )`, want: []string{"renamed", "CE6087", "'Spacing': ['margin-bottom': 'M']", "DesignProperties"}, wantNot: []string{"CE6083"}, }, { name: "multi-select toggle renamed into an option", - set: `set 'Hide on phone' = on`, + set: `set ( 'Hide on phone': on )`, want: []string{"renamed", "CE6087", "'Hide on': ['Phone': on]", "DesignProperties"}, wantNot: []string{"CE6083"}, }, From a32681c70c1d4f51cdaa38f044ecfe95aa580e12 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 06:51:40 +0000 Subject: [PATCH 13/35] docs: settings and OData alter syntax in the R3 property list (#751) Co-Authored-By: Claude Opus 5.5 --- .../skills/mendix/odata-data-sharing/SKILL.md | 2 +- docs-site/src/language/project-settings.md | 8 ++++---- .../01-language-reference.md | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.claude/skills/mendix/odata-data-sharing/SKILL.md b/.claude/skills/mendix/odata-data-sharing/SKILL.md index b38a891e72..938115b155 100644 --- a/.claude/skills/mendix/odata-data-sharing/SKILL.md +++ b/.claude/skills/mendix/odata-data-sharing/SKILL.md @@ -81,7 +81,7 @@ Then re-import: `CREATE OR MODIFY EXTERNAL ENTITIES FROM F1Now.NowApi` maps the new entity sets. Do **not** `DROP CONSUMED ODATA SERVICE` and recreate it to force a refresh — that invalidates the client ID the existing external entities point at. -Note that `ALTER CONSUMED ODATA SERVICE SET MetadataUrl = …` does *not* re-fetch. Use +Note that `ALTER CONSUMED ODATA SERVICE … SET ( MetadataUrl: … )` does *not* re-fetch. Use `CREATE OR MODIFY` when the contract is what changed. **Use Cases for Local Metadata:** diff --git a/docs-site/src/language/project-settings.md b/docs-site/src/language/project-settings.md index 41a3ae27d1..da6068d8a7 100644 --- a/docs-site/src/language/project-settings.md +++ b/docs-site/src/language/project-settings.md @@ -21,7 +21,7 @@ Settings are organized into categories. Each `ALTER SETTINGS` command targets on Runtime-level settings such as the after-startup microflow, hashing algorithm, and Java version: ```sql -ALTER SETTINGS RUNTIME = ; +ALTER SETTINGS RUNTIME ( : , ... ); ``` Examples: @@ -42,7 +42,7 @@ project already uses. Server configuration settings like database type, URL, and HTTP port. Each configuration is identified by name (commonly `'default'`): ```sql -ALTER SETTINGS CONFIGURATION '' = ; +ALTER SETTINGS CONFIGURATION '' ( : , ... ); ``` Examples: @@ -75,7 +75,7 @@ for. A translation written for any other language is stored in the model, passes the step that makes translating an app do anything. ```sql -ALTER SETTINGS LANGUAGE = ; +ALTER SETTINGS LANGUAGE ( : , ... ); ALTER SETTINGS LANGUAGE ADD [OR MODIFY] '' [(