diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-23-pluggable-text-template-param-drops-association-steps.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-23-pluggable-text-template-param-drops-association-steps.json new file mode 100644 index 0000000000..d3d8e042cb --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-23-pluggable-text-template-param-drops-association-steps.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-23", "symptom": "A pluggable widget's text-template parameter bound across an association — `fieldset fs (legend: '{1}', legendParams: ({1} = Repro.Parent_Child/Label))` — passed check and exec and failed the build: [CE1365] \"Attribute 'Repro.Child.Label' is not an attribute of entity 'Repro.Parent'.\" The stored AttributeRef had the right Attribute and EntityRef null. describe then printed the parameter as the bare `{1} = Label`, which re-executes into CE1613 \"The selected attribute 'Repro.Parent.Label' no longer exists.\" (#1494). The companion report #1495 (an expression parameter stored in AttributeRef.Attribute, spaces stripped) was already fixed in v0.25.0 by #823 and only needed a pin.", "cause": "The executor resolved the hops all along (buildClientTemplateParams -> resolveTemplateAssociationPath fills ClientTemplateParameter.AttributeRefSteps); widgetobj.SerializeColumnClientTemplateParameter hard-coded `EntityRef: nil` and never read the field, although attributeEntityRefBSON, which builds exactly that IndirectEntityRef, sat in the same file for column attributes. On the read side the pluggable describer's extractTextTemplateParameters was a copy of the core extractClientTemplateParameters loop that never learned associationTemplateParamPath.", "fix": "SerializeColumnClientTemplateParameter writes attributeEntityRefBSON(param.AttributeRefSteps); extractClientTemplateParameters now delegates to extractTextTemplateParameters, which gained the association path and the trimmed expression — one reader instead of two.", "file": "mdl/backend/widgetobj/builder.go (SerializeColumnClientTemplateParameter), mdl/executor/cmd_pages_describe_pluggable.go (extractTextTemplateParameters), mdl/executor/cmd_pages_describe_output.go (extractClientTemplateParameters)", "insight": "The reported statement was one of three victims: the same serializer writes widget-level templates, object-list item templates (Accordion group headerText) and Data grid 2 column templates, and the pre-fix binary on a blank 11.12.4 app gives CE1365 on all three — so enumerate the serializer's callers (grep BuildClientTemplateWithTextAndParams) before writing the repro, not after. Dump the stored ClientTemplateParameter before theorising: Attribute correct + EntityRef null says the resolver is fine and the encoder dropped a field it was handed, which rules out the whole entity-context investigation binding-context.md would otherwise send you on. For the expression half, do not trust the report's version: it was filed against v0.24.0 and CHANGELOG 0.25.0 already describes the fix; reproduce on main first, then pin it by stubbing each guard separately — the visitor's expressionSourceText (stub: `if1=1then'A'else'B'`) and isTemplateExpression (stub: `M.Job.if 1 = 1 …`) each reproduce one half of the reported message. The describe twin is the #977 drift a second time: #977 shared the source-variable sub-resolver and left the loop duplicated, so the next feature (association paths) landed in one copy only — share the loop, not a helper.", "refs": ["mendixlabs/mxcli#1494", "mendixlabs/mxcli#1495", "ako/mxcli#1348", "ako/mxcli#1349", "ako/mxcli#823"], "ce": ["CE1365", "CE1613"], "test": "mdl/backend/widgetobj/template_param_association_test.go (fails with EntityRef when builder.go is reverted); mdl/executor/cmd_pages_describe_sourcevariable_test.go TestBothDescribers_RenderAnAssociationPath (pluggable renders [Label]); mdl/executor/cmd_pages_template_expression_1495_test.go"} diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-23-replace-carries-hidden-content-onto-another-entity.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-23-replace-carries-hidden-content-onto-another-entity.json new file mode 100644 index 0000000000..12fff6a922 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-23-replace-carries-hidden-content-onto-another-entity.json @@ -0,0 +1 @@ +{"date": "2026-10-23", "area": "mdl/backend", "symptom": "ako/mxcli#1563 (mendixlabs/mxcli#1631): `alter page … replace cbBooking with { combobox cbSale (… DataSource: database from M.Sale …) }` kept cbBooking's custom content (txtOptionName, bound to Booking.Name) inside cbSale. check -p and exec passed; with the content type 'yes', mx check: CE1365 \"Attribute 'M.Booking.Name' is not an attribute of entity 'M.Sale'\" at a widget the author never wrote and describe never printed. On current main describe prints the type, so the replace turns it 'no' and mx check passes on 11.13.0 — with txtOptionName still stored in cbSale, hidden and bound to Booking", "cause": "The #1247 same-kind REPLACE merge keeps every stored property on which the replacement and a baseline built from describe agree. describe never prints a combo box's custom content, so baseline and replacement are both empty there and the stored widgets are always kept — right while the content's data source keeps its entity, wrong once the statement moves it. The stored Type says which data source the content is built for (ValueType.DataSourceProperty: optionsSourceAssociationCustomContent → optionsSourceAssociationDataSource); the merge never read it", "file": "mdl/backend/pagemutator/pluggable_replace.go (staleLinkedContent, dataSourceEntity, mergePluggableReplace, CheckPluggableReplace), mdl/backend/stale_widget_content.go (StaleWidgetContentError), mdl/executor/cmd_alter_page.go (pluggableReplaceBaseline, refuseStaleContentReplace), mdl/executor/validate_alter_page_refs.go (check-time call, droppedFirst)", "fix": "A merge that would keep a Widgets property's stored widgets while the data source it is linked to now names another entity is refused (ADR-0012: carry or refuse, never drop), by exec and by check -p, naming the widgets and the remedy — `drop ;` first in the same statement, or drop + insert. check counts drops earlier in the script, since it reads the stored document where exec has already applied them. Same entity (a label, a sort, an XPath) keeps the content as before", "test": "TestStaleLinkedContent (2 shapes + 4 controls), TestReplacePluggableRefusesStaleContent (refused, nothing written; the check-time question; control after the content is gone), TestDroppedFirstDiscountsTheRecommendedDrop; mdl-examples/bug-tests/1563-replace-combobox-keeps-hidden-custom-content.mdl", "insight": "**Re-run the repro on main before fixing the reported symptom**: the CE1365 no longer reproduced (describe had since learned to print the content TYPE, so the merge now flipped it to 'no'), but the stored page still held the stale widget — the defect had moved from a build error to an invisible one, which no mx check catches. Dump the stored widget names, not just the error count. **The schema already said which content belongs to which data source** (ValueType.DataSourceProperty in the stored Type); entity comparison, not 'did the data source change', is what separates the refusal from #1247's own case (adding a sort to the same source). **A check-time twin of a refusal must model what exec applies first**: the recommended `drop X; replace …` was refused by check until it counted earlier drops — and a stub of that discount reproduced the false refusal, which is the control that shows it is load-bearing. Found on the way, not fixed here: `create or modify` of a page whose combo box the statement CHANGES silently drops the same custom content (Replaced page, then CE0642 'Custom content is required'); an unchanged combo box survives through the pluggable passthrough", "ce": ["CE1365", "CE0642"], "rules": [], "refs": ["ako/mxcli#1563", "mendixlabs/mxcli#1631", "mendixlabs/mxcli#1247", "mendixlabs/mxcli#1622"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-alter-column-filter-ignores-its-properties.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-alter-column-filter-ignores-its-properties.json new file mode 100644 index 0000000000..8a5e008bd3 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-alter-column-filter-ignores-its-properties.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-23", "symptom": "`alter page … { insert after dg column(Name) { column (Attribute: Kind, Caption: 'Kind') { dropdownfilter ddf (attributes: [Status]) } } }` stored attrChoice \"auto\" with an empty `attr`; check passed and describe printed a bare `dropdownfilter ddf` — #1519 item 2's symptom on the ALTER path, which #1422 named as not done (mendixlabs/mxcli#1519)", "cause": "buildColumnSpecFromAST (the INSERT/REPLACE column path) built a column's filter with backend.BuildFilterWidget from the widget id and name alone, so no filter property was ever read; CREATE routes the same child through buildWidgetV3 and the filter's .def.json. Routing it through buildWidgetV3 then exposed a second gap: a column REPLACE excluded only the target from the name scope, and ContainedWidgetNames read a column reference only as a data view footer, so a replacement reusing its filter's name was refused as a duplicate", "file": "`mdl/executor/cmd_pages_builder_v3_widgets.go` (buildColumnSpecFromAST builds filters via buildWidgetV3), `mdl/executor/cmd_alter_page.go` (column REPLACE excludes what the column contains), `mdl/backend/pagemutator/footerregion.go` (ContainedWidgetNames falls back to findBsonColumn)", "insight": "When a fix lands in one builder, grep for the OTHER builders of the same node: CREATE and ALTER build Data grid 2 columns through different functions, and the #1422 fix covered one. A path that never registered names cannot collide, so switching it onto the shared builder surfaces the name-scope gap the old path hid — run a REPLACE that reuses the inner widget's name as the control, not just an INSERT.", "refs": ["mendixlabs/mxcli#1519", "ako/mxcli#1401", "ako/mxcli#1422"], "test": "mdl/executor/widget_dropdownfilter_attr_test.go TestAlterColumn1519_FilterKeepsItsProperties; mdl/backend/pagemutator/mutator_column_addressing_test.go TestContainedWidgetNames_GridColumnReportsItsWidgets; mdl-examples/bug-tests/1519-alter-column-filter-attribute.mdl"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-alter-entity-second-event-handler-same-event.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-alter-entity-second-event-handler-same-event.json new file mode 100644 index 0000000000..d3da8f26d8 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-alter-entity-second-event-handler-same-event.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-23", "symptom": "`alter entity M.E add event handler on before commit call M.Second($currentObject)` failed with \"event handler already exists for Before Commit\" when the entity already had a Before Commit handler, while `create or modify entity` with two `on before commit` clauses stored both. The same key hid two quieter defects: `add event handler if not exists … call M.New` printed \"already exists, skipping\" for a microflow never wired, and `drop event handler on before commit` removed whichever handler was stored first", "cause": "ALTER ENTITY keyed a handler on (Moment, Event) alone, in both arms. A handler's identity is (Moment, Event, Microflow): Mendix allows several per event, and the DROP grammar had no way to name the microflow at all", "file": "`mdl/executor/cmd_entities.go` (`matchEventHandlers`, `storedHandlerMicroflow`, both event-handler arms of execAlterEntity), `mdl/grammar/domains/MDLDomainModel.g4` (`(CALL qualifiedName)?` on DROP EVENT HANDLER), `mdl/visitor/visitor_entity.go`; tests `mdl/executor/alter_entity_event_handlers_test.go`, `mdl/visitor/drop_event_handler_call_test.go`; repro `mdl-examples/bug-tests/alter-entity-1540-second-event-handler.mdl`", "insight": "When ADD's duplicate key is too coarse, look at DROP and the IF [NOT] EXISTS guards on the same key: all three were wrong, and two of them silently (a skip notice for a handler never added; the first-stored handler dropped). Once ADD can create two, a bare DROP is ambiguous, so it is refused and names the candidates rather than taking the first (the mutator-addressing rule). The validity claim was measured, not assumed, on 11.12.4 with three projects: two Before Commit handlers via ALTER = 0 errors; the same via the OLD binary's create or modify = 0 errors; a void-returning Before Commit handler = CE7247 (the control that shows mx check looks at handlers at all). The same microflow twice on one event is ALSO 0 errors, so ADD's remaining refusal of an exact duplicate is mxcli's re-run guard (it would run twice per commit), not a Mendix rule; say that in the comment rather than letting it read as one. Wrong turn to skip: renaming a copied project's .mpr breaks mx with StorageMprNameDiscrepancyException, which looks like a model failure", "refs": ["mendixlabs/mxcli#1540", "ako/mxcli#1438", "#18"], "ce": ["CE7247"], "rules": []} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-combobox-database-mode-not-written.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-combobox-database-mode-not-written.json new file mode 100644 index 0000000000..94e0de4eb7 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-combobox-database-mode-not-written.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-23", "symptom": "`combobox cbCode (source: 'database', optionsSourceDatabaseDataSource: microflow Mod.GET_Parents, optionsSourceDatabaseItemSelection: 'Single', optionsSourceDatabaseCaptionAttribute: Name, optionsSourceDatabaseValueAttribute: Name, databaseAttributeString: Code)` — check \"3 issues: 0 errors, 3 warnings\" (MDL-WIDGET06 'recognized but not yet persisted' for the datasource and the item selection), exec \"Created page\", mx check \"[CE0463] The definition of this widget has changed\" at Combo box 'cbCode' (CE1613 on Mod.Child.Name when the data view's entity has no Name) (mendixlabs/mxcli#1583)", "cause": "Two layers. (1) combobox.def.json had no database mode: the datasource had no write path, the explicit-key pass wrote the Selection-typed item selection as a PrimitiveValue, and the caption/value attributes, linked to the unwritten datasource, bound to the data view's entity. (2) Once those were written CE0463 stayed: the editorConfig lifter skipped every hide after `\"context\"===t.source ? (…) :` — the database and static branches of an else-if chain, whose guard's boundary is the outer `:` — and unmappedHiddenResets counted a property mapped in ANY mode as written, while the mapping loop only visits the selected mode; so the hidden `optionsSourceType` kept the template's 'enumeration' and `selectAllButtonCaption` its populated template", "file": "`sdk/widgets/definitions/combobox.def.json` (database mode on `hasDataSource:optionsSourceDatabaseDataSource`), `mdl/executor/widget_engine.go` (explicit pass routes Selection via SetSelection; Selection mapping reads its key; unmappedHiddenResets takes the selected mappings), `mdl/executor/editorconfig_extract.go` (elseChainTerms, groupElseChain, parseGuard `:` boundary, else-branch hides inherit enclosing group terms), `cmd_pages_describe_bykey.go` + `cmd_pages_describe_parse.go` (describeComboBoxDatabaseMode), `validate_widgets.go` (comboBoxMode via the engine's selectMode; validateComboBoxDatabase = MDL-WIDGET16), `validate_widget_attribute_type.go` (comboBoxDatabaseRule = MDL-WIDGET39), `widget_describe.go` (definitionDefaults reads the unconditioned mode)", "insight": "**Diff against `mx update-widgets` on a copy, not against a hypothesis.** After the first fix the widget still failed; flattening mxcli's widget and the update-widgets output (keyed by PropertyKey, $ID dropped) gave exactly two differences, both properties the editorConfig hides in database mode — which pointed straight at the lifter rather than the writer. The lifter's gap was invisible to every test because no widget before the combo box hid anything in an else-if branch; dump the lifted rules for every installed .mpk before and after (only Combo box and TreeNode changed) to bound the blast radius. Mode-scoped reads keep biting: widgetValueMap (#1519), unmappedHiddenResets and the example generator's definitionDefaults each read every mode's mappings and each was wrong once a second conditioned mode existed — grep `def.Modes` when adding a mode. A new mode needs its check rules measured too: database mode makes `Attribute:` a String/Integer/Long/Enum target (MDL-WIDGET39 had assumed enumeration) and makes the value attribute required when a target is set (CE0642 'Value') — one throwaway page per row on mxbuild settled both. Describe must spell the mode by the datasource's KEY: the generic `DataSource:` selects the association mode. Measured on 11.12.5 and 11.15.0, Combo box 2.5.0 (the reporter's 2.6.2 was not available here).", "refs": ["mendixlabs/mxcli#1583", "ako/mxcli#1488"], "ce": ["CE0463", "CE0642", "CE7247", "CE1613"], "rules": ["MDL-WIDGET06", "MDL-WIDGET16", "MDL-WIDGET39", "MDL-WIDGET10"], "test": "mdl/executor/widget_combobox_database_test.go; editorconfig_else_if_test.go; widget_hidden_reset_unmapped_test.go TestHiddenResets_AMappingOfAnotherModeIsReset; validate_combobox_caption_test.go TestMDLWIDGET16_ComboBoxDatabaseModeRequired; validate_widget_attribute_type_test.go TestCheck_ComboBoxDatabaseModeAttributeTypes; mdl-examples/bug-tests/1583-combobox-database-mode.mdl"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-combobox-database-mode.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-combobox-database-mode.json new file mode 100644 index 0000000000..9477ce668e --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-combobox-database-mode.json @@ -0,0 +1 @@ +{"date": "2026-10-23", "area": "mdl/executor", "symptom": "ako/mxcli#1647: describe → exec of TestApp's WorkflowCommons.User_WorkflowGroup_Select and WorkflowGroup_User_Select (Studio Pro-authored, 11.14.0) rebuilt each database-source, multi-selection comboBox1 as source 'context', selection Single — mx check CE0642 'Property Caption/Entity is required' ×4 and CE0115 on the button passing $comboBox1 to a list parameter ×2; describe had printed the database source as the association mode's `DataSource:` with a NOT re-executable note for the caption", "cause": "The same gap as ako/mxcli#1488 seen from the read side: combobox.def.json had no database mode, so describe could not spell one and exec could not build one", "file": "none of its own — fixed by mendixlabs/mxcli#1583 (database mode on hasDataSource:optionsSourceDatabaseDataSource, describeComboBoxDatabaseMode), see 2026-10-23-combobox-database-mode-not-written.json", "fix": "Verified on the merged branch: the same drop + create rebuild of the two TestApp pages stores both combo boxes exactly as Studio Pro does (optionsSourceType 'association' leftover included) and mx check 11.14.0 is 0 errors; describe → check over all 68 TestApp pages loses its two MDL-WIDGET16 false errors", "test": "covered by widget_combobox_database_test.go (from #1583); mdl-examples/bug-tests/1647-combobox-database-mode.mdl reproduces the TestApp shape (multi-selection passed to a list parameter) on a fresh project", "insight": "**Two sessions fixed the same gap from opposite ends the same day** — this one from describe → exec of a Studio Pro project, #1583 from a hand-written script — and converged on the same mode switch (hasDataSource:, which the engine had documented for exactly this and no definition used). Merging main surfaced it as conflicts in five files and a definition that auto-merged into TWO database modes with no conflict marker: re-read every file both sides touched, not only the conflicted ones. The better half won: #1583 also fixed the editorConfig else-chain lift and the hidden-property reset that kept CE0463 after the write was right, which this session's version never reached because its repro (TestApp) does not set the properties those hide. **The measurement that tells a describe gap from a write gap is the rebuild, not the in-place round trip**: in place the pluggable passthrough kept the stored combo box and the run was 'Unchanged'", "ce": ["CE0642", "CE0115"], "rules": ["MDL-WIDGET16"], "refs": ["ako/mxcli#1647", "ako/mxcli#1488", "mendixlabs/mxcli#1583"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-describe-names-combobox-custom-content.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-describe-names-combobox-custom-content.json new file mode 100644 index 0000000000..7796d8677d --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-describe-names-combobox-custom-content.json @@ -0,0 +1 @@ +{"date": "2026-10-23", "area": "mdl/executor", "symptom": "ako/mxcli#1563: `describe page` printed a combo box with custom content as if it had none (`combobox cbBooking (… optionsSourceAssociationCustomContentType: 'yes')`), so the widgets in it — txtOptionName, bound to Booking.Name — were visible to nobody until mx check named one. A `create or modify` of that description that CHANGES the combo box drops the content (Replaced page, then CE0642 'Custom content is required'); unchanged, the pluggable passthrough keeps it", "cause": "The NOT SHOWN note for a child slot holding widgets (unreconstructedContainers, #965) is computed only for generic pluggable widgets; a combo box is a known type with its own describer, and MDL cannot author its custom content (MDL-WIDGET26), so nothing ever said it was there", "file": "mdl/executor/cmd_pages_describe_omitted.go (comboBoxHiddenContent, writeHiddenContentNote), cmd_pages_describe_parse.go, cmd_pages_describe_output.go, cmd_pages_describe.go (rawWidget.HiddenContent)", "fix": "A combo box whose widgets properties hold widgets is described with a `-- NOT SHOWN:` comment before it, naming the property and each widget (or 'an unnamed '), what keeps and what loses the content, and `alter page … set` as the edit that keeps it. The output still parses and re-executes; GetPut on the repro page stays 'Unchanged page'", "test": "TestDescribeComboBoxNamesItsHiddenCustomContent (the shipped template plus content; reparses), control TestDescribeComboBoxWithoutCustomContentHasNoNote", "insight": "Measure what a note claims before writing it: on 11.13.0 the content survives describe → exec in place only while the combo box is unchanged (passthrough), is lost when the statement changes it (CE0642), and `alter … set` keeps it — three outcomes the generic note's 'Re-running this script would DROP it … re-add the block by hand' gets wrong for a widget whose block cannot be written at all. The note names widgets rather than the slot keyword because the name is what the replace refusal and `drop` take", "ce": ["CE0642", "CE1365"], "rules": ["MDL-WIDGET26"], "refs": ["ako/mxcli#1563", "mendixlabs/mxcli#1631", "#965"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-excluded-on-decision-silently-dropped.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-excluded-on-decision-silently-dropped.json new file mode 100644 index 0000000000..f7dc24671d --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-excluded-on-decision-silently-dropped.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-23", "symptom": "mendixlabs/mxcli#1139 (ako/mxcli#1282): asked for Studio Pro's 'Disable activity' in MDL; it is `@excluded` on a statement, kept since #1328. Residual: `@excluded if $b then … end if;` (or on a loop, while, return, raise error) passed check and exec and was dropped — describe showed no @excluded and the branch ran — and `mxcli syntax microflow layout` said @excluded is a DOCUMENT annotation that does not go on a statement", "cause": "only ActionActivity has Disabled (Model SDK: ActionActivity.disabled, introduced 9.12.0, on no other type), so applyAnnotations has nowhere to put it on an ExclusiveSplit/LoopedActivity/event and silently skips it; no check reported the drop. The syntax help predated activity-level @excluded", "file": "mdl/executor/validate_microflow.go (checkExcludedOnNonActivity, called from checkUnknownAnnotations so it covers microflows, nanoflows and handler bodies), cmd/mxcli/syntax/features_microflow.go, .claude/skills/mendix/write-microflows/SKILL.md", "fix": "MDL095 warning for @excluded on a decision, loop, end/error/break/continue event or merge, naming the activities as the place to put it; help and skill document statement-level @excluded as 'Disable activity'", "test": "mdl/executor/issue1139_excluded_non_activity_test.go (decision, loop, while, return, raise error, nested, nanoflow; activity forms stay quiet). Control: with the rule constant but no check every case failed 'want one MDL095, got []'. Real run on 11.12.4: describe of the decision microflow printed no @excluded, the activity and nanoflow-call forms printed it back", "insight": "Re-run the reporter's exact repro on main before fixing: the headline symptom of #1282 was already fixed by #1328, and the triage comment named the remaining part precisely. What was left were the shapes the fix did not cover (non-activity statements) and documentation that contradicted the feature — the help text telling users @excluded does not go on a statement is probably why the request stayed open. A warning, not a refusal, matches MDL042 (caption on a loop), the same drop shape", "refs": ["mendixlabs/mxcli#1139", "ako/mxcli#1282", "mendixlabs/mxcli#1328"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-flow-call-widget-selection-argument-dropped.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-flow-call-widget-selection-argument-dropped.json new file mode 100644 index 0000000000..d90c4fa2da --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-flow-call-widget-selection-argument-dropped.json @@ -0,0 +1 @@ +{"date": "2026-10-23", "area": "mdl/executor", "symptom": "ako/mxcli#1560 (mendixlabs/mxcli#1628): a button passing a widget's SELECTION to a microflow — a Data grid 2 control-bar button passing its grid, a data view footer button passing a combo box's selection, a control-bar data view whose microflow source takes the grid — described as `Action: call microflow M.ACT_Remove` with the argument gone; describe → check reported CE1571 13 times on TestApp pages mx check passes, and rebuilding the nine pages from their description gave 13 real CE1571 on mx check 11.14.0. An in-place describe → exec kept the grid ones only because the stored Data grid 2 passed through unchanged; the combo box buttons (native, rebuilt) lost theirs: 2 CE1571", "cause": "Studio Pro stores a selection argument as a Forms$PageVariable naming only the widget (Widget: grid2, every other slot empty). pageVariableArgValue read PageParameter/SnippetParameter/LocalVariable and deliberately not Widget, because the builder had no way to write `$grid` back: classifyFlowArgValue knew parameters and page variables only, so `$grid2` fell to Expression \"$grid2\", and bindParameterMappingValue had no widget kind (a widget kind fell to the PageParameter slot). Show page had solved the same shape for the control bar alone (#122); the flow-call path was its untouched twin", "file": "mdl/executor/cmd_pages_flow_args.go (flowArgVariableValue, classifyFlowArgValue widget kind, widgetNameSet), mdl/executor/cmd_pages_describe_output.go (extractMicroflowParameters, extractNanoflowParameters, extractPageParameters), mdl/executor/cmd_pages_describe_datasource.go (flowSourceArgs), mdl/executor/cmd_pages_builder.go (isDocumentWidget), mdl/executor/cmd_pages_create_v3.go, cmd_pages_layout_v3.go, cmd_alter_page.go (alterDocumentWidgets), mdl/backend/modelsdk/widget_write.go (bindParameterMappingValue)", "fix": "Describe renders a Widget-only PageVariable on a flow call or flow data source as `$widget` (snippet calls unchanged: no Studio Pro mapping has one). The builder binds `$name` as kind \"widget\" when name is a widget of the document — every name in a CREATE PAGE/SNIPPET/LAYOUT, the stored tree plus the added widgets in an ALTER — after parameters and page variables, which win on a clash; the writer puts it in the Widget slot with Expression \"\"", "test": "TestDescribeFlowArgReadsWidgetSelection, TestDescribeDataSourceArgReadsWidgetSelection, TestClassifyFlowArgValueNamesDocumentWidget (+ controls), TestClassifyFlowArgValueWithoutDocumentWidgets, TestClientActionToGen_FlowCallWritesSelectionMapping (+ page-parameter control); mdl-examples/bug-tests/1560-selection-and-row-action-arguments.mdl", "insight": "**Run describe → check over a whole Studio Pro-authored project before trusting a report's repro.** The reporter's repro (a data view with a button) passed check; the 16 TestApp hits were two different defects, and a BSON dump of each flagged widget's mappings sorted them in minutes (13 Widget-only variables, 3 empty mapping lists). **The in-place round trip hid the loss**: pluggable passthrough (#721 L4) kept the stored grid, so control-bar buttons survived exec — only `drop` + `create` from the description (or a different project) exercises the builder; do that before calling a describe gap harmless. **A comment saying 'deliberately not read' records a write-side gap, not a fact about Mendix** — fix both halves together or the reader is right to stay blind. A pre-fix script wrote Expression \"$grid\" and mxbuild 10.24.25 accepted it (0 errors), so mx check cannot distinguish the forms; Studio Pro's stored shape is the evidence. Left open: a database-source multi-select combo box rebuilt from describe comes back source 'context', selection Single (CE0642, and CE0115 against a list parameter) — a separate combo box round-trip loss", "ce": ["CE1571", "CE0115"], "rules": [], "refs": ["ako/mxcli#1560", "mendixlabs/mxcli#1628", "mendixlabs/mxcli#122", "mendixlabs/mxcli#1140", "ako/mxcli#826"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-list-widget-row-action-judged-outside-its-row.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-list-widget-row-action-judged-outside-its-row.json new file mode 100644 index 0000000000..2b60213a2b --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-list-widget-row-action-judged-outside-its-row.json @@ -0,0 +1 @@ +{"date": "2026-10-23", "area": "mdl/executor", "symptom": "ako/mxcli#1560 (mendixlabs/mxcli#1628): `check -p` reported CE1571 for a list widget's OWN click action with no argument — `listview lv (DataSource: database M.Item, Action: call microflow M.ACT_OpenItem)`, the same as datagrid / gallery `OnClick:` — on pages mxbuild builds (10.24.25: 0 CE1571 for all three; TestApp's ConflictedWorkflowDefinitionView dataGrid2_2, ConflictResolution dataGrid2_1, WorkflowDefinition_Overview dataGrid21 are Studio Pro-authored with empty ParameterMappings). Because exec refuses on a check error, a describe → exec of those pages could not run without --no-check", "cause": "validateFlowArgumentsIn judged every action slot against `enclosing`, the context around the widget. Right for a button, wrong for the widget that establishes the context: a list widget's click fires per row and the row fills the parameter in. Show page had the same fix since #552 (argContextForOwnAction); the CE1571 walk was its twin", "file": "mdl/executor/validate_datasource_args.go (isRowAction, actionArgErrors gains the row context, walk computes childContext before the action check)", "fix": "The \"Action\" slot (where `Action:` and `OnClick:` both land) of a widget with a data source of its own is judged in childContext(enclosing, ds, …); every other slot (a gallery's onSelectionChange) and the data source itself stay on the enclosing context", "test": "TestRowAction_ListWidgetsOwnClickIsRowScoped (listview, datagrid, gallery), TestRowAction_NanoflowAndSpecializationAreRowScoped; controls TestRowAction_RowOfAnotherTypeIsReported, _PrimitiveParameterIsReported, _NonRowSlotKeepsTheEnclosingContext, _LooseButtonStillReported, _DataSourceStaysOnTheEnclosingContext", "insight": "**A context rule fixed on one path has a twin on the other**: show page's own-action context (#552) and control-bar selection (#122) both existed, and the CE1571 walk and the flow-call builder each needed the same two facts again. When a context fix lands, grep the other rules that walk the same tree (validate_datasource_args.go, cmd_pages_showpage_args.go, validate_widgets.go) for the shape. The gallery measurement needs care: with the default selection and single-click trigger it fails on \"The item click action is ambiguous\" (MDL-WIDGET36), not CE1571 — read the error before counting it as a positive. Control the fix with what must STAY reported (row of another type, primitive parameter, non-row slot, a loose button) or it is indistinguishable from deleting the rule", "ce": ["CE1571"], "rules": [], "refs": ["ako/mxcli#1560", "mendixlabs/mxcli#1628", "#552", "mendixlabs/mxcli#1082"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-mdl031-case-column-first-branch-not-first-typable.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-mdl031-case-column-first-branch-not-first-typable.json new file mode 100644 index 0000000000..23cc3f30d8 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-mdl031-case-column-first-branch-not-first-typable.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-23","symptom":"MDL031 refused a view-entity CASE column declared String(8) — `case when … then l.Code when … then '0' + l.Code else '0000' end` over a String(8) Code — with \"declared as String(8) but OQL expression returns String(200) … Fix: change to 'SortKey: String(200)'\"; following the fix is CE6770 \"View Entity is out of sync with the OQL Query\", and exec ran the same check, so only --no-check wrote the declaration that builds. Numeric CASEs had the twin: `then 1 else 2.5` was typed Integer, refusing Decimal (the one that builds) and suggesting Integer (CE6770).","cause":"inferCaseType returned the first branch it could TYPE. Without the project a bare attribute is untyped, so the later concatenation (derived, String(200)) decided. Mendix's rule is different per kind: a String CASE takes its FIRST branch's type, length included; a numeric CASE widens across ALL branches, Integer < Long < Decimal. The project-aware path never typed CASE branches at all (it fell back to the static inferrer), and typed a string literal at its own length instead of the derived 200.","file":"mdl/executor/oql_type_inference.go (inferCaseType, caseResultExprs, numericRank; inferTypeFromExpression's CASE and literal branches; passthroughLengthError wording)","insight":"Don't reason about the derivation rule — grid it. One mx check run over N expressions × every candidate declaration (lengths 1–16, 100, 108, 112, 200, unlimited; Integer/Long/Decimal) leaves exactly one accepted declaration per view, so the derived type reads straight off the survivors: 294 views, 20 s. The grid overturned the prior finding's 'a string-returning CASE is String(200)', which had only ever been measured on literal-first CASEs, and showed the two kinds follow DIFFERENT rules — `then l.Code else l.Name` (8, 100) is String(8), not the max, while `then l.IntV else l.LngV` is Long either way round. Had the fix been 'first branch decides' for every kind it would have kept the numeric false positive. The table of measurements is the test (oql_case_type_test.go): each row asserts the accepted declaration is never refused and, where check can judge, every other one is, with the accepted type as the Fix. A branch the checker cannot type must leave the result unknown wherever it could decide it — a first branch that is not a typed string, a numeric branch that could be wider — which is the issue's own 'or MDL031 is not raised when the length cannot be inferred'.","refs":["ako/mxcli#1294","mendixlabs/mxcli#1250"],"ce":["CE6770"],"rules":["MDL031"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-rewrite-changed-combobox-refuses-content-loss.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-rewrite-changed-combobox-refuses-content-loss.json new file mode 100644 index 0000000000..aa8b3a7243 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-rewrite-changed-combobox-refuses-content-loss.json @@ -0,0 +1 @@ +{"date": "2026-10-23", "area": "mdl/executor", "symptom": "ako/mxcli#1648: `create or modify` of a page whose combo box the statement changes (Label 'Booking' → 'Booking (changed)' in the describe output) deleted the combo box's custom content — txtOptionName, which MDL cannot author — while optionsSourceAssociationCustomContentType stayed 'yes': exec reported 'Replaced page', then mx check 11.13.0 CE0642 \"Property 'Custom content' is required.\" at Widgets container 'Custom content' of combo box 'cbBooking'", "cause": "The #721 L4 pluggable passthrough keeps a stored pluggable widget's Type and Object only when the statement's widget equals what describe prints for it; a changed one is rebuilt from the template, which has no custom content. describe prints the content TYPE (so the rebuilt combo box keeps 'yes') but cannot print the content, so nothing carried it, and nothing refused", "file": "mdl/executor/cmd_pages_pluggable_passthrough.go (storedPluggable.hidden, storedHiddenContent, refuseLostHiddenContent, refuseChangedWithHiddenContent, hiddenContentRebuildError), cmd_pages_builder_v3.go (buildPluggable: refuse when the passthrough declines), cmd_pages_rewrite_guard.go (checkNoHiddenContentRebuilt in validateUnauthorableRewrites), cmd_pages_describe_omitted.go (note wording)", "fix": "A stored combo box whose custom content holds widgets is, when the passthrough declines it and the rebuild is a combo box again, refused by exec naming the widgets, with `alter page … set` (keeps the content) or drop-then-rewrite-without-the-type as the remedy; check -p refuses the same rewrite up front from describe's pairing, as #1622 does for widgets MDL cannot author. An unchanged combo box, another widget kind under the name, and a combo box without content are untouched", "test": "TestStoredHiddenContentReadsTheComboBoxContent, TestChangedComboBoxWithHiddenContentIsRefused, TestComboBoxWithHiddenContentControls (unchanged, no content, another widget kind, nested); mdl-examples/bug-tests/1648-rewrite-changed-combobox-keeps-custom-content.mdl", "insight": "**Refuse at the decision, not at a guess of it**: the passthrough already decides 'kept as stored' vs 'rebuilt'; hanging the refusal on its 'no' covers a context change under identical text too, which check — comparing only the text — cannot see, so exec's half is a superset of check's and they never contradict on the reported shape. A blanket #1622-style guard ('the page holds content MDL cannot author') would have refused every unchanged describe → exec, breaking GetPut, because unlike a classic data grid the combo box survives an unchanged rewrite. Scope it to the widget whose slots describe never prints (the combo box): for every other pluggable widget a child missing from the statement is a deletion the author wrote. Carrying the content instead would need the #1247 merge's TypePointer remap inside a template rebuild — the refusal is the honest smaller fix, and its remedy was measured to build clean", "ce": ["CE0642"], "rules": [], "refs": ["ako/mxcli#1648", "ako/mxcli#1563", "mendixlabs/mxcli#1622", "ako/mxcli#721"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-selection-by-widget-key-stored-as-primitive.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-selection-by-widget-key-stored-as-primitive.json new file mode 100644 index 0000000000..e7d9e231ce --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-selection-by-widget-key-stored-as-primitive.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-23","symptom":"mendixlabs/mxcli#1618: `datagrid dg (…, itemSelection: Single)` / `gallery g (…, itemSelection: None)` passed check, exec and mx check and stored PrimitiveValue \"Single\"/\"None\" with Selection left at the default (None on a grid, Single on a gallery) — no selection, and no way to turn a gallery's off. `itemSelection: Bogus` was accepted, and `gallery (…, advanced: true)` was discarded (Gallery 3.4.0 declares no `advanced`; the hand-written definition maps it to the constant false)","cause":"resolveMapping's Selection source read only the `Selection:` keyword; the explicit-key pass skipped 'already handled' names by the mappings' SOURCE keywords, not their property keys, so the key fell to the scalar branch (SetPrimitive). refuseUndeclaredMappedKey exempted every constant mapping, including one the script overrode by key","file":"mdl/executor/widget_selection_value.go (selectionMappingValue), widget_engine.go (explicit pass, refuseUndeclaredMappedKey), validate_widget_selection.go (MDL-WIDGET50), validate_widgets.go + widget_known_props_from_mpk.go (declaredKeys)","insight":"Already named as trap (3) in the 2026-10-17 describe-by-key finding — grep the findings for the KEY name before diagnosing, the cause may be written down beside a different fix. A widget property has two spellings (the mapping's Source keyword and its PropertyKey) and every reader must treat them as one: the builder, the explicit pass, and the rules that judge the value (the Gallery click-ambiguity rule read only `Selection`, so `itemSelection: None` + onClick was falsely MDL-WIDGET36; on the pre-fix build mxbuild really did fail it, 'The item click action is ambiguous', and passes it after). mxbuild 11.12.5 accepts Selection \"Bogus\" at 0 errors even with --no-update-widgets, so the refusal is load-bearing. The best runtime-free proof was structural: the by-key grid's widget object is identical to the keyword-spelled grid's (ids stripped), and differs on the pre-fix build. The declared-key check needs the INSTALLED package (check -p): the embedded template is a guess about the version, so without a project it stays quiet and exec refuses","refs":["mendixlabs/mxcli#1618","ako/mxcli#1552","mendixlabs/mxcli#1573"],"rules":["MDL-WIDGET50","MDL-WIDGET01","MDL-WIDGET36"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-show-page-number-of-pages-to-close-undescribed-unwritable.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-show-page-number-of-pages-to-close-undescribed-unwritable.json new file mode 100644 index 0000000000..a0049346b9 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-show-page-number-of-pages-to-close-undescribed-unwritable.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-23","symptom":"mendixlabs/mxcli#1617: DESCRIBE MICROFLOW printed a Show page action whose 'Number of pages to close' is 1 (Microflows$ShowFormAction.NumberOfPagesToClose \"1\") exactly like one that closes none, and no statement could write it: `show page M.P (NumberOfPagesToClose: 1)` passed check and exec and stored \"\"","cause":"Three hops each lacked it: the reader never read NumberOfPagesToClose, the writer hard-coded SetNumberOfPagesToClose(\"\"), and the show page `with` list read title/location/modal and silently ignored any other key. The reporter's paren spelling is the deprecated `Param: value` ARGUMENT form, so it wrote a page parameter mapping to a parameter the page does not have (CE1613 in mxbuild) while `check --references` said 'All references valid' — the flow calls had that check (#953), show page did not","file":"mdl/backend/modelsdk/microflow_read_actions.go, microflow_write.go, mdl/executor/cmd_microflows_format_action.go, mdl/visitor/visitor_strict_properties.go (ExitShowPageStatement), mdl/executor/validate_show_page_params.go","insight":"A setting the reporter tried as `(Key: value)` on a call site is not a dropped property but a dangling ARGUMENT — check the argument-name validation before adding a property, because that is what told the user nothing. The value is a MicroflowExpression per the Model SDK (introduced 8.11, no version gate), and mxbuild 11.12.5 is the arbiter of which values pass: 0, -1, 2 and `$Count + 1` build clean, 1.5 and a Boolean are CE0117, `order` for parameter `Order` is CE1613 (case-sensitive) — so the visitor refuses only non-integer literals, ungated like `close page 1.5` since the key never worked. `with location`/`modal` were read into PageSettings that no writer emits (location deleted in 8.0), now MDL-V1-PROP. Prove the round trip with drop + exec of the description and a raw grep of the unit: an 'Unchanged' report is the writer comparing two read-backs that both lack the field, which is why the reporter saw no data loss","refs":["mendixlabs/mxcli#1617","ako/mxcli#1550","#953"],"ce":["CE1613","CE0117"],"rules":["MDL-V1-PROP","MDL-V1-PROPVALUE"]} diff --git a/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-23-log-include-latest-stack-trace-unspellable.json b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-23-log-include-latest-stack-trace-unspellable.json new file mode 100644 index 0000000000..f41614a007 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-23-log-include-latest-stack-trace-unspellable.json @@ -0,0 +1 @@ +{"area": "mdl/grammar", "date": "2026-10-23", "symptom": "mendixlabs/mxcli#1543 (ako/mxcli#1441): a Log message activity's 'Include latest stack trace' had no MDL spelling — `LOG ERROR NODE 'X' 'failed' WITH STACKTRACE;` was a parse error (`mismatched input 'stacktrace' expecting '('`), so every log written through MDL stored IncludeLatestStackTrace = false and describe dropped a Studio Pro-ticked box on the round trip", "cause": "half-wired: the model field (IncludeLastStackTrace), the modelsdk writer and the reader all carried it since the engine rewrite, but no grammar slot, AST field, builder assignment or describe clause existed, and the --mcp writer omitted it", "file": "mdl/grammar/MDLLexer.g4 (STACKTRACE), mdl/grammar/domains/MDLMicroflow.g4 (logStackTrace), MDLSettings.g4 (keyword rule), mdl/visitor/visitor_microflow_actions.go, mdl/executor/cmd_microflows_builder_calls.go, mdl/executor/cmd_microflows_format_action.go, mdl/backend/mcp/microflow.go", "fix": "`log … [with ({1} = …)] with stacktrace` after the template parameters; `WITH (` vs `WITH STACKTRACE` is decided on the second token. STACKTRACE is in the keyword rule so `$latestError/Stacktrace` (System.Error's attribute) still parses. Describe prints it only when ticked (unticked is the default an absent clause builds), so existing describe output is unchanged", "test": "mdl/executor/issue1543_log_stacktrace_test.go (parse→build, describe→reparse, $latestError/Stacktrace); mdl/backend/mcp/microflow_test.go. Control: with only the AST field the test failed with the reported parse error; with grammar but no builder line, IncludeLatestStackTrace = false. Real run on 11.12.4: mx check 0 errors with the flag in a microflow and a nanoflow; control nanoflow with an undefined template variable reports CE0109 on that Log message activity, so the check does inspect it", "insight": "Grep the model struct's field writers before designing syntax: the backend already round-tripped the flag, so the gap was only the language surface and the --mcp map, which turns a 'feature' into five small wiring edits. Version facts: the Model SDK's LogMessageAction.versionInfo has no entry for includeLatestStackTrace (present in every supported version), so no gate. A new keyword next to a template clause needs a test that the System attribute of the same name still parses in expressions. `describe … with handles` now prints handles containing `with stacktrace`, which an alter target cannot spell (alterFlowTarget stops at WITH — pre-existing for `with ({1} = …)` and `with events`); `*` covers it", "refs": ["mendixlabs/mxcli#1543", "ako/mxcli#1441"]} diff --git a/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-23-routine-header-without-parens-one-error.json b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-23-routine-header-without-parens-one-error.json new file mode 100644 index 0000000000..5593b48f9a --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-23-routine-header-without-parens-one-error.json @@ -0,0 +1 @@ +{"area":"mdl/visitor","date":"2026-10-23","symptom":"`create microflow Tmp.F returns Boolean as $b begin … return $b; end;` still produced three errors after the parentheses hint landed — \"missing '(' at 'returns'\" (with the hint), \"missing ':' at 'Boolean'\" and, three lines into the body, \"extraneous input 'return' expecting the start of a statement\"; a bare `$l: List of Tmp.Item` gave \"missing ')' at 'returns'\" as a second error, `create nanoflow M.NF begin end;` three. The report asked for one message.","cause":"The hint only annotated ANTLR's first error; the cascade came from recovery itself. ANTLR inserts the `(`, parses `returns` as a parameter name (parameterName admits keywords), lets dataType's recovery swallow `Boolean as $b begin declare … true` and closes the statement at that `;` — so the tail of the body is reported at program level, outside the statement the hint fired for.","file":"mdl/grammar/domains/MDLMicroflow.g4 (createMicroflow/Nanoflow/RuleStatement: `(LPAREN microflowParameterList? RPAREN | microflowParameterList)?`); mdl/visitor/visitor_microflow.go (refuseRoutineHeaderWithoutParens); the regex hint in visitor.go removed","insight":"Dump the error listener's rule-context stack at each error before choosing where to fix a cascade: here the third error's stack was just ProgramContext, i.e. the parser had already closed the statement, so no per-line or per-statement suppression in the listener can tie it back to the hint — the recovered parse no longer knows where the real statement ends. Accept-then-refuse removes the cascade at its source: the grammar parses the bare header only so the builder refuses it, which gives one error, positioned at the token the `(` should precede, and lets the builder quote the user's own header back with the parentheses added. The previous fix's note ('suppressing it would need cross-error state') was the tell that the listener was the wrong layer. Two controls: stubbing the refusal makes the test fail with 0 errors (the grammar alone would silently ACCEPT a new spelling), and the original code fails it with the reported three. Same pattern as the positional call argument (#569). When removing the old hint, check the surviving case — a bare header with a second, unrelated error on its line — would otherwise get the explanation twice.","refs":["ako/mxcli#1396","mendixlabs/mxcli#1514"]} diff --git a/.claude/skills/fix-issue/findings/other/2026-10-23-datagrid-column-size-is-a-weight.json b/.claude/skills/fix-issue/findings/other/2026-10-23-datagrid-column-size-is-a-weight.json new file mode 100644 index 0000000000..a576df0c74 --- /dev/null +++ b/.claude/skills/fix-issue/findings/other/2026-10-23-datagrid-column-size-is-a-weight.json @@ -0,0 +1 @@ +{"area": "docs", "date": "2026-10-23", "symptom": "`alter page … set (ColumnWidth: manual, Size: 220) on dg column(Name)`: the other columns collapse at runtime; `mxcli syntax page column` documented `[, Size: integer]` with the example `ColumnWidth: manual, Size: 150`, and the quick references and create-page skill said \"integer (px)\" (mendixlabs/mxcli#1519 item 3a)", "cause": "Not a write defect: Data grid 2 renders `size` as a relative weight. Its `columnWidth` getter (Datagrid 3.4.0, Datagrid.mjs) returns `${weight}fr` for manual, `1fr` for autoFill and `minmax(px | min-content | auto, auto)` for autoFit — so Size: 220 takes 220 shares against one per autoFill column. The documentation called it pixels", "file": "`cmd/mxcli/syntax/features_page.go`, `.claude/skills/mendix/create-page/reference/widgets.md`, `docs-site/src/appendixes/quick-reference.md`, `docs/01-project/MDL_QUICK_REFERENCE.md`", "insight": "For a rendering question, read the widget's own render code in the .mpk before building an app: `grep -o '.{200}\"manual\".{250}' Datagrid.mjs` answered fr-vs-px in one line and gave the exact CSS for each width mode. The pixel option is `ColumnWidth: autoFit, minWidth: manual, minWidthLimit: N` — writable and round-tripping on main (0 errors, 11.15.0), though the report's nightly predated that.", "refs": ["mendixlabs/mxcli#1519", "ako/mxcli#1401"]} diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 2a043f26a7..3bdfb1b183 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -329,7 +329,9 @@ datagrid gridName ( | `Draggable` | `true`, `false` | `true` | Can reorder column | | `Hidable` | `yes`, `hidden`, `no` | `yes` | Can hide column | | `ColumnWidth` | `autofill`, `autoFit`, `manual` | `autofill` | Column width mode | -| `Size` | integer (px) | `1` | Width in pixels — only under `ColumnWidth: manual`. Any other width hides it, so it must stay `1` (CE0463 otherwise): an `ALTER PAGE … SET (ColumnWidth: autoFill)` puts it back at `1` itself, and `SET (ColumnWidth: autoFill, Size: 220)` is refused | +| `Size` | integer (weight) | `1` | A **relative weight, not pixels** — only under `ColumnWidth: manual`. Data grid 2 renders it as `fr` beside `1fr` for every `autoFill` column, so `Size: 220` takes 220 shares of the free width against one share per autoFill column: the others collapse (mendixlabs/mxcli#1519). Use small ratios (`Size: 2` = twice an autoFill column). For a width in pixels use `minWidthLimit` below. Any width but manual hides `Size`, so it must stay `1` (CE0463 otherwise): an `ALTER PAGE … SET (ColumnWidth: autoFill)` puts it back at `1` itself, and `SET (ColumnWidth: autoFill, Size: 220)` is refused | +| `minWidth` | `auto`, `minContent`, `manual` | `auto` | Only under `ColumnWidth: autoFit`, which renders `minmax(, auto)`: `manual` takes the minimum from `minWidthLimit` | +| `minWidthLimit` | integer (px) | `100` | The minimum width **in pixels**, under `ColumnWidth: autoFit, minWidth: manual` — the pixel option `Size` is not | | `visible` | expression string | `true` | Column-level visibility — hides/shows the whole column, so use page variables, NOT `$currentObject` (per-object widget visibility is different — see "Conditional Visibility and Editability") | | `DynamicCellClass` | expression string | (empty) | Dynamic CSS class per cell | | `tooltip` | text string | (empty) | Cell tooltip text | @@ -359,7 +361,7 @@ column ( Alignment: right, WrapText: true, Sortable: false, Resizable: false, Hidable: hidden, - ColumnWidth: manual, Size: 150, + ColumnWidth: manual, Size: 2, -- a weight: twice an autoFill column DynamicCellClass: if($currentObject/Price > 100) then 'highlight' else '' , tooltip: 'Price in USD' ) @@ -562,6 +564,22 @@ combobox cmbCustomer (label: 'Customer', Association: Order_Customer, datasource -- optionsSourceAssociationCaptionType: expression, -- optionsSourceAssociationCaptionExpression: '$currentObject/Name' -- Enumeration mode (no datasource) has no caption and needs none. + +-- Database mode: options from a datasource of their own; the chosen option's +-- value is stored on an attribute of the enclosing object. Selected by naming +-- `optionsSourceDatabaseDataSource` — the generic `datasource:` clause selects +-- association mode instead. `Attribute:` is the target (String, Integer, Long +-- or Enumeration, else CE7247 — MDL-WIDGET39 with -p), `CaptionAttribute:` the +-- option caption, and `optionsSourceDatabaseValueAttribute:` the option's +-- attribute whose value is stored. +combobox cbCode (label: 'Code', optionsSourceDatabaseDataSource: microflow MyModule.DS_Codes, + attribute: Code, CaptionAttribute: Name, optionsSourceDatabaseValueAttribute: Code) +-- WRONG: a target attribute with no value attribute — MDL-WIDGET16, CE0642 +-- "Property 'Value' is required." Without `attribute:` the combo box only +-- selects and needs no value attribute. No caption is CE0642 'Caption', as in +-- association mode (expression caption: optionsSourceDatabaseCaptionType / +-- optionsSourceDatabaseCaptionExpression). `optionsSourceDatabaseItemSelection: +-- Multi` selects several and hides both the target and the value attribute. ``` ### DataView with Form Layout diff --git a/.claude/skills/mendix/generate-domain-model/reference/syntax.md b/.claude/skills/mendix/generate-domain-model/reference/syntax.md index b62e7b41e0..0d29d14406 100644 --- a/.claude/skills/mendix/generate-domain-model/reference/syntax.md +++ b/.claude/skills/mendix/generate-domain-model/reference/syntax.md @@ -435,15 +435,15 @@ on after create call Sales.ACT_InitDefaults; alter entity Sales.Order add event handler on before delete call Sales.ACT_CheckCanDelete raise error; --- Drop via ALTER ENTITY +-- Drop via ALTER ENTITY (call names which, when the event has several) alter entity Sales.Order - drop event handler on before commit; + drop event handler on before commit call Sales.ACT_ValidateOrder; ``` **Moments**: `before`, `after` **Events**: `create`, `commit`, `delete`, `rollback` -Each (Moment, Event) combination can only have one handler per entity. The microflow must exist (the executor validates the reference). `raise error` is optional — without it, the handler runs but its return value doesn't affect the operation. +An entity may have several handlers for one (Moment, Event), as in Studio Pro: a handler is identified by its moment, event and microflow, so `add event handler` with another microflow adds a second one, and `drop event handler … call Mod.MF` says which to remove (without `call`, a drop matching several is refused). The microflow must exist (the executor validates the reference). `raise error` is optional — without it, the handler runs but its return value doesn't affect the operation. ### Associations diff --git a/.claude/skills/mendix/mdl-entities/SKILL.md b/.claude/skills/mendix/mdl-entities/SKILL.md index 3e61872451..1ff4771f90 100644 --- a/.claude/skills/mendix/mdl-entities/SKILL.md +++ b/.claude/skills/mendix/mdl-entities/SKILL.md @@ -18,11 +18,11 @@ description: "Complete syntax reference for entities, attributes and association | 130-175 | Generalization (Inheritance) | | 176-224 | Associations | | 225-241 | Enumerations | -| 242-283 | View Entity (OQL) | -| 284-307 | Entity with Index | -| 308-369 | Complete Domain Model Example | -| 370-387 | Changing an Existing Domain Model | -| 388-430 | Quick Reference | +| 242-298 | View Entity (OQL) | +| 299-322 | Entity with Index | +| 323-384 | Complete Domain Model Example | +| 385-402 | Changing an Existing Domain Model | +| 403-445 | Quick Reference | # MDL Entity Syntax Reference @@ -266,12 +266,12 @@ as **Derived string columns must be `string(200)`.** A plain pass-through column (`c.Name as CustomerName`) inherits its source attribute's length, so declaring `string(100)` above is fine. But a **derived** string column — `cast(x as -string)`, a string-returning `CASE`, or a string expression — is normalized by -Mendix to the platform default length **`string(200)`**. Declaring any other -length (`string(30)`, unlimited, …) passes `mxcli check`'s parser but fails the -MxBuild consistency check with **CE6770 "View Entity is out of sync with the OQL -Query."** `mxcli check` catches this pre-build as **MDL031** with a suggested -fix: +string)`, a string literal, or a string expression such as a concatenation — is +normalized by Mendix to the platform default length **`string(200)`**. +Declaring any other length (`string(30)`, unlimited, …) passes `mxcli check`'s +parser but fails the MxBuild consistency check with **CE6770 "View Entity is out +of sync with the OQL Query."** `mxcli check` catches this pre-build as +**MDL031** with a suggested fix: ```mdl mdl 1; @@ -281,6 +281,21 @@ create view entity Module.TicketLabel ( select cast(t.Status as string) as StatusLabel from Module.Ticket t; ``` +**A `CASE` column is typed by its branches, by two different rules** (measured, +mx check 11.12.5, mendixlabs/mxcli#1250): + +| CASE | Declare | Why | +|---|---|---| +| `then l.Code … else '0000'` (Code `string(8)`) | `string(8)` | a string CASE takes its **first** branch's type, length included | +| `then l.Code else l.Name` (8, 100) | `string(8)` | the first branch, not the longest | +| `then '0' + l.Code else l.Code` | `string(200)` | the first branch is derived | +| `then 1 else 2.5` / `then l.IntV else l.DecV` | `decimal` | a numeric CASE widens over **all** branches, integer < long < decimal | +| `then l.IntV else l.LngV` | `long` | | + +Without `-p`, `mxcli check` cannot see an attribute's length, so it only judges a +CASE whose deciding branches are literals or expressions; with `-p` it judges the +rest. + ## Entity with Index ```mdl diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index 455c665d4a..ca43bd4717 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -15,18 +15,18 @@ description: "Microflow syntax reference in MDL — every activity type, control | 62-76 | Changing an Existing Microflow | | 77-95 | When to Use a Microflow vs a Nanoflow | | 96-112 | Key Differences from Nanoflows | -| 113-266 | Microflow Structure | -| 267-344 | Variable Declarations | -| 345-402 | Operators | -| 403-419 | Logging | -| 420-445 | Special Values | -| 446-492 | Complete Example | -| 493-549 | Calling Microflows | -| 550-576 | Page Navigation | -| 577-593 | Validation Checklist | -| 594-605 | Tips for Success | -| 606-612 | Related Documentation | -| 613-725 | Quick Reference | +| 113-267 | Microflow Structure | +| 268-345 | Variable Declarations | +| 346-403 | Operators | +| 404-420 | Logging | +| 421-446 | Special Values | +| 447-493 | Complete Example | +| 494-550 | Calling Microflows | +| 551-578 | Page Navigation | +| 579-595 | Validation Checklist | +| 596-607 | Tips for Success | +| 608-614 | Related Documentation | +| 615-727 | Quick Reference | # Mendix Microflow Skill @@ -189,6 +189,7 @@ alternative. Java and JavaScript actions have one entry each and use the shorter `@excluded` before a `create microflow` marks the document **"Exclude from project"** (the same checkbox Studio Pro offers). The document stays in the `.mpr`, does not build, and `list microflows` reports it in the `Excluded` column. +**On a statement** it disables that one activity instead — see [disabled activities](reference/control-flow.md#disabled-activities--excluded-on-a-statement). ```mdl mdl 1; @@ -414,7 +415,7 @@ log warning node 'ValidationService' 'Invalid data detected'; -- With variables (use concatenation) log info node 'OrderService' 'Order processed: ' + $OrderNumber; -log error node 'Service' 'Error: ' + $ErrorMessage; +log error node 'Service' 'Error: ' + $ErrorMessage with stacktrace; -- ticks "Include latest stack trace" ``` ## Special Values @@ -561,6 +562,7 @@ parameter name: `call microflow`, `show page`, and widget actions (`action: show page Module.Page(Param = $value)`) alike. `$Param = $value` and `Param: $value` still parse but are deprecated (MDL-DEPR006/007); `mxcli fmt --upgrade` rewrites them. +Settings follow in a `with` list: `show page M.P with NumberOfPagesToClose = 1;` closes the current page first (an Integer expression; unstated closes none) — as an argument, `(NumberOfPagesToClose: 1)` is CE1613. ### CLOSE PAGE diff --git a/.claude/skills/mendix/write-microflows/reference/control-flow.md b/.claude/skills/mendix/write-microflows/reference/control-flow.md index fa85345570..65db6f86d0 100644 --- a/.claude/skills/mendix/write-microflows/reference/control-flow.md +++ b/.claude/skills/mendix/write-microflows/reference/control-flow.md @@ -342,6 +342,32 @@ commit $Product; $Feedback: MyModule.Feedback ) ``` + +### Disabled activities — `@excluded` on a statement + +`@excluded` **on a statement inside the body** is Studio Pro's **Disable +activity** (right-click → Disable): the activity stays in the flow with its +configuration and is skipped at runtime, greyed out until someone re-enables it. +Use it for an activity mxcli cannot configure fully, so a developer can finish it +in Studio Pro. `describe` prints it back, in microflows and nanoflows alike. +(Before `create microflow` the same annotation is the *document's* "Exclude from +project" — a different property.) + +```mdl +mdl 1; +create microflow MyModule.ACT_Process ($Order: MyModule.Order) +begin + log info node 'Orders' 'processing'; + @excluded + call microflow MyModule.SUB_NotifyLegacy(Order = $Order); + return; +end; +``` + +Only an **activity** can be disabled — the property exists on the action activity +alone. On a decision, a loop, `return` or `raise error` there is nowhere to put +it, so it is dropped and `mxcli check` warns **MDL095**. Put it on the activities +inside the branch or loop body instead (mendixlabs/mxcli#1139). ## Error Handling MDL supports error handling for activities that may fail (microflow calls, commits, external service calls, etc.). @@ -434,7 +460,9 @@ begin -- The call output establishes $response — objects are never declared $response = call microflow Module.CallExternalAPI(data = $RequestData) on error without rollback begin - log error node 'ExternalAPI' 'API call failed for: ' + $RequestData; + -- `with stacktrace` ticks "Include latest stack trace" (after any + -- `with ({1} = …)` template parameters) + log error node 'ExternalAPI' 'API call failed for: ' + $RequestData with stacktrace; -- Not `$response = create …`: a variable name is unique flow-wide, -- handler included (MDL063, CE0111). The fallback gets its own name -- and the handler returns it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 780314426d..b291e68f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **A pluggable widget's text-template parameter across an association builds, and describes so it re-executes** (mendixlabs/mxcli#1494) — `fieldset fs (legend: '{1}', legendParams: ({1} = M.Parent_Child/Label))` passed `check` and `exec` and failed the build with `[CE1365] "Attribute 'M.Child.Label' is not an attribute of entity 'M.Parent'."`: the parameter was written with its attribute but without the association hop, so mxbuild read the attribute against the widget's own entity. The same writer serves an object-list item's template (an Accordion group's `headerText`) and a Data grid 2 column's dynamic text, and all three failed; on Mendix 11.12.4 they now build with 0 errors. `describe page` printed such a parameter as the bare attribute (`{1} = Label`), which re-executed into CE1613 "The selected attribute 'M.Parent.Label' no longer exists"; it now prints `M.Parent_Child/Label`. The companion report, an expression parameter (`{1} = if 1 = 1 then 'A' else 'B'`) stored as an attribute name (mendixlabs/mxcli#1495), was fixed in v0.25.0 (#823) and is now pinned by a test. +- **`alter entity … add event handler` adds a second handler for the same event** (mendixlabs/mxcli#1540) — `add event handler on before commit call M.Second($currentObject)` failed with "event handler already exists for Before Commit" when the entity already had one, though Studio Pro allows several handlers for one event and `create or modify entity` with two `on before commit` clauses stores both (measured on 11.12.4: two Before Commit handlers are 0 errors, a void-returning one is CE7247, so the check does look at them). A handler is now identified by its moment, event and microflow: another microflow adds a second handler, the same microflow again is still a duplicate. The same key had made `add event handler if not exists … call M.New` skip — reporting "already exists" — whenever any handler for the event existed, and `drop event handler on before commit` remove whichever handler was stored first. `drop event handler` now takes `call M.Mf` to say which one; without it, a drop that matches several handlers is refused and names them. + ## [0.28.0] - 2026-10-23 Headline: **Change one thing without restating the rest: `alter navigation`, `alter menu` and `alter consumed rest service` edit a document in place, ALTER PAGE reaches every widget, and `create or modify` of a flow splices note, caption and error-handler changes instead of refusing them.** Alongside it: Protected (add-on) modules and `export level` are understood by `check` and MDL, `mxcli marketplace package` exports a module as a `.mpk`, rewriting a page no longer deletes the widgets MDL cannot author, and the deprecated v1 BSON driver is gone, leaving mxcli on `mongo-driver/v2` alone. @@ -40,6 +45,8 @@ Headline: **Change one thing without restating the rest: `alter navigation`, `al ### Fixed +- **`log … with stacktrace` sets 'Include latest stack trace'** (mendixlabs/mxcli#1543) — a Log message activity's checkbox had no MDL spelling: `log error node 'X' 'failed' with stacktrace;` was a parse error, so every log written through MDL stored it unticked, an error handler could not log the trace, and `describe` dropped a box ticked in Studio Pro on the round trip. `with stacktrace` now follows the message and any template parameters (`log error 'failed: {1}' with ({1} = $latestError/Message) with stacktrace;`), and `describe` prints it only when the box is ticked. It is written in microflows and nanoflows and over `--mcp`; `mx check` reports 0 errors for both (measured on 11.12.4). `stacktrace` stays usable as a name, so `$latestError/Stacktrace` still parses. +- **`@excluded` on a decision, loop or end event is reported instead of silently dropped** (mendixlabs/mxcli#1139) — `@excluded` on a statement is Studio Pro's *Disable activity* (kept since mendixlabs/mxcli#1328, in microflows and nanoflows), but only an activity has that property, so `@excluded if $b then … end if;` passed `check`, was written without it, and the branch it was meant to switch off still ran. `mxcli check` now warns **MDL095** there and names the activities as the place to put it. `mxcli syntax microflow layout` said `@excluded` is a document annotation that does not go on a statement; it now documents both meanings, as does the `write-microflows` skill. - **A find, filter or create by an association inherited from another module keeps that association when written short** (ako/mxcli#1637) — over `HR.Emp extends Other.Person`, `describe` prints `find $l by Other.Person_Dept = $d` as `find $l by Person_Dept = $d`, and the builder looked a bare member up only in `HR`, so executing that description as a new microflow — or writing the name short by hand — stored the attribute `HR.Emp.Person_Dept`: `check -p` and exec passed, then mxbuild failed with CE1613 (11.12.5). Re-running over the stored flow said "Unchanged", so only a rebuild showed it. A bare member name now resolves through the generalization chain to an association of an ancestor in another module, after the inherited attributes. - **`find` / `filter` by `System.owner` or `System.changedBy` builds, and describe prints System associations back so they re-execute** (ako/mxcli#1629) — `$m = find $l by System.owner = $User;` was refused at exec with advice to create a System association, though mxbuild builds it (11.12.5). It now resolves to the System association; on an entity whose generalization root does not store the member, `check -p` and exec refuse it with the CE0091 "No member selected." mxbuild would report, naming the `alter entity` that fixes it. `describe` printed a stored one as `by owner`, and a System association inherited by a specialization of `System.User` as `by User_Language` — which re-executed as an attribute of the specialization, CE1613. A System association in a find or filter is now printed qualified (`by System.User_Language`), and running the description again changes nothing. - **`marketplace diff` and `update` no longer fail with "path is too long" under the default Windows `%TEMP%`** (mendixlabs/mxcli#1486) — `mx` refuses to write any file whose path is 260 characters or longer, on every platform (measured on Linux with mx 11.12.1: a 259-character path is written, a 260-character one refused with the reported *"The specified file name or path is too long"*), and the reference project was built in `%TEMP%\mxdiff\target-ref`. The deepest files mx writes there are bundled `node_modules` trees — 181 characters in the 11.12.1 blank app, about 202 in NanoflowCommons 7.x — so that layout fitted 7.x for no user name at all. References are now built under the shortest directory the user owns (`%USERPROFILE%\.mxcli\t` on Windows, `/tmp` on Linux and macOS, or `TMP`/`TMPDIR` when shorter), named in one letter beneath it; never a drive-root folder such as `C:\mxt`, whose children every local account may modify. A package deep enough to hit the limit anyway gets an error naming the directory, its length, what it leaves for the package, and how to move it. `marketplace install` builds its reference the same way. diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index 3ba08e24f0..c21ba79730 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -174,6 +174,7 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "WARNING", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "TRACE", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "CRITICAL", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, + {Label: "STACKTRACE", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "WITH", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "EMPTY", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "OBJECT", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index 03f4e6dd92..1dcc9795da 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -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 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.", + 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 [IF NOT EXISTS] ON BEFORE COMMIT CALL Module.MF RAISE ERROR;\nALTER ENTITY Module.Name DROP EVENT HANDLER [IF EXISTS] ON BEFORE COMMIT [CALL Module.MF];\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\nAn entity may have several handlers for one event, as in Studio Pro: a handler\nis identified by its moment, event AND microflow, so ADD EVENT HANDLER with\nanother microflow adds a second one, and only the same microflow again is a\nduplicate. DROP EVENT HANDLER takes CALL Module.MF to say which one to remove;\nwithout it, a drop that matches several handlers is refused and names them.\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"}, }) @@ -196,9 +196,11 @@ func init() { " consuming the view sorts and pages as it needs; use `ORDER BY … LIMIT n`\n" + " only for a view that is intrinsically top-N (MDL030; CE0174).\n" + "3. A DERIVED string column is String(200), always — a CAST to string, a\n" + - " string-returning CASE, any string expression. Declare it `String(200)`\n" + - " or mxbuild rejects the view with CE6770. Only a pass-through column\n" + - " inherits its source attribute's length (MDL031).\n" + + " string literal, any string expression. Declare it `String(200)` or\n" + + " mxbuild rejects the view with CE6770. A pass-through column inherits\n" + + " its source attribute's length, and a string CASE its FIRST branch's:\n" + + " `then l.Code else '0000'` is Code's length, `then '0000' else l.Code`\n" + + " String(200). A numeric CASE widens over all branches (MDL031).\n" + "4. A SOURCE may be double-quoted like SQL (`s.\"Month\"`), an ALIAS may not\n" + " (MDL072). So a view attribute can never be called `Month` or `Year` —\n" + " that one is renamed, not quoted. An MDL keyword that is NOT an OQL one\n" + diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index d1a1775406..3f0528dcad 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -592,9 +592,10 @@ func init() { Keywords: []string{ "log", "logging", "info", "warning", "error", "debug", "trace", "critical", "node", "message template", + "stacktrace", "stack trace", "include latest stack trace", }, - Syntax: "LOG [LEVEL] [NODE 'Name'] 'message';\nLOG [LEVEL] 'template {1}' WITH ({1} = $value);\n\n-- Levels: INFO, WARNING, ERROR, DEBUG, TRACE, CRITICAL\n-- Defaults: level INFO, node 'Application'. DESCRIBE leaves both out.\n-- A line break is written into the literal: under `mdl 1;` a template literal\n-- that spans lines is still the template text (without the header: MDL-V1-TEMPLATE).", - Example: "LOG INFO NODE 'OrderService' 'Order created successfully';\nLOG WARNING 'Customer not found';\nLOG ERROR 'Failed to process {1}' WITH (\n {1} = $OrderNumber\n);", + Syntax: "LOG [LEVEL] [NODE 'Name'] 'message';\nLOG [LEVEL] 'template {1}' WITH ({1} = $value);\nLOG [LEVEL] 'message' [WITH ({1} = $value)] WITH STACKTRACE;\n\n-- Levels: INFO, WARNING, ERROR, DEBUG, TRACE, CRITICAL\n-- Defaults: level INFO, node 'Application'. DESCRIBE leaves both out.\n-- WITH STACKTRACE ticks \"Include latest stack trace\"; absent is unticked, the\n-- default, and DESCRIBE prints it only when ticked. It follows the template\n-- parameters, which also open with WITH.\n-- A line break is written into the literal: under `mdl 1;` a template literal\n-- that spans lines is still the template text (without the header: MDL-V1-TEMPLATE).", + Example: "LOG INFO NODE 'OrderService' 'Order created successfully';\nLOG WARNING 'Customer not found';\nLOG ERROR 'Failed to process {1}' WITH (\n {1} = $OrderNumber\n);\nLOG ERROR NODE 'OrderService' 'Failed: {1}' WITH ({1} = $latestError/Message) WITH STACKTRACE;", }) Register(SyntaxFeature{ @@ -639,10 +640,16 @@ func init() { Summary: "Open and close pages from microflows", Keywords: []string{ "show page", "open page", "close page", "display page", - "navigate", "page action", + "navigate", "page action", "number of pages to close", "title override", }, - Syntax: "SHOW PAGE Module.Page;\nSHOW PAGE Module.Page (Param = $value);\nCLOSE PAGE [n];", - Example: "SHOW PAGE MyModule.OrderDetail (Order = $NewOrder);\nCLOSE PAGE;\n\n-- Close this page and the one that opened it (n >= 1, default 1)\nCLOSE PAGE 2;", + Syntax: "SHOW PAGE Module.Page;\nSHOW PAGE Module.Page (Param = $value) [WITH title = 'Title', NumberOfPagesToClose = n];\nCLOSE PAGE [n];", + Example: "SHOW PAGE MyModule.OrderDetail (Order = $NewOrder);\n\n" + + "-- Close the current page, then open the overview (Studio Pro: \"Number of\n" + + "-- pages to close\"; an Integer expression, unstated closes none). A setting,\n" + + "-- not an argument: (NumberOfPagesToClose: 1) is a page parameter (CE1613).\n" + + "SHOW PAGE MyModule.OrderOverview WITH NumberOfPagesToClose = 1;\n" + + "SHOW PAGE MyModule.OrderDetail (Order = $NewOrder) WITH title = 'Edit order';\n\n" + + "CLOSE PAGE;\n\n-- Close this page and the one that opened it (n >= 1, default 1)\nCLOSE PAGE 2;", SeeAlso: []string{"page"}, }) @@ -812,10 +819,11 @@ func init() { Register(SyntaxFeature{ Path: "microflow.layout", - Summary: "Canvas layout annotations — @position, @start, @anchor, @curve, @caption, @color", + Summary: "Canvas layout annotations — @position, @start, @anchor, @curve, @caption, @color, @excluded", Keywords: []string{ "position", "start", "anchor", "curve", "layout", "canvas", "annotation", "caption", "color", "excluded", "bezier", + "disable activity", "disabled activity", }, Syntax: "@position(x, y) -- the activity's centre point\n" + "@position(x, y) -- also on a PARAMETER, in the ( … ) list\n" + @@ -852,12 +860,17 @@ func init() { "DOCUMENT annotations too — a typo, or one on a document kind that does not\n" + "read it (@applyentityaccess on a nanoflow, @excluded on a queue), is\n" + "refused with the list of what that document does accept.\n\n" + - "@excluded and @applyentityaccess are DOCUMENT annotations — they go before\n" + - "CREATE, not on a statement. @applyentityaccess runs the flow under the\n" + - "current user's entity access rules instead of with full access; it is a\n" + - "SECURITY setting and only ever narrows, so an ABSENT annotation PRESERVES\n" + - "whatever is stored and @applyentityaccess(false) is how a script turns it\n" + - "off. A nanoflow has no such property (it runs in the client).\n\n" + + "@excluded on a STATEMENT disables that activity (Studio Pro's Disable\n" + + "activity): it stays in the flow, configured, and is skipped at runtime.\n" + + "Only an activity can be disabled — on a decision, loop, return or raise\n" + + "error it is dropped, and check warns (MDL095).\n\n" + + "@excluded before CREATE is the DOCUMENT annotation (Exclude from project).\n" + + "@applyentityaccess is document-only — it goes before CREATE, never on a\n" + + "statement — and runs the flow under the current user's entity access rules\n" + + "instead of with full access; it is a SECURITY setting and only ever\n" + + "narrows, so an ABSENT annotation PRESERVES whatever is stored and\n" + + "@applyentityaccess(false) is how a script turns it off. A nanoflow has no\n" + + "such property (it runs in the client).\n\n" + "Mendix stores no waypoints — a flow's shape is two control vectors, each a\n" + "pixel offset from its end of the line. (0, 0) at both ends is straight.\n" + "@position on a split belongs to the SPLIT, so its end-if join has its own\n" + diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 7b1d11757d..efa8c65014 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -413,8 +413,8 @@ LIST IMPACT OF htmlelement; "column width", "alignment", "wrap text", "visible", "dynamic cell class", "tooltip", "associated attribute", "association column", }, - Syntax: "COLUMN ( -- no name: Mendix stores none for a data grid column (MDL-DEPR005)\n Attribute: AttrName, -- own attribute\n -- or an attribute over an association (bare association name):\n -- Attribute: Assoc/Attr e.g. Order_Customer/Name\n Caption: 'Header'\n [, Sortable: true|false]\n [, Resizable: true|false]\n [, Draggable: true|false]\n [, Hidable: yes|hidden|no]\n [, ColumnWidth: autoFill|autoFit|manual]\n [, Size: integer]\n [, Alignment: left|center|right]\n [, WrapText: true|false]\n [, Visible: ] -- once for the grid: a page variable or parameter, never $currentObject (MDL-WIDGET43)\n [, DynamicCellClass: ] -- per row: $currentObject is the row\n [, Tooltip: 'text']\n)", - Example: "COLUMN (\n Attribute: Price, Caption: 'Price',\n Alignment: right, Sortable: false,\n ColumnWidth: manual, Size: 150,\n Tooltip: 'Price in USD'\n)\n\n-- Associated attribute (attribute over a reference association):\nCOLUMN (Attribute: Order_Customer/Name, Caption: 'Customer')", + Syntax: "COLUMN ( -- no name: Mendix stores none for a data grid column (MDL-DEPR005)\n Attribute: AttrName, -- own attribute\n -- or an attribute over an association (bare association name):\n -- Attribute: Assoc/Attr e.g. Order_Customer/Name\n Caption: 'Header'\n [, Sortable: true|false]\n [, Resizable: true|false]\n [, Draggable: true|false]\n [, Hidable: yes|hidden|no]\n [, ColumnWidth: autoFill|autoFit|manual]\n [, Size: integer] -- manual only: a relative WEIGHT, not px — rendered fr\n -- against 1fr per autoFill column (Size: 220 collapses the rest)\n [, minWidth: auto|minContent|manual] -- autoFit only\n [, minWidthLimit: integer] -- autoFit + minWidth: manual: the minimum width in px\n [, Alignment: left|center|right]\n [, WrapText: true|false]\n [, Visible: ] -- once for the grid: a page variable or parameter, never $currentObject (MDL-WIDGET43)\n [, DynamicCellClass: ] -- per row: $currentObject is the row\n [, Tooltip: 'text']\n)", + Example: "COLUMN (\n Attribute: Price, Caption: 'Price',\n Alignment: right, Sortable: false,\n ColumnWidth: manual, Size: 2, -- twice the width of an autoFill column\n Tooltip: 'Price in USD'\n)\n\n-- A width in pixels: autoFit with a manual minimum\nCOLUMN (Attribute: Code, Caption: 'Code', ColumnWidth: autoFit, minWidth: manual, minWidthLimit: 150)\n\n-- Associated attribute (attribute over a reference association):\nCOLUMN (Attribute: Order_Customer/Name, Caption: 'Customer')", SeeAlso: []string{"page.widgets"}, }) diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index f13ca22cfe..84a271a91c 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -164,6 +164,7 @@ CREATE PUBLISHED ODATA SERVICE MyModule.CustomerAPI ( | Close page | `CLOSE PAGE [n];` | `n` pages, default 1 | | Validation | `VALIDATION FEEDBACK $Entity/Attribute MESSAGE 'message';` | Requires attribute path + MESSAGE | | Log | `LOG INFO\|WARNING\|ERROR [NODE 'name'] 'message';` | | +| Log with stack trace | `LOG ERROR 'message' [WITH ({1} = $v)] WITH STACKTRACE;` | "Include latest stack trace" | | Position | `@position(x, y)` | Canvas position (before activity) | | Start event | `@start(x, y)` | Canvas position of the start, on the **first** statement. Omit it and the start is placed one spacing unit left of the first activity and MOVES with it on a rewrite; a start that is not at that derived spot is treated as hand-placed, survives a rewrite, and is emitted by DESCRIBE (#951) | | Caption | `@caption 'text'` | Custom caption (before activity) | @@ -388,7 +389,9 @@ MDL uses explicit property declarations for pages: | `Draggable` | `true`, `false` | `true` | `Draggable: false` | | `Hidable` | `yes`, `hidden`, `no` | `yes` | `Hidable: no` | | `ColumnWidth` | `autoFill`, `autoFit`, `manual` | `autoFill` | `ColumnWidth: manual` | -| `Size` | integer (px) | `1` | `Size: 200` | +| `Size` | integer — a relative weight, not px | `1` | `Size: 2` — under `ColumnWidth: manual` only, rendered `2fr` against `1fr` per autoFill column | +| `minWidth` | `auto`, `minContent`, `manual` | `auto` | `minWidth: manual` — under `ColumnWidth: autoFit` only | +| `minWidthLimit` | integer (px) | `100` | `minWidthLimit: 220` — the minimum in pixels, under `ColumnWidth: autoFit, minWidth: manual` | | `Visible` | expression | `true` | `Visible: $showColumn` — evaluated once for the grid: a page variable or parameter, never `$currentObject` (MDL-WIDGET43, CE0117) | | `DynamicCellClass` | expression | (empty) | `DynamicCellClass: if $currentObject/Stock < 10 then 'text-danger' else ''` | | `Tooltip` | text string | (empty) | `Tooltip: 'Price in USD'` | @@ -418,6 +421,7 @@ CREATE PAGE MyModule.Customer_Edit **Supported Widgets:** - Layout: `LAYOUTGRID`, `ROW`, `COLUMN`, `CONTAINER`, `CUSTOMCONTAINER` - Input: `TEXTBOX`, `TEXTAREA`, `CHECKBOX`, `RADIOBUTTONS`, `DATEPICKER`, `COMBOBOX` + - `COMBOBOX` modes: enumeration `(Attribute: EnumAttr)`; association `(Attribute: Assoc, DataSource: DATABASE Mod.Target, CaptionAttribute: Name)`; database `(optionsSourceDatabaseDataSource: , Attribute: TargetAttr, CaptionAttribute: Name, optionsSourceDatabaseValueAttribute: ValueAttr)` — the datasource's key selects database mode, the generic `DataSource:` association mode - Display: `DYNAMICTEXT`, `DATAGRID`, `GALLERY`, `LISTVIEW`, `IMAGE`, `STATICIMAGE`, `DYNAMICIMAGE` - Actions: `ACTIONBUTTON`, `LINKBUTTON`, `NAVIGATIONLIST` - Structure: `DATAVIEW`, `HEADER`, `FOOTER`, `CONTROLBAR`, `SNIPPETCALL` diff --git a/docs-site/src/language/activity-types.md b/docs-site/src/language/activity-types.md index cb46aa74c9..dd904eec9a 100644 --- a/docs-site/src/language/activity-types.md +++ b/docs-site/src/language/activity-types.md @@ -186,6 +186,19 @@ SHOW PAGE Sales.OrderDetail ( The older spellings `($Order = $Order)` and `(Order: $Order)` still parse but are deprecated (MDL-DEPR006, MDL-DEPR007); `mxcli fmt --upgrade` rewrites them. +The action's own settings go in a `with` list after the arguments: + +```sql +SHOW PAGE Sales.Order_Edit (Order = $Order) WITH title = 'Edit order'; +SHOW PAGE Sales.Order_Overview WITH NumberOfPagesToClose = 1; -- close the current page first +``` + +`title` overrides the page title. `NumberOfPagesToClose` is Studio Pro's "Number of +pages to close": an Integer expression for how many pages to close before the page +opens. Leaving it out closes none. It is a setting, not an argument — +`(NumberOfPagesToClose: 1)` passes a page parameter of that name, which +`check --references` refuses (Mendix build: CE1613). + ### CLOSE PAGE Closes the current page, or the given number of pages: @@ -232,6 +245,17 @@ LOG INFO NODE 'OrderProcessing' 'Order created: ' + $Order/OrderNumber; LOG ERROR NODE 'PaymentGateway' 'Payment failed for order ' + $Order/OrderNumber; ``` +`WITH STACKTRACE` ticks **Include latest stack trace**, which is what an error +handler usually wants. It comes after the template parameters, which also open +with `WITH`: + +```sql +LOG ERROR NODE 'PaymentGateway' 'Payment failed: {1}' WITH ({1} = $latestError/Message) WITH STACKTRACE; +``` + +Without it the box is unticked, which is Mendix's default, so `DESCRIBE` prints +`WITH STACKTRACE` only for a log that has it. + ## Email ### SEND EMAIL @@ -321,7 +345,7 @@ The query name follows a three-part naming convention: `Module.ConnectionName.Qu | Call microflow | `$Var = CALL MICROFLOW Module.Name (Param = $val);` | Any type | | Call nanoflow | `$Var = CALL NANOFLOW Module.Name (Param = $val);` | Any type | | Call Java action | `$Var = CALL JAVA ACTION Module.Name (Param = val);` | Any type | -| Show page | `SHOW PAGE Module.Page (Param = $val);` | -- | +| Show page | `SHOW PAGE Module.Page (Param = $val) [WITH title = '…', NumberOfPagesToClose = n];` | -- | | Close page | `CLOSE PAGE [n];` | -- | | Validation | `VALIDATION FEEDBACK $Var/Attr MESSAGE 'msg';` | -- | | Log | `LOG INFO\|WARNING\|ERROR [NODE 'name'] 'msg';` | -- | diff --git a/docs-site/src/language/alter-entity.md b/docs-site/src/language/alter-entity.md index 32437abf4d..90c91dd3b0 100644 --- a/docs-site/src/language/alter-entity.md +++ b/docs-site/src/language/alter-entity.md @@ -176,11 +176,22 @@ ALTER ENTITY Sales.Order ALTER ENTITY Sales.Order ADD EVENT HANDLER ON AFTER CREATE CALL Sales.NotifyNewOrder(); --- Remove an event handler +-- A second handler for the same event runs as well ALTER ENTITY Sales.Order - DROP EVENT HANDLER ON BEFORE COMMIT; + ADD EVENT HANDLER ON BEFORE COMMIT CALL Sales.CheckCredit($currentObject) RAISE ERROR; + +-- Remove an event handler: CALL names which one +ALTER ENTITY Sales.Order + DROP EVENT HANDLER ON BEFORE COMMIT CALL Sales.CheckCredit; ``` +An entity may have several handlers for one event. A handler is identified by +its moment, event **and** microflow: `ADD EVENT HANDLER` with another microflow +adds a second one, and only the same microflow again is a duplicate (an error, +or skipped with `IF NOT EXISTS`). `DROP EVENT HANDLER ON BEFORE COMMIT` without +`CALL` works while the entity has one Before Commit handler; when it has several, +it is refused and the error names them. + | Moment | Returns | RAISE ERROR | Use case | |--------|---------|-------------|----------| | `BEFORE` | Boolean | Yes — aborts on `false` | Validation, permission checks | diff --git a/docs-site/src/language/entities.md b/docs-site/src/language/entities.md index 52f53d4b25..3e3fab7b73 100644 --- a/docs-site/src/language/entities.md +++ b/docs-site/src/language/entities.md @@ -215,9 +215,9 @@ mdl 1; ALTER ENTITY Sales.Order ADD EVENT HANDLER ON BEFORE DELETE CALL Sales.ACT_CheckCanDelete RAISE ERROR; --- Remove a handler +-- Remove a handler (CALL names which, when the event has several) ALTER ENTITY Sales.Order - DROP EVENT HANDLER ON BEFORE COMMIT; + DROP EVENT HANDLER ON BEFORE COMMIT CALL Sales.ACT_ValidateOrder; ``` **Moments**: `BEFORE`, `AFTER` diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index 8f415e4b72..338eaa6385 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -126,6 +126,7 @@ DATAGRID dgOrders (DataSource: DATABASE Sales.Order, PageSize: 20) { | `Pagination` | Pagination mode | `Pagination: virtualScrolling` | | `PagingPosition` | Position of paging controls | `PagingPosition: both` | | `ShowPagingButtons` | When to show paging buttons | `ShowPagingButtons: auto` | +| `Selection` | Row selection: `None`, `Single` or `Multi` (also on a GALLERY). The widget key `itemSelection:` is the same property; two different values for it are refused (MDL-WIDGET50) | `Selection: Single` | **Any other Data grid 2 setting** can be set by its widget key — the name the widget's own definition gives it (`DESCRIBE WIDGET TYPE datagrid` lists them with diff --git a/docs-site/src/reference/page/create-page.md b/docs-site/src/reference/page/create-page.md index cc13ba8ab3..cd6dce27b2 100644 --- a/docs-site/src/reference/page/create-page.md +++ b/docs-site/src/reference/page/create-page.md @@ -72,7 +72,7 @@ The widget tree inside `{ ... }` defines the page content. Widgets are nested hi | `CHECKBOX` | Boolean toggle | `Label`, `Attribute` | | `RADIOBUTTONS` | Radio button group | `Label`, `Attribute` | | `DATEPICKER` | Date/time selector | `Label`, `Attribute` | -| `COMBOBOX` | Dropdown selector | `Label`, `Attribute` | +| `COMBOBOX` | Dropdown selector | `Label`, `Attribute`; association mode adds `DataSource`, `CaptionAttribute`; database mode `optionsSourceDatabaseDataSource`, `CaptionAttribute`, `optionsSourceDatabaseValueAttribute` | | `FILEMANAGER` | File upload/download, inside a data view on a `System.FileDocument` specialization | `Label`, `Type` (`Upload`, `Download`, `Both` default), `MaxFileSize` (MB, default 5), `AllowedExtensions`, `ShowFileInBrowser` | **Display Widgets** diff --git a/docs-wiki/bug-patterns/check-mxbuild-drift.md b/docs-wiki/bug-patterns/check-mxbuild-drift.md index 8c291f3ae3..a2ae1bbda0 100644 --- a/docs-wiki/bug-patterns/check-mxbuild-drift.md +++ b/docs-wiki/bug-patterns/check-mxbuild-drift.md @@ -195,7 +195,11 @@ The **controls are what decide the predicate**, not the failures: a comparison inside `CASE` builds, grouping by `r.ID` does not exempt `r.Name`, `Long` over `AutoNumber` is the one numeric declaration that is not an error. A rule written from the failing cases alone over-fires, and over-firing is the expensive -direction (above). +direction (above). The matrix also has to vary the *shape* the rule generalises +over, not only the values: "a string `CASE` is String(200)" had been measured on +CASEs whose first branch was a literal, and putting an attribute first showed the +first branch decides — while numeric CASEs widen over every branch instead, so no +single rule covered both (mendixlabs/mxcli#1250). **`mx check` is phased, and two switches silently turn its checks off.** While any CE1613 stands, the later phases do not run — so re-measuring on a project diff --git a/docs-wiki/bug-patterns/misleading-diagnostics.md b/docs-wiki/bug-patterns/misleading-diagnostics.md index 39b09ccb68..d5d1261b12 100644 --- a/docs-wiki/bug-patterns/misleading-diagnostics.md +++ b/docs-wiki/bug-patterns/misleading-diagnostics.md @@ -81,6 +81,17 @@ tokens can report a missing terminator that is plainly present in the source — a second, phantom mistake stacked on the real one. See [[the-visitor-walks-a-failed-parse]]. +**A hint cannot repair a cascade that recovery built.** A microflow header +without its parameter parentheses got the right hint on ANTLR's first error, and +two more errors underneath it — one three lines into the body, reported after +recovery had already closed the statement. Once the parser has left the +statement, nothing in the error listener can tie the later error back to the +mistake, because the recovered parse no longer knows where the statement ends. +The layer that can is the grammar: accept the wrong form only so the visitor +refuses it, and the cascade never starts — one error, at the mistake, quoting +the author's own header back corrected. Dump the rule-context stack at each +error first; a stack of just the program rule is the sign. + **The reachable-form question.** Several of these are not really about wording: the reader's next question is "then how *do* I write it?", and a hint that names the working spelling answers it. Where there is no working spelling, saying so is diff --git a/docs-wiki/bug-patterns/silent-property-drop.md b/docs-wiki/bug-patterns/silent-property-drop.md index 7a8e824a54..705bb3bf40 100644 --- a/docs-wiki/bug-patterns/silent-property-drop.md +++ b/docs-wiki/bug-patterns/silent-property-drop.md @@ -216,6 +216,26 @@ is both plausible and inert. That table is also the fix: because the engine already maps keyword to property per widget, the diagnostic can name the spelling *this* widget uses instead of listing everything it declares. +**One property, two names: every reader has to treat them as one.** A +pluggable property is reachable by its mapping's keyword (`Selection:`) and by +its own key (`itemSelection:`). The pass that skips "already handled" names +compared the author's name with the keywords only, so the key fell through to +the scalar writer and was stored where no reader looks; a rule judging the value +read only the keyword, and refused a page the builder writes correctly. Grep +every consumer for *both* names. Where the two spellings can disagree, refuse +the pair rather than choose — choosing is a silent drop of the other. And a +hand-written definition outlives the widget version it was written for: its +constant for a key the installed package no longer declares is harmless until +the script names that key, and then it is a drop like any other. + +**A setting written as an argument is a dangling reference, not a dropped +property.** `show page M.P (NumberOfPagesToClose: 1)` parsed as the deprecated +`Param: value` argument, so the setting became a mapping to a page parameter +that does not exist — a build error the reference check never saw, because +show page arguments, unlike flow-call arguments, were never compared with the +callee's parameters. Before adding the missing property, ask what the author's +spelling meant instead, and close that path too. + ## See also - [fix-issue findings](../../.claude/skills/fix-issue/findings/) — the individual diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 0f33572935..f074499364 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -125,8 +125,8 @@ Modifies an existing entity without full replacement. | Create if absent | `create entity if not exists Module.Name (...);`
`create association if not exists Module.Assoc from ... to ...;` | Every `create` that names one element takes the same guard, after the kind's keywords (`create page if not exists M.P …`, `create module if not exists M;`) — see `mxcli syntax create-if-not-exists`. Skips when it already exists, leaving the stored definition untouched. Unlike `create or modify`, which rebuilds the element from the statement and drops any attribute the statement omits — `mxcli check … -p app.mpr --references` warns about that as **MDL087**, naming the members the script removes without restating them | | Add index (SQL form) | `create index IdxName on Module.Name (Col1 [asc\|desc], ...);` | Same effect as `alter entity … add index`. The index name is accepted and not stored — a Mendix index is identified by its columns — so `check` warns (MDL-IDX01); prefer `alter entity … add index (…)` | | Drop index | `alter entity Module.Name drop index [if exists] (Col1 [asc\|desc], ...);` | Selected by its columns — a Mendix index stores no name, so the columns are its identity, and they are what `describe entity` prints. The legacy positional form `drop index idx1` still works but shifts when an earlier index is dropped | -| Add event handler | `alter entity Module.Name add event handler on before commit call Mod.MF($currentObject) [raise error];` | `($currentObject)` or `()`, RAISE ERROR only on BEFORE | -| Drop event handler | `alter entity Module.Name drop event handler on before commit;` | | +| Add event handler | `alter entity Module.Name add event handler [if not exists] on before commit call Mod.MF($currentObject) [raise error];` | `($currentObject)` or `()`, RAISE ERROR only on BEFORE. An event may have several handlers: a different microflow adds a second one; only the same microflow again is a duplicate | +| Drop event handler | `alter entity Module.Name drop event handler [if exists] on before commit [call Mod.MF];` | `call` names which handler; without it the drop is refused when the event has several | | Set documentation | `alter entity Module.Name set documentation 'text';` | | | Set position | `alter entity Module.Name set position (100, 200);` | Canvas position | | Add system attribute | `alter entity Module.Name add attribute owner: autoowner;` | Same syntax as regular attributes | @@ -600,6 +600,7 @@ it is for pages. | Synchronize (nanoflow only) | `synchronize all;` / `synchronize unsynchronized;` / `synchronize $Obj, $List;` | Offline sync. `unsynchronized` needs Mendix 9.4+. In a microflow this is MDL057 / CE0009 | | Validation | `validation feedback $entity/attribute message 'message {1}' [with ({1} = $a)];` | Requires attribute path + MESSAGE | | Log | `log [info\|warning\|error] [node 'name'] 'message';` | Level `info` and node `'Application'` are the defaults; `describe` leaves them out | +| Log with stack trace | `log error node 'name' 'failed: {1}' with ({1} = $latestError/Message) with stacktrace;` | Studio Pro's "Include latest stack trace". After the template parameters; absent = unticked, the default (mendixlabs/mxcli#1543) | | Apply entity access | `@applyentityaccess` / `@applyentityaccess(false)` before `create microflow` or `create rule` | Runs the flow under the **current user's** entity access rules instead of with full access. A **security** setting and only ever narrowing, so an ABSENT annotation **preserves** what is stored rather than clearing it — the same rule as `@excluded`. Not available on a nanoflow: it runs in the client and Mendix stores no such property | | Position | `@position(x, y)` | Canvas position (before activity) | | Deep-link URL | `url 'item/{Key}'` / `url search parameters ($Filter)` / `drop url` | Header clauses on `create microflow`, Mendix **10.6+**. Every `{Name}` must name a parameter (**MDL-MF01**), and a parameter used in the PATH may **not** also be a search parameter (**MDL-MF02** / CE5612) — the two sets are disjoint. With a project, a URL another microflow already owns is refused (CE0570). An OMITTED clause **preserves** what is stored | @@ -1591,7 +1592,9 @@ MDL uses explicit property declarations for pages: | `Draggable` | `true`, `false` | `true` | `Draggable: false` | | `Hidable` | `yes`, `hidden`, `no` | `yes` | `Hidable: no` | | `ColumnWidth` | `autofill`, `autoFit`, `manual` | `autofill` | `ColumnWidth: manual` | -| `Size` | integer (px) | `1` | `Size: 200` | +| `Size` | integer — a relative weight, not px | `1` | `Size: 2` — under `ColumnWidth: manual` only, rendered `2fr` against `1fr` per autoFill column | +| `minWidth` | `auto`, `minContent`, `manual` | `auto` | `minWidth: manual` — under `ColumnWidth: autoFit` only | +| `minWidthLimit` | integer (px) | `100` | `minWidthLimit: 220` — the minimum in pixels, under `ColumnWidth: autoFit, minWidth: manual` | | `visible` | expression string | `true` | `visible: '$showColumn'` (page variable, not $currentObject) | | `DynamicCellClass` | expression string | (empty) | `DynamicCellClass: if(...) then ... else ...` | | `tooltip` | text string | (empty) | `tooltip: 'Price in USD'` | @@ -1634,6 +1637,7 @@ create page MyModule.Customer_Edit **Supported Widgets:** - Layout: `layoutgrid`, `row`, `column`, `container`, `customcontainer` - Input: `textbox`, `textarea`, `checkbox`, `radiobuttons`, `datepicker`, `combobox` + - `combobox` modes: enumeration `(attribute: EnumAttr)`; association `(attribute: Assoc, datasource: database Mod.Target, CaptionAttribute: Name)`; database `(optionsSourceDatabaseDataSource: , attribute: TargetAttr, CaptionAttribute: Name, optionsSourceDatabaseValueAttribute: ValueAttr)` — the datasource's key selects database mode, the generic `datasource:` association mode - Display: `dynamictext`, `datagrid`, `gallery`, `listview`, `image`, `staticimage`, `dynamicimage` ### List View specialization templates diff --git a/mdl-examples/bug-tests/1139-excluded-activity-vs-decision.mdl b/mdl-examples/bug-tests/1139-excluded-activity-vs-decision.mdl new file mode 100644 index 0000000000..47486e44ea --- /dev/null +++ b/mdl-examples/bug-tests/1139-excluded-activity-vs-decision.mdl @@ -0,0 +1,56 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1139 — "MDL syntax to disable/enable individual activities +-- in microflows and nanoflows" +-- ============================================================================ +-- +-- Studio Pro's right-click "Disable activity" is `@excluded` on the statement. +-- The flow builder dropped it until #1328 (see 1328-excluded-activity.mdl); a +-- nanoflow takes it the same way, which is the case the report was about. +-- +-- Only an activity can be disabled: the Model SDK declares `disabled` on +-- ActionActivity and nowhere else. So `@excluded` on a decision, a loop or an +-- end event parsed, passed check, and was dropped on write — the logic the +-- author meant to switch off still ran. `mxcli check` now warns MDL095. +-- +-- EXPECTED: `mxcli check` reports exactly one MDL095, on the `if` in +-- ACT_excluded_decision; nothing for the activities. +-- After exec, DESCRIBE prints `@excluded` above 'skipped' in the microflow and +-- above the call in the nanoflow, and above nothing in ACT_excluded_decision. +-- In Studio Pro: those two activities are greyed out (disabled). +-- Guard: mdl/executor/issue1139_excluded_non_activity_test.go. +-- ============================================================================ + +create module BugExcluded1139; + +create or modify microflow BugExcluded1139.ACT_excluded_activity () +begin + log info node 'BugExcluded1139' 'first'; + @caption 'cap' + @excluded + log info node 'BugExcluded1139' 'skipped'; + return; +end; + +create or modify nanoflow BugExcluded1139.NAV_target () +begin + return; +end; + +create or modify nanoflow BugExcluded1139.NAV_excluded_activity () +begin + log info node 'BugExcluded1139' 'before'; + @excluded + call nanoflow BugExcluded1139.NAV_target(); + return; +end; + +-- MDL095: a decision has no Disabled property, so this @excluded is dropped. +create or modify microflow BugExcluded1139.ACT_excluded_decision ($Flag: Boolean) +begin + @excluded + if $Flag then + log info node 'BugExcluded1139' 'branch'; + end if; + return; +end; diff --git a/mdl-examples/bug-tests/1294-case-column-takes-first-branch.mdl b/mdl-examples/bug-tests/1294-case-column-takes-first-branch.mdl new file mode 100644 index 0000000000..3d3559be61 --- /dev/null +++ b/mdl-examples/bug-tests/1294-case-column-takes-first-branch.mdl @@ -0,0 +1,67 @@ +mdl 1; +-- ako/mxcli#1294 (mendixlabs/mxcli#1250) — MDL031 demanded String(200) for a +-- CASE column that Mendix derives as String(8), and following its "Fix:" +-- produced CE6770 "View Entity is out of sync with the OQL Query". The only +-- way to write the declaration that builds was `exec --no-check`. +-- +-- Mendix types a CASE column by two rules, measured with mx check 11.12.5 on +-- one-column views declared at every candidate type: +-- +-- a String CASE takes its FIRST branch's type, length included +-- (`then l.Code else l.Name`, 8 and 100, is String(8)) +-- a numeric CASE widens across ALL branches, Integer < Long < Decimal +-- +-- mxcli took the first branch it could TYPE. Without the project `l.Code` is +-- untyped, so the second branch's concatenation decided: String(200). +-- +-- Every declaration below is the one mxbuild accepts. `make check-mdl` runs +-- `mxcli check` with no project, which is the reported shape (the source entity +-- is created by the same script) and was enough to raise the false MDL031. +-- `mxcli exec` + `mxcli docker check` on a fresh project: 0 errors. + +create module Bt1294; + +create persistent entity Bt1294.Lane ( + Code: String(8), + Name: String(100), + Points: Integer, + Bonus: Long, + Factor: Decimal +); + +-- The reported view: String(8), Code's length, not String(200). +create view entity Bt1294.LaneSorted ( + SortKey: String(8) +) as ( + select case when length(l.Code) >= 4 then l.Code + when length(l.Code) = 3 then '0' + l.Code + else '0000' end as SortKey + from Bt1294.Lane as l +); + +-- The first branch decides, not the longest one. +create view entity Bt1294.LaneLabel ( + Label: String(100) +) as ( + select case when length(l.Code) >= 4 then l.Name else l.Code end as Label + from Bt1294.Lane as l +); + +-- A derived first branch is String(200), whatever follows it. +create view entity Bt1294.LanePadded ( + Padded: String(200) +) as ( + select case when length(l.Code) >= 4 then '0' + l.Code else l.Code end as Padded + from Bt1294.Lane as l +); + +-- Numbers widen across the branches: an Integer literal and a Decimal column +-- are Decimal, an Integer and a Long column are Long. +create view entity Bt1294.LaneScore ( + Score: Decimal, + Total: Long +) as ( + select case when l.Points > 4 then 1 else l.Factor end as Score, + case when l.Points > 4 then l.Points else l.Bonus end as Total + from Bt1294.Lane as l +); diff --git a/mdl-examples/bug-tests/1294-case-numeric-widens.fail.mdl b/mdl-examples/bug-tests/1294-case-numeric-widens.fail.mdl new file mode 100644 index 0000000000..a90d99184b --- /dev/null +++ b/mdl-examples/bug-tests/1294-case-numeric-widens.fail.mdl @@ -0,0 +1,18 @@ +mdl 1; +-- ako/mxcli#1294 — the negative half: a numeric CASE widens across its +-- branches, so `then 1 else 2.5` is Decimal. mxcli used to type it from the +-- first branch alone, accepting Integer (CE6770 in mx check 11.12.5) and +-- refusing Decimal, the one declaration that builds, with "Fix: change to +-- 'Score: Integer'". `mxcli check` must refuse this file as MDL031 and +-- suggest Decimal. + +create module Bt1294f; + +create persistent entity Bt1294f.Lane (Points: Integer); + +create view entity Bt1294f.LaneScore ( + Score: Integer +) as ( + select case when l.Points > 4 then 1 else 2.5 end as Score + from Bt1294f.Lane as l +); diff --git a/mdl-examples/bug-tests/1396-routine-header-without-parens.fail.mdl b/mdl-examples/bug-tests/1396-routine-header-without-parens.fail.mdl new file mode 100644 index 0000000000..9f17466d29 --- /dev/null +++ b/mdl-examples/bug-tests/1396-routine-header-without-parens.fail.mdl @@ -0,0 +1,31 @@ +mdl 1; +-- ako/mxcli#1396 (mendixlabs/mxcli#1514) — a microflow header without its +-- parameter parentheses. The parentheses are mandatory, even when the list is +-- empty; the error is that this script is refused. +-- +-- What changed is HOW it is refused. ANTLR's recovery inserted the `(`, read +-- `returns` as a parameter name, swallowed the body into its type and closed +-- the statement early, so `mxcli check` reported +-- +-- line 1:23 missing '(' at 'returns' +-- line 1:31 missing ':' at 'Boolean' +-- line 4:2 extraneous input 'return' expecting the start of a statement +-- +-- none of them at the mistake. The grammar now accepts the bare header only so +-- the visitor can refuse it with one error, at the place the `(` belongs: +-- +-- line 1:23 a microflow header needs its parameter list in parentheses, even +-- when it is empty — they are not optional: write +-- `create microflow Bt1396.F () returns Boolean as $b`. +-- +-- The assertion that survives CI is mdl/visitor/routine_parens_hint_test.go, +-- which requires exactly one error per shape and that the next statement still +-- parses. + +create module Bt1396; + +create microflow Bt1396.F returns Boolean as $b +begin + declare $b Boolean = true; + return $b; +end; diff --git a/mdl-examples/bug-tests/1494-pluggable-text-template-param-across-association.mdl b/mdl-examples/bug-tests/1494-pluggable-text-template-param-across-association.mdl new file mode 100644 index 0000000000..ab8cf19133 --- /dev/null +++ b/mdl-examples/bug-tests/1494-pluggable-text-template-param-across-association.mdl @@ -0,0 +1,66 @@ +mdl 1; +-- mendixlabs/mxcli#1494 (ako/mxcli#1348): a pluggable widget's text-template +-- parameter bound across an association resolved to the right attribute and +-- the right steps, and the serializer dropped the steps — it wrote +-- AttributeRef.EntityRef null, so mxbuild read the attribute against the +-- widget's context entity: +-- +-- [CE1365] "Attribute 'Bug1494.Child.Label' is not an attribute of entity 'Bug1494.Parent'." +-- +-- One serializer writes three things, and all three failed: a widget-level +-- template (Fieldset `legend`), an object-list item's template (Accordion +-- group `headerText`) and a Data grid 2 column's dynamic text. `describe` +-- then printed the Fieldset's and the column's parameter as the bare `Label`, +-- which re-executes into CE1613 "The selected attribute 'Bug1494.Parent.Label' +-- no longer exists." +-- +-- mendixlabs/mxcli#1495 (ako/mxcli#1349), fixed in v0.25.0 (#823): an +-- expression parameter is stored as the parameter's Expression, not in +-- AttributeRef.Attribute with its spaces stripped. +-- +-- Expected: `mxcli docker check` reports 0 errors, and the describe below +-- prints `{1} = Bug1494.Parent_Child/Label` for fsAssoc, the group and the +-- column, and the expression as written for fsExpr. + +create module Bug1494; + +create persistent entity Bug1494.Parent (Name: string(50)); +create persistent entity Bug1494.Child (Label: string(50)); +create association Bug1494.Parent_Child from Bug1494.Parent to Bug1494.Child; + +create page Bug1494.Parent_View ( + title: 'Parent', + layout: Atlas_Core.Atlas_Default, + params: ($Parent: Bug1494.Parent) +) { + dataview dvParent (DataSource: $Parent) { + -- control: an attribute of the context entity, no steps + fieldset fsOwn (legend: '{1}', legendParams: ({1} = Name)) { + dynamictext txtOwn (content: 'own') + } + fieldset fsAssoc (legend: '{1}', legendParams: ({1} = Bug1494.Parent_Child/Label)) { + dynamictext txtAssoc (content: 'across the association') + } + fieldset fsExpr (legend: '{1}', legendParams: ({1} = if 1 = 1 then 'A' else 'B')) { + dynamictext txtExpr (content: 'an expression') + } + accordion acc1 { + group (headerRenderMode: 'text', headerText: '{1}', headerTextParams: ({1} = Bug1494.Parent_Child/Label)) { + dynamictext txtGroup (content: 'group body') + } + } + } +}; + +create page Bug1494.Parent_Overview ( + title: 'Parents', + layout: Atlas_Core.Atlas_Default +) { + datagrid dgParents (DataSource: database Bug1494.Parent) { + column (Attribute: Name) + column (ShowContentAs: dynamicText, Caption: 'Child', Content: '{1}', ContentParams: ({1} = Bug1494.Parent_Child/Label)) + } +}; + +describe page Bug1494.Parent_View; +describe page Bug1494.Parent_Overview; diff --git a/mdl-examples/bug-tests/1519-alter-column-filter-attribute.mdl b/mdl-examples/bug-tests/1519-alter-column-filter-attribute.mdl new file mode 100644 index 0000000000..b9907148ef --- /dev/null +++ b/mdl-examples/bug-tests/1519-alter-column-filter-attribute.mdl @@ -0,0 +1,35 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1519 (item 2, the ALTER path) — a column's filter properties +-- ============================================================================ +-- +-- `1519-dropdownfilter-attribute.mdl` covers CREATE. A column built by ALTER +-- PAGE INSERT or REPLACE built its filter from the widget id and name alone, so +-- `dropdownfilter ddf (attributes: [Status])` stored attrChoice "auto" with an +-- empty `attr` — check passed, describe printed a bare `dropdownfilter ddf`. +-- +-- Expected now: both ALTERs store attrChoice "linked" with the named attribute +-- (Status, then Kind), the REPLACE may reuse the filter's name, `mxcli docker +-- check --no-update-widgets` reports 0 errors (11.15.0), and describe prints +-- `dropdownfilter ddf (Attributes: [Kind])`. + +create module Bug1519d; +create enumeration Bug1519d.Status ( Open 'Open', Closed 'Closed' ); +create persistent entity Bug1519d.Item ( + Name: String(100), Status: Enumeration(Bug1519d.Status), Kind: Enumeration(Bug1519d.Status) +); + +create page Bug1519d.Item_Overview ( Title: 'Items', Layout: Atlas_Core.Atlas_Default ) { + datagrid dg ( DataSource: database from Bug1519d.Item ) { + column (Attribute: Name, Caption: 'Name') + } +}; + +alter page Bug1519d.Item_Overview { + insert after dg column(Name) { column (Attribute: Kind, Caption: 'Kind') { dropdownfilter ddf (attributes: [Status]) } }; +}; +alter page Bug1519d.Item_Overview { + replace dg column(Kind) with { column (Attribute: Kind, Caption: 'Kind') { dropdownfilter ddf (attributes: [Kind]) } }; +}; + +describe page Bug1519d.Item_Overview; diff --git a/mdl-examples/bug-tests/1519-datagrid-empty-caption-custom-content.mdl b/mdl-examples/bug-tests/1519-datagrid-empty-caption-custom-content.mdl new file mode 100644 index 0000000000..86edde999c --- /dev/null +++ b/mdl-examples/bug-tests/1519-datagrid-empty-caption-custom-content.mdl @@ -0,0 +1,32 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1519 (item 1) — a custom-content column with an empty caption +-- ============================================================================ +-- +-- `column (Caption: '', ShowContentAs: customContent)` passed check, and mx +-- check reported CE0463 on the Data grid 2: a column with no caption, no +-- attribute and no name had nothing to fall back to, and its header was written +-- as TextTemplate: null where Studio Pro writes an empty Forms$ClientTemplate. +-- Fixed by d1528359 (ako/mxcli#1125); this is the reporter's shape as a +-- regression case. +-- +-- Expected: `mxcli docker check --no-update-widgets` reports 0 errors (11.15.0). +-- Control: a binary built at d1528359^ reports CE0463 at Data grid 2 'dg' on +-- this script. + +create module Bug1519c; +create persistent entity Bug1519c.Item ( Name: String(100) ); + +create page Bug1519c.Item_Overview ( Title: 'Items', Layout: Atlas_Core.Atlas_Default ) { + datagrid dg ( DataSource: database from Bug1519c.Item ) { + column (Attribute: Name, Caption: 'Name') + column (Caption: '', ShowContentAs: customContent) { + actionbutton btnOpen (Caption: 'Open') + } + column (Attribute: Name, Caption: '', ShowContentAs: customContent) { + dynamictext dt (Content: '{1}', ContentParams: ({1} = Name)) + } + } +}; + +describe page Bug1519c.Item_Overview; diff --git a/mdl-examples/bug-tests/1543-log-stacktrace.mdl b/mdl-examples/bug-tests/1543-log-stacktrace.mdl new file mode 100644 index 0000000000..4c65233af8 --- /dev/null +++ b/mdl-examples/bug-tests/1543-log-stacktrace.mdl @@ -0,0 +1,46 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1543 — "MDL: LOG cannot set 'Include latest stack trace'" +-- ============================================================================ +-- +-- A Log message activity has 'Include latest stack trace' +-- (IncludeLatestStackTrace), but `LOG ERROR NODE 'X' 'failed' WITH STACKTRACE;` +-- was a parse error, so every log written through MDL stored false — and an +-- error handler written by an agent could not log the stack trace. +-- +-- `with stacktrace` ticks the box. It follows the template parameters, which +-- also open with `with`; absent is unticked, Mendix's default. +-- +-- EXPECTED after exec: DESCRIBE prints `with stacktrace` on exactly the logs +-- below that carry it, in the microflow and the nanoflow, and re-running the +-- DESCRIBE output reports "Unchanged". `mxcli docker check` reports 0 errors +-- (measured on 11.12.4, nanoflow included). +-- +-- In Studio Pro: those logs have 'Include latest stack trace' ticked. +-- Guard: mdl/executor/issue1543_log_stacktrace_test.go. +-- ============================================================================ + +create module BugLogStack1543; + +create or modify microflow BugLogStack1543.ACT_log_stacktrace () +begin + -- The reporter's spelling. + log error node 'BugLogStack1543' 'failed' with stacktrace; + -- After template parameters: two clauses that both open with `with`. + log error node 'BugLogStack1543' 'failed {1}' with ({1} = 'probe') with stacktrace; + -- Absent: unticked, and DESCRIBE prints nothing for it. + log warning node 'BugLogStack1543' 'plain'; + -- The use case: an error handler logging the error with its stack trace. + -- `$latestError/Stacktrace` is System.Error's attribute, still a name although + -- `stacktrace` is now a keyword. + declare $Ratio Decimal = 1 div 0 on error without rollback begin + log error node 'BugLogStack1543' 'failed: {1} {2}' with ({1} = $latestError/Message, {2} = $latestError/Stacktrace) with stacktrace; + end error; + return; +end; + +create or modify nanoflow BugLogStack1543.NAV_log_stacktrace () +begin + log error node 'BugLogStack1543' 'failed' with stacktrace; + return; +end; diff --git a/mdl-examples/bug-tests/1560-selection-and-row-action-arguments.mdl b/mdl-examples/bug-tests/1560-selection-and-row-action-arguments.mdl new file mode 100644 index 0000000000..34f230d03d --- /dev/null +++ b/mdl-examples/bug-tests/1560-selection-and-row-action-arguments.mdl @@ -0,0 +1,66 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1560 (mendixlabs/mxcli#1628) — "a grid or list view calls +-- M.ACT_Open whose parameter is filled in from the container; describe prints +-- `Action: microflow M.ACT_Open` and check rejects it with CE1571". Two causes, +-- both measured on TestApp (Studio Pro-authored, 11.14.0), where describe → +-- check reported CE1571 16 times on pages `mx check` passes: +-- +-- 1. A button passing a widget's SELECTION — a grid's control-bar button, or a +-- button passing a combo box's selection — is stored as a Forms$PageVariable +-- naming only the widget. Describe dropped it (13 of the 16), and exec +-- wrote the call unbound: rebuilt from the description, the nine pages give +-- 13 CE1571 on mx check 11.14.0. Describe now prints `Items: $dgItems` and +-- exec binds it to the same Widget slot: those 11 grid and data view +-- arguments build clean, and an in-place describe → exec is 0 errors where +-- the pre-fix binary gave 2 CE1571. +-- 2. A list widget's OWN click (list view `Action:`, data grid / gallery +-- `OnClick:`) fires per row, and the row fills the parameter in: Studio Pro +-- stores no mapping (3 of the 16). check judged it against the context +-- around the widget. Measured on 10.24.25: list view, data grid and gallery +-- with no argument → no CE1571; a button outside every data context → +-- CE1571 (the control, kept below as btnLoose's commented-out form). +-- +-- Expected: `check -p` passes (the pre-fix binary reported dgItems and lvItems +-- as CE1571); exec writes btnRemove's argument as Variable {Widget: dgItems}, +-- the shape Studio Pro stores, where the pre-fix binary wrote the Expression +-- "$dgItems" (the #1140 form: mxbuild accepts it, Studio Pro reports CE1571); +-- describe page SelA.P_Items prints `Items = $dgItems` and the row actions with +-- no argument. Measured on 10.24.25: mx check 0 errors. +-- ============================================================================ + +create module SelA; + +create persistent entity SelA.Item (Code: String(20)); + +create microflow SelA.ACT_OpenItem ($Item: SelA.Item) +begin + return; +end; + +create microflow SelA.ACT_RemoveItems ($Items: list of SelA.Item) +begin + return; +end; + +create page SelA.P_Items (Title: 'Items', Layout: Atlas_Core.Atlas_Default) { + datagrid dgItems ( + DataSource: database from SelA.Item, + Selection: Multi, + OnClick: call microflow SelA.ACT_OpenItem + ) { + controlbar { + actionbutton btnRemove (Caption: 'Remove', Action: call microflow SelA.ACT_RemoveItems(Items = $dgItems)) + } + column (Attribute: Code, Caption: 'Code') + } + listview lvItems (DataSource: database from SelA.Item, Action: call microflow SelA.ACT_OpenItem) { + dynamictext txtCode (Content: '{1}', ContentParams: ({1} = Code)) + } +}; + +-- The control: no data context supplies $Item here, so check refuses it with +-- CE1571, as mx check does. Uncomment to see it. +-- create page SelA.P_Loose (Title: 'Loose', Layout: Atlas_Core.Atlas_Default) { +-- actionbutton btnLoose (Caption: 'No context', Action: call microflow SelA.ACT_OpenItem) +-- }; diff --git a/mdl-examples/bug-tests/1563-replace-combobox-keeps-hidden-custom-content.mdl b/mdl-examples/bug-tests/1563-replace-combobox-keeps-hidden-custom-content.mdl new file mode 100644 index 0000000000..bae92fd7ee --- /dev/null +++ b/mdl-examples/bug-tests/1563-replace-combobox-keeps-hidden-custom-content.mdl @@ -0,0 +1,87 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1563 (mendixlabs/mxcli#1631) — `alter page … replace cbBooking +-- with { combobox cbSale (… DataSource: database from …Sale …) }` kept +-- cbBooking's custom content inside cbSale: txtOptionName, still bound to +-- Booking.Name, which describe never prints. check and exec passed; with the +-- content type 'yes' mx check reported CE1365 "Attribute +-- 'MyFirstModule.Booking.Name' is not an attribute of entity +-- 'MyFirstModule.Sale'" at a widget the author never wrote. (On a build whose +-- describe prints the type, the replace turned it 'no' and mx check passed — +-- with txtOptionName still stored, hidden.) +-- +-- A same-kind REPLACE keeps what the statement does not state +-- (mendixlabs/mxcli#1247), and content describe cannot print is never stated. +-- When the data source that content is built for moves to another entity, the +-- replace is now refused by check -p and by exec, naming the widgets; dropping +-- them first, in the same statement, lets it through. +-- +-- MDL cannot author a combo box's custom content (MDL-WIDGET26), so the state +-- is set up by hand: run step 1, then — as the issue's repro does — move +-- txtOptionName into cbBooking's optionsSourceAssociationCustomContent and set +-- optionsSourceAssociationCustomContentType to 'yes' with a BSON edit, then +-- `mxcli fix hashes --repair`. Measured on 11.13.0: +-- +-- the replace as reported → check -p and exec refuse; nothing is written +-- step 3 (drop, then the same replace) → cbSale, type 'no', no content; +-- mx check 0 errors +-- step 4 instead (replace keeping Booking) → the content is kept, as #1247 +-- intends; mx check 0 errors +-- ============================================================================ + +-- Step 1: the page. +create persistent entity MyFirstModule.Booking (Name: String(100)); +create persistent entity MyFirstModule.Sale (Code: String(100)); +create non-persistent entity MyFirstModule.Holder (Note: String(100)); +create association MyFirstModule.Holder_Booking from MyFirstModule.Holder to MyFirstModule.Booking; +create association MyFirstModule.Holder_Sale from MyFirstModule.Holder to MyFirstModule.Sale; +create page MyFirstModule.Holder_Edit ( + Title: 'Holder', + Layout: Atlas_Core.PopupLayout, + Params: ($Holder: MyFirstModule.Holder) +) { + layoutgrid lgHolder { + row { + column (DesktopWidth: AutoFill) { + dataview dvHolder (DataSource: $Holder) { + combobox cbBooking ( + Label: 'Booking', + Attribute: MyFirstModule.Holder_Booking, + DataSource: database from MyFirstModule.Booking, + CaptionAttribute: Name + ) + } + } + } + } +}; + +-- Step 2 is the BSON edit described above. + +-- Step 3, on the page after step 2: the replace the refusal recommends — drop +-- the hidden content first. Without the `drop`, this replace is refused. +-- alter page MyFirstModule.Holder_Edit { +-- drop txtOptionName; +-- replace cbBooking with { +-- combobox cbSale ( +-- Label: 'Sale', +-- Attribute: MyFirstModule.Holder_Sale, +-- DataSource: database from MyFirstModule.Sale, +-- CaptionAttribute: Code +-- ) +-- }; +-- }; + +-- Step 4 (instead of step 3, on the page after step 2): a replace that keeps +-- the entity keeps the content. +-- alter page MyFirstModule.Holder_Edit { +-- replace cbBooking with { +-- combobox cbBooking ( +-- Label: 'Booking, renamed label', +-- Attribute: MyFirstModule.Holder_Booking, +-- DataSource: database from MyFirstModule.Booking, +-- CaptionAttribute: Name, +-- optionsSourceAssociationCustomContentType: 'yes' +-- ) +-- }; +-- }; diff --git a/mdl-examples/bug-tests/1583-combobox-database-mode.mdl b/mdl-examples/bug-tests/1583-combobox-database-mode.mdl new file mode 100644 index 0000000000..a252fba693 --- /dev/null +++ b/mdl-examples/bug-tests/1583-combobox-database-mode.mdl @@ -0,0 +1,48 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1583 (ako/mxcli#1488) — a combo box in database mode +-- ============================================================================ +-- +-- `check` warned MDL-WIDGET06 ("recognized but not yet persisted") for the +-- datasource and the item selection and passed; `exec` wrote the widget without +-- the datasource, the selection as a PrimitiveValue, and the caption and value +-- attributes against the data view's entity; mx check reported CE0463 at +-- Combo box 'cbCode'. A second cause sat behind the first: the database branch +-- of the widget's editorConfig was never lifted, so `optionsSourceType` and +-- `selectAllButtonCaption`, hidden there, kept the template's values. +-- +-- Expected now: check is clean (no MDL-WIDGET06), and `mxcli docker check +-- --no-update-widgets` reports 0 errors (measured on 11.12.5 and 11.15.0, +-- Combo box 2.5.0). Both spellings write the same widget: the widget's own keys, +-- and the keywords the association mode already has. The datasource's key is +-- what selects the mode; the generic `DataSource:` clause selects the +-- association mode. Describe prints the keyword spelling, which re-executes. + +create module Bug1583; +create persistent entity Bug1583.Parent ( Name: String(200) ); +create persistent entity Bug1583.Child ( Code: String(200), Name: String(200) ); + +create or modify microflow Bug1583.GET_Parents () returns List of Bug1583.Parent as $List +begin + retrieve $List from Bug1583.Parent; + return $List; +end; + +create or modify page Bug1583.Child_Edit ( + Title: 'Child edit', + Layout: Atlas_Core.Atlas_Default, + Params: ( $Child: Bug1583.Child ) +) { + dataview dvChild (DataSource: $Child) { + -- The reported spelling: the widget's own keys. + combobox cbCode (source: 'database', optionsSourceDatabaseDataSource: microflow Bug1583.GET_Parents, + optionsSourceDatabaseItemSelection: 'Single', optionsSourceDatabaseCaptionAttribute: Name, + optionsSourceDatabaseValueAttribute: Name, databaseAttributeString: Code) + -- The keyword spelling: `Attribute:` is the attribute the value is stored + -- into, `CaptionAttribute:` the option caption. + combobox cbName (optionsSourceDatabaseDataSource: database from Bug1583.Parent, + Attribute: Name, CaptionAttribute: Name, optionsSourceDatabaseValueAttribute: Name) + } +}; + +describe page Bug1583.Child_Edit; diff --git a/mdl-examples/bug-tests/1617-show-page-number-of-pages-to-close.mdl b/mdl-examples/bug-tests/1617-show-page-number-of-pages-to-close.mdl new file mode 100644 index 0000000000..e95a458e67 --- /dev/null +++ b/mdl-examples/bug-tests/1617-show-page-number-of-pages-to-close.mdl @@ -0,0 +1,50 @@ +mdl 1; +-- mendixlabs/mxcli#1617: a microflow Show page action's "Number of pages to +-- close" (Microflows$ShowFormAction.NumberOfPagesToClose) had no MDL spelling. +-- DESCRIBE MICROFLOW printed an action that closes the current page exactly +-- like one that closes none, and every action exec built stored "". +-- +-- It is a setting of the action, so it goes in the `with` list beside the +-- title override; the value is an Integer expression, "" (unstated) closing +-- none. `show page M.P (NumberOfPagesToClose: 1)` is an ARGUMENT for a page +-- parameter of that name — `check --references` and exec now refuse it +-- (CE1613 in mxbuild) and name the spelling below. +-- +-- mxcli exec mdl-examples/bug-tests/1617-show-page-number-of-pages-to-close.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow ShowPageClose.ACT_OpenOverview" +-- -- show page ShowPageClose.Booking_Overview with NumberOfPagesToClose = 1; +-- mx check app.mpr # 0 errors (11.12.5) + +create or modify module ShowPageClose; + +create page ShowPageClose.Booking_Overview (Title: 'Bookings', Layout: Atlas_Core.Atlas_Default) { + dynamictext text1 (Content: 'Bookings') +}; + +-- Closes the current page, then opens the overview. +create microflow ShowPageClose.ACT_OpenOverview () +begin + show page ShowPageClose.Booking_Overview with NumberOfPagesToClose = 1; + return; +end; + +-- Beside a title override, and as an expression. +create microflow ShowPageClose.ACT_OpenOverviewAfter ($Count: Integer) +begin + show page ShowPageClose.Booking_Overview with title = 'Bookings', NumberOfPagesToClose = $Count + 1; + return; +end; + +-- The same action in a nanoflow. +create nanoflow ShowPageClose.NF_OpenOverview () +begin + show page ShowPageClose.Booking_Overview with NumberOfPagesToClose = 2; + return; +end; + +-- Control: unstated closes none and describes without the setting. +create microflow ShowPageClose.ACT_OpenOverviewOnTop () +begin + show page ShowPageClose.Booking_Overview; + return; +end; diff --git a/mdl-examples/bug-tests/1618-selection-by-widget-key.fail.mdl b/mdl-examples/bug-tests/1618-selection-by-widget-key.fail.mdl new file mode 100644 index 0000000000..b692c1c919 --- /dev/null +++ b/mdl-examples/bug-tests/1618-selection-by-widget-key.fail.mdl @@ -0,0 +1,14 @@ +mdl 1; +-- mendixlabs/mxcli#1618, the refusals (MDL-WIDGET50). Each was accepted and +-- written: `Bogus` as a PrimitiveValue by key and as a Selection by keyword, +-- which mxbuild 11.12.5 accepts at 0 errors; and of two disagreeing spellings +-- one was silently dropped. See 1618-selection-by-widget-key.mdl. + +create or modify module BugSelByKeyFail; + +create persistent entity BugSelByKeyFail.Ord (Code: String(20)); + +create page BugSelByKeyFail.Ord_Overview (Title: 'Orders', Layout: Atlas_Core.Atlas_Default) { + gallery galBogus (DataSource: database BugSelByKeyFail.Ord, itemSelection: Bogus) { dynamictext t1 (Content: 'x') } + datagrid dgTwo (DataSource: database BugSelByKeyFail.Ord, Selection: Single, itemSelection: Multi) { column (Attribute: Code) } +}; diff --git a/mdl-examples/bug-tests/1618-selection-by-widget-key.mdl b/mdl-examples/bug-tests/1618-selection-by-widget-key.mdl new file mode 100644 index 0000000000..9dd1a48aac --- /dev/null +++ b/mdl-examples/bug-tests/1618-selection-by-widget-key.mdl @@ -0,0 +1,40 @@ +mdl 1; +-- mendixlabs/mxcli#1618: a Data grid 2 or Gallery selection written by the +-- widget's own key, `itemSelection: Single`, passed check, exec and mx check +-- and was stored as the property's PrimitiveValue, with its Selection left at +-- the default — so the grids below had no selection, and the gallery could not +-- turn its default selection off. `Selection:` and `itemSelection:` are now one +-- property: both are stored in Selection, and describe prints `Selection: …`. +-- +-- An invalid value, or two different values under the two spellings, is +-- refused (MDL-WIDGET50; see 1618-selection-by-widget-key.fail.mdl) — mxbuild +-- 11.12.5 accepts any string there at 0 errors. With -p, a by-key override of a +-- definition constant the installed widget does not declare (`advanced` on +-- Gallery 3.4.0) is MDL-WIDGET01, and exec refuses it. +-- +-- mxcli exec mdl-examples/bug-tests/1618-selection-by-widget-key.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe page BugSelByKey.Ord_Overview" +-- -- datagrid dgSingle (…, Selection: Single) / gallery galNone (…, Selection: None) +-- mx check app.mpr # 0 errors (11.12.5) + +create or modify module BugSelByKey; + +create persistent entity BugSelByKey.Ord (Code: String(20)); + +create page BugSelByKey.Ord_Detail (Title: 'Order', Layout: Atlas_Core.Atlas_Default) { + dynamictext title (Content: 'Order') +}; + +create page BugSelByKey.Ord_Overview (Title: 'Orders', Layout: Atlas_Core.Atlas_Default) { + datagrid dgSingle (DataSource: database BugSelByKey.Ord, itemSelection: Single) { column (Attribute: Code) } + datagrid dgMulti (DataSource: database BugSelByKey.Ord, itemSelection: Multi) { column (Attribute: Code) } + gallery galNone (DataSource: database BugSelByKey.Ord, itemSelection: None) { dynamictext t1 (Content: 'x') } + gallery galMulti (DataSource: database BugSelByKey.Ord, itemSelection: Multi) { dynamictext t2 (Content: 'x') } + -- A gallery with no selection can take a single-click action. + gallery galClick (DataSource: database BugSelByKey.Ord, itemSelection: None, onClick: show page BugSelByKey.Ord_Detail) { + dynamictext t3 (Content: 'x') + } + -- Control: the keyword, and both spellings agreeing. + datagrid dgKw (DataSource: database BugSelByKey.Ord, Selection: Single) { column (Attribute: Code) } + gallery galBoth (DataSource: database BugSelByKey.Ord, Selection: None, itemSelection: None) { dynamictext t4 (Content: 'x') } +}; diff --git a/mdl-examples/bug-tests/1647-combobox-database-mode.mdl b/mdl-examples/bug-tests/1647-combobox-database-mode.mdl new file mode 100644 index 0000000000..f1606352c8 --- /dev/null +++ b/mdl-examples/bug-tests/1647-combobox-database-mode.mdl @@ -0,0 +1,51 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1647 — a combo box in database mode (Studio Pro "Source: +-- Database") did not survive describe → exec. Measured on TestApp +-- (Studio Pro-authored, 11.14.0): WorkflowCommons.User_WorkflowGroup_Select +-- and WorkflowGroup_User_Select, rebuilt from their description (drop + +-- create), came back with comboBox1 as source 'context', selection Single — +-- mx check CE0642 "Property 'Caption'/'Entity' is required" ×4 and CE0115 on +-- the button passing $comboBox1 to a list parameter ×2. Describe printed the +-- database source as the association mode's `DataSource:`. +-- +-- Fixed by the database mode mendixlabs/mxcli#1583 (ako/mxcli#1488) gave the +-- definition: the same rebuild now stores both combo boxes as Studio Pro does +-- and builds at 0 errors. This script is the TestApp shape on a fresh project: +-- a multi-selection database combo box whose selection is passed to a list. +-- +-- Expected (measured 11.13.0): check -p passes with no MDL-WIDGET06; cbGroups +-- stores source 'database', the data source on DbA.Grp, item selection Multi +-- and the caption on DbA.Grp.Name; describe prints +-- `optionsSourceDatabaseDataSource: …, CaptionAttribute: Name, +-- optionsSourceDatabaseItemSelection: Multi`; describe → exec is "Unchanged"; +-- mx check 0 errors. +-- ============================================================================ + +create module DbA; + +create persistent entity DbA.Grp (Name: String(100)); +create persistent entity DbA.Holder (Note: String(100)); + +create microflow DbA.ACT_AddGroups ($Groups: list of DbA.Grp) +begin + return; +end; + +create page DbA.P_Pick (Title: 'Pick', Layout: Atlas_Core.Atlas_Default, Params: ($Holder: DbA.Holder)) { + layoutgrid lg { + row { + column (DesktopWidth: AutoFill) { + dataview dvHolder (DataSource: $Holder) { + combobox cbGroups ( + optionsSourceDatabaseDataSource: database from DbA.Grp sort by Name asc, + optionsSourceDatabaseItemSelection: 'Multi', + optionsSourceDatabaseCaptionAttribute: Name + ) + -- The selection is a list here, so it fits the list parameter. + actionbutton btnAdd (Caption: 'Add', Action: call microflow DbA.ACT_AddGroups(Groups = $cbGroups)) + } + } + } + } +}; diff --git a/mdl-examples/bug-tests/1648-rewrite-changed-combobox-keeps-custom-content.mdl b/mdl-examples/bug-tests/1648-rewrite-changed-combobox-keeps-custom-content.mdl new file mode 100644 index 0000000000..84a5432ce7 --- /dev/null +++ b/mdl-examples/bug-tests/1648-rewrite-changed-combobox-keeps-custom-content.mdl @@ -0,0 +1,71 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1648 — `create or modify` of a page whose combo box the statement +-- CHANGES rebuilt the combo box from its template and deleted its custom +-- content (txtOptionName), which MDL cannot author, while the content type +-- stayed 'yes': exec reported "Replaced page", then mx check CE0642 "Property +-- 'Custom content' is required." An unchanged combo box kept it (the pluggable +-- passthrough), and `alter page … set` keeps it too. +-- +-- The rewrite is now refused, by check -p and by exec, naming the widgets +-- (ADR-0012: carry or refuse, never drop). Measured on 11.13.0, on the page +-- after #1563's steps 1–2 (the BSON edit that puts txtOptionName in +-- cbBooking's custom content): +-- +-- the description, executed unchanged → "Unchanged page" +-- the description with Label changed → check -p and exec refuse; +-- nothing is written +-- step 2 below (drop the content first, +-- then write the combo box without its +-- content type) → "Replaced page"; mx check 0 errors +-- ============================================================================ + +-- Step 1: the page, as in mdl-examples/bug-tests/1563-replace-combobox-keeps-hidden-custom-content.mdl, +-- then the BSON edit described there. +create persistent entity MyFirstModule.Booking (Name: String(100)); +create non-persistent entity MyFirstModule.Holder (Note: String(100)); +create association MyFirstModule.Holder_Booking from MyFirstModule.Holder to MyFirstModule.Booking; +create page MyFirstModule.Holder_Edit ( + Title: 'Holder', + Layout: Atlas_Core.PopupLayout, + Params: ($Holder: MyFirstModule.Holder) +) { + layoutgrid lgHolder { + row { + column (DesktopWidth: AutoFill) { + dataview dvHolder (DataSource: $Holder) { + combobox cbBooking ( + Label: 'Booking', + Attribute: MyFirstModule.Holder_Booking, + DataSource: database from MyFirstModule.Booking, + CaptionAttribute: Name + ) + } + } + } + } +}; + +-- Step 2, on the page after the BSON edit: the rewrite the refusal recommends. +-- Without the `drop`, the `create or modify` below is refused. +-- alter page MyFirstModule.Holder_Edit { drop txtOptionName; }; +-- create or modify page MyFirstModule.Holder_Edit ( +-- Title: 'Holder', +-- Layout: Atlas_Core.PopupLayout, +-- Params: ($Holder: MyFirstModule.Holder) +-- ) { +-- layoutgrid lgHolder { +-- row { +-- column (DesktopWidth: AutoFill) { +-- dataview dvHolder (DataSource: $Holder) { +-- combobox cbBooking ( +-- Label: 'Booking (changed)', +-- Attribute: MyFirstModule.Holder_Booking, +-- DataSource: database from MyFirstModule.Booking, +-- CaptionAttribute: Name +-- ) +-- } +-- } +-- } +-- } +-- }; diff --git a/mdl-examples/bug-tests/alter-entity-1540-second-event-handler.mdl b/mdl-examples/bug-tests/alter-entity-1540-second-event-handler.mdl new file mode 100644 index 0000000000..0e5df5d8f3 --- /dev/null +++ b/mdl-examples/bug-tests/alter-entity-1540-second-event-handler.mdl @@ -0,0 +1,69 @@ +mdl 1; +-- ============================================================================ +-- Issue mendixlabs/mxcli#1540 (ako/mxcli#1438) — ALTER ENTITY ADD EVENT HANDLER +-- refuses a second handler for the same event +-- ============================================================================ +-- +-- Reported: +-- +-- `ALTER ENTITY M.E ADD EVENT HANDLER ON BEFORE COMMIT CALL +-- M.Second($currentObject)` fails with "event handler already exists for +-- Before Commit" when the entity already has one. Studio Pro allows several +-- handlers for one event, and `CREATE OR MODIFY ENTITY` with two `ON BEFORE +-- COMMIT` clauses stores both, so the two statements disagree. +-- +-- ALTER keyed a handler on (moment, event) alone. Its identity is (moment, +-- event, microflow): the same microflow on the same event is still a duplicate, +-- a different one is a second handler. The same key had made two other things +-- wrong, both fixed here: +-- +-- * `add event handler if not exists … call M.New` skipped when ANY handler +-- for the event existed — "already exists" for a microflow never wired. +-- * `drop event handler on before commit` removed the FIRST handler for the +-- event, whichever that was. It now takes `call M.X` to say which; without +-- it, a drop that matches several handlers is refused and names them. +-- +-- Measured on Mendix 11.12.4 (`mxcli docker check`): an entity with two Before +-- Commit handlers is "0 errors", so the refusal was protecting nothing. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/alter-entity-1540-second-event-handler.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe entity Bug1540.Invoice" +-- # two `on before commit` lines, ACT_Invoice_Second then ACT_Invoice_First +-- ============================================================================ + +create module if not exists Bug1540; + +create entity Bug1540.Invoice ( + "Amount": decimal +); + +create microflow Bug1540.ACT_Invoice_First ($Invoice: Bug1540.Invoice) +returns boolean +begin + return true; +end; + +create microflow Bug1540.ACT_Invoice_Second ($Invoice: Bug1540.Invoice) +returns boolean +begin + return true; +end; + +alter entity Bug1540.Invoice + add event handler on before commit call Bug1540.ACT_Invoice_First($currentObject) raise error; + +-- The reported statement: a second handler for the same event. Was refused. +alter entity Bug1540.Invoice + add event handler on before commit call Bug1540.ACT_Invoice_Second($currentObject); + +-- The guard keys on the microflow too: this one is present, so it skips. +alter entity Bug1540.Invoice + add event handler if not exists on before commit call Bug1540.ACT_Invoice_Second($currentObject); + +-- Two handlers now match `on before commit`, so a drop names the one it means. +alter entity Bug1540.Invoice + drop event handler on before commit call Bug1540.ACT_Invoice_First; + +alter entity Bug1540.Invoice + add event handler on before commit call Bug1540.ACT_Invoice_First($currentObject) raise error; diff --git a/mdl-examples/doctype-tests/03-page-examples.mdl b/mdl-examples/doctype-tests/03-page-examples.mdl index 74d1cbc4e7..e6b8352f1d 100644 --- a/mdl-examples/doctype-tests/03-page-examples.mdl +++ b/mdl-examples/doctype-tests/03-page-examples.mdl @@ -2467,6 +2467,41 @@ create page PgTest.P036_ComboBox_Association folder 'ComboBox' } }; +/** + * Order edit form with a ComboBox in database mode (mendixlabs/mxcli#1583). + * The options come from a datasource of their own, and the chosen option's + * value is stored on an attribute of the order — no association involved. + * + * - Database mode: COMBOBOX name (optionsSourceDatabaseDataSource: , Attribute: TargetAttr, + * CaptionAttribute: DisplayAttr, optionsSourceDatabaseValueAttribute: ValueAttr) + * - Naming optionsSourceDatabaseDataSource selects the mode; the generic + * `datasource:` clause selects association mode. + * - The target takes String, Integer, Long or Enumeration (MDL-WIDGET39); with a + * target, the value attribute is required (MDL-WIDGET16, CE0642 'Value'). + * + * @param $Order The order to edit + */ +create page PgTest.P036b_ComboBox_Database folder 'ComboBox' +( + params: ( + $Order: PgTest.Order + ), + title: 'ComboBox Database Selection', + layout: Atlas_Core.PopupLayout +) +{ + dataview dvOrder (datasource: $Order) { + -- Stores the chosen customer's City in the order's Notes, showing the Name + combobox cmbShipCity (label: 'Ship to city of', optionsSourceDatabaseDataSource: database PgTest.Customer, + attribute: Notes, CaptionAttribute: Name, optionsSourceDatabaseValueAttribute: City) + + footer { + actionbutton btnSave (caption: 'Save', action: save changes close page, buttonstyle: success) + actionbutton btnCancel (caption: 'Cancel', action: cancel changes close page, buttonstyle: default) + } + } +}; + -- ============================================================================================ -- -- MARK: Layout Placeholders (issue #532) diff --git a/mdl/ast/ast_entity.go b/mdl/ast/ast_entity.go index d11da9c0f2..c597f40e9a 100644 --- a/mdl/ast/ast_entity.go +++ b/mdl/ast/ast_entity.go @@ -80,7 +80,7 @@ const ( AlterEntityDropIndex // DROP INDEX AlterEntitySetPosition // SET POSITION (x, y) AlterEntityAddEventHandler // ADD EVENT HANDLER ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK CALL Mod.MF - AlterEntityDropEventHandler // DROP EVENT HANDLER ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK + AlterEntityDropEventHandler // DROP EVENT HANDLER ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK [CALL Mod.MF] AlterEntitySetAllowCreateChangeLocally // SET ALLOW_CREATE_CHANGE_LOCALLY = true/false AlterEntityDropDefault // DROP DEFAULT ON ATTRIBUTE — clear a default value AlterEntitySetExportLevel // SET EXPORT LEVEL API | HIDDEN @@ -90,7 +90,7 @@ const ( type EventHandlerDef struct { Moment string // "Before" or "After" Event string // "Create", "Commit", "Delete", "Rollback" - Microflow QualifiedName // Microflow to call + Microflow QualifiedName // Microflow to call; on DROP, empty unless CALL names one RaiseErrorOnFalse bool // RAISE ERROR clause present PassEventObject bool // Whether to pass entity object (default true) } diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 5b5111632f..5a4b9cece1 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -715,14 +715,15 @@ func (p *TemplateParam) IsDataSourceRef() bool { return p.DataSourceName != "" } -// LogStmt represents: LOG LEVEL [NODE expr] message [WITH params] +// LogStmt represents: LOG LEVEL [NODE expr] message [WITH params] [WITH STACKTRACE] type LogStmt struct { - Level LogLevel // Log level (INFO, WARNING, etc.) - Node Expression // Optional log node expression - Message Expression // Message expression - Template []TemplateParam // Optional WITH template params - Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation - ErrorHandling *ErrorHandlingClause // Optional ON ERROR clause + Level LogLevel // Log level (INFO, WARNING, etc.) + Node Expression // Optional log node expression + Message Expression // Message expression + Template []TemplateParam // Optional WITH template params + IncludeLatestStackTrace bool // WITH STACKTRACE — Studio Pro's "Include latest stack trace" + Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation + ErrorHandling *ErrorHandlingClause // Optional ON ERROR clause } func (s *LogStmt) isMicroflowStatement() {} @@ -1127,6 +1128,9 @@ type ShowPageStmt struct { ModalForm bool // Whether to show as modal Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation ErrorHandling *ErrorHandlingClause // Optional ON ERROR clause + // NumberOfPagesToClose is `with NumberOfPagesToClose = `: how many + // pages to close before the page opens; nil closes none (mendixlabs/mxcli#1617). + NumberOfPagesToClose Expression } func (s *ShowPageStmt) isMicroflowStatement() {} diff --git a/mdl/backend/mcp/microflow.go b/mdl/backend/mcp/microflow.go index 791da254de..c6c2050f00 100644 --- a/mdl/backend/mcp/microflow.go +++ b/mdl/backend/mcp/microflow.go @@ -912,6 +912,11 @@ func mapMicroflowAction(a microflows.MicroflowAction) (map[string]any, error) { if act.LogNodeName != "" { m["node"] = act.LogNodeName } + // The Model SDK's name for "Include latest stack trace"; false is the + // default, so it is sent only when ticked (mendixlabs/mxcli#1543). + if act.IncludeLastStackTrace { + m["includeLatestStackTrace"] = true + } return m, nil case *microflows.NotifyWorkflowAction: // ped_get_schema, Studio Pro 11.14: workflowVariable, outputVariableName and diff --git a/mdl/backend/mcp/microflow_test.go b/mdl/backend/mcp/microflow_test.go index 30f774ff52..91646e694d 100644 --- a/mdl/backend/mcp/microflow_test.go +++ b/mdl/backend/mcp/microflow_test.go @@ -52,6 +52,20 @@ func TestMapMicroflowAction_VariableAndMessages(t *testing.T) { if tmpl, _ := logged["messageTemplate"].(map[string]any); tmpl["$Type"] != "Microflows$StringTemplate" || tmpl["text"] != "done" { t.Fatalf("log messageTemplate needs $Type: %+v", logged["messageTemplate"]) } + if _, set := logged["includeLatestStackTrace"]; set { + t.Fatalf("an unticked stack trace is the default and is not sent: %+v", logged) + } + + // `log ... with stacktrace` (mendixlabs/mxcli#1543) must reach Studio Pro + // over --mcp too, not only the file backend. + traced, err := mapMicroflowAction(µflows.LogMessageAction{ + LogLevel: microflows.LogLevelError, + MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "failed"}}, + IncludeLastStackTrace: true, + }) + if err != nil || traced["includeLatestStackTrace"] != true { + t.Fatalf("log with stack trace: %+v / %v", traced, err) + } } func TestMapMicroflowAction_ObjectActions(t *testing.T) { diff --git a/mdl/backend/modelsdk/microflow_read_actions.go b/mdl/backend/modelsdk/microflow_read_actions.go index d1098dc661..a8b2d9f9e1 100644 --- a/mdl/backend/modelsdk/microflow_read_actions.go +++ b/mdl/backend/modelsdk/microflow_read_actions.go @@ -281,7 +281,12 @@ func actionFromGen(el element.Element) microflows.MicroflowAction { case *genMf.ShowPageAction: // Storage $Type Microflows$ShowFormAction. The FormSettings tree is written // raw (Form / ParameterMappings with legacy keys), so read it from raw. - out := µflows.ShowPageAction{ErrorHandlingType: microflows.ErrorHandlingType(a.ErrorHandlingType())} + out := µflows.ShowPageAction{ + ErrorHandlingType: microflows.ErrorHandlingType(a.ErrorHandlingType()), + // Unread, an action that closes a page described as one that closes + // none (mendixlabs/mxcli#1617). + NumberOfPagesToClose: a.NumberOfPagesToClose(), + } out.ID = model.ID(a.ID()) if fs, ok := a.Raw().Lookup("FormSettings").DocumentOK(); ok { out.FormSettingsID = model.ID(rawStr(fs, "$ID")) diff --git a/mdl/backend/modelsdk/microflow_write.go b/mdl/backend/modelsdk/microflow_write.go index 080686bf85..e52e1c4437 100644 --- a/mdl/backend/modelsdk/microflow_write.go +++ b/mdl/backend/modelsdk/microflow_write.go @@ -886,7 +886,9 @@ func microflowActionToGen(action microflows.MicroflowAction) element.Element { g.SetID(element.ID(a.ID)) g.SetErrorHandlingType(orDefault(string(a.ErrorHandlingType), "Rollback")) g.SetPageSettings(showPageFormSettingsToGen(a)) - g.SetNumberOfPagesToClose("") + // An expression; "" (the default) closes no page. It was always written + // as "", whatever the statement said (mendixlabs/mxcli#1617). + g.SetNumberOfPagesToClose(a.NumberOfPagesToClose) return g case *microflows.ClosePageAction: // Storage $Type Microflows$CloseFormAction. The count is the string diff --git a/mdl/backend/modelsdk/showpage_pages_to_close_test.go b/mdl/backend/modelsdk/showpage_pages_to_close_test.go new file mode 100644 index 0000000000..a581efbfb5 --- /dev/null +++ b/mdl/backend/modelsdk/showpage_pages_to_close_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1617: a microflow Show page action's "Number of pages to +// close" (Microflows$ShowFormAction.NumberOfPagesToClose, a microflow +// expression; "" closes nothing) was never read, and the writer stored "" in +// every action it built. DESCRIBE therefore printed an action that closes one +// page exactly like one that closes none, and no statement could write one. +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// studioProShowFormAction is a Show page action as Studio Pro stores it with +// "Number of pages to close" set (bson dump of the #1617 repro). +func studioProShowFormAction(pagesToClose string) bson.D { + return bson.D{ + {Key: "$ID", Value: "a-1"}, + {Key: "$Type", Value: "Microflows$ShowFormAction"}, + {Key: "ErrorHandlingType", Value: "Rollback"}, + {Key: "FormSettings", Value: bson.D{ + {Key: "$ID", Value: "fs-1"}, + {Key: "$Type", Value: "Forms$FormSettings"}, + {Key: "Form", Value: "MyFirstModule.Booking_Overview"}, + {Key: "ParameterMappings", Value: bson.A{int32(2)}}, + {Key: "TitleOverride", Value: nil}, + }}, + {Key: "NumberOfPagesToClose", Value: pagesToClose}, + } +} + +func TestShowPage_NumberOfPagesToCloseIsRead(t *testing.T) { + got, ok := decodeAction(t, studioProShowFormAction("1")).(*microflows.ShowPageAction) + if !ok { + t.Fatalf("read back as %T", got) + } + if got.NumberOfPagesToClose != "1" { + t.Errorf("NumberOfPagesToClose = %q, want \"1\": an action that closes a page reads back as one "+ + "that closes none (mendixlabs/mxcli#1617)", got.NumberOfPagesToClose) + } + // CONTROL: the stored default reads back as no pages to close, so the + // assertion above is about the value and not a constant the reader returns. + none, _ := decodeAction(t, studioProShowFormAction("")).(*microflows.ShowPageAction) + if none == nil || none.NumberOfPagesToClose != "" { + t.Errorf("the default \"\" read back as %+v", none) + } +} + +func TestShowPage_NumberOfPagesToCloseIsWritten(t *testing.T) { + d := encodeDoc(t, microflowActionToGen(µflows.ShowPageAction{ + PageName: "MyFirstModule.Booking_Overview", NumberOfPagesToClose: "1", + })) + if v, _ := lookupKey(d, "NumberOfPagesToClose"); v != "1" { + t.Errorf("NumberOfPagesToClose = %#v, want \"1\": the writer stored \"\" whatever the statement "+ + "said (mendixlabs/mxcli#1617)", v) + } + // CONTROL: an action that closes nothing keeps the key, as Studio Pro + // stores it, with the empty expression. + d = encodeDoc(t, microflowActionToGen(µflows.ShowPageAction{PageName: "MyFirstModule.Booking_Overview"})) + if v, ok := lookupKey(d, "NumberOfPagesToClose"); !ok || v != "" { + t.Errorf("no pages to close: NumberOfPagesToClose = %#v (present %v), want \"\"", v, ok) + } +} diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index b7c797ea0f..2fb10492b9 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -2003,14 +2003,22 @@ type parameterMappingTarget interface { // kind empty means "not a page-variable reference": variable is then written as // the expression, preserving what every caller before #1140 relied on — // $currentObject among them, whose stored form has not been measured. +// +// Kind "widget" is a widget's selection: the PageVariable names the widget and +// nothing else, as on all 13 such mappings in TestApp's WorkflowCommons +// (ako/mxcli#1560). It used to fall to the page-parameter slot. func bindParameterMappingValue(m parameterMappingTarget, variable, kind, expression string) { if variable != "" && kind != "" { - // sourceVariableToGen spells the page-parameter slot as the empty kind. - svKind := kind - if svKind == "parameter" { - svKind = "" + name := strings.TrimPrefix(variable, "$") + switch kind { + case "widget": + m.SetVariable(pageVariableToGen(name, "", "")) + case "parameter": + // sourceVariableToGen spells the page-parameter slot as the empty kind. + m.SetVariable(sourceVariableToGen(name, "")) + default: + m.SetVariable(sourceVariableToGen(name, kind)) } - m.SetVariable(sourceVariableToGen(strings.TrimPrefix(variable, "$"), svKind)) // Studio Pro writes both keys, the unused one empty. m.SetExpression("") return diff --git a/mdl/backend/modelsdk/widget_write_selection_arg_1560_test.go b/mdl/backend/modelsdk/widget_write_selection_arg_1560_test.go new file mode 100644 index 0000000000..0193ab9a27 --- /dev/null +++ b/mdl/backend/modelsdk/widget_write_selection_arg_1560_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + genPg "github.com/mendixlabs/mxcli/modelsdk/gen/pages" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#1560: a flow argument that is a widget's selection is stored as +// Studio Pro stores it on TestApp's WorkflowCommons.UserTask_Assign — a +// Forms$PageVariable naming only the widget, Expression "". Kind "widget" fell +// to the page-parameter slot, which names a page parameter `grid2` the page does +// not have. +func TestClientActionToGen_FlowCallWritesSelectionMapping(t *testing.T) { + check := func(t *testing.T, variable any, expression string) { + t.Helper() + pv, ok := variable.(*genPg.PageVariable) + if !ok { + t.Fatalf("Variable = %T, want *PageVariable", variable) + } + if pv.WidgetQualifiedName() != "grid2" || pv.PageParameterQualifiedName() != "" || + pv.SnippetParameterQualifiedName() != "" || pv.LocalVariableQualifiedName() != "" { + t.Errorf("PageVariable widget=%q page=%q snippet=%q local=%q, want grid2 and three empty slots", + pv.WidgetQualifiedName(), pv.PageParameterQualifiedName(), + pv.SnippetParameterQualifiedName(), pv.LocalVariableQualifiedName()) + } + if expression != "" { + t.Errorf("Expression = %q, want \"\" (Studio Pro writes both keys, the unused one empty)", expression) + } + } + + t.Run("microflow", func(t *testing.T) { + el, err := clientActionToGen(&pages.MicroflowClientAction{ + BaseElement: model.BaseElement{ID: "a"}, + MicroflowName: "WorkflowCommons.ACT_WorkflowUserTask_Assignees_Remove", + ParameterMappings: []*pages.MicroflowParameterMapping{ + {ParameterName: "UserList", Variable: "$grid2", VariableKind: "widget"}, + }, + }) + if err != nil { + t.Fatalf("clientActionToGen: %v", err) + } + items := el.(*genPg.MicroflowClientAction).MicroflowSettings().(*genPg.MicroflowSettings).ParameterMappingsItems() + if len(items) != 1 { + t.Fatalf("%d mappings written, want 1", len(items)) + } + m := items[0].(*genPg.MicroflowParameterMapping) + check(t, m.Variable(), m.Expression()) + }) + + t.Run("nanoflow", func(t *testing.T) { + el, err := clientActionToGen(&pages.NanoflowClientAction{ + BaseElement: model.BaseElement{ID: "b"}, + NanoflowName: "M.NF_Remove", + ParameterMappings: []*pages.NanoflowParameterMapping{ + {ParameterName: "Items", Variable: "$grid2", VariableKind: "widget"}, + }, + }) + if err != nil { + t.Fatalf("clientActionToGen: %v", err) + } + items := el.(*genPg.CallNanoflowClientAction).ParameterMappingsItems() + if len(items) != 1 { + t.Fatalf("%d mappings written, want 1", len(items)) + } + m := items[0].(*genPg.NanoflowParameterMapping) + check(t, m.Variable(), m.Expression()) + }) + + // Control: a page parameter still fills the PageParameter slot alone. + t.Run("page parameter control", func(t *testing.T) { + el, err := clientActionToGen(&pages.MicroflowClientAction{ + BaseElement: model.BaseElement{ID: "c"}, + MicroflowName: "M.ACT", + ParameterMappings: []*pages.MicroflowParameterMapping{ + {ParameterName: "Order", Variable: "$Order", VariableKind: "parameter"}, + }, + }) + if err != nil { + t.Fatalf("clientActionToGen: %v", err) + } + m := el.(*genPg.MicroflowClientAction).MicroflowSettings().(*genPg.MicroflowSettings). + ParameterMappingsItems()[0].(*genPg.MicroflowParameterMapping) + pv := m.Variable().(*genPg.PageVariable) + if pv.PageParameterQualifiedName() != "Order" || pv.WidgetQualifiedName() != "" { + t.Errorf("page parameter written as page=%q widget=%q, want Order and \"\"", + pv.PageParameterQualifiedName(), pv.WidgetQualifiedName()) + } + }) +} diff --git a/mdl/backend/pagemutator/footerregion.go b/mdl/backend/pagemutator/footerregion.go index 1fe4321aa2..027b729a84 100644 --- a/mdl/backend/pagemutator/footerregion.go +++ b/mdl/backend/pagemutator/footerregion.go @@ -165,11 +165,18 @@ func (m *Mutator) ContainedWidgetNames(widgetRef, columnRef string) []string { var roots []any switch { case columnRef != "": - dv, ok := m.findDataViewFooter(widgetRef, columnRef) - if !ok { + if dv, ok := m.findDataViewFooter(widgetRef, columnRef); ok { + roots = dataViewFooterWidgets(dv.widget) + break + } + // A Data grid 2 column: its filter and its custom content. Reported + // nothing before, so a replacement column could not reuse its filter's + // name (mendixlabs/mxcli#1519). + col, err := findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder) + if err != nil || col == nil { return nil } - roots = dataViewFooterWidgets(dv.widget) + roots = []any{col.parentArr[col.index]} default: result := m.widgetFinder(m.rawData, widgetRef) if result == nil { diff --git a/mdl/backend/pagemutator/mutator_column_addressing_test.go b/mdl/backend/pagemutator/mutator_column_addressing_test.go index e5ef47a48d..63d466ea55 100644 --- a/mdl/backend/pagemutator/mutator_column_addressing_test.go +++ b/mdl/backend/pagemutator/mutator_column_addressing_test.go @@ -307,3 +307,31 @@ func TestObjectListItemGuard_IgnoresRealWidgets(t *testing.T) { t.Fatalf("guard wrongly refused a pluggable widget: %v", err) } } + +// REPLACE of a Data grid 2 column frees the names of what the column holds — +// its filter, its custom content — so the replacement may reuse them. The +// column reference was read only as a data view footer, so a grid column +// reported nothing, and a replacement whose filter kept its name (`dropdownfilter +// ddf`) was refused as a duplicate once the filter went through the engine +// (mendixlabs/mxcli#1519 item 2, the ALTER path). +func TestContainedWidgetNames_GridColumnReportsItsWidgets(t *testing.T) { + grid := buildGridWithColumns([]map[string]string{{"attr": "M.E.Merchant"}, {"caption": "Amount"}}) + // Give the Merchant column a filter slot holding a named drop-down filter. + objs := grid[1].Value.(bson.D)[0].Value.(bson.A)[1].(bson.D)[1].Value.(bson.D)[0].Value.(bson.A) + merchant := objs[1].(bson.D) + merchant[0].Value = append(merchant[0].Value.(bson.A), bson.D{ + {Key: "TypePointer", Value: idBin(0x13)}, + {Key: "Value", Value: bson.D{{Key: "Widgets", Value: bson.A{int32(2), + bson.D{{Key: "$Type", Value: "CustomWidgets$CustomWidget"}, {Key: "Name", Value: "ddf"}}}}}}, + }) + m := &Mutator{rawData: bson.D{}, widgetFinder: gridFinder(grid)} + + got := m.ContainedWidgetNames("dg", "Merchant") + if len(got) != 1 || got[0] != "ddf" { + t.Errorf("ContainedWidgetNames(dg, Merchant) = %v, want [ddf]", got) + } + // The control: a column holding nothing frees nothing. + if got := m.ContainedWidgetNames("dg", "Amount"); len(got) != 0 { + t.Errorf("ContainedWidgetNames(dg, Amount) = %v, want none", got) + } +} diff --git a/mdl/backend/pagemutator/pluggable_replace.go b/mdl/backend/pagemutator/pluggable_replace.go index be07caf1a8..4d7820eb09 100644 --- a/mdl/backend/pagemutator/pluggable_replace.go +++ b/mdl/backend/pagemutator/pluggable_replace.go @@ -5,9 +5,11 @@ package pagemutator import ( "bytes" "strconv" + "strings" "go.mongodb.org/mongo-driver/v2/bson" + "github.com/mendixlabs/mxcli/mdl/backend" "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" "github.com/mendixlabs/mxcli/sdk/pages" ) @@ -41,26 +43,115 @@ import ( // with replacement, keeping every stored property on which replacement and // baseline agree. handled is false when the merge does not apply; the caller // then replaces as usual. +// +// A merge that would keep content describe cannot show, bound to an entity the +// replacement no longer uses, is refused instead (StaleWidgetContentError, +// ako/mxcli#1563): handled is then true and nothing is written. func (m *Mutator) ReplacePluggableKeepingUnstated(widgetRef string, replacement, baseline pages.Widget) (handled bool, err error) { - if replacement == nil || baseline == nil { + result, merged, ok := m.mergePluggableReplace(widgetRef, replacement, baseline) + if !ok { return false, nil } + if stale := staleLinkedContent(result.widget, merged); stale != nil { + stale.Widget = widgetRef + return true, stale + } + result.parentArr[result.index] = merged + bsonnav.DSetArray(result.parentDoc, result.parentKey, result.parentArr) + return true, nil +} + +// CheckPluggableReplace is ReplacePluggableKeepingUnstated without the write: +// the refusal it would give, for `check` to report before exec runs. +func (m *Mutator) CheckPluggableReplace(widgetRef string, replacement, baseline pages.Widget) error { + result, merged, ok := m.mergePluggableReplace(widgetRef, replacement, baseline) + if !ok { + return nil + } + if stale := staleLinkedContent(result.widget, merged); stale != nil { + stale.Widget = widgetRef + return stale + } + return nil +} + +// mergePluggableReplace finds the stored pluggable widget and merges the +// replacement into it; ok is false when the merge does not apply. +func (m *Mutator) mergePluggableReplace(widgetRef string, replacement, baseline pages.Widget) (*bsonWidgetResult, bson.D, bool) { + if replacement == nil || baseline == nil { + return nil, nil, false + } result := m.widgetFinder(m.rawData, widgetRef) if result == nil || len(result.colPropKeys) > 0 { - return false, nil + return nil, nil, false } if bsonnav.DGetString(result.widget, "$Type") != "CustomWidgets$CustomWidget" { - return false, nil + return nil, nil, false } - newDoc := m.deps.SerializeWidget(replacement) - baseDoc := m.deps.SerializeWidget(baseline) - merged, ok := mergeUnstatedPluggable(result.widget, newDoc, baseDoc) + merged, ok := mergeUnstatedPluggable(result.widget, m.deps.SerializeWidget(replacement), m.deps.SerializeWidget(baseline)) if !ok { - return false, nil + return nil, nil, false } - result.parentArr[result.index] = merged - bsonnav.DSetArray(result.parentDoc, result.parentKey, result.parentArr) - return true, nil + return result, merged, true +} + +// staleLinkedContent reports the widgets a merge keeps in a Widgets property +// while the data source that property is built for — its DataSourceProperty in +// the stored Type — now names another entity (ako/mxcli#1563). +// +// Content describe prints is always stated, so the merge only ever keeps +// content describe cannot print; that is the content this is about. An entity +// that cannot be read on either side reports nothing: the refusal is for what +// is provably bound to the wrong entity. +func staleLinkedContent(stored, merged bson.D) *backend.StaleWidgetContentError { + storedProps, mergedProps := propertiesByKey(stored), propertiesByKey(merged) + for _, pt := range bsonnav.DGetArrayElements(bsonnav.DGet(bsonnav.DGetDoc(bsonnav.DGetDoc(stored, "Type"), "ObjectType"), "PropertyTypes")) { + ptd, ok := pt.(bson.D) + if !ok { + continue + } + vt := bsonnav.DGetDoc(ptd, "ValueType") + key, linked := bsonnav.DGetString(ptd, "PropertyKey"), bsonnav.DGetString(vt, "DataSourceProperty") + if bsonnav.DGetString(vt, "Type") != "Widgets" || linked == "" { + continue + } + content := listElements(bsonnav.DGetDoc(storedProps[key], "Value"), "Widgets") + if len(content) == 0 || + !sameIgnoringIdentity(bsonnav.DGet(bsonnav.DGetDoc(mergedProps[key], "Value"), "Widgets"), + bsonnav.DGet(bsonnav.DGetDoc(storedProps[key], "Value"), "Widgets")) { + continue // nothing to carry, or the statement's own content + } + was := dataSourceEntity(bsonnav.DGetDoc(storedProps[linked], "Value")) + now := dataSourceEntity(bsonnav.DGetDoc(mergedProps[linked], "Value")) + if was == "" || now == "" || strings.EqualFold(was, now) { + continue + } + stale := &backend.StaleWidgetContentError{Property: key, DataSource: linked, StoredEntity: was, NewEntity: now} + for _, w := range content { + wd, _ := w.(bson.D) + stale.Widgets = append(stale.Widgets, bsonnav.DGetString(wd, "Name")) + stale.WidgetTypes = append(stale.WidgetTypes, bsonnav.DGetString(wd, "$Type")) + } + return stale + } + return nil +} + +// dataSourceEntity is the entity a DataSource-typed WidgetValue's source +// names, "" when it names none this can read. +func dataSourceEntity(value bson.D) string { + ref := bsonnav.DGetDoc(bsonnav.DGetDoc(value, "DataSource"), "EntityRef") + if e := bsonnav.DGetString(ref, "Entity"); e != "" { + return e + } + // An IndirectEntityRef: the entity the last step arrives at. + steps := bsonnav.DGetArrayElements(bsonnav.DGet(ref, "Steps")) + if len(steps) > 0 { + if last, ok := steps[len(steps)-1].(bson.D); ok { + return bsonnav.DGetString(last, "DestinationEntity") + } + } + return "" } // mergeUnstatedPluggable returns stored with the replacement's stated changes diff --git a/mdl/backend/pagemutator/pluggable_replace_stale_content_1563_test.go b/mdl/backend/pagemutator/pluggable_replace_stale_content_1563_test.go new file mode 100644 index 0000000000..faddd737b0 --- /dev/null +++ b/mdl/backend/pagemutator/pluggable_replace_stale_content_1563_test.go @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "errors" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#1563 (mendixlabs/mxcli#1631): `replace cbBooking with { combobox +// cbSale (… DataSource: database from M.Sale …) }` kept cbBooking's custom +// content — txtOptionName, bound to Booking.Name — inside cbSale. describe +// never prints custom content, so the baseline and the replacement agree on it +// and the #1247 merge keeps the stored widgets; with the content type 'yes' +// mx check reported CE1365 "Attribute 'M.Booking.Name' is not an attribute of +// entity 'M.Sale'" at a widget the author never wrote. On current main +// describe prints the type, so the replace turns it 'no' and mx check passes — +// with txtOptionName still stored, hidden, and bound to Booking. + +// contentCombo is a combo box whose custom content (widget names) is built for +// its association data source on entity. linked is the content's +// DataSourceProperty, as the stored Type declares it. +func contentCombo(prefix, entity, contentType, emptyText, linked string, content ...string) bson.D { + type prop struct { + key string + vt bson.D + val bson.D + } + widgets := bson.A{int32(2)} + for _, n := range content { + widgets = append(widgets, bson.D{ + {Key: "$ID", Value: bid(prefix + "cw-" + n)}, + {Key: "$Type", Value: "Forms$DynamicText"}, + {Key: "Name", Value: n}, + }) + } + ds := bson.D{} + if entity != "" { + ds = bson.D{ + {Key: "$ID", Value: bid(prefix + "ds")}, + {Key: "$Type", Value: "CustomWidgets$CustomWidgetXPathSource"}, + {Key: "EntityRef", Value: bson.D{ + {Key: "$ID", Value: bid(prefix + "er")}, + {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, + {Key: "Entity", Value: entity}, + }}, + } + } + props := []prop{ + {"optionsSourceAssociationDataSource", bson.D{{Key: "Type", Value: "DataSource"}}, bson.D{{Key: "DataSource", Value: ds}}}, + {"optionsSourceAssociationCustomContentType", bson.D{{Key: "Type", Value: "Enumeration"}}, bson.D{{Key: "PrimitiveValue", Value: contentType}}}, + {"optionsSourceAssociationCustomContent", + bson.D{{Key: "Type", Value: "Widgets"}, {Key: "DataSourceProperty", Value: linked}}, + bson.D{{Key: "PrimitiveValue", Value: ""}, {Key: "Widgets", Value: widgets}}}, + {"emptyOptionText", bson.D{{Key: "Type", Value: "TextTemplate"}}, bson.D{{Key: "PrimitiveValue", Value: emptyText}}}, + } + var pts, ps bson.A + pts = append(pts, int32(2)) + ps = append(ps, int32(2)) + for _, p := range props { + vt := append(bson.D{{Key: "$ID", Value: bid(prefix + "vt-" + p.key)}}, p.vt...) + pts = append(pts, bson.D{ + {Key: "$ID", Value: bid(prefix + "pt-" + p.key)}, + {Key: "PropertyKey", Value: p.key}, + {Key: "ValueType", Value: vt}, + }) + val := append(bson.D{{Key: "$ID", Value: bid(prefix + "v-" + p.key)}}, p.val...) + val = append(val, bson.E{Key: "TypePointer", Value: bid(prefix + "vt-" + p.key)}) + ps = append(ps, bson.D{ + {Key: "$ID", Value: bid(prefix + "p-" + p.key)}, + {Key: "TypePointer", Value: bid(prefix + "pt-" + p.key)}, + {Key: "Value", Value: val}, + }) + } + return bson.D{ + {Key: "$ID", Value: bid(prefix + "w")}, + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Name", Value: "cbBooking"}, + {Key: "Object", Value: bson.D{{Key: "$ID", Value: bid(prefix + "o")}, {Key: "Properties", Value: ps}}}, + {Key: "Type", Value: bson.D{ + {Key: "$ID", Value: bid(prefix + "t")}, + {Key: "ObjectType", Value: bson.D{{Key: "$ID", Value: bid(prefix + "ot")}, {Key: "PropertyTypes", Value: pts}}}, + {Key: "WidgetId", Value: "com.mendix.widget.web.combobox.Combobox"}, + }}, + } +} + +const linkedDS = "optionsSourceAssociationDataSource" + +func TestStaleLinkedContent(t *testing.T) { + stored := contentCombo("S", "M.Booking", "yes", "", linkedDS, "txtOptionName") + + for _, tc := range []struct { + name string + baseline, replacement bson.D + want bool + }{ + { + // The reported shape, as describe prints it on current main (the + // type, not the widgets) and as it printed it then (neither). + "another entity, type described", + contentCombo("B", "M.Booking", "yes", "", linkedDS), + contentCombo("N", "M.Sale", "no", "", linkedDS), + true, + }, + { + "another entity, type not described", + contentCombo("B", "M.Booking", "no", "", linkedDS), + contentCombo("N", "M.Sale", "no", "", linkedDS), + true, + }, + // Controls: each still merges, and keeps the content — #1247's case. + { + "same entity, another property stated", + contentCombo("B", "M.Booking", "yes", "", linkedDS), + contentCombo("N", "M.Booking", "yes", "Pick one", linkedDS), + false, + }, + { + "entity of the replacement unknown", + contentCombo("B", "M.Booking", "yes", "", linkedDS), + contentCombo("N", "", "yes", "", linkedDS), + false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + merged, ok := mergeUnstatedPluggable(stored, tc.replacement, tc.baseline) + if !ok { + t.Fatal("merge did not apply") + } + stale := staleLinkedContent(stored, merged) + if (stale != nil) != tc.want { + t.Fatalf("staleLinkedContent = %v, want stale=%v", stale, tc.want) + } + if stale == nil { + return + } + if stale.Property != "optionsSourceAssociationCustomContent" || stale.DataSource != linkedDS || + stale.StoredEntity != "M.Booking" || stale.NewEntity != "M.Sale" || + len(stale.Widgets) != 1 || stale.Widgets[0] != "txtOptionName" { + t.Errorf("stale = %+v", stale) + } + }) + } + + // Controls on the stored side: nothing to carry, or content built for no + // data source (a static source's custom content). + for name, s := range map[string]bson.D{ + "stored content empty": contentCombo("S", "M.Booking", "yes", "", linkedDS), + "content linked to no source": contentCombo("S", "M.Booking", "yes", "", "", "txtOptionName"), + } { + t.Run(name, func(t *testing.T) { + merged, ok := mergeUnstatedPluggable(s, contentCombo("N", "M.Sale", "no", "", linkedDS), + contentCombo("B", "M.Booking", "yes", "", linkedDS)) + if !ok { + t.Fatal("merge did not apply") + } + if stale := staleLinkedContent(s, merged); stale != nil { + t.Errorf("reported %v, want nothing", stale) + } + }) + } +} + +// fakeSerializer hands back the document built for each semantic widget. +type fakeSerializer struct { + Deps + docs map[pages.Widget]bson.D +} + +func (f fakeSerializer) SerializeWidget(w pages.Widget) bson.D { return f.docs[w] } + +// Through the mutator: the replace is refused, nothing is written, and the +// check-time question gives the same answer without writing either. +func TestReplacePluggableRefusesStaleContent(t *testing.T) { + replacement := &pages.CustomWidget{BaseWidget: pages.BaseWidget{Name: "cbSale"}} + baseline := &pages.CustomWidget{BaseWidget: pages.BaseWidget{Name: "cbBooking"}} + newDoc := contentCombo("N", "M.Sale", "no", "", linkedDS) + bsonnav.DSet(newDoc, "Name", "cbSale") + deps := fakeSerializer{docs: map[pages.Widget]bson.D{ + replacement: newDoc, + baseline: contentCombo("B", "M.Booking", "yes", "", linkedDS), + }} + m := New(makeRawPage(contentCombo("S", "M.Booking", "yes", "", linkedDS, "txtOptionName")), model.ID("u"), deps) + err := m.CheckPluggableReplace("cbBooking", replacement, baseline) + var stale *backend.StaleWidgetContentError + if !errors.As(err, &stale) { + t.Fatalf("CheckPluggableReplace = %v, want a StaleWidgetContentError", err) + } + if stale.Widget != "cbBooking" || !strings.Contains(err.Error(), "drop txtOptionName;") { + t.Errorf("refusal should name the widget and the drop that clears it: %v", err) + } + + handled, err := m.ReplacePluggableKeepingUnstated("cbBooking", replacement, baseline) + if !handled || !errors.As(err, &stale) { + t.Fatalf("ReplacePluggableKeepingUnstated = (%v, %v), want (true, StaleWidgetContentError)", handled, err) + } + if findBsonWidget(m.rawData, "cbSale") != nil || findBsonWidget(m.rawData, "cbBooking") == nil { + t.Error("the refused replace wrote the page") + } + + // Control: the same replace once the content is dropped goes through. + clean := New(makeRawPage(contentCombo("S", "M.Booking", "yes", "", linkedDS)), model.ID("u"), deps) + if err := clean.CheckPluggableReplace("cbBooking", replacement, baseline); err != nil { + t.Errorf("no content to carry, yet refused: %v", err) + } + if handled, err := clean.ReplacePluggableKeepingUnstated("cbBooking", replacement, baseline); !handled || err != nil { + t.Errorf("ReplacePluggableKeepingUnstated = (%v, %v), want (true, nil)", handled, err) + } + if findBsonWidget(clean.rawData, "cbSale") == nil { + t.Error("the replace with nothing to carry was not written") + } +} diff --git a/mdl/backend/stale_widget_content.go b/mdl/backend/stale_widget_content.go new file mode 100644 index 0000000000..6b7e798d05 --- /dev/null +++ b/mdl/backend/stale_widget_content.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "fmt" + "strings" +) + +// StaleWidgetContentError refuses a REPLACE of a pluggable widget that would +// carry widgets MDL cannot show or author — a combo box's custom content — into +// a replacement whose data source for them is another entity +// (ako/mxcli#1563). +// +// A same-kind REPLACE keeps every stored property the statement does not state +// (mendixlabs/mxcli#1247), and content describe never prints is never stated, +// so it is always kept. That is right while the content's data source keeps +// its entity. Moved onto another one, the kept widgets stay bound to the old +// entity: stored, hidden from describe, and CE1365 once Mendix shows them. +// Dropping them instead would delete Studio Pro-authored content the statement +// has no words for, so the replace is refused (ADR-0012) and the author drops +// them on purpose. +type StaleWidgetContentError struct { + // Widget is the widget the REPLACE targets. + Widget string + // Property holds the content; DataSource is the property it is built for. + Property, DataSource string + // StoredEntity is DataSource's entity as stored, NewEntity the replacement's. + StoredEntity, NewEntity string + // Widgets are the content's widgets, by name ("" for an unnamed one), with + // their types in WidgetTypes at the same index. + Widgets, WidgetTypes []string +} + +func (e *StaleWidgetContentError) Error() string { + var listed, named []string + for i, n := range e.Widgets { + if n == "" { + listed = append(listed, "an unnamed "+e.widgetType(i)) + continue + } + listed = append(listed, n) + named = append(named, n) + } + msg := fmt.Sprintf( + "replace %s: its %s holds %s, which describe does not print and MDL cannot author, built for %s; "+ + "the replacement takes %s from %s, so the widgets would be kept, hidden and still bound to %s "+ + "(Mendix reports CE1365 once they are shown).", + e.Widget, e.Property, strings.Join(listed, ", "), e.StoredEntity, + e.DataSource, e.NewEntity, e.StoredEntity) + if len(named) == len(e.Widgets) { + msg += fmt.Sprintf(" Drop them first, in the same statement: `drop %s;` before the replace.", strings.Join(named, ", ")) + } + return msg + fmt.Sprintf(" Or `drop %s` and `insert` the new widget, which builds it from the statement alone.", e.Widget) +} + +func (e *StaleWidgetContentError) widgetType(i int) string { + if i < len(e.WidgetTypes) && e.WidgetTypes[i] != "" { + return e.WidgetTypes[i] + } + return "widget" +} diff --git a/mdl/backend/widgetobj/builder.go b/mdl/backend/widgetobj/builder.go index caf29dd272..09582396cb 100644 --- a/mdl/backend/widgetobj/builder.go +++ b/mdl/backend/widgetobj/builder.go @@ -1865,13 +1865,17 @@ func SerializeColumnClientTemplateParameter(param *pages.ClientTemplateParameter paramID = bsonutil.IDToBsonBinary(string(param.ID)) } + // An attribute reached over associations carries its hops in EntityRef. Left + // null, mxbuild reads the attribute against the widget's context entity: + // CE1365 "Attribute 'M.Child.Label' is not an attribute of entity 'M.Parent'" + // (mendixlabs/mxcli#1494). The executor resolved the steps all along. var attrRefBSON any if param.AttributeRef != "" { attrRefBSON = bson.D{ {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, {Key: "$Type", Value: "DomainModels$AttributeRef"}, {Key: "Attribute", Value: param.AttributeRef}, - {Key: "EntityRef", Value: nil}, + {Key: "EntityRef", Value: attributeEntityRefBSON(param.AttributeRefSteps)}, } } diff --git a/mdl/backend/widgetobj/template_param_association_test.go b/mdl/backend/widgetobj/template_param_association_test.go new file mode 100644 index 0000000000..dcc266141d --- /dev/null +++ b/mdl/backend/widgetobj/template_param_association_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 + +package widgetobj + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// firstTemplateParameter returns the one Forms$ClientTemplateParameter of a +// client template built from a single parameter. +func firstTemplateParameter(t *testing.T, p *pages.ClientTemplateParameter) bson.D { + t.Helper() + tmpl := BuildClientTemplateWithTextAndParams("{1}", []*pages.ClientTemplateParameter{p}) + params, ok := findField(t, tmpl, "Parameters").(bson.A) + if !ok || len(params) != 2 { + t.Fatalf("Parameters = %#v, want the marker and one parameter", findField(t, tmpl, "Parameters")) + } + param, ok := params[1].(bson.D) + if !ok { + t.Fatalf("Parameters[1] is %T, not a document", params[1]) + } + return param +} + +// mendixlabs/mxcli#1494: a pluggable widget's text-template parameter bound +// across an association (`legendParams: ({1} = Repro.Parent_Child/Label)`) +// resolved to the right attribute and the right steps, and the serializer threw +// the steps away: AttributeRef.EntityRef was written null, so mxbuild read +// Repro.Child.Label against the widget's context entity — CE1365 "Attribute +// 'Repro.Child.Label' is not an attribute of entity 'Repro.Parent'." The same +// serializer writes Data grid 2 column templates and object-list item templates. +func TestBuildClientTemplateWithTextAndParams_AssociationSteps(t *testing.T) { + param := firstTemplateParameter(t, &pages.ClientTemplateParameter{ + AttributeRef: "Repro.Child.Label", + AttributeRefSteps: []pages.AttributeRefStep{ + {Association: "Repro.Parent_Child", DestinationEntity: "Repro.Child"}, + }, + }) + attrRef, ok := findField(t, param, "AttributeRef").(bson.D) + if !ok { + t.Fatalf("AttributeRef is %T, not a document", findField(t, param, "AttributeRef")) + } + if a := findField(t, attrRef, "Attribute"); a != "Repro.Child.Label" { + t.Errorf("Attribute = %v, want Repro.Child.Label", a) + } + entityRef, ok := findField(t, attrRef, "EntityRef").(bson.D) + if !ok { + t.Fatalf("EntityRef is %v, want a DomainModels$IndirectEntityRef — the association hop was dropped "+ + "(CE1365: Repro.Child.Label read against the context entity)", findField(t, attrRef, "EntityRef")) + } + if tp := findField(t, entityRef, "$Type"); tp != "DomainModels$IndirectEntityRef" { + t.Errorf("EntityRef.$Type = %v, want DomainModels$IndirectEntityRef", tp) + } + steps, ok := findField(t, entityRef, "Steps").(bson.A) + if !ok || len(steps) != 2 { + t.Fatalf("Steps = %#v, want the marker and one step", findField(t, entityRef, "Steps")) + } + step, ok := steps[1].(bson.D) + if !ok { + t.Fatalf("Steps[1] is %T, not a document", steps[1]) + } + if a := findField(t, step, "Association"); a != "Repro.Parent_Child" { + t.Errorf("step Association = %v, want Repro.Parent_Child", a) + } + if d := findField(t, step, "DestinationEntity"); d != "Repro.Child" { + t.Errorf("step DestinationEntity = %v, want Repro.Child", d) + } +} + +// The control: an attribute of the context entity itself has no steps, and its +// EntityRef stays null — what Studio Pro stores, and what this wrote before. +func TestBuildClientTemplateWithTextAndParams_OwnAttributeHasNoEntityRef(t *testing.T) { + param := firstTemplateParameter(t, &pages.ClientTemplateParameter{AttributeRef: "Repro.Parent.Name"}) + attrRef, ok := findField(t, param, "AttributeRef").(bson.D) + if !ok { + t.Fatalf("AttributeRef is %T, not a document", findField(t, param, "AttributeRef")) + } + if e := findField(t, attrRef, "EntityRef"); e != nil { + t.Errorf("EntityRef = %#v, want null for an own-entity attribute", e) + } +} + +// mendixlabs/mxcli#1495: an expression parameter (`{1} = if 1 = 1 then 'A' +// else 'B'`) is the parameter's Expression, with no AttributeRef. v0.24.0 put +// the expression text in AttributeRef.Attribute, and mxbuild answered +// +// CE1613 "The selected attribute 'Repro.Parent.if1=1then'A'else'B'' no longer exists." +// +// #823 fixed where the executor puts it; this pins that the pluggable +// serializer keeps it there. +func TestBuildClientTemplateWithTextAndParams_ExpressionParameter(t *testing.T) { + param := firstTemplateParameter(t, &pages.ClientTemplateParameter{Expression: "if 1 = 1 then 'A' else 'B'"}) + if a := findField(t, param, "AttributeRef"); a != nil { + t.Errorf("AttributeRef = %#v, want null for an expression parameter", a) + } + if e := findField(t, param, "Expression"); e != "if 1 = 1 then 'A' else 'B'" { + t.Errorf("Expression = %v, want the expression as written", e) + } +} diff --git a/mdl/executor/alter_entity_event_handlers_test.go b/mdl/executor/alter_entity_event_handlers_test.go new file mode 100644 index 0000000000..5532610f04 --- /dev/null +++ b/mdl/executor/alter_entity_event_handlers_test.go @@ -0,0 +1,231 @@ +// 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/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// eventHandlerTestCtx builds an ExecContext over M.E carrying the given stored +// handlers, with microflows M.First, M.Second and M.Third resolvable. It returns +// the handlers the last UpdateEntity wrote (nil when nothing was written). +func eventHandlerTestCtx(t *testing.T, stored ...*domainmodel.EventHandler) (*ExecContext, *[]*domainmodel.EventHandler) { + t.Helper() + mod := mkModule("M") + ent := &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: nextID("ent")}, + ContainerID: nextID("dm"), + Name: "E", + Persistable: true, + EventHandlers: stored, + } + dm := &domainmodel.DomainModel{ + BaseElement: model.BaseElement{ID: nextID("dm")}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{ent}, + } + h := mkHierarchy(mod) + withContainer(h, dm.ID, mod.ID) + withContainer(h, ent.ContainerID, dm.ID) + mfs := []*microflows.Microflow{ + mkMicroflow(mod.ID, "First"), + mkMicroflow(mod.ID, "Second"), + mkMicroflow(mod.ID, "Third"), + } + + written := new([]*domainmodel.EventHandler) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return []*domainmodel.DomainModel{dm}, nil }, + GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return mfs, nil }, + UpdateEntityFunc: func(dmID model.ID, e *domainmodel.Entity) error { + *written = append([]*domainmodel.EventHandler{}, e.EventHandlers...) + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + return ctx, written +} + +func storedHandler(moment, event, mf string) *domainmodel.EventHandler { + return &domainmodel.EventHandler{ + BaseElement: model.BaseElement{ID: nextID("eh")}, + Moment: domainmodel.EventMoment(moment), + Event: domainmodel.EventType(event), + MicroflowName: mf, + PassEventObject: true, + } +} + +func handlerMicroflows(ehs []*domainmodel.EventHandler) []string { + out := make([]string, 0, len(ehs)) + for _, eh := range ehs { + out = append(out, eh.MicroflowName) + } + return out +} + +func addHandlerStmt(mf string, ifNotExists bool) *ast.AlterEntityStmt { + return &ast.AlterEntityStmt{ + Name: ast.QualifiedName{Module: "M", Name: "E"}, + Operation: ast.AlterEntityAddEventHandler, + EventHandler: &ast.EventHandlerDef{ + Moment: "Before", + Event: "Commit", + Microflow: ast.QualifiedName{Module: "M", Name: mf}, + PassEventObject: true, + }, + IfNotExists: ifNotExists, + } +} + +func dropHandlerStmt(mf string, ifExists bool) *ast.AlterEntityStmt { + eh := &ast.EventHandlerDef{Moment: "Before", Event: "Commit"} + if mf != "" { + eh.Microflow = ast.QualifiedName{Module: "M", Name: mf} + } + return &ast.AlterEntityStmt{ + Name: ast.QualifiedName{Module: "M", Name: "E"}, + Operation: ast.AlterEntityDropEventHandler, + EventHandler: eh, + IfExists: ifExists, + } +} + +func outputOf(ctx *ExecContext) string { + return ctx.Output.(interface{ String() string }).String() +} + +// mendixlabs/mxcli#1540: "ALTER ENTITY M.E ADD EVENT HANDLER ON BEFORE COMMIT +// CALL M.Second($currentObject) fails with "event handler already exists for +// Before Commit" when the entity already has one." Studio Pro allows several +// handlers for one event, and CREATE OR MODIFY already stores both. +func TestAlterEntityAddSecondHandlerForSameEvent(t *testing.T) { + ctx, written := eventHandlerTestCtx(t, storedHandler("Before", "Commit", "M.First")) + err := execAlterEntity(ctx, addHandlerStmt("Second", false)) + assertNoError(t, err) + got := handlerMicroflows(*written) + if strings.Join(got, ",") != "M.First,M.Second" { + t.Fatalf("handlers written = %v, want [M.First M.Second] (the stored one kept, the new one appended)", got) + } +} + +// The identity of a handler is its moment, event AND microflow: the same +// microflow on the same event twice is still a duplicate, and still refused. +func TestAlterEntityAddSameHandlerTwiceStillErrors(t *testing.T) { + ctx, written := eventHandlerTestCtx(t, storedHandler("Before", "Commit", "M.First")) + err := execAlterEntity(ctx, addHandlerStmt("First", false)) + if err == nil { + t.Fatal("expected an already-exists error for the same microflow on the same event") + } + if !strings.Contains(err.Error(), "M.First") || !strings.Contains(err.Error(), "IF NOT EXISTS") { + t.Errorf("error should name the microflow and the guard, got: %v", err) + } + if *written != nil { + t.Error("expected no UpdateEntity for a refused duplicate") + } +} + +// IF NOT EXISTS skips only when THIS handler is present. It used to skip when +// any handler for the event was present — reporting "already exists" for a +// microflow that was never wired. +func TestAlterEntityAddHandlerIfNotExistsKeysOnMicroflow(t *testing.T) { + ctx, written := eventHandlerTestCtx(t, storedHandler("Before", "Commit", "M.First")) + assertNoError(t, execAlterEntity(ctx, addHandlerStmt("Second", true))) + if got := handlerMicroflows(*written); strings.Join(got, ",") != "M.First,M.Second" { + t.Fatalf("handlers written = %v, want [M.First M.Second]", got) + } + + ctx2, written2 := eventHandlerTestCtx(t, storedHandler("Before", "Commit", "M.First")) + assertNoError(t, execAlterEntity(ctx2, addHandlerStmt("First", true))) + if *written2 != nil { + t.Error("expected no UpdateEntity when the same handler is already present") + } + if out := outputOf(ctx2); !strings.Contains(out, "already exists") || !strings.Contains(out, "M.First") { + t.Errorf("expected a skip notice naming M.First, got: %q", out) + } +} + +// With several handlers on one event, DROP … CALL M.X removes that one only. +func TestAlterEntityDropHandlerByMicroflow(t *testing.T) { + ctx, written := eventHandlerTestCtx(t, + storedHandler("Before", "Commit", "M.First"), + storedHandler("Before", "Commit", "M.Second"), + storedHandler("Before", "Commit", "M.Third")) + assertNoError(t, execAlterEntity(ctx, dropHandlerStmt("Second", false))) + if got := handlerMicroflows(*written); strings.Join(got, ",") != "M.First,M.Third" { + t.Fatalf("handlers written = %v, want [M.First M.Third]", got) + } +} + +// A bare DROP that matches several handlers cannot know which one is meant. +// Taking the first one would drop a handler the author may have wanted kept, +// so it is refused, and the error names the candidates and the CALL form. +func TestAlterEntityBareDropAmbiguousIsRefused(t *testing.T) { + ctx, written := eventHandlerTestCtx(t, + storedHandler("Before", "Commit", "M.First"), + storedHandler("Before", "Commit", "M.Second")) + err := execAlterEntity(ctx, dropHandlerStmt("", false)) + if err == nil { + t.Fatal("expected a bare DROP over two Before Commit handlers to be refused") + } + for _, want := range []string{"M.First", "M.Second", "call"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should mention %q, got: %v", want, err) + } + } + if *written != nil { + t.Error("expected no UpdateEntity for an ambiguous drop") + } + + // IF EXISTS answers "is it absent?", not "which one?" — still refused. + ctx2, _ := eventHandlerTestCtx(t, + storedHandler("Before", "Commit", "M.First"), + storedHandler("Before", "Commit", "M.Second")) + if err := execAlterEntity(ctx2, dropHandlerStmt("", true)); err == nil { + t.Fatal("expected an ambiguous DROP IF EXISTS to be refused too") + } +} + +// The single-handler case keeps working exactly as before, including the +// handler on another event being left alone. +func TestAlterEntityBareDropSingleHandler(t *testing.T) { + ctx, written := eventHandlerTestCtx(t, + storedHandler("After", "Commit", "M.Third"), + storedHandler("Before", "Commit", "M.First")) + assertNoError(t, execAlterEntity(ctx, dropHandlerStmt("", false))) + if got := handlerMicroflows(*written); strings.Join(got, ",") != "M.Third" { + t.Fatalf("handlers written = %v, want [M.Third]", got) + } +} + +// DROP … CALL M.X IF EXISTS on an absent handler skips; without the guard it +// errors — even when another handler for the same event is present. +func TestAlterEntityDropHandlerByMicroflowMissing(t *testing.T) { + ctx, written := eventHandlerTestCtx(t, storedHandler("Before", "Commit", "M.First")) + assertNoError(t, execAlterEntity(ctx, dropHandlerStmt("Second", true))) + if *written != nil { + t.Error("expected no UpdateEntity when the named handler is absent") + } + if out := outputOf(ctx); !strings.Contains(out, "M.Second") || !strings.Contains(out, "skipping") { + t.Errorf("expected a skip notice naming M.Second, got: %q", out) + } + + ctx2, _ := eventHandlerTestCtx(t, storedHandler("Before", "Commit", "M.First")) + err := execAlterEntity(ctx2, dropHandlerStmt("Second", false)) + if err == nil { + t.Fatal("expected a not-found error for an absent named handler") + } + if !strings.Contains(err.Error(), "M.Second") { + t.Errorf("error should name M.Second, got: %v", err) + } +} diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index 3da5300117..fe90dd1e8e 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -579,6 +579,7 @@ func convertASTAction(ctx *ExecContext, mutator backend.PageMutator, value any, themeRegistry: ctx.GetThemeRegistry(), widgetBackend: ctx.Backend, localVariables: storedPageVariables(mutator), + isDocumentWidget: alterDocumentWidgets(mutator, nil), isSnippet: mutator.ContainerType() == backend.ContainerSnippet, } return pb.buildClientActionV3(action) @@ -770,7 +771,13 @@ func applyReplaceWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op // attribute is required when column sorting is enabled" (#1468). if op.Target.IsColumn() && allColumns(op.NewWidgets) { entityCtx, _ := alterEntityContext(ctx, mutator, op.Target.Widget, true, moduleName, moduleID) - specs, err := buildColumnSpecsFromAST(ctx, op.NewWidgets, moduleName, moduleID, entityCtx, mutator, op.Target.Widget, columnRefOf(op.Target)) + // What the replaced column contains — its filter, its custom content — + // leaves the name scope with it, so the replacement may reuse the names. + exclude := []string{op.Target.Widget, columnRefOf(op.Target)} + for n := range replaced { + exclude = append(exclude, n) + } + specs, err := buildColumnSpecsFromAST(ctx, op.NewWidgets, moduleName, moduleID, entityCtx, mutator, exclude...) if err != nil { return mdlerrors.NewBackend("build replacement column specs", err) } @@ -835,22 +842,80 @@ func replacePluggableKeepingUnstated(ctx *ExecContext, mutator backend.PageMutat widgets []pages.Widget, storedWidgets func() map[string]*ast.WidgetV3, moduleName string, moduleID model.ID, entityCtx string, exclude []string) (bool, error) { keeper, ok := mutator.(pluggableKeepingReplacer) - if !ok || storedWidgets == nil || op.Target.Column != "" || op.Target.IsColumnAddress() || - len(widgets) != 1 || len(op.NewWidgets) != 1 { + if !ok { return false, nil } - if _, isPluggable := widgets[0].(*pages.CustomWidget); !isPluggable { + baseline, ok := pluggableReplaceBaseline(ctx, mutator, op, widgets, storedWidgets, moduleName, moduleID, entityCtx, exclude) + if !ok { return false, nil } + return keeper.ReplacePluggableKeepingUnstated(op.Target.Widget, widgets[0], baseline) +} + +// pluggableReplaceBaseline builds the stored widget as describe prints it, the +// baseline the #1247 merge compares the replacement with; ok is false when the +// merge does not apply — not exactly one pluggable widget for one of the same +// kind, no description of the stored one, or a baseline that does not build. +func pluggableReplaceBaseline(ctx *ExecContext, mutator backend.PageMutator, op *ast.ReplaceWidgetOp, + widgets []pages.Widget, storedWidgets func() map[string]*ast.WidgetV3, + moduleName string, moduleID model.ID, entityCtx string, exclude []string) (pages.Widget, bool) { + if storedWidgets == nil || op.Target.Column != "" || op.Target.IsColumnAddress() || + len(widgets) != 1 || len(op.NewWidgets) != 1 { + return nil, false + } + if _, isPluggable := widgets[0].(*pages.CustomWidget); !isPluggable { + return nil, false + } stored := storedWidgets()[op.Target.Widget] if stored == nil || !strings.EqualFold(stored.Type, op.NewWidgets[0].Type) { - return false, nil + return nil, false } baseline, err := buildWidgetsFromAST(ctx, []*ast.WidgetV3{replaceBaseline(stored, op.NewWidgets[0])}, moduleName, moduleID, entityCtx, mutator, exclude...) if err != nil || len(baseline) != 1 { - return false, nil + return nil, false } - return keeper.ReplacePluggableKeepingUnstated(op.Target.Widget, widgets[0], baseline[0]) + return baseline[0], true +} + +// pluggableReplaceChecker is the check-time half of pluggableKeepingReplacer: +// the refusal ReplacePluggableKeepingUnstated would give, without the write. +type pluggableReplaceChecker interface { + CheckPluggableReplace(widgetRef string, replacement, baseline pages.Widget) error +} + +// refuseStaleContentReplace is the check -p half of the refusal a pluggable +// REPLACE gives for content it would carry onto another entity +// (backend.StaleWidgetContentError, ako/mxcli#1563): the replacement built +// as exec builds it, and the stored document asked, without writing. A +// replacement that does not build here is exec's to report, so nothing is +// said for it. +func refuseStaleContentReplace(ctx *ExecContext, mutator backend.PageMutator, op *ast.ReplaceWidgetOp, + moduleName string, moduleID model.ID, storedWidgets func() map[string]*ast.WidgetV3) error { + checker, ok := mutator.(pluggableReplaceChecker) + if !ok || op.Target.Item != nil || isFooterRegionTarget(op.Target) { + return nil + } + newWidgets, err := expandAlterFragments(ctx, op.NewWidgets, moduleName, moduleID) + if err != nil { + return nil + } + expanded := *op + expanded.NewWidgets = newWidgets + op = &expanded + entityCtx, _ := alterEntityContext(ctx, mutator, op.Target.Widget, false, moduleName, moduleID) + exclude := []string{op.Target.Widget, columnRefOf(op.Target)} + for n := range replacedWidgetNames(mutator, op.Target) { + exclude = append(exclude, n) + } + widgets, err := buildWidgetsFromAST(ctx, op.NewWidgets, moduleName, moduleID, entityCtx, mutator, exclude...) + if err != nil { + return nil + } + baseline, ok := pluggableReplaceBaseline(ctx, mutator, op, widgets, storedWidgets, moduleName, moduleID, entityCtx, exclude) + if !ok { + return nil + } + return checker.CheckPluggableReplace(op.Target.Widget, widgets[0], baseline) } // replaceBaselineSystemProps are the widget-level settings describe prints for @@ -1058,6 +1123,7 @@ func buildColumnSpecsFromAST(ctx *ExecContext, widgets []*ast.WidgetV3, moduleNa themeRegistry: ctx.GetThemeRegistry(), widgetBackend: ctx.Backend, localVariables: storedPageVariables(mutator), + isDocumentWidget: alterDocumentWidgets(mutator, widgets), isSnippet: mutator.ContainerType() == backend.ContainerSnippet, } @@ -1134,6 +1200,7 @@ func resolveDataSourceFlowEntity(ctx *ExecContext, moduleName string, moduleID m // used when replacing a widget so the new one may reuse the target's name. func buildWidgetsFromAST(ctx *ExecContext, widgets []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string, mutator backend.PageMutator, excludeFromScope ...string) ([]pages.Widget, error) { pb := newAlterPageBuilder(ctx, moduleName, moduleID, entityContext, mutator, excludeFromScope...) + pb.isDocumentWidget = alterDocumentWidgets(mutator, widgets) var result []pages.Widget for _, w := range widgets { @@ -1153,6 +1220,7 @@ func buildWidgetsFromAST(ctx *ExecContext, widgets []*ast.WidgetV3, moduleName s // with the same builder CREATE PAGE uses for a tab container's children. func buildTabPagesFromAST(ctx *ExecContext, nodes []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string, mutator backend.PageMutator) ([]*pages.TabPage, error) { pb := newAlterPageBuilder(ctx, moduleName, moduleID, entityContext, mutator) + pb.isDocumentWidget = alterDocumentWidgets(mutator, nodes) out := make([]*pages.TabPage, 0, len(nodes)) for _, node := range nodes { tp, err := pb.buildTabPageV3(node) @@ -1168,6 +1236,7 @@ func buildTabPagesFromAST(ctx *ExecContext, nodes []*ast.WidgetV3, moduleName st // navigation list, with the same builder CREATE uses for a list's children. func buildNavigationListItemsFromAST(ctx *ExecContext, nodes []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string, mutator backend.PageMutator, excludeFromScope ...string) ([]*pages.NavigationListItem, error) { pb := newAlterPageBuilder(ctx, moduleName, moduleID, entityContext, mutator, excludeFromScope...) + pb.isDocumentWidget = alterDocumentWidgets(mutator, nodes) out := make([]*pages.NavigationListItem, 0, len(nodes)) for _, node := range nodes { item, err := pb.buildNavigationListItemV3(node) @@ -1228,6 +1297,14 @@ func hasTabPage(widgets []*ast.WidgetV3) bool { return false } +// alterDocumentWidgets is isDocumentWidget for an ALTER: a widget the stored +// page already has, or one the statement adds with it (ako/mxcli#1560). +func alterDocumentWidgets(mutator backend.PageMutator, added []*ast.WidgetV3) func(name string) bool { + names := map[string]bool{} + collectWidgetNames(added, names) + return func(name string) bool { return names[name] || mutator.FindWidget(name) } +} + // newAlterPageBuilder returns a page builder scoped to the stored page an ALTER // edits: its parameters, its widget names and its local variables. func newAlterPageBuilder(ctx *ExecContext, moduleName string, moduleID model.ID, entityContext string, mutator backend.PageMutator, excludeFromScope ...string) *pageBuilder { @@ -1251,6 +1328,7 @@ func newAlterPageBuilder(ctx *ExecContext, moduleName string, moduleID model.ID, themeRegistry: ctx.GetThemeRegistry(), widgetBackend: ctx.Backend, localVariables: storedPageVariables(mutator), + isDocumentWidget: alterDocumentWidgets(mutator, nil), isSnippet: mutator.ContainerType() == backend.ContainerSnippet, } } diff --git a/mdl/executor/cmd_entities.go b/mdl/executor/cmd_entities.go index c4e6324026..e486a0eb0b 100644 --- a/mdl/executor/cmd_entities.go +++ b/mdl/executor/cmd_entities.go @@ -49,6 +49,33 @@ func buildEventHandlers(ctx *ExecContext, defs []ast.EventHandlerDef) ([]*domain return handlers, nil } +// matchEventHandlers returns the indexes of the entity's handlers for moment and +// event, narrowed to those calling mfQN when it is non-empty. An entity may carry +// several handlers for one event (mendixlabs/mxcli#1540), so (moment, event) alone +// is not an identity — the microflow is part of it. +func matchEventHandlers(ctx *ExecContext, entity *domainmodel.Entity, moment, event, mfQN string) []int { + var out []int + for i, eh := range entity.EventHandlers { + if eh.Moment != domainmodel.EventMoment(moment) || eh.Event != domainmodel.EventType(event) { + continue + } + if mfQN != "" && storedHandlerMicroflow(ctx, eh) != mfQN { + continue + } + out = append(out, i) + } + return out +} + +// storedHandlerMicroflow is the qualified name of the microflow a stored handler +// calls. The read path fills MicroflowName; the ID is the fallback, as in describe. +func storedHandlerMicroflow(ctx *ExecContext, eh *domainmodel.EventHandler) string { + if eh.MicroflowName == "" && eh.MicroflowID != "" { + return lookupMicroflowName(ctx, eh.MicroflowID) + } + return eh.MicroflowName +} + // checkBeforeCreateHandlerHasNoParameters refuses to wire a microflow that takes // parameters to a BEFORE CREATE event handler. // @@ -1868,61 +1895,77 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { if err != nil { return err } - // Reject duplicate (same Moment + Event) - for _, existing := range entity.EventHandlers { - if existing.Moment == ehs[0].Moment && existing.Event == ehs[0].Event { - // An event handler has no other re-run route: a defensive - // drop-then-add fails on the drop when it is absent and on the - // add when it is present. IF NOT EXISTS makes the script - // idempotent, like ADD ATTRIBUTE. (mxcli-todo findings #18) - if s.IfNotExists { - fmt.Fprintf(ctx.Output, "Event handler %s %s already exists on %s, skipping\n", - s.EventHandler.Moment, s.EventHandler.Event, s.Name) - return nil - } - return mdlerrors.NewAlreadyExistsMsg("event handler", - fmt.Sprintf("%s %s", s.EventHandler.Moment, s.EventHandler.Event), - fmt.Sprintf("event handler already exists for %s %s on %s (use ADD EVENT HANDLER IF NOT EXISTS to make the script re-runnable)", s.EventHandler.Moment, s.EventHandler.Event, s.Name)) + // A handler is identified by its moment, event AND microflow: Studio + // Pro allows several handlers for one event, and CREATE OR MODIFY + // stores them, so only the same microflow on the same event is a + // duplicate (mendixlabs/mxcli#1540). mxbuild accepts even that + // (measured 11.12.4, 0 errors); refusing it is what stops a re-run + // from wiring the microflow twice, so it would run twice per commit. + mfQN := s.EventHandler.Microflow.String() + label := fmt.Sprintf("%s %s call %s", s.EventHandler.Moment, s.EventHandler.Event, mfQN) + if matches := matchEventHandlers(ctx, entity, s.EventHandler.Moment, s.EventHandler.Event, mfQN); len(matches) > 0 { + // An event handler has no other re-run route: a defensive + // drop-then-add fails on the drop when it is absent and on the + // add when it is present. IF NOT EXISTS makes the script + // idempotent, like ADD ATTRIBUTE. (mxcli-todo findings #18) + if s.IfNotExists { + fmt.Fprintf(ctx.Output, "Event handler %s already exists on %s, skipping\n", label, s.Name) + return nil } + return mdlerrors.NewAlreadyExistsMsg("event handler", label, + fmt.Sprintf("event handler %s already exists on %s (use ADD EVENT HANDLER IF NOT EXISTS to make the script re-runnable)", label, s.Name)) } entity.EventHandlers = append(entity.EventHandlers, ehs[0]) if err := ctx.Backend.UpdateEntity(dm.ID, entity); err != nil { return mdlerrors.NewBackend("add event handler", err) } invalidateDomainModelsCache(ctx) - fmt.Fprintf(ctx.Output, "Added event handler %s %s on %s\n", - s.EventHandler.Moment, s.EventHandler.Event, s.Name) + fmt.Fprintf(ctx.Output, "Added event handler %s on %s\n", label, s.Name) case ast.AlterEntityDropEventHandler: if s.EventHandler == nil { return mdlerrors.NewValidation("missing event handler reference") } - moment := domainmodel.EventMoment(s.EventHandler.Moment) - event := domainmodel.EventType(s.EventHandler.Event) - idx := -1 - for i, eh := range entity.EventHandlers { - if eh.Moment == moment && eh.Event == event { - idx = i - break - } + mfQN := "" + if s.EventHandler.Microflow.Name != "" { + mfQN = s.EventHandler.Microflow.String() } - if idx < 0 { + label := fmt.Sprintf("%s %s", s.EventHandler.Moment, s.EventHandler.Event) + if mfQN != "" { + label += " call " + mfQN + } + matches := matchEventHandlers(ctx, entity, s.EventHandler.Moment, s.EventHandler.Event, mfQN) + if len(matches) == 0 { if s.IfExists { - fmt.Fprintf(ctx.Output, "Event handler %s %s not present on %s, skipping\n", - s.EventHandler.Moment, s.EventHandler.Event, s.Name) + fmt.Fprintf(ctx.Output, "Event handler %s not present on %s, skipping\n", label, s.Name) return nil } - return mdlerrors.NewNotFoundMsg("event handler", - fmt.Sprintf("%s %s", s.EventHandler.Moment, s.EventHandler.Event), - fmt.Sprintf("event handler %s %s not found on %s (use DROP EVENT HANDLER IF EXISTS to make the script re-runnable)", s.EventHandler.Moment, s.EventHandler.Event, s.Name)) + return mdlerrors.NewNotFoundMsg("event handler", label, + fmt.Sprintf("event handler %s not found on %s (use DROP EVENT HANDLER IF EXISTS to make the script re-runnable)", label, s.Name)) + } + // Without CALL the drop has to be unambiguous. Taking the first match + // would remove whichever handler happens to be stored first, which + // is not necessarily the one the author meant — and IF EXISTS does + // not help, since the question is which, not whether. + if len(matches) > 1 { + names := make([]string, 0, len(matches)) + for _, i := range matches { + names = append(names, storedHandlerMicroflow(ctx, entity.EventHandlers[i])) + } + return mdlerrors.NewValidationf("entity %s has %d %s event handlers (%s); name the one to drop: drop event handler on %s %s call %s", + s.Name, len(matches), label, strings.Join(names, ", "), + strings.ToLower(s.EventHandler.Moment), strings.ToLower(s.EventHandler.Event), names[0]) + } + idx := matches[0] + if mfQN == "" { + label += " call " + storedHandlerMicroflow(ctx, entity.EventHandlers[idx]) } entity.EventHandlers = append(entity.EventHandlers[:idx], entity.EventHandlers[idx+1:]...) if err := ctx.Backend.UpdateEntity(dm.ID, entity); err != nil { return mdlerrors.NewBackend("drop event handler", err) } invalidateDomainModelsCache(ctx) - fmt.Fprintf(ctx.Output, "Dropped event handler %s %s from %s\n", - s.EventHandler.Moment, s.EventHandler.Event, s.Name) + fmt.Fprintf(ctx.Output, "Dropped event handler %s from %s\n", label, s.Name) case ast.AlterEntitySetAllowCreateChangeLocally: entity.CreateChangeLocally = s.BoolValue diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index 2252e93a07..9a599a20c1 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -91,7 +91,8 @@ func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID { fb.textLangOrDefault(): templateText, }, }, - TemplateParameters: templateParams, + TemplateParameters: templateParams, + IncludeLastStackTrace: s.IncludeLatestStackTrace, } activity := µflows.ActionActivity{ @@ -1160,6 +1161,11 @@ func (fb *flowBuilder) addShowPageAction(s *ast.ShowPageStmt) model.ID { action.PassedObject = "$" + s.ForObject } + // "Number of pages to close" (mendixlabs/mxcli#1617); none when unstated. + if s.NumberOfPagesToClose != nil { + action.NumberOfPagesToClose = fb.exprToString(s.NumberOfPagesToClose) + } + // Set title override if specified if s.Title != "" { action.OverridePageTitle = &model.Text{ diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index b4c35499f4..36cdbd362c 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -756,6 +756,11 @@ func formatAction( } withClause = fmt.Sprintf(" with (%s)", strings.Join(params, ", ")) } + // "Include latest stack trace" (mendixlabs/mxcli#1543). Unticked is what + // an absent clause builds, so only a ticked box is printed. + if a.IncludeLastStackTrace { + withClause += " with stacktrace" + } // Level info and node 'Application' are what an unstated level and node // build (addLogMessageAction), so neither is printed (R12, #748). @@ -949,11 +954,21 @@ func formatAction( // taking it out of a create or modify compared as no change // (ako/mxcli#869). An override without text has no statement form: // `with title = ''` builds none. - titleStr := "" + var settings []string if title := pickTextTranslation(a.OverridePageTitle, describeDefaultLanguage(ctx)); title != "" { - titleStr = " with title = " + mdlQuote(ctx, title) + settings = append(settings, "title = "+mdlQuote(ctx, title)) + } + // "Number of pages to close". Left out, an action that closes the + // current page read exactly like one that closes none + // (mendixlabs/mxcli#1617). "" is Studio Pro's default: none. + if n := strings.TrimSpace(a.NumberOfPagesToClose); n != "" { + settings = append(settings, "NumberOfPagesToClose = "+describeExpr(ctx, n)) + } + withStr := "" + if len(settings) > 0 { + withStr = " with " + strings.Join(settings, ", ") } - return fmt.Sprintf("show page %s%s%s;", pageName, paramStr, titleStr) + return fmt.Sprintf("show page %s%s%s;", pageName, paramStr, withStr) case *microflows.ClosePageAction: if a.NumberOfPages > 1 { diff --git a/mdl/executor/cmd_microflows_format_action_test.go b/mdl/executor/cmd_microflows_format_action_test.go index 39b36b0b91..eb510f46bb 100644 --- a/mdl/executor/cmd_microflows_format_action_test.go +++ b/mdl/executor/cmd_microflows_format_action_test.go @@ -556,6 +556,30 @@ func TestFormatAction_ShowPage_WithTitle(t *testing.T) { } } +// "Number of pages to close" is described, so an action that closes the +// current page before opening reads differently from one that closes none, +// and describe -> exec keeps it (mendixlabs/mxcli#1617). +func TestFormatAction_ShowPage_NumberOfPagesToClose(t *testing.T) { + e := newTestExecutor() + action := µflows.ShowPageAction{PageName: "MyFirstModule.Booking_Overview", NumberOfPagesToClose: "1"} + if got, want := e.formatAction(action, nil, nil), + "show page MyFirstModule.Booking_Overview with NumberOfPagesToClose = 1;"; got != want { + t.Errorf("got %q, want %q", got, want) + } + // With a title, both settings share the one `with` list. + action.OverridePageTitle = &model.Text{Translations: map[string]string{"en_US": "Bookings"}} + action.NumberOfPagesToClose = "$Count + 1" + if got, want := e.formatAction(action, nil, nil), + "show page MyFirstModule.Booking_Overview with title = 'Bookings', NumberOfPagesToClose = $Count + 1;"; got != want { + t.Errorf("got %q, want %q", got, want) + } + // CONTROL: the stored default, "", closes nothing and says nothing. + action.OverridePageTitle, action.NumberOfPagesToClose = nil, "" + if got := e.formatAction(action, nil, nil); got != "show page MyFirstModule.Booking_Overview;" { + t.Errorf("no pages to close: got %q", got) + } +} + func TestFormatAction_ClosePage(t *testing.T) { e := newTestExecutor() action := µflows.ClosePageAction{NumberOfPages: 1} diff --git a/mdl/executor/cmd_pages_builder.go b/mdl/executor/cmd_pages_builder.go index 3b6c7811eb..8521b26e65 100644 --- a/mdl/executor/cmd_pages_builder.go +++ b/mdl/executor/cmd_pages_builder.go @@ -102,6 +102,13 @@ type pageBuilder struct { // Forms$PageVariable.LocalVariable in BSON, not as a literal Expression. localVariables map[string]bool + // isDocumentWidget reports whether a name is a widget of the document being + // built. A flow argument `$name` naming one passes that widget's selection, + // which Studio Pro binds through the PageVariable's Widget slot + // (ako/mxcli#1560). nil where the document's widgets are not known: nothing + // is then a widget, and the argument binds as it always did. + isDocumentWidget func(name string) bool + // storedPluggables are the pluggable widgets of the stored document a // `create or replace`/`create or modify` rewrites; nil for a new document. // A widget the statement did not change keeps its stored Type and Object diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 169da8d7db..4ebe060f64 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -558,7 +558,11 @@ func (pb *pageBuilder) buildPluggable(def *WidgetDefinition, w *ast.WidgetV3) (p if err != nil { return nil, err } - pb.storedPluggables.passStoredThrough(def, w, widget) + if !pb.storedPluggables.passStoredThrough(def, w, widget) { + if err := pb.storedPluggables.refuseLostHiddenContent(def, w); err != nil { + return nil, err + } + } if err := applyWidgetAppearance(widget, w, pb.themeRegistry); err != nil { return nil, err } diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index ec74d5d09c..e8ede20e95 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -223,12 +223,14 @@ func (pb *pageBuilder) buildColumnSpecFromAST(child *ast.WidgetV3) (*backend.Dat Properties: columnSpecProperties(child), } for _, grandchild := range child.Children { - if filterWidgetID := dataGridFilterWidgetID(grandchild.Type); filterWidgetID != "" { - fw, err := pb.widgetBackend.BuildFilterWidget(backend.FilterWidgetSpec{ - WidgetID: filterWidgetID, - FilterName: grandchild.Name, - VisibilityRules: resolveWidgetVisibilityRules(pb.backend.Path(), filterWidgetID), - }, pb.backend.Path()) + if dataGridFilterWidgetID(grandchild.Type) != "" { + // Through the pluggable engine and the filter's own definition, as + // CREATE builds a column's filter. Built from the widget id and name + // alone, the filter's properties were never read: a drop-down + // filter's `attributes: [Status]` was stored as attrChoice "auto" + // with an empty `attr`, check clean (mendixlabs/mxcli#1519 item 2, + // the ALTER path #1422 left). + fw, err := pb.buildWidgetV3(grandchild) if err != nil { return nil, mdlerrors.NewBackend("build column filter widget", err) } diff --git a/mdl/executor/cmd_pages_create_v3.go b/mdl/executor/cmd_pages_create_v3.go index 48b7827095..75bfdb0eb3 100644 --- a/mdl/executor/cmd_pages_create_v3.go +++ b/mdl/executor/cmd_pages_create_v3.go @@ -115,7 +115,8 @@ func execCreatePageV3(ctx *ExecContext, s *ast.CreatePageStmtV3) error { widgetBackend: ctx.Backend, // The root of a document that this pass walks in full: there is no // enclosing data widget, so there is no context object. #1029. - argCtx: atDocumentRoot(), + argCtx: atDocumentRoot(), + isDocumentWidget: widgetNameSet(allPageWidgets(s)), // An excluded page may name documents that do not exist; Mendix does // not validate it, and the check reports them as warnings. The page // stays excluded whether the statement says so or the carry does. @@ -271,7 +272,8 @@ func execCreateSnippetV3(ctx *ExecContext, s *ast.CreateSnippetStmtV3) error { widgetBackend: ctx.Backend, // The root of a document that this pass walks in full: there is no // enclosing data widget, so there is no context object. #1029. - argCtx: atDocumentRoot(), + argCtx: atDocumentRoot(), + isDocumentWidget: widgetNameSet(s.Widgets), // A snippet has no @excluded of its own; it stays excluded through the // carry, and an excluded one may name documents that do not exist. tolerateDanglingRefs: existingExcluded, diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index e6157f7b74..ebc2b1043a 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -972,6 +972,11 @@ type rawWidget struct { // See unreconstructedContainers. OmittedContainers []string + // HiddenContent is a combo box's custom content: widgets MDL cannot author + // (MDL-WIDGET26), named in a comment so the author can see they are there + // (ako/mxcli#1563). See comboBoxHiddenContent. + HiddenContent []rawHiddenContent + // OmittedProps names properties whose stored value differs from the default // and that DESCRIBE has no spelling for (an attribute reached over // associations, …). Emitted as a comment, like OmittedContainers, so a diff --git a/mdl/executor/cmd_pages_describe_bykey.go b/mdl/executor/cmd_pages_describe_bykey.go index 4e47dd5c03..23633784db 100644 --- a/mdl/executor/cmd_pages_describe_bykey.go +++ b/mdl/executor/cmd_pages_describe_bykey.go @@ -389,22 +389,82 @@ var sharedBranchByKeySkip = map[string]map[string]bool{ }, } -// 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", +// comboBoxStaticModePrefixes and comboBoxDatabaseModePrefixes are the keys of +// the combo box's option sources DESCRIBE does not spell in the mode being +// described (withoutUndescribedComboBoxModes): the static +// source always, and the database source outside database mode. Those 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 ( + comboBoxStaticModePrefixes = []string{"optionsSourceStatic", "staticDataSource", "staticAttribute"} + comboBoxDatabaseModePrefixes = []string{"optionsSourceDatabase", "databaseAttributeString"} +) + +// comboBoxDatabaseByKeySkip is sharedBranchByKeySkip for a database-mode combo +// box: the association and enumeration keys stay unprinted, and so do the +// database keys the reader spells as keywords — the target attribute as +// `Attribute:`, the option caption as `CaptionAttribute:` — and `source`, +// which the datasource's key selects on exec. +var comboBoxDatabaseByKeySkip = func() map[string]bool { + skip := map[string]bool{"databaseAttributeString": true, "optionsSourceDatabaseCaptionAttribute": true} + for k, v := range sharedBranchByKeySkip["combobox"] { + skip[k] = v + } + return skip +}() + +// comboBoxIsDatabaseMode reports whether a stored combo box takes its options +// from a database source of its own (`source` "database"). +func comboBoxIsDatabaseMode(ctx *ExecContext, w map[string]any) bool { + return extractCustomWidgetPropertyString(ctx, w, "source") == "database" +} + +// describeComboBoxDatabaseMode reads a database-mode combo box (mendixlabs/ +// mxcli#1583): its option list under the datasource's own key, which is what +// selects the mode on exec — the generic `DataSource:` clause selects the +// association mode — the attribute it stores into as `Attribute:`, the option +// caption as `CaptionAttribute:`, and the change action from the mode's own +// slot. The value attribute and the caption type and expression are settings by +// key (comboBoxDatabaseByKeySkip). +func describeComboBoxDatabaseMode(ctx *ExecContext, w map[string]any, widget *rawWidget) { + if ds := extractCustomWidgetPropertyDataSource(ctx, w, "optionsSourceDatabaseDataSource"); ds != nil { + widget.NamedDataSources = []rawNamedDataSource{{Key: "optionsSourceDatabaseDataSource", DataSource: ds}} + } + widget.Content = extractCustomWidgetPropertyAttributeRef(ctx, w, "databaseAttributeString") + if param := customWidgetPropertyParameter(w, "databaseAttributeString"); param != "" && widget.Content != "" { + widget.Content = parameterAttributeMDL(param, widget.Content) + } + if extractCustomWidgetPropertyString(ctx, w, "optionsSourceDatabaseCaptionType") != "expression" { + widget.CaptionAttribute = extractCustomWidgetPropertyAttributeRef(ctx, w, "optionsSourceDatabaseCaptionAttribute") + } + widget.OnChange = renderClientActionMDL(ctx, customWidgetPropertyActionMap(ctx, w, "onChangeDatabaseEvent")) +} + +// comboBoxDatabaseSelection is the item selection of a database-mode combo +// box, by key, when it is not the Single exec writes for it left out. The +// by-key reader skips every selection-typed property, because a data grid's +// and a gallery's have the `Selection:` keyword. +func comboBoxDatabaseSelection(ctx *ExecContext, w map[string]any) []rawExplicitProp { + sel := extractCustomWidgetPropertySelection(ctx, w, "optionsSourceDatabaseItemSelection") + if sel == "" || sel == "Single" { + return nil + } + return []rawExplicitProp{{Key: "optionsSourceDatabaseItemSelection", Value: sel, IsRef: true}} } // 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) { +// undescribed option-source modes from props to omitted. database says the +// combo box is described in database mode, whose own settings are kept. +func withoutUndescribedComboBoxModes(props []rawExplicitProp, omitted []string, database bool) ([]rawExplicitProp, []string) { + prefixes := comboBoxStaticModePrefixes + if !database { + prefixes = append(append([]string{}, prefixes...), comboBoxDatabaseModePrefixes...) + } kept := props[:0:0] for _, p := range props { undescribed := false - for _, prefix := range comboBoxUndescribedModePrefixes { + for _, prefix := range prefixes { if strings.HasPrefix(p.Key, prefix) { undescribed = true break diff --git a/mdl/executor/cmd_pages_describe_combobox_hidden_content_1563_test.go b/mdl/executor/cmd_pages_describe_combobox_hidden_content_1563_test.go new file mode 100644 index 0000000000..96658e9ca1 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_combobox_hidden_content_1563_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// ako/mxcli#1563: "describe page does not show [a combo box's custom content]", +// so the widgets in it — txtOptionName, bound to Booking.Name — were invisible +// until mx check named one. MDL cannot author custom content (MDL-WIDGET26), +// so describe cannot print it as MDL; it now says it is there, by widget name, +// which is also what the replace refusal tells the author to drop. + +// comboFromTemplate is an untouched combo box: the shipped template's schema +// and default values. +func comboFromTemplate(t *testing.T) map[string]any { + return widgetFromTemplate(t, comboBoxWidgetID, "cbBooking") +} + +func TestDescribeComboBoxNamesItsHiddenCustomContent(t *testing.T) { + w := comboFromTemplate(t) + gridValue(t, w, "optionsSourceAssociationCustomContentType")["PrimitiveValue"] = "yes" + gridValue(t, w, "optionsSourceAssociationCustomContent")["Widgets"] = []any{2, + map[string]any{"$Type": "Forms$DynamicText", "Name": "txtOptionName"}, + map[string]any{"$Type": "Forms$Image"}, + } + + got := describeGrid(t, w, "") + if len(got.HiddenContent) != 1 { + t.Fatalf("HiddenContent = %+v, want one slot", got.HiddenContent) + } + hc := got.HiddenContent[0] + if hc.Property != "optionsSourceAssociationCustomContent" || strings.Join(hc.Widgets, ",") != "txtOptionName,an unnamed image" { + t.Errorf("HiddenContent = %+v", hc) + } + + var buf bytes.Buffer + outputWidgetMDLV3(&ExecContext{Output: &buf}, got, 0) + out := buf.String() + for _, want := range []string{"-- NOT SHOWN:", "cbBooking", "optionsSourceAssociationCustomContent", "txtOptionName"} { + if !strings.Contains(out, want) { + t.Errorf("description lacks %q:\n%s", want, out) + } + } + // A comment, so the description still parses. + if _, errs := visitor.Build("create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) {\n" + out + "};"); len(errs) > 0 { + t.Errorf("description no longer parses: %v\n%s", errs, out) + } +} + +// Control: an untouched combo box describes exactly as before. +func TestDescribeComboBoxWithoutCustomContentHasNoNote(t *testing.T) { + got := describeGrid(t, comboFromTemplate(t), "") + if len(got.HiddenContent) != 0 { + t.Errorf("untouched combo box reported hidden content %+v", got.HiddenContent) + } + var buf bytes.Buffer + outputWidgetMDLV3(&ExecContext{Output: &buf}, got, 0) + if strings.Contains(buf.String(), "NOT SHOWN") { + t.Errorf("untouched combo box printed a note:\n%s", buf.String()) + } +} diff --git a/mdl/executor/cmd_pages_describe_datasource.go b/mdl/executor/cmd_pages_describe_datasource.go index 3a76362026..280d47abfb 100644 --- a/mdl/executor/cmd_pages_describe_datasource.go +++ b/mdl/executor/cmd_pages_describe_datasource.go @@ -691,7 +691,7 @@ func flowSourceArgs(ds map[string]any, settingsKey, flowName string) []rawDataSo } value := strings.TrimSpace(extractString(mapping["Expression"])) if value == "" { - value = pageVariableArgValue(mapping["Variable"]) + value = flowArgVariableValue(mapping["Variable"]) } if value == "" { // A pre-#1140 document, or another writer, may have put the bare diff --git a/mdl/executor/cmd_pages_describe_flow_args_test.go b/mdl/executor/cmd_pages_describe_flow_args_test.go index 25b0fdcadf..33f9080de9 100644 --- a/mdl/executor/cmd_pages_describe_flow_args_test.go +++ b/mdl/executor/cmd_pages_describe_flow_args_test.go @@ -52,8 +52,9 @@ func TestPageVariableArgValue(t *testing.T) { "$Workflow", }, { - // A grid's selection. MDL has no syntax for it, and rendering it as - // $dataGrid23 would re-execute into a different binding. + // A grid's selection is a flow call's to read (flowArgVariableValue, + // ako/mxcli#1560); a snippet call, which reads through this, has no + // Studio Pro mapping of that shape. "widget selection is not rendered", map[string]any{"$Type": "Forms$PageVariable", "Widget": "dataGrid23"}, "", diff --git a/mdl/executor/cmd_pages_describe_omitted.go b/mdl/executor/cmd_pages_describe_omitted.go index 4d97cc06b7..c632ebfc0d 100644 --- a/mdl/executor/cmd_pages_describe_omitted.go +++ b/mdl/executor/cmd_pages_describe_omitted.go @@ -151,3 +151,69 @@ func writeOmittedPropsNote(out io.Writer, prefix string, omitted []string) { prefix, strings.Join(omitted, ", ")) fmt.Fprintf(out, "%s-- no spelling for. Re-running this script would reset it to the widget's default.\n", prefix) } + +// rawHiddenContent is one widgets property of a combo box that holds widgets, +// by property key, with the widgets by name. +type rawHiddenContent struct { + Property string + Widgets []string // a name, or "an unnamed " +} + +// comboBoxHiddenContent reads a combo box's custom content off the stored +// document: every top-level property holding widgets. A combo box has no +// child slot MDL can write (MDL-WIDGET26), so each of them is content the +// description leaves out — unlike a generic pluggable widget, whose slots +// unreconstructedContainers reports only when DESCRIBE did not reproduce them. +func comboBoxHiddenContent(w map[string]any) []rawHiddenContent { + obj, ok := w["Object"].(map[string]any) + if !ok { + return nil + } + keyMap := buildPropertyTypeKeyMap(w, false) + var out []rawHiddenContent + for _, prop := range getBsonArrayElements(obj["Properties"]) { + propMap, ok := prop.(map[string]any) + if !ok { + continue + } + value, _ := propMap["Value"].(map[string]any) + content := getBsonArrayElements(value["Widgets"]) + key := keyMap[extractBinaryID(propMap["TypePointer"])] + if len(content) == 0 || key == "" { + continue + } + hc := rawHiddenContent{Property: key} + for _, c := range content { + cw, _ := c.(map[string]any) + if name := extractString(cw["Name"]); name != "" { + hc.Widgets = append(hc.Widgets, name) + continue + } + kind := extractString(cw["$Type"]) + kind = strings.ToLower(kind[strings.LastIndex(kind, "$")+1:]) + if kind == "" { + kind = "widget" + } + hc.Widgets = append(hc.Widgets, "an unnamed "+kind) + } + out = append(out, hc) + } + sort.Slice(out, func(i, j int) bool { return out[i].Property < out[j].Property }) + return out +} + +// writeHiddenContentNote names a combo box's custom content. Executed against +// this page the content is kept while the combo box is left as stored — the +// pluggable passthrough — and a statement that changes the combo box is refused +// rather than delete it (ako/mxcli#1648); run anywhere else, it is left out. +// `alter page … set` changes the combo box and keeps it. +func writeHiddenContentNote(out io.Writer, prefix, widget string, hidden []rawHiddenContent) { + for _, hc := range hidden { + fmt.Fprintf(out, "%s-- NOT SHOWN: %s's %s holds %s, custom content MDL cannot author.\n", + prefix, widget, hc.Property, strings.Join(hc.Widgets, ", ")) + fmt.Fprintf(out, "%s-- Executed on this page it is kept while %s is unchanged, and a change to %s is\n", + prefix, widget, widget) + fmt.Fprintf(out, "%s-- refused; run anywhere else, it is left out. Change %s with `alter page … set (…) on %s`.\n", + prefix, widget, widget) + } +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 73d9e5f46f..44854d9c8d 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -1029,6 +1029,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { props = appendExplicitProps(ctx, props, w.ExplicitProperties) props = appendAppearanceProps(ctx, props, w) writeOmittedPropsNote(ctx.Output, prefix, w.OmittedProps) + writeHiddenContentNote(ctx.Output, prefix, w.Name, w.HiddenContent) formatWidgetProps(ctx.Output, prefix, header, props, "\n") } @@ -1823,18 +1824,11 @@ func extractPageParameters(ctx *ExecContext, settings map[string]any) string { } } - // Check for a Forms$PageVariable binding. + // Check for a Forms$PageVariable binding. One naming only a widget is a + // control-bar button passing its grid's selection (mendixlabs/mxcli#122), + // written `$grid`. 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 - } - } + value = flowArgVariableValue(mappingMap["Variable"]) } if value != "" { @@ -1891,7 +1885,7 @@ func extractMicroflowParameters(ctx *ExecContext, settings map[string]any) strin // Check for a Forms$PageVariable binding. if value == "" { - value = pageVariableArgValue(mappingMap["Variable"]) + value = flowArgVariableValue(mappingMap["Variable"]) } if value != "" { @@ -1950,7 +1944,7 @@ func extractNanoflowParameters(ctx *ExecContext, action map[string]any) string { // Check for a Forms$PageVariable binding. if value == "" { - value = pageVariableArgValue(mappingMap["Variable"]) + value = flowArgVariableValue(mappingMap["Variable"]) } if value != "" { @@ -1979,84 +1973,19 @@ func extractTextsText(ctx *ExecContext, w map[string]any, field string) string { } // extractClientTemplateParameters extracts parameter values from a ClientTemplate field (Content or Caption). +// +// One reader for every stored Forms$ClientTemplateParameter: the pluggable +// describer's extractTextTemplateParameters. This used to be a second copy of +// that loop, and the two drifted twice — a local-variable binding read here as +// `` (upstream #977), and an association path was read only here, so a +// pluggable widget's `{1} = Assoc/Attr` described as the bare attribute +// (mendixlabs/mxcli#1494). func extractClientTemplateParameters(ctx *ExecContext, w map[string]any, fieldName string) []string { template, ok := w[fieldName].(map[string]any) if !ok { return nil } - params := getBsonArrayElements(template["Parameters"]) - if params == nil { - return nil - } - var result []string - var suffixes []string // per-param format block " (decimalPrecision: 2, …)", "" when default - for _, p := range params { - pMap, ok := p.(map[string]any) - if !ok { - continue - } - // One suffix per emitted param, in the same order, so it can be zipped in - // after the value string is chosen (round-trips per-parameter formatting). - suffixes = append(suffixes, formatParamFormatSuffix(pMap)) - // Check for Expression first (literal value) - if expr, ok := pMap["Expression"].(string); ok && expr != "" { - // The expression as stored. `toString($Order/Total)` used to be - // unwrapped to `$Order.Total`, the spelling mxcli once wrapped in - // toString itself; that spelling now binds the attribute (#823, - // MDL-V1-TEMPLATEATTR), so unwrapping changed the page's output on - // describe -> exec. The write side stores a non-attribute value as - // an expression (isTemplateExpression), so the text round-trips. - result = append(result, strings.TrimSpace(expr)) - continue - } - - // Which variable the parameter is bound to, if any. This read only - // PageParameter, so a binding to a page-level LOCAL variable came back as - // `` here while the pluggable describer rendered the same bytes - // as `$Name` (upstream #977) — hence the shared resolver. - sourceVarName, isLocalVariable := sourceVariableBinding(pMap) - - // A local variable renders bare: it has no AttributeRef to hang off. - if isLocalVariable { - result = append(result, "$"+sourceVarName) - continue - } - - // Check for AttributeRef - if attrRef, ok := pMap["AttributeRef"].(map[string]any); ok && attrRef != nil { - // Attribute navigated over associations (AttributeRef.EntityRef): - // reconstruct the "Assoc/.../Attr" path so DESCRIBE round-trips. - if assocPath := associationTemplateParamPath(attrRef); assocPath != "" { - result = append(result, assocPath) - continue - } - if attr, ok := attrRef["Attribute"].(string); ok { - if sourceVarName != "" { - // Has SourceVariable - this is a page parameter reference - // Extract just the attribute name from the path - // attr is like "Module.Entity.Attribute", we want just "Attribute" - parts := strings.Split(attr, ".") - attrName := parts[len(parts)-1] - // Use $ParamName.Attribute format to indicate parameter reference - result = append(result, "$"+sourceVarName+"."+attrName) - } else { - // No SourceVariable - use short attribute name - result = append(result, describeAttr(ctx, attr)) - } - continue - } - } - // Parameter exists but has no binding - mark as unbound - result = append(result, "") - } - // Append each param's format block to its value string. result and suffixes are - // aligned (one of each per param that had a valid pMap). - for i := range result { - if i < len(suffixes) && suffixes[i] != "" { - result[i] += suffixes[i] - } - } - return result + return extractTextTemplateParameters(ctx, template) } // formatParamFormatSuffix renders a dynamic-text parameter's FormattingInfo back diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index af836cf39c..4d200f3609 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -467,7 +467,9 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s // For ComboBox, extract datasource and association attribute for association mode. // In association mode the Attribute binding is stored as EntityRef (not AttributeRef), // so we must use extractCustomWidgetPropertyAssociation instead of the generic scan. - if widget.RenderMode == "combobox" { + if widget.RenderMode == "combobox" && comboBoxIsDatabaseMode(ctx, w) { + describeComboBoxDatabaseMode(ctx, w, &widget) + } else if widget.RenderMode == "combobox" { widget.DataSource = extractComboBoxDataSource(ctx, w) if widget.DataSource != nil { widget.Content = associationRefForContext(extractCustomWidgetPropertyAssociationQN(ctx, w, "attributeAssociation"), inheritedCtx) @@ -620,7 +622,17 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s } widget.ExplicitProperties, widget.OmittedProps = extractSharedBranchByKeyProps(ctx, w, skip, inheritedCtx, ownEntity) if widget.RenderMode == "combobox" { - widget.ExplicitProperties, widget.OmittedProps = withoutUndescribedComboBoxModes(widget.ExplicitProperties, widget.OmittedProps) + database := comboBoxIsDatabaseMode(ctx, w) + if database { + ownEntity = "" + if len(widget.NamedDataSources) > 0 { + ownEntity = dataSourceEntityContext(ctx, widget.NamedDataSources[0].DataSource) + } + widget.ExplicitProperties, widget.OmittedProps = extractSharedBranchByKeyProps(ctx, w, comboBoxDatabaseByKeySkip, inheritedCtx, ownEntity) + widget.ExplicitProperties = append(widget.ExplicitProperties, comboBoxDatabaseSelection(ctx, w)...) + } + widget.ExplicitProperties, widget.OmittedProps = withoutUndescribedComboBoxModes(widget.ExplicitProperties, widget.OmittedProps, database) + widget.HiddenContent = comboBoxHiddenContent(w) } } // For pluggable Image widget, extract image-specific properties diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 0260f2aeaf..bb2068191c 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -586,7 +586,6 @@ func extractTextTemplateText(value map[string]any) (string, map[string]any) { return "", nil } -// extractTextTemplateParameters extracts parameters from a TextTemplate (Forms$ClientTemplate). // sourceVariableBinding reads the SourceVariable of a stored // Forms$ClientTemplateParameter and reports which variable it names, and whether // that variable is a page-level LOCAL one. @@ -623,6 +622,10 @@ func sourceVariableBinding(param map[string]any) (name string, isLocal bool) { return "", false } +// extractTextTemplateParameters renders the parameters of a stored +// Forms$ClientTemplate as the MDL values of its `{n} = …` list. It is the one +// reader of a template parameter: the core describer's +// extractClientTemplateParameters delegates here. func extractTextTemplateParameters(ctx *ExecContext, textTemplate map[string]any) []string { params := getBsonArrayElements(textTemplate["Parameters"]) if params == nil || len(params) == 0 { @@ -641,7 +644,13 @@ func extractTextTemplateParameters(ctx *ExecContext, textTemplate map[string]any suffixes = append(suffixes, formatParamFormatSuffix(pMap)) // Check for Expression first (literal value) if expr, ok := pMap["Expression"].(string); ok && expr != "" { - result = append(result, expr) + // The expression as stored. `toString($Order/Total)` used to be + // unwrapped to `$Order.Total`, the spelling mxcli once wrapped in + // toString itself; that spelling now binds the attribute (#823, + // MDL-V1-TEMPLATEATTR), so unwrapping changed the page's output on + // describe -> exec. The write side stores a non-attribute value as + // an expression (isTemplateExpression), so the text round-trips. + result = append(result, strings.TrimSpace(expr)) continue } @@ -655,6 +664,14 @@ func extractTextTemplateParameters(ctx *ExecContext, textTemplate map[string]any // Check for AttributeRef if attrRef, ok := pMap["AttributeRef"].(map[string]any); ok && attrRef != nil { + // Attribute navigated over associations (AttributeRef.EntityRef): + // reconstruct the "Assoc/.../Attr" path so DESCRIBE round-trips. The + // bare attribute re-executes against the widget's own entity (CE1365, + // mendixlabs/mxcli#1494). + if assocPath := associationTemplateParamPath(attrRef); assocPath != "" { + result = append(result, assocPath) + continue + } if attr, ok := attrRef["Attribute"].(string); ok { if sourceVarName != "" { // Has SourceVariable - this is a page parameter reference @@ -1173,6 +1190,27 @@ func extractCustomWidgetPropertyString(ctx *ExecContext, w map[string]any, prope return "" } +// extractCustomWidgetPropertySelection reads the Selection a named property +// holds ("None", "Single", "Multi"); "" when the widget has no such property. +func extractCustomWidgetPropertySelection(ctx *ExecContext, w map[string]any, propertyKey string) string { + obj, ok := w["Object"].(map[string]any) + if !ok { + return "" + } + propTypeKeyMap := buildPropertyTypeKeyMap(w, false) + for _, prop := range getBsonArrayElements(obj["Properties"]) { + propMap, ok := prop.(map[string]any) + if !ok || propTypeKeyMap[extractBinaryID(propMap["TypePointer"])] != propertyKey { + continue + } + if value, ok := propMap["Value"].(map[string]any); ok { + sel, _ := value["Selection"].(string) + return sel + } + } + return "" +} + // extractCustomWidgetPropertyAttributes extracts attribute references from a CustomWidget property. func extractCustomWidgetPropertyAttributes(ctx *ExecContext, w map[string]any, propertyKey string) []string { obj, ok := w["Object"].(map[string]any) diff --git a/mdl/executor/cmd_pages_describe_sourcevariable_test.go b/mdl/executor/cmd_pages_describe_sourcevariable_test.go index e21a89823c..d4930f00c3 100644 --- a/mdl/executor/cmd_pages_describe_sourcevariable_test.go +++ b/mdl/executor/cmd_pages_describe_sourcevariable_test.go @@ -151,3 +151,37 @@ func TestBothDescribers_StillReportATrulyUnboundParameter(t *testing.T) { } } } + +// mendixlabs/mxcli#1494: a parameter bound across an association +// (AttributeRef.EntityRef = IndirectEntityRef of steps) renders as its +// `Assoc/Attr` path in BOTH describers. The pluggable one printed the bare +// `Label`, which re-executes against the widget's own entity — the same CE1365 +// the write side had just stopped producing. The core describer had learned the +// path and its twin had not: the drift #977 found, a second time. +func TestBothDescribers_RenderAnAssociationPath(t *testing.T) { + param := map[string]any{ + "AttributeRef": map[string]any{ + "$Type": "DomainModels$AttributeRef", + "Attribute": "Repro.Child.Label", + "EntityRef": map[string]any{ + "$Type": "DomainModels$IndirectEntityRef", + "Steps": []any{map[string]any{ + "$Type": "DomainModels$EntityRefStep", + "Association": "Repro.Parent_Child", + "DestinationEntity": "Repro.Child", + }}, + }, + }, + } + template := map[string]any{"Parameters": []any{param}} + ctx, _ := newMockCtx(t) + + for label, values := range map[string][]string{ + "cmd_pages_describe_output": extractClientTemplateParameters(ctx, map[string]any{"Content": template}, "Content"), + "cmd_pages_describe_pluggable": extractTextTemplateParameters(ctx, template), + } { + if len(values) != 1 || values[0] != "Repro.Parent_Child/Label" { + t.Errorf("%s: rendered %v, want [Repro.Parent_Child/Label]", label, values) + } + } +} diff --git a/mdl/executor/cmd_pages_flow_args.go b/mdl/executor/cmd_pages_flow_args.go index cfbb63bcfb..ff9e7d9dee 100644 --- a/mdl/executor/cmd_pages_flow_args.go +++ b/mdl/executor/cmd_pages_flow_args.go @@ -2,7 +2,11 @@ package executor -import "strings" +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" +) // classifyFlowArgValue decides how an action or data-source argument binds. // @@ -55,6 +59,12 @@ func (pb *pageBuilder) classifyFlowArgValue(value string) (variable, kind string } return value, "parameter" } + // A widget of the document: its selection, bound by naming the widget + // (ako/mxcli#1560). After the parameters and variables, because that is + // what `$name` means when both exist. + if pb.isDocumentWidget != nil && pb.isDocumentWidget(name) { + return value, "widget" + } return "", "" } @@ -73,9 +83,9 @@ func (pb *pageBuilder) classifyFlowArgValue(value string) (variable, kind string // for a button whose stored mapping binds $ConflictedWorkflowHelper — the same // describe → exec loss as #835, one storage form over. // -// Widget is deliberately not read. It names the grid whose SELECTION supplies a -// list argument, which MDL has no syntax for; rendering it as `$widgetName` would -// emit a statement that re-executes into a different binding. +// Widget is not read here. It names the widget whose SELECTION supplies the +// argument; a flow call reads it through flowArgVariableValue, and a snippet +// call, where no Studio Pro-authored mapping has one, not at all. func pageVariableArgValue(raw any) string { v, ok := raw.(map[string]any) if !ok || v == nil { @@ -88,3 +98,33 @@ func pageVariableArgValue(raw any) string { } return "" } + +// flowArgVariableValue is pageVariableArgValue for a flow call's argument — an +// action's or a data source's — which may also be a widget's selection: a +// PageVariable naming only the widget, written `$widget`. Studio Pro stores it +// for a control-bar button passing its grid (TestApp UserTask_Assign: grid2) +// and for a button passing a combo box's selection (User_WorkflowGroup_Select: +// comboBox1). describe printed the call with the argument gone, so check +// reported CE1571 on a page that builds and exec wrote the call unbound +// (ako/mxcli#1560); the builder binds `$widget` back to the same slot. +func flowArgVariableValue(raw any) string { + if value := pageVariableArgValue(raw); value != "" { + return value + } + if v, ok := raw.(map[string]any); ok { + if w := extractString(v["Widget"]); w != "" { + return "$" + w + } + } + return "" +} + +// widgetNameSet is isDocumentWidget for a statement that declares the whole +// widget tree, as CREATE PAGE, SNIPPET and LAYOUT do: every name in it, wherever +// it sits — a combo box's selection is passed from a sibling's footer, not only +// from a grid's control bar. +func widgetNameSet(widgets []*ast.WidgetV3) func(name string) bool { + names := map[string]bool{} + collectWidgetNames(widgets, names) + return func(name string) bool { return names[name] } +} diff --git a/mdl/executor/cmd_pages_flow_args_selection_1560_test.go b/mdl/executor/cmd_pages_flow_args_selection_1560_test.go new file mode 100644 index 0000000000..c88bc040e1 --- /dev/null +++ b/mdl/executor/cmd_pages_flow_args_selection_1560_test.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" +) + +// ako/mxcli#1560 (mendixlabs/mxcli#1628): a button passing a widget's +// SELECTION to a microflow — Studio Pro stores the argument as a +// Forms$PageVariable naming only the widget — described as +// +// Action: call microflow WorkflowCommons.ACT_WorkflowUserTask_Assignees_Remove +// +// with the argument gone, so `check` reported CE1571 on a page Studio Pro builds, +// and exec would have written the call with no mapping at all. 13 of the 16 +// CE1571 reports describe → check gives on TestApp are this shape: a grid's +// control-bar button passing the grid (UserTask_Assign: grid2, grid8 …) and a +// data view footer button passing a combo box's selection +// (User_WorkflowGroup_Select: comboBox1). + +// gridSelection is the stored Variable of those 13 mappings, every slot present +// as Studio Pro writes it. +func gridSelection(widget string) map[string]any { + return map[string]any{ + "$Type": "Forms$PageVariable", "LocalVariable": "", "PageParameter": "", + "SnippetParameter": "", "SubKey": "", "UseAllPages": false, "Widget": widget, + } +} + +func selectionMapping(param string, widget string) map[string]any { + return map[string]any{ + "$Type": "Forms$MicroflowParameterMapping", + "Parameter": param, + "Expression": "", + "Variable": gridSelection(widget), + } +} + +func TestDescribeFlowArgReadsWidgetSelection(t *testing.T) { + for _, tc := range []struct { + name string + action map[string]any + want string + }{ + { + "microflow action in a control bar", + map[string]any{ + "$Type": "Forms$MicroflowAction", + "MicroflowSettings": map[string]any{ + "$Type": "Forms$MicroflowSettings", + "Microflow": "WorkflowCommons.ACT_WorkflowUserTask_Assignees_Remove", + "ParameterMappings": []any{int32(2), + selectionMapping("WorkflowCommons.ACT_WorkflowUserTask_Assignees_Remove.UserList", "grid2")}, + }, + }, + "call microflow WorkflowCommons.ACT_WorkflowUserTask_Assignees_Remove(UserList = $grid2)", + }, + { + "nanoflow action", + map[string]any{ + "$Type": "Forms$CallNanoflowClientAction", + "Nanoflow": "M.NF_Remove", + "ParameterMappings": []any{int32(2), map[string]any{ + "$Type": "Forms$NanoflowParameterMapping", "Parameter": "M.NF_Remove.Items", + "Expression": "", "Variable": gridSelection("comboBox1"), + }}, + }, + "call nanoflow M.NF_Remove(Items = $comboBox1)", + }, + { + // Control: a page-parameter binding reads exactly as before. + "page parameter is unchanged", + map[string]any{ + "$Type": "Forms$MicroflowAction", + "MicroflowSettings": map[string]any{ + "Microflow": "M.ACT", + "ParameterMappings": []any{int32(2), map[string]any{ + "Parameter": "M.ACT.Order", + "Variable": map[string]any{ + "$Type": "Forms$PageVariable", "PageParameter": "Order", "Widget": "", + }, + }}, + }, + }, + "call microflow M.ACT(Order = $Order)", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := renderClientActionBaseMDL(nil, tc.action); got != tc.want { + t.Errorf("described\n %s\nwant\n %s", got, tc.want) + } + }) + } +} + +// A data source stores its arguments the same way: TestApp's +// ConflictedWorkflowDefinitionView has a data view in a grid's control bar whose +// microflow source takes the grid's selection. +func TestDescribeDataSourceArgReadsWidgetSelection(t *testing.T) { + ds := map[string]any{ + "$Type": "Forms$MicroflowSource", + "MicroflowSettings": map[string]any{ + "Microflow": "WorkflowCommons.DS_ShowActionOptions", + "ParameterMappings": []any{int32(2), + selectionMapping("WorkflowCommons.DS_ShowActionOptions.ViewList", "dataGrid2_2")}, + }, + } + got := parseDataSource(ds) + if got == nil { + t.Fatal("datasource not read") + } + if expr, want := dataSourceExpr(nil, got), "microflow WorkflowCommons.DS_ShowActionOptions(ViewList = $dataGrid2_2)"; expr != want { + t.Errorf("rendered %q, want %q", expr, want) + } +} + +// The write half: `$grid2` naming a widget of the document being built binds as +// that widget — the PageVariable's Widget slot — and not as the Expression +// "$grid2" it fell through to, which binds nothing. +func TestClassifyFlowArgValueNamesDocumentWidget(t *testing.T) { + pb := builderWithParams("WorkflowUserTask", "Shared") + pb.localVariables["showAll"] = true + widgets := map[string]bool{"grid2": true, "comboBox1": true, "Shared": true, "showAll": true} + pb.isDocumentWidget = func(name string) bool { return widgets[name] } + + for _, tc := range []struct { + name, value, wantVar, wantKind string + }{ + {"grid selection", "$grid2", "$grid2", "widget"}, + {"combo box selection", "$comboBox1", "$comboBox1", "widget"}, + // A parameter or page variable is what the name means when both exist: + // that is how Mendix resolves `$name` in an expression. + {"parameter wins over a widget of the same name", "$Shared", "$Shared", "parameter"}, + {"page variable wins over a widget of the same name", "$showAll", "$showAll", "local"}, + // Controls: nothing else changes. + {"no such widget", "$grid9", "", ""}, + {"current object", "$currentObject", "", ""}, + {"path through a widget name", "$grid2/M.Assoc", "", ""}, + {"page parameter", "$WorkflowUserTask", "$WorkflowUserTask", "parameter"}, + } { + t.Run(tc.name, func(t *testing.T) { + gotVar, gotKind := pb.classifyFlowArgValue(tc.value) + if gotVar != tc.wantVar || gotKind != tc.wantKind { + t.Errorf("classifyFlowArgValue(%q) = (%q, %q), want (%q, %q)", + tc.value, gotVar, gotKind, tc.wantVar, tc.wantKind) + } + }) + } +} + +// With no document in hand (ALTER on a mutator that cannot answer), nothing is +// a widget, and the argument binds exactly as it did before. +func TestClassifyFlowArgValueWithoutDocumentWidgets(t *testing.T) { + pb := builderWithParams() + if v, kind := pb.classifyFlowArgValue("$grid2"); v != "" || kind != "" { + t.Errorf("classifyFlowArgValue($grid2) with no document = (%q, %q), want (\"\", \"\")", v, kind) + } +} diff --git a/mdl/executor/cmd_pages_hidden_content_rebuild_1648_test.go b/mdl/executor/cmd_pages_hidden_content_rebuild_1648_test.go new file mode 100644 index 0000000000..ba4e434325 --- /dev/null +++ b/mdl/executor/cmd_pages_hidden_content_rebuild_1648_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#1648: `create or modify` of a page whose combo box the statement +// CHANGES rebuilt the combo box from the template and deleted its custom +// content — txtOptionName, which MDL cannot author — while the content type +// stayed 'yes': "Replaced page", then mx check CE0642 "Property 'Custom +// content' is required." An unchanged combo box survived through the pluggable +// passthrough, and `alter page … set` keeps it, so the refusal is for the +// rebuild alone (ADR-0012: carry or refuse, never drop). + +func hcBin(s string) bson.Binary { return bson.Binary{Data: []byte(s)} } + +// storedComboWithContent is a stored combo box Type and Object whose custom +// content holds the named widgets. +func storedComboWithContent(content ...string) (typ, obj bson.D) { + widgets := bson.A{int32(2)} + for _, n := range content { + widgets = append(widgets, bson.D{{Key: "$Type", Value: "Forms$DynamicText"}, {Key: "Name", Value: n}}) + } + typ = bson.D{{Key: "ObjectType", Value: bson.D{{Key: "PropertyTypes", Value: bson.A{int32(2), + bson.D{{Key: "$ID", Value: hcBin("pt-label")}, {Key: "PropertyKey", Value: "emptyOptionText"}, + {Key: "ValueType", Value: bson.D{{Key: "Type", Value: "TextTemplate"}}}}, + bson.D{{Key: "$ID", Value: hcBin("pt-content")}, {Key: "PropertyKey", Value: "optionsSourceAssociationCustomContent"}, + {Key: "ValueType", Value: bson.D{{Key: "Type", Value: "Widgets"}, {Key: "DataSourceProperty", Value: "optionsSourceAssociationDataSource"}}}}, + }}}}} + obj = bson.D{{Key: "Properties", Value: bson.A{int32(2), + bson.D{{Key: "TypePointer", Value: hcBin("pt-label")}, {Key: "Value", Value: bson.D{{Key: "Widgets", Value: bson.A{int32(2)}}}}}, + bson.D{{Key: "TypePointer", Value: hcBin("pt-content")}, {Key: "Value", Value: bson.D{{Key: "Widgets", Value: widgets}}}}, + }}} + return typ, obj +} + +const describedCombo = `combobox cbBooking ( + Label: 'Booking', + Attribute: Holder_Booking, + DataSource: database from MyFirstModule.Booking, + CaptionAttribute: Name, + optionsSourceAssociationCustomContentType: 'yes' +)` + +func passthroughWith(t *testing.T, content ...string) *pluggablePassthrough { + t.Helper() + typ, obj := storedComboWithContent(content...) + return &pluggablePassthrough{byName: map[string]*storedPluggable{ + "cbBooking": { + widgetID: comboBoxPackageWidgetID, + typ: typ, obj: obj, + declared: parseSnippetWidget(t, describedCombo), + hidden: storedHiddenContent(comboBoxPackageWidgetID, typ, obj), + }, + }} +} + +func TestStoredHiddenContentReadsTheComboBoxContent(t *testing.T) { + typ, obj := storedComboWithContent("txtOptionName") + got := storedHiddenContent(comboBoxPackageWidgetID, typ, obj) + if len(got) != 1 || got[0].Property != "optionsSourceAssociationCustomContent" || + strings.Join(got[0].Widgets, ",") != "txtOptionName" { + t.Errorf("storedHiddenContent = %+v", got) + } + // Another package's widgets property is not this rule's: its slots are + // described, and a widget left out of the statement is a deletion. + if got := storedHiddenContent("com.mendix.widget.web.gallery.Gallery", typ, obj); len(got) != 0 { + t.Errorf("gallery reported hidden content %+v", got) + } +} + +func TestChangedComboBoxWithHiddenContentIsRefused(t *testing.T) { + pt := passthroughWith(t, "txtOptionName") + changed := parseSnippetWidget(t, strings.Replace(describedCombo, "Label: 'Booking'", "Label: 'Booking (changed)'", 1)) + err := pt.refuseChangedWithHiddenContent([]*ast.WidgetV3{changed}) + if err == nil { + t.Fatal("a rebuild deleting custom content was not refused") + } + for _, want := range []string{"cbBooking", "txtOptionName", "optionsSourceAssociationCustomContent", "alter page", "drop txtOptionName"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal lacks %q: %v", want, err) + } + } +} + +func TestComboBoxWithHiddenContentControls(t *testing.T) { + // Unchanged: passed through, content kept. + if err := passthroughWith(t, "txtOptionName").refuseChangedWithHiddenContent( + []*ast.WidgetV3{parseSnippetWidget(t, describedCombo)}); err != nil { + t.Errorf("unchanged combo box refused: %v", err) + } + // No content to lose. + changed := parseSnippetWidget(t, strings.Replace(describedCombo, "Label: 'Booking'", "Label: 'Other'", 1)) + if err := passthroughWith(t).refuseChangedWithHiddenContent([]*ast.WidgetV3{changed}); err != nil { + t.Errorf("combo box without content refused: %v", err) + } + // Another widget kind under the name: the author replaced the combo box. + other := parseSnippetWidget(t, `textbox cbBooking (Label: 'Booking', Attribute: Note)`) + if err := passthroughWith(t, "txtOptionName").refuseChangedWithHiddenContent([]*ast.WidgetV3{other}); err != nil { + t.Errorf("a different widget under the name refused: %v", err) + } + // Nested: the changed combo box inside a data view is found. + nested := parseSnippetWidget(t, "dataview dv (DataSource: $Holder) {\n"+ + strings.Replace(describedCombo, "Label: 'Booking'", "Label: 'X'", 1)+"\n}") + if err := passthroughWith(t, "txtOptionName").refuseChangedWithHiddenContent([]*ast.WidgetV3{nested}); err == nil { + t.Error("a changed combo box nested in a data view was not refused") + } +} diff --git a/mdl/executor/cmd_pages_layout_v3.go b/mdl/executor/cmd_pages_layout_v3.go index 925fc5be35..e2259db69a 100644 --- a/mdl/executor/cmd_pages_layout_v3.go +++ b/mdl/executor/cmd_pages_layout_v3.go @@ -256,7 +256,8 @@ func execCreateLayout(ctx *ExecContext, s *ast.CreateLayoutStmt) error { widgetBackend: ctx.Backend, // The root of a document that this pass walks in full: there is no // enclosing data widget, so there is no context object. #1029. - argCtx: atDocumentRoot(), + argCtx: atDocumentRoot(), + isDocumentWidget: widgetNameSet(s.AllWidgets()), } // Built before the old one is deleted: a build failure must leave the diff --git a/mdl/executor/cmd_pages_pluggable_passthrough.go b/mdl/executor/cmd_pages_pluggable_passthrough.go index 1a4d4b9e66..0212387632 100644 --- a/mdl/executor/cmd_pages_pluggable_passthrough.go +++ b/mdl/executor/cmd_pages_pluggable_passthrough.go @@ -5,10 +5,12 @@ package executor import ( "bytes" "fmt" + "strings" "go.mongodb.org/mongo-driver/v2/bson" "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/mendixlabs/mxcli/model" @@ -57,6 +59,9 @@ type storedPluggable struct { declared *ast.WidgetV3 // refs are the model elements the stored Object names. refs map[string]bool + // hidden is content of the stored widget that describe cannot print, and so + // no statement can carry (ako/mxcli#1648); see storedHiddenContent. + hidden []rawHiddenContent } // pluggablePassthrough indexes a stored document's pluggable widgets by name. @@ -100,6 +105,7 @@ func loadPluggablePassthrough(ctx *ExecContext, unitID model.ID, describe func() typ: typ, obj: obj, refs: modelRefs(obj), + hidden: storedHiddenContent(w.WidgetID, typ, obj), } }) for name := range ambiguous { @@ -249,3 +255,155 @@ func cloneBsonD(d bson.D) bson.D { } return out } + +// A rebuild that would delete content MDL cannot author (ako/mxcli#1648). +// +// A combo box's custom content is widgets the combo box holds that describe +// cannot print as MDL (MDL-WIDGET26) — only name, in a NOT SHOWN note. An +// unchanged combo box keeps it, passed through above; a CHANGED one is rebuilt +// from the template, the content is gone, and its type stays 'yes', because +// describe prints that: "Replaced page", then mx check CE0642 "Property 'Custom +// content' is required". The statement cannot have asked for the deletion — it +// has no words for the content — so the rewrite is refused (ADR-0012: carry or +// refuse, never drop), naming the widgets, as #1622 refuses for widgets MDL +// cannot author. +// +// Only the combo box: every other pluggable widget's slots are described, so a +// widget the statement leaves out of one is a deletion the author wrote. + +const comboBoxPackageWidgetID = "com.mendix.widget.web.combobox.Combobox" + +// storedHiddenContent is the custom content of a stored combo box, by +// property, read off its stored Type and Object; nil for any other widget. +func storedHiddenContent(widgetID string, typ, obj bson.D) []rawHiddenContent { + if widgetID != comboBoxPackageWidgetID { + return nil + } + widgetsKeys := map[string]string{} + for _, pt := range bsonDocs(bsonValue(bsonDocField(typ, "ObjectType"), "PropertyTypes")) { + if bsonString(bsonDocField(pt, "ValueType"), "Type") == "Widgets" { + widgetsKeys[bsonIDKey(bsonValue(pt, "$ID"))] = bsonString(pt, "PropertyKey") + } + } + var out []rawHiddenContent + for _, prop := range bsonDocs(bsonValue(obj, "Properties")) { + key := widgetsKeys[bsonIDKey(bsonValue(prop, "TypePointer"))] + content := bsonDocs(bsonValue(bsonDocField(prop, "Value"), "Widgets")) + if key == "" || len(content) == 0 { + continue + } + hc := rawHiddenContent{Property: key} + for _, w := range content { + if name := bsonString(w, "Name"); name != "" { + hc.Widgets = append(hc.Widgets, name) + continue + } + kind := bsonString(w, "$Type") + hc.Widgets = append(hc.Widgets, "an unnamed "+strings.ToLower(kind[strings.LastIndex(kind, "$")+1:])) + } + out = append(out, hc) + } + return out +} + +// refuseChangedWithHiddenContent is the check -p half: a widget of the +// statement that is a stored combo box with custom content, written +// differently from how describe prints it, is the rebuild exec refuses. +func (pt *pluggablePassthrough) refuseChangedWithHiddenContent(widgets []*ast.WidgetV3) error { + if pt == nil { + return nil + } + var found error + var walk func([]*ast.WidgetV3) + walk = func(ws []*ast.WidgetV3) { + for _, w := range ws { + if w == nil || found != nil { + continue + } + if sp := pt.byName[w.Name]; sp != nil && len(sp.hidden) > 0 && sp.declared != nil && + strings.EqualFold(w.Type, sp.declared.Type) && !declaredMatches(w, sp.declared) { + found = hiddenContentRebuildError(w.Name, sp.hidden) + return + } + walk(w.Children) + } + } + walk(widgets) + return found +} + +// refuseLostHiddenContent is exec's half, for a widget passStoredThrough did +// not keep: a stored combo box with custom content, rebuilt as one. It covers +// every reason the passthrough declines — a changed statement, and a context +// change under the same text — which check, reading only the text, cannot. +func (pt *pluggablePassthrough) refuseLostHiddenContent(def *WidgetDefinition, w *ast.WidgetV3) error { + if pt == nil || def == nil || w == nil { + return nil + } + sp, ok := pt.byName[w.Name] + if !ok || len(sp.hidden) == 0 || sp.widgetID != def.WidgetID { + return nil + } + return hiddenContentRebuildError(w.Name, sp.hidden) +} + +func hiddenContentRebuildError(widget string, hidden []rawHiddenContent) error { + hc := hidden[0] + var named []string + for _, n := range hc.Widgets { + if !strings.HasPrefix(n, "an unnamed ") { + named = append(named, n) + } + } + remedy := fmt.Sprintf("give the content a name in Studio Pro, or drop %s itself", widget) + if len(named) == len(hc.Widgets) { + remedy = fmt.Sprintf("delete it on purpose first (`alter page … { drop %s; }`) and write %s without %sType", + strings.Join(named, ", "), widget, hc.Property) + } + return mdlerrors.NewUnsupported(fmt.Sprintf( + "%s would be rebuilt from this statement, which changes it, and its %s — %s, custom content MDL cannot "+ + "author — would be deleted (Mendix then reports CE0642 \"Custom content is required\" while the content "+ + "type stays set). Change %s with `alter page … { set (…) on %s; }`, which keeps the content, or %s", + widget, hc.Property, strings.Join(hc.Widgets, ", "), widget, widget, remedy)) +} + +// The raw-document helpers the content reader needs. + +func bsonValue(d bson.D, key string) any { + for _, e := range d { + if e.Key == key { + return e.Value + } + } + return nil +} + +func bsonDocField(d bson.D, key string) bson.D { + v, _ := bsonValue(d, key).(bson.D) + return v +} + +// bsonDocs is an array's documents, without the leading list marker. +func bsonDocs(v any) []bson.D { + arr, _ := v.(bson.A) + var out []bson.D + for _, e := range arr { + if d, ok := e.(bson.D); ok { + out = append(out, d) + } + } + return out +} + +// bsonIDKey renders an element ID — binary as stored, or a string — as a key. +func bsonIDKey(v any) string { + switch x := v.(type) { + case bson.Binary: + return string(x.Data) + case []byte: + return string(x) + case string: + return x + } + return "" +} diff --git a/mdl/executor/cmd_pages_rewrite_guard.go b/mdl/executor/cmd_pages_rewrite_guard.go index 169946b857..b085b3092e 100644 --- a/mdl/executor/cmd_pages_rewrite_guard.go +++ b/mdl/executor/cmd_pages_rewrite_guard.go @@ -79,6 +79,29 @@ func checkNoUnauthorableWidgetsDropped(ctx *ExecContext, kind string, name ast.Q return mdlerrors.NewUnsupported(unauthorableRewriteMessage(kind, name, lost)) } +// checkNoHiddenContentRebuilt is check's half of the refusal a rewrite gives +// for a changed combo box whose custom content it would delete +// (ako/mxcli#1648): the stored document's pluggable widgets, paired with what +// describe prints for them, exactly as the rewrite itself pairs them. +func checkNoHiddenContentRebuilt(ctx *ExecContext, kind string, name ast.QualifiedName, unitID model.ID, widgets []*ast.WidgetV3) error { + if ctx == nil || ctx.Backend == nil || unitID == "" { + return nil + } + pt := loadPluggablePassthrough(ctx, unitID, func() error { + if kind == "snippet" { + return describeSnippet(ctx, name) + } + prev := ctx.describeID + ctx.describeID = unitID + defer func() { ctx.describeID = prev }() + return describePage(ctx, name) + }) + if err := pt.refuseChangedWithHiddenContent(widgets); err != nil { + return fmt.Errorf("%s %s: %w", kind, name, err) + } + return nil +} + // unauthorableRewriteMessage explains the refusal: every widget, and the two // ways forward. func unauthorableRewriteMessage(kind string, name ast.QualifiedName, lost []unauthorableWidget) string { @@ -127,6 +150,9 @@ func validateUnauthorableRewrites(ctx *ExecContext, prog *ast.Program) []error { if err := checkNoUnauthorableWidgetsDropped(ctx, "page", s.Name, id, allPageWidgets(s)); err != nil { errs = append(errs, fmt.Errorf("statement %d: %w", i+1, err)) } + if err := checkNoHiddenContentRebuilt(ctx, "page", s.Name, id, allPageWidgets(s)); err != nil { + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, err)) + } } } touched[k] = true @@ -137,6 +163,9 @@ func validateUnauthorableRewrites(ctx *ExecContext, prog *ast.Program) []error { if err := checkNoUnauthorableWidgetsDropped(ctx, "snippet", s.Name, id, s.Widgets); err != nil { errs = append(errs, fmt.Errorf("statement %d: %w", i+1, err)) } + if err := checkNoHiddenContentRebuilt(ctx, "snippet", s.Name, id, s.Widgets); err != nil { + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, err)) + } } } touched[k] = true diff --git a/mdl/executor/cmd_pages_template_expression_1495_test.go b/mdl/executor/cmd_pages_template_expression_1495_test.go new file mode 100644 index 0000000000..c1daec1b8b --- /dev/null +++ b/mdl/executor/cmd_pages_template_expression_1495_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// mendixlabs/mxcli#1495: a pluggable widget's text-template parameter written +// as an expression, in the reporter's own spelling, was stored as an attribute +// path on the context entity with its spaces stripped (v0.24.0): +// +// CE1613 "The selected attribute 'Repro.Parent.if1=1then'A'else'B'' no longer exists." +// +// Two layers each had to be right: the visitor keeps the expression's source +// text (not the token text, which drops whitespace), and the parameter builder +// stores a value that is not an attribute reference as the Expression (#823). +// The serializer keeping it there is pinned in widgetobj. +func TestPluggableTemplateParam_ExpressionIsStoredAsWritten(t *testing.T) { + const script = `alter page M.P { + replace fieldset1 with { + fieldset fs (legend: '{1}', legendParams: [{1}=if 1 = 1 then 'A' else 'B']) + } +};` + const want = "if 1 = 1 then 'A' else 'B'" + + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + alter, ok := prog.Statements[0].(*ast.AlterPageStmt) + if !ok || len(alter.Operations) != 1 { + t.Fatalf("statement = %T, want one alter page operation", prog.Statements[0]) + } + replace, ok := alter.Operations[0].(*ast.ReplaceWidgetOp) + if !ok || len(replace.NewWidgets) != 1 { + t.Fatalf("operation = %T, want a replace with one widget", alter.Operations[0]) + } + raw, ok := lookupProperty(replace.NewWidgets[0].Properties, "legendParams") + if !ok { + t.Fatalf("legendParams not on the widget: %v", replace.NewWidgets[0].Properties) + } + params, ok := raw.([]ast.ParamAssignmentV3) + if !ok || len(params) != 1 { + t.Fatalf("legendParams = %#v, want one parameter assignment", raw) + } + if params[0].Value != want { + t.Fatalf("parsed value = %q, want %q (the source text, spaces kept)", params[0].Value, want) + } + + pb := templateBindingPB(false) + pb.entityContext = "M.Job" // a context entity for a wrong binding to land on + got := pb.buildClientTemplateParams(params) + if len(got) != 1 { + t.Fatalf("got %d template parameters, want 1", len(got)) + } + if got[0].Expression != want || got[0].AttributeRef != "" { + t.Errorf("Expression=%q AttributeRef=%q, want the expression stored as written and no attribute", + got[0].Expression, got[0].AttributeRef) + } +} diff --git a/mdl/executor/editorconfig_else_if_test.go b/mdl/executor/editorconfig_else_if_test.go new file mode 100644 index 0000000000..2e2e4e77fc --- /dev/null +++ b/mdl/executor/editorconfig_else_if_test.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/modelsdk/widgets/mpk" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +// An else-if chain: `A ? (…) : B ? (…hides…) : C && hide(…)`. A hide in the B +// branch fires when B holds and A does not; one in the C branch when C holds +// and neither A nor B does. Combo box writes its three option sources this way: +// +// "context"===t.source ? (…) : "database"===t.source ? (…) : "static"===t.source && hide(…) +// +// Every hide after the first `:` was unread — its guard's boundary is the outer +// ternary's `:`, which parseGuard treats as "a fragment of something larger" — +// so nothing the database or static source hides was known to be hidden. A +// database-mode combo box then kept the template's `optionsSourceType` and +// `selectAllButtonCaption`, both hidden there, and mx check reported CE0463 +// (mendixlabs/mxcli#1583). + +const elseIfChainJS = `function getProperties(t,e){` + + `return"context"===e.source?(x.hidePropertiesIn(t,e,["a"])):` + + `"database"===e.source?(x.hidePropertiesIn(t,e,["b","c"]),` + + `"attribute"===e.ct?x.hidePropertiesIn(t,e,["d"]):x.hidePropertiesIn(t,e,["f"])):` + + `"static"===e.source&&x.hidePropertiesIn(t,e,["g"]),t}` + +// ruleHidesUnder reports whether any rule for prop fires under values, and +// whether a rule for it exists at all. +func ruleHidesUnder(rules []types.WidgetVisibilityRule, prop string, values map[string]string) (hidden, found bool) { + for _, r := range rules { + if r.PropertyKey != prop || r.ListPropertyKey != "" || r.HiddenWhen == nil { + continue + } + found = true + fires, determinable := r.Fires(func(c types.WidgetVisibilityCondition) (string, bool) { + v, ok := values[c.PropertyKey] + return v, ok + }) + if determinable && fires { + hidden = true + } + } + return hidden, found +} + +func TestElseIfChainBranchesAreLifted(t *testing.T) { + rules, _ := extractVisibilityRulesFromJS(elseIfChainJS) + + cases := []struct { + prop string + values map[string]string + want bool + }{ + // The first branch, which already worked — the control. + {"a", map[string]string{"source": "context"}, true}, + {"a", map[string]string{"source": "database"}, false}, + // The else-if's group: its first hide and its comma sibling. + {"b", map[string]string{"source": "database"}, true}, + {"b", map[string]string{"source": "context"}, false}, + {"b", map[string]string{"source": "static"}, false}, + {"c", map[string]string{"source": "database"}, true}, + {"c", map[string]string{"source": "static"}, false}, + // A ternary nested inside the else-if's group carries both terms. + {"d", map[string]string{"source": "database", "ct": "attribute"}, true}, + {"d", map[string]string{"source": "database", "ct": "expression"}, false}, + {"d", map[string]string{"source": "context", "ct": "attribute"}, false}, + {"f", map[string]string{"source": "database", "ct": "expression"}, true}, + {"f", map[string]string{"source": "database", "ct": "attribute"}, false}, + {"f", map[string]string{"source": "static", "ct": "expression"}, false}, + // The last branch, `: C && hide(…)`, guarded with no group of its own. + {"g", map[string]string{"source": "static"}, true}, + {"g", map[string]string{"source": "database"}, false}, + {"g", map[string]string{"source": "context"}, false}, + } + for _, c := range cases { + got, found := ruleHidesUnder(rules, c.prop, c.values) + if !found { + t.Errorf("%s: no rule lifted", c.prop) + continue + } + if got != c.want { + t.Errorf("%s under %v: hidden=%v, want %v", c.prop, c.values, got, c.want) + } + } +} + +// The terms of an else-if are only exact when every earlier condition is read. +// One the extractor cannot represent leaves the branch unlifted rather than +// lifted on its own condition — which would hide `b` wherever source is +// "database", including where the unread first branch runs instead. +func TestElseIfChainWithAnUnreadableBranchIsNotLifted(t *testing.T) { + js := `function getProperties(t,e){` + + `return e.source.startsWith("c")?(x.hidePropertiesIn(t,e,["a"])):` + + `"database"===e.source?(x.hidePropertiesIn(t,e,["b"])):0,t}` + rules, _ := extractVisibilityRulesFromJS(js) + if _, found := ruleHidesUnder(rules, "b", nil); found { + t.Errorf("lifted a rule for b although the chain's first condition is unreadable: %+v", rules) + } +} + +// The real widget: Combo box 2.5.0, the version a blank 11.12.5 app ships. +// optionsSourceType and selectAllButtonCaption are what mx update-widgets +// resets on a database-mode combo box mxcli wrote — the two differences +// between it and a widget that builds. +func TestComboBoxDatabaseBranchIsLifted(t *testing.T) { + js, err := mpk.ReadEditorConfig( + "../../testdata/expr-checker/widgets/com.mendix.widget.web.Combobox.mpk", + "com.mendix.widget.web.combobox.Combobox") + if err != nil || js == "" { + t.Fatalf("read Combo box editorConfig: %v", err) + } + rules, _ := extractVisibilityRulesFromJS(js) + + database := map[string]string{ + "source": "database", "optionsSourceType": "enumeration", "selectAllButton": "true", + "optionsSourceDatabaseCaptionType": "attribute", "optionsSourceDatabaseItemSelection": "Single", + } + for _, prop := range []string{"optionsSourceType", "selectAllButtonCaption", "onChangeEvent", + "optionsSourceDatabaseCaptionExpression", "selectedItemsSorting"} { + if hidden, found := ruleHidesUnder(rules, prop, database); !found || !hidden { + t.Errorf("%s: not hidden in database mode (rule found: %v)", prop, found) + } + } + // Shown in database mode: the rules must not over-fire. + for _, prop := range []string{"optionsSourceDatabaseCaptionAttribute", "optionsSourceDatabaseDataSource", + "databaseAttributeString"} { + if hidden, _ := ruleHidesUnder(rules, prop, database); hidden { + t.Errorf("%s: hidden in database mode, where the editor shows it", prop) + } + } + // And the control: in context/association mode with the select-all button + // on, both properties are shown. + association := map[string]string{ + "source": "context", "optionsSourceType": "association", "selectAllButton": "true", + "optionsSourceAssociationCustomContentType": "no", "showFooter": "false", + "optionsSourceAssociationCaptionType": "attribute", + } + for _, prop := range []string{"optionsSourceType", "selectAllButtonCaption"} { + if hidden, _ := ruleHidesUnder(rules, prop, association); hidden { + t.Errorf("%s: hidden in association mode, where the editor shows it", prop) + } + } +} diff --git a/mdl/executor/editorconfig_extract.go b/mdl/executor/editorconfig_extract.go index 5bf4eea6d4..6ced442799 100644 --- a/mdl/executor/editorconfig_extract.go +++ b/mdl/executor/editorconfig_extract.go @@ -159,14 +159,26 @@ func extractVisibilityRulesFromJS(js string) ([]types.WidgetVisibilityRule, edit if listKey != "" { itemIdent = enclosingForEachParam(js, callStart) } - cond, guardText, ok, conjunctive := parseGuard(js, callStart, itemIdent) + cond, guardText, ok, conjunctive, chain := parseGuard(js, callStart, itemIdent) // A guard inside `outer ? ( … inner && hide(x) … )` states only the INNER // term; the branch runs on outer too. Collect the enclosing group guards // so the rule carries the whole conjunction — Combo box nests three deep, // and a rule keeping only the innermost term claims hidden in // configurations the editor shows. var extra []types.WidgetVisibilityCondition - if ok && conjunctive { + if ok && len(chain) > 0 { + // An else-if branch: its own condition, the negated earlier + // branches, and whatever groups enclose the chain. Exact or not + // lifted — this shape had no rule before, so withholding one loses + // nothing. (An else-branch hide's chain IS its enclosing groups, so + // this only dedupes it.) + enclosing, enclosed := enclosingGroupConditions(js, callStart, itemIdent) + if !enclosed { + stats.SkippedComplex++ + continue + } + extra = dedupeConditions(cond, append(chain, enclosing...)) + } else if ok && conjunctive { enclosing, enclosed := enclosingGroupConditions(js, callStart, itemIdent) switch { case enclosed: @@ -663,7 +675,12 @@ var nsPrefixRE = regexp.MustCompile(`[A-Za-z_$][\w$]*\.$`) // (it sits inside a grouping paren carrying its own guard). Such a rule is only // safe when the ternary's other branch hides the same property anyway — see // hiddenInComplementaryBranch, which the caller consults. -func parseGuard(js string, callStart int, itemIdent string) (types.WidgetVisibilityCondition, string, bool, bool) { +// +// The fifth result holds the further conditions the rule must carry: for a +// guard that heads an else-if branch (`A ? x : guard && hide(…)`), the negated +// conditions of the chain's earlier branches — see elseChainTerms; for an +// else-branch hide inside a guarded group, that group's conditions. +func parseGuard(js string, callStart int, itemIdent string) (types.WidgetVisibilityCondition, string, bool, bool, []types.WidgetVisibilityCondition) { pre := strings.TrimRight(js[:callStart], " ") // Strip the widget-editor namespace prefix (any `.`, not just `_.`). if loc := nsPrefixRE.FindStringIndex(pre); loc != nil { @@ -686,6 +703,7 @@ func parseGuard(js string, callStart int, itemIdent string) (types.WidgetVisibil } // Identify the connector. var falsy bool // || connector or !prefix ⇒ hide when guard is falsy + elseBranch := false switch { case strings.HasSuffix(pre, "&&"): pre = pre[:len(pre)-2] @@ -710,17 +728,18 @@ func parseGuard(js string, callStart int, itemIdent string) (types.WidgetVisibil // with showLabel false. See the CE0463 that produced (ledger #104). cond, ok := ternaryCondition(pre[:len(pre)-1]) if !ok { - return types.WidgetVisibilityCondition{}, pre, false, false + return types.WidgetVisibilityCondition{}, pre, false, false, nil } pre = cond falsy = true + elseBranch = true default: - return types.WidgetVisibilityCondition{}, pre, false, false + return types.WidgetVisibilityCondition{}, pre, false, false, nil } - guard, boundary := lastGuardExpr(pre) + guard, boundary, boundaryAt := lastGuardExprAt(pre) guard = stripReturnPrefix(guard) // getProperties' first statement is `return && hide…` if guard == "" { - return types.WidgetVisibilityCondition{}, guard, false, false + return types.WidgetVisibilityCondition{}, guard, false, false, nil } // Skip guards nested inside a larger expression. A clean statement-level guard // is bounded by a statement separator (`,`, `;`, `{`, or start-of-input); a @@ -768,25 +787,47 @@ func parseGuard(js string, callStart int, itemIdent string) (types.WidgetVisibil conjunctive := boundary == ',' && insideOpenGroup(pre[:len(pre)-len(guard)]) && sameReceiver(groupGuard(js, callStart), guard) + var chain []types.WidgetVisibilityCondition switch boundary { case 0, ',', ';', '{': // clean + case ':': + // The guard heads an else branch: `A ? (…) : guard && hide(…)`, or the + // else-if `A ? (…) : guard ? (hide(…), …)`. The hide fires on the + // guard AND on every earlier branch's condition being false. + terms, ok := elseChainTerms(pre[:boundaryAt], enclosingAliases(js, callStart), itemIdent) + if !ok { + return types.WidgetVisibilityCondition{}, guard, false, false, nil + } + chain = terms case '?': // The guard is the THEN branch of a ternary and carries its own condition // (`outer ? inner && hide(...)`). Hand back the WHOLE expression, `?` // included, so the caller can split it and decide whether the else branch // makes the pair expressible — see ternaryThenCandidate (#238). - return types.WidgetVisibilityCondition{}, pre, false, false + return types.WidgetVisibilityCondition{}, pre, false, false, nil case '&': if !falsy { - return types.WidgetVisibilityCondition{}, guard, false, false + return types.WidgetVisibilityCondition{}, guard, false, false, nil } default: - return types.WidgetVisibilityCondition{}, guard, false, false + return types.WidgetVisibilityCondition{}, guard, false, false, nil } aliases := enclosingAliases(js, callStart) c, ok := guardToCondition(guard, falsy, aliases, itemIdent) - return c, guard, ok, conjunctive + // An else-branch hide inside a guarded group, `G ? (…, C ? hide(a) : + // hide(b), …)`, fires on G as well as on !C. Its guard was isolated from the + // group by the ternary walk above, so it read as statement-level and b was + // hidden wherever C is false — Combo box's `selectedItemsSorting`, hidden by + // the database branch whenever its item selection is not Multi, read as + // hidden in association mode too. Carried only when the whole enclosing + // chain reads; otherwise the rule stays as it was lifted before. + if ok && elseBranch && chain == nil { + if enclosing, enclosed := enclosingGroupConditions(js, callStart, itemIdent); enclosed && len(enclosing) > 0 { + chain = enclosing + } + } + return c, guard, ok, conjunctive, chain } // ternaryCondition returns the text preceding the `?` that matches a trailing @@ -881,6 +922,13 @@ func isIdentByte(b byte) bool { // terminated it (0 for start-of-input). The boundary lets the caller reject // guards nested inside a compound/ternary expression. func lastGuardExpr(pre string) (string, byte) { + expr, boundary, _ := lastGuardExprAt(pre) + return expr, boundary +} + +// lastGuardExprAt is lastGuardExpr plus the index of the boundary byte in pre +// (-1 for start-of-input), for a caller that has to keep walking past it. +func lastGuardExprAt(pre string) (string, byte, int) { depth := 0 var boundary byte i := len(pre) - 1 @@ -903,7 +951,46 @@ func lastGuardExpr(pre string) (string, byte) { } } done: - return strings.TrimSpace(pre[i+1:]), boundary + return strings.TrimSpace(pre[i+1:]), boundary, i +} + +// elseChainTerms returns the conditions under which an ELSE branch runs, for +// the branch opened by the `:` that follows before (exclusive): the negation +// of the ternary's own condition and, when that condition heads an else branch +// in turn, of every earlier one in the chain. +// +// A ? x : B ? y : → [!B, !A] +// +// Combo box selects its option source this way, and the database and static +// branches' hides were never lifted: their guard's boundary is the outer `:`, +// which reads as "one fragment of a larger expression" (mendixlabs/mxcli#1583). +// The chain ends at a statement separator or a group's `(` — the caller's +// enclosing-group walk carries on from there. Any condition the guard +// vocabulary cannot represent fails the whole chain: a branch lifted on its own +// condition fires where an unread earlier branch runs instead. +func elseChainTerms(before string, aliases map[string]string, itemIdent string) ([]types.WidgetVisibilityCondition, bool) { + var out []types.WidgetVisibilityCondition + for depth := 0; depth < maxGroupNesting; depth++ { + q := matchingTernaryQuestion(before) + if q < 0 { + return nil, false + } + text, boundary, at := lastGuardExprAt(before[:q]) + c, ok := guardToCondition(stripReturnPrefix(text), true, aliases, itemIdent) + if !ok { + return nil, false + } + out = append(out, c) + switch boundary { + case ':': + before = before[:at] + case 0, ',', ';', '{', '(': + return out, true + default: + return nil, false + } + } + return nil, false } var ( @@ -1054,6 +1141,11 @@ func impliesGroupGuard(js string, callStart int, cond types.WidgetVisibilityCond if cond.Operator != "eq" || cond.PropertyKey == "" { return false } + // A group in an else-if branch carries the chain's negations as well, + // which cond need not imply. + if _, elseIf, _ := groupElseChain(js, callStart, ""); elseIf { + return false + } text := groupGuard(js, callStart) if text == "" { return false @@ -1103,6 +1195,14 @@ func groupGuard(js string, callStart int) string { } head = head[:q] } + // An else-if group, `A ? (…) : B ? ( … )`: its condition is B, not + // the whole chain trailingExpr would return — which is no + // comparison, so every hide in the group went unread or lost its + // conjunction (mendixlabs/mxcli#1583). The negated A is + // groupElseChain's. + if g, boundary := lastGuardExpr(head); boundary == ':' && g != "" { + return g + } // trailingExpr, not lastGuardExpr: the ternary is rarely the first // thing in its function — ProgressCircle's is preceded by a whole // `switch` — and taking everything back to the enclosing `{` hands @@ -1291,11 +1391,66 @@ func enclosingGroupConditions(js string, callStart int, itemIdent string) ([]typ // a real term we cannot represent, so the conjunction is incomplete. return nil, false } + // A group whose condition heads an else branch runs only where the + // chain's earlier conditions are false. + chain, elseIf, chainOK := groupElseChain(js, at, itemIdent) + switch { + case chainOK: + out = append(out, chain...) + case elseIf: + return nil, false + } at = open } return nil, false } +// groupElseChain returns the negated earlier conditions of the ternary chain +// whose else branch the group enclosing at is guarded in: +// +// A ? (…) : B ? ( …at… ) → [!A] +// A ? (…) : B && ( …at… ) → [!A] +// +// elseIf reports whether the group's condition heads an else branch at all; ok +// whether the chain was read. A `?`/`:` group whose chain is unreadable is not +// representable (elseIf && !ok). An `&&`/`||` group keeps the reading it had +// before chains were followed — its rule stands on the group's own condition — +// so an unreadable chain there reports elseIf false rather than withdrawing a +// rule an older release lifted. +func groupElseChain(js string, at int, itemIdent string) (chain []types.WidgetVisibilityCondition, elseIf, ok bool) { + open := enclosingGroupOpen(js, at) + if open < 0 { + return nil, false, false + } + head := strings.TrimRight(js[:open], " ") + conditional := false + switch { + case strings.HasSuffix(head, "&&"), strings.HasSuffix(head, "||"): + head = head[:len(head)-2] + case strings.HasSuffix(head, "?"): + head = head[:len(head)-1] + conditional = true + case strings.HasSuffix(head, ":"): + q := matchingTernaryQuestion(head[:len(head)-1]) + if q < 0 { + return nil, false, false + } + head = head[:q] + conditional = true + default: + return nil, false, false + } + _, boundary, boundaryAt := lastGuardExprAt(head) + if boundary != ':' { + return nil, false, false + } + terms, read := elseChainTerms(head[:boundaryAt], enclosingAliases(js, at), itemIdent) + if !read { + return nil, conditional, false + } + return terms, true, true +} + // maxGroupNesting bounds the outward walk. Combo box reaches three; a file that // nests further answers "cannot read" and its rules are simply not lifted. const maxGroupNesting = 8 diff --git a/mdl/executor/issue1139_excluded_non_activity_test.go b/mdl/executor/issue1139_excluded_non_activity_test.go new file mode 100644 index 0000000000..d4270fad17 --- /dev/null +++ b/mdl/executor/issue1139_excluded_non_activity_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// mendixlabs/mxcli#1139 asked for Studio Pro's "Disable activity" in MDL; it +// is `@excluded` on the statement. Only an ActionActivity has the Disabled +// property (Model SDK: ActionActivity.disabled, nowhere else), so on a +// decision, a loop or an end event the annotation parsed, passed check, and +// was dropped on write — the activity the author meant to switch off ran. +func excludedViolations(t *testing.T, src string) []linter.Violation { + t.Helper() + prog, errs := visitor.Build(src) + for _, e := range errs { + t.Fatalf("parse error: %v", e) + } + var out []linter.Violation + var all []linter.Violation + switch s := prog.Statements[0].(type) { + case *ast.CreateMicroflowStmt: + all = ValidateMicroflow(s) + case *ast.CreateNanoflowStmt: + all = ValidateNanoflow(s) + default: + t.Fatalf("unexpected statement %T", s) + } + for _, v := range all { + if v.RuleID == excludedNonActivityRule { + out = append(out, v) + } + } + return out +} + +func TestExcludedOnNonActivityWarns_Issue1139(t *testing.T) { + for _, tc := range []struct{ name, src string }{ + {"decision", "create microflow M.F ($b: Boolean) begin @excluded if $b then log info 'a'; end if; end;"}, + {"loop", "create microflow M.F ($L: list of M.E) begin @excluded loop $i in $L begin log info 'a'; end loop; end;"}, + {"while", "create microflow M.F ($b: Boolean) begin @excluded while $b begin log info 'a'; end while; end;"}, + {"return", "create microflow M.F () begin log info 'a'; @excluded return; end;"}, + {"raise error", "create microflow M.F () begin log info 'a'; @excluded raise error; end;"}, + {"nested in a branch", "create microflow M.F ($b: Boolean) begin if $b then @excluded if $b then log info 'a'; end if; end if; end;"}, + {"nanoflow decision", "create nanoflow M.N ($b: Boolean) begin @excluded if $b then log info 'a'; end if; end;"}, + } { + t.Run(tc.name, func(t *testing.T) { + got := excludedViolations(t, tc.src) + if len(got) != 1 { + t.Fatalf("want one %s, got %v", excludedNonActivityRule, got) + } + if got[0].Severity != linter.SeverityWarning { + t.Errorf("severity = %v, want warning", got[0].Severity) + } + }) + } +} + +// The supported use — an activity — must stay quiet. +func TestExcludedOnActivityDoesNotWarn_Issue1139(t *testing.T) { + for _, src := range []string{ + "create microflow M.F () begin @excluded log info 'a'; end;", + "create microflow M.F ($b: Boolean) begin if $b then @excluded log info 'a'; end if; end;", + "create nanoflow M.N () begin @excluded declare $s String = 'x'; end;", + } { + if got := excludedViolations(t, src); len(got) != 0 { + t.Errorf("%s\n unexpected %v", src, got) + } + } +} diff --git a/mdl/executor/issue1543_log_stacktrace_test.go b/mdl/executor/issue1543_log_stacktrace_test.go new file mode 100644 index 0000000000..490a91dad9 --- /dev/null +++ b/mdl/executor/issue1543_log_stacktrace_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1543: "LOG cannot set 'Include latest stack trace'" — +// `LOG ERROR NODE 'X' 'failed' WITH STACKTRACE;` was a parse error, so every +// log written through MDL stored IncludeLatestStackTrace = false. +func buildLogAction(t *testing.T, body string) *microflows.LogMessageAction { + t.Helper() + prog, errs := visitor.Build("create or modify microflow M.F () begin " + body + " end;") + for _, e := range errs { + t.Fatalf("parse error: %v", e) + } + mf := prog.Statements[0].(*ast.CreateMicroflowStmt) + fb := &flowBuilder{ + posX: 100, posY: 100, spacing: HorizontalSpacing, + varTypes: map[string]string{}, declaredVars: map[string]string{}, + } + fb.buildFlowGraph(mf.Body, nil) + var logs []*microflows.LogMessageAction + for _, obj := range fb.objects { + if a, ok := obj.(*microflows.ActionActivity); ok { + if l, ok := a.Action.(*microflows.LogMessageAction); ok { + logs = append(logs, l) + } + } + } + if len(logs) != 1 { + t.Fatalf("want 1 log activity, got %d", len(logs)) + } + return logs[0] +} + +func TestLogWithStackTrace_Issue1543(t *testing.T) { + for _, tc := range []struct { + name, body string + want bool + }{ + {"reported spelling", "log error node 'X' 'failed' with stacktrace; return;", true}, + {"after template params", "log error 'failed {1}' with ({1} = 'a') with stacktrace; return;", true}, + {"before on error", "log error 'failed' with stacktrace on error continue; return;", true}, + {"absent", "log error node 'X' 'failed'; return;", false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := buildLogAction(t, tc.body).IncludeLastStackTrace; got != tc.want { + t.Errorf("IncludeLatestStackTrace = %v, want %v", got, tc.want) + } + }) + } + + // The template parameters and the stack trace are two clauses that both + // open with `with`; neither may swallow the other. + act := buildLogAction(t, "log error 'failed {1}' with ({1} = 'a') with stacktrace; return;") + if len(act.TemplateParameters) != 1 { + t.Errorf("template parameters lost next to `with stacktrace`: %v", act.TemplateParameters) + } +} + +// DESCRIBE has to print the flag, or a Studio Pro-authored log with the box +// ticked loses it on describe → exec. The rendered form must parse back to +// the same flag. +func TestDescribeLogWithStackTrace_Issue1543(t *testing.T) { + for _, include := range []bool{true, false} { + action := µflows.LogMessageAction{ + LogLevel: microflows.LogLevelError, + LogNodeName: "'X'", + IncludeLastStackTrace: include, + TemplateParameters: []string{"$Order/Number"}, + } + rendered := formatAction(nil, action, nil, nil) + if has := strings.Contains(rendered, "with stacktrace"); has != include { + t.Fatalf("IncludeLatestStackTrace=%v rendered as %q", include, rendered) + } + prog, errs := visitor.Build("create microflow M.A ($Order: M.Order) begin " + rendered + " end;") + if len(errs) > 0 { + t.Fatalf("DESCRIBE emitted MDL the parser rejects.\n rendered: %s\n errors: %v", rendered, errs) + } + log := prog.Statements[0].(*ast.CreateMicroflowStmt).Body[0].(*ast.LogStmt) + if log.IncludeLatestStackTrace != include { + t.Errorf("round trip changed the flag: %v -> %v (rendered %q)", include, log.IncludeLatestStackTrace, rendered) + } + } +} + +// `stacktrace` becoming a keyword must not take the System.Error attribute +// of the same name away from expressions: `$latestError/Stacktrace` is the +// usual thing to log from an error handler. +func TestLatestErrorStacktraceStillParses_Issue1543(t *testing.T) { + act := buildLogAction(t, "log error 'failed: {1}' with ({1} = $latestError/Stacktrace); return;") + if len(act.TemplateParameters) != 1 || !strings.Contains(act.TemplateParameters[0], "Stacktrace") { + t.Errorf("template parameter = %v, want $latestError/Stacktrace", act.TemplateParameters) + } +} diff --git a/mdl/executor/oql_case_type_test.go b/mdl/executor/oql_case_type_test.go new file mode 100644 index 0000000000..fbcbbab34a --- /dev/null +++ b/mdl/executor/oql_case_type_test.go @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#1294 — MDL031 demanded String(200) for a CASE column that Mendix +// derives as String(8), and following its "Fix:" produced CE6770. +// +// inferCaseType took the first branch it COULD type, not the first branch. In +// +// CASE WHEN … THEN l.Code WHEN … THEN '0' + l.Code ELSE '0000' END +// +// `l.Code` cannot be typed without a project, so the concatenation in the +// second branch decided — String(200) — while Mendix takes a string CASE's +// type from its FIRST branch: String(8), Code's length. +package executor + +import ( + "regexp" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// caseColumnMeasurements are one-column view entities over ServiceCore.Lane +// (Code String(8), Name String(100), Unl String) and ServiceCore.Num (IntV +// Integer, LngV Long, DecV Decimal). Each was declared at every length 1–16, +// 100, 108, 112, 200 and unlimited (strings) or as Integer, Long and Decimal +// (numbers), and checked with mx check 11.12.5: `accepted` is the only +// declaration that was not CE6770 "View Entity is out of sync with the OQL +// Query". +// +// Two rules fall out. A string CASE takes its FIRST branch's type, length +// included — not the longest branch (`then l.Code else l.Name` is String(8)), +// and not 200 unless that first branch is itself derived. A numeric CASE +// widens across ALL branches, Integer < Long < Decimal. +// +// staticJudges marks the rows a check without a project can decide: those +// whose deciding branches are literals or derived expressions. A branch that +// is a bare attribute needs the source entity. +var caseColumnMeasurements = []struct { + name string + expr string + accepted ast.DataType + staticJudges bool +}{ + {"the reported case", "case when length(l.Code) >= 4 then l.Code when length(l.Code) = 3 then '0' + l.Code else '0000' end", str(8), false}, + {"attribute then literal", "case when length(l.Code) >= 4 then l.Code else '0000' end", str(8), false}, + {"attribute without else", "case when length(l.Code) >= 4 then l.Code end", str(8), false}, + {"short attribute then long one", "case when length(l.Code) >= 4 then l.Code else l.Name end", str(8), false}, + {"long attribute then short one", "case when length(l.Code) >= 4 then l.Name else l.Code end", str(100), false}, + {"attribute then unlimited attribute", "case when length(l.Code) >= 4 then l.Code else l.Unl end", str(8), false}, + {"literal then attribute", "case when length(l.Code) >= 4 then '0000' else l.Code end", str(200), true}, + {"long literal then attribute", "case when length(l.Code) >= 4 then 'abcdefghijkl' else l.Code end", str(200), true}, + {"concatenation then attribute", "case when length(l.Code) >= 4 then '0' + l.Code else l.Code end", str(200), true}, + {"concatenation then literal", "case when length(l.Code) >= 4 then '0' + l.Code else '0000' end", str(200), true}, + {"literals only", "case when length(l.Code) >= 4 then 'ab' else 'abcdef' end", str(200), true}, + + {"integer then decimal literal", "case when l.IntV > 4 then l.IntV else 1.5 end", num(ast.TypeDecimal), true}, + {"integer then decimal", "case when l.IntV > 4 then l.IntV else l.DecV end", num(ast.TypeDecimal), false}, + {"decimal then integer", "case when l.IntV > 4 then l.DecV else l.IntV end", num(ast.TypeDecimal), false}, + {"integer literal then decimal", "case when l.IntV > 4 then 1 else l.DecV end", num(ast.TypeDecimal), false}, + {"integer then long", "case when l.IntV > 4 then l.IntV else l.LngV end", num(ast.TypeLong), false}, + {"long then integer", "case when l.IntV > 4 then l.LngV else l.IntV end", num(ast.TypeLong), false}, + {"integer and decimal literals", "case when l.IntV > 4 then 1 else 2.5 end", num(ast.TypeDecimal), true}, +} + +func str(n int) ast.DataType { return ast.DataType{Kind: ast.TypeString, Length: n} } +func num(k ast.DataTypeKind) ast.DataType { return ast.DataType{Kind: k} } + +// caseColumnCandidates are the declarations every measured row is tried with. +func caseColumnCandidates(accepted ast.DataType) []ast.DataType { + if accepted.Kind == ast.TypeString { + return []ast.DataType{str(4), str(8), str(9), str(100), str(200), str(0)} + } + return []ast.DataType{num(ast.TypeInteger), num(ast.TypeLong), num(ast.TypeDecimal)} +} + +func sameDeclaration(a, b ast.DataType) bool { + return a.Kind == b.Kind && (a.Kind != ast.TypeString || a.Length == b.Length) +} + +var fixSuggestionRe = regexp.MustCompile(`Fix: change to 'C: ([^']+)'`) + +func caseView(expr, source string, declared ast.DataType) *ast.CreateViewEntityStmt { + return &ast.CreateViewEntityStmt{ + Name: ast.QualifiedName{Module: "ServiceCore", Name: "V"}, + Attributes: []ast.ViewAttribute{{Name: "C", Type: declared}}, + Query: ast.OQLQuery{RawQuery: "select " + expr + " as C from ServiceCore." + source + " as l"}, + } +} + +func caseSource(expr string) string { + if regexp.MustCompile(`IntV|LngV|DecV`).MatchString(expr) { + return "Num" + } + return "Lane" +} + +// Without a project — the reported shape: the source entity is created by the +// same script, so `check` cannot type its attributes. The declaration mxbuild +// accepts must never be refused, and where the CASE can be typed from its +// literals the wrong ones must be, with the accepted type as the fix. +func TestCaseColumnType_WithoutProject(t *testing.T) { + for _, m := range caseColumnMeasurements { + t.Run(m.name, func(t *testing.T) { + for _, declared := range caseColumnCandidates(m.accepted) { + vs := ValidateOQLTypes("select "+m.expr+" as C from ServiceCore."+caseSource(m.expr)+" as l", + []ast.ViewAttribute{{Name: "C", Type: declared}}) + right := sameDeclaration(declared, m.accepted) + switch { + case right && len(vs) > 0: + t.Errorf("declared %s — the one declaration mxbuild accepts — was refused: %s %s", + formatDataTypeForError(declared), vs[0].Message, vs[0].Suggestion) + case !right && m.staticJudges && len(vs) == 0: + t.Errorf("declared %s was accepted; mxbuild reports CE6770 for it", + formatDataTypeForError(declared)) + } + for _, v := range vs { + if got := fixSuggestionRe.FindStringSubmatch(v.Suggestion); got == nil || + got[1] != formatDataTypeForMDL(m.accepted) { + t.Errorf("declared %s: suggestion %q, want the accepted %s", + formatDataTypeForError(declared), v.Suggestion, formatDataTypeForMDL(m.accepted)) + } + } + } + }) + } +} + +// With the source entity in the project, every branch can be typed, so every +// wrong declaration is refused — and the fix names the type mxbuild accepts, +// never one it rejects. +func TestCaseColumnType_WithProject(t *testing.T) { + attr := func(name string, typ domainmodel.AttributeType) *domainmodel.Attribute { + return &domainmodel.Attribute{BaseElement: model.BaseElement{ID: model.ID("a-" + name)}, Name: name, Type: typ} + } + lane := &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: "ent-lane", TypeName: "DomainModels$Entity"}, + Name: "Lane", Persistable: true, + Attributes: []*domainmodel.Attribute{ + attr("Code", &domainmodel.StringAttributeType{Length: 8}), + attr("Name", &domainmodel.StringAttributeType{Length: 100}), + attr("Unl", &domainmodel.StringAttributeType{}), + }, + } + nums := &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: "ent-num", TypeName: "DomainModels$Entity"}, + Name: "Num", Persistable: true, + Attributes: []*domainmodel.Attribute{ + attr("IntV", &domainmodel.IntegerAttributeType{}), + attr("LngV", &domainmodel.LongAttributeType{}), + attr("DecV", &domainmodel.DecimalAttributeType{}), + }, + } + ctx := dropCheckCtx(t, lane, nums) + for _, m := range caseColumnMeasurements { + t.Run(m.name, func(t *testing.T) { + for _, declared := range caseColumnCandidates(m.accepted) { + err := validateWithContext(ctx, caseView(m.expr, caseSource(m.expr), declared), newScriptContext()) + right := sameDeclaration(declared, m.accepted) + switch { + case right && err != nil: + t.Errorf("declared %s — the one declaration mxbuild accepts — was refused: %v", + formatDataTypeForError(declared), err) + case !right && err == nil: + t.Errorf("declared %s was accepted; mxbuild reports CE6770 for it", + formatDataTypeForError(declared)) + } + if err == nil { + continue + } + for _, got := range fixSuggestionRe.FindAllStringSubmatch(err.Error(), -1) { + if got[1] != formatDataTypeForMDL(m.accepted) { + t.Errorf("declared %s: the fix says %s, mxbuild accepts only %s", + formatDataTypeForError(declared), got[1], formatDataTypeForMDL(m.accepted)) + } + } + if strings.Contains(err.Error(), "pass-through column 'case") { + t.Errorf("a CASE is not a pass-through column: %v", err) + } + } + }) + } +} + +// The first branch decides a string CASE whatever comes after it, so an +// unlimited first branch is unlimited (measured: `then l.Unl else l.Code` +// builds only as `String`). The project path cannot refuse String(8) here — +// a source length of 0 is not judged (passthroughStringLengthMismatch, #585) +// — but it must not refuse the right answer either. +func TestCaseColumnType_UnlimitedFirstBranch(t *testing.T) { + lane := &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: "ent-lane", TypeName: "DomainModels$Entity"}, + Name: "Lane", Persistable: true, + Attributes: []*domainmodel.Attribute{ + {BaseElement: model.BaseElement{ID: "a-code"}, Name: "Code", Type: &domainmodel.StringAttributeType{Length: 8}}, + {BaseElement: model.BaseElement{ID: "a-unl"}, Name: "Unl", Type: &domainmodel.StringAttributeType{}}, + }, + } + ctx := dropCheckCtx(t, lane) + const expr = "case when length(l.Code) >= 4 then l.Unl else l.Code end" + if err := validateWithContext(ctx, caseView(expr, "Lane", str(0)), newScriptContext()); err != nil { + t.Errorf("declared String — what mxbuild accepts — was refused: %v", err) + } + if vs := ValidateOQLTypes("select "+expr+" as C from ServiceCore.Lane as l", + []ast.ViewAttribute{{Name: "C", Type: str(0)}}); len(vs) > 0 { + t.Errorf("declared String was refused without a project: %v", vs) + } +} diff --git a/mdl/executor/oql_type_inference.go b/mdl/executor/oql_type_inference.go index 560b037f1a..d002e29f58 100644 --- a/mdl/executor/oql_type_inference.go +++ b/mdl/executor/oql_type_inference.go @@ -203,9 +203,10 @@ func ValidateOQLTypes(oql string, attrs []ast.ViewAttribute) []linter.Violation if inferred.Kind == ast.TypeUnknown { continue } - // A DERIVED string column (CAST AS string, a string-returning CASE, a - // string literal/expression) always maps to Mendix's default length (200) - // in a view entity — a plain attribute reference infers TypeUnknown and is + // A DERIVED string column (CAST AS string, a string literal/expression, + // a CASE whose first branch is one of those) always maps to Mendix's + // default length (200) in a view entity — a plain attribute reference, + // and a CASE whose first branch is one, infers TypeUnknown and is // skipped above, so any concrete String here is derived. Declaring another // length makes mxbuild report CE6770 "out of sync"; normalize so the check // flags that mismatch pre-build. @@ -328,9 +329,9 @@ func inferTypeStatic(expr string) ast.DataType { return castTargetType(expr) } - // CASE expression: infer from THEN clauses + // CASE expression: infer from its result branches if strings.HasPrefix(upper, "CASE") { - return inferCaseType(expr) + return inferCaseType(expr, func(_ int, branch string) ast.DataType { return inferTypeStatic(branch) }) } // Numeric literals @@ -355,8 +356,8 @@ func inferTypeStatic(expr string) ast.DataType { } // derivedStringLength is the length Mendix assigns to a DERIVED string column in -// a view entity (CAST(x AS string), a string-returning CASE, a string -// expression). It is the platform default (200); declaring any other length for +// a view entity (CAST(x AS string), a string literal or expression, a CASE +// whose first branch is one of those). It is the platform default (200); declaring any other length for // such a column makes mxbuild report CE6770 "View Entity is out of sync". const derivedStringLength = 200 @@ -386,45 +387,91 @@ func castTargetType(expr string) ast.DataType { } } -// inferCaseType infers the type of a CASE expression by looking at THEN clause values. -// Only reports a type when it can be confidently inferred from a non-literal THEN branch. -// Falls back to ELSE only if all THEN branches are unknown AND the ELSE is non-trivial. -// A bare "0" in ELSE is ambiguous (could be Integer or Decimal depending on context). -func inferCaseType(expr string) ast.DataType { - // Nested CASE expressions are too complex for static regex-based inference. - // The regex would match inner THEN clauses, producing wrong types. - upperExpr := strings.ToUpper(expr) - if strings.Count(upperExpr, "CASE") > 1 { - return ast.DataType{Kind: ast.TypeUnknown} +// caseResultExprs returns a CASE expression's result branches in source order: +// each THEN's expression, then the ELSE's if there is one. Nil for a nested +// CASE, whose inner THENs the patterns would mistake for the outer's. +func caseResultExprs(expr string) []string { + if strings.Count(strings.ToUpper(expr), "CASE") > 1 { + return nil + } + var out []string + for _, m := range caseThenRe.FindAllStringSubmatch(expr, -1) { + out = append(out, strings.TrimSpace(m[1])) } + if m := caseElseRe.FindStringSubmatch(expr); m != nil { + out = append(out, strings.TrimSpace(m[1])) + } + return out +} - // Find THEN ... WHEN/ELSE/END patterns to extract result expressions - thenPattern := regexp.MustCompile(`(?i)\bTHEN\s+(.+?)(?:\s+when\b|\s+else\b|\s+end\b)`) - matches := thenPattern.FindAllStringSubmatch(expr, -1) - for _, match := range matches { - if len(match) >= 2 { - t := inferTypeStatic(strings.TrimSpace(match[1])) - if t.Kind != ast.TypeUnknown { - return t +var ( + caseThenRe = regexp.MustCompile(`(?is)\bTHEN\s+(.+?)(?:\s+when\b|\s+else\b|\s+end\b)`) + caseElseRe = regexp.MustCompile(`(?is)\bELSE\s+(.+?)\s+end\b`) +) + +// numericRank orders the numeric kinds a CASE widens across; 0 is not numeric. +func numericRank(k ast.DataTypeKind) int { + switch k { + case ast.TypeInteger: + return 1 + case ast.TypeLong: + return 2 + case ast.TypeDecimal: + return 3 + } + return 0 +} + +// inferCaseType derives a CASE column's type from its result branches, typing +// each with infer (i is the branch's position, 0 the first THEN). Mendix's two +// rules, measured with mx check 11.12.5 on one-column views declared at every +// candidate type (ako/mxcli#1294): +// +// - A String CASE takes its FIRST branch's type, length included. +// `then l.Code else l.Name` (8 and 100) is String(8), `then l.Name else +// l.Code` String(100), and `then l.Code … else '0000'` String(8) — a +// derived String(200) only when the first branch is itself derived. +// - A numeric CASE widens across ALL branches, Integer < Long < Decimal: +// `then l.IntV else l.LngV` is Long either way round, `then 1 else 2.5` +// Decimal. +// +// Taking the first branch that could be typed instead of the first branch made +// MDL031 demand String(200) for a CASE whose first branch is a String(8) +// attribute — an attribute is untypable without the project — and its fix was +// CE6770. A branch infer cannot type leaves the result unknown wherever it +// could decide it: a first branch that is not a typed String, or a numeric +// branch that could be wider than the rest. +func inferCaseType(expr string, infer func(i int, branch string) ast.DataType) ast.DataType { + var widest, other ast.DataType + untyped := false + for i, branch := range caseResultExprs(expr) { + t := infer(i, branch) + switch { + case i == 0 && t.Kind == ast.TypeString: + return t + case t.Kind == ast.TypeUnknown: + untyped = true + case numericRank(t.Kind) > 0: + if numericRank(t.Kind) > numericRank(widest.Kind) { + widest = t } + case other.Kind == ast.TypeUnknown: + other = t } } - // Try ELSE clause — but only if the value is not a bare integer literal. - // Bare "0" or "1" in ELSE are type-ambiguous fallback values that should - // not override the actual branch type (which may involve division or - // expressions the static inferrer can't parse). - elsePattern := regexp.MustCompile(`(?i)\bELSE\s+(.+?)\s+end\b`) - if match := elsePattern.FindStringSubmatch(expr); len(match) >= 2 { - elseExpr := strings.TrimSpace(match[1]) - // Skip bare integer literals — they're ambiguous in CASE context - if !regexp.MustCompile(`^-?\d+$`).MatchString(elseExpr) { - t := inferTypeStatic(elseExpr) - if t.Kind != ast.TypeUnknown { - return t - } + switch { + case widest.Kind != ast.TypeUnknown && other.Kind != ast.TypeUnknown: + return ast.DataType{Kind: ast.TypeUnknown} // mixed kinds: not a column mxbuild builds + case widest.Kind != ast.TypeUnknown: + if untyped && widest.Kind != ast.TypeDecimal { + return ast.DataType{Kind: ast.TypeUnknown} } + return widest + case other.Kind == ast.TypeString: + // A later branch is a string, but the length is the first branch's. + return ast.DataType{Kind: ast.TypeUnknown} } - return ast.DataType{Kind: ast.TypeUnknown} + return other } // passthroughStringLengthMismatch reports whether a PASS-THROUGH view-entity @@ -469,10 +516,17 @@ func passthroughStringLengthMismatch(declared, inferred ast.DataType, sourceAttr // The hardcoded String(200) in formatDataTypeForMDL is untouched and still // right where it is used: a DERIVED string column is String(200) by rule // whatever its source. +// +// A String CASE whose first branch is an attribute reaches here too, since it +// inherits that branch's length (inferCaseType), and is named as what it is. func passthroughLengthError(attrName string, declared, inferred ast.DataType, expression, sourceEntity, sourceAttr string) string { + column, via := "pass-through column", "" + if strings.HasPrefix(strings.ToUpper(expression), "CASE") { + column, via = "CASE column", " through its first branch" + } return fmt.Sprintf( - "attribute '%s': declared as %s but pass-through column '%s' inherits length %d from source attribute %s.%s — Mendix requires an exact length match (CE6770 \"View Entity out of sync\"). Fix: change to '%s: %s'", - attrName, formatDataTypeForError(declared), expression, inferred.Length, + "attribute '%s': declared as %s but %s '%s' inherits length %d%s from source attribute %s.%s — Mendix requires an exact length match (CE6770 \"View Entity out of sync\"). Fix: change to '%s: %s'", + attrName, formatDataTypeForError(declared), column, expression, inferred.Length, via, sourceEntity, sourceAttr, attrName, formatDataTypeForMDL(inferred)) } @@ -850,9 +904,25 @@ func inferTypeFromExpression(ctx *ExecContext, expr string, col *OQLColumnInfo, } } - // Check for string literal + // A string literal is a derived String(200) whatever its own length: `'abc'` + // builds only declared String(200) (measured, mx check 11.12.5, + // ako/mxcli#1294). if strings.HasPrefix(expr, "'") && strings.HasSuffix(expr, "'") { - return ast.DataType{Kind: ast.TypeString, Length: len(expr) - 2} + return ast.DataType{Kind: ast.TypeString, Length: derivedStringLength} + } + + // CASE: type each branch here as well, so an attribute branch resolves + // against the project rather than leaving the column unknown. A String CASE + // is its first branch, source attribute included — which puts it under the + // pass-through rule's exact length match — so col goes to that branch. + if strings.HasPrefix(strings.ToUpper(expr), "CASE") { + return inferCaseType(expr, func(i int, branch string) ast.DataType { + c := &OQLColumnInfo{} + if i == 0 { + c = col + } + return inferTypeFromExpression(ctx, branch, c, aliasMap) + }) } // Check for numeric literal @@ -1101,7 +1171,7 @@ func typesStrictlyCompatible(declared, inferred ast.DataType) bool { } // For a derived string column the LENGTH must also match: Mendix's view-entity // sync (CE6770) requires the declared length to equal the OQL column's length - // (200 for CAST/CASE/string expressions — see ValidateOQLTypes). Length 0 = + // (200 for a derived column — see ValidateOQLTypes). Length 0 = // unlimited, which also mismatches the fixed 200. if inferred.Kind == ast.TypeString { return declared.Length == inferred.Length diff --git a/mdl/executor/show_page_pages_to_close_test.go b/mdl/executor/show_page_pages_to_close_test.go new file mode 100644 index 0000000000..611150d788 --- /dev/null +++ b/mdl/executor/show_page_pages_to_close_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1617, the write half: `show page … with +// NumberOfPagesToClose = n` reaches the action. The `with` list read title, +// location and modal and ignored every other key, so the setting was dropped +// with `check` and `exec` both clean. +func TestBuildShowPage_NumberOfPagesToClose(t *testing.T) { + fb := buildFlowFromMDL(t, " show page MyFirstModule.Booking_Overview with NumberOfPagesToClose = 1;\n"+ + " show page MyFirstModule.Booking_Overview with title = 'Bookings', NumberOfPagesToClose = $Count;\n"+ + " show page MyFirstModule.Booking_Overview;") + var got []string + for _, o := range fb.objects { + if a, ok := o.(*microflows.ActionActivity); ok { + if sp, ok := a.Action.(*microflows.ShowPageAction); ok { + got = append(got, sp.NumberOfPagesToClose) + } + } + } + want := []string{"1", "$Count", ""} + if len(got) != len(want) { + t.Fatalf("built %d show page actions, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("action %d: NumberOfPagesToClose = %q, want %q", i+1, got[i], want[i]) + } + } +} + +// What describe prints for the setting builds the setting it printed. +func TestShowPage_NumberOfPagesToCloseRoundTrips(t *testing.T) { + e := newTestExecutor() + stored := µflows.ShowPageAction{PageName: "MyFirstModule.Booking_Overview", NumberOfPagesToClose: "1"} + described := e.formatAction(stored, nil, nil) + fb := buildFlowFromMDL(t, " "+described) + for _, o := range fb.objects { + if a, ok := o.(*microflows.ActionActivity); ok { + if sp, ok := a.Action.(*microflows.ShowPageAction); ok { + if sp.NumberOfPagesToClose != "1" { + t.Errorf("%q built NumberOfPagesToClose %q, want \"1\"", described, sp.NumberOfPagesToClose) + } + return + } + } + } + t.Fatalf("%q built no show page action", described) +} diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index b3dc7e4d83..88bdff7b04 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -94,6 +94,7 @@ type scriptContext struct { // task page, the targeting microflow, the context entity and the workflow are // ordinarily one script. pageParams map[string][]string // Module.Page (lower-cased) -> parameter entities + pageParamNames map[string][]string // Module.Page (lower-cased) -> parameter names, for show page arguments entityGeneralizations map[string]string // Module.Entity (lower-cased) -> generalization, "" for none // Associations and entity attributes declared in the script, for @@ -158,6 +159,7 @@ func newScriptContext() *scriptContext { flowParams: make(map[string]*flowSignature), pageParams: make(map[string][]string), + pageParamNames: make(map[string][]string), entityGeneralizations: make(map[string]string), } } @@ -315,6 +317,7 @@ func (sc *scriptContext) collectSingle(stmt ast.Statement) { if s.Name.Module != "" { sc.pages[s.Name.String()] = true sc.recordPageParams(s.Name.String(), s.Parameters) + sc.recordPageParamNames(s.Name.String(), s.Parameters) } case *ast.CreateSnippetStmtV3: if s.Name.Module != "" { @@ -1480,6 +1483,10 @@ func validateFlowBodyReferences(ctx *ExecContext, body []ast.MicroflowStatement, } } + if len(refs.pageCalls) > 0 { + errors = append(errors, validateShowPageParams(ctx, refs.pageCalls, sc)...) + } + if len(refs.javaActions) > 0 { known := buildJavaActionQualifiedNames(ctx) for _, ref := range refs.javaActions { @@ -1771,7 +1778,10 @@ type flowRefCollector struct { nanoflows []string // flowCalls are the microflow and nanoflow calls with the parameter names // written on them, for the CE1613 parameter-name check (#953). - flowCalls []flowCallRef + flowCalls []flowCallRef + // pageCalls are the show page actions with arguments, for the same check + // (mendixlabs/mxcli#1617). + pageCalls []codeActionCallRef javaActions []codeActionCallRef javaScriptActions []codeActionCallRef entities []entityRef @@ -1898,6 +1908,9 @@ func (c *flowRefCollector) collectFromStatements(stmts []ast.MicroflowStatement) case *ast.ShowPageStmt: if s.PageName.Module != "" { c.pages = append(c.pages, s.PageName.String()) + if names := showPageArgNames(s.Arguments); len(names) > 0 { + c.pageCalls = append(c.pageCalls, codeActionCallRef{name: s.PageName.String(), argNames: names}) + } } case *ast.CallMicroflowStmt: if s.MicroflowName.Module != "" { diff --git a/mdl/executor/validate_alter_page_refs.go b/mdl/executor/validate_alter_page_refs.go index 9c2d3b6df2..e7f360eab3 100644 --- a/mdl/executor/validate_alter_page_refs.go +++ b/mdl/executor/validate_alter_page_refs.go @@ -3,6 +3,7 @@ package executor import ( + "errors" "fmt" "strings" @@ -48,6 +49,9 @@ func validateAlterPageReferences(ctx *ExecContext, prog *ast.Program, sc *script h, _ := getHierarchy(ctx) added := widgetsTheScriptAddsTo(prog) grown := map[string]bool{} // documents an operation so far has added widgets to + // The widgets an operation so far has dropped, per document: this pass + // reads the stored document, where exec has already applied them. + dropped := map[string]map[string]bool{} opened := map[model.ID]backend.PageMutator{} var judge columnAttrJudge @@ -70,16 +74,28 @@ func validateAlterPageReferences(ctx *ExecContext, prog *ast.Program, sc *script // creates, or one that does not resolve (validateAlterTarget's // finding) or will not open. var mutator backend.PageMutator + var unitID, containerID model.ID if h != nil && !alterTargetComesFromScript(sc, s) { - if unitID, _, _, err := resolveAlterPageUnit(ctx, s, h); err == nil { - m, seen := opened[unitID] + if uid, cid, _, err := resolveAlterPageUnit(ctx, s, h); err == nil { + m, seen := opened[uid] if !seen { - m, _ = ctx.Backend.OpenPageForMutation(unitID) - opened[unitID] = m + m, _ = ctx.Backend.OpenPageForMutation(uid) + opened[uid] = m } - mutator = m + mutator, unitID, containerID = m, uid, cid } } + // The stored document as describe prints it, for a pluggable REPLACE's + // baseline — read once, and only when a REPLACE needs it, as exec does. + var described map[string]*ast.WidgetV3 + describedOnce := false + storedWidgets := func() map[string]*ast.WidgetV3 { + if !describedOnce { + describedOnce = true + described = describedStoredWidgets(ctx, unitID, kind, s.PageName) + } + return described + } // The references the inserted and replacing widgets carry. They need no // stored document, so a document the script creates is covered too. @@ -149,18 +165,47 @@ func validateAlterPageReferences(ctx *ExecContext, prog *ast.Program, sc *script if err := refuseHiddenItemSlotFor(ctx, mutator, op, ref); err != nil { errs = append(errs, mdlerrors.NewValidationf("%s: %v", label, err)) } + if r, isReplace := op.(*ast.ReplaceWidgetOp); isReplace && ref.Widget == r.Target.Widget { + err := refuseStaleContentReplace(ctx, mutator, r, h.GetModuleName(containerID), containerID, storedWidgets) + if err != nil && !droppedFirst(err, dropped[doc]) { + errs = append(errs, mdlerrors.NewValidationf("%s: %v", label, err)) + } + } } } } - switch op.(type) { + switch o := op.(type) { case *ast.InsertWidgetOp, *ast.ReplaceWidgetOp: grown[doc] = true + case *ast.DropWidgetOp: + if dropped[doc] == nil { + dropped[doc] = map[string]bool{} + } + for _, t := range o.Targets { + dropped[doc][t.Widget] = true + } } } } return errs } +// droppedFirst reports whether err is a stale-content refusal whose widgets an +// earlier DROP removes — the remedy the refusal itself gives, which exec sees +// applied and this pass, reading the stored document, does not. +func droppedFirst(err error, dropped map[string]bool) bool { + var stale *backend.StaleWidgetContentError + if !errors.As(err, &stale) || len(stale.Widgets) == 0 { + return false + } + for _, w := range stale.Widgets { + if w == "" || !dropped[w] { + return false + } + } + return true +} + // alterColumnInsertion returns the grid target and the columns of an INSERT or // REPLACE that exec routes down its DataGrid 2 column path, or no columns. func alterColumnInsertion(op ast.AlterPageOperation) (ast.WidgetRef, []*ast.WidgetV3) { diff --git a/mdl/executor/validate_alter_stale_content_1563_test.go b/mdl/executor/validate_alter_stale_content_1563_test.go new file mode 100644 index 0000000000..790169e57c --- /dev/null +++ b/mdl/executor/validate_alter_stale_content_1563_test.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "errors" + "fmt" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend" +) + +// ako/mxcli#1563: the refusal's remedy is `drop txtOptionName;` before the +// replace. exec applies the drop first and has nothing left to refuse; check +// reads the stored document, which still holds the widget, so it has to count +// the drop itself or it refuses the very script the refusal recommends. +func TestDroppedFirstDiscountsTheRecommendedDrop(t *testing.T) { + stale := &backend.StaleWidgetContentError{ + Widget: "cbBooking", Property: "optionsSourceAssociationCustomContent", + DataSource: "optionsSourceAssociationDataSource", StoredEntity: "M.Booking", NewEntity: "M.Sale", + Widgets: []string{"txtOptionName", "txtCode"}, WidgetTypes: []string{"Forms$DynamicText", "Forms$DynamicText"}, + } + wrapped := fmt.Errorf("replace: %w", stale) + + for _, tc := range []struct { + name string + err error + dropped map[string]bool + want bool + }{ + {"every widget dropped first", wrapped, map[string]bool{"txtOptionName": true, "txtCode": true}, true}, + // Controls: each still reports. + {"one widget left", wrapped, map[string]bool{"txtOptionName": true}, false}, + {"nothing dropped", wrapped, nil, false}, + {"another error", errors.New("replace: something else"), map[string]bool{"txtOptionName": true, "txtCode": true}, false}, + {"an unnamed widget cannot have been dropped", &backend.StaleWidgetContentError{Widgets: []string{""}}, + map[string]bool{"": true}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := droppedFirst(tc.err, tc.dropped); got != tc.want { + t.Errorf("droppedFirst = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/mdl/executor/validate_combobox_caption_test.go b/mdl/executor/validate_combobox_caption_test.go index ab3ac2e017..c7292ef837 100644 --- a/mdl/executor/validate_combobox_caption_test.go +++ b/mdl/executor/validate_combobox_caption_test.go @@ -86,3 +86,50 @@ func parseOneComboBox(t *testing.T, props string) *ast.WidgetV3 { } return found } + +// mendixlabs/mxcli#1583: the database mode has the same two required parts, +// and one more. Each row was one combo box on a fresh 11.12.5 project (Combo +// box 2.5.0) built with mxbuild; "want" is the CE0642 it reported, "" for a +// combo box that built clean. +func TestMDLWIDGET16_ComboBoxDatabaseModeRequired(t *testing.T) { + const ds = `optionsSourceDatabaseDataSource: database Bm.District` + cases := []struct { + name string + props string + want string // the required property mxbuild named + }{ + {"no caption", ds + `, Attribute: Name, optionsSourceDatabaseValueAttribute: Name`, "Caption"}, + {"target without a value attribute", ds + `, Attribute: Name, CaptionAttribute: Name`, "Value"}, + {"target by key without a value attribute", ds + `, databaseAttributeString: Name, CaptionAttribute: Name`, "Value"}, + {"caption type expression, no expression", ds + `, optionsSourceDatabaseCaptionType: expression, CaptionAttribute: Name`, "Caption"}, + + // Controls: each built at 0 errors. + {"target, value and caption", ds + `, Attribute: Name, CaptionAttribute: Name, optionsSourceDatabaseValueAttribute: Name`, ""}, + {"caption by key", ds + `, Attribute: Name, optionsSourceDatabaseCaptionAttribute: Name, optionsSourceDatabaseValueAttribute: Name`, ""}, + {"no target needs no value attribute", ds + `, CaptionAttribute: Name`, ""}, + {"caption expression", ds + `, optionsSourceDatabaseCaptionType: expression, optionsSourceDatabaseCaptionExpression: '$currentObject/Name'`, ""}, + {"Multi hides the target and value", ds + `, optionsSourceDatabaseItemSelection: Multi, CaptionAttribute: Name`, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var hits []string + for _, v := range validateComboBoxAssociation(parseOneComboBox(t, c.props), "page Bm.P") { + if v.RuleID == "MDL-WIDGET16" { + hits = append(hits, v.Message) + } + } + if c.want == "" { + if len(hits) > 0 { + t.Fatalf("built clean, but MDL-WIDGET16 fired: %q", hits) + } + return + } + if len(hits) != 1 { + t.Fatalf("want one MDL-WIDGET16 naming %q, got %q", c.want, hits) + } + if want := "Property '" + c.want + "' is required."; !strings.Contains(hits[0], "CE0642") || !strings.Contains(hits[0], want) { + t.Errorf("message should cite CE0642 and %q, got: %s", want, hits[0]) + } + }) + } +} diff --git a/mdl/executor/validate_datasource_args.go b/mdl/executor/validate_datasource_args.go index eb9691cb2d..f3d582f924 100644 --- a/mdl/executor/validate_datasource_args.go +++ b/mdl/executor/validate_datasource_args.go @@ -242,9 +242,9 @@ func validateFlowArgumentsIn( // cannot supply its own parameter. errs = append(errs, dataSourceArgErrors(w.Name, ds, sigs, enclosing, compatible)...) } - errs = append(errs, actionArgErrors(w, sigs, enclosing, compatible, controlBarOf)...) - inner := childContext(enclosing, ds, sigs, paramEntities) + errs = append(errs, actionArgErrors(w, sigs, enclosing, inner, compatible, controlBarOf)...) + // A widget that establishes its own data context ends the control bar's // reach: inside a dataview placed in a control bar, $currentObject is // that dataview's object like anywhere else. @@ -437,6 +437,10 @@ type namedAction struct { // the flow's parameters — CE1571 and the silent-typo argument, the same two // faults dataSourceArgErrors reports for a data source. // +// row is the context the widget's own data source puts in scope, which is what +// its row action is judged in (see isRowAction); every other slot is judged in +// enclosing, the context around the widget. +// // controlBarOf names the data widget whose control bar this widget sits in, and // is "" everywhere else. It only changes the advice: the remedy there is the // grid's selection, which is the one thing a control-bar author needs told and @@ -444,7 +448,7 @@ type namedAction struct { func actionArgErrors( w *ast.WidgetV3, sigs map[string]*flowSignature, - enclosing dataContext, + enclosing, row dataContext, compatible func(contextQN, paramQN string) bool, controlBarOf string, ) []string { @@ -454,7 +458,11 @@ func actionArgErrors( if !known || sig == nil { continue // resolution is validateWidgetReferences' job } - missing, unknown, missingEntity := argDiff(na.Action.Args, sig, enclosing, compatible) + judgedIn := enclosing + if isRowAction(w, na.Property) { + judgedIn = row + } + missing, unknown, missingEntity := argDiff(na.Action.Args, sig, judgedIn, compatible) if len(missing) > 0 { msg := fmt.Sprintf( @@ -466,7 +474,7 @@ func actionArgErrors( na.Property, na.Action.Type, na.Action.Target, missing[0]) if hint := controlBarHint(controlBarOf); hint != "" { msg += hint - } else if hint := contextHint(enclosing, missingEntity); hint != "" { + } else if hint := contextHint(judgedIn, missingEntity); hint != "" { msg += hint } out = append(out, msg) @@ -482,6 +490,22 @@ func actionArgErrors( return out } +// isRowAction reports whether a widget's action slot fires per row of the +// widget's own data source, so that the row — not what surrounds the widget — +// is what Mendix fills the flow's parameter in from. +// +// That is a list widget's own click: `Action:` / `OnClick:` on a list view, +// data grid or gallery, both spellings landing on "Action". Studio Pro stores it +// with no parameter mappings (TestApp ConflictedWorkflowDefinitionView +// dataGrid2_2), and judged in the enclosing context it was reported as CE1571 +// on pages mxbuild 10.24.25 builds clean (ako/mxcli#1560). A slot that does not +// fire per row — a gallery's onSelectionChange, whose template links no data +// source — keeps the enclosing context. It is the rule argContextForOwnAction +// applies to show page (#552). +func isRowAction(w *ast.WidgetV3, property string) bool { + return property == "Action" && w.GetDataSource() != nil +} + // controlBarHint is the advice for an action in a control bar, which replaces // the ordinary "nest it in a data container" hint: a control bar is not // row-scoped, so nesting is not the remedy — the grid's selection is. diff --git a/mdl/executor/validate_flow_args_row_action_1560_test.go b/mdl/executor/validate_flow_args_row_action_1560_test.go new file mode 100644 index 0000000000..7bb71fa08a --- /dev/null +++ b/mdl/executor/validate_flow_args_row_action_1560_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#1560 (mendixlabs/mxcli#1628): "a grid or list view … calls +// M.ACT_Open whose parameter is filled in from the container; describe prints +// `Action: microflow M.ACT_Open` and check rejects it with CE1571". +// +// A list widget's OWN click action fires per row, and the row is what fills +// the parameter in: Studio Pro stores it with no parameter mappings at all +// (TestApp ConflictedWorkflowDefinitionView dataGrid2_2, ConflictResolution +// dataGrid2_1, WorkflowDefinition_Overview dataGrid21). The rule judged that +// action against the context AROUND the widget, as for a button. Measured on +// mxbuild 10.24.25, one project, one `mx check`, every action with no argument: +// +// list view, Action: ACT_OpenItem($Item: Item), source Item → no error +// data grid 2, OnClick: the same → no error +// gallery, OnClick: the same → no CE1571 +// action button, no data context (control) → CE1571 +// +// It is the rule argContextForOwnAction already applies to show page (#552). + +// listWidget is a list widget of the given type bound to an entity, with its own +// click action under "Action" — where both `Action:` and `OnClick:` land. +func listWidget(typ, name, entity string, action *ast.ActionV3, extra map[string]any) *ast.WidgetV3 { + props := map[string]any{"DataSource": &ast.DataSourceV3{Type: "database", Reference: entity}} + if action != nil { + props["Action"] = action + } + for k, v := range extra { + props[k] = v + } + return &ast.WidgetV3{Name: name, Type: typ, Properties: props} +} + +func openItemSigs() map[string]*flowSignature { + return map[string]*flowSignature{ + "m.act_openitem": objSig("Item", "M.Item"), + "m.mf_other": {Returns: "M.Other"}, + } +} + +func openItem() *ast.ActionV3 { return &ast.ActionV3{Type: "microflow", Target: "M.ACT_OpenItem"} } + +func TestRowAction_ListWidgetsOwnClickIsRowScoped(t *testing.T) { + for _, typ := range []string{"listview", "datagrid", "gallery"} { + t.Run(typ, func(t *testing.T) { + page := []*ast.WidgetV3{listWidget(typ, "lst", "M.Item", openItem(), nil)} + if errs := validateFlowArgumentsIn(nil, page, openItemSigs(), sameName); len(errs) != 0 { + t.Errorf("the row supplies the argument (measured: no CE1571); got %v", errs) + } + }) + } +} + +// The nanoflow call and a row object of a specialization are the same case. +func TestRowAction_NanoflowAndSpecializationAreRowScoped(t *testing.T) { + sigs := map[string]*flowSignature{"m.nf_openitem": objSig("Item", "M.Item")} + page := []*ast.WidgetV3{ + listWidget("datagrid", "dg", "M.Item", &ast.ActionV3{Type: "nanoflow", Target: "M.NF_OpenItem"}, nil), + } + if errs := validateFlowArgumentsIn(nil, page, sigs, sameName); len(errs) != 0 { + t.Errorf("nanoflow row action reported: %v", errs) + } +} + +// Controls. Each is the same widget with one thing changed, and each still +// reports — so the tests above cannot pass through the rule having gone quiet. + +// The row is the wrong type: the row does not supply it, and nothing else does. +func TestRowAction_RowOfAnotherTypeIsReported(t *testing.T) { + page := []*ast.WidgetV3{listWidget("listview", "lstOther", "M.Other", openItem(), nil)} + errs := validateFlowArgumentsIn(nil, page, openItemSigs(), sameName) + if len(errs) != 1 || !strings.Contains(errs[0], "CE1571") || !strings.Contains(errs[0], "lstOther") { + t.Fatalf("a row of another type supplies nothing; got %v", errs) + } +} + +// A primitive parameter is never filled in from a row. +func TestRowAction_PrimitiveParameterIsReported(t *testing.T) { + sigs := map[string]*flowSignature{ + "m.act_openitem": {Params: []flowParam{{Name: "Code", Object: false}}}, + } + page := []*ast.WidgetV3{listWidget("datagrid", "dgCode", "M.Item", openItem(), nil)} + if errs := validateFlowArgumentsIn(nil, page, sigs, sameName); len(errs) != 1 { + t.Fatalf("a primitive parameter is never supplied by a row; got %v", errs) + } +} + +// A slot that does not fire per row — a gallery's onSelectionChange, whose +// template links no data source — is judged where the widget sits, as before. +func TestRowAction_NonRowSlotKeepsTheEnclosingContext(t *testing.T) { + page := []*ast.WidgetV3{ + listWidget("gallery", "galSel", "M.Item", nil, map[string]any{"onSelectionChange": openItem()}), + } + errs := validateFlowArgumentsIn(nil, page, openItemSigs(), sameName) + if len(errs) != 1 || !strings.Contains(errs[0], "onSelectionChange") { + t.Fatalf("onSelectionChange is not row-scoped; got %v", errs) + } +} + +// A button outside every data context — the measured CE1571 control. +func TestRowAction_LooseButtonStillReported(t *testing.T) { + page := []*ast.WidgetV3{{ + Name: "btnLoose", Type: "actionbutton", + Properties: map[string]any{"Action": openItem()}, + }} + if errs := validateFlowArgumentsIn(nil, page, openItemSigs(), sameName); len(errs) != 1 { + t.Fatalf("measured CE1571; got %v", errs) + } +} + +// A data source is never its own context: a list widget whose microflow source +// takes an Item is still reported with no Item around it, row action or not. +func TestRowAction_DataSourceStaysOnTheEnclosingContext(t *testing.T) { + sigs := map[string]*flowSignature{ + "m.ds_items": {Params: []flowParam{{Name: "Item", Entity: "M.Item", Object: true}}, Returns: "M.Item"}, + "m.act_openitem": objSig("Item", "M.Item"), + } + w := &ast.WidgetV3{Name: "dgDS", Type: "datagrid", Properties: map[string]any{ + "DataSource": &ast.DataSourceV3{Type: "microflow", Reference: "M.DS_Items"}, + "Action": openItem(), + }} + errs := validateFlowArgumentsIn(nil, []*ast.WidgetV3{w}, sigs, sameName) + if len(errs) != 1 || !strings.Contains(errs[0], "data source") { + t.Fatalf("want the data source reported and the row action not; got %v", errs) + } +} diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index 7a5c51a818..77570c90dc 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -1875,6 +1875,7 @@ func (v *microflowValidator) checkUnknownAnnotations(s ast.MicroflowStatement) { "`@anchor(iterator: (…), tail: (…))` — either side of a pair may be left out (mendixlabs/mxcli#992).") } v.checkCaseAnchors(s, ann) + v.checkExcludedOnNonActivity(s, ann) for _, name := range ann.UnknownNames { v.addViolation("MDL059", linter.SeverityError, fmt.Sprintf("unknown annotation `@%s` — it parses but does nothing, so whatever it was "+ @@ -1934,6 +1935,42 @@ func (v *microflowValidator) checkAnnotationLabels(body []ast.MicroflowStatement walk(body) } +// excludedNonActivityRule is `@excluded` on a statement that builds no activity. +const excludedNonActivityRule = "MDL095" + +// checkExcludedOnNonActivity raises MDL095 for `@excluded` on a statement that +// does not build an action activity. On an activity it is Studio Pro's "Disable +// activity" (ActionActivity.Disabled); the Model SDK declares that property on +// ActionActivity and nowhere else, so on a decision, a loop or an event the +// builder has nowhere to put it and drops it — and the logic the author meant +// to switch off runs (mendixlabs/mxcli#1139). +func (v *microflowValidator) checkExcludedOnNonActivity(s ast.MicroflowStatement, ann *ast.ActivityAnnotations) { + if !ann.Excluded { + return + } + var what string + switch s.(type) { + case *ast.IfStmt, *ast.EnumSplitStmt, *ast.InheritanceSplitStmt: + what = "a decision" + case *ast.LoopStmt, *ast.WhileStmt: + what = "a loop" + case *ast.ReturnStmt: + what = "an end event" + case *ast.RaiseErrorStmt: + what = "an error event" + case *ast.BreakStmt, *ast.ContinueStmt: + what = "a break or continue event" + case *ast.MergeStmt, *ast.JoinStmt: + what = "a merge" + default: + return + } + v.addViolation(excludedNonActivityRule, linter.SeverityWarning, + "@excluded on "+what+" has no effect — only an activity can be disabled (Disabled is "+ + "a property of the action activity alone), so the annotation is dropped and nothing is disabled.", + "Put @excluded on the activities themselves, e.g. each activity inside the branch or loop body") +} + // checkCaptionOnLoop raises MDL042 for a @caption on a loop or a while loop. Both // build a Microflows$LoopedActivity, which has no Caption property -- Studio Pro // labels a for-each loop from its iterator and a while loop from its condition -- diff --git a/mdl/executor/validate_show_page_params.go b/mdl/executor/validate_show_page_params.go new file mode 100644 index 0000000000..a7e05bd5e3 --- /dev/null +++ b/mdl/executor/validate_show_page_params.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// validateShowPageParams checks that each argument of a show page names a +// parameter of the page. One that does not is CE1613 "The selected page +// parameter 'M.P.X' no longer exists" (mxbuild 11.12.5), and exec writes it as +// given. The reported case was `show page M.P (NumberOfPagesToClose: 1)`, +// which reads as an argument for a page parameter of that name rather than as +// the action's setting (mendixlabs/mxcli#1617). A page that does not resolve is +// the not-found check's to report. +func validateShowPageParams(ctx *ExecContext, calls []codeActionCallRef, sc *scriptContext) []string { + var stored map[string][]string + var errs []string + for _, call := range calls { + key := strings.ToLower(call.name) + declared, ok := sc.pageParamNames[key] + if !ok { + if stored == nil { + stored = buildPageParamNames(ctx) + } + declared, ok = stored[key] + } + if !ok { + continue + } + for _, msg := range validateCodeActionParams("page", call, declared) { + for _, written := range call.argNames { + if strings.EqualFold(written, "NumberOfPagesToClose") && strings.Contains(msg, `"`+written+`"`) { + msg += " NumberOfPagesToClose is the action's setting, not a page parameter: " + + "write `show page " + call.name + " with NumberOfPagesToClose = 1`." + } + } + errs = append(errs, msg) + } + } + return errs +} + +// showPageArgNames is the parameter names a show page's arguments name. +func showPageArgNames(args []ast.ShowPageArg) []string { + var names []string + for _, a := range args { + if a.ParamName != "" { + names = append(names, a.ParamName) + } + } + return names +} + +// buildPageParamNames maps each stored page (lower-cased qualified name) to +// its parameter names. A page with no parameters is present with an empty, +// non-nil list: known to take none, so any argument is the CE1613. +func buildPageParamNames(ctx *ExecContext) map[string][]string { + out := map[string][]string{} + h, err := getHierarchy(ctx) + if err != nil || h == nil { + return out + } + all, err := ctx.Backend.ListPages() + if err != nil { + return out + } + for _, p := range all { + if p == nil { + continue + } + names := []string{} + for _, param := range p.Parameters { + if param != nil { + names = append(names, param.Name) + } + } + out[strings.ToLower(h.GetQualifiedName(p.ContainerID, p.Name))] = names + } + return out +} + +// recordPageParamNames stores a script-declared page's parameter names, for +// the show page argument check: the page and the flow opening it are usually +// one script. +func (sc *scriptContext) recordPageParamNames(qualifiedName string, params []ast.PageParameter) { + names := make([]string, 0, len(params)) + for _, p := range params { + names = append(names, p.Name) + } + sc.pageParamNames[strings.ToLower(qualifiedName)] = names +} diff --git a/mdl/executor/validate_show_page_params_test.go b/mdl/executor/validate_show_page_params_test.go new file mode 100644 index 0000000000..53f36b95c5 --- /dev/null +++ b/mdl/executor/validate_show_page_params_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1617: `show page M.P (NumberOfPagesToClose: 1)` is an +// argument for a page parameter called NumberOfPagesToClose, not the action's +// setting. `check --references` said "All references valid" and exec wrote it; +// mxbuild 11.12.5 then failed it with CE1613 "The selected page parameter +// 'MyFirstModule.Booking_Overview.NumberOfPagesToClose' no longer exists". The +// flow calls had this check (#953); show page did not. +package executor + +import ( + "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/pages" +) + +func TestValidate_ShowPageParameterNames(t *testing.T) { + arg := func(name string) []ast.ShowPageArg { + return []ast.ShowPageArg{{ParamName: name, Value: &ast.LiteralExpr{Kind: ast.LiteralInteger, Value: int64(1)}}} + } + show := func(page string, args []ast.ShowPageArg) ast.MicroflowStatement { + return &ast.ShowPageStmt{PageName: qn("M", page), Arguments: args} + } + + mod := &model.Module{Name: "M"} + mod.ID = "mod1" + stored := &pages.Page{Name: "Stored", Parameters: []*pages.PageParameter{{Name: "Order"}}} + stored.ContainerID = "mod1" + storedNone := &pages.Page{Name: "StoredNone"} + storedNone.ContainerID = "mod1" + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListPagesFunc: func() ([]*pages.Page, error) { return []*pages.Page{stored, storedNone}, nil }, + } + + cases := []struct { + name string + stmt ast.MicroflowStatement + wantErr []string + }{ + {"the reported spelling, on a page created in the script", show("Booking_Overview", arg("NumberOfPagesToClose")), []string{ + `page M.Booking_Overview has no parameter "NumberOfPagesToClose" (declared parameters: none)`, + `CE1613 "The selected page parameter`, + "with NumberOfPagesToClose = 1", + }}, + {"a misspelt parameter of a stored page", show("Stored", arg("Ordr")), []string{ + `page M.Stored has no parameter "Ordr" (declared parameters: Order)`, + }}, + {"a stored page taking no parameters", show("StoredNone", arg("Order")), []string{ + `(declared parameters: none)`, + }}, + {"control: a declared parameter of a stored page", show("Stored", arg("Order")), nil}, + {"control: no arguments", show("StoredNone", nil), nil}, + {"control: a page that does not resolve is the not-found check's", show("Missing", arg("X")), []string{ + "page not found: M.Missing", + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, _ := newMockCtx(t, withBackend(mb)) + body := []ast.MicroflowStatement{tc.stmt} + prog := &ast.Program{Statements: []ast.Statement{ + &ast.CreatePageStmtV3{Name: qn("M", "Booking_Overview")}, + &ast.CreateMicroflowStmt{Name: qn("M", "Caller"), Body: body}, + }} + sc := newScriptContext() + sc.collectDefinitions(prog) + errs := validateFlowBodyReferences(ctx, body, sc) + if len(tc.wantErr) == 0 { + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + return + } + if len(errs) != 1 { + t.Fatalf("errors = %v, want one", errs) + } + for _, w := range tc.wantErr { + if !strings.Contains(errs[0], w) { + t.Errorf("the error does not say %q:\n%s", w, errs[0]) + } + } + }) + } +} diff --git a/mdl/executor/validate_widget_attribute_type.go b/mdl/executor/validate_widget_attribute_type.go index 97545efa62..2ba843c552 100644 --- a/mdl/executor/validate_widget_attribute_type.go +++ b/mdl/executor/validate_widget_attribute_type.go @@ -72,9 +72,18 @@ var builtinInputAttributeRules = map[string]inputAttributeRule{ // a separate Boolean source (`attributeBoolean`), which MDL does not write. var comboBoxEnumerationRule = inputAttributeRule{"combo box", kindSet("Enumeration"), "Enumeration", "CE7247"} +// comboBoxDatabaseRule is the combo box in database mode, which stores the +// chosen option's value on `databaseAttributeString` (mendixlabs/mxcli#1583). +// Measured on a fresh 11.12.5 project, Combo box 2.5.0, one combo box per +// kind: Boolean, DateTime, Decimal, AutoNumber and Binary were CE7247 "Only +// attributes of type String, Integer, Long, Enumeration are allowed here." +// Its attribute may also be written under that key. +var comboBoxDatabaseRule = inputAttributeRule{"combo box", kindSet("String", "Integer", "Long", "Enumeration"), + "String, Integer, Long, Enumeration", "CE7247"} + // inputAttributeRuleFor is the rule w's attribute is judged by. A combo box -// with an options data source is in association mode and binds an -// association, not an attribute, so it has none. +// is judged in the mode exec builds it in (comboBoxMode): the association mode +// binds an association, not an attribute, so it has none. func inputAttributeRuleFor(w *ast.WidgetV3) (rule inputAttributeRule, isComboBox, ok bool) { if w.TypeIsGeneric { return inputAttributeRule{}, false, false @@ -83,6 +92,9 @@ func inputAttributeRuleFor(w *ast.WidgetV3) (rule inputAttributeRule, isComboBox if comboBoxHasOptionsDataSource(w) { return inputAttributeRule{}, true, false } + if comboBoxMode(w) == "database" { + return comboBoxDatabaseRule, true, true + } return comboBoxEnumerationRule, true, true } rule, ok = builtinInputAttributeRules[strings.ToLower(w.Type)] @@ -337,7 +349,11 @@ func (v *attributeScopeValidator) checkInputBinding(w *ast.WidgetV3, enclosing d if !ok { return } - attr := strings.TrimPrefix(w.GetAttribute(), "$currentObject/") + attr := w.GetAttribute() + if attr == "" && isComboBox && comboBoxMode(w) == "database" { + attr = w.GetStringProp("databaseAttributeString") + } + attr = strings.TrimPrefix(attr, "$currentObject/") if attr == "" || strings.HasPrefix(attr, "$") || enclosing.unresolved || len(enclosing.entities) == 0 { return } diff --git a/mdl/executor/validate_widget_attribute_type_test.go b/mdl/executor/validate_widget_attribute_type_test.go index 0e7fdc539a..f9f694ded0 100644 --- a/mdl/executor/validate_widget_attribute_type_test.go +++ b/mdl/executor/validate_widget_attribute_type_test.go @@ -347,3 +347,38 @@ func TestCheck_ComboBoxAssociationModeIsNotTypeChecked(t *testing.T) { } } } + +// mendixlabs/mxcli#1583: a combo box in DATABASE mode — selected by naming its +// own datasource, `optionsSourceDatabaseDataSource` — stores the chosen value on +// `databaseAttributeString`, which takes String, Integer, Long or Enumeration. +// Judged as enumeration mode, a String target was refused with CE7247, so the +// MDL describe prints for such a combo box could not be re-executed. Measured on +// a fresh 11.12.5 project (Combo box 2.5.0), one combo box per type: Boolean, +// DateTime, Decimal, AutoNumber and Binary are CE7247 "Only attributes of type +// String, Integer, Long, Enumeration are allowed here."; the other four build. +func TestCheck_ComboBoxDatabaseModeAttributeTypes(t *testing.T) { + ctx := typeCheckCtx(t, "Yes") + attrs := map[string]string{ + "Name": "String", "Color": "Enumeration", "Num": "Integer", "LongNum": "Long", + "Flag": "Boolean", "WhenAt": "DateTime", "Amount": "Decimal", "Seq": "AutoNumber", "Blob": "Binary", + } + accepted := map[string]bool{"String": true, "Enumeration": true, "Integer": true, "Long": true} + for _, spelling := range []string{"Attribute", "databaseAttributeString"} { + for attrName, kind := range attrs { + body := fmt.Sprintf(`dataview dv (datasource: $Thing) { combobox cb1 (optionsSourceDatabaseDataSource: database from Sales.Other, `+ + `%s: %s, CaptionAttribute: Txt, optionsSourceDatabaseValueAttribute: Txt) }`, spelling, attrName) + errs := typeErrs(t, ctx, body, nil) + if want := !accepted[kind]; (len(errs) > 0) != want { + t.Errorf("%s: %s (%s): flagged=%v, want %v (%q)", spelling, attrName, kind, len(errs) > 0, want, errs) + continue + } + if len(errs) > 0 { + for _, want := range []string{"MDL-WIDGET39", "CE7247", "String, Integer, Long, Enumeration"} { + if !strings.Contains(errs[0], want) { + t.Errorf("%s: %s: error should mention %q, got: %s", spelling, attrName, want, errs[0]) + } + } + } + } + } +} diff --git a/mdl/executor/validate_widget_explicit_writable.go b/mdl/executor/validate_widget_explicit_writable.go index 054297f780..eae32c3ee9 100644 --- a/mdl/executor/validate_widget_explicit_writable.go +++ b/mdl/executor/validate_widget_explicit_writable.go @@ -11,12 +11,12 @@ import ( // explicitPassWritableTypes are the property value types the widget engine's // explicit-property pass (PluggableWidgetEngine.Build, step 4.6) writes by -// storage key: Expression, TextTemplate and Attribute through their own -// setters, and the scalar types as a primitive. A value of any other type +// storage key: Expression, TextTemplate, Attribute and Selection through their +// own setters, and the scalar types as a primitive. A value of any other type // (objects, widgets, icons, images, actions, datasources…) has no correct // route there, so MDL-WIDGET06 stays true for it. var explicitPassWritableTypes = map[string]bool{ - "expression": true, "texttemplate": true, "attribute": true, + "expression": true, "texttemplate": true, "attribute": true, "selection": true, "string": true, "boolean": true, "integer": true, "decimal": true, "enumeration": true, } diff --git a/mdl/executor/validate_widget_gallery_click.go b/mdl/executor/validate_widget_gallery_click.go index b63c5f856a..44e978cc49 100644 --- a/mdl/executor/validate_widget_gallery_click.go +++ b/mdl/executor/validate_widget_gallery_click.go @@ -30,9 +30,13 @@ func validateGalleryClickAmbiguity(w *ast.WidgetV3, locationPrefix string) []lin if w == nil || !strings.EqualFold(w.Type, "gallery") || w.GetAction() == nil { return nil } + // By the keyword or by the property's own key, which the builder writes + // alike (mendixlabs/mxcli#1618): `itemSelection: None` is no selection. selection := "Single" if v, ok := lookupProperty(w.Properties, "Selection"); ok { selection = stringifyAny(v) + } else if v, ok := lookupProperty(w.Properties, "itemSelection"); ok { + selection = stringifyAny(v) } trigger := "single" if v, ok := lookupProperty(w.Properties, "onClickTrigger"); ok { diff --git a/mdl/executor/validate_widget_selection.go b/mdl/executor/validate_widget_selection.go new file mode 100644 index 0000000000..1e8fbb414f --- /dev/null +++ b/mdl/executor/validate_widget_selection.go @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// validateWidgetSelectionValues is MDL-WIDGET50, the check-time half of +// selectionMappingValue: a selection property given a value that is not a +// selection, or given two different values under its two spellings (the +// `Selection:` keyword and the property's own key). exec refuses both; before +// mendixlabs/mxcli#1618 the key's value was stored as a PrimitiveValue and +// `itemSelection: Bogus` passed check, exec and mx check alike. +// +// def is w's definition; schema stands in for it when there is none (a Data +// grid 2 without a project), its Selection-typed keys being the properties. +func validateWidgetSelectionValues(w *ast.WidgetV3, def *WidgetDefinition, schema *embeddedWidgetSchema, locationPrefix string) []linter.Violation { + if w == nil || len(w.Properties) == 0 { + return nil + } + groups := selectionSpellings(def, schema) + if len(groups) == 0 { + return nil + } + var out []linter.Violation + report := func(msg string) { + out = append(out, linter.Violation{ + RuleID: "MDL-WIDGET50", + Severity: linter.SeverityError, + Message: locationPrefix + ": " + msg, + Suggestion: "Write one of `Selection: None`, `Selection: Single` or `Selection: Multi`.", + }) + } + checked := map[string]bool{} + for _, names := range groups { + var firstKey, firstVal string + for _, name := range names { + raw, ok := lookupProperty(w.Properties, name) + if !ok { + continue + } + key := writtenPropertyName(w, name) + v := stringifyPropValue(raw) + if !isSelectionValue(v) { + if !checked[strings.ToLower(key)] { + report(selectionValueError(w.Name, key, v)) + } + checked[strings.ToLower(key)] = true + continue + } + checked[strings.ToLower(key)] = true + switch { + case firstKey == "": + firstKey, firstVal = key, v + case canonicalSelection(v) != canonicalSelection(firstVal): + report(selectionDisagreementError(w.Name, firstKey, firstVal, key, v)) + } + } + } + return out +} + +// selectionSpellings lists, per selection property, the names it can be +// written under: the mapping's keyword, its key and its aliases. Without a +// definition the schema's Selection-typed keys are the properties, and the +// keyword names one only when there is exactly one. +func selectionSpellings(def *WidgetDefinition, schema *embeddedWidgetSchema) [][]string { + var groups [][]string + if def != nil { + mappings := append([]PropertyMapping(nil), def.PropertyMappings...) + for _, m := range def.Modes { + mappings = append(mappings, m.PropertyMappings...) + } + seen := map[string]bool{} + for _, m := range mappings { + if m.Operation != "selection" || seen[strings.ToLower(m.PropertyKey)] { + continue + } + seen[strings.ToLower(m.PropertyKey)] = true + var names []string + if m.Source != "" { + names = append(names, m.Source) + } + groups = append(groups, append(append(names, m.PropertyKey), m.MdlAliases...)) + } + return groups + } + if schema == nil { + return nil + } + var keys []string + for k, t := range schema.types { + if t == "Selection" { + keys = append(keys, k) + } + } + sort.Strings(keys) + for _, k := range keys { + names := []string{k} + if len(keys) == 1 { + names = append([]string{"Selection"}, k) + } + groups = append(groups, names) + } + return groups +} + +// writtenPropertyName is the spelling w used for the property name matches +// case-insensitively, for a message that quotes the script. +func writtenPropertyName(w *ast.WidgetV3, name string) string { + if _, ok := w.Properties[name]; ok { + return name + } + for k := range w.Properties { + if strings.EqualFold(k, name) { + return k + } + } + return name +} diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index a6d82e32d3..070a96078e 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -18,6 +18,7 @@ import ( "sort" "strconv" "strings" + "sync" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/linter" @@ -339,6 +340,8 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc out = append(out, validateWidgetExpressionValuesIn(w, def, nil, isObjectListItem, embedded, locationPrefix)...) // An Expression property's value of the wrong kind (MDL-WIDGET49). out = append(out, validateWidgetExpressionReturnTypes(w, def, embedded, locationPrefix)...) + // A selection that is not one, or two of them (MDL-WIDGET50, #1618). + out = append(out, validateWidgetSelectionValues(w, def, embedded, locationPrefix)...) } // A generic widget type that resolved to nothing is already reported as // MDL-WIDGET25 (the kind is wrong). Validating its properties on top of @@ -461,6 +464,9 @@ func validateComboBoxAssociation(w *ast.WidgetV3, locationPrefix string) []linte if comboBoxHasOptionsDataSource(w) { return validateComboBoxCaption(w, locationPrefix) } + if comboBoxMode(w) == "database" { + return validateComboBoxDatabase(w, locationPrefix) + } if w.GetStringProp("Association") == "" { return nil } @@ -474,6 +480,34 @@ func validateComboBoxAssociation(w *ast.WidgetV3, locationPrefix string) []linte }} } +// comboBoxDefinition is the built-in combo box definition. Check asks its mode +// selection rather than restating it, so the two cannot drift. +var comboBoxDefinition = sync.OnceValue(func() *WidgetDefinition { + reg, err := NewWidgetRegistry() + if err != nil { + return nil + } + def, _ := reg.Get("COMBOBOX") + return def +}) + +// comboBoxMode is the mode exec builds a combo box in — "association", +// "database" or "default" (enumeration) — by the definition's own conditions: +// the generic `datasource:` clause or the association key selects association +// mode, `optionsSourceDatabaseDataSource` the database mode +// (mendixlabs/mxcli#1583). "" when it cannot be told. +func comboBoxMode(w *ast.WidgetV3) string { + def := comboBoxDefinition() + if def == nil || w == nil { + return "" + } + mode, err := (&PluggableWidgetEngine{}).selectMode(def, w) + if err != nil || mode == nil { + return "" + } + return mode.Name +} + // comboBoxHasOptionsDataSource reports whether a ComboBox carries the option // list of association mode. The option list may also be given under the schema // key the widget declares for it. Reading only the generic clause made @@ -538,6 +572,50 @@ func validateComboBoxCaption(w *ast.WidgetV3, locationPrefix string) []linter.Vi }} } +// validateComboBoxDatabase is MDL-WIDGET16 for the database mode +// (mendixlabs/mxcli#1583): the option caption, and — when the combo box stores +// the chosen option on an attribute of its own object — the option attribute +// whose value it stores. Measured on a fresh 11.12.5 project, Combo box 2.5.0, +// one combo box per row: +// +// no caption CE0642 'Caption' +// caption type expression, only CaptionAttribute CE0642 'Caption' +// Attribute: (or databaseAttributeString:), no value CE0642 'Value' +// no target attribute, no value attribute ok +// optionsSourceDatabaseItemSelection: Multi ok (hides both) +// caption expression with the expression type ok +func validateComboBoxDatabase(w *ast.WidgetV3, locationPrefix string) []linter.Violation { + var out []linter.Violation + required := func(property, why, fix string) { + out = append(out, linter.Violation{ + RuleID: "MDL-WIDGET16", + Severity: linter.SeverityError, + Message: fmt.Sprintf("%s: combobox `%s` takes its options from `optionsSourceDatabaseDataSource` (database mode) %s — "+ + "mxbuild fails the build with CE0642 (\"Property '%s' is required.\")", locationPrefix, w.Name, why, property), + Suggestion: fix, + }) + } + if strings.EqualFold(comboBoxPropText(w, "optionsSourceDatabaseCaptionType"), "expression") { + if comboBoxPropText(w, "optionsSourceDatabaseCaptionExpression") == "" { + required("Caption", "and sets `optionsSourceDatabaseCaptionType: expression` but no `optionsSourceDatabaseCaptionExpression`", + "Add `optionsSourceDatabaseCaptionExpression: '$currentObject/'`, or drop the caption type and use `CaptionAttribute: `.") + } + } else if comboBoxPropText(w, "CaptionAttribute") == "" && comboBoxPropText(w, "optionsSourceDatabaseCaptionAttribute") == "" { + required("Caption", "but has no caption", + "Name the attribute shown for each option: `CaptionAttribute: `, or give an expression caption: "+ + "`optionsSourceDatabaseCaptionType: expression, optionsSourceDatabaseCaptionExpression: '$currentObject/'`.") + } + multi := strings.EqualFold(comboBoxPropText(w, "optionsSourceDatabaseItemSelection"), "Multi") || + strings.EqualFold(w.GetSelection(), "Multi") + target := w.GetAttribute() != "" || comboBoxPropText(w, "databaseAttributeString") != "" + if !multi && target && comboBoxPropText(w, "optionsSourceDatabaseValueAttribute") == "" { + required("Value", "and stores the chosen option on an attribute, but does not say which of the option's attributes to store", + "Name it: `optionsSourceDatabaseValueAttribute: `. "+ + "Without a target attribute (`Attribute:`) the combo box only selects, and needs no value attribute.") + } + return out +} + // comboBoxPropText is a ComboBox property's value as text, "" when it is absent // or empty. An expression value that is not a plain string counts as set. func comboBoxPropText(w *ast.WidgetV3, key string) string { @@ -1734,6 +1812,24 @@ func validatePluggableWidgetProperties(w *ast.WidgetV3, registry *WidgetRegistry continue } + // A key the definition maps by its own name that the installed widget + // does not declare is written nowhere. Gallery 3.4.0 has no `advanced`, + // which the hand-written definition sets to a constant, so + // `advanced: true` was dropped with check and exec clean + // (mendixlabs/mxcli#1618). Known only from the project's package. + if def.declaredKeys != nil && !def.declaredKeys[lower] && mapsOwnKey(def, lower) { + out = append(out, linter.Violation{ + RuleID: "MDL-WIDGET01", + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s: %s has no property `%s` — mxcli's definition maps one, but the installed widget %s does not declare it, so the value would be dropped", + locationPrefix, widgetLabel(w.Name, def.MDLName), key, def.WidgetID, + ), + Suggestion: "Remove it: this version of the widget has no such setting.", + }) + continue + } + if allowed[lower] { continue } @@ -1773,6 +1869,21 @@ func validatePluggableWidgetProperties(w *ast.WidgetV3, registry *WidgetRegistry return out } +// mapsOwnKey reports whether a mapping of def (or of one of its modes) writes +// the property named lower, the key itself lower-cased. +func mapsOwnKey(def *WidgetDefinition, lower string) bool { + mappings := append([]PropertyMapping(nil), def.PropertyMappings...) + for _, m := range def.Modes { + mappings = append(mappings, m.PropertyMappings...) + } + for _, m := range mappings { + if strings.ToLower(m.PropertyKey) == lower { + return true + } + } + return false +} + // datasourceTypedKeys returns the lowercased propertyKeys whose def.json mapping // has operation "datasource" (across the top-level mappings and every mode). // addMappingNames records the MDL names a PropertyMapping is authorable under. diff --git a/mdl/executor/widget_combobox_database_test.go b/mdl/executor/widget_combobox_database_test.go new file mode 100644 index 0000000000..8ec3e1f1fd --- /dev/null +++ b/mdl/executor/widget_combobox_database_test.go @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "path/filepath" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1583 (ako/mxcli#1488): a Combo box in database mode +// +// combobox cbCode (source: 'database', optionsSourceDatabaseDataSource: microflow Mod.GET_Parents, +// optionsSourceDatabaseItemSelection: 'Single', optionsSourceDatabaseCaptionAttribute: Name, +// optionsSourceDatabaseValueAttribute: Name, databaseAttributeString: Code) +// +// "check reports 3 issues: 0 errors, 3 warnings" (two MDL-WIDGET06 "recognized +// but not yet persisted"), "exec reports Created page Mod.Child_Edit", and mx +// check reports "[CE0463] The definition of this widget has changed" at Combo +// box 'cbCode'. +// +// The definition had no database mode: the datasource had no write path, the +// item selection went through the explicit-key pass as a PrimitiveValue, and +// the caption and value attributes — linked to the unwritten datasource — bound +// against the enclosing data view's entity instead. + +// comboBoxDatabaseEngine builds inside a data view over Mod.Child (Code, Name), +// with Mod.GET_Parents returning a list of Mod.Parent (Name) created earlier in +// the same script, and the REAL combo box template. +func comboBoxDatabaseEngine(t *testing.T) *PluggableWidgetEngine { + t.Helper() + return comboBoxDatabaseEngineIn(t, "") +} + +// comboBoxDatabaseEngineIn is comboBoxDatabaseEngine in a project, whose +// installed widget packages supply the visibility rules the build applies. +func comboBoxDatabaseEngineIn(t *testing.T, mprPath string) *PluggableWidgetEngine { + t.Helper() + real := &modelsdkbackend.Backend{} + mod := &model.Module{BaseElement: model.BaseElement{ID: model.ID("mod-mod")}, Name: "Mod"} + b := &mock.MockBackend{ + PathFunc: func() string { return mprPath }, + LoadWidgetTemplateFunc: real.LoadWidgetTemplate, + 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-mod")}, + 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-child")}, Name: "Child", + Attributes: []*domainmodel.Attribute{{Name: "Code"}, {Name: "Name"}}}, + }, + }}, nil + }, + } + pb := &pageBuilder{ + backend: b, + entityContext: "Mod.Child", + paramEntityNames: map[string]string{}, + widgetScope: map[string]model.ID{}, + execCache: &executorCache{createdMicroflows: map[string]*createdMicroflowInfo{ + "Mod.GET_Parents": {ID: model.ID("mf-get-parents"), Name: "GET_Parents", ModuleName: "Mod", + ReturnEntityName: "Mod.Parent"}, + }}, + } + e := NewPluggableWidgetEngine(b, pb) + pb.pluggableEngine = e + return e +} + +func realComboBoxDef(t *testing.T) *WidgetDefinition { + t.Helper() + reg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("registry: %v", err) + } + def, ok := reg.Get("COMBOBOX") + if !ok { + t.Fatal("no COMBOBOX definition") + } + return def +} + +func buildComboBox(t *testing.T, src string) *pages.CustomWidget { + t.Helper() + widget, err := comboBoxDatabaseEngine(t).Build(realComboBoxDef(t), parseSnippetWidget(t, src)) + if err != nil { + t.Fatalf("Build: %v", err) + } + return widget +} + +func storedSelection(t *testing.T, cw *pages.CustomWidget, key string) string { + t.Helper() + s, _ := bsonField(customWidgetValue(t, cw, key), "Selection").(string) + return s +} + +// storedMicroflowSource returns the microflow a datasource property is bound +// to, "" when it holds no microflow source. +func storedMicroflowSource(t *testing.T, cw *pages.CustomWidget, key string) string { + t.Helper() + ds, _ := bsonField(customWidgetValue(t, cw, key), "DataSource").(bson.D) + if typ, _ := bsonField(ds, "$Type").(string); typ != "Forms$MicroflowSource" { + return "" + } + settings, _ := bsonField(ds, "MicroflowSettings").(bson.D) + mf, _ := bsonField(settings, "Microflow").(string) + return mf +} + +const reportedDatabaseComboBox = `combobox cbCode (source: 'database', optionsSourceDatabaseDataSource: microflow Mod.GET_Parents, ` + + `optionsSourceDatabaseItemSelection: 'Single', optionsSourceDatabaseCaptionAttribute: Name, ` + + `optionsSourceDatabaseValueAttribute: Name, databaseAttributeString: Code)` + +func assertDatabaseComboBox(t *testing.T, cw *pages.CustomWidget, selection string) { + t.Helper() + if got := storedPrimitive(t, cw, "source"); got != "database" { + t.Errorf("source = %q, want database", got) + } + if got := storedMicroflowSource(t, cw, "optionsSourceDatabaseDataSource"); got != "Mod.GET_Parents" { + t.Errorf("optionsSourceDatabaseDataSource microflow = %q, want Mod.GET_Parents — the datasource was dropped", got) + } + if got := storedSelection(t, cw, "optionsSourceDatabaseItemSelection"); got != selection { + t.Errorf("optionsSourceDatabaseItemSelection Selection = %q, want %q", got, selection) + } + // A selection lives in the value's Selection field. Writing it as a + // PrimitiveValue too is a value Studio Pro never stores there. + if got := storedPrimitive(t, cw, "optionsSourceDatabaseItemSelection"); got != "" { + t.Errorf("optionsSourceDatabaseItemSelection PrimitiveValue = %q, want empty", got) + } + // Linked to optionsSourceDatabaseDataSource (widget.xml dataSource=…): + // attributes of the option entity, not of the data view's Mod.Child. + for _, key := range []string{"optionsSourceDatabaseCaptionAttribute"} { + if got := storedAttribute(t, cw, key); got != "Mod.Parent.Name" { + t.Errorf("%s = %q, want Mod.Parent.Name", key, got) + } + } + // Linked to no datasource: the attribute of the enclosing object. + if got := storedAttribute(t, cw, "databaseAttributeString"); got != "Mod.Child.Code" { + t.Errorf("databaseAttributeString = %q, want Mod.Child.Code", got) + } +} + +func TestComboBoxDatabase1488_ReportedScriptIsWritten(t *testing.T) { + cw := buildComboBox(t, reportedDatabaseComboBox) + assertDatabaseComboBox(t, cw, "Single") + if got := storedAttribute(t, cw, "optionsSourceDatabaseValueAttribute"); got != "Mod.Parent.Name" { + t.Errorf("optionsSourceDatabaseValueAttribute = %q, want Mod.Parent.Name", got) + } +} + +// In a project the build applies the installed package's visibility rules, and +// a database-mode combo box hides properties the template holds at another +// value. These two are what mx update-widgets changed on the widget mxcli wrote +// — the CE0463 once the datasource and selection were in place: the hidden +// `optionsSourceType` must hold its declared default, and the hidden +// `selectAllButtonCaption` text template must be null. +func TestComboBoxDatabase1488_HiddenPropertiesAreAtTheirDefaults(t *testing.T) { + mpr, err := filepath.Abs("../../testdata/expr-checker/minimal.mpr") + if err != nil { + t.Fatal(err) + } + e := comboBoxDatabaseEngineIn(t, mpr) + cw, err := e.Build(realComboBoxDef(t), parseSnippetWidget(t, reportedDatabaseComboBox)) + if err != nil { + t.Fatalf("Build: %v", err) + } + assertDatabaseComboBox(t, cw, "Single") + if got := storedPrimitive(t, cw, "optionsSourceType"); got != "association" { + t.Errorf("optionsSourceType = %q, want its declared default association — hidden in database mode", got) + } + if tt := bsonField(customWidgetValue(t, cw, "selectAllButtonCaption"), "TextTemplate"); tt != nil { + t.Errorf("selectAllButtonCaption TextTemplate = %v, want null — hidden in database mode", tt) + } +} + +// The keyword spelling the association mode already has — `Attribute:` for the +// attribute the widget stores into, `CaptionAttribute:` for the option caption — +// selected by the datasource key alone, with no `source:`. +func TestComboBoxDatabase1488_KeywordSpelling(t *testing.T) { + cw := buildComboBox(t, `combobox cbCode (optionsSourceDatabaseDataSource: microflow Mod.GET_Parents, `+ + `Attribute: Code, CaptionAttribute: Name)`) + assertDatabaseComboBox(t, cw, "Single") + // The enumeration and association slots stay empty: the bare `Attribute:` + // belongs to the database mode's target attribute here. + for _, key := range []string{"attributeEnumeration", "attributeAssociation"} { + if got := storedAttribute(t, cw, key); got != "" { + t.Errorf("%s = %q, want empty in database mode", key, got) + } + } +} + +func TestComboBoxDatabase1488_MultiSelection(t *testing.T) { + cw := buildComboBox(t, `combobox cbCode (optionsSourceDatabaseDataSource: microflow Mod.GET_Parents, `+ + `optionsSourceDatabaseItemSelection: Multi, CaptionAttribute: Name)`) + if got := storedSelection(t, cw, "optionsSourceDatabaseItemSelection"); got != "Multi" { + t.Errorf("optionsSourceDatabaseItemSelection Selection = %q, want Multi", got) + } + if got := storedPrimitive(t, cw, "optionsSourceDatabaseItemSelection"); got != "" { + t.Errorf("optionsSourceDatabaseItemSelection PrimitiveValue = %q, want empty", got) + } +} + +// The control: an association-mode combo box is untouched — no source switch, +// its own datasource, the database slots empty. +func TestComboBoxDatabase1488_AssociationModeUnchanged(t *testing.T) { + cw := buildComboBox(t, `combobox cbCode (DataSource: microflow Mod.GET_Parents)`) + if got := storedPrimitive(t, cw, "source"); got != "context" { + t.Errorf("source = %q, want context for an association-mode combo box", got) + } + if got := storedMicroflowSource(t, cw, "optionsSourceDatabaseDataSource"); got != "" { + t.Errorf("optionsSourceDatabaseDataSource = %q, want empty in association mode", got) + } + if got := storedMicroflowSource(t, cw, "optionsSourceAssociationDataSource"); got != "Mod.GET_Parents" { + t.Errorf("optionsSourceAssociationDataSource = %q, want Mod.GET_Parents", got) + } +} + +// storedDatabaseComboBox is a database-mode combo box as it is stored: the +// shipped template with the values exec (and Studio Pro) write for the reported +// configuration — the hidden association-mode `optionsSourceType` back at its +// declared default. +func storedDatabaseComboBox(t *testing.T, selection string) map[string]any { + t.Helper() + w := widgetFromTemplate(t, comboBoxWidgetID, "cbCode") + gridValue(t, w, "source")["PrimitiveValue"] = "database" + gridValue(t, w, "optionsSourceType")["PrimitiveValue"] = "association" + gridValue(t, w, "optionsSourceDatabaseDataSource")["DataSource"] = + dsBSON(dsTypeMicroflow, "MicroflowSettings", map[string]any{"Microflow": "Mod.GET_Parents"}) + gridValue(t, w, "optionsSourceDatabaseItemSelection")["Selection"] = selection + attr := func(key, qn string) { + gridValue(t, w, key)["AttributeRef"] = map[string]any{"$Type": "DomainModels$AttributeRef", "Attribute": qn} + } + attr("optionsSourceDatabaseCaptionAttribute", "Mod.Parent.Name") + if selection != "Multi" { // Multi hides both + attr("optionsSourceDatabaseValueAttribute", "Mod.Parent.Name") + attr("databaseAttributeString", "Mod.Child.Code") + } + return w +} + +// DESCRIBE spelled only the association and enumeration modes. A database-mode +// combo box came back as `combobox cbCode (DataSource: microflow …)` — the +// generic clause, which selects the ASSOCIATION mode on exec — with its +// target, caption and value attributes named as not reproduced. +func TestDescribeComboBox1488_DatabaseModeRoundTrips(t *testing.T) { + got := describeParsed(t, storedDatabaseComboBox(t, "Single")) + for _, want := range []string{ + "optionsSourceDatabaseDataSource: microflow Mod.GET_Parents", + "Attribute: Code", + "CaptionAttribute: Name", + // Bare where the microflow's return entity resolves (a project), and + // qualified where it cannot (here) — exec reads both. + "optionsSourceDatabaseValueAttribute: ", + } { + if !strings.Contains(got, want) { + t.Errorf("describe lacks %q:\n%s", want, got) + } + } + for _, unwanted := range []string{"(DataSource:", " DataSource:", "NOT re-executable", "source:", "optionsSourceType"} { + if strings.Contains(got, unwanted) { + t.Errorf("describe prints %q:\n%s", unwanted, got) + } + } + + // And the description builds the same widget again. + stmt := got[strings.Index(got, "combobox"):] + rebuilt := buildComboBox(t, stmt) + assertDatabaseComboBox(t, rebuilt, "Single") + if v := storedAttribute(t, rebuilt, "optionsSourceDatabaseValueAttribute"); v != "Mod.Parent.Name" { + t.Errorf("re-executed optionsSourceDatabaseValueAttribute = %q, want Mod.Parent.Name", v) + } +} + +func TestDescribeComboBox1488_MultiSelectionRoundTrips(t *testing.T) { + got := describeParsed(t, storedDatabaseComboBox(t, "Multi")) + if !strings.Contains(got, "optionsSourceDatabaseItemSelection: Multi") { + t.Fatalf("describe lacks the Multi selection:\n%s", got) + } + stmt := got[strings.Index(got, "combobox"):] + if sel := storedSelection(t, buildComboBox(t, stmt), "optionsSourceDatabaseItemSelection"); sel != "Multi" { + t.Errorf("re-executed selection = %q, want Multi", sel) + } +} + +// A database-mode combo box saved before its option list was chosen — a state +// Studio Pro allows — describes without one rather than failing. +func TestDescribeComboBox1488_DatabaseModeWithoutDataSource(t *testing.T) { + w := storedDatabaseComboBox(t, "Single") + delete(gridValue(t, w, "optionsSourceDatabaseDataSource"), "DataSource") + got := describeParsed(t, w) + if !strings.Contains(got, "combobox cbCode") || strings.Contains(got, "optionsSourceDatabaseDataSource:") { + t.Errorf("describe of a database-mode combo box with no option list:\n%s", got) + } +} diff --git a/mdl/executor/widget_describe.go b/mdl/executor/widget_describe.go index ad763b7f6c..9bb79b0b5f 100644 --- a/mdl/executor/widget_describe.go +++ b/mdl/executor/widget_describe.go @@ -946,7 +946,22 @@ func definitionDefaults(def *WidgetDefinition) map[string]string { } } collect(def.PropertyMappings) - for _, mode := range def.Modes { + // The example is a bare widget, so it builds in the unconditioned mode, and + // that mode's constants are the ones the validator reads (widgetValueMap + // ignores an inactive mode's, #1519). Collecting every mode let a + // conditioned one speak for the example: Combo box's database mode set + // `source` to "database", the example was narrowed for database mode, and + // MDL-WIDGET10 then flagged the database-only settings it emitted + // (mendixlabs/mxcli#1583). A definition with no unconditioned mode keeps + // the old reading. + modes := def.Modes + for i := range def.Modes { + if def.Modes[i].Condition == "" { + modes = def.Modes[i : i+1] + break + } + } + for _, mode := range modes { collect(mode.PropertyMappings) } return out diff --git a/mdl/executor/widget_dropdownfilter_attr_test.go b/mdl/executor/widget_dropdownfilter_attr_test.go index 2ae1fdf4e3..2a86c3e96a 100644 --- a/mdl/executor/widget_dropdownfilter_attr_test.go +++ b/mdl/executor/widget_dropdownfilter_attr_test.go @@ -282,3 +282,100 @@ func TestWidgetValueMap1519_ReadsOnlyTheActiveMode(t *testing.T) { t.Errorf("baseType = %q, but only the association mode writes it", got) } } + +// The same drop of #1519 item 2 on the ALTER path. A column built by ALTER PAGE +// INSERT or REPLACE (buildColumnSpecFromAST) built its filter from the widget id +// and name alone, never reading the filter's own properties: +// +// alter page … { insert after dg column(Name) { column (…) { dropdownfilter ddf (attributes: [Status]) } } } +// +// stored attrChoice "auto" and an empty `attr`, check passed, and describe +// printed a bare `dropdownfilter ddf` — the CREATE path's symptom before #1422. +func TestAlterColumn1519_FilterKeepsItsProperties(t *testing.T) { + e := dropdownFilterEngine(t) + real := &modelsdkbackend.Backend{} + b := e.pageBuilder.backend.(*mock.MockBackend) + b.BuildFilterWidgetFunc = real.BuildFilterWidget + pb := e.pageBuilder + pb.widgetBackend = b + reg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("registry: %v", err) + } + pb.widgetRegistry = reg + + grid := parseSnippetWidget(t, `datagrid dg (DataSource: database from Tmp.Item) { + column (Attribute: Name, Caption: 'Name') { dropdownfilter ddf (attributes: [Status]) } + }`) + spec, err := pb.buildColumnSpecFromAST(grid.Children[0]) + if err != nil { + t.Fatalf("buildColumnSpecFromAST: %v", err) + } + cw, ok := spec.FilterWidget.(*pages.CustomWidget) + if !ok { + t.Fatalf("column filter = %T, want a drop-down filter", spec.FilterWidget) + } + if got, _ := bsonField(rawWidgetValue(t, cw, "attrChoice"), "PrimitiveValue").(string); got != "linked" { + t.Errorf("attrChoice = %q, want linked", got) + } + ref, _ := bsonField(rawWidgetValue(t, cw, "attr"), "AttributeRef").(bson.D) + if got, _ := bsonField(ref, "Attribute").(string); got != "Tmp.Item.Status" { + t.Errorf("attr = %q, want Tmp.Item.Status", got) + } + if cw.Name != "ddf" { + t.Errorf("filter name = %q, want ddf", cw.Name) + } + + // The control: a bare filter keeps the column's attribute (auto). + bare := parseSnippetWidget(t, `datagrid dg (DataSource: database from Tmp.Item) { + column (Attribute: Name, Caption: 'Name') { dropdownfilter ddf2 } + }`) + spec, err = pb.buildColumnSpecFromAST(bare.Children[0]) + if err != nil { + t.Fatalf("buildColumnSpecFromAST: %v", err) + } + cw, ok = spec.FilterWidget.(*pages.CustomWidget) + if !ok { + t.Fatalf("column filter = %T, want a drop-down filter", spec.FilterWidget) + } + if got, _ := bsonField(rawWidgetValue(t, cw, "attrChoice"), "PrimitiveValue").(string); got != "auto" { + t.Errorf("a bare drop-down filter: attrChoice = %q, want auto", got) + } +} + +// rawWidgetValue is customWidgetValue for a widget whose PropertyTypeIDMap is +// not filled in: the property type is found by key in the stored Type. +func rawWidgetValue(t *testing.T, cw *pages.CustomWidget, key string) bson.D { + t.Helper() + idOf := func(v any) string { + switch b := v.(type) { + case bson.Binary: + return strings.ReplaceAll(types.BlobToUUID(b.Data), "-", "") + case []byte: + return strings.ReplaceAll(types.BlobToUUID(b), "-", "") + case string: + return strings.ReplaceAll(b, "-", "") + } + return "" + } + objType, _ := bsonField(cw.RawType, "ObjectType").(bson.D) + propTypes, _ := bsonField(objType, "PropertyTypes").(bson.A) + var typeID string + for _, pt := range propTypes { + if d, ok := pt.(bson.D); ok && bsonField(d, "PropertyKey") == key { + typeID = idOf(bsonField(d, "$ID")) + } + } + if typeID == "" { + t.Fatalf("stored Type declares no property %q", key) + } + props, _ := bsonField(cw.RawObject, "Properties").(bson.A) + for _, p := range props { + if d, ok := p.(bson.D); ok && idOf(bsonField(d, "TypePointer")) == typeID { + v, _ := bsonField(d, "Value").(bson.D) + return v + } + } + t.Fatalf("property %q not found in the widget object", key) + return nil +} diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index 39c8dc0051..c1e76d3a40 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -122,6 +122,11 @@ type WidgetDefinition struct { // value yields an MDL-WIDGET04 warning rather than a false MDL-WIDGET01 // error. Validation-only; never written to BSON. See issue #643. KnownProperties []string `json:"knownProperties,omitempty"` + + // declaredKeys is every property key (lower-cased) the project's INSTALLED + // package declares, system ones included; nil when unknown — no project, + // or no package for the widget. Validation-only (mendixlabs/mxcli#1618). + declaredKeys map[string]bool } // PropertyMapping maps an MDL source (attribute, association, literal, etc.) @@ -442,7 +447,7 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* // // The set of properties that must be default-valued is the widget's to decide, // not mxcli's; making it a subset of what MDL has words for was the mistake. - for propKey, reset := range unmappedHiddenResets(def, e.visibilityRules(def), hiddenSkip) { + for propKey, reset := range unmappedHiddenResets(mappings, e.visibilityRules(def), hiddenSkip) { builder.SetPrimitive(propKey, reset) } @@ -594,16 +599,26 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* // 4.6 Apply explicit properties (not covered by .def.json mappings) mappedKeys := make(map[string]bool) + selectionKeys := make(map[string]bool) // lower-cased: read like lookupProperty, in any case for _, m := range mappings { if m.Source != "" { mappedKeys[m.Source] = true } + // A selection is read by its key as well as by `Selection:` + // (selectionMappingValue), so this pass must not write it a second + // time — as a PrimitiveValue, which is where `itemSelection: Single` + // used to end up (mendixlabs/mxcli#1618). + if m.Operation == "selection" { + for _, k := range append([]string{m.PropertyKey}, m.MdlAliases...) { + selectionKeys[strings.ToLower(k)] = true + } + } } for _, s := range slots { mappedKeys[s.MDLContainer] = true } for propName, propVal := range w.Properties { - if mappedKeys[propName] || isBuiltinPropName(propName) { + if mappedKeys[propName] || selectionKeys[strings.ToLower(propName)] || isBuiltinPropName(propName) { continue } entry, ok := propertyTypeIDs[propName] @@ -699,6 +714,18 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* if attrPath != "" { builder.SetAttribute(propName, attrPath) } + case "Selection": + // A selection lives in the value's Selection field, never in + // PrimitiveValue, where the primitive fallback below put a combo + // box's `optionsSourceDatabaseItemSelection: 'Single'` + // (mendixlabs/mxcli#1583). A selection the definition maps is read + // in the mapping pass instead (mendixlabs/mxcli#1618); this is one + // it does not. + canonical, err := checkedSelectionValue(w, propName, strVal) + if err != nil { + return nil, err + } + builder.SetSelection(propName, canonical) default: // Known non-attribute types: always use primitive if entry.ValueType != "" && entry.ValueType != "Attribute" { @@ -902,20 +929,20 @@ func (e *PluggableWidgetEngine) visibilityRules(def *WidgetDefinition) []types.W // datasource has no default. // // The mapped ones are excluded rather than merged: the loop already writes them, -// and writing a second value for one property is its own defect. -func unmappedHiddenResets(def *WidgetDefinition, rules []types.WidgetVisibilityRule, hidden map[string]string) map[string]string { +// and writing a second value for one property is its own defect. "Mapped" means +// in mappings — the mode the widget is built in, which is all the loop visits. +// Counting every mode's mappings left a property that only ANOTHER mode maps +// unwritten by either: a database-mode Combo box kept the template's +// `optionsSourceType` "enumeration", hidden there, against a declared +// "association" — CE0463 (mendixlabs/mxcli#1583). +func unmappedHiddenResets(mappings []PropertyMapping, rules []types.WidgetVisibilityRule, hidden map[string]string) map[string]string { if len(hidden) == 0 { return nil } - mapped := make(map[string]bool, len(def.PropertyMappings)) - for _, m := range def.PropertyMappings { + mapped := make(map[string]bool, len(mappings)) + for _, m := range mappings { mapped[strings.ToLower(m.PropertyKey)] = true } - for _, mode := range def.Modes { - for _, m := range mode.PropertyMappings { - mapped[strings.ToLower(m.PropertyKey)] = true - } - } out := map[string]string{} for _, rule := range rules { key := strings.ToLower(rule.PropertyKey) @@ -1046,8 +1073,16 @@ func singleAttributeOverflowMessage(w *ast.WidgetV3, m PropertyMapping, attrs [] // (`value` in the .def.json) is not checked: it is the definition's default, not // the user's data, and an older widget version lacking the key is not the // script's fault. +// +// A constant the script overrides by the property's own key is the script's +// value, though: Gallery 3.4.0 declares no `advanced`, which the hand-written +// definition maps to false, and `advanced: true` was discarded +// (mendixlabs/mxcli#1618). func refuseUndeclaredMappedKey(builder backend.WidgetObjectBuilder, def *WidgetDefinition, mapping PropertyMapping, ctx *BuildContext, w *ast.WidgetV3) error { - if mapping.Value != "" || !ctx.carriesValue() { + if !ctx.carriesValue() { + return nil + } + if _, authored := lookupProperty(w.Properties, mapping.PropertyKey); mapping.Value != "" && !authored { return nil } declarer, ok := builder.(backend.WidgetPropertyDeclarer) @@ -1657,7 +1692,10 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W } case "Selection": - val := w.GetSelection() + val, err := selectionMappingValue(mapping, w) + if err != nil { + return nil, err + } if val == "" && mapping.Default != "" { val = mapping.Default } diff --git a/mdl/executor/widget_hidden_reset_unmapped_test.go b/mdl/executor/widget_hidden_reset_unmapped_test.go index 1b441c0327..8a48ef5a64 100644 --- a/mdl/executor/widget_hidden_reset_unmapped_test.go +++ b/mdl/executor/widget_hidden_reset_unmapped_test.go @@ -106,7 +106,7 @@ func TestHiddenResets_IncludeAPropertyMDLCannotName(t *testing.T) { hidden := e.hiddenUnnamedProperties(def, &ast.WidgetV3{Name: "img"}, imageDefaultsWithMaxHeight(), imageTemplateValues()) - resets := unmappedHiddenResets(def, def.PropertyVisibility, hidden) + resets := unmappedHiddenResets(def.PropertyMappings, def.PropertyVisibility, hidden) got, ok := resets["maxHeight"] if !ok { t.Fatalf("maxHeight is hidden and has a declared default, but is not in the resets: %v", resets) @@ -128,7 +128,7 @@ func TestHiddenResets_DeclaredDefaultAloneCannotAnswerTheCondition(t *testing.T) hidden := e.hiddenUnnamedProperties(def, &ast.WidgetV3{Name: "img"}, imageDefaultsWithMaxHeight(), nil) - if v, ok := unmappedHiddenResets(def, def.PropertyVisibility, hidden)["maxHeight"]; ok { + if v, ok := unmappedHiddenResets(def.PropertyMappings, def.PropertyVisibility, hidden)["maxHeight"]; ok { t.Errorf("maxHeight was reset to %q from the declared defaults alone — if this "+ "passes, the condition no longer depends on the template's value and the "+ "test above proves nothing", v) @@ -145,7 +145,7 @@ func TestHiddenResets_SkipWhatTheMappingLoopAlreadyWrites(t *testing.T) { imageDefaultsWithMaxHeight(), imageTemplateValues()) for _, mapped := range []string{"width", "height"} { - if _, ok := unmappedHiddenResets(def, def.PropertyVisibility, hidden)[mapped]; ok { + if _, ok := unmappedHiddenResets(def.PropertyMappings, def.PropertyVisibility, hidden)[mapped]; ok { t.Errorf("%s has a property mapping and is written by that loop; "+ "it must not be written a second time here", mapped) } @@ -163,7 +163,7 @@ func TestHiddenResets_NoDeclaredDefaultWritesNothing(t *testing.T) { hidden := e.hiddenUnnamedProperties(def, &ast.WidgetV3{Name: "img"}, imageDefaults(), imageTemplateValues()) - if v, ok := unmappedHiddenResets(def, def.PropertyVisibility, hidden)["maxHeight"]; ok { + if v, ok := unmappedHiddenResets(def.PropertyMappings, def.PropertyVisibility, hidden)["maxHeight"]; ok { t.Errorf("maxHeight was reset to %q with no declared default to reset it to", v) } } @@ -182,7 +182,32 @@ func TestHiddenResets_VisibleUnmappedPropertyIsLeftAlone(t *testing.T) { hidden := e.hiddenUnnamedProperties(def, w, imageDefaultsWithMaxHeight(), imageTemplateValues()) - if _, ok := unmappedHiddenResets(def, def.PropertyVisibility, hidden)["maxHeight"]; ok { + if _, ok := unmappedHiddenResets(def.PropertyMappings, def.PropertyVisibility, hidden)["maxHeight"]; ok { t.Error("maxHeight is visible under this configuration and must keep its value") } } + +// A property mapped only in ANOTHER mode is as unreachable as one with no +// mapping at all: the mapping loop visits the mode the widget is built in. A +// database-mode Combo box hides `optionsSourceType`, which only the association +// mode maps, and kept the template's "enumeration" against a declared +// "association" — the last difference between mxcli's widget and the one +// mx update-widgets leaves, and the CE0463 (mendixlabs/mxcli#1583). +func TestHiddenResets_AMappingOfAnotherModeIsReset(t *testing.T) { + rules := []types.WidgetVisibilityRule{{ + PropertyKey: "optionsSourceType", + HiddenWhen: &types.WidgetVisibilityCondition{PropertyKey: "source", Operator: "eq", Value: "database"}, + }} + association := []PropertyMapping{{PropertyKey: "optionsSourceType", Value: "association", Operation: "primitive"}} + database := []PropertyMapping{{PropertyKey: "source", Value: "database", Operation: "primitive"}} + hidden := map[string]string{"optionssourcetype": "association"} + + if got := unmappedHiddenResets(database, rules, hidden)["optionsSourceType"]; got != "association" { + t.Errorf("built in the database mode, optionsSourceType reset = %q, want its declared default %q", + got, "association") + } + // The control: built in the mode that maps it, the mapping loop writes it. + if v, ok := unmappedHiddenResets(association, rules, hidden)["optionsSourceType"]; ok { + t.Errorf("optionsSourceType is mapped in the mode being built; reset here too (%q) writes it twice", v) + } +} diff --git a/mdl/executor/widget_known_props_from_mpk.go b/mdl/executor/widget_known_props_from_mpk.go index 095c58f9f9..8abfdfa457 100644 --- a/mdl/executor/widget_known_props_from_mpk.go +++ b/mdl/executor/widget_known_props_from_mpk.go @@ -72,7 +72,9 @@ func enrichKnownPropertiesFromMPK(r *WidgetRegistry, projectPath string) { continue } var keys []string + def.declaredKeys = make(map[string]bool, len(props)) for _, p := range props { + def.declaredKeys[strings.ToLower(p.Key)] = true if !p.IsSystem { keys = append(keys, p.Key) } diff --git a/mdl/executor/widget_registry_test.go b/mdl/executor/widget_registry_test.go index cf1dbe6af7..ad37fdb439 100644 --- a/mdl/executor/widget_registry_test.go +++ b/mdl/executor/widget_registry_test.go @@ -370,8 +370,8 @@ func TestRegistryComboboxModes(t *testing.T) { t.Fatal("combobox not found") } - if len(def.Modes) != 2 { - t.Fatalf("modes count = %d, want 2", len(def.Modes)) + if len(def.Modes) != 3 { + t.Fatalf("modes count = %d, want 3", len(def.Modes)) } // First mode: association (conditional) @@ -386,13 +386,22 @@ func TestRegistryComboboxModes(t *testing.T) { t.Errorf("association mode mappings = %d, want 5", len(def.Modes[0].PropertyMappings)) } - // Second mode: default (no condition) - if def.Modes[1].Name != "default" { - t.Errorf("second mode name = %q, want default", def.Modes[1].Name) + // Second mode: database, selected by ITS datasource's key — the generic + // `DataSource:` clause stays the association mode's (mendixlabs/mxcli#1583). + if def.Modes[1].Name != "database" { + t.Errorf("second mode name = %q, want database", def.Modes[1].Name) + } + if def.Modes[1].Condition != "hasDataSource:optionsSourceDatabaseDataSource" { + t.Errorf("database mode condition = %q, want hasDataSource:optionsSourceDatabaseDataSource", def.Modes[1].Condition) + } + + // Third mode: default (no condition) + if def.Modes[2].Name != "default" { + t.Errorf("third mode name = %q, want default", def.Modes[2].Name) } // attributeEnumeration + the onChangeEvent action slot (ledger #14). - if len(def.Modes[1].PropertyMappings) != 2 { - t.Errorf("default mode mappings = %d, want 2", len(def.Modes[1].PropertyMappings)) + if len(def.Modes[2].PropertyMappings) != 2 { + t.Errorf("default mode mappings = %d, want 2", len(def.Modes[2].PropertyMappings)) } } diff --git a/mdl/executor/widget_selection_by_key_test.go b/mdl/executor/widget_selection_by_key_test.go new file mode 100644 index 0000000000..91c159e41c --- /dev/null +++ b/mdl/executor/widget_selection_by_key_test.go @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1618: a Data grid 2 or Gallery selection written by the +// widget's own key — `itemSelection: Single` — passed check, exec and mx check, +// and was stored as the property's PrimitiveValue with its Selection left at +// the default, so the grid had no selection and a gallery could not turn its +// default selection off: +// +// dgSingle (itemSelection: Single) → Selection "None", PrimitiveValue "Single" +// galNone (itemSelection: None) → Selection "Single", PrimitiveValue "None" +// +// The mapping pass read only the `Selection:` keyword, and the explicit-key pass +// skipped "already handled" names by comparing what the author wrote with the +// mappings' source keywords (`Selection`), not their property keys, so the key +// fell through to the scalar branch. +// +// Two neighbours of the same silence were in the report: an invalid value +// (`itemSelection: Bogus`) was accepted, and a definition constant overridden by +// key on a widget that does not declare the property (Gallery 3.4.0 has no +// `advanced`; its hand-written definition maps one to the constant false) was +// discarded. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/pages" + "github.com/mendixlabs/mxcli/sdk/widgets/mpk" +) + +// selectionEngine builds with the REAL embedded templates over Shop.Ord (Code). +func selectionEngine(t *testing.T) *PluggableWidgetEngine { + t.Helper() + real := &modelsdkbackend.Backend{} + mod := &model.Module{BaseElement: model.BaseElement{ID: model.ID("mod-shop")}, Name: "Shop"} + b := &mock.MockBackend{ + LoadWidgetTemplateFunc: real.LoadWidgetTemplate, + 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-shop")}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: model.ID("e-ord")}, Name: "Ord", + Attributes: []*domainmodel.Attribute{{Name: "Code"}}}, + }, + }}, nil + }, + } + pb := &pageBuilder{ + backend: b, + paramEntityNames: map[string]string{}, + widgetScope: map[string]model.ID{}, + } + e := NewPluggableWidgetEngine(b, pb) + pb.pluggableEngine = e + return e +} + +// selectionDef is the definition exec builds the widget from: the Gallery's +// hand-written one, and the Data grid 2's generated from its package. +func selectionDef(t *testing.T, keyword string) *WidgetDefinition { + t.Helper() + if keyword == "DATAGRID" { + d, err := mpk.ParseMPKWidget("../../testdata/expr-checker/widgets/com.mendix.widget.web.Datagrid.mpk", pages.WidgetIDDataGrid2) + if err != nil { + t.Fatalf("parse Datagrid.mpk: %v", err) + } + return GenerateDefJSON(d, "DATAGRID") + } + reg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("registry: %v", err) + } + def, ok := reg.Get(keyword) + if !ok { + t.Fatalf("no %s definition", keyword) + } + return def +} + +func buildSelectionWidget(t *testing.T, keyword, src string) (*pages.CustomWidget, error) { + t.Helper() + w := parseSnippetWidget(t, src) + return selectionEngine(t).Build(selectionDef(t, keyword), w) +} + +const ( + dgBody = " { column (Attribute: Code) }" + galBody = " { dynamictext t (Content: 'x') }" +) + +func TestBuild_SelectionByKeyIsStoredAsTheSelection(t *testing.T) { + cases := []struct { + name, keyword, src, want string + }{ + {"datagrid single", "DATAGRID", "datagrid dg (DataSource: database Shop.Ord, itemSelection: Single)" + dgBody, "Single"}, + {"datagrid multi", "DATAGRID", "datagrid dg (DataSource: database Shop.Ord, itemSelection: Multi)" + dgBody, "Multi"}, + {"gallery none", "GALLERY", "gallery g (DataSource: database Shop.Ord, itemSelection: None)" + galBody, "None"}, + {"gallery multi", "GALLERY", "gallery g (DataSource: database Shop.Ord, itemSelection: Multi)" + galBody, "Multi"}, + // The two spellings agreeing is one value, not two. + {"gallery both spellings, agreeing", "GALLERY", "gallery g (DataSource: database Shop.Ord, Selection: None, itemSelection: None)" + galBody, "None"}, + // CONTROL: the keyword, which always worked, and the defaults. + {"control: datagrid keyword", "DATAGRID", "datagrid dg (DataSource: database Shop.Ord, Selection: Single)" + dgBody, "Single"}, + {"control: gallery keyword", "GALLERY", "gallery g (DataSource: database Shop.Ord, Selection: None)" + galBody, "None"}, + {"control: gallery default", "GALLERY", "gallery g (DataSource: database Shop.Ord)" + galBody, "Single"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cw, err := buildSelectionWidget(t, tc.keyword, tc.src) + if err != nil { + t.Fatalf("Build: %v", err) + } + v := customWidgetValue(t, cw, "itemSelection") + sel, _ := bsonField(v, "Selection").(string) + prim, _ := bsonField(v, "PrimitiveValue").(string) + if sel != tc.want || prim != "" { + t.Errorf("itemSelection stored Selection %q, PrimitiveValue %q; want Selection %q, PrimitiveValue \"\" "+ + "(mendixlabs/mxcli#1618)", sel, prim, tc.want) + } + }) + } +} + +// An invalid selection is refused rather than stored. By key it used to land +// in PrimitiveValue, where nothing reads it. +func TestBuild_InvalidSelectionIsRefused(t *testing.T) { + for _, src := range []string{ + "gallery g (DataSource: database Shop.Ord, itemSelection: Bogus)" + galBody, + "gallery g (DataSource: database Shop.Ord, Selection: Bogus)" + galBody, + } { + _, err := buildSelectionWidget(t, "GALLERY", src) + if err == nil || !strings.Contains(err.Error(), "Bogus") || !strings.Contains(err.Error(), "None, Single or Multi") { + t.Errorf("%s\n built with err %v; want the value refused, naming None, Single or Multi", src, err) + } + } +} + +// The keyword and the key both name itemSelection; two different values for it +// cannot both be written. +func TestBuild_DisagreeingSelectionSpellingsAreRefused(t *testing.T) { + _, err := buildSelectionWidget(t, "GALLERY", + "gallery g (DataSource: database Shop.Ord, Selection: Single, itemSelection: Multi)"+galBody) + if err == nil || !strings.Contains(err.Error(), "itemSelection") || !strings.Contains(err.Error(), "Selection") { + t.Fatalf("built with err %v; want the two values refused", err) + } +} + +// A definition constant the author overrides by key, on a widget that does not +// declare the property, cannot be written. The Gallery's hand-written +// definition maps `advanced` to false; Gallery 3.4.0 (and the embedded 11.6 +// template) declare no `advanced`. +func TestBuild_ConstantOverrideOfAnUndeclaredPropertyIsRefused(t *testing.T) { + _, err := buildSelectionWidget(t, "GALLERY", "gallery g (DataSource: database Shop.Ord, advanced: true)"+galBody) + if err == nil || !strings.Contains(err.Error(), "advanced") || !strings.Contains(err.Error(), "does not declare") { + t.Fatalf("built with err %v; want `advanced` refused as undeclared", err) + } + // CONTROL: the constant alone is the definition's, not the script's, and + // does not stop a gallery being built. + if _, err := buildSelectionWidget(t, "GALLERY", "gallery g (DataSource: database Shop.Ord)"+galBody); err != nil { + t.Fatalf("a plain gallery: %v", err) + } +} + +// check reports what exec refuses, before exec. +func TestCheck_SelectionValuesAndSpellings(t *testing.T) { + page := func(widget string) string { + return "mdl 1;\ncreate page Shop.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) {\n " + widget + "\n};" + } + cases := []struct { + name, widget, rule, want string + }{ + {"invalid by key", "gallery g (DataSource: database Shop.Ord, itemSelection: Bogus)" + galBody, + "MDL-WIDGET50", "None, Single or Multi"}, + {"invalid by key on a datagrid", "datagrid dg (DataSource: database Shop.Ord, itemSelection: Bogus)" + dgBody, + "MDL-WIDGET50", "None, Single or Multi"}, + {"two values", "gallery g (DataSource: database Shop.Ord, Selection: Single, itemSelection: Multi)" + galBody, + "MDL-WIDGET50", "two values"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + vs := widgetViolations(t, page(tc.widget), tc.rule) + if len(vs) != 1 || !strings.Contains(vs[0].Message, tc.want) { + t.Fatalf("violations %+v; want one %s saying %q", vs, tc.rule, tc.want) + } + }) + } + // CONTROL: valid values, in either spelling, report nothing. + for _, w := range []string{ + "gallery g (DataSource: database Shop.Ord, itemSelection: None)" + galBody, + "gallery g (DataSource: database Shop.Ord, Selection: Single)" + galBody, + "datagrid dg (DataSource: database Shop.Ord, itemSelection: Multi)" + dgBody, + "gallery g (DataSource: database Shop.Ord, Selection: None, itemSelection: none)" + galBody, + } { + if vs := widgetViolations(t, page(w), "MDL-WIDGET50"); len(vs) != 0 { + t.Errorf("%s: %+v", w, vs) + } + } +} + +// The Gallery's click-ambiguity rule reads the selection the builder now +// writes, by either spelling: `itemSelection: None` turns the selection off. +func TestCheck_GalleryClickAmbiguityReadsTheKey(t *testing.T) { + src := func(sel string) string { + return "mdl 1;\ncreate page Shop.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) {\n" + + " gallery g (DataSource: database Shop.Ord, " + sel + "onClick: call nanoflow Shop.Open)" + galBody + "\n};" + } + if vs := widgetViolations(t, src("itemSelection: None, "), "MDL-WIDGET36"); len(vs) != 0 { + t.Errorf("`itemSelection: None` is no selection, yet the click was called ambiguous: %+v", vs) + } + // CONTROL: a selection by key is still one. + if vs := widgetViolations(t, src("itemSelection: Single, "), "MDL-WIDGET36"); len(vs) != 1 { + t.Errorf("`itemSelection: Single` with an onClick: %+v, want MDL-WIDGET36", vs) + } +} + +// check -p reports the undeclared override too: it reads the installed +// package (testdata/expr-checker ships Gallery 3.4.0, which has no `advanced`). +// Without a project it cannot know which version is installed, and stays +// quiet — exec, which has the project, refuses it. +func TestCheck_ConstantOverrideOfAnUndeclaredProperty(t *testing.T) { + const src = "mdl 1;\ncreate page Shop.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) {\n" + + " gallery g (DataSource: database Shop.Ord, advanced: true)" + galBody + "\n};" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatal(errs) + } + find := func(vs []linter.Violation) []linter.Violation { + var out []linter.Violation + for _, v := range vs { + if v.RuleID == "MDL-WIDGET01" && strings.Contains(v.Message, "advanced") { + out = append(out, v) + } + } + return out + } + withProject := find(ValidateWidgetProperties(prog, "../../testdata/expr-checker/minimal.mpr")) + if len(withProject) != 1 || !strings.Contains(withProject[0].Message, "does not declare") { + t.Fatalf("check -p: %+v; want one MDL-WIDGET01 saying the installed Gallery does not declare `advanced`", withProject) + } + // CONTROL: no project, no installed version to judge by. + if got := find(ValidateWidgetProperties(prog, "")); len(got) != 0 { + t.Errorf("check without a project reported %+v", got) + } +} diff --git a/mdl/executor/widget_selection_value.go b/mdl/executor/widget_selection_value.go new file mode 100644 index 0000000000..c143d4d433 --- /dev/null +++ b/mdl/executor/widget_selection_value.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" +) + +// A selection property — Data grid 2 and Gallery `itemSelection` — is written +// by the `Selection:` keyword or by its own key. The mapping pass read only the +// keyword, and the key fell through to the explicit-key pass, which stored it as +// the property's PrimitiveValue with its Selection left at the default: no +// selection on a grid, and no way to turn a gallery's default one off +// (mendixlabs/mxcli#1618). Both spellings are one property, read here. + +// selectionValues are the values Studio Pro stores in a selection property's +// Selection, as canonicalSelection writes them. mxbuild 11.12.5 accepts any +// other string at 0 errors, so nothing after mxcli would catch one. +var selectionValues = []string{"None", "Single", "Multi"} + +// isSelectionValue reports whether v names a selection, in any case ("Multiple" +// is the keyword grammar's spelling of Multi). +func isSelectionValue(v string) bool { + switch strings.ToLower(v) { + case "none", "single", "multi", "multiple": + return true + } + return false +} + +// selectionValueError is the refusal for a value that is not a selection. +func selectionValueError(widgetName, key, value string) string { + return fmt.Sprintf("widget `%s` property `%s` takes None, Single or Multi, not `%s`", widgetName, key, value) +} + +// checkedSelectionValue is value canonicalised, or an error naming key when it +// is not a selection. +func checkedSelectionValue(w *ast.WidgetV3, key, value string) (string, error) { + if !isSelectionValue(value) { + return "", mdlerrors.NewValidation(selectionValueError(w.Name, key, value)) + } + return canonicalSelection(value), nil +} + +// selectionMappingValue is the value w gives a selection mapping, as written: +// by the mapping's keyword (`Selection:`), its property key or an alias (the +// builder's SetSelection canonicalises it). Two different values for the one +// property are refused — keeping either would drop the other with nothing +// said. "" means unset. +func selectionMappingValue(mapping PropertyMapping, w *ast.WidgetV3) (string, error) { + type written struct{ key, value string } + var got []written + if mapping.Source != "" { + if v, ok := lookupProperty(w.Properties, mapping.Source); ok { + got = append(got, written{mapping.Source, stringifyAny(v)}) + } + } + if v, key, ok := namedPropRaw(mapping, w); ok { + got = append(got, written{key, stringifyAny(v)}) + } + for _, g := range got { + if _, err := checkedSelectionValue(w, g.key, g.value); err != nil { + return "", err + } + if first := got[0]; canonicalSelection(g.value) != canonicalSelection(first.value) { + return "", mdlerrors.NewValidation(selectionDisagreementError(w.Name, first.key, first.value, g.key, g.value)) + } + } + if len(got) == 0 { + return "", nil + } + return got[0].value, nil +} + +// selectionDisagreementError is the refusal for two different values given to +// one selection property under its two spellings. +func selectionDisagreementError(widgetName, keyA, valueA, keyB, valueB string) string { + return fmt.Sprintf("widget `%s` gives its selection two values, `%s: %s` and `%s: %s` — both name the "+ + "one property; write one of them", widgetName, keyA, valueA, keyB, valueB) +} diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index 91065a3c27..97b9483a11 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -339,6 +339,8 @@ INFO: I N F O; WARNING: W A R N I N G; TRACE: T R A C E; CRITICAL: C R I T I C A L; +// `log ... with stacktrace` — the Log message activity's "Include latest stack trace". +STACKTRACE: S T A C K T R A C E; WITH: W I T H; EMPTY: E M P T Y; OBJECT: O B J E C T; diff --git a/mdl/grammar/domains/MDLDomainModel.g4 b/mdl/grammar/domains/MDLDomainModel.g4 index d04a7857b1..6acff8291c 100644 --- a/mdl/grammar/domains/MDLDomainModel.g4 +++ b/mdl/grammar/domains/MDLDomainModel.g4 @@ -298,7 +298,9 @@ alterEntityAction | DROP INDEX ifExists? indexDefinition | DROP INDEX ifExists? IDENTIFIER | ADD EVENT HANDLER ifNotExists? eventHandlerDefinition - | DROP EVENT HANDLER ifExists? ON eventMoment eventType + // An entity may carry several handlers for one event, so CALL names which + // one to drop. Without it the drop must be unambiguous (mendixlabs/mxcli#1540). + | DROP EVENT HANDLER ifExists? ON eventMoment eventType (CALL qualifiedName)? ; // Idempotency guards for a re-runnable domain script: ADD ... IF NOT EXISTS diff --git a/mdl/grammar/domains/MDLMicroflow.g4 b/mdl/grammar/domains/MDLMicroflow.g4 index 07584f508c..c99f5ad332 100644 --- a/mdl/grammar/domains/MDLMicroflow.g4 +++ b/mdl/grammar/domains/MDLMicroflow.g4 @@ -12,10 +12,18 @@ options { tokenVocab = MDLLexer; } /** * Creates a new microflow with parameters, return type, and activity body. + * + * The parameter list's parentheses are mandatory, even when it is empty. The + * bare form (`Mod.F returns …`, `Mod.F $l: List of …`) parses only so the + * visitor can refuse it with one error that names them + * (refuseRoutineHeaderWithoutParens). Left to ANTLR it inserted the `(`, read + * `returns` as a parameter name, swallowed the body into its type and closed + * the statement early — three errors, none at the mistake + * (mendixlabs/mxcli#1514). The same holds for nanoflows and rules below. */ createMicroflowStatement : MICROFLOW ifNotExists? qualifiedName - LPAREN microflowParameterList? RPAREN + (LPAREN microflowParameterList? RPAREN | microflowParameterList)? microflowReturnType? microflowOptions? BEGIN microflowBody END SEMICOLON? SLASH? @@ -26,7 +34,7 @@ createMicroflowStatement */ createNanoflowStatement : NANOFLOW ifNotExists? qualifiedName - LPAREN microflowParameterList? RPAREN + (LPAREN microflowParameterList? RPAREN | microflowParameterList)? // bare: refused microflowReturnType? microflowOptions? BEGIN microflowBody END SEMICOLON? SLASH? @@ -42,7 +50,7 @@ createNanoflowStatement */ createRuleStatement : RULE ifNotExists? qualifiedName - LPAREN microflowParameterList? RPAREN + (LPAREN microflowParameterList? RPAREN | microflowParameterList)? // bare: refused microflowReturnType? microflowOptions? BEGIN microflowBody END SEMICOLON? SLASH? @@ -543,8 +551,16 @@ raiseErrorStatement ; // LOG INFO NODE 'TEST' 'Message'; or LOG INFO 'Message'; or LOG WARNING 'Message' WITH ({1} = $var); +// LOG ERROR 'Failed' WITH STACKTRACE; ticks "Include latest stack trace". logStatement - : LOG logLevel? (NODE expression)? expression logTemplateParams? onErrorClause? + : LOG logLevel? (NODE expression)? expression logTemplateParams? logStackTrace? onErrorClause? + ; + +/** Studio Pro's "Include latest stack trace" (IncludeLatestStackTrace). After + * the template parameters, which also open with WITH: `WITH (` is the one and + * `WITH STACKTRACE` the other. */ +logStackTrace + : WITH STACKTRACE ; logLevel diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index 19fae4b158..db0da34b8e 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -774,8 +774,9 @@ keyword // that word — which is what TestKeywordRuleCoverage exists to catch. | SYNC | ONLINE | NEVER | PRESERVE - // Log levels - | CRITICAL | DEBUG | ERROR | INFO | SUCCESS | WARNING + // Log levels, and the log activity's `with stacktrace`. STACKTRACE has to + // stay a name: System.Error's attribute is `$latestError/Stacktrace`. + | CRITICAL | DEBUG | ERROR | INFO | STACKTRACE | SUCCESS | WARNING // OData / REST / API | API | SAMPLE | BASE | BODY | CHANNELS | CLIENT | CLIENTS | CONTRACT | OPENAPI diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index 272271f120..39f39400c3 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -59,6 +59,10 @@ package roundtrip // imported web service under the SDK name instead of WsdlFile, so the #365 // refusal could not see it and the rewrite dropped the binding (GetPut). Read // under the storage key, the rewrite is refused and nothing is written. +// ako/mxcli#1560 struck WorkflowCommons.User_WorkflowGroup_Select and +// WorkflowGroup_User_Select: their Save button passes comboBox1's selection, a +// PageVariable naming only the widget, which describe dropped and exec could +// not write back — the rebuilt button called its microflow unbound (CE1571). var testAppKnownFailures = map[string]knownFailure{ "building block Atlas_Web_Content.Alert": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "building block Atlas_Web_Content.AlertIcon": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, @@ -128,14 +132,12 @@ var testAppKnownFailures = map[string]knownFailure{ "page WorkflowCommons.UserTask_Assign": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp (putget since this branch's widget fixes), measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page WorkflowCommons.UserTask_Target": {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.UserTask_TargetGroups": {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.User_WorkflowGroup_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.WorkflowAttachment_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.WorkflowCommentHelper_Edit": {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.WorkflowComment_Edit_Admin": {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_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_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)"}, "page WorkflowCommons.Workflow_Dashboard": {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"}, diff --git a/mdl/visitor/drop_event_handler_call_test.go b/mdl/visitor/drop_event_handler_call_test.go new file mode 100644 index 0000000000..7a68d8a9bb --- /dev/null +++ b/mdl/visitor/drop_event_handler_call_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// TestDropEventHandlerCallParses covers the CALL clause on DROP EVENT HANDLER. +// An entity can carry several handlers for one event (mendixlabs/mxcli#1540), +// so a drop has to be able to name which one; the bare form still parses and +// leaves the microflow empty. +func TestDropEventHandlerCallParses(t *testing.T) { + prog, errs := Build(` +alter entity M.E drop event handler on before commit call M.Second; +alter entity M.E drop event handler if exists on after delete call M."Third"; +alter entity M.E drop event handler on before rollback; +`) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %s", errsText(errs)) + } + if len(prog.Statements) != 3 { + t.Fatalf("got %d statements, want 3", len(prog.Statements)) + } + + want := []struct { + moment, event, mf string + ifExists bool + }{ + {"Before", "Commit", "M.Second", false}, + {"After", "Delete", "M.Third", true}, + {"Before", "RollBack", "", false}, + } + for i, w := range want { + s, ok := prog.Statements[i].(*ast.AlterEntityStmt) + if !ok || s.Operation != ast.AlterEntityDropEventHandler || s.EventHandler == nil { + t.Fatalf("statement %d: not a DROP EVENT HANDLER (%+v)", i, prog.Statements[i]) + } + eh := s.EventHandler + got := "" + if eh.Microflow.Name != "" { + got = eh.Microflow.String() + } + if eh.Moment != w.moment || eh.Event != w.event || got != w.mf || s.IfExists != w.ifExists { + t.Errorf("statement %d: got %s %s call %q ifExists=%v, want %s %s call %q ifExists=%v", + i, eh.Moment, eh.Event, got, s.IfExists, w.moment, w.event, w.mf, w.ifExists) + } + } +} diff --git a/mdl/visitor/routine_parens_hint_test.go b/mdl/visitor/routine_parens_hint_test.go index 279ed4cc12..5634653a63 100644 --- a/mdl/visitor/routine_parens_hint_test.go +++ b/mdl/visitor/routine_parens_hint_test.go @@ -1,9 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 -// mendixlabs/mxcli#1514: a microflow header without its parameter parentheses — -// `create microflow M.F returns Boolean as $b` — gave "missing '(' at -// 'returns'", "missing ':' at 'Boolean'", and with a bare `$l: List of …` -// parameter "missing ')' at 'returns'". None says the parentheses are mandatory. +// mendixlabs/mxcli#1514 (ako/mxcli#1396): a microflow header without its +// parameter parentheses — `create microflow M.F returns Boolean as $b` — gave +// "missing '(' at 'returns'", "missing ':' at 'Boolean'" and, three lines into +// the body, "extraneous input 'return' expecting the start of a statement"; +// with a bare `$l: List of …` parameter "missing '(' at '$l'" and "missing ')' +// at 'returns'". Every one points past the mistake: ANTLR inserts the `(`, +// reads `returns` as a parameter name, swallows the body into its type and +// closes the statement early. The report asked for one message. package visitor import ( @@ -13,33 +17,55 @@ import ( const routineParensHint = "parameter list in parentheses" -func TestRoutineHeaderWithoutParensIsHinted(t *testing.T) { - for name, src := range map[string]string{ - "no params": "create microflow Tmp.F returns Boolean as $b\nbegin\n declare $b Boolean = true;\n return $b;\nend;", - "bare param": "create microflow Tmp.F $l: List of Tmp.Item returns Boolean as $b\nbegin\n return true;\nend;", - "nanoflow": "create nanoflow Tmp.NF begin\nend;", - "or modify": "create or modify microflow Tmp.F returns Boolean\nbegin\n return true;\nend;", - "rule as var": "create rule Tmp.R returns Boolean as $r\nbegin\n return true;\nend;", +func TestRoutineHeaderWithoutParensIsOneError(t *testing.T) { + for _, c := range []struct { + name, src, fix string + }{ + {"no params", + "create microflow Tmp.F returns Boolean as $b\nbegin\n declare $b Boolean = true;\n return $b;\nend;", + "create microflow Tmp.F () returns"}, + {"bare param", + "create microflow Tmp.F $l: List of Tmp.Item returns Boolean as $b\nbegin\n return true;\nend;", + "create microflow Tmp.F ($l: List of Tmp.Item) returns"}, + {"two bare params", + "create microflow Tmp.F $a: String, $b: Integer\nbegin\nend;", + "create microflow Tmp.F ($a: String, $b: Integer)"}, + {"nanoflow", "create nanoflow Tmp.NF begin\nend;", "create nanoflow Tmp.NF ()"}, + {"or modify", "create or modify microflow Tmp.F returns Boolean\nbegin\n return true;\nend;", + "create or modify microflow Tmp.F () returns Boolean"}, + {"rule as var", "create rule Tmp.R returns Boolean as $r\nbegin\n return true;\nend;", + "create rule Tmp.R () returns"}, + {"body with a statement keyword", + "create microflow Tmp.F returns Boolean\nbegin\n set $x = true;\n return true;\nend;", + "create microflow Tmp.F () returns"}, } { - _, errs := Build(src) - got := errsText(errs) - if len(errs) == 0 { - t.Fatalf("%s: expected a parse error", name) - } - // One mistake, one explanation — the cascade on the header line must not - // repeat it. - if n := strings.Count(got, routineParensHint); n != 1 { - t.Errorf("%s: hint appears %d times, want 1:\n%s", name, n, got) - } + t.Run(c.name, func(t *testing.T) { + // A statement after the bad one must still parse: the cascade used to + // swallow the body, and could swallow what followed it. + _, errs := Build(c.src + "\ncreate entity Tmp.E (A: String(10));") + got := errsText(errs) + if len(errs) != 1 { + t.Fatalf("want exactly one error, got %d:\n%s", len(errs), got) + } + if !strings.HasPrefix(got, "line 1:") { + t.Errorf("the error is not on the header line:\n%s", got) + } + if !strings.Contains(got, routineParensHint) { + t.Errorf("the error does not say the parentheses are mandatory:\n%s", got) + } + if !strings.Contains(got, c.fix) { + t.Errorf("the error does not show the header with parentheses (%q):\n%s", c.fix, got) + } + }) } } -// The control: the form the hint recommends parses, and a correct header with a +// The control: the form the error recommends parses, and a correct header with a // different error on its line is not told to add parentheses. func TestRoutineHeaderWithParensNotHinted(t *testing.T) { ok := "create microflow Tmp.F () returns Boolean as $b\nbegin\n declare $b Boolean = true;\n return $b;\nend;" if _, errs := Build(ok); len(errs) != 0 { - t.Fatalf("the hinted form does not parse: %v", errs) + t.Fatalf("the recommended form does not parse: %v", errs) } bad := "create microflow Tmp.F ($l List of Tmp.Item) returns Boolean\nbegin\n return true;\nend;" _, errs := Build(bad) @@ -47,6 +73,6 @@ func TestRoutineHeaderWithParensNotHinted(t *testing.T) { t.Fatal("expected a parse error") } if got := errsText(errs); strings.Contains(got, routineParensHint) { - t.Errorf("a header with parentheses got the parentheses hint:\n%s", got) + t.Errorf("a header with parentheses got the parentheses error:\n%s", got) } } diff --git a/mdl/visitor/strict_properties_test.go b/mdl/visitor/strict_properties_test.go index 08055bf209..e03c6e7af0 100644 --- a/mdl/visitor/strict_properties_test.go +++ b/mdl/visitor/strict_properties_test.go @@ -90,6 +90,21 @@ var strictPropertyCases = []struct { "create agent M.A (UsageType: Task, Model: GPT);", "MDL-V1-PROPVALUE", []string{"'Model'", "a qualified name"}, }, + { + "show page with key", + "create microflow M.F () begin show page M.P with NumberOfPagesToClos = 1; end;", + "MDL-V1-PROP", []string{"unknown property 'NumberOfPagesToClos'", "show page", "did you mean 'NumberOfPagesToClose'"}, + }, + { + "show page location", + "create microflow M.F () begin show page M.P with location = 'Popup'; end;", + "MDL-V1-PROP", []string{"'location'", "show page", "not stored"}, + }, + { + "show page title as a variable", + "create microflow M.F () begin show page M.P with title = $T; end;", + "MDL-V1-PROPVALUE", []string{"'title'", "a string"}, + }, { "agent tool block key", "create agent M.A (UsageType: Task, Model: M.GPT) { tool T { Enabeld: true } };", @@ -156,6 +171,7 @@ func TestStrictPropertiesAcceptWhatDescribeWrites(t *testing.T) { operation Del { Method: delete, Path: '/x', Response: file as $F } };`, "create rest client M.Open (OpenApi: 'spec.json', BaseUrl: 'https://x', Authentication: none);", + "create microflow M.F () begin show page M.P(Order = $O) with title = 'T', NumberOfPagesToClose = 1; show page M.P with NumberOfPagesToClose = $N + 1; end;", "create published rest service M.S (Path: 'rest/s', Version: '1.0', ServiceName: 'S', Folder: 'F') { };", "create business event service M.BE (ServiceName: 'S', EventNamePrefix: '', Folder: 'F') { message Changed (Id: Long) publish; };", "create model M.GPT (Provider: MxCloudGenAI, Key: M.Key, DisplayName: 'GPT', KeyName: 'k', KeyId: 'i', Environment: 'e', ResourceName: 'r', DeepLinkURL: 'u');", @@ -223,3 +239,48 @@ func TestStrictPropertiesKeepTheOldReadingUnderMdl0(t *testing.T) { t.Fatalf("mdl 0 changed what `Response: json from $X` builds: %+v", op) } } + +// mendixlabs/mxcli#1617: a show page's "Number of pages to close" has a +// spelling, and it reaches the statement. Before, the `with` list ignored any +// key but title, location and modal, so it vanished with check clean. +func TestShowPageWithNumberOfPagesToClose(t *testing.T) { + prog, errs := Build("mdl 1;\ncreate microflow M.F () begin show page M.P with title = 'T', NumberOfPagesToClose = 1; end;") + if len(errs) > 0 { + t.Fatal(errs) + } + sp := prog.Statements[0].(*ast.CreateMicroflowStmt).Body[0].(*ast.ShowPageStmt) + lit, ok := sp.NumberOfPagesToClose.(*ast.LiteralExpr) + if !ok || lit.Kind != ast.LiteralInteger { + t.Fatalf("NumberOfPagesToClose = %#v, want the literal 1", sp.NumberOfPagesToClose) + } + if sp.Title != "T" { + t.Errorf("the title beside it was lost: %q", sp.Title) + } + // CONTROL: without the key there is no expression. + prog, _ = Build("create microflow M.F () begin show page M.P with title = 'T'; end;") + if sp := prog.Statements[0].(*ast.CreateMicroflowStmt).Body[0].(*ast.ShowPageStmt); sp.NumberOfPagesToClose != nil { + t.Errorf("no key, yet NumberOfPagesToClose = %#v", sp.NumberOfPagesToClose) + } +} + +// A NumberOfPagesToClose literal that is not a whole number is refused under +// every version: mxbuild fails it with CE0117, and the key never worked +// before, so no script depends on it being accepted. +func TestShowPageNumberOfPagesToCloseRefusesNonInteger(t *testing.T) { + for _, v := range []string{"1.5", "'1'", "true", "empty"} { + src := "create microflow M.F () begin show page M.P with NumberOfPagesToClose = " + v + "; end;" + for _, header := range []string{"", "mdl 1;\n"} { + _, errs := Build(header + src) + if len(errs) != 1 || !strings.Contains(errs[0].Error(), "Integer expression") { + t.Errorf("%q (header %q): want one Integer-expression error, got %v", v, header, errs) + } + } + } + // CONTROL: whole numbers, including the 0 and -1 mxbuild accepts, and + // expressions it cannot judge without types. + for _, v := range []string{"0", "1", "-1", "$Count", "$Count + 1", "length($S)"} { + if _, errs := Build("mdl 1;\ncreate microflow M.F () begin show page M.P with NumberOfPagesToClose = " + v + "; end;"); len(errs) > 0 { + t.Errorf("%q refused: %v", v, errs) + } + } +} diff --git a/mdl/visitor/visitor.go b/mdl/visitor/visitor.go index bc82286f63..c22a4fd986 100644 --- a/mdl/visitor/visitor.go +++ b/mdl/visitor/visitor.go @@ -181,21 +181,6 @@ func enhanceErrorMessageAt(msg, offendingLine string, site errorSite) string { " mdl 1;\n"+ " create entity Shop.Customer ( Name: String(200) ); (correct)", msg) } - // A microflow / nanoflow / rule header without its parameter parentheses - // (mendixlabs/mxcli#1514). ANTLR's recovery inserts the missing `(` and then - // reads `returns Boolean` or `$l: List of …` as a parameter, so the errors - // point at `returns`, `Boolean` and `)` — never at the parentheses. Keyed off - // the source line; deduplicateHint keeps the cascade on that line to one - // explanation. - if m := routineHeaderWithoutParensRe.FindStringSubmatch(offendingLine); m != nil { - kind := strings.ToLower(m[1]) - return fmt.Sprintf("%s\n\n A %s header needs its parameter list in parentheses, even when it is\n"+ - " empty — they are not optional:\n"+ - " create %s Mod.F () returns Boolean as $b (correct)\n"+ - " create %s Mod.F ($l: List of Mod.Item) returns Boolean (correct)\n"+ - " create %s Mod.F returns Boolean as $b (wrong — causes parse error)", - msg, kind, kind, kind, kind) - } // `drop microflow M.F if exists;` — the SQL order. The grammar takes the // clause before the name, and ANTLR reports only an extraneous `if`. if dropIfExistsAfterNameRe.MatchString(offendingLine) { @@ -484,13 +469,6 @@ var dropIfExistsAfterNameRe = regexp.MustCompile(`(?i)^\s*drop\s+[a-z][a-z ]*?\s // `Icon: 'Mod.Coll.add-circle'`. Group 1 is the collection, group 2 the name. var hyphenatedIconRe = regexp.MustCompile(`(?i)\bicon\s*:?\s*'?([A-Za-z_]\w*\.[A-Za-z_]\w*)\.([A-Za-z_]\w*(?:-\w+)+)'?`) -// routineHeaderWithoutParensRe matches `create [or modify] microflow|nanoflow|rule -// ` followed directly by what comes after the parameter list — `returns`, -// a `$param`, `as` or `begin` — instead of `(`. A header with its parentheses -// cannot match, so an unrelated error on a correct header line is not hinted. -var routineHeaderWithoutParensRe = regexp.MustCompile( - `(?i)^\s*create\s+(?:or\s+\w+\s+)?(microflow|nanoflow|rule)\s+(?:if\s+not\s+exists\s+)?[\w."]+\s+(?:returns\b|\$|as\b|begin\b)`) - var workflowAccessRe = regexp.MustCompile(`(?i)^\s*(grant|revoke)\s+execute\s+on\s+workflow\b`) // addMissingAttributeRe matches `add :` on a source line — the shape of an diff --git a/mdl/visitor/visitor_entity.go b/mdl/visitor/visitor_entity.go index 83a0980e21..88c0024704 100644 --- a/mdl/visitor/visitor_entity.go +++ b/mdl/visitor/visitor_entity.go @@ -967,7 +967,7 @@ func (b *Builder) ExitAlterEntityAction(ctx *parser.AlterEntityActionContext) { return } - // DROP EVENT HANDLER ON Moment Type + // DROP EVENT HANDLER ON Moment Type [CALL Mod.Microflow] if ctx.DROP() != nil && ctx.EVENT() != nil && ctx.HANDLER() != nil { eh := &ast.EventHandlerDef{} if m := ctx.EventMoment(); m != nil { @@ -986,6 +986,9 @@ func (b *Builder) ExitAlterEntityAction(ctx *parser.AlterEntityActionContext) { eh.Event = "RollBack" } } + if qn := ctx.QualifiedName(); qn != nil { + eh.Microflow = buildQualifiedName(qn) + } b.statements = append(b.statements, &ast.AlterEntityStmt{ Name: name, Operation: ast.AlterEntityDropEventHandler, diff --git a/mdl/visitor/visitor_microflow.go b/mdl/visitor/visitor_microflow.go index 0c56c9635d..dba92874f6 100644 --- a/mdl/visitor/visitor_microflow.go +++ b/mdl/visitor/visitor_microflow.go @@ -3,15 +3,93 @@ package visitor import ( + "fmt" "strconv" "strings" + "github.com/antlr4-go/antlr/v4" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/grammar/parser" "github.com/mendixlabs/mxcli/mdl/types" ) +// routineHeader is the header a create microflow, nanoflow and rule statement +// share. +type routineHeader interface { + antlr.ParserRuleContext + IfNotExists() parser.IIfNotExistsContext + QualifiedName() parser.IQualifiedNameContext + LPAREN() antlr.TerminalNode + MicroflowParameterList() parser.IMicroflowParameterListContext + MicroflowReturnType() parser.IMicroflowReturnTypeContext + MicroflowOptions() parser.IMicroflowOptionsContext + BEGIN() antlr.TerminalNode +} + +// refuseRoutineHeaderWithoutParens refuses a header written without its +// parameter parentheses, `create microflow M.F returns Boolean` or `… M.F $l: +// List of M.Item returns …` (mendixlabs/mxcli#1514). The grammar accepts the +// bare form only so that this is the one error the mistake produces: left to +// ANTLR, recovery inserted the `(`, read `returns` as a parameter name and +// swallowed the body into its type, and every error it reported — `missing ':' +// at 'Boolean'`, `extraneous input 'return'` three lines down — pointed past +// the mistake. The error sits where the `(` belongs and shows the header as +// written, with them added. +func (b *Builder) refuseRoutineHeaderWithoutParens(ctx routineHeader, kind string) { + name := ctx.QualifiedName() + if ctx.LPAREN() != nil || name == nil || name.GetStop() == nil { + return + } + header := "create " + if c := findParentCreateStatement(ctx); c != nil && c.OR() != nil { + if c.MODIFY() != nil { + header += "or modify " + } else { + header += "or replace " + } + } + header += kind + " " + if ctx.IfNotExists() != nil { + header += "if not exists " + } + header += name.GetText() + " (" + params := ctx.MicroflowParameterList() + if params != nil { + header += extractExpressionText(params) + } + header += ")" + if ret := ctx.MicroflowReturnType(); ret != nil { + header += " " + extractExpressionText(ret) + } + + // Point at what follows the name — the token the `(` should precede, and + // the one ANTLR's own error named. + at := name.GetStop() + line, col := at.GetLine(), at.GetColumn()+len(at.GetText()) + var next antlr.Token + switch { + case params != nil: + next = params.GetStart() + case ctx.MicroflowReturnType() != nil: + next = ctx.MicroflowReturnType().GetStart() + case ctx.MicroflowOptions() != nil: + next = ctx.MicroflowOptions().GetStart() + case ctx.BEGIN() != nil: + next = ctx.BEGIN().GetSymbol() + } + if next != nil { + line, col = next.GetLine(), next.GetColumn() + } + topic := "microflow" + if kind != "microflow" { + topic += "." + kind // microflow.nanoflow, microflow.rule + } + b.addError(fmt.Errorf("line %d:%d a %s header needs its parameter list in parentheses, even when it is "+ + "empty — they are not optional: write `%s`. See: mxcli syntax %s", line, col, kind, header, topic)) +} + func (b *Builder) ExitCreateMicroflowStatement(ctx *parser.CreateMicroflowStatementContext) { + b.refuseRoutineHeaderWithoutParens(ctx, "microflow") stmt := &ast.CreateMicroflowStmt{ Name: buildQualifiedName(ctx.QualifiedName()), } @@ -66,6 +144,7 @@ func (b *Builder) ExitCreateMicroflowStatement(ctx *parser.CreateMicroflowStatem } func (b *Builder) ExitCreateNanoflowStatement(ctx *parser.CreateNanoflowStatementContext) { + b.refuseRoutineHeaderWithoutParens(ctx, "nanoflow") stmt := &ast.CreateNanoflowStmt{ Name: buildQualifiedName(ctx.QualifiedName()), } @@ -129,6 +208,7 @@ func (b *Builder) ExitCreateNanoflowStatement(ctx *parser.CreateNanoflowStatemen // ExitCreateRuleStatement mirrors ExitCreateNanoflowStatement — a rule shares the // microflow header and body grammar, so the only difference is the AST node. func (b *Builder) ExitCreateRuleStatement(ctx *parser.CreateRuleStatementContext) { + b.refuseRoutineHeaderWithoutParens(ctx, "rule") stmt := &ast.CreateRuleStmt{ Name: buildQualifiedName(ctx.QualifiedName()), } diff --git a/mdl/visitor/visitor_microflow_actions.go b/mdl/visitor/visitor_microflow_actions.go index db74d43739..b53f19a6b2 100644 --- a/mdl/visitor/visitor_microflow_actions.go +++ b/mdl/visitor/visitor_microflow_actions.go @@ -13,7 +13,7 @@ import ( ) // buildLogStatement converts LOG statement context to LogStmt. -// Grammar: LOG logLevel? (NODE expression)? expression logTemplateParams? +// Grammar: LOG logLevel? (NODE expression)? expression logTemplateParams? logStackTrace? func buildLogStatement(ctx parser.ILogStatementContext) *ast.LogStmt { if ctx == nil { return nil @@ -67,6 +67,9 @@ func buildLogStatement(ctx parser.ILogStatementContext) *ast.LogStmt { } } + // WITH STACKTRACE: "Include latest stack trace" (mendixlabs/mxcli#1543) + stmt.IncludeLatestStackTrace = logCtx.LogStackTrace() != nil + // Check for ON ERROR clause if errClause := logCtx.OnErrorClause(); errClause != nil { stmt.ErrorHandling = buildOnErrorClause(errClause) @@ -1282,6 +1285,8 @@ func buildShowPageStatement(ctx parser.IShowPageStatementContext) *ast.ShowPageS stmt.ModalForm = b } } + case "numberofpagestoclose": + stmt.NumberOfPagesToClose = change.Value } } } diff --git a/mdl/visitor/visitor_strict_properties.go b/mdl/visitor/visitor_strict_properties.go index ae1533e7a2..6c3782eb8a 100644 --- a/mdl/visitor/visitor_strict_properties.go +++ b/mdl/visitor/visitor_strict_properties.go @@ -417,3 +417,81 @@ func odataPropertyShape(pc *parser.OdataPropertyAssignmentContext) propShape { } return shapeOther } + +// showPageWithSchema is what a show page's `with` list reads. The list +// ignored every other key, so `with NumberOfPagesToClose = 1` was dropped +// with check and exec clean before the key existed (mendixlabs/mxcli#1617). +var showPageWithSchema = propSchema{on: "a show page action", keys: []propKey{ + {"title", []propShape{shapeString}}, + {"NumberOfPagesToClose", nil}, // any expression: refusePagesToCloseLiteral +}} + +// ExitShowPageStatement checks the `with` list against showPageWithSchema. +// location and modal are read and never written: Mendix deleted a Show page +// action's location in 8.0 — the page's own layout decides how it opens — +// and the action never had a modal flag. +func (b *Builder) ExitShowPageStatement(ctx *parser.ShowPageStatementContext) { + list, ok := ctx.MemberAssignmentList().(*parser.MemberAssignmentListContext) + if !ok || list == nil { + return + } + for _, m := range list.AllMemberAssignment() { + assign, ok := m.(*parser.MemberAssignmentContext) + if !ok || assign.MemberAttributeName() == nil { + continue + } + key := memberAttributeNameText(assign.MemberAttributeName()) + switch strings.ToLower(key) { + case "numberofpagestoclose": + b.refusePagesToCloseLiteral(assign) + case "location", "modal", "modalform": + b.strict(unknownPropertyKey, assign, fmt.Sprintf( + "'%s' on a show page action is not stored: the action has no location or modal setting "+ + "(Mendix removed its location in 8.0), the page's own layout decides how it opens. Known properties: %s", + key, strings.Join(showPageWithSchema.names(), ", "))) + default: + b.checkProperty(assign, &showPageWithSchema, key, expressionShape(assign.Expression())) + } + } +} + +// refusePagesToCloseLiteral refuses a NumberOfPagesToClose literal that is +// not a whole number. The setting is an Integer expression, and mxbuild +// 11.12.5 fails 1.5 and a Boolean with CE0117 "Error(s) in expression" at the +// activity (0, -1 and `$Count + 1` pass). The key was dropped until #1617, so +// no script relied on it and the refusal is not tied to the header — the same +// reasoning as `close page 1.5` (ExitClosePageStatement). +func (b *Builder) refusePagesToCloseLiteral(assign *parser.MemberAssignmentContext) { + lit, ok := buildExpression(assign.Expression()).(*ast.LiteralExpr) + if !ok || lit.Kind == ast.LiteralInteger { + return + } + b.addErrorWithExample( + fmt.Sprintf("%s: `NumberOfPagesToClose = %s`: the number of pages to close is an Integer expression — "+ + "a whole number or an expression of type Integer (Mendix build fails CE0117 otherwise)", + ctxPos(assign), expressionSourceText(assign.Expression())), + " show page M.P with NumberOfPagesToClose = 1; -- close the current page first\n"+ + " show page M.P with NumberOfPagesToClose = $Count; -- an Integer expression") +} + +// expressionShape is the shape of an expression value, in the propShape +// vocabulary. +func expressionShape(expr parser.IExpressionContext) propShape { + if expr == nil { + return shapeOther + } + switch e := buildExpression(expr).(type) { + case *ast.LiteralExpr: + switch e.Kind { + case ast.LiteralString: + return shapeString + case ast.LiteralInteger, ast.LiteralDecimal: + return shapeNumber + case ast.LiteralBoolean: + return shapeBool + } + case *ast.VariableExpr: + return shapeVariable + } + return shapeOther +} diff --git a/sdk/microflows/microflows_actions.go b/sdk/microflows/microflows_actions.go index 7aa0a2b50a..96476662ca 100644 --- a/sdk/microflows/microflows_actions.go +++ b/sdk/microflows/microflows_actions.go @@ -508,6 +508,9 @@ type ShowPageAction struct { PassedObject string `json:"passedObject,omitempty"` OverridePageTitle *model.Text `json:"overridePageTitle,omitempty"` PageParameterMappings []*PageParameterMapping `json:"pageParameterMappings,omitempty"` + // NumberOfPagesToClose is the expression for how many pages to close before + // the page opens; "" (Studio Pro's default) closes none (mendixlabs/mxcli#1617). + NumberOfPagesToClose string `json:"numberOfPagesToClose,omitempty"` } func (ShowPageAction) isMicroflowAction() {} diff --git a/sdk/widgets/definitions/combobox.def.json b/sdk/widgets/definitions/combobox.def.json index ba79255b89..85075f6a3b 100644 --- a/sdk/widgets/definitions/combobox.def.json +++ b/sdk/widgets/definitions/combobox.def.json @@ -22,6 +22,19 @@ {"propertyKey": "onChangeEvent", "source": "OnChange", "operation": "action"} ] }, + { + "name": "database", + "condition": "hasDataSource:optionsSourceDatabaseDataSource", + "description": "Database mode", + "propertyMappings": [ + {"propertyKey": "source", "value": "database", "operation": "primitive"}, + {"propertyKey": "optionsSourceDatabaseDataSource", "source": "DataSource", "operation": "datasource"}, + {"propertyKey": "optionsSourceDatabaseItemSelection", "source": "Selection", "operation": "selection", "default": "Single"}, + {"propertyKey": "databaseAttributeString", "source": "Attribute", "operation": "attribute"}, + {"propertyKey": "optionsSourceDatabaseCaptionAttribute", "source": "CaptionAttribute", "operation": "attribute"}, + {"propertyKey": "onChangeDatabaseEvent", "source": "OnChange", "operation": "action"} + ] + }, { "name": "default", "description": "Enumeration mode",