From 3e4009ce063363977a6fbccddf6d1aa4c7537bfa Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 15:23:15 +0000 Subject: [PATCH 01/17] =?UTF-8?q?feat(mdl):=20create=20=E2=80=A6=20if=20no?= =?UTF-8?q?t=20exists=20on=20every=20document=20type=20(#731)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `if not exists` was accepted on create entity and create association only (and parsed-then-ignored on view entities). ADR-0010 R1 makes it the separate "leave it alone" operation beside create or modify, on every type. - grammar: `ifNotExists?` after the kind's keywords, before the name, in every create rule that names one element. Not on annotation, index, validation rule, navigation, translations, external entities. - ast: CreateGuard embedded in every named create statement. - visitor: the guard is applied once, in ExitCreateStatement, to whichever statement the create rule built; a type that cannot carry it is an error. - executor: Registry.Dispatch probes existence and skips before the handler (entity/association keep their in-handler check); stmtCreateInfo counts the guard as idempotent for every kind; MDL067 (or modify + if not exists) covers every kind. - docs: mxcli syntax create-if-not-exists, basics.md, quick reference, check-syntax skill; finding recorded. describe never emits the guard, so the round trip is unaffected. Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-visitor.jsonl | 1 + .claude/skills/mendix/check-syntax/SKILL.md | 6 +- cmd/mxcli/syntax/features_misc.go | 37 ++- docs-site/src/language/basics.md | 12 + docs/01-project/MDL_QUICK_REFERENCE.md | 9 +- mdl/ast/ast_agenteditor.go | 4 + mdl/ast/ast_association.go | 4 +- mdl/ast/ast_businessevents.go | 1 + mdl/ast/ast_create_guard.go | 45 +++ mdl/ast/ast_datatransformer.go | 1 + mdl/ast/ast_entity.go | 6 +- mdl/ast/ast_enumeration.go | 5 +- mdl/ast/ast_imagecollection.go | 1 + mdl/ast/ast_import_export_mapping.go | 18 +- mdl/ast/ast_javaaction.go | 2 + mdl/ast/ast_jsonstructure.go | 1 + mdl/ast/ast_messagedefinition.go | 1 + mdl/ast/ast_microflow.go | 3 + mdl/ast/ast_navigation.go | 1 + mdl/ast/ast_odata.go | 3 + mdl/ast/ast_page_v3.go | 17 +- mdl/ast/ast_queue.go | 1 + mdl/ast/ast_regularexpression.go | 1 + mdl/ast/ast_rest.go | 2 + mdl/ast/ast_scheduledevent.go | 1 + mdl/ast/ast_security.go | 3 + mdl/ast/ast_settings.go | 5 +- mdl/ast/ast_sql.go | 1 + mdl/ast/ast_workflow.go | 1 + mdl/executor/cmd_create_guard.go | 306 ++++++++++++++++++ mdl/executor/create_if_not_exists_test.go | 257 +++++++++++++++ mdl/executor/registry.go | 6 + mdl/executor/validate_duplicates.go | 18 +- .../validate_duplicates_coverage_test.go | 111 ++----- .../validate_idempotency_guard_test.go | 14 +- .../validate_odata_properties_drift_test.go | 2 +- mdl/executor/validate_program.go | 3 + mdl/grammar/MDLParser.g4 | 2 +- mdl/grammar/domains/MDLAgent.g4 | 8 +- mdl/grammar/domains/MDLDomainModel.g4 | 33 +- mdl/grammar/domains/MDLMicroflow.g4 | 10 +- mdl/grammar/domains/MDLPage.g4 | 6 +- mdl/grammar/domains/MDLSecurity.g4 | 6 +- mdl/grammar/domains/MDLService.g4 | 18 +- mdl/grammar/domains/MDLWorkflow.g4 | 2 +- mdl/visitor/visitor.go | 4 + mdl/visitor/visitor_association.go | 1 - mdl/visitor/visitor_create_guard.go | 59 ++++ .../visitor_create_if_not_exists_test.go | 147 +++++++++ mdl/visitor/visitor_document_annotations.go | 1 + mdl/visitor/visitor_entity.go | 5 +- 51 files changed, 1047 insertions(+), 165 deletions(-) create mode 100644 mdl/ast/ast_create_guard.go create mode 100644 mdl/executor/cmd_create_guard.go create mode 100644 mdl/executor/create_if_not_exists_test.go create mode 100644 mdl/visitor/visitor_create_guard.go create mode 100644 mdl/visitor/visitor_create_if_not_exists_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl index 81eedde092..452b190a3b 100644 --- a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl @@ -37,3 +37,4 @@ {"area": "mdl/visitor", "date": "2026-09-25", "symptom": "A page action's microflow argument `Flag: true and false` was stored as the expression \"trueandfalse\", `Mode: if true then 'a' else 'b'` as \"iftruethen'a'else'b'\", and a REST call parameter `$OrderId = if $x then $id else 'none'` as \"if$xthen$idelse'none'\" (measured by decoding the units on a copy of ako/TestApp). `check -p --references` passed; describe printed the fused text back", "cause": "Four sites stored an expression as text via ANTLR's ctx.GetText(), which concatenates tokens without the hidden-channel whitespace: microflowArgV3 values (page/nanoflow call arguments), contentparams values, send-rest-request WITH parameters, and a dynamic `execute database query`. Literals, `+` and a lone $currentObject are single tokens or need no spacing, so every common case looked correct", "file": "`mdl/visitor/visitor_helpers.go` (`expressionSourceText`), `mdl/visitor/visitor_page_v3.go` (`buildMicroflowArgV3`, `buildParamAssignmentV3`), `mdl/visitor/visitor_microflow_actions.go` (dynamic query, send rest params)", "insight": "**GetText() on an expression context is always a bug** — grep `Expression().*GetText()` / `expr.GetText()` in mdl/visitor; each hit either builds the AST or must use expressionSourceText (whitespace kept, MDL comments stripped). The earlier comment-leak fix moved the six microflow sites to extractExpressionText and missed these four because they lived in page/REST code, not microflow statements: fix a text-extraction defect by searching for the call, not the feature. The tell that hid it: tests used `$currentObject` and `'a' + 'b'`, both immune; a probe with `and` / `if…then` exposed all four at once. Found while writing PROPOSAL_first_class_expressions.md §5.1. Tests `visitor_expression_source_text_test.go`", "refs": ["mendixlabs/mxcli#750"]} {"area": "mdl/visitor", "date": "2026-09-26", "refs": ["ako/mxcli#706"], "rules": ["MDL-IDX01", "MDL-ENUMDOC01"], "symptom": "Five forms parsed and `check` printed \"Syntax OK\", then the model lost or changed them: `throw 'x';` vanished from the microflow; `Amount: float` / `currency` became String(unlimited) (Void as a microflow type) and `date` became DateTime; `create association X (from A to B, type: referenceset, storage: table)` was stored as a Reference with column storage; a `/** … */` on an enumeration value and an index name (`index Idx on (…)`, `create index Idx on E (…)`) were read and written nowhere", "cause": "Three different gaps behind one symptom: throwStatement had a grammar rule and no listener (a whole statement); buildDataType/buildMicroflowDataType end in a default return (String / Void) that the FLOAT_TYPE and CURRENCY_TYPE tokens fell into, and DATE_TYPE mapped to an ast.TypeDate the writers alias to DateTime; ExitCreateAssociationStatement read only ctx.AssociationOptions(), while the parenthesised alternative puts its options directly under the statement as `(COMMA associationOption)*`. Enumeration-value docs and index names have no home in the metamodel (EnumerationValue and Index have no such property)", "file": "`mdl/visitor/visitor_silent_drops.go` (ExitThrowStatement, EnterDataType, EnterNonListDataType, rejectParenthesisedAssociation), `mdl/executor/validate_unstored_text.go` (MDL-IDX01, MDL-ENUMDOC01); tests `mdl/visitor/silent_drops_706_test.go`, `mdl/executor/validate_unstored_text_test.go`", "insight": "**Split by what is lost: refuse when the MODEL would be wrong, warn when only WORDS are lost.** throw/types/association write a different model, so they are visitor errors with the working spelling in the message; doc comments on values and index names leave a correct model, and were used in 56 places in our own examples, so they are check warnings. **Refuse from the parse tree, not by deleting the grammar alternative**: `float`, `currency`, `date` and `throw` are also keywords-as-identifiers, so removing `| FLOAT_TYPE` from dataType would let `Amount: float` re-parse as an enumeration reference named float — the same silence again. A listener on EnterDataType covers every place a type is written (attribute, parameter, return, declare, constant, service rules) in one method; checking only the direct child token keeps `M.Currency` and `enumeration(M.Float)` legal (the control). To find these: any `if … ; return ast.DataType{Kind: …}` fall-through default in a builder is a silent-substitution site, and a grammar alternative whose sub-rules differ from its sibling's (`associationOption` vs `associationOptions`) needs its own read. Sweep: run old vs new `mxcli check` over `git ls-files '*.mdl'` and diff exit codes — it found every example that relied on the dropped forms (5 files)"} {"date": "2026-09-27", "area": "mdl/visitor", "symptom": "A member path after `*`, `div`, `mod` (`$a/X * $b/Y`) is stored as division: `describe` shows `$a/X * $b / Y`, and inside a list `filter` `$b / $currentObject/Y`. `mxcli check`, `mx check` and the build are all green; Mendix evaluates a different expression.", "cause": "`/` shares a precedence level with `*`, `div`, `mod` in `multiplicativeExpression` (MDLSettings.g4), so the left-associative chain parses `$a/X * $b/Y` as `(($a/X) * $b) / Y`. `buildMultiplicativeExpression` only turned `/ Member` into an AttributePathExpr when the WHOLE left side was a variable or path; here `$b` was already the right operand of the product, so the `/` stayed a BinaryExpr. The serializer then printed a spaced `/` and the filter's `qualifyIteratorAttributes` read the stranded `Y` as a bare attribute.", "file": "mdl/visitor/visitor_microflow_expression.go (attachPathToLastOperand)", "fix": "When a `/ Member` step cannot attach to the whole left side, attach it to the last operand of the preceding multiplicative BinaryExpr (`*`, `div`, `mod`, `%`, `:`): `(L op $b) / Y` becomes `L op ($b/Y)`. Mendix has no `/` division, so `/` followed by a member name is always navigation. Division by a parenthesised expression is left alone (control test).", "insight": "Fixing it where the AST is BUILT fixes every consumer at once (serializer, iterator qualification, MDL045's slash-division check), where the earlier #52 fix only taught MDL045 to tolerate the mis-nested tree. Test: `TestMemberPathAfterMultiplicativeOperatorIsStoredAsWritten` (flow builder, stored strings for sum, filter, declare, div chain) plus a control `TestSlashAfterNonPathIsNotMadeAMemberPath`. Revert check: stubbing the fix brings back all four wrong strings. A unary-minus branch was dropped because removing it failed nothing (`2 * -$b/Y` was already stored correctly).", "refs": ["#768", "#52"], "rules": []} +{"date": "2026-09-28", "area": "mdl/visitor", "symptom": "`create view entity if not exists M.V (...) as (...)` on an existing view entity fails with \"entity already exists\" although `mxcli syntax` documents `CREATE VIEW ENTITY [IF NOT EXISTS]`; every other create kind rejected `if not exists` as a parse error.", "cause": "The grammar accepted `ifNotExists?` on all five entity alternatives, but only `ExitCreateEntityStatement`'s non-view branch read it; `buildViewEntity` built a `CreateViewEntityStmt`, which had no field for it, so the guard parsed and was dropped. A per-builder guard is the one the next builder forgets (same class as #531 for drop if exists).", "file": "mdl/visitor/visitor_create_guard.go (applyCreateGuard), mdl/executor/cmd_create_guard.go (skipExistingCreate)", "fix": "`ast.CreateGuard` embedded in every named create statement; the visitor applies `if not exists` once in ExitCreateStatement to whichever statement the create rule built (and errors if that type cannot carry it), and Registry.Dispatch probes existence and skips before the handler. Entity and association keep their in-handler check.", "insight": "Tests are driven by the grammar's own list of create kinds (createStatementKinds) plus allKnownStatements, so a new create rule fails until it carries the guard or is exempted with a reason. The executor test swaps the handler for a recorder, which isolates the guard from what each handler needs from a mock; revert check: stubbing skipExistingCreate fails all 38 existing-element cases, and the absent-element and unguarded controls keep it honest.", "refs": ["#731"], "rules": ["MDL067"]} diff --git a/.claude/skills/mendix/check-syntax/SKILL.md b/.claude/skills/mendix/check-syntax/SKILL.md index e15eed2e0b..7610716078 100644 --- a/.claude/skills/mendix/check-syntax/SKILL.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -87,8 +87,10 @@ fourth — so "run it and see" is not a free experiment. `check` reports every conflict in the script before anything is written. Three spellings say "fine if it already exists", and none is reported: -`create or modify`, `create or replace`, and `create … if not exists` (which -leaves the stored element untouched rather than rewriting it). `create module M;` +`create or modify`, `create or replace`, and `create if not exists ` +(which leaves the stored element untouched rather than rewriting it; every +`create` that names one element takes it, e.g. `create page if not exists M.P …` +— `mxcli syntax create-if-not-exists`). `create module M;` is never reported either — it is a no-op when the module exists, which is what lets it open every script. diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 43f8351f01..9bf0ccc29b 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -89,7 +89,7 @@ func init() { "-- where it does not, DESCRIBE flags the gap as a comment rather than\n" + "-- producing output that looks complete.", Example: "CREATE OR REPLACE MICROFLOW MyModule.ACT_Recalculate ()\nBEGIN\n RETURN;\nEND;\n\nCREATE OR MODIFY PERSISTENT ENTITY MyModule.Customer (\n Name: String(200)\n);", - SeeAlso: []string{"microflow", "domain-model.entity", "page", "document-folder"}, + SeeAlso: []string{"microflow", "domain-model.entity", "page", "document-folder", "create-if-not-exists"}, }) // IF EXISTS sits on every document-level alternative of dropStatement, so it @@ -128,6 +128,41 @@ func init() { SeeAlso: []string{"create-modifiers"}, }) + // IF NOT EXISTS sits in every create rule that names one element, and is + // applied once in the visitor and once in the executor's dispatch, so it is + // documented once here too (ako/mxcli#731, ADR-0010 R1). + Register(SyntaxFeature{ + Path: "create-if-not-exists", + Summary: "CREATE … IF NOT EXISTS — create an element only when it is absent", + Keywords: []string{ + "if not exists", "create if not exists", "re-run", "rerun", + "idempotent", "already exists", "leave alone", "skip", + }, + Syntax: "CREATE IF NOT EXISTS Module.Name …;\n" + + "CREATE MODULE IF NOT EXISTS ModuleName;\n" + + "CREATE USER ROLE IF NOT EXISTS Name (…);\n" + + "CREATE DEMO USER IF NOT EXISTS 'name' PASSWORD '…' (…);\n" + + "CREATE CONFIGURATION IF NOT EXISTS 'Name' (…);\n\n" + + "-- IF NOT EXISTS goes after the kind's keywords, before the name. When the\n" + + "-- element already exists the statement is SKIPPED (and says so) and the\n" + + "-- stored element is left exactly as it is; otherwise it creates, like a\n" + + "-- plain CREATE.\n" + + "--\n" + + "-- It is not CREATE OR MODIFY, which makes the stored element match the\n" + + "-- statement. Writing both is refused as MDL067. DESCRIBE never emits it.\n" + + "--\n" + + "-- Every CREATE that names one element accepts it. Not accepted where\n" + + "-- there is no one named element to test: ANNOTATION, INDEX (use ALTER\n" + + "-- ENTITY … ADD INDEX IF NOT EXISTS), VALIDATION RULE, NAVIGATION,\n" + + "-- TRANSLATIONS and EXTERNAL ENTITIES.", + Example: "-- seed a module once; later runs leave hand edits alone\n" + + "CREATE MODULE IF NOT EXISTS Shop;\n" + + "CREATE ENUMERATION IF NOT EXISTS Shop.Status (Open 'Open', Closed 'Closed');\n" + + "CREATE CONSTANT IF NOT EXISTS Shop.ApiUrl TYPE String DEFAULT 'https://api.example.com';\n" + + "CREATE MICROFLOW IF NOT EXISTS Shop.ACT_Init ()\nBEGIN\n RETURN;\nEND;", + SeeAlso: []string{"create-modifiers", "drop-if-exists"}, + }) + // The folder clause is the other cross-cutting CREATE modifier, and gets one // topic for the same reason OR MODIFY does: it applies to every document // type, so documenting it in all 27 places would guarantee 27 chances to diff --git a/docs-site/src/language/basics.md b/docs-site/src/language/basics.md index 9601bd1029..0e8a42ed16 100644 --- a/docs-site/src/language/basics.md +++ b/docs-site/src/language/basics.md @@ -122,6 +122,18 @@ A construct with no mechanical rewrite is reported with the reason, and `fmt` re The design is in [ADR-0011](https://github.com/mendixlabs/mxcli/blob/main/docs/13-decisions/0011-mdl-language-versioning.md); `mxcli syntax language-header` has the details. +## Re-runnable Creates: `or modify` and `if not exists` + +A plain `create` fails when the element already exists. Two guards make a script re-runnable, and they mean different things: + +| Statement | Element absent | Element present | +|---|---|---| +| `create page M.P …` | created | error — the script stops | +| `create or modify page M.P …` | created | rewritten to match the statement (identity kept) | +| `create page if not exists M.P …` | created | **left untouched**, reported as skipped | + +`if not exists` goes after the kind's keywords and before the name, on every `create` that names one element — `create microflow if not exists M.MF () …`, `create module if not exists M;`, `create user role if not exists Clerk (M.User);`, `create configuration if not exists 'Default' (…);`. It is not accepted on `annotation`, `index` (use `alter entity … add index if not exists`), `validation rule`, `navigation`, `translations` or `external entities`, which have no single named element to test. Writing `create or modify … if not exists` is refused as `MDL067`: the two guards contradict each other. `describe` never emits `if not exists`. + ## Case Insensitivity All MDL **keywords** are case-insensitive. The following are equivalent: diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 3ccdf2301b..7a7685f198 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -114,7 +114,7 @@ Modifies an existing entity without full replacement. | 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;` | Documentation is a doc comment, as on every document. `... comment 'What it links.'` still parses as a deprecated alias (`MDL-DEPR100`, also on constants, JSON structures and image collections); the doc comment wins when both are present | -| Create if absent | `create entity if not exists Module.Name (...);`
`create association if not exists Module.Assoc from ... to ...;` | Skips when it already exists, leaving the stored definition untouched. Unlike `create or modify`, which rebuilds the element from the statement and drops any attribute the statement omits — `mxcli check … -p app.mpr --references` warns about that as **MDL087**, naming the members the script removes without restating them | +| Create if absent | `create entity if not exists Module.Name (...);`
`create association if not exists Module.Assoc from ... to ...;` | Every `create` that names one element takes the same guard, after the kind's keywords (`create page if not exists M.P …`, `create module if not exists M;`) — see `mxcli syntax create-if-not-exists`. Skips when it already exists, leaving the stored definition untouched. Unlike `create or modify`, which rebuilds the element from the statement and drops any attribute the statement omits — `mxcli check … -p app.mpr --references` warns about that as **MDL087**, naming the members the script removes without restating them | | Add index (SQL form) | `create index IdxName on Module.Name (Col1 [asc\|desc], ...);` | Same effect as `alter entity … add index`. The index name is accepted and not stored — a Mendix index is identified by its columns — so `check` warns (MDL-IDX01); prefer `alter entity … add index (…)` | | Drop index | `alter entity Module.Name drop index [if exists] (Col1 [asc\|desc], ...);` | Selected by its columns — a Mendix index stores no name, so the columns are its identity, and they are what `describe entity` prints. The legacy positional form `drop index idx1` still works but shifts when an earlier index is dropped | | Add event handler | `alter entity Module.Name add event handler on before commit call Mod.MF($currentObject) [raise error];` | `($currentObject)` or `()`, RAISE ERROR only on BEFORE | @@ -126,9 +126,10 @@ Modifies an existing entity without full replacement. | Add attribute to every entity | `alter entities [in Module] add attribute [if not exists] attr: type [, ...] [where persistent\|non-persistent];` | The bulk form — one statement instead of one per entity. **ADD ATTRIBUTE only**: drop/rename aimed at a set are destructive by a typo. A **view** entity matches neither persistence filter. **Without `in`**, the sweep skips System and every Marketplace module (and says which) — an upgrade replaces those and would take the attribute with it | > **Re-running domain scripts.** `IF NOT EXISTS` / `IF EXISTS` make an individual -> create/add/drop a no-op when already applied — accepted on `create entity`, -> `create association`, `add attribute`, `add index`, `add event handler` and -> their drops. A script built from guarded statements re-runs to a byte-identical +> create/add/drop a no-op when already applied — accepted on every `create` that +> names one element (entity, association, microflow, page, enumeration, module, +> role, …), on `add attribute`, `add index`, `add event handler`, and on their +> drops. A script built from guarded statements re-runs to a byte-identical > project. > > Prefer them to `CREATE OR MODIFY`, which is not the same thing: `or modify` diff --git a/mdl/ast/ast_agenteditor.go b/mdl/ast/ast_agenteditor.go index 46b6060adc..d6f0071e90 100644 --- a/mdl/ast/ast_agenteditor.go +++ b/mdl/ast/ast_agenteditor.go @@ -16,6 +16,7 @@ package ast // [, DeepLinkURL: '...'] // ); type CreateModelStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName Documentation string @@ -58,6 +59,7 @@ func (s *AlterModelStmt) isStatement() {} // Documentation: '...' // ); type CreateConsumedMCPServiceStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName OuterDocumentation string // /** ... */ doc comment @@ -95,6 +97,7 @@ func (s *AlterConsumedMCPServiceStmt) isStatement() {} // Key: @Module.SomeConstant // ); type CreateKnowledgeBaseStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName Documentation string @@ -131,6 +134,7 @@ func (s *AlterKnowledgeBaseStmt) isStatement() {} // CreateAgentStmt represents CREATE AGENT Module.Name (...) [{ body }]. type CreateAgentStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName Documentation string diff --git a/mdl/ast/ast_association.go b/mdl/ast/ast_association.go index b4e3904d74..47c00ab5e7 100644 --- a/mdl/ast/ast_association.go +++ b/mdl/ast/ast_association.go @@ -97,6 +97,7 @@ func (s StorageType) String() string { // CreateAssociationStmt represents: CREATE ASSOCIATION Module.Name FROM ... TO ... TYPE ... type CreateAssociationStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Parent QualifiedName Child QualifiedName @@ -112,9 +113,6 @@ type CreateAssociationStmt struct { Documentation string DocumentationSet bool // see mendixlabs/mxcli#1018: absent preserves, empty clears CreateOrModify bool // true for CREATE OR MODIFY / CREATE OR REPLACE - // IfNotExists is CREATE ASSOCIATION IF NOT EXISTS: skip when it already - // exists, leaving the stored definition untouched. - IfNotExists bool // Line anchors from `@anchor(from: (x, y), to: (x, y))` — where the // connector attaches to the FROM and TO entity boxes, as a PERCENTAGE of the diff --git a/mdl/ast/ast_businessevents.go b/mdl/ast/ast_businessevents.go index d1978e163c..8ca33ff1b0 100644 --- a/mdl/ast/ast_businessevents.go +++ b/mdl/ast/ast_businessevents.go @@ -4,6 +4,7 @@ package ast // CreateBusinessEventServiceStmt represents CREATE BUSINESS EVENT SERVICE. type CreateBusinessEventServiceStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName ServiceName string EventNamePrefix string diff --git a/mdl/ast/ast_create_guard.go b/mdl/ast/ast_create_guard.go new file mode 100644 index 0000000000..9f334f441f --- /dev/null +++ b/mdl/ast/ast_create_guard.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 + +package ast + +// CreateGuard is embedded in every document-level CREATE statement that accepts +// `if not exists`. The guard is the "leave it alone" operation of ADR-0010 R1: +// when the named element already exists, the statement is skipped and the +// stored element is not touched; otherwise it creates exactly as a plain +// `create` would. It is deliberately not `create or modify`, which makes the +// stored element match the statement. +// +// The guard is honoured once, in the executor's dispatch, rather than in each +// handler — the same choice DropGuard made for `drop … if exists`: a guard that +// has to be remembered per handler is the one the next doctype forgets. +type CreateGuard struct { + // IfNotExists is `create if not exists `. + IfNotExists bool + // GuardWithOrModify records that the same statement also said + // `create or modify` (or its alias `or replace`). The two guards contradict + // each other, which check reports as MDL067. + GuardWithOrModify bool +} + +// CreateIfNotExists reports whether the statement was written with +// `if not exists`. +func (g *CreateGuard) CreateIfNotExists() bool { return g.IfNotExists } + +// SetCreateIfNotExists records the guard; the visitor calls it once for +// whichever create statement it built. orModify is whether the statement also +// carried `or modify` / `or replace`. +func (g *CreateGuard) SetCreateIfNotExists(orModify bool) { + g.IfNotExists = true + g.GuardWithOrModify = orModify +} + +// CreateGuardContradicts reports `create or modify … if not exists`. +func (g *CreateGuard) CreateGuardContradicts() bool { return g.IfNotExists && g.GuardWithOrModify } + +// IfNotExistsCreate is implemented by every statement that embeds CreateGuard. +type IfNotExistsCreate interface { + Statement + CreateIfNotExists() bool + SetCreateIfNotExists(orModify bool) + CreateGuardContradicts() bool +} diff --git a/mdl/ast/ast_datatransformer.go b/mdl/ast/ast_datatransformer.go index 9e8d2dabe3..bc4958d62d 100644 --- a/mdl/ast/ast_datatransformer.go +++ b/mdl/ast/ast_datatransformer.go @@ -6,6 +6,7 @@ package ast // // CREATE DATA TRANSFORMER Module.Name SOURCE JSON '...' { JSLT '...'; }; type CreateDataTransformerStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName SourceType string // "JSON" or "XML" diff --git a/mdl/ast/ast_entity.go b/mdl/ast/ast_entity.go index 4f7524ef9f..b6e130194c 100644 --- a/mdl/ast/ast_entity.go +++ b/mdl/ast/ast_entity.go @@ -33,6 +33,7 @@ func (k EntityKind) String() string { // CreateEntityStmt represents: CREATE [OR MODIFY] PERSISTENT|NON-PERSISTENT ENTITY Module.Name [EXTENDS Parent] (attributes) ... type CreateEntityStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Kind EntityKind Generalization *QualifiedName // Parent entity for inheritance (e.g., System.Image) @@ -47,10 +48,6 @@ type CreateEntityStmt struct { // comment clears it (mendixlabs/mxcli#1018). DocumentationSet bool CreateOrModify bool // true for CREATE OR MODIFY - // IfNotExists is CREATE ENTITY IF NOT EXISTS: skip entirely when the entity - // is already there. Unlike CreateOrModify it never touches an existing - // definition, so it is the safe way to make a domain script re-runnable. - IfNotExists bool } func (s *CreateEntityStmt) isStatement() {} @@ -176,6 +173,7 @@ type OQLQuery struct { // CreateViewEntityStmt represents: CREATE [OR MODIFY|REPLACE] VIEW ENTITY Module.Name (attrs) AS SELECT ... type CreateViewEntityStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Attributes []ViewAttribute Query OQLQuery diff --git a/mdl/ast/ast_enumeration.go b/mdl/ast/ast_enumeration.go index 6648da5273..007c259ff4 100644 --- a/mdl/ast/ast_enumeration.go +++ b/mdl/ast/ast_enumeration.go @@ -15,7 +15,8 @@ type EnumValue struct { // CreateModuleStmt represents: CREATE MODULE ModuleName type CreateModuleStmt struct { - Name string + CreateGuard // `create … if not exists` (ako/mxcli#731) + Name string } func (s *CreateModuleStmt) isStatement() {} @@ -48,6 +49,7 @@ func (s *MoveFolderStmt) isStatement() {} // CreateEnumerationStmt represents: CREATE ENUMERATION Module.Name (values) COMMENT '...' type CreateEnumerationStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Values []EnumValue Documentation string @@ -104,6 +106,7 @@ func (s *DropEnumerationStmt) isStatement() {} // CreateConstantStmt represents: CREATE CONSTANT Module.Name TYPE type DEFAULT value [COMMENT '...'] type CreateConstantStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName DataType DataType DefaultValue any // The default value (can be string, number, boolean, etc.) diff --git a/mdl/ast/ast_imagecollection.go b/mdl/ast/ast_imagecollection.go index 15d229df72..54750be8b0 100644 --- a/mdl/ast/ast_imagecollection.go +++ b/mdl/ast/ast_imagecollection.go @@ -12,6 +12,7 @@ type ImageItem struct { // // CREATE IMAGE COLLECTION Module.Name [EXPORT LEVEL 'Public'] [COMMENT '...'] [(IMAGE "name" FROM FILE 'path', ...)] type CreateImageCollectionStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName CreateOrModify bool diff --git a/mdl/ast/ast_import_export_mapping.go b/mdl/ast/ast_import_export_mapping.go index 0f964b1d75..ef25a666ca 100644 --- a/mdl/ast/ast_import_export_mapping.go +++ b/mdl/ast/ast_import_export_mapping.go @@ -17,10 +17,11 @@ package ast // } // }; type CreateImportMappingStmt struct { - Name QualifiedName - Folder string // Folder path within module (empty = leave placement alone) - SchemaKind string // "JSON_STRUCTURE" or "XML_SCHEMA" or "" - SchemaRef QualifiedName // qualified name of the schema source + CreateGuard // `create … if not exists` (ako/mxcli#731) + Name QualifiedName + Folder string // Folder path within module (empty = leave placement alone) + SchemaKind string // "JSON_STRUCTURE" or "XML_SCHEMA" or "" + SchemaRef QualifiedName // qualified name of the schema source // SchemaRoot selects the element the mapping STARTS at, when that is not the // structure's own root (#267). Written in member names, "/"-separated. SchemaRoot string @@ -100,10 +101,11 @@ type ImportMappingElementDef struct { // } // }; type CreateExportMappingStmt struct { - Name QualifiedName - Folder string // Folder path within module (empty = leave placement alone) - SchemaKind string // "JSON_STRUCTURE" or "XML_SCHEMA" or "" - SchemaRef QualifiedName // qualified name of the schema source + CreateGuard // `create … if not exists` (ako/mxcli#731) + Name QualifiedName + Folder string // Folder path within module (empty = leave placement alone) + SchemaKind string // "JSON_STRUCTURE" or "XML_SCHEMA" or "" + SchemaRef QualifiedName // qualified name of the schema source // SchemaRoot — see the note on CreateImportMappingStmt (#267). SchemaRoot string NullValueOption string // "LeaveOutElement" or "SendAsNil" (default: "LeaveOutElement") diff --git a/mdl/ast/ast_javaaction.go b/mdl/ast/ast_javaaction.go index 7c4f5191d2..9560510e97 100644 --- a/mdl/ast/ast_javaaction.go +++ b/mdl/ast/ast_javaaction.go @@ -22,6 +22,7 @@ type JavaActionParam struct { // EXPOSED AS 'caption' IN 'category' // AS $$ ... $$; type CreateJavaActionStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName // Qualified name (Module.ActionName) Parameters []JavaActionParam // Input parameters @@ -64,6 +65,7 @@ func (s *DropJavaActionStmt) isStatement() {} // It mirrors CreateJavaActionStmt with an added Platform (Web/Native/Hybrid/All, // default Web). The inline source is JavaScript rather than Java. type CreateJavaScriptActionStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName // Qualified name (Module.ActionName) Parameters []JavaActionParam // Input parameters diff --git a/mdl/ast/ast_jsonstructure.go b/mdl/ast/ast_jsonstructure.go index 617366ce0d..981fbc06db 100644 --- a/mdl/ast/ast_jsonstructure.go +++ b/mdl/ast/ast_jsonstructure.go @@ -6,6 +6,7 @@ package ast // // CREATE [OR REPLACE] JSON STRUCTURE Module.Name [COMMENT 'doc'] SNIPPET '...json...' [CUSTOM NAME MAP (...)]; type CreateJsonStructureStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName JsonSnippet string // Raw JSON snippet Documentation string // Optional documentation comment diff --git a/mdl/ast/ast_messagedefinition.go b/mdl/ast/ast_messagedefinition.go index 7daa36ab13..af06569e4b 100644 --- a/mdl/ast/ast_messagedefinition.go +++ b/mdl/ast/ast_messagedefinition.go @@ -16,6 +16,7 @@ package ast // [FOLDER 'path'] // ( definition Name for Module.Entity [as 'Exposed'] ( members ) , ... ); type CreateMessageDefinitionCollectionStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Folder string CreateOrModify bool diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 0f1207460a..b878a6e254 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -52,6 +52,7 @@ type MicroflowReturnType struct { // CreateMicroflowStmt represents: CREATE MICROFLOW Module.Name (params) RETURNS type BEGIN body END type CreateMicroflowStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Parameters []MicroflowParam ReturnType *MicroflowReturnType @@ -153,6 +154,7 @@ func (s *DropMicroflowStmt) isStatement() {} // CreateNanoflowStmt represents: CREATE NANOFLOW Module.Name (params) RETURNS type BEGIN body END type CreateNanoflowStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Parameters []MicroflowParam ReturnType *MicroflowReturnType @@ -181,6 +183,7 @@ func (s *CreateNanoflowStmt) isStatement() {} // are the same minus the ones a rule document has no property for (a rule stores // no AllowedModuleRoles, so there is nothing to grant). type CreateRuleStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Parameters []MicroflowParam ReturnType *MicroflowReturnType diff --git a/mdl/ast/ast_navigation.go b/mdl/ast/ast_navigation.go index 53791655c6..30a23b0f9c 100644 --- a/mdl/ast/ast_navigation.go +++ b/mdl/ast/ast_navigation.go @@ -73,6 +73,7 @@ type NavMenuItemDef struct { // Like CREATE NAVIGATION, this is a full replacement: the item list given is the // document's complete contents, so an omitted item is a removed item. type CreateMenuStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName Items []NavMenuItemDef diff --git a/mdl/ast/ast_odata.go b/mdl/ast/ast_odata.go index 2e19e4cad2..1707ed4ec6 100644 --- a/mdl/ast/ast_odata.go +++ b/mdl/ast/ast_odata.go @@ -8,6 +8,7 @@ package ast // CreateODataClientStmt represents: CREATE ODATA CLIENT Module.Name (...) type CreateODataClientStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Version string ODataVersion string @@ -86,6 +87,7 @@ func (s *DropODataClientStmt) isStatement() {} // CreateODataServiceStmt represents: CREATE ODATA SERVICE Module.Name (...) AUTHENTICATION ... { ... } type CreateODataServiceStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Path string Version string @@ -210,6 +212,7 @@ func (s *DropODataServiceStmt) isStatement() {} // from "explicitly set to zero" (e.g. Countable: false). Treating omitted // fields as zero on modify silently corrupted entities — see issue #594. type CreateExternalEntityStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName ServiceRef QualifiedName // FROM ODATA CLIENT ... EntitySet *string diff --git a/mdl/ast/ast_page_v3.go b/mdl/ast/ast_page_v3.go index 1fe8f539bd..7b4e236085 100644 --- a/mdl/ast/ast_page_v3.go +++ b/mdl/ast/ast_page_v3.go @@ -25,13 +25,14 @@ import ( // CreatePageStmtV3 represents a V3 page creation statement. // V3 syntax: CREATE PAGE Module.Page (Title: '...', Layout: ...) { widgets } type CreatePageStmtV3 struct { - Name QualifiedName - Parameters []PageParameter // From Params: { } block - Variables []PageVariable // From Variables: { } block - Title string - Layout string - URL string - Folder string + CreateGuard // `create … if not exists` (ako/mxcli#731) + Name QualifiedName + Parameters []PageParameter // From Params: { } block + Variables []PageVariable // From Variables: { } block + Title string + Layout string + URL string + Folder string // Class / Style set the page's Forms$Appearance CSS class and inline style // (issue #714). Empty means "not specified". Class string @@ -71,6 +72,7 @@ type PagePlaceholderV3 struct { // CreateSnippetStmtV3 represents a V3 snippet creation statement. type CreateSnippetStmtV3 struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Parameters []PageParameter // From Params: { } block Variables []PageVariable // From Variables: { } block @@ -90,6 +92,7 @@ func (s *CreateSnippetStmtV3) isStatement() {} // on the content wrapper rather than on the layout element — and which // placeholder a page's content goes into. type CreateLayoutStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Properties map[string]any Widgets []*WidgetV3 diff --git a/mdl/ast/ast_queue.go b/mdl/ast/ast_queue.go index a4f4ee199f..313d020d50 100644 --- a/mdl/ast/ast_queue.go +++ b/mdl/ast/ast_queue.go @@ -6,6 +6,7 @@ package ast // // CREATE [OR REPLACE|MODIFY] QUEUE Module.Name ( Parallelism: 3, ClusterWide: true ); type CreateQueueStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName Documentation string diff --git a/mdl/ast/ast_regularexpression.go b/mdl/ast/ast_regularexpression.go index eb01475d7b..272fe957be 100644 --- a/mdl/ast/ast_regularexpression.go +++ b/mdl/ast/ast_regularexpression.go @@ -8,6 +8,7 @@ package ast // Expression: '^[a-z]+$' // ); type CreateRegularExpressionStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName Documentation string diff --git a/mdl/ast/ast_rest.go b/mdl/ast/ast_rest.go index 5b9d930df8..58332e84ac 100644 --- a/mdl/ast/ast_rest.go +++ b/mdl/ast/ast_rest.go @@ -8,6 +8,7 @@ package ast // CreateRestClientStmt represents: CREATE REST CLIENT Module.Name BASE URL '...' AUTHENTICATION ... BEGIN ... END type CreateRestClientStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName BaseUrl string Authentication *RestAuthDef // nil = AUTHENTICATION NONE @@ -104,6 +105,7 @@ func (s *DescribeContractFromOpenAPIStmt) isStatement() {} // // CREATE PUBLISHED REST SERVICE Module.Name (Path: '...', Version: '...') { RESOURCE ... }; type CreatePublishedRestServiceStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Path string Version string diff --git a/mdl/ast/ast_scheduledevent.go b/mdl/ast/ast_scheduledevent.go index 296d27e61c..6dbf425582 100644 --- a/mdl/ast/ast_scheduledevent.go +++ b/mdl/ast/ast_scheduledevent.go @@ -13,6 +13,7 @@ package ast // "not mentioned" stays distinguishable from "mentioned as 0" — 0 is a real // hour, minute and month offset. type CreateScheduledEventStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName Documentation string diff --git a/mdl/ast/ast_security.go b/mdl/ast/ast_security.go index 4f152b76af..f517cebbc5 100644 --- a/mdl/ast/ast_security.go +++ b/mdl/ast/ast_security.go @@ -8,6 +8,7 @@ package ast // CreateModuleRoleStmt represents: CREATE MODULE ROLE Module.RoleName [DESCRIPTION '...'] type CreateModuleRoleStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name QualifiedName Description string // CreateOrModify makes the statement idempotent: an existing role has its @@ -29,6 +30,7 @@ func (s *DropModuleRoleStmt) isStatement() {} // CreateUserRoleStmt represents: CREATE [OR MODIFY] USER ROLE Name (ModuleRole, ...) [MANAGE ALL ROLES] type CreateUserRoleStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Name string ModuleRoles []QualifiedName ManageAllRoles bool @@ -213,6 +215,7 @@ func (s *AlterProjectSecurityStmt) isStatement() {} // CreateDemoUserStmt represents: CREATE [OR MODIFY] DEMO USER 'name' PASSWORD 'pw' [ENTITY Module.Entity] (Role1, Role2) type CreateDemoUserStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) UserName string Password string Entity string // qualified name of user entity, e.g. "Administration.Account" diff --git a/mdl/ast/ast_settings.go b/mdl/ast/ast_settings.go index 5333e1b555..21f86e37f3 100644 --- a/mdl/ast/ast_settings.go +++ b/mdl/ast/ast_settings.go @@ -41,8 +41,9 @@ func (s *AlterSettingsStmt) isStatement() {} // CreateConfigurationStmt represents CREATE CONFIGURATION 'name' [properties...]. type CreateConfigurationStmt struct { - Name string - Properties map[string]any + CreateGuard // `create … if not exists` (ako/mxcli#731) + Name string + Properties map[string]any // CreateOrModify is CREATE OR MODIFY: update the configuration when it is // already there instead of refusing. The grammar has always accepted the // prefix — `CREATE (OR (MODIFY|REPLACE))?` is generic — so without this the diff --git a/mdl/ast/ast_sql.go b/mdl/ast/ast_sql.go index 3578737706..c631f7bc2a 100644 --- a/mdl/ast/ast_sql.go +++ b/mdl/ast/ast_sql.go @@ -104,6 +104,7 @@ type DatabaseQueryParamDef struct { // CreateDatabaseConnectionStmt represents: CREATE DATABASE CONNECTION Module.Name ... type CreateDatabaseConnectionStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName DatabaseType string // "PostgreSQL", "MSSQL", "Oracle" diff --git a/mdl/ast/ast_workflow.go b/mdl/ast/ast_workflow.go index 5bc82a9528..58f9f1233a 100644 --- a/mdl/ast/ast_workflow.go +++ b/mdl/ast/ast_workflow.go @@ -4,6 +4,7 @@ package ast // CreateWorkflowStmt represents: CREATE WORKFLOW Module.Name ... type CreateWorkflowStmt struct { + CreateGuard // `create … if not exists` (ako/mxcli#731) Folder string // Folder path within module (empty = leave placement alone) Name QualifiedName CreateOrModify bool diff --git a/mdl/executor/cmd_create_guard.go b/mdl/executor/cmd_create_guard.go new file mode 100644 index 0000000000..f6732b2c75 --- /dev/null +++ b/mdl/executor/cmd_create_guard.go @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "reflect" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/model" +) + +// createGuardTarget is what `create … if not exists` tests before the handler +// runs: the element's kind and name as the author wrote them, and a probe for +// whether the project already has it. +type createGuardTarget struct { + kind string + name string + exists func(ctx *ExecContext) (bool, error) +} + +// skipExistingCreate honours `create … if not exists` (ako/mxcli#731, ADR-0010 +// R1): when the named element already exists, the statement is skipped and the +// stored element is left exactly as it is; otherwise the handler creates it as +// a plain `create` would. It reports whether the statement was skipped. +// +// It runs once, in the dispatch, for every statement that embeds +// ast.CreateGuard — the same place `drop … if exists` is honoured — so a new +// document type gets the guard by embedding it rather than by each handler +// remembering to test it. +// +// A probe that cannot answer is an error, not a "no": creating on a guess is +// exactly what the guard promises not to do. +func skipExistingCreate(ctx *ExecContext, stmt ast.Statement) (bool, error) { + g, ok := stmt.(ast.IfNotExistsCreate) + if !ok || !g.CreateIfNotExists() || !ctx.Connected() { + return false, nil + } + target, ok := createGuardTargetOf(stmt) + if !ok { + // Entities and associations test the guard in their own handlers, which + // also know the cross-module association forms. + return false, nil + } + exists, err := target.exists(ctx) + if err != nil { + return false, mdlerrors.NewBackend(fmt.Sprintf("check whether %s %s exists", target.kind, target.name), err) + } + if !exists { + return false, nil + } + fmt.Fprintf(ctx.Output, "%s %s already exists, skipped (if not exists)\n", target.kind, target.name) + return true, nil +} + +// createGuardTargetOf names the element a guarded create would make. ok is +// false for a statement whose handler tests the guard itself. +func createGuardTargetOf(stmt ast.Statement) (createGuardTarget, bool) { + doc := func(kind string, qn ast.QualifiedName, list func(*ExecContext) (any, error)) (createGuardTarget, bool) { + return createGuardTarget{kind: kind, name: qn.String(), exists: func(ctx *ExecContext) (bool, error) { + items, err := list(ctx) + if err != nil { + return false, err + } + return documentListed(ctx, items, qn) + }}, true + } + switch s := stmt.(type) { + case *ast.CreateEntityStmt, *ast.CreateAssociationStmt: + return createGuardTarget{}, false + case *ast.CreateViewEntityStmt: + return entityGuardTarget("view entity", s.Name), true + case *ast.CreateExternalEntityStmt: + return entityGuardTarget("external entity", s.Name), true + case *ast.CreateModuleStmt: + return createGuardTarget{kind: "module", name: s.Name, exists: func(ctx *ExecContext) (bool, error) { + mods, err := ctx.Backend.ListModules() + if err != nil { + return false, err + } + for _, m := range mods { + if m != nil && strings.EqualFold(m.Name, s.Name) { + return true, nil + } + } + return false, nil + }}, true + case *ast.CreateModuleRoleStmt: + return createGuardTarget{kind: "module role", name: s.Name.String(), exists: func(ctx *ExecContext) (bool, error) { + mod, err := findModule(ctx, s.Name.Module) + if err != nil { + // No module, no role: let the handler report the module. + return false, nil + } + ms, err := ctx.Backend.GetModuleSecurity(mod.ID) + if err != nil || ms == nil { + return false, err + } + for _, mr := range ms.ModuleRoles { + // Role names are case-insensitive (CE0123), as in the handler. An + // auto-provisioned role is not the author's: the handler adopts it. + if mr != nil && strings.EqualFold(mr.Name, s.Name.Name) && mr.Description != autoDocumentRoleDescription { + return true, nil + } + } + return false, nil + }}, true + case *ast.CreateUserRoleStmt: + return createGuardTarget{kind: "user role", name: s.Name, exists: func(ctx *ExecContext) (bool, error) { + ps, err := ctx.Backend.GetProjectSecurity() + if err != nil || ps == nil { + return false, err + } + for _, ur := range ps.UserRoles { + if ur != nil && ur.Name == s.Name { + return true, nil + } + } + return false, nil + }}, true + case *ast.CreateDemoUserStmt: + return createGuardTarget{kind: "demo user", name: s.UserName, exists: func(ctx *ExecContext) (bool, error) { + ps, err := ctx.Backend.GetProjectSecurity() + if err != nil || ps == nil { + return false, err + } + for _, du := range ps.DemoUsers { + if du != nil && du.UserName == s.UserName { + return true, nil + } + } + return false, nil + }}, true + case *ast.CreateConfigurationStmt: + return createGuardTarget{kind: "configuration", name: s.Name, exists: func(ctx *ExecContext) (bool, error) { + ps, err := ctx.Backend.GetProjectSettings() + if err != nil || ps == nil || ps.Configuration == nil { + return false, err + } + for _, cfg := range ps.Configuration.Configurations { + if cfg != nil && strings.EqualFold(cfg.Name, s.Name) { + return true, nil + } + } + return false, nil + }}, true + case *ast.CreateMicroflowStmt: + return doc("microflow", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListMicroflows() }) + case *ast.CreateNanoflowStmt: + return doc("nanoflow", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListNanoflows() }) + case *ast.CreateRuleStmt: + return doc("rule", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListRules() }) + case *ast.CreateJavaActionStmt: + return doc("java action", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListJavaActions() }) + case *ast.CreateJavaScriptActionStmt: + return doc("javascript action", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListJavaScriptActions() }) + case *ast.CreatePageStmtV3: + return doc("page", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListPages() }) + case *ast.CreateSnippetStmtV3: + return doc("snippet", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListSnippets() }) + case *ast.CreateLayoutStmt: + return doc("layout", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListLayouts() }) + case *ast.CreateEnumerationStmt: + return doc("enumeration", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListEnumerations() }) + case *ast.CreateConstantStmt: + return doc("constant", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListConstants() }) + case *ast.CreateDatabaseConnectionStmt: + return doc("database connection", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListDatabaseConnections() }) + case *ast.CreateRestClientStmt: + return doc("rest client", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListConsumedRestServices() }) + case *ast.CreatePublishedRestServiceStmt: + return doc("published rest service", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListPublishedRestServices() }) + case *ast.CreateODataClientStmt: + return doc("odata client", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListConsumedODataServices() }) + case *ast.CreateODataServiceStmt: + return doc("odata service", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListPublishedODataServices() }) + case *ast.CreateBusinessEventServiceStmt: + return doc("business event service", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListBusinessEventServices() }) + case *ast.CreateWorkflowStmt: + return doc("workflow", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListWorkflows() }) + case *ast.CreateImageCollectionStmt: + return doc("image collection", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListImageCollections() }) + case *ast.CreateQueueStmt: + return doc("task queue", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListQueues() }) + case *ast.CreateScheduledEventStmt: + return doc("scheduled event", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListScheduledEvents() }) + case *ast.CreateRegularExpressionStmt: + return doc("regular expression", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListRegularExpressions() }) + case *ast.CreateJsonStructureStmt: + return doc("json structure", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListJsonStructures() }) + case *ast.CreateMessageDefinitionCollectionStmt: + return doc("message definition collection", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListMessageDefinitionCollections() }) + case *ast.CreateImportMappingStmt: + return doc("import mapping", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListImportMappings() }) + case *ast.CreateExportMappingStmt: + return doc("export mapping", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListExportMappings() }) + case *ast.CreateDataTransformerStmt: + return doc("data transformer", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListDataTransformers() }) + case *ast.CreateModelStmt: + return doc("model", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListAgentEditorModels() }) + case *ast.CreateConsumedMCPServiceStmt: + return doc("consumed mcp service", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListAgentEditorConsumedMCPServices() }) + case *ast.CreateKnowledgeBaseStmt: + return doc("knowledge base", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListAgentEditorKnowledgeBases() }) + case *ast.CreateAgentStmt: + return doc("agent", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListAgentEditorAgents() }) + case *ast.CreateMenuStmt: + return doc("menu", s.Name, func(ctx *ExecContext) (any, error) { return ctx.Backend.ListMenuDocuments() }) + } + // A statement that embeds the guard but is not listed here would have it + // silently ignored; TestCreateGuardTargetCoversEveryGuardedStatement fails + // first. At run time, refuse rather than create on a guess. + return createGuardTarget{kind: fmt.Sprintf("%T", stmt), exists: func(*ExecContext) (bool, error) { + return false, fmt.Errorf("`if not exists` has no existence check for %T", stmt) + }}, true +} + +// entityGuardTarget probes the domain models for an entity of any kind: a view +// or external entity shares its name space with every other entity. +func entityGuardTarget(kind string, qn ast.QualifiedName) createGuardTarget { + return createGuardTarget{kind: kind, name: qn.String(), exists: func(ctx *ExecContext) (bool, error) { + mod, err := findModule(ctx, qn.Module) + if err != nil { + return false, nil // the handler reports the missing module + } + dms, err := ctx.Backend.ListDomainModels() + if err != nil { + return false, err + } + for _, dm := range dms { + if dm == nil || dm.ContainerID != mod.ID { + continue + } + for _, e := range dm.Entities { + if e != nil && strings.EqualFold(e.Name, qn.Name) { + return true, nil + } + } + } + return false, nil + }} +} + +// documentListed reports whether items — a slice of pointers to document +// structs, each with a ContainerID and a Name, as every Backend.List* returns — +// holds the document qn. Names compare case-insensitively: Mendix refuses two +// documents in one module whose names differ only in case, so an element that +// matches that way is the one a create would collide with. +func documentListed(ctx *ExecContext, items any, qn ast.QualifiedName) (bool, error) { + h, err := getHierarchy(ctx) + if err != nil { + return false, err + } + if h == nil { + return false, fmt.Errorf("no project hierarchy") + } + want := qn.String() + v := reflect.ValueOf(items) + if v.Kind() != reflect.Slice { + return false, fmt.Errorf("document list is %T, not a slice", items) + } + for i := 0; i < v.Len(); i++ { + e := v.Index(i) + if e.Kind() == reflect.Pointer { + if e.IsNil() { + continue + } + e = e.Elem() + } + if e.Kind() != reflect.Struct { + return false, fmt.Errorf("document list element is %s, not a struct", e.Type()) + } + cid, name := e.FieldByName("ContainerID"), e.FieldByName("Name") + if !cid.IsValid() || !name.IsValid() { + return false, fmt.Errorf("document list element %s has no ContainerID or Name", e.Type()) + } + id, ok := cid.Interface().(model.ID) + if !ok { + return false, fmt.Errorf("%s.ContainerID is %s, not model.ID", e.Type(), cid.Type()) + } + if strings.EqualFold(h.GetQualifiedName(id, name.String()), want) { + return true, nil + } + } + return false, nil +} + +// validateCreateGuardContradiction reports `create or modify … if not exists` +// (MDL067) on every guarded document kind. The two guards contradict each +// other: `or modify` makes the stored element match the statement, `if not +// exists` leaves it untouched, and which one wins is not readable from the +// statement. Entities and associations report it from their own flags. +func validateCreateGuardContradiction(stmt ast.Statement) []linter.Violation { + g, ok := stmt.(ast.IfNotExistsCreate) + if !ok || !g.CreateGuardContradicts() { + return nil + } + target, ok := createGuardTargetOf(stmt) + if !ok { + return nil + } + return validateIdempotencyGuard(true, true, target.kind, target.name) +} diff --git a/mdl/executor/create_if_not_exists_test.go b/mdl/executor/create_if_not_exists_test.go new file mode 100644 index 0000000000..00ed17383a --- /dev/null +++ b/mdl/executor/create_if_not_exists_test.go @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/security" +) + +// createGuardCase is one guarded create per document family, and how to make +// the mock project already hold the element it names (ako/mxcli#731). +// +// Most families are found through a Backend.List* method; lister names the +// MockBackend field, and the test fills it by reflection with one element of +// the list's own element type, named after the statement. That also proves the +// production probe reads the real type's ContainerID and Name. +type createGuardCase struct { + src string + lister string // MockBackend List*Func field, or "" + seed func(mb *mock.MockBackend, mod *model.Module) // for the families without one +} + +var createGuardCases = map[string]createGuardCase{ + "microflow": {src: "create microflow if not exists M.X () begin return; end;", lister: "ListMicroflowsFunc"}, + "nanoflow": {src: "create nanoflow if not exists M.X () begin return; end;", lister: "ListNanoflowsFunc"}, + "rule": {src: "create rule if not exists M.X ($c: M.C) returns Boolean begin return true; end;", lister: "ListRulesFunc"}, + "javaaction": {src: "create java action if not exists M.X() returns String as $$return \"\";$$;", lister: "ListJavaActionsFunc"}, + "javascriptaction": {src: "create javascript action if not exists M.X() returns Boolean platform Web as $$return true;$$;", lister: "ListJavaScriptActionsFunc"}, + "page": {src: "create page if not exists M.X (Title: 'X', Layout: Atlas_Core.Atlas_Default) { dynamictext t (Content: 'x') };", lister: "ListPagesFunc"}, + "snippet": {src: "create snippet if not exists M.X { dynamictext t (Content: 'x') };", lister: "ListSnippetsFunc"}, + "layout": {src: "create layout if not exists M.X (layouttype: 'Responsive') { placeholder Main };", lister: "ListLayoutsFunc"}, + "enumeration": {src: "create enumeration if not exists M.X (Red 'Red');", lister: "ListEnumerationsFunc"}, + "constant": {src: "create constant if not exists M.X type String default 'a';", lister: "ListConstantsFunc"}, + "databaseconnection": {src: "create database connection if not exists M.X type 'PostgreSQL' connection string @M.DbUrl username @M.DbUser password @M.DbPass;", + lister: "ListDatabaseConnectionsFunc"}, + "restclient": {src: "create consumed rest service if not exists M.X (BaseUrl: 'https://x.example.com', Authentication: NONE) { };", lister: "ListConsumedRestServicesFunc"}, + "publishedrestservice": {src: "create published rest service if not exists M.X (Path: 'rest/x/v1', Version: '1.0.0', ServiceName: 'X') { };", lister: "ListPublishedRestServicesFunc"}, + "odataclient": {src: "create consumed odata service if not exists M.X (Version: '1.0', ODataVersion: OData4, MetadataUrl: 'https://x.example.com/$metadata');", lister: "ListConsumedODataServicesFunc"}, + "odataservice": {src: "create published odata service if not exists M.X (path: 'odata/x/', version: '1.0.0', ODataVersion: OData4, namespace: 'M.X') authentication basic { };", + lister: "ListPublishedODataServicesFunc"}, + "businesseventservice": {src: "create business event service if not exists M.X (ServiceName: 'X', EventNamePrefix: '') { message E (Id: Long) publish entity M.PBE; };", + lister: "ListBusinessEventServicesFunc"}, + "workflow": {src: "create workflow if not exists M.X parameter $Context: M.C begin end workflow;", lister: "ListWorkflowsFunc"}, + "imagecollection": {src: "create image collection if not exists M.X;", lister: "ListImageCollectionsFunc"}, + "queue": {src: "create task queue if not exists M.X (Parallelism: 3);", lister: "ListQueuesFunc"}, + "scheduledevent": {src: "create scheduled event if not exists M.X (Microflow: M.SE, Repeat: Daily, HourOfDay: 4, MinuteOfHour: 0, TimeZone: Server, Enabled: true);", lister: "ListScheduledEventsFunc"}, + "regularexpression": {src: "create regular expression if not exists M.X (Expression: '.+');", lister: "ListRegularExpressionsFunc"}, + "jsonstructure": {src: "create json structure if not exists M.X snippet '{\"id\": 1}';", lister: "ListJsonStructuresFunc"}, + "messagedefinitioncollection": {src: "create message definition collection if not exists M.X {definition D for M.C as 'Cs' {Id}};", lister: "ListMessageDefinitionCollectionsFunc"}, + "importmapping": {src: "create import mapping if not exists M.X with json structure M.J { create M.C { Id = id } };", lister: "ListImportMappingsFunc"}, + "exportmapping": {src: "create export mapping if not exists M.X with json structure M.J { M.C { id = Id } };", lister: "ListExportMappingsFunc"}, + "datatransformer": {src: "create data transformer if not exists M.X source json '{\"id\": 1}' { jslt '{\"id\": .id}'; };", lister: "ListDataTransformersFunc"}, + "model": {src: "create model if not exists M.X (Provider: MxCloudGenAI, Key: @M.K);", lister: "ListAgentEditorModelsFunc"}, + "consumedmcpservice": {src: "create consumed mcp service if not exists M.X (ProtocolVersion: v2025_03_26, Version: '1.0');", lister: "ListAgentEditorConsumedMCPServicesFunc"}, + "knowledgebase": {src: "create knowledge base if not exists M.X (Provider: MxCloudGenAI, Key: @M.K);", lister: "ListAgentEditorKnowledgeBasesFunc"}, + "agent": {src: "create agent if not exists M.X (UsageType: Task, Model: M.GPT4, SystemPrompt: 'S.', UserPrompt: 'U.');", lister: "ListAgentEditorAgentsFunc"}, + "menu": {src: "create menu if not exists M.X (menu item 'Plain';);", lister: "ListMenuDocumentsFunc"}, + "module": {src: "create module if not exists X;", seed: func(mb *mock.MockBackend, mod *model.Module) { + x := mkModule("X") + mb.ListModulesFunc = func() ([]*model.Module, error) { return []*model.Module{mod, x}, nil } + }}, + "modulerole": {src: "create module role if not exists M.X description 'x';", seed: func(mb *mock.MockBackend, mod *model.Module) { + mb.GetModuleSecurityFunc = func(model.ID) (*security.ModuleSecurity, error) { + return &security.ModuleSecurity{ContainerID: mod.ID, ModuleRoles: []*security.ModuleRole{{Name: "x"}}}, nil + } + }}, + "userrole": {src: "create user role if not exists X (M.User);", seed: func(mb *mock.MockBackend, _ *model.Module) { + mb.GetProjectSecurityFunc = func() (*security.ProjectSecurity, error) { + return &security.ProjectSecurity{UserRoles: []*security.UserRole{{Name: "X"}}}, nil + } + }}, + "demouser": {src: "create demo user if not exists 'X' password 'Password1!' (Clerk);", seed: func(mb *mock.MockBackend, _ *model.Module) { + mb.GetProjectSecurityFunc = func() (*security.ProjectSecurity, error) { + return &security.ProjectSecurity{DemoUsers: []*security.DemoUser{{UserName: "X"}}}, nil + } + }}, + "configuration": {src: "create configuration if not exists 'X';", seed: func(mb *mock.MockBackend, _ *model.Module) { + mb.GetProjectSettingsFunc = func() (*model.ProjectSettings, error) { + return &model.ProjectSettings{Configuration: &model.ConfigurationSettings{ + Configurations: []*model.ServerConfiguration{{Name: "x"}}}}, nil + } + }}, + "viewentity": {src: "create view entity if not exists M.X (Name: String(100)) as (select c.Name as Name from M.C as c);", seed: seedEntityX}, + "externalentity": {src: "create external entity if not exists M.X from consumed odata service M.Api (EntitySet: 'Xs', RemoteName: 'X');", + seed: seedEntityX}, +} + +func seedEntityX(mb *mock.MockBackend, mod *model.Module) { + mb.ListDomainModelsFunc = func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{{ContainerID: mod.ID, Entities: []*domainmodel.Entity{{Name: "X"}}}}, nil + } +} + +// createGuardCtx is a project holding module M and nothing else; with exists, +// it also holds the element the case's statement names. +func createGuardCtx(t *testing.T, c createGuardCase, exists bool) (*ExecContext, *strings.Builder) { + t.Helper() + mod := mkModule("M") + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{{ContainerID: mod.ID}}, nil + }, + GetModuleSecurityFunc: func(model.ID) (*security.ModuleSecurity, error) { + return &security.ModuleSecurity{ContainerID: mod.ID}, nil + }, + GetProjectSecurityFunc: func() (*security.ProjectSecurity, error) { return &security.ProjectSecurity{}, nil }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { + return &model.ProjectSettings{Configuration: &model.ConfigurationSettings{}}, nil + }, + } + if c.lister != "" { + f := reflect.ValueOf(mb).Elem().FieldByName(c.lister) + if !f.IsValid() { + t.Fatalf("MockBackend has no field %s", c.lister) + } + var items []reflect.Value + sliceType := f.Type().Out(0) + if exists { + elem := reflect.New(sliceType.Elem().Elem()) + elem.Elem().FieldByName("ContainerID").Set(reflect.ValueOf(mod.ID)) + elem.Elem().FieldByName("Name").SetString("X") + items = append(items, elem) + } + slice := reflect.MakeSlice(sliceType, 0, len(items)) + slice = reflect.Append(slice, items...) + f.Set(reflect.MakeFunc(f.Type(), func([]reflect.Value) []reflect.Value { + return []reflect.Value{slice, reflect.Zero(f.Type().Out(1))} + })) + } else if exists { + c.seed(mb, mod) + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + var out strings.Builder + ctx.Output = &out + return ctx, &out +} + +// runRecorded runs src through a registry whose handler for the built +// statement only records that it ran, so the test sees exactly what the guard +// decides, independent of what each handler needs from a mock. +func runRecorded(t *testing.T, ctx *ExecContext, src string) (bool, error) { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse %q: %v", src, errs) + } + stmt := prog.Statements[0] + ran := false + r := NewRegistry() + r.handlers[reflect.TypeOf(stmt)] = func(*ExecContext, ast.Statement) error { + ran = true + return nil + } + err := r.Dispatch(ctx, stmt) + return ran, err +} + +// An element that already exists is left alone: the handler — which would +// refuse the create, or rewrite the element — never runs, and the skip is +// reported. +func TestCreateIfNotExists_ExistingElementIsLeftAlone(t *testing.T) { + for name, c := range createGuardCases { + t.Run(name, func(t *testing.T) { + ctx, out := createGuardCtx(t, c, true) + ran, err := runRecorded(t, ctx, c.src) + if err != nil { + t.Fatalf("errored: %v", err) + } + if ran { + t.Error("the create handler ran on an element that already exists") + } + if !strings.Contains(out.String(), "skipped (if not exists)") { + t.Errorf("a skipped create should say so; output: %q", out.String()) + } + }) + } +} + +// CONTROL: an absent element is created — the handler runs. Without this the +// test above passes against a guard that skips everything. +func TestCreateIfNotExists_AbsentElementIsCreated(t *testing.T) { + for name, c := range createGuardCases { + t.Run(name, func(t *testing.T) { + ctx, out := createGuardCtx(t, c, false) + ran, err := runRecorded(t, ctx, c.src) + if err != nil { + t.Fatalf("errored: %v", err) + } + if !ran { + t.Errorf("the create handler did not run on an absent element; output: %q", out.String()) + } + }) + } +} + +// CONTROL: without the guard, an existing element still reaches the handler — +// the skip is the guard's doing, not the probe's. +func TestCreateIfNotExists_UnguardedCreateReachesHandler(t *testing.T) { + for name, c := range createGuardCases { + t.Run(name, func(t *testing.T) { + ctx, _ := createGuardCtx(t, c, true) + ran, err := runRecorded(t, ctx, strings.Replace(c.src, " if not exists", "", 1)) + if err != nil { + t.Fatalf("errored: %v", err) + } + if !ran { + t.Error("an unguarded create was skipped") + } + }) + } +} + +// Every statement type that carries the guard has an existence probe. The +// visitor test proves every create kind builds a guarded statement; this closes +// the loop to the executor, so the fallback "no existence check" error is +// unreachable from a script. +func TestCreateGuardTargetCoversEveryGuardedStatement(t *testing.T) { + var guarded []ast.Statement + for _, c := range createGuardCases { + prog, errs := visitor.Build(c.src) + if len(errs) > 0 { + t.Fatalf("parse %q: %v", c.src, errs) + } + guarded = append(guarded, prog.Statements[0]) + } + seen := map[reflect.Type]bool{} + for _, s := range guarded { + seen[reflect.TypeOf(s)] = true + } + // Every AST type that embeds CreateGuard must be among the cases, except + // the two whose handlers test the guard themselves. + for _, s := range allKnownStatements() { + if _, ok := s.(ast.IfNotExistsCreate); !ok { + continue + } + switch s.(type) { + case *ast.CreateEntityStmt, *ast.CreateAssociationStmt: + continue + } + if !seen[reflect.TypeOf(s)] { + t.Errorf("%T carries the if-not-exists guard but has no case in createGuardCases", s) + } + if tgt, ok := createGuardTargetOf(s); ok && strings.HasPrefix(tgt.kind, "*ast.") { + t.Errorf("%T has no existence probe in createGuardTargetOf", s) + } + } +} diff --git a/mdl/executor/registry.go b/mdl/executor/registry.go index 93054e789c..689c9387f0 100644 --- a/mdl/executor/registry.go +++ b/mdl/executor/registry.go @@ -89,6 +89,12 @@ func (r *Registry) Dispatch(ctx *ExecContext, stmt ast.Statement) error { if h == nil { return mdlerrors.NewUnsupported(fmt.Sprintf("unhandled statement type %T", stmt)) } + // CREATE … IF NOT EXISTS: an element that is already there is left + // untouched, and the handler — which would refuse or rewrite it — never + // runs (ako/mxcli#731). + if skipped, err := skipExistingCreate(ctx, stmt); skipped || err != nil { + return err + } err := h(ctx, stmt) // DROP … IF EXISTS: a missing target is a skip, not a failure (#531). The // guard lives here rather than in the ~35 drop handlers because every one diff --git a/mdl/executor/validate_duplicates.go b/mdl/executor/validate_duplicates.go index 77c77ead21..3bceec58de 100644 --- a/mdl/executor/validate_duplicates.go +++ b/mdl/executor/validate_duplicates.go @@ -89,8 +89,20 @@ func (r *nameRegistry) renameModule(oldMod, newMod string) { // re-runnable domain scripts use it. Missing it makes the check disagree with // what exec does, and the check is wrong: a `create entity if not exists` was // reported as a conflict for a statement exec cleanly skips. -// TestIfNotExistsCountsAsIdempotent guards the mapping. +// TestIfNotExistsCountsAsIdempotent guards the mapping. The guard is read once, +// here, for every document kind that carries it (ako/mxcli#731). func stmtCreateInfo(stmt ast.Statement) (docType, name string, idempotent bool) { + docType, name, idempotent = stmtCreateKind(stmt) + if g, ok := stmt.(ast.IfNotExistsCreate); ok && g.CreateIfNotExists() { + idempotent = true + } + return docType, name, idempotent +} + +// stmtCreateKind is stmtCreateInfo without the `if not exists` guard: the +// doc-type key, the qualified name, and whether `or modify` / `or replace` +// makes the create idempotent. +func stmtCreateKind(stmt ast.Statement) (docType, name string, idempotent bool) { switch s := stmt.(type) { case *ast.CreateModuleStmt: return "module", s.Name, false @@ -100,7 +112,7 @@ func stmtCreateInfo(stmt ast.Statement) (docType, name string, idempotent bool) // existing role, so check has to as well. return "module-role", s.Name.String(), s.CreateOrModify case *ast.CreateEntityStmt: - return "entity", s.Name.String(), s.CreateOrModify || s.IfNotExists + return "entity", s.Name.String(), s.CreateOrModify case *ast.CreateViewEntityStmt: return "entity", s.Name.String(), s.CreateOrModify || s.CreateOrReplace case *ast.CreateExternalEntityStmt: @@ -108,7 +120,7 @@ func stmtCreateInfo(stmt ast.Statement) (docType, name string, idempotent bool) case *ast.CreateEnumerationStmt: return "enumeration", s.Name.String(), s.CreateOrModify case *ast.CreateAssociationStmt: - return "association", s.Name.String(), s.CreateOrModify || s.IfNotExists + return "association", s.Name.String(), s.CreateOrModify case *ast.CreateConstantStmt: return "constant", s.Name.String(), s.CreateOrModify case *ast.CreateMicroflowStmt: diff --git a/mdl/executor/validate_duplicates_coverage_test.go b/mdl/executor/validate_duplicates_coverage_test.go index a2c8917d62..b75305db34 100644 --- a/mdl/executor/validate_duplicates_coverage_test.go +++ b/mdl/executor/validate_duplicates_coverage_test.go @@ -9,6 +9,8 @@ import ( "sort" "strconv" "testing" + + mdlast "github.com/mendixlabs/mxcli/mdl/ast" ) // projectCheckExemptDocTypes lists the doc types stmtCreateInfo can return that @@ -95,7 +97,7 @@ func docTypesFromSwitch(t *testing.T, funcName, kind string) map[string]bool { // // Nothing compared the two switches, so the gap was silent. This does. func TestEveryCreateDocTypeIsProjectChecked(t *testing.T) { - created := docTypesFromSwitch(t, "stmtCreateInfo", "return") + created := docTypesFromSwitch(t, "stmtCreateKind", "return") checked := docTypesFromSwitch(t, "setFor", "case") var missing []string @@ -136,7 +138,7 @@ func TestEveryCreateDocTypeIsProjectChecked(t *testing.T) { // type DROP knows and CREATE does not is harmless, but the reverse means a // `drop X; create X;` pair reports a conflict the script already resolved. func TestEveryDropDocTypeIsCreatable(t *testing.T) { - created := docTypesFromSwitch(t, "stmtCreateInfo", "return") + created := docTypesFromSwitch(t, "stmtCreateKind", "return") dropped := docTypesFromSwitch(t, "stmtDropInfo", "return") var missing []string @@ -161,93 +163,34 @@ func TestEveryDropDocTypeIsCreatable(t *testing.T) { // was told its `create entity if not exists` conflicted with the project, for // a statement exec skips with "already exists — skipped". // -// The guard reads both sides out of source: every `case *ast.XStmt` in -// stmtCreateInfo whose AST type declares an IfNotExists field must mention -// IfNotExists in that case's return. Neither list is restated here. +// Since #731 every document kind carries the guard (ast.CreateGuard), and +// stmtCreateInfo reads it once for all of them. This walks every known +// statement type rather than restating a list: each one that carries the guard +// and is classified as a create must count as idempotent once guarded — and, +// as the control, not before. func TestIfNotExistsCountsAsIdempotent(t *testing.T) { - // Which mdl/ast CREATE types declare an IfNotExists field. - fset := token.NewFileSet() - pkgs, err := parser.ParseDir(fset, "../ast", nil, 0) - if err != nil { - t.Fatalf("parse mdl/ast: %v", err) - } - hasIfNotExists := map[string]bool{} - for _, pkg := range pkgs { - for _, f := range pkg.Files { - ast.Inspect(f, func(n ast.Node) bool { - ts, ok := n.(*ast.TypeSpec) - if !ok { - return true - } - st, ok := ts.Type.(*ast.StructType) - if !ok || st.Fields == nil { - return true - } - for _, fld := range st.Fields.List { - for _, nm := range fld.Names { - if nm.Name == "IfNotExists" { - hasIfNotExists[ts.Name.Name] = true - } - } - } - return true - }) - } - } - if len(hasIfNotExists) == 0 { - t.Fatal("found no mdl/ast type with an IfNotExists field — the guard would pass vacuously") - } - - // Which of them stmtCreateInfo handles, and whether its case consults the field. - fset2 := token.NewFileSet() - f, err := parser.ParseFile(fset2, "validate_duplicates.go", nil, 0) - if err != nil { - t.Fatalf("parse validate_duplicates.go: %v", err) - } - var fn *ast.FuncDecl - for _, d := range f.Decls { - if fd, ok := d.(*ast.FuncDecl); ok && fd.Name.Name == "stmtCreateInfo" { - fn = fd - break - } - } - if fn == nil { - t.Fatal("stmtCreateInfo not found") - } - checked := 0 - ast.Inspect(fn, func(n ast.Node) bool { - c, ok := n.(*ast.CaseClause) + for _, stmt := range allKnownStatements() { + g, ok := stmt.(mdlast.IfNotExistsCreate) if !ok { - return true + continue } - for _, e := range c.List { - star, ok := e.(*ast.StarExpr) - if !ok { - continue - } - sel, ok := star.X.(*ast.SelectorExpr) - if !ok || !hasIfNotExists[sel.Sel.Name] { - continue - } - checked++ - consulted := false - ast.Inspect(c, func(m ast.Node) bool { - if id, ok := m.(*ast.Ident); ok && id.Name == "IfNotExists" { - consulted = true - } - return true - }) - if !consulted { - t.Errorf("stmtCreateInfo case *ast.%s ignores its IfNotExists field: "+ - "`create ... if not exists` on an element the project already has would be "+ - "reported as a conflict, for a statement exec cleanly skips. "+ - "Return `s.CreateOrModify || s.IfNotExists`.", sel.Sel.Name) - } + dt, _, before := stmtCreateInfo(stmt) + if dt == "" { + continue // not a create the duplicate checks track } - return true - }) + checked++ + if before { + t.Errorf("%T counts as idempotent without any guard — the control is void", stmt) + continue + } + g.SetCreateIfNotExists(false) + if _, _, after := stmtCreateInfo(stmt); !after { + t.Errorf("%T: `create … if not exists` is not idempotent to stmtCreateInfo: "+ + "it would be reported as a conflict for a statement exec cleanly skips", stmt) + } + } if checked == 0 { - t.Fatal("stmtCreateInfo handles no type with an IfNotExists field — the guard would pass vacuously") + t.Fatal("no guarded create statement is classified by stmtCreateInfo — the guard would pass vacuously") } } diff --git a/mdl/executor/validate_idempotency_guard_test.go b/mdl/executor/validate_idempotency_guard_test.go index a972952827..247583f38c 100644 --- a/mdl/executor/validate_idempotency_guard_test.go +++ b/mdl/executor/validate_idempotency_guard_test.go @@ -18,6 +18,14 @@ func TestMDL067RejectsContradictoryGuards(t *testing.T) { for _, src := range []string{ `create or modify entity if not exists M."Game" ("Level": string(20));`, `create or modify association if not exists M.Move_Game from M.Move to M.Game;`, + // Every other document kind carries the same guard (ako/mxcli#731). + `create or modify page if not exists M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { dynamictext t (Content: 'x') };`, + `create or modify microflow if not exists M.MF () begin return; end;`, + `create or modify enumeration if not exists M.E (A 'A');`, + `create or replace constant if not exists M.C type String default 'a';`, + `create or modify module role if not exists M.User;`, + `create or modify configuration if not exists 'Default';`, + `create or modify view entity if not exists M.V (Name: String(100)) as (select c.Name as Name from M.C as c);`, } { prog, errs := visitor.Build(src) if len(errs) > 0 { @@ -48,6 +56,10 @@ func TestMDL067LeavesEitherGuardAlone(t *testing.T) { `create association if not exists M.Move_Game from M.Move to M.Game;`, `create or modify association M.Move_Game from M.Move to M.Game;`, `create entity M."Game" ("Level": string(20));`, + `create page if not exists M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { dynamictext t (Content: 'x') };`, + `create or modify page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { dynamictext t (Content: 'x') };`, + `create microflow if not exists M.MF () begin return; end;`, + `create module role if not exists M.User;`, } { prog, errs := visitor.Build(src) if len(errs) > 0 { @@ -72,7 +84,7 @@ func TestCreateEntityIfNotExistsSkipsWithoutTouching(t *testing.T) { Name: ast.QualifiedName{Module: "Sudoku", Name: "Game"}, Kind: ast.EntityPersistent, Attributes: []ast.Attribute{{Name: "OnlyThisOne", Type: ast.DataType{Kind: ast.TypeInteger}}}, - IfNotExists: true, + CreateGuard: ast.CreateGuard{IfNotExists: true}, }) assertNoError(t, err) if *updated { diff --git a/mdl/executor/validate_odata_properties_drift_test.go b/mdl/executor/validate_odata_properties_drift_test.go index 08cef446ac..8966f3781b 100644 --- a/mdl/executor/validate_odata_properties_drift_test.go +++ b/mdl/executor/validate_odata_properties_drift_test.go @@ -78,7 +78,7 @@ func TestKnownODataProps_CoverEveryASTField(t *testing.T) { "external entity", reflect.TypeOf(ast.CreateExternalEntityStmt{}), knownExternalEntityProps, map[string]bool{ "Name": true, "ServiceRef": true, "Attributes": true, "Documentation": true, - "CreateOrModify": true, "UnknownProperties": true, + "CreateOrModify": true, "UnknownProperties": true, "CreateGuard": true, }, }, } diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index fb2b710ddf..3359e92503 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -47,6 +47,9 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { assocStmt.CreateOrModify, assocStmt.IfNotExists, "association", assocStmt.Name.String())...) violations = append(violations, ValidateAssociationModules(assocStmt)...) } + // Every other guarded create carries the same contradiction (#731). + // Entities and associations report it above, from their own flags. + violations = append(violations, validateCreateGuardContradiction(stmt)...) // A user role with no System module role cannot sign in (CE0156) — but // only once security is on, which the script may say itself. if roleStmt, ok := stmt.(*ast.CreateUserRoleStmt); ok { diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 6c619c70a8..c30c719a58 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -606,7 +606,7 @@ navMenuIcon // built from the same items, so this reuses navMenuItemDef rather than defining a // second item syntax. createMenuStatement - : MENU_KW qualifiedName (FOLDER STRING_LITERAL)? LPAREN navMenuItemDef* RPAREN + : MENU_KW ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN navMenuItemDef* RPAREN ; dropStatement diff --git a/mdl/grammar/domains/MDLAgent.g4 b/mdl/grammar/domains/MDLAgent.g4 index 51d25f36ac..c56ca3d02b 100644 --- a/mdl/grammar/domains/MDLAgent.g4 +++ b/mdl/grammar/domains/MDLAgent.g4 @@ -15,7 +15,7 @@ options { tokenVocab = MDLLexer; } // [, DisplayName: '...', KeyName: '...', etc. — Portal-populated metadata] // ); createModelStatement - : MODEL qualifiedName + : MODEL ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN modelProperty (COMMA modelProperty)* RPAREN ; @@ -51,7 +51,7 @@ variableDef // Documentation: '...' // ); createConsumedMCPServiceStatement - : CONSUMED MCP SERVICE qualifiedName + : CONSUMED MCP SERVICE ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN modelProperty (COMMA modelProperty)* RPAREN ; @@ -64,7 +64,7 @@ createConsumedMCPServiceStatement // Key: @Module.SomeConstant // ); createKnowledgeBaseStatement - : KNOWLEDGE BASE qualifiedName + : KNOWLEDGE BASE ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN modelProperty (COMMA modelProperty)* RPAREN ; @@ -81,7 +81,7 @@ createKnowledgeBaseStatement // [ { tool X ( ... ) | mcp service M.X ( ... ) | knowledge base KB ( ... ) } ] // ; createAgentStatement - : AGENT qualifiedName + : AGENT ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN modelProperty (COMMA modelProperty)* RPAREN agentBody? diff --git a/mdl/grammar/domains/MDLDomainModel.g4 b/mdl/grammar/domains/MDLDomainModel.g4 index 5c8376aef1..d5000e4632 100644 --- a/mdl/grammar/domains/MDLDomainModel.g4 +++ b/mdl/grammar/domains/MDLDomainModel.g4 @@ -294,8 +294,13 @@ alterEntityAction // Idempotency guards for a re-runnable domain script: ADD ... IF NOT EXISTS // skips (with a notice) when the member is already present, and DROP ... IF // EXISTS skips when it is already gone — instead of erroring and halting the -// run. Accepted on ATTRIBUTE, EVENT HANDLER and INDEX, and on CREATE ENTITY / -// CREATE ASSOCIATION. +// run. Accepted on ATTRIBUTE, EVENT HANDLER and INDEX. +// +// On a document-level CREATE it sits after the kind's keywords and before the +// name (`create page if not exists M.P …`) on every kind that names one element +// (ako/mxcli#731, ADR-0010 R1): leave an existing element untouched, create it +// otherwise. The visitor applies it once, in ExitCreateStatement, and the +// executor's dispatch honours it, so a create rule only has to accept it. // // EVENT HANDLER and INDEX have no other way to be re-run: a defensive // drop-then-add fails on the drop when the member is absent, and on the add @@ -346,7 +351,7 @@ alterEnumerationAction // ============================================================================= createModuleStatement - : MODULE identifierOrKeyword moduleOptions? + : MODULE ifNotExists? identifierOrKeyword moduleOptions? ; // ============================================================================= @@ -386,7 +391,7 @@ moduleOption // ============================================================================= createEnumerationStatement - : ENUMERATION qualifiedName + : ENUMERATION ifNotExists? qualifiedName LPAREN enumerationValueList RPAREN enumerationOptions? ; @@ -428,7 +433,7 @@ enumerationOption * quoted expression. */ createQueueStatement - : taskQueueKw qualifiedName (FOLDER STRING_LITERAL)? queueBody? + : taskQueueKw ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? queueBody? ; queueBody @@ -448,7 +453,7 @@ queueProperty // document rather than a string on the rule. createRegularExpressionStatement - : REGULAR EXPRESSION qualifiedName (FOLDER STRING_LITERAL)? regularExpressionBody? + : REGULAR EXPRESSION ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? regularExpressionBody? ; regularExpressionBody @@ -473,7 +478,7 @@ regularExpressionProperty // not belong to the chosen repeat. createScheduledEventStatement - : SCHEDULED EVENT qualifiedName (FOLDER STRING_LITERAL)? scheduledEventBody? + : SCHEDULED EVENT ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? scheduledEventBody? ; scheduledEventBody @@ -489,7 +494,7 @@ scheduledEventProperty // ============================================================================= createImageCollectionStatement - : IMAGE COLLECTION qualifiedName (FOLDER STRING_LITERAL)? imageCollectionOptions? imageCollectionBody? + : IMAGE COLLECTION ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? imageCollectionOptions? imageCollectionBody? ; // CREATE [OR MODIFY] ANNOTATION IN Module ( Caption: '…', Position: (x, y), Width: n ) @@ -557,7 +562,7 @@ imageName // ============================================================================= createJsonStructureStatement - : JSON STRUCTURE qualifiedName (FOLDER STRING_LITERAL)? (COMMENT /* @alias MDL-DEPR100 */ STRING_LITERAL)? SNIPPET (STRING_LITERAL | DOLLAR_STRING) + : JSON STRUCTURE ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? (COMMENT /* @alias MDL-DEPR100 */ STRING_LITERAL)? SNIPPET (STRING_LITERAL | DOLLAR_STRING) (CUSTOM_NAME_MAP LPAREN customNameMapping (COMMA customNameMapping)* RPAREN)? ; @@ -611,7 +616,7 @@ customNameMapping * (Module.Collection.Definition), so the collection is never implicit. */ createMessageDefinitionCollectionStatement - : MESSAGE DEFINITION COLLECTION qualifiedName + : MESSAGE DEFINITION COLLECTION ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? ( LBRACE messageDefinitionDef (COMMA? messageDefinitionDef)* COMMA? RBRACE | LPAREN /* @alias MDL-DEPR073 */ messageDefinitionDef (COMMA messageDefinitionDef)* COMMA? RPAREN @@ -727,7 +732,7 @@ messageMemberPath * }; */ createImportMappingStatement - : IMPORT MAPPING qualifiedName + : IMPORT MAPPING ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? importMappingWithClause? importMappingParameterClause? @@ -869,7 +874,7 @@ importMappingObjectHandling * }; */ createExportMappingStatement - : EXPORT MAPPING qualifiedName + : EXPORT MAPPING ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? exportMappingWithClause? exportMappingNullValuesClause? @@ -969,7 +974,7 @@ validationRuleRange // ============================================================================= createConstantStatement - : CONSTANT qualifiedName + : CONSTANT ifNotExists? qualifiedName TYPE dataType DEFAULT literal constantOptions? @@ -1005,7 +1010,7 @@ createIndexStatement * }; */ createDataTransformerStatement - : DATA TRANSFORMER qualifiedName + : DATA TRANSFORMER ifNotExists? qualifiedName (FOLDER folder=STRING_LITERAL)? SOURCE_KW (JSON | XML) source=STRING_LITERAL LBRACE dataTransformerStep* RBRACE diff --git a/mdl/grammar/domains/MDLMicroflow.g4 b/mdl/grammar/domains/MDLMicroflow.g4 index 3c6d68b090..24b6abbac2 100644 --- a/mdl/grammar/domains/MDLMicroflow.g4 +++ b/mdl/grammar/domains/MDLMicroflow.g4 @@ -14,7 +14,7 @@ options { tokenVocab = MDLLexer; } * Creates a new microflow with parameters, return type, and activity body. */ createMicroflowStatement - : MICROFLOW qualifiedName + : MICROFLOW ifNotExists? qualifiedName LPAREN microflowParameterList? RPAREN microflowReturnType? microflowOptions? @@ -25,7 +25,7 @@ createMicroflowStatement * Nanoflow creation — mirrors microflow syntax but targets client-side execution. */ createNanoflowStatement - : NANOFLOW qualifiedName + : NANOFLOW ifNotExists? qualifiedName LPAREN microflowParameterList? RPAREN microflowReturnType? microflowOptions? @@ -41,7 +41,7 @@ createNanoflowStatement * no explanation. */ createRuleStatement - : RULE qualifiedName + : RULE ifNotExists? qualifiedName LPAREN microflowParameterList? RPAREN microflowReturnType? microflowOptions? @@ -52,7 +52,7 @@ createRuleStatement * Java Action creation with inline Java source code. */ createJavaActionStatement - : JAVA ACTION qualifiedName + : JAVA ACTION ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN javaActionParameterList? RPAREN javaActionReturnType? @@ -108,7 +108,7 @@ exposeBitmapClause * defaults to Web). Reuses the javaAction parameter/return/exposed sub-rules. */ createJavaScriptActionStatement - : JAVASCRIPT ACTION qualifiedName + : JAVASCRIPT ACTION ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN javaActionParameterList? RPAREN javaActionReturnType? diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index 15d7700bd2..6d11fd058b 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -16,7 +16,7 @@ options { tokenVocab = MDLLexer; } // R9: the folder is a clause after the name, as on every document; the // `Folder:` header property is a registered alias. createPageStatement - : PAGE qualifiedName + : PAGE ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? pageHeaderV3 LBRACE pageBodyV3 RBRACE @@ -31,7 +31,7 @@ createPageStatement // wrapper, not on the layout element — and which placeholder a page's content // goes into. createLayoutStatement - : LAYOUT qualifiedName + : LAYOUT ifNotExists? qualifiedName widgetPropertiesV3? LBRACE pageBodyV3 RBRACE ; @@ -41,7 +41,7 @@ createLayoutStatement // ============================================================================= createSnippetStatement - : SNIPPET qualifiedName + : SNIPPET ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? snippetHeaderV3? snippetOptions? diff --git a/mdl/grammar/domains/MDLSecurity.g4 b/mdl/grammar/domains/MDLSecurity.g4 index 8fd199125d..dbd0a7954b 100644 --- a/mdl/grammar/domains/MDLSecurity.g4 +++ b/mdl/grammar/domains/MDLSecurity.g4 @@ -15,7 +15,7 @@ options { tokenVocab = MDLLexer; } // it, re-executing the script that sets up roles fails on the first role that // already exists), and a doc comment attaches to it (#731). createModuleRoleStatement - : MODULE ROLE qualifiedName (DESCRIPTION STRING_LITERAL)? + : MODULE ROLE ifNotExists? qualifiedName (DESCRIPTION STRING_LITERAL)? ; dropModuleRoleStatement @@ -23,7 +23,7 @@ dropModuleRoleStatement ; createUserRoleStatement - : USER ROLE identifierOrKeyword + : USER ROLE ifNotExists? identifierOrKeyword LPAREN moduleRoleList RPAREN (MANAGE ALL ROLES)? ; @@ -139,7 +139,7 @@ appSecurityKw ; createDemoUserStatement - : DEMO USER STRING_LITERAL PASSWORD STRING_LITERAL (ENTITY qualifiedName)? + : DEMO USER ifNotExists? STRING_LITERAL PASSWORD STRING_LITERAL (ENTITY qualifiedName)? LPAREN identifierOrKeyword (COMMA identifierOrKeyword)* RPAREN ; diff --git a/mdl/grammar/domains/MDLService.g4 b/mdl/grammar/domains/MDLService.g4 index 2595aa5586..4125b3246d 100644 --- a/mdl/grammar/domains/MDLService.g4 +++ b/mdl/grammar/domains/MDLService.g4 @@ -11,7 +11,7 @@ options { tokenVocab = MDLLexer; } // ============================================================================= createDatabaseConnectionStatement - : DATABASE CONNECTION qualifiedName + : DATABASE CONNECTION ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? databaseConnectionOption+ (BEGIN databaseQuery* END)? @@ -42,8 +42,8 @@ databaseQueryMapping ; createConfigurationStatement - : CONFIGURATION STRING_LITERAL settingsItemOptions? // configuration 'X' ( Key: value, … ) - | CONFIGURATION STRING_LITERAL + : CONFIGURATION ifNotExists? STRING_LITERAL settingsItemOptions? // configuration 'X' ( Key: value, … ) + | CONFIGURATION ifNotExists? STRING_LITERAL settingsAssignment (COMMA settingsAssignment)* // old spelling: Key = value, … (MDL-DEPR060) ; @@ -54,7 +54,7 @@ createConfigurationStatement // list is a registered alias /* @alias MDL-DEPR105 */, here and in the // published REST and OData property lists; the visitor reports it by key. createRestClientStatement - : consumedRestServiceKw qualifiedName + : consumedRestServiceKw ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN restClientProperty (COMMA restClientProperty)* RPAREN (LBRACE restClientOperation* RBRACE)? @@ -113,7 +113,7 @@ restHttpMethod // ============================================================================= createPublishedRestServiceStatement - : PUBLISHED REST SERVICE qualifiedName + : PUBLISHED REST SERVICE ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN publishedRestProperty (COMMA publishedRestProperty)* RPAREN LBRACE publishedRestResource* RBRACE @@ -191,14 +191,14 @@ taskQueuesKw ; createODataClientStatement - : consumedODataServiceKw qualifiedName + : consumedODataServiceKw ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN odataPropertyAssignment (COMMA odataPropertyAssignment)* RPAREN odataHeadersClause? ; createODataServiceStatement - : publishedODataServiceKw qualifiedName + : publishedODataServiceKw ifNotExists? qualifiedName (FOLDER STRING_LITERAL)? LPAREN odataPropertyAssignment (COMMA odataPropertyAssignment)* RPAREN odataAuthenticationClause? @@ -283,7 +283,7 @@ exposeMemberOptions ; createExternalEntityStatement - : EXTERNAL ENTITY qualifiedName + : EXTERNAL ENTITY ifNotExists? qualifiedName FROM consumedODataServiceKw qualifiedName LPAREN odataPropertyAssignment (COMMA odataPropertyAssignment)* RPAREN (LPAREN attributeDefinitionList? RPAREN)? @@ -313,7 +313,7 @@ odataHeaderEntry // ============================================================================= createBusinessEventServiceStatement - : BUSINESS EVENT SERVICE qualifiedName + : BUSINESS EVENT SERVICE ifNotExists? qualifiedName LPAREN odataPropertyAssignment (COMMA odataPropertyAssignment)* RPAREN LBRACE businessEventMessageDef+ RBRACE ; diff --git a/mdl/grammar/domains/MDLWorkflow.g4 b/mdl/grammar/domains/MDLWorkflow.g4 index 3fe2e5ce5e..687691c3d9 100644 --- a/mdl/grammar/domains/MDLWorkflow.g4 +++ b/mdl/grammar/domains/MDLWorkflow.g4 @@ -13,7 +13,7 @@ options { tokenVocab = MDLLexer; } * Create a workflow with activities. */ createWorkflowStatement - : WORKFLOW qualifiedName + : WORKFLOW ifNotExists? qualifiedName workflowHeaderClause* BEGIN workflowMainBody workflowEventSubProcess* END WORKFLOW SEMICOLON? SLASH? ; diff --git a/mdl/visitor/visitor.go b/mdl/visitor/visitor.go index 0f0ee1e36c..a6beb4ebeb 100644 --- a/mdl/visitor/visitor.go +++ b/mdl/visitor/visitor.go @@ -517,6 +517,10 @@ type Builder struct { // stringLits are the string literals of the parse tree, by token index, // for the string-escape rewrite to find the expression each is in. stringLits map[int]antlr.TerminalNode + // createStart is len(statements) when the current createStatement was + // entered, so ExitCreateStatement can tell which statement it built — see + // applyCreateGuard. + createStart int } // NewBuilder creates a new AST builder. diff --git a/mdl/visitor/visitor_association.go b/mdl/visitor/visitor_association.go index 4a43a0959e..d373abf5c2 100644 --- a/mdl/visitor/visitor_association.go +++ b/mdl/visitor/visitor_association.go @@ -28,7 +28,6 @@ func (b *Builder) ExitCreateAssociationStatement(ctx *parser.CreateAssociationSt Type: ast.AssocReference, // Default Owner: ast.OwnerDefault, DeleteBehavior: ast.DeleteKeepReferences, - IfNotExists: ctx.IfNotExists() != nil, } // The doc comment, the same spelling every other document type uses. It // was never captured here, so an association was the one domain-model diff --git a/mdl/visitor/visitor_create_guard.go b/mdl/visitor/visitor_create_guard.go new file mode 100644 index 0000000000..6678b50ea5 --- /dev/null +++ b/mdl/visitor/visitor_create_guard.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "fmt" + + "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" +) + +// EnterCreateStatement remembers how many statements were built before this +// one, so ExitCreateStatement can find the statement the create rule built. +func (b *Builder) EnterCreateStatement(ctx *parser.CreateStatementContext) { + b.createStart = len(b.statements) +} + +// applyCreateGuard applies `if not exists` to whichever statement the create +// rule built (ako/mxcli#731, ADR-0010 R1). +// +// The guard is written in each create rule — after the kind's keywords, before +// the name — but applied here once, the way `drop … if exists` is applied in +// ExitDropStatement: a guard each document builder has to remember to read is +// the one the next document type forgets. A statement type that does not embed +// ast.CreateGuard is reported rather than silently built without the guard, +// since an ignored guard turns a leave-it-alone create into a plain one. +func (b *Builder) applyCreateGuard(ctx *parser.CreateStatementContext) { + guard := createGuardToken(ctx) + if guard == nil || len(b.statements) != b.createStart+1 { + return + } + stmt := b.statements[len(b.statements)-1] + g, ok := stmt.(ast.IfNotExistsCreate) + if !ok { + b.addError(fmt.Errorf("line %d: `if not exists` is not supported on %T", + guard.GetStart().GetLine(), stmt)) + return + } + g.SetCreateIfNotExists(ctx.OR() != nil) +} + +// createGuardToken returns the `if not exists` of the create rule under ctx, or +// nil. Only the create rule's own children are searched: a guard deeper in the +// tree belongs to something inside the document, not to the document. +func createGuardToken(ctx *parser.CreateStatementContext) *parser.IfNotExistsContext { + for _, child := range ctx.GetChildren() { + rc, ok := child.(antlr.ParserRuleContext) + if !ok { + continue + } + for _, grand := range rc.GetChildren() { + if g, ok := grand.(*parser.IfNotExistsContext); ok { + return g + } + } + } + return nil +} diff --git a/mdl/visitor/visitor_create_if_not_exists_test.go b/mdl/visitor/visitor_create_if_not_exists_test.go new file mode 100644 index 0000000000..12303e3d9f --- /dev/null +++ b/mdl/visitor/visitor_create_if_not_exists_test.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "regexp" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// createIfNotExistsExempt names the create kinds that do not take +// `if not exists`, and why. Everything else createStatement accepts must +// (ako/mxcli#731, ADR-0010 R1: "if not exists on every document type"). +var createIfNotExistsExempt = map[string]string{ + "annotation": "an annotation has no name to test for existence", + "index": "an index is an entity member identified by its columns; alter entity … add index if not exists is its guard", + "validationrule": "a validation rule is keyed by its attribute, not by a name", + "navigation": "a navigation profile always exists; create navigation configures it", + "translations": "translations merge into existing texts; there is nothing to leave alone", + "externalentities": "a bulk import of many entities, not one element", +} + +// createNameRE finds where the element name starts in a createOrReplaceCases +// body: the first qualified name in module M, the bare module name, a user +// role name, or a quoted name (demo user, configuration). +var createNameRE = regexp.MustCompile(`\s(M\.\w|M;|Clerk\b|'Default'|'demo')`) + +// withIfNotExists writes `if not exists` in its canonical place: after the +// kind's keywords, before the name — `create persistent entity if not exists +// Shop.Audit (…)`, as in the proposal's §3. +func withIfNotExists(t *testing.T, body string) string { + t.Helper() + loc := createNameRE.FindStringIndex(body) + if loc == nil { + t.Fatalf("no element name found in %q", body) + } + return body[:loc[0]] + " if not exists" + body[loc[0]:] +} + +func clearCreateGuard(stmts []ast.Statement) { + for _, s := range stmts { + if g, ok := s.(ast.IfNotExistsCreate); ok { + v := reflect.ValueOf(g).Elem().FieldByName("CreateGuard") + v.Set(reflect.Zero(v.Type())) + } + } +} + +// Every document kind accepts `create if not exists `, the built +// statement carries the guard, and apart from the guard it is the statement a +// plain `create` builds — the guard changes whether it runs, not what it says. +func TestCreateIfNotExistsOnEveryDocumentKind(t *testing.T) { + kinds := createStatementKinds(t) + for _, kind := range kinds { + if _, ok := createOrReplaceCases[kind]; !ok { + t.Errorf("create kind %q has no case in createOrReplaceCases", kind) + } + } + cases := make(map[string]string, len(createOrReplaceCases)+1) + for k, v := range createOrReplaceCases { + cases[k] = v + } + cases["entity (view)"] = "view entity M.V (Name: String(100)) as (select c.Name as Name from M.Customer as c);" + cases["entity (non-persistent)"] = "non-persistent entity M.Filter (Q: String(200));" + + for name, body := range cases { + if _, exempt := createIfNotExistsExempt[name]; exempt { + continue + } + body = strings.TrimSuffix(body, ";") + ";" // every statement ends with ; (valid under mdl 0 and mdl 1) + for _, header := range []string{"", "mdl 1;\n"} { + t.Run(name+"/"+strings.TrimSpace(header), func(t *testing.T) { + guarded := header + "create " + withIfNotExists(t, body) + plain := header + "create " + body + g := mustBuild(t, guarded) + p := mustBuild(t, plain) + if len(g.Statements) != 1 || len(p.Statements) != 1 { + t.Fatalf("want one statement each, got %d and %d", len(g.Statements), len(p.Statements)) + } + guard, ok := g.Statements[0].(ast.IfNotExistsCreate) + if !ok { + t.Fatalf("%q built %T, which does not carry the if-not-exists guard", guarded, g.Statements[0]) + } + if !guard.CreateIfNotExists() { + t.Errorf("%q: guard not recorded", guarded) + } + if guard.CreateGuardContradicts() { + t.Errorf("%q: reported as contradicting or modify", guarded) + } + if pg, ok := p.Statements[0].(ast.IfNotExistsCreate); ok && pg.CreateIfNotExists() { + t.Errorf("%q: plain create recorded the guard", plain) + } + clearCreateGuard(g.Statements) + clearCreateGuard(p.Statements) + if !reflect.DeepEqual(g.Statements, p.Statements) { + t.Errorf("the guard changed what the statement says:\n guarded: %#v\n plain: %#v", + g.Statements, p.Statements) + } + if len(g.Deprecations) != 0 { + t.Errorf("the canonical guarded form recorded deprecations %v", deprecationCodes(g)) + } + }) + } + } +} + +// `create or modify … if not exists` is recorded as such on every kind, so +// check can refuse it (MDL067) — the two guards contradict each other. +func TestCreateOrModifyIfNotExistsIsRecorded(t *testing.T) { + for name, body := range createOrReplaceCases { + if _, exempt := createIfNotExistsExempt[name]; exempt { + continue + } + t.Run(name, func(t *testing.T) { + prog := mustBuild(t, "create or modify "+withIfNotExists(t, body)) + guard, ok := prog.Statements[0].(ast.IfNotExistsCreate) + if !ok { + t.Fatalf("built %T without the guard", prog.Statements[0]) + } + if !guard.CreateGuardContradicts() { + t.Error("or modify + if not exists not recorded as contradicting") + } + }) + } +} + +// The exempt kinds refuse the guard at parse time rather than accepting and +// ignoring it — an ignored guard is a plain create that fails, or worse +// rewrites, on the second run. +func TestCreateIfNotExistsRefusedOnExemptKinds(t *testing.T) { + for name := range createIfNotExistsExempt { + body, ok := createOrReplaceCases[name] + if !ok { + t.Errorf("exempt kind %q is not a create kind", name) + continue + } + t.Run(name, func(t *testing.T) { + src := "create " + strings.Replace(body, " ", " if not exists ", 1) + if _, errs := Build(src); len(errs) == 0 { + t.Errorf("%q parsed; an exempt kind must refuse `if not exists`", src) + } + }) + } +} diff --git a/mdl/visitor/visitor_document_annotations.go b/mdl/visitor/visitor_document_annotations.go index a1b499ef2c..5ec8bcca70 100644 --- a/mdl/visitor/visitor_document_annotations.go +++ b/mdl/visitor/visitor_document_annotations.go @@ -29,6 +29,7 @@ func (b *Builder) ExitCreateStatement(ctx *parser.CreateStatementContext) { return } b.recordCreateOrReplace(ctx) + b.applyCreateGuard(ctx) anns := ctx.AllAnnotation() if len(anns) == 0 { return diff --git a/mdl/visitor/visitor_entity.go b/mdl/visitor/visitor_entity.go index 157042022c..ceb88e750c 100644 --- a/mdl/visitor/visitor_entity.go +++ b/mdl/visitor/visitor_entity.go @@ -20,9 +20,8 @@ func (b *Builder) ExitCreateEntityStatement(ctx *parser.CreateEntityStatementCon } stmt := &ast.CreateEntityStmt{ - Name: buildQualifiedName(ctx.QualifiedName()), - Kind: ast.EntityPersistent, // Default - IfNotExists: ctx.IfNotExists() != nil, + Name: buildQualifiedName(ctx.QualifiedName()), + Kind: ast.EntityPersistent, // Default } // Entity type From fd55a9a0dbe0fe46912e68e1ff69f54f5a4088b7 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 16:03:21 +0000 Subject: [PATCH 02/17] mdl: menus, property maps and the database connection take R2's brackets (#754) Menu items are { } children with no ';' separators, and an item's action and icon are its properties: menu item 'Home' ( OnClick: show page M.P, Icon: I ). A navigation profile's items are its own { } children. The property maps are in ( ): a page or snippet header's Params/Variables, ContentParams/CaptionParams/Params ({1} = e), DesignProperties, a snippet call's arguments (P = $v, R4) and a REST operation's Headers ('Name': value). A database connection is ( Type: ..., ConnectionString: @M.C, ... ) { query Q ( Sql: ..., Parameters: ( ... ), Returns: M.E, Map: ( Attr = column ) ) }. Every old spelling keeps parsing under both language versions, builds the same statement, warns (MDL-DEPR120..127) and carries a structural fmt --upgrade rewrite. Co-Authored-By: Claude Opus 5.5 --- mdl/ast/ast_page_v3.go | 4 +- mdl/deprecation/deprecation.go | 134 ++++++++ mdl/grammar/MDLParser.g4 | 61 +++- mdl/grammar/domains/MDLPage.g4 | 52 ++- mdl/grammar/domains/MDLService.g4 | 53 ++- mdl/upgrade/metadata_placement_test.go | 4 +- mdl/upgrade/r2_children_test.go | 2 +- mdl/upgrade/r2_rest_test.go | 104 ++++++ mdl/upgrade/r8_spellings_test.go | 2 +- mdl/visitor/r2_children_test.go | 4 +- mdl/visitor/r2_rest_test.go | 310 +++++++++++++++++ mdl/visitor/r8_spellings_test.go | 4 +- mdl/visitor/visitor_dbconnection.go | 134 +++++++- mdl/visitor/visitor_deprecations_test.go | 4 +- mdl/visitor/visitor_menu.go | 8 +- mdl/visitor/visitor_navigation.go | 76 ++++- mdl/visitor/visitor_page_v3.go | 33 +- mdl/visitor/visitor_r2_children.go | 1 + mdl/visitor/visitor_r2_rest.go | 318 ++++++++++++++++++ mdl/visitor/visitor_r8_spellings.go | 5 + mdl/visitor/visitor_rest.go | 2 +- .../visitor_snippetcall_params_test.go | 6 +- 22 files changed, 1251 insertions(+), 70 deletions(-) create mode 100644 mdl/upgrade/r2_rest_test.go create mode 100644 mdl/visitor/r2_rest_test.go create mode 100644 mdl/visitor/visitor_r2_rest.go diff --git a/mdl/ast/ast_page_v3.go b/mdl/ast/ast_page_v3.go index 1fe8f539bd..dcf40815b1 100644 --- a/mdl/ast/ast_page_v3.go +++ b/mdl/ast/ast_page_v3.go @@ -26,7 +26,7 @@ import ( // V3 syntax: CREATE PAGE Module.Page (Title: '...', Layout: ...) { widgets } type CreatePageStmtV3 struct { Name QualifiedName - Parameters []PageParameter // From Params: { } block + Parameters []PageParameter // From the Params: ( ) map Variables []PageVariable // From Variables: { } block Title string Layout string @@ -72,7 +72,7 @@ type PagePlaceholderV3 struct { // CreateSnippetStmtV3 represents a V3 snippet creation statement. type CreateSnippetStmtV3 struct { Name QualifiedName - Parameters []PageParameter // From Params: { } block + Parameters []PageParameter // From the Params: ( ) map Variables []PageVariable // From Variables: { } block Folder string Widgets []*WidgetV3 diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 61e19434f4..156f09f647 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -241,6 +241,34 @@ const ( // fragment in braces: `insert after $X { … }`, `replace … with { … }`. AlterFlowFragmentBraces = "MDL-DEPR074" + // Codes 120-129 are the rest of R2 (ako/mxcli#754): navigation and menus, + // property maps, and the database connection. + + // RestHeaderEquals is a consumed REST service operation's header written + // `'Name' = value`: a header list is a map, `( 'Name': value )`. + RestHeaderEquals = "MDL-DEPR120" + // MenuChildrenParens is a navigation profile's `menu ( … )`, a menu + // document's or a sub-menu's items in ( ), and the `;` after an item: + // menu items are children, in { } with no separator. + MenuChildrenParens = "MDL-DEPR121" + // MenuItemClauses is a menu item's action or icon written as a clause after + // its caption, `menu item 'X' page M.P icon I`, rather than in its property + // list `( OnClick: show page M.P, Icon: I )`. + MenuItemClauses = "MDL-DEPR122" + // HeaderMapBraces is a page or snippet header's `Params: { … }` / + // `Variables: { … }`: a map in braces. + HeaderMapBraces = "MDL-DEPR123" + // TemplateParamsBrackets is a text template's parameters in brackets, + // `ContentParams: [{1} = …]` (also CaptionParams and `Params`). + TemplateParamsBrackets = "MDL-DEPR124" + // DesignPropertiesBrackets is `DesignProperties: ['Key': 'Value']`. + DesignPropertiesBrackets = "MDL-DEPR125" + // SnippetCallParamsBraces is a snippet call's `Params: {$P: $v}`. + SnippetCallParamsBraces = "MDL-DEPR126" + // DatabaseConnectionClauses is a database connection written as clauses + // with a begin … end block of queries. + DatabaseConnectionClauses = "MDL-DEPR127" + // 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 @@ -495,6 +523,7 @@ func init() { entries = append(entries, r6Entries...) entries = append(entries, r5Entries...) entries = append(entries, r2Entries...) + entries = append(entries, r2RestEntries...) entries = append(entries, r3Entries...) } @@ -781,6 +810,111 @@ var r2Entries = []Entry{ }, } +// r2RestEntries are the rest of R2 (ako/mxcli#754): navigation and menus, +// property maps, and the database connection. +var r2RestEntries = []Entry{ + { + Code: RestHeaderEquals, + Old: "Headers: ( 'Name' = value )", + Canonical: "Headers: ( 'Name': value )", + Rewrite: Rewrite{Structural: "header list: `'Name' = value` becomes `'Name': value`"}, + RemovedIn: 2, + Note: "A header list is a map of the operation's properties, so it is `( key: value )` like every property map (R2/R3).", + Example: "create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) " + + "{ operation Ping ( Method: get, Path: '/p', Headers: ('Accept' = 'application/json'), Response: none ) };", + CanonicalExample: "create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) " + + "{ operation Ping ( Method: get, Path: '/p', Headers: ('Accept': 'application/json'), Response: none ) };", + }, + { + Code: MenuChildrenParens, + Old: "navigation P menu ( menu item …; menu 'X' ( … ); ) / create menu M.M ( … )", + Canonical: "navigation P { menu item … menu 'X' { … } } / create menu M.M { … }", + Rewrite: Rewrite{Structural: "menu items: `menu (` becomes `{`, a menu's or sub-menu's ( ) become { }, " + + "and the `;` after each item is dropped"}, + RemovedIn: 2, + Note: "Menu items are children, so they are in { } like a page's widgets, and a child ends in `)` or `}`, " + + "so it needs no separator (R2). In a navigation profile the items are the profile's own children.", + Example: "create or modify navigation Responsive home page M.Home menu ( menu item 'Home'; menu 'Admin' ( menu item 'Users'; ); );", + CanonicalExample: "create or modify navigation Responsive home page M.Home { menu item 'Home' menu 'Admin' { menu item 'Users' } };", + }, + { + Code: MenuItemClauses, + Old: "menu item 'X' page M.P icon I / microflow M.F / sign out", + Canonical: "menu item 'X' ( OnClick: show page M.P, Icon: I ) / call microflow M.F / sign out", + Rewrite: Rewrite{Structural: "item clauses: `page M.P` becomes `( OnClick: show page M.P )`, `microflow M.F` " + + "`OnClick: call microflow M.F`, `sign out` `OnClick: sign out`, and `icon I` `Icon: I`"}, + RemovedIn: 2, + Note: "A menu item is a child: its properties are in ( ), and its action is written in the words a page " + + "action uses (R2, R8). A sub-menu's icon moves the same way: `menu 'X' ( Icon: I ) { … }`.", + Example: "create menu M.Main { menu item 'Home' page M.Home icon Atlas_Core.Atlas.home menu item 'Bye' sign out };", + CanonicalExample: "create menu M.Main { menu item 'Home' ( OnClick: show page M.Home, Icon: Atlas_Core.Atlas.home ) menu item 'Bye' ( OnClick: sign out ) };", + }, + { + Code: HeaderMapBraces, + Old: "Params: { $P: M.E } / Variables: { $v: Boolean = 'true' }", + Canonical: "Params: ( $P: M.E ) / Variables: ( $v: Boolean = 'true' )", + Rewrite: Rewrite{Structural: "header map: the braces become ( )"}, + RemovedIn: 2, + Note: "In a page's and a snippet's header. A map is a property list, so it is in ( ); { } holds children (R2).", + Example: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: { $Order: M.Order }) { };", + CanonicalExample: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ( $Order: M.Order )) { };", + }, + { + Code: TemplateParamsBrackets, + Old: "ContentParams: [{1} = expr]", + Canonical: "ContentParams: ({1} = expr)", + Rewrite: Rewrite{Structural: "template parameters: the brackets become ( )"}, + RemovedIn: 2, + Note: "Also CaptionParams and a pluggable widget's `Params`. The parameters are a map, in ( ) (R2), " + + "and each binds a value with `=`, as `with ({1} = …)` does (R4).", + Example: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) " + + "{ dynamictext t (Content: 'Hi {1}', ContentParams: [{1} = 'x']) };", + CanonicalExample: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) " + + "{ dynamictext t (Content: 'Hi {1}', ContentParams: ({1} = 'x')) };", + }, + { + Code: DesignPropertiesBrackets, + Old: "DesignProperties: ['Key': 'Value', 'Group': ['k': on]]", + Canonical: "DesignProperties: ('Key': 'Value', 'Group': ('k': on))", + Rewrite: Rewrite{Structural: "design properties: each bracketed list becomes ( )"}, + RemovedIn: 2, + Note: "The design properties are a map of properties, so they are in ( ) (R2).", + Example: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) " + + "{ container c (DesignProperties: ['Spacing': ['margin-top': 'Large'], 'Full width': on]) };", + CanonicalExample: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) " + + "{ container c (DesignProperties: ('Spacing': ('margin-top': 'Large'), 'Full width': on)) };", + }, + { + Code: SnippetCallParamsBraces, + Old: "snippetcall s (Snippet: M.S, Params: {$Asset: $var})", + Canonical: "snippetcall s (Snippet: M.S, Params: (Asset = $var))", + Rewrite: Rewrite{Structural: "snippet call arguments: `{$P: $v}` becomes `(P = $v)`"}, + RemovedIn: 2, + Note: "A snippet call is a call site, so it binds `Param = value` without a `$` on the parameter name (R4), " + + "in the ( ) of a map (R2).", + Example: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ($Asset: M.Asset)) " + + "{ snippetcall s (Snippet: M.S, Params: {$Asset: $Asset}) };", + CanonicalExample: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ($Asset: M.Asset)) " + + "{ snippetcall s (Snippet: M.S, Params: (Asset = $Asset)) };", + }, + { + Code: DatabaseConnectionClauses, + Old: "database connection M.Db type '…' connection string @M.C … begin query Q sql '…' returns M.E map (c as A); end", + Canonical: "database connection M.Db ( Type: '…', ConnectionString: @M.C, … ) { query Q ( Sql: '…', Returns: M.E, Map: ( A = c ) ) }", + Rewrite: Rewrite{Structural: "clauses become the property list: `type` `Type:`, `connection string` `ConnectionString:`, " + + "`host` `Host:`, `port` `Port:`, `database` `DatabaseName:`, `username` `Username:`, `password` `Password:`; " + + "`begin … end` becomes { }; a query's `sql`, `parameter`, `returns` and `map` become Sql, Parameters, Returns " + + "and Map, and `column as Attr` becomes `Attr = column`"}, + RemovedIn: 2, + Note: "The database connection was the one declarative document written as clauses and begin … end. Its " + + "properties are in ( ) and its queries are children in { } (R2).", + Example: "create database connection M.Db type 'PostgreSQL' connection string @M.Url username @M.User password @M.Pass " + + "begin query Q sql $$select id from t where id > {min}$$ parameter min: Integer default '0' returns M.T map (id as Id); end;", + CanonicalExample: "create database connection M.Db ( Type: 'PostgreSQL', ConnectionString: @M.Url, Username: @M.User, Password: @M.Pass ) " + + "{ query Q ( Sql: $$select id from t where id > {min}$$, Parameters: ( min: Integer default '0' ), Returns: M.T, Map: (Id = id) ) };", + }, +} + // 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. diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 6c619c70a8..f9ab1f434c 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -518,7 +518,12 @@ navigationClause : HOME (PAGE | MICROFLOW) qualifiedName (FOR qualifiedName)? | LOGIN PAGE qualifiedName | NOT FOUND PAGE qualifiedName - | MENU_KW LPAREN navMenuItemDef* RPAREN + // The profile's menu items are its declarative children, in { } like a + // page's widgets and with no separators (R2, ako/mxcli#754). describe + // writes the block after every other clause; the clauses are order-free, + // so it parses anywhere among them. `menu ( item; … )` is the old spelling. + | navMenuChildren + | MENU_KW LPAREN /* @alias MDL-DEPR121 */ navMenuItemDef* RPAREN | SYNC LPAREN navSyncDef* RPAREN // Studio Pro's "Throw error when server rejects objects during // synchronization", stored as the profile-level ThrowPartialSyncError. @@ -574,9 +579,46 @@ navSyncMode // the same Forms$SignOutClientAction a button uses (measured on ako/TestApp), // which is why it sits beside PAGE and MICROFLOW rather than in a syntax of its // own. +// +// R2 (ako/mxcli#754): a menu item is a child with the shape every child has, +// ` Caption ( props ) [ { children } ]`. Its action is `OnClick:` with the +// words a page action uses (R8), and its icon is `Icon:` as on a widget: +// menu item 'Home' ( OnClick: show page Shop.Home, Icon: Atlas_Core.Atlas.home ) +// menu 'Admin' ( Icon: glyph 57345 ) { menu item 'Users' ( OnClick: call microflow M.F ) } +// A child ends in `)` or `}`, so no separator is needed. +// +// The old spelling is still read: the action and icon as clauses after the +// caption (MDL-DEPR122), and a sub-menu's items in ( ) with `;` after each +// item (MDL-DEPR121) — a `;` is read after either shape, so a half-converted +// menu still parses. The canonical alternatives come first, so a bare +// `menu item 'x'` is read as the canonical form. navMenuItemDef - : MENU_KW ITEM STRING_LITERAL ((PAGE qualifiedName) | (MICROFLOW qualifiedName) | SIGN_OUT)? navMenuIcon? SEMICOLON? - | MENU_KW STRING_LITERAL navMenuIcon? LPAREN navMenuItemDef* RPAREN SEMICOLON? + : MENU_KW ITEM STRING_LITERAL navMenuItemProps? (SEMICOLON /* @alias MDL-DEPR121 */)? + | MENU_KW STRING_LITERAL navMenuItemProps? navMenuChildren (SEMICOLON /* @alias MDL-DEPR121 */)? + | MENU_KW ITEM STRING_LITERAL + ((PAGE qualifiedName) | (MICROFLOW qualifiedName) | SIGN_OUT)? /* @alias MDL-DEPR122 */ navMenuIcon? SEMICOLON? + | MENU_KW STRING_LITERAL navMenuIcon? LPAREN /* @alias MDL-DEPR121 */ navMenuItemDef* RPAREN SEMICOLON? + ; + +navMenuChildren + : LBRACE navMenuItemDef* RBRACE + ; + +navMenuItemProps + : LPAREN (navMenuItemProp (COMMA navMenuItemProp)* COMMA?)? RPAREN + ; + +// OnClick takes the three actions a menu item can carry, in the page-action +// words (R8); Icon the three icon elements, as navMenuIcon. +navMenuItemProp + : ONCLICK COLON navMenuAction + | ICON COLON navMenuIconValue + ; + +navMenuAction + : SHOW PAGE qualifiedName + | CALL MICROFLOW qualifiedName + | SIGN_OUT ; // Mendix stores three DIFFERENT icon elements, and they are not variants of one @@ -596,9 +638,13 @@ navMenuItemDef // bare form with `image` read as the name; listing the specific alternatives // ahead of the general one is what settles it. navMenuIcon - : ICON GLYPH NUMBER_LITERAL - | ICON IMAGE qualifiedName - | ICON qualifiedName + : ICON navMenuIconValue + ; + +navMenuIconValue + : GLYPH NUMBER_LITERAL + | IMAGE qualifiedName + | qualifiedName ; // A standalone menu document (Menus$MenuDocument) — the reusable menu a menu @@ -606,7 +652,8 @@ navMenuIcon // built from the same items, so this reuses navMenuItemDef rather than defining a // second item syntax. createMenuStatement - : MENU_KW qualifiedName (FOLDER STRING_LITERAL)? LPAREN navMenuItemDef* RPAREN + : MENU_KW qualifiedName (FOLDER STRING_LITERAL)? navMenuChildren + | MENU_KW qualifiedName (FOLDER STRING_LITERAL)? LPAREN /* @alias MDL-DEPR121 */ navMenuItemDef* RPAREN ; dropStatement diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index 15d7700bd2..a361095ee4 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -243,8 +243,11 @@ pageHeaderV3 ; pageHeaderPropertyV3 - : PARAMS COLON LBRACE pageParameterList RBRACE // Params: { $Order: Entity } - | VARIABLES_KW COLON LBRACE variableDeclarationList RBRACE // Variables: { $show: Boolean = 'true' } + // A map is a property list, so it is in ( ) (R2, ako/mxcli#754). + : PARAMS COLON LPAREN pageParameterList COMMA? RPAREN // Params: ( $Order: Entity ) + | PARAMS COLON LBRACE /* @alias MDL-DEPR123 */ pageParameterList RBRACE // Params: { $Order: Entity } + | VARIABLES_KW COLON LPAREN variableDeclarationList COMMA? RPAREN // Variables: ( $show: Boolean = 'true' ) + | VARIABLES_KW COLON LBRACE /* @alias MDL-DEPR123 */ variableDeclarationList RBRACE | TITLE COLON STRING_LITERAL // Title: 'My Page' | LAYOUT COLON (qualifiedName | STRING_LITERAL) // Layout: Atlas_Core.Atlas_Default | URL COLON STRING_LITERAL // Url: 'my-page' @@ -260,8 +263,10 @@ snippetHeaderV3 ; snippetHeaderPropertyV3 - : PARAMS COLON LBRACE pageParameterList RBRACE // Params: { $Customer: Module.Entity } — entities only (MDL087) - | VARIABLES_KW COLON LBRACE variableDeclarationList RBRACE // Variables: { $show: Boolean = 'true' } + : PARAMS COLON LPAREN pageParameterList COMMA? RPAREN // Params: ( $Customer: Module.Entity ) — entities only (MDL087) + | PARAMS COLON LBRACE /* @alias MDL-DEPR123 */ pageParameterList RBRACE + | VARIABLES_KW COLON LPAREN variableDeclarationList COMMA? RPAREN // Variables: ( $show: Boolean = 'true' ) + | VARIABLES_KW COLON LBRACE /* @alias MDL-DEPR123 */ variableDeclarationList RBRACE | FOLDER COLON /* @alias MDL-DEPR105 */ STRING_LITERAL // Folder: 'Snippets/Common' ; @@ -503,15 +508,15 @@ widgetPropertyV3 | ATTR COLON attributePathV3 // Attr: (deprecated, use Attribute:) | CONTENT COLON stringExprV3 // Content: 'Hello {1}' | RENDERMODE COLON renderModeV3 // RenderMode: H3 - | CONTENTPARAMS COLON paramListV3 // ContentParams: [{1} = $var.Name] - | CAPTIONPARAMS COLON paramListV3 // CaptionParams: [{1} = 'hello'] + | CONTENTPARAMS COLON paramListV3 // ContentParams: ({1} = $var.Name) + | CAPTIONPARAMS COLON paramListV3 // CaptionParams: ({1} = 'hello') // A text-template sub-property of an object-list ITEM carries its // parameters under `Params`, and those names are the widget's own // (a File Uploader custom button's `ButtonCaptionParams`), so they cannot // each have a token. Placed before the generic propertyValueV3 // alternatives, which also admit a `[...]` array — `{N} = expr` inside is // what separates them (#956). - | (IDENTIFIER | keyword) COLON paramListV3 // Params: [{1} = Attr] + | (IDENTIFIER | keyword) COLON paramListV3 // Params: ({1} = Attr) | BUTTONSTYLE COLON buttonStyleV3 // ButtonStyle: Primary | ICON COLON widgetIconV3 // Icon: 'Atlas_Core.Atlas_Filled.pencil' | image Mod.Images.logo | glyph 57377 | CLASS COLON STRING_LITERAL // Class: 'my-class' @@ -521,10 +526,10 @@ widgetPropertyV3 | PHONEWIDTH COLON desktopWidthV3 // PhoneWidth: 12 | AutoFill | SELECTION COLON selectionModeV3 // Selection: Single | Multiple | SNIPPET COLON qualifiedName // Snippet: Module.SnippetName - | PARAMS COLON snippetCallParamListV3 // Params: {$Asset: $var} — snippet call parameter mappings + | PARAMS COLON snippetCallParamListV3 // Params: (Asset = $var) — snippet call arguments | ATTRIBUTES COLON attributeListV3 // Attributes: [Entity.Attr1, Entity.Attr2] | FILTERTYPE COLON filterTypeValue // FilterType: startsWith | contains | equal - | DESIGNPROPERTIES COLON designPropertyListV3 // DesignProperties: [...] + | DESIGNPROPERTIES COLON designPropertyListV3 // DesignProperties: ( 'Key': 'Value', … ) | WIDTH COLON NUMBER_LITERAL // Width: 200 | HEIGHT COLON NUMBER_LITERAL // Height: 100 // R5 (ako/mxcli#753): a conditional Visible / Editable is a client @@ -613,9 +618,17 @@ filterTypeValue | IDENTIFIER // startsWith, endsWith, greater, greaterEqual, equal, notEqual, smaller, smallerEqual, notEmpty ; -// Snippet call parameter mappings: {$Asset: $var, $Other: $other} +// Snippet call arguments: (Asset = $var, Other = $other). A snippet call is a +// call site, so it binds its arguments the way every call does, `Param = value` +// (R4), in the ( ) of a property map (R2, ako/mxcli#754). The old spelling was +// a brace map `{$Asset: $var}`. snippetCallParamListV3 - : LBRACE snippetCallParamMappingV3 (COMMA snippetCallParamMappingV3)* RBRACE + : LPAREN snippetCallArgV3 (COMMA snippetCallArgV3)* COMMA? RPAREN + | LBRACE /* @alias MDL-DEPR126 */ snippetCallParamMappingV3 (COMMA snippetCallParamMappingV3)* RBRACE + ; + +snippetCallArgV3 + : parameterName EQUALS VARIABLE ; snippetCallParamMappingV3 @@ -730,9 +743,12 @@ stringExprV3 | VARIABLE (DOT (IDENTIFIER | keyword))? ; -// V3 Parameter list: [{1} = value, {2} = value] +// V3 Parameter list: ({1} = value, {2} = value). The parameters of a text +// template are a map, so they are in ( ) (R2, ako/mxcli#754), and each binds a +// runtime value with `=` (R4, `with ({1} = …)`). `[…]` is the old spelling. paramListV3 - : LBRACKET paramAssignmentV3 (COMMA paramAssignmentV3)* RBRACKET + : LPAREN paramAssignmentV3 (COMMA paramAssignmentV3)* COMMA? RPAREN + | LBRACKET /* @alias MDL-DEPR124 */ paramAssignmentV3 (COMMA paramAssignmentV3)* RBRACKET ; paramAssignmentV3 @@ -822,17 +838,19 @@ objectEntryFieldV3 : identifierOrKeyword COLON propertyValueV3 ; -// V3 Design property list: ['Key': 'Value', 'Key': ON] +// V3 Design property list: ('Key': 'Value', 'Key': on). A map of properties, so +// it is in ( ) (R2, ako/mxcli#754); `['Key': 'Value']` is the old spelling. designPropertyListV3 - : LBRACKET designPropertyEntryV3 (COMMA designPropertyEntryV3)* RBRACKET - | LBRACKET RBRACKET + : LPAREN (designPropertyEntryV3 (COMMA designPropertyEntryV3)* COMMA?)? RPAREN + | LBRACKET /* @alias MDL-DEPR125 */ designPropertyEntryV3 (COMMA designPropertyEntryV3)* RBRACKET + | LBRACKET /* @alias MDL-DEPR125 */ RBRACKET ; designPropertyEntryV3 : STRING_LITERAL COLON STRING_LITERAL | STRING_LITERAL COLON ON | STRING_LITERAL COLON OFF - | STRING_LITERAL COLON designPropertyListV3 // compound: 'Spacing': ['margin-top': 'Large', ...] + | STRING_LITERAL COLON designPropertyListV3 // compound: 'Spacing': ('margin-top': 'Large', ...) ; // V3 Widget body: { children } diff --git a/mdl/grammar/domains/MDLService.g4 b/mdl/grammar/domains/MDLService.g4 index 2595aa5586..444eb51656 100644 --- a/mdl/grammar/domains/MDLService.g4 +++ b/mdl/grammar/domains/MDLService.g4 @@ -10,13 +10,60 @@ options { tokenVocab = MDLLexer; } // DATABASE / REST CLIENT // ============================================================================= +// R2 (ako/mxcli#754): a declarative document has its properties in ( ) and +// its children in { }. The database connection was the one declarative +// document written as clauses with a begin … end block of queries: +// +// create database connection M.Db ( +// Type: 'PostgreSQL', ConnectionString: @M.Url, Username: @M.User, Password: @M.Pass, +// ) { +// query GetCustomers ( +// Sql: $$select id, name from customer where id > {minId}$$, +// Parameters: ( minId: Integer default '0' ), +// Returns: M.Customer, +// Map: ( CustomerId = id, Name = name ), +// ) +// }; +// +// A query's column map binds an attribute to a column, `Attr = column`, the way +// a REST mapping binds `Attr = jsonField` (R3: `=` for a mapping side). The +// clause form is the old spelling (MDL-DEPR127), and builds the same statement. createDatabaseConnectionStatement : DATABASE CONNECTION qualifiedName (FOLDER STRING_LITERAL)? - databaseConnectionOption+ + LPAREN databaseConnectionProp (COMMA databaseConnectionProp)* COMMA? RPAREN + (LBRACE databaseQueryDef* RBRACE)? + | DATABASE CONNECTION qualifiedName + (FOLDER STRING_LITERAL)? + databaseConnectionOption+ /* @alias MDL-DEPR127 */ (BEGIN databaseQuery* END)? ; +databaseConnectionProp + : identifierOrKeyword COLON (STRING_LITERAL | NUMBER_LITERAL | AT qualifiedName) + ; + +databaseQueryDef + : QUERY identifierOrKeyword LPAREN databaseQueryProp (COMMA databaseQueryProp)* COMMA? RPAREN + ; + +databaseQueryProp + : identifierOrKeyword COLON (STRING_LITERAL | DOLLAR_STRING) // Sql: $$…$$ + | identifierOrKeyword COLON LPAREN + databaseQueryParam (COMMA databaseQueryParam)* COMMA? RPAREN // Parameters: ( p: Integer default '0' ) + | identifierOrKeyword COLON LPAREN + databaseQueryColumn (COMMA databaseQueryColumn)* COMMA? RPAREN // Map: ( Attr = column ) + | identifierOrKeyword COLON qualifiedName // Returns: M.Entity + ; + +databaseQueryParam + : identifierOrKeyword COLON dataType (DEFAULT STRING_LITERAL | NULL)? + ; + +databaseQueryColumn + : identifierOrKeyword EQUALS identifierOrKeyword + ; + databaseConnectionOption : TYPE STRING_LITERAL | CONNECTION STRING_TYPE (STRING_LITERAL | AT qualifiedName) @@ -94,8 +141,10 @@ restClientParamItem : VARIABLE COLON dataType ; +// A header list is a map, so it is `( 'Name': value, … )` like every other +// property map (R2/R3, ako/mxcli#754). `'Name' = value` is the old spelling. restClientHeaderItem - : STRING_LITERAL EQUALS (STRING_LITERAL | VARIABLE | STRING_LITERAL PLUS VARIABLE) + : STRING_LITERAL (COLON | EQUALS /* @alias MDL-DEPR120 */) (STRING_LITERAL | VARIABLE | STRING_LITERAL PLUS VARIABLE) ; restClientMappingEntry diff --git a/mdl/upgrade/metadata_placement_test.go b/mdl/upgrade/metadata_placement_test.go index 92e6a7b5e9..b40c4d4937 100644 --- a/mdl/upgrade/metadata_placement_test.go +++ b/mdl/upgrade/metadata_placement_test.go @@ -63,8 +63,8 @@ func TestUpgrade_MetadataPlacement(t *testing.T) { "create page M.P folder 'Admin' (Title: 'P', Layout: Atlas_Core.Atlas_Default) { };\n", deprecation.FolderProperty}, {"snippet Folder property", - "create snippet M.S (Params: { $C: M.E }, Folder: 'Common') { };\n", - "create snippet M.S folder 'Common' (Params: { $C: M.E }) { };\n", + "create snippet M.S (Params: ( $C: M.E ), Folder: 'Common') { };\n", + "create snippet M.S folder 'Common' (Params: ( $C: M.E )) { };\n", deprecation.FolderProperty}, {"snippet with Folder as its only property", "create snippet M.S(\n folder: 'Snippets'\n)\n{ };\n", diff --git a/mdl/upgrade/r2_children_test.go b/mdl/upgrade/r2_children_test.go index 8b3cb4ffb7..3451ef0270 100644 --- a/mdl/upgrade/r2_children_test.go +++ b/mdl/upgrade/r2_children_test.go @@ -51,7 +51,7 @@ ALTER NANOFLOW M.N { operation GetUser ( Method: get, Path: '/u/{id}', - Headers: ('Accept' = 'application/json'), + Headers: ('Accept': 'application/json'), Response: mapping M.User { Name = name } ) operation Ping ( Method: get, Path: '/ping', Response: none ) diff --git a/mdl/upgrade/r2_rest_test.go b/mdl/upgrade/r2_rest_test.go new file mode 100644 index 0000000000..06daffedda --- /dev/null +++ b/mdl/upgrade/r2_rest_test.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// The rest of R2 (ako/mxcli#754): menus, property maps and the database +// connection upgrade together — several old spellings in one statement, in +// either letter case, with comments and layout left as written. +func TestUpgrade_R2NavigationMapsAndDatabaseConnection(t *testing.T) { + src := `create or replace navigation Responsive + home page M.Home + menu ( + menu item 'Home' page M.Home icon Atlas_Core.Atlas.home; + -- administration + menu 'Admin' icon glyph 57345 ( + menu item 'Users' microflow M.ShowUsers; + MENU ITEM 'Out' SIGN_OUT; + ); + ); +CREATE MENU M.Side (MENU ITEM 'Plain';); +create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: { $Asset: M.Asset }, Variables: { $n: Integer = '1' }) { + dynamictext t (Content: 'Hi {1}', ContentParams: [{1} = Name]) + container c (DesignProperties: ['Spacing': ['margin-top': 'Large'], 'Full width': on]) + snippetcall s (Snippet: M.S, Params: {$Asset: $Asset}) +}; +create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) { + operation Ping ( Method: get, Path: '/p', Headers: ('Accept' = 'application/json'), Response: none ) +}; +create database connection M.Db + type 'PostgreSQL' + connection string @M.Url + username @M.User + password @M.Pass +begin + query Customers + sql $$select id, name from customer where id > {min}$$ + parameter min: Integer default '0' + parameter name: String null + returns M.Customer + map ( + id as CustomerId, + name as Name + ); +end; +` + want := `create or modify navigation Responsive + home page M.Home + { + menu item 'Home' ( OnClick: show page M.Home, Icon: Atlas_Core.Atlas.home ) + -- administration + menu 'Admin' ( Icon: glyph 57345 ) { + menu item 'Users' ( OnClick: call microflow M.ShowUsers ) + MENU ITEM 'Out' ( OnClick: SIGN OUT ) + } + }; +CREATE MENU M.Side {MENU ITEM 'Plain'}; +create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ( $Asset: M.Asset ), Variables: ( $n: Integer = '1' )) { + dynamictext t (Content: 'Hi {1}', ContentParams: ({1} = Name)) + container c (DesignProperties: ('Spacing': ('margin-top': 'Large'), 'Full width': on)) + snippetcall s (Snippet: M.S, Params: (Asset = $Asset)) +}; +create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) { + operation Ping ( Method: get, Path: '/p', Headers: ('Accept': 'application/json'), Response: none ) +}; +create database connection M.Db ( + Type: 'PostgreSQL', + ConnectionString: @M.Url, + Username: @M.User, + Password: @M.Pass ) +{ + query Customers ( + Sql: $$select id, name from customer where id > {min}$$, + Parameters: ( min: Integer default '0', + name: String null ), + Returns: M.Customer, + Map: ( + CustomerId = id, + Name = name + ) ) +}; +` + 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.MenuChildrenParens: 2, deprecation.MenuItemClauses: 1, + deprecation.HeaderMapBraces: 1, deprecation.TemplateParamsBrackets: 1, + deprecation.DesignPropertiesBrackets: 1, deprecation.SnippetCallParamsBraces: 1, + deprecation.RestHeaderEquals: 1, deprecation.DatabaseConnectionClauses: 1, + } { + 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/upgrade/r8_spellings_test.go b/mdl/upgrade/r8_spellings_test.go index 6490d5419d..a6019fa537 100644 --- a/mdl/upgrade/r8_spellings_test.go +++ b/mdl/upgrade/r8_spellings_test.go @@ -28,7 +28,7 @@ func TestUpgrade_R8Spellings(t *testing.T) { {page("sign_out"), page("sign out")}, {page("complete_task 'Approve'"), page("complete task 'Approve'")}, {"create navigation Responsive home page M.Home menu (menu item 'Out' sign_out;);\n", - "create navigation Responsive home page M.Home menu (menu item 'Out' sign out;);\n"}, + "create navigation Responsive home page M.Home {menu item 'Out' ( OnClick: sign out )};\n"}, {"create entity M.E (\n Name: String(100) not null error 'Required',\n Code: String(9) UNIQUE ERROR 'Taken'\n);\n", "create entity M.E (\n Name: String(100) not null error message 'Required',\n Code: String(9) UNIQUE ERROR MESSAGE 'Taken'\n);\n"}, {"create validation rule for M.E.Email regex M.Pattern\n feedback 'Bad';\n", diff --git a/mdl/visitor/r2_children_test.go b/mdl/visitor/r2_children_test.go index 821b02f24f..65a06ef981 100644 --- a/mdl/visitor/r2_children_test.go +++ b/mdl/visitor/r2_children_test.go @@ -28,11 +28,11 @@ 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 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 GetUser ( Method: get, Path: '/u/{id}', Parameters: ($id: Integer), Headers: ('Accept': 'application/json'), Response: none, ) operation Ping ( Method: get, Path: '/ping', Response: none ) };`, }, diff --git a/mdl/visitor/r2_rest_test.go b/mdl/visitor/r2_rest_test.go new file mode 100644 index 0000000000..ab2fcdeb3e --- /dev/null +++ b/mdl/visitor/r2_rest_test.go @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "sort" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// The rest of R2 (ako/mxcli#754): navigation and menus, property maps, and +// the database connection. As for the integration documents +// (r2_children_test.go), each canonical form builds without a warning under +// both versions, and each old form builds the SAME statement, records its code +// once per statement, and carries a rewrite that reaches the canonical form. +// Every old form below uses exactly one old spelling, so the one code's +// rewrite is the whole upgrade; mixtures are covered by the upgrade tests. +var r2RestCases = []r2Case{ + { + name: "rest header", + code: deprecation.RestHeaderEquals, + old: `create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) { + operation GetUser ( Method: get, Path: '/u', Headers: ('Accept' = 'application/json', 'X-Key'='k', 'Auth' = 'Bearer ' + $Token), Response: none ) + operation Ping ( Method: get, Path: '/ping', Headers: ('Accept' = '*/*'), Response: none ) +};`, + canonical: `create consumed rest service M.Api (BaseUrl: 'https://x', Authentication: none) { + operation GetUser ( Method: get, Path: '/u', Headers: ('Accept': 'application/json', 'X-Key':'k', 'Auth': 'Bearer ' + $Token), Response: none ) + operation Ping ( Method: get, Path: '/ping', Headers: ('Accept': '*/*'), Response: none ) +};`, + }, + { + name: "navigation menu block", + code: deprecation.MenuChildrenParens, + old: `create or modify navigation Responsive + home page M.Home + menu ( + menu item 'Home' ( OnClick: show page M.Home ); + menu 'Admin' ( + menu item 'Users' ( OnClick: call microflow M.ShowUsers ); + menu item 'Plain'; + ); + ) + login page M.Login;`, + canonical: `create or modify navigation Responsive + home page M.Home + { + menu item 'Home' ( OnClick: show page M.Home ) + menu 'Admin' { + menu item 'Users' ( OnClick: call microflow M.ShowUsers ) + menu item 'Plain' + } + } + login page M.Login;`, + }, + { + name: "menu document", + code: deprecation.MenuChildrenParens, + old: `create or modify menu M.Main folder 'Menus' ( + menu item 'Home' ( OnClick: show page M.Home, Icon: Atlas_Core.Atlas.home ) + menu 'More' (menu item 'Out' ( OnClick: sign out )); +);`, + canonical: `create or modify menu M.Main folder 'Menus' { + menu item 'Home' ( OnClick: show page M.Home, Icon: Atlas_Core.Atlas.home ) + menu 'More' {menu item 'Out' ( OnClick: sign out )} +};`, + }, + { + name: "menu item clauses", + code: deprecation.MenuItemClauses, + old: `create menu M.Main { + menu item 'Home' page M.Home icon Atlas_Core.Atlas.home + menu item 'Run' microflow M.Run + menu item 'Pic' page M.Pic icon image M.Images.pic + menu item 'Glyph' icon glyph 57345 + MENU ITEM 'Bye' SIGN OUT + menu 'Admin' { menu item 'Users' page M.Users } +};`, + canonical: `create menu M.Main { + menu item 'Home' ( OnClick: show page M.Home, Icon: Atlas_Core.Atlas.home ) + menu item 'Run' ( OnClick: call microflow M.Run ) + menu item 'Pic' ( OnClick: show page M.Pic, Icon: image M.Images.pic ) + menu item 'Glyph' ( Icon: glyph 57345 ) + MENU ITEM 'Bye' ( OnClick: SIGN OUT ) + menu 'Admin' { menu item 'Users' ( OnClick: show page M.Users ) } +};`, + }, + { + name: "page header maps", + code: deprecation.HeaderMapBraces, + old: `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, + Params: { $Order: M.Order, $Qty: Integer }, + Variables: { $show: Boolean = 'true' }) { };`, + canonical: `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, + Params: ( $Order: M.Order, $Qty: Integer ), + Variables: ( $show: Boolean = 'true', )) { };`, + }, + { + name: "snippet header maps", + code: deprecation.HeaderMapBraces, + old: `create snippet M.S (Params: { $Customer: M.Customer }, Variables: {$x: Integer = '1'}) { };`, + canonical: `create snippet M.S (Params: ( $Customer: M.Customer ), Variables: ($x: Integer = '1')) { };`, + }, + { + name: "template parameters", + code: deprecation.TemplateParamsBrackets, + old: `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + dynamictext t (Content: '{1} of {2}', ContentParams: [{1} = 'a', {2} = Name format (decimalPrecision: 2)]) + actionbutton b (Caption: 'Go {1}', CaptionParams: [{1} = Title], Action: save changes) + image i (ImageType: imageUrl, ImageUrl: '{1}', ImageUrlParams: [{1} = PictureUrl]) +};`, + canonical: `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + dynamictext t (Content: '{1} of {2}', ContentParams: ({1} = 'a', {2} = Name format (decimalPrecision: 2))) + actionbutton b (Caption: 'Go {1}', CaptionParams: ({1} = Title,), Action: save changes) + image i (ImageType: imageUrl, ImageUrl: '{1}', ImageUrlParams: ({1} = PictureUrl)) +};`, + }, + { + name: "design properties", + code: deprecation.DesignPropertiesBrackets, + old: `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + container c (DesignProperties: ['Spacing': ['margin-top': 'Large', 'margin-bottom': 'Medium'], 'Full width': on]) { + container d (DesignProperties: []) + } +};`, + canonical: `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + container c (DesignProperties: ('Spacing': ('margin-top': 'Large', 'margin-bottom': 'Medium'), 'Full width': on,)) { + container d (DesignProperties: ()) + } +};`, + }, + { + name: "snippet call arguments", + code: deprecation.SnippetCallParamsBraces, + old: `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ($Asset: M.Asset)) { + snippetcall s (Snippet: M.S, Params: {$Asset: $Asset, Agent:$Asset}) +};`, + canonical: `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ($Asset: M.Asset)) { + snippetcall s (Snippet: M.S, Params: (Asset = $Asset, Agent = $Asset)) +};`, + }, + { + name: "database connection", + code: deprecation.DatabaseConnectionClauses, + old: `create or modify database connection M.Erp folder 'Db' + type 'PostgreSQL' + connection string @M.DbUrl + host 'db.local' + port 5432 + database 'erp' + username @M.DbUser + password 'secret' +begin + query GetCustomers + sql $$select id, name from customer where id > {minId} and name = {name}$$ + parameter minId: Integer default '0' + parameter name: String null + returns M.Customer + map ( + id as CustomerId, + name as Name + ); + query Ping sql 'select 1'; +end;`, + canonical: `create or modify database connection M.Erp folder 'Db' ( + Type: 'PostgreSQL', + ConnectionString: @M.DbUrl, + Host: 'db.local', + Port: 5432, + DatabaseName: 'erp', + Username: @M.DbUser, + Password: 'secret', +) { + query GetCustomers ( + Sql: $$select id, name from customer where id > {minId} and name = {name}$$, + Parameters: ( minId: Integer default '0', name: String null ), + Returns: M.Customer, + Map: ( CustomerId = id, Name = name ), + ) + query Ping ( Sql: 'select 1' ) +};`, + }, +} + +func TestR2Rest_CanonicalFormBuildsWithoutWarning(t *testing.T) { + for _, c := range r2RestCases { + 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 TestR2Rest_OldFormIsADeprecatedAlias(t *testing.T) { + for _, c := range r2RestCases { + 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 { + t.Errorf("%s: also recorded %s; each case exercises one spelling", c.name, d.Code) + } + if d.Fix == nil || len(d.Fix.Edits) == 0 { + t.Errorf("%s: %s carries no rewrite", c.name, d.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 TestR2Rest_RewriteReachesTheCanonicalForm(t *testing.T) { + for _, c := range r2RestCases { + prog := buildNoErrors(t, c.old) + out := applyAllFixes(prog, c.old) + 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) + } + } +} + +// applyAllFixes applies every recorded rewrite the way mdl/upgrade does: two +// edits at one offset apply in the order they were recorded, an insertion +// before a replacement. +func applyAllFixes(prog *ast.Program, src string) string { + var edits []ast.TextEdit + for _, d := range prog.Deprecations { + if d.Fix != nil { + edits = append(edits, d.Fix.Edits...) + } + } + sort.SliceStable(edits, func(i, j int) bool { + if edits[i].Start != edits[j].Start { + return edits[i].Start < edits[j].Start + } + return edits[i].Stop-edits[i].Start < edits[j].Stop-edits[j].Start + }) + runes := []rune(src) + var b strings.Builder + at := 0 + for _, e := range edits { + b.WriteString(string(runes[at:e.Start])) + b.WriteString(e.Text) + at = e.Stop + } + b.WriteString(string(runes[at:])) + return b.String() +} + +// The rewrites are exact: each old form upgrades to the canonical text the +// case spells out, except where the case's canonical form deliberately adds a +// trailing comma (which the rewrite has no reason to write). +func TestR2Rest_RewriteText(t *testing.T) { + for src, want := range map[string]string{ + `create menu M.M (menu item 'A' page M.A icon Atlas_Core.Atlas.home; menu 'B' icon glyph 1 (menu item 'C' sign_out;););`: `create menu M.M {menu item 'A' ( OnClick: show page M.A, Icon: Atlas_Core.Atlas.home ) menu 'B' ( Icon: glyph 1 ) {menu item 'C' ( OnClick: sign out )}};`, + `create or modify navigation Responsive menu (menu item 'A' microflow M.F;) home page M.H;`: `create or modify navigation Responsive {menu item 'A' ( OnClick: call microflow M.F )} home page M.H;`, + `create page M.P (Title: 'P', Layout: L.L) { snippetcall s (Snippet: M.S, Params: {$A: $A}) };`: `create page M.P (Title: 'P', Layout: L.L) { snippetcall s (Snippet: M.S, Params: (A = $A)) };`, + `create database connection M.Db type 'x' connection string @M.C;`: `create database connection M.Db ( Type: 'x', ConnectionString: @M.C );`, + "create database connection M.Db type 'x' connection string @M.C begin query Q sql 'select 1' returns M.E; end;": "create database connection M.Db ( Type: 'x', ConnectionString: @M.C ) { query Q ( Sql: 'select 1', Returns: M.E ) };", + } { + prog := buildNoErrors(t, src) + if got := applyAllFixes(prog, src); got != want { + t.Errorf("rewrite of\n %s\ngot\n %s\nwant\n %s", src, got, want) + } + } +} + +// The new property lists are new syntax, so a key they do not know is an +// error rather than something warned about and dropped. +func TestR2Rest_DatabaseConnectionRejectsUnknownKeys(t *testing.T) { + for src, want := range map[string]string{ + `create database connection M.Db (Type: 'x', Server: 'h');`: "unknown property 'Server'", + `create database connection M.Db (Type: 'x', Port: 'many');`: "Port takes a number", + `create database connection M.Db (Type: 'x') { query Q ( Sql: 'x', Limit: 'y' ) };`: "unknown property 'Limit'", + `create database connection M.Db (Type: 'x') { query Q ( Sql: 'x', Map: ( a: String ) ) };`: "Map takes a column map", + `create database connection M.Db (Type: 'x') { query Q ( Sql: 'x', Parameters: ( a = b ) ) };`: "Parameters takes a parameter list", + } { + _, 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) + } + } +} + +// A menu item's OnClick takes only the three actions an item can carry. +func TestR2Rest_MenuItemActionIsRestricted(t *testing.T) { + if _, errs := Build(`create menu M.M { menu item 'x' ( OnClick: save changes ) };`); len(errs) == 0 { + t.Error("a menu item accepted `save changes`, which a menu item cannot carry") + } +} diff --git a/mdl/visitor/r8_spellings_test.go b/mdl/visitor/r8_spellings_test.go index 8d49f37bd9..a4a53a7017 100644 --- a/mdl/visitor/r8_spellings_test.go +++ b/mdl/visitor/r8_spellings_test.go @@ -36,8 +36,8 @@ var r8Pairs = []r8Pair{ {"open link attr", pageWith("open_link $currentObject/Url"), pageWith("open link $currentObject/Url"), deprecation.PageActionWord}, {"sign out", pageWith("sign_out"), pageWith("sign out"), deprecation.PageActionWord}, {"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}, + {"menu sign out", "create navigation Responsive home page M.Home { menu item 'Out' ( OnClick: sign_out ) };", + "create navigation Responsive home page M.Home { menu item 'Out' ( OnClick: 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}, diff --git a/mdl/visitor/visitor_dbconnection.go b/mdl/visitor/visitor_dbconnection.go index 8de82bc511..0ec2b55826 100644 --- a/mdl/visitor/visitor_dbconnection.go +++ b/mdl/visitor/visitor_dbconnection.go @@ -3,6 +3,7 @@ package visitor import ( + "fmt" "strconv" "strings" @@ -20,7 +21,10 @@ func (b *Builder) ExitCreateDatabaseConnectionStatement(ctx *parser.CreateDataba stmt.Folder = unquoteStringLit(lit) } - // Parse options + // Canonical form: ( Key: value, … ) { query Q ( … ) } (R2) + b.applyDatabaseConnectionProps(stmt, ctx) + + // Old form: clauses and a begin … end block (MDL-DEPR127) for _, optCtx := range ctx.AllDatabaseConnectionOption() { opt := optCtx.(*parser.DatabaseConnectionOptionContext) @@ -203,3 +207,131 @@ func unquoteDollarString(s string) string { } return s } + +// applyDatabaseConnectionProps reads the canonical database connection: its +// properties in ( ) and its queries as { query Q ( … ) } children (R2, +// ako/mxcli#754). The keys are those of the old clauses; they are new syntax, +// so an unknown key or a value of the wrong kind is an error rather than +// something to warn about and drop. +func (b *Builder) applyDatabaseConnectionProps(stmt *ast.CreateDatabaseConnectionStmt, ctx *parser.CreateDatabaseConnectionStatementContext) { + for _, pc := range ctx.AllDatabaseConnectionProp() { + p := pc.(*parser.DatabaseConnectionPropContext) + key := identifierOrKeywordText(p.IdentifierOrKeyword()) + line := p.GetStart().GetLine() + str, ref, num := p.STRING_LITERAL(), p.QualifiedName(), p.NUMBER_LITERAL() + refOrString := func(val *string, isRef *bool) { + switch { + case ref != nil: + *val, *isRef = buildQualifiedName(ref).String(), true + case str != nil: + *val = unquoteStringLit(str) + default: + b.addError(fmt.Errorf("line %d: database connection %s: %s takes a string or a constant (@Module.Constant)", line, stmt.Name, key)) + } + } + onlyString := func(val *string) { + if str == nil { + b.addError(fmt.Errorf("line %d: database connection %s: %s takes a string", line, stmt.Name, key)) + return + } + *val = unquoteStringLit(str) + } + switch strings.ToLower(key) { + case "type": + onlyString(&stmt.DatabaseType) + case "connectionstring": + refOrString(&stmt.ConnectionString, &stmt.ConnectionStringIsRef) + case "username": + refOrString(&stmt.UserName, &stmt.UserNameIsRef) + case "password": + refOrString(&stmt.Password, &stmt.PasswordIsRef) + case "host": + onlyString(&stmt.Host) + case "databasename": + onlyString(&stmt.Database) + case "port": + if num == nil { + b.addError(fmt.Errorf("line %d: database connection %s: Port takes a number", line, stmt.Name)) + continue + } + stmt.Port, _ = strconv.Atoi(num.GetText()) + default: + b.addError(fmt.Errorf("line %d: unknown property '%s' on database connection %s: the properties are "+ + "Type, ConnectionString, Host, Port, DatabaseName, Username and Password", line, key, stmt.Name)) + } + } + + for _, qc := range ctx.AllDatabaseQueryDef() { + stmt.Queries = append(stmt.Queries, b.buildDatabaseQueryDef(stmt.Name, qc.(*parser.DatabaseQueryDefContext))) + } +} + +// buildDatabaseQueryDef reads `query Q ( Sql: …, Parameters: ( … ), Returns: +// M.E, Map: ( Attr = column ) )`. +func (b *Builder) buildDatabaseQueryDef(conn ast.QualifiedName, qc *parser.DatabaseQueryDefContext) ast.DatabaseQueryDef { + q := ast.DatabaseQueryDef{Name: identifierOrKeywordText(qc.IdentifierOrKeyword())} + for _, pc := range qc.AllDatabaseQueryProp() { + p := pc.(*parser.DatabaseQueryPropContext) + key := identifierOrKeywordText(p.IdentifierOrKeyword()) + line := p.GetStart().GetLine() + wrong := func(want string) { + b.addError(fmt.Errorf("line %d: query %s on database connection %s: %s takes %s", line, q.Name, conn, key, want)) + } + switch strings.ToLower(key) { + case "sql": + switch { + case p.DOLLAR_STRING() != nil: + q.SQL = unquoteDollarString(p.DOLLAR_STRING().GetText()) + case p.STRING_LITERAL() != nil: + q.SQL = unquoteStringLit(p.STRING_LITERAL()) + default: + wrong("the SQL, as $$…$$ or a string") + } + case "returns": + if p.QualifiedName() == nil { + wrong("an entity") + continue + } + q.Returns = buildQualifiedName(p.QualifiedName()) + case "parameters": + if len(p.AllDatabaseQueryParam()) == 0 { + wrong("a parameter list, ( name: Type [default '…' | null], … )") + continue + } + for _, dc := range p.AllDatabaseQueryParam() { + d := dc.(*parser.DatabaseQueryParamContext) + param := ast.DatabaseQueryParamDef{Name: identifierOrKeywordText(d.IdentifierOrKeyword())} + if dt := d.DataType(); dt != nil { + param.DataType = buildDataType(dt) + } + switch { + case d.DEFAULT() != nil && d.STRING_LITERAL() != nil: + param.DefaultValue = unquoteStringLit(d.STRING_LITERAL()) + case d.NULL() != nil: + param.TestWithNull = true + } + q.Parameters = append(q.Parameters, param) + } + case "map": + if len(p.AllDatabaseQueryColumn()) == 0 { + wrong("a column map, ( Attribute = column, … )") + continue + } + for _, mc := range p.AllDatabaseQueryColumn() { + m := mc.(*parser.DatabaseQueryColumnContext) + ioks := m.AllIdentifierOrKeyword() + if len(ioks) < 2 { + continue + } + q.Mappings = append(q.Mappings, ast.DatabaseQueryMappingDef{ + ColumnName: identifierOrKeywordText(ioks[1]), + AttributeName: identifierOrKeywordText(ioks[0]), + }) + } + default: + b.addError(fmt.Errorf("line %d: unknown property '%s' on query %s of database connection %s: "+ + "a query takes Sql, Parameters, Returns and Map", line, key, q.Name, conn)) + } + } + return q +} diff --git a/mdl/visitor/visitor_deprecations_test.go b/mdl/visitor/visitor_deprecations_test.go index 7d11fba96b..ad3d79f92f 100644 --- a/mdl/visitor/visitor_deprecations_test.go +++ b/mdl/visitor/visitor_deprecations_test.go @@ -80,7 +80,7 @@ var createOrReplaceCases = map[string]string{ "snippet": "snippet M.CustomerInfo { dynamictext t (Content: 'x') }", "enumeration": "enumeration M.Color (Red 'Red');", "validationrule": "validation rule for M.Customer.Email regex M.EmailPattern error message 'Invalid';", - "databaseconnection": "database connection M.Erp type 'PostgreSQL' connection string @M.DbUrl username @M.DbUser password @M.DbPass;", + "databaseconnection": "database connection M.Erp (Type: 'PostgreSQL', ConnectionString: @M.DbUrl, Username: @M.DbUser, Password: @M.DbPass);", "constant": "constant M.ApiBaseUrl type String default 'https://api.example.com';", "restclient": "consumed rest service M.PetStore (BaseUrl: 'https://petstore.example.com', Authentication: NONE) { };", "index": "index idx_name on M.Customer (Name);", @@ -111,7 +111,7 @@ var createOrReplaceCases = map[string]string{ "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;", - "menu": "menu M.Main_Menu (menu item 'Plain';);", + "menu": "menu M.Main_Menu { menu item 'Plain' };", "translations": "translations in Administration for nl_NL ('Save' as 'Opslaan');", } diff --git a/mdl/visitor/visitor_menu.go b/mdl/visitor/visitor_menu.go index dcb14830f5..64f86f3b27 100644 --- a/mdl/visitor/visitor_menu.go +++ b/mdl/visitor/visitor_menu.go @@ -7,7 +7,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/grammar/parser" ) -// ExitCreateMenuStatement handles CREATE [OR MODIFY] MENU Module.Name ( items ). +// ExitCreateMenuStatement handles CREATE [OR MODIFY] MENU Module.Name { items } (old spelling: ( items )). // // The items reuse navMenuItemDef, the same rule CREATE NAVIGATION's MENU block // uses, so buildNavMenuItemDef is reused verbatim — a menu item is written the @@ -26,7 +26,11 @@ func (b *Builder) ExitCreateMenuStatement(ctx *parser.CreateMenuStatementContext if lit := ctx.STRING_LITERAL(); lit != nil { stmt.Folder = unquoteStringLit(lit) } - for _, itemCtx := range ctx.AllNavMenuItemDef() { + items := ctx.AllNavMenuItemDef() + if ch, ok := ctx.NavMenuChildren().(*parser.NavMenuChildrenContext); ok && ch != nil { + items = ch.AllNavMenuItemDef() + } + for _, itemCtx := range items { stmt.Items = append(stmt.Items, buildNavMenuItemDef(itemCtx)) } diff --git a/mdl/visitor/visitor_navigation.go b/mdl/visitor/visitor_navigation.go index b9686cd6f4..68767c49a4 100644 --- a/mdl/visitor/visitor_navigation.go +++ b/mdl/visitor/visitor_navigation.go @@ -77,8 +77,15 @@ func (b *Builder) processNavigationClause(stmt *ast.AlterNavigationStmt, ctx *pa qn := buildQualifiedName(names[0]) stmt.NotFoundPage = &qn } + } else if ch, ok := ctx.NavMenuChildren().(*parser.NavMenuChildrenContext); ok && ch != nil { + // { navMenuItemDef* } — the profile's menu items as its children (R2) + stmt.HasMenuBlock = true + for _, itemCtx := range ch.AllNavMenuItemDef() { + item := buildNavMenuItemDef(itemCtx) + stmt.MenuItems = append(stmt.MenuItems, item) + } } else if ctx.MENU_KW() != nil { - // MENU (navMenuItemDef*) + // MENU (navMenuItemDef*) — the old spelling (MDL-DEPR121) stmt.HasMenuBlock = true for _, itemCtx := range ctx.AllNavMenuItemDef() { item := buildNavMenuItemDef(itemCtx) @@ -155,6 +162,11 @@ func buildNavSyncDef(ctx parser.INavSyncDefContext) ast.NavSyncDef { } // buildNavMenuItemDef recursively builds a NavMenuItemDef from the parse context. +// +// An item is read the same whichever spelling wrote it: the canonical +// `menu item 'X' ( OnClick: show page M.P, Icon: … )` with sub-items in { }, +// or the old clauses `menu item 'X' page M.P icon …;` with sub-items in ( ) +// (R2, ako/mxcli#754). The two build the same item. func buildNavMenuItemDef(ctx parser.INavMenuItemDefContext) ast.NavMenuItemDef { c := ctx.(*parser.NavMenuItemDefContext) @@ -165,10 +177,8 @@ func buildNavMenuItemDef(ctx parser.INavMenuItemDefContext) ast.NavMenuItemDef { item := ast.NavMenuItemDef{Caption: caption} - // The PAGE/MICROFLOW target is the item's only qualifiedName now that the - // icon is its own sub-rule — which is what removed the old positional - // bookkeeping, where the target and the icon shared one indexed list and the - // icon was "whatever remains". + // Old spelling: the PAGE/MICROFLOW target is the item's only qualifiedName + // now that the icon is its own sub-rule. if qn := c.QualifiedName(); qn != nil { switch { case c.PAGE() != nil: @@ -184,17 +194,54 @@ func buildNavMenuItemDef(ctx parser.INavMenuItemDefContext) ast.NavMenuItemDef { if c.SIGN_OUT() != nil { item.SignOut = true } - applyNavMenuIcon(&item, c.NavMenuIcon()) + if ic, ok := c.NavMenuIcon().(*parser.NavMenuIconContext); ok && ic != nil { + applyNavMenuIcon(&item, ic.NavMenuIconValue()) + } + + // Canonical spelling: OnClick and Icon in the item's property list. + if pc, ok := c.NavMenuItemProps().(*parser.NavMenuItemPropsContext); ok && pc != nil { + for _, p := range pc.AllNavMenuItemProp() { + prop := p.(*parser.NavMenuItemPropContext) + switch { + case prop.ONCLICK() != nil: + applyNavMenuAction(&item, prop.NavMenuAction()) + case prop.ICON() != nil: + applyNavMenuIcon(&item, prop.NavMenuIconValue()) + } + } + } - // Recurse into sub-items (for MENU 'caption' (...)) - for _, subCtx := range c.AllNavMenuItemDef() { - subItem := buildNavMenuItemDef(subCtx) - item.Items = append(item.Items, subItem) + // Sub-items: in { } (canonical) or directly in ( ) (old spelling). + subs := c.AllNavMenuItemDef() + if ch, ok := c.NavMenuChildren().(*parser.NavMenuChildrenContext); ok && ch != nil { + subs = ch.AllNavMenuItemDef() + } + for _, subCtx := range subs { + item.Items = append(item.Items, buildNavMenuItemDef(subCtx)) } return item } +// applyNavMenuAction reads a menu item's `OnClick:` action: `show page M.P`, +// `call microflow M.F` or `sign out`. +func applyNavMenuAction(item *ast.NavMenuItemDef, ctx parser.INavMenuActionContext) { + a, ok := ctx.(*parser.NavMenuActionContext) + if !ok || a == nil { + return + } + switch { + case a.SIGN_OUT() != nil: + item.SignOut = true + case a.PAGE() != nil && a.QualifiedName() != nil: + built := buildQualifiedName(a.QualifiedName()) + item.Page = &built + case a.MICROFLOW() != nil && a.QualifiedName() != nil: + built := buildQualifiedName(a.QualifiedName()) + item.Microflow = &built + } +} + // applyNavMenuIcon reads the ICON clause onto the item. // // Mendix stores three different icon ELEMENTS, not three spellings of one @@ -206,12 +253,9 @@ func buildNavMenuItemDef(ctx parser.INavMenuItemDefContext) ast.NavMenuItemDef { // // The bare form is the collection icon, which keeps every existing script // meaning exactly what it did. -func applyNavMenuIcon(item *ast.NavMenuItemDef, ctx parser.INavMenuIconContext) { - if ctx == nil { - return - } - c, ok := ctx.(*parser.NavMenuIconContext) - if !ok { +func applyNavMenuIcon(item *ast.NavMenuItemDef, ctx parser.INavMenuIconValueContext) { + c, ok := ctx.(*parser.NavMenuIconValueContext) + if !ok || c == nil { return } switch { diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index ca0edc5854..22bb61f3cb 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -97,12 +97,12 @@ func (b *Builder) parsePageHeaderV3(ctx parser.IPageHeaderV3Context, stmt *ast.C prop := propCtx.(*parser.PageHeaderPropertyV3Context) if prop.PARAMS() != nil { - // Params: { $Order: Entity, ... } + // Params: ( $Order: Entity, ... ) if paramList := prop.PageParameterList(); paramList != nil { stmt.Parameters = buildPageParameters(paramList) } } else if prop.VARIABLES_KW() != nil { - // Variables: { $showStock: Boolean = 'true', ... } + // Variables: ( $showStock: Boolean = 'true', ... ) if varList := prop.VariableDeclarationList(); varList != nil { stmt.Variables = buildVariableDeclarations(varList) } @@ -269,12 +269,12 @@ func (b *Builder) parseSnippetHeaderV3(ctx parser.ISnippetHeaderV3Context, stmt prop := propCtx.(*parser.SnippetHeaderPropertyV3Context) if prop.PARAMS() != nil { - // Params: { $Customer: Entity, ... } + // Params: ( $Customer: Entity, ... ) if paramList := prop.PageParameterList(); paramList != nil { stmt.Parameters = buildPageParameters(paramList) } } else if prop.VARIABLES_KW() != nil { - // Variables: { $showStock: Boolean = 'true', ... } + // Variables: ( $showStock: Boolean = 'true', ... ) if varList := prop.VariableDeclarationList(); varList != nil { stmt.Variables = buildVariableDeclarations(varList) } @@ -716,7 +716,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg return } - // ContentParams: [...] + // ContentParams: (...) if propCtx.CONTENTPARAMS() != nil { if plCtx := propCtx.ParamListV3(); plCtx != nil { widget.Properties["ContentParams"] = buildParamListV3(plCtx) @@ -724,7 +724,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg return } - // CaptionParams: [...] + // CaptionParams: (...) if propCtx.CAPTIONPARAMS() != nil { if plCtx := propCtx.ParamListV3(); plCtx != nil { widget.Properties["CaptionParams"] = buildParamListV3(plCtx) @@ -806,7 +806,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg return } - // Params: {$Asset: $var} — snippet call parameter mappings + // Params: (Asset = $var) — snippet call arguments if propCtx.PARAMS() != nil { if plCtx := propCtx.SnippetCallParamListV3(); plCtx != nil { widget.Properties["Params"] = buildSnippetCallParamListV3(plCtx) @@ -850,7 +850,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg return } - // DesignProperties: [...] + // DesignProperties: (...) if propCtx.DESIGNPROPERTIES() != nil { if dpCtx := propCtx.DesignPropertyListV3(); dpCtx != nil { widget.Properties["DesignProperties"] = buildDesignPropertyListV3(dpCtx) @@ -915,7 +915,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg } return } - // `Params: [{1} = Attr]` — the parameters of a text-template + // `Params: ({1} = Attr)` — the parameters of a text-template // sub-property whose name belongs to the WIDGET rather than to MDL (a // File Uploader custom button's ButtonCaptionParams). ContentParams and // CaptionParams have their own tokens and are handled above; every other @@ -1918,8 +1918,21 @@ func xpathPathToString(path *ast.XPathPathExpr) string { // buildSnippetCallParamListV3 converts a parsed snippetCallParamListV3 context // into a slice of SnippetCallParam AST nodes. +// +// The canonical form binds `Param = $var` (R4) in ( ); the old brace map +// `{$Param: $var}` / `{Param: $var}` builds the same params. The parameter name +// is stored without its `$`, which the builder strips either way. func buildSnippetCallParamListV3(ctx parser.ISnippetCallParamListV3Context) []ast.SnippetCallParam { var params []ast.SnippetCallParam + for _, argCtx := range ctx.AllSnippetCallArgV3() { + param := ast.SnippetCallParam{ParamName: parameterNameText(argCtx.ParameterName())} + if v := argCtx.VARIABLE(); v != nil { + param.Variable = v.GetText() + } + if param.ParamName != "" && param.Variable != "" { + params = append(params, param) + } + } for _, mappingCtx := range ctx.AllSnippetCallParamMappingV3() { param := ast.SnippetCallParam{} if iok := mappingCtx.IdentifierOrKeyword(); iok != nil { @@ -1932,7 +1945,7 @@ func buildSnippetCallParamListV3(ctx parser.ISnippetCallParamListV3Context) []as // Param name written with $: $Asset: $someVar vars := mappingCtx.AllVARIABLE() if len(vars) >= 2 { - param.ParamName = vars[0].GetText() + param.ParamName = strings.TrimPrefix(vars[0].GetText(), "$") param.Variable = vars[1].GetText() } } diff --git a/mdl/visitor/visitor_r2_children.go b/mdl/visitor/visitor_r2_children.go index 64faca7369..a1cdfcfa17 100644 --- a/mdl/visitor/visitor_r2_children.go +++ b/mdl/visitor/visitor_r2_children.go @@ -77,6 +77,7 @@ func (b *Builder) EnterStatement(ctx *parser.StatementContext) { use(deprecation.MessageTreeParens, x.LPAREN().GetSymbol()).swap(x.LPAREN(), x.RPAREN(), "{", "}") } } + r2RestUse(n, use) for _, c := range n.GetChildren() { walk(c) } diff --git a/mdl/visitor/visitor_r2_rest.go b/mdl/visitor/visitor_r2_rest.go new file mode 100644 index 0000000000..8b5f2b6c2b --- /dev/null +++ b/mdl/visitor/visitor_r2_rest.go @@ -0,0 +1,318 @@ +// 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" +) + +// The rest of R2 (ako/mxcli#754), after the integration documents: the +// navigation and menu documents, the property maps, and the database +// connection. +// +// - a REST header list binds `'Name' = value` where a map has `'Name': value` +// (MDL-DEPR120); +// - menu items are in ( ) with `;` after each, where children are in { } +// (MDL-DEPR121), and an item's action and icon are clauses after its +// caption rather than `( OnClick: …, Icon: … )` (MDL-DEPR122); +// - the page and snippet header's `Params:` / `Variables:` maps are in +// braces (MDL-DEPR123), a text template's parameters in brackets +// (MDL-DEPR124), `DesignProperties:` in brackets (MDL-DEPR125), and a +// snippet call's arguments are a brace map `{$P: $v}` (MDL-DEPR126); +// - the database connection is clauses and a begin … end block of queries +// (MDL-DEPR127). +// +// r2RestUse is called for every node of the statement by EnterStatement's +// walk, which records each code once per statement. +func r2RestUse(n antlr.Tree, use func(code string, at antlr.Token) *r2Use) { + switch x := n.(type) { + case *parser.RestClientHeaderItemContext: + if eq := x.EQUALS(); eq != nil && len(x.AllSTRING_LITERAL()) > 0 { + name := x.STRING_LITERAL(0).GetSymbol() + u := use(deprecation.RestHeaderEquals, eq.GetSymbol()) + u.edits = append(u.edits, replaceGap(name.GetStop(), eq.GetSymbol().GetStop()+1, ":")) + } + case *parser.NavigationClauseContext: + if x.MENU_KW() != nil && x.LPAREN() != nil && x.RPAREN() != nil { + u := use(deprecation.MenuChildrenParens, x.LPAREN().GetSymbol()) + u.edits = append(u.edits, + replaceSpan(x.MENU_KW().GetSymbol(), x.LPAREN().GetSymbol(), "{"), + replaceSpan(x.RPAREN().GetSymbol(), x.RPAREN().GetSymbol(), "}")) + } + case *parser.CreateMenuStatementContext: + if x.LPAREN() != nil && x.RPAREN() != nil { + use(deprecation.MenuChildrenParens, x.LPAREN().GetSymbol()).swap(x.LPAREN(), x.RPAREN(), "{", "}") + } + case *parser.NavMenuItemDefContext: + if x.LPAREN() != nil && x.RPAREN() != nil { + use(deprecation.MenuChildrenParens, x.LPAREN().GetSymbol()).swap(x.LPAREN(), x.RPAREN(), "{", "}") + } + if semi := x.SEMICOLON(); semi != nil { + u := use(deprecation.MenuChildrenParens, semi.GetSymbol()) + u.edits = append(u.edits, replaceSpan(semi.GetSymbol(), semi.GetSymbol(), "")) + } + if first := menuItemClauseStart(x); first != nil { + use(deprecation.MenuItemClauses, first).menuItemClauses(x) + } + case *parser.PageHeaderPropertyV3Context: + if x.LBRACE() != nil && x.RBRACE() != nil { + use(deprecation.HeaderMapBraces, x.LBRACE().GetSymbol()).swap(x.LBRACE(), x.RBRACE(), "(", ")") + } + case *parser.SnippetHeaderPropertyV3Context: + if x.LBRACE() != nil && x.RBRACE() != nil { + use(deprecation.HeaderMapBraces, x.LBRACE().GetSymbol()).swap(x.LBRACE(), x.RBRACE(), "(", ")") + } + case *parser.ParamListV3Context: + if x.LBRACKET() != nil && x.RBRACKET() != nil { + use(deprecation.TemplateParamsBrackets, x.LBRACKET().GetSymbol()).swap(x.LBRACKET(), x.RBRACKET(), "(", ")") + } + case *parser.DesignPropertyListV3Context: + if x.LBRACKET() != nil && x.RBRACKET() != nil { + use(deprecation.DesignPropertiesBrackets, x.LBRACKET().GetSymbol()).swap(x.LBRACKET(), x.RBRACKET(), "(", ")") + } + case *parser.SnippetCallParamListV3Context: + if x.LBRACE() != nil && x.RBRACE() != nil { + use(deprecation.SnippetCallParamsBraces, x.LBRACE().GetSymbol()).snippetCallArgs(x) + } + case *parser.CreateDatabaseConnectionStatementContext: + if opts := x.AllDatabaseConnectionOption(); len(opts) > 0 { + use(deprecation.DatabaseConnectionClauses, opts[0].GetStart()).databaseConnection(x) + } + } +} + +// menuItemClauseStart is the first token of a menu item's action or icon +// clause — `page`, `microflow`, `sign out` or `icon` — or nil when it has none. +func menuItemClauseStart(x *parser.NavMenuItemDefContext) antlr.Token { + for _, t := range []antlr.TerminalNode{x.PAGE(), x.MICROFLOW(), x.SIGN_OUT()} { + if t != nil { + return t.GetSymbol() + } + } + if ic := x.NavMenuIcon(); ic != nil { + return ic.GetStart() + } + return nil +} + +// menuItemClauses rewrites `menu item 'X' page M.P icon I` as +// `menu item 'X' ( OnClick: show page M.P, Icon: I )`, and a sub-menu's +// `menu 'X' icon I (` as `menu 'X' ( Icon: I ) (` (the parentheses around +// its items are MDL-DEPR121's). Only the clause words are replaced, so the +// target and the icon stay as written. +func (u *r2Use) menuItemClauses(x *parser.NavMenuItemDefContext) { + like := x.MENU_KW().GetText() + var last antlr.Token + switch { + case x.PAGE() != nil && x.QualifiedName() != nil: + t := x.PAGE().GetSymbol() + u.edits = append(u.edits, replaceSpan(t, t, "( OnClick: "+keywordLike(like, "show page"))) + last = x.QualifiedName().GetStop() + case x.MICROFLOW() != nil && x.QualifiedName() != nil: + t := x.MICROFLOW().GetSymbol() + u.edits = append(u.edits, replaceSpan(t, t, "( OnClick: "+keywordLike(like, "call microflow"))) + last = x.QualifiedName().GetStop() + case x.SIGN_OUT() != nil: + // Inserted before the word rather than replacing it: `sign_out` has + // MDL-DEPR020's own rewrite of that token. + t := x.SIGN_OUT().GetSymbol() + u.edits = append(u.edits, insertAt(t.GetStart(), "( OnClick: ")) + last = t + } + if ic, ok := x.NavMenuIcon().(*parser.NavMenuIconContext); ok && ic != nil { + t := ic.ICON().GetSymbol() + if last != nil { + u.edits = append(u.edits, insertAt(last.GetStop()+1, ","), replaceSpan(t, t, "Icon:")) + } else { + u.edits = append(u.edits, replaceSpan(t, t, "( Icon:")) + } + last = ic.GetStop() + } + if last != nil { + u.edits = append(u.edits, insertAt(last.GetStop()+1, " )")) + } +} + +// snippetCallArgs rewrites a snippet call's `{$Asset: $var, Other: $o}` as +// `(Asset = $var, Other = $o)`: a call binds its arguments `Param = value`, +// without a `$` on the parameter's name (R4). +func (u *r2Use) snippetCallArgs(x *parser.SnippetCallParamListV3Context) { + u.swap(x.LBRACE(), x.RBRACE(), "(", ")") + for _, mc := range x.AllSnippetCallParamMappingV3() { + m, ok := mc.(*parser.SnippetCallParamMappingV3Context) + if !ok || m.COLON() == nil { + continue + } + var name antlr.Token + if iok := m.IdentifierOrKeyword(); iok != nil { + name = iok.GetStop() + } else if vars := m.AllVARIABLE(); len(vars) >= 2 { + name = vars[0].GetSymbol() + u.edits = append(u.edits, replaceSpan(name, name, strings.TrimPrefix(name.GetText(), "$"))) + } + if name == nil { + continue + } + u.edits = append(u.edits, replaceGap(name.GetStop(), m.COLON().GetSymbol().GetStop()+1, " =")) + } +} + +// databaseConnection rewrites the clause form of a database connection, +// +// type 'PostgreSQL' connection string @M.Url username @M.U password @M.P +// begin +// query Q sql $$…$$ parameter p: Integer default '1' returns M.E map (c as A); +// end +// +// as its property list and query children: +// +// ( Type: 'PostgreSQL', ConnectionString: @M.Url, Username: @M.U, Password: @M.P ) +// { +// query Q ( Sql: $$…$$, Parameters: ( p: Integer default '1' ), Returns: M.E, Map: ( A = c ) ) +// } +// +// Only the clause words are replaced and punctuation inserted: every value — +// the strings, the SQL, the constants — stays where it is, as written. +func (u *r2Use) databaseConnection(x *parser.CreateDatabaseConnectionStatementContext) { + opts := x.AllDatabaseConnectionOption() + for i, oc := range opts { + o := oc.(*parser.DatabaseConnectionOptionContext) + key, keyEnd := databaseConnectionKey(o) + if key == "" { + continue + } + if i == 0 { + // The list opens after the name (or folder), before the clauses' + // line break: `M.Db (` rather than `M.Db\n ( Type:`. + if prev := childTokenBefore(x, o); prev != nil { + u.edits = append(u.edits, insertAt(prev.GetStop()+1, " (")) + } + } + u.edits = append(u.edits, replaceSpan(o.GetStart(), keyEnd, key+":")) + closing := "," + if i == len(opts)-1 { + closing = " )" + } + u.edits = append(u.edits, insertAt(o.GetStop().GetStop()+1, closing)) + } + if x.BEGIN() != nil && x.END() != nil { + u.swap(x.BEGIN(), x.END(), "{", "}") + } + for _, qc := range x.AllDatabaseQuery() { + u.databaseQuery(qc.(*parser.DatabaseQueryContext)) + } +} + +// databaseConnectionKey is the property key of an old connection clause, and +// the last token of the clause's words (`connection string` is two). +func databaseConnectionKey(o *parser.DatabaseConnectionOptionContext) (string, antlr.Token) { + switch { + case o.TYPE() != nil: + return "Type", o.TYPE().GetSymbol() + case o.CONNECTION() != nil && o.STRING_TYPE() != nil: + return "ConnectionString", o.STRING_TYPE().GetSymbol() + case o.HOST() != nil: + return "Host", o.HOST().GetSymbol() + case o.PORT() != nil: + return "Port", o.PORT().GetSymbol() + case o.DATABASE() != nil: + return "DatabaseName", o.DATABASE().GetSymbol() + case o.USERNAME() != nil: + return "Username", o.USERNAME().GetSymbol() + case o.PASSWORD() != nil: + return "Password", o.PASSWORD().GetSymbol() + } + return "", nil +} + +// databaseQuery rewrites one `query Q sql … parameter … returns M.E map (…);` +// as `query Q ( Sql: …, Parameters: ( … ), Returns: M.E, Map: ( … ) )`. +func (u *r2Use) databaseQuery(q *parser.DatabaseQueryContext) { + children := q.GetChildren() + // last is the last token before child i; next the first token of child i. + last := func(i int) antlr.Token { + for j := i - 1; j >= 0; j-- { + switch n := children[j].(type) { + case antlr.TerminalNode: + return n.GetSymbol() + case antlr.ParserRuleContext: + return n.GetStop() + } + } + return nil + } + inParams := false + for i, c := range children { + tn, ok := c.(antlr.TerminalNode) + if !ok { + continue + } + tok := tn.GetSymbol() + switch tok.GetTokenType() { + case parser.MDLParserSQL: + u.edits = append(u.edits, insertAt(last(i).GetStop()+1, " ("), replaceSpan(tok, tok, "Sql:")) + case parser.MDLParserPARAMETER: + prev := last(i) + if !inParams { + u.edits = append(u.edits, insertAt(prev.GetStop()+1, ","), replaceSpan(tok, tok, "Parameters: (")) + inParams = true + continue + } + // A later parameter: a comma after the one before, and the word + // dropped with the space after it. + stop := tok.GetStop() + 1 + if i+1 < len(children) { + if pr, ok := children[i+1].(antlr.ParserRuleContext); ok && pr.GetStart() != nil { + stop = pr.GetStart().GetStart() + } + } + u.edits = append(u.edits, insertAt(prev.GetStop()+1, ","), + ast.TextEdit{Start: tok.GetStart(), Stop: stop}) + case parser.MDLParserRETURNS, parser.MDLParserSEMICOLON: + prev := last(i) + if inParams { + u.edits = append(u.edits, insertAt(prev.GetStop()+1, " )")) + inParams = false + } + if tok.GetTokenType() == parser.MDLParserRETURNS { + u.edits = append(u.edits, insertAt(prev.GetStop()+1, ","), replaceSpan(tok, tok, "Returns:")) + } else { + u.edits = append(u.edits, replaceSpan(tok, tok, padWord(tok, ")"))) + } + case parser.MDLParserMAP: + u.edits = append(u.edits, insertAt(last(i).GetStop()+1, ","), replaceSpan(tok, tok, "Map:")) + } + } + for _, mc := range q.AllDatabaseQueryMapping() { + mm := mc.(*parser.DatabaseQueryMappingContext) + ioks := mm.AllIdentifierOrKeyword() + if len(ioks) < 2 { + continue + } + // `column as Attr` binds Attr = column, the way a mapping side does. + u.edits = append(u.edits, replaceSpan(mm.GetStart(), mm.GetStop(), + nodeText(ioks[1])+" = "+nodeText(ioks[0]))) + } +} + +// childTokenBefore is the last token of the child of parent that precedes child. +func childTokenBefore(parent antlr.ParserRuleContext, child antlr.Tree) antlr.Token { + var prev antlr.Token + for _, c := range parent.GetChildren() { + if c == child { + return prev + } + switch n := c.(type) { + case antlr.TerminalNode: + prev = n.GetSymbol() + case antlr.ParserRuleContext: + prev = n.GetStop() + } + } + return nil +} diff --git a/mdl/visitor/visitor_r8_spellings.go b/mdl/visitor/visitor_r8_spellings.go index bd7e1a0feb..63ce095ceb 100644 --- a/mdl/visitor/visitor_r8_spellings.go +++ b/mdl/visitor/visitor_r8_spellings.go @@ -79,6 +79,11 @@ func (b *Builder) ExitNavMenuItemDef(ctx *parser.NavMenuItemDefContext) { b.recordSnakeWord(ctx.SIGN_OUT()) } +// ExitNavMenuAction reports `OnClick: sign_out` on a menu item. +func (b *Builder) ExitNavMenuAction(ctx *parser.NavMenuActionContext) { + b.recordSnakeWord(ctx.SIGN_OUT()) +} + // errorMessageWord is the canonical spelling of the message keyword, in the // letter case of the spelling it replaces. func errorMessageWord(like string) string { return keywordLike(like, "error message") } diff --git a/mdl/visitor/visitor_rest.go b/mdl/visitor/visitor_rest.go index 1805339fcc..91a63d452c 100644 --- a/mdl/visitor/visitor_rest.go +++ b/mdl/visitor/visitor_rest.go @@ -256,7 +256,7 @@ func parseRestClientOpProp(ctx *parser.RestClientOpPropContext, op *ast.RestOper return } - // Header list: ('Name' = 'Value', ...) + // Header list: ('Name': 'Value', ...) — or the old ('Name' = 'Value') headerItems := ctx.AllRestClientHeaderItem() if len(headerItems) > 0 { for _, hi := range headerItems { diff --git a/mdl/visitor/visitor_snippetcall_params_test.go b/mdl/visitor/visitor_snippetcall_params_test.go index 51010cc6a8..e5ce06488f 100644 --- a/mdl/visitor/visitor_snippetcall_params_test.go +++ b/mdl/visitor/visitor_snippetcall_params_test.go @@ -31,8 +31,10 @@ func TestSnippetCallParams_DollarPrefix(t *testing.T) { if len(params) != 1 { t.Fatalf("GetSnippetParams: want 1, got %d", len(params)) } - if params[0].ParamName != "$Asset" { - t.Errorf("ParamName: want $Asset, got %q", params[0].ParamName) + // The name is stored without its `$` (the builder strips it either way), + // so the old brace map builds what the canonical (Asset = $theAsset) does. + if params[0].ParamName != "Asset" { + t.Errorf("ParamName: want Asset, got %q", params[0].ParamName) } if params[0].Variable != "$theAsset" { t.Errorf("Variable: want $theAsset, got %q", params[0].Variable) From 243d8a5d1c0b43a353693c15264f89c07b2b46db Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 16:03:21 +0000 Subject: [PATCH 03/17] describe: print menus, property maps and database connections in R2's brackets (#754) describe navigation/menu, describe page/snippet (Params, Variables, ContentParams, CaptionParams, Params, DesignProperties), describe consumed rest service (Headers) and describe database connection emit the canonical forms, so their output re-parses without recording any deprecated spelling. Hints in validator messages use the same forms. Co-Authored-By: Claude Opus 5.5 --- .../button_caption_params_632_test.go | 2 +- mdl/executor/cmd_dbconnection.go | 99 +++++----- mdl/executor/cmd_menus.go | 6 +- mdl/executor/cmd_menus_mock_test.go | 14 +- mdl/executor/cmd_misc.go | 12 +- mdl/executor/cmd_navigation.go | 60 +++--- .../cmd_navigation_icon_roundtrip_test.go | 6 +- mdl/executor/cmd_navigation_icon_test.go | 28 +-- mdl/executor/cmd_pages_builder.go | 2 +- mdl/executor/cmd_pages_builder_v3_widgets.go | 6 +- mdl/executor/cmd_pages_describe.go | 8 +- .../cmd_pages_describe_designprops_test.go | 2 +- mdl/executor/cmd_pages_describe_output.go | 20 +- mdl/executor/cmd_pages_describe_pluggable.go | 4 +- mdl/executor/cmd_rest_clients.go | 4 +- mdl/executor/cmd_styling.go | 4 +- mdl/executor/design_property_routing.go | 2 +- mdl/executor/design_property_routing_test.go | 2 +- mdl/executor/menu_signout_test.go | 6 +- mdl/executor/r2_rest_describe_test.go | 172 ++++++++++++++++++ mdl/executor/validate.go | 2 +- mdl/executor/validate_alter_styling.go | 4 +- mdl/executor/validate_widget_member_refs.go | 2 +- mdl/executor/validate_widgets.go | 6 +- .../validate_workflow_task_signature.go | 2 +- ...dget_texttemplate_named_params_575_test.go | 4 +- 26 files changed, 327 insertions(+), 152 deletions(-) create mode 100644 mdl/executor/r2_rest_describe_test.go diff --git a/mdl/executor/button_caption_params_632_test.go b/mdl/executor/button_caption_params_632_test.go index adb836c0f6..797e4a099e 100644 --- a/mdl/executor/button_caption_params_632_test.go +++ b/mdl/executor/button_caption_params_632_test.go @@ -108,7 +108,7 @@ func TestDescribeButtonEmitsCaptionParams(t *testing.T) { Action: "save_changes", }, 0) out := buf.String() - if !strings.Contains(out, "CaptionParams: [{1} = Title]") { + if !strings.Contains(out, "CaptionParams: ({1} = Title)") { t.Errorf("button parameters not described as CaptionParams:\n%s", out) } if strings.Contains(out, "ContentParams") { diff --git a/mdl/executor/cmd_dbconnection.go b/mdl/executor/cmd_dbconnection.go index afcef20f00..b9441a95d9 100644 --- a/mdl/executor/cmd_dbconnection.go +++ b/mdl/executor/cmd_dbconnection.go @@ -237,72 +237,59 @@ func describeDatabaseConnection(ctx *ExecContext, name ast.QualifiedName) error // outputDatabaseConnectionMDL outputs a database connection definition in MDL format. func outputDatabaseConnectionMDL(ctx *ExecContext, conn *model.DatabaseConnection, moduleName string) error { - fmt.Fprintf(ctx.Output, "create or modify database connection %s.%s%s\n", moduleName, conn.Name, describeFolderClause(ctx, conn.ContainerID)) - fmt.Fprintf(ctx.Output, "type '%s'\n", conn.DatabaseType) - - // Connection string - fmt.Fprintf(ctx.Output, "connection string @%s\n", conn.ConnectionString) - - // Username - fmt.Fprintf(ctx.Output, "username @%s\n", conn.UserName) - - // Password - fmt.Fprintf(ctx.Output, "password @%s\n", conn.Password) - - // Queries - if len(conn.Queries) > 0 { - fmt.Fprintln(ctx.Output, "begin") - for _, q := range conn.Queries { - fmt.Fprintf(ctx.Output, " query %s\n", q.Name) - - // SQL string - if q.SQL != "" { - escaped := strings.ReplaceAll(q.SQL, "'", "''") - fmt.Fprintf(ctx.Output, " sql '%s'\n", escaped) - } - - // PARAMETER clauses + // R2 (ako/mxcli#754): the connection's properties in ( ), its queries as + // children in { }, each query's properties in its own ( ). + w := ctx.Output + fmt.Fprintf(w, "create or modify database connection %s.%s%s (\n", moduleName, conn.Name, describeFolderClause(ctx, conn.ContainerID)) + fmt.Fprintf(w, " Type: %s,\n", mdlQuoted(conn.DatabaseType)) + fmt.Fprintf(w, " ConnectionString: @%s,\n", conn.ConnectionString) + fmt.Fprintf(w, " Username: @%s,\n", conn.UserName) + fmt.Fprintf(w, " Password: @%s\n", conn.Password) + + if len(conn.Queries) == 0 { + fmt.Fprintln(w, ");") + return nil + } + fmt.Fprintln(w, ") {") + for _, q := range conn.Queries { + var props []string + if q.SQL != "" { + props = append(props, "Sql: "+mdlQuoted(q.SQL)) + } + if len(q.Parameters) > 0 { + var params []string for _, p := range q.Parameters { - typeName := dbTypeToMDLType(p.DataType) + param := fmt.Sprintf("%s: %s", p.ParameterName, dbTypeToMDLType(p.DataType)) if p.EmptyValueBecomesNull { - fmt.Fprintf(ctx.Output, " parameter %s: %s null\n", p.ParameterName, typeName) + param += " null" } else if p.DefaultValue != "" { - escaped := strings.ReplaceAll(p.DefaultValue, "'", "''") - fmt.Fprintf(ctx.Output, " parameter %s: %s default '%s'\n", p.ParameterName, typeName, escaped) - } else { - fmt.Fprintf(ctx.Output, " parameter %s: %s\n", p.ParameterName, typeName) + param += " default " + mdlQuoted(p.DefaultValue) } + params = append(params, param) } - - // RETURNS and MAP from table mapping - if len(q.TableMappings) > 0 { - tm := q.TableMappings[0] - fmt.Fprintf(ctx.Output, " returns %s\n", tm.Entity) - - // MAP clause - if len(tm.Columns) > 0 { - fmt.Fprintln(ctx.Output, " map (") - for i, c := range tm.Columns { - // Extract attribute name from qualified ref (Module.Entity.Attr → Attr) - attrName := c.Attribute - if parts := strings.Split(attrName, "."); len(parts) >= 3 { - attrName = parts[len(parts)-1] - } - sep := "," - if i == len(tm.Columns)-1 { - sep = "" - } - fmt.Fprintf(ctx.Output, " %s as %s%s\n", c.ColumnName, attrName, sep) + props = append(props, "Parameters: ( "+strings.Join(params, ", ")+" )") + } + if len(q.TableMappings) > 0 { + tm := q.TableMappings[0] + props = append(props, "Returns: "+tm.Entity) + if len(tm.Columns) > 0 { + var cols []string + for _, c := range tm.Columns { + // Extract attribute name from qualified ref (Module.Entity.Attr → Attr) + attrName := c.Attribute + if parts := strings.Split(attrName, "."); len(parts) >= 3 { + attrName = parts[len(parts)-1] } - fmt.Fprintln(ctx.Output, " )") + // A column map binds the attribute to the column, as a + // mapping side does: Attr = column. + cols = append(cols, fmt.Sprintf("%s = %s", attrName, c.ColumnName)) } + props = append(props, "Map: (\n "+strings.Join(cols, ",\n ")+"\n )") } - fmt.Fprintln(ctx.Output, " ;") } - fmt.Fprintln(ctx.Output, "end") + fmt.Fprintf(w, " query %s (\n %s\n )\n", q.Name, strings.Join(props, ",\n ")) } - - fmt.Fprintln(ctx.Output, ";") + fmt.Fprintln(w, "};") return nil } diff --git a/mdl/executor/cmd_menus.go b/mdl/executor/cmd_menus.go index d985ab06cf..00fe00053b 100644 --- a/mdl/executor/cmd_menus.go +++ b/mdl/executor/cmd_menus.go @@ -43,13 +43,13 @@ func describeMenu(ctx *ExecContext, name ast.QualifiedName) error { // Output is re-executable: the item syntax is the same one CREATE MENU // accepts, so describe → exec → describe is a fixed point. - fmt.Fprintf(ctx.Output, "create or modify menu %s.%s%s (\n", name.Module, md.Name, describeFolderClause(ctx, md.ContainerID)) + fmt.Fprintf(ctx.Output, "create or modify menu %s.%s%s {\n", name.Module, md.Name, describeFolderClause(ctx, md.ContainerID)) printMenuMDL(ctx.Output, md.Items, 1, "CREATE MENU") - fmt.Fprintln(ctx.Output, ");") + fmt.Fprintln(ctx.Output, "};") return nil } -// execCreateMenu handles CREATE [OR MODIFY] MENU Module.Name ( items ). +// execCreateMenu handles CREATE [OR MODIFY] MENU Module.Name { items }. // // Like CREATE NAVIGATION, the item list is the document's complete contents, so // a modify replaces the items wholesale rather than merging. The existing diff --git a/mdl/executor/cmd_menus_mock_test.go b/mdl/executor/cmd_menus_mock_test.go index 95dd5c0e5f..500e9d57ee 100644 --- a/mdl/executor/cmd_menus_mock_test.go +++ b/mdl/executor/cmd_menus_mock_test.go @@ -62,13 +62,13 @@ func TestDescribeMenu_Nested(t *testing.T) { out := buf.String() // The output is re-executable, so it opens with the statement that recreates it. - assertContainsStr(t, out, "create or modify menu Atlas_Core.Main_Menu (") - assertContainsStr(t, out, "menu item 'Home' page MyModule.Home_Web icon Atlas_Core.Atlas.home;") + assertContainsStr(t, out, "create or modify menu Atlas_Core.Main_Menu {") + assertContainsStr(t, out, "menu item 'Home' ( OnClick: show page MyModule.Home_Web, Icon: Atlas_Core.Atlas.home )") // A sub-menu opens a nested block and its children are indented one level in. - assertContainsStr(t, out, "menu 'Admin' (") - assertContainsStr(t, out, " menu item 'Accounts' page Administration.Account_Overview;") - assertContainsStr(t, out, " menu item 'Rebuild' microflow Administration.Rebuild;") + assertContainsStr(t, out, "menu 'Admin' {") + assertContainsStr(t, out, " menu item 'Accounts' ( OnClick: show page Administration.Account_Overview )") + assertContainsStr(t, out, " menu item 'Rebuild' ( OnClick: call microflow Administration.Rebuild )") // The glyph icon is reported rather than dropped, and points at the statement // that would have to reproduce it — CREATE MENU, not CREATE NAVIGATION. @@ -83,8 +83,8 @@ func TestDescribeMenu_Empty(t *testing.T) { ctx, buf := newMockCtx(t, withBackend(menuBackend(md))) assertNoError(t, describeMenu(ctx, ast.QualifiedName{Module: "Atlas_Core", Name: "Empty_Menu"})) // An empty menu still describes to a statement that recreates it. - assertContainsStr(t, buf.String(), "create or modify menu Atlas_Core.Empty_Menu (") - assertContainsStr(t, buf.String(), ");") + assertContainsStr(t, buf.String(), "create or modify menu Atlas_Core.Empty_Menu {") + assertContainsStr(t, buf.String(), "};") } func TestDescribeMenu_NotFound(t *testing.T) { diff --git a/mdl/executor/cmd_misc.go b/mdl/executor/cmd_misc.go index a67ea623be..0440c42767 100644 --- a/mdl/executor/cmd_misc.go +++ b/mdl/executor/cmd_misc.go @@ -276,12 +276,12 @@ Navigation: [home page Module.Page for UserRole] [login page Module.Page] [not found page Module.Page] - [menu ( - menu item 'Caption' page Module.Page; - menu 'SubMenu' ( - menu item 'Child' microflow Module.Flow; - ); - )]; + [{ + menu item 'Caption' ( OnClick: show page Module.Page ) + menu 'SubMenu' { + menu item 'Child' ( OnClick: call microflow Module.Flow ) + } + }]; Data Types: String[(length)] Integer Long Decimal[(p,s)] diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index 632c6f353b..6c2b1e7827 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -344,13 +344,6 @@ func outputNavigationProfile(ctx *ExecContext, p *types.NavigationProfile) { fmt.Fprintf(ctx.Output, " not found page %s\n", p.NotFoundPage) } - // Menu items - if len(p.MenuItems) > 0 { - fmt.Fprintln(ctx.Output, " menu (") - printMenuMDL(ctx.Output, p.MenuItems, 2, "CREATE NAVIGATION") - fmt.Fprintln(ctx.Output, " )") - } - // Only emitted when it differs from the platform default, so the clause // appears exactly when it carries information. Describing every profile // with `on sync error throw` would add a line to every navigation script @@ -379,7 +372,14 @@ func outputNavigationProfile(ctx *ExecContext, p *types.NavigationProfile) { } } - fmt.Fprintln(ctx.Output, ";") + // Menu items: the profile's children, in { } after its clauses (R2). + if len(p.MenuItems) > 0 { + fmt.Fprintln(ctx.Output, "{") + printMenuMDL(ctx.Output, p.MenuItems, 1, "CREATE NAVIGATION") + fmt.Fprintln(ctx.Output, "};") + } else { + fmt.Fprintln(ctx.Output, ";") + } fmt.Fprintln(ctx.Output) } @@ -421,23 +421,39 @@ func menuItemTarget(item *types.NavMenuItem) string { // printMenuMDL prints menu items in MDL-style format. reproducer names the // construct an icon note should point at — navigation menus are authored by // CREATE NAVIGATION, while a standalone menu document cannot be authored at all. +// +// Each item is a child with the shape every child has (R2, ako/mxcli#754): +// `menu item 'X' ( OnClick: show page M.P, Icon: I )`, and a sub-menu +// `menu 'X' ( Icon: I ) { … }`. A child ends in `)` or `}`, or in its caption +// when it has no properties, so no separator is written. func printMenuMDL(w io.Writer, items []*types.NavMenuItem, depth int, reproducer string) { indent := strings.Repeat(" ", depth) for _, item := range items { - icon := menuItemIconMDL(item) + var props []string + if len(item.Items) == 0 { + switch { + case item.Page != "": + props = append(props, "OnClick: show page "+item.Page) + case item.Microflow != "": + props = append(props, "OnClick: call microflow "+item.Microflow) + case item.ActionType == "SignOutAction": + props = append(props, "OnClick: sign out") + } + } + if icon := menuItemIconMDL(item); icon != "" { + props = append(props, "Icon: "+icon) + } + propList := "" + if len(props) > 0 { + propList = " ( " + strings.Join(props, ", ") + " )" + } if len(item.Items) > 0 { // Sub-menu container - fmt.Fprintf(w, "%smenu '%s'%s (\n", indent, item.Caption, icon) + fmt.Fprintf(w, "%smenu '%s'%s {\n", indent, item.Caption, propList) printMenuMDL(w, item.Items, depth+1, reproducer) - fmt.Fprintf(w, "%s);\n", indent) - } else if item.Page != "" { - fmt.Fprintf(w, "%smenu item '%s' page %s%s;\n", indent, item.Caption, item.Page, icon) - } else if item.Microflow != "" { - fmt.Fprintf(w, "%smenu item '%s' microflow %s%s;\n", indent, item.Caption, item.Microflow, icon) - } else if item.ActionType == "SignOutAction" { - fmt.Fprintf(w, "%smenu item '%s' sign out%s;\n", indent, item.Caption, icon) + fmt.Fprintf(w, "%s}\n", indent) } else { - fmt.Fprintf(w, "%smenu item '%s'%s;\n", indent, item.Caption, icon) + fmt.Fprintf(w, "%smenu item '%s'%s\n", indent, item.Caption, propList) } if note := menuItemIconNote(item, reproducer); note != "" { fmt.Fprintf(w, "%s%s\n", indent, note) @@ -445,7 +461,7 @@ func printMenuMDL(w io.Writer, items []*types.NavMenuItem, depth int, reproducer } } -// menuItemIconMDL renders the ICON clause for a menu item, or "" when there is +// menuItemIconMDL renders a menu item's `Icon:` value, or "" when there is // nothing CREATE NAVIGATION can reproduce. // // All three of Mendix's icon elements have a form now. Only the collection one @@ -460,17 +476,17 @@ func menuItemIconMDL(item *types.NavMenuItem) string { if item.IconCode == 0 { return "" } - return fmt.Sprintf(" icon glyph %d", item.IconCode) + return fmt.Sprintf("glyph %d", item.IconCode) case types.MenuIconImage: if item.Icon == "" { return "" } - return " icon image " + quoteQualifiedName(item.Icon) + return "image " + quoteQualifiedName(item.Icon) case types.MenuIconCollection: if item.Icon == "" { return "" } - return " icon " + quoteQualifiedName(item.Icon) + return quoteQualifiedName(item.Icon) } return "" } diff --git a/mdl/executor/cmd_navigation_icon_roundtrip_test.go b/mdl/executor/cmd_navigation_icon_roundtrip_test.go index 0565241007..2b9f6d519d 100644 --- a/mdl/executor/cmd_navigation_icon_roundtrip_test.go +++ b/mdl/executor/cmd_navigation_icon_roundtrip_test.go @@ -39,14 +39,14 @@ func TestMenuIconMDL_RoundTripsEveryVariant(t *testing.T) { { "collection", types.NavMenuItem{IconType: "Forms$IconCollectionIcon", Icon: "Atlas_Core.Atlas.home"}, - " icon Atlas_Core.Atlas.home", + "Atlas_Core.Atlas.home", }, { // The one that was being destroyed. The code IS the glyph's identity; // reading only the $Type left DESCRIBE with nothing to say. "glyph", types.NavMenuItem{IconType: "Forms$GlyphIcon", IconCode: 57377}, - " icon glyph 57377", + "glyph 57377", }, { // An image icon points into an IMAGE collection, a different document @@ -54,7 +54,7 @@ func TestMenuIconMDL_RoundTripsEveryVariant(t *testing.T) { // would rebuild it as the wrong element. "image", types.NavMenuItem{IconType: "Forms$ImageIcon", Icon: "System.Images.Close"}, - " icon image System.Images.Close", + "image System.Images.Close", }, } diff --git a/mdl/executor/cmd_navigation_icon_test.go b/mdl/executor/cmd_navigation_icon_test.go index c66676db33..83cfb2d076 100644 --- a/mdl/executor/cmd_navigation_icon_test.go +++ b/mdl/executor/cmd_navigation_icon_test.go @@ -30,7 +30,7 @@ func TestPrintMenuMDL_RoundTripsAnIconCollectionIcon(t *testing.T) { Icon: "Atlas_Core.Atlas.align-center", IconType: "Forms$IconCollectionIcon", }}) - want := "menu item 'Dashboard' page M.Dash icon Atlas_Core.Atlas.\"align-center\";\n" + want := "menu item 'Dashboard' ( OnClick: show page M.Dash, Icon: Atlas_Core.Atlas.\"align-center\" )\n" if got != want { t.Errorf("got %q, want %q", got, want) } @@ -47,14 +47,14 @@ func TestPrintMenuMDL_LeavesAKeywordIconNameUnquoted(t *testing.T) { Caption: "Home", Page: "M.Home", Icon: "Atlas_Core.Atlas.home", IconType: "Forms$IconCollectionIcon", }}) - want := "menu item 'Home' page M.Home icon Atlas_Core.Atlas.home;\n" + want := "menu item 'Home' ( OnClick: show page M.Home, Icon: Atlas_Core.Atlas.home )\n" if got != want { t.Errorf("got %q, want %q", got, want) } } -// A sub-menu carries its icon before the parenthesised body, matching the -// grammar's second alternative. +// A sub-menu carries its icon in its property list, before the { } of its +// items, matching the grammar's second alternative. func TestPrintMenuMDL_RoundTripsASubMenuIcon(t *testing.T) { got := menuMDL([]*types.NavMenuItem{{ Caption: "Reports", @@ -62,10 +62,10 @@ func TestPrintMenuMDL_RoundTripsASubMenuIcon(t *testing.T) { IconType: "Forms$IconCollectionIcon", Items: []*types.NavMenuItem{{Caption: "Monthly", Page: "M.Monthly"}}, }}) - if !strings.HasPrefix(got, "menu 'Reports' icon Atlas_Core.Atlas.\"list-bullets\" (\n") { + if !strings.HasPrefix(got, "menu 'Reports' ( Icon: Atlas_Core.Atlas.\"list-bullets\" ) {\n") { t.Errorf("sub-menu header lost its icon: %q", got) } - if !strings.Contains(got, "menu item 'Monthly' page M.Monthly;") { + if !strings.Contains(got, "menu item 'Monthly' ( OnClick: show page M.Monthly )") { t.Errorf("sub-items went missing: %q", got) } } @@ -76,13 +76,13 @@ func TestPrintMenuMDL_RoundTripsASubMenuIcon(t *testing.T) { // on testdata/expr-checker, a glyph icon destroyed at exit 0. // // Each form is emitted with its own keyword, so replay rebuilds the same -// ELEMENT. Writing `icon System.Images.Close` for an ImageIcon would have +// ELEMENT. Writing `Icon: System.Images.Close` for an ImageIcon would have // converted it to an IconCollectionIcon: a silent variant swap, which is why the // bare form was not simply widened to cover all three. func TestPrintMenuMDL_EmitsEachIconVariant(t *testing.T) { for _, tc := range []struct{ name, iconType, icon, want string }{ - {"collection icon", "Forms$IconCollectionIcon", "Atlas_Core.Atlas.home", " icon Atlas_Core.Atlas.home"}, - {"image icon", "Forms$ImageIcon", "System.Images.Close", " icon image System.Images.Close"}, + {"collection icon", "Forms$IconCollectionIcon", "Atlas_Core.Atlas.home", "Icon: Atlas_Core.Atlas.home"}, + {"image icon", "Forms$ImageIcon", "System.Images.Close", "Icon: image System.Images.Close"}, } { t.Run(tc.name, func(t *testing.T) { got := menuMDL([]*types.NavMenuItem{{ @@ -101,8 +101,8 @@ func TestPrintMenuMDL_EmitsEachIconVariant(t *testing.T) { got := menuMDL([]*types.NavMenuItem{{ Caption: "Close", Page: "M.Close", IconType: "Forms$GlyphIcon", IconCode: 57345, }}) - if !strings.Contains(got, " icon glyph 57345") { - t.Errorf("got %q, want it to contain ` icon glyph 57345`", got) + if !strings.Contains(got, "Icon: glyph 57345") { + t.Errorf("got %q, want it to contain `Icon: glyph 57345`", got) } if strings.Contains(got, "-- icon") { t.Errorf("still flagged as unreproducible: %q", got) @@ -127,7 +127,7 @@ func TestPrintMenuMDL_StillFlagsWhatItCannotRebuild(t *testing.T) { Caption: "Close", Page: "M.Close", Icon: tc.icon, IconType: tc.iconType, }}) stmt := strings.SplitN(got, "\n", 2)[0] - if strings.Contains(stmt, " icon ") { + if strings.Contains(stmt, "Icon:") { t.Errorf("emitted a clause for %s, which replay could not rebuild: %q", tc.iconType, got) } if !strings.Contains(got, "-- icon") { @@ -140,7 +140,7 @@ func TestPrintMenuMDL_StillFlagsWhatItCannotRebuild(t *testing.T) { // No icon means no clause and no note — the common case must stay clean. func TestPrintMenuMDL_SilentWhenThereIsNoIcon(t *testing.T) { got := menuMDL([]*types.NavMenuItem{{Caption: "Dashboard", Page: "M.Dash"}}) - if got != "menu item 'Dashboard' page M.Dash;\n" { + if got != "menu item 'Dashboard' ( OnClick: show page M.Dash )\n" { t.Errorf("got %q", got) } } @@ -160,7 +160,7 @@ func TestPrintMenuMDL_EmittedIconsReParse(t *testing.T) { body := menuMDL([]*types.NavMenuItem{{ Caption: "X", Page: "M.P", Icon: icon, IconType: "Forms$IconCollectionIcon", }}) - script := "create or replace navigation Responsive menu (\n" + body + ");" + script := "create or modify navigation Responsive {\n" + body + "};" prog, errs := visitor.Build(script) if len(errs) > 0 { t.Fatalf("DESCRIBE emitted output its own parser rejects:\n%s\nerrors: %v", script, errs) diff --git a/mdl/executor/cmd_pages_builder.go b/mdl/executor/cmd_pages_builder.go index 35b102b570..559da6fae4 100644 --- a/mdl/executor/cmd_pages_builder.go +++ b/mdl/executor/cmd_pages_builder.go @@ -90,7 +90,7 @@ type pageBuilder struct { // reference as a last line, ALTER included (canon.BareAttributeRefError). tolerateDanglingRefs bool - // Local page/snippet variables (Variables: { $name: Type = 'default' }). + // Local page/snippet variables (Variables: ( $name: Type = 'default' )). // Used to distinguish a $localVar reference from a page parameter when // resolving TextTemplate parameters — local variables must be stored as // Forms$PageVariable.LocalVariable in BSON, not as a literal Expression. diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index 39feb89a18..761c958c72 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -782,7 +782,7 @@ func (pb *pageBuilder) buildDynamicTextV3(w *ast.WidgetV3) (*pages.DynamicText, // Content: $widget.Name -> auto-generate {1} with $widget.Name as param // Content: Entity.Attribute -> auto-generate {1} with Entity.Attribute as param // Content: SomeStaticText -> literal string, no params (no dot, no $) - // Content: 'Name: {1}', ContentParams: [Name] -> use explicit template and params + // Content: 'Name: {1}', ContentParams: (Name) -> use explicit template and params var autoGeneratedParams []string if content != "" && explicitParams == nil { // Only auto-generate for: @@ -807,7 +807,7 @@ func (pb *pageBuilder) buildDynamicTextV3(w *ast.WidgetV3) (*pages.DynamicText, } // Attribute: X binds the dynamic text to an attribute (issue #650), equivalent - // to `ContentParams: [{1} = X]`. Without this the Attribute was dropped, leaving + // to `ContentParams: ({1} = X)`. Without this the Attribute was dropped, leaving // an orphaned `{1}` template with no parameter — which Studio Pro can't open // (NullReferenceException in ClientTemplateFormPart.CollectControls). // Outside a data container that parameter binds nothing (CE0402). @@ -1239,7 +1239,7 @@ func (pb *pageBuilder) buildSnippetCallParams(sc *pages.SnippetCallWidget, snipp continue } return mdlerrors.NewValidationf( - "snippet %s requires parameter $%s — add Params: {%s: $} to the SNIPPETCALL, "+ + "snippet %s requires parameter $%s — add Params: (%s = $) to the SNIPPETCALL, "+ "or place the call inside a data context of %s so the parameter is satisfied from it", snippetQName, declared.Name, declared.Name, orDefaultStr(declared.EntityName, "the parameter's entity"), ) diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 593e94b363..e02dbabc9f 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -86,7 +86,7 @@ func describePage(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprintln(ctx.Output, "@excluded") } - // V3 syntax: CREATE PAGE Module.Page (Title: '...', Layout: ..., Params: { }) + // V3 syntax: CREATE PAGE Module.Page (Title: '...', Layout: ..., Params: ( )) header := fmt.Sprintf("create or modify page %s.%s", modName, foundPage.Name) // The folder is a clause after the name (R9); `Folder:` is its alias. if folderPath := h.BuildFolderPath(foundPage.ContainerID); folderPath != "" { @@ -135,7 +135,7 @@ func describePage(ctx *ExecContext, name ast.QualifiedName) error { typeName := pageParamTypeMDL(p) params = append(params, fmt.Sprintf("$%s: %s", p.Name, typeName)) } - props = append(props, fmt.Sprintf("Params: { %s }", strings.Join(params, ", "))) + props = append(props, fmt.Sprintf("Params: ( %s )", strings.Join(params, ", "))) } // Output page variables from raw BSON if rawData != nil { @@ -157,7 +157,7 @@ func describePage(ctx *ExecContext, name ast.QualifiedName) error { } varParts = append(varParts, fmt.Sprintf("$%s: %s = %s", varName, varTypeName, mdlQuote(defaultVal))) } - props = append(props, fmt.Sprintf("Variables: { %s }", strings.Join(varParts, ", "))) + props = append(props, fmt.Sprintf("Variables: ( %s )", strings.Join(varParts, ", "))) } } @@ -284,7 +284,7 @@ func describeSnippet(ctx *ExecContext, name ast.QualifiedName) error { paramName, _ := p["Name"].(string) paramParts = append(paramParts, fmt.Sprintf("$%s: %s", paramName, snippetParamTypeMDL(p["ParameterType"]))) } - snippetProps = append(snippetProps, fmt.Sprintf("Params: { %s }", strings.Join(paramParts, ", "))) + snippetProps = append(snippetProps, fmt.Sprintf("Params: ( %s )", strings.Join(paramParts, ", "))) } fmt.Fprintf(ctx.Output, " (%s)", strings.Join(snippetProps, ", ")) } diff --git a/mdl/executor/cmd_pages_describe_designprops_test.go b/mdl/executor/cmd_pages_describe_designprops_test.go index dbd0f3140e..164202ab72 100644 --- a/mdl/executor/cmd_pages_describe_designprops_test.go +++ b/mdl/executor/cmd_pages_describe_designprops_test.go @@ -72,7 +72,7 @@ func TestFormatDesignPropertiesMDL_Compound(t *testing.T) { {Key: "Show divider", ValueType: "toggle"}, } got := formatDesignPropertiesMDL(dps) - want := "DesignProperties: ['Column gap': 'Medium', 'Spacing': ['margin-top': 'Large', 'margin-bottom': 'Medium'], 'Show divider': on]" + want := "DesignProperties: ('Column gap': 'Medium', 'Spacing': ('margin-top': 'Large', 'margin-bottom': 'Medium'), 'Show divider': on)" if got != want { t.Errorf("formatDesignPropertiesMDL:\n got %s\n want %s", got, want) } diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 1d5d91c2e1..04bce59214 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -214,12 +214,12 @@ func appendAppearanceProps(props []string, w rawWidget) []string { // formatDesignPropertiesMDL formats design properties as MDL V3 syntax. // Toggle → 'Key': ON, Option → 'Key': 'Value' func formatDesignPropertiesMDL(dps []rawDesignProp) string { - return fmt.Sprintf("DesignProperties: [%s]", joinDesignPropertyEntries(dps)) + return fmt.Sprintf("DesignProperties: (%s)", joinDesignPropertyEntries(dps)) } // joinDesignPropertyEntries renders design-property entries as comma-separated // MDL. Compound properties recurse into a nested list (issue #668): -// 'Spacing': ['margin-top': 'Large', 'margin-bottom': 'Medium']. +// 'Spacing': ('margin-top': 'Large', 'margin-bottom': 'Medium'). func joinDesignPropertyEntries(dps []rawDesignProp) string { var entries []string for _, dp := range dps { @@ -229,7 +229,7 @@ func joinDesignPropertyEntries(dps []rawDesignProp) string { case "option": entries = append(entries, fmt.Sprintf("%s: %s", mdlQuote(dp.Key), mdlQuote(dp.Option))) case "compound": - entries = append(entries, fmt.Sprintf("%s: [%s]", mdlQuote(dp.Key), joinDesignPropertyEntries(dp.Nested))) + entries = append(entries, fmt.Sprintf("%s: (%s)", mdlQuote(dp.Key), joinDesignPropertyEntries(dp.Nested))) } } return strings.Join(entries, ", ") @@ -498,7 +498,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { props = append(props, fmt.Sprintf("RenderMode: %s", w.RenderMode)) } if len(w.Parameters) > 0 { - props = append(props, fmt.Sprintf("ContentParams: [%s]", strings.Join(formatParametersV3(w.Parameters), ", "))) + props = append(props, fmt.Sprintf("ContentParams: (%s)", strings.Join(formatParametersV3(w.Parameters), ", "))) } props = appendAppearanceProps(props, w) formatWidgetProps(ctx.Output, prefix, header, props, "\n") @@ -515,7 +515,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { props = append(props, fmt.Sprintf("Caption: %s", mdlQuote(w.Caption))) } if len(w.Parameters) > 0 { - props = append(props, fmt.Sprintf("CaptionParams: [%s]", strings.Join(formatParametersV3(w.Parameters), ", "))) + props = append(props, fmt.Sprintf("CaptionParams: (%s)", strings.Join(formatParametersV3(w.Parameters), ", "))) } if w.Action != "" { props = append(props, actionProp("Action", w.Action)) @@ -803,7 +803,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { // A `{1}` re-executed without its parameter is CE0720, so the // companion travels with the text it belongs to (#575). if len(ep.Params) > 0 { - props = append(props, fmt.Sprintf("%sParams: [%s]", + props = append(props, fmt.Sprintf("%sParams: (%s)", ep.Key, strings.Join(formatParametersV3(ep.Params), ", "))) } } @@ -1121,7 +1121,7 @@ func outputDataGrid2ColumnV3(ctx *ExecContext, prefix string, col rawDataGridCol props = append(props, fmt.Sprintf("Caption: %s", mdlQuote(col.Caption))) } if len(col.CaptionParams) > 0 { - props = append(props, fmt.Sprintf("CaptionParams: [%s]", strings.Join(formatParametersV3(col.CaptionParams), ", "))) + props = append(props, fmt.Sprintf("CaptionParams: (%s)", strings.Join(formatParametersV3(col.CaptionParams), ", "))) } // Add ShowContentAs if not default "attribute" if col.ShowContentAs != "" && col.ShowContentAs != "attribute" { @@ -1131,7 +1131,7 @@ func outputDataGrid2ColumnV3(ctx *ExecContext, prefix string, col rawDataGridCol if col.ShowContentAs == "dynamicText" && col.DynamicText != "" { props = append(props, fmt.Sprintf("Content: %s", mdlQuote(col.DynamicText))) if len(col.DynamicTextParams) > 0 { - props = append(props, fmt.Sprintf("ContentParams: [%s]", strings.Join(formatParametersV3(col.DynamicTextParams), ", "))) + props = append(props, fmt.Sprintf("ContentParams: (%s)", strings.Join(formatParametersV3(col.DynamicTextParams), ", "))) } } // Add column styling properties if non-default @@ -2019,14 +2019,14 @@ func describeImageWidgetProps(w rawWidget) []string { if w.ImageUrl != "" { props = append(props, fmt.Sprintf("ImageUrl: %s", mdlQuote(w.ImageUrl))) if len(w.ImageUrlParams) > 0 { - props = append(props, fmt.Sprintf("ImageUrlParams: [%s]", + props = append(props, fmt.Sprintf("ImageUrlParams: (%s)", strings.Join(formatParametersV3(w.ImageUrlParams), ", "))) } } if w.AlternativeText != "" { props = append(props, fmt.Sprintf("AlternativeText: %s", mdlQuote(w.AlternativeText))) if len(w.AlternativeTextParams) > 0 { - props = append(props, fmt.Sprintf("AlternativeTextParams: [%s]", + props = append(props, fmt.Sprintf("AlternativeTextParams: (%s)", strings.Join(formatParametersV3(w.AlternativeTextParams), ", "))) } } diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index ec5e5898bb..d64e832226 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -1246,8 +1246,8 @@ func extractImageProperties(ctx *ExecContext, w map[string]any, widget *rawWidge // property of a CustomWidget, together with the `{N}` parameters bound to it. // // The parameters are returned separately rather than folded into the text -// because MDL spells them separately: `imageUrl: '{1}', imageUrlParams: [{1} = -// PictureUrl]` (#575). +// because MDL spells them separately: `imageUrl: '{1}', imageUrlParams: ({1} = +// PictureUrl)` (#575). func extractCustomWidgetPropertyTextTemplate(ctx *ExecContext, w map[string]any, propertyKey string) (string, []string) { obj, ok := w["Object"].(map[string]any) if !ok { diff --git a/mdl/executor/cmd_rest_clients.go b/mdl/executor/cmd_rest_clients.go index 84518cc2a4..b53672a84a 100644 --- a/mdl/executor/cmd_rest_clients.go +++ b/mdl/executor/cmd_rest_clients.go @@ -180,11 +180,11 @@ func outputRestOperation(w io.Writer, op *model.RestClientOperation) { fmt.Fprintf(w, " Query: (%s),\n", strings.Join(params, ", ")) } - // Headers: ('Name' = 'Value', ...) + // Headers: ('Name': 'Value', ...) — a map, `key: value` (R2/R3) if len(op.Headers) > 0 { var hdrs []string for _, h := range op.Headers { - hdrs = append(hdrs, mdlQuoted(h.Name)+" = "+mdlQuoted(h.Value)) + hdrs = append(hdrs, mdlQuoted(h.Name)+": "+mdlQuoted(h.Value)) } fmt.Fprintf(w, " Headers: (%s),\n", strings.Join(hdrs, ", ")) } diff --git a/mdl/executor/cmd_styling.go b/mdl/executor/cmd_styling.go index 21df27f04d..120686f783 100644 --- a/mdl/executor/cmd_styling.go +++ b/mdl/executor/cmd_styling.go @@ -214,7 +214,7 @@ func execDescribeStyling(ctx *ExecContext, s *ast.DescribeStylingStmt) error { if len(w.DesignProperties) > 0 { // Reuse the DESCRIBE PAGE formatter so toggle/option/compound render // identically across both describe paths (compound = issue #668). - fmt.Fprintf(ctx.Output, " DesignProperties: [%s]\n", joinDesignPropertyEntries(w.DesignProperties)) + fmt.Fprintf(ctx.Output, " DesignProperties: (%s)\n", joinDesignPropertyEntries(w.DesignProperties)) } } @@ -337,7 +337,7 @@ func applyStylingMutator(mutator backend.PageMutator, s *ast.AlterStylingStmt, t return mdlerrors.NewUnsupported(fmt.Sprintf( "design property %q takes a SET of options, and `alter styling` writes one value — "+ "mxbuild refuses that with CE6084. Set it inline instead: "+ - "`DesignProperties: ['%s': ['%s': on]]` on the widget, in CREATE PAGE or an "+ + "`DesignProperties: ('%s': ('%s': on))` on the widget, in CREATE PAGE or an "+ "ALTER PAGE REPLACE.", a.Property, a.Property, firstOptionName(multi, a.Value))) } diff --git a/mdl/executor/design_property_routing.go b/mdl/executor/design_property_routing.go index 98185e7c4c..ad4f2c405c 100644 --- a/mdl/executor/design_property_routing.go +++ b/mdl/executor/design_property_routing.go @@ -123,7 +123,7 @@ func designPropertyAssignment(p *ThemeProperty, value any) (valueType, option st if p.MultiSelect { return "", "", fmt.Errorf( "design property %q takes a SET of options, not one value — `set` carries a single "+ - "value, so write it inline instead: `DesignProperties: ['%s': ['%s': on]]` on the "+ + "value, so write it inline instead: `DesignProperties: ('%s': ('%s': on))` on the "+ "widget, in CREATE PAGE or an ALTER PAGE REPLACE. A single value is stored as an "+ "Option and mxbuild refuses it with CE6084", p.Name, p.Name, firstOptionName(p, str)) diff --git a/mdl/executor/design_property_routing_test.go b/mdl/executor/design_property_routing_test.go index 26ebf4fa8e..0ec9957e2b 100644 --- a/mdl/executor/design_property_routing_test.go +++ b/mdl/executor/design_property_routing_test.go @@ -144,7 +144,7 @@ func TestDesignPropertyAssignment(t *testing.T) { if err == nil { t.Fatal("a flat value on a multi-select property was accepted; mxbuild refuses it with CE6084") } - if !strings.Contains(err.Error(), "['Phone': on]") { + if !strings.Contains(err.Error(), "('Phone': on)") { t.Errorf("the message does not name the compound spelling: %v", err) } } diff --git a/mdl/executor/menu_signout_test.go b/mdl/executor/menu_signout_test.go index 24bfa1cf78..f8cd2480a5 100644 --- a/mdl/executor/menu_signout_test.go +++ b/mdl/executor/menu_signout_test.go @@ -103,11 +103,11 @@ func TestPrintMenuMDL_RendersSignOut(t *testing.T) { }, 0, "CREATE NAVIGATION") out := b.String() - if !strings.Contains(out, "menu item 'Sign out' sign out;") { + if !strings.Contains(out, "menu item 'Sign out' ( OnClick: sign out )") { t.Errorf("describe output does not round-trip the sign-out item:\n%s", out) } // CONTROL: a plain item must not gain an action. - if strings.Contains(out, "'Plain' sign out") { + if strings.Contains(out, "'Plain' (") { t.Errorf("a plain item was rendered as sign-out:\n%s", out) } } @@ -120,7 +120,7 @@ func TestMenuItem_SignOutRoundTripsThroughDescribe(t *testing.T) { printMenuMDL(&b, []*types.NavMenuItem{{Caption: "Sign out", ActionType: "SignOutAction"}}, 0, "CREATE NAVIGATION") - stmt := signOutMenuStmt(t, "create or modify menu M.Main (\n"+b.String()+");") + stmt := signOutMenuStmt(t, "create or modify menu M.Main {\n"+b.String()+"};") if !stmt.Items[0].SignOut { t.Errorf("describe emitted %q, which does not parse back as a sign-out item", b.String()) } diff --git a/mdl/executor/r2_rest_describe_test.go b/mdl/executor/r2_rest_describe_test.go new file mode 100644 index 0000000000..92b0d4bf38 --- /dev/null +++ b/mdl/executor/r2_rest_describe_test.go @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "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/pages" +) + +// The rest of R2 (ako/mxcli#754): describe prints navigation and menus with +// { } children, the property maps in ( ), and the database connection as a +// property list with query children — so its output re-parses without +// recording any deprecated spelling (MDL-DEPR120..127), and builds what was +// described. + +func TestDescribeNavigation_MenuItemsAreChildren(t *testing.T) { + ctx, buf := newMockCtx(t) + outputNavigationProfile(ctx, &types.NavigationProfile{ + Name: "Responsive", + HomePage: &types.NavHomePage{Page: "M.Home"}, + ThrowPartialSyncError: true, + MenuItems: []*types.NavMenuItem{ + {Caption: "Home", Page: "M.Home", Icon: "Atlas_Core.Atlas.home", IconType: "Forms$IconCollectionIcon"}, + {Caption: "Admin", IconType: "Forms$GlyphIcon", IconCode: 57345, Items: []*types.NavMenuItem{ + {Caption: "Users", Microflow: "M.ShowUsers"}, + {Caption: "Out", ActionType: "SignOutAction"}, + }}, + }, + }) + out := buf.String() + assertCanonicalDescribe(t, out, + "{\n menu item 'Home' ( OnClick: show page M.Home, Icon: Atlas_Core.Atlas.home )", + "menu 'Admin' ( Icon: glyph 57345 ) {", + "menu item 'Users' ( OnClick: call microflow M.ShowUsers )", + "menu item 'Out' ( OnClick: sign out )", + "};") + stmt := findStmt[*ast.AlterNavigationStmt](t, reparse(t, out), out) + if len(stmt.MenuItems) != 2 || len(stmt.MenuItems[1].Items) != 2 || !stmt.MenuItems[1].Items[1].SignOut || + stmt.MenuItems[1].IconCode != 57345 || stmt.MenuItems[0].Page == nil { + t.Errorf("menu did not round-trip: %+v", stmt.MenuItems) + } +} + +// Control: a profile without menu items still ends in `;` and gains no block. +func TestDescribeNavigation_NoMenuNoBlock(t *testing.T) { + ctx, buf := newMockCtx(t) + outputNavigationProfile(ctx, &types.NavigationProfile{Name: "Responsive", HomePage: &types.NavHomePage{Page: "M.Home"}, ThrowPartialSyncError: true}) + out := buf.String() + assertCanonicalDescribe(t, out, "home page M.Home\n;") + if strings.Contains(out, "{") { + t.Errorf("a profile with no menu items gained a block:\n%s", out) + } +} + +func TestDescribeMenu_ReParsesCanonically(t *testing.T) { + md := &types.MenuDocument{Name: "Main_Menu", Items: []*types.NavMenuItem{ + {Caption: "Home", Page: "M.Home"}, + {Caption: "More", Items: []*types.NavMenuItem{{Caption: "Out", ActionType: "SignOutAction"}}}, + }} + ctx, buf := newMockCtx(t, withBackend(menuBackend(md))) + assertNoError(t, describeMenu(ctx, ast.QualifiedName{Module: "Atlas_Core", Name: "Main_Menu"})) + assertCanonicalDescribe(t, buf.String(), "create or modify menu Atlas_Core.Main_Menu {", "menu 'More' {") +} + +func TestDescribeDatabaseConnection_PropertiesAndQueryChildren(t *testing.T) { + conn := &model.DatabaseConnection{ + Name: "Erp", DatabaseType: "PostgreSQL", + ConnectionString: "M.DbUrl", UserName: "M.DbUser", Password: "M.DbPass", + Queries: []*model.DatabaseQuery{ + { + Name: "GetCustomers", + SQL: "select id, name from customer where id > {minId} and name = 'it''s'", + Parameters: []*model.DatabaseQueryParameter{ + {ParameterName: "minId", DataType: "DataTypes$IntegerType", DefaultValue: "0"}, + {ParameterName: "name", DataType: "DataTypes$StringType", EmptyValueBecomesNull: true}, + }, + TableMappings: []*model.DatabaseTableMapping{{Entity: "M.Customer", Columns: []*model.DatabaseColumnMapping{ + {Attribute: "M.Customer.CustomerId", ColumnName: "id"}, + {Attribute: "M.Customer.Name", ColumnName: "name"}, + }}}, + }, + {Name: "Ping", SQL: "select 1"}, + }, + } + ctx, buf := newMockCtx(t) + assertNoError(t, outputDatabaseConnectionMDL(ctx, conn, "M")) + out := buf.String() + assertCanonicalDescribe(t, out, "create or modify database connection M.Erp (\n Type: 'PostgreSQL',", + "query GetCustomers (", "Map: (", "CustomerId = id", "query Ping (") + stmt := findStmt[*ast.CreateDatabaseConnectionStmt](t, reparse(t, out), out) + if stmt.DatabaseType != "PostgreSQL" || stmt.ConnectionString != "M.DbUrl" || !stmt.ConnectionStringIsRef || + !stmt.UserNameIsRef || !stmt.PasswordIsRef || len(stmt.Queries) != 2 { + t.Fatalf("connection did not round-trip: %+v", stmt) + } + q := stmt.Queries[0] + if q.SQL != conn.Queries[0].SQL || q.Returns.String() != "M.Customer" || len(q.Parameters) != 2 || + q.Parameters[0].DefaultValue != "0" || !q.Parameters[1].TestWithNull || + len(q.Mappings) != 2 || q.Mappings[0].ColumnName != "id" || q.Mappings[0].AttributeName != "CustomerId" { + t.Errorf("query did not round-trip: %+v", q) + } +} + +// Control: a connection without queries ends at its property list. +func TestDescribeDatabaseConnection_NoQueries(t *testing.T) { + ctx, buf := newMockCtx(t) + assertNoError(t, outputDatabaseConnectionMDL(ctx, &model.DatabaseConnection{ + Name: "Erp", DatabaseType: "MSSQL", ConnectionString: "M.U", UserName: "M.N", Password: "M.P"}, "M")) + assertCanonicalDescribe(t, buf.String(), "Password: @M.P\n);") +} + +func TestDescribeRestHeaders_AreAMap(t *testing.T) { + svc := &model.ConsumedRestService{ + Name: "Api", BaseUrl: "https://api.example.com", + Operations: []*model.RestClientOperation{{Name: "Ping", HttpMethod: "GET", Path: "/ping", ResponseType: "NONE", + Headers: []*model.RestClientHeader{{Name: "Accept", Value: "application/json"}}}}, + } + ctx, buf := newMockCtx(t) + assertNoError(t, outputConsumedRestServiceMDL(ctx, svc, "M")) + assertCanonicalDescribe(t, buf.String(), "Headers: ('Accept': 'application/json')") +} + +// A widget's design properties and text-template parameters are maps, in ( ). +func TestDescribeWidgetMaps_InParens(t *testing.T) { + var buf bytes.Buffer + ctx := &ExecContext{Output: &buf} + outputWidgetMDLV3(ctx, rawWidget{ + Type: "Forms$DynamicText", Name: "t", Content: "Hi {1}", Parameters: []string{"Name"}, + DesignProperties: []rawDesignProp{ + {Key: "Spacing", ValueType: "compound", Nested: []rawDesignProp{{Key: "margin-top", ValueType: "option", Option: "Large"}}}, + {Key: "Full width", ValueType: "toggle"}, + }, + }, 1) + outputWidgetMDLV3(ctx, rawWidget{ + Type: "Forms$ActionButton", Name: "b", Caption: "Go {1}", Parameters: []string{"Title"}, Action: "save changes", + }, 1) + out := "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) {\n" + buf.String() + "};\n" + assertCanonicalDescribe(t, out, + "ContentParams: ({1} = Name)", "CaptionParams: ({1} = Title)", + "DesignProperties: ('Spacing': ('margin-top': 'Large'), 'Full width': on)") +} + +// A page's header maps — Params and Variables — are in ( ). +func TestDescribePageHeaderMaps_InParens(t *testing.T) { + mod := mkModule("M") + h := mkHierarchy(mod) + page := &pages.Page{ + BaseElement: model.BaseElement{ID: nextID("pg")}, + ContainerID: mod.ID, + Name: "Edit", + Parameters: []*pages.PageParameter{{Name: "Order", EntityName: "M.Order"}}, + } + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListPagesFunc: func() ([]*pages.Page, error) { return []*pages.Page{page}, nil }, + GetRawUnitFunc: func(model.ID) (map[string]any, error) { + return map[string]any{"Variables": []any{int32(3), map[string]any{ + "Name": "show", "DefaultValue": "true", + "VariableType": map[string]any{"$Type": "DataTypes$BooleanType"}, + }}}, nil + }, + } + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, describePage(ctx, ast.QualifiedName{Module: "M", Name: "Edit"})) + assertCanonicalDescribe(t, buf.String(), "Params: ( $Order: M.Order )", "Variables: ( $show: Boolean = 'true' )") +} diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 01f9c5f1ab..c07ab9dd05 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -941,7 +941,7 @@ func (sc *scriptContext) relaxExcludedWidgetRefs(kind, name string, widgets []*a // nested container with a data source of its own, which scopes its children. // // Covered: `Attribute:`, `CaptionAttribute:`, `Visible: Attr in (…)` and -// template parameters (`…Params: [{1} = Attr]`). Anything else is caught by +// template parameters (`…Params: ({1} = Attr)`). Anything else is caught by // the page writer's refusal of a bare attribute reference. func unscopedBindings(widgets []*ast.WidgetV3, ref string) []string { var out []string diff --git a/mdl/executor/validate_alter_styling.go b/mdl/executor/validate_alter_styling.go index 2b66d9dd30..8682149a29 100644 --- a/mdl/executor/validate_alter_styling.go +++ b/mdl/executor/validate_alter_styling.go @@ -98,7 +98,7 @@ func validateAlterStylingDesignProps(prog *ast.Program, reg *ThemeRegistry) []li "and ALTER STYLING can only write one value — mxbuild refuses the result "+ "with CE6084", label, a.Property, s.WidgetName), Location: linter.Location{DocumentType: "page", DocumentName: s.ContainerName.String()}, - Suggestion: fmt.Sprintf("Set it inline instead: `DesignProperties: ['%s': ['%s': on]]` "+ + Suggestion: fmt.Sprintf("Set it inline instead: `DesignProperties: ('%s': ('%s': on))` "+ "on the widget in CREATE PAGE, or in an ALTER PAGE REPLACE.", a.Property, firstOptionName(multi, a.Value)), }) @@ -241,7 +241,7 @@ func renamedStylingSuggestion(r *designPropRename, value string) string { } if strings.Contains(r.Replacement, "[") { 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) + "`DesignProperties: (%s)` on the widget in CREATE PAGE, or in an ALTER PAGE REPLACE.", r.Replacement) } // 'Key': 'Value' → set ( 'Key': 'Value' ), the canonical list (R3) if strings.Contains(r.Replacement, "': ") { diff --git a/mdl/executor/validate_widget_member_refs.go b/mdl/executor/validate_widget_member_refs.go index dae0fd3216..b4758d0297 100644 --- a/mdl/executor/validate_widget_member_refs.go +++ b/mdl/executor/validate_widget_member_refs.go @@ -29,7 +29,7 @@ import ( // (mendixlabs/mxcli#1046). The writer put the whole string in the attribute // name, so the model came out naming an attribute that could never exist: // -// ContentParams: [{1} = $Customer/Name] +// ContentParams: ({1} = $Customer/Name) // mx check -> [CE1613] "The selected attribute // 'Bench.Customer.$Customer/Name' no longer exists." // diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index a5fb7edc8a..cfe349f399 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -826,7 +826,7 @@ func validateDynamicTextFormatting(w *ast.WidgetV3, locationPrefix string) []lin RuleID: "MDL-WIDGET18", Severity: linter.SeverityError, Message: fmt.Sprintf( - "%s: widget `%s`: `%s` is a per-parameter format, not a widget property — put it in the ContentParams format block, e.g. `ContentParams: [{1} = Attr format (%s: )]`. A widget-level `%s` is dropped on write.", + "%s: widget `%s`: `%s` is a per-parameter format, not a widget property — put it in the ContentParams format block, e.g. `ContentParams: ({1} = Attr format (%s: ))`. A widget-level `%s` is dropped on write.", locationPrefix, w.Name, key, strings.ToLower(key), key, ), }) @@ -1150,7 +1150,7 @@ func validateDynamicTextPlaceholders(w *ast.WidgetV3, locationPrefix string) *li RuleID: "MDL-WIDGET04", Severity: linter.SeverityError, Message: fmt.Sprintf( - "%s: widget `%s` (dynamictext) references template placeholder {%d} but only %d parameter(s) are bound — bind it with `Attribute: ` or `ContentParams: [{%d} = ]`. An orphaned placeholder crashes Studio Pro.", + "%s: widget `%s` (dynamictext) references template placeholder {%d} but only %d parameter(s) are bound — bind it with `Attribute: ` or `ContentParams: ({%d} = )`. An orphaned placeholder crashes Studio Pro.", locationPrefix, w.Name, maxIdx, params, maxIdx, ), } @@ -1176,7 +1176,7 @@ func validateButtonCaptionPlaceholders(w *ast.WidgetV3, locationPrefix string) * RuleID: "MDL-WIDGET04", Severity: linter.SeverityError, Message: fmt.Sprintf( - "%s: %s caption references template placeholder {%d} but only %d parameter(s) are bound — bind it with `CaptionParams: [{%d} = ]`. An orphaned placeholder fails the build (CE0720).", + "%s: %s caption references template placeholder {%d} but only %d parameter(s) are bound — bind it with `CaptionParams: ({%d} = )`. An orphaned placeholder fails the build (CE0720).", locationPrefix, widgetLabel(w.Name, strings.ToLower(w.Type)), maxIdx, params, maxIdx, ), } diff --git a/mdl/executor/validate_workflow_task_signature.go b/mdl/executor/validate_workflow_task_signature.go index 64a545c254..ade66b56c5 100644 --- a/mdl/executor/validate_workflow_task_signature.go +++ b/mdl/executor/validate_workflow_task_signature.go @@ -146,7 +146,7 @@ func (c *workflowTaskSignatureChecker) checkPage(label, pageQN string) string { if len(params) == 0 { return fmt.Sprintf( "%s: page %s takes no parameters — a task page is opened with the task, so it must take a %s parameter; "+ - "the build fails CE7410 (add `params: { $WorkflowUserTask: %s }`)", + "the build fails CE7410 (add `params: ( $WorkflowUserTask: %s )`)", label, pageQN, workflowUserTaskEntity, workflowUserTaskEntity) } for _, e := range params { diff --git a/mdl/executor/widget_texttemplate_named_params_575_test.go b/mdl/executor/widget_texttemplate_named_params_575_test.go index 9c4c07ce9b..d493c333e9 100644 --- a/mdl/executor/widget_texttemplate_named_params_575_test.go +++ b/mdl/executor/widget_texttemplate_named_params_575_test.go @@ -229,8 +229,8 @@ func TestIssue575_DescribeEmitsTheParamsCompanion(t *testing.T) { } got := strings.Join(describeImageWidgetProps(w), ", ") for _, want := range []string{ - "ImageUrlParams: [{1} = Bug575.Product.PictureUrl]", - "AlternativeTextParams: [{1} = Bug575.Product.Name]", + "ImageUrlParams: ({1} = Bug575.Product.PictureUrl)", + "AlternativeTextParams: ({1} = Bug575.Product.Name)", } { if !strings.Contains(got, want) { t.Errorf("describe output missing %q; got: %s", want, got) From cc6072715e0ad6158c2a1a6cbd800b8d9940a068 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 16:03:25 +0000 Subject: [PATCH 04/17] docs: menus, property maps and database connections in their R2 forms (#754) Skills (synced), docs-site, the quick reference, the language reference, mxcli syntax entries, mdl-examples and the layout scaffold use the canonical spellings. The .mdl files and parseable markdown blocks were converted by applying only the visitor's own MDL-DEPR120..127 rewrites; syntax patterns and prose were edited by hand. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/mendix/alter-page/SKILL.md | 2 +- .claude/skills/mendix/atlas-design/SKILL.md | 4 +- .../atlas-design/reference/building-blocks.md | 30 +- .claude/skills/mendix/check-syntax/SKILL.md | 2 +- .claude/skills/mendix/create-page/SKILL.md | 14 +- .../mendix/create-page/reference/examples.md | 6 +- .../mendix/create-page/reference/widgets.md | 44 +- .claude/skills/mendix/custom-widgets/SKILL.md | 8 +- .../mendix/database-connections/SKILL.md | 227 +++++---- .claude/skills/mendix/fragments/SKILL.md | 4 +- .../skills/mendix/manage-navigation/SKILL.md | 129 ++--- .../mendix/master-detail-pages/SKILL.md | 14 +- .../mendix/migrate-design-prototype/SKILL.md | 10 +- .../skills/mendix/migrate-k2-nintex/SKILL.md | 2 +- .claude/skills/mendix/overview-pages/SKILL.md | 30 +- .../resolve-forward-references/SKILL.md | 6 +- .claude/skills/mendix/rest-client/SKILL.md | 6 +- .claude/skills/mendix/theme-styling/SKILL.md | 10 +- .claude/skills/mendix/write-layouts/SKILL.md | 6 +- CHANGELOG.md | 2 + README.md | 2 +- cmd/mxcli/cmd_new_layout.go | 4 +- cmd/mxcli/lsp_completion.go | 2 +- cmd/mxcli/syntax/features_integration.go | 79 +-- cmd/mxcli/syntax/features_misc.go | 44 +- cmd/mxcli/syntax/features_page.go | 76 +-- cmd/mxcli/syntax/features_workflow.go | 2 +- docs-site/src/appendixes/quick-reference.md | 18 +- .../src/appendixes/version-compatibility.md | 12 +- docs-site/src/examples/crm-module.md | 2 +- docs-site/src/examples/data-import.md | 25 +- docs-site/src/examples/master-detail.md | 4 +- docs-site/src/examples/rest-integration.md | 6 +- docs-site/src/examples/validation.md | 2 +- docs-site/src/language/data-binding.md | 2 +- docs-site/src/language/document-access.md | 2 +- docs-site/src/language/home-pages.md | 40 +- docs-site/src/language/navigation-profiles.md | 24 +- docs-site/src/language/page-patterns.md | 6 +- docs-site/src/language/page-structure.md | 14 +- docs-site/src/language/pages.md | 8 +- docs-site/src/language/snippets.md | 4 +- docs-site/src/language/widget-types.md | 16 +- .../reference/navigation/alter-navigation.md | 48 +- docs-site/src/reference/navigation/menu.md | 49 +- docs-site/src/reference/page/create-page.md | 18 +- .../src/reference/page/create-snippet.md | 10 +- .../src/reference/query/describe-page.md | 2 +- docs-site/src/tutorial/create-page.md | 4 +- docs-site/src/tutorial/describe-search.md | 2 +- .../CASE_STUDY_MxGraphStudioDemo.md | 12 +- docs/01-project/MDL_QUICK_REFERENCE.md | 66 +-- .../LEGACY_ENGINE_KNOWN_ISSUES.md | 2 +- docs/03-development/STRING_TEMPLATE_SYNTAX.md | 2 +- .../01-language-reference.md | 32 +- .../PROPOSAL_mdl_beta_syntax_freeze.md | 1 + .../bug-tests/1024-boundary-event-jump-to.mdl | 2 +- .../1028-snippet-primitive-parameter.fail.mdl | 2 +- .../1028-snippet-primitive-parameter.mdl | 8 +- .../1029-showpage-arg-with-context.mdl | 4 +- ...1029-showpage-arg-without-context.fail.mdl | 4 +- ...032-alter-page-set-database-datasource.mdl | 2 +- ...76-alter-page-selection-and-flow-scope.mdl | 22 +- .../1113-database-query-type-enum.mdl | 22 +- ...12-combobox-enum-ce0463-widget-version.mdl | 2 +- .../1121-page-parameters-on-mendix-10.mdl | 2 +- .../114-quoted-reserved-page-params.mdl | 4 +- .../1140-flow-arg-page-parameter.mdl | 6 +- ...fileuploader-describe-named-datasource.mdl | 2 +- .../bug-tests/1200-mpk-action-variables.mdl | 2 +- .../bug-tests/200-basic-auth-rest-client.mdl | 2 +- .../bug-tests/254-slider-custom-tooltip.mdl | 2 +- .../bug-tests/295-showpage-null-variable.mdl | 2 +- ...95-showpage-parameter-mapping-validity.mdl | 4 +- .../55-alter-page-insert-assoc-binding.mdl | 2 +- .../554-dataview-form-orientation.mdl | 6 +- .../56-show-page-currentobject-arg.mdl | 2 +- .../572-use-fragment-in-alter-page.mdl | 2 +- .../bug-tests/573-simple-menu-bar.mdl | 10 +- .../574-widget-property-visibility.mdl | 4 +- .../576-datasource-bare-entity-shorthand.mdl | 2 +- .../619-quoted-reserved-emitter-names.mdl | 2 +- .../627-container-visible-expression.mdl | 2 +- .../631-listview-inputs-not-editable.mdl | 2 +- ...32-button-captionparams-bare-attribute.mdl | 12 +- .../643-combobox-datasource-by-name.fail.mdl | 2 +- ...inherited-association-module-qualifier.mdl | 2 +- .../664-combobox-expression-caption.mdl | 2 +- ...80-enum-literal-conditional-visibility.mdl | 2 +- .../bug-tests/713-popup-zero-dimensions.mdl | 4 +- .../bug-tests/716-widget-package-upgrade.mdl | 2 +- .../bug-tests/762-813-dataview-properties.mdl | 2 +- ...datagrid-microflow-datasource-describe.mdl | 2 +- .../bug-tests/843-rest-response-mapping.mdl | 2 +- .../851-alter-page-conditional-attribute.mdl | 4 +- .../852-conditional-keyword-functions.mdl | 2 +- .../854-assoc-datasource-qualified-name.mdl | 2 +- ...55-alter-page-set-parameter-datasource.mdl | 2 +- .../863-execute-database-query-describe.mdl | 23 +- .../868-snippetcall-context-parameter.mdl | 12 +- .../bug-tests/928-widget-binding-gaps.mdl | 4 +- .../bug-tests/928-widget-binding-warnings.mdl | 2 +- ...35-customcontent-column-entity-context.mdl | 4 +- .../939-showpage-argument-ignored.mdl | 4 +- .../941-describe-page-datasources.mdl | 2 +- .../956-fileuploader-six-action-slots.mdl | 4 +- .../978-gallery-slot-names-not-duplicates.mdl | 8 +- .../989-navigation-title-override.mdl | 30 +- .../alter-page-bare-attributeref-refused.mdl | 2 +- .../alter-page-lowercase-set-on-builtin.mdl | 2 +- .../alter-page-unscoped-insert-bindings.mdl | 6 +- .../alter-styling-renamed-design-property.mdl | 2 +- .../alterpage-527-set-documentation.mdl | 2 +- .../bug-tests/assoc-datasource-modelsdk.mdl | 2 +- .../bug-tests/assoc-datasource-page-level.mdl | 6 +- .../bug1-widget-action-param-mapping.mdl | 2 +- .../bug2-modelsdk-nanoflow-widget-action.mdl | 2 +- ...ug3-contentparam-inherited-association.mdl | 2 +- .../bug8-datagrid-gallery-sort-desc.mdl | 2 +- .../captrack-10-sign-out-menu-item.mdl | 12 +- .../compound-design-property-marker.mdl | 2 +- .../bug-tests/compound-designproperties.mdl | 6 +- .../contentparam-expression-rejected.fail.mdl | 4 +- ...datagrid-filter-block-dropped-silently.mdl | 2 +- .../datagrid2-custom-content-column.mdl | 2 +- .../dataview-context-association-source.mdl | 2 +- ...dataview-database-source-rejected.fail.mdl | 2 +- .../dbconn-literal-credentials.fail.mdl | 11 +- ...scribe-dynamictext-nonstring-attribute.mdl | 2 +- .../bug-tests/describe-label-widget.mdl | 2 +- ...cribe-roundtrip-705-carried-properties.mdl | 2 +- .../design-property-keyword-mappings.mdl | 12 +- .../dynamictext-contentparam-association.mdl | 4 +- ...age-unresolved-flow-qualified-bindings.mdl | 2 +- .../bug-tests/expr-quoted-identifiers.mdl | 6 +- .../expression-text-keyword-operators.mdl | 2 +- .../gallery-1035-paging-position-enum.mdl | 6 +- .../bug-tests/icon-reference-validation.mdl | 12 +- ...s-1008-placeholder-snippet-layout-menu.mdl | 16 +- .../input-binding-without-context.mdl | 4 +- .../it-14-assoc-destination-entity.mdl | 4 +- .../it-19-cross-module-attribute-path.mdl | 2 +- .../label-design-properties-check.mdl | 4 +- .../language-970-authoring-language.mdl | 10 +- ...ledger-147-datasource-arg-data-context.mdl | 4 +- .../ledger-27-consecutive-dynamictext.mdl | 14 +- .../ledger-75-dynamictext-formatting.mdl | 4 +- .../ledger-77-datagrid-dynamictext-column.mdl | 2 +- .../ledger-78-datagrid-column-addressing.mdl | 2 +- .../listview-database-source-searchrefs.mdl | 2 +- ...istwidget-reverse-assoc-specialization.mdl | 2 +- ...maint2-call-workflow-multiline-mapping.mdl | 2 +- .../bug-tests/maint2-datasource-arguments.mdl | 2 +- .../maint2-editable-never-create-page.mdl | 2 +- .../microflow-1152-sort-association-path.mdl | 2 +- .../navigation-describe-profile-pages.mdl | 6 +- .../bug-tests/navigation-glyph-code.mdl | 20 +- .../bug-tests/navigation-icon-variants.mdl | 24 +- .../bug-tests/navigation-menu-item-icons.mdl | 22 +- .../bug-tests/nested-fragment-expansion.mdl | 2 +- .../bug-tests/open-link-dynamic-address.mdl | 2 +- .../page-header-version-floored-keys.mdl | 2 +- .../bug-tests/page-variable-bindings.mdl | 8 +- ...s-529-input-attribute-over-association.mdl | 2 +- .../pages-541-roundtrip-property-drift.mdl | 2 +- .../pages-550-describe-input-properties.mdl | 2 +- .../pages-552-list-widget-row-action.mdl | 4 +- .../pluggable-child-slot-container.mdl | 2 +- .../pluggable-texttemplate-content.mdl | 2 +- .../refs-graph-members-enums-workflows.mdl | 2 +- .../renamed-design-property-legacy-names.mdl | 8 +- .../security-587-system-member-access.mdl | 6 +- .../bug-tests/snippetcall-missing-params.mdl | 2 +- ...09-511-alter-styling-design-properties.mdl | 2 +- .../textbox-placeholder-onchange.mdl | 2 +- .../traceops-23-combobox-association.mdl | 2 +- .../bug-tests/typed-design-properties.mdl | 6 +- .../visible-based-on-attribute-value.mdl | 2 +- .../wf-415-alter-outcome-activity-kind.mdl | 2 +- .../widget-arg-keyword-param-names.mdl | 2 +- .../bug-tests/widget-dynamicclasses.mdl | 2 +- .../widget-member-refs-resolved-at-check.mdl | 8 +- .../bug-tests/widget-visible-expression.mdl | 2 +- .../widgets-1109-named-widget-datasources.mdl | 2 +- ...gets-575-texttemplate-params-companion.mdl | 18 +- .../bug-tests/widgets-deprecated-builtins.mdl | 2 +- .../workflow-408-describe-roundtrip.mdl | 2 +- .../workflow-586-clause-order-canonical.mdl | 2 +- .../bug-tests/workflow-586-clause-order.mdl | 2 +- .../workflow-586b-overview-page-dropped.mdl | 4 +- .../bug-tests/workflow-actions-describe.mdl | 2 +- .../bug-tests/workflow-end-activity.mdl | 2 +- .../workflow-end-every-path-ends.fail.mdl | 2 +- .../workflow-end-in-parallel-split.fail.mdl | 2 +- .../bug-tests/workflow-end-not-last.fail.mdl | 2 +- .../workflow-return-in-workflow.fail.mdl | 2 +- ...low-task-page-and-targeting-signatures.mdl | 2 +- ...orkflow-wait-for-notification-boundary.mdl | 2 +- .../bug-tests/xpath-assoc-path-expansion.mdl | 2 +- .../doctype-tests/02-microflow-examples.mdl | 4 +- .../doctype-tests/03-page-examples.mdl | 177 +++---- .../05-database-connection-examples.mdl | 457 +++++++++--------- .../doctype-tests/06-rest-client-examples.mdl | 52 +- .../doctype-tests/11-navigation-examples.mdl | 40 +- .../doctype-tests/12-styling-examples.mdl | 50 +- .../doctype-tests/15-fragment-examples.mdl | 10 +- .../17-custom-widget-examples.mdl | 24 +- .../doctype-tests/24-workflow-examples.mdl | 8 +- .../doctype-tests/26-menu-examples.mdl | 38 +- .../doctype-tests/29-datagrid-examples.mdl | 16 +- .../29-listview-specialization-templates.mdl | 10 +- .../30-pluggable-widget-examples.mdl | 10 +- ...uggable-datagrid-gallery-v010-examples.mdl | 34 +- .../doctype-tests/33-alter-page-examples.mdl | 8 +- .../35-mcp-pluggable-thirdparty.mdl | 8 +- .../doctype-tests/alter-page-insert-into.mdl | 4 +- mdl-examples/doctype-tests/layouts.mdl | 8 +- .../doctype-tests/navigation-profiles.mdl | 30 +- mdl-examples/use-cases/02-agentic-search.mdl | 8 +- .../use-cases/ai-agent-platform-demo.mdl | 4 +- .../use-cases/graph-traversal-viewer-demo.mdl | 4 +- .../widget-matrix/pluggable-smoke.mdl | 2 +- mdl-examples/widgetdemo/03-showcase-page.mdl | 22 +- 223 files changed, 1492 insertions(+), 1396 deletions(-) diff --git a/.claude/skills/mendix/alter-page/SKILL.md b/.claude/skills/mendix/alter-page/SKILL.md index f1733e50f2..57f69e2765 100644 --- a/.claude/skills/mendix/alter-page/SKILL.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -96,7 +96,7 @@ everywhere. Removing one has its own form: alter page Pages.Vehicle_Overview { insert into vehicleListView { template for Pages.Motorcycle { - dynamictext mcLabel (content: 'Motorcycle {1}', contentparams: [{1} = Brand]) + dynamictext mcLabel (content: 'Motorcycle {1}', contentparams: ({1} = Brand)) } }; drop template for Pages.SUV in vehicleListView diff --git a/.claude/skills/mendix/atlas-design/SKILL.md b/.claude/skills/mendix/atlas-design/SKILL.md index 4c2bbf48e2..53ace22414 100644 --- a/.claude/skills/mendix/atlas-design/SKILL.md +++ b/.claude/skills/mendix/atlas-design/SKILL.md @@ -151,9 +151,9 @@ mxcli -p app.mpr -c "describe building block Atlas_Web_Content.Card" ``` ``` { - container container2 (DesignProperties: ['Card style': on]) { + container container2 (DesignProperties: ('Card style': on)) { dynamictext text22 (Content: 'Card title', RenderMode: H4, Class: 'card-title', - DesignProperties: ['Spacing': ['margin-bottom': 'L']]) + DesignProperties: ('Spacing': ('margin-bottom': 'L'))) } } ``` diff --git a/.claude/skills/mendix/atlas-design/reference/building-blocks.md b/.claude/skills/mendix/atlas-design/reference/building-blocks.md index 5440173086..eb838aaa73 100644 --- a/.claude/skills/mendix/atlas-design/reference/building-blocks.md +++ b/.claude/skills/mendix/atlas-design/reference/building-blocks.md @@ -63,13 +63,13 @@ create page MyModule.CardDemo layout: Atlas_Core.Atlas_Default ) { - container myCard (designproperties: ['Card style': on]) { + container myCard (designproperties: ('Card style': on)) { dynamictext cardTitle ( content: 'Customers', rendermode: H4, class: 'card-title', - designproperties: ['Spacing': ['margin-bottom': 'L']] + designproperties: ('Spacing': ('margin-bottom': 'L')) ) } }; @@ -81,7 +81,7 @@ arbitrary bodies, no copy-paste of the wrapper markup. ```mdl create fragment SectionCard as { - container card1 (designproperties: ['Card style': on, 'Spacing': ['margin-bottom': 'Large']]) { + container card1 (designproperties: ('Card style': on, 'Spacing': ('margin-bottom': 'Large'))) { container cardBody (class: 'card-body') { slot content -- each page's widgets land here } @@ -120,7 +120,7 @@ inside; typed **parameters** vary *which entity* and *which microflow*. Declare ```mdl create fragment EntityCard($data: datasource, $onOpen: action) as { - container card1 (designproperties: ['Card style': on]) { + container card1 (designproperties: ('Card style': on)) { listview lv (datasource: $data) { slot content actionbutton open (caption: 'Open', action: $onOpen, buttonstyle: primary) @@ -149,10 +149,10 @@ For a binding the override rule can't reach, copy the block in (`as prefix_`) an ``` { - container container1 (Class: 'pageheader', DesignProperties: ['Item gap': 'None']) { + container container1 (Class: 'pageheader', DesignProperties: ('Item gap': 'None')) { dynamictext text40 (Content: 'Page header title', RenderMode: H1, Class: 'pageheader-title') dynamictext text39 (Content: 'Supporting text', RenderMode: Paragraph, Class: 'pageheader-subtitle', - DesignProperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']]) + DesignProperties: ('Color': 'Detail color', 'Spacing': ('margin-bottom': 'None'))) } } ``` @@ -166,14 +166,14 @@ create page MyModule.CustomersHeaderDemo layout: Atlas_Core.Atlas_Default ) { - container pageHeader (class: 'pageheader', designproperties: ['Item gap': 'None']) { + container pageHeader (class: 'pageheader', designproperties: ('Item gap': 'None')) { dynamictext headerTitle (content: 'Customers', rendermode: H1, class: 'pageheader-title') dynamictext headerSubtitle ( content: 'All active accounts', rendermode: Paragraph, class: 'pageheader-subtitle', - designproperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']] + designproperties: ('Color': 'Detail color', 'Spacing': ('margin-bottom': 'None')) ) } }; @@ -245,13 +245,13 @@ cleanly into Studio Pro. Common mappings: | Class-style | Typed design-property equivalent | |---|---| -| `class:'card'` | `designproperties: ['Card style': on]` | -| `class:'background-primary'` | `designproperties: ['Background color': 'Brand Primary']` | -| `class:'flex-column'` | `designproperties: ['Flex container': 'Vertical (column)']` | -| `class:'flex-row'` | `designproperties: ['Flex container': 'Horizontal (row)']` | -| `class:'align-x-center'` | `designproperties: ['Align items X': 'Center']` | -| `class:'Shadow'` | `designproperties: ['Shadow': 'None' / 'Small' / …]` | -| spacing utilities | `designproperties: ['Spacing': ['margin-bottom': 'L', 'padding-top': 'S']]` | +| `class:'card'` | `designproperties: ('Card style': on)` | +| `class:'background-primary'` | `designproperties: ('Background color': 'Brand Primary')` | +| `class:'flex-column'` | `designproperties: ('Flex container': 'Vertical (column)')` | +| `class:'flex-row'` | `designproperties: ('Flex container': 'Horizontal (row)')` | +| `class:'align-x-center'` | `designproperties: ('Align items X': 'Center')` | +| `class:'Shadow'` | `designproperties: ('Shadow': 'None' / 'Small' / …)` | +| spacing utilities | `designproperties: ('Spacing': ('margin-bottom': 'L', 'padding-top': 'S'))` | **Both channels render identically at runtime** — raw `class:` is sufficient for the visual result today. The typed channel matters for Studio Pro round-trip and is the diff --git a/.claude/skills/mendix/check-syntax/SKILL.md b/.claude/skills/mendix/check-syntax/SKILL.md index e15eed2e0b..f595855f74 100644 --- a/.claude/skills/mendix/check-syntax/SKILL.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -311,7 +311,7 @@ Before writing any MDL, verify these requirements: > **Exception — never quote `$`-prefixed variable/parameter references.** The quote > rule is for *bare* names (entities, attributes, associations, declared parameter > names). Variable and parameter **references** in expressions and widget bindings -> stay **unquoted**: `datasource: $X`, `params: { $X: MES."Order" }`, `$currentObject`. +> stay **unquoted**: `datasource: $X`, `params: ( $X: MES."Order" )`, `$currentObject`. > Quoting them (`"$X"`) breaks resolution ("parameter … references '$X' but no such > parameter is declared"). > diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 23214d2139..1d1d47946f 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -29,8 +29,8 @@ Guide for writing CREATE PAGE statements in Mendix Definition Language (MDL). ```sql create [or replace] page Module.PageName ( - [params: { $ParamName: Module.EntityType | PrimitiveType, ... },] - [variables: { $varName: DataType = 'defaultExpression', ... },] + [params: ( $ParamName: Module.EntityType | PrimitiveType, ... ),] + [variables: ( $varName: DataType = 'defaultExpression', ... ),] title: 'Page Title', layout: Module.LayoutName, [url: 'page-url',] @@ -75,7 +75,7 @@ Both are optional and can be changed later with `alter page … { set (Class: ' | Selection binding | `datasource: selection widget` | `dataview dv (datasource: selection galleryList)` | | CSS class | `class: 'classes'` | `container c (class: 'card mx-spacing-top-large')` | | Inline style | `style: 'css'` | `container c (style: 'padding: 16px;')` | -| Design properties | `designproperties: [...]` | `container c (designproperties: ['Spacing top': 'Large', 'full width': on])` | +| Design properties | `designproperties: (...)` | `container c (designproperties: ('Spacing top': 'Large', 'full width': on))` | ### FOLDER Option @@ -124,13 +124,13 @@ container c (style: 'background-color: #f8f9fa; padding: 16px;') { ... } **Design Properties** — Atlas UI structured properties (spacing, colors, toggles): ```sql -- Option property: 'Key': 'Value' -container c (designproperties: ['Spacing top': 'Large', 'Background color': 'Brand Primary']) { ... } +container c (designproperties: ('Spacing top': 'Large', 'Background color': 'Brand Primary')) { ... } -- Toggle property: 'Key': ON (enabled) or OFF (disabled/omitted) -container c (designproperties: ['Full width': on]) { ... } +container c (designproperties: ('Full width': on)) { ... } -- Multiple types combined -actionbutton btn (caption: 'Save', designproperties: ['Size': 'Large', 'Full width': on]) +actionbutton btn (caption: 'Save', designproperties: ('Size': 'Large', 'Full width': on)) ``` **Dynamic Classes** — a Mendix expression evaluated at runtime that returns a @@ -157,7 +157,7 @@ container ctnHero ( class: 'card', style: 'border-left: 4px solid #264AE5;', dynamicclasses: if $currentObject/Featured then 'is-featured' else '', - designproperties: ['Spacing top': 'Large', 'Full width': on] + designproperties: ('Spacing top': 'Large', 'Full width': on) ) { dynamictext txtTitle (content: 'Styled Container', rendermode: H3) } diff --git a/.claude/skills/mendix/create-page/reference/examples.md b/.claude/skills/mendix/create-page/reference/examples.md index 65c1b431c3..0ba8361a58 100644 --- a/.claude/skills/mendix/create-page/reference/examples.md +++ b/.claude/skills/mendix/create-page/reference/examples.md @@ -9,7 +9,7 @@ Supporting reference for [create-page](../SKILL.md). ```sql create or replace page CRM.CustomerEdit ( - params: { $Customer: CRM.Customer }, + params: ( $Customer: CRM.Customer ), title: 'Edit Customer', layout: Atlas_Core.PopupLayout ) @@ -82,8 +82,8 @@ create page CRM.Customer_MasterDetail dynamictext heading (content: 'Customers', rendermode: H3) gallery customerList (datasource: database from CRM.Customer sort by Name asc, selection: single) { template { - dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) - dynamictext email (content: '{1}', contentparams: [{1} = Email]) + dynamictext name (content: '{1}', contentparams: ({1} = Name), rendermode: H4) + dynamictext email (content: '{1}', contentparams: ({1} = Email)) } } } diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 7e75d8e763..aa73e7247b 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -17,13 +17,13 @@ dynamictext heading (content: 'Heading Text', rendermode: H2) dynamictext productName (content: '$Product.Name', rendermode: H3) -- Explicit template with page parameter binding -dynamictext greeting (content: 'Welcome, {1}!', contentparams: [{1} = $Customer.Name]) +dynamictext greeting (content: 'Welcome, {1}!', contentparams: ({1} = $Customer.Name)) -- Template with attribute from current DataView context (simple attribute name) -dynamictext email (content: 'Email: {1}', contentparams: [{1} = Email]) +dynamictext email (content: 'Email: {1}', contentparams: ({1} = Email)) -- Bind directly to an attribute of the surrounding DataView/ListView/Gallery --- entity. `Attribute: X` is shorthand for `content: '{1}', contentparams: [{1} = X]`. +-- entity. `Attribute: X` is shorthand for `content: '{1}', contentparams: ({1} = X)`. dynamictext title (Attribute: Title) ``` @@ -40,11 +40,11 @@ default (e.g. `5068.38000000`). ```sql -- Decimal: 2 decimals + thousands separator -> "5,068.38" -dynamictext amt (content: '{1}', contentparams: [{1} = Amount format (decimalPrecision: 2, groupDigits: true)]) +dynamictext amt (content: '{1}', contentparams: ({1} = Amount format (decimalPrecision: 2, groupDigits: true))) -- DateTime: date + time, or a custom pattern -dynamictext due (content: '{1}', contentparams: [{1} = DueOn format (dateFormat: DateTime)]) -dynamictext day (content: '{1}', contentparams: [{1} = DueOn format (dateFormat: Custom, customDateFormat: 'dd-MM-yyyy')]) +dynamictext due (content: '{1}', contentparams: ({1} = DueOn format (dateFormat: DateTime))) +dynamictext day (content: '{1}', contentparams: ({1} = DueOn format (dateFormat: Custom, customDateFormat: 'dd-MM-yyyy'))) ``` | Format key | Applies to | Values | @@ -198,13 +198,13 @@ name, which is why the keyword takes `for` and a qualified entity: ```sql listview vehicleListView (datasource: database from Pages.Vehicle) { -- the default body: used for an object no template matches - dynamictext defaultVehicle (content: '{1} {2}', contentparams: [{1} = Brand, {2} = Model]) + dynamictext defaultVehicle (content: '{1} {2}', contentparams: ({1} = Brand, {2} = Model)) template for Pages.Bus { - dynamictext busLabel (content: 'Bus, capacity {1}', contentparams: [{1} = PassengerCapacity]) + dynamictext busLabel (content: 'Bus, capacity {1}', contentparams: ({1} = PassengerCapacity)) } template for Pages.Truck { - dynamictext truckLabel (content: 'Truck, max load {1} kg', contentparams: [{1} = MaxLoadKg]) + dynamictext truckLabel (content: 'Truck, max load {1} kg', contentparams: ({1} = MaxLoadKg)) } } ``` @@ -322,7 +322,7 @@ datagrid gridName (datasource: database from Module.Entity) { Caption: 'Amount', ShowContentAs: dynamicText, Content: 'Amt: {1}', - ContentParams: [{1} = Amount format (decimalPrecision: 2, groupDigits: true)] + ContentParams: ({1} = Amount format (decimalPrecision: 2, groupDigits: true)) ) column due (attribute: DueOn, caption: 'Due') } @@ -549,8 +549,8 @@ gallery galleryName ( PhoneColumns: 1 ) { template { - dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) - dynamictext email (content: '{1}', contentparams: [{1} = Email]) + dynamictext name (content: '{1}', contentparams: ({1} = Name), rendermode: H4) + dynamictext email (content: '{1}', contentparams: ({1} = Email)) } } ``` @@ -562,8 +562,8 @@ gallery productGallery (datasource: database Module.Product, selection: single) textfilter searchName (attribute: Name) } template { - dynamictext prodName (content: '{1}', contentparams: [{1} = Name], rendermode: H4) - dynamictext prodCode (content: 'SKU: {1}', contentparams: [{1} = Code]) + dynamictext prodName (content: '{1}', contentparams: ({1} = Name), rendermode: H4) + dynamictext prodCode (content: 'SKU: {1}', contentparams: ({1} = Code)) } } ``` @@ -658,7 +658,7 @@ Embed a reusable snippet: snippetcall snippetName (snippet: Module.SnippetName) -- With parameters -snippetcall actions (snippet: Module.EntityActions, params: {entity: $Param}) +snippetcall actions (snippet: Module.EntityActions, params: (entity = $Param)) ``` **A parameter satisfied by the enclosing data context takes NO mapping.** Mendix @@ -672,12 +672,12 @@ dataview dvOrder (datasource: $Order) { } ``` -`params: {Order: $currentObject}` means the same thing and produces the same +`params: (Order = $currentObject)` means the same thing and produces the same (empty) mapping. Naming a real page parameter or variable produces a real mapping, as expected: ```sql -snippetcall scActions (snippet: MyModule.OrderActions, params: {Order: $Order}) +snippetcall scActions (snippet: MyModule.OrderActions, params: (Order = $Order)) ``` Omitting `Params:` where the context is a *different* entity is still an error — @@ -804,7 +804,7 @@ pluggablewidget 'com.mendix.widget.web.image.Image' cardImage ( -- numbered placeholders + contentparams: needed for several values, or a format block pluggablewidget 'com.mendix.widget.web.image.Image' cardImage ( datasource: imageUrl, - imageUrl: '{1}/{2}', contentparams: [{1} = BaseUrl, {2} = PictureUrl] + imageUrl: '{1}/{2}', contentparams: ({1} = BaseUrl, {2} = PictureUrl) ) -- `Params`: the property's OWN parameters. `contentparams` is one list @@ -812,8 +812,8 @@ pluggablewidget 'com.mendix.widget.web.image.Image' cardImage ( -- `alternativeText` to different attributes; this can (ako/mxcli#575). pluggablewidget 'com.mendix.widget.web.image.Image' cardImage ( datasource: imageUrl, - imageUrl: '{1}', imageUrlParams: [{1} = PictureUrl], - alternativeText: '{1}', alternativeTextParams: [{1} = Name] + imageUrl: '{1}', imageUrlParams: ({1} = PictureUrl), + alternativeText: '{1}', alternativeTextParams: ({1} = Name) ) ``` @@ -911,13 +911,13 @@ container card1 (class: 'card', style: 'padding: 16px;') { } -- Container with design properties -container spaced1 (designproperties: ['Spacing top': 'Large', 'Full width': on]) { +container spaced1 (designproperties: ('Spacing top': 'Large', 'Full width': on)) { dynamictext text1 (content: 'Spaced full-width content') } -- Nested containers with combined styling customcontainer outer1 (class: 'section') { - container inner1 (class: 'card', designproperties: ['Spacing top': 'Medium']) { + container inner1 (class: 'card', designproperties: ('Spacing top': 'Medium')) { dynamictext text1 (content: 'Nested content') } } diff --git a/.claude/skills/mendix/custom-widgets/SKILL.md b/.claude/skills/mendix/custom-widgets/SKILL.md index 62526e3a45..83b6aac102 100644 --- a/.claude/skills/mendix/custom-widgets/SKILL.md +++ b/.claude/skills/mendix/custom-widgets/SKILL.md @@ -142,8 +142,8 @@ gallery galleryName ( PhoneColumns: 1 ) { template { - dynamictext title (content: '{1}', contentparams: [{1} = Name], rendermode: H4) - dynamictext info (content: '{1}', contentparams: [{1} = Email]) + dynamictext title (content: '{1}', contentparams: ({1} = Name), rendermode: H4) + dynamictext info (content: '{1}', contentparams: ({1} = Email)) } filter { textfilter searchName (attribute: Name) @@ -456,7 +456,7 @@ Set `"templateFile": "mywidget.json"` in the .def.json. Project definitions over ```sql MYWIDGET myWidget1 (datasource: database Module.Entity, attribute: Name) { template content1 { - dynamictext label1 (content: '{1}', contentparams: [{1}=Name]) + dynamictext label1 (content: '{1}', contentparams: ({1}=Name)) } } ``` @@ -559,7 +559,7 @@ A `texttemplate` takes **text**, so a bare value renders the same string on ever row. Bind it with the property's own `Params` companion, named for whichever spelling the template used (`ImageUrl:` pairs with `ImageUrlParams:`) and taking the same `format (...)` block a `dynamictext` does — e.g. -`headerCaption: '{1}', headerCaptionParams: [{1} = Name]`, or a Timeline's +`headerCaption: '{1}', headerCaptionParams: ({1} = Name)`, or a Timeline's `title` / `description` bound separately. `contentparams:` is ONE list shared by every template on the widget, so it only disambiguates a widget with a single one; `'{AttrName}'` is the short form for one attribute. A companion whose diff --git a/.claude/skills/mendix/database-connections/SKILL.md b/.claude/skills/mendix/database-connections/SKILL.md index d5ebc3fd6c..eab17666e3 100644 --- a/.claude/skills/mendix/database-connections/SKILL.md +++ b/.claude/skills/mendix/database-connections/SKILL.md @@ -65,17 +65,22 @@ create constant MyModule.DbPassword type string ### Basic Connection Structure ```sql -create database connection Module.ConnectionName -type '' -connection string @Module.ConnectionStringConstant -username @Module.UsernameConstant -password @Module.PasswordConstant -begin - -- Query definitions go here -end; +create database connection Module.ConnectionName ( + Type: '', + ConnectionString: @Module.ConnectionStringConstant, + Username: @Module.UsernameConstant, + Password: @Module.PasswordConstant +) { + -- query definitions go here: query Name ( Sql: …, Returns: … ) +}; ``` -**The `@` is not optional.** `connection string`, `username` and `password` are +The connection's properties are in `( )` and its queries are its children, in +`{ }` — the shape of every declarative document (R2). The old clause form +(`type '…' connection string @… begin query … ; end`) still parses and warns +(MDL-DEPR127); `mxcli fmt --upgrade` rewrites it. + +**The `@` is not optional.** `ConnectionString`, `Username` and `Password` are ConstantIdentifier properties — Mendix stores a *reference to a Constant document*, never a value. The grammar accepts a bare string there, but writing one produces a project that **cannot be opened at all**: @@ -91,15 +96,15 @@ it as **MDL058** at both `check` and `exec`. ```sql -- WRONG — writes an unopenable .mpr -connection string 'jdbc:postgresql://localhost:5432/app' -username 'app' +ConnectionString: 'jdbc:postgresql://localhost:5432/app', +Username: 'app' -- RIGHT — declare the constant, then reference it create constant Module.DbUrl type String default 'jdbc:postgresql://localhost:5432/app'; create constant Module.DbUser type String default 'app'; -connection string @Module.DbUrl -username @Module.DbUser +ConnectionString: @Module.DbUrl, +Username: @Module.DbUser ``` The indirection is the point: the constant's value is per-environment, so a @@ -111,7 +116,7 @@ These are the values Studio Pro's own connector editor offers — read out of th shipped bundle at `modeler/ide-client/database-connector-editor/`, identical on 11.10.0, 11.12.1 and 11.13.0. -| Database | TYPE Value | Studio Pro label | +| Database | `Type:` value | Studio Pro label | |----------|------------|------------------| | SQL Server | `'MSSQL'` | Microsoft SQL | | MySQL | `'MySQL'` | MySQL | @@ -158,42 +163,50 @@ the file system disagree about where the dependency comes from. **`'Redshift'` and `'SQLServer'` are not real values.** Both appeared in an earlier version of this table and neither is in the picker on any version checked. mxcli writes the type string through unchanged and **mxbuild does not -validate it** — `type 'Redshift'` builds 0 errors and simply does not connect — +validate it** — `Type: 'Redshift'` builds 0 errors and simply does not connect — so `mxcli check` warns about an unrecognised type (MDL-DB01) rather than letting a green build hide it. ## Query Definition Syntax +A query is a child of the connection, with its properties in `( )`: +`Sql`, `Parameters`, `Returns` and `Map`. The SQL may be a string or `$$…$$`, +which needs no quote doubling. + ### Simple Query (No Parameters) ```sql -query QueryName - sql 'SELECT column1, column2 FROM table_name' - returns Module.EntityName; +query QueryName ( + Sql: 'SELECT column1, column2 FROM table_name', + Returns: Module.EntityName +) ``` ### Parameterized Query ```sql -query QueryName - sql 'SELECT * FROM table_name WHERE column = {paramName}' - parameter paramName: string - returns Module.EntityName; +query QueryName ( + Sql: 'SELECT * FROM table_name WHERE column = {paramName}', + Parameters: ( paramName: string ), + Returns: Module.EntityName +) ``` ### Query with Column Mapping -When database column names don't match entity attribute names: +When database column names don't match entity attribute names, `Map` binds each +attribute to its column — `Attribute = column`, the way a mapping side is written: ```sql -query QueryName - sql 'SELECT emp_id, emp_name, dept_no FROM employees' - returns Module.EmployeeRecord - map ( - emp_id as EmployeeId, - emp_name as EmployeeName, - dept_no as DepartmentNumber - ); +query QueryName ( + Sql: 'SELECT emp_id, emp_name, dept_no FROM employees', + Returns: Module.EmployeeRecord, + Map: ( + EmployeeId = emp_id, + EmployeeName = emp_name, + DepartmentNumber = dept_no + ) +) ``` ### Supported Parameter Types @@ -210,10 +223,10 @@ Parameters can include a test value for Studio Pro testing, or indicate they sho ```sql -- Test value (used in Studio Pro's Execute Query dialog) -parameter empName: string default 'Smith' +Parameters: ( empName: string default 'Smith' ) -- Test with NULL value -parameter optionalDate: datetime null +Parameters: ( optionalDate: datetime null ) ``` ## Complete Examples @@ -242,26 +255,29 @@ create non-persistent entity OracleDemo.EmpRecord ( ); -- Step 4: Create database connection -create database connection OracleDemo.HRDatabase -type 'Oracle' -connection string @OracleDemo.OracleConnectionString -username @OracleDemo.OracleUser -password @OracleDemo.OraclePassword -begin - query GetAllEmployees - sql 'SELECT EMPNO, ENAME, JOB, SAL, DEPTNO FROM EMP ORDER BY EMPNO' - returns OracleDemo.EmpRecord; - - query GetEmployeeByName - sql 'SELECT EMPNO, ENAME, JOB, SAL, DEPTNO FROM EMP WHERE ENAME = {empName}' - parameter empName: string - returns OracleDemo.EmpRecord; - - query GetHighEarners - sql 'SELECT EMPNO, ENAME, JOB, SAL, DEPTNO FROM EMP WHERE SAL >= {minSalary}' - parameter minSalary: decimal - returns OracleDemo.EmpRecord; -end; +create database connection OracleDemo.HRDatabase ( + Type: 'Oracle', + ConnectionString: @OracleDemo.OracleConnectionString, + Username: @OracleDemo.OracleUser, + Password: @OracleDemo.OraclePassword +) { + query GetAllEmployees ( + Sql: 'SELECT EMPNO, ENAME, JOB, SAL, DEPTNO FROM EMP ORDER BY EMPNO', + Returns: OracleDemo.EmpRecord + ) + + query GetEmployeeByName ( + Sql: 'SELECT EMPNO, ENAME, JOB, SAL, DEPTNO FROM EMP WHERE ENAME = {empName}', + Parameters: ( empName: string ), + Returns: OracleDemo.EmpRecord + ) + + query GetHighEarners ( + Sql: 'SELECT EMPNO, ENAME, JOB, SAL, DEPTNO FROM EMP WHERE SAL >= {minSalary}', + Parameters: ( minSalary: decimal ), + Returns: OracleDemo.EmpRecord + ) +}; ``` ### Example 2: PostgreSQL Connection @@ -280,33 +296,35 @@ create non-persistent entity Inventory.ProductRecord ( Price: decimal ); -create database connection Inventory.ProductDatabase -type 'PostgreSQL' -connection string @Inventory.PgConnectionString -username @Inventory.PgUser -password @Inventory.PgPassword -begin - query GetAllProducts - sql 'SELECT product_id, product_name, quantity, price FROM products' - returns Inventory.ProductRecord - map ( - product_id as ProductId, - product_name as ProductName, - quantity as Quantity, - price as Price - ); - - query SearchProducts - sql 'SELECT product_id, product_name, quantity, price FROM products WHERE product_name ILIKE {searchPattern}' - parameter searchPattern: string - returns Inventory.ProductRecord - map ( - product_id as ProductId, - product_name as ProductName, - quantity as Quantity, - price as Price - ); -end; +create database connection Inventory.ProductDatabase ( + Type: 'PostgreSQL', + ConnectionString: @Inventory.PgConnectionString, + Username: @Inventory.PgUser, + Password: @Inventory.PgPassword +) { + query GetAllProducts ( + Sql: 'SELECT product_id, product_name, quantity, price FROM products', + Returns: Inventory.ProductRecord, + Map: ( + ProductId = product_id, + ProductName = product_name, + Quantity = quantity, + Price = price + ) + ) + + query SearchProducts ( + Sql: 'SELECT product_id, product_name, quantity, price FROM products WHERE product_name ILIKE {searchPattern}', + Parameters: ( searchPattern: string ), + Returns: Inventory.ProductRecord, + Map: ( + ProductId = product_id, + ProductName = product_name, + Quantity = quantity, + Price = price + ) + ) +}; ``` ## Viewing Connections @@ -418,14 +436,15 @@ standalone JDBC harness before that. ### Parameterized Queries -Pass values for query parameters defined with `parameter` in the query definition: +Pass values for the query parameters declared in the query's `Parameters:` list: ```sql --- Query definition (in DATABASE CONNECTION block): --- QUERY GetDriversByNationality --- SQL 'SELECT * FROM drivers WHERE nationality = {nation}' --- PARAMETER nation: String --- RETURNS Module.DriverRecord; +-- Query definition (in the database connection's { } block): +-- query GetDriversByNationality ( +-- Sql: 'SELECT * FROM drivers WHERE nationality = {nation}', +-- Parameters: ( nation: String ), +-- Returns: Module.DriverRecord +-- ) -- Microflow execution: $Drivers = execute database query Module.Connection.GetDriversByNationality @@ -463,23 +482,25 @@ create constant HR.DbUrl type string default 'jdbc:postgresql://localhost:5432/h create constant HR.DbUser type string default 'app'; create constant HR.DbPass type string default ''; -create database connection HR.MainDB -type 'PostgreSQL' -connection string @HR.DbUrl -username @HR.DbUser -password @HR.DbPass -begin - query GetAllEmployees - sql 'SELECT emp_id, name, department FROM employees' - returns HR.EmployeeRecord - map (emp_id as EmpId, name as Name, department as Department); - - query GetByDepartment - sql 'SELECT emp_id, name, department FROM employees WHERE department = {dept}' - parameter dept: string - returns HR.EmployeeRecord - map (emp_id as EmpId, name as Name, department as Department); -end; +create database connection HR.MainDB ( + Type: 'PostgreSQL', + ConnectionString: @HR.DbUrl, + Username: @HR.DbUser, + Password: @HR.DbPass +) { + query GetAllEmployees ( + Sql: 'SELECT emp_id, name, department FROM employees', + Returns: HR.EmployeeRecord, + Map: (EmpId = emp_id, Name = name, Department = department) + ) + + query GetByDepartment ( + Sql: 'SELECT emp_id, name, department FROM employees WHERE department = {dept}', + Parameters: ( dept: string ), + Returns: HR.EmployeeRecord, + Map: (EmpId = emp_id, Name = name, Department = department) + ) +}; -- Microflow that executes the query create microflow HR.ACT_LoadEmployees($Department: string) diff --git a/.claude/skills/mendix/fragments/SKILL.md b/.claude/skills/mendix/fragments/SKILL.md index de133d3ca7..487408a169 100644 --- a/.claude/skills/mendix/fragments/SKILL.md +++ b/.claude/skills/mendix/fragments/SKILL.md @@ -52,7 +52,7 @@ Inside a page or snippet body: ```mdl create page Module.CustomerEdit ( - params: { $Customer: Module.Customer }, + params: ( $Customer: Module.Customer ), title: 'Edit Customer', layout: Atlas_Core.PopupLayout ) @@ -80,7 +80,7 @@ widgets should land, then fill it with the `use fragment X { … }` payload form ```mdl create fragment Card as { - container cardWrap (class: 'card', designproperties: ['Card style': on]) { + container cardWrap (class: 'card', designproperties: ('Card style': on)) { container cardBody (class: 'card-body') { slot content -- caller's widgets are spliced in here } diff --git a/.claude/skills/mendix/manage-navigation/SKILL.md b/.claude/skills/mendix/manage-navigation/SKILL.md index 07cb796d95..4c68c074bf 100644 --- a/.claude/skills/mendix/manage-navigation/SKILL.md +++ b/.claude/skills/mendix/manage-navigation/SKILL.md @@ -31,7 +31,7 @@ Use when the user asks to: - **Offline Profiles** — An offline profile makes the app work without a connection (a PWA with a local database that syncs). It is not a different document — same properties as its online twin — but it **constrains every page it can reach**, see below. - **Home Page** — The default page shown after login. Can be a PAGE or MICROFLOW. - **Role-Based Home Pages** — Override the default home page per user role (e.g., admins see a dashboard, users see a task list). -- **Menu Items** — Hierarchical menu tree. Each item has a caption and optionally targets a PAGE or MICROFLOW. Sub-menus nest with `menu 'caption' (...)`. +- **Menu Items** — Hierarchical menu tree. Each item has a caption and optionally targets a PAGE or MICROFLOW. Sub-menus nest with `menu 'caption' { ... }`. - **Menu Documents** — A *separate* document type (`Menus$MenuDocument`) holding a reusable menu that a menu widget points at, e.g. Atlas_Core's `Phone_Menu`. Not the same thing as a profile's menu, though both are built from the same items, so the item syntax is identical. Managed with `create/describe/drop menu` — see below. - **Login Page** — Custom login page (optional; Mendix provides a default). - **Not-Found Page** — Custom 404 page (optional). @@ -97,25 +97,29 @@ roles called `Administrator` in three modules. List the real ones with ### Full Menu Tree -The `menu (...)` block replaces the entire menu. Use `menu item` for leaf items and `menu 'caption' (...)` for sub-menus: +The menu items are the profile's children, in `{ ... }` after its clauses, like a page's widgets — no `;` between them. The block replaces the entire menu. Use `menu item 'Caption' ( OnClick: … )` for leaf items and `menu 'caption' { ... }` for sub-menus. The action is `OnClick:` in the words a page action uses: `show page M.P`, `call microflow M.F`, or `sign out`: ```sql create or replace navigation Responsive home page MyModule.Home_Web login page Administration.Login - menu ( - menu item 'Home' page MyModule.Home_Web; - menu 'Orders' ( - menu item 'All Orders' page Orders.Order_Overview; - menu item 'New Order' page Orders.Order_New; - ); - menu 'Admin' ( - menu item 'Users' page Administration.Account_Overview; - menu item 'Run Report' microflow Reports.ACT_GenerateReport; - ); - ); + { + menu item 'Home' ( OnClick: show page MyModule.Home_Web ) + menu 'Orders' { + menu item 'All Orders' ( OnClick: show page Orders.Order_Overview ) + menu item 'New Order' ( OnClick: show page Orders.Order_New ) + } + menu 'Admin' { + menu item 'Users' ( OnClick: show page Administration.Account_Overview ) + menu item 'Run Report' ( OnClick: call microflow Reports.ACT_GenerateReport ) + } + }; ``` +The old spelling — `menu ( menu item 'Home' page M.Home; menu 'Admin' ( … ); )`, +the action and icon as clauses and `;` after each item — still parses and warns +(MDL-DEPR121, MDL-DEPR122); `mxcli fmt --upgrade` rewrites it. + ### Menu Icons **Give every menu item an icon.** It is optional in the grammar and `mxcli check` @@ -125,14 +129,15 @@ shows its icon, and one without falls back to the first few characters of its caption — rarely enough to tell `Orders` from `Order lines`. Nothing else catches it. The model builds, `mx check` passes, and the menu is simply hard to use. -Both `menu item` and `menu 'caption' (...)` take an `icon`, in one of three -forms — Mendix stores three different icon **elements**, not three spellings of -one value: +Both `menu item` and `menu 'caption' { ... }` take an `Icon:` in their property +list, in one of three forms — Mendix stores three different icon **elements**, not +three spellings of one value: ```sql -menu item 'Home' page M.Home icon Atlas_Core.Atlas.home; -- icon collection -menu item 'Close' page M.Close icon glyph 57377; -- numeric glyph code -menu item 'Logo' page M.Logo icon image M.Images.logo; -- image collection +menu item 'Home' ( OnClick: show page M.Home, Icon: Atlas_Core.Atlas.home ) -- icon collection +menu item 'Close' ( OnClick: show page M.Close, Icon: glyph 57377 ) -- numeric glyph code +menu item 'Logo' ( OnClick: show page M.Logo, Icon: image M.Images.logo ) -- image collection +menu 'Admin' ( Icon: Atlas_Core.Atlas.user ) { … } -- a sub-menu's icon ``` The **bare** form is the icon-collection icon and is what you normally want. Use @@ -148,7 +153,7 @@ not the cause. `mxcli check` now warns (**MDL078**) against the 247 codes the shipped font defines, but a glyph is still an unchecked number where an icon collection reference is a resolved model reference. Browse the codes with `show glyphs` (`show glyphs like 'star'` searches by name, `describe glyph 57350` goes the -other way), or use `icon Atlas_Core.Atlas.` and list the names with +other way), or use `Icon: Atlas_Core.Atlas.` and list the names with `describe icon collection Atlas_Core.Atlas`. The icon-collection form is a **qualified name** — a model reference, written @@ -157,13 +162,13 @@ like every other reference in MDL, not a string: ```sql create or replace navigation Responsive home page MyModule.Home_Web - menu ( - menu item 'Home' page MyModule.Home_Web icon Atlas_Core.Atlas.home; - menu 'Orders' icon Atlas_Core.Atlas."shopping-cart" ( - menu item 'All Orders' page Orders.Order_Overview - icon Atlas_Core.Atlas."list-bullets"; - ); - ); + { + menu item 'Home' ( OnClick: show page MyModule.Home_Web, Icon: Atlas_Core.Atlas.home ) + menu 'Orders' ( Icon: Atlas_Core.Atlas."shopping-cart" ) { + menu item 'All Orders' ( OnClick: show page Orders.Order_Overview, + Icon: Atlas_Core.Atlas."list-bullets" ) + } + }; ``` **Hyphenated names are double-quoted** (`Atlas_Core.Atlas."align-center"`) — @@ -182,11 +187,11 @@ describe icon collection Atlas_Core.Atlas; *glyph* icon (a numeric character code) or an *image* icon (pointing into an image collection). Those are different elements with different fields, so MDL does not write them — and `describe navigation` reports them as a comment rather -than emitting an `icon` clause that would silently convert one into the other on +than emitting an `Icon:` that would silently convert one into the other on replay: ``` -menu item 'Close' page MyModule.Close; +menu item 'Close' ( OnClick: show page MyModule.Close ) -- icon System.Images.Close (Forms$ImageIcon) is not reproducible by CREATE NAVIGATION; set it in Studio Pro ``` @@ -239,7 +244,7 @@ doubled — and a stored constraint already carries Mendix's own escaping, so th two compose into runs of six quotes. `describe navigation` emits the bracket form. This is the general problem tracked as `mendixlabs/mxcli#750`. -**The block replaces the stored list**, the way `menu (...)` replaces the menu. +**The block replaces the stored list**, the way the `{ ... }` menu block replaces the menu. Omitting it leaves the stored configuration alone. **Ask the catalog which entities sync, rather than reading the profile.** @@ -280,12 +285,12 @@ silently dropped. ### Clear the Menu -An empty `menu ()` block removes all menu items: +An empty `{ }` menu block removes all menu items: ```sql create or replace navigation Responsive home page MyModule.Home_Web - menu (); + {}; ``` ### Not-Found Page @@ -317,10 +322,10 @@ describe navigation Responsive; create or replace navigation Responsive home page MyModule.Home_Web login page Administration.Login - menu ( - menu item 'Home' page MyModule.Home_Web; - menu item 'New Feature' page MyModule.NewFeature; - ); + { + menu item 'Home' ( OnClick: show page MyModule.Home_Web ) + menu item 'New Feature' ( OnClick: show page MyModule.NewFeature ) + }; -- Step 3: Verify describe navigation Responsive; @@ -370,9 +375,9 @@ create page MyModule.Home_Web -- Configure navigation create or replace navigation Responsive home page MyModule.Home_Web - menu ( - menu item 'Home' page MyModule.Home_Web; - ); + { + menu item 'Home' ( OnClick: show page MyModule.Home_Web ) + }; ``` ### Adding a New Page to Navigation @@ -387,13 +392,13 @@ describe navigation Responsive; create or replace navigation Responsive home page MyModule.Home_Web login page Administration.Login - menu ( - menu item 'Home' page MyModule.Home_Web; - menu item 'Customers' page MyModule.Customer_Overview; -- new - menu 'Admin' ( - menu item 'Users' page Administration.Account_Overview; - ); - ); + { + menu item 'Home' ( OnClick: show page MyModule.Home_Web ) + menu item 'Customers' ( OnClick: show page MyModule.Customer_Overview ) -- new + menu 'Admin' { + menu item 'Users' ( OnClick: show page Administration.Account_Overview ) + } + }; ``` ## Menu Documents (standalone, reusable) @@ -410,17 +415,17 @@ show navigation menu; -- the menu inside each profile describe menu Atlas_Core.Phone_Menu; -- a standalone menu document ``` -Menu documents use the same item syntax as the profile `menu (...)` block: +Menu documents use the same item syntax as the profile's `{ ... }` menu block: ```sql -create or modify menu MyModule.Main_Menu ( - menu item 'Home' page MyModule.Home_Web icon Atlas_Core.Atlas.home; - menu item 'Run' microflow MyModule.DoThing; - menu 'Admin' ( - menu item 'Accounts' page Administration.Account_Overview; - ); - menu item 'Plain'; -); +create or modify menu MyModule.Main_Menu { + menu item 'Home' ( OnClick: show page MyModule.Home_Web, Icon: Atlas_Core.Atlas.home ) + menu item 'Run' ( OnClick: call microflow MyModule.DoThing ) + menu 'Admin' { + menu item 'Accounts' ( OnClick: show page Administration.Account_Overview ) + } + menu item 'Plain' +}; drop menu MyModule.Main_Menu; ``` @@ -454,9 +459,9 @@ An offline profile is created the same way as any other: ```sql create or replace navigation TabletOffline home page Maintenance.Request_Overview - menu ( - menu item 'Requests' page Maintenance.Request_Overview; - ); + { + menu item 'Requests' ( OnClick: show page Maintenance.Request_Overview ) + }; ``` Two things about it are worth knowing before you do. @@ -503,9 +508,9 @@ stored. MDL does not author per-entity sync modes — set those in Studio Pro. - [ ] For an **offline** profile, no page it can reach binds an attribute across more than one association (CE6206) - [ ] All PAGE/MICROFLOW targets are fully qualified (`Module.Name`) - [ ] Role references in `for` clauses are fully qualified (`Module.Role`) -- [ ] Every `menu item` and `menu 'caption' (...)` ends with `;` -- [ ] Sub-menu items are wrapped in `menu 'caption' ( ... );` -- [ ] `icon` is a qualified name (not a string); hyphenated segments are double-quoted +- [ ] Menu items are in `{ }` with no `;` between them; sub-menu items in `menu 'caption' { ... }` +- [ ] A menu item's action is `( OnClick: show page M.P )`, `call microflow M.F` or `sign out` +- [ ] `Icon:` is a qualified name (not a string); hyphenated segments are double-quoted - [ ] The icon exists — check with `describe icon collection Module.Name`, do not guess - [ ] Use `describe navigation` to verify changes after applying - [ ] For a **menu document**, confirm you want `create menu` and not a profile menu — `show navigation menu` vs `describe menu` tells them apart @@ -517,4 +522,4 @@ an offline navigation profile downloads **nothing** until each entity has a sync ## Menu documents (CREATE OR MODIFY/DESCRIBE/DROP MENU) -standalone `Menus$MenuDocument`, the reusable menu a menu widget points at (Atlas_Core's `Phone_Menu`/`Tablet_Menu`) — **not** the menu inside a navigation profile, though both are built from the same items, so the item syntax is shared with `CREATE NAVIGATION`'s `MENU (...)` block. DESCRIBE is round-trippable. Written through gen+codec, which is load-bearing: Studio Pro's menu documents carry typed-array marker **3** on the item collection and each item's sub-items (the codec default), while the navigation writers hand-build items with marker **1** — unverified whether that is a latent navigation bug or a real difference, so navigation is left alone. Authoring is modelsdk-only; legacy refuses. Two traps: a menu item cannot open a page with required parameters (**CE1571**), and only `Forms$IconCollectionIcon` round-trips (glyph/image icons are flagged by DESCRIBE, not dropped silently) +standalone `Menus$MenuDocument`, the reusable menu a menu widget points at (Atlas_Core's `Phone_Menu`/`Tablet_Menu`) — **not** the menu inside a navigation profile, though both are built from the same items, so the item syntax is shared with `CREATE NAVIGATION`'s `{ ... }` menu block. DESCRIBE is round-trippable. Written through gen+codec, which is load-bearing: Studio Pro's menu documents carry typed-array marker **3** on the item collection and each item's sub-items (the codec default), while the navigation writers hand-build items with marker **1** — unverified whether that is a latent navigation bug or a real difference, so navigation is left alone. Authoring is modelsdk-only; legacy refuses. Two traps: a menu item cannot open a page with required parameters (**CE1571**), and only `Forms$IconCollectionIcon` round-trips (glyph/image icons are flagged by DESCRIBE, not dropped silently) diff --git a/.claude/skills/mendix/master-detail-pages/SKILL.md b/.claude/skills/mendix/master-detail-pages/SKILL.md index f60378fefc..de5266670d 100644 --- a/.claude/skills/mendix/master-detail-pages/SKILL.md +++ b/.claude/skills/mendix/master-detail-pages/SKILL.md @@ -28,7 +28,7 @@ create page Module.Entity_MasterDetail column (desktopwidth: 4) { gallery entityList (datasource: database Module.Entity, selection: single) { template { - dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) + dynamictext name (content: '{1}', contentparams: ({1} = Name), rendermode: H4) } } } @@ -59,7 +59,7 @@ gallery widgetName ( ) { template template1 { -- Widgets for each item - dynamictext name (content: '{1}', contentparams: [{1} = AttrName], rendermode: H4) + dynamictext name (content: '{1}', contentparams: ({1} = AttrName), rendermode: H4) } } ``` @@ -110,8 +110,8 @@ create page CRM.Customer_MasterDetail dynamictext heading (content: 'Customers', rendermode: H3) gallery customerList (datasource: database from CRM.Customer sort by Name asc, selection: single) { template { - dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) - dynamictext email (content: '{1}', contentparams: [{1} = Email]) + dynamictext name (content: '{1}', contentparams: ({1} = Name), rendermode: H4) + dynamictext email (content: '{1}', contentparams: ({1} = Email)) } } } @@ -154,8 +154,8 @@ The selection binding uses widget names to connect: Inside Gallery templates, use `contentparams` to reference current item attributes: ```sql template template1 { - dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) - dynamictext email (content: '{1}', contentparams: [{1} = Email]) + dynamictext name (content: '{1}', contentparams: ({1} = Name), rendermode: H4) + dynamictext email (content: '{1}', contentparams: ({1} = Email)) } ``` @@ -174,7 +174,7 @@ template template1 { | Attribute binding | `attribute: attributename` | | Action binding | `action: save changes` | | Button style | `buttonstyle: success` | -| Text content | `content: 'text'` with `contentparams: [{1} = attr]` | +| Text content | `content: 'text'` with `contentparams: ({1} = attr)` | | Render mode | `rendermode: H4` | | Template content | `template template1 { ... }` | diff --git a/.claude/skills/mendix/migrate-design-prototype/SKILL.md b/.claude/skills/mendix/migrate-design-prototype/SKILL.md index d270f7a2ed..6e82e38d2c 100644 --- a/.claude/skills/mendix/migrate-design-prototype/SKILL.md +++ b/.claude/skills/mendix/migrate-design-prototype/SKILL.md @@ -57,7 +57,7 @@ gives you *before* hand-writing custom SCSS. In order of preference: 2. **Atlas utility classes and typed design properties** — `class:'card'`, `class:'btn btn-primary'`, `spacing-inner-*`/`spacing-outer-*` for padding/margin, `flex-row`/`flex-column` + `align-x-*`/`align-y-*` for layout (no `layoutgrid` needed); or the typed equivalents - `designproperties: ['Card style': on]`, `['Background color': 'Brand Primary']`, + `designproperties: ('Card style': on)`, `['Background color': 'Brand Primary']`, `['Spacing': ['margin-bottom': 'L']]`. `mxcli check -p` validates design-property keys and values (MDL-WIDGET11/12) and lists the allowed values. 3. **Brand-token retune** — build the palette with `mxcli theme create --from` @@ -485,7 +485,7 @@ create or replace page ResourceScheduling.ResourceHeatmap ( Class: 'ss-panel ss-heat-lv' ) { container heatRow (Class: 'ss-heat-row') { - dynamictext hc01 (Content: '{1}', ContentParams: [{1} = M01], Class: 'ss-heat-cell') + dynamictext hc01 (Content: '{1}', ContentParams: ({1} = M01), Class: 'ss-heat-cell') } } } @@ -646,10 +646,10 @@ the fast index so a design migration doesn't rediscover them. children bind to the related entity: ``` dataview dvEmp (datasource: $currentObject/Module.Entity_Related) { - dynamictext n (content: 'By {1}', contentparams: [{1} = Name]) -- own attr of the related entity + dynamictext n (content: 'By {1}', contentparams: ({1} = Name)) -- own attr of the related entity } ``` - - An **association path** for a single inline value: `contentparams: [{1} = Entity_Related/Name]` + - An **association path** for a single inline value: `contentparams: ({1} = Entity_Related/Name)` in a `dynamictext`, or `attribute: Entity_Related/Name` on a DataGrid2 column — both persist as an AttributeRef over the association (use a **bare** association name; a module-qualified one is rejected on a column). @@ -690,7 +690,7 @@ the fast index so a design migration doesn't rediscover them. - [ ] Persistent sidebar/topbar built as **navigation profile + layout + CSS**, not per-page widgets; new screens added via `CREATE OR REPLACE NAVIGATION` - [ ] Shell **restructured** to the design (full-height sidebar via `position:fixed` + `margin-left` offset; region sizes forced with `flex-basis`; collapse toggle hidden if unused) - [ ] Multi-part chrome (brand block, user chip) rendered as **inline-SVG backgrounds**; single-style chrome (labels, dots, footer) as plain `::before`/`::after` -- [ ] Related (to-one) object attributes shown via a **nested "data from context" DataView** (full read view) or an **association path** (`contentparams: [{1} = Assoc/Attr]` / column `attribute: Assoc/Attr`) for a single inline value — both persist +- [ ] Related (to-one) object attributes shown via a **nested "data from context" DataView** (full read view) or an **association path** (`contentparams: ({1} = Assoc/Attr)` / column `attribute: Assoc/Attr`) for a single inline value — both persist - [ ] A real **`docker build`** run after each slice (not just `mxcli check`) before screenshotting - [ ] Widgets styled with `Class:`; data-driven state via `DynamicClasses:` - [ ] No inline `Style:` on any DYNAMICTEXT diff --git a/.claude/skills/mendix/migrate-k2-nintex/SKILL.md b/.claude/skills/mendix/migrate-k2-nintex/SKILL.md index 0da04733e2..720698e619 100644 --- a/.claude/skills/mendix/migrate-k2-nintex/SKILL.md +++ b/.claude/skills/mendix/migrate-k2-nintex/SKILL.md @@ -180,7 +180,7 @@ SmartForms Views map to Mendix pages/snippets. The rules system (event-driven, " ```sql create page CRM.Customer_Edit ( - params: { $Customer: CRM.Customer }, + params: ( $Customer: CRM.Customer ), title: 'Edit Customer', layout: Atlas_Core.PopupLayout ) diff --git a/.claude/skills/mendix/overview-pages/SKILL.md b/.claude/skills/mendix/overview-pages/SKILL.md index 3faef3acb5..9a2a11debf 100644 --- a/.claude/skills/mendix/overview-pages/SKILL.md +++ b/.claude/skills/mendix/overview-pages/SKILL.md @@ -41,7 +41,7 @@ create snippet Module.Entity_Menu ```sql create [or replace] snippet Module.SnippetName [( - params: { $ParamName: Module.EntityType } + params: ( $ParamName: Module.EntityType ) )] [folder 'path'] { @@ -96,7 +96,7 @@ create page Module.Entity_Overview datasource: database Module.Entity, selection: Multi, PagingPosition: both, - designproperties: ['Compact': on, 'Hover': on, 'Striped': on] + designproperties: ('Compact': on, 'Hover': on, 'Striped': on) ) { column (attribute: Name, caption: 'Name') { textfilter textFilter1 @@ -124,7 +124,7 @@ Include a snippet in a page using SNIPPETCALL: snippetcall widgetName (snippet: Module.SnippetName) -- With parameters (for parameterized snippets): -snippetcall widgetName (snippet: Module.SnippetName, params: {Customer: $Customer}) +snippetcall widgetName (snippet: Module.SnippetName, params: (Customer = $Customer)) ``` ### Overview Page Components @@ -141,7 +141,7 @@ datagrid GridName ( datasource: database from Module.Entity where [IsActive = true] sort by Name asc, selection: Multi, PagingPosition: both, - designproperties: ['Compact': on, 'Hover': on, 'Striped': on] + designproperties: ('Compact': on, 'Hover': on, 'Striped': on) ) { column (attribute: Name, caption: 'Name') { textfilter textFilter1 @@ -163,7 +163,7 @@ datagrid GridName ( thing (mendixlabs/mxcli#1152) - `selection: Multi` - Multi-selection (`Multi`, `Single`, or omit for none) - `PagingPosition: both` - Pagination bar position (`top`, `bottom`, `both`) -- `designproperties: ['Compact': on, 'Hover': on, 'Striped': on]` - Atlas design tokens +- `designproperties: ('Compact': on, 'Hover': on, 'Striped': on)` - Atlas design tokens **Column Types:** - `column (attribute: attribute, caption: 'label')` - Attribute column (own-entity attribute) @@ -236,7 +236,7 @@ Form for creating or editing a single entity. **Requires a page parameter** to r ```sql create page Module.Entity_NewEdit ( - params: { $entity: Module.Entity }, + params: ( $entity: Module.Entity ), title: 'Edit Entity', layout: Atlas_Core.PopupLayout, folder: 'OverviewPages' @@ -268,7 +268,7 @@ create page Module.Entity_NewEdit ```sql create page Module.PageName ( - params: { $ParamName: Module.EntityName }, + params: ( $ParamName: Module.EntityName ), title: '...', layout: ... ) @@ -281,7 +281,7 @@ create page Module.PageName ### NewEdit Page Components -1. **Page Parameter**: `params: { $entity: Module.Entity }` - Receives the object to edit +1. **Page Parameter**: `params: ( $entity: Module.Entity )` - Receives the object to edit 2. **Layout**: `Atlas_Core.PopupLayout` - Popup/modal style 3. **DataView**: Container bound to page parameter (`datasource: $entity`) 4. **Input Widgets**: Match entity attributes with `attribute:` property @@ -354,7 +354,7 @@ create page MdlTemplates.Store_Overview ```sql create page MdlTemplates.Store_NewEdit ( - params: { $store: MdlTemplates.Store }, + params: ( $store: MdlTemplates.Store ), title: 'Edit Store', layout: Atlas_Core.PopupLayout, folder: 'OverviewPages' @@ -407,7 +407,7 @@ Shows various input widget types: ```sql create page MdlTemplates.Car_NewEdit ( - params: { $Car: MdlTemplates.Car }, + params: ( $Car: MdlTemplates.Car ), title: 'Edit Car', layout: Atlas_Core.PopupLayout, folder: 'OverviewPages' @@ -498,7 +498,7 @@ module/ ## Parameterized Snippets Snippets can accept parameters to display context-specific data. **A snippet -parameter must be an entity.** A primitive one (`params: { $Label: String }`) is +parameter must be an entity.** A primitive one (`params: ( $Label: String )`) is refused as **MDL087**, because Mendix rejects it with **CE0046** *"Invalid data type 'String'."* — a *page* parameter may be a primitive, a snippet parameter may not. To parameterise a snippet on a value, keep the primitive on the calling @@ -508,7 +508,7 @@ page's parameters, or pass an object and read the member inside the snippet. -- Create a snippet with a parameter create snippet Module.CustomerDetails ( - params: { $Customer: Module.Customer } + params: ( $Customer: Module.Customer ) ) { layoutgrid detailsGrid { @@ -521,7 +521,7 @@ create snippet Module.CustomerDetails } -- Use the snippet with parameter passing -snippetcall customerDetails (snippet: Module.CustomerDetails, params: {Customer: $Customer}) +snippetcall customerDetails (snippet: Module.CustomerDetails, params: (Customer = $Customer)) ``` ## Entity Menu Snippets with NavigationList @@ -531,7 +531,7 @@ For entity-specific action menus (Edit, Delete, etc.), use the `navigationlist` ```sql create snippet Module.Entity_Menu ( - params: { $EntityParameter: Module.Entity } + params: ( $EntityParameter: Module.Entity ) ) { navigationlist EntityMenuNav { @@ -604,7 +604,7 @@ create snippet Module.NavigationMenu -- Step 2: Create all pages (they reference the snippet via SNIPPETCALL) create page Module.Customer_NewEdit ( - params: { $Customer: Module.Customer }, + params: ( $Customer: Module.Customer ), title: 'Edit Customer', layout: Atlas_Core.PopupLayout ) diff --git a/.claude/skills/mendix/resolve-forward-references/SKILL.md b/.claude/skills/mendix/resolve-forward-references/SKILL.md index a57a1111ec..3542a473d8 100644 --- a/.claude/skills/mendix/resolve-forward-references/SKILL.md +++ b/.claude/skills/mendix/resolve-forward-references/SKILL.md @@ -133,7 +133,7 @@ page must exist before the overview can reference it. create page MyModule.Customer_NewEdit ( - params: { $Customer: MyModule.Customer }, + params: ( $Customer: MyModule.Customer ), title: 'Edit Customer', layout: Atlas_Core.PopupLayout ) @@ -181,7 +181,7 @@ For simple cases, reordering declarations is sufficient and no placeholder is ne -- Page first create page MyModule.Order_Detail ( - params: { $Order: MyModule.Order }, + params: ( $Order: MyModule.Order ), title: 'Order Detail', layout: Atlas_Core.Atlas_Default ) @@ -267,7 +267,7 @@ create snippet MyModule.AppNav -- 2. NewEdit page (referenced by Overview's New button) create page MyModule.Customer_NewEdit ( - params: { $Customer: MyModule.Customer }, + params: ( $Customer: MyModule.Customer ), title: 'Edit Customer', layout: Atlas_Core.PopupLayout ) diff --git a/.claude/skills/mendix/rest-client/SKILL.md b/.claude/skills/mendix/rest-client/SKILL.md index f42788c556..d08cd4f8f1 100644 --- a/.claude/skills/mendix/rest-client/SKILL.md +++ b/.claude/skills/mendix/rest-client/SKILL.md @@ -85,7 +85,7 @@ create consumed rest service Module.OpenMeteoAPI ( method: get, path: '/forecast', query: ($latitude: decimal, $longitude: decimal, $current: string), - headers: ('Accept' = 'application/json'), + headers: ('Accept': 'application/json'), timeout: 30, response: json as $WeatherJson ) @@ -93,7 +93,7 @@ create consumed rest service Module.OpenMeteoAPI ( operation PostData ( method: post, path: '/submit', - headers: ('Content-Type' = 'application/json'), + headers: ('Content-Type': 'application/json'), body: json from $JsonPayload, response: none ) @@ -421,7 +421,7 @@ create consumed rest service Module.WeatherAPI ( method: get, path: '/forecast', query: ($latitude: decimal, $longitude: decimal, $current: string), - headers: ('Accept' = 'application/json'), + headers: ('Accept': 'application/json'), response: json as $Result ) }; diff --git a/.claude/skills/mendix/theme-styling/SKILL.md b/.claude/skills/mendix/theme-styling/SKILL.md index aaf19b3556..b8164731e0 100644 --- a/.claude/skills/mendix/theme-styling/SKILL.md +++ b/.claude/skills/mendix/theme-styling/SKILL.md @@ -367,10 +367,10 @@ Two surprises when styling a **DataGrid2** matrix/pivot (ledger finding #46): Keys must match the `name` field in `design-properties.json` exactly: ```sql -- CORRECT -designproperties: ['Spacing top': 'Large'] +designproperties: ('Spacing top': 'Large') -- WRONG (case mismatch — silently ignored) -designproperties: ['spacing top': 'Large'] +designproperties: ('spacing top': 'Large') ``` ### Compound (Nested) Design Properties @@ -381,11 +381,11 @@ one whose value is itself a set of sub-properties (e.g. Atlas's `Spacing` → `margin-top`, `margin-bottom`, …). A compound value is written as a nested list: ```sql -designproperties: [ +designproperties: ( 'Column gap': 'Medium', -- flat option 'Cards style': ON, -- flat toggle - 'Spacing': ['margin-top': 'Large', 'margin-bottom': 'Medium'] -- compound -] + 'Spacing': ('margin-top': 'Large', 'margin-bottom': 'Medium') -- compound +) ``` Supported on the **modelsdk** (`.mpr`) and **MCP** (live Studio Pro) backends. diff --git a/.claude/skills/mendix/write-layouts/SKILL.md b/.claude/skills/mendix/write-layouts/SKILL.md index 2e17bccd5a..401963067b 100644 --- a/.claude/skills/mendix/write-layouts/SKILL.md +++ b/.claude/skills/mendix/write-layouts/SKILL.md @@ -160,9 +160,9 @@ instead of its own. The fix is a phone layout of your own whose bottom bar names your own menu document, not an edit to Atlas's layout: ```sql -create or modify menu MyModule.Phone_Menu ( - menu item 'Home' page MyModule.Home_Phone icon Atlas_Core.Atlas.home; -); +create or modify menu MyModule.Phone_Menu { + menu item 'Home' ( OnClick: show page MyModule.Home_Phone, Icon: Atlas_Core.Atlas.home ) +}; create or replace layout MyModule.Phone_Bottom ( layouttype: 'Phone', class: 'layout-atlas layout-atlas-phone' ) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 40db61988d..e03fbcc431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Session commands belong to the REPL** (ako/mxcli#755, R7) — `connect`, `disconnect`, `use`, `set = …`, `status`, `check`, `build`, `lint`, `debug`, `execute script`, `execute runtime`, `help` and `introspect api` need a session, not a model. Under the `mdl 1;` preview header a script that holds one is refused; without the header it runs as before and `check` / `exec` warn **MDL-V1-SESSION**. The REPL keeps accepting them. Put the session on the command line instead: `mxcli exec script.mdl -p app.mpr --json`. `exit` / `quit` are not session commands. - **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. +- **Menus, property maps and the database connection take R2's brackets** (ako/mxcli#754, R2) — menu items are children in `{ }` with no `;` between them, and an item's action and icon are its properties: `create or modify navigation Responsive home page Shop.Home { menu item 'Home' ( OnClick: show page Shop.Home, Icon: Atlas_Core.Atlas.home ) menu 'Admin' { menu item 'Users' ( OnClick: call microflow Shop.ShowUsers ) } };`, and the same items in `create menu M.Main { … }`. The property maps are in `( )`: a page's or snippet's `Params: ( $Order: Shop.Order )` and `Variables: ( $show: Boolean = 'true' )`, `ContentParams: ({1} = Name)` (also `CaptionParams` and `Params`), `DesignProperties: ('Spacing': ('margin-top': 'Large'), 'Full width': on)`, a snippet call's arguments `Params: (Asset = $Asset)` (bound as at every call site, R4), and a REST operation's `Headers: ('Accept': 'application/json')`. A database connection is `create database connection M.Db ( Type: 'PostgreSQL', ConnectionString: @M.Url, Username: @M.User, Password: @M.Pass ) { query Q ( Sql: $$…$$, Parameters: ( p: Integer default '0' ), Returns: M.E, Map: ( Attr = column ) ) }`. `describe` prints these forms. **Migrating:** every old spelling still parses under both language versions, builds the same statement, warns (MDL-DEPR120–127) and is rewritten by `mxcli fmt --upgrade`. + - **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. - **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. diff --git a/README.md b/README.md index 56a8b4be8d..aed53b69c0 100644 --- a/README.md +++ b/README.md @@ -432,7 +432,7 @@ create association MyModule.Order_Product -- Create pages create page MyModule.Product_Edit ( - params: { $Product: MyModule.Product }, + params: ( $Product: MyModule.Product ), title: 'Edit Product', layout: Atlas_Core.PopupLayout ) diff --git a/cmd/mxcli/cmd_new_layout.go b/cmd/mxcli/cmd_new_layout.go index 82aa3d23c7..820cfce67d 100644 --- a/cmd/mxcli/cmd_new_layout.go +++ b/cmd/mxcli/cmd_new_layout.go @@ -57,12 +57,12 @@ const scaffoldLayoutMDL = `create or replace layout {{.Module}}.{{.Layout}} ( region top (class: 'region-topbar') { container topbarContent ( class: 'topbar-content', - designproperties: [ + designproperties: ( 'Flex container': 'Horizontal (row)', 'Align items Y': 'Center', 'Disable row wrap': on, 'Grow / shrink (self)': 'Fill container (only grow)' - ] + ) ) { menubar mainMenu (profile: '{{.Profile}}') snippetcall languageSelector (snippet: Atlas_Core.LanguageSelectorWidget) diff --git a/cmd/mxcli/lsp_completion.go b/cmd/mxcli/lsp_completion.go index 0c6ea087cd..5d3f575c3a 100644 --- a/cmd/mxcli/lsp_completion.go +++ b/cmd/mxcli/lsp_completion.go @@ -477,7 +477,7 @@ func (s *mdlServer) variableCompletionItems(docText string, linePrefix string, c } } - // Extract page parameter names from CREATE PAGE ... Params: { $Name: Type, ... } + // Extract page parameter names from CREATE PAGE ... Params: ( $Name: Type, ... ) paramNames := extractPageParamNames(docText) for _, name := range paramNames { if partial == "" || strings.HasPrefix(strings.ToUpper(name), partial) { diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index ee3a1699ea..0b3d0d6316 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -304,7 +304,7 @@ 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-- 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: `call rest service post '' body binary $Doc/Contents`.\n-- `Response: FILE AS $Doc` is unaffected — downloads work.", + 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: `call rest service 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"}, }) @@ -411,19 +411,26 @@ func init() { "jdbc", "byod", "database connector", "execute database query", "postgresql", "mysql", "oracle", "snowflake", "sql server", }, - Syntax: `CREATE [OR MODIFY] DATABASE CONNECTION Module.Name [FOLDER 'path'] - TYPE '' - CONNECTION STRING @Module.UrlConstant - USERNAME @Module.UserConstant - PASSWORD @Module.PasswordConstant -[BEGIN - QUERY - SQL $$$$ - [PARAMETER : [DEFAULT '' | NULL]] - [RETURNS Module.Entity - [MAP ( AS , ... )]] - ; -END]; + Syntax: `CREATE [OR MODIFY] DATABASE CONNECTION Module.Name [FOLDER 'path'] ( + Type: '', + ConnectionString: @Module.UrlConstant, + Username: @Module.UserConstant, + Password: @Module.PasswordConstant + [, Host: '', Port: , DatabaseName: ''] +) [{ + QUERY ( + Sql: $$$$ + [, Parameters: ( : [DEFAULT '' | NULL], ... )] + [, Returns: Module.Entity] + [, Map: ( = , ... )] + ) + ... +}]; + +The properties are in ( ) and the queries are children in { } (R2). The old +clause form -- TYPE '…' CONNECTION STRING @… BEGIN QUERY … SQL … RETURNS … +MAP (column AS Attribute); END -- still parses and warns (MDL-DEPR127); +mxcli fmt --upgrade rewrites it. SHOW DATABASE CONNECTIONS [IN ]; DESCRIBE DATABASE CONNECTION Module.Name; @@ -432,7 +439,7 @@ DROP DATABASE CONNECTION Module.Name; Calling a query from a microflow: EXECUTE DATABASE QUERY Module.Connection.QueryName (...); -TYPE is one of Studio Pro's entries — 'MSSQL', 'MySQL', 'Oracle', +Type is one of Studio Pro's entries — 'MSSQL', 'MySQL', 'Oracle', 'PostgreSQL', 'Snowflake' — or 'BYOD' ("bring your own driver"), which skips the driver-presence check and takes the connection string verbatim. Use BYOD for any JDBC driver Mendix has no entry for; put the driver on the classpath @@ -442,12 +449,12 @@ build stays green and the connection simply does not work. Three traps: - 1. CONNECTION STRING / USERNAME / PASSWORD must reference CONSTANTS + 1. ConnectionString / Username / Password must reference CONSTANTS (@Module.Name), not literals. A literal produces a project Studio Pro cannot open at all: StorageLoadException "is not a valid ConstantIdentifier". mxcli catches it (MDL058); mxbuild does not, so the build is green. - 2. USERNAME and PASSWORD must be given even when the driver needs neither. + 2. Username and Password must be given even when the driver needs neither. Omitting them writes an empty constant reference, the build stays green, and the query fails only at run time with "Could not find value for constant ''". @@ -477,22 +484,22 @@ CREATE NON-PERSISTENT ENTITY Ops.EmployeeRow ( Name: String(100) ); -CREATE DATABASE CONNECTION Ops.Erp - TYPE 'PostgreSQL' - CONNECTION STRING @Ops.DbUrl - USERNAME @Ops.DbUser - PASSWORD @Ops.DbPass -BEGIN - QUERY GetEmployees - SQL $$SELECT id, name FROM employees WHERE dept = {dept}$$ - PARAMETER dept: String DEFAULT 'sales' - RETURNS Ops.EmployeeRow - MAP ( - id AS EmployeeId, - name AS Name +CREATE DATABASE CONNECTION Ops.Erp ( + Type: 'PostgreSQL', + ConnectionString: @Ops.DbUrl, + Username: @Ops.DbUser, + Password: @Ops.DbPass +) { + QUERY GetEmployees ( + Sql: $$SELECT id, name FROM employees WHERE dept = {dept}$$, + Parameters: ( dept: String DEFAULT 'sales' ), + Returns: Ops.EmployeeRow, + Map: ( + EmployeeId = id, + Name = name ) - ; -END; + ) +}; -- A named type needs its driver declared, or the build fails with CE5278. ALTER MODULE Ops ADD JAR DEPENDENCY ( @@ -683,7 +690,7 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, Register(SyntaxFeature{ Path: "glyph", - Summary: "Glyph icons — the numeric codes `icon glyph ` accepts, and their names", + Summary: "Glyph icons — the numeric codes `Icon: glyph ` accepts, and their names", Keywords: []string{ "glyph", "glyphs", "show glyphs", "describe glyph", "icon glyph", "glyphicon", "glyph code", "menu icon", "navigation icon", @@ -694,10 +701,10 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, Example: "-- A glyph is a character code in the Mendix glyph font, not a document in the\n" + "-- project, so there is no module to scope and no connection needed.\n" + "SHOW GLYPHS LIKE 'star';\n" + - "-- 57350 star icon glyph 57350\n" + - "-- 57351 star-empty icon glyph 57351\n" + + "-- 57350 star Icon: glyph 57350\n" + + "-- 57351 star-empty Icon: glyph 57351\n" + "DESCRIBE GLYPH 'star';\n" + - "-- → then: MENU ITEM 'Favourites' PAGE M.Favourites ICON GLYPH 57350;\n" + + "-- → then: MENU ITEM 'Favourites' ( OnClick: SHOW PAGE M.Favourites, Icon: GLYPH 57350 )\n" + "\n" + "-- Prefer an icon COLLECTION reference where the icon exists there: it is a\n" + "-- model reference that `check --references` resolves, while a glyph code is\n" + diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 43f8351f01..82e303c8bd 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -294,10 +294,6 @@ DISCONNECT;`, [HOME PAGE Module.Page FOR UserRole] [LOGIN PAGE Module.LoginPage] [NOT FOUND PAGE Module.Custom404] - [MENU ( - MENU ITEM 'Label' PAGE Module.Page [ICON Module.IconCollection.Name]; - MENU 'Group' [ICON Module.IconCollection.Name] ( ... ); - )] [ON SYNC ERROR THROW|CONTINUE] [SYNC ( SYNC Module.Entity ONLINE; @@ -306,7 +302,18 @@ DISCONNECT;`, SYNC Module.Entity NEVER; SYNC Module.Entity NONE; SYNC Module.Entity NONE PRESERVE DATA; - )]; + )] + [{ + MENU ITEM 'Label' [( OnClick: SHOW PAGE Module.Page | CALL MICROFLOW Module.Flow | SIGN OUT + [, Icon: Module.IconCollection.Name] )] + MENU 'Group' [( Icon: Module.IconCollection.Name )] { ... } + }]; + +-- The menu items are the profile's CHILDREN, in { } after its clauses, like a +-- page's widgets: no ; between them, since a child ends in ) or }. An item's +-- action is OnClick: in the words a page action uses. The old spelling, +-- MENU ( MENU ITEM 'Label' PAGE M.P ICON I; ... ), still parses and warns +-- (MDL-DEPR121, MDL-DEPR122); mxcli fmt --upgrade rewrites it. -- FOR takes a USER role, written BARE (FOR Administrator). User roles are -- project-level and have no module part; a module role is a different thing @@ -316,10 +323,10 @@ DISCONNECT;`, -- UserRoleIdentifier", raised before checking runs, so there is no error code -- and no line number. List the real ones with LIST USER ROLES. -- --- ICON is a qualified name into an ICON COLLECTION (Atlas_Core.Atlas, +-- Icon: is a qualified name into an ICON COLLECTION (Atlas_Core.Atlas, -- Atlas_Core.Atlas_Filled, Atlas_Core.Atlas_Styling, or your own) -- a model -- reference, not a string. Hyphenated Atlas names are double-quoted: --- ICON Atlas_Core.Atlas."align-center" +-- Icon: Atlas_Core.Atlas."align-center" -- Browse the available names with: -- SHOW ICON COLLECTION / DESCRIBE ICON COLLECTION Module.Name -- @@ -355,7 +362,7 @@ DISCONNECT;`, -- than a new keyword. OMITTING it leaves the stored value alone; DESCRIBE emits -- it only when it is not the default. -- --- The block REPLACES the stored list, the way MENU replaces the menu. An +-- The block REPLACES the stored list, the way { } replaces the menu. An -- entity's compatibility-mode flag has no syntax and is preserved across the -- rewrite untouched; DESCRIBE NAVIGATION flags it rather than dropping it. -- An invented name ("Mobile") is an error: the runtime routes on User-Agent to @@ -368,19 +375,20 @@ DISCONNECT;`, HOME PAGE MyModule.Home_Web HOME PAGE MyModule.AdminDashboard FOR Administrator LOGIN PAGE Administration.Login - MENU ( - MENU ITEM 'Home' PAGE MyModule.Home_Web ICON Atlas_Core.Atlas.home; - MENU 'Orders' ICON Atlas_Core.Atlas."shopping-cart" ( - MENU ITEM 'All Orders' PAGE Orders.Order_Overview ICON Atlas_Core.Atlas."list-bullets"; - MENU ITEM 'New Order' PAGE Orders.Order_New ICON Atlas_Core.Atlas.add; - ); - ); + { + MENU ITEM 'Home' ( OnClick: SHOW PAGE MyModule.Home_Web, Icon: Atlas_Core.Atlas.home ) + MENU 'Orders' ( Icon: Atlas_Core.Atlas."shopping-cart" ) { + MENU ITEM 'All Orders' ( OnClick: SHOW PAGE Orders.Order_Overview, Icon: Atlas_Core.Atlas."list-bullets" ) + MENU ITEM 'New Order' ( OnClick: SHOW PAGE Orders.Order_New, Icon: Atlas_Core.Atlas.add ) + } + MENU ITEM 'Log out' ( OnClick: SIGN OUT, Icon: Atlas_Core.Atlas."log-out" ) + }; CREATE OR REPLACE NAVIGATION TabletOffline HOME PAGE Maintenance.Request_Overview - MENU ( - MENU ITEM 'Requests' PAGE Maintenance.Request_Overview; - );`, + { + MENU ITEM 'Requests' ( OnClick: SHOW PAGE Maintenance.Request_Overview ) + };`, SeeAlso: []string{"navigation.show"}, }) diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index f2247d0169..4021ecbb34 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -12,8 +12,8 @@ func init() { "page", "pages", "form", "UI", "user interface", "widget", "layout", "screen", }, - Syntax: "CREATE PAGE Module.Name [FOLDER 'FolderPath']\n (\n Title: 'Page Title',\n Layout: Module.LayoutName\n [, Params: { $Param: Module.Entity }]\n [, Url: 'page-url']\n [, Variables: { $var: Boolean = 'true' }]\n [, PopupWidth: 800, PopupHeight: 480, PopupResizable: true]\n [, PopupCloseAction: cancelButton1]\n [, Class: 'css-class', Style: 'css: rule']\n )\n {\n -- widgets\n }", - Example: "CREATE PAGE MyModule.EditCustomer\n (\n Params: { $Customer: MyModule.Customer },\n Title: 'Edit Customer',\n Layout: Atlas_Core.PopupLayout,\n Class: 'container-fluid'\n )\n {\n DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)\n }\n }\n }", + Syntax: "CREATE PAGE Module.Name [FOLDER 'FolderPath']\n (\n Title: 'Page Title',\n Layout: Module.LayoutName\n [, Params: ( $Param: Module.Entity )]\n [, Url: 'page-url']\n [, Variables: ( $var: Boolean = 'true' )]\n [, PopupWidth: 800, PopupHeight: 480, PopupResizable: true]\n [, PopupCloseAction: cancelButton1]\n [, Class: 'css-class', Style: 'css: rule']\n )\n {\n -- widgets\n }", + Example: "CREATE PAGE MyModule.EditCustomer\n (\n Params: ( $Customer: MyModule.Customer ),\n Title: 'Edit Customer',\n Layout: Atlas_Core.PopupLayout,\n Class: 'container-fluid'\n )\n {\n DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)\n }\n }\n }", SeeAlso: []string{"page.create", "page.widgets", "page.alter", "snippet"}, }) @@ -24,8 +24,8 @@ func init() { "create page", "new page", "page parameters", "page variables", "layout", "url", "folder", }, - Syntax: "CREATE PAGE Module.Name [FOLDER 'FolderPath']\n (\n Title: 'Title',\n Layout: Module.Layout\n [, Params: { $P: Module.Entity, $Qty: Integer }]\n [, Url: 'page-url']\n [, Variables: { $showStock: Boolean = 'true' }]\n )\n { }", - Example: "CREATE PAGE Module.Products\n (\n Title: 'Products',\n Layout: Atlas_Core.Atlas_Default,\n Url: 'products',\n Variables: { $showStock: Boolean = 'true' }\n )\n {\n DATAGRID gridProducts (DataSource: DATABASE Module.Product) {\n COLUMN colName (Attribute: Name, Caption: 'Name')\n }\n }", + Syntax: "CREATE PAGE Module.Name [FOLDER 'FolderPath']\n (\n Title: 'Title',\n Layout: Module.Layout\n [, Params: ( $P: Module.Entity, $Qty: Integer )]\n [, Url: 'page-url']\n [, Variables: ( $showStock: Boolean = 'true' )]\n )\n { }", + Example: "CREATE PAGE Module.Products\n (\n Title: 'Products',\n Layout: Atlas_Core.Atlas_Default,\n Url: 'products',\n Variables: ( $showStock: Boolean = 'true' )\n )\n {\n DATAGRID gridProducts (DataSource: DATABASE Module.Product) {\n COLUMN colName (Attribute: Name, Caption: 'Name')\n }\n }", SeeAlso: []string{"page", "page.widgets", "page.datasource"}, }) @@ -151,7 +151,7 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "-- 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" + + "-- 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" + "-- image collection, named as three parts: Module.Collection.ImageName.\n" + "-- `show image collections` lists the collections, `describe image collection\n" + @@ -163,8 +163,8 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "-- A text-template property (ImageUrl, AlternativeText, a pluggable widget's\n" + "-- headerCaption/title/…) takes TEXT, so a bare value renders the same string\n" + "-- on every row. Bind it with the property's own `Params` companion:\n" + - "IMAGE name (ImageType: imageUrl, ImageUrl: '{1}', ImageUrlParams: [{1} = PictureUrl],\n" + - " AlternativeText: '{1}', AlternativeTextParams: [{1} = Name])\n" + + "IMAGE name (ImageType: imageUrl, ImageUrl: '{1}', ImageUrlParams: ({1} = PictureUrl),\n" + + " AlternativeText: '{1}', AlternativeTextParams: ({1} = Name))\n" + "-- The widget-wide `contentparams:` is one list shared by every template on the\n" + "-- widget; `'{AttrName}'` is the shortest form for a single attribute.\n\n" + "-- Any pluggable widget by its id (id FIRST, then the name)\nPLUGGABLEWIDGET 'com.mendix.widget.web.badge.Badge' name (value: 'x')\nCUSTOMWIDGET 'com.mendix.widget.custom.x.X' name (prop: 'x') -- legacy spelling\n\n" + @@ -232,12 +232,12 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "template, DROP it and INSERT the new one in the same ALTER block; operations apply in\n" + "order.", Example: "LISTVIEW vehicleListView (DataSource: DATABASE Pages.Vehicle) {\n" + - " DYNAMICTEXT defaultVehicle (Content: '{1} {2}', ContentParams: [{1} = Brand, {2} = Model])\n" + + " DYNAMICTEXT defaultVehicle (Content: '{1} {2}', ContentParams: ({1} = Brand, {2} = Model))\n" + " TEMPLATE FOR Pages.Bus {\n" + - " DYNAMICTEXT busLabel (Content: 'Bus, capacity {1}', ContentParams: [{1} = PassengerCapacity])\n" + + " DYNAMICTEXT busLabel (Content: 'Bus, capacity {1}', ContentParams: ({1} = PassengerCapacity))\n" + " }\n" + " TEMPLATE FOR Pages.Truck {\n" + - " DYNAMICTEXT truckLabel (Content: 'Truck, max load {1} kg', ContentParams: [{1} = MaxLoadKg])\n" + + " DYNAMICTEXT truckLabel (Content: 'Truck, max load {1} kg', ContentParams: ({1} = MaxLoadKg))\n" + " }\n" + "}", SeeAlso: []string{"page.widgets", "page.datasource"}, @@ -290,7 +290,7 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "popup width", "popup height", "popup resizable", "drop template", "insert template", "list view template", }, - Syntax: "ALTER PAGE Module.Name { -- the generic ALTER: set / insert / replace / drop\n SET (property: value) ON widgetName; -- widget property names: any casing\n SET ('Row size': 'Small') ON lvOrders; -- an Atlas DESIGN property of that widget's\n -- type; quoted and case-sensitive.\n -- `show design properties for ` lists\n -- them. ON/OFF for a toggle, where OFF\n -- REMOVES the entry.\n -- A multi-select ('Hide on') or compound\n -- ('Spacing') one needs the inline\n -- DesignProperties: [...] form, because a\n -- SET assignment carries one value.\n SET (Action: CALL MICROFLOW Module.MF) ON btnSave; -- any CREATE PAGE action form\n SET ('createFileAction': CALL MICROFLOW Module.MF) ON fileUploader1;\n -- a pluggable widget's NAMED action slot,\n -- by the widget's own key; refused on a\n -- key that is not action-typed\n SET (DataSource: $Param) ON dvOrder; -- parameter/microflow/nanoflow/selection;\n -- DATABASE and association are REPLACE-only,\n -- and a data view takes no database source\n SET (prop1: val1, prop2: val2) ON widgetName;\n SET (Title: 'New Title'); -- page-level (case-sensitive): no ON\n SET (Documentation: 'What this page is for.');\n SET (Class: 'css-class'); -- page-level CSS class / style\n SET (Style: 'css: rule');\n SET (PopupWidth: 800, PopupHeight: 480, PopupResizable: true); -- page-level pop-up\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP name1, name2;\n DROP TEMPLATE FOR Module.Specialization IN listViewName;\n REPLACE widgetName WITH { };\n};\n-- A target is a widget name, or a DataGrid 2 column by what it shows:\n-- grid column(Attr), grid column('Caption'), @n when two match. The old spellings `SET p = v`,\n-- `SET p: v` (no parentheses) and `DROP WIDGET a` still run and warn\n-- (MDL-DEPR101..103).\n\n-- The BULK form: one design property on every widget of a TYPE.\nALTER PAGES [IN Module]\n SET 'Compact' = ON, 'Striped' = ON\n WHERE WIDGETTYPE = datagrid -- the MDL keyword, which resolves to\n -- exactly one widget id. A full id in\n -- quotes works too. NOT a name: a widget\n -- name is unique only within its page.\n [DRY RUN]; -- run this FIRST. It reports the matches\n -- against a discardable copy and writes\n -- nothing.", + Syntax: "ALTER PAGE Module.Name { -- the generic ALTER: set / insert / replace / drop\n SET (property: value) ON widgetName; -- widget property names: any casing\n SET ('Row size': 'Small') ON lvOrders; -- an Atlas DESIGN property of that widget's\n -- type; quoted and case-sensitive.\n -- `show design properties for ` lists\n -- them. ON/OFF for a toggle, where OFF\n -- REMOVES the entry.\n -- A multi-select ('Hide on') or compound\n -- ('Spacing') one needs the inline\n -- DesignProperties: (...) form, because a\n -- SET assignment carries one value.\n SET (Action: CALL MICROFLOW Module.MF) ON btnSave; -- any CREATE PAGE action form\n SET ('createFileAction': CALL MICROFLOW Module.MF) ON fileUploader1;\n -- a pluggable widget's NAMED action slot,\n -- by the widget's own key; refused on a\n -- key that is not action-typed\n SET (DataSource: $Param) ON dvOrder; -- parameter/microflow/nanoflow/selection;\n -- DATABASE and association are REPLACE-only,\n -- and a data view takes no database source\n SET (prop1: val1, prop2: val2) ON widgetName;\n SET (Title: 'New Title'); -- page-level (case-sensitive): no ON\n SET (Documentation: 'What this page is for.');\n SET (Class: 'css-class'); -- page-level CSS class / style\n SET (Style: 'css: rule');\n SET (PopupWidth: 800, PopupHeight: 480, PopupResizable: true); -- page-level pop-up\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP name1, name2;\n DROP TEMPLATE FOR Module.Specialization IN listViewName;\n REPLACE widgetName WITH { };\n};\n-- A target is a widget name, or a DataGrid 2 column by what it shows:\n-- grid column(Attr), grid column('Caption'), @n when two match. The old spellings `SET p = v`,\n-- `SET p: v` (no parentheses) and `DROP WIDGET a` still run and warn\n-- (MDL-DEPR101..103).\n\n-- The BULK form: one design property on every widget of a TYPE.\nALTER PAGES [IN Module]\n SET 'Compact' = ON, 'Striped' = ON\n WHERE WIDGETTYPE = datagrid -- the MDL keyword, which resolves to\n -- exactly one widget id. A full id in\n -- quotes works too. NOT a name: a widget\n -- name is unique only within its page.\n [DRY RUN]; -- run this FIRST. It reports the matches\n -- against a discardable copy and writes\n -- nothing.", Example: "ALTER PAGE Module.EditPage {\n SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave;\n INSERT AFTER txtName {\n TEXTBOX txtMiddleName (Label: 'Middle Name', Attribute: MiddleName)\n };\n DROP txtUnused;\n};", SeeAlso: []string{"page.create", "page.show", "snippet.alter"}, }) @@ -308,8 +308,8 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { " Class: 'css-class-name' -- static CSS classes\n" + " Style: 'color: red; padding: 8px;' -- inline CSS\n" + " DynamicClasses: '' -- runtime-computed (stacks on Class)\n" + - " DesignProperties: ['Spacing top': 'Large']\n" + - " DesignProperties: ['Full width': ON]\n\n" + + " DesignProperties: ('Spacing top': 'Large')\n" + + " 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" + @@ -402,8 +402,8 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "snippet", "snippets", "reusable", "snippetcall", "page fragment", "component", }, - Syntax: "CREATE SNIPPET Module.Name [FOLDER 'path']\n [( Params: { $P: Module.Entity } )] -- parameters are entities only\n {\n -- widgets (same as page)\n }\n\n-- Embed in a page:\nSNIPPETCALL scName (Snippet: Module.SnippetName)", - Example: "CREATE SNIPPET MyModule.CustomerInfo (\n Params: { $Customer: MyModule.Customer }\n)\n{\n DATAVIEW dv (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n TEXTBOX txtEmail (Label: 'Email', Attribute: Email)\n }\n}", + Syntax: "CREATE SNIPPET Module.Name [FOLDER 'path']\n [( Params: ( $P: Module.Entity ) )] -- parameters are entities only\n {\n -- widgets (same as page)\n }\n\n-- Embed in a page:\nSNIPPETCALL scName (Snippet: Module.SnippetName)", + Example: "CREATE SNIPPET MyModule.CustomerInfo (\n Params: ( $Customer: MyModule.Customer )\n)\n{\n DATAVIEW dv (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n TEXTBOX txtEmail (Label: 'Email', Attribute: Email)\n }\n}", SeeAlso: []string{"snippet.create", "snippet.alter", "page"}, }) @@ -420,7 +420,7 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { // (a PAGE parameter may be primitive; a snippet parameter may not), and // the reporter of mendixlabs/mxcli#1028 reached the bug by following // this line. A primitive is now refused as MDL087. - Syntax: "CREATE SNIPPET Module.Name [FOLDER 'Snippets/Common']\n [( Params: { $P: Module.Entity } )] -- entities only; a primitive is CE0046\n [( Variables: { $isEditable: Boolean = 'true' } )]\n {\n -- widgets\n }\n\n-- To parameterise a snippet on a primitive, keep the primitive on the\n-- calling PAGE and pass an object, or read the value off an entity member.", + Syntax: "CREATE SNIPPET Module.Name [FOLDER 'Snippets/Common']\n [( Params: ( $P: Module.Entity ) )] -- entities only; a primitive is CE0046\n [( Variables: ( $isEditable: Boolean = 'true' ) )]\n {\n -- widgets\n }\n\n-- To parameterise a snippet on a primitive, keep the primitive on the\n-- calling PAGE and pass an object, or read the value off an entity member.", Example: "CREATE SNIPPET MyModule.NavigationMenu\n{\n NAVIGATIONLIST navMenu {\n ITEM itemCustomers (Action: SHOW PAGE MyModule.CustomerOverview) {\n DYNAMICTEXT txtCustomers (Content: 'Customers')\n }\n }\n}", SeeAlso: []string{"snippet", "snippet.alter", "page.widgets"}, }) @@ -502,9 +502,9 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "}\n\n" + "-- A phone layout's bottom bar, as Atlas_Core.Phone_BottomBar has it —\n" + "-- a simple menu bar rendering a menu document:\n" + - "CREATE OR MODIFY MENU MyModule.Phone_Menu (\n" + - " menu item 'Home' page MyModule.Home_Phone icon Atlas_Core.Atlas.home;\n" + - ");\n" + + "CREATE OR MODIFY MENU MyModule.Phone_Menu {\n" + + " menu item 'Home' ( OnClick: show page MyModule.Home_Phone, Icon: Atlas_Core.Atlas.home )\n" + + "};\n" + "CREATE OR REPLACE LAYOUT MyModule.Phone_Bottom (\n" + " layouttype: 'Phone',\n" + " class: 'layout-atlas layout-atlas-phone'\n" + @@ -626,20 +626,21 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "create menu", "describe menu", "drop menu", "menu", "menus", "menu document", "menu item", }, - Syntax: "CREATE [OR MODIFY] MENU Module.Name [FOLDER 'path'] (\n" + - " MENU ITEM '' [PAGE Module.Page | MICROFLOW Module.Flow | SIGN OUT] [ICON Module.Collection.name];\n" + - " MENU '' [ICON Module.Collection.name] ( );\n" + - ");\n" + + Syntax: "CREATE [OR MODIFY] MENU Module.Name [FOLDER 'path'] {\n" + + " MENU ITEM '' [( [OnClick: SHOW PAGE Module.Page | CALL MICROFLOW Module.Flow | SIGN OUT]\n" + + " [, Icon: Module.Collection.name | GLYPH | IMAGE Module.Images.name] )]\n" + + " MENU '' [( Icon: Module.Collection.name )] { }\n" + + "};\n" + "DESCRIBE MENU Module.Name;\n" + "DROP MENU Module.Name;", - Example: "CREATE OR MODIFY MENU MyModule.Main_Menu (\n" + - " menu item 'Home' page MyModule.Home_Web icon Atlas_Core.Atlas.home;\n" + - " menu item 'Run' microflow MyModule.DoThing;\n" + - " menu 'Admin' (\n" + - " menu item 'Accounts' page Administration.Account_Overview;\n" + - " );\n" + - " menu item 'Plain';\n" + - ");\n\n" + + Example: "CREATE OR MODIFY MENU MyModule.Main_Menu {\n" + + " menu item 'Home' ( OnClick: show page MyModule.Home_Web, Icon: Atlas_Core.Atlas.home )\n" + + " menu item 'Run' ( OnClick: call microflow MyModule.DoThing )\n" + + " menu 'Admin' {\n" + + " menu item 'Accounts' ( OnClick: show page Administration.Account_Overview )\n" + + " }\n" + + " menu item 'Plain'\n" + + "};\n\n" + "-- Notes:\n" + "-- * A menu document is the reusable menu a menu widget points at. It is\n" + "-- NOT the menu inside a navigation profile — for that use\n" + @@ -649,8 +650,11 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "-- * SIGN OUT is the log-out menu item. It needs no target and stores the\n" + "-- same Forms$SignOutClientAction a sign-out BUTTON carries. Works both\n" + "-- here and in a navigation profile's menu.\n" + - "-- * ICON names an icon collection entry. A glyph or image icon cannot be\n" + - "-- expressed in MDL; DESCRIBE flags those rather than dropping them silently.\n" + + "-- * The items are children, in { } with no ; between them (R2). The old\n" + + "-- spelling, ( MENU ITEM 'X' PAGE M.P ICON I; ... ), still parses and warns\n" + + "-- (MDL-DEPR121, MDL-DEPR122).\n" + + "-- * Icon: names an icon collection entry; GLYPH and IMAGE M.Images.x\n" + + "-- write the other two icon kinds.\n" + "-- * A page with required parameters cannot be opened from a menu item\n" + "-- without an argument — Mendix reports CE1571.", SeeAlso: []string{"navigation.create", "navigation.show", "page.show"}, @@ -666,7 +670,7 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "use fragment", "template", "script scope", }, Syntax: "CREATE FRAGMENT Name AS { };\nCREATE FRAGMENT Name AS { SLOT [name] };\nCREATE FRAGMENT Name ($d: datasource, $a: action) AS { };\nUSE FRAGMENT Name [(args)] [AS prefix_];\nUSE FRAGMENT Name [(args)] [AS prefix_] { };\nSHOW FRAGMENTS;\nDESCRIBE FRAGMENT Name;\nDESCRIBE FRAGMENT FROM PAGE Module.Page WIDGET widgetName;", - Example: "CREATE FRAGMENT SaveCancelFooter AS {\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)\n }\n};\n\nCREATE PAGE Module.EditPage (Params: { $Param: Module.Customer }, Title: 'Edit', Layout: 'Atlas_Core.Atlas_Default') {\n DATAVIEW dv (DataSource: $Param) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n USE FRAGMENT SaveCancelFooter\n }\n};", + Example: "CREATE FRAGMENT SaveCancelFooter AS {\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)\n }\n};\n\nCREATE PAGE Module.EditPage (Params: ( $Param: Module.Customer ), Title: 'Edit', Layout: 'Atlas_Core.Atlas_Default') {\n DATAVIEW dv (DataSource: $Param) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n USE FRAGMENT SaveCancelFooter\n }\n};", SeeAlso: []string{"fragment.define", "fragment.use", "fragment.slot", "fragment.params", "snippet"}, }) @@ -689,7 +693,7 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "prefix", "name conflict", }, Syntax: "USE FRAGMENT Name\nUSE FRAGMENT Name AS prefix_\nUSE FRAGMENT Name [AS prefix_] { }", - Example: "-- Basic usage\nCREATE PAGE Module.Page (Params: { $Param: Module.Customer }, Title: 'Page', Layout: 'Atlas_Core.Atlas_Default') {\n DATAVIEW dv (DataSource: $Param) {\n USE FRAGMENT FormFields\n USE FRAGMENT SaveCancelFooter\n }\n};\n\n-- With prefix to avoid name conflicts\nUSE FRAGMENT SaveCancelFooter AS order_\n-- Creates: order_footer1, order_btnSave, order_btnCancel\n\n-- Fill a fragment's content slot (see fragment.slot)\nUSE FRAGMENT Card {\n DYNAMICTEXT cardHeading (Content: 'Welcome', RenderMode: H2)\n DYNAMICTEXT cardText (Content: 'Wrapped content')\n}", + Example: "-- Basic usage\nCREATE PAGE Module.Page (Params: ( $Param: Module.Customer ), Title: 'Page', Layout: 'Atlas_Core.Atlas_Default') {\n DATAVIEW dv (DataSource: $Param) {\n USE FRAGMENT FormFields\n USE FRAGMENT SaveCancelFooter\n }\n};\n\n-- With prefix to avoid name conflicts\nUSE FRAGMENT SaveCancelFooter AS order_\n-- Creates: order_footer1, order_btnSave, order_btnCancel\n\n-- Fill a fragment's content slot (see fragment.slot)\nUSE FRAGMENT Card {\n DYNAMICTEXT cardHeading (Content: 'Welcome', RenderMode: H2)\n DYNAMICTEXT cardText (Content: 'Wrapped content')\n}", SeeAlso: []string{"fragment", "fragment.define", "fragment.slot"}, }) @@ -713,7 +717,7 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "card wrapper", "reusable shell", "use fragment payload", }, Syntax: "-- In the definition, mark where caller content lands:\nCREATE FRAGMENT Name AS { SLOT [name] };\n-- At the use site, supply the payload in a brace block:\nUSE FRAGMENT Name [AS prefix_] { }", - Example: "CREATE FRAGMENT Card AS {\n CONTAINER cardWrap (Class: 'card', DesignProperties: ['Card style': on]) {\n CONTAINER cardBody (Class: 'card-body') {\n SLOT content\n }\n }\n};\n\nCREATE PAGE Module.Dashboard (Title: 'Dashboard', Layout: Atlas_Core.Atlas_Default) {\n USE FRAGMENT Card {\n DYNAMICTEXT cardHeading (Content: 'Welcome', RenderMode: H2)\n DYNAMICTEXT cardText (Content: 'Any widgets can go inside the reusable Card shell')\n }\n};\n\n-- Notes:\n-- * Slot name is optional (defaults to 'content'); one slot per fragment.\n-- * USE FRAGMENT with no payload leaves the slot empty (valid).\n-- * Supplying a payload to a slotless fragment is an error.", + Example: "CREATE FRAGMENT Card AS {\n CONTAINER cardWrap (Class: 'card', DesignProperties: ('Card style': on)) {\n CONTAINER cardBody (Class: 'card-body') {\n SLOT content\n }\n }\n};\n\nCREATE PAGE Module.Dashboard (Title: 'Dashboard', Layout: Atlas_Core.Atlas_Default) {\n USE FRAGMENT Card {\n DYNAMICTEXT cardHeading (Content: 'Welcome', RenderMode: H2)\n DYNAMICTEXT cardText (Content: 'Any widgets can go inside the reusable Card shell')\n }\n};\n\n-- Notes:\n-- * Slot name is optional (defaults to 'content'); one slot per fragment.\n-- * USE FRAGMENT with no payload leaves the slot empty (valid).\n-- * Supplying a payload to a slotless fragment is an error.", SeeAlso: []string{"fragment", "fragment.define", "fragment.use"}, }) diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index b625784ee4..1c96cb56f7 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -139,7 +139,7 @@ func init() { "-- page without a WorkflowUserTask one -> CE7412\n" + "-- Other parameters may sit alongside it.", Example: "-- The task page takes the task:\n" + - "CREATE PAGE HR.ReviewPage (\n title: 'Review',\n layout: Atlas_Core.Atlas_Default,\n params: { $WorkflowUserTask: System.WorkflowUserTask }\n) { };\n\n" + + "CREATE PAGE HR.ReviewPage (\n title: 'Review',\n layout: Atlas_Core.Atlas_Default,\n params: ( $WorkflowUserTask: System.WorkflowUserTask )\n) { };\n\n" + "USER TASK ReviewTask 'Review the request'\n PAGE HR.ReviewPage\n TARGETING USERS XPATH [Module.Employee/Active = true()]\n OUTCOMES 'Approve' { } 'Reject' { };", SeeAlso: []string{"workflow.user-task.targeting", "workflow.multi-user-task", "workflow.create"}, }) diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 45457b6fb8..b5b2b15792 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -286,12 +286,12 @@ CREATE OR REPLACE NAVIGATION Responsive HOME PAGE MyModule.AdminHome FOR Administrator LOGIN PAGE Administration.Login NOT FOUND PAGE MyModule.Custom404 - MENU ( - MENU ITEM 'Home' PAGE MyModule.Home_Web; - MENU 'Admin' ( - MENU ITEM 'Users' PAGE Administration.Account_Overview; - ); - ); + { + MENU ITEM 'Home' ( OnClick: SHOW PAGE MyModule.Home_Web ) + MENU 'Admin' { + MENU ITEM 'Users' ( OnClick: SHOW PAGE Administration.Account_Overview ) + } + }; ``` ## Project Settings @@ -349,7 +349,7 @@ MDL uses explicit property declarations for pages: | Element | Syntax | Example | |---------|-----------|---------| | Page properties | `(Key: value, ...)` | `(Title: 'Edit', Layout: Atlas_Core.Atlas_Default)` | -| Page variables | `Variables: { $name: Type = 'expr' }` | `Variables: { $show: Boolean = 'true' }` | +| Page variables | `Variables: ( $name: Type = 'expr' )` | `Variables: ( $show: Boolean = 'true' )` | | Widget name | Required after type | `TEXTBOX txtName (...)` | | Attribute binding | `Attribute: AttrName` | `TEXTBOX txt (Label: 'Name', Attribute: Name)` | | Variable binding | `DataSource: $Var` | `DATAVIEW dv (DataSource: $Product) { ... }` | @@ -360,7 +360,7 @@ MDL uses explicit property declarations for pages: | CSS class | `Class: 'classes'` | `CONTAINER c (Class: 'card mx-spacing-top-large')` | | Inline style | `Style: 'css'` | `CONTAINER c (Style: 'padding: 16px;')` | | Dynamic classes | `DynamicClasses: 'expr'` | `CONTAINER c (DynamicClasses: if $currentObject/IsActive then 'is-active' else '')` — runtime-computed; stacks on `Class` | -| Design properties | `DesignProperties: [...]` | `CONTAINER c (DesignProperties: ['Spacing top': 'Large', 'Full width': ON])` | +| Design properties | `DesignProperties: (...)` | `CONTAINER c (DesignProperties: ('Spacing top': 'Large', 'Full width': ON))` | | Width (pixels) | `Width: integer` | `IMAGE img (Width: 200)` | | Height (pixels) | `Height: integer` | `IMAGE img (Height: 150)` | | Page size | `PageSize: integer` | `DATAGRID dg (PageSize: 25)` | @@ -390,7 +390,7 @@ MDL uses explicit property declarations for pages: ```sql CREATE PAGE MyModule.Customer_Edit ( - Params: { $Customer: MyModule.Customer }, + Params: ( $Customer: MyModule.Customer ), Title: 'Edit Customer', Layout: Atlas_Core.PopupLayout ) diff --git a/docs-site/src/appendixes/version-compatibility.md b/docs-site/src/appendixes/version-compatibility.md index bb9e199d23..d51970d127 100644 --- a/docs-site/src/appendixes/version-compatibility.md +++ b/docs-site/src/appendixes/version-compatibility.md @@ -78,10 +78,10 @@ The tables below show exactly which features are available on each Mendix versio | 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+ | -| Page variables | `Variables: { ... }` | -- | 10.20+ | 10.20+ | Yes | -| Design properties (Atlas v3) | `DesignProperties: [...]` | -- | -- | -- | Yes | +| Page parameters (entity) | `Params: ( $Item: Module.Entity )` | 9.4+ | Yes | Yes | Yes | +| Page parameters (primitive) | `Params: ( $Qty: Integer )` | -- | -- | -- | 11.6+ | +| Page variables | `Variables: ( ... )` | -- | 10.20+ | 10.20+ | Yes | +| Design properties (Atlas v3) | `DesignProperties: (...)` | -- | -- | -- | Yes | ::: tip Widget Templates Pluggable widget templates are currently extracted from Mendix 11.6. When used on 10.x projects, the [MPK augmentation system](../internals/widget-templates.md) reconciles property differences. Some CE0463 ("widget definition changed") errors may still occur. @@ -102,8 +102,8 @@ Pluggable widget templates are currently extracted from Mendix 11.6. When used o |---------|-----------|-------|-------|-------|-------|-------| | OData client | `CREATE CONSUMED ODATA SERVICE` | Yes | Yes | Yes | Yes | Yes | | Business events | `CREATE BUSINESS EVENT SERVICE` | Yes | Yes | Yes | Yes | Yes | -| REST client (basic) | `CREATE CONSUMED REST SERVICE ... BEGIN ... END` | -- | Yes | Yes | Yes | Yes | -| REST client headers | `HEADER 'Name' = 'Value'` | -- | -- | Yes | Yes | Yes | +| REST client (basic) | `CREATE CONSUMED REST SERVICE ... ( ... ) { OPERATION ... }` | -- | Yes | Yes | Yes | Yes | +| REST client headers | `Headers: ('Name': 'Value')` | -- | -- | Yes | Yes | Yes | | Database Connector | `CREATE DATABASE CONNECTION` | -- | -- | -- | Yes | Yes | | REST client query params | `QUERY $param: Type` | -- | -- | -- | -- | Yes | diff --git a/docs-site/src/examples/crm-module.md b/docs-site/src/examples/crm-module.md index d68f0e6d5b..6e9aa14eda 100644 --- a/docs-site/src/examples/crm-module.md +++ b/docs-site/src/examples/crm-module.md @@ -131,7 +131,7 @@ CREATE PAGE CRM.Customer_Overview ( -- NewEdit page with validation CREATE PAGE CRM.Customer_NewEdit ( - Params: { $Customer: CRM.Customer }, + Params: ( $Customer: CRM.Customer ), Title: 'Customer', Layout: Atlas_Core.PopupLayout ) { diff --git a/docs-site/src/examples/data-import.md b/docs-site/src/examples/data-import.md index c8200018db..3a2820baf1 100644 --- a/docs-site/src/examples/data-import.md +++ b/docs-site/src/examples/data-import.md @@ -80,18 +80,19 @@ CREATE NON-PERSISTENT ENTITY Integration.Customer ( / -- Database connection with parameterized query -CREATE DATABASE CONNECTION Integration.LegacyDatabase -TYPE 'PostgreSQL' -CONNECTION STRING @Integration.LegacyDatabase_DBSource -USERNAME @Integration.LegacyDatabase_DBUsername -PASSWORD @Integration.LegacyDatabase_DBPassword -BEGIN - QUERY SearchCustomers - SQL 'SELECT name, email, balance FROM customers WHERE name ILIKE {search}' - PARAMETER search: String DEFAULT '%' - RETURNS Integration.Customer - MAP (name AS Name, email AS Email, balance AS Balance); -END; +CREATE DATABASE CONNECTION Integration.LegacyDatabase ( + Type: 'PostgreSQL', + ConnectionString: @Integration.LegacyDatabase_DBSource, + Username: @Integration.LegacyDatabase_DBUsername, + Password: @Integration.LegacyDatabase_DBPassword +) { + QUERY SearchCustomers ( + Sql: 'SELECT name, email, balance FROM customers WHERE name ILIKE {search}', + Parameters: ( search: String DEFAULT '%' ), + Returns: Integration.Customer, + Map: (Name = name, Email = email, Balance = balance) + ) +}; / -- Microflow that executes the query diff --git a/docs-site/src/examples/master-detail.md b/docs-site/src/examples/master-detail.md index f6e7cda13a..c250eac8ff 100644 --- a/docs-site/src/examples/master-detail.md +++ b/docs-site/src/examples/master-detail.md @@ -16,12 +16,12 @@ CREATE PAGE CRM.Customer_MasterDetail ( TEMPLATE template1 { DYNAMICTEXT name ( Content: '{1}', - ContentParams: [{1} = Name], + ContentParams: ({1} = Name), RenderMode: H4 ) DYNAMICTEXT email ( Content: '{1}', - ContentParams: [{1} = Email] + ContentParams: ({1} = Email) ) } } diff --git a/docs-site/src/examples/rest-integration.md b/docs-site/src/examples/rest-integration.md index b7877e754f..23aa9a8cde 100644 --- a/docs-site/src/examples/rest-integration.md +++ b/docs-site/src/examples/rest-integration.md @@ -255,7 +255,7 @@ CREATE CONSUMED REST SERVICE Integration.OrdersApi ( Method: GET, Path: '/orders/{id}', Parameters: ($id: String), - Headers: ('Accept' = 'application/json'), + Headers: ('Accept': 'application/json'), Timeout: 30, Response: JSON AS $Result ) @@ -263,7 +263,7 @@ CREATE CONSUMED REST SERVICE Integration.OrdersApi ( OPERATION CreateOrder ( Method: POST, Path: '/orders', - Headers: ('Content-Type' = 'application/json'), + Headers: ('Content-Type': 'application/json'), Body: MAPPING Integration.OrderRequest { customerId = CustomerId, totalAmount = TotalAmount, @@ -290,7 +290,7 @@ CREATE OR MODIFY CONSUMED REST SERVICE Integration.OrdersApi ( Method: GET, Path: '/orders/{id}', Parameters: ($id: String), - Headers: ('Accept' = 'application/json'), + Headers: ('Accept': 'application/json'), Timeout: 60, Response: JSON AS $Result ) diff --git a/docs-site/src/examples/validation.md b/docs-site/src/examples/validation.md index 18af118bef..69a96af43a 100644 --- a/docs-site/src/examples/validation.md +++ b/docs-site/src/examples/validation.md @@ -72,7 +72,7 @@ The page's Save button calls the action microflow (not the validation microflow ```sql CREATE PAGE Sales.Order_Edit ( - Params: { $Order: Sales.Order }, + Params: ( $Order: Sales.Order ), Title: 'Order', Layout: Atlas_Core.PopupLayout ) { diff --git a/docs-site/src/language/data-binding.md b/docs-site/src/language/data-binding.md index 2200aaa89a..9abfadaa91 100644 --- a/docs-site/src/language/data-binding.md +++ b/docs-site/src/language/data-binding.md @@ -30,7 +30,7 @@ The simplest binding -- connects a DataView to a page parameter: ```sql CREATE PAGE MyModule.Customer_Edit ( - Params: { $Customer: MyModule.Customer }, + Params: ( $Customer: MyModule.Customer ), Title: 'Edit Customer', Layout: Atlas_Core.PopupLayout ) diff --git a/docs-site/src/language/document-access.md b/docs-site/src/language/document-access.md index 16dd9e3035..9731731c79 100644 --- a/docs-site/src/language/document-access.md +++ b/docs-site/src/language/document-access.md @@ -94,7 +94,7 @@ GRANT EXECUTE ON MICROFLOW Shop.ACT_CreateOrder TO Shop.User, Shop.Admin; -- Create the page CREATE PAGE Shop.Order_Edit ( - Params: { $Order: Shop.Order }, + Params: ( $Order: Shop.Order ), Title: 'Edit Order', Layout: Atlas_Core.PopupLayout ) { ... } diff --git a/docs-site/src/language/home-pages.md b/docs-site/src/language/home-pages.md index 65ae2084ec..4e975f3fdc 100644 --- a/docs-site/src/language/home-pages.md +++ b/docs-site/src/language/home-pages.md @@ -58,24 +58,26 @@ CREATE OR REPLACE NAVIGATION Responsive ## Menus -The `MENU` block defines the navigation menu as a tree of items and submenus. +The `{ }` block after the profile's clauses defines the navigation menu as a tree of items and submenus. ### Menu Items -A menu item links a label to a page: +A menu item links a label to a page, a microflow, or sign-out, in the words a page action uses: ```sql -MENU ITEM '