From c380d6126e001f827bc759a128d929a6bf0279b1 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 05:44:53 +0000 Subject: [PATCH 1/4] mdl: alter sets properties in create's ( Key: value ) list; colons where R3 puts them (#751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 of the beta syntax freeze: `:` sets a model property, so every `alter` takes the same `( Key: value, … )` list its `create` does, and a colon is written in a property list or an attribute definition and nowhere else. Canonical forms, parsed under mdl 0 and mdl 1; the old spellings keep parsing as registered aliases that warn and that `fmt --upgrade` rewrites: - MDL-DEPR060 alter settings
/ configuration 'X' and create configuration: `Key = v, …` -> `( Key: v, … )` - MDL-DEPR061 alter consumed|published odata service X set Key = v -> set ( Key: v, … ), exactly create's property list - MDL-DEPR062 alter styling … set Class = 'x', 'P' = on -> set ( … ) - MDL-DEPR063 alter entity … set allow_create_change_locally = v -> set ( AllowCreateChangeLocally: v ) - MDL-DEPR064 association `type: Reference` / owner: / storage: -> no colon - MDL-DEPR065 modify attribute A T -> modify attribute A: T - MDL-DEPR101..103, the generic alter page/snippet/layout aliases, move from the executor's provisional table into the registry, with rewrites. describe settings now writes the canonical lists, and the MDL mxcli generates (testrunner, new-project hints) uses them. Docs, skills, `mxcli syntax` and the quick reference follow. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/mendix/bootstrap-app/SKILL.md | 5 +- .claude/skills/mendix/create-page/SKILL.md | 6 +- .claude/skills/mendix/demo-data/SKILL.md | 15 +- .../mendix/migrate-design-prototype/SKILL.md | 2 +- .../reference/walkthroughs.md | 4 +- .../skills/mendix/project-settings/SKILL.md | 104 +++--- .../mendix/regular-expressions/SKILL.md | 2 +- .claude/skills/mendix/write-layouts/SKILL.md | 4 +- cmd/mxcli/cmd_new.go | 2 +- cmd/mxcli/cmd_new_java.go | 4 +- cmd/mxcli/docker/detect.go | 2 +- cmd/mxcli/syntax/features_domain_model.go | 4 +- cmd/mxcli/syntax/features_integration.go | 7 +- cmd/mxcli/syntax/features_misc.go | 44 +-- cmd/mxcli/syntax/features_page.go | 10 +- .../testrunner/generator_endpoint_test.go | 8 +- cmd/mxcli/testrunner/host.go | 2 +- cmd/mxcli/testrunner/runner.go | 6 +- cmd/mxcli/testrunner/runner_cleanup_test.go | 10 +- cmd/mxcli/testrunner/runner_endpoint.go | 4 +- docs-site/src/appendixes/quick-reference.md | 26 +- docs-site/src/examples/alter-page.md | 16 +- docs-site/src/language/alter-entity.md | 4 +- docs-site/src/language/project-settings.md | 22 +- docs-site/src/language/snippets.md | 2 +- docs-site/src/reference/page/alter-layout.md | 8 +- .../src/reference/settings/alter-settings.md | 33 +- docs-site/src/tools/docker-run.md | 4 +- docs/01-project/MDL_QUICK_REFERENCE.md | 20 +- mdl/ast/ast_alter_page.go | 17 - mdl/deprecation/deprecation.go | 137 +++++++- mdl/executor/alter_aliases.go | 149 --------- mdl/executor/cmd_settings.go | 66 ++-- .../cmd_settings_configuration_test.go | 2 +- .../cmd_settings_describe_canonical_test.go | 67 ++++ mdl/executor/cmd_settings_languages.go | 2 +- mdl/executor/validate_alter_page_addresses.go | 47 +++ ... => validate_alter_page_addresses_test.go} | 52 +-- mdl/executor/validate_program.go | 3 - mdl/grammar/MDLParser.g4 | 30 +- mdl/grammar/domains/MDLDomainModel.g4 | 16 +- mdl/grammar/domains/MDLService.g4 | 15 +- mdl/grammar/domains/MDLSettings.g4 | 22 +- mdl/upgrade/document_names_test.go | 4 +- mdl/upgrade/r3_property_lists_test.go | 60 ++++ mdl/visitor/r3_property_lists_test.go | 140 ++++++++ mdl/visitor/r8_spellings_test.go | 4 +- mdl/visitor/visitor_alter.go | 26 +- .../visitor_alter_alias_equivalence_test.go | 40 +-- mdl/visitor/visitor_alter_generic_test.go | 58 ++-- mdl/visitor/visitor_alter_page.go | 18 +- mdl/visitor/visitor_document_names_test.go | 10 +- mdl/visitor/visitor_entity.go | 3 + mdl/visitor/visitor_odata_expression.go | 6 + mdl/visitor/visitor_r3_property_lists.go | 300 ++++++++++++++++++ mdl/visitor/visitor_r8_spellings.go | 1 + mdl/visitor/visitor_settings.go | 81 +++-- 57 files changed, 1158 insertions(+), 598 deletions(-) delete mode 100644 mdl/executor/alter_aliases.go create mode 100644 mdl/executor/cmd_settings_describe_canonical_test.go create mode 100644 mdl/executor/validate_alter_page_addresses.go rename mdl/executor/{alter_aliases_test.go => validate_alter_page_addresses_test.go} (63%) create mode 100644 mdl/upgrade/r3_property_lists_test.go create mode 100644 mdl/visitor/r3_property_lists_test.go create mode 100644 mdl/visitor/visitor_r3_property_lists.go diff --git a/.claude/skills/mendix/bootstrap-app/SKILL.md b/.claude/skills/mendix/bootstrap-app/SKILL.md index 683b8e0e81..a2a4fca33e 100644 --- a/.claude/skills/mendix/bootstrap-app/SKILL.md +++ b/.claude/skills/mendix/bootstrap-app/SKILL.md @@ -253,8 +253,9 @@ with these deltas: against the host name rather than the listen address: ```sql - alter settings configuration 'Default' - ApplicationRootUrl = 'http://backend.local:8080/'; + alter settings configuration 'Default' ( + ApplicationRootUrl: 'http://backend.local:8080/' + ); ``` `run --local` picks that up at boot and prints which configuration it came from. diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 3f944f0807..65dd872670 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -47,11 +47,11 @@ create [or replace] page Module.PageName page is opened in a pop-up. They are optional — omitting them uses the Mendix defaults (600 × 600, not resizable). Unlike the other header keywords, these property names are **case-sensitive** and must be written exactly as shown. They can also be changed later -with `alter page … { set PopupWidth = …; }` (see the alter-page skill). +with `alter page … { set (PopupWidth: …); }` (see the alter-page skill). **Page CSS class / style** (`Class` / `Style`) set the page's Appearance — a CSS class and inline style applied to the whole page (e.g. `Class: 'container-fluid bg-light'`). -Both are optional and can be changed later with `alter page … { set Class = '…'; }`. +Both are optional and can be changed later with `alter page … { set (Class: '…'); }`. **Page Variables**: Local variables at the page level for use in expressions (e.g., column visibility). - DataType: `boolean`, `string`, `integer`, `decimal`, `datetime` @@ -243,7 +243,7 @@ To make targeted changes to an existing page (change a label, add a field, remov ```sql -- Change a button caption and add a field alter page Module.Customer_Edit { - set caption = 'Save & Close' on btnSave; + set (caption: 'Save & Close') on btnSave; insert after txtEmail { textbox txtPhone (label: 'Phone', attribute: Phone) } diff --git a/.claude/skills/mendix/demo-data/SKILL.md b/.claude/skills/mendix/demo-data/SKILL.md index faead4d4bf..74975483ba 100644 --- a/.claude/skills/mendix/demo-data/SKILL.md +++ b/.claude/skills/mendix/demo-data/SKILL.md @@ -41,13 +41,14 @@ For full credentials (username, password): Example output: ```sql -alter settings configuration 'Default' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'localhost:5434', - DatabaseName = 'mxcli2-dev', - DatabaseUserName = 'mendix', - DatabasePassword = 'mendix', - HttpPortNumber = 8080; +alter settings configuration 'Default' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'localhost:5434', + DatabaseName: 'mxcli2-dev', + DatabaseUserName: 'mendix', + DatabasePassword: 'mendix', + HttpPortNumber: 8080 +); ``` --- diff --git a/.claude/skills/mendix/migrate-design-prototype/SKILL.md b/.claude/skills/mendix/migrate-design-prototype/SKILL.md index 423769afbd..d270f7a2ed 100644 --- a/.claude/skills/mendix/migrate-design-prototype/SKILL.md +++ b/.claude/skills/mendix/migrate-design-prototype/SKILL.md @@ -538,7 +538,7 @@ Use `alter page` to attach a class without rewriting the page (see `alter-page`) ```sql alter page ResourceScheduling.Approvals { - set Class = 'ss-appr-card ss-appr-card--conflict' on queueCard; + set (Class: 'ss-appr-card ss-appr-card--conflict') on queueCard; } ``` diff --git a/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md b/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md index 54b37a4edd..661a110a20 100644 --- a/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md +++ b/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md @@ -309,8 +309,8 @@ from consumed odata service ShopClient.ShopApiClient ); -- Toggle the flag without recreating the entity. -alter entity ShopClient.Product set allow_create_change_locally = true; -alter entity ShopClient.Product set allow_create_change_locally = false; +alter entity ShopClient.Product set ( AllowCreateChangeLocally: true ); +alter entity ShopClient.Product set ( AllowCreateChangeLocally: false ); ``` ## Publishing a Non-Persistable Entity (no copy of the data) diff --git a/.claude/skills/mendix/project-settings/SKILL.md b/.claude/skills/mendix/project-settings/SKILL.md index bcf305d913..529d8486ab 100644 --- a/.claude/skills/mendix/project-settings/SKILL.md +++ b/.claude/skills/mendix/project-settings/SKILL.md @@ -30,22 +30,22 @@ describe settings; ### Modify Model Settings ```sql -alter settings runtime AfterStartupMicroflow = 'Module.MF_Startup'; -- must return Boolean (CE0142) -alter settings runtime BeforeShutdownMicroflow = 'Module.MF_Shutdown'; -alter settings runtime HealthCheckMicroflow = 'Module.MF_HealthCheck'; -alter settings runtime HashAlgorithm = 'BCrypt'; -alter settings runtime BcryptCost = 12; -alter settings runtime JavaVersion = 'Java21'; -- or '21'; see note below -alter settings runtime RoundingMode = 'HalfUp'; -alter settings runtime AllowUserMultipleSessions = true; -alter settings runtime ScheduledEventTimeZoneCode = 'Etc/UTC'; -alter settings runtime DefaultTimeZoneCode = 'Europe/Amsterdam'; -alter settings runtime FirstDayOfWeek = 'Monday'; -- Default, Monday..Sunday -alter settings runtime DecimalScale = 8; -alter settings runtime EnableDataStorageOptimisticLocking = true; -alter settings runtime UseDatabaseForeignKeyConstraints = true; -alter settings runtime UseOQLVersion2 = true; -alter settings runtime SslCertificateAlgorithm = 'PKIX'; -- PKIX or SunX509 +alter settings runtime ( AfterStartupMicroflow: 'Module.MF_Startup' ); -- must return Boolean (CE0142) +alter settings runtime ( BeforeShutdownMicroflow: 'Module.MF_Shutdown' ); +alter settings runtime ( HealthCheckMicroflow: 'Module.MF_HealthCheck' ); +alter settings runtime ( HashAlgorithm: 'BCrypt' ); +alter settings runtime ( BcryptCost: 12 ); +alter settings runtime ( JavaVersion: 'Java21' ); -- or '21'; see note below +alter settings runtime ( RoundingMode: 'HalfUp' ); +alter settings runtime ( AllowUserMultipleSessions: true ); +alter settings runtime ( ScheduledEventTimeZoneCode: 'Etc/UTC' ); +alter settings runtime ( DefaultTimeZoneCode: 'Europe/Amsterdam' ); +alter settings runtime ( FirstDayOfWeek: 'Monday' ); -- Default, Monday..Sunday +alter settings runtime ( DecimalScale: 8 ); +alter settings runtime ( EnableDataStorageOptimisticLocking: true ); +alter settings runtime ( UseDatabaseForeignKeyConstraints: true ); +alter settings runtime ( UseOQLVersion2: true ); +alter settings runtime ( SslCertificateAlgorithm: 'PKIX' ); -- PKIX or SunX509 ``` **Not every project stores every setting.** Mendix adds model settings over time — @@ -97,18 +97,20 @@ configuration, add `DatabasePassword = '…'` yourself. ```sql -- Full database configuration -alter settings configuration 'Default' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'localhost:5432', - DatabaseName = 'mydb', - DatabaseUserName = 'mendix', - DatabasePassword = 'mendix', - HttpPortNumber = 8080, - ServerPortNumber = 8090; +alter settings configuration 'Default' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'localhost:5432', + DatabaseName: 'mydb', + DatabaseUserName: 'mendix', + DatabasePassword: 'mendix', + HttpPortNumber: 8080, + ServerPortNumber: 8090 +); -- Update a single field -alter settings configuration 'Default' - DatabaseUrl = 'newhost:5432'; +alter settings configuration 'Default' ( + DatabaseUrl: 'newhost:5432' +); ``` `HttpPortNumber`, `ServerPortNumber`, `BcryptCost`, `DefaultTaskParallelism` and @@ -169,10 +171,11 @@ on shared values only: create configuration 'Staging'; -- Create with properties -create configuration 'Production' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'prod-db:5432', - HttpPortNumber = 8080; +create configuration 'Production' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'prod-db:5432', + HttpPortNumber: 8080 +); -- Drop a configuration drop configuration 'Staging'; @@ -197,7 +200,7 @@ alter settings LANGUAGE modify 'de_DE' (CheckCompleteness: true); alter settings LANGUAGE remove 'de_DE'; -- the default must already be enabled -alter settings LANGUAGE DefaultLanguageCode = 'en_US'; +alter settings LANGUAGE ( DefaultLanguageCode: 'en_US' ); ``` A language is identified by its **code** — "Arabic, Sudan" is derived from @@ -213,7 +216,7 @@ A language is identified by its **code** — "Arabic, Sudan" is derived from **Set the default language BEFORE authoring content.** The default is not only a fallback — it is the language a new caption is stored under, because Mendix has no -language-neutral text. `alter settings LANGUAGE DefaultLanguageCode = 'nl_NL'` +language-neutral text. `alter settings LANGUAGE (DefaultLanguageCode: 'nl_NL')` *after* creating a page leaves that page's texts in the old language, and nothing reports it: `mx check` is 0 errors either way and the symptom shows up only in Studio Pro, as the empty-caption placeholder plus a "no translation" warning. @@ -231,9 +234,10 @@ list. ```sql -alter settings workflows - UserEntity = 'System.User', - DefaultTaskParallelism = 3; +alter settings workflows ( + UserEntity: 'System.User', + DefaultTaskParallelism: 3 +); ``` ### Workflow Groups (Mendix 11.2+) @@ -281,24 +285,26 @@ Four things worth knowing: ### PostgreSQL Configuration ```sql -alter settings configuration 'Default' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'localhost:5432', - DatabaseName = 'myapp', - DatabaseUserName = 'mendix', - DatabasePassword = 'mendix', - HttpPortNumber = 8080; +alter settings configuration 'Default' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'localhost:5432', + DatabaseName: 'myapp', + DatabaseUserName: 'mendix', + DatabasePassword: 'mendix', + HttpPortNumber: 8080 +); ``` ### SQL Server Configuration ```sql -alter settings configuration 'Default' - DatabaseType = 'SqlServer', - DatabaseUrl = 'localhost:1433', - DatabaseName = 'myapp', - DatabaseUserName = 'sa', - DatabasePassword = 'MyPassword', - HttpPortNumber = 8080; +alter settings configuration 'Default' ( + DatabaseType: 'SqlServer', + DatabaseUrl: 'localhost:1433', + DatabaseName: 'myapp', + DatabaseUserName: 'sa', + DatabasePassword: 'MyPassword', + HttpPortNumber: 8080 +); ``` ## Checklist diff --git a/.claude/skills/mendix/regular-expressions/SKILL.md b/.claude/skills/mendix/regular-expressions/SKILL.md index 0f70dd8847..9ce048de96 100644 --- a/.claude/skills/mendix/regular-expressions/SKILL.md +++ b/.claude/skills/mendix/regular-expressions/SKILL.md @@ -102,7 +102,7 @@ create entity Val.Person ( Code: String(20) unique error message 'Code must be unique' ); -alter entity Val.Person modify attribute Email String(200) +alter entity Val.Person modify attribute Email: String(200) not null error message 'Email is required'; ``` diff --git a/.claude/skills/mendix/write-layouts/SKILL.md b/.claude/skills/mendix/write-layouts/SKILL.md index 8a9055d5f1..2e17bccd5a 100644 --- a/.claude/skills/mendix/write-layouts/SKILL.md +++ b/.claude/skills/mendix/write-layouts/SKILL.md @@ -96,8 +96,8 @@ survive. Same operations as `ALTER PAGE`: ```sql alter layout MyModule.App_Default { insert into layoutContainer.top { snippetcall bar (snippet: MyModule.SNIPPET_ThemeBar) }; - set Content = 'My App' on brandText; - drop widget oldBanner; + set (Content: 'My App') on brandText; + drop oldBanner; }; ``` diff --git a/cmd/mxcli/cmd_new.go b/cmd/mxcli/cmd_new.go index 0304d1bb37..62b88cf40a 100644 --- a/cmd/mxcli/cmd_new.go +++ b/cmd/mxcli/cmd_new.go @@ -266,7 +266,7 @@ Examples: if lowered, err := alignJavaVersion(mprPath, os.Stdout); err != nil { fmt.Fprintf(os.Stderr, " Warning: could not align the project's Java version: %v\n", err) fmt.Fprintln(os.Stderr, " If the first build fails with 'release version NN not supported',") - fmt.Fprintln(os.Stderr, " run: mxcli -p .mpr -c \"alter settings runtime JavaVersion = '21'\"") + fmt.Fprintln(os.Stderr, " run: mxcli -p .mpr -c \"alter settings runtime ( JavaVersion: '21' )\"") } else if lowered { fmt.Println() } diff --git a/cmd/mxcli/cmd_new_java.go b/cmd/mxcli/cmd_new_java.go index 608ac3efad..19ad99fce9 100644 --- a/cmd/mxcli/cmd_new_java.go +++ b/cmd/mxcli/cmd_new_java.go @@ -56,13 +56,13 @@ func alignJavaVersion(projectPath string, out io.Writer) (bool, error) { return false, nil } - script := fmt.Sprintf("alter settings runtime JavaVersion = '%d';\n", fallback) + script := fmt.Sprintf("alter settings runtime ( JavaVersion: '%d' );\n", fallback) if err := runMDL(projectPath, script, io.Discard); err != nil { return false, fmt.Errorf("lowering JavaVersion from %d to %d: %w", major, fallback, err) } fmt.Fprintf(out, " Java %d → %d: no JDK %d on this machine, and the project would not build.\n", major, fallback, major) fmt.Fprintf(out, " Install a JDK %d and set JavaVersion back to %d to build for it:\n", major, major) - fmt.Fprintf(out, " mxcli -p .mpr -c \"alter settings runtime JavaVersion = '%d'\"\n", major) + fmt.Fprintf(out, " mxcli -p .mpr -c \"alter settings runtime ( JavaVersion: '%d' )\"\n", major) return true, nil } diff --git a/cmd/mxcli/docker/detect.go b/cmd/mxcli/docker/detect.go index 723be44742..9bba775481 100644 --- a/cmd/mxcli/docker/detect.go +++ b/cmd/mxcli/docker/detect.go @@ -350,7 +350,7 @@ func resolveJDK(major int) (string, error) { // be noise. if major != DefaultJavaMajor { msg += fmt.Sprintf("\n This is the project's own Settings > Model > JavaVersion. To build for a"+ - "\n release you already have instead: mxcli -p .mpr -c \"alter settings runtime JavaVersion = '%d'\"", DefaultJavaMajor) + "\n release you already have instead: mxcli -p .mpr -c \"alter settings runtime ( JavaVersion: '%d' )\"", DefaultJavaMajor) } return "", fmt.Errorf("%s", msg) } diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index 8f094e8420..aac434a206 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -96,8 +96,8 @@ func init() { "if not exists", "if exists", "idempotent", "alter entities", "bulk", "every entity", "all entities", "where persistent", }, - Syntax: "ALTER ENTITY Module.Name ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [constraints];\nALTER ENTITY Module.Name DROP ATTRIBUTE [IF EXISTS] AttrName;\nALTER ENTITY Module.Name RENAME ATTRIBUTE OldName TO NewName;\nALTER ENTITY Module.Name MODIFY ATTRIBUTE AttrName Type [DEFAULT val];\nALTER ENTITY Module.Name DROP DEFAULT ON ATTRIBUTE AttrName;\nALTER ENTITY Module.Name ADD INDEX [name] [ON] (attr1, attr2);\nALTER ENTITY Module.Name SET DOCUMENTATION 'text';\nALTER ENTITY Module.Name SET POSITION (x, y);\nALTER ENTITY Module.Name ADD EVENT HANDLER ON BEFORE COMMIT CALL Module.MF RAISE ERROR;\nALTER ENTITIES [IN Module] ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [, ...]\n [WHERE PERSISTENT | WHERE NON-PERSISTENT];\n\nALTER ENTITIES is the bulk form: one statement applied to every entity in a\nmodule instead of one statement per entity. Only ADD ATTRIBUTE is offered --\nDROP and RENAME aimed at a set are destructive by a typo, and SET POSITION on\nevery entity is meaningless. Pair it with IF NOT EXISTS so the script re-runs.\n\nWHERE filters by persistence, using the same words CREATE ENTITY uses. A VIEW\nentity matches NEITHER: its rows come from an OQL query, so it is not the\npersistent/non-persistent distinction this filter means.\n\nWITHOUT IN, the sweep covers the whole project but SKIPS System and every\nMarketplace module, reporting which -- an upgrade replaces those modules and\nwould take the attribute with it. Naming a module with IN is taken as meaning\nit, so a deliberate edit there is still possible.\n\nSET POSITION places the entity in the domain-model editor, and CREATE ENTITY\ntakes the same thing as an @Position(x, y) annotation. Both are the box's\nCENTRE, not its top-left corner. An entity created without one takes the next\nslot in a wrapping grid, which is a default rather than a layout: to arrange a\nwhole module from its association graph, run 'mxcli layout -p app.mpr'\n(--dry-run first; it replaces positions you set by hand).\n\nMODIFY ATTRIBUTE always takes a type — restate it even when you are only\nchanging the default. There is no 'MODIFY ATTRIBUTE X SET DEFAULT v' form:\nSET would be read as the type name. Use DROP DEFAULT to clear one.\n\nIF NOT EXISTS / IF EXISTS make the add/drop a no-op (skipped, not an error)\nwhen the attribute is already present / already gone — so a domain script\nre-runs cleanly. For a whole script, 'mxcli exec --continue-on-error' reports\neach failed statement and keeps going instead of halting at the first.\n\nRENAME ATTRIBUTE also rewrites every reference to the attribute: the stored\nqualified names (microflow create/change members, page widgets, the entity's own\nvalidation and access rules) AND the bare steps inside XPath constraints, which\nare resolved to their owning entity first so another entity's identically-named\nattribute is left alone. A constraint that cannot be resolved is reported and\nleft unchanged, never guessed at. Uses inside microflow expressions ($obj/Attr)\nare free text and are NOT rewritten; mxbuild reports those as CE0117.", - Example: "ALTER ENTITY Shop.Customer ADD ATTRIBUTE Phone: String(20);\nALTER ENTITY Shop.Customer ADD ATTRIBUTE IF NOT EXISTS Phone: String(20); -- re-runnable\nALTER ENTITY Shop.Customer DROP ATTRIBUTE IF EXISTS OldField; -- re-runnable\nALTER ENTITY Shop.Customer RENAME ATTRIBUTE Email TO EmailAddress;\nALTER ENTITY Shop.Customer MODIFY ATTRIBUTE Phone String(30) DEFAULT ''; -- type restated\nALTER ENTITY Shop.Customer DROP DEFAULT ON ATTRIBUTE Phone; -- clear a default\nALTER ENTITY Shop.Customer ADD INDEX ON (EmailAddress);\nALTER ENTITY Shop.Customer\n ADD EVENT HANDLER ON BEFORE COMMIT CALL Shop.Validate($currentObject) RAISE ERROR;\n\n-- give every persistent entity in a module an audit trail, in one statement\nALTER ENTITIES IN Shop\n ADD ATTRIBUTE IF NOT EXISTS CreatedDate: AutoCreatedDate,\n ADD ATTRIBUTE IF NOT EXISTS ChangedDate: AutoChangedDate\n WHERE PERSISTENT;", + Syntax: "ALTER ENTITY Module.Name ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [constraints];\nALTER ENTITY Module.Name DROP ATTRIBUTE [IF EXISTS] AttrName;\nALTER ENTITY Module.Name RENAME ATTRIBUTE OldName TO NewName;\nALTER ENTITY Module.Name MODIFY ATTRIBUTE AttrName: Type [DEFAULT val];\nALTER ENTITY Module.Name DROP DEFAULT ON ATTRIBUTE AttrName;\nALTER ENTITY Module.Name ADD INDEX [name] [ON] (attr1, attr2);\nALTER ENTITY Module.Name SET DOCUMENTATION 'text';\nALTER ENTITY Module.Name SET POSITION (x, y);\nALTER ENTITY Module.Name SET (AllowCreateChangeLocally: true|false); -- external entity\nALTER ENTITY Module.Name ADD EVENT HANDLER ON BEFORE COMMIT CALL Module.MF RAISE ERROR;\nALTER ENTITIES [IN Module] ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [, ...]\n [WHERE PERSISTENT | WHERE NON-PERSISTENT];\n\nALTER ENTITIES is the bulk form: one statement applied to every entity in a\nmodule instead of one statement per entity. Only ADD ATTRIBUTE is offered --\nDROP and RENAME aimed at a set are destructive by a typo, and SET POSITION on\nevery entity is meaningless. Pair it with IF NOT EXISTS so the script re-runs.\n\nWHERE filters by persistence, using the same words CREATE ENTITY uses. A VIEW\nentity matches NEITHER: its rows come from an OQL query, so it is not the\npersistent/non-persistent distinction this filter means.\n\nWITHOUT IN, the sweep covers the whole project but SKIPS System and every\nMarketplace module, reporting which -- an upgrade replaces those modules and\nwould take the attribute with it. Naming a module with IN is taken as meaning\nit, so a deliberate edit there is still possible.\n\nSET POSITION places the entity in the domain-model editor, and CREATE ENTITY\ntakes the same thing as an @Position(x, y) annotation. Both are the box's\nCENTRE, not its top-left corner. An entity created without one takes the next\nslot in a wrapping grid, which is a default rather than a layout: to arrange a\nwhole module from its association graph, run 'mxcli layout -p app.mpr'\n(--dry-run first; it replaces positions you set by hand).\n\nMODIFY ATTRIBUTE always takes a type — restate it even when you are only\nchanging the default. There is no 'MODIFY ATTRIBUTE X SET DEFAULT v' form:\nSET would be read as the type name. Use DROP DEFAULT to clear one.\n\nIF NOT EXISTS / IF EXISTS make the add/drop a no-op (skipped, not an error)\nwhen the attribute is already present / already gone — so a domain script\nre-runs cleanly. For a whole script, 'mxcli exec --continue-on-error' reports\neach failed statement and keeps going instead of halting at the first.\n\nRENAME ATTRIBUTE also rewrites every reference to the attribute: the stored\nqualified names (microflow create/change members, page widgets, the entity's own\nvalidation and access rules) AND the bare steps inside XPath constraints, which\nare resolved to their owning entity first so another entity's identically-named\nattribute is left alone. A constraint that cannot be resolved is reported and\nleft unchanged, never guessed at. Uses inside microflow expressions ($obj/Attr)\nare free text and are NOT rewritten; mxbuild reports those as CE0117.", + Example: "ALTER ENTITY Shop.Customer ADD ATTRIBUTE Phone: String(20);\nALTER ENTITY Shop.Customer ADD ATTRIBUTE IF NOT EXISTS Phone: String(20); -- re-runnable\nALTER ENTITY Shop.Customer DROP ATTRIBUTE IF EXISTS OldField; -- re-runnable\nALTER ENTITY Shop.Customer RENAME ATTRIBUTE Email TO EmailAddress;\nALTER ENTITY Shop.Customer MODIFY ATTRIBUTE Phone: String(30) DEFAULT ''; -- type restated\nALTER ENTITY Shop.Customer DROP DEFAULT ON ATTRIBUTE Phone; -- clear a default\nALTER ENTITY Shop.Customer ADD INDEX ON (EmailAddress);\nALTER ENTITY Shop.Customer\n ADD EVENT HANDLER ON BEFORE COMMIT CALL Shop.Validate($currentObject) RAISE ERROR;\n\n-- give every persistent entity in a module an audit trail, in one statement\nALTER ENTITIES IN Shop\n ADD ATTRIBUTE IF NOT EXISTS CreatedDate: AutoCreatedDate,\n ADD ATTRIBUTE IF NOT EXISTS ChangedDate: AutoChangedDate\n WHERE PERSISTENT;", SeeAlso: []string{"domain-model.entity.create", "domain-model.entity.attributes"}, }) diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index e1a662c6ec..7830d1ff95 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -55,12 +55,17 @@ func init() { " ErrorHandlingMicroflow: microflow Module.HandleError\n" + ")\n" + "[HEADERS ('Key': 'Value')];\n\n" + + "ALTER CONSUMED ODATA SERVICE Module.Name SET (Key: value, ...); -- CREATE's keys\n" + + "ALTER PUBLISHED ODATA SERVICE Module.Name SET (Key: value, ...);\n\n" + "CREATE EXTERNAL ENTITY Module.Name\n" + " FROM CONSUMED ODATA SERVICE Module.Client\n" + " (EntitySet: 'Name', RemoteName: 'Name')\n" + " (Attr: Type, ...);\n\n" + "CREATE EXTERNAL ENTITIES FROM Module.Client\n" + - " [INTO Module] [ENTITIES (Name1, Name2)];", + " [INTO Module] [ENTITIES (Name1, Name2)];\n\n" + + "ALTER ENTITY Module.Name SET (AllowCreateChangeLocally: true);\n\n" + + "-- `SET Key = value, ...` (no parentheses) still runs and warns MDL-DEPR061;\n" + + "-- `SET allow_create_change_locally = true` warns MDL-DEPR063.", Example: "CREATE CONSTANT MyModule.SvcUrl TYPE String DEFAULT 'https://api.example.com/odata/v4/';\n\nCREATE CONSUMED ODATA SERVICE MyModule.SalesforceAPI (\n Version: '1.0',\n ODataVersion: OData4,\n MetadataUrl: 'https://api.example.com/odata/$metadata',\n Timeout: 300,\n ServiceUrl: MyModule.SvcUrl\n);\n\nCREATE EXTERNAL ENTITIES FROM MyModule.SalesforceAPI INTO Integration;", SeeAlso: []string{"odata", "odata.publish", "odata.show"}, }) diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 50f6410ae4..aae68a05a2 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -369,8 +369,8 @@ CREATE OR REPLACE NAVIGATION TabletOffline "settings", "project settings", "configuration", "startup", "shutdown", "hash algorithm", "java version", }, - Syntax: "SHOW SETTINGS;\nDESCRIBE SETTINGS;\nDESCRIBE SETTINGS CONFIGURATION ''; -- just one configuration\nALTER SETTINGS RUNTIME = ; -- MODEL is a deprecated alias\nALTER SETTINGS CONFIGURATION '' = ;", - Example: "SHOW SETTINGS;\nALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Module.MF_Startup';", + Syntax: "SHOW SETTINGS;\nDESCRIBE SETTINGS;\nDESCRIBE SETTINGS CONFIGURATION ''; -- just one configuration\nALTER SETTINGS RUNTIME (: , ...); -- MODEL is a deprecated alias\nALTER SETTINGS CONFIGURATION '' (: , ...);", + Example: "SHOW SETTINGS;\nALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Module.MF_Startup');", SeeAlso: []string{"settings.show", "settings.alter"}, }) @@ -460,33 +460,37 @@ create or modify translations in Administration for nl_NL ( "optimistic locking", "concurrency", "lost update", "workflow group", "workflow groups", "add group", "task assignment", }, - Syntax: `ALTER SETTINGS RUNTIME = ; -ALTER SETTINGS CONFIGURATION '' = , ...; + Syntax: `ALTER SETTINGS RUNTIME (: , ...); +ALTER SETTINGS CONFIGURATION '' (: , ...); ALTER SETTINGS CONSTANT '' VALUE '' IN CONFIGURATION ''; ALTER SETTINGS DROP CONSTANT '' IN CONFIGURATION ''; -ALTER SETTINGS LANGUAGE DefaultLanguageCode = ''; +ALTER SETTINGS LANGUAGE (DefaultLanguageCode: ''); ALTER SETTINGS LANGUAGE ADD '' [(CheckCompleteness: true, CustomDateFormat: '')]; ALTER SETTINGS LANGUAGE ADD OR MODIFY '' [(...)]; ALTER SETTINGS LANGUAGE MODIFY '' (CheckCompleteness: true, ...); ALTER SETTINGS LANGUAGE REMOVE ''; -ALTER SETTINGS WORKFLOWS UserEntity = ''; +ALTER SETTINGS WORKFLOWS (UserEntity: ''); ALTER SETTINGS WORKFLOWS ADD [OR MODIFY] GROUP '' [(Description: '')]; ALTER SETTINGS WORKFLOWS MODIFY GROUP '' (Description: ''); ALTER SETTINGS WORKFLOWS REMOVE GROUP ''; -CREATE [OR MODIFY] CONFIGURATION '' [ = , ...]; -DROP CONFIGURATION '';`, - Example: `ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Module.MF_Startup'; -ALTER SETTINGS RUNTIME HashAlgorithm = 'BCrypt'; -ALTER SETTINGS RUNTIME EnableDataStorageOptimisticLocking = true; -ALTER SETTINGS CONFIGURATION 'Default' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'localhost:5432', - DatabaseName = 'mydb'; +CREATE [OR MODIFY] CONFIGURATION '' [(: , ...)]; +DROP CONFIGURATION ''; + +-- A property is Key: value in a ( … ) list, as everywhere else in MDL (R3). +-- Key = value, … without the parentheses still runs and warns MDL-DEPR060.`, + Example: `ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Module.MF_Startup'); +ALTER SETTINGS RUNTIME (HashAlgorithm: 'BCrypt', EnableDataStorageOptimisticLocking: true); +ALTER SETTINGS CONFIGURATION 'Default' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'localhost:5432', + DatabaseName: 'mydb' +); ALTER SETTINGS CONSTANT 'BusinessEvents.ServerUrl' VALUE 'kafka:9092' IN CONFIGURATION 'Default'; -CREATE CONFIGURATION 'Production' - DatabaseType = 'PostgreSql', - HttpPortNumber = 8080; +CREATE CONFIGURATION 'Production' ( + DatabaseType: 'PostgreSql', + HttpPortNumber: 8080 +); -- LANGUAGE ADD/REMOVE change the ENABLED languages — the list under App -- Settings > Languages, and the only languages a build emits anything for. A @@ -719,9 +723,9 @@ CE0135 "No regular expression specified" at build time. REQUIRED and UNIQUE rules are written as attribute constraints instead, on CREATE ENTITY or ALTER ENTITY: - ALTER ENTITY Shop.Product MODIFY ATTRIBUTE Email string(200) + ALTER ENTITY Shop.Product MODIFY ATTRIBUTE Email: string(200) NOT NULL ERROR MESSAGE 'Email is required'; - ALTER ENTITY Shop.Product MODIFY ATTRIBUTE Code string(20) + ALTER ENTITY Shop.Product MODIFY ATTRIBUTE Code: string(20) UNIQUE ERROR MESSAGE 'Code must be unique';`, Example: `CREATE REGULAR EXPRESSION Shop.EmailPattern ( Expression: '^[^@\s]+@[^@\s]+\.[^@\s]+$' diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 81e6b357bd..d19944a8c7 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -306,21 +306,23 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { " DesignProperties: ['Full width': ON]\n\n" + "ON A PAGE THAT ALREADY EXISTS, without rewriting it:\n\n" + " ALTER STYLING ON PAGE|SNIPPET Module.Name WIDGET \n" + - " SET Class = 'css-class', Style = 'css', 'Design property' = 'Value'|ON|OFF;\n\n" + + " SET (Class: 'css-class', Style: 'css', 'Design property': 'Value'|ON|OFF);\n\n" + " ALTER STYLING ON PAGE|SNIPPET Module.Name WIDGET \n" + " CLEAR DESIGN PROPERTIES;\n\n" + "The widget is named by its MDL NAME — the identifier after the widget\n" + "keyword (`ACTIONBUTTON btnSave`), not its caption. `DESCRIBE PAGE` prints\n" + "the names.\n\n" + - "A bare `Class =` REPLACES the widget's classes rather than adding to them.\n" + + "A bare `Class:` REPLACES the widget's classes rather than adding to them.\n" + "Read the current value first if you meant to append.\n\n" + "Reach for ALTER STYLING rather than CREATE OR REPLACE PAGE whenever only\n" + "the look changes: replacing the page rewrites every widget in it, so the\n" + - "diff is the whole document and anything MDL cannot yet spell is lost.", + "diff is the whole document and anything MDL cannot yet spell is lost.\n\n" + + "`SET Class = 'x', 'P' = ON` (no parentheses, `=`) still runs and warns\n" + + "MDL-DEPR062.", Example: "CONTAINER ctn (\n Class: 'my-card',\n DynamicClasses: if $currentObject/Priority = 'High' then 'card-danger' else 'card-normal'\n) {\n DYNAMICTEXT txt (Content: 'Styled text')\n}\n\n" + "-- Restyle one widget on a page that already exists\n" + "alter styling on page Sales.OrderOverview widget btnSave\n" + - " set Class = 'btn-primary', 'Spacing top' = 'Large';\n\n" + + " set (Class: 'btn-primary', 'Spacing top': 'Large');\n\n" + "-- Back to Atlas defaults\n" + "alter styling on snippet Sales.OrderRow widget ctnMain\n" + " clear design properties;", diff --git a/cmd/mxcli/testrunner/generator_endpoint_test.go b/cmd/mxcli/testrunner/generator_endpoint_test.go index 976513c798..1b695d3cf5 100644 --- a/cmd/mxcli/testrunner/generator_endpoint_test.go +++ b/cmd/mxcli/testrunner/generator_endpoint_test.go @@ -171,7 +171,7 @@ func TestEndpointCleanupCommands(t *testing.T) { state: projectState{afterStartup: "Mod.ASU", createdMxTest: true}, present: true, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Mod.ASU'", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Mod.ASU')", "DROP MODULE MxTest", }, }, @@ -180,7 +180,7 @@ func TestEndpointCleanupCommands(t *testing.T) { state: projectState{createdMxTest: false}, present: true, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = ''", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: '')", "DROP MICROFLOW MxTest.Test_test_1", "DROP MICROFLOW MxTest.Test_test_2", "DROP MICROFLOW " + endpointStartupFlow, @@ -191,7 +191,7 @@ func TestEndpointCleanupCommands(t *testing.T) { name: "drops nothing when the module never landed", state: projectState{afterStartup: "Mod.ASU", createdMxTest: true}, present: false, - want: []string{"ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Mod.ASU'"}, + want: []string{"ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Mod.ASU')"}, }, } @@ -220,7 +220,7 @@ func TestEndpointCleanupRestoreIsAlwaysFirst(t *testing.T) { {afterStartup: "Mod.ASU", createdMxTest: true}, } { cmds := endpointCleanupCommands(st, suiteTestFlowNames(suite), true) - if !strings.HasPrefix(cmds[0], "ALTER SETTINGS RUNTIME AfterStartupMicroflow") { + if !strings.HasPrefix(cmds[0], "ALTER SETTINGS RUNTIME (AfterStartupMicroflow") { t.Errorf("state %+v: first command is %q, want the after-startup restore", st, cmds[0]) } } diff --git a/cmd/mxcli/testrunner/host.go b/cmd/mxcli/testrunner/host.go index 20b02bf531..e6ba9c5629 100644 --- a/cmd/mxcli/testrunner/host.go +++ b/cmd/mxcli/testrunner/host.go @@ -61,7 +61,7 @@ func InstallHostedEndpoint(projectPath string, w io.Writer) (*HostedEndpoint, er h.Remove() return nil, fmt.Errorf("injecting the test endpoint: %w", err) } - if err := execMxcliCmd(projectPath, "ALTER SETTINGS RUNTIME AfterStartupMicroflow = "+quoteMDLString(endpointStartupFlow)); err != nil { + if err := execMxcliCmd(projectPath, "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: "+quoteMDLString(endpointStartupFlow)+")"); err != nil { h.Remove() return nil, fmt.Errorf("pointing after-startup at the endpoint: %w", err) } diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index b14a996f9b..fae3d0ea33 100644 --- a/cmd/mxcli/testrunner/runner.go +++ b/cmd/mxcli/testrunner/runner.go @@ -667,7 +667,7 @@ func moduleExists(projectPath, name string) (bool, error) { // changing projects that run at another level (mendixlabs/mxcli#802). func setupCommands(startupFlow string) []string { return []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = " + quoteMDLString(startupFlow), + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: " + quoteMDLString(startupFlow) + ")", } } @@ -714,9 +714,9 @@ func execMDLScript(projectPath, mdl, namePattern string) error { // injection landed does not report a spurious cleanup failure. func cleanupCommands(st projectState, mxTestPresent bool) []string { // Restore the original after-startup microflow, or clear it if there was none. - restore := "ALTER SETTINGS RUNTIME AfterStartupMicroflow = ''" + restore := "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: '')" if st.afterStartup != "" { - restore = "ALTER SETTINGS RUNTIME AfterStartupMicroflow = " + quoteMDLString(st.afterStartup) + restore = "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: " + quoteMDLString(st.afterStartup) + ")" } cmds := []string{restore} if !mxTestPresent { diff --git a/cmd/mxcli/testrunner/runner_cleanup_test.go b/cmd/mxcli/testrunner/runner_cleanup_test.go index d5f0f5e460..03f5642fa1 100644 --- a/cmd/mxcli/testrunner/runner_cleanup_test.go +++ b/cmd/mxcli/testrunner/runner_cleanup_test.go @@ -134,7 +134,7 @@ func TestCleanupCommands(t *testing.T) { state: projectState{afterStartup: "MyFirstModule.ASU_Startup", createdMxTest: true}, present: true, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'MyFirstModule.ASU_Startup'", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'MyFirstModule.ASU_Startup')", "DROP MODULE MxTest", }, }, @@ -143,7 +143,7 @@ func TestCleanupCommands(t *testing.T) { state: projectState{createdMxTest: true}, present: true, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = ''", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: '')", "DROP MODULE MxTest", }, }, @@ -154,7 +154,7 @@ func TestCleanupCommands(t *testing.T) { state: projectState{afterStartup: "Mod.Flow"}, present: true, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Mod.Flow'", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Mod.Flow')", "DROP MICROFLOW MxTest.TestRunner", }, }, @@ -165,7 +165,7 @@ func TestCleanupCommands(t *testing.T) { state: projectState{afterStartup: "Mod.Flow", createdMxTest: true}, present: false, want: []string{ - "ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'Mod.Flow'", + "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'Mod.Flow')", }, }, } @@ -189,7 +189,7 @@ func TestCleanupCommands(t *testing.T) { func TestCleanupCommands_RestoreIsWellFormed(t *testing.T) { parsed := parseSettingValue(" AfterStartupMicroflow = 'MyFirstModule.ASU_Startup',") restore := cleanupCommands(projectState{afterStartup: parsed}, true)[0] - want := "ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'MyFirstModule.ASU_Startup'" + want := "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: 'MyFirstModule.ASU_Startup')" if restore != want { t.Errorf("restore command = %q, want %q", restore, want) } diff --git a/cmd/mxcli/testrunner/runner_endpoint.go b/cmd/mxcli/testrunner/runner_endpoint.go index 74eb3883a6..e80204045d 100644 --- a/cmd/mxcli/testrunner/runner_endpoint.go +++ b/cmd/mxcli/testrunner/runner_endpoint.go @@ -211,9 +211,9 @@ func endpointReadyTimeout(suiteTimeout time.Duration) time.Duration { // when the module was already the user's, each generated document is named // explicitly so nothing of theirs is touched. func endpointCleanupCommands(st projectState, flows []string, mxTestPresent bool) []string { - restore := "ALTER SETTINGS RUNTIME AfterStartupMicroflow = ''" + restore := "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: '')" if st.afterStartup != "" { - restore = "ALTER SETTINGS RUNTIME AfterStartupMicroflow = " + quoteMDLString(st.afterStartup) + restore = "ALTER SETTINGS RUNTIME (AfterStartupMicroflow: " + quoteMDLString(st.afterStartup) + ")" } cmds := []string{restore} if !mxTestPresent { diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 9ced9a4d4a..7be500653b 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -83,12 +83,12 @@ CREATE CONSTANT MyModule.EnableLogging TYPE Boolean DEFAULT true; | List consumed odata services | `LIST CONSUMED ODATA SERVICES [IN Module];` | Consumed OData services | | Describe consumed odata service | `DESCRIBE CONSUMED ODATA SERVICE Module.Name;` | Full MDL output | | Create consumed odata service | `CREATE [OR MODIFY] CONSUMED ODATA SERVICE Module.Name (...);` | Version, MetadataUrl, Timeout, etc. | -| Alter consumed odata service | `ALTER CONSUMED ODATA SERVICE Module.Name SET Key = Value;` | | +| Alter consumed odata service | `ALTER CONSUMED ODATA SERVICE Module.Name SET (Key: Value, ...);` | | | Drop consumed odata service | `DROP CONSUMED ODATA SERVICE Module.Name;` | | | List published odata services | `LIST PUBLISHED ODATA SERVICES [IN Module];` | Published OData services | | Describe published odata service | `DESCRIBE PUBLISHED ODATA SERVICE Module.Name;` | Full MDL output | | Create published odata service | `CREATE [OR MODIFY] PUBLISHED ODATA SERVICE Module.Name (...) AUTHENTICATION ... { PUBLISH ENTITY ... };` | | -| Alter published odata service | `ALTER PUBLISHED ODATA SERVICE Module.Name SET Key = Value;` | | +| Alter published odata service | `ALTER PUBLISHED ODATA SERVICE Module.Name SET (Key: Value, ...);` | | | Drop published odata service | `DROP PUBLISHED ODATA SERVICE Module.Name;` | | | List external entities | `LIST EXTERNAL ENTITIES [IN Module];` | OData-backed entities | | List external actions | `LIST EXTERNAL ACTIONS [IN Module];` | Actions used in microflows | @@ -300,11 +300,11 @@ CREATE OR REPLACE NAVIGATION Responsive |-----------|--------|-------| | List settings | `LIST SETTINGS;` | Overview of all settings parts | | Describe settings | `DESCRIBE SETTINGS;` | Full MDL output (round-trippable) | -| Alter model settings | `ALTER SETTINGS RUNTIME Key = Value;` | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. | -| Alter configuration | `ALTER SETTINGS CONFIGURATION 'Name' Key = Value;` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | +| Alter model settings | `ALTER SETTINGS RUNTIME (Key: Value, ...);` | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. | +| Alter configuration | `ALTER SETTINGS CONFIGURATION 'Name' (Key: Value, ...);` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | | Alter constant | `ALTER SETTINGS CONSTANT 'Name' VALUE 'val' IN CONFIGURATION 'cfg';` | Override constant per configuration | -| Alter language | `ALTER SETTINGS LANGUAGE Key = Value;` | DefaultLanguageCode | -| Alter workflows | `ALTER SETTINGS WORKFLOWS Key = Value;` | UserEntity, DefaultTaskParallelism | +| Alter language | `ALTER SETTINGS LANGUAGE (Key: Value);` | DefaultLanguageCode | +| Alter workflows | `ALTER SETTINGS WORKFLOWS (Key: Value, ...);` | UserEntity, DefaultTaskParallelism | ## Business Events @@ -421,15 +421,15 @@ Modify an existing page or snippet's widget tree in-place without full `CREATE O | Operation | Syntax | Notes | |-----------|--------|-------| -| Set property | `SET Caption = 'New' ON widgetName` | Single property on a widget | +| Set property | `SET (Caption: 'New') ON widgetName` | Single property on a widget | | Set multiple | `SET (Caption = 'Save', ButtonStyle = Success) ON btn` | Multiple properties at once | -| Page-level set | `SET Title = 'New Title'` | No ON clause for page properties | +| Page-level set | `SET (Title: 'New Title')` | No ON clause for page properties | | Insert after | `INSERT AFTER widgetName { widgets }` | Add widgets after target | | Insert before | `INSERT BEFORE widgetName { widgets }` | Add widgets before target | | Insert into | `INSERT INTO containerName { widgets }` | Append as the container's last child (fills an empty container) | -| Drop widgets | `DROP WIDGET name1, name2` | Remove widgets by name | +| Drop widgets | `DROP name1, name2` | Remove widgets by name | | Replace widget | `REPLACE widgetName WITH { widgets }` | Replace widget subtree | -| Pluggable prop | `SET 'showLabel' = false ON cbStatus` | Quoted name for pluggable widgets | +| Pluggable prop | `SET ('showLabel': false) ON cbStatus` | Quoted name for pluggable widgets | | Add variable | `ADD Variables $name: Type = 'expr'` | Add a page variable | | Drop variable | `DROP Variables $name` | Remove a page variable | @@ -438,15 +438,15 @@ Modify an existing page or snippet's widget tree in-place without full `CREATE O **Example:** ```sql ALTER PAGE Module.EditPage { - SET (Caption = 'Save & Close', ButtonStyle = Success) ON btnSave; - DROP WIDGET txtUnused; + SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave; + DROP txtUnused; INSERT AFTER txtEmail { TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone) } }; ALTER SNIPPET Module.NavMenu { - SET Caption = 'Dashboard' ON btnHome + SET (Caption: 'Dashboard') ON btnHome }; ``` diff --git a/docs-site/src/examples/alter-page.md b/docs-site/src/examples/alter-page.md index 2afd6c931d..4cb0f50b67 100644 --- a/docs-site/src/examples/alter-page.md +++ b/docs-site/src/examples/alter-page.md @@ -6,7 +6,7 @@ ```sql ALTER PAGE CRM.Customer_Edit { - SET (Caption = 'Save & Close', ButtonStyle = Success) ON btnSave + SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave }; ``` @@ -36,7 +36,7 @@ ALTER PAGE CRM.Customer_Overview { ```sql ALTER PAGE CRM.Customer_Edit { - DROP WIDGET txtLegacyField, lblOldNote + DROP txtLegacyField, lblOldNote }; ``` @@ -58,12 +58,12 @@ ALTER PAGE CRM.Customer_Edit { ```sql ALTER PAGE CRM.Customer_Edit { - SET Title = 'Edit Customer Details'; - SET Label = 'Email Address' ON txtEmail; + SET (Title: 'Edit Customer Details'); + SET (Label: 'Email Address') ON txtEmail; INSERT AFTER txtPhone { TEXTBOX txtWebsite (Label: 'Website', Attribute: Website) }; - DROP WIDGET lblInternalRef + DROP lblInternalRef }; ``` @@ -108,12 +108,12 @@ ALTER PAGE CRM.Customer_List { -- Remove a column ALTER PAGE CRM.Customer_List { - DROP WIDGET dgCustomers.OldNotes + DROP dgCustomers.OldNotes }; -- Rename a column header ALTER PAGE CRM.Customer_List { - SET Caption = 'E-mail Address' ON dgCustomers.Email + SET (Caption: 'E-mail Address') ON dgCustomers.Email }; ``` @@ -123,7 +123,7 @@ Use `DESCRIBE PAGE CRM.Customer_List` to discover column names. ```sql ALTER SNIPPET CRM.NavigationMenu { - SET Caption = 'Dashboard' ON btnHome; + SET (Caption: 'Dashboard') ON btnHome; INSERT AFTER btnHome { ACTIONBUTTON btnReports ( Caption: 'Reports', diff --git a/docs-site/src/language/alter-entity.md b/docs-site/src/language/alter-entity.md index e554339c0b..f436111636 100644 --- a/docs-site/src/language/alter-entity.md +++ b/docs-site/src/language/alter-entity.md @@ -49,10 +49,10 @@ a clause written where the type belongs is read *as* the type: ```mdl -- WRONG: `SET` is read as the type name, not as a keyword -ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Discount SET DEFAULT 0; +ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Discount: SET DEFAULT 0; -- Right: restate the type -ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Discount Decimal DEFAULT 0; +ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Discount: Decimal DEFAULT 0; ``` mxcli refuses the first form and names the alternatives. Before it did, that diff --git a/docs-site/src/language/project-settings.md b/docs-site/src/language/project-settings.md index 661230d847..41a3ae27d1 100644 --- a/docs-site/src/language/project-settings.md +++ b/docs-site/src/language/project-settings.md @@ -27,9 +27,9 @@ ALTER SETTINGS RUNTIME = ; Examples: ```sql -ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'MyModule.ACT_Startup'; -ALTER SETTINGS RUNTIME HashAlgorithm = 'BCrypt'; -ALTER SETTINGS RUNTIME JavaVersion = '17'; +ALTER SETTINGS RUNTIME ( AfterStartupMicroflow: 'MyModule.ACT_Startup' ); +ALTER SETTINGS RUNTIME ( HashAlgorithm: 'BCrypt' ); +ALTER SETTINGS RUNTIME ( JavaVersion: '17' ); ``` Mendix renamed the Java version property between versions — up to 11.6 it is stored @@ -48,9 +48,9 @@ ALTER SETTINGS CONFIGURATION '' = ; Examples: ```sql -ALTER SETTINGS CONFIGURATION 'default' DatabaseType = 'POSTGRESQL'; -ALTER SETTINGS CONFIGURATION 'default' DatabaseUrl = 'jdbc:postgresql://localhost:5432/myapp'; -ALTER SETTINGS CONFIGURATION 'default' HttpPortNumber = '8080'; +ALTER SETTINGS CONFIGURATION 'default' ( DatabaseType: 'POSTGRESQL' ); +ALTER SETTINGS CONFIGURATION 'default' ( DatabaseUrl: 'jdbc:postgresql://localhost:5432/myapp' ); +ALTER SETTINGS CONFIGURATION 'default' ( HttpPortNumber: '8080' ); ``` ### Constant Overrides @@ -92,7 +92,7 @@ ALTER SETTINGS LANGUAGE ADD 'ar_SD' (CheckCompleteness: true, CustomDateFormat: ALTER SETTINGS LANGUAGE MODIFY 'de_DE' (CheckCompleteness: true); -- make it the default (it must already be enabled) -ALTER SETTINGS LANGUAGE DefaultLanguageCode = 'de_DE'; +ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'de_DE' ); -- disable it ALTER SETTINGS LANGUAGE REMOVE 'de_DE'; @@ -131,13 +131,13 @@ So the order of these two statements changes the result: ```sql -- right: the page's texts are stored as nl_NL ALTER SETTINGS LANGUAGE ADD 'nl_NL'; -ALTER SETTINGS LANGUAGE DefaultLanguageCode = 'nl_NL'; +ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'nl_NL' ); CREATE PAGE MyModule.Opslaan ( Title: 'Opslaanpagina', ... ) { ... } -- wrong: the page is authored while en_US is still the default, so its texts -- are stored as en_US and stay there CREATE PAGE MyModule.Opslaan ( Title: 'Opslaanpagina', ... ) { ... } -ALTER SETTINGS LANGUAGE DefaultLanguageCode = 'nl_NL'; +ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'nl_NL' ); ``` Changing `DefaultLanguageCode` **does not move text that already exists** — it @@ -173,8 +173,8 @@ ALTER SETTINGS WORKFLOWS = ; Examples: ```sql -ALTER SETTINGS WORKFLOWS UserEntity = 'Administration.Account'; -ALTER SETTINGS WORKFLOWS DefaultTaskParallelism = '5'; +ALTER SETTINGS WORKFLOWS ( UserEntity: 'Administration.Account' ); +ALTER SETTINGS WORKFLOWS ( DefaultTaskParallelism: '5' ); ``` ## See Also diff --git a/docs-site/src/language/snippets.md b/docs-site/src/language/snippets.md index 8469362269..ffcf71ce16 100644 --- a/docs-site/src/language/snippets.md +++ b/docs-site/src/language/snippets.md @@ -94,7 +94,7 @@ Snippets support the same in-place modification operations as pages. See [ALTER ```sql ALTER SNIPPET MyModule.CustomerCard { - SET Caption = 'View Details' ON btnEdit; + SET (Caption: 'View Details') ON btnEdit; INSERT AFTER txtEmail { DYNAMICTEXT txtPhone (Content: '{1}', Attribute: Phone) }; diff --git a/docs-site/src/reference/page/alter-layout.md b/docs-site/src/reference/page/alter-layout.md index 6bb19eef02..f3419800f7 100644 --- a/docs-site/src/reference/page/alter-layout.md +++ b/docs-site/src/reference/page/alter-layout.md @@ -23,10 +23,10 @@ INSERT BEFORE widgetName { widget_definitions }; INSERT AFTER widgetName { widget_definitions }; -- Set a property on a widget -SET property = value ON widgetName; +SET (property: value) ON widgetName; -- Remove widgets -DROP WIDGET widgetName1, widgetName2; +DROP widgetName1, widgetName2; -- Replace a widget with new widgets REPLACE widgetName WITH { widget_definitions }; @@ -114,11 +114,11 @@ Change it, then remove it: ```sql ALTER LAYOUT MyModule.App_Default { - SET Content = 'Renamed' ON brand; + SET (Content: 'Renamed') ON brand; }; ALTER LAYOUT MyModule.App_Default { - DROP WIDGET brand; + DROP brand; }; ``` diff --git a/docs-site/src/reference/settings/alter-settings.md b/docs-site/src/reference/settings/alter-settings.md index c8593588b5..bd5957e1b3 100644 --- a/docs-site/src/reference/settings/alter-settings.md +++ b/docs-site/src/reference/settings/alter-settings.md @@ -2,31 +2,31 @@ ## Synopsis - ALTER SETTINGS RUNTIME key = value + ALTER SETTINGS RUNTIME ( key: value, ... ) - ALTER SETTINGS CONFIGURATION 'name' key = value + ALTER SETTINGS CONFIGURATION 'name' ( key: value, ... ) ALTER SETTINGS CONSTANT 'name' VALUE 'value' IN CONFIGURATION 'config' ALTER SETTINGS DROP CONSTANT 'name' IN CONFIGURATION 'config' - CREATE CONFIGURATION 'name' [key = value, ...] + CREATE CONFIGURATION 'name' [( key: value, ... )] DROP CONFIGURATION 'name' - ALTER SETTINGS LANGUAGE key = value + ALTER SETTINGS LANGUAGE ( key: value, ... ) ALTER SETTINGS LANGUAGE ADD [OR MODIFY] 'code' [( option: value, ... )] ALTER SETTINGS LANGUAGE MODIFY 'code' ( option: value, ... ) ALTER SETTINGS LANGUAGE REMOVE 'code' - ALTER SETTINGS WORKFLOWS key = value + ALTER SETTINGS WORKFLOWS ( key: value, ... ) ALTER SETTINGS WORKFLOWS ADD [OR MODIFY] GROUP 'name' [( Description: 'text' )] ALTER SETTINGS WORKFLOWS MODIFY GROUP 'name' ( Description: 'text' ) ALTER SETTINGS WORKFLOWS REMOVE GROUP 'name' ## Description -Modifies project settings by category. Each category has its own syntax and available keys. +Modifies project settings by category. Each category has its own syntax and available keys. Properties are written `Key: value` in a parenthesised list, as in every other MDL statement; the older `key = value, ...` without parentheses still runs and warns `MDL-DEPR060`, and `mxcli fmt --upgrade` rewrites it. **MODEL** settings control application-level behavior such as the after-startup microflow, hashing algorithm, and Java version. @@ -61,7 +61,7 @@ task's group targeting selects from. Groups need Mendix **11.2** or later. ### Set the after-startup microflow ```sql -ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'MyModule.ACT_Startup'; +ALTER SETTINGS RUNTIME ( AfterStartupMicroflow: 'MyModule.ACT_Startup' ); ``` > **The after-startup microflow must return `Boolean`.** Mendix build fails with **CE0142** @@ -71,13 +71,13 @@ ALTER SETTINGS RUNTIME AfterStartupMicroflow = 'MyModule.ACT_Startup'; ### Configure database type ```sql -ALTER SETTINGS CONFIGURATION 'default' DatabaseType = 'POSTGRESQL'; +ALTER SETTINGS CONFIGURATION 'default' ( DatabaseType: 'POSTGRESQL' ); ``` ### Set database URL for a configuration ```sql -ALTER SETTINGS CONFIGURATION 'production' DatabaseUrl = 'jdbc:postgresql://dbhost:5432/myapp'; +ALTER SETTINGS CONFIGURATION 'production' ( DatabaseUrl: 'jdbc:postgresql://dbhost:5432/myapp' ); ``` ### Override a constant in a configuration @@ -104,7 +104,7 @@ The code must already be enabled; `ALTER SETTINGS LANGUAGE DefaultLanguageCode` is validated against the enabled list. ```sql -ALTER SETTINGS LANGUAGE DefaultLanguageCode = 'en_US'; +ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'en_US' ); ``` ### Enable, change and disable a language @@ -132,7 +132,7 @@ the run reports how many source strings are affected. ### Configure workflow user entity ```sql -ALTER SETTINGS WORKFLOWS UserEntity = 'Administration.Account'; +ALTER SETTINGS WORKFLOWS ( UserEntity: 'Administration.Account' ); ``` ### Manage workflow groups @@ -170,7 +170,7 @@ List the groups with [`SHOW WORKFLOW GROUPS`](show-settings.md). ### Set Java version ```sql -ALTER SETTINGS RUNTIME JavaVersion = '17'; +ALTER SETTINGS RUNTIME ( JavaVersion: '17' ); ``` ### Remove a constant override from a configuration @@ -182,10 +182,11 @@ ALTER SETTINGS DROP CONSTANT 'MyModule.ApiBaseUrl' IN CONFIGURATION 'staging'; ### Create a new configuration ```sql -CREATE CONFIGURATION 'Staging' - DatabaseType = 'PostgreSql', - DatabaseUrl = 'staging-db:5432', - HttpPortNumber = 8080; +CREATE CONFIGURATION 'Staging' ( + DatabaseType: 'PostgreSql', + DatabaseUrl: 'staging-db:5432', + HttpPortNumber: 8080 +); ``` `DatabaseType` must name a Mendix database type — `Db2`, `Hsqldb`, `MySql`, diff --git a/docs-site/src/tools/docker-run.md b/docs-site/src/tools/docker-run.md index d0cd7a9dd2..ff621ddd51 100644 --- a/docs-site/src/tools/docker-run.md +++ b/docs-site/src/tools/docker-run.md @@ -28,8 +28,8 @@ The runtime uses configuration from the project's settings. You can view and mod ```sql SHOW SETTINGS; DESCRIBE SETTINGS; -ALTER SETTINGS CONFIGURATION 'default' DatabaseType = 'POSTGRESQL'; -ALTER SETTINGS CONFIGURATION 'default' HttpPortNumber = '8080'; +ALTER SETTINGS CONFIGURATION 'default' ( DatabaseType: 'POSTGRESQL' ); +ALTER SETTINGS CONFIGURATION 'default' ( HttpPortNumber: '8080' ); ``` ## Checking Project Health diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index fad6388442..c039e9a29b 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -102,7 +102,7 @@ Modifies an existing entity without full replacement. |-----------|--------|-------| | Add attribute | `alter entity Module.Name add attribute [if not exists] attr: type [constraints];` | Comma-separate the whole action to add several: `add attribute A: integer, add attribute B: string(20)`. `if not exists` skips instead of erroring, so the script re-runs | | Drop attribute | `alter entity Module.Name drop attribute [if exists] AttrName;` | `if exists` skips when it is already gone | -| Modify attributes | `alter entity Module.Name modify (attr: NewType [constraints]);` | Change type/constraints | +| Modify attribute | `alter entity Module.Name modify attribute Attr: NewType [constraints];` | Change type/constraints. Always `Name: Type`; without the colon warns MDL-DEPR065 | | Rename attribute | `alter entity Module.Name rename attribute OldName to NewName;` | Also rewrites stored references (microflow members, page widgets, validation/access rules) and XPath constraints. Microflow expressions are free text and are **not** rewritten | | Add index | `alter entity Module.Name add index [if not exists] [name] [on] (Col1 [asc\|desc], ...);` | `on` is optional (SQL-like). **Without `if not exists`, re-running is an error** — a second identical index fails the build with CE0072 | | Document an association | `/** What it links. */`
`create association Mod.C_P from Mod.C to Mod.P;`
or `... to Mod.P comment 'What it links.';` | Both spellings work on create; the doc comment wins when both are present. `comment` survives here — and only here among the CREATE statements — because it is an association's **only inline** spelling | @@ -256,7 +256,7 @@ expression specified". ```sql create entity Shop.Product ( Email: String(200) not null error message 'Required' ); -alter entity Shop.Product modify attribute Code String(20) unique error message 'Unique'; +alter entity Shop.Product modify attribute Code: String(20) unique error message 'Unique'; ``` A range bounded by another *attribute* cannot be authored in MDL, but survives a @@ -337,18 +337,19 @@ create scheduled event Ops.WeeklyReport ( | Show consumed odata services | `show consumed odata services [in module];` | Consumed OData services | | Describe consumed odata service | `describe consumed odata service Module.Name;` | Full MDL output | | Create consumed odata service | `create [or modify] consumed odata service Module.Name (...);` | Version, MetadataUrl, Timeout, etc. | -| Alter consumed odata service | `alter consumed odata service Module.Name set key = value;` | | +| Alter consumed odata service | `alter consumed odata service Module.Name set (Key: value, ...);` | | | Drop consumed odata service | `drop consumed odata service [if exists] Module.Name;` | | | Show published odata services | `show published odata services [in module];` | Published OData services | | Describe published odata service | `describe published odata service Module.Name;` | Full MDL output | | Create published odata service | `create [or modify] published odata service Module.Name (...) authentication ... { publish entity ... };` | | | Publish as GraphQL too | `create published odata service Module.Name (SupportsGraphQL: Yes) {...};` | Mendix 10.14+. Same location, clients POST a query. Exposed names must be unique beyond case (CE2881); query fields are camelCased | -| Alter published odata service | `alter published odata service Module.Name set key = value;` | | +| Alter published odata service | `alter published odata service Module.Name set (Key: value, ...);` | | | Drop published odata service | `drop published odata service [if exists] Module.Name;` | | | Show external entities | `show external entities [in module];` | OData-backed entities | | Show external actions | `show external actions [in module];` | Actions used in microflows | | Create external entity | `create [or modify] external entity Module.Name from consumed odata service Module.Client (...) (attrs);` | | | Create external entities | `create [or modify] external entities from Module.Client [into module] [entities (...)];` | Bulk from $metadata | +| Allow local create/change | `alter entity Module.Name set (AllowCreateChangeLocally: true);` | `create external entity`'s key. `set allow_create_change_locally = true` warns MDL-DEPR063 | | Grant OData access | `grant access on published odata service Module.Name to Module.Role, ...;` | | | Revoke OData access | `revoke access on published odata service Module.Name from Module.Role, ...;` | | | Show contract entities | `show contract entities from Module.Client;` | Browse cached $metadata | @@ -973,19 +974,19 @@ still flagged rather than guessed at. |-----------|--------|-------| | Show settings | `show settings;` | Overview of all settings parts | | Describe settings | `describe settings;` | Full MDL output (round-trippable) | -| Alter model settings | `alter settings runtime key = value;` | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. | -| Alter configuration | `alter settings configuration 'Name' key = value;` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | +| Alter model settings | `alter settings runtime (Key: value, ...);` | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. | +| Alter configuration | `alter settings configuration 'Name' (Key: value, ...);` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | | Alter constant | `alter settings constant 'Name' value 'val' in configuration 'cfg';` | Override constant per configuration | | Drop constant override | `alter settings drop constant 'Name' in configuration 'cfg';` | Reset to default value | | Create or modify configuration | `create or modify configuration 'Name' [key = value, ...];` | Upsert — what `describe settings` emits, so a described project replays onto a target that already has `Default` | | Create configuration | `create configuration 'Name' [key = value, ...];` | New server configuration. `DatabaseType` must be `Db2`, `Hsqldb`, `MySql`, `Oracle`, `PostgreSql`, `SapHana` or `SqlServer` (case-insensitive) | | Drop configuration | `drop configuration [if exists] 'Name';` | Remove a configuration | -| Alter language | `alter settings LANGUAGE key = value;` | DefaultLanguageCode (must already be enabled). Set it **before** creating pages — it decides what language their captions are stored in | +| Alter language | `alter settings LANGUAGE (Key: value);` | DefaultLanguageCode (must already be enabled). Set it **before** creating pages — it decides what language their captions are stored in | | Enable a language | `alter settings LANGUAGE add 'de_DE' [(CheckCompleteness: true, CustomDateFormat: 'yyyy-MM-dd')];` | Adds to the enabled list — the only languages a build emits translations for. A language is identified by its code; Studio Pro's "German, Germany" is derived for display and not stored | | Enable or modify (upsert) | `alter settings LANGUAGE add or modify 'de_DE' (CheckCompleteness: true);` | What `describe settings` emits, so a described project replays onto itself or onto one that already has the language | | Modify a language | `alter settings LANGUAGE modify 'de_DE' (CheckCompleteness: true);` | Changes only the options it names. `CheckCompleteness` turns on error reporting for texts with no translation in that language (the default language is always checked regardless) | | Disable a language | `alter settings LANGUAGE remove 'de_DE';` | The **default** language is refused (every missing translation falls back on it). Translations are NOT deleted — they stay in the model and stop being built; the run reports how many | -| Alter workflows | `alter settings workflows key = value;` | UserEntity, DefaultTaskParallelism, WorkflowEngineParallelism | +| Alter workflows | `alter settings workflows (Key: value, ...);` | UserEntity, DefaultTaskParallelism, WorkflowEngineParallelism | | Add a workflow group | `alter settings workflows add group 'Approvers' [(Description: 'Primary approval group')];` | The buckets under App Settings > Workflows > Groups that a user task's group targeting selects from. Mendix **11.2+**. `Description` is the only option — a `Settings$WorkflowGroup` stores Name and Description and nothing else, so the **name is the identity** and a second group differing only in case is refused | | Add or modify (upsert) | `alter settings workflows add or modify group 'Approvers' (Description: '...');` | What `describe settings` emits, so a described project replays onto itself | | Modify a workflow group | `alter settings workflows modify group 'Approvers' (Description: '...');` | Changes only the options it names, and keeps the group's element id — which is the **runtime's identity** for it (Mendix materialises one `System.WorkflowGroup` row per entry, keyed on that id), so an edit updates the row instead of replacing it | @@ -1490,6 +1491,7 @@ MDL uses explicit property declarations for pages: | Declare a placeholder | `placeholder Main` | **No body.** Exactly one must be named `Main` — mxbuild enforces it (**CE0848**/**CE0849**), and names must be unique (**CE0495**). `placeholder X { … }` is the page-side form and declares nothing (MDL083) | | Alter layout | `alter layout Module.Name { };` | Edits the stored document, so widgets MDL cannot spell survive. Refused for a Marketplace target | | Set a design property | `alter page Module.Page { set ('Row size': 'Small') on lvOrders; };` | An Atlas design property of that widget's **type** — quoted, case-sensitive; `show design properties for ` lists them. `on`/`off` for a toggle, where `off` removes the entry. Same document `alter styling` writes. A **multi-select** (`Hide on`) or **compound** (`Spacing`) property needs the inline `DesignProperties: [...]` form, since a `set` assignment carries one value | +| Restyle one widget | `alter styling on page Module.Page widget w set (Class: 'card', 'Full width': on);` | `set Class = …, 'P' = on` (no parentheses, `=`) warns MDL-DEPR062 | | Repoint one page | `alter page Module.Page { set Layout = Module.Layout [map (Old as New, …)]; };` | Rewrites the layout reference **and** every placeholder binding | | Set a design property on every widget of a type | `alter pages [in ] set 'Compact' = on, 'Striped' = on where widgettype = datagrid [dry run];` | The house-style sweep. `widgettype` takes the **MDL keyword**, which resolves to exactly one widget id — a `like '%datagrid%'` predicate also matches the data grid's *filter* widgets. Never a widget **name**: a name is unique only within its page. `dry run` previews against a discardable copy. A sweep that matches widgets and writes none of them exits non-zero | | Repoint many pages | `alter pages [in ] set layout = Module.Layout [map (…)] [where layout = Module.Old];` | The migration form. Marketplace pages are skipped and named. A `where layout` that names no real layout is an error, not a 0-page success | @@ -1816,7 +1818,7 @@ Bulk translation of every user-visible string, one file per language. Entries us | Replace | `create or replace translations ...` | The file is authoritative: a translation whose source it does not name is **REMOVED**, and the run says which. `in Module` **bounds** the deletion | | Remove a language's translations | `create or replace translations [in Module] for ( );` | An empty file is authoritative over nothing, so everything in scope goes — the only way to take a language's translations out of the model | | Show languages | `show languages;` | ⚠️ languages that **have translations**, not enabled ones — a stock app reports 8 while 1 is enabled. The enabled list is in `describe settings`. Needs `refresh catalog full` | -| Default language | `alter settings LANGUAGE DefaultLanguageCode = 'en_US';` | The language a translation file's left column is written in — **and the language a new `Caption:`/`Title:` is stored under**, so set it before authoring content. Changing it later does not move existing text and nothing warns | +| Default language | `alter settings LANGUAGE (DefaultLanguageCode: 'en_US');` | The language a translation file's left column is written in — **and the language a new `Caption:`/`Title:` is stored under**, so set it before authoring content. Changing it later does not move existing text and nothing warns | **A translation for a language the project has not enabled is discarded at build time** — it is stored in the model, passes `mx check`, and produces no diff --git a/mdl/ast/ast_alter_page.go b/mdl/ast/ast_alter_page.go index 602a06abd7..3656f62fdf 100644 --- a/mdl/ast/ast_alter_page.go +++ b/mdl/ast/ast_alter_page.go @@ -81,21 +81,6 @@ func (r WidgetRef) Name() string { return s } -// Spellings of the generic ALTER that are aliases of its canonical form -// (ADR-0011: an old form warns, and is rewritten mechanically). The visitor -// records which one a statement used; the executor maps it to a deprecation -// code. Nothing downstream of the validator may branch on these: both spellings -// build the identical operation. -const ( - // `set Key = value …` / `set (Key = value, …) …` — R3 puts `:` between a - // property and its value; `=` is comparison. - AlterAliasSetEquals = "set-equals" - // `set Key: value …` — properties are a parenthesised list (R2), even one. - AlterAliasSetUnparenthesised = "set-unparenthesised" - // `drop widget a, b` — the target names the element; the kind is its own. - AlterAliasDropWidget = "drop-widget" -) - // IsColumn returns true if this addresses a member of a widget: a grid column // (`dg.Name` or `dg column(Name)`) or a scroll-container region. func (r WidgetRef) IsColumn() bool { @@ -107,7 +92,6 @@ func (r WidgetRef) IsColumn() bool { type SetPropertyOp struct { Target WidgetRef // empty Widget for page-level SET Properties map[string]interface{} // property name -> value - Legacy string // AlterAlias* when an old spelling was used, else "" } func (s *SetPropertyOp) isAlterPageOperation() {} @@ -124,7 +108,6 @@ func (s *InsertWidgetOp) isAlterPageOperation() {} // DropWidgetOp represents: DROP WIDGET ref1, ref2, ... type DropWidgetOp struct { Targets []WidgetRef - Legacy string // AlterAliasDropWidget when written `drop widget …`, else "" } func (s *DropWidgetOp) isAlterPageOperation() {} diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 36f7f253e2..e19836106c 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -157,6 +157,40 @@ const ( // on a text template: the placeholders bound by position (R4, // ako/mxcli#751). PositionalTemplateArguments = "MDL-DEPR009" + + // Codes 060-069 and 101-103 are R3's (ako/mxcli#751, + // PROPOSAL_mdl_beta_syntax_freeze.md §3 R3): `:` sets a model property, so + // an `alter` sets properties in create's `( Key: value, … )` list, and a + // colon is written where a property list or an attribute definition has + // one and nowhere else. + + // AlterPageSetEquals is the generic alter's `set Key = value` / + // `set (Key = value, …)`. Numbered from 101 because it shipped with the + // generic alter (ako/mxcli#712) before the registry existed; the code is + // published, so it is kept. + AlterPageSetEquals = "MDL-DEPR101" + // AlterPageSetUnparenthesised is `set Key: value` without the list's + // parentheses. + AlterPageSetUnparenthesised = "MDL-DEPR102" + // AlterPageDropWidget is `drop widget a, b`. + AlterPageDropWidget = "MDL-DEPR103" + // SettingsAssignment is a settings property written `Key = value`, outside + // a list: `alter settings
`, `alter settings configuration` and + // `create configuration`. + SettingsAssignment = "MDL-DEPR060" + // ODataAlterAssignment is `alter … odata service X set Key = value, …`. + ODataAlterAssignment = "MDL-DEPR061" + // StylingAssignment is `alter styling … set Class = 'x', 'Prop' = on`. + StylingAssignment = "MDL-DEPR062" + // AllowCreateChangeLocally is `alter entity … set allow_create_change_locally + // = true`, the only snake-case `=` alter action. + AllowCreateChangeLocally = "MDL-DEPR063" + // AssociationClauseColon is `type: Reference` (also `owner:`, `storage:`) + // on an association: a clause, which takes no colon. + AssociationClauseColon = "MDL-DEPR064" + // ModifyAttributeColon is `modify attribute A T`: an attribute definition + // is always `Name: Type`. + ModifyAttributeColon = "MDL-DEPR065" ) // entries is the registry. Append only: a code is never reused or renumbered, @@ -283,8 +317,8 @@ var entries = []Entry{ Rewrite: Rewrite{Structural: "section name: `model` becomes `runtime`"}, RemovedIn: 2, Note: "`runtime` is the App Settings tab that holds these values in Studio Pro (R10); `model` also collided with the agent-editor document type.", - Example: "alter settings model AfterStartupMicroflow = 'M.Startup';", - CanonicalExample: "alter settings runtime AfterStartupMicroflow = 'M.Startup';", + Example: "alter settings model ( AfterStartupMicroflow: 'M.Startup' );", + CanonicalExample: "alter settings runtime ( AfterStartupMicroflow: 'M.Startup' );", }, { Code: ReversedEntityGrant, @@ -374,6 +408,105 @@ var entries = []Entry{ func init() { entries = append(entries, r8Entries...) + entries = append(entries, r3Entries...) +} + +// r3Entries are R3's spellings (ako/mxcli#751): `:` sets a model property. An +// `alter` takes exactly create's `( Key: value, … )` list, so a fragment of +// describe output pastes into an alter unchanged. +var r3Entries = []Entry{ + { + Code: AlterPageSetEquals, + Old: "set Key = value [on target] / set (Key = value, …) [on target]", + Canonical: "set (Key: value, …) [on target]", + Rewrite: Rewrite{Structural: "each `=` as `:`, and the assignments in parentheses when they are not"}, + RemovedIn: 2, + Note: "In `alter page`, `alter snippet` and `alter layout`. `set layout = M.L` is a separate form " + + "and is not reported.", + Example: "alter page M.P { set Caption = 'Save' on btnSave; };", + CanonicalExample: "alter page M.P { set (Caption: 'Save') on btnSave; };", + }, + { + Code: AlterPageSetUnparenthesised, + Old: "set Key: value [on target]", + Canonical: "set (Key: value) [on target]", + Rewrite: Rewrite{Structural: "assignment in parentheses"}, + RemovedIn: 2, + Note: "Properties are a parenthesised list, even when there is one.", + Example: "alter page M.P { set Caption: 'Save' on btnSave; };", + CanonicalExample: "alter page M.P { set (Caption: 'Save') on btnSave; };", + }, + { + Code: AlterPageDropWidget, + Old: "drop widget a, b", + Canonical: "drop a, b", + Rewrite: Rewrite{Structural: "`drop widget a` as `drop a`"}, + RemovedIn: 2, + Note: "The target names the element; the kind is its own.", + Example: "alter page M.P { drop widget txtOld; };", + CanonicalExample: "alter page M.P { drop txtOld; };", + }, + { + Code: SettingsAssignment, + Old: "alter settings runtime Key = value, … / create configuration 'X' Key = value, …", + Canonical: "alter settings runtime ( Key: value, … ) / create configuration 'X' ( Key: value, … )", + Rewrite: Rewrite{Structural: "assignments in parentheses, each `=` as `:`"}, + RemovedIn: 2, + Note: "Every settings section (runtime, language, workflows, configuration 'X') and `create configuration`. " + + "`alter settings constant 'C' value 'v'` is a clause, not a property, and is unchanged.", + Example: "alter settings runtime AfterStartupMicroflow = 'M.Startup', BcryptCost = 11;", + CanonicalExample: "alter settings runtime ( AfterStartupMicroflow: 'M.Startup', BcryptCost: 11 );", + }, + { + Code: ODataAlterAssignment, + Old: "alter consumed|published odata service X set Key = value, …", + Canonical: "alter consumed|published odata service X set ( Key: value, … )", + Rewrite: Rewrite{Structural: "assignments in parentheses, each `=` as `:`"}, + RemovedIn: 2, + Note: "The list takes exactly the keys and values of the service's `create` statement.", + Example: "alter consumed odata service M.Crm set Version = '2.0', Timeout = 30;", + CanonicalExample: "alter consumed odata service M.Crm set ( Version: '2.0', Timeout: 30 );", + }, + { + Code: StylingAssignment, + Old: "alter styling on page P widget w set Class = 'x', 'Full width' = on", + Canonical: "alter styling on page P widget w set ( Class: 'x', 'Full width': on )", + Rewrite: Rewrite{Structural: "assignments in parentheses, each `=` as `:`"}, + RemovedIn: 2, + Note: "The same list `alter page … set ( … ) on w` takes for a widget's class, style and design properties.", + Example: "alter styling on page M.P widget ctn1 set Class = 'card', 'Full width' = on;", + CanonicalExample: "alter styling on page M.P widget ctn1 set ( Class: 'card', 'Full width': on );", + }, + { + Code: AllowCreateChangeLocally, + Old: "alter entity M.E set allow_create_change_locally = true", + Canonical: "alter entity M.E set ( AllowCreateChangeLocally: true )", + Rewrite: Rewrite{Structural: "property as create's list: `set ( AllowCreateChangeLocally: )`"}, + RemovedIn: 2, + Note: "The key `create external entity` takes for the same property.", + Example: "alter entity M.Remote set allow_create_change_locally = true;", + CanonicalExample: "alter entity M.Remote set ( AllowCreateChangeLocally: true );", + }, + { + Code: AssociationClauseColon, + Old: "type: Reference / owner: Both / storage: Table", + Canonical: "type Reference / owner Both / storage Table", + Rewrite: Rewrite{Structural: "clause without its colon: `type: Reference` as `type Reference` (also `owner`, `storage`)"}, + RemovedIn: 2, + Note: "A clause outside a property list takes no colon, as describe writes it.", + Example: "create association M.Order_Customer from M.Order to M.Customer type: Reference;", + CanonicalExample: "create association M.Order_Customer from M.Order to M.Customer type Reference;", + }, + { + Code: ModifyAttributeColon, + Old: "alter entity M.E modify attribute A Type", + Canonical: "alter entity M.E modify attribute A: Type", + Rewrite: Rewrite{Structural: "attribute definition with its colon: `A Type` as `A: Type`"}, + RemovedIn: 2, + Note: "An attribute definition is always `Name: Type`, as in `create entity` and `add attribute`.", + Example: "alter entity M.E modify attribute Code String(20);", + CanonicalExample: "alter entity M.E modify attribute Code: String(20);", + }, } // r8Entries are R8's spellings (ako/mxcli#752). Kept apart from the list above diff --git a/mdl/executor/alter_aliases.go b/mdl/executor/alter_aliases.go deleted file mode 100644 index 647668eefe..0000000000 --- a/mdl/executor/alter_aliases.go +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/mdl/ast" - "github.com/mendixlabs/mxcli/mdl/backend" - "github.com/mendixlabs/mxcli/mdl/linter" -) - -// The old spellings of ALTER PAGE / SNIPPET / LAYOUT are aliases of the generic -// ALTER (ADR-0012 decision 2): they parse to the identical operation and warn -// (ADR-0011: an old form keeps working through the alias window, and says what -// replaces it). -// -// Each entry has the shape the deprecation registry (ako/mxcli#709) takes — -// code, old form, canonical form, mechanical rewrite, language version it is -// removed in — so the registry absorbs this table rather than re-deriving it. -// Until it lands the codes are provisional and numbered from 101, clear of the -// registry's seed entries. The grammar marks each alias alternative with -// `// alias: `; TestAlterAliasGrammarMarkersMatchTable pins the two -// together, in both directions. -type alterAlias struct { - Code string // MDL-DEPRnnn - Spelling string // ast.AlterAlias* - Old string // the old form, as written - Canonical string // what replaces it - Rewrite string // the mechanical rewrite, for `fmt --upgrade` - RemovedIn string // the MDL language version that drops the alias -} - -var alterAliases = []alterAlias{ - { - Code: "MDL-DEPR101", - Spelling: ast.AlterAliasSetEquals, - Old: "set Key = value [on target] / set (Key = value, …) [on target]", - Canonical: "set (Key: value, …) [on target]", - Rewrite: "put the assignments in parentheses and write each `=` as `:`", - RemovedIn: "mdl 2", - }, - { - Code: "MDL-DEPR102", - Spelling: ast.AlterAliasSetUnparenthesised, - Old: "set Key: value [on target]", - Canonical: "set (Key: value) [on target]", - Rewrite: "put the assignment in parentheses", - RemovedIn: "mdl 2", - }, - { - Code: "MDL-DEPR103", - Spelling: ast.AlterAliasDropWidget, - Old: "drop widget a, b", - Canonical: "drop a, b", - Rewrite: "remove the word `widget`", - RemovedIn: "mdl 2", - }, -} - -func alterAliasFor(spelling string) (alterAlias, bool) { - for _, a := range alterAliases { - if a.Spelling == spelling { - return a, true - } - } - return alterAlias{}, false -} - -// validateAlterAliases warns on every old ALTER spelling a statement uses, once -// per operation. A warning, never an error: both spellings build the identical -// operation, and scripts in the wild use the old ones. -func validateAlterAliases(stmt ast.Statement) []linter.Violation { - s, ok := stmt.(*ast.AlterPageStmt) - if !ok { - return nil - } - kind := strings.ToLower(s.ContainerType) - if kind == "" { - kind = "page" - } - var out []linter.Violation - for _, op := range s.Operations { - var spelling string - switch o := op.(type) { - case *ast.SetPropertyOp: - spelling = o.Legacy - case *ast.DropWidgetOp: - spelling = o.Legacy - } - if spelling == "" { - continue - } - a, known := alterAliasFor(spelling) - if !known { - continue - } - out = append(out, linter.Violation{ - RuleID: a.Code, - Severity: linter.SeverityWarning, - Message: fmt.Sprintf("alter %s %s: `%s` is the old spelling of `%s`", - kind, s.PageName.String(), a.Old, a.Canonical), - Location: linter.Location{ - Module: s.PageName.Module, - DocumentType: kind, - DocumentName: s.PageName.Name, - }, - Suggestion: fmt.Sprintf("Write `%s` (%s). Both build the identical change; the old form is removed in %s.", - a.Canonical, a.Rewrite, a.RemovedIn), - }) - } - return out -} - -// validateAlterPageAddresses refuses, with no project needed, a target whose -// address FORM a page cannot use: a quoted caption, an @n, a path of more than -// two names. The generic grammar accepts every address form any document type -// uses, and the page family's resolver refuses these whatever the page holds — -// so `check` can say so up front instead of `exec` stopping mid-script. The -// rule is the resolver's own (backend.CheckPageAlterTarget), not a copy. -func validateAlterPageAddresses(stmt ast.Statement) []linter.Violation { - s, ok := stmt.(*ast.AlterPageStmt) - if !ok { - return nil - } - kind := strings.ToLower(s.ContainerType) - if kind == "" { - kind = "page" - } - var out []linter.Violation - for _, op := range s.Operations { - for _, ref := range alterPageOperationTargets(op) { - if err := backend.CheckPageAlterTarget(alterTargetOf(ref)); err != nil { - out = append(out, linter.Violation{ - RuleID: "MDL-ALTER01", - Severity: linter.SeverityError, - Message: fmt.Sprintf("alter %s %s: %v", kind, s.PageName.String(), err), - Location: linter.Location{ - Module: s.PageName.Module, - DocumentType: kind, - DocumentName: s.PageName.Name, - }, - }) - } - } - } - return out -} diff --git a/mdl/executor/cmd_settings.go b/mdl/executor/cmd_settings.go index 93bf8e21a8..520780ba33 100644 --- a/mdl/executor/cmd_settings.go +++ b/mdl/executor/cmd_settings.go @@ -148,27 +148,31 @@ func describeSettings(ctx *ExecContext, configName string) error { } } - addIfSet("AfterStartupMicroflow", "AfterStartupMicroflow = '%s'", ms.AfterStartupMicroflow) - addIfSet("BeforeShutdownMicroflow", "BeforeShutdownMicroflow = '%s'", ms.BeforeShutdownMicroflow) - addIfSet("HealthCheckMicroflow", "HealthCheckMicroflow = '%s'", ms.HealthCheckMicroflow) - add("HashAlgorithm", "HashAlgorithm = '%s'", ms.HashAlgorithm) - add("BcryptCost", "BcryptCost = %d", ms.BcryptCost) + addIfSet("AfterStartupMicroflow", "AfterStartupMicroflow: '%s'", ms.AfterStartupMicroflow) + addIfSet("BeforeShutdownMicroflow", "BeforeShutdownMicroflow: '%s'", ms.BeforeShutdownMicroflow) + addIfSet("HealthCheckMicroflow", "HealthCheckMicroflow: '%s'", ms.HealthCheckMicroflow) + add("HashAlgorithm", "HashAlgorithm: '%s'", ms.HashAlgorithm) + add("BcryptCost", "BcryptCost: %d", ms.BcryptCost) // JavaVersion is stored under either JavaVersion or JavaMajorVersion; emit it // when the document carries whichever spelling, in mxcli's single input name. if settingsoverlay.JavaVersionKey(stored) != "" || stored == nil { - parts = append(parts, fmt.Sprintf(" JavaVersion = '%s'", ms.JavaVersion)) + parts = append(parts, fmt.Sprintf(" JavaVersion: '%s'", ms.JavaVersion)) + } + add("RoundingMode", "RoundingMode: '%s'", ms.RoundingMode) + add("AllowUserMultipleSessions", "AllowUserMultipleSessions: %t", ms.AllowUserMultipleSessions) + add("EnableDataStorageOptimisticLocking", "EnableDataStorageOptimisticLocking: %t", ms.EnableDataStorageOptimisticLocking) + add("UseDatabaseForeignKeyConstraints", "UseDatabaseForeignKeyConstraints: %t", ms.UseDatabaseForeignKeyConstraints) + add("UseOQLVersion2", "UseOQLVersion2: %t", ms.UseOQLVersion2) + add("DecimalScale", "DecimalScale: %d", ms.DecimalScale) + addIfSet("FirstDayOfWeek", "FirstDayOfWeek: '%s'", ms.FirstDayOfWeek) + addIfSet("SslCertificateAlgorithm", "SslCertificateAlgorithm: '%s'", ms.SslCertificateAlgorithm) + addIfSet("ScheduledEventTimeZoneCode", "ScheduledEventTimeZoneCode: '%s'", ms.ScheduledEventTimeZoneCode) + addIfSet("DefaultTimeZoneCode", "DefaultTimeZoneCode: '%s'", ms.DefaultTimeZoneCode) + // R3: the ( Key: value, … ) list every alter takes (ako/mxcli#751). An + // empty list does not parse, so nothing is written for none. + if len(parts) > 0 { + fmt.Fprintf(ctx.Output, "alter settings runtime (\n%s\n);\n\n", strings.Join(parts, ",\n")) } - add("RoundingMode", "RoundingMode = '%s'", ms.RoundingMode) - add("AllowUserMultipleSessions", "AllowUserMultipleSessions = %t", ms.AllowUserMultipleSessions) - add("EnableDataStorageOptimisticLocking", "EnableDataStorageOptimisticLocking = %t", ms.EnableDataStorageOptimisticLocking) - add("UseDatabaseForeignKeyConstraints", "UseDatabaseForeignKeyConstraints = %t", ms.UseDatabaseForeignKeyConstraints) - add("UseOQLVersion2", "UseOQLVersion2 = %t", ms.UseOQLVersion2) - add("DecimalScale", "DecimalScale = %d", ms.DecimalScale) - addIfSet("FirstDayOfWeek", "FirstDayOfWeek = '%s'", ms.FirstDayOfWeek) - addIfSet("SslCertificateAlgorithm", "SslCertificateAlgorithm = '%s'", ms.SslCertificateAlgorithm) - addIfSet("ScheduledEventTimeZoneCode", "ScheduledEventTimeZoneCode = '%s'", ms.ScheduledEventTimeZoneCode) - addIfSet("DefaultTimeZoneCode", "DefaultTimeZoneCode = '%s'", ms.DefaultTimeZoneCode) - fmt.Fprintf(ctx.Output, "alter settings runtime\n%s;\n\n", strings.Join(parts, ",\n")) } // Configuration settings @@ -211,7 +215,7 @@ func describeSettings(ctx *ExecContext, configName string) error { mdlQuoted(l.Code), l.CheckCompleteness, mdlQuoted(l.CustomDateFormat), mdlQuoted(l.CustomTimeFormat), mdlQuoted(l.CustomDateTimeFormat)) } - fmt.Fprintf(ctx.Output, "alter settings LANGUAGE\n DefaultLanguageCode = '%s';\n", ps.Language.DefaultLanguageCode) + fmt.Fprintf(ctx.Output, "alter settings LANGUAGE (\n DefaultLanguageCode: '%s'\n);\n", ps.Language.DefaultLanguageCode) fmt.Fprintln(ctx.Output) } @@ -220,16 +224,16 @@ func describeSettings(ctx *ExecContext, configName string) error { ws := ps.Workflows var parts []string if ws.UserEntity != "" { - parts = append(parts, fmt.Sprintf(" UserEntity = '%s'", ws.UserEntity)) + parts = append(parts, fmt.Sprintf(" UserEntity: '%s'", ws.UserEntity)) } if ws.DefaultTaskParallelism > 0 { - parts = append(parts, fmt.Sprintf(" DefaultTaskParallelism = %d", ws.DefaultTaskParallelism)) + parts = append(parts, fmt.Sprintf(" DefaultTaskParallelism: %d", ws.DefaultTaskParallelism)) } if ws.WorkflowEngineParallelism > 0 { - parts = append(parts, fmt.Sprintf(" WorkflowEngineParallelism = %d", ws.WorkflowEngineParallelism)) + parts = append(parts, fmt.Sprintf(" WorkflowEngineParallelism: %d", ws.WorkflowEngineParallelism)) } if len(parts) > 0 { - fmt.Fprintf(ctx.Output, "alter settings workflows\n%s;\n\n", strings.Join(parts, ",\n")) + fmt.Fprintf(ctx.Output, "alter settings workflows (\n%s\n);\n\n", strings.Join(parts, ",\n")) } // The groups, in stored order. `add or modify` so a described project // re-executes against a project that already has some of them. @@ -972,31 +976,31 @@ func settingsValueToString(val any) string { // writeSettingsConfiguration emits one configuration as re-executable MDL. func writeSettingsConfiguration(ctx *ExecContext, cfg *model.ServerConfiguration) { var parts []string - parts = append(parts, " DatabaseType = "+mdlQuoted(cfg.DatabaseType)) - parts = append(parts, " DatabaseUrl = "+mdlQuoted(cfg.DatabaseUrl)) - parts = append(parts, " DatabaseName = "+mdlQuoted(cfg.DatabaseName)) - parts = append(parts, " DatabaseUserName = "+mdlQuoted(cfg.DatabaseUserName)) + parts = append(parts, " DatabaseType: "+mdlQuoted(cfg.DatabaseType)) + parts = append(parts, " DatabaseUrl: "+mdlQuoted(cfg.DatabaseUrl)) + parts = append(parts, " DatabaseName: "+mdlQuoted(cfg.DatabaseName)) + parts = append(parts, " DatabaseUserName: "+mdlQuoted(cfg.DatabaseUserName)) // DatabasePassword is deliberately not printed (ako/mxcli#707): describe // output is what gets committed and reviewed, and printing the stored value // put a database credential into PR diffs. Omitting the key is lossless for // the replay that matters — `create or modify` on an existing configuration // is a patch, so the stored password is left as it is. On a project without // this configuration the password starts empty, which the comment says. - parts = append(parts, fmt.Sprintf(" HttpPortNumber = %d", cfg.HttpPortNumber)) - parts = append(parts, fmt.Sprintf(" ServerPortNumber = %d", cfg.ServerPortNumber)) + parts = append(parts, fmt.Sprintf(" HttpPortNumber: %d", cfg.HttpPortNumber)) + parts = append(parts, fmt.Sprintf(" ServerPortNumber: %d", cfg.ServerPortNumber)) if cfg.ApplicationRootUrl != "" { - parts = append(parts, " ApplicationRootUrl = "+mdlQuoted(cfg.ApplicationRootUrl)) + parts = append(parts, " ApplicationRootUrl: "+mdlQuoted(cfg.ApplicationRootUrl)) } if cfg.DatabasePassword != "" { fmt.Fprintf(ctx.Output, "-- DatabasePassword is set in configuration %s and is not printed; "+ - "add `DatabasePassword = '…'` to set it on a project that lacks it.\n", mdlQuoted(cfg.Name)) + "add `DatabasePassword: '…'` to set it on a project that lacks it.\n", mdlQuoted(cfg.Name)) } // CREATE OR MODIFY, not ALTER: a described project has to replay onto a // target that does not have this configuration yet. ALTER answered // "configuration not found: Acceptance" and stopped the whole file, which is // the same shape as the language list emitting a comment — output that reads // correctly and cannot be run. - fmt.Fprintf(ctx.Output, "create or modify configuration %s\n%s;\n\n", mdlQuoted(cfg.Name), strings.Join(parts, ",\n")) + fmt.Fprintf(ctx.Output, "create or modify configuration %s (\n%s\n);\n\n", mdlQuoted(cfg.Name), strings.Join(parts, ",\n")) // Output constant overrides. A private override has no value in the // model — emitting `value ''` would round-trip into a *shared* empty diff --git a/mdl/executor/cmd_settings_configuration_test.go b/mdl/executor/cmd_settings_configuration_test.go index 0366077a24..5bb9af51c3 100644 --- a/mdl/executor/cmd_settings_configuration_test.go +++ b/mdl/executor/cmd_settings_configuration_test.go @@ -51,7 +51,7 @@ func TestDescribeSettingsConfiguration_ByName(t *testing.T) { if !strings.Contains(out, "create or modify configuration 'Default'") { t.Errorf("expected the named configuration in its replayable form, got:\n%s", out) } - if !strings.Contains(out, "ApplicationRootUrl = 'http://backend.local:8080/'") { + if !strings.Contains(out, "ApplicationRootUrl: 'http://backend.local:8080/'") { t.Errorf("expected the root URL, got:\n%s", out) } // Naming one configuration means one configuration, not all of them. diff --git a/mdl/executor/cmd_settings_describe_canonical_test.go b/mdl/executor/cmd_settings_describe_canonical_test.go new file mode 100644 index 0000000000..e9ee8f3830 --- /dev/null +++ b/mdl/executor/cmd_settings_describe_canonical_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" +) + +// R3 (ako/mxcli#751): describe settings writes every section as the +// ( Key: value, … ) list `alter` takes, so its output replays with no +// deprecation warning, under no header and under `mdl 1;` alike. The control +// is the property count: output that dropped the properties would parse +// cleanly too. +func TestDescribeSettings_EmitsCanonicalPropertyLists(t *testing.T) { + ps := &model.ProjectSettings{ + Model: &model.ModelSettings{ + AfterStartupMicroflow: "M.ASU", HashAlgorithm: "BCrypt", BcryptCost: 11, + JavaVersion: "Java21", RoundingMode: "HalfEven", + }, + Configuration: &model.ConfigurationSettings{ + Configurations: []*model.ServerConfiguration{ + {Name: "Default", DatabaseType: "Hsqldb", DatabaseName: "default", HttpPortNumber: 8080, + ApplicationRootUrl: "http://localhost:8080/"}, + }, + }, + Language: &model.LanguageSettings{DefaultLanguageCode: "en_US", Languages: []model.Language{{Code: "en_US"}}}, + Workflows: &model.WorkflowsSettings{UserEntity: "System.User", DefaultTaskParallelism: 3, + Groups: []model.WorkflowGroup{{Name: "Approvers", Description: "First line"}}}, + } + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { return ps, nil }, + } + ctx, buf := newMockCtx(t, withBackend(mb)) + if err := describeSettings(ctx, ""); err != nil { + t.Fatal(err) + } + out := buf.String() + + for _, header := range []string{"", "mdl 1;\n"} { + prog, errs := visitor.Build(header + out) + if len(errs) > 0 { + t.Fatalf("describe output does not parse (header %q): %v\n%s", header, errs, out) + } + if len(prog.Deprecations) > 0 { + t.Errorf("describe output uses a deprecated spelling (header %q): %+v\n%s", header, prog.Deprecations, out) + } + } + for _, want := range []string{ + "alter settings runtime (\n AfterStartupMicroflow: 'M.ASU',", + "create or modify configuration 'Default' (\n DatabaseType: 'Hsqldb',", + "alter settings LANGUAGE (\n DefaultLanguageCode: 'en_US'\n);", + "alter settings workflows (\n UserEntity: 'System.User',\n DefaultTaskParallelism: 3\n);", + } { + if !strings.Contains(out, want) { + t.Errorf("describe output lacks %q:\n%s", want, out) + } + } + if strings.Contains(out, " = ") { + t.Errorf("describe output still assigns with `=`:\n%s", out) + } +} diff --git a/mdl/executor/cmd_settings_languages.go b/mdl/executor/cmd_settings_languages.go index 634ba1814d..72ab7e90fe 100644 --- a/mdl/executor/cmd_settings_languages.go +++ b/mdl/executor/cmd_settings_languages.go @@ -251,7 +251,7 @@ func alterSettingsLanguageRemove(ctx *ExecContext, ps *model.ProjectSettings, st if strings.EqualFold(ps.Language.DefaultLanguageCode, code) { return mdlerrors.NewValidationf( "%s is the project's DEFAULT language and cannot be removed — every missing translation falls back on it. "+ - "Make another language the default first: `alter settings LANGUAGE DefaultLanguageCode = ''`", code) + "Make another language the default first: `alter settings LANGUAGE (DefaultLanguageCode: '')`", code) } stored := ps.Language.Languages[idx].Code diff --git a/mdl/executor/validate_alter_page_addresses.go b/mdl/executor/validate_alter_page_addresses.go new file mode 100644 index 0000000000..91d430acb1 --- /dev/null +++ b/mdl/executor/validate_alter_page_addresses.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// validateAlterPageAddresses refuses, with no project needed, a target whose +// address FORM a page cannot use: a quoted caption, an @n, a path of more than +// two names. The generic grammar accepts every address form any document type +// uses, and the page family's resolver refuses these whatever the page holds — +// so `check` can say so up front instead of `exec` stopping mid-script. The +// rule is the resolver's own (backend.CheckPageAlterTarget), not a copy. +func validateAlterPageAddresses(stmt ast.Statement) []linter.Violation { + s, ok := stmt.(*ast.AlterPageStmt) + if !ok { + return nil + } + kind := strings.ToLower(s.ContainerType) + if kind == "" { + kind = "page" + } + var out []linter.Violation + for _, op := range s.Operations { + for _, ref := range alterPageOperationTargets(op) { + if err := backend.CheckPageAlterTarget(alterTargetOf(ref)); err != nil { + out = append(out, linter.Violation{ + RuleID: "MDL-ALTER01", + Severity: linter.SeverityError, + Message: fmt.Sprintf("alter %s %s: %v", kind, s.PageName.String(), err), + Location: linter.Location{ + Module: s.PageName.Module, + DocumentType: kind, + DocumentName: s.PageName.Name, + }, + }) + } + } + } + return out +} diff --git a/mdl/executor/alter_aliases_test.go b/mdl/executor/validate_alter_page_addresses_test.go similarity index 63% rename from mdl/executor/alter_aliases_test.go rename to mdl/executor/validate_alter_page_addresses_test.go index 2ef32eae80..1975c8619b 100644 --- a/mdl/executor/alter_aliases_test.go +++ b/mdl/executor/validate_alter_page_addresses_test.go @@ -3,10 +3,6 @@ package executor import ( - "os" - "path/filepath" - "regexp" - "sort" "strings" "testing" @@ -30,7 +26,8 @@ func aliasWarnings(t *testing.T, src string) []linter.Violation { } // The old ALTER PAGE spellings still run, and warn with the code that names -// their rewrite (ako/mxcli#712). The canonical script is the control: it must +// their rewrite (ako/mxcli#712; registered in mdl/deprecation since +// ako/mxcli#751). The canonical script is the control: it must // produce no deprecation warning at all, or a warning on every ALTER would pass. func TestAlterAliases_OldSpellingsWarn(t *testing.T) { old := aliasWarnings(t, `alter page M.P { @@ -50,8 +47,8 @@ func TestAlterAliases_OldSpellingsWarn(t *testing.T) { if strings.Join(codes, ",") != strings.Join(want, ",") { t.Errorf("codes: got %v, want %v", codes, want) } - if len(old) > 0 && !strings.Contains(old[0].Suggestion, "set (Key: value") { - t.Errorf("suggestion should name the canonical form: %q", old[0].Suggestion) + if len(old) > 0 && !strings.Contains(old[0].Message, "set (Key: value") { + t.Errorf("the warning should name the canonical form: %q", old[0].Message) } canonical := aliasWarnings(t, `alter page M.P { @@ -66,47 +63,6 @@ func TestAlterAliases_OldSpellingsWarn(t *testing.T) { } } -// Every grammar alternative marked `// alias: ` has an entry in the -// alias table, and every entry is marked somewhere in the grammar — so an alias -// cannot be added to one without the other. The deprecation registry -// (ako/mxcli#709) generalises this check. -func TestAlterAliasGrammarMarkersMatchTable(t *testing.T) { - marker := regexp.MustCompile(`//\s*alias:\s*(MDL-DEPR\d+)`) - files, _ := filepath.Glob("../grammar/*.g4") - more, _ := filepath.Glob("../grammar/domains/*.g4") - files = append(files, more...) - if len(files) == 0 { - t.Fatal("no grammar files found") - } - marked := map[string]bool{} - for _, f := range files { - b, err := os.ReadFile(f) - if err != nil { - t.Fatal(err) - } - for _, m := range marker.FindAllStringSubmatch(string(b), -1) { - marked[m[1]] = true - } - } - table := map[string]bool{} - for _, a := range alterAliases { - table[a.Code] = true - if !marked[a.Code] { - t.Errorf("%s is in the alias table but no grammar alternative is marked `// alias: %s`", a.Code, a.Code) - } - } - var missing []string - for code := range marked { - if !table[code] { - missing = append(missing, code) - } - } - sort.Strings(missing) - for _, code := range missing { - t.Errorf("grammar marks an alias %s with no entry in alterAliases", code) - } -} - // A caption or @n target is a form a page does not use. The generic grammar // parses it (other document types need it), so check must refuse it — with no // project — rather than leave exec to stop partway through a script. A name diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index 8997177628..fb2b710ddf 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -110,9 +110,6 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { if awfStmt, ok := stmt.(*ast.AlterWorkflowStmt); ok { violations = append(violations, ValidateAlterWorkflow(awfStmt)...) } - // The old ALTER PAGE / SNIPPET / LAYOUT spellings are aliases of the - // generic ALTER and warn with their deprecation code (MDL-DEPR101..103). - violations = append(violations, validateAlterAliases(stmt)...) // A page element is addressed by name; a caption or @n target is // refused before exec would stop on it (MDL-ALTER01). violations = append(violations, validateAlterPageAddresses(stmt)...) diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 28fe0f0230..6a43211cc9 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -163,7 +163,11 @@ alterStatement | alterEntitiesStatement | ALTER ASSOCIATION qualifiedName alterAssociationAction+ | ALTER ENUMERATION qualifiedName alterEnumerationAction+ + // R3 (ako/mxcli#751): `set ( Key: value, … )`, create's property list. + // The unparenthesised `set Key = value, …` is the old spelling. + | ALTER consumedODataServiceKw qualifiedName SET odataAlterPropertyList | ALTER consumedODataServiceKw qualifiedName SET odataAlterAssignment (COMMA odataAlterAssignment)* + | ALTER publishedODataServiceKw qualifiedName SET odataAlterPropertyList | ALTER publishedODataServiceKw qualifiedName SET odataAlterAssignment (COMMA odataAlterAssignment)* | ALTER STYLING ON (PAGE | SNIPPET) qualifiedName WIDGET IDENTIFIER alterStylingAction+ | ALTER SETTINGS alterSettingsClause @@ -226,16 +230,24 @@ publishedRestAlterAssignment * ``` */ alterStylingAction - : SET alterStylingAssignment (COMMA alterStylingAssignment)* + : SET LPAREN alterStylingAssignment (COMMA alterStylingAssignment)* RPAREN // set ( Class: 'x', 'Full width': on ) + | SET alterStylingAssignment (COMMA alterStylingAssignment)* /* @alias MDL-DEPR062 */ // set Class = 'x' | CLEAR DESIGN PROPERTIES ; +// `Key: value` is canonical (R3: `:` sets a model property); `=` is the old +// spelling, still accepted. +alterStylingAssignOp + : COLON + | EQUALS /* @alias MDL-DEPR062 */ + ; + alterStylingAssignment - : CLASS EQUALS STRING_LITERAL // Class = 'my-class' - | STYLE EQUALS STRING_LITERAL // Style = 'color: red;' - | STRING_LITERAL EQUALS STRING_LITERAL // 'Spacing top' = 'Large' - | STRING_LITERAL EQUALS ON // 'Full width' = ON - | STRING_LITERAL EQUALS OFF // 'Full width' = OFF + : CLASS alterStylingAssignOp STRING_LITERAL // Class: 'my-class' + | STYLE alterStylingAssignOp STRING_LITERAL // Style: 'color: red;' + | STRING_LITERAL alterStylingAssignOp STRING_LITERAL // 'Spacing top': 'Large' + | STRING_LITERAL alterStylingAssignOp ON // 'Full width': ON + | STRING_LITERAL alterStylingAssignOp OFF // 'Full width': OFF ; /** @@ -282,7 +294,7 @@ alterOperation alterSet : SET LAYOUT EQUALS qualifiedName (MAP LPAREN alterLayoutMapping (COMMA alterLayoutMapping)* RPAREN)? // SET Layout = Atlas_Core.TopBar MAP (Main AS Content) | SET LPAREN alterPageAssignment (COMMA alterPageAssignment)* RPAREN (ON alterTarget)? // set (Caption: 'Save', ButtonStyle: Success) on btnSave - | SET alterPageAssignment (ON alterTarget)? // alias: MDL-DEPR102 — set Caption: 'Save' on btnSave + | SET alterPageAssignment (ON alterTarget)? /* @alias MDL-DEPR102 */ // set Caption: 'Save' on btnSave ; alterLayoutMapping @@ -299,7 +311,7 @@ alterReplace alterDrop : DROP alterTarget (COMMA alterTarget)* - | DROP WIDGET alterTarget (COMMA alterTarget)* // alias: MDL-DEPR103 — drop widget a, b + | DROP WIDGET /* @alias MDL-DEPR103 */ alterTarget (COMMA alterTarget)* // drop widget a, b ; // A fragment is written exactly as `create` writes the same content. Only the @@ -408,7 +420,7 @@ alterPagesStylingAssignment // the old spelling, still accepted. alterAssignOp : COLON - | EQUALS // alias: MDL-DEPR101 — set (Caption = 'Save') / set Caption = 'Save' + | EQUALS /* @alias MDL-DEPR101 */ // set (Caption = 'Save') / set Caption = 'Save' ; alterPageAssignment diff --git a/mdl/grammar/domains/MDLDomainModel.g4 b/mdl/grammar/domains/MDLDomainModel.g4 index 65ae1ff81e..ad40400cdd 100644 --- a/mdl/grammar/domains/MDLDomainModel.g4 +++ b/mdl/grammar/domains/MDLDomainModel.g4 @@ -189,9 +189,10 @@ associationOptions ; associationOption - : TYPE COLON? (REFERENCE | REFERENCE_SET) - | OWNER COLON? (DEFAULT | BOTH) - | STORAGE COLON? (COLUMN | TABLE) + // A clause takes no colon (R3): `type Reference`. The colon is an alias. + : TYPE (COLON /* @alias MDL-DEPR064 */)? (REFERENCE | REFERENCE_SET) + | OWNER (COLON /* @alias MDL-DEPR064 */)? (DEFAULT | BOTH) + | STORAGE (COLON /* @alias MDL-DEPR064 */)? (COLUMN | TABLE) | DELETE_BEHAVIOR /* @alias MDL-DEPR022 */ deleteBehavior errorMessageClause? | onDeleteClause | COMMENT STRING_LITERAL @@ -268,15 +269,18 @@ alterEntityAction | docComment? ADD COLUMN ifNotExists? attributeDefinition | RENAME ATTRIBUTE attributeName TO attributeName | RENAME COLUMN attributeName TO attributeName - | MODIFY ATTRIBUTE attributeName COLON? dataType attributeConstraint* - | MODIFY COLUMN attributeName COLON? dataType attributeConstraint* + // An attribute definition is always `Name: Type` (R3). The colon is + // optional only so the old spelling keeps parsing: its ABSENCE is the alias. + | MODIFY ATTRIBUTE attributeName COLON? /* @alias MDL-DEPR065 */ dataType attributeConstraint* + | MODIFY COLUMN attributeName COLON? /* @alias MDL-DEPR065 */ dataType attributeConstraint* | DROP ATTRIBUTE ifExists? attributeName | DROP COLUMN ifExists? attributeName | DROP DEFAULT ON ATTRIBUTE attributeName // clear an attribute's default value | SET DOCUMENTATION STRING_LITERAL | SET COMMENT STRING_LITERAL | SET POSITION LPAREN NUMBER_LITERAL COMMA NUMBER_LITERAL RPAREN - | SET ALLOW_CREATE_CHANGE_LOCALLY EQUALS (TRUE | FALSE) + | SET LPAREN ALLOW_CREATE_CHANGE_LOCALLY COLON (TRUE | FALSE) RPAREN // set ( AllowCreateChangeLocally: true ) + | SET ALLOW_CREATE_CHANGE_LOCALLY EQUALS /* @alias MDL-DEPR063 */ (TRUE | FALSE) | ADD INDEX ifNotExists? indexDefinition | DROP INDEX ifExists? indexDefinition | DROP INDEX ifExists? IDENTIFIER diff --git a/mdl/grammar/domains/MDLService.g4 b/mdl/grammar/domains/MDLService.g4 index a44bb27775..1dc8826580 100644 --- a/mdl/grammar/domains/MDLService.g4 +++ b/mdl/grammar/domains/MDLService.g4 @@ -42,8 +42,9 @@ databaseQueryMapping ; createConfigurationStatement - : CONFIGURATION STRING_LITERAL - (settingsAssignment (COMMA settingsAssignment)*)? + : CONFIGURATION STRING_LITERAL settingsItemOptions? // configuration 'X' ( Key: value, … ) + | CONFIGURATION STRING_LITERAL + settingsAssignment (COMMA settingsAssignment)* // old spelling: Key = value, … (MDL-DEPR060) ; /** @@ -215,9 +216,15 @@ odataPropertyAssignment | identifierOrKeyword COLON expression ; +// ALTER … SET ( Key: value, … ): exactly create's property list (R3). +odataAlterPropertyList + : LPAREN odataPropertyAssignment (COMMA odataPropertyAssignment)* RPAREN + ; + +// The old spelling of the alter list: `set Key = value, …` (R3). odataAlterAssignment - : identifierOrKeyword EQUALS odataPropertyValue - | identifierOrKeyword EQUALS expression + : identifierOrKeyword EQUALS /* @alias MDL-DEPR061 */ odataPropertyValue + | identifierOrKeyword EQUALS /* @alias MDL-DEPR061 */ expression ; odataAuthenticationClause diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index f3c3ba2651..e2533004ba 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -11,14 +11,17 @@ options { tokenVocab = MDLLexer; } // ============================================================================= /** - * ALTER SETTINGS RUNTIME Key = Value, ...; (MODEL is a deprecated alias) - * ALTER SETTINGS CONFIGURATION 'name' Key = Value, ...; + * ALTER SETTINGS RUNTIME ( Key: Value, ... ); (MODEL is a deprecated alias) + * ALTER SETTINGS CONFIGURATION 'name' ( Key: Value, ... ); * ALTER SETTINGS CONSTANT 'name' VALUE 'value' [IN CONFIGURATION 'name']; - * ALTER SETTINGS LANGUAGE Key = Value, ...; + * ALTER SETTINGS LANGUAGE ( Key: Value, ... ); * ALTER SETTINGS LANGUAGE ADD [OR MODIFY] 'ar_SD' [(Key: Value, ...)]; * ALTER SETTINGS LANGUAGE MODIFY 'ar_SD' (Key: Value, ...); * ALTER SETTINGS LANGUAGE REMOVE 'ar_SD'; - * ALTER SETTINGS WORKFLOWS Key = Value, ...; + * ALTER SETTINGS WORKFLOWS ( Key: Value, ... ); + * + * `Key = Value, …` without the parentheses is the old spelling of the property + * list (MDL-DEPR060, R3). * ALTER SETTINGS WORKFLOWS ADD [OR MODIFY] GROUP 'Approvers' [(Description: '...')]; * ALTER SETTINGS WORKFLOWS MODIFY GROUP 'Approvers' (Description: '...'); * ALTER SETTINGS WORKFLOWS REMOVE GROUP 'Approvers'; @@ -45,10 +48,12 @@ alterSettingsClause | settingsSection ADD STRING_LITERAL settingsItemOptions? | settingsSection MODIFY STRING_LITERAL settingsItemOptions | settingsSection REMOVE STRING_LITERAL - | settingsSection settingsAssignment (COMMA settingsAssignment)* + | settingsSection settingsItemOptions // runtime ( Key: value, … ) + | settingsSection settingsAssignment (COMMA settingsAssignment)* // old spelling (MDL-DEPR060) | CONSTANT STRING_LITERAL (VALUE settingsValue | DROP) (IN CONFIGURATION STRING_LITERAL)? | DROP CONSTANT STRING_LITERAL (IN CONFIGURATION STRING_LITERAL)? - | CONFIGURATION STRING_LITERAL settingsAssignment (COMMA settingsAssignment)* + | CONFIGURATION STRING_LITERAL settingsItemOptions // configuration 'X' ( Key: value, … ) + | CONFIGURATION STRING_LITERAL settingsAssignment (COMMA settingsAssignment)* // old spelling (MDL-DEPR060) ; // RUNTIME is Studio Pro's tab for Settings$ModelSettings (R10); MODEL, the @@ -60,8 +65,11 @@ settingsSection | WORKFLOWS ; +// The old spelling of a settings property: `Key = value`, outside a list. R3 +// (ako/mxcli#751): `:` sets a model property, in the ( Key: value, … ) list +// every other statement uses. settingsAssignment - : IDENTIFIER EQUALS settingsValue + : IDENTIFIER EQUALS /* @alias MDL-DEPR060 */ settingsValue ; // The optional properties of an added language or workflow group, in the diff --git a/mdl/upgrade/document_names_test.go b/mdl/upgrade/document_names_test.go index 7079f4f1b1..5c7998a732 100644 --- a/mdl/upgrade/document_names_test.go +++ b/mdl/upgrade/document_names_test.go @@ -16,14 +16,14 @@ func TestUpgrade_DocumentTypeNames(t *testing.T) { "grant access on odata service M.Api to M.User;\n" + "create or modify queue M.Jobs (Parallelism: 2);\nlist queues;\n" + "alter project security demo users off;\n" + - "alter settings model BcryptCost = 11;\n" + + "alter settings model ( BcryptCost: 11 );\n" + "create microflow M.F () begin call microflow M.G() in queue M.Jobs; end;\n" want := "CREATE CONSUMED REST SERVICE M.Api (BaseUrl: 'https://x', Authentication: none) { };\n" + "list consumed odata services;\nlist published odata services in M;\n" + "grant access on published odata service M.Api to M.User;\n" + "create or modify task queue M.Jobs (Parallelism: 2);\nlist task queues;\n" + "alter app security demo users off;\n" + - "alter settings runtime BcryptCost = 11;\n" + + "alter settings runtime ( BcryptCost: 11 );\n" + // `in queue` is a call option, not the document type name. "create microflow M.F () begin call microflow M.G() in queue M.Jobs; end;\n" res := mustUpgrade(t, src, Options{}) diff --git a/mdl/upgrade/r3_property_lists_test.go b/mdl/upgrade/r3_property_lists_test.go new file mode 100644 index 0000000000..cf7e655b53 --- /dev/null +++ b/mdl/upgrade/r3_property_lists_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "testing" +) + +// R3 (ako/mxcli#751): every old property-list spelling upgrades to exactly +// create's `( Key: value, … )` list, and the colons to where R3 puts them. The +// visitor test (mdl/visitor/r3_property_lists_test.go) proves each pair builds +// the same statements; this one proves the rewrite produces that pair, in the +// layout it was written in. +func TestUpgrade_R3PropertyLists(t *testing.T) { + cases := []struct{ old, want string }{ + {"alter settings runtime AfterStartupMicroflow = 'M.Startup', BcryptCost = 11;\n", + "alter settings runtime ( AfterStartupMicroflow: 'M.Startup', BcryptCost: 11 );\n"}, + // describe's old layout: one property per line. + {"alter settings runtime\n HashAlgorithm = 'BCrypt',\n BcryptCost = 11;\n", + "alter settings runtime (\n HashAlgorithm: 'BCrypt',\n BcryptCost: 11\n);\n"}, + {"ALTER SETTINGS MODEL BcryptCost = 11;\n", "ALTER SETTINGS RUNTIME ( BcryptCost: 11 );\n"}, + // Indented: the closing parenthesis lines up with the statement. + {" alter settings configuration 'Default'\n HttpPortNumber = 8080;\n", + " alter settings configuration 'Default' (\n HttpPortNumber: 8080\n );\n"}, + {"alter settings LANGUAGE\n DefaultLanguageCode = 'en_US';\n", + "alter settings LANGUAGE (\n DefaultLanguageCode: 'en_US'\n);\n"}, + {"alter settings configuration 'Default' HttpPortNumber = 8080;\n", + "alter settings configuration 'Default' ( HttpPortNumber: 8080 );\n"}, + {"create or modify configuration 'Acc'\n DatabaseType = 'HSQLDB',\n HttpPortNumber = 8081;\n", + "create or modify configuration 'Acc' (\n DatabaseType: 'HSQLDB',\n HttpPortNumber: 8081\n);\n"}, + {"alter odata client M.Crm set Version = '2.0', HttpUsername = 'Bearer ' + @M.Token;\n", + "alter consumed odata service M.Crm set ( Version: '2.0', HttpUsername: 'Bearer ' + @M.Token );\n"}, + {"ALTER PUBLISHED ODATA SERVICE M.Api SET Version='2.0.0';\n", + "ALTER PUBLISHED ODATA SERVICE M.Api SET ( Version:'2.0.0' );\n"}, + {"alter styling on page M.P widget ctn1 set Class = 'card', 'Full width' = on;\n", + "alter styling on page M.P widget ctn1 set ( Class: 'card', 'Full width': on );\n"}, + {"alter styling on page M.P widget ctn1 set ( Class = 'card' ) clear design properties;\n", + "alter styling on page M.P widget ctn1 set ( Class: 'card' ) clear design properties;\n"}, + {"alter entity M.Remote set allow_create_change_locally = true;\n", + "alter entity M.Remote set ( AllowCreateChangeLocally: true );\n"}, + {"alter entity M.E modify attribute Code String(20), modify column Amount Decimal;\n", + "alter entity M.E modify attribute Code: String(20), modify column Amount: Decimal;\n"}, + {"create association M.A_B from M.A to M.B type: Reference owner:Both storage :Table;\n", + "create association M.A_B from M.A to M.B type Reference owner Both storage Table;\n"}, + {"alter page M.P {\n set Caption = 'Save' on btnSave;\n set (Caption = 'x', ButtonStyle = Success) on b2;\n set Title: 'T';\n drop widget a, b;\n};\n", + "alter page M.P {\n set (Caption: 'Save') on btnSave;\n set (Caption: 'x', ButtonStyle: Success) on b2;\n set (Title: 'T');\n drop a, b;\n};\n"}, + // A nested respelling inside the value is rewritten with the list. + {"alter page M.P { set Action = show_page M.Q(Item: $currentObject) on b; };\n", + "alter page M.P { set (Action: show page M.Q(Item = $currentObject)) on b; };\n"}, + } + for _, c := range cases { + res := mustUpgrade(t, c.old, Options{}) + if res.Source != c.want { + t.Errorf("upgrade of\n%s got:\n%s want:\n%s", c.old, res.Source, c.want) + } + if again := mustUpgrade(t, res.Source, Options{}); again.Changed() { + t.Errorf("upgrade is not idempotent on\n%s", res.Source) + } + } +} diff --git a/mdl/visitor/r3_property_lists_test.go b/mdl/visitor/r3_property_lists_test.go new file mode 100644 index 0000000000..3696dcc8e0 --- /dev/null +++ b/mdl/visitor/r3_property_lists_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" +) + +// r3Pairs are R3's old spellings and their canonical forms (ako/mxcli#751): an +// `alter` sets properties in create's `( Key: value, … )` list, a clause takes +// no colon and an attribute definition always has one. Both spellings must +// parse and build the same statements; only the old one may record the code, +// and it must carry the rewrite `fmt --upgrade` applies. +var r3Pairs = []r8Pair{ + // alter settings, every section, and configurations. + {"settings runtime", "alter settings runtime AfterStartupMicroflow = 'M.Startup', BcryptCost = 11, UseOQLVersion2 = true;", + "alter settings runtime ( AfterStartupMicroflow: 'M.Startup', BcryptCost: 11, UseOQLVersion2: true );", deprecation.SettingsAssignment}, + {"settings runtime qualified name", "alter settings runtime AfterStartupMicroflow = M.Startup;", + "alter settings runtime ( AfterStartupMicroflow: M.Startup );", deprecation.SettingsAssignment}, + {"settings language", "alter settings language DefaultLanguageCode = 'en_US';", + "alter settings language ( DefaultLanguageCode: 'en_US' );", deprecation.SettingsAssignment}, + {"settings workflows", "alter settings workflows UserEntity = 'System.User', DefaultTaskParallelism = 3;", + "alter settings workflows ( UserEntity: 'System.User', DefaultTaskParallelism: 3 );", deprecation.SettingsAssignment}, + {"settings configuration", "alter settings configuration 'Default' DatabaseType = 'POSTGRESQL', HttpPortNumber = 8080;", + "alter settings configuration 'Default' ( DatabaseType: 'POSTGRESQL', HttpPortNumber: 8080 );", deprecation.SettingsAssignment}, + {"create configuration", "create or modify configuration 'Acc' DatabaseType = 'HSQLDB', HttpPortNumber = 8081;", + "create or modify configuration 'Acc' ( DatabaseType: 'HSQLDB', HttpPortNumber: 8081 );", deprecation.SettingsAssignment}, + + // alter odata service / client. + {"odata client", "alter consumed odata service M.Crm set Version = '2.0', MetadataUrl = 'https://x.org/$metadata';", + "alter consumed odata service M.Crm set ( Version: '2.0', MetadataUrl: 'https://x.org/$metadata' );", deprecation.ODataAlterAssignment}, + {"odata client expression", "alter consumed odata service M.Crm set HttpUsername = 'Bearer ' + @M.Token;", + "alter consumed odata service M.Crm set ( HttpUsername: 'Bearer ' + @M.Token );", deprecation.ODataAlterAssignment}, + {"odata service", "alter published odata service M.Api set Version = '2.0.0', Summary = 'Orders';", + "alter published odata service M.Api set ( Version: '2.0.0', Summary: 'Orders' );", deprecation.ODataAlterAssignment}, + + // alter styling. + {"styling equals", "alter styling on page M.P widget ctn1 set Class = 'card', Style = 'margin: 0;', 'Spacing top' = 'Large', 'Full width' = on;", + "alter styling on page M.P widget ctn1 set ( Class: 'card', Style: 'margin: 0;', 'Spacing top': 'Large', 'Full width': on );", deprecation.StylingAssignment}, + {"styling colon unparenthesised", "alter styling on snippet M.S widget ctn1 set 'Full width': off;", + "alter styling on snippet M.S widget ctn1 set ( 'Full width': off );", deprecation.StylingAssignment}, + {"styling parenthesised equals", "alter styling on page M.P widget ctn1 set ( Class = 'card' ) clear design properties;", + "alter styling on page M.P widget ctn1 set ( Class: 'card' ) clear design properties;", deprecation.StylingAssignment}, + + // alter entity. + {"allow create change locally", "alter entity M.Remote set allow_create_change_locally = false;", + "alter entity M.Remote set ( AllowCreateChangeLocally: false );", deprecation.AllowCreateChangeLocally}, + {"allow create change locally camel", "alter entity M.Remote set AllowCreateChangeLocally = true;", + "alter entity M.Remote set ( AllowCreateChangeLocally: true );", deprecation.AllowCreateChangeLocally}, + {"modify attribute", "alter entity M.E modify attribute Code String(20) default 'x';", + "alter entity M.E modify attribute Code: String(20) default 'x';", deprecation.ModifyAttributeColon}, + {"modify column", "alter entity M.E modify column Amount Decimal;", + "alter entity M.E modify column Amount: Decimal;", deprecation.ModifyAttributeColon}, + + // association clauses. + {"association type", "create association M.A_B from M.A to M.B type: Reference owner: Both storage: Table;", + "create association M.A_B from M.A to M.B type Reference owner Both storage Table;", deprecation.AssociationClauseColon}, + + // alter page / snippet / layout (MDL-DEPR101..103, folded into the registry). + {"page set equals", "alter page M.P { set Caption = 'Save' on btnSave; set (Caption = 'x', ButtonStyle = Success) on b2; };", + "alter page M.P { set (Caption: 'Save') on btnSave; set (Caption: 'x', ButtonStyle: Success) on b2; };", deprecation.AlterPageSetEquals}, + {"page set unparenthesised", "alter snippet M.S { set Title: 'T'; };", + "alter snippet M.S { set (Title: 'T'); };", deprecation.AlterPageSetUnparenthesised}, + {"page drop widget", "alter layout M.L { drop widget a, b; };", + "alter layout M.L { drop a, b; };", deprecation.AlterPageDropWidget}, +} + +func TestR3OldSpellingsAreAliases(t *testing.T) { + for _, p := range r3Pairs { + t.Run(p.name, func(t *testing.T) { + old := mustBuild(t, p.old) + canon := mustBuild(t, p.canon) + if got := deprecationCodes(canon); len(got) != 0 { + t.Errorf("canonical %q recorded %v, want none", p.canon, got) + } + got := deprecationCodes(old) + if len(got) == 0 { + t.Fatalf("old %q recorded nothing, want %s", p.old, p.code) + } + for _, d := range old.Deprecations { + if d.Code != p.code { + t.Errorf("old %q recorded %v, want only %s", p.old, got, p.code) + } + if d.Fix == nil { + t.Errorf("old %q: %s recorded without a rewrite (%s)", p.old, d.Code, d.NoFix) + } + } + if len(old.Statements) == 0 { + t.Fatalf("old %q built no statement", p.old) + } + if !reflect.DeepEqual(old.Statements, canon.Statements) { + t.Errorf("old and canonical build different statements:\n old: %#v\n canon: %#v", old.Statements, canon.Statements) + } + }) + } +} + +// The canonical list is read, not merely parsed: each form must build the +// values the executor acts on. Guards against a list alternative the visitor +// never looks at, which would pass the pair test above only if both halves +// built nothing — so the values are pinned here. +func TestR3CanonicalListsCarryTheirValues(t *testing.T) { + settings := mustBuild(t, "alter settings runtime ( BcryptCost: 11, UseOQLVersion2: true );").Statements[0].(*ast.AlterSettingsStmt) + if settings.Properties["BcryptCost"] != int64(11) || settings.Properties["UseOQLVersion2"] != true { + t.Errorf("settings properties: %#v", settings.Properties) + } + cfg := mustBuild(t, "create configuration 'Acc' ( HttpPortNumber: 8081 );").Statements[0].(*ast.CreateConfigurationStmt) + if cfg.Properties["HttpPortNumber"] != "8081" { + t.Errorf("configuration properties: %#v", cfg.Properties) + } + client := mustBuild(t, "alter consumed odata service M.Crm set ( Version: '2.0', HttpUsername: 'Bearer ' + @M.Token );").Statements[0].(*ast.AlterODataClientStmt) + if client.Changes["Version"] != "2.0" || client.Changes["HttpUsername"] != "'Bearer ' + @M.Token" { + t.Errorf("odata client changes: %#v", client.Changes) + } + styling := mustBuild(t, "alter styling on page M.P widget c set ( Class: 'card', 'Full width': on );").Statements[0].(*ast.AlterStylingStmt) + if len(styling.Assignments) != 2 || styling.Assignments[0].Value != "card" || !styling.Assignments[1].ToggleOn { + t.Errorf("styling assignments: %#v", styling.Assignments) + } + entity := mustBuild(t, "alter entity M.Remote set ( AllowCreateChangeLocally: true );").Statements[0].(*ast.AlterEntityStmt) + if entity.Operation != ast.AlterEntitySetAllowCreateChangeLocally || !entity.BoolValue { + t.Errorf("alter entity: %#v", entity) + } +} + +// An expression is refused on an OData property that takes a plain value, in +// the canonical list as in the old spelling. +func TestR3ODataListRefusesExpressionOnPlainProperty(t *testing.T) { + for _, src := range []string{ + "alter published odata service M.Api set ( Version: '1' + '2' );", + "alter consumed odata service M.Crm set ( Version: '1' + '2' );", + } { + if _, errs := Build(src); len(errs) == 0 { + t.Errorf("%q: want an error for an expression on Version", src) + } + } +} diff --git a/mdl/visitor/r8_spellings_test.go b/mdl/visitor/r8_spellings_test.go index 612bb81d2e..a2060ab230 100644 --- a/mdl/visitor/r8_spellings_test.go +++ b/mdl/visitor/r8_spellings_test.go @@ -38,8 +38,8 @@ var r8Pairs = []r8Pair{ {"complete task", pageWith("complete_task 'Approve'"), pageWith("complete task 'Approve'"), deprecation.PageActionWord}, {"menu sign out", "create navigation Responsive home page M.Home menu (menu item 'Out' sign_out;);", "create navigation Responsive home page M.Home menu (menu item 'Out' sign out;);", deprecation.PageActionWord}, - {"alter page set action", "alter page M.P { set Action = show_page M.Q on b };", - "alter page M.P { set Action = show page M.Q on b };", deprecation.PageActionWord}, + {"alter page set action", "alter page M.P { set (Action: show_page M.Q) on b };", + "alter page M.P { set (Action: show page M.Q) on b };", deprecation.PageActionWord}, {"not null error", "create entity M.E (Name: String(100) not null error 'Required');", "create entity M.E (Name: String(100) not null error message 'Required');", deprecation.ErrorMessageKeyword}, diff --git a/mdl/visitor/visitor_alter.go b/mdl/visitor/visitor_alter.go index 5c54bc6bba..8c56e611f1 100644 --- a/mdl/visitor/visitor_alter.go +++ b/mdl/visitor/visitor_alter.go @@ -68,19 +68,29 @@ func (b *Builder) ExitAlterStatement(ctx *parser.AlterStatementContext) { } changes := make(map[string]any) - for _, propCtx := range ctx.AllOdataAlterAssignment() { - prop := propCtx.(*parser.OdataAlterAssignmentContext) - name := identifierOrKeywordText(prop.IdentifierOrKeyword()) + // One property, in either spelling: `set ( Key: value, … )` or the old + // `set Key = value, …` (MDL-DEPR061). Both build the same statement. + set := func(name string, value parser.IOdataPropertyValueContext, expr parser.IExpressionContext) { if ctx.ConsumedODataServiceKw() != nil && isODataClientExpressionProp(name) { // Expression-typed: the expression as written (see visitor_odata_expression.go). - changes[name], _ = odataExpressionValue(prop.OdataPropertyValue(), prop.Expression()) - continue + changes[name], _ = odataExpressionValue(value, expr) + return } - val := prop.OdataPropertyValue() - if val != nil { - changes[name] = odataValueText(val.(*parser.OdataPropertyValueContext)) + if value != nil { + changes[name] = odataValueText(value.(*parser.OdataPropertyValueContext)) + } + } + if pl, ok := ctx.OdataAlterPropertyList().(*parser.OdataAlterPropertyListContext); ok && pl != nil { + for _, propCtx := range pl.AllOdataPropertyAssignment() { + prop := propCtx.(*parser.OdataPropertyAssignmentContext) + set(identifierOrKeywordText(prop.IdentifierOrKeyword()), prop.OdataPropertyValue(), prop.Expression()) } } + for _, propCtx := range ctx.AllOdataAlterAssignment() { + prop := propCtx.(*parser.OdataAlterAssignmentContext) + set(identifierOrKeywordText(prop.IdentifierOrKeyword()), prop.OdataPropertyValue(), prop.Expression()) + } + b.recordODataAlterAssignments(ctx) if ctx.ConsumedODataServiceKw() != nil { b.statements = append(b.statements, &ast.AlterODataClientStmt{ diff --git a/mdl/visitor/visitor_alter_alias_equivalence_test.go b/mdl/visitor/visitor_alter_alias_equivalence_test.go index d48d43d73b..1908417a4c 100644 --- a/mdl/visitor/visitor_alter_alias_equivalence_test.go +++ b/mdl/visitor/visitor_alter_alias_equivalence_test.go @@ -5,13 +5,11 @@ package visitor import ( "reflect" "testing" - - "github.com/mendixlabs/mxcli/mdl/ast" ) // ADR-0011: an alias parses to the IDENTICAL operation as its canonical form — -// the only difference allowed is the Legacy marker that drives the -// deprecation warning. Without this, an alias could silently build a different +// the only difference allowed is the deprecation the visitor records for the +// old spelling. Without this, an alias could silently build a different // change (a value parsed through another rule, a target dropped) and the // warning would tell the user the two are interchangeable when they are not. // `fmt --upgrade` relies on the same equivalence (ADR-0011, Negative). @@ -22,8 +20,8 @@ func TestGenericAlter_AliasesBuildTheIdenticalOperation(t *testing.T) { {"set unparenthesised colon", `set Caption: 'Save' on btnSave`, `set (Caption: 'Save') on btnSave`}, {"page-level set", `set Title = 'Edit'`, `set (Title: 'Edit')`}, {"quoted key", `set 'showLabel' = false on w1`, `set ('showLabel': false) on w1`}, - {"action", `set Action = microflow M.ACT on btnGo`, `set (Action: microflow M.ACT) on btnGo`}, - {"named action slot", `set 'createFileAction' = microflow M.F on up1`, `set ('createFileAction': microflow M.F) on up1`}, + {"action", `set Action = call microflow M.ACT on btnGo`, `set (Action: call microflow M.ACT) on btnGo`}, + {"named action slot", `set 'createFileAction' = call microflow M.F on up1`, `set ('createFileAction': call microflow M.F) on up1`}, {"datasource", `set DataSource = $Param on dv1`, `set (DataSource: $Param) on dv1`}, {"visible", `set Visible = [Name != ''] on txt1`, `set (Visible: [Name != '']) on txt1`}, {"expression", `set DynamicClasses = if $x/F then 'a' else '' on c1`, `set (DynamicClasses: if $x/F then 'a' else '') on c1`}, @@ -32,40 +30,22 @@ func TestGenericAlter_AliasesBuildTheIdenticalOperation(t *testing.T) { } for _, p := range pairs { t.Run(p.name, func(t *testing.T) { + oldProg := mustBuild(t, "alter page M.P { "+p.old+"; };") + canonProg := mustBuild(t, "alter page M.P { "+p.canonical+"; };") old := buildAlterPage(t, "alter page M.P { "+p.old+"; };").Operations canon := buildAlterPage(t, "alter page M.P { "+p.canonical+"; };").Operations if len(old) != 1 || len(canon) != 1 { t.Fatalf("want one operation each, got %d and %d", len(old), len(canon)) } - if legacyOf(old[0]) == "" { - t.Fatalf("old spelling %q not flagged as an alias", p.old) + if len(oldProg.Deprecations) != 1 { + t.Fatalf("old spelling %q recorded %v, want one deprecation", p.old, deprecationCodes(oldProg)) } - if legacyOf(canon[0]) != "" { - t.Fatalf("canonical spelling %q flagged as alias %q", p.canonical, legacyOf(canon[0])) + if got := deprecationCodes(canonProg); len(got) != 0 { + t.Fatalf("canonical spelling %q recorded %v", p.canonical, got) } - clearLegacy(old[0]) if !reflect.DeepEqual(old[0], canon[0]) { t.Errorf("alias builds a different operation:\n old: %#v\n canonical: %#v", old[0], canon[0]) } }) } } - -func legacyOf(op ast.AlterPageOperation) string { - switch o := op.(type) { - case *ast.SetPropertyOp: - return o.Legacy - case *ast.DropWidgetOp: - return o.Legacy - } - return "" -} - -func clearLegacy(op ast.AlterPageOperation) { - switch o := op.(type) { - case *ast.SetPropertyOp: - o.Legacy = "" - case *ast.DropWidgetOp: - o.Legacy = "" - } -} diff --git a/mdl/visitor/visitor_alter_generic_test.go b/mdl/visitor/visitor_alter_generic_test.go index 81de337312..10cea2c380 100644 --- a/mdl/visitor/visitor_alter_generic_test.go +++ b/mdl/visitor/visitor_alter_generic_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" ) // The generic ALTER (ADR-0012 decision 2, ako/mxcli#712): one grammar rule @@ -31,10 +32,14 @@ func buildAlterPage(t *testing.T, input string) *ast.AlterPageStmt { } func TestGenericAlter_CanonicalSetIsParenthesisedAndColon(t *testing.T) { - stmt := buildAlterPage(t, `alter page Module.Page { + const src = `alter page Module.Page { set (Caption: 'Save', ButtonStyle: Success) on btnSave; set (Title: 'Edit order'); - };`) + };` + stmt := buildAlterPage(t, src) + if got := deprecationCodes(mustBuild(t, src)); len(got) != 0 { + t.Errorf("canonical set must not be reported as an alias, got %v", got) + } if len(stmt.Operations) != 2 { t.Fatalf("want 2 operations, got %d", len(stmt.Operations)) } @@ -45,22 +50,20 @@ func TestGenericAlter_CanonicalSetIsParenthesisedAndColon(t *testing.T) { if onWidget.Properties["Caption"] != "Save" || onWidget.Properties["ButtonStyle"] != "Success" { t.Errorf("properties: got %v", onWidget.Properties) } - if onWidget.Legacy != "" { - t.Errorf("canonical set must not be flagged as an alias, got %q", onWidget.Legacy) - } pageLevel := stmt.Operations[1].(*ast.SetPropertyOp) if pageLevel.Target.Widget != "" || pageLevel.Properties["Title"] != "Edit order" { t.Errorf("page-level set: target %q, properties %v", pageLevel.Target.Widget, pageLevel.Properties) } - if pageLevel.Legacy != "" { - t.Errorf("canonical page-level set flagged as alias: %q", pageLevel.Legacy) - } } func TestGenericAlter_CanonicalDropNamesTargetsWithoutKeyword(t *testing.T) { - stmt := buildAlterPage(t, `alter snippet Module.Snip { + const src = `alter snippet Module.Snip { drop txtOld, dgOrders.Total; - };`) + };` + stmt := buildAlterPage(t, src) + if got := deprecationCodes(mustBuild(t, src)); len(got) != 0 { + t.Errorf("canonical drop reported as an alias: %v", got) + } if stmt.ContainerType != "SNIPPET" { t.Errorf("container type: got %q", stmt.ContainerType) } @@ -69,18 +72,16 @@ func TestGenericAlter_CanonicalDropNamesTargetsWithoutKeyword(t *testing.T) { drop.Targets[1].Widget != "dgOrders" || drop.Targets[1].Column != "Total" { t.Errorf("targets: got %+v", drop.Targets) } - if drop.Legacy != "" { - t.Errorf("canonical drop flagged as alias: %q", drop.Legacy) - } } // A widget may be NAMED like a keyword the old forms use; the canonical drop // of it must still parse as a drop of that name. func TestGenericAlter_DropOfWidgetNamedLikeAKeyword(t *testing.T) { - stmt := buildAlterPage(t, `alter page Module.Page { drop widget; };`) + const src = `alter page Module.Page { drop widget; };` + stmt := buildAlterPage(t, src) drop := stmt.Operations[0].(*ast.DropWidgetOp) - if len(drop.Targets) != 1 || drop.Targets[0].Widget != "widget" || drop.Legacy != "" { - t.Errorf("got %+v legacy=%q", drop.Targets, drop.Legacy) + if got := deprecationCodes(mustBuild(t, src)); len(drop.Targets) != 1 || drop.Targets[0].Widget != "widget" || len(got) != 0 { + t.Errorf("got %+v, deprecations %v", drop.Targets, got) } } @@ -114,26 +115,17 @@ func TestGenericAlter_OldSpellingsAreFlaggedAliases(t *testing.T) { name, op string legacy string }{ - {"set without parentheses", `set Caption = 'Save' on btnSave`, ast.AlterAliasSetEquals}, - {"page-level set without parentheses", `set Title = 'Edit'`, ast.AlterAliasSetEquals}, - {"parenthesised set with =", `set (Caption = 'Save', ButtonStyle = Success) on btnSave`, ast.AlterAliasSetEquals}, - {"set without parentheses, with colon", `set Caption: 'Save' on btnSave`, ast.AlterAliasSetUnparenthesised}, - {"drop widget", `drop widget txtOld, txtUnused`, ast.AlterAliasDropWidget}, + {"set without parentheses", `set Caption = 'Save' on btnSave`, deprecation.AlterPageSetEquals}, + {"page-level set without parentheses", `set Title = 'Edit'`, deprecation.AlterPageSetEquals}, + {"parenthesised set with =", `set (Caption = 'Save', ButtonStyle = Success) on btnSave`, deprecation.AlterPageSetEquals}, + {"set without parentheses, with colon", `set Caption: 'Save' on btnSave`, deprecation.AlterPageSetUnparenthesised}, + {"drop widget", `drop widget txtOld, txtUnused`, deprecation.AlterPageDropWidget}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - stmt := buildAlterPage(t, "alter page Module.Page { "+c.op+"; };") - var got string - switch o := stmt.Operations[0].(type) { - case *ast.SetPropertyOp: - got = o.Legacy - case *ast.DropWidgetOp: - got = o.Legacy - default: - t.Fatalf("unexpected op %T", o) - } - if got != c.legacy { - t.Errorf("legacy spelling: got %q, want %q", got, c.legacy) + got := deprecationCodes(mustBuild(t, "alter page Module.Page { "+c.op+"; };")) + if len(got) != 1 || got[0] != c.legacy { + t.Errorf("recorded %v, want [%s]", got, c.legacy) } }) } diff --git a/mdl/visitor/visitor_alter_page.go b/mdl/visitor/visitor_alter_page.go index 326905bffa..063696b1ec 100644 --- a/mdl/visitor/visitor_alter_page.go +++ b/mdl/visitor/visitor_alter_page.go @@ -86,26 +86,14 @@ func (b *Builder) buildAlterSet(ctx *parser.AlterSetContext) ast.AlterPageOperat } } - usedEquals := false for _, assignCtx := range ctx.AllAlterPageAssignment() { assign := assignCtx.(*parser.AlterPageAssignmentContext) - if ao := assign.AlterAssignOp(); ao != nil && ao.(*parser.AlterAssignOpContext).EQUALS() != nil { - usedEquals = true - } name, value := b.buildAlterPageAssignment(assign) if name != "" { op.Properties[name] = value } } - - // Which alias, if any. `=` is reported first: its rewrite — the - // parenthesised, colon form — also fixes a missing parenthesis. - switch { - case usedEquals: - op.Legacy = ast.AlterAliasSetEquals - case ctx.LPAREN() == nil: - op.Legacy = ast.AlterAliasSetUnparenthesised - } + b.recordAlterPageSet(ctx) return op } @@ -225,9 +213,7 @@ func (b *Builder) buildAlterInsert(ctx *parser.AlterInsertContext) *ast.InsertWi // buildAlterDrop builds a DropWidgetOp from the parse tree. func (b *Builder) buildAlterDrop(ctx *parser.AlterDropContext) *ast.DropWidgetOp { op := &ast.DropWidgetOp{} - if ctx.WIDGET() != nil { - op.Legacy = ast.AlterAliasDropWidget - } + b.recordAlterPageDropWidget(ctx) for _, tr := range ctx.AllAlterTarget() { op.Targets = append(op.Targets, b.buildAlterTarget(tr)) } diff --git a/mdl/visitor/visitor_document_names_test.go b/mdl/visitor/visitor_document_names_test.go index 4b00ed67a6..46b26d3f34 100644 --- a/mdl/visitor/visitor_document_names_test.go +++ b/mdl/visitor/visitor_document_names_test.go @@ -31,7 +31,7 @@ func TestDocumentTypeNamesFollowStudioPro(t *testing.T) { {deprecation.ConsumedODataService, "create odata client M.Crm (ODataVersion: OData4, MetadataUrl: 'https://x/$metadata');", "create consumed odata service M.Crm (ODataVersion: OData4, MetadataUrl: 'https://x/$metadata');"}, - {deprecation.ConsumedODataService, "alter odata client M.Crm set Version = '2';", "alter consumed odata service M.Crm set Version = '2';"}, + {deprecation.ConsumedODataService, "alter odata client M.Crm set ( Version: '2' );", "alter consumed odata service M.Crm set ( Version: '2' );"}, {deprecation.ConsumedODataService, "drop odata client M.Crm;", "drop consumed odata service M.Crm;"}, {deprecation.ConsumedODataService, "describe odata client M.Crm;", "describe consumed odata service M.Crm;"}, {deprecation.ConsumedODataService, "list odata clients;", "list consumed odata services;"}, @@ -43,7 +43,7 @@ func TestDocumentTypeNamesFollowStudioPro(t *testing.T) { {deprecation.PublishedODataService, "create odata service M.Api (Path: 'odata/v1', Namespace: 'M');", "create published odata service M.Api (Path: 'odata/v1', Namespace: 'M');"}, - {deprecation.PublishedODataService, "alter odata service M.Api set Version = '2';", "alter published odata service M.Api set Version = '2';"}, + {deprecation.PublishedODataService, "alter odata service M.Api set ( Version: '2' );", "alter published odata service M.Api set ( Version: '2' );"}, {deprecation.PublishedODataService, "drop odata service M.Api;", "drop published odata service M.Api;"}, {deprecation.PublishedODataService, "describe odata service M.Api;", "describe published odata service M.Api;"}, {deprecation.PublishedODataService, "list odata services in M;", "list published odata services in M;"}, @@ -61,8 +61,8 @@ func TestDocumentTypeNamesFollowStudioPro(t *testing.T) { {deprecation.AppSecurity, "alter project security guest access on role Guest;", "alter app security guest access on role Guest;"}, {deprecation.AppSecurity, "alter project security strict mode on;", "alter app security strict mode on;"}, // settings runtime - {deprecation.SettingsRuntime, "alter settings model BcryptCost = 11, HashAlgorithm = 'BCrypt';", "alter settings runtime BcryptCost = 11, HashAlgorithm = 'BCrypt';"}, - {deprecation.SettingsRuntime, "ALTER SETTINGS MODEL BcryptCost = 11;", "ALTER SETTINGS RUNTIME BcryptCost = 11;"}, + {deprecation.SettingsRuntime, "alter settings model ( BcryptCost: 11, HashAlgorithm: 'BCrypt' );", "alter settings runtime ( BcryptCost: 11, HashAlgorithm: 'BCrypt' );"}, + {deprecation.SettingsRuntime, "ALTER SETTINGS MODEL (BcryptCost: 11);", "ALTER SETTINGS RUNTIME (BcryptCost: 11);"}, } for _, c := range cases { t.Run(c.old, func(t *testing.T) { @@ -94,7 +94,7 @@ func TestDocumentTypeNamesLeaveOtherUsesAlone(t *testing.T) { "drop published rest service M.Api;", "list consumed mcp services;", "show project security;", - "alter settings workflows UserEntity = 'System.User';", + "alter settings workflows ( UserEntity: 'System.User' );", } { t.Run(src, func(t *testing.T) { if got := deprecationCodes(mustBuild(t, src)); len(got) != 0 { diff --git a/mdl/visitor/visitor_entity.go b/mdl/visitor/visitor_entity.go index 1811689b7f..711ac31a6c 100644 --- a/mdl/visitor/visitor_entity.go +++ b/mdl/visitor/visitor_entity.go @@ -604,6 +604,9 @@ func parseAnnotationParamInt(ctx parser.IAnnotationParamContext) int { // ExitAlterEntityAction handles ALTER ENTITY ... ADD/DROP/RENAME/MODIFY ATTRIBUTE ... func (b *Builder) ExitAlterEntityAction(ctx *parser.AlterEntityActionContext) { + // R3 respellings (visitor_r3_property_lists.go). + b.recordAllowCreateChangeLocally(ctx) + b.recordModifyAttributeColon(ctx) // Walk up to the parent AlterStatement to get the entity's qualified name parent := ctx.GetParent() for parent != nil { diff --git a/mdl/visitor/visitor_odata_expression.go b/mdl/visitor/visitor_odata_expression.go index 1ab3f31044..7862d5ac87 100644 --- a/mdl/visitor/visitor_odata_expression.go +++ b/mdl/visitor/visitor_odata_expression.go @@ -66,6 +66,12 @@ func (b *Builder) ExitOdataPropertyAssignment(ctx *parser.OdataPropertyAssignmen if _, onClient := ctx.GetParent().(*parser.CreateODataClientStatementContext); onClient && isODataClientExpressionProp(name) { return } + // `alter consumed odata service X set ( Key: expr )` takes the same list. + if list, ok := ctx.GetParent().(*parser.OdataAlterPropertyListContext); ok && isODataClientExpressionProp(name) { + if alter, ok := list.GetParent().(*parser.AlterStatementContext); ok && alter.ConsumedODataServiceKw() != nil { + return + } + } b.addError(odataExpressionNotAllowed(name, ctx.Expression())) } diff --git a/mdl/visitor/visitor_r3_property_lists.go b/mdl/visitor/visitor_r3_property_lists.go new file mode 100644 index 0000000000..7087deca97 --- /dev/null +++ b/mdl/visitor/visitor_r3_property_lists.go @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + + "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" +) + +// R3 (PROPOSAL_mdl_beta_syntax_freeze.md §3, ako/mxcli#751): `:` sets a model +// property. An `alter` sets properties in exactly the `( Key: value, … )` list +// its `create` takes, so a fragment of describe output pastes into an alter +// unchanged; a clause outside a property list takes no colon, and an attribute +// definition always has one. Every old spelling here is a respelling — the +// grammar builds the same statement from both — so each use is recorded as a +// deprecation with the edits that rewrite it, and nothing is gated on the +// language version. + +// oldAssignment is one `Key = value` of an old-spelling list, by the rune +// offset where its key ends and the `=` token. +type oldAssignment struct { + keyStop int + op antlr.Token +} + +// assignmentAt reads an assignment whose key is child 0 of ctx and whose +// operator is the node op (a terminal or a one-token rule). +func assignmentAt(ctx antlr.ParserRuleContext, op antlr.Tree) (oldAssignment, bool) { + if ctx == nil || op == nil || ctx.GetChildCount() == 0 { + return oldAssignment{}, false + } + _, keyStop := nodeSpan(ctx.GetChild(0)) + var tok antlr.Token + switch o := op.(type) { + case antlr.TerminalNode: + tok = o.GetSymbol() + case antlr.ParserRuleContext: + tok = o.GetStart() + } + if keyStop < 0 || tok == nil { + return oldAssignment{}, false + } + return oldAssignment{keyStop: keyStop, op: tok}, true +} + +// colonEdits writes each assignment's `=` as `:`, directly after the key: +// `Caption = 'x'` becomes `Caption: 'x'`. +func colonEdits(as []oldAssignment) []ast.TextEdit { + out := make([]ast.TextEdit, 0, len(as)) + for _, a := range as { + out = append(out, ast.TextEdit{Start: a.keyStop + 1, Stop: a.op.GetStop() + 1, Text: ":"}) + } + return out +} + +// wrapEdits puts the list from first to last in parentheses. A list that +// starts on a new line after before (describe's layout) opens its parenthesis +// on before's line and closes it on a line of its own, indented as that line +// is; otherwise the parentheses hug the list, padded with a space when pad is +// set. +func wrapEdits(before, first, last antlr.Token, pad bool) []ast.TextEdit { + if before != nil && first != nil && before.GetInputStream() != nil { + is := before.GetInputStream() + gap := is.GetText(before.GetStop()+1, first.GetStart()-1) + if strings.Contains(gap, "\n") { + return []ast.TextEdit{ + insertAt(before.GetStop()+1, " ("), + insertAt(last.GetStop()+1, "\n"+lineIndent(is, before.GetStart())+")"), + } + } + } + open, closing := "(", ")" + if pad { + open, closing = "( ", " )" + } + return []ast.TextEdit{insertAt(first.GetStart(), open), insertAt(last.GetStop()+1, closing)} +} + +// recordOldList records code for an old-spelling assignment list written +// after before, with the rewrite to `( Key: value, … )`. +func (b *Builder) recordOldList(code string, before antlr.Token, first, last antlr.ParserRuleContext, as []oldAssignment, subject string) { + if first == nil || last == nil || first.GetStart() == nil || last.GetStop() == nil { + return + } + b.recordDeprecation(code, first.GetStart(), subject) + edits := append(colonEdits(as), wrapEdits(before, first.GetStart(), last.GetStop(), true)...) + b.fixLastDeprecation(code, &ast.Fix{Edits: edits}, "") +} + +// recordAlterPageSet reports the generic alter's old `set` spellings: +// `set Key = value` / `set (Key = value)` (MDL-DEPR101) and `set Key: value` +// (MDL-DEPR102). `=` is reported first: its rewrite, the parenthesised colon +// form, also adds a missing parenthesis. +func (b *Builder) recordAlterPageSet(ctx *parser.AlterSetContext) { + assigns := ctx.AllAlterPageAssignment() + if len(assigns) == 0 { + return + } + var as []oldAssignment + for _, a := range assigns { + ac := a.(*parser.AlterPageAssignmentContext) + if op, ok := ac.AlterAssignOp().(*parser.AlterAssignOpContext); ok && op != nil && op.EQUALS() != nil { + if oa, ok := assignmentAt(ac, op); ok { + as = append(as, oa) + } + } + } + var edits []ast.TextEdit + code := deprecation.AlterPageSetEquals + switch { + case len(as) > 0: + edits = colonEdits(as) + case ctx.LPAREN() == nil: + code = deprecation.AlterPageSetUnparenthesised + default: + return + } + if ctx.LPAREN() == nil { + first := assigns[0].(*parser.AlterPageAssignmentContext).GetStart() + last := assigns[len(assigns)-1].(*parser.AlterPageAssignmentContext).GetStop() + edits = append(edits, insertAt(first.GetStart(), "("), insertAt(last.GetStop()+1, ")")) + } + b.recordDeprecation(code, ctx.SET().GetSymbol(), "alter set") + b.fixLastDeprecation(code, &ast.Fix{Edits: edits}, "") +} + +// recordAlterPageDropWidget reports `drop widget a, b` (MDL-DEPR103). +func (b *Builder) recordAlterPageDropWidget(ctx *parser.AlterDropContext) { + w := ctx.WIDGET() + targets := ctx.AllAlterTarget() + if w == nil || len(targets) == 0 { + return + } + tok := w.GetSymbol() + b.recordDeprecation(deprecation.AlterPageDropWidget, tok, "drop widget") + edit := ast.TextEdit{Start: tok.GetStart(), Stop: targets[0].GetStart().GetStart(), Text: ""} + b.fixLastDeprecation(deprecation.AlterPageDropWidget, &ast.Fix{Edits: []ast.TextEdit{edit}}, "") +} + +// recordSettingsAssignments reports a settings list written `Key = value, …` +// (MDL-DEPR060), in `alter settings` and `create configuration`. +func (b *Builder) recordSettingsAssignments(assigns []parser.ISettingsAssignmentContext) { + if len(assigns) == 0 { + return + } + var as []oldAssignment + for _, a := range assigns { + ac := a.(*parser.SettingsAssignmentContext) + if oa, ok := assignmentAt(ac, ac.EQUALS()); ok { + as = append(as, oa) + } + } + first := assigns[0].(*parser.SettingsAssignmentContext) + b.recordOldList(deprecation.SettingsAssignment, tokenBefore(first), first, + assigns[len(assigns)-1].(*parser.SettingsAssignmentContext), as, "settings") +} + +// recordODataAlterAssignments reports `alter … odata service X set Key = +// value, …` (MDL-DEPR061). +func (b *Builder) recordODataAlterAssignments(ctx *parser.AlterStatementContext) { + assigns := ctx.AllOdataAlterAssignment() + if len(assigns) == 0 { + return + } + var as []oldAssignment + for _, a := range assigns { + ac := a.(*parser.OdataAlterAssignmentContext) + if oa, ok := assignmentAt(ac, ac.EQUALS()); ok { + as = append(as, oa) + } + } + b.recordOldList(deprecation.ODataAlterAssignment, ctx.SET().GetSymbol(), + assigns[0].(*parser.OdataAlterAssignmentContext), assigns[len(assigns)-1].(*parser.OdataAlterAssignmentContext), + as, "odata service") +} + +// ExitAlterStylingAction reports `alter styling … set Class = 'x', 'P' = on` +// (MDL-DEPR062): an `=`, or a list without its parentheses. +func (b *Builder) ExitAlterStylingAction(ctx *parser.AlterStylingActionContext) { + assigns := ctx.AllAlterStylingAssignment() + if ctx.SET() == nil || len(assigns) == 0 { + return + } + var as []oldAssignment + for _, a := range assigns { + ac := a.(*parser.AlterStylingAssignmentContext) + if op, ok := ac.AlterStylingAssignOp().(*parser.AlterStylingAssignOpContext); ok && op != nil && op.EQUALS() != nil { + if oa, ok := assignmentAt(ac, op); ok { + as = append(as, oa) + } + } + } + parenthesised := ctx.LPAREN() != nil + if len(as) == 0 && parenthesised { + return + } + edits := colonEdits(as) + first := assigns[0].(*parser.AlterStylingAssignmentContext) + last := assigns[len(assigns)-1].(*parser.AlterStylingAssignmentContext) + if !parenthesised { + edits = append(edits, wrapEdits(ctx.SET().GetSymbol(), first.GetStart(), last.GetStop(), true)...) + } + b.recordDeprecation(deprecation.StylingAssignment, ctx.SET().GetSymbol(), "alter styling") + b.fixLastDeprecation(deprecation.StylingAssignment, &ast.Fix{Edits: edits}, "") +} + +// recordAllowCreateChangeLocally reports `set allow_create_change_locally = +// v` (MDL-DEPR063), rewritten to `set ( AllowCreateChangeLocally: v )`. +func (b *Builder) recordAllowCreateChangeLocally(ctx *parser.AlterEntityActionContext) { + key, eq := ctx.ALLOW_CREATE_CHANGE_LOCALLY(), ctx.EQUALS() + if ctx.SET() == nil || key == nil || eq == nil { + return + } + var value antlr.TerminalNode = ctx.TRUE() + if value == nil { + value = ctx.FALSE() + } + if value == nil { + return + } + kt := key.GetSymbol() + b.recordDeprecation(deprecation.AllowCreateChangeLocally, kt, "alter entity") + b.fixLastDeprecation(deprecation.AllowCreateChangeLocally, &ast.Fix{Edits: []ast.TextEdit{ + {Start: kt.GetStart(), Stop: eq.GetSymbol().GetStop() + 1, Text: "( AllowCreateChangeLocally:"}, + insertAt(value.GetSymbol().GetStop()+1, " )"), + }}, "") +} + +// recordModifyAttributeColon reports `modify attribute A Type` (MDL-DEPR065), +// rewritten to `modify attribute A: Type`. +func (b *Builder) recordModifyAttributeColon(ctx *parser.AlterEntityActionContext) { + if ctx.MODIFY() == nil || ctx.COLON() != nil || ctx.DataType() == nil { + return + } + names := ctx.AllAttributeName() + if len(names) == 0 || names[0].GetStop() == nil { + return + } + stop := names[0].GetStop() + b.recordDeprecation(deprecation.ModifyAttributeColon, stop, "modify attribute") + b.fixLastDeprecation(deprecation.ModifyAttributeColon, + &ast.Fix{Edits: []ast.TextEdit{insertAt(stop.GetStop()+1, ":")}}, "") +} + +// recordAssociationClauseColon reports `type: Reference`, `owner: Both` and +// `storage: Table` (MDL-DEPR064): the colon goes, and the clause keeps one +// space before its value. +func (b *Builder) recordAssociationClauseColon(ctx *parser.AssociationOptionContext) { + colon := ctx.COLON() + if colon == nil || ctx.GetChildCount() < 3 { + return + } + _, kwStop := nodeSpan(ctx.GetChild(0)) + valueStart, _ := nodeSpan(ctx.GetChild(2)) + if kwStop < 0 || valueStart < 0 { + return + } + b.recordDeprecation(deprecation.AssociationClauseColon, colon.GetSymbol(), "association") + b.fixLastDeprecation(deprecation.AssociationClauseColon, + &ast.Fix{Edits: []ast.TextEdit{replaceGap(kwStop, valueStart, " ")}}, "") +} + +// lineIndent is the leading whitespace of the line holding rune offset pos. +func lineIndent(is antlr.CharStream, pos int) string { + if pos <= 0 { + return "" + } + head := is.GetText(0, pos-1) + line := head[strings.LastIndex(head, "\n")+1:] + return line[:len(line)-len(strings.TrimLeft(line, " \t"))] +} + +// tokenBefore is the token directly before ctx in the token stream (on the +// default channel), or nil. +func tokenBefore(ctx antlr.ParserRuleContext) antlr.Token { + p, ok := ctx.GetParent().(antlr.ParserRuleContext) + if !ok || p == nil { + return nil + } + var prev antlr.Token + for i := 0; i < p.GetChildCount(); i++ { + c := p.GetChild(i) + if c == antlr.Tree(ctx) { + return prev + } + switch x := c.(type) { + case antlr.TerminalNode: + prev = x.GetSymbol() + case antlr.ParserRuleContext: + if x.GetStop() != nil { + prev = x.GetStop() + } + } + } + return nil +} diff --git a/mdl/visitor/visitor_r8_spellings.go b/mdl/visitor/visitor_r8_spellings.go index 21b9b44ffb..bd7e1a0feb 100644 --- a/mdl/visitor/visitor_r8_spellings.go +++ b/mdl/visitor/visitor_r8_spellings.go @@ -165,6 +165,7 @@ func (b *Builder) recordDeleteBehavior(kw antlr.TerminalNode, behaviour parser.I // ExitAssociationOption reports `delete_behavior …` and `reference_set`. func (b *Builder) ExitAssociationOption(ctx *parser.AssociationOptionContext) { + b.recordAssociationClauseColon(ctx) // R3: `type: Reference` (visitor_r3_property_lists.go) b.recordDeleteBehavior(ctx.DELETE_BEHAVIOR(), ctx.DeleteBehavior()) if n := ctx.REFERENCE_SET(); n != nil && strings.Contains(n.GetText(), "_") { b.recordRespelling(deprecation.ReferenceSetUnderscore, n.GetSymbol(), "ReferenceSet") diff --git a/mdl/visitor/visitor_settings.go b/mdl/visitor/visitor_settings.go index 2d601cb6d3..02f0ffd67e 100644 --- a/mdl/visitor/visitor_settings.go +++ b/mdl/visitor/visitor_settings.go @@ -49,22 +49,9 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext if len(allStrings) > 0 { stmt.ConfigName = unquoteStringLit(allStrings[0]) } - for _, assignCtx := range ctx.AllSettingsAssignment() { - assign, ok := assignCtx.(*parser.SettingsAssignmentContext) - if !ok || assign == nil { - continue - } - if assign.IDENTIFIER() == nil || assign.SettingsValue() == nil { - continue - } - key := assign.IDENTIFIER().GetText() - svCtx, ok := assign.SettingsValue().(*parser.SettingsValueContext) - if !ok || svCtx == nil { - continue - } - val := settingsValueText(svCtx) - stmt.Properties[key] = val - } + eachSettingsProperty(ctx.SettingsItemOptions(), ctx.AllSettingsAssignment(), func(key string, sv *parser.SettingsValueContext) { + stmt.Properties[key] = settingsValueText(sv) + }) } else if ctx.SettingsSection() != nil && ctx.GROUP() != nil { // ALTER SETTINGS WORKFLOWS ADD [OR MODIFY] GROUP 'Approvers' [( Description: '…' )] // ALTER SETTINGS WORKFLOWS MODIFY GROUP 'Approvers' ( Description: '…' ) @@ -92,25 +79,13 @@ func (b *Builder) ExitAlterSettingsClause(ctx *parser.AlterSettingsClauseContext } collectSettingsItemOptions(ctx.SettingsItemOptions(), stmt.Properties) } else if ctx.SettingsSection() != nil { - // ALTER SETTINGS MODEL|LANGUAGE|WORKFLOWS Key = Value, ... + // ALTER SETTINGS RUNTIME|LANGUAGE|WORKFLOWS ( Key: Value, ... ) stmt.Section = settingsSectionName(ctx.SettingsSection()) - for _, assignCtx := range ctx.AllSettingsAssignment() { - assign, ok := assignCtx.(*parser.SettingsAssignmentContext) - if !ok || assign == nil { - continue - } - if assign.IDENTIFIER() == nil || assign.SettingsValue() == nil { - continue - } - key := assign.IDENTIFIER().GetText() - svCtx, ok := assign.SettingsValue().(*parser.SettingsValueContext) - if !ok || svCtx == nil { - continue - } - val := settingsValueToInterface(svCtx) - stmt.Properties[key] = val - } + eachSettingsProperty(ctx.SettingsItemOptions(), ctx.AllSettingsAssignment(), func(key string, sv *parser.SettingsValueContext) { + stmt.Properties[key] = settingsValueToInterface(sv) + }) } + b.recordSettingsAssignments(ctx.AllSettingsAssignment()) b.statements = append(b.statements, stmt) } @@ -132,23 +107,39 @@ func (b *Builder) ExitCreateConfigurationStatement(ctx *parser.CreateConfigurati } } - for _, assignCtx := range ctx.AllSettingsAssignment() { - assign, ok := assignCtx.(*parser.SettingsAssignmentContext) - if !ok || assign == nil { - continue + eachSettingsProperty(ctx.SettingsItemOptions(), ctx.AllSettingsAssignment(), func(key string, sv *parser.SettingsValueContext) { + stmt.Properties[key] = settingsValueText(sv) + }) + b.recordSettingsAssignments(ctx.AllSettingsAssignment()) + + b.statements = append(b.statements, stmt) +} + +// eachSettingsProperty calls f for every property of a settings list, in +// either spelling: the canonical ( Key: value, … ) list, or the old +// `Key = value, …` assignments (MDL-DEPR060). Both build the same statement. +func eachSettingsProperty(opts parser.ISettingsItemOptionsContext, assigns []parser.ISettingsAssignmentContext, + f func(key string, sv *parser.SettingsValueContext)) { + if oc, ok := opts.(*parser.SettingsItemOptionsContext); ok && oc != nil { + for _, o := range oc.AllSettingsItemOption() { + so, ok := o.(*parser.SettingsItemOptionContext) + if !ok || so == nil || so.IdentifierOrKeyword() == nil || so.SettingsValue() == nil { + continue + } + if sv, ok := so.SettingsValue().(*parser.SettingsValueContext); ok && sv != nil { + f(unquoteIdentifier(so.IdentifierOrKeyword().GetText()), sv) + } } - if assign.IDENTIFIER() == nil || assign.SettingsValue() == nil { + } + for _, a := range assigns { + assign, ok := a.(*parser.SettingsAssignmentContext) + if !ok || assign == nil || assign.IDENTIFIER() == nil || assign.SettingsValue() == nil { continue } - key := assign.IDENTIFIER().GetText() - svCtx, ok := assign.SettingsValue().(*parser.SettingsValueContext) - if !ok || svCtx == nil { - continue + if sv, ok := assign.SettingsValue().(*parser.SettingsValueContext); ok && sv != nil { + f(assign.IDENTIFIER().GetText(), sv) } - stmt.Properties[key] = settingsValueText(svCtx) } - - b.statements = append(b.statements, stmt) } // collectSettingsItemOptions reads a ( key: value, … ) option list — the shared From 3621c63949fcd9fe2cdb915e249deac4f921450b Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 06:51:38 +0000 Subject: [PATCH 2/4] mdl/visitor: R3 rewrites keep a comment between the tokens they touch (#751) fmt --upgrade promises to keep comments, but the MDL-DEPR103, -064, -060..063 rewrites replaced whole gaps: 'drop widget -- c\n a' became 'drop a', 'type: /* c */ Reference' became 'type Reference'. A gap that holds more than whitespace now keeps its text; only the old token goes. Co-Authored-By: Claude Opus 5.5 --- mdl/upgrade/r3_property_lists_test.go | 29 ++++++++++++ mdl/visitor/visitor_r3_property_lists.go | 60 +++++++++++++++++++++--- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/mdl/upgrade/r3_property_lists_test.go b/mdl/upgrade/r3_property_lists_test.go index cf7e655b53..6f43935051 100644 --- a/mdl/upgrade/r3_property_lists_test.go +++ b/mdl/upgrade/r3_property_lists_test.go @@ -58,3 +58,32 @@ func TestUpgrade_R3PropertyLists(t *testing.T) { } } } + +// A comment between the tokens a rewrite touches survives it: the rewrite +// removes the old token and the blank space after it, never the text between +// two tokens (upgrade.go: "comments, layout … are kept"). +func TestUpgrade_R3KeepsCommentsInsideTheRewrite(t *testing.T) { + cases := []struct{ old, want string }{ + {"alter page M.P { drop widget -- old\n a; };\n", + "alter page M.P { drop -- old\n a; };\n"}, + {"alter page M.P { drop widget /* x */ a; };\n", + "alter page M.P { drop /* x */ a; };\n"}, + {"create association M.A_B from M.A to M.B type: /* c */ Reference;\n", + "create association M.A_B from M.A to M.B type /* c */ Reference;\n"}, + {"create association M.A_B from M.A to M.B type /* c */ : Reference;\n", + "create association M.A_B from M.A to M.B type /* c */ Reference;\n"}, + {"alter settings runtime BcryptCost /* c */ = 11;\n", + "alter settings runtime ( BcryptCost /* c */ : 11 );\n"}, + {"alter entity M.Remote set allow_create_change_locally /* c */ = true;\n", + "alter entity M.Remote set ( AllowCreateChangeLocally /* c */ : true );\n"}, + } + for _, c := range cases { + res := mustUpgrade(t, c.old, Options{}) + if res.Source != c.want { + t.Errorf("upgrade of\n%s got:\n%s want:\n%s", c.old, res.Source, c.want) + } + if again := mustUpgrade(t, res.Source, Options{}); again.Changed() { + t.Errorf("upgrade is not idempotent on\n%s", res.Source) + } + } +} diff --git a/mdl/visitor/visitor_r3_property_lists.go b/mdl/visitor/visitor_r3_property_lists.go index 7087deca97..1117112ea1 100644 --- a/mdl/visitor/visitor_r3_property_lists.go +++ b/mdl/visitor/visitor_r3_property_lists.go @@ -49,14 +49,50 @@ func assignmentAt(ctx antlr.ParserRuleContext, op antlr.Tree) (oldAssignment, bo // colonEdits writes each assignment's `=` as `:`, directly after the key: // `Caption = 'x'` becomes `Caption: 'x'`. +// +// A comment between the key and the `=` stays where it is: only the `=` is +// replaced then (`Key /* c */: 'x'`). func colonEdits(as []oldAssignment) []ast.TextEdit { out := make([]ast.TextEdit, 0, len(as)) for _, a := range as { - out = append(out, ast.TextEdit{Start: a.keyStop + 1, Stop: a.op.GetStop() + 1, Text: ":"}) + start := a.keyStop + 1 + if !blankBetween(a.op.GetInputStream(), start, a.op.GetStart()) { + start = a.op.GetStart() + } + out = append(out, ast.TextEdit{Start: start, Stop: a.op.GetStop() + 1, Text: ":"}) } return out } +// blankBetween reports whether the runes in [start, stop) are whitespace only +// — no comment the rewrite would delete. A missing stream counts as blank. +func blankBetween(is antlr.CharStream, start, stop int) bool { + if is == nil || stop <= start { + return true + } + return strings.TrimSpace(is.GetText(start, stop-1)) == "" +} + +// afterBlank is the offset of the first non-blank rune after tok: deleting +// [tok.start, afterBlank(tok)) removes the token and the space after it, and +// keeps a comment that follows. +func afterBlank(tok antlr.Token) int { + end := tok.GetStop() + 1 + is := tok.GetInputStream() + if is == nil { + return end + } + for end < is.Size() { + switch is.GetText(end, end) { + case " ", "\t", "\n", "\r": + end++ + continue + } + break + } + return end +} + // wrapEdits puts the list from first to last in parentheses. A list that // starts on a new line after before (describe's layout) opens its parenthesis // on before's line and closes it on a line of its own, indented as that line @@ -137,7 +173,7 @@ func (b *Builder) recordAlterPageDropWidget(ctx *parser.AlterDropContext) { } tok := w.GetSymbol() b.recordDeprecation(deprecation.AlterPageDropWidget, tok, "drop widget") - edit := ast.TextEdit{Start: tok.GetStart(), Stop: targets[0].GetStart().GetStart(), Text: ""} + edit := ast.TextEdit{Start: tok.GetStart(), Stop: afterBlank(tok), Text: ""} b.fixLastDeprecation(deprecation.AlterPageDropWidget, &ast.Fix{Edits: []ast.TextEdit{edit}}, "") } @@ -225,7 +261,8 @@ func (b *Builder) recordAllowCreateChangeLocally(ctx *parser.AlterEntityActionCo kt := key.GetSymbol() b.recordDeprecation(deprecation.AllowCreateChangeLocally, kt, "alter entity") b.fixLastDeprecation(deprecation.AllowCreateChangeLocally, &ast.Fix{Edits: []ast.TextEdit{ - {Start: kt.GetStart(), Stop: eq.GetSymbol().GetStop() + 1, Text: "( AllowCreateChangeLocally:"}, + {Start: kt.GetStart(), Stop: kt.GetStop() + 1, Text: "( AllowCreateChangeLocally"}, + colonEdits([]oldAssignment{{keyStop: kt.GetStop(), op: eq.GetSymbol()}})[0], insertAt(value.GetSymbol().GetStop()+1, " )"), }}, "") } @@ -259,9 +296,20 @@ func (b *Builder) recordAssociationClauseColon(ctx *parser.AssociationOptionCont if kwStop < 0 || valueStart < 0 { return } - b.recordDeprecation(deprecation.AssociationClauseColon, colon.GetSymbol(), "association") - b.fixLastDeprecation(deprecation.AssociationClauseColon, - &ast.Fix{Edits: []ast.TextEdit{replaceGap(kwStop, valueStart, " ")}}, "") + ct := colon.GetSymbol() + is := ct.GetInputStream() + edit := replaceGap(kwStop, valueStart, " ") + if !blankBetween(is, kwStop+1, valueStart) { + // A comment sits in the gap: delete only the colon and the space + // after it, keeping one space between the keyword and what follows. + text := "" + if kwStop+1 == ct.GetStart() { + text = " " + } + edit = ast.TextEdit{Start: ct.GetStart(), Stop: afterBlank(ct), Text: text} + } + b.recordDeprecation(deprecation.AssociationClauseColon, ct, "association") + b.fixLastDeprecation(deprecation.AssociationClauseColon, &ast.Fix{Edits: []ast.TextEdit{edit}}, "") } // lineIndent is the leading whitespace of the line holding rune offset pos. From 11a9ff598f33e1670bd2ad75b2ebbd58261ffaac Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 06:51:39 +0000 Subject: [PATCH 3/4] mdl/executor: renamed design property suggests the canonical styling list (#751) The MDL-WIDGET renamed-property suggestion told users to write `set 'Key' = 'Value'`, the spelling this PR deprecates as MDL-DEPR062. Co-Authored-By: Claude Opus 5.5 --- mdl/executor/validate_alter_styling.go | 8 ++++---- mdl/executor/validate_alter_styling_renamed_test.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mdl/executor/validate_alter_styling.go b/mdl/executor/validate_alter_styling.go index 63a91e81ed..2b66d9dd30 100644 --- a/mdl/executor/validate_alter_styling.go +++ b/mdl/executor/validate_alter_styling.go @@ -19,7 +19,7 @@ import ( // *ast.AlterStylingStmt is not among them, so an unsupported key was silent // until mxbuild: // -// alter styling on page … widget lvThings set 'Remove empty text' = on; +// alter styling on page … widget lvThings set ( 'Remove empty text': on ); // mxcli check --references -> Check passed! // mxcli exec -> Updated styling on widget "lvThings" // mxcli docker check -> [CE6083] "Design property Remove empty text is @@ -243,9 +243,9 @@ func renamedStylingSuggestion(r *designPropRename, value string) string { return fmt.Sprintf("ALTER STYLING cannot write its current form, which is a compound: set "+ "`DesignProperties: [%s]` on the widget in CREATE PAGE, or in an ALTER PAGE REPLACE.", r.Replacement) } - // 'Key': 'Value' → set 'Key' = 'Value' - if i := strings.Index(r.Replacement, "': "); i > 0 { - return fmt.Sprintf("Write it as `set %s' = %s`.", r.Replacement[:i], r.Replacement[i+3:]) + // 'Key': 'Value' → set ( 'Key': 'Value' ), the canonical list (R3) + if strings.Contains(r.Replacement, "': ") { + return fmt.Sprintf("Write it as `set ( %s )`.", r.Replacement) } return renamedDesignPropSuggestion(r, value) } diff --git a/mdl/executor/validate_alter_styling_renamed_test.go b/mdl/executor/validate_alter_styling_renamed_test.go index 409ca92af8..f808db2b01 100644 --- a/mdl/executor/validate_alter_styling_renamed_test.go +++ b/mdl/executor/validate_alter_styling_renamed_test.go @@ -24,8 +24,8 @@ func TestAlterStyling_RenamedKey_NamesTheCurrentProperty(t *testing.T) { }{ { name: "renamed property, value mapped through old option names", - set: `set 'Align content' = 'Left align as column'`, - want: []string{"renamed", "Align content (deprecated)", "CE6087", `set 'Align content (deprecated)' = 'Left align as a column'`}, + set: `set ( 'Align content': 'Left align as column' )`, + want: []string{"renamed", "Align content (deprecated)", "CE6087", `set ( 'Align content (deprecated)': 'Left align as a column' )`}, wantNot: []string{"CE6083"}, }, { @@ -33,13 +33,13 @@ func TestAlterStyling_RenamedKey_NamesTheCurrentProperty(t *testing.T) { // cannot write (one flat value — the MDL-WIDGET12 limit): point at // the inline form instead. name: "spacing side renamed into a compound", - set: `set 'Spacing bottom' = 'Outer medium'`, + set: `set ( 'Spacing bottom': 'Outer medium' )`, want: []string{"renamed", "CE6087", "'Spacing': ['margin-bottom': 'M']", "DesignProperties"}, wantNot: []string{"CE6083"}, }, { name: "multi-select toggle renamed into an option", - set: `set 'Hide on phone' = on`, + set: `set ( 'Hide on phone': on )`, want: []string{"renamed", "CE6087", "'Hide on': ['Phone': on]", "DesignProperties"}, wantNot: []string{"CE6083"}, }, From a32681c70c1d4f51cdaa38f044ecfe95aa580e12 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 28 Sep 2026 06:51:40 +0000 Subject: [PATCH 4/4] docs: settings and OData alter syntax in the R3 property list (#751) Co-Authored-By: Claude Opus 5.5 --- .../skills/mendix/odata-data-sharing/SKILL.md | 2 +- docs-site/src/language/project-settings.md | 8 ++++---- .../01-language-reference.md | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.claude/skills/mendix/odata-data-sharing/SKILL.md b/.claude/skills/mendix/odata-data-sharing/SKILL.md index b38a891e72..938115b155 100644 --- a/.claude/skills/mendix/odata-data-sharing/SKILL.md +++ b/.claude/skills/mendix/odata-data-sharing/SKILL.md @@ -81,7 +81,7 @@ Then re-import: `CREATE OR MODIFY EXTERNAL ENTITIES FROM F1Now.NowApi` maps the new entity sets. Do **not** `DROP CONSUMED ODATA SERVICE` and recreate it to force a refresh — that invalidates the client ID the existing external entities point at. -Note that `ALTER CONSUMED ODATA SERVICE SET MetadataUrl = …` does *not* re-fetch. Use +Note that `ALTER CONSUMED ODATA SERVICE … SET ( MetadataUrl: … )` does *not* re-fetch. Use `CREATE OR MODIFY` when the contract is what changed. **Use Cases for Local Metadata:** diff --git a/docs-site/src/language/project-settings.md b/docs-site/src/language/project-settings.md index 41a3ae27d1..da6068d8a7 100644 --- a/docs-site/src/language/project-settings.md +++ b/docs-site/src/language/project-settings.md @@ -21,7 +21,7 @@ Settings are organized into categories. Each `ALTER SETTINGS` command targets on Runtime-level settings such as the after-startup microflow, hashing algorithm, and Java version: ```sql -ALTER SETTINGS RUNTIME = ; +ALTER SETTINGS RUNTIME ( : , ... ); ``` Examples: @@ -42,7 +42,7 @@ project already uses. Server configuration settings like database type, URL, and HTTP port. Each configuration is identified by name (commonly `'default'`): ```sql -ALTER SETTINGS CONFIGURATION '' = ; +ALTER SETTINGS CONFIGURATION '' ( : , ... ); ``` Examples: @@ -75,7 +75,7 @@ for. A translation written for any other language is stored in the model, passes the step that makes translating an app do anything. ```sql -ALTER SETTINGS LANGUAGE = ; +ALTER SETTINGS LANGUAGE ( : , ... ); ALTER SETTINGS LANGUAGE ADD [OR MODIFY] '' [(