diff --git a/.claude/commands/mendix/lint.md b/.claude/commands/mendix/lint.md index 6263a6896c..636d2b4301 100644 --- a/.claude/commands/mendix/lint.md +++ b/.claude/commands/mendix/lint.md @@ -41,6 +41,7 @@ mxcli lint -p app.mpr --exclude System --exclude Administration | MPR006 | quality | EmptyContainer - Empty layout containers | | MPR007 | security | PageNavigationSecurity - Navigation pages need allowed roles (CE0557); every user role must be able to open its home page (at Production security, else CE2729 per widget) | | MPR012 | correctness | LegacyImageWidget - staticimage/dynamicimage are unsupported by the React client (CE0582) | +| MPR013 | correctness | SnippetCallAppearance - Class/Style/DynamicClasses/design properties on a snippet call never reach the page (it renders no element) | | SEC001 | security | NoEntityAccessRules - Persistent entities need access rules | | SEC002 | security | WeakPasswordPolicy - Password minimum length should be 8+ | | SEC003 | security | DemoUsersActive - Demo users should be off at Production security | @@ -49,6 +50,7 @@ mxcli lint -p app.mpr --exclude System --exclude Administration | CONV012 | quality | ExclusiveSplitCaption - Exclusive splits need meaningful captions | | CONV013 | quality | ErrorHandlingOnCalls - External calls (REST/WS/Java) need custom error handling | | CONV014 | quality | NoContinueErrorHandling - Don't silently swallow errors with Continue | +| CONV020 | quality | CreatedObjectNeverPersisted - A microflow creates a persistent object without commit and never commits, returns or passes it on, so it is discarded | ## Bundled Starlark Rules diff --git a/.claude/lint-rules/example_microflow.star b/.claude/lint-rules/example_microflow.star index c76adbac9c..04bb742bee 100644 --- a/.claude/lint-rules/example_microflow.star +++ b/.claude/lint-rules/example_microflow.star @@ -7,7 +7,15 @@ # VAL_ - Validation microflows # SCH_ - Scheduled event microflows # -# Customize the prefixes and patterns to match your project standards. +# Customize the prefixes in lint-config.yaml rather than here (`mxcli init` +# rewrites this file): +# +# rules: +# CUSTOM002: +# options: +# valid_prefixes: [ACT_, SUB_, DS_, TEST_] # or "ACT_, SUB_, DS_, TEST_" +# +# The option replaces VALID_PREFIXES below; unset keeps it. # # Microflow properties: # .id - Document ID @@ -28,7 +36,7 @@ DESCRIPTION = "Microflows should have standard naming prefixes (ACT_, SUB_, DS_, CATEGORY = "naming" SEVERITY = "info" -# Define valid prefixes - customize for your project +# Built-in valid prefixes, used when the valid_prefixes option is unset VALID_PREFIXES = ["ACT_", "SUB_", "DS_", "VAL_", "SCH_", "IVK_", "OCH_"] def check(): @@ -36,13 +44,14 @@ def check(): Check that microflows use standard naming prefixes. """ violations = [] + valid_prefixes = get_list_option("valid_prefixes", VALID_PREFIXES) for mf in microflows(): name = mf.name # Simple name # Check if name starts with any valid prefix has_valid_prefix = False - for prefix in VALID_PREFIXES: + for prefix in valid_prefixes: if name.startswith(prefix): has_valid_prefix = True break @@ -57,7 +66,7 @@ def check(): message="{} '{}' should start with a standard prefix ({})".format( mf.document_noun_title, name, - ", ".join(VALID_PREFIXES) + ", ".join(valid_prefixes) ), location=loc ) diff --git a/.claude/lint-rules/orphaned_elements.star b/.claude/lint-rules/orphaned_elements.star index 5fe5189a6b..a13b400043 100644 --- a/.claude/lint-rules/orphaned_elements.star +++ b/.claude/lint-rules/orphaned_elements.star @@ -12,6 +12,13 @@ # - Entities with no references at all # # This rule requires REFRESH CATALOG FULL to populate the refs table. +# +# Options (lint-config.yaml, rules.QUAL004.options): +# entry_point_prefixes - microflow name prefixes that mark an entry point, +# as a YAML list or comma-separated text. Replaces +# the built-in list below; unset keeps it. +# +# Edit the option, not this file: `mxcli init` rewrites the shipped rules. RULE_ID = "QUAL004" RULE_NAME = "Orphaned Elements" @@ -19,7 +26,8 @@ DESCRIPTION = "Elements should be referenced somewhere in the project or marked CATEGORY = "quality" SEVERITY = "info" -# Prefixes for microflows that are expected to be entry points +# Prefixes for microflows that are expected to be entry points, unless the +# entry_point_prefixes option says otherwise ENTRY_POINT_PREFIXES = ["ACT_", "SCH_", "WS_", "REST_", "OData_"] # Page name patterns that are likely entry points @@ -33,9 +41,9 @@ MICROFLOW_ENTRY_KINDS = ["call", "schedule", "publish", "datasource", "action", # Reference kinds that mean "something opens this page". PAGE_ENTRY_KINDS = ["show_page", "home_page", "login_page", "menu_item", "action"] -def is_entry_point_microflow(name): +def is_entry_point_microflow(name, prefixes): """Check if a microflow name suggests it's a UI/scheduled entry point.""" - for prefix in ENTRY_POINT_PREFIXES: + for prefix in prefixes: if name.startswith(prefix): return True return False @@ -52,11 +60,13 @@ def check(): Check for orphaned elements that have no incoming references. """ violations = [] + entry_prefixes = get_list_option("entry_point_prefixes", ENTRY_POINT_PREFIXES) + rename_hint = "/".join(entry_prefixes[:2]) if entry_prefixes else "an entry-point" # Check microflows for mf in microflows(): # Skip entry point microflows - if is_entry_point_microflow(mf.name): + if is_entry_point_microflow(mf.name, entry_prefixes): continue # Get references to this microflow @@ -95,7 +105,7 @@ def check(): v = violation( message="{} '{}' is not called from anywhere.".format(mf.document_noun_title, mf.name), location=loc, - suggestion="Remove if unused, or rename with ACT_/SCH_ prefix if it's an entry point." + suggestion="Remove if unused, rename with {} prefix if it's an entry point, or add its prefix to rules.QUAL004.options.entry_point_prefixes in lint-config.yaml.".format(rename_hint) ) violations.append(v) diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-20-mcp-alter-page-in-folder-module-not-found.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-20-mcp-alter-page-in-folder-module-not-found.json new file mode 100644 index 0000000000..1618ab3d66 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-20-mcp-alter-page-in-folder-module-not-found.json @@ -0,0 +1 @@ +{"area": "mdl-backend", "date": "2026-10-20", "symptom": "`alter page` over the MCP backend (Studio Pro 11.12.5) fails for every page inside a folder with `failed to open page for mutation: module not found: ` — the uuid is the page's parent FOLDER — while the same statement on a module-root page succeeds; `check --references` passes and the trace shows no PED call before the error", "cause": "`resolvePageUnit` (MCP page mutator) resolved the module with `GetModule(page.ContainerID)`, and a page's ContainerID is its immediate parent, a folder for most Studio Pro pages. The backend already had the folder-aware answer, `moduleNameForContainer` (walks `resolveDocContainer` up the folder chain), used by CreatePage, DeleteConstant, the microflow mutator and others. `UpdateConstant` and `UpdateWorkflow` (CREATE OR MODIFY on a foldered document) and `forgetSessionConstant` / `forgetSessionEnum` (DROP cleanup; silently left the entry registered) had the same direct lookup", "file": "`mdl/backend/mcp/page_mutator.go` (`resolvePageUnit`), `mdl/backend/mcp/constant.go`, `mdl/backend/mcp/workflow.go`, `mdl/backend/mcp/enumeration.go`", "insight": "When an error says ` not found: ` and the id is NOT an id of that kind, it is the caller passing the wrong id — map the id to what it actually is (here a folder) before reading any resolver. For containers: `GetModule(x.ContainerID)` is correct only for a domain model or a folder's own walk; for any other document grep the package for that exact pattern — a folder-aware helper usually already exists and the bug is the call sites that bypass it (this is the MCP-backend recurrence of #976/#1071 in the executor). The modelsdk engine never had it, so running the bug-test MDL without `--mcp` proves nothing about the fix; the evidence is `mdl/backend/mcp/page_mutator_folder_test.go`, whose fake reader returns `module not found: ` for any non-module id exactly as the codec does. Control: production change stashed → `OpenPageForMutation: module not found: fPages` plus the three sibling failures. Repro `mdl-examples/bug-tests/1593-mcp-alter-page-in-folder.mdl`. Issue #1593", "refs": ["#1593", "#1071", "#976"]} diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-21-specialization-of-npe-reads-persistent.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-21-specialization-of-npe-reads-persistent.json new file mode 100644 index 0000000000..15b84fc141 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-21-specialization-of-npe-reads-persistent.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-21", "symptom": "`describe entity Probe.Child` printed `create or modify persistent entity Probe.Child extends Probe.Base (` when Probe.Base is non-persistent; check and exec were clean, and the same wrong flag reached the catalog's EntityType, mermaid/ELK diagrams, the microflow builder's entityPersistable map, and `alter entities in Probe add attribute … where persistent`, which added the attribute to the non-persistent Probe.Child too. The virtual System module had it too: System.HttpRequest, HttpResponse and SoapFault read persistent although HttpMessage and Error are not.", "cause": "A specialization's MaybeGeneralization is a DomainModels$Generalization, which has no Persistable property — the flag lives only on the root's NoGeneralization. entityFromGen initialised Persistable to true and only the NoGeneralization arm overwrote it, so a specialization always read the default; the System meta table likewise carried a meaningless `Persistable: true` on its specializations.", "file": "`mdl/backend/modelsdk/domainmodel.go` (`resolveInheritedPersistability`, called by ListDomainModels / GetDomainModel / GetDomainModelByID over every stored domain model plus the System entities); `domainmodel_carry.go` (`entityUnchanged` takes the statement's value for a specialization)", "insight": "A property Mendix stores on only one end of a chain is a READ of the chain, not a field of the element — entityFromGen sees one entity and cannot answer it, so the resolution belongs in the domain-model reads, which already decode every module. Resolve it there and every consumer (describe, the bulk `where persistent` filter, catalog, diagrams) is fixed at once; patching cmd_entities_describe.go alone would have left four. The trap on the way: once reads return the resolved value, any 'is this unchanged?' comparison against entityFromGen's raw default calls every specialization of a non-persistent entity changed and rebuilds it on each UpdateDomainModel — treat the inherited value like the 11+ view OQL, as not the element's content. The CLI round trip did not show that churn (write elision caught it), so it needs a unit test on entityUnchanged with a root-entity control.", "refs": ["ako/mxcli#1510", "mendixlabs/mxcli#1600"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-mdl089-suggests-moving-doc-comment-to-another-create.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-mdl089-suggests-moving-doc-comment-to-another-create.json new file mode 100644 index 0000000000..70f6e083fc --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-mdl089-suggests-moving-doc-comment-to-another-create.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "mendixlabs/mxcli#1510: MDL089 on a doc comment above a create that stores no documentation told the user to \"Move the doc comment directly above `create json structure …`, after the `create or modify published rest service …`\" — moving text written for one element onto a different one.", "cause": "ValidateDetachedDocComments built the move suggestion from DetachedDocComment.Next for every statement. That is right for a drop/grant standing between a comment and its create (#877), but a create IS the element the comment describes; the next storing statement is a different element.", "file": "mdl/executor/validate_detached_doc_comments.go", "fix": "When the statement head is a create, the warning says MDL cannot store documentation for that element and suggests a `--` comment; the next-statement suggestion stays for drop/grant/revoke/set/alter.", "test": "mdl/executor TestValidateProgram_DetachedDocCommentOnCreate_Issue1510 (create module role followed by a create entity); control TestValidateProgram_DetachedDocComment keeps the drop suggestion. Revert check: with the create branch disabled the test reports the move-to-next-create suggestion.", "insight": "A suggestion derived from position (\"the next statement that can take it\") is only right when the position is the mistake. Ask what the comment was written FOR before telling the user where to put it.", "refs": ["mdl/executor/validate_detached_doc_comments_test.go"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-qual002-mappings-published-rest-data-transformer-undocumentable.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-qual002-mappings-published-rest-data-transformer-undocumentable.json new file mode 100644 index 0000000000..82ad800791 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-qual002-mappings-published-rest-data-transformer-undocumentable.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "mendixlabs/mxcli#1510: lint QUAL002 reported import/export mappings, published REST services and data transformers as undocumented (45 findings on one project), and nothing in MDL could close them: a `/** … */` above `create or modify published rest service` was dropped with MDL089. Every published REST service was reported even when Studio Pro HAD documented it, and `create or modify` of a Studio Pro-documented mapping, published REST service or data transformer wrote an empty Documentation over the stored text.", "cause": "The four create statements' AST had no Documentation field, so storesDocumentation() reported the comment as detached and the visitor never read it. The executors never set the model's Documentation (the mapping writers wrote whatever the model held, i.e. \"\"; the data transformer writer wrote a literal \"\"; the published REST writer only carried the stored value via keepStoredTopLevel). model.PublishedRestService and model.DataTransformer had no Documentation, and the catalog builder wrote \"\" for published_rest_services.Documentation (\"not stored on published REST\").", "file": "mdl/ast/ast_import_export_mapping.go, mdl/ast/ast_rest.go, mdl/ast/ast_datatransformer.go, mdl/visitor/visitor_import_export_mapping.go, mdl/visitor/visitor_rest.go, mdl/visitor/visitor_datatransformer.go, mdl/executor/cmd_import_mappings.go, mdl/executor/cmd_export_mappings.go, mdl/executor/cmd_published_rest.go, mdl/executor/cmd_datatransformer.go, mdl/executor/documentation_carry.go, mdl/backend/modelsdk/published_rest_write.go, mdl/backend/modelsdk/integration_read.go, mdl/backend/modelsdk/data_transformer_write.go, mdl/catalog/builder_rest.go, model/types.go", "fix": "Documentation + DocumentationSet on the four statements, read with findDocComment; the executors set it and carry the stored value when no comment is written (carriedDocumentation, #1018); model + modelsdk read and write the top-level `Documentation` key for published REST services and data transformers (published REST drops it from the keep-stored list); describe prints it with writeDocumentDocComment (`--` lines when the text holds `*/`); the catalog stores the service's documentation. Java action parameter descriptions were already writable (#1588).", "test": "mdl/visitor TestIntegrationDocumentsReadDocComment_Issue1510; mdl/executor TestIntegrationDocumentation_Issue1510 (PedApp copy: stored key, describe round trip, carry, `/** */` clears) and TestIntegrationDocumentation_StudioProMappingKeepsIt_Issue1510; mdl/catalog TestPublishedRestServiceDocumentationReachesCatalog_Issue1510; the integration TestDocumentation_SurvivesRewrite gains the four doctypes. Revert check: without the fix every new test fails with the reported symptom; stubbing only the carry fails the rewrite and Studio Pro cases.", "insight": "Verify the BSON key against Mendix's own reader, not only modelsdk/gen: `mx dump-mpr --module-names=X` prints the model as Mendix deserialises it, so the doc comment appearing as the unit's `documentation` there (11.6.6 and 11.15) proves the key is the one Studio Pro reads. A QUAL002 finding nobody can close has two suspects, the writer and the catalog reader, and here both were broken: the catalog's hard-coded \"\" made the rule fire on Studio Pro-documented services too.", "refs": ["mdl-examples/bug-tests/1510-documentation-for-mappings-rest-javaparams.mdl", "mdl/executor/integration_documentation_issue1510_test.go", "mdl/visitor/visitor_integration_documentation_test.go", "mdl/catalog/builder_rest_documentation_test.go"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-snippetcall-appearance-never-renders.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-snippetcall-appearance-never-renders.json new file mode 100644 index 0000000000..4a557a6658 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-snippetcall-appearance-never-renders.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "`snippetcall sc (snippet: M.S, class: 'x')` / `alter page M.P { set (Class: 'x') on sc }` passed check, exec and mx check (0 errors) and describe showed the class, but nothing in the running app carried it (mendixlabs/mxcli#1511)", "cause": "Not an mxcli defect: a Forms$SnippetCallWidget renders no DOM element - the snippet's widgets are inlined into the parent - so its Appearance (Class, Style, DynamicClasses, DesignProperties) has nothing to land on. Measured on 11.15.0 with run --local (hsqldb) + playwright check: .probe-x / .probe-dyn / the red border / pull-right / hide-tablet = 0 elements; the snippet's own container was a direct child of the enclosing container; a container on the same page with the same four settings carried all of them (control)", "file": "`mdl/executor/validate_snippetcall_appearance.go` (MDL-WIDGET48: `validateSnippetCallAppearance` in the widget-tree walk for CREATE / ALTER INSERT / REPLACE; `validateStoredSnippetCallAppearance` in the reference pass for ALTER PAGE SET and ALTER STYLING, typed via `widgetStorageTyper`), `mdl/linter/rules/snippet_call_appearance.go` (MPR013: catalog Class/Style/DynamicClasses + design properties read from the raw unit)", "insight": "**Valid BSON, a clean mx check and a correct describe prove nothing about whether an appearance setting renders** - only the DOM does. The positive control (same settings on a container on the same page) is what makes a 0-count meaningful: without it a stale build or a wrong selector reads the same. ALTER ... SET names a STORED widget, so whether it is a snippet call is only answerable against the document; `widgetStorageTyper` (pagemutator) already gives the $Type, so the check runs in the reference pass and appends a warning (never blocks). Clearing a class on a snippet call is the fix and must stay silent.", "refs": ["mendixlabs/mxcli#1511", "ako/mxcli#1393"], "rules": ["MDL-WIDGET48", "MPR013"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-domain-model-export-level-no-mdl-syntax.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-domain-model-export-level-no-mdl-syntax.json new file mode 100644 index 0000000000..dbba655ea7 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-20-domain-model-export-level-no-mdl-syntax.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-20","symptom":"`check -p --references` reported CE5400 for an `export level api` microflow exposing a Hidden entity of a Protected (add-on) module and told the user to fix it in Studio Pro: `create persistent entity Shop.Order (…) export level api` was \"extraneous input 'export' expecting the start of a statement\", and attributes, associations and enumerations had no clause either (mendixlabs/mxcli#1498, kris-bom's point 2). check also missed CE5400 for a Hidden ENUMERATION parameter, which mxbuild reports the same way.","cause":"Only microflows, nanoflows and workflows had an EXPORT LEVEL clause. entityToGen/attributeToGen/assocToGen/crossAssocToGen/enumToGen wrote the \"Hidden\" constant and the #801 carry wrote the stored level back over the rebuild unconditionally, so even a semantic model holding API could not reach storage. validateProtectedModuleRefs judged entities only, from stored levels and a 'script-created = Hidden' bool.","fix":"`export level api|hidden` (the microflow clause) on create entity / view entity / each attribute / create association / create enumeration, `set export level` on alter entity / association / enumeration and on modify attribute; absent keeps the stored level. Converters write orDefault(level, \"Hidden\"); the entity, attribute, association and cross-association carries and keepStoredExportLevelUnlessSet stand down for an authored level; reads fill Attribute/Association/CrossModuleAssociation/Enumeration.ExportLevel; describe prints `export level api`, omitting Hidden. The validator takes a level stated anywhere in the script over the stored one and checks enumerations for CE5400; its hints name the alter statement. Gated at 9.3.0 (domain_model.export_levels).","file":"mdl/backend/modelsdk/domainmodel_carry.go","insight":"A carry added to stop a rewrite resetting a property (#801: ExportLevel back to Hidden) silently becomes the reason the property can never be authored: once there is a spelling, every carry of it must stand down for an authored value, and each one is a separate site (entity, attribute in two rebuild paths, association, cross-association patch, enumeration unit). A test that sets only Hidden over a stored API cannot tell an honoured clause from the carry, and only API over a stored Hidden cannot tell it from the constant — run both. Measure attributes and associations too before scoping a 'level' feature to entities: on 11.14.0 another module changing a Hidden attribute or retrieving over a Hidden association is CE1406 on its own, and CE1406 masks CE5400 in one mx check, so measure each rule in a copy without the other. check still does not predict the attribute/association CE1406.","refs":["mendixlabs/mxcli#1498","ako/mxcli#1351","ako/mxcli#801","ako/mxcli#816"],"ce":["CE5400","CE1406"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-alter-entity-after-move-rename-not-found.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-alter-entity-after-move-rename-not-found.json new file mode 100644 index 0000000000..66eac30972 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-alter-entity-after-move-rename-not-found.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-21", "symptom": "`move entity MyFirstModule.Item to Probe; alter entity Probe.Item set documentation 'text';` — check: \"statement 2: entity not found: Probe.Item (referenced by alter entity)\", exec: \"Refusing to execute: … Nothing was written.\"; `--no-check` applies it and mx check is clean. Same for `rename entity M.Item to Thing; alter entity M.Thing …` (\"— M has Item\"). mendixlabs/mxcli#1601.", "cause": "validateAlterTarget's entity case accepted only sc.entities (what a CREATE declares). collectSingle already put MOVE/RENAME destinations in sc.indirectEntities, and the association-endpoint, grant, view-source and datagrid checks read it; the ALTER target check did not.", "file": "mdl/executor/validate_alter_target.go", "fix": "Accept sc.indirectEntities[qn] alongside sc.entities[qn]. Test: TestValidateAlterTarget_CountsEntitiesAMoveOrRenameProduces (+ a control that a typo next to a move is still reported); bug test mdl-examples/bug-tests/1601-alter-entity-after-move-or-rename.mdl.", "insight": "The reporter's suggested site (the refs.entities loop in validate.go) was wrong: the message came from validateAlterTarget, which formats \"(referenced by alter )\" itself — grep the message's suffix, not its prefix. When a scriptContext set exists (indirectEntities), grep its readers: every consumer of sc.entities that skips it is a candidate for the same false 'not found'. A bug-test MDL must move INTO a module the project already has — checkAlterTarget skips script-created modules, so a fully self-contained script (create module Probe; move …) never reproduced it on the unfixed binary.", "refs": ["mendixlabs/mxcli#1601", "ako/mxcli#1511"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-alter-flow-fragment-annotations-unchecked.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-alter-flow-fragment-annotations-unchecked.json new file mode 100644 index 0000000000..0f42fb8455 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-alter-flow-fragment-annotations-unchecked.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-21", "symptom": "mendixlabs/mxcli#1597 (case B): `@annotation('Why this commit is here.')` in an `alter microflow … insert after $Result begin … end;` fragment passed check and exec (\"Altered microflow\") and no note was written; the same line in `create or modify microflow` is refused as MDL079", "cause": "the annotation rules (MDL059 unknown name, MDL060 bad curve, MDL079 bad note, MDL092 bad anchor) ran only over create bodies — ValidateProgram had no AlterFlowStmt case, and execAlterFlow ran no statement validation — while the fragment goes through the same flowBuilder, which drops what the visitor could not read", "file": "mdl/executor/validate_alter_flow.go (ValidateAlterFlow, validateAlterFlowRules); validate_program.go (AlterFlowStmt case); cmd_alter_flow.go (execAlterFlow refuses the exec-enforced subset before loading)", "test": "TestValidateProgram_ChecksAnnotationsInAnAlterFlowFragment (all four rules, insert and replace, nested in an if), TestValidateProgram_AlterFlowFragmentValidNotesStayClean (control), TestExecAlterFlow_RefusesADroppedAnnotation", "insight": "The reported rule was one of four with the same hole: probing `@postion`, `@curve(from: (a, b))` and `@anchor(from: sideways)` in the same fragment showed all four passing, which is what moved the fix from one rule to walkAnnotations. Only the annotation rules are run on a fragment — the rest of the microflow rule set reads parameters, declares and later uses of the whole flow, which a fragment cannot see, so running ValidateMicroflow on it would trade a silent drop for false refusals. MDL079 is still not exec-enforced for create or alter, so `-c` and the REPL write past it in both; exec of a file is covered by its check preflight", "refs": ["mendixlabs/mxcli#1597", "ako/mxcli#1507"], "rules": ["MDL059", "MDL060", "MDL079", "MDL092"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-alter-page-replace-column-own-target-unchecked.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-alter-page-replace-column-own-target-unchecked.json new file mode 100644 index 0000000000..b8ab236df9 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-alter-page-replace-column-own-target-unchecked.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-21", "symptom": "`alter page Shop.P { replace grid1 column(C) with { column (Attribute: Note, Caption: 'C') } }` on a grid with no column C: `check --references` \"Check passed!\", exec \"column grid1 column(C) not found — a column is addressed by the attribute or the caption describe prints in it\". Found reproducing mendixlabs/mxcli#1512, whose own symptom (\"An attribute is required when 'Show' is set to 'Attribute'\" after REPLACE) is #1468 and reproduces only on a pre-f5901e24 build, on the microflow-sourced grid.", "cause": "validateAlterPageReferences skipped every column address on a document any INSERT/REPLACE in the script touched (documentsTheScriptAddsWidgetsTo, computed up front), because a column the script adds could be the one addressed. That includes the REPLACE's own target, so the check never ran for the ordinary one-statement column REPLACE.", "file": "mdl/executor/validate_alter_page_refs.go", "fix": "Track growth in script order (grown[doc]) and mark it AFTER an operation's targets are checked: exec resolves each operation's targets before running it (cmd_alter_page.go resolveAlterPageTargets), so only an earlier operation can add the addressed column. Test: validate_alter_page_column_target_test.go (replace / insert / caption / drop-before-insert reported; same-statement and later-statement additions accepted); bug test 1512-alter-page-replace-column-attribute.mdl.", "insight": "For #1512 itself, the cheapest discriminator was a pre-fix worktree build (git worktree add ~1) against seven grids, one per source kind: it reproduced the reporter's two mxbuild messages verbatim on the flow-sourced grid only, which identified the report as #1468 without guessing. A 'could not establish' skip computed over the whole script is order-blind; when the executor resolves in order, compute the skip in the same order or it waives the very statement it exists for. The SET dry run (validate_alter_set.go) still uses the up-front documentsTheScriptAddsWidgetsTo.", "refs": ["mendixlabs/mxcli#1512", "mendixlabs/mxcli#1468", "ako/mxcli#1394"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-association-path-as-return-or-loop-source.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-association-path-as-return-or-loop-source.json new file mode 100644 index 0000000000..de6a49f596 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-association-path-as-return-or-loop-source.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-21","symptom":"`return $Parent/Mod.Child_Parent;` in a list-returning microflow passes `mxcli check --references` (\"Check passed!\"), and `mxcli docker check` reports `[CE0117] \"Error(s) in expression.\" at End event`; `loop $c in $Parent/Mod.Child_Parent` is written with no list, CE0099 \"The 'Iterate over' property is required.\"","cause":"MDL049 (bare association path as a value) was applied to call arguments only; the return value is the same shape. The loop grammar accepts `IN attributePath` but buildLoopStatement read only a bare VARIABLE token, so the path was silently dropped and the builder wrote ListVariableName \"\".","file":"`mdl/executor/validate_association_path_value.go`, `mdl/visitor/visitor_microflow_statements.go` (buildLoopStatement), `mdl/ast/ast_microflow.go` (LoopStmt.ListPath)","insight":"Measure before ruling: the forward/object case `return $Child/M.Child_Parent` is CE0117 too, so the rule is not about lists or direction — a path ENDING on an association is never a whole value, while naming the target entity (`…/M.Child_Parent/M.Parent`, and `…/M.Child_Tag/M.Tag` into a list) is 0 errors, exactly MDL049's existing exemptions. mx check's 'at End event' does not name the microflow: bisect by dropping microflows on copies (two checks in parallel). Measured on 11.12.1 the new checks match mxbuild one-to-one on a 6-microflow probe (4 errors, 4 violations, both clean forms silent); nanoflow return measured the same. DECLARE of a list was already MDL040. A grammar alternative the visitor ignores is a silent drop: grep loop/statement builders for AllVARIABLE() where the rule also admits a path.","refs":["mendixlabs/mxcli#199","ako/mxcli#1231"],"ce":["CE0117","CE0099"],"rules":["MDL049","MDL094"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-controlbar-show-page-grid-selection.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-controlbar-show-page-grid-selection.json new file mode 100644 index 0000000000..af2ac3ceaf --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-controlbar-show-page-grid-selection.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-21", "symptom": "A Data grid 2 control-bar button opening a page with the grid's selection had no spelling that built: `show page M.Detail(OrderParam = $currentObject)` is MDL-BUTTON01 (CE1571, not row-scoped), `OrderParam = $dgOrders` — what MDL-BUTTON01's own hint suggested — was MDL-PAGEARG01 \"a widget's page argument is always the enclosing context object\", and no argument is mx check `[CE1571] \"No argument has been selected for parameter 'OrderParam'\"`", "cause": "mxcli wrote show page arguments only as the inferred empty mapping (#296) or, since #1403, a PageVariable naming a page/snippet parameter; it had no third form, so the guard (correctly, for what could be written) refused `$grid`. The control bar also had no context of its own: the engine builds a widgets slot's children directly (applyChildSlots), never through the `controlbar` node, so a rule hung on that node in buildWidgetV3 is never reached for Data grid 2", "file": "`mdl/executor/cmd_pages_showpage_args.go` (`pageArgContext.selectionOf`, `namesSelection`, `argContextForChildren`, `namedParameterArgument`), `mdl/executor/widget_engine.go` (`applyChildSlots`, `inDataWidget` at the datasource passes), `mdl/backend/modelsdk/widget_write.go` (`pageSettingsWithMappings`, kind \"widget\"), `mdl/executor/cmd_pages_describe_output.go` (`extractPageParameters`)", "insight": "No Studio Pro reference for the shape existed in the corpus (12 PageParameterMappings, none with Variable.Widget — the scanner's control was counting all 12 first, since bson v2 decodes nested docs as bson.D and a first scan silently matched nothing). Three mxbuild 11.12.5 builds of the same script settled the storage: Variable{Widget: grid} with Argument \"\" → 0 errors, Argument \"$grid\" → CE0115, no mapping → CE1571; the microflow path's Expression form does NOT carry over to a page mapping. A runtime check then showed the binding means what it says: the button is disabled until a row is selected and opens the detail with the selected (second) row. Wrong turn to skip: a builder unit test that sets argCtx and calls buildClientActionV3 passed while exec still refused — test the engine's applyChildSlots, where Data grid 2's control bar is actually built", "refs": ["mendixlabs/mxcli#122", "ako/mxcli#1224"], "ce": ["CE1571", "CE0115"], "rules": ["MDL-PAGEARG01", "MDL-BUTTON01"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-curve-inside-loop-body-not-stored.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-curve-inside-loop-body-not-stored.json new file mode 100644 index 0000000000..db22df0650 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-curve-inside-loop-body-not-stored.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-21", "symptom": "mendixlabs/mxcli#1597 (case A): `@curve(from: (39, 0), to: (0, 61))` on a statement inside a `loop` or `while` body passed check and exec with no warning, and `describe` printed the body with no `@curve` — the same annotation after the loop was kept", "cause": "a loop body is built by its own flowBuilder; applyPendingAnnotations recorded the curve on that sub-builder's curveByOrigin, and addLoopStatement / addWhileStatement hoisted flows, annotationFlows, errors and the label registry to the parent but not the curves. applyFlowCurves runs once, on the builder that owns the flows, so the body's curves were never applied", "file": "mdl/executor/cmd_microflows_builder_annotations.go (adoptCurves); cmd_microflows_builder_control.go (addLoopStatement, addWhileStatement); cmd_microflows_builder_flows.go (addErrorHandlerFlow now calls the same helper)", "test": "TestLoopBody_KeepsCurve, TestWhileBody_KeepsCurve, TestNestedLoopBody_KeepsCurve (cmd_microflows_loop_curve_test.go)", "insight": "#991 fixed exactly this for the error-handler builder a month earlier, at one of the three sites that hoist a sub-builder's flows — each site copies a hand-picked list of fields, so a fix at one is not a fix at the others. When a child builder's state is lost, grep `&flowBuilder{` for every sub-builder and diff the field lists their merge tails copy, rather than following the reported statement. The merge is now one helper (adoptCurves) the three sites share, and a nested loop test proves it chains through two levels. The main-line curve after the loop is the in-test control: it passed before the fix, so the test isolates the body", "refs": ["mendixlabs/mxcli#1597", "mendixlabs/mxcli#991", "ako/mxcli#1507"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-describe-combobox-filter-bykey-settings.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-describe-combobox-filter-bykey-settings.json new file mode 100644 index 0000000000..2612880d39 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-describe-combobox-filter-bykey-settings.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-21","symptom":"`describe page` omitted a combo box's `customEditability` / `customEditabilityExpression` and a data grid text/number filter's `defaultValue` expression and `delay`, though exec stored them (bson dump shows `Expression: $currentObject/Locked`); the report's own generic widget (`entitypicker … Placeholder:`) and its MDL-WIDGET06 \"not yet persisted\" warning reproduce only on the pre-#1578 binary","cause":"Widgets with a dedicated describe reader (isKnownCustomWidgetType) never ran extractExplicitProperties, and only Data grid 2 and Gallery had been given the by-key reader (#1573); the combo box and filter branch printed its keyword set and nothing else. Separately, nonDefaultPropsByKey compared with the schema DefaultValue, but exec writes the embedded template's value for an absent property — combo box readOnlyStyle is `bordered` in the template against a default of `text`","file":"`mdl/executor/cmd_pages_describe_bykey.go` (sharedBranchByKeySkip, templateWrittenDefaults, extractSharedBranchByKeyProps, withoutUndescribedComboBoxModes), `cmd_pages_describe_parse.go`, `cmd_pages_describe_output.go` (generic branch no longer claims a known widget with explicit props), `widget_known_props_from_mpk.go` + `validate_widgets.go` (enrichKnownPropertiesFromTemplates for check without -p)","insight":"Retest an old report against a pre-fix worktree before theorising: the reporter's exact shape was already fixed a day earlier, and only a probe on SHIPPED widgets with dedicated readers found what was still live. Compare by-key values with what exec writes when the key is absent (the template object), not the schema default — the corpus diff against a baseline build showed Studio Pro's own Administration combo boxes store `readOnlyStyle: text`, which describe omitted and an edit-replay would have flipped to `bordered`. Settings of a mode DESCRIBE does not spell (combo box database/static) are named in a NOT re-executable note, not printed into a combo box of another mode. And a key describe prints must pass `check` WITHOUT -p too: the hand-crafted defs only learn the widget's keys from the project's .mpk, so the embedded template now stands in when there is none. Not done: datagrid.json's template stores columnsFilterable=false against default true — the #1573 reader still compares with the default there","refs":["mendixlabs/mxcli#1586","ako/mxcli#1491","mendixlabs/mxcli#1578"],"test":"mdl/executor/cmd_pages_describe_shared_bykey_test.go"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-describe-gallery-sort-over-association.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-describe-gallery-sort-over-association.json new file mode 100644 index 0000000000..76e053255e --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-describe-gallery-sort-over-association.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-21","symptom":"`describe page` printed a gallery whose Database source runs over an association as `gallery g1 (DataSource: $currentObject/Probe.Item_Parent, …)` — no `sort by`, no `where`, although the stored CustomWidgets$CustomWidgetXPathSource carries a GridSortBar; and `create page` refused the faithful spelling with \"`database from $currentObject/…` is a LIST VIEW source reached over an association, and gallery cannot store one\"","cause":"parseDataSource sent an XPath source with an IndirectEntityRef to parseEntityPathSource only for Forms$ListViewXPathSource (#721 L5); the pluggable CustomWidgetXPathSource fell to parseContextSource (the #941 reading), which yields an association source and drops XPath and sort — and replays as a Forms$AssociationSource, a different retrieve. On the write side checkDatabaseOverAssociationIsOnAListView refused the form for every non-list-view widget on 10+, and customWidgetDataSourceToGen wrote only a DirectEntityRef","file":"`mdl/executor/cmd_pages_describe_datasource.go` (parseDataSource), `mdl/executor/pluggable_database_source.go` (buildPluggableDataSourceV3), `mdl/executor/cmd_pages_builder_v3.go` (check removed; buildDataSourceV3 refusal reworded), `mdl/backend/modelsdk/widget_write.go` (customWidgetDataSourceToGen: IndirectEntityRef + SourceVariable)","insight":"The 9.x fix for the same shape (#1399) already had the whole write path — buildDatabaseOverAssociationV3 and the version gate — so the 10+ case was a one-branch change once the reader stopped treating the XPath source as an association; check which source TYPE a reading replays to, not just whether the text parses. The #941 association reading was itself lossy (type change on replay) and only looked faithful because a source with no sort replays to the same rows. Measured on 11.12.1: the reporter's shape authored directly in MDL, mx check 0 errors, describe -> exec `Unchanged page`; control with the association name byte-patched to a nonexistent one -> CE1613 at Gallery 'g1', so mx check does read the path. A hop with no stored destination keeps the association reading","refs":["mendixlabs/mxcli#1599","ako/mxcli#1509"],"test":"mdl/executor/pluggable_xpath_over_association_test.go; mdl/backend/modelsdk/cw_xpath_source_over_association_test.go"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-page-xpath-member-added-by-script.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-page-xpath-member-added-by-script.json new file mode 100644 index 0000000000..0ec69291cd --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-page-xpath-member-added-by-script.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-21", "symptom": "`alter entity Shop.Order add attribute IsPaid: Boolean default false;` then a datagrid `database from Shop.Order where [IsPaid = true]` in the same script — check: \"the constraint on Shop.Order names \\\"IsPaid\\\", which is neither an attribute nor an association of it — mxbuild reports this as CE1613\"; exec refused. Same for an attribute added by restating the entity (`create or modify`) and an association the script creates. The microflow retrieve form was already fixed (v0.27.0). mendixlabs/mxcli#1271 (comment of 2026-10-16).", "cause": "validateXPathMembers (widget data source) resolved members with execXPathModel + resolveMemberOnEntity — project only. The retrieve check had been moved to scriptXPathResolver (alteredEntities → unknown, entityDecls → declared attributes, sc.associationEnds for hops); the page walk kept the original resolver.", "file": "mdl/executor/validate_widget_member_refs.go", "fix": "validateXPathMembers builds the whole-program scriptContext and passes scriptXPathResolver's model and bare-member lookup to unresolvableXPathStepsWith; the project-only wrapper unresolvableXPathSteps had no callers left and was removed. Tests: TestValidateXPathMembers_SeesMembersTheScriptAdds and the typo control TestValidateXPathMembers_ScriptMembersDoNotHideATypo; bug test 1271-page-xpath-member-added-by-script.mdl.", "insight": "duplicate-resolver-drift: the #1271 fix for retrieves even named the risk ('probably shared with the page XPath walk … not tested') and fixed one copy. When a resolver gains script awareness, grep for every caller of the walk it feeds (unresolvableXPathSteps*) — after this fix the project-only wrapper had zero callers, which is the signal that no copy is left behind. The defect needs the entity to be STORED: a script creating the entity too was silent before and after (unknown entity → memberUnknown), so a self-contained bug test cannot reproduce it — exec a setup section first.", "refs": ["mendixlabs/mxcli#1271", "ako/mxcli#1296"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-pluggable-attribute-outside-data-view-entity.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-pluggable-attribute-outside-data-view-entity.json new file mode 100644 index 0000000000..f269ed9fcb --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-pluggable-attribute-outside-data-view-entity.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-21", "symptom": "Custom chart `eventDataAttribute: EventData` inside a data view over page parameter $Entry (ChartEntry has no EventData) passes `check --references` and `exec`, then mx check reports `[CE1613] \"The selected attribute 'MyFirstModule.ChartEntry.EventData' no longer exists.\"`; written qualified as MyFirstModule.ChartContext.EventData it is stored as such, but `describe page` prints `eventDataAttribute: EventData` and re-running that rebinds it to ChartEntry", "cause": "(1) misboundAttributeError refused a bare name only when ANOTHER entity in scope declared it, and returned nil for a name no entity had (\"a plain typo, which other checks own\") — no check owned it for pluggable properties; (2) extractExplicitProperties shortened every AttributeRef with describeAttr and was given no scope, and dataSourceEntityContext returned a parameter source's NAME (`Entry`) as the context entity, so no consumer could compare it to the attribute's entity", "file": "`mdl/executor/widget_attribute_scope.go` (`misboundAttributeError`), `mdl/executor/cmd_pages_describe_pluggable.go` (`extractExplicitProperties`), `mdl/executor/cmd_pages_describe_datasource.go` (`parseContextSource`, `parameterSourceEntity`), `mdl/executor/cmd_pages_describe_flowcontext.go` (`dataSourceEntityContext`)", "insight": "A guard that stays quiet on the case it cannot place should say which check owns that case — this one named none, and grep found none. The describe half was the same mistake dataSourceEntityContext had already fixed twice (a flow's name, an association's name standing in for the entity): a parameter source stores its entity beside the name (`EntityRef.Entity` on Forms$DataViewSource), so it is read, not looked up from the page's parameters. To force the fault into a build, use a pre-fix binary: exec shares the refusal with check, so `--no-check` no longer writes the broken binding. Remaining: the stored qualified binding without a SourceVariable gets CE7247 from mx check in a fresh app; Studio Pro's page-parameter binding carries a Forms$PageVariable, which MDL still cannot spell for a pluggable attribute property", "refs": ["mendixlabs/mxcli#1591", "ako/mxcli#1502"], "ce": ["CE1613", "CE7247"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-protected-module-hidden-member-ce1406.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-protected-module-hidden-member-ce1406.json new file mode 100644 index 0000000000..aab8f6d8ff --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-protected-module-hidden-member-ce1406.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-21","symptom":"`check -p --references` passed a microflow in an ordinary module that used a Hidden attribute or association of a Usable entity in a Protected (add-on) module; mxbuild then refused it with CE1406 \"… is not allowed to use attribute 'AddOn.Request.Payload' because app modules cannot reference hidden elements in add-on modules\" (mendixlabs/mxcli#1498).","cause":"validateProtectedModuleRefs judged entity, microflow and page targets only; nothing resolved the members a flow uses against their export level, which the semantic model did not carry until ako/mxcli#1518.","fix":"checkFlowMembers (validate_protected_module_members.go): over walkTypedFlow — the variable-typing walk extracted from validate_member_refs.go so both checks share it — resolve change/create members, association retrieves, XPath steps of a database retrieve and `$v/…` expression paths (return, declare, set, decision, while, log, call arguments, member values) to attributes (through generalizations) and associations (hops via associationTargetFrom), and report CE1406 when the member's module is Protected, the flow's is not, and the member is Hidden — script-stated level, else stored, else Hidden for a script-created one. The hint is the alter statement with the stored type restated.","file":"mdl/executor/validate_protected_module_members.go","insight":"Measure every shape before checking it, one microflow per shape so mx check's 'at ' attributes each CE1406: on 11.14.0 all nine uses (change/create attribute and association member, association retrieve, XPath attribute and association, expression attribute and association) were refused and the Usable controls and in-module uses were clean, so the rule is 'any use', not a list of activities. The check is only safe as far as variable typing reaches: anything unresolved — an untyped variable, an association with no stored ends, a Module.Entity.Attribute member — is silent rather than reported, because a false refusal blocks exec. Do not copy the variable-typing walk: two copies drift on which statements type a variable (the duplicate-resolver-drift class), so extract it.","refs":["mendixlabs/mxcli#1498","ako/mxcli#1351","ako/mxcli#1518"],"ce":["CE1406"]} diff --git a/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-16-call-workflow-boundary-events-unparseable.json b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-16-call-workflow-boundary-events-unparseable.json new file mode 100644 index 0000000000..8d21730b17 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-16-call-workflow-boundary-events-unparseable.json @@ -0,0 +1 @@ +{"area": "mdl/grammar", "date": "2026-10-16", "symptom": "`create workflow … call workflow M.Child … boundary event interrupting timer … { … };` fails to parse (\"mismatched input 'boundary' expecting ';'\", \"mismatched input 'interrupting' expecting SUBPROCESS\") while the identical clause after `call microflow` / `wait for notification` parses. DESCRIBE of a workflow whose call-workflow activity has a timer (Studio Pro-authored, or added by ALTER `insert into callX { boundary event … }`) prints that clause, so its describe output did not re-parse", "cause": "`workflowCallWorkflowStmt` (MDLWorkflow.g4) had no BOUNDARY EVENT clause and `ast.WorkflowCallWorkflowNode` no BoundaryEvents field, though the reader, writer, describer, ALTER mutator and MCP backend all handled Workflows$CallWorkflowActivity.boundaryEvents (10.14+, the same floor as every other owner). None of the AST walkers knew the case either: seven hand-written switches over the boundary-event owners (walkWorkflowActivities, collectEndBodies, the WF07 bare-timer count, WF15, the WF19 sites, the rewrite-guard count, the notification gate) listed user task / call microflow / wait only. Two SDK walkers had the same gap on the stored side: collectStoredPlacements (ALTER end ancestry lost the non-interrupting context under a call workflow) and collectJumpableNames (MDL-WF05 did not see activities inside a call-workflow boundary body)", "fix": "Grammar reuses call microflow's exact boundary clause (no new tokens); the visitor fills BoundaryEvents; buildCallWorkflowActivity writes them through buildBoundaryEvents. A new executor helper, workflowActivityBoundaryEvents, is the one place that lists the AST owners; the walkers that only need the events go through it, and the two that label by owner (WF15, WF19) gained the case. The stored walkers gained the CallWorkflowActivity case", "file": "mdl/grammar/domains/MDLWorkflow.g4; mdl/ast/ast_workflow.go; mdl/visitor/visitor_workflow.go; mdl/executor/cmd_workflows_write.go, validate_workflow.go (workflowActivityBoundaryEvents), validate_workflow_end.go, validate_workflow_esp.go, validate_workflow_scope.go, validate_workflow_refs.go, validate_workflow_rewrite.go, validate_workflow_jump.go; tests mdl/visitor/visitor_workflow_callworkflow_boundary_test.go, mdl/executor/workflow_callworkflow_boundary_test.go; mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl", "insight": "When the read side, describe and ALTER all handle a property and only CREATE cannot spell it, the gap is a round-trip break, not a missing feature: describe output is the repro. The grammar line was one; the cost was the walkers. Every rule that recurses into boundary bodies had its own owner switch, and adding an owner meant finding all of them: grep for the node types of the EXISTING owners (e.g. WorkflowWaitForNotificationNode), not the new one, since the new one appears in none of the switches that are wrong. Folding them onto one helper keeps the next owner from repeating it. Revert controls were per walker: each rule row failed only with its own walker reverted, which shows each case is load-bearing. Measured: interrupting and non-interrupting timer on call workflow, mx check 0 errors on 11.6.6 and 11.15.0, describe -> exec 'already in sync' on both", "refs": ["mendixlabs/mxcli#1532", "ako/mxcli#1414"]} diff --git a/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-21-java-action-param-description-return-name-unspellable.json b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-21-java-action-param-description-return-name-unspellable.json new file mode 100644 index 0000000000..7cbe940f75 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-21-java-action-param-description-return-name-unspellable.json @@ -0,0 +1 @@ +{"area": "mdl/grammar", "date": "2026-10-21", "symptom": "mendixlabs/mxcli#1588: `create java action` had no way to set a parameter's description or the return value's name; `describe` printed both as `--` comments (`Input: String not null -- The text to convert.`, `-- return NAME: 'UpperText'`), and a script copying that form passed check and exec with the description empty and the return name `ReturnValueName`", "cause": "no grammar slot for either: javaActionParameter had no docComment and javaActionReturnType no `AS VARIABLE`, so the only spelling describe could offer was a comment the lexer discards. The writers carried the stored values (#705, #731), which hid the gap on round trips of Studio Pro-authored actions and made it total on new ones", "file": "mdl/grammar/domains/MDLMicroflow.g4 (javaActionParameter: docComment?, javaActionReturnType: AS VARIABLE); mdl/visitor/visitor_javaaction.go (buildJavaActionParams, buildJavaActionReturn — one helper for the Java and JavaScript handlers); mdl/executor/cmd_javaactions.go + cmd_javascript_actions_write.go (carriedDocumentation per parameter, stated ReturnName wins); cmd_javaactions.go writeCodeActionParams / describeCodeActionReturnName shared by both describers", "test": "cmd_javaactions_param_description_test.go: create, rewrite (stated replaces, unstated carries) and describe round trip, for Java and JavaScript actions", "insight": "When describe prints a value as a comment, ask whether create has a slot for it — a comment in describe output is either decoration or an unspellable property, and only the second is a bug. Pick the spelling the language already uses for the same thing elsewhere (a member's `/** */` as on an attribute, `returns T as $Name` as on a microflow): the reporter had tried exactly those two first. Java and JavaScript actions share the grammar rules, so a field read by only one handler would have been a fresh silent drop in the other — both visitors now go through one helper and both describers through one emitter. The `--` form had also kept only the first line of a multi-line description; the doc comment carries all of it, and carriedDocumentation's normal form keeps a CRLF description from churning on describe → exec", "refs": ["mendixlabs/mxcli#1588", "ako/mxcli#1496"]} diff --git a/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-21-dynamicclasses-keeps-quoted-attribute.json b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-21-dynamicclasses-keeps-quoted-attribute.json new file mode 100644 index 0000000000..73e415f036 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-21-dynamicclasses-keeps-quoted-attribute.json @@ -0,0 +1 @@ +{"area":"mdl/visitor","date":"2026-10-21","symptom":"`DynamicClasses: if $currentObject/\"Code\" = empty then 'x' else ''` is stored with the quotes (describe prints them) while ContentParams and Visible on the same page store `$currentObject/Code`; check passes, mx check: CE0117 \"Error(s) in expression.\" at Text 'txtCode'. The same held for DynamicCellClass, the mdl 0 quoted spelling, ALTER PAGE SET, and a pluggable widget's Expression-typed property (bare or `$var/Attr` form).","cause":"The widget expression slots (widgetExpressionValue, the legacy quoted content in widgetExpressionPropValue, genericWidgetExpression, keepExpressionSource) took ruleSourceText and never ran stripExpressionIdentifierQuotes, which the microflow SourceExpr paths and ContentParams/Visible apply (#827, #1481).","file":"`mdl/visitor/visitor_widget_expression.go`, `mdl/visitor/visitor_quoted_expression.go`","insight":"The previous sweep grepped `&ast.SourceExpr{` — widget slots never build a SourceExpr, they store a string or *ast.WidgetExpressionV3 straight from ruleSourceText, so that grep could not find them. Grep `ruleSourceText(` instead: every caller that stores expression text needs the strip. The mdl 0 read-back check compares against the stored value, so it must compare to the stripped text or the quoted-expression rewrite is withheld. Measured on 11.12.1 with the issue's page: unfixed `mxcli docker check` CE0117 at Text 'txtCode', fixed 0.","refs":["mendixlabs/mxcli#1584","ako/mxcli#1489","mendixlabs/mxcli#1481"],"ce":["CE0117"]} diff --git a/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-21-retrieve-xpath-drops-line-breaks.json b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-21-retrieve-xpath-drops-line-breaks.json new file mode 100644 index 0000000000..d7c25d4476 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-21-retrieve-xpath-drops-line-breaks.json @@ -0,0 +1 @@ +{"area":"mdl/visitor","date":"2026-10-21","symptom":"`retrieve $Notes from M.Note where [IsOpen = true() and\\n\\tCount > 0];` is stored as `[IsOpen = true() and Count > 0]` — line break and tab dropped — while split conditions, variable values and member values keep their layout; describe printed any stored constraint on one line, so a Studio Pro layout did not survive describe -> exec into another project.","cause":"The visitor kept the source text (SourceExpr); the WRITER dropped it: FormatXPathConstraint sent every constraint containing a line break through the #979 canonical layout, which renders a short one flat. Its doc premise — 'a constraint is rebuilt from its parse tree on every write, so there is no original whitespace left' — had stopped being true once retrieve and access-rule constraints kept their source.","file":"`mdl/visitor/xpath_format.go` (FormatXPathConstraintWidth, DescribeXPathConstraintLayout); describers in `mdl/executor/cmd_microflows_format_action.go`, `mdl/executor/cmd_entities_access.go`","insight":"Look at the writer before the visitor: the stored-bytes test (retrieveConstraintOf) showed the visitor's Source intact and the formatter flattening it. The formatter is shared by retrieve, access rule and page datasource, so a write-side change needs the describe side changed for every sink that keeps source (retrieve, grant) or describe -> exec drifts in a new place — the grant round-trip test caught exactly that before it shipped. The rule that keeps both #979 and #1542: a constraint with a line break is the author's layout (kept); a flat one gets the derived layout; describe flattens only text equal to Format(Flatten(x)). Trap when measuring: `create or modify` over the SAME project reports 'Unchanged' with the old build too, because the declared-match compares XPath tokens ignoring whitespace — the drift only shows in a fresh project. Measured on 11.12.1: unfixed stores flat, fixed stores the tab and line break, mx check clean, describe -> exec byte-identical.","refs":["mendixlabs/mxcli#1542","ako/mxcli#1440","mendixlabs/mxcli#979"]} diff --git a/.claude/skills/fix-issue/findings/other/2026-10-21-go-mod-tidy-dropped-klauspost-floor.json b/.claude/skills/fix-issue/findings/other/2026-10-21-go-mod-tidy-dropped-klauspost-floor.json new file mode 100644 index 0000000000..4cd9ceed54 --- /dev/null +++ b/.claude/skills/fix-issue/findings/other/2026-10-21-go-mod-tidy-dropped-klauspost-floor.json @@ -0,0 +1 @@ +{"area": "go.mod", "date": "2026-10-21", "symptom": "After the v1 BSON driver removal (ako/mxcli#1497), go.mod no longer named github.com/klauspost/compress and `go list -m github.com/klauspost/compress` reported v1.19.2, the version a customer's Sonatype scan had blocked on for GHSA-fpmh-frq8-7rj8. No build, test or CI job failed", "cause": "The v1.20.3 floor from ako/mxcli#1493 was a hand-written `// indirect` require line for a module no mxcli package imports (it arrives through mongo-driver/v2, used only for its BSON packages). `go mod tidy` removes any requirement that no package in the module's import graph needs, so the first tidy after it, the one #1497 ran to drop v1, deleted the line and MVS fell back to the v1.19.2 mongo-driver/v2@v2.9.2 declares", "file": "`internal/depfloor/floor.go` (new, `//go:build depfloor`, blank-imports github.com/klauspost/compress/zstd); `internal/depfloor/floor_test.go` (new, TestGoModKeepsSecurityFloors); `go.mod`/`go.sum` (klauspost/compress v1.20.3, now direct)", "insight": "A security floor on a module the program never imports is not a go.mod edit, it is an import: tidy treats every build tag except `ignore` as enabled, so a blank import behind a tag no build sets keeps the requirement through tidy while compiling nothing (`go version -m bin/mxcli` still lists no klauspost/compress). Measured both ways: a hand-added require line is gone after one `go mod tidy`; with floor.go deleted tidy drops the line and the test fails 'go.mod does not require'; with floor.go present but no version pinned tidy re-adds it at the graph's v1.19.2 and the test fails 'below the floor'. The guard reads go.mod, which is what the scanner's graph is built from, and a direct requirement at or above the floor guarantees MVS selects at least that version", "refs": ["GHSA-fpmh-frq8-7rj8", "ako/mxcli#1493", "ako/mxcli#1497"]} diff --git a/.claude/skills/mendix/assess-quality/SKILL.md b/.claude/skills/mendix/assess-quality/SKILL.md index e9ca03e1a0..c232305bed 100644 --- a/.claude/skills/mendix/assess-quality/SKILL.md +++ b/.claude/skills/mendix/assess-quality/SKILL.md @@ -12,14 +12,14 @@ description: "Audit a Mendix project against best practices for naming, security |---|---| | 29-36 | When to Use This Skill | | 37-96 | Assessment Workflow | -| 97-352 | Quality Guidelines Reference | -| 353-363 | Assessment Report Template | -| 364-376 | Executive Summary | -| 377-387 | project Metrics | -| 388-400 | Automated Findings | -| 401-420 | Manual Review Findings | -| 421-436 | Recommendations | -| 437-447 | Sources | +| 97-353 | Quality Guidelines Reference | +| 354-364 | Assessment Report Template | +| 365-377 | Executive Summary | +| 378-388 | project Metrics | +| 389-401 | Automated Findings | +| 402-421 | Manual Review Findings | +| 422-437 | Recommendations | +| 438-448 | Sources | # Assess Mendix Project Quality @@ -200,7 +200,7 @@ The Dutch Institute for Vulnerability Disclosure (DIVD) found widespread authori ### D. Maintainability -**Enforced by rules:** QUAL001-QUAL004, CONV009, CONV012, CONV014, MPR002, MPR004, MPR006 +**Enforced by rules:** QUAL001-QUAL004, CONV009, CONV012, CONV014, CONV020, MPR002, MPR004, MPR006 | Guideline | Rule/Check | Threshold | |-----------|-----------|-----------| @@ -211,6 +211,7 @@ The Dutch Institute for Vulnerability Disclosure (DIVD) found widespread authori | No empty microflows | MPR002 | Every microflow should have at least one activity | | Caption on exclusive splits | CONV012 | All decision points must have captions | | No "Continue" error handling | CONV014 | Never swallow errors silently | +| No created object silently discarded | CONV020 | A persistent object created without commit is committed, returned or passed on | | ACT_ microflows: thin controllers | CONV010 | Only page activities + submicroflow calls | **Manual checks:** diff --git a/.claude/skills/mendix/check-syntax/SKILL.md b/.claude/skills/mendix/check-syntax/SKILL.md index 2668849e38..0a0d2a741b 100644 --- a/.claude/skills/mendix/check-syntax/SKILL.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -191,9 +191,9 @@ keeps the header off a file it would make refuse (ako/mxcli#876). A `/** … */` doc comment documents the statement right after it, and only a `create` of something with documentation stores one. Above a `drop`, `grant`, -`revoke`, `set`, `alter` or `create module` it is ignored, so **MDL089** warns -and names the next statement that could have taken it. In drop-then-create, put -the comment between the `drop` and the `create` (ako/mxcli#877). +`revoke`, `set` or `alter` it is ignored, so **MDL089** warns and names the next +statement that could take it — in drop-then-create, put it between the two +(ako/mxcli#877). Above a `create` that stores none, make it a `--` comment. ### It resolves MEMBER names too, where it can establish the entity diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 51f834dcf0..767ba259da 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -12,15 +12,15 @@ description: "CREATE PAGE syntax reference — parameters, variables, layouts, a |---|---| | 28-43 | Reference files | | 44-46 | Overview | -| 47-196 | Syntax | -| 197-267 | Basic Examples | -| 268-289 | Modifying Existing Pages | -| 290-393 | Conditional Visibility and Editability | -| 394-558 | Known Limitations | -| 559-567 | Tips | -| 568-594 | Related Commands | -| 595-619 | PLUGGABLEWIDGET Escape Hatch | -| 620-623 | See Also | +| 47-198 | Syntax | +| 199-269 | Basic Examples | +| 270-291 | Modifying Existing Pages | +| 292-395 | Conditional Visibility and Editability | +| 396-560 | Known Limitations | +| 561-569 | Tips | +| 570-596 | Related Commands | +| 597-621 | PLUGGABLEWIDGET Escape Hatch | +| 622-625 | See Also | # CREATE PAGE - MDL Syntax Guide @@ -130,7 +130,9 @@ create page MyModule.OrderDetail folder 'Orders/Details' ### Styling: Class, Style, and DesignProperties -Three styling mechanisms can be applied to any widget: +Three styling mechanisms can be applied to any widget — except a `snippetcall`, +which renders no element of its own, so its styling never reaches the page +(MDL-WIDGET48 / lint MPR013); style a container around or inside it instead: **CSS Class** — Atlas UI utility classes or custom CSS classes: ```sql diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 7c18f4c4f6..a8fddacc31 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -751,6 +751,20 @@ snippetcall snippetName (snippet: Module.SnippetName) snippetcall actions (snippet: Module.EntityActions, params: (entity = $Param)) ``` +**A snippet call renders no element of its own** — the snippet's widgets are +inlined into the parent. So `Class`, `Style`, `DynamicClasses` and +`DesignProperties` on a `snippetcall` never reach the page: valid, `mx check` +clean, and silently useless (measured on Mendix 11.15). `mxcli check` warns +**MDL-WIDGET48** (also for `alter page … { set (Class: …) on }` and +`alter styling` on one) and `mxcli lint` reports existing ones as **MPR013**. +Style a container instead — inside the snippet, or around the call: + +```sql +container cNav (class: 'sidebar') { + snippetcall scNav (snippet: Module.NavigationMenu) +} +``` + **A parameter satisfied by the enclosing data context takes NO mapping.** Mendix has no variable meaning "the surrounding context" — the correct model is the *absence* of a mapping, which is what Studio Pro writes. So inside a DataView diff --git a/.claude/skills/mendix/download-marketplace-content/SKILL.md b/.claude/skills/mendix/download-marketplace-content/SKILL.md index 67aa31f7fb..0bc6226614 100644 --- a/.claude/skills/mendix/download-marketplace-content/SKILL.md +++ b/.claude/skills/mendix/download-marketplace-content/SKILL.md @@ -10,19 +10,20 @@ description: "The full lifecycle of Mendix Marketplace modules and widgets from | lines | section | |---|---| -| 34-42 | When to Use This Skill | -| 43-59 | Prerequisites: Authenticate | -| 60-93 | Step 1 — Discover | -| 94-103 | Step 2 — Download a `.mpk` to disk (optional) | -| 104-223 | Step 3 — Install into a project | -| 224-288 | Step 4 — Repair the model after the install (required, headless) | -| 289-345 | Step 5 — Before updating: has the module been edited? | -| 346-404 | Step 6 — Update an installed module | -| 405-450 | Worked example: the agent-editor stack on a vanilla app | -| 451-458 | Notes | -| 459-462 | Platform authentication (`mxcli auth login/logout/status/list`) with PAT scheme for marketplace-api | -| 463-466 | Marketplace download/install (`mxcli marketplace download/install`) — the content API now exposes a per-version downloadUrl (303→public CDN); install is type-aware (widget→widgets/, new module→`mx module-import`); existing-module updates are reported, not applied (entity-ID/local-edit safety — see PROPOSAL_marketplace_modules | -| 467-471 | Marketplace drift detection (`mxcli marketplace diff -p app.mpr [--to VERSION] [--json]`) | +| 35-44 | When to Use This Skill | +| 45-61 | Prerequisites: Authenticate | +| 62-95 | Step 1 — Discover | +| 96-105 | Step 2 — Download a `.mpk` to disk (optional) | +| 106-225 | Step 3 — Install into a project | +| 226-290 | Step 4 — Repair the model after the install (required, headless) | +| 291-347 | Step 5 — Before updating: has the module been edited? | +| 348-406 | Step 6 — Update an installed module | +| 407-452 | Worked example: the agent-editor stack on a vanilla app | +| 453-474 | Package a module of the project (`marketplace package`) | +| 475-482 | Notes | +| 483-486 | Platform authentication (`mxcli auth login/logout/status/list`) with PAT scheme for marketplace-api | +| 487-490 | Marketplace download/install (`mxcli marketplace download/install`) — the content API now exposes a per-version downloadUrl (303→public CDN); install is type-aware (widget→widgets/, new module→`mx module-import`); existing-module updates are reported, not applied (entity-ID/local-edit safety — see PROPOSAL_marketplace_modules | +| 491-495 | Marketplace drift detection (`mxcli marketplace diff -p app.mpr [--to VERSION] [--json]`) | # Download, Install and Update Marketplace Content @@ -37,6 +38,7 @@ These are **CLI commands**, not MDL statements. - User wants to upgrade a module that is already installed - User asks whether a marketplace module has been edited locally, or what an upgrade would overwrite - User asks to download a specific `.mpk` (e.g. for CI, or to import in Studio Pro) +- User asks to export a module of their own project as a `.mpk` - User asks which versions of a marketplace item are compatible with their Mendix version - User reports `CE0463` or `CE6087` right after installing or updating a module @@ -448,6 +450,28 @@ Four things this run established, none of them obvious from the command list: The ids are a convenience, not an authority: confirm with `search`/`info` rather than trusting them from memory, and note that the listing name never matches the module name. +## Package a module of the project (`marketplace package`) + +The reverse of `install --file`: export a module as a `.mpk`. No PAT is needed. + +```bash +mxcli marketplace package MyModule -p app.mpr # ./MyModule.mpk +mxcli marketplace package MyModule -p app.mpr -o ./dist --json # {path, sizeBytes, files, foreignEntries} +mxcli marketplace install --file ./dist/MyModule.mpk -p other.mpr # and back in +``` + +- Only the module's own required `userlib/` JARs go in; `--all-libs` bundles every JAR. +- `mx` still exports the whole `resources/` folder (another module's `resources/EventLog/` came + along in a measured export). mxcli warns about each such file in `resources/`, `javasource/` or + `themesource/` and prints the `--exclude` pattern + that drops it, e.g. `--exclude '^resources.EventLog.'`. `--exclude` takes **one** regex (join + with `|`) and the separator must be written `.`: `[/\\]` makes `mx` write nothing. +- The `mx` used must be the project's Mendix version (read from the `mx` itself): a mismatch, or an + `mx` of unknown version, is refused with both versions named. Fix: `mxcli setup mxbuild --version X` + (an `mx` on PATH is used first). `--mxbuild-path` is the override: a mismatch then only warns on stderr. +- `mx` writes into the project it is given, so mxcli runs it on a temporary copy; the project on + disk is unchanged. A Studio Pro lock file beside the project does not stop the export. + ## Notes - `install`/`update`/`diff` require `-p `; `download` does not. diff --git a/.claude/skills/mendix/java-actions/SKILL.md b/.claude/skills/mendix/java-actions/SKILL.md index e86a3faec4..c800a453be 100644 --- a/.claude/skills/mendix/java-actions/SKILL.md +++ b/.claude/skills/mendix/java-actions/SKILL.md @@ -13,12 +13,12 @@ description: "Create and call custom Java actions — extending Mendix with serv | 28-39 | Reference files | | 40-48 | When to Use This Skill | | 49-55 | Overview | -| 56-332 | Part 2.5: Creating Java Actions in MDL | -| 333-526 | Part 3: Calling Java Actions from MDL | -| 527-540 | Validation Checklist | -| 541-551 | Common Errors | -| 552-559 | Related Documentation | -| 560-641 | Quick Reference | +| 56-355 | Part 2.5: Creating Java Actions in MDL | +| 356-549 | Part 3: Calling Java Actions from MDL | +| 550-563 | Validation Checklist | +| 564-574 | Common Errors | +| 575-582 | Related Documentation | +| 583-664 | Quick Reference | # Mendix Java Actions Skill @@ -78,6 +78,29 @@ return false; $$; ``` +### Parameter Descriptions and the Return Value Name + +What a modeler sees when dragging the action into a microflow: each parameter's +description, and the return value's name (the default output variable name). +A description is a `/** … */` doc comment **before** the parameter, as on an entity +attribute; the return value's name is `returns T as $Name`, as on a microflow: + +```mdl +mdl 1; +/** Converts text to upper case. */ +create java action Module.ToUpper( + /** The text to convert. */ + Input: string not null +) returns string as $UpperText +as $$ +return Input.toUpperCase(); +$$; +``` + +A trailing `-- text` after a parameter is an ordinary comment and is **not stored**. +On `create or modify`, leaving either out keeps what is stored; a new action gets +no descriptions and `ReturnValueName`. The same forms apply to JavaScript actions. + ### Type Parameters (Generics) Type parameters let Java actions accept any entity type dynamically. Use `entity ` in a parameter type to declare the type parameter inline. That parameter becomes the **entity type selector** (receives the entity type name, e.g., `'Module.Entity'`). Bare `pEntity` parameters become **parameterized entity** params (receive entity instances, e.g., `$Variable`). diff --git a/.claude/skills/mendix/javascript-actions/SKILL.md b/.claude/skills/mendix/javascript-actions/SKILL.md index 7d893fd9f7..62311b2d5b 100644 --- a/.claude/skills/mendix/javascript-actions/SKILL.md +++ b/.claude/skills/mendix/javascript-actions/SKILL.md @@ -12,14 +12,14 @@ description: "Create, call and drop JavaScript actions — client-side logic cal |---|---| | 31-39 | When to Use This Skill | | 40-51 | Overview | -| 52-94 | Syntax | -| 95-156 | Examples | -| 157-168 | Calling from a nanoflow | -| 169-193 | The generated .js file | -| 194-202 | Inspecting | -| 203-211 | Validation Checklist | -| 212-221 | Common Errors | -| 222-226 | Related Documentation | +| 52-97 | Syntax | +| 98-159 | Examples | +| 160-171 | Calling from a nanoflow | +| 172-196 | The generated .js file | +| 197-205 | Inspecting | +| 206-214 | Validation Checklist | +| 215-224 | Common Errors | +| 225-229 | Related Documentation | # Mendix JavaScript Actions Skill @@ -53,7 +53,7 @@ normally returns a `Promise`. ```sql CREATE [OR MODIFY] JAVASCRIPT ACTION Module.Name ( parameters ) - RETURNS type + RETURNS type [AS $ReturnName] [EXPOSED AS 'caption' IN 'category'] [PLATFORM Web | Native | Hybrid | All] -- default Web AS $$ @@ -82,6 +82,9 @@ Same type system as Java actions: - Type parameter (generics): declare `EntityType: ENTITY `, then use the bare `pEntity` for instance parameters - Append `NOT NULL` to mark a parameter required. +- A `/** … */` doc comment **before** a parameter is its description; a trailing + `-- text` is an ordinary comment and is not stored. `RETURNS type AS $Name` + names the return value. Left out on `OR MODIFY`, both keep what is stored. ### Parameter names in the body diff --git a/.claude/skills/mendix/json-structures-and-mappings/SKILL.md b/.claude/skills/mendix/json-structures-and-mappings/SKILL.md index edb2b347de..877b345d24 100644 --- a/.claude/skills/mendix/json-structures-and-mappings/SKILL.md +++ b/.claude/skills/mendix/json-structures-and-mappings/SKILL.md @@ -10,19 +10,20 @@ description: "Create and manage JSON structures, import mappings and export mapp | lines | section | |---|---| -| 32-109 | Key Concepts | -| 110-121 | Export Mappings | -| 122-186 | JSON Structures | -| 187-228 | Message Definitions | -| 229-389 | Import Mappings | -| 390-487 | Export Mappings | -| 488-505 | Starting a Mapping Below the Payload Root | -| 506-575 | Microflow Actions | -| 576-588 | Browse | -| 589-636 | Export Workflow: PE → NPE → JSON | -| 637-643 | Realistic Example: Countries REST API | -| 644-667 | Placing Documents in Folders | -| 668-682 | Common Mistakes | +| 33-110 | Key Concepts | +| 111-122 | Export Mappings | +| 123-187 | JSON Structures | +| 188-229 | Message Definitions | +| 230-390 | Import Mappings | +| 391-488 | Export Mappings | +| 489-506 | Starting a Mapping Below the Payload Root | +| 507-576 | Microflow Actions | +| 577-589 | Browse | +| 590-637 | Export Workflow: PE → NPE → JSON | +| 638-644 | Realistic Example: Countries REST API | +| 645-668 | Placing Documents in Folders | +| 669-684 | Documentation | +| 685-699 | Common Mistakes | # JSON Structures, Import Mappings & Export Mappings @@ -665,6 +666,22 @@ adding a folder to an existing script is safe and removing one is a no-op. See `organize-project` for `move` and the full folder story. +## Documentation + +A `/** … */` doc comment directly above the `create` is the document's +documentation — for JSON structures, import and export mappings alike (and for +published REST services and data transformers). It is what `lint` QUAL002 asks +for, and `describe` prints it back. On `create or modify`, **no** comment keeps +the stored text and an empty `/** */` clears it: + +```mdl +mdl 1; +/** Maps the order payload of the shop API onto Sales.Order. */ +create or modify import mapping Sales.IMM_Order + with json structure Sales.JSON_Order +{ create Sales.Order { OrderId = id, Total = total } }; +``` + ## Common Mistakes | Mistake | Fix | diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index 8d9ab2afbc..a32c87198a 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -615,7 +615,7 @@ Returned by `languages()`, one per language enabled in the project settings. A p | `is_camel_case(s)` | Returns True if string is camelCase | | `matches(s, pattern)` | Returns True if string matches regex | | `find_all(s, pattern)` | Every match of the regex in `s`: a tuple of the capture groups per match, or the matched string when the pattern has no groups | -| `get_option(key, default?)` | The rule's option `key` from the `options:` block under its rule ID in `.claude/lint-config.yaml`, or `default` (`None` if omitted) when unset | +| `get_option(key, default?)` / `get_list_option(key, default?)` | The rule's option `key` from the `options:` block under its rule ID in `.claude/lint-config.yaml`, or `default` (`None` if omitted) when unset. YAML lists and maps arrive as lists and dicts. `get_list_option` returns a list of strings from a YAML list or comma-separated text (`"ACT_, SUB_"`) — use it, not a constant list a project would have to edit (`mxcli init` rewrites shipped rules). A value of the wrong shape is reported as a warning and yields `default` | | `struct(**kwargs)` | Build an ad-hoc struct, e.g. `struct(name="x", count=1)`, to group values inside a rule | ## Common Patterns diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index a532de4095..b1df1bbddc 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -60,6 +60,29 @@ set $Name = $Product/Shop.Product_Category/Name; **Explanation**: XPath navigation requires the full qualified association name in the format `Module.AssociationName`. +A path that **ends** on the association is not a value at all, in either +direction: `return $Parent/Shop.Order_Customer;` is CE0117 at the end event +(MDL049, as for a call argument), and `loop $o in $Customer/Shop.Order_Customer` +has no list to store, CE0099 (MDL094). Retrieve over the association — that is +the activity both mean: + +```mdl +mdl 1; +create microflow Shop.CustomerOrders ($Customer: Shop.Customer) +returns list of Shop.Order as $Orders +begin + retrieve $Orders from $Customer/Shop.Order_Customer; + loop $o in $Orders begin + log info node 'Shop' $o/OrderNumber; + end loop; + return $Orders; +end; +``` + +Naming the target entity after a reference followed from its owner, +`$Order/Shop.Order_Customer/Shop.Customer`, is the one path form that is an +object value (Studio Pro's own spelling). + ### 3. Missing Attributes **Error**: Attribute references must exist in entity definition @@ -669,13 +692,20 @@ end; module's public surface when the module is exported. It bites in a **Protected (add-on) module** — `describe module` prints `-- export level: Protected …` and `show modules` says `Protected AddOn`: - another module or the navigation referencing a Hidden microflow, entity or - any page there is **CE1406**, and an `api` microflow whose parameter or return - type is a Hidden entity of that module is **CE5400**. `check -p --references` - reports both (mendixlabs/mxcli#1498). A page there cannot be made usable - (mxbuild refuses it even when stored as API) — reach it through an `api` - microflow in the module that shows it. An entity's export level has no MDL - syntax (`describe entity` prints it as a comment); set it in Studio Pro. + another module or the navigation referencing a Hidden microflow, entity, + attribute, association or any page there is **CE1406**, and an `api` + microflow whose parameter or return type is a Hidden entity or enumeration of + that module is **CE5400**. `check -p --references` reports both + (mendixlabs/mxcli#1498), including a flow's use of a Hidden attribute or + association — a change / create member, an association retrieve, an XPath + step or a `$v/…` path — with the `alter` statement that makes it usable. + Page widgets binding a Hidden member are not checked yet. A page there cannot + be made usable (mxbuild refuses it even when stored as API) — reach it through + an `api` microflow in the module that shows it. Entities, attributes, + associations and enumerations take the same clause: `alter entity M.E set + export level api`, `Attr: Type export level api`, `create association … export + level api`, `alter enumeration M.K set export level api` + (`mxcli syntax domain-model.export-level`). - **`disallow concurrent execution`** takes `error message 'text'` or `error microflow Mod.Name`; `allow concurrent execution` is the default. diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index 59cfb221bd..ea82bfe07b 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -218,7 +218,7 @@ end workflow; > the resulting `.mpr` **cannot be loaded at all**. `mxcli` refuses it (MDL-WF04) > at check and exec time. Attach the note to an activity with `@annotation`. -**Boundary events** attach a timer to a user task / call-microflow / wait: +**Boundary events** attach a timer to a user task, call microflow, call workflow or wait: ```sql mdl 1; diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5bdbdda112..da3b5719ea 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -40,7 +40,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: '1.27.2' + go-version: '1.27.3' - name: Cache ANTLR4 JAR uses: actions/cache@v6 @@ -89,7 +89,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: '1.27.2' + go-version: '1.27.3' # mxbuild 9 draws through System.Drawing; without libgdiplus every mx call # dies with "The type initializer for 'Gdip' threw an exception." @@ -144,7 +144,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: '1.27.2' + go-version: '1.27.3' - name: Cache ANTLR4 JAR uses: actions/cache@v6 with: @@ -175,7 +175,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: '1.27.2' + go-version: '1.27.3' - uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/push-test.yml b/.github/workflows/push-test.yml index 1b876f965d..f9feb55789 100644 --- a/.github/workflows/push-test.yml +++ b/.github/workflows/push-test.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: '1.27.2' + go-version: '1.27.3' - name: Test the tunnel seam shell: bash # Scoped with -run to the seam's own tests. The full test binaries are still @@ -78,7 +78,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: '1.27.2' + go-version: '1.27.3' - name: Test the Windows process helpers shell: bash # Scoped with -run: the rest of the docker package's tests assert POSIX @@ -122,7 +122,7 @@ jobs: submodules: true - uses: actions/setup-go@v7 with: - go-version: '1.27.2' + go-version: '1.27.3' - name: Cache ANTLR4 JAR uses: actions/cache@v6 with: @@ -256,7 +256,7 @@ jobs: submodules: ${{ matrix.suite == 'roundtrip' || matrix.suite == 'parity' }} - uses: actions/setup-go@v7 with: - go-version: '1.27.2' + go-version: '1.27.3' - name: Cache ANTLR4 JAR uses: actions/cache@v6 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f0b8d4021..5bdcf8eb98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v7 with: - go-version: '1.27.2' + go-version: '1.27.3' - uses: oven-sh/setup-bun@v2 - name: Cache ANTLR4 JAR uses: actions/cache@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index aa95d8c77d..ea8ca9c59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **A specialization of a non-persistent entity reads as non-persistent** (mendixlabs/mxcli#1600) — `describe entity Probe.Child` printed `create or modify persistent entity Probe.Child extends Probe.Base (` when `Probe.Base` is non-persistent, so a reader took the child for a table and the output stated a combination Mendix does not have. A specialization stores no persistable flag of its own; it takes its root generalization's, and the reader defaulted it to persistent. The flag is now resolved along the generalization chain, across modules and into the System module (whose `HttpRequest`, `HttpResponse` and `SoapFault` read persistent for the same reason), and the fix reaches everything that reads it: `describe`, the catalog's `EntityType`, the domain-model diagrams, and `alter entities … where persistent`, which used to add its attribute to such a child as well. Re-running the describe output leaves the entity unchanged. - **`go mod tidy` runs again, and the expression round-trip test runs for the first time** — `mdl/exprcheck/roundtrip_test.go` imported `mdl/backend/mpr` (the legacy engine, deleted) plus `executor.GetHierarchyForMining` and `executor.DescribeMicroflowToString` (both removed). It never broke a build because it sat behind `//go:build roundtrip`, a tag no Makefile target and no CI job has ever passed — the only file in the tree using it. `go mod tidy` considers every build tag, so it was the one command that still saw the file, and it failed with "no matching versions for query latest" on the deleted package, which reads as a dependency problem rather than a dead test. The test is ported to `mdl/executor/exprcheck_roundtrip_test.go` under the `integration` tag, where `setupTestEnv`/`describeMDL` already provide the real-project harness and `make test-integration-executor` actually runs it; the removed hierarchy helper is replaced by building the container-to-module map from `backend.ListModules()`. Its assertion is worth keeping: describing every microflow and re-parsing each expression with the robust parser catches grammar gaps — a missing `parsePrimary` branch, a missing `funcTable` entry, a missing slot constraint — that the unit tests cannot, since those only feed the parser expressions someone thought to write down. It now also fails rather than passing silently when the fixture project yields no microflows or no expression slots. ### Security @@ -18,13 +19,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Security +- **The `klauspost/compress` v1.20.3 floor survives `go mod tidy`** — the v1 BSON driver removal ran `go mod tidy`, which deleted the hand-written `github.com/klauspost/compress v1.20.3` requirement, because no package mxcli builds imports it. The module graph fell back to the v1.19.2 that `mongo-driver/v2` declares, which is flagged for GHSA-fpmh-frq8-7rj8, so the dependency scan would have blocked the release again. A new `internal/depfloor/floor.go` imports `github.com/klauspost/compress/zstd` behind a `depfloor` build tag that no build sets. `go mod tidy` considers every tag, so the requirement is now direct and kept, while `go version -m bin/mxcli` still lists no `klauspost/compress`. `TestGoModKeepsSecurityFloors` fails when go.mod lacks a floored module or requires it below its floor, and it also pins `mongo-driver/v2` at v2.9.2. - **Dependency scan follow-up: `klauspost/compress` and `go-mssqldb` raised to their fixed versions** — `github.com/klauspost/compress` v1.19.2 -> v1.20.3 and `github.com/microsoft/go-mssqldb` v1.10.0 -> v1.11.2. A second scan of the published `.exe`, run after the mongo-driver upgrade, still blocked the release. `klauspost/compress` v1.19.2 predates GHSA-fpmh-frq8-7rj8 (a zstd `DecodeAll` crash on crafted input, fixed in v1.20.2) — the earlier upgrade had only raised it to whatever `mongo-driver/v2` declared, which is not a security floor; it is now an explicit requirement. As before, that module is compiled into no mxcli build (no `go.sum` entry, no `go version -m` line), so the bump moves only the requirement graph the scanner reads. `go-mssqldb` is a blank import in `sql/drivers.go` for driver registration and mxcli calls none of its API, so the bump has no API surface; `TestExternalDriversRegistered` now pins that contract, which had no coverage, and fails with `driver "sqlserver" is not registered` when the import is dropped. - **`go.mongodb.org/mongo-driver` v1 remains flagged and cannot be upgraded out** — CVE-2026-107325 has no fix on the v1 line (v1.17.10 is its last release and the module is deprecated). mxcli is not exposed: the panic is in `bsoncore.Array.Validate`, which mxcli imports in no file, every `.Validate()` call site in the tree resolves to the v2 driver, and v1's `bson.Unmarshal` — the API mxcli does use, on `.mpr` bytes — does not panic on the malformed payloads tried. Measured on v1.17.10: `bsoncore.Array.Validate([]byte{0,0,0,0})` panics with `index out of range [-1]`, while `bson.Raw.Validate` on CVE-2026-107324's `math.MaxInt32` payload does not, so that second CVE's code path is v2-only and the v1 report overstates it by one. Clearing the finding at source needs the remaining v1 `bson` imports moved to v2, which is tracked separately. ### Added +- **Lint rule CONV020: an object created in a microflow and never committed, returned or passed on** (mendixlabs/mxcli#1518) — `$o = create Shop.Item (…)` without `commit` makes an object in memory only; if the microflow then never commits it or hands it to anything that could, the object is discarded when the microflow ends, and `check`, `mx check` and the build all pass. In the field an after-startup seed microflow logged "Seeded 20" and the table had 0 rows. CONV020 (warning) reports a create with commit No of a persistent entity in a microflow when nothing in the flow uses the variable except to read a member (`$o/Cnt`) or change it without commit. Any other mention — commit, change with commit, return, an argument to a microflow, Java/JavaScript action, workflow, mapping or page, a list, an association value, delete, rollback, or the bare variable in any expression — counts as passing it on. It is conservative by design: flow-insensitive (a commit on one branch or in an error handler counts), and nanoflows, non-persistent entities and System/Marketplace modules are not reported; anything it cannot classify keeps it quiet. Docs: "Built-in Rules" › CONV020 details. +- **QUAL004's entry-point prefixes and CUSTOM002's valid prefixes are `lint-config.yaml` options** (mendixlabs/mxcli#1509) — both lists were constants in the rule files, and `mxcli init` rewrites those files, so a project whose convention names microflows `TEST_`, `GET_` or `PRS_` could not keep an edit and got one finding per such microflow (84 QUAL004 and 121 CUSTOM002 findings on the reporting project). `rules.QUAL004.options.entry_point_prefixes` and `rules.CUSTOM002.options.valid_prefixes` take a YAML list or comma-separated text and replace the built-in list; unset, the built-in list applies as before. Underneath, a YAML list or map under any rule's `options` reached a Starlark rule as `None` — `get_option` converted scalars only — so a list-valued option was dropped without a word; lists and maps now convert recursively, the new `get_list_option(key, default)` builtin reads a list of strings in either spelling, and an option value a rule cannot use is reported as a warning naming `rules..options.` (kept out of the score) while the rule runs on its default. QUAL004 still counts only callers in the model: a microflow called only from `tests/*.test.mdl` is reported, and its prefix belongs in `entry_point_prefixes`. Docs: "Starlark Rules" › QUAL004 and CUSTOM002 options. - **`layoutheader` and native layout parts — every Atlas layout copies whole** — `describe layout` → `create layout` of Atlas's Phone_ and Tablet_ layouts dropped their header bar (`Forms$Header`, printed as NOT re-executable), so pages bound to its `HeaderLeft` / `HeaderRight` placeholders failed CE1613 and a copy of Phone_Sidebar or Tablet_Sidebar failed CE0611 "A sidebar toggle button is required if a region can toggle". `layoutheader header1 { region left { … } region right { … } }` writes it. A native layout's copy silently lost its header placeholder, bottom bar and sidebar — pages bound to `Header` or a sidebar placeholder then failed CE1613; they are `region header { placeholder Header }`, `showbottombar: true` and `region sidebar { … }`, and the native content is written with the eight keys Studio Pro stores. Measured on a new 11.13.0 app: all 22 Atlas_Core layouts copied, with pages bound to the copied header and sidebar placeholders, build with 0 errors. - **`sidebartoggle` — Atlas's responsive sidebar toggle (`Forms$SidebarToggleButton`) in MDL** (mendixlabs/mxcli#1220) — copying `Atlas_Core.Atlas_Default` with `describe layout` → `create layout` dropped the button that opens the sidebar on phones and tablets: `describe` printed it as a NOT re-executable comment, and `exec` gave no warning. `sidebartoggle sidebarToggle3 (Tooltip: 'Toggle Menu', ButtonStyle: Primary, Icon: 'Atlas_Core.Atlas_Filled.navigation-menu', Class: 'toggle-btn')` takes an action button's caption, tooltip, style and icon, and `RenderMode: Link`; it has no action. The written widget has the keys of Atlas's own, including its caption under `CaptionTemplate` rather than the `Caption` key the vendored metamodel bound. A scroll container region's `ToggleMode` (`ToggleMode: 'ShrinkContentInitiallyClosed'`), which the toggle opens, is described and written too — without it the copy failed CE0628. +- **`mxcli marketplace package` — export a module as a `.mpk`** (mendixlabs/mxcli#1382) — `mxcli marketplace package MyModule -p app.mpr [-o dir] [--all-libs] [--exclude REGEX] [--exclude-managed-dependencies] [--json]` is the reverse of `marketplace install --file`: it wraps Mendix's own `mx create-module-package` (resolved for the project's Mendix version), so the package is what Studio Pro's *Export module package* makes, and it installs back with `install --file`. By default only the module's own required `userlib/` JARs go in (`mx -l`; measured 12.5 KB against 24 MB with Business Events installed); `--all-libs` bundles every JAR. `-l` filters only JARs — mx still exports the app's whole `resources/` folder (measured: another module's `resources/EventLog/` came along) — so after the export mxcli opens the package, warns on stderr about each `resources//`, `javasource//` or `themesource//` file of another module, and prints the `--exclude` pattern that drops them in the one form mx accepts (`^resources.EventLog.`; a character class or backslash is rejected by mx with exit 1 and a usage message, and an exit 0 that wrote no package is reported as an error too). A module name that is not in the project lists the project's modules instead of failing with mx's exit 3, and mx's exit codes 1–4 are reported with what they mean and what mx printed. `mx create-module-package` writes into the project it is given — measured on a blank 11.12.5 app: `theme-cache/web/theme.compiled.css` and its map rewritten, `deployment/sass/main.scss` added, the module's `themesource/` folder created, model files untouched — so it runs on a temporary copy, as `docker check` does; a before/after listing of the project (sizes and modification times) was identical. The `mx` that runs must be the project's Mendix version: `ResolveMxForVersion` prefers an `mx` on PATH and falls back to any cached one, so mxcli reads the version of the `mx` it found (its `mx.deps.json`, else its version-named folder) and refuses a different major.minor.patch, or one it cannot identify, naming both versions and the fix (`mxcli setup mxbuild --version X`); `--mxbuild-path` is the override, with a warning on stderr. mx's own output on a successful export goes to stderr, cancelling the command stops mx, and an output directory inside the project is not copied into the temporary copy. Round trip on 11.12.5: export, `install --file` into a second fresh app, `mx check` — 0 errors. +- **`mxcli search --exclude-marketplace` leaves out hits inside Marketplace modules** (mendixlabs/mxcli#1477) — an audit of the app's own text (a leftover `TODO`, a word that must not ship) reported a vendor module's text beside the project's own. The flag drops hits whose module was downloaded from the Marketplace, including the module's own documentation and domain model documentation; it is off by default, because `search` is read-only and the default must not change what existing callers see (`layout` skips Marketplace modules because it writes). A module counts as a Marketplace module by the current snapshot of the catalog only, so one that was a Marketplace module in an older snapshot is not left out of every later search. The `SEARCH` statement is unchanged. Rules: docs-site "Full-Text Search". ### Fixed diff --git a/cmd/mxcli/cmd_lint.go b/cmd/mxcli/cmd_lint.go index fb145b0bcb..e42ce6d8e0 100644 --- a/cmd/mxcli/cmd_lint.go +++ b/cmd/mxcli/cmd_lint.go @@ -76,6 +76,7 @@ Bundled Starlark rules (in .claude/lint-rules/): - No validation rules (CONV015) - use microflows instead of entity validation rules - No event handlers (CONV016) - avoid entity event handlers - No calculated attributes (CONV017) - avoid calculated attributes + - Created object never persisted (CONV020) - a created object that is never committed, returned or passed on Custom Starlark rules in .claude/lint-rules/*.star are loaded automatically. @@ -417,6 +418,7 @@ func builtinLintRules() []linter.Rule { rules.NewValidationFeedbackRule(), rules.NewImageSourceRule(), rules.NewLegacyImageWidgetRule(), + rules.NewSnippetCallAppearanceRule(), rules.NewEmptyContainerRule(), rules.NewGallerySelectionListenerRule(), rules.NewDataViewLayoutGridRule(), @@ -432,7 +434,8 @@ func builtinLintRules() []linter.Rule { rules.NewExclusiveSplitCaptionRule(), rules.NewErrorHandlingOnCallsRule(), rules.NewNoContinueErrorHandlingRule(), - rules.NewIrreducibleFlowGraphRule(), // MDL-FLOW01 - graph structure vs MDL nesting - rules.NewImportRangeObjectMappingRule(), // MDL-MAP04 - First over an object-rooted mapping in the model + rules.NewCreatedObjectNeverPersistedRule(), // CONV020 - create without commit, never persisted or passed on + rules.NewIrreducibleFlowGraphRule(), // MDL-FLOW01 - graph structure vs MDL nesting + rules.NewImportRangeObjectMappingRule(), // MDL-MAP04 - First over an object-rooted mapping in the model } } diff --git a/cmd/mxcli/cmd_marketplace.go b/cmd/mxcli/cmd_marketplace.go index 3a9cb256c8..3a80a2a8aa 100644 --- a/cmd/mxcli/cmd_marketplace.go +++ b/cmd/mxcli/cmd_marketplace.go @@ -28,7 +28,9 @@ Requires a Personal Access Token (PAT). Run 'mxcli auth login' first. 'marketplace download' fetches a content version's .mpk to disk; 'marketplace install --file ' installs a .mpk from disk — a downloaded one, or any package distributed as a file — through the same writer as an online -install, with no PAT.`, +install, with no PAT. +'marketplace package ' is the reverse: it exports a module of a project +as a .mpk.`, } var marketplaceSearchCmd = &cobra.Command{ diff --git a/cmd/mxcli/cmd_marketplace_package.go b/cmd/mxcli/cmd_marketplace_package.go new file mode 100644 index 0000000000..2b020b2c2d --- /dev/null +++ b/cmd/mxcli/cmd_marketplace_package.go @@ -0,0 +1,522 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" + "github.com/spf13/cobra" +) + +var marketplacePackageCmd = &cobra.Command{ + Use: "package -p ", + Short: "Export a module of a project as a .mpk module package", + Long: `Export a module as a .mpk module package, the reverse of +'marketplace install --file '. The package is made by Mendix's own +'mx create-module-package', so Studio Pro, 'mx module-import' and mxcli can all +read it back. + + - Without --all-libs only the userlib JARs the module declares it needs (those + with a '..RequiredLib' file beside them) are bundled; with --all-libs + every JAR in the app's userlib/ goes in (24 MB on an app with Business Events + installed, against 12 KB for the module's own). + - mx still exports the app's whole resources/ folder, whatever --all-libs says + (measured: resources/EventLog/ of another module came along). mxcli opens the + package and warns about any resources/, javasource/ or themesource/ file that + belongs to another module, and prints the --exclude pattern that drops it. + - --exclude takes ONE regular expression, matched against each file's path + inside the package; join alternatives with '|'. Write the folder separator as + '.' ('^resources.EventLog.'): a character class or a backslash makes mx print + its usage and write nothing. + +The mx must be the Mendix version of the project: a package made by another +version than the app it comes from is refused, naming both versions. mx is taken +from, in order, an mx on PATH, a Studio Pro install and the mxbuild cache, so a +different version can be found first; 'mxcli setup mxbuild --version ' +installs the right one. --mxbuild-path is the override: with it a mismatch is +allowed, with a warning on stderr. + +mx writes into the project it is given (theme-cache/, deployment/sass/, the +module's themesource/ folder), so it runs on a temporary copy; the project on +disk is not changed. An output directory inside the project is left out of that +copy. A Studio Pro lock file beside the project does not stop the +export, which reads what is saved on disk. + +The package is named .mpk and is written to --output-dir (default: the +current directory, created if missing), replacing an earlier one.`, + Example: ` mxcli marketplace package MyModule -p app.mpr + mxcli marketplace package MyModule -p app.mpr -o ./dist + mxcli marketplace package MyModule -p app.mpr --exclude '^resources.EventLog.|^themesource.atlas_core.' + mxcli marketplace package MyModule -p app.mpr --all-libs --json`, + Args: cobra.ExactArgs(1), + RunE: runMarketplacePackage, + // Like install: a failed export is a runtime failure, not a misuse of the + // command, and main() already prints the error. + SilenceUsage: true, + SilenceErrors: true, +} + +func init() { + marketplacePackageCmd.Flags().StringP("project", "p", "", "path to the Mendix project (.mpr)") + marketplacePackageCmd.Flags().StringP("output-dir", "o", ".", "directory to write .mpk to (created if missing)") + marketplacePackageCmd.Flags().Bool("all-libs", false, + "bundle every userlib JAR of the app, not only those the module declares it requires") + marketplacePackageCmd.Flags().String("exclude", "", + "regular expression for files to leave out, one only (join alternatives with '|'); write the folder separator as '.'") + marketplacePackageCmd.Flags().Bool("exclude-managed-dependencies", false, + "leave out the module's managed dependencies (mx -m)") + marketplacePackageCmd.Flags().String("mxbuild-path", "", + "path to the Mendix installation to take mx from; it overrides the version check (a mismatch only warns)") + marketplacePackageCmd.Flags().Bool("json", false, "emit {path, sizeBytes, files, foreignEntries} as JSON") + _ = marketplacePackageCmd.MarkFlagRequired("project") + + marketplaceCmd.AddCommand(marketplacePackageCmd) +} + +func runMarketplacePackage(cmd *cobra.Command, args []string) error { + var opts packageOptions + opts.Module = args[0] + opts.ProjectPath, _ = cmd.Flags().GetString("project") + opts.OutputDir, _ = cmd.Flags().GetString("output-dir") + opts.AllLibs, _ = cmd.Flags().GetBool("all-libs") + opts.Exclude, _ = cmd.Flags().GetString("exclude") + opts.ExcludeManaged, _ = cmd.Flags().GetBool("exclude-managed-dependencies") + opts.MxBuildPath, _ = cmd.Flags().GetString("mxbuild-path") + opts.JSON, _ = cmd.Flags().GetBool("json") + _, err := runPackage(cmd.Context(), opts, cmd.OutOrStdout(), cmd.ErrOrStderr()) + return err +} + +type packageOptions struct { + ProjectPath string + Module string + OutputDir string + AllLibs bool + Exclude string + ExcludeManaged bool + MxBuildPath string + JSON bool +} + +// packageResult is what the export produced. ForeignEntries is never nil, so the +// JSON form carries an empty array rather than null. +type packageResult struct { + Path string `json:"path"` + SizeBytes int64 `json:"sizeBytes"` + Files int `json:"files"` + ForeignEntries []string `json:"foreignEntries"` +} + +// The variables below are seams: tests replace them, because they cannot run +// Studio Pro's mx. + +// packageProjectInfo reports the project's Mendix version and its module names. +var packageProjectInfo = func(mprPath string) (version string, modules []string, err error) { + reader, err := openProjectReadOnly(mprPath) + if err != nil { + return "", nil, err + } + defer reader.Disconnect() + version, _ = reader.GetMendixVersion() + mods, err := reader.ListModules() + if err != nil { + return "", nil, err + } + for _, m := range mods { + modules = append(modules, m.Name) + } + return version, modules, nil +} + +var resolveMxForPackage = docker.ResolveMxForVersion + +// mxBinaryVersion reports the Mendix version of an mx binary, "" when unknown. +var mxBinaryVersion = docker.MxBinaryVersion + +// mxCreateModulePackageCmd runs mx with args and writes what it prints (stdout +// and stderr together; mx reports errors on stdout) to out. It returns mx's exit +// code; err is set when mx could not be started or ctx ended before it did (mx +// is then killed). +var mxCreateModulePackageCmd = func(ctx context.Context, mxPath string, args []string, out io.Writer) (exitCode int, err error) { + c := exec.CommandContext(ctx, mxPath, args...) + c.Stdout = out + c.Stderr = out + docker.PrepareMxCommand(c) + runErr := c.Run() + if ctxErr := ctx.Err(); ctxErr != nil { + return -1, ctxErr + } + var exitErr *exec.ExitError + switch { + case runErr == nil: + return 0, nil + case errors.As(runErr, &exitErr): + return exitErr.ExitCode(), nil + } + return -1, runErr +} + +// packageStagingPrefix names the directory inside the output directory that mx +// writes into. Exporting to a fresh directory, then moving the package out, is +// how a package left over from an earlier run is told apart from the one this +// run made, and guards against an mx that exits 0 without writing a package (it +// exits 1 for a pattern it cannot parse, measured on 11.12.5). +const packageStagingPrefix = ".mxcli-package-" + +func runPackage(ctx context.Context, opts packageOptions, stdout, stderr io.Writer) (*packageResult, error) { + if _, err := os.Stat(opts.ProjectPath); err != nil { + return nil, fmt.Errorf("project not found: %s", opts.ProjectPath) + } + projectAbs, err := filepath.Abs(opts.ProjectPath) + if err != nil { + return nil, err + } + progress := stdout + if opts.JSON { + progress = io.Discard // stdout carries one JSON document and nothing else + } + + version, modules, err := packageProjectInfo(opts.ProjectPath) + if err != nil { + return nil, fmt.Errorf("open project: %w", err) + } + module, ok := findModuleName(modules, opts.Module) + if !ok { + sorted := append([]string(nil), modules...) + sort.Strings(sorted) + return nil, fmt.Errorf("module %q not found in %s\nmodules in the project: %s", + opts.Module, filepath.Base(opts.ProjectPath), strings.Join(sorted, ", ")) + } + + if opts.MxBuildPath != "" { + // ResolveMxForVersion quietly falls back to an mx on PATH when this path is + // bad, which would turn a typo into an unchecked export. + if _, err := os.Stat(opts.MxBuildPath); err != nil { + return nil, fmt.Errorf("--mxbuild-path %s: %w", opts.MxBuildPath, err) + } + } + mxPath, err := resolveMxForPackage(opts.MxBuildPath, version) + if err != nil { + return nil, fmt.Errorf("locate mx for Mendix %s: %w\nhint: run 'mxcli setup mxbuild -p %s'", version, err, opts.ProjectPath) + } + mxVersion, err := checkMxVersion(mxPath, version, opts.MxBuildPath != "", stderr) + if err != nil { + return nil, err + } + + outDir, err := filepath.Abs(opts.OutputDir) + if err != nil { + return nil, err + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + return nil, fmt.Errorf("create output directory: %w", err) + } + if err := ctx.Err(); err != nil { + return nil, err + } + + // mx writes into the project it is given, so it runs on a copy; see the + // command's Long text. The staging directory is made after the copy, and an + // output directory inside the project is left out of it, so neither the staging + // directory nor earlier packages are copied. + var finalMpk, mxOutput string + var exitCode int + err = docker.WithProjectCopy(projectAbs, func(workMpr string) error { + staging, err := os.MkdirTemp(outDir, packageStagingPrefix+"*") + if err != nil { + return fmt.Errorf("create staging directory in %s: %w", outDir, err) + } + defer os.RemoveAll(staging) + + fmt.Fprintf(progress, "Exporting module %s from %s (Mendix %s) with %s...\n", + module, filepath.Base(projectAbs), version, mxDescription(mxPath, mxVersion)) + var out bytes.Buffer + exitCode, err = mxCreateModulePackageCmd(ctx, mxPath, mxArgs(opts, staging, workMpr, module), &out) + if err != nil { + return fmt.Errorf("run %s: %w", mxPath, err) + } + mxOutput = strings.TrimSpace(nameUserPaths(out.String(), filepath.Dir(workMpr), filepath.Dir(projectAbs), staging, outDir)) + if exitCode != 0 { + return nil + } + found, _ := filepath.Glob(filepath.Join(staging, "*.mpk")) + if len(found) != 1 { + return fmt.Errorf("mx exited 0 but wrote no .mpk%s", withMxOutput(mxOutput)) + } + dest := filepath.Join(outDir, filepath.Base(found[0])) + if err := os.Rename(found[0], dest); err != nil { + return fmt.Errorf("move the package to %s: %w", dest, err) + } + finalMpk = dest + if mxOutput != "" { + // mx's warnings on a successful export; stderr, so --json stays clean. + fmt.Fprintln(stderr, mxOutput) + } + return nil + }, outDir) + if err != nil { + return nil, err + } + if exitCode != 0 { + return nil, mxExitError(exitCode, module, mxOutput) + } + + names, err := readPackageEntries(finalMpk) + if err != nil { + return nil, fmt.Errorf("read the package %s: %w", finalMpk, err) + } + foreign, own := classifyPackageEntries(names, module) + info, err := os.Stat(finalMpk) + if err != nil { + return nil, err + } + res := &packageResult{Path: finalMpk, SizeBytes: info.Size(), Files: len(names), ForeignEntries: foreign} + + if len(foreign) > 0 { + warnForeignEntries(stderr, module, foreign, own, opts.Exclude) + } + if opts.JSON { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + return res, enc.Encode(res) + } + fmt.Fprintf(stdout, "Exported module %s\n path: %s\n size: %s (%d bytes)\n files: %d\n", + module, res.Path, formatBytes(res.SizeBytes), res.SizeBytes, res.Files) + return res, nil +} + +// mxArgs builds the argument list of 'mx create-module-package': options first, +// then the project and the module as the positional arguments mx expects. +func mxArgs(opts packageOptions, packageDir, targetMpr, module string) []string { + args := []string{"create-module-package"} + if !opts.AllLibs { + args = append(args, "-l") + } + if opts.Exclude != "" { + args = append(args, "-e", opts.Exclude) + } + if opts.ExcludeManaged { + args = append(args, "-m") + } + return append(args, "-d", packageDir, targetMpr, module) +} + +// checkMxVersion compares the Mendix version of the mx that was found with the +// project's and returns the mx's version ("" when unknown). A mismatch, or an mx +// whose version cannot be told, is refused: the package would be made by another +// Mendix version than the app it comes from. override is true when the user named +// the mx with --mxbuild-path; then the same findings are only warned about on +// stderr (which --json leaves alone). +func checkMxVersion(mxPath, projectVersion string, override bool, stderr io.Writer) (string, error) { + got := mxBinaryVersion(mxPath) + if projectVersion == "" { + fmt.Fprintf(stderr, "warning: the project's Mendix version is unknown, so the version of %s was not checked\n", mxPath) + return got, nil + } + if docker.SameMendixRelease(got, projectVersion) { + return got, nil + } + var problem string + if got == "" { + problem = fmt.Sprintf("cannot tell which Mendix version %s is, and the project is Mendix %s", mxPath, projectVersion) + } else { + problem = fmt.Sprintf("the mx found, %s, is Mendix %s, but the project is Mendix %s", mxPath, got, projectVersion) + } + if override { + fmt.Fprintf(stderr, "warning: %s; going on because --mxbuild-path was given\n", problem) + return got, nil + } + return "", fmt.Errorf("%s.\nA package made by another Mendix version than the app it comes from is refused.\n"+ + "hint: run 'mxcli setup mxbuild --version %s' to install the matching toolchain (an mx on PATH is used before it,\n"+ + "so take that one off PATH); to use this mx on purpose, pass --mxbuild-path", problem, projectVersion) +} + +// mxDescription names the mx for the progress line: its path, and its version when known. +func mxDescription(mxPath, mxVersion string) string { + if mxVersion == "" { + return mxPath + } + return fmt.Sprintf("%s (Mendix %s)", mxPath, mxVersion) +} + +// findModuleName matches a module name exactly, then case-insensitively, and +// returns the project's own spelling, which is what mx wants. +func findModuleName(modules []string, name string) (string, bool) { + for _, m := range modules { + if m == name { + return m, true + } + } + for _, m := range modules { + if strings.EqualFold(m, name) { + return m, true + } + } + return "", false +} + +// nameUserPaths rewrites mx's output to name the files the user has: the +// temporary copy becomes the project, the staging directory the output directory. +func nameUserPaths(text, workDir, projectDir, staging, outDir string) string { + return strings.NewReplacer(workDir, projectDir, staging, outDir).Replace(text) +} + +func withMxOutput(output string) string { + if output == "" { + return "" + } + return "\nmx printed:\n" + output +} + +// mxExitError turns the documented exit codes of 'mx create-module-package' +// into messages that say what to change. +func mxExitError(code int, module, output string) error { + var msg string + switch code { + case 1: + msg = "mx could not use the project file (exit 1): it is not a usable Mendix project, or an option was rejected (a --exclude pattern mx cannot parse fails this way)" + case 2: + msg = "mx could not determine the output folder (exit 2)" + case 3: + msg = fmt.Sprintf("mx rejected the module name %q (exit 3)", module) + case 4: + msg = "mx reported an unknown export error (exit 4)" + default: + msg = fmt.Sprintf("mx create-module-package failed (exit status %d)", code) + } + return errors.New(msg + withMxOutput(output)) +} + +// packageModuleRoots are the package folders that hold one sub-folder per +// module. Only resources/ was observed leaking another module's files into a +// package (11.12.5); javasource/ and themesource/ are checked as well, in case an +// mx version exports them. Mendix writes those two folder names in lower case. +var packageModuleRoots = map[string]bool{"resources": true, "javasource": true, "themesource": true} + +// readPackageEntries lists the files in the package, folder entries left out, +// with '/' as the separator. +func readPackageEntries(mpkPath string) ([]string, error) { + zr, err := zip.OpenReader(mpkPath) + if err != nil { + return nil, err + } + defer zr.Close() + var names []string + for _, f := range zr.File { + name := strings.ReplaceAll(f.Name, `\`, "/") + if strings.HasSuffix(name, "/") { + continue + } + names = append(names, name) + } + return names, nil +} + +// classifyPackageEntries splits entries into those under another module's +// folder — resources//…, javasource//…, themesource//… with X not the +// exported module, compared case-insensitively — and everything else. Both are +// sorted; foreign is never nil. +func classifyPackageEntries(names []string, module string) (foreign, own []string) { + foreign = []string{} + for _, name := range names { + parts := strings.Split(name, "/") + if len(parts) >= 3 && packageModuleRoots[parts[0]] && !strings.EqualFold(parts[1], module) { + foreign = append(foreign, name) + } else { + own = append(own, name) + } + } + sort.Strings(foreign) + sort.Strings(own) + return foreign, own +} + +var nonWordChar = regexp.MustCompile(`[^A-Za-z0-9_]`) + +// excludePatternFor builds the one --exclude expression that drops the foreign +// entries, in the form mx accepts everywhere: anchored, with '.' for the folder +// separator and for any character that is not a letter, digit or underscore — +// never a character class or a backslash. Because '.' matches any character, a +// folder whose pattern would also match one of the module's own files is left +// out of the pattern and returned in ambiguous instead. +func excludePatternFor(foreign, own []string) (pattern string, ambiguous []string) { + groups := map[string][]string{} // "resources/EventLog" -> its entries + for _, name := range foreign { + parts := strings.Split(name, "/") + key := parts[0] + "/" + parts[1] + groups[key] = append(groups[key], name) + } + keys := make([]string, 0, len(groups)) + for k := range groups { + keys = append(keys, k) + } + sort.Strings(keys) + + var patterns []string + for _, key := range keys { + p := "^" + nonWordChar.ReplaceAllString(key, ".") + "." + re := regexp.MustCompile(p) + clash := false + for _, o := range own { + if re.MatchString(o) { + clash = true + break + } + } + if clash { + ambiguous = append(ambiguous, groups[key]...) + continue + } + patterns = append(patterns, p) + } + return strings.Join(patterns, "|"), ambiguous +} + +// shellQuote quotes s as one word for a POSIX shell: in single quotes, with each +// single quote inside closing the quote, adding an escaped quote, and reopening it. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// warnForeignEntries tells the user the package holds files of other modules and +// how to leave them out. It is a warning, not an error: the package is valid, +// just larger than the module. +func warnForeignEntries(w io.Writer, module string, foreign, own []string, existingExclude string) { + const maxListed = 20 + fmt.Fprintf(w, "warning: the package holds %d file(s) that belong to other modules, not to %s:\n", len(foreign), module) + for i, name := range foreign { + if i == maxListed { + fmt.Fprintf(w, " ... and %d more\n", len(foreign)-maxListed) + break + } + fmt.Fprintf(w, " %s\n", name) + } + pattern, ambiguous := excludePatternFor(foreign, own) + if pattern != "" { + if existingExclude != "" { + pattern = existingExclude + "|" + pattern + fmt.Fprintln(w, "To leave them out, export again with this --exclude (yours first):") + } else { + fmt.Fprintln(w, "To leave them out, export again with:") + } + fmt.Fprintf(w, " --exclude %s\n", shellQuote(pattern)) + } + if len(ambiguous) > 0 { + fmt.Fprintf(w, "%d of them (e.g. %s) cannot be left out by a pattern that is safe for %s:\n"+ + " it would also match files of the module itself. Remove them from the project's\n"+ + " resources/, javasource/ or themesource/ folder instead.\n", len(ambiguous), ambiguous[0], module) + } +} diff --git a/cmd/mxcli/cmd_marketplace_package_test.go b/cmd/mxcli/cmd_marketplace_package_test.go new file mode 100644 index 0000000000..32a3cfa0c4 --- /dev/null +++ b/cmd/mxcli/cmd_marketplace_package_test.go @@ -0,0 +1,624 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// packageFake replaces the three seams `marketplace package` calls out through — +// the project reader, mx resolution and the mx process — so the command runs +// without Studio Pro. The mx stub records its arguments and, when exit is 0 and +// entries is non-nil, writes a .mpk holding entries into the directory it was +// given with -d. +type packageFake struct { + version string + modules []string + exit int + output string + entries []string + calls [][]string + mx string + + mxVersion string // version of the mx that resolution finds; defaults to version + mxVersionUnknown bool // the version of that mx cannot be told + gotMxbuildPath string // the --mxbuild-path resolution was given + inspectCopy func(workDir string) +} + +func installPackageFake(t *testing.T, f *packageFake) { + t.Helper() + origInfo, origResolve, origMx, origVer := packageProjectInfo, resolveMxForPackage, mxCreateModulePackageCmd, mxBinaryVersion + t.Cleanup(func() { + packageProjectInfo, resolveMxForPackage, mxCreateModulePackageCmd, mxBinaryVersion = origInfo, origResolve, origMx, origVer + }) + if f.version == "" { + f.version = "11.12.5" + } + if f.mxVersion == "" { + f.mxVersion = f.version + } + packageProjectInfo = func(string) (string, []string, error) { return f.version, f.modules, nil } + resolveMxForPackage = func(mxbuildPath, version string) (string, error) { + f.gotMxbuildPath = mxbuildPath + f.mx = "mx-stub@" + version + return f.mx, nil + } + mxBinaryVersion = func(string) string { + if f.mxVersionUnknown { + return "" + } + return f.mxVersion + } + mxCreateModulePackageCmd = func(_ context.Context, _ string, args []string, out io.Writer) (int, error) { + f.calls = append(f.calls, args) + if f.inspectCopy != nil { + f.inspectCopy(filepath.Dir(args[len(args)-2])) // args end with + } + io.WriteString(out, f.output) + if f.exit != 0 { + return f.exit, nil + } + if f.entries != nil { + dir := pkgArgAfter(args, "-d") + module := args[len(args)-1] + pkgWriteZip(t, filepath.Join(dir, module+".mpk"), f.entries) + } + return 0, nil + } +} + +func pkgArgAfter(args []string, flag string) string { + for i, a := range args { + if a == flag && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +func pkgWriteZip(t *testing.T, path string, names []string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + for _, n := range names { + w, err := zw.Create(n) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(n, "/") { + w.Write([]byte("x")) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + f.Close() +} + +// pkgPlaceholderProject writes an .mpr placeholder: the command only copies and +// names it, the reader is faked. +func pkgPlaceholderProject(t *testing.T) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "App.mpr") + if err := os.WriteFile(p, []byte("not a real mpr"), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func runPackageForTest(t *testing.T, opts packageOptions) (stdout, stderr string, res *packageResult, err error) { + t.Helper() + var so, se bytes.Buffer + res, err = runPackage(context.Background(), opts, &so, &se) + return so.String(), se.String(), res, err +} + +var pkgOwnEntries = []string{"project.mpr", "package.xml", "manifest.json", "themesource/pbxexport/web/main.scss"} + +func pkgContainsArg(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +func TestPackage_DefaultArguments(t *testing.T) { + f := &packageFake{modules: []string{"System", "PbxExport"}, entries: pkgOwnEntries} + installPackageFake(t, f) + mpr := pkgPlaceholderProject(t) + out := filepath.Join(t.TempDir(), "new", "dir") // does not exist yet + + _, _, res, err := runPackageForTest(t, packageOptions{ProjectPath: mpr, Module: "pbxexport", OutputDir: out}) + if err != nil { + t.Fatal(err) + } + if len(f.calls) != 1 { + t.Fatalf("mx called %d times, want 1", len(f.calls)) + } + args := f.calls[0] + if args[0] != "create-module-package" { + t.Errorf("args[0] = %q, want create-module-package", args[0]) + } + if !pkgContainsArg(args, "-l") { + t.Errorf("-l (filter required libs) must be the default: %v", args) + } + if pkgContainsArg(args, "-e") || pkgContainsArg(args, "-m") { + t.Errorf("-e/-m must be absent by default: %v", args) + } + if got := args[len(args)-1]; got != "PbxExport" { + t.Errorf("module argument = %q, want the project's spelling PbxExport", got) + } + target := args[len(args)-2] + if target == mpr || filepath.Base(target) != "App.mpr" { + t.Errorf("mx must run on a temporary copy of the project, got target %q (original %q)", target, mpr) + } + if f.mx != "mx-stub@11.12.5" { + t.Errorf("mx resolved as %q, want the project's version", f.mx) + } + want := filepath.Join(out, "PbxExport.mpk") + if res.Path != want { + t.Errorf("Path = %q, want %q", res.Path, want) + } + if _, err := os.Stat(want); err != nil { + t.Errorf("package not at the output dir: %v", err) + } + if res.Files != len(pkgOwnEntries) { + t.Errorf("Files = %d, want %d", res.Files, len(pkgOwnEntries)) + } + if left, _ := filepath.Glob(filepath.Join(out, ".mxcli-package-*")); len(left) != 0 { + t.Errorf("staging directory left behind: %v", left) + } +} + +func TestPackage_FlagsMapToMxOptions(t *testing.T) { + f := &packageFake{modules: []string{"PbxExport"}, entries: pkgOwnEntries} + installPackageFake(t, f) + _, _, _, err := runPackageForTest(t, packageOptions{ + ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir(), + AllLibs: true, Exclude: "^resources.EventLog.|^themesource.foo.", ExcludeManaged: true, + }) + if err != nil { + t.Fatal(err) + } + args := f.calls[0] + if pkgContainsArg(args, "-l") { + t.Errorf("--all-libs must drop -l: %v", args) + } + if got := pkgArgAfter(args, "-e"); got != "^resources.EventLog.|^themesource.foo." { + t.Errorf("-e = %q", got) + } + if !pkgContainsArg(args, "-m") { + t.Errorf("--exclude-managed-dependencies must pass -m: %v", args) + } +} + +func TestPackage_MapsMxExitCodes(t *testing.T) { + cases := []struct { + exit int + want string + }{ + {1, "project file"}, + {2, "output folder"}, + {3, "module name"}, + {4, "unknown export error"}, + {9, "exit status 9"}, + } + for _, tc := range cases { + f := &packageFake{modules: []string{"PbxExport"}, exit: tc.exit, output: "MX SAYS NO"} + installPackageFake(t, f) + _, _, _, err := runPackageForTest(t, packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir()}) + if err == nil { + t.Fatalf("exit %d: no error", tc.exit) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("exit %d: error %q does not mention %q", tc.exit, err, tc.want) + } + if !strings.Contains(err.Error(), "MX SAYS NO") { + t.Errorf("exit %d: error must carry mx's output: %q", tc.exit, err) + } + } +} + +func TestPackage_UnknownModuleListsModulesAndSkipsMx(t *testing.T) { + f := &packageFake{modules: []string{"System", "Zeta", "Alpha"}} + installPackageFake(t, f) + _, _, _, err := runPackageForTest(t, packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "Typo", OutputDir: t.TempDir()}) + if err == nil { + t.Fatal("want an error for an unknown module") + } + for _, want := range []string{`"Typo"`, "Alpha, System, Zeta"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q lacks %q", err, want) + } + } + if len(f.calls) != 0 { + t.Errorf("mx must not run for an unknown module, ran %d time(s)", len(f.calls)) + } +} + +func TestPackage_ExitZeroWithoutPackageIsAnError(t *testing.T) { + // A defensive check: the mx measured (11.12.5) exits 1 for a pattern it cannot + // parse, but a build that exited 0 without a package must not pass for success. + f := &packageFake{modules: []string{"PbxExport"}, output: "Usage: mx create-module-package"} + installPackageFake(t, f) + _, _, _, err := runPackageForTest(t, packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "wrote no .mpk") || !strings.Contains(err.Error(), "Usage: mx create-module-package") { + t.Fatalf("want a 'wrote no .mpk' error carrying mx's output, got %v", err) + } +} + +func TestPackage_WarnsAboutForeignEntries(t *testing.T) { + f := &packageFake{modules: []string{"PbxExport"}, entries: append([]string{ + "resources/EventLog/x.txt", "resources/EventLog/y.txt", "javasource/other/Foo.java", + }, pkgOwnEntries...)} + installPackageFake(t, f) + stdout, stderr, res, err := runPackageForTest(t, packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir()}) + if err != nil { + t.Fatalf("foreign entries are a warning, not an error: %v", err) + } + if len(res.ForeignEntries) != 3 { + t.Errorf("ForeignEntries = %v", res.ForeignEntries) + } + for _, want := range []string{"resources/EventLog/x.txt", "javasource/other/Foo.java", `--exclude '^javasource.other.|^resources.EventLog.'`} { + if !strings.Contains(stderr, want) { + t.Errorf("stderr lacks %q:\n%s", want, stderr) + } + } + if strings.Contains(stdout, "warning") { + t.Errorf("warnings belong on stderr, stdout was:\n%s", stdout) + } +} + +func TestPackage_JSONShape(t *testing.T) { + f := &packageFake{modules: []string{"PbxExport"}, entries: append([]string{"resources/EventLog/x.txt"}, pkgOwnEntries...)} + installPackageFake(t, f) + var so bytes.Buffer + res, err := runPackage(context.Background(), packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir(), JSON: true}, &so, io.Discard) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(so.Bytes(), &got); err != nil { + t.Fatalf("stdout is not a single JSON document: %v\n%s", err, so.String()) + } + if len(got) != 4 { + t.Errorf("keys = %v, want path, sizeBytes, files, foreignEntries", got) + } + if got["path"] != res.Path || got["files"] != float64(5) || got["sizeBytes"].(float64) <= 0 { + t.Errorf("unexpected JSON: %v", got) + } + if fe, _ := got["foreignEntries"].([]any); len(fe) != 1 || fe[0] != "resources/EventLog/x.txt" { + t.Errorf("foreignEntries = %v", got["foreignEntries"]) + } +} + +func TestPackage_JSONForeignEntriesIsEmptyArrayNotNull(t *testing.T) { + f := &packageFake{modules: []string{"PbxExport"}, entries: pkgOwnEntries} + installPackageFake(t, f) + var so bytes.Buffer + if _, err := runPackage(context.Background(), packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir(), JSON: true}, &so, io.Discard); err != nil { + t.Fatal(err) + } + if !strings.Contains(so.String(), `"foreignEntries": []`) { + t.Errorf("want an empty array, got:\n%s", so.String()) + } +} + +func TestPackage_ReplacesAnExistingPackage(t *testing.T) { + f := &packageFake{modules: []string{"PbxExport"}, entries: pkgOwnEntries} + installPackageFake(t, f) + out := t.TempDir() + if err := os.WriteFile(filepath.Join(out, "PbxExport.mpk"), []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + res, err := runPackage(context.Background(), packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: out}, io.Discard, io.Discard) + if err != nil { + t.Fatal(err) + } + if res.Files != len(pkgOwnEntries) { + t.Errorf("reported a stale package: %+v", res) + } +} + +func TestPackage_StaleFileIsNotReportedWhenMxWritesNothing(t *testing.T) { + f := &packageFake{modules: []string{"PbxExport"}} // exit 0, writes nothing + installPackageFake(t, f) + out := t.TempDir() + pkgWriteZip(t, filepath.Join(out, "PbxExport.mpk"), pkgOwnEntries) + _, _, _, err := runPackageForTest(t, packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: out}) + if err == nil { + t.Fatal("an old package in the output dir must not pass for a fresh export") + } +} + +func TestClassifyPackageEntries_ForeignEntries(t *testing.T) { + p := filepath.Join(t.TempDir(), "m.mpk") + pkgWriteZip(t, p, []string{ + "project.mpr", "package.xml", + "resources/", "resources/loose.txt", // a directory entry; a file directly under resources/ belongs to no module + "resources/PbxExport/own.txt", "resources/pbxexport/own2.txt", // own, in any case + "resources/EventLog/a.txt", "resources\\Win\\b.txt", // backslash separators are normalised + "javasource/pbxexport/Own.java", "javasource/other/Foo.java", + "themesource/pbxexport/web/main.scss", "themesource/atlas_core/web/x.scss", + "userlib/foo.jar", // not attributable to a module: never flagged + }) + names, err := readPackageEntries(p) + if err != nil { + t.Fatal(err) + } + if len(names) != 12 { // 13 entries minus the directory entry + t.Errorf("files = %d, want 12", len(names)) + } + foreign, _ := classifyPackageEntries(names, "PbxExport") + want := []string{"javasource/other/Foo.java", "resources/EventLog/a.txt", "resources/Win/b.txt", "themesource/atlas_core/web/x.scss"} + if !reflect.DeepEqual(foreign, want) { + t.Errorf("foreign = %v\nwant %v", foreign, want) + } +} + +func TestExcludePattern_PlainDotForm(t *testing.T) { + pat, ambiguous := excludePatternFor( + []string{"resources/EventLog/a.txt", "resources/EventLog/b.txt", "themesource/atlas_core/web/x.scss", "resources/My Folder/c.txt"}, + pkgOwnEntries) + if pat != "^resources.EventLog.|^resources.My.Folder.|^themesource.atlas_core." || len(ambiguous) != 0 { + t.Errorf("pattern = %q ambiguous = %v", pat, ambiguous) + } + if strings.ContainsAny(pat, `[]\`) { + t.Errorf("mx rejects character classes and backslashes in -e: %q", pat) + } +} + +func TestExcludePattern_NeverDropsTheModulesOwnFiles(t *testing.T) { + // Exporting EventLog, with a foreign "Event" folder: '^resources.Event.' would + // also match resources/EventLog/..., because '.' stands for any character. + pat, ambiguous := excludePatternFor( + []string{"resources/Event/a.txt"}, + []string{"resources/EventLog/own.txt"}) + if pat != "" || !reflect.DeepEqual(ambiguous, []string{"resources/Event/a.txt"}) { + t.Errorf("pattern = %q ambiguous = %v, want the clash reported and no pattern", pat, ambiguous) + } +} + +func TestPackage_RefusesAnMxOfAnotherVersion(t *testing.T) { + // docker.ResolveMxForVersion prefers an mx on PATH and falls back to any cached + // one, so the mx it returns may belong to another Mendix version than the app. + f := &packageFake{modules: []string{"PbxExport"}, version: "11.12.5", mxVersion: "11.12.1", entries: pkgOwnEntries} + installPackageFake(t, f) + out := t.TempDir() + _, _, _, err := runPackageForTest(t, packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: out}) + if err == nil { + t.Fatal("an mx of Mendix 11.12.1 made a package for a 11.12.5 app without complaint") + } + for _, want := range []string{"11.12.1", "11.12.5", "mxcli setup mxbuild --version 11.12.5", "--mxbuild-path"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should contain %q:\n%v", want, err) + } + } + if len(f.calls) != 0 { + t.Errorf("mx ran %d time(s) although it is the wrong version", len(f.calls)) + } + if left, _ := filepath.Glob(filepath.Join(out, "*")); len(left) != 0 { + t.Errorf("a refused export left %v behind", left) + } +} + +func TestPackage_AcceptsAnMxOfTheProjectsVersion(t *testing.T) { + cases := []struct{ project, mx string }{ + {"11.12.5", "11.12.5"}, + {"10.24.9", "10.24.9.81004"}, // Studio Pro folders carry the build number + {"10.24.9.81004", "10.24.9"}, // and so may the project's stamp + {"11.12.5.1234", "11.12.5.999"}, // same release, other build + } + for _, tc := range cases { + f := &packageFake{modules: []string{"PbxExport"}, version: tc.project, mxVersion: tc.mx, entries: pkgOwnEntries} + installPackageFake(t, f) + _, stderr, _, err := runPackageForTest(t, packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir()}) + if err != nil { + t.Errorf("project %s, mx %s: %v", tc.project, tc.mx, err) + } + if strings.Contains(stderr, "warning") { + t.Errorf("project %s, mx %s: unexpected warning:\n%s", tc.project, tc.mx, stderr) + } + } +} + +func TestPackage_RefusesAnMxWhoseVersionIsUnknown(t *testing.T) { + f := &packageFake{modules: []string{"PbxExport"}, mxVersionUnknown: true, entries: pkgOwnEntries} + installPackageFake(t, f) + _, _, _, err := runPackageForTest(t, packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "--mxbuild-path") || !strings.Contains(err.Error(), "11.12.5") { + t.Fatalf("want a refusal naming the project's version and the override, got %v", err) + } + if len(f.calls) != 0 { + t.Error("mx ran although its version could not be told") + } +} + +func TestPackage_ExplicitMxbuildPathOverridesButWarns(t *testing.T) { + mxbuildDir := t.TempDir() + for _, tc := range []struct { + name string + unknown bool + mx string + want []string + }{ + {"mismatch", false, "11.12.1", []string{"warning", "11.12.1", "11.12.5"}}, + {"unknown", true, "", []string{"warning", "cannot tell", "11.12.5"}}, + } { + f := &packageFake{modules: []string{"PbxExport"}, mxVersion: tc.mx, mxVersionUnknown: tc.unknown, entries: pkgOwnEntries} + installPackageFake(t, f) + stdout, stderr, res, err := runPackageForTest(t, packageOptions{ + ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir(), MxBuildPath: mxbuildDir, JSON: true}) + if err != nil || res == nil { + t.Errorf("%s: an explicit --mxbuild-path is the override, got %v", tc.name, err) + continue + } + if f.gotMxbuildPath != mxbuildDir { + t.Errorf("%s: resolution got --mxbuild-path %q, want %q", tc.name, f.gotMxbuildPath, mxbuildDir) + } + for _, w := range tc.want { + if !strings.Contains(stderr, w) { + t.Errorf("%s: stderr should contain %q:\n%s", tc.name, w, stderr) + } + } + if strings.Contains(stdout, "warning") || !json.Valid([]byte(stdout)) { + t.Errorf("%s: the warning must stay off stdout, which carries the JSON:\n%s", tc.name, stdout) + } + if len(f.calls) != 1 { + t.Errorf("%s: mx should have run once, ran %d times", tc.name, len(f.calls)) + } + } +} + +func TestPackage_ExplicitMxbuildPathThatDoesNotExistIsAnError(t *testing.T) { + // ResolveMxForVersion silently falls back to PATH when the explicit path is + // bad; the override must not turn a typo into "any mx on PATH, warned about". + f := &packageFake{modules: []string{"PbxExport"}, entries: pkgOwnEntries} + installPackageFake(t, f) + _, _, _, err := runPackageForTest(t, packageOptions{ + ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir(), + MxBuildPath: filepath.Join(t.TempDir(), "no-such-mendix")}) + if err == nil || !strings.Contains(err.Error(), "--mxbuild-path") { + t.Fatalf("want an error naming --mxbuild-path, got %v", err) + } + if len(f.calls) != 0 { + t.Error("mx ran") + } +} + +func TestPackage_MxOutputOnSuccessGoesToStderr(t *testing.T) { + f := &packageFake{modules: []string{"PbxExport"}, output: "MX WARNING: something to read\n", entries: pkgOwnEntries} + installPackageFake(t, f) + stdout, stderr, _, err := runPackageForTest(t, packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir(), JSON: true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stderr, "MX WARNING: something to read") { + t.Errorf("mx's output never reached the user:\nstderr: %q", stderr) + } + if strings.Contains(stdout, "MX WARNING") || !json.Valid([]byte(stdout)) { + t.Errorf("mx's output must not go to stdout, which carries the JSON:\n%s", stdout) + } +} + +func TestPackage_NoMxOutputMeansNothingOnStderr(t *testing.T) { + f := &packageFake{modules: []string{"PbxExport"}, entries: pkgOwnEntries} + installPackageFake(t, f) + _, stderr, _, err := runPackageForTest(t, packageOptions{ProjectPath: pkgPlaceholderProject(t), Module: "PbxExport", OutputDir: t.TempDir()}) + if err != nil || stderr != "" { + t.Errorf("err = %v, stderr = %q", err, stderr) + } +} + +// TestMxCreateModulePackageCmd_StopsWhenContextIsCancelled runs the real process +// runner (not the seam) on this test binary acting as a child that sleeps. +func TestMxCreateModulePackageCmd_StopsWhenContextIsCancelled(t *testing.T) { + if os.Getenv("MXCLI_TEST_SLEEP_CHILD") == "1" { + time.Sleep(time.Minute) + return + } + t.Setenv("MXCLI_TEST_SLEEP_CHILD", "1") + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + start := time.Now() + var out bytes.Buffer + code, err := mxCreateModulePackageCmd(ctx, os.Args[0], []string{"-test.run=^TestMxCreateModulePackageCmd_StopsWhenContextIsCancelled$"}, &out) + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("code = %d, err = %v, want the context's error", code, err) + } + if time.Since(start) > 20*time.Second { + t.Errorf("mx was not stopped by the context (took %s)", time.Since(start)) + } +} + +func TestShellQuote(t *testing.T) { + cases := map[string]string{ + `^resources.EventLog.`: `'^resources.EventLog.'`, + ``: `''`, + `it's`: `'it'\''s'`, + `'a'|'b'`: `''\''a'\''|'\''b'\'''`, + } + for in, want := range cases { + if got := shellQuote(in); got != want { + t.Errorf("shellQuote(%q) = %s, want %s", in, got, want) + } + } +} + +func TestWarnForeignEntries_QuotesAUsersQuoteSafelyForPasting(t *testing.T) { + var w bytes.Buffer + warnForeignEntries(&w, "PbxExport", []string{"resources/EventLog/a.txt"}, pkgOwnEntries, `^resources.it's`) + want := `--exclude '^resources.it'\''s|^resources.EventLog.'` + if !strings.Contains(w.String(), want) { + t.Errorf("want %s in:\n%s", want, w.String()) + } +} + +func TestPackage_OutputFolderInsideTheProjectIsNotCopied(t *testing.T) { + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + os.WriteFile(mpr, []byte("not a real mpr"), 0o644) + os.MkdirAll(filepath.Join(dir, "dist"), 0o755) + os.WriteFile(filepath.Join(dir, "dist", "Old.mpk"), []byte("earlier package"), 0o644) + os.MkdirAll(filepath.Join(dir, "widgets"), 0o755) + os.WriteFile(filepath.Join(dir, "widgets", "w.mpk"), []byte("a widget, mx needs it"), 0o644) + + var distCopied, widgetCopied bool + f := &packageFake{modules: []string{"PbxExport"}, entries: pkgOwnEntries, inspectCopy: func(work string) { + _, e1 := os.Stat(filepath.Join(work, "dist", "Old.mpk")) + _, e2 := os.Stat(filepath.Join(work, "widgets", "w.mpk")) + distCopied, widgetCopied = e1 == nil, e2 == nil + }} + installPackageFake(t, f) + res, err := runPackage(context.Background(), packageOptions{ProjectPath: mpr, Module: "PbxExport", OutputDir: filepath.Join(dir, "dist")}, io.Discard, io.Discard) + if err != nil { + t.Fatal(err) + } + if distCopied { + t.Error("the output folder's earlier packages were copied along with the project") + } + if !widgetCopied { + t.Error("the rest of the project must still be copied") + } + if _, err := os.Stat(res.Path); err != nil { + t.Errorf("package missing: %v", err) + } +} + +func TestPackage_OutputFolderIsTheProjectFolderStillCopiesTheProject(t *testing.T) { + // "-o ." run from inside the project: skipping the output folder must never + // skip the project itself. + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + os.WriteFile(mpr, []byte("not a real mpr"), 0o644) + var mprCopied bool + f := &packageFake{modules: []string{"PbxExport"}, entries: pkgOwnEntries, inspectCopy: func(work string) { + _, err := os.Stat(filepath.Join(work, "App.mpr")) + mprCopied = err == nil + }} + installPackageFake(t, f) + if _, err := runPackage(context.Background(), packageOptions{ProjectPath: mpr, Module: "PbxExport", OutputDir: dir}, io.Discard, io.Discard); err != nil { + t.Fatal(err) + } + if !mprCopied { + t.Error("the project was not copied when the output folder is the project folder") + } +} diff --git a/cmd/mxcli/cmd_query.go b/cmd/mxcli/cmd_query.go index 140469341d..1244df22a7 100644 --- a/cmd/mxcli/cmd_query.go +++ b/cmd/mxcli/cmd_query.go @@ -231,18 +231,15 @@ Searches across string literals (captions, labels, messages), the documentation of project elements, and MDL source definitions. Requires at least a FULL catalog build (done automatically). -What is searched, besides captions, labels and messages, is the documentation of: - entities; attributes; associations; enumerations; constants; pages, snippets, - layouts, building blocks and page templates; microflows, nanoflows, rules and - workflows; Java and JavaScript actions; microflow and Java action parameters; - consumed and published REST services and business event services; import and - export mappings, JSON structures and data transformers; scheduled events, task - queues, regular expressions, image and icon collections and menus; agents, AI - models, knowledge bases and consumed MCP services; and a module's domain model - documentation. That is every document type whose catalog table carries a - documentation text. - Not searched: documentation of an enumeration value (Mendix has no such - field) and the summary text of a remote service's contract. +Besides captions, labels and messages, it searches the documentation of every +element that has documentation (entities, attributes, constants, pages, +microflows, Java actions, REST services, ... and a module's domain model +documentation). The full list is the catalog tables with a Description or +Documentation column: + mxcli -p app.mpr -c "list catalog tables" + mxcli -p app.mpr -c "describe CATALOG.entities" +Not searched: the documentation of an enumeration value (Mendix has no such +field) and the summary text of a remote service's contract. A hit names the element (qualified name, type) and the field: the context column says "documentation" for a documentation hit. @@ -263,12 +260,18 @@ Gating on a word: index cannot run, or any other error, exits 2 so that 1 always means "something matched". +Marketplace modules: + --exclude-marketplace + leave out hits inside modules downloaded from the + Marketplace. Off by default: every module is searched. + Examples: mxcli search -p app.mpr "validation" mxcli search -p app.mpr "Customer" --json | jq '.[].qualifiedName' mxcli search -p app.mpr "Customer" --format names mxcli search -p app.mpr "error" -q --format names | xargs -I {} mxcli describe -p app.mpr microflow {} mxcli search -p app.mpr "TODO" --fail-on-match + mxcli search -p app.mpr "TODO" --exclude-marketplace `, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { @@ -276,6 +279,7 @@ Examples: format := resolveFormat(cmd, "table") quiet, _ := cmd.Flags().GetBool("quiet") failOnMatch, _ := cmd.Flags().GetBool("fail-on-match") + excludeMarketplace, _ := cmd.Flags().GetBool("exclude-marketplace") // Under --fail-on-match, exit status 1 means "something matched", so a // search that could not run says so with 2 rather than with 1. @@ -315,6 +319,7 @@ Examples: // Execute search with format option query := strings.ReplaceAll(args[0], "'", "''") hits, err := exec.SearchWith(query, format, executor.SearchOptions{ + ExcludeMarketplace: excludeMarketplace, // A gate must not read "the index refused the query" as "no match". Strict: failOnMatch, }) diff --git a/cmd/mxcli/docker/check_copy.go b/cmd/mxcli/docker/check_copy.go index b76d70c809..bc138c9a13 100644 --- a/cmd/mxcli/docker/check_copy.go +++ b/cmd/mxcli/docker/check_copy.go @@ -27,7 +27,7 @@ import ( // Only what mx reads is copied: build output, caches and VCS metadata are skipped // (copyProjectTree), so the cost is the model plus the widget, theme and source // folders. cleanup removes the copy; it is never nil and safe to defer. -func copyProjectToTemp(mprPath string) (workMpr string, cleanup func(), err error) { +func copyProjectToTemp(mprPath string, skip ...string) (workMpr string, cleanup func(), err error) { cleanup = func() {} abs, err := filepath.Abs(mprPath) if err != nil { @@ -46,7 +46,7 @@ func copyProjectToTemp(mprPath string) (workMpr string, cleanup func(), err erro projectDir := filepath.Dir(abs) dst := filepath.Join(tmp, filepath.Base(projectDir)) - if err := copyProjectTree(projectDir, dst); err != nil { + if err := copyProjectTree(projectDir, dst, skip...); err != nil { cleanup() return "", func() {}, err } @@ -73,10 +73,18 @@ var checkCopySkipAnywhere = map[string]bool{ "node_modules": true, } -// copyProjectTree copies src to dst, skipping the folders above. A symlinked -// file is copied as a file, so a tool writing to the copy cannot write through -// it into the original; a symlinked folder is recreated as a link. -func copyProjectTree(src, dst string) error { +// copyProjectTree copies src to dst, skipping the folders above and any folder +// named in skip (compared as the same directory, so spelling and case do not +// matter; src itself is never skipped). A symlinked file is copied as a file, so a +// tool writing to the copy cannot write through it into the original; a symlinked +// folder is recreated as a link. +func copyProjectTree(src, dst string, skip ...string) error { + var skipInfos []fs.FileInfo + for _, s := range skip { + if info, err := os.Stat(s); err == nil && info.IsDir() { + skipInfos = append(skipInfos, info) + } + } return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { if err != nil { return err @@ -94,6 +102,15 @@ func copyProjectTree(src, dst string) error { if rel != "." && (checkCopySkipAnywhere[d.Name()] || (!strings.ContainsRune(rel, filepath.Separator) && checkCopySkipRoot[rel])) { return filepath.SkipDir } + if rel != "." && len(skipInfos) > 0 { + if info, err := d.Info(); err == nil { + for _, s := range skipInfos { + if os.SameFile(info, s) { + return filepath.SkipDir + } + } + } + } return os.MkdirAll(target, 0o755) } if d.Type()&fs.ModeSymlink != 0 { @@ -167,3 +184,29 @@ func (r *pathRewriter) rewrite(s string) string { } return s } + +// WithProjectCopy runs fn with the path of a temporary copy of the project that +// holds mprPath, and removes the copy afterwards. It is for callers that run an +// mx command which writes into the project it is given (theme-cache/, +// deployment/sass/, a module's themesource/ folder) but must leave the user's +// project alone — the same copy Check and MxCheckOnCopy use. +// +// Folders named in skip are left out of the copy — an output folder inside the +// project, whose earlier contents mx has no use for. Naming the project's own +// folder skips nothing. +// +// Output naming the copy should be reported against the project: replace +// filepath.Dir(workMpr) with the project's directory. +func WithProjectCopy(mprPath string, fn func(workMpr string) error, skip ...string) error { + abs, err := filepath.Abs(mprPath) + if err != nil { + return err + } + workMpr, cleanup, err := copyProjectToTemp(abs, skip...) + if err != nil { + return fmt.Errorf("copy the project to a temporary directory: %w\n"+ + " set TMPDIR to a disk with room for the project", err) + } + defer cleanup() + return fn(workMpr) +} diff --git a/cmd/mxcli/docker/check_readonly_test.go b/cmd/mxcli/docker/check_readonly_test.go index 14d92f9fed..bb7d0ce91c 100644 --- a/cmd/mxcli/docker/check_readonly_test.go +++ b/cmd/mxcli/docker/check_readonly_test.go @@ -273,3 +273,82 @@ func TestCopyProjectForCheck_MissingProject(t *testing.T) { t.Error("want an error for a project file that does not exist") } } + +// TestWithProjectCopy_WritesStayOnTheCopy: fn gets a copy it may write into, the +// original is untouched, and the copy is gone once fn returns. +func TestWithProjectCopy_WritesStayOnTheCopy(t *testing.T) { + proj := t.TempDir() + mpr := filepath.Join(proj, "App.mpr") + if err := os.WriteFile(mpr, []byte("model"), 0o644); err != nil { + t.Fatal(err) + } + var seen string + err := WithProjectCopy(mpr, func(work string) error { + seen = work + if work == mpr { + t.Error("fn was handed the original project") + } + return os.WriteFile(filepath.Join(filepath.Dir(work), "theme-cache.txt"), []byte("mx wrote this"), 0o644) + }) + if err != nil { + t.Fatalf("WithProjectCopy: %v", err) + } + if _, err := os.Stat(filepath.Join(proj, "theme-cache.txt")); err == nil { + t.Error("a write into the copy reached the original project") + } + if _, err := os.Stat(seen); err == nil { + t.Errorf("the copy %s was not removed", seen) + } + if err := WithProjectCopy(filepath.Join(proj, "nope.mpr"), func(string) error { return nil }); err == nil { + t.Error("want an error for a project file that does not exist") + } +} + +// TestWithProjectCopy_SkipsNamedFoldersButNeverTheProject: a folder inside the +// project that the caller names is left out of the copy; naming the project's own +// folder (an output directory of ".") skips nothing. +func TestWithProjectCopy_SkipsNamedFoldersButNeverTheProject(t *testing.T) { + proj := t.TempDir() + mpr := filepath.Join(proj, "App.mpr") + for p, content := range map[string]string{ + "App.mpr": "model", + "dist/Old.mpk": "earlier package", + "widgets/w.mpk": "a widget", + "userlib/dist/ok.jar": "a deeper folder of the same name", + } { + full := filepath.Join(proj, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + exists := func(work, rel string) bool { + _, err := os.Stat(filepath.Join(filepath.Dir(work), filepath.FromSlash(rel))) + return err == nil + } + + err := WithProjectCopy(mpr, func(work string) error { + if exists(work, "dist/Old.mpk") { + t.Error("the skipped folder was copied") + } + if !exists(work, "widgets/w.mpk") || !exists(work, "userlib/dist/ok.jar") || !exists(work, "App.mpr") { + t.Error("something outside the skipped folder was not copied") + } + return nil + }, filepath.Join(proj, "dist"), filepath.Join(t.TempDir(), "elsewhere")) + if err != nil { + t.Fatal(err) + } + + err = WithProjectCopy(mpr, func(work string) error { + if !exists(work, "App.mpr") || !exists(work, "dist/Old.mpk") { + t.Error("naming the project folder itself must skip nothing") + } + return nil + }, proj) + if err != nil { + t.Fatal(err) + } +} diff --git a/cmd/mxcli/docker/mx_version.go b/cmd/mxcli/docker/mx_version.go new file mode 100644 index 0000000000..16a680be91 --- /dev/null +++ b/cmd/mxcli/docker/mx_version.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "os" + "path/filepath" + "regexp" +) + +// mxDepsVersion finds the version of the mx assembly in the mx.deps.json that +// Studio Pro and the mxbuild download ship beside the binary: the target key is +// "mx/" followed by a "\n" escape (measured on 10.24.9 and 11.12.5). +var mxDepsVersion = regexp.MustCompile(`"mx/(\d+\.\d+\.\d+(?:\.\d+)?)[^"]*"`) + +// MxBinaryVersion reports the Mendix version of the mx binary at mxPath, or "" +// when it cannot be told. +// +// ResolveMxForVersion prefers an mx on PATH over the one for the project's +// version and falls back to any cached one, so the binary it returns is not +// necessarily the version asked for; callers that must not mix versions use this +// to find out. The binary's own mx.deps.json is read first, because it holds for +// any install layout (a PATH entry, a renamed folder). The folder name is the +// fallback — ~/.mxcli/mxbuild//modeler/mx and +// Program Files/Mendix//modeler/mx both carry it — and only when it is a +// dotted version of at least three parts. Symbolic links are followed first. +func MxBinaryVersion(mxPath string) string { + if resolved, err := filepath.EvalSymlinks(mxPath); err == nil { + mxPath = resolved + } + if data, err := os.ReadFile(filepath.Join(filepath.Dir(mxPath), "mx.deps.json")); err == nil { + if m := mxDepsVersion.FindSubmatch(data); m != nil { + return string(m[1]) + } + } + if v := versionFromPath(mxPath); isReleaseVersion(v) { + return v + } + return "" +} + +// SameMendixRelease reports whether two Mendix versions are the same release: +// major, minor and patch equal. The build number a Studio Pro folder or a project +// stamp may add ("10.24.9.81004") is not part of the release. An empty or +// malformed version matches nothing. +func SameMendixRelease(a, b string) bool { + pa, okA := parseVersionParts(a) + pb, okB := parseVersionParts(b) + if !okA || !okB || len(pa) < 3 || len(pb) < 3 { + return false + } + return pa[0] == pb[0] && pa[1] == pb[1] && pa[2] == pb[2] +} + +func isReleaseVersion(v string) bool { + parts, ok := parseVersionParts(v) + return ok && len(parts) >= 3 +} diff --git a/cmd/mxcli/docker/mx_version_test.go b/cmd/mxcli/docker/mx_version_test.go new file mode 100644 index 0000000000..df6c81a778 --- /dev/null +++ b/cmd/mxcli/docker/mx_version_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "os" + "path/filepath" + "testing" +) + +// writeMx makes //modeler/mx(.exe) and, when deps is not empty, the +// mx.deps.json beside it. It returns the path of the binary. +func writeMx(t *testing.T, root, verDir, deps string) string { + t.Helper() + dir := filepath.Join(root, verDir, "modeler") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + bin := filepath.Join(dir, mxBinaryName()) + if err := os.WriteFile(bin, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + if deps != "" { + if err := os.WriteFile(filepath.Join(dir, "mx.deps.json"), []byte(deps), 0o644); err != nil { + t.Fatal(err) + } + } + return bin +} + +// depsFor is the shape of the mx.deps.json Studio Pro ships: the target key names +// the assembly and its version, with a literal "\n" escape after the version +// (measured on 10.24.9 and 11.12.5). +func depsFor(version string) string { + return `{"targets": {".NETCoreApp,Version=v10.0": {"mx/` + version + `\n": {"dependencies": {"Mendix.Common": "9.9.9"}}}}}` +} + +func TestMxBinaryVersion_ReadsMxDepsJSONWhateverTheFolderIsCalled(t *testing.T) { + // A folder named after no version (a PATH install, a renamed copy): the + // deps file still says which Mendix version the binary is. + bin := writeMx(t, t.TempDir(), "tools", depsFor("11.12.5")) + if got := MxBinaryVersion(bin); got != "11.12.5" { + t.Errorf("MxBinaryVersion = %q, want 11.12.5", got) + } +} + +func TestMxBinaryVersion_DepsFileWinsOverTheFolderName(t *testing.T) { + // The folder says 11.6.3, the binary says 11.12.5: believe the binary. + bin := writeMx(t, t.TempDir(), "11.6.3", depsFor("11.12.5")) + if got := MxBinaryVersion(bin); got != "11.12.5" { + t.Errorf("MxBinaryVersion = %q, want 11.12.5", got) + } +} + +func TestMxBinaryVersion_FallsBackToTheVersionFolder(t *testing.T) { + // No deps file (an older Studio Pro): ~/.mxcli/mxbuild//modeler/mx and + // Program Files/Mendix//modeler/mx both name the version. + for _, dir := range []string{"11.6.3", "10.24.9.81004"} { + bin := writeMx(t, t.TempDir(), dir, "") + if got := MxBinaryVersion(bin); got != dir { + t.Errorf("MxBinaryVersion(%s) = %q, want %q", dir, got, dir) + } + } +} + +func TestMxBinaryVersion_UnknownWhenNothingSaysSo(t *testing.T) { + for _, tc := range []struct{ name, dir, deps string }{ + {"no deps file, folder is not a version", "tools", ""}, + {"deps file without an mx entry", "tools", `{"targets": {"x": {"other/1.2.3": {}}}}`}, + {"folder looks like a version but is not a release", "v2", ""}, + {"two-part folder", "11.12", ""}, + } { + bin := writeMx(t, t.TempDir(), tc.dir, tc.deps) + if got := MxBinaryVersion(bin); got != "" { + t.Errorf("%s: MxBinaryVersion = %q, want unknown", tc.name, got) + } + } + if got := MxBinaryVersion(filepath.Join(t.TempDir(), "nowhere", "modeler", "mx")); got != "" { + t.Errorf("a binary that does not exist: %q, want unknown", got) + } +} + +func TestSameMendixRelease(t *testing.T) { + for _, tc := range []struct { + a, b string + want bool + }{ + {"11.12.5", "11.12.5", true}, + {"11.12.5", "11.12.1", false}, + {"11.12.5", "11.6.5", false}, + {"11.12.5", "10.12.5", false}, + {"10.24.9", "10.24.9.81004", true}, // build number is not part of the release + {"10.24.9.81004", "10.24.9.99999", true}, + {"11.12.5", "", false}, + {"", "", false}, // nothing known is never a match + {"11.12", "11.12.5", false}, + {"11.12.x", "11.12.5", false}, + } { + if got := SameMendixRelease(tc.a, tc.b); got != tc.want { + t.Errorf("SameMendixRelease(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} diff --git a/cmd/mxcli/init.go b/cmd/mxcli/init.go index d8b322d6ab..c7e0186174 100644 --- a/cmd/mxcli/init.go +++ b/cmd/mxcli/init.go @@ -648,6 +648,9 @@ excludeModules: # severity: warning # options: # maxObjects: 20 +# QUAL004: # orphaned elements: name prefixes of entry points +# options: +# entry_point_prefixes: [ACT_, SCH_, WS_, REST_, OData_] rules: {} ` diff --git a/cmd/mxcli/main.go b/cmd/mxcli/main.go index 2769527258..7221997c9d 100644 --- a/cmd/mxcli/main.go +++ b/cmd/mxcli/main.go @@ -391,6 +391,7 @@ func init() { searchCmd.Flags().StringP("format", "f", "table", "Output format: table, names (for JSON use --json; \"json\" is accepted as a deprecated alias)") searchCmd.Flags().BoolP("quiet", "q", false, "Suppress connection and status messages (for piping)") searchCmd.Flags().Bool("fail-on-match", false, "Exit 1 when there is at least one hit, 0 when there is none (an error exits 2)") + searchCmd.Flags().Bool("exclude-marketplace", false, "Leave out hits inside modules downloaded from the Marketplace") // Callers/callees command flags callersCmd.Flags().BoolP("transitive", "t", false, "Find transitive (indirect) callers") diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index 9dd16345bb..65fe54fdc6 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -82,9 +82,9 @@ func init() { "non-persistent", "extends", "generalization", "index", "event handler", "before commit", "after commit", }, - Syntax: "CREATE PERSISTENT ENTITY Module.Name (\n Attr: Type [NOT NULL [ERROR MESSAGE 'msg']] [UNIQUE [ERROR MESSAGE 'msg']] [DEFAULT val],\n ...\n)\n[INDEX (attr1, attr2)]\n[ON BEFORE|AFTER CREATE|COMMIT|DELETE|ROLLBACK CALL Module.MF [RAISE ERROR]];\n\n-- An AutoNumber attribute needs its start value: `Nr: AutoNumber DEFAULT 1`.\n-- Without the DEFAULT seed, check reports MDL023 and the build fails CE7247.\n\n-- Documentation: the /** … */ doc comment before the statement, or\n-- ALTER ENTITY Module.Name SET DOCUMENTATION 'text' on an existing entity.\n\nCREATE NON-PERSISTENT ENTITY Module.Name (...);\nCREATE PERSISTENT ENTITY Module.Name EXTENDS Module.Parent (...);\n\n-- INDEX goes AFTER the closing parenthesis, never inside the attribute list.\n-- On an existing entity, either spelling works:\nALTER ENTITY Module.Name ADD INDEX [IF NOT EXISTS] [name] [ON] (attr1 [ASC|DESC], ...);\nCREATE INDEX IdxName ON Module.Name (attr1 [ASC|DESC], ...);\n\n-- Re-runnable script: IF NOT EXISTS skips instead of erroring, and leaves an\n-- existing element untouched (unlike OR MODIFY, which rebuilds it from the\n-- statement and drops any attribute the statement omits).\nCREATE ENTITY IF NOT EXISTS Module.Name (...);\nALTER ENTITY Module.Name ADD ATTRIBUTE IF NOT EXISTS Attr: Type;\nALTER ENTITY Module.Name DROP INDEX IF EXISTS (attr1, ...);", + Syntax: "CREATE PERSISTENT ENTITY Module.Name (\n Attr: Type [NOT NULL [ERROR MESSAGE 'msg']] [UNIQUE [ERROR MESSAGE 'msg']] [DEFAULT val],\n ...\n)\n[INDEX (attr1, attr2)]\n[ON BEFORE|AFTER CREATE|COMMIT|DELETE|ROLLBACK CALL Module.MF [RAISE ERROR]]\n[EXPORT LEVEL API|HIDDEN]; -- also per attribute: `Attr: Type EXPORT LEVEL API`\n\n-- An AutoNumber attribute needs its start value: `Nr: AutoNumber DEFAULT 1`.\n-- Without the DEFAULT seed, check reports MDL023 and the build fails CE7247.\n\n-- Documentation: the /** … */ doc comment before the statement, or\n-- ALTER ENTITY Module.Name SET DOCUMENTATION 'text' on an existing entity.\n\nCREATE NON-PERSISTENT ENTITY Module.Name (...);\nCREATE PERSISTENT ENTITY Module.Name EXTENDS Module.Parent (...);\n\n-- INDEX goes AFTER the closing parenthesis, never inside the attribute list.\n-- On an existing entity, either spelling works:\nALTER ENTITY Module.Name ADD INDEX [IF NOT EXISTS] [name] [ON] (attr1 [ASC|DESC], ...);\nCREATE INDEX IdxName ON Module.Name (attr1 [ASC|DESC], ...);\n\n-- Re-runnable script: IF NOT EXISTS skips instead of erroring, and leaves an\n-- existing element untouched (unlike OR MODIFY, which rebuilds it from the\n-- statement and drops any attribute the statement omits).\nCREATE ENTITY IF NOT EXISTS Module.Name (...);\nALTER ENTITY Module.Name ADD ATTRIBUTE IF NOT EXISTS Attr: Type;\nALTER ENTITY Module.Name DROP INDEX IF EXISTS (attr1, ...);", Example: "mdl 1;\n-- Persistent with constraints and index\nCREATE PERSISTENT ENTITY Shop.Order (\n OrderNumber: String(20) NOT NULL,\n Total: Decimal DEFAULT 0,\n CreatedAt: DateTime\n)\nINDEX (OrderNumber)\nON BEFORE COMMIT CALL Shop.ValidateOrder($currentObject) RAISE ERROR;\n\n-- With generalization\nCREATE PERSISTENT ENTITY Shop.ProductImage EXTENDS System.Image (\n Caption: String(200)\n);", - SeeAlso: []string{"domain-model.entity.alter", "domain-model.entity.attributes"}, + SeeAlso: []string{"domain-model.entity.alter", "domain-model.entity.attributes", "domain-model.export-level"}, }) Register(SyntaxFeature{ @@ -97,7 +97,7 @@ 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 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.", + 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 EXPORT LEVEL API|HIDDEN; -- see domain-model.export-level\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: "mdl 1;\nALTER 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"}, }) @@ -304,9 +304,9 @@ func init() { "reference set", "junction table", "foreign key", "owner default", "owner both", "storage column", "storage table", }, - Syntax: "CREATE [OR MODIFY] ASSOCIATION Module.AssociationName\n FROM Module.FromEntity TO Module.ToEntity\n [TYPE Reference|ReferenceSet]\n [OWNER Default|Both]\n [STORAGE COLUMN|TABLE]\n [ON DELETE CASCADE|RESTRICT|SET NULL [ERROR MESSAGE 'text']];\n-- Documentation: the /** … */ doc comment before the statement\n-- (COMMENT 'text' is its deprecated alias, MDL-DEPR100).\n\nDefaults: TYPE Reference, OWNER Default, STORAGE COLUMN, ON DELETE SET NULL.\nDESCRIBE prints only the clauses that differ, so STORAGE TABLE always prints.\n\nDirection:\n FROM = entity holding the FK (the \"many\" side)\n TO = entity being referenced (the \"one\" side)\n\nTypes:\n Reference = Many-to-one (FK column on FROM table)\n ReferenceSet = Many-to-many (junction table)\n\nOR MODIFY: updates in-place, preserves UUID. Safe to re-run.", + Syntax: "CREATE [OR MODIFY] ASSOCIATION Module.AssociationName\n FROM Module.FromEntity TO Module.ToEntity\n [TYPE Reference|ReferenceSet]\n [OWNER Default|Both]\n [STORAGE COLUMN|TABLE]\n [ON DELETE CASCADE|RESTRICT|SET NULL [ERROR MESSAGE 'text']]\n [EXPORT LEVEL API|HIDDEN];\n-- Documentation: the /** … */ doc comment before the statement\n-- (COMMENT 'text' is its deprecated alias, MDL-DEPR100).\nALTER ASSOCIATION Module.Name SET EXPORT LEVEL API|HIDDEN; -- see domain-model.export-level\n\nDefaults: TYPE Reference, OWNER Default, STORAGE COLUMN, ON DELETE SET NULL.\nDESCRIBE prints only the clauses that differ, so STORAGE TABLE always prints.\n\nDirection:\n FROM = entity holding the FK (the \"many\" side)\n TO = entity being referenced (the \"one\" side)\n\nTypes:\n Reference = Many-to-one (FK column on FROM table)\n ReferenceSet = Many-to-many (junction table)\n\nOR MODIFY: updates in-place, preserves UUID. Safe to re-run.", Example: "mdl 1;\n-- Many-to-one with delete behavior\nCREATE ASSOCIATION Shop.Order_Customer\n FROM Shop.Order TO Shop.Customer\n TYPE Reference\n OWNER Default\n ON DELETE RESTRICT;\n\n-- Many-to-many\nCREATE ASSOCIATION Shop.Product_Tag\n FROM Shop.Product TO Shop.Tag\n TYPE ReferenceSet\n OWNER Both;\n\n-- Idempotent update\nCREATE OR MODIFY ASSOCIATION Shop.Order_Customer\n FROM Shop.Order TO Shop.Customer\n TYPE Reference\n OWNER Default\n ON DELETE CASCADE;", - SeeAlso: []string{"domain-model.association.delete-behavior", "domain-model.entity.create"}, + SeeAlso: []string{"domain-model.association.delete-behavior", "domain-model.entity.create", "domain-model.export-level"}, }) Register(SyntaxFeature{ @@ -396,9 +396,47 @@ func init() { "caption", "list enumerations", "describe enumeration", "drop enumeration", }, - Syntax: "CREATE ENUMERATION Module.Name (\n ValueName 'Display Caption',\n ...\n);\n\nALTER ENUMERATION Module.Name ADD VALUE [IF NOT EXISTS] NewValue [CAPTION 'Display Caption'];\nALTER ENUMERATION Module.Name RENAME VALUE OldName TO NewName;\nALTER ENUMERATION Module.Name MODIFY VALUE ValueName CAPTION 'New Caption';\nALTER ENUMERATION Module.Name DROP VALUE [IF EXISTS] ValueName;\n\nIF NOT EXISTS / IF EXISTS make a script RE-RUNNABLE. Without them the second\nrun errors and exec STOPS THERE, so one already-present value leaves every\nlater statement unapplied. A defensive drop-then-add is not a substitute: the\ndrop fails when the value is absent and the add when it is present.\n\nLIST ENUMERATIONS;\nLIST ENUMERATIONS IN ;\nDESCRIBE ENUMERATION Module.Name;\nDROP ENUMERATION Module.Name;\n\nUsing in entity:\n AttrName: Enumeration(Module.EnumName)\n\nThe System module's enumerations are platform built-ins with no stored\nunit. LIST ENUMERATIONS and DESCRIBE ENUMERATION report them anyway, so\ntheir values can be read instead of guessed at until the build rejects one\nwith CE1613. They are READ-ONLY: DESCRIBE prints them as -- comment lines,\nand CREATE / ALTER / DROP / MOVE naming System is refused.\n mxcli -p app.mpr describe enumeration System.WorkflowActivityType", + Syntax: "CREATE ENUMERATION Module.Name (\n ValueName 'Display Caption',\n ...\n) [FOLDER 'path'] [EXPORT LEVEL API|HIDDEN];\n\nALTER ENUMERATION Module.Name SET EXPORT LEVEL API|HIDDEN; -- see domain-model.export-level\nALTER ENUMERATION Module.Name ADD VALUE [IF NOT EXISTS] NewValue [CAPTION 'Display Caption'];\nALTER ENUMERATION Module.Name RENAME VALUE OldName TO NewName;\nALTER ENUMERATION Module.Name MODIFY VALUE ValueName CAPTION 'New Caption';\nALTER ENUMERATION Module.Name DROP VALUE [IF EXISTS] ValueName;\n\nIF NOT EXISTS / IF EXISTS make a script RE-RUNNABLE. Without them the second\nrun errors and exec STOPS THERE, so one already-present value leaves every\nlater statement unapplied. A defensive drop-then-add is not a substitute: the\ndrop fails when the value is absent and the add when it is present.\n\nLIST ENUMERATIONS;\nLIST ENUMERATIONS IN ;\nDESCRIBE ENUMERATION Module.Name;\nDROP ENUMERATION Module.Name;\n\nUsing in entity:\n AttrName: Enumeration(Module.EnumName)\n\nThe System module's enumerations are platform built-ins with no stored\nunit. LIST ENUMERATIONS and DESCRIBE ENUMERATION report them anyway, so\ntheir values can be read instead of guessed at until the build rejects one\nwith CE1613. They are READ-ONLY: DESCRIBE prints them as -- comment lines,\nand CREATE / ALTER / DROP / MOVE naming System is refused.\n mxcli -p app.mpr describe enumeration System.WorkflowActivityType", Example: "mdl 1;\nCREATE ENUMERATION MyModule.OrderStatus (\n Pending 'Pending Approval',\n Processing 'Being Processed',\n Shipped 'Shipped to Customer'\n);\n\n-- Using in an entity\nCREATE PERSISTENT ENTITY MyModule.Order (\n OrderNumber: String(20) NOT NULL,\n Status: Enumeration(MyModule.OrderStatus)\n);", - SeeAlso: []string{"domain-model.enumeration", "domain-model.entity.attributes"}, + SeeAlso: []string{"domain-model.enumeration", "domain-model.entity.attributes", "domain-model.export-level"}, + }) + + Register(SyntaxFeature{ + Path: "domain-model.export-level", + Summary: "EXPORT LEVEL API|HIDDEN on entities, attributes, associations and enumerations (Protected / add-on modules)", + Keywords: []string{ + "export level", "usable", "hidden", "api", "protected module", "add-on module", + "addon", "CE5400", "CE1406", "public surface", + }, + Syntax: "CREATE PERSISTENT ENTITY Module.E (\n Attr: Type [constraints] [EXPORT LEVEL API|HIDDEN],\n ...\n) [EXPORT LEVEL API|HIDDEN];\n" + + "CREATE ASSOCIATION Module.A FROM Module.X TO Module.Y [...] [EXPORT LEVEL API|HIDDEN];\n" + + "CREATE ENUMERATION Module.K (...) [EXPORT LEVEL API|HIDDEN];\n\n" + + "ALTER ENTITY Module.E SET EXPORT LEVEL API|HIDDEN;\n" + + "ALTER ENTITY Module.E MODIFY ATTRIBUTE Attr: Type EXPORT LEVEL API|HIDDEN;\n" + + "ALTER ASSOCIATION Module.A SET EXPORT LEVEL API|HIDDEN;\n" + + "ALTER ENUMERATION Module.K SET EXPORT LEVEL API|HIDDEN;\n\n" + + "API is Studio Pro's \"Usable\"; HIDDEN is the default. The level only matters\n" + + "in a Protected (add-on) module — `describe module` prints `-- export level:\n" + + "Protected …` and `list modules` says `Protected AddOn` — where mxbuild refuses:\n" + + " CE5400 an `export level api` microflow whose parameter or return type is a\n" + + " Hidden entity or enumeration of that module;\n" + + " CE1406 another module (or the navigation) using a Hidden entity, attribute,\n" + + " association or microflow of it.\n" + + "In an ordinary module mxbuild accepts either level. `check -p --references`\n" + + "reports both, a flow's attribute and association uses included (change /\n" + + "create members, association retrieves, XPath, `$v/…` expressions).\n\n" + + "An ABSENT clause keeps the stored level, so re-running describe output or a\n" + + "`create or modify` that says nothing about it never hides a usable element.\n" + + "`describe` prints `export level api` and omits the default. Needs Mendix 9.3+.", + Example: "mdl 1;\n-- The flow is part of the add-on's API, so what it exposes must be too\n" + + "ALTER ENTITY MyAddOn.Request SET EXPORT LEVEL API;\n" + + "ALTER ENUMERATION MyAddOn.Kind SET EXPORT LEVEL API;\n" + + "CREATE OR MODIFY MICROFLOW MyAddOn.API_Request_Post ($body: MyAddOn.Request, $k: ENUM MyAddOn.Kind)\n" + + "RETURNS Boolean EXPORT LEVEL API\nBEGIN\n RETURN true;\nEND;\n\n" + + "-- Members another module reads or traverses\n" + + "CREATE PERSISTENT ENTITY MyAddOn.Item (\n Name: String(50) EXPORT LEVEL API,\n Internal: String(50)\n) EXPORT LEVEL API;\n" + + "CREATE ASSOCIATION MyAddOn.Item_Request FROM MyAddOn.Item TO MyAddOn.Request EXPORT LEVEL API;", + SeeAlso: []string{"domain-model.entity.create", "domain-model.association.create", "domain-model.enumeration.create", "microflow"}, }) // --- Constant --- diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index b17148de70..1fae5af288 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -348,7 +348,7 @@ func init() { "basic authentication", "active session", "authentication microflow", "authentication parameter", "header parameter", "api key", "token", }, - Syntax: "CREATE [OR MODIFY] PUBLISHED REST SERVICE Module.Name (\n Path: 'rest/api/v1',\n Version: '1.0.0',\n ServiceName: 'My API',\n [Authentication: none | ( basic, session, microflow Module.Authenticate [( $Param: query|header ['HttpName'] [description '...'], ... )] )]\n)\n{\n RESOURCE 'name' {\n GET '' MICROFLOW Module.GetAll;\n GET '{id}' MICROFLOW Module.GetById;\n POST '' MICROFLOW Module.Create\n [IMPORT MAPPING Module.IMM] [EXPORT MAPPING Module.EMM]\n [COMMIT Yes | YesWithoutEvents | No]; -- no COMMIT clause: Yes\n }\n};\n\n" + + Syntax: "[/** documentation */]\nCREATE [OR MODIFY] PUBLISHED REST SERVICE Module.Name (\n Path: 'rest/api/v1',\n Version: '1.0.0',\n ServiceName: 'My API',\n [Authentication: none | ( basic, session, microflow Module.Authenticate [( $Param: query|header ['HttpName'] [description '...'], ... )] )]\n)\n{\n RESOURCE 'name' {\n GET '' MICROFLOW Module.GetAll;\n GET '{id}' MICROFLOW Module.GetById;\n POST '' MICROFLOW Module.Create\n [IMPORT MAPPING Module.IMM] [EXPORT MAPPING Module.EMM]\n [COMMIT Yes | YesWithoutEvents | No]; -- no COMMIT clause: Yes\n }\n};\n\n" + "-- Operation parameters come from the microflow, as Studio Pro derives them:\n" + "-- a parameter named in the path ('{id}') -> path parameter\n" + "-- an object or a list -> the body\n" + @@ -374,6 +374,9 @@ func init() { "-- list only when it differs from that default.\n" + "-- With authentication on, grant at least one module role (CE0338);\n" + "-- custom authentication also needs app security on (CE6600).\n\n" + + "-- Documentation: the /** ... */ doc comment above CREATE, the service's own\n" + + "-- documentation (not the OpenAPI description, which MDL keeps unchanged).\n" + + "-- On OR MODIFY no comment keeps the stored text, /** */ clears it.\n\n" + "ALTER PUBLISHED REST SERVICE Module.Name SET ( Version: '2.0.0', Authentication: (session) );\nALTER PUBLISHED REST SERVICE Module.Name SET Version = '2.0.0';\nALTER PUBLISHED REST SERVICE Module.Name ADD RESOURCE 'items' { ... };\nALTER PUBLISHED REST SERVICE Module.Name DROP RESOURCE 'legacy';\nDROP PUBLISHED REST SERVICE Module.Name;", Example: "mdl 1;\nCREATE PUBLISHED REST SERVICE Module.OrderAPI (\n Path: 'rest/orders/v1',\n Version: '1.0.0',\n ServiceName: 'Order API',\n Authentication: (basic, session)\n)\n{\n RESOURCE 'orders' {\n GET '' MICROFLOW Module.GetAllOrders;\n GET '{id}' MICROFLOW Module.GetOrderById;\n POST '' MICROFLOW Module.CreateOrder;\n DELETE '{id}' MICROFLOW Module.DeleteOrder;\n }\n};\n\nGRANT ACCESS ON PUBLISHED REST SERVICE Module.OrderAPI\n TO Module.User, Module.Admin;", SeeAlso: []string{"rest", "rest.consumed"}, @@ -646,8 +649,8 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "create java action", "or modify java action", "type parameter", "entity parameter", "exposed as", "returns", "generics", "drop java action", }, - Syntax: "CREATE [OR MODIFY] JAVA ACTION Module.Name[] [FOLDER 'path'](\n Param: Type [NOT NULL],\n EntityType: ENTITY NOT NULL,\n Obj: pEntity\n) RETURNS ReturnType\n[EXPOSED AS 'Label' IN 'Category'\n [ICON 'icon.png'] [ICON DARK 'icon-dark.png']\n [IMAGE 'image.png'] [IMAGE DARK 'image-dark.png']]\n[NOT EXPOSED]\nAS $$\n// Java code — AS $$ ... $$ is mandatory, cannot be omitted\n$$;\n\nOR MODIFY: updates signature/body in-place, preserves UUID.\n\nEXPOSED AS puts the action in Studio Pro's toolbox. The icon is a 64x64 PNG\nand the image a 256x192 PNG, read from disk relative to the .mdl file's own directory.\nAn OMITTED clause preserves what is stored — including bitmaps set in Studio\nPro — so removing an entry is NOT EXPOSED, and clearing one bitmap is\nDROP ICON / DROP IMAGE [DARK].\n\nType parameters: an ENTITY parameter declares T. A type parameter no\nENTITY parameter names is declared in the header, Module.Name; an\nunqualified type that is neither is refused (MDL-JA01), since it would be\nwritten as an entity with no module (CE1613).", - Example: "mdl 1;\nCREATE JAVA ACTION Utils.FormatCurrency(\n Amount: Decimal NOT NULL\n) RETURNS String\nEXPOSED AS 'Format Currency' IN 'Formatting'\n ICON 'assets/currency-64.png'\n IMAGE 'assets/currency-256.png'\nAS $$\nreturn String.format(\"%.2f\", Amount);\n$$;\n\n-- Generic entity validator with type parameter\nCREATE JAVA ACTION Utils.IsValid(\n EntityType: ENTITY NOT NULL,\n Obj: pEntity NOT NULL\n) RETURNS Boolean\nAS $$\nreturn Obj != null;\n$$;\n\n-- Type parameter with no entity selector: the argument's entity binds it\nCREATE JAVA ACTION Utils.Describe(\n Obj: T NOT NULL\n) RETURNS String\nAS $$\nreturn Obj.toString();\n$$;\n\n-- Idempotent update (preserves UUID)\nCREATE OR MODIFY JAVA ACTION Utils.FormatCurrency(\n Amount: Decimal NOT NULL,\n Decimals: Integer NOT NULL\n) RETURNS String\nAS $$\nreturn String.format(\"%.\" + Decimals + \"f\", Amount);\n$$;", + Syntax: "CREATE [OR MODIFY] JAVA ACTION Module.Name[] [FOLDER 'path'](\n [/** description */] Param: Type [NOT NULL],\n EntityType: ENTITY NOT NULL,\n Obj: pEntity\n) RETURNS ReturnType [AS $ReturnName]\n[EXPOSED AS 'Label' IN 'Category'\n [ICON 'icon.png'] [ICON DARK 'icon-dark.png']\n [IMAGE 'image.png'] [IMAGE DARK 'image-dark.png']]\n[NOT EXPOSED]\nAS $$\n// Java code — AS $$ ... $$ is mandatory, cannot be omitted\n$$;\n\nOR MODIFY: updates signature/body in-place, preserves UUID.\n\nEXPOSED AS puts the action in Studio Pro's toolbox. The icon is a 64x64 PNG\nand the image a 256x192 PNG, read from disk relative to the .mdl file's own directory.\nAn OMITTED clause preserves what is stored — including bitmaps set in Studio\nPro — so removing an entry is NOT EXPOSED, and clearing one bitmap is\nDROP ICON / DROP IMAGE [DARK].\n\nA parameter's description is a /** ... */ doc comment before it, as on an\nentity attribute; AS $ReturnName names the return value (the default name of\nthe variable a microflow call stores it in), as on a microflow. Leaving either\nout keeps what is stored; a new action gets no description and ReturnValueName.\n\nType parameters: an ENTITY parameter declares T. A type parameter no\nENTITY parameter names is declared in the header, Module.Name; an\nunqualified type that is neither is refused (MDL-JA01), since it would be\nwritten as an entity with no module (CE1613).", + Example: "mdl 1;\nCREATE JAVA ACTION Utils.FormatCurrency(\n /** The amount to format. */\n Amount: Decimal NOT NULL\n) RETURNS String AS $FormattedAmount\nEXPOSED AS 'Format Currency' IN 'Formatting'\n ICON 'assets/currency-64.png'\n IMAGE 'assets/currency-256.png'\nAS $$\nreturn String.format(\"%.2f\", Amount);\n$$;\n\n-- Generic entity validator with type parameter\nCREATE JAVA ACTION Utils.IsValid(\n EntityType: ENTITY NOT NULL,\n Obj: pEntity NOT NULL\n) RETURNS Boolean\nAS $$\nreturn Obj != null;\n$$;\n\n-- Type parameter with no entity selector: the argument's entity binds it\nCREATE JAVA ACTION Utils.Describe(\n Obj: T NOT NULL\n) RETURNS String\nAS $$\nreturn Obj.toString();\n$$;\n\n-- Idempotent update (preserves UUID)\nCREATE OR MODIFY JAVA ACTION Utils.FormatCurrency(\n Amount: Decimal NOT NULL,\n Decimals: Integer NOT NULL\n) RETURNS String\nAS $$\nreturn String.format(\"%.\" + Decimals + \"f\", Amount);\n$$;", SeeAlso: []string{"java-action", "javascript-action"}, }) @@ -658,7 +661,7 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "javascript action", "javascript", "js action", "call javascript", "platform", "exposed as", "javascriptaction", }, - Syntax: "LIST JAVASCRIPT ACTIONS [IN Module];\nDESCRIBE JAVASCRIPT ACTION Module.Name;\nCREATE [OR MODIFY] JAVASCRIPT ACTION Module.Name [FOLDER 'path'](...) RETURNS Type [PLATFORM Web|Native|Hybrid|All] AS $$ ... $$;\nDROP JAVASCRIPT ACTION Module.Name;\n\nWrites the unit plus javascriptsource//actions/.js. AS $$ ... $$ is mandatory. PLATFORM defaults to Web. The body sees each parameter with its first character lower-cased (ReadOnly is readOnly, URL is uRL), as mxbuild generates the signature.", + Syntax: "LIST JAVASCRIPT ACTIONS [IN Module];\nDESCRIBE JAVASCRIPT ACTION Module.Name;\nCREATE [OR MODIFY] JAVASCRIPT ACTION Module.Name [FOLDER 'path'](...) RETURNS Type [AS $ReturnName] [PLATFORM Web|Native|Hybrid|All] AS $$ ... $$;\nDROP JAVASCRIPT ACTION Module.Name;\n\nParameters, their /** ... */ descriptions and AS $ReturnName are written as for a Java action (see java-action.create).\n\nWrites the unit plus javascriptsource//actions/.js. AS $$ ... $$ is mandatory. PLATFORM defaults to Web. The body sees each parameter with its first character lower-cased (ReadOnly is readOnly, URL is uRL), as mxbuild generates the signature.", Example: "mdl 1;\nCREATE JAVASCRIPT ACTION Utils.IsStrictMode() RETURNS Boolean\nPLATFORM Web\nAS $$\nreturn Promise.resolve((function(){ return !this; })());\n$$;\n\n-- Exposed, native, with parameters\nCREATE JAVASCRIPT ACTION Utils.ShowToast(\n Message: String NOT NULL,\n Duration: Integer\n) RETURNS Boolean\nEXPOSED AS 'Show Toast' IN 'UI'\nPLATFORM Native\nAS $$\nreturn Promise.resolve(true);\n$$;", SeeAlso: []string{"java-action"}, }) @@ -784,6 +787,7 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "object handling", "converter", "value transform", "custom handling", "by microflow", }, Syntax: "LIST IMPORT MAPPINGS [IN Module];\nDESCRIBE IMPORT MAPPING Module.Name;\n" + + "[/** documentation */]\n" + "CREATE [OR MODIFY] IMPORT MAPPING Module.Name [FOLDER 'path']\n" + " WITH JSON STRUCTURE Module.JsonStruct [ROOT a/b/c]\n" + " | WITH MESSAGE DEFINITION Module.Collection.Definition\n" + @@ -794,6 +798,8 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, " Attr = a/b/c,\n" + " Assoc/Module.Child = nestedKey { ... }\n }\n};\nDROP IMPORT MAPPING Module.Name;\n\n" + "OR MODIFY: updates mapping in-place, preserves UUID.\n\n" + + "Documentation: the /** ... */ doc comment above CREATE (QUAL002 asks for\n" + + "it). On OR MODIFY no comment keeps the stored text, /** */ clears it.\n\n" + "Nested members (Attr = a/b/c):\n" + " Reaches a leaf several levels down with NO entity for the levels in\n" + " between — the shape Studio Pro produces when you tick a nested leaf\n" + @@ -848,7 +854,9 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "with json structure", "with message definition", "null values", "as jsonKey", "converter", "value transform", }, - Syntax: "LIST EXPORT MAPPINGS [IN Module];\nDESCRIBE EXPORT MAPPING Module.Name;\nCREATE [OR MODIFY] EXPORT MAPPING Module.Name [FOLDER 'path']\n WITH JSON STRUCTURE Module.JsonStruct [ROOT a/b/c]\n | WITH MESSAGE DEFINITION Module.Collection.Definition\n | WITH XML SCHEMA Module.Schema\n [NULL VALUES LeaveOutElement|SendAsNil]\n{\n Module.Entity {\n jsonField = Attr,\n jsonField = Module.MF(Attr), -- value transform\n group AS key { ... }, -- entity-less grouping node\n Assoc/Module.Child AS nestedKey { ... }\n }\n};\nDROP EXPORT MAPPING Module.Name;\n\nOR MODIFY: updates mapping in-place, preserves UUID.\n\n" + + Syntax: "LIST EXPORT MAPPINGS [IN Module];\nDESCRIBE EXPORT MAPPING Module.Name;\n[/** documentation */]\nCREATE [OR MODIFY] EXPORT MAPPING Module.Name [FOLDER 'path']\n WITH JSON STRUCTURE Module.JsonStruct [ROOT a/b/c]\n | WITH MESSAGE DEFINITION Module.Collection.Definition\n | WITH XML SCHEMA Module.Schema\n [NULL VALUES LeaveOutElement|SendAsNil]\n{\n Module.Entity {\n jsonField = Attr,\n jsonField = Module.MF(Attr), -- value transform\n group AS key { ... }, -- entity-less grouping node\n Assoc/Module.Child AS nestedKey { ... }\n }\n};\nDROP EXPORT MAPPING Module.Name;\n\nOR MODIFY: updates mapping in-place, preserves UUID.\n\n" + + "Documentation: the /** ... */ doc comment above CREATE, as on an import\n" + + "mapping. On OR MODIFY no comment keeps the stored text, /** */ clears it.\n\n" + "Grouping nodes (group as key { ... }):\n" + " A JSON object with no Mendix object behind it — Studio Pro's\n" + " entity-less object element. It may hold OBJECT elements only: a\n" + @@ -874,7 +882,7 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "data transformer", "create data transformer", "drop data transformer", "list data transformers", "jslt", "xslt", "transform", }, - Syntax: "LIST DATA TRANSFORMERS [IN Module];\nDESCRIBE DATA TRANSFORMER Module.Name;\nCREATE [OR MODIFY] DATA TRANSFORMER Module.Name [FOLDER 'path']\n SOURCE JSON '{ ... }'\n{\n JSLT 'single-line-expression';\n -- or multi-line:\n JSLT $$\n{ ... }\n $$;\n};\nDROP DATA TRANSFORMER Module.Name;\n\nOR MODIFY: updates transformer in-place, preserves UUID.", + Syntax: "LIST DATA TRANSFORMERS [IN Module];\nDESCRIBE DATA TRANSFORMER Module.Name;\n[/** documentation */]\nCREATE [OR MODIFY] DATA TRANSFORMER Module.Name [FOLDER 'path']\n SOURCE JSON '{ ... }'\n{\n JSLT 'single-line-expression';\n -- or multi-line:\n JSLT $$\n{ ... }\n $$;\n};\nDROP DATA TRANSFORMER Module.Name;\n\nOR MODIFY: updates transformer in-place, preserves UUID.\n\nDocumentation: the /** ... */ doc comment above CREATE. On OR MODIFY no\ncomment keeps the stored text, /** */ clears it.", Example: "mdl 1;\nCREATE DATA TRANSFORMER ETL.FlattenOrder\n SOURCE JSON '{\"order\": {\"id\": 1, \"total\": 99.0}}'\n{\n JSLT '{\"id\": .order.id, \"total\": .order.total}';\n};\n\n-- Multi-line JSLT\nCREATE OR MODIFY DATA TRANSFORMER ETL.WeatherSummary\n SOURCE JSON '{\"current\": {\"temp\": 12.8, \"wind\": 18.3}}'\n{\n JSLT $$\n{\n \"temperature\": .current.temp,\n \"wind_speed\": .current.wind\n}\n $$;\n};", SeeAlso: []string{"integration"}, }) diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index d2a00fe4ac..0430795bac 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -1259,7 +1259,7 @@ mxcli -p app.mpr --json -c "LIST FOLDERS IN MyModule" Syntax: `SEARCH ''; -- CLI -mxcli search -p app.mpr "" [--format table|names|json] [-q] [--fail-on-match] +mxcli search -p app.mpr "" [--format table|names|json] [-q] [--fail-on-match] [--exclude-marketplace] -- Raw FTS queries SELECT * FROM CATALOG.STRINGS WHERE strings MATCH ''; @@ -1273,6 +1273,9 @@ mxcli search -p app.mpr "validation" -q --format names -- Gate: exit 1 on a hit, 0 on none (2 when the search cannot run) mxcli search -p app.mpr "TODO" --fail-on-match +-- Leave out hits inside Marketplace modules +mxcli search -p app.mpr "TODO" --exclude-marketplace + -- FTS5 operators SEARCH 'word1 OR word2'; SEARCH '"exact phrase"'; diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index d746a7fcfe..7b1d11757d 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -427,7 +427,7 @@ LIST IMPACT OF htmlelement; "snippet", "snippets", "reusable", "snippetcall", "page fragment", "component", }, - Syntax: "CREATE SNIPPET Module.Name [FOLDER 'path']\n [( Params: ( $P: Module.Entity ) )] -- parameters are entities only\n {\n -- widgets (same as page)\n }\n\n-- Embed in a page:\nSNIPPETCALL scName (Snippet: Module.SnippetName)", + Syntax: "CREATE SNIPPET Module.Name [FOLDER 'path']\n [( Params: ( $P: Module.Entity ) )] -- parameters are entities only\n {\n -- widgets (same as page)\n }\n\n-- Embed in a page:\nSNIPPETCALL scName (Snippet: Module.SnippetName)\n-- A snippet call renders no element: Class / Style / DynamicClasses /\n-- DesignProperties on it never reach the page (MDL-WIDGET48). Style a\n-- container around the call, or inside the snippet, instead.", Example: "CREATE SNIPPET MyModule.CustomerInfo (\n Params: ( $Customer: MyModule.Customer )\n)\n{\n DATAVIEW dv (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n TEXTBOX txtEmail (Label: 'Email', Attribute: Email)\n }\n};", SeeAlso: []string{"snippet.create", "snippet.alter", "page"}, }) diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 0d2e78e407..74540d77a2 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -323,7 +323,8 @@ func init() { Keywords: []string{ "call workflow", "sub-workflow", "nested workflow", }, - Syntax: "CALL WORKFLOW Module.WF[(WorkflowContext = )] [AS ] [CAPTION ''];\n\n" + + Syntax: "CALL WORKFLOW Module.WF[(WorkflowContext = )] [AS ] [CAPTION '']\n" + + " [BOUNDARY EVENT ...];\n\n" + "-- A workflow has exactly one parameter and Mendix stores it as WorkflowContext,\n" + "-- whatever name the called workflow's `PARAMETER $Name: ...` header declares — so\n" + "-- the mapping key is always WorkflowContext. Any other key is CE1613 (MDL-WF18).\n" + @@ -332,8 +333,11 @@ func init() { Example: "CALL WORKFLOW HR.SubApproval CAPTION 'Delegate to sub-process';\n\n" + "CALL WORKFLOW HR.SubApproval(WorkflowContext = $WorkflowContext) AS callWf1;\n\n" + "-- pass an associated object: the path names the association AND its target entity\n" + - "CALL WORKFLOW HR.ReviewLine(WorkflowContext = $WorkflowContext/HR.Request_Line/HR.Line) AS callWf2;", - SeeAlso: []string{"workflow.create", "workflow.call-microflow", "microflow.workflow-actions"}, + "CALL WORKFLOW HR.ReviewLine(WorkflowContext = $WorkflowContext/HR.Request_Line/HR.Line) AS callWf2;\n\n" + + "-- put a deadline on the child without moving the timer into it\n" + + "CALL WORKFLOW HR.SubApproval AS callWf3\n" + + " BOUNDARY EVENT INTERRUPTING TIMER addDays([%CurrentDateTime%], 5) { END WORKFLOW; };", + SeeAlso: []string{"workflow.create", "workflow.call-microflow", "workflow.boundary-event", "microflow.workflow-actions"}, }) Register(SyntaxFeature{ @@ -419,7 +423,7 @@ func init() { Register(SyntaxFeature{ Path: "workflow.boundary-event", - Summary: "Attach boundary events (timer) to user tasks for timeouts", + Summary: "Attach boundary events (timer) to user tasks, calls and waits for timeouts", Keywords: []string{ "boundary event", "timer", "timeout", "deadline", "SLA", "escalation", @@ -430,7 +434,7 @@ func init() { // 'Workflows$TimerBoundaryEvent' could not be found"). This entry showed // the bare form, with 'P3D' — not a valid timer expression — as its delay. // ako/view-entity-examples FINDINGS §7. - Syntax: "-- inline, as a clause of a USER TASK (after OUTCOMES):\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER { }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [''] { }\n\n-- A notification boundary event (Mendix 11.11+) is triggered by `notify workflow`\n-- targeting its name, so the name is unique in the workflow. ALTER cannot insert\n-- one yet; restate the workflow instead.\n\n-- or add a timer to an existing task:\nALTER WORKFLOW { INSERT INTO { BOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER { } } };\n\n-- Name the kind: a bare TIMER is refused on Mendix 11 (MDL-WF07) — it writes a\n-- type the runtime cannot load. The delay is an expression that yields a\n-- DateTime, e.g. addDays([%CurrentDateTime%], 3). mxcli ends every boundary\n-- path with Mendix's end-of-path marker, so a path may end in a call; use\n-- JUMP TO to return to the task instead.", + Syntax: "-- inline, as a clause of a USER TASK or CALL MICROFLOW (after OUTCOMES),\n-- a CALL WORKFLOW or a WAIT FOR NOTIFICATION:\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER { }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [''] { }\n\n-- A notification boundary event (Mendix 11.11+) is triggered by `notify workflow`\n-- targeting its name, so the name is unique in the workflow. ALTER cannot insert\n-- one yet; restate the workflow instead.\n\n-- or add a timer to an existing task:\nALTER WORKFLOW { INSERT INTO { BOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER { } } };\n\n-- Name the kind: a bare TIMER is refused on Mendix 11 (MDL-WF07) — it writes a\n-- type the runtime cannot load. The delay is an expression that yields a\n-- DateTime, e.g. addDays([%CurrentDateTime%], 3). mxcli ends every boundary\n-- path with Mendix's end-of-path marker, so a path may end in a call; use\n-- JUMP TO to return to the task instead.", Example: "user task ReviewTask 'Review'\n page Module.WF_Review\n outcomes 'Done' { }\n boundary event interrupting timer addDays([%CurrentDateTime%], 3) {\n call microflow Module.WF_Escalate;\n };", MinVersion: "10.6.0", SeeAlso: []string{"workflow.user-task", "workflow.notification", "workflow.event-subprocess"}, diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 1d6998bcd0..f13ca22cfe 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -330,6 +330,7 @@ CREATE OR MODIFY NAVIGATION Responsive | Describe Java action | `DESCRIBE JAVA ACTION Module.Name;` | Full MDL output with signature | | Create Java action | `CREATE JAVA ACTION Module.Name(params) RETURNS type AS $$ ... $$;` | Inline Java code | | Create with type params | `CREATE JAVA ACTION Module.Name(EntityType: ENTITY , Obj: pEntity) ...;` | Generic type parameters | +| Describe a parameter, name the return value | `CREATE JAVA ACTION Module.Name(/** The text. */ Input: String) RETURNS String AS $Result AS $$ ... $$;` | `/** … */` before a parameter is its description; `AS $Name` the return value's name | | Create exposed action | `... EXPOSED AS 'Caption' IN 'Category' AS $$ ... $$;` | Toolbox-visible in Studio Pro | | Drop Java action | `DROP JAVA ACTION Module.Name;` | Delete a Java action | | Call from microflow | `$Result = CALL JAVA ACTION Module.Name(Param = value);` | Inside BEGIN...END | diff --git a/docs-site/src/examples/rest-integration.md b/docs-site/src/examples/rest-integration.md index f8bbe03414..465286b4dc 100644 --- a/docs-site/src/examples/rest-integration.md +++ b/docs-site/src/examples/rest-integration.md @@ -309,6 +309,7 @@ CREATE OR MODIFY CONSUMED REST SERVICE Integration.OrdersApi ( Create a published REST service with CRUD operations backed by microflows. ```sql +/** Order management API for the shop front end. */ CREATE PUBLISHED REST SERVICE Module.OrderAPI ( Path: 'rest/orders/v1', Version: '1.0.0', @@ -325,6 +326,8 @@ CREATE PUBLISHED REST SERVICE Module.OrderAPI ( }; ``` +**Documentation:** the `/** … */` doc comment above `CREATE` is the service's documentation — what `lint` QUAL002 asks for. On `CREATE OR MODIFY` no comment keeps the stored text and `/** */` clears it. The OpenAPI description consumers read (`PublicDocumentation`) has no MDL spelling and is kept as stored. + **Operation paths:** Use empty string `''` for the root, `'{paramName}'` for path parameters. Do NOT start or end with `/`. Path parameters must match a microflow parameter name exactly (case-sensitive) — `'{id}'` binds the microflow's `$id`, whatever its type. **Operation parameters** come from the microflow, the way Studio Pro derives them. Create the microflow before the service: diff --git a/docs-site/src/guides/marketplace.md b/docs-site/src/guides/marketplace.md index 11fcc3a390..9f84c5e76a 100644 --- a/docs-site/src/guides/marketplace.md +++ b/docs-site/src/guides/marketplace.md @@ -104,6 +104,71 @@ Agent Commons 4.2.0 requires Mendix 11.12.2, and the project is 11.12.1 `install` installs exactly the content you name. Its dependencies are neither fetched nor named — read the check errors after each install, which identify what is missing by qualified name. The error count is **not monotonic**, because each new module brings its own unmet dependencies: installing the agent-editor stack into a vanilla 11.12.1 app went 0 → 15 → 0 → 18 → 1 → 22 → 1 → 1. Dependencies include widget content as well as modules (`CE0462 "Could not find widget ..."`). +## Exporting a module (`marketplace package`) + +The reverse of `install --file`: write a module of a project out as a `.mpk`, for a company-standard +module, a CI artifact, or to move a module to another app. + +```bash +mxcli marketplace package MyModule -p app.mpr # ./MyModule.mpk +mxcli marketplace package MyModule -p app.mpr -o ./dist --json +``` + +It wraps Mendix's own `mx create-module-package` at the project's Mendix version, so the package is +the one Studio Pro's *Export module package* makes. The module name is checked against the project +first (a typo lists the modules instead of failing with `mx` exit code 3), and `mx`'s exit codes 1–4 +are reported with what they mean and what `mx` printed. Whatever `mx` prints on a successful export +goes to stderr, so `--json` output stays clean. + +**The `mx` must be the project's Mendix version.** `mx` is found from an `mx` on PATH first, then a +Studio Pro install, then the `mxbuild` cache, falling back to any cached one when the project's version +is missing, so the first match can belong to another Mendix version than the app. mxcli reads the +version of the `mx` it found (from the `mx.deps.json` beside it, else its version-named folder), +compares major.minor.patch with the project's, and **refuses** a mismatch — or an `mx` whose version +cannot be told — naming both versions. The fix is `mxcli setup mxbuild --version ` (take an +older `mx` off PATH, which is used before it). `--mxbuild-path` is the deliberate override: with it +a mismatch only warns on stderr and the export goes on. A path that does not exist is an error. + +| Flag | Purpose | +|---|---| +| `-p, --project` | The project holding the module (required). | +| `-o, --output-dir` | Where `.mpk` goes (default: the current directory; created if missing). | +| `--all-libs` | Bundle every `userlib/` JAR. By default only the module's own required libs go in (`mx -l`): 12 KB against 24 MB on an app with Business Events installed. | +| `--exclude ` | Leave out files whose path inside the package matches. `mx` takes **one** expression, so join alternatives with `\|`; write the folder separator as `.`. | +| `--exclude-managed-dependencies` | Leave out managed dependencies (`mx -m`). | +| `--mxbuild-path` | Take `mx` from this Mendix installation (or `mxbuild`) instead of resolving it by the project's version. Overrides the version check: a mismatch warns instead of refusing. | +| `--json` | Print `{path, sizeBytes, files, foreignEntries}`. | + +**Files of other modules.** `-l` filters only `userlib/` JARs. The app's whole `resources/` folder is +still exported (measured on 11.12.5: another module's `resources/EventLog/` came along; its `javasource/` and +`themesource/` did not). After the export mxcli opens the package and warns on stderr about every `resources//…`, `javasource//…` or +`themesource//…` entry where `X` is not the exported module (the last two are a defensive check), +and prints the pattern that drops them: + +```text +warning: the package holds 1 file(s) that belong to other modules, not to MyModule: + resources/EventLog/x.txt +To leave them out, export again with: + --exclude '^resources.EventLog.' +``` + +The pattern is quoted for a POSIX shell (a `'` in your own `--exclude` is written `'''`), so it can be +pasted as it stands; in PowerShell or `cmd` quote it for that shell. + +The pattern uses `.` for the separator because a character class or a backslash (`[/\]`) makes `mx` +print its usage and write nothing. `.` matches any character, so a pattern that would also match a file +of the module itself is not suggested. + +**The project is not changed.** `mx create-module-package` writes into the project it is given: +measured on a blank 11.12.5 app, one run rewrote `theme-cache/web/theme.compiled.css` and its map, +added `deployment/sass/main.scss`, and created the module's `themesource//` folder; the model +files were untouched. mxcli therefore runs it on a temporary copy, as `docker check` does, and a +before/after listing of the project (sizes and modification times) was identical. An output directory +inside the project (`-o ./dist`) is left out of that copy, so earlier packages are not copied along. + +The package installs back with `marketplace install --file`; the round trip into a fresh 11.12.5 app +checks with 0 errors. + ## Why installs do not use `mx module-import` `mx module-import` rewrites an MPR v2 project as v1. Measured on a blank Mendix 11.12.1 app, a single import turned a 69 KB `.mpr` plus 341 `.mxunit` files into one 14 MB SQLite blob with no `mprcontents/` — and the same was observed independently on 11.13.0, so it is not version-specific: diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index 8c819ce0b7..8f415e4b72 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -210,7 +210,12 @@ trailing association with no entity gets the end opposite the context. It is a different source from `$Task/System.WorkflowUserTask_Assignees` — an **association** source, which follows the association in memory and has no -XPath, sort or search. List views only; on any other widget it is refused. +XPath, sort or search. + +A gallery, a data grid and any other pluggable widget take the same form for +their "Database" source, with its XPath and sort (`search by` stays a list view +search bar). Widgets with a source of their own, such as a chart series or a +dynamic image, refuse it. #### Specialization templates @@ -521,6 +526,20 @@ Embeds a snippet (reusable page fragment) into the page: SNIPPETCALL scNav (Snippet: MyModule.NavigationMenu) ``` +**A snippet call renders no element of its own** — the snippet's widgets are +inlined into the parent. So `Class`, `Style`, `DynamicClasses` and +`DesignProperties` on a `snippetcall` never reach the page: valid, `mx check` +clean, and silently useless (measured on Mendix 11.15). `mxcli check` warns +**MDL-WIDGET48** (also for `alter page … { set (Class: …) on }` and +`alter styling` on one) and `mxcli lint` reports existing ones as **MPR013**. +Style a container instead — inside the snippet, or around the call: + +```sql +container cNav (class: 'sidebar') { + snippetcall scNav (snippet: MyModule.NavigationMenu) +} +``` + ## Navigation Widgets ### NAVIGATIONLIST diff --git a/docs-site/src/language/workflow-activities.md b/docs-site/src/language/workflow-activities.md index 72340f1c21..882f6f6713 100644 --- a/docs-site/src/language/workflow-activities.md +++ b/docs-site/src/language/workflow-activities.md @@ -137,7 +137,8 @@ branch with `OUTCOMES`; return nothing for a single path. Start a sub-workflow: ```sql -CALL WORKFLOW . [( = , ...)] [CAPTION '']; +CALL WORKFLOW . [( = , ...)] [AS ] [CAPTION ''] + [BOUNDARY EVENT ...]; ``` Example: @@ -146,6 +147,14 @@ Example: CALL WORKFLOW HR.BackgroundCheck CAPTION 'Run background check sub-process'; ``` +A call workflow takes the same boundary events as a user task or call microflow, +so a parent can put a deadline on a child without moving the timer into it: + +```sql +CALL WORKFLOW HR.BackgroundCheck AS callCheck + BOUNDARY EVENT INTERRUPTING TIMER addDays([%CurrentDateTime%], 5) { END WORKFLOW; }; +``` + ## Decision Branch the workflow based on a condition. The condition is a bare expression, Boolean or enumeration; each outcome contains a block of activities: @@ -239,7 +248,7 @@ An intermediate notification event (Mendix 11.11+): the point a `NOTIFY WORKFLOW NOTIFICATION [] [CAPTION '']; ``` -A **notification boundary event** attaches the same trigger to a user task, call microflow or wait. It takes a name instead of a timer delay: +A **notification boundary event** attaches the same trigger to a user task, call microflow, call workflow or wait. It takes a name instead of a timer delay: ```sql USER TASK Review 'Review' diff --git a/docs-site/src/reference/capabilities.md b/docs-site/src/reference/capabilities.md index 4f7ec1cb03..78e39865df 100644 --- a/docs-site/src/reference/capabilities.md +++ b/docs-site/src/reference/capabilities.md @@ -135,6 +135,7 @@ Everything mxcli can do, organized by use case. | Marketplace browse | `mxcli marketplace search/info/versions` | Discover content; `--min-mendix` compatibility filter | | Marketplace download | `mxcli marketplace download [--version X]` | Fetch a content version's `.mpk` to disk | | Marketplace install | `mxcli marketplace install -p app.mpr` | Widget → `widgets/`; new module → `mx module-import`; existing module reported, not updated | +| Marketplace package | `mxcli marketplace package -p app.mpr [-o dir]` | Export a module as a `.mpk` via `mx create-module-package` (module's own libs only; warns about other modules' files); installs back with `install --file` | ## Known Limitations diff --git a/docs-site/src/reference/integration/create-data-transformer.md b/docs-site/src/reference/integration/create-data-transformer.md index 4f6b7dde1a..30c53ddbe8 100644 --- a/docs-site/src/reference/integration/create-data-transformer.md +++ b/docs-site/src/reference/integration/create-data-transformer.md @@ -3,6 +3,7 @@ ## Synopsis ```sql +[ /** description */ ] CREATE [ OR MODIFY ] DATA TRANSFORMER module.Name [ FOLDER 'folder_path' ] SOURCE { JSON | XML } 'sample' @@ -41,6 +42,9 @@ The `SOURCE` clause provides a representative input document. Mendix uses it to `FOLDER 'folder_path'` : Optional. Places the document in the named module folder, creating missing folders in the path. On `CREATE OR MODIFY` this **moves** an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See [MOVE](../organization/move.md). +`/** description */` +: Optional. The documentation of the data transformer, as a doc comment before the statement. `lint` QUAL002 reports a data transformer without it, and `DESCRIBE` prints it back. On `CREATE OR MODIFY`, no doc comment keeps the stored documentation and an empty `/** */` clears it. + `SOURCE JSON 'sample'` : A representative JSON input document. Multi-line samples can use `$$...$$` quoting. diff --git a/docs-site/src/reference/integration/create-export-mapping.md b/docs-site/src/reference/integration/create-export-mapping.md index a665bf384a..e4a84956ca 100644 --- a/docs-site/src/reference/integration/create-export-mapping.md +++ b/docs-site/src/reference/integration/create-export-mapping.md @@ -3,6 +3,7 @@ ## Synopsis ```sql +[ /** description */ ] CREATE [ OR MODIFY ] EXPORT MAPPING module.Name [ FOLDER 'folder_path' ] [ WITH JSON STRUCTURE module.JsonStructure ] @@ -57,6 +58,9 @@ If `OR MODIFY` is specified and the mapping already exists, it is updated in pla `FOLDER 'folder_path'` : Optional. Places the document in the named module folder, creating missing folders in the path. On `CREATE OR MODIFY` this **moves** an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See [MOVE](../organization/move.md). +`/** description */` +: Optional. The documentation of the export mapping, as a doc comment before the statement. `lint` QUAL002 reports a export mapping without it, and `DESCRIBE` prints it back. On `CREATE OR MODIFY`, no doc comment keeps the stored documentation and an empty `/** */` clears it. + `WITH JSON STRUCTURE module.JsonStructure` : Associates the mapping with the named JSON structure. diff --git a/docs-site/src/reference/integration/create-import-mapping.md b/docs-site/src/reference/integration/create-import-mapping.md index b38366be66..d412b34674 100644 --- a/docs-site/src/reference/integration/create-import-mapping.md +++ b/docs-site/src/reference/integration/create-import-mapping.md @@ -3,6 +3,7 @@ ## Synopsis ```sql +[ /** description */ ] CREATE [ OR MODIFY ] IMPORT MAPPING module.Name [ FOLDER 'folder_path' ] [ WITH JSON STRUCTURE module.JsonStructure ] @@ -80,6 +81,9 @@ If `OR MODIFY` is specified and the mapping already exists, it is updated in pla `FOLDER 'folder_path'` : Optional. Places the document in the named module folder, creating missing folders in the path. On `CREATE OR MODIFY` this **moves** an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See [MOVE](../organization/move.md). +`/** description */` +: Optional. The documentation of the import mapping, as a doc comment before the statement. `lint` QUAL002 reports a import mapping without it, and `DESCRIBE` prints it back. On `CREATE OR MODIFY`, no doc comment keeps the stored documentation and an empty `/** */` clears it. + `WITH JSON STRUCTURE module.JsonStructure` : Associates the mapping with the named JSON structure. The structure defines the JSON shape. diff --git a/docs-site/src/reference/microflow/create-java-action.md b/docs-site/src/reference/microflow/create-java-action.md index 3f45325899..4e4bb9dff3 100644 --- a/docs-site/src/reference/microflow/create-java-action.md +++ b/docs-site/src/reference/microflow/create-java-action.md @@ -4,7 +4,7 @@ ```sql CREATE [ OR MODIFY ] JAVA ACTION module.Name [ < T [, ...] > ] [ FOLDER 'folder_path' ] ( parameters ) - RETURNS type + RETURNS type [ AS $return_name ] [ EXPOSED AS 'caption' IN 'category' ] AS $$ java_code $$ @@ -27,6 +27,19 @@ Java actions support generic entity handling through type parameters. A paramete A type parameter that no `ENTITY ` parameter names -- one bound by the object the caller passes in -- is declared in the header after the action name: `module.Name`. The header also fixes the order of the type parameters when it differs from the order the `ENTITY ` parameters would give. An unqualified type that is neither a declared type parameter nor `Microflow`/`Nanoflow` is refused (`MDL-JA01`): it would be written as an entity with no module, which Mendix reports as CE1613. +### Parameter Descriptions and the Return Value Name + +A parameter's description is a `/** ... */` doc comment written before it -- the +same form an entity attribute's documentation takes. `RETURNS type AS $Name` +names the return value: the default name of the variable a microflow call stores +the result in, as `RETURNS type AS $Name` does on a microflow. Both are what Studio +Pro shows when a modeler drags the action into a microflow, and what `DESCRIBE JAVA +ACTION` prints. + +On `CREATE OR MODIFY`, leaving either out keeps what is stored; an empty `/** */` +clears a description. A new action gets no descriptions and the return value name +`ReturnValueName`, as in Studio Pro. + ### Exposed Actions The optional `EXPOSED AS` clause makes the action visible in the Studio Pro toolbox under the specified category, allowing modelers to use it directly in microflows. @@ -43,7 +56,7 @@ The optional `EXPOSED AS` clause makes the action visible in the Studio Pro tool : Optional. Places the document in the named module folder, creating missing folders in the path. On `CREATE OR MODIFY` this **moves** an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See [MOVE](../organization/move.md). `parameters` -: Comma-separated parameter declarations. Each parameter has a name, colon, and type. Supported types: +: Comma-separated parameter declarations. Each parameter has an optional `/** description */`, a name, colon, and type. Supported types: - Primitives: `String`, `Integer`, `Long`, `Decimal`, `Boolean`, `DateTime` - Entity: `Module.EntityName` - List: `List of Module.EntityName` @@ -56,6 +69,9 @@ The optional `EXPOSED AS` clause makes the action visible in the Studio Pro tool `RETURNS type` : The return type. Same type options as parameters. +`AS $return_name` +: Optional. The return value's name. Omitted, the stored name is kept (`ReturnValueName` on a new action). + `EXPOSED AS 'caption' IN 'category'` : Optional. Makes the action visible in the toolbox with the given caption and category. @@ -81,6 +97,19 @@ AS $$ $$; ``` +Java action with parameter descriptions and a named return value: + +```sql +/** Converts text to upper case. */ +CREATE JAVA ACTION MyModule.JA_ToUpper ( + /** The text to convert. */ + Input: String NOT NULL +) RETURNS String AS $UpperText +AS $$ + return Input.toUpperCase(); +$$; +``` + Java action with type parameters for generic entity handling: ```sql diff --git a/docs-site/src/reference/microflow/create-javascript-action.md b/docs-site/src/reference/microflow/create-javascript-action.md index 109c9a4afa..49f8b1bdad 100644 --- a/docs-site/src/reference/microflow/create-javascript-action.md +++ b/docs-site/src/reference/microflow/create-javascript-action.md @@ -4,7 +4,7 @@ ```sql CREATE [ OR MODIFY ] JAVASCRIPT ACTION module.Name [ FOLDER 'folder_path' ] ( parameters ) - RETURNS type + RETURNS type [ AS $return_name ] [ EXPOSED AS 'caption' IN 'category' ] [ PLATFORM Web | Native | Hybrid | All ] AS $$ javascript_code $$ @@ -42,6 +42,13 @@ nothing else. The body is written against that name. `ReadOnly` is `readOnly`, body that uses the declared spelling (`ReadOnly`) throws a `ReferenceError` when the action runs. `DESCRIBE` still prints the declared names. +### Parameter Descriptions and the Return Value Name + +As for a Java action, a parameter's description is a `/** ... */` doc comment +before it, and `RETURNS type AS $Name` names the return value. Leaving either out +on `CREATE OR MODIFY` keeps what is stored. See +[CREATE JAVA ACTION](create-java-action.md#parameter-descriptions-and-the-return-value-name). + ### Platform The optional `PLATFORM` clause restricts where the action runs: `Web`, `Native`, diff --git a/docs-site/src/reference/page/create-page.md b/docs-site/src/reference/page/create-page.md index 3b1c0d20f0..2e30cfda60 100644 --- a/docs-site/src/reference/page/create-page.md +++ b/docs-site/src/reference/page/create-page.md @@ -184,6 +184,10 @@ DATAGRID dgOrders (DataSource: DATABASE FROM Sales.Order, Selection: Single) { } ``` +A `SHOW PAGE` in the control bar takes the selection the same way — +`Action: SHOW PAGE Sales.Order_Detail(Order = $dgOrders)` opens the page with +the selected row, and the button is disabled until a row is selected. + `mxcli check -p ` reports a missing argument before mxbuild does. ### ButtonStyle Values diff --git a/docs-site/src/reference/query/search.md b/docs-site/src/reference/query/search.md index 1e380626d5..b507bb4f27 100644 --- a/docs-site/src/reference/query/search.md +++ b/docs-site/src/reference/query/search.md @@ -8,7 +8,7 @@ Performs a full-text search across all strings, messages, captions, the documentation of project elements (entities, attributes, constants, pages and more), and MDL source in the project. Uses FTS5 (SQLite full-text search) for fast matching. Returns results grouped by document type and location. -This command requires the catalog to be populated. If search returns no results, run `REFRESH CATALOG FULL` first. The search is also available as a CLI subcommand: `mxcli search -p app.mpr "keyword" --format names|json`. +This command requires the catalog to be populated. If search returns no results, run `REFRESH CATALOG FULL` first. The search is also available as a CLI subcommand: `mxcli search -p app.mpr "keyword" --format names|json`. The CLI adds `--exclude-marketplace`; see [Full-Text Search](../../tools/search.md). ## Parameters diff --git a/docs-site/src/tools/builtin-rules.md b/docs-site/src/tools/builtin-rules.md index 55beb7343b..8a7f499bde 100644 --- a/docs-site/src/tools/builtin-rules.md +++ b/docs-site/src/tools/builtin-rules.md @@ -39,9 +39,36 @@ The **lint rules** below run with `mxcli lint`. There is also a separate group o | **CONV012** | Exclusive split captions -- Checks that decision branches have meaningful captions | | **CONV013** | Error handling on external calls -- Ensures external service calls have error handling | | **CONV014** | No continue error handling -- Warns against using CONTINUE error handling without logging | +| **CONV020** | Created object never persisted -- A microflow creates an object of a persistent entity without commit, and never commits, returns or passes it on, so the object is discarded when the microflow ends ([details](#conv020-details)) | | **MDL-FLOW01** | Un-describable branch structure -- Decision branches that re-enter each other's paths, so `DESCRIBE MICROFLOW` cannot render them as nested `IF`s without changing what they mean | | **MDL-MAP04** | First over an object-rooted import mapping -- An `import from mapping` or `rest call … returns mapping` activity already in the model stores Studio Pro's *First* (`ForceSingleOccurrence` or `Range.SingleObject`) over a mapping that returns one object. `mx check` reports 0 errors; the activity throws `key not found: Path(QName(None,),None,)` when it runs. `mxcli check` refuses the same statement in a script under the same ID; this finds activities written before that check existed, or with `exec --no-check`. A list-rooted mapping's *First* is legitimate and is not reported | +### CONV020 details {#conv020-details} + +`$Item = create Shop.Item (...)` without `commit` makes an object in memory only. +If the microflow then neither commits it nor hands it to anything that could, +the object is gone when the microflow ends. Nothing else reports this: `check`, +`mx check` and the build all pass, and the microflow runs without error. In the +report that prompted the rule, an after-startup seed microflow logged +"Seeded 20" and the table stayed empty. + +The rule reports a create only when **all** of these hold: + +- it is in a **microflow** (not a nanoflow or a rule) and its commit setting is + **No**; +- the entity is **persistent** (a non-persistent entity is meant to be + temporary), and is not a System or Marketplace entity; +- nothing in the microflow, on any path, uses the variable except to read one + of its members (`$Item/Name`) or to change it without commit. + +Any other mention counts as passing the object on and keeps the rule quiet: +`commit`, `change … commit`, returning it, an argument to a microflow, Java or +JavaScript action, workflow, mapping or page, adding it to a list, using it as +an association value on another object, `delete`, `rollback`, or the bare +variable anywhere in an expression. The check is flow-insensitive: a commit on +one branch only, or only in an error handler, counts, so that case is not +reported. When the rule cannot tell, it stays quiet. + ## Running Built-in Rules Built-in rules run automatically with `mxcli lint`: diff --git a/docs-site/src/tools/linting.md b/docs-site/src/tools/linting.md index 8f7dc0eb95..5b10e6e3c7 100644 --- a/docs-site/src/tools/linting.md +++ b/docs-site/src/tools/linting.md @@ -18,7 +18,7 @@ The linting system provides: |----------|--------|-------| | MDL | MDL001-MDL007 | Naming conventions, empty microflows, domain model size | | Security | SEC001-SEC010 | Access rules, password policy, demo users, PII exposure | -| Convention | CONV001-CONV019 | Best practice conventions, error handling | +| Convention | CONV001-CONV020 | Best practice conventions, error handling | | Quality | QUAL001-QUAL004 | Complexity, documentation, long microflows | | Architecture | ARCH001-ARCH003 | Cross-module data, entity business keys | | Design | DESIGN001 | Entity attribute count | diff --git a/docs-site/src/tools/mxcli-lint.md b/docs-site/src/tools/mxcli-lint.md index 474b504fa5..4a2ad410da 100644 --- a/docs-site/src/tools/mxcli-lint.md +++ b/docs-site/src/tools/mxcli-lint.md @@ -98,6 +98,12 @@ failure is an error (`Starlark rule error: …`). A rule's configured severity i `lint-config.yaml` applies to its findings, never to its failure. `mxcli report` keeps all rule failures out of the score. +An option in `lint-config.yaml` that a rule cannot use — for example a number +where the rule expects a list of prefixes — is reported at **warning** level as +`lint-config.yaml: rules..options. … the rule ran with its default`. +The rule still runs, on its built-in default, and the warning is kept out of the +score like any other rule failure. + A rule file that does not load at all (for example, one calling a builtin this mxcli lacks) is skipped with a warning on stderr. diff --git a/docs-site/src/tools/search.md b/docs-site/src/tools/search.md index 03fc035ff3..390b64487b 100644 --- a/docs-site/src/tools/search.md +++ b/docs-site/src/tools/search.md @@ -41,6 +41,9 @@ mxcli search -p app.mpr "validation" --json # Exit 1 when anything matches (0 when nothing does) mxcli search -p app.mpr "validation" --fail-on-match + +# Leave out Marketplace modules +mxcli search -p app.mpr "validation" --exclude-marketplace ``` ## Output Formats @@ -122,6 +125,10 @@ mxcli search -p app.mpr "TODO" --fail-on-match --json Without the flag a hit exits `0`, as before. +## Marketplace modules + +By default every module is searched, Marketplace modules included, so existing results do not change. `--exclude-marketplace` leaves out hits inside modules downloaded from the Marketplace, which is what an audit of the app's own text usually wants: the vendor's documentation is not yours to edit. A Marketplace module's own documentation and domain model documentation are left out with the rest of it. + ## Comparison with Catalog Queries `SEARCH` provides quick keyword-based search, while catalog queries (`SELECT FROM CATALOG.*`) provide structured SQL-based queries with filtering, joining, and aggregation. Use `SEARCH` for quick discovery and catalog queries for precise analysis. diff --git a/docs-site/src/tools/starlark-rules.md b/docs-site/src/tools/starlark-rules.md index c3818abffe..ed0dd18617 100644 --- a/docs-site/src/tools/starlark-rules.md +++ b/docs-site/src/tools/starlark-rules.md @@ -30,7 +30,7 @@ In addition to the built-in Go rules, mxcli bundles 29 Starlark-based lint rules | **QUAL001** | McCabe complexity -- Flags microflows with high cyclomatic complexity | | **QUAL002** | Documentation -- Missing documentation across every document type: modules, entities, pages, microflows, workflows, Java/JavaScript actions and their parameters, REST services, mappings, constants ([options](#qual002-options)) | | **QUAL003** | Long microflows -- Warns about microflows with too many activities | -| **QUAL004** | Orphaned elements -- Detects unused entities, microflows, or pages | +| **QUAL004** | Orphaned elements -- Detects unused entities, microflows, or pages ([options](#qual004-and-custom002-options)) | ### Design Rules (DESIGN001) @@ -127,6 +127,44 @@ and one in `_DOC_KINDS` (`missing_documentation.star`) giving the option name an the suggestion text. `TestQUAL002_SweepsEveryAdvertisedDocumentType` fails if the Go side advertises a kind the tests do not cover. +### QUAL004 and CUSTOM002 options {#qual004-and-custom002-options} + +Both rules decide by name prefix. QUAL004 (orphaned elements) does not report a +microflow whose name starts with an **entry-point prefix**, because something +outside the model calls it; CUSTOM002 (microflow prefix convention) reports a +microflow whose name starts with none of the **valid prefixes**. Projects name +things differently, so both lists are options — set them in `lint-config.yaml` +rather than in the rule files, which `mxcli init` rewrites: + +| Rule | Option | Default | +|------|--------|---------| +| QUAL004 | `entry_point_prefixes` | `ACT_, SCH_, WS_, REST_, OData_` | +| CUSTOM002 | `valid_prefixes` | `ACT_, SUB_, DS_, VAL_, SCH_, IVK_, OCH_` | + +```yaml +rules: + QUAL004: + options: + entry_point_prefixes: [ACT_, SCH_, WS_, REST_, OData_, TEST_] + CUSTOM002: + options: + valid_prefixes: "ACT_, SUB_, DS_, VAL_, SCH_, TEST_, GET_" +``` + +Write the list as a YAML list or as one comma-separated string; spaces around +the commas are ignored. A configured list **replaces** the default, so repeat the +default prefixes you still want. An empty list is a real setting: for QUAL004 it +means no microflow is exempt by name. A value of any other shape (a number, a +list holding a number) is not silently ignored — the rule runs with its default +and the run reports a warning naming the option, listed with the rules that could +not run and kept out of the score. + +QUAL004 counts a microflow as called only when something **in the model** runs +it (a call, a schedule, a page or widget, a published service, a project +setting). A call from an MDL test script (`tests/*.test.mdl`) is not in the +model, so a helper used only by tests is reported; give such helpers a prefix +and list it in `entry_point_prefixes`. + ### CONV018 options {#conv018-options} Studio Pro lets a module hold folders, and every Mendix style guide expects them diff --git a/docs-wiki/bug-patterns/describe-round-trip-gaps.md b/docs-wiki/bug-patterns/describe-round-trip-gaps.md index 743142f294..f10f8a40a1 100644 --- a/docs-wiki/bug-patterns/describe-round-trip-gaps.md +++ b/docs-wiki/bug-patterns/describe-round-trip-gaps.md @@ -8,6 +8,7 @@ sources: - .claude/skills/fix-issue/findings/mdl-executor/ - mdl/executor/cmd_workflows.go - mdl/executor/cmd_pages_describe_pluggable.go + - mdl/executor/cmd_pages_describe_bykey.go - mdl/executor/cmd_microflows_show_crossed.go - mdl/executor/cmd_microflows_normalize.go - mdl/executor/describe_graph_invariant_test.go @@ -153,6 +154,17 @@ The same third state appears without translations: an argument field Studio Pro stores as `Argument: ""` is neither the value `empty` nor absence, and a describer that conflates it with either cannot round-trip to `Unchanged`. +**"Default" means what exec writes for the key left out, not the schema's +`DefaultValue`.** A by-key reader prints what differs from the default, and the +two disagree wherever the embedded template stores something else — a combo +box's `readOnlyStyle` is `bordered` in the template against a default of +`text`. Compared with the schema, every mxcli-written widget prints a value +nobody authored (*invents*), and every Studio Pro one storing the schema default +prints nothing and replays as the template's value (*silently drops*). A reader +for a widget with a dedicated describer has to run alongside it, or every key +outside its keyword set is lost — and what it prints must also pass `check` +without `-p`, where only the built-in definition knows the widget's keys. + **Fixing one half is worse than the bug.** Where a describer has two defects at once — say, quoting *and* a missing property — shipping the quoting fix alone turns unparseable output into output that parses cleanly while silently dropping diff --git a/docs-wiki/bug-patterns/duplicate-resolver-drift.md b/docs-wiki/bug-patterns/duplicate-resolver-drift.md index 8e7595641a..b78ddc922f 100644 --- a/docs-wiki/bug-patterns/duplicate-resolver-drift.md +++ b/docs-wiki/bug-patterns/duplicate-resolver-drift.md @@ -57,7 +57,12 @@ one reader, one renderer, one resolver — and taking the type set from *Per-doctype copies.* A behaviour implemented per document type drifts by construction. The folder clause is the clean example: every doctype's `FOLDER` handling had the same bug, and the report that named one of them read as a -doctype-specific defect. +doctype-specific defect. Its inverse — *which module owns this document?* — has +been answered by `GetModule(doc.ContainerID)` in one engine after another, which +is right only at the module root: the executor's reference checker (#1071), then +the MCP backend's page mutator and constant/workflow updates (#1593), each beside +a folder-aware resolver it did not call. The error names the folder's ID as a +"module not found", which is the tell. *Two walks over one tree.* The duplication does not need two *resolvers* — two traversals of the same structure drift the same way, and faster, because each diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 0d0dd07c89..e6031de7b8 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -108,6 +108,7 @@ create persistent entity Module.Photo ( | Association line anchors | `@anchor(from: (0, 54), to: (100, 54))` above `create association …` | Where the connector attaches to each entity box, as a **percentage** of the box (0..100, whole numbers). `from` = the FROM entity's box, `to` = the TO entity's. Omitting an end preserves what is stored, so a `create or modify` about something else never flattens a hand-tuned line. Cross-module associations have no anchors — Mendix stores none | | Set documentation | `alter entity\|association\|enumeration Module.Name set documentation 'text';` | `set comment` is the deprecated spelling (MDL-DEPR135) | | Retune anchors in place | `alter association Module.Name set anchor from (50, 100) to (50, 0);` | `(0, 50)` left-middle, `(100, 50)` right-middle, `(50, 100)` bottom-centre. `describe association` re-emits a non-default pair as the same `@anchor(...)`, so describe → edit → exec round-trips | +| Export level | `create … entity M.E (A: String(50) export level api) export level api;` · `create association … export level api;` · `create enumeration … export level api;` · `alter entity\|association\|enumeration M.X set export level api\|hidden;` | Studio Pro's **Usable** (`api`) / Hidden. Matters in a **Protected (add-on)** module: CE5400 when an `api` microflow exposes a Hidden entity or enumeration, CE1406 when another module uses a Hidden element. An absent clause keeps the stored level; Mendix 9.3+. `mxcli syntax domain-model.export-level` | ## ALTER ENTITY @@ -1442,6 +1443,7 @@ Module.OrderResponse_CustomerInfo/Module.CustomerInfo as customer { | Describe Java action | `describe java action Module.Name;` | Full MDL output with signature | | Create Java action | `create [or modify] java action Module.Name [folder 'path'](params) returns type as $$ ... $$;` | OR MODIFY updates signature/body, preserves UUID | | Create with type params | `create java action Module.Name(EntityType: entity , Obj: pEntity) ...;` | Generic type parameters | +| Describe a parameter, name the return value | `create java action Module.Name(/** The text. */ Input: String) returns String as $Result as $$ ... $$;` | `/** … */` before a parameter is its description; `as $Name` the return value's name. Omitted on `or modify`, both keep what is stored | | Create exposed action | `... exposed as 'caption' in 'Category' as $$ ... $$;` | Toolbox-visible in Studio Pro | | Exposed action with bitmaps | `... exposed as 'caption' in 'Category' icon 'i.png' icon dark 'id.png' image 'm.png' image dark 'md.png' as $$ ... $$;` | Icon 64x64 PNG, image 256x192; paths relative to the .mdl file's own directory. A wrong size warns and is written; a non-PNG is refused | | Remove a toolbox entry | `... not exposed as $$ ... $$;` | An **omitted** clause preserves the stored entry (bitmaps included), so removal is explicit | diff --git a/go.mod b/go.mod index 48d47e5da9..77d136b3bd 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/mendixlabs/mxcli go 1.26.0 -toolchain go1.27.2 +toolchain go1.27.3 require ( github.com/alecthomas/chroma/v2 v2.26.1 @@ -14,6 +14,7 @@ require ( github.com/fsnotify/fsnotify v1.10.1 github.com/jackc/pgx/v5 v5.9.2 github.com/jpillora/chisel v1.11.8 + github.com/klauspost/compress v1.20.3 github.com/mattn/go-runewidth v0.0.24 github.com/microsoft/go-mssqldb v1.11.2 github.com/pmezard/go-difflib v1.0.0 diff --git a/go.sum b/go.sum index f1181e690a..31520db476 100644 --- a/go.sum +++ b/go.sum @@ -107,6 +107,8 @@ github.com/jpillora/requestlog v1.0.0 h1:bg++eJ74T7DYL3DlIpiwknrtfdUA9oP/M4fL+Pp github.com/jpillora/requestlog v1.0.0/go.mod h1:HTWQb7QfDc2jtHnWe2XEIEeJB7gJPnVdpNn52HXPvy8= github.com/jpillora/sizestr v1.0.0 h1:4tr0FLxs1Mtq3TnsLDV+GYUWG7Q26a6s+tV5Zfw2ygw= github.com/jpillora/sizestr v1.0.0/go.mod h1:bUhLv4ctkknatr6gR42qPxirmd5+ds1u7mzD+MZ33f0= +github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4= +github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= diff --git a/internal/depfloor/floor.go b/internal/depfloor/floor.go new file mode 100644 index 0000000000..8556531fd7 --- /dev/null +++ b/internal/depfloor/floor.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build depfloor + +// Package depfloor keeps security floors in go.mod for modules mxcli requires +// but never compiles. +// +// A customer's dependency scan reads the module graph, so a vulnerable version +// there blocks a release even when no mxcli build contains the code. Raising +// such a module needs a require line in go.mod, but `go mod tidy` removes a +// requirement no package imports. Tidy considers every build tag, so the +// imports below keep each requirement alive. Nothing sets the depfloor tag, +// so no build compiles this file and the binary is unchanged. +// +// TestGoModKeepsSecurityFloors checks the versions. To raise a floor, run +// `go get @` and then `go mod tidy`. To drop one, remove its +// import here and its row in the test. +package depfloor + +import ( + // GHSA-fpmh-frq8-7rj8: zstd DecodeAll crash on crafted input, fixed in + // v1.20.2. Arrives through go.mongodb.org/mongo-driver/v2, which declares + // v1.19.2 and which mxcli uses only for its BSON packages. + _ "github.com/klauspost/compress/zstd" +) diff --git a/internal/depfloor/floor_test.go b/internal/depfloor/floor_test.go new file mode 100644 index 0000000000..8540ada9ad --- /dev/null +++ b/internal/depfloor/floor_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +package depfloor_test + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// floors are the lowest versions go.mod may require. Each one is a version a +// customer's dependency scan blocked a release on. The scan reads the module +// graph, not the binary, so a module counts even when no mxcli build compiles +// it. +var floors = []struct { + module, min, why string +}{ + {"github.com/klauspost/compress", "v1.20.2", "GHSA-fpmh-frq8-7rj8 (zstd DecodeAll crash), fixed in v1.20.2"}, + {"go.mongodb.org/mongo-driver/v2", "v2.9.2", "CVE-2026-81521, fixed in v2.8.2, regressed in v2.9.0 and v2.9.1, restored in v2.9.2"}, +} + +// TestGoModKeepsSecurityFloors fails if go.mod no longer requires a floored +// module at or above its floor. +// +// klauspost/compress is the case this exists for. mxcli imports none of its +// packages; it arrives through mongo-driver/v2, which declares v1.19.2. A +// hand-written require line is removed by the next `go mod tidy`, which is how +// the v1.20.3 floor from ako/mxcli#1493 was lost in ako/mxcli#1497 without any +// build or test noticing. floor.go keeps the requirement alive across tidy. +func TestGoModKeepsSecurityFloors(t *testing.T) { + b, err := os.ReadFile(filepath.Join("..", "..", "go.mod")) + if err != nil { + t.Fatalf("read go.mod: %v", err) + } + required := map[string]string{} + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "require ")) + if len(f) >= 2 && strings.HasPrefix(f[1], "v") { + required[f[0]] = f[1] + } + } + for _, fl := range floors { + got, ok := required[fl.module] + switch { + case !ok: + t.Errorf("go.mod does not require %s, so the module graph falls back to whatever a dependency declares.\n"+ + "Floor %s: %s.\n"+ + "Keep its import in internal/depfloor/floor.go and run `go mod tidy`; a hand-written require line does not survive tidy.", + fl.module, fl.min, fl.why) + case less(got, fl.min): + t.Errorf("go.mod requires %s %s, below the floor %s: %s", fl.module, got, fl.min, fl.why) + } + } +} + +// less reports whether semantic version a sorts before b. It compares only +// vMAJOR.MINOR.PATCH, which is all the floors use; a pre-release suffix sorts +// as its release. +func less(a, b string) bool { + pa, pb := parts(a), parts(b) + for i := range pa { + if pa[i] != pb[i] { + return pa[i] < pb[i] + } + } + return false +} + +func parts(v string) [3]int { + var p [3]int + v = strings.TrimPrefix(v, "v") + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + for i, s := range strings.SplitN(v, ".", 3) { + p[i], _ = strconv.Atoi(s) + } + return p +} + +func TestLess(t *testing.T) { + for _, c := range []struct { + a, b string + want bool + }{ + {"v1.19.2", "v1.20.2", true}, + {"v1.20.2", "v1.20.2", false}, + {"v1.20.3", "v1.20.2", false}, + {"v1.9.0", "v1.10.0", true}, + {"v2.9.2-rc1", "v2.9.2", false}, + } { + if got := less(c.a, c.b); got != c.want { + t.Errorf("less(%s, %s) = %v, want %v", c.a, c.b, got, c.want) + } + } +} diff --git a/mdl-examples/bug-tests/122-controlbar-show-page-grid-selection.mdl b/mdl-examples/bug-tests/122-controlbar-show-page-grid-selection.mdl new file mode 100644 index 0000000000..cc6e2c3ac9 --- /dev/null +++ b/mdl-examples/bug-tests/122-controlbar-show-page-grid-selection.mdl @@ -0,0 +1,55 @@ +mdl 1; +-- ============================================================================ +-- Control-bar SHOW PAGE passing the grid's selection (mendixlabs/mxcli#122) +-- ============================================================================ +-- +-- Symptom (before fix), Mendix 11.12.5: +-- - `Order = $currentObject` in the control bar: MDL-BUTTON01 (CE1571 — +-- a control bar is not row-scoped), whose hint said to pass `$dgOrders`; +-- - `Order = $dgOrders`: MDL-PAGEARG01 "a widget's page argument is always +-- the enclosing context object"; +-- - no argument: mx check CE1571 "No argument has been selected for +-- parameter 'OrderParam'". +-- There was no spelling that built. +-- +-- Fix: `$dgOrders` in dgOrders' control bar is the grid's selection, stored +-- as an explicit Forms$PageParameterMapping {Argument "", Variable: +-- Forms$PageVariable{Widget "dgOrders"}}. Measured on mxbuild 11.12.5: +-- that shape → 0 errors +-- Argument "$dgOrders" instead → CE0115 "arguments … do not match" +-- no mapping → CE1571 +-- In the running app the button is disabled until a row is selected, and +-- opens the detail page with the selected row (checked against the 2nd row). +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/122-controlbar-show-page-grid-selection.mdl -p app.mpr +-- mxcli docker check -p app.mpr → 0 errors +-- mxcli -p app.mpr -c "describe page BugTest122.Order_Overview" +-- → Action: show page BugTest122.Order_Detail(OrderParam = $dgOrders) +-- ============================================================================ + +create module BugTest122; + +create persistent entity BugTest122.Order (OrderNumber: String(50)); + +create page BugTest122.Order_Detail ( + Title: 'Order', + Layout: Atlas_Core.Atlas_Default, + Params: ( $OrderParam: BugTest122.Order ) +) { + dataview dv (DataSource: $OrderParam) { + textbox tbNumber (Label: 'Number', Attribute: OrderNumber) + } +}; + +create page BugTest122.Order_Overview ( + Title: 'Orders', + Layout: Atlas_Core.Atlas_Default +) { + datagrid dgOrders (DataSource: database from BugTest122.Order, Selection: Single) { + controlbar { + actionbutton btnView (Caption: 'View', Action: show page BugTest122.Order_Detail(OrderParam = $dgOrders), ButtonStyle: Primary) + } + column (Attribute: OrderNumber, Caption: 'Order') + } +}; diff --git a/mdl-examples/bug-tests/1271-page-xpath-member-added-by-script.mdl b/mdl-examples/bug-tests/1271-page-xpath-member-added-by-script.mdl new file mode 100644 index 0000000000..7e2083af4a --- /dev/null +++ b/mdl-examples/bug-tests/1271-page-xpath-member-added-by-script.mdl @@ -0,0 +1,46 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1271 (page data source): a widget XPath naming a member the +-- SAME script adds to a stored entity was "neither an attribute nor an +-- association of it" +-- ============================================================================ +-- +-- The microflow retrieve was fixed for this in v0.27.0; the page data source +-- still resolved the members against the project only: +-- page Shop.Order_Overview: datagrid "dgOrders": the constraint on Shop.Order +-- names "IsPaid", which is neither an attribute nor an association of it — +-- mxbuild reports this as CE1613 "The selected attribute 'IsPaid' no longer +-- exists" +-- Refusing to execute: 1 unresolved reference(s) above. Nothing was written. +-- It now uses the retrieve's script-aware resolver (scriptXPathResolver). +-- +-- The entities must ALREADY be in the project for the defect to show — a +-- script that creates them too was never affected. To verify: exec the SETUP +-- section alone, then `mxcli check` the rest with `-p app.mpr --references` +-- (passes), exec it, and `mxcli docker check` reports 0 errors. +-- ============================================================================ + +-- SETUP (exec first, on its own) +create module Bug1271; +create persistent entity Bug1271.Order (Number: String(50)); +create persistent entity Bug1271.Customer (Name: String(200)); + +-- SCRIPT UNDER TEST +-- An attribute added by ALTER ENTITY. +alter entity Bug1271.Order add attribute IsPaid: Boolean default false; +-- An attribute added by restating the entity. +create or modify persistent entity Bug1271.Customer (Name: String(200), Vip: Boolean default false); +-- An association the script creates between the two stored entities. +create association Bug1271.Order_Customer from Bug1271.Order to Bug1271.Customer type Reference; + +create page Bug1271.Order_Overview (Title: 'Orders', Layout: Atlas_Core.Atlas_Default) { + datagrid dgOrders (DataSource: database from Bug1271.Order where [IsPaid = true]) { + column (Attribute: Number, Caption: 'Number') + } + datagrid dgVip (DataSource: database from Bug1271.Order where [Bug1271.Order_Customer/Bug1271.Customer/Vip = true]) { + column (Attribute: Number, Caption: 'Number') + } + datagrid dgCustomers (DataSource: database from Bug1271.Customer where [Vip = true]) { + column (Attribute: Name, Caption: 'Name') + } +}; diff --git a/mdl-examples/bug-tests/1498-domain-model-export-levels.mdl b/mdl-examples/bug-tests/1498-domain-model-export-levels.mdl new file mode 100644 index 0000000000..ab517f050c --- /dev/null +++ b/mdl-examples/bug-tests/1498-domain-model-export-levels.mdl @@ -0,0 +1,64 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1498 (follow-up, point 2) — export levels on domain-model +-- elements +-- ============================================================================ +-- +-- Reported (v0.27.0): `check` reports CE5400 for an `export level api` +-- microflow that exposes a Hidden entity of a Protected (add-on) module, but +-- MDL had no way to make the entity Usable — `create persistent entity … +-- export level api` failed with "extraneous input 'export' expecting the start +-- of a statement", and the same for attributes, associations and enumerations. +-- +-- Measured on blank Mendix 11.14.0 apps with BugAddOn1498Dm set to Protected +-- (MDL cannot make a module Protected, so `make check-mdl` runs this as an +-- ordinary module, where mxbuild accepts either level — also measured): +-- +-- without the clauses CE1406 four times: MyFirstModule.BugUser1498Dm's two +-- parameters (Hidden entities), its change of the Hidden +-- attribute and its retrieve over the Hidden association. +-- Without that microflow: CE5400 for the entity and for +-- the enumeration API_Request_Post exposes — mxbuild +-- reports CE5400 only once no CE1406 is left. +-- with the clauses 0 errors +-- +-- An absent clause keeps the stored level: re-running a `create or modify` +-- without it never hides a Usable element. `describe` prints the clause for a +-- Usable element and omits the default. +-- ============================================================================ + +CREATE MODULE BugAddOn1498Dm; + +CREATE OR MODIFY PERSISTENT ENTITY BugAddOn1498Dm.Request ( + Payload: String(200) EXPORT LEVEL api, + Internal: String(50) +) EXPORT LEVEL api; + +CREATE OR MODIFY PERSISTENT ENTITY BugAddOn1498Dm.Item ( Name: String(50) ); +ALTER ENTITY BugAddOn1498Dm.Item SET EXPORT LEVEL api; + +CREATE OR MODIFY ASSOCIATION BugAddOn1498Dm.Item_Request + FROM BugAddOn1498Dm.Item TO BugAddOn1498Dm.Request + EXPORT LEVEL api; + +CREATE OR MODIFY ENUMERATION BugAddOn1498Dm.Kind ( A 'A', B 'B' ) EXPORT LEVEL api; + +-- A Usable microflow may expose them now (CE5400 while they were Hidden). +CREATE OR MODIFY MICROFLOW BugAddOn1498Dm.API_Request_Post ($body: BugAddOn1498Dm.Request, $k: ENUM BugAddOn1498Dm.Kind) +RETURNS Boolean +EXPORT LEVEL api +BEGIN + RETURN true; +END; + +-- Another module may use the Usable members (CE1406 while they were Hidden). +CREATE OR MODIFY MICROFLOW MyFirstModule.BugUser1498Dm ($r: BugAddOn1498Dm.Request, $i: BugAddOn1498Dm.Item) +BEGIN + CHANGE $r (Payload = 'x'); + RETRIEVE $Req FROM $i/BugAddOn1498Dm.Item_Request; +END; + +-- And back: the ALTER spellings set Hidden as well. +ALTER ENTITY BugAddOn1498Dm.Request MODIFY ATTRIBUTE Internal: String(50) EXPORT LEVEL hidden; +ALTER ASSOCIATION BugAddOn1498Dm.Item_Request SET EXPORT LEVEL api; +ALTER ENUMERATION BugAddOn1498Dm.Kind SET EXPORT LEVEL api; diff --git a/mdl-examples/bug-tests/1498-protected-module-member-refs.mdl b/mdl-examples/bug-tests/1498-protected-module-member-refs.mdl new file mode 100644 index 0000000000..27039863d1 --- /dev/null +++ b/mdl-examples/bug-tests/1498-protected-module-member-refs.mdl @@ -0,0 +1,56 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1498 — a Hidden attribute or association of a Protected +-- (add-on) module used from another module (CE1406) +-- ============================================================================ +-- +-- `check -p --references` reported CE1406 for a Hidden ENTITY, microflow or page +-- of an add-on module, and passed every flow that used a Hidden MEMBER of a +-- Usable entity. mxbuild refuses those too. Measured on a blank Mendix 11.14.0 +-- app, BugMembers1498 set to Protected, one microflow per shape, with Payload +-- and Item_Request Hidden: +-- +-- change $r (Payload = …) CE1406 … attribute +-- create … (Payload = …) CE1406 … attribute +-- change / create (… Item_Request = …) CE1406 … association +-- retrieve $x from $i/…Item_Request CE1406 … association +-- retrieve … where Payload = … CE1406 … attribute +-- retrieve … where …Item_Request = $r CE1406 … association +-- return $r/Payload CE1406 … attribute (End event) +-- return $i/…Item_Request != empty CE1406 … association (End event) +-- +-- The same uses of Usable members, and of the Hidden ones from inside the +-- add-on, were 0 errors; so was the whole script with the module ordinary. +-- `check` now reports each of the nine, with the statement that fixes it +-- (`alter entity … modify attribute Payload: String(200) export level api`, +-- `alter association … set export level api`); with both applied, check +-- passes and mxbuild reports 0 errors. This file as written: 0 from check and +-- from mxbuild; with the two members' clauses removed: 8 CE1406 from each. +-- +-- This file is the shape that must PASS: every member it uses is Usable. MDL +-- cannot make a module Protected and `make check-mdl` runs without a project, +-- so the refused cases are Go tests against a Protected module: +-- mdl/executor/validate_protected_module_refs_test.go. +-- ============================================================================ + +CREATE MODULE BugMembers1498; + +CREATE OR MODIFY PERSISTENT ENTITY BugMembers1498.Request ( + Payload: String(200) EXPORT LEVEL api +) EXPORT LEVEL api; +CREATE OR MODIFY PERSISTENT ENTITY BugMembers1498.Item ( Name: String(50) EXPORT LEVEL api ) EXPORT LEVEL api; +CREATE OR MODIFY ASSOCIATION BugMembers1498.Item_Request + FROM BugMembers1498.Item TO BugMembers1498.Request + EXPORT LEVEL api; + +CREATE OR MODIFY MICROFLOW MyFirstModule.BugMembers1498_Use ($r: BugMembers1498.Request, $i: BugMembers1498.Item) +RETURNS Boolean +BEGIN + CHANGE $r (Payload = 'x'); + $n = CREATE BugMembers1498.Request (Payload = 'y'); + CHANGE $i (BugMembers1498.Item_Request = $r); + RETRIEVE $Req FROM $i/BugMembers1498.Item_Request; + RETRIEVE $L FROM BugMembers1498.Request WHERE Payload = 'x'; + RETRIEVE $Items FROM BugMembers1498.Item WHERE BugMembers1498.Item_Request = $r; + RETURN $r/Payload != empty and $i/BugMembers1498.Item_Request != empty; +END; diff --git a/mdl-examples/bug-tests/1510-documentation-for-mappings-rest-javaparams.mdl b/mdl-examples/bug-tests/1510-documentation-for-mappings-rest-javaparams.mdl new file mode 100644 index 0000000000..b429548d76 --- /dev/null +++ b/mdl-examples/bug-tests/1510-documentation-for-mappings-rest-javaparams.mdl @@ -0,0 +1,68 @@ +-- mendixlabs/mxcli#1510: `lint` QUAL002 asks for documentation on import and +-- export mappings, published REST services, data transformers and Java action +-- parameters. MDL could not write the first four — the doc comment above their +-- create was dropped with MDL089 — so the findings were permanent. After the +-- fix each doc comment below is stored as the document's Documentation (a Java +-- action parameter's as its Description), `describe` prints it back, and QUAL002 +-- reports none of these elements. +-- +-- Check: exec, then `describe` each document, then `lint` — no QUAL002 for +-- BugTest1510. The data transformer needs Mendix 11.9+; drop it on older ones. +mdl 1; + +create module BugTest1510; + +/** One order line as the shop sends it. */ +create persistent entity BugTest1510.OrderLine (Code: String(20), Amount: Integer); + +/** The order line payload of the shop API. */ +create json structure BugTest1510.JSON_OrderLine sample '{"code": "A1", "amount": 2}'; + +/** + * Reads an order line from the shop API. + * + * One object per payload. + */ +create import mapping BugTest1510.IMM_OrderLine + with json structure BugTest1510.JSON_OrderLine +{ + create BugTest1510.OrderLine { + Code = code, + Amount = amount + } +}; + +/** Writes an order line for the shop API. */ +create export mapping BugTest1510.EMM_OrderLine + with json structure BugTest1510.JSON_OrderLine +{ + BugTest1510.OrderLine { + code = Code, + amount = Amount + } +}; + +/** The order line API other apps call. */ +create published rest service BugTest1510.OrderLineApi ( + Path: 'rest/orderlines/v1', + Version: '1.0.0', + ServiceName: 'Order lines' +) +{ +}; + +/** Formats an amount for display. */ +create java action BugTest1510.FormatAmount ( + /** The amount in cents. */ + Amount: Integer not null +) returns String +as $$ +return String.valueOf(Amount); +$$; + +/** Renames the amount field. */ +create data transformer BugTest1510.DT_Rename + source json '{"amount": 1}' +{ + jslt '{"total": .amount}'; +}; diff --git a/mdl-examples/bug-tests/1511-snippetcall-class-style.mdl b/mdl-examples/bug-tests/1511-snippetcall-class-style.mdl new file mode 100644 index 0000000000..23bfc89200 --- /dev/null +++ b/mdl-examples/bug-tests/1511-snippetcall-class-style.mdl @@ -0,0 +1,49 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1511 — Class / Style on a snippet call never reach the page +-- ============================================================================ +-- +-- A snippet call renders no element of its own: the snippet's widgets are +-- inlined into the parent. So the Class, Style, DynamicClasses and design +-- properties on `scStyled` are valid, `mx check` clean, and silently useless — +-- measured on Mendix 11.15.0 (run --local + playwright check): no element +-- carries `.probe-x`, `.probe-dyn`, the red border, `pull-right` or +-- `hide-tablet`, while container `ctl` with the same settings carries them all. +-- +-- Expected: `mxcli check` warns MDL-WIDGET48 once, on `scStyled` only (not on +-- `cWrap` or `ctl`, which are the fix and the control). Applied, `mxcli lint` +-- reports MPR013 on `scStyled`. After applying, a separate script with +-- alter page Bug1511.ProbePage { set (Class: 'x') on scPlain }; +-- warns MDL-WIDGET48 under `check -p … --references` (the target's type lives +-- in the stored page, so only the reference pass can tell it is a snippet call). + +create module Bug1511; + +create snippet Bug1511.ProbeSnippet { + container scInner { dynamictext scTxt (Content: 'Inside the snippet') } +}; + +create page Bug1511.ProbePage ( Title: 'Probe', Layout: Atlas_Core.Atlas_Default, Url: 'probe1511' ) { + container cOuter (Class: 'probe-outer') { + snippetcall scStyled ( + Snippet: Bug1511.ProbeSnippet, + Class: 'probe-x', + Style: 'border: 3px solid red', + DynamicClasses: 'probe-dyn', + DesignProperties: ('Align self': 'Right', 'Hide on': ('Tablet': on)) + ) + } + -- the fix: style a container around the call + container cWrap (Class: 'probe-wrap') { + snippetcall scPlain (Snippet: Bug1511.ProbeSnippet) + } + -- the control: the same settings on a container do render + container ctl ( + Class: 'probe-ctl', + Style: 'border: 3px solid blue', + DynamicClasses: 'probe-ctl-dyn', + DesignProperties: ('Align self': 'Left', 'Hide on': ('Desktop': on)) + ) { + dynamictext ctlTxt (Content: 'Control container') + } +}; diff --git a/mdl-examples/bug-tests/1512-alter-page-replace-column-attribute.mdl b/mdl-examples/bug-tests/1512-alter-page-replace-column-attribute.mdl new file mode 100644 index 0000000000..60219f8075 --- /dev/null +++ b/mdl-examples/bug-tests/1512-alter-page-replace-column-attribute.mdl @@ -0,0 +1,46 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1512: ALTER PAGE REPLACE of a Data grid 2 column with a new +-- Attribute passed check, then mxbuild failed +-- ============================================================================ +-- +-- Reported on v0.26.0 (11.15.0): +-- An attribute is required when 'Show' is set to 'Attribute'. Select the +-- 'Attribute' property for column (C) +-- plus the sorting variant. Reproduced verbatim with a build from before +-- mendixlabs/mxcli#1468's fix, on the MICROFLOW-sourced grid below only; every +-- other source kind built clean. It is #1468 (the column path took no entity +-- from a flow source), fixed since — this case pins the reporter's shape: a +-- DIFFERENT attribute than the column held. +-- +-- Found while reproducing: the REPLACE's own column address was never resolved +-- by check, so `replace grid1 column(C)` on a grid with no column C passed and +-- exec refused it. check now reports it (validateAlterPageReferences). +-- +-- To verify: exec, then `mxcli docker check` reports 0 errors. +-- ============================================================================ + +create module Bug1512; +create persistent entity Bug1512.Order (Number: String(50), Note: String(200), Total: Decimal); + +create microflow Bug1512.DS_Orders () returns list of Bug1512.Order as $L +begin + retrieve $L from Bug1512.Order; + return $L; +end; + +create page Bug1512.Orders (Title: 'Orders', Layout: Atlas_Core.Atlas_Default) { + datagrid dgDatabase (DataSource: database from Bug1512.Order) { + column (Attribute: Number, Caption: 'C') + } + datagrid dgMicroflow (DataSource: microflow Bug1512.DS_Orders) { + column (Attribute: Number, Caption: 'C') + } +}; + +alter page Bug1512.Orders { + replace dgDatabase column(Number) with { column (Attribute: Note, Caption: 'C') }; + replace dgMicroflow column(Number) with { column (Attribute: Note, Caption: 'C') }; + -- addressed by the column an earlier operation put there + replace dgMicroflow column(Note) with { column (Attribute: Total, Caption: 'T') }; +}; diff --git a/mdl-examples/bug-tests/1518-created-object-never-persisted.mdl b/mdl-examples/bug-tests/1518-created-object-never-persisted.mdl new file mode 100644 index 0000000000..e1e910f19c --- /dev/null +++ b/mdl-examples/bug-tests/1518-created-object-never-persisted.mdl @@ -0,0 +1,47 @@ +mdl 1; +-- mendixlabs/mxcli#1518: an object created without commit and never committed, +-- returned or passed on is discarded when the microflow ends. check, mx check +-- and the build are silent; lint rule CONV020 reports it. +-- +-- mxcli exec 1518-created-object-never-persisted.mdl -p app.mpr +-- mxcli lint -p app.mpr -r CONV020 +-- +-- Expected: exactly one CONV020 warning, on Tmp1518.Seed. The other three are +-- controls: committed, a non-persistent entity, and a nanoflow. + +create module Tmp1518; + +create persistent entity Tmp1518.Item (Cnt: Integer); +create non-persistent entity Tmp1518.Helper (Cnt: Integer); + +-- Reported: $o is never committed, returned or passed on. +create microflow Tmp1518.Seed () returns Boolean as $b +begin + declare $b Boolean = true; + $o = create Tmp1518.Item (Cnt = 1); + log info node 'Seed' 'Seeded {1}' with ({1} = toString($o/Cnt)); + return $b; +end; + +-- Not reported: committed. +create microflow Tmp1518.SeedCommitted () returns Boolean as $b +begin + declare $b Boolean = true; + $o = create Tmp1518.Item (Cnt = 1); + commit $o; + return $b; +end; + +-- Not reported: a non-persistent entity is meant to be temporary. +create microflow Tmp1518.TempHelper () returns Boolean as $b +begin + declare $b Boolean = true; + $h = create Tmp1518.Helper (Cnt = 1); + return $b; +end; + +-- Not reported: nanoflows are out of scope. +create nanoflow Tmp1518.SeedInClient () +begin + $o = create Tmp1518.Item (Cnt = 1); +end; diff --git a/mdl-examples/bug-tests/1520-describe-snippet-root-datasource.mdl b/mdl-examples/bug-tests/1520-describe-snippet-root-datasource.mdl new file mode 100644 index 0000000000..ee93d94fb6 --- /dev/null +++ b/mdl-examples/bug-tests/1520-describe-snippet-root-datasource.mdl @@ -0,0 +1,28 @@ +-- ============================================================================ +-- Bug test: mendixlabs/mxcli#1520 — DESCRIBE SNIPPET omits the root data +-- source of a snippet +-- ============================================================================ +-- +-- A data view inside a snippet, sourced from a snippet parameter, stores the +-- parameter in Forms$PageVariable.SnippetParameter with PageParameter empty. +-- DESCRIBE read PageParameter alone and printed the data view with no +-- DataSource, so exec of the described text failed with CE0488 (fixed with +-- ako/mxcli#721 L3; this script keeps the shape the report names). +-- +-- To verify: +-- 1. mxcli exec this script, then: describe snippet Bug1520S.S +-- -> dataview dv (DataSource: $View, …) +-- 2. exec that output -> "Unchanged snippet Bug1520S.S". +-- ============================================================================ + +mdl 1; + +create module Bug1520S; + +create persistent entity Bug1520S.Rec (Label: String(50)); + +create snippet Bug1520S.S (Params: ($View: Bug1520S.Rec)) { + dataview dv (DataSource: $View) { + textbox tb (Attribute: Label) + } +}; diff --git a/mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl b/mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl new file mode 100644 index 0000000000..b9bec32d31 --- /dev/null +++ b/mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl @@ -0,0 +1,50 @@ +mdl 1; +-- ============================================================================ +-- Regression: mendixlabs/mxcli#1532 - no boundary events on CALL WORKFLOW +-- ============================================================================ +-- +-- Symptom (before fix): +-- `call workflow … boundary event interrupting timer …` failed to parse: +-- mismatched input 'boundary' expecting ';' +-- mismatched input 'interrupting' expecting SUBPROCESS +-- The same clause after `call microflow` and `wait for notification` parsed, +-- and DESCRIBE printed it for a call workflow Studio Pro had given a timer — +-- so describe output of such a workflow did not re-parse. +-- +-- After fix: +-- The clause is the one call microflow uses. Measured with mx check on +-- Mendix 11.6.6 and 11.15.0: both parents below build with 0 errors, and +-- describe -> exec of them reports "already in sync". +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe workflow BugTest1532.WF_ParentInt" +-- mxcli docker check -p app.mpr -- expect 0 errors +-- ============================================================================ + +create module BugTest1532; +create persistent entity BugTest1532.Item ( Name: String(100) ); + +create workflow BugTest1532.WF_Child + parameter $WorkflowContext: BugTest1532.Item +begin + wait for timer childWait addDays([%CurrentDateTime%], 1); +end workflow; + +-- Interrupting: the deadline cancels the child and ends the parent. +create workflow BugTest1532.WF_ParentInt + parameter $WorkflowContext: BugTest1532.Item +begin + call workflow BugTest1532.WF_Child(WorkflowContext = $WorkflowContext) as callChild + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; }; +end workflow; + +-- Non-interrupting: the reminder path runs alongside the child. +create workflow BugTest1532.WF_ParentNonInt + parameter $WorkflowContext: BugTest1532.Item +begin + call workflow BugTest1532.WF_Child(WorkflowContext = $WorkflowContext) as callChild + boundary event non interrupting timer addHours([%CurrentDateTime%], 4) { + wait for timer remindWait addHours([%CurrentDateTime%], 1); + }; +end workflow; diff --git a/mdl-examples/bug-tests/1542-retrieve-xpath-keeps-line-breaks.mdl b/mdl-examples/bug-tests/1542-retrieve-xpath-keeps-line-breaks.mdl new file mode 100644 index 0000000000..3f37d0c247 --- /dev/null +++ b/mdl-examples/bug-tests/1542-retrieve-xpath-keeps-line-breaks.mdl @@ -0,0 +1,31 @@ +mdl 1; +-- mendixlabs/mxcli#1542 (ako/mxcli#1440) — a retrieve's XPath lost its line +-- breaks: the constraint below was stored as `[IsOpen = true() and Count > 0]`, +-- the line break and tab dropped, while every other expression slot keeps its +-- layout. Studio Pro keeps the layout an author gives a constraint, so a +-- describe/exec round trip changed the model. +-- +-- mxcli exec mdl-examples/bug-tests/1542-retrieve-xpath-keeps-line-breaks.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest1542.GetOpenNotes" +-- +-- Expected: the stored constraint is `[IsOpen = true() and\n\tCount > 0]`, and +-- describe prints it over the same two lines. GetFlatNotes is the control: a +-- constraint written on one line is stored on one line, as before. + +create module BugTest1542; +create persistent entity BugTest1542.Note (IsOpen: Boolean default false, Count: Integer); + +create microflow BugTest1542.GetOpenNotes () +returns list of BugTest1542.Note as $Notes +begin + retrieve $Notes from BugTest1542.Note where [IsOpen = true() and + Count > 0]; + return $Notes; +end; + +create microflow BugTest1542.GetFlatNotes () +returns list of BugTest1542.Note as $Notes +begin + retrieve $Notes from BugTest1542.Note where [IsOpen = true() and Count > 0]; + return $Notes; +end; diff --git a/mdl-examples/bug-tests/1584-dynamicclasses-quoted-attribute.mdl b/mdl-examples/bug-tests/1584-dynamicclasses-quoted-attribute.mdl new file mode 100644 index 0000000000..0ee6c39ebd --- /dev/null +++ b/mdl-examples/bug-tests/1584-dynamicclasses-quoted-attribute.mdl @@ -0,0 +1,28 @@ +mdl 1; +-- mendixlabs/mxcli#1584 (ako/mxcli#1489) — a quoted attribute segment in a +-- widget's DynamicClasses was stored with its quotes, `$currentObject/"Code"`, +-- while ContentParams and Visible on the same page stored `$currentObject/Code`. +-- check passed; mx check reported CE0117 "Error(s) in expression." at Text +-- 'txtCode'. +-- +-- mxcli exec mdl-examples/bug-tests/1584-dynamicclasses-quoted-attribute.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe page BugTest1584.Child_Detail" +-- +-- Expected: DynamicClasses describes as `if $currentObject/Code = empty …`, +-- and `mxcli docker check` reports no CE0117 at txtCode. + +create module BugTest1584; +create persistent entity BugTest1584.Child (Code: String(50), Name: String(100)); + +create or modify page BugTest1584.Child_Detail ( + Title: 'Child', + Layout: Atlas_Core.Atlas_Default, + Params: ( $Child: BugTest1584.Child ) +) { + dataview dvChild (DataSource: $Child) { + dynamictext txtCode (Content: '{1}', ContentParams: ({1} = if $currentObject/"Code" = empty then '-' else $currentObject/"Code"), DynamicClasses: if $currentObject/"Code" = empty then 'x' else '') + container ctnVis (Visible: $currentObject/"Code" != empty) { + dynamictext txtName (Content: '{1}', ContentParams: ({1} = "Name")) + } + } +}; diff --git a/mdl-examples/bug-tests/1586-describe-combobox-filter-expression-props.mdl b/mdl-examples/bug-tests/1586-describe-combobox-filter-expression-props.mdl new file mode 100644 index 0000000000..8fff2d4625 --- /dev/null +++ b/mdl-examples/bug-tests/1586-describe-combobox-filter-expression-props.mdl @@ -0,0 +1,54 @@ +-- ============================================================================ +-- Bug test: mendixlabs/mxcli#1586 — DESCRIBE PAGE omits expression-type +-- pluggable widget properties +-- ============================================================================ +-- +-- A combo box and the data grid filters have their own DESCRIBE readers, which +-- knew only the keys MDL has keywords for. Every other setting — written by +-- exec's explicit-property pass — described back as nothing: a combo box's +-- `customEditabilityExpression`, a text filter's `defaultValue` expression and +-- `delay`. A describe -> edit -> exec cycle reset them to their defaults. +-- +-- (A generic pluggable widget's top-level expression, the report's own +-- `entitypicker … Placeholder:`, is covered by mendixlabs/mxcli#1578.) +-- +-- To verify: +-- 1. mxcli exec this script, then: describe page Bug1586.Edit and +-- describe page Bug1586.Grid +-- -> combobox cb (…, customEditability: 'conditionally', +-- customEditabilityExpression: $currentObject/Locked) +-- -> textfilter tf (defaultValue: '''abc''', delay: 300) +-- 2. exec each output -> "Unchanged page". +-- 3. mx check: 0 errors. +-- ============================================================================ + +mdl 1; + +create module Bug1586; + +create persistent entity Bug1586.Parent (Name: String(50)); +create persistent entity Bug1586.Rec (Code: String(20), Amount: Integer, Locked: Boolean); +create association Bug1586.Rec_Parent from Bug1586.Rec to Bug1586.Parent; + +create page Bug1586.Edit (Title: 'Edit', Layout: Atlas_Core.Atlas_Default, Params: ($Rec: Bug1586.Rec)) { + dataview dv (DataSource: $Rec) { + combobox cb ( + Attribute: Bug1586.Rec_Parent, + DataSource: database from Bug1586.Parent, + CaptionAttribute: Name, + customEditability: conditionally, + customEditabilityExpression: '$currentObject/Locked' + ) + } +}; + +create page Bug1586.Grid (Title: 'Grid', Layout: Atlas_Core.Atlas_Default) { + datagrid dg (DataSource: database from Bug1586.Rec) { + column (Attribute: Code, Caption: 'Code') { + textfilter tf (defaultValue: '''abc''', delay: 300) + } + column (Attribute: Amount, Caption: 'Amount') { + numberfilter nf (defaultValue: 5) + } + } +}; diff --git a/mdl-examples/bug-tests/1588-java-action-param-description-return-name.mdl b/mdl-examples/bug-tests/1588-java-action-param-description-return-name.mdl new file mode 100644 index 0000000000..a563ab22c9 --- /dev/null +++ b/mdl-examples/bug-tests/1588-java-action-param-description-return-name.mdl @@ -0,0 +1,40 @@ +-- mendixlabs/mxcli#1588: `create java action` had no way to set a parameter's +-- description or the return value's name. `describe` printed both as `--` +-- comments (`Input: String not null -- The text to convert.` and +-- `-- return NAME: 'UpperText'`); written in a script they passed check and +-- exec and were dropped — the description empty, the return name the default +-- `ReturnValueName`. +-- After the fix a `/** … */` before a parameter is its description (as on an +-- entity attribute) and `returns T as $Name` names the return value (as on a +-- microflow), for Java and JavaScript actions alike; `describe` prints those +-- forms, so its output re-executes as "Unchanged". +mdl 1; + +create module BugTest1588; + +/** Converts text to upper case. */ +create java action BugTest1588.ToUpper( + /** The text to convert. */ + Input: String not null, + /** + * How to convert it. + * A description of two lines keeps both. + */ + Mode: String +) returns String as $UpperText +as $$ +return Input.toUpperCase(); +$$; + +create javascript action BugTest1588.JS_ToUpper( + /** The text to convert. */ + Input: String not null +) returns String as $UpperText +as $$ +return Input.toUpperCase(); +$$; + +-- Expect: both descriptions as `/** … */` before their parameters, and +-- `returns String as $UpperText`, in both actions. +describe java action BugTest1588.ToUpper; +describe javascript action BugTest1588.JS_ToUpper; diff --git a/mdl-examples/bug-tests/1591-pluggable-attribute-outside-data-view-entity.mdl b/mdl-examples/bug-tests/1591-pluggable-attribute-outside-data-view-entity.mdl new file mode 100644 index 0000000000..c898a68508 --- /dev/null +++ b/mdl-examples/bug-tests/1591-pluggable-attribute-outside-data-view-entity.mdl @@ -0,0 +1,51 @@ +mdl 1; +-- ============================================================================ +-- Pluggable attribute property bound outside the data view entity +-- (mendixlabs/mxcli#1591) +-- ============================================================================ +-- +-- Symptom (before fix), Mendix 11.12.5: +-- +-- 1. `eventDataAttribute: EventData` inside a data view over $Entry +-- (ChartEntry, which has no EventData) passed `check --references` and +-- `exec`; mx check then reported +-- [CE1613] "The selected attribute 'MyFirstModule.ChartEntry.EventData' +-- no longer exists." at Custom chart 'chart1' +-- 2. Written qualified (MyFirstModule.ChartContext.EventData) it was stored +-- as such, but DESCRIBE printed `eventDataAttribute: EventData`, and +-- re-running the description rebound it to ChartEntry — case 1 again. +-- +-- Root cause: +-- 1. misboundAttributeError refused a name only when ANOTHER entity in scope +-- had it, and let a name no entity has through as "a typo other checks +-- own" — none did. +-- 2. The generic pluggable describer shortened every attribute, and a data +-- view over a page parameter put the parameter's NAME in scope instead of +-- its entity, so the two could not be compared. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/1591-pluggable-attribute-outside-data-view-entity.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe page BugTest1591.ChartPage" +-- → eventDataAttribute: BugTest1591.ChartContext.EventData +-- Changing that line to `eventDataAttribute: EventData` is now refused by +-- check: "`EventData` is not an attribute of BugTest1591.ChartEntry …". +-- ============================================================================ + +create module BugTest1591; + +create non-persistent entity BugTest1591.ChartContext (EventData: String(unlimited)); +create non-persistent entity BugTest1591.ChartEntry (JsonData: String(unlimited), JsonLayout: String(unlimited)); + +create page BugTest1591.ChartPage ( + Title: 'Chart', + Layout: Atlas_Core.Atlas_Default, + Params: ( $Ctx: BugTest1591.ChartContext, $Entry: BugTest1591.ChartEntry ) +) { + dataview dvEntry (DataSource: $Entry) { + customchart chart1 ( + dataAttribute: JsonData, + layoutAttribute: JsonLayout, + eventDataAttribute: BugTest1591.ChartContext.EventData + ) + } +}; diff --git a/mdl-examples/bug-tests/1593-mcp-alter-page-in-folder.mdl b/mdl-examples/bug-tests/1593-mcp-alter-page-in-folder.mdl new file mode 100644 index 0000000000..bd6976107d --- /dev/null +++ b/mdl-examples/bug-tests/1593-mcp-alter-page-in-folder.mdl @@ -0,0 +1,51 @@ +mdl 1; +-- ============================================================================ +-- ALTER PAGE over MCP failed for every page inside a folder (mendixlabs/mxcli#1593) +-- ============================================================================ +-- +-- Symptom (before fix), MCP backend against Studio Pro 11.12.5: +-- +-- failed to open page for mutation: module not found: +-- +-- for a page in a folder (one level or A/B/Pages), while the same statement on +-- a page at the module root succeeded. `check --references` passed in both +-- cases, and the trace showed no PED tool call before the error. +-- +-- Root cause: +-- The MCP page mutator resolved the page's module with +-- GetModule(page.ContainerID). A page's ContainerID is its immediate parent, +-- which for a foldered page is the FOLDER — the in the error was the +-- folder's ID. The backend already had a folder-aware resolver +-- (moduleNameForContainer, walking the folder chain up to the module); the +-- page mutator did not use it. UpdateConstant (CREATE OR MODIFY constant), +-- UpdateWorkflow (CREATE OR MODIFY workflow) and the session registries' +-- DROP cleanup had the same lookup and are fixed with it. +-- +-- WHAT THIS FILE PROVES, AND WHAT IT DOES NOT +-- Run with the default (modelsdk) backend, this script only shows the shape +-- is valid MDL and that the modelsdk engine handles it — that engine never +-- had the bug. The defect lived in the MCP backend, which needs a live +-- Studio Pro; it is covered by mdl/backend/mcp/page_mutator_folder_test.go +-- against a fake Studio Pro. To reproduce live, open the app in Studio Pro +-- and run this script with `--mcp `. + +create module "Bug1593"; + +create page "Bug1593"."RootPage" (Title: 'Root', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtRoot (Content: 'root') +}; + +create page "Bug1593"."FolderPage" folder 'Pages' (Title: 'Folder', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtFolder (Content: 'folder') +}; + +create page "Bug1593"."NestedPage" folder 'A/B/Pages' (Title: 'Nested', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtNested (Content: 'nested') +}; + +-- control: module-root page always worked +alter page "Bug1593"."RootPage" { insert after txtRoot { dynamictext txtRoot2 (Content: 'root 2') } }; +-- reported: one folder deep +alter page "Bug1593"."FolderPage" { insert after txtFolder { dynamictext txtFolder2 (Content: 'folder 2') } }; +-- reported: nested folders +alter page "Bug1593"."NestedPage" { insert after txtNested { dynamictext txtNested2 (Content: 'nested 2') } }; diff --git a/mdl-examples/bug-tests/1597-alter-annotation-forms.mdl b/mdl-examples/bug-tests/1597-alter-annotation-forms.mdl new file mode 100644 index 0000000000..44953f9bde --- /dev/null +++ b/mdl-examples/bug-tests/1597-alter-annotation-forms.mdl @@ -0,0 +1,26 @@ +-- mendixlabs/mxcli#1597 (case B, the control): the two spellings of a note that +-- an `alter microflow … insert` fragment writes. The positional +-- `@annotation('text')` is refused as MDL079 — see +-- 1597-alter-annotation-positional.fail.mdl. +mdl 1; + +create module BugTest1597c; + +create persistent entity BugTest1597c.Item (Code: String(50)); + +create microflow BugTest1597c.ACT_Do () returns Boolean begin + $Result = create BugTest1597c.Item (Code = 'a'); + return true; +end; + +alter microflow BugTest1597c.ACT_Do { + insert after $Result begin + @annotation 'Why this commit is here.' + commit $Result; + @annotation(text: 'And why this log is.') + log info node 'n' 'committed'; + end; +}; + +-- Expect: both notes, attached to the commit and the log. +describe microflow BugTest1597c.ACT_Do; diff --git a/mdl-examples/bug-tests/1597-alter-annotation-positional.fail.mdl b/mdl-examples/bug-tests/1597-alter-annotation-positional.fail.mdl new file mode 100644 index 0000000000..0bb8b8a3d7 --- /dev/null +++ b/mdl-examples/bug-tests/1597-alter-annotation-positional.fail.mdl @@ -0,0 +1,30 @@ +-- ============================================================================ +-- mendixlabs/mxcli#1597 (case B, the refusal): `@annotation('text')` in an +-- `alter microflow … insert` fragment +-- ============================================================================ +-- +-- A positional parameter is not one the visitor reads, so the note is not +-- written at all. `create or modify microflow` refused the line as MDL079; +-- `alter` passed check and exec ("Altered microflow") and wrote the commit +-- without the note. See the companion 1597-alter-annotation-forms.mdl for the +-- two spellings that do write it. +-- +-- This file is expected to FAIL `mxcli check` (MDL079). +-- ============================================================================ +mdl 1; + +create module BugTest1597b; + +create persistent entity BugTest1597b.Item (Code: String(50)); + +create microflow BugTest1597b.ACT_Do () returns Boolean begin + $Result = create BugTest1597b.Item (Code = 'a'); + return true; +end; + +alter microflow BugTest1597b.ACT_Do { + insert after $Result begin + @annotation('Why this commit is here.') + commit $Result; + end; +}; diff --git a/mdl-examples/bug-tests/1597-curve-inside-loop-body.mdl b/mdl-examples/bug-tests/1597-curve-inside-loop-body.mdl new file mode 100644 index 0000000000..0a4c60e101 --- /dev/null +++ b/mdl-examples/bug-tests/1597-curve-inside-loop-body.mdl @@ -0,0 +1,41 @@ +-- mendixlabs/mxcli#1597 (case A): a `@curve` on a statement inside a `loop` or +-- `while` body passed check and exec and was not stored — `describe` printed the +-- body with no `@curve`, while the same annotation after the loop was kept. +-- After the fix the body's curve is written on the flow leaving that statement, +-- and `describe` prints it inside the loop as it does outside. +mdl 1; + +create module BugTest1597; + +create persistent entity BugTest1597.Item (Code: String(20)); + +create microflow BugTest1597.ACT_Loop () +begin + retrieve $L from BugTest1597.Item; + loop $I in $L + begin + @curve(from: (39, 0), to: (0, 61)) + log info node 'n' 'inside-a'; + log info node 'n' 'inside-b'; + end loop; + @curve(from: (39, 0), to: (0, 61)) + log info node 'n' 'outside-c'; + log info node 'n' 'outside-d'; +end; + +create microflow BugTest1597.ACT_While () +begin + declare $N integer = 0; + while $N < 3 + begin + @curve(from: (39, 0), to: (0, 61)) + log info node 'n' 'inside-a'; + log info node 'n' 'inside-b'; + set $N = $N + 1; + end while; +end; + +-- Expect: `@curve(from: (39, 0), to: (0, 61))` above `log … 'inside-a'` in both, +-- and above `log … 'outside-c'` in ACT_Loop. +describe microflow BugTest1597.ACT_Loop; +describe microflow BugTest1597.ACT_While; diff --git a/mdl-examples/bug-tests/1599-describe-gallery-sort-over-association.mdl b/mdl-examples/bug-tests/1599-describe-gallery-sort-over-association.mdl new file mode 100644 index 0000000000..fcc182785e --- /dev/null +++ b/mdl-examples/bug-tests/1599-describe-gallery-sort-over-association.mdl @@ -0,0 +1,41 @@ +-- ============================================================================ +-- Bug test: mendixlabs/mxcli#1599 — DESCRIBE PAGE drops a gallery's sort when +-- its "Database" source runs over an association +-- ============================================================================ +-- +-- Studio Pro stores a gallery bound to "Database" over an association from its +-- context object as a CustomWidgets$CustomWidgetXPathSource whose EntityRef is +-- an IndirectEntityRef, with its XPath and sort bar beside it. DESCRIBE read it +-- as the association source `$currentObject/Assoc`, without the sort; replayed, +-- that is a Forms$AssociationSource — a different retrieve. MDL could not +-- author the shape at all: `create page` refused `database from +-- $currentObject/…` on a gallery. +-- +-- To verify: +-- 1. mxcli exec this script, then: describe page Bug1599.P1 +-- -> gallery g1 (DataSource: database from +-- $currentObject/Bug1599.Item_Parent/Bug1599.Item sort by Code asc, …) +-- 2. exec that output -> "Unchanged page Bug1599.P1". +-- 3. mx check: 0 errors (measured on 11.12.1). +-- ============================================================================ + +mdl 1; + +create module Bug1599; + +create persistent entity Bug1599.Parent (Name: String(50)); +create persistent entity Bug1599.Item (Code: String(20), Amount: Integer); +create association Bug1599.Item_Parent from Bug1599.Item to Bug1599.Parent; + +create page Bug1599.P1 (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + listview lv (DataSource: database from Bug1599.Parent) { + gallery g1 ( + DataSource: database from $currentObject/Bug1599.Item_Parent/Bug1599.Item where [Amount > 0] sort by Code asc, + Selection: None + ) { + template { + dynamictext x (Content: '{1}', ContentParams: ({1} = Code)) + } + } + } +}; diff --git a/mdl-examples/bug-tests/1600-describe-specialization-persistability.mdl b/mdl-examples/bug-tests/1600-describe-specialization-persistability.mdl new file mode 100644 index 0000000000..7e9f114256 --- /dev/null +++ b/mdl-examples/bug-tests/1600-describe-specialization-persistability.mdl @@ -0,0 +1,25 @@ +-- mendixlabs/mxcli#1600: `describe entity Probe.Child` printed +-- `create or modify persistent entity Probe.Child extends Probe.Base (` for a +-- specialization of the NON-persistent Probe.Base. A specialization stores no +-- Persistable flag of its own; the reader defaulted it to true, so every +-- specialization read as persistent whatever its root said — in describe, in +-- `show entities where non-persistent`, the catalog, and the diagrams. +-- After the fix each specialization reads its root's flag, across modules and +-- into the System module (System.HttpResponse's root, HttpMessage, is +-- non-persistent), and re-running the describe output is a no-op. +mdl 1; + +create module BugTest1600; + +create non-persistent entity BugTest1600.Base (Code: String(20)); +create non-persistent entity BugTest1600.Child extends BugTest1600.Base (Status: String(20)); +create non-persistent entity BugTest1600.GrandChild extends BugTest1600.Child (Extra: String(20)); +create persistent entity BugTest1600.PBase (Code: String(20)); +create persistent entity BugTest1600.PChild extends BugTest1600.PBase (Amount: Integer); +create non-persistent entity BugTest1600.Response extends System.HttpResponse (Note: String(20)); + +-- Expect `non-persistent` for Child, GrandChild and Response; `persistent` for PChild. +describe entity BugTest1600.Child; +describe entity BugTest1600.GrandChild; +describe entity BugTest1600.PChild; +describe entity BugTest1600.Response; diff --git a/mdl-examples/bug-tests/1601-alter-entity-after-move-or-rename.mdl b/mdl-examples/bug-tests/1601-alter-entity-after-move-or-rename.mdl new file mode 100644 index 0000000000..7db1180774 --- /dev/null +++ b/mdl-examples/bug-tests/1601-alter-entity-after-move-or-rename.mdl @@ -0,0 +1,30 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1601: an entity an earlier MOVE or RENAME produced was +-- "entity not found" to an ALTER ENTITY in the same script +-- ============================================================================ +-- +-- check (v0.27.0): +-- statement 2: entity not found: Probe.Item (referenced by alter entity) +-- Refusing to execute: 2 unresolved reference(s) above. Nothing was written. +-- and with --no-check the script applied cleanly. The move / rename target was +-- recorded (scriptContext.indirectEntities) but the ALTER target check +-- (validateAlterTarget) only read the entities a CREATE declares. +-- +-- To verify: `mxcli check … -p app.mpr --references` passes, exec applies every +-- statement, and `mxcli docker check` reports 0 errors. +-- ============================================================================ + +-- The ALTERed entity must land in a module the PROJECT has: an ALTER into a +-- module the script creates was never checked, so it hid the defect. Hence +-- MyFirstModule, which every blank app has. +create module Bug1601; +create persistent entity Bug1601.Item (Code: String(20)); +create persistent entity MyFirstModule.Bug1601Gadget (Code: String(20)); + +move entity Bug1601.Item to MyFirstModule; +alter entity MyFirstModule.Item set position (930, 560); +alter entity MyFirstModule.Item set documentation 'moved'; + +rename entity MyFirstModule.Bug1601Gadget to Bug1601Thing; +alter entity MyFirstModule.Bug1601Thing set documentation 'renamed'; diff --git a/mdl-examples/bug-tests/199-association-path-as-value.mdl b/mdl-examples/bug-tests/199-association-path-as-value.mdl new file mode 100644 index 0000000000..9b79a47df2 --- /dev/null +++ b/mdl-examples/bug-tests/199-association-path-as-value.mdl @@ -0,0 +1,39 @@ +mdl 1; +-- mendixlabs/mxcli#199 (ako/mxcli#1231) — an association path used as a whole +-- value passed `mxcli check --references` and failed the build: +-- +-- return $Parent/Mod.Child_Parent; CE0117 "Error(s) in expression." at End event +-- loop $c in $Parent/Mod.Child_Parent CE0099 "The 'Iterate over' property is required." +-- +-- check now refuses both (MDL049, MDL094); the retrieve below is the activity +-- they mean, and it is what this script does. To see the refusal, change +-- GetChildren to `return $Parent/BugTest199.Child_Parent;` and run +-- `mxcli check` on it. +-- +-- mxcli check mdl-examples/bug-tests/199-association-path-as-value.mdl +-- mxcli exec mdl-examples/bug-tests/199-association-path-as-value.mdl -p app.mpr +-- +-- Expected: check passes, and `mxcli docker check` reports no error for +-- either microflow. GetParent is the control for the one path form that IS a +-- value: a reference followed from its owner with the target entity named. + +create module BugTest199; +create persistent entity BugTest199.Parent (Name: String(100)); +create persistent entity BugTest199.Child (Code: String(50)); +create association BugTest199.Child_Parent from BugTest199.Child to BugTest199.Parent; + +create microflow BugTest199.GetChildren ($Parent: BugTest199.Parent) +returns list of BugTest199.Child as $Children +begin + retrieve $Children from $Parent/BugTest199.Child_Parent; + loop $c in $Children begin + log info node 'BugTest199' $c/Code; + end loop; + return $Children; +end; + +create microflow BugTest199.GetParent ($Child: BugTest199.Child) +returns BugTest199.Parent as $P +begin + return $Child/BugTest199.Child_Parent/BugTest199.Parent; +end; diff --git a/mdl-examples/bug-tests/xpath-constraint-formatting.mdl b/mdl-examples/bug-tests/xpath-constraint-formatting.mdl index 21dfb15301..ff97e33c60 100644 --- a/mdl-examples/bug-tests/xpath-constraint-formatting.mdl +++ b/mdl-examples/bug-tests/xpath-constraint-formatting.mdl @@ -1,12 +1,10 @@ mdl 1; -- mendixlabs/mxcli#979 — a long XPath constraint is stored on one line. -- --- Filed as "preserve the whitespace I typed". Preserving is the weaker half: --- the formatting the reporter cares about lives in Studio Pro's constraint --- editor, and a constraint is rebuilt from its parse tree on every write, so --- there is no original whitespace left by the time anything is stored. What is --- stored instead is a layout DERIVED from the expression — which also covers a --- constraint a person formatted by hand in Studio Pro. +-- Filed as "preserve the whitespace I typed". A constraint written on ONE line +-- is stored with a layout DERIVED from the expression; one its author already +-- broke across lines is stored as written (mendixlabs/mxcli#1542, see +-- 1542-retrieve-xpath-keeps-line-breaks.mdl). -- -- Run this, then look at the stored constraints: -- diff --git a/mdl-examples/doctype-tests/domain-model-export-levels.mdl b/mdl-examples/doctype-tests/domain-model-export-levels.mdl new file mode 100644 index 0000000000..ec096ca8bd --- /dev/null +++ b/mdl-examples/doctype-tests/domain-model-export-levels.mdl @@ -0,0 +1,55 @@ +mdl 1; +-- ============================================================================= +-- DOMAIN-MODEL EXPORT LEVELS +-- ============================================================================= +-- +-- `export level api | hidden` on an entity, an attribute, an association and an +-- enumeration — the clause microflows already take. API is Studio Pro's +-- "Usable"; Hidden is the default. It matters in a Protected (add-on) module, +-- where mxbuild refuses CE5400 (a Usable microflow exposing a Hidden entity or +-- enumeration) and CE1406 (another module using a Hidden element). An absent +-- clause keeps what is stored. `mxcli syntax domain-model.export-level`. +-- Mendix 9.3+ (mendixlabs/mxcli#1498). +-- ============================================================================= + +CREATE MODULE ExportLevels; + +-- On create: the entity's clause follows the attribute list, an attribute's +-- follows its type and constraints. +CREATE PERSISTENT ENTITY ExportLevels.Customer ( + Name: String(100) NOT NULL EXPORT LEVEL api, + Notes: String(200) +) EXPORT LEVEL api; + +CREATE NON-PERSISTENT ENTITY ExportLevels.Filter EXPORT LEVEL api; + +CREATE PERSISTENT ENTITY ExportLevels.Order ( Number: String(20) EXPORT LEVEL api ) +INDEX (Number) +EXPORT LEVEL api; + +CREATE ASSOCIATION ExportLevels.Order_Customer + FROM ExportLevels.Order TO ExportLevels.Customer + TYPE Reference + EXPORT LEVEL api; + +CREATE ENUMERATION ExportLevels.Status ( Open 'Open', Closed 'Closed' ) EXPORT LEVEL api; + +-- On an existing element. +ALTER ENTITY ExportLevels.Customer SET EXPORT LEVEL hidden; +ALTER ENTITY ExportLevels.Customer MODIFY ATTRIBUTE Notes: String(200) EXPORT LEVEL api; +ALTER ENTITY ExportLevels.Customer ADD ATTRIBUTE Email: String(200) EXPORT LEVEL api; +ALTER ASSOCIATION ExportLevels.Order_Customer SET EXPORT LEVEL hidden; +ALTER ENUMERATION ExportLevels.Status SET EXPORT LEVEL hidden; + +-- A rewrite without the clause keeps the stored level: Order stays Usable. +CREATE OR MODIFY PERSISTENT ENTITY ExportLevels.Order ( Number: String(30) ) INDEX (Number); + +-- A Usable microflow. In a Protected module its signature must use Usable +-- elements: Order is, Status no longer is (CE5400 there — `check -p +-- --references` says so); in this ordinary module both are fine. +CREATE MICROFLOW ExportLevels.API_Open ($order: ExportLevels.Order, $status: ENUM ExportLevels.Status) +RETURNS Boolean +EXPORT LEVEL api +BEGIN + RETURN true; +END; diff --git a/mdl/ast/ast_association.go b/mdl/ast/ast_association.go index f7454d04ce..c4d2f8b35a 100644 --- a/mdl/ast/ast_association.go +++ b/mdl/ast/ast_association.go @@ -122,6 +122,10 @@ type CreateAssociationStmt struct { // pixels). (issue #872) FromAnchor *Position ToAnchor *Position + + // ExportLevel is "API" or "Hidden" from an EXPORT LEVEL clause; nil keeps + // the stored level (Hidden on a new element). mendixlabs/mxcli#1498. + ExportLevel *string } func (s *CreateAssociationStmt) isStatement() {} @@ -143,6 +147,7 @@ const ( AlterAssociationSetComment AlterAssociationSetStorage AlterAssociationSetAnchor + AlterAssociationSetExportLevel // SET EXPORT LEVEL API | HIDDEN ) // AlterAssociationStmt represents: ALTER ASSOCIATION Module.Name SET ... @@ -156,6 +161,7 @@ type AlterAssociationStmt struct { Owner OwnerType Storage StorageType Comment string + ExportLevel string // For SET EXPORT LEVEL: "API" or "Hidden" // SET ANCHOR FROM (x, y) TO (x, y) — both ends are always given together, // because the pair is one visual decision. diff --git a/mdl/ast/ast_attribute.go b/mdl/ast/ast_attribute.go index ca8ae01070..020afa014d 100644 --- a/mdl/ast/ast_attribute.go +++ b/mdl/ast/ast_attribute.go @@ -21,6 +21,9 @@ type Attribute struct { Comment string Documentation string RenamedFrom string // @RenamedFrom annotation value + // ExportLevel is "API" or "Hidden" from an EXPORT LEVEL clause; nil keeps + // the stored level (Hidden on a new attribute). mendixlabs/mxcli#1498. + ExportLevel *string } // ============================================================================ diff --git a/mdl/ast/ast_datatransformer.go b/mdl/ast/ast_datatransformer.go index bc4958d62d..59d4564320 100644 --- a/mdl/ast/ast_datatransformer.go +++ b/mdl/ast/ast_datatransformer.go @@ -13,6 +13,11 @@ type CreateDataTransformerStmt struct { SourceJSON string // the source content Steps []DataTransformerStepDef CreateOrModify bool // true for CREATE OR MODIFY / CREATE OR REPLACE + // Documentation is the `/** … */` doc comment before the statement + // (mendixlabs/mxcli#1510). DocumentationSet says one was written: absent + // keeps the stored documentation on a rewrite, empty clears it (#1018). + Documentation string + DocumentationSet bool } func (s *CreateDataTransformerStmt) isStatement() {} diff --git a/mdl/ast/ast_entity.go b/mdl/ast/ast_entity.go index b4616af2be..d11da9c0f2 100644 --- a/mdl/ast/ast_entity.go +++ b/mdl/ast/ast_entity.go @@ -48,6 +48,9 @@ type CreateEntityStmt struct { // comment clears it (mendixlabs/mxcli#1018). DocumentationSet bool CreateOrModify bool // true for CREATE OR MODIFY + // ExportLevel is "API" or "Hidden" from an EXPORT LEVEL clause; nil keeps + // the stored level (Hidden on a new element). mendixlabs/mxcli#1498. + ExportLevel *string } func (s *CreateEntityStmt) isStatement() {} @@ -80,6 +83,7 @@ const ( AlterEntityDropEventHandler // DROP EVENT HANDLER ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK AlterEntitySetAllowCreateChangeLocally // SET ALLOW_CREATE_CHANGE_LOCALLY = true/false AlterEntityDropDefault // DROP DEFAULT ON ATTRIBUTE — clear a default value + AlterEntitySetExportLevel // SET EXPORT LEVEL API | HIDDEN ) // EventHandlerDef represents an event handler in CREATE/ALTER ENTITY syntax. @@ -112,13 +116,17 @@ type AlterEntityStmt struct { ModifyUniqueError string ModifyHasDefault bool ModifyDefaultValue any - Documentation string // For SET DOCUMENTATION - Comment string // For SET COMMENT - Index *Index // For ADD INDEX, and DROP INDEX by column list - IndexName string // For DROP INDEX by ordinal name ("idx1", "idx2", ...) - Position *Position // For SET POSITION - EventHandler *EventHandlerDef // For ADD/DROP EVENT HANDLER - BoolValue bool // For SET ALLOW_CREATE_CHANGE_LOCALLY + // ModifyExportLevel is MODIFY ATTRIBUTE's EXPORT LEVEL clause; nil keeps + // the stored level. + ModifyExportLevel *string + ExportLevel string // For SET EXPORT LEVEL: "API" or "Hidden" + Documentation string // For SET DOCUMENTATION + Comment string // For SET COMMENT + Index *Index // For ADD INDEX, and DROP INDEX by column list + IndexName string // For DROP INDEX by ordinal name ("idx1", "idx2", ...) + Position *Position // For SET POSITION + EventHandler *EventHandlerDef // For ADD/DROP EVENT HANDLER + BoolValue bool // For SET ALLOW_CREATE_CHANGE_LOCALLY // Idempotency guards (findings #10). IfNotExists on ADD ATTRIBUTE skips the // add (with a notice) when the attribute already exists; IfExists on DROP // ATTRIBUTE skips the drop when it is already gone — so a domain script @@ -164,8 +172,9 @@ func (s *AlterEntitiesStmt) isStatement() {} // ViewAttribute represents an attribute in a view entity. type ViewAttribute struct { - Name string - Type DataType + Name string + Type DataType + ExportLevel *string // EXPORT LEVEL clause; nil keeps the stored level (#1498) } // OQLQuery represents a simplified OQL query for view entities. @@ -185,6 +194,8 @@ type CreateViewEntityStmt struct { DocumentationSet bool // see mendixlabs/mxcli#1018: absent preserves, empty clears CreateOrModify bool CreateOrReplace bool + // ExportLevel: see CreateEntityStmt.ExportLevel. + ExportLevel *string } func (s *CreateViewEntityStmt) isStatement() {} diff --git a/mdl/ast/ast_enumeration.go b/mdl/ast/ast_enumeration.go index 64c229d25d..bb08afe61c 100644 --- a/mdl/ast/ast_enumeration.go +++ b/mdl/ast/ast_enumeration.go @@ -69,6 +69,9 @@ type CreateEnumerationStmt struct { DocumentationSet bool Folder string // Module folder to place the enumeration in (Bug 12b) CreateOrModify bool // True if CREATE OR MODIFY was used + // ExportLevel is "API" or "Hidden" from an EXPORT LEVEL clause; nil keeps + // the stored level (Hidden on a new element). mendixlabs/mxcli#1498. + ExportLevel *string } func (s *CreateEnumerationStmt) isStatement() {} @@ -82,6 +85,8 @@ type AlterEnumerationStmt struct { Caption string // For ADD and MODIFY CAPTION // Documentation is the text of SET DOCUMENTATION. Documentation string + // ExportLevel is the "API" or "Hidden" of SET EXPORT LEVEL. + ExportLevel string // Idempotency guards, so a script that adds an enumeration value is // re-runnable. Without them the second run errors and exec STOPS THERE, @@ -104,6 +109,7 @@ const ( // AlterEnumSetDocumentation is SET DOCUMENTATION '…' (SET COMMENT is its // deprecated alias, MDL-DEPR135). AlterEnumSetDocumentation + AlterEnumSetExportLevel // SET EXPORT LEVEL API | HIDDEN ) // DropEnumerationStmt represents: DROP ENUMERATION Module.Name diff --git a/mdl/ast/ast_import_export_mapping.go b/mdl/ast/ast_import_export_mapping.go index ef25a666ca..9455594107 100644 --- a/mdl/ast/ast_import_export_mapping.go +++ b/mdl/ast/ast_import_export_mapping.go @@ -32,6 +32,11 @@ type CreateImportMappingStmt struct { RootElement *ImportMappingElementDef CreateOrModify bool // true for CREATE OR MODIFY / CREATE OR REPLACE Excluded bool // @excluded — document excluded from project + // Documentation is the `/** … */` doc comment before the statement + // (mendixlabs/mxcli#1510). DocumentationSet says one was written: absent + // keeps the stored documentation on a rewrite, empty clears it (#1018). + Documentation string + DocumentationSet bool } func (s *CreateImportMappingStmt) isStatement() {} @@ -112,6 +117,9 @@ type CreateExportMappingStmt struct { RootElement *ExportMappingElementDef CreateOrModify bool // true for CREATE OR MODIFY / CREATE OR REPLACE Excluded bool // @excluded — document excluded from project + // Documentation — see the note on CreateImportMappingStmt (#1510). + Documentation string + DocumentationSet bool } func (s *CreateExportMappingStmt) isStatement() {} diff --git a/mdl/ast/ast_javaaction.go b/mdl/ast/ast_javaaction.go index 9560510e97..162e43e640 100644 --- a/mdl/ast/ast_javaaction.go +++ b/mdl/ast/ast_javaaction.go @@ -11,6 +11,11 @@ type JavaActionParam struct { Name string // Parameter name Type DataType // Parameter type IsRequired bool // NOT NULL constraint + // Description is the `/** … */` doc comment written before the parameter. + // DescriptionSet says one was written: an absent comment keeps the stored + // description, an empty one clears it (mendixlabs/mxcli#1588). + Description string + DescriptionSet bool } // CreateJavaActionStmt represents: @@ -18,7 +23,7 @@ type JavaActionParam struct { // CREATE JAVA ACTION Module.Name( // EntityType: ENTITY NOT NULL, // Source: pEntity NOT NULL -// ) RETURNS type +// ) RETURNS type [AS $ReturnName] // EXPOSED AS 'caption' IN 'category' // AS $$ ... $$; type CreateJavaActionStmt struct { @@ -27,6 +32,7 @@ type CreateJavaActionStmt struct { Name QualifiedName // Qualified name (Module.ActionName) Parameters []JavaActionParam // Input parameters ReturnType DataType // Return type (can be nil for void) + ReturnName string // `returns T as $Name`: the return value's name; "" keeps the stored one (mendixlabs/mxcli#1588) JavaCode string // The executeAction() body ExtraCode string // Optional extra code section Imports []string // Optional additional imports @@ -57,7 +63,7 @@ func (s *DropJavaActionStmt) isStatement() {} // // CREATE JAVASCRIPT ACTION Module.Name( // Param: type NOT NULL -// ) RETURNS type +// ) RETURNS type [AS $ReturnName] // EXPOSED AS 'caption' IN 'category' // PLATFORM Web // AS $$ ... $$; @@ -70,6 +76,7 @@ type CreateJavaScriptActionStmt struct { Name QualifiedName // Qualified name (Module.ActionName) Parameters []JavaActionParam // Input parameters ReturnType DataType // Return type (can be nil for void) + ReturnName string // `returns T as $Name`; "" keeps the stored one (mendixlabs/mxcli#1588) JavaScriptCode string // The exported function body (user code) Documentation string // Optional documentation comment DocumentationSet bool // see mendixlabs/mxcli#1018: absent preserves, empty clears diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 2af068099e..577a5e9951 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -628,10 +628,15 @@ func (s *IfStmt) isMicroflowStatement() {} // LoopStmt represents: LOOP $Var IN $List BEGIN body END LOOP type LoopStmt struct { - LoopVariable string // Iterator variable name - ListVariable string // List variable name - Body []MicroflowStatement // Loop body - Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation + LoopVariable string // Iterator variable name + ListVariable string // List variable name + // ListPath is the source of a list written as a path, `$Parent/Mod.Assoc`. + // The model's loop iterates over a variable, so no path can be stored; + // the validator refuses it (MDL094) rather than the builder writing a loop + // over nothing (mendixlabs/mxcli#199). + ListPath string + Body []MicroflowStatement // Loop body + Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation } func (s *LoopStmt) isMicroflowStatement() {} diff --git a/mdl/ast/ast_rest.go b/mdl/ast/ast_rest.go index 3a442261d9..6ce15e8a33 100644 --- a/mdl/ast/ast_rest.go +++ b/mdl/ast/ast_rest.go @@ -118,6 +118,11 @@ type CreatePublishedRestServiceStmt struct { Authentication *PublishedRestAuthentication Resources []*PublishedRestResourceDef CreateOrModify bool + // Documentation is the `/** … */` doc comment before the statement + // (mendixlabs/mxcli#1510). DocumentationSet says one was written: absent + // keeps the stored documentation on a rewrite, empty clears it (#1018). + Documentation string + DocumentationSet bool } // PublishedRestAuthentication is `Authentication: none | ( method, … )`. diff --git a/mdl/ast/ast_workflow.go b/mdl/ast/ast_workflow.go index fdd67216b3..7a71a2617d 100644 --- a/mdl/ast/ast_workflow.go +++ b/mdl/ast/ast_workflow.go @@ -172,6 +172,7 @@ type WorkflowCallWorkflowNode struct { Workflow QualifiedName Caption string ParameterMappings []WorkflowParameterMappingNode + BoundaryEvents []WorkflowBoundaryEventNode // mendixlabs/mxcli#1532 } func (n *WorkflowCallWorkflowNode) workflowActivityNode() {} diff --git a/mdl/backend/mcp/constant.go b/mdl/backend/mcp/constant.go index a00826386b..870384f92f 100644 --- a/mdl/backend/mcp/constant.go +++ b/mdl/backend/mcp/constant.go @@ -47,7 +47,7 @@ func (b *Backend) CreateConstant(c *model.Constant) error { // set it directly (same as an attribute's type), so a type change is rejected // rather than silently ignored. func (b *Backend) UpdateConstant(c *model.Constant) error { - mod, err := b.GetModule(c.ContainerID) + modName, err := b.moduleNameForContainer(c.ContainerID) if err != nil { return fmt.Errorf("resolve module for constant %q: %w", c.Name, err) } @@ -55,7 +55,7 @@ func (b *Backend) UpdateConstant(c *model.Constant) error { if err != nil { return err } - qn := mod.Name + "." + c.Name + qn := modName + "." + c.Name if live, err := b.liveConstantType(qn); err == nil && live != "" && live != pedType { return fmt.Errorf("constant %q: changing its type (%s -> %s) is not supported by the MCP backend — PED can't set the type element in place; drop and recreate, or edit in Studio Pro", c.Name, live, pedType) } @@ -66,7 +66,7 @@ func (b *Backend) UpdateConstant(c *model.Constant) error { if err := b.pedUpdateDoc(constantDocType, qn, ops...); err != nil { return err } - b.markDirty(mod.Name) + b.markDirty(modName) b.upsertSessionConstant(c) return b.pedCheckDocument(constantDocType, qn) } @@ -167,7 +167,7 @@ func (b *Backend) upsertSessionConstant(c *model.Constant) { func (b *Backend) forgetSessionConstant(moduleName, name string) { out := b.sessionConstants[:0] for _, c := range b.sessionConstants { - if mod, err := b.GetModule(c.ContainerID); err == nil && mod.Name == moduleName && c.Name == name { + if mod, err := b.moduleNameForContainer(c.ContainerID); err == nil && mod == moduleName && c.Name == name { continue } out = append(out, c) diff --git a/mdl/backend/mcp/domainmodel.go b/mdl/backend/mcp/domainmodel.go index 0b2f4734b3..00018764d9 100644 --- a/mdl/backend/mcp/domainmodel.go +++ b/mdl/backend/mcp/domainmodel.go @@ -82,6 +82,9 @@ func (b *Backend) CreateEntity(domainModelID model.ID, entity *domainmodel.Entit if err := b.gateAttributeDefaults(entity.Attributes); err != nil { return err } + if err := guardExportLevels(entity, nil); err != nil { + return err + } value, err := b.buildEntityValue(entity) if err != nil { return err @@ -160,6 +163,9 @@ func (b *Backend) AddAttribute(domainModelID model.ID, entityID model.ID, attr * if err := b.gateAttributeDefaults([]*domainmodel.Attribute{attr}); err != nil { return err } + if err := guardExportLevels(&domainmodel.Entity{Name: name, Attributes: []*domainmodel.Attribute{attr}}, nil); err != nil { + return err + } idx, err := b.entityIndex(moduleName, name) if err != nil { return err @@ -212,6 +218,11 @@ func (b *Backend) UpdateEntity(domainModelID model.ID, entity *domainmodel.Entit if err := b.gateAttributeDefaults(entity.Attributes); err != nil { return err } + if statesExportLevel(entity) { + if err := guardExportLevels(entity, b.storedEntity(domainModelID, entity.Name)); err != nil { + return err + } + } entIdx, err := b.entityIndex(moduleName, entity.Name) if err != nil { return err @@ -632,6 +643,10 @@ func guardAssociationFeatures(a *domainmodel.Association) error { if a.Source != "" { return fmt.Errorf("association %q: external/OData associations are not supported by the MCP backend", a.Name) } + if a.ExportLevel != "" && a.ExportLevel != "Hidden" { + return fmt.Errorf("association %q: `export level %s` is not yet supported by the MCP backend; "+ + "run it against a local .mpr instead, or set it in Studio Pro", a.Name, strings.ToLower(a.ExportLevel)) + } return nil } @@ -969,6 +984,73 @@ func guardUnsupportedEntityFeatures(entity *domainmodel.Entity) error { return nil } +// guardExportLevels refuses an export level this backend would drop: PED is not +// sent one (mendixlabs/mxcli#1498), so `export level api` on a new entity or +// attribute — or any level that differs from the stored one on a rewrite — is +// refused rather than reported as written. stored is nil for a new entity; a new +// element's default, Hidden, is what PED creates anyway. +func guardExportLevels(entity, stored *domainmodel.Entity) error { + storedLevel := func(name string) string { + if stored == nil { + return "Hidden" + } + if name == "" { + return orHidden(stored.ExportLevel) + } + for _, a := range stored.Attributes { + if a.Name == name { + return orHidden(a.ExportLevel) + } + } + return "Hidden" + } + if entity.ExportLevel != "" && entity.ExportLevel != storedLevel("") { + return unsupportedEntityFeature(entity.Name, "export levels (`export level "+strings.ToLower(entity.ExportLevel)+"`)") + } + for _, a := range entity.Attributes { + if a.ExportLevel != "" && a.ExportLevel != storedLevel(a.Name) { + return unsupportedEntityFeature(entity.Name, "export levels (`export level "+strings.ToLower(a.ExportLevel)+"` on attribute "+a.Name+")") + } + } + return nil +} + +func orHidden(level string) string { + if level == "" { + return "Hidden" + } + return level +} + +func statesExportLevel(entity *domainmodel.Entity) bool { + if entity.ExportLevel != "" { + return true + } + for _, a := range entity.Attributes { + if a.ExportLevel != "" { + return true + } + } + return false +} + +// storedEntity is the entity as this backend reads it, or nil. +func (b *Backend) storedEntity(domainModelID model.ID, name string) *domainmodel.Entity { + if b.reader == nil { + return nil + } + dm, err := b.GetDomainModelByID(domainModelID) + if err != nil || dm == nil { + return nil + } + for _, e := range dm.Entities { + if e.Name == name { + return e + } + } + return nil +} + func unsupportedEntityFeature(entityName, feature string) error { return fmt.Errorf("entity %q: %s are not yet supported by the MCP backend (entity slice); create it against a local .mpr instead", entityName, feature) } diff --git a/mdl/backend/mcp/domainmodel_test.go b/mdl/backend/mcp/domainmodel_test.go index f63b40d12a..93b9fc6224 100644 --- a/mdl/backend/mcp/domainmodel_test.go +++ b/mdl/backend/mcp/domainmodel_test.go @@ -546,3 +546,37 @@ func TestCreateEntity_AttributeTypesAndLengthsOnTheWire(t *testing.T) { t.Errorf("length set on a default-length or non-String attribute: %s", updates[1]) } } + +// PED is not sent an export level (mendixlabs/mxcli#1498), so an authored one +// that would change what is stored is refused rather than reported as written. +func TestGuardExportLevels_RefusesWhatPEDWouldDrop(t *testing.T) { + api := newPersistentEntity("Request", attr("Payload", &domainmodel.StringAttributeType{Length: 10})) + api.ExportLevel = "API" + if err := guardExportLevels(api, nil); err == nil || !strings.Contains(err.Error(), "export level api") { + t.Errorf("new API entity: err = %v, want a refusal naming the clause", err) + } + attrAPI := newPersistentEntity("Request", attr("Payload", &domainmodel.StringAttributeType{Length: 10})) + attrAPI.Attributes[0].ExportLevel = "API" + if err := guardExportLevels(attrAPI, nil); err == nil || !strings.Contains(err.Error(), "attribute Payload") { + t.Errorf("new API attribute: err = %v, want a refusal naming the attribute", err) + } + // Controls: no clause, the default, and a rewrite that keeps the stored + // level are all what PED leaves in place. + plain := newPersistentEntity("Request", attr("Payload", &domainmodel.StringAttributeType{Length: 10})) + if err := guardExportLevels(plain, nil); err != nil { + t.Errorf("no clause refused: %v", err) + } + plain.ExportLevel = "Hidden" + if err := guardExportLevels(plain, nil); err != nil { + t.Errorf("hidden on a new entity refused: %v", err) + } + if err := guardExportLevels(api, api); err != nil { + t.Errorf("rewrite keeping the stored API level refused: %v", err) + } + if err := guardExportLevels(plain, api); err == nil { + t.Error("rewrite changing API to Hidden accepted, which PED would drop") + } + if err := guardAssociationFeatures(&domainmodel.Association{Name: "A", ExportLevel: "API"}); err == nil { + t.Error("association with export level api accepted") + } +} diff --git a/mdl/backend/mcp/enumeration.go b/mdl/backend/mcp/enumeration.go index c4e2364860..74220766d4 100644 --- a/mdl/backend/mcp/enumeration.go +++ b/mdl/backend/mcp/enumeration.go @@ -4,6 +4,7 @@ package mcp import ( "fmt" + "strings" "github.com/mendixlabs/mxcli/model" ) @@ -14,6 +15,11 @@ const enumerationDocType = "Enumerations$Enumeration" // registers it in the session so it is visible to ListEnumerations this run // (e.g. a subsequent CREATE ENTITY with an attribute of this enum type). func (b *Backend) CreateEnumeration(enum *model.Enumeration) error { + // PED is not sent an export level (mendixlabs/mxcli#1498): refused, not dropped. + if enum.ExportLevel != "" && enum.ExportLevel != "Hidden" { + return fmt.Errorf("enumeration %q: `export level %s` is not yet supported by the MCP backend; "+ + "run it against a local .mpr instead, or set it in Studio Pro", enum.Name, strings.ToLower(enum.ExportLevel)) + } moduleName, folderPath, err := b.resolveDocContainer(enum.ContainerID) if err != nil { return fmt.Errorf("resolve container for enumeration %q: %w", enum.Name, err) @@ -60,7 +66,7 @@ func (b *Backend) DeleteEnumeration(id model.ID) error { func (b *Backend) forgetSessionEnum(moduleName, name string) { out := b.sessionEnums[:0] for _, e := range b.sessionEnums { - if mod, err := b.GetModule(e.ContainerID); err == nil && mod.Name == moduleName && e.Name == name { + if mod, err := b.moduleNameForContainer(e.ContainerID); err == nil && mod == moduleName && e.Name == name { continue } out = append(out, e) diff --git a/mdl/backend/mcp/page_mutator.go b/mdl/backend/mcp/page_mutator.go index 370c0a7a06..2a234e8c41 100644 --- a/mdl/backend/mcp/page_mutator.go +++ b/mdl/backend/mcp/page_mutator.go @@ -35,26 +35,29 @@ func (b *Backend) OpenPageForMutation(unitID model.ID) (backend.PageMutator, err // resolvePageUnit maps a page unit ID to its module and page name (needed for the // pg_* tools, which are name-addressed). Saved pages come from the local reader; -// pages created over MCP this session come from sessionPages. +// pages created over MCP this session come from sessionPages. A page's ContainerID +// is its immediate parent — usually a folder, not the module — so the module is +// found by walking up the folder chain (mendixlabs/mxcli#1593). func (b *Backend) resolvePageUnit(unitID model.ID) (moduleName, pageName string, err error) { - for _, p := range b.sessionPages { - if p.ID == unitID { - mod, e := b.reader.GetModule(p.ContainerID) - if e != nil { - return "", "", e - } - return mod.Name, p.Name, nil + var p *pages.Page + for _, sp := range b.sessionPages { + if sp.ID == unitID { + p = sp + break } } - p, e := b.reader.GetPage(unitID) - if e != nil { - return "", "", fmt.Errorf("resolve page %s for mutation: %w", unitID, e) + if p == nil { + saved, e := b.reader.GetPage(unitID) + if e != nil { + return "", "", fmt.Errorf("resolve page %s for mutation: %w", unitID, e) + } + p = saved } - mod, e := b.reader.GetModule(p.ContainerID) + mod, e := b.moduleNameForContainer(p.ContainerID) if e != nil { - return "", "", e + return "", "", fmt.Errorf("resolve module of page %s: %w", p.Name, e) } - return mod.Name, p.Name, nil + return mod, p.Name, nil } type mcpPageMutator struct { diff --git a/mdl/backend/mcp/page_mutator_folder_test.go b/mdl/backend/mcp/page_mutator_folder_test.go new file mode 100644 index 0000000000..699bb9af80 --- /dev/null +++ b/mdl/backend/mcp/page_mutator_folder_test.go @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// A page's ContainerID is its immediate parent, which for most Studio Pro pages is +// a folder. resolvePageUnit handed that ID straight to GetModule, so ALTER PAGE over +// MCP failed for every foldered page with "module not found: " before any +// pg_* call (mendixlabs/mxcli#1593). Root pages, whose parent IS the module, worked. +func TestResolvePageUnit_FolderedPages(t *testing.T) { + mod := &model.Module{Name: "MyFirstModule"} + mod.ID = "mod1" + folders := []*types.FolderInfo{ + {ID: "fA", ContainerID: "mod1", Name: "A"}, + {ID: "fB", ContainerID: "fA", Name: "B"}, + {ID: "fPages", ContainerID: "fB", Name: "Pages"}, + } + saved := map[model.ID]*pages.Page{} + for _, p := range []struct{ id, container, name string }{ + {"pRoot", "mod1", "RootPage"}, + {"pFolder", "fA", "FolderPage"}, + {"pNested", "fPages", "NestedPage"}, + } { + pg := &pages.Page{} + pg.ID = model.ID(p.id) + pg.ContainerID = model.ID(p.container) + pg.Name = p.name + saved[pg.ID] = pg + } + reader := &mock.MockBackend{ + // The local reader resolves only true module IDs, exactly as modelsdk does. + GetModuleFunc: func(id model.ID) (*model.Module, error) { + if id == mod.ID { + return mod, nil + } + return nil, fmt.Errorf("module not found: %s", id) + }, + ListFoldersFunc: func() ([]*types.FolderInfo, error) { return folders, nil }, + GetPageFunc: func(id model.ID) (*pages.Page, error) { + if p, ok := saved[id]; ok { + return p, nil + } + return nil, fmt.Errorf("page not found: %s", id) + }, + } + session := &pages.Page{} + session.ID = "pSession" + session.ContainerID = "fA" + session.Name = "SessionPage" + b := &Backend{reader: reader, sessionPages: []*pages.Page{session}} + + cases := []struct { + id model.ID + wantPage string + }{ + {"pRoot", "RootPage"}, // control: module-root page always resolved + {"pFolder", "FolderPage"}, // one folder deep (the reported case) + {"pNested", "NestedPage"}, // A/B/Pages + {"pSession", "SessionPage"}, // created over MCP this session, in a folder + } + for _, c := range cases { + mn, pn, err := b.resolvePageUnit(c.id) + if err != nil { + t.Errorf("resolvePageUnit(%s): %v", c.id, err) + continue + } + if mn != "MyFirstModule" || pn != c.wantPage { + t.Errorf("resolvePageUnit(%s) = (%q, %q), want (%q, %q)", c.id, mn, pn, "MyFirstModule", c.wantPage) + } + } + + // A page whose container chain is genuinely broken still errors. + saved["pOrphan"] = &pages.Page{} + saved["pOrphan"].ID = "pOrphan" + saved["pOrphan"].ContainerID = "nowhere" + if _, _, err := b.resolvePageUnit("pOrphan"); err == nil { + t.Error("orphaned page should fail to resolve") + } +} + +// End to end over a fake Studio Pro: the reporter's trace showed no PED call before +// the error. With the module resolved, the read goes out addressed by module name. +func TestOpenPageForMutation_FolderedPageReachesPgReadPage(t *testing.T) { + f := newFakePED(t, func(name string, _ map[string]any) (string, bool) { + if name == "pg_read_page" { + return `{"layout":"Atlas_Core.Atlas_Default","widgets":[]}`, false + } + return "{}", false + }) + mod := &model.Module{Name: "MyFirstModule"} + mod.ID = "mod1" + pg := &pages.Page{} + pg.ID = "p1" + pg.ContainerID = "fPages" + pg.Name = "Customer_Overview" + b := &Backend{ + client: f.connectClient(t), + reader: &mock.MockBackend{GetModuleFunc: func(id model.ID) (*model.Module, error) { + return nil, fmt.Errorf("module not found: %s", id) + }}, + sessionModules: []*model.Module{mod}, + sessionFolders: []*types.FolderInfo{{ID: "fPages", ContainerID: "mod1", Name: "Pages"}}, + sessionPages: []*pages.Page{pg}, + } + if _, err := b.OpenPageForMutation("p1"); err != nil { + t.Fatalf("OpenPageForMutation: %v", err) + } + call, ok := f.callByName("pg_read_page") + if !ok { + t.Fatal("pg_read_page was never called") + } + if call.Args["moduleName"] != "MyFirstModule" || call.Args["pageName"] != "Customer_Overview" { + t.Errorf("pg_read_page args = %v, want MyFirstModule.Customer_Overview", call.Args) + } +} + +// The same immediate-container lookup sat in the constant and workflow update paths +// (CREATE OR MODIFY on a foldered document) and in the session-registry cleanup. +func folderedBackend(t *testing.T, f *fakePED) *Backend { + t.Helper() + mod := &model.Module{Name: "M"} + mod.ID = "mod1" + b := &Backend{ + sessionModules: []*model.Module{mod}, + sessionFolders: []*types.FolderInfo{ + {ID: "f1", ContainerID: "mod1", Name: "Config"}, + {ID: "f2", ContainerID: "f1", Name: "Nested"}, + }, + } + if f != nil { + b.client = f.connectClient(t) + } + return b +} + +func documentNamesSent(f *fakePED) []string { + var out []string + for _, c := range f.calls { + if n, _ := c.Args["documentName"].(string); n != "" { + out = append(out, n) + } + } + return out +} + +func TestUpdateConstant_FolderedConstant(t *testing.T) { + f := newFakePED(t, func(name string, _ map[string]any) (string, bool) { + if name == "ped_check_errors" { + return "No errors found.", false + } + return "SUCCESS", false + }) + b := folderedBackend(t, f) + c := &model.Constant{Name: "ApiUrl", Type: model.ConstantDataType{Kind: "String"}, DefaultValue: "x"} + c.ID = "c1" + c.ContainerID = "f2" + if err := b.UpdateConstant(c); err != nil { + t.Fatalf("UpdateConstant: %v", err) + } + names := documentNamesSent(f) + if len(names) == 0 || names[0] != "M.ApiUrl" { + t.Errorf("PED calls addressed %v, want M.ApiUrl", names) + } +} + +func TestUpdateWorkflow_FolderedWorkflow(t *testing.T) { + f := newFakePED(t, func(name string, _ map[string]any) (string, bool) { + if name == "ped_check_errors" { + return "No errors found.", false + } + return "SUCCESS", false + }) + b := folderedBackend(t, f) + wf := &workflows.Workflow{ + Name: "WF", + Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{ + &workflows.StartWorkflowActivity{BaseWorkflowActivity: wfBase("Start", "Start")}, + &workflows.EndWorkflowActivity{BaseWorkflowActivity: wfBase("End", "End")}, + }}, + } + wf.ID = "wf1" + wf.ContainerID = "f1" + err := b.UpdateWorkflow(wf) + if err != nil && strings.Contains(err.Error(), "resolve module") { + t.Fatalf("UpdateWorkflow: %v", err) + } + names := documentNamesSent(f) + if len(names) == 0 || names[0] != "M.WF" { + t.Errorf("PED calls addressed %v, want M.WF", names) + } +} + +func TestForgetSessionDocuments_Foldered(t *testing.T) { + b := folderedBackend(t, nil) + c := &model.Constant{Name: "ApiUrl"} + c.ContainerID = "f2" + e := &model.Enumeration{Name: "Status"} + e.ContainerID = "f1" + b.sessionConstants = []*model.Constant{c} + b.sessionEnums = []*model.Enumeration{e} + b.forgetSessionConstant("M", "ApiUrl") + b.forgetSessionEnum("M", "Status") + if len(b.sessionConstants) != 0 || len(b.sessionEnums) != 0 { + t.Errorf("dropped foldered documents still registered: %d constant(s), %d enumeration(s)", + len(b.sessionConstants), len(b.sessionEnums)) + } +} diff --git a/mdl/backend/mcp/workflow.go b/mdl/backend/mcp/workflow.go index 7b53bfeb7c..40c2fe82f2 100644 --- a/mdl/backend/mcp/workflow.go +++ b/mdl/backend/mcp/workflow.go @@ -73,11 +73,11 @@ func (b *Backend) CreateWorkflow(wf *workflows.Workflow) error { // stable. exportLevel and overviewPage are not reapplied (no settable PED path, as // in ALTER WORKFLOW). func (b *Backend) UpdateWorkflow(wf *workflows.Workflow) error { - mod, err := b.GetModule(wf.ContainerID) + modName, err := b.moduleNameForContainer(wf.ContainerID) if err != nil { return fmt.Errorf("resolve module for workflow %q: %w", wf.Name, err) } - qn := mod.Name + "." + wf.Name + qn := modName + "." + wf.Name flowVal, err := b.mapWorkflowFlow(wf.Flow) if err != nil { @@ -192,7 +192,7 @@ func (b *Backend) UpdateWorkflow(wf *workflows.Workflow) error { if err := b.applyTimerBoundaryEvents(qn, wf, order); err != nil { return err } - b.markDirty(mod.Name) + b.markDirty(modName) b.upsertSessionWorkflow(wf) return b.pedCheckDocument(workflowDocType, qn) } diff --git a/mdl/backend/modelsdk/association_move_write.go b/mdl/backend/modelsdk/association_move_write.go index 42f03490ee..63a4ce8bd2 100644 --- a/mdl/backend/modelsdk/association_move_write.go +++ b/mdl/backend/modelsdk/association_move_write.go @@ -55,7 +55,7 @@ func crossAssocToGen(ca *domainmodel.CrossModuleAssociation) *genDm.CrossAssocia } out.SetName(ca.Name) out.SetDocumentation(ca.Documentation) - out.SetExportLevel("Hidden") + out.SetExportLevel(orDefault(ca.ExportLevel, "Hidden")) out.SetParentID(element.ID(string(ca.ParentID))) out.SetChildQualifiedName(ca.ChildRef) out.SetType(string(ca.Type)) diff --git a/mdl/backend/modelsdk/cw_xpath_source_over_association_test.go b/mdl/backend/modelsdk/cw_xpath_source_over_association_test.go new file mode 100644 index 0000000000..8233b47d5e --- /dev/null +++ b/mdl/backend/modelsdk/cw_xpath_source_over_association_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/modelsdk/codec" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1599. On 10+ a gallery's "Database" source reached over an +// association is a CustomWidgets$CustomWidgetXPathSource whose EntityRef is an +// IndirectEntityRef of the hops, with its sort bar beside it — the shape the +// report's bson dump shows. The writer stored only a DirectEntityRef, so the +// hops (and with them the context restriction) could not be written at all. +func TestCustomWidgetXPathSourceToGen_OverAssociation(t *testing.T) { + el, err := customWidgetDataSourceToGen(&pages.DatabaseSource{ + EntityName: "Probe.Item", + EntitySteps: []pages.AttributeRefStep{{ + Association: "Probe.Item_Parent", DestinationEntity: "Probe.Item", + }}, + ContextVariable: "Parent", + Sorting: []*pages.GridSort{{ + AttributePath: "Probe.Item.Code", Direction: "Ascending", + }}, + }) + if err != nil { + t.Fatalf("customWidgetDataSourceToGen: %v", err) + } + raw, err := (&codec.Encoder{}).Encode(el) + if err != nil { + t.Fatalf("encode: %v", err) + } + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := lookup(d, "$Type"); got != "CustomWidgets$CustomWidgetXPathSource" { + t.Fatalf("$Type = %v, want CustomWidgets$CustomWidgetXPathSource", got) + } + ref, _ := lookup(d, "EntityRef").(bson.D) + if lookup(ref, "$Type") != "DomainModels$IndirectEntityRef" { + t.Fatalf("EntityRef = %v, want an IndirectEntityRef", ref) + } + steps, _ := lookup(ref, "Steps").(bson.A) + if len(steps) != 2 { + t.Fatalf("Steps = %v, want one hop", steps) + } + step, _ := steps[1].(bson.D) + if lookup(step, "Association") != "Probe.Item_Parent" || lookup(step, "DestinationEntity") != "Probe.Item" { + t.Errorf("step = %v, want Probe.Item_Parent → Probe.Item", step) + } + sv, _ := lookup(d, "SourceVariable").(bson.D) + if lookup(sv, "PageParameter") != "Parent" { + t.Errorf("SourceVariable = %v, want page parameter Parent", sv) + } + bar, _ := lookup(d, "SortBar").(bson.D) + if items, _ := lookup(bar, "SortItems").(bson.A); len(items) != 2 { + t.Errorf("SortBar.SortItems = %v, want one sort item", items) + } +} diff --git a/mdl/backend/modelsdk/data_transformer_write.go b/mdl/backend/modelsdk/data_transformer_write.go index 2aaf5e1fef..e4cff3f1e6 100644 --- a/mdl/backend/modelsdk/data_transformer_write.go +++ b/mdl/backend/modelsdk/data_transformer_write.go @@ -26,7 +26,8 @@ func (b *Backend) ListDataTransformers() ([]*model.DataTransformer, error) { out := make([]*model.DataTransformer, 0, len(units)) for _, u := range units { g := u.Element - dt := &model.DataTransformer{ContainerID: model.ID(u.ContainerID), Name: g.Name(), Excluded: g.Excluded()} + dt := &model.DataTransformer{ContainerID: model.ID(u.ContainerID), Name: g.Name(), Excluded: g.Excluded(), + Documentation: g.Documentation()} // mendixlabs/mxcli#1510 dt.ID = model.ID(g.ID()) dt.TypeName = "DataTransformers$DataTransformer" if src := g.Source(); src != nil { @@ -154,7 +155,7 @@ func dataTransformerToGen(dt *model.DataTransformer) element.Element { g := newElem("DataTransformers$DataTransformer", string(dt.ID)) addStr(g, "Name", dt.Name) - addStr(g, "Documentation", "") + addStr(g, "Documentation", dt.Documentation) addBool(g, "Excluded", dt.Excluded) addStr(g, "ExportLevel", "Hidden") addPart(g, "Source", source) diff --git a/mdl/backend/modelsdk/domainmodel.go b/mdl/backend/modelsdk/domainmodel.go index 2c453553b6..42b8bf3c37 100644 --- a/mdl/backend/modelsdk/domainmodel.go +++ b/mdl/backend/modelsdk/domainmodel.go @@ -12,6 +12,7 @@ import ( genDm "github.com/mendixlabs/mxcli/modelsdk/gen/domainmodels" genRest "github.com/mendixlabs/mxcli/modelsdk/gen/rest" genTexts "github.com/mendixlabs/mxcli/modelsdk/gen/texts" + "github.com/mendixlabs/mxcli/modelsdk/meta" "github.com/mendixlabs/mxcli/modelsdk/mprread" "github.com/mendixlabs/mxcli/model" @@ -40,6 +41,7 @@ func (b *Backend) ListDomainModels() ([]*domainmodel.DomainModel, error) { // The System module is virtual (not stored in the project); inject its domain // model so platform entities (System.WorkflowUserTask, User, …) resolve. out = append(out, buildSystemDomainModel()) + b.resolveInheritedPersistability(units, out...) return out, nil } @@ -90,6 +92,94 @@ func (b *Backend) populateViewEntityOql(dms []*domainmodel.DomainModel) { } } +// persistabilityLink is what one entity's own generalization element says about +// persistability: a root stores the flag, a specialization names its parent. +type persistabilityLink struct { + persistable bool + parent string +} + +// resolveInheritedPersistability sets Persistable on every specialization in dms +// to its root generalization's. Mendix stores the flag only on a root's +// NoGeneralization — a Generalization element has no such property — so +// entityFromGen cannot know it, and its default made every specialization of a +// non-persistent entity read as persistent: DESCRIBE printed `persistent entity +// … extends` for it, and `show entities where non-persistent`, the catalog and +// the microflow builder all treated it as a table (mendixlabs/mxcli#1600). +// +// The chain may cross modules and end in the virtual System module, so the index +// is built over every stored domain model in units plus the System entities. A +// chain that leaves the project or loops keeps the default: there is no root to +// read, and the model is already unloadable (CE1613). +func (b *Backend) resolveInheritedPersistability(units []mprread.Unit[*genDm.DomainModel], dms ...*domainmodel.DomainModel) { + need := false + for _, dm := range dms { + for _, e := range dm.Entities { + if e.GeneralizationRef != "" { + need = true + } + } + } + if !need { + return + } + + index := make(map[string]persistabilityLink) + for _, e := range meta.ModelerSystemEntities() { + index["System."+e.Name] = persistabilityLink{persistable: e.Persistable, parent: e.Generalization} + } + moduleName := make(map[model.ID]string) + if mods, err := b.reader.ListModules(); err == nil { + for _, m := range mods { + moduleName[model.ID(m.ID)] = m.Name + } + } + for _, u := range units { + mod := moduleName[u.ContainerID] + if mod == "" { + continue + } + for _, el := range u.Element.EntitiesItems() { + e, ok := el.(*genDm.Entity) + if !ok { + continue + } + switch g := e.Generalization().(type) { + case *genDm.NoGeneralization: + index[mod+"."+e.Name()] = persistabilityLink{persistable: g.Persistable()} + case *genDm.Generalization: + index[mod+"."+e.Name()] = persistabilityLink{parent: g.GeneralizationQualifiedName()} + } + } + } + + rootPersistable := func(qn string) (bool, bool) { + seen := make(map[string]bool) + for !seen[qn] { + seen[qn] = true + link, ok := index[qn] + if !ok { + return false, false + } + if link.parent == "" { + return link.persistable, true + } + qn = link.parent + } + return false, false + } + for _, dm := range dms { + for _, e := range dm.Entities { + if e.GeneralizationRef == "" { + continue + } + if p, ok := rootPersistable(e.GeneralizationRef); ok { + e.Persistable = p + } + } + } +} + // GetDomainModel returns the domain model whose container is moduleID. func (b *Backend) GetDomainModel(moduleID model.ID) (*domainmodel.DomainModel, error) { units, err := mprread.ListUnitsWithContainer[*genDm.DomainModel](b.reader) @@ -100,6 +190,7 @@ func (b *Backend) GetDomainModel(moduleID model.ID) (*domainmodel.DomainModel, e if u.ContainerID == moduleID { dm := domainModelFromGen(u.Element, u.ContainerID) b.populateViewEntityOql([]*domainmodel.DomainModel{dm}) + b.resolveInheritedPersistability(units, dm) return dm, nil } } @@ -107,6 +198,7 @@ func (b *Backend) GetDomainModel(moduleID model.ID) (*domainmodel.DomainModel, e // so serve the injected one (matching ListDomainModels) so DESCRIBE System.* and // any lookup of a platform entity's domain model resolves instead of erroring. if sys := buildSystemDomainModel(); sys.ContainerID == moduleID { + b.resolveInheritedPersistability(units, sys) return sys, nil } // Match the legacy contract: a missing domain model is an error, not (nil, nil). @@ -198,8 +290,10 @@ func entityFromGen(e *genDm.Entity) *domainmodel.Entity { out.HasCreatedDate = g.HasCreatedDate() case *genDm.Generalization: out.GeneralizationRef = g.GeneralizationQualifiedName() - // Persistability is inherited from the parent chain; default true - // matches legacy (sdk/mpr parser_domainmodel.go). + // A specialization stores no Persistable — it is its root's, which this + // entity alone cannot see. The default stands until the domain-model + // reads resolve the chain (resolveInheritedPersistability); a caller of + // entityFromGen on its own gets the unresolved value. } out.Location = parseLocation(e.Location()) @@ -300,6 +394,7 @@ func attributeFromGen(a *genDm.Attribute) *domainmodel.Attribute { Name: a.Name(), Documentation: a.Documentation(), Type: attributeTypeFromGen(a.Type()), + ExportLevel: a.ExportLevel(), } attr.ID = model.ID(a.ID()) switch v := a.Value().(type) { @@ -578,6 +673,7 @@ func assocFromGen(a *genDm.Association) *domainmodel.Association { out := &domainmodel.Association{ Name: a.Name(), Documentation: a.Documentation(), + ExportLevel: a.ExportLevel(), ParentID: model.ID(a.ParentRefID()), // FROM entity (owns the FK) ChildID: model.ID(a.ChildRefID()), // TO entity // Type/Owner/StorageFormat/DeleteBehavior MUST be read here: the executor's @@ -650,6 +746,7 @@ func crossAssocFromGen(ca *genDm.CrossAssociation) *domainmodel.CrossModuleAssoc out := &domainmodel.CrossModuleAssociation{ Name: ca.Name(), Documentation: ca.Documentation(), + ExportLevel: ca.ExportLevel(), ParentID: model.ID(ca.ParentRefID()), // local FROM entity (owns the FK) ChildRef: ca.ChildQualifiedName(), // remote TO entity, BY_NAME Type: domainmodel.AssociationType(ca.Type()), diff --git a/mdl/backend/modelsdk/domainmodel_alter.go b/mdl/backend/modelsdk/domainmodel_alter.go index d780709514..441f06958e 100644 --- a/mdl/backend/modelsdk/domainmodel_alter.go +++ b/mdl/backend/modelsdk/domainmodel_alter.go @@ -395,9 +395,10 @@ func (b *Backend) UpdateDomainModel(dm *domainmodel.DomainModel) error { // Unlike the Entities and Associations lists above, this does not rebuild: each // stored element is edited in place, and a property is set only when the semantic // value differs from what is stored. Everything the semantic model does not carry -// — the GUID above all, but also the export level, the capabilities and the -// remote-source name — is never touched, so it cannot be lost, and an unchanged -// element stays clean and passes through byte-for-byte. +// — the GUID above all, but also the capabilities and the remote-source name — is +// never touched, so it cannot be lost, and an unchanged element stays clean and +// passes through byte-for-byte. The export level is set only when it is set and +// differs, so an unauthored one keeps the stored level (mendixlabs/mxcli#1498). // // A semantic association with no stored counterpart is new and is appended. A // stored one missing from the semantic model is REFUSED rather than silently kept @@ -458,6 +459,9 @@ func patchCrossAssociation(gca *genDm.CrossAssociation, ca *domainmodel.CrossMod if ca.StorageFormat != "" && gca.StorageFormat() != string(ca.StorageFormat) { gca.SetStorageFormat(string(ca.StorageFormat)) } + if ca.ExportLevel != "" && gca.ExportLevel() != ca.ExportLevel { + gca.SetExportLevel(ca.ExportLevel) + } db, ok := gca.DeleteBehavior().(*genDm.AssociationDeleteBehavior) if !ok || db == nil { @@ -813,7 +817,7 @@ func (b *Backend) UpdateAttribute(domainModelID, entityID model.ID, attr *domain // Attribute -> Attribute keeps the $Type, so carrying the stored raw bytes // is enough; no raw transform is needed. if stored, ok := items[idx].(*genDm.Attribute); ok { - carryStoredAttribute(next, stored) + carryStoredAttribute(next, stored, attr.ExportLevel) } // The generated list offers only Append and Remove, so an in-place replace diff --git a/mdl/backend/modelsdk/domainmodel_carry.go b/mdl/backend/modelsdk/domainmodel_carry.go index b380ad0edd..03f055e2e9 100644 --- a/mdl/backend/modelsdk/domainmodel_carry.go +++ b/mdl/backend/modelsdk/domainmodel_carry.go @@ -57,7 +57,9 @@ func carryStoredEntity(ge, orig *genDm.Entity, entity *domainmodel.Entity) { ge.SetRaw(raw) } carryChildIdentity(ge, orig, entity) - if lvl := orig.ExportLevel(); lvl != "" { + // An authored level stands; only an unauthored one is carried + // (mendixlabs/mxcli#1498). + if lvl := orig.ExportLevel(); lvl != "" && entity.ExportLevel == "" { ge.SetExportLevel(lvl) } carryAccessRules(ge, orig, entity) @@ -155,6 +157,13 @@ func entityUnchanged(e *domainmodel.Entity, orig *genDm.Entity) bool { if stored.OqlQuery == "" { stored.OqlQuery = e.OqlQuery } + // Nor is a specialization's Persistable: it is its root's, resolved by the + // domain-model read and stored nowhere on this element (#1600). Comparing it + // with entityFromGen's unresolved default would rebuild every specialization + // of a non-persistent entity on every write of its domain model. + if _, inherits := orig.Generalization().(*genDm.Generalization); inherits { + stored.Persistable = e.Persistable + } return semanticEqual(e, stored) } @@ -167,7 +176,7 @@ func assocUnchanged(a *domainmodel.Association, orig *genDm.Association) bool { } // carryStoredAssociation is carryStoredEntity for a rebuilt association: the raw -// bytes (the GUID, #1169), the export level (#801), and the stored delete +// bytes (the GUID, #1169), the export level when unauthored (#801, #1498), and the stored delete // behaviour element where the statement left the behaviour alone, so its $ID and // its refusal message's $IDs do not churn. func carryStoredAssociation(ga, orig *genDm.Association, a *domainmodel.Association) { @@ -177,7 +186,7 @@ func carryStoredAssociation(ga, orig *genDm.Association, a *domainmodel.Associat if raw := orig.Raw(); raw != nil { ga.SetRaw(raw) } - if lvl := orig.ExportLevel(); lvl != "" { + if lvl := orig.ExportLevel(); lvl != "" && a.ExportLevel == "" { ga.SetExportLevel(lvl) } storedDB, ok := orig.DeleteBehavior().(*genDm.AssociationDeleteBehavior) diff --git a/mdl/backend/modelsdk/domainmodel_child_identity.go b/mdl/backend/modelsdk/domainmodel_child_identity.go index ae1cd19551..7411f5e7fe 100644 --- a/mdl/backend/modelsdk/domainmodel_child_identity.go +++ b/mdl/backend/modelsdk/domainmodel_child_identity.go @@ -82,7 +82,11 @@ func carryAttributeIdentity(ge, orig *genDm.Entity, entity *domainmodel.Entity) claimed := make(map[string]bool, len(storedByID)) carry := func(ga, sa *genDm.Attribute) { - carryStoredAttribute(ga, sa) + authored := "" + if sem := semantic[ga.Name()]; sem != nil { + authored = sem.ExportLevel + } + carryStoredAttribute(ga, sa, authored) claimed[string(sa.ID())] = true } @@ -179,18 +183,19 @@ func carryIndexIdentity(ge, orig *genDm.Entity, entity *domainmodel.Entity) { // carryStoredAttribute makes the rebuilt attribute ga the stored attribute sa as // far as identity goes: the stored $ID and raw bytes (so the GUID passes through // instead of being re-minted as $ID — #1119, ako/mxcli#627) and the stored export -// level. Both rebuild sites use it: the entity rewrite (carryAttributeIdentity) -// and the single-attribute rewrite (Backend.UpdateAttribute). -func carryStoredAttribute(ga, sa *genDm.Attribute) { +// level unless the statement authored one. Both rebuild sites use it: the entity +// rewrite (carryAttributeIdentity) and the single-attribute rewrite +// (Backend.UpdateAttribute). +func carryStoredAttribute(ga, sa *genDm.Attribute, authored string) { ga.SetID(sa.ID()) if raw := sa.Raw(); raw != nil { ga.SetRaw(raw) } - // attributeToGen sets ExportLevel "Hidden", and a property the rebuild - // sets wins over the carried raw bytes — so without this an API attribute - // became Hidden on every rewrite of its entity (ako/mxcli#801). The - // semantic attribute has no export level to take it from. - if lvl := sa.ExportLevel(); lvl != "" { + // attributeToGen sets ExportLevel "Hidden" for an unset one, and a property + // the rebuild sets wins over the carried raw bytes — so without this an API + // attribute became Hidden on every rewrite of its entity (ako/mxcli#801). An + // authored level is the statement's (mendixlabs/mxcli#1498). + if lvl := sa.ExportLevel(); lvl != "" && authored == "" { ga.SetExportLevel(lvl) } } diff --git a/mdl/backend/modelsdk/domainmodel_export_level_write_test.go b/mdl/backend/modelsdk/domainmodel_export_level_write_test.go new file mode 100644 index 0000000000..0af3dd9f12 --- /dev/null +++ b/mdl/backend/modelsdk/domainmodel_export_level_write_test.go @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1498 (follow-up, point 2): `check` reports CE5400 for a +// Hidden entity a Usable microflow exposes in a Protected module, and nothing in +// mxcli could write any other level — entityToGen, attributeToGen, assocToGen and +// crossAssocToGen wrote the "Hidden" constant, and the rebuild carry wrote the +// stored level back over whatever the statement said. +// +// Each update case runs both directions: an authored Hidden over a stored API +// alone cannot tell a writer that honours the level from one that writes the +// constant (the #816 lesson), and an authored API over a stored Hidden alone +// cannot tell one that honours it from one that carries the stored level. +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + genDm "github.com/mendixlabs/mxcli/modelsdk/gen/domainmodels" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +func TestCreateDomainElements_WriteAuthoredExportLevel(t *testing.T) { + b, dmID := exportLevelFixture(t) + api := &domainmodel.Entity{Name: "ZzApi", Persistable: true, ExportLevel: "API", Attributes: []*domainmodel.Attribute{ + {Name: "Usable", Type: &domainmodel.StringAttributeType{Length: 10}, ExportLevel: "API"}, + {Name: "Plain", Type: &domainmodel.StringAttributeType{Length: 10}}, + }} + plain := &domainmodel.Entity{Name: "ZzPlain", Persistable: true} + for _, e := range []*domainmodel.Entity{api, plain} { + if err := b.CreateEntity(dmID, e); err != nil { + t.Fatalf("CreateEntity %s: %v", e.Name, err) + } + } + assoc := &domainmodel.Association{Name: "ZzApi_ZzPlain", ParentID: api.ID, ChildID: plain.ID, + Type: domainmodel.AssociationTypeReference, Owner: domainmodel.AssociationOwnerDefault, ExportLevel: "API"} + if err := b.CreateAssociation(dmID, assoc); err != nil { + t.Fatalf("CreateAssociation: %v", err) + } + cross := &domainmodel.CrossModuleAssociation{Name: "ZzApi_User", ParentID: api.ID, ChildRef: "System.User", + Type: domainmodel.AssociationTypeReference, Owner: domainmodel.AssociationOwnerDefault, ExportLevel: "API"} + if err := b.CreateCrossAssociation(dmID, cross); err != nil { + t.Fatalf("CreateCrossAssociation: %v", err) + } + + got := storedDomainLevels(t, b, dmID) + for key, want := range map[string]string{ + "entity ZzApi": "API", + "entity ZzPlain": "Hidden", + "attribute ZzApi.Usable": "API", + "attribute ZzApi.Plain": "Hidden", + "association ZzApi_ZzPlain": "API", + "cross ZzApi_User": "API", + } { + if got[key] != want { + t.Errorf("%s: stored ExportLevel %q, want %q", key, got[key], want) + } + } + + // The read fills the semantic model, which describe prints and the + // Protected-module check consults. + dm := reloadDomainModel(t, b, dmID) + for _, e := range dm.Entities { + if e.Name == "ZzApi" && (e.ExportLevel != "API" || e.Attributes[0].ExportLevel != "API" || e.Attributes[1].ExportLevel != "Hidden") { + t.Errorf("read back entity %q attributes %q/%q", e.ExportLevel, e.Attributes[0].ExportLevel, e.Attributes[1].ExportLevel) + } + } + for _, a := range dm.Associations { + if a.Name == "ZzApi_ZzPlain" && a.ExportLevel != "API" { + t.Errorf("read back association ExportLevel %q", a.ExportLevel) + } + } + for _, ca := range dm.CrossAssociations { + if ca.Name == "ZzApi_User" && ca.ExportLevel != "API" { + t.Errorf("read back cross association ExportLevel %q", ca.ExportLevel) + } + } +} + +func TestUpdateDomainElements_AuthoredExportLevelWins(t *testing.T) { + for _, tc := range []struct{ stored, authored, want string }{ + {"Hidden", "API", "API"}, + {"API", "Hidden", "Hidden"}, + {"API", "", "API"}, // unauthored keeps the stored level (#801) + } { + t.Run(tc.stored+"->"+orDefault(tc.authored, "unauthored"), func(t *testing.T) { + b, dmID := exportLevelFixture(t) + e := &domainmodel.Entity{Name: "ZzSubject", Persistable: true, Attributes: []*domainmodel.Attribute{ + {Name: "A", Type: &domainmodel.StringAttributeType{Length: 10}}, + {Name: "B", Type: &domainmodel.StringAttributeType{Length: 10}}, + }} + other := &domainmodel.Entity{Name: "ZzOther", Persistable: true} + for _, x := range []*domainmodel.Entity{e, other} { + if err := b.CreateEntity(dmID, x); err != nil { + t.Fatalf("CreateEntity: %v", err) + } + } + if err := b.CreateAssociation(dmID, &domainmodel.Association{Name: "ZzSubject_ZzOther", ParentID: e.ID, ChildID: other.ID, + Type: domainmodel.AssociationTypeReference, Owner: domainmodel.AssociationOwnerDefault}); err != nil { + t.Fatalf("CreateAssociation: %v", err) + } + if err := b.CreateCrossAssociation(dmID, &domainmodel.CrossModuleAssociation{Name: "ZzSubject_User", ParentID: e.ID, + ChildRef: "System.User", Type: domainmodel.AssociationTypeReference, Owner: domainmodel.AssociationOwnerDefault}); err != nil { + t.Fatalf("CreateCrossAssociation: %v", err) + } + setStoredDomainLevels(t, b, dmID, tc.stored) + + // UpdateEntity: the entity and attribute A carry the authored level, + // attribute B never says one and keeps what is stored. + ent := entityNamed(t, reloadDomainModel(t, b, dmID), "ZzSubject") + ent.ExportLevel = tc.authored + ent.Attributes[0].ExportLevel = tc.authored + ent.Attributes[1].ExportLevel = "" + if err := b.UpdateEntity(dmID, ent); err != nil { + t.Fatalf("UpdateEntity: %v", err) + } + // UpdateAttribute, the single-attribute path. + attrB := entityNamed(t, reloadDomainModel(t, b, dmID), "ZzSubject").Attributes[1] + attrB.ExportLevel = tc.authored + attrB.Documentation = "touched" + if err := b.UpdateAttribute(dmID, ent.ID, attrB); err != nil { + t.Fatalf("UpdateAttribute: %v", err) + } + // UpdateDomainModel: associations, regular and cross-module. + dm := reloadDomainModel(t, b, dmID) + for _, a := range dm.Associations { + if a.Name == "ZzSubject_ZzOther" { + a.ExportLevel = tc.authored + } + } + for _, ca := range dm.CrossAssociations { + if ca.Name == "ZzSubject_User" { + ca.ExportLevel = tc.authored + } + } + if err := b.UpdateDomainModel(dm); err != nil { + t.Fatalf("UpdateDomainModel: %v", err) + } + + got := storedDomainLevels(t, b, dmID) + for _, key := range []string{"entity ZzSubject", "attribute ZzSubject.A", "attribute ZzSubject.B", + "association ZzSubject_ZzOther", "cross ZzSubject_User"} { + if got[key] != tc.want { + t.Errorf("%s: stored %s, authored %q: ExportLevel %q, want %q", key, tc.stored, tc.authored, got[key], tc.want) + } + } + }) + } +} + +// An enumeration is a document of its own; the update direction is a case of +// TestUpdatePaths_KeepStoredExportLevel. +func TestCreateEnumeration_WritesAuthoredExportLevel(t *testing.T) { + b, _ := exportLevelFixture(t) + mod, err := b.GetModuleByName("MyFirstModule") + if err != nil || mod == nil { + t.Fatalf("GetModuleByName: %v", err) + } + for _, lvl := range []string{"API", ""} { + enum := &model.Enumeration{ContainerID: mod.ID, Name: "ZzKind" + lvl, ExportLevel: lvl, + Values: []model.EnumerationValue{{Name: "A"}}} + if err := b.CreateEnumeration(enum); err != nil { + t.Fatalf("CreateEnumeration: %v", err) + } + want := orDefault(lvl, "Hidden") + if got := storedKeys(t, b, enum.ID)["ExportLevel"]; got != want { + t.Errorf("authored %q: stored ExportLevel %#v, want %s", lvl, got, want) + } + if got, err := b.GetEnumeration(enum.ID); err != nil || got.ExportLevel != want { + t.Errorf("authored %q: read back ExportLevel %q (%v), want %s", lvl, got.ExportLevel, err, want) + } + } +} + +func exportLevelFixture(t *testing.T) (*Backend, model.ID) { + t.Helper() + b := New() + if err := b.Connect(copyFixture(t)); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + mod, err := b.GetModuleByName("MyFirstModule") + if err != nil || mod == nil { + t.Fatalf("GetModuleByName: %v", err) + } + dm, err := b.GetDomainModel(mod.ID) + if err != nil || dm == nil { + t.Fatalf("GetDomainModel: %v", err) + } + return b, dm.ID +} + +func entityNamed(t *testing.T, dm *domainmodel.DomainModel, name string) *domainmodel.Entity { + t.Helper() + for _, e := range dm.Entities { + if e.Name == name { + return e + } + } + t.Fatalf("entity %s not found", name) + return nil +} + +// storedDomainLevels reads the ExportLevel stored on every entity, attribute, +// association and cross-module association of the unit, keyed by kind and name. +func storedDomainLevels(t *testing.T, b *Backend, dmID model.ID) map[string]string { + t.Helper() + gdm, err := b.loadDomainModelGen(dmID) + if err != nil { + t.Fatalf("loadDomainModelGen: %v", err) + } + out := map[string]string{} + for _, el := range gdm.EntitiesItems() { + ge, ok := el.(*genDm.Entity) + if !ok { + continue + } + out["entity "+ge.Name()] = ge.ExportLevel() + for _, ael := range ge.AttributesItems() { + if ga, ok := ael.(*genDm.Attribute); ok { + out["attribute "+ge.Name()+"."+ga.Name()] = ga.ExportLevel() + } + } + } + for _, el := range gdm.AssociationsItems() { + if ga, ok := el.(*genDm.Association); ok { + out["association "+ga.Name()] = ga.ExportLevel() + } + } + for _, el := range gdm.CrossAssociationsItems() { + if gca, ok := el.(*genDm.CrossAssociation); ok { + out["cross "+gca.Name()] = gca.ExportLevel() + } + } + return out +} + +// setStoredDomainLevels sets the stored ExportLevel of every Zz-prefixed +// element of the unit, the way Studio Pro would have written it. +func setStoredDomainLevels(t *testing.T, b *Backend, dmID model.ID, lvl string) { + t.Helper() + gdm, err := b.loadDomainModelGen(dmID) + if err != nil { + t.Fatalf("loadDomainModelGen: %v", err) + } + isSubject := func(name string) bool { return len(name) > 2 && name[:2] == "Zz" } + for _, el := range gdm.EntitiesItems() { + if ge, ok := el.(*genDm.Entity); ok && isSubject(ge.Name()) { + ge.SetExportLevel(lvl) + for _, ael := range ge.AttributesItems() { + if ga, ok := ael.(*genDm.Attribute); ok { + ga.SetExportLevel(lvl) + } + } + } + } + for _, el := range gdm.AssociationsItems() { + if ga, ok := el.(*genDm.Association); ok && isSubject(ga.Name()) { + ga.SetExportLevel(lvl) + } + } + for _, el := range gdm.CrossAssociationsItems() { + if gca, ok := el.(*genDm.CrossAssociation); ok && isSubject(gca.Name()) { + gca.SetExportLevel(lvl) + } + } + if err := b.persistDM(dmID, gdm); err != nil { + t.Fatalf("persist stored levels: %v", err) + } + got := storedDomainLevels(t, b, dmID) + if got["entity ZzSubject"] != lvl { + t.Fatalf("precondition: stored entity level %q after setting %q", got["entity ZzSubject"], lvl) + } +} diff --git a/mdl/backend/modelsdk/domainmodel_write.go b/mdl/backend/modelsdk/domainmodel_write.go index 693491d8ca..0c672e88b9 100644 --- a/mdl/backend/modelsdk/domainmodel_write.go +++ b/mdl/backend/modelsdk/domainmodel_write.go @@ -104,7 +104,7 @@ func assocToGen(a *domainmodel.Association) *genDm.Association { out := genDm.NewAssociation() out.SetName(a.Name) out.SetDocumentation(a.Documentation) - out.SetExportLevel("Hidden") + out.SetExportLevel(orDefault(a.ExportLevel, "Hidden")) out.SetParentID(element.ID(string(a.ParentID))) out.SetChildID(element.ID(string(a.ChildID))) out.SetType(string(a.Type)) @@ -264,11 +264,13 @@ func entityToGen(e *domainmodel.Entity, moduleName string, major int) *genDm.Ent out.SetSource(externalEntitySourceToGen(e)) } // ExportLevel is a mandatory scalar NewEntity's pending applyDefaults does not - // yet set (engalar tech-debt Fix 4). The entity GUID and the empty member + // yet set (engalar tech-debt Fix 4), so an unset one is written "Hidden"; a + // rewrite of a stored entity carries the stored level onto it instead + // (carryStoredEntity). The entity GUID and the empty member // arrays (Attributes/AccessRules/…) legacy also emits are NOT settable on the // gen Entity / not emitted for unset PartLists — that residual is the // documented applyDefaults gap (see the write-parity known-gap test). - out.SetExportLevel("Hidden") + out.SetExportLevel(orDefault(e.ExportLevel, "Hidden")) if e.GeneralizationRef != "" { g := genDm.NewGeneralization() @@ -643,7 +645,7 @@ func attributeToGen(a *domainmodel.Attribute, isExternal bool) *genDm.Attribute out := genDm.NewAttribute() out.SetName(a.Name) out.SetDocumentation(a.Documentation) - out.SetExportLevel("Hidden") + out.SetExportLevel(orDefault(a.ExportLevel, "Hidden")) out.SetType(attributeTypeToGen(a.Type)) def := "" diff --git a/mdl/backend/modelsdk/enumeration.go b/mdl/backend/modelsdk/enumeration.go index a6206e33b5..53ef568de3 100644 --- a/mdl/backend/modelsdk/enumeration.go +++ b/mdl/backend/modelsdk/enumeration.go @@ -54,6 +54,7 @@ func enumToModel(e *genEnum.Enumeration, containerID model.ID) *model.Enumeratio Name: e.Name(), Documentation: e.Documentation(), Excluded: e.Excluded(), + ExportLevel: e.ExportLevel(), } out.ID = model.ID(e.ID()) out.TypeName = "Enumerations$Enumeration" diff --git a/mdl/backend/modelsdk/enumeration_write.go b/mdl/backend/modelsdk/enumeration_write.go index e340cb7de1..f257bbbfc3 100644 --- a/mdl/backend/modelsdk/enumeration_write.go +++ b/mdl/backend/modelsdk/enumeration_write.go @@ -64,8 +64,9 @@ func (b *Backend) UpdateEnumeration(enum *model.Enumeration) error { if err != nil { return fmt.Errorf("UpdateEnumeration: encode: %w", err) } - // enumToGen writes ExportLevel "Hidden" as a constant (#816). - contents, err = b.keepStoredExportLevel(string(enum.ID), contents) + // An unauthored level keeps the stored one (#816); `export level` sets it + // (mendixlabs/mxcli#1498). + contents, err = b.keepStoredExportLevelUnlessSet(string(enum.ID), enum.ExportLevel, contents) if err != nil { return fmt.Errorf("UpdateEnumeration: %w", err) } @@ -80,8 +81,8 @@ func (b *Backend) DeleteEnumeration(id model.ID) error { return b.writer.DeleteUnit(string(id)) } -// enumToGen builds a gen Enumeration from the model. ExportLevel="Hidden" -// mirrors the legacy serializer; RemoteSource (null) comes from the registered +// enumToGen builds a gen Enumeration from the model. ExportLevel is the model's, +// "Hidden" when unset as the legacy serializer wrote it; RemoteSource (null) comes from the registered // default. Excluded is carried from the model — hardcoding false here made a // CREATE OR MODIFY silently un-exclude the document (#914). func enumToGen(enum *model.Enumeration) *genEnum.Enumeration { @@ -89,7 +90,7 @@ func enumToGen(enum *model.Enumeration) *genEnum.Enumeration { out.SetName(enum.Name) out.SetDocumentation(enum.Documentation) out.SetExcluded(enum.Excluded) - out.SetExportLevel("Hidden") + out.SetExportLevel(orDefault(enum.ExportLevel, "Hidden")) for _, v := range enum.Values { out.AddValues(enumValueToGen(v)) } diff --git a/mdl/backend/modelsdk/integration_read.go b/mdl/backend/modelsdk/integration_read.go index 5f1a0190b1..019a120fc9 100644 --- a/mdl/backend/modelsdk/integration_read.go +++ b/mdl/backend/modelsdk/integration_read.go @@ -411,6 +411,8 @@ func (b *Backend) ListPublishedRestServices() ([]*model.PublishedRestService, er ServiceName: g.ServiceName(), Excluded: g.Excluded(), AllowedRoles: append([]string(nil), g.AllowedRolesQualifiedNames()...), + // mendixlabs/mxcli#1510 + Documentation: g.Documentation(), AuthenticationTypes: append([]string(nil), g.AuthenticationTypesItems()...), AuthenticationMicroflow: g.AuthenticationMicroflowQualifiedName(), diff --git a/mdl/backend/modelsdk/issue1510_inherited_persistability_test.go b/mdl/backend/modelsdk/issue1510_inherited_persistability_test.go new file mode 100644 index 0000000000..0386c2fbfe --- /dev/null +++ b/mdl/backend/modelsdk/issue1510_inherited_persistability_test.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1600 (ako/mxcli#1510): `describe entity` printed +// `persistent entity Probe.Child extends Probe.Base` for a specialization of a +// NON-persistent Probe.Base. A specialization stores no Persistable flag of its +// own — its MaybeGeneralization is a DomainModels$Generalization, which has no +// such property — so the flag is its root's. entityFromGen defaulted it to true +// and only the NoGeneralization arm ever overwrote it, so every specialization +// read as persistent, whatever its chain said. The same flag feeds +// `show entities where non-persistent`, the catalog, the diagrams, and the +// microflow builder's persistability map. +package modelsdkbackend + +import ( + "testing" + + genDm "github.com/mendixlabs/mxcli/modelsdk/gen/domainmodels" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +func TestReadResolvesPersistabilityFromTheRootGeneralization(t *testing.T) { + proj := copyFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + + mod, err := b.GetModuleByName("MyFirstModule") + if err != nil || mod == nil { + t.Fatalf("GetModuleByName: %v", err) + } + dm, err := b.GetDomainModel(mod.ID) + if err != nil { + t.Fatalf("GetDomainModel: %v", err) + } + + // Persistable on a specialization is what the executor's statement says — + // `create persistent entity … extends` — and the writer drops it, because + // the Generalization element has nowhere to store it. Declare every + // specialization persistent, so a reader that returns the declared value (or + // the old default) fails on the non-persistent chains. + str := func(n string) []*domainmodel.Attribute { + return []*domainmodel.Attribute{{Name: n, Type: &domainmodel.StringAttributeType{Length: 20}}} + } + for _, e := range []*domainmodel.Entity{ + {Name: "ZzNpBase", Persistable: false, Attributes: str("Code")}, + {Name: "ZzNpChild", Persistable: true, GeneralizationRef: "MyFirstModule.ZzNpBase", Attributes: str("Status")}, + {Name: "ZzNpGrandChild", Persistable: true, GeneralizationRef: "MyFirstModule.ZzNpChild", Attributes: str("Extra")}, + {Name: "ZzPBase", Persistable: true, Attributes: str("Code")}, + {Name: "ZzPChild", Persistable: true, GeneralizationRef: "MyFirstModule.ZzPBase", Attributes: str("Amount")}, + // System.HttpResponse extends the non-persistent System.HttpMessage; + // System.Image extends the persistent System.FileDocument. The chain + // crosses into the virtual System module, which is not stored. + {Name: "ZzResponse", Persistable: true, GeneralizationRef: "System.HttpResponse", Attributes: str("Note")}, + {Name: "ZzPicture", Persistable: true, GeneralizationRef: "System.Image", Attributes: str("Caption")}, + } { + if err := b.CreateEntity(dm.ID, e); err != nil { + t.Fatalf("CreateEntity %s: %v", e.Name, err) + } + } + + want := map[string]bool{ + "ZzNpBase": false, + "ZzNpChild": false, + "ZzNpGrandChild": false, + "ZzPBase": true, + "ZzPChild": true, + "ZzResponse": false, + "ZzPicture": true, + } + check := func(read string, entities []*domainmodel.Entity) { + t.Helper() + seen := 0 + for _, e := range entities { + w, ok := want[e.Name] + if !ok { + continue + } + seen++ + if e.Persistable != w { + t.Errorf("%s: %s reads Persistable=%v, want %v (its root generalization's)", read, e.Name, e.Persistable, w) + } + } + if seen != len(want) { + t.Fatalf("%s: found %d of the %d probe entities", read, seen, len(want)) + } + } + + got, err := b.GetDomainModel(mod.ID) + if err != nil { + t.Fatalf("GetDomainModel: %v", err) + } + check("GetDomainModel", got.Entities) + + got, err = b.GetDomainModelByID(dm.ID) + if err != nil { + t.Fatalf("GetDomainModelByID: %v", err) + } + check("GetDomainModelByID", got.Entities) + + all, err := b.ListDomainModels() + if err != nil { + t.Fatalf("ListDomainModels: %v", err) + } + for _, d := range all { + if d.ContainerID == mod.ID { + check("ListDomainModels", d.Entities) + } + // The System module's own specializations follow the same rule: the + // HTTP messages and the SOAP fault are non-persistent like their roots. + for _, e := range d.Entities { + switch e.Name { + case "HttpRequest", "HttpResponse", "SoapFault": + if e.Persistable { + t.Errorf("ListDomainModels: System.%s reads persistent; its root is not", e.Name) + } + } + } + } +} + +// The resolved flag is a read of the chain, not a property of the stored +// specialization. Comparing it with entityFromGen's default would call every +// specialization of a non-persistent entity changed, and rebuild it on every +// UpdateDomainModel — the churn that a statement which names one association +// already paid for once (ako/mxcli#801). +func TestEntityUnchanged_IgnoresInheritedPersistability(t *testing.T) { + stored := genDm.NewEntity() + stored.SetName("Child") + g := genDm.NewGeneralization() + g.SetGeneralizationQualifiedName("M.Base") + stored.SetGeneralization(g) + + read := entityFromGen(stored) + read.Persistable = false // what the chain resolves to when M.Base is non-persistent + if !entityUnchanged(read, stored) { + t.Error("a specialization read back unchanged compares as changed on its inherited Persistable") + } + + // Control: a root stores its flag, so a different one is a real change. + root := genDm.NewEntity() + root.SetName("Base") + ng := genDm.NewNoGeneralization() + ng.SetPersistable(true) + root.SetGeneralization(ng) + flipped := entityFromGen(root) + flipped.Persistable = false + if entityUnchanged(flipped, root) { + t.Error("a root entity whose Persistable flipped compares as unchanged") + } +} diff --git a/mdl/backend/modelsdk/issue816_export_level_test.go b/mdl/backend/modelsdk/issue816_export_level_test.go index 495332d891..ddbe57017a 100644 --- a/mdl/backend/modelsdk/issue816_export_level_test.go +++ b/mdl/backend/modelsdk/issue816_export_level_test.go @@ -106,16 +106,19 @@ func orHidden(authored string) string { var exportLevelCases = []exportLevelCase{ { - name: "Enumeration", + // `create enumeration … export level api` (mendixlabs/mxcli#1498). + name: "Enumeration", + authorable: true, subject: func(t *testing.T, b *Backend) model.ID { all, err := b.ListEnumerations() return firstID(t, err, len(all), func() model.ID { return all[0].ID }) }, - rewrite: func(t *testing.T, b *Backend, id model.ID, _ string) error { + rewrite: func(t *testing.T, b *Backend, id model.ID, authored string) error { e, err := b.GetEnumeration(id) if err != nil { return err } + e.ExportLevel = authored return b.UpdateEnumeration(e) }, }, diff --git a/mdl/backend/modelsdk/mcp_bypass_reads.go b/mdl/backend/modelsdk/mcp_bypass_reads.go index 4e334e0d51..59d0da873a 100644 --- a/mdl/backend/modelsdk/mcp_bypass_reads.go +++ b/mdl/backend/modelsdk/mcp_bypass_reads.go @@ -49,6 +49,7 @@ func (b *Backend) GetDomainModelByID(id model.ID) (*domainmodel.DomainModel, err if model.ID(u.Element.ID()) == id { dm := domainModelFromGen(u.Element, u.ContainerID) b.populateViewEntityOql([]*domainmodel.DomainModel{dm}) + b.resolveInheritedPersistability(units, dm) return dm, nil } } diff --git a/mdl/backend/modelsdk/published_rest_write.go b/mdl/backend/modelsdk/published_rest_write.go index d40a3a4edd..eb41a0773d 100644 --- a/mdl/backend/modelsdk/published_rest_write.go +++ b/mdl/backend/modelsdk/published_rest_write.go @@ -138,17 +138,17 @@ func (b *Backend) UpdatePublishedRestServiceRoles(unitID model.ID, roles []strin // output of a Studio Pro service turned its Basic and Session authentication // off (ako/mxcli#571). They are written from the model now, and an unstated // setting is carried by the executor, which reads it with the service. So are -// the authentication Parameters, since mendixlabs/mxcli#1417. +// the authentication Parameters, since mendixlabs/mxcli#1417, and the +// Documentation, since the doc comment states it (mendixlabs/mxcli#1510). var publishedRestServiceUnauthored = []string{ "CorsConfiguration", - "Documentation", "PublicDocumentation", } func publishedRestServiceToGen(svc *model.PublishedRestService) element.Element { g := newElem("Rest$PublishedRestService", string(svc.ID)) addStr(g, "Name", svc.Name) - addStr(g, "Documentation", "") + addStr(g, "Documentation", svc.Documentation) addBool(g, "Excluded", svc.Excluded) addStr(g, "ExportLevel", "Hidden") addStr(g, "Path", svc.Path) diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index c209843f8f..b7c797ea0f 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -1850,7 +1850,22 @@ func customWidgetDataSourceToGen(ds pages.DataSource) (element.Element, error) { assignID(src) src.SetForceFullObjects(false) src.SetXPathConstraint(d.XPathConstraint) - if d.EntityName != "" { + switch { + case len(d.EntitySteps) > 0: + // "Database" reached over associations from a context object: the + // hops as an IndirectEntityRef inside the XPath source, the start + // parameter in its SourceVariable — the shape listViewSourceToGen + // writes, and the one Studio Pro stores for a gallery or grid + // bound this way (mendixlabs/mxcli#1599). + src.SetEntityRef(pageEntityRefToGen(d.EntitySteps)) + if d.ContextVariable != "" { + kind := "" + if d.IsSnippetParameter { + kind = "snippet" + } + src.SetSourceVariable(sourceVariableToGen(d.ContextVariable, kind)) + } + case d.EntityName != "": ref := genDm.NewDirectEntityRef() assignID(ref) ref.SetEntityQualifiedName(d.EntityName) @@ -2086,7 +2101,14 @@ func pageSettingsWithMappings(pageName string, mappings []*pages.PageClientParam assignID(m) m.SetParameterQualifiedName(pageName + "." + pm.ParameterName) m.SetArgument("") - m.SetVariable(sourceVariableToGen(strings.TrimPrefix(pm.Variable, "$"), pm.VariableKind)) + if pm.VariableKind == "widget" { + // A control-bar button passing its grid's selection names the grid + // in the Widget slot (mendixlabs/mxcli#122). mxbuild 11.12.5: 0 + // errors; the empty array is CE1571, Argument "$grid" CE0115. + m.SetVariable(pageVariableToGen(strings.TrimPrefix(pm.Variable, "$"), "", "")) + } else { + m.SetVariable(sourceVariableToGen(strings.TrimPrefix(pm.Variable, "$"), pm.VariableKind)) + } ps.AddParameterMappings(m) } return ps diff --git a/mdl/backend/modelsdk/widget_write_showpage_param_mapping_test.go b/mdl/backend/modelsdk/widget_write_showpage_param_mapping_test.go index 32635eebaa..50e2b950b7 100644 --- a/mdl/backend/modelsdk/widget_write_showpage_param_mapping_test.go +++ b/mdl/backend/modelsdk/widget_write_showpage_param_mapping_test.go @@ -73,3 +73,37 @@ func TestClientActionToGen_ShowPageWritesNamedParameterMapping(t *testing.T) { t.Errorf("inferred argument written as %d explicit mappings, want 0 (#296: CE0115)", len(got)) } } + +// mendixlabs/mxcli#122: a control-bar button passing its grid's selection +// (`$dgOrders`) is the same explicit mapping with the grid in the PageVariable's +// Widget slot. Measured on mxbuild 11.12.5: this shape is 0 errors; the empty +// array is CE1571 "No argument has been selected", and the selection written as +// Argument "$dgOrders" is CE0115 "do not match the expected parameters". +func TestClientActionToGen_ShowPageWritesSelectionMapping(t *testing.T) { + el, err := clientActionToGen(&pages.PageClientAction{ + BaseElement: model.BaseElement{ID: "a"}, + PageName: "M.Detail", + ParameterMappings: []*pages.PageClientParameterMapping{ + {ParameterName: "Order", Variable: "$dgOrders", VariableKind: "widget"}, + }, + }) + if err != nil { + t.Fatalf("clientActionToGen: %v", err) + } + items := el.(*genPg.PageClientAction).PageSettings().(*genPg.PageSettings).ParameterMappingsItems() + if len(items) != 1 { + t.Fatalf("%d mappings written, want 1", len(items)) + } + m := items[0].(*genPg.PageParameterMapping) + if m.TypeName() != "Forms$PageParameterMapping" || m.ParameterQualifiedName() != "M.Detail.Order" || m.Argument() != "" { + t.Errorf("mapping $Type=%q Parameter=%q Argument=%q, want Forms$PageParameterMapping, M.Detail.Order, \"\"", + m.TypeName(), m.ParameterQualifiedName(), m.Argument()) + } + pv, ok := m.Variable().(*genPg.PageVariable) + if !ok { + t.Fatalf("Variable = %T, want *PageVariable", m.Variable()) + } + if pv.WidgetQualifiedName() != "dgOrders" || pv.PageParameterQualifiedName() != "" { + t.Errorf("PageVariable widget=%q page=%q, want dgOrders and \"\"", pv.WidgetQualifiedName(), pv.PageParameterQualifiedName()) + } +} diff --git a/mdl/catalog/builder_rest.go b/mdl/catalog/builder_rest.go index 767a27601a..5b6f33a47c 100644 --- a/mdl/catalog/builder_rest.go +++ b/mdl/catalog/builder_rest.go @@ -178,7 +178,7 @@ func (b *Builder) buildPublishedRestServices() error { svc.ServiceName, len(svc.Resources), opCount, - "", // Documentation not stored on published REST + svc.Documentation, // was always "", so QUAL002 could never close (mendixlabs/mxcli#1510) projectID, snapshotID, ) if err != nil { diff --git a/mdl/catalog/builder_rest_documentation_test.go b/mdl/catalog/builder_rest_documentation_test.go new file mode 100644 index 0000000000..be3f6bfc36 --- /dev/null +++ b/mdl/catalog/builder_rest_documentation_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import "testing" + +// mendixlabs/mxcli#1510: the catalog wrote an empty Documentation for every +// published REST service ("not stored on published REST"), so QUAL002 reported +// each one as undocumented whatever Studio Pro or MDL had written — a finding +// nobody could close. The column carries the service's own documentation. +func TestPublishedRestServiceDocumentationReachesCatalog_Issue1510(t *testing.T) { + svc := oneService() + svc.Documentation = "The public orders API." + cat := restFixture(t, svc) + + rows := queryRows(t, cat, `SELECT Documentation FROM published_rest_services WHERE QualifiedName = 'Sales.OrdersApi'`) + if len(rows) != 1 { + t.Fatalf("want 1 service row, got %d", len(rows)) + } + if got := rows[0][0]; got != "The public orders API." { + t.Errorf("Documentation = %v, want the service's documentation", got) + } + + // Control: an undocumented service stays empty, so the rule still fires. + cat = restFixture(t, oneService()) + rows = queryRows(t, cat, `SELECT Documentation FROM published_rest_services`) + if len(rows) != 1 || rows[0][0] != "" { + t.Errorf("undocumented service: got %v, want one empty Documentation", rows) + } +} diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go index adbfe888b5..fe9a1aa944 100644 --- a/mdl/executor/cmd_alter_flow.go +++ b/mdl/executor/cmd_alter_flow.go @@ -31,6 +31,10 @@ func execAlterFlow(ctx *ExecContext, s *ast.AlterFlowStmt) error { if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } + // An annotation the fragment's builder would drop (mendixlabs/mxcli#1597). + if err := validateAlterFlowRules(s); err != nil { + return err + } a, err := loadAlterFlow(ctx, s) if err != nil { return err diff --git a/mdl/executor/cmd_associations.go b/mdl/executor/cmd_associations.go index adda7a830a..c657c7e24d 100644 --- a/mdl/executor/cmd_associations.go +++ b/mdl/executor/cmd_associations.go @@ -19,6 +19,9 @@ func execCreateAssociation(ctx *ExecContext, s *ast.CreateAssociationStmt) error if !ctx.Connected() { return mdlerrors.NewNotConnected() } + if err := checkVersionGates(ctx, s); err != nil { + return err + } // A FROM entity in another module writes a project that cannot be LOADED, so // this must fail before the write — `check` reporting it is not enough for a @@ -121,6 +124,9 @@ func execCreateAssociation(ctx *ExecContext, s *ast.CreateAssociationStmt) error assoc.ChildDeleteBehavior = &domainmodel.DeleteBehavior{Type: deleteBehavior, ErrorMessage: deleteMessage, ErrorMessageLanguage: deleteLang} assoc.Documentation = carriedDocumentation( associationDocumentationStated(s), associationDocumentation(s), assoc.Documentation) + if s.ExportLevel != nil { // absent keeps the stored level (#1498) + assoc.ExportLevel = *s.ExportLevel + } // Anchors are applied only when the statement names them — // silence preserves what is stored, so a `create or modify` // that is not about layout does not flatten a hand-tuned line. @@ -148,6 +154,9 @@ func execCreateAssociation(ctx *ExecContext, s *ast.CreateAssociationStmt) error ca.ChildRef = childRef ca.Documentation = carriedDocumentation( associationDocumentationStated(s), associationDocumentation(s), ca.Documentation) + if s.ExportLevel != nil { // as above (#1498) + ca.ExportLevel = *s.ExportLevel + } if err := ctx.Backend.UpdateDomainModel(dm); err != nil { return mdlerrors.NewBackend("update cross-module association", err) } @@ -195,6 +204,7 @@ func execCreateAssociation(ctx *ExecContext, s *ast.CreateAssociationStmt) error StorageFormat: storageFormat, ParentID: parentID, ChildRef: childRef, + ExportLevel: authoredExportLevel(s.ExportLevel), ChildDeleteBehavior: &domainmodel.DeleteBehavior{ Type: deleteBehavior, ErrorMessage: deleteMessage, @@ -228,6 +238,7 @@ func execCreateAssociation(ctx *ExecContext, s *ast.CreateAssociationStmt) error StorageFormat: storageFormat, ParentID: parentID, ChildID: childID, + ExportLevel: authoredExportLevel(s.ExportLevel), ChildDeleteBehavior: &domainmodel.DeleteBehavior{ Type: deleteBehavior, ErrorMessage: deleteMessage, @@ -269,6 +280,9 @@ func execAlterAssociation(ctx *ExecContext, s *ast.AlterAssociationStmt) error { if !ctx.Connected() { return mdlerrors.NewNotConnected() } + if err := checkVersionGates(ctx, s); err != nil { + return err + } module, err := findModule(ctx, s.Name.Module) if err != nil { @@ -298,10 +312,13 @@ func execAlterAssociation(ctx *ExecContext, s *ast.AlterAssociationStmt) error { assoc.Documentation = s.Comment case ast.AlterAssociationSetAnchor: applyAnchors(assoc, s.FromAnchor, s.ToAnchor) + case ast.AlterAssociationSetExportLevel: + assoc.ExportLevel = s.ExportLevel } want := alteredAssociationValue(s.Operation, assocAlterView{ del: assoc.ChildDeleteBehavior, owner: string(assoc.Owner), storage: string(assoc.StorageFormat), doc: assoc.Documentation, lang: authoringLanguage(ctx), anchors: associationAnchors(assoc), + exportLevel: assoc.ExportLevel, }) if err := ctx.Backend.UpdateDomainModel(dm); err != nil { return mdlerrors.NewBackend("update association", err) @@ -333,6 +350,8 @@ func execAlterAssociation(ctx *ExecContext, s *ast.AlterAssociationStmt) error { ca.StorageFormat = domainmodel.AssociationStorageFormat(s.Storage.String()) case ast.AlterAssociationSetComment: ca.Documentation = s.Comment + case ast.AlterAssociationSetExportLevel: + ca.ExportLevel = s.ExportLevel case ast.AlterAssociationSetAnchor: // DomainModels$CrossAssociation has no connection properties at // all, and writing them there crashes Studio Pro (#50) — so this @@ -343,7 +362,7 @@ func execAlterAssociation(ctx *ExecContext, s *ast.AlterAssociationStmt) error { } want := alteredAssociationValue(s.Operation, assocAlterView{ del: ca.ChildDeleteBehavior, owner: string(ca.Owner), storage: string(ca.StorageFormat), - doc: ca.Documentation, lang: authoringLanguage(ctx), + doc: ca.Documentation, lang: authoringLanguage(ctx), exportLevel: ca.ExportLevel, }) if err := ctx.Backend.UpdateDomainModel(dm); err != nil { return mdlerrors.NewBackend("update cross-module association", err) @@ -428,8 +447,8 @@ func reconcileModuleAccess(ctx *ExecContext, moduleName, why string) error { // assocAlterView is the part of an association ALTER ASSOCIATION can change, // common to the same-module and the cross-module kind. type assocAlterView struct { - del *domainmodel.DeleteBehavior - owner, storage, doc, anchors string + del *domainmodel.DeleteBehavior + owner, storage, doc, anchors, exportLevel string // lang is the language the delete message is compared in — the one the // alter wrote. Reading it back in en_US would compare against a stale // translation the write carried over (mendixlabs/mxcli#1344). @@ -458,6 +477,8 @@ func alteredAssociationValue(op ast.AlterAssociationOperation, v assocAlterView) return v.doc case ast.AlterAssociationSetAnchor: return v.anchors + case ast.AlterAssociationSetExportLevel: + return v.exportLevel } return "" } @@ -485,6 +506,7 @@ func verifyAssociationAltered(ctx *ExecContext, moduleID model.ID, s *ast.AlterA got, found = alteredAssociationValue(s.Operation, assocAlterView{ del: a.ChildDeleteBehavior, owner: string(a.Owner), storage: string(a.StorageFormat), doc: a.Documentation, lang: authoringLanguage(ctx), anchors: associationAnchors(a), + exportLevel: a.ExportLevel, }), true break } @@ -494,7 +516,7 @@ func verifyAssociationAltered(ctx *ExecContext, moduleID model.ID, s *ast.AlterA if ca.Name == s.Name.Name { got, found = alteredAssociationValue(s.Operation, assocAlterView{ del: ca.ChildDeleteBehavior, owner: string(ca.Owner), storage: string(ca.StorageFormat), - doc: ca.Documentation, lang: authoringLanguage(ctx), + doc: ca.Documentation, lang: authoringLanguage(ctx), exportLevel: ca.ExportLevel, }), true break } @@ -746,7 +768,7 @@ func describeAssociation(ctx *ExecContext, name ast.QualifiedName) error { // omitted; Table is not, so it is always printed — omitting it made a table // association come back as column wherever the description was replayed // (#704). - formatAssocDetails := func(assocType domainmodel.AssociationType, assocOwner domainmodel.AssociationOwner, storageFormat domainmodel.AssociationStorageFormat, childDeleteBehavior *domainmodel.DeleteBehavior) { + formatAssocDetails := func(assocType domainmodel.AssociationType, assocOwner domainmodel.AssociationOwner, storageFormat domainmodel.AssociationStorageFormat, childDeleteBehavior *domainmodel.DeleteBehavior, exportLevel string) { var clauses []string if assocType == domainmodel.AssociationTypeReferenceSet { clauses = append(clauses, "type ReferenceSet") @@ -764,6 +786,9 @@ func describeAssociation(ctx *ExecContext, name ast.QualifiedName) error { if del := describeDeleteClause(ctx, childDeleteBehavior); del != defaultDeleteClause { clauses = append(clauses, del) } + if c := exportLevelClause(exportLevel); c != "" { // mendixlabs/mxcli#1498 + clauses = append(clauses, c) + } for _, c := range clauses { fmt.Fprintf(ctx.Output, "\n%s", c) } @@ -782,7 +807,7 @@ func describeAssociation(ctx *ExecContext, name ast.QualifiedName) error { describeConnectionPoints(ctx, assoc) fmt.Fprintf(ctx.Output, "create or modify association %s.%s\n", module.Name, assoc.Name) fmt.Fprintf(ctx.Output, "from %s to %s", fromEntity, toEntity) - formatAssocDetails(assoc.Type, assoc.Owner, assoc.StorageFormat, assoc.ChildDeleteBehavior) + formatAssocDetails(assoc.Type, assoc.Owner, assoc.StorageFormat, assoc.ChildDeleteBehavior, assoc.ExportLevel) return nil } } @@ -799,7 +824,7 @@ func describeAssociation(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprintf(ctx.Output, "create or modify association %s.%s\n", module.Name, ca.Name) fmt.Fprintf(ctx.Output, "from %s to %s", fromEntity, ca.ChildRef) - formatAssocDetails(ca.Type, ca.Owner, ca.StorageFormat, ca.ChildDeleteBehavior) + formatAssocDetails(ca.Type, ca.Owner, ca.StorageFormat, ca.ChildDeleteBehavior, ca.ExportLevel) return nil } } diff --git a/mdl/executor/cmd_catalog.go b/mdl/executor/cmd_catalog.go index e00e7b9ab2..8c765ca04e 100644 --- a/mdl/executor/cmd_catalog.go +++ b/mdl/executor/cmd_catalog.go @@ -1091,9 +1091,12 @@ func preWarmCache(ctx *ExecContext) { describeDefaultLanguage(ctx) } -// SearchOptions harden a search. The zero value is what the SEARCH statement -// uses. +// SearchOptions narrow or harden a search. The zero value is what the SEARCH +// statement uses. type SearchOptions struct { + // ExcludeMarketplace drops hits in modules downloaded from the Marketplace, + // so an audit of the app's own text does not report a vendor's. + ExcludeMarketplace bool // Strict returns the index's error for a query it cannot run (an FTS5 syntax // error, say) instead of reporting "no matches". A caller that gates on the // absence of hits needs the difference: "nothing found" and "could not look" @@ -1101,15 +1104,31 @@ type SearchOptions struct { Strict bool } +// marketplaceModuleFilter is the WHERE fragment that keeps a search to modules +// the project owns. A module's Source is empty unless it came from the +// Marketplace (see the modules table). +// +// Only the current snapshot's modules count: the modules view holds a row per +// snapshot, and a module that came from the Marketplace in an older one would +// otherwise be left out of every later search. The current snapshot is the +// latest one recorded, as AddGraphAnalysis reads it. +const marketplaceModuleFilter = " and ModuleName not in (select Name from modules" + + " where COALESCE(Source, '') <> ''" + + " and SnapshotId = (select SnapshotId from snapshots order by rowid desc limit 1))" + // searchSQL returns the strings-index and source-index queries for a search. -func searchSQL(query string) (stringsSQL, sourceSQL string) { +func searchSQL(query string, opts SearchOptions) (stringsSQL, sourceSQL string) { + filter := "" + if opts.ExcludeMarketplace { + filter = marketplaceModuleFilter + } q := escapeFTSQuery(query) stringsSQL = fmt.Sprintf( - "select QualifiedName, ObjectType, snippet(strings, 2, '>>>', '<<<', '...', 32) as Match, StringContext, ModuleName from strings where strings match '%s' limit 50", - q) + "select QualifiedName, ObjectType, snippet(strings, 2, '>>>', '<<<', '...', 32) as Match, StringContext, ModuleName from strings where strings match '%s'%s limit 50", + q, filter) sourceSQL = fmt.Sprintf( - "select QualifiedName, ObjectType, snippet(source, 2, '>>>', '<<<', '...', 48) as Match, ModuleName from source where source match '%s' limit 50", - q) + "select QualifiedName, ObjectType, snippet(source, 2, '>>>', '<<<', '...', 48) as Match, ModuleName from source where source match '%s'%s limit 50", + q, filter) return stringsSQL, sourceSQL } @@ -1145,7 +1164,7 @@ func searchTable(ctx *ExecContext, query string, opts SearchOptions) (int, error return 0, err } - stringsSQL, sourceSQL := searchSQL(query) + stringsSQL, sourceSQL := searchSQL(query, opts) hits := 0 // Search strings table @@ -1261,7 +1280,7 @@ func searchWith(ctx *ExecContext, query, format string, opts SearchOptions) (int var allResults []searchResult seen := make(map[string]bool) // For deduplicating names format - stringsSQL, sourceSQL := searchSQL(query) + stringsSQL, sourceSQL := searchSQL(query, opts) // Search strings table strResult, err := runSearchQuery(ctx, stringsSQL, opts) diff --git a/mdl/executor/cmd_datatransformer.go b/mdl/executor/cmd_datatransformer.go index ebbde4951b..44905ec503 100644 --- a/mdl/executor/cmd_datatransformer.go +++ b/mdl/executor/cmd_datatransformer.go @@ -75,7 +75,8 @@ func describeDataTransformer(ctx *ExecContext, name ast.QualifiedName) error { w := ctx.Output - // Emit re-executable MDL + // Emit re-executable MDL, the documentation first (mendixlabs/mxcli#1510). + writeDocumentDocComment(w, dt.Documentation) fmt.Fprintf(w, "create or modify data transformer %s.%s%s\n", modName, dt.Name, describeFolderClause(ctx, dt.ContainerID)) // Source — collapse newlines into spaces for single-line string @@ -137,10 +138,14 @@ func execCreateDataTransformer(ctx *ExecContext, s *ast.CreateDataTransformerStm Name: s.Name.Name, SourceType: s.SourceType, SourceJSON: s.SourceJSON, + // The doc comment (mendixlabs/mxcli#1510). + Documentation: s.Documentation, } if existing != nil { // Excluded is model state, not script state (#914). dt.Excluded = existing.Excluded + // A rewrite that carried no doc comment keeps the stored one (#1018). + dt.Documentation = carriedDocumentation(s.DocumentationSet, s.Documentation, existing.Documentation) } for _, step := range s.Steps { diff --git a/mdl/executor/cmd_domain_export_level_test.go b/mdl/executor/cmd_domain_export_level_test.go new file mode 100644 index 0000000000..e0ed292b9f --- /dev/null +++ b/mdl/executor/cmd_domain_export_level_test.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// mendixlabs/mxcli#1498 (follow-up, point 2): describe prints an entity's, +// attribute's, association's and enumeration's `export level api`, and the +// printed statement parses back to the same level — so describe -> exec keeps a +// Usable element Usable. A Hidden one prints no clause (the default), which is +// the control: a describe that printed the clause for every element would pass +// the API half alone. +func TestDescribe_DomainModelExportLevelRoundTrips(t *testing.T) { + mod := mkModule("M") + e := mkEntity(mod.ID, "E") + e.ExportLevel = "API" + e.Attributes = []*domainmodel.Attribute{ + {BaseElement: model.BaseElement{ID: nextID("attr")}, Name: "Usable", Type: &domainmodel.StringAttributeType{Length: 10}, ExportLevel: "API"}, + {BaseElement: model.BaseElement{ID: nextID("attr")}, Name: "Plain", Type: &domainmodel.StringAttributeType{Length: 10}, ExportLevel: "Hidden"}, + } + f := mkEntity(mod.ID, "F") + f.ExportLevel = "Hidden" + dm := mkDomainModel(mod.ID, e, f) + usable := mkAssociation(mod.ID, "E_F", e.ID, f.ID) + usable.ExportLevel = "API" + plain := mkAssociation(mod.ID, "F_E", f.ID, e.ID) + plain.ExportLevel = "Hidden" + dm.Associations = []*domainmodel.Association{usable, plain} + dm.CrossAssociations = []*domainmodel.CrossModuleAssociation{{ + BaseElement: model.BaseElement{ID: nextID("cross")}, Name: "E_User", ParentID: e.ID, ChildRef: "System.User", + Type: domainmodel.AssociationTypeReference, Owner: domainmodel.AssociationOwnerDefault, ExportLevel: "API", + }} + kind := mkEnumeration(mod.ID, "Kind", "A") + kind.ExportLevel = "API" + other := mkEnumeration(mod.ID, "Other", "A") + other.ExportLevel = "Hidden" + + mods := []*model.Module{mod} + ctx, _ := newMockCtx(t, withBackend(&mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return mods, nil }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return []*domainmodel.DomainModel{dm}, nil }, + ListEnumerationsFunc: func() ([]*model.Enumeration, error) { return []*model.Enumeration{kind, other}, nil }, + }), withHierarchy(mkHierarchy(mods...))) + buf := ctx.Output.(*bytes.Buffer) + describe := func(f func() error) *ast.Program { + t.Helper() + buf.Reset() + if err := f(); err != nil { + t.Fatal(err) + } + return parseMDL(t, buf.String()) + } + qn := func(name string) ast.QualifiedName { return ast.QualifiedName{Module: "M", Name: name} } + level := func(p *string) string { + if p == nil { + return "" + } + return *p + } + + ent := firstStmt[*ast.CreateEntityStmt](t, describe(func() error { return describeEntity(ctx, qn("E")) })) + if level(ent.ExportLevel) != "API" { + t.Errorf("describe entity M.E re-parses with ExportLevel %q, want API", level(ent.ExportLevel)) + } + for _, a := range ent.Attributes { + want := map[string]string{"Usable": "API", "Plain": ""}[a.Name] + if level(a.ExportLevel) != want { + t.Errorf("attribute %s re-parses with ExportLevel %q, want %q", a.Name, level(a.ExportLevel), want) + } + } + if ent := firstStmt[*ast.CreateEntityStmt](t, describe(func() error { return describeEntity(ctx, qn("F")) })); ent.ExportLevel != nil { + t.Errorf("describe of a Hidden entity printed export level %q", *ent.ExportLevel) + } + + for name, want := range map[string]string{"E_F": "API", "F_E": "", "E_User": "API"} { + a := firstStmt[*ast.CreateAssociationStmt](t, describe(func() error { return describeAssociation(ctx, qn(name)) })) + if level(a.ExportLevel) != want { + t.Errorf("describe association M.%s re-parses with ExportLevel %q, want %q", name, level(a.ExportLevel), want) + } + } + + for name, want := range map[string]string{"Kind": "API", "Other": ""} { + en := firstStmt[*ast.CreateEnumerationStmt](t, describe(func() error { return describeEnumeration(ctx, qn(name)) })) + if level(en.ExportLevel) != want { + t.Errorf("describe enumeration M.%s re-parses with ExportLevel %q, want %q", name, level(en.ExportLevel), want) + } + } + if strings.Contains(buf.String(), "Studio Pro") { + t.Errorf("describe still points at Studio Pro for the level:\n%s", buf.String()) + } +} + +func firstStmt[T ast.Statement](t *testing.T, prog *ast.Program) T { + t.Helper() + for _, s := range prog.Statements { + if v, ok := s.(T); ok { + return v + } + } + var zero T + t.Fatalf("no %T in %d statements", zero, len(prog.Statements)) + return zero +} diff --git a/mdl/executor/cmd_entities.go b/mdl/executor/cmd_entities.go index 5780476348..62df98c2e1 100644 --- a/mdl/executor/cmd_entities.go +++ b/mdl/executor/cmd_entities.go @@ -89,6 +89,9 @@ func execCreateEntity(ctx *ExecContext, s *ast.CreateEntityStmt) error { if !ctx.Connected() { return mdlerrors.NewNotConnected() } + if err := checkVersionGates(ctx, s); err != nil { + return err + } // An unqualified EXTENDS target is refused here as well as at check time: // `exec --no-check` skips the pre-flight, and this is the case that writes a @@ -259,6 +262,7 @@ func execCreateEntity(ctx *ExecContext, s *ast.CreateEntityStmt) error { Name: a.Name, Documentation: doc, Type: convertDataType(a.Type), + ExportLevel: authoredExportLevel(a.ExportLevel), } attr.ID = attrID @@ -375,6 +379,7 @@ func execCreateEntity(ctx *ExecContext, s *ast.CreateEntityStmt) error { HasChangedBy: storeChangedBy, HasCreatedDate: storeCreatedDate, HasChangedDate: storeChangedDate, + ExportLevel: authoredExportLevel(s.ExportLevel), } if autoPlace { // The next grid slot whose box clears everything already in the module. @@ -529,6 +534,11 @@ func mergeDeclaredOntoStoredEntity(stored, declared *domainmodel.Entity, s *ast. } else { merged.Documentation = declared.Documentation } + // EXPORT LEVEL likewise: an absent clause ("" on the rebuilt entity) keeps + // the stored level (mendixlabs/mxcli#1498). + if declared.ExportLevel != "" { + merged.ExportLevel = declared.ExportLevel + } return &merged } @@ -573,6 +583,9 @@ func carryStoredAttributeState(stored *domainmodel.Entity, declared []*domainmod if !stated[a.Name] { carryStoredLocalizeDate(old.Type, a.Type) } + if a.ExportLevel == "" { + a.ExportLevel = old.ExportLevel // no EXPORT LEVEL clause: keep it (#1498) + } } } @@ -660,6 +673,7 @@ var entityFieldsDeclaredByStatement = map[string]bool{ "GeneralizationRef": true, "Generalization": true, "GeneralizationID": true, + "ExportLevel": true, // when stated: "" keeps the stored level (#1498) // ContainerID and the embedded BaseElement identify the stored element, so // they are preserved. `entity.ID = existingEntity.ID` at the call site says // the same thing about the ID; the merge is where it now comes from. @@ -1103,6 +1117,7 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { Value: &domainmodel.AttributeValue{ ViewReference: a.Name, // OQL column alias matches attribute name }, + ExportLevel: authoredExportLevel(a.ExportLevel), } if dt, ok := attr.Type.(*domainmodel.DateTimeAttributeType); ok && a.Type.Localize == ast.LocalizeUnstated { if l, ok := localize[a.Name]; ok { @@ -1115,6 +1130,9 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { if ea.Value != nil { attr.Value.ID = ea.Value.ID } + if attr.ExportLevel == "" { + attr.ExportLevel = ea.ExportLevel // no clause: keep it (#1498) + } } attrs = append(attrs, attr) } @@ -1131,6 +1149,7 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { Source: "DomainModels$OqlViewEntitySource", SourceDocumentRef: sourceDocRef, OqlQuery: s.Query.RawQuery, + ExportLevel: authoredExportLevel(s.ExportLevel), } if autoPlace { entity.Location = dmlayout.Place(dm, entity) @@ -1156,6 +1175,9 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { // A rewrite that carried no doc comment keeps the stored one (#1018). merged.Documentation = carriedDocumentation( s.DocumentationSet, s.Documentation, existingEntity.Documentation) + if s.ExportLevel != nil { // absent keeps the stored level (#1498) + merged.ExportLevel = *s.ExportLevel + } // The ID and the Source object ID (CE-6770) are the stored ones. entity = &merged created = entity @@ -1210,6 +1232,9 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { if !ctx.Connected() { return mdlerrors.NewNotConnected() } + if err := checkVersionGates(ctx, s); err != nil { + return err + } // Find module module, err := findModule(ctx, s.Name.Module) @@ -1296,6 +1321,7 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { Name: a.Name, Documentation: a.Documentation, Type: convertDataType(a.Type), + ExportLevel: authoredExportLevel(a.ExportLevel), } attr.ID = attrID if a.Calculated { @@ -1515,6 +1541,9 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { } attr.Value = &domainmodel.AttributeValue{DefaultValue: defaultStr} } + if s.ModifyExportLevel != nil { + attr.ExportLevel = *s.ModifyExportLevel + } found = true break } @@ -1697,6 +1726,14 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { invalidateDomainModelsCache(ctx) fmt.Fprintf(ctx.Output, "Set documentation on entity %s\n", s.Name) + case ast.AlterEntitySetExportLevel: + entity.ExportLevel = s.ExportLevel + if err := ctx.Backend.UpdateEntity(dm.ID, entity); err != nil { + return mdlerrors.NewBackend("set export level", err) + } + invalidateDomainModelsCache(ctx) + ctx.ReportMutation("Set", "export level %s on entity %s", exportLevelKeyword(s.ExportLevel), s.Name) + case ast.AlterEntitySetPosition: if s.Position == nil { return mdlerrors.NewValidation("no position provided") diff --git a/mdl/executor/cmd_entities_access.go b/mdl/executor/cmd_entities_access.go index 1f06591eab..ef87abb2fa 100644 --- a/mdl/executor/cmd_entities_access.go +++ b/mdl/executor/cmd_entities_access.go @@ -54,8 +54,9 @@ func outputEntityAccessGrants(ctx *ExecContext, entity *domainmodel.Entity, modu // string is (describeXPath, ako/mxcli#825). // // A long constraint is stored broken across lines so it can be read in Studio -// Pro's editor (upstream #979); MDL keeps it on one line, and the executor -// re-derives the stored layout on write. +// Pro's editor (upstream #979); MDL keeps that layout on one line, and the +// executor re-derives it on write. A layout its author gave it is written as +// stored, which is what re-executing the grant stores (mendixlabs/mxcli#1542). // // A stored constraint the bracketed grammar does not read (one Studio Pro // stored without brackets, or with syntax the XPath rules do not cover) is @@ -63,7 +64,7 @@ func outputEntityAccessGrants(ctx *ExecContext, entity *domainmodel.Entity, modu // describe must stay re-executable over whatever a project holds, and a // deprecation warning is better than output that does not parse. func entityGrantMDL(ctx *ExecContext, rights, entity string, roles []string, xpath string) string { - x := visitor.FlattenXPathConstraint(xpath) + x := visitor.DescribeXPathConstraintLayout(xpath) if x == "" || visitor.IsBracketedXPath(x) { line := fmt.Sprintf("grant %s on entity %s to %s", rights, entity, strings.Join(roles, ", ")) if x != "" { diff --git a/mdl/executor/cmd_entities_describe.go b/mdl/executor/cmd_entities_describe.go index da888c430b..3dab13c812 100644 --- a/mdl/executor/cmd_entities_describe.go +++ b/mdl/executor/cmd_entities_describe.go @@ -235,21 +235,9 @@ func describeEntityWith(ctx *ExecContext, name ast.QualifiedName, withGrants boo } lang := describeDefaultLanguage(ctx) - protection := moduleProtection(ctx, string(module.ID)) for _, entity := range dm.Entities { if entity.Name == name.Name { - // In a Protected module an entity's export level decides whether - // other modules may use it (CE1406) and whether a Usable microflow - // may expose it (CE5400). MDL has no syntax for it, so it prints as - // a comment and a rewrite keeps the stored value (mendixlabs/mxcli#1498). - if protection != "" { - lvl := "Hidden" - if entity.ExportLevel == "API" { - lvl = "Usable" - } - fmt.Fprintf(ctx.Output, "-- export level: %s (%s is a protected module; set in Studio Pro)\n", lvl, module.Name) - } // Output JavaDoc documentation if present if entity.Documentation != "" { outputJavadocIndented(ctx.Output, entity.Documentation, "") @@ -362,6 +350,11 @@ func describeEntityWith(ctx *ExecContext, name ast.QualifiedName, withGrants boo } constraints.WriteString(fmt.Sprintf(" default %s", defaultVal)) } + // In a Protected module the level decides whether other modules + // may use the member (mendixlabs/mxcli#1498). + if c := exportLevelClause(attr.ExportLevel); c != "" { + constraints.WriteString(" " + c) + } line.WriteString(fmt.Sprintf(" %s: %s%s", attr.Name, typeStr, constraints.String())) attrLines = append(attrLines, attrLine{text: line.String()}) @@ -390,6 +383,13 @@ func describeEntityWith(ctx *ExecContext, name ast.QualifiedName, withGrants boo fmt.Fprintf(ctx.Output, "%s%s\n", al.text, comma) } fmt.Fprint(ctx.Output, ")") + // In a Protected module an entity's export level decides whether + // other modules may use it (CE1406) and whether a Usable microflow + // may expose it (CE5400). Before a view's `as (…)`, which ends the + // statement's options (mendixlabs/mxcli#1498). + if c := exportLevelClause(entity.ExportLevel); c != "" { + fmt.Fprintf(ctx.Output, "\n%s", c) + } // For VIEW entities, output the OQL query if entityType == "view" && entity.OqlQuery != "" { diff --git a/mdl/executor/cmd_enumerations.go b/mdl/executor/cmd_enumerations.go index 05ce2d1d3a..0d7e091074 100644 --- a/mdl/executor/cmd_enumerations.go +++ b/mdl/executor/cmd_enumerations.go @@ -53,6 +53,9 @@ func execCreateEnumeration(ctx *ExecContext, s *ast.CreateEnumerationStmt) error if !ctx.Connected() { return mdlerrors.NewNotConnected() } + if err := checkVersionGates(ctx, s); err != nil { + return err + } verb := "create enumeration" if s.CreateOrModify { @@ -113,6 +116,9 @@ func execCreateEnumeration(ctx *ExecContext, s *ast.CreateEnumerationStmt) error Name: s.Name.Name, Documentation: s.Documentation, Values: values, + // "" — no EXPORT LEVEL clause — keeps the stored level on a rewrite + // (UpdateEnumeration) and writes Hidden on create (#1498). + ExportLevel: authoredExportLevel(s.ExportLevel), } if existingEnum != nil && s.CreateOrModify { @@ -188,6 +194,9 @@ func execAlterEnumeration(ctx *ExecContext, s *ast.AlterEnumerationStmt) error { if err := refuseSystemEnumerationWrite("alter enumeration", s.Name); err != nil { return err } + if err := checkVersionGates(ctx, s); err != nil { + return err + } enum := findEnumeration(ctx, s.Name.Module, s.Name.Name) if enum == nil { return mdlerrors.NewNotFound("enumeration", s.Name.String()) @@ -267,6 +276,9 @@ func execAlterEnumeration(ctx *ExecContext, s *ast.AlterEnumerationStmt) error { case ast.AlterEnumSetDocumentation: enum.Documentation = s.Documentation + case ast.AlterEnumSetExportLevel: + enum.ExportLevel = s.ExportLevel + default: return mdlerrors.NewUnsupported("unknown ALTER ENUMERATION operation") } @@ -458,6 +470,9 @@ func describeEnumeration(ctx *ExecContext, name ast.QualifiedName) error { if fp := h.BuildFolderPath(enum.ContainerID); fp != "" && fp != modName { folderClause = " folder " + mdlQuote(ctx, fp) } + if c := exportLevelClause(enum.ExportLevel); c != "" { // mendixlabs/mxcli#1498 + folderClause += " " + c + } fmt.Fprintf(ctx.Output, ")%s;\n", folderClause) return nil } diff --git a/mdl/executor/cmd_export_mappings.go b/mdl/executor/cmd_export_mappings.go index 35fc037e7c..7be3d3fa25 100644 --- a/mdl/executor/cmd_export_mappings.go +++ b/mdl/executor/cmd_export_mappings.go @@ -94,9 +94,7 @@ func describeExportMapping(ctx *ExecContext, name ast.QualifiedName) error { return mdlerrors.NewBackend("get export mapping", err) } - if em.Documentation != "" { - fmt.Fprintf(ctx.Output, "/**\n * %s\n */\n", strings.ReplaceAll(em.Documentation, "\n", "\n * ")) - } + writeDocumentDocComment(ctx.Output, em.Documentation) // The same prefix a microflow or page carries: without it an excluded // mapping reads as live, and beside a live twin of the same name the two // are indistinguishable (#1185). @@ -328,11 +326,15 @@ func execCreateExportMapping(ctx *ExecContext, s *ast.CreateExportMappingStmt) e NullValueOption: nullValueOption, // @excluded, which DESCRIBE prints for an excluded mapping (#1185). Excluded: s.Excluded, + // The doc comment (mendixlabs/mxcli#1510). + Documentation: s.Documentation, } if existing != nil { // Excluded is model state, not script state: an absent @excluded must // not clear a stored exclusion (#914). em.Excluded = s.Excluded || existing.Excluded + // A rewrite that carried no doc comment keeps the stored one (#1018). + em.Documentation = carriedDocumentation(s.DocumentationSet, s.Documentation, existing.Documentation) } if em.NullValueOption == "" { em.NullValueOption = "LeaveOutElement" diff --git a/mdl/executor/cmd_import_mappings.go b/mdl/executor/cmd_import_mappings.go index 64a1be8a5b..9dd996e296 100644 --- a/mdl/executor/cmd_import_mappings.go +++ b/mdl/executor/cmd_import_mappings.go @@ -94,9 +94,7 @@ func describeImportMapping(ctx *ExecContext, name ast.QualifiedName) error { return mdlerrors.NewBackend("get import mapping", err) } - if im.Documentation != "" { - fmt.Fprintf(ctx.Output, "/**\n * %s\n */\n", strings.ReplaceAll(im.Documentation, "\n", "\n * ")) - } + writeDocumentDocComment(ctx.Output, im.Documentation) // The same prefix a microflow or page carries: without it an excluded // mapping reads as live, and beside a live twin of the same name the two // are indistinguishable (#1185). @@ -421,11 +419,15 @@ func execCreateImportMapping(ctx *ExecContext, s *ast.CreateImportMappingStmt) e ExportLevel: "Hidden", // @excluded, which DESCRIBE prints for an excluded mapping (#1185). Excluded: s.Excluded, + // The doc comment (mendixlabs/mxcli#1510). + Documentation: s.Documentation, } if existing != nil { // Excluded is model state, not script state: an absent @excluded must // not clear a stored exclusion (#914). im.Excluded = s.Excluded || existing.Excluded + // A rewrite that carried no doc comment keeps the stored one (#1018). + im.Documentation = carriedDocumentation(s.DocumentationSet, s.Documentation, existing.Documentation) } // The mapping's input object (#265), which `Param: parameter` binds. diff --git a/mdl/executor/cmd_javaactions.go b/mdl/executor/cmd_javaactions.go index 949f9fce3e..8938518b44 100644 --- a/mdl/executor/cmd_javaactions.go +++ b/mdl/executor/cmd_javaactions.go @@ -97,62 +97,15 @@ func describeJavaAction(ctx *ExecContext, name ast.QualifiedName) error { sb.WriteString(describeFolderClause(ctx, ja.ContainerID)) sb.WriteString("(") - // Parameters — one per line when descriptions are present - hasParamDescriptions := false - for _, p := range ja.Parameters { - if p.Description != "" { - hasParamDescriptions = true - break - } - } - - for i, param := range ja.Parameters { - if hasParamDescriptions { - sb.WriteString("\n ") - } - sb.WriteString(param.Name) - sb.WriteString(": ") - if param.ParameterType != nil { - sb.WriteString(formatJavaActionType(param.ParameterType)) - } else { - sb.WriteString("Object") - } - if param.IsRequired { - sb.WriteString(" not null") - } - // The separator precedes any `--` doc comment: written after it, the - // comment swallows the comma and the next parameter fails to parse (#1412). - if i < len(ja.Parameters)-1 { - sb.WriteString(",") - if !hasParamDescriptions { - sb.WriteString(" ") - } - } - if param.Description != "" { - paramDoc := strings.ReplaceAll(param.Description, "\r\n", "\n") - paramDoc = strings.ReplaceAll(paramDoc, "\r", "\n") - firstLine, _, _ := strings.Cut(paramDoc, "\n") - sb.WriteString(" -- ") - sb.WriteString(firstLine) - } - } - if hasParamDescriptions { - sb.WriteString("\n") - } + writeCodeActionParams(&sb, ja.Parameters, formatJavaActionType) sb.WriteString(")") - // Return type + // Return type, and the return value's name if ja.ReturnType != nil { sb.WriteString(" returns ") sb.WriteString(formatJavaActionReturnType(ja.ReturnType)) } - - // RETURN NAME metadata - if ja.ActionDefaultReturnName != "" { - sb.WriteString("\n-- return NAME: '") - sb.WriteString(ja.ActionDefaultReturnName) - sb.WriteString("'") - } + sb.WriteString(describeCodeActionReturnName(ja.ActionDefaultReturnName, ja.ReturnType != nil)) // EXPOSED AS clause if ja.MicroflowActionInfo != nil && ja.MicroflowActionInfo.Caption != "" { @@ -346,6 +299,94 @@ func formatJavaActionType(t javaactions.CodeActionParameterType) string { } // formatJavaActionReturnType formats a Java action return type. +// writeCodeActionParams prints a Java or JavaScript action's parameters, each +// description as the `/** … */` doc comment that create reads before a +// parameter — the form an entity attribute's documentation takes. It was a +// trailing `--` comment, which the lexer discards: a script copying describe's +// output lost every description, and only the first line was printed anyway +// (mendixlabs/mxcli#1588). One parameter per line when any is described. +func writeCodeActionParams(sb *strings.Builder, params []*javaactions.JavaActionParameter, + formatType func(javaactions.CodeActionParameterType) string) { + described := false + for _, p := range params { + if p.Description != "" { + described = true + break + } + } + for i, param := range params { + if described { + sb.WriteString("\n") + if param.Description != "" { + doc := strings.ReplaceAll(param.Description, "\r\n", "\n") + doc = strings.ReplaceAll(doc, "\r", "\n") + // A `*/` would end the doc comment mid-text and the rest would not + // parse. Shown as a comment, the parameter states none, so a + // rewrite carries the stored one. + if strings.Contains(doc, "*/") { + for line := range strings.SplitSeq(doc, "\n") { + fmt.Fprintf(sb, " -- %s\n", line) + } + } else if !strings.Contains(doc, "\n") && !strings.HasPrefix(doc, "*") { + // The inline form holds one line that does not itself start with + // `*`, which the parser strips as the gutter (mendixlabs/mxcli#1428). + fmt.Fprintf(sb, " /** %s */\n", doc) + } else { + outputJavadocIndented(sb, doc, " ") + } + } + sb.WriteString(" ") + } + sb.WriteString(param.Name) + sb.WriteString(": ") + if param.ParameterType != nil { + sb.WriteString(formatType(param.ParameterType)) + } else { + sb.WriteString("Object") + } + if param.IsRequired { + sb.WriteString(" not null") + } + if i < len(params)-1 { + sb.WriteString(",") + if !described { + sb.WriteString(" ") + } + } + } + if described { + sb.WriteString("\n") + } +} + +// describeCodeActionReturnName is the ` as $Name` that follows `returns T`, +// naming the return value as create reads it (mendixlabs/mxcli#1588). A name +// the `$Name` token cannot spell, or one with no `returns` to follow, is still +// shown, as a comment on its own line. +func describeCodeActionReturnName(name string, hasReturns bool) string { + if name == "" { + return "" + } + if hasReturns && isVariableTokenBody(name) { + return " as $" + name + } + return "\n-- return NAME: '" + name + "'" +} + +// isVariableTokenBody reports whether `$` + s lexes as one VARIABLE token +// ('$' ID_BODY+). +func isVariableTokenBody(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !(r == '_' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { + return false + } + } + return true +} + func formatJavaActionReturnType(t javaactions.CodeActionReturnType) string { if t == nil { return "Void" @@ -455,11 +496,12 @@ func execCreateJavaAction(ctx *ExecContext, s *ast.CreateJavaActionStmt) error { var existingJADoc string haveExistingJA := false var existingActionInfo *javaactions.MicroflowActionInfo - // A parameter's Description and Category have no MDL spelling either — - // DESCRIBE prints the description as a comment — so a rewrite carries them - // from the stored parameter of the same name. + // A parameter's Category has no MDL spelling, and its Description is set + // only by a `/** … */` before it, so a rewrite carries both from the stored + // parameter of the same name unless the statement says otherwise. storedParams := map[string]*javaactions.JavaActionParameter{} - // Neither has an MDL spelling, so a rewrite carries them. The defaults are + // The export level has no MDL spelling and the return value's name is set + // only by `returns T as $Name`, so a rewrite carries them. The defaults are // what Studio Pro writes on a new action; "Public" — what this used to // hardcode — is not a member of JavaActionsExportLevel (API | Hidden), and // every rewrite turned a Hidden action into it (ako/mxcli#705). @@ -505,6 +547,11 @@ func execCreateJavaAction(ctx *ExecContext, s *ast.CreateJavaActionStmt) error { newID = existingJAID } + // `returns T as $Name` names the return value (mendixlabs/mxcli#1588). + if s.ReturnName != "" { + defaultReturnName = s.ReturnName + } + // Create the Java action ja := &javaactions.JavaAction{ BaseElement: model.BaseElement{ @@ -581,10 +628,14 @@ func execCreateJavaAction(ctx *ExecContext, s *ast.CreateJavaActionStmt) error { } else { jaParam.ParameterType = astDataTypeToJavaActionParamType(param.Type) } + var storedDescription string if sp := storedParams[param.Name]; sp != nil { - jaParam.Description = sp.Description + storedDescription = sp.Description jaParam.Category = sp.Category } + // A doc comment before the parameter sets its description; none keeps + // the stored one (mendixlabs/mxcli#1588). + jaParam.Description = carriedDocumentation(param.DescriptionSet, param.Description, storedDescription) ja.Parameters = append(ja.Parameters, jaParam) } diff --git a/mdl/executor/cmd_javaactions_param_description_test.go b/mdl/executor/cmd_javaactions_param_description_test.go new file mode 100644 index 0000000000..b06e3b58ff --- /dev/null +++ b/mdl/executor/cmd_javaactions_param_description_test.go @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/javaactions" +) + +// mendixlabs/mxcli#1588. `create java action` had no way to set a parameter's +// description or the return value's name. `describe` printed both as `--` +// comments (`Input: String not null -- The text to convert.` and `-- return +// NAME: 'UpperText'`), which read like syntax: written in a script they passed +// check and exec and were dropped, the description left empty and the return +// name the default `ReturnValueName`. +// +// They are now spelled the way the language already spells the same two things +// elsewhere — a parameter's description is a `/** … */` doc comment before it, +// as on an entity attribute, and the return value's name is `returns T as +// $Name`, as on a microflow — and describe prints those forms, so a describe → +// exec round trip carries them. Both forms are shared with JavaScript actions. + +const toUpperScript = `create java action MyModule.ToUpper( + /** The text to convert. */ + Input: String not null, + /** + * How to convert it. + * Two lines, both kept. + */ + Mode: String +) returns String as $UpperText +as $$ +return Input.toUpperCase(); +$$;` + +func parseOne[T ast.Statement](t *testing.T, src string) T { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs, src) + } + return findStmt[T](t, prog, src) +} + +func TestCreateJavaAction_WritesStatedParameterDescriptionAndReturnName(t *testing.T) { + s := parseOne[*ast.CreateJavaActionStmt](t, toUpperScript) + + mod := mkModule("MyModule") + var captured *javaactions.JavaAction + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListJavaActionsFunc: func() ([]*types.JavaAction, error) { return nil, nil }, + CreateJavaActionFunc: func(ja *javaactions.JavaAction) error { + captured = ja + return nil + }, + WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, + javaactions.CodeActionReturnType, []string, string) error { + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + assertNoError(t, execCreateJavaAction(ctx, s)) + + if captured == nil || len(captured.Parameters) != 2 { + t.Fatalf("CreateJavaAction not called with two parameters: %+v", captured) + } + if got := captured.Parameters[0].Description; got != "The text to convert." { + t.Errorf("Input description = %q, want %q", got, "The text to convert.") + } + if got, want := captured.Parameters[1].Description, "How to convert it.\nTwo lines, both kept."; got != want { + t.Errorf("Mode description = %q, want %q", got, want) + } + if captured.ActionDefaultReturnName != "UpperText" { + t.Errorf("return value name = %q, want UpperText — the default is ReturnValueName, "+ + "which is what the report saw", captured.ActionDefaultReturnName) + } +} + +// A stated value replaces the stored one; an unstated one is still carried +// (TestJavaActionRewriteCarriesParameterDescriptionAndCategory), so the +// existing preserve rule and the new spelling do not fight. +func TestJavaActionRewrite_StatedDescriptionAndReturnNameReplaceStored(t *testing.T) { + mod := mkModule("MyModule") + stored := storedExposedAction(mod) + stored.ActionDefaultReturnName = "Old" + stored.Parameters = []*javaactions.JavaActionParameter{ + {Name: "Input", Description: "old text", Category: "Kept"}, + {Name: "Other", Description: "kept, the statement does not say"}, + } + ctx, captured := exposeRewriteCtx(t, stored, mod) + + s := rewriteStmt(false) + s.ReturnType = ast.DataType{Kind: ast.TypeString} + s.ReturnName = "New" + s.Parameters = []ast.JavaActionParam{ + {Name: "Input", Type: ast.DataType{Kind: ast.TypeString}, Description: "new text", DescriptionSet: true}, + {Name: "Other", Type: ast.DataType{Kind: ast.TypeString}}, + } + assertNoError(t, execCreateJavaAction(ctx, s)) + + got := *captured + if got == nil { + t.Fatal("UpdateJavaAction was never called") + } + if p := got.Parameters[0]; p.Description != "new text" || p.Category != "Kept" { + t.Errorf("Input: description %q, category %q — want the stated description and the carried category", p.Description, p.Category) + } + if p := got.Parameters[1]; p.Description != "kept, the statement does not say" { + t.Errorf("Other: description %q — an unstated description must be carried", p.Description) + } + if got.ActionDefaultReturnName != "New" { + t.Errorf("return value name = %q, want the stated New", got.ActionDefaultReturnName) + } +} + +// Describe prints the forms create reads, so its output re-executes into the +// same values — including a description of several lines, of which the `--` +// form kept only the first. +func TestDescribeJavaAction_DescriptionAndReturnNameRoundTrip(t *testing.T) { + mod := mkModule("MyModule") + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ReadJavaActionByNameFunc: func(string) (*javaactions.JavaAction, error) { + return &javaactions.JavaAction{ + BaseElement: model.BaseElement{ID: nextID("ja")}, + ContainerID: mod.ID, + Name: "ToUpper", + ActionDefaultReturnName: "UpperText", + ReturnType: &types.StringType{}, + Parameters: describedParams(), + }, nil + }, + } + ctx, buf := newMockCtx(t, withBackend(mb)) + assertNoError(t, describeJavaAction(ctx, ast.QualifiedName{Module: "MyModule", Name: "ToUpper"})) + out := buf.String() + stmt := findStmt[*ast.CreateJavaActionStmt](t, reparse(t, out), out) + assertDescribedForms(t, out, stmt.Parameters, stmt.ReturnName) +} + +func TestCreateJavaScriptAction_WritesStatedParameterDescriptionAndReturnName(t *testing.T) { + s := parseOne[*ast.CreateJavaScriptActionStmt](t, + strings.Replace(toUpperScript, "java action", "javascript action", 1)) + + mod := mkModule("MyModule") + var captured *types.JavaScriptAction + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListJavaScriptActionsFunc: func() ([]*types.JavaScriptAction, error) { return nil, nil }, + CreateJavaScriptActionFunc: func(jsa *types.JavaScriptAction) error { + captured = jsa + return nil + }, + WriteJavaScriptSourceFileFunc: func(string, string, string, []*types.JavaActionParameter, types.CodeActionReturnType) error { + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + assertNoError(t, execCreateJavaScriptAction(ctx, s)) + + if captured == nil || len(captured.Parameters) != 2 { + t.Fatalf("CreateJavaScriptAction not called with two parameters: %+v", captured) + } + if got := captured.Parameters[0].Description; got != "The text to convert." { + t.Errorf("Input description = %q", got) + } + if got, want := captured.Parameters[1].Description, "How to convert it.\nTwo lines, both kept."; got != want { + t.Errorf("Mode description = %q, want %q", got, want) + } + if captured.ActionDefaultReturnName != "UpperText" { + t.Errorf("return value name = %q, want UpperText", captured.ActionDefaultReturnName) + } +} + +func TestDescribeJavaScriptAction_DescriptionAndReturnNameRoundTrip(t *testing.T) { + mod := mkModule("WebMod") + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ReadJavaScriptActionByNameFunc: func(string) (*types.JavaScriptAction, error) { + return &types.JavaScriptAction{ + BaseElement: model.BaseElement{ID: nextID("jsa")}, + ContainerID: mod.ID, + Name: "ToUpper", + Platform: "Web", + ActionDefaultReturnName: "UpperText", + ReturnType: &types.StringType{}, + Parameters: describedParams(), + }, nil + }, + } + ctx, buf := newMockCtx(t, withBackend(mb)) + assertNoError(t, describeJavaScriptAction(ctx, ast.QualifiedName{Module: "WebMod", Name: "ToUpper"})) + out := buf.String() + stmt := findStmt[*ast.CreateJavaScriptActionStmt](t, reparse(t, out), out) + assertDescribedForms(t, out, stmt.Parameters, stmt.ReturnName) +} + +// describedParams covers the description shapes describe has to print: one +// line, several lines (CRLF, as Studio Pro stores it), a line that starts with +// `*` (which the doc-comment gutter would eat inline), one holding `*/` (which +// would end the doc comment early, so it cannot be one), and none. +func describedParams() []*types.JavaActionParameter { + return []*types.JavaActionParameter{ + {Name: "Input", Description: "The text to convert.", IsRequired: true, ParameterType: &types.StringType{}}, + {Name: "Mode", Description: "How to convert it.\r\nTwo lines, both kept.", ParameterType: &types.StringType{}}, + {Name: "Bullet", Description: "* a bullet", ParameterType: &types.StringType{}}, + {Name: "Glob", Description: "Files matching /tmp/*/ or */logs", ParameterType: &types.StringType{}}, + {Name: "Plain", ParameterType: &types.StringType{}}, + } +} + +func assertDescribedForms(t *testing.T, out string, params []ast.JavaActionParam, returnName string) { + t.Helper() + if strings.Contains(out, "-- return NAME") || strings.Contains(out, " -- The text") { + t.Errorf("describe still prints a value as a `--` comment, which create drops:\n%s", out) + } + want := []struct { + name, desc string + set bool + }{ + {"Input", "The text to convert.", true}, + {"Mode", "How to convert it.\nTwo lines, both kept.", true}, + {"Bullet", "* a bullet", true}, + // Not stated, so a rewrite carries the stored description — and the + // output still parses, which a doc comment holding `*/` would not. + {"Glob", "", false}, + {"Plain", "", false}, + } + if len(params) != len(want) { + t.Fatalf("re-parsed %d parameters, want %d\n%s", len(params), len(want), out) + } + for i, w := range want { + p := params[i] + if p.Name != w.name || p.Description != w.desc || p.DescriptionSet != w.set { + t.Errorf("parameter %d re-parsed as %q description %q (set %v), want %q %q (set %v)\n%s", + i, p.Name, p.Description, p.DescriptionSet, w.name, w.desc, w.set, out) + } + } + if returnName != "UpperText" { + t.Errorf("return value name re-parsed as %q, want UpperText\n%s", returnName, out) + } +} diff --git a/mdl/executor/cmd_javascript_actions.go b/mdl/executor/cmd_javascript_actions.go index 113b29f44b..2216f4c0c3 100644 --- a/mdl/executor/cmd_javascript_actions.go +++ b/mdl/executor/cmd_javascript_actions.go @@ -102,48 +102,8 @@ func describeJavaScriptAction(ctx *ExecContext, name ast.QualifiedName) error { sb.WriteString(describeFolderClause(ctx, jsa.ContainerID)) sb.WriteString("(") - // Parameters - hasParamDescriptions := false - for _, p := range jsa.Parameters { - if p.Description != "" { - hasParamDescriptions = true - break - } - } - - for i, param := range jsa.Parameters { - if hasParamDescriptions { - sb.WriteString("\n ") - } - sb.WriteString(param.Name) - sb.WriteString(": ") - if param.ParameterType != nil { - sb.WriteString(formatJavaScriptActionType(param.ParameterType)) - } else { - sb.WriteString("Object") - } - if param.IsRequired { - sb.WriteString(" not null") - } - // The separator precedes any `--` doc comment: written after it, the - // comment swallows the comma and the next parameter fails to parse (#1412). - if i < len(jsa.Parameters)-1 { - sb.WriteString(",") - if !hasParamDescriptions { - sb.WriteString(" ") - } - } - if param.Description != "" { - paramDoc := strings.ReplaceAll(param.Description, "\r\n", "\n") - paramDoc = strings.ReplaceAll(paramDoc, "\r", "\n") - firstLine, _, _ := strings.Cut(paramDoc, "\n") - sb.WriteString(" -- ") - sb.WriteString(firstLine) - } - } - if hasParamDescriptions { - sb.WriteString("\n") - } + // Parameters, each description as the doc comment create reads (see the Java twin). + writeCodeActionParams(&sb, jsa.Parameters, formatJavaScriptActionType) sb.WriteString(")") // Clauses are emitted in grammar order (returns, exposed, platform, body) so @@ -154,6 +114,7 @@ func describeJavaScriptAction(ctx *ExecContext, name ast.QualifiedName) error { sb.WriteString("\n returns ") sb.WriteString(formatJavaActionReturnType(jsa.ReturnType)) } + sb.WriteString(describeCodeActionReturnName(jsa.ActionDefaultReturnName, jsa.ReturnType != nil)) // EXPOSED AS clause if jsa.MicroflowActionInfo != nil && jsa.MicroflowActionInfo.Caption != "" { @@ -197,9 +158,6 @@ func describeJavaScriptAction(ctx *ExecContext, name ast.QualifiedName) error { fmt.Fprintln(ctx.Output, stmt) // Additional info as comments. - if jsa.ActionDefaultReturnName != "" { - fmt.Fprintf(ctx.Output, "-- return NAME: '%s'\n", jsa.ActionDefaultReturnName) - } if jsa.MicroflowActionInfo != nil && len(jsa.MicroflowActionInfo.IconData) > 0 { fmt.Fprintf(ctx.Output, "-- icon: %d bytes\n", len(jsa.MicroflowActionInfo.IconData)) } diff --git a/mdl/executor/cmd_javascript_actions_write.go b/mdl/executor/cmd_javascript_actions_write.go index 6de9667fe4..a59ce88b67 100644 --- a/mdl/executor/cmd_javascript_actions_write.go +++ b/mdl/executor/cmd_javascript_actions_write.go @@ -55,13 +55,15 @@ func execCreateJavaScriptAction(ctx *ExecContext, s *ast.CreateJavaScriptActionS var existingActionInfo *types.MicroflowActionInfo var existingJSDoc string haveExistingJS := false - // A parameter's Description and Category have no MDL spelling — DESCRIBE - // prints the description as a comment — so a rewrite carries them from the - // stored parameter of the same name, as the Java twin does (#731). + // A parameter's Category has no MDL spelling, and its Description is set + // only by a `/** … */` before it, so a rewrite carries both from the stored + // parameter of the same name unless the statement says otherwise, as the + // Java twin does (#731, mendixlabs/mxcli#1588). storedParams := map[string]*types.JavaActionParameter{} // The stored parameter names in order, for sourceUnchanged below. var storedParamNames []string - // No MDL spelling for either, so a rewrite carries them; the defaults are + // The export level has no MDL spelling and the return value's name is set + // only by `returns T as $Name`, so a rewrite carries them; the defaults are // Studio Pro's. "Public", which this hardcoded, is not a member of // JavaScriptActionsExportLevel (API | Hidden) — see the Java twin. exportLevel, defaultReturnName := "Hidden", "ReturnValueName" @@ -110,6 +112,10 @@ func execCreateJavaScriptAction(ctx *ExecContext, s *ast.CreateJavaScriptActionS newID = existingID } + if s.ReturnName != "" { + defaultReturnName = s.ReturnName + } + jsa := &types.JavaScriptAction{ BaseElement: model.BaseElement{ID: newID, TypeName: "JavaScriptActions$JavaScriptAction"}, ContainerID: containerID, @@ -170,10 +176,12 @@ func execCreateJavaScriptAction(ctx *ExecContext, s *ast.CreateJavaScriptActionS p.ParameterType = astDataTypeToJavaActionParamType(param.Type) } } + var storedDescription string if sp := storedParams[param.Name]; sp != nil { - p.Description = sp.Description + storedDescription = sp.Description p.Category = sp.Category } + p.Description = carriedDocumentation(param.DescriptionSet, param.Description, storedDescription) jsa.Parameters = append(jsa.Parameters, p) } diff --git a/mdl/executor/cmd_lint.go b/mdl/executor/cmd_lint.go index 634db86eb5..d19b7dc296 100644 --- a/mdl/executor/cmd_lint.go +++ b/mdl/executor/cmd_lint.go @@ -36,6 +36,7 @@ func execLint(ctx *ExecContext, s *ast.LintStmt) error { rules.NewValidationFeedbackRule(), rules.NewImageSourceRule(), rules.NewLegacyImageWidgetRule(), + rules.NewSnippetCallAppearanceRule(), rules.NewMissingTranslationsRule(), rules.NewRequiredCaptionDefaultLanguageRule(), rules.NewGallerySelectionListenerRule(), @@ -153,6 +154,7 @@ func listLintRules(ctx *ExecContext) error { lint.AddRule(rules.NewValidationFeedbackRule()) lint.AddRule(rules.NewImageSourceRule()) lint.AddRule(rules.NewLegacyImageWidgetRule()) + lint.AddRule(rules.NewSnippetCallAppearanceRule()) lint.AddRule(rules.NewMissingTranslationsRule()) lint.AddRule(rules.NewRequiredCaptionDefaultLanguageRule()) lint.AddRule(rules.NewGallerySelectionListenerRule()) diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index 045c81d09c..8402bacc8b 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -182,6 +182,21 @@ func mergePosition(ann *ast.ActivityAnnotations, computedX, computedY int) (int, return ann.Merge.X, ann.Merge.Y } +// adoptCurves takes over the @curves a sub-builder recorded, for a caller that +// is taking over its flows. applyFlowCurves runs once, on the builder that owns +// the flows when the graph is complete, so a curve left behind on a loop body's +// or an error handler's builder was never applied: check and exec passed and +// the edge came out straight (mendixlabs/mxcli#991, mendixlabs/mxcli#1597). +// Every site that appends a sub-builder's flows calls this alongside. +func (fb *flowBuilder) adoptCurves(sub *flowBuilder) { + for id, c := range sub.curveByOrigin { + if fb.curveByOrigin == nil { + fb.curveByOrigin = map[model.ID]*ast.FlowCurve{} + } + fb.curveByOrigin[id] = c + } +} + // applyFlowCurves stamps each recorded @curve onto the flows leaving that // activity. Runs once, after the graph is built. // diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index 616b932216..a032231ff0 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -760,6 +760,10 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID { // Add the internal flows to the parent's flows (top-level), not inside loop // This is how Mendix stores them - all flows at the microflow level fb.flows = append(fb.flows, loopBuilder.flows...) + // The body's @curves belong on those flows, and are applied by whichever + // builder ends up owning them; left on loopBuilder they were never applied + // (mendixlabs/mxcli#1597). + fb.adoptCurves(loopBuilder) fb.annotationFlows = append(fb.annotationFlows, loopBuilder.annotationFlows...) // Refusals raised in the body were collected into the sub-builder and // dropped on the floor, so exec reported success on a flow it had silently @@ -1111,6 +1115,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID { fb.objects = append(fb.objects, loop) fb.flows = append(fb.flows, loopBuilder.flows...) + fb.adoptCurves(loopBuilder) // see addLoopStatement (mendixlabs/mxcli#1597) fb.annotationFlows = append(fb.annotationFlows, loopBuilder.annotationFlows...) // Refusals raised in the body were collected into the sub-builder and // dropped on the floor, so exec reported success on a flow it had silently diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 9cd46edd97..3f159724ee 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -845,12 +845,7 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in // builder that owns the flows once its graph is complete — this one, since // the handler's flows (and the edge that rejoins the main flow) land here. // Left on errBuilder it was never applied (mendixlabs/mxcli#991). - for id, c := range errBuilder.curveByOrigin { - if fb.curveByOrigin == nil { - fb.curveByOrigin = map[model.ID]*ast.FlowCurve{} - } - fb.curveByOrigin[id] = c - } + fb.adoptCurves(errBuilder) if fb.annotationsByLabel == nil { fb.annotationsByLabel = errBuilder.annotationsByLabel } diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index 619f239ce3..6894201ebd 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -629,10 +629,12 @@ func formatAction( if dbSource.XPathConstraint != "" { // A stored constraint may be broken across lines — mxcli formats a - // long one that way so it can be read in Studio Pro's editor, and a - // person may have done the same by hand (upstream #979). MDL keeps it - // on one line; the executor re-derives the stored layout on write. - constraint := visitor.FlattenXPathConstraint(strings.TrimSpace(dbSource.XPathConstraint)) + // long one that way so it can be read in Studio Pro's editor + // (upstream #979), and that layout is described on one line: the + // executor re-derives it on write. A layout a person gave it is + // described as stored, which is what re-executing it stores + // (mendixlabs/mxcli#1542). + constraint := visitor.DescribeXPathConstraintLayout(strings.TrimSpace(dbSource.XPathConstraint)) // Enrich string literals for enum attributes to qualified names // (e.g. Status = 'Open' → Status = Module.OrderStatus.Open) when // the entity is known and we are connected to a project. diff --git a/mdl/executor/cmd_microflows_loop_curve_test.go b/mdl/executor/cmd_microflows_loop_curve_test.go new file mode 100644 index 0000000000..ce7dc0303d --- /dev/null +++ b/mdl/executor/cmd_microflows_loop_curve_test.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1597. A `@curve` on a statement inside a `loop` or `while` +// body passed check and exec and was not stored: the body is built by its own +// flowBuilder, which recorded the curve, and the loop handed its flows to the +// parent without the curves that belong on them. applyFlowCurves runs once, on +// the builder that owns the flows, so a curve left on the sub-builder was never +// applied. The same statement after the loop kept its curve. + +// loopBodyIDs returns the IDs of every object inside a loop's collection, at +// any depth, so a test can tell a body flow from a main-line one. +func loopBodyIDs(oc *microflows.MicroflowObjectCollection) map[model.ID]bool { + ids := map[model.ID]bool{} + var walk func(objs []microflows.MicroflowObject, inLoop bool) + walk = func(objs []microflows.MicroflowObject, inLoop bool) { + for _, o := range objs { + if inLoop { + ids[o.GetID()] = true + } + if l, ok := o.(*microflows.LoopedActivity); ok && l.ObjectCollection != nil { + walk(l.ObjectCollection.Objects, true) + } + } + } + walk(oc.Objects, false) + return ids +} + +// curvedFlows splits the flows carrying the given control vectors by whether +// they leave an activity inside a loop body. +func curvedFlows(oc *microflows.MicroflowObjectCollection, from, to string) (inBody, outside int) { + body := loopBodyIDs(oc) + for _, f := range oc.Flows { + if f.OriginControlVector != from || f.DestinationControlVector != to { + continue + } + if body[f.OriginID] { + inBody++ + } else { + outside++ + } + } + return inBody, outside +} + +func TestLoopBody_KeepsCurve(t *testing.T) { + src := `mdl 1; +create microflow Probe.ACT_Do ($L: List of Probe.Item) +begin + loop $I in $L + begin + @curve(from: (39, 0), to: (0, 61)) + log info node 'n' 'inside-a'; + log info node 'n' 'inside-b'; + end loop; + @curve(from: (39, 0), to: (0, 61)) + log info node 'n' 'outside-c'; + log info node 'n' 'outside-d'; +end;` + oc := buildCollectionFromMDL(t, false, src) + inBody, outside := curvedFlows(oc, "39;0", "0;61") + if outside != 1 { + t.Fatalf("%d main-line flows carry the curve, want 1 (the control: outside-c → outside-d)", outside) + } + if inBody != 1 { + t.Errorf("%d loop-body flows carry the curve, want 1 (inside-a → inside-b) — "+ + "the @curve inside the loop was recorded on the body's builder and never applied", inBody) + } +} + +func TestWhileBody_KeepsCurve(t *testing.T) { + src := `mdl 1; +create microflow Probe.ACT_While () +begin + declare $N integer = 0; + while $N < 3 + begin + @curve(from: (39, 0), to: (0, 61)) + log info node 'n' 'inside-a'; + log info node 'n' 'inside-b'; + set $N = $N + 1; + end while; +end;` + oc := buildCollectionFromMDL(t, false, src) + if inBody, _ := curvedFlows(oc, "39;0", "0;61"); inBody != 1 { + t.Errorf("%d while-body flows carry the curve, want 1 (inside-a → inside-b)", inBody) + } +} + +// A loop inside a loop is built by a sub-builder of a sub-builder, so the +// curve has to be handed up twice to reach the builder that applies it. +func TestNestedLoopBody_KeepsCurve(t *testing.T) { + src := `mdl 1; +create microflow Probe.ACT_Nested ($L: List of Probe.Item) +begin + loop $I in $L + begin + loop $J in $L + begin + @curve(from: (39, 0), to: (0, 61)) + log info node 'n' 'inner-a'; + log info node 'n' 'inner-b'; + end loop; + end loop; +end;` + oc := buildCollectionFromMDL(t, false, src) + if inBody, _ := curvedFlows(oc, "39;0", "0;61"); inBody != 1 { + t.Errorf("%d nested-loop-body flows carry the curve, want 1 (inner-a → inner-b)", inBody) + } +} diff --git a/mdl/executor/cmd_modules_export_level_test.go b/mdl/executor/cmd_modules_export_level_test.go index 9d00d8a0aa..3978c047d4 100644 --- a/mdl/executor/cmd_modules_export_level_test.go +++ b/mdl/executor/cmd_modules_export_level_test.go @@ -12,9 +12,9 @@ import ( // mendixlabs/mxcli#1498: nothing mxcli printed said a module was Protected, nor // what an entity's export level was, so the reporter found both by a raw read. -// MDL has no syntax for either, so they print as comments — the output still -// re-runs, and a rewrite keeps the stored values. Only a Protected module's -// output changes; Open is the control. +// MDL has no syntax for a module's, so it prints as a comment — the output +// still re-runs, and a rewrite keeps the stored value. Only a Protected +// module's output changes; Open is the control. func TestDescribe_ProtectedModuleExportLevels(t *testing.T) { ctx := protectedModuleFixture(t, nil) buf := ctx.Output.(*bytes.Buffer) @@ -39,18 +39,26 @@ func TestDescribe_ProtectedModuleExportLevels(t *testing.T) { t.Errorf("describe module of an ordinary module mentions an export level:\n%s", out) } - for _, c := range []struct{ entity, want string }{ - {"Request", "-- export level: Hidden"}, - {"Pub", "-- export level: Usable"}, + // An entity's level is a clause since MDL can state it (the #1498 + // follow-up): `export level api` when Usable, nothing for the default. + for _, c := range []struct { + entity string + want bool + }{ + {"Request", false}, + {"Pub", true}, } { out = describe(func() error { return describeEntity(ctx, ast.QualifiedName{Module: "MyAddOn", Name: c.entity}) }) - if !strings.Contains(out, c.want) { - t.Errorf("describe entity MyAddOn.%s: want %q in\n%s", c.entity, c.want, out) + if got := strings.Contains(out, "export level api"); got != c.want { + t.Errorf("describe entity MyAddOn.%s: `export level api` printed = %v, want %v:\n%s", c.entity, got, c.want, out) + } + if strings.Contains(out, "set in Studio Pro") { + t.Errorf("describe entity MyAddOn.%s still says the level is set in Studio Pro:\n%s", c.entity, out) } } out = describe(func() error { return describeEntity(ctx, ast.QualifiedName{Module: "Open", Name: "E"}) }) if strings.Contains(out, "export level") { - t.Errorf("describe entity in an ordinary module mentions an export level:\n%s", out) + t.Errorf("describe of a Hidden entity mentions an export level:\n%s", out) } } diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index bf140dff91..169da8d7db 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -368,12 +368,10 @@ func (pb *pageBuilder) buildWidgetV3(w *ast.WidgetV3) (pages.Widget, error) { if err := checkSearchByIsOnAListView(w); err != nil { return nil, err } - // On a 9.x project a pluggable widget stores this form too, as a - // CustomWidgetDatabaseSource (mendixlabs/mxcli#1399); a widget with its - // own builder still refuses it there, in buildDataSourceV3. - if err := checkDatabaseOverAssociationIsOnAListView(w); err != nil && !pb.customWidgetDatabaseSourceSupported() { - return nil, err - } + // `database from $ctx/Assoc/Entity` needs no check here: a list view and + // every widget the pluggable engine writes store it (buildPluggableDataSourceV3, + // mendixlabs/mxcli#1399, #1599), and a widget with its own builder refuses + // it in buildDataSourceV3. if err := checkWidgetHasName(w); err != nil { return nil, err } @@ -864,27 +862,6 @@ func checkSearchByIsOnAListView(w *ast.WidgetV3) error { w.Name, strings.ToLower(w.Type), strings.ToLower(w.Type))) } -// checkDatabaseOverAssociationIsOnAListView refuses `database from $ctx/Assoc/…` -// on a widget other than a list view. Only the List View writer stores a -// database source reached over an association (a Forms$ListViewXPathSource with -// an IndirectEntityRef); every other writer would store the entity alone and -// drop the path — a retrieve of every row instead of the context's -// (ako/mxcli#721 L5). A data view is refused by MDL-WIDGET09 whatever the form. -func checkDatabaseOverAssociationIsOnAListView(w *ast.WidgetV3) error { - if w == nil || strings.EqualFold(w.Type, "listview") || strings.EqualFold(w.Type, "dataview") { - return nil - } - ds := w.GetDataSource() - if ds == nil || ds.Type != "database" || ds.AssociationPath == "" { - return nil - } - return mdlerrors.NewValidation(fmt.Sprintf( - "widget %q (%s): `database from $%s/%s` is a LIST VIEW source reached over an association, "+ - "and %s cannot store one — use the association source `$%s/%s`, or a `listview`.", - w.Name, strings.ToLower(w.Type), ds.ContextVariable, ds.AssociationPath, - strings.ToLower(w.Type), ds.ContextVariable, ds.AssociationPath)) -} - // buildDataSourceV3 converts a V3 DataSource AST to a pages.DataSource. // Returns the datasource, the entity name for context, and any error. func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource, string, error) { @@ -929,14 +906,15 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource case "database": if ds.AssociationPath != "" { - // Only buildListViewDataSourceV3 may build this form. Every other - // caller — a widget-engine datasource under its own key, a chart - // series, a dynamic image — hands the result to a writer that stores - // the entity alone, a retrieve of every row instead of the context's + // Only buildListViewDataSourceV3 and buildPluggableDataSourceV3 may + // build this form. Every other caller — a chart series, a dynamic + // image — hands the result to a writer that stores the entity + // alone, a retrieve of every row instead of the context's // (ako/mxcli#721 L5). return nil, "", mdlerrors.NewValidationf( - "`database from $%s/%s` is a LIST VIEW source reached over an association; "+ - "only a `listview` DataSource can store one — use the association source `$%s/%s` here", + "`database from $%s/%s` is a database source reached over an association; "+ + "only a `listview` or a pluggable widget's DataSource (gallery, datagrid, …) can store one "+ + "— use the association source `$%s/%s` here", ds.ContextVariable, ds.AssociationPath, ds.ContextVariable, ds.AssociationPath) } // Database source: DATABASE Entity diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index a55ab32140..c900af47c1 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -665,8 +665,10 @@ type rawDataSource struct { ContextVariable string // association source: context variable name (empty → $currentObject) // Destination is the entity an association source's last hop arrives at, as // stored (EntityRef.Steps[last].DestinationEntity). Reference is the - // association there, so this is the entity the source puts in scope. Empty - // when the model does not store it. + // association there, so this is the entity the source puts in scope. A + // parameter source sets it to the parameter's entity (EntityRef.Entity), + // Reference being the parameter's name. Empty when the model does not + // store it. Destination string // EntitySteps is set on a database source reached over associations from a // context object: a Forms$ListViewXPathSource whose EntityRef is an diff --git a/mdl/executor/cmd_pages_describe_assoc_context_test.go b/mdl/executor/cmd_pages_describe_assoc_context_test.go index 9525f207b9..49c9df60d7 100644 --- a/mdl/executor/cmd_pages_describe_assoc_context_test.go +++ b/mdl/executor/cmd_pages_describe_assoc_context_test.go @@ -147,20 +147,22 @@ func TestDescribe_AssociationSourceContextIsTheEntityReached(t *testing.T) { // The pluggable Gallery / Data grid 2 / chart datasource reads through the same // parser (#941): an XPath source with no entity and the association hops in -// EntityRef. +// EntityRef. It is a database retrieve over the association, spelled as one so +// its XPath and sort survive (mendixlabs/mxcli#1599); the context is the entity +// reached either way. func TestParseDataSource_PluggableAssociationSourceContextIsTheEntityReached(t *testing.T) { ds := parseDataSource(map[string]any{ "$Type": "CustomWidgets$CustomWidgetXPathSource", "EntityRef": indirectRef(assocStep("Shop.Order_Line", "Shop.OrderLine")), }) - if ds == nil || ds.Type != "association" { - t.Fatalf("parseDataSource = %+v, want an association source", ds) + if ds == nil || ds.Type != "database" { + t.Fatalf("parseDataSource = %+v, want a database source over the association", ds) } if got := dataSourceEntityContext(nil, ds); got != "Shop.OrderLine" { t.Errorf("dataSourceEntityContext = %q, want Shop.OrderLine", got) } - if got := dataSourceExpr(nil, ds); got != "$currentObject/Shop.Order_Line" { - t.Errorf("dataSourceExpr = %q, want the unchanged $currentObject/Shop.Order_Line", got) + if got := dataSourceExpr(nil, ds); got != "database from $currentObject/Shop.Order_Line/Shop.OrderLine" { + t.Errorf("dataSourceExpr = %q, want database from $currentObject/Shop.Order_Line/Shop.OrderLine", got) } } diff --git a/mdl/executor/cmd_pages_describe_bykey.go b/mdl/executor/cmd_pages_describe_bykey.go index 8ee3aec952..4e47dd5c03 100644 --- a/mdl/executor/cmd_pages_describe_bykey.go +++ b/mdl/executor/cmd_pages_describe_bykey.go @@ -5,6 +5,9 @@ package executor import ( "sort" "strings" + "sync" + + mwidgets "github.com/mendixlabs/mxcli/modelsdk/widgets" ) // Settings addressed by the widget's own property key. @@ -140,6 +143,20 @@ type byKeyOptions struct { // AttrEntity returns the entity a bare attribute name of property sc // resolves against on re-execution; "" when there is none. AttrEntity func(sc widgetPropSchema) string + // WrittenDefault, when set, returns the primitive or expression value exec + // writes for property key left out, where that is not the schema's + // DefaultValue (templateWrittenDefaults). + WrittenDefault func(key string) (string, bool) +} + +// defaultFor is the value a stored primitive or expression is compared with. +func (o byKeyOptions) defaultFor(sc widgetPropSchema) string { + if o.WrittenDefault != nil { + if v, ok := o.WrittenDefault(sc.Key); ok { + return v + } + } + return sc.Default } // nonDefaultPropsByKey returns, in key order, the properties of a stored widget @@ -170,13 +187,13 @@ func nonDefaultPropsByKey(ctx *ExecContext, objProps []any, schemas map[string]w continue case "Boolean", "Integer", "Decimal", "String", "Enumeration": pv := extractString(value["PrimitiveValue"]) - if pv == "" || pv == sc.Default { + if pv == "" || pv == opts.defaultFor(sc) { continue } props = append(props, rawExplicitProp{Key: sc.Key, Value: pv, ValueType: sc.Type}) case "Expression": expr := extractString(value["Expression"]) - if expr == "" || expr == sc.Default { + if expr == "" || expr == opts.defaultFor(sc) { continue } props = append(props, expressionByKeyProp(ctx, sc.Key, expr)) @@ -352,6 +369,97 @@ var galleryByKeySkip = map[string]bool{ "desktopItems": true, "tabletItems": true, "phoneItems": true, "onClickTrigger": true, } +// sharedBranchByKeySkip are, per widget, the keys the combo box's and the data +// grid filters' readers already print under an MDL keyword (Attribute:, +// DataSource:, CaptionAttribute:, Attributes:, FilterType:, the expression +// caption) or that select the mode those keywords imply. Every other scalar +// setting is printed by its own key. +var sharedBranchByKeySkip = map[string]map[string]bool{ + "combobox": { + "source": true, "optionsSourceType": true, + "attributeEnumeration": true, "attributeBoolean": true, "attributeAssociation": true, + "optionsSourceAssociationCaptionType": true, "optionsSourceAssociationCaptionAttribute": true, + "optionsSourceAssociationCaptionExpression": true, + }, + "textfilter": {"attrChoice": true, "defaultFilter": true}, + "numberfilter": {"attrChoice": true, "defaultFilter": true}, + "datefilter": {"attrChoice": true, "defaultFilter": true}, + "dropdownfilter": { + "baseType": true, "attrChoice": true, "attr": true, "refEntity": true, "refCaption": true, + }, +} + +// comboBoxUndescribedModePrefixes are the keys of the combo box's database and +// static option sources. DESCRIBE spells only the association and enumeration +// modes, so these settings are named as not reproduced rather than printed: on +// their own, without the mode and its datasource, they would be written into a +// combo box in another mode — an attribute resolved against the wrong entity. +var comboBoxUndescribedModePrefixes = []string{ + "optionsSourceDatabase", "optionsSourceStatic", "staticDataSource", "staticAttribute", "databaseAttributeString", +} + +// withoutUndescribedComboBoxModes moves the settings of a combo box's +// undescribed option-source modes from props to omitted. +func withoutUndescribedComboBoxModes(props []rawExplicitProp, omitted []string) ([]rawExplicitProp, []string) { + kept := props[:0:0] + for _, p := range props { + undescribed := false + for _, prefix := range comboBoxUndescribedModePrefixes { + if strings.HasPrefix(p.Key, prefix) { + undescribed = true + break + } + } + if undescribed { + omitted = append(omitted, p.Key) + continue + } + kept = append(kept, p) + } + sort.Strings(omitted) + return kept, omitted +} + +var templateWrittenDefaultsCache sync.Map // widgetID → map[key]string + +// templateWrittenDefaults returns, per property key, the primitive or +// expression value the widget's embedded template stores — which is what exec +// writes for a property left out. It is not always the schema's DefaultValue: +// the combo box template stores `readOnlyStyle: bordered` against a default of +// `text`, so comparing with the default printed `readOnlyStyle: 'bordered'` on +// every combo box mxcli wrote, and left a Studio Pro one's `text` unprinted — +// which re-executed as `bordered`. Empty for a widget with no embedded template. +func templateWrittenDefaults(widgetID string) map[string]string { + if v, ok := templateWrittenDefaultsCache.Load(widgetID); ok { + return v.(map[string]string) + } + out := map[string]string{} + if tmpl, err := mwidgets.GetTemplate(widgetID); err == nil && tmpl != nil { + keys := map[string]string{} + for id, sc := range widgetPropertySchemas(map[string]any{"Type": tmpl.Type}) { + keys[id] = sc.Key + } + for _, prop := range getBsonArrayElements(tmpl.Object["Properties"]) { + propMap, ok := prop.(map[string]any) + if !ok { + continue + } + key := keys[extractBinaryID(propMap["TypePointer"])] + value, ok := propMap["Value"].(map[string]any) + if key == "" || !ok { + continue + } + if pv := extractString(value["PrimitiveValue"]); pv != "" { + out[key] = pv + } else if expr := extractString(value["Expression"]); expr != "" { + out[key] = expr + } + } + } + templateWrittenDefaultsCache.Store(widgetID, out) + return out +} + // extractDataGrid2ByKeyProps returns the grid-level settings of a Data grid 2 // that DESCRIBE has no keyword for and that differ from their defaults, and the // keys it could not print. enclosingEntity is the entity of the data container @@ -369,19 +477,35 @@ func extractGalleryByKeyProps(ctx *ExecContext, w map[string]any, enclosingEntit } func extractWidgetByKeyProps(ctx *ExecContext, w map[string]any, skip map[string]bool, enclosingEntity, ownEntity string) ([]rawExplicitProp, []string) { + return widgetByKeyProps(ctx, w, byKeyOptions{Skip: skip}, enclosingEntity, ownEntity) +} + +// extractSharedBranchByKeyProps is extractWidgetByKeyProps for a combo box or +// a data grid filter, compared with what exec writes for a property left out +// (templateWrittenDefaults) rather than with the schema's DefaultValue. +func extractSharedBranchByKeyProps(ctx *ExecContext, w map[string]any, skip map[string]bool, enclosingEntity, ownEntity string) ([]rawExplicitProp, []string) { + written := templateWrittenDefaults(extractCustomWidgetID(ctx, w)) + return widgetByKeyProps(ctx, w, byKeyOptions{ + Skip: skip, + WrittenDefault: func(key string) (string, bool) { + v, ok := written[key] + return v, ok + }, + }, enclosingEntity, ownEntity) +} + +func widgetByKeyProps(ctx *ExecContext, w map[string]any, opts byKeyOptions, enclosingEntity, ownEntity string) ([]rawExplicitProp, []string) { obj, ok := w["Object"].(map[string]any) if !ok { return nil, nil } - return nonDefaultPropsByKey(ctx, getBsonArrayElements(obj["Properties"]), widgetPropertySchemas(w), byKeyOptions{ - Skip: skip, - AttrEntity: func(sc widgetPropSchema) string { - if sc.DataSourceProperty != "" { - return ownEntity - } - return enclosingEntity - }, - }) + opts.AttrEntity = func(sc widgetPropSchema) string { + if sc.DataSourceProperty != "" { + return ownEntity + } + return enclosingEntity + } + return nonDefaultPropsByKey(ctx, getBsonArrayElements(obj["Properties"]), widgetPropertySchemas(w), opts) } // extractDataGrid2ColumnByKeyProps is extractDataGrid2ByKeyProps for one diff --git a/mdl/executor/cmd_pages_describe_datasource.go b/mdl/executor/cmd_pages_describe_datasource.go index 567d93030b..3a76362026 100644 --- a/mdl/executor/cmd_pages_describe_datasource.go +++ b/mdl/executor/cmd_pages_describe_datasource.go @@ -118,22 +118,23 @@ func parseDataSource(ds map[string]any) *rawDataSource { if got := parseEntitySource(ds); got != nil { return got } - // A List View's database source reached over an association from a - // context object. It is still a database retrieve — XPath, sort bar and - // search bar included — so it must not fall through to the association - // reading below, whose spelling executes as a Forms$AssociationSource - // and drops all three (ako/mxcli#721 L5). Only the List View source is - // known to mean this; the pluggable XPath source keeps the #941 reading. - if dsType == dsTypeListViewXPath { + // A database source reached over an association from a context object. + // It is still a database retrieve — XPath and sort bar included, and a + // List View's search bar — so it must not fall through to the + // association reading below, whose spelling executes as a + // Forms$AssociationSource and drops all three: a List View's + // (ako/mxcli#721 L5), and a Gallery's or Data grid 2's "Database" + // source, stored as a CustomWidgetXPathSource with the same + // IndirectEntityRef (mendixlabs/mxcli#1599). + if dsType == dsTypeListViewXPath || dsType == dsTypeCustomWidgetXPath { if got := parseEntityPathSource(ds); got != nil { return got } } - // An entity-backed source with no entity is really a context source: - // Studio Pro stores a pluggable list bound over an association or to a - // page parameter as an XPath source whose EntityRef is empty and whose - // navigation lives in Steps / SourceVariable / EntityPath. Reading only - // EntityRef produced `database from ` with an empty slot (#941). + // A context source: an XPath source whose navigation lives in + // SourceVariable / EntityPath, or whose hops lack a stored destination + // and so cannot be spelled as a database path. Reading only EntityRef + // produced `database from ` with an empty slot (#941). if got := parseContextSource(ds); got != nil { return got } @@ -325,6 +326,16 @@ func entityPathExpr(ds *rawDataSource) string { return strings.Join(parts, "/") } +// parameterSourceEntity is the entity a parameter source stores beside the +// parameter's name (a DirectEntityRef), "" when it stores none. +func parameterSourceEntity(ds map[string]any) string { + entityRef, ok := ds["EntityRef"].(map[string]any) + if !ok || extractString(entityRef["$Type"]) != "DomainModels$DirectEntityRef" { + return "" + } + return extractString(entityRef["Entity"]) +} + // parseContextSource reads the "data from context" forms: over an association, // or straight from a page or snippet parameter. func parseContextSource(ds map[string]any) *rawDataSource { @@ -333,13 +344,13 @@ func parseContextSource(ds map[string]any) *rawDataSource { } if sv, ok := ds["SourceVariable"].(map[string]any); ok && sv != nil { if param := extractString(sv["PageParameter"]); param != "" { - return &rawDataSource{Type: "parameter", Reference: param} + return &rawDataSource{Type: "parameter", Reference: param, Destination: parameterSourceEntity(ds)} } // Inside a snippet the parameter has its own slot, PageParameter left // empty; reading PageParameter alone described the data view with no // DataSource, and re-executing that dropped the binding. if param := extractString(sv["SnippetParameter"]); param != "" { - return &rawDataSource{Type: "parameter", Reference: param} + return &rawDataSource{Type: "parameter", Reference: param, Destination: parameterSourceEntity(ds)} } } if entityPath := extractString(ds["EntityPath"]); entityPath != "" { diff --git a/mdl/executor/cmd_pages_describe_flowcontext.go b/mdl/executor/cmd_pages_describe_flowcontext.go index 6ddd7a6ca3..46ae4b4db7 100644 --- a/mdl/executor/cmd_pages_describe_flowcontext.go +++ b/mdl/executor/cmd_pages_describe_flowcontext.go @@ -26,6 +26,12 @@ import ( // accounts for the direction travelled (owner to child or back, reference or // reference set) and for a specialization of the association's end; it is read // rather than re-derived. A source that stores none keeps the reference. +// +// For a page or snippet parameter the reference is the parameter's name +// (`Entry`), which no consumer can compare with an entity: a pluggable +// property bound to an attribute of another entity described back bare, and +// re-exec bound it to the parameter's entity — CE1613 (mendixlabs/mxcli#1591). +// The parameter's entity, stored beside the name, is used instead. func dataSourceEntityContext(ctx *ExecContext, ds *rawDataSource) string { if ds == nil || ds.Reference == "" { return "" @@ -35,7 +41,7 @@ func dataSourceEntityContext(ctx *ExecContext, ds *rawDataSource) string { if entity := flowReturnEntity(ctx, ds.Type, ds.Reference); entity != "" { return entity } - case "association": + case "association", "parameter": if ds.Destination != "" { return ds.Destination } diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index b6c58fcabd..828db1468f 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -884,7 +884,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { // twice (#721 C). props = appendAppearanceProps(ctx, props, w) formatWidgetProps(ctx.Output, prefix, header, props, "\n") - } else if (len(w.ExplicitProperties) > 0 || len(w.ObjectLists) > 0 || w.OnClick != "" || + } else if ((len(w.ExplicitProperties) > 0 && !isKnownCustomWidgetType(widgetType)) || len(w.ObjectLists) > 0 || w.OnClick != "" || (w.OnChange != "" && !isKnownCustomWidgetType(widgetType)) || len(w.NamedActions) > 0 || len(w.ChildSlots) > 0 || len(w.OmittedContainers) > 0) && w.WidgetID != "" { // Generic pluggable widget with explicit properties, object-list child @@ -1021,7 +1021,11 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if w.FilterExpression != "" && w.FilterExpression != "contains" { props = append(props, fmt.Sprintf("FilterType: %s", w.FilterExpression)) } + // The settings with no keyword, by the widget's own key + // (mendixlabs/mxcli#1586), and a note for those not reproduced. + props = appendExplicitProps(ctx, props, w.ExplicitProperties) props = appendAppearanceProps(ctx, props, w) + writeOmittedPropsNote(ctx.Output, prefix, w.OmittedProps) formatWidgetProps(ctx.Output, prefix, header, props, "\n") } @@ -1813,6 +1817,15 @@ func extractPageParameters(ctx *ExecContext, settings map[string]any) string { if value == "" { value = pageVariableArgValue(mappingMap["Variable"]) } + // A PageVariable naming only a widget is a control-bar button passing + // its grid's selection (mendixlabs/mxcli#122), written `$grid`. + if value == "" { + if v, ok := mappingMap["Variable"].(map[string]any); ok { + if w := extractString(v["Widget"]); w != "" { + value = "$" + w + } + } + } if value != "" { params = append(params, visitor.ParameterNameSpelling(paramName)+" = "+value) diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 0502004d44..4ef9c3b6c4 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -607,6 +607,22 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s } } } + // Every other setting of a combo box or a data grid filter, by the + // widget's own key. Their readers above know only the keys MDL has a + // keyword for, so a stored `customEditabilityExpression` or a filter's + // `defaultValue` expression — written by exec's explicit-property pass + // — described back as nothing, and a describe → exec cycle reset it + // (mendixlabs/mxcli#1586). + if skip, ok := sharedBranchByKeySkip[widget.RenderMode]; ok { + ownEntity := "" + if widget.DataSource != nil { + ownEntity = dataSourceEntityContext(ctx, widget.DataSource) + } + widget.ExplicitProperties, widget.OmittedProps = extractSharedBranchByKeyProps(ctx, w, skip, inheritedCtx, ownEntity) + if widget.RenderMode == "combobox" { + widget.ExplicitProperties, widget.OmittedProps = withoutUndescribedComboBoxModes(widget.ExplicitProperties, widget.OmittedProps) + } + } // For pluggable Image widget, extract image-specific properties if widget.RenderMode == "image" { extractImageProperties(ctx, w, &widget) @@ -614,7 +630,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s // For generic pluggable widgets (not handled by dedicated extractors above), // extract all non-default properties as explicit key-value pairs. if !isKnownCustomWidgetType(widget.RenderMode) { - widget.ExplicitProperties = extractExplicitProperties(ctx, w) + widget.ExplicitProperties = extractExplicitProperties(ctx, w, inheritedCtx) widget.ObjectLists = extractObjectLists(ctx, w) widget.ChildSlots = extractChildSlots(ctx, w, widget.EntityContext) widget.OmittedContainers = unreconstructedContainers(w, widget.ObjectLists, widget.ChildSlots) diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 21264a19e6..e788056184 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -1259,7 +1259,12 @@ func isKnownCustomWidgetType(widgetType string) bool { // extractExplicitProperties extracts non-default property values from a CustomWidget BSON. // Returns attribute references and primitive values for properties that differ from defaults. -func extractExplicitProperties(ctx *ExecContext, w map[string]any) []rawExplicitProp { +// +// enclosingEntity is the entity of the data container the widget sits in. An +// attribute property linked to no datasource binds to that object, so one of +// another entity keeps its qualified name: shortened, re-exec bound it to the +// container's entity (mendixlabs/mxcli#1591, CE1613). +func extractExplicitProperties(ctx *ExecContext, w map[string]any, enclosingEntity ...string) []rawExplicitProp { obj, ok := w["Object"].(map[string]any) if !ok { return nil @@ -1270,6 +1275,11 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any) []rawExplicit return nil } valueTypes := buildPropertyValueTypeMap(w) + schemas := widgetPropertySchemas(w) + enclosing := "" + if len(enclosingEntity) > 0 { + enclosing = enclosingEntity[0] + } var result []rawExplicitProp props := getBsonArrayElements(obj["Properties"]) @@ -1291,9 +1301,14 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any) []rawExplicit // Check for AttributeRef (attribute binding) if attrRef, ok := value["AttributeRef"].(map[string]any); ok && attrRef != nil { if attr := extractString(attrRef["Attribute"]); attr != "" { + value := describeAttr(ctx, attr) + if schemas[typePointerID].DataSourceProperty == "" && strings.Contains(enclosing, ".") && + !strings.EqualFold(attributeEntity(attr), enclosing) { + value = attr + } result = append(result, rawExplicitProp{ Key: propKey, - Value: describeAttr(ctx, attr), + Value: value, IsRef: true, }) continue diff --git a/mdl/executor/cmd_pages_describe_pluggable_attr_scope_1591_test.go b/mdl/executor/cmd_pages_describe_pluggable_attr_scope_1591_test.go new file mode 100644 index 0000000000..433edb161e --- /dev/null +++ b/mdl/executor/cmd_pages_describe_pluggable_attr_scope_1591_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" +) + +// mendixlabs/mxcli#1591: a Custom chart inside a data view over the page +// parameter $Entry (MyFirstModule.ChartEntry), its eventDataAttribute bound to +// an attribute of ANOTHER entity, MyFirstModule.ChartContext.EventData. DESCRIBE +// printed `eventDataAttribute: EventData`, and re-running that bound the +// property to ChartEntry.EventData — CE1613 "The selected attribute +// 'MyFirstModule.ChartEntry.EventData' no longer exists". +// +// Two halves: the generic pluggable extractor shortened every attribute +// unconditionally, and a data view over a page parameter put the parameter's +// NAME in scope rather than its entity, so nothing could have compared them. +func chartInParameterDataView(eventAttr string) map[string]any { + chart := map[string]any{ + "$Type": "CustomWidgets$CustomWidget", + "Name": "chart1", + "Type": map[string]any{ + "WidgetId": "com.mendix.widget.web.customchart.CustomChart", + "ObjectType": map[string]any{ + "PropertyTypes": []any{ + map[string]any{"$ID": "pt-data", "PropertyKey": "dataAttribute", + "ValueType": map[string]any{"Type": "Attribute"}}, + map[string]any{"$ID": "pt-event", "PropertyKey": "eventDataAttribute", + "ValueType": map[string]any{"Type": "Attribute"}}, + }, + }, + }, + "Object": map[string]any{ + "Properties": []any{ + map[string]any{"TypePointer": "pt-data", "Value": map[string]any{ + "AttributeRef": map[string]any{"Attribute": "MyFirstModule.ChartEntry.JsonData"}}}, + map[string]any{"TypePointer": "pt-event", "Value": map[string]any{ + "AttributeRef": map[string]any{"Attribute": eventAttr}}}, + }, + }, + } + return map[string]any{ + "$Type": "Forms$DataView", + "Name": "dvEntry", + "DataSource": map[string]any{ + "$Type": "Forms$DataViewSource", + "EntityRef": map[string]any{ + "$Type": "DomainModels$DirectEntityRef", + "Entity": "MyFirstModule.ChartEntry", + }, + "SourceVariable": map[string]any{ + "$Type": "Forms$PageVariable", + "PageParameter": "Entry", + }, + }, + "Widgets": []any{chart}, + } +} + +func describedChartProps(t *testing.T, eventAttr string) map[string]string { + t.Helper() + ctx, _ := newMockCtx(t) + dvs := parseRawWidget(ctx, chartInParameterDataView(eventAttr)) + if len(dvs) != 1 || len(dvs[0].Children) != 1 { + t.Fatalf("want one data view with one chart, got %+v", dvs) + } + got := map[string]string{} + for _, p := range dvs[0].Children[0].ExplicitProperties { + got[p.Key] = p.Value + } + return got +} + +func TestDescribePluggableAttributeOfAnotherEntityStaysQualified(t *testing.T) { + got := describedChartProps(t, "MyFirstModule.ChartContext.EventData") + if got["eventDataAttribute"] != "MyFirstModule.ChartContext.EventData" { + t.Errorf("eventDataAttribute = %q, want the qualified MyFirstModule.ChartContext.EventData "+ + "(bare, re-exec binds it to ChartEntry: CE1613)", got["eventDataAttribute"]) + } + // The control: an attribute of the data view's own entity stays bare, as + // the rest of describe writes one. + if got["dataAttribute"] != "JsonData" { + t.Errorf("dataAttribute = %q, want the bare JsonData", got["dataAttribute"]) + } +} + +func TestDescribeParameterDataViewPutsTheEntityInScope(t *testing.T) { + ctx, _ := newMockCtx(t) + dvs := parseRawWidget(ctx, chartInParameterDataView("MyFirstModule.ChartEntry.JsonLayout")) + if len(dvs) != 1 || dvs[0].EntityContext != "MyFirstModule.ChartEntry" { + t.Fatalf("data view context = %q, want the parameter's entity MyFirstModule.ChartEntry", dvs[0].EntityContext) + } + if got := describedChartProps(t, "MyFirstModule.ChartEntry.JsonLayout"); got["eventDataAttribute"] != "JsonLayout" { + t.Errorf("eventDataAttribute = %q, want the bare JsonLayout", got["eventDataAttribute"]) + } +} diff --git a/mdl/executor/cmd_pages_describe_shared_bykey_test.go b/mdl/executor/cmd_pages_describe_shared_bykey_test.go new file mode 100644 index 0000000000..a88e7719ef --- /dev/null +++ b/mdl/executor/cmd_pages_describe_shared_bykey_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/modelsdk/widgets" +) + +// widgetFromTemplate is an untouched stored widget: the shipped template's +// schema and the values exec writes for a property left out. +func widgetFromTemplate(t *testing.T, widgetID, name string) map[string]any { + t.Helper() + tmpl, err := widgets.GetTemplate(widgetID) + if err != nil || tmpl == nil { + t.Fatalf("%s template: %v, %v", widgetID, tmpl, err) + } + raw, err := json.Marshal(map[string]any{"Type": tmpl.Type, "Object": tmpl.Object}) + if err != nil { + t.Fatal(err) + } + var w map[string]any + if err := json.Unmarshal(raw, &w); err != nil { + t.Fatal(err) + } + normalizeTemplateJSON(w) + w["$Type"] = "CustomWidgets$CustomWidget" + w["Name"] = name + return w +} + +const comboBoxWidgetID = "com.mendix.widget.web.combobox.Combobox" + +// mendixlabs/mxcli#1586: a combo box's expression-typed settings are written by +// exec's explicit-property pass and were never described — the combo box has +// its own reader, which knew only the keys MDL has keywords for. A describe → +// exec cycle reset `customEditabilityExpression` to `false`. +func TestDescribeComboBox_PrintsExpressionSettingsByKey(t *testing.T) { + w := widgetFromTemplate(t, comboBoxWidgetID, "cb") + gridValue(t, w, "customEditability")["PrimitiveValue"] = "conditionally" + gridValue(t, w, "customEditabilityExpression")["Expression"] = "$currentObject/Locked" + gridValue(t, w, "ariaRequired")["Expression"] = "true" + + got := describeParsed(t, w) + for _, want := range []string{ + "customEditability: 'conditionally'", + "customEditabilityExpression: $currentObject/Locked", + "ariaRequired: true", + } { + if !strings.Contains(got, want) { + t.Errorf("describe lacks %q:\n%s", want, got) + } + } +} + +// Control: an untouched combo box prints none of them. The template stores +// `readOnlyStyle: bordered` against a schema default of `text`, and exec writes +// the template's value for a property left out — so that is the value compared +// with, or every combo box mxcli wrote would print it. +func TestDescribeComboBox_UntouchedPrintsNothingByKey(t *testing.T) { + w := widgetFromTemplate(t, comboBoxWidgetID, "cb") + got := describeParsed(t, w) + if strings.Contains(got, "readOnlyStyle") || strings.Contains(got, "NOT re-executable") { + t.Errorf("untouched combo box printed settings:\n%s", got) + } + // The schema default, which Studio Pro stores, differs from what exec + // writes, so it is printed. + gridValue(t, w, "readOnlyStyle")["PrimitiveValue"] = "text" + if got := describeParsed(t, w); !strings.Contains(got, "readOnlyStyle: 'text'") { + t.Errorf("a stored readOnlyStyle text was not printed:\n%s", got) + } +} + +// A combo box in a mode DESCRIBE does not spell has its settings named, not +// printed into a combo box of another mode. +func TestDescribeComboBox_UndescribedModeIsNamedNotPrinted(t *testing.T) { + w := widgetFromTemplate(t, comboBoxWidgetID, "cb") + gridValue(t, w, "optionsSourceDatabaseCaptionType")["PrimitiveValue"] = "expression" + got := describeParsed(t, w) + if strings.Contains(got, "optionsSourceDatabaseCaptionType:") { + t.Errorf("database-mode setting printed:\n%s", got) + } + if !strings.Contains(got, "-- NOT re-executable: optionsSourceDatabaseCaptionType") { + t.Errorf("database-mode setting not named:\n%s", got) + } +} + +// The data grid filters share the combo box's branch and had the same loss: a +// text filter's `defaultValue` expression and its `delay`. +func TestDescribeTextFilter_PrintsSettingsByKey(t *testing.T) { + w := widgetFromTemplate(t, "com.mendix.widget.web.datagridtextfilter.DatagridTextFilter", "tf") + gridValue(t, w, "defaultValue")["Expression"] = "'abc'" + gridValue(t, w, "delay")["PrimitiveValue"] = "300" + got := describeParsed(t, w) + for _, want := range []string{"defaultValue: '''abc'''", "delay: 300"} { + if !strings.Contains(got, want) { + t.Errorf("describe lacks %q:\n%s", want, got) + } + } +} diff --git a/mdl/executor/cmd_pages_showpage_args.go b/mdl/executor/cmd_pages_showpage_args.go index 6860d8f040..9ada8c67b5 100644 --- a/mdl/executor/cmd_pages_showpage_args.go +++ b/mdl/executor/cmd_pages_showpage_args.go @@ -55,6 +55,26 @@ type pageArgContext struct { // argument naming one is bound by name and needs no context object // (mendixlabs/mxcli#1403); the executor asks classifyFlowArgValue instead. docParams *documentParams + // dataWidget is the name of the data widget whose context this is, so a + // control bar below it knows whose selection it holds. + dataWidget string + // selectionOf is set inside a control bar: the name of its grid, whose + // selection `$` passes to a page as an explicit mapping naming the + // grid (mendixlabs/mxcli#122). The row object is not the control bar's. + selectionOf string +} + +// inDataWidget names the data widget whose context c is. +func (c pageArgContext) inDataWidget(name string) pageArgContext { + c.dataWidget = name + return c +} + +// namesSelection reports whether value is `$` written in that grid's +// control bar: the grid's selection, bound by name like a page parameter. +func (c pageArgContext) namesSelection(value string) bool { + name, ok := strings.CutPrefix(value, "$") + return ok && c.selectionOf != "" && strings.EqualFold(name, c.selectionOf) } // enteringDataWidget is the context below a data-bound widget bound to ds and @@ -106,10 +126,15 @@ func argContextForChildren(w *ast.WidgetV3, parent pageArgContext) pageArgContex if ds := w.GetDataSource(); ds != nil { // The entity is only used to word the refusal; the executor's builder // overwrites this with one that carries it. - c := enteringDataWidget(ds, "") + c := enteringDataWidget(ds, "").inDataWidget(w.Name) c.docParams = parent.docParams return c } + if strings.EqualFold(w.Type, "controlbar") && parent.dataWidget != "" { + c := parent + c.selectionOf = parent.dataWidget + return c + } if parent.known && !parent.present && !contextFreeContainers[strings.ToLower(w.Type)] { return pageArgContext{} } @@ -135,7 +160,7 @@ func argContextForOwnAction(w *ast.WidgetV3, parent pageArgContext) pageArgConte if ds := w.GetDataSource(); ds != nil { // The entity is only used to word a refusal; the executor's builder // overwrites this with one that carries it. - c := enteringDataWidget(ds, "") + c := enteringDataWidget(ds, "").inDataWidget(w.Name) c.docParams = parent.docParams return c } @@ -252,6 +277,12 @@ func refuseShowPageArgument(widgetName, target, argName, argValue string, c page "parameter of this page or snippet (bound by name), or call a microflow that shows the page with it", target, argName, argValue, where, argName) } + if c.selectionOf != "" { + return fmt.Sprintf( + "show page %s: argument %s: %s cannot be stored — in the control bar of `%s` a page argument is the "+ + "grid's selection, $%s, or a parameter of this page or snippet; %s names neither", + target, argName, argValue, c.selectionOf, c.selectionOf, argValue) + } return fmt.Sprintf( "show page %s: argument %s: %s cannot be stored — a widget's page argument is always the enclosing context "+ "object, which mxcli records by leaving the mapping empty (an explicit one is rejected as CE0115). "+ @@ -262,15 +293,21 @@ func refuseShowPageArgument(widgetName, target, argName, argValue string, c page // namedParameterArgument reports whether a show page argument is a page or // snippet parameter of the document being built, which Mendix binds by name -// rather than from a context object (mendixlabs/mxcli#1403). It returns the -// variable and its PageVariable kind, or "" when the argument is anything else +// rather than from a context object (mendixlabs/mxcli#1403) — or, in a grid's +// control bar, the grid's selection, kind "widget" (mendixlabs/mxcli#122). It +// returns the variable and its PageVariable kind, or "" when the argument is +// anything else // — including a page variable, which is never an object and so never a page // argument, and any project older than 9.7, whose page settings have no // ParameterMappings to hold the binding. func (pb *pageBuilder) namedParameterArgument(value string) (variable, kind string) { variable, kind = pb.classifyFlowArgValue(value) if kind != "parameter" && kind != "snippet" { - return "", "" + if pb.argCtx.namesSelection(value) { + variable, kind = value, "widget" + } else { + return "", "" + } } if pb.backend != nil { if pv := pb.backend.ProjectVersion(); pv != nil && pv.MajorVersion > 0 && !pv.IsAtLeast(9, 7) { @@ -324,7 +361,7 @@ func validateShowPageArguments(w *ast.WidgetV3, c pageArgContext, locationPrefix var out []linter.Violation for _, arg := range action.Args { strVal, ok := arg.Value.(string) - if !ok || c.binds(strVal) || c.namesDocumentParameter(strVal) { + if !ok || c.binds(strVal) || c.namesDocumentParameter(strVal) || c.namesSelection(strVal) { continue } out = append(out, linter.Violation{ diff --git a/mdl/executor/cmd_pages_showpage_selection_arg_122_test.go b/mdl/executor/cmd_pages_showpage_selection_arg_122_test.go new file mode 100644 index 0000000000..e46c9b95e8 --- /dev/null +++ b/mdl/executor/cmd_pages_showpage_selection_arg_122_test.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#122: a control-bar button opening a page with the grid's +// selection had no spelling. `$currentObject` is unbound there (MDL-BUTTON01, +// CE1571), no argument is CE1571, and `$dgOrders` — what MDL-BUTTON01's own hint +// suggests — was refused as MDL-PAGEARG01 "a widget's page argument is always +// the enclosing context object". Studio Pro binds it as an explicit mapping +// whose PageVariable names the grid in its Widget slot; mxbuild 11.12.5 builds +// that at 0 errors. + +func controlBarPage(t *testing.T, arg string) []*ast.WidgetV3 { + t.Helper() + src := `create page Mod.Overview (title: 'O', layout: Atlas_Core.Atlas_Default) { + datagrid dgOrders (datasource: database from Mod.Order, selection: single) { + controlbar cb { + actionbutton btnView (caption: 'View', action: show page Mod.Detail(OrderParam = ` + arg + `)) + } + column colNum (attribute: OrderNumber, caption: 'Order') + } +}` + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + s, ok := prog.Statements[0].(*ast.CreatePageStmtV3) + if !ok { + t.Fatalf("got %T", prog.Statements[0]) + } + return s.Widgets +} + +func pageArgViolations(t *testing.T, tree []*ast.WidgetV3) []string { + t.Helper() + registry := LoadWidgetRegistry("") + if registry == nil { + t.Fatal("LoadWidgetRegistry returned nil") + } + var out []string + for _, v := range validateDocumentWidgetTree(tree, registry, "page Mod.Overview", nil) { + if v.RuleID == "MDL-PAGEARG01" { + out = append(out, v.Message) + } + } + return out +} + +func TestValidateShowPageArguments_ControlBarPassesGridSelection(t *testing.T) { + if got := pageArgViolations(t, controlBarPage(t, "$dgOrders")); len(got) != 0 { + t.Errorf("control-bar show page passing its grid's selection refused: %q", got) + } + if got := pageArgViolations(t, controlBarPage(t, "$DGORDERS")); len(got) != 0 { + t.Errorf("widget names are case-insensitive: %q", got) + } + // Control: another widget's name is still a variable that binds nothing. + got := pageArgViolations(t, controlBarPage(t, "$dgOther")) + if len(got) != 1 { + t.Fatalf("control-bar show page passing $dgOther: %d MDL-PAGEARG01, want 1", len(got)) + } + // And the refusal in a control bar names the spelling that works. + if !strings.Contains(got[0], "$dgOrders") { + t.Errorf("refusal should offer the grid's selection $dgOrders, got: %s", got[0]) + } +} + +// The selection is the control bar's, not the row's: a column button passing +// its own grid's name is not the measured shape and stays refused. +func TestValidateShowPageArguments_GridSelectionOnlyInItsControlBar(t *testing.T) { + src := `create page Mod.Overview (title: 'O', layout: Atlas_Core.Atlas_Default) { + datagrid dgOrders (datasource: database from Mod.Order, selection: single) { + column colNum (attribute: OrderNumber, caption: 'Order') { + actionbutton btnView (caption: 'View', action: show page Mod.Detail(OrderParam = $dgOrders)) + } + } +}` + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + s := prog.Statements[0].(*ast.CreatePageStmtV3) + if got := pageArgViolations(t, s.Widgets); len(got) != 1 { + t.Errorf("row-scoped button passing $dgOrders: %d MDL-PAGEARG01, want 1", len(got)) + } +} + +// The exec half: the builder writes the selection as a "widget" mapping. +func TestBuildShowPage_ControlBarSelectionIsAWidgetMapping(t *testing.T) { + build := func(ctx pageArgContext, arg string) (*pages.PageClientAction, error) { + pb := &pageBuilder{ + execCache: &executorCache{createdPages: map[string]*createdPageInfo{"Mod.Detail": {ID: "detail-id"}}}, + argCtx: ctx, + } + built, err := pb.buildClientActionV3(showPageButton(arg).GetAction()) + if err != nil { + return nil, err + } + return built.(*pages.PageClientAction), nil + } + grid := enteringDataWidget(&ast.DataSourceV3{Type: "database", Reference: "Mod.Order"}, "Mod.Order").inDataWidget("dgOrders") + bar := argContextForChildren(&ast.WidgetV3{Type: "controlbar", Name: "cb"}, grid) + + act, err := build(bar, "$dgOrders") + if err != nil { + t.Fatalf("control-bar show page passing $dgOrders refused: %v", err) + } + if len(act.ParameterMappings) != 1 { + t.Fatalf("mappings = %d, want 1", len(act.ParameterMappings)) + } + if m := act.ParameterMappings[0]; m.Variable != "$dgOrders" || m.VariableKind != "widget" { + t.Errorf("mapping = %+v, want Car <- $dgOrders as widget", m) + } + + // Controls: outside the control bar, and another name inside it. + if _, err := build(grid, "$dgOrders"); err == nil { + t.Error("$dgOrders from a grid row was accepted; only the control bar has the selection") + } + if _, err := build(bar, "$dgOther"); err == nil { + t.Error("$dgOther in dgOrders' control bar was accepted; it binds nothing") + } +} + +// The read half: DESCRIBE prints the selection mapping back as `$dgOrders`. It +// read only the PageParameter / SnippetParameter / LocalVariable slots, so the +// mapping described as absent. +func TestDescribeShowPage_SelectionMapping(t *testing.T) { + ctx, _ := newMockCtx(t) + settings := map[string]any{ + "ParameterMappings": []any{int32(2), map[string]any{ + "$Type": "Forms$PageParameterMapping", + "Parameter": "Mod.Detail.OrderParam", + "Argument": "", + "Variable": map[string]any{ + "$Type": "Forms$PageVariable", "Widget": "dgOrders", + "PageParameter": "", "SnippetParameter": "", "LocalVariable": "", + }, + }}, + } + if got := extractPageParameters(ctx, settings); got != "OrderParam = $dgOrders" { + t.Errorf("extractPageParameters = %q, want %q", got, "OrderParam = $dgOrders") + } +} + +// Through the engine: a Data grid 2's control bar is a widgets slot whose +// children the engine builds directly, never through the `controlbar` node, so +// the selection has to be put in scope there. The builder test above passed +// while exec still refused `$dgOrders` for exactly that reason. +type slotRecorder struct { + backend.WidgetObjectBuilder + children map[string][]pages.Widget +} + +func (r *slotRecorder) SetChildWidgets(key string, ws []pages.Widget) { r.children[key] = ws } + +func TestApplyChildSlots_ControlBarHoldsTheGridSelection(t *testing.T) { + def := datagridLikeDef() + build := func(arg string) (*slotRecorder, error) { + pb := &pageBuilder{ + execCache: &executorCache{createdPages: map[string]*createdPageInfo{"Mod.Detail": {ID: "detail-id"}}}, + widgetScope: map[string]model.ID{}, + // The grid's own context, as the engine's datasource pass sets it. + argCtx: enteringDataWidget(&ast.DataSourceV3{Type: "database", Reference: "Mod.Order"}, "Mod.Order").inDataWidget("dgOrders"), + } + e := &PluggableWidgetEngine{pageBuilder: pb} + rec := &slotRecorder{children: map[string][]pages.Widget{}} + grid := controlBarPage(t, arg)[0] + var bar []*ast.WidgetV3 + for _, c := range grid.Children { + if strings.EqualFold(c.Type, "controlbar") { + bar = append(bar, c) + } + } + grid.Children = bar + return rec, e.applyChildSlots(rec, def.ChildSlots, grid, nil, nil) + } + rec, err := build("$dgOrders") + if err != nil { + t.Fatalf("control-bar show page passing $dgOrders refused by exec: %v", err) + } + if len(rec.children["filtersPlaceholder"]) != 1 { + t.Fatalf("control bar slot = %v, want the one button", rec.children) + } + if _, err := build("$dgOther"); err == nil || !strings.Contains(err.Error(), "MDL-PAGEARG01") { + t.Errorf("control-bar show page passing $dgOther: err = %v, want MDL-PAGEARG01", err) + } +} diff --git a/mdl/executor/cmd_published_rest.go b/mdl/executor/cmd_published_rest.go index 160c854eca..2538339632 100644 --- a/mdl/executor/cmd_published_rest.go +++ b/mdl/executor/cmd_published_rest.go @@ -105,6 +105,7 @@ func describePublishedRestService(ctx *ExecContext, name ast.QualifiedName) erro if folderPath := h.BuildFolderPath(svc.ContainerID); folderPath != "" { folder = " folder " + mdlQuote(ctx, folderPath) } + writeDocumentDocComment(ctx.Output, svc.Documentation) // mendixlabs/mxcli#1510 fmt.Fprintf(ctx.Output, "create or modify published rest service %s%s (\n", qualifiedName, folder) fmt.Fprintf(ctx.Output, " Path: %s", mdlQuoted(svc.Path)) if svc.Version != "" { @@ -577,8 +578,12 @@ func execCreatePublishedRestService(ctx *ExecContext, s *ast.CreatePublishedRest Path: s.Path, Version: s.Version, ServiceName: s.ServiceName, + // The doc comment (mendixlabs/mxcli#1510). + Documentation: s.Documentation, } if existing != nil { + // A rewrite that carried no doc comment keeps the stored one (#1018). + svc.Documentation = carriedDocumentation(s.DocumentationSet, s.Documentation, existing.Documentation) svc.ID = existing.ID svc.AllowedRoles = existing.AllowedRoles // Excluded is model state, not script state (#914). diff --git a/mdl/executor/cmd_search_exclude_marketplace_test.go b/mdl/executor/cmd_search_exclude_marketplace_test.go new file mode 100644 index 0000000000..5e12460b10 --- /dev/null +++ b/mdl/executor/cmd_search_exclude_marketplace_test.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" +) + +// moduleSeed is one row of the modules table: which snapshot it belongs to, the +// module, and its Source (empty unless the module came from the Marketplace). +type moduleSeed struct{ snapshot, name, source string } + +// seedSearchCatalog writes snapshots (oldest first, so the last one is the +// current snapshot), the given module rows and a documentation hit in each of +// Sales, Vendor and the module rows themselves, as the catalog builder writes +// them: a module's own documentation carries the module's name in ModuleName. +func seedSearchCatalog(t *testing.T, snapshots []string, modules []moduleSeed) func(catalog.CatalogDB) { + return func(db catalog.CatalogDB) { + t.Helper() + var stmts []string + for _, s := range snapshots { + stmts = append(stmts, `INSERT INTO snapshots (SnapshotId, ProjectId) VALUES ('`+s+`', 'p')`) + } + for i, m := range modules { + stmts = append(stmts, `INSERT INTO modules_data (Id, Name, QualifiedName, Source, SnapshotId, ProjectId) + VALUES ('m`+string(rune('a'+i))+`', '`+m.name+`', '`+m.name+`', '`+m.source+`', '`+m.snapshot+`', 'p')`) + } + stmts = append(stmts, + `INSERT INTO strings (QualifiedName, ObjectType, StringValue, StringContext, ModuleName) + VALUES ('Sales.Limit', 'CONSTANT', 'Upper bound for the quuxwidget', 'documentation', 'Sales')`, + `INSERT INTO strings (QualifiedName, ObjectType, StringValue, StringContext, ModuleName) + VALUES ('Vendor.Limit', 'CONSTANT', 'Upper bound for the quuxwidget', 'documentation', 'Vendor')`, + // A module's own documentation: the objects view holds '' as the + // module's ModuleName, and the builder stores the module's Name instead. + `INSERT INTO strings (QualifiedName, ObjectType, StringValue, StringContext, ModuleName) + VALUES ('Vendor', 'MODULE', 'Vendor notes on the quuxwidget', 'documentation', 'Vendor')`, + `INSERT INTO strings (QualifiedName, ObjectType, StringValue, StringContext, ModuleName) + VALUES ('Vendor', 'MODULE', 'Vendor domain notes on the quuxwidget', 'domain_model_documentation', 'Vendor')`, + ) + for _, q := range stmts { + if _, err := db.Exec(q); err != nil { + t.Fatalf("seed: %v\n%s", err, q) + } + } + } +} + +// searchHits runs a JSON search and returns "Qualified.Name/context" per hit. +func searchHits(t *testing.T, seed func(catalog.CatalogDB), opts SearchOptions) []string { + t.Helper() + // One fixture per search: a second search on the same context made the + // TempDir cleanup fail on Windows (catalog.db still in use). + ctx, out, _ := searchCacheFixtureSeeded(t, "source", nil, seed) + if _, err := searchWith(ctx, "quuxwidget", "json", opts); err != nil { + t.Fatal(err) + } + var rows []struct { + QualifiedName string `json:"qualifiedName"` + StringContext string `json:"stringContext"` + } + if err := json.Unmarshal(out.Bytes(), &rows); err != nil { + t.Fatalf("not JSON: %v\n%s", err, out.String()) + } + var hits []string + for _, r := range rows { + hits = append(hits, r.QualifiedName+"/"+r.StringContext) + } + return hits +} + +func sameSet(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("hits = %v, want %v", got, want) + } + seen := map[string]bool{} + for _, g := range got { + seen[g] = true + } + for _, w := range want { + if !seen[w] { + t.Fatalf("hits = %v, want %v", got, want) + } + } +} + +var sales = moduleSeed{"s1", "Sales", ""} +var vendor = moduleSeed{"s1", "Vendor", "Marketplace v1.0.0"} + +// The default keeps every module, as it always has; the option drops a +// Marketplace module's hits and nothing else - the module's own documentation +// and domain model documentation included, which a filter on ModuleName missed +// while the builder stored an empty one for a module. +func TestSearchWith_ExcludeMarketplace(t *testing.T) { + seed := seedSearchCatalog(t, []string{"s1"}, []moduleSeed{sales, vendor}) + + sameSet(t, searchHits(t, seed, SearchOptions{}), []string{ + "Sales.Limit/documentation", "Vendor.Limit/documentation", + "Vendor/documentation", "Vendor/domain_model_documentation", + }) + sameSet(t, searchHits(t, seed, SearchOptions{ExcludeMarketplace: true}), []string{ + "Sales.Limit/documentation", + }) +} + +// The same option reaches the table and names formats, not only JSON. +func TestSearchWith_ExcludeMarketplace_AllFormats(t *testing.T) { + seed := seedSearchCatalog(t, []string{"s1"}, []moduleSeed{sales, vendor}) + for _, format := range []string{"table", "names"} { + t.Run(format, func(t *testing.T) { + ctx, out, _ := searchCacheFixtureSeeded(t, "source", nil, seed) + if _, err := searchWith(ctx, "quuxwidget", format, SearchOptions{ExcludeMarketplace: true}); err != nil { + t.Fatal(err) + } + got := out.String() + if !strings.Contains(got, "Sales.Limit") || strings.Contains(got, "Vendor") { + t.Errorf("want Sales.Limit only, got:\n%s", got) + } + }) + } +} + +// The catalog can hold more than one snapshot. Only the current one (the latest +// recorded) says which modules are Marketplace modules: a module that was one in +// an older snapshot is the project's own now, and the other way round. +func TestSearchWith_ExcludeMarketplace_UsesTheCurrentSnapshot(t *testing.T) { + // Vendor came from the Marketplace in s1 and is the project's own in s2. + seed := seedSearchCatalog(t, []string{"s1", "s2"}, []moduleSeed{ + sales, vendor, + {"s2", "Sales", ""}, {"s2", "Vendor", ""}, + }) + sameSet(t, searchHits(t, seed, SearchOptions{ExcludeMarketplace: true}), []string{ + "Sales.Limit/documentation", "Vendor.Limit/documentation", + "Vendor/documentation", "Vendor/domain_model_documentation", + }) + + // Vendor is a Marketplace module in the current snapshot only. + seed = seedSearchCatalog(t, []string{"s1", "s2"}, []moduleSeed{ + sales, {"s1", "Vendor", ""}, + {"s2", "Sales", ""}, {"s2", "Vendor", "Marketplace v2.0.0"}, + }) + sameSet(t, searchHits(t, seed, SearchOptions{ExcludeMarketplace: true}), []string{ + "Sales.Limit/documentation", + }) +} diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index bf59a5d714..290709bdc3 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -650,6 +650,9 @@ func buildCallWorkflowActivity(n *ast.WorkflowCallWorkflowNode) *workflows.CallW act.ParameterMappings = append(act.ParameterMappings, mapping) } + // BoundaryEvents (mendixlabs/mxcli#1532) + act.BoundaryEvents = buildBoundaryEvents(n.BoundaryEvents) + return act } diff --git a/mdl/executor/documentation_carry.go b/mdl/executor/documentation_carry.go index 976fa1d69c..1094ebab4e 100644 --- a/mdl/executor/documentation_carry.go +++ b/mdl/executor/documentation_carry.go @@ -2,7 +2,11 @@ package executor -import "strings" +import ( + "fmt" + "io" + "strings" +) // carriedDocumentation returns the documentation a rewrite should store. // @@ -54,3 +58,27 @@ func docCommentNormalForm(s string) string { } return strings.Join(kept, "\n") } + +// writeDocumentDocComment prints a document's documentation as the `/** … */` +// doc comment its create reads (mendixlabs/mxcli#1510). A text holding `*/` +// would end the comment early and the rest would not parse, so it is shown as +// `--` lines instead: the statement then states none, and executing it keeps +// the stored text (#1018) rather than truncating it. +func writeDocumentDocComment(w io.Writer, doc string) { + if doc == "" { + return + } + doc = strings.ReplaceAll(doc, "\r\n", "\n") + if strings.Contains(doc, "*/") { + fmt.Fprintln(w, "-- Documentation (kept on execute; a doc comment cannot hold `*/`):") + for line := range strings.SplitSeq(doc, "\n") { + fmt.Fprintf(w, "-- %s\n", line) + } + return + } + fmt.Fprintln(w, "/**") + for line := range strings.SplitSeq(doc, "\n") { + fmt.Fprintln(w, strings.TrimRight(" * "+line, " ")) + } + fmt.Fprintln(w, " */") +} diff --git a/mdl/executor/documentation_coverage_test.go b/mdl/executor/documentation_coverage_test.go index e0ff3ecdec..8d1b1aa383 100644 --- a/mdl/executor/documentation_coverage_test.go +++ b/mdl/executor/documentation_coverage_test.go @@ -56,6 +56,12 @@ var docCarryDone = map[string]bool{ "CreateConsumedMCPServiceStmt": true, "CreateAgentStmt": true, "CreateModuleStmt": true, + // mendixlabs/mxcli#1510: covered by TestIntegrationDocumentation_Issue1510 + // (unit, on PedApp) as well as TestDocumentation_SurvivesRewrite. + "CreateImportMappingStmt": true, + "CreateExportMappingStmt": true, + "CreatePublishedRestServiceStmt": true, + "CreateDataTransformerStmt": true, } // docCarryPending is empty: every statement type in scope is carried AND diff --git a/mdl/executor/documentation_preserved_test.go b/mdl/executor/documentation_preserved_test.go index f1e7193fd8..120821e45d 100644 --- a/mdl/executor/documentation_preserved_test.go +++ b/mdl/executor/documentation_preserved_test.go @@ -274,6 +274,36 @@ func docPreserveCases() []docPreserveCase { rewrite: "create or replace enumeration TestModule.DocEnum ( A 'A', B 'B', C 'C' );", describe: "describe enumeration TestModule.DocEnum", }, + { + name: "import mapping", + create: "create entity TestModule.DocImEnt ( Code: String );\n" + + "create json structure TestModule.DocImJs sample '{\"code\": \"x\"}';\n" + doc + + "create import mapping TestModule.DocIm\n with json structure TestModule.DocImJs\n{\n create TestModule.DocImEnt {\n Code = code\n }\n};", + rewrite: "create or modify import mapping TestModule.DocIm\n with json structure TestModule.DocImJs\n{\n find TestModule.DocImEnt or create {\n Code = code key\n }\n};", + describe: "describe import mapping TestModule.DocIm", + }, + { + name: "export mapping", + create: "create entity TestModule.DocEmEnt ( Code: String );\n" + + "create json structure TestModule.DocEmJs sample '{\"code\": \"x\"}';\n" + doc + + "create export mapping TestModule.DocEm\n with json structure TestModule.DocEmJs\n{\n TestModule.DocEmEnt {\n code = Code\n }\n};", + rewrite: "create or modify export mapping TestModule.DocEm\n with json structure TestModule.DocEmJs\n null values SendAsNil\n{\n TestModule.DocEmEnt {\n code = Code\n }\n};", + describe: "describe export mapping TestModule.DocEm", + }, + { + name: "published rest service", + create: doc + "create published rest service MyFirstModule.DocPrs (\n Path: 'rest/doc/v1',\n Version: '1.0.0'\n)\n{\n};", + rewrite: "create or modify published rest service MyFirstModule.DocPrs (\n Path: 'rest/doc/v2',\n Version: '2.0.0'\n)\n{\n};", + describe: "describe published rest service MyFirstModule.DocPrs", + }, + { + name: "data transformer", + minMajor: 11, + minMinor: 9, + create: doc + "create data transformer MyFirstModule.DocDt\n source json '{\"a\": 1}'\n{\n jslt '{\"b\": .a}';\n};", + rewrite: "create or modify data transformer MyFirstModule.DocDt\n source json '{\"a\": 2}'\n{\n jslt '{\"c\": .a}';\n};", + describe: "describe data transformer MyFirstModule.DocDt", + }, { // The module's documentation is its domain model's // (mendixlabs/mxcli#1314). diff --git a/mdl/executor/domain_export_level.go b/mdl/executor/domain_export_level.go new file mode 100644 index 0000000000..c4636e9564 --- /dev/null +++ b/mdl/executor/domain_export_level.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +// The EXPORT LEVEL clause on an entity, attribute, association and enumeration +// (mendixlabs/mxcli#1498). It is the microflow clause and means what it means +// there: whether the element is usable outside a Protected (add-on) module. In an +// ordinary module mxbuild ignores it, as it does a microflow's. + +// authoredExportLevel is the level a clause stated, or "" when the statement had +// none — which every writer reads as "keep what is stored" (Hidden on a new +// element). +func authoredExportLevel(p *string) string { + if p == nil { + return "" + } + return *p +} + +// exportLevelKeyword is the MDL spelling of a stored level: `api` or `hidden`. +func exportLevelKeyword(level string) string { + return strings.ToLower(level) +} + +// exportLevelClause is what describe prints after a domain-model element: the +// clause for a Usable one, nothing for Hidden — the default an absent clause +// writes on create, the way describe treats a microflow's level. +func exportLevelClause(level string) string { + if level == types.ExportLevelAPI { + return "export level api" + } + return "" +} diff --git a/mdl/executor/integration_documentation_issue1510_test.go b/mdl/executor/integration_documentation_issue1510_test.go new file mode 100644 index 0000000000..10a2e924fc --- /dev/null +++ b/mdl/executor/integration_documentation_issue1510_test.go @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/model" +) + +// mendixlabs/mxcli#1510: QUAL002 reports import and export mappings, +// published REST services and data transformers without documentation, and MDL +// had no way to write it — the doc comment above their create was dropped with +// MDL089. Worse, a rewrite wrote an empty Documentation over whatever Studio Pro +// had stored, so copying a mapping through describe -> exec deleted its text. +// +// Measured on a copy of the Studio Pro-authored PedApp (11.13), with the real +// engine, so what is asserted is the stored document's own `Documentation` key. + +type issue1510Doctype struct { + name string + create string // without its doc comment + rewrite string // a create or modify that states none + describe string + unitID func(t *testing.T, ctx *ExecContext) model.ID +} + +func issue1510Doctypes() []issue1510Doctype { + return []issue1510Doctype{{ + name: "import mapping", + create: "create import mapping MyFirstModule.IMM_Doc1510\n with json structure MyFirstModule.JSON_Doc1510\n" + + "{\n create MyFirstModule.Doc1510 {\n Code = code\n }\n};", + rewrite: "create or modify import mapping MyFirstModule.IMM_Doc1510\n with json structure MyFirstModule.JSON_Doc1510\n" + + "{\n create MyFirstModule.Doc1510 {\n Code = code,\n Amount = amount\n }\n};", + describe: "describe import mapping MyFirstModule.IMM_Doc1510", + unitID: func(t *testing.T, ctx *ExecContext) model.ID { + m, err := ctx.Backend.GetImportMappingByQualifiedName("MyFirstModule", "IMM_Doc1510") + if err != nil { + t.Fatal(err) + } + return m.ID + }, + }, { + name: "export mapping", + create: "create export mapping MyFirstModule.EMM_Doc1510\n with json structure MyFirstModule.JSON_Doc1510\n" + + "{\n MyFirstModule.Doc1510 {\n code = Code\n }\n};", + rewrite: "create or modify export mapping MyFirstModule.EMM_Doc1510\n with json structure MyFirstModule.JSON_Doc1510\n" + + "{\n MyFirstModule.Doc1510 {\n code = Code,\n amount = Amount\n }\n};", + describe: "describe export mapping MyFirstModule.EMM_Doc1510", + unitID: func(t *testing.T, ctx *ExecContext) model.ID { + m, err := ctx.Backend.GetExportMappingByQualifiedName("MyFirstModule", "EMM_Doc1510") + if err != nil { + t.Fatal(err) + } + return m.ID + }, + }, { + name: "published rest service", + create: "create published rest service MyFirstModule.Api_Doc1510 (\n Path: 'rest/doc1510/v1',\n Version: '1.0.0'\n)\n{\n};", + rewrite: "create or modify published rest service MyFirstModule.Api_Doc1510 (\n Path: 'rest/doc1510/v2',\n Version: '2.0.0'\n)\n{\n};", + describe: "describe published rest service MyFirstModule.Api_Doc1510", + unitID: func(t *testing.T, ctx *ExecContext) model.ID { + svc, err := findPublishedRestService(ctx, "MyFirstModule", "Api_Doc1510") + if err != nil { + t.Fatal(err) + } + return svc.ID + }, + }, { + name: "data transformer", + create: "create data transformer MyFirstModule.DT_Doc1510\n source json '{\"a\": 1}'\n{\n jslt '{\"b\": .a}';\n};", + rewrite: "create or modify data transformer MyFirstModule.DT_Doc1510\n source json '{\"a\": 2}'\n{\n jslt '{\"c\": .a}';\n};", + describe: "describe data transformer MyFirstModule.DT_Doc1510", + unitID: func(t *testing.T, ctx *ExecContext) model.ID { + _, id := findDataTransformer(ctx, "MyFirstModule", "DT_Doc1510") + if id == "" { + t.Fatal("data transformer not found") + } + return id + }, + }} +} + +const issue1510Prereqs = "create persistent entity MyFirstModule.Doc1510 (Code: String(20), Amount: Integer);\n" + + "create json structure MyFirstModule.JSON_Doc1510 sample '{\"code\": \"x\", \"amount\": 1}';\n" + +// storedDocumentation is the unit's top-level Documentation key, and whether +// the key is there at all. +func storedDocumentation(t *testing.T, ctx *ExecContext, id model.ID) (string, bool) { + t.Helper() + raw, err := ctx.Backend.GetRawUnitBytes(id) + if err != nil { + t.Fatal(err) + } + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + for _, e := range d { + if e.Key == "Documentation" { + s, _ := e.Value.(string) + return s, true + } + } + return "", false +} + +func TestIntegrationDocumentation_Issue1510(t *testing.T) { + const doc = "Doc 1510: the orders payload.\n\nSecond paragraph." + const comment = "/**\n * Doc 1510: the orders payload.\n *\n * Second paragraph.\n */\n" + for _, tc := range issue1510Doctypes() { + t.Run(tc.name, func(t *testing.T) { + exec, out := openPedAppFixture(t) + ctx := exec.newExecContext(context.Background()) + run := func(src string) { + t.Helper() + if err := afRun(t, exec, src); err != nil { + t.Fatalf("%v\n%s", err, src) + } + } + describe := func() string { + t.Helper() + out.Reset() + run(tc.describe + ";") + return out.String() + } + run(issue1510Prereqs) + + // 1. The doc comment reaches the stored document's Documentation. + run(comment + tc.create) + id := tc.unitID(t, ctx) + if got, ok := storedDocumentation(t, ctx, id); !ok || got != doc { + t.Fatalf("stored Documentation = %q (key present %v), want %q", got, ok, doc) + } + + // 2. Describe prints it back as the doc comment, and the output + // re-executes to the same text: the round trip. + described := describe() + if !strings.Contains(described, comment) { + t.Fatalf("describe does not print the documentation as a doc comment:\n%s", described) + } + run(described) + if got, _ := storedDocumentation(t, ctx, id); got != doc { + t.Errorf("describe -> exec changed the documentation to %q", got) + } + + // 3. A rewrite that states none keeps the stored text (#1018). + run(tc.rewrite) + if got, _ := storedDocumentation(t, ctx, id); got != doc { + t.Errorf("a rewrite with no doc comment changed the documentation to %q, want it kept", got) + } + + // 4. Control: an empty doc comment clears it, so the carry is the + // absence of a comment and not a write path that ignores the text. + run("/** */\n" + tc.rewrite) + if got, ok := storedDocumentation(t, ctx, id); !ok || got != "" { + t.Errorf("an empty doc comment left Documentation = %q (key present %v), want empty", got, ok) + } + }) + } +} + +// The Studio Pro-authored mappings in PedApp: a rewrite through describe -> +// exec, and one with no doc comment, must not delete documentation Studio Pro +// wrote. Before #1510 the executor never set it, so every rewrite stored "". +func TestIntegrationDocumentation_StudioProMappingKeepsIt_Issue1510(t *testing.T) { + exec, out := openPedAppFixture(t) + ctx := exec.newExecContext(context.Background()) + im, err := ctx.Backend.GetImportMappingByQualifiedName("FeedbackModule", "IMM_PostResponse") + if err != nil { + t.Fatalf("fixture mapping: %v", err) + } + // Give the Studio Pro mapping documentation the way Studio Pro would store + // it, without going through the executor being tested. + im.Documentation = "Written in Studio Pro." + if err := ctx.Backend.UpdateImportMapping(im); err != nil { + t.Fatal(err) + } + out.Reset() + if err := afRun(t, exec, "describe import mapping FeedbackModule.IMM_PostResponse;"); err != nil { + t.Fatal(err) + } + described := out.String() + if !strings.Contains(described, "Written in Studio Pro.") { + t.Fatalf("precondition: describe does not show the documentation:\n%s", described) + } + // Strip the doc comment: the statement now states none. + body := described[strings.Index(described, "create or modify"):] + if err := afRun(t, exec, body); err != nil { + t.Fatalf("re-execute describe body: %v\n%s", err, body) + } + if got, _ := storedDocumentation(t, ctx, im.ID); got != "Written in Studio Pro." { + t.Errorf("a rewrite with no doc comment changed Studio Pro's documentation to %q", got) + } +} diff --git a/mdl/executor/listview_database_over_association_test.go b/mdl/executor/listview_database_over_association_test.go index fc1d55b919..ea64f53fe3 100644 --- a/mdl/executor/listview_database_over_association_test.go +++ b/mdl/executor/listview_database_over_association_test.go @@ -121,21 +121,16 @@ func TestAssociationSugarStillParsesAsAssociation(t *testing.T) { } } -// Only the List View writer stores the path. A gallery or grid would write the -// entity alone — every row instead of the context's — so the builder refuses. -func TestDatabaseOverAssociationRefusedOffAListView(t *testing.T) { - prog, errs := visitor.Build(`create page W.P ( Title: 'P' ) { - gallery g (DataSource: database from $currentObject/W.A_B/W.B) { dynamictext t (Content: 'x') } -}`) - if len(errs) > 0 { - t.Fatalf("parse: %v", errs) - } - w := prog.Statements[0].(*ast.CreatePageStmtV3).Widgets[0] - if err := checkDatabaseOverAssociationIsOnAListView(w); err == nil { - t.Error("a gallery accepted `database from $currentObject/…`") +// A list view and the pluggable engine store the path; any other caller of +// buildDataSourceV3 (a chart series, a dynamic image) would write the entity +// alone — every row instead of the context's — so it refuses. +func TestDatabaseOverAssociationRefusedOutsideListViewAndPluggable(t *testing.T) { + pb := pluggableAssocPageBuilder(11, 12) + ds := &ast.DataSourceV3{Type: "database", ContextVariable: "currentObject", AssociationPath: "Probe.Item_Parent/Probe.Item"} + if _, _, err := pb.buildDataSourceV3(ds); err == nil { + t.Error("buildDataSourceV3 accepted `database from $currentObject/…`") } - w.Type = "listview" - if err := checkDatabaseOverAssociationIsOnAListView(w); err != nil { - t.Errorf("a list view was refused: %v", err) + if _, _, err := pb.buildPluggableDataSourceV3(ds); err != nil { + t.Errorf("a pluggable widget was refused: %v", err) } } diff --git a/mdl/executor/pluggable_database_source.go b/mdl/executor/pluggable_database_source.go index fff8ae45f1..d5e5123a0d 100644 --- a/mdl/executor/pluggable_database_source.go +++ b/mdl/executor/pluggable_database_source.go @@ -29,12 +29,15 @@ func (pb *pageBuilder) customWidgetDatabaseSourceSupported() bool { // buildPluggableDataSourceV3 builds the datasource of a widget the pluggable // engine writes. `database from $ctx/Assoc/Entity` is the one form it adds to -// buildDataSourceV3: on a 9.x project it is the CustomWidgetDatabaseSource -// Studio Pro writes for a repeater, gallery or grid bound to "Database" over an -// association, its WHERE stored as constraint rows (mendixlabs/mxcli#1399). -// Everywhere else the form keeps buildDataSourceV3's refusal. +// buildDataSourceV3: the "Database" source Studio Pro writes for a repeater, +// gallery or grid bound over an association. On 10+ that is a +// CustomWidgetXPathSource holding the hops as an IndirectEntityRef beside its +// XPath and sort bar (mendixlabs/mxcli#1599); on a 9.x project it is the +// CustomWidgetDatabaseSource, its WHERE stored as constraint rows +// (mendixlabs/mxcli#1399). A widget with its own builder keeps +// buildDataSourceV3's refusal. func (pb *pageBuilder) buildPluggableDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource, string, error) { - if ds == nil || ds.Type != "database" || ds.AssociationPath == "" || !pb.customWidgetDatabaseSourceSupported() { + if ds == nil || ds.Type != "database" || ds.AssociationPath == "" { return pb.buildDataSourceV3(ds) } built, entity, err := pb.buildDatabaseOverAssociationV3(ds) @@ -42,7 +45,7 @@ func (pb *pageBuilder) buildPluggableDataSourceV3(ds *ast.DataSourceV3) (pages.D return nil, "", err } src, ok := built.(*pages.DatabaseSource) - if !ok { + if !ok || !pb.customWidgetDatabaseSourceSupported() { return built, entity, nil } constraints, err := parseDatabaseConstraints(src.XPathConstraint, entity) diff --git a/mdl/executor/pluggable_database_source_test.go b/mdl/executor/pluggable_database_source_test.go index d6e68660b2..37415fa007 100644 --- a/mdl/executor/pluggable_database_source_test.go +++ b/mdl/executor/pluggable_database_source_test.go @@ -6,7 +6,6 @@ import ( "reflect" "testing" - "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/backend/mock" "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/sdk/pages" @@ -21,7 +20,8 @@ func pageBuilderAt(major, minor int) *pageBuilder { } // The source type exists in 9.x only: mx convert 10.12.19 rewrites every one -// into an XPath source. On 10+ the pluggable form stays refused, as before. +// into an XPath source, which is what 10+ builds (mendixlabs/mxcli#1599, +// TestBuildPluggableDataSource_DatabaseOverAssociationOn10). func TestCustomWidgetDatabaseSourceVersionGate(t *testing.T) { if !pageBuilderAt(9, 24).customWidgetDatabaseSourceSupported() { t.Error("9.24 does not get the 9.x database source") @@ -32,10 +32,6 @@ func TestCustomWidgetDatabaseSourceVersionGate(t *testing.T) { if (&pageBuilder{backend: &mock.MockBackend{}}).customWidgetDatabaseSourceSupported() { t.Error("an unknown version got the 9.x source") } - ds := &ast.DataSourceV3{Type: "database", ContextVariable: "P", AssociationPath: "M.A_B/M.A"} - if _, _, err := pageBuilderAt(10, 24).buildPluggableDataSourceV3(ds); err == nil { - t.Error("10.24 built `database from $P/…` on a pluggable widget") - } } // mendixlabs/mxcli#1399, write half. A 9.x "Database" source on a pluggable diff --git a/mdl/executor/pluggable_xpath_over_association_test.go b/mdl/executor/pluggable_xpath_over_association_test.go new file mode 100644 index 0000000000..0752e7cd10 --- /dev/null +++ b/mdl/executor/pluggable_xpath_over_association_test.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// gallerySortBar is the Forms$GridSortBar Studio Pro stores on a pluggable +// widget's XPath source: one ascending item on Probe.Item.Code. +func gallerySortBar() map[string]any { + return map[string]any{ + "$Type": "Forms$GridSortBar", + "SortItems": []any{int32(2), map[string]any{ + "$Type": "Forms$GridSortItem", + "AttributeRef": map[string]any{"$Type": "DomainModels$AttributeRef", "Attribute": "Probe.Item.Code"}, + "SortDirection": "Ascending", + }}, + } +} + +// mendixlabs/mxcli#1599: a gallery whose "Database" source runs over an +// association from its context object is stored as a +// CustomWidgets$CustomWidgetXPathSource with an IndirectEntityRef and a sort +// bar. Describe read it as the association source `$currentObject/Assoc`, +// which drops the sort (and the XPath) and re-executes as a +// Forms$AssociationSource — a different retrieve. +func TestParseDataSource_PluggableXPathOverAssociationKeepsSort(t *testing.T) { + ds := parseDataSource(map[string]any{ + "$Type": dsTypeCustomWidgetXPath, + "EntityRef": indirectRef(assocStep("Probe.Item_Parent", "Probe.Item")), + "XPathConstraint": "", + "SortBar": gallerySortBar(), + }) + if ds == nil { + t.Fatal("datasource dropped") + } + want := "database from $currentObject/Probe.Item_Parent/Probe.Item sort by Code asc" + if got := dataSourceExpr(nil, ds); got != want { + t.Errorf("dataSourceExpr = %q, want %q", got, want) + } + if got := dataSourceEntityContext(nil, ds); got != "Probe.Item" { + t.Errorf("dataSourceEntityContext = %q, want Probe.Item", got) + } +} + +// The XPath constraint rides along as well, and a page-parameter start is kept. +func TestParseDataSource_PluggableXPathOverAssociationKeepsWhereAndParameter(t *testing.T) { + ds := parseDataSource(map[string]any{ + "$Type": dsTypeCustomWidgetXPath, + "EntityRef": indirectRef(assocStep("Probe.Item_Parent", "Probe.Item")), + "SourceVariable": pageVariable("Parent"), + "XPathConstraint": "[Amount > 3]", + }) + got := dataSourceExpr(nil, ds) + want := "database from $Parent/Probe.Item_Parent/Probe.Item where [Amount > 3]" + if got != want { + t.Errorf("dataSourceExpr = %q, want %q", got, want) + } +} + +// Control for the change above: a hop with no stored destination cannot be +// spelled as a database path, and keeps the association reading (#941). +func TestParseDataSource_PluggableXPathWithoutDestinationStaysAssociation(t *testing.T) { + ds := parseDataSource(dsBSON(dsTypeCustomWidgetXPath, + "EntityRef", map[string]any{"Steps": []any{map[string]any{"Association": "Mod.Item_Bucket"}}}, + )) + if ds == nil || ds.Type != "association" { + t.Fatalf("parseDataSource = %+v, want an association source", ds) + } +} + +// Write half: on 10+ a pluggable widget builds `database from $ctx/Assoc/Entity` +// as a database source carrying the hops, its sort and the start variable, for +// customWidgetDataSourceToGen to store as a CustomWidgetXPathSource. +func TestBuildPluggableDataSource_DatabaseOverAssociationOn10(t *testing.T) { + pb := pluggableAssocPageBuilder(11, 12) + prog, errs := visitor.Build(`create page Probe.P ( Title: 'P' ) { + gallery g (DataSource: database from $currentObject/Probe.Item_Parent/Probe.Item sort by Code asc) { dynamictext t (Content: 'x') } +}`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + w := prog.Statements[0].(*ast.CreatePageStmtV3).Widgets[0] + built, entity, err := pb.buildPluggableDataSourceV3(w.GetDataSource()) + if err != nil { + t.Fatalf("buildPluggableDataSourceV3: %v", err) + } + if entity != "Probe.Item" { + t.Errorf("entity = %q, want Probe.Item", entity) + } + src, ok := built.(*pages.DatabaseSource) + if !ok { + t.Fatalf("built %T, want *pages.DatabaseSource", built) + } + if src.UseConstraints { + t.Error("10+ got the 9.x constraint-row source") + } + if len(src.EntitySteps) != 1 || src.EntitySteps[0].Association != "Probe.Item_Parent" || src.EntitySteps[0].DestinationEntity != "Probe.Item" { + t.Errorf("EntitySteps = %+v, want one hop Probe.Item_Parent → Probe.Item", src.EntitySteps) + } + if len(src.Sorting) != 1 || !strings.HasSuffix(src.Sorting[0].AttributePath, "Code") || src.Sorting[0].Direction != pages.SortDirectionAscending { + t.Errorf("Sorting = %+v, want Code ascending", src.Sorting) + } +} + +// pluggableAssocPageBuilder is a page builder at the given Mendix version over +// the issue's domain model: Probe.Item_Parent from Probe.Item to Probe.Parent, +// with the context object a Probe.Parent (the enclosing list view's). +func pluggableAssocPageBuilder(major, minor int) *pageBuilder { + mod := &model.Module{BaseElement: model.BaseElement{ID: model.ID("mod-probe")}, Name: "Probe"} + b := &mock.MockBackend{ + ProjectVersionFunc: func() *types.ProjectVersion { + return &types.ProjectVersion{MajorVersion: major, MinorVersion: minor} + }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{{ + BaseElement: model.BaseElement{ID: model.ID("dm-probe")}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: model.ID("e-parent")}, Name: "Parent", + Attributes: []*domainmodel.Attribute{{Name: "Name"}}}, + {BaseElement: model.BaseElement{ID: model.ID("e-item")}, Name: "Item", + Attributes: []*domainmodel.Attribute{{Name: "Code"}, {Name: "Amount"}}}, + }, + Associations: []*domainmodel.Association{{ + BaseElement: model.BaseElement{ID: model.ID("a-item-parent")}, + Name: "Item_Parent", + ParentID: model.ID("e-item"), + ChildID: model.ID("e-parent"), + }}, + }}, nil + }, + } + return &pageBuilder{ + backend: b, + entityContext: "Probe.Parent", + paramScope: map[string]model.ID{}, + paramEntityNames: map[string]string{}, + widgetScope: map[string]model.ID{}, + } +} diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 5775f890ba..46cdc17273 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -487,6 +487,10 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // widget that is already stored, so its property can only be resolved // against the document — which is why it passed check and failed exec. errors = append(errors, validateAlterSetProperties(ctx, prog, sc)...) + // A SET or ALTER STYLING that styles a stored snippet call: valid, and + // useless — a snippet call renders no element (mendixlabs/mxcli#1511). + // What the target IS lives only in the document. Warns, never blocks. + validateStoredSnippetCallAppearance(ctx, prog, sc) // The entity an ALTER's INSERT / REPLACE binds against is in the stored // document, not the statement. Where it has none (a missing flow source, or // no data container at all) a bare binding is written bare and the project @@ -1955,6 +1959,10 @@ var execEnforcedMicroflowRules = map[string]bool{ // MDL092: an @anchor parameter the visitor cannot use is dropped, the edge // keeping its default sides — the same class as MDL060 (mendixlabs/mxcli#992). "MDL092": true, + // MDL094: a loop over a path, `loop $c in $Parent/Mod.Assoc`. The model's + // loop holds a list VARIABLE, so the builder has nowhere to put the path and + // writes a loop over nothing — CE0099 on 11.12.1 (mendixlabs/mxcli#199). + "MDL094": true, // MDL076: `on error continue` on an activity that rejects it is CE6035 at // build time — every row of continueUnsupportedOn was measured on 11.14.0. // check reported it; exec without the pre-check (-c, the REPL, --no-check) diff --git a/mdl/executor/validate_alter_flow.go b/mdl/executor/validate_alter_flow.go new file mode 100644 index 0000000000..66b6eccdf0 --- /dev/null +++ b/mdl/executor/validate_alter_flow.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// ValidateAlterFlow runs the annotation rules (MDL059, MDL060, MDL079, MDL092) +// over the fragments an `alter microflow|nanoflow` inserts or replaces. +// +// A fragment is built by the same flowBuilder as a create body, which drops an +// annotation it cannot read — the visitor records why, and only the validator +// says so. The rules were run over create bodies alone, so +// `@annotation('text')` in an `insert` passed check and exec ("Altered +// microflow") and no note was written, while the same line in `create or +// modify` was refused (mendixlabs/mxcli#1597). +// +// Only the annotation rules: the rest of the microflow rule set reads the whole +// flow — its parameters, its declares, what a later statement uses — and a +// fragment is a piece of a stored flow that this pass cannot see. +func ValidateAlterFlow(s *ast.AlterFlowStmt) []linter.Violation { + v := µflowValidator{mfName: s.Name.String(), docType: s.Kind()} + for _, op := range s.Operations { + v.walkAnnotations(op.Body) + } + return v.violations +} + +// validateAlterFlowRules is validateMicroflowRules for an alter: exec refuses +// what ValidateAlterFlow reports, restricted to the same verified allowlist, so +// `exec -c` and the REPL — which skip the check preflight — refuse what a +// create body would have. +func validateAlterFlowRules(s *ast.AlterFlowStmt) error { + var msgs []string + for _, v := range ValidateAlterFlow(s) { + if v.Severity != linter.SeverityError || !execEnforcedMicroflowRules[v.RuleID] { + continue + } + msg := fmt.Sprintf("[%s] %s", v.RuleID, v.Message) + if v.Suggestion != "" { + msg += "\n " + v.Suggestion + } + msgs = append(msgs, msg) + } + if len(msgs) == 0 { + return nil + } + return mdlerrors.NewValidationf("alter %s '%s' has validation errors:\n - %s", + s.Kind(), s.Name.String(), strings.Join(msgs, "\n - ")) +} diff --git a/mdl/executor/validate_alter_flow_annotations_test.go b/mdl/executor/validate_alter_flow_annotations_test.go new file mode 100644 index 0000000000..13df184ae9 --- /dev/null +++ b/mdl/executor/validate_alter_flow_annotations_test.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// mendixlabs/mxcli#1597 (case B). `@annotation('Why this commit is here.')` in +// an `alter microflow … insert` fragment passed check and exec ("Altered +// microflow"), and the note was not written — the visitor cannot read a +// positional parameter, so it drops the whole note and records why. The same +// line in `create or modify microflow` is refused as MDL079. The annotation +// rules (MDL059/MDL060/MDL079/MDL092) were run over create bodies only; an +// alter fragment is a body too, and reached the builder unchecked. + +func parseAlterFlow(t *testing.T, src string) (*ast.Program, *ast.AlterFlowStmt) { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + s, ok := prog.Statements[len(prog.Statements)-1].(*ast.AlterFlowStmt) + if !ok { + t.Fatalf("last statement is %T, want *ast.AlterFlowStmt", prog.Statements[len(prog.Statements)-1]) + } + return prog, s +} + +func alterFlowRuleCounts(t *testing.T, prog *ast.Program) map[string]int { + t.Helper() + ids := map[string]int{} + for _, v := range ValidateProgram(prog, "") { + ids[v.RuleID]++ + } + return ids +} + +func TestValidateProgram_ChecksAnnotationsInAnAlterFlowFragment(t *testing.T) { + cases := []struct { + name, fragment, rule string + }{ + {"positional note text (the report)", "@annotation('Why this commit is here.')\n commit $Result;", "MDL079"}, + {"typo'd annotation name", "@postion(10, 20)\n commit $Result;", "MDL059"}, + {"curve that is not a pair", "@curve(from: (a, b))\n commit $Result;", "MDL060"}, + {"anchor side that does not exist", "@anchor(from: sideways)\n commit $Result;", invalidAnchorRule}, + // Nested inside a fragment's own control flow: the walk has to enter it. + {"inside an if in the fragment", "if $Result != empty then\n @annotation('nested')\n commit $Result;\n end if;", "MDL079"}, + } + for _, op := range []string{"insert after $Result", "replace $Result with"} { + for _, c := range cases { + t.Run(op+"/"+c.name, func(t *testing.T) { + prog, _ := parseAlterFlow(t, "mdl 1;\nalter microflow Probe.ACT_Do {\n "+op+ + " begin\n "+c.fragment+"\n end;\n};") + if got := alterFlowRuleCounts(t, prog)[c.rule]; got == 0 { + t.Errorf("check passed an alter fragment carrying %s — the annotation is dropped by "+ + "the builder, so exec writes the flow without it", c.rule) + } + }) + } + } +} + +// The control: the two spellings of a note that DO work in an alter fragment +// must stay clean, or the check would refuse what the reporter's workaround is. +func TestValidateProgram_AlterFlowFragmentValidNotesStayClean(t *testing.T) { + prog, _ := parseAlterFlow(t, `mdl 1; +alter microflow Probe.ACT_Do { + insert after $Result begin + @annotation 'Why this commit is here.' + @curve(from: (39, 0), to: (0, 61)) + @anchor(from: right, to: left) + commit $Result; + @annotation(text: 'And this log.') + log info node 'n' 'x'; + end; +};`) + ids := alterFlowRuleCounts(t, prog) + for _, rule := range []string{"MDL059", "MDL060", "MDL079", invalidAnchorRule} { + if ids[rule] != 0 { + t.Errorf("%s reported on a valid alter fragment (%d times)", rule, ids[rule]) + } + } +} + +// exec without the preflight (`-c`, the REPL, --no-check) refuses what create +// refuses: the exec-enforced annotation rules, before anything is loaded. +func TestExecAlterFlow_RefusesADroppedAnnotation(t *testing.T) { + _, s := parseAlterFlow(t, `mdl 1; +alter microflow Probe.ACT_Do { + insert after $Result begin + @postion(10, 20) + commit $Result; + end; +};`) + ctx, _ := newMockCtx(t, withBackend(&mock.MockBackend{IsConnectedFunc: func() bool { return true }})) + err := execAlterFlow(ctx, s) + if err == nil || !strings.Contains(err.Error(), "MDL059") { + t.Fatalf("execAlterFlow = %v, want a refusal naming MDL059", err) + } +} diff --git a/mdl/executor/validate_alter_page_column_target_test.go b/mdl/executor/validate_alter_page_column_target_test.go new file mode 100644 index 0000000000..c31d116b76 --- /dev/null +++ b/mdl/executor/validate_alter_page_column_target_test.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// Found reproducing mendixlabs/mxcli#1512: `alter page M.P { replace grid1 +// column(C) with { column (…) } }`, where grid1 has no column C, passed +// `check --references` and exec then refused it — "column grid1 column(C) not +// found — a column is addressed by the attribute or the caption describe prints +// in it". Any INSERT or REPLACE on the document waived every column address on +// it, the REPLACE's own target included, because a column the script adds could +// be the one addressed. It can only be one an EARLIER operation adds: exec +// resolves each operation's targets before that operation runs. + +// storedColumnsMutator is a stored page holding grid1 with one column, Number. +// Only target resolution and the column-entity lookups are answered. +type storedColumnsMutator struct { + backend.PageMutator +} + +func (storedColumnsMutator) ResolveAlterTarget(t backend.AlterTarget) (backend.AlterTargetMatch, error) { + switch { + case len(t.Path) == 1 && t.Path[0] == "grid1": + return backend.AlterTargetMatch{Kind: "widget", Name: "grid1"}, nil + case len(t.Path) == 2 && t.Path[0] == "grid1" && t.Path[1] == "column(Number)": + return backend.AlterTargetMatch{Kind: "column", Name: "grid1 column(Number)"}, nil + } + return backend.AlterTargetMatch{}, fmt.Errorf("column %s not found — available: grid1 column(Number)", t) +} +func (storedColumnsMutator) EnclosingEntityForChildren(string) string { return "" } +func (storedColumnsMutator) EnclosingDataSourceFlow(string, bool) (string, string) { + return "", "" +} + +func storedColumnsCtx(t *testing.T) *ExecContext { + t.Helper() + mod := mkModule("Shop") + pg := mkPage(mod.ID, "P_Db") + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListFoldersFunc: func() ([]*types.FolderInfo, error) { return nil, nil }, + ListPagesFunc: func() ([]*pages.Page, error) { return []*pages.Page{pg}, nil }, + OpenPageForMutationFunc: func(model.ID) (backend.PageMutator, error) { + return storedColumnsMutator{}, nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + return ctx +} + +func TestAlterPageRefs_ReportsAMissingColumnTheStatementReplaces(t *testing.T) { + for name, src := range map[string]string{ + "replace": `alter page Shop.P_Db { replace grid1 column(C) with { column (Attribute: Note, Caption: 'C') } };`, + "insert after": `alter page Shop.P_Db { insert after grid1 column(C) { column (Attribute: Note, Caption: 'N') } };`, + "drop before an insert": `alter page Shop.P_Db { drop widget grid1 column(Nope) + insert after grid1 column(Number) { column (Attribute: Note, Caption: 'N') } };`, + "caption address": `alter page Shop.P_Db { replace grid1 column('Nope') with { column (Attribute: Note, Caption: 'C') } };`, + "after an unrelated insert": `alter page Shop.P_Db { replace grid1 column(C) with { column (Attribute: Note, Caption: 'C') } }; + alter page Shop.P_Db { insert after grid1 column(Note) { column (Attribute: Total, Caption: 'T') } };`, + } { + t.Run(name, func(t *testing.T) { + errs := checkAlterPageRefs(t, storedColumnsCtx(t), src) + if len(errs) == 0 || !strings.Contains(errs[0].Error(), "not found") { + t.Fatalf("an address no stored column has passed check: %v", errs) + } + }) + } +} + +// CONTROL: a column an earlier operation adds is not in the stored document, +// and must not be reported — in the same statement or a later one. +func TestAlterPageRefs_AcceptsAColumnAnEarlierOperationAdds(t *testing.T) { + for name, src := range map[string]string{ + "stored column": `alter page Shop.P_Db { replace grid1 column(Number) with { column (Attribute: Note, Caption: 'C') } };`, + "same statement": `alter page Shop.P_Db { + insert after grid1 column(Number) { column (Attribute: Note, Caption: 'N') } + replace grid1 column(Note) with { column (Attribute: Total, Caption: 'T') } };`, + "later statement": `alter page Shop.P_Db { replace grid1 column(Number) with { column (Attribute: Note, Caption: 'C') } }; + alter page Shop.P_Db { replace grid1 column(Note) with { column (Attribute: Total, Caption: 'T') } };`, + } { + t.Run(name, func(t *testing.T) { + if errs := checkAlterPageRefs(t, storedColumnsCtx(t), src); len(errs) != 0 { + t.Errorf("got %v, want none", errs) + } + }) + } +} diff --git a/mdl/executor/validate_alter_page_refs.go b/mdl/executor/validate_alter_page_refs.go index a2bc7ad9d4..0f955d2989 100644 --- a/mdl/executor/validate_alter_page_refs.go +++ b/mdl/executor/validate_alter_page_refs.go @@ -32,8 +32,12 @@ import ( // // "Could not establish" stays silent: a document that will not open, a target // the script itself puts on the document (or renames a widget to), and a grid -// column on a document the script adds widgets to (a column is addressed by a -// derived name, so a name match cannot say whether the script adds it). +// column on a document an EARLIER operation adds widgets to (a column is +// addressed by a derived name, so a name match cannot say whether the script +// adds it). Earlier only: exec resolves an operation's targets before it runs, +// so a REPLACE's own column address is against what is already there. Waiving +// it too passed `replace grid1 column(C) …` on a grid with no column C, which +// exec then refused (found reproducing mendixlabs/mxcli#1512). // validateAlterPageReferences reports the unresolvable targets and widget // references of every ALTER PAGE / SNIPPET / LAYOUT in the script. @@ -43,7 +47,7 @@ func validateAlterPageReferences(ctx *ExecContext, prog *ast.Program, sc *script } h, _ := getHierarchy(ctx) added := widgetsTheScriptAddsTo(prog) - grows := documentsTheScriptAddsWidgetsTo(prog) + grown := map[string]bool{} // documents an operation so far has added widgets to opened := map[model.ID]backend.PageMutator{} var judge columnAttrJudge @@ -118,25 +122,27 @@ func validateAlterPageReferences(ctx *ExecContext, prog *ast.Program, sc *script } } - // The targets, against the stored document. - if mutator == nil { - continue - } + // The targets, against the stored document, in the order exec + // resolves them. for _, op := range s.Operations { - if _, isSet := op.(*ast.SetPropertyOp); isSet { - continue // validateAlterSetProperties' finding - } - for _, ref := range alterPageOperationTargets(op) { - if ref.Widget != "" && added[doc][ref.Widget] { - continue - } - if ref.IsColumn() && grows[doc] { - continue - } - if _, err := mutator.ResolveAlterTarget(alterTargetOf(ref)); err != nil { - errs = append(errs, mdlerrors.NewValidationf("%s: %v", label, err)) + // A SET's target is validateAlterSetProperties' finding. + if _, isSet := op.(*ast.SetPropertyOp); mutator != nil && !isSet { + for _, ref := range alterPageOperationTargets(op) { + if ref.Widget != "" && added[doc][ref.Widget] { + continue + } + if ref.IsColumn() && grown[doc] { + continue + } + if _, err := mutator.ResolveAlterTarget(alterTargetOf(ref)); err != nil { + errs = append(errs, mdlerrors.NewValidationf("%s: %v", label, err)) + } } } + switch op.(type) { + case *ast.InsertWidgetOp, *ast.ReplaceWidgetOp: + grown[doc] = true + } } } return errs diff --git a/mdl/executor/validate_alter_target.go b/mdl/executor/validate_alter_target.go index 8bfed30b03..a39168b967 100644 --- a/mdl/executor/validate_alter_target.go +++ b/mdl/executor/validate_alter_target.go @@ -55,8 +55,10 @@ func validateAlterTarget(ctx *ExecContext, stmt ast.Statement, sc *scriptContext buildPageQualifiedNames, func(qn string) bool { return sc.pages[qn] }) } case *ast.AlterEntityStmt: - return checkAlterTarget(ctx, sc, "entity", s.Name, - buildEntityQualifiedNames, func(qn string) bool { return sc.entities[qn] }) + // A MOVE or RENAME earlier in the script lands an entity under a name + // no CREATE declares (mendixlabs/mxcli#1601). + return checkAlterTarget(ctx, sc, "entity", s.Name, buildEntityQualifiedNames, + func(qn string) bool { return sc.entities[qn] || sc.indirectEntities[qn] }) } return nil } diff --git a/mdl/executor/validate_alter_target_test.go b/mdl/executor/validate_alter_target_test.go index 0607300d44..bf02603359 100644 --- a/mdl/executor/validate_alter_target_test.go +++ b/mdl/executor/validate_alter_target_test.go @@ -169,3 +169,38 @@ func TestValidateAlterTarget_SilentWhenTheListingIsEmpty(t *testing.T) { t.Errorf("an unanswerable listing produced a not-found: %v", err) } } + +// An entity an earlier MOVE or RENAME in the same script produces is not in the +// project yet, and is not CREATEd either — so `move entity … to Probe; alter +// entity Probe.Item …` was "entity not found: Probe.Item (referenced by alter +// entity)" and exec refused a script that `--no-check` applied cleanly +// (mendixlabs/mxcli#1601). collectSingle already recorded the destination in +// indirectEntities; the alter-target check did not read it. +func TestValidateAlterTarget_CountsEntitiesAMoveOrRenameProduces(t *testing.T) { + ctx := alterTargetFixture(t) + + for _, src := range []string{ + "move entity Shop.Order to Probe;\nalter entity Probe.Order set documentation 'text';", + "rename entity Shop.Order to Thing;\nalter entity Shop.Thing set documentation 'text';", + } { + prog := parseMDL(t, src) + sc := newScriptContext() + sc.collectDefinitions(prog) + if err := validateAlterTarget(ctx, prog.Statements[1], sc); err != nil { + t.Errorf("%s\nthe entity the script moves or renames was reported missing: %v", src, err) + } + } +} + +// CONTROL: the destination of a move is the only name the move vouches for — a +// typo next to it is still a missing entity. +func TestValidateAlterTarget_MoveDoesNotVouchForOtherNames(t *testing.T) { + ctx := alterTargetFixture(t) + prog := parseMDL(t, "move entity Shop.Order to Probe;\nalter entity Shop.Ordr set documentation 'text';") + sc := newScriptContext() + sc.collectDefinitions(prog) + err := validateAlterTarget(ctx, prog.Statements[1], sc) + if err == nil || !strings.Contains(err.Error(), "entity not found: Shop.Ordr") { + t.Errorf("a misspelled entity next to a move was accepted: %v", err) + } +} diff --git a/mdl/executor/validate_association_path_value.go b/mdl/executor/validate_association_path_value.go new file mode 100644 index 0000000000..6db510835c --- /dev/null +++ b/mdl/executor/validate_association_path_value.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// loopOverPathRule reports a loop whose list is written as a path. +const loopOverPathRule = "MDL094" + +// checkAssociationPathValues flags an association path written where a flow +// needs a whole value or a list variable (mendixlabs/mxcli#199). Measured on +// mxbuild 11.12.1, one microflow each into a fresh project: +// +// return $Parent/M.Child_Parent (reverse, list) CE0117 +// return $Child/M.Child_Parent (forward, object) CE0117 +// return $Child/M.Child_Tag (reference set, list) CE0117 +// return $Child/M.Child_Parent/M.Parent (entity step) 0 errors +// return $Child/M.Child_Tag/M.Tag (entity step, list) 0 errors +// loop $c in $Parent/M.Child_Parent CE0099 +// +// A return value is the shape MDL049 refuses as a call argument, so it is the +// same rule with the same exemptions. A loop's list is a variable name in the +// model — there is nowhere to store a path, with or without the entity step — +// so any path there is refused (MDL094). `retrieve $Children from +// $Parent/M.Child_Parent;` is the activity both mean. +func (v *microflowValidator) checkAssociationPathValues(body []ast.MicroflowStatement) { + walkFlowStatements(body, func(s ast.MicroflowStatement) { + switch st := s.(type) { + case *ast.ReturnStmt: + if exprIsAssociationObjectPath(st.Value) { + path := microflowExprSource(st.Value) + v.addViolation("MDL049", linter.SeverityError, + fmt.Sprintf("return %s returns a bare association path, which Mendix rejects as a value "+ + "(CE0117 \"Error(s) in expression\" at the end event)", path), + fmt.Sprintf("Retrieve over the association and return the variable — `retrieve $Result from %s;` "+ + "then `return $Result;` — or, for a reference followed from its owner, name the target entity "+ + "after the association: `%s/`.", path, path)) + } + case *ast.LoopStmt: + if st.ListPath != "" { + v.addViolation(loopOverPathRule, linter.SeverityError, + fmt.Sprintf("loop $%s in %s iterates over a path, but a loop iterates over a list variable — "+ + "the path cannot be stored and the loop would be written with nothing to iterate "+ + "(CE0099 \"The 'Iterate over' property is required.\")", st.LoopVariable, st.ListPath), + fmt.Sprintf("Retrieve the list first: `retrieve $%sList from %s;` then `loop $%s in $%sList`.", + st.LoopVariable, st.ListPath, st.LoopVariable, st.LoopVariable)) + } + } + }) +} diff --git a/mdl/executor/validate_association_path_value_test.go b/mdl/executor/validate_association_path_value_test.go new file mode 100644 index 0000000000..e3f540877c --- /dev/null +++ b/mdl/executor/validate_association_path_value_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// mendixlabs/mxcli#199 (ako/mxcli#1231): `return $Parent/Module.Parent_Child;` +// passed `mxcli check --references` and was CE0117 "Error(s) in expression." at +// End event, and `loop $c in $Parent/Module.Parent_Child` was written with no +// list — CE0099 "The 'Iterate over' property is required." Measured on mxbuild +// 11.12.1, one microflow each into a fresh project: +// +// return $Parent/M.Child_Parent (reverse, list) CE0117 +// return $Child/M.Child_Parent (forward, object) CE0117 +// return $Child/M.Child_Tag (reference set, list) CE0117 +// return $Child/M.Child_Parent/Name 0 errors +// return $Child/M.Child_Parent/M.Parent (entity step, object) 0 errors +// return $Child/M.Child_Tag/M.Tag (entity step, list) 0 errors +// loop $c in $Parent/M.Child_Parent CE0099 +// +// The return value is the same shape MDL049 refuses as a call argument; the +// loop's list is a variable name in the model, so no path can be stored there. + +func microflowRuleFires(t *testing.T, src, rule string) bool { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse errors: %v\n%s", errs, src) + } + for _, vi := range ValidateMicroflow(prog.Statements[0].(*ast.CreateMicroflowStmt)) { + if vi.RuleID == rule { + return true + } + } + return false +} + +func TestValidateMicroflow_AssociationPathReturn(t *testing.T) { + cases := []struct { + name, params, returns, value string + want bool + }{ + {"reverse traversal into a list", "$Parent: M.Parent", "list of M.Child", "$Parent/M.Child_Parent", true}, + {"reference into an object", "$Child: M.Child", "M.Parent", "$Child/M.Child_Parent", true}, + {"reference set into a list", "$Child: M.Child", "list of M.Tag", "$Child/M.Child_Tag", true}, + // Controls, each 0 errors on 11.12.1. + {"attribute over the association", "$Child: M.Child", "String", "$Child/M.Child_Parent/Name", false}, + {"target entity named", "$Child: M.Child", "M.Parent", "$Child/M.Child_Parent/M.Parent", false}, + {"reference set, target entity named", "$Child: M.Child", "list of M.Tag", "$Child/M.Child_Tag/M.Tag", false}, + {"a variable", "$Child: M.Child", "M.Child", "$Child", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + src := "mdl 1;\ncreate microflow M.F (" + c.params + ")\nreturns " + c.returns + "\nbegin\n return " + c.value + ";\nend;" + if got := microflowRuleFires(t, src, "MDL049"); got != c.want { + t.Errorf("MDL049 fired=%v, want %v", got, c.want) + } + }) + } +} + +func TestValidateMicroflow_LoopOverAssociationPath(t *testing.T) { + cases := []struct { + name, source string + want bool + }{ + {"association path", "$Parent/M.Child_Parent", true}, + {"target entity named", "$Parent/M.Child_Parent/M.Child", true}, + {"a list variable", "$Children", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + src := "mdl 1;\ncreate microflow M.F ($Parent: M.Parent, $Children: list of M.Child)\nbegin\n loop $c in " + c.source + + " begin\n log info node 'x' 'y';\n end loop;\nend;" + if got := microflowRuleFires(t, src, "MDL094"); got != c.want { + t.Errorf("MDL094 fired=%v, want %v", got, c.want) + } + }) + } +} + +// A nanoflow's return and loop are built the same way: measured on 11.12.1, a +// nanoflow returning `$Child/M.Child_Parent` is CE0117 at the end event, and +// `$Child/M.Child_Parent/M.Parent` is 0 errors. +func TestValidateNanoflow_AssociationPathValues(t *testing.T) { + for _, c := range []struct { + name, body, rule string + want bool + }{ + {"bare path returned", "return $Child/M.Child_Parent;", "MDL049", true}, + {"target entity named", "return $Child/M.Child_Parent/M.Parent;", "MDL049", false}, + {"loop over a path", "loop $p in $Child/M.Child_Tag begin\n log info node 'x' 'y';\n end loop;\n return empty;", "MDL094", true}, + } { + t.Run(c.name, func(t *testing.T) { + prog, errs := visitor.Build("mdl 1;\ncreate nanoflow M.N ($Child: M.Child)\nreturns M.Parent\nbegin\n " + c.body + "\nend;") + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + var got bool + for _, vi := range ValidateNanoflow(prog.Statements[0].(*ast.CreateNanoflowStmt)) { + got = got || vi.RuleID == c.rule + } + if got != c.want { + t.Errorf("%s fired=%v, want %v", c.rule, got, c.want) + } + }) + } +} diff --git a/mdl/executor/validate_detached_doc_comments.go b/mdl/executor/validate_detached_doc_comments.go index 7b13b34c4b..94a0bc65d8 100644 --- a/mdl/executor/validate_detached_doc_comments.go +++ b/mdl/executor/validate_detached_doc_comments.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "strings" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/linter" @@ -20,7 +21,8 @@ const detachedDocCommentRule = "MDL089" // wrote `drop microflow if exists X;` between six flows' comments and their // creates, and only lint's QUAL002 noticed the missing documentation. The // warning names the next statement that would have stored it, which is almost -// always the one it was meant for. +// always the one it was meant for — except after a create, which is itself the +// element the comment describes (mendixlabs/mxcli#1510). // // A warning, not an error, under every language version: the statement does // what it says, and only the comment is lost. @@ -30,7 +32,16 @@ func ValidateDetachedDocComments(prog *ast.Program) []linter.Violation { msg := fmt.Sprintf("line %d: the doc comment before `%s` is lost: that statement cannot store "+ "documentation, so the comment is ignored", d.Line, d.Statement) suggestion := "Move the doc comment above the statement it documents, or make it a `--` comment." - if d.Next != "" { + switch { + case isCreateHead(d.Statement): + // The comment is ON the element it describes, which simply has no + // documentation MDL can write. The next statement documents a + // different element, so pointing there would move text written for + // this one onto that one (mendixlabs/mxcli#1510). + msg = fmt.Sprintf("line %d: the doc comment before `%s` is lost: MDL cannot store documentation "+ + "for that element, so the comment is ignored", d.Line, d.Statement) + suggestion = "Make it a `--` comment, which keeps the note without implying it is stored." + case d.Next != "": msg += fmt.Sprintf("; the next statement that can take it is `%s` (line %d)", d.Next, d.NextLine) suggestion = fmt.Sprintf("Move the doc comment directly above `%s`, after the `%s`.", d.Next, d.Statement) } @@ -43,3 +54,9 @@ func ValidateDetachedDocComments(prog *ast.Program) []linter.Violation { } return out } + +// isCreateHead reports whether a statement head, as statementHead records it, +// is a create — the element the doc comment above it describes. +func isCreateHead(head string) bool { + return strings.HasPrefix(strings.ToLower(head), "create ") +} diff --git a/mdl/executor/validate_detached_doc_comments_test.go b/mdl/executor/validate_detached_doc_comments_test.go index c643d69295..949384ff28 100644 --- a/mdl/executor/validate_detached_doc_comments_test.go +++ b/mdl/executor/validate_detached_doc_comments_test.go @@ -43,3 +43,38 @@ func TestValidateProgram_DetachedDocComment(t *testing.T) { t.Errorf("a comment on its create was reported: %v", got) } } + +// mendixlabs/mxcli#1510: a doc comment above a create that stores none was +// told to move "directly above" the next create — documenting a different +// element with text written for this one. A drop or grant stands between a +// comment and its create; a create IS the element the comment describes, so +// the next statement is not where it belongs. Control: a drop still gets the +// move suggestion. +func TestValidateProgram_DetachedDocCommentOnCreate_Issue1510(t *testing.T) { + const src = "/** Sales staff. */\ncreate module role Sales.User;\n" + + "/** An order. */\ncreate persistent entity Sales.Order (Name: String(20));\n" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + var got []string + for _, v := range ValidateProgram(prog, "") { + if v.RuleID == detachedDocCommentRule { + got = append(got, v.Message+" | "+v.Suggestion) + } + } + if len(got) != 1 { + t.Fatalf("got %d MDL089 warnings, want 1: %v", len(got), got) + } + for _, bad := range []string{"Move the doc comment directly above", "next statement that can take it", + "create persistent entity Sales.Order"} { + if strings.Contains(got[0], bad) { + t.Errorf("the warning sends the comment to a different element (%q): %s", bad, got[0]) + } + } + for _, want := range []string{"create module role Sales.User", "`--` comment"} { + if !strings.Contains(got[0], want) { + t.Errorf("want %q in %q", want, got[0]) + } + } +} diff --git a/mdl/executor/validate_member_refs.go b/mdl/executor/validate_member_refs.go index 169b1950ab..7cbe774855 100644 --- a/mdl/executor/validate_member_refs.go +++ b/mdl/executor/validate_member_refs.go @@ -345,6 +345,37 @@ func validateMemberReferences(ctx *ExecContext, prog *ast.Program, sc *scriptCon func checkFlowMembers(ctx *ExecContext, sc *scriptContext, authored map[string]bool, flowQN string, params []ast.MicroflowParam, body []ast.MicroflowStatement) []string { + var out []string + walkTypedFlow(ctx, params, body, func(st ast.MicroflowStatement, vars map[string]string) { + switch s := st.(type) { + case *ast.CreateObjectStmt: + out = append(out, checkMembers(ctx, sc, authored, flowQN, + fmt.Sprintf("create %s", s.EntityType.String()), + s.EntityType.String(), s.Changes)...) + case *ast.ChangeObjectStmt: + out = append(out, checkMembers(ctx, sc, authored, flowQN, + fmt.Sprintf("change $%s", s.Variable), vars[s.Variable], s.Changes)...) + case *ast.RetrieveStmt: + if s.Variable != "" && s.Source.Name != "" && s.StartVariable == "" { + out = append(out, checkSortSystemMemberSpelling(ctx, sc, authored, flowQN, s.Source.String(), s.SortColumns)...) + } + } + }) + return out +} + +// walkTypedFlow calls visit for every statement of a flow body — nested blocks, +// loop bodies and custom error handlers included — with the entity each +// variable is known to hold at that point. visit runs before the statement's +// own output variable is typed, so a statement sees the variables it reads. +// +// It is the one variable-typing walk the --references checks share +// (checkFlowMembers, the Protected-module member check): two copies of it would +// drift on which statements type a variable, and a check that loses a variable +// goes silent rather than wrong. +func walkTypedFlow(ctx *ExecContext, params []ast.MicroflowParam, body []ast.MicroflowStatement, + visit func(st ast.MicroflowStatement, vars map[string]string)) { + vars := map[string]string{} for _, p := range params { if p.Name == "" { @@ -362,22 +393,15 @@ func checkFlowMembers(ctx *ExecContext, sc *scriptContext, authored map[string]b } } - var out []string var walk func(stmts []ast.MicroflowStatement) walk = func(stmts []ast.MicroflowStatement) { for _, st := range stmts { + visit(st, vars) switch s := st.(type) { case *ast.CreateObjectStmt: if s.Variable != "" { vars[s.Variable] = s.EntityType.String() } - out = append(out, checkMembers(ctx, sc, authored, flowQN, - fmt.Sprintf("create %s", s.EntityType.String()), - s.EntityType.String(), s.Changes)...) - case *ast.ChangeObjectStmt: - entityQN := vars[s.Variable] - out = append(out, checkMembers(ctx, sc, authored, flowQN, - fmt.Sprintf("change $%s", s.Variable), entityQN, s.Changes)...) case *ast.RetrieveStmt: // Source is the ENTITY for a database retrieve and the // ASSOCIATION for an association retrieve (StartVariable set). @@ -388,7 +412,6 @@ func checkFlowMembers(ctx *ExecContext, sc *scriptContext, authored map[string]b case s.Variable == "" || s.Source.Name == "": case s.StartVariable == "": vars[s.Variable] = s.Source.String() - out = append(out, checkSortSystemMemberSpelling(ctx, sc, authored, flowQN, s.Source.String(), s.SortColumns)...) default: if from, ok := vars[strings.TrimPrefix(s.StartVariable, "$")]; ok { if to, ok := associationTargetFrom(ctx, s.Source.String(), from); ok { @@ -417,7 +440,6 @@ func checkFlowMembers(ctx *ExecContext, sc *scriptContext, authored map[string]b } } walk(body) - return out } // checkMembers resolves each member of one CREATE / CHANGE. diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index f0592091c1..5284ac8374 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -127,6 +127,7 @@ func (v *microflowValidator) validate(body []ast.MicroflowStatement) { v.checkListOperationSource(body) v.checkMergeJoinLabels(body) v.checkAnnotationLabels(body) + v.checkAssociationPathValues(body) // Walk the body for per-statement checks (validation feedback, return value checks) v.emptyListVars = make(map[string]bool) diff --git a/mdl/executor/validate_nanoflow.go b/mdl/executor/validate_nanoflow.go index 0c2e1440da..137037ab43 100644 --- a/mdl/executor/validate_nanoflow.go +++ b/mdl/executor/validate_nanoflow.go @@ -56,6 +56,10 @@ func validateNanoflowWith(stmt *ast.CreateNanoflowStmt, voids *voidCodeActions) // MDL-FLOW05: a nanoflow's flow is built by the same builder, so a statement // after a return is dead there too (mendixlabs/mxcli#1491). v.checkUnreachableStatements(stmt.Body) + // MDL049 / MDL094: a bare association path returned, or a loop over a path. + // Measured on 11.12.1, a nanoflow's return is CE0117 as a microflow's is, + // and its loop is built from the same AST (mendixlabs/mxcli#199). + v.checkAssociationPathValues(stmt.Body) // The nanoflow restrictions exec's build refuses (validateNanoflow): an // action a nanoflow cannot hold, an error-handling clause its activity // rejects (CE6035), a Binary return. They ran only inside exec, so `check` diff --git a/mdl/executor/validate_page_button_context.go b/mdl/executor/validate_page_button_context.go index 2ec23c6bd5..5a6b6cdd6e 100644 --- a/mdl/executor/validate_page_button_context.go +++ b/mdl/executor/validate_page_button_context.go @@ -215,7 +215,7 @@ func controlBarSuggestion(controlBarOf string) string { return "Move the button into a grid column (row-scoped) so it has a current row, or pass a page parameter instead of $currentObject." } return fmt.Sprintf( - "Pass the selection of `%s` instead — `Action: microflow M.F(Param = $%s)`, with `Selection:` set on the widget. "+ + "Pass the selection of `%s` instead — `Param = $%s` in a microflow call or a show page, with `Selection:` set on the widget. "+ "Or move the button into a grid column (row-scoped) so it has a current row, or pass a page parameter.", controlBarOf, controlBarOf) } diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index 9e26be466d..6b269effe8 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -115,6 +115,11 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { violations = append(violations, ValidateFlowParameterAnnotations("microflow '"+mfStmt.Name.String()+"'", mfStmt.Parameters)...) } + // An alter fragment is a body too: an annotation its builder drops was + // refused in create and passed here (mendixlabs/mxcli#1597). + if afStmt, ok := stmt.(*ast.AlterFlowStmt); ok { + violations = append(violations, ValidateAlterFlow(afStmt)...) + } // Parameter annotations for the two flow flavours that do not go through // ValidateMicroflow but share the parameter grammar. if nfStmt, ok := stmt.(*ast.CreateNanoflowStmt); ok { diff --git a/mdl/executor/validate_protected_module_members.go b/mdl/executor/validate_protected_module_members.go new file mode 100644 index 0000000000..97f0fa5b60 --- /dev/null +++ b/mdl/executor/validate_protected_module_members.go @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// checkFlowMembers is the member half of CE1406 (mendixlabs/mxcli#1498): a flow +// in an ordinary module may not use a Hidden attribute or association of a +// Protected (add-on) module, even of a Usable entity. Measured on 11.14.0, one +// microflow per shape, each refused while the member is Hidden and accepted +// once it is Usable, and accepted in the module itself and in an ordinary one: +// +// change $r (Payload = …) Change object activity … attribute +// create AddOn.Request (Payload = …) Create object activity … attribute +// change / create (AddOn.Assoc = …) … association +// retrieve $x from $i/AddOn.Assoc Retrieve object(s) activity … association +// retrieve … where Payload = … Retrieve … attribute (and an association step) +// return $r/Payload, $i/AddOn.Assoc End event … attribute / association +// +// A member is reported only when it is established: its variable's entity is +// known, the member resolves on it (an association hop through +// associationTargetFrom), and its level is known — a level the script states, +// else the stored one, else Hidden for a member the script creates. Anything +// short of that is silent, because a false refusal blocks exec. +func (r *protectedRefResolver) checkFlowMembers(name ast.QualifiedName, params []ast.MicroflowParam, + body []ast.MicroflowStatement) []string { + + if r.protected[name.Module] { + return nil // a module uses its own Hidden members freely + } + var out []string + report := func(who string, m protectedMember, ok bool) { + if ok { + out = appendNonEmpty(out, r.memberRef(who, m)) + } + } + exprs := func(who string, vars map[string]string, es ...ast.Expression) { + for _, e := range es { + for _, p := range exprAttributePaths(e) { + for _, m := range r.pathMembers(vars[p.Variable], memberPathSteps(p)) { + report(who, m, true) + } + } + } + } + + walkTypedFlow(r.ctx, params, body, func(st ast.MicroflowStatement, vars map[string]string) { + switch s := st.(type) { + case *ast.CreateObjectStmt: + who := "create " + s.EntityType.String() + for _, ch := range s.Changes { + m, ok := r.changeMember(s.EntityType.String(), ch.Attribute) + report(who, m, ok) + exprs(who, vars, ch.Value) + } + case *ast.ChangeObjectStmt: + who := "change $" + s.Variable + for _, ch := range s.Changes { + m, ok := r.changeMember(vars[s.Variable], ch.Attribute) + report(who, m, ok) + exprs(who, vars, ch.Value) + } + case *ast.RetrieveStmt: + who := "retrieve $" + s.Variable + if s.StartVariable != "" { + report(who, protectedMember{kind: "association", qn: s.Source.String()}, s.Source.Module != "") + } else if s.Where != nil { + for _, steps := range xpathMemberPaths(s.Where) { + for _, m := range r.pathMembers(s.Source.String(), steps) { + report(who, m, true) + } + } + } + case *ast.ReturnStmt: + exprs("the end event", vars, s.Value) + case *ast.DeclareStmt: + exprs("declare $"+s.Variable, vars, s.InitialValue) + case *ast.MfSetStmt: + exprs("set "+s.Target, vars, s.Value) + case *ast.IfStmt: + exprs("the decision", vars, s.Condition) + case *ast.WhileStmt: + exprs("the while loop", vars, s.Condition) + case *ast.LogStmt: + exprs("the log message", vars, s.Node, s.Message) + for _, tp := range s.Template { + exprs("the log message", vars, tp.Value) + } + case *ast.CallMicroflowStmt: + for _, a := range s.Arguments { + exprs("call microflow "+s.MicroflowName.String(), vars, a.Value) + } + case *ast.CallNanoflowStmt: + for _, a := range s.Arguments { + exprs("call nanoflow "+s.NanoflowName.String(), vars, a.Value) + } + } + }) + return out +} + +// protectedMember is an attribute ("Module.Entity.Attr", with the entity that +// declares it) or an association ("Module.Assoc") a flow uses. +type protectedMember struct { + kind string // "attribute" or "association" + qn string + entity string // the declaring entity, for an attribute + attr string +} + +// changeMember is the member a CREATE / CHANGE item assigns on entityQN: an +// association when the name is `Module.Assoc` over a module, else an attribute +// of the entity or one it inherits. +func (r *protectedRefResolver) changeMember(entityQN, name string) (protectedMember, bool) { + name = strings.Trim(name, `"`) + if entityQN == "" || name == "" { + return protectedMember{}, false + } + if strings.Count(name, ".") == 1 { + return protectedMember{kind: "association", qn: name}, true + } + if strings.Contains(name, ".") { + return protectedMember{}, false // Module.Entity.Attribute: not modelled + } + return r.attributeMember(entityQN, name) +} + +// attributeMember resolves an attribute name on entityQN, walking its +// generalizations, to the entity that declares it. +func (r *protectedRefResolver) attributeMember(entityQN, attr string) (protectedMember, bool) { + chain := []string{entityQN} + if c, _ := generalizationChain(r.ctx, entityQN); len(c) > 0 { + chain = c + } + for _, qn := range chain { + if _, declared := r.scriptAttr[qn+"."+attr]; declared { + return protectedMember{kind: "attribute", qn: qn + "." + attr, entity: qn, attr: attr}, true + } + if _, ok := r.storedAttribute(qn, attr); ok { + return protectedMember{kind: "attribute", qn: qn + "." + attr, entity: qn, attr: attr}, true + } + } + return protectedMember{}, false +} + +// pathMembers resolves a member path from entityQN — the steps of `$v/A/B` or +// of an XPath path — to the members it uses: each association hop and the +// attribute it ends in. It stops at the first step it cannot resolve, so a path +// it does not understand contributes what it established up to there. +func (r *protectedRefResolver) pathMembers(entityQN string, steps []string) []protectedMember { + var out []protectedMember + cur := entityQN + for i := 0; i < len(steps) && cur != ""; i++ { + step := steps[i] + if !strings.Contains(step, ".") { + if m, ok := r.attributeMember(cur, step); ok { + out = append(out, m) + } + return out + } + out = append(out, protectedMember{kind: "association", qn: step}) + to, ok := associationTargetFrom(r.ctx, step, cur) + if !ok { + return out + } + cur = to + // XPath names the entity after a hop (`Assoc/Module.Entity/Attr`); an + // expression path does not. Either way the step is the target entity. + if i+1 < len(steps) && steps[i+1] == to { + i++ + } + } + return out +} + +// memberRef is the CE1406 report for m, or "" when m is not a Hidden member of +// a Protected module. +func (r *protectedRefResolver) memberRef(who string, m protectedMember) string { + module, _, _ := strings.Cut(m.qn, ".") + if !r.protected[module] { + return "" + } + var fix string + switch m.kind { + case "attribute": + if !r.attributeHidden(m.entity, m.attr) { + return "" + } + fix = fmt.Sprintf("alter entity %s modify attribute %s: %s export level api", + m.entity, m.attr, r.attributeTypeText(m.entity, m.attr)) + case "association": + if !r.associationHidden(m.qn) { + return "" + } + fix = fmt.Sprintf("alter association %s set export level api", m.qn) + default: + return "" + } + return fmt.Sprintf("CE1406: %s is not allowed to use %s '%s' because app modules %s — %s is a protected module "+ + "and the %s's export level is Hidden; make it usable with `%s`", + who, m.kind, m.qn, ce1406Tail, module, m.kind, fix) +} + +func (r *protectedRefResolver) attributeHidden(entityQN, attr string) bool { + stated, defined := r.scriptAttr[entityQN+"."+attr] + if stated != nil { + return *stated != types.ExportLevelAPI + } + if a, ok := r.storedAttribute(entityQN, attr); ok { + return a.ExportLevel != types.ExportLevelAPI + } + return defined +} + +func (r *protectedRefResolver) associationHidden(qn string) bool { + stated, defined := r.scriptAssoc[qn] + if stated != nil { + return *stated != types.ExportLevelAPI + } + if lvl, ok := r.storedAssociationLevel(qn); ok { + return lvl != types.ExportLevelAPI + } + return defined +} + +// attributeTypeText is the attribute's type as MDL spells it, for the hint — +// MODIFY ATTRIBUTE restates it. +func (r *protectedRefResolver) attributeTypeText(entityQN, attr string) string { + if a, ok := r.storedAttribute(entityQN, attr); ok && a.Type != nil { + return formatAttributeType(a.Type) + } + return "" +} + +func (r *protectedRefResolver) storedAttribute(entityQN, attr string) (*domainmodel.Attribute, bool) { + b, ok := r.ctx.Backend.(entityLookupBackend) + if !ok { + return nil, false + } + e, ok := findEntityByQN(b, entityQN) + if !ok { + return nil, false + } + for _, a := range e.Attributes { + if a != nil && a.Name == attr { + return a, true + } + } + return nil, false +} + +func (r *protectedRefResolver) storedAssociationLevel(qn string) (string, bool) { + b, ok := r.ctx.Backend.(entityLookupBackend) + if !ok { + return "", false + } + modName, name, found := strings.Cut(qn, ".") + if !found { + return "", false + } + mod, err := b.GetModuleByName(modName) + if err != nil || mod == nil { + return "", false + } + dm, err := b.GetDomainModel(mod.ID) + if err != nil || dm == nil { + return "", false + } + for _, a := range dm.Associations { + if a != nil && a.Name == name { + return a.ExportLevel, true + } + } + for _, a := range dm.CrossAssociations { + if a != nil && a.Name == name { + return a.ExportLevel, true + } + } + return "", false +} + +// exprAttributePaths collects every `$var/…` path in an expression. +func exprAttributePaths(e ast.Expression) []*ast.AttributePathExpr { + var out []*ast.AttributePathExpr + var walk func(ast.Expression) + walk = func(e ast.Expression) { + switch x := e.(type) { + case *ast.AttributePathExpr: + out = append(out, x) + case *ast.BinaryExpr: + walk(x.Left) + walk(x.Right) + case *ast.UnaryExpr: + walk(x.Operand) + case *ast.FunctionCallExpr: + for _, a := range x.Arguments { + walk(a) + } + case *ast.ParenExpr: + walk(x.Inner) + case *ast.IfThenElseExpr: + walk(x.Condition) + walk(x.ThenExpr) + walk(x.ElseExpr) + case *ast.SourceExpr: + walk(x.Expression) + } + } + walk(e) + return out +} + +func memberPathSteps(p *ast.AttributePathExpr) []string { + if len(p.Path) > 0 { + return p.Path + } + steps := make([]string, 0, len(p.Segments)) + for _, sg := range p.Segments { + steps = append(steps, sg.Name) + } + return steps +} + +// xpathMemberPaths collects the member paths of an XPath constraint relative to +// the retrieved entity: a bare attribute, an association, and `/` chains of them +// (`Mod.Assoc/Mod.Entity/Attr`), through and/or, not() and parentheses. A path +// that starts at a variable (`$v/…`) is not relative to the entity and is left +// out. +func xpathMemberPaths(e ast.Expression) [][]string { + var out [][]string + var walk func(ast.Expression) + walk = func(e ast.Expression) { + switch x := e.(type) { + case *ast.SourceExpr: + walk(x.Expression) + case *ast.ParenExpr: + walk(x.Inner) + case *ast.UnaryExpr: + walk(x.Operand) + case *ast.FunctionCallExpr: + for _, a := range x.Arguments { + walk(a) + } + case *ast.BinaryExpr: + if x.Operator == "/" { + if steps, ok := xpathSteps(x); ok { + out = append(out, steps) + } + return + } + walk(x.Left) + walk(x.Right) + case *ast.IdentifierExpr: + out = append(out, []string{x.Name}) + case *ast.QualifiedNameExpr: + out = append(out, []string{x.QualifiedName.String()}) + } + } + walk(e) + return out +} + +// xpathSteps flattens a left-nested `/` chain of names into its steps. +func xpathSteps(e ast.Expression) ([]string, bool) { + switch x := e.(type) { + case *ast.BinaryExpr: + if x.Operator != "/" { + return nil, false + } + left, ok := xpathSteps(x.Left) + if !ok { + return nil, false + } + right, ok := xpathSteps(x.Right) + if !ok { + return nil, false + } + return append(left, right...), true + case *ast.IdentifierExpr: + return []string{x.Name}, true + case *ast.QualifiedNameExpr: + return []string{x.QualifiedName.String()}, true + } + return nil, false +} diff --git a/mdl/executor/validate_protected_module_refs.go b/mdl/executor/validate_protected_module_refs.go index c34279d2fd..9744c830c3 100644 --- a/mdl/executor/validate_protected_module_refs.go +++ b/mdl/executor/validate_protected_module_refs.go @@ -19,8 +19,8 @@ import ( // - CE1406: nothing outside the module may reference a Hidden element in it. // mxbuild words the source "project level documents" for navigation and // "app modules" for a document in an ordinary module. -// - CE5400: a Usable microflow in the module may not expose a Hidden entity of -// it through a parameter or its return type. +// - CE5400: a Usable microflow in the module may not expose a Hidden entity or +// enumeration of it through a parameter or its return type. // // Measured on blank 11.14.0 and 11.13.0 apps, a control beside each refusal: // a home microflow, menu item, call microflow and parameter type are refused @@ -28,11 +28,13 @@ import ( // every one. A PAGE is refused even with ExportLevel "API" stored on it — from // navigation and from a show page activity alike — so a page in an add-on // module is reported whatever it stores; an API microflow inside the module -// that shows it is the measured way in. +// that shows it is the measured way in. The enumeration arm of CE5400 was +// measured on 11.14.0 the same way (mendixlabs/mxcli#1498 follow-up). // // Covered shapes: navigation home pages, home flows, login / not-found pages and // menu items; menu documents; microflow and nanoflow parameter and return -// types, call microflow and show page in their bodies. Everything else is left +// types, call microflow and show page in their bodies; and the attributes and +// associations a flow uses (checkFlowMembers). Everything else is left // alone, and so is anything whose level cannot be established — settings that // do not read, a target that does not resolve (the not-found checks own that), // a nanoflow target (mxcli does not read a nanoflow's export level), or a @@ -49,7 +51,10 @@ func validateProtectedModuleRefs(ctx *ExecContext, prog *ast.Program) []error { ctx: ctx, protected: protected, scriptFlows: map[string]*string{}, - scriptEntity: map[string]bool{}, + scriptEntity: map[string]*string{}, + scriptEnum: map[string]*string{}, + scriptAttr: map[string]*string{}, + scriptAssoc: map[string]*string{}, scriptPages: map[string]bool{}, entityLevels: map[string]map[string]string{}, } @@ -128,13 +133,18 @@ type protectedRefResolver struct { protected map[string]bool // What the script defines: a flow's stated export level (nil = not stated), - // the entities and pages it creates. + // the entities and enumerations it creates or sets a level on with the same + // convention, and the pages it creates. scriptFlows map[string]*string - scriptEntity map[string]bool + scriptEntity map[string]*string + scriptEnum map[string]*string + scriptAttr map[string]*string // "Module.Entity.Attribute" + scriptAssoc map[string]*string // "Module.Association" scriptPages map[string]bool storedPages map[string]bool entityLevels map[string]map[string]string // module -> entity -> stored level; nil = unreadable + enumLevels map[string]string // qualified name -> stored level; nil = not read yet } func (r *protectedRefResolver) recordDefinition(stmt ast.Statement) { @@ -142,12 +152,52 @@ func (r *protectedRefResolver) recordDefinition(stmt ast.Statement) { case *ast.CreateMicroflowStmt: r.scriptFlows[s.Name.String()] = s.ExportLevel case *ast.CreateEntityStmt: - r.scriptEntity[s.Name.String()] = true + recordLevel(r.scriptEntity, s.Name.String(), s.ExportLevel) + for _, a := range s.Attributes { + recordLevel(r.scriptAttr, s.Name.String()+"."+a.Name, a.ExportLevel) + } + case *ast.CreateViewEntityStmt: + recordLevel(r.scriptEntity, s.Name.String(), s.ExportLevel) + for _, a := range s.Attributes { + recordLevel(r.scriptAttr, s.Name.String()+"."+a.Name, a.ExportLevel) + } + case *ast.AlterEntityStmt: + switch { + case s.Operation == ast.AlterEntitySetExportLevel: + lvl := s.ExportLevel + r.scriptEntity[s.Name.String()] = &lvl + case s.Operation == ast.AlterEntityAddAttribute && s.Attribute != nil: + recordLevel(r.scriptAttr, s.Name.String()+"."+s.Attribute.Name, s.Attribute.ExportLevel) + case s.Operation == ast.AlterEntityModifyAttribute && s.ModifyExportLevel != nil: + r.scriptAttr[s.Name.String()+"."+s.AttributeName] = s.ModifyExportLevel + } + case *ast.CreateAssociationStmt: + recordLevel(r.scriptAssoc, s.Name.String(), s.ExportLevel) + case *ast.AlterAssociationStmt: + if s.Operation == ast.AlterAssociationSetExportLevel { + lvl := s.ExportLevel + r.scriptAssoc[s.Name.String()] = &lvl + } + case *ast.CreateEnumerationStmt: + recordLevel(r.scriptEnum, s.Name.String(), s.ExportLevel) + case *ast.AlterEnumerationStmt: + if s.Operation == ast.AlterEnumSetExportLevel { + lvl := s.ExportLevel + r.scriptEnum[s.Name.String()] = &lvl + } case *ast.CreatePageStmtV3: r.scriptPages[s.Name.String()] = true } } +// recordLevel notes that the script defines qn, with the level it states. A +// level stated anywhere in the script wins over an unstated definition. +func recordLevel(m map[string]*string, qn string, lvl *string) { + if lvl != nil || m[qn] == nil { + m[qn] = lvl + } +} + func (r *protectedRefResolver) check(stmt ast.Statement) []string { if v := menuAlterView(stmt); v != nil { stmt = v // what an ALTER NAVIGATION / ALTER MENU states @@ -161,11 +211,13 @@ func (r *protectedRefResolver) check(stmt ast.Statement) []string { } return r.checkMenuItems(s.Items, "app modules") case *ast.CreateMicroflowStmt: - return r.checkFlow(s.Name, s.Parameters, s.ReturnType, s.Body, effectiveFlowLevel(r, s)) + return append(r.checkFlow(s.Name, s.Parameters, s.ReturnType, s.Body, effectiveFlowLevel(r, s)), + r.checkFlowMembers(s.Name, s.Parameters, s.Body)...) case *ast.CreateNanoflowStmt: // A nanoflow's own export level is not read, so only its references out // of an ordinary module are checked, never CE5400 on it. - return r.checkFlow(s.Name, s.Parameters, s.ReturnType, s.Body, "") + return append(r.checkFlow(s.Name, s.Parameters, s.ReturnType, s.Body, ""), + r.checkFlowMembers(s.Name, s.Parameters, s.Body)...) } return nil } @@ -231,15 +283,18 @@ func (r *protectedRefResolver) checkFlow(name ast.QualifiedName, params []ast.Mi if level != types.ExportLevelAPI { return nil } - for _, p := range params { - if e, ok := signatureEntity(p.Type); ok && e.Module == name.Module && r.entityHidden(e) { - out = append(out, ce5400(e, fmt.Sprintf("parameter '%s'", p.Name))) + exposes := func(dt ast.DataType, exposer string) { + if e, ok := signatureEntity(dt); ok && e.Module == name.Module && r.entityHidden(e) { + out = append(out, ce5400("entity", e, exposer)) + } else if e, ok := signatureEnumeration(dt); ok && e.Module == name.Module && r.enumHidden(e) { + out = append(out, ce5400("enumeration", e, exposer)) } } + for _, p := range params { + exposes(p.Type, fmt.Sprintf("parameter '%s'", p.Name)) + } if ret != nil { - if e, ok := signatureEntity(ret.Type); ok && e.Module == name.Module && r.entityHidden(e) { - out = append(out, ce5400(e, fmt.Sprintf("microflow '%s'", name.String()))) - } + exposes(ret.Type, fmt.Sprintf("microflow '%s'", name.String())) } return out } @@ -307,15 +362,17 @@ func (r *protectedRefResolver) entityRef(who string, qn ast.QualifiedName, src s return "" } return fmt.Sprintf("CE1406: %s is not allowed to use entity '%s' because %s %s — %s is a protected module "+ - "and the entity's export level is Hidden (mxcli cannot change an entity's export level; set it to Usable in Studio Pro)", - who, qn.String(), src, ce1406Tail, qn.Module) + "and the entity's export level is Hidden; make it usable with `alter entity %s set export level api`", + who, qn.String(), src, ce1406Tail, qn.Module, qn.String()) } -func ce5400(entity ast.QualifiedName, exposer string) string { - return fmt.Sprintf("CE5400: the export level of entity '%s' should be 'Usable' because %s exposes it and has export level 'Usable' "+ - "(%s is a protected module and the entity is Hidden; mxcli cannot change an entity's export level — set it to Usable in Studio Pro, "+ - "or drop `export level api` from the microflow)", - entity.String(), exposer, entity.Module) +// ce5400 words mxbuild's message, kind being "entity" or "enumeration", and +// names the MDL that makes the element Usable. +func ce5400(kind string, qn ast.QualifiedName, exposer string) string { + return fmt.Sprintf("CE5400: the export level of %s '%s' should be 'Usable' because %s exposes it and has export level 'Usable' "+ + "(%s is a protected module and the %s is Hidden) — make it usable with `alter %s %s set export level api` "+ + "(or `export level api` on its create statement), or drop `export level api` from the microflow", + kind, qn.String(), exposer, qn.Module, kind, kind, qn.String()) } // flowLevel is a microflow's effective export level: what the script states, @@ -352,14 +409,48 @@ func (r *protectedRefResolver) storedMicroflowLevel(qn ast.QualifiedName) (strin return "", false } -// entityHidden reports whether an entity is known to be Hidden: a stored one -// whose level is not API, or one the script creates (always written Hidden, -// a rewrite carrying the stored level). +// entityHidden reports whether an entity is known to be Hidden once the script +// has run: the level the script states for it (on its create statement or by +// `alter entity … set export level`), else the stored level, else Hidden for one +// the script creates — the level an absent clause writes, a rewrite of a stored +// entity carrying the stored level instead. func (r *protectedRefResolver) entityHidden(qn ast.QualifiedName) bool { + stated, defined := r.scriptEntity[qn.String()] + if stated != nil { + return *stated != types.ExportLevelAPI + } if lvl, ok := r.storedEntityLevel(qn); ok { return lvl != types.ExportLevelAPI } - return r.scriptEntity[qn.String()] + return defined +} + +// enumHidden is entityHidden for an enumeration. +func (r *protectedRefResolver) enumHidden(qn ast.QualifiedName) bool { + stated, defined := r.scriptEnum[qn.String()] + if stated != nil { + return *stated != types.ExportLevelAPI + } + if lvl, ok := r.storedEnumLevel(qn); ok { + return lvl != types.ExportLevelAPI + } + return defined +} + +func (r *protectedRefResolver) storedEnumLevel(qn ast.QualifiedName) (string, bool) { + if r.enumLevels == nil { + r.enumLevels = map[string]string{} + h, herr := getHierarchy(r.ctx) + enums, err := r.ctx.Backend.ListEnumerations() + if herr == nil && err == nil { + for _, e := range enums { + // "" is written as Hidden (enumToGen) and read so by mxbuild. + r.enumLevels[h.GetModuleName(h.FindModuleID(e.ContainerID))+"."+e.Name] = e.ExportLevel + } + } + } + lvl, ok := r.enumLevels[qn.String()] + return lvl, ok } func (r *protectedRefResolver) storedEntityLevel(qn ast.QualifiedName) (string, bool) { @@ -403,6 +494,16 @@ func signatureEntity(dt ast.DataType) (ast.QualifiedName, bool) { return ast.QualifiedName{}, false } +// signatureEnumeration is the enumeration a parameter or return type names, if +// any: `enum M.E`, or a bare `M.E`, which resolves only if an enumeration has +// that name. +func signatureEnumeration(dt ast.DataType) (ast.QualifiedName, bool) { + if dt.Kind == ast.TypeEnumeration && dt.EnumRef != nil { + return *dt.EnumRef, true + } + return ast.QualifiedName{}, false +} + func parseQN(s string) ast.QualifiedName { for i := len(s) - 1; i >= 0; i-- { if s[i] == '.' { diff --git a/mdl/executor/validate_protected_module_refs_test.go b/mdl/executor/validate_protected_module_refs_test.go index 4124e3eb23..bf5decf230 100644 --- a/mdl/executor/validate_protected_module_refs_test.go +++ b/mdl/executor/validate_protected_module_refs_test.go @@ -42,15 +42,36 @@ func protectedModuleFixture(t *testing.T, settingsErr error) *ExecContext { mf(addOn, "API_MF", "API"), mf(open, "MF", "Hidden"), } + // Members, for the CE1406 member cases: Pub and Pub2 are Usable entities + // with one Usable and one Hidden attribute each, joined by a Hidden and a + // Usable association. Open.E holds a Hidden attribute too — the control. + member := func(name, level string) *domainmodel.Attribute { + return &domainmodel.Attribute{BaseElement: model.BaseElement{ID: nextID("attr")}, Name: name, + Type: &domainmodel.StringAttributeType{Length: 10}, ExportLevel: level} + } + pub, pub2 := ent(addOn, "Pub", "API"), ent(addOn, "Pub2", "API") + pub.Attributes = []*domainmodel.Attribute{member("Shown", "API"), member("Inner", "Hidden")} + pub2.Attributes = []*domainmodel.Attribute{member("Visible", "API"), member("Hid", "Hidden")} + openE := ent(open, "E", "Hidden") + openE.Attributes = []*domainmodel.Attribute{member("Inner", "Hidden")} + addOnDM := mkDomainModel(addOn.ID, ent(addOn, "Request", "Hidden"), ent(addOn, "Secret", "Hidden"), pub, pub2) + hiddenAssoc := mkAssociation(addOn.ID, "Pub_Pub2", pub.ID, pub2.ID) + hiddenAssoc.ExportLevel = "Hidden" + apiAssoc := mkAssociation(addOn.ID, "Pub_Pub2Api", pub.ID, pub2.ID) + apiAssoc.ExportLevel = "API" + addOnDM.Associations = []*domainmodel.Association{hiddenAssoc, apiAssoc} dms := map[model.ID]*domainmodel.DomainModel{ - addOn.ID: mkDomainModel(addOn.ID, - ent(addOn, "Request", "Hidden"), - ent(addOn, "Secret", "Hidden"), - ent(addOn, "Pub", "API")), - open.ID: mkDomainModel(open.ID, ent(open, "E", "Hidden")), - app.ID: mkDomainModel(app.ID), + addOn.ID: addOnDM, + open.ID: mkDomainModel(open.ID, openE), + app.ID: mkDomainModel(app.ID), } pgs := []*pages.Page{mkPage(addOn.ID, "Home_Page"), mkPage(open.ID, "P"), mkPage(app.ID, "Home_Web")} + enum := func(mod *model.Module, name, level string) *model.Enumeration { + e := mkEnumeration(mod.ID, name, "A") + e.ExportLevel = level + return e + } + enums := []*model.Enumeration{enum(addOn, "Kind", "Hidden"), enum(addOn, "PubKind", "API"), enum(open, "Kind", "Hidden")} mods := []*model.Module{app, addOn, open} ctx, _ := newMockCtx(t, withBackend(&mock.MockBackend{ @@ -66,9 +87,21 @@ func protectedModuleFixture(t *testing.T, settingsErr error) *ExecContext { {ContainerID: open.ID, ExportLevel: "Source"}, }, nil }, - ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return flows, nil }, - ListPagesFunc: func() ([]*pages.Page, error) { return pgs, nil }, - GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) { return dms[id], nil }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return flows, nil }, + ListPagesFunc: func() ([]*pages.Page, error) { return pgs, nil }, + ListEnumerationsFunc: func() ([]*model.Enumeration, error) { return enums, nil }, + GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) { return dms[id], nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{dms[app.ID], dms[addOn.ID], dms[open.ID]}, nil + }, + GetModuleByNameFunc: func(name string) (*model.Module, error) { + for _, m := range mods { + if m.Name == name { + return m, nil + } + } + return nil, nil + }, }), withHierarchy(mkHierarchy(mods...))) return ctx } @@ -139,6 +172,103 @@ func TestValidateProtectedModuleRefs(t *testing.T) { "create non-persistent entity MyAddOn.Fresh (X: String(10));\ncreate microflow MyAddOn.API_F ($f: MyAddOn.Fresh) returns Boolean export level api begin return true; end;", []string{"statement 2", "CE5400", "entity 'MyAddOn.Fresh'"}}, + // --- mendixlabs/mxcli#1498 follow-up: the level MDL can now state --- + {"script entity with export level api", + "create non-persistent entity MyAddOn.Fresh (X: String(10)) export level api;\ncreate microflow MyAddOn.API_F ($f: MyAddOn.Fresh) returns Boolean export level api begin return true; end;", nil}, + {"stored hidden entity rewritten with export level api", + "create or modify non-persistent entity MyAddOn.Request (Payload: String(200)) export level api;\ncreate microflow MyAddOn.API_R ($r: MyAddOn.Request) returns Boolean export level api begin return true; end;", nil}, + {"stored hidden entity made usable by alter", + "alter entity MyAddOn.Request set export level api;\ncreate microflow MyAddOn.API_R ($r: MyAddOn.Request) returns Boolean export level api begin return true; end;", nil}, + {"stored usable entity made hidden by alter", + "alter entity MyAddOn.Pub set export level hidden;\ncreate microflow MyAddOn.API_P ($p: MyAddOn.Pub) returns Boolean export level api begin return true; end;", + []string{"statement 2", "CE5400", "entity 'MyAddOn.Pub'"}}, + {"rewrite without the clause keeps the stored usable level", + "create or modify non-persistent entity MyAddOn.Pub (X: String(10));\ncreate microflow MyAddOn.API_P ($p: MyAddOn.Pub) returns Boolean export level api begin return true; end;", nil}, + {"the hint names the MDL fix", + "create microflow MyAddOn.API_Request_Post ($body: MyAddOn.Request) returns Boolean export level api begin return true; end;", + []string{"CE5400", "alter entity MyAddOn.Request set export level api"}}, + {"api microflow, hidden enumeration parameter (measured 11.14.0)", + "create microflow MyAddOn.API_Kind ($k: enum MyAddOn.Kind) returns Boolean export level api begin return true; end;", + []string{"CE5400", "enumeration 'MyAddOn.Kind' should be 'Usable' because parameter 'k' exposes it", "alter enumeration MyAddOn.Kind set export level api"}}, + {"api microflow, hidden enumeration return type", + "create microflow MyAddOn.API_GetKind () returns enum MyAddOn.Kind export level api begin return empty; end;", + []string{"CE5400", "enumeration 'MyAddOn.Kind'", "microflow 'MyAddOn.API_GetKind'"}}, + {"api microflow, usable enumeration", + "create microflow MyAddOn.API_Kind ($k: enum MyAddOn.PubKind) returns Boolean export level api begin return true; end;", nil}, + {"script enumeration is hidden", + "create enumeration MyAddOn.New (A 'A');\ncreate microflow MyAddOn.API_N ($k: enum MyAddOn.New) returns Boolean export level api begin return true; end;", + []string{"statement 2", "CE5400", "enumeration 'MyAddOn.New'"}}, + {"script enumeration with export level api", + "create enumeration MyAddOn.New (A 'A') export level api;\ncreate microflow MyAddOn.API_N ($k: enum MyAddOn.New) returns Boolean export level api begin return true; end;", nil}, + {"stored hidden enumeration made usable by alter", + "alter enumeration MyAddOn.Kind set export level api;\ncreate microflow MyAddOn.API_K ($k: enum MyAddOn.Kind) returns Boolean export level api begin return true; end;", nil}, + {"open module api microflow, hidden enumeration", + "create microflow Open.API_K ($k: enum Open.Kind) returns Boolean export level api begin return true; end;", nil}, + + // --- CE1406 on a Hidden MEMBER, from an app module (measured 11.14.0, + // one microflow per shape; the Usable member beside each is the control) + {"change hidden attribute", + "create microflow MyFirstModule.C ($p: MyAddOn.Pub) begin change $p (Inner = 'x'); end;", + []string{"CE1406", "change $p is not allowed to use attribute 'MyAddOn.Pub.Inner'", "app modules cannot reference hidden elements", + "alter entity MyAddOn.Pub modify attribute Inner: String(10) export level api"}}, + {"change usable attribute", "create microflow MyFirstModule.C ($p: MyAddOn.Pub) begin change $p (Shown = 'x'); end;", nil}, + {"create with hidden attribute", + "create microflow MyFirstModule.C () begin $n = create MyAddOn.Pub (Inner = 'x'); end;", + []string{"CE1406", "create MyAddOn.Pub is not allowed to use attribute 'MyAddOn.Pub.Inner'"}}, + {"change sets hidden association", + "create microflow MyFirstModule.C ($p: MyAddOn.Pub, $q: MyAddOn.Pub2) begin change $p (MyAddOn.Pub_Pub2 = $q); end;", + []string{"CE1406", "association 'MyAddOn.Pub_Pub2'", "alter association MyAddOn.Pub_Pub2 set export level api"}}, + {"change sets usable association", + "create microflow MyFirstModule.C ($p: MyAddOn.Pub, $q: MyAddOn.Pub2) begin change $p (MyAddOn.Pub_Pub2Api = $q); end;", nil}, + {"retrieve over hidden association", + "create microflow MyFirstModule.C ($p: MyAddOn.Pub) begin retrieve $q from $p/MyAddOn.Pub_Pub2; end;", + []string{"CE1406", "retrieve $q is not allowed to use association 'MyAddOn.Pub_Pub2'"}}, + {"retrieve over usable association", + "create microflow MyFirstModule.C ($p: MyAddOn.Pub) begin retrieve $q from $p/MyAddOn.Pub_Pub2Api; end;", nil}, + {"xpath on hidden attribute", + "create microflow MyFirstModule.C () begin retrieve $l from MyAddOn.Pub where Inner = 'x'; end;", + []string{"CE1406", "attribute 'MyAddOn.Pub.Inner'"}}, + {"xpath on usable attribute", "create microflow MyFirstModule.C () begin retrieve $l from MyAddOn.Pub where Shown = 'x'; end;", nil}, + {"xpath on hidden association", + "create microflow MyFirstModule.C ($p: MyAddOn.Pub) begin retrieve $l from MyAddOn.Pub2 where MyAddOn.Pub_Pub2 = $p; end;", + []string{"CE1406", "association 'MyAddOn.Pub_Pub2'"}}, + {"xpath path to hidden attribute over usable association", + "create microflow MyFirstModule.C () begin retrieve $l from MyAddOn.Pub where MyAddOn.Pub_Pub2Api/MyAddOn.Pub2/Hid = 'x'; end;", + []string{"CE1406", "attribute 'MyAddOn.Pub2.Hid'"}}, + {"expression reads hidden attribute", + "create microflow MyFirstModule.C ($p: MyAddOn.Pub) returns String begin return $p/Inner; end;", + []string{"CE1406", "attribute 'MyAddOn.Pub.Inner'"}}, + {"expression reads usable attribute", + "create microflow MyFirstModule.C ($p: MyAddOn.Pub) returns String begin return $p/Shown; end;", nil}, + {"expression over usable association to hidden attribute", + "create microflow MyFirstModule.C ($p: MyAddOn.Pub) returns String begin return $p/MyAddOn.Pub_Pub2Api/Hid; end;", + []string{"CE1406", "attribute 'MyAddOn.Pub2.Hid'"}}, + {"expression over hidden association", + "create microflow MyFirstModule.C ($p: MyAddOn.Pub) returns Boolean begin return $p/MyAddOn.Pub_Pub2 != empty; end;", + []string{"CE1406", "association 'MyAddOn.Pub_Pub2'"}}, + {"member in a nested block and a loop", + "create microflow MyFirstModule.C () begin retrieve $all from MyAddOn.Pub; loop $x in $all begin if true then change $x (Inner = 'x'); end if; end loop; end;", + []string{"CE1406", "attribute 'MyAddOn.Pub.Inner'"}}, + {"script makes the attribute usable", + "alter entity MyAddOn.Pub modify attribute Inner: String(10) export level api;\ncreate microflow MyFirstModule.C ($p: MyAddOn.Pub) begin change $p (Inner = 'x'); end;", nil}, + {"script makes the association usable", + "alter association MyAddOn.Pub_Pub2 set export level api;\ncreate microflow MyFirstModule.C ($p: MyAddOn.Pub) begin retrieve $q from $p/MyAddOn.Pub_Pub2; end;", nil}, + {"script hides a usable attribute", + "alter entity MyAddOn.Pub modify attribute Shown: String(10) export level hidden;\ncreate microflow MyFirstModule.C ($p: MyAddOn.Pub) begin change $p (Shown = 'x'); end;", + []string{"statement 2", "CE1406", "attribute 'MyAddOn.Pub.Shown'"}}, + {"script-created attribute is hidden", + "create persistent entity MyAddOn.Fresh (X: String(10)) export level api;\ncreate microflow MyFirstModule.C ($f: MyAddOn.Fresh) begin change $f (X = 'x'); end;", + []string{"statement 2", "CE1406", "attribute 'MyAddOn.Fresh.X'"}}, + {"script-created attribute with export level api", + "create persistent entity MyAddOn.Fresh (X: String(10) export level api) export level api;\ncreate microflow MyFirstModule.C ($f: MyAddOn.Fresh) begin change $f (X = 'x'); end;", nil}, + {"script-created association is hidden", + "create association MyAddOn.Pub_Fresh from MyAddOn.Pub to MyAddOn.Pub2;\ncreate microflow MyFirstModule.C ($p: MyAddOn.Pub, $q: MyAddOn.Pub2) begin change $p (MyAddOn.Pub_Fresh = $q); end;", + []string{"statement 2", "CE1406", "association 'MyAddOn.Pub_Fresh'"}}, + {"inside the add-on a hidden member is fine", + "create microflow MyAddOn.Inner ($p: MyAddOn.Pub) begin change $p (Inner = 'x'); retrieve $q from $p/MyAddOn.Pub_Pub2; end;", nil}, + {"open module hidden attribute", "create microflow MyFirstModule.C ($e: Open.E) begin change $e (Inner = 'x'); end;", nil}, + {"untyped variable is silent", "create microflow MyFirstModule.C () begin change $nobody (Inner = 'x'); end;", nil}, + // --- controls: an ordinary module hides nothing --- {"open module page", "create or modify navigation Responsive home page Open.P;", nil}, {"open module hidden microflow", "create microflow MyFirstModule.C () begin call microflow Open.MF(); end;", nil}, diff --git a/mdl/executor/validate_retrieve_members.go b/mdl/executor/validate_retrieve_members.go index dd0e05661c..8d735d0c67 100644 --- a/mdl/executor/validate_retrieve_members.go +++ b/mdl/executor/validate_retrieve_members.go @@ -22,7 +22,7 @@ import ( // [CreatedDate > '[%CurrentDateTime%]'] CE0161 (the spelling describe prints) // [createdDate > '[%CurrentDateTime%]'] clean // -// The resolution is unresolvableXPathSteps, the widget check's walk, over the +// The resolution is unresolvableXPathStepsWith, the widget check's walk, over the // constraint exactly as the flow builder stores it (retrieveXPathConstraint), so // what is checked is what is written. Silence where the entity cannot be // established, as everywhere in that walk. diff --git a/mdl/executor/validate_snippetcall_appearance.go b/mdl/executor/validate_snippetcall_appearance.go new file mode 100644 index 0000000000..54461ec0fd --- /dev/null +++ b/mdl/executor/validate_snippetcall_appearance.go @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/model" +) + +// A snippet call renders no element of its own (mendixlabs/mxcli#1511). +// +// The snippet's widgets are inlined into the parent, so the Class, Style, +// DynamicClasses and design properties a Forms$SnippetCallWidget carries have +// nothing to land on. Measured on Mendix 11.15.0 with `run --local` and +// `playwright check`: a snippetcall carrying Class 'probe-x', Style +// 'border: 3px solid red', DynamicClasses 'probe-dyn' and the design properties +// 'Align self': 'Right' / 'Hide on': Tablet produced no element with any of +// them — the snippet's own container was a DIRECT child of the enclosing +// container — while a container on the same page with the same four settings +// carried every one. check, exec and mx check (0 errors) were all clean. +// +// A warning, not an error: the model is valid and Studio Pro stores it, the +// setting is only useless. The lint rule MPR013 reports the same for a project +// already holding such a call. + +const snippetCallAppearanceRule = "MDL-WIDGET48" + +const snippetCallAppearanceSuggestion = "Put the class or style on a container inside the snippet, " + + "or wrap the snippet call in a container and style that container." + +// snippetCallAppearanceMessage is the one wording the CREATE and the stored +// (SET / ALTER STYLING) paths share. +func snippetCallAppearanceMessage(widget string, fields []string) string { + return fmt.Sprintf("snippet call `%s` sets %s, but a snippet call renders no element of its own "+ + "(the snippet's widgets are inlined into the parent), so the setting never reaches the page", + widget, strings.Join(fields, ", ")) +} + +// validateSnippetCallAppearance reports a snippetcall the script writes with +// appearance settings — CREATE PAGE / SNIPPET and ALTER PAGE INSERT / REPLACE. +func validateSnippetCallAppearance(w *ast.WidgetV3, locationPrefix string) []linter.Violation { + if w == nil || !strings.EqualFold(w.Type, "snippetcall") { + return nil + } + var fields []string + if strings.TrimSpace(w.GetClass()) != "" { + fields = append(fields, "Class") + } + if strings.TrimSpace(w.GetStyle()) != "" { + fields = append(fields, "Style") + } + if strings.TrimSpace(w.GetDynamicClasses()) != "" { + fields = append(fields, "DynamicClasses") + } + for _, dp := range w.GetDesignProperties() { + fields = append(fields, fmt.Sprintf("design property '%s'", dp.Key)) + } + if len(fields) == 0 { + return nil + } + return []linter.Violation{{ + RuleID: snippetCallAppearanceRule, + Severity: linter.SeverityWarning, + Message: locationPrefix + ": " + snippetCallAppearanceMessage(w.Name, fields), + Suggestion: snippetCallAppearanceSuggestion, + }} +} + +// validateStoredSnippetCallAppearance is the same finding for the statements +// that name a widget already STORED — `alter page … set (Class: …) on sc` (the +// reported case) and `alter styling … widget sc set …`. Whether the target is +// a snippet call lives only in the document, so this runs in the reference pass +// and adds warnings; it never blocks. A document that will not open, or a +// target that does not resolve, is silent: those are other passes' findings. +func validateStoredSnippetCallAppearance(ctx *ExecContext, prog *ast.Program, sc *scriptContext) { + if prog == nil || sc == nil || !ctx.Connected() { + return + } + h, err := getHierarchy(ctx) + if err != nil || h == nil { + return + } + opened := map[model.ID]backend.PageMutator{} + open := func(unitID model.ID) backend.PageMutator { + m, seen := opened[unitID] + if !seen { + m, _ = ctx.Backend.OpenPageForMutation(unitID) + opened[unitID] = m + } + return m + } + + for _, stmt := range prog.Statements { + switch s := stmt.(type) { + case *ast.AlterPageStmt: + if !hasSetPropertyOp(s) || alterTargetComesFromScript(sc, s) { + continue + } + unitID, _, kind, err := resolveAlterPageUnit(ctx, s, h) + if err != nil { + continue + } + m := open(unitID) + if m == nil { + continue + } + for _, op := range s.Operations { + set, ok := op.(*ast.SetPropertyOp) + if !ok || set.Target.Widget == "" || set.Target.IsColumn() || !storedIsSnippetCall(m, set.Target.Widget) { + continue + } + var fields []string + for name, value := range set.Properties { + if setsSnippetCallAppearance(ctx, m, set.Target.Widget, name, value) { + fields = append(fields, name) + } + } + warnSnippetCallAppearance(sc, fmt.Sprintf("alter %s %s", kind, s.PageName.String()), set.Target.Widget, fields) + } + case *ast.AlterStylingStmt: + kind := strings.ToLower(s.ContainerType) + var unitID model.ID + switch kind { + case "page": + if sc.pages[s.ContainerName.String()] { + continue + } + pg, err := findPageByName(ctx, s.ContainerName, h) + if err != nil { + continue + } + unitID = pg.ID + case "snippet": + if sc.snippets[s.ContainerName.String()] { + continue + } + snp, _, err := findSnippetByName(ctx, s.ContainerName, h) + if err != nil { + continue + } + unitID = snp.ID + default: + continue + } + m := open(unitID) + if m == nil || !storedIsSnippetCall(m, s.WidgetName) { + continue + } + var fields []string + for _, a := range s.Assignments { + if a.Property == "" || (a.IsCSS && strings.TrimSpace(a.Value) == "") || + (a.IsToggle && !a.ToggleOn) { + continue + } + if a.IsCSS { + fields = append(fields, a.Property) + } else { + fields = append(fields, fmt.Sprintf("design property '%s'", a.Property)) + } + } + warnSnippetCallAppearance(sc, fmt.Sprintf("alter styling on %s %s", kind, s.ContainerName.String()), s.WidgetName, fields) + } + } +} + +func warnSnippetCallAppearance(sc *scriptContext, label, widget string, fields []string) { + if len(fields) == 0 { + return + } + sort.Strings(fields) + sc.warnings = append(sc.warnings, fmt.Sprintf("%s: %s [%s] — %s", + label, snippetCallAppearanceMessage(widget, fields), snippetCallAppearanceRule, snippetCallAppearanceSuggestion)) +} + +// storedIsSnippetCall reports whether the stored widget is a snippet call. +func storedIsSnippetCall(m backend.PageMutator, widget string) bool { + typer, ok := m.(widgetStorageTyper) + if !ok { + return false + } + bsonType, _ := typer.WidgetStorageType(widget) + return bsonType == "Forms$SnippetCallWidget" +} + +// setsSnippetCallAppearance reports whether one SET property writes a +// non-empty appearance setting: Class, Style, DynamicClasses, or a design +// property the theme declares for the stored widget. Clearing one — the fix +// this warning asks for — is not reported. +func setsSnippetCallAppearance(ctx *ExecContext, m backend.PageMutator, widget, name string, value any) bool { + switch strings.ToLower(name) { + case "class", "style", "dynamicclasses": + s, ok := value.(string) + return !ok || strings.TrimSpace(s) != "" + } + return designPropertyForStoredWidget(ctx.GetThemeRegistry(), m, widget, name) != nil +} diff --git a/mdl/executor/validate_snippetcall_appearance_test.go b/mdl/executor/validate_snippetcall_appearance_test.go new file mode 100644 index 0000000000..596e6f64a9 --- /dev/null +++ b/mdl/executor/validate_snippetcall_appearance_test.go @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/backend/pagemutator" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1511. A snippet call renders no element of its own: the +// snippet's widgets are inlined into the parent. Measured on Mendix 11.15.0 +// (`run --local`, `playwright check`): a snippetcall carrying Class 'probe-x', +// Style 'border: 3px solid red', DynamicClasses 'probe-dyn' and the design +// properties 'Align self' / 'Hide on' put NONE of them in the DOM — the +// snippet's container was a direct child of the enclosing container — while a +// container on the same page with the same four settings carried all of them. +// check, exec and mx check were all clean, so nothing told the author. + +func snippetCallRuleCount(t *testing.T, src string) int { + t.Helper() + n := 0 + for _, v := range ValidateWidgetProperties(parseMDL(t, src), "") { + if v.RuleID == "MDL-WIDGET48" { + n++ + } + } + return n +} + +func TestCheck1511_SnippetCallAppearanceWarns(t *testing.T) { + cases := map[string]string{ + "class": `class: 'probe-x'`, + "style": `style: 'border: 3px solid red'`, + "dynamic classes": `dynamicclasses: '''probe-dyn'''`, + "design property": `designproperties: ('Align self': 'Right')`, + "several at once": `class: 'a', style: 'color: red'`, + } + for name, props := range cases { + t.Run(name, func(t *testing.T) { + src := `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + container c1 { snippetcall sc1 (snippet: M.S, ` + props + `) } +};` + if got := snippetCallRuleCount(t, src); got != 1 { + t.Errorf("MDL-WIDGET48 reported %d times, want 1", got) + } + }) + } +} + +// The message has to say why and what to do instead, or it is one more +// warning to ignore. +func TestCheck1511_SnippetCallAppearanceMessage(t *testing.T) { + src := `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + snippetcall sc1 (snippet: M.S, class: 'x', style: 'color: red') +};` + for _, v := range ValidateWidgetProperties(parseMDL(t, src), "") { + if v.RuleID != "MDL-WIDGET48" { + continue + } + for _, want := range []string{"sc1", "Class", "Style", "renders no element"} { + if !strings.Contains(v.Message, want) { + t.Errorf("message %q lacks %q", v.Message, want) + } + } + if !strings.Contains(v.Suggestion, "container") { + t.Errorf("suggestion %q does not point at a container", v.Suggestion) + } + return + } + t.Fatal("no MDL-WIDGET48 reported") +} + +// The controls: a bare snippet call, and the same settings on a container, +// must stay silent. A rule that fires on the fix it recommends is worse than +// no rule. +func TestCheck1511_SnippetCallAppearanceControls(t *testing.T) { + for name, src := range map[string]string{ + "bare snippet call": `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + snippetcall sc1 (snippet: M.S) +};`, + "styled container around the call": `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + container c1 (class: 'probe-x', style: 'border: 1px solid', designproperties: ('Align self': 'Right')) { + snippetcall sc1 (snippet: M.S) + } +};`, + } { + t.Run(name, func(t *testing.T) { + if got := snippetCallRuleCount(t, src); got != 0 { + t.Errorf("MDL-WIDGET48 reported %d times, want 0", got) + } + }) + } +} + +// ALTER PAGE INSERT / REPLACE carry the widget, so they are checked without a +// project, like CREATE. +func TestCheck1511_SnippetCallAppearanceInAlterInsert(t *testing.T) { + src := `alter page M.P { insert after c1 { snippetcall sc2 (snippet: M.S, class: 'x') } };` + if got := snippetCallRuleCount(t, src); got != 1 { + t.Errorf("MDL-WIDGET48 reported %d times, want 1", got) + } +} + +// --------------------------------------------------------------------------- +// ALTER PAGE … SET and ALTER STYLING name a STORED widget: what it is lives +// only in the document, so these are judged in the reference pass. +// --------------------------------------------------------------------------- + +func storedSnippetCallPage() bson.D { + appearance := func() bson.D { + return bson.D{ + {Key: "$Type", Value: "Forms$Appearance"}, + {Key: "Class", Value: ""}, + {Key: "DesignProperties", Value: bson.A{int32(3)}}, + {Key: "DynamicClasses", Value: ""}, + {Key: "Style", Value: ""}, + } + } + call := bson.D{ + {Key: "$Type", Value: "Forms$SnippetCallWidget"}, + {Key: "Name", Value: "snippetCall1"}, + {Key: "Appearance", Value: appearance()}, + {Key: "FormCall", Value: bson.D{{Key: "$Type", Value: "Forms$SnippetCall"}}}, + } + div := bson.D{ + {Key: "$Type", Value: "Forms$DivContainer"}, + {Key: "Name", Value: "c1"}, + {Key: "Appearance", Value: appearance()}, + {Key: "Widgets", Value: bson.A{int32(2), call}}, + } + return bson.D{ + {Key: "$Type", Value: "Forms$Page"}, + {Key: "FormCall", Value: bson.D{ + {Key: "Arguments", Value: bson.A{ + int32(2), + bson.D{{Key: "Widgets", Value: bson.A{int32(2), div}}}, + }}, + }}, + } +} + +func snippetCallPageCtx(t *testing.T) *ExecContext { + t.Helper() + mod := mkModule("M") + pg := mkPage(mod.ID, "P") + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListFoldersFunc: func() ([]*types.FolderInfo, error) { return nil, nil }, + ListPagesFunc: func() ([]*pages.Page, error) { return []*pages.Page{pg}, nil }, + OpenPageForMutationFunc: func(unitID model.ID) (backend.PageMutator, error) { + return pagemutator.New(storedSnippetCallPage(), unitID, &countingDeps{}), nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + ctx.ThemeRegistry = atlasListViewTheme() + return ctx +} + +func storedSnippetCallWarnings(t *testing.T, src string) []string { + t.Helper() + prog := parseMDL(t, src) + sc := newScriptContext() + sc.collectDefinitions(prog) + validateStoredSnippetCallAppearance(snippetCallPageCtx(t), prog, sc) + var out []string + for _, w := range sc.warnings { + if strings.Contains(w, "MDL-WIDGET48") { + out = append(out, w) + } + } + return out +} + +// The reported statement. +func TestCheck1511_AlterSetClassOnStoredSnippetCall(t *testing.T) { + got := storedSnippetCallWarnings(t, `alter page M.P { set (Class: 'x') on snippetCall1 };`) + if len(got) != 1 { + t.Fatalf("want one MDL-WIDGET48 warning, got %v", got) + } + if !strings.Contains(got[0], "snippetCall1") || !strings.Contains(got[0], "Class") { + t.Errorf("warning %q does not name the widget and the property", got[0]) + } +} + +func TestCheck1511_AlterSetStyleAndDesignPropertyOnStoredSnippetCall(t *testing.T) { + got := storedSnippetCallWarnings(t, + `alter page M.P { set (Style: 'color: red', DynamicClasses: '''d''', 'Align self': 'Right') on snippetCall1 };`) + if len(got) != 1 { + t.Fatalf("want one MDL-WIDGET48 warning, got %v", got) + } + for _, want := range []string{"Style", "DynamicClasses", "Align self"} { + if !strings.Contains(got[0], want) { + t.Errorf("warning %q does not name %s", got[0], want) + } + } +} + +func TestCheck1511_AlterStylingOnStoredSnippetCall(t *testing.T) { + got := storedSnippetCallWarnings(t, `alter styling on page M.P widget snippetCall1 set Class = 'x';`) + if len(got) != 1 { + t.Fatalf("want one MDL-WIDGET48 warning, got %v", got) + } +} + +// Controls: the same SET on the container around the call, and a SET of a +// property that is not appearance, are silent. +func TestCheck1511_StoredControlsAreSilent(t *testing.T) { + for _, src := range []string{ + `alter page M.P { set (Class: 'x', Style: 'color: red') on c1 };`, + `alter styling on page M.P widget c1 set Class = 'x';`, + `alter page M.P { set (Class: '') on snippetCall1 };`, + } { + if got := storedSnippetCallWarnings(t, src); len(got) != 0 { + t.Errorf("%s: want no MDL-WIDGET48, got %v", src, got) + } + } +} diff --git a/mdl/executor/validate_widget_attribute_scope_test.go b/mdl/executor/validate_widget_attribute_scope_test.go index fead61bbf1..d61efebd88 100644 --- a/mdl/executor/validate_widget_attribute_scope_test.go +++ b/mdl/executor/validate_widget_attribute_scope_test.go @@ -137,3 +137,53 @@ func TestCheck_AttributeScopeIsPartOfThePageReferencePass(t *testing.T) { t.Fatalf("want the page refused for its attribute binding, got %v", err) } } + +// A name NO entity in the widget's scope has was let through as "a plain typo, +// which other checks own" — and none did, so exec wrote Entity.Missing and only +// mx check said so, as CE1613 "The selected attribute … no longer exists" +// (mendixlabs/mxcli#1591: a Custom chart's eventDataAttribute inside a data view +// over a page parameter whose entity lacks it). When the bound entity is known +// and does not declare the name, the write is CE1613 whatever else is in scope. +func TestCheck_AttributeNoScopeHasIsRefused(t *testing.T) { + ctx := scopeCheckCtx(t) + s := scopeCheckPage(t, `staticAttribute: EventData`) + errs := validatePluggableAttributeScopes(ctx, s.Layout, s.Parameters, allPageWidgets(s), newScriptContext()) + if len(errs) != 1 { + t.Fatalf("want one binding error, got %q", errs) + } + for _, want := range []string{"viewer", "staticAttribute", "`EventData`", "Sales.Invoice", "CE1613"} { + if !strings.Contains(errs[0], want) { + t.Errorf("error should mention %q, got: %s", want, errs[0]) + } + } + + e := scopeEngine(t) + _, err := e.Build(multiSourceDef(), twoSourceWidget(map[string]any{"staticAttribute": "EventData"})) + if err == nil || !strings.Contains(err.Error(), errs[0]) { + t.Errorf("check and exec disagree:\n check: %s\n exec: %v", errs[0], err) + } +} + +// The same refusal when the data view's object is a page parameter, the shape +// mendixlabs/mxcli#1591 was reported on. +func TestCheck_AttributeThePageParameterEntityLacksIsRefused(t *testing.T) { + ctx := scopeCheckCtx(t) + src := `create page Sales.P (title: 'P', layout: Atlas_Core.Atlas_Default, params: ($Ctx: Sales.Customer, $Entry: Sales.Invoice)) { + dataview dv (datasource: $Entry) { + pluggablewidget 'com.mendix.widget.web.combobox.Combobox' viewer ( + optionsSourceAssociationDataSource: database from Sales.Order, + DatabaseSource: database from Sales.Customer, + staticAttribute: EventData + ) + } +}` + prog, perrs := visitor.Build(src) + if len(perrs) > 0 { + t.Fatalf("parse: %v", perrs) + } + s := prog.Statements[0].(*ast.CreatePageStmtV3) + errs := validatePluggableAttributeScopes(ctx, s.Layout, s.Parameters, allPageWidgets(s), newScriptContext()) + if len(errs) != 1 || !strings.Contains(errs[0], "Sales.Invoice") { + t.Fatalf("want one refusal naming Sales.Invoice, got %q", errs) + } +} diff --git a/mdl/executor/validate_widget_expression_value.go b/mdl/executor/validate_widget_expression_value.go index 4ba898ed02..4af0d74504 100644 --- a/mdl/executor/validate_widget_expression_value.go +++ b/mdl/executor/validate_widget_expression_value.go @@ -62,8 +62,17 @@ func validateWidgetExpressionValuesIn(w *ast.WidgetV3, def *WidgetDefinition, it continue } op, known := expressionValueOperation(key, def, item) - if !known && def == nil && item == nil { - op, known = schema.operation(key) + if !known && item == nil { + if def == nil { + op, known = schema.operation(key) + } else { + // A key the definition knows but does not map — a combo box's + // `emptyOptionText` — is written by the explicit-property pass + // with the kind its template declares, and a kind other than + // Expression refuses the expression at exec. Decide it here + // from the same template (mendixlabs/mxcli#1586). + op, known = loadEmbeddedSchema(def.WidgetID, "").operation(key) + } } switch { case op == "expression": diff --git a/mdl/executor/validate_widget_member_refs.go b/mdl/executor/validate_widget_member_refs.go index ba896ec88c..fea3c7ddec 100644 --- a/mdl/executor/validate_widget_member_refs.go +++ b/mdl/executor/validate_widget_member_refs.go @@ -40,11 +40,19 @@ import ( // elsewhere, and an entity this cannot establish leaves the constraint // unchecked rather than wrongly checked — the same rule as the member resolver // in validate_member_refs.go, and for the same reason. +// +// The members are resolved against the project AND the script, with the +// resolver the microflow retrieve uses. Project-only, a member the script adds +// to a stored entity — `alter entity … add attribute`, a restating `create or +// modify`, a new association — was "neither an attribute nor an association", +// and exec refused a script that builds clean (mendixlabs/mxcli#1271). func validateXPathMembers(ctx *ExecContext, prog *ast.Program) []error { if prog == nil || !ctx.Connected() { return nil } - m := &execXPathModel{ctx: ctx} + sc := newScriptContext() + sc.collectDefinitions(prog) + m, resolve := scriptXPathResolver(ctx, sc) var errs []error for _, stmt := range prog.Statements { forEachWidget(stmt, func(w *ast.WidgetV3, where string) { @@ -58,7 +66,7 @@ func validateXPathMembers(ctx *ExecContext, prog *ast.Program) []error { } // Checked as it is stored: a qualified attribute of the entity is // the attribute, not a step to resolve (ako/mxcli#874). - for _, bad := range unresolvableXPathSteps(ctx, m, storedXPathConstraint(ds.Where, entityQN), entityQN) { + for _, bad := range unresolvableXPathStepsWith(ctx, m, storedXPathConstraint(ds.Where, entityQN), entityQN, resolve) { errs = append(errs, mdlerrors.NewValidation(fmt.Sprintf( "%s: the constraint on %s names %q, which is neither an attribute nor an "+ "association of it — mxbuild reports this as CE1613 \"The selected %s "+ @@ -76,8 +84,10 @@ type badStep struct { kind string // "association" or "attribute" — which mxbuild will call it } -// unresolvableXPathSteps walks the constraint's predicate groups and returns the -// steps that name nothing on the entity. +// unresolvableXPathStepsWith walks the constraint's predicate groups and returns +// the steps that name nothing on the entity. resolveBare is the bare-member +// lookup — every caller passes scriptXPathResolver's, which also knows the +// entities the script declares; nil is resolveMemberOnEntity. // // The parse is deliberately lenient (ANTLR with the error listeners removed), so // a group it cannot read is SKIPPED rather than reported: a tree that quietly @@ -85,13 +95,6 @@ type badStep struct { // wrote. That is the same trap xpathrefs documents for the rename path, where // the consequence is a corrupted constraint; here it is only a false error, but // a false error still blocks a script that builds. -func unresolvableXPathSteps(ctx *ExecContext, m xpathrefs.Model, constraint, entityQN string) []badStep { - return unresolvableXPathStepsWith(ctx, m, constraint, entityQN, nil) -} - -// unresolvableXPathStepsWith is unresolvableXPathSteps with the bare-member -// lookup replaced — the microflow retrieve check passes one that also knows the -// entities the script declares. nil is resolveMemberOnEntity. func unresolvableXPathStepsWith(ctx *ExecContext, m xpathrefs.Model, constraint, entityQN string, resolveBare func(entityQN, member string) memberResolution) []badStep { groups := visitor.SplitXPathPredicateGroups(constraint) diff --git a/mdl/executor/validate_widget_member_refs_test.go b/mdl/executor/validate_widget_member_refs_test.go index ed9bbbb485..c98b64cea6 100644 --- a/mdl/executor/validate_widget_member_refs_test.go +++ b/mdl/executor/validate_widget_member_refs_test.go @@ -244,3 +244,67 @@ func TestValidateXPathMembers_SkipsAnUnparseableConstraint(t *testing.T) { } } } + +// --------------------------------------------------------------------------- +// mendixlabs/mxcli#1271 — members the SAME script adds to a stored entity +// --------------------------------------------------------------------------- + +// The microflow retrieve learned to see the members a script adds; the page +// data source did not. `alter entity Shop.Order add attribute IsPaid …` and a +// grid `database from Shop.Order where [IsPaid = true]` in one script was +// "names \"IsPaid\", which is neither an attribute nor an association of it", +// and exec refused a script mxbuild builds clean. Same for an attribute added by +// restating the entity, and for an association the script creates. +func TestValidateXPathMembers_SeesMembersTheScriptAdds(t *testing.T) { + ctx := memberFixture(t) + for _, tc := range []struct{ name, src string }{ + {"alter entity … add attribute", `alter entity Shop.Order add attribute IsPaid: Boolean default false; +create page Shop.P (Title: 'Orders', Layout: Atlas_Core.Atlas_Default) { + datagrid dg (DataSource: database from Shop.Order where [IsPaid = true]) { + column (Attribute: OrderNo, Caption: 'No') + } +};`}, + {"create or modify restating the entity", `create or modify persistent entity Shop.Customer (Name: String(200), Vip: Boolean default false); +create page Shop.P (Title: 'Customers', Layout: Atlas_Core.Atlas_Default) { + datagrid dg (DataSource: database from Shop.Customer where [Vip = true]) { + column (Attribute: Name, Caption: 'Name') + } +};`}, + {"an association the script creates", `create association Shop.Order_Home from Shop.Order to Shop.Region type Reference; +create page Shop.P (Title: 'Orders', Layout: Atlas_Core.Atlas_Default) { + datagrid dg (DataSource: database from Shop.Order where [Shop.Order_Home/Shop.Region/RegionName = 'x']) { + column (Attribute: OrderNo, Caption: 'No') + } +};`}, + } { + if errs := validateXPathMembers(ctx, parseMDL(t, tc.src)); len(errs) != 0 { + t.Errorf("%s: a member the script adds was reported missing: %v", tc.name, errs) + } + } +} + +// CONTROL: seeing the script is not a blanket pass. A restated entity's members +// are the ones the statement lists, so a name it does not list is still missing; +// and altering one entity vouches for nothing on another. +func TestValidateXPathMembers_ScriptMembersDoNotHideATypo(t *testing.T) { + ctx := memberFixture(t) + for _, tc := range []struct{ name, src, want string }{ + {"restated entity, unlisted member", `create or modify persistent entity Shop.Customer (Name: String(200), Vip: Boolean default false); +create page Shop.P (Title: 'Customers', Layout: Atlas_Core.Atlas_Default) { + datagrid dg (DataSource: database from Shop.Customer where [Vipp = true]) { + column (Attribute: Name, Caption: 'Name') + } +};`, "Vipp"}, + {"another entity altered", `alter entity Shop.Order add attribute IsPaid: Boolean default false; +create page Shop.P (Title: 'Customers', Layout: Atlas_Core.Atlas_Default) { + datagrid dg (DataSource: database from Shop.Customer where [IsPaid = true]) { + column (Attribute: Name, Caption: 'Name') + } +};`, "IsPaid"}, + } { + errs := validateXPathMembers(ctx, parseMDL(t, tc.src)) + if len(errs) != 1 || !strings.Contains(errs[0].Error(), tc.want) { + t.Errorf("%s: want one error naming %q, got %v", tc.name, tc.want, errs) + } + } +} diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 8138073c11..22d9638236 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -68,6 +68,11 @@ func LoadWidgetRegistry(projectPath string) *WidgetRegistry { // from the same .mpk DESCRIBE parses. See // widget_known_props_from_mpk.go for why this is not a list of nine. enrichKnownPropertiesFromMPK(registry, projectPath) + } else { + // No project to read packages from: the embedded template is the + // widget's schema, so check without -p accepts what DESCRIBE prints + // by key (mendixlabs/mxcli#1586). + enrichKnownPropertiesFromTemplates(registry) } return registry } @@ -280,6 +285,9 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc // reference MDL cannot author. See validate_widget_image.go. out = append(out, validateImageSource(w, locationPrefix)...) out = append(out, validateStaticWidget(w, locationPrefix)...) + // #1511: a snippet call renders no element, so its Class / Style / + // DynamicClasses / design properties never reach the page. + out = append(out, validateSnippetCallAppearance(w, locationPrefix)...) out = append(out, validateDynamicTextFormatting(w, locationPrefix)...) out = append(out, validateInputFormatting(w, locationPrefix)...) // A text input's SubmitBehaviour and a layout grid's Width (ako/mxcli-ledger FINDINGS #170). diff --git a/mdl/executor/validate_workflow.go b/mdl/executor/validate_workflow.go index c767adbab8..70ec5ec75f 100644 --- a/mdl/executor/validate_workflow.go +++ b/mdl/executor/validate_workflow.go @@ -324,9 +324,6 @@ func walkWorkflowActivities(acts []ast.WorkflowActivityNode, visit func(ast.Work for _, o := range n.Outcomes { walkWorkflowActivities(o.Activities, visit) } - for _, b := range n.BoundaryEvents { - walkWorkflowActivities(b.Activities, visit) - } case *ast.WorkflowDecisionNode: for _, o := range n.Outcomes { walkWorkflowActivities(o.Activities, visit) @@ -335,19 +332,34 @@ func walkWorkflowActivities(acts []ast.WorkflowActivityNode, visit func(ast.Work for _, o := range n.Outcomes { walkWorkflowActivities(o.Activities, visit) } - for _, b := range n.BoundaryEvents { - walkWorkflowActivities(b.Activities, visit) - } case *ast.WorkflowParallelSplitNode: for _, p := range n.Paths { walkWorkflowActivities(p.Activities, visit) } - case *ast.WorkflowWaitForNotificationNode: - for _, b := range n.BoundaryEvents { - walkWorkflowActivities(b.Activities, visit) - } } + for _, b := range workflowActivityBoundaryEvents(a) { + walkWorkflowActivities(b.Activities, visit) + } + } +} + +// workflowActivityBoundaryEvents returns the boundary events an authored +// activity owns: user tasks, call microflow, call workflow and wait for +// notification. Every walk over boundary events goes through it — each used to +// switch on the owners itself, and when `call workflow` gained the clause +// (mendixlabs/mxcli#1532) eight of those switches had to learn it at once. +func workflowActivityBoundaryEvents(a ast.WorkflowActivityNode) []ast.WorkflowBoundaryEventNode { + switch n := a.(type) { + case *ast.WorkflowUserTaskNode: + return n.BoundaryEvents + case *ast.WorkflowCallMicroflowNode: + return n.BoundaryEvents + case *ast.WorkflowCallWorkflowNode: + return n.BoundaryEvents + case *ast.WorkflowWaitForNotificationNode: + return n.BoundaryEvents } + return nil } // ValidateAlterWorkflow applies the rules that ALTER WORKFLOW can reach: diff --git a/mdl/executor/validate_workflow_end.go b/mdl/executor/validate_workflow_end.go index e37a254939..ea8a7bc752 100644 --- a/mdl/executor/validate_workflow_end.go +++ b/mdl/executor/validate_workflow_end.go @@ -60,7 +60,6 @@ func collectEndBodies(b endBody, out *[]endBody) { for _, o := range n.Outcomes { child(o.Activities, false, false) } - boundary(n.BoundaryEvents) case *ast.WorkflowDecisionNode: for _, o := range n.Outcomes { child(o.Activities, false, false) @@ -69,14 +68,12 @@ func collectEndBodies(b endBody, out *[]endBody) { for _, o := range n.Outcomes { child(o.Activities, false, false) } - boundary(n.BoundaryEvents) case *ast.WorkflowParallelSplitNode: for _, p := range n.Paths { child(p.Activities, true, false) } - case *ast.WorkflowWaitForNotificationNode: - boundary(n.BoundaryEvents) } + boundary(workflowActivityBoundaryEvents(a)) } } @@ -368,6 +365,8 @@ func collectStoredPlacements(flow *workflows.Flow, ref string, at storedPlacemen collectStoredPlacements(o.GetFlow(), ref, at, out) } boundary(t.BoundaryEvents) + case *workflows.CallWorkflowActivity: + boundary(t.BoundaryEvents) case *workflows.WaitForNotificationActivity: boundary(t.BoundaryEvents) default: diff --git a/mdl/executor/validate_workflow_esp.go b/mdl/executor/validate_workflow_esp.go index a783aa8335..787a86e008 100644 --- a/mdl/executor/validate_workflow_esp.go +++ b/mdl/executor/validate_workflow_esp.go @@ -58,18 +58,10 @@ func workflowStatementActivities(stmt *ast.CreateWorkflowStmt) []ast.WorkflowAct func workflowUsesNotificationEvents(activities []ast.WorkflowActivityNode) bool { found := false walkWorkflowActivities(activities, func(a ast.WorkflowActivityNode) { - var events []ast.WorkflowBoundaryEventNode - switch n := a.(type) { - case *ast.WorkflowNotificationNode: + if _, ok := a.(*ast.WorkflowNotificationNode); ok { found = true - case *ast.WorkflowUserTaskNode: - events = n.BoundaryEvents - case *ast.WorkflowCallMicroflowNode: - events = n.BoundaryEvents - case *ast.WorkflowWaitForNotificationNode: - events = n.BoundaryEvents } - for _, e := range events { + for _, e := range workflowActivityBoundaryEvents(a) { if e.EventType == "InterruptingNotification" || e.EventType == "NonInterruptingNotification" { found = true } @@ -101,6 +93,8 @@ func ValidateWorkflowEventSubProcesses(stmt *ast.CreateWorkflowStmt) []linter.Vi events, label = n.BoundaryEvents, "user task "+workflowUserTaskLabel(n) case *ast.WorkflowCallMicroflowNode: events, label = n.BoundaryEvents, "call microflow "+workflowCallMicroflowLabel(n) + case *ast.WorkflowCallWorkflowNode: + events, label = n.BoundaryEvents, "call workflow "+n.Workflow.String() case *ast.WorkflowWaitForNotificationNode: name := n.Name if name == "" { diff --git a/mdl/executor/validate_workflow_jump.go b/mdl/executor/validate_workflow_jump.go index 6715048088..8c93b0c8ee 100644 --- a/mdl/executor/validate_workflow_jump.go +++ b/mdl/executor/validate_workflow_jump.go @@ -152,6 +152,7 @@ func collectJumpableNames(acts []workflows.WorkflowActivity, out map[string]bool add(a.Name) case *workflows.CallWorkflowActivity: add(a.Name) + collectBoundaryEventNames(a.BoundaryEvents, out) case *workflows.ExclusiveSplitActivity: add(a.Name) collectConditionOutcomeNames(a.Outcomes, out) diff --git a/mdl/executor/validate_workflow_refs.go b/mdl/executor/validate_workflow_refs.go index 1a647aed8b..ecef71b63d 100644 --- a/mdl/executor/validate_workflow_refs.go +++ b/mdl/executor/validate_workflow_refs.go @@ -245,14 +245,7 @@ func bareTimerBoundaryEventErrors(ctx *ExecContext, activities []ast.WorkflowAct } } walkWorkflowActivities(activities, func(a ast.WorkflowActivityNode) { - switch t := a.(type) { - case *ast.WorkflowUserTaskNode: - count(t.BoundaryEvents) - case *ast.WorkflowCallMicroflowNode: - count(t.BoundaryEvents) - case *ast.WorkflowWaitForNotificationNode: - count(t.BoundaryEvents) - } + count(workflowActivityBoundaryEvents(a)) }) if n == 0 { return nil diff --git a/mdl/executor/validate_workflow_rewrite.go b/mdl/executor/validate_workflow_rewrite.go index b5185fef33..cfffec6947 100644 --- a/mdl/executor/validate_workflow_rewrite.go +++ b/mdl/executor/validate_workflow_rewrite.go @@ -257,14 +257,7 @@ func countRawWorkflowNodes(v any, marker string) int { func countAuthoredBoundaryEvents(activities []ast.WorkflowActivityNode) int { n := 0 walkWorkflowActivities(activities, func(act ast.WorkflowActivityNode) { - switch a := act.(type) { - case *ast.WorkflowUserTaskNode: - n += len(a.BoundaryEvents) - case *ast.WorkflowCallMicroflowNode: - n += len(a.BoundaryEvents) - case *ast.WorkflowWaitForNotificationNode: - n += len(a.BoundaryEvents) - } + n += len(workflowActivityBoundaryEvents(act)) }) return n } diff --git a/mdl/executor/validate_workflow_scope.go b/mdl/executor/validate_workflow_scope.go index ebb6038492..9c431b2f4b 100644 --- a/mdl/executor/validate_workflow_scope.go +++ b/mdl/executor/validate_workflow_scope.go @@ -101,6 +101,7 @@ func workflowActivityExprSites(acts []ast.WorkflowActivityNode) []wfExprSite { boundary(n.BoundaryEvents, "call microflow "+n.Microflow.String()) case *ast.WorkflowCallWorkflowNode: mappings(n.ParameterMappings, "call workflow", n.Workflow.String()) + boundary(n.BoundaryEvents, "call workflow "+n.Workflow.String()) case *ast.WorkflowDecisionNode: sites = append(sites, wfExprSite{where: "decision " + workflowDecisionLabel(n), expr: n.Expression}) case *ast.WorkflowWaitForTimerNode: diff --git a/mdl/executor/version_gates.go b/mdl/executor/version_gates.go index 428e9d0f91..a4b77ae048 100644 --- a/mdl/executor/version_gates.go +++ b/mdl/executor/version_gates.go @@ -65,6 +65,34 @@ func statementVersionGates(stmt ast.Statement) []versionGate { case *ast.CreateViewEntityStmt: return []versionGate{{"domain_model", "view_entities", "create view entity", "upgrade your project to 10.18+ or use a regular entity with a microflow data source"}} + // A domain-model EXPORT LEVEL is 9.3+ (mendixlabs/mxcli#1498). Gated on the + // clause being STATED, as the deep-link URL is: a statement without one + // carries the stored level and writes nothing new. + case *ast.CreateEntityStmt: + if s.ExportLevel != nil || attributesStateExportLevel(s.Attributes) { + return []versionGate{exportLevelGate("export level on an entity or attribute")} + } + case *ast.AlterEntityStmt: + if s.Operation == ast.AlterEntitySetExportLevel || s.ModifyExportLevel != nil || + (s.Attribute != nil && s.Attribute.ExportLevel != nil) { + return []versionGate{exportLevelGate("export level on an entity or attribute")} + } + case *ast.CreateAssociationStmt: + if s.ExportLevel != nil { + return []versionGate{exportLevelGate("export level on an association")} + } + case *ast.AlterAssociationStmt: + if s.Operation == ast.AlterAssociationSetExportLevel { + return []versionGate{exportLevelGate("export level on an association")} + } + case *ast.CreateEnumerationStmt: + if s.ExportLevel != nil { + return []versionGate{exportLevelGate("export level on an enumeration")} + } + case *ast.AlterEnumerationStmt: + if s.Operation == ast.AlterEnumSetExportLevel { + return []versionGate{exportLevelGate("export level on an enumeration")} + } case *ast.CreateModelStmt: return []versionGate{agentDocumentGate("agent_model", "create model")} case *ast.CreateConsumedMCPServiceStmt: @@ -146,6 +174,20 @@ func statementVersionGates(stmt ast.Statement) []versionGate { return nil } +func exportLevelGate(statement string) versionGate { + return versionGate{"domain_model", "export_levels", statement, + "Mendix stores no export level on domain-model elements before 9.3 — remove the clause, or upgrade the project"} +} + +func attributesStateExportLevel(attrs []ast.Attribute) bool { + for _, a := range attrs { + if a.ExportLevel != nil { + return true + } + } + return false +} + var ( agentTaskGate = versionGate{"workflows", "ai_agent_task", "call agent microflow", "AI agent tasks need Mendix 11.9 or later — use `call microflow` on older projects"} diff --git a/mdl/executor/version_gates_test.go b/mdl/executor/version_gates_test.go index f9952e3d32..61d55776ce 100644 --- a/mdl/executor/version_gates_test.go +++ b/mdl/executor/version_gates_test.go @@ -19,6 +19,7 @@ import ( // statement in this table is refused by exec exactly when check reports it. func TestCheckVersionGates_ReportsEveryHandlerGate(t *testing.T) { url := "item/{Id}" + api := "API" cases := []struct { name string stmt ast.Statement @@ -50,6 +51,15 @@ func TestCheckVersionGates_ReportsEveryHandlerGate(t *testing.T) { {"event subprocess", &ast.CreateWorkflowStmt{EventSubProcesses: []ast.WorkflowEventSubProcessNode{{Name: "E"}}}, [3]int{11, 7, 0}, "event subprocess"}, {"timer event subprocess", &ast.CreateWorkflowStmt{EventSubProcesses: []ast.WorkflowEventSubProcessNode{{Name: "E", Timer: true}}}, [3]int{11, 12, 0}, "on … timer"}, {"notification", &ast.CreateWorkflowStmt{Activities: []ast.WorkflowActivityNode{&ast.WorkflowNotificationNode{}}}, [3]int{11, 10, 0}, "notification activities"}, + // mendixlabs/mxcli#1498: a domain-model export level is 9.3+. + {"entity export level", &ast.CreateEntityStmt{ExportLevel: &api}, [3]int{9, 2, 0}, "export level on an entity"}, + {"attribute export level", &ast.CreateEntityStmt{Attributes: []ast.Attribute{{Name: "A", ExportLevel: &api}}}, [3]int{9, 2, 0}, "or attribute"}, + {"alter entity export level", &ast.AlterEntityStmt{Operation: ast.AlterEntitySetExportLevel, ExportLevel: "API"}, [3]int{9, 2, 0}, "export level on an entity"}, + {"modify attribute export level", &ast.AlterEntityStmt{Operation: ast.AlterEntityModifyAttribute, ModifyExportLevel: &api}, [3]int{9, 2, 0}, "or attribute"}, + {"association export level", &ast.CreateAssociationStmt{ExportLevel: &api}, [3]int{9, 2, 0}, "export level on an association"}, + {"alter association export level", &ast.AlterAssociationStmt{Operation: ast.AlterAssociationSetExportLevel}, [3]int{9, 2, 0}, "export level on an association"}, + {"enumeration export level", &ast.CreateEnumerationStmt{ExportLevel: &api}, [3]int{9, 2, 0}, "export level on an enumeration"}, + {"alter enumeration export level", &ast.AlterEnumerationStmt{Operation: ast.AlterEnumSetExportLevel}, [3]int{9, 2, 0}, "export level on an enumeration"}, {"alter workflow", &ast.AlterWorkflowStmt{}, [3]int{8, 18, 0}, "alter workflow"}, {"alter workflow agent task", &ast.AlterWorkflowStmt{Operations: []ast.AlterWorkflowOp{ &ast.InsertAfterOp{NewActivities: []ast.WorkflowActivityNode{&ast.WorkflowCallMicroflowNode{Agent: true}}}}}, [3]int{11, 8, 0}, "call agent microflow"}, @@ -91,6 +101,11 @@ func TestCheckVersionGates_UngatedFormsPass(t *testing.T) { &ast.CreatePageStmtV3{}, &ast.CreateODataServiceStmt{}, &ast.AlterSettingsStmt{Section: "MODEL"}, + // No EXPORT LEVEL clause: the stored level is carried (#1498). + &ast.CreateEntityStmt{Attributes: []ast.Attribute{{Name: "A"}}}, + &ast.CreateAssociationStmt{}, + &ast.CreateEnumerationStmt{}, + &ast.AlterEntityStmt{Operation: ast.AlterEntitySetDocumentation}, }} if vs := versionGateViolations(versionCtx(t, 9, 0, 0), prog); len(vs) != 0 { t.Errorf("ungated forms reported on 9.0: %+v", vs) diff --git a/mdl/executor/widget_attribute_scope.go b/mdl/executor/widget_attribute_scope.go index f047681d97..418b06d91a 100644 --- a/mdl/executor/widget_attribute_scope.go +++ b/mdl/executor/widget_attribute_scope.go @@ -127,9 +127,14 @@ func (ix attributeIndex) declares(entityQN, attr string) (found, known bool) { // author either picks an attribute of the bound entity, or binds the value to // the property the widget links to the other datasource. // +// A name no entity in scope has is refused too, without candidates. It was let +// through as "a plain typo, which other checks own", and no check owned it: exec +// wrote Entity.Missing and the first word of it was mx check's CE1613 +// (mendixlabs/mxcli#1591). +// // Nothing is refused where the question cannot be answered: a qualified name or -// path, a scope the rule did not decide (scopeShared), a model the index cannot -// read, or a name no candidate has (a plain typo, which other checks own). +// path, a scope the rule did not decide (scopeShared), or a model the index +// cannot read. func misboundAttributeError(where, attr string, scope bindingScope, outer string, dsEntities map[string]string, ix attributeIndex) error { if attr == "" || strings.ContainsAny(attr, "./$") || scope.Kind == scopeShared || scope.Entity == "" { return nil @@ -158,7 +163,10 @@ func misboundAttributeError(where, attr string, scope bindingScope, outer string add(dsEntities[k], fmt.Sprintf("datasource `%s`", k)) } if len(candidates) == 0 { - return nil + return mdlerrors.NewValidationf( + "%s: `%s` is not an attribute of %s, the entity this property binds to (%s), "+ + "and writing it is CE1613 \"The selected attribute no longer exists\". Bind an attribute of %s here", + where, attr, scope.Entity, scope.describe(), scope.Entity) } return mdlerrors.NewValidationf( "%s: `%s` is not an attribute of %s, the entity this property binds to (%s); "+ diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index be3c0d719b..495a5344fb 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -480,7 +480,7 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* e.recordDataSourceEntity(propKey, entityName) if entityName != "" { e.pageBuilder.entityContext = entityName - e.pageBuilder.argCtx = enteringDataWidget(ds, entityName) + e.pageBuilder.argCtx = enteringDataWidget(ds, entityName).inDataWidget(w.Name) } } } @@ -1615,7 +1615,7 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W e.recordDataSourceEntity(mapping.PropertyKey, entityName) if entityName != "" { e.pageBuilder.entityContext = entityName - e.pageBuilder.argCtx = enteringDataWidget(ds, entityName) + e.pageBuilder.argCtx = enteringDataWidget(ds, entityName).inDataWidget(w.Name) if w.Name != "" { e.pageBuilder.paramEntityNames[w.Name] = entityName } @@ -2462,15 +2462,25 @@ func (e *PluggableWidgetEngine) applyChildSlots(builder backend.WidgetObjectBuil slot, ok = slotContainers[strings.ToLower(child.Name)] } if ok { + // The slot's children are built here, not through the slot's own + // node, so the context it gives them is applied here: a control + // bar holds its grid's selection (mendixlabs/mxcli#122), the same + // rule the check-time walk applies. + outerArgCtx := e.pageBuilder.argCtx + if lowerType == "controlbar" { + e.pageBuilder.argCtx = argContextForChildren(child, outerArgCtx) + } for _, slotChild := range child.Children { widget, err := e.pageBuilder.buildWidgetV3(slotChild) if err != nil { + e.pageBuilder.argCtx = outerArgCtx return err } if widget != nil { slotWidgets[slot.PropertyKey] = append(slotWidgets[slot.PropertyKey], widget) } } + e.pageBuilder.argCtx = outerArgCtx } else { widget, err := e.pageBuilder.buildWidgetV3(child) if err != nil { diff --git a/mdl/executor/widget_known_props_from_mpk.go b/mdl/executor/widget_known_props_from_mpk.go index 2f0e38cfe7..095c58f9f9 100644 --- a/mdl/executor/widget_known_props_from_mpk.go +++ b/mdl/executor/widget_known_props_from_mpk.go @@ -7,6 +7,7 @@ import ( "sort" "strings" + mwidgets "github.com/mendixlabs/mxcli/modelsdk/widgets" "github.com/mendixlabs/mxcli/modelsdk/widgets/mpk" ) @@ -70,29 +71,66 @@ func enrichKnownPropertiesFromMPK(r *WidgetRegistry, projectPath string) { if !ok { continue } - allowed, _ := allowedWidgetProperties(def) - seen := make(map[string]bool, len(def.KnownProperties)) - for _, k := range def.KnownProperties { - seen[strings.ToLower(k)] = true - } - var added []string + var keys []string for _, p := range props { - if p.Key == "" || p.IsSystem { - continue + if !p.IsSystem { + keys = append(keys, p.Key) } - l := strings.ToLower(p.Key) - if allowed[l] || seen[l] { - continue + } + addKnownProperties(def, keys) + } +} + +// enrichKnownPropertiesFromTemplates is enrichKnownPropertiesFromMPK for a +// check with no project: the widget's embedded template stands in for its +// installed package. Without it the hand-crafted definitions rejected, as +// MDL-WIDGET01, the settings DESCRIBE prints for a combo box or a data grid +// filter by the widget's own key — so the description failed a check that +// the same check with -p passes. +func enrichKnownPropertiesFromTemplates(r *WidgetRegistry) { + if r == nil { + return + } + for _, def := range r.byWidgetID { + tmpl, err := mwidgets.GetTemplate(def.WidgetID) + if err != nil || tmpl == nil { + continue + } + var keys []string + for _, p := range propsFromTemplate(tmpl.Type) { + if !strings.EqualFold(p.Type, "System") { + keys = append(keys, p.Key) } - seen[l] = true - added = append(added, p.Key) } - if len(added) == 0 { + addKnownProperties(def, keys) + } +} + +// addKnownProperties appends to def.KnownProperties each key it neither maps +// nor already knows, in sorted order. +func addKnownProperties(def *WidgetDefinition, keys []string) { + allowed, _ := allowedWidgetProperties(def) + seen := make(map[string]bool, len(def.KnownProperties)) + for _, k := range def.KnownProperties { + seen[strings.ToLower(k)] = true + } + var added []string + for _, key := range keys { + if key == "" { + continue + } + l := strings.ToLower(key) + if allowed[l] || seen[l] { continue } - sort.Strings(added) - def.KnownProperties = append(def.KnownProperties, added...) + seen[l] = true + added = append(added, key) + } + if len(added) == 0 { + return } + sort.Strings(added) + def.KnownProperties = append(def.KnownProperties, added...) } // mpkPropertiesByWidgetID parses every package in the project's widgets/ folder diff --git a/mdl/executor/workflow_callworkflow_boundary_test.go b/mdl/executor/workflow_callworkflow_boundary_test.go new file mode 100644 index 0000000000..cc94f61d1a --- /dev/null +++ b/mdl/executor/workflow_callworkflow_boundary_test.go @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// mendixlabs/mxcli#1532. A `call workflow` activity takes timer boundary events +// in Mendix (Workflows$CallWorkflowActivity.boundaryEvents, 10.14+, the same +// floor as every other boundary-event owner), and mxcli read, wrote, described +// and ALTERed them — but `create workflow … call workflow … boundary event …` +// did not parse, so describe output of such a workflow did not re-parse either. + +const cwBoundaryHead = "create workflow M.W parameter $WorkflowContext: M.Item\nbegin\n" + +func cwBoundaryWF(body string) string { return cwBoundaryHead + body + "\nend workflow;" } + +func firstCallWorkflow(t *testing.T, src string) *ast.WorkflowCallWorkflowNode { + t.Helper() + stmt := parseWorkflowStmt(t, src) + n, ok := stmt.Activities[0].(*ast.WorkflowCallWorkflowNode) + if !ok { + t.Fatalf("first activity is %T, want *ast.WorkflowCallWorkflowNode", stmt.Activities[0]) + } + return n +} + +// The builder writes the parsed boundary events onto the CallWorkflowActivity, +// each with its handler flow ended by Mendix's end-of-path marker. +func TestCallWorkflowBoundary_BuilderWritesBoundaryEvents(t *testing.T) { + n := firstCallWorkflow(t, cwBoundaryWF(`call workflow M.WF_Child(WorkflowContext = $WorkflowContext) as callChild + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; } + boundary event non interrupting timer addHours([%CurrentDateTime%], 4) { call microflow M.ACT_Remind; };`)) + if len(n.BoundaryEvents) != 2 { + t.Fatalf("parsed boundary events = %d, want 2", len(n.BoundaryEvents)) + } + + act := buildCallWorkflowActivity(n) + if len(act.BoundaryEvents) != 2 { + t.Fatalf("written boundary events = %d, want 2", len(act.BoundaryEvents)) + } + want := []struct{ typ, delay string }{ + {"InterruptingTimer", "addDays([%CurrentDateTime%], 1)"}, + {"NonInterruptingTimer", "addHours([%CurrentDateTime%], 4)"}, + } + for i, w := range want { + be := act.BoundaryEvents[i] + if be.EventType != w.typ || be.TimerDelay != w.delay { + t.Errorf("event %d = %s %q, want %s %q", i, be.EventType, be.TimerDelay, w.typ, w.delay) + } + if be.ID == "" { + t.Errorf("event %d has no ID", i) + } + if be.Flow == nil || len(be.Flow.Activities) == 0 { + t.Errorf("event %d has no handler flow", i) + } + } + if _, ok := act.BoundaryEvents[1].Flow.Activities[0].(*workflows.CallMicroflowTask); !ok { + t.Errorf("non-interrupting handler's first activity = %T, want the call microflow", act.BoundaryEvents[1].Flow.Activities[0]) + } + // The call itself is unchanged by the clause. + if act.Name != "callChild" || act.Workflow != "M.WF_Child" { + t.Errorf("call = %q -> %q, want callChild -> M.WF_Child", act.Name, act.Workflow) + } +} + +// Control: a call with no clause writes no boundary events. +func TestCallWorkflowBoundary_NoClauseNoEvents(t *testing.T) { + n := firstCallWorkflow(t, cwBoundaryWF(`call workflow M.WF_Child;`)) + if act := buildCallWorkflowActivity(n); len(act.BoundaryEvents) != 0 { + t.Errorf("boundary events = %d, want 0", len(act.BoundaryEvents)) + } +} + +// Every workflow rule walks nested flows through walkWorkflowActivities and its +// siblings; a boundary body under `call workflow` must be reached like one under +// `call microflow`. Each "flagged" row would be a build error; each control row +// is the same body where Mendix accepts it. +func TestCallWorkflowBoundary_ValidatorRecursesIntoBody(t *testing.T) { + cases := []struct { + name, body, rule string + want bool + }{ + // MDL-WF08 (CE1844): no end inside a non-interrupting boundary path. + {"WF08 end under non-interrupting", + `call workflow M.WF_Child boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; };`, + "MDL-WF08", true}, + {"WF08 control: end under interrupting", + `call workflow M.WF_Child boundary event interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; };`, + "MDL-WF08", false}, + // MDL-WF09 (CE6671): an activity after an end in the body. + {"WF09 activity after end in body", + `call workflow M.WF_Child boundary event interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; call microflow M.ACT_X; };`, + "MDL-WF09", true}, + // MDL-WF18 (CE1613): a nested call workflow keyed on a non-context name. + {"WF18 nested call workflow key", + `call workflow M.WF_Child boundary event interrupting timer addDays([%CurrentDateTime%], 1) { call workflow M.WF_Other(Task = $WorkflowContext); };`, + "MDL-WF18", true}, + {"WF18 control: nested key is WorkflowContext", + `call workflow M.WF_Child boundary event interrupting timer addDays([%CurrentDateTime%], 1) { call workflow M.WF_Other(WorkflowContext = $WorkflowContext); };`, + "MDL-WF18", false}, + // MDL-WF19 (CE0117): the timer expression and expressions in the body. + {"WF19 timer on $WorkflowUserTask", + `call workflow M.WF_Child boundary event interrupting timer addDays($WorkflowUserTask/StartTime, 1) { };`, + "MDL-WF19", true}, + {"WF19 argument in the body", + `call workflow M.WF_Child boundary event interrupting timer addDays([%CurrentDateTime%], 1) { call microflow M.ACT_X(Workflow = $Workflow); };`, + "MDL-WF19", true}, + {"WF19 control: timer on the workflow instance", + `call workflow M.WF_Child boundary event interrupting timer addDays($WorkflowInstance/StartTime, 1) { };`, + "MDL-WF19", false}, + // MDL-WF05: an activity inside the body is a jump target like any other. + {"WF05 control: jump to an activity in the body", + `call workflow M.WF_Child boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { user task Escalate 'Escalate' page M.P outcomes 'a' { } 'b' { }; }; + user task Next 'Next' page M.P outcomes 'a' { } 'b' { jump to Escalate; };`, + "MDL-WF05", false}, + // MDL-WF15: at most one interrupting boundary event per activity. + {"WF15 two interrupting", + `call workflow M.WF_Child + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { } + boundary event interrupting timer addDays([%CurrentDateTime%], 2) { };`, + "MDL-WF15", true}, + {"WF15 control: one of each", + `call workflow M.WF_Child + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { } + boundary event non interrupting timer addDays([%CurrentDateTime%], 2) { };`, + "MDL-WF15", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var got []linter.Violation + for _, v := range ValidateWorkflow(parseWorkflowStmt(t, cwBoundaryWF(c.body))) { + if v.RuleID == c.rule { + got = append(got, v) + } + } + if (len(got) > 0) != c.want { + t.Errorf("%s fired = %v, want %v: %v", c.rule, len(got) > 0, c.want, got) + } + }) + } +} + +// The helpers that count or classify boundary events feed the rewrite guard +// (#948), the bare-timer refusal (MDL-WF07) and the notification version gate. +func TestCallWorkflowBoundary_CountedAndClassified(t *testing.T) { + stmt := parseWorkflowStmt(t, cwBoundaryWF(`call workflow M.WF_Child + boundary event interrupting notification Withdrawn { } + boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { };`)) + if n := countAuthoredBoundaryEvents(stmt.Activities); n != 2 { + t.Errorf("countAuthoredBoundaryEvents = %d, want 2", n) + } + if !workflowUsesNotificationEvents(stmt.Activities) { + t.Error("a notification boundary event on call workflow must trip the 11.11 notification gate") + } +} + +// Describe output of a call workflow with boundary events re-parses with both +// events intact — the round trip the missing grammar clause broke. +func TestCallWorkflowBoundary_DescribeReparses(t *testing.T) { + call := &workflows.CallWorkflowActivity{Workflow: "M.WF_Child", ParameterExpression: "$WorkflowContext"} + call.Name, call.Caption = "callChild", "Call child" + call.BoundaryEvents = []*workflows.BoundaryEvent{ + {EventType: "InterruptingTimer", TimerDelay: "addDays([%CurrentDateTime%], 3)"}, + {EventType: "NonInterruptingTimer", TimerDelay: "addDays([%CurrentDateTime%], 1)"}, + } + body := strings.Join(formatSingleActivity(call, " "), "\n") + src := "create workflow M.W parameter $C: M.E\nbegin\n" + body + "\nend workflow;" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("describe output does not parse: %v\n%s", errs, src) + } + n, ok := prog.Statements[0].(*ast.CreateWorkflowStmt).Activities[0].(*ast.WorkflowCallWorkflowNode) + if !ok { + t.Fatalf("re-parsed activity is not a call workflow\n%s", src) + } + if len(n.BoundaryEvents) != 2 { + t.Errorf("boundary events after re-parse = %d, want 2\n%s", len(n.BoundaryEvents), src) + } +} + +// ALTER's end-ancestry check reads the STORED workflow: a task inside a +// non-interrupting boundary path of a call workflow is "under a non-interrupting +// boundary" (CE1844 for an end there) exactly as under a user task's. +func TestCallWorkflowBoundary_StoredEndAncestry(t *testing.T) { + task := &workflows.UserTask{Outcomes: []*workflows.UserTaskOutcome{{Value: "a"}, {Value: "b"}}} + task.Name = "InCallBoundary" + call := &workflows.CallWorkflowActivity{Workflow: "WF.Child"} + call.Name = "callChild" + call.BoundaryEvents = []*workflows.BoundaryEvent{{ + EventType: "NonInterruptingTimer", + Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{task}}, + }} + wf := &workflows.Workflow{Name: "W", Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{call}}} + wf.ContainerID = "mod1" + mod := &model.Module{Name: "WF"} + mod.ID = "mod1" + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListWorkflowsFunc: func() ([]*workflows.Workflow, error) { return []*workflows.Workflow{wf}, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb)) + + for src, refused := range map[string]bool{ + `alter workflow WF.W insert outcome 'Stop' on InCallBoundary { end workflow; };`: true, + // Control: same target, nothing that ends. + `alter workflow WF.W insert outcome 'Stop' on InCallBoundary { };`: false, + } { + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + got := validateAlterWorkflowEndAncestry(ctx, prog.Statements[0].(*ast.AlterWorkflowStmt)) + if (len(got) > 0) != refused { + t.Errorf("%s: refused = %v, want %v: %v", src, len(got) > 0, refused, got) + } + } +} diff --git a/mdl/executor/xpath_author_layout_test.go b/mdl/executor/xpath_author_layout_test.go new file mode 100644 index 0000000000..88e623e92d --- /dev/null +++ b/mdl/executor/xpath_author_layout_test.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1542 (ako/mxcli#1440): a retrieve's XPath lost the layout +// its author gave it. +// +// retrieve $Notes from M.Note where [IsOpen = true() and +// Count > 0]; +// +// was stored as `[IsOpen = true() and Count > 0]` — the line break and tab +// dropped — while every other expression slot keeps its line breaks. Studio Pro +// keeps the layout an author gives a constraint, so a describe/exec round trip +// changed the model. A constraint laid out over several lines is now stored as +// written; a flat one is laid out as before (upstream #979). + +const authoredXPath = "[IsOpen = true() and\n\tCount > 0]" + +func TestRetrieveXPath_KeepsTheAuthorsLineBreaks(t *testing.T) { + cases := []struct{ name, where, want string }{ + {"line break and tab", "[IsOpen = true() and\n\tCount > 0]", authoredXPath}, + {"operator leading the next line", "[IsOpen = true()\n and Count > 0]", "[IsOpen = true()\n and Count > 0]"}, + {"operator case still folded", "[IsOpen = true() AND\n\tCount > 0]", authoredXPath}, + {"bare where", "IsOpen = true() and\n\tCount > 0", authoredXPath}, + // Controls: a flat constraint is stored as before. + {"flat and short", "[IsOpen = true() and Count > 0]", "[IsOpen = true() and Count > 0]"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := retrieveConstraintOf(t, "mdl 1;\ncreate microflow M.F ()\nbegin\n retrieve $Notes from M.Note where "+c.where+";\nend;\n") + if got != c.want { + t.Errorf("stored %q, want %q", got, c.want) + } + }) + } +} + +// The flat control's long sibling: a constraint written on one line and too long +// to read there is still laid out by the writer (upstream #979). +func TestRetrieveXPath_FlatLongConstraintIsStillLaidOut(t *testing.T) { + got := retrieveConstraintOf(t, "mdl 1;\ncreate microflow M.F ()\nbegin\n retrieve $L from M.Req where [Archived = false and Status = 'Open' and Priority = 'High' and Category = 'Electrical' and Severity > 3];\nend;\n") + if !strings.HasPrefix(got, "[\n ") { + t.Errorf("stored %q, want the #979 layout", got) + } +} + +// describe prints an authored layout as stored, and the description stores it +// again: the round trip the issue reports as changing the model. +func TestRetrieveXPath_AuthoredLayoutSurvivesDescribeAndExec(t *testing.T) { + for _, stored := range []string{ + authoredXPath, + "[IsOpen = true()\nand Count > 0]", + // The writer's own layout of a long flat constraint: described flat, and + // re-derived on write. + "[\n Archived = false\n and Status = 'Open'\n and Priority = 'High'\n and Category = 'Electrical'\n and Severity > 3\n]", + } { + e := newTestExecutor() + described := e.formatAction(µflows.RetrieveAction{ + OutputVariable: "Notes", + Source: µflows.DatabaseRetrieveSource{ + EntityQualifiedName: "M.Note", + XPathConstraint: stored, + }, + }, nil, nil) + got := retrieveConstraintOf(t, "mdl 1;\ncreate microflow M.F ()\nbegin\n "+described+"\nend;\n") + if got != stored { + t.Errorf("stored %q\ndescribed as:\n%s\nre-executed it stores %q", stored, described, got) + } + } +} + +// An access rule keeps its constraint's source text as the retrieve does, and +// shares the writer, so it keeps an authored layout too — and its describer +// must print it, or describe/exec of the rule would now change the model. +func TestAccessRuleXPath_AuthoredLayoutSurvivesDescribeAndExec(t *testing.T) { + ctx := &ExecContext{LanguageVersion: langver.V1} + for _, stored := range []string{ + authoredXPath, + "[IsOpen = true()\nand Count > 0]", + "[IsOpen = true()][Count > 0]", + "[\n Archived = false\n and Status = 'Open'\n and Priority = 'High'\n and Category = 'Electrical'\n and Severity > 3\n]", + } { + described := entityGrantMDL(ctx, "read *", "M.Note", []string{"M.R"}, stored) + prog, errs := visitor.Build("mdl 1;\n" + described) + if len(errs) > 0 { + t.Fatalf("%q: description does not parse: %v\n%s", stored, errs, described) + } + written := prog.Statements[0].(*ast.GrantEntityAccessStmt).XPathConstraint + if got := accessRuleXPathConstraint(nil, written, "M.Note"); got != stored { + t.Errorf("stored %q\ndescribed as:\n%s\nre-executed it stores %q", stored, described, got) + } + } +} + +// A Studio Pro constraint can end in a line break (WorkflowCommons' +// WorkflowUserTaskView rule does). Described as stored, the trailing newline +// made it fail IsBracketedXPath and fall back to the deprecated quoted form — +// which flattening used to hide by trimming. The layout inside the brackets +// is the author's; the whitespace outside them is nothing a bracketed +// constraint can say, and is dropped as before. +func TestAccessRuleXPath_TrailingLineBreakStaysBracketed(t *testing.T) { + ctx := &ExecContext{LanguageVersion: langver.V1} + stored := "[M.Note_Owner/M.Owner[Active = true()]]\n[IsOpen = true()\n\tor Count > 0]\n" + described := entityGrantMDL(ctx, "read *", "M.Note", []string{"M.R"}, stored) + if !strings.Contains(described, " where [") { + t.Fatalf("described in the quoted form, want brackets:\n%s", described) + } + prog, errs := visitor.Build("mdl 1;\n" + described) + if len(errs) > 0 { + t.Fatalf("description does not parse: %v\n%s", errs, described) + } + written := prog.Statements[0].(*ast.GrantEntityAccessStmt).XPathConstraint + if got, want := accessRuleXPathConstraint(nil, written, "M.Note"), strings.TrimSpace(stored); got != want { + t.Errorf("re-executed it stores %q, want %q", got, want) + } +} diff --git a/mdl/grammar/domains/MDLDomainModel.g4 b/mdl/grammar/domains/MDLDomainModel.g4 index c53808b6e3..d04a7857b1 100644 --- a/mdl/grammar/domains/MDLDomainModel.g4 +++ b/mdl/grammar/domains/MDLDomainModel.g4 @@ -42,9 +42,14 @@ entityOptions // COMMENT is deliberately absent — it was parsed and dropped. Use the `/** … */` // doc comment before the statement, or ALTER ENTITY … SET COMMENT. +// +// EXPORT LEVEL is the microflow clause, and means what it means there: whether +// the element is usable outside a Protected (add-on) module. An absent clause +// keeps the stored level (mendixlabs/mxcli#1498). entityOption : INDEX indexDefinition | eventHandlerDefinition + | microflowExportLevelClause ; // Entity event handler: ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK CALL Mod.Microflow($currentObject) [RAISE ERROR] @@ -88,6 +93,7 @@ attributeConstraint | DEFAULT (literal | expression) | REQUIRED (constraintErrorKeyword STRING_LITERAL)? | CALCULATED (BY? qualifiedName)? + | microflowExportLevelClause // EXPORT LEVEL API | HIDDEN (#1498) ; // `error message '…'` is the one spelling of a rule's user-facing message @@ -197,6 +203,7 @@ associationOption | STORAGE (COLON /* @alias MDL-DEPR064 */)? (COLUMN | TABLE) | DELETE_BEHAVIOR /* @alias MDL-DEPR022 */ deleteBehavior errorMessageClause? | onDeleteClause + | microflowExportLevelClause // EXPORT LEVEL API | HIDDEN (#1498) // R9: documentation is a `/** … */` doc comment before the statement. | COMMENT /* @alias MDL-DEPR100 */ STRING_LITERAL ; @@ -284,6 +291,7 @@ alterEntityAction | SET DOCUMENTATION STRING_LITERAL | SET COMMENT /* @alias MDL-DEPR135 */ STRING_LITERAL // R9: set documentation | SET POSITION LPAREN NUMBER_LITERAL COMMA NUMBER_LITERAL RPAREN + | SET microflowExportLevelClause // SET EXPORT LEVEL API | HIDDEN (#1498) | 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 @@ -322,6 +330,7 @@ alterAssociationAction | SET onDeleteClause | SET OWNER (DEFAULT | BOTH) | SET STORAGE (COLUMN | TABLE) + | SET microflowExportLevelClause // SET EXPORT LEVEL API | HIDDEN (#1498) | SET DOCUMENTATION STRING_LITERAL | SET COMMENT /* @alias MDL-DEPR135 */ STRING_LITERAL // R9: set documentation // Line anchors: where the connector attaches to each entity box, as a @@ -348,6 +357,7 @@ alterEnumerationAction | RENAME VALUE enumValueName TO enumValueName | MODIFY VALUE enumValueName CAPTION STRING_LITERAL | DROP VALUE ifExists? enumValueName + | SET microflowExportLevelClause // SET EXPORT LEVEL API | HIDDEN (#1498) | SET DOCUMENTATION STRING_LITERAL | SET COMMENT /* @alias MDL-DEPR135 */ STRING_LITERAL // R9: set documentation ; @@ -425,6 +435,7 @@ enumerationOptions // doc comment before the statement. enumerationOption : FOLDER STRING_LITERAL // place the enumeration in a module folder (Bug 12b) + | microflowExportLevelClause // EXPORT LEVEL API | HIDDEN (#1498) ; // ============================================================================= diff --git a/mdl/grammar/domains/MDLMicroflow.g4 b/mdl/grammar/domains/MDLMicroflow.g4 index 852344669b..07584f508c 100644 --- a/mdl/grammar/domains/MDLMicroflow.g4 +++ b/mdl/grammar/domains/MDLMicroflow.g4 @@ -73,12 +73,18 @@ javaActionTypeParameters : LESS_THAN identifierOrKeyword (COMMA identifierOrKeyword)* GREATER_THAN ; +// A parameter's description is a `/** … */` doc comment before it, as on an +// entity attribute. DESCRIBE printed it as a trailing `--` comment, which the +// lexer discards, so a script copying that form lost it (mendixlabs/mxcli#1588). javaActionParameter - : parameterName COLON dataType NOT_NULL? + : docComment? parameterName COLON dataType NOT_NULL? ; +// `returns T as $Name` names the return value — the default name of the +// variable a microflow call stores it in — with the spelling a microflow's +// return variable uses (mendixlabs/mxcli#1588). javaActionReturnType - : RETURNS dataType + : RETURNS dataType (AS VARIABLE)? ; // EXPOSED AS 'Caption' IN 'Category' — appear in Studio Pro's toolbox diff --git a/mdl/grammar/domains/MDLWorkflow.g4 b/mdl/grammar/domains/MDLWorkflow.g4 index ebb2a074e6..d91bd647b8 100644 --- a/mdl/grammar/domains/MDLWorkflow.g4 +++ b/mdl/grammar/domains/MDLWorkflow.g4 @@ -306,9 +306,14 @@ workflowParameterMapping : qualifiedName EQUALS STRING_LITERAL ; +// A call-workflow activity owns boundary events like a call-microflow one +// (Workflows$CallWorkflowActivity.boundaryEvents, 10.14+). The reader, writer, +// describer and ALTER all handled them; only this rule did not, so describe +// output of such a workflow did not re-parse (mendixlabs/mxcli#1532). workflowCallWorkflowStmt : CALL WORKFLOW qualifiedName workflowCallArguments? (AS workflowActivityName)? workflowCaption? (WITH /* @alias MDL-DEPR008 */ LPAREN workflowParameterMapping (COMMA workflowParameterMapping)* RPAREN)? + (BOUNDARY EVENT workflowBoundaryEventClause ((BOUNDARY EVENT)? workflowBoundaryEventClause)*)? ; workflowDecisionStmt diff --git a/mdl/linter/report.go b/mdl/linter/report.go index a51eb28621..726f4b32d7 100644 --- a/mdl/linter/report.go +++ b/mdl/linter/report.go @@ -78,6 +78,7 @@ var categoryMapping = map[string]string{ "CONV015": "Quality", "CONV018": "Quality", "CONV019": "Quality", + "CONV020": "Quality", // Architecture "ARCH001": "Architecture", diff --git a/mdl/linter/rules/conv_created_never_persisted.go b/mdl/linter/rules/conv_created_never_persisted.go new file mode 100644 index 0000000000..af0a83eda3 --- /dev/null +++ b/mdl/linter/rules/conv_created_never_persisted.go @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "fmt" + "reflect" + "regexp" + "strings" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// CreatedObjectNeverPersistedRule flags a microflow that creates an object of a +// persistent entity without committing it and then does nothing that could +// persist it or hand it on: no commit, no return, no argument to any call, no +// association to another object, no list, no delete or rollback +// (mendixlabs/mxcli#1518). +// +// The object is discarded when the microflow ends. An after-startup seed +// microflow that logged "Seeded 20" while the table stayed empty is the case +// the rule exists for — check, mx check and the build are all silent on it. +// +// The rule is deliberately conservative: it reports only what it can prove. +// - Only an explicit Commit = No create of an entity the catalog knows to be +// persistent, in a microflow (a nanoflow's object lives in the client, and +// is commonly handed to a page through means the flow does not show). +// - It is flow-insensitive: a commit on any path, including an error +// handler, counts, so an object committed on one branch only is not +// reported (an accepted false negative). +// - Every string in every object of the flow is scanned. The only uses that +// do NOT count as passing the object on are reading a member of it +// ($Obj/Attr) and changing it without commit. Anything else that names +// the variable — a bare $Obj in any expression, or a field holding the +// variable name — counts as a use, including uses the rule does not know +// about. When in doubt it stays quiet. +type CreatedObjectNeverPersistedRule struct{} + +func NewCreatedObjectNeverPersistedRule() *CreatedObjectNeverPersistedRule { + return &CreatedObjectNeverPersistedRule{} +} + +func (r *CreatedObjectNeverPersistedRule) ID() string { return "CONV020" } +func (r *CreatedObjectNeverPersistedRule) Name() string { return "CreatedObjectNeverPersisted" } +func (r *CreatedObjectNeverPersistedRule) Category() string { return "quality" } +func (r *CreatedObjectNeverPersistedRule) DefaultSeverity() linter.Severity { + return linter.SeverityWarning +} + +func (r *CreatedObjectNeverPersistedRule) Description() string { + return "A microflow creates an object of a persistent entity without committing it, and never commits, returns, passes on, stores or deletes it" +} + +func (r *CreatedObjectNeverPersistedRule) Check(ctx *linter.LintContext) []linter.Violation { + if ctx.Reader() == nil { + return nil + } + + persistent := map[string]bool{} + for e := range ctx.Entities() { + if e.EntityType == "Persistent" { + persistent[strings.ToLower(e.QualifiedName)] = true + } + } + if len(persistent) == 0 { + return nil + } + isPersistent := func(qn string) bool { return persistent[strings.ToLower(qn)] } + + var violations []linter.Violation + for mf := range ctx.Microflows() { + if mf.MicroflowType != "MICROFLOW" || ctx.IsExcluded(mf.ModuleName) { + continue + } + full, err := ctx.FullMicroflow(model.ID(mf.ID)) + if err != nil || full == nil || full.ObjectCollection == nil { + continue + } + violations = append(violations, findUnpersistedCreates(full.ObjectCollection, mf, isPersistent, r)...) + } + return violations +} + +// findUnpersistedCreates reports each uncommitted create of a persistent +// entity in oc (loops included) whose output variable is never used in a way +// that could persist it or hand it on. +func findUnpersistedCreates(oc *microflows.MicroflowObjectCollection, mf linter.Microflow, + isPersistent func(string) bool, r *CreatedObjectNeverPersistedRule) []linter.Violation { + + var violations []linter.Violation + for _, create := range uncommittedCreates(oc.Objects) { + if create.OutputVariable == "" || !isPersistent(create.EntityQualifiedName) { + continue + } + if variableEscapes(oc, create) { + continue + } + violations = append(violations, linter.Violation{ + RuleID: r.ID(), + Severity: r.DefaultSeverity(), + Message: fmt.Sprintf("%s '%s.%s' creates $%s (%s) without committing it, and never commits, "+ + "returns or passes it on: the object is discarded when the microflow ends.", + mf.DocumentNounTitle(), mf.ModuleName, mf.Name, create.OutputVariable, create.EntityQualifiedName), + Location: linter.Location{ + Module: mf.ModuleName, + DocumentType: mf.DocumentNoun(), + DocumentName: mf.Name, + DocumentID: mf.ID, + }, + Suggestion: fmt.Sprintf("Commit $%s (or create it with commit), return it, or pass it on; "+ + "if it is only a temporary value, use a non-persistent entity.", create.OutputVariable), + }) + } + return violations +} + +// uncommittedCreates returns the create actions with an explicit Commit = No, +// at any loop depth. An empty commit value is unknown, not No, and is skipped. +func uncommittedCreates(objects []microflows.MicroflowObject) []*microflows.CreateObjectAction { + var out []*microflows.CreateObjectAction + for _, obj := range objects { + switch o := obj.(type) { + case *microflows.ActionActivity: + if c, ok := o.Action.(*microflows.CreateObjectAction); ok && c.Commit == microflows.CommitTypeNo { + out = append(out, c) + } + case *microflows.LoopedActivity: + if o.ObjectCollection != nil { + out = append(out, uncommittedCreates(o.ObjectCollection.Objects)...) + } + } + } + return out +} + +// variableEscapes reports whether anything in the flow, other than create +// itself, names create's output variable in a way that is not merely reading +// a member or changing it without commit. +func variableEscapes(oc *microflows.MicroflowObjectCollection, create *microflows.CreateObjectAction) bool { + name := create.OutputVariable + // $Name as a whole word, case-insensitively, and what follows it. + ref := regexp.MustCompile(`(?i)\$` + regexp.QuoteMeta(name) + `\b(\s*/)?`) + w := &escapeWalker{ + name: name, + ref: ref, + skip: create, + visited: map[uintptr]bool{}, + } + w.walk(reflect.ValueOf(oc)) + return w.escapes +} + +type escapeWalker struct { + name string + ref *regexp.Regexp + skip *microflows.CreateObjectAction + visited map[uintptr]bool + escapes bool +} + +func (w *escapeWalker) walk(v reflect.Value) { + if w.escapes || !v.IsValid() { + return + } + switch v.Kind() { + case reflect.Pointer: + if v.IsNil() { + return + } + p := v.Pointer() + if w.visited[p] { + return + } + w.visited[p] = true + if v.Type() == reflect.TypeOf(w.skip) && v.Interface().(*microflows.CreateObjectAction) == w.skip { + return + } + if ch, ok := v.Interface().(*microflows.ChangeObjectAction); ok && w.isUncommittedChangeOfVar(ch) { + // Changing the object without commit does not persist it; the + // new member values are still scanned (setting an association + // of the object to itself would be a use). + for _, c := range ch.Changes { + w.walk(reflect.ValueOf(c)) + } + return + } + w.walk(v.Elem()) + case reflect.Interface: + if !v.IsNil() { + w.walk(v.Elem()) + } + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + if v.Type().Field(i).IsExported() { + w.walk(v.Field(i)) + } + } + case reflect.Slice, reflect.Array: + if v.Type().Elem().Kind() == reflect.Uint8 { + return // raw bytes hold no expression + } + for i := 0; i < v.Len(); i++ { + w.walk(v.Index(i)) + } + case reflect.Map: + iter := v.MapRange() + for iter.Next() { + w.walk(iter.Key()) + w.walk(iter.Value()) + } + case reflect.String: + w.escapes = w.stringUses(v.String()) + } +} + +func (w *escapeWalker) isUncommittedChangeOfVar(ch *microflows.ChangeObjectAction) bool { + return strings.EqualFold(ch.ChangeVariable, w.name) && ch.Commit == microflows.CommitTypeNo +} + +// stringUses reports whether s uses the variable as anything other than the +// owner of a member being read. A field holding exactly the variable name is +// a variable-name field (commit, delete, rollback, loop source, retrieve over +// an association, a page's passed object...) and is a use. +func (w *escapeWalker) stringUses(s string) bool { + if strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(s), "$"), w.name) { + return true + } + for _, m := range w.ref.FindAllStringSubmatch(s, -1) { + if m[1] == "" { // not followed by '/': the object itself + return true + } + } + return false +} diff --git a/mdl/linter/rules/conv_created_never_persisted_test.go b/mdl/linter/rules/conv_created_never_persisted_test.go new file mode 100644 index 0000000000..c6bac0e040 --- /dev/null +++ b/mdl/linter/rules/conv_created_never_persisted_test.go @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1518: an after-startup seed microflow created its objects +// with commit = No and never committed, returned or passed them on. It logged +// "Seeded 20"; the table had 0 rows. check, mx check and the build said nothing. + +func act(a microflows.MicroflowAction) *microflows.ActionActivity { + return µflows.ActionActivity{Action: a} +} + +// createItem is the issue's `$o = create Tmp.Item (Cnt = 1)`. +func createItem(commit microflows.CommitType) *microflows.ActionActivity { + return act(µflows.CreateObjectAction{ + EntityQualifiedName: "Tmp.Item", + OutputVariable: "o", + Commit: commit, + InitialMembers: []*microflows.MemberChange{ + {AttributeQualifiedName: "Tmp.Item.Cnt", Type: microflows.MemberChangeTypeSet, Value: "1"}, + }, + }) +} + +func persistentTmp(qn string) bool { + return qn == "Tmp.Item" || qn == "Tmp.Other" +} + +func lintCreated(objects ...microflows.MicroflowObject) []linter.Violation { + oc := µflows.MicroflowObjectCollection{Objects: objects} + return findUnpersistedCreates(oc, testMicroflow(), persistentTmp, NewCreatedObjectNeverPersistedRule()) +} + +// The issue's repro: create, nothing else, return an unrelated Boolean. +func TestCreatedNeverPersisted_IssueRepro(t *testing.T) { + got := lintCreated( + µflows.StartEvent{}, + act(µflows.CreateVariableAction{VariableName: "b", DataType: µflows.BooleanType{}, InitialValue: "true"}), + createItem(microflows.CommitTypeNo), + µflows.EndEvent{ReturnValue: "$b"}, + ) + if len(got) != 1 { + t.Fatalf("want 1 violation, got %d: %+v", len(got), got) + } + v := got[0] + if v.RuleID != "CONV020" || v.Severity != linter.SeverityWarning { + t.Errorf("got %s/%s, want CONV020/warning", v.RuleID, v.Severity) + } + if !strings.Contains(v.Message, "$o") || !strings.Contains(v.Message, "Tmp.Item") { + t.Errorf("message %q should name the variable and the entity", v.Message) + } +} + +// Uses that do not persist or hand the object on: still reported. +func TestCreatedNeverPersisted_NonEscapingUsesStillFlagged(t *testing.T) { + cases := map[string][]microflows.MicroflowObject{ + "member read in a log message": { + act(µflows.LogMessageAction{LogNodeName: "'Seed'", TemplateParameters: []string{"toString($o/Cnt)"}}), + }, + "change without commit": { + act(µflows.ChangeObjectAction{ChangeVariable: "o", Commit: microflows.CommitTypeNo, + Changes: []*microflows.MemberChange{{AttributeQualifiedName: "Tmp.Item.Cnt", Value: "$o/Cnt + 1"}}}), + }, + "member read in a split": { + µflows.ExclusiveSplit{SplitCondition: µflows.ExpressionSplitCondition{Expression: "$o/Cnt > 0"}}, + }, + "member read in the return value": { + µflows.EndEvent{ReturnValue: "$o / Cnt > 0"}, + }, + "inside a loop": { + µflows.LoopedActivity{ + LoopSource: µflows.IterableList{ListVariableName: "Rows", VariableName: "Row"}, + ObjectCollection: µflows.MicroflowObjectCollection{Objects: []microflows.MicroflowObject{createItem(microflows.CommitTypeNo)}}, + }, + }, + } + for name, extra := range cases { + t.Run(name, func(t *testing.T) { + objects := extra + if name != "inside a loop" { + objects = append([]microflows.MicroflowObject{createItem(microflows.CommitTypeNo)}, extra...) + } + if got := lintCreated(objects...); len(got) != 1 { + t.Errorf("want 1 violation, got %d: %+v", len(got), got) + } + }) + } +} + +// Every way the object can be persisted or handed on: not reported. +func TestCreatedNeverPersisted_EscapesAreNotFlagged(t *testing.T) { + cases := map[string]microflows.MicroflowObject{ + "commit": act(µflows.CommitObjectsAction{CommitVariable: "o", WithEvents: true}), + "change with commit": act(µflows.ChangeObjectAction{ChangeVariable: "o", Commit: microflows.CommitTypeYes}), + "change with commit, no evt": act(µflows.ChangeObjectAction{ChangeVariable: "o", Commit: microflows.CommitTypeYesWithoutEvents}), + "returned": µflows.EndEvent{ReturnValue: "$o"}, + "microflow argument": act(µflows.MicroflowCallAction{MicroflowCall: µflows.MicroflowCall{ + Microflow: "Tmp.SUB_Save", + ParameterMappings: []*microflows.MicroflowCallParameterMapping{{Parameter: "Tmp.SUB_Save.Item", Argument: "$o"}}}}), + "java action argument": act(µflows.JavaActionCallAction{JavaAction: "Tmp.JA_Save", + ParameterMappings: []*microflows.JavaActionParameterMapping{{Parameter: "Tmp.JA_Save.Item", + Value: µflows.BasicCodeActionParameterValue{Argument: "$o"}}}}), + "javascript action argument": act(µflows.JavaScriptActionCallAction{JavaScriptAction: "Tmp.JSA_Save", + ParameterMappings: []*microflows.JavaScriptActionParameterMapping{{Parameter: "Tmp.JSA_Save.Item", + Value: µflows.BasicCodeActionParameterValue{Argument: "$o"}}}}), + "workflow context": act(µflows.WorkflowCallAction{Workflow: "Tmp.WF", WorkflowContextVariable: "o"}), + "export mapping": act(µflows.ExportMappingCallAction{MappingID: "m1", SourceVariable: "o", OutputVariable: "json"}), + "show page argument": act(µflows.ShowPageAction{PageName: "Tmp.Item_Edit", PageParameterMappings: []*microflows.PageParameterMapping{{Parameter: "Tmp.Item_Edit.Item", Argument: "$o"}}}), + "show page passed obj": act(µflows.ShowPageAction{PageName: "Tmp.Item_Edit", PassedObject: "o"}), + "added to a list": act(µflows.ChangeListAction{ChangeVariable: "Items", Type: microflows.ChangeListTypeAdd, Value: "$o"}), + "association on another object": act(µflows.ChangeObjectAction{ChangeVariable: "Order", Commit: microflows.CommitTypeNo, + Changes: []*microflows.MemberChange{{AssociationQualifiedName: "Tmp.Order_Item", Type: microflows.MemberChangeTypeSet, Value: "$o"}}}), + "association value of a new object": act(µflows.CreateObjectAction{EntityQualifiedName: "Tmp.Other", OutputVariable: "p", + Commit: microflows.CommitTypeYes, + InitialMembers: []*microflows.MemberChange{{AssociationQualifiedName: "Tmp.Other_Item", Value: "$o"}}}), + "deleted": act(µflows.DeleteObjectAction{DeleteVariable: "o"}), + "rolled back": act(µflows.RollbackObjectAction{RollbackVariable: "o"}), + "bare in a split": µflows.ExclusiveSplit{SplitCondition: µflows.ExpressionSplitCondition{Expression: "$o != empty"}}, + "case differs": act(µflows.CommitObjectsAction{CommitVariable: "O"}), + } + for name, use := range cases { + t.Run(name, func(t *testing.T) { + if got := lintCreated(createItem(microflows.CommitTypeNo), use); len(got) != 0 { + t.Errorf("want no violation, got %+v", got) + } + }) + } +} + +// A commit reached only through an error handler, or on one branch only, is +// still a commit: the rule is flow-insensitive on purpose. +func TestCreatedNeverPersisted_ErrorHandlerCommitCounts(t *testing.T) { + create := createItem(microflows.CommitTypeNo) + failing := act(µflows.MicroflowCallAction{ErrorHandlingType: microflows.ErrorHandlingTypeCustom, + MicroflowCall: µflows.MicroflowCall{Microflow: "Tmp.SUB_Fails"}}) + handlerCommit := act(µflows.CommitObjectsAction{CommitVariable: "o"}) + if got := lintCreated(create, failing, handlerCommit, µflows.EndEvent{}); len(got) != 0 { + t.Errorf("want no violation, got %+v", got) + } +} + +// What the rule leaves alone by design. +func TestCreatedNeverPersisted_OutOfScope(t *testing.T) { + nonPersistent := act(µflows.CreateObjectAction{EntityQualifiedName: "Tmp.Helper", OutputVariable: "h", Commit: microflows.CommitTypeNo}) + unknownCommit := createItem("") + committed := createItem(microflows.CommitTypeYes) + noOutput := act(µflows.CreateObjectAction{EntityQualifiedName: "Tmp.Item", Commit: microflows.CommitTypeNo}) + for name, obj := range map[string]microflows.MicroflowObject{ + "non-persistent entity": nonPersistent, + "commit value not stored": unknownCommit, + "created with commit": committed, + "no output variable": noOutput, + } { + t.Run(name, func(t *testing.T) { + if got := lintCreated(obj); len(got) != 0 { + t.Errorf("want no violation, got %+v", got) + } + }) + } +} + +// Nanoflows and rules are skipped by Check, and without a reader it does nothing. +func TestCreatedNeverPersistedRule_NilReader(t *testing.T) { + if got := NewCreatedObjectNeverPersistedRule().Check(linter.NewLintContextFromDB(nil)); got != nil { + t.Errorf("want nil without a reader, got %v", got) + } +} + +// $o must not match $other or $o_2. +func TestCreatedNeverPersisted_WholeVariableNameOnly(t *testing.T) { + got := lintCreated(createItem(microflows.CommitTypeNo), + act(µflows.CommitObjectsAction{CommitVariable: "other"}), + µflows.EndEvent{ReturnValue: "$o_2"}, + ) + if len(got) != 1 { + t.Errorf("want 1 violation, got %d: %+v", len(got), got) + } +} diff --git a/mdl/linter/rules/snippet_call_appearance.go b/mdl/linter/rules/snippet_call_appearance.go new file mode 100644 index 0000000000..b16aa3c38c --- /dev/null +++ b/mdl/linter/rules/snippet_call_appearance.go @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/model" +) + +// SnippetCallAppearanceRule reports a snippet call that carries Class, Style, +// DynamicClasses or design properties (mendixlabs/mxcli#1511). +// +// A snippet call renders no element of its own — the snippet's widgets are +// inlined into the parent — so those settings have nothing to land on. +// Measured on Mendix 11.15.0 (`run --local`, `playwright check`): a snippetcall +// with Class 'probe-x', Style 'border: 3px solid red', DynamicClasses +// 'probe-dyn' and the design properties 'Align self' / 'Hide on' produced no +// element carrying any of them; the snippet's own container was a direct child +// of the enclosing container. A container on the same page with the same four +// settings carried every one (the control). `mx check` reports nothing, so +// without this the setting is silently useless. +// +// `mxcli check` warns the same as MDL-WIDGET48 when a script writes one; this +// rule finds the ones a project already holds, including those authored in +// Studio Pro, which offers the fields on a snippet call. +type SnippetCallAppearanceRule struct{} + +// NewSnippetCallAppearanceRule creates a new snippet call appearance rule. +func NewSnippetCallAppearanceRule() *SnippetCallAppearanceRule { + return &SnippetCallAppearanceRule{} +} + +func (r *SnippetCallAppearanceRule) ID() string { return "MPR013" } +func (r *SnippetCallAppearanceRule) Name() string { return "SnippetCallAppearance" } +func (r *SnippetCallAppearanceRule) Category() string { return "correctness" } +func (r *SnippetCallAppearanceRule) DefaultSeverity() linter.Severity { return linter.SeverityWarning } + +// RequiredCatalogMode: ctx.Widgets() reads widgets_data, which only a full +// catalog build fills. +func (r *SnippetCallAppearanceRule) RequiredCatalogMode() linter.CatalogMode { + return linter.CatalogFull +} + +func (r *SnippetCallAppearanceRule) Description() string { + return "Checks for snippet calls with Class, Style, DynamicClasses or design properties, which never reach the page (a snippet call renders no element)" +} + +const snippetCallWidgetType = "Forms$SnippetCallWidget" + +// Check reports one violation per styled snippet call. Class, Style and +// DynamicClasses come from the catalog; design properties, which the catalog +// does not record, from the stored document when a reader is available. +func (r *SnippetCallAppearanceRule) Check(ctx *linter.LintContext) []linter.Violation { + type call struct { + w linter.Widget + fields []string + } + var calls []*call + byContainer := map[string]map[string]*call{} + + for w := range ctx.Widgets() { + if ctx.IsExcluded(w.ModuleName) || w.WidgetType != snippetCallWidgetType { + continue + } + c := &call{w: w} + if strings.TrimSpace(w.Class) != "" { + c.fields = append(c.fields, "Class") + } + if strings.TrimSpace(w.Style) != "" { + c.fields = append(c.fields, "Style") + } + if strings.TrimSpace(w.DynamicClasses) != "" { + c.fields = append(c.fields, "DynamicClasses") + } + calls = append(calls, c) + if byContainer[w.ContainerID] == nil { + byContainer[w.ContainerID] = map[string]*call{} + } + byContainer[w.ContainerID][w.Name] = c + } + + if reader := ctx.Reader(); reader != nil { + for containerID, named := range byContainer { + raw, err := reader.GetRawUnit(model.ID(containerID)) + if err != nil || raw == nil { + continue + } + for name, keys := range findSnippetCallDesignProperties(raw) { + if c := named[name]; c != nil { + for _, k := range keys { + c.fields = append(c.fields, fmt.Sprintf("design property '%s'", k)) + } + } + } + } + } + + var violations []linter.Violation + for _, c := range calls { + if len(c.fields) == 0 { + continue + } + docType := "page" + if c.w.ContainerType == "SNIPPET" { + docType = "snippet" + } + violations = append(violations, linter.Violation{ + RuleID: r.ID(), + Severity: r.DefaultSeverity(), + Message: fmt.Sprintf("snippet call '%s' in %s sets %s, but a snippet call renders no element of its own "+ + "(the snippet's widgets are inlined into the parent), so the setting never reaches the page", + c.w.Name, c.w.ContainerQualifiedName, strings.Join(c.fields, ", ")), + Location: linter.Location{ + Module: c.w.ModuleName, + DocumentType: docType, + DocumentName: docNameFromQualified(c.w.ContainerQualifiedName), + DocumentID: c.w.ContainerID, + }, + Suggestion: "Put the class or style on a container inside the snippet, " + + "or wrap the snippet call in a container and style that container", + }) + } + return violations +} + +// findSnippetCallDesignProperties walks a raw page or snippet document and +// returns, per snippet call name, the design-property keys it stores. +// +// It visits every nested document rather than following named child slots: a +// snippet call can sit in any widget's children (layout grid columns, tab +// pages, pluggable widget slots, list view templates), and the one thing that +// identifies it is its $Type. +func findSnippetCallDesignProperties(raw map[string]any) map[string][]string { + out := map[string][]string{} + var walk func(v any) + walk = func(v any) { + switch n := v.(type) { + case map[string]any: + if extractStr(n["$Type"]) == snippetCallWidgetType { + if app, ok := n["Appearance"].(map[string]any); ok { + for _, dp := range getBsonArray(app["DesignProperties"]) { + if m, ok := dp.(map[string]any); ok { + if k := extractStr(m["Key"]); k != "" { + name := extractStr(n["Name"]) + out[name] = append(out[name], k) + } + } + } + } + } + for _, child := range n { + walk(child) + } + case []any: + for _, child := range n { + walk(child) + } + } + } + walk(raw) + return out +} diff --git a/mdl/linter/rules/snippet_call_appearance_test.go b/mdl/linter/rules/snippet_call_appearance_test.go new file mode 100644 index 0000000000..48cb1dbaed --- /dev/null +++ b/mdl/linter/rules/snippet_call_appearance_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/model" +) + +// mendixlabs/mxcli#1511. A snippet call renders no element of its own, so the +// Class / Style / DynamicClasses / design properties it carries never reach the +// DOM — measured on Mendix 11.15.0 with run --local + playwright check, against +// a container on the same page carrying the same settings as the control. + +// snippetCallCatalog is a real catalog holding the given widgets_data rows +// (Id, Name, WidgetType, ContainerId, ContainerQualifiedName, ContainerType, +// ModuleName, Class, Style, DynamicClasses) in module M. +func snippetCallCatalog(t *testing.T, rows ...string) *catalog.Catalog { + t.Helper() + cat, err := catalog.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cat.Close() }) + stmts := []string{ + `INSERT INTO modules_data (Id, Name, QualifiedName, ModuleName, Source) VALUES ('m1', 'M', 'M', 'M', '')`, + } + for _, r := range rows { + stmts = append(stmts, `INSERT INTO widgets_data (Id, Name, WidgetType, ContainerId, ContainerQualifiedName, + ContainerType, ModuleName, Class, Style, DynamicClasses) VALUES `+r) + } + for _, st := range stmts { + if _, err := cat.CatalogDB().Exec(st); err != nil { + t.Fatalf("%s: %v", st, err) + } + } + return cat +} + +func TestSnippetCallAppearance_ReportsClassStyleDynamicClasses(t *testing.T) { + cat := snippetCallCatalog(t, + `('w1', 'scStyled', 'Forms$SnippetCallWidget', 'p1', 'M.P', 'PAGE', 'M', 'probe-x', 'border: 3px solid red', '''d''')`, + // controls: a bare snippet call, and a styled container + `('w2', 'scBare', 'Forms$SnippetCallWidget', 'p1', 'M.P', 'PAGE', 'M', '', '', '')`, + `('w3', 'c1', 'Forms$DivContainer', 'p1', 'M.P', 'PAGE', 'M', 'probe-x', 'border: 1px', '')`, + ) + got := NewSnippetCallAppearanceRule().Check(linter.NewLintContext(cat, nil)) + if len(got) != 1 { + t.Fatalf("want exactly the styled snippet call reported, got %d: %+v", len(got), got) + } + v := got[0] + for _, want := range []string{"scStyled", "Class", "Style", "DynamicClasses", "renders no element"} { + if !strings.Contains(v.Message, want) { + t.Errorf("message %q lacks %q", v.Message, want) + } + } + if v.Location.DocumentName != "P" || v.Location.DocumentType != "page" { + t.Errorf("location = %+v, want page P", v.Location) + } + if !strings.Contains(v.Suggestion, "container") { + t.Errorf("suggestion %q does not point at a container", v.Suggestion) + } +} + +// Design properties are not in the catalog; they are read from the stored +// document when a reader is available. +func TestFindSnippetCallDesignProperties(t *testing.T) { + dp := func(key string) map[string]any { + return map[string]any{"$Type": "Forms$DesignPropertyValue", "Key": key} + } + call := func(name string, props ...any) map[string]any { + return map[string]any{ + "$Type": "Forms$SnippetCallWidget", + "Name": name, + "Appearance": map[string]any{ + "DesignProperties": append([]any{int32(3)}, props...), + }, + } + } + raw := map[string]any{ + "FormCall": map[string]any{"Arguments": []any{int32(2), map[string]any{ + "Widgets": []any{int32(2), map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "c1", + // a styled container is the control + "Appearance": map[string]any{"DesignProperties": []any{int32(3), dp("Align self")}}, + "Widgets": []any{int32(2), + call("scStyled", dp("Align self"), dp("Hide on")), + call("scBare"), + }, + }}, + }}}, + } + got := findSnippetCallDesignProperties(raw) + if len(got) != 1 || strings.Join(got["scStyled"], ",") != "Align self,Hide on" { + t.Fatalf("got %v, want only scStyled with Align self, Hide on", got) + } +} + +// rawUnitReader is dataview_layout_grid_test.go's. + +func TestSnippetCallAppearance_ReportsDesignPropertiesFromTheDocument(t *testing.T) { + cat := snippetCallCatalog(t, + `('w1', 'scDP', 'Forms$SnippetCallWidget', 'p1', 'M.P', 'PAGE', 'M', '', '', '')`, + ) + reader := rawUnitReader{units: map[model.ID]map[string]any{ + "p1": {"FormCall": map[string]any{"Arguments": []any{int32(2), map[string]any{ + "Widgets": []any{int32(2), map[string]any{ + "$Type": "Forms$SnippetCallWidget", + "Name": "scDP", + "Appearance": map[string]any{"DesignProperties": []any{int32(3), + map[string]any{"$Type": "Forms$DesignPropertyValue", "Key": "Align self"}}}, + }}, + }}}}, + }} + got := NewSnippetCallAppearanceRule().Check(linter.NewLintContext(cat, reader)) + if len(got) != 1 || !strings.Contains(got[0].Message, "Align self") { + t.Fatalf("want one violation naming the design property, got %+v", got) + } +} + +func TestSnippetCallAppearanceRule_Identity(t *testing.T) { + r := NewSnippetCallAppearanceRule() + if r.ID() != "MPR013" { + t.Errorf("ID = %q, want MPR013", r.ID()) + } + if r.RequiredCatalogMode() != linter.CatalogFull { + t.Error("ctx.Widgets() needs the full catalog; under the fast build the rule finds nothing") + } + if r.Name() == "" || r.Description() == "" { + t.Error("a rule with no name or description cannot be configured or explained") + } +} diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index 3c3865d386..3d3522ec28 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "unicode" @@ -31,6 +32,9 @@ type StarlarkRule struct { globals starlark.StringDict options map[string]any requiredMode CatalogMode + // optionWarnings collects lint-config.yaml values this run could not use; + // Check reports each as a warning after the rule has run on its default. + optionWarnings []string } // RequiredCatalogMode reports the catalog depth this rule needs (CatalogRequirer). @@ -59,6 +63,7 @@ func (r *StarlarkRule) Category() string { return r.category } // Check executes the Starlark check function and returns violations. func (r *StarlarkRule) Check(ctx *LintContext) []Violation { r.ctx = ctx + r.optionWarnings = nil // Create a new thread for execution thread := &starlark.Thread{ @@ -71,11 +76,28 @@ func (r *StarlarkRule) Check(ctx *LintContext) []Violation { // Call the check function result, err := starlark.Call(thread, r.checkFn, nil, nil) if err != nil { - return []Violation{ruleFailureViolation(r.id, err)} + return append(r.optionWarningViolations(), ruleFailureViolation(r.id, err)) } // Convert result to violations - return r.convertViolations(result) + return append(r.convertViolations(result), r.optionWarningViolations()...) +} + +// optionWarningViolations reports the configured option values the rule could +// not use. They are RuleFailure findings: a misconfigured lint-config.yaml says +// nothing about the project, so it must not move the score. +func (r *StarlarkRule) optionWarningViolations() []Violation { + var out []Violation + for _, msg := range r.optionWarnings { + out = append(out, Violation{ + RuleID: r.id, + Severity: SeverityWarning, + Message: msg, + Suggestion: "Fix the value in lint-config.yaml; until then the rule runs with its built-in default.", + RuleFailure: true, + }) + } + return out } // missingStructAttrRe matches the evaluator's message for reading a field a @@ -446,6 +468,9 @@ func (r *StarlarkRule) buildPredeclared() starlark.StringDict { // Options access: get_option("key") or get_option("key", default) "get_option": starlark.NewBuiltin("get_option", r.builtinGetOption), + // get_list_option("key", default): a list of strings, given in YAML + // as a list or as comma-separated text + "get_list_option": starlark.NewBuiltin("get_list_option", r.builtinGetListOption), } } @@ -776,6 +801,9 @@ func (r *StarlarkRule) builtinScheduledEvents(_ *starlark.Thread, _ *starlark.Bu // builtinGetOption returns a rule option value from the lint config file. // Signature: get_option(key, default=None) +// +// A value that cannot be represented in Starlark is reported as a warning and +// the default is returned — never a silent None (mendixlabs/mxcli#1509). func (r *StarlarkRule) builtinGetOption(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var key starlark.String var defaultVal starlark.Value = starlark.None @@ -785,27 +813,118 @@ func (r *StarlarkRule) builtinGetOption(_ *starlark.Thread, _ *starlark.Builtin, ); err != nil { return nil, err } - if r.options != nil { - if v, ok := r.options[string(key)]; ok { - return goValueToStarlark(v), nil + v, ok := r.options[string(key)] + if !ok || v == nil { + return defaultVal, nil + } + sv, err := goValueToStarlark(v) + if err != nil { + r.warnOption(string(key), v, err.Error()) + return defaultVal, nil + } + return sv, nil +} + +// builtinGetListOption returns a rule option as a list of strings. The option +// may be a YAML list of strings or one comma-separated string; parts are +// trimmed and empty parts dropped. An unset option returns the default; any +// other value is reported as a warning and also returns the default. +// Signature: get_list_option(key, default=None) +func (r *StarlarkRule) builtinGetListOption(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var key starlark.String + var defaultVal starlark.Value = starlark.None + if err := starlark.UnpackArgs("get_list_option", args, kwargs, + "key", &key, + "default?", &defaultVal, + ); err != nil { + return nil, err + } + v, ok := r.options[string(key)] + if !ok || v == nil { + return defaultVal, nil + } + var items []starlark.Value + switch val := v.(type) { + case string: + for _, part := range strings.Split(val, ",") { + if part = strings.TrimSpace(part); part != "" { + items = append(items, starlark.String(part)) + } + } + case []any: + for _, e := range val { + s, isStr := e.(string) + if !isStr { + r.warnOption(string(key), v, "must be a list of strings or a comma-separated string") + return defaultVal, nil + } + if s = strings.TrimSpace(s); s != "" { + items = append(items, starlark.String(s)) + } } + default: + r.warnOption(string(key), v, "must be a list of strings or a comma-separated string") + return defaultVal, nil } - return defaultVal, nil + return starlark.NewList(items), nil +} + +// warnOption records a configured option value the rule cannot use. +func (r *StarlarkRule) warnOption(key string, v any, problem string) { + r.optionWarnings = append(r.optionWarnings, fmt.Sprintf( + "lint-config.yaml: rules.%s.options.%s %s (got %T %v); the rule ran with its default", + r.id, key, problem, v, v)) } -// goValueToStarlark converts a Go value (from YAML unmarshaling) to a Starlark value. -func goValueToStarlark(v any) starlark.Value { +// goValueToStarlark converts a Go value (from YAML unmarshaling) to a Starlark +// value. Lists and string-keyed maps convert recursively; anything else is an +// error, so the caller can say which option was dropped instead of handing +// the rule a None it cannot tell from "unset". +func goValueToStarlark(v any) (starlark.Value, error) { switch val := v.(type) { + case nil: + return starlark.None, nil case bool: - return starlark.Bool(val) + return starlark.Bool(val), nil case int: - return starlark.MakeInt(val) + return starlark.MakeInt(val), nil + case int64: + return starlark.MakeInt64(val), nil + case uint64: + return starlark.MakeUint64(val), nil case float64: - return starlark.Float(val) + return starlark.Float(val), nil case string: - return starlark.String(val) + return starlark.String(val), nil + case []any: + items := make([]starlark.Value, 0, len(val)) + for _, e := range val { + sv, err := goValueToStarlark(e) + if err != nil { + return nil, err + } + items = append(items, sv) + } + return starlark.NewList(items), nil + case map[string]any: + keys := make([]string, 0, len(val)) + for k := range val { + keys = append(keys, k) + } + sort.Strings(keys) + d := starlark.NewDict(len(val)) + for _, k := range keys { + sv, err := goValueToStarlark(val[k]) + if err != nil { + return nil, err + } + if err := d.SetKey(starlark.String(k), sv); err != nil { + return nil, err + } + } + return d, nil default: - return starlark.None + return nil, fmt.Errorf("has a value of a type rules cannot read") } } diff --git a/mdl/linter/starlark_catalog_mode_guard_test.go b/mdl/linter/starlark_catalog_mode_guard_test.go index 17833799e0..edc26c2c5c 100644 --- a/mdl/linter/starlark_catalog_mode_guard_test.go +++ b/mdl/linter/starlark_catalog_mode_guard_test.go @@ -87,15 +87,16 @@ var builtinModes = map[string]builtinProbe{ "integration_surface": {CatalogCommunities, ""}, // No catalog access. - "parse_xpath": {CatalogFast, ""}, - "violation": {CatalogFast, ""}, - "location": {CatalogFast, ""}, - "is_pascal_case": {CatalogFast, ""}, - "is_camel_case": {CatalogFast, ""}, - "matches": {CatalogFast, ""}, - "find_all": {CatalogFast, ""}, - "struct": {CatalogFast, ""}, - "get_option": {CatalogFast, ""}, + "parse_xpath": {CatalogFast, ""}, + "violation": {CatalogFast, ""}, + "location": {CatalogFast, ""}, + "is_pascal_case": {CatalogFast, ""}, + "is_camel_case": {CatalogFast, ""}, + "matches": {CatalogFast, ""}, + "find_all": {CatalogFast, ""}, + "struct": {CatalogFast, ""}, + "get_option": {CatalogFast, ""}, + "get_list_option": {CatalogFast, ""}, } // fieldsKnownFull are struct fields filled only by a full build, pinned so the diff --git a/mdl/linter/starlark_options_test.go b/mdl/linter/starlark_options_test.go new file mode 100644 index 0000000000..5e6de9debf --- /dev/null +++ b/mdl/linter/starlark_options_test.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter + +import ( + "path/filepath" + "strings" + "testing" +) + +// mendixlabs/mxcli#1509: a YAML list under a rule's options reached the rule as +// None, because goValueToStarlark knew only scalars. A rule reading a list +// option therefore fell back to its default with no sign that the configured +// value had been dropped. + +const optionEchoRule = ` +RULE_ID = "OPT001" +RULE_NAME = "Echo" +DESCRIPTION = "reports the options it was given" +CATEGORY = "Quality" +SEVERITY = "info" + +def check(): + return [ + violation(message = "get_option=" + repr(get_option("xs", "unset"))), + violation(message = "get_list_option=" + repr(get_list_option("xs", ["DEF_"]))), + ] +` + +func runEcho(t *testing.T, opts map[string]any) (getOpt, getList string, warnings []Violation) { + t.Helper() + r := loadRuleSource(t, "echo.star", optionEchoRule) + if opts != nil { + r.Configure(opts) + } + for _, v := range r.Check(&LintContext{}) { + switch { + case v.RuleFailure: + warnings = append(warnings, v) + case strings.HasPrefix(v.Message, "get_option="): + getOpt = strings.TrimPrefix(v.Message, "get_option=") + case strings.HasPrefix(v.Message, "get_list_option="): + getList = strings.TrimPrefix(v.Message, "get_list_option=") + } + } + return +} + +func TestGetOption_YAMLListBecomesStarlarkList(t *testing.T) { + getOpt, getList, warnings := runEcho(t, map[string]any{"xs": []any{"ACT_", "TEST_"}}) + if getOpt != `["ACT_", "TEST_"]` { + t.Errorf("get_option = %s, want the list (a YAML list used to arrive as None)", getOpt) + } + if getList != `["ACT_", "TEST_"]` { + t.Errorf("get_list_option = %s, want the list", getList) + } + if len(warnings) != 0 { + t.Errorf("unexpected warnings: %+v", warnings) + } +} + +func TestGetOption_NestedValuesConvertRecursively(t *testing.T) { + v, err := goValueToStarlark(map[string]any{"a": []any{1, "b", true, []any{2.5}}}) + if err != nil { + t.Fatal(err) + } + if got := v.String(); got != `{"a": [1, "b", True, [2.5]]}` { + t.Errorf("got %s", got) + } +} + +func TestGetListOption_CommaSeparatedString(t *testing.T) { + _, getList, warnings := runEcho(t, map[string]any{"xs": " ACT_, TEST_ ,,GET_"}) + if getList != `["ACT_", "TEST_", "GET_"]` { + t.Errorf("get_list_option = %s, want the trimmed, non-empty parts", getList) + } + if len(warnings) != 0 { + t.Errorf("unexpected warnings: %+v", warnings) + } +} + +func TestGetListOption_UnsetUsesDefault(t *testing.T) { + getOpt, getList, warnings := runEcho(t, nil) + if getOpt != `"unset"` || getList != `["DEF_"]` { + t.Errorf("get_option = %s, get_list_option = %s, want the defaults", getOpt, getList) + } + if len(warnings) != 0 { + t.Errorf("unexpected warnings: %+v", warnings) + } +} + +// A value of the wrong shape is reported, and the rule still runs on its +// default rather than on None. +func TestGetListOption_MalformedValueWarnsAndUsesDefault(t *testing.T) { + for name, val := range map[string]any{ + "number": 5, + "list of numbers": []any{"ACT_", 7}, + "map": map[string]any{"a": "b"}, + } { + t.Run(name, func(t *testing.T) { + _, getList, warnings := runEcho(t, map[string]any{"xs": val}) + if getList != `["DEF_"]` { + t.Errorf("get_list_option = %s, want the default", getList) + } + if len(warnings) != 1 { + t.Fatalf("got %d warnings, want 1: %+v", len(warnings), warnings) + } + w := warnings[0] + if w.Severity != SeverityWarning || w.RuleID != "OPT001" { + t.Errorf("warning = %+v, want a warning for OPT001", w) + } + for _, want := range []string{"rules.OPT001.options.xs", "list of strings or a comma-separated string", "default"} { + if !strings.Contains(w.Message, want) { + t.Errorf("warning %q does not mention %q", w.Message, want) + } + } + }) + } +} + +// A warning belongs to the run that read the option: a second run with a +// fixed config must not repeat it. +func TestGetListOption_WarningDoesNotLeakIntoNextRun(t *testing.T) { + r := loadRuleSource(t, "echo.star", optionEchoRule) + r.Configure(map[string]any{"xs": 5}) + _ = r.Check(&LintContext{}) + r.Configure(map[string]any{"xs": "A_"}) + for _, v := range r.Check(&LintContext{}) { + if v.RuleFailure { + t.Errorf("stale warning: %+v", v) + } + } +} + +// The options map is what yaml.v3 makes of lint-config.yaml; this pins that a +// flow-style and a block-style list both arrive in the shape get_list_option +// reads. +func TestGetListOption_FromLintConfigYAML(t *testing.T) { + dir := t.TempDir() + write(t, dir, "lint-config.yaml", `rules: + OPT001: + options: + xs: [ACT_, TEST_] + OPT002: + options: + xs: + - GET_ +`) + cfg, err := LoadConfig(filepath.Join(dir, "lint-config.yaml")) + if err != nil { + t.Fatal(err) + } + _, got, warnings := runEcho(t, cfg.Rules["OPT001"].Options) + if got != `["ACT_", "TEST_"]` || len(warnings) != 0 { + t.Errorf("flow list: get_list_option = %s, warnings %+v", got, warnings) + } + _, got, warnings = runEcho(t, cfg.Rules["OPT002"].Options) + if got != `["GET_"]` || len(warnings) != 0 { + t.Errorf("block list: get_list_option = %s, warnings %+v", got, warnings) + } +} diff --git a/mdl/linter/starlark_prefix_options_test.go b/mdl/linter/starlark_prefix_options_test.go new file mode 100644 index 0000000000..4e28d77abb --- /dev/null +++ b/mdl/linter/starlark_prefix_options_test.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter_test + +import ( + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// mendixlabs/mxcli#1509: QUAL004's entry-point prefixes and CUSTOM002's valid +// prefixes were constants in the rule files, and `mxcli init` rewrites those +// files, so a project with its own naming convention could not keep a change. +// Both now read a rule option from lint-config.yaml and fall back to the old +// lists when it is unset. + +// flaggedFlows runs a shipped rule over the flow-kinds fixture (Big_ and +// ACT_ for each flavour, all unreferenced) and returns the flagged names +// and the rule-failure messages. +func flaggedFlows(t *testing.T, rule string, opts map[string]any) (names []string, failures []string) { + t.Helper() + r, err := linter.LoadStarlarkRule(filepath.Join(flowRulesDir, rule)) + if err != nil { + t.Fatalf("LoadStarlarkRule(%s): %v", rule, err) + } + if opts != nil { + r.Configure(opts) + } + for _, v := range r.Check(linter.NewLintContextFromDB(flowKindsFixtureDB(t))) { + if v.RuleFailure { + failures = append(failures, v.Message) + continue + } + names = append(names, v.Location.DocumentName) + } + sort.Strings(names) + return names, failures +} + +func TestQUAL004_EntryPointPrefixesOption(t *testing.T) { + const rule = "orphaned_elements.star" + defaults := []string{"Sales.Big_MF", "Sales.Big_NF", "Sales.Big_RU"} + bigOnly := []string{"Sales.ACT_MF", "Sales.ACT_NF", "Sales.ACT_RU"} + + for _, tc := range []struct { + name string + opts map[string]any + want []string + wantFailures int + }{ + // Control: unset, the built-in list (ACT_ among it) still applies. + {"unset keeps the built-in list", nil, defaults, 0}, + {"yaml list replaces the list", map[string]any{"entry_point_prefixes": []any{"Big_"}}, bigOnly, 0}, + {"comma-separated string", map[string]any{"entry_point_prefixes": "Big_, ACT_"}, nil, 0}, + {"malformed value warns, keeps the built-in list", map[string]any{"entry_point_prefixes": 3}, defaults, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + got, failures := flaggedFlows(t, rule, tc.opts) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("flagged %v, want %v", got, tc.want) + } + if len(failures) != tc.wantFailures { + t.Errorf("rule failures %v, want %d", failures, tc.wantFailures) + } + }) + } +} + +func TestCUSTOM002_ValidPrefixesOption(t *testing.T) { + const rule = "example_microflow.star" + defaults := []string{"Sales.Big_MF", "Sales.Big_NF", "Sales.Big_RU"} + + got, _ := flaggedFlows(t, rule, nil) + if strings.Join(got, ",") != strings.Join(defaults, ",") { + t.Fatalf("control: flagged %v, want %v", got, defaults) + } + + got, failures := flaggedFlows(t, rule, map[string]any{"valid_prefixes": []any{"ACT_", "Big_"}}) + if len(got) != 0 || len(failures) != 0 { + t.Errorf("with valid_prefixes [ACT_, Big_]: flagged %v, failures %v, want none", got, failures) + } + + // The message names the prefixes in force, not the built-in ones. + r, err := linter.LoadStarlarkRule(filepath.Join(flowRulesDir, rule)) + if err != nil { + t.Fatal(err) + } + r.Configure(map[string]any{"valid_prefixes": "TEST_,GET_"}) + vs := r.Check(linter.NewLintContextFromDB(flowKindsFixtureDB(t))) + if len(vs) != 6 { + t.Fatalf("got %d violations, want all 6 fixture flows", len(vs)) + } + if !strings.Contains(vs[0].Message, "(TEST_, GET_)") { + t.Errorf("message %q does not name the configured prefixes", vs[0].Message) + } +} diff --git a/mdl/roundtrip/listview_database_over_association_test.go b/mdl/roundtrip/listview_database_over_association_test.go index fd53c6f27c..2e3e534bcb 100644 --- a/mdl/roundtrip/listview_database_over_association_test.go +++ b/mdl/roundtrip/listview_database_over_association_test.go @@ -163,14 +163,13 @@ func firstListViewSource(t *testing.T, unit []byte) []byte { return ds } -// Only the List View writer stores the path. A datasource property the widget -// engine reaches under its own key — here a combo box's -// optionsSourceAssociationDataSource — bypassed the `DataSource:` guard: exec -// reported "Created page" and the pluggable writer stored the entity alone, a -// retrieve of every row instead of the context's (describe read it back as -// `database from Rules.BusinessRule`). Every non-List View call site of the -// datasource builder must refuse it. -func TestDatabaseOverAssociationRefusedUnderANamedDatasourceKey(t *testing.T) { +// A combo box's named datasource takes `database from $ctx/Assoc/Entity` and +// keeps the path: the pluggable writer stores it as a CustomWidgetXPathSource +// with the hops in an IndirectEntityRef and the start parameter in its +// SourceVariable, so the retrieve stays the context's +// (mendixlabs/mxcli#1599). It used to be refused, because that writer stored +// the entity alone and every row would have been retrieved. +func TestDatabaseOverAssociationKeptUnderANamedDatasourceKey(t *testing.T) { h := newFixtureHarness(t, testApp) defer h.close() h.restore() @@ -188,11 +187,11 @@ func TestDatabaseOverAssociationRefusedUnderANamedDatasourceKey(t *testing.T) { ) } };`) - if err == nil { - t.Fatal("a combo box's named datasource accepted `database from $ctx/Assoc/Entity` — " + - "its writer stores the entity alone, so every row would be retrieved") + if err != nil { + t.Fatalf("exec: %v", err) } - if !strings.Contains(err.Error(), "list view") && !strings.Contains(err.Error(), "LIST VIEW") { - t.Errorf("refused for another reason: %v", err) + const want = "database from $RuleCategory/Rules.BusinessRule_RuleCategory/Rules.BusinessRule" + if got := h.mustDescribeMdl0(t, "page Rules.L5NamedKeyProbe"); !strings.Contains(got, want) { + t.Errorf("the combo box's datasource lost its path — describe lacks %q:\n%s", want, got) } } diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index 3a15447150..1f61b39ac5 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -146,7 +146,6 @@ var testAppKnownFailures = map[string]knownFailure{ "page WorkflowCommons.WorkflowDefinition_CleanUp": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page WorkflowCommons.WorkflowDefinition_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page WorkflowCommons.WorkflowEndedUserTask": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowGroup_Edit": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page WorkflowCommons.WorkflowGroup_User_Select": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page WorkflowCommons.WorkflowSubProcess_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page WorkflowCommons.WorkflowUserTaskView_View": {laws: []law{lawGetPut}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); the page Appearance keeps 1 of 3 DesignProperties (#721 C)"}, diff --git a/mdl/visitor/visitor_association.go b/mdl/visitor/visitor_association.go index db59557d92..d68c103946 100644 --- a/mdl/visitor/visitor_association.go +++ b/mdl/visitor/visitor_association.go @@ -42,6 +42,10 @@ func (b *Builder) ExitCreateAssociationStatement(ctx *parser.CreateAssociationSt for _, opt := range optsCtx.AllAssociationOption() { optCtx := opt.(*parser.AssociationOptionContext) + if el := optCtx.MicroflowExportLevelClause(); el != nil { + stmt.ExportLevel = exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)) + } + // TYPE if optCtx.TYPE() != nil { if optCtx.REFERENCE_SET() != nil { @@ -243,6 +247,16 @@ func (b *Builder) ExitAlterAssociationAction(ctx *parser.AlterAssociationActionC return } + // SET EXPORT LEVEL API | HIDDEN + if el := ctx.MicroflowExportLevelClause(); el != nil { + b.statements = append(b.statements, &ast.AlterAssociationStmt{ + Name: name, + Operation: ast.AlterAssociationSetExportLevel, + ExportLevel: *exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)), + }) + return + } + // SET OWNER if ctx.OWNER() != nil { owner := ast.OwnerDefault diff --git a/mdl/visitor/visitor_datatransformer.go b/mdl/visitor/visitor_datatransformer.go index 169b20ab9e..9144ae9c92 100644 --- a/mdl/visitor/visitor_datatransformer.go +++ b/mdl/visitor/visitor_datatransformer.go @@ -17,6 +17,7 @@ func (b *Builder) ExitCreateDataTransformerStatement(ctx *parser.CreateDataTrans stmt := &ast.CreateDataTransformerStmt{ Name: buildQualifiedName(qn), } + stmt.Documentation, stmt.DocumentationSet = findDocComment(ctx) // mendixlabs/mxcli#1510 // Source type: JSON or XML if ctx.JSON() != nil { diff --git a/mdl/visitor/visitor_domain_export_level_test.go b/mdl/visitor/visitor_domain_export_level_test.go new file mode 100644 index 0000000000..1ad5b53a78 --- /dev/null +++ b/mdl/visitor/visitor_domain_export_level_test.go @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1498 (point 2 of the 2026-10-16 follow-up): `check` reports +// CE5400 when a Usable microflow in a Protected module exposes a Hidden entity +// or enumeration, and MDL had no way to make either Usable — `export level` on +// an entity, attribute, association or enumeration was a parse error +// ("extraneous input 'export' expecting the start of a statement"). +// +// Each case parses the clause both ways: Hidden alone could not tell a visitor +// that reads the keyword from one that writes the default. +func TestDomainModelExportLevelClause(t *testing.T) { + api, hidden := "API", "Hidden" + for _, tc := range []struct { + name string + src string + want *string + get func(t *testing.T, s ast.Statement) *string + }{ + {"entity api", `create persistent entity M.E (A: String(10)) export level api;`, &api, entityLevel}, + {"entity hidden", `create persistent entity M.E (A: String(10)) export level hidden;`, &hidden, entityLevel}, + {"entity without body", `create non-persistent entity M.E export level api;`, &api, entityLevel}, + {"entity with index", `create persistent entity M.E (A: String(10)) index (A) export level api;`, &api, entityLevel}, + {"entity absent", `create persistent entity M.E (A: String(10));`, nil, entityLevel}, + {"attribute api", `create persistent entity M.E (A: String(10) not null export level api);`, &api, attrLevel}, + {"attribute hidden", `create persistent entity M.E (A: String(10) export level hidden);`, &hidden, attrLevel}, + {"attribute absent", `create persistent entity M.E (A: String(10));`, nil, attrLevel}, + {"association api", `create association M.A_B from M.A to M.B type Reference export level api;`, &api, assocLevel}, + {"association hidden", `create association M.A_B from M.A to M.B export level hidden;`, &hidden, assocLevel}, + {"association absent", `create association M.A_B from M.A to M.B;`, nil, assocLevel}, + {"enumeration api", `create enumeration M.K (A 'A') export level api;`, &api, enumLevel}, + {"enumeration with folder", `create enumeration M.K (A 'A') folder 'X' export level hidden;`, &hidden, enumLevel}, + {"enumeration absent", `create enumeration M.K (A 'A');`, nil, enumLevel}, + } { + t.Run(tc.name, func(t *testing.T) { + prog, errs := Build(tc.src) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + got := tc.get(t, prog.Statements[0]) + switch { + case tc.want == nil && got != nil: + t.Errorf("ExportLevel = %q, want nil (an absent clause keeps the stored level)", *got) + case tc.want != nil && got == nil: + t.Errorf("ExportLevel = nil, want %q", *tc.want) + case tc.want != nil && *got != *tc.want: + t.Errorf("ExportLevel = %q, want %q", *got, *tc.want) + } + }) + } +} + +func entityLevel(t *testing.T, s ast.Statement) *string { + t.Helper() + e, ok := s.(*ast.CreateEntityStmt) + if !ok { + t.Fatalf("statement is %T", s) + } + return e.ExportLevel +} + +func attrLevel(t *testing.T, s ast.Statement) *string { + t.Helper() + e, ok := s.(*ast.CreateEntityStmt) + if !ok || len(e.Attributes) != 1 { + t.Fatalf("statement is %T", s) + } + return e.Attributes[0].ExportLevel +} + +func assocLevel(t *testing.T, s ast.Statement) *string { + t.Helper() + a, ok := s.(*ast.CreateAssociationStmt) + if !ok { + t.Fatalf("statement is %T", s) + } + return a.ExportLevel +} + +func enumLevel(t *testing.T, s ast.Statement) *string { + t.Helper() + e, ok := s.(*ast.CreateEnumerationStmt) + if !ok { + t.Fatalf("statement is %T", s) + } + return e.ExportLevel +} + +// The ALTER spellings, so a stored element changes level without restating it. +func TestAlterDomainModelExportLevel(t *testing.T) { + t.Run("alter entity", func(t *testing.T) { + for src, want := range map[string]string{ + `alter entity M.E set export level api;`: "API", + `alter entity M.E set export level hidden;`: "Hidden", + } { + s := onlyStatement[*ast.AlterEntityStmt](t, src) + if s.Operation != ast.AlterEntitySetExportLevel || s.ExportLevel != want { + t.Errorf("%s: op %v level %q, want SetExportLevel %q", src, s.Operation, s.ExportLevel, want) + } + } + }) + t.Run("modify attribute", func(t *testing.T) { + s := onlyStatement[*ast.AlterEntityStmt](t, `alter entity M.E modify attribute A: String(10) export level api;`) + if s.Operation != ast.AlterEntityModifyAttribute || s.ModifyExportLevel == nil || *s.ModifyExportLevel != "API" { + t.Errorf("op %v ModifyExportLevel %v, want ModifyAttribute API", s.Operation, s.ModifyExportLevel) + } + s = onlyStatement[*ast.AlterEntityStmt](t, `alter entity M.E modify attribute A: String(10);`) + if s.ModifyExportLevel != nil { + t.Errorf("ModifyExportLevel = %q without the clause, want nil", *s.ModifyExportLevel) + } + }) + t.Run("add attribute", func(t *testing.T) { + s := onlyStatement[*ast.AlterEntityStmt](t, `alter entity M.E add attribute A: String(10) export level api;`) + if s.Attribute == nil || s.Attribute.ExportLevel == nil || *s.Attribute.ExportLevel != "API" { + t.Errorf("added attribute ExportLevel = %v, want API", s.Attribute) + } + }) + t.Run("alter association", func(t *testing.T) { + for src, want := range map[string]string{ + `alter association M.A_B set export level api;`: "API", + `alter association M.A_B set export level hidden;`: "Hidden", + } { + s := onlyStatement[*ast.AlterAssociationStmt](t, src) + if s.Operation != ast.AlterAssociationSetExportLevel || s.ExportLevel != want { + t.Errorf("%s: op %v level %q, want SetExportLevel %q", src, s.Operation, s.ExportLevel, want) + } + } + }) + t.Run("alter enumeration", func(t *testing.T) { + for src, want := range map[string]string{ + `alter enumeration M.K set export level api;`: "API", + `alter enumeration M.K set export level hidden;`: "Hidden", + } { + s := onlyStatement[*ast.AlterEnumerationStmt](t, src) + if s.Operation != ast.AlterEnumSetExportLevel || s.ExportLevel != want { + t.Errorf("%s: op %v level %q, want SetExportLevel %q", src, s.Operation, s.ExportLevel, want) + } + } + }) +} + +func onlyStatement[T ast.Statement](t *testing.T, src string) T { + t.Helper() + prog, errs := Build(src) + if len(errs) > 0 { + t.Fatalf("%s: parse errors: %v", src, errs) + } + if len(prog.Statements) != 1 { + t.Fatalf("%s: %d statements", src, len(prog.Statements)) + } + s, ok := prog.Statements[0].(T) + if !ok { + t.Fatalf("%s: statement is %T", src, prog.Statements[0]) + } + return s +} diff --git a/mdl/visitor/visitor_entity.go b/mdl/visitor/visitor_entity.go index ffc59e7fbe..2f79ac2a4c 100644 --- a/mdl/visitor/visitor_entity.go +++ b/mdl/visitor/visitor_entity.go @@ -90,6 +90,9 @@ func (b *Builder) ExitCreateEntityStatement(ctx *parser.CreateEntityStatementCon stmt.EventHandlers = append(stmt.EventHandlers, *eh) } } + if el := optCtx.MicroflowExportLevelClause(); el != nil { + stmt.ExportLevel = exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)) + } } } } @@ -186,6 +189,13 @@ func (b *Builder) buildViewEntity(ctx *parser.CreateEntityStatementContext) { if attrList := bodyCtx.AttributeDefinitionList(); attrList != nil { stmt.Attributes = buildViewAttributes(attrList) } + if opts := bodyCtx.EntityOptions(); opts != nil { + for _, opt := range opts.(*parser.EntityOptionsContext).AllEntityOption() { + if el := opt.(*parser.EntityOptionContext).MicroflowExportLevelClause(); el != nil { + stmt.ExportLevel = exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)) + } + } + } } // OQL Query - use token stream to preserve whitespace, and walk parse tree for structured data @@ -518,7 +528,11 @@ func buildViewAttributes(attrList parser.IAttributeDefinitionListContext) []ast. attr.Type = buildDataType(dt) } for _, c := range defCtx.AllAttributeConstraint() { - applyLocalizeConstraint(&attr.Type, c.(*parser.AttributeConstraintContext)) + cc := c.(*parser.AttributeConstraintContext) + applyLocalizeConstraint(&attr.Type, cc) + if el := cc.MicroflowExportLevelClause(); el != nil { + attr.ExportLevel = exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)) + } } attrs = append(attrs, attr) } @@ -704,6 +718,8 @@ func (b *Builder) ExitAlterEntityAction(ctx *parser.AlterEntityActionContext) { } case c.LOCALIZED() != nil: applyLocalizeConstraint(&stmt.DataType, c) + case c.MicroflowExportLevelClause() != nil: + stmt.ModifyExportLevel = exportLevelOf(c.MicroflowExportLevelClause().(*parser.MicroflowExportLevelClauseContext)) } } b.statements = append(b.statements, stmt) @@ -754,6 +770,16 @@ func (b *Builder) ExitAlterEntityAction(ctx *parser.AlterEntityActionContext) { return } + // SET EXPORT LEVEL API | HIDDEN + if el := ctx.MicroflowExportLevelClause(); ctx.SET() != nil && el != nil { + b.statements = append(b.statements, &ast.AlterEntityStmt{ + Name: name, + Operation: ast.AlterEntitySetExportLevel, + ExportLevel: *exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)), + }) + return + } + // SET POSITION (x, y) if ctx.SET() != nil && ctx.POSITION() != nil { nums := ctx.AllNUMBER_LITERAL() diff --git a/mdl/visitor/visitor_enumeration.go b/mdl/visitor/visitor_enumeration.go index 4d65e72319..fe53326472 100644 --- a/mdl/visitor/visitor_enumeration.go +++ b/mdl/visitor/visitor_enumeration.go @@ -24,6 +24,9 @@ func (b *Builder) ExitCreateEnumerationStatement(ctx *parser.CreateEnumerationSt if optCtx.FOLDER() != nil && optCtx.STRING_LITERAL() != nil { stmt.Folder = unquoteStringLit(optCtx.STRING_LITERAL()) } + if el := optCtx.MicroflowExportLevelClause(); el != nil { + stmt.ExportLevel = exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)) + } } } @@ -83,6 +86,12 @@ func (b *Builder) ExitAlterEnumerationAction(ctx *parser.AlterEnumerationActionC ValueName: ids[0], NewName: ids[1], }) + } else if el := ctx.MicroflowExportLevelClause(); ctx.SET() != nil && el != nil { + b.statements = append(b.statements, &ast.AlterEnumerationStmt{ + Name: name, + Operation: ast.AlterEnumSetExportLevel, + ExportLevel: *exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)), + }) } else if ctx.SET() != nil && ctx.STRING_LITERAL() != nil && (ctx.DOCUMENTATION() != nil || ctx.COMMENT() != nil) { // SET DOCUMENTATION; SET COMMENT is its deprecated spelling // (R9, MDL-DEPR135). Neither built a statement before. diff --git a/mdl/visitor/visitor_helpers.go b/mdl/visitor/visitor_helpers.go index 6dd53d2a22..18d6c37778 100644 --- a/mdl/visitor/visitor_helpers.go +++ b/mdl/visitor/visitor_helpers.go @@ -226,6 +226,9 @@ func buildAttributes(ctx parser.IAttributeDefinitionListContext, b *Builder) []a } } applyLocalizeConstraint(&attr.Type, c) + if el := c.MicroflowExportLevelClause(); el != nil { + attr.ExportLevel = exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)) + } } attrs = append(attrs, attr) @@ -285,6 +288,9 @@ func buildSingleAttribute(a *parser.AttributeDefinitionContext) *ast.Attribute { } } applyLocalizeConstraint(&attr.Type, c) + if el := c.MicroflowExportLevelClause(); el != nil { + attr.ExportLevel = exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)) + } } return attr diff --git a/mdl/visitor/visitor_import_export_mapping.go b/mdl/visitor/visitor_import_export_mapping.go index d378ceb58d..7c4e52220d 100644 --- a/mdl/visitor/visitor_import_export_mapping.go +++ b/mdl/visitor/visitor_import_export_mapping.go @@ -16,6 +16,7 @@ func (b *Builder) ExitCreateImportMappingStatement(ctx *parser.CreateImportMappi stmt := &ast.CreateImportMappingStmt{ Name: buildQualifiedName(ctx.QualifiedName()), } + stmt.Documentation, stmt.DocumentationSet = findDocComment(ctx) // mendixlabs/mxcli#1510 if lit := ctx.STRING_LITERAL(); lit != nil { stmt.Folder = unquoteStringLit(lit) } @@ -156,6 +157,7 @@ func (b *Builder) ExitCreateExportMappingStatement(ctx *parser.CreateExportMappi stmt := &ast.CreateExportMappingStmt{ Name: buildQualifiedName(ctx.QualifiedName()), } + stmt.Documentation, stmt.DocumentationSet = findDocComment(ctx) // mendixlabs/mxcli#1510 if lit := ctx.STRING_LITERAL(); lit != nil { stmt.Folder = unquoteStringLit(lit) } diff --git a/mdl/visitor/visitor_integration_documentation_test.go b/mdl/visitor/visitor_integration_documentation_test.go new file mode 100644 index 0000000000..39b887215d --- /dev/null +++ b/mdl/visitor/visitor_integration_documentation_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1510: QUAL002 asks for documentation on import and export +// mappings, published REST services and data transformers, and a `/** … */` +// doc comment above their create was dropped with MDL089 — the statements had +// nowhere to put it, so the finding could never be closed from MDL. Each now +// reads the comment, and records whether one was written at all, so a rewrite +// that states none keeps the stored documentation (#1018). +func TestIntegrationDocumentsReadDocComment_Issue1510(t *testing.T) { + for _, tc := range []struct { + name string + src string + doc func(ast.Statement) (string, bool) + }{{ + name: "import mapping", + src: "create import mapping M.IMM_Order\n with json structure M.JSON_Order\n" + + "{\n create M.Order {\n Id = id\n }\n};", + doc: func(s ast.Statement) (string, bool) { + st := s.(*ast.CreateImportMappingStmt) + return st.Documentation, st.DocumentationSet + }, + }, { + name: "export mapping", + src: "create export mapping M.EMM_Order\n with json structure M.JSON_Order\n" + + "{\n M.Order {\n id = Id\n }\n};", + doc: func(s ast.Statement) (string, bool) { + st := s.(*ast.CreateExportMappingStmt) + return st.Documentation, st.DocumentationSet + }, + }, { + name: "published rest service", + src: "create or modify published rest service M.OrdersApi (\n Path: 'rest/orders/v1'\n)\n{\n};", + doc: func(s ast.Statement) (string, bool) { + st := s.(*ast.CreatePublishedRestServiceStmt) + return st.Documentation, st.DocumentationSet + }, + }, { + name: "data transformer", + src: "create data transformer M.Flatten\n source json '{\"a\": 1}'\n{\n jslt '{\"b\": .a}';\n};", + doc: func(s ast.Statement) (string, bool) { + st := s.(*ast.CreateDataTransformerStmt) + return st.Documentation, st.DocumentationSet + }, + }} { + t.Run(tc.name, func(t *testing.T) { + prog, errs := Build("/** Line one.\n *\n * Line two. */\n" + tc.src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if len(prog.DetachedDocComments) != 0 { + t.Errorf("doc comment reported as lost (MDL089): %+v", prog.DetachedDocComments) + } + if len(prog.Statements) != 1 { + t.Fatalf("got %d statements, want 1", len(prog.Statements)) + } + doc, set := tc.doc(prog.Statements[0]) + if !set || doc != "Line one.\n\nLine two." { + t.Errorf("documentation = %q (set %v), want %q", doc, set, "Line one.\n\nLine two.") + } + + // Control: no comment is "not stated", which a rewrite must read as + // "keep what is stored", not as "clear it". + prog, errs = Build(tc.src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if doc, set := tc.doc(prog.Statements[0]); set || doc != "" { + t.Errorf("without a comment: documentation = %q (set %v), want unset", doc, set) + } + }) + } +} diff --git a/mdl/visitor/visitor_javaaction.go b/mdl/visitor/visitor_javaaction.go index 0b28eb6a78..5a080bda2c 100644 --- a/mdl/visitor/visitor_javaaction.go +++ b/mdl/visitor/visitor_javaaction.go @@ -22,22 +22,7 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat } // Get parameters - if paramList := ctx.JavaActionParameterList(); paramList != nil { - for _, paramCtx := range paramList.AllJavaActionParameter() { - param := ast.JavaActionParam{} - if pn := paramCtx.ParameterName(); pn != nil { - param.Name = parameterNameText(pn) - } - if dt := paramCtx.DataType(); dt != nil { - param.Type = buildDataType(dt) - } - // Check for NOT NULL constraint - if paramCtx.NOT_NULL() != nil { - param.IsRequired = true - } - stmt.Parameters = append(stmt.Parameters, param) - } - } + stmt.Parameters = buildJavaActionParams(ctx.JavaActionParameterList()) // Type parameters: those the `Name` header declares, in its order, // then any ENTITY parameter declares that the header did not @@ -64,11 +49,7 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat } // Get return type - if retType := ctx.JavaActionReturnType(); retType != nil { - if dt := retType.DataType(); dt != nil { - stmt.ReturnType = buildJavaActionReturnType(dt) - } - } + stmt.ReturnType, stmt.ReturnName = buildJavaActionReturn(ctx.JavaActionReturnType()) // Get exposed clause (EXPOSED AS 'caption' IN 'category') if exposed := ctx.JavaActionExposedClause(); exposed != nil { @@ -134,21 +115,7 @@ func (b *Builder) ExitCreateJavaScriptActionStatement(ctx *parser.CreateJavaScri stmt.Folder = unquoteStringLit(lit) } - if paramList := ctx.JavaActionParameterList(); paramList != nil { - for _, paramCtx := range paramList.AllJavaActionParameter() { - param := ast.JavaActionParam{} - if pn := paramCtx.ParameterName(); pn != nil { - param.Name = parameterNameText(pn) - } - if dt := paramCtx.DataType(); dt != nil { - param.Type = buildDataType(dt) - } - if paramCtx.NOT_NULL() != nil { - param.IsRequired = true - } - stmt.Parameters = append(stmt.Parameters, param) - } - } + stmt.Parameters = buildJavaActionParams(ctx.JavaActionParameterList()) // Extract type parameters from ENTITY parameter declarations. for _, param := range stmt.Parameters { @@ -166,11 +133,7 @@ func (b *Builder) ExitCreateJavaScriptActionStatement(ctx *parser.CreateJavaScri } } - if retType := ctx.JavaActionReturnType(); retType != nil { - if dt := retType.DataType(); dt != nil { - stmt.ReturnType = buildJavaActionReturnType(dt) - } - } + stmt.ReturnType, stmt.ReturnName = buildJavaActionReturn(ctx.JavaActionReturnType()) if exposed := ctx.JavaActionExposedClause(); exposed != nil { allStrings := exposed.AllSTRING_LITERAL() @@ -285,3 +248,49 @@ func extractJavaImports(code string) (body string, imports []string) { } return strings.TrimSpace(strings.Join(bodyLines, "\n")), imports } + +// buildJavaActionParams reads a Java or JavaScript action's parameter list — +// one helper for both, since they share the grammar rule and a field read by +// one handler only is a silent drop in the other. +func buildJavaActionParams(list parser.IJavaActionParameterListContext) []ast.JavaActionParam { + if list == nil { + return nil + } + var params []ast.JavaActionParam + for _, paramCtx := range list.AllJavaActionParameter() { + param := ast.JavaActionParam{} + if pn := paramCtx.ParameterName(); pn != nil { + param.Name = parameterNameText(pn) + } + if dt := paramCtx.DataType(); dt != nil { + param.Type = buildDataType(dt) + } + // Check for NOT NULL constraint + if paramCtx.NOT_NULL() != nil { + param.IsRequired = true + } + // The parameter's description (mendixlabs/mxcli#1588). + if doc := paramCtx.DocComment(); doc != nil { + param.Description, param.DescriptionSet = extractDocComment(doc.GetText()), true + } + params = append(params, param) + } + return params +} + +// buildJavaActionReturn reads `returns T [as $Name]`: the return type, and the +// return value's name without its `$` ("" when the clause names none). +func buildJavaActionReturn(ret parser.IJavaActionReturnTypeContext) (ast.DataType, string) { + if ret == nil { + return ast.DataType{}, "" + } + var dt ast.DataType + if d := ret.DataType(); d != nil { + dt = buildJavaActionReturnType(d) + } + var name string + if v := ret.VARIABLE(); v != nil { + name = strings.TrimPrefix(v.GetText(), "$") + } + return dt, name +} diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 179d133749..d67667d11e 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -1717,6 +1717,11 @@ func buildLoopStatement(ctx parser.ILoopStatementContext) *ast.LoopStmt { if len(vars) >= 2 { stmt.ListVariable = strings.TrimPrefix(vars[1].GetText(), "$") } + // `loop $c in $Parent/Mod.Assoc` — the grammar takes a path here, and + // dropping it wrote a loop with no list (CE0099). Kept for the validator. + if ap := loopCtx.AttributePath(); ap != nil { + stmt.ListPath = strings.TrimSpace(extractExpressionText(ap.(antlr.ParserRuleContext))) + } // Get body if body := loopCtx.MicroflowBody(); body != nil { diff --git a/mdl/visitor/visitor_quoted_expression.go b/mdl/visitor/visitor_quoted_expression.go index 14d808c430..f146a49ad2 100644 --- a/mdl/visitor/visitor_quoted_expression.go +++ b/mdl/visitor/visitor_quoted_expression.go @@ -138,13 +138,14 @@ func (b *Builder) noteQuotedExpression(at antlr.ParserRuleContext, lit antlr.Tok func (b *Builder) widgetExpressionPropValue(key string, v antlr.ParserRuleContext) any { if content, lit, ok := legacyQuotedExpression(v, IsLegacyWidgetExpressionText); ok { b.noteQuotedExpression(v, lit, content, func() bool { return widgetExpressionReadsBack(key, content) }) - return content + return stripExpressionIdentifierQuotes(content) } return widgetExpressionValue(v) } // widgetExpressionReadsBack reports whether `key: expr` in a widget's -// properties stores exactly expr. +// properties stores what the quoted spelling of expr stores: expr, with its +// identifier quotes stripped as both spellings strip them. func widgetExpressionReadsBack(key, expr string) bool { ctx, ok := parseRule(key+": "+expr, func(p *parser.MDLParser) antlr.ParserRuleContext { return p.WidgetPropertyV3() @@ -158,7 +159,7 @@ func widgetExpressionReadsBack(key, expr string) bool { } var none *Builder got, isText := none.widgetExpressionPropValue(key, v).(string) - return isText && got == expr + return isText && got == stripExpressionIdentifierQuotes(expr) } // odataExpressionReadsBack reports whether `HttpUsername: expr` stores exactly diff --git a/mdl/visitor/visitor_rest.go b/mdl/visitor/visitor_rest.go index a260f55be2..8a90ac38af 100644 --- a/mdl/visitor/visitor_rest.go +++ b/mdl/visitor/visitor_rest.go @@ -349,6 +349,7 @@ func (b *Builder) ExitCreatePublishedRestServiceStatement(ctx *parser.CreatePubl stmt := &ast.CreatePublishedRestServiceStmt{ Name: buildQualifiedName(ctx.QualifiedName()), } + stmt.Documentation, stmt.DocumentationSet = findDocComment(ctx) // mendixlabs/mxcli#1510 // `folder '…'` after the name (R9); `Folder:` in the list is its alias. folderClause := ctx.STRING_LITERAL() != nil if folderClause { diff --git a/mdl/visitor/visitor_widget_expression.go b/mdl/visitor/visitor_widget_expression.go index 1cf1941202..816316355f 100644 --- a/mdl/visitor/visitor_widget_expression.go +++ b/mdl/visitor/visitor_widget_expression.go @@ -41,11 +41,15 @@ func isWidgetExpressionProp(name string) bool { // text is taken from the value node itself. A bracketed list is the one // exception: it is returned as the list the other readers build, so MDL-WIDGET27 // (empty) and MDL-WIDGET32 (non-empty) still report it. +// +// Identifier quotes are stripped from the text, as in every other expression +// slot: `$currentObject/"Code"` stored verbatim is CE0117 at build time +// (mendixlabs/mxcli#1584). func widgetExpressionValue(valueNode antlr.ParserRuleContext) any { if pv, ok := valueNode.(*parser.PropertyValueV3Context); ok && (pv.LBRACKET() != nil || pv.ObjectEntryListV3() != nil) { return buildPropertyValueV3(pv) } - return ruleSourceText(valueNode) + return stripExpressionIdentifierQuotes(ruleSourceText(valueNode)) } // lastRuleChild is the rule node after `name :` / `name =` — the value. @@ -72,7 +76,7 @@ func widgetExpressionNotAllowed(name string, expr parser.IExpressionContext) err // plain one, so it keeps the text and the schema-aware layers decide // (PROPOSAL_first_class_expressions.md §6.2, the schema-driven slice). func genericWidgetExpression(expr parser.IExpressionContext) *ast.WidgetExpressionV3 { - return &ast.WidgetExpressionV3{Text: ruleSourceText(expr.(antlr.ParserRuleContext))} + return &ast.WidgetExpressionV3{Text: stripExpressionIdentifierQuotes(ruleSourceText(expr.(antlr.ParserRuleContext)))} } // keepExpressionSource records the source text of a generic property whose @@ -89,5 +93,5 @@ func keepExpressionSource(widget *ast.WidgetV3, key string, dsCtx parser.IDataSo if widget.ValueSource == nil { widget.ValueSource = map[string]string{} } - widget.ValueSource[key] = ruleSourceText(ds) + widget.ValueSource[key] = stripExpressionIdentifierQuotes(ruleSourceText(ds)) } diff --git a/mdl/visitor/visitor_widget_expression_quotes_test.go b/mdl/visitor/visitor_widget_expression_quotes_test.go new file mode 100644 index 0000000000..efea3387f4 --- /dev/null +++ b/mdl/visitor/visitor_widget_expression_quotes_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1584: a quoted attribute segment in DynamicClasses was +// stored with its quotes — `$currentObject/"Code"` — while ContentParams and +// Visible on the same page stored `$currentObject/Code`. check passed, mxbuild +// reported CE0117 "Error(s) in expression." at the widget. Every widget slot +// that stores expression text strips identifier quotes, as the other +// expression slots do (#827, #1481). + +func TestWidgetExpressionSlots_StripIdentifierQuotes(t *testing.T) { + const quoted = `if $currentObject/"Code" = empty then 'x' else ''` + const want = `if $currentObject/Code = empty then 'x' else ''` + + t.Run("DynamicClasses and DynamicCellClass", func(t *testing.T) { + prog := mustBuild(t, dynamicClassPage("mdl 1;\n", quoted)) + cls, cell := dynamicClassValues(t, prog) + if cls != want || cell != want { + t.Errorf("stored %#v / %#v, want %q", cls, cell, want) + } + }) + + t.Run("mdl 0 quoted spelling", func(t *testing.T) { + legacy := `'if $currentObject/"Code" = empty then ''x'' else '''''` + prog := mustBuild(t, dynamicClassPage("", legacy)) + cls, cell := dynamicClassValues(t, prog) + if cls != want || cell != want { + t.Errorf("stored %#v / %#v, want %q", cls, cell, want) + } + // The rewrite to the bare form still applies: the bare form stores the same. + for _, n := range quotedExprNotes(prog) { + if n.Fix == nil { + t.Errorf("note %+v offers no rewrite", n) + } + } + }) + + t.Run("alter page set", func(t *testing.T) { + prog := mustBuild(t, "mdl 1;\nalter page M.P { set DynamicClasses = "+quoted+" on c1 };") + op := prog.Statements[0].(*ast.AlterPageStmt).Operations[0].(*ast.SetPropertyOp) + if got := op.Properties["DynamicClasses"]; got != want { + t.Errorf("stored %#v, want %q", got, want) + } + }) + + t.Run("pluggable expression property", func(t *testing.T) { + prog := mustBuild(t, `mdl 1; +create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { + pluggablewidget 'com.mendix.widget.custom.badge.Badge' b1 (barColor: if $currentObject/"Code" = empty then 'x' else '', lineColor: $currentObject/"Code") +};`) + w := prog.Statements[0].(*ast.CreatePageStmtV3).Widgets[0] + expr, ok := w.Properties["barColor"].(*ast.WidgetExpressionV3) + if !ok || expr.Text != want { + t.Errorf("barColor = %#v, want expression %q", w.Properties["barColor"], want) + } + if src, _ := w.ExpressionSource("lineColor"); src != "$currentObject/Code" { + t.Errorf("lineColor source = %q, want %q", src, "$currentObject/Code") + } + }) +} diff --git a/mdl/visitor/visitor_workflow.go b/mdl/visitor/visitor_workflow.go index bba45581c3..3877f76961 100644 --- a/mdl/visitor/visitor_workflow.go +++ b/mdl/visitor/visitor_workflow.go @@ -826,6 +826,11 @@ func buildWorkflowCallWorkflow(ctx parser.IWorkflowCallWorkflowStmtContext) *ast node.ParameterMappings = buildWorkflowParameterMappings(cwCtx.AllWorkflowParameterMapping()) } + // BoundaryEvents (mendixlabs/mxcli#1532) + for _, beCtx := range cwCtx.AllWorkflowBoundaryEventClause() { + node.BoundaryEvents = append(node.BoundaryEvents, buildBoundaryEventNode(beCtx)) + } + return node } diff --git a/mdl/visitor/visitor_workflow_callworkflow_boundary_test.go b/mdl/visitor/visitor_workflow_callworkflow_boundary_test.go new file mode 100644 index 0000000000..ef19fb48e6 --- /dev/null +++ b/mdl/visitor/visitor_workflow_callworkflow_boundary_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1532: `call workflow` takes the same boundary-event clause as +// `call microflow` and `wait for notification`. It was a parse error +// ("mismatched input 'boundary' expecting ';'"). +func TestWorkflowVisitor_CallWorkflowBoundaryEvents(t *testing.T) { + input := `create workflow Tmp.WF_Parent + parameter $WorkflowContext: Tmp.Item +begin + call workflow Tmp.WF_Child(WorkflowContext = $WorkflowContext) as callChild + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; } + non interrupting timer addHours([%CurrentDateTime%], 4) { call microflow Tmp.ACT_Remind; }; +end workflow;` + + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + stmt := prog.Statements[0].(*ast.CreateWorkflowStmt) + n, ok := stmt.Activities[0].(*ast.WorkflowCallWorkflowNode) + if !ok { + t.Fatalf("activity is %T, want *ast.WorkflowCallWorkflowNode", stmt.Activities[0]) + } + if n.Name != "callChild" || n.Workflow.String() != "Tmp.WF_Child" || len(n.ParameterMappings) != 1 { + t.Errorf("call = %q %s %d mappings, want callChild Tmp.WF_Child 1", n.Name, n.Workflow, len(n.ParameterMappings)) + } + if len(n.BoundaryEvents) != 2 { + t.Fatalf("boundary events = %d, want 2", len(n.BoundaryEvents)) + } + if be := n.BoundaryEvents[0]; be.EventType != "InterruptingTimer" || be.Delay != "addDays([%CurrentDateTime%], 1)" || len(be.Activities) != 1 { + t.Errorf("event 0 = %+v", be) + } + if be := n.BoundaryEvents[1]; be.EventType != "NonInterruptingTimer" || len(be.Activities) != 1 { + t.Errorf("event 1 = %+v", be) + } + if _, ok := n.BoundaryEvents[1].Activities[0].(*ast.WorkflowCallMicroflowNode); !ok { + t.Errorf("event 1 body = %T, want call microflow", n.BoundaryEvents[1].Activities[0]) + } +} + +// The deprecated `with (…)` mapping form still parses with a boundary clause +// after it, as it does on call microflow. +func TestWorkflowVisitor_CallWorkflowBoundaryAfterWith(t *testing.T) { + input := `create workflow Tmp.WF_Parent +begin + call workflow Tmp.WF_Child with (WorkflowContext = '$WorkflowContext') + boundary event non interrupting timer '${PT1H}'; +end workflow;` + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + n := prog.Statements[0].(*ast.CreateWorkflowStmt).Activities[0].(*ast.WorkflowCallWorkflowNode) + if len(n.BoundaryEvents) != 1 || len(n.ParameterMappings) != 1 { + t.Errorf("boundary events = %d, mappings = %d, want 1 and 1", len(n.BoundaryEvents), len(n.ParameterMappings)) + } +} diff --git a/mdl/visitor/xpath_format.go b/mdl/visitor/xpath_format.go index 6442792ced..15918d8f31 100644 --- a/mdl/visitor/xpath_format.go +++ b/mdl/visitor/xpath_format.go @@ -18,18 +18,21 @@ const DefaultXPathWidth = 80 // Studio Pro's constraint editor, breaking it across lines when it does not fit // in DefaultXPathWidth columns. // -// The form is CANONICAL, not preserved. #979 was filed as "keep the whitespace I -// typed", but the formatting that matters to the reporter is the formatting -// Studio Pro's editor holds, and a constraint is rebuilt from its parse tree on -// every write (see buildXPathString) — so by the time anything is stored there is -// no original whitespace left to keep. Deriving the layout from the expression -// instead means a constraint comes out readable however it went in, including one -// a person formatted by hand in Studio Pro. +// Only a FLAT constraint is laid out. One that already holds a line break was +// laid out by its author — by hand in MDL, or in Studio Pro's editor, which keeps +// the layout it is given — and is stored as written (mendixlabs/mxcli#1542: an +// author's `and` leading the next line, flattened, was a layout rule's fixture +// that MDL could not produce, and a describe/exec round trip changed the model). +// The writers that keep the source text — retrieve and access rule — hand it +// here with its line breaks; describe prints such a layout as stored +// (DescribeXPathConstraintLayout). // -// Two properties make it safe to apply on every write: +// Three properties make it safe to apply on every write: // // - A constraint that already fits comes back BYTE-IDENTICAL, so the common // case is untouched and ADR-0008's write elision still skips the unit. +// - A constraint with a line break comes back byte-identical too, apart from +// its operator case, which is folded whatever the layout. // - It is idempotent, so re-running it over its own output moves nothing. // // Anything the grammar cannot read is handed back unchanged. A formatter that @@ -53,10 +56,14 @@ func FormatXPathConstraintWidth(constraint string, width int) string { // which is exactly where it hid (mxcli-formula1 §80). See // NormalizeXPathOperators. constraint = NormalizeXPathOperators(constraint) + // Laid out by its author: kept as written (mendixlabs/mxcli#1542). + if strings.ContainsAny(constraint, "\r\n") { + return constraint + } // Already short enough to read at a glance: leave it exactly as it is. This // is the case for the overwhelming majority of constraints, and returning the // caller's own bytes is what keeps this change from touching them. - if !strings.ContainsAny(constraint, "\r\n") && len(constraint) <= width { + if len(constraint) <= width { return constraint } // Sibling predicate groups (`[a][b]`) are the author's own top-level @@ -241,3 +248,19 @@ func isXPathBoolChain(expr ast.Expression) bool { op, _ := flattenXPathBoolChain(expr) return op != "" } + +// DescribeXPathConstraintLayout is the text describe prints for a stored +// constraint: on one line when its layout is the one FormatXPathConstraint +// derives from the flat form — the writer re-derives it on exec (upstream #979) +// — and as stored otherwise, so that a layout its author chose is stored again +// by re-executing the description (mendixlabs/mxcli#1542). Whitespace outside +// the brackets is not layout a bracketed constraint can carry — Studio Pro can +// store a trailing line break — and is dropped, as flattening always did. +func DescribeXPathConstraintLayout(constraint string) string { + constraint = strings.TrimSpace(constraint) + flat := FlattenXPathConstraint(constraint) + if !strings.ContainsAny(constraint, "\r\n") || FormatXPathConstraint(flat) == constraint { + return flat + } + return constraint +} diff --git a/model/types.go b/model/types.go index 5cce6f216c..a075ca33fb 100644 --- a/model/types.go +++ b/model/types.go @@ -205,6 +205,10 @@ type Enumeration struct { // carry the stored value rather than default it to false (#914). Excluded bool `json:"excluded,omitempty"` Values []EnumerationValue `json:"values,omitempty"` + // ExportLevel is "Hidden" or "API" (Studio Pro's "Usable"), which matters in + // a Protected (add-on) module (CE5400). The writer writes a set level and, + // for "", keeps the stored one (mendixlabs/mxcli#1498). + ExportLevel string `json:"exportLevel,omitempty"` } // GetName returns the enumeration's name. @@ -789,6 +793,11 @@ type PublishedRestService struct { Excluded bool `json:"excluded,omitempty"` AllowedRoles []string `json:"allowedRoles,omitempty"` Resources []*PublishedRestResource `json:"resources,omitempty"` + // Documentation is the service's own Documentation property — the + // developer-facing text Studio Pro shows on the document, not + // PublicDocumentation, the OpenAPI description consumers read + // (mendixlabs/mxcli#1510). + Documentation string `json:"documentation,omitempty"` // AuthenticationTypes are the methods a caller may authenticate with, in // the order stored: "Basic", "Session", "Microflow". Empty is "Requires @@ -985,6 +994,9 @@ type DataTransformer struct { SourceJSON string `json:"sourceJson,omitempty"` // source content Steps []*DataTransformerStep `json:"steps,omitempty"` Excluded bool `json:"excluded,omitempty"` + // Documentation is the transformer's Documentation property + // (mendixlabs/mxcli#1510). + Documentation string `json:"documentation,omitempty"` } // GetName returns the transformer's name. diff --git a/sdk/domainmodel/domainmodel.go b/sdk/domainmodel/domainmodel.go index 7e5e366e89..51c3a09af1 100644 --- a/sdk/domainmodel/domainmodel.go +++ b/sdk/domainmodel/domainmodel.go @@ -64,7 +64,9 @@ type Entity struct { // ExportLevel is "Hidden" or "API" (Studio Pro's "Usable"). It only means // something in a Protected (add-on) module, where a Hidden entity may not be // referenced from outside it (CE1406) nor exposed by a Usable microflow - // (CE5400). Read-only here: the writer carries the stored value. + // (CE5400). The writer writes a set level and, for "", keeps the stored one + // (Hidden on a new entity) — the same holds for Attribute, Association and + // CrossModuleAssociation (mendixlabs/mxcli#1498). ExportLevel string `json:"exportLevel,omitempty"` // Persistability @@ -183,6 +185,8 @@ type Attribute struct { Documentation string `json:"documentation,omitempty"` Type AttributeType `json:"type"` Value *AttributeValue `json:"value,omitempty"` + // ExportLevel: see Entity.ExportLevel. + ExportLevel string `json:"exportLevel,omitempty"` // External entity attribute fields (for OData remote entities). // When RemoteName is set on an attribute of an external entity, the writer @@ -354,6 +358,8 @@ type Association struct { Type AssociationType `json:"type"` Owner AssociationOwner `json:"owner"` StorageFormat AssociationStorageFormat `json:"storageFormat,omitempty"` + // ExportLevel: see Entity.ExportLevel. + ExportLevel string `json:"exportLevel,omitempty"` // Line anchors in the domain model editor. Pointers because the zero Point // is a legitimate anchor (top-left corner), so it cannot double as "unset"; // nil means nothing was stored and the writer uses DefaultParentConnection / @@ -695,6 +701,8 @@ type CrossModuleAssociation struct { StorageFormat AssociationStorageFormat `json:"storageFormat,omitempty"` ParentDeleteBehavior *DeleteBehavior `json:"parentDeleteBehavior,omitempty"` ChildDeleteBehavior *DeleteBehavior `json:"childDeleteBehavior,omitempty"` + // ExportLevel: see Entity.ExportLevel. + ExportLevel string `json:"exportLevel,omitempty"` // Source / ViewSourceReference carry a view entity's OqlViewAssociationSource, // exactly as on Association. A view entity's association is cross-module diff --git a/sdk/pages/pages_widgets_action.go b/sdk/pages/pages_widgets_action.go index 60371e4cc1..518372782c 100644 --- a/sdk/pages/pages_widgets_action.go +++ b/sdk/pages/pages_widgets_action.go @@ -230,6 +230,8 @@ type PageClientParameterMapping struct { // explicit mapping whose Variable is a Forms$PageVariable, as Studio Pro // does. Empty means the argument is the context object, which Mendix infers // from an empty ParameterMappings array (#296). See mendixlabs/mxcli#1403. + // "widget" is a control-bar button passing its grid's selection, Variable + // then naming the grid (mendixlabs/mxcli#122). VariableKind string `json:"variableKind,omitempty"` } diff --git a/sdk/versions/mendix-10.yaml b/sdk/versions/mendix-10.yaml index 43ac5c89d7..9e10a00ac4 100644 --- a/sdk/versions/mendix-10.yaml +++ b/sdk/versions/mendix-10.yaml @@ -21,6 +21,10 @@ features: entity_generalization: min_version: "10.0.0" mdl: "EXTENDS Module.ParentEntity" + export_levels: + min_version: "10.0.0" + mdl: "EXPORT LEVEL API | HIDDEN on an entity, attribute, association or enumeration" + notes: "DomainModels Entity/Attribute/AssociationBase.exportLevel and Projects Document.exportLevel (enumerations) introduced in 9.3.0 (mendixmodelsdk 4.116.0 StructureVersionInfo); mendixlabs/mxcli#1498" alter_entity: min_version: "10.0.0" mdl: "ALTER ENTITY Module.Name ADD ATTRIBUTE ..." diff --git a/sdk/versions/mendix-11.yaml b/sdk/versions/mendix-11.yaml index 3e47fb894e..ed4f76e2f5 100644 --- a/sdk/versions/mendix-11.yaml +++ b/sdk/versions/mendix-11.yaml @@ -18,6 +18,10 @@ features: entity_generalization: min_version: "10.0.0" mdl: "EXTENDS Module.ParentEntity" + export_levels: + min_version: "10.0.0" + mdl: "EXPORT LEVEL API | HIDDEN on an entity, attribute, association or enumeration" + notes: "DomainModels Entity/Attribute/AssociationBase.exportLevel and Projects Document.exportLevel (enumerations) introduced in 9.3.0 (mendixmodelsdk 4.116.0 StructureVersionInfo); mendixlabs/mxcli#1498" alter_entity: min_version: "10.0.0" mdl: "ALTER ENTITY Module.Name ADD ATTRIBUTE ..." diff --git a/sdk/versions/mendix-9.yaml b/sdk/versions/mendix-9.yaml index 103322d3da..7995a0554d 100644 --- a/sdk/versions/mendix-9.yaml +++ b/sdk/versions/mendix-9.yaml @@ -16,6 +16,10 @@ features: entity_generalization: min_version: "9.0.0" mdl: "EXTENDS Module.ParentEntity" + export_levels: + min_version: "9.3.0" + mdl: "EXPORT LEVEL API | HIDDEN on an entity, attribute, association or enumeration" + notes: "DomainModels Entity/Attribute/AssociationBase.exportLevel and Projects Document.exportLevel (enumerations) introduced in 9.3.0 (mendixmodelsdk 4.116.0 StructureVersionInfo); mendixlabs/mxcli#1498" microflows: basic: