From 6bd520897e47d1b3691aef9c3f863ff4b2e5793c Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 25 Oct 2026 18:23:10 +0000 Subject: [PATCH 01/40] feat: attach an alter fragment's statement to a stored note by its text (ako/mxcli#1695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `@annotation` in an `alter microflow|nanoflow … insert|replace` fragment could not refer to a note already in the flow. `@annotation(id: n1)` was refused at exec ("refers to an annotation that has not been declared"), because a fragment is built alone, and check passed it as "not predicted". The `id:` labels describe prints are re-derived on every describe and stored nowhere, so they cannot address a stored note anyway. From mendixlabs/mxcli#1517, comment item 3. What: a new key, `@annotation(existing: 'Validates the order')`, names a stored note by its text. The text must match exactly one note of the flow (CRLF/LF line breaks match); none or several is refused by check and exec alike, through one function (resolveStoredNotes, called by plan and before check's pre-build loop), the message listing the candidates with where each is drawn and what it is attached to. The note keeps its $ID, text, position and size: the builder links its ID, the line is taken out of the fragment and drawn after the splice by a new MicroflowMutator.LinkNote, which skips a line that is already there (a replace carries the target's line) and orients it on the post-splice geometry with microflows.FacingSides — the executor's formula, moved to sdk/microflows so builder and splice share one copy. `existing:` is its own key, never `text:`, so attaching cannot be read as creating; it never creates a note, stands alone (beside id:/text:/position:/ size: it is MDL079 at check and refused by the builder at exec), and only an alter fragment takes it (a whole-flow statement declares its notes: MDL079). describe never prints it. The visitor never classes it as a free note. ValidateAlterFlow now also checks note labels per fragment, so an undeclared `id:` is refused by check too, with a hint at `existing:`. Verification: executor tests on PedApp copies (attach keeps the note's $ID, position and size and adds the line, with a control where `@annotation 'text'` makes a second note; line orientation; replace dedupe; no-match and ambiguous refusals by check and exec with nothing written; misuse; fragment `id:` regression), LinkNote and visitor unit tests. Revert proofs: every new test fails against the baseline production code or a targeted stub. Real app (11.13.0): the issue's scenario before (MDL079, `--no-check` wrote no note) and after (one shared note, stored note BSON byte-identical), describe → exec Unchanged, mx check 0 errors; TestMxCheck_DoctypeScripts/02-microflow-examples 0 errors. Co-Authored-By: Claude Opus 5.5 --- ...er-fragment-cannot-name-a-stored-note.json | 1 + .../skills/mendix/write-microflows/SKILL.md | 9 +- .../write-microflows/reference/pitfalls.md | 40 ++ cmd/mxcli/syntax/features_microflow.go | 30 +- docs-site/src/language/microflow-structure.md | 36 ++ docs/01-project/MDL_QUICK_REFERENCE.md | 2 + .../bug-tests/alter-1695-note-by-text.mdl | 63 ++++ .../doctype-tests/02-microflow-examples.mdl | 25 ++ mdl/ast/ast_microflow.go | 9 + mdl/backend/mfmutator/note_link.go | 57 +++ mdl/backend/mfmutator/note_link_test.go | 94 +++++ mdl/backend/microflow_mutation.go | 6 + mdl/executor/cmd_alter_flow.go | 16 + mdl/executor/cmd_alter_flow_stored_notes.go | 313 +++++++++++++++ ...lter_flow_stored_notes_1695_pedapp_test.go | 357 ++++++++++++++++++ mdl/executor/cmd_microflows_builder.go | 8 + .../cmd_microflows_builder_annotations.go | 43 ++- .../cmd_microflows_builder_control.go | 2 + mdl/executor/cmd_microflows_builder_flows.go | 1 + mdl/executor/layout_rows.go | 15 +- mdl/executor/validate_alter_flow.go | 8 +- mdl/executor/validate_flow_verdicts.go | 21 +- mdl/executor/validate_microflow.go | 49 ++- mdl/visitor/visitor_existing_note_test.go | 73 ++++ mdl/visitor/visitor_microflow_statements.go | 22 +- sdk/microflows/note_sides.go | 37 ++ 26 files changed, 1300 insertions(+), 37 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-25-alter-fragment-cannot-name-a-stored-note.json create mode 100644 mdl-examples/bug-tests/alter-1695-note-by-text.mdl create mode 100644 mdl/backend/mfmutator/note_link.go create mode 100644 mdl/backend/mfmutator/note_link_test.go create mode 100644 mdl/executor/cmd_alter_flow_stored_notes.go create mode 100644 mdl/executor/cmd_alter_flow_stored_notes_1695_pedapp_test.go create mode 100644 mdl/visitor/visitor_existing_note_test.go create mode 100644 sdk/microflows/note_sides.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-alter-fragment-cannot-name-a-stored-note.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-alter-fragment-cannot-name-a-stored-note.json new file mode 100644 index 0000000000..549654133f --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-alter-fragment-cannot-name-a-stored-note.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-25", "symptom": "`alter microflow … insert` with `@annotation(id: n1)` meant to attach the new activity to a note already in the flow was refused at exec — '@annotation(id: n1) refers to an annotation that has not been declared — the first mention must carry the text' — while check passed it as 'not predicted'; there was no other way to attach a fragment statement to a stored note", "cause": "A fragment is built by a fresh flowBuilder whose annotationsByLabel starts empty, and the `id:` labels describe prints (n1, n2, …) are re-derived on every describe and stored nowhere, so there is nothing for a fragment to resolve them against. check only learned of the builder's error in alterFlowViolations' pre-build loop, which files builder errors as dependencies ('not predicted') rather than refusals", "file": "mdl/executor/cmd_alter_flow_stored_notes.go (resolveStoredNotes, attachStoredNote, takeStoredNoteLines); mdl/backend/mfmutator/note_link.go (LinkNote); mdl/visitor/visitor_microflow_statements.go (parseNoteAnnotation `existing:`)", "fix": "New key `@annotation(existing: 'text')`: resolveStoredNotes (called by plan for exec and before the pre-build loop for check) resolves each text to exactly one stored note or refuses, listing the candidates with position and attachments; the builder links the stored note's ID; the lines are taken out of the fragment and drawn by MicroflowMutator.LinkNote after the splice, oriented with microflows.FacingSides (moved there from the executor so builder and splice share one formula) on the post-splice geometry. ValidateAlterFlow now also runs checkAnnotationLabels per fragment, so an undeclared `id:` is refused by check (MDL079) with a hint at `existing:`", "insight": "Two traps beyond the obvious. (1) Any refusal that lives in the fragment BUILDER is downgraded by check to 'not predicted — exec decides' (alterFlowViolations treats builder errors as dependencies on earlier statements), so a deterministic refusal must be decided before the build, by a function both plan and alterFlowViolations call. (2) A line from a stored note cannot ride in the fragment: the builder never knew where the stored note is relative to what it builds (sides come out as the bottom→top default), withoutNotes/singleActivity treat a line to a non-fragment object as a non-note so the error-handler and loop-body in-place replaces refuse, and Replace carries the target's own line to the entry so the same note gets two lines. Drawing it after the splice via a mutator op that dedupes and orients on current geometry fixes all three; each was shown by a revert (lines left in the fragment: origin 2/destination 0 instead of 0/2, and 2 lines on replace). Address by text, not by the describe-time id: the id is a derived name that moves (docs-wiki/bug-patterns/mutator-addressing.md); text is what the canvas shows, and the miss/ambiguity message lists the notes so a mismatch is visible", "refs": ["ako/mxcli#1695", "mendixlabs/mxcli#1517", "#1077", "mendixlabs/mxcli#1598", "mendixlabs/mxcli#1505"], "test": "TestAlterMicroflow_AttachesAStoredNoteByItsText, TestAlterMicroflow_StatedNoteTextIsANewNote (control), TestAlterMicroflow_StoredNoteLineFacesTheNote, TestAlterMicroflow_ReplaceAttachedToTheReplacedNote, TestAlterMicroflow_StoredNoteTextMustNameOneNote, TestAlterMicroflow_ExistingNoteMisuseRefused, TestAlterMicroflow_FragmentNoteLabelsUnchanged (mdl/executor/cmd_alter_flow_stored_notes_1695_pedapp_test.go); TestLinkNote_* (mdl/backend/mfmutator/note_link_test.go); TestExistingNoteAnnotation (mdl/visitor/visitor_existing_note_test.go)"} diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index ca43bd4717..177645a09e 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -26,7 +26,7 @@ description: "Microflow syntax reference in MDL — every activity type, control | 579-595 | Validation Checklist | | 596-607 | Tips for Success | | 608-614 | Related Documentation | -| 615-727 | Quick Reference | +| 615-734 | Quick Reference | # Mendix Microflow Skill @@ -673,6 +673,13 @@ above the activity at 200×50 (60px per extra), and DESCRIBE omits it again. is a free note**, attached to nothing (where DESCRIBE prints one): put an activity's own notes after them. +**In an `alter microflow` fragment, a note already in the flow is named by its +text**: `@annotation(existing: 'Validates the order')` attaches the stored note to +the new statement — it never creates one, and the text must match exactly one note +or the statement is refused, listing the candidates. The `id:` labels describe +prints are not stored, so a fragment cannot use them +([pitfalls §14](reference/pitfalls.md#14-attaching-an-inserted-statement-to-a-note-the-flow-already-has)). + ### Page Navigation Pattern ```mdl show page Module.Page(Param = $value); diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index 9c34b1408c..57164b09a1 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -398,6 +398,46 @@ it back. Either: A headerless (mdl 0) script still moves the row along, as it always did. +### 14. Attaching an Inserted Statement to a Note the Flow Already Has + +A fragment of `alter microflow … insert|replace` is built on its own, so it cannot see +the `id:` labels `describe` prints — and those are re-derived on every describe and +stored nowhere, so a saved one could name a different note next time. Name the stored +note by its **text**, exactly as `describe` prints it (ako/mxcli#1695): + +❌ **REFUSED** — "refers to a note that has not been declared above it": +```mdl +mdl 1; +alter microflow MyModule.ACT_Process { + insert after log node 'App' 'start' begin + @annotation(id: n1) + log info node 'App' 'middle'; + end; +}; +``` + +✅ **CORRECT** — the new activity joins the note; the note keeps its `$ID`, text and place: +```mdl +mdl 1; +alter microflow MyModule.ACT_Process { + insert after log node 'App' 'start' begin + @annotation(existing: 'Validates the order') + log info node 'App' 'middle'; + end; +}; +``` + +- The text must be the text of **exactly one** note of the flow. None, or several, is + refused by `check` and `exec` alike, and the refusal lists the flow's notes (with where + each is drawn and what it is attached to). Two notes with the same text cannot be told + apart by text: reword one first. +- `existing:` stands alone. Beside `text:`, `id:`, `position:` or `size:` it is refused + (MDL079) — `@annotation 'text'` is how a statement gets a note of its own. +- Only an alter fragment takes it: `create`/`create or modify` state the whole flow and + declare every note, so `existing:` there is refused (MDL079). `describe` never prints it. +- Re-running the alter inserts the statement again, as any `insert` does; the note is + attached to both. + ## Implicit Variable Creation (CE0111 Duplicate Variable) These statements **implicitly create a new variable** with the name on the left side: diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index ea0f133052..25fcf77735 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -606,6 +606,7 @@ func init() { Keywords: []string{ "alter microflow", "alter nanoflow", "insert after", "insert before", "replace", "drop activity", "patch microflow", "splice", "handle", + "existing note", "attach stored note", }, Syntax: "ALTER MICROFLOW|NANOFLOW Module.Name {\n" + " INSERT AFTER|BEFORE BEGIN END;\n" + @@ -625,14 +626,28 @@ func init() { "-- a loop body, a fragment that returns, and a fragment variable that clashes with one\n" + "-- the flow has or reads one not declared on the path. Over --mcp only insert is supported.\n" + "-- A fragment is imperative flow, written as the body of `create microflow` is: BEGIN … END.\n" + - "-- The brace fragment `{ }` is the deprecated spelling MDL-DEPR074.", - Example: "alter microflow FeedbackModule.VAL_Feedback {\n" + + "-- The brace fragment `{ }` is the deprecated spelling MDL-DEPR074.\n" + + "-- A fragment statement joins a note ALREADY in the flow with\n" + + "-- @annotation(existing: 'the note''s text')\n" + + "-- named by its text exactly as describe prints it. The note keeps its $ID, text and\n" + + "-- place; a text that no note reads, or that several notes read, is refused (the\n" + + "-- refusal lists them). `existing:` stands alone and never creates a note — a note of\n" + + "-- the statement's own is `@annotation 'text'`. The `id:` labels describe prints are\n" + + "-- not stored, so a fragment cannot name a stored note by one (ako/mxcli#1695).", + Example: "mdl 1;\nalter microflow FeedbackModule.VAL_Feedback {\n" + " insert after $IsValidEmail begin log info node 'Feedback' 'Email checked'; end;\n" + " replace set $ValidFeedback = false @3 with begin\n" + " set $ValidFeedback = false;\n" + " log warning node 'Feedback' 'Email rejected';\n" + " end;\n" + " drop log debug node 'Feedback' *;\n" + + "};\n\n" + + "-- The new statement joins the note the flow already has.\n" + + "alter microflow Shop.ACT_ProcessOrder {\n" + + " insert after $Order begin\n" + + " @annotation(existing: 'Validates the order')\n" + + " log info node 'Shop' 'order validated';\n" + + " end;\n" + "};", SeeAlso: []string{"microflow"}, }) @@ -852,7 +867,9 @@ func init() { "@caption 'text'\n@color Green\n@annotation 'a note'\n@excluded\n" + "@applyentityaccess | @applyentityaccess(false) -- DOCUMENT-level, before CREATE MICROFLOW/RULE\n" + "@annotation(id: n1, text: 'a note', position: (x, y), size: (w, h))\n" + - "@annotation(id: n1) -- attaches THAT note to another activity\n\n" + + "@annotation(id: n1) -- attaches THAT note to another activity\n" + + "@annotation(existing: 'note text') -- ALTER fragments only: attaches the stored note\n" + + " -- with that text (it must be the only one)\n\n" + "An @anchor never reaches the far end of its own outgoing flow: that end is\n" + "the NEXT statement's `to:`. A straight vertical edge from A down to B is\n" + "@anchor(from: bottom) on A and @anchor(to: top) on B; `to: top` on A alone\n" + @@ -916,6 +933,13 @@ func init() { "is scoped to the flow being authored and is not stored in the model —\n" + "DESCRIBE re-derives labels, and emits one only for a note that really is\n" + "shared. Two notes with identical text and no id stay two notes.\n\n" + + "An ALTER fragment is built alone, so it cannot see those labels; it names a\n" + + "note already stored in the flow by its text: @annotation(existing: '…'). The\n" + + "text must be the text of exactly one note of the flow (CRLF and LF line\n" + + "breaks match), or the statement is refused, listing the candidates. The note\n" + + "keeps its $ID, text and place, so `existing:` takes no id:, text:, position:\n" + + "or size:, and a statement that states the whole flow refuses it. DESCRIBE\n" + + "never prints it: a whole flow declares its notes. (ako/mxcli#1695)\n\n" + "position:/size: are the note's own geometry, omitted whenever they match\n" + "what a rewrite re-derives (100px above the activity, stacked 60px per extra\n" + "note, 200x50), so an ordinary note keeps the short form. (#1077)", diff --git a/docs-site/src/language/microflow-structure.md b/docs-site/src/language/microflow-structure.md index 489db14228..d7494c2b5e 100644 --- a/docs-site/src/language/microflow-structure.md +++ b/docs-site/src/language/microflow-structure.md @@ -271,6 +271,42 @@ Attach a visual annotation note to the next activity: VALIDATION FEEDBACK $Customer/Email MESSAGE 'Email is required'; ``` +A note is a node of its own, so one note can document several activities. Give it +an `id:` at its first mention and refer to that id afterwards; without the id, two +notes with the same text stay two notes: + +```sql +@annotation(id: n1, text: 'Both of these touch the same record') +COMMIT $Order; +@annotation(id: n1) +COMMIT $Invoice; +``` + +The id is scoped to the statement being written and is not stored in the model: +`DESCRIBE` numbers shared notes afresh every time. + +### Attaching a Note Already in the Flow + +`ALTER MICROFLOW` (and `ALTER NANOFLOW`) inserts or replaces a fragment of a stored +flow. The fragment cannot see the `id:` labels `DESCRIBE` prints, so it names a note +that is already in the flow by its **text**, with `existing:`: + +```sql +ALTER MICROFLOW Sales.ACT_ProcessOrder { + INSERT AFTER LOG NODE 'Sales' 'validated' BEGIN + @annotation(existing: 'Validates the order') + LOG INFO NODE 'Sales' 'discount applied'; + END; +}; +``` + +The new activity is joined to the stored note, which keeps its identity, text and +place on the canvas. The text must match exactly one note of the flow, as `DESCRIBE` +prints it; when no note matches, or several do, the statement is refused and the +message lists the flow's notes. `existing:` never creates a note and takes no other +parameter, and only an `ALTER` fragment accepts it — a `CREATE` states the whole +flow and declares its notes. + ### Combining Annotations Multiple annotations can be stacked before a single activity: diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index b7d547672b..87b4ff7e79 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -512,6 +512,7 @@ rather than updating the first. | Describe microflow (with handles) | `describe microflow Module.Name with handles;` | Prints `-- handle: ` above each activity: its content address for `alter microflow` — output `$Var`, `'Caption'`, or a statement pattern with `*` wildcards (anchored at both ends), plus `@n` when several match. Comments only; cannot be combined with `normalized` | | Insert into a stored microflow | `alter microflow Module.Name { insert after begin end; };` | Also `insert before`, and `alter nanoflow`. A graph splice into the stored flow, not a rebuild: only the new activities, the two rewired flows and the objects moved to make room change; every other element keeps its `$ID`, position and curve. `` is a handle from `describe … with handles`, resolved before any operation runs. Refused: after a decision, before an activity several flows enter, inside a loop body, a fragment whose every path returns (a guard clause — `if … then return …; end if;` — is inserted: each return is a new end event, refused where its branch has no free room or would cross a stored flow), a variable the flow already has or one not declared on the path. The fragment is `begin … end` like a microflow body; `{ … }` is the deprecated spelling MDL-DEPR074 | | Replace or drop an activity | `alter microflow Module.Name { replace with begin end; drop ; };` | Flows into the activity are re-pointed at the replacement (or at its successor, for `drop`). Refused for a decision, an end event, an activity with an error handler, and an activity whose output variable is still read. Over `--mcp` only `insert` is supported | +| Attach a stored note | `alter microflow Module.Name { insert after begin @annotation(existing: 'note text') end; };` | The inserted (or replacing) statement joins a note **already in the flow**, named by its text exactly as `describe` prints it; the note keeps its `$ID`, text and place. A text that no note of the flow reads, or that several read, is refused by `check` and `exec` alike, the refusal listing the candidates. `existing:` stands alone (no `id:`/`text:`/`position:`/`size:`) and only an alter fragment takes it — a whole-flow statement declares its notes. Not `id:`: the labels `describe` prints are re-derived every time and stored nowhere (ako/mxcli#1695) | | Describe nanoflow | `describe nanoflow Module.Name;` | Full MDL with activities | | Rename microflow | `rename microflow Module.Old to New;` | Updates all references | | Rename nanoflow | `rename nanoflow Module.Old to New;` | Updates all references | @@ -614,6 +615,7 @@ it is for pages. | Color | `@color Green` | Background color (before activity) | | Annotation | `@annotation 'text'` | Visual note attached to next activity. **Repeatable** — an activity can carry several, and each is its own note | | Shared annotation | `@annotation(id: n1, text: 'note')` then `@annotation(id: n1)` | ONE note wired to several activities, which is how Mendix stores it. Without the `id:` the two lines are two separate notes, even with identical text. The id is scoped to the flow being authored and is not stored (#1077) | +| Stored annotation | `@annotation(existing: 'note')` | **`alter` fragments only.** Attaches the note of the stored flow whose text is `'note'` — it must be the only one — instead of creating a note. Never printed by `describe`, which declares every note of a whole flow (ako/mxcli#1695) | | Annotation geometry | `@annotation(text: 'note', position: (x, y), size: (w, h))` | The note's own place and box on the canvas. Both are omitted whenever they match what a rewrite re-derives — 100px above the activity, stacked 60px per extra note, at 200×50 — so an ordinary note stays on the short form | | Free annotation | `@annotation 'text'` before `@position(...)` | Free-floating visual note preserved by order. A free note has no activity to be placed relative to, so DESCRIBE always emits its `position:` | | IF | `if condition then ... [else ...] end if;` | | diff --git a/mdl-examples/bug-tests/alter-1695-note-by-text.mdl b/mdl-examples/bug-tests/alter-1695-note-by-text.mdl new file mode 100644 index 0000000000..806e28c59c --- /dev/null +++ b/mdl-examples/bug-tests/alter-1695-note-by-text.mdl @@ -0,0 +1,63 @@ +-- ============================================================================ +-- ako/mxcli#1695 (mendixlabs/mxcli#1517, comment item 3) — an alter fragment +-- attaches its new statement to a note already in the flow, by the note's text +-- ============================================================================ +-- +-- An `@annotation` in an `alter microflow … insert` fragment could not refer to +-- a note already stored in the flow. `@annotation(id: n1)` failed at exec with +-- @annotation(id: n1) refers to an annotation that has not been declared — +-- the first mention must carry the text, as @annotation(id: n1, text: '…') +-- because the fragment is built alone and cannot see stored notes — while +-- check passed it as "not predicted". And the ids describe prints are +-- re-derived on every describe, so an id saved in a script could later name a +-- different note. +-- +-- A stored note is now named by its text, with a key of its own: +-- @annotation(existing: 'Validates the order') +-- The text must match exactly one note of the flow (CRLF and LF line breaks +-- match). `existing:` never creates a note — `@annotation 'text'` does — and +-- takes no `id:`, `text:`, `position:` or `size:`. +-- +-- To reproduce: exec this file against any project. Expected: +-- Created microflow: BugTest1695.ACT_Notes +-- Altered microflow BugTest1695.ACT_Notes +-- and `describe microflow BugTest1695.ACT_Notes` prints ONE shared note: +-- @annotation(id: n1, text: 'Validates the order', position: (…), size: (…)) +-- log node 'BugTest1695' 'start'; +-- @annotation(id: n1) +-- log node 'BugTest1695' 'middle'; +-- The note keeps its $ID, position and size. describe → exec is Unchanged. +-- mx check: 0 errors (11.13.0). Before the fix check, and exec's pre-flight, +-- refused `existing:` as a parameter mxcli does not understand (MDL079), and +-- `exec --no-check` wrote the activity with no note at all. +-- The file can be run again: the flow is dropped and made afresh. +-- +-- Refused by check and exec alike (nothing is written): +-- @annotation(existing: 'Validates the orders') +-- … names no note of the stored flow: none reads that text. The flow's notes: +-- "Validates the order" at (…), attached to `log node 'BugTest1695' 'start'` … +-- @annotation(existing: 'Check') -- in a flow with two notes reading 'Check' +-- … names 2 notes of the stored flow, so it does not say which one to +-- attach: at (…), attached to …; at (…), attached to … . Reword one first. +-- @annotation(existing: 'Validates the order', text: 'x') MDL079 +-- @annotation(existing: '…') in a create / create or modify MDL079 + +mdl 1; + +create module BugTest1695; + +drop microflow if exists BugTest1695.ACT_Notes; + +create microflow BugTest1695.ACT_Notes () +begin + @annotation 'Validates the order' + log info node 'BugTest1695' 'start'; + log info node 'BugTest1695' 'done'; +end; + +alter microflow BugTest1695.ACT_Notes { + insert after log node 'BugTest1695' 'start' begin + @annotation(existing: 'Validates the order') + log info node 'BugTest1695' 'middle'; + end; +}; diff --git a/mdl-examples/doctype-tests/02-microflow-examples.mdl b/mdl-examples/doctype-tests/02-microflow-examples.mdl index 0a8bc98286..e2cea1abb8 100644 --- a/mdl-examples/doctype-tests/02-microflow-examples.mdl +++ b/mdl-examples/doctype-tests/02-microflow-examples.mdl @@ -2448,6 +2448,30 @@ begin return; end; +/** + * M081b: A note already in the flow, joined to a statement an ALTER inserts. + * + * An alter fragment is built on its own and cannot see the `id:` labels + * describe prints (they are not stored), so it names a stored note by its + * text with `existing:`. The text must match exactly one note of the flow; the + * note keeps its $ID, text and place, and is joined to the new activity too + * (ako/mxcli#1695). `existing:` stands alone and only an alter fragment takes it. + */ +create microflow MfTest.M081b_AnnotationStoredNote () +begin + @annotation 'Validates the order' + log info node 'App' 'validating'; + log info node 'App' 'done'; + return; +end; + +alter microflow MfTest.M081b_AnnotationStoredNote { + insert after log node 'App' 'validating' begin + @annotation(existing: 'Validates the order') + log info node 'App' 'validated'; + end; +}; + create microflow MfTest.M082_ShowMessage_2 () begin show message 'Hello {1}' type Information with ({1} = 'World'); @@ -2690,6 +2714,7 @@ end; -- - Multiple annotations can be stacked before a single activity -- - Free-floating annotations (not followed by an activity) -- - Single quotes escaped by doubling: 'Don''t forget' +-- - @annotation(existing: 'text') in an ALTER fragment joins a note the flow already has -- ✅ MOVE - Organize elements into folders and across modules: -- - MOVE MICROFLOW Module.Name TO FOLDER 'path' - move to folder -- - MOVE MICROFLOW Module.Name TO FOLDER 'a/b/c' - nested folders (auto-created) diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index fc09eb37ea..fb04d5a2a1 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -497,6 +497,15 @@ type MicroflowAnnotation struct { // (`@annotation(id: n1)`), which attaches a note already declared above. Text string + // Existing is the `existing:` in `@annotation(existing: '…')`: the text of + // a note already STORED in the flow, which the statement is attached to + // instead of getting a note of its own. Only an `alter microflow|nanoflow` + // fragment has a stored flow to look in; the text has to match exactly one + // of its notes (ako/mxcli#1695). It stands alone — a stored note keeps its + // own text and place, so `text:`, `id:`, `position:` and `size:` are + // refused beside it rather than half-applied. + Existing string + // Position and Size are the note's own canvas geometry, which Mendix stores per // annotation and MDL had no way to spell. Nil means "let the writer place // it" — see defaultAnnotationGeometry in mdl/executor, which the builder and diff --git a/mdl/backend/mfmutator/note_link.go b/mdl/backend/mfmutator/note_link.go new file mode 100644 index 0000000000..745cc359b1 --- /dev/null +++ b/mdl/backend/mfmutator/note_link.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// LinkNote draws a line from the stored note to object, both in the flow — the +// object may be one a fragment put there earlier — unless one is drawn +// between them already (a replace carries the replaced activity's lines to its +// replacement). Nothing else changes: the note keeps its $ID, text and place. +// +// The line attaches at the sides that face each other where the two are drawn +// NOW, after the fragment was placed and the flow moved to make room for it — +// which is why it is drawn here and not by the builder, which never knew where +// a stored note is relative to what it builds (ako/mxcli#1695). +func (m *Mutator) LinkNote(note, object model.ID) error { + g := m.graph() + n, ok := g.nodes[string(note)] + if !ok || n.typ != "Microflows$Annotation" { + return fmt.Errorf("note %s is not in the flow", note) + } + o, ok := g.nodes[string(object)] + if !ok { + return fmt.Errorf("object %s is not in the flow", object) + } + for _, f := range g.annotationFlows(n.id) { + if (f.origin == n.id && f.dest == o.id) || (f.origin == o.id && f.dest == n.id) { + return nil + } + } + sides := microflows.FacingSides(g.canvasBox(n), g.canvasBox(o)) + return m.addFragment(&backend.MicroflowFragment{AnnotationFlows: []*microflows.AnnotationFlow{{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + OriginID: note, + DestinationID: object, + Sides: &sides, + }}}) +} + +// canvasBox is n's box in absolute canvas coordinates: a loop body's objects +// are drawn relative to the loop box's top-left corner, and a note outside a +// loop may be attached to an object inside it. +func (g *graph) canvasBox(n *node) microflows.CanvasBox { + x, y := n.pos.X, n.pos.Y + for l := g.nodes[n.loop]; l != nil; l = g.nodes[l.loop] { + x += l.pos.X - l.size.X/2 + y += l.pos.Y - l.size.Y/2 + } + return microflows.CanvasBox{Left: x - n.size.X/2, Top: y - n.size.Y/2, Right: x + n.size.X/2, Bottom: y + n.size.Y/2} +} diff --git a/mdl/backend/mfmutator/note_link_test.go b/mdl/backend/mfmutator/note_link_test.go new file mode 100644 index 0000000000..9d8978c506 --- /dev/null +++ b/mdl/backend/mfmutator/note_link_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// ako/mxcli#1695: LinkNote draws a line from a note already in the flow to an +// object, oriented by where the two are drawn — a loop body's object relative +// to its loop box — and draws none where one is already. + +// sidesDeps records the note lines the mutator serializes, sides included, +// which fakeDeps does not write. +type sidesDeps struct { + *fakeDeps + lines []*microflows.AnnotationFlow +} + +func (d *sidesDeps) SerializeAnnotationFlow(f *microflows.AnnotationFlow) (bson.D, error) { + d.lines = append(d.lines, f) + return d.fakeDeps.SerializeAnnotationFlow(f) +} + +// noteLinkUnit is a loop at (420, 200), 600×300 (its box's top-left corner +// at (120, 50)), holding "inner" 200×60 at (100, 60) relative to that corner — +// (220, 110) on the canvas — and a note "note" 200×30 at (700, 115). +func noteLinkUnit(extraFlows ...bson.D) bson.D { + inner := obj("inner", "Microflows$ActionActivity", 100, 60) + dSet(inner, "Size", "200;60") + loop := obj("loop", "Microflows$LoopedActivity", 420, 200) + dSet(loop, "Size", "600;300") + loop = append(loop, bson.E{Key: "ObjectCollection", Value: bson.D{ + {Key: "$ID", Value: bin("loopoc")}, + {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, + {Key: "Objects", Value: bson.A{int32(3), inner}}, + }}) + note := obj("note", "Microflows$Annotation", 700, 115) + dSet(note, "Size", "200;30") + return unit([]bson.D{obj("start", "Microflows$StartEvent", 50, 200), loop, note}, + append([]bson.D{flow("f1", "start", "loop", 1, 3, false)}, extraFlows...)) +} + +func newSidesMutator(t *testing.T, doc bson.D) (*Mutator, *sidesDeps) { + t.Helper() + m, fd := newMutator(t, doc) + deps := &sidesDeps{fakeDeps: fd} + m.deps = deps + return m, deps +} + +func TestLinkNote_FacesTheObjectWhereItIsDrawn(t *testing.T) { + m, deps := newSidesMutator(t, noteLinkUnit()) + if err := m.LinkNote(model.ID(uid("note")), model.ID(uid("inner"))); err != nil { + t.Fatal(err) + } + if len(deps.lines) != 1 { + t.Fatalf("want one line drawn, got %d", len(deps.lines)) + } + // On the canvas the note (y 100..130) is level with inner (y 80..140) and + // right of it, so the line runs from the note's left to inner's right. + // Read in the loop's own coordinates (y 30..90), the note would be below. + if s := deps.lines[0].SidesOrDefault(); s.Origin != 3 || s.Destination != 1 { + t.Errorf("want the note's left side to inner's right side, got origin %d, destination %d", s.Origin, s.Destination) + } + if err := m.Save(); err != nil { + t.Fatalf("save: %v", err) + } +} + +func TestLinkNote_DrawsNoSecondLine(t *testing.T) { + m, deps := newSidesMutator(t, noteLinkUnit(annotationFlow("al", "note", "inner"))) + if err := m.LinkNote(model.ID(uid("note")), model.ID(uid("inner"))); err != nil { + t.Fatal(err) + } + if len(deps.lines) != 0 { + t.Errorf("a line is drawn already, want none added, got %d", len(deps.lines)) + } +} + +func TestLinkNote_RefusesWhatIsNotANote(t *testing.T) { + m, _ := newSidesMutator(t, noteLinkUnit()) + if err := m.LinkNote(model.ID(uid("start")), model.ID(uid("inner"))); err == nil { + t.Error("a line from an object that is not a note must be refused") + } + if err := m.LinkNote(model.ID(uid("note")), model.ID(uid("gone"))); err == nil { + t.Error("a line to an object that is not in the flow must be refused") + } +} diff --git a/mdl/backend/microflow_mutation.go b/mdl/backend/microflow_mutation.go index 81f6b03465..fc4b90b89d 100644 --- a/mdl/backend/microflow_mutation.go +++ b/mdl/backend/microflow_mutation.go @@ -63,6 +63,12 @@ type MicroflowMutator interface { // relative to the stand-in, but relative to target, and its line runs to // target. AddNotes(target model.ID, frag *MicroflowFragment) error + // LinkNote draws a line from note, a note already in the flow, to object, + // an object in the flow — one an earlier call added included — unless one + // is drawn already. The note keeps its $ID, text and place; the line + // attaches at the sides of the two that face each other where they are + // drawn now (ako/mxcli#1695). + LinkNote(note, object model.ID) error // SetFreeNotes replaces the flow's free notes — notes on the canvas, // attached to nothing — by the ones frag holds (its Annotation objects // that no line in it attaches; its other objects are ignored). Nothing diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go index da09867665..b1122ca9e3 100644 --- a/mdl/executor/cmd_alter_flow.go +++ b/mdl/executor/cmd_alter_flow.go @@ -55,6 +55,9 @@ func execAlterFlow(ctx *ExecContext, s *ast.AlterFlowStmt) error { // and check reports the error exec would (ako/mxcli#876). func (a *alterFlowContext) plan(ctx *ExecContext) (backend.MicroflowMutator, error) { s := a.stmt + if err := a.resolveStoredNotes(); err != nil { + return nil, err + } targets := make([]mfmutator.Candidate, len(s.Operations)) for i, op := range s.Operations { c, err := mfmutator.ResolveText(a.cands, op.Target) @@ -167,6 +170,9 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato if err != nil { return fail(err) } + // Lines from stored notes are drawn once the fragment is in place + // (ako/mxcli#1695). + noteLines := a.takeStoredNoteLines(frag) if op.Op == ast.AlterFlowReplace { // An activity with a custom error handler, replaced by one that // states the same handler, keeps the stored one (mendixlabs/mxcli#1489). @@ -215,6 +221,9 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato default: err = fmt.Errorf("unknown operation") } + if err == nil { + err = linkStoredNotes(mut, noteLines) + } if err != nil { return fail(err) } @@ -275,6 +284,12 @@ type alterFlowContext struct { // read once when an operation needs the stored statement behind a target // (storedStatementAt). storedDesc ast.Statement + + // storedNotes are the stored notes the statement's `@annotation(existing: + // '…')` name, by text, once resolveStoredNotes has found exactly one for + // each (ako/mxcli#1695). nil until then — and for the patch of a `create or + // modify`, which states its notes and has none to resolve. + storedNotes map[string]*microflows.Annotation } // enclosingLoop returns the top-level loop whose body holds the stored @@ -668,6 +683,7 @@ func (a *alterFlowContext) fragmentBuilder(ctx *ExecContext) *flowBuilder { restServices: restServices, isNanoflow: a.stmt.Nanoflow, scriptFlows: ctx.scriptFlows, + storedNotes: a.storedNotes, // A fragment is spliced into a stored flow: a member it cannot // qualify is refused, never written bare (ako/mxcli#885). diff --git a/mdl/executor/cmd_alter_flow_stored_notes.go b/mdl/executor/cmd_alter_flow_stored_notes.go new file mode 100644 index 0000000000..514f4c9e94 --- /dev/null +++ b/mdl/executor/cmd_alter_flow_stored_notes.go @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strconv" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// A fragment of `alter microflow|nanoflow … insert|replace` attaches its new +// statement to a note already stored in the flow by naming the note's text +// (ako/mxcli#1695): +// +// insert after log node 'App' 'start' begin +// @annotation(existing: 'Validates the order') +// log info node 'App' 'middle'; +// end; +// +// By text, not by the `id:` labels describe prints: those are re-derived on +// every describe (n1, n2, … in traversal order) and stored nowhere, so an id +// saved in a script could later name a different note. The text is what the +// canvas shows and describe prints, and it is checked: it must name exactly one +// note of the flow as stored, or the statement is refused — by check and exec +// alike, through resolveStoredNotes, before any fragment is built. +// +// A key of its own, never `text:`: `@annotation(text: '…')` creates a note, +// and a reuse of it would read the same whether it creates one or attaches +// one. `existing:` never creates — a text that names no note is a refusal. + +// resolveStoredNotes resolves every `@annotation(existing: '…')` in the +// statement's fragments against the notes of the flow as stored, for the +// fragments' builders, and refuses the statement when a text names no note or +// several. A note an earlier operation of the statement adds is not stored, as +// a target an earlier operation inserts is not addressable. +func (a *alterFlowContext) resolveStoredNotes() error { + s := a.stmt + a.storedNotes = map[string]*microflows.Annotation{} + var notes []*microflows.Annotation + collectStoredNotes(a.mf.ObjectCollection, ¬es) + for _, op := range s.Operations { + for _, text := range existingNoteTexts(op.Body) { + if _, done := a.storedNotes[text]; done { + continue + } + note, err := a.storedNoteNamed(notes, text) + if err != nil { + return mdlerrors.NewValidation(fmt.Sprintf("alter %s %s: %s %s: %v", s.Kind(), s.Name, op.Op, op.Target, err)) + } + a.storedNotes[text] = note + } + } + return nil +} + +// collectStoredNotes appends every note of the collection, loop bodies +// included, to out: a note's text names it wherever on the canvas it is, and +// the text is unique only if it is unique in the whole flow. +func collectStoredNotes(oc *microflows.MicroflowObjectCollection, out *[]*microflows.Annotation) { + if oc == nil { + return + } + for _, obj := range oc.Objects { + switch o := obj.(type) { + case *microflows.Annotation: + *out = append(*out, o) + case *microflows.LoopedActivity: + collectStoredNotes(o.ObjectCollection, out) + } + } +} + +// existingNoteTexts returns the `existing:` texts the statements name, nested +// bodies and error handlers included, in order. +func existingNoteTexts(body []ast.MicroflowStatement) []string { + var out []string + var walk func([]ast.MicroflowStatement) + walk = func(stmts []ast.MicroflowStatement) { + for _, s := range stmts { + if ann := ast.StatementAnnotations(s); ann != nil { + for _, n := range append(append([]ast.MicroflowAnnotation(nil), ann.Notes...), ann.FreeNotes...) { + if n.Existing != "" { + out = append(out, n.Existing) + } + } + } + for _, nested := range ast.StatementBodies(s) { + walk(nested) + } + } + } + walk(body) + return out +} + +// storedNoteNamed returns the one note of notes whose text is text. A note's +// line breaks are compared as LF: Studio Pro stores CRLF, and a script saved by +// an editor that writes LF names the same note (mendixlabs/mxcli#1505). +func (a *alterFlowContext) storedNoteNamed(notes []*microflows.Annotation, text string) (*microflows.Annotation, error) { + var matches []*microflows.Annotation + for _, n := range notes { + if lfLineBreaks(n.Caption) == lfLineBreaks(text) { + matches = append(matches, n) + } + } + ref := "`@annotation(existing: " + mdlQuoted(text) + ")`" + switch { + case len(matches) == 1: + return matches[0], nil + case len(matches) > 1: + return nil, fmt.Errorf("%s names %d notes of the stored flow, so it does not say which one to attach: %s. "+ + "A note is attached by its text only when no other note of the flow reads the same; reword the one "+ + "you mean first (in Studio Pro, or with `create or modify`)", + ref, len(matches), a.describeNotes(matches, false)) + case len(notes) == 0: + return nil, fmt.Errorf("%s names a note of the stored flow, and the flow has no notes; "+ + "`existing:` attaches a note that is already there — to add one, write `@annotation %s`", + ref, mdlQuoted(text)) + } + return nil, fmt.Errorf("%s names no note of the stored flow: none reads that text. The flow's notes: %s. "+ + "`existing:` attaches a note that is already there, named by its text exactly as describe prints it; "+ + "to add a new note, write `@annotation %s`", + ref, a.describeNotes(notes, true), mdlQuoted(text)) +} + +// describeNotes names notes for a refusal: each one's text (when withText), +// where it is drawn, and what it is attached to. +func (a *alterFlowContext) describeNotes(notes []*microflows.Annotation, withText bool) string { + const most = 10 + var parts []string + for i, n := range notes { + if i == most { + parts = append(parts, fmt.Sprintf("and %d more", len(notes)-most)) + break + } + var b strings.Builder + if withText { + b.WriteString(quoteNoteText(n.Caption) + " ") + } + p := n.GetPosition() + fmt.Fprintf(&b, "at (%d, %d)", p.X, p.Y) + if targets := a.noteTargets(n.ID); len(targets) > 0 { + b.WriteString(", attached to " + strings.Join(targets, " and ")) + } else { + b.WriteString(", attached to nothing") + } + parts = append(parts, b.String()) + } + return strings.Join(parts, "; ") +} + +// quoteNoteText quotes a note's text with escapes, which shows a difference in +// invisible characters for what it is, cut short when it is long. +func quoteNoteText(text string) string { + if r := []rune(text); len(r) > 60 { + text = string(r[:60]) + "…" + } + return strconv.Quote(text) +} + +// noteTargets names what the stored note id is attached to, as describe +// prints each statement. +func (a *alterFlowContext) noteTargets(id model.ID) []string { + var out []string + for _, af := range a.mf.ObjectCollection.AnnotationFlows { + other := af.DestinationID + if af.DestinationID == id { + other = af.OriginID + } else if af.OriginID != id { + continue + } + out = append(out, a.objectLabel(other)) + } + return out +} + +// objectLabel is the stored object id as a reader finds it: its statement as +// describe prints it, or its kind. +func (a *alterFlowContext) objectLabel(id model.ID) string { + for _, c := range a.cands { + if c.ID == id && c.Statement != "" { + return "`" + c.Statement + "`" + } + } + for _, c := range a.cands { + if c.ID == id { + return "the " + strings.ToLower(strings.TrimPrefix(fmt.Sprintf("%T", c.Object), "*microflows.")) + } + } + return "an object that cannot be addressed" +} + +// takeStoredNoteLines takes the lines from stored notes out of frag: they are +// drawn once the fragment is in the flow (linkStoredNotes), where both ends +// are where they will stay. Left in, the splice would see a line to an object +// that is not the fragment's and refuse a fragment it can otherwise write in +// place (an activity with an error handler, one inside a loop body). +func (a *alterFlowContext) takeStoredNoteLines(frag *backend.MicroflowFragment) []*microflows.AnnotationFlow { + if frag == nil || len(a.storedNotes) == 0 { + return nil + } + stored := map[model.ID]bool{} + for _, n := range a.storedNotes { + stored[n.ID] = true + } + var lines []*microflows.AnnotationFlow + kept := frag.AnnotationFlows[:0:0] + for _, af := range frag.AnnotationFlows { + if stored[af.OriginID] { + lines = append(lines, af) + continue + } + kept = append(kept, af) + } + frag.AnnotationFlows = kept + return lines +} + +// linkStoredNotes draws the lines takeStoredNoteLines took, now that the +// fragment is in the flow. +func linkStoredNotes(mut backend.MicroflowMutator, lines []*microflows.AnnotationFlow) error { + for _, af := range lines { + if err := mut.LinkNote(af.OriginID, af.DestinationID); err != nil { + return err + } + } + return nil +} + +// attachStoredNote attaches the stored note an `@annotation(existing: '…')` +// names to the activity: a line from that note, which is not created and +// keeps its $ID, text and place. Mentioned twice on one activity, it is +// attached once. +func (fb *flowBuilder) attachStoredNote(note ast.MicroflowAnnotation, activityID model.ID) { + if msg := existingNoteMisuse(note); msg != "" { + fb.addError("%s", msg) + return + } + if fb.storedNotes == nil { + fb.addError("%s", existingNoteOutsideAlter(note.Existing)) + return + } + stored, ok := fb.storedNotes[note.Existing] + if !ok { + // resolveStoredNotes refuses a text that names no stored note before + // a fragment is built; this is a caller that built one without it. + fb.addError("`@annotation(existing: %s)` was not resolved against the stored flow", mdlQuoted(note.Existing)) + return + } + for _, af := range fb.annotationFlows { + if af.OriginID == stored.ID && af.DestinationID == activityID { + return + } + } + fb.linkAnnotation(stored.ID, activityID) +} + +// storedNoteHint points an `@annotation(id: …)` in an alter fragment that +// names no note of the fragment at the form that names a stored one: the +// labels describe prints are not stored, so a fragment cannot see them. +func (fb *flowBuilder) storedNoteHint() string { + if fb.storedNotes == nil { + return "" + } + return "; a note already stored in the flow is attached by its text, as @annotation(existing: '…')" +} + +// existingNoteMisuse is the refusal for `existing:` beside a parameter that +// describes a note of the statement's own, or "" when it stands alone. check +// (the validator) and exec (the builder) both say it. +func existingNoteMisuse(note ast.MicroflowAnnotation) string { + var extra []string + if note.Label != "" { + extra = append(extra, "`id:`") + } + if note.Text != "" { + extra = append(extra, "`text:`") + } + if note.Position != nil { + extra = append(extra, "`position:`") + } + if note.Size != nil { + extra = append(extra, "`size:`") + } + if len(extra) == 0 { + return "" + } + return fmt.Sprintf("`@annotation(existing: %s)` attaches a note already stored in the flow, which keeps its own "+ + "text and place, so it takes no %s beside it; to create a note of the statement's own, write "+ + "`@annotation(text: …)` without `existing:`", mdlQuoted(note.Existing), strings.Join(extra, " or ")) +} + +// existingNoteOutsideAlter is the refusal for `existing:` in a statement that +// states a whole flow, which has no stored flow to attach from. +func existingNoteOutsideAlter(text string) string { + return fmt.Sprintf("`@annotation(existing: %s)` attaches a note already stored in the flow, which only a "+ + "fragment of `alter microflow|nanoflow … insert|replace` has; a statement that states the whole flow "+ + "declares its notes — `@annotation 'text'`, or `@annotation(id: n1, text: '…')` and then "+ + "`@annotation(id: n1)` to share one", mdlQuoted(text)) +} + +// existingNoteUnattached is the refusal for `existing:` with no statement +// after it to attach the stored note to. +func existingNoteUnattached(text string) string { + return fmt.Sprintf("`@annotation(existing: %s)` is followed by no statement to attach the stored note to", + mdlQuoted(text)) +} diff --git a/mdl/executor/cmd_alter_flow_stored_notes_1695_pedapp_test.go b/mdl/executor/cmd_alter_flow_stored_notes_1695_pedapp_test.go new file mode 100644 index 0000000000..1a61ee16f3 --- /dev/null +++ b/mdl/executor/cmd_alter_flow_stored_notes_1695_pedapp_test.go @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// ako/mxcli#1695 (mendixlabs/mxcli#1517, comment item 3): a fragment of +// `alter microflow … insert` could not attach its new statement to a note +// already stored in the flow. `@annotation(id: n1)` was refused — "refers to +// an annotation that has not been declared" — because the fragment is built +// alone, and the labels describe prints are re-derived on every describe, so a +// saved one could later name a different note. A stored note is now named by +// its text: `@annotation(existing: '…')`, which must match exactly one note. + +const storedNoteFlow = `mdl 1; +create microflow MyFirstModule.NoteAttach () +begin + @annotation 'Validates the order' + log info node 'App' 'start'; + @annotation(text: 'Below the row', position: (560, 330)) + log info node 'App' 'done'; +end;` + +const storedNoteTwinsFlow = `mdl 1; +create microflow MyFirstModule.NoteTwins () +begin + @annotation 'Check' + log info node 'App' 'a'; + @annotation 'Check' + log info node 'App' 'b'; +end;` + +func openStoredNoteFixture(t *testing.T) (*Executor, *bytes.Buffer, string) { + t.Helper() + exec, out, dir := openPedAppCopy(t) + for _, src := range []string{storedNoteFlow, storedNoteTwinsFlow} { + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + } + return exec, out, dir +} + +// notesReading returns the flow's notes whose text is text. +func notesReading(mf *microflows.Microflow, text string) []*microflows.Annotation { + var out []*microflows.Annotation + for _, obj := range mf.ObjectCollection.Objects { + if n, ok := obj.(*microflows.Annotation); ok && n.Caption == text { + out = append(out, n) + } + } + return out +} + +// linesFrom returns the note lines leaving note id. +func linesFrom(mf *microflows.Microflow, id model.ID) []*microflows.AnnotationFlow { + var out []*microflows.AnnotationFlow + for _, af := range mf.ObjectCollection.AnnotationFlows { + if af.OriginID == id { + out = append(out, af) + } + } + return out +} + +// logActivity returns the ID of the log activity whose message is text. +func logActivity(t *testing.T, mf *microflows.Microflow, text string) model.ID { + t.Helper() + for _, obj := range mf.ObjectCollection.Objects { + act, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + lg, ok := act.Action.(*microflows.LogMessageAction) + if !ok || lg.MessageTemplate == nil { + continue + } + for _, s := range lg.MessageTemplate.Translations { + if s == text { + return act.ID + } + } + } + t.Fatalf("no log activity says %q", text) + return "" +} + +// flowUnitBytes returns MyFirstModule.name's stored bytes. +func flowUnitBytes(t *testing.T, exec *Executor, name string) []byte { + t.Helper() + mf := loopEditFlowOf(t, exec, name) + raw, err := exec.newExecContext(context.Background()).Backend.GetRawUnitBytes(mf.ID) + if err != nil { + t.Fatal(err) + } + return append([]byte(nil), raw...) +} + +// staticErrors is what `mxcli check` reports as an error without a project. +func staticErrors(t *testing.T, src string) string { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs[0], src) + } + var out []string + for _, v := range ValidateProgram(prog, "") { + if v.Severity == linter.SeverityError { + out = append(out, v.RuleID+": "+v.Message) + } + } + return strings.Join(out, "\n") +} + +const attachByText = `mdl 1; +alter microflow MyFirstModule.NoteAttach { + insert after log node 'App' 'start' begin + @annotation(existing: 'Validates the order') + log info node 'App' 'middle'; + end; +};` + +func TestAlterMicroflow_AttachesAStoredNoteByItsText(t *testing.T) { + exec, out, _ := openStoredNoteFixture(t) + before := loopEditFlowOf(t, exec, "NoteAttach") + stored := notesReading(before, "Validates the order") + if len(stored) != 1 { + t.Fatalf("setup: want one stored note, got %d", len(stored)) + } + note := stored[0] + if s := staticErrors(t, attachByText); s != "" { + t.Errorf("check must accept the statement, reported:\n%s", s) + } + predictedAgree(t, exec, attachByText, "") + + after := loopEditFlowOf(t, exec, "NoteAttach") + notes := notesReading(after, "Validates the order") + if len(notes) != 1 { + t.Fatalf("the note must be attached, not copied: %d notes read its text", len(notes)) + } + if got := notes[0]; got.ID != note.ID || got.Position != note.Position || got.Size != note.Size { + t.Errorf("the stored note must keep its $ID, place and size: was %s at %v %v, is %s at %v %v", + note.ID, note.Position, note.Size, got.ID, got.Position, got.Size) + } + middle := logActivity(t, after, "middle") + start := logActivity(t, after, "start") + dests := map[model.ID]bool{} + for _, af := range linesFrom(after, note.ID) { + dests[af.DestinationID] = true + } + if len(dests) != 2 || !dests[middle] || !dests[start] { + t.Errorf("the note must be attached to the stored activity and the inserted one, its lines go to %v "+ + "(start %s, middle %s)", dests, start, middle) + } + + // describe prints the note as one shared note, and its statement re-runs + // to nothing: the flow is what a whole-flow statement would write. + desc := describeFlow(t, exec, "NoteAttach") + if !strings.Contains(desc, "@annotation(id: n1, text: 'Validates the order'") || + !strings.Contains(desc, "@annotation(id: n1)\n log node 'App' 'middle';") { + t.Errorf("describe must print one note shared by both activities:\n%s", desc) + } + out.Reset() + if err := agreeExec(t, exec, desc); err != nil { + t.Fatalf("describe → exec: %v", err) + } + if !strings.Contains(out.String(), "Unchanged microflow: MyFirstModule.NoteAttach") { + t.Errorf("describe → exec must leave the flow unchanged, got:\n%s", out.String()) + } +} + +// Control: the same insert stating the note as text makes a second note, +// which is what the one-note-same-$ID assertion above tells apart. +func TestAlterMicroflow_StatedNoteTextIsANewNote(t *testing.T) { + exec, _, _ := openStoredNoteFixture(t) + predictedAgree(t, exec, strings.Replace(attachByText, + "@annotation(existing: 'Validates the order')", "@annotation 'Validates the order'", 1), "") + if n := len(notesReading(loopEditFlowOf(t, exec, "NoteAttach"), "Validates the order")); n != 2 { + t.Errorf("control: `@annotation 'text'` creates a note of its own, want 2 notes reading the text, got %d", n) + } +} + +// The line is drawn where the two are once the fragment is in place, from the +// sides that face each other: a note below the row is left by its top edge. +func TestAlterMicroflow_StoredNoteLineFacesTheNote(t *testing.T) { + exec, _, _ := openStoredNoteFixture(t) + predictedAgree(t, exec, `mdl 1; +alter microflow MyFirstModule.NoteAttach { + insert after log node 'App' 'done' begin + @annotation(existing: 'Below the row') + log info node 'App' 'later'; + end; +};`, "") + mf := loopEditFlowOf(t, exec, "NoteAttach") + notes := notesReading(mf, "Below the row") + if len(notes) != 1 { + t.Fatalf("want one note reading 'Below the row', got %d", len(notes)) + } + later := logActivity(t, mf, "later") + for _, af := range linesFrom(mf, notes[0].ID) { + if af.DestinationID != later { + continue + } + if s := af.SidesOrDefault(); s.Origin != AnchorTop || s.Destination != AnchorBottom { + t.Errorf("a note below the row is joined top to bottom, got origin %d, destination %d", s.Origin, s.Destination) + } + return + } + t.Errorf("no line from the note to the inserted activity") +} + +// A replacement attached to the note its target already carries gets that +// note's one line, not a second one. +func TestAlterMicroflow_ReplaceAttachedToTheReplacedNote(t *testing.T) { + exec, _, _ := openStoredNoteFixture(t) + note := notesReading(loopEditFlowOf(t, exec, "NoteAttach"), "Validates the order")[0] + predictedAgree(t, exec, `mdl 1; +alter microflow MyFirstModule.NoteAttach { + replace log node 'App' 'start' with begin + @annotation(existing: 'Validates the order') + log info node 'App' 'started'; + end; +};`, "") + mf := loopEditFlowOf(t, exec, "NoteAttach") + lines := linesFrom(mf, note.ID) + if len(lines) != 1 || lines[0].DestinationID != logActivity(t, mf, "started") { + t.Errorf("want the note's one line on the replacement, got %d lines", len(lines)) + } +} + +// A text that names no stored note, or several, is refused by check and exec +// alike, and nothing is written. +func TestAlterMicroflow_StoredNoteTextMustNameOneNote(t *testing.T) { + exec, _, _ := openStoredNoteFixture(t) + t.Run("no note reads the text", func(t *testing.T) { + raw := flowUnitBytes(t, exec, "NoteAttach") + src := strings.Replace(attachByText, "existing: 'Validates the order'", "existing: 'Validates the orders'", 1) + predictedAgree(t, exec, src, "names no note of the stored flow") + scopeAgree(t, exec, src, `The flow's notes: "Validates the order" at (`) + if !bytes.Equal(raw, flowUnitBytes(t, exec, "NoteAttach")) { + t.Errorf("a refused statement wrote the flow") + } + }) + t.Run("two notes read the text", func(t *testing.T) { + raw := flowUnitBytes(t, exec, "NoteTwins") + src := `mdl 1; +alter microflow MyFirstModule.NoteTwins { + insert after log node 'App' 'a' begin + @annotation(existing: 'Check') + log info node 'App' 'between'; + end; +};` + predictedAgree(t, exec, src, "names 2 notes of the stored flow") + scopeAgree(t, exec, src, "attached to `log node 'App' 'a'`") + scopeAgree(t, exec, src, "attached to `log node 'App' 'b'`") + if !bytes.Equal(raw, flowUnitBytes(t, exec, "NoteTwins")) { + t.Errorf("a refused statement wrote the flow") + } + }) +} + +// `existing:` stands alone, and only a fragment has a stored flow: check (no +// project needed) and exec refuse the rest with the same words. +func TestAlterMicroflow_ExistingNoteMisuseRefused(t *testing.T) { + cases := []struct{ name, src, want string }{ + {"beside text", strings.Replace(attachByText, "existing: 'Validates the order'", + "existing: 'Validates the order', text: 'other'", 1), "so it takes no `text:` beside it"}, + {"beside position", strings.Replace(attachByText, "existing: 'Validates the order'", + "existing: 'Validates the order', position: (10, 10)", 1), "so it takes no `position:` beside it"}, + {"in a whole flow", `mdl 1; +create microflow MyFirstModule.NoteWhole () +begin + @annotation(existing: 'Validates the order') + log info node 'App' 'x'; +end;`, "which only a fragment of `alter microflow|nanoflow … insert|replace` has"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + exec, _, _ := openStoredNoteFixture(t) + if s := staticErrors(t, c.src); !strings.Contains(s, "MDL079") || !strings.Contains(s, c.want) { + t.Errorf("check must refuse with %q, reported:\n%s", c.want, s) + } + if err := agreeExec(t, exec, c.src); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("exec must refuse with %q, got: %v", c.want, err) + } + }) + } +} + +// Regression: a fragment's own notes — by text, and shared through `id:` — +// mean what they meant, and an `id:` the fragment does not declare is now +// refused by check as well as exec, pointing at `existing:`. +func TestAlterMicroflow_FragmentNoteLabelsUnchanged(t *testing.T) { + t.Run("a note the fragment declares and shares", func(t *testing.T) { + exec, _, _ := openStoredNoteFixture(t) + predictedAgree(t, exec, `mdl 1; +alter microflow MyFirstModule.NoteAttach { + insert after log node 'App' 'start' begin + @annotation(id: n1, text: 'shared by the two') + log info node 'App' 'one'; + @annotation(id: n1) + log info node 'App' 'two'; + @annotation 'only the third' + log info node 'App' 'three'; + end; +};`, "") + mf := loopEditFlowOf(t, exec, "NoteAttach") + shared := notesReading(mf, "shared by the two") + if len(shared) != 1 || len(linesFrom(mf, shared[0].ID)) != 2 { + t.Errorf("want one new note with two lines, got %d notes", len(shared)) + } + if own := notesReading(mf, "only the third"); len(own) != 1 || len(linesFrom(mf, own[0].ID)) != 1 { + t.Errorf("want one new note on the third activity, got %d", len(own)) + } + }) + t.Run("an id the fragment does not declare", func(t *testing.T) { + exec, _, _ := openStoredNoteFixture(t) + src := strings.Replace(attachByText, "@annotation(existing: 'Validates the order')", "@annotation(id: n1)", 1) + if s := staticErrors(t, src); !strings.Contains(s, "`@annotation(id: n1)` refers to a note that has not been declared above it") { + t.Errorf("check must refuse an undeclared id in a fragment, reported:\n%s", s) + } + err := agreeExec(t, exec, src) + if err == nil || !strings.Contains(err.Error(), "has not been declared") || + !strings.Contains(err.Error(), "@annotation(existing: '…')") { + t.Errorf("exec must refuse it and point at `existing:`, got: %v", err) + } + }) +} + +// A note's line breaks are CRLF as Studio Pro stores them; a script saved with +// LF names the same note (mendixlabs/mxcli#1505). Any other difference is no +// match. +func TestStoredNoteNamedMatchesLineBreaksEitherWay(t *testing.T) { + note := func(id, text string) *microflows.Annotation { + return µflows.Annotation{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(id)}}, Caption: text} + } + a := &alterFlowContext{mf: µflows.Microflow{ObjectCollection: µflows.MicroflowObjectCollection{}}} + notes := []*microflows.Annotation{note("n1", "first line\r\nsecond line"), note("n2", "other")} + got, err := a.storedNoteNamed(notes, "first line\nsecond line") + if err != nil || got.ID != "n1" { + t.Errorf("an LF text must name the CRLF note, got %v, %v", got, err) + } + if _, err := a.storedNoteNamed(notes, "first line second line"); err == nil { + t.Error("control: a text that differs in more than its line breaks names no note") + } +} diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index a518e6f014..13496f30c2 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -37,6 +37,14 @@ type flowBuilder struct { flows []*microflows.SequenceFlow annotationFlows []*microflows.AnnotationFlow + // storedNotes resolves `@annotation(existing: '…')` to the note of the + // stored flow with that text, keyed by the text as the script writes it. + // Set only for an alter fragment, by resolveStoredNotes, which refuses a + // text that names no note or several; nil means there is no stored flow + // to attach to — a create body — and the form is refused + // (ako/mxcli#1695). + storedNotes map[string]*microflows.Annotation + // annotationsByLabel resolves `@annotation(id: n1)` back to the Annotation // its first mention created, so a note wired to several activities is ONE // object with several flows — the shape Mendix stores and the shape MDL diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index b5b2f3c5b7..e22b66da7f 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -144,8 +144,16 @@ func (fb *flowBuilder) applyAnnotations(activityID model.ID, ann *ast.ActivityAn // @annotation — attach the notes. All of them: an activity can carry more // than one, and keeping only the last silently deleted the others (#1077). - for i, note := range ann.Notes { + // + // A stored note (`existing:`) takes no place in the stack: it is drawn + // where it is, and its line is drawn after the fragment's own, so describe + // counts it after them too (ako/mxcli#1695). + i := 0 + for _, note := range ann.Notes { fb.attachAnnotation(note, activityID, i) + if note.Existing == "" { + i++ + } } } @@ -325,6 +333,10 @@ func objectHeight(obj microflows.MicroflowObject) int { // index is the note's ordinal among those attached to this activity, used only // to place unpositioned notes so they stack instead of overlapping. func (fb *flowBuilder) attachAnnotation(note ast.MicroflowAnnotation, activityID model.ID, index int) { + if note.Existing != "" { + fb.attachStoredNote(note, activityID) + return + } if note.Label != "" { if existing, ok := fb.annotationsByLabel[note.Label]; ok { if note.Text != "" && note.Text != existing.Caption { @@ -338,8 +350,8 @@ func (fb *flowBuilder) attachAnnotation(note ast.MicroflowAnnotation, activityID } if note.Text == "" { fb.addError("@annotation(id: %s) refers to an annotation that has not been declared — "+ - "the first mention must carry the text, as @annotation(id: %s, text: '…')", - note.Label, note.Label) + "the first mention must carry the text, as @annotation(id: %s, text: '…')%s", + note.Label, note.Label, fb.storedNoteHint()) return } } @@ -381,6 +393,12 @@ func (fb *flowBuilder) recordStatedNote(note ast.MicroflowAnnotation, id model.I // activity. A label on one is accepted and reused, so a note can be shared // between the canvas and an activity. func (fb *flowBuilder) attachFreeAnnotation(note ast.MicroflowAnnotation) { + if note.Existing != "" { + // A stored note is on the canvas already; the mention can only mean + // attaching it, and there is nothing here to attach it to. + fb.addError("%s", existingNoteUnattached(note.Existing)) + return + } if note.Label != "" { if _, ok := fb.annotationsByLabel[note.Label]; ok { // Already created; a free mention adds no flow, so there is @@ -471,20 +489,13 @@ func collectCanvasBoxes(objects []microflows.MicroflowObject, origin model.Point } // facingSides returns the sides at which a line from note to target attaches. -// Vertical separation wins over horizontal; boxes that overlap fall back to the -// note-above drawing the writer has always used. +// The formula is microflows.FacingSides, which the splice also uses for a line +// it draws from a stored note (ako/mxcli#1695): two copies would drift. func facingSides(note, target canvasBox) *microflows.ConnectionSides { - switch { - case note.bottom < target.top: - return µflows.ConnectionSides{Origin: AnchorBottom, Destination: AnchorTop} - case note.top > target.bottom: - return µflows.ConnectionSides{Origin: AnchorTop, Destination: AnchorBottom} - case note.right < target.left: - return µflows.ConnectionSides{Origin: AnchorRight, Destination: AnchorLeft} - case note.left > target.right: - return µflows.ConnectionSides{Origin: AnchorLeft, Destination: AnchorRight} - } - return µflows.ConnectionSides{Origin: AnchorBottom, Destination: AnchorTop} + s := microflows.FacingSides( + microflows.CanvasBox{Left: note.left, Top: note.top, Right: note.right, Bottom: note.bottom}, + microflows.CanvasBox{Left: target.left, Top: target.top, Right: target.right, Bottom: target.bottom}) + return &s } func (fb *flowBuilder) linkAnnotation(annotationID, activityID model.ID) { diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index c6ac42683d..c5d9e66a76 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -741,6 +741,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID { // is the same split mxcli already ships for an ordinary loop-body note, // whose flow is hoisted to the parent a few lines below. (#1077) annotationsByLabel: fb.annotationsByLabel, + storedNotes: fb.storedNotes, } // Process loop body statements and connect them with flows. @@ -1120,6 +1121,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID { // is the same split mxcli already ships for an ordinary loop-body note, // whose flow is hoisted to the parent a few lines below. (#1077) annotationsByLabel: fb.annotationsByLabel, + storedNotes: fb.storedNotes, } // Body bookkeeping is addLoopStatement's, verbatim: a WHILE body is a loop diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index c71b147e77..00aa4dcb71 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -889,6 +889,7 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in // (or the reverse) lands in one collection — sharing the registry is // sound here in a way it is not across a loop boundary (#1077). annotationsByLabel: fb.annotationsByLabel, + storedNotes: fb.storedNotes, // Same collection, so the same label table: `join recovered` inside a // handler must find the `merge recovered` declared on the main path. // This is the one rejoin MDL could not spell before, and the reason the diff --git a/mdl/executor/layout_rows.go b/mdl/executor/layout_rows.go index ab3fc1c2a2..621f81a4c6 100644 --- a/mdl/executor/layout_rows.go +++ b/mdl/executor/layout_rows.go @@ -134,9 +134,22 @@ func (fb *flowBuilder) shouldWrap(stmt ast.MicroflowStatement, rest []ast.Microf } // startsWithNote reports whether a statement carries a note, which is drawn above it. +// A stored note it is attached to (`existing:`) is drawn where it is, not above +// it (ako/mxcli#1695). func startsWithNote(stmt ast.MicroflowStatement) bool { ann := getStatementAnnotations(stmt) - return ann != nil && (len(ann.Notes) > 0 || len(ann.FreeNotes) > 0) + if ann == nil { + return false + } + if len(ann.FreeNotes) > 0 { + return true + } + for _, n := range ann.Notes { + if n.Existing == "" { + return true + } + } + return false } // wrapRow moves the cursor to the start of a new row, below everything the current diff --git a/mdl/executor/validate_alter_flow.go b/mdl/executor/validate_alter_flow.go index 66b6eccdf0..682463bd6b 100644 --- a/mdl/executor/validate_alter_flow.go +++ b/mdl/executor/validate_alter_flow.go @@ -24,10 +24,16 @@ import ( // Only the annotation rules: the rest of the microflow rule set reads the whole // flow — its parameters, its declares, what a later statement uses — and a // fragment is a piece of a stored flow that this pass cannot see. +// +// The note labels are checked per fragment, as each fragment's builder keeps +// them: an `@annotation(id: n1)` no earlier mention in the fragment declares +// names nothing — the labels describe prints are not stored — and was left to +// exec to refuse, check passing it as "not predicted" (ako/mxcli#1695). func ValidateAlterFlow(s *ast.AlterFlowStmt) []linter.Violation { - v := µflowValidator{mfName: s.Name.String(), docType: s.Kind()} + v := µflowValidator{mfName: s.Name.String(), docType: s.Kind(), fragment: true} for _, op := range s.Operations { v.walkAnnotations(op.Body) + v.checkAnnotationLabels(op.Body) } return v.violations } diff --git a/mdl/executor/validate_flow_verdicts.go b/mdl/executor/validate_flow_verdicts.go index 7e106d519f..dd92eee699 100644 --- a/mdl/executor/validate_flow_verdicts.go +++ b/mdl/executor/validate_flow_verdicts.go @@ -343,6 +343,20 @@ func alterFlowViolations(ctx *ExecContext, s *ast.AlterFlowStmt) (_ []linter.Vio if err != nil { return nil, "", nil // no such flow: reference validation reports it } + refusal := func(err error) []linter.Violation { + return []linter.Violation{{ + RuleID: alterFlowRefusedRule, + Severity: linter.SeverityError, + Message: "exec would refuse this statement and write nothing: " + err.Error(), + Location: linter.Location{Module: s.Name.Module, DocumentType: s.Kind(), DocumentName: s.Name.Name}, + }} + } + // A stored note named by a text that names none, or several, is a + // refusal, not something the script could still declare: resolved before + // a fragment is built, by the function exec's plan calls (ako/mxcli#1695). + if err := a.resolveStoredNotes(); err != nil { + return refusal(err), "", nil + } // A fragment that does not build against the stored project is not // predictable (see CheckFlowVerdicts). Only the builder's own errors say // so; a fragment that builds and cannot be spliced is a refusal. Each is @@ -370,12 +384,7 @@ func alterFlowViolations(ctx *ExecContext, s *ast.AlterFlowStmt) (_ []linter.Vio if errors.As(err, &nf) { return nil, "", nil } - return []linter.Violation{{ - RuleID: alterFlowRefusedRule, - Severity: linter.SeverityError, - Message: "exec would refuse this statement and write nothing: " + err.Error(), - Location: linter.Location{Module: s.Name.Module, DocumentType: s.Kind(), DocumentName: s.Name.Name}, - }}, "", nil + return refusal(err), "", nil } // FormatUnpredictedFlows is the note check and exec's preflight print for diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index e50c5d5ff0..a8b403afdf 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -98,6 +98,10 @@ type microflowValidator struct { // excluded marks an @excluded document. mxbuild does not check one, so the // #893 rules stand down for it — see skipCEGapRules. excluded bool + // fragment marks the body of an `alter microflow|nanoflow` operation: a + // piece of a stored flow, whose notes `@annotation(existing: '…')` can + // attach to (ako/mxcli#1695). Anything else states a whole flow. + fragment bool // voids resolves which Java/JavaScript action calls target a void action; // such a call declares no variable (MDL063, #953). May be nil. voids *voidCodeActions @@ -1899,7 +1903,34 @@ func (v *microflowValidator) checkUnknownAnnotations(s ast.MicroflowStatement) { "`@annotation(id: n1, text: 'text', position: (x, y), size: (w, h))` where every parameter "+ "except one of `id:`/`text:` is optional. `id:` names a note so a later "+ "`@annotation(id: n1)` attaches THAT note to another activity instead of creating a "+ - "second one (mendixlabs/mxcli#1077).") + "second one (mendixlabs/mxcli#1077). In an `alter` fragment, `@annotation(existing: 'text')` "+ + "attaches a note already stored in the flow (ako/mxcli#1695).") + } + v.checkExistingNotes(ann) +} + +// checkExistingNotes refuses an `@annotation(existing: '…')` that cannot +// attach a stored note: one beside a parameter that describes a note of its +// own, and one in a statement that states a whole flow, where there is no +// stored flow to attach from. The builder refuses both at exec with the same +// words; whether the text names exactly one stored note is the plan's to say, +// against the project (resolveStoredNotes, ako/mxcli#1695). +func (v *microflowValidator) checkExistingNotes(ann *ast.ActivityAnnotations) { + for _, note := range append(append([]ast.MicroflowAnnotation{}, ann.Notes...), ann.FreeNotes...) { + if note.Existing == "" { + continue + } + if msg := existingNoteMisuse(note); msg != "" { + v.addViolation("MDL079", linter.SeverityError, msg, + "Write `@annotation(existing: 'text')` alone to attach the stored note, or a note of the "+ + "statement's own without `existing:`.") + continue + } + if !v.fragment { + v.addViolation("MDL079", linter.SeverityError, existingNoteOutsideAlter(note.Existing), + "Name a stored note by its text in `alter microflow M.F { insert after … begin "+ + "@annotation(existing: 'text') … end; }`.") + } } } @@ -1922,15 +1953,23 @@ func (v *microflowValidator) checkAnnotationLabels(body []ast.MicroflowStatement if ann := ast.StatementAnnotations(s); ann != nil { for _, note := range append(append([]ast.MicroflowAnnotation{}, ann.Notes...), ann.FreeNotes...) { switch { - case note.Label == "": + case note.Label == "" || note.Existing != "": + // `existing:` beside `id:` is checkExistingNotes' to refuse. case note.Text != "": declared[note.Label] = true case !declared[note.Label]: + hint := fmt.Sprintf("The FIRST mention of a note carries its text: "+ + "`@annotation(id: %s, text: '…')`. Later mentions attach that same note to "+ + "another activity with `@annotation(id: %s)`.", note.Label, note.Label) + if v.fragment { + // The labels describe prints are not stored, so a + // fragment cannot see the stored flow's (ako/mxcli#1695). + hint += " A note already stored in the flow is attached by its text: " + + "`@annotation(existing: '…')`." + } v.addViolation("MDL079", linter.SeverityError, fmt.Sprintf("`@annotation(id: %s)` refers to a note that has not been declared above it", note.Label), - fmt.Sprintf("The FIRST mention of a note carries its text: "+ - "`@annotation(id: %s, text: '…')`. Later mentions attach that same note to "+ - "another activity with `@annotation(id: %s)`.", note.Label, note.Label)) + hint) } } } diff --git a/mdl/visitor/visitor_existing_note_test.go b/mdl/visitor/visitor_existing_note_test.go new file mode 100644 index 0000000000..c2fd1f1bf7 --- /dev/null +++ b/mdl/visitor/visitor_existing_note_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#1695: `@annotation(existing: '…')` names a note already stored in +// the flow. It is its own key, never read as `text:`, and it attaches to the +// statement wherever it stands among the statement's annotations — a stored +// note is on the canvas already, so before `@position` it is not a free note. +func TestExistingNoteAnnotation(t *testing.T) { + prog, errs := Build(`alter microflow M.F { + insert after log node 'App' 'start' begin + @annotation(existing: 'Validates the order') + @position(400, 200) + log info node 'App' 'middle'; + end; +};`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + alter := prog.Statements[0].(*ast.AlterFlowStmt) + ann := ast.StatementAnnotations(alter.Operations[0].Body[0]) + if ann == nil { + t.Fatal("no annotations") + } + if len(ann.FreeNotes) != 0 || len(ann.InvalidNotes) != 0 { + t.Fatalf("free %#v, invalid %#v: want neither", ann.FreeNotes, ann.InvalidNotes) + } + if len(ann.Notes) != 1 { + t.Fatalf("notes %#v: want one", ann.Notes) + } + if n := ann.Notes[0]; n.Existing != "Validates the order" || n.Text != "" || n.Label != "" { + t.Errorf("note %#v: want Existing set and no text or label", n) + } + + // Control: the same position for a note of its own makes it free. + prog, errs = Build(`alter microflow M.F { + insert after log node 'App' 'start' begin + @annotation(text: 'Validates the order') + @position(400, 200) + log info node 'App' 'middle'; + end; +};`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + alter = prog.Statements[0].(*ast.AlterFlowStmt) + if ann := ast.StatementAnnotations(alter.Operations[0].Body[0]); len(ann.FreeNotes) != 1 { + t.Errorf("control: a note of its own before @position is free, got %#v", ann) + } +} + +// An empty text names nothing and is recorded, not dropped (MDL079). +func TestExistingNoteAnnotationEmptyIsInvalid(t *testing.T) { + prog, errs := Build(`create microflow M.F () +begin + @annotation(existing: '') + log info node 'App' 'x'; +end;`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + body := prog.Statements[0].(*ast.CreateMicroflowStmt).Body + ann := ast.StatementAnnotations(body[0]) + if ann == nil || len(ann.InvalidNotes) == 0 || len(ann.Notes) != 0 { + t.Errorf("want the empty `existing:` recorded as invalid, got %#v", ann) + } +} diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index dfdf6e3732..77febb190b 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -328,8 +328,11 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A if ok { // Free-floating only when nothing has claimed this statement // yet AND an activity annotation follows: the note belongs to - // the canvas, not to the statement below it. - if !seenActivityMetadata && hasLaterActivityAnnotation(annotations, i+1) { + // the canvas, not to the statement below it. A stored note + // named by `existing:` is on the canvas already, so the only + // thing the mention can mean is "attach it to this statement" + // (ako/mxcli#1695). + if note.Existing == "" && !seenActivityMetadata && hasLaterActivityAnnotation(annotations, i+1) { result.FreeNotes = append(result.FreeNotes, note) } else { result.Notes = append(result.Notes, note) @@ -461,6 +464,16 @@ func parseNoteAnnotation(ann *parser.AnnotationContext, result *ast.ActivityAnno if note.Text == "" { result.InvalidNotes = append(result.InvalidNotes, strings.TrimSpace(pCtx.GetText())) } + case "existing": + // The text of a note already stored in the flow (ako/mxcli#1695). + // A key of its own, not `text:`, so a note that is attached can + // never be read as one that is created. + if v := pCtx.AnnotationValue(); v != nil { + note.Existing = extractAnnotationValueString(v) + } + if note.Existing == "" { + result.InvalidNotes = append(result.InvalidNotes, strings.TrimSpace(pCtx.GetText())) + } case "position": pt, ok := annotationPointValue(pCtx) if !ok { @@ -480,8 +493,9 @@ func parseNoteAnnotation(ann *parser.AnnotationContext, result *ast.ActivityAnno } } - // A note needs either text (a declaration) or a label (a reference to one). - if note.Text == "" && note.Label == "" { + // A note needs text (a declaration), a label (a reference to one declared + // in the script) or a stored note's text (a reference to one in the flow). + if note.Text == "" && note.Label == "" && note.Existing == "" { result.InvalidNotes = append(result.InvalidNotes, strings.TrimSpace(ann.GetText())) return note, false } diff --git a/sdk/microflows/note_sides.go b/sdk/microflows/note_sides.go new file mode 100644 index 0000000000..1d3e40e5ec --- /dev/null +++ b/sdk/microflows/note_sides.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +package microflows + +// CanvasBox is an element's box in absolute canvas coordinates: a loop body's +// objects are drawn relative to the loop box's top-left corner, so a box of one +// of them has that corner added. +type CanvasBox struct{ Left, Top, Right, Bottom int } + +// FacingSides returns the sides at which a line from a note to the element it +// documents attaches: the side of the note that faces the element, and the +// side of the element that faces the note (mendixlabs/mxcli#1598, case 2). +// +// Studio Pro's own drawings are the rule: of 523 distinct note lines in +// Studio Pro-authored apps, the element's side is top or bottom whenever the +// two boxes are vertically clear of each other, and left or right only when +// they overlap vertically — 98% agreement. So vertical separation wins over +// horizontal, and boxes that overlap fall back to the note-above drawing the +// writer has always used. +// +// One formula for every writer: the flow builder orients the lines of the +// notes it creates, and the splice orients a line it draws from a note +// already in the flow (ako/mxcli#1695). +func FacingSides(note, target CanvasBox) ConnectionSides { + const top, right, bottom, left = 0, 1, 2, 3 + switch { + case note.Bottom < target.Top: + return ConnectionSides{Origin: bottom, Destination: top} + case note.Top > target.Bottom: + return ConnectionSides{Origin: top, Destination: bottom} + case note.Right < target.Left: + return ConnectionSides{Origin: right, Destination: left} + case note.Left > target.Right: + return ConnectionSides{Origin: left, Destination: right} + } + return ConnectionSides{Origin: bottom, Destination: top} +} From d9ea0b8af054fa5372bee46e80ffa97eefdbc7ce Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 25 Oct 2026 18:40:14 +0000 Subject: [PATCH 02/40] =?UTF-8?q?feat:=20splice=20plain=20activities=20ins?= =?UTF-8?q?ide=20a=20loop=20body,=20and=20add=20alter=20=E2=80=A6=20replac?= =?UTF-8?q?e=20body=20of=20(ako/mxcli#1693)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create or modify microflow` (mdl 1) and `alter microflow` refused any change of a loop body's shape — an activity inserted, dropped or moved — with "the Loop … changes inside its body; the splice does not edit inside a loop", and the advice was `alter … replace loop`, which re-mints every element ID in the loop. Maintainer decision on the issue: splice plain activities inside a body (option c), add an explicit `replace body` as the fallback (option a); do not replace the whole loop implicitly (option b). What changes - mfmutator: room-making, room checks and pins are scoped to a collection — the top level or one loop's body, in the loop box's coordinates. Insert after/before, replace and drop work on body activities, the body's first and last included (appendInLoop, prependInLoop, dropInLoop, replaceInLoop by several). An edited body is fitted back into its box (fitLoopTo): the box grows on its far side along the line the loop sits on, what is drawn past it moving along (and an enclosing loop growing in turn), and symmetrically across that line so the flows in and out stay level. PinInLoop pins a body's stated positions under mdl 1, as Pin does at top level (#1598), so a restated describe never comes back "moved" on its next run. A fragment that is not plain activities is refused in a body (inBodyFragment). - ReplaceLoopBody (new MicroflowMutator method): keeps the loop — $ID, position, iterator, list, flows and notes around it — and replaces only its contents with the body of the loop the builder made of the stored header restated with the new body; the box keeps its size when that fits and grows when it does not. Refused over MCP, like every removal. - create or modify: a loop restated with a changed body is paired with the stored one (lcsStatements) and its body diffed in place (inLoopBody, bodyGap): LCS over the body, plain activities only. A decision, loop, return, break/continue, raise error or error handler added, changed or taken out there, or a body node moved, is refused naming the innermost loop: "the Loop at (…) changes inside its body: a decision is added to it; inside a loop body the splice inserts, drops and replaces plain activities only … `alter microflow M.F { replace body of loop $It in $Items with begin … end; };`". An empty stored body is filled by replace body, which loses nothing there. - alter: `replace body of with begin … end;` (grammar, AST AlterFlowReplaceBody, visitor, executor replaceBody); the body is scope- checked like a fragment and may not redeclare a variable the flow has outside the loop. In-body operations are checked first (inBodyTarget, notPlainFragment) so a refusal names the real reason and replace body. check -p builds a replace-body fragment the same way exec does. Verification - Tests first on PedApp copies (flow_splice_loop_body_1693_pedapp_test.go): insert middle/first/last/two, drop middle/first/last, swap, replace by two, box growth, nested loop, every alter form, stated positions pinned, replace body (loop $ID kept, iterator kept, box kept when it fits, while loop, nested loop, empty body filled, describe round trip, three refusals), and the refusals that remain — each asserted for check and exec, nothing written. Only the changed activities' $IDs change; control: `replace loop` of the same change re-mints the whole body. Re-running each script is Unchanged with identical bytes. mfmutator unit tests for the geometry (splice_loop_test.go). - Revert proofs: with the production files reverted every new executor test fails with the reported refusal ("the Loop at (780, 200) changes inside its body"; "alter does not splice inside a loop body"); stubbing fitLoopTo, dropInLoop and body pins fails the matching mfmutator tests. - Real app (blank 11.13.0): baseline refuses the repro scripts; new binary splices them — insert: 43 element IDs kept, 0 gone, 6 added (the activity and its flow); alter insert after last, drop first; replace body keeps the loop's $ID (30 kept, the 20 body elements replaced); only the microflow's unit (and Base.mpr) written; every re-run Unchanged with a clean git status; describe → exec Unchanged; mx check 0 errors. - TestMxCheck_DoctypeScripts 02-microflow, 02b-nanoflow, 41-error-handling and the new 02d-microflow-splice-examples: pass. describe → exec over all 29 PedApp flows: Unchanged. mdl/roundtrip integration suite: pass (tests that used a plain insert in a loop as the refused change now add a decision). Docs: alter-microflow reference page (docs-site), quick reference, syntax help, write-microflows skill and pitfalls §11, bug-test and doctype scripts, finding. Co-Authored-By: Claude Opus 5.5 --- ...plain-activities-spliced-replace-body.json | 1 + .../skills/mendix/write-microflows/SKILL.md | 8 +- .../write-microflows/reference/pitfalls.md | 75 +- cmd/mxcli/cmd_fmt_upgrade_test.go | 2 +- cmd/mxcli/exec_preflight_script_flows_test.go | 4 +- cmd/mxcli/exec_preflight_verdict_test.go | 9 +- cmd/mxcli/script_set_test.go | 2 +- cmd/mxcli/syntax/features_microflow.go | 24 +- docs-site/src/SUMMARY.md | 1 + docs-site/src/reference/microflow/README.md | 3 +- .../reference/microflow/alter-microflow.md | 111 +++ docs/01-project/MDL_QUICK_REFERENCE.md | 5 +- .../bug-tests/splice-1693-loop-body.mdl | 110 +++ .../02d-microflow-splice-examples.mdl | 105 +++ mdl/ast/ast_alter_flow.go | 9 +- mdl/backend/mcp/microflow_mutator.go | 26 + mdl/backend/mfmutator/splice.go | 823 ++++++++++++++++-- mdl/backend/mfmutator/splice_loop_test.go | 343 ++++++++ mdl/backend/mfmutator/splice_test.go | 63 +- mdl/backend/microflow_mutation.go | 10 + mdl/executor/cmd_alter_flow.go | 143 ++- .../cmd_flow_loop_advice_pedapp_test.go | 86 +- .../cmd_flow_loop_body_edit_pedapp_test.go | 14 +- mdl/executor/cmd_flow_modify.go | 376 +++++--- mdl/executor/cmd_flow_modify_patch.go | 7 +- mdl/executor/flow_splice_loop_body.go | 162 ++++ .../flow_splice_loop_body_1693_pedapp_test.go | 731 ++++++++++++++++ mdl/executor/flow_verdict.go | 43 +- mdl/executor/flow_verdict_test.go | 10 +- mdl/executor/validate_flow_verdicts.go | 18 +- ...validate_flow_verdicts_1361_pedapp_test.go | 4 +- ...idate_flow_verdicts_chained_pedapp_test.go | 9 +- ..._flow_verdicts_script_flows_pedapp_test.go | 8 +- mdl/grammar/MDLParser.g4 | 7 + .../flow_commit_events_rerun_test.go | 11 +- mdl/roundtrip/flow_modify_test.go | 18 +- mdl/roundtrip/flow_splice_parity_test.go | 7 +- mdl/roundtrip/flow_verdict_agreement_test.go | 27 +- mdl/visitor/visitor_alter_flow.go | 4 +- 39 files changed, 3058 insertions(+), 361 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-25-loop-body-plain-activities-spliced-replace-body.json create mode 100644 docs-site/src/reference/microflow/alter-microflow.md create mode 100644 mdl-examples/bug-tests/splice-1693-loop-body.mdl create mode 100644 mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl create mode 100644 mdl/backend/mfmutator/splice_loop_test.go create mode 100644 mdl/executor/flow_splice_loop_body.go create mode 100644 mdl/executor/flow_splice_loop_body_1693_pedapp_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-loop-body-plain-activities-spliced-replace-body.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-loop-body-plain-activities-spliced-replace-body.json new file mode 100644 index 0000000000..09e46161fb --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-loop-body-plain-activities-spliced-replace-body.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-25", "symptom": "`create or modify microflow` (mdl 1) and `alter microflow` refused any change of a loop body's shape — an activity inserted, dropped or moved inside `loop … end loop` — with 'the Loop at (x, y) changes inside its body; the splice does not edit inside a loop', and named `alter … replace loop`, which re-minted every element ID in the loop", "cause": "A loop body's nodes are drawn in the loop box's coordinates, so the splice had no layout for them: graph.node() refused every body node and only an in-place one-for-one replace (#1106) was allowed; the diff paired a loop only when its body had the same shape (editableLoopBody)", "file": "mdl/backend/mfmutator/splice.go (spliceOnFlow/makeRoom scoped to a loop, appendInLoop, prependInLoop, dropInLoop, replaceInLoop, fitLoopTo, growLoop, ReplaceLoopBody); mdl/executor/cmd_flow_modify.go (inLoopBody, bodyGap, bodyRefusal); mdl/executor/cmd_alter_flow.go (inLoopRefusal, inBodyTarget, notPlainFragment); mdl/executor/flow_splice_loop_body.go (replaceBody)", "fix": "Shift/room/pin are scoped to a collection (the top level or one loop's body); an edited body is fitted back into its box, which grows on its far side along the line the loop sits on (from the side its incoming flow enters) with what is past it moving along, and symmetrically across it so the flows in and out stay level, recursing into an enclosing loop. The diff runs inLoopBody as an LCS over the body, plain activities only; a decision/loop/return/break/continue/raise/handler is refused naming the new `alter … replace body of `, which keeps the loop's $ID, position, iterator and list and rebuilds only its contents (the loop restated with its stored header is built, and its body taken)", "insight": "Three facts settled the design and are cheap to re-measure: body flows live in the unit's top-level Flows list, not the loop; an empty loop body passes mx check (0 errors on 11.13), so dropping a body's only activity needs no refusal — but mxcli stores an empty body without its Objects key, so a writer into it must add the list (marker 3); and `create` itself draws a tall nested loop over the parameter row, so a room check that counts parameters refuses layouts the builder makes. A body restated from describe states every @position, so body positions have to be pinned (PinInLoop) like top-level ones or the second run of a spliced script reads its own shifted nodes as 'moved'", "refs": ["ako/mxcli#1693", "ako/mxcli#1399", "mendixlabs/mxcli#1517", "ako/mxcli#1106", "mendixlabs/mxcli#1598"], "test": "TestSpliceLoopBody1693_* , TestAlterReplaceBody1693 (mdl/executor/flow_splice_loop_body_1693_pedapp_test.go); TestSpliceLoop_* (mdl/backend/mfmutator/splice_loop_test.go)"} diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index ca43bd4717..1cb5ab53ec 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -69,10 +69,10 @@ Choose the mode by who owns the microflow ([choose-edit-mode](../choose-edit-mod (targets from `describe microflow X with handles`). `create or modify` of `describe` output patches too: unchanged writes nothing; a statement (even one whose handler returns), guard clause, `return` value, `if` condition, header clause, parameter (added/retyped; removed only if unused) or stated `@position`/`@start` change is - patched in place (a move keeps the node's flows), as is a loop-body activity with new values ([an insert needing room past a stated `@position`](reference/pitfalls.md#13-inserting-where-the-script-states-every-position)). A redrawn - `@anchor`/`@curve`, other loop-body change, error handler or other `return` added/taken away rebuilds under - mdl 0 (`MDL-V1-REBUILD`: IDs renumbered, merges and curves lost) and is refused under `mdl 1;`. **To add, - remove or reorder in a loop body, `alter … replace` the whole loop** ([pitfalls](reference/pitfalls.md#11-changing-something-inside-a-loop-body)). **To rebuild deliberately, `drop` and `create` in ONE script** — the grants carry only within it ([pitfalls](reference/pitfalls.md#drop--create-is-still-a-new-document)). + patched in place (a move keeps the node's flows), as are plain activities added, removed, reordered or changed in a loop body ([an insert needing room past a stated `@position`](reference/pitfalls.md#13-inserting-where-the-script-states-every-position)). A redrawn + `@anchor`/`@curve`, a decision/loop/`return` added or changed inside a loop body, error handler or other `return` added/taken away rebuilds under + mdl 0 (`MDL-V1-REBUILD`: IDs renumbered, merges and curves lost) and is refused under `mdl 1;`. **To put a + decision or loop in a loop body, `alter … replace body of loop $X in $Xs with begin … end;`** — the loop keeps its `$ID` ([pitfalls](reference/pitfalls.md#11-changing-something-inside-a-loop-body)). **To rebuild deliberately, `drop` and `create` in ONE script** — the grants carry only within it ([pitfalls](reference/pitfalls.md#drop--create-is-still-a-new-document)). ## When to Use a Microflow vs a Nanoflow diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index 9c34b1408c..cb97808007 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -300,60 +300,67 @@ the same CE0117, and mxcli refuses the statement rather than writing it. ### 11. Changing Something Inside a Loop Body -**What is spliced** (both modes, ako/mxcli#1106): an activity in the body replaced by -one activity **of the same kind**, in place — a new expression in a `set`, a new -message in a `log`, `commit $Car` → `commit $Car without events`. Re-running a -`create or modify` whose loop body has the same statements in the same order, each -as stored or with new values, writes just those activities; the loop and everything -else keep their `$ID`s and positions. The loop's iterator is in scope. - -**Refusal** for anything else inside the body — a statement added, removed, -reordered or moved, one kind swapped for another, an `if` or `return` changed, a -custom error handler, a note or connector redrawn: `create or modify` under -`mdl 1;` — "the Loop at (x, y) changes inside its body; the splice does not edit -inside a loop"; `alter` aimed at an activity in the loop — "… is inside the body of -loop $Car in $Cars; alter does not splice inside a loop body but to replace one -activity by one of the same kind". A body node's coordinates are relative to the -loop box, so those changes would need the body laid out again. Both refusals spell -out the statement below. - -✅ **SPLICED** in place: +**What is spliced** (both modes): **plain activities** inside a loop body — one +inserted, dropped, moved (dropped and inserted again) or replaced by one or more +others (ako/mxcli#1693), and one replaced by one of the same kind in place +(ako/mxcli#1106). The new activities are laid out among the body's nodes; when the +body no longer fits, the loop box grows on its far side and what is drawn past the +loop moves along. Only the changed activities get new `$ID`s — the loop, the rest of +its body and everything outside it keep theirs. The loop's iterator is in scope, and +a nested loop's body is edited the same way. `create or modify` does this for a body +whose statements change; `alter` for an activity of the body addressed by its handle. + +**Refusal** for a change of the body's *shape* — a decision, a nested loop, a +`return`, `break`/`continue`, a `raise error` or an activity with a custom error +handler added, changed or taken out inside the body, or a body node drawn elsewhere: +`create or modify` under `mdl 1;` — "the Loop at (x, y) changes inside its body: a +decision is added to it; inside a loop body the splice inserts, drops and replaces +plain activities only …"; `alter` aimed at an activity in the loop — "… is inside the +body of loop $Car in $Cars, and the fragment holds a decision …". Both refusals spell +out the statement below, addressed to the innermost loop around the change. + +✅ **SPLICED** — a plain activity added to the body: ```mdl mdl 1; alter microflow MyModule.ACT_SaveAll { - replace commit $Car with begin commit $Car without events; end; + insert after commit $Car begin log info node 'App' 'saved'; end; }; ``` -❌ **REFUSED** (either way) — the body changes shape: +❌ **REFUSED** (either way) — a decision changes the body's shape: ```mdl mdl 1; alter microflow MyModule.ACT_SaveAll { - insert after commit $Car begin log info node 'App' 'saved'; end; + insert after commit $Car begin if $Car/Price > 0 then log info node 'App' 'priced'; end if; end; }; ``` -✅ **CORRECT** — replace the loop: +✅ **CORRECT** — replace the loop's body: ```mdl mdl 1; alter microflow MyModule.ACT_SaveAll { - replace loop $Car in $Cars with begin - loop $Car in $Cars - begin - commit $Car; - log info node 'App' 'saved'; - end loop; + replace body of loop $Car in $Cars with begin + commit $Car; + if $Car/Price > 0 then + log info node 'App' 'priced'; + end if; end; }; ``` -Everything outside the loop keeps its `$ID`s, positions and flows; the loop and its -body are rebuilt (new IDs, drawn by mxcli). A `while` loop is the same: -`replace while $N < 3 with begin while $N < 3 begin … end while; end;`. For a loop -nested in another loop, replace the outer one. +`replace body of` keeps the loop itself — its `$ID`, position, iterator and list, the +flows into and out of it and the notes on it — and rebuilds only what is inside it +(new IDs, laid out by mxcli). The box keeps its size when the new body fits, and grows +when it does not. A `while` loop is addressed by its handle the same way — +`replace body of while $N < 3 with begin … end;` — and so is a loop nested in another +loop. The new body is scope-checked like any fragment: a variable it reads must be the +iterator, declared in the body, or declared before the loop; one it declares must not +be declared outside the loop. (`replace loop … with begin loop … end loop; end;` still +works, and rebuilds the loop as well — its `$ID` and position included.) -The replaced loop's body is scope-checked like any fragment: a variable it reads must -be the iterator, declared in the body, or declared before the loop. +Under `mdl 1;` a body that states its nodes' `@position` (a `describe` restated) is +pinned like the top level (§13): an insert that would have to move one of them, or +grow the loop past a node the script draws where it is, is refused. ### 12. One Alter Block Is One Change diff --git a/cmd/mxcli/cmd_fmt_upgrade_test.go b/cmd/mxcli/cmd_fmt_upgrade_test.go index 50774e456c..618137047f 100644 --- a/cmd/mxcli/cmd_fmt_upgrade_test.go +++ b/cmd/mxcli/cmd_fmt_upgrade_test.go @@ -288,7 +288,7 @@ func TestFmtUpgrade_HeaderDeclinedWhereExecWouldRefuse(t *testing.T) { mpr := filepath.Join(dir, "PedApp.mpr") const stub = "create or modify microflow MyFirstModule.Grow ($Items: List of System.User)\nreturns String as $Out\nbegin\n declare $Out String = '';\n loop $U in $Items\n begin\n set $Out = $Out + ',';\n end loop;\n return $Out;\nend;\n" const grown = "create or modify microflow MyFirstModule.Grow ($Items: List of System.User)\nreturns String as $Out\nbegin\n declare $Out String = '';\n" + - " loop $U in $Items\n begin\n set $Out = $Out + ',';\n set $Out = $Out + $U/Name;\n end loop;\n return $Out;\nend;\n" + " loop $U in $Items\n begin\n set $Out = $Out + ',';\n if $U/Name != empty then\n set $Out = $Out + $U/Name;\n end if;\n end loop;\n return $Out;\nend;\n" exe := executor.New(io.Discard) exe.SetBackendFactory(newBackendFactory()) prog, errs := visitor.Build(fmt.Sprintf("connect local '%s';\n%s", visitor.QuoteString(mpr), stub)) diff --git a/cmd/mxcli/exec_preflight_script_flows_test.go b/cmd/mxcli/exec_preflight_script_flows_test.go index 911bb33ccf..f6b02d0325 100644 --- a/cmd/mxcli/exec_preflight_script_flows_test.go +++ b/cmd/mxcli/exec_preflight_script_flows_test.go @@ -86,7 +86,9 @@ begin loop $It in $Items begin commit $It; - log info node 'X' 'committed'; + if $It/Subject = empty then + log info node 'X' 'no subject'; + end if; end loop; log info node 'X' 'done'; end;`) diff --git a/cmd/mxcli/exec_preflight_verdict_test.go b/cmd/mxcli/exec_preflight_verdict_test.go index 167049d9be..ab0223fef8 100644 --- a/cmd/mxcli/exec_preflight_verdict_test.go +++ b/cmd/mxcli/exec_preflight_verdict_test.go @@ -62,10 +62,11 @@ end;` t.Fatalf("setup: %v", err) } - // A statement added inside the loop body: a change of the body's shape, - // which the splice refuses (an in-place edit is spliced, ako/mxcli#1106). + // A decision added inside the loop body: a change of the body's shape, + // which the splice refuses (an in-place edit is spliced, ako/mxcli#1106, + // and so is a plain activity added, ako/mxcli#1693). changed := strings.Replace(strings.Replace(flow, "create microflow", "create or modify microflow", 1), - "commit $It;", "commit $It;\n log info node 'X' 'committed';", 1) + "commit $It;", "commit $It;\n if $It/Subject = empty then log info node 'X' 'committed'; end if;", 1) script := "mdl 1;\ncreate microflow MyFirstModule.Before () begin end;\n" + strings.TrimPrefix(changed, "mdl 1;\n") + "\ncreate microflow MyFirstModule.After () begin end;\n" prog, errs := visitor.Build(script) @@ -78,7 +79,7 @@ end;` if refusal == "" || !strings.Contains(refusal, "Nothing was written") { t.Fatalf("want the pre-flight to refuse the script, got %q\n%s", refusal, out.String()) } - if !strings.Contains(out.String(), "replace loop") { + if !strings.Contains(out.String(), "replace body of loop $It in $Items") { t.Errorf("the refusal's reason is not printed:\n%s", out.String()) } diff --git a/cmd/mxcli/script_set_test.go b/cmd/mxcli/script_set_test.go index fe7f827338..287ffcaf72 100644 --- a/cmd/mxcli/script_set_test.go +++ b/cmd/mxcli/script_set_test.go @@ -144,7 +144,7 @@ func TestFmtUpgrade_ScriptSetDecidesStubThenRealHeaderTogether(t *testing.T) { // The stored real flow: a loop body the stub's statement would change, // which the splice cannot do, so the stub is refused under mdl 1. const real = "create or modify microflow MyFirstModule.Grow ($Items: List of System.User)\nreturns String as $Out\nbegin\n declare $Out String = '';\n" + - " loop $U in $Items\n begin\n set $Out = $Out + ',';\n set $Out = $Out + $U/Name;\n end loop;\n return $Out;\nend;\n" + " loop $U in $Items\n begin\n set $Out = $Out + ',';\n if $U/Name != empty then\n set $Out = $Out + $U/Name;\n end if;\n end loop;\n return $Out;\nend;\n" const stub = "create or modify microflow MyFirstModule.Grow ($Items: List of System.User)\nreturns String as $Out\nbegin\n declare $Out String = '';\n loop $U in $Items\n begin\n set $Out = $Out + ',';\n end loop;\n return $Out;\nend;\n" exe := executor.New(io.Discard) exe.SetBackendFactory(newBackendFactory()) diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index ea0f133052..e4f8b5c533 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -602,14 +602,16 @@ func init() { Register(SyntaxFeature{ Path: "microflow.alter", - Summary: "Patch a stored microflow or nanoflow: insert, replace or drop activities in place", + Summary: "Patch a stored microflow or nanoflow: insert, replace or drop activities in place, or replace a loop's body", Keywords: []string{ "alter microflow", "alter nanoflow", "insert after", "insert before", "replace", "drop activity", "patch microflow", "splice", "handle", + "replace body", "loop body", "replace body of", }, Syntax: "ALTER MICROFLOW|NANOFLOW Module.Name {\n" + " INSERT AFTER|BEFORE BEGIN END;\n" + " REPLACE WITH BEGIN END;\n" + + " REPLACE BODY OF WITH BEGIN END;\n" + " DROP ;\n" + "};\n\n" + "-- addresses one activity by content, as `describe microflow ... with handles` prints it:\n" + @@ -620,10 +622,16 @@ func init() { "-- stored flow before any operation runs; an ambiguous or unknown target is an error.\n" + "-- Only the new activities, the rewired flows and the objects moved to make room change;\n" + "-- every other element keeps its $ID, position and curve.\n" + + "-- Inside a loop body, plain activities are inserted, replaced and dropped the same way,\n" + + "-- the loop box growing when the body no longer fits (ako/mxcli#1693).\n" + + "-- REPLACE BODY OF keeps the loop itself (its $ID, position, iterator and list)\n" + + "-- and rebuilds only what is inside it: the way to put a decision, a nested loop or a\n" + + "-- return in a loop body. is the loop's handle: loop $It in $Items, while $N < 3.\n" + "-- Refused: insert after a decision, insert before an activity several flows enter,\n" + - "-- drop/replace of a decision or of an activity with an error handler, anything inside\n" + - "-- a loop body, a fragment that returns, and a fragment variable that clashes with one\n" + - "-- the flow has or reads one not declared on the path. Over --mcp only insert is supported.\n" + + "-- drop/replace of a decision or of an activity with an error handler, a decision, loop,\n" + + "-- return or error handler put in a loop body (use REPLACE BODY OF), a fragment that\n" + + "-- returns, and a fragment variable that clashes with one the flow has or reads one not\n" + + "-- declared on the path. Over --mcp only insert is supported, and not inside a loop body.\n" + "-- A fragment is imperative flow, written as the body of `create microflow` is: BEGIN … END.\n" + "-- The brace fragment `{ }` is the deprecated spelling MDL-DEPR074.", Example: "alter microflow FeedbackModule.VAL_Feedback {\n" + @@ -633,6 +641,14 @@ func init() { " log warning node 'Feedback' 'Email rejected';\n" + " end;\n" + " drop log debug node 'Feedback' *;\n" + + "};\n\n" + + "alter microflow Shop.ACT_ProcessLines {\n" + + " replace body of loop $Line in $Lines with begin\n" + + " if $Line/Quantity > 0 then\n" + + " change $Line (Processed = true);\n" + + " end if;\n" + + " commit $Line;\n" + + " end;\n" + "};", SeeAlso: []string{"microflow"}, }) diff --git a/docs-site/src/SUMMARY.md b/docs-site/src/SUMMARY.md index 4856a44981..2196886aed 100644 --- a/docs-site/src/SUMMARY.md +++ b/docs-site/src/SUMMARY.md @@ -248,6 +248,7 @@ - [Microflow Statements](reference/microflow/README.md) - [CREATE MICROFLOW](reference/microflow/create-microflow.md) - [CREATE NANOFLOW](reference/microflow/create-nanoflow.md) + - [ALTER MICROFLOW / NANOFLOW](reference/microflow/alter-microflow.md) - [DROP MICROFLOW / NANOFLOW](reference/microflow/drop-microflow.md) - [CREATE JAVA ACTION](reference/microflow/create-java-action.md) - [CREATE JAVASCRIPT ACTION](reference/microflow/create-javascript-action.md) diff --git a/docs-site/src/reference/microflow/README.md b/docs-site/src/reference/microflow/README.md index a0f7f79ef6..cb864b322c 100644 --- a/docs-site/src/reference/microflow/README.md +++ b/docs-site/src/reference/microflow/README.md @@ -1,6 +1,6 @@ # Microflow Statements -Statements for creating and dropping microflows, nanoflows, and Java actions. +Statements for creating, altering and dropping microflows, nanoflows, and Java actions. Microflows are the primary mechanism for implementing business logic in Mendix applications. They execute on the server and have access to the full range of activities including database operations, integrations, and security-sensitive actions. @@ -10,5 +10,6 @@ Nanoflows share the same syntax as microflows but execute on the client (browser |-----------|-------------| | [CREATE MICROFLOW](create-microflow.md) | Create a microflow with activities and control flow | | [CREATE NANOFLOW](create-nanoflow.md) | Create a client-side nanoflow | +| [ALTER MICROFLOW / NANOFLOW](alter-microflow.md) | Patch a stored flow in place: insert, replace or drop activities, or replace a loop's body | | [CREATE JAVA ACTION](create-java-action.md) | Create a Java action stub with inline code | | [DROP MICROFLOW / NANOFLOW](drop-microflow.md) | Remove a microflow or nanoflow | diff --git a/docs-site/src/reference/microflow/alter-microflow.md b/docs-site/src/reference/microflow/alter-microflow.md new file mode 100644 index 0000000000..f6510c54c7 --- /dev/null +++ b/docs-site/src/reference/microflow/alter-microflow.md @@ -0,0 +1,111 @@ +# ALTER MICROFLOW / NANOFLOW + +## Synopsis + +```sql +mdl 1; +ALTER MICROFLOW module.Name { + INSERT AFTER | BEFORE BEGIN END; + REPLACE WITH BEGIN END; + REPLACE BODY OF WITH BEGIN END; + DROP ; +}; + +ALTER NANOFLOW module.Name { … }; +``` + +## Description + +Patches a stored microflow or nanoflow in place: the operations are spliced +into the stored graph rather than rebuilding it. Only the new activities, the +flows rewired around them and the nodes moved to make room change; every other +element keeps its element ID (`$ID`), position and curve, so Studio Pro shows +the change and nothing else. + +A target addresses one stored activity by content, as +`DESCRIBE MICROFLOW module.Name WITH HANDLES` prints it above each activity: +`$Var` for the activity that outputs `$Var`, `'Caption'` for a decision or an +activity with a custom caption, or a statement pattern in which `*` matches any +run of tokens — followed by `@n` when several match. Every target is resolved +against the flow as stored before any operation runs. + +A fragment is written exactly as the same statements are in `CREATE +MICROFLOW`, between `BEGIN` and `END`. + +### Inside a loop body + +Inside a loop body, plain activities are inserted, replaced and dropped the +same way — the body's first and last activity included. The new activities are +laid out among the body's nodes; when the body no longer fits, the loop box +grows on its far side, and what is drawn past the loop moves along. The loop's +iterator is in scope. + +What would change the body's *shape* — a decision, a nested loop, a `RETURN`, +`BREAK` or `CONTINUE`, a `RAISE ERROR` or an activity with a custom error +handler put in, changed or taken out — is refused there, and the refusal names +`REPLACE BODY OF`. + +### REPLACE BODY OF + +`REPLACE BODY OF ` keeps the stored loop itself — its element ID, its +position, its iterator and list, the flows into and out of it and the notes on +it — and replaces only what is inside it. The new body is laid out as `CREATE +MICROFLOW` lays out a loop body; the loop box keeps its size when the new body +fits it, and grows on its far side when it does not. + +`` is the loop's handle: `loop $It in $Items`, or `while $N < 3` for a +while loop, at any depth of nesting. The new body is scope-checked like any +fragment: it may read the iterator, what it declares itself and what is +declared before the loop, and it may not declare a variable the flow already +has outside the loop. + +`REPLACE WITH BEGIN LOOP … END LOOP; END;` also works, and +rebuilds the loop as well — its element ID and position included. + +### CREATE OR MODIFY + +`CREATE OR MODIFY MICROFLOW` of a flow that exists derives the same operations +from the difference between the script and the stored flow. A plain activity +added, removed, reordered or changed inside a loop body is spliced; under +`mdl 1;` a change the splice cannot make is refused with the operation that +makes it — for a decision inside a loop body, `REPLACE BODY OF` that loop. + +## Examples + +Insert, replace and drop at top level: + +```sql +mdl 1; +ALTER MICROFLOW FeedbackModule.VAL_Feedback { + INSERT AFTER $IsValidEmail BEGIN LOG INFO NODE 'Feedback' 'Email checked'; END; + REPLACE COMMIT $Order WITH BEGIN COMMIT $Order WITH EVENTS; END; + DROP LOG DEBUG NODE 'Feedback' *; +}; +``` + +Add an activity inside a loop body: + +```sql +mdl 1; +ALTER MICROFLOW Shop.ACT_ProcessLines { + INSERT AFTER COMMIT $Line BEGIN LOG INFO NODE 'Shop' 'line saved'; END; +}; +``` + +Put a decision inside a loop body, keeping the loop: + +```sql +mdl 1; +ALTER MICROFLOW Shop.ACT_ProcessLines { + REPLACE BODY OF LOOP $Line IN $Lines WITH BEGIN + IF $Line/Quantity > 0 THEN + CHANGE $Line (Processed = true); + END IF; + COMMIT $Line; + END; +}; +``` + +## See Also + +[CREATE MICROFLOW](create-microflow.md), [DESCRIBE MICROFLOW](/reference/query/describe-microflow.md) diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index b7d547672b..d40855b389 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -510,8 +510,9 @@ rather than updating the first. | Describe microflow | `describe microflow Module.Name;` | Full MDL with activities | | Describe microflow (normalized) | `describe microflow Module.Name normalized;` | Folds crossed branches into one condition instead of flattening them. Opt-in: the output re-executes to an equivalent graph with fewer nodes and a different layout | | Describe microflow (with handles) | `describe microflow Module.Name with handles;` | Prints `-- handle: ` above each activity: its content address for `alter microflow` — output `$Var`, `'Caption'`, or a statement pattern with `*` wildcards (anchored at both ends), plus `@n` when several match. Comments only; cannot be combined with `normalized` | -| Insert into a stored microflow | `alter microflow Module.Name { insert after begin end; };` | Also `insert before`, and `alter nanoflow`. A graph splice into the stored flow, not a rebuild: only the new activities, the two rewired flows and the objects moved to make room change; every other element keeps its `$ID`, position and curve. `` is a handle from `describe … with handles`, resolved before any operation runs. Refused: after a decision, before an activity several flows enter, inside a loop body, a fragment whose every path returns (a guard clause — `if … then return …; end if;` — is inserted: each return is a new end event, refused where its branch has no free room or would cross a stored flow), a variable the flow already has or one not declared on the path. The fragment is `begin … end` like a microflow body; `{ … }` is the deprecated spelling MDL-DEPR074 | -| Replace or drop an activity | `alter microflow Module.Name { replace with begin end; drop ; };` | Flows into the activity are re-pointed at the replacement (or at its successor, for `drop`). Refused for a decision, an end event, an activity with an error handler, and an activity whose output variable is still read. Over `--mcp` only `insert` is supported | +| Insert into a stored microflow | `alter microflow Module.Name { insert after begin end; };` | Also `insert before`, and `alter nanoflow`. A graph splice into the stored flow, not a rebuild: only the new activities, the two rewired flows and the objects moved to make room change; every other element keeps its `$ID`, position and curve. `` is a handle from `describe … with handles`, resolved before any operation runs. Inside a loop body, plain activities are inserted the same way, the loop box growing on its far side when the body no longer fits (ako/mxcli#1693); a fragment with a decision, a loop, a return, break/continue or an error handler is refused there, naming `replace body of`. Refused: after a decision, before an activity several flows enter, a fragment whose every path returns (a guard clause — `if … then return …; end if;` — is inserted: each return is a new end event, refused where its branch has no free room or would cross a stored flow), a variable the flow already has or one not declared on the path. The fragment is `begin … end` like a microflow body; `{ … }` is the deprecated spelling MDL-DEPR074 | +| Replace or drop an activity | `alter microflow Module.Name { replace with begin end; drop ; };` | Flows into the activity are re-pointed at the replacement (or at its successor, for `drop`). Works on an activity inside a loop body too (the body's first or last included). Refused for a decision, an end event, an activity with an error handler, and an activity whose output variable is still read. Over `--mcp` only `insert` is supported, and not inside a loop body | +| Replace a loop's body | `alter microflow Module.Name { replace body of loop $It in $Items with begin end; };` | Keeps the loop itself — its `$ID`, position, iterator, list and the flows and notes around it — and rebuilds only what is inside it (ako/mxcli#1693): the change a splice inside a body does not make (a decision, a nested loop, a return). The box keeps its size when the new body fits and grows on its far side when it does not. Address the loop by its handle (`loop $It in $Items`, `while $N < 3`), at any depth. The body is scope-checked like a fragment: it reads the iterator, what it declares and what is declared before the loop, and declares nothing the flow has outside it | | Describe nanoflow | `describe nanoflow Module.Name;` | Full MDL with activities | | Rename microflow | `rename microflow Module.Old to New;` | Updates all references | | Rename nanoflow | `rename nanoflow Module.Old to New;` | Updates all references | diff --git a/mdl-examples/bug-tests/splice-1693-loop-body.mdl b/mdl-examples/bug-tests/splice-1693-loop-body.mdl new file mode 100644 index 0000000000..407fcfe1db --- /dev/null +++ b/mdl-examples/bug-tests/splice-1693-loop-body.mdl @@ -0,0 +1,110 @@ +-- ============================================================================ +-- ako/mxcli#1693 (mendixlabs/mxcli#1517) — plain activities spliced inside a +-- loop body, and `alter … replace body of` the loop +-- ============================================================================ +-- +-- `create or modify microflow` and `alter microflow` refused any change of a +-- loop body's shape — an activity inserted, dropped or moved — with +-- +-- this change cannot be spliced into the stored flow: the Loop at (…) +-- changes inside its body; the splice does not edit inside a loop … +-- +-- and named `alter … replace loop`, which re-minted every identity in the +-- loop. The splice now inserts, drops and replaces plain activities inside a +-- body, laid out in the loop box's coordinates (the box grows on its far side +-- when the body no longer fits, and what is drawn past it moves along); only +-- the changed activities get new element IDs. What changes the body's shape — +-- a decision, a nested loop, a return — is still refused, and the refusal +-- names `alter … replace body of `, which keeps the loop itself (its +-- element ID, position, iterator and list) and rebuilds only what is inside. +-- +-- To reproduce: exec this file against any project. Expected: +-- Created microflow: BugTest1693.LoopFlow (first run) +-- Modified microflow: BugTest1693.LoopFlow (spliced: 1 inserted) +-- Modified microflow: BugTest1693.LoopFlow (spliced: 1 inserted, 1 dropped) +-- Altered microflow BugTest1693.LoopFlow (three times) +-- Before the fix the first `create or modify` was refused "the Loop … changes +-- inside its body", and so was every alter below (`insert after commit $It: +-- commit $It is inside the body of loop $It in $Items; alter does not splice +-- inside a loop body …`). mx check: 0 errors. +-- +-- Still refused, by check and exec alike (try it): adding an `if` inside the +-- loop in a `create or modify` — +-- the Loop at (…) changes inside its body: a decision is added to it; inside +-- a loop body the splice inserts, drops and replaces plain activities only … +-- Replace the loop's body instead, … `alter microflow BugTest1693.LoopFlow +-- { replace body of loop $It in $Items with begin … end; };` +-- +-- The file can be run again: LoopFlow is dropped and made afresh, and every +-- statement after the create reports what it did the first time. + +mdl 1; + +create module BugTest1693; + +create or modify persistent entity BugTest1693.Item ( + Name: String(100), + Count: Integer +); + +drop microflow if exists BugTest1693.LoopFlow; + +create microflow BugTest1693.LoopFlow ($Items: List of BugTest1693.Item) +begin + declare $S String = ''; + loop $It in $Items + begin + set $S = $S + 'a'; + change $It (Count = 1); + end loop; + log info node 'BugTest1693' $S; +end; + +-- An activity inserted in the body: spliced, the loop box grows. +create or modify microflow BugTest1693.LoopFlow ($Items: List of BugTest1693.Item) +begin + declare $S String = ''; + loop $It in $Items + begin + set $S = $S + 'a'; + log info node 'BugTest1693' 'in the loop'; + change $It (Count = 1); + end loop; + log info node 'BugTest1693' $S; +end; + +-- Two activities swapped: the moved one is dropped and inserted again. +create or modify microflow BugTest1693.LoopFlow ($Items: List of BugTest1693.Item) +begin + declare $S String = ''; + loop $It in $Items + begin + log info node 'BugTest1693' 'in the loop'; + set $S = $S + 'a'; + change $It (Count = 1); + end loop; + log info node 'BugTest1693' $S; +end; + +-- The same from alter: insert after the body's last activity, drop one. +alter microflow BugTest1693.LoopFlow { + insert after change $It (Count = 1) begin commit $It; end; + drop log node 'BugTest1693' 'in the loop'; +}; + +-- A decision in the body is the one change the splice does not make there: +-- replace the loop's body, keeping the loop. +alter microflow BugTest1693.LoopFlow { + replace body of loop $It in $Items with begin + set $S = $S + 'a'; + if $It/Count = empty then + change $It (Count = 0); + end if; + commit $It; + end; +}; + +-- One activity of the new body changed in place (ako/mxcli#1106). +alter microflow BugTest1693.LoopFlow { + replace commit $It with begin commit $It without events; end; +}; diff --git a/mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl b/mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl new file mode 100644 index 0000000000..57a0ec9783 --- /dev/null +++ b/mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl @@ -0,0 +1,105 @@ +mdl 1; +-- ============================================================================ +-- Microflow Splice Examples — editing a stored flow in place +-- ============================================================================ +-- +-- `create or modify microflow` of a flow that exists, and `alter microflow`, +-- splice the change into the stored flow rather than rebuilding it: only the +-- elements the change touches get new element IDs, and a re-run of the same +-- script writes nothing. +-- +-- Patterns: +-- 1. SP_LoopBody — plain activities inserted, moved and dropped inside +-- a loop body, by `create or modify` and by `alter` +-- (ako/mxcli#1693) +-- 2. SP_LoopBody — `alter … replace body of `: what is inside the +-- loop is rebuilt, the loop itself (its element ID, +-- position, iterator and list) stays (ako/mxcli#1693) +-- 3. SP_WhileBody — the same for a while loop, addressed by its handle +-- +-- Usage: +-- ./bin/mxcli check mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl +-- ./bin/mxcli -p app.mpr exec mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl +-- ============================================================================ + +create module SpliceExamples; + +create persistent entity SpliceExamples.Line ( + Description: String(200), + Quantity: Integer, + Processed: Boolean default false +); + +-- ============================================================================ +-- 1. Plain activities inside a loop body +-- ============================================================================ + +create microflow SpliceExamples.SP_LoopBody ($Lines: List of SpliceExamples.Line) +begin + declare $Count Integer = 0; + loop $Line in $Lines + begin + set $Count = $Count + 1; + change $Line (Processed = true); + end loop; + log info node 'Splice' 'processed {1}' with ({1} = toString($Count)); +end; + +-- An activity added in the body is spliced in; the loop box grows to fit it. +create or modify microflow SpliceExamples.SP_LoopBody ($Lines: List of SpliceExamples.Line) +begin + declare $Count Integer = 0; + loop $Line in $Lines + begin + set $Count = $Count + 1; + log info node 'Splice' 'line {1}' with ({1} = $Line/Description); + change $Line (Processed = true); + end loop; + log info node 'Splice' 'processed {1}' with ({1} = toString($Count)); +end; + +-- The same from alter, addressing the activities of the body by their handles +-- (`describe microflow … with handles` prints them). +alter microflow SpliceExamples.SP_LoopBody { + insert after change $Line (Processed = true) begin commit $Line; end; + drop log node 'Splice' 'line {1}' *; +}; + +-- ============================================================================ +-- 2. replace body of a loop +-- ============================================================================ +-- +-- A decision, a nested loop or a return inside a body changes the body's +-- shape, which the splice does not make in place. `replace body of` states the +-- whole new body; the loop keeps its element ID, position, iterator and list. + +alter microflow SpliceExamples.SP_LoopBody { + replace body of loop $Line in $Lines with begin + set $Count = $Count + 1; + if $Line/Quantity > 0 then + change $Line (Processed = true); + end if; + commit $Line; + end; +}; + +-- ============================================================================ +-- 3. A while loop, addressed by its handle +-- ============================================================================ + +create microflow SpliceExamples.SP_WhileBody () +begin + declare $N Integer = 0; + while $N < 3 + begin + set $N = $N + 1; + end while; + log info node 'Splice' 'done'; +end; + +alter microflow SpliceExamples.SP_WhileBody { + replace body of while $N < 3 with begin + set $N = $N + 1; + log info node 'Splice' 'step {1}' with ({1} = toString($N)); + end; +}; diff --git a/mdl/ast/ast_alter_flow.go b/mdl/ast/ast_alter_flow.go index 20eae660c4..b583837c8d 100644 --- a/mdl/ast/ast_alter_flow.go +++ b/mdl/ast/ast_alter_flow.go @@ -9,8 +9,9 @@ package ast // AlterFlowStmt represents: // // alter microflow|nanoflow Module.Name { -// insert after|before { } -// replace with { } +// insert after|before begin end; +// replace with begin end; +// replace body of with begin end; // drop ; // } // @@ -40,6 +41,10 @@ const ( AlterFlowInsertBefore AlterFlowOpKind = "insert before" AlterFlowReplace AlterFlowOpKind = "replace" AlterFlowDrop AlterFlowOpKind = "drop" + // AlterFlowReplaceBody replaces what is inside a stored loop and keeps + // the loop itself — its $ID, position, iterator and list + // (ako/mxcli#1693). Target addresses the loop; Body is its new body. + AlterFlowReplaceBody AlterFlowOpKind = "replace body of" ) // AlterFlowOperation is one operation of an AlterFlowStmt. diff --git a/mdl/backend/mcp/microflow_mutator.go b/mdl/backend/mcp/microflow_mutator.go index b0142eab8a..bd42c13d0b 100644 --- a/mdl/backend/mcp/microflow_mutator.go +++ b/mdl/backend/mcp/microflow_mutator.go @@ -79,6 +79,32 @@ func (m *mcpFlowMutator) Drop(model.ID) error { return fmt.Errorf("drop is not supported by the MCP backend yet: Studio Pro does not roll back a removal when an update fails; run without --mcp to alter %s in the .mpr", m.qn) } +// ReplaceLoopBody is refused like a replace: it removes the stored body. +func (m *mcpFlowMutator) ReplaceLoopBody(model.ID, *backend.MicroflowFragment) error { + return fmt.Errorf("replace body is not supported by the MCP backend yet: Studio Pro does not roll back a removal when an update fails; run without --mcp to alter %s in the .mpr", m.qn) +} + +// InsertAfter and InsertBefore inside a loop body are refused: the patch Save +// sends addresses top-level objects only, so neither the objects added to the +// body nor a loop box grown to fit them would reach Studio Pro. +func (m *mcpFlowMutator) InsertAfter(target model.ID, frag *backend.MicroflowFragment) error { + if m.InLoopBody(target) { + return m.inLoopRefusal() + } + return m.Mutator.InsertAfter(target, frag) +} + +func (m *mcpFlowMutator) InsertBefore(target model.ID, frag *backend.MicroflowFragment) error { + if m.InLoopBody(target) { + return m.inLoopRefusal() + } + return m.Mutator.InsertBefore(target, frag) +} + +func (m *mcpFlowMutator) inLoopRefusal() error { + return fmt.Errorf("inserting inside a loop body is not supported by the MCP backend yet: the update it sends addresses top-level objects only; run without --mcp to alter %s in the .mpr", m.qn) +} + func (m *mcpFlowMutator) RemoveNotes(model.ID) error { return fmt.Errorf("removing notes is not supported by the MCP backend yet: Studio Pro does not roll back a removal when an update fails; run without --mcp to alter %s in the .mpr", m.qn) } diff --git a/mdl/backend/mfmutator/splice.go b/mdl/backend/mfmutator/splice.go index 5bf9bf0af8..f7766de7e1 100644 --- a/mdl/backend/mfmutator/splice.go +++ b/mdl/backend/mfmutator/splice.go @@ -26,9 +26,15 @@ package mfmutator // after a decision (which branch?), before an activity several flows enter // (which path?), a drop of an activity with an error handler, or its replace // by anything but one activity handling errors the same way (the handler would -// be orphaned; mendixlabs/mxcli#1489), and — for now — anything inside a loop -// body but an activity replaced by one of the same kind, in place, since a -// body node's coordinates are relative to the loop box (ako/mxcli#1106). +// be orphaned; mendixlabs/mxcli#1489). +// +// Inside a loop body the splice inserts, drops and replaces plain activities +// (ako/mxcli#1693): a body node's coordinates are relative to the loop box, so +// room is made among the body's own nodes, and the box grows when the body no +// longer fits (growLoop) — with what is drawn past it moving along, as for an +// insert at top level. Anything else in a body — a decision, a nested loop, a +// return — is a change of the body's shape, which ReplaceLoopBody makes by +// rebuilding the body inside the stored loop. import ( "bytes" @@ -72,6 +78,9 @@ type Mutator struct { removed map[string]bool // pinned are the points the script states a node is drawn at (Pin). pinned map[point]bool + // pinnedIn are the same for the nodes of a loop body, by the loop's $ID, + // in the loop box's coordinates (PinInLoop). + pinnedIn map[string]map[point]bool } var _ backend.MicroflowMutator = (*Mutator)(nil) @@ -226,21 +235,32 @@ func (g *graph) annotationFlows(id string) []flowRef { return out } +// node returns the stored top-level node id. A node inside a loop body is +// refused: the operations that call it set what a node at top level is (where +// it is drawn, a decision's condition, an end event's value or notes), and +// inside a body they would have to be laid out against the loop box. func (g *graph) node(id model.ID) (*node, error) { - n, ok := g.nodes[string(id)] - if !ok { - return nil, fmt.Errorf("activity %s is not in the stored flow (was it dropped by an earlier operation?)", id) + n, err := g.anyNode(id) + if err != nil { + return nil, err } if n.loop != "" { - // create or modify does not edit inside a loop either, so the - // advice is the one operation that makes the change. - return nil, fmt.Errorf("%s is inside a loop body; alter does not splice inside a loop yet — "+ - "replace the whole loop instead: `replace loop … with begin loop … begin … end loop; end;`, "+ + return nil, fmt.Errorf("%s is inside a loop body, where the splice inserts, drops and replaces plain "+ + "activities only; replace the loop's body instead: `replace body of with begin … end;`, "+ "addressing the loop by its handle from `describe … with handles`", describeNode(n)) } return n, nil } +// anyNode returns the stored node id, at top level or inside a loop body. +func (g *graph) anyNode(id model.ID) (*node, error) { + n, ok := g.nodes[string(id)] + if !ok { + return nil, fmt.Errorf("activity %s is not in the stored flow (was it dropped by an earlier operation?)", id) + } + return n, nil +} + // --------------------------------------------------------------------------- // Operations // --------------------------------------------------------------------------- @@ -248,12 +268,19 @@ func (g *graph) node(id model.ID) (*node, error) { // InsertAfter splices frag onto the flow leaving target. func (m *Mutator) InsertAfter(target model.ID, frag *backend.MicroflowFragment) error { g := m.graph() - x, err := g.node(target) + x, err := g.anyNode(target) if err != nil { return err } + if err := inBodyFragment(x, frag); err != nil { + return err + } normal, _ := g.outgoing(x.id) switch { + case len(normal) == 0 && x.loop != "": + // The last node of a loop body: the iteration ends there, so the + // fragment goes after it and ends the iteration instead. + return m.appendInLoop(g, x, frag) case len(normal) == 0: return fmt.Errorf("nothing follows %s: it ends the flow; insert before it instead", describeNode(x)) case len(normal) > 1: @@ -266,12 +293,19 @@ func (m *Mutator) InsertAfter(target model.ID, frag *backend.MicroflowFragment) // InsertBefore splices frag onto the flow entering target. func (m *Mutator) InsertBefore(target model.ID, frag *backend.MicroflowFragment) error { g := m.graph() - y, err := g.node(target) + y, err := g.anyNode(target) if err != nil { return err } + if err := inBodyFragment(y, frag); err != nil { + return err + } in := g.incoming(y.id) switch { + case len(in) == 0 && y.loop != "": + // The first node of a loop body: an iteration starts there, so it + // starts at the fragment instead. + return m.prependInLoop(g, y, frag) case len(in) == 0: return fmt.Errorf("no flow enters %s; there is no path to insert on", describeNode(y)) case len(in) > 1: @@ -289,9 +323,13 @@ func (m *Mutator) spliceOnFlow(g *graph, f flowRef, frag *backend.MicroflowFragm if x == nil || y == nil { return fmt.Errorf("flow %s points at an object that is not in the flow", f.id) } - if x.loop != "" || y.loop != "" { - return fmt.Errorf("the flow runs inside a loop body; alter does not splice inside a loop yet") + if x.loop != y.loop { + return fmt.Errorf("flow %s runs between a loop body and the rest of the flow", f.id) } + // Inside a loop body the fragment is laid out among the body's own nodes, + // in the loop box's coordinates, and the box is fitted to it afterwards. + scope := x.loop + before := m.contentBounds(scope) fb, err := fragmentGeometry(frag) if err != nil { return err @@ -308,7 +346,7 @@ func (m *Mutator) spliceOnFlow(g *graph, f flowRef, frag *backend.MicroflowFragm return err } destIdx, destVec := flowEnd(f.doc, "Destination") - if err := m.addFragment(frag); err != nil { + if err := m.addFragmentIn(frag, scope); err != nil { return err } // f now enters the fragment. @@ -316,7 +354,7 @@ func (m *Mutator) spliceOnFlow(g *graph, f flowRef, frag *backend.MicroflowFragm setInt(f.doc, "DestinationConnectionIndex", entrySide) setVector(f.doc, "DestinationControlVector", sideVector(entrySide)) // And a new flow carries on from the fragment to Y. - return m.addFlow(µflows.SequenceFlow{ + if err := m.addFlow(µflows.SequenceFlow{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, OriginID: frag.Exit, DestinationID: model.ID(y.id), @@ -325,7 +363,10 @@ func (m *Mutator) spliceOnFlow(g *graph, f flowRef, frag *backend.MicroflowFragm OriginControlVector: sideVector(exitSide), DestinationControlVector: destVec, CaseValue: frag.ExitCase, - }) + }); err != nil { + return err + } + return m.fitLoopTo(g, scope, before) } // makeRoom places the fragment in the gap between x and y, moving everything @@ -343,9 +384,10 @@ func (m *Mutator) makeRoom(g *graph, fb *fragmentBox, f flowRef, x, y *node) (en a := x.pos.get(ax) + s*x.size.get(ax)/2 b := y.pos.get(ax) - s*y.size.get(ax)/2 need := fb.length(ax) + 2*minGap + scope := x.loop if deficit := need - s*(b-a); deficit > 0 { cut := float64(a+b) / 2 - if p := m.pinnedPast(g, ax, s, cut, ""); p != nil { + if p := m.pinnedPast(g, scope, ax, s, cut, ""); p != nil { blocked := ", and making it would move " + describeNode(p) + "," if p == y { blocked = "," @@ -353,7 +395,7 @@ func (m *Mutator) makeRoom(g *graph, fb *fragmentBox, f flowRef, x, y *node) (en return 0, 0, fmt.Errorf("the inserted statements need %dpx more room than there is between %s and %s%s "+ "which the script draws where it is; %s", deficit, describeNode(x), describeNode(y), blocked, roomAdvice) } - m.shift(g, ax, s, cut, s*deficit, "") + m.shiftIn(g, scope, ax, s, cut, s*deficit, "") b += s * deficit } @@ -363,7 +405,7 @@ func (m *Mutator) makeRoom(g *graph, fb *fragmentBox, f flowRef, x, y *node) (en mid = mid.set(ax, (a+b)/2) mid = mid.set(cross, (x.pos.get(cross)+y.pos.get(cross))/2) fb.placeCentred(ax, mid) - if err := fb.checkRoom(g, "", f.id); err != nil { + if err := fb.checkRoomIn(g, scope, "", f.id); err != nil { return 0, 0, err } return side(ax, -s), side(ax, s), nil @@ -406,6 +448,9 @@ func fragmentNode(frag *backend.MicroflowFragment, id model.ID) *node { func (m *Mutator) Replace(target model.ID, frag *backend.MicroflowFragment) error { g := m.graph() if n, ok := g.nodes[string(target)]; ok && n.loop != "" { + if err := inBodyFragment(n, frag); err != nil { + return err + } return m.replaceInLoop(g, n, frag) } x, err := g.node(target) @@ -438,7 +483,7 @@ func (m *Mutator) Replace(target model.ID, frag *backend.MicroflowFragment) erro near := x.pos.get(ax) - s*x.size.get(ax)/2 if extra := fb.length(ax) - x.size.get(ax); extra > 0 { cut := float64(x.pos.get(ax)) - if p := m.pinnedPast(g, ax, s, cut, x.id); p != nil { + if p := m.pinnedPast(g, "", ax, s, cut, x.id); p != nil { return fmt.Errorf("the replacing statements are %dpx longer than %s, and making room for them would move %s, "+ "which the script draws where it is; %s", extra, describeNode(x), describeNode(p), roomAdvice) } @@ -476,27 +521,24 @@ func (m *Mutator) Replace(target model.ID, frag *backend.MicroflowFragment) erro return m.removeObject(x) } -// replaceInLoop replaces an activity inside a loop body by one activity of -// the same kind, in place (ako/mxcli#1106): the new activity takes the stored -// one's place in the loop's collection, is drawn where it was at the size it -// was, and the flows that entered and left it now enter and leave the new one. -// Nothing else in the loop moves, so no layout is needed — which is why this -// is the one edit inside a loop body the splice makes. A body node's -// coordinates are relative to the loop box, so anything that adds, removes or -// redraws a node there would have to re-lay the body out; that is refused. +// replaceInLoop replaces an activity inside a loop body. By one activity it is +// swapped in place (ako/mxcli#1106): the new activity takes the stored one's +// place in the loop's collection, is drawn where it was at the size it was, +// and the flows that entered and left it now enter and leave the new one, so +// nothing else in the body moves. By several (ako/mxcli#1693) the fragment +// starts where the activity started, what is past it in the body moves along +// when the fragment is longer, and the loop box grows when the body no longer +// fits it. The activity may be the body's first (no flow in) or last (no flow +// out): the fragment then starts or ends the iteration in its place. func (m *Mutator) replaceInLoop(g *graph, x *node, frag *backend.MicroflowFragment) error { - if !singleActivity(frag) { - return fmt.Errorf("%s is inside a loop body, where the splice replaces an activity by one activity "+ - "only (nothing in the body is added, removed or redrawn); replace the whole loop instead", describeNode(x)) - } if x.typ != "Microflows$ActionActivity" { - return fmt.Errorf("cannot replace %s in place: only an activity can be", describeNode(x)) + return fmt.Errorf("cannot replace %s inside a loop body: only an activity can be", describeNode(x)) } // The body's last activity ends it, with no flow out. normal, errs := g.outgoing(x.id) if len(errs) > 0 { return fmt.Errorf("cannot replace %s: it has an error handler, which would be left with no activity; "+ - "replace the whole loop instead", describeNode(x)) + "replace the loop's body instead", describeNode(x)) } if len(normal) > 1 { return fmt.Errorf("cannot replace %s: it has %d outgoing flows, not one", describeNode(x), len(normal)) @@ -505,15 +547,278 @@ func (m *Mutator) replaceInLoop(g *graph, x *node, frag *backend.MicroflowFragme if loop == nil { return fmt.Errorf("%s is in a loop that is not in the flow", describeNode(x)) } - d, err := m.serializeInPlace(x, frag) + if singleActivity(frag) { + if d, err := m.serializeInPlace(x, frag); err != nil { + return err + } else if dString(d, "$Type") == x.typ { + return m.swapInPlace(g, x, dDoc(loop.doc, "ObjectCollection"), d, frag.Entry) + } + } + in := g.incoming(x.id) + before := m.contentBounds(x.loop) + fb, err := fragmentGeometry(frag) if err != nil { return err } - if t := dString(d, "$Type"); t != x.typ { - return fmt.Errorf("%s is inside a loop body, where the splice replaces an activity by one of the same "+ - "kind only, not by a %s; replace the whole loop instead", describeNode(x), t) + if !frag.Placed { + ax, s := bodyAxis(g, x, normal, in) + // The fragment starts where x started; if it is longer, what is past + // x in the body moves along by the difference. + near := x.pos.get(ax) - s*x.size.get(ax)/2 + if extra := fb.length(ax) - x.size.get(ax); extra > 0 { + m.shiftIn(g, x.loop, ax, s, float64(x.pos.get(ax)), s*extra, x.id) + } + centre := point{} + centre = centre.set(ax, near+s*fb.length(ax)/2) + centre = centre.set(ax.other(), x.pos.get(ax.other())) + fb.placeCentred(ax, centre) + if err := fb.checkRoomIn(g, x.loop, x.id, ""); err != nil { + return err + } + } + if err := m.addFragmentIn(frag, x.loop); err != nil { + return err + } + for _, f := range in { + setPointer(f.doc, "DestinationPointer", frag.Entry) + } + if len(normal) == 1 { + setPointer(normal[0].doc, "OriginPointer", frag.Exit) + } + for _, af := range g.annotationFlows(x.id) { + key := "DestinationPointer" + if af.origin == x.id { + key = "OriginPointer" + } + setPointer(af.doc, key, frag.Entry) + } + if err := m.removeObject(x); err != nil { + return err } - return m.swapInPlace(g, x, dDoc(loop.doc, "ObjectCollection"), d, frag.Entry) + return m.fitLoopTo(g, x.loop, before) +} + +// inBodyFragment refuses to put into a loop body a fragment that is not a run +// of plain activities: one with a decision, a loop, an end or error event, an +// error handler or a case on its exit. Such a fragment changes the body's +// shape — where its paths split, end or rejoin — which the splice does not lay +// out inside a loop box; ReplaceLoopBody rebuilds the body instead. The +// executor refuses the same fragments first, with the statement that makes the +// change; this is the engine's own guard. +func inBodyFragment(x *node, frag *backend.MicroflowFragment) error { + if x.loop == "" || frag == nil { + return nil + } + if frag.ExitCase != nil { + return fmt.Errorf("%s is inside a loop body, where the splice puts plain activities only, not a decision; "+ + "replace the loop's body instead", describeNode(x)) + } + for _, obj := range frag.Objects { + switch obj.(type) { + case *microflows.ActionActivity, *microflows.Annotation: + default: + return fmt.Errorf("%s is inside a loop body, where the splice puts plain activities only, not a %s; "+ + "replace the loop's body instead", describeNode(x), strings.TrimPrefix(fmt.Sprintf("%T", obj), "*microflows.")) + } + } + for _, f := range frag.Flows { + if f.IsErrorHandler { + return fmt.Errorf("%s is inside a loop body, where the splice puts plain activities only, not one with an "+ + "error handler; replace the loop's body instead", describeNode(x)) + } + } + return nil +} + +// bodyAxis is the direction a loop body runs through x: along the flow that +// leaves it, else the one that enters it, else left to right (x is the body's +// only node). +func bodyAxis(g *graph, x *node, out, in []flowRef) (axis, int) { + if len(out) > 0 { + if y := g.nodes[out[0].dest]; y != nil { + if ax, s, err := flowAxis(x, y); err == nil { + return ax, s + } + } + } + if len(in) > 0 { + if w := g.nodes[in[0].origin]; w != nil { + if ax, s, err := flowAxis(w, x); err == nil { + return ax, s + } + } + } + return axisX, 1 +} + +// appendInLoop puts frag after x, the last node of a loop body: x gets a flow +// into the fragment, whose exit ends the iteration now. The fragment is drawn +// past x along the direction the body runs, and the loop box is fitted to it. +func (m *Mutator) appendInLoop(g *graph, x *node, frag *backend.MicroflowFragment) error { + if x.typ != "Microflows$ActionActivity" && x.typ != "Microflows$LoopedActivity" { + return fmt.Errorf("%s ends the loop body, and only an activity or a loop can be followed there", describeNode(x)) + } + before := m.contentBounds(x.loop) + fb, err := fragmentGeometry(frag) + if err != nil { + return err + } + ax, s := bodyAxis(g, x, nil, g.incoming(x.id)) + originSide, entrySide := side(ax, s), side(ax, -s) + if frag.Placed { + entry := fragmentNode(frag, frag.Entry) + if entry == nil { + return fmt.Errorf("the fragment's entry is not one of its objects") + } + pax, ps, err := flowAxis(x, entry) + if err != nil { + return err + } + originSide, entrySide = side(pax, ps), side(pax, -ps) + } else { + centre := point{} + centre = centre.set(ax, x.pos.get(ax)+s*(x.size.get(ax)/2+minGap+fb.length(ax)/2)) + centre = centre.set(ax.other(), x.pos.get(ax.other())) + fb.placeCentred(ax, centre) + if err := fb.checkRoomIn(g, x.loop, "", ""); err != nil { + return err + } + } + if err := m.addFragmentIn(frag, x.loop); err != nil { + return err + } + if err := m.addFlow(µflows.SequenceFlow{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + OriginID: model.ID(x.id), + DestinationID: frag.Entry, + OriginConnectionIndex: originSide, + DestinationConnectionIndex: entrySide, + OriginControlVector: sideVector(originSide), + DestinationControlVector: sideVector(entrySide), + }); err != nil { + return err + } + return m.fitLoopTo(g, x.loop, before) +} + +// prependInLoop puts frag before y, the first node of a loop body: the +// fragment starts the iteration now, and its exit gets a flow into y. y and +// everything past its near edge move along to make room, and the loop box is +// fitted to the body. +func (m *Mutator) prependInLoop(g *graph, y *node, frag *backend.MicroflowFragment) error { + before := m.contentBounds(y.loop) + fb, err := fragmentGeometry(frag) + if err != nil { + return err + } + out, _ := g.outgoing(y.id) + ax, s := bodyAxis(g, y, out, nil) + exitSide, destSide := side(ax, s), side(ax, -s) + if frag.Placed { + exit := fragmentNode(frag, frag.Exit) + if exit == nil { + return fmt.Errorf("the fragment's exit is not one of its objects") + } + pax, ps, err := flowAxis(exit, y) + if err != nil { + return err + } + exitSide, destSide = side(pax, ps), side(pax, -ps) + } else { + near := y.pos.get(ax) - s*y.size.get(ax)/2 + m.shiftIn(g, y.loop, ax, s, float64(near)-float64(s)/2, s*(fb.length(ax)+minGap), "") + centre := point{} + centre = centre.set(ax, near+s*fb.length(ax)/2) + centre = centre.set(ax.other(), y.pos.get(ax.other())) + fb.placeCentred(ax, centre) + if err := fb.checkRoomIn(g, y.loop, "", ""); err != nil { + return err + } + } + if err := m.addFragmentIn(frag, y.loop); err != nil { + return err + } + if err := m.addFlow(µflows.SequenceFlow{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + OriginID: frag.Exit, + DestinationID: model.ID(y.id), + OriginConnectionIndex: exitSide, + DestinationConnectionIndex: destSide, + OriginControlVector: sideVector(exitSide), + DestinationControlVector: sideVector(destSide), + }); err != nil { + return err + } + return m.fitLoopTo(g, y.loop, before) +} + +// dropInLoop removes the activity x from a loop body. Between two nodes it is +// a drop like any other: the flows that entered it enter its successor. As the +// body's first node its successor starts the iteration; as its last, its +// predecessor ends it, which only an activity or a loop can (a decision would +// be left with a path to nowhere); as its only node the body is left empty, +// which Mendix accepts (mx check 0 errors on 11.13). Nothing else in the body +// moves: the gap stays, as it does at top level. +func (m *Mutator) dropInLoop(g *graph, x *node) error { + if x.typ != "Microflows$ActionActivity" { + return fmt.Errorf("cannot drop %s from a loop body: only an activity can be, because a decision, a loop "+ + "or an event changes the shape of the body; replace the loop's body instead", describeNode(x)) + } + normal, errs := g.outgoing(x.id) + if len(errs) > 0 { + return fmt.Errorf("cannot drop %s: it has an error handler, which would be left with no activity; "+ + "replace the loop's body instead", describeNode(x)) + } + in := g.incoming(x.id) + drop := map[string]bool{} + switch { + case len(normal) > 1: + return fmt.Errorf("cannot drop %s: it has %d outgoing flows, not one", describeNode(x), len(normal)) + case len(normal) == 1 && len(in) > 0: + out := normal[0] + destIdx, destVec := flowEnd(out.doc, "Destination") + destPtr := dGet(out.doc, "DestinationPointer") + for _, f := range in { + if f.origin == out.dest { + return fmt.Errorf("dropping %s would leave a flow from an object to itself", describeNode(x)) + } + } + for _, f := range in { + setBinary(f.doc, "DestinationPointer", destPtr) + setInt(f.doc, "DestinationConnectionIndex", destIdx) + setVector(f.doc, "DestinationControlVector", destVec) + } + drop[out.id] = true + case len(normal) == 1: + // The first node: its successor starts the iteration, which only a + // node no other flow enters can. + out := normal[0] + if n := len(g.incoming(out.dest)); n != 1 { + return fmt.Errorf("cannot drop %s, the first activity of the loop body: %d flows enter the node after it, "+ + "which would then start the body", describeNode(x), n) + } + drop[out.id] = true + case len(in) > 1: + return fmt.Errorf("cannot drop %s, the last activity of the loop body: %d flows enter it, and each path "+ + "would then end where it came from", describeNode(x), len(in)) + case len(in) == 1: + // The last node: its predecessor ends the iteration. + w := g.nodes[in[0].origin] + if w == nil { + return fmt.Errorf("flow %s points at an object that is not in the flow", in[0].id) + } + wOut, _ := g.outgoing(w.id) + if (w.typ != "Microflows$ActionActivity" && w.typ != "Microflows$LoopedActivity") || len(wOut) != 1 { + return fmt.Errorf("cannot drop %s, the last activity of the loop body: %s before it would then end the "+ + "body, and only an activity or a loop can; replace the loop's body instead", describeNode(x), describeNode(w)) + } + drop[in[0].id] = true + } + for _, af := range g.annotationFlows(x.id) { + drop[af.id] = true + } + m.removeFlows(drop) + return m.removeObject(x) } // replaceKeepingHandler replaces a top-level activity that has a custom error @@ -725,7 +1030,9 @@ func (m *Mutator) setCase(d bson.D, cv microflows.CaseValue) error { // from that object too. func (m *Mutator) RemoveNotes(target model.ID) error { g := m.graph() - x, err := g.node(target) + // An activity in a loop body has notes like any other (ako/mxcli#1693): + // the note sits in the loop's collection, its line in the unit's flows. + x, err := g.anyNode(target) if err != nil { return err } @@ -831,10 +1138,13 @@ func (m *Mutator) SetFreeNotes(frag *backend.MicroflowFragment) error { // led to. Annotation lines attached to it go with it; the annotations stay. func (m *Mutator) Drop(target model.ID) error { g := m.graph() - x, err := g.node(target) + x, err := g.anyNode(target) if err != nil { return err } + if x.loop != "" { + return m.dropInLoop(g, x) + } out, err := removable(g, x, "drop") if err != nil { return err @@ -937,6 +1247,28 @@ func (m *Mutator) Pin(at model.Point) error { return nil } +// PinInLoop is Pin for a node of loop's body, at a point in the loop box's +// coordinates (ako/mxcli#1693): making room in that body refuses to move it. +func (m *Mutator) PinInLoop(loop model.ID, at model.Point) error { + if m.pinnedIn == nil { + m.pinnedIn = map[string]map[point]bool{} + } + if m.pinnedIn[string(loop)] == nil { + m.pinnedIn[string(loop)] = map[point]bool{} + } + m.pinnedIn[string(loop)][point{at.X, at.Y}] = true + return nil +} + +// isPinned reports whether the script states that a node of scope is drawn +// at p. +func (m *Mutator) isPinned(scope string, p point) bool { + if scope == "" { + return m.pinned[p] + } + return m.pinnedIn[scope][p] +} + // bodyFlows returns the flows that run inside loop's body, at any depth. They // are stored in the unit's Flows list, not in the loop, so taking the loop out // has to take them too; left behind they would point at removed objects. @@ -990,7 +1322,29 @@ func removable(g *graph, x *node, verb string) (flowRef, error) { // level collection (appended, so no stored object changes list position), its // flows to the Flows list. func (m *Mutator) addFragment(frag *backend.MicroflowFragment) error { - oc := dDoc(m.doc, "ObjectCollection") + return m.addFragmentIn(frag, "") +} + +// collection returns the object collection of scope: the loop with that $ID, +// or the flow's own for "". nil when there is no such loop. +func (m *Mutator) collection(scope string) bson.D { + if scope == "" { + return dDoc(m.doc, "ObjectCollection") + } + if l := m.graph().nodes[scope]; l != nil && l.typ == "Microflows$LoopedActivity" { + return dDoc(l.doc, "ObjectCollection") + } + return nil +} + +// addFragmentIn is addFragment into the collection of scope — a loop body, +// whose objects the loop holds while its flows, like every flow of the unit, +// are in the unit's Flows list. +func (m *Mutator) addFragmentIn(frag *backend.MicroflowFragment, scope string) error { + oc := m.collection(scope) + if oc == nil { + return fmt.Errorf("loop %s has no object collection", scope) + } objs := arrayElements(dGet(oc, "Objects")) for _, obj := range frag.Objects { d, err := m.deps.SerializeObject(obj) @@ -1054,9 +1408,9 @@ func (m *Mutator) removeFlows(ids map[string]bool) { setArray(m.doc, "Flows", keep) } -// removeObject takes x out of the top-level collection. +// removeObject takes x out of the collection that holds it. func (m *Mutator) removeObject(x *node) error { - oc := dDoc(m.doc, "ObjectCollection") + oc := m.collection(x.loop) var keep []any found := false for _, el := range arrayElements(dGet(oc, "Objects")) { @@ -1068,7 +1422,7 @@ func (m *Mutator) removeObject(x *node) error { keep = append(keep, el) } if !found { - return fmt.Errorf("%s is not in the top-level collection", describeNode(x)) + return fmt.Errorf("%s is not in its collection", describeNode(x)) } setArray(oc, "Objects", keep) return nil @@ -1101,8 +1455,16 @@ func (m *Mutator) markRemoved(v any) { // control vectors are relative to its ends, so no curve changes. skip is a // node that is about to be removed. func (m *Mutator) shift(g *graph, ax axis, s int, cut float64, delta int, skip string) { + m.shiftIn(g, "", ax, s, cut, delta, skip) +} + +// shiftIn is shift among the nodes of scope: the loop body with that $ID, or +// the top level for "". A body node's coordinates are relative to its loop +// box, so a shift inside a body moves the body's own nodes only; the box is +// fitted to them afterwards (fitLoopTo). +func (m *Mutator) shiftIn(g *graph, scope string, ax axis, s int, cut float64, delta int, skip string) { for _, n := range g.order { - if !shifted(n, ax, s, cut, skip) { + if !shifted(n, scope, ax, s, cut, skip) { continue } n.pos = n.pos.set(ax, n.pos.get(ax)+delta) @@ -1110,14 +1472,14 @@ func (m *Mutator) shift(g *graph, ax axis, s int, cut float64, delta int, skip s } } -// shifted reports whether shift moves n: a top-level node past the cut that -// is not a parameter. A parameter is not on the line the room is made on: it +// shifted reports whether shift moves n: a node of scope past the cut that is +// not a parameter. A parameter is not on the line the room is made on: it // belongs to the flow as a whole, and Studio Pro authors draw it above the // start event (63 of 71 in PedApp and JTSBootLogboek at the start's x, none // within 60px of the line). Moved, it drifted right with every insert and // describe printed a @position the script never stated (ako/mxcli#1547). -func shifted(n *node, ax axis, s int, cut float64, skip string) bool { - return n.loop == "" && n.id != skip && n.typ != "Microflows$MicroflowParameter" && +func shifted(n *node, scope string, ax axis, s int, cut float64, skip string) bool { + return n.loop == scope && n.id != skip && n.typ != "Microflows$MicroflowParameter" && float64(s)*(float64(n.pos.get(ax))-cut) > 0 } @@ -1126,16 +1488,361 @@ func shifted(n *node, ax axis, s int, cut float64, skip string) bool { // something other than the script — a later fragment placed at its own stated // position would be left behind on the wrong side of a node that moved, and // re-running the script would move the node back (mendixlabs/mxcli#1598, -// case 6) — so the caller refuses instead. -func (m *Mutator) pinnedPast(g *graph, ax axis, s int, cut float64, skip string) *node { +// case 6) — so the caller refuses instead. A node of a loop body is pinned in +// its loop box's coordinates (PinInLoop). +func (m *Mutator) pinnedPast(g *graph, scope string, ax axis, s int, cut float64, skip string) *node { for _, n := range g.order { - if m.pinned[n.pos] && shifted(n, ax, s, cut, skip) { + if m.isPinned(scope, n.pos) && shifted(n, scope, ax, s, cut, skip) { return n } } return nil } +// --------------------------------------------------------------------------- +// Loop boxes +// --------------------------------------------------------------------------- + +// loopPadding is the room mxcli's layout leaves between a loop box's edge and +// the body inside it (the executor's LoopPadding). +const loopPadding = 50 + +// bounds is the box around a collection's objects, in its coordinates. +type bounds struct { + min, max point + n int +} + +// contentBounds returns the box around the objects of scope's collection — +// notes included, as mxcli's layout sizes a loop box (fitContainerSize). It +// reads the stored documents, so objects an operation has just added count. +// The zero value for the top level, which has no box. +func (m *Mutator) contentBounds(scope string) bounds { + var b bounds + if scope == "" { + return b + } + for _, el := range arrayElements(dGet(m.collection(scope), "Objects")) { + d, ok := el.(bson.D) + if !ok { + continue + } + p, sz := parsePoint(dString(d, "RelativeMiddlePoint")), parsePoint(dString(d, "Size")) + lo := point{p.X - sz.X/2, p.Y - sz.Y/2} + hi := point{p.X + sz.X/2, p.Y + sz.Y/2} + if b.n == 0 { + b.min, b.max = lo, hi + } else { + b.min = point{min(b.min.X, lo.X), min(b.min.Y, lo.Y)} + b.max = point{max(b.max.X, hi.X), max(b.max.Y, hi.Y)} + } + b.n++ + } + return b +} + +// fitLoopTo grows the box of the loop scope when an edit of its body — +// before is the body's box before it — left the body reaching past it +// (ako/mxcli#1693). A body may reach as close to the box's edge as it did +// before, or loopPadding, whichever is closer: a Studio Pro-drawn body that +// sat tight in its box is not a reason to grow. A body that still fits leaves +// the box as it is. "" (an edit at top level) has no box to fit. +func (m *Mutator) fitLoopTo(g *graph, scope string, before bounds) error { + if scope == "" { + return nil + } + l := g.nodes[scope] + if l == nil { + return fmt.Errorf("loop %s is not in the flow", scope) + } + after := m.contentBounds(scope) + if after.n == 0 { + return nil + } + allowMin := point{loopPadding, loopPadding} + allowMax := point{l.size.X - loopPadding, l.size.Y - loopPadding} + if before.n > 0 { + allowMin = point{min(allowMin.X, before.min.X), min(allowMin.Y, before.min.Y)} + allowMax = point{max(allowMax.X, before.max.X), max(allowMax.Y, before.max.Y)} + } + lo := point{max(0, allowMin.X-after.min.X), max(0, allowMin.Y-after.min.Y)} + hi := point{max(0, after.max.X-allowMax.X), max(0, after.max.Y-allowMax.Y)} + if lo == (point{}) && hi == (point{}) { + return nil + } + ax, s := loopAxis(g, l) + near, far := lo.get(ax), hi.get(ax) + if s < 0 { + near, far = far, near + } + if near > 0 { + return fmt.Errorf("the body of %s would need %dpx more room on the side its flow enters from; "+ + "replace the loop's body instead, or make room in Studio Pro first", describeNode(l), near) + } + cross := ax.other() + grow := point{} + grow = grow.set(ax, far) + grow = grow.set(cross, 2*max(lo.get(cross), hi.get(cross))) + _, err := m.growLoop(g, l, grow, true) + return err +} + +// loopAxis is the line a loop sits on: the direction of the flow that enters +// it, by the side it enters at, else of the one that leaves it, else left to +// right. The box grows along that line on its far side only, so the flows into +// and out of the loop stay level and its predecessor stays where it is. +func loopAxis(g *graph, l *node) (axis, int) { + for _, f := range g.incoming(l.id) { + switch idx, _ := flowEnd(f.doc, "Destination"); idx { + case sideLeft: + return axisX, 1 + case sideRight: + return axisX, -1 + case sideTop: + return axisY, 1 + case sideBottom: + return axisY, -1 + } + } + normal, _ := g.outgoing(l.id) + for _, f := range normal { + switch idx, _ := flowEnd(f.doc, "Origin"); idx { + case sideRight: + return axisX, 1 + case sideLeft: + return axisX, -1 + case sideBottom: + return axisY, 1 + case sideTop: + return axisY, -1 + } + } + return axisX, 1 +} + +// growLoop enlarges the box of loop l by grow (the width and height to add). +// Along the line the loop sits on (loopAxis) the box grows on its far side, +// its near edge staying where it is, and everything in the loop's own +// collection past its far edge moves along — as the nodes past an inserted +// fragment do. Across that line it grows on both sides alike, so its centre +// stays on the line; nothing moves to make that room, and a box that would be +// drawn over another node is refused. A loop in another loop's body may make +// that box grow in turn. +// +// It returns where the box's old top-left corner is in the grown box. With +// keepBody, the body's own nodes are moved by that much, so they stay where +// they were drawn on the canvas. +func (m *Mutator) growLoop(g *graph, l *node, grow point, keepBody bool) (point, error) { + if grow.X <= 0 && grow.Y <= 0 { + return point{}, nil + } + ax, s := loopAxis(g, l) + cross := ax.other() + far := max(0, grow.get(ax)) + far += far % 2 // the centre moves by half of it + half := (max(0, grow.get(cross)) + 1) / 2 + parentBefore := m.contentBounds(l.loop) + + size, pos := l.size, l.pos + size = size.set(ax, size.get(ax)+far) + size = size.set(cross, size.get(cross)+2*half) + pos = pos.set(ax, pos.get(ax)+s*far/2) + + if pos != l.pos && m.isPinned(l.loop, l.pos) { + return point{}, fmt.Errorf("%s has to grow by %dpx to fit its body, which moves its centre, and the script "+ + "draws it where it is; %s", describeNode(l), far, roomAdvice) + } + if far > 0 { + cut := float64(l.pos.get(ax) + s*l.size.get(ax)/2) + if p := m.pinnedPast(g, l.loop, ax, s, cut, l.id); p != nil { + return point{}, fmt.Errorf("%s has to grow by %dpx to fit its body, and making room for it would move %s, "+ + "which the script draws where it is; %s", describeNode(l), far, describeNode(p), roomAdvice) + } + m.shiftIn(g, l.loop, ax, s, cut, s*far, l.id) + } + if half > 0 { + lo := point{pos.X - size.X/2, pos.Y - size.Y/2} + hi := point{pos.X + size.X/2, pos.Y + size.Y/2} + for _, n := range g.order { + // A parameter is not on the line either: it belongs to the flow as + // a whole and sits above the start event, and mxcli's own layout + // draws a tall loop over it (measured: a nested loop with a + // decision, built by `create`, spans the parameters' row). + if n.loop != l.loop || n.id == l.id || n.typ == "Microflows$MicroflowParameter" { + continue + } + nlo := point{n.pos.X - n.size.X/2, n.pos.Y - n.size.Y/2} + nhi := point{n.pos.X + n.size.X/2, n.pos.Y + n.size.Y/2} + if nlo.X < hi.X && lo.X < nhi.X && nlo.Y < hi.Y && lo.Y < nhi.Y { + return point{}, fmt.Errorf("there is no free room for %s to grow to fit its body: at (%d, %d)-(%d, %d) "+ + "it would be drawn over %s; move that object aside in Studio Pro first", describeNode(l), + lo.X, lo.Y, hi.X, hi.Y, describeNode(n)) + } + } + } + l.size, l.pos = size, pos + dSet(l.doc, "RelativeMiddlePoint", pos.String()) + dSet(l.doc, "Size", size.String()) + + off := point{} + off = off.set(cross, half) + if s < 0 { + off = off.set(ax, far) + } + if keepBody && off != (point{}) { + m.translateBody(g, l, off) + } + return off, m.fitLoopTo(g, l.loop, parentBefore) +} + +// translateBody moves every node directly in loop l's body by off, in the +// loop box's coordinates: the box grew on its top or left side, and the body +// stays where it was drawn on the canvas. +func (m *Mutator) translateBody(g *graph, l *node, off point) { + for _, el := range arrayElements(dGet(dDoc(l.doc, "ObjectCollection"), "Objects")) { + d, ok := el.(bson.D) + if !ok { + continue + } + p := parsePoint(dString(d, "RelativeMiddlePoint")) + dSet(d, "RelativeMiddlePoint", point{p.X + off.X, p.Y + off.Y}.String()) + } + for _, n := range g.order { + if n.loop == l.id { + n.pos = parsePoint(dString(n.doc, "RelativeMiddlePoint")) + } + } +} + +// ReplaceLoopBody replaces everything inside the stored loop target by the +// body of the loop frag holds (ako/mxcli#1693): the loop itself — its $ID, its +// position, its iterator and list, the flows into and out of it and the notes +// on it — stays, and only what is inside it is rebuilt. frag is the fragment +// the builder made of the loop restated with the new body: one loop object, +// and the flows and note lines of its body. The new body is laid out as the +// builder laid it out, centred across the stored box; a box too small for it +// grows (growLoop), one large enough keeps its size. +// +// A note outside the loop attached to a node in its body, or a flow between +// the body and the rest of the flow, would be left pointing at nothing, and is +// refused. +func (m *Mutator) ReplaceLoopBody(target model.ID, frag *backend.MicroflowFragment) error { + g := m.graph() + l, err := g.anyNode(target) + if err != nil { + return err + } + if l.typ != "Microflows$LoopedActivity" { + return fmt.Errorf("cannot replace the body of %s: only a loop has one", describeNode(l)) + } + var built *microflows.LoopedActivity + for _, obj := range frag.Objects { + lp, ok := obj.(*microflows.LoopedActivity) + if !ok || obj.GetID() != frag.Entry || built != nil { + return fmt.Errorf("the new body of %s is not one loop's body", describeNode(l)) + } + built = lp + } + if built == nil { + return fmt.Errorf("the new body of %s is not one loop's body", describeNode(l)) + } + inBody := func(id string) bool { + for n := g.nodes[id]; n != nil && n.loop != ""; n = g.nodes[n.loop] { + if n.loop == l.id { + return true + } + } + return false + } + for _, f := range g.flows { + in1, in2 := inBody(f.origin), inBody(f.dest) + if in1 == in2 { + continue + } + inner := g.nodes[f.origin] + if in2 { + inner = g.nodes[f.dest] + } + if !f.isSequence() { + return fmt.Errorf("cannot replace the body of %s: a note outside the loop is attached to %s in its body, "+ + "and would be left attached to nothing; move the note into the loop or detach it in Studio Pro first", + describeNode(l), describeNode(inner)) + } + return fmt.Errorf("cannot replace the body of %s: flow %s runs between %s in its body and the rest of the flow", + describeNode(l), f.id, describeNode(inner)) + } + d, err := m.deps.SerializeObject(built) + if err != nil { + return fmt.Errorf("serialize the new body: %w", err) + } + newBody := arrayElements(dGet(dDoc(d, "ObjectCollection"), "Objects")) + builtSize := point{built.Size.Width, built.Size.Height} + + // Out with the stored body: its objects (nested loops' bodies with them) + // and every flow and note line inside it. + oc := dDoc(l.doc, "ObjectCollection") + if oc == nil { + return fmt.Errorf("%s has no object collection", describeNode(l)) + } + if _, ok := dGet(oc, "Objects").(bson.A); !ok { + // An empty body may be stored without its list; it is written with + // the marker every object list of a loop carries. + oc = append(oc, bson.E{Key: "Objects", Value: bson.A{int32(3)}}) + dSet(l.doc, "ObjectCollection", oc) + } + for _, el := range arrayElements(dGet(oc, "Objects")) { + m.markRemoved(el) + } + m.removeFlows(g.bodyFlows(l.id)) + if !setArray(oc, "Objects", nil) { + return fmt.Errorf("%s has no Objects list", describeNode(l)) + } + for _, n := range g.order { + if inBody(n.id) { + delete(g.nodes, n.id) + } + } + + // The box: as stored where the new body fits it, grown where it does not. + grow := point{max(0, builtSize.X-l.size.X), max(0, builtSize.Y-l.size.Y)} + if _, err := m.growLoop(g, l, grow, false); err != nil { + return err + } + // The builder drew the body in a box of builtSize, from its left edge and + // centred across it; centred across the stored box it stays so. + dy := (l.size.Y - builtSize.Y) / 2 + for _, el := range newBody { + if bd, ok := el.(bson.D); ok && dy != 0 { + p := parsePoint(dString(bd, "RelativeMiddlePoint")) + dSet(bd, "RelativeMiddlePoint", point{p.X, p.Y + dy}.String()) + } + } + if !setArray(oc, "Objects", newBody) { + return fmt.Errorf("%s has no Objects list", describeNode(l)) + } + for _, f := range frag.Flows { + if err := m.addFlow(f); err != nil { + return err + } + } + for _, af := range frag.AnnotationFlows { + ad, err := m.deps.SerializeAnnotationFlow(af) + if err != nil { + return fmt.Errorf("serialize annotation flow: %w", err) + } + if err := m.appendFlowDoc(ad); err != nil { + return err + } + } + return nil +} + +// InLoopBody reports whether the stored object id is inside a loop body. +func (m *Mutator) InLoopBody(id model.ID) bool { + n, ok := m.graph().nodes[string(id)] + return ok && n.loop != "" +} + // roomAdvice is the way out of a refused shift. const roomAdvice = "state @position on the new statements, where there is room for them, or leave unstated the positions " + "of what comes after them (@position, @merge, a note's position:) so that it can move along" @@ -1351,8 +2058,14 @@ func (fb *fragmentBox) placeCentred(ax axis, c point) { // object a replace removes; onFlow the flow an insert goes on, which runs to // the fragment's entry afterwards rather than across it. func (fb *fragmentBox) checkRoom(g *graph, skip, onFlow string) error { + return fb.checkRoomIn(g, "", skip, onFlow) +} + +// checkRoomIn is checkRoom among the nodes of scope: a loop body, in its +// box's coordinates, or the top level for "". +func (fb *fragmentBox) checkRoomIn(g *graph, scope, skip, onFlow string) error { for _, n := range g.order { - if n.loop != "" || n.id == skip { + if n.loop != scope || n.id == skip { continue } lo := point{n.pos.X - n.size.X/2, n.pos.Y - n.size.Y/2} diff --git a/mdl/backend/mfmutator/splice_loop_test.go b/mdl/backend/mfmutator/splice_loop_test.go new file mode 100644 index 0000000000..f0175799fa --- /dev/null +++ b/mdl/backend/mfmutator/splice_loop_test.go @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// ako/mxcli#1693: inside a loop body the splice inserts, drops and replaces +// plain activities, laid out among the body's nodes in the loop box's +// coordinates; the box grows on its far side when the body no longer fits, +// and what is drawn past it moves along. + +// loopUnit is start -> loop -> end, the loop drawn as mxcli's layout draws it +// (centre 500;200, 640x160, so its box runs from x=180 to x=820) around a body +// n1 -> n2 -> n3 at y=80 in the box's coordinates. +func loopUnit(body ...string) bson.D { + if len(body) == 0 { + body = []string{"n1", "n2", "n3"} + } + objs := bson.A{int32(3)} + var flows []bson.D + for i, n := range body { + objs = append(objs, obj(n, "Microflows$ActionActivity", 210+160*i, 80)) + if i > 0 { + flows = append(flows, flow("b"+n, body[i-1], n, 1, 3, false)) + } + } + loop := obj("loop", "Microflows$LoopedActivity", 500, 200) + dSet(loop, "Size", "640;160") + loop = append(loop, bson.E{Key: "ObjectCollection", Value: bson.D{ + {Key: "$ID", Value: bin("loopoc")}, + {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, + {Key: "Objects", Value: objs}, + }}) + return unit( + []bson.D{obj("start", "Microflows$StartEvent", 60, 200), loop, obj("end", "Microflows$EndEvent", 900, 200)}, + append([]bson.D{flow("fs", "start", "loop", 1, 3, false), flow("fe", "loop", "end", 1, 3, false)}, flows...), + ) +} + +// geometry returns every object's position and size by its $ID, after a save. +func geometry(t *testing.T, saved []byte) (map[string]point, map[string]point) { + t.Helper() + var d bson.D + if err := bson.Unmarshal(saved, &d); err != nil { + t.Fatal(err) + } + m, err := New(d, "unit", &fakeDeps{}) + if err != nil { + t.Fatal(err) + } + pos, size := map[string]point{}, map[string]point{} + for _, n := range m.graph().order { + pos[n.id], size[n.id] = n.pos, n.size + } + return pos, size +} + +// edges returns the unit's sequence flows as origin->destination $ID pairs. +func edges(t *testing.T, saved []byte) map[[2]string]bool { + t.Helper() + var d bson.D + if err := bson.Unmarshal(saved, &d); err != nil { + t.Fatal(err) + } + m, _ := New(d, "unit", &fakeDeps{}) + out := map[[2]string]bool{} + for _, f := range m.graph().flows { + if f.isSequence() { + out[[2]string{f.origin, f.dest}] = true + } + } + return out +} + +func TestSpliceLoop_InsertInTheMiddleGrowsTheBox(t *testing.T) { + m, deps := newMutator(t, loopUnit()) + frag := oneActivity() + if err := m.InsertAfter(model.ID(uid("n1")), frag); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + pos, size := geometry(t, deps.saved) + id := string(frag.Entry) + // 40px between n1 and n2; the activity and a gap either side need 200. + if pos[id] != (point{370, 80}) { + t.Errorf("the activity is drawn at %v, want 370;80 between n1 and the moved n2", pos[id]) + } + if pos[uid("n2")] != (point{530, 80}) || pos[uid("n3")] != (point{690, 80}) { + t.Errorf("n2 and n3 did not move along by 160: %v %v", pos[uid("n2")], pos[uid("n3")]) + } + if pos[uid("n1")] != (point{210, 80}) { + t.Errorf("n1 moved: %v", pos[uid("n1")]) + } + // The box grows on its far side by what the body grew: its left edge, + // and so the flow into it, stay; the end event moves along. + if size[uid("loop")] != (point{800, 160}) || pos[uid("loop")] != (point{580, 200}) { + t.Errorf("the loop box is %v at %v, want 800;160 at 580;200", size[uid("loop")], pos[uid("loop")]) + } + if pos[uid("end")] != (point{1060, 200}) { + t.Errorf("the end event is at %v, want 1060;200", pos[uid("end")]) + } + e := edges(t, deps.saved) + if !e[[2]string{uid("n1"), id}] || !e[[2]string{id, uid("n2")}] || e[[2]string{uid("n1"), uid("n2")}] { + t.Errorf("the body flow does not run through the activity: %v", e) + } +} + +func TestSpliceLoop_EndsOfTheBody(t *testing.T) { + t.Run("after the last", func(t *testing.T) { + m, deps := newMutator(t, loopUnit()) + frag := oneActivity() + if err := m.InsertAfter(model.ID(uid("n3")), frag); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + pos, _ := geometry(t, deps.saved) + if pos[string(frag.Entry)] != (point{690, 80}) { + t.Errorf("the activity is drawn at %v, want 690;80 after n3", pos[string(frag.Entry)]) + } + if !edges(t, deps.saved)[[2]string{uid("n3"), string(frag.Entry)}] { + t.Error("no flow from n3 into the activity") + } + }) + t.Run("before the first", func(t *testing.T) { + m, deps := newMutator(t, loopUnit()) + frag := oneActivity() + if err := m.InsertBefore(model.ID(uid("n1")), frag); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + pos, _ := geometry(t, deps.saved) + if pos[string(frag.Entry)] != (point{210, 80}) || pos[uid("n1")] != (point{370, 80}) { + t.Errorf("want the activity where n1 was and n1 moved along, got %v and %v", pos[string(frag.Entry)], pos[uid("n1")]) + } + if !edges(t, deps.saved)[[2]string{string(frag.Entry), uid("n1")}] { + t.Error("no flow from the activity into n1") + } + }) + for _, tc := range []struct { + name, target string + gone [][2]string + kept [][2]string + }{ + {"drop the first", "n1", [][2]string{{"n1", "n2"}}, [][2]string{{"n2", "n3"}}}, + {"drop the last", "n3", [][2]string{{"n2", "n3"}}, [][2]string{{"n1", "n2"}}}, + {"drop the middle", "n2", [][2]string{{"n1", "n2"}, {"n2", "n3"}}, [][2]string{{"n1", "n3"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + m, deps := newMutator(t, loopUnit()) + if err := m.Drop(model.ID(uid(tc.target))); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + e := edges(t, deps.saved) + for _, g := range tc.gone { + if e[[2]string{uid(g[0]), uid(g[1])}] { + t.Errorf("flow %v is still there", g) + } + } + for _, k := range tc.kept { + if !e[[2]string{uid(k[0]), uid(k[1])}] { + t.Errorf("flow %v is missing", k) + } + } + pos, size := geometry(t, deps.saved) + if _, ok := pos[uid(tc.target)]; ok { + t.Errorf("%s is still in the unit", tc.target) + } + if size[uid("loop")] != (point{640, 160}) { + t.Errorf("a drop resized the loop box to %v", size[uid("loop")]) + } + }) + } + t.Run("drop the only one", func(t *testing.T) { + m, deps := newMutator(t, loopUnit("n1")) + if err := m.Drop(model.ID(uid("n1"))); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + if pos, _ := geometry(t, deps.saved); len(pos) != 3 { + t.Errorf("want start, loop and end left, got %d objects", len(pos)) + } + }) +} + +func TestSpliceLoop_ReplaceByTwo(t *testing.T) { + m, deps := newMutator(t, loopUnit()) + frag := oneActivity() + second := oneActivity() + second.Objects[0].SetPosition(model.Point{X: 520, Y: 200}) + frag.Objects = append(frag.Objects, second.Objects...) + frag.Flows = []*microflows.SequenceFlow{{BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + OriginID: frag.Entry, DestinationID: second.Entry, OriginConnectionIndex: 1, DestinationConnectionIndex: 3}} + frag.Exit = second.Exit + if err := m.Replace(model.ID(uid("n2")), frag); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + pos, size := geometry(t, deps.saved) + if pos[string(frag.Entry)] != (point{370, 80}) || pos[string(second.Entry)] != (point{530, 80}) { + t.Errorf("the two activities are at %v and %v, want 370;80 and 530;80", pos[string(frag.Entry)], pos[string(second.Entry)]) + } + if pos[uid("n3")] != (point{690, 80}) { + t.Errorf("n3 did not move along by the extra 160: %v", pos[uid("n3")]) + } + if size[uid("loop")].X != 800 { + t.Errorf("the loop box did not grow to fit: %v", size[uid("loop")]) + } + e := edges(t, deps.saved) + if !e[[2]string{uid("n1"), string(frag.Entry)}] || !e[[2]string{string(second.Exit), uid("n3")}] { + t.Errorf("the body flow does not run through the fragment: %v", e) + } +} + +// A shift the script's stated positions forbid is refused, inside a body as +// at top level (PinInLoop), and so is a box that has to grow past a node the +// script draws where it is. +func TestSpliceLoop_PinnedRefusals(t *testing.T) { + t.Run("a pinned body node", func(t *testing.T) { + m, _ := newMutator(t, loopUnit()) + _ = m.PinInLoop(model.ID(uid("loop")), model.Point{X: 370, Y: 80}) + err := m.InsertAfter(model.ID(uid("n1")), oneActivity()) + if err == nil || !strings.Contains(err.Error(), "draws where it is") { + t.Fatalf("want the pinned refusal, got %v", err) + } + }) + t.Run("a pinned node past the loop", func(t *testing.T) { + m, _ := newMutator(t, loopUnit()) + _ = m.Pin(model.Point{X: 900, Y: 200}) + err := m.InsertAfter(model.ID(uid("n3")), oneActivity()) + if err == nil || !strings.Contains(err.Error(), "draws where it is") { + t.Fatalf("want the pinned refusal, got %v", err) + } + }) +} + +// ReplaceLoopBody keeps the loop — its $ID, position and size where the new +// body fits — and replaces everything inside it, flows included. +func TestSpliceLoop_ReplaceBody(t *testing.T) { + newBody := func(n int) *backend.MicroflowFragment { + loop := µflows.LoopedActivity{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Size: model.Size{Width: 200 + 160*n, Height: 160}, + }, + ObjectCollection: µflows.MicroflowObjectCollection{BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}}, + } + frag := &backend.MicroflowFragment{Objects: []microflows.MicroflowObject{loop}, Entry: loop.ID, Exit: loop.ID} + var prev model.ID + for i := 0; i < n; i++ { + a := oneActivity().Objects[0] + a.SetPosition(model.Point{X: 210 + 160*i, Y: 80}) + loop.ObjectCollection.Objects = append(loop.ObjectCollection.Objects, a) + if prev != "" { + frag.Flows = append(frag.Flows, µflows.SequenceFlow{BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + OriginID: prev, DestinationID: a.GetID(), OriginConnectionIndex: 1, DestinationConnectionIndex: 3}) + } + prev = a.GetID() + } + return frag + } + t.Run("a body that fits keeps the box", func(t *testing.T) { + m, deps := newMutator(t, loopUnit()) + if err := m.ReplaceLoopBody(model.ID(uid("loop")), newBody(2)); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + pos, size := geometry(t, deps.saved) + if pos[uid("loop")] != (point{500, 200}) || size[uid("loop")] != (point{640, 160}) { + t.Errorf("the loop moved or was resized: %v %v", pos[uid("loop")], size[uid("loop")]) + } + for _, n := range []string{"n1", "n2", "n3"} { + if _, ok := pos[uid(n)]; ok { + t.Errorf("stored body node %s survived", n) + } + } + if len(pos) != 5 { + t.Errorf("want start, loop, end and the two new activities, got %d objects", len(pos)) + } + if e := edges(t, deps.saved); !e[[2]string{uid("start"), uid("loop")}] || !e[[2]string{uid("loop"), uid("end")}] { + t.Errorf("the flows around the loop changed: %v", e) + } + }) + t.Run("a body that does not fit grows the box", func(t *testing.T) { + m, deps := newMutator(t, loopUnit()) + if err := m.ReplaceLoopBody(model.ID(uid("loop")), newBody(5)); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + pos, size := geometry(t, deps.saved) + if size[uid("loop")] != (point{1000, 160}) || pos[uid("loop")] != (point{680, 200}) { + t.Errorf("the loop box is %v at %v, want 1000;160 at 680;200", size[uid("loop")], pos[uid("loop")]) + } + if pos[uid("end")] != (point{1260, 200}) { + t.Errorf("the end event did not move along: %v", pos[uid("end")]) + } + }) + t.Run("a note outside attached inside is refused", func(t *testing.T) { + doc := loopUnit() + objs := arrayElements(dGet(dDoc(doc, "ObjectCollection"), "Objects")) + objs = append(objs, obj("note", "Microflows$Annotation", 500, 20)) + setArray(dDoc(doc, "ObjectCollection"), "Objects", objs) + fl := arrayElements(dGet(doc, "Flows")) + fl = append(fl, bson.D{ + {Key: "$ID", Value: bin("af")}, + {Key: "$Type", Value: "Microflows$AnnotationFlow"}, + {Key: "DestinationPointer", Value: bin("n2")}, + {Key: "OriginPointer", Value: bin("note")}, + }) + setArray(doc, "Flows", fl) + m, _ := newMutator(t, doc) + err := m.ReplaceLoopBody(model.ID(uid("loop")), newBody(1)) + if err == nil || !strings.Contains(err.Error(), "note outside the loop") { + t.Fatalf("want the outside-note refusal, got %v", err) + } + }) +} diff --git a/mdl/backend/mfmutator/splice_test.go b/mdl/backend/mfmutator/splice_test.go index b8438f425e..649fd9cae6 100644 --- a/mdl/backend/mfmutator/splice_test.go +++ b/mdl/backend/mfmutator/splice_test.go @@ -105,6 +105,26 @@ func (d *fakeDeps) SerializeObject(o microflows.MicroflowObject) (bson.D, error) {Key: "RelativeMiddlePoint", Value: fmt.Sprintf("%d;%d", p.X, p.Y)}, {Key: "Size", Value: fmt.Sprintf("%d;%d", sz.X, sz.Y)}, } + // A loop carries its body, as the codec writes it (ReplaceLoopBody reads + // it from there). + if l, ok := o.(*microflows.LoopedActivity); ok { + doc[1].Value = "Microflows$LoopedActivity" + body := bson.A{int32(3)} + if l.ObjectCollection != nil { + for _, b := range l.ObjectCollection.Objects { + bd, err := d.SerializeObject(b) + if err != nil { + return nil, err + } + body = append(body, bd) + } + } + doc = append(doc, bson.E{Key: "ObjectCollection", Value: bson.D{ + {Key: "$ID", Value: bson.Binary{Data: types.UUIDToBlob(types.GenerateID())}}, + {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, + {Key: "Objects", Value: body}, + }}) + } // The error handling is stored on the action, as the codec writes it. if eh := microflows.ObjectErrorHandlingType(o); eh != "" { doc = append(doc, bson.E{Key: "Action", Value: bson.D{ @@ -171,6 +191,17 @@ func newMutator(t *testing.T, doc bson.D) (*Mutator, *fakeDeps) { return m, deps } +// oneDecision is a fragment holding a decision, in builder coordinates. +func oneDecision() *backend.MicroflowFragment { + id := model.ID(types.GenerateID()) + split := µflows.ExclusiveSplit{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: id}, + Position: model.Point{X: 360, Y: 200}, + Size: model.Size{Width: 60, Height: 60}, + }} + return &backend.MicroflowFragment{Objects: []microflows.MicroflowObject{split}, Entry: id, Exit: id} +} + // oneActivity is a single-activity fragment in builder coordinates. func oneActivity() *backend.MicroflowFragment { id := model.ID(types.GenerateID()) @@ -296,10 +327,12 @@ func TestSplice_Refusals(t *testing.T) { op func(m *Mutator) error want string }{ - {"insert inside a loop", withLoop, loopFlows, - func(m *Mutator) error { return m.InsertAfter(model.ID(uid("inner")), oneActivity()) }, "inside a loop"}, - {"drop inside a loop", withLoop, loopFlows, - func(m *Mutator) error { return m.Drop(model.ID(uid("inner"))) }, "inside a loop"}, + // Inside a loop body the splice puts plain activities only + // (ako/mxcli#1693); a decision changes the body's shape. + {"insert a decision inside a loop", withLoop, loopFlows, + func(m *Mutator) error { return m.InsertAfter(model.ID(uid("inner")), oneDecision()) }, "inside a loop body"}, + {"move inside a loop", withLoop, loopFlows, + func(m *Mutator) error { return m.Move(model.ID(uid("inner")), model.Point{X: 1, Y: 1}) }, "inside a loop body"}, {"insert before a join", twoIn, twoInFlows, func(m *Mutator) error { return m.InsertBefore(model.ID(uid("b")), oneActivity()) }, "2 flows enter"}, {"insert after the end", line(), lineFlows(), @@ -325,11 +358,11 @@ func TestSplice_Refusals(t *testing.T) { if !bytes.Equal(before, after) { t.Error("a refused operation changed the document") } - // create or modify does not edit inside a loop either, so the - // in-loop refusal advises the alter that does: replace the loop. - if tc.want == "inside a loop" { - if !strings.Contains(err.Error(), "replace loop") || strings.Contains(err.Error(), "create or modify") { - t.Errorf("want advice to replace the loop, not create or modify: %v", err) + // The in-loop refusal advises the alter that makes any change of + // a body: replace the loop's body (ako/mxcli#1693). + if tc.want == "inside a loop body" { + if !strings.Contains(err.Error(), "replace the loop's body") || strings.Contains(err.Error(), "create or modify") { + t.Errorf("want advice to replace the loop's body, not create or modify: %v", err) } } }) @@ -587,16 +620,12 @@ func TestSplice_ReplaceInsideALoopIsInPlace(t *testing.T) { }) } - // Two activities for one would change the body's shape: refused. - t.Run("two for one refused", func(t *testing.T) { + // A decision for an activity would change the body's shape: refused. + t.Run("a decision for an activity refused", func(t *testing.T) { objs, flows := build() m, _ := newMutator(t, unit(objs, flows)) - frag := oneActivity() - second := oneActivity() - frag.Objects = append(frag.Objects, second.Objects...) - frag.Exit = second.Exit - err := m.Replace(model.ID(uid("inner")), frag) - if err == nil || !strings.Contains(err.Error(), "replace the whole loop") { + err := m.Replace(model.ID(uid("inner")), oneDecision()) + if err == nil || !strings.Contains(err.Error(), "replace the loop's body") { t.Fatalf("want the in-loop refusal, got %v", err) } }) diff --git a/mdl/backend/microflow_mutation.go b/mdl/backend/microflow_mutation.go index 81f6b03465..8b809e5b10 100644 --- a/mdl/backend/microflow_mutation.go +++ b/mdl/backend/microflow_mutation.go @@ -51,6 +51,13 @@ type MicroflowMutator interface { Replace(target model.ID, frag *MicroflowFragment) error // Drop removes target and joins its incoming flows to its successor. Drop(target model.ID) error + // ReplaceLoopBody replaces everything inside the loop target by the body + // of the one loop frag holds — the loop restated with its new body, as the + // builder made it — and keeps the loop itself: its $ID, position, + // iterator, list and the flows and notes around it (ako/mxcli#1693). The + // box keeps its size where the new body fits it, and grows where it does + // not. + ReplaceLoopBody(target model.ID, frag *MicroflowFragment) error // RemoveNotes removes the annotations attached to target, with their // lines, so that a Replace or Drop after it does not keep them // (ako/mxcli#859: a `create or modify` states an activity's notes). A @@ -84,6 +91,9 @@ type MicroflowMutator interface { // stored flow keeps saying what the script says (mendixlabs/mxcli#1598, // case 6). Pin(at model.Point) error + // PinInLoop is Pin for a node of the body of the loop: at is in the loop + // box's coordinates (ako/mxcli#1693). + PinInLoop(loop model.ID, at model.Point) error // SetHeader writes the document properties of declared — a // *microflows.Microflow or *microflows.Nanoflow built from the statement, // with every property it does not state carried from the stored one — onto diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go index da09867665..6c53bd5416 100644 --- a/mdl/executor/cmd_alter_flow.go +++ b/mdl/executor/cmd_alter_flow.go @@ -94,20 +94,23 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato fail := func(err error) error { return mdlerrors.NewValidation(fmt.Sprintf("alter %s %s: %s %s: %v", s.Kind(), s.Name, op.Op, op.Target, err)) } - // An activity inside a loop body is refused by the splice for any - // change of the body's shape. Say so first: the checks below see only the flow - // around the loop, so a fragment reading the iterator would otherwise - // be refused as reading an undeclared variable, which is not why. - // The one exception is an activity replaced by one activity, in place - // (ako/mxcli#1106): nothing in the body moves, so it needs no layout. - loop := a.enclosingLoop(target.ID) - inLoop := func() error { - return fail(fmt.Errorf("%s is inside the body of %s; alter does not splice inside a loop body "+ - "but to replace one activity by one of the same kind. %s", - target.Statement, loop.Statement, replaceLoopAdvice(s.Kind(), s.Name.String(), loop.Statement))) - } - if loop != nil && (op.Op != ast.AlterFlowReplace || op.SetCondition || op.SetNotes || op.ReplaceNotes || len(op.Body) != 1) { - return inLoop() + if op.Op == ast.AlterFlowReplaceBody { + // What is inside a loop, replaced; the loop itself stays + // (ako/mxcli#1693). + if err := a.replaceBody(ctx, mut, op, target); err != nil { + return fail(err) + } + continue + } + // Inside a loop body the splice inserts, drops and replaces plain + // activities (ako/mxcli#1693). Anything else changes the body's shape + // and is refused first, naming `replace body of` the loop — the + // checks below would otherwise refuse it for a reason that is not why. + inLoop := a.inLoopRefusal(target) + if inLoop != nil { + if why := inBodyTarget(op, target); why != "" { + return fail(inLoop(why)) + } } if op.ReplaceNotes { // The statement states the activity's notes, so the stored ones @@ -174,8 +177,10 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato return fail(err) } } - if loop != nil && (len(frag.Objects) != 1 || len(frag.Flows) > 0 || len(frag.AnnotationFlows) > 0) { - return inLoop() + if inLoop != nil { + if what := notPlainFragment(frag); what != "" { + return fail(inLoop("the fragment holds " + what)) + } } // A stated @position on the fragment is where it goes (ako/mxcli#818). frag.Placed = firstStatementPlaced(op.Body) @@ -277,23 +282,101 @@ type alterFlowContext struct { storedDesc ast.Statement } -// enclosingLoop returns the top-level loop whose body holds the stored -// object id, at any depth, or nil when id is not inside a loop. The top-level -// loop is the one to replace: the splice addresses nothing inside a loop, -// a nested loop included. -func (a *alterFlowContext) enclosingLoop(id model.ID) *mfmutator.Candidate { - for _, obj := range a.mf.ObjectCollection.Objects { - loop, ok := obj.(*microflows.LoopedActivity) - if !ok || !loopHolds(loop, id) { - continue +// inLoopRefusal returns, for a target inside a loop body, the refusal of an +// operation the splice cannot make there, given why; nil at top level. The +// refusal names the innermost loop around the target and `replace body of` +// it, which makes any change of that body (ako/mxcli#1693). +func (a *alterFlowContext) inLoopRefusal(target mfmutator.Candidate) func(why string) error { + loops := a.loopsAround(target.ID) + if len(loops) == 0 { + return nil + } + handle := "the loop" + for i := range a.cands { + if a.cands[i].ID == loops[0].ID { + handle = a.cands[i].Statement } - for i := range a.cands { - if a.cands[i].ID == loop.ID { - return &a.cands[i] - } + } + s := a.stmt + return func(why string) error { + return fmt.Errorf("%s is inside the body of %s, and %s; %s. %s", target.Statement, handle, why, + strings.Replace(plainInBodyNote, "the splice", "alter", 1), replaceBodyAdvice(s.Kind(), s.Name.String(), handle)) + } +} + +// inBodyTarget says why an operation aimed at target, inside a loop body, is +// not one the splice makes there, or "" when it may be: an insert next to an +// activity or a loop, or a drop or replace of an activity without a custom +// error handler. A decision's condition, notes set in place on a decision or +// an end event, or an operation on any other node change the body's shape. +func inBodyTarget(op *ast.AlterFlowOperation, target mfmutator.Candidate) string { + if op.SetCondition { + return "its condition would change" + } + switch o := target.Object.(type) { + case *microflows.ActionActivity: + if microflows.IsCustomErrorHandling(microflows.ObjectErrorHandlingType(o)) && + (op.Op == ast.AlterFlowDrop || op.Op == ast.AlterFlowReplace) { + return "it has an error handler" + } + if op.SetNotes { + return "its notes would be set in place" + } + return "" + case *microflows.LoopedActivity: + if op.Op == ast.AlterFlowInsertAfter || op.Op == ast.AlterFlowInsertBefore { + return "" + } + return "it is a loop" + case *microflows.ExclusiveSplit, *microflows.InheritanceSplit: + return "it is a decision" + case *microflows.EndEvent: + return "it is a return" + case *microflows.ErrorEvent: + return "it is a raise error" + case *microflows.BreakEvent: + return "it is a break" + case *microflows.ContinueEvent: + return "it is a continue" + } + return "it is not an activity" +} + +// notPlainFragment says what a built fragment holds that is not a plain +// activity — the first decision, loop, event or error handler — or "" when it +// is a run of plain activities (with their notes), the only fragment the +// splice puts into a loop body (ako/mxcli#1693). +func notPlainFragment(frag *backend.MicroflowFragment) string { + for _, obj := range frag.Objects { + switch obj.(type) { + case *microflows.ActionActivity, *microflows.Annotation: + case *microflows.ExclusiveSplit, *microflows.InheritanceSplit: + return "a decision" + case *microflows.LoopedActivity: + return "a loop" + case *microflows.EndEvent: + return "a return" + case *microflows.ErrorEvent: + return "a raise error" + case *microflows.BreakEvent: + return "a break" + case *microflows.ContinueEvent: + return "a continue" + case *microflows.ExclusiveMerge: + return "a merge" + default: + return "a " + strings.TrimPrefix(fmt.Sprintf("%T", obj), "*microflows.") } } - return nil + for _, f := range frag.Flows { + if f.IsErrorHandler { + return "an activity with an error handler" + } + } + if frag.ExitCase != nil { + return "a decision" + } + return "" } // loopsAround returns the loops whose body holds the stored object id, the diff --git a/mdl/executor/cmd_flow_loop_advice_pedapp_test.go b/mdl/executor/cmd_flow_loop_advice_pedapp_test.go index 536d1e6043..bf27124c52 100644 --- a/mdl/executor/cmd_flow_loop_advice_pedapp_test.go +++ b/mdl/executor/cmd_flow_loop_advice_pedapp_test.go @@ -11,15 +11,16 @@ import ( "github.com/mendixlabs/mxcli/mdl/visitor" ) -// A change of a loop body's shape is refused by both editing modes: `create or -// modify` under mdl 1 (the splice does not edit inside a loop) and `alter` -// (the mutator does not splice inside a loop). Each refusal used to send the -// reader to the other — create or modify said "change activities with alter", -// alter said "rewrite the loop with create or modify" — and an alter of an -// activity inside the loop that reads the iterator failed first on "the -// fragment uses $It, which is not declared on the path", before the real -// reason. What works is replacing the whole loop with alter, so both -// refusals name that statement, with the stored loop's own handle. +// A change of a loop body's shape the splice cannot make — a decision added +// in it — is refused by both editing modes: `create or modify` under mdl 1 +// and `alter`. Each refusal used to send the reader to the other — create or +// modify said "change activities with alter", alter said "rewrite the loop +// with create or modify" — and an alter of an activity inside the loop that +// reads the iterator failed first on "the fragment uses $It, which is not +// declared on the path", before the real reason. Both refusals now name the +// statement that makes the change, `replace body of` the loop, by the stored +// loop's own handle (ako/mxcli#1693; replacing the whole loop, which they +// named before, re-minted every identity in it). const loopAdviceFlow = `mdl 1; create microflow MyFirstModule.LoopAdvice ($Items: List of FeedbackModule.Feedback) @@ -57,9 +58,13 @@ func loopAdviceUnit(t *testing.T, exec *Executor) []byte { return nil } -// The advice both refusals give: the alter that replaces the stored loop, -// addressed by its handle. -const loopAdviceReplace = "replace loop $It in $Items with begin" +// The advice both refusals give: the alter that replaces the stored loop's +// body, addressed by its handle. +const loopAdviceReplace = "replace body of loop $It in $Items with begin" + +// loopAdviceDecision is a decision for the loop body, which the splice does +// not put in one. +const loopAdviceDecision = "if $It/Subject = empty then log info node 'X' 'no subject'; end if;" // checkSays asserts `check -p`'s flow verdict for src carries want. func checkSays(t *testing.T, exec *Executor, src, want string) { @@ -77,18 +82,18 @@ func checkSays(t *testing.T, exec *Executor, src, want string) { } } -func TestFlowLoopRefusals_PointAtReplaceLoop(t *testing.T) { +func TestFlowLoopRefusals_PointAtReplaceBody(t *testing.T) { exec, out, _ := openPedAppCopy(t) if err := agreeExec(t, exec, loopAdviceFlow); err != nil { t.Fatalf("setup: %v\n%s", err, out.String()) } stored := loopAdviceUnit(t, exec) - t.Run("create or modify names alter replace loop", func(t *testing.T) { + t.Run("create or modify names alter replace body", func(t *testing.T) { src := strings.Replace(loopAdviceFlow, "create microflow", "create or modify microflow", 1) - // An insert inside the body: a change of the body's shape, which - // needs a layout (an in-place edit is spliced, ako/mxcli#1106). - src = strings.Replace(src, "commit $It;", "commit $It;\n log info node 'X' 'committed';", 1) + // A decision inside the body: a change of the body's shape the + // splice does not make (a plain activity it splices, ako/mxcli#1693). + src = strings.Replace(src, "commit $It;", "commit $It;\n "+loopAdviceDecision, 1) // check predicts exec's refusal, with the same advice. checkSays(t, exec, src, loopAdviceReplace) err := agreeExec(t, exec, src) @@ -96,9 +101,12 @@ func TestFlowLoopRefusals_PointAtReplaceLoop(t *testing.T) { t.Fatal("want the in-loop change refused under mdl 1") } msg := err.Error() - if !strings.Contains(msg, "alter microflow MyFirstModule.LoopAdvice {") { + if !strings.Contains(msg, "alter microflow MyFirstModule.LoopAdvice {") || !strings.Contains(msg, loopAdviceReplace) { t.Errorf("the refusal does not spell out the alter to run: %s", msg) } + if !strings.Contains(msg, "a decision is added to it") { + t.Errorf("the refusal does not say what is out of reach: %s", msg) + } // mendixlabs/mxcli#1517: the deliberate rebuild is named too, as // every other splice refusal names it. if !strings.Contains(msg, "drop the microflow and create it in the same script") { @@ -114,12 +122,10 @@ func TestFlowLoopRefusals_PointAtReplaceLoop(t *testing.T) { } }) - t.Run("alter of an activity in the loop names replace loop, not scope", func(t *testing.T) { + t.Run("alter of an activity in the loop names replace body, not scope", func(t *testing.T) { for _, op := range []string{ - // Replaced by two activities: the body changes shape. - "replace commit $It with begin commit $It; log info node 'X' 'committed'; end;", - "insert after commit $It begin log info node 'X' 'committed'; end;", - "drop commit $It;", + "replace commit $It with begin " + loopAdviceDecision + " end;", + "insert after commit $It begin " + loopAdviceDecision + " end;", } { src := "mdl 1;\nalter microflow MyFirstModule.LoopAdvice {\n " + op + "\n};" checkSays(t, exec, src, loopAdviceReplace) @@ -131,8 +137,9 @@ func TestFlowLoopRefusals_PointAtReplaceLoop(t *testing.T) { if strings.Contains(msg, "not declared on the path") { t.Errorf("%s: the loop iterator is reported out of scope before the real reason: %s", op, msg) } - if !strings.Contains(msg, "inside the body of loop $It in $Items") || !strings.Contains(msg, loopAdviceReplace) { - t.Errorf("%s: want the in-loop reason and the replace-loop advice, got: %s", op, msg) + if !strings.Contains(msg, "inside the body of loop $It in $Items") || !strings.Contains(msg, loopAdviceReplace) || + !strings.Contains(msg, "the fragment holds a decision") { + t.Errorf("%s: want the in-loop reason and the replace-body advice, got: %s", op, msg) } if strings.Contains(msg, "create or modify") { t.Errorf("%s: alter's refusal sends the reader back to create or modify: %s", op, msg) @@ -143,9 +150,9 @@ func TestFlowLoopRefusals_PointAtReplaceLoop(t *testing.T) { } }) - // In a nested loop the loop to replace is the top-level one: the splice - // addresses nothing inside a loop, the inner loop included. - t.Run("nested loop names the top-level loop", func(t *testing.T) { + // In a nested loop the loop named is the one whose body the change is + // in: replace body addresses any loop, a nested one included. + t.Run("nested loop names the inner loop", func(t *testing.T) { if err := agreeExec(t, exec, `mdl 1; create microflow MyFirstModule.LoopAdviceNested ($Items: List of FeedbackModule.Feedback, $Others: List of FeedbackModule.Feedback) begin @@ -159,10 +166,11 @@ begin end;`); err != nil { t.Fatalf("setup: %v", err) } - err := agreeExec(t, exec, "mdl 1;\nalter microflow MyFirstModule.LoopAdviceNested {\n drop commit $Other;\n};") - if err == nil || !strings.Contains(err.Error(), "inside the body of loop $It in $Items") || - !strings.Contains(err.Error(), loopAdviceReplace) { - t.Fatalf("want the top-level loop named, got %v", err) + err := agreeExec(t, exec, "mdl 1;\nalter microflow MyFirstModule.LoopAdviceNested {\n"+ + " insert after commit $Other begin if $Other/Subject = empty then log info node 'X' 'none'; end if; end;\n};") + if err == nil || !strings.Contains(err.Error(), "inside the body of loop $Other in $Others") || + !strings.Contains(err.Error(), "replace body of loop $Other in $Others with begin") { + t.Fatalf("want the inner loop named, got %v", err) } }) @@ -195,16 +203,14 @@ end;`); err != nil { } }) - // Control: the advice works — the replace-loop alter is accepted and writes - // the change. + // Control: the advice works — the replace-body alter is accepted and + // writes the decision into the stored loop. t.Run("control: the advised alter applies", func(t *testing.T) { err := agreeExec(t, exec, `mdl 1; alter microflow MyFirstModule.LoopAdvice { - replace loop $It in $Items with begin - loop $It in $Items - begin - commit $It without events; - end loop; + replace body of loop $It in $Items with begin + commit $It without events; + `+loopAdviceDecision+` end; };`) if err != nil { @@ -215,7 +221,7 @@ alter microflow MyFirstModule.LoopAdvice { if err := afRun(t, exec, "describe microflow MyFirstModule.LoopAdvice;"); err != nil { t.Fatal(err) } - if !strings.Contains(buf.String(), "commit $It without events;") { + if !strings.Contains(buf.String(), "commit $It without events;") || !strings.Contains(buf.String(), "if $It/Subject = empty then") { t.Errorf("the alter did not write the change:\n%s", buf.String()) } }) diff --git a/mdl/executor/cmd_flow_loop_body_edit_pedapp_test.go b/mdl/executor/cmd_flow_loop_body_edit_pedapp_test.go index ef76b5aa36..856646225e 100644 --- a/mdl/executor/cmd_flow_loop_body_edit_pedapp_test.go +++ b/mdl/executor/cmd_flow_loop_body_edit_pedapp_test.go @@ -167,14 +167,16 @@ func TestCreateOrModify_EditInsideLoopBody(t *testing.T) { edited = src }) - // Controls: a change to the body's shape still refuses, writing nothing. + // Controls: a change to the body's shape the splice does not make — a + // decision or a loop added in it — still refuses, writing nothing. (An + // activity inserted, dropped or moved is spliced since ako/mxcli#1693, + // flow_splice_loop_body_1693_pedapp_test.go.) stored := loopEditBytes(t, exec) for name, src := range map[string]string{ - "insert": strings.Replace(edited, "log info node 'X' 'in loop';", "log info node 'X' 'in loop';\n log info node 'X' 'more';", 1), - "drop": strings.Replace(edited, "log info node 'X' 'in loop';", "", 1), - "kind": strings.Replace(edited, "log info node 'X' 'in loop';", "declare $T string = 'x';", 1), - "swap": strings.Replace(edited, "set $S = $S + toString(length($S));\n log info node 'X' 'in loop';", - "log info node 'X' 'in loop';\n set $S = $S + toString(length($S));", 1), + "decision": strings.Replace(edited, "log info node 'X' 'in loop';", + "if $It/Subject = empty then\n log info node 'X' 'in loop';\n end if;", 1), + "loop": strings.Replace(edited, "log info node 'X' 'in loop';", + "loop $Inner in $Items\n begin\n log info node 'X' 'in loop';\n end loop;", 1), } { t.Run("control: "+name+" refused", func(t *testing.T) { if src == edited { diff --git a/mdl/executor/cmd_flow_modify.go b/mdl/executor/cmd_flow_modify.go index 1625392db9..d6ceb4329b 100644 --- a/mdl/executor/cmd_flow_modify.go +++ b/mdl/executor/cmd_flow_modify.go @@ -50,8 +50,8 @@ import ( // differs from the stored one moves the stored node — its position only, its // flows keep their ends and curves (cmd_flow_modify_patch.go). // -// What the patch cannot express — a change inside a loop body or an error -// handler, a redrawn connector, a reordered parameter — is not rebuilt under +// What the patch cannot express — a decision added or changed inside a loop +// body, a redrawn connector, a reordered parameter — is not rebuilt under // `mdl 1`: the whole-document rebuild is what reset curves, dropped merges and // moved element IDs on Studio Pro-authored flows (#721 class A). It is refused // with the reason instead. Under mdl 0 the rebuild still runs, with the @@ -135,7 +135,8 @@ type notSpliceable struct { reason string // loopHandle is the stored loop's handle (`loop $It in $Items`) when the // change is inside that loop's body: the refusal then advises replacing - // the loop with alter, the one statement that can make the change. + // the loop's body with alter, the one statement that can make the change + // (ako/mxcli#1693). loopHandle string } @@ -307,6 +308,7 @@ func planFlowModify(ctx *ExecContext, d *flowDecl) (*flowPlan, error) { var targets []mfmutator.Candidate var moves []flowMove var freeNotes *[]ast.MicroflowAnnotation + var bodyPins []bodyPin // A body that states what describe prints for the stored one — the // statement re-run unchanged, or a describe executed back — has nothing to // patch, and needs no build to say so (ako/mxcli#870): every statement @@ -326,7 +328,7 @@ func planFlowModify(ctx *ExecContext, d *flowDecl) (*flowPlan, error) { } } body := describedMemberSpellings(ctx, d.params, d.body, varTypes) - if ops, targets, moves, freeNotes, err = diffFlowBody(a, body, storedBody(stored), d.returnVar); err != nil { + if ops, targets, moves, freeNotes, bodyPins, err = diffFlowBody(a, body, storedBody(stored), d.returnVar); err != nil { return nil, asNotSpliceable(err) } } @@ -346,10 +348,12 @@ func planFlowModify(ctx *ExecContext, d *flowDecl) (*flowPlan, error) { var stated []model.Point if flowRebuildRefused.Applies(ctx.LanguageVersion) { stated = statedPoints(d) + } else { + bodyPins = nil } p := &flowPlan{a: a, ops: ops, moves: moves, storedFolder: storedFolderOf(stored)} if len(ops) > 0 || len(moves) > 0 || headerChanged || freeNotes != nil { - if p.mut, err = patch(ctx, a, ops, targets, moves, stated); err != nil { + if p.mut, err = patch(ctx, a, ops, targets, moves, stated, bodyPins); err != nil { return nil, cannotSplice("%v", err) } if headerChanged { @@ -777,7 +781,7 @@ func nanoflowDecl(s *ast.CreateNanoflowStmt) *flowDecl { // Targets are the stored activities, located by the @position describe printed // for each statement. A run whose stored end cannot be located, or that the // splice cannot express, is a notSpliceable error. -func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement, returnVar string) ([]*ast.AlterFlowOperation, []mfmutator.Candidate, []flowMove, *[]ast.MicroflowAnnotation, error) { +func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement, returnVar string) ([]*ast.AlterFlowOperation, []mfmutator.Candidate, []flowMove, *[]ast.MicroflowAnnotation, []bodyPin, error) { // Free annotations — notes wired to nothing — belong to the flow, not to // the statement describe happens to print them above (the first one), so // they are compared as a whole and kept out of the statement match: an @@ -792,11 +796,11 @@ func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement var freeNotes *[]ast.MicroflowAnnotation if !declaredMatches(declaredFree, storedFree) { if hint := misplacedNote(declaredFree, storedFree, stored); hint != "" { - return nil, nil, nil, nil, cannotSplice("%s", hint) + return nil, nil, nil, nil, nil, cannotSplice("%s", hint) } for _, n := range declaredFree { if n.Label != "" { - return nil, nil, nil, nil, cannotSplice("the free annotations change (%s), and one of them has an id (%s); "+ + return nil, nil, nil, nil, nil, cannotSplice("the free annotations change (%s), and one of them has an id (%s); "+ "the splice sets free notes that are attached to nothing", freeNoteChange(declaredFree, storedFree), n.Label) } } @@ -818,11 +822,11 @@ func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement pd := &patchDiff{loc: newStoredLocator(a)} if err := pd.statements(declared, stored); err != nil { - return nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, err } pd.startEvent(a, declared, declaredStart) if err := pd.followingEnd(declared, stored); err != nil { - return nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, err } // Drops go last. Each operation's scope check runs against the flow as the // earlier operations left it, so a stored activity whose output only a @@ -839,7 +843,7 @@ func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement } } } - return ops, targets, pd.moves, freeNotes, nil + return ops, targets, pd.moves, freeNotes, pd.bodyPins, nil } // withImplicitEnd states the end a declared body falls through to as the @@ -874,6 +878,9 @@ type patchDiff struct { ops []*ast.AlterFlowOperation targets []mfmutator.Candidate moves []flowMove + // bodyPins are the points the declared loop bodies state their nodes at + // (pinBody). + bodyPins []bodyPin } func (pd *patchDiff) add(op ast.AlterFlowOpKind, c mfmutator.Candidate, body []ast.MicroflowStatement) { @@ -992,16 +999,18 @@ func (pd *patchDiff) gap(ins []ast.MicroflowStatement, stored []ast.MicroflowSta } } // A stored loop declared again with a new body, in a run with other - // changes: replacing the run would rebuild the loop just as replacing it - // alone would, so it is refused the same way. Asked only of the 1:1 run, - // the refusal depended on what happened to change NEXT to the loop - // (ako/mxcli#942). - for _, d := range ins { - for _, st := range del { - if sameLoopShell(d, st) { - return pd.loopBodyChanged(st) - } + // changes: replacing the run would rebuild the loop, so the run is split + // at the loop, which is diffed in place like a loop on its own, and the + // changes on either side of it are runs of their own (ako/mxcli#942, + // ako/mxcli#1693). + if i, j, ok := loopPair(ins, del); ok { + if err := pd.gap(ins[:i], stored, si, si+j); err != nil { + return err + } + if err := pd.gap(ins[i:i+1], stored, si+j, si+j+1); err != nil { + return err } + return pd.gap(ins[i+1:], stored, si+j+1, sEnd) } // A stored `if` declared again with its condition but neither as the same // shell nor with only its condition changed has its else or a branch's @@ -1254,9 +1263,8 @@ func (pd *patchDiff) conditionEdit(declared, stored *ast.IfStmt) error { // diffed branch by branch, rather than left to merge with the changes around // it into one run: a change before a guard and one inside its then-branch were // one replace whose fragment held the guard's return, which the splice cannot -// insert (#859). A loop is paired this way only when its body can be edited in -// place (editableLoopBody, ako/mxcli#1106); any other change inside a loop is -// refused either way. +// insert (#859). A loop is paired the same way (lcsStatements), and its body +// diffed inside it (inLoopBody, ako/mxcli#1693). func sameBlockShell(declared, stored ast.MicroflowStatement) bool { if _, _, ok := sameIfShell(declared, stored); ok { return true @@ -1302,16 +1310,10 @@ func sameLoopShell(declared, stored ast.MicroflowStatement) bool { } // loopBody diffs a stored loop declared again with a change inside its body -// (ako/mxcli#1106). A body of the same shape — the same statements in the same -// order, each as stored or the same kind of activity with new values, drawn -// and annotated as stored — is spliced: each changed activity is replaced in -// place, inside the loop, and needs no layout. Anything else would add, -// remove, move or redraw a node whose coordinates are relative to the loop -// box, and is refused (loopBodyChanged), naming the top-level loop. +// (ako/mxcli#1106, ako/mxcli#1693). The loop itself stays — its $ID, where it +// is drawn, its iterator and list — and its body is diffed as a statement list +// of its own (inLoopBody). func (pd *patchDiff) loopBody(declared, stored ast.MicroflowStatement) error { - if !editableLoopBody(declared, stored) { - return pd.loopBodyChanged(stored) - } c, err := pd.loc.locate(stored) if err != nil { return err @@ -1320,92 +1322,282 @@ func (pd *patchDiff) loopBody(declared, stored ast.MicroflowStatement) error { if !ok { return cannotSplice("the stored %s is not drawn as a loop", describeAt(stored)) } + if !sameExceptPositions(loopShell(declared), loopShell(stored)) { + return redrawn(stored) + } if err := pd.movedNode(declared, stored); err != nil { return err } - return pd.inLoopBody(loop, loopStatements(declared), loopStatements(stored)) + return pd.inLoopBody(c, loop, stored, loopStatements(declared), loopStatements(stored)) } -// inLoopBody adds the in-place replaces of one loop body, already shown to be -// of the same shape (editableLoopBody), recursing into a nested loop. -func (pd *patchDiff) inLoopBody(loop *microflows.LoopedActivity, declared, stored []ast.MicroflowStatement) error { - loc := pd.loc.within(loop) - for i := range declared { - d, s := declared[i], stored[i] - if declaredMatches(d, s) { - continue +// loopShell is a copy of a loop or while statement without its body. +func loopShell(st ast.MicroflowStatement) ast.MicroflowStatement { + switch l := st.(type) { + case *ast.LoopStmt: + c := *l + c.Body = nil + return &c + case *ast.WhileStmt: + c := *l + c.Body = nil + return &c + } + return st +} + +// loopPair finds, in a run of unmatched statements, a declared loop that is a +// stored one restated (sameLoopShell): its index in ins and in del. +func loopPair(ins, del []ast.MicroflowStatement) (int, int, bool) { + for i, d := range ins { + for j, st := range del { + if sameLoopShell(d, st) { + return i, j, true + } + } + } + return 0, 0, false +} + +// inLoopBody diffs the body of the stored loop lc (drawn as loop, described +// as at) the way the top level is diffed — statements matched by a longest +// common subsequence, each run between two matched ones an insert, replace or +// drop aimed at the stored body activity — but for plain activities only +// (ako/mxcli#1693). The mutator lays an inserted run out among the body's +// nodes, in the loop box's coordinates, and grows the box when the body no +// longer fits it. A nested loop restated with a new body is diffed the same +// way, inside its own box. +// +// What changes the body's shape — a decision, a loop, a return, break or +// continue, a raise error or an error handler added, changed or taken out — +// and a body node drawn elsewhere are refused (bodyRefusal), naming +// `replace body of` the loop. A stored body that is empty has no activity to +// insert next to: the declared one is put in it by replace body, which loses +// nothing there. +func (pd *patchDiff) inLoopBody(lc mfmutator.Candidate, loop *microflows.LoopedActivity, at ast.MicroflowStatement, + declared, stored []ast.MicroflowStatement) error { + outer := pd.loc + pd.loc = outer.within(loop) + defer func() { pd.loc = outer }() + pd.pinBody(lc.ID, declared) + if len(stored) == 0 { + if len(declared) > 0 { + pd.add(ast.AlterFlowReplaceBody, lc, declared) } - if sameLoopShell(d, s) { - c, err := loc.locate(s) + return nil + } + pairs := lcsStatements(declared, stored) + di, si := 0, 0 + for p := 0; p <= len(pairs); p++ { + dEnd, sEnd := len(declared), len(stored) + if p < len(pairs) { + dEnd, sEnd = pairs[p][0], pairs[p][1] + } + if err := pd.bodyGap(lc, at, declared[di:dEnd], stored, si, sEnd); err != nil { + return err + } + if p < len(pairs) { + d, st := declared[pairs[p][0]], stored[pairs[p][1]] + if !declaredMatches(d, st) { + if sameExceptPositions(d, st) { + return bodyRefusal(lc, at, fmt.Sprintf("the %s in it is drawn elsewhere, and the splice moves no node "+ + "inside a loop body", describeAt(st))) + } + if err := pd.bodyGap(lc, at, []ast.MicroflowStatement{d}, stored, pairs[p][1], pairs[p][1]+1); err != nil { + return err + } + } + di, si = pairs[p][0]+1, pairs[p][1]+1 + } + } + return nil +} + +// bodyGap is gap for a run of unmatched statements inside the body of the +// stored loop lc: declared ins where stored[si:sEnd] is stored. +func (pd *patchDiff) bodyGap(lc mfmutator.Candidate, at ast.MicroflowStatement, ins, stored []ast.MicroflowStatement, si, sEnd int) error { + del := stored[si:sEnd] + if len(ins) == 0 && len(del) == 0 { + return nil + } + if len(ins) == 1 && len(del) == 1 { + if sameLoopShell(ins[0], del[0]) { + // A nested loop restated with a change in its body: the same rules, + // inside its own box. The loop is a body node, so it is not moved. + c, err := pd.loc.locate(del[0]) if err != nil { return err } inner, ok := c.Object.(*microflows.LoopedActivity) if !ok { - return cannotSplice("the stored %s is not drawn as a loop", describeAt(s)) + return cannotSplice("the stored %s is not drawn as a loop", describeAt(del[0])) + } + if !declaredMatches(loopShell(ins[0]), loopShell(del[0])) { + return bodyRefusal(lc, at, fmt.Sprintf("the %s in it is drawn elsewhere or its connectors are redrawn, and "+ + "the splice moves no node inside a loop body", describeAt(del[0]))) } - if err := pd.inLoopBody(inner, loopStatements(d), loopStatements(s)); err != nil { + return pd.inLoopBody(c, inner, del[0], loopStatements(ins[0]), loopStatements(del[0])) + } + if replaceableInPlace(ins[0], del[0]) { + // One activity changed: replaced in place, drawn where it is + // (ako/mxcli#1106). The stored notes stay on the replacement. + c, err := pd.loc.locate(del[0]) + if err != nil { return err } - continue + body, _, err := keepStoredNotes(del[0], ins) + if err != nil { + return err + } + pd.add(ast.AlterFlowReplace, c, body) + return nil + } + } + if i, j, ok := loopPair(ins, del); ok { + if err := pd.bodyGap(lc, at, ins[:i], stored, si, si+j); err != nil { + return err } - c, err := loc.locate(s) + if err := pd.bodyGap(lc, at, ins[i:i+1], stored, si+j, si+j+1); err != nil { + return err + } + return pd.bodyGap(lc, at, ins[i+1:], stored, si+j+1, sEnd) + } + for _, st := range del { + if what := notPlainInBody(st); what != "" { + return bodyRefusal(lc, at, fmt.Sprintf("%s in it (the %s) is changed or taken out", what, describeAt(st))) + } + } + for _, st := range ins { + if what := notPlainInBody(st); what != "" { + return bodyRefusal(lc, at, fmt.Sprintf("%s is added to it", what)) + } + } + cands := make([]mfmutator.Candidate, len(del)) + for i, st := range del { + c, err := pd.loc.locate(st) + if err != nil { + return err + } + cands[i] = c + } + if len(del) == 0 { + op, c, err := insertAnchor(pd.loc, stored, si, sEnd) if err != nil { return err } - // The stored notes stay on the replacement (editableLoopBody showed - // the declared ones are the same), so the builder does not draw them. - body, _, err := keepStoredNotes(s, []ast.MicroflowStatement{d}) + pd.add(op, c, ins) + return nil + } + rest, restStmts := cands, del + if len(ins) > 0 { + body, replaceNotes, err := keepStoredNotes(del[0], ins) if err != nil { return err } - pd.add(ast.AlterFlowReplace, c, body) + pd.add(ast.AlterFlowReplace, cands[0], body) + pd.ops[len(pd.ops)-1].ReplaceNotes = replaceNotes + rest, restStmts = cands[1:], del[1:] + } + for i, c := range rest { + var notes []ast.MicroflowAnnotation + if ann := statementAnnotations(restStmts[i]); ann != nil { + notes = ann.Notes + } + for _, n := range notes { + if n.Label != "" { + return cannotSplice("the %s dropped at (%d, %d) carries an annotation shared with another activity (id: %s), "+ + "which dropping it would take from that activity too", + statementKind(restStmts[i]), c.Object.GetPosition().X, c.Object.GetPosition().Y, n.Label) + } + } + pd.add(ast.AlterFlowDrop, c, nil) + pd.ops[len(pd.ops)-1].ReplaceNotes = len(notes) > 0 } return nil } -// editableLoopBody reports whether the declared loop differs from the stored -// one only by activities that can be replaced in place: the same number of -// statements, each matching its stored one, or a nested loop of which the -// same holds, or the same kind of plain activity — not a decision, a return, -// a break or continue, nor one with a custom error handler — whose -// annotations (where it is drawn, its connectors, caption, colour and notes) -// state nothing the stored one does not have. -func editableLoopBody(declared, stored ast.MicroflowStatement) bool { - if !sameLoopShell(declared, stored) { - return false +// notPlainInBody says what a statement is when it is not a plain activity — +// one the splice inserts, drops or replaces inside a loop body — and "" when +// it is one. Each of the others changes where the body's paths split, end or +// rejoin. +func notPlainInBody(st ast.MicroflowStatement) string { + switch st.(type) { + case *ast.IfStmt, *ast.EnumSplitStmt, *ast.InheritanceSplitStmt: + return "a decision" + case *ast.LoopStmt, *ast.WhileStmt: + return "a loop" + case *ast.ReturnStmt: + return "a return" + case *ast.RaiseErrorStmt: + return "a raise error" + case *ast.BreakStmt: + return "a break" + case *ast.ContinueStmt: + return "a continue" + case *ast.MergeStmt, *ast.JoinStmt: + return "a merge" + } + if customErrorHandler(st) { + return "an activity with an error handler" } - dBody, sBody := loopStatements(declared), loopStatements(stored) - if len(dBody) != len(sBody) { - return false + return "" +} + +// plainInBodyNote is the part of every in-body refusal that says what the +// splice does make there. +const plainInBodyNote = "inside a loop body the splice inserts, drops and replaces plain activities only — not a " + + "decision, a loop, a return, break or continue, a raise error, or an activity with an error handler" + +// bodyRefusal refuses a change inside the body of the stored loop lc, +// described as at, saying why; the refusal names `replace body of` the loop, +// which makes any change of a body (ako/mxcli#1693). +func bodyRefusal(lc mfmutator.Candidate, at ast.MicroflowStatement, why string) error { + return ¬Spliceable{ + reason: fmt.Sprintf("the %s changes inside its body: %s; %s", describeAt(at), why, plainInBodyNote), + loopHandle: lc.Statement, } - for i := range dBody { - d, s := dBody[i], sBody[i] - if declaredMatches(d, s) { +} + +// pinBody records the points the declared body of the stored loop lc states +// its nodes are drawn at, in the loop box's coordinates: each statement's +// @position and its notes' positions. Making room in that body then moves no +// node away from where the script draws it — under mdl 1, as at top level +// (statedPoints). +func (pd *patchDiff) pinBody(loop model.ID, declared []ast.MicroflowStatement) { + for _, st := range declared { + ann := statementAnnotations(st) + if ann == nil { continue } - if sameLoopShell(d, s) { - if !editableLoopBody(d, s) { - return false - } - continue + if ann.Position != nil { + pd.bodyPins = append(pd.bodyPins, bodyPin{loop: loop, at: model.Point{X: ann.Position.X, Y: ann.Position.Y}}) } - if !replaceableInPlace(d, s) { - return false + for _, n := range ann.Notes { + if n.Position != nil { + pd.bodyPins = append(pd.bodyPins, bodyPin{loop: loop, at: model.Point{X: n.Position.X, Y: n.Position.Y}}) + } } } - return true } +// bodyPin is a point a declared loop body states one of its nodes is drawn +// at, in the box of the stored loop. +type bodyPin struct { + loop model.ID + at model.Point +} + +// replaceableInPlace reports whether a declared statement is the stored one +// changed so that it can be swapped in place inside a loop body: the same kind +// of plain activity — not a decision, a return, a break or continue, nor one +// with a custom error handler — whose annotations (where it is drawn, its +// connectors, caption, colour and notes) state nothing the stored one does +// not have (ako/mxcli#1106). func replaceableInPlace(declared, stored ast.MicroflowStatement) bool { if statementKind(declared) != statementKind(stored) || sameExceptPositions(declared, stored) { return false } - switch declared.(type) { - case *ast.IfStmt, *ast.ReturnStmt, *ast.LoopStmt, *ast.WhileStmt, *ast.BreakStmt, *ast.ContinueStmt: - return false - } - if customErrorHandler(declared) || customErrorHandler(stored) { + if notPlainInBody(declared) != "" || notPlainInBody(stored) != "" { return false } if da := statementAnnotations(declared); da != nil { @@ -1446,20 +1638,6 @@ func loopStatements(st ast.MicroflowStatement) []ast.MicroflowStatement { return nil } -// loopBodyChanged refuses a change inside the body of the stored loop. The -// splice does not edit inside a loop, and the engine would take it as a replace -// of the whole loop: every node in it rebuilt, renumbered and redrawn — the -// rebuild's loss, confined to the loop but no less silent. An explicit -// `alter … replace loop` states that loss, so the refusal names it. -func (pd *patchDiff) loopBodyChanged(stored ast.MicroflowStatement) error { - why := ¬Spliceable{reason: fmt.Sprintf("the %s changes inside its body; the splice does not edit inside a loop, "+ - "and replacing the whole loop would rebuild every node it holds", describeAt(stored))} - if c, err := pd.loc.locate(stored); err == nil { - why.loopHandle = c.Statement - } - return why -} - // describeAt names a stored statement and where it is drawn, for a message. func describeAt(st ast.MicroflowStatement) string { if p := statementAnnotations(st); p != nil && p.Position != nil { @@ -1684,9 +1862,11 @@ func lcsStatements(declared, stored []ast.MicroflowStatement) [][2]int { // (mendixlabs/mxcli#1489): a change beside it must not merge // with it into a replace, which would take the handler out. w[i][j] = 2 - case editableLoopBody(declared[i], stored[j]): - // A loop edited in place inside is that loop: a change beside - // it must not merge with it into a replace (ako/mxcli#1106). + case sameLoopShell(declared[i], stored[j]): + // A loop with a change in its body is that loop: a change + // beside it must not merge with it into a replace, which would + // rebuild it (ako/mxcli#1106, ako/mxcli#1693). Whether the + // change can be made inside it is inLoopBody's to say. w[i][j] = 2 case conditionChanged(declared[i], stored[j]): w[i][j] = 1 @@ -1815,7 +1995,7 @@ func patchSummary(ops []*ast.AlterFlowOperation, moves []flowMove, set []string) switch op.Op { case ast.AlterFlowInsertAfter, ast.AlterFlowInsertBefore: ins++ - case ast.AlterFlowReplace: + case ast.AlterFlowReplace, ast.AlterFlowReplaceBody: rep++ case ast.AlterFlowDrop: drop++ diff --git a/mdl/executor/cmd_flow_modify_patch.go b/mdl/executor/cmd_flow_modify_patch.go index 908016b05a..333c34f4c4 100644 --- a/mdl/executor/cmd_flow_modify_patch.go +++ b/mdl/executor/cmd_flow_modify_patch.go @@ -67,7 +67,7 @@ const endSpacing = HorizontalSpacing + HorizontalSpacing/2 // point the script states a node at is pinned, so an operation that would // have to move one to make room is refused (mendixlabs/mxcli#1598, case 6). func patch(ctx *ExecContext, a *alterFlowContext, ops []*ast.AlterFlowOperation, targets []mfmutator.Candidate, - moves []flowMove, stated []model.Point) (backend.MicroflowMutator, error) { + moves []flowMove, stated []model.Point, bodyPins []bodyPin) (backend.MicroflowMutator, error) { mut, err := ctx.Backend.OpenMicroflowForMutation(a.mf.ID) if err != nil { return nil, err @@ -88,6 +88,11 @@ func patch(ctx *ExecContext, a *alterFlowContext, ops []*ast.AlterFlowOperation, return nil, err } } + for _, p := range bodyPins { + if err := mut.PinInLoop(p.loop, p.at); err != nil { + return nil, err + } + } if err := a.applyTo(ctx, mut, ops, targets); err != nil { return nil, err } diff --git a/mdl/executor/flow_splice_loop_body.go b/mdl/executor/flow_splice_loop_body.go new file mode 100644 index 0000000000..92e5d047f7 --- /dev/null +++ b/mdl/executor/flow_splice_loop_body.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// # Editing inside a loop body (ako/mxcli#1693) +// +// A loop body is a statement list of its own, drawn inside the loop box in +// that box's coordinates. The splice edits it the way it edits the top level, +// for plain activities: `create or modify` diffs the body statement by +// statement (inLoopBody, cmd_flow_modify.go) and `alter` addresses an +// activity in it by its handle; an insert is laid out among the body's nodes +// and the box grows when the body no longer fits it (mfmutator, growLoop). +// Everything else — a decision, a nested loop, a return — changes the body's +// shape, and is made by `replace body of` the loop: the loop itself keeps its +// $ID, position, iterator and list, and only what is inside it is rebuilt. +// +// Replacing the whole loop implicitly instead (option (b) of the issue) was +// rejected: it would silently drop the loop's identity and layout, which is +// what the mdl 1 refusal of the rebuild protects. + +// replaceBody applies `replace body of with begin … end`: the stored +// loop target keeps everything but what is inside it, which becomes the +// fragment's statements, built as `create` builds a loop body — the loop +// restated with its stored header, so the iterator is typed from the list as +// it is there. +func (a *alterFlowContext) replaceBody(ctx *ExecContext, mut backend.MicroflowMutator, op *ast.AlterFlowOperation, + target mfmutator.Candidate) error { + loop, ok := target.Object.(*microflows.LoopedActivity) + if !ok { + return fmt.Errorf("replace body of addresses a loop, and %s is not one; address the loop by its handle from "+ + "`describe %s %s with handles`, e.g. `replace body of loop $It in $Items with begin … end;`", + target.Statement, a.stmt.Kind(), a.stmt.Name) + } + header, err := a.storedLoopStatement(ctx, target) + if err != nil { + return err + } + restated := withLoopBody(header, op.Body) + frag, vars, err := a.buildFragmentVars(ctx, []ast.MicroflowStatement{restated}) + if err != nil { + return err + } + // The body reads what is declared before the loop, its iterator and what + // it declares itself — as a replaced loop's body does. + scopeOp := &ast.AlterFlowOperation{Op: ast.AlterFlowReplace, Target: op.Target} + if err := a.checkFragmentScope(ctx, scopeOp, target, frag); err != nil { + return err + } + if err := a.checkBodyUnclashed(frag, loop); err != nil { + return err + } + if err := mut.ReplaceLoopBody(target.ID, frag); err != nil { + return err + } + // What the stored body held is gone: a variable it declared clashes with + // nothing a later operation declares, and its reads are no uses. + for _, c := range a.cands { + if loopHolds(loop, c.ID) { + a.removedIDs[c.ID] = true + } + } + a.noteFragment(ctx, op, target, frag, vars) + return nil +} + +// withLoopBody is the stored loop statement header with body as its body, +// stated nowhere on the canvas: the builder lays the body out, and the loop +// object it builds around it is discarded. +func withLoopBody(header ast.MicroflowStatement, body []ast.MicroflowStatement) ast.MicroflowStatement { + switch l := header.(type) { + case *ast.LoopStmt: + c := *l + c.Body, c.Annotations = body, nil + return &c + case *ast.WhileStmt: + c := *l + c.Body, c.Annotations = body, nil + return &c + } + return header +} + +// storedLoopStatement returns the statement the stored loop target is +// described as — a loop or while statement, at any depth — found by where it +// is drawn, level by level through the loops around it (a body's positions +// are relative to its loop box, so a position is unique only within one body). +func (a *alterFlowContext) storedLoopStatement(ctx *ExecContext, target mfmutator.Candidate) (ast.MicroflowStatement, error) { + if a.storedDesc == nil { + st, err := describedFlowStmt(ctx, &flowDecl{nanoflow: a.stmt.Nanoflow, name: a.stmt.Name}, a) + if err != nil { + return nil, fmt.Errorf("the stored flow's description cannot be read: %v", err) + } + a.storedDesc = st + } + stmts := storedBody(a.storedDesc) + around := a.loopsAround(target.ID) + for i := len(around) - 1; i >= 0; i-- { + st := statementDrawnAt(stmts, around[i].Position) + if st == nil { + return nil, fmt.Errorf("no stored loop is drawn at (%d, %d)", around[i].Position.X, around[i].Position.Y) + } + stmts = loopStatements(st) + } + p := target.Object.GetPosition() + switch st := statementDrawnAt(stmts, p).(type) { + case *ast.LoopStmt, *ast.WhileStmt: + return st, nil + } + return nil, fmt.Errorf("no stored loop is drawn at (%d, %d), where %s is", p.X, p.Y, target.Statement) +} + +// checkBodyUnclashed refuses a new body that declares a variable the flow +// already has outside the body it replaces — a parameter, or an activity +// output elsewhere — which it would shadow or clash with. One the stored body +// declares is gone with it. +func (a *alterFlowContext) checkBodyUnclashed(frag *backend.MicroflowFragment, loop *microflows.LoopedActivity) error { + existing := map[string]bool{} + for _, p := range a.mf.Parameters { + existing[p.Name] = true + } + for _, c := range a.cands { + if c.OutputVariable != "" && c.ID != loop.ID && !loopHolds(loop, c.ID) && !a.takenAway(c.ID) { + existing[c.OutputVariable] = true + } + } + var clash []string + var walk func(objs []microflows.MicroflowObject) + walk = func(objs []microflows.MicroflowObject) { + for _, obj := range objs { + switch o := obj.(type) { + case *microflows.ActionActivity: + if v := mfmutator.OutputVariable(o.Action); v != "" && existing[v] { + clash = append(clash, "$"+v) + } + case *microflows.LoopedActivity: + if o.ObjectCollection != nil { + walk(o.ObjectCollection.Objects) + } + } + } + } + for _, obj := range frag.Objects { + if l, ok := obj.(*microflows.LoopedActivity); ok && l.ObjectCollection != nil { + walk(l.ObjectCollection.Objects) + } + } + if len(clash) > 0 { + return fmt.Errorf("the new body declares %s, which the %s already has outside the loop; choose another name", + strings.Join(clash, ", "), a.stmt.Kind()) + } + return nil +} diff --git a/mdl/executor/flow_splice_loop_body_1693_pedapp_test.go b/mdl/executor/flow_splice_loop_body_1693_pedapp_test.go new file mode 100644 index 0000000000..a319980c80 --- /dev/null +++ b/mdl/executor/flow_splice_loop_body_1693_pedapp_test.go @@ -0,0 +1,731 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// ako/mxcli#1693: `create or modify` and `alter` refused any change of a loop +// body's shape — an activity inserted, dropped or moved — with "the Loop … +// changes inside its body", and the way out was `alter … replace loop`, which +// re-minted every identity in the loop. Inside a body the splice now inserts, +// drops and replaces plain activities, laid out in the loop box's +// coordinates, the box growing when the body no longer fits; only the changed +// activities get new $IDs. Anything else in a body — a decision, a nested +// loop, a return — is still refused, naming `alter … replace body of` the +// loop, which keeps the loop and rebuilds only what is inside it. + +const lb1693Flow = `mdl 1; +create or modify microflow MyFirstModule.%s ($Items: List of FeedbackModule.Feedback) +begin + declare $S string = ''; + loop $It in $Items + begin + set $S = $S + 'a'; + log info node 'X' 'middle'; + commit $It; + end loop; + log info node 'X' $S; +end;` + +// lb1693Body is the body of lb1693Flow as it is written there. +const lb1693Body = " set $S = $S + 'a';\n log info node 'X' 'middle';\n commit $It;\n" + +// lb1693Script is lb1693Flow for the flow name with its loop body replaced. +func lb1693Script(name, body string) string { + return strings.Replace(fmt.Sprintf(lb1693Flow, name), lb1693Body, body, 1) +} + +// unitBytesOf returns the stored bytes of MyFirstModule.name. +func unitBytesOf(t *testing.T, exec *Executor, name string) []byte { + t.Helper() + ctx := exec.newExecContext(context.Background()) + raw, err := ctx.Backend.GetRawUnitBytes(loopEditFlowOf(t, exec, name).ID) + if err != nil { + t.Fatal(err) + } + return append([]byte(nil), raw...) +} + +// loopOf returns the first top-level loop of the stored flow. +func loopOf(t *testing.T, mf *microflows.Microflow) *microflows.LoopedActivity { + t.Helper() + for _, obj := range mf.ObjectCollection.Objects { + if l, ok := obj.(*microflows.LoopedActivity); ok { + return l + } + } + t.Fatalf("%s has no loop", mf.Name) + return nil +} + +// idChurn compares two flowObjectIDs maps: the $IDs gone and the ones added. +func idChurn(before, after map[model.ID]model.Point) (gone, added int) { + for id := range before { + if _, ok := after[id]; !ok { + gone++ + } + } + for id := range after { + if _, ok := before[id]; !ok { + added++ + } + } + return gone, added +} + +// assertLoopBodies checks what Studio Pro needs of every loop body in the +// flow: its nodes form one chain — one node no flow enters, which starts an +// iteration, one no flow leaves — and every node of it is drawn inside the +// loop box. +func assertLoopBodies(t *testing.T, mf *microflows.Microflow) { + t.Helper() + flows := mf.ObjectCollection.Flows + var walk func(oc *microflows.MicroflowObjectCollection) + walk = func(oc *microflows.MicroflowObjectCollection) { + for _, obj := range oc.Objects { + l, ok := obj.(*microflows.LoopedActivity) + if !ok || l.ObjectCollection == nil { + continue + } + inBody := map[model.ID]bool{} + var nodes []microflows.MicroflowObject + for _, b := range l.ObjectCollection.Objects { + if _, note := b.(*microflows.Annotation); note { + continue + } + inBody[b.GetID()] = true + nodes = append(nodes, b) + p := b.GetPosition() + var sz model.Size + if s, ok := b.(interface{ GetSize() model.Size }); ok { + sz = s.GetSize() + } + if p.X-sz.Width/2 < 0 || p.Y-sz.Height/2 < 0 || p.X+sz.Width/2 > l.Size.Width || p.Y+sz.Height/2 > l.Size.Height { + t.Errorf("a body node at %v (size %v) is drawn outside its loop box %v", p, sz, l.Size) + } + } + in, out := map[model.ID]int{}, map[model.ID]int{} + for _, f := range flows { + if inBody[f.OriginID] != inBody[f.DestinationID] { + t.Errorf("flow %s runs between the loop body and the rest of the flow", f.ID) + } + if inBody[f.OriginID] { + out[f.OriginID]++ + in[f.DestinationID]++ + } + } + var starts, ends int + for _, n := range nodes { + if in[n.GetID()] == 0 { + starts++ + } + if out[n.GetID()] == 0 { + ends++ + } + } + if len(nodes) > 0 && (starts != 1 || ends < 1) { + t.Errorf("the loop body has %d nodes no flow enters and %d no flow leaves; want one start", starts, ends) + } + walk(l.ObjectCollection) + } + } + walk(mf.ObjectCollection) +} + +// agreeVerdict runs check's flow verdict (CheckFlowVerdicts, what `check -p` +// reports for a flow change) and then exec, and asserts both refuse with want +// — or, for want "", both accept. +func agreeVerdict(t *testing.T, exec *Executor, src, want string) { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs[0], src) + } + var says []string + for _, v := range exec.CheckFlowVerdicts(prog) { + says = append(says, v.Message) + } + execErr := exec.ExecuteProgram(prog) + checkSays := strings.Join(says, "\n") + if want == "" { + if checkSays != "" || execErr != nil { + t.Errorf("check and exec must both accept\ncheck: %s\nexec: %v", checkSays, execErr) + } + return + } + if !strings.Contains(checkSays, want) { + t.Errorf("check -p must predict the refusal %q, reported:\n%s", want, checkSays) + } + if execErr == nil || !strings.Contains(execErr.Error(), want) { + t.Errorf("exec must refuse with %q, got: %v", want, execErr) + } +} + +// bodyOrder asserts the loop body of the flow's description holds the given +// texts in order. +func bodyOrder(t *testing.T, desc string, want ...string) { + t.Helper() + start := strings.Index(desc, "loop $It in $Items") + end := strings.Index(desc, "end loop;") + if start < 0 || end < start { + t.Fatalf("no loop in:\n%s", desc) + } + body := desc[start:end] + at := 0 + for _, w := range want { + i := strings.Index(body[at:], w) + if i < 0 { + t.Fatalf("the loop body does not hold %q (in order) — got:\n%s", w, body) + } + at += i + len(w) + } +} + +func TestSpliceLoopBody1693_CreateOrModify(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + cases := []struct { + name, body, summary string + order []string + gone, added int + }{ + {"LB1693Mid", " set $S = $S + 'a';\n log info node 'X' 'new';\n log info node 'X' 'middle';\n commit $It;\n", + "spliced: 1 inserted", []string{"set $S", "'new'", "'middle'", "commit $It"}, 0, 1}, + {"LB1693First", " log info node 'X' 'new';\n" + lb1693Body, + "spliced: 1 inserted", []string{"'new'", "set $S", "'middle'", "commit $It"}, 0, 1}, + {"LB1693Last", lb1693Body + " log info node 'X' 'new';\n", + "spliced: 1 inserted", []string{"set $S", "'middle'", "commit $It", "'new'"}, 0, 1}, + {"LB1693Two", " set $S = $S + 'a';\n log info node 'X' 'new 1';\n log info node 'X' 'new 2';\n log info node 'X' 'middle';\n commit $It;\n", + "spliced: 1 inserted", []string{"set $S", "'new 1'", "'new 2'", "'middle'", "commit $It"}, 0, 2}, + {"LB1693DropMid", " set $S = $S + 'a';\n commit $It;\n", + "spliced: 1 dropped", []string{"set $S", "commit $It"}, 1, 0}, + {"LB1693DropFirst", " log info node 'X' 'middle';\n commit $It;\n", + "spliced: 1 dropped", []string{"'middle'", "commit $It"}, 1, 0}, + {"LB1693DropLast", " set $S = $S + 'a';\n log info node 'X' 'middle';\n", + "spliced: 1 dropped", []string{"set $S", "'middle'"}, 1, 0}, + {"LB1693Swap", " log info node 'X' 'middle';\n set $S = $S + 'a';\n commit $It;\n", + "", []string{"'middle'", "set $S", "commit $It"}, 1, 1}, + {"LB1693ReplaceByTwo", " set $S = $S + 'a';\n log info node 'X' 'one';\n log info node 'X' 'two';\n commit $It;\n", + "spliced: 1 replaced", []string{"set $S", "'one'", "'two'", "commit $It"}, 1, 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := agreeExec(t, exec, fmt.Sprintf(lb1693Flow, tc.name)); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + stored := loopEditFlowOf(t, exec, tc.name) + before := flowObjectIDs(stored.ObjectCollection) + loopID := loopOf(t, stored).ID + + src := lb1693Script(tc.name, tc.body) + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatal(errs[0]) + } + for _, v := range exec.CheckFlowVerdicts(prog) { + t.Errorf("check -p predicts a refusal for a plain-activity change in the loop body: %s", v.Message) + } + out.Reset() + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("a plain-activity change in the loop body is refused: %v", err) + } + if !strings.Contains(out.String(), "Modified microflow: MyFirstModule."+tc.name+" (") || + (tc.summary != "" && !strings.Contains(out.String(), tc.summary)) { + t.Errorf("want the change reported as %q, got:\n%s", tc.summary, out.String()) + } + bodyOrder(t, describeFlow(t, exec, tc.name), tc.order...) + + mf := loopEditFlowOf(t, exec, tc.name) + assertLoopBodies(t, mf) + after := flowObjectIDs(mf.ObjectCollection) + if loopOf(t, mf).ID != loopID { + t.Error("the loop itself was replaced") + } + if gone, added := idChurn(before, after); gone != tc.gone || added != tc.added { + t.Errorf("want %d $IDs gone and %d added (only the changed activities), got %d and %d", + tc.gone, tc.added, gone, added) + } + + // The re-run is a fixed point: nothing written. + stored2 := unitBytesOf(t, exec, tc.name) + out.Reset() + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("re-run: %v", err) + } + if !strings.Contains(out.String(), "Unchanged") || !bytes.Equal(stored2, unitBytesOf(t, exec, tc.name)) { + t.Errorf("re-running the script wrote again:\n%s", out.String()) + } + }) + } + + // The control for the $ID assertions above: the route the refusal used to + // name — `alter … replace loop` — re-mints every identity in the loop for + // the same change, so a comparison that saw nothing change would have + // passed against it. + t.Run("control: replace loop re-mints the body", func(t *testing.T) { + const name = "LB1693Control" + if err := agreeExec(t, exec, fmt.Sprintf(lb1693Flow, name)); err != nil { + t.Fatalf("setup: %v", err) + } + before := flowObjectIDs(loopEditFlowOf(t, exec, name).ObjectCollection) + if err := agreeExec(t, exec, "mdl 1;\nalter microflow MyFirstModule."+name+" {\n"+ + " replace loop $It in $Items with begin loop $It in $Items begin\n"+ + " set $S = $S + 'a';\n log info node 'X' 'new';\n log info node 'X' 'middle';\n commit $It;\n"+ + " end loop; end;\n};"); err != nil { + t.Fatal(err) + } + gone, added := idChurn(before, flowObjectIDs(loopEditFlowOf(t, exec, name).ObjectCollection)) + if gone < 4 || added < 5 { + t.Errorf("replace loop kept identities (%d gone, %d added); the comparison would not detect churn", gone, added) + } + }) +} + +// A body edit that needs more room than the loop box has grows the box, and +// what is drawn past the loop moves along, as past an inserted activity. +func TestSpliceLoopBody1693_BoxGrows(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + const name = "LB1693Grow" + if err := agreeExec(t, exec, fmt.Sprintf(lb1693Flow, name)); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + mf := loopEditFlowOf(t, exec, name) + loop := loopOf(t, mf) + pos, size := loop.Position, loop.Size + var after model.Point // the log after the loop + for _, obj := range mf.ObjectCollection.Objects { + if obj.GetPosition().X > pos.X+size.Width/2 { + if _, ok := obj.(*microflows.ActionActivity); ok { + after = obj.GetPosition() + } + } + } + if err := agreeExec(t, exec, lb1693Script(name, lb1693Body+" log info node 'X' 'new 1';\n log info node 'X' 'new 2';\n")); err != nil { + t.Fatal(err) + } + mf = loopEditFlowOf(t, exec, name) + assertLoopBodies(t, mf) + grown := loopOf(t, mf) + if grown.Size.Width <= size.Width || grown.Size.Height != size.Height { + t.Errorf("want the box wider and as high, got %v from %v", grown.Size, size) + } + if left := grown.Position.X - grown.Size.Width/2; left != pos.X-size.Width/2 { + t.Errorf("the box's left edge moved from %d to %d; it grows on its far side", pos.X-size.Width/2, left) + } + if grown.Position.Y != pos.Y { + t.Errorf("the box moved off its line: %v -> %v", pos, grown.Position) + } + moved := false + for _, obj := range mf.ObjectCollection.Objects { + if p := obj.GetPosition(); p.Y == after.Y && p.X == after.X+(grown.Size.Width-size.Width) { + moved = true + } + } + if !moved { + t.Errorf("the activity after the loop did not move along by %d", grown.Size.Width-size.Width) + } +} + +// The body of a nested loop is edited the same way, inside its own box; the +// outer box grows to fit a grown inner one. +func TestSpliceLoopBody1693_NestedLoop(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + src := `mdl 1; +create or modify microflow MyFirstModule.LB1693Nested ($Items: List of FeedbackModule.Feedback, $Others: List of FeedbackModule.Feedback) +begin + loop $It in $Items + begin + log info node 'X' 'outer'; + loop $Other in $Others + begin + log info node 'X' 'inner'; + end loop; + end loop; + log info node 'X' 'done'; +end;` + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + before := flowObjectIDs(loopEditFlowOf(t, exec, "LB1693Nested").ObjectCollection) + edited := strings.Replace(src, " log info node 'X' 'inner';\n", + " log info node 'X' 'inner';\n log info node 'X' 'inner 2';\n commit $Other;\n", 1) + out.Reset() + if err := agreeExec(t, exec, edited); err != nil { + t.Fatalf("an insert in a nested loop body is refused: %v", err) + } + if !strings.Contains(out.String(), "spliced: 1 inserted") { + t.Errorf("want one insert, got:\n%s", out.String()) + } + mf := loopEditFlowOf(t, exec, "LB1693Nested") + assertLoopBodies(t, mf) + if gone, added := idChurn(before, flowObjectIDs(mf.ObjectCollection)); gone != 0 || added != 2 { + t.Errorf("want 0 $IDs gone and 2 added, got %d and %d", gone, added) + } + out.Reset() + if err := agreeExec(t, exec, edited); err != nil || !strings.Contains(out.String(), "Unchanged") { + t.Errorf("the re-run is not Unchanged: %v\n%s", err, out.String()) + } +} + +func TestSpliceLoopBody1693_Alter(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + cases := []struct { + name, op string + order []string + gone, added int + }{ + {"LBA1693InsAfter", "insert after set $S = $S + 'a' begin log info node 'X' 'new'; end;", + []string{"set $S", "'new'", "'middle'", "commit $It"}, 0, 1}, + {"LBA1693InsAfterLast", "insert after commit $It begin log info node 'X' 'new'; end;", + []string{"set $S", "'middle'", "commit $It", "'new'"}, 0, 1}, + {"LBA1693InsBeforeFirst", "insert before set $S = $S + 'a' begin log info node 'X' 'new'; end;", + []string{"'new'", "set $S", "'middle'", "commit $It"}, 0, 1}, + {"LBA1693Drop", "drop log node 'X' 'middle';", + []string{"set $S", "commit $It"}, 1, 0}, + {"LBA1693DropLast", "drop commit $It;", + []string{"set $S", "'middle'"}, 1, 0}, + {"LBA1693ReplaceTwo", "replace log node 'X' 'middle' with begin log info node 'X' 'one'; log info node 'X' 'two'; end;", + []string{"set $S", "'one'", "'two'", "commit $It"}, 1, 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := agreeExec(t, exec, fmt.Sprintf(lb1693Flow, tc.name)); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + stored := loopEditFlowOf(t, exec, tc.name) + before := flowObjectIDs(stored.ObjectCollection) + loopID := loopOf(t, stored).ID + src := "mdl 1;\nalter microflow MyFirstModule." + tc.name + " {\n " + tc.op + "\n};" + // check and exec agree: both accept. + agreeVerdict(t, exec, src, "") + bodyOrder(t, describeFlow(t, exec, tc.name), tc.order...) + mf := loopEditFlowOf(t, exec, tc.name) + assertLoopBodies(t, mf) + if loopOf(t, mf).ID != loopID { + t.Error("the loop itself was replaced") + } + if gone, added := idChurn(before, flowObjectIDs(mf.ObjectCollection)); gone != tc.gone || added != tc.added { + t.Errorf("want %d $IDs gone and %d added, got %d and %d", tc.gone, tc.added, gone, added) + } + }) + } +} + +// What changes a body's shape is still refused — by check and exec alike, +// writing nothing — and the refusal names `replace body of` the loop. +func TestSpliceLoopBody1693_StillRefused(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + const withIf = `mdl 1; +create or modify microflow MyFirstModule.LB1693If ($Items: List of FeedbackModule.Feedback) +begin + loop $It in $Items + begin + if $It/Subject = empty then + log info node 'X' 'no email'; + end if; + commit $It; + end loop; +end;` + for _, setup := range []string{fmt.Sprintf(lb1693Flow, "LB1693Refused"), withIf} { + if err := agreeExec(t, exec, setup); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + } + const advice = "replace body of loop $It in $Items with begin" + cases := []struct { + name, flow, src, want string + }{ + {"create or modify adds a decision", "LB1693Refused", + lb1693Script("LB1693Refused", " if $It/Subject = empty then\n log info node 'X' 'no email';\n end if;\n"+lb1693Body), + "a decision is added to it"}, + {"create or modify changes a decision in the body", "LB1693If", + strings.Replace(withIf, "$It/Subject = empty", "$It/Subject != empty", 1), + "a decision in it"}, + {"create or modify takes a decision out of the body", "LB1693If", + strings.Replace(withIf, " if $It/Subject = empty then\n log info node 'X' 'no email';\n end if;\n", "", 1), + "a decision in it"}, + {"create or modify adds a nested loop", "LB1693Refused", + lb1693Script("LB1693Refused", lb1693Body+" loop $Inner in $Items\n begin\n commit $Inner;\n end loop;\n"), + "a loop is added to it"}, + {"alter inserts a decision", "LB1693Refused", + "mdl 1;\nalter microflow MyFirstModule.LB1693Refused {\n insert after commit $It begin if $It/Subject = empty then log info node 'X' 'e'; end if; end;\n};", + "the fragment holds a decision"}, + {"alter drops a decision in the body", "LB1693If", + "mdl 1;\nalter microflow MyFirstModule.LB1693If {\n drop '$It/Subject = empty';\n};", + "it is a decision"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stored := unitBytesOf(t, exec, tc.flow) + agreeVerdict(t, exec, tc.src, tc.want) + err := agreeExec(t, exec, tc.src) + if err == nil || !strings.Contains(err.Error(), advice) { + t.Errorf("the refusal must name %q, got: %v", advice, err) + } + if !bytes.Equal(stored, unitBytesOf(t, exec, tc.flow)) { + t.Error("the refused statement wrote") + } + }) + } + + // In a nested loop the refusal names the loop whose body changes. + t.Run("nested loop names the inner loop", func(t *testing.T) { + src := `mdl 1; +create or modify microflow MyFirstModule.LB1693NestedRefused ($Items: List of FeedbackModule.Feedback, $Others: List of FeedbackModule.Feedback) +begin + loop $It in $Items + begin + loop $Other in $Others + begin + commit $Other; + end loop; + end loop; +end;` + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("setup: %v", err) + } + edited := strings.Replace(src, " commit $Other;\n", " if $Other/Subject = empty then\n commit $Other;\n end if;\n", 1) + agreeVerdict(t, exec, edited, "replace body of loop $Other in $Others with begin") + agreeVerdict(t, exec, "mdl 1;\nalter microflow MyFirstModule.LB1693NestedRefused {\n"+ + " insert after commit $Other begin if $Other/Subject = empty then log info node 'X' 'e'; end if; end;\n};", + "replace body of loop $Other in $Others with begin") + }) +} + +func TestAlterReplaceBody1693(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + const name = "LB1693ReplaceBody" + if err := agreeExec(t, exec, fmt.Sprintf(lb1693Flow, name)); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + mf := loopEditFlowOf(t, exec, name) + before := flowObjectIDs(mf.ObjectCollection) + loop := loopOf(t, mf) + bodyIDs := flowObjectIDs(loop.ObjectCollection) + + // A body with a decision, which the splice does not put in a body: the + // loop keeps its $ID and everything outside it stays. + const replace = `mdl 1; +alter microflow MyFirstModule.LB1693ReplaceBody { + replace body of loop $It in $Items with begin + if $It/Subject = empty then + log info node 'X' 'no email'; + end if; + commit $It; + end; +};` + agreeVerdict(t, exec, replace, "") + mf = loopEditFlowOf(t, exec, name) + assertLoopBodies(t, mf) + after := flowObjectIDs(mf.ObjectCollection) + got := loopOf(t, mf) + if got.ID != loop.ID { + t.Fatal("replace body replaced the loop itself") + } + if src, ok := got.LoopSource.(*microflows.IterableList); !ok || src.VariableName != "It" || src.ListVariableName != "Items" { + t.Errorf("the loop's iterator or list changed: %#v", got.LoopSource) + } + for id, p := range before { + if _, inBody := bodyIDs[id]; inBody { + if _, ok := after[id]; ok { + t.Errorf("a stored body element %s survived replace body", id) + } + continue + } + if q, ok := after[id]; !ok { + t.Errorf("element %s outside the loop was taken out", id) + } else if id != loop.ID && q != p && q.Y == p.Y && q.X < p.X { + t.Errorf("element %s outside the loop moved back from %v to %v", id, p, q) + } + } + desc := describeFlow(t, exec, name) + bodyOrder(t, desc, "if $It/Subject = empty then", "'no email'", "end if;", "commit $It") + if strings.Contains(desc, "'middle'") || strings.Contains(desc, "set $S") { + t.Errorf("the stored body is still there:\n%s", desc) + } + + // Round trip: the flow's own description, executed back, is Unchanged. + t.Run("describe round-trips", func(t *testing.T) { + stored := unitBytesOf(t, exec, name) + out.Reset() + if err := agreeExec(t, exec, "mdl 1;\n"+strings.TrimPrefix(desc, "mdl 1;\n")); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Unchanged") || !bytes.Equal(stored, unitBytesOf(t, exec, name)) { + t.Errorf("describe -> exec wrote:\n%s", out.String()) + } + }) + + // A body that fits the box keeps its size and position. + t.Run("a body that fits keeps the box", func(t *testing.T) { + l := loopOf(t, loopEditFlowOf(t, exec, name)) + if err := agreeExec(t, exec, "mdl 1;\nalter microflow MyFirstModule."+name+" {\n"+ + " replace body of loop $It in $Items with begin commit $It; end;\n};"); err != nil { + t.Fatal(err) + } + got := loopOf(t, loopEditFlowOf(t, exec, name)) + if got.ID != l.ID || got.Position != l.Position || got.Size != l.Size { + t.Errorf("want the loop kept as it is, got %v %v from %v %v", got.Position, got.Size, l.Position, l.Size) + } + if body := describeFlow(t, exec, name); !strings.Contains(body, "commit $It;") || strings.Contains(body, "no email") { + t.Errorf("the body was not replaced:\n%s", body) + } + }) + + refusals := []struct{ name, op, want string }{ + {"not a loop", "replace body of $S with begin commit $It; end;", + "replace body of addresses a loop"}, + {"reads an undeclared variable", "replace body of loop $It in $Items with begin commit $Nope; end;", + "not declared on the path"}, + {"declares a variable the flow has", "replace body of loop $It in $Items with begin declare $S string = 'x'; end;", + "already has outside the loop"}, + } + for _, r := range refusals { + t.Run("refused: "+r.name, func(t *testing.T) { + stored := unitBytesOf(t, exec, name) + agreeVerdict(t, exec, "mdl 1;\nalter microflow MyFirstModule."+name+" {\n "+r.op+"\n};", r.want) + if !bytes.Equal(stored, unitBytesOf(t, exec, name)) { + t.Error("the refused statement wrote") + } + }) + } + + t.Run("while loop", func(t *testing.T) { + if err := agreeExec(t, exec, `mdl 1; +create or modify microflow MyFirstModule.LB1693While () +begin + declare $N integer = 0; + while $N < 3 + begin + set $N = $N + 1; + end while; +end;`); err != nil { + t.Fatalf("setup: %v", err) + } + l := loopOf(t, loopEditFlowOf(t, exec, "LB1693While")) + agreeVerdict(t, exec, "mdl 1;\nalter microflow MyFirstModule.LB1693While {\n"+ + " replace body of while $N < 3 with begin set $N = $N + 2; log info node 'X' 'step'; end;\n};", "") + mf := loopEditFlowOf(t, exec, "LB1693While") + assertLoopBodies(t, mf) + if loopOf(t, mf).ID != l.ID { + t.Error("the while loop itself was replaced") + } + if d := describeFlow(t, exec, "LB1693While"); !strings.Contains(d, "set $N = $N + 2;") || !strings.Contains(d, "'step'") { + t.Errorf("the body was not replaced:\n%s", d) + } + }) + + t.Run("nested loop", func(t *testing.T) { + src := `mdl 1; +create or modify microflow MyFirstModule.LB1693RBNested ($Items: List of FeedbackModule.Feedback, $Others: List of FeedbackModule.Feedback) +begin + loop $It in $Items + begin + loop $Other in $Others + begin + commit $Other; + end loop; + end loop; +end;` + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("setup: %v", err) + } + mf := loopEditFlowOf(t, exec, "LB1693RBNested") + outer := loopOf(t, mf) + var inner *microflows.LoopedActivity + for _, obj := range outer.ObjectCollection.Objects { + if l, ok := obj.(*microflows.LoopedActivity); ok { + inner = l + } + } + agreeVerdict(t, exec, "mdl 1;\nalter microflow MyFirstModule.LB1693RBNested {\n"+ + " replace body of loop $Other in $Others with begin\n"+ + " if $Other/Subject = empty then log info node 'X' 'none'; end if;\n"+ + " commit $Other; log info node 'X' 'done';\n end;\n};", "") + mf = loopEditFlowOf(t, exec, "LB1693RBNested") + assertLoopBodies(t, mf) + ids := flowObjectIDs(mf.ObjectCollection) + if _, ok := ids[outer.ID]; !ok { + t.Error("the outer loop was replaced") + } + if _, ok := ids[inner.ID]; !ok { + t.Error("the inner loop was replaced") + } + out.Reset() + if err := agreeExec(t, exec, "mdl 1;\n"+strings.TrimPrefix(describeFlow(t, exec, "LB1693RBNested"), "mdl 1;\n")); err != nil || + !strings.Contains(out.String(), "Unchanged") { + t.Errorf("describe -> exec is not Unchanged: %v\n%s", err, out.String()) + } + }) + + // A stored loop whose body is empty has no activity to insert next to; + // create or modify fills it with replace body, which loses nothing there. + t.Run("create or modify fills an empty body", func(t *testing.T) { + empty := `mdl 1; +create or modify microflow MyFirstModule.LB1693Empty ($Items: List of FeedbackModule.Feedback) +begin + loop $It in $Items + begin + end loop; + log info node 'X' 'done'; +end;` + if err := agreeExec(t, exec, empty); err != nil { + t.Fatalf("setup: %v", err) + } + before := flowObjectIDs(loopEditFlowOf(t, exec, "LB1693Empty").ObjectCollection) + filled := strings.Replace(empty, " begin\n end loop;", " begin\n commit $It;\n log info node 'X' 'one';\n end loop;", 1) + out.Reset() + if err := agreeExec(t, exec, filled); err != nil { + t.Fatalf("filling an empty loop body is refused: %v", err) + } + mf := loopEditFlowOf(t, exec, "LB1693Empty") + assertLoopBodies(t, mf) + if gone, added := idChurn(before, flowObjectIDs(mf.ObjectCollection)); gone != 0 || added != 2 { + t.Errorf("want 0 $IDs gone and 2 added, got %d and %d", gone, added) + } + out.Reset() + if err := agreeExec(t, exec, filled); err != nil || !strings.Contains(out.String(), "Unchanged") { + t.Errorf("the re-run is not Unchanged: %v\n%s", err, out.String()) + } + }) +} + +// A body restated from describe states every node's @position: under mdl 1 +// the splice moves none of them (as at top level, mendixlabs/mxcli#1598), so +// an insert that needs room among them is refused rather than written in a +// shape the next run would read as moved nodes. The control is the same insert +// in the script that states no positions, which is spliced. +func TestSpliceLoopBody1693_StatedPositionsArePinned(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + const name = "LB1693Pinned" + if err := agreeExec(t, exec, fmt.Sprintf(lb1693Flow, name)); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + var buf bytes.Buffer + saved := exec.output + exec.output = &buf + if err := afRun(t, exec, "describe microflow MyFirstModule."+name+" with handles;"); err != nil { + t.Fatal(err) + } + exec.output = saved + restated := strings.Replace(buf.String(), " set $S = $S + 'a';\n", + " set $S = $S + 'a';\n log info node 'X' 'new';\n", 1) + if restated == buf.String() { + t.Fatalf("the restated script has no set statement to insert after:\n%s", buf.String()) + } + stored := unitBytesOf(t, exec, name) + agreeVerdict(t, exec, restated, "draws where it is") + if !bytes.Equal(stored, unitBytesOf(t, exec, name)) { + t.Error("the refused statement wrote") + } + agreeVerdict(t, exec, lb1693Script(name, " set $S = $S + 'a';\n log info node 'X' 'new';\n log info node 'X' 'middle';\n commit $It;\n"), "") +} diff --git a/mdl/executor/flow_verdict.go b/mdl/executor/flow_verdict.go index 2d7226bc66..50df94e58f 100644 --- a/mdl/executor/flow_verdict.go +++ b/mdl/executor/flow_verdict.go @@ -61,15 +61,16 @@ func decideFlowModify(ctx *ExecContext, d *flowDecl) flowVerdict { func flowRefusal(d *flowDecl, why error) error { var ns *notSpliceable if errors.As(why, &ns) && ns.loopHandle != "" { - // alter cannot splice inside a loop either, so "change activities - // with alter" would only lead to alter's own refusal: name the one - // alter that makes the change. - // The deliberate rebuild is a route here too (mendixlabs/mxcli#1517). + // alter splices inside a loop body only what create or modify does, + // so "change activities with alter" would only lead to alter's own + // refusal: name the one alter that makes the change, `replace body + // of` the loop (ako/mxcli#1693). The deliberate rebuild is a route + // here too (mendixlabs/mxcli#1517). return mdlerrors.NewValidation(fmt.Sprintf( "%s %s %s: this change cannot be spliced into the stored flow: %v. "+ "Nothing was written: rebuilding the whole flow instead would reset what Studio Pro drew "+ "(curves, merges, element IDs). %s. Or, %s", - d.verb(), d.kind(), d.name, why, strings.TrimSuffix(replaceLoopAdvice(d.kind(), d.name.String(), ns.loopHandle), "."), + d.verb(), d.kind(), d.name, why, strings.TrimSuffix(replaceBodyAdvice(d.kind(), d.name.String(), ns.loopHandle), "."), deliberateRebuildAdvice(d.kind()))) } return mdlerrors.NewValidation(fmt.Sprintf( @@ -86,24 +87,24 @@ func deliberateRebuildAdvice(kind string) string { "(the drop's module-role grants carry to a create in the same script or session, not to one in a later run)", kind) } -// replaceLoopAdvice is what `create or modify` and `alter` both say about a -// change inside a loop body: neither splices inside a loop, and each used to -// send the reader to the other. What makes the change is an alter that -// replaces the whole loop — the rest of the flow keeps its element IDs and -// layout (measured on an 11.14 app: every object and flow outside the loop -// kept its $ID and position), and the loop and its body are rebuilt, which -// the statement states. +// replaceBodyAdvice is what `create or modify` and `alter` both say about a +// change inside a loop body the splice cannot make (ako/mxcli#1693): the +// splice inserts, drops and replaces plain activities in a body, and anything +// else there — a decision, a nested loop, a return — changes the body's shape. +// What makes such a change is `replace body of` the loop: the loop itself +// keeps its element ID, position, iterator and list, and the rest of the flow +// is untouched; only what is inside the loop is rebuilt, which the statement +// states. Each mode used to send the reader to the other, and then to +// `replace loop`, which rebuilt the loop as well (option (b) of the issue, +// rejected: it drops the loop's identity and layout). // loopHandle is the stored loop's handle as `describe … with handles` prints // it (`loop $It in $Items`, `while $N < 10`). -func replaceLoopAdvice(kind, name, loopHandle string) string { - end := "end loop;" - if strings.HasPrefix(loopHandle, "while ") { - end = "end while;" - } - return fmt.Sprintf("Replace the whole loop instead, stating its body as it should be "+ - "(the rest of the flow keeps its element IDs and layout; the loop and its body are rebuilt): "+ - "`alter %s %s { replace %s with begin %s begin … %s end; };`", - kind, name, loopHandle, loopHandle, end) +func replaceBodyAdvice(kind, name, loopHandle string) string { + return fmt.Sprintf("Replace the loop's body instead, stating it as it should be "+ + "(the loop keeps its element ID, position, iterator and list, and the rest of the flow is untouched; "+ + "only what is inside the loop is rebuilt): "+ + "`alter %s %s { replace body of %s with begin … end; };`", + kind, name, loopHandle) } // flowRebuildWarning is exec's MDL-V1-REBUILD warning for the whole-flow diff --git a/mdl/executor/flow_verdict_test.go b/mdl/executor/flow_verdict_test.go index b8bc180543..5f2bfdcdd3 100644 --- a/mdl/executor/flow_verdict_test.go +++ b/mdl/executor/flow_verdict_test.go @@ -39,12 +39,14 @@ func TestFlowRefusalNamesTheFlowAndTheReason(t *testing.T) { t.Errorf("the rebuild advice must keep drop and create in one script: %q", msg) } - // Inside a loop alter cannot splice either: the refusal names the alter - // that replaces the loop, closing a while with `end while;`. + // Inside a loop alter splices what create or modify does: the refusal + // names the alter that replaces the loop's body (ako/mxcli#1693), a + // while addressed by its handle like any loop. inLoop := ¬Spliceable{reason: "the While at (1, 2) changes inside its body", loopHandle: "while $N < 3"} msg = flowRefusal(d, inLoop).Error() - want := "`alter microflow M.F { replace while $N < 3 with begin while $N < 3 begin … end while; end; };`" - if !strings.Contains(msg, want) || strings.Contains(msg, "Change activities with") { + want := "`alter microflow M.F { replace body of while $N < 3 with begin … end; };`" + if !strings.Contains(msg, want) || strings.Contains(msg, "Change activities with") || + strings.Contains(msg, "replace while") { t.Errorf("want %q and no generic alter advice in %q", want, msg) } w := flowRebuildWarning(&ExecContext{LanguageVersion: langver.V0}, d, why) diff --git a/mdl/executor/validate_flow_verdicts.go b/mdl/executor/validate_flow_verdicts.go index 7e106d519f..283b921332 100644 --- a/mdl/executor/validate_flow_verdicts.go +++ b/mdl/executor/validate_flow_verdicts.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/mdl/linter" "github.com/mendixlabs/mxcli/sdk/microflows" @@ -352,8 +353,23 @@ func alterFlowViolations(ctx *ExecContext, s *ast.AlterFlowStmt) (_ []linter.Vio if len(op.Body) == 0 { continue } + body := op.Body + if op.Op == ast.AlterFlowReplaceBody { + // A loop's new body is built inside the loop restated, as exec + // builds it (replaceBody): its iterator is typed from the list. + // A target that is no stored loop is the plan's to refuse. + c, err := mfmutator.ResolveText(a.cands, op.Target) + if err != nil { + continue + } + header, err := a.storedLoopStatement(ctx, c) + if err != nil { + continue + } + body = []ast.MicroflowStatement{withLoopBody(header, op.Body)} + } fb := a.fragmentBuilder(ctx) - oc := fb.buildFlowGraph(op.Body, nil) + oc := fb.buildFlowGraph(body, nil) if errs := fb.GetErrors(); len(errs) > 0 { return nil, buildDependency(strings.Join(errs, "\n")), nil } diff --git a/mdl/executor/validate_flow_verdicts_1361_pedapp_test.go b/mdl/executor/validate_flow_verdicts_1361_pedapp_test.go index 8dc5670626..2aa2e9ff00 100644 --- a/mdl/executor/validate_flow_verdicts_1361_pedapp_test.go +++ b/mdl/executor/validate_flow_verdicts_1361_pedapp_test.go @@ -123,7 +123,9 @@ begin loop $It in $Items begin set $S = $S + 'a'; - log info node 'X' 'added in loop'; + if $It/Subject = empty then + log info node 'X' 'added in loop'; + end if; commit $It; end loop; %s diff --git a/mdl/executor/validate_flow_verdicts_chained_pedapp_test.go b/mdl/executor/validate_flow_verdicts_chained_pedapp_test.go index 6a949271d8..87f60eb1b5 100644 --- a/mdl/executor/validate_flow_verdicts_chained_pedapp_test.go +++ b/mdl/executor/validate_flow_verdicts_chained_pedapp_test.go @@ -60,8 +60,9 @@ func TestCheckFlowVerdicts_ChainedOnTheSameFlow(t *testing.T) { } t.Run("a refusal after an earlier change is predicted", func(t *testing.T) { - // Step 2 changes the loop body's shape: refused, whatever step 1 did. - step2 := strings.Replace(chainedStep1, "'in loop';", "'in loop';\n log info node 'X' 'again';", 1) + // Step 2 adds a decision to the loop body: refused, whatever step 1 did + // (a plain activity is spliced there, ako/mxcli#1693). + step2 := strings.Replace(chainedStep1, "'in loop';", "'in loop';\n if $It/Subject = empty then log info node 'X' 'again'; end if;", 1) r := report(t, chainedStep1+"\n"+strings.TrimPrefix(step2, "mdl 1;\n")) if len(r.Violations) != 1 || !strings.Contains(r.Violations[0].Message, "changes inside its body") { t.Errorf("want statement 2's refusal predicted, got violations %v, unpredicted %+v", r.Violations, r.Unpredicted) @@ -80,7 +81,7 @@ func TestCheckFlowVerdicts_ChainedOnTheSameFlow(t *testing.T) { } }) t.Run("control: a refusal of the first statement is still reported", func(t *testing.T) { - step := strings.Replace(chainedBase, "'in loop';", "'in loop';\n log info node 'X' 'again';", 1) + step := strings.Replace(chainedBase, "'in loop';", "'in loop';\n if $It/Subject = empty then log info node 'X' 'again'; end if;", 1) r := report(t, step) if len(r.Violations) != 1 { t.Errorf("want the refusal, got %v", r.Violations) @@ -95,7 +96,7 @@ func TestCheckFlowVerdicts_ChainedOnTheSameFlow(t *testing.T) { // exec agrees with the first prediction: it refuses statement 2. (Run // last: ExecuteProgram writes statement 1; the rollback is cmd exec's.) t.Run("exec refuses what check predicted", func(t *testing.T) { - step2 := strings.Replace(chainedStep1, "'in loop';", "'in loop';\n log info node 'X' 'again';", 1) + step2 := strings.Replace(chainedStep1, "'in loop';", "'in loop';\n if $It/Subject = empty then log info node 'X' 'again'; end if;", 1) err := agreeExec(t, exec, chainedStep1+"\n"+strings.TrimPrefix(step2, "mdl 1;\n")) if err == nil || !strings.Contains(err.Error(), "changes inside its body") { t.Errorf("want exec to refuse statement 2, got %v", err) diff --git a/mdl/executor/validate_flow_verdicts_script_flows_pedapp_test.go b/mdl/executor/validate_flow_verdicts_script_flows_pedapp_test.go index f563b1298d..55b86b7e4e 100644 --- a/mdl/executor/validate_flow_verdicts_script_flows_pedapp_test.go +++ b/mdl/executor/validate_flow_verdicts_script_flows_pedapp_test.go @@ -18,8 +18,8 @@ import ( // resolves calls to flows the script itself declares. // verdictActStored is the flow as stored: a log, a loop and a log. The new -// body calls a sub-flow and also adds an activity to the loop's body, which -// the splice refuses. (It used to move a retrieve and its reader into the +// body calls a sub-flow and also adds a decision to the loop's body, which +// the splice refuses (a plain activity it splices there, ako/mxcli#1693). (It used to move a retrieve and its reader into the // sub-flow, refused "$Reporter is still used by" — a false refusal, since // the reader goes too, which mendixlabs/mxcli#1517 fixed.) const verdictActStored = `mdl 1; @@ -46,7 +46,9 @@ begin loop $It in $Items begin commit $It; - log info node 'X' 'committed'; + if $It/Subject = empty then + log info node 'X' 'committed'; + end if; end loop; log info node 'X' 'done'; end;` diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 9247d40786..ba301a64c4 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -468,10 +468,16 @@ alterTarget * insert after $IsValidEmail begin log info node 'Feedback' 'Email checked'; end; * insert before 'Email is Valid?' begin … end; * replace commit $Order with begin commit $Order with events; end; + * replace body of loop $Line in $Lines with begin commit $Line; end; * drop log * node 'Debug' *; * } * ``` * + * `replace body of ` keeps the stored loop — its element ID, position, + * iterator and list — and replaces only what is inside it (ako/mxcli#1693). + * Its alternative comes first: a pattern target is any run of tokens, so + * `body of loop …` would otherwise also read as the target of a plain replace. + * * A fragment is written exactly as the same statements are in `create * microflow`. A target is a content address, resolved by mfmutator: `$Var` * (the activity that outputs it), `'Caption'`, or a statement pattern with `*` @@ -481,6 +487,7 @@ alterTarget */ alterFlowOperation : INSERT (AFTER | BEFORE) alterFlowTarget alterFlowFragment SEMICOLON? + | REPLACE BODY OF alterFlowTarget WITH alterFlowFragment SEMICOLON? | REPLACE alterFlowTarget WITH alterFlowFragment SEMICOLON? | DROP alterFlowTarget SEMICOLON ; diff --git a/mdl/roundtrip/flow_commit_events_rerun_test.go b/mdl/roundtrip/flow_commit_events_rerun_test.go index 53a530be68..20ea9eb6c1 100644 --- a/mdl/roundtrip/flow_commit_events_rerun_test.go +++ b/mdl/roundtrip/flow_commit_events_rerun_test.go @@ -21,9 +21,10 @@ import ( // loop and the commit together, which bypassed the "changes inside its // body" refusal: under mdl 1 the loop was silently rebuilt with new $IDs. // -// Since ako/mxcli#1106 a value edit inside the body is spliced in place, so the -// loop-body change these tests use also grows the body (growBody): a change of -// the body's shape, which is still refused. +// Since ako/mxcli#1106 a value edit inside the body is spliced in place, and +// since ako/mxcli#1693 so is a plain activity added to it, so the loop-body +// change these tests use adds a decision to the body (growBody): a change of +// the body's shape the splice does not make, which is still refused. // // The bare-commit spelling is the control for both: it always behaved. func commitEventsFlow(name, commit, value string) string { @@ -39,9 +40,9 @@ end; `, name, value, commit) } -// growBody adds a statement to the loop body of a commitEventsFlow script. +// growBody adds a decision to the loop body of a commitEventsFlow script. func growBody(script string) string { - return strings.Replace(script, " end loop;", " log info node 'N' 'more';\n end loop;", 1) + return strings.Replace(script, " end loop;", " if $U/Name = empty then\n log info node 'N' 'more';\n end if;\n end loop;", 1) } func TestSpliceRerun_CommitWithEventsIsTheStoredCommit(t *testing.T) { diff --git a/mdl/roundtrip/flow_modify_test.go b/mdl/roundtrip/flow_modify_test.go index 758ef361e5..2b5e75556a 100644 --- a/mdl/roundtrip/flow_modify_test.go +++ b/mdl/roundtrip/flow_modify_test.go @@ -294,13 +294,13 @@ func TestFlowModify_UnspliceableChange(t *testing.T) { } } -// A change of a loop body's shape is not a splice: a body node's coordinates -// are relative to the loop box, and replacing the whole loop would renumber and -// redraw every node it holds — the rebuild's loss, confined to the loop but -// just as silent. So under mdl 1 it is refused, and under mdl 0 it takes the -// warned rebuild. A value edit of a body activity is spliced in place -// (ako/mxcli#1106), here beside an `if` in the body. Control: a change after -// the loop in the same flow is spliced. +// A decision added inside a loop body is not a splice: the splice puts plain +// activities in a body (ako/mxcli#1693), and replacing the whole loop would +// renumber and redraw every node it holds — the rebuild's loss, confined to the +// loop but just as silent. So under mdl 1 it is refused (naming `replace body +// of` the loop), and under mdl 0 it takes the warned rebuild. A value edit of a +// body activity is spliced in place (ako/mxcli#1106), here beside an `if` in +// the body. Control: a change after the loop in the same flow is spliced. func TestFlowModify_LoopBodyChangeIsNotSpliced(t *testing.T) { h := newHarness(t) defer h.close() @@ -338,8 +338,10 @@ end;` t.Errorf("a value edit in the loop body: want a splice, got:\n%s", h.out.String()) } described = h.mustDescribeMdl0(t, target) + // A decision added in the body: a change of its shape the splice does not + // make (a plain activity it does, ako/mxcli#1693). inLoop := strings.Replace(described, "log node 'X' 'in the loop';", - "log node 'X' 'in the loop';\n log node 'X' 'grown';", 1) + "log node 'X' 'in the loop';\n if $It/Subject = empty then\n log node 'X' 'grown';\n end if;", 1) if inLoop == described { t.Fatalf("describe output has no 'in the loop':\n%s", described) } diff --git a/mdl/roundtrip/flow_splice_parity_test.go b/mdl/roundtrip/flow_splice_parity_test.go index 2364e3bb40..10674fb7aa 100644 --- a/mdl/roundtrip/flow_splice_parity_test.go +++ b/mdl/roundtrip/flow_splice_parity_test.go @@ -150,10 +150,11 @@ func TestPedAppSpliceParity_AuthoredSpellings(t *testing.T) { // Control: a change exec refuses under mdl 1 is a refusal in diff too. t.Run("diff reports the refusal exec makes", func(t *testing.T) { - // A statement added inside the loop body: a change of its shape (a - // value edit there is spliced in place, ako/mxcli#1106). + // A decision added inside the loop body: a change of its shape (a + // value edit there is spliced in place, ako/mxcli#1106, and so is a + // plain activity added, ako/mxcli#1693). inLoop := strings.Replace(spliceParityFlow, "set $Total = $Total + $R/MonthKey;", - "set $Total = $Total + $R/MonthKey;\n set $Total = $Total + 1;", 1) + "set $Total = $Total + $R/MonthKey;\n if $R/MonthKey > 0 then\n set $Total = $Total + 1;\n end if;", 1) if inLoop == spliceParityFlow { t.Fatal("the flow has no loop-body set") } diff --git a/mdl/roundtrip/flow_verdict_agreement_test.go b/mdl/roundtrip/flow_verdict_agreement_test.go index 995ca1fdce..e45e6b2796 100644 --- a/mdl/roundtrip/flow_verdict_agreement_test.go +++ b/mdl/roundtrip/flow_verdict_agreement_test.go @@ -101,9 +101,9 @@ func agree(t *testing.T, mdl1 bool, v verdicts) bool { return v.execRebuilt } -// A statement added inside a stored loop's body: the splice does not edit inside -// a loop, so exec refuses it under mdl 1 and rebuilds under mdl 0. Before #876 -// check passed it. (This test used the rehearsal's G1 guard-clause shape until +// A decision added inside a stored loop's body: the splice puts plain +// activities there only (ako/mxcli#1693), so exec refuses it under mdl 1 and +// rebuilds under mdl 0. Before #876 check passed it. (This test used the rehearsal's G1 guard-clause shape until // #888 made the splice grow a guard.) func TestFlowVerdictAgreement_ChangeInsideLoopBody(t *testing.T) { h := newHarness(t) @@ -126,7 +126,9 @@ begin loop $U in $Items begin set $Out = $Out + ','; - set $Out = $Out + $U/Name; + if $U/Name != empty then + set $Out = $Out + $U/Name; + end if; end loop; return $Out; end; @@ -176,7 +178,9 @@ begin loop $U in $Items begin set $Out = $Out + ','; - set $Out = $Out + $U/Name; + if $U/Name != empty then + set $Out = $Out + $U/Name; + end if; end loop; return $Out; end; @@ -214,10 +218,11 @@ end; // TestFlowVerdictAgreement_Corpus: over the Studio Pro-authored flows of PedApp // and a subset of TestApp's, check, diff and exec reach the same verdict on // each flow's description executed back (which splices: nothing to refuse) and -// on the description with a statement added inside its first loop body (which -// the splice cannot make: refused under mdl 1, rebuilt under mdl 0). The -// second kind is the control: a corpus with no refusal in it would make the -// agreement vacuous. +// on the description with a plain activity added inside its first loop body +// (which splices since ako/mxcli#1693 — on Studio Pro-drawn loop bodies, in +// TestApp) and with a decision added there (which the splice cannot make: +// refused under mdl 1, rebuilt under mdl 0). The decision is the control: a +// corpus with no refusal in it would make the agreement vacuous. // // PedApp has no flow with a loop, so a fixture scanned in full that has none // gets an mxcli-authored one (verdictLoopControl) to probe: without it the @@ -252,6 +257,10 @@ func TestFlowVerdictAgreement_Corpus(t *testing.T) { if agree(t, mdl1, h.verdictsOf(withProbe)) { refusals++ } + withDecision := described[:i] + "if true then\ndeclare $VerdictProbe Integer = 0;\nend if;\n" + described[i:] + if agree(t, mdl1, h.verdictsOf(withDecision)) { + refusals++ + } }) } } diff --git a/mdl/visitor/visitor_alter_flow.go b/mdl/visitor/visitor_alter_flow.go index 181a6a2eb5..b8f22ab4b9 100644 --- a/mdl/visitor/visitor_alter_flow.go +++ b/mdl/visitor/visitor_alter_flow.go @@ -10,7 +10,7 @@ import ( ) // exitAlterFlowStatement builds an AlterFlowStmt from -// ALTER MICROFLOW|NANOFLOW Module.Name { insert / replace / drop }. +// ALTER MICROFLOW|NANOFLOW Module.Name { insert / replace / replace body of / drop }. func (b *Builder) exitAlterFlowStatement(ctx *parser.AlterStatementContext) { stmt := &ast.AlterFlowStmt{Nanoflow: ctx.NANOFLOW() != nil} if qn := ctx.QualifiedName(); qn != nil { @@ -24,6 +24,8 @@ func (b *Builder) exitAlterFlowStatement(ctx *parser.AlterStatementContext) { o.Op = ast.AlterFlowInsertBefore case op.INSERT() != nil: o.Op = ast.AlterFlowInsertAfter + case op.REPLACE() != nil && op.BODY() != nil: + o.Op = ast.AlterFlowReplaceBody case op.REPLACE() != nil: o.Op = ast.AlterFlowReplace default: From 7e5633197363e0778c4a629cf137d8dcdbd690e3 Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 25 Oct 2026 18:46:10 +0000 Subject: [PATCH 03/40] docs(skills): keep write-microflows under its 700-line body cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new `existing:` paragraph took the skill's body from 700 to 707 lines, which TestLargeSkillsWereSplit refuses (the body is loaded whole every time the skill is used). The detail already lives in reference/pitfalls.md §14, so SKILL.md keeps a one-line pointer on the paragraph about notes. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/mendix/write-microflows/SKILL.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index 177645a09e..418b6a32b3 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -26,7 +26,7 @@ description: "Microflow syntax reference in MDL — every activity type, control | 579-595 | Validation Checklist | | 596-607 | Tips for Success | | 608-614 | Related Documentation | -| 615-734 | Quick Reference | +| 615-727 | Quick Reference | # Mendix Microflow Skill @@ -671,14 +671,7 @@ Geometry is `position: (x, y)` and `size: (w, h)`; omitted, the note goes 100px above the activity at 200×50 (60px per extra), and DESCRIBE omits it again. **An `@annotation` before a statement's `@position`/`@caption`/`@color`/`@anchor` is a free note**, attached to nothing (where DESCRIBE prints one): put an -activity's own notes after them. - -**In an `alter microflow` fragment, a note already in the flow is named by its -text**: `@annotation(existing: 'Validates the order')` attaches the stored note to -the new statement — it never creates one, and the text must match exactly one note -or the statement is refused, listing the candidates. The `id:` labels describe -prints are not stored, so a fragment cannot use them -([pitfalls §14](reference/pitfalls.md#14-attaching-an-inserted-statement-to-a-note-the-flow-already-has)). +activity's own notes after them. In an `alter` fragment, attach a note the flow already has by its text, `@annotation(existing: '…')` — never by a describe `id:` ([pitfalls §14](reference/pitfalls.md#14-attaching-an-inserted-statement-to-a-note-the-flow-already-has)). ### Page Navigation Pattern ```mdl From 01985eca5eee587610767a4ea7ce1683c4c8d870 Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 25 Oct 2026 19:10:33 +0000 Subject: [PATCH 04/40] =?UTF-8?q?fix:=20take=20out=20a=20decision=20of=20t?= =?UTF-8?q?he=20shape=20alter=20=E2=80=A6=20insert=20produces=20(ako/mxcli?= =?UTF-8?q?#1694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After another script spliced a decision into a microflow (`alter microflow … insert after $X begin if … end if; end`), re-running the original `create or modify` script was refused under mdl 1 — "cannot drop the ExclusiveSplit … only an activity or a loop can be, because a decision or an end event changes the shape of the flow" — and so were `alter … drop` and `alter … replace` of the decision. Maintainer decision on the issue: support exactly the shape `alter … insert` produces; refuse every other shape with a specific reason. What changes - mfmutator (splice_decision.go): Drop and Replace of a split check that it has that shape — two paths of plain activities (or none), each node entered only along its path, no error handler, rejoining at one merge that only those two paths enter and that leads on to one node (insertedDecision). Drop joins the flows into the split to the merge's successor (keeping their $IDs, taking the end the merge's flow had); Replace puts the fragment where the split began, moving what is past the merge along only if it is longer. The split, the path activities, the merge and the flows between them go; notes attached only to path nodes go with them. Any other decision is refused naming the part out of reach: "its true path ends in a return", "… holds a decision", "… holds a loop", "… has an error handler", "N flows join at its merge", "it has N paths", "it splits by type". - MicroflowMutator.DecisionNodes: the path activities a drop or replace of a decision takes out. alter judges their outputs like the target's (checkOutputUnused / noteRemoved), so a variable a path declares that the flow still reads is refused "still used by". - The statement diff already emitted Drop/Replace for the stored `if`, so create or modify needed no change; check -p and exec share the verdict because both run the mutator. Verification - Tests first (flow_splice_decision_1694_pedapp_test.go, PedApp): re-running the original script after four inserted shapes (if/else, then only, two activities on a path, else only) reports "spliced: 1 dropped", leaves every original $ID and nothing else, and a second re-run is Unchanged with identical bytes (control: a replaced activity shows as 1 gone, 1 added); alter drop, alter replace, and create or modify replacing the decision; five out-of-shape decisions refused by check and exec alike for the re-run, drop and replace, writing nothing; a path output still read refused. mfmutator unit tests for the shape, geometry and notes. - Revert proofs: production files reverted — every new executor test fails ("cannot drop the ExclusiveSplit … only an activity or a loop can be"); the decision routing stubbed out of Drop/Replace — every mfmutator decision test fails. - Real app (blank 11.13.0): baseline refuses the re-run, the drop and the replace; new binary: re-run drops the decision — the flow is back to its 29 original element IDs, the 24 inserted ones gone — and a second re-run is Unchanged with a clean git status; alter drop: 29 kept, 0 added; alter replace: 29 kept, 6 added; describe → exec Unchanged; only the microflow's unit (and Base.mpr) written; mx check 0 errors. Bug-test and doctype scripts run twice on a fresh app: mx check 0 errors. - TestMxCheck_DoctypeScripts (02, 02b, 41, 02d) and the mdl/roundtrip integration suite pass; describe → exec over the 29 PedApp flows Unchanged. Docs: alter-microflow reference page, quick reference, syntax help, pitfalls §14 (SKILL.md net zero lines), bug-test and doctype scripts, finding. Co-Authored-By: Claude Opus 5.5 --- ...inserted-decision-cannot-be-taken-out.json | 1 + .../skills/mendix/write-microflows/SKILL.md | 2 +- .../write-microflows/reference/pitfalls.md | 25 ++ cmd/mxcli/syntax/features_microflow.go | 4 +- .../reference/microflow/alter-microflow.md | 23 ++ docs/01-project/MDL_QUICK_REFERENCE.md | 2 +- .../splice-1694-drop-inserted-decision.mdl | 105 ++++++ .../02d-microflow-splice-examples.mdl | 53 +++ mdl/backend/mfmutator/splice.go | 16 +- mdl/backend/mfmutator/splice_decision.go | 330 ++++++++++++++++++ mdl/backend/mfmutator/splice_decision_test.go | 260 ++++++++++++++ mdl/backend/microflow_mutation.go | 10 +- mdl/executor/cmd_alter_flow.go | 57 ++- .../flow_splice_decision_1694_pedapp_test.go | 250 +++++++++++++ 14 files changed, 1118 insertions(+), 20 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-25-inserted-decision-cannot-be-taken-out.json create mode 100644 mdl-examples/bug-tests/splice-1694-drop-inserted-decision.mdl create mode 100644 mdl/backend/mfmutator/splice_decision.go create mode 100644 mdl/backend/mfmutator/splice_decision_test.go create mode 100644 mdl/executor/flow_splice_decision_1694_pedapp_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-inserted-decision-cannot-be-taken-out.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-inserted-decision-cannot-be-taken-out.json new file mode 100644 index 0000000000..183929156c --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-inserted-decision-cannot-be-taken-out.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-25", "symptom": "After `alter microflow … insert after $X begin if … end if; end` spliced a decision in, re-running the original `create or modify` (mdl 1) was refused — 'cannot drop the ExclusiveSplit … only an activity or a loop can be, because a decision or an end event changes the shape of the flow' — and so were `alter … drop ''` and `alter … replace '' with …`, so the script that created the flow could not be re-run", "cause": "The mutator's Drop/Replace went through removable(), which accepts an activity or a loop only: a decision is several nodes (split, path activities, merge) and the splice had no notion of taking a subgraph out; the statement diff already produced the right Drop/Replace op for the stored `if`", "file": "mdl/backend/mfmutator/splice_decision.go (insertedDecision, dropDecision, replaceDecision, pathNotes, DecisionNodes); mdl/executor/cmd_alter_flow.go (takenOut: path activities' outputs judged like the target's)", "fix": "Drop/Replace of a split validate it has exactly the shape `alter … insert` produces — two paths of plain activities (or none), each entered only by its predecessor, no error handler, rejoining at one merge entered by those two paths alone and leading on to one node — then rewire the split's incoming flows to the merge's successor (drop) or the fragment (replace) and take out the split, path activities, merge and the flows between them; notes attached only to path nodes go too. The shape check is the one verdict exec and check -p share (both run the mutator), and every out-of-shape decision is refused with the part that is out of reach ('its true path ends in a return', '… holds a decision', '… has an error handler', '3 flows join at its merge', 'it has 3 paths')", "insight": "No executor diff change was needed: the LCS already emitted Drop of the stored `if` (and Replace for a run starting at it), so the whole refusal lived in one mutator guard — check the verb the diff emits before designing a new op. Two traps for tests: the path activities' outputs must be judged like the target's (a variable a path declares and the flow reads after the merge), or the drop leaves a dangling reader; and a 'rebuild re-mints every $ID' control does not work inside one executor session — a flow dropped and created again there gets its stored identities back — so the churn control is a replaced activity (1 gone, 1 added)", "refs": ["ako/mxcli#1694", "ako/mxcli#1399", "mendixlabs/mxcli#1517"], "test": "TestSpliceDecision1694_* (mdl/executor/flow_splice_decision_1694_pedapp_test.go); TestSpliceDecision_* (mdl/backend/mfmutator/splice_decision_test.go)"} diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index 1cb5ab53ec..7f0aef1d05 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -72,7 +72,7 @@ Choose the mode by who owns the microflow ([choose-edit-mode](../choose-edit-mod patched in place (a move keeps the node's flows), as are plain activities added, removed, reordered or changed in a loop body ([an insert needing room past a stated `@position`](reference/pitfalls.md#13-inserting-where-the-script-states-every-position)). A redrawn `@anchor`/`@curve`, a decision/loop/`return` added or changed inside a loop body, error handler or other `return` added/taken away rebuilds under mdl 0 (`MDL-V1-REBUILD`: IDs renumbered, merges and curves lost) and is refused under `mdl 1;`. **To put a - decision or loop in a loop body, `alter … replace body of loop $X in $Xs with begin … end;`** — the loop keeps its `$ID` ([pitfalls](reference/pitfalls.md#11-changing-something-inside-a-loop-body)). **To rebuild deliberately, `drop` and `create` in ONE script** — the grants carry only within it ([pitfalls](reference/pitfalls.md#drop--create-is-still-a-new-document)). + decision or loop in a loop body, `alter … replace body of loop $X in $Xs with begin … end;`** — the loop keeps its `$ID` ([pitfalls](reference/pitfalls.md#11-changing-something-inside-a-loop-body)); a decision `alter … insert` put in is taken out by a re-run, `drop` or `replace` ([pitfalls](reference/pitfalls.md#14-taking-out-a-decision-another-script-put-in)). **To rebuild deliberately, `drop` and `create` in ONE script** — the grants carry only within it ([pitfalls](reference/pitfalls.md#drop--create-is-still-a-new-document)). ## When to Use a Microflow vs a Nanoflow diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index cb97808007..1ba6a61702 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -405,6 +405,31 @@ it back. Either: A headerless (mdl 0) script still moves the row along, as it always did. +### 14. Taking Out a Decision Another Script Put In + +A decision spliced in by `alter microflow … insert` — `if … then … [else …] end if` whose +paths hold plain activities (or nothing) and rejoin at one merge — is taken out whole +(ako/mxcli#1694): re-running the script that created the flow drops it, and so does +`alter … drop` or `alter … replace` addressed by its handle (its caption, e.g. +`'$Order/Total > 100'`). The flow into the decision is joined to the node after its +merge; the decision, the activities on its paths (and notes on them) and the merge go, +and every other element keeps its `$ID`. A variable a path declares that the rest of +the flow still reads is refused like any dropped producer. + +```mdl +mdl 1; +alter microflow MyModule.ACT_Order { + drop '$Order/Total > 100'; +}; +``` + +Any other decision is refused, by `check` and `exec` alike, naming the part of the +shape that is out of reach — "its true path ends in a return", "its false path holds a +decision", "… on its true path has an error handler", "3 flows join at its merge", "it +has 3 paths". Those change where the flow's paths end or meet; rebuild the flow +deliberately (drop the microflow and create it in the same script) or edit it in Studio +Pro. Inside a loop body a decision is the loop's to replace (§11). + ## Implicit Variable Creation (CE0111 Duplicate Variable) These statements **implicitly create a new variable** with the name on the left side: diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index e4f8b5c533..11a277a02e 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -627,8 +627,10 @@ func init() { "-- REPLACE BODY OF keeps the loop itself (its $ID, position, iterator and list)\n" + "-- and rebuilds only what is inside it: the way to put a decision, a nested loop or a\n" + "-- return in a loop body. is the loop's handle: loop $It in $Items, while $N < 3.\n" + + "-- DROP/REPLACE of a decision takes it out whole when it has the shape INSERT produces:\n" + + "-- two paths of plain activities, or none, rejoining at one merge (ako/mxcli#1694).\n" + "-- Refused: insert after a decision, insert before an activity several flows enter,\n" + - "-- drop/replace of a decision or of an activity with an error handler, a decision, loop,\n" + + "-- drop/replace of any other decision or of an activity with an error handler, a decision, loop,\n" + "-- return or error handler put in a loop body (use REPLACE BODY OF), a fragment that\n" + "-- returns, and a fragment variable that clashes with one the flow has or reads one not\n" + "-- declared on the path. Over --mcp only insert is supported, and not inside a loop body.\n" + diff --git a/docs-site/src/reference/microflow/alter-microflow.md b/docs-site/src/reference/microflow/alter-microflow.md index f6510c54c7..97d8779d81 100644 --- a/docs-site/src/reference/microflow/alter-microflow.md +++ b/docs-site/src/reference/microflow/alter-microflow.md @@ -62,6 +62,18 @@ has outside the loop. `REPLACE WITH BEGIN LOOP … END LOOP; END;` also works, and rebuilds the loop as well — its element ID and position included. +### Decisions + +`DROP` and `REPLACE` take a decision out whole when it has the shape `INSERT` +produces: two paths of plain activities, or none, that rejoin at one merge. +The flow into the decision is joined to the node after its merge (or to the +replacing fragment), and the decision, the activities on its paths and the +merge go; every other element keeps its element ID. A decision is addressed by +its handle — its caption, `'$Order/Total > 100'`. Any other decision — a path +that returns or raises an error, a decision or loop on a path, an activity +with an error handler, a path from elsewhere joining it — is refused with the +part of its shape that is out of reach. + ### CREATE OR MODIFY `CREATE OR MODIFY MICROFLOW` of a flow that exists derives the same operations @@ -69,6 +81,8 @@ from the difference between the script and the stored flow. A plain activity added, removed, reordered or changed inside a loop body is spliced; under `mdl 1;` a change the splice cannot make is refused with the operation that makes it — for a decision inside a loop body, `REPLACE BODY OF` that loop. +Re-running the script that created a flow after another script spliced a +decision into it takes that decision out again, when it has the shape above. ## Examples @@ -106,6 +120,15 @@ ALTER MICROFLOW Shop.ACT_ProcessLines { }; ``` +Take out a decision another script put in: + +```sql +mdl 1; +ALTER MICROFLOW Shop.ACT_Order { + DROP '$Order/Total > 100'; +}; +``` + ## See Also [CREATE MICROFLOW](create-microflow.md), [DESCRIBE MICROFLOW](/reference/query/describe-microflow.md) diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index d40855b389..3c9ea50e1c 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -511,7 +511,7 @@ rather than updating the first. | Describe microflow (normalized) | `describe microflow Module.Name normalized;` | Folds crossed branches into one condition instead of flattening them. Opt-in: the output re-executes to an equivalent graph with fewer nodes and a different layout | | Describe microflow (with handles) | `describe microflow Module.Name with handles;` | Prints `-- handle: ` above each activity: its content address for `alter microflow` — output `$Var`, `'Caption'`, or a statement pattern with `*` wildcards (anchored at both ends), plus `@n` when several match. Comments only; cannot be combined with `normalized` | | Insert into a stored microflow | `alter microflow Module.Name { insert after begin end; };` | Also `insert before`, and `alter nanoflow`. A graph splice into the stored flow, not a rebuild: only the new activities, the two rewired flows and the objects moved to make room change; every other element keeps its `$ID`, position and curve. `` is a handle from `describe … with handles`, resolved before any operation runs. Inside a loop body, plain activities are inserted the same way, the loop box growing on its far side when the body no longer fits (ako/mxcli#1693); a fragment with a decision, a loop, a return, break/continue or an error handler is refused there, naming `replace body of`. Refused: after a decision, before an activity several flows enter, a fragment whose every path returns (a guard clause — `if … then return …; end if;` — is inserted: each return is a new end event, refused where its branch has no free room or would cross a stored flow), a variable the flow already has or one not declared on the path. The fragment is `begin … end` like a microflow body; `{ … }` is the deprecated spelling MDL-DEPR074 | -| Replace or drop an activity | `alter microflow Module.Name { replace with begin end; drop ; };` | Flows into the activity are re-pointed at the replacement (or at its successor, for `drop`). Works on an activity inside a loop body too (the body's first or last included). Refused for a decision, an end event, an activity with an error handler, and an activity whose output variable is still read. Over `--mcp` only `insert` is supported, and not inside a loop body | +| Replace or drop an activity | `alter microflow Module.Name { replace with begin end; drop ; };` | Flows into the activity are re-pointed at the replacement (or at its successor, for `drop`). Works on an activity inside a loop body too (the body's first or last included). A decision is taken out whole — split, the activities on its two paths and its merge, the flow into it joined to the node after it — when it has the shape `alter … insert` produces: two paths of plain activities, or none, rejoining at one merge (ako/mxcli#1694); any other decision is refused with the part of its shape that is out of reach. Refused for an end event, an activity with an error handler, and an activity (or a decision's path activity) whose output variable is still read. Over `--mcp` only `insert` is supported, and not inside a loop body | | Replace a loop's body | `alter microflow Module.Name { replace body of loop $It in $Items with begin end; };` | Keeps the loop itself — its `$ID`, position, iterator, list and the flows and notes around it — and rebuilds only what is inside it (ako/mxcli#1693): the change a splice inside a body does not make (a decision, a nested loop, a return). The box keeps its size when the new body fits and grows on its far side when it does not. Address the loop by its handle (`loop $It in $Items`, `while $N < 3`), at any depth. The body is scope-checked like a fragment: it reads the iterator, what it declares and what is declared before the loop, and declares nothing the flow has outside it | | Describe nanoflow | `describe nanoflow Module.Name;` | Full MDL with activities | | Rename microflow | `rename microflow Module.Old to New;` | Updates all references | diff --git a/mdl-examples/bug-tests/splice-1694-drop-inserted-decision.mdl b/mdl-examples/bug-tests/splice-1694-drop-inserted-decision.mdl new file mode 100644 index 0000000000..96d4933870 --- /dev/null +++ b/mdl-examples/bug-tests/splice-1694-drop-inserted-decision.mdl @@ -0,0 +1,105 @@ +-- ============================================================================ +-- ako/mxcli#1694 (mendixlabs/mxcli#1517) — take out a decision of the shape +-- `alter … insert` puts in +-- ============================================================================ +-- +-- After another script spliced a decision into a microflow +-- (`alter microflow … insert after $X begin if … end if; end`), re-running +-- the script that created the flow means taking that decision out again. It +-- was refused, and so were `alter … drop` and `alter … replace` of it: +-- +-- cannot drop the ExclusiveSplit '…' at (…): only an activity or a loop can +-- be, because a decision or an end event changes the shape of the flow +-- +-- The splice now takes out exactly the shape `alter … insert` produces — two +-- paths of plain activities, or none, that rejoin at one merge — joining the +-- flow into the decision to the node after its merge; every other element +-- keeps its element ID. Any other decision (a path that returns or raises an +-- error, a nested decision or loop on a path, an activity with an error +-- handler, a path from elsewhere joining it) is still refused, saying which. +-- +-- To reproduce: exec this file against any project. Expected: +-- Created microflow: BugTest1694.Decide (first run) +-- Altered microflow BugTest1694.Decide +-- Modified microflow: BugTest1694.Decide (spliced: 1 dropped) +-- Altered microflow BugTest1694.Decide (insert again) +-- Altered microflow BugTest1694.Decide (drop) +-- Altered microflow BugTest1694.Decide (insert again) +-- Altered microflow BugTest1694.Decide (replace) +-- Modified microflow: BugTest1694.Decide (spliced: 1 dropped) +-- Before the fix exec stopped at the first re-run of the create: "this +-- change cannot be spliced into the stored flow: … cannot drop the +-- ExclusiveSplit …". mx check: 0 errors. The file can be run again. + +mdl 1; + +create module BugTest1694; + +create or modify persistent entity BugTest1694.Item ( + Name: String(100), + Count: Integer +); + +drop microflow if exists BugTest1694.Decide; + +create microflow BugTest1694.Decide ($Item: BugTest1694.Item) +begin + log info node 'BugTest1694' 'start'; + change $Item (Count = 1); + log info node 'BugTest1694' 'done'; +end; + +-- Another script splices a decision in. +alter microflow BugTest1694.Decide { + insert after log node 'BugTest1694' 'start' begin + if $Item/Count > 10 then + log info node 'BugTest1694' 'big'; + else + log info node 'BugTest1694' 'small'; + end if; + end; +}; + +-- The original script, run again: the decision is taken out. +create or modify microflow BugTest1694.Decide ($Item: BugTest1694.Item) +begin + log info node 'BugTest1694' 'start'; + change $Item (Count = 1); + log info node 'BugTest1694' 'done'; +end; + +-- The same decision, taken out by alter: drop … +alter microflow BugTest1694.Decide { + insert after log node 'BugTest1694' 'start' begin + if $Item/Count > 10 then + log info node 'BugTest1694' 'big'; + end if; + end; +}; + +alter microflow BugTest1694.Decide { + drop '$Item/Count > 10'; +}; + +-- … and replace, addressed by its caption. +alter microflow BugTest1694.Decide { + insert after log node 'BugTest1694' 'start' begin + if $Item/Count > 10 then + log info node 'BugTest1694' 'big'; + end if; + end; +}; + +alter microflow BugTest1694.Decide { + replace '$Item/Count > 10' with begin + log info node 'BugTest1694' 'no decision'; + end; +}; + +-- The original script once more: the replacement is dropped. +create or modify microflow BugTest1694.Decide ($Item: BugTest1694.Item) +begin + log info node 'BugTest1694' 'start'; + change $Item (Count = 1); + log info node 'BugTest1694' 'done'; +end; diff --git a/mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl b/mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl index 57a0ec9783..48c446481c 100644 --- a/mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl +++ b/mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl @@ -16,6 +16,10 @@ mdl 1; -- loop is rebuilt, the loop itself (its element ID, -- position, iterator and list) stays (ako/mxcli#1693) -- 3. SP_WhileBody — the same for a while loop, addressed by its handle +-- 4. SP_Decide — a decision `alter … insert` put in, taken out again: +-- by re-running the original `create or modify`, by +-- `alter … drop` and by `alter … replace` +-- (ako/mxcli#1694) -- -- Usage: -- ./bin/mxcli check mdl-examples/doctype-tests/02d-microflow-splice-examples.mdl @@ -103,3 +107,52 @@ alter microflow SpliceExamples.SP_WhileBody { log info node 'Splice' 'step {1}' with ({1} = toString($N)); end; }; + +-- ============================================================================ +-- 4. Taking out a decision `alter … insert` put in +-- ============================================================================ +-- +-- A decision whose two paths hold plain activities (or nothing) and rejoin at +-- one merge — the shape `alter … insert` produces — is taken out whole: the +-- flow into it is joined to the node after its merge, and every other element +-- keeps its element ID. Any other decision shape is refused. + +create microflow SpliceExamples.SP_Decide ($Line: SpliceExamples.Line) +begin + log info node 'Splice' 'start'; + change $Line (Processed = true); + log info node 'Splice' 'done'; +end; + +alter microflow SpliceExamples.SP_Decide { + insert after log node 'Splice' 'start' begin + if $Line/Quantity > 10 then + log info node 'Splice' 'large'; + else + log info node 'Splice' 'small'; + end if; + end; +}; + +-- The original script, run again, takes the decision out. +create or modify microflow SpliceExamples.SP_Decide ($Line: SpliceExamples.Line) +begin + log info node 'Splice' 'start'; + change $Line (Processed = true); + log info node 'Splice' 'done'; +end; + +-- So do `drop` and `replace`, addressing the decision by its caption. +alter microflow SpliceExamples.SP_Decide { + insert after log node 'Splice' 'start' begin + if $Line/Quantity > 10 then + log info node 'Splice' 'large'; + end if; + end; +}; + +alter microflow SpliceExamples.SP_Decide { + replace '$Line/Quantity > 10' with begin + log info node 'Splice' 'checked'; + end; +}; diff --git a/mdl/backend/mfmutator/splice.go b/mdl/backend/mfmutator/splice.go index f7766de7e1..a1f7a1f6fb 100644 --- a/mdl/backend/mfmutator/splice.go +++ b/mdl/backend/mfmutator/splice.go @@ -457,6 +457,9 @@ func (m *Mutator) Replace(target model.ID, frag *backend.MicroflowFragment) erro if err != nil { return err } + if isDecision(x) { + return m.replaceDecision(g, x, frag) + } if _, errs := g.outgoing(x.id); len(errs) > 0 && x.typ == "Microflows$ActionActivity" { return m.replaceKeepingHandler(g, x, frag) } @@ -1145,6 +1148,9 @@ func (m *Mutator) Drop(target model.ID) error { if x.loop != "" { return m.dropInLoop(g, x) } + if isDecision(x) { + return m.dropDecision(g, x) + } out, err := removable(g, x, "drop") if err != nil { return err @@ -1296,8 +1302,8 @@ func removable(g *graph, x *node, verb string) (flowRef, error) { switch x.typ { case "Microflows$ActionActivity", "Microflows$LoopedActivity": default: - return flowRef{}, fmt.Errorf("cannot %s %s: only an activity or a loop can be, because a decision or an end event "+ - "changes the shape of the flow", verb, describeNode(x)) + return flowRef{}, fmt.Errorf("cannot %s %s: only an activity, a loop or a decision of the shape `alter … insert` "+ + "puts in can be, because an end event or a merge is where the flow's paths end or meet", verb, describeNode(x)) } normal, errs := g.outgoing(x.id) if len(errs) > 0 { @@ -1837,6 +1843,12 @@ func (m *Mutator) ReplaceLoopBody(target model.ID, frag *backend.MicroflowFragme return nil } +// isDecision reports whether n is a split: a decision on a condition or by +// type. +func isDecision(n *node) bool { + return n.typ == "Microflows$ExclusiveSplit" || n.typ == "Microflows$InheritanceSplit" +} + // InLoopBody reports whether the stored object id is inside a loop body. func (m *Mutator) InLoopBody(id model.ID) bool { n, ok := m.graph().nodes[string(id)] diff --git a/mdl/backend/mfmutator/splice_decision.go b/mdl/backend/mfmutator/splice_decision.go new file mode 100644 index 0000000000..b6ab941e8e --- /dev/null +++ b/mdl/backend/mfmutator/splice_decision.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "fmt" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/model" +) + +// # Taking out a decision (ako/mxcli#1694) +// +// `alter … insert` puts a decision into a flow — `if … then … [else …] end if` +// as a fragment — and the script that created the flow, run again, has to take +// it out. The splice takes out exactly that shape: an exclusive split whose two +// paths hold plain activities (or nothing) and rejoin at one merge, which leads +// on to one node. Dropping it joins the flows that entered the split to the +// node after the merge; replacing it puts a fragment where the split began. +// Either way the split, the activities on its paths, the merge and the flows +// between them go, and every other element keeps its $ID. +// +// Any other decision — a path that returns, raises an error, holds a decision +// or a loop, an activity on a path with an error handler, a path from +// elsewhere joining one of its paths or its merge — is a change of where the +// flow's paths end or meet, and is refused with the part that is out of reach. + +// decisionShape is a decision of the shape `alter … insert` produces. +type decisionShape struct { + split, merge *node + // inner are the activities on the decision's paths, in path order. + inner []*node + // in are the flows that enter the split; out the one that leaves the + // merge. + in []flowRef + out flowRef + // flows are the flows from the split to the merge, on both paths. + flows map[string]bool +} + +func (sh *decisionShape) nodes() []*node { + out := append([]*node{sh.split}, sh.inner...) + return append(out, sh.merge) +} + +// insertedDecision checks that x is a decision of the shape `alter … insert` +// produces, and returns it. verb says what is being done to it, for the +// refusal. +func insertedDecision(g *graph, x *node, verb string) (*decisionShape, error) { + refuse := func(format string, args ...any) error { + return fmt.Errorf("cannot %s %s: %s; the splice takes out a decision only of the shape `alter … insert` "+ + "puts in — two paths of plain activities, or none, that rejoin at one merge (ako/mxcli#1694)", + verb, describeNode(x), fmt.Sprintf(format, args...)) + } + switch x.typ { + case "Microflows$ExclusiveSplit": + case "Microflows$InheritanceSplit": + return nil, refuse("it splits by type, not on a condition") + default: + return nil, refuse("it is not a decision") + } + normal, errs := g.outgoing(x.id) + if len(errs) > 0 { + return nil, refuse("it has an error handler") + } + if len(normal) != 2 { + return nil, refuse("it has %d paths, not two", len(normal)) + } + sh := &decisionShape{split: x, flows: map[string]bool{}} + for _, f := range normal { + label := caseLabel(f) + sh.flows[f.id] = true + n := g.nodes[f.dest] + for n != nil && n.typ == "Microflows$ActionActivity" { + if in := g.incoming(n.id); len(in) != 1 { + return nil, refuse("%d flows enter %s on its %s path, so a path from elsewhere joins it there", + len(in), describeNode(n), label) + } + out, errs := g.outgoing(n.id) + if len(errs) > 0 { + return nil, refuse("%s on its %s path has an error handler", describeNode(n), label) + } + if len(out) != 1 { + return nil, refuse("%s on its %s path has %d outgoing flows, not one", describeNode(n), label, len(out)) + } + sh.inner = append(sh.inner, n) + sh.flows[out[0].id] = true + n = g.nodes[out[0].dest] + } + if n == nil { + return nil, refuse("its %s path leads to an object that is not in the flow", label) + } + switch n.typ { + case "Microflows$ExclusiveMerge": + case "Microflows$EndEvent": + return nil, refuse("its %s path ends in a return", label) + case "Microflows$ErrorEvent": + return nil, refuse("its %s path ends in a raise error", label) + case "Microflows$ExclusiveSplit", "Microflows$InheritanceSplit": + return nil, refuse("its %s path holds a decision (%s)", label, describeNode(n)) + case "Microflows$LoopedActivity": + return nil, refuse("its %s path holds a loop (%s)", label, describeNode(n)) + case "Microflows$BreakEvent", "Microflows$ContinueEvent": + return nil, refuse("its %s path ends in a %s", label, describeNode(n)) + default: + return nil, refuse("its %s path reaches %s", label, describeNode(n)) + } + if sh.merge != nil && sh.merge != n { + return nil, refuse("its paths do not rejoin at one merge") + } + sh.merge = n + } + if in := g.incoming(sh.merge.id); len(in) != 2 { + return nil, refuse("%d flows join at its merge, %s, not its two paths alone", len(in), describeNode(sh.merge)) + } + out, errs := g.outgoing(sh.merge.id) + if len(out) != 1 || len(errs) > 0 { + return nil, refuse("its merge, %s, does not lead on to one node", describeNode(sh.merge)) + } + sh.out = out[0] + sh.in = g.incoming(x.id) + if len(sh.in) == 0 { + return nil, refuse("no flow enters it") + } + return sh, nil +} + +// caseLabel is how a decision's path is named in a message: the case value +// its flow carries ("true", "false"), or "one" when it carries none. +func caseLabel(f flowRef) string { + for _, el := range arrayElements(dGet(f.doc, "CaseValues")) { + if d, ok := el.(bson.D); ok { + if v := dString(d, "Value"); v != "" { + return v + } + } + } + if d := dDoc(f.doc, "NewCaseValue"); d != nil { + if v := dString(d, "Value"); v != "" { + return v + } + } + return "one" +} + +// dropDecision takes the decision x out, when it has the shape `alter … +// insert` produces: the flows that entered the split now enter the node the +// merge led to, with the end that flow had there; the split, the activities +// on its paths, the merge and the flows between them go, and so do the notes +// on its paths (pathNotes). The split's own note lines go and the notes stay, +// as for a dropped activity. Nothing else moves: the gap stays, as it does +// for a drop. +func (m *Mutator) dropDecision(g *graph, x *node) error { + sh, err := insertedDecision(g, x, "drop") + if err != nil { + return err + } + for _, in := range sh.in { + if in.origin == sh.out.dest { + return fmt.Errorf("dropping %s would leave a flow from an object to itself", describeNode(x)) + } + } + destIdx, destVec := flowEnd(sh.out.doc, "Destination") + destPtr := dGet(sh.out.doc, "DestinationPointer") + for _, in := range sh.in { + setBinary(in.doc, "DestinationPointer", destPtr) + setInt(in.doc, "DestinationConnectionIndex", destIdx) + setVector(in.doc, "DestinationControlVector", destVec) + } + drop := map[string]bool{sh.out.id: true} + for id := range sh.flows { + drop[id] = true + } + for _, af := range g.annotationFlows(x.id) { + drop[af.id] = true + } + notes := pathNotes(g, sh, drop) + m.removeFlows(drop) + for _, n := range append(sh.nodes(), notes...) { + if err := m.removeObject(n); err != nil { + return err + } + } + return nil +} + +// pathNotes adds to drop the lines of the notes attached to the activities on +// the decision's paths and to its merge, and returns the notes attached to +// nothing else: those go with the decision, which takes what is on its paths +// out wholesale — a note left behind would be a free note on the canvas about +// an activity that is gone. A note also attached to a node that stays keeps +// that line, and stays. The split's own notes are the caller's: they follow +// the decision as a dropped or replaced activity's do. +func pathNotes(g *graph, sh *decisionShape, drop map[string]bool) []*node { + gone := map[string]bool{} + for _, n := range append(append([]*node(nil), sh.inner...), sh.merge) { + gone[n.id] = true + } + var notes []*node + seen := map[string]bool{} + for _, n := range append(append([]*node(nil), sh.inner...), sh.merge) { + for _, af := range g.annotationFlows(n.id) { + drop[af.id] = true + other := af.origin + if other == n.id { + other = af.dest + } + note := g.nodes[other] + if note == nil || note.typ != "Microflows$Annotation" || seen[note.id] { + continue + } + seen[note.id] = true + alone := true + for _, f := range g.annotationFlows(note.id) { + end := f.origin + if end == note.id { + end = f.dest + } + if !gone[end] { + alone = false + } + } + if alone { + notes = append(notes, note) + } + } + } + return notes +} + +// replaceDecision puts frag where the decision x is, when it has the shape +// `alter … insert` produces: the flows that entered the split enter the +// fragment, the flow that left the merge leaves the fragment's exit, and the +// notes on the split are attached to the fragment's entry, as for a replaced +// activity. The fragment starts where the split started; when it is longer +// than the decision was, what is past the merge moves along. +func (m *Mutator) replaceDecision(g *graph, x *node, frag *backend.MicroflowFragment) error { + sh, err := insertedDecision(g, x, "replace") + if err != nil { + return err + } + fb, err := fragmentGeometry(frag) + if err != nil { + return err + } + ax, s, err := flowAxis(sh.split, sh.merge) + if err != nil { + return err + } + near := sh.split.pos.get(ax) - s*sh.split.size.get(ax)/2 + far := sh.merge.pos.get(ax) + s*sh.merge.size.get(ax)/2 + span := s * (far - near) + cross := sh.split.pos.get(ax.other()) + mergeAt := sh.merge.pos.get(ax) + + // The decision goes first, so the room the fragment needs is judged on + // the flow without it. The flows into it and out of it stay, re-pointed + // below; the split's note lines go to the fragment's entry. + drop := map[string]bool{} + for id := range sh.flows { + drop[id] = true + } + notes := pathNotes(g, sh, drop) + m.removeFlows(drop) + for _, n := range append(sh.nodes(), notes...) { + if err := m.removeObject(n); err != nil { + return err + } + } + if !frag.Placed { + g2 := m.graph() + if extra := fb.length(ax) - span; extra > 0 { + cut := float64(mergeAt) + if p := m.pinnedPast(g2, "", ax, s, cut, ""); p != nil { + return fmt.Errorf("the replacing statements are %dpx longer than %s was, and making room for them would "+ + "move %s, which the script draws where it is; %s", extra, describeNode(x), describeNode(p), roomAdvice) + } + m.shiftIn(g2, "", ax, s, cut, s*extra, "") + } + centre := point{} + centre = centre.set(ax, near+s*fb.length(ax)/2) + centre = centre.set(ax.other(), cross) + fb.placeCentred(ax, centre) + if err := fb.checkRoom(g2, "", ""); err != nil { + return err + } + } + if err := m.addFragment(frag); err != nil { + return err + } + for _, in := range sh.in { + setPointer(in.doc, "DestinationPointer", frag.Entry) + } + setPointer(sh.out.doc, "OriginPointer", frag.Exit) + if frag.ExitCase != nil { + if err := m.setCase(sh.out.doc, frag.ExitCase); err != nil { + return err + } + } + for _, af := range g.annotationFlows(x.id) { + key := "DestinationPointer" + if af.origin == x.id { + key = "OriginPointer" + } + setPointer(af.doc, key, frag.Entry) + } + return nil +} + +// DecisionNodes returns the stored activities on the paths of the decision +// target, when it has the shape `alter … insert` produces — what taking it out +// takes with it, besides the split and its merge — and nil otherwise. +func (m *Mutator) DecisionNodes(target model.ID) []model.ID { + g := m.graph() + x, ok := g.nodes[string(target)] + if !ok { + return nil + } + sh, err := insertedDecision(g, x, "take out") + if err != nil { + return nil + } + var out []model.ID + for _, n := range sh.inner { + out = append(out, model.ID(n.id)) + } + return out +} diff --git a/mdl/backend/mfmutator/splice_decision_test.go b/mdl/backend/mfmutator/splice_decision_test.go new file mode 100644 index 0000000000..1ae374ffa5 --- /dev/null +++ b/mdl/backend/mfmutator/splice_decision_test.go @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "bytes" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/model" +) + +// ako/mxcli#1694: a decision of the shape `alter … insert` produces — two +// paths of plain activities, or none, rejoining at one merge — is taken out by +// a drop or a replace; any other decision is refused, saying which part of +// the shape is out of reach. + +// caseFlow is flow with the case value an exclusive split's path carries. +func caseFlow(name, from, to string, fromSide, toSide int32, value string) bson.D { + f := flow(name, from, to, fromSide, toSide, false) + dSet(f, "CaseValues", bson.A{int32(2), bson.D{ + {Key: "$ID", Value: bin(name + "c")}, + {Key: "$Type", Value: "Microflows$EnumerationCase"}, + {Key: "Value", Value: value}, + }}) + return f +} + +// decisionUnit is start -> a -> split -> (true: t1 | false: f1) -> merge -> b +// -> end, the shape `alter … insert` produces for `if … then … else … end if`. +// edit changes the objects and flows before the unit is built. +func decisionUnit(edit func(objs []bson.D, flows []bson.D) ([]bson.D, []bson.D)) bson.D { + objs := []bson.D{ + obj("start", "Microflows$StartEvent", 100, 200), + obj("a", "Microflows$ActionActivity", 250, 200), + obj("split", "Microflows$ExclusiveSplit", 400, 200), + obj("t1", "Microflows$ActionActivity", 550, 200), + obj("f1", "Microflows$ActionActivity", 550, 300), + obj("merge", "Microflows$ExclusiveMerge", 700, 200), + obj("b", "Microflows$ActionActivity", 850, 200), + obj("end", "Microflows$EndEvent", 1000, 200), + } + flows := []bson.D{ + flow("f1s", "start", "a", 1, 3, false), + flow("fa", "a", "split", 1, 3, false), + caseFlow("ft", "split", "t1", 1, 3, "true"), + caseFlow("ff", "split", "f1", 2, 3, "false"), + flow("ftm", "t1", "merge", 1, 3, false), + flow("ffm", "f1", "merge", 1, 2, false), + flow("fm", "merge", "b", 1, 3, false), + flow("fb", "b", "end", 1, 3, false), + } + if edit != nil { + objs, flows = edit(objs, flows) + } + return unit(objs, flows) +} + +func TestSpliceDecision_DropTakesTheShapeOut(t *testing.T) { + m, deps := newMutator(t, decisionUnit(nil)) + if got := m.DecisionNodes(model.ID(uid("split"))); len(got) != 2 { + t.Fatalf("want the two path activities, got %v", got) + } + if err := m.Drop(model.ID(uid("split"))); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + pos, _ := geometry(t, deps.saved) + for _, gone := range []string{"split", "t1", "f1", "merge"} { + if _, ok := pos[uid(gone)]; ok { + t.Errorf("%s is still in the unit", gone) + } + } + for _, kept := range []string{"start", "a", "b", "end"} { + if _, ok := pos[uid(kept)]; !ok { + t.Errorf("%s was taken out", kept) + } + } + e := edges(t, deps.saved) + if !e[[2]string{uid("a"), uid("b")}] || len(e) != 3 { + t.Errorf("want start -> a -> b -> end, got %v", e) + } + // The flow a -> b is the stored a -> split flow, re-pointed: its $ID is + // kept. + if !bytes.Contains(deps.saved, bin("fa").Data) || bytes.Contains(deps.saved, bin("fm").Data) { + t.Error("want the flow into the decision kept and the one out of its merge gone") + } +} + +func TestSpliceDecision_OnePathEmpty(t *testing.T) { + // `if … then … end if`: the false path runs straight to the merge. + m, deps := newMutator(t, decisionUnit(func(objs, flows []bson.D) ([]bson.D, []bson.D) { + var o []bson.D + for _, d := range objs { + if binaryID(dGet(d, "$ID")) != uid("f1") { + o = append(o, d) + } + } + var f []bson.D + for _, d := range flows { + switch binaryID(dGet(d, "$ID")) { + case uid("ff"): + f = append(f, caseFlow("ff", "split", "merge", 2, 2, "false")) + case uid("ffm"): + default: + f = append(f, d) + } + } + return o, f + })) + if err := m.Drop(model.ID(uid("split"))); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + if e := edges(t, deps.saved); !e[[2]string{uid("a"), uid("b")}] || len(e) != 3 { + t.Errorf("want start -> a -> b -> end, got %v", e) + } +} + +func TestSpliceDecision_ReplaceTakesItsPlace(t *testing.T) { + m, deps := newMutator(t, decisionUnit(nil)) + frag := oneActivity() + if err := m.Replace(model.ID(uid("split")), frag); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + e := edges(t, deps.saved) + id := string(frag.Entry) + if !e[[2]string{uid("a"), id}] || !e[[2]string{id, uid("b")}] || len(e) != 4 { + t.Errorf("want start -> a -> new -> b -> end, got %v", e) + } + pos, _ := geometry(t, deps.saved) + // It starts where the split started (x 390), and fits in the span the + // decision took (390 .. 710): nothing moves. + if pos[id] != (point{450, 200}) || pos[uid("b")] != (point{850, 200}) { + t.Errorf("the activity is at %v and b at %v, want 450;200 and 850;200", pos[id], pos[uid("b")]) + } +} + +func TestSpliceDecision_OtherShapesRefused(t *testing.T) { + without := func(drop ...string) func(objs, flows []bson.D) ([]bson.D, []bson.D) { + return func(objs, flows []bson.D) ([]bson.D, []bson.D) { + skip := map[string]bool{} + for _, d := range drop { + skip[uid(d)] = true + } + var o, f []bson.D + for _, d := range objs { + if !skip[binaryID(dGet(d, "$ID"))] { + o = append(o, d) + } + } + for _, d := range flows { + if !skip[binaryID(dGet(d, "$ID"))] { + f = append(f, d) + } + } + return o, f + } + } + plus := func(base func(objs, flows []bson.D) ([]bson.D, []bson.D), objs []bson.D, flows []bson.D) func(o, f []bson.D) ([]bson.D, []bson.D) { + return func(o, f []bson.D) ([]bson.D, []bson.D) { + if base != nil { + o, f = base(o, f) + } + return append(o, objs...), append(f, flows...) + } + } + cases := []struct { + name string + edit func(objs, flows []bson.D) ([]bson.D, []bson.D) + want string + }{ + {"a path that returns", plus(without("ffm"), + []bson.D{obj("ret", "Microflows$EndEvent", 700, 300)}, + []bson.D{flow("fr", "f1", "ret", 1, 3, false)}), "its false path ends in a return"}, + {"a nested decision", plus(without("ffm"), + []bson.D{obj("inner", "Microflows$ExclusiveSplit", 650, 300)}, + []bson.D{flow("fi", "f1", "inner", 1, 3, false), caseFlow("fi1", "inner", "merge", 1, 2, "true"), caseFlow("fi2", "inner", "merge", 2, 2, "false")}), + "its false path holds a decision"}, + {"an error handler on a path", plus(nil, + []bson.D{obj("h", "Microflows$ActionActivity", 550, 450)}, + []bson.D{flow("fh", "t1", "h", 2, 0, true), flow("fhm", "h", "merge", 1, 2, false)}), + "has an error handler"}, + {"a path from elsewhere joins the merge", plus(nil, + []bson.D{obj("x", "Microflows$ActionActivity", 550, 450)}, + []bson.D{flow("fx", "x", "merge", 1, 2, false)}), + "3 flows join at its merge"}, + {"three paths", plus(nil, + []bson.D{obj("x", "Microflows$ActionActivity", 550, 100)}, + []bson.D{caseFlow("fx", "split", "x", 0, 3, "other"), flow("fxm", "x", "merge", 1, 0, false)}), + "it has 3 paths"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for _, verb := range []string{"drop", "replace"} { + m, _ := newMutator(t, decisionUnit(tc.edit)) + before, _ := bson.Marshal(m.doc) + var err error + if verb == "drop" { + err = m.Drop(model.ID(uid("split"))) + } else { + err = m.Replace(model.ID(uid("split")), oneActivity()) + } + if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "cannot "+verb) { + t.Fatalf("%s: want a refusal saying %q, got %v", verb, tc.want, err) + } + after, _ := bson.Marshal(m.doc) + if !bytes.Equal(before, after) { + t.Errorf("%s: a refused operation changed the document", verb) + } + if m.DecisionNodes(model.ID(uid("split"))) != nil { + t.Errorf("DecisionNodes of a refused shape must be nil") + } + } + }) + } +} + +// A note on an activity of the decision's paths goes with it; one also +// attached to a node that stays keeps that line, and stays. +func TestSpliceDecision_PathNotes(t *testing.T) { + note := func(name string, x, y int) bson.D { return obj(name, "Microflows$Annotation", x, y) } + line := func(name, from, to string) bson.D { + return bson.D{ + {Key: "$ID", Value: bin(name)}, + {Key: "$Type", Value: "Microflows$AnnotationFlow"}, + {Key: "DestinationPointer", Value: bin(to)}, + {Key: "OriginPointer", Value: bin(from)}, + } + } + m, deps := newMutator(t, decisionUnit(func(objs, flows []bson.D) ([]bson.D, []bson.D) { + return append(objs, note("own", 550, 100), note("shared", 700, 100)), + append(flows, line("l1", "own", "t1"), line("l2", "shared", "f1"), line("l3", "shared", "b")) + })) + if err := m.Drop(model.ID(uid("split"))); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + pos, _ := geometry(t, deps.saved) + if _, ok := pos[uid("own")]; ok { + t.Error("the note on a path activity stayed behind") + } + if _, ok := pos[uid("shared")]; !ok { + t.Error("the note also attached to b was taken out") + } + if !bytes.Contains(deps.saved, bin("l3").Data) || bytes.Contains(deps.saved, bin("l2").Data) { + t.Error("want the shared note's line to b kept and its line to f1 gone") + } +} diff --git a/mdl/backend/microflow_mutation.go b/mdl/backend/microflow_mutation.go index 8b809e5b10..0fe082dd37 100644 --- a/mdl/backend/microflow_mutation.go +++ b/mdl/backend/microflow_mutation.go @@ -49,7 +49,9 @@ type MicroflowMutator interface { InsertBefore(target model.ID, frag *MicroflowFragment) error // Replace puts frag in target's place and removes target. Replace(target model.ID, frag *MicroflowFragment) error - // Drop removes target and joins its incoming flows to its successor. + // Drop removes target and joins its incoming flows to its successor. A + // decision of the shape `alter … insert` produces goes with its paths and + // its merge (ako/mxcli#1694); any other decision is refused. Drop(target model.ID) error // ReplaceLoopBody replaces everything inside the loop target by the body // of the one loop frag holds — the loop restated with its new body, as the @@ -58,6 +60,12 @@ type MicroflowMutator interface { // box keeps its size where the new body fits it, and grows where it does // not. ReplaceLoopBody(target model.ID, frag *MicroflowFragment) error + // DecisionNodes returns the activities on the paths of the decision + // target when it has the shape `alter … insert` produces — two paths of + // plain activities, or none, rejoining at one merge — which a Drop or a + // Replace of it takes out with it (ako/mxcli#1694); nil for any other + // target. + DecisionNodes(target model.ID) []model.ID // RemoveNotes removes the annotations attached to target, with their // lines, so that a Replace or Drop after it does not keep them // (ako/mxcli#859: a `create or modify` states an activity's notes). A diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go index 6c53bd5416..890a60b9db 100644 --- a/mdl/executor/cmd_alter_flow.go +++ b/mdl/executor/cmd_alter_flow.go @@ -119,17 +119,27 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato return fail(err) } } + // A decision taken out takes the activities on its paths with it + // (ako/mxcli#1694): their outputs are judged like the target's. + takenOut := []mfmutator.Candidate{target} + if (op.Op == ast.AlterFlowDrop || op.Op == ast.AlterFlowReplace) && !op.SetCondition && !op.SetNotes { + takenOut = append(takenOut, a.candidatesOf(mut.DecisionNodes(target.ID))...) + } if op.Op == ast.AlterFlowDrop { - outputChecks = append(outputChecks, func() error { - if err := a.checkOutputUnused(target, nil); err != nil { - return fail(err) - } - return nil - }) + for _, c := range takenOut { + outputChecks = append(outputChecks, func() error { + if err := a.checkOutputUnused(c, nil); err != nil { + return fail(err) + } + return nil + }) + } if err := mut.Drop(target.ID); err != nil { return fail(err) } - a.noteRemoved(target, nil) + for _, c := range takenOut { + a.noteRemoved(c, nil) + } continue } if op.SetNotes { @@ -210,12 +220,14 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato err = mut.InsertBefore(target.ID, frag) case ast.AlterFlowReplace: replacement := frag - outputChecks = append(outputChecks, func() error { - if err := a.checkOutputUnused(target, replacement); err != nil { - return fail(err) - } - return nil - }) + for _, c := range takenOut { + outputChecks = append(outputChecks, func() error { + if err := a.checkOutputUnused(c, replacement); err != nil { + return fail(err) + } + return nil + }) + } err = mut.Replace(target.ID, frag) default: err = fmt.Errorf("unknown operation") @@ -224,7 +236,9 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato return fail(err) } if op.Op == ast.AlterFlowReplace { - a.noteRemoved(target, frag) + for _, c := range takenOut { + a.noteRemoved(c, frag) + } } a.noteFragment(ctx, op, target, frag, vars) } @@ -282,6 +296,21 @@ type alterFlowContext struct { storedDesc ast.Statement } +// candidatesOf returns the stored candidates with the given $IDs. +func (a *alterFlowContext) candidatesOf(ids []model.ID) []mfmutator.Candidate { + want := map[model.ID]bool{} + for _, id := range ids { + want[id] = true + } + var out []mfmutator.Candidate + for _, c := range a.cands { + if want[c.ID] { + out = append(out, c) + } + } + return out +} + // inLoopRefusal returns, for a target inside a loop body, the refusal of an // operation the splice cannot make there, given why; nil at top level. The // refusal names the innermost loop around the target and `replace body of` diff --git a/mdl/executor/flow_splice_decision_1694_pedapp_test.go b/mdl/executor/flow_splice_decision_1694_pedapp_test.go new file mode 100644 index 0000000000..e14fae3977 --- /dev/null +++ b/mdl/executor/flow_splice_decision_1694_pedapp_test.go @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/model" +) + +// ako/mxcli#1694: after another script spliced a decision into a microflow +// (`alter microflow … insert after $X begin if … end if; end`), re-running the +// original `create or modify` script means taking that decision out again, +// which was refused — "cannot drop the ExclusiveSplit … only an activity or a +// loop can be" — and so were `alter … drop` and `alter … replace` of it. The +// splice now takes out exactly the decision shape `alter … insert` produces: a +// split whose two paths hold plain activities or nothing and rejoin at one +// merge. The flow is reconnected from the split's incoming flow to the merge's +// outgoing one, and every other element keeps its $ID. Any other decision +// shape is still refused, by check and exec alike, saying which part of the +// shape is out of reach. + +const dec1694Flow = `mdl 1; +create or modify microflow MyFirstModule.%s ($F: FeedbackModule.Feedback) +begin + log info node 'X' 'start'; + change $F (Subject = 'x'); + log info node 'X' 'done'; +end;` + +// dec1694Shapes are decisions of the shape `alter … insert` produces, as the +// fragment of the insert. +var dec1694Shapes = []struct{ name, fragment string }{ + {"D1694IfElse", "if $F/Subject = empty then\n log info node 'X' 'empty';\n else\n log info node 'X' 'set';\n end if;"}, + {"D1694IfThen", "if $F/Subject = empty then\n log info node 'X' 'empty';\n end if;"}, + {"D1694TwoActs", "if $F/Subject = empty then\n log info node 'X' 'one';\n log info node 'X' 'two';\n else\n change $F (Description = 'd');\n end if;"}, + {"D1694ElseOnly", "if $F/Subject = empty then\n else\n log info node 'X' 'set';\n end if;"}, +} + +func dec1694Insert(name, fragment string) string { + return "mdl 1;\nalter microflow MyFirstModule." + name + " {\n insert after log node 'X' 'start' begin\n " + + fragment + "\n end;\n};" +} + +// sameIDs reports the $IDs in want that got does not have, and the ones got +// has that want does not. +func sameIDs(want, got map[model.ID]model.Point) (missing, extra int) { + return idChurn(want, got) +} + +func TestSpliceDecision1694_ReRunTakesTheInsertedDecisionOut(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + for _, sh := range dec1694Shapes { + t.Run(sh.name, func(t *testing.T) { + original := fmt.Sprintf(dec1694Flow, sh.name) + if err := agreeExec(t, exec, original); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + before := flowObjectIDs(loopEditFlowOf(t, exec, sh.name).ObjectCollection) + if err := agreeExec(t, exec, dec1694Insert(sh.name, sh.fragment)); err != nil { + t.Fatalf("the insert: %v", err) + } + if d := describeFlow(t, exec, sh.name); !strings.Contains(d, "if ") { + t.Fatalf("the insert wrote no decision:\n%s", d) + } + + // Re-running the original script takes the decision out: check + // and exec agree, and only the decision's elements go. + out.Reset() + agreeVerdict(t, exec, original, "") + if !strings.Contains(out.String(), "Modified microflow: MyFirstModule."+sh.name+" (spliced: 1 dropped)") { + t.Errorf("want the decision reported as one drop, got:\n%s", out.String()) + } + d := describeFlow(t, exec, sh.name) + if strings.Contains(d, "if ") || strings.Contains(d, "'empty'") || strings.Contains(d, "'set'") { + t.Errorf("the decision is still there:\n%s", d) + } + bodyOrderTop(t, d, "'start'", "change $F", "'done'") + after := flowObjectIDs(loopEditFlowOf(t, exec, sh.name).ObjectCollection) + if missing, extra := sameIDs(before, after); missing != 0 || extra != 0 { + t.Errorf("want the flow's own elements back, every $ID kept: %d missing, %d extra", missing, extra) + } + + // A second re-run is Unchanged and writes nothing. + stored := unitBytesOf(t, exec, sh.name) + out.Reset() + if err := agreeExec(t, exec, original); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Unchanged") || !bytes.Equal(stored, unitBytesOf(t, exec, sh.name)) { + t.Errorf("the second re-run wrote:\n%s", out.String()) + } + }) + } + + // The control for the $ID assertion: an activity the script does change + // shows up as one $ID gone and one added, so the comparison sees churn + // where there is some. (A rebuild is no control here: a flow dropped and + // created again in one session gets its stored identities back.) + t.Run("control: a replaced activity is churn", func(t *testing.T) { + const name = "D1694Control" + original := fmt.Sprintf(dec1694Flow, name) + if err := agreeExec(t, exec, original); err != nil { + t.Fatal(err) + } + before := flowObjectIDs(loopEditFlowOf(t, exec, name).ObjectCollection) + if err := agreeExec(t, exec, strings.Replace(original, "'start'", "'begin'", 1)); err != nil { + t.Fatal(err) + } + if missing, extra := sameIDs(before, flowObjectIDs(loopEditFlowOf(t, exec, name).ObjectCollection)); missing != 1 || extra != 1 { + t.Errorf("a replaced activity: want 1 $ID gone and 1 added, got %d and %d", missing, extra) + } + }) +} + +// bodyOrderTop asserts the description holds the given texts in order. +func bodyOrderTop(t *testing.T, desc string, want ...string) { + t.Helper() + at := 0 + for _, w := range want { + i := strings.Index(desc[at:], w) + if i < 0 { + t.Fatalf("the flow does not hold %q (in order):\n%s", w, desc) + } + at += i + len(w) + } +} + +func TestSpliceDecision1694_AlterDropAndReplace(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + setup := func(t *testing.T, name string) map[model.ID]model.Point { + t.Helper() + if err := agreeExec(t, exec, fmt.Sprintf(dec1694Flow, name)); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + before := flowObjectIDs(loopEditFlowOf(t, exec, name).ObjectCollection) + if err := agreeExec(t, exec, dec1694Insert(name, dec1694Shapes[0].fragment)); err != nil { + t.Fatalf("the insert: %v", err) + } + return before + } + + t.Run("drop", func(t *testing.T) { + const name = "D1694AlterDrop" + before := setup(t, name) + agreeVerdict(t, exec, "mdl 1;\nalter microflow MyFirstModule."+name+" {\n drop '$F/Subject = empty';\n};", "") + d := describeFlow(t, exec, name) + if strings.Contains(d, "if ") { + t.Errorf("the decision is still there:\n%s", d) + } + if missing, extra := sameIDs(before, flowObjectIDs(loopEditFlowOf(t, exec, name).ObjectCollection)); missing != 0 || extra != 0 { + t.Errorf("want every other $ID kept: %d missing, %d extra", missing, extra) + } + }) + + t.Run("replace", func(t *testing.T) { + const name = "D1694AlterReplace" + before := setup(t, name) + agreeVerdict(t, exec, "mdl 1;\nalter microflow MyFirstModule."+name+" {\n"+ + " replace '$F/Subject = empty' with begin log info node 'X' 'instead'; end;\n};", "") + d := describeFlow(t, exec, name) + if strings.Contains(d, "if ") { + t.Errorf("the decision is still there:\n%s", d) + } + bodyOrderTop(t, d, "'start'", "'instead'", "change $F", "'done'") + missing, extra := sameIDs(before, flowObjectIDs(loopEditFlowOf(t, exec, name).ObjectCollection)) + if missing != 0 || extra != 1 { + t.Errorf("want every other $ID kept and one activity added: %d missing, %d extra", missing, extra) + } + // Its own description, executed back, is Unchanged. + stored := unitBytesOf(t, exec, name) + out.Reset() + if err := agreeExec(t, exec, d); err != nil || !strings.Contains(out.String(), "Unchanged") || + !bytes.Equal(stored, unitBytesOf(t, exec, name)) { + t.Errorf("describe -> exec is not Unchanged: %v\n%s", err, out.String()) + } + }) + + // create or modify replacing the decision by another statement in its + // place: a replace of the decision. + t.Run("create or modify replaces it", func(t *testing.T) { + const name = "D1694ModifyReplace" + before := setup(t, name) + src := strings.Replace(fmt.Sprintf(dec1694Flow, name), " log info node 'X' 'start';\n", + " log info node 'X' 'start';\n log info node 'X' 'instead';\n", 1) + out.Reset() + agreeVerdict(t, exec, src, "") + bodyOrderTop(t, describeFlow(t, exec, name), "'start'", "'instead'", "change $F", "'done'") + missing, extra := sameIDs(before, flowObjectIDs(loopEditFlowOf(t, exec, name).ObjectCollection)) + if missing != 0 || extra != 1 { + t.Errorf("want every other $ID kept and one activity added: %d missing, %d extra", missing, extra) + } + }) +} + +// Every other decision shape is still refused, by check and exec alike, +// writing nothing, with the part of the shape that is out of reach. +func TestSpliceDecision1694_OtherShapesRefused(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + cases := []struct { + name, body, want string + }{ + {"D1694Return", "if $F/Subject = empty then\n log info node 'X' 'empty';\n return;\n end if;", "ends in a return"}, + {"D1694Nested", "if $F/Subject = empty then\n if $F/Description = empty then\n log info node 'X' 'both';\n end if;\n end if;", "holds a decision"}, + {"D1694Loop", "if $F/Subject = empty then\n loop $It in $Items\n begin\n log info node 'X' 'it';\n end loop;\n end if;", "holds a loop"}, + {"D1694Handler", "if $F/Subject = empty then\n commit $F on error begin log info node 'X' 'failed'; end error;\n end if;", "has an error handler"}, + {"D1694Raise", "if $F/Subject = empty then\n raise error;\n end if;", "ends in a raise error"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + withDecision := "mdl 1;\ncreate or modify microflow MyFirstModule." + tc.name + + " ($F: FeedbackModule.Feedback, $Items: List of FeedbackModule.Feedback)\nbegin\n log info node 'X' 'start';\n " + + tc.body + "\n change $F (Subject = 'x');\n log info node 'X' 'done';\nend;" + if err := agreeExec(t, exec, withDecision); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + without := "mdl 1;\ncreate or modify microflow MyFirstModule." + tc.name + + " ($F: FeedbackModule.Feedback, $Items: List of FeedbackModule.Feedback)\nbegin\n log info node 'X' 'start';\n" + + " change $F (Subject = 'x');\n log info node 'X' 'done';\nend;" + stored := unitBytesOf(t, exec, tc.name) + agreeVerdict(t, exec, without, tc.want) + agreeVerdict(t, exec, "mdl 1;\nalter microflow MyFirstModule."+tc.name+" {\n drop '$F/Subject = empty';\n};", tc.want) + agreeVerdict(t, exec, "mdl 1;\nalter microflow MyFirstModule."+tc.name+" {\n"+ + " replace '$F/Subject = empty' with begin log info node 'X' 'instead'; end;\n};", tc.want) + if !bytes.Equal(stored, unitBytesOf(t, exec, tc.name)) { + t.Error("a refused statement wrote") + } + }) + } + + // A variable a branch declares and the flow reads after the decision is + // still in use: taking the decision out is refused like dropping its + // producer. + t.Run("a branch output still read", func(t *testing.T) { + const name = "D1694Used" + if err := agreeExec(t, exec, "mdl 1;\ncreate or modify microflow MyFirstModule."+name+" ($F: FeedbackModule.Feedback)\nbegin\n"+ + " log info node 'X' 'start';\n if $F/Subject = empty then\n declare $N Integer = 1;\n end if;\n"+ + " log info node 'X' 'n {1}' with ({1} = toString($N));\nend;"); err != nil { + t.Fatalf("setup: %v", err) + } + stored := unitBytesOf(t, exec, name) + agreeVerdict(t, exec, "mdl 1;\nalter microflow MyFirstModule."+name+" {\n drop '$F/Subject = empty';\n};", "still used by") + if !bytes.Equal(stored, unitBytesOf(t, exec, name)) { + t.Error("the refused statement wrote") + } + }) +} From 50aa437ed20766efad51bd38a092822094775856 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 26 Oct 2026 08:17:20 +0000 Subject: [PATCH 05/40] docs(skills): mark the refused @annotation(id:) example as wrong inside its block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-skill-mdl-semantics.sh skips a deliberately wrong example only when the marker is inside the code block; the ❌ sat in the sentence above it, so CI ran the refused fragment as a real script and failed on MDL079 — the refusal this PR adds to check. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/mendix/write-microflows/reference/pitfalls.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index 57164b09a1..37d03bec17 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -408,6 +408,7 @@ note by its **text**, exactly as `describe` prints it (ako/mxcli#1695): ❌ **REFUSED** — "refers to a note that has not been declared above it": ```mdl mdl 1; +-- ❌ WRONG: a describe-time id: label names no stored note alter microflow MyModule.ACT_Process { insert after log node 'App' 'start' begin @annotation(id: n1) From 71ed64949514022f35aebc4ac167ce42a820858c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:25:33 +0000 Subject: [PATCH 06/40] feat(pages): spell a tab page's Badge in MDL, so a rewrite keeps it (#1653) A tab page's Badge (Forms$TabPage.Badge, a client template) had no MDL spelling: check warned MDL-WIDGET07 that `Badge:` would be dropped, describe printed nothing for it, and re-executing describe's output stored "Badge": null, so a badge set in Studio Pro was removed by any rewrite of its page (mendixlabs/mxcli#1644). `tabpage tp (Caption: 'Open', Badge: '{1}', BadgeParams: ({1} = Count))` now writes it, in the shape of a dynamic text's Content/ContentParams, with the parameters resolved against the enclosing data container, and describe prints it. The keys are exempt from MDL-WIDGET07 on a tab page only; BadgeParams without a Badge, or a Badge that is not a quoted template, is refused (MDL-WIDGET51). On ako/TestApp the seven Studio Pro badges of WorkflowCommons.TaskInbox and TaskDashboard now survive describe -> exec; before, all were nulled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NUbjJ5DeS2vysn9hfKir4Y --- ...6-10-26-tabpage-badge-no-mdl-spelling.json | 1 + cmd/mxcli/syntax/features_page.go | 2 +- docs-site/src/language/widget-types.md | 32 +++ mdl-examples/bug-tests/1644-tabpage-badge.mdl | 45 ++++ mdl/backend/modelsdk/widget_write.go | 5 + mdl/executor/cmd_pages_builder_v3_layout.go | 5 + mdl/executor/cmd_pages_describe.go | 5 + mdl/executor/cmd_pages_describe_output.go | 1 + mdl/executor/cmd_pages_describe_parse.go | 1 + mdl/executor/issue1653_tabpage_badge_test.go | 208 ++++++++++++++++++ mdl/executor/tabpage_badge.go | 151 +++++++++++++ mdl/executor/validate_widgets.go | 6 + sdk/pages/pages_widgets_container.go | 4 + 13 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-tabpage-badge-no-mdl-spelling.json create mode 100644 mdl-examples/bug-tests/1644-tabpage-badge.mdl create mode 100644 mdl/executor/issue1653_tabpage_badge_test.go create mode 100644 mdl/executor/tabpage_badge.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-tabpage-badge-no-mdl-spelling.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-tabpage-badge-no-mdl-spelling.json new file mode 100644 index 0000000000..566e0f6440 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-tabpage-badge-no-mdl-spelling.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "A tab page Badge set in Studio Pro (Forms$TabPage.Badge, a Forms$ClientTemplate) was removed by any MDL rewrite of its page: `check` warned MDL-WIDGET07 \"property `Badge` is not recognized and will be silently dropped on write — did you mean `Image`?\", describe printed `tabpage tpOne (Caption: 'One') {`, and executing that output answered \"Replaced page\" with \"Badge\": null; mx check clean throughout (Mendix 11.12.5)", "cause": "MDL had no spelling for the badge: pages.TabPage had no field, buildTabPageV3 read only caption, visibility and children, the writer never set Badge, and describe's tab page parse did not read it. The visitor already parsed `Badge: '…'` as a string and `BadgeParams: (…)` through the generic Params path", "file": "mdl/executor/tabpage_badge.go (reads, emits, MDL-WIDGET51); cmd_pages_builder_v3_layout.go (buildTabPageV3); validate_widgets.go (per-type MDL-WIDGET07 exemption); cmd_pages_describe_parse.go / cmd_pages_describe_output.go; sdk/pages TabPage.Badge; mdl/backend/modelsdk/widget_write.go (SetBadge via clientTemplateToGen)", "fix": "`Badge: '{1}', BadgeParams: ({1} = Attribute)` on a tab page, the dynamic text's Content/ContentParams shape, built with buildClientTemplateParams against the enclosing data container and written as Forms$TabPage.Badge; describe prints it, including an empty template, so the round trip keeps it. The keys are exempted from MDL-WIDGET07 for tabpage only (isTabPageBadgeProp), not added to the cross-widget union; BadgeParams without Badge or a non-string Badge is refused at check and exec (MDL-WIDGET51)", "insight": "The real data was already in a fixture: ako/TestApp (11.14.0) stores Studio Pro badges on WorkflowCommons.TaskInbox (3 tab pages) and TaskDashboard (4), `{1}` bound to a helper's MyOpenTaskCount / AllOpenTaskCount / UnassignedTaskCount — scan the fixtures for the property before building a seed. Describe → exec now keeps all 7 identical (ids aside); the pre-fix binary nulls all 3 on TaskInbox. Check what the visitor already produces before touching the grammar: both keys arrived parsed, so the gap was builder, writer and describe only. Keep a per-type key out of staticWidgetKnownProps and in its own file (the input_submit_width.go arrangement): the union would have silenced MDL-WIDGET07 for `Badge:` on every other widget, and TestStaticWidgetKnownProps_CoverSourceVocabulary scans the builder and describe-output files for keys. The version facts are in modelsdk/gen/pages/version.go (Forms$TabPage badge Introduced 8.13.0, below every supported version), so no gate. Wrong turn to skip: on the 11.6.6 fixture the report's `layoutgrid … column (DesktopWidth: AutoFill)` wrapper fails mx check with CE0535 with or without a badge — drop the grid to test the badge. ALTER PAGE … SET (Badge: …) is refused (property not found), not supported yet", "refs": ["mendixlabs/mxcli#1644", "ako/mxcli#1653"], "rules": ["MDL-WIDGET07", "MDL-WIDGET51"], "test": "TestIssue1653_TabPageBadgeIsWritten, TestIssue1653_DescribeRoundTripKeepsAStudioProBadge, TestIssue1653_CheckAcceptsTheBadgeOnATabPageOnly, TestIssue1653_MalformedBadgeIsRefused (mdl/executor/issue1653_tabpage_badge_test.go); mdl-examples/bug-tests/1644-tabpage-badge.mdl"} diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index b056202834..fdc98c14da 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -128,7 +128,7 @@ LIST IMPACT OF htmlelement; "statictext", "staticimage", "dynamicimage", "dropdown", "referenceselector", "legacydatagrid", "visible", "editable", "conditional visibility", }, - Syntax: "-- Containers\nLAYOUTGRID name { ROW { COLUMN (DesktopWidth: 6) { ... } } }\nCONTAINER name (Class: 'cls') { ... }\nCONTAINER name (OnClick: CALL MICROFLOW Module.MF) { ... } -- clickable container\nCUSTOMCONTAINER name (Class: 'cls') { ... }\nGROUPBOX name (Caption: 'C') { ... }\nTABCONTAINER name { TABPAGE tp (Caption: 'One') { ... } TABPAGE tp2 (Caption: 'Two') { ... } }\n\n" + + Syntax: "-- Containers\nLAYOUTGRID name { ROW { COLUMN (DesktopWidth: 6) { ... } } }\nCONTAINER name (Class: 'cls') { ... }\nCONTAINER name (OnClick: CALL MICROFLOW Module.MF) { ... } -- clickable container\nCUSTOMCONTAINER name (Class: 'cls') { ... }\nGROUPBOX name (Caption: 'C') { ... }\nTABCONTAINER name { TABPAGE tp (Caption: 'One') { ... } TABPAGE tp2 (Caption: 'Two') { ... } }\nTABPAGE tp (Caption: 'Open', Badge: '{1}', BadgeParams: ({1} = OpenCount)) { ... } -- tab badge (a text template)\n\n" + "-- Data widgets\nDATAVIEW name (DataSource: $Param) { ... FOOTER { ... } }\nDATAGRID name (DataSource: DATABASE Module.Entity) { COLUMN (Attribute: A) }\nGALLERY name (DataSource: DATABASE Module.Entity, DesktopColumns: 3) { ... }\nLISTVIEW name (DataSource: DATABASE Module.Entity) { ... }\nLISTVIEW name (...) { ... TEMPLATE FOR Module.Specialization { ... } }\n\n" + "-- Data grid 2 column filters go INSIDE the column's own braces\nDATAGRID dg (...) { COLUMN (Attribute: A) { TEXTFILTER tf (Attribute: A) } }\nTEXTFILTER | NUMBERFILTER | DATEFILTER | DROPDOWNFILTER | DROPDOWNSORT\n" + "-- Match the filter to the column's type, or MxBuild refuses it: String ->\n" + diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index 338eaa6385..6a1c9bddc2 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -76,6 +76,38 @@ CONTAINER cCard (Class: 'card mx-spacing-top-large') { Similar to `CONTAINER` but used for custom-styled containers. +### TABCONTAINER / TABPAGE + +A tab container holds tab pages; each tab page holds widgets. + +```sql +CREATE PAGE MyModule.Task_Tabs ( + Title: 'Tasks', + Layout: Atlas_Core.Atlas_Default, + Params: ($Panel: MyModule.Panel) +) { + DATAVIEW dvPanel (DataSource: $Panel) { + TABCONTAINER tcMain { + TABPAGE tpOpen (Caption: 'Open', Badge: '{1}', BadgeParams: ({1} = OpenCount)) { + DYNAMICTEXT txtOpen (Content: 'Open tasks') + } + TABPAGE tpDone (Caption: 'Done') { + DYNAMICTEXT txtDone (Content: 'Done tasks') + } + } + } +}; +``` + +| Property (tab page) | Description | Example | +|---|---|---| +| `Caption` | Tab caption, plain text (no parameters) | `Caption: 'Open'` | +| `Badge` | Text shown beside the caption, a text template like a dynamic text's `Content` | `Badge: '{1}'` | +| `BadgeParams` | The badge template's parameters, resolved against the enclosing data container | `BadgeParams: ({1} = OpenCount)` | +| `Visible` | Visibility condition | `Visible: $Panel/ShowDone` | + +A tab page has no `Class`, `Style` or `CaptionParams`: Mendix stores none for it. A `BadgeParams` without a `Badge`, or a `Badge` that is not a quoted template, is refused (MDL-WIDGET51); `Badge` on any other widget is not stored, and `check` says so (MDL-WIDGET07). + ## Data Widgets ### DATAVIEW diff --git a/mdl-examples/bug-tests/1644-tabpage-badge.mdl b/mdl-examples/bug-tests/1644-tabpage-badge.mdl new file mode 100644 index 0000000000..21b202d2fd --- /dev/null +++ b/mdl-examples/bug-tests/1644-tabpage-badge.mdl @@ -0,0 +1,45 @@ +-- mendixlabs/mxcli#1644 (ako/mxcli#1653): a tab page's Badge had no MDL +-- spelling. +-- +-- Symptom: `check` warned MDL-WIDGET07 "property `Badge` is not recognized and +-- will be silently dropped on write", `describe page` printed +-- tabpage tpOne (Caption: 'One') { +-- for a tab page whose Badge Studio Pro had set, and executing that output +-- answered "Replaced page" with the stored Badge now null — mx check clean +-- before and after. +-- +-- Fix: `Badge: '{1}', BadgeParams: ({1} = Attribute)` on a tab page, the +-- shape of a dynamic text's Content / ContentParams, written to +-- Forms$TabPage.Badge and printed by describe, so the round trip keeps it. +-- The parameters resolve against the data container around the tab container. +-- On any other widget `Badge:` still draws MDL-WIDGET07; BadgeParams without a +-- Badge, or a Badge that is not a quoted template, is refused (MDL-WIDGET51). +-- +-- To verify in Studio Pro: run this script, open Chores.Panel_Tabs — +-- tab One shows the panel's Total as its badge, tab Two shows "new" — then run +-- `describe page Chores.Panel_Tabs` and execute its output: "Unchanged page". + +mdl 1; + +create module Chores; + +create non-persistent entity Chores.Panel ( + Total: integer +); + +create page Chores.Panel_Tabs ( + Title: 'Tabs', + Layout: Atlas_Core.Atlas_Default, + Params: ( $Panel: Chores.Panel ) +) { + dataview dvPanel (DataSource: $Panel) { + tabcontainer tcMain { + tabpage tpOne (Caption: 'One', Badge: '{1}', BadgeParams: ({1} = Total)) { + dynamictext txtOne (Content: 'one') + } + tabpage tpTwo (Caption: 'Two', Badge: 'new') { + dynamictext txtTwo (Content: 'two') + } + } + } +}; diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 0e8a62fc05..c733e6038d 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -442,6 +442,11 @@ func widgetToGen(w pages.Widget) (element.Element, error) { if tp.ConditionalVisibility != nil { tpg.SetConditionalVisibilitySettings(conditionalVisibilityToGen(tp.ConditionalVisibility)) } + // Its badge, a client template like a dynamic text's content + // (mendixlabs/mxcli#1644); null otherwise, as Studio Pro writes it. + if tp.Badge != nil { + tpg.SetBadge(clientTemplateToGen(tp.Badge)) + } for _, c := range tp.Widgets { cg, err := widgetToGen(c) if err != nil { diff --git a/mdl/executor/cmd_pages_builder_v3_layout.go b/mdl/executor/cmd_pages_builder_v3_layout.go index 51261e2847..6ecf4db494 100644 --- a/mdl/executor/cmd_pages_builder_v3_layout.go +++ b/mdl/executor/cmd_pages_builder_v3_layout.go @@ -367,6 +367,11 @@ func (pb *pageBuilder) buildTabPageV3(w *ast.WidgetV3) (*pages.TabPage, error) { } tp.ConditionalVisibility = cvs + // Its badge (mendixlabs/mxcli#1644, tabpage_badge.go). + if tp.Badge, err = pb.buildTabPageBadge(w); err != nil { + return nil, err + } + // Build child widgets for _, child := range w.Children { widget, err := pb.buildWidgetV3(child) diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index dbdb81edbc..45f25b9452 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -927,6 +927,11 @@ type rawWidget struct { // TabControl parsing — preserves the original tab page name/caption so // DESCRIBE output shows which tab each nested widget belongs to). TabCaption string + // The tab page's badge (mendixlabs/mxcli#1644): set when one is stored, + // with its template text and parameters. See tabpage_badge.go. + TabBadgeSet bool + TabBadge string + TabBadgeParams []string // Scroll-container region properties, set only on the synthetic wrappers // the region walk emits (the slot name goes in Name). Same shape as // TabCaption: a nameless container whose identity DESCRIBE has to put back. diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 0ed44a18c1..94f7797d70 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -429,6 +429,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if w.TabCaption != "" { props = append(props, fmt.Sprintf("Caption: %s", mdlQuote(ctx, w.TabCaption))) } + props = describeTabPageBadge(ctx, props, w) // A tab page's visibility (#1465). Its Forms$TabPage has no Appearance, // so appendAppearanceProps would print nothing else either. if w.VisibleIf != "" { diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 821dee9e53..1de7cd8a80 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -220,6 +220,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s // Forms$TabPage carries its own visibility (#1465); it has no // editability, so only the visibility half is read. extractConditionalSettings(ctx, &tabPage, tpMap) + readTabPageBadge(ctx, tpMap, &tabPage) for _, tw := range getBsonArrayElements(tpMap["Widgets"]) { if twMap, ok := tw.(map[string]any); ok { tabPage.Children = append(tabPage.Children, parseRawWidget(ctx, twMap, inheritedCtx)...) diff --git a/mdl/executor/issue1653_tabpage_badge_test.go b/mdl/executor/issue1653_tabpage_badge_test.go new file mode 100644 index 0000000000..7e45dba952 --- /dev/null +++ b/mdl/executor/issue1653_tabpage_badge_test.go @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "crypto/rand" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// mendixlabs/mxcli#1644 (ako/mxcli#1653): a tab page's Badge — a text template +// with parameters, Forms$TabPage.Badge — had no MDL spelling. `check` warned +// MDL-WIDGET07 "will be silently dropped", describe printed nothing for it, and +// re-executing describe's output stored "Badge": null, so a badge set in Studio +// Pro was removed by any rewrite of its page. + +const issue1653Page = `create or modify page MyFirstModule.Panel_Tabs ( + Title: 'Tabs', + Layout: Atlas_Core.Atlas_Default, + Params: ( $Panel: MyFirstModule.Panel ) +) { + dataview dvPanel (DataSource: $Panel) { + dynamictext txtTotal (Content: '{1}', ContentParams: ({1} = Total)) + tabcontainer tcMain { + tabpage tpOne (Caption: 'One', Badge: '{1} open', BadgeParams: ({1} = Total)) { dynamictext txtOne (Content: 'one') } + tabpage tpTwo (Caption: 'Two') { dynamictext txtTwo (Content: 'two') } + } + } +};` + +func issue1653Executor(t *testing.T) (*Executor, interface { + Reset() + String() string +}, string) { + t.Helper() + exec, out := pageFixtureExecutor(t) + run(t, exec, `create non-persistent entity MyFirstModule.Panel (Total: Integer);`) + return exec, out, "" +} + +// storedBadge is the stored Badge of the tab page named tab. +func storedBadge(t *testing.T, exec *Executor, id, tab string) any { + t.Helper() + return bsonnav.DGet(namedDoc(t, storedUnitDoc(t, exec, id), tab), "Badge") +} + +func badgeTemplate(t *testing.T, badge any) (text string, attrs []string) { + t.Helper() + ct, ok := badge.(bson.D) + if !ok || bsonnav.DGetString(ct, "$Type") != "Forms$ClientTemplate" { + t.Fatalf("Badge is %v, want a Forms$ClientTemplate", badge) + } + for _, tr := range bsonDocs(bsonValue(bsonDocField(ct, "Template"), "Items")) { + if bsonString(tr, "LanguageCode") == "en_US" { + text = bsonString(tr, "Text") + } + } + for _, p := range bsonDocs(bsonValue(ct, "Parameters")) { + attrs = append(attrs, bsonnav.DGetString(bsonDocField(p, "AttributeRef"), "Attribute")) + } + return text, attrs +} + +func TestIssue1653_TabPageBadgeIsWritten(t *testing.T) { + exec, out, _ := issue1653Executor(t) + run(t, exec, issue1653Page) + id := unitIDByName(t, exec, "page", "Panel_Tabs") + + text, attrs := badgeTemplate(t, storedBadge(t, exec, id, "tpOne")) + if text != "{1} open" || strings.Join(attrs, ",") != "MyFirstModule.Panel.Total" { + t.Errorf("tpOne's Badge is %q bound to %v, want '{1} open' bound to MyFirstModule.Panel.Total", text, attrs) + } + // A tab page without one stores none, as Studio Pro does. + if b := storedBadge(t, exec, id, "tpTwo"); b != nil { + t.Errorf("tpTwo stores a Badge it was not given: %v", b) + } + + // describe prints it, and its output runs again without writing. + out.Reset() + run(t, exec, "describe page MyFirstModule.Panel_Tabs") + described := out.String() + if !strings.Contains(described, "tabpage tpOne (Caption: 'One', Badge: '{1} open', BadgeParams: ({1} = Total))") { + t.Fatalf("describe does not print the badge:\n%s", described) + } + if strings.Contains(described, "tabpage tpTwo (Caption: 'Two', Badge") { + t.Errorf("describe prints a badge on tpTwo:\n%s", described) + } + out.Reset() + run(t, exec, described) + if got := out.String(); !strings.Contains(got, "Unchanged page") { + t.Errorf("re-running describe wrote the page:\n%s", got) + } +} + +// The report's own scenario: a badge as Studio Pro stores it — a copy of a +// dynamic text's Content template in TabPage.Badge — survives describe → exec. +func TestIssue1653_DescribeRoundTripKeepsAStudioProBadge(t *testing.T) { + exec, out, _ := issue1653Executor(t) + run(t, exec, strings.Replace(issue1653Page, ", Badge: '{1} open', BadgeParams: ({1} = Total)", "", 1)) + id := unitIDByName(t, exec, "page", "Panel_Tabs") + + doc := storedUnitDoc(t, exec, id) + badge := cloneBsonD(bsonDocField(namedDoc(t, doc, "txtTotal"), "Content")) + freshIDs(badge) + if !bsonnav.DSet(namedDoc(t, doc, "tpOne"), "Badge", badge) { + t.Fatal("the stored tab page has no Badge key to set") + } + writeUnitDoc(t, exec, id, doc) + if text, _ := badgeTemplate(t, storedBadge(t, exec, id, "tpOne")); text != "{1}" { + t.Fatalf("seed did not take: %q", text) + } + + out.Reset() + run(t, exec, "describe page MyFirstModule.Panel_Tabs") + described := strings.Replace(out.String(), "Title: 'Tabs'", "Title: 'Tabs (changed)'", 1) // force the write + run(t, exec, described) + if !storedHas(t, exec, id, "Tabs (changed)") { + t.Fatal("the rewrite did not land, so it shows nothing about the badge") + } + text, attrs := badgeTemplate(t, storedBadge(t, exec, id, "tpOne")) + if text != "{1}" || strings.Join(attrs, ",") != "MyFirstModule.Panel.Total" { + t.Errorf("after describe → exec tpOne's Badge is %q bound to %v", text, attrs) + } +} + +// freshIDs gives every element of a copied document an id of its own, as the +// report's script does: two elements sharing one is a unit Studio Pro refuses. +func freshIDs(v any) { + switch x := v.(type) { + case bson.D: + for i, e := range x { + if e.Key == "$ID" { + x[i].Value = bson.Binary{Subtype: 0, Data: freshID()} + continue + } + freshIDs(e.Value) + } + case bson.A: + for _, e := range x { + freshIDs(e) + } + } +} + +func freshID() []byte { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return b +} + +func issue1653Violations(t *testing.T, src string) []string { + t.Helper() + var widgets []*ast.WidgetV3 + for _, s := range parseMDL(t, src).Statements { + if p, ok := s.(*ast.CreatePageStmtV3); ok { + widgets = p.Widgets + } + } + var out []string + for _, v := range validateWidgetTree(widgets, LoadWidgetRegistry(""), "page M.P") { + out = append(out, v.RuleID+" "+v.Message) + } + return out +} + +func TestIssue1653_CheckAcceptsTheBadgeOnATabPageOnly(t *testing.T) { + for _, v := range issue1653Violations(t, issue1653Page) { + if strings.Contains(v, "Badge") { + t.Errorf("check reports the tab page badge: %s", v) + } + } + // A badge is a tab page's: anywhere else it is still dropped, and said so. + got := issue1653Violations(t, `create page M.P (Title: 'x', Layout: Atlas_Core.Atlas_Default) { + dynamictext txt (Content: 'a', Badge: 'b') +};`) + if len(got) == 0 || !strings.Contains(strings.Join(got, "\n"), "MDL-WIDGET07") { + t.Errorf("Badge on a dynamic text is not reported: %v", got) + } +} + +// A badge in a shape the tab page cannot store is refused, by check and by exec, +// rather than written as something else. +func TestIssue1653_MalformedBadgeIsRefused(t *testing.T) { + for _, tc := range []struct{ name, props, want string }{ + {"parameters without a template", "Caption: 'One', BadgeParams: ({1} = Total)", "has no Badge template"}, + {"an expression, not a template", "Caption: 'One', Badge: $Panel/Total", "written in quotes"}, + } { + src := strings.Replace(issue1653Page, "Caption: 'One', Badge: '{1} open', BadgeParams: ({1} = Total)", tc.props, 1) + got := strings.Join(issue1653Violations(t, src), "\n") + if !strings.Contains(got, "MDL-WIDGET51") || !strings.Contains(got, tc.want) { + t.Errorf("%s: check reports %q", tc.name, got) + } + + exec, _, _ := issue1653Executor(t) + prog := parseMDL(t, src) + err := exec.Execute(prog.Statements[0]) + if err == nil || !strings.Contains(err.Error(), "MDL-WIDGET51") { + t.Errorf("%s: exec wrote the page (%v)", tc.name, err) + } + } +} diff --git a/mdl/executor/tabpage_badge.go b/mdl/executor/tabpage_badge.go new file mode 100644 index 0000000000..55d0eb2549 --- /dev/null +++ b/mdl/executor/tabpage_badge.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// A tab page's badge (mendixlabs/mxcli#1644). +// +// Forms$TabPage.Badge is a client template — the counter Studio Pro shows +// beside a tab's caption, stored in the shape of a dynamic text's Content. MDL +// had no spelling for it: `check` warned MDL-WIDGET07 that `Badge:` would be +// dropped, describe printed nothing, and re-executing describe's output stored +// "Badge": null, so a badge set in Studio Pro was removed by any rewrite of its +// page while `mx check` stayed clean. The spelling is the dynamic text's: +// +// tabpage tpOpen (Caption: 'Open', Badge: '{1}', BadgeParams: ({1} = OpenCount)) { … } +// +// The parameters resolve against the data container around the tab container, +// as a dynamic text's there would. Translations of the template beyond the +// one MDL writes are carried by canon.CarryTranslations, keyed on the tab +// page's name. +// +// The keys are a tab page's only. They are kept out of staticWidgetKnownProps +// (the union every widget type shares) so `Badge:` on any other widget still +// draws MDL-WIDGET07; the per-type exemption is isTabPageBadgeProp, the same +// arrangement as the submit keys of input_submit_width.go, and this file, not a +// cmd_pages_builder* or describe-output file, holds the reads and the emits. + +// tabPageBadgeProps are the badge keys, in canonical spelling and describe +// order. +var tabPageBadgeProps = []string{"Badge", "BadgeParams"} + +// tabPageBadgeRule refuses a badge written in a shape the tab page cannot +// store. +const tabPageBadgeRule = "MDL-WIDGET51" + +const tabPageBadgeExample = "Badge: '{1}', BadgeParams: ({1} = Attribute)" + +// isTabPageBadgeProp reports whether key is a badge property of the MDL widget +// type widgetType. +func isTabPageBadgeProp(widgetType, key string) bool { + if !strings.EqualFold(widgetType, "tabpage") { + return false + } + for _, p := range tabPageBadgeProps { + if strings.EqualFold(p, key) { + return true + } + } + return false +} + +// tabPageBadgeOf reads a tab page's badge template and parameters. set is +// false when the statement gives neither; a malformed badge is an error, the +// same one check reports. +func tabPageBadgeOf(w *ast.WidgetV3) (text string, params []ast.ParamAssignmentV3, set bool, err error) { + rawText, hasText := lookupPropCI(w, "Badge") + rawParams, hasParams := lookupPropCI(w, "BadgeParams") + if !hasText && !hasParams { + return "", nil, false, nil + } + if hasText { + s, ok := rawText.(string) + if !ok { + return "", nil, false, fmt.Errorf("tabpage %s: Badge is a text template written in quotes, like a dynamic text's Content (%s)", + w.Name, tabPageBadgeExample) + } + text = s + } + if hasParams { + p, ok := rawParams.([]ast.ParamAssignmentV3) + if !ok { + return "", nil, false, fmt.Errorf("tabpage %s: BadgeParams is a parameter list (%s)", w.Name, tabPageBadgeExample) + } + params = p + } + if !hasText { + return "", nil, false, fmt.Errorf("tabpage %s: BadgeParams has no Badge template to fill (%s)", w.Name, tabPageBadgeExample) + } + return text, params, true, nil +} + +// buildTabPageBadge is the badge the builder writes; nil when the statement +// gives none. +func (pb *pageBuilder) buildTabPageBadge(w *ast.WidgetV3) (*pages.ClientTemplate, error) { + text, params, set, err := tabPageBadgeOf(w) + if err != nil { + return nil, mdlerrors.NewValidationf("%v [%s]", err, tabPageBadgeRule) + } + if !set { + return nil, nil + } + return &pages.ClientTemplate{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID()), TypeName: "Forms$ClientTemplate"}, + Template: &model.Text{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID()), TypeName: "Texts$Text"}, + Translations: map[string]string{pb.textLang(): text}, + }, + Parameters: pb.buildClientTemplateParams(params), + }, nil +} + +// validateTabPageBadge is the check-time half of the refusal above. +func validateTabPageBadge(w *ast.WidgetV3, locationPrefix string) []linter.Violation { + if w == nil || !strings.EqualFold(w.Type, "tabpage") { + return nil + } + if _, _, _, err := tabPageBadgeOf(w); err != nil { + return []linter.Violation{{ + RuleID: tabPageBadgeRule, + Severity: linter.SeverityError, + Message: fmt.Sprintf("%s: %v", locationPrefix, err), + }} + } + return nil +} + +// readTabPageBadge reads a stored tab page's badge for describe. A stored +// Badge is a client template, or null when none is set. +func readTabPageBadge(ctx *ExecContext, tp map[string]any, w *rawWidget) { + if _, ok := tp["Badge"].(map[string]any); !ok { + return + } + w.TabBadgeSet = true + w.TabBadge = extractTextContent(ctx, tp, "Badge") + w.TabBadgeParams = extractClientTemplateParameters(ctx, tp, "Badge") +} + +// describeTabPageBadge appends a stored badge to a tab page's properties. An +// empty template is printed too: the badge is set, and leaving it out would +// make re-executing the description remove it. +func describeTabPageBadge(ctx *ExecContext, props []string, w rawWidget) []string { + if !w.TabBadgeSet { + return props + } + props = append(props, fmt.Sprintf("Badge: %s", mdlQuote(ctx, w.TabBadge))) + if len(w.TabBadgeParams) > 0 { + props = append(props, fmt.Sprintf("BadgeParams: (%s)", strings.Join(formatParametersV3(w.TabBadgeParams), ", "))) + } + return props +} diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 606ad735ba..1f5d060b03 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -359,6 +359,7 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc // #1465: the vocabulary above is a cross-widget union, so a key a // tab page cannot store (CaptionParams, Class, Style) passes it. out = append(out, validateTabPageProps(w, locationPrefix)...) + out = append(out, validateTabPageBadge(w, locationPrefix)...) // #928: `editable:` on a widget Mendix gives no editability — same // "silently dropped on write" family, but the flat property // allow-list cannot see it because it is type-agnostic. @@ -1283,6 +1284,11 @@ func validateStaticWidgetUnknownPropsIn(w *ast.WidgetV3, locationPrefix string, if isInputSubmitProp(w.Type, key) { continue } + // A tab page's Badge / BadgeParams, per type for the same reason + // (mendixlabs/mxcli#1644). + if isTabPageBadgeProp(w.Type, key) { + continue + } candidates := staticWidgetKnownPropList if schema != nil { // The widget's own names first: they are what the key was meant to be. diff --git a/sdk/pages/pages_widgets_container.go b/sdk/pages/pages_widgets_container.go index 43aa2288e9..265d960d72 100644 --- a/sdk/pages/pages_widgets_container.go +++ b/sdk/pages/pages_widgets_container.go @@ -119,6 +119,10 @@ type TabPage struct { // tab page is not a widget (no BaseWidget, no Appearance), so it carries // its own (mendixlabs/mxcli#1465). ConditionalVisibility *ConditionalVisibilitySettings `json:"conditionalVisibility,omitempty"` + // Badge is Forms$TabPage.Badge, the text template shown beside the caption + // (a counter, say); nil writes none, as Studio Pro stores an unset one + // (mendixlabs/mxcli#1644). + Badge *ClientTemplate `json:"badge,omitempty"` } // GetName returns the tab page's name. From dfb3a89aed5a7b9641b4b2cb0914bb849d00b64e Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 26 Oct 2026 08:38:28 +0000 Subject: [PATCH 07/40] docs: pass the conformance gate with the new alter-microflow docs The docs-site synopsis is grammar with placeholders, not runnable MDL, so it is fenced as text, as the gate advises; the `microflow.alter` syntax example gains the `mdl 1;` header every complete example carries. Co-Authored-By: Claude Opus 5.5 --- cmd/mxcli/syntax/features_microflow.go | 2 +- docs-site/src/reference/microflow/alter-microflow.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index 11a277a02e..8ab938bfb3 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -636,7 +636,7 @@ func init() { "-- declared on the path. Over --mcp only insert is supported, and not inside a loop body.\n" + "-- A fragment is imperative flow, written as the body of `create microflow` is: BEGIN … END.\n" + "-- The brace fragment `{ }` is the deprecated spelling MDL-DEPR074.", - Example: "alter microflow FeedbackModule.VAL_Feedback {\n" + + Example: "mdl 1;\nalter microflow FeedbackModule.VAL_Feedback {\n" + " insert after $IsValidEmail begin log info node 'Feedback' 'Email checked'; end;\n" + " replace set $ValidFeedback = false @3 with begin\n" + " set $ValidFeedback = false;\n" + diff --git a/docs-site/src/reference/microflow/alter-microflow.md b/docs-site/src/reference/microflow/alter-microflow.md index 97d8779d81..69536e27f7 100644 --- a/docs-site/src/reference/microflow/alter-microflow.md +++ b/docs-site/src/reference/microflow/alter-microflow.md @@ -2,7 +2,7 @@ ## Synopsis -```sql +```text mdl 1; ALTER MICROFLOW module.Name { INSERT AFTER | BEFORE BEGIN END; From 8b9a1944f663f99c375a01c1845f6a38b5ca22e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:44:26 +0000 Subject: [PATCH 08/40] fix: carry ReadOnlyStyle on text box, text area, date picker and radio buttons (#977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Studio Pro radio button group stored with ReadOnlyStyle Text came back from describe → exec as Inherit, and the same on the other three input widgets: the writer kept the hard-coded Inherit that #490 removed for the check box, the model had no field, and describe read the key only on the check box and the data view. The four types now take ReadOnlyStyle: Inherit | Control | Text, written, described when not Inherit (the default on 236/237 text boxes, 57/58 text areas, 51/59 date pickers and 4/4 radio button groups in PedApp and TestApp), and checked against one vocabulary (pages.ReadOnlyStyles). `alter page … set (ReadOnlyStyle: …)` was refused on every built-in widget, check box and data view included; it now writes the stored property. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- ...26-input-widget-readonlystyle-dropped.json | 1 + cmd/mxcli/syntax/features_page.go | 3 + docs/01-project/MDL_QUICK_REFERENCE.md | 1 + .../977-input-widget-readonlystyle.mdl | 49 +++++ .../modelsdk/widget_readonlystyle_977_test.go | 55 ++++++ mdl/backend/modelsdk/widget_write.go | 8 +- mdl/backend/pagemutator/mutator.go | 20 ++ mdl/backend/pagemutator/readonlystyle_test.go | 58 ++++++ mdl/executor/cmd_pages_builder_v3_widgets.go | 34 +++- mdl/executor/cmd_pages_describe_output.go | 20 +- mdl/executor/cmd_pages_describe_parse.go | 4 + .../readonlystyle_input_widgets_977_test.go | 181 ++++++++++++++++++ sdk/pages/pages_widgets_input.go | 40 +++- 13 files changed, 456 insertions(+), 18 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-input-widget-readonlystyle-dropped.json create mode 100644 mdl-examples/bug-tests/977-input-widget-readonlystyle.mdl create mode 100644 mdl/backend/modelsdk/widget_readonlystyle_977_test.go create mode 100644 mdl/backend/pagemutator/readonlystyle_test.go create mode 100644 mdl/executor/readonlystyle_input_widgets_977_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-input-widget-readonlystyle-dropped.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-input-widget-readonlystyle-dropped.json new file mode 100644 index 0000000000..375a18d5ec --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-input-widget-readonlystyle-dropped.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "A Studio Pro radio button group stored with ReadOnlyStyle Text came back from describe → exec as Inherit (JTSBootLogboek Dashboard_Fullscreen_web radioButtons1, Gebruikers_Overview); the same on text box, text area and date picker. describe printed `radiobuttons radioButtons1 (Attribute: IsAanwezig)`; mx check 0 errors before and after. `alter page … set (ReadOnlyStyle: …)` was refused on every built-in widget, check box and data view included", "cause": "Four writer cases kept the g.SetReadOnlyStyle(\"Inherit\") constant after #490 wired the check box and #550 the data view; the four sdk/pages structs had no field, the builders read nothing and describe's extract/emit covered only those two types. The mutator had no readonlystyle case, so the set fell through to the pluggable path and was refused", "file": "mdl/backend/modelsdk/widget_write.go, mdl/executor/cmd_pages_builder_v3_widgets.go, cmd_pages_describe_parse.go, cmd_pages_describe_output.go, mdl/backend/pagemutator/mutator.go, sdk/pages/pages_widgets_input.go", "insight": "A property fixed for one widget type is a signal to grep every SetX(\"literal\") for the same key: #490's finding already named the per-type constants on these four, and they survived two more issues because the fix and the report were both scoped to one type. List the metamodel types that carry the property (grep `func (o *X) SetReadOnlyStyle` in modelsdk/gen) and check each writer, describer and the mutator. Measure the default per type before choosing what describe omits: Inherit on 236/237 text boxes, 57/58 text areas, 51/59 date pickers, 4/4 radio buttons (PedApp + TestApp), unlike the data view's Control. The GetPut test only proves something on a Studio Pro-shaped value: create the page through mxcli, BSON-patch the stored value to Text, then describe → exec must report Unchanged", "refs": ["ako/mxcli#977", "ako/mxcli#490", "ako/mxcli#550"]} diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index b056202834..3f49fdf5c8 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -150,6 +150,9 @@ LIST IMPACT OF htmlelement; "-- container the grid declares — it used to be dropped on write with no\n" + "-- diagnostic, and is now refused (MDL-WIDGET30).\n\n" + "-- Inputs\nTEXTBOX name (Label: 'L', Attribute: Attr)\nTEXTAREA | DATEPICKER | COMBOBOX | CHECKBOX | RADIOBUTTONS\n" + + "-- Read-only style of TEXTBOX, TEXTAREA, DATEPICKER, CHECKBOX, RADIOBUTTONS (and DATAVIEW):\n" + + "-- Inherit (default) | Control | Text — how the widget renders when it is read-only.\n" + + "TEXTBOX name (Attribute: Name, Editable: Never, ReadOnlyStyle: Text)\n" + "-- A date picker's DateFormat is its mode: Date (default) | Time | DateTime | Custom.\n" + "DATEPICKER name (Attribute: Start, DateFormat: DateTime)\n" + "-- File upload/download: inside a data view on a System.FileDocument specialization (CE6810 otherwise).\n" + diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index b7d547672b..c45a76f297 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1493,6 +1493,7 @@ MDL uses explicit property declarations for pages: | Pop-up dimensions | `PopupWidth: n, PopupHeight: n, PopupResizable: bool` | `(Layout: Atlas_Core.PopupLayout, PopupWidth: 800, PopupHeight: 480, PopupResizable: true)` — case-sensitive; default 600×600 | | Page design properties | `DesignProperties: ( 'Key': 'Value', … )` | `(Layout: Atlas_Core.NativePhone_Default, DesignProperties: ('Spacing top': 'Inner large'))` — the page's own, from the theme's Document and Page groups | | Pop-up close button | `PopupCloseAction: ` | `(Layout: Atlas_Core.PopupLayout, PopupCloseAction: cancelButton1)` — names a widget on this page. Not carried from the stored document on a rewrite: the statement rebuilds the widget tree, so a carried name could dangle | +| Input read-only style | `ReadOnlyStyle: Inherit\|Control\|Text` | `textbox t (Attribute: Name, ReadOnlyStyle: Text)` — on a text box, text area, date picker, check box and radio buttons. **Inherit** is the default; also `alter page … set (ReadOnlyStyle: Text) on t` | | DataView read-only style | `ReadOnlyStyle: Inherit\|Control\|Text` | `dataview dv (datasource: $O, ReadOnlyStyle: Text)` — a DataView's own, distinct from a checkbox's. **Control** is Studio Pro's default here, not Inherit | | Page CSS class / style | `Class: 'css-class', Style: 'css: rule'` | `(Title: 'Home', Class: 'container-fluid bg-light', Style: 'min-height: 100vh')` — the page's Appearance | | Page variables | `variables: ( $name: type = )` | `variables: ( $show: boolean = true )` | diff --git a/mdl-examples/bug-tests/977-input-widget-readonlystyle.mdl b/mdl-examples/bug-tests/977-input-widget-readonlystyle.mdl new file mode 100644 index 0000000000..7086ab15bc --- /dev/null +++ b/mdl-examples/bug-tests/977-input-widget-readonlystyle.mdl @@ -0,0 +1,49 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#977 item 1 — `ReadOnlyStyle` was a silent drop on text box, text +-- area, date picker and radio buttons. Reported from the JTSBootLogboek round +-- trip on v0.28.0: "`Dashboard_Fullscreen_web` `radioButtons1` stores +-- `Forms$RadioButtonGroup.ReadOnlyStyle: Text`; `describe` prints +-- `radiobuttons radioButtons1 (Attribute: IsAanwezig)`, and `exec` stores +-- `Inherit`." +-- +-- The writer hard-coded Inherit on all four and describe never read the key; +-- only the check box (#490) and the data view (#550) carried it. `alter page … +-- set (ReadOnlyStyle: …)` was refused on every built-in widget. +-- +-- Inherit is Studio Pro's default: 236 of 237 text boxes, 57 of 58 text areas, +-- 51 of 59 date pickers and 4 of 4 radio button groups in PedApp and TestApp. +-- The others store Text, as Studio Pro wrote them. +-- +-- Measured on 11.13.0: this script, then describe → exec of the page — +-- before the fix: describe prints no ReadOnlyStyle; exec → Inherit on all four +-- after: describe prints it; exec → "Unchanged page"; mx check 0 errors +-- ============================================================================ + +create module BugReadOnlyStyle; + +create persistent entity BugReadOnlyStyle.Crew ( + Name: String(100), + Notes: String(unlimited), + Joined: DateTime, + Present: Boolean +); + +create microflow BugReadOnlyStyle.DS_Crew () returns BugReadOnlyStyle.Crew as $Crew +begin + $Crew = create BugReadOnlyStyle.Crew (Name = 'Ann'); + return $Crew; +end; + +create page BugReadOnlyStyle.Crew_Overview (Title: 'Crew', Layout: Atlas_Core.Atlas_Default) { + dataview dvCrew (DataSource: microflow BugReadOnlyStyle.DS_Crew, Editable: Never) { + textbox textBox1 (Label: 'Name', Attribute: Name, ReadOnlyStyle: Text) + textarea textArea1 (Label: 'Notes', Attribute: Notes, ReadOnlyStyle: Text) + datepicker datePicker1 (Label: 'Joined', Attribute: Joined, ReadOnlyStyle: Control) + radiobuttons radioButtons1 (Label: 'Present', Attribute: Present, ReadOnlyStyle: Text) + } +}; + +alter page BugReadOnlyStyle.Crew_Overview { + set (ReadOnlyStyle: Text) on datePicker1 +}; diff --git a/mdl/backend/modelsdk/widget_readonlystyle_977_test.go b/mdl/backend/modelsdk/widget_readonlystyle_977_test.go new file mode 100644 index 0000000000..118fc5459b --- /dev/null +++ b/mdl/backend/modelsdk/widget_readonlystyle_977_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#977 item 1: "`ReadOnlyStyle` is a silent drop on text box, text +// area, date picker and radio buttons. The writer hard-codes `Inherit`" — so +// `Dashboard_Fullscreen_web` `radioButtons1`, stored `ReadOnlyStyle: Text`, came +// back from describe → exec as `Inherit`. The check box was wired in #490; these +// four kept the constant. +func TestInputWidgetReadOnlyStyle_Written(t *testing.T) { + widgets := map[string]func(style string) pages.Widget{ + "textbox": func(s string) pages.Widget { + w := &pages.TextBox{ReadOnlyStyle: s} + w.Name = "textBox1" + return w + }, + "textarea": func(s string) pages.Widget { + w := &pages.TextArea{ReadOnlyStyle: s} + w.Name = "textArea1" + return w + }, + "datepicker": func(s string) pages.Widget { + w := &pages.DatePicker{ReadOnlyStyle: s} + w.Name = "datePicker1" + return w + }, + "radiobuttons": func(s string) pages.Widget { + w := &pages.RadioButtons{ReadOnlyStyle: s} + w.Name = "radioButtons1" + return w + }, + } + for kind, mk := range widgets { + for _, tc := range []struct{ style, want string }{ + // Unset keeps Studio Pro's default — Inherit on 236 of 237 text + // boxes, 57 of 58 text areas, 51 of 59 date pickers and 4 of 4 + // radio button groups across PedApp and TestApp. + {"", "Inherit"}, + {"Text", "Text"}, + {"Control", "Control"}, + } { + doc := encodeWidget(t, mk(tc.style)) + if got := docGet(doc, "ReadOnlyStyle"); got != tc.want { + t.Errorf("%s with ReadOnlyStyle %q: stored %v, want %q (ako/mxcli#977)", + kind, tc.style, got, tc.want) + } + } + } +} diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 0e8a62fc05..f5ae461046 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -582,7 +582,7 @@ func widgetToGen(w pages.Widget) (element.Element, error) { g.SetOnEnterKeyPressAction(noActionGen()) g.SetOnLeaveAction(noActionGen()) g.SetPlaceholderTemplate(textAsClientTemplate(x.Placeholder)) - g.SetReadOnlyStyle("Inherit") + g.SetReadOnlyStyle(orDefaultStr(x.ReadOnlyStyle, "Inherit")) // ako/mxcli#977 g.SetSubmitBehaviour(orDefaultStr(x.SubmitBehaviour, pages.DefaultSubmitBehaviour)) g.SetSubmitOnInputDelay(submitOnInputDelay(x.SubmitOnInputDelay)) g.SetValidation(widgetValidationToGenWith(x.ValidationExpression, x.ValidationMessage)) @@ -689,7 +689,7 @@ func widgetToGen(w pages.Widget) (element.Element, error) { g.SetOnEnterAction(noActionGen()) g.SetOnLeaveAction(noActionGen()) g.SetPlaceholderTemplate(textAsClientTemplate(x.Placeholder)) - g.SetReadOnlyStyle("Inherit") + g.SetReadOnlyStyle(orDefaultStr(x.ReadOnlyStyle, "Inherit")) // ako/mxcli#977 g.SetSubmitBehaviour(orDefaultStr(x.SubmitBehaviour, pages.DefaultSubmitBehaviour)) g.SetSubmitOnInputDelay(submitOnInputDelay(x.SubmitOnInputDelay)) // Studio Pro stores one on every textarea; without the key a rewrite @@ -725,7 +725,7 @@ func widgetToGen(w pages.Widget) (element.Element, error) { g.SetOnEnterAction(noActionGen()) g.SetOnLeaveAction(noActionGen()) g.SetPlaceholderTemplate(textAsClientTemplate(x.Placeholder)) - g.SetReadOnlyStyle("Inherit") + g.SetReadOnlyStyle(orDefaultStr(x.ReadOnlyStyle, "Inherit")) // ako/mxcli#977 g.SetValidation(widgetValidationToGen()) return g, nil @@ -743,7 +743,7 @@ func widgetToGen(w pages.Widget) (element.Element, error) { if x.Label != "" { g.SetLabelTemplate(textAsClientTemplate(textFromString(x.Label))) } - g.SetReadOnlyStyle("Inherit") + g.SetReadOnlyStyle(orDefaultStr(x.ReadOnlyStyle, "Inherit")) // ako/mxcli#977 g.SetRenderHorizontal(x.RenderDirection != pages.RenderDirectionVertical) onChange, err := clientActionToGen(x.OnChangeAction) if err != nil { diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index e449a1a3fd..ddf1ce657c 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -3321,6 +3321,13 @@ func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { return setDynamicTextNativeTextStyleMut(widget, value) } return setPluggableWidgetPropertyMut(widget, propName, value) + case "readonlystyle": + // Stored on the built-in input widgets and the data view; a pluggable + // widget with its own "readOnlyStyle" key keeps that path (ako/mxcli#977). + if bsonnav.DGet(widget, "ReadOnlyStyle") != nil { + return setReadOnlyStyleMut(widget, value) + } + return setPluggableWidgetPropertyMut(widget, propName, value) default: // Try as pluggable widget property return setPluggableWidgetPropertyMut(widget, propName, value) @@ -3397,6 +3404,19 @@ func setDynamicTextNativeTextStyleMut(widget bson.D, value any) error { return nil } +// setReadOnlyStyleMut writes a widget's ReadOnlyStyle in place, canonically +// spelled; an unknown member is refused before the document is touched. +func setReadOnlyStyleMut(widget bson.D, value any) error { + s, _ := value.(string) + style, ok := pages.CanonicalReadOnlyStyle(s) + if !ok { + return fmt.Errorf("invalid ReadOnlyStyle %q for widget %q: expected one of %s", + fmt.Sprint(value), bsonnav.DGetString(widget, "Name"), strings.Join(pages.ReadOnlyStyles, ", ")) + } + bsonnav.DSet(widget, "ReadOnlyStyle", style) + return nil +} + // setContainerRenderModeMut writes a container's RenderMode — the HTML element // it renders as — in place, canonically spelled; an unknown member is refused // before the document is touched (mendixlabs/mxcli#1582). diff --git a/mdl/backend/pagemutator/readonlystyle_test.go b/mdl/backend/pagemutator/readonlystyle_test.go new file mode 100644 index 0000000000..666d87981c --- /dev/null +++ b/mdl/backend/pagemutator/readonlystyle_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// ako/mxcli#977: `alter page … set (ReadOnlyStyle: Text) on ` fell +// through to the pluggable-widget path and was refused as "not a pluggable +// widget" on every widget that stores the property. It is now written in place, +// canonically spelled, on each type that stores it. +func TestSetWidgetProperty_ReadOnlyStyle(t *testing.T) { + for _, typ := range []string{ + "Forms$TextBox", "Forms$TextArea", "Forms$DatePicker", + "Forms$RadioButtonGroup", "Forms$CheckBox", "Forms$DataView", + } { + t.Run(typ, func(t *testing.T) { + rawData := makeRawPage(bson.D{ + {Key: "$Type", Value: typ}, + {Key: "Name", Value: "w1"}, + {Key: "ReadOnlyStyle", Value: "Inherit"}, + }) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("w1", "ReadOnlyStyle", "text"); err != nil { + t.Fatalf("set ReadOnlyStyle: %v", err) + } + if got := bsonnav.DGet(findBsonWidget(rawData, "w1").widget, "ReadOnlyStyle"); got != "Text" { + t.Fatalf("ReadOnlyStyle = %#v, want the canonical \"Text\"", got) + } + // An unknown member is refused before the document is touched. + err := m.SetWidgetProperty("w1", "ReadOnlyStyle", "ReadOnly") + if err == nil || !strings.Contains(err.Error(), "Inherit, Control, Text") { + t.Fatalf("set ReadOnlyStyle = ReadOnly: err = %v, want a refusal naming the members", err) + } + if got := bsonnav.DGet(findBsonWidget(rawData, "w1").widget, "ReadOnlyStyle"); got != "Text" { + t.Fatalf("a refused set changed ReadOnlyStyle to %#v", got) + } + }) + } +} + +// A widget that stores no ReadOnlyStyle (a container) refuses the set. +func TestSetWidgetProperty_ReadOnlyStyleNoSuchProperty(t *testing.T) { + rawData := makeRawPage(bson.D{ + {Key: "$Type", Value: "Forms$DivContainer"}, + {Key: "Name", Value: "c1"}, + }) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("c1", "ReadOnlyStyle", "Text"); err == nil { + t.Fatal("set ReadOnlyStyle on a container succeeded") + } +} diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index 70b973b36d..4fd0cf1ca4 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -551,6 +551,13 @@ func (pb *pageBuilder) buildTextBoxV3(w *ast.WidgetV3) (*pages.TextBox, error) { } } + // ReadOnlyStyle, as on a checkbox; unset keeps Inherit (ako/mxcli#977). + tbStyle, err := readOnlyStyleValue(w.GetStringProp("ReadOnlyStyle"), "textbox", w.Name) + if err != nil { + return nil, err + } + tb.ReadOnlyStyle = tbStyle + // Handle OnChange (the "On change" client action) if err := pb.applyOnChangeV3(w, &tb.OnChangeAction); err != nil { return nil, err @@ -611,6 +618,13 @@ func (pb *pageBuilder) buildTextAreaV3(w *ast.WidgetV3) (*pages.TextArea, error) } } + // ReadOnlyStyle, as on a checkbox; unset keeps Inherit (ako/mxcli#977). + taStyle, err := readOnlyStyleValue(w.GetStringProp("ReadOnlyStyle"), "textarea", w.Name) + if err != nil { + return nil, err + } + ta.ReadOnlyStyle = taStyle + // Handle OnChange (the "On change" client action) if err := pb.applyOnChangeV3(w, &ta.OnChangeAction); err != nil { return nil, err @@ -661,6 +675,13 @@ func (pb *pageBuilder) buildDatePickerV3(w *ast.WidgetV3) (*pages.DatePicker, er } dp.FormattingInfo = fi + // ReadOnlyStyle, as on a checkbox; unset keeps Inherit (ako/mxcli#977). + dpStyle, err := readOnlyStyleValue(w.GetStringProp("ReadOnlyStyle"), "datepicker", w.Name) + if err != nil { + return nil, err + } + dp.ReadOnlyStyle = dpStyle + // Handle OnChange (the "On change" client action) if err := pb.applyOnChangeV3(w, &dp.OnChangeAction); err != nil { return nil, err @@ -860,10 +881,8 @@ func readOnlyStyleValue(raw, kind, widgetName string) (string, error) { if raw == "" { return "", nil } - for _, member := range []string{"Inherit", "Control", "Text"} { - if strings.EqualFold(raw, member) { - return member, nil - } + if member, ok := pages.CanonicalReadOnlyStyle(raw); ok { + return member, nil } return "", mdlerrors.NewValidationf( "%s %q: ReadOnlyStyle %q is not a Mendix read-only style — use Inherit, Control or Text", @@ -898,6 +917,13 @@ func (pb *pageBuilder) buildRadioButtonsV3(w *ast.WidgetV3) (*pages.RadioButtons return nil, err } + // ReadOnlyStyle, as on a checkbox; unset keeps Inherit (ako/mxcli#977). + rbStyle, err := readOnlyStyleValue(w.GetStringProp("ReadOnlyStyle"), "radiobuttons", w.Name) + if err != nil { + return nil, err + } + rb.ReadOnlyStyle = rbStyle + // Handle OnChange (the "On change" client action) if err := pb.applyOnChangeV3(w, &rb.OnChangeAction); err != nil { return nil, err diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 0ed44a18c1..b66eefb7bf 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -669,6 +669,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if w.Content != "" { props = append(props, fmt.Sprintf("Attribute: %s", w.Content)) } + props = appendReadOnlyStyle(props, w) if w.Placeholder != "" { props = append(props, fmt.Sprintf("Placeholder: %s", mdlQuote(ctx, w.Placeholder))) } @@ -690,6 +691,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if w.Content != "" { props = append(props, fmt.Sprintf("Attribute: %s", w.Content)) } + props = appendReadOnlyStyle(props, w) if w.Placeholder != "" { props = append(props, fmt.Sprintf("Placeholder: %s", mdlQuote(ctx, w.Placeholder))) } @@ -719,6 +721,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if w.Content != "" { props = append(props, fmt.Sprintf("Attribute: %s", w.Content)) } + props = appendReadOnlyStyle(props, w) // The picker's mode — without it a date-time picker described as a // date-only one, and describe → exec wrote it that way (ako/mxcli#968). props = append(props, w.Formatting...) @@ -737,6 +740,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if w.Content != "" { props = append(props, fmt.Sprintf("Attribute: %s", w.Content)) } + props = appendReadOnlyStyle(props, w) if w.OnChange != "" { props = append(props, actionProp("OnChange", w.OnChange)) } @@ -752,10 +756,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if w.Content != "" { props = append(props, fmt.Sprintf("Attribute: %s", w.Content)) } - // Show ReadOnlyStyle if not default "Inherit" - if w.ReadOnlyStyle != "" && w.ReadOnlyStyle != "Inherit" { - props = append(props, fmt.Sprintf("ReadOnlyStyle: %s", w.ReadOnlyStyle)) - } + props = appendReadOnlyStyle(props, w) // Show ShowLabel if false (not showing label) if !w.ShowLabel { props = append(props, "ShowLabel: No") @@ -2239,3 +2240,14 @@ func layoutGridAlignmentMDL(a string) string { } return a } + +// appendReadOnlyStyle prints an input widget's ReadOnlyStyle unless it is +// Inherit, Studio Pro's default. Read on the check box only until +// ako/mxcli#977, so a text box, text area, date picker or radio button group +// stored with Text or Control came back from describe → exec as Inherit. +func appendReadOnlyStyle(props []string, w rawWidget) []string { + if w.ReadOnlyStyle != "" && w.ReadOnlyStyle != "Inherit" { + props = append(props, fmt.Sprintf("ReadOnlyStyle: %s", w.ReadOnlyStyle)) + } + return props +} diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 821dee9e53..72a5b6de94 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -409,6 +409,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.Content = extractInputAttribute(ctx, w) widget.Placeholder = extractPlaceholderText(ctx, w) widget.Editable = extractEditable(ctx, w) + widget.ReadOnlyStyle = extractReadOnlyStyle(ctx, w) // ako/mxcli#977 widget.IsPassword, _ = w["IsPasswordBox"].(bool) widget.ValidationExpression, widget.ValidationMessage = extractWidgetValidation(ctx, w) widget.SubmitBehaviour, widget.SubmitOnInputDelay = extractSubmitBehaviour(w) @@ -423,6 +424,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s // textarea's placeholder with all its translations (ako/mxcli#705). widget.Placeholder = extractPlaceholderText(ctx, w) widget.Editable = extractEditable(ctx, w) + widget.ReadOnlyStyle = extractReadOnlyStyle(ctx, w) // ako/mxcli#977 widget.SubmitBehaviour, widget.SubmitOnInputDelay = extractSubmitBehaviour(w) widget.OnChange = extractOnChangeAction(ctx, w) return []rawWidget{widget} @@ -439,6 +441,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.Caption = extractLabelText(ctx, w) widget.Content = extractInputAttribute(ctx, w) widget.Editable = extractEditable(ctx, w) + widget.ReadOnlyStyle = extractReadOnlyStyle(ctx, w) // ako/mxcli#977 widget.OnChange = extractOnChangeAction(ctx, w) widget.Formatting = describeInputFormatting(ctx, "datepicker", w) return []rawWidget{widget} @@ -448,6 +451,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.Caption = extractLabelText(ctx, w) widget.Content = extractInputAttribute(ctx, w) widget.Editable = extractEditable(ctx, w) + widget.ReadOnlyStyle = extractReadOnlyStyle(ctx, w) // ako/mxcli#977 widget.OnChange = extractOnChangeAction(ctx, w) return []rawWidget{widget} diff --git a/mdl/executor/readonlystyle_input_widgets_977_test.go b/mdl/executor/readonlystyle_input_widgets_977_test.go new file mode 100644 index 0000000000..afa414f115 --- /dev/null +++ b/mdl/executor/readonlystyle_input_widgets_977_test.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#977 item 1, as reported from the JTSBootLogboek round trip: +// "`Dashboard_Fullscreen_web` `radioButtons1` stores +// `Forms$RadioButtonGroup.ReadOnlyStyle: Text`; `describe` prints `radiobuttons +// radioButtons1 (Attribute: IsAanwezig)`, and `exec` stores `Inherit`." +// +// The same held for text box, text area and date picker: the writer hard-coded +// Inherit and describe never read the key. Only the check box (#490) and the +// data view (#550) carried it. +package executor + +import ( + "context" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/model" +) + +const ros977Prelude = `create persistent entity MyFirstModule.Crew (Name: String(100), Notes: String(unlimited), Joined: DateTime, Present: Boolean); +create microflow MyFirstModule.DS_Crew () returns MyFirstModule.Crew as $Crew +begin + $Crew = create MyFirstModule.Crew (Name = 'Ann'); + return $Crew; +end; +` + +func ros977Page(name, style string) string { + set := "" + if style != "" { + set = ", ReadOnlyStyle: " + style + } + return "create or modify page MyFirstModule." + name + " (Title: 'Crew', Layout: Atlas_Core.Atlas_Default) {\n" + + " dataview dvCrew (DataSource: microflow MyFirstModule.DS_Crew) {\n" + + " textbox textBox1 (Attribute: Name" + set + ")\n" + + " textarea textArea1 (Attribute: Notes" + set + ")\n" + + " datepicker datePicker1 (Attribute: Joined" + set + ")\n" + + " radiobuttons radioButtons1 (Attribute: Present" + set + ")\n" + + " }\n};\n" +} + +var ros977Widgets = []string{"textBox1", "textArea1", "datePicker1", "radioButtons1"} + +// pageUnitByName returns the id and stored bytes of MyFirstModule.. +func pageUnitByName(t *testing.T, exec *Executor, name string) (model.ID, []byte) { + t.Helper() + ctx := exec.newExecContext(context.Background()) + h, err := getHierarchy(ctx) + if err != nil { + t.Fatal(err) + } + all, err := ctx.Backend.ListPages() + if err != nil { + t.Fatal(err) + } + for _, p := range all { + if p.Name == name && h.GetModuleName(h.FindModuleID(p.ContainerID)) == "MyFirstModule" { + raw, err := ctx.Backend.GetRawUnitBytes(p.ID) + if err != nil { + t.Fatal(err) + } + return p.ID, raw + } + } + t.Fatalf("page MyFirstModule.%s not found", name) + return "", nil +} + +// storedReadOnlyStyles maps each named widget on the page to its stored +// ReadOnlyStyle. +func storedReadOnlyStyles(t *testing.T, exec *Executor, page string) map[string]string { + t.Helper() + _, raw := pageUnitByName(t, exec, page) + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + out := map[string]string{} + walkStoredDocs(doc, func(d bson.D) { + if s, ok := afGet(d, "ReadOnlyStyle").(string); ok { + if name, _ := afGet(d, "Name").(string); name != "" { + out[name] = s + } + } + }) + return out +} + +func TestInputWidgetReadOnlyStyle_AuthoredIsWritten(t *testing.T) { + exec, _ := openPedAppFixture(t) + if err := afRun(t, exec, ros977Prelude+ros977Page("Crew_Authored", "Control")); err != nil { + t.Fatalf("exec: %v", err) + } + got := storedReadOnlyStyles(t, exec, "Crew_Authored") + for _, w := range ros977Widgets { + if got[w] != "Control" { + t.Errorf("%s: stored ReadOnlyStyle %q, want Control (all: %v)", w, got[w], got) + } + } + + // `alter page … set` was refused on every built-in widget: "property + // ReadOnlyStyle not found on this widget". + alter := "alter page MyFirstModule.Crew_Authored {\n" + for _, w := range ros977Widgets { + alter += " set (ReadOnlyStyle: Text) on " + w + "\n" + } + if err := afRun(t, exec, alter+"};"); err != nil { + t.Fatalf("alter page set ReadOnlyStyle: %v", err) + } + got = storedReadOnlyStyles(t, exec, "Crew_Authored") + for _, w := range ros977Widgets { + if got[w] != "Text" { + t.Errorf("%s: after alter set, ReadOnlyStyle %q, want Text", w, got[w]) + } + } +} + +// The reported round trip, on a page whose four widgets carry ReadOnlyStyle +// Text as Studio Pro stores it. +func TestInputWidgetReadOnlyStyle_StudioProValueSurvivesDescribeExec(t *testing.T) { + exec, out := openPedAppFixture(t) + if err := afRun(t, exec, ros977Prelude+ros977Page("Crew_Overview", "")); err != nil { + t.Fatalf("exec: %v", err) + } + id, raw := pageUnitByName(t, exec, "Crew_Overview") + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + walkStoredDocs(doc, func(d bson.D) { + if _, ok := afGet(d, "ReadOnlyStyle").(string); !ok || afGet(d, "$Type") == "Forms$DataView" { + return + } + for i := range d { + if d[i].Key == "ReadOnlyStyle" { + d[i].Value = "Text" + } + } + }) + b, err := bson.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := exec.newExecContext(context.Background()).Backend.UpdateRawUnit(string(id), b); err != nil { + t.Fatal(err) + } + + out.Reset() + if err := afRun(t, exec, "describe page MyFirstModule.Crew_Overview;"); err != nil { + t.Fatal(err) + } + described := out.String() + for _, w := range ros977Widgets { + line := "" + for _, l := range strings.Split(described, "\n") { + if strings.Contains(l, " "+w+" ") { + line = l + } + } + if !strings.Contains(line, "ReadOnlyStyle: Text") { + t.Errorf("describe dropped %s's ReadOnlyStyle: Text: %q", w, line) + } + } + + out.Reset() + if err := afRun(t, exec, described); err != nil { + t.Fatalf("re-exec of the description: %v", err) + } + if !strings.Contains(out.String(), "Unchanged page") { + t.Errorf("re-executing the description was not a no-op:\n%s", out.String()) + } + got := storedReadOnlyStyles(t, exec, "Crew_Overview") + for _, w := range ros977Widgets { + if got[w] != "Text" { + t.Errorf("%s: after describe → exec ReadOnlyStyle is %q, want Text (all: %v)", w, got[w], got) + } + } +} diff --git a/sdk/pages/pages_widgets_input.go b/sdk/pages/pages_widgets_input.go index 2eb53e8b17..b2c800288d 100644 --- a/sdk/pages/pages_widgets_input.go +++ b/sdk/pages/pages_widgets_input.go @@ -10,6 +10,22 @@ import ( // Input Widgets +// ReadOnlyStyles are the members of Mendix's "Read-only style", stored as +// ReadOnlyStyle on a check box, text box, text area, date picker, radio button +// group and data view. +var ReadOnlyStyles = []string{"Inherit", "Control", "Text"} + +// CanonicalReadOnlyStyle matches s case-insensitively against ReadOnlyStyles +// and returns the member as Mendix spells it. +func CanonicalReadOnlyStyle(s string) (string, bool) { + for _, v := range ReadOnlyStyles { + if strings.EqualFold(strings.TrimSpace(s), v) { + return v, true + } + } + return "", false +} + // WidgetVariable is an input widget's SourceVariable: the Forms$PageVariable // that names which object the widget's attribute is read from when that is not // simply the enclosing data context. Studio Pro stores a widget-scoped one — @@ -34,6 +50,9 @@ type TextBox struct { Placeholder *model.Text `json:"placeholder,omitempty"` MaxLength int `json:"maxLength,omitempty"` IsPassword bool `json:"isPassword,omitempty"` + // ReadOnlyStyle: Inherit, Control or Text, as on CheckBox. Empty writes + // Studio Pro's default, Inherit (ako/mxcli#977). + ReadOnlyStyle string `json:"readOnlyStyle,omitempty"` // ValidationExpression / ValidationMessage are the two fields of the widget's // Forms$WidgetValidation. Without them a rewrite wrote the empty default over // whatever Studio Pro had stored (ako/mxcli#550). @@ -63,8 +82,11 @@ type TextArea struct { MaxLength int `json:"maxLength,omitempty"` CounterMessage *model.Text `json:"counterMessage,omitempty"` Rows int `json:"rows,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - OnChangeAction ClientAction `json:"onChangeAction,omitempty"` + // ReadOnlyStyle: Inherit, Control or Text, as on CheckBox. Empty writes + // Studio Pro's default, Inherit (ako/mxcli#977). + ReadOnlyStyle string `json:"readOnlyStyle,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + OnChangeAction ClientAction `json:"onChangeAction,omitempty"` // SubmitBehaviour / SubmitOnInputDelay: as on TextBox. SubmitBehaviour string `json:"submitBehaviour,omitempty"` SubmitOnInputDelay *int `json:"submitOnInputDelay,omitempty"` @@ -94,8 +116,11 @@ type DatePicker struct { // defaults (Date). It replaced a DateFormat string nothing read, so every // picker was written as date-only (ako/mxcli#968). FormattingInfo *FormattingInfo `json:"formattingInfo,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - OnChangeAction ClientAction `json:"onChangeAction,omitempty"` + // ReadOnlyStyle: Inherit, Control or Text, as on CheckBox. Empty writes + // Studio Pro's default, Inherit (ako/mxcli#977). + ReadOnlyStyle string `json:"readOnlyStyle,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + OnChangeAction ClientAction `json:"onChangeAction,omitempty"` } // DropDown represents a drop-down selection widget. @@ -180,8 +205,11 @@ type RadioButtons struct { AttributeRefSteps []AttributeRefStep `json:"attributeRefSteps,omitempty"` // association hops when the attribute is reached over associations (AttributeRef.EntityRef) SourceVariable *WidgetVariable `json:"sourceVariable,omitempty"` // widget-scoped variable the attribute is read from (#826) RenderDirection RenderDirection `json:"renderDirection,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - OnChangeAction ClientAction `json:"onChangeAction,omitempty"` + // ReadOnlyStyle: Inherit, Control or Text, as on CheckBox. Empty writes + // Studio Pro's default, Inherit (ako/mxcli#977). + ReadOnlyStyle string `json:"readOnlyStyle,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + OnChangeAction ClientAction `json:"onChangeAction,omitempty"` } // RenderDirection represents the direction for rendering. From e72464e9ba73abd18371f176ba8252772c7564e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:45:42 +0000 Subject: [PATCH 09/40] test: strike WorkflowEndedUserTask from the TestApp allowlist The page #977 named as blocked by the ReadOnlyStyle drop now passes getput: its three date pickers and its text box store ReadOnlyStyle Text. The allowlist may only shrink. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- mdl/roundtrip/testapp_allowlist_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index 539cb7a5e1..2f72822fe4 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -137,7 +137,6 @@ var testAppKnownFailures = map[string]knownFailure{ "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.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.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"}, "rest client Clients.OrdersRestClient": {laws: []law{lawParse}, issue: "#721", why: "rest client: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, From 84e75858c311a5f6048373d021063bb846b495c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 11:32:31 +0000 Subject: [PATCH 10/40] fix: keep the stored language order when carrying translations (#1704) The rebuild writes the one language MDL carries, and CarryTranslations appended the stored others after it in sorted order. A text Studio Pro stored as [nl_NL, en_US] came back [en_US, nl_NL], and en_US,fr_FR,nl_NL, de_DE came back sorted. The canonical form compares lists in order, so the reorder alone made an unchanged page a write. The carry now follows the stored text's language order, from whichever pairing found it (path, owning element or source string). Studio Pro's order is not sorted: 129 texts in PedApp and TestApp are stored Dutch first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- modelsdk/canon/translations.go | 96 ++++++++++--- .../canon/translations_order_1704_test.go | 126 ++++++++++++++++++ 2 files changed, 201 insertions(+), 21 deletions(-) create mode 100644 modelsdk/canon/translations_order_1704_test.go diff --git a/modelsdk/canon/translations.go b/modelsdk/canon/translations.go index e8a0f775d2..de61e56701 100644 --- a/modelsdk/canon/translations.go +++ b/modelsdk/canon/translations.go @@ -123,17 +123,18 @@ func CarryTranslations(contents, stored []byte) []byte { for _, path := range sortedPaths(newByPath) { text := newByPath[path] var want map[string]bson.D + var order []string if byPath { - want = textTranslationElems(storedByPath[path]) + want, order = textTranslationElems(storedByPath[path]), textLanguageOrder(storedByPath[path]) } else if stored, ok := storedByElement[newElementOf[path]]; ok { - want = textTranslationElems(stored) + want, order = textTranslationElems(stored), textLanguageOrder(stored) } else { - want = matchBySource(textTranslations(text), sets) + want, order = matchBySource(textTranslations(text), sets) } if len(want) == 0 { continue } - if mergeText(text, want, path, used) { + if mergeText(text, want, order, path, used) { changed = true } } @@ -233,23 +234,25 @@ func samePathSet(a, b map[string]bson.D) bool { } // matchBySource finds the stored translation set for a rebuilt text, by whichever -// (language, text) pair the rebuild wrote. Returns nil when nothing matches or -// the match is ambiguous. -func matchBySource(have map[string]string, sets map[string]*translationSet) map[string]bson.D { +// (language, text) pair the rebuild wrote, with its stored language order. +// Returns nil when nothing matches or the match is ambiguous. +func matchBySource(have map[string]string, sets map[string]*translationSet) (map[string]bson.D, []string) { for lang, txt := range have { if s, ok := sets[lang+"\x00"+txt]; ok { if s.ambiguous { - return nil + return nil, nil } - return s.byLang + return s.byLang, s.order } } - return nil + return nil, nil } // ambiguous marks a key whose stored translation sets disagree. type translationSet struct { byLang map[string]bson.D + // order is the stored text's language order, which the carry keeps. + order []string // text is the language→string view, used only to decide whether two stored // sets sharing a source key actually agree. text map[string]string @@ -268,7 +271,7 @@ func storedTranslationSets(doc bson.D) map[string]*translationSet { if ty, _ := docLookup(n, "$Type").(string); ty == "Texts$Text" { byText := textTranslations(n) if len(byText) > 1 { - set := &translationSet{byLang: textTranslationElems(n), text: byText} + set := &translationSet{byLang: textTranslationElems(n), order: textLanguageOrder(n), text: byText} for lang, text := range byText { k := lang + "\x00" + text prev, seen := out[k] @@ -299,26 +302,38 @@ func storedTranslationSets(doc bson.D) map[string]*translationSet { // anything was added. A language the rebuild wrote is never overwritten: the // statement is the authority for what it says. // +// The result follows the stored text's language order. The rebuild writes the +// one language MDL carries, so appending the others after it — in sorted order, +// as this did — turned a text Studio Pro stored as [nl_NL, en_US] into [en_US, +// nl_NL]. The canonical form compares lists in order, so the reorder alone +// made an unchanged page a write (ako/mxcli#1704). +// // used carries every element id already in the document and is updated as ids // are taken, so the same stored element handed to two texts does not put one id // on both of them (see reuseSafeID). -func mergeText(text bson.D, want map[string]bson.D, path string, used map[string]bool) bool { +func mergeText(text bson.D, want map[string]bson.D, order []string, path string, used map[string]bool) bool { have := textTranslations(text) items, ok := docLookup(text, "Items").(bson.A) if !ok { return false } added := false - for _, lang := range sortedLangs(want) { + for _, lang := range order { if _, exists := have[lang]; exists { continue } - items = append(items, reuseSafeID(want[lang], path, lang, used)) + elem, ok := want[lang] + if !ok { + continue + } + items = append(items, reuseSafeID(elem, path, lang, used)) + have[lang] = "" added = true } if !added { return false } + items = inLanguageOrder(items, order) for i, e := range text { if e.Key == "Items" { text[i].Value = items @@ -327,14 +342,53 @@ func mergeText(text bson.D, want map[string]bson.D, path string, used map[string return true } -// sortedLangs keeps the appended order deterministic, so the same inputs produce -// the same bytes and no-op elision can fire. -func sortedLangs(m map[string]bson.D) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) +// inLanguageOrder arranges a text's translation elements in the given language +// order. The list marker stays first; a language the order does not name keeps +// its relative place after the named ones. +func inLanguageOrder(items bson.A, order []string) bson.A { + rank := make(map[string]int, len(order)) + for i, lang := range order { + if _, seen := rank[lang]; !seen { + rank[lang] = i + } + } + rankOf := func(d bson.D) int { + lang, _ := docLookup(d, "LanguageCode").(string) + if r, ok := rank[lang]; ok { + return r + } + return len(order) + } + out := make(bson.A, 0, len(items)) + var elems []bson.D + for _, it := range items { + if d, ok := it.(bson.D); ok { + elems = append(elems, d) + } else { + out = append(out, it) + } + } + sort.SliceStable(elems, func(i, j int) bool { return rankOf(elems[i]) < rankOf(elems[j]) }) + for _, d := range elems { + out = append(out, d) + } + return out +} + +// textLanguageOrder reads a Texts$Text's languages in stored order. +func textLanguageOrder(text bson.D) []string { + items, ok := docLookup(text, "Items").(bson.A) + if !ok { + return nil + } + var out []string + for _, it := range items { + if d, ok := it.(bson.D); ok { + if lang, _ := docLookup(d, "LanguageCode").(string); lang != "" { + out = append(out, lang) + } + } } - sort.Strings(out) return out } diff --git a/modelsdk/canon/translations_order_1704_test.go b/modelsdk/canon/translations_order_1704_test.go new file mode 100644 index 0000000000..9447d088c4 --- /dev/null +++ b/modelsdk/canon/translations_order_1704_test.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 + +package canon + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ako/mxcli#1704: describe → exec rewrote JTSBootLogboek.Home_Native although +// nothing was lost — "the order … of some English/Dutch texts changed". The +// rebuild writes the one language MDL carries and the carry appended the stored +// others in sorted order, so a text stored `[nl_NL, en_US]` came back `[en_US, +// nl_NL]`. The canonical form compares lists in order, so the write went ahead. +// +// Studio Pro's order is not sorted: across PedApp and TestApp, 129 texts are +// stored nl_NL first, and orders such as en_US,fr_FR,nl_NL,de_DE occur. + +// languageOrder returns the LanguageCode order of the first Texts$Text found. +func languageOrder(t *testing.T, raw []byte) string { + t.Helper() + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + var out []string + var walk func(v any) bool + walk = func(v any) bool { + switch n := v.(type) { + case bson.D: + if docLookup(n, "$Type") == "Texts$Text" { + items, _ := docLookup(n, "Items").(bson.A) + for _, it := range items { + if d, ok := it.(bson.D); ok { + l, _ := docLookup(d, "LanguageCode").(string) + out = append(out, l) + } + } + return true + } + for _, e := range n { + if walk(e.Value) { + return true + } + } + case bson.A: + for _, e := range n { + if walk(e) { + return true + } + } + } + return false + } + walk(doc) + return strings.Join(out, ",") +} + +func TestCarryTranslations_KeepsTheStoredLanguageOrder(t *testing.T) { + for _, tc := range []struct { + name string + stored []bson.D + }{ + // The reported shape: the rebuilt language is not the stored first. + {"Dutch first", []bson.D{tr(11, "nl_NL", "Mijn account"), tr(12, "en_US", "My Account")}}, + // The carried languages are not in sorted order (measured in TestApp). + {"unsorted", []bson.D{ + tr(11, "en_US", "My Account"), tr(12, "fr_FR", "Mon compte"), + tr(13, "nl_NL", "Mijn account"), tr(14, "de_DE", "Mein Konto"), + }}, + } { + t.Run(tc.name, func(t *testing.T) { + stored := textDoc(t, 10, "MyAccount", tc.stored...) + rebuilt := textDoc(t, 20, "MyAccount", tr(21, "en_US", "My Account")) + + if got, want := languageOrder(t, CarryTranslations(rebuilt, stored)), languageOrder(t, stored); got != want { + t.Errorf("carried order %s, want the stored %s", got, want) + } + if _, unchanged := Reconcile(rebuilt, stored); !unchanged { + t.Errorf("a rebuild that only dropped languages was written: the carry reordered them") + } + }) + } +} + +// The same when the text is paired by its source string: a text in a list under +// an unnamed element, in a rebuild whose texts sit at other paths. +func TestCarryTranslations_KeepsTheStoredLanguageOrderBySource(t *testing.T) { + caption := func(id byte, items ...bson.D) bson.D { + arr := bson.A{int32(3)} + for _, it := range items { + arr = append(arr, it) + } + return bson.D{ + {Key: "$Type", Value: "Forms$Unnamed"}, + {Key: "$ID", Value: bin(id)}, + {Key: "Caption", Value: bson.D{ + {Key: "$Type", Value: "Texts$Text"}, + {Key: "$ID", Value: bin(id + 1)}, + {Key: "Items", Value: arr}, + }}, + } + } + page := func(widgets ...bson.D) []byte { + arr := bson.A{int32(2)} + for _, w := range widgets { + arr = append(arr, w) + } + return marshal(t, bson.D{ + {Key: "$Type", Value: "Forms$Form"}, + {Key: "$ID", Value: bin(1)}, + {Key: "Name", Value: "Home"}, + {Key: "Widgets", Value: arr}, + }) + } + stored := page(caption(10, tr(12, "nl_NL", "Welkom"), tr(13, "en_US", "Welcome"))) + rebuilt := page( + caption(20, tr(22, "en_US", "Welcome")), + caption(30, tr(32, "en_US", "Added")), + ) + if got := languageOrder(t, CarryTranslations(rebuilt, stored)); got != "nl_NL,en_US" { + t.Errorf("carried order %s, want the stored nl_NL,en_US", got) + } +} From 3e9e8090c460a07316186e5f5aac98d5512121a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 11:32:31 +0000 Subject: [PATCH 11/40] fix: build layout arguments in the order the placeholder blocks give (#1704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describe prints a multi-placeholder page's `placeholder X { … }` blocks in stored order, and the builder put Main first whatever the blocks said, so JTSBootLogboek.Home_Native, stored [Header, Main], came back [Main, Header] and describe → exec rewrote it. Main now comes first only when no block names it, which keeps every page without an explicit Main block as before. The round-trip test covers both causes of #1704 on a Studio Pro-shaped page (arguments [Header, Main], texts stored Dutch first): with either fix reverted it reports "Replaced page". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- ...c-reorders-arguments-and-translations.json | 1 + ...-layout-argument-and-translation-order.mdl | 35 +++++ mdl/executor/cmd_pages_builder_v3.go | 13 +- .../layout_argument_order_1704_test.go | 140 ++++++++++++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/modelsdk/2026-10-26-describe-exec-reorders-arguments-and-translations.json create mode 100644 mdl-examples/bug-tests/1704-layout-argument-and-translation-order.mdl create mode 100644 mdl/executor/layout_argument_order_1704_test.go diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-10-26-describe-exec-reorders-arguments-and-translations.json b/.claude/skills/fix-issue/findings/modelsdk/2026-10-26-describe-exec-reorders-arguments-and-translations.json new file mode 100644 index 0000000000..426ca1b88f --- /dev/null +++ b/.claude/skills/fix-issue/findings/modelsdk/2026-10-26-describe-exec-reorders-arguments-and-translations.json @@ -0,0 +1 @@ +{"area": "modelsdk/canon", "date": "2026-10-26", "symptom": "describe → exec of a Studio Pro page reported Replaced although nothing was lost (JTSBootLogboek.Home_Native): only the order of its two layout arguments and of English/Dutch translations changed. mx check 0 errors before and after, so the only trace is a rewritten unit in git", "cause": "Two independent reorders, each enough to defeat no-op elision because canon renders lists in order. (1) buildLayoutCallArguments always put Main first, while describe prints `placeholder` blocks in stored order — a stored [Header, Main] came back [Main, Header]. (2) CarryTranslations' mergeText kept the rebuilt language first and appended the stored others via sortedLangs, so [nl_NL, en_US] came back [en_US, nl_NL] and en_US,fr_FR,nl_NL,de_DE came back sorted", "file": "mdl/executor/cmd_pages_builder_v3.go (buildLayoutCallArguments), modelsdk/canon/translations.go (mergeText, inLanguageOrder, textLanguageOrder)", "insight": "A carry or rebuild that sorts for determinism is still a reorder: any list the canonical form compares positionally must come back in the STORED order, and the stored document is the only deterministic source of it. Tally the stored orders before assuming one — 129 texts in PedApp/TestApp are Dutch first and multi-language texts are in addition order, never sorted; the fixtures happen to hide it because their default language is usually first. When one symptom has two causes, revert each fix separately: here either revert alone still printed 'Replaced page', which is what proves both are needed. GetPut tests only bite on a Studio Pro-shaped document — create through mxcli, BSON-patch the order Studio Pro stores, then describe → exec", "refs": ["ako/mxcli#1704", "ADR-0008"]} diff --git a/mdl-examples/bug-tests/1704-layout-argument-and-translation-order.mdl b/mdl-examples/bug-tests/1704-layout-argument-and-translation-order.mdl new file mode 100644 index 0000000000..ae273980bf --- /dev/null +++ b/mdl-examples/bug-tests/1704-layout-argument-and-translation-order.mdl @@ -0,0 +1,35 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1704 — describe → exec rewrote JTSBootLogboek.Home_Native although +-- nothing was lost: "only the order of its two placeholders and of some +-- English/Dutch texts changed". +-- +-- Two causes, each enough on its own to make the write go ahead (the canonical +-- form compares lists in order): +-- +-- placeholders describe prints `placeholder X { … }` blocks in stored order; +-- the builder put Main first whatever the blocks said, so a page +-- stored [Header, Main] came back [Main, Header]. +-- translations the rebuild writes the one language MDL carries and the carry +-- appended the stored others in sorted order, so a text stored +-- [nl_NL, en_US] came back [en_US, nl_NL]. Studio Pro's order is +-- not sorted: 129 texts in PedApp and TestApp are stored Dutch +-- first. (Covered by modelsdk/canon/translations_order_1704_test.go; +-- MDL writes one language, so this script cannot set up the +-- stored order.) +-- +-- After the fix the arguments follow the blocks, so this page is stored +-- [Header, Main]; describe → exec of it reports "Unchanged page" and mx check +-- is 0 errors. +-- ============================================================================ + +create module BugArgumentOrder; + +create page BugArgumentOrder.Home_Native (Title: 'Home', Layout: Atlas_Core.NativePhone_Default) { + placeholder "Header" { + dynamictext textHeader (Content: 'Header') + } + placeholder Main { + dynamictext textMain (Content: 'Welcome') + } +}; diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 8712ec244e..e4886e26c6 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -7,6 +7,7 @@ import ( "log" "path/filepath" "regexp" + "slices" "strings" "github.com/mendixlabs/mxcli/mdl/ast" @@ -209,19 +210,27 @@ func (pb *pageBuilder) buildPageV3(s *ast.CreatePageStmtV3) (*pages.Page, error) // Shared by pages and by layouts built on another layout (mendixlabs/mxcli#1411): // both fill a layout's placeholders through the same Forms$LayoutCall, so the // MDL that fills them is the same too. +// +// The arguments follow the blocks' order, which is the order describe prints +// them in — the stored one. Main comes first only when no block names it: +// putting it first regardless turned a page Studio Pro stored as [Header, +// Main] into [Main, Header], so describe → exec rewrote it (ako/mxcli#1704). func (pb *pageBuilder) buildLayoutCallArguments(layoutQN string, bare []*ast.WidgetV3, blocks []*ast.PagePlaceholderV3) ([]*pages.LayoutCallArgument, error) { - order := []string{"Main"} + var order []string byName := map[string][]*ast.WidgetV3{"Main": append([]*ast.WidgetV3(nil), bare...)} for _, ph := range blocks { name := ph.Name if name == "" { name = "Main" } - if _, seen := byName[name]; !seen { + if !slices.Contains(order, name) { order = append(order, name) } byName[name] = append(byName[name], ph.Widgets...) } + if !slices.Contains(order, "Main") { + order = append([]string{"Main"}, order...) + } var out []*pages.LayoutCallArgument for _, name := range order { diff --git a/mdl/executor/layout_argument_order_1704_test.go b/mdl/executor/layout_argument_order_1704_test.go new file mode 100644 index 0000000000..5157dd2b9b --- /dev/null +++ b/mdl/executor/layout_argument_order_1704_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#1704: describe → exec rewrote JTSBootLogboek.Home_Native — "only the +// order of its two placeholders and of some English/Dutch texts changed". +// describe prints a multi-placeholder page's `placeholder X { … }` blocks in +// stored order; the builder put Main first whatever the blocks said, so a page +// stored [Header, Main] came back [Main, Header]. +package executor + +import ( + "context" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" +) + +func argumentOrder(t *testing.T, blocks []string, bare bool) string { + t.Helper() + pb := &pageBuilder{widgetScope: map[string]model.ID{}} + var phs []*ast.PagePlaceholderV3 + for _, b := range blocks { + phs = append(phs, &ast.PagePlaceholderV3{Name: b}) + } + var bareWidgets []*ast.WidgetV3 + if bare { + bareWidgets = []*ast.WidgetV3{} // an empty body still binds Main + } + args, err := pb.buildLayoutCallArguments("L.Layout", bareWidgets, phs) + if err != nil { + t.Fatal(err) + } + var names []string + for _, a := range args { + names = append(names, strings.TrimPrefix(string(a.ParameterID), "L.Layout.")) + } + return strings.Join(names, ",") +} + +func TestLayoutCallArguments_FollowTheBlockOrder(t *testing.T) { + for _, tc := range []struct { + blocks []string + want string + }{ + // The reported shape: Main is not the first block. + {[]string{"Header", "Main"}, "Header,Main"}, + {[]string{"Main", "Header"}, "Main,Header"}, + // Controls: without a Main block, Main is first and always emitted. + {[]string{"Header"}, "Main,Header"}, + {nil, "Main"}, + } { + if got := argumentOrder(t, tc.blocks, false); got != tc.want { + t.Errorf("blocks %v: arguments %s, want %s", tc.blocks, got, tc.want) + } + } +} + +// The reported round trip on a PedApp page whose stored arguments are [Header, +// Main] and whose texts are stored Dutch first, as Studio Pro stores them. +func TestDescribeExec_KeepsArgumentAndLanguageOrder(t *testing.T) { + exec, out := openPedAppFixture(t) + src := `create page MyFirstModule.Home_Native (Title: 'Home', Layout: Atlas_Core.NativePhone_Default) { + placeholder Main { dynamictext textMain (Content: 'Welcome') } + placeholder Header { dynamictext textHeader (Content: 'Header') } +};` + if err := afRun(t, exec, src); err != nil { + t.Fatalf("exec: %v", err) + } + id, raw := pageUnitByName(t, exec, "Home_Native") + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + // Studio Pro's shape: Header's argument first, and a Dutch translation + // stored ahead of the English on every text. + nl := 0 + walkStoredDocs(doc, func(d bson.D) { + for i := range d { + switch { + case d[i].Key == "Arguments" && afGet(d, "$Type") == "Forms$LayoutCall": + args := d[i].Value.(bson.A) // [marker, Main, Header] + d[i].Value = bson.A{args[0], args[2], args[1]} + case d[i].Key == "Items" && afGet(d, "$Type") == "Texts$Text": + items := d[i].Value.(bson.A) + if len(items) != 2 { + continue + } + en := items[1].(bson.D) + nlElem := bson.D{} + for _, e := range en { + switch e.Key { + case "$ID": + id := e.Value.(bson.Binary) + data := append([]byte(nil), id.Data...) + data[len(data)-1] ^= 0xFF // a distinct element id + nlElem = append(nlElem, bson.E{Key: "$ID", Value: bson.Binary{Subtype: id.Subtype, Data: data}}) + case "LanguageCode": + nlElem = append(nlElem, bson.E{Key: e.Key, Value: "nl_NL"}) + case "Text": + nlElem = append(nlElem, bson.E{Key: e.Key, Value: "NL " + e.Value.(string)}) + default: + nlElem = append(nlElem, e) + } + } + d[i].Value = bson.A{items[0], nlElem, en} + nl++ + } + } + }) + if nl == 0 { + t.Fatal("no text to translate on the page") + } + b, err := bson.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := exec.newExecContext(context.Background()).Backend.UpdateRawUnit(string(id), b); err != nil { + t.Fatal(err) + } + + out.Reset() + if err := afRun(t, exec, "describe page MyFirstModule.Home_Native;"); err != nil { + t.Fatal(err) + } + described := out.String() + // Header is a keyword, so describe quotes it. + if h, m := strings.Index(described, `placeholder "Header"`), strings.Index(described, "placeholder Main"); h < 0 || m < 0 || h > m { + t.Fatalf("describe did not print the stored placeholder order:\n%s", described) + } + out.Reset() + if err := afRun(t, exec, described); err != nil { + t.Fatalf("re-exec of the description: %v", err) + } + if !strings.Contains(out.String(), "Unchanged page") { + t.Errorf("re-executing the description was not a no-op:\n%s", out.String()) + } +} From 9b92a1e988aab1561c41c2fb9e4baa9120277606 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:00:50 +0000 Subject: [PATCH 12/40] test: strike six documents the language-order fix made round-trip FeedbackModule.PopupFailure, PopupSuccess, ShareFeedback, ShareFeedback_Logo, MyFirstModule.Home_Web and Administration.ReadMe now pass getput on both PedApp and TestApp: their texts are stored with the languages in an order the translation carry used to sort (#1704). The PedApp list's snippet section is empty now and goes with them. The allowlist may only shrink. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- mdl/roundtrip/allowlist_test.go | 10 +--------- mdl/roundtrip/testapp_allowlist_test.go | 6 ------ 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/mdl/roundtrip/allowlist_test.go b/mdl/roundtrip/allowlist_test.go index 9d06717275..6dbc6b479b 100644 --- a/mdl/roundtrip/allowlist_test.go +++ b/mdl/roundtrip/allowlist_test.go @@ -75,13 +75,5 @@ var knownFailures = map[string]knownFailure{ // AlternativeText and Items). // Pages: #705 item 1 plus #721 C. - "page Administration.Account_Overview": {laws: []law{lawGetPut}, issue: "#705 #721", why: "texts and translations (#705 item 1); widget properties (#721 C)"}, - "page FeedbackModule.PopupFailure": {laws: []law{lawGetPut}, issue: "#705 #721", why: "texts and translations (#705 item 1); widget properties (#721 C)"}, - "page FeedbackModule.PopupSuccess": {laws: []law{lawGetPut}, issue: "#705 #721", why: "texts and translations (#705 item 1); widget properties (#721 C)"}, - "page FeedbackModule.ShareFeedback": {laws: []law{lawGetPut}, issue: "#705 #721", why: "texts and translations (#705 item 1); widget properties (#721 C)"}, - "page FeedbackModule.ShareFeedback_Logo": {laws: []law{lawGetPut}, issue: "#705 #721", why: "texts and translations (#705 item 1); widget properties (#721 C)"}, - "page MyFirstModule.Home_Web": {laws: []law{lawGetPut}, issue: "#705 #721", why: "texts and translations (#705 item 1); widget properties (#721 C)"}, - - // Snippets: #705 item 4 plus #721 D. - "snippet Administration.ReadMe": {laws: []law{lawGetPut}, issue: "#705 #721", why: "snippet Type (#705 item 4); widget content (#721 D)"}, + "page Administration.Account_Overview": {laws: []law{lawGetPut}, issue: "#705 #721", why: "texts and translations (#705 item 1); widget properties (#721 C)"}, } diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index 2f72822fe4..4cebdada44 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -115,11 +115,6 @@ var testAppKnownFailures = map[string]knownFailure{ "enumeration WorkflowCommons.Enum_NotificationArea_RenderAs": {laws: []law{lawGetPut}, issue: "#721", why: "enumeration: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "export mapping Services.OrdersExportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "export mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page Administration.Account_Overview": {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 FeedbackModule.PopupFailure": {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 FeedbackModule.PopupSuccess": {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 FeedbackModule.ShareFeedback": {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 FeedbackModule.ShareFeedback_Logo": {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 MyFirstModule.Home_Web": {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.DefaultWorkflowAdmin": {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.ManageTaskAssignments": {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.MyInitiatedWorkflowView_WithdrawConfirmation": {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"}, @@ -143,7 +138,6 @@ var testAppKnownFailures = map[string]knownFailure{ "rule Rules.Rule1": {laws: []law{lawGetPut}, issue: "#721", why: "rule: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "rule Rules.Rule2": {laws: []law{lawGetPut}, issue: "#721", why: "rule: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "scheduled event WorkflowCommons.SE_WorkflowAuditTrailRecord_CleanUp": {laws: []law{lawGetPut}, issue: "#721", why: "scheduled event: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet Administration.ReadMe": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet WorkflowCommons.Snip_Assignee_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, "snippet WorkflowCommons.Snip_AuditTrailViewer_Toolbar": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet WorkflowCommons.Snip_MyInitiatedWorkflowView_NotificationArea": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, From a9a7997c39557c75190882c5ce043147da763016 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:43:35 +0000 Subject: [PATCH 13/40] test: strike ShareFeedback from the Studio Pro round-trip lossy list FeedbackModule.ShareFeedback's whole-document check now passes: the losses its entry listed were fixed earlier, and the last difference was the language order of its texts, which the translation carry now keeps (#1704). With that fix reverted the check fails again. The test fails in both directions, so the entry goes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- mdl/executor/studiopro_roundtrip_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/mdl/executor/studiopro_roundtrip_test.go b/mdl/executor/studiopro_roundtrip_test.go index e925afe5c9..aa0bccef2d 100644 --- a/mdl/executor/studiopro_roundtrip_test.go +++ b/mdl/executor/studiopro_roundtrip_test.go @@ -60,9 +60,6 @@ import ( var studioProKnownLossy = map[string]string{ // Losses this suite found beyond the ones ako/mxcli#705 reported. They are // the whole-document verdict only; the #705 slices are checked separately. - "page FeedbackModule.ShareFeedback|document": "input widgets: SubmitBehaviour, " + - "DisabledDuringExecution on no-action events, TextTooLongMessage, Autocomplete, " + - "empty OutputMappings, NumberOfPagesToClose are rebuilt from writer constants", "page Administration.Account_Overview|document": "DataGrid2 object rebuilt from the " + "widget template (property order, column texts, filter captions), layout-grid " + "column weights 12 -> -1, tab-container nulls", From 460c782b1b3e9a9c2ecf4189b8540841f90ec5fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:36:18 +0000 Subject: [PATCH 14/40] fix: unescape and re-escape a dynamic-text customDateFormat (#977 item 4) A Java date pattern quotes literal text: `h 'at' mm`. describe printed it unescaped, `customDateFormat: 'h 'at' mm'`, which ends the string at `h `. The write side was wrong too: the format block's string had its outer quotes trimmed and was never unescaped, so 'h ''at'' mm' was stored as `h ''at'' mm`, a literal apostrophe in the pattern. The visitor now reads the value like any other string literal (unquoteStringLit), and describe writes it with mdlQuote, as the date picker's CustomDateFormat already did. Each half alone still fails the round-trip test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- ...dynamictext-custom-date-format-quotes.json | 1 + ...-dynamictext-custom-date-format-quotes.mdl | 26 +++++++ mdl/executor/cmd_pages_describe_output.go | 6 +- mdl/executor/cmd_pages_describe_pluggable.go | 2 +- ...ctext_custom_date_format_quote_977_test.go | 72 +++++++++++++++++++ mdl/executor/dynamictext_format_test.go | 4 +- mdl/visitor/visitor_page_v3.go | 6 ++ 7 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-dynamictext-custom-date-format-quotes.json create mode 100644 mdl-examples/bug-tests/977-4-dynamictext-custom-date-format-quotes.mdl create mode 100644 mdl/executor/dynamictext_custom_date_format_quote_977_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-dynamictext-custom-date-format-quotes.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-dynamictext-custom-date-format-quotes.json new file mode 100644 index 0000000000..f9ddb054a2 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-dynamictext-custom-date-format-quotes.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "A dynamic-text parameter's customDateFormat holding quoted literal text (`h 'at' mm`) did not round-trip: describe printed `customDateFormat: 'h 'at' mm'`, which ends the string at `h `; and writing 'h ''at'' mm' stored `h ''at'' mm` — in a Java date pattern a literal apostrophe, not the quoted word", "cause": "Both directions of one property. buildParamFormatV3 stripped the outer quotes with strings.Trim and never unescaped the literal; formatParamFormatSuffix concatenated the stored pattern between quotes instead of calling mdlQuote. The date picker's own CustomDateFormat (input_formatting.go) already used mdlQuote, which is why only the template-parameter path broke", "file": "mdl/visitor/visitor_page_v3.go (buildParamFormatV3), mdl/executor/cmd_pages_describe_output.go (formatParamFormatSuffix)", "insight": "A report that names only the describe half of a string property is worth testing from the WRITE side first: assert the stored value of a statement that contains the escape, before testing describe. Here the store was already wrong, so fixing describe alone would have printed 'h ''at'' mm' and re-stored a doubled pattern — a green-looking change that still loses the value. Any `strings.Trim(x.GetText(), \"'\\\"\")` in the visitor is this bug waiting for an apostrophe; unquoteStringLit is the version-aware reader. Revert each half separately: each alone fails the round-trip test", "refs": ["ako/mxcli#977"]} diff --git a/mdl-examples/bug-tests/977-4-dynamictext-custom-date-format-quotes.mdl b/mdl-examples/bug-tests/977-4-dynamictext-custom-date-format-quotes.mdl new file mode 100644 index 0000000000..037f982905 --- /dev/null +++ b/mdl-examples/bug-tests/977-4-dynamictext-custom-date-format-quotes.mdl @@ -0,0 +1,26 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#977 item 4 — "Describe does not escape `'` in a dynamic-text +-- `customDateFormat`, so a pattern with quoted literal text (`'at'`) does not +-- round-trip." +-- +-- A Java date pattern quotes literal text: `h 'at' mm`. Two halves were wrong: +-- write the format block's string kept its doubled quotes, so +-- 'h ''at'' mm' was stored as `h ''at'' mm` — in a date pattern a +-- literal apostrophe, not the quoted word "at"; +-- describe printed the stored pattern unescaped, `customDateFormat: +-- 'h 'at' mm'`, which ends the string at `h `. +-- +-- After the fix the pattern is stored as `h 'at' mm`, describe prints it as +-- below, and describe → exec reports "Unchanged page". +-- ============================================================================ + +create module BugCustomDateQuotes; + +create persistent entity BugCustomDateQuotes.Shift (Starts: DateTime); + +create page BugCustomDateQuotes.Shift_View (Title: 'Shift', Layout: Atlas_Core.Atlas_Default, Params: ($Shift: BugCustomDateQuotes.Shift)) { + dataview dvShift (DataSource: $Shift) { + dynamictext textStarts (Content: '{1}', ContentParams: ({1} = Starts format (dateFormat: Custom, customDateFormat: 'h ''at'' mm'))) + } +}; diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index c3a628825d..f9538237cd 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -2000,7 +2000,7 @@ func extractClientTemplateParameters(ctx *ExecContext, w map[string]any, fieldNa // DecimalPrecision=2, EnumFormat=Text, GroupDigits=false, CustomDateFormat=""), // so an unformatted parameter round-trips as before (empty string). Mirrors the // writer defaults in formattingInfoFromParamFormat / formattingInfoToGen. -func formatParamFormatSuffix(pMap map[string]any) string { +func formatParamFormatSuffix(ctx *ExecContext, pMap map[string]any) string { fi, ok := pMap["FormattingInfo"].(map[string]any) if !ok || fi == nil { return "" @@ -2016,7 +2016,9 @@ func formatParamFormatSuffix(pMap map[string]any) string { parts = append(parts, "dateFormat: "+df) } if cdf := extractString(fi["CustomDateFormat"]); cdf != "" { - parts = append(parts, "customDateFormat: '"+cdf+"'") + // Quoted like every other string: a Java date pattern quotes literal + // text with apostrophes (ako/mxcli#977). + parts = append(parts, "customDateFormat: "+mdlQuote(ctx, cdf)) } if ef := extractString(fi["EnumFormat"]); ef != "" && ef != "Text" { parts = append(parts, "enumFormat: "+ef) diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index bb2068191c..91eed9d63d 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -641,7 +641,7 @@ func extractTextTemplateParameters(ctx *ExecContext, textTemplate map[string]any // One suffix per emitted param, in the same order, zipped in after the // value string is chosen so a DataGrid2 dynamic-text column's per-parameter // formatting round-trips through DESCRIBE (ledger #77). - suffixes = append(suffixes, formatParamFormatSuffix(pMap)) + suffixes = append(suffixes, formatParamFormatSuffix(ctx, 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 diff --git a/mdl/executor/dynamictext_custom_date_format_quote_977_test.go b/mdl/executor/dynamictext_custom_date_format_quote_977_test.go new file mode 100644 index 0000000000..22f1c6a9ef --- /dev/null +++ b/mdl/executor/dynamictext_custom_date_format_quote_977_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#977 item 4: "Describe does not escape `'` in a dynamic-text +// `customDateFormat`, so a pattern with quoted literal text (`'at'`) does not +// round-trip." A Java date pattern quotes literal text — `h 'at' mm` — and +// describe printed it inside a single-quoted literal as it was: +// `customDateFormat: 'h 'at' mm'`, which ends the string at `h `. +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// storedTemplateCustomDateFormats lists every template parameter's stored +// CustomDateFormat on the page. +func storedTemplateCustomDateFormats(t *testing.T, exec *Executor, page string) []string { + t.Helper() + _, raw := pageUnitByName(t, exec, page) + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + var out []string + walkStoredDocs(doc, func(d bson.D) { + if afGet(d, "$Type") != "Forms$ClientTemplateParameter" { + return + } + fi, _ := afGet(d, "FormattingInfo").(bson.D) + cdf, _ := afGet(fi, "CustomDateFormat").(string) + out = append(out, cdf) + }) + return out +} + +func TestDynamicTextCustomDateFormatWithQuotesRoundTrips(t *testing.T) { + exec, out := openPedAppFixture(t) + src := `create persistent entity MyFirstModule.Shift (Starts: DateTime); +create page MyFirstModule.Shift_View (Title: 'Shift', Layout: Atlas_Core.Atlas_Default, Params: ($Shift: MyFirstModule.Shift)) { + dataview dvShift (DataSource: $Shift) { + dynamictext textStarts (Content: '{1}', ContentParams: ({1} = Starts format (dateFormat: Custom, customDateFormat: 'h ''at'' mm'))) + } +};` + if err := afRun(t, exec, src); err != nil { + t.Fatalf("exec: %v", err) + } + const want = "h 'at' mm" + if got := storedTemplateCustomDateFormats(t, exec, "Shift_View"); len(got) != 1 || got[0] != want { + t.Fatalf("stored CustomDateFormat = %q, want [%q]", got, want) + } + + out.Reset() + if err := afRun(t, exec, "describe page MyFirstModule.Shift_View;"); err != nil { + t.Fatal(err) + } + described := out.String() + if !strings.Contains(described, `customDateFormat: 'h ''at'' mm'`) { + t.Fatalf("describe did not escape the quotes in the pattern:\n%s", described) + } + out.Reset() + if err := afRun(t, exec, described); err != nil { + t.Fatalf("re-exec of the description: %v", err) + } + if !strings.Contains(out.String(), "Unchanged page") { + t.Errorf("re-executing the description was not a no-op:\n%s", out.String()) + } + if got := storedTemplateCustomDateFormats(t, exec, "Shift_View"); len(got) != 1 || got[0] != want { + t.Errorf("after describe → exec CustomDateFormat = %q, want [%q]", got, want) + } +} diff --git a/mdl/executor/dynamictext_format_test.go b/mdl/executor/dynamictext_format_test.go index c64fb09bfe..88079c4c93 100644 --- a/mdl/executor/dynamictext_format_test.go +++ b/mdl/executor/dynamictext_format_test.go @@ -111,13 +111,13 @@ func TestFormatParamFormatSuffix(t *testing.T) { def := map[string]any{"FormattingInfo": map[string]any{ "DecimalPrecision": int64(2), "GroupDigits": false, "DateFormat": "Date", "EnumFormat": "Text", "CustomDateFormat": "", }} - if s := formatParamFormatSuffix(def); s != "" { + if s := formatParamFormatSuffix(nil, def); s != "" { t.Errorf("default FormattingInfo must yield empty suffix, got %q", s) } non := map[string]any{"FormattingInfo": map[string]any{ "DecimalPrecision": int64(4), "GroupDigits": true, "DateFormat": "DateTime", "EnumFormat": "Text", "CustomDateFormat": "", }} - s := formatParamFormatSuffix(non) + s := formatParamFormatSuffix(nil, non) for _, want := range []string{" format (", "decimalPrecision: 4", "groupDigits: true", "dateFormat: DateTime"} { if !strings.Contains(s, want) { t.Errorf("suffix %q missing %q", s, want) diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index d10ccc5cbd..3128765009 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -1484,6 +1484,12 @@ func buildParamFormatV3(ctx parser.IParamFormatV3Context) *ast.ParamFormatV3 { continue } val := strings.Trim(vc.GetText(), "'\"") + // A string value is unescaped like any other literal: a date pattern + // quotes literal text (`h ''at'' mm` is `h 'at' mm`), and stripping the + // outer quotes alone stored the doubled ones (ako/mxcli#977). + if pv, ok := vc.(*parser.PropertyValueV3Context); ok && pv.STRING_LITERAL() != nil { + val = unquoteStringLit(pv.STRING_LITERAL()) + } f.Props = append(f.Props, ast.ParamFormatProp{ Key: strings.ToLower(id.GetText()), Value: val, From 7dfb709214a662056bf8b32c7cb0cc17e22bf44e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:42:40 +0000 Subject: [PATCH 15/40] fix: refuse a SET that leaves a date picker Custom with no pattern (#977 item 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `alter page … set (CustomDateFormat: '') on dp` on a picker already Custom was written, and mxbuild then reports CE0493 "Date format is custom but no format string is specified" (measured on 11.13.0). The only guard sat in the DateFormat setter and relied on name-order application, so clearing the pattern alone never reached it. The rule relates two properties, so it is now checked on the widget the whole SET leaves behind, for statements that name DateFormat or CustomDateFormat. Clearing the pattern and switching to Date in one statement stays valid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- ...26-alter-set-empty-custom-date-format.json | 1 + ...977-5-custom-date-format-empty-refused.mdl | 34 +++++++++ mdl/backend/pagemutator/mutator.go | 23 ++++++ mdl/executor/cmd_alter_page.go | 29 ++++++++ .../custom_date_format_empty_977_test.go | 74 +++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-alter-set-empty-custom-date-format.json create mode 100644 mdl-examples/bug-tests/977-5-custom-date-format-empty-refused.mdl create mode 100644 mdl/executor/custom_date_format_empty_977_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-alter-set-empty-custom-date-format.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-alter-set-empty-custom-date-format.json new file mode 100644 index 0000000000..ce8829b90e --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-alter-set-empty-custom-date-format.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "`alter page … set (CustomDateFormat: '') on dp` on a date picker already DateFormat Custom reported Altered page and wrote Custom with no pattern; mx check: CE0493 \"Date format is custom but no format string is specified.\" (measured on 11.13.0 with the pre-fix binary)", "cause": "The only guard was inside the DateFormat setter (refuse Custom when the stored pattern is blank), relying on properties being applied in name order. A statement that only cleared the pattern never ran it, and a per-property check on CustomDateFormat would wrongly refuse `set (CustomDateFormat: '', DateFormat: Date)`, whose result is valid", "file": "mdl/executor/cmd_alter_page.go (checkDateFormatAfterSet), mdl/backend/pagemutator/mutator.go (CheckDateFormat)", "insight": "A rule that relates two properties is a rule about the widget the statement leaves behind, so check it after the whole SET, not inside either setter — the per-property guard is order-dependent in one direction and over-strict in the other. applySetPropertyMutator already had the post-statement seam (resetHiddenColumnProperties); an optional mutator capability keeps the MCP backend untouched. Scope the check to statements that name one of the two keys, so an unrelated edit to a widget already in the bad state is not blocked. The control that matters is a valid two-property statement in the same test", "refs": ["ako/mxcli#977"], "ce": ["CE0493"]} diff --git a/mdl-examples/bug-tests/977-5-custom-date-format-empty-refused.mdl b/mdl-examples/bug-tests/977-5-custom-date-format-empty-refused.mdl new file mode 100644 index 0000000000..b8a79488ee --- /dev/null +++ b/mdl-examples/bug-tests/977-5-custom-date-format-empty-refused.mdl @@ -0,0 +1,34 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#977 item 5 — "`alter set (CustomDateFormat: '')` is not refused on +-- a picker already set to Custom, because properties are applied one at a +-- time. It produces CE0493" ("Date format is custom but no format string is +-- specified"). +-- +-- The rule is about the widget a statement leaves behind, so it is checked +-- once the whole SET is applied. On the picker below, +-- +-- set (CustomDateFormat: '') on dpStarts +-- +-- is now refused, naming CE0493, and nothing is written. Clearing the pattern +-- and switching to another DateFormat in the same statement is a valid result +-- and goes through, as the last statement here does. +-- +-- Measured on 11.13.0: the pre-fix binary applies the refused statement and +-- mx check reports CE0493; the fixed binary refuses it and mx check is 0 +-- errors. +-- ============================================================================ + +create module BugCustomDateEmpty; + +create persistent entity BugCustomDateEmpty.Shift (Starts: DateTime); + +create page BugCustomDateEmpty.Shift_Edit (Title: 'Shift', Layout: Atlas_Core.Atlas_Default, Params: ($Shift: BugCustomDateEmpty.Shift)) { + dataview dvShift (DataSource: $Shift) { + datepicker dpStarts (Attribute: Starts, DateFormat: Custom, CustomDateFormat: 'dd-MM-yyyy') + } +}; + +alter page BugCustomDateEmpty.Shift_Edit { + set (CustomDateFormat: '', DateFormat: Date) on dpStarts +}; diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index ddf1ce657c..8d296f460d 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -3474,6 +3474,29 @@ func setLegacyImageRefMut(widget bson.D, propName string, value any) (bool, erro return true, nil } +// CheckDateFormat refuses a widget a SET left with DateFormat Custom and no +// pattern — mxbuild CE0493, "Date format is custom but no format string is +// specified". It is judged on the statement's result, not per property: +// properties are applied one at a time, so clearing the pattern of a picker +// already Custom went through, while clearing it and switching to Date in the +// same statement is valid (ako/mxcli#977). +func (m *Mutator) CheckDateFormat(widgetRef string) error { + result := m.widgetFinder(m.rawData, widgetRef) + if result == nil || len(result.colPropKeys) > 0 { + return nil + } + fi := bsonnav.DGetDoc(result.widget, "FormattingInfo") + if fi == nil { + return nil + } + if bsonnav.DGetString(fi, "DateFormat") == "Custom" && strings.TrimSpace(bsonnav.DGetString(fi, "CustomDateFormat")) == "" { + return fmt.Errorf("the %s would be left with `DateFormat: Custom` and no pattern (mxbuild CE0493): "+ + "set a CustomDateFormat such as 'dd-MM-yyyy HH:mm', or another DateFormat in the same statement", + widgetTypeLabel(bsonnav.DGetString(result.widget, "$Type"))) + } + return nil +} + // setWidgetFormattingMut sets one field of an input widget's // Forms$FormattingInfo — a date picker's DateFormat / CustomDateFormat, a text // box's DecimalPrecision / GroupDigits (ako/mxcli#968). Which widget carries diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index 35c41fbfa8..9d213ef8f9 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -350,6 +350,9 @@ func applySetPropertyMutator(ctx *ExecContext, mutator backend.PageMutator, op * if err := applySetProperties(ctx, mutator, op, moduleName, moduleID); err != nil { return err } + if err := checkDateFormatAfterSet(mutator, op); err != nil { + return err + } if op.Target.IsColumn() || op.Target.Item != nil { names := make([]string, 0, len(op.Properties)) for k := range op.Properties { @@ -364,6 +367,32 @@ func applySetPropertyMutator(ctx *ExecContext, mutator backend.PageMutator, op * return nil } +// dateFormatChecker is the page mutator capability behind +// checkDateFormatAfterSet; engines without it are left as they were. +type dateFormatChecker interface { + CheckDateFormat(widgetRef string) error +} + +// checkDateFormatAfterSet refuses a SET that leaves a picker Custom with no +// pattern (CE0493). Only a statement that sets DateFormat or CustomDateFormat +// is checked, so an unrelated edit to a widget already in that state still +// goes through (ako/mxcli#977). +func checkDateFormatAfterSet(mutator backend.PageMutator, op *ast.SetPropertyOp) error { + checker, ok := mutator.(dateFormatChecker) + if !ok || op.Target.IsColumn() || op.Target.Item != nil || op.Target.Widget == "" { + return nil + } + for name := range op.Properties { + if strings.EqualFold(name, "DateFormat") || strings.EqualFold(name, "CustomDateFormat") { + if err := checker.CheckDateFormat(op.Target.Widget); err != nil { + return mdlerrors.NewValidation("set on " + op.Target.Name() + ": " + err.Error()) + } + return nil + } + } + return nil +} + // itemHiddenPropertyResetter is the page mutator capability behind // resetHiddenItemProperties; engines without it are left as they were. type itemHiddenPropertyResetter interface { diff --git a/mdl/executor/custom_date_format_empty_977_test.go b/mdl/executor/custom_date_format_empty_977_test.go new file mode 100644 index 0000000000..37eab807c9 --- /dev/null +++ b/mdl/executor/custom_date_format_empty_977_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#977 item 5: an `alter set` of an empty CustomDateFormat "is not +// refused on a picker already set to Custom, because properties are applied +// one at a time. It produces CE0493" ("Date format is custom but no format +// string is specified"). The DateFormat setter refused `Custom` without a pattern, but +// clearing the pattern of a picker already Custom went through. +// +// The rule is about the widget a statement leaves behind, so it is checked +// once the whole SET is applied: clearing the pattern and leaving Custom is +// refused, while clearing it and switching to Date in the same statement is +// a valid result and is not. +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +func storedPickerFormat(t *testing.T, exec *Executor, page, widget string) (dateFormat, custom string) { + t.Helper() + _, raw := pageUnitByName(t, exec, page) + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + walkStoredDocs(doc, func(d bson.D) { + if afGet(d, "Name") != widget { + return + } + fi, _ := afGet(d, "FormattingInfo").(bson.D) + dateFormat, _ = afGet(fi, "DateFormat").(string) + custom, _ = afGet(fi, "CustomDateFormat").(string) + }) + return dateFormat, custom +} + +func TestAlterSetEmptyCustomDateFormatOnCustomPickerIsRefused(t *testing.T) { + exec, _ := openPedAppFixture(t) + if err := afRun(t, exec, `create persistent entity MyFirstModule.Shift (Starts: DateTime); +create page MyFirstModule.Shift_Edit (Title: 'Shift', Layout: Atlas_Core.Atlas_Default, Params: ($Shift: MyFirstModule.Shift)) { + dataview dvShift (DataSource: $Shift) { + datepicker dpStarts (Attribute: Starts, DateFormat: Custom, CustomDateFormat: 'dd-MM-yyyy') + } +};`); err != nil { + t.Fatalf("exec: %v", err) + } + alter := func(set string) error { + return afRun(t, exec, "alter page MyFirstModule.Shift_Edit {\n set ("+set+") on dpStarts\n};") + } + + for _, set := range []string{"CustomDateFormat: ''", "CustomDateFormat: ' '"} { + err := alter(set) + if err == nil || !strings.Contains(err.Error(), "CE0493") { + t.Errorf("set (%s) on a Custom picker: err = %v, want a refusal naming CE0493", set, err) + } + if df, cdf := storedPickerFormat(t, exec, "Shift_Edit", "dpStarts"); df != "Custom" || cdf != "dd-MM-yyyy" { + t.Fatalf("the refused statement changed the picker to %s / %q", df, cdf) + } + } + + // Controls: a statement whose result is valid goes through. + if err := alter("CustomDateFormat: 'yyyy-MM-dd'"); err != nil { + t.Errorf("a new pattern was refused: %v", err) + } + if err := alter("CustomDateFormat: '', DateFormat: Date"); err != nil { + t.Errorf("clearing the pattern while leaving Custom was refused: %v", err) + } + if df, cdf := storedPickerFormat(t, exec, "Shift_Edit", "dpStarts"); df != "Date" || cdf != "" { + t.Errorf("after the valid statements the picker is %s / %q, want Date / \"\"", df, cdf) + } +} From fc6e3f76719e5bc8c0cb8307de1f076ebc2c44b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:50:18 +0000 Subject: [PATCH 16/40] fix: round-trip a text area's NumberOfLines and Autocomplete (#977 item 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describe printed neither, nothing parsed either, and the writer wrote 5 lines and no Autocomplete key whatever was stored. In PedApp and TestApp a text area stores 5 lines on 52 of 58, 2 on 4 and 0 on 2, and Autocomplete true on all 58; a Studio Pro text area of 2 or 0 lines, or with Autocomplete off, came back from describe → exec as 5 and on. mx check is 0 errors either way. `textarea (NumberOfLines: n, Autocomplete: true|false)` is now written, described when not the default (5, true), and settable with `alter page … set`. The model's `Rows int`, which nothing set and which could not hold a stored 0, becomes `NumberOfLines *int`. Autocomplete is written on every text area, as Studio Pro does (introduced in 8.10.0, like the text box's). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- ...6-textarea-lines-autocomplete-dropped.json | 1 + cmd/mxcli/syntax/features_page.go | 2 + docs/01-project/MDL_QUICK_REFERENCE.md | 1 + .../977-2-textarea-lines-autocomplete.mdl | 30 +++++ ...et_textarea_lines_autocomplete_977_test.go | 51 ++++++++ mdl/backend/modelsdk/widget_write.go | 10 +- mdl/backend/pagemutator/mutator.go | 33 +++++ mdl/executor/cmd_pages_builder_v3_widgets.go | 33 +++++ mdl/executor/cmd_pages_describe.go | 4 + mdl/executor/cmd_pages_describe_output.go | 8 ++ mdl/executor/cmd_pages_describe_parse.go | 7 + .../textarea_lines_autocomplete_977_test.go | 122 ++++++++++++++++++ mdl/executor/validate_widgets.go | 2 + sdk/pages/pages_widgets_input.go | 8 +- 14 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-textarea-lines-autocomplete-dropped.json create mode 100644 mdl-examples/bug-tests/977-2-textarea-lines-autocomplete.mdl create mode 100644 mdl/backend/modelsdk/widget_textarea_lines_autocomplete_977_test.go create mode 100644 mdl/executor/textarea_lines_autocomplete_977_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-textarea-lines-autocomplete-dropped.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-textarea-lines-autocomplete-dropped.json new file mode 100644 index 0000000000..904b817929 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-textarea-lines-autocomplete-dropped.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "A Studio Pro text area's NumberOfLines (2 or 0) and Autocomplete false came back from describe → exec as 5 and true: describe printed neither, nothing parsed them, and the writer wrote 5 lines and no Autocomplete key. mx check 0 errors before and after (measured on 11.13.0 with the pre-fix binary)", "cause": "sdk/pages.TextArea had `Rows int`, which nothing set, and the writer mapped 0 to 5 — so even a stored 0 could not be expressed; Forms$TextArea.Autocomplete was never written (Studio Pro stores it on all 58 text areas in the fixtures); the describe textarea case read neither key and the mutator had no case for them", "file": "sdk/pages/pages_widgets_input.go, mdl/backend/modelsdk/widget_write.go, mdl/executor/cmd_pages_builder_v3_widgets.go, cmd_pages_describe_parse.go, cmd_pages_describe_output.go, mdl/backend/pagemutator/mutator.go", "insight": "An int field that means 'default when zero' cannot round-trip a stored zero; tally the stored values before choosing the field type (5 on 52, 2 on 4, 0 on 2 here) and use a pointer when zero occurs. Tally key PRESENCE as well as values: the writer omitted Autocomplete entirely, which only a check for in the stored-vs-written comparison shows. Before writing a key the writer never wrote, look up its floor in modelsdk/gen/pages/version.go (TextArea.autocomplete: 8.10.0, the same as the text box's, which the writer already writes)", "refs": ["ako/mxcli#977"]} diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index f5bb1e5192..6ca08ea0b6 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -153,6 +153,8 @@ LIST IMPACT OF htmlelement; "-- Read-only style of TEXTBOX, TEXTAREA, DATEPICKER, CHECKBOX, RADIOBUTTONS (and DATAVIEW):\n" + "-- Inherit (default) | Control | Text — how the widget renders when it is read-only.\n" + "TEXTBOX name (Attribute: Name, Editable: Never, ReadOnlyStyle: Text)\n" + + "-- A text area's height and autocomplete: NumberOfLines (default 5, 0 allowed), Autocomplete (default true).\n" + + "TEXTAREA name (Attribute: Notes, NumberOfLines: 2, Autocomplete: false)\n" + "-- A date picker's DateFormat is its mode: Date (default) | Time | DateTime | Custom.\n" + "DATEPICKER name (Attribute: Start, DateFormat: DateTime)\n" + "-- File upload/download: inside a data view on a System.FileDocument specialization (CE6810 otherwise).\n" + diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index e05c78b713..cd4b481d9d 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1496,6 +1496,7 @@ MDL uses explicit property declarations for pages: | Pop-up dimensions | `PopupWidth: n, PopupHeight: n, PopupResizable: bool` | `(Layout: Atlas_Core.PopupLayout, PopupWidth: 800, PopupHeight: 480, PopupResizable: true)` — case-sensitive; default 600×600 | | Page design properties | `DesignProperties: ( 'Key': 'Value', … )` | `(Layout: Atlas_Core.NativePhone_Default, DesignProperties: ('Spacing top': 'Inner large'))` — the page's own, from the theme's Document and Page groups | | Pop-up close button | `PopupCloseAction: ` | `(Layout: Atlas_Core.PopupLayout, PopupCloseAction: cancelButton1)` — names a widget on this page. Not carried from the stored document on a rewrite: the statement rebuilds the widget tree, so a carried name could dangle | +| Text area lines / autocomplete | `NumberOfLines: n, Autocomplete: true\|false` | `textarea t (Attribute: Notes, NumberOfLines: 2, Autocomplete: false)` — defaults 5 and true; `0` lines is a value Studio Pro stores. Also settable with `alter page … set` | | Input read-only style | `ReadOnlyStyle: Inherit\|Control\|Text` | `textbox t (Attribute: Name, ReadOnlyStyle: Text)` — on a text box, text area, date picker, check box and radio buttons. **Inherit** is the default; also `alter page … set (ReadOnlyStyle: Text) on t` | | DataView read-only style | `ReadOnlyStyle: Inherit\|Control\|Text` | `dataview dv (datasource: $O, ReadOnlyStyle: Text)` — a DataView's own, distinct from a checkbox's. **Control** is Studio Pro's default here, not Inherit | | Page CSS class / style | `Class: 'css-class', Style: 'css: rule'` | `(Title: 'Home', Class: 'container-fluid bg-light', Style: 'min-height: 100vh')` — the page's Appearance | diff --git a/mdl-examples/bug-tests/977-2-textarea-lines-autocomplete.mdl b/mdl-examples/bug-tests/977-2-textarea-lines-autocomplete.mdl new file mode 100644 index 0000000000..ea27b646e5 --- /dev/null +++ b/mdl-examples/bug-tests/977-2-textarea-lines-autocomplete.mdl @@ -0,0 +1,30 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#977 item 2 — "The text area's `NumberOfLines` and `Autocomplete` +-- are not round-tripped." +-- +-- describe printed neither, nothing parsed either, and the writer wrote 5 lines +-- and no Autocomplete whatever was stored. In PedApp and TestApp a text area +-- stores 5 lines on 52 of 58, 2 on 4 and 0 on 2, and Autocomplete true on all +-- 58; a Studio Pro text area of 2 or 0 lines, or with Autocomplete off, came +-- back from describe → exec with 5 and on. +-- +-- After the fix both are written, described when not the default (5, true), +-- and settable with `alter page … set`; describe → exec reports "Unchanged +-- page". +-- ============================================================================ + +create module BugTextAreaLines; + +create persistent entity BugTextAreaLines.Note (Body: String(unlimited), Summary: String(unlimited)); + +create page BugTextAreaLines.Note_Edit (Title: 'Note', Layout: Atlas_Core.Atlas_Default, Params: ($Note: BugTextAreaLines.Note)) { + dataview dvNote (DataSource: $Note) { + textarea textBody (Label: 'Body', Attribute: Body, NumberOfLines: 2, Autocomplete: false) + textarea textSummary (Label: 'Summary', Attribute: Summary) + } +}; + +alter page BugTextAreaLines.Note_Edit { + set (NumberOfLines: 0) on textSummary +}; diff --git a/mdl/backend/modelsdk/widget_textarea_lines_autocomplete_977_test.go b/mdl/backend/modelsdk/widget_textarea_lines_autocomplete_977_test.go new file mode 100644 index 0000000000..44d3982d56 --- /dev/null +++ b/mdl/backend/modelsdk/widget_textarea_lines_autocomplete_977_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#977 item 2: "The text area's `NumberOfLines` and `Autocomplete` +// are not round-tripped." The writer turned an unset row count — and a stored +// 0 — into 5, and wrote no Autocomplete at all. +// +// Measured in PedApp and TestApp: NumberOfLines 5 on 52 of 58 text areas, 2 on +// 4 and 0 on 2; Autocomplete true on all 58. +func TestTextAreaNumberOfLinesAndAutocomplete_Written(t *testing.T) { + n := func(v int) *int { return &v } + b := func(v bool) *bool { return &v } + for _, tc := range []struct { + name string + lines *int + autocomplete *bool + wantLines int64 + wantAuto bool + }{ + {"unset keeps the defaults", nil, nil, 5, true}, + {"two lines", n(2), nil, 2, true}, + {"zero lines, as Studio Pro stores it", n(0), nil, 0, true}, + {"autocomplete off", nil, b(false), 5, false}, + } { + t.Run(tc.name, func(t *testing.T) { + ta := &pages.TextArea{NumberOfLines: tc.lines, Autocomplete: tc.autocomplete} + ta.Name = "textArea1" + doc := encodeWidget(t, ta) + var lines int64 + switch v := docGet(doc, "NumberOfLines").(type) { + case int32: + lines = int64(v) + case int64: + lines = v + } + if lines != tc.wantLines { + t.Errorf("NumberOfLines = %v, want %d", docGet(doc, "NumberOfLines"), tc.wantLines) + } + if got := docGet(doc, "Autocomplete"); got != tc.wantAuto { + t.Errorf("Autocomplete = %v, want %v", got, tc.wantAuto) + } + }) + } +} diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index bd6f216d3c..4120e652f4 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -681,11 +681,15 @@ func widgetToGen(w pages.Widget) (element.Element, error) { g.SetLabelTemplate(textAsClientTemplate(textFromString(x.Label))) } g.SetMaxLengthCode(maxLengthCodeFor(x.SourceVariable)) - lines := int32(x.Rows) - if lines == 0 { - lines = 5 + // Unset writes Studio Pro's defaults: 5 lines and Autocomplete on, as on + // all 58 text areas in PedApp and TestApp. Autocomplete was not written + // at all, and a stored 0 lines came back as 5 (ako/mxcli#977). + lines := int32(5) + if x.NumberOfLines != nil { + lines = int32(*x.NumberOfLines) } g.SetNumberOfLines(lines) + g.SetAutocomplete(x.Autocomplete == nil || *x.Autocomplete) onChangeTA, err := clientActionToGen(x.OnChangeAction) if err != nil { return nil, err diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 8d296f460d..7ab41a29cf 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -3321,6 +3321,12 @@ func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { return setDynamicTextNativeTextStyleMut(widget, value) } return setPluggableWidgetPropertyMut(widget, propName, value) + case "numberoflines", "autocomplete": + // A text area's (ako/mxcli#977). Any other widget keeps the pluggable path. + if typ := bsonnav.DGetString(widget, "$Type"); typ == "Forms$TextArea" || typ == "Pages$TextArea" { + return setTextAreaPropertyMut(widget, propName, value) + } + return setPluggableWidgetPropertyMut(widget, propName, value) case "readonlystyle": // Stored on the built-in input widgets and the data view; a pluggable // widget with its own "readOnlyStyle" key keeps that path (ako/mxcli#977). @@ -3404,6 +3410,33 @@ func setDynamicTextNativeTextStyleMut(widget bson.D, value any) error { return nil } +// setTextAreaPropertyMut writes a text area's NumberOfLines or Autocomplete in +// place. NumberOfLines keeps the stored integer width: Studio Pro writes an +// int64, mxcli's codec an int32, and swapping one for the other is a change of +// its own. +func setTextAreaPropertyMut(widget bson.D, propName string, value any) error { + if strings.EqualFold(propName, "NumberOfLines") { + n, ok := formattingInt(value) + if !ok || n < 0 { + return fmt.Errorf("NumberOfLines is a whole number of 0 or more, not %v", value) + } + if _, isInt32 := bsonnav.DGet(widget, "NumberOfLines").(int32); isInt32 { + bsonnav.DSet(widget, "NumberOfLines", int32(n)) + } else { + bsonnav.DSet(widget, "NumberOfLines", int64(n)) + } + return nil + } + b, ok := editableBool(value) + if !ok { + return fmt.Errorf("Autocomplete is true or false, not %v", value) + } + if !bsonnav.DSet(widget, "Autocomplete", b) { + return fmt.Errorf("text area %q stores no Autocomplete property to set", bsonnav.DGetString(widget, "Name")) + } + return nil +} + // setReadOnlyStyleMut writes a widget's ReadOnlyStyle in place, canonically // spelled; an unknown member is refused before the document is touched. func setReadOnlyStyleMut(widget bson.D, value any) error { diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index 4fd0cf1ca4..48b567a90e 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -618,6 +618,23 @@ func (pb *pageBuilder) buildTextAreaV3(w *ast.WidgetV3) (*pages.TextArea, error) } } + // NumberOfLines and Autocomplete; unset keeps Studio Pro's 5 lines and + // Autocomplete on (ako/mxcli#977). + if raw, ok := lookupPropCI(w, "NumberOfLines"); ok { + n, ok := nonNegativeInt(raw) + if !ok { + return nil, mdlerrors.NewValidationf("textarea %q: NumberOfLines is a whole number of 0 or more, not %v", w.Name, raw) + } + ta.NumberOfLines = &n + } + if raw, ok := lookupPropCI(w, "Autocomplete"); ok { + v, err := propBool(raw) + if err != nil { + return nil, mdlerrors.NewBackend("textarea Autocomplete", err) + } + ta.Autocomplete = &v + } + // ReadOnlyStyle, as on a checkbox; unset keeps Inherit (ako/mxcli#977). taStyle, err := readOnlyStyleValue(w.GetStringProp("ReadOnlyStyle"), "textarea", w.Name) if err != nil { @@ -1817,6 +1834,22 @@ func lookupPropCI(w *ast.WidgetV3, key string) (any, bool) { return nil, false } +// nonNegativeInt reads a whole-number property value of 0 or more. +func nonNegativeInt(v any) (int, bool) { + switch n := v.(type) { + case int: + return n, n >= 0 + case int64: + return int(n), n >= 0 + case float64: + return int(n), n >= 0 && n == float64(int(n)) + case string: + i, err := strconv.Atoi(strings.TrimSpace(n)) + return i, err == nil && i >= 0 + } + return 0, false +} + // propBool coerces a widget property value to a bool. GetBoolProp cannot be used // here: it is case-SENSITIVE (unlike GetStringProp) and accepts only a real bool, so // `showFooter: true` silently read as false — the property was found and its value diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 45f25b9452..9a26300bcd 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -876,6 +876,10 @@ type rawWidget struct { // IsPassword is Forms$TextBox.IsPasswordBox. Security-relevant: a text box // that round-trips without it renders the value in plaintext (ako/mxcli#550). IsPassword bool + // NumberOfLines and Autocomplete are a text area's; nil when not stored + // (ako/mxcli#977). + NumberOfLines *int64 + Autocomplete *bool // ValidationExpression / ValidationMessage are the two fields of // Forms$WidgetValidation, the per-widget validation Studio Pro stores on // input widgets. diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index f9538237cd..fcb311651d 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -700,6 +700,14 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { props = append(props, actionProp("OnChange", w.OnChange)) } props = describeSubmitBehaviour(props, w) + // Printed when not Studio Pro's default — 5 lines, Autocomplete on — so + // a text area authored without them describes as it did (ako/mxcli#977). + if w.NumberOfLines != nil && *w.NumberOfLines != 5 { + props = append(props, fmt.Sprintf("NumberOfLines: %d", *w.NumberOfLines)) + } + if w.Autocomplete != nil && !*w.Autocomplete { + props = append(props, "Autocomplete: false") + } props = appendAppearanceProps(ctx, props, w) formatWidgetProps(ctx.Output, prefix, header, props, "\n") diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 14a9bccb5f..e99537f8da 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -428,6 +428,13 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.ReadOnlyStyle = extractReadOnlyStyle(ctx, w) // ako/mxcli#977 widget.SubmitBehaviour, widget.SubmitOnInputDelay = extractSubmitBehaviour(w) widget.OnChange = extractOnChangeAction(ctx, w) + if v, ok := w["NumberOfLines"]; ok { + n := int64(extractInt(v)) + widget.NumberOfLines = &n + } + if b, ok := w["Autocomplete"].(bool); ok { + widget.Autocomplete = &b + } return []rawWidget{widget} case "Forms$FileManager", "Pages$FileManager": diff --git a/mdl/executor/textarea_lines_autocomplete_977_test.go b/mdl/executor/textarea_lines_autocomplete_977_test.go new file mode 100644 index 0000000000..c02472247d --- /dev/null +++ b/mdl/executor/textarea_lines_autocomplete_977_test.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#977 item 2: "The text area's `NumberOfLines` and `Autocomplete` are +// not round-tripped." describe printed neither, nothing parsed either, and the +// writer wrote 5 lines and no Autocomplete whatever was stored — so a Studio +// Pro text area of 2 lines (4 in PedApp and TestApp) or 0 lines (2) came back +// as 5. +package executor + +import ( + "context" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +func storedTextArea(t *testing.T, exec *Executor, page, widget string) (lines any, autocomplete any) { + t.Helper() + _, raw := pageUnitByName(t, exec, page) + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + walkStoredDocs(doc, func(d bson.D) { + if afGet(d, "Name") == widget { + lines, autocomplete = afGet(d, "NumberOfLines"), afGet(d, "Autocomplete") + } + }) + return lines, autocomplete +} + +func asInt(v any) int64 { + switch n := v.(type) { + case int32: + return int64(n) + case int64: + return n + } + return -1 +} + +const ta977Prelude = `create persistent entity MyFirstModule.Note (Body: String(unlimited)); +` + +func ta977Page(name, props string) string { + return "create or modify page MyFirstModule." + name + " (Title: 'Note', Layout: Atlas_Core.Atlas_Default, Params: ($Note: MyFirstModule.Note)) {\n" + + " dataview dvNote (DataSource: $Note) {\n" + + " textarea textArea1 (Attribute: Body" + props + ")\n" + + " }\n};\n" +} + +func TestTextAreaNumberOfLinesAndAutocomplete_Authored(t *testing.T) { + exec, _ := openPedAppFixture(t) + if err := afRun(t, exec, ta977Prelude+ta977Page("Note_Edit", ", NumberOfLines: 2, Autocomplete: false")); err != nil { + t.Fatalf("exec: %v", err) + } + if lines, auto := storedTextArea(t, exec, "Note_Edit", "textArea1"); asInt(lines) != 2 || auto != false { + t.Errorf("stored NumberOfLines %v, Autocomplete %v; want 2, false", lines, auto) + } + if err := afRun(t, exec, "alter page MyFirstModule.Note_Edit {\n set (NumberOfLines: 0, Autocomplete: true) on textArea1\n};"); err != nil { + t.Fatalf("alter page set: %v", err) + } + if lines, auto := storedTextArea(t, exec, "Note_Edit", "textArea1"); asInt(lines) != 0 || auto != true { + t.Errorf("after alter set: NumberOfLines %v, Autocomplete %v; want 0, true", lines, auto) + } +} + +// The reported round trip, on a text area whose stored values are Studio +// Pro's: 0 lines and Autocomplete off. +func TestTextAreaNumberOfLinesAndAutocomplete_StudioProValuesSurviveDescribeExec(t *testing.T) { + exec, out := openPedAppFixture(t) + if err := afRun(t, exec, ta977Prelude+ta977Page("Note_View", "")); err != nil { + t.Fatalf("exec: %v", err) + } + id, raw := pageUnitByName(t, exec, "Note_View") + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + walkStoredDocs(doc, func(d bson.D) { + if afGet(d, "Name") != "textArea1" { + return + } + for i := range d { + switch d[i].Key { + case "NumberOfLines": + d[i].Value = int64(0) // Studio Pro's width + case "Autocomplete": + d[i].Value = false + } + } + }) + b, err := bson.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := exec.newExecContext(context.Background()).Backend.UpdateRawUnit(string(id), b); err != nil { + t.Fatal(err) + } + + out.Reset() + if err := afRun(t, exec, "describe page MyFirstModule.Note_View;"); err != nil { + t.Fatal(err) + } + described := out.String() + for _, want := range []string{"NumberOfLines: 0", "Autocomplete: false"} { + if !strings.Contains(described, want) { + t.Errorf("describe dropped %q:\n%s", want, described) + } + } + out.Reset() + if err := afRun(t, exec, described); err != nil { + t.Fatalf("re-exec of the description: %v", err) + } + if !strings.Contains(out.String(), "Unchanged page") { + t.Errorf("re-executing the description was not a no-op:\n%s", out.String()) + } + if lines, auto := storedTextArea(t, exec, "Note_View", "textArea1"); asInt(lines) != 0 || auto != false { + t.Errorf("after describe → exec: NumberOfLines %v, Autocomplete %v; want 0, false", lines, auto) + } +} diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 1f5d060b03..0352e74917 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -1173,6 +1173,8 @@ var staticWidgetKnownProps = func() map[string]bool { // box's password flag and its Forms$WidgetValidation. Leaving them out // makes the describe -> create round trip warn about its own output. "Password", "Validation", "ValidationMessage", + // a text area's (ako/mxcli#977) + "NumberOfLines", "Autocomplete", // button icon-collection reference (issue #602) "Icon", // staticimage's image-collection reference, Module.Collection.Image diff --git a/sdk/pages/pages_widgets_input.go b/sdk/pages/pages_widgets_input.go index b2c800288d..1b9bb03c03 100644 --- a/sdk/pages/pages_widgets_input.go +++ b/sdk/pages/pages_widgets_input.go @@ -81,7 +81,13 @@ type TextArea struct { Placeholder *model.Text `json:"placeholder,omitempty"` MaxLength int `json:"maxLength,omitempty"` CounterMessage *model.Text `json:"counterMessage,omitempty"` - Rows int `json:"rows,omitempty"` + // NumberOfLines is Forms$TextArea.NumberOfLines; nil writes Studio Pro's + // default, 5 (52 of 58 text areas in PedApp and TestApp). Studio Pro also + // stores 0, which an int defaulting on zero could not express (ako/mxcli#977). + NumberOfLines *int `json:"numberOfLines,omitempty"` + // Autocomplete is Forms$TextArea.Autocomplete; nil writes true, the + // default (ako/mxcli#977). + Autocomplete *bool `json:"autocomplete,omitempty"` // ReadOnlyStyle: Inherit, Control or Text, as on CheckBox. Empty writes // Studio Pro's default, Inherit (ako/mxcli#977). ReadOnlyStyle string `json:"readOnlyStyle,omitempty"` From 5d339506ba0e94bceae56f44c061129eaf7f1cf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 11:56:53 +0000 Subject: [PATCH 17/40] fix(workflow): check user task targeting XPath against the user entity `targeting users xpath [System.User/Name = 'MxAdmin']` passed `check --references` and exec, and Studio Pro reported CE0161 on every task carrying it. Nothing walked a targeting constraint, and the shared XPath member walk read an entity as the first path step as a cast. The reference pass now checks each targeting constraint, as buildUserTask stores it, against the workflow user entity (System.User or the App Settings specialization) or System.WorkflowGroup, with the System module's members from modelsdk/meta. It reports a leading entity step, a member the entity lacks, and a missing association. A retrieve and a widget's database source now report a leading entity step too. The access-rule check keeps the plain walk (unmeasured there). Measured with mx check on mxbuild 11.15.0: the three rejected shapes are CE0161, and the role form [System.UserRoles = '[%UserRole_X%]'] is clean and selects the role's users at runtime (11.13.0). A CE1613 in the app hides every CE0161 from mx check, which is recorded in the finding. Fixes ako/mxcli#1711 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xfGeNtrQ5BJrH2Ew64rt5 --- ...orkflow-targeting-xpath-never-checked.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 28 +- .../write-workflows/reference/targeting.md | 33 +++ cmd/mxcli/syntax/features_workflow.go | 8 +- .../1711-workflow-targeting-xpath.mdl | 68 +++++ .../doctype-tests/24-workflow-examples.mdl | 4 +- mdl/executor/validate_retrieve_members.go | 7 +- mdl/executor/validate_widget_member_refs.go | 71 +++++- mdl/executor/validate_workflow_refs.go | 2 + mdl/executor/validate_workflow_scope.go | 4 +- .../validate_workflow_targeting_xpath.go | 241 ++++++++++++++++++ .../validate_workflow_targeting_xpath_test.go | 178 +++++++++++++ .../xpath_leading_entity_step_test.go | 95 +++++++ 13 files changed, 714 insertions(+), 26 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-workflow-targeting-xpath-never-checked.json create mode 100644 .claude/skills/mendix/write-workflows/reference/targeting.md create mode 100644 mdl-examples/bug-tests/1711-workflow-targeting-xpath.mdl create mode 100644 mdl/executor/validate_workflow_targeting_xpath.go create mode 100644 mdl/executor/validate_workflow_targeting_xpath_test.go create mode 100644 mdl/executor/xpath_leading_entity_step_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-workflow-targeting-xpath-never-checked.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-workflow-targeting-xpath-never-checked.json new file mode 100644 index 0000000000..f53f416d17 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-workflow-targeting-xpath-never-checked.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "A user task's `targeting users xpath [System.User/Name = 'MxAdmin']` passed `check -p --references` and exec; Studio Pro reported CE0161 'Error(s) in XPath constraint' on every task carrying it (three of five workflows in an agent eval). `[NoSuchAttr = 'x']` and `[System.WorkflowGroup/Name = …]` on group targeting passed the same way. The same leading entity step in a microflow retrieve (`[Mod.Doc/Title = 'x']` on Mod.Doc) and a list view's database source passed check and were CE0161 too", "cause": "Nothing walked a targeting constraint: the expression-scope pass skipped it as 'not an expression' and the reference pass checked only a targeting microflow's name. The shared XPath member walk (walkPath) reads an entity as a FIRST path step as a cast and accepts it; a constraint is already evaluated on its entity, so mxbuild rejects that step. The System module has no domain model in the project, so the project resolver answers memberUnknown for every System.User member and could not have reported NoSuchAttr", "file": "`mdl/executor/validate_workflow_targeting_xpath.go` (new; wired into validateWorkflowStatementRefs and validateAlterWorkflowRefs), `mdl/executor/validate_widget_member_refs.go` (unresolvableXPathStepsAndLeadingEntity, leadingEntityStepMessage), `mdl/executor/validate_retrieve_members.go`, `cmd/mxcli/syntax/features_workflow.go` (examples), `mdl-examples/doctype-tests/24-workflow-examples.mdl`", "insight": "**mx check hides CE0161 behind any CE1613.** The first 11.15.0 run had one task with an unknown `[%UserRole_Manager%]` token (CE1613), and mx check then reported NO CE0161 anywhere in the app — not the bad targeting constraints, not an unrelated retrieve — which reads exactly like 'mxbuild 11.15 accepts these'. Dropping that one task surfaced four. Measure each rejected shape in an app with no other error, or one experiment per copy. Second: a retrieve-shaped check cannot be reused for targeting as is — the constraint's context is the workflow user entity (System.User or the App Settings specialization), whose System members exist only in modelsdk/meta; the system-aware model is local to this check because making the shared model know System entities would let noteQualified start judging System associations it cannot resolve. The access-rule caller keeps the plain walk: the same leading step in a grant was not rejected on that app (possibly the security level), so it is unmeasured there, not known-good Third: ANY entity as the first step is CE0161 (measured: the constrained entity, a specialization of it, an unrelated entity), but 'drop the step' is only the fix when it names the constrained entity or a generalization — the message offers it only then Fourth, likely the source: `mxcli syntax workflow.user-task(.targeting)` itself showed `[Module.Employee/Active = true()]` and `[HR.Employee/Role = …]` — the leading-entity shape the agent wrote as `[System.User/Name = …]`; grep the syntax help and doctype examples for a pattern before blaming the model. The doctype showcase's `[Status = 'Draft']` passed CI only because a later ALTER replaced it with microflow targeting before mx check ran (measured CE0161 on 11.12.2, CI's version)", "fix": "Check each targeting constraint as buildUserTask stores it (resolveXPathMemberNames) against the configured user entity / System.WorkflowGroup with a meta-backed model; report a leading entity step (CE0161, with the `[Member = …]` fix), a member the entity lacks (CE0161), and a missing association (CE1613). The walk reports a leading entity step only where opted in: retrieve, widget source, targeting", "refs": ["ako/mxcli#1711"], "ce": ["CE0161", "CE1613"]} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index ea82bfe07b..d8fc0dfa7f 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -12,17 +12,17 @@ description: "Author Mendix workflows in MDL — user tasks, decisions, parallel |---|---| | 35-46 | When to Use This Skill | | 47-117 | Syntax — CREATE WORKFLOW | -| 118-293 | Activities | -| 294-300 | DROP WORKFLOW | -| 301-366 | ALTER WORKFLOW | -| 367-387 | DESCRIBE round-trip | -| 388-408 | Activity names, and why `jump to` depends on them | -| 409-429 | Rewriting an existing workflow | -| 430-517 | Microflow statements for workflow tasks | -| 518-538 | System-module enumerations are synthesized, not stored | -| 539-663 | Platform rules | -| 664-694 | Observing a running workflow | -| 695-704 | Validate before presenting | +| 118-299 | Activities | +| 300-306 | DROP WORKFLOW | +| 307-372 | ALTER WORKFLOW | +| 373-393 | DESCRIBE round-trip | +| 394-414 | Activity names, and why `jump to` depends on them | +| 415-435 | Rewriting an existing workflow | +| 436-523 | Microflow statements for workflow tasks | +| 524-544 | System-module enumerations are synthesized, not stored | +| 545-669 | Platform rules | +| 670-700 | Observing a running workflow | +| 701-710 | Validate before presenting | # Mendix Workflows Skill @@ -196,6 +196,12 @@ A `call workflow` mapping is always keyed `WorkflowContext` — the one paramete workflow has, whatever its header calls it: `(WorkflowContext = $WorkflowContext/M.A_B/M.B)`. Any other key is CE1613 (`MDL-WF18`); a path must name the target entity (else CE0117). +**Targeting by role:** the constraint is evaluated on the user, so it names the +user's members with no entity in front — `targeting users xpath +[System.UserRoles = '[%UserRole_Manager%]']`. `[System.User/Name = …]` is CE0161 +(`check -p --references` reports it, ako/mxcli#1711); an unknown role is CE1613, +which hides every CE0161 from mx check. Measured: [reference/targeting.md](reference/targeting.md). + **Notes** attach to an activity with `@annotation '…'` on the line before it, as in a microflow; the workflow's own note is the header clause `annotation '…'`, and an event sub-process takes `@annotation` before diff --git a/.claude/skills/mendix/write-workflows/reference/targeting.md b/.claude/skills/mendix/write-workflows/reference/targeting.md new file mode 100644 index 0000000000..9142ca1fec --- /dev/null +++ b/.claude/skills/mendix/write-workflows/reference/targeting.md @@ -0,0 +1,33 @@ +# Targeting a user task — reference + +A `targeting users xpath` constraint is evaluated **on the user** — `System.User`, +or the specialization App Settings > Workflows names — so it names the user's own +members, with no entity in front. `targeting groups xpath` is evaluated on +`System.WorkflowGroup`. To send a task to everyone holding a user role: + +```sql +user task Review 'Review' page Module.WF_Review + targeting users xpath [System.UserRoles = '[%UserRole_Manager%]'] + outcomes 'Approve' { } 'Reject' { }; +``` + +## Measured + +With mx check on 11.15.0, and for the role form also at runtime on 11.13.0: + +| Constraint | Result | +|---|---| +| `[System.UserRoles = '[%UserRole_User%]']` | clean; selects the role's users | +| `[System.UserRoles/System.UserRole/Name = 'User']` | clean | +| `[Name = 'MxAdmin']`, `[Active = true()]` | clean | +| `[System.User/Name = 'MxAdmin']` — the entity as a path step | **CE0161** | +| `[NoSuchAttr = 'x']` | **CE0161** | +| `[System.UserRoles = '[%UserRole_Manager%]']` with no `Manager` role | **CE1613** | +| groups: `[Name = 'Reviewers']` / `[System.WorkflowGroup/Name = …]` | clean / **CE0161** | + +`check -p --references` and `exec` report the two CE0161 shapes, and the same +leading entity step in a microflow retrieve or a list view's database source +(ako/mxcli#1711). The role token must name a user role the project has (`show user +roles`) — nothing checks that yet, and while any CE1613 is in the app mx check +reports **no CE0161 at all**, so clear it first. A constraint on one hard-coded +user is a test shortcut, not targeting. diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 74540d77a2..7148ddaa49 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -145,7 +145,7 @@ func init() { "-- Other parameters may sit alongside it.", Example: "-- The task page takes the task:\n" + "CREATE PAGE HR.ReviewPage (\n title: 'Review',\n layout: Atlas_Core.Atlas_Default,\n params: ( $WorkflowUserTask: System.WorkflowUserTask )\n) { };\n\n" + - "USER TASK ReviewTask 'Review the request'\n PAGE HR.ReviewPage\n TARGETING USERS XPATH [Module.Employee/Active = true()]\n OUTCOMES 'Approve' { } 'Reject' { };", + "USER TASK ReviewTask 'Review the request'\n PAGE HR.ReviewPage\n TARGETING USERS XPATH [Active = true()]\n OUTCOMES 'Approve' { } 'Reject' { };", SeeAlso: []string{"workflow.user-task.targeting", "workflow.multi-user-task", "workflow.create"}, }) @@ -169,12 +169,16 @@ func init() { "-- The XPath is written in brackets, as everywhere else, so quotes inside\n" + "-- it are written once. The quoted form XPATH '[...]' still parses and\n" + "-- warns MDL-DEPR031; `mxcli fmt --upgrade` rewrites it.\n\n" + + "-- The constraint is evaluated on the user (System.User, or the App Settings\n" + + "-- > Workflows user entity), so it names the user's members directly:\n" + + "-- [Name = 'x'], [System.UserRoles = '[%UserRole_Manager%]']. An entity as\n" + + "-- the first step ([System.User/Name = 'x']) is CE0161; check -p reports it.\n\n" + "-- A targeting microflow takes EXACTLY two parameters, in either order:\n" + "-- System.Workflow\n" + "-- the workflow's context entity, or a generalization of it\n" + "-- One parameter, none, a third, or a specialization of the context\n" + "-- entity is CE6677.", - Example: "-- XPath targeting: only active managers\nUSER TASK Approve 'Approve request'\n TARGETING USERS XPATH [HR.Employee/Role = 'Manager' and Active = true()]\n OUTCOMES 'Done' { };\n\n" + + Example: "-- XPath targeting: only active managers\nUSER TASK Approve 'Approve request'\n TARGETING USERS XPATH [System.UserRoles = '[%UserRole_Manager%]' and Active = true()]\n OUTCOMES 'Done' { };\n\n" + "-- Microflow targeting: the microflow takes the workflow AND its context object\n" + "CREATE MICROFLOW HR.GetApprovers ($Workflow: System.Workflow, $Request: HR.Request)\nRETURNS List of System.User AS $Approvers\nBEGIN\n RETRIEVE $Approvers FROM System.User;\n RETURN $Approvers;\nEND;\n\n" + "USER TASK Approve 'Approve request'\n TARGETING MICROFLOW HR.GetApprovers\n OUTCOMES 'Done' { };", diff --git a/mdl-examples/bug-tests/1711-workflow-targeting-xpath.mdl b/mdl-examples/bug-tests/1711-workflow-targeting-xpath.mdl new file mode 100644 index 0000000000..d39ae499df --- /dev/null +++ b/mdl-examples/bug-tests/1711-workflow-targeting-xpath.mdl @@ -0,0 +1,68 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1711 — "User task targeting XPath is never validated: +-- `targeting users xpath [System.User/Name = 'MxAdmin']` passes check, Studio +-- Pro reports CE0161" +-- ============================================================================ +-- +-- A targeting constraint is evaluated on the user (System.User, or the +-- specialization App Settings > Workflows names) — or on System.WorkflowGroup +-- for group targeting — so it names that entity's members directly. Measured +-- with mx check on mxbuild 11.15.0: +-- +-- users [System.User/Name = 'MxAdmin'] CE0161 (entity as a path step) +-- users [NoSuchAttr = 'x'] CE0161 +-- groups [System.WorkflowGroup/Name = 'Reviewers'] CE0161 +-- +-- and the same leading entity step is CE0161 in a microflow retrieve and a list +-- view's database source. Every constraint below builds clean. +-- +-- EXPECTED: `mxcli check` passes this file. With -p --references, +-- replacing any constraint below with one of the three above is reported, +-- naming the task and the fix (`drop the leading System.User/: [Name = …]`). +-- Guard: mdl/executor/validate_workflow_targeting_xpath_test.go, +-- mdl/executor/xpath_leading_entity_step_test.go. +-- ============================================================================ + +create module BugTargeting1711; + +create persistent entity BugTargeting1711.Request ( + Title: String(200) +); + +create page BugTargeting1711.WF_Review +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve') + actionbutton btnReject (Caption: 'Reject', Action: complete task 'Reject') + } +}; + +create or modify workflow BugTargeting1711.WF_Approve + parameter $Context: BugTargeting1711.Request +begin + user task ByRole 'Review by role' page BugTargeting1711.WF_Review + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + outcomes 'Approve' { } 'Reject' { }; + user task ByRolePath 'Review by role name' page BugTargeting1711.WF_Review + targeting users xpath [System.UserRoles/System.UserRole/Name = 'User'] + outcomes 'Approve' { } 'Reject' { }; + user task ByAttribute 'Review by active users' page BugTargeting1711.WF_Review + targeting users xpath [Active = true()] + outcomes 'Approve' { } 'Reject' { }; + user task ByGroup 'Review by group' page BugTargeting1711.WF_Review + targeting groups xpath [Name = 'Reviewers'] + outcomes 'Approve' { } 'Reject' { }; +end workflow; + +create microflow BugTargeting1711.ACT_ActiveUsers () +returns Boolean +begin + retrieve $Users from System.User where [Active = true()]; + return true; +end; diff --git a/mdl-examples/doctype-tests/24-workflow-examples.mdl b/mdl-examples/doctype-tests/24-workflow-examples.mdl index 22175ad875..f46d61d73f 100644 --- a/mdl-examples/doctype-tests/24-workflow-examples.mdl +++ b/mdl-examples/doctype-tests/24-workflow-examples.mdl @@ -140,7 +140,7 @@ create or modify workflow WFTest.OrderApproval begin user task ReviewOrder 'Review the order' page WFTest.TaskPage - targeting xpath [Status = 'Draft'] + targeting xpath [Active = true()] description 'Please review this order' outcomes 'Approve' { call microflow WFTest.ACT_Process; } @@ -182,7 +182,7 @@ alter workflow WFTest.OrderApproval { alter workflow WFTest.OrderApproval { set (Page: WFTest.AlternateTaskPage) on ReviewOrder; }; alter workflow WFTest.OrderApproval { set (Description: 'Updated instructions') on ReviewOrder; }; -alter workflow WFTest.OrderApproval { set (Targeting: xpath [Status = 'Pending']) on ReviewOrder; }; +alter workflow WFTest.OrderApproval { set (Targeting: xpath [Blocked = false()]) on ReviewOrder; }; alter workflow WFTest.OrderApproval { set (Targeting: microflow WFTest.ACT_TargetUsers) on ReviewOrder; }; alter workflow WFTest.OrderApproval { set (DueDate: '') on ReviewOrder; }; alter workflow WFTest.OrderApproval { set (Description: 'Referenced by caption') on 'Review the order'; }; diff --git a/mdl/executor/validate_retrieve_members.go b/mdl/executor/validate_retrieve_members.go index 8d735d0c67..e595c19aa1 100644 --- a/mdl/executor/validate_retrieve_members.go +++ b/mdl/executor/validate_retrieve_members.go @@ -40,7 +40,12 @@ func validateRetrieveMembers(ctx *ExecContext, retrieves []retrieveConstraintRef if r.entity == "" || r.stored == "" { continue } - for _, bad := range unresolvableXPathStepsWith(ctx, m, r.stored, r.entity, resolve) { + for _, bad := range unresolvableXPathStepsAndLeadingEntity(ctx, m, r.stored, r.entity, resolve) { + if bad.kind == "entity" { + errs = append(errs, fmt.Sprintf("retrieve from %s: %s", + r.entity, leadingEntityStepMessage(ctx, r.stored, bad.name, r.entity))) + continue + } errs = append(errs, fmt.Sprintf( "retrieve from %s: the constraint names %q, which is neither an attribute nor an association of it — mxbuild rejects the constraint (CE0161 \"Error(s) in XPath constraint\")%s", r.entity, bad.name, systemMemberSpellingHint(bad.name))) diff --git a/mdl/executor/validate_widget_member_refs.go b/mdl/executor/validate_widget_member_refs.go index fea3c7ddec..9ad42b1a4d 100644 --- a/mdl/executor/validate_widget_member_refs.go +++ b/mdl/executor/validate_widget_member_refs.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "regexp" "strings" "github.com/mendixlabs/mxcli/mdl/ast" @@ -66,7 +67,13 @@ func validateXPathMembers(ctx *ExecContext, prog *ast.Program) []error { } // Checked as it is stored: a qualified attribute of the entity is // the attribute, not a step to resolve (ako/mxcli#874). - for _, bad := range unresolvableXPathStepsWith(ctx, m, storedXPathConstraint(ds.Where, entityQN), entityQN, resolve) { + stored := storedXPathConstraint(ds.Where, entityQN) + for _, bad := range unresolvableXPathStepsAndLeadingEntity(ctx, m, stored, entityQN, resolve) { + if bad.kind == "entity" { + errs = append(errs, mdlerrors.NewValidation(fmt.Sprintf("%s: %s", + where, leadingEntityStepMessage(ctx, stored, bad.name, entityQN)))) + continue + } errs = append(errs, mdlerrors.NewValidation(fmt.Sprintf( "%s: the constraint on %s names %q, which is neither an attribute nor an "+ "association of it — mxbuild reports this as CE1613 \"The selected %s "+ @@ -81,7 +88,31 @@ func validateXPathMembers(ctx *ExecContext, prog *ast.Program) []error { // badStep is one step of a constraint that resolved to nothing. type badStep struct { name string - kind string // "association" or "attribute" — which mxbuild will call it + kind string // "association" or "attribute" — which mxbuild will call it; "entity" for a leading entity step +} + +// leadingEntityStepMessage is the report for a constraint that starts with an +// entity as a path step (unresolvableXPathStepsAndLeadingEntity). Any entity +// there is CE0161 — measured on 11.15.0 for the constrained entity, a +// specialization of it and an unrelated one — but dropping the step is the fix +// only when it names the constrained entity or a generalization, whose members +// the constraint already sees; another entity is reached through an association. +func leadingEntityStepMessage(ctx *ExecContext, constraint, entityStep, evaluatedOn string) string { + fix := "name a member of " + evaluatedOn + ", or reach " + entityStep + " through an association (`[Module.Assoc/" + entityStep + "/…]`)" + chain, _ := generalizationChain(ctx, evaluatedOn) + for _, e := range chain { + if e != entityStep { + continue + } + fix = "name the member directly" + if m := regexp.MustCompile(regexp.QuoteMeta(entityStep) + `/([A-Za-z_][A-Za-z0-9_.]*)`).FindStringSubmatch(constraint); m != nil { + fix = fmt.Sprintf("drop the leading `%s/`: `[%s = …]`", entityStep, m[1]) + } + break + } + return fmt.Sprintf("the constraint starts with the entity %s as a path step, but a constraint is "+ + "evaluated on %s and cannot start with an entity — %s. mxbuild rejects a leading entity step (CE0161 \"Error(s) in XPath constraint\")", + entityStep, evaluatedOn, fix) } // unresolvableXPathStepsWith walks the constraint's predicate groups and returns @@ -97,6 +128,26 @@ type badStep struct { // a false error still blocks a script that builds. func unresolvableXPathStepsWith(ctx *ExecContext, m xpathrefs.Model, constraint, entityQN string, resolveBare func(entityQN, member string) memberResolution) []badStep { + return walkXPathSteps(ctx, m, constraint, entityQN, resolveBare, false) +} + +// unresolvableXPathStepsAndLeadingEntity is unresolvableXPathStepsWith that also +// reports a path whose FIRST step is an entity, as kind "entity". +// +// A constraint is evaluated on its entity, so `[Shop.Order/Status = 'Open']` on +// Shop.Order is not a cast; the walk read it as one. Measured on mxbuild 11.15.0, +// it is CE0161 "Error(s) in XPath constraint" in a microflow retrieve, a list +// view's database source and a user task's targeting (ako/mxcli#1711); after an +// association (`[Shop.Order_Customer/Shop.Customer/Name = …]`) the same step is +// the path's specialization step and builds. mx check did not reject one in an +// access rule on that app, so the access-rule check keeps the plain walk. +func unresolvableXPathStepsAndLeadingEntity(ctx *ExecContext, m xpathrefs.Model, constraint, entityQN string, + resolveBare func(entityQN, member string) memberResolution) []badStep { + return walkXPathSteps(ctx, m, constraint, entityQN, resolveBare, true) +} + +func walkXPathSteps(ctx *ExecContext, m xpathrefs.Model, constraint, entityQN string, + resolveBare func(entityQN, member string) memberResolution, leadingEntity bool) []badStep { groups := visitor.SplitXPathPredicateGroups(constraint) if len(groups) == 0 { groups = []string{constraint} @@ -107,7 +158,7 @@ func unresolvableXPathStepsWith(ctx *ExecContext, m xpathrefs.Model, constraint, if !ok || expr == nil { continue } - v := &xpathMemberVisitor{ctx: ctx, model: m, resolveBare: resolveBare} + v := &xpathMemberVisitor{ctx: ctx, model: m, resolveBare: resolveBare, leadingEntity: leadingEntity} v.walk(expr, entityQN) out = append(out, v.bad...) } @@ -117,11 +168,12 @@ func unresolvableXPathStepsWith(ctx *ExecContext, m xpathrefs.Model, constraint, // xpathMemberVisitor walks a parsed constraint carrying the entity each step is // evaluated against, the same traversal xpathrefs' walker performs for renames. type xpathMemberVisitor struct { - ctx *ExecContext - model xpathrefs.Model - resolveBare func(entityQN, member string) memberResolution // nil: resolveMemberOnEntity - bad []badStep - seen map[string]bool + ctx *ExecContext + model xpathrefs.Model + resolveBare func(entityQN, member string) memberResolution // nil: resolveMemberOnEntity + leadingEntity bool // report a path that starts with an entity step + bad []badStep + seen map[string]bool } func (v *xpathMemberVisitor) add(s badStep) { @@ -246,6 +298,9 @@ func (v *xpathMemberVisitor) walkPath(steps []ast.XPathStep, cur string) { } else if t, ok := xpathSystemAssociations[qn]; ok { next = t } else if v.model.IsEntity(qn) { + if i == 0 && v.leadingEntity { + v.add(badStep{name: qn, kind: "entity"}) + } next = qn } else { v.noteQualified(qn, cur) diff --git a/mdl/executor/validate_workflow_refs.go b/mdl/executor/validate_workflow_refs.go index ecef71b63d..23b4ab68e9 100644 --- a/mdl/executor/validate_workflow_refs.go +++ b/mdl/executor/validate_workflow_refs.go @@ -202,6 +202,7 @@ func validateWorkflowStatementRefs(ctx *ExecContext, s *ast.CreateWorkflowStmt, activities := workflowStatementActivities(s) errs = append(errs, bareTimerBoundaryEventErrors(ctx, activities, 0)...) errs = append(errs, validateWorkflowReferences(ctx, activities, sc)...) + errs = append(errs, validateWorkflowTargetingXPath(ctx, activities, sc)...) errs = append(errs, validateWorkflowEventHandlers(ctx, s, sc)...) // Then the signatures of the page and targeting microflow each user task // hands work to — names that resolve can still be the wrong shape (CE7410, @@ -353,5 +354,6 @@ func validateAlterWorkflowRefs(ctx *ExecContext, s *ast.AlterWorkflowStmt, sc *s errs = append(errs, validateAlterWorkflowEndAncestry(ctx, s)...) errs = append(errs, validateWorkflowReferences(ctx, added, sc)...) + errs = append(errs, validateAlterWorkflowTargetingXPath(ctx, s, added, sc)...) return append(errs, validateAlterWorkflowTaskSignatures(ctx, s, added, sc)...) } diff --git a/mdl/executor/validate_workflow_scope.go b/mdl/executor/validate_workflow_scope.go index 9c431b2f4b..63785f869a 100644 --- a/mdl/executor/validate_workflow_scope.go +++ b/mdl/executor/validate_workflow_scope.go @@ -42,8 +42,8 @@ import ( // `$WorkflowContext` (contextExprNormalizer), and any casing of // `$WorkflowInstance` (lower case is CE0117 as written, measured on 11.6.6). // -// XPath targeting is not scanned: it is an XPath constraint, not an expression, -// and its scope was not measured. +// XPath targeting is not scanned here: it is an XPath constraint, not an +// expression, and validateWorkflowTargetingXPath checks its members instead. const wfExprScopeRule = "MDL-WF19" // workflowInstanceVar is the System.Workflow object of the running instance — diff --git a/mdl/executor/validate_workflow_targeting_xpath.go b/mdl/executor/validate_workflow_targeting_xpath.go new file mode 100644 index 0000000000..764c07ce80 --- /dev/null +++ b/mdl/executor/validate_workflow_targeting_xpath.go @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/xpathrefs" + "github.com/mendixlabs/mxcli/modelsdk/meta" +) + +// A user task's targeting constraint picks the users (or workflow groups) the +// task goes to, and nothing checked it: the expression-scope pass skips it as +// "not an expression", and the reference pass looked only at a targeting +// microflow's name. An agent's `[System.User/Name = 'MxAdmin']` therefore passed +// `check --references` and exec, and Studio Pro reported CE0161 on every task +// that carried it (ako/mxcli#1711). +// +// Measured with mx check on mxbuild 11.15.0, blank app: +// +// users [System.User/Name = 'MxAdmin'] CE0161 +// users [NoSuchAttr = 'x'] CE0161 +// groups [System.WorkflowGroup/Name = 'Reviewers'] CE0161 +// users [Name = 'MxAdmin'], [Active = true()] clean +// users [System.UserRoles = '[%UserRole_User%]'] clean (and selects the role's users at runtime, 11.13.0) +// users [System.UserRoles/System.UserRole/Name = 'User'] clean +// groups [Name = 'Reviewers'] clean +// +// Re-measuring has one trap: with a CE1613 anywhere in the app (an unknown +// `[%UserRole_X%]` token, a dangling call), mx check reported no CE0161 at all. +// +// The constraint is evaluated on the workflow user entity — System.User unless +// App Settings > Workflows names a specialization — or on System.WorkflowGroup +// for group targeting. The System module has no domain model in the project, so +// its members come from modelsdk/meta; a specialization's own members come from +// the project, and what the model cannot answer stays silent. + +// validateWorkflowTargetingXPath checks the targeting constraint of every user +// task among activities, nested ones included. +func validateWorkflowTargetingXPath(ctx *ExecContext, activities []ast.WorkflowActivityNode, sc *scriptContext) []string { + if ctx == nil || !ctx.Connected() || len(activities) == 0 { + return nil + } + var errs []string + walkWorkflowActivities(activities, func(act ast.WorkflowActivityNode) { + if n, ok := act.(*ast.WorkflowUserTaskNode); ok { + errs = append(errs, targetingXPathProblems(ctx, sc, "user task "+workflowUserTaskLabel(n), n.Targeting.Kind, n.Targeting.XPath)...) + } + }) + return errs +} + +// validateAlterWorkflowTargetingXPath is the ALTER WORKFLOW entry point: the +// tasks an operation adds, and `set (Targeting: xpath [ … ]) on X`. +func validateAlterWorkflowTargetingXPath(ctx *ExecContext, s *ast.AlterWorkflowStmt, added []ast.WorkflowActivityNode, sc *scriptContext) []string { + if ctx == nil || !ctx.Connected() { + return nil + } + errs := validateWorkflowTargetingXPath(ctx, added, sc) + for _, op := range s.Operations { + if o, ok := op.(*ast.SetActivityPropertyOp); ok && o.Property == "targeting_xpath" { + errs = append(errs, targetingXPathProblems(ctx, sc, "activity '"+o.ActivityRef+"'", "xpath", o.Value)...) + } + } + return errs +} + +// targetingXPathProblems reports what in one targeting constraint mxbuild rejects. +func targetingXPathProblems(ctx *ExecContext, sc *scriptContext, label, kind, xpath string) []string { + var base string + switch kind { + case "xpath": + base = "System.User" + case "group_xpath": + base = "System.WorkflowGroup" + default: + return nil + } + if strings.TrimSpace(xpath) == "" { + return nil + } + // The constraint as buildUserTask stores it: a name qualified with the + // evaluated entity is the bare member. + stored := resolveXPathMemberNames(xpath, base) + on := base + if kind == "xpath" { + on = workflowUserEntity(ctx) + } + m, resolve := targetingXPathResolver(ctx, sc) + + var errs []string + for _, bad := range unresolvableXPathStepsAndLeadingEntity(ctx, m, stored, on, resolve) { + switch bad.kind { + case "entity": + errs = append(errs, fmt.Sprintf("%s targeting: %s", label, leadingEntityStepMessage(ctx, stored, bad.name, on))) + case "attribute": + errs = append(errs, fmt.Sprintf( + "%s targeting: the constraint names %q, which is neither an attribute nor an association of %s — "+ + "mxbuild rejects the constraint (CE0161 \"Error(s) in XPath constraint\")", + label, bad.name, on)) + default: + errs = append(errs, fmt.Sprintf( + "%s targeting: the constraint names the %s %q, which %s does not have — "+ + "mxbuild reports CE1613 \"The selected %s '%s' no longer exists\"", + label, bad.kind, bad.name, on, bad.kind, bad.name)) + } + } + return errs +} + +// workflowUserEntity is the entity a users-targeting constraint is evaluated on: +// App Settings > Workflows > User entity, System.User when none is set. +func workflowUserEntity(ctx *ExecContext) string { + if ps, err := ctx.Backend.GetProjectSettings(); err == nil && ps != nil && ps.Workflows != nil && ps.Workflows.UserEntity != "" { + return ps.Workflows.UserEntity + } + return "System.User" +} + +// targetingXPathResolver is scriptXPathResolver with the System module added, +// which a targeting constraint always starts from. +func targetingXPathResolver(ctx *ExecContext, sc *scriptContext) (xpathrefs.Model, func(entityQN, member string) memberResolution) { + inner, projectResolve := scriptXPathResolver(ctx, sc) + resolve := func(entityQN, member string) memberResolution { + if strings.HasPrefix(entityQN, "System.") { + return systemMemberResolution(entityQN, member) + } + // A specialization's own members; at System.User the project walk has + // no domain model to read and answers memberUnknown, never a guess. + return projectResolve(entityQN, member) + } + return &systemXPathModel{inner: inner, ctx: ctx}, resolve +} + +// systemXPathModel answers xpathrefs.Model for the System module from +// modelsdk/meta and defers everything else to the project-and-script model. +type systemXPathModel struct { + inner xpathrefs.Model + ctx *ExecContext +} + +func (m *systemXPathModel) IsEntity(qn string) bool { + if _, ok := modelerSystemEntity(qn); ok { + return true + } + return m.inner.IsEntity(qn) +} + +func (m *systemXPathModel) AssociationTarget(qn, from string) (string, bool) { + name, ok := strings.CutPrefix(qn, "System.") + if !ok { + return m.inner.AssociationTarget(qn, from) + } + for _, a := range meta.ModelerSystemAssociations() { + if a.Name != name { + continue + } + // Either end may be traversed, from the entity or anything it + // specializes. + for _, e := range m.chain(from) { + switch e { + case "System." + a.Parent: + return "System." + a.Child, true + case "System." + a.Child: + return "System." + a.Parent, true + } + } + return "", false + } + return m.inner.AssociationTarget(qn, from) +} + +// chain is from and its generalizations, through the project's entities into +// the System module's. +func (m *systemXPathModel) chain(from string) []string { + chain := []string{from} + if !strings.HasPrefix(from, "System.") { + if c, _ := generalizationChain(m.ctx, from); len(c) > 0 { + chain = c + } + } + last := chain[len(chain)-1] + for seen := map[string]bool{}; !seen[last]; { + seen[last] = true + def, ok := modelerSystemEntity(last) + if !ok || def.Generalization == "" { + break + } + last = def.Generalization + chain = append(chain, last) + } + return chain +} + +// modelerSystemEntity is the System entity qn names, among those Studio Pro shows. +func modelerSystemEntity(qn string) (meta.SystemEntityDef, bool) { + name, ok := strings.CutPrefix(qn, "System.") + if !ok { + return meta.SystemEntityDef{}, false + } + for _, e := range meta.ModelerSystemEntities() { + if e.Name == name { + return e, true + } + } + return meta.SystemEntityDef{}, false +} + +// systemMemberResolution resolves a bare member of a System entity: one of its +// attributes or its generalizations', or — the bare spelling being +// MDL-XPATH01's to report, not this check's — one of its associations. +func systemMemberResolution(entityQN, member string) memberResolution { + def, ok := modelerSystemEntity(entityQN) + if !ok { + return memberUnknown + } + ends := map[string]bool{} + for seen := map[string]bool{}; !seen[def.Name]; { + seen[def.Name] = true + ends[def.Name] = true + for _, a := range def.Attributes { + if a.Name == member { + return memberFound + } + } + if def.Generalization == "" { + break + } + if def, ok = modelerSystemEntity(def.Generalization); !ok { + return memberUnknown + } + } + for _, a := range meta.ModelerSystemAssociations() { + if a.Name == member && (ends[a.Parent] || ends[a.Child]) { + return memberFound + } + } + return memberMissing +} diff --git a/mdl/executor/validate_workflow_targeting_xpath_test.go b/mdl/executor/validate_workflow_targeting_xpath_test.go new file mode 100644 index 0000000000..6d2b0ac6f0 --- /dev/null +++ b/mdl/executor/validate_workflow_targeting_xpath_test.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "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" +) + +// A user task's targeting constraint was never checked (ako/mxcli#1711): an +// agent's `[System.User/Name = 'MxAdmin']` passed `check --references` and +// exec, and Studio Pro reported CE0161 on every task carrying it. Every case +// below was measured with mx check on mxbuild 11.15.0 (blank app). + +func targetingTask(kind, xpath string) *ast.WorkflowUserTaskNode { + return &ast.WorkflowUserTaskNode{ + Name: "Review", Caption: "Review", + Targeting: ast.WorkflowTargetingNode{Kind: kind, XPath: xpath}, + } +} + +func TestWorkflowTargetingXPath_ReportsWhatMxbuildRejects(t *testing.T) { + ctx := memberFixture(t) + for _, tc := range []struct { + name, kind, xpath string + want []string + }{ + // The reported constraint: the entity the constraint is evaluated on, + // written as a path step. + {"a leading System.User step", "xpath", "[System.User/Name = 'MxAdmin']", + []string{"System.User", "CE0161", "[Name = …]"}}, + {"a member System.User does not have", "xpath", "[NoSuchAttr = 'x']", + []string{"NoSuchAttr", "System.User", "CE0161"}}, + {"a leading System.WorkflowGroup step on group targeting", "group_xpath", "[System.WorkflowGroup/Name = 'Reviewers']", + []string{"System.WorkflowGroup", "CE0161"}}, + } { + t.Run(tc.name, func(t *testing.T) { + errs := validateWorkflowTargetingXPath(ctx, + []ast.WorkflowActivityNode{targetingTask(tc.kind, tc.xpath)}, newScriptContext()) + if len(errs) != 1 { + t.Fatalf("got %d errors, want 1: %v", len(errs), errs) + } + for _, w := range tc.want { + if !strings.Contains(errs[0], w) { + t.Errorf("error should mention %q: %s", w, errs[0]) + } + } + }) + } +} + +// CONTROL: each of these built clean on the same app. +func TestWorkflowTargetingXPath_AcceptsWhatMxbuildBuilds(t *testing.T) { + ctx := memberFixture(t) + for _, tc := range []struct{ name, kind, xpath string }{ + {"a bare attribute", "xpath", "[Name = 'MxAdmin']"}, + {"a role through System.UserRoles", "xpath", "[System.UserRoles = '[%UserRole_User%]']"}, + {"a path through System.UserRoles to the role's name", "xpath", "[System.UserRoles/System.UserRole/Name = 'User']"}, + {"the qualified attribute the writer resolves", "xpath", "[System.User.Name = 'MxAdmin']"}, + {"a boolean attribute", "xpath", "[Active = true()]"}, + {"an implicit member", "xpath", "[createdDate < '[%BeginOfCurrentDay%]']"}, + {"a group's attribute", "group_xpath", "[Name = 'Reviewers']"}, + } { + t.Run(tc.name, func(t *testing.T) { + if errs := validateWorkflowTargetingXPath(ctx, + []ast.WorkflowActivityNode{targetingTask(tc.kind, tc.xpath)}, newScriptContext()); len(errs) != 0 { + t.Errorf("%s was reported, but mxbuild builds it: %v", tc.xpath, errs) + } + }) + } +} + +// Microflow targeting and no targeting carry no constraint. +func TestWorkflowTargetingXPath_IgnoresTasksWithoutAConstraint(t *testing.T) { + ctx := memberFixture(t) + acts := []ast.WorkflowActivityNode{ + &ast.WorkflowUserTaskNode{Name: "A", Targeting: ast.WorkflowTargetingNode{Kind: "microflow", + Microflow: ast.QualifiedName{Module: "Shop", Name: "Targets"}}}, + &ast.WorkflowUserTaskNode{Name: "B"}, + } + if errs := validateWorkflowTargetingXPath(ctx, acts, newScriptContext()); len(errs) != 0 { + t.Errorf("a task without an XPath constraint was reported: %v", errs) + } +} + +// A task nested in an outcome is checked like one in the main flow. +func TestWorkflowTargetingXPath_ReachesNestedTasks(t *testing.T) { + ctx := memberFixture(t) + outer := &ast.WorkflowUserTaskNode{Name: "Outer", Outcomes: []ast.WorkflowUserTaskOutcomeNode{{ + Caption: "Escalate", + Activities: []ast.WorkflowActivityNode{targetingTask("xpath", "[System.User/Name = 'Boss']")}, + }}} + if errs := validateWorkflowTargetingXPath(ctx, []ast.WorkflowActivityNode{outer}, newScriptContext()); len(errs) != 1 { + t.Fatalf("the nested task's constraint was not checked: %v", errs) + } +} + +// When App Settings > Workflows names a specialization of System.User, the +// constraint is evaluated on it: its own attributes are members too, and an +// attribute the project model cannot rule out stays silent. +func TestWorkflowTargetingXPath_UsesTheConfiguredUserEntity(t *testing.T) { + ctx := userEntityFixture(t) + for _, xpath := range []string{"[FullName = 'Jane']", "[Name = 'jane']"} { + if errs := validateWorkflowTargetingXPath(ctx, + []ast.WorkflowActivityNode{targetingTask("xpath", xpath)}, newScriptContext()); len(errs) != 0 { + t.Errorf("%s names a member of the configured user entity, but was reported: %v", xpath, errs) + } + } + // The leading entity step is wrong whichever entity it names. + if errs := validateWorkflowTargetingXPath(ctx, + []ast.WorkflowActivityNode{targetingTask("xpath", "[Accounts.Account/FullName = 'Jane']")}, newScriptContext()); len(errs) != 1 { + t.Errorf("a leading Accounts.Account step was not reported: %v", errs) + } +} + +// ALTER WORKFLOW … set (Targeting: xpath …) on X writes the same constraint. +func TestWorkflowTargetingXPath_ChecksAlterSetTargeting(t *testing.T) { + ctx := memberFixture(t) + stmt := &ast.AlterWorkflowStmt{ + Name: ast.QualifiedName{Module: "Shop", Name: "Approve"}, + Operations: []ast.AlterWorkflowOp{ + &ast.SetActivityPropertyOp{ActivityRef: "Review", Property: "targeting_xpath", Value: "[System.User/Name = 'MxAdmin']"}, + }, + } + errs := validateAlterWorkflowTargetingXPath(ctx, stmt, nil, newScriptContext()) + if len(errs) != 1 || !strings.Contains(errs[0], "CE0161") { + t.Fatalf("set (Targeting: xpath [System.User/Name = …]) was not reported: %v", errs) + } +} + +// userEntityFixture is memberFixture's project with App Settings > Workflows > +// User entity set to Accounts.Account, a specialization of System.User. +func userEntityFixture(t *testing.T) *ExecContext { + t.Helper() + ctx := memberFixture(t) + mb := ctx.Backend.(*mock.MockBackend) + + accounts := &model.Module{Name: "Accounts"} + accounts.ID = nextID("accounts") + account := &domainmodel.Entity{Name: "Account", Persistable: true, GeneralizationRef: "System.User"} + account.ID = nextID("account") + fullName := &domainmodel.Attribute{Name: "FullName", Type: &domainmodel.StringAttributeType{}} + fullName.ID = nextID("fullname") + account.Attributes = []*domainmodel.Attribute{fullName} + adm := &domainmodel.DomainModel{ContainerID: accounts.ID, Entities: []*domainmodel.Entity{account}} + adm.ID = nextID("adm") + + listModules, getModule, getDM := mb.ListModulesFunc, mb.GetModuleByNameFunc, mb.GetDomainModelFunc + mb.ListModulesFunc = func() ([]*model.Module, error) { + mods, err := listModules() + return append(mods, accounts), err + } + mb.GetModuleByNameFunc = func(name string) (*model.Module, error) { + if name == "Accounts" { + return accounts, nil + } + if name == "System" { + return nil, fmt.Errorf("no module %q", name) + } + return getModule(name) + } + mb.GetDomainModelFunc = func(id model.ID) (*domainmodel.DomainModel, error) { + if id == accounts.ID { + return adm, nil + } + return getDM(id) + } + mb.GetProjectSettingsFunc = func() (*model.ProjectSettings, error) { + return &model.ProjectSettings{Workflows: &model.WorkflowsSettings{UserEntity: "Accounts.Account"}}, nil + } + return ctx +} diff --git a/mdl/executor/xpath_leading_entity_step_test.go b/mdl/executor/xpath_leading_entity_step_test.go new file mode 100644 index 0000000000..c331f7bc0f --- /dev/null +++ b/mdl/executor/xpath_leading_entity_step_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// A constraint is evaluated on its entity, so it cannot start with that entity +// (or any entity) as a path step: `[Shop.Order/Status = 'Open']` on Shop.Order. +// The XPath member walk read the step as a cast and passed it; mxbuild 11.15.0 +// reports CE0161 for it in a microflow retrieve and in a list view's database +// source, as it does in a user task's targeting (ako/mxcli#1711). + +func TestLeadingEntityStep_ReportedOnARetrieve(t *testing.T) { + ctx := memberFixture(t) + errs := validateRetrieveMembers(ctx, []retrieveConstraintRef{{ + entity: "Shop.Order", stored: "[Shop.Order/Status = 'Open']", + }}, newScriptContext()) + if len(errs) != 1 { + t.Fatalf("got %d errors, want 1: %v", len(errs), errs) + } + for _, w := range []string{"Shop.Order", "CE0161", "[Status = …]"} { + if !strings.Contains(errs[0], w) { + t.Errorf("error should mention %q: %s", w, errs[0]) + } + } +} + +func TestLeadingEntityStep_ReportedOnAWidgetSource(t *testing.T) { + ctx := memberFixture(t) + errs := validateXPathMembers(ctx, pageWith(dbGrid("Shop.Order", "[Shop.Order/Status = 'Open']"))) + if len(errs) != 1 { + t.Fatalf("got %d errors, want 1: %v", len(errs), errs) + } + msg := errs[0].Error() + if !strings.Contains(msg, "CE0161") || strings.Contains(msg, "CE1613") { + t.Errorf("mxbuild reports a leading entity step as CE0161, not as a missing member: %s", msg) + } +} + +// CONTROL: an entity step AFTER an association is the specialization step of a +// path, and builds. +func TestLeadingEntityStep_AnEntityAfterAnAssociationIsFine(t *testing.T) { + ctx := memberFixture(t) + const where = "[Shop.Order_Customer/Shop.Customer/Name = 'x']" + if errs := validateRetrieveMembers(ctx, []retrieveConstraintRef{{entity: "Shop.Order", stored: where}}, + newScriptContext()); len(errs) != 0 { + t.Errorf("retrieve: %v", errs) + } + if errs := validateXPathMembers(ctx, pageWith(dbGrid("Shop.Order", where))); len(errs) != 0 { + t.Errorf("widget source: %v", errs) + } +} + +// CONTROL: the plain walk, which the access-rule check uses, still passes a +// leading entity step. mx check did not reject one in an access rule on the +// same app, so that caller is left as it was. +func TestLeadingEntityStep_PlainWalkStaysSilent(t *testing.T) { + ctx := memberFixture(t) + m, resolve := scriptXPathResolver(ctx, newScriptContext()) + if bad := unresolvableXPathStepsWith(ctx, m, "[Shop.Order/Status = 'Open']", "Shop.Order", resolve); len(bad) != 0 { + t.Errorf("the plain walk now reports %v", bad) + } +} + +// Any entity as the first step is CE0161 — measured on 11.15.0 for a +// specialization of the retrieved entity and for an unrelated one too — but +// dropping the step only helps when it names the constrained entity or a +// generalization of it: the members are then the entity's own. +func TestLeadingEntityStep_TheFixDependsOnWhichEntity(t *testing.T) { + ctx := memberFixture(t) + for _, tc := range []struct { + name, where string + dropFix bool + }{ + {"the constrained entity", "[Shop.Order/Status = 'Open']", true}, + {"a generalization", "[Shop.Base/Code = 'x']", true}, + {"another entity", "[Shop.Customer/Name = 'x']", false}, + } { + t.Run(tc.name, func(t *testing.T) { + errs := validateRetrieveMembers(ctx, []retrieveConstraintRef{{entity: "Shop.Order", stored: tc.where}}, newScriptContext()) + if len(errs) != 1 { + t.Fatalf("got %d errors, want 1: %v", len(errs), errs) + } + if got := strings.Contains(errs[0], "drop the leading"); got != tc.dropFix { + t.Errorf("offers dropping the step = %v, want %v: %s", got, tc.dropFix, errs[0]) + } + if !tc.dropFix && !strings.Contains(errs[0], "association") { + t.Errorf("for another entity the fix is to reach it through an association: %s", errs[0]) + } + }) + } +} From 24c7c5177aa53d975f9168e04a0ea3c35951a93e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:16:56 +0000 Subject: [PATCH 18/40] fix(workflow): end an interrupting boundary path with an End, as Studio Pro does An interrupting boundary path that runs to its last activity was ended with the end-of-path marker, the terminator Studio Pro writes for a non-interrupting path. Studio Pro ends an interrupting path with an End (a jump inside a parallel split), so a workflow opened there showed the two mixed up, and MCP refused the same MDL. Measured first on the Mendix 11.13.0 runtime: marker and End behave alike on an interrupting path (instance Completed, nothing after the task runs), so this changes the stored shape, not what runs. A post-build pass now gives each interrupting path that runs to its end an End wherever an End may stand: the main flow, outcomes, interrupting paths and event sub-process bodies. Under a parallel split or a non-interrupting path an End is CE1844, so those keep the marker. ALTER uses the target's stored placement for inserted activities and for an inserted boundary event. describe now prints `end workflow;` there, and the describe -> exec round trip reports Unchanged. mx check: 0 errors on 11.15.0 for every placement. Fixes ako/mxcli#1712 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xfGeNtrQ5BJrH2Ew64rt5 --- ...rrupting-boundary-path-ends-in-marker.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 35 ++-- .../1712-interrupting-boundary-path-end.mdl | 79 ++++++++ mdl/executor/cmd_alter_workflow.go | 11 +- mdl/executor/cmd_workflows_write.go | 3 + mdl/executor/workflow_interrupting_end.go | 64 +++++++ .../workflow_interrupting_end_test.go | 180 ++++++++++++++++++ sdk/workflows/workflow.go | 95 +++++++++ .../workflow_interrupting_end_test.go | 133 +++++++++++++ 9 files changed, 583 insertions(+), 18 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/sdk/2026-10-26-interrupting-boundary-path-ends-in-marker.json create mode 100644 mdl-examples/bug-tests/1712-interrupting-boundary-path-end.mdl create mode 100644 mdl/executor/workflow_interrupting_end.go create mode 100644 mdl/executor/workflow_interrupting_end_test.go create mode 100644 sdk/workflows/workflow_interrupting_end_test.go diff --git a/.claude/skills/fix-issue/findings/sdk/2026-10-26-interrupting-boundary-path-ends-in-marker.json b/.claude/skills/fix-issue/findings/sdk/2026-10-26-interrupting-boundary-path-ends-in-marker.json new file mode 100644 index 0000000000..a8f26e9f92 --- /dev/null +++ b/.claude/skills/fix-issue/findings/sdk/2026-10-26-interrupting-boundary-path-ends-in-marker.json @@ -0,0 +1 @@ +{"area": "sdk/workflows", "date": "2026-10-26", "symptom": "An interrupting boundary path written from MDL that runs to its last activity (`boundary event interrupting timer … { call microflow M.X; }`) was stored ending in EndOfBoundaryEventPathActivity — the terminator Studio Pro writes for a NON-interrupting path. An evaluator opening the result in Studio Pro reported mxcli 'mixing end of boundary path with End', and agents were unsure how either kind should end. Over MCP the same MDL was refused, since Studio Pro's interrupting constructor requires an End or a jump", "cause": "workflows.EndBoundaryEventPath, added to clear CE0105 (interrupting) and a runtime start failure (non-interrupting), appended the marker to every unended path whatever its kind, at all three call sites (CREATE's buildBoundaryEvents, the file mutator's and MCP's ALTER insert)", "file": "`sdk/workflows/workflow.go` (EndInterruptingBoundaryPaths, EndInterruptingPath), `mdl/executor/workflow_interrupting_end.go`, `mdl/executor/cmd_workflows_write.go` (execCreateWorkflow), `mdl/executor/cmd_alter_workflow.go`", "insight": "**Measure the runtime before calling a stored shape a defect.** The obvious fear was that an interrupting path ending in the marker leaves an instance with no active path after the parent is aborted; on the 11.13.0 runtime (run --local, hsqldb, a 20-second timer, a marker after the task to show whether the main flow continued) the marker and an End behaved identically: instance Completed, EndTime set, the activity after the task never ran. That turned a would-be semantics fix into a shape fix and set its limits: change only where an End can stand (not under a parallel split or a non-interrupting path, CE1844 at any depth — already measured in PROPOSAL_workflow_end_activity), keep the marker elsewhere because it builds, and never invent a jump target. Probe trap worth skipping: an after-startup microflow that starts workflows runs before any user exists, so every XPath-targeted task fails 'User targeting returned no users' and no timer is ever armed — create and commit a user (with a password, or startup fails on validation) first. JDK 21 runs 11.13 but not 11.14+ (needs 25)", "fix": "A post-build pass replaces the trailing marker of each interrupting path with an End where endAllowed (main flow, outcomes, interrupting paths, event sub-process bodies; not parallel split paths or non-interrupting paths). ALTER computes endAllowed from the stored placement of the target (resolveStoredPlacement) and appends the End before the mutator, whose EndBoundaryEventPath then leaves an ended path alone. describe now prints `end workflow;` there and the describe → exec round trip is Unchanged", "refs": ["ako/mxcli#1712"], "ce": ["CE1844", "CE0105"]} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index d8fc0dfa7f..c87d1d2be0 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -12,17 +12,17 @@ description: "Author Mendix workflows in MDL — user tasks, decisions, parallel |---|---| | 35-46 | When to Use This Skill | | 47-117 | Syntax — CREATE WORKFLOW | -| 118-299 | Activities | -| 300-306 | DROP WORKFLOW | -| 307-372 | ALTER WORKFLOW | -| 373-393 | DESCRIBE round-trip | -| 394-414 | Activity names, and why `jump to` depends on them | -| 415-435 | Rewriting an existing workflow | -| 436-523 | Microflow statements for workflow tasks | -| 524-544 | System-module enumerations are synthesized, not stored | -| 545-669 | Platform rules | -| 670-700 | Observing a running workflow | -| 701-710 | Validate before presenting | +| 118-300 | Activities | +| 301-307 | DROP WORKFLOW | +| 308-373 | ALTER WORKFLOW | +| 374-394 | DESCRIBE round-trip | +| 395-415 | Activity names, and why `jump to` depends on them | +| 416-436 | Rewriting an existing workflow | +| 437-524 | Microflow statements for workflow tasks | +| 525-545 | System-module enumerations are synthesized, not stored | +| 546-670 | Platform rules | +| 671-701 | Observing a running workflow | +| 702-711 | Validate before presenting | # Mendix Workflows Skill @@ -247,12 +247,13 @@ end workflow; on Mendix 11 (MDL-WF07). - **The delay is a DateTime expression**, such as `'addDays([%CurrentDateTime%], 3)'` — not an ISO duration like `'P3D'`. -- **Every boundary path must end** in a jump, an end, or Mendix's end-of-path - marker, and mxcli now appends the marker for you — so a path may end in a - `call microflow`, as above. Without it the two kinds fail in different places: - an interrupting path is **CE0105** at build, and a non-interrupting one builds - cleanly and then stops the runtime from starting ("Expected the flow to end with - an end event"). Use `jump to ` when the path should return to the task. +- **Every boundary path must end**, and mxcli ends one that runs to its last + activity as Studio Pro does (ako/mxcli#1712): an **interrupting** path with an + End — the timeout ends the workflow; `describe` shows `end workflow;` — and a + non-interrupting one with the end-of-path marker, as an interrupting one under a + parallel split or a non-interrupting path, where an End is CE1844. Unended, an + interrupting path is **CE0105** and a non-interrupting one stops the runtime from + starting. To carry on, end the path with `jump to ` (or ``). - **A notification boundary event** (Mendix 11.11+) fires when `notify workflow` targets it, so it takes a **name** instead of a delay: `boundary event interrupting notification Withdrawn 'Request withdrawn' { end workflow; }`. diff --git a/mdl-examples/bug-tests/1712-interrupting-boundary-path-end.mdl b/mdl-examples/bug-tests/1712-interrupting-boundary-path-end.mdl new file mode 100644 index 0000000000..6c1be2e714 --- /dev/null +++ b/mdl-examples/bug-tests/1712-interrupting-boundary-path-end.mdl @@ -0,0 +1,79 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1712 — "Interrupting boundary-event paths are ended with the +-- non-interrupting end-of-path marker; Studio Pro ends them with an End (or a +-- jump inside a split)" +-- ============================================================================ +-- +-- A boundary path that runs to its last activity is ended for the author. It +-- used to get Mendix's end-of-path marker whatever its kind; Studio Pro writes +-- that marker only for a NON-interrupting path and ends an interrupting one +-- with an End. Measured on the 11.13.0 runtime, the two behave alike (instance +-- Completed, the activity after the task never runs), so this is about the +-- shape Studio Pro shows. An End under a parallel split is CE1844, so a path +-- there keeps the marker. +-- +-- EXPECTED: `mxcli check` passes. After exec, `describe workflow` prints +-- `end workflow;` as the last statement of Review's interrupting path and of +-- the event sub-process task's, and nothing after the non-interrupting path or +-- the interrupting path inside the split. mx check: 0 errors (11.15.0). +-- Guard: sdk/workflows/workflow_interrupting_end_test.go, +-- mdl/executor/workflow_interrupting_end_test.go. +-- ============================================================================ + +create module BugBoundaryEnd1712; + +create persistent entity BugBoundaryEnd1712.Request ( + Title: String(200) +); + +create page BugBoundaryEnd1712.WF_Review +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + actionbutton btnDone (Caption: 'Done', Action: complete task 'Done') + } +}; + +create microflow BugBoundaryEnd1712.ACT_Remind ($Request: BugBoundaryEnd1712.Request) +begin + log info node 'BugBoundaryEnd1712' 'reminder'; +end; + +create or modify workflow BugBoundaryEnd1712.WF_Review + parameter $Context: BugBoundaryEnd1712.Request +begin + user task Review 'Review' page BugBoundaryEnd1712.WF_Review + targeting users xpath [Active = true()] + outcomes 'Done' { } + boundary event interrupting timer addDays([%CurrentDateTime%], 3) { + call microflow BugBoundaryEnd1712.ACT_Remind(Request = $WorkflowContext); + } + boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { + call microflow BugBoundaryEnd1712.ACT_Remind(Request = $WorkflowContext); + }; + parallel split split1 + path 1 { + user task SplitReview 'Split review' page BugBoundaryEnd1712.WF_Review + targeting users xpath [Active = true()] + outcomes 'Done' { } + boundary event interrupting timer addDays([%CurrentDateTime%], 3) { + call microflow BugBoundaryEnd1712.ACT_Remind(Request = $WorkflowContext); + }; + } + path 2 { call microflow BugBoundaryEnd1712.ACT_Remind(Request = $WorkflowContext); }; + + event subprocess ESP_Recheck 'Recheck' + on non interrupting notification recheckStart 'Recheck requested' { + user task Recheck 'Recheck' page BugBoundaryEnd1712.WF_Review + targeting users xpath [Active = true()] + outcomes 'Done' { } + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { + call microflow BugBoundaryEnd1712.ACT_Remind(Request = $WorkflowContext); + }; + }; +end workflow; diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index 88e7a9f0a8..8ee33b367a 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -91,6 +91,10 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { return err } + // Where an inserted interrupting boundary path may end in an End, as + // Studio Pro ends it (ako/mxcli#1712). + ends := newAlterEndPlacement(ctx, s) + // Apply operations sequentially for _, op := range s.Operations { switch o := op.(type) { @@ -143,6 +147,7 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { if len(acts) == 0 { return mdlerrors.NewValidation("failed to build new activity") } + ends.endInserted(acts, o.ActivityRef, o.AtPosition) if err := mutator.InsertAfterActivity(o.ActivityRef, o.AtPosition, acts); err != nil { return mdlerrors.NewBackend("insert after", err) } @@ -152,6 +157,7 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { if len(acts) == 0 { return mdlerrors.NewValidation("failed to build new activity") } + ends.endInserted(acts, o.ActivityRef, o.AtPosition) if err := mutator.InsertBeforeActivity(o.ActivityRef, o.AtPosition, acts); err != nil { return mdlerrors.NewBackend("insert before", err) } @@ -166,12 +172,14 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { if len(acts) == 0 { return mdlerrors.NewValidation("failed to build replacement activity") } + ends.endInserted(acts, o.ActivityRef, o.AtPosition) if err := mutator.ReplaceActivity(o.ActivityRef, o.AtPosition, acts); err != nil { return mdlerrors.NewBackend("replace activity", err) } case *ast.InsertOutcomeOp: acts := buildAndBindActivities(ctx, o.Activities) + ends.endInserted(acts, o.ActivityRef, o.AtPosition) if err := mutator.InsertOutcome(o.ActivityRef, o.AtPosition, o.OutcomeName, acts); err != nil { return mdlerrors.NewBackend("insert outcome", err) } @@ -194,6 +202,7 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { case *ast.InsertBranchOp: acts := buildAndBindActivities(ctx, o.Activities) + ends.endInserted(acts, o.ActivityRef, o.AtPosition) if err := mutator.InsertBranch(o.ActivityRef, o.AtPosition, o.Condition, acts); err != nil { return mdlerrors.NewBackend("insert branch", err) } @@ -210,7 +219,7 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { return mdlerrors.NewUnsupported("insert boundary event: a notification boundary event cannot be inserted with ALTER WORKFLOW yet — " + "restate the workflow with CREATE OR MODIFY WORKFLOW, which accepts `boundary event interrupting notification '' { … }`") } - acts := buildAndBindActivities(ctx, o.Activities) + acts := ends.endInsertedBoundaryPath(buildAndBindActivities(ctx, o.Activities), o.EventType, o.ActivityRef, o.AtPosition) if err := mutator.InsertBoundaryEvent(o.ActivityRef, o.AtPosition, o.EventType, normalizeWorkflowContextExpr(o.Delay), acts); err != nil { return mdlerrors.NewBackend("insert boundary event", err) } diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index 290709bdc3..473db3e644 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -219,6 +219,9 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { // a start event named like a main-flow activity is CE0495 — so they are // deduplicated together with the main flow's. wf.EventSubProcesses = buildEventSubProcesses(s.EventSubProcesses) + // An interrupting boundary path ends in an End where Studio Pro writes one, + // before names are carried and deduplicated (ako/mxcli#1712). + endBoundaryPathsLikeStudioPro(userActivities, wf.EventSubProcesses) named := append([]workflows.WorkflowActivity{}, userActivities...) for _, esp := range wf.EventSubProcesses { autoBindWorkflowParameters(ctx, esp.Flow.Activities, s.ParameterVar) diff --git a/mdl/executor/workflow_interrupting_end.go b/mdl/executor/workflow_interrupting_end.go new file mode 100644 index 0000000000..160de3f88f --- /dev/null +++ b/mdl/executor/workflow_interrupting_end.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// endBoundaryPathsLikeStudioPro ends the interrupting boundary paths of a +// workflow built from CREATE the way Studio Pro does — see +// workflows.EndInterruptingBoundaryPaths (ako/mxcli#1712). The main flow and +// each event sub-process body may hold an End (an End closing an interrupting +// path in a sub-process built clean on 11.15.0). +func endBoundaryPathsLikeStudioPro(main []workflows.WorkflowActivity, esps []*workflows.EventSubProcess) { + workflows.EndInterruptingBoundaryPaths(main, true, newWorkflowID) + for _, esp := range esps { + if esp != nil && esp.Flow != nil { + workflows.EndInterruptingBoundaryPaths(esp.Flow.Activities, true, newWorkflowID) + } + } +} + +// alterEndPlacement answers, for ALTER WORKFLOW, whether an End may close an +// interrupting boundary path placed at an activity of the stored workflow. An +// activity the stored workflow does not show unambiguously — one the statement +// inserts, one in an event sub-process — gets no: its path keeps the marker, +// which builds everywhere. +type alterEndPlacement struct { + wf *workflows.Workflow +} + +func newAlterEndPlacement(ctx *ExecContext, s *ast.AlterWorkflowStmt) *alterEndPlacement { + return &alterEndPlacement{wf: findStoredWorkflow(ctx, s.Name)} +} + +func (p *alterEndPlacement) endAllowedAt(ref string, atPos int) bool { + if p == nil || p.wf == nil || p.wf.Flow == nil { + return false + } + place, ok := resolveStoredPlacement(p.wf.Flow, ref, atPos) + return ok && !place.underSplit && !place.underNonInterrupting +} + +// endInserted ends the interrupting paths nested in activities an ALTER inserts +// at ref. +func (p *alterEndPlacement) endInserted(acts []workflows.WorkflowActivity, ref string, atPos int) { + workflows.EndInterruptingBoundaryPaths(acts, p.endAllowedAt(ref, atPos), newWorkflowID) +} + +// endInsertedBoundaryPath ends the path of a boundary event an ALTER inserts on +// ref: an End for an interrupting event where one may stand — the mutator then +// appends no marker — and the same rule for paths nested in it. +func (p *alterEndPlacement) endInsertedBoundaryPath(acts []workflows.WorkflowActivity, eventType, ref string, atPos int) []workflows.WorkflowActivity { + allowed := p.endAllowedAt(ref, atPos) + interrupting := strings.HasPrefix(eventType, "Interrupting") + workflows.EndInterruptingBoundaryPaths(acts, allowed && interrupting, newWorkflowID) + if allowed && interrupting { + return workflows.EndInterruptingPath(acts, newWorkflowID) + } + return acts +} diff --git a/mdl/executor/workflow_interrupting_end_test.go b/mdl/executor/workflow_interrupting_end_test.go new file mode 100644 index 0000000000..bbd8a2ece9 --- /dev/null +++ b/mdl/executor/workflow_interrupting_end_test.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// An interrupting boundary path written from MDL that runs to its last activity +// ended in the non-interrupting end-of-path marker; Studio Pro ends it with an +// End, and an evaluator read the two as mxcli "mixing" them (ako/mxcli#1712). + +func lastActivity(acts []workflows.WorkflowActivity) workflows.WorkflowActivity { + if len(acts) == 0 { + return nil + } + return acts[len(acts)-1] +} + +// CREATE: the whole workflow is known, so the main flow, outcomes and event +// sub-process bodies get an End and a parallel split keeps the marker. +func TestCreateWorkflow_InterruptingPathsEndLikeStudioPro(t *testing.T) { + stmt := parseWorkflowStmt(t, `create workflow M.W + parameter $Context: M.E +begin + user task T1 'T1' page M.P outcomes 'Done' { } + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { call microflow M.Act; } + boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { call microflow M.Act; }; + parallel split s1 + path 1 { + user task T2 'T2' page M.P outcomes 'Done' { } + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { call microflow M.Act; }; + } + path 2 { call microflow M.Act; }; + + event subprocess ESP 'ESP' on non interrupting notification espStart 'Start' { + user task T3 'T3' page M.P outcomes 'Done' { } + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { call microflow M.Act; }; + }; +end workflow;`) + acts := buildWorkflowActivities(stmt.Activities) + esps := buildEventSubProcesses(stmt.EventSubProcesses) + endBoundaryPathsLikeStudioPro(acts, esps) + + t1 := acts[0].(*workflows.UserTask) + if _, ok := lastActivity(t1.BoundaryEvents[0].Flow.Activities).(*workflows.EndWorkflowActivity); !ok { + t.Errorf("main-flow interrupting path ends with %T, want *workflows.EndWorkflowActivity", + lastActivity(t1.BoundaryEvents[0].Flow.Activities)) + } + if _, ok := lastActivity(t1.BoundaryEvents[1].Flow.Activities).(*workflows.EndOfBoundaryEventPathActivity); !ok { + t.Errorf("non-interrupting path ends with %T, want the marker", lastActivity(t1.BoundaryEvents[1].Flow.Activities)) + } + t2 := acts[1].(*workflows.ParallelSplitActivity).Outcomes[0].Flow.Activities[0].(*workflows.UserTask) + if _, ok := lastActivity(t2.BoundaryEvents[0].Flow.Activities).(*workflows.EndOfBoundaryEventPathActivity); !ok { + t.Errorf("interrupting path in a parallel split ends with %T, want the marker (an End is CE1844 there)", + lastActivity(t2.BoundaryEvents[0].Flow.Activities)) + } + var t3 *workflows.UserTask + for _, a := range esps[0].Flow.Activities { + if ut, ok := a.(*workflows.UserTask); ok { + t3 = ut + } + } + if t3 == nil { + t.Fatal("event sub-process has no user task") + } + if _, ok := lastActivity(t3.BoundaryEvents[0].Flow.Activities).(*workflows.EndWorkflowActivity); !ok { + t.Errorf("interrupting path in an event sub-process ends with %T, want *workflows.EndWorkflowActivity", + lastActivity(t3.BoundaryEvents[0].Flow.Activities)) + } +} + +// interruptingEndCtx is genericWfCtx's workflow with a user task inside the +// parallel split's first path. +func interruptingEndCtx(t *testing.T, mut *mock.MockWorkflowMutator) *ExecContext { + t.Helper() + mod := mkModule("Sales") + wf := mkWorkflow(mod.ID, "WF") + wf.Flow = &workflows.Flow{Activities: []workflows.WorkflowActivity{ + &workflows.UserTask{BaseWorkflowActivity: workflows.BaseWorkflowActivity{ + BaseElement: model.BaseElement{ID: "a1"}, Name: "task1"}}, + &workflows.ParallelSplitActivity{BaseWorkflowActivity: workflows.BaseWorkflowActivity{ + BaseElement: model.BaseElement{ID: "a3"}, Name: "split1"}, + Outcomes: []*workflows.ParallelSplitOutcome{ + {Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{ + &workflows.UserTask{BaseWorkflowActivity: workflows.BaseWorkflowActivity{ + BaseElement: model.BaseElement{ID: "a4"}, Name: "taskInSplit"}}, + }}}, + {}, + }}, + }} + h := mkHierarchy(mod) + withContainer(h, wf.ContainerID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListWorkflowsFunc: func() ([]*workflows.Workflow, error) { return []*workflows.Workflow{wf}, nil }, + OpenWorkflowForMutationFunc: func(model.ID) (backend.WorkflowMutator, error) { return mut, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + return ctx +} + +// ALTER … insert into X { boundary event interrupting … }: the path handed to +// the mutator ends in an End where the stored workflow allows one; the mutator +// then adds no marker (EndBoundaryEventPath leaves an ended path alone). +func TestAlterInsertBoundaryEvent_InterruptingPathEndsLikeStudioPro(t *testing.T) { + var got map[string][]workflows.WorkflowActivity + mut := &mock.MockWorkflowMutator{ + ResolveAlterTargetFunc: func(tg backend.AlterTarget) (backend.AlterTargetMatch, error) { + return backend.AlterTargetMatch{Kind: "user task", Name: backend.WorkflowTargetText(tg)}, nil + }, + InsertBoundaryEventFunc: func(ref string, _ int, eventType, _ string, acts []workflows.WorkflowActivity) error { + got[ref+" "+eventType] = acts + return nil + }, + } + for _, tc := range []struct { + src, key string + wantEnd bool + }{ + {`alter workflow Sales.WF { insert into task1 { boundary event interrupting timer addDays([%CurrentDateTime%], 1) { wait for notification wn1; } } };`, + "task1 InterruptingTimer", true}, + // CONTROL: a non-interrupting path keeps the marker the mutator appends. + {`alter workflow Sales.WF { insert into task1 { boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { wait for notification wn1; } } };`, + "task1 NonInterruptingTimer", false}, + // CONTROL: under a parallel split an End is CE1844, so the marker stays. + {`alter workflow Sales.WF { insert into taskInSplit { boundary event interrupting timer addDays([%CurrentDateTime%], 1) { wait for notification wn1; } } };`, + "taskInSplit InterruptingTimer", false}, + } { + got = map[string][]workflows.WorkflowActivity{} + if err := execAlterWf(t, interruptingEndCtx(t, mut), tc.src); err != nil { + t.Fatalf("%s: %v", tc.key, err) + } + acts, ok := got[tc.key] + if !ok { + t.Fatalf("%s: InsertBoundaryEvent was not called (calls: %v)", tc.key, got) + } + _, isEnd := lastActivity(acts).(*workflows.EndWorkflowActivity) + if isEnd != tc.wantEnd { + t.Errorf("%s: path ends with %T, want End = %v", tc.key, lastActivity(acts), tc.wantEnd) + } + } +} + +// ALTER … insert after X { user task … boundary event interrupting … }: a +// boundary event nested in inserted activities follows the same rule. +func TestAlterInsertAfter_NestedInterruptingPathEndsLikeStudioPro(t *testing.T) { + var acts []workflows.WorkflowActivity + mut := &mock.MockWorkflowMutator{ + ResolveAlterTargetFunc: func(tg backend.AlterTarget) (backend.AlterTargetMatch, error) { + return backend.AlterTargetMatch{Kind: "user task", Name: backend.WorkflowTargetText(tg)}, nil + }, + InsertAfterActivityFunc: func(_ string, _ int, a []workflows.WorkflowActivity) error { acts = a; return nil }, + } + for _, tc := range []struct { + target string + wantEnd bool + }{{"task1", true}, {"taskInSplit", false}} { + acts = nil + src := `alter workflow Sales.WF { insert after ` + tc.target + ` { + user task Escalate 'Escalate' outcomes 'Done' { } + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { wait for notification wn1; }; +} };` + if err := execAlterWf(t, interruptingEndCtx(t, mut), src); err != nil { + t.Fatalf("%s: %v", tc.target, err) + } + if len(acts) != 1 { + t.Fatalf("%s: inserted %d activities, want 1", tc.target, len(acts)) + } + path := acts[0].(*workflows.UserTask).BoundaryEvents[0].Flow.Activities + if _, isEnd := lastActivity(path).(*workflows.EndWorkflowActivity); isEnd != tc.wantEnd { + t.Errorf("after %s: path ends with %T, want End = %v", tc.target, lastActivity(path), tc.wantEnd) + } + } +} diff --git a/sdk/workflows/workflow.go b/sdk/workflows/workflow.go index 53a75f7e70..a2ac21bfd9 100644 --- a/sdk/workflows/workflow.go +++ b/sdk/workflows/workflow.go @@ -423,6 +423,101 @@ func EndBoundaryEventPath(flow *Flow, newID func() model.ID) *Flow { return flow } +// EndInterruptingBoundaryPaths gives each interrupting boundary-event path that +// runs to its last activity the End Studio Pro writes there, in place of the +// end-of-path marker EndBoundaryEventPath appended, wherever an End may stand +// (ako/mxcli#1712). +// +// The marker is Studio Pro's terminator for a NON-interrupting path; for an +// interrupting one its constructor writes an End outside a parallel split and a +// jump inside one. mxbuild builds both, and on the 11.13.0 runtime both complete +// the instance without running what follows the activity — so this changes what +// Studio Pro shows, not what runs. Measured clean on 11.15.0: an End closing an +// interrupting path in the main flow, under a decision outcome, on a call +// microflow and in an event sub-process. +// +// endAllowed is false where an End is CE1844: under a parallel split path or a +// non-interrupting boundary path, at any depth (PROPOSAL_workflow_end_activity, +// rows 5, 6 and 13). A path there keeps the marker, which builds: Studio Pro +// would aim a jump, and only the author knows where. A bare `timer` (no kind) +// keeps it too. +func EndInterruptingBoundaryPaths(activities []WorkflowActivity, endAllowed bool, newID func() model.ID) { + for _, act := range activities { + for _, be := range boundaryEventsOf(act) { + if be == nil || be.Flow == nil { + continue + } + interrupting := strings.HasPrefix(be.EventType, "Interrupting") + if interrupting && endAllowed { + be.Flow.Activities = EndInterruptingPath(be.Flow.Activities, newID) + } + EndInterruptingBoundaryPaths(be.Flow.Activities, endAllowed && !be.IsNonInterrupting(), newID) + } + switch a := act.(type) { + case *UserTask: + for _, o := range a.Outcomes { + if o != nil && o.Flow != nil { + EndInterruptingBoundaryPaths(o.Flow.Activities, endAllowed, newID) + } + } + case *CallMicroflowTask: + endInConditionOutcomes(a.Outcomes, endAllowed, newID) + case *SystemTask: + endInConditionOutcomes(a.Outcomes, endAllowed, newID) + case *ExclusiveSplitActivity: + endInConditionOutcomes(a.Outcomes, endAllowed, newID) + case *ParallelSplitActivity: + for _, o := range a.Outcomes { + if o != nil && o.Flow != nil { + EndInterruptingBoundaryPaths(o.Flow.Activities, false, newID) + } + } + } + } +} + +func endInConditionOutcomes(outcomes []ConditionOutcome, endAllowed bool, newID func() model.ID) { + for _, o := range outcomes { + if o != nil && o.GetFlow() != nil { + EndInterruptingBoundaryPaths(o.GetFlow().Activities, endAllowed, newID) + } + } +} + +// boundaryEventsOf is the boundary events an activity owns. +func boundaryEventsOf(act WorkflowActivity) []*BoundaryEvent { + switch a := act.(type) { + case *UserTask: + return a.BoundaryEvents + case *CallMicroflowTask: + return a.BoundaryEvents + case *CallWorkflowActivity: + return a.BoundaryEvents + case *WaitForNotificationActivity: + return a.BoundaryEvents + } + return nil +} + +// EndInterruptingPath ends an interrupting boundary path the way Studio Pro +// does: an End, replacing the end-of-path marker if one is last, unless the path +// already ends in a jump or an End. +func EndInterruptingPath(activities []WorkflowActivity, newID func() model.ID) []WorkflowActivity { + if n := len(activities); n > 0 { + switch activities[n-1].(type) { + case *JumpToActivity, *EndWorkflowActivity: + return activities + case *EndOfBoundaryEventPathActivity: + activities = activities[:n-1] + } + } + end := &EndWorkflowActivity{} + end.ID = newID() + end.Caption = "End" + end.Name = "End" + return append(activities, end) +} + // EndOfBoundaryEventPathActivity marks the end of a boundary event path (auto-generated by Mendix). type EndOfBoundaryEventPathActivity struct { BaseWorkflowActivity diff --git a/sdk/workflows/workflow_interrupting_end_test.go b/sdk/workflows/workflow_interrupting_end_test.go new file mode 100644 index 0000000000..a2e3a5d4c9 --- /dev/null +++ b/sdk/workflows/workflow_interrupting_end_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +package workflows + +import ( + "testing" +) + +// An interrupting boundary path that runs to its last activity was ended with +// the end-of-path marker, the terminator Studio Pro writes for a +// NON-interrupting path; Studio Pro ends an interrupting one with an End +// (ako/mxcli#1712). Both build, and on the 11.13.0 runtime both complete the +// instance without running what follows the task, so this is the shape Studio +// Pro shows, not a runtime change. + +// boundaryPath is a boundary event whose path is a call followed by the marker, +// as buildBoundaryEvents leaves every path that does not end on its own. +func boundaryPath(eventType string, nested ...*BoundaryEvent) *BoundaryEvent { + task := &UserTask{BoundaryEvents: nested} + return &BoundaryEvent{EventType: eventType, Flow: &Flow{Activities: []WorkflowActivity{ + &CallMicroflowTask{}, task, &EndOfBoundaryEventPathActivity{}, + }}} +} + +func lastOf(t *testing.T, be *BoundaryEvent) WorkflowActivity { + t.Helper() + if be.Flow == nil || len(be.Flow.Activities) == 0 { + t.Fatal("boundary path is empty") + } + return be.Flow.Activities[len(be.Flow.Activities)-1] +} + +func TestEndInterruptingBoundaryPaths_EndsAnInterruptingPathWithAnEnd(t *testing.T) { + for _, kind := range []string{"InterruptingTimer", "InterruptingNotification"} { + be := boundaryPath(kind) + EndInterruptingBoundaryPaths([]WorkflowActivity{&UserTask{BoundaryEvents: []*BoundaryEvent{be}}}, true, counterIDs()) + end, ok := lastOf(t, be).(*EndWorkflowActivity) + if !ok { + t.Fatalf("%s: path ends with %T, want *EndWorkflowActivity", kind, lastOf(t, be)) + } + if end.ID == "" || end.Name != "End" || end.Caption != "End" { + t.Errorf("%s: the End is %+v, want a fresh ID named and captioned End", kind, end.BaseWorkflowActivity) + } + if n := len(be.Flow.Activities); n != 3 { + t.Errorf("%s: %d activities, want the marker REPLACED, not followed", kind, n) + } + } +} + +// CONTROL: the marker stays wherever an End is not Studio Pro's terminator or +// cannot stand — a non-interrupting path (an End there is CE1844), and +// anything under a parallel split or a non-interrupting path (CE1844 at any +// depth), where Studio Pro would end it with a jump only the author can aim. +func TestEndInterruptingBoundaryPaths_KeepsTheMarkerWhereAnEndCannotStand(t *testing.T) { + ids := counterIDs() + + nonInt := boundaryPath("NonInterruptingTimer") + EndInterruptingBoundaryPaths([]WorkflowActivity{&CallMicroflowTask{BoundaryEvents: []*BoundaryEvent{nonInt}}}, true, ids) + if _, ok := lastOf(t, nonInt).(*EndOfBoundaryEventPathActivity); !ok { + t.Errorf("non-interrupting path ends with %T, want the marker", lastOf(t, nonInt)) + } + + inSplit := boundaryPath("InterruptingTimer") + split := &ParallelSplitActivity{Outcomes: []*ParallelSplitOutcome{{Flow: &Flow{Activities: []WorkflowActivity{ + &UserTask{BoundaryEvents: []*BoundaryEvent{inSplit}}, + }}}}} + EndInterruptingBoundaryPaths([]WorkflowActivity{split}, true, ids) + if _, ok := lastOf(t, inSplit).(*EndOfBoundaryEventPathActivity); !ok { + t.Errorf("interrupting path under a parallel split ends with %T, want the marker", lastOf(t, inSplit)) + } + + underNonInt := boundaryPath("InterruptingTimer") + outer := boundaryPath("NonInterruptingTimer", underNonInt) + EndInterruptingBoundaryPaths([]WorkflowActivity{&UserTask{BoundaryEvents: []*BoundaryEvent{outer}}}, true, ids) + if _, ok := lastOf(t, underNonInt).(*EndOfBoundaryEventPathActivity); !ok { + t.Errorf("interrupting path under a non-interrupting one ends with %T, want the marker", lastOf(t, underNonInt)) + } + + // The caller says the place itself cannot hold an End. + notAllowed := boundaryPath("InterruptingTimer") + EndInterruptingBoundaryPaths([]WorkflowActivity{&UserTask{BoundaryEvents: []*BoundaryEvent{notAllowed}}}, false, ids) + if _, ok := lastOf(t, notAllowed).(*EndOfBoundaryEventPathActivity); !ok { + t.Errorf("with endAllowed false the path ends with %T, want the marker", lastOf(t, notAllowed)) + } +} + +// An End is reachable through outcomes and through interrupting paths, at any +// depth (measured clean: a decision outcome, a user-task outcome). +func TestEndInterruptingBoundaryPaths_ReachesNestedPaths(t *testing.T) { + inner := boundaryPath("InterruptingTimer") + outer := boundaryPath("InterruptingTimer", inner) + viaOutcome := boundaryPath("InterruptingTimer") + decision := &ExclusiveSplitActivity{Outcomes: []ConditionOutcome{&BooleanConditionOutcome{ + Value: true, Flow: &Flow{Activities: []WorkflowActivity{&UserTask{BoundaryEvents: []*BoundaryEvent{viaOutcome}}}}, + }}} + task := &UserTask{BoundaryEvents: []*BoundaryEvent{outer}} + EndInterruptingBoundaryPaths([]WorkflowActivity{task, decision}, true, counterIDs()) + for name, be := range map[string]*BoundaryEvent{"outer": outer, "nested": inner, "in a decision outcome": viaOutcome} { + if _, ok := lastOf(t, be).(*EndWorkflowActivity); !ok { + t.Errorf("%s path ends with %T, want *EndWorkflowActivity", name, lastOf(t, be)) + } + } +} + +// A path that already ends — in a jump, or an End the author wrote — is left +// alone, and a bare `timer` (no kind, Mendix 10) keeps the marker. +func TestEndInterruptingBoundaryPaths_LeavesEndedAndUnkindedPaths(t *testing.T) { + jump := &BoundaryEvent{EventType: "InterruptingTimer", Flow: &Flow{Activities: []WorkflowActivity{&JumpToActivity{}}}} + bare := boundaryPath("Timer") + EndInterruptingBoundaryPaths([]WorkflowActivity{&UserTask{BoundaryEvents: []*BoundaryEvent{jump, bare}}}, true, counterIDs()) + if len(jump.Flow.Activities) != 1 { + t.Errorf("a path ending in a jump got %d activities, want it untouched", len(jump.Flow.Activities)) + } + if _, ok := lastOf(t, bare).(*EndOfBoundaryEventPathActivity); !ok { + t.Errorf("a bare timer's path ends with %T, want the marker", lastOf(t, bare)) + } +} + +func TestEndInterruptingPath(t *testing.T) { + ids := counterIDs() + got := EndInterruptingPath([]WorkflowActivity{&CallMicroflowTask{}}, ids) + if _, ok := got[len(got)-1].(*EndWorkflowActivity); !ok || len(got) != 2 { + t.Errorf("a path running to its end got %d activities ending %T, want an End appended", len(got), got[len(got)-1]) + } + if got := EndInterruptingPath(nil, ids); len(got) != 1 { + t.Errorf("an empty path got %d activities, want just the End", len(got)) + } + for _, last := range []WorkflowActivity{&JumpToActivity{}, &EndWorkflowActivity{}} { + if got := EndInterruptingPath([]WorkflowActivity{&CallMicroflowTask{}, last}, ids); len(got) != 2 { + t.Errorf("a path ending in %T got %d activities, want it untouched", last, len(got)) + } + } +} From 9f06acbe586ef86ad1754cd839ab520420b70025 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:18:14 +0000 Subject: [PATCH 19/40] docs(skills): task pages get a claim button and one complete-task button per outcome Agents following write-workflows generated task pages with no outcome buttons, so no user could finish a task, and added a microflow per outcome. The skill never mentioned the page action `complete task ''`. It said "there is no `complete task`" (true only of microflows) and recommended buttons that call a microflow doing `set task outcome`. A new Task pages section gives a template. Its claim step is measured: on the 11.13.0 runtime a `complete task` button clicked by a targeted user who has not claimed the task fails with "You can't complete this user task, it is not assigned to you"; after an Assign-to-me microflow the same click completes it and the outcome branch runs. So the template has one shared claim microflow and one button per outcome. The microflow section now says where each form belongs, and the skill advises against a status attribute with a microflow per transition unless the prompt asks for one. The template builds clean on 11.15.0. Fixes ako/mxcli#1709 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xfGeNtrQ5BJrH2Ew64rt5 --- ...ow-task-pages-without-outcome-buttons.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 51 ++++++++------ .../write-workflows/reference/task-pages.md | 66 +++++++++++++++++++ ...709-workflow-task-page-outcome-buttons.mdl | 55 ++++++++++++++++ 4 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/other/2026-10-26-workflow-task-pages-without-outcome-buttons.json create mode 100644 .claude/skills/mendix/write-workflows/reference/task-pages.md create mode 100644 mdl-examples/bug-tests/1709-workflow-task-page-outcome-buttons.mdl diff --git a/.claude/skills/fix-issue/findings/other/2026-10-26-workflow-task-pages-without-outcome-buttons.json b/.claude/skills/fix-issue/findings/other/2026-10-26-workflow-task-pages-without-outcome-buttons.json new file mode 100644 index 0000000000..aa0aca7d65 --- /dev/null +++ b/.claude/skills/fix-issue/findings/other/2026-10-26-workflow-task-pages-without-outcome-buttons.json @@ -0,0 +1 @@ +{"area": "skills", "date": "2026-10-26", "symptom": "Agents following the write-workflows skill generated task pages that show the task but have no outcome buttons, so no user could finish the task and every instance stalled at it; the same agents created one microflow per outcome (six for a two-task workflow where Maia made far fewer), explaining that each outcome 'needs a microflow to update the status'. check, mx check and the build were all clean", "cause": "The skill never mentioned the page action `complete task ''` (Pages$SetTaskOutcomeClientAction), which parses and builds; it said 'there is no `complete task`' (meaning no MICROFLOW statement of that name) and recommended page buttons that call a microflow doing `set task outcome`, i.e. a microflow per outcome", "file": "`.claude/skills/mendix/write-workflows/SKILL.md` (Task pages section, Microflow statements, Platform rules)", "insight": "**A sentence scoped to one context is read as global by an agent.** 'There is no complete task' was true of microflows and false of pages, and it steered every generated workflow away from the native button. Before documenting the button as the answer, measure it: on the 11.13.0 runtime (Prototype security, Playwright as the targeted user) a `complete task` button on a task the user is TARGETED for but has not CLAIMED shows 'You can't complete this user task, it is not assigned to you.' and the task stays InProgress — the same rule as `set task outcome` (MDL-WORKFLOW10). With an Assign-to-me microflow (Assignees = [%CurrentUser%]) clicked first, the same button completed the task and the outcome branch ran. So the template carries one shared claim microflow plus one button per outcome, not just the buttons the evaluator asked for. Probe traps: security Off has no user to complete as; a single outcome with a body is CE1876", "fix": "Skill only: a Task pages section with a measured template (claim microflow, one `complete task` button per outcome, page and microflow grants), the microflow section reworded to say where each form belongs, and a default against mirroring workflow progress into a status attribute with a microflow per transition", "refs": ["ako/mxcli#1709"]} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index c87d1d2be0..71f2befb31 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -10,19 +10,20 @@ description: "Author Mendix workflows in MDL — user tasks, decisions, parallel | lines | section | |---|---| -| 35-46 | When to Use This Skill | -| 47-117 | Syntax — CREATE WORKFLOW | -| 118-300 | Activities | -| 301-307 | DROP WORKFLOW | -| 308-373 | ALTER WORKFLOW | -| 374-394 | DESCRIBE round-trip | -| 395-415 | Activity names, and why `jump to` depends on them | -| 416-436 | Rewriting an existing workflow | -| 437-524 | Microflow statements for workflow tasks | -| 525-545 | System-module enumerations are synthesized, not stored | -| 546-670 | Platform rules | -| 671-701 | Observing a running workflow | -| 702-711 | Validate before presenting | +| 36-47 | When to Use This Skill | +| 48-118 | Syntax — CREATE WORKFLOW | +| 119-301 | Activities | +| 302-308 | DROP WORKFLOW | +| 309-374 | ALTER WORKFLOW | +| 375-395 | DESCRIBE round-trip | +| 396-416 | Activity names, and why `jump to` depends on them | +| 417-437 | Rewriting an existing workflow | +| 438-447 | Task pages | +| 448-535 | Microflow statements for workflow tasks | +| 536-556 | System-module enumerations are synthesized, not stored | +| 557-681 | Platform rules | +| 682-712 | Observing a running workflow | +| 713-722 | Validate before presenting | # Mendix Workflows Skill @@ -434,14 +435,24 @@ mxcli refuses the two cases where that would lose something: The safe way to change one activity in a workflow carrying hand-placed structure is `ALTER WORKFLOW`, which mutates in place and touches nothing else. +## Task pages + +A task page needs **a claim button and one `complete task ''` button per +outcome**. Mendix adds none, and without them every instance waits at the task +while mx check and the build pass. The button is the page's own action, no +microflow needed — but the user must have claimed the task: on 11.13.0 an +unclaimed task's button fails, "it is not assigned to you". Do not add a microflow +per outcome to mirror progress into a status attribute; the workflow records it. +Template and the measurement: [reference/task-pages.md](reference/task-pages.md). + ## Microflow statements for workflow tasks These run **inside a microflow** (not in the workflow body) and drive a running -workflow / its tasks. They are easy to miss — there is no `complete task`: +workflow / its tasks (on a page, a task is finished with `complete task`, above): - `set task outcome $Task 'Approve';` — completes a `System.WorkflowUserTask` with a - named outcome. This is how a microflow (e.g. a task page's button) finishes a task - and does the domain work; the outcome branches still record which one was chosen. + named outcome, when completing it is part of other work a microflow does; the + outcome branches still record which one was chosen. - `$Notified = notify workflow $Wf target Module.Workflow.Name;` resumes the element it names — a notification-started event sub-process, a notification activity, a notification boundary event or a wait for notification. **The target is @@ -454,9 +465,9 @@ workflow / its tasks. They are easy to miss — there is no `complete task`: `pause all` / `unpause all` after it is Studio Pro's "Pause / Unpause instances". A bare `lock workflow all` is refused (MDL-WF17) — it built as CE1825. -A common shape: the task page's buttons call a microflow that does the change and -then `set task outcome $Task ''`, leaving the workflow's outcome branch -bodies empty. +When one click must do domain work and complete the task, a button calls a +microflow that does the change and then `set task outcome $Task ''` — +a microflow per outcome, so prefer `complete task` buttons otherwise. **The outcome is a literal, by design.** Mendix stores `Microflows$SetTaskOutcomeAction.Outcome` by name — a reference to one outcome of @@ -619,7 +630,7 @@ values. The full list and the System **entities** are in `system-module`. Pro stores a list, not a flag), so it needs Mendix 11.6+; name the types on older projects. - A user task needs a **task page** to be useful; without one Mendix flags the - task (`CE1834`). Bind the page to `System.WorkflowUserTask`. + task (`CE1834`). Bind it to `System.WorkflowUserTask`; see "Task pages" above. - **The task page takes the TASK, not the workflow's context object.** It must declare a `System.WorkflowUserTask` parameter: a page with no parameters is `CE7410`, a page whose parameters are all something else (the usual mistake: diff --git a/.claude/skills/mendix/write-workflows/reference/task-pages.md b/.claude/skills/mendix/write-workflows/reference/task-pages.md new file mode 100644 index 0000000000..70e2a5bdad --- /dev/null +++ b/.claude/skills/mendix/write-workflows/reference/task-pages.md @@ -0,0 +1,66 @@ +# Workflow task pages — reference + +A task page is how a user finishes a task, so it needs a button for every +outcome. Mendix does not add them: a page that only shows the task leaves every +instance waiting at it, and mx check and the build pass. The button action is +the page's own **`complete task ''`**; no microflow is needed to complete +a task. + +## The user must claim the task first + +Targeting decides who may see a task; it does not assign it. Measured on the +11.13.0 runtime (Prototype security, the targeted user in a browser): a `complete +task` button on a task the user has not claimed shows "You can't complete this +user task, it is not assigned to you." and the task stays open. With an *Assign to +me* button clicked first, the same button completes it, the outcome's branch runs +and the workflow moves on. So a task page has one shared claim microflow and one +button per outcome. + +## Template + +```sql +mdl 1; +-- One per app, shared by every task page. +create microflow Approvals.ACT_Task_AssignToMe ($WorkflowUserTask: System.WorkflowUserTask) +begin + change $WorkflowUserTask (System.WorkflowUserTask_Assignees = [%CurrentUser%]); + commit $WorkflowUserTask; +end; + +create page Approvals.WF_ReviewRequest +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review request', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + dynamictext txtTask (Content: '{1}', ContentParams: ({1} = Name)) + actionbutton btnAssign (Caption: 'Assign to me', + Action: call microflow Approvals.ACT_Task_AssignToMe(WorkflowUserTask = $WorkflowUserTask)) + actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve', ButtonStyle: Success) + actionbutton btnReject (Caption: 'Reject', Action: complete task 'Reject', ButtonStyle: Danger) + } +}; + +grant view on page Approvals.WF_ReviewRequest to Approvals.User; +grant execute on microflow Approvals.ACT_Task_AssignToMe to Approvals.User; +``` + +It builds clean on 11.15.0 (ako/mxcli#1709). + +- **The outcome names must match the user task's** (`outcomes 'Approve' { … } + 'Reject' { … }`), one button each. +- A task whose targeting can only yield one user can use `auto assign single + user` on the user task instead of the claim button. +- To show the context object, give the page a second parameter of the context + entity alongside `$WorkflowUserTask`. + +## Keep the microflows to what the workflow has to do + +A workflow cannot change an object itself, so an outcome that must write data, or +a timer that must notify someone, does need a `call microflow`. But do not add one +per outcome only to mirror progress into a status attribute: `System.Workflow` and +`System.WorkflowUserTask` already record the state and the outcome chosen, and the +task page's `complete task` buttons need none. Add a status attribute and its +microflows only when the prompt asks for one. diff --git a/mdl-examples/bug-tests/1709-workflow-task-page-outcome-buttons.mdl b/mdl-examples/bug-tests/1709-workflow-task-page-outcome-buttons.mdl new file mode 100644 index 0000000000..8c69da78e1 --- /dev/null +++ b/mdl-examples/bug-tests/1709-workflow-task-page-outcome-buttons.mdl @@ -0,0 +1,55 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1709 — "write-workflows skill steers agents away from `complete +-- task` buttons: generated task pages cannot complete their tasks, and every +-- outcome gets its own microflow" +-- ============================================================================ +-- +-- The skill's task-page template. A page's `complete task ''` button +-- finishes a task without a microflow — but only for a user who has CLAIMED it. +-- Measured on the 11.13.0 runtime: clicked by a targeted user who has not, it +-- shows "You can't complete this user task, it is not assigned to you." and the +-- task stays open; after the Assign-to-me button, the same click completes it +-- and the outcome branch runs. +-- +-- EXPECTED: `mxcli check` passes; exec then mx check: 0 errors (11.15.0). +-- In the running app: open the task, click Assign to me, then Approve — the +-- workflow completes. +-- ============================================================================ + +create module BugTaskPage1709; +create module role BugTaskPage1709.User; +create persistent entity BugTaskPage1709.Request ( Title: String(200) ); +-- One per app, shared by every task page. +create microflow BugTaskPage1709.ACT_Task_AssignToMe ($WorkflowUserTask: System.WorkflowUserTask) +begin + change $WorkflowUserTask (System.WorkflowUserTask_Assignees = [%CurrentUser%]); + commit $WorkflowUserTask; +end; + +create page BugTaskPage1709.WF_ReviewRequest +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review request', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + dynamictext txtTask (Content: '{1}', ContentParams: ({1} = Name)) + actionbutton btnAssign (Caption: 'Assign to me', + Action: call microflow BugTaskPage1709.ACT_Task_AssignToMe(WorkflowUserTask = $WorkflowUserTask)) + actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve', ButtonStyle: Success) + actionbutton btnReject (Caption: 'Reject', Action: complete task 'Reject', ButtonStyle: Danger) + } +}; + +grant view on page BugTaskPage1709.WF_ReviewRequest to BugTaskPage1709.User; +grant execute on microflow BugTaskPage1709.ACT_Task_AssignToMe to BugTaskPage1709.User; + +create or modify workflow BugTaskPage1709.WF_Request + parameter $Context: BugTaskPage1709.Request +begin + user task Review 'Review request' page BugTaskPage1709.WF_ReviewRequest + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + outcomes 'Approve' { } 'Reject' { }; +end workflow; From 31979b3dc3756f567b46cb860306a77cd4b03478 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:37:45 +0000 Subject: [PATCH 20/40] feat(check): warn when a user task's page cannot complete one of its outcomes (MDL-WF20) A task page is where a user finishes a task, and Mendix adds no buttons to it. A page that only shows the task passed check, mx check and the build, and every instance then waited at the task. An agent eval generated three such pages. MDL-WF20 warns per user or multi-user task, listing the outcomes its page cannot complete. An outcome counts as reachable through a `complete task` button or a button whose microflow reaches `set task outcome` (script or stored, through calls). Whatever the check cannot read stays silent: an unloadable microflow, a snippet, a page the script alters. The script pass runs without a project. Under -p, a stored page is read raw, with the key names taken from a dump of a real page, and only what the script pass missed is added. It is a warning, not a refusal: an outcome can be completed off the page on purpose. The syntax help, quick reference and write-workflows skill name the rule. Fixes ako/mxcli#1710 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xfGeNtrQ5BJrH2Ew64rt5 --- ...-task-page-cannot-complete-an-outcome.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 12 +- .../write-workflows/reference/task-pages.md | 4 +- cmd/mxcli/cmd_check.go | 1 + cmd/mxcli/exec_preflight.go | 3 + cmd/mxcli/syntax/features_workflow.go | 7 +- docs/01-project/MDL_QUICK_REFERENCE.md | 2 +- .../1710-task-page-outcome-buttons-check.mdl | 75 ++++ mdl/executor/validate_program.go | 4 + mdl/executor/validate_task_page_outcomes.go | 321 ++++++++++++++++++ .../validate_task_page_outcomes_test.go | 223 ++++++++++++ 11 files changed, 644 insertions(+), 9 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-task-page-cannot-complete-an-outcome.json create mode 100644 mdl-examples/bug-tests/1710-task-page-outcome-buttons-check.mdl create mode 100644 mdl/executor/validate_task_page_outcomes.go create mode 100644 mdl/executor/validate_task_page_outcomes_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-task-page-cannot-complete-an-outcome.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-task-page-cannot-complete-an-outcome.json new file mode 100644 index 0000000000..87abcd3971 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-task-page-cannot-complete-an-outcome.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "A user task whose page has no button completing one of its outcomes — a page that only shows the task, as an agent eval generated three times — passed `check`, `check -p --references`, mx check and the build; every instance then waited at the task, with nothing anywhere reporting why", "cause": "No check looked at what a task page can DO: the task-signature check verifies the page's parameter (CE7410/CE7412) and stops there, and Mendix itself never requires an outcome button", "file": "`mdl/executor/validate_task_page_outcomes.go` (ValidateTaskPageOutcomes in validate_program.go; StoredTaskPageOutcomeViolations in cmd/mxcli/cmd_check.go and exec_preflight.go)", "insight": "Judge reachability, not presence: an outcome counts as choosable through a `complete task` button OR a button whose microflow reaches `set task outcome` through any calls (script or stored, depth-bounded), and anything the check cannot read — a stored microflow it cannot load, a snippet, a page the script alters — makes it silent, because a warning on a page that works trains people to ignore the rule. Stored pages are read raw (GetRawUnitByName 'page'): the key names were taken from a dump of a real stored page (Forms$SetTaskOutcomeClientAction.OutcomeValue, Forms$MicroflowAction.MicroflowSettings.Microflow), not from the metamodel. Two passes, like MDL-WORKFLOW10: the script pass runs without a project; the stored pass returns only what the script pass did not, so check -p prints each once. It stays a WARNING — an outcome can be completed off the page on purpose — and it does not police the claim, which the skill's template handles (measured: an unclaimed task's complete-task button fails at runtime)", "fix": "MDL-WF20 warning per user/multi-user task listing the outcomes its page cannot complete, with the button to add", "refs": ["ako/mxcli#1710", "ako/mxcli#1709"], "rules": ["MDL-WF20"]} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index 71f2befb31..f1274fdc81 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -438,12 +438,12 @@ is `ALTER WORKFLOW`, which mutates in place and touches nothing else. ## Task pages A task page needs **a claim button and one `complete task ''` button per -outcome**. Mendix adds none, and without them every instance waits at the task -while mx check and the build pass. The button is the page's own action, no -microflow needed — but the user must have claimed the task: on 11.13.0 an -unclaimed task's button fails, "it is not assigned to you". Do not add a microflow -per outcome to mirror progress into a status attribute; the workflow records it. -Template and the measurement: [reference/task-pages.md](reference/task-pages.md). +outcome**. Mendix adds none; without them every instance waits at the task while +mx check and the build pass (`mxcli check` warns MDL-WF20). The button is the +page's own action, no microflow needed — but the user must have claimed the task: +on 11.13.0 an unclaimed task's button fails, "it is not assigned to you". Do not +add a microflow per outcome to mirror progress into a status attribute; the +workflow records it. Template and the measurement: [reference/task-pages.md](reference/task-pages.md). ## Microflow statements for workflow tasks diff --git a/.claude/skills/mendix/write-workflows/reference/task-pages.md b/.claude/skills/mendix/write-workflows/reference/task-pages.md index 70e2a5bdad..7f03ae6091 100644 --- a/.claude/skills/mendix/write-workflows/reference/task-pages.md +++ b/.claude/skills/mendix/write-workflows/reference/task-pages.md @@ -2,7 +2,9 @@ A task page is how a user finishes a task, so it needs a button for every outcome. Mendix does not add them: a page that only shows the task leaves every -instance waiting at it, and mx check and the build pass. The button action is +instance waiting at it, and mx check and the build pass. `mxcli check` warns +**MDL-WF20** when a task's page has no button that completes one of its outcomes +(a `complete task` button, or a microflow that reaches `set task outcome`). The button action is the page's own **`complete task ''`**; no microflow is needed to complete a task. diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index 3cf51ee419..d5584d0906 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -336,6 +336,7 @@ func runCheckFileAgainst(cmd *cobra.Command, filePath, against string) int { // already stored that way: re-applying it changes nothing. violations = executor.SettleStoredVoidReturnValues(violations, prog, executor.NewStoredVoidReturns(b)) violations = append(violations, executor.StoredTaskClaimViolations(prog, b)...) + violations = append(violations, executor.StoredTaskPageOutcomeViolations(prog, b)...) // MDL-VERSION01: a statement the project's Mendix version cannot // store. The gates are the ones exec's handlers refuse with, from // one table (executor.statementVersionGates), so check -p no diff --git a/cmd/mxcli/exec_preflight.go b/cmd/mxcli/exec_preflight.go index 5d3eac4327..d385e133ee 100644 --- a/cmd/mxcli/exec_preflight.go +++ b/cmd/mxcli/exec_preflight.go @@ -43,6 +43,9 @@ func execPreflight(exec *executor.Executor, prog *ast.Program, projectPath, scri // A called microflow the script does not create is read from the // project for MDL-WORKFLOW10 (ako/mxcli#943). violations = append(violations, executor.StoredTaskClaimViolations(prog, b)...) + // A task page the script does not create is read from the project + // for MDL-WF20 (ako/mxcli#1710). + violations = append(violations, executor.StoredTaskPageOutcomeViolations(prog, b)...) } // A statement the project's Mendix version cannot store // (MDL-VERSION01): the gate its handler applies when reached, diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 7148ddaa49..7425453471 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -518,9 +518,14 @@ func init() { Keywords: []string{ "set task outcome", "task outcome", "complete task", "complete user task", "outcome variable", "dynamic outcome", "claim and complete", "claim task", - "MDL-WORKFLOW10", + "MDL-WORKFLOW10", "task page", "outcome button", "MDL-WF20", }, Syntax: "SET TASK OUTCOME $UserTask 'OutcomeName';\n\n" + + "-- On a task page the same is a button action, with no microflow:\n" + + "-- actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve')\n" + + "-- A task page needs one per outcome; check warns MDL-WF20 when a task's\n" + + "-- page cannot complete one of its outcomes. Either way the user must\n" + + "-- have claimed the task.\n\n" + "-- A microflow statement (not a nanoflow one). $UserTask is a\n" + "-- System.WorkflowUserTask; the outcome is one of that task's outcomes.\n" + "-- The outcome is a quoted LITERAL, by design: Mendix stores it by name\n" + diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index e05c78b713..b3898dc920 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -773,7 +773,7 @@ mandatory and a misplaced clause failed with a token error **Notifying a workflow** (a microflow statement): `[$Notified =] notify workflow $Workflow target Module.Workflow.ElementName;` — the element is a notification-started event sub-process's start, a notification activity, a notification boundary event or a wait for notification, and mxcli resolves which. The target is required (CE0166, MDL-WF16). -**Completing a user task** (a microflow statement): `set task outcome $Task 'Approve';` — the outcome is a quoted **literal**, by design: Mendix stores it by name, a reference to one of the task's outcomes resolved at build time, so a variable is a parse error. A shared claim-and-complete flow branches once per outcome (`if $Outcome = 'Approve' then set task outcome $Task 'Approve'; else …`). Claim the task first (MDL-WORKFLOW10). +**Completing a user task** (a microflow statement): `set task outcome $Task 'Approve';` — the outcome is a quoted **literal**, by design: Mendix stores it by name, a reference to one of the task's outcomes resolved at build time, so a variable is a parse error. A shared claim-and-complete flow branches once per outcome (`if $Outcome = 'Approve' then set task outcome $Task 'Approve'; else …`). Claim the task first (MDL-WORKFLOW10). On a task page the same is the button action `complete task 'Approve'`, one per outcome; check warns MDL-WF20 when a task's page cannot complete one of its outcomes, and the user must have claimed the task either way. **Event sub-processes**, after the main body: `event subprocess [''] on [non] interrupting notification [] [''] { … };` (11.8+) or `… on [non] interrupting timer [as ] [caption ''] { … };` (11.13+). The body's End is implicit; a `jump to` stays in its own sub-process (CE6682, MDL-WF05); a timer needs its expression (CE0126, MDL-WF14). diff --git a/mdl-examples/bug-tests/1710-task-page-outcome-buttons-check.mdl b/mdl-examples/bug-tests/1710-task-page-outcome-buttons-check.mdl new file mode 100644 index 0000000000..5c2b80e5de --- /dev/null +++ b/mdl-examples/bug-tests/1710-task-page-outcome-buttons-check.mdl @@ -0,0 +1,75 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1710 — "check --references: warn when a user task's page has no +-- way to complete one of its outcomes" +-- ============================================================================ +-- +-- A task page is where a user finishes a task, and Mendix adds no buttons to +-- it. A page that only shows the task passes check, mx check and the build, +-- and every instance waits at the task — an agent eval generated three. +-- `mxcli check` now warns MDL-WF20 for each task whose page cannot complete one +-- of its outcomes, with a `complete task` button or through a microflow that +-- reaches `set task outcome`. It reads a page stored in the project under -p. +-- +-- EXPECTED: `mxcli check` passes with exactly one MDL-WF20 warning, on +-- BugTaskPage1710.WF_Review, naming the outcome 'Reject' (WF_Approve's page has +-- a button per outcome, through a microflow for 'Reject'). +-- Guard: mdl/executor/validate_task_page_outcomes_test.go. +-- ============================================================================ + +create module BugTaskPage1710; + +create persistent entity BugTaskPage1710.Request ( + Title: String(200) +); + +create microflow BugTaskPage1710.ACT_Reject ($WorkflowUserTask: System.WorkflowUserTask) +begin + change $WorkflowUserTask (System.WorkflowUserTask_Assignees = [%CurrentUser%]); + commit $WorkflowUserTask; + set task outcome $WorkflowUserTask 'Reject'; +end; + +-- Both outcomes reachable: a button, and a microflow. +create page BugTaskPage1710.WF_Approve_Task +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Approve', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve') + actionbutton btnReject (Caption: 'Reject', + Action: call microflow BugTaskPage1710.ACT_Reject(WorkflowUserTask = $WorkflowUserTask)) + } +}; + +-- Only 'Approve' reachable: MDL-WF20 for 'Reject'. +create page BugTaskPage1710.WF_Review_Task +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve') + } +}; + +create or modify workflow BugTaskPage1710.WF_Approve + parameter $Context: BugTaskPage1710.Request +begin + user task Approve 'Approve' page BugTaskPage1710.WF_Approve_Task + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + outcomes 'Approve' { } 'Reject' { }; +end workflow; + +create or modify workflow BugTaskPage1710.WF_Review + parameter $Context: BugTaskPage1710.Request +begin + user task Review 'Review' page BugTaskPage1710.WF_Review_Task + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + outcomes 'Approve' { } 'Reject' { }; +end workflow; diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index 31939750c1..5fbf7f6dc3 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -236,6 +236,10 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { // appearing to do nothing. violations = append(violations, ValidateTaskClaims(prog)...) + // Warn (MDL-WF20) when a user task's page has no button that completes one + // of its outcomes: the instance waits at the task and nothing reports it. + violations = append(violations, ValidateTaskPageOutcomes(prog)...) + // Flag control-bar buttons that pass $currentObject — a control bar is // not row-scoped, so the argument is unbound (CE1571) at build time. violations = append(violations, ValidatePageButtonContext(prog)...) diff --git a/mdl/executor/validate_task_page_outcomes.go b/mdl/executor/validate_task_page_outcomes.go new file mode 100644 index 0000000000..4965a0fc76 --- /dev/null +++ b/mdl/executor/validate_task_page_outcomes.go @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// MDL-WF20: a user task whose page offers no way to choose one of its +// outcomes. The task page is the only place a user finishes a task, and Mendix +// adds no buttons to it; a page that only shows the task passes check, mx check +// and the build, and every instance stalls at the task. An agent eval generated +// three such pages (ako/mxcli#1709, #1710). +// +// A page can choose an outcome with a `complete task ''` button +// (Forms$SetTaskOutcomeClientAction), or with a button calling a microflow that +// reaches `set task outcome … ''`, directly or through the microflows +// it calls. What the check cannot see is not a finding: a microflow it cannot +// read, a snippet, a page the script alters. It is a warning, not a refusal — +// an outcome can be completed off the page on purpose. +const taskPageOutcomeRule = "MDL-WF20" + +// maxTaskOutcomeCallDepth bounds the microflow calls followed from a button. +const maxTaskOutcomeCallDepth = 6 + +// ValidateTaskPageOutcomes checks the user tasks whose page the script creates. +func ValidateTaskPageOutcomes(prog *ast.Program) []linter.Violation { + return validateTaskPageOutcomes(prog, nil) +} + +// StoredTaskPageOutcomeViolations is the MDL-WF20 warnings that need the +// project: a task page stored in it, or a button calling a stored microflow. +// Only what ValidateTaskPageOutcomes did not already report is returned, so the +// two can be printed together. +func StoredTaskPageOutcomeViolations(prog *ast.Program, b backend.FullBackend) []linter.Violation { + if prog == nil || b == nil { + return nil + } + base := map[string]bool{} + for _, v := range validateTaskPageOutcomes(prog, nil) { + base[v.Location.DocumentName+"\x00"+v.Message] = true + } + var out []linter.Violation + for _, v := range validateTaskPageOutcomes(prog, b) { + if !base[v.Location.DocumentName+"\x00"+v.Message] { + out = append(out, v) + } + } + return out +} + +func validateTaskPageOutcomes(prog *ast.Program, b backend.FullBackend) []linter.Violation { + if prog == nil { + return nil + } + r := &taskOutcomeResolver{ + b: b, + pages: map[string]*ast.CreatePageStmtV3{}, + flows: map[string]*ast.CreateMicroflowStmt{}, + alters: map[string]bool{}, + } + for _, stmt := range prog.Statements { + switch s := stmt.(type) { + case *ast.CreatePageStmtV3: + r.pages[strings.ToLower(s.Name.String())] = s + case *ast.CreateMicroflowStmt: + r.flows[strings.ToLower(s.Name.String())] = s + case *ast.AlterPageStmt: + r.alters[strings.ToLower(s.PageName.String())] = true + } + } + + var out []linter.Violation + for _, stmt := range prog.Statements { + s, ok := stmt.(*ast.CreateWorkflowStmt) + if !ok { + continue + } + walkWorkflowActivities(workflowStatementActivities(s), func(act ast.WorkflowActivityNode) { + n, ok := act.(*ast.WorkflowUserTaskNode) + if !ok || len(n.Outcomes) == 0 { + return + } + page := n.Page.String() + if page == "" || page == "." { + return + } + reach, ok := r.pageReach(page) + if !ok || reach.any { + return + } + var missing []string + for _, o := range n.Outcomes { + if !reach.outcomes[o.Caption] { + missing = append(missing, "'"+o.Caption+"'") + } + } + if len(missing) == 0 { + return + } + out = append(out, taskPageOutcomeViolation(s, n, page, missing)) + }) + } + return out +} + +func taskPageOutcomeViolation(s *ast.CreateWorkflowStmt, n *ast.WorkflowUserTaskNode, page string, missing []string) linter.Violation { + kind := "user task" + if n.IsMultiUser { + kind = "multi user task" + } + outcomes := strings.Join(missing, ", ") + first := strings.Trim(missing[0], "'") + return linter.Violation{ + RuleID: taskPageOutcomeRule, + Severity: linter.SeverityWarning, + Location: linter.Location{ + Module: s.Name.Module, + DocumentType: "workflow", + DocumentName: s.Name.Name, + }, + Message: fmt.Sprintf( + "%s %s in workflow %s: its page %s has no button that completes %s %s — no user can choose %s "+ + "from the task page — mx check and the build pass, and the instance waits at the task", + kind, workflowUserTaskLabel(n), s.Name.String(), page, + plural(len(missing), "the outcome", "the outcomes"), outcomes, plural(len(missing), "it", "them")), + Suggestion: fmt.Sprintf( + "Add one button per outcome to %s: `actionbutton btn%s (Caption: '%s', Action: complete task '%s')`, "+ + "or a button calling a microflow that runs `set task outcome $WorkflowUserTask '%s'`. "+ + "The user must claim the task first (see the write-workflows skill, Task pages).", + page, identifierSafe(first), first, first, first), + } +} + +// identifierSafe keeps the letters and digits of s, for a suggested widget name. +func identifierSafe(s string) string { + var b strings.Builder + for _, r := range s { + if r < 128 && (r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + if b.Len() == 0 { + return "Outcome" + } + return b.String() +} + +// taskReach is the outcomes a page or microflow can complete a task with; any +// is set when something it does could not be followed. +type taskReach struct { + outcomes map[string]bool + any bool +} + +func (t *taskReach) merge(o taskReach) { + if t.outcomes == nil { + t.outcomes = map[string]bool{} + } + for k := range o.outcomes { + t.outcomes[k] = true + } + t.any = t.any || o.any +} + +func (t *taskReach) add(outcome string) { + if t.outcomes == nil { + t.outcomes = map[string]bool{} + } + t.outcomes[outcome] = true +} + +// taskOutcomeResolver answers taskReach for pages and microflows from the +// script and, when b is set, the project. +type taskOutcomeResolver struct { + b backend.FullBackend // nil: the script only + pages map[string]*ast.CreatePageStmtV3 + flows map[string]*ast.CreateMicroflowStmt + alters map[string]bool +} + +// pageReach is what the named page's buttons can complete; ok is false when the +// page cannot be judged (not created by the script and not readable, or altered). +func (r *taskOutcomeResolver) pageReach(qn string) (taskReach, bool) { + key := strings.ToLower(qn) + if r.alters[key] { + return taskReach{}, false + } + if s := r.pages[key]; s != nil { + var reach taskReach + forEachWidget(s, func(w *ast.WidgetV3, _ string) { + if strings.EqualFold(w.Type, "snippetcall") { + reach.any = true + } + for _, v := range w.Properties { + if a, ok := v.(*ast.ActionV3); ok { + reach.merge(r.actionReach(a)) + } + } + }) + return reach, true + } + if r.b == nil { + return taskReach{}, false + } + info, err := r.b.GetRawUnitByName("page", qn) + if err != nil || info == nil || len(info.Contents) == 0 { + return taskReach{}, false + } + var doc bson.D + if err := bson.Unmarshal(info.Contents, &doc); err != nil { + return taskReach{}, false + } + var reach taskReach + r.walkStoredPage(doc, &reach) + return reach, true +} + +func (r *taskOutcomeResolver) actionReach(a *ast.ActionV3) taskReach { + var reach taskReach + switch a.Type { + case "completeTask": + reach.add(a.OutcomeValue) + case "microflow": + reach.merge(r.flowReach(a.Target, 0, map[string]bool{})) + } + return reach +} + +// walkStoredPage collects the page document's complete-task actions and the +// microflows its actions call; a snippet is not followed. +func (r *taskOutcomeResolver) walkStoredPage(node any, reach *taskReach) { + switch v := node.(type) { + case bson.D: + switch bsonString(v, "$Type") { + case "Forms$SetTaskOutcomeClientAction": + reach.add(bsonString(v, "OutcomeValue")) + case "Forms$MicroflowAction": + for _, e := range v { + if s, ok := e.Value.(bson.D); ok && e.Key == "MicroflowSettings" { + if mf := bsonString(s, "Microflow"); mf != "" { + reach.merge(r.flowReach(mf, 0, map[string]bool{})) + } + } + } + case "Forms$SnippetCallWidget": + reach.any = true + } + for _, e := range v { + r.walkStoredPage(e.Value, reach) + } + case bson.A: + for _, item := range v { + r.walkStoredPage(item, reach) + } + } +} + +// flowReach is the outcomes a microflow — and the microflows it calls — can +// complete a task with. One it cannot read could complete any. +func (r *taskOutcomeResolver) flowReach(qn string, depth int, visited map[string]bool) taskReach { + key := strings.ToLower(qn) + if depth > maxTaskOutcomeCallDepth { + return taskReach{any: true} + } + if visited[key] { + return taskReach{} + } + visited[key] = true + + var reach taskReach + if s := r.flows[key]; s != nil { + walkFlowStatements(s.Body, func(st ast.MicroflowStatement) { + switch x := st.(type) { + case *ast.SetTaskOutcomeStmt: + reach.add(x.OutcomeValue) + case *ast.CallMicroflowStmt: + reach.merge(r.flowReach(x.MicroflowName.String(), depth+1, visited)) + } + }) + return reach + } + if r.b == nil { + return taskReach{any: true} + } + objs, found := (&StoredCommitEvents{b: r.b}).flowObjects(false, qn) + if !found { + return taskReach{any: true} + } + var walk func(*microflows.MicroflowObjectCollection) + walk = func(oc *microflows.MicroflowObjectCollection) { + if oc == nil { + return + } + for _, o := range oc.Objects { + switch a := o.(type) { + case *microflows.ActionActivity: + switch act := a.Action.(type) { + case *microflows.SetTaskOutcomeAction: + reach.add(act.OutcomeValue) + case *microflows.MicroflowCallAction: + if act.MicroflowCall != nil { + reach.merge(r.flowReach(act.MicroflowCall.Microflow, depth+1, visited)) + } + } + case *microflows.LoopedActivity: + walk(a.ObjectCollection) + } + } + } + walk(objs) + return reach +} diff --git a/mdl/executor/validate_task_page_outcomes_test.go b/mdl/executor/validate_task_page_outcomes_test.go new file mode 100644 index 0000000000..e489028bb4 --- /dev/null +++ b/mdl/executor/validate_task_page_outcomes_test.go @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// A user task whose page offers no way to choose one of its outcomes passed +// check, mx check and the build, and every instance stalled at the task: an +// agent eval generated three such pages (ako/mxcli#1709, #1710). + +const taskPageWorkflow = `create workflow M.W + parameter $Context: M.Request +begin + user task Review 'Review' page M.WF_Review outcomes 'Approve' { } 'Reject' { }; +end workflow; +` + +func taskPageProgram(t *testing.T, src string) *ast.Program { + t.Helper() + prog, errs := visitor.Build("mdl 1;\n" + src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + return prog +} + +func taskPage(widgets string) string { + return `create page M.WF_Review +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { +` + widgets + ` + } +}; +` +} + +func wf20(vs []linter.Violation) []linter.Violation { + var out []linter.Violation + for _, v := range vs { + if v.RuleID == taskPageOutcomeRule { + out = append(out, v) + } + } + return out +} + +func TestTaskPageOutcomes_ReportsAnOutcomeThePageCannotChoose(t *testing.T) { + for _, tc := range []struct { + name, widgets string + missing []string + }{ + {"a page with no outcome buttons", ` dynamictext txt (Content: 'Review')`, []string{"'Approve'", "'Reject'"}}, + {"a page with one of two", ` actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve')`, []string{"'Reject'"}}, + } { + t.Run(tc.name, func(t *testing.T) { + vs := wf20(ValidateTaskPageOutcomes(taskPageProgram(t, taskPage(tc.widgets)+taskPageWorkflow))) + if len(vs) != 1 { + t.Fatalf("got %d MDL-WF20 warnings, want 1: %v", len(vs), vs) + } + if vs[0].Severity != linter.SeverityWarning { + t.Errorf("severity = %v, want a warning (an outcome may be completed off the page on purpose)", vs[0].Severity) + } + for _, w := range append([]string{"Review", "M.WF_Review", "complete task"}, tc.missing...) { + if !strings.Contains(vs[0].Message+vs[0].Suggestion, w) { + t.Errorf("warning should mention %s: %s / %s", w, vs[0].Message, vs[0].Suggestion) + } + } + }) + } +} + +// CONTROL: every page here lets the user choose each outcome. +func TestTaskPageOutcomes_SilentWhenEveryOutcomeIsReachable(t *testing.T) { + for _, tc := range []struct{ name, src string }{ + {"a complete task button per outcome", taskPage(` + actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve') + actionbutton btnReject (Caption: 'Reject', Action: complete task 'Reject')`)}, + {"a microflow that sets the other outcome", taskPage(` + actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve') + actionbutton btnReject (Caption: 'Reject', Action: call microflow M.ACT_Reject(Task = $WorkflowUserTask))`) + ` +create microflow M.ACT_Reject ($Task: System.WorkflowUserTask) +begin + change $Task (System.WorkflowUserTask_Assignees = [%CurrentUser%]); + commit $Task; + set task outcome $Task 'Reject'; +end; +`}, + {"through a second microflow", taskPage(` + actionbutton btnDecide (Caption: 'Decide', Action: call microflow M.ACT_Decide(Task = $WorkflowUserTask))`) + ` +create microflow M.ACT_Decide ($Task: System.WorkflowUserTask) +begin + call microflow M.ACT_Complete(Task = $Task); +end; +create microflow M.ACT_Complete ($Task: System.WorkflowUserTask) +begin + if $Task/Name = 'x' then + set task outcome $Task 'Approve'; + else + set task outcome $Task 'Reject'; + end if; +end; +`}, + } { + t.Run(tc.name, func(t *testing.T) { + if vs := wf20(ValidateTaskPageOutcomes(taskPageProgram(t, tc.src+taskPageWorkflow))); len(vs) != 0 { + t.Errorf("reported, but every outcome is reachable: %v", vs) + } + }) + } +} + +// What the script cannot show is not a finding: a button calling a microflow +// that is not in the script, a snippet the page embeds, a page the script does +// not create, and a page the script alters. +func TestTaskPageOutcomes_SilentWhenThePageCannotBeJudged(t *testing.T) { + for _, tc := range []struct{ name, src string }{ + {"a microflow the script does not create", taskPage(` + actionbutton btnAll (Caption: 'Decide', Action: call microflow M.ACT_Elsewhere(Task = $WorkflowUserTask))`)}, + {"a snippet", taskPage(` + snippetcall scButtons (Snippet: M.TaskButtons)`)}, + {"a page not in the script", ``}, + } { + t.Run(tc.name, func(t *testing.T) { + if vs := wf20(ValidateTaskPageOutcomes(taskPageProgram(t, tc.src+taskPageWorkflow))); len(vs) != 0 { + t.Errorf("reported a page the script cannot show: %v", vs) + } + }) + } +} + +// A multi-user task is completed through the same page actions. +func TestTaskPageOutcomes_CoversMultiUserTasks(t *testing.T) { + src := taskPage(` actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve')`) + `create workflow M.W + parameter $Context: M.Request +begin + multi user task Board 'Board' page M.WF_Review outcomes 'Approve' { } 'Reject' { }; +end workflow; +` + if vs := wf20(ValidateTaskPageOutcomes(taskPageProgram(t, src))); len(vs) != 1 { + t.Fatalf("got %d warnings, want 1 for the multi-user task: %v", len(vs), vs) + } +} + +// storedTaskPage is a stored page document with the given actions, shaped like +// the one mxbuild 11.15.0's project held for the skill's template page. +func storedTaskPage(t *testing.T, actions ...bson.D) []byte { + t.Helper() + var widgets bson.A + widgets = append(widgets, int32(2)) + for i, a := range actions { + widgets = append(widgets, bson.D{{Key: "$Type", Value: "Forms$ActionButton"}, {Key: "Name", Value: "b" + string(rune('a'+i))}, {Key: "Action", Value: a}}) + } + doc := bson.D{ + {Key: "$Type", Value: "Forms$Page"}, + {Key: "FormCall", Value: bson.D{{Key: "Arguments", Value: bson.A{int32(2), bson.D{{Key: "Widgets", Value: widgets}}}}}}, + } + b, err := bson.Marshal(doc) + if err != nil { + t.Fatal(err) + } + return b +} + +func completeTaskAction(outcome string) bson.D { + return bson.D{{Key: "$Type", Value: "Forms$SetTaskOutcomeClientAction"}, {Key: "OutcomeValue", Value: outcome}} +} + +// With a project, a page the script does not create is read from it. +func TestStoredTaskPageOutcomes_ReadsAStoredPage(t *testing.T) { + prog := taskPageProgram(t, taskPageWorkflow) + for _, tc := range []struct { + name string + page []byte + wantOne bool + }{ + {"one of two outcomes", storedTaskPage(t, completeTaskAction("Approve")), true}, + {"both outcomes", storedTaskPage(t, completeTaskAction("Approve"), completeTaskAction("Reject")), false}, + {"a snippet", storedTaskPage(t, completeTaskAction("Approve"), bson.D{{Key: "$Type", Value: "Forms$SnippetCallWidget"}}), false}, + } { + t.Run(tc.name, func(t *testing.T) { + mb := &mock.MockBackend{ + GetRawUnitByNameFunc: func(kind, qn string) (*types.RawUnitInfo, error) { + if kind == "page" && qn == "M.WF_Review" { + return &types.RawUnitInfo{QualifiedName: qn, Contents: tc.page}, nil + } + return nil, nil + }, + } + vs := wf20(StoredTaskPageOutcomeViolations(prog, mb)) + if got := len(vs) == 1; got != tc.wantOne || len(vs) > 1 { + t.Errorf("got %v, want one warning = %v", vs, tc.wantOne) + } + }) + } +} + +// The stored pass adds only what the script-only pass did not report, so check +// -p, which prints both, says it once. +func TestStoredTaskPageOutcomes_DoesNotRepeatTheScriptPass(t *testing.T) { + prog := taskPageProgram(t, taskPage(` actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve')`)+taskPageWorkflow) + if n := len(wf20(ValidateTaskPageOutcomes(prog))); n != 1 { + t.Fatalf("control: the script pass reported %d, want 1", n) + } + if vs := wf20(StoredTaskPageOutcomeViolations(prog, &mock.MockBackend{})); len(vs) != 0 { + t.Errorf("the stored pass repeated the script pass's warning: %v", vs) + } +} From 94e67b76374f9cfa1fe54e7feed6f6949e8e865e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:18:22 +0000 Subject: [PATCH 21/40] test: strike WithdrawConfirmation from the TestApp allowlist WorkflowCommons.MyInitiatedWorkflowView_WithdrawConfirmation now passes getput: its text area stores NumberOfLines 2, which the writer used to replace with 5 (#977 item 2). The allowlist may only shrink. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- mdl/roundtrip/testapp_allowlist_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index 4cebdada44..09db9b7dba 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -117,7 +117,6 @@ var testAppKnownFailures = map[string]knownFailure{ "page Administration.Account_Overview": {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.DefaultWorkflowAdmin": {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.ManageTaskAssignments": {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.MyInitiatedWorkflowView_WithdrawConfirmation": {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.MyInitiatedWorkflows": {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.TaskAssignmentHelper_UserTask_Reassign_TargetUserOptions": {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.TaskAssignmentHelper_UserTask_Retarget_TargetUserOptions": {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"}, From 313f4d9c90585bf1d85699339f6f5b628c39f3ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:16:14 +0000 Subject: [PATCH 22/40] fix: bound a text area's NumberOfLines to the stored 32-bit range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged the new NumberOfLines path: a value parsed with strconv.Atoi reached the writer's int32 conversion with no upper bound, so a large value would wrap. The builder and `alter page … set` now refuse a value above math.MaxInt32, and the writer converts only a value in range. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- mdl/backend/modelsdk/widget_write.go | 5 ++-- mdl/backend/pagemutator/mutator.go | 2 +- mdl/executor/cmd_pages_builder_v3_widgets.go | 28 ++++++++++++++----- .../textarea_lines_autocomplete_977_test.go | 19 +++++++++++++ 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 4120e652f4..2b3ba4b628 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -4,6 +4,7 @@ package modelsdkbackend import ( "fmt" + "math" "strings" "go.mongodb.org/mongo-driver/v2/bson" @@ -685,8 +686,8 @@ func widgetToGen(w pages.Widget) (element.Element, error) { // all 58 text areas in PedApp and TestApp. Autocomplete was not written // at all, and a stored 0 lines came back as 5 (ako/mxcli#977). lines := int32(5) - if x.NumberOfLines != nil { - lines = int32(*x.NumberOfLines) + if n := x.NumberOfLines; n != nil && *n >= 0 && *n <= math.MaxInt32 { + lines = int32(*n) } g.SetNumberOfLines(lines) g.SetAutocomplete(x.Autocomplete == nil || *x.Autocomplete) diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 7ab41a29cf..58aadd7579 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -3417,7 +3417,7 @@ func setDynamicTextNativeTextStyleMut(widget bson.D, value any) error { func setTextAreaPropertyMut(widget bson.D, propName string, value any) error { if strings.EqualFold(propName, "NumberOfLines") { n, ok := formattingInt(value) - if !ok || n < 0 { + if !ok || n < 0 || n > math.MaxInt32 { return fmt.Errorf("NumberOfLines is a whole number of 0 or more, not %v", value) } if _, isInt32 := bsonnav.DGet(widget, "NumberOfLines").(int32); isInt32 { diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index 48b567a90e..b3de00c267 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "math" "regexp" "strconv" "strings" @@ -1834,20 +1835,33 @@ func lookupPropCI(w *ast.WidgetV3, key string) (any, bool) { return nil, false } -// nonNegativeInt reads a whole-number property value of 0 or more. +// nonNegativeInt reads a whole-number property value from 0 up to the largest +// value the stored 32-bit integer holds. func nonNegativeInt(v any) (int, bool) { + var i int switch n := v.(type) { case int: - return n, n >= 0 + i = n case int64: - return int(n), n >= 0 + if n > math.MaxInt32 { + return 0, false + } + i = int(n) case float64: - return int(n), n >= 0 && n == float64(int(n)) + if n != math.Trunc(n) || n > math.MaxInt32 { + return 0, false + } + i = int(n) case string: - i, err := strconv.Atoi(strings.TrimSpace(n)) - return i, err == nil && i >= 0 + parsed, err := strconv.Atoi(strings.TrimSpace(n)) + if err != nil { + return 0, false + } + i = parsed + default: + return 0, false } - return 0, false + return i, i >= 0 && i <= math.MaxInt32 } // propBool coerces a widget property value to a bool. GetBoolProp cannot be used diff --git a/mdl/executor/textarea_lines_autocomplete_977_test.go b/mdl/executor/textarea_lines_autocomplete_977_test.go index c02472247d..f6a1fa3b8d 100644 --- a/mdl/executor/textarea_lines_autocomplete_977_test.go +++ b/mdl/executor/textarea_lines_autocomplete_977_test.go @@ -120,3 +120,22 @@ func TestTextAreaNumberOfLinesAndAutocomplete_StudioProValuesSurviveDescribeExec t.Errorf("after describe → exec: NumberOfLines %v, Autocomplete %v; want 0, false", lines, auto) } } + +// NumberOfLines is stored as a 32-bit integer, so a larger value is refused +// rather than truncated on the way to the writer. +func TestNonNegativeIntBounds(t *testing.T) { + for _, tc := range []struct { + in any + want int + ok bool + }{ + {2, 2, true}, {0, 0, true}, {"7", 7, true}, {float64(3), 3, true}, + {-1, 0, false}, {"x", 0, false}, {1.5, 0, false}, + {"2147483647", 2147483647, true}, {"2147483648", 0, false}, {int64(1) << 40, 0, false}, + } { + got, ok := nonNegativeInt(tc.in) + if ok != tc.ok || (ok && got != tc.want) { + t.Errorf("nonNegativeInt(%v) = %d, %v; want %d, %v", tc.in, got, ok, tc.want, tc.ok) + } + } +} From 4de9d965e2d7ba82178d7ed9bc3108459e41ea98 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:21:01 +0000 Subject: [PATCH 23/40] fix: parse a text area's NumberOfLines at the stored 32-bit width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL still saw the writer's int32 conversion as unbounded: the range check was on *n and the conversion a separate dereference, so the guard did not count. The value is now parsed with strconv.ParseInt(s, 10, 32) in the builder and in `alter page … set`, the model holds an int32, and the writer converts nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- ...et_textarea_lines_autocomplete_977_test.go | 4 +-- mdl/backend/modelsdk/widget_write.go | 5 ++- mdl/backend/pagemutator/mutator.go | 10 ++++-- mdl/executor/cmd_pages_builder_v3_widgets.go | 34 +++++++++---------- .../textarea_lines_autocomplete_977_test.go | 6 ++-- sdk/pages/pages_widgets_input.go | 2 +- 6 files changed, 31 insertions(+), 30 deletions(-) diff --git a/mdl/backend/modelsdk/widget_textarea_lines_autocomplete_977_test.go b/mdl/backend/modelsdk/widget_textarea_lines_autocomplete_977_test.go index 44d3982d56..212dba79ad 100644 --- a/mdl/backend/modelsdk/widget_textarea_lines_autocomplete_977_test.go +++ b/mdl/backend/modelsdk/widget_textarea_lines_autocomplete_977_test.go @@ -15,11 +15,11 @@ import ( // Measured in PedApp and TestApp: NumberOfLines 5 on 52 of 58 text areas, 2 on // 4 and 0 on 2; Autocomplete true on all 58. func TestTextAreaNumberOfLinesAndAutocomplete_Written(t *testing.T) { - n := func(v int) *int { return &v } + n := func(v int32) *int32 { return &v } b := func(v bool) *bool { return &v } for _, tc := range []struct { name string - lines *int + lines *int32 autocomplete *bool wantLines int64 wantAuto bool diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 2b3ba4b628..b2a7d3645a 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -4,7 +4,6 @@ package modelsdkbackend import ( "fmt" - "math" "strings" "go.mongodb.org/mongo-driver/v2/bson" @@ -686,8 +685,8 @@ func widgetToGen(w pages.Widget) (element.Element, error) { // all 58 text areas in PedApp and TestApp. Autocomplete was not written // at all, and a stored 0 lines came back as 5 (ako/mxcli#977). lines := int32(5) - if n := x.NumberOfLines; n != nil && *n >= 0 && *n <= math.MaxInt32 { - lines = int32(*n) + if x.NumberOfLines != nil { + lines = *x.NumberOfLines } g.SetNumberOfLines(lines) g.SetAutocomplete(x.Autocomplete == nil || *x.Autocomplete) diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 58aadd7579..5ee4c9096a 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -3417,13 +3417,17 @@ func setDynamicTextNativeTextStyleMut(widget bson.D, value any) error { func setTextAreaPropertyMut(widget bson.D, propName string, value any) error { if strings.EqualFold(propName, "NumberOfLines") { n, ok := formattingInt(value) - if !ok || n < 0 || n > math.MaxInt32 { + if !ok { + return fmt.Errorf("NumberOfLines is a whole number of 0 or more, not %v", value) + } + lines, err := strconv.ParseInt(strconv.Itoa(n), 10, 32) + if err != nil || lines < 0 { return fmt.Errorf("NumberOfLines is a whole number of 0 or more, not %v", value) } if _, isInt32 := bsonnav.DGet(widget, "NumberOfLines").(int32); isInt32 { - bsonnav.DSet(widget, "NumberOfLines", int32(n)) + bsonnav.DSet(widget, "NumberOfLines", int32(lines)) } else { - bsonnav.DSet(widget, "NumberOfLines", int64(n)) + bsonnav.DSet(widget, "NumberOfLines", lines) } return nil } diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index b3de00c267..9c03d6b5d6 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -622,7 +622,7 @@ func (pb *pageBuilder) buildTextAreaV3(w *ast.WidgetV3) (*pages.TextArea, error) // NumberOfLines and Autocomplete; unset keeps Studio Pro's 5 lines and // Autocomplete on (ako/mxcli#977). if raw, ok := lookupPropCI(w, "NumberOfLines"); ok { - n, ok := nonNegativeInt(raw) + n, ok := nonNegativeInt32(raw) if !ok { return nil, mdlerrors.NewValidationf("textarea %q: NumberOfLines is a whole number of 0 or more, not %v", w.Name, raw) } @@ -1835,33 +1835,31 @@ func lookupPropCI(w *ast.WidgetV3, key string) (any, bool) { return nil, false } -// nonNegativeInt reads a whole-number property value from 0 up to the largest -// value the stored 32-bit integer holds. -func nonNegativeInt(v any) (int, bool) { - var i int +// nonNegativeInt32 reads a whole-number property value of 0 or more that fits +// the 32-bit integer Mendix stores. A string is parsed at that width, so no +// value is narrowed after the fact. +func nonNegativeInt32(v any) (int32, bool) { + var s string switch n := v.(type) { case int: - i = n + s = strconv.Itoa(n) case int64: - if n > math.MaxInt32 { - return 0, false - } - i = int(n) + s = strconv.FormatInt(n, 10) case float64: - if n != math.Trunc(n) || n > math.MaxInt32 { + if n != math.Trunc(n) { return 0, false } - i = int(n) + s = strconv.FormatFloat(n, 'f', -1, 64) case string: - parsed, err := strconv.Atoi(strings.TrimSpace(n)) - if err != nil { - return 0, false - } - i = parsed + s = strings.TrimSpace(n) default: return 0, false } - return i, i >= 0 && i <= math.MaxInt32 + i, err := strconv.ParseInt(s, 10, 32) + if err != nil || i < 0 { + return 0, false + } + return int32(i), true } // propBool coerces a widget property value to a bool. GetBoolProp cannot be used diff --git a/mdl/executor/textarea_lines_autocomplete_977_test.go b/mdl/executor/textarea_lines_autocomplete_977_test.go index f6a1fa3b8d..6cf17c29c2 100644 --- a/mdl/executor/textarea_lines_autocomplete_977_test.go +++ b/mdl/executor/textarea_lines_autocomplete_977_test.go @@ -126,16 +126,16 @@ func TestTextAreaNumberOfLinesAndAutocomplete_StudioProValuesSurviveDescribeExec func TestNonNegativeIntBounds(t *testing.T) { for _, tc := range []struct { in any - want int + want int32 ok bool }{ {2, 2, true}, {0, 0, true}, {"7", 7, true}, {float64(3), 3, true}, {-1, 0, false}, {"x", 0, false}, {1.5, 0, false}, {"2147483647", 2147483647, true}, {"2147483648", 0, false}, {int64(1) << 40, 0, false}, } { - got, ok := nonNegativeInt(tc.in) + got, ok := nonNegativeInt32(tc.in) if ok != tc.ok || (ok && got != tc.want) { - t.Errorf("nonNegativeInt(%v) = %d, %v; want %d, %v", tc.in, got, ok, tc.want, tc.ok) + t.Errorf("nonNegativeInt32(%v) = %d, %v; want %d, %v", tc.in, got, ok, tc.want, tc.ok) } } } diff --git a/sdk/pages/pages_widgets_input.go b/sdk/pages/pages_widgets_input.go index 1b9bb03c03..0e87f69f6f 100644 --- a/sdk/pages/pages_widgets_input.go +++ b/sdk/pages/pages_widgets_input.go @@ -84,7 +84,7 @@ type TextArea struct { // NumberOfLines is Forms$TextArea.NumberOfLines; nil writes Studio Pro's // default, 5 (52 of 58 text areas in PedApp and TestApp). Studio Pro also // stores 0, which an int defaulting on zero could not express (ako/mxcli#977). - NumberOfLines *int `json:"numberOfLines,omitempty"` + NumberOfLines *int32 `json:"numberOfLines,omitempty"` // Autocomplete is Forms$TextArea.Autocomplete; nil writes true, the // default (ako/mxcli#977). Autocomplete *bool `json:"autocomplete,omitempty"` From 89404ac880c92b888327d100e1e4dcbf62faf33f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:01:39 +0000 Subject: [PATCH 24/40] fix: carry a flow call's output mappings through a rewrite (#977 item 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A button whose call-microflow or call-nanoflow action has output mappings (Mendix 11.5) lost them on describe → exec: MDL has no syntax for them, so the rebuild wrote the empty list Studio Pro stores on every other call, exec reported "Replaced page", and mx check said nothing. The empty-list drop #977 originally recorded was fixed on 2026-10-10. canon.CarryOutputMappings now keeps the stored mappings on a rebuild, paired by the owning named element and path, when the rebuilt list is empty and the call still names the same flow. Like the translation carry, it runs in Reconcile before the id transplant. Calling another flow drops them, since they read the old flow's result. describe prints a comment under the button saying how many it has. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- ...-10-26-button-output-mappings-carried.json | 1 + .../977-3-button-output-mappings-carried.mdl | 31 +++ .../button_output_mappings_977_test.go | 119 ++++++++++++ mdl/executor/cmd_pages_describe.go | 3 + mdl/executor/cmd_pages_describe_output.go | 18 ++ mdl/executor/cmd_pages_describe_parse.go | 22 +++ modelsdk/canon/identity.go | 6 + modelsdk/canon/outputmappings.go | 183 ++++++++++++++++++ modelsdk/canon/outputmappings_977_test.go | 123 ++++++++++++ 9 files changed, 506 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/modelsdk/2026-10-26-button-output-mappings-carried.json create mode 100644 mdl-examples/bug-tests/977-3-button-output-mappings-carried.mdl create mode 100644 mdl/executor/button_output_mappings_977_test.go create mode 100644 modelsdk/canon/outputmappings.go create mode 100644 modelsdk/canon/outputmappings_977_test.go diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-10-26-button-output-mappings-carried.json b/.claude/skills/fix-issue/findings/modelsdk/2026-10-26-button-output-mappings-carried.json new file mode 100644 index 0000000000..5a75f63682 --- /dev/null +++ b/.claude/skills/fix-issue/findings/modelsdk/2026-10-26-button-output-mappings-carried.json @@ -0,0 +1 @@ +{"area": "modelsdk/canon", "date": "2026-10-26", "symptom": "A button whose flow call has output mappings (Mendix 11.5, Forms$MicroflowSettings / Forms$CallNanoflowClientAction .OutputMappings) lost them on describe → exec: describe printed nothing for them, exec reported Replaced page and wrote the empty list; mx check silent", "cause": "MDL has no syntax for output mappings and no fixture or Studio Pro sample shows a populated one, so the rebuild could only write the empty list Studio Pro stores on every other call (written since 2026-10-10, which fixed the empty-list drop #977 originally recorded)", "file": "modelsdk/canon/outputmappings.go (CarryOutputMappings, wired into Reconcile before TransplantIDs), mdl/executor/cmd_pages_describe_parse.go + _output.go (countOutputMappings, outputMappingsNote)", "insight": "When a property has no syntax and no measured shape, carry it rather than invent it: pair the stored and rebuilt holder by owning named element + path (exact, list-free, unique — the same addressing as the translation carry), carry only into an EMPTY rebuilt list and only while the call names the same flow, and keep ids verbatim so the canonical form sees the same elements. Length-changing carries must run before TransplantIDs. Check an issue's remaining scope against main before building: #977's recorded symptom (the empty list) had been fixed six days after it was filed; the propdist tally (180/180 empty) and a BSON-patched probe separated that from the live non-empty case", "refs": ["ako/mxcli#977"]} diff --git a/mdl-examples/bug-tests/977-3-button-output-mappings-carried.mdl b/mdl-examples/bug-tests/977-3-button-output-mappings-carried.mdl new file mode 100644 index 0000000000..602f5a776f --- /dev/null +++ b/mdl-examples/bug-tests/977-3-button-output-mappings-carried.mdl @@ -0,0 +1,31 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#977 item 3 — "A button's `OutputMappings` are not round-tripped." +-- +-- The empty list Studio Pro stores on every flow call is written since +-- 2026-10-10 (widget_write_mapping_markers_test.go). A NON-empty list — Mendix +-- 11.5's mapping of a flow's result back onto the page — has no MDL, so a +-- describe → exec of a page whose button had one reported "Replaced page" and +-- dropped it; mx check is silent about it. +-- +-- A rebuild now keeps the stored mappings while the button still calls the +-- same flow (canon.CarryOutputMappings), and describe prints a comment under +-- the button: +-- +-- -- btnGo: 1 output mapping, which MDL cannot show; kept on a rewrite while +-- the button calls the same flow +-- +-- Calling another flow drops them, since they read the old flow's result. The +-- mappings themselves are authored in Studio Pro; this script makes the button +-- they hang on. +-- ============================================================================ + +create module BugOutputMappings; + +create microflow BugOutputMappings.ACT_Go () +begin +end; + +create page BugOutputMappings.Go_Page (Title: 'Go', Layout: Atlas_Core.Atlas_Default) { + actionbutton btnGo (Caption: 'Go', Action: call microflow BugOutputMappings.ACT_Go) +}; diff --git a/mdl/executor/button_output_mappings_977_test.go b/mdl/executor/button_output_mappings_977_test.go new file mode 100644 index 0000000000..b1fd4999a2 --- /dev/null +++ b/mdl/executor/button_output_mappings_977_test.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#977 item 3: "A button's `OutputMappings` are not round-tripped." +// The empty list Studio Pro stores on every call has been written since +// 2026-10-10 (widget_write_mapping_markers_test.go). A NON-empty list — Mendix +// 11.5's mapping of a flow's result back onto the page — has no MDL, so +// describe → exec dropped it: "Replaced page", the mapping gone, mx check +// silent. The stored mappings are now carried while the button calls the same +// flow, and describe says the button has them. +package executor + +import ( + "context" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const om977Prelude = `create microflow MyFirstModule.ACT_Go () begin end; +create microflow MyFirstModule.ACT_Other () begin end; +` + +func om977Page(flow string) string { + return "create or modify page MyFirstModule.Go_Page (Title: 'Go', Layout: Atlas_Core.Atlas_Default) {\n" + + " actionbutton btnGo (Caption: 'Go', Action: call microflow MyFirstModule." + flow + ")\n};\n" +} + +// storedOutputMappings returns the Expression of every stored Forms$OutputMapping +// on the page. +func storedOutputMappings(t *testing.T, exec *Executor, page string) []string { + t.Helper() + _, raw := pageUnitByName(t, exec, page) + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + var out []string + walkStoredDocs(doc, func(d bson.D) { + if afGet(d, "$Type") == "Forms$OutputMapping" { + s, _ := afGet(d, "Expression").(string) + out = append(out, s) + } + }) + return out +} + +// addOutputMapping BSON-patches one Forms$OutputMapping onto every +// OutputMappings list of the page — the Studio Pro 11.5 shape per the +// metamodel (Expression, AttributeRef, SourceAttributeRef, SourceVariable). +func addOutputMapping(t *testing.T, exec *Executor, page string) { + t.Helper() + id, raw := pageUnitByName(t, exec, page) + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + walkStoredDocs(doc, func(d bson.D) { + for i := range d { + if d[i].Key != "OutputMappings" { + continue + } + owner := afGet(d, "$ID").(bson.Binary) + data := append([]byte(nil), owner.Data...) + data[len(data)-1] ^= 0x5A // a distinct element id + d[i].Value = append(d[i].Value.(bson.A), bson.D{ + {Key: "$ID", Value: bson.Binary{Subtype: owner.Subtype, Data: data}}, + {Key: "$Type", Value: "Forms$OutputMapping"}, + {Key: "AttributeRef", Value: nil}, + {Key: "Expression", Value: "'mapped'"}, + {Key: "SourceAttributeRef", Value: nil}, + {Key: "SourceVariable", Value: nil}, + }) + } + }) + b, err := bson.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := exec.newExecContext(context.Background()).Backend.UpdateRawUnit(string(id), b); err != nil { + t.Fatal(err) + } +} + +func TestButtonOutputMappingsSurviveDescribeExec(t *testing.T) { + exec, out := openPedAppFixture(t) + if err := afRun(t, exec, "mdl 1;\n"+om977Prelude+om977Page("ACT_Go")); err != nil { + t.Fatalf("exec: %v", err) + } + addOutputMapping(t, exec, "Go_Page") + + out.Reset() + if err := afRun(t, exec, "describe page MyFirstModule.Go_Page;"); err != nil { + t.Fatal(err) + } + described := out.String() + if !strings.Contains(described, "-- btnGo: 1 output mapping") { + t.Errorf("describe does not say the button has output mappings MDL cannot show:\n%s", described) + } + out.Reset() + if err := afRun(t, exec, described); err != nil { + t.Fatalf("re-exec of the description: %v", err) + } + if !strings.Contains(out.String(), "Unchanged page") { + t.Errorf("re-executing the description was not a no-op:\n%s", out.String()) + } + if got := storedOutputMappings(t, exec, "Go_Page"); len(got) != 1 || got[0] != "'mapped'" { + t.Errorf("after describe → exec the output mappings are %q, want ['mapped']", got) + } + + // Control: a button that now calls another microflow does not keep + // mappings that read the old one's result. + if err := afRun(t, exec, "mdl 1;\n"+om977Page("ACT_Other")); err != nil { + t.Fatalf("exec with another microflow: %v", err) + } + if got := storedOutputMappings(t, exec, "Go_Page"); len(got) != 0 { + t.Errorf("mappings for ACT_Go were carried onto a call of ACT_Other: %q", got) + } +} diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 9a26300bcd..aebd86267a 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -880,6 +880,9 @@ type rawWidget struct { // (ako/mxcli#977). NumberOfLines *int64 Autocomplete *bool + // OutputMappings counts the output mappings (Mendix 11.5) of a button's + // flow call, which MDL cannot show (ako/mxcli#977). + OutputMappings int // ValidationExpression / ValidationMessage are the two fields of // Forms$WidgetValidation, the per-widget validation Studio Pro stores on // input widgets. diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index fcb311651d..8dddaf99b8 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -609,6 +609,9 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if note := widgetIconNote(ctx, w); note != "" { fmt.Fprintf(ctx.Output, "%s%s\n", prefix, note) } + if note := outputMappingsNote(w); note != "" { + fmt.Fprintf(ctx.Output, "%s%s\n", prefix, note) + } case "Forms$Text", "Pages$Text": props := []string{} @@ -2262,3 +2265,18 @@ func appendReadOnlyStyle(props []string, w rawWidget) []string { } return props } + +// outputMappingsNote says a button's flow call has output mappings (Mendix +// 11.5), which MDL has no syntax for. A rewrite keeps the stored ones while the +// button calls the same flow (canon.CarryOutputMappings), so the description +// still re-executes; the note is there so a reader knows they exist +// (ako/mxcli#977). +func outputMappingsNote(w rawWidget) string { + switch w.OutputMappings { + case 0: + return "" + case 1: + return fmt.Sprintf("-- %s: 1 output mapping, which MDL cannot show; kept on a rewrite while the button calls the same flow", w.Name) + } + return fmt.Sprintf("-- %s: %d output mappings, which MDL cannot show; kept on a rewrite while the button calls the same flow", w.Name, w.OutputMappings) +} diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index e99537f8da..733b2245b8 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -345,6 +345,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.RenderMode = rt } widget.Icon, widget.IconType, widget.IconCode = extractButtonIcon(w) + widget.OutputMappings = countOutputMappings(w["Action"]) return []rawWidget{widget} case "Forms$Header", "Pages$Header": @@ -1554,3 +1555,24 @@ func describeFileManagerProps(ctx *ExecContext, w map[string]any) []string { } return props } + +// countOutputMappings counts the output mappings of a button's action: a +// microflow call holds them in its MicroflowSettings, a nanoflow call directly +// (ako/mxcli#977). +func countOutputMappings(action any) int { + a, ok := action.(map[string]any) + if !ok { + return 0 + } + holder := a + if ms, ok := a["MicroflowSettings"].(map[string]any); ok { + holder = ms + } + n := 0 + for _, e := range getBsonArrayElements(holder["OutputMappings"]) { + if _, ok := e.(map[string]any); ok { + n++ + } + } + return n +} diff --git a/modelsdk/canon/identity.go b/modelsdk/canon/identity.go index 8a8cb0c960..2455e38154 100644 --- a/modelsdk/canon/identity.go +++ b/modelsdk/canon/identity.go @@ -126,6 +126,12 @@ func Reconcile(contents, stored []byte, opts ...Option) (out []byte, unchanged b contents = CarryTranslations(contents, stored) } + // A flow call's output mappings (Mendix 11.5), which MDL has no syntax for: + // a rebuild wrote the empty list and dropped them (ako/mxcli#977). Like the + // translations, it changes the document's length, so it runs before the + // transplant. + contents = CarryOutputMappings(contents, stored) + // The same reasoning one level down, for the writes that do land: a rebuild // mints a fresh $ID for every sub-element, so a two-line change reads in // version control as a whole-document replacement. TransplantIDs puts the diff --git a/modelsdk/canon/outputmappings.go b/modelsdk/canon/outputmappings.go new file mode 100644 index 0000000000..6d25d95354 --- /dev/null +++ b/modelsdk/canon/outputmappings.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 + +package canon + +import ( + "sort" + "strconv" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// CarryOutputMappings copies onto a rebuilt document the output mappings of a +// flow call that the statement had no way to say. +// +// # Why anything is lost +// +// Mendix 11.5 lets a call-microflow or call-nanoflow action map what the flow +// returns back onto the page (Forms$MicroflowSettings.OutputMappings, +// Forms$CallNanoflowClientAction.OutputMappings). MDL has no syntax for them, +// so a rebuild writes the empty list Studio Pro stores on every other call, and +// describe → exec dropped every mapping: "Replaced page", the mapping gone, +// mx check silent (ako/mxcli#977). This is the carry Reconcile already does for +// translations, for the same reason: the rebuild is authoritative about what +// the statement said and silent about what it could not say. +// +// # How lists are paired +// +// By the named element that owns the call and the path from it — "the button +// btnGo, /Action/MicroflowSettings" — and only where that address occurs once +// in each document and is reached without passing through a list. A list is +// carried only when +// +// - the rebuilt list is empty: a statement that wrote mappings is the +// authority for them; +// - the call still names the same flow: mappings read the flow's result, so +// they mean nothing for another one, and carrying them would bind a page to +// outputs the new flow does not have; +// - none of the carried elements' ids is already in the rebuilt document. +// Rebuilt ids are freshly minted, so this does not happen in practice, but a +// duplicate id makes the unit unopenable, which is not a risk worth taking +// for a carry. +// +// The elements are carried verbatim, ids included, so the canonical form sees +// the same elements it saw before and an unchanged button is an unchanged +// document. Anything that cannot be read returns the contents untouched. +func CarryOutputMappings(contents, stored []byte) []byte { + if len(contents) == 0 || len(stored) == 0 { + return contents + } + var storedDoc, newDoc bson.D + if bson.Unmarshal(stored, &storedDoc) != nil { + return contents + } + storedLists := outputMappingHolders(storedDoc) + if len(storedLists) == 0 { + return contents + } + if bson.Unmarshal(contents, &newDoc) != nil { + return contents + } + newLists := outputMappingHolders(newDoc) + used := elementIDs(newDoc) + + addrs := make([]string, 0, len(storedLists)) + for a := range storedLists { + addrs = append(addrs, a) + } + sort.Strings(addrs) + + changed := false + for _, addr := range addrs { + s, n := storedLists[addr], newLists[addr] + if n == nil || flowOf(s) == "" || flowOf(s) != flowOf(n) { + continue + } + have, ok := docLookup(n, "OutputMappings").(bson.A) + if !ok || len(listElements(have)) > 0 { + continue + } + carried := listElements(docLookup(s, "OutputMappings").(bson.A)) + if len(carried) == 0 || anyIDUsed(carried, used) { + continue + } + for _, e := range carried { + markIDs(e, used) + have = append(have, e) + } + for i := range n { + if n[i].Key == "OutputMappings" { + n[i].Value = have + } + } + changed = true + } + if !changed { + return contents + } + out, err := bson.Marshal(newDoc) + if err != nil { + return contents + } + return out +} + +// outputMappingHolders addresses every flow call holding an OutputMappings list +// by its owning named element and the path from it, keeping only the addresses +// that are exact: owned by an element with a $Type and a Name, reached from it +// without passing through a list, and occurring once in the document. In the +// stored document only calls with a non-empty list matter, but the rebuilt one +// needs every call, so both are returned and the caller filters. +func outputMappingHolders(doc bson.D) map[string]bson.D { + out := map[string]bson.D{} + count := map[string]int{} + var walk func(v any, element, rel string, indexed bool) + walk = func(v any, element, rel string, indexed bool) { + switch n := v.(type) { + case bson.D: + ty, _ := docLookup(n, "$Type").(string) + if ty == "Forms$MicroflowSettings" || ty == "Forms$CallNanoflowClientAction" { + if _, ok := docLookup(n, "OutputMappings").(bson.A); ok && element != "" && !indexed { + key := element + "\x00" + rel + count[key]++ + out[key] = n + } + } + if name, _ := docLookup(n, "Name").(string); name != "" && ty != "" { + element, rel, indexed = ty+"\x00"+name, "", false + } + for _, e := range n { + walk(e.Value, element, rel+"/"+e.Key, indexed) + } + case bson.A: + for i, e := range n { + walk(e, element, rel+"/"+strconv.Itoa(i), true) + } + } + } + walk(doc, "", "", false) + for key, c := range count { + if c > 1 { + delete(out, key) + } + } + return out +} + +// flowOf is the flow a call names: a MicroflowSettings' Microflow or a +// nanoflow call's Nanoflow, both by qualified name. +func flowOf(d bson.D) string { + if s, _ := docLookup(d, "Microflow").(string); s != "" { + return s + } + s, _ := docLookup(d, "Nanoflow").(string) + return s +} + +// listElements returns a stored list's elements, without its leading marker. +func listElements(a bson.A) []bson.D { + var out []bson.D + for _, e := range a { + if d, ok := e.(bson.D); ok { + out = append(out, d) + } + } + return out +} + +func anyIDUsed(elems []bson.D, used map[string]bool) bool { + for _, e := range elems { + for id := range elementIDs(e) { + if used[id] { + return true + } + } + } + return false +} + +func markIDs(e bson.D, used map[string]bool) { + for id := range elementIDs(e) { + used[id] = true + } +} diff --git a/modelsdk/canon/outputmappings_977_test.go b/modelsdk/canon/outputmappings_977_test.go new file mode 100644 index 0000000000..b7750de92f --- /dev/null +++ b/modelsdk/canon/outputmappings_977_test.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 + +package canon + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ako/mxcli#977 item 3: a button's output mappings (Mendix 11.5) have no MDL, so +// a rebuild writes the empty list and describe → exec dropped every mapping +// Studio Pro stored — "Replaced page", the mapping gone, mx check silent. +// CarryOutputMappings keeps them while the button still calls the same flow. + +func outputMapping(id byte, expr string) bson.D { + return bson.D{ + {Key: "$ID", Value: bin(id)}, + {Key: "$Type", Value: "Forms$OutputMapping"}, + {Key: "AttributeRef", Value: nil}, + {Key: "Expression", Value: expr}, + {Key: "SourceAttributeRef", Value: nil}, + {Key: "SourceVariable", Value: nil}, + } +} + +// buttonPage is a page with one button named name whose action calls flow +// with the given output mappings. nanoflow selects a CallNanoflowClientAction, +// otherwise a MicroflowClientAction with MicroflowSettings. +func buttonPage(t *testing.T, base byte, name, flow string, nanoflow bool, mappings ...bson.D) []byte { + t.Helper() + list := bson.A{int32(3)} + for _, m := range mappings { + list = append(list, m) + } + var action bson.D + if nanoflow { + action = bson.D{ + {Key: "$ID", Value: bin(base + 2)}, + {Key: "$Type", Value: "Forms$CallNanoflowClientAction"}, + {Key: "Nanoflow", Value: flow}, + {Key: "OutputMappings", Value: list}, + } + } else { + action = bson.D{ + {Key: "$ID", Value: bin(base + 2)}, + {Key: "$Type", Value: "Forms$MicroflowClientAction"}, + {Key: "MicroflowSettings", Value: bson.D{ + {Key: "$ID", Value: bin(base + 3)}, + {Key: "$Type", Value: "Forms$MicroflowSettings"}, + {Key: "Microflow", Value: flow}, + {Key: "OutputMappings", Value: list}, + }}, + } + } + return marshal(t, bson.D{ + {Key: "$Type", Value: "Forms$Page"}, + {Key: "$ID", Value: bin(1)}, + {Key: "Name", Value: "Go_Page"}, + {Key: "Widgets", Value: bson.A{int32(2), bson.D{ + {Key: "$ID", Value: bin(base + 1)}, + {Key: "$Type", Value: "Forms$ActionButton"}, + {Key: "Name", Value: name}, + {Key: "Action", Value: action}, + }}}, + }) +} + +// outputMappingExprs lists the Expression of every Forms$OutputMapping. +func outputMappingExprs(t *testing.T, raw []byte) []string { + t.Helper() + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + var out []string + var walk func(v any) + walk = func(v any) { + switch n := v.(type) { + case bson.D: + if docLookup(n, "$Type") == "Forms$OutputMapping" { + s, _ := docLookup(n, "Expression").(string) + out = append(out, s) + } + for _, e := range n { + walk(e.Value) + } + case bson.A: + for _, e := range n { + walk(e) + } + } + } + walk(doc) + return out +} + +func TestCarryOutputMappings(t *testing.T) { + for _, nanoflow := range []bool{false, true} { + stored := buttonPage(t, 10, "btnGo", "M.ACT_Go", nanoflow, outputMapping(40, "'a'"), outputMapping(41, "'b'")) + for _, tc := range []struct { + name string + rebuilt []byte + want int + }{ + // The reported round trip: the rebuild wrote the empty list. + {"same button, same flow", buttonPage(t, 20, "btnGo", "M.ACT_Go", nanoflow), 2}, + // Controls: what the statement said wins. + {"the flow changed", buttonPage(t, 20, "btnGo", "M.ACT_Other", nanoflow), 0}, + {"the statement wrote its own", buttonPage(t, 20, "btnGo", "M.ACT_Go", nanoflow, outputMapping(50, "'mine'")), 1}, + {"another button", buttonPage(t, 20, "btnOther", "M.ACT_Go", nanoflow), 0}, + } { + got := outputMappingExprs(t, CarryOutputMappings(tc.rebuilt, stored)) + if len(got) != tc.want { + t.Errorf("nanoflow=%v %s: output mappings %v, want %d", nanoflow, tc.name, got, tc.want) + } + } + // The whole point: an unchanged button is an unchanged document. + if _, unchanged := Reconcile(buttonPage(t, 20, "btnGo", "M.ACT_Go", nanoflow), stored); !unchanged { + t.Errorf("nanoflow=%v: a rebuild that only dropped the output mappings was written", nanoflow) + } + } +} From 8a559601cfcc90e54400c0a040363ea27b07af10 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:52:52 +0000 Subject: [PATCH 25/40] fix(workflows): read group XPath targeting back instead of dropping it A user task created with `targeting groups xpath [...]` was stored as Workflows$XPathGroupTargeting, but the reader had no case for that type and decoded it as no targeting. describe printed the task without the clause, so describe -> create or modify silently removed the targeting. Add the missing case. A table test round-trips every targeting kind the writer emits (single and multi user task), and an integration test replays the describe output with users-xpath targeting as the control; both fail with the fix reverted. Fixes #1722 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xfGeNtrQ5BJrH2Ew64rt5 --- ...ow-group-xpath-targeting-read-as-none.json | 1 + mdl/backend/modelsdk/workflow_read.go | 2 + .../modelsdk/workflow_targeting_read_test.go | 44 ++++++++++++++++++ mdl/executor/roundtrip_workflow_test.go | 46 +++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-26-workflow-group-xpath-targeting-read-as-none.json create mode 100644 mdl/backend/modelsdk/workflow_targeting_read_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-26-workflow-group-xpath-targeting-read-as-none.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-26-workflow-group-xpath-targeting-read-as-none.json new file mode 100644 index 0000000000..b29266e592 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-26-workflow-group-xpath-targeting-read-as-none.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-26", "symptom": "A user task created with `targeting groups xpath [Name = 'Reviewers']` was stored correctly (`UserTargeting: Workflows$XPathGroupTargeting`), but `describe workflow` printed it with no targeting clause, so describe -> create or modify silently removed the targeting. Users xpath, users microflow and groups microflow on the same workflow described fine", "cause": "userSourceFromGen, the reader's switch from the polymorphic UserSource/UserTargeting part to sdk/workflows, had a case for every targeting $Type the writer emits except Workflows$XPathGroupTargeting, which fell through to NoUserSource. The writer (userTargetingToGen) and the describe formatter (the XPathGroupSource case in cmd_workflows.go) both handled it, so reading the formatter and the writer showed nothing wrong", "file": "`mdl/backend/modelsdk/workflow_read.go` (userSourceFromGen)", "insight": "When a describe clause is missing but the formatter has a case for it, the reader is the suspect: a missing case in a type switch on decoded gen types defaults silently. A table test that round-trips EVERY value the writer can emit through encode -> decode -> read catches the whole class, not just the reported kind; the per-kind tests that existed covered only the kinds someone had happened to describe. The executor's `TestRoundtripWorkflow_XPathTargeting` (integration) replays the describe output and compares, with the users-xpath task as the control", "fix": "Add the `*genWf.XPathGroupTargeting` -> `&workflows.XPathGroupSource{XPath: s.XPathConstraint()}` case; table test over all five targeting kinds, single and multi user task", "refs": ["ako/mxcli#1722"], "ce": []} diff --git a/mdl/backend/modelsdk/workflow_read.go b/mdl/backend/modelsdk/workflow_read.go index 778ee4164b..a88f75b408 100644 --- a/mdl/backend/modelsdk/workflow_read.go +++ b/mdl/backend/modelsdk/workflow_read.go @@ -478,6 +478,8 @@ func userSourceFromGen(source, targeting element.Element) workflows.UserSource { return &workflows.XPathBasedUserSource{XPath: s.XPathConstraint()} case *genWf.XPathUserTargeting: return &workflows.XPathBasedUserSource{XPath: s.XPathConstraint()} + case *genWf.XPathGroupTargeting: + return &workflows.XPathGroupSource{XPath: s.XPathConstraint()} default: return &workflows.NoUserSource{} } diff --git a/mdl/backend/modelsdk/workflow_targeting_read_test.go b/mdl/backend/modelsdk/workflow_targeting_read_test.go new file mode 100644 index 0000000000..87ed55ed34 --- /dev/null +++ b/mdl/backend/modelsdk/workflow_targeting_read_test.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// Issue ako/mxcli#1722. `targeting groups xpath [...]` was written as +// Workflows$XPathGroupTargeting and read back as no targeting: the reader had a +// case for every other targeting kind but that one. DESCRIBE printed the task +// without the clause, so a describe -> create or modify round trip removed it. +// Every kind the writer emits must read back as itself. +func TestWorkflowRead_UserTaskTargetingRoundTrips(t *testing.T) { + for _, tc := range []struct { + name string + source workflows.UserSource + }{ + {"users microflow", &workflows.MicroflowBasedUserSource{Microflow: "M.ACT_Users"}}, + {"users xpath", &workflows.XPathBasedUserSource{XPath: "[Active = true()]"}}, + {"groups microflow", &workflows.MicroflowGroupSource{Microflow: "M.ACT_Groups"}}, + {"groups xpath", &workflows.XPathGroupSource{XPath: "[Name = 'Reviewers']"}}, + {"none", &workflows.NoUserSource{}}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, multi := range []bool{false, true} { + ut := &workflows.UserTask{Page: "M.TaskPage", IsMulti: multi, UserSource: tc.source} + ut.Name, ut.Caption = "Review", "Review" + ut.Outcomes = []*workflows.UserTaskOutcome{{Value: "Approve"}, {Value: "Reject"}} + + rt, ok := roundTripWorkflowActivity(t, ut).(*workflows.UserTask) + if !ok { + t.Fatalf("multi=%v: round-tripped to a different type", multi) + } + if !reflect.DeepEqual(rt.UserSource, tc.source) { + t.Errorf("multi=%v: targeting read back as %#v, want %#v", multi, rt.UserSource, tc.source) + } + } + }) + } +} diff --git a/mdl/executor/roundtrip_workflow_test.go b/mdl/executor/roundtrip_workflow_test.go index 3f3920586b..5b52e36eeb 100644 --- a/mdl/executor/roundtrip_workflow_test.go +++ b/mdl/executor/roundtrip_workflow_test.go @@ -425,3 +425,49 @@ end workflow;` t.Errorf("Expected describe output to bind the argument in R4's form, got:\n%s", output) } } + +// Issue ako/mxcli#1722. A task targeted with `groups xpath` described without +// its targeting clause, so replaying the description removed the targeting. +// The users-xpath task is the control: it always described. +func TestRoundtripWorkflow_XPathTargeting(t *testing.T) { + env := setupTestEnv(t) + defer env.teardown() + + if err := env.executeMDL(`create or modify persistent entity ` + testModule + `.WfTargetCtx (Name: String(100));`); err != nil { + t.Fatalf("create entity: %v", err) + } + createTaskPages(t, env, testModule, "TargetPage") + + if err := env.executeMDL(`create workflow ` + testModule + `.WfXPathTargeting + parameter $WorkflowContext: ` + testModule + `.WfTargetCtx +begin + user task ByUsers 'By users' page ` + testModule + `.TargetPage targeting users xpath [Active = true()] outcomes 'Done' { }; + user task ByGroups 'By groups' page ` + testModule + `.TargetPage targeting groups xpath [Name = 'Reviewers'] outcomes 'Done' { }; +end workflow;`); err != nil { + t.Fatalf("create workflow: %v", err) + } + + describe := func() string { + t.Helper() + out, err := env.describeMDL(`describe workflow ` + testModule + `.WfXPathTargeting;`) + if err != nil { + t.Fatalf("describe workflow: %v", err) + } + return out + } + want := []string{"targeting users xpath [Active = true()]", "targeting groups xpath [Name = 'Reviewers']"} + first := describe() + for _, w := range want { + if !strings.Contains(first, w) { + t.Errorf("describe lost %q:\n%s", w, first) + } + } + + // Replaying the description (it is `create or modify`) must keep both. + if err := env.executeMDL(first); err != nil { + t.Fatalf("replay describe output: %v\n%s", err, first) + } + if second := describe(); second != first { + t.Errorf("describe -> create or modify changed the workflow:\nbefore:\n%s\nafter:\n%s", first, second) + } +} From 49ea8ca35e009acfc51f4ae4caddaef5c23289a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:03:29 +0000 Subject: [PATCH 26/40] fix(check): refuse an XPath [%UserRole_X%] token naming no user role `[System.UserRoles = '[%UserRole_Manager%]']` with no Manager role passed check --references and exec, and mx check then reported CE1613 at the constraint. Worse, with any CE1613 in the app mx check reports no CE0161 at all, so one bad token hides every invalid XPath elsewhere. Measured on mxbuild 11.15.0: an unknown role is CE1613 at a retrieve, a list view's database source and an access rule alike, and the name is matched case-sensitively ('UserRole_user' fails where 'UserRole_User' is clean). The reference pass now walks every XPath slot (retrieve, data source, access rule, user task targeting as created and altered, offline sync) and refuses a token whose role is neither in the project nor created by the script, listing the roles and the right spelling for a case mismatch. Excluded flows warn; without a project it stays silent. Fixes #1723 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xfGeNtrQ5BJrH2Ew64rt5 --- ...0-26-unknown-user-role-token-in-xpath.json | 1 + .../write-workflows/reference/targeting.md | 7 +- .../skills/mendix/xpath-constraints/SKILL.md | 17 +- .../bug-tests/1723-user-role-token.mdl | 52 +++++ mdl/executor/validate.go | 4 + mdl/executor/validate_user_role_tokens.go | 185 ++++++++++++++++++ .../validate_user_role_tokens_test.go | 154 +++++++++++++++ 7 files changed, 411 insertions(+), 9 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-unknown-user-role-token-in-xpath.json create mode 100644 mdl-examples/bug-tests/1723-user-role-token.mdl create mode 100644 mdl/executor/validate_user_role_tokens.go create mode 100644 mdl/executor/validate_user_role_tokens_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-unknown-user-role-token-in-xpath.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-unknown-user-role-token-in-xpath.json new file mode 100644 index 0000000000..d210135d30 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-unknown-user-role-token-in-xpath.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "`targeting users xpath [System.UserRoles = '[%UserRole_Manager%]']` with no Manager user role passed `check -p --references` and exec; mx check reported CE1613 \"The selected user role 'Manager' no longer exists.\" — and, with that CE1613 in the app, no CE0161 anywhere, hiding four invalid targeting constraints during #1711's measurement. The same token in a retrieve, a list view's database source and an access rule is CE1613 at each (11.15.0), and the name is case-sensitive: '[%UserRole_user%]' is CE1613 where '[%UserRole_User%]' is clean", "cause": "No validator read the name inside an XPath token: the member walks treat a quoted token as a literal, and the reference pass resolves qualified names, which a token is not", "file": "`mdl/executor/validate_user_role_tokens.go` (new: validateUserRoleTokens, walkXPathConstraints, userRoleNames), wired after validateEnumValueRefs in `mdl/executor/validate.go`", "insight": "One reflective walk with a type switch on the nodes that HOLD an XPath (RetrieveStmt.Where via expressionToXPath, DataSourceV3.Where, GrantEntityAccessStmt.XPathConstraint, WorkflowTargetingNode.XPath, SetActivityPropertyOp targeting_xpath, NavSyncDef.Constraint) covers every slot wherever it nests — alter page, alter workflow, navigation — without a per-statement collector; scanning every string instead would flag a log message that mentions a token. A node held by value has to be routed through its address or the struct walk recurses into itself. mx check validates the role name but not every enum: a non-interrupting timer's Recurrence.IntervalType 'Bogus' passed mx check (#1716), so 'mx check accepted it' proves a value only where a bogus control fails", "fix": "Collect the role names from GetProjectSecurity plus the script's create user role statements; report each unknown token per statement as CE1613 with the role list, and the right spelling when only the case differs. Excluded flows warn. No project or no security document: silent", "refs": ["ako/mxcli#1723", "ako/mxcli#1711"], "ce": ["CE1613", "CE0161"]} diff --git a/.claude/skills/mendix/write-workflows/reference/targeting.md b/.claude/skills/mendix/write-workflows/reference/targeting.md index 9142ca1fec..cc72791fe2 100644 --- a/.claude/skills/mendix/write-workflows/reference/targeting.md +++ b/.claude/skills/mendix/write-workflows/reference/targeting.md @@ -28,6 +28,7 @@ With mx check on 11.15.0, and for the role form also at runtime on 11.13.0: `check -p --references` and `exec` report the two CE0161 shapes, and the same leading entity step in a microflow retrieve or a list view's database source (ako/mxcli#1711). The role token must name a user role the project has (`show user -roles`) — nothing checks that yet, and while any CE1613 is in the app mx check -reports **no CE0161 at all**, so clear it first. A constraint on one hard-coded -user is a test shortcut, not targeting. +roles`), spelled with the same case: `check -p --references` and `exec` refuse +one that does not, in any XPath constraint (ako/mxcli#1723). It matters beyond the +one error — while any CE1613 is in the app, mx check reports **no CE0161 at all**. +A constraint on one hard-coded user is a test shortcut, not targeting. diff --git a/.claude/skills/mendix/xpath-constraints/SKILL.md b/.claude/skills/mendix/xpath-constraints/SKILL.md index 8280b09fd3..2817bbbd67 100644 --- a/.claude/skills/mendix/xpath-constraints/SKILL.md +++ b/.claude/skills/mendix/xpath-constraints/SKILL.md @@ -13,12 +13,12 @@ description: "XPath constraint syntax for MDL — retrieve WHERE clauses, page d | 28-34 | When to Use This Skill | | 35-75 | XPath vs Mendix Expressions | | 76-119 | Syntax Reference | -| 120-292 | How a Constraint Is Laid Out on Disk | -| 293-343 | Usage Contexts | -| 344-383 | Enumeration Attributes | -| 384-425 | Common Patterns | -| 426-437 | Validation | -| 438-447 | Troubleshooting | +| 120-297 | How a Constraint Is Laid Out on Disk | +| 298-348 | Usage Contexts | +| 349-388 | Enumeration Attributes | +| 389-430 | Common Patterns | +| 431-442 | Validation | +| 443-452 | Troubleshooting | # XPath Constraints in MDL @@ -280,6 +280,11 @@ where [System.owner = '[%CurrentUser%]'] Common tokens: `[%CurrentUser%]`, `[%CurrentDateTime%]`, `[%CurrentObject%]`, `[%UserRole_RoleName%]`, `[%DayLength%]` +> **`[%UserRole_RoleName%]` names a project user role, case-sensitively** (`show user +> roles`). One that does not exist is CE1613 at the retrieve, data source or access +> rule — and while any CE1613 is in the app, mx check reports no CE0161 at all. +> `check --references` refuses it. + ### ID Pseudo-Attribute The `id` pseudo-attribute compares object identity (GUID): diff --git a/mdl-examples/bug-tests/1723-user-role-token.mdl b/mdl-examples/bug-tests/1723-user-role-token.mdl new file mode 100644 index 0000000000..1d010ac005 --- /dev/null +++ b/mdl-examples/bug-tests/1723-user-role-token.mdl @@ -0,0 +1,52 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1723 — "check --references: an XPath `[%UserRole_X%]` token naming +-- no user role passes, then fails mx check with CE1613 (and hides every CE0161)" +-- ============================================================================ +-- +-- `[%UserRole_%]` names a user role of the project, matched +-- case-sensitively. Measured with mx check on mxbuild 11.15.0, the same script +-- with the role as the only variable: +-- +-- '[%UserRole_User%]' clean +-- '[%UserRole_Manager%]' CE1613 "The selected user role 'Manager' no longer exists." +-- '[%UserRole_user%]' CE1613 +-- +-- at the retrieve, the list view and the access rule below alike. With a CE1613 +-- anywhere in the app, mx check reported no CE0161 at all. +-- +-- EXPECTED: `mxcli check` passes this file, and so does `check -p +-- --references` on an app with the standard User role. Replacing `User` with +-- `Manager` (or `user`) is reported at each of the three statements, naming the +-- project's roles (and the right spelling, for a case mismatch). +-- Guard: mdl/executor/validate_user_role_tokens_test.go. +-- ============================================================================ + +create module BugRoleToken1723; + +create persistent entity BugRoleToken1723.Doc ( + Title: String(200), + owner: AutoOwner +); + +create microflow BugRoleToken1723.ACT_UsersInRole () +returns Boolean +begin + retrieve $Users from System.User where [System.UserRoles = '[%UserRole_User%]']; + return true; +end; + +create page BugRoleToken1723.User_Overview +( + title: 'Users', + layout: Atlas_Core.Atlas_Default +) +{ + listview lvUsers (datasource: database from System.User where [System.UserRoles = '[%UserRole_User%]']) { + dynamictext txtName (Content: '{1}', ContentParams: ({1} = Name)) + } +}; + +create module role BugRoleToken1723.Reader; +grant read * on entity BugRoleToken1723.Doc to BugRoleToken1723.Reader + where [System.owner/System.User/System.UserRoles = '[%UserRole_User%]']; diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index ce5ab2d6cb..9163fac704 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -555,6 +555,10 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // `Module.Enum.Key` in any expression: the key must be one of the // enumeration's values (mendixlabs/mxcli#1472). errors = append(errors, validateEnumValueRefs(ctx, prog, sc)...) + // `[%UserRole_X%]` in any XPath constraint: X must be a user role, matched + // case-sensitively, or mxbuild reports CE1613 — and then no CE0161 at all + // (ako/mxcli#1723). + errors = append(errors, validateUserRoleTokens(ctx, prog, sc)...) // A reference into a Protected (add-on) module's Hidden element, and a // Usable microflow there exposing a Hidden entity: mxbuild's CE1406 and // CE5400, after exec has written the model (mendixlabs/mxcli#1498). diff --git a/mdl/executor/validate_user_role_tokens.go b/mdl/executor/validate_user_role_tokens.go new file mode 100644 index 0000000000..cba8ddf0ab --- /dev/null +++ b/mdl/executor/validate_user_role_tokens.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "reflect" + "regexp" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" +) + +// An XPath constraint names a user role with a token, `'[%UserRole_Manager%]'`: +// the documented way to target a role's users from a user task, and as valid in +// a retrieve, a data source or an access rule. The name after `UserRole_` must +// be one of the project's user roles, or mxbuild refuses the constraint — and +// nothing asked, so a typo passed `check --references` and exec wrote it +// (ako/mxcli#1723). Measured on mxbuild 11.15.0, the same script with the role +// as the only variable: +// +// '[%UserRole_User%]' clean +// '[%UserRole_Manager%]' CE1613 "The selected user role 'Manager' no longer exists." +// '[%UserRole_user%]' CE1613 — the name is matched case-sensitively +// +// at an access rule, a list view's database source and a retrieve alike (and a +// workflow's targeting, #1711). It costs more than the one error: with a CE1613 +// anywhere in the app, mx check reported no CE0161 at all. +// +// Could-not-establish is silence: without a project the roles are unknown, and a +// role the script creates is known. + +// userRoleTokenRe matches a user role token and captures the role's name. +var userRoleTokenRe = regexp.MustCompile(`\[%UserRole_([^%\]]+)%\]`) + +// validateUserRoleTokens reports the user role tokens naming no user role. +func validateUserRoleTokens(ctx *ExecContext, prog *ast.Program, sc *scriptContext) []error { + if prog == nil || !ctx.Connected() { + return nil + } + var known map[string]bool // nil until a statement carries a token + var names []string // the project's and the script's roles, for the message + + var errs []error + for i, stmt := range prog.Statements { + var bad []string + seen := map[string]bool{} + walkXPathConstraints(stmt, func(xpath string) { + for _, m := range userRoleTokenRe.FindAllStringSubmatch(xpath, -1) { + role := m[1] + if seen[role] { + continue + } + seen[role] = true + if known == nil { + known, names = userRoleNames(ctx, prog) + } + if known == nil || known[role] { + continue + } + bad = append(bad, role) + } + }) + if len(bad) == 0 { + continue + } + var msgs []string + for _, role := range bad { + msg := fmt.Sprintf("[%%UserRole_%s%%]: the project has no user role %s (its user roles: %s)", + role, role, strings.Join(names, ", ")) + for _, n := range names { + if strings.EqualFold(n, role) { + msg += fmt.Sprintf(" — the name is case-sensitive: [%%UserRole_%s%%]", n) + break + } + } + msgs = append(msgs, msg) + } + if kind, name, excluded := excludedFlow(stmt); excluded { + sc.warnExcluded(kind, name, msgs) + continue + } + errs = append(errs, mdlerrors.NewValidationf("statement %d: unknown user role in an XPath token "+ + "(mxbuild: CE1613 \"The selected user role '…' no longer exists.\"):\n - %s", + i+1, strings.Join(msgs, "\n - "))) + } + return errs +} + +// userRoleNames is the set of user role names a token may name — the project's +// and those the script creates — and the same names sorted. The set is nil when +// the project's roles cannot be read, which makes every token unjudged. +func userRoleNames(ctx *ExecContext, prog *ast.Program) (map[string]bool, []string) { + ps, err := ctx.Backend.GetProjectSecurity() + if err != nil || ps == nil { + return nil, nil + } + known := map[string]bool{} + for _, r := range ps.UserRoles { + if r != nil { + known[r.Name] = true + } + } + for _, stmt := range prog.Statements { + if s, ok := stmt.(*ast.CreateUserRoleStmt); ok { + known[s.Name] = true + } + } + names := make([]string, 0, len(known)) + for n := range known { + names = append(names, n) + } + sort.Strings(names) + return known, names +} + +// walkXPathConstraints calls fn with every XPath constraint v carries, at any +// depth: a database retrieve's, a data source's, an access rule's, a user +// task's targeting (as created and as altered) and an offline sync's. +func walkXPathConstraints(v any, fn func(xpath string)) { + var walk func(rv reflect.Value) + walk = func(rv reflect.Value) { + switch rv.Kind() { + case reflect.Interface: + if !rv.IsNil() { + walk(rv.Elem()) + } + case reflect.Pointer: + if rv.IsNil() { + return + } + switch n := rv.Interface().(type) { + case *ast.RetrieveStmt: + if n.StartVariable == "" && n.Where != nil { + fn(expressionToXPath(n.Where)) + } + return + case *ast.DataSourceV3: + fn(n.Where) + return + case *ast.GrantEntityAccessStmt: + fn(n.XPathConstraint) + return + case *ast.WorkflowTargetingNode: + fn(n.XPath) + return + case *ast.SetActivityPropertyOp: + if n.Property == "targeting_xpath" { + fn(n.Value) + } + return + case *ast.NavSyncDef: + fn(n.Constraint) + return + } + walk(rv.Elem()) + case reflect.Struct: + if rv.CanAddr() { + // A node held by value is judged like one held by pointer. + switch rv.Addr().Interface().(type) { + case *ast.DataSourceV3, *ast.WorkflowTargetingNode, *ast.NavSyncDef: + walk(rv.Addr()) + return + } + } + for i := 0; i < rv.NumField(); i++ { + if rv.Type().Field(i).IsExported() { + walk(rv.Field(i)) + } + } + case reflect.Slice, reflect.Array: + for i := 0; i < rv.Len(); i++ { + walk(rv.Index(i)) + } + case reflect.Map: + iter := rv.MapRange() + for iter.Next() { + walk(iter.Value()) + } + } + } + walk(reflect.ValueOf(v)) +} diff --git a/mdl/executor/validate_user_role_tokens_test.go b/mdl/executor/validate_user_role_tokens_test.go new file mode 100644 index 0000000000..ce17846d13 --- /dev/null +++ b/mdl/executor/validate_user_role_tokens_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/sdk/security" +) + +// ako/mxcli#1723: a `[%UserRole_X%]` token naming no user role passed check +// --references and exec, and mxbuild refused it as CE1613 — which also hid +// every CE0161 in the app. + +func userRoleTokenCtx(t *testing.T) *ExecContext { + t.Helper() + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSecurityFunc: func() (*security.ProjectSecurity, error) { + return &security.ProjectSecurity{UserRoles: []*security.UserRole{{Name: "Administrator"}, {Name: "User"}}}, nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb)) + return ctx +} + +func checkUserRoleTokens(t *testing.T, ctx *ExecContext, src string) ([]error, []string) { + t.Helper() + prog := parseMDL(t, src) + sc := newScriptContext() + sc.collectDefinitions(prog) + return validateUserRoleTokens(ctx, prog, sc), sc.warnings +} + +// userRoleSlots puts one role token in each XPath slot mxbuild resolves it in. +// The first four are measured (11.15.0): CE1613 at each with an unknown role. +func userRoleSlots(role string) map[string]string { + tok := `'[%UserRole_` + role + `%]'` + return map[string]string{ + "retrieve": `create microflow MyModule.R () returns Boolean + begin + retrieve $Users from System.User where [System.UserRoles = ` + tok + `]; + return true; + end;`, + "list view database source": `create page MyModule.Users (title: 'Users', layout: Atlas_Core.Atlas_Default) { + listview lvUsers (datasource: database from System.User where [System.UserRoles = ` + tok + `]) { + dynamictext txtName (Content: 'x') + } + };`, + "access rule": `grant read * on entity MyModule.Doc to MyModule.Reader + where [System.owner/System.User/System.UserRoles = ` + tok + `];`, + "user task targeting": `create workflow MyModule.W + parameter $Context: MyModule.Doc + begin + user task Review 'Review' page MyModule.TaskPage + targeting users xpath [System.UserRoles = ` + tok + `] outcomes 'Done' { }; + end workflow;`, + "alter workflow targeting": `alter workflow MyModule.W { set (Targeting: xpath [System.UserRoles = ` + tok + `]) on Review; };`, + "offline sync": `create or modify navigation "PhoneOffline" + sync ( sync MyModule.Doc where [System.owner/System.User/System.UserRoles = ` + tok + `]; );`, + } +} + +func TestUserRoleTokens_ReportsAnUnknownRoleInEverySlot(t *testing.T) { + for slot, src := range userRoleSlots("Manager") { + t.Run(slot, func(t *testing.T) { + errs, _ := checkUserRoleTokens(t, userRoleTokenCtx(t), src) + if len(errs) != 1 { + t.Fatalf("got %d errors, want 1: %v", len(errs), errs) + } + for _, w := range []string{"[%UserRole_Manager%]", "CE1613", "Administrator, User"} { + if !strings.Contains(errs[0].Error(), w) { + t.Errorf("error should mention %s: %v", w, errs[0]) + } + } + }) + } +} + +// CONTROL: the same slots with a role the project has. +func TestUserRoleTokens_SilentForAKnownRole(t *testing.T) { + for slot, src := range userRoleSlots("User") { + t.Run(slot, func(t *testing.T) { + if errs, warns := checkUserRoleTokens(t, userRoleTokenCtx(t), src); len(errs)+len(warns) != 0 { + t.Errorf("reported a role the project has: %v %v", errs, warns) + } + }) + } +} + +// Measured: 'UserRole_user' is CE1613 where 'UserRole_User' is clean. +func TestUserRoleTokens_TheNameIsCaseSensitive(t *testing.T) { + errs, _ := checkUserRoleTokens(t, userRoleTokenCtx(t), userRoleSlots("user")["retrieve"]) + if len(errs) != 1 || !strings.Contains(errs[0].Error(), "case-sensitive: [%UserRole_User%]") { + t.Errorf("want one error naming the right spelling, got %v", errs) + } +} + +func TestUserRoleTokens_ARoleTheScriptCreatesIsKnown(t *testing.T) { + src := `create user role Manager (ModuleRoles: (MyModule.Reader)); +` + userRoleSlots("Manager")["retrieve"] + if errs, _ := checkUserRoleTokens(t, userRoleTokenCtx(t), src); len(errs) != 0 { + t.Errorf("reported a role the script creates: %v", errs) + } +} + +// What cannot be established is silence: no project, an unreadable security +// document, and tokens that are not user roles. +func TestUserRoleTokens_SilentWhenItCannotJudge(t *testing.T) { + src := userRoleSlots("Manager")["retrieve"] + + ctx, _ := newMockCtx(t, withBackend(&mock.MockBackend{IsConnectedFunc: func() bool { return false }})) + if errs, _ := checkUserRoleTokens(t, ctx, src); len(errs) != 0 { + t.Errorf("no project: %v", errs) + } + + ctx, _ = newMockCtx(t, withBackend(&mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSecurityFunc: func() (*security.ProjectSecurity, error) { return nil, nil }, + })) + if errs, _ := checkUserRoleTokens(t, ctx, src); len(errs) != 0 { + t.Errorf("no security document: %v", errs) + } + + other := `create microflow MyModule.R () returns Boolean + begin + retrieve $Docs from MyModule.Doc where [System.owner = '[%CurrentUser%]']; + return true; + end;` + if errs, _ := checkUserRoleTokens(t, userRoleTokenCtx(t), other); len(errs) != 0 { + t.Errorf("a token that is not a user role: %v", errs) + } +} + +// Mendix does not validate an excluded document: a warning, not an error. +func TestUserRoleTokens_ExcludedFlowWarns(t *testing.T) { + errs, warns := checkUserRoleTokens(t, userRoleTokenCtx(t), "@excluded\n"+userRoleSlots("Manager")["retrieve"]) + if len(errs) != 0 || len(warns) != 1 { + t.Errorf("excluded: got errors %v, warnings %v; want one warning", errs, warns) + } +} + +// The check is wired into the --references pass, which exec runs too. +func TestUserRoleTokens_RunsInReferencePass(t *testing.T) { + errs, _ := validateProgramWithWarnings(userRoleTokenCtx(t), parseMDL(t, userRoleSlots("Manager")["access rule"])) + for _, e := range errs { + if strings.Contains(e.Error(), "[%UserRole_Manager%]") { + return + } + } + t.Fatalf("reference pass did not report [%%UserRole_Manager%%]: %v", errs) +} From dbf80aa034fa9880b3e21eebaf81c0add2fe7a99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:12:43 +0000 Subject: [PATCH 27/40] fix(workflows): refuse a rewrite that would reset settings MDL cannot state describe -> create or modify on a workflow with Studio Pro-only settings reported "Modified workflow" and reset them. Measured on 11.15.0 against a stored workflow given each setting (mx check clean): the user task's task name 'Review {1}' became its caption, the template parameter {1} = $WorkflowContext/Name was dropped, both timer boundary event captions were blanked, and a recurring reminder became a one-shot one. REPLACE ACTIVITY rebuilt the activity the same way. The rewrite guard now refuses, naming each setting and the issue that adds its syntax: workflow display/description template parameters; a restated user task's task name other than its caption, and its task name/description parameters (#1714); a timer boundary event's caption (#1715) and recurrence (#1716); and a boundary event annotation. REPLACE ACTIVITY refuses the same for the activity it replaces. A task the statement drops loses its name on purpose and is not refused. Fixes #1713 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xfGeNtrQ5BJrH2Ew64rt5 --- ...ow-rewrite-resets-unstatable-settings.json | 1 + mdl/executor/validate_workflow_rewrite.go | 2 + mdl/executor/validate_workflow_unstatable.go | 181 ++++++++++++++++++ .../validate_workflow_unstatable_test.go | 148 ++++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-workflow-rewrite-resets-unstatable-settings.json create mode 100644 mdl/executor/validate_workflow_unstatable.go create mode 100644 mdl/executor/validate_workflow_unstatable_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-workflow-rewrite-resets-unstatable-settings.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-workflow-rewrite-resets-unstatable-settings.json new file mode 100644 index 0000000000..574c659226 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-workflow-rewrite-resets-unstatable-settings.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "describe workflow -> create or modify on a workflow holding Studio Pro settings MDL cannot state reported \"Modified workflow\" and reset them: a user task's task name 'Review {1}' became its caption 'Review', the template parameter {1} = $WorkflowContext/Name was dropped, both timer boundary events' captions were blanked, and a non-interrupting timer's recurrence (every 1 Day, at most 5 times) became a one-shot reminder. mx check passed before and after. REPLACE ACTIVITY rebuilt the activity the same way", "cause": "The rebuild writes a user task's TaskName from the statement's caption, a StringTemplate's Text without Parameters, and a timer boundary event with no Caption and Recurrence null. The rewrite guard (studioProOnlyWorkflowState, rawReplaceLosses) listed only an event handler with no event types and the multi-user/on-created settings, none of these", "file": "`mdl/executor/validate_workflow_unstatable.go` (new: rawUnstatableWorkflowState, rawUnstatableActivityState), wired into checkNoDroppedWorkflowConstructs and validateAlterReplaceKeepsStudioProState in `mdl/executor/validate_workflow_rewrite.go`", "insight": "Measure the loss on a stored document that HAS the settings: an mxcli-created workflow has none, so a rewrite of it proves nothing. Without Studio Pro, patch the .mxunit with a small Go program (go.mongodb.org/mongo-driver/v2 bson, run with the repo's toolchain: GOTOOLCHAIN=local and $(go env GOROOT)/bin/go): $ID must be binary subtype 0 (subtype 3 gives \"Expected '$ID' as the first property … but got '$ID'\"), and a template parameter is Microflows$TemplateParameter, not the gen type's TemplateArgument (\"type cache does not contain … TemplateArgument\"). mx check then accepted the patched unit — but it also accepted IntervalType 'Bogus', so it does not validate that enum and cannot establish its values (#1716 needs a Studio Pro reference). The sweep found one more than the issue listed: the task name itself, not just its parameters, is reset whenever it differs from the caption. A user task's name/description count only when the statement restates the task (dropping the task drops them on purpose); boundary-event state counts wherever it is, because an unrestated boundary event is refused already", "fix": "Refuse the rewrite, listing each setting with the issue that adds its syntax: workflow display/description parameters, a restated user task's task name other than its caption and its task name/description parameters (#1714), a timer boundary event's caption (#1715), its recurrence (#1716), and a boundary event annotation. REPLACE ACTIVITY refuses the same for the replaced activity", "refs": ["ako/mxcli#1713", "ako/mxcli#1714", "ako/mxcli#1715", "ako/mxcli#1716"], "ce": []} diff --git a/mdl/executor/validate_workflow_rewrite.go b/mdl/executor/validate_workflow_rewrite.go index cfffec6947..5b0009db7f 100644 --- a/mdl/executor/validate_workflow_rewrite.go +++ b/mdl/executor/validate_workflow_rewrite.go @@ -54,6 +54,7 @@ func checkNoDroppedWorkflowConstructs(ctx *ExecContext, workflowID model.ID, qua cannotExpress = append(cannotExpress, fmt.Sprintf("%d event sub-process(es) with no start event, which it would delete", n)) } cannotExpress = append(cannotExpress, studioProOnlyWorkflowState(raw)...) + cannotExpress = append(cannotExpress, rawUnstatableWorkflowState(raw, stmt)...) if len(cannotExpress) > 0 { return mdlerrors.NewUnsupported(fmt.Sprintf( "workflow %s holds state MDL cannot express, and rewriting it would delete or reset it silently:\n - %s\n"+ @@ -468,6 +469,7 @@ func validateAlterReplaceKeepsStudioProState(ctx *ExecContext, s *ast.AlterWorkf if n, _ := d["Name"].(string); n != "" && n == act.GetName() { repl := replacementFor(o.NewActivities, n) reasons = append(reasons, rawReplaceLosses(d, repl)...) + reasons = append(reasons, rawUnstatableActivityState(d, repl)...) if mf := rawOnCreatedMicroflow(d); mf != "" && !restatesOnCreated(repl) { reasons = append(reasons, fmt.Sprintf( "it runs on-created microflow %s, which the replacement does not restate (add `on created microflow %s`)", mf, mf)) diff --git a/mdl/executor/validate_workflow_unstatable.go b/mdl/executor/validate_workflow_unstatable.go new file mode 100644 index 0000000000..0c58187108 --- /dev/null +++ b/mdl/executor/validate_workflow_unstatable.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// Settings a Studio Pro author can give a workflow that MDL has no words for, so +// a rebuild from a statement resets them without a word (ako/mxcli#1713). +// Measured on mxbuild 11.15.0: a stored workflow given each of them passed mx +// check; describe printed none of them, and replaying the description with +// `create or modify` reported "Modified workflow" and left none of them: +// +// a user task's task name 'Review {1}' (caption 'Review') reset to 'Review' +// its parameter {1} = $WorkflowContext/Name dropped +// a timer boundary event's caption blanked +// a non-interrupting timer's recurrence dropped (a one-shot reminder) +// +// The rebuild takes a user task's name from its caption, writes a template's +// text without its parameters, and writes a timer boundary event with neither a +// caption nor a recurrence. Each is refused until MDL can state it — the issue +// named after each reason adds the syntax. + +// workflowTemplateLabel names a stored string template by its key, as MDL calls +// the clause that states its text. +var workflowTemplateLabel = map[string]string{ + "WorkflowName": "display", + "WorkflowDescription": "description", + "TaskName": "task name", + "TaskDescription": "description", +} + +// rawTemplateParams describes a stored string template whose placeholders are +// filled from expressions — `'Review {1}' with ({1} = $WorkflowContext/Name)` — +// or returns "" when it has none. +func rawTemplateParams(v any) string { + st, ok := v.(map[string]any) + if !ok { + return "" + } + var params []string + for _, p := range rawList(st["Parameters"]) { + if pm, ok := p.(map[string]any); ok { + expr, _ := pm["Expression"].(string) + params = append(params, fmt.Sprintf("{%d} = %s", len(params)+1, expr)) + } + } + if len(params) == 0 { + return "" + } + text, _ := st["Text"].(string) + return fmt.Sprintf("'%s' with (%s)", text, strings.Join(params, ", ")) +} + +// rawWorkflowTemplateLosses lists the workflow's own templates whose parameters +// a rebuild drops. +func rawWorkflowTemplateLosses(raw map[string]any) []string { + var out []string + for _, k := range []string{"WorkflowName", "WorkflowDescription"} { + if p := rawTemplateParams(raw[k]); p != "" { + out = append(out, fmt.Sprintf("the workflow's %s %s fills its placeholders from expressions, which MDL cannot state yet (ako/mxcli#1714)", + workflowTemplateLabel[k], p)) + } + } + return out +} + +func isRawUserTask(d map[string]any) bool { + switch t, _ := d["$Type"].(string); t { + case "Workflows$SingleUserTaskActivity", "Workflows$MultiUserTaskActivity", "Workflows$UserTask": + return true + } + return false +} + +// rawUserTaskTemplateLosses lists what rebuilding a stored user task from a +// statement resets in its task name and description; caption is the caption the +// statement gives it, which the rebuild takes the task name from. +func rawUserTaskTemplateLosses(d map[string]any, caption string) []string { + var out []string + if tn, ok := d["TaskName"].(map[string]any); ok { + if text, _ := tn["Text"].(string); text != "" && text != caption { + out = append(out, fmt.Sprintf("its task name '%s' is not its caption '%s', and the rebuilt task takes its name from the caption (ako/mxcli#1714)", + text, caption)) + } + } + for _, k := range []string{"TaskName", "TaskDescription"} { + if p := rawTemplateParams(d[k]); p != "" { + out = append(out, fmt.Sprintf("its %s %s fills its placeholders from expressions, which MDL cannot state yet (ako/mxcli#1714)", + workflowTemplateLabel[k], p)) + } + } + return out +} + +// rawBoundaryEventLosses lists what rebuilding a stored activity resets on its +// boundary events: a timer's caption and recurrence, and an annotation. +func rawBoundaryEventLosses(d map[string]any) []string { + var out []string + for _, be := range rawList(d["BoundaryEvents"]) { + bm, ok := be.(map[string]any) + if !ok { + continue + } + kind := rawBoundaryEventKind(bm) + if strings.HasSuffix(kind, "timer") { + if c, _ := bm["Caption"].(string); c != "" { + out = append(out, fmt.Sprintf("its %s boundary event has caption '%s', which MDL cannot state for a timer yet (ako/mxcli#1715)", kind, c)) + } + } + if r, ok := bm["Recurrence"].(map[string]any); ok { + out = append(out, fmt.Sprintf("its %s boundary event repeats (every %v %v, at most %v times), which MDL cannot state yet (ako/mxcli#1716)", + kind, r["Interval"], r["IntervalType"], r["MaxExecutions"])) + } + if a, ok := bm["Annotation"].(map[string]any); ok { + text, _ := a["Description"].(string) + out = append(out, fmt.Sprintf("its %s boundary event has annotation '%s', which MDL cannot state", kind, text)) + } + } + return out +} + +// rawBoundaryEventKind is a stored boundary event's kind as MDL writes it after +// `boundary event`: "interrupting timer", "non interrupting notification", …. +func rawBoundaryEventKind(bm map[string]any) string { + t, _ := bm["$Type"].(string) + t = strings.TrimSuffix(strings.TrimPrefix(t, "Workflows$"), "BoundaryEvent") + kind := "interrupting" + if strings.HasPrefix(t, "NonInterrupting") { + kind = "non interrupting" + t = strings.TrimPrefix(t, "NonInterrupting") + } else { + t = strings.TrimPrefix(t, "Interrupting") + } + return kind + " " + strings.ToLower(t) +} + +// rawUnstatableWorkflowState lists, for CREATE OR MODIFY WORKFLOW, what the +// stored workflow holds that rebuilding it from stmt resets. A user task's name +// and description count only when the statement restates the task — dropping +// the task drops them on purpose — while boundary-event state counts wherever it +// is, since a boundary event the statement does not restate is refused already. +func rawUnstatableWorkflowState(raw map[string]any, stmt *ast.CreateWorkflowStmt) []string { + out := rawWorkflowTemplateLosses(raw) + captions := map[string]string{} // user task name -> the caption the statement gives it + walkWorkflowActivities(workflowStatementActivities(stmt), func(a ast.WorkflowActivityNode) { + if t, ok := a.(*ast.WorkflowUserTaskNode); ok { + captions[t.Name] = t.Caption + } + }) + walkRawDocs(raw, func(d map[string]any) { + name, _ := d["Name"].(string) + var reasons []string + if caption, restated := captions[name]; restated && isRawUserTask(d) { + reasons = append(reasons, rawUserTaskTemplateLosses(d, caption)...) + } + reasons = append(reasons, rawBoundaryEventLosses(d)...) + for _, r := range reasons { + out = append(out, fmt.Sprintf("activity '%s': %s", name, r)) + } + }) + return out +} + +// rawUnstatableActivityState lists, for REPLACE ACTIVITY, what the stored +// activity holds that rebuilding it from the replacement resets. +func rawUnstatableActivityState(d map[string]any, replacement ast.WorkflowActivityNode) []string { + var out []string + if isRawUserTask(d) { + caption := "" + if t, ok := replacement.(*ast.WorkflowUserTaskNode); ok { + caption = t.Caption + } + out = append(out, rawUserTaskTemplateLosses(d, caption)...) + } + return append(out, rawBoundaryEventLosses(d)...) +} diff --git a/mdl/executor/validate_workflow_unstatable_test.go b/mdl/executor/validate_workflow_unstatable_test.go new file mode 100644 index 0000000000..0bf59c69dd --- /dev/null +++ b/mdl/executor/validate_workflow_unstatable_test.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// ako/mxcli#1713: settings a Studio Pro author gives a workflow and MDL cannot +// state were reset by a describe -> create or modify round trip without a word. + +// unstatableWorkflow is a stored workflow with one user task, Review, and two +// timer boundary events, holding only what a rebuild writes anyway — the +// shape the probe app held before its settings were given (11.15.0). +func unstatableWorkflow() (wf, task, timer, reminder map[string]any) { + template := func(text string) map[string]any { + return map[string]any{"$Type": "Microflows$StringTemplate", "Text": text, "Parameters": []any{2}} + } + timer = map[string]any{"$Type": "Workflows$InterruptingTimerBoundaryEvent", "Caption": "", "FirstExecutionTime": "addDays([%CurrentDateTime%], 3)"} + reminder = map[string]any{"$Type": "Workflows$NonInterruptingTimerBoundaryEvent", "Caption": "", "Recurrence": nil, "FirstExecutionTime": "addDays([%CurrentDateTime%], 1)"} + task = map[string]any{ + "$Type": "Workflows$SingleUserTaskActivity", "Name": "Review", "Caption": "Review", + "TaskName": template("Review"), "TaskDescription": template(""), + "OnCreatedEvent": map[string]any{"$Type": "Workflows$NoEvent"}, + "BoundaryEvents": []any{2, timer, reminder}, + } + wf = map[string]any{ + "$Type": "Workflows$Workflow", + "WorkflowName": template("W"), + "WorkflowDescription": template(""), + "Flow": map[string]any{"$Type": "Workflows$Flow", "Activities": []any{3, + map[string]any{"$Type": "Workflows$StartWorkflowActivity", "Name": "start1"}, + task, + map[string]any{"$Type": "Workflows$EndWorkflowActivity", "Name": "end1"}, + }}, + "OnWorkflowEvent": []any{2}, + } + return wf, task, timer, reminder +} + +func templateParam(expr string) []any { + return []any{2, map[string]any{"$Type": "Microflows$TemplateParameter", "Expression": expr}} +} + +const wfUnstatableRestated = `create or modify workflow M.W parameter $WorkflowContext: M.Ctx + display 'W' +begin + user task Review 'Review' page M.P outcomes 'Done' { } + boundary event interrupting timer addDays([%CurrentDateTime%], 3) { } + boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { }; +end workflow;` + +func TestWorkflowRewrite_RefusesResettingUnstatableSettings(t *testing.T) { + cases := []struct { + name string + set func(wf, task, timer, reminder map[string]any) + want string + }{ + {"task name parameter", func(_, task, _, _ map[string]any) { + task["TaskName"].(map[string]any)["Parameters"] = templateParam("$WorkflowContext/Name") + }, "'Review' with ({1} = $WorkflowContext/Name)"}, + {"task name other than the caption", func(_, task, _, _ map[string]any) { + task["TaskName"].(map[string]any)["Text"] = "Review the request" + }, "task name 'Review the request' is not its caption 'Review'"}, + {"task description parameter", func(_, task, _, _ map[string]any) { + task["TaskDescription"].(map[string]any)["Parameters"] = templateParam("$WorkflowContext/Title") + }, "description '' with ({1} = $WorkflowContext/Title)"}, + {"workflow display parameter", func(wf, _, _, _ map[string]any) { + wf["WorkflowName"].(map[string]any)["Parameters"] = templateParam("$WorkflowContext/Title") + }, "display 'W' with ({1} = $WorkflowContext/Title)"}, + {"timer caption", func(_, _, timer, _ map[string]any) { timer["Caption"] = "Escalate" }, + "interrupting timer boundary event has caption 'Escalate'"}, + {"recurrence", func(_, _, _, reminder map[string]any) { + reminder["Recurrence"] = map[string]any{"$Type": "Workflows$LinearRecurrence", "Interval": int32(1), "IntervalType": "Day", "MaxExecutions": int32(5)} + }, "repeats (every 1 Day, at most 5 times)"}, + {"boundary event annotation", func(_, _, _, reminder map[string]any) { + reminder["Annotation"] = map[string]any{"$Type": "Workflows$Annotation", "Description": "nudge"} + }, "annotation 'nudge'"}, + } + stmt := parseWorkflowStmt(t, wfUnstatableRestated) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + wf, task, timer, reminder := unstatableWorkflow() + c.set(wf, task, timer, reminder) + err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, wf), "wf1", "M.W", stmt) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("rewrite must be refused naming %q, got %v", c.want, err) + } + }) + } +} + +// CONTROLS: what a rebuild reproduces is not refused — the stored workflow +// holding only that; a task name the statement's caption restates; and a task +// the statement drops, whose name goes with it on purpose. +func TestWorkflowRewrite_AllowsWhatARebuildRestates(t *testing.T) { + wf, _, _, _ := unstatableWorkflow() + if err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, wf), "wf1", "M.W", parseWorkflowStmt(t, wfUnstatableRestated)); err != nil { + t.Errorf("nothing to lose, got %v", err) + } + + wf, task, _, _ := unstatableWorkflow() + task["TaskName"].(map[string]any)["Text"] = "Review the request" + task["Caption"] = "Review the request" + restated := strings.Replace(wfUnstatableRestated, "user task Review 'Review'", "user task Review 'Review the request'", 1) + if err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, wf), "wf1", "M.W", parseWorkflowStmt(t, restated)); err != nil { + t.Errorf("the caption restates the task name, got %v", err) + } + + wf, task, _, _ = unstatableWorkflow() + task["TaskName"].(map[string]any)["Text"] = "Review the request" + task["BoundaryEvents"] = []any{2} + dropped := `create or modify workflow M.W parameter $WorkflowContext: M.Ctx + display 'W' +begin + user task Approve 'Approve' page M.P outcomes 'Done' { }; +end workflow;` + if err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, wf), "wf1", "M.W", parseWorkflowStmt(t, dropped)); err != nil { + t.Errorf("a dropped task's name is dropped on purpose, got %v", err) + } +} + +// REPLACE ACTIVITY rebuilds the activity from the statement, so it resets the +// same settings on the activity it replaces. +func TestAlterWorkflow_ReplaceRefusesToResetUnstatableSettings(t *testing.T) { + replace := func() *ast.AlterWorkflowStmt { + prog, errs := visitor.Build(`alter workflow M.W replace activity userTask1 with user task userTask1 'Review' page M.P outcomes 'Done' { } + boundary event interrupting timer addDays([%CurrentDateTime%], 3) { };`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + return prog.Statements[0].(*ast.AlterWorkflowStmt) + } + wf, task, timer, _ := unstatableWorkflow() + task["Name"] = "userTask1" + if got := validateAlterReplaceKeepsStudioProState(storedReplaceCtx(t, wf), replace()); len(got) != 0 { + t.Fatalf("control: nothing to lose, got %v", got) + } + timer["Caption"] = "Escalate" + got := validateAlterReplaceKeepsStudioProState(storedReplaceCtx(t, wf), replace()) + if len(got) != 1 || !strings.Contains(got[0], "caption 'Escalate'") { + t.Errorf("must be refused naming the timer's caption, got %v", got) + } +} From ceb71180f4dcb45e79b33838c715497a5acaca7e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:42:39 +0000 Subject: [PATCH 28/40] =?UTF-8?q?feat:=20returns=20(=20=E2=80=A6=20)=20syn?= =?UTF-8?q?tax=20for=20flow-call=20output=20mappings=20(#977=20item=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call microflow / call nanoflow action can map what the flow returns back onto the page (Mendix 11.5, Studio Pro's "Return value mappings", Forms$OutputMapping). MDL had no syntax for them: describe printed nothing, and #1726 could only carry the stored ones through a rewrite. call microflow M.ACT_Vehicle_Get returns ( Brand = $ActionReturnValue/Brand, -- attribute of the result $stringResult = $ActionReturnValue, -- the result itself Brand = 'Demo brand') -- an expression A bare target is an attribute of the enclosing data container's object, $name a page variable. The three stored shapes match what Studio Pro 11.15 writes on ako/TestApp's Pages.OutputMappingButton (SourceVariable holds the target variable; the result itself fills no source slot). describe prints the list, a Studio Pro page round-trips (only the data view's FormValidations differs, which is #1729), and alter page set takes it. Floors: 11.5 (metamodel) for any mapping; 11.15 for an attribute target, measured: mx 11.13.0 and 11.14.0 report CE7006 on the same bytes 11.15.0-rc.4 accepts, before and after mx convert. Both are in the statement gate table, so check -p refuses what exec would. The carry is removed: with syntax, the statement is the authority, and the carry restored mappings a script had deliberately removed. A stored mapping the syntax cannot spell (a path over an association) is noted under the button instead. The MCP backend refuses mappings rather than dropping them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- .../2026-10-26-flow-call-returns-syntax.json | 1 + ...-10-26-button-output-mappings-carried.json | 1 - .../mendix/create-page/reference/widgets.md | 1 + cmd/mxcli/syntax/features_page.go | 2 +- docs-site/src/reference/page/create-page.md | 27 ++ docs/01-project/MDL_QUICK_REFERENCE.md | 1 + .../977-3-button-output-mappings-carried.mdl | 31 -- .../bug-tests/977-3-flow-call-returns.mdl | 72 +++++ mdl/ast/ast_page_v3.go | 13 + mdl/backend/mcp/page_widgets.go | 3 + mdl/backend/mcp/widget.go | 3 + mdl/backend/modelsdk/widget_write.go | 44 ++- .../button_output_mappings_977_test.go | 119 ------- mdl/executor/cmd_alter_page.go | 12 +- mdl/executor/cmd_fragments.go | 2 +- mdl/executor/cmd_pages_builder_v3.go | 10 +- mdl/executor/cmd_pages_describe.go | 7 +- mdl/executor/cmd_pages_describe_output.go | 19 +- mdl/executor/cmd_pages_describe_parse.go | 23 +- mdl/executor/flow_returns_977_test.go | 299 ++++++++++++++++++ mdl/executor/page_flow_returns.go | 248 +++++++++++++++ mdl/executor/page_flow_returns_test.go | 132 ++++++++ mdl/executor/version_gates.go | 55 +++- mdl/grammar/domains/MDLPage.g4 | 21 +- mdl/visitor/visitor_flow_returns_test.go | 47 +++ mdl/visitor/visitor_page_v3.go | 23 ++ modelsdk/canon/identity.go | 6 - modelsdk/canon/outputmappings.go | 183 ----------- modelsdk/canon/outputmappings_977_test.go | 123 ------- sdk/pages/pages_widgets_action.go | 20 ++ sdk/versions/mendix-11.yaml | 16 + 31 files changed, 1052 insertions(+), 512 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-flow-call-returns-syntax.json delete mode 100644 .claude/skills/fix-issue/findings/modelsdk/2026-10-26-button-output-mappings-carried.json delete mode 100644 mdl-examples/bug-tests/977-3-button-output-mappings-carried.mdl create mode 100644 mdl-examples/bug-tests/977-3-flow-call-returns.mdl delete mode 100644 mdl/executor/button_output_mappings_977_test.go create mode 100644 mdl/executor/flow_returns_977_test.go create mode 100644 mdl/executor/page_flow_returns.go create mode 100644 mdl/executor/page_flow_returns_test.go create mode 100644 mdl/visitor/visitor_flow_returns_test.go delete mode 100644 modelsdk/canon/outputmappings.go delete mode 100644 modelsdk/canon/outputmappings_977_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-flow-call-returns-syntax.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-flow-call-returns-syntax.json new file mode 100644 index 0000000000..1ab22f61b9 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-flow-call-returns-syntax.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "A flow call's return value mappings (Mendix 11.5 Forms$OutputMapping, Studio Pro's \"Return value mappings\") had no MDL: describe printed nothing, describe → exec of a Studio Pro page dropped every mapping (Replaced page, mx check silent), and a script could not write one. The stopgap carry (canon.CarryOutputMappings) kept them through a rewrite but also restored mappings a script had deliberately removed", "cause": "No syntax and, until ako/TestApp Pages.OutputMappingButton (Studio Pro 11.15), no measured shape to write — so the rebuild wrote the empty list", "file": "mdl/grammar/domains/MDLPage.g4 (actionReturnsV3), mdl/visitor/visitor_page_v3.go (buildActionReturnsV3), mdl/executor/page_flow_returns.go (applyFlowReturns, storedFlowReturns), mdl/executor/version_gates.go (flowReturnsGates), mdl/backend/modelsdk/widget_write.go (outputMappingToGen, Forms$OutputMapping defaults), sdk/versions/mendix-11.yaml (flow_call_output_mappings 11.5, _to_attribute 11.15); carry removed from modelsdk/canon", "insight": "Studio Pro's slot names lie: SourceVariable is the TARGET page variable, and the return value itself fills no source slot (AttributeRef/SourceAttributeRef/Expression/SourceVariable map to 3 measured row kinds). A carry is a stopgap that must go when syntax lands, or a statement that omits the clause gets the stored mappings back — the control that proves it is 'remove one returns clause, count drops by one'. The metamodel floor (outputMappings introduced 11.5.0) was not the whole floor: the identical bytes fail CE7006 on mx 11.13.0 and 11.14.0 and pass on 11.15.0-rc.4, before and after `mx convert`, while a page-variable target passes on all three — measure per target kind with `mx convert -p -s` to the next version rather than trusting the introduction version. The Studio Pro sample itself crashed mx 11.15 check (NullReferenceException in OutputMappingUtils.Check) because its nanoflow call names no nanoflow (#1728); wire a real flow into a scratch copy before using such a page as a control. Gate in statementVersionGates as well as the builder, or check -p passes what exec refuses (#1434)", "refs": ["ako/mxcli#977", "ako/mxcli#1726", "ako/mxcli#1728", "ako/mxcli#1729"], "ce": ["CE7006"]} diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-10-26-button-output-mappings-carried.json b/.claude/skills/fix-issue/findings/modelsdk/2026-10-26-button-output-mappings-carried.json deleted file mode 100644 index 5a75f63682..0000000000 --- a/.claude/skills/fix-issue/findings/modelsdk/2026-10-26-button-output-mappings-carried.json +++ /dev/null @@ -1 +0,0 @@ -{"area": "modelsdk/canon", "date": "2026-10-26", "symptom": "A button whose flow call has output mappings (Mendix 11.5, Forms$MicroflowSettings / Forms$CallNanoflowClientAction .OutputMappings) lost them on describe → exec: describe printed nothing for them, exec reported Replaced page and wrote the empty list; mx check silent", "cause": "MDL has no syntax for output mappings and no fixture or Studio Pro sample shows a populated one, so the rebuild could only write the empty list Studio Pro stores on every other call (written since 2026-10-10, which fixed the empty-list drop #977 originally recorded)", "file": "modelsdk/canon/outputmappings.go (CarryOutputMappings, wired into Reconcile before TransplantIDs), mdl/executor/cmd_pages_describe_parse.go + _output.go (countOutputMappings, outputMappingsNote)", "insight": "When a property has no syntax and no measured shape, carry it rather than invent it: pair the stored and rebuilt holder by owning named element + path (exact, list-free, unique — the same addressing as the translation carry), carry only into an EMPTY rebuilt list and only while the call names the same flow, and keep ids verbatim so the canonical form sees the same elements. Length-changing carries must run before TransplantIDs. Check an issue's remaining scope against main before building: #977's recorded symptom (the empty list) had been fixed six days after it was filed; the propdist tally (180/180 empty) and a BSON-patched probe separated that from the live non-empty case", "refs": ["ako/mxcli#977"]} diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 3bdfb1b183..4249962a48 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -127,6 +127,7 @@ describe icon collection Atlas_Core.Atlas_Filled; -- every icon + its referenc - `action: call nanoflow Module.NanoflowName` - Call nanoflow (client-side) - `action: call nanoflow Module.NanoflowName(Param = $value)` - Call nanoflow with parameters - `action: call microflow Module.Delete(Obj = $currentObject) with (Confirmation: 'Delete this?', ProgressBar: Blocking)` - Ask for confirmation and show a progress bar. The `with ( … )` settings: `DisabledDuringExecution` (any action, default `true`), `ProgressBar` (`None`/`NonBlocking`/`Blocking`), `ProgressMessage`, `Confirmation`, `ProceedCaption`, `CancelCaption` (flow calls), `Asynchronous`, `FormValidations` (`All`/`Widget`/`None`, microflow calls only) +- `action: call microflow Module.GetVehicle returns (Brand = $ActionReturnValue/Brand, $result = $ActionReturnValue)` - Map the flow's return value back onto the page (Studio Pro's "Return value mappings", Mendix 11.5+). Target = source: a bare target is an attribute of the enclosing data container's object (needs 11.15 — mx 11.13/11.14 report CE7006), `$name` a page variable; the source is `$ActionReturnValue`, `$ActionReturnValue/Attr` or an expression. Goes before `with ( … )` - `action: open link 'https://example.com'` - Open a fixed web address - `action: open link $currentObject/URL` - Open the address held in an attribute of the enclosing data container's object (inside a data container only; not over an association) - **Every parameter needs an argument, or an enclosing data container of its diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 6ca08ea0b6..e98de25c99 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -293,7 +293,7 @@ LIST IMPACT OF htmlelement; "confirmation", "confirm", "are you sure", "progress bar", "progress message", "disabled during execution", "disabled during action", "asynchronous", "form validations", }, - Syntax: "Action: NOTHING -- deliberately no action (Forms$NoAction)\nAction: SAVE CHANGES\nAction: SAVE CHANGES CLOSE PAGE -- save, then close the pop-up\nAction: CANCEL CHANGES\nAction: CANCEL CHANGES CLOSE PAGE\nAction: CLOSE PAGE\nAction: DELETE\nAction: DELETE CLOSE PAGE\nAction: CALL NANOFLOW Module.NF\nAction: CALL NANOFLOW Module.NF(Param = $val)\nAction: OPEN LINK 'https://example.com'\nAction: OPEN LINK $currentObject/URL -- address read from an attribute\nAction: SIGN OUT\nAction: COMPLETE TASK 'OutcomeName'\nAction: SHOW PAGE Module.Page\nAction: SHOW PAGE Module.Page(Param = $currentObject)\nAction: CALL MICROFLOW Module.MF\nAction: CALL MICROFLOW Module.MF(Param = $val)\nAction: CREATE OBJECT Module.Entity THEN SHOW PAGE Module.Page\n\nThe list above is exhaustive. Anything else in an action slot is an\nERROR: `Action: OPEN LINK` without a URL or `Action: SHOW PAGE` without a\npage is a syntax error, and a value that is not an action at all\n(or a snake-case keyword short its argument) is MDL-WIDGET28.\nSuch a widget used to be written with NO action at all and rendered as a\ndead control, with check, exec and mxbuild all clean, because a\nno-action widget is legal Mendix (mendixlabs/mxcli#1062). Write NOTHING\nwhen a control really is meant to be inert.\n\nThe actions are the words a microflow uses (R8). The snake-case spellings\n(SAVE_CHANGES, SHOW_PAGE, CLOSE_PAGE, DELETE_OBJECT, OPEN_LINK, SIGN_OUT, ...)\nand a flow call without CALL still parse, but are deprecated (MDL-DEPR020);\n`mxcli fmt --upgrade` rewrites them.\n\nThe same forms serve `OnClick:` (an alias of `Action:`) and `OnChange:`.\n\nSETTINGS. Any action but NOTHING takes a `with ( … )` list of the\nsettings Studio Pro shows under the event (ako/mxcli#721):\n\n DisabledDuringExecution: true|false -- every action; default true\n ProgressBar: None|NonBlocking|Blocking -- flow calls; default None\n ProgressMessage: 'text' -- flow calls\n Confirmation: 'question' -- flow calls: ask before running\n ProceedCaption: 'text', CancelCaption: 'text' -- its buttons; default Proceed / Cancel\n Asynchronous: true|false -- call microflow only; default false\n FormValidations: All|Widget|None -- call microflow only; default All\n\nAction: CALL MICROFLOW M.Delete(Order = $currentObject) with (\n ProgressBar: Blocking, ProgressMessage: 'Deleting…',\n Confirmation: 'Delete this order?', ProceedCaption: 'Delete', CancelCaption: 'Keep')\n\nA setting left out keeps its default. DESCRIBE prints only the settings\nthat differ from it. An unknown key, or one the action does not have, is an\nerror. For CREATE OBJECT … THEN SHOW PAGE …, the list goes at the end and\nbelongs to the create.\n\nA microflow or nanoflow action is a CALL: it needs an argument for every\nparameter the flow declares, or Mendix rejects the page with CE1571. The\nargument list is the same on every widget that takes an action -- a\nCONTAINER (which is clickable) as much as an ACTIONBUTTON. An enclosing\ndata container of the right type supplies it without an argument; a data\ngrid's CONTROL BAR does not, because it is not row-scoped -- pass the\ngrid's selection there (`$dgOrders`).\n\nA SHOW PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\nThe exception is a parameter of the page or snippet itself: it is bound\nby name (Studio Pro stores an explicit mapping), so `(Item = $Item)` works\nanywhere, outside every data widget too (mendixlabs/mxcli#1403).\nOutside any data widget there is no context object to infer, so such a\nbutton takes no OTHER argument -- not $currentObject, not a literal.\nmxcli used to drop it in silence and mxbuild then reported CE1571 per\nparameter of the target page (mendixlabs/mxcli#1029). Route that\nnavigation through a microflow.\n\nOPEN LINK takes a web address, stored as a Forms$StaticOrDynamicString:\neither a literal, or $currentObject/Attr to read it from an attribute of\nthe enclosing data container's object at runtime (Studio Pro's \"Address:\nattribute\"). An address over an association path is not supported yet.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\n\nMendix has THREE icon elements and the keyword picks which one:\n\nIcon: 'Atlas_Core.Atlas_Filled.pencil' -- an icon collection\nIcon: image MyModule.Images.logo -- an IMAGE collection\nIcon: glyph 57377 -- a font code point\n\nThe bare form is the icon-collection icon and any collection in the\nproject works, third-party ones included. The image form points into a\ndifferent document, and is spelled the same way apart from the keyword\n-- write it without `image` and mxcli stores a custom-icon reference,\nwhich fails the build with CE1613 (mendixlabs/mxcli#1059).\n`mxcli check -p … --references` resolves each kind against its own\ncollection and names the remedy when the kind is wrong.\n\nA glyph carries a code and no name. Codes are sparse, and an undefined\none fails only at `mxbuild --target=deploy`, naming the PAGE rather\nthan the icon -- so MDL078 refuses it (an error) against the font's own\ntable, naming the defined glyphs either side of the code.\nList them with `list glyphs`.\n\nA name may be quoted or bare; a hyphenated segment is double-quoted on\nits own: Atlas_Core.Atlas.\"align-center\".\n\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).", + Syntax: "Action: NOTHING -- deliberately no action (Forms$NoAction)\nAction: SAVE CHANGES\nAction: SAVE CHANGES CLOSE PAGE -- save, then close the pop-up\nAction: CANCEL CHANGES\nAction: CANCEL CHANGES CLOSE PAGE\nAction: CLOSE PAGE\nAction: DELETE\nAction: DELETE CLOSE PAGE\nAction: CALL NANOFLOW Module.NF\nAction: CALL NANOFLOW Module.NF(Param = $val)\nAction: OPEN LINK 'https://example.com'\nAction: OPEN LINK $currentObject/URL -- address read from an attribute\nAction: SIGN OUT\nAction: COMPLETE TASK 'OutcomeName'\nAction: SHOW PAGE Module.Page\nAction: SHOW PAGE Module.Page(Param = $currentObject)\nAction: CALL MICROFLOW Module.MF\nAction: CALL MICROFLOW Module.MF(Param = $val)\nAction: CREATE OBJECT Module.Entity THEN SHOW PAGE Module.Page\n\nThe list above is exhaustive. Anything else in an action slot is an\nERROR: `Action: OPEN LINK` without a URL or `Action: SHOW PAGE` without a\npage is a syntax error, and a value that is not an action at all\n(or a snake-case keyword short its argument) is MDL-WIDGET28.\nSuch a widget used to be written with NO action at all and rendered as a\ndead control, with check, exec and mxbuild all clean, because a\nno-action widget is legal Mendix (mendixlabs/mxcli#1062). Write NOTHING\nwhen a control really is meant to be inert.\n\nThe actions are the words a microflow uses (R8). The snake-case spellings\n(SAVE_CHANGES, SHOW_PAGE, CLOSE_PAGE, DELETE_OBJECT, OPEN_LINK, SIGN_OUT, ...)\nand a flow call without CALL still parse, but are deprecated (MDL-DEPR020);\n`mxcli fmt --upgrade` rewrites them.\n\nThe same forms serve `OnClick:` (an alias of `Action:`) and `OnChange:`.\n\nSETTINGS. Any action but NOTHING takes a `with ( … )` list of the\nsettings Studio Pro shows under the event (ako/mxcli#721):\n\n DisabledDuringExecution: true|false -- every action; default true\n ProgressBar: None|NonBlocking|Blocking -- flow calls; default None\n ProgressMessage: 'text' -- flow calls\n Confirmation: 'question' -- flow calls: ask before running\n ProceedCaption: 'text', CancelCaption: 'text' -- its buttons; default Proceed / Cancel\n Asynchronous: true|false -- call microflow only; default false\n FormValidations: All|Widget|None -- call microflow only; default All\n\nAction: CALL MICROFLOW M.Delete(Order = $currentObject) with (\n ProgressBar: Blocking, ProgressMessage: 'Deleting…',\n Confirmation: 'Delete this order?', ProceedCaption: 'Delete', CancelCaption: 'Keep')\n\nA setting left out keeps its default. DESCRIBE prints only the settings\nthat differ from it. An unknown key, or one the action does not have, is an\nerror. For CREATE OBJECT … THEN SHOW PAGE …, the list goes at the end and\nbelongs to the create.\n\nRETURN VALUE MAPPINGS. A flow call can map what the flow returns back onto\nthe page -- Studio Pro's \"Return value mappings\" (Mendix 11.5). The list\ngoes after the arguments and before `with ( … )`, target = source:\n\nAction: CALL MICROFLOW M.ACT_Vehicle_Get returns (\n Brand = $ActionReturnValue/Brand, -- an attribute of the returned object\n $stringResult = $ActionReturnValue, -- the return value itself\n Brand = 'Demo brand') -- any expression\n\nA bare target is an attribute of the enclosing data container's object;\n`$name` is a page variable. $ActionReturnValue/Attr needs a flow that\nreturns an object. A mapping into an attribute needs Mendix 11.15: mx\n11.13 and 11.14 report CE7006 on it. Leave the list out and the call maps\nnothing; DESCRIBE prints the stored mappings, and re-running a statement\nwithout them removes them (ako/mxcli#977).\n\nA microflow or nanoflow action is a CALL: it needs an argument for every\nparameter the flow declares, or Mendix rejects the page with CE1571. The\nargument list is the same on every widget that takes an action -- a\nCONTAINER (which is clickable) as much as an ACTIONBUTTON. An enclosing\ndata container of the right type supplies it without an argument; a data\ngrid's CONTROL BAR does not, because it is not row-scoped -- pass the\ngrid's selection there (`$dgOrders`).\n\nA SHOW PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\nThe exception is a parameter of the page or snippet itself: it is bound\nby name (Studio Pro stores an explicit mapping), so `(Item = $Item)` works\nanywhere, outside every data widget too (mendixlabs/mxcli#1403).\nOutside any data widget there is no context object to infer, so such a\nbutton takes no OTHER argument -- not $currentObject, not a literal.\nmxcli used to drop it in silence and mxbuild then reported CE1571 per\nparameter of the target page (mendixlabs/mxcli#1029). Route that\nnavigation through a microflow.\n\nOPEN LINK takes a web address, stored as a Forms$StaticOrDynamicString:\neither a literal, or $currentObject/Attr to read it from an attribute of\nthe enclosing data container's object at runtime (Studio Pro's \"Address:\nattribute\"). An address over an association path is not supported yet.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\n\nMendix has THREE icon elements and the keyword picks which one:\n\nIcon: 'Atlas_Core.Atlas_Filled.pencil' -- an icon collection\nIcon: image MyModule.Images.logo -- an IMAGE collection\nIcon: glyph 57377 -- a font code point\n\nThe bare form is the icon-collection icon and any collection in the\nproject works, third-party ones included. The image form points into a\ndifferent document, and is spelled the same way apart from the keyword\n-- write it without `image` and mxcli stores a custom-icon reference,\nwhich fails the build with CE1613 (mendixlabs/mxcli#1059).\n`mxcli check -p … --references` resolves each kind against its own\ncollection and names the remedy when the kind is wrong.\n\nA glyph carries a code and no name. Codes are sparse, and an undefined\none fails only at `mxbuild --target=deploy`, naming the PAGE rather\nthan the icon -- so MDL078 refuses it (an error) against the font's own\ntable, naming the defined glyphs either side of the code.\nList them with `list glyphs`.\n\nA name may be quoted or bare; a hyphenated segment is double-quoted on\nits own: Atlas_Core.Atlas.\"align-center\".\n\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).", Example: "ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\nACTIONBUTTON btnEdit (Caption: 'Edit',\n Action: SHOW PAGE Module.EditPage(Item = $currentObject))\nLINKBUTTON btnDelete (Caption: 'Delete', Action: DELETE,\n Icon: 'Atlas_Core.Atlas_Filled.pencil')\n\n-- A clickable CONTAINER in a data grid's control bar, calling a nanoflow\n-- with the grid's selection as its argument.\nDATAGRID dgOrders (DataSource: DATABASE FROM Sales.Order, Selection: Single) {\n COLUMN (Attribute: Number, Caption: 'Order #')\n CONTROLBAR {\n CONTAINER cShip (Class: 'command',\n Action: CALL NANOFLOW Sales.ACT_Ship(Order = $dgOrders)) {\n ACTIONBUTTON btnShip (Caption: 'Ship')\n }\n }\n}", SeeAlso: []string{"page.widgets"}, }) diff --git a/docs-site/src/reference/page/create-page.md b/docs-site/src/reference/page/create-page.md index cd6dce27b2..8ba2c8bfa4 100644 --- a/docs-site/src/reference/page/create-page.md +++ b/docs-site/src/reference/page/create-page.md @@ -168,6 +168,33 @@ that differ from it. An unknown key, or a setting the action does not have, is an error. For `CREATE OBJECT … THEN SHOW PAGE …` the list goes at the end and belongs to the create action. +#### Return value mappings + +A microflow or nanoflow call can map what the flow returns back onto the page — +Studio Pro's **Return value mappings** (Mendix 11.5+). The `returns ( … )` list +goes after the arguments and before `with ( … )`, one `target = source` per row: + +```sql +DATAVIEW dvVehicle (DataSource: $Vehicle) { + ACTIONBUTTON btnRefresh (Caption: 'Refresh', + Action: CALL MICROFLOW Shop.ACT_Vehicle_Get returns ( + Brand = $ActionReturnValue/Brand, -- an attribute of the returned object + $stringResult = $ActionReturnValue, -- the return value itself + Brand = 'Demo brand')) -- any expression +} +``` + +| Target | Means | Needs | +|--------|-------|-------| +| `Attr` (bare) | an attribute of the enclosing data container's object | Mendix 11.15 — mx 11.13 and 11.14 report CE7006 on it | +| `$name` | a page variable (`Variables: ( … )`) | Mendix 11.5 | + +The source is `$ActionReturnValue`, `$ActionReturnValue/Attr` (the flow must +return an object that has `Attr`), or any expression. `DESCRIBE` prints the +stored mappings, and re-running a statement without the list removes them — the +statement is the authority. A mapping `DESCRIBE` cannot spell (a path over an +association) is printed as a comment under the widget, saying a re-run drops it. + A microflow or nanoflow action is a **call**: every parameter the flow declares needs an argument, or Mendix rejects the page with **CE1571**. An enclosing data container of the parameter's type supplies it without one — but a data grid's diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 7e5a7f2acb..030a67ddf9 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1520,6 +1520,7 @@ MDL uses explicit property declarations for pages: | No action | `action: nothing` | `actionbutton btn (caption: 'Decorative', action: nothing)` — an explicitly inert control. Write it deliberately: an action keyword **short its argument** (`action: open link` with no URL) is now an error rather than a widget silently written with no action at all | | Microflow action | `action: call microflow Name(Param = val)` | `action: call microflow Mod.ACT_Process(Order = $Order)` | | Action settings | `action: … with (Key: value, …)` | `action: call microflow Mod.ACT_Delete(Order = $currentObject) with (ProgressBar: Blocking, ProgressMessage: 'Deleting…', Confirmation: 'Delete this order?', ProceedCaption: 'Delete', CancelCaption: 'Keep')` — `DisabledDuringExecution` on any action (default `true`); `ProgressBar` (`None`/`NonBlocking`/`Blocking`), `ProgressMessage`, `Confirmation`, `ProceedCaption`, `CancelCaption` on flow calls; `Asynchronous`, `FormValidations` (`All`/`Widget`/`None`) on `call microflow`. Omitted keys keep their defaults; describe prints only the ones that differ. Before this spelling existed a describe → exec dropped every button confirmation and progress bar (ako/mxcli#721) | +| Return value mappings | `call microflow\|nanoflow M.F(…) returns (Target = Source, …)` | `action: call microflow Mod.ACT_Vehicle_Get returns (Brand = $ActionReturnValue/Brand, $stringResult = $ActionReturnValue, Brand = 'Demo brand')` — Studio Pro's "Return value mappings" (Mendix 11.5+). Target: an attribute of the enclosing data container's object (bare name, needs 11.15 — mx 11.13/11.14 report CE7006) or a page variable (`$name`). Source: `$ActionReturnValue`, `$ActionReturnValue/Attr` (flow must return an object), or an expression. Goes before `with ( … )`; describe prints the stored mappings, and a statement without the list removes them (ako/mxcli#977) | | Button icon | `icon: 'Module.IconCollection.IconName'` | `linkbutton btn (caption: 'Edit', action: nothing, icon: 'Atlas_Core.Atlas_Filled.pencil')` — the **icon-collection** icon; MxBuild rejects an unknown name (CE1613) | | Image icon | `icon: image Module.ImageCollection.Name` | `actionbutton btn (caption: 'Logo', action: nothing, icon: image MyMod.Images.logo)` — an **image** collection is a different document from an icon collection, and the names are spelled the same, so the keyword is what separates them. Written without `image` it is stored as a custom-icon reference and the build fails **CE1613** | | Glyph icon | `icon: glyph ` | `actionbutton btn (caption: 'Home', action: nothing, icon: glyph 57377)` — a font code point with no name. Codes are sparse; an undefined one fails only at `mxbuild --target=deploy`, naming the **page**, so **MDL078** checks it. Browse with `list glyphs` | diff --git a/mdl-examples/bug-tests/977-3-button-output-mappings-carried.mdl b/mdl-examples/bug-tests/977-3-button-output-mappings-carried.mdl deleted file mode 100644 index 602f5a776f..0000000000 --- a/mdl-examples/bug-tests/977-3-button-output-mappings-carried.mdl +++ /dev/null @@ -1,31 +0,0 @@ -mdl 1; --- ============================================================================ --- ako/mxcli#977 item 3 — "A button's `OutputMappings` are not round-tripped." --- --- The empty list Studio Pro stores on every flow call is written since --- 2026-10-10 (widget_write_mapping_markers_test.go). A NON-empty list — Mendix --- 11.5's mapping of a flow's result back onto the page — has no MDL, so a --- describe → exec of a page whose button had one reported "Replaced page" and --- dropped it; mx check is silent about it. --- --- A rebuild now keeps the stored mappings while the button still calls the --- same flow (canon.CarryOutputMappings), and describe prints a comment under --- the button: --- --- -- btnGo: 1 output mapping, which MDL cannot show; kept on a rewrite while --- the button calls the same flow --- --- Calling another flow drops them, since they read the old flow's result. The --- mappings themselves are authored in Studio Pro; this script makes the button --- they hang on. --- ============================================================================ - -create module BugOutputMappings; - -create microflow BugOutputMappings.ACT_Go () -begin -end; - -create page BugOutputMappings.Go_Page (Title: 'Go', Layout: Atlas_Core.Atlas_Default) { - actionbutton btnGo (Caption: 'Go', Action: call microflow BugOutputMappings.ACT_Go) -}; diff --git a/mdl-examples/bug-tests/977-3-flow-call-returns.mdl b/mdl-examples/bug-tests/977-3-flow-call-returns.mdl new file mode 100644 index 0000000000..3fe396f494 --- /dev/null +++ b/mdl-examples/bug-tests/977-3-flow-call-returns.mdl @@ -0,0 +1,72 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#977 item 3 — "A button's `OutputMappings` are not round-tripped." +-- +-- Mendix 11.5 lets a call microflow / call nanoflow action map the flow's +-- result back onto the page: Studio Pro's "Return value mappings", stored as +-- Forms$OutputMapping in MicroflowSettings.OutputMappings (microflow) or the +-- action's own OutputMappings (nanoflow). MDL had no syntax for them, so a +-- describe → exec of a Studio Pro page dropped every mapping ("Replaced page", +-- mx check silent), and a script could not write one. +-- +-- `returns ( … )` after the arguments spells them, target = source: +-- +-- Brand = $ActionReturnValue/Brand AttributeRef + SourceAttributeRef +-- $stringResult = $ActionReturnValue SourceVariable (the target variable) +-- Brand = 'Demo brand' AttributeRef + Expression +-- +-- the three shapes Studio Pro 11.15 stores on ako/TestApp's +-- Pages.OutputMappingButton. An attribute target belongs to the data +-- container's object; `$name` is a page variable. +-- +-- Run it on a Mendix 11.15+ app. A mapping INTO AN ATTRIBUTE needs 11.15: +-- mx 11.13.0 and 11.14.0 report CE7006 "Selected value is not valid for +-- attribute 'Brand'" on the same bytes 11.15.0-rc.4 accepts, so on an older +-- project check -p and exec refuse it (MDL-VERSION01). A page-variable target +-- checks clean from 11.13 on. +-- +-- mxcli exec mdl-examples/bug-tests/977-3-flow-call-returns.mdl -p app.mpr +-- mxcli describe page BugFlowReturns.OM_Page -- prints the four returns +-- (re-exec the description) -- Unchanged page +-- mx check app.mpr -- 0 errors (11.15.0-rc.4) +-- +-- Removing a returns clause removes that mapping: the statement is the +-- authority (the stopgap that carried stored mappings through a rewrite is +-- gone, because it restored mappings a script had deliberately removed). +-- ============================================================================ + +create module BugFlowReturns; + +create persistent entity BugFlowReturns.Vehicle (Brand: String(200)); + +create microflow BugFlowReturns.ACT_Vehicle_Get () returns BugFlowReturns.Vehicle +begin + $Vehicle = create BugFlowReturns.Vehicle; + return $Vehicle; +end; + +create microflow BugFlowReturns.ACT_String_Get () returns String +begin + return 'from the flow'; +end; + +create nanoflow BugFlowReturns.NF_Vehicle_Get () returns BugFlowReturns.Vehicle +begin + $Vehicle = create BugFlowReturns.Vehicle; + return $Vehicle; +end; + +create page BugFlowReturns.OM_Page ( + Title: 'Output mappings', + Layout: Atlas_Core.Atlas_Default, + Params: ($Vehicle: BugFlowReturns.Vehicle), + Variables: ($stringResult: String = empty) +) { + dataview dvVehicle (DataSource: $Vehicle) { + actionbutton btnAttributeToAttribute (Caption: 'Attribute', Action: call microflow BugFlowReturns.ACT_Vehicle_Get returns (Brand = $ActionReturnValue/Brand)) + actionbutton btnToPageVariable (Caption: 'Variable', Action: call microflow BugFlowReturns.ACT_String_Get returns ($stringResult = $ActionReturnValue)) + actionbutton btnExpressionMapping (Caption: 'Expression', Action: call microflow BugFlowReturns.ACT_Vehicle_Get returns (Brand = 'Demo brand')) + actionbutton btnNanoflowMapping (Caption: 'Nanoflow', Action: call nanoflow BugFlowReturns.NF_Vehicle_Get returns (Brand = $ActionReturnValue/Brand)) + actionbutton btnControlNoMapping (Caption: 'Control', Action: call microflow BugFlowReturns.ACT_Vehicle_Get) + } +}; diff --git a/mdl/ast/ast_page_v3.go b/mdl/ast/ast_page_v3.go index 4ebd74704a..bca8426256 100644 --- a/mdl/ast/ast_page_v3.go +++ b/mdl/ast/ast_page_v3.go @@ -339,6 +339,19 @@ type ActionV3 struct { OutcomeValue string // For COMPLETE_TASK // Settings is the action's `with ( … )` list, nil when none was written. Settings *ActionSettingsV3 + // Returns is a flow call's `returns ( … )` list: Studio Pro's "Return value + // mappings" (Mendix 11.5), nil when none was written. + Returns []FlowReturnV3 +} + +// FlowReturnV3 is one `returns ( … )` entry, `Target = Source`. Target is an +// attribute of the data container's object (`Brand`) or, with Variable set, a +// page variable (`$result`, stored without the `$`). Source is the expression +// text: `$ActionReturnValue`, `$ActionReturnValue/Attr`, or any expression. +type FlowReturnV3 struct { + Target string + Variable bool + Source string } // ActionSettingsV3 holds the client-action settings Studio Pro shows under an diff --git a/mdl/backend/mcp/page_widgets.go b/mdl/backend/mcp/page_widgets.go index ca10a8785a..1a8d8e020d 100644 --- a/mdl/backend/mcp/page_widgets.go +++ b/mdl/backend/mcp/page_widgets.go @@ -470,6 +470,9 @@ func mapClientAction(a pages.ClientAction) (map[string]any, error) { case nil, *pages.NoClientAction: return map[string]any{"$Type": "Pages$NoClientAction"}, nil case *pages.MicroflowClientAction: + if len(act.OutputMappings) > 0 { + return nil, fmt.Errorf("call microflow %s: returns ( … ) is not yet supported by the MCP backend", act.MicroflowName) + } return map[string]any{ "$Type": "Pages$MicroflowClientAction", "microflow": act.MicroflowName, diff --git a/mdl/backend/mcp/widget.go b/mdl/backend/mcp/widget.go index f8fb416b15..9e25487f2a 100644 --- a/mdl/backend/mcp/widget.go +++ b/mdl/backend/mcp/widget.go @@ -437,6 +437,9 @@ func customWidgetClientAction(a pages.ClientAction) (map[string]any, error) { if len(act.ParameterMappings) > 0 { return nil, fmt.Errorf("microflow action parameter mappings are not yet supported over MCP") } + if len(act.OutputMappings) > 0 { + return nil, fmt.Errorf("microflow action output mappings (returns ( … )) are not yet supported over MCP") + } return map[string]any{ "$Type": "Pages$MicroflowClientAction", "microflowSettings": map[string]any{ diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index b2a7d3645a..9f82ef658a 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -237,6 +237,13 @@ func init() { MandatoryListMarkers: map[string]int32{"ParameterMappings": 2}, NullFields: []string{"ProgressMessage", "ConfirmationInfo"}, }) + // One return value mapping: Studio Pro writes all four slots on every row, + // null where the row does not use them and Expression "" (ako/TestApp + // Pages.OutputMappingButton, 11.15). + codec.RegisterTypeDefaults("Forms$OutputMapping", codec.TypeDefaults{ + NullFields: []string{"AttributeRef", "SourceAttributeRef", "SourceVariable"}, + EmptyStringFields: []string{"Expression"}, + }) // A nanoflow DATA SOURCE is flat — no settings child — and carries its // (possibly empty) mapping list directly, marker 2. Measured: 5 of 5 Studio // Pro-authored Forms$NanoflowSource in Feedback v4.0.2 at 11.13.0. @@ -1678,7 +1685,7 @@ func dataViewSourceToGen(ds pages.DataSource) (element.Element, error) { } assignID(ms) ms.SetForceFullObjects(false) - ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, nil)) + ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, nil, nil)) return ms, nil // A NANOFLOW data source. Flat, unlike the microflow source above: the @@ -1827,7 +1834,7 @@ func listViewSourceToGen(ds pages.DataSource) (element.Element, error) { } assignID(ms) ms.SetForceFullObjects(false) - ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, nil)) + ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, nil, nil)) return ms, nil // A NANOFLOW data source. Flat, unlike the microflow source above: the @@ -1910,7 +1917,7 @@ func customWidgetDataSourceToGen(ds pages.DataSource) (element.Element, error) { } assignID(ms) ms.SetForceFullObjects(false) - ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, nil)) + ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, nil, nil)) return ms, nil // A NANOFLOW data source. Flat, unlike the microflow source above: the @@ -2052,7 +2059,8 @@ func bindParameterMappingValue(m parameterMappingTarget, variable, kind, express // // settings carries what a call-microflow action's `with ( … )` says; nil (the // data-source case, which has no MDL spelling for them) writes Mendix's defaults. -func microflowSettingsToGen(microflowName string, mappings []*pages.MicroflowParameterMapping, settings *pages.FlowCallSettings) element.Element { +// outputs are its `returns ( … )` mappings (ako/mxcli#977 item 3). +func microflowSettingsToGen(microflowName string, mappings []*pages.MicroflowParameterMapping, settings *pages.FlowCallSettings, outputs []*pages.FlowOutputMapping) element.Element { if settings == nil { settings = &pages.FlowCallSettings{} } @@ -2076,9 +2084,32 @@ func microflowSettingsToGen(microflowName string, mappings []*pages.MicroflowPar bindParameterMappingValue(gm, pm.Variable, pm.VariableKind, pm.Expression) s.AddParameterMappings(gm) } + for _, om := range outputs { + s.AddOutputMappings(outputMappingToGen(om)) + } return s } +// outputMappingToGen builds a Forms$OutputMapping, one row of a flow call's +// return value mappings (ako/mxcli#977 item 3). The slots a row does not use +// stay null, Expression empty — the shape Studio Pro stores for each of the +// three kinds of row on ako/TestApp's Pages.OutputMappingButton. +func outputMappingToGen(om *pages.FlowOutputMapping) element.Element { + g := genPg.NewOutputMapping() + assignID(g) + if r := attributeRefToGen(om.TargetAttribute); r != nil { + g.SetAttributeRef(r) + } + if om.TargetVariable != "" { + g.SetSourceVariable(pageVariableToGen("", om.TargetVariable, "local")) + } + if r := attributeRefToGen(om.SourceAttribute); r != nil { + g.SetSourceAttributeRef(r) + } + g.SetExpression(om.Expression) + return g +} + // formSettingsToGen builds the Forms$FormSettings (PageSettings) shared by the // page-opening actions: target page by-name and empty parameter mappings. // @@ -2302,7 +2333,7 @@ func clientActionToGen(a pages.ClientAction) (element.Element, error) { } assignID(g) g.SetDisabledDuringExecution(actionDisabledDuringExecution(x.ActionExecution)) - g.SetMicroflowSettings(microflowSettingsToGen(x.MicroflowName, x.ParameterMappings, &x.FlowCallSettings)) + g.SetMicroflowSettings(microflowSettingsToGen(x.MicroflowName, x.ParameterMappings, &x.FlowCallSettings, x.OutputMappings)) return g, nil case *pages.NanoflowClientAction: // call_nanoflow → Forms$CallNanoflowClientAction. Unlike the microflow @@ -2331,6 +2362,9 @@ func clientActionToGen(a pages.ClientAction) (element.Element, error) { bindParameterMappingValue(m, pm.Variable, pm.VariableKind, pm.Expression) g.AddParameterMappings(m) } + for _, om := range x.OutputMappings { + g.AddOutputMappings(outputMappingToGen(om)) + } return g, nil case *pages.CreateObjectClientAction: // create_object → Forms$CreateObjectClientAction (entity ref + page settings). diff --git a/mdl/executor/button_output_mappings_977_test.go b/mdl/executor/button_output_mappings_977_test.go deleted file mode 100644 index b1fd4999a2..0000000000 --- a/mdl/executor/button_output_mappings_977_test.go +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// ako/mxcli#977 item 3: "A button's `OutputMappings` are not round-tripped." -// The empty list Studio Pro stores on every call has been written since -// 2026-10-10 (widget_write_mapping_markers_test.go). A NON-empty list — Mendix -// 11.5's mapping of a flow's result back onto the page — has no MDL, so -// describe → exec dropped it: "Replaced page", the mapping gone, mx check -// silent. The stored mappings are now carried while the button calls the same -// flow, and describe says the button has them. -package executor - -import ( - "context" - "strings" - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -const om977Prelude = `create microflow MyFirstModule.ACT_Go () begin end; -create microflow MyFirstModule.ACT_Other () begin end; -` - -func om977Page(flow string) string { - return "create or modify page MyFirstModule.Go_Page (Title: 'Go', Layout: Atlas_Core.Atlas_Default) {\n" + - " actionbutton btnGo (Caption: 'Go', Action: call microflow MyFirstModule." + flow + ")\n};\n" -} - -// storedOutputMappings returns the Expression of every stored Forms$OutputMapping -// on the page. -func storedOutputMappings(t *testing.T, exec *Executor, page string) []string { - t.Helper() - _, raw := pageUnitByName(t, exec, page) - var doc bson.D - if err := bson.Unmarshal(raw, &doc); err != nil { - t.Fatal(err) - } - var out []string - walkStoredDocs(doc, func(d bson.D) { - if afGet(d, "$Type") == "Forms$OutputMapping" { - s, _ := afGet(d, "Expression").(string) - out = append(out, s) - } - }) - return out -} - -// addOutputMapping BSON-patches one Forms$OutputMapping onto every -// OutputMappings list of the page — the Studio Pro 11.5 shape per the -// metamodel (Expression, AttributeRef, SourceAttributeRef, SourceVariable). -func addOutputMapping(t *testing.T, exec *Executor, page string) { - t.Helper() - id, raw := pageUnitByName(t, exec, page) - var doc bson.D - if err := bson.Unmarshal(raw, &doc); err != nil { - t.Fatal(err) - } - walkStoredDocs(doc, func(d bson.D) { - for i := range d { - if d[i].Key != "OutputMappings" { - continue - } - owner := afGet(d, "$ID").(bson.Binary) - data := append([]byte(nil), owner.Data...) - data[len(data)-1] ^= 0x5A // a distinct element id - d[i].Value = append(d[i].Value.(bson.A), bson.D{ - {Key: "$ID", Value: bson.Binary{Subtype: owner.Subtype, Data: data}}, - {Key: "$Type", Value: "Forms$OutputMapping"}, - {Key: "AttributeRef", Value: nil}, - {Key: "Expression", Value: "'mapped'"}, - {Key: "SourceAttributeRef", Value: nil}, - {Key: "SourceVariable", Value: nil}, - }) - } - }) - b, err := bson.Marshal(doc) - if err != nil { - t.Fatal(err) - } - if err := exec.newExecContext(context.Background()).Backend.UpdateRawUnit(string(id), b); err != nil { - t.Fatal(err) - } -} - -func TestButtonOutputMappingsSurviveDescribeExec(t *testing.T) { - exec, out := openPedAppFixture(t) - if err := afRun(t, exec, "mdl 1;\n"+om977Prelude+om977Page("ACT_Go")); err != nil { - t.Fatalf("exec: %v", err) - } - addOutputMapping(t, exec, "Go_Page") - - out.Reset() - if err := afRun(t, exec, "describe page MyFirstModule.Go_Page;"); err != nil { - t.Fatal(err) - } - described := out.String() - if !strings.Contains(described, "-- btnGo: 1 output mapping") { - t.Errorf("describe does not say the button has output mappings MDL cannot show:\n%s", described) - } - out.Reset() - if err := afRun(t, exec, described); err != nil { - t.Fatalf("re-exec of the description: %v", err) - } - if !strings.Contains(out.String(), "Unchanged page") { - t.Errorf("re-executing the description was not a no-op:\n%s", out.String()) - } - if got := storedOutputMappings(t, exec, "Go_Page"); len(got) != 1 || got[0] != "'mapped'" { - t.Errorf("after describe → exec the output mappings are %q, want ['mapped']", got) - } - - // Control: a button that now calls another microflow does not keep - // mappings that read the old one's result. - if err := afRun(t, exec, "mdl 1;\n"+om977Page("ACT_Other")); err != nil { - t.Fatalf("exec with another microflow: %v", err) - } - if got := storedOutputMappings(t, exec, "Go_Page"); len(got) != 0 { - t.Errorf("mappings for ACT_Go were carried onto a call of ACT_Other: %q", got) - } -} diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index 9d213ef8f9..e57db33062 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -465,7 +465,7 @@ func applySetProperties(ctx *ExecContext, mutator backend.PageMutator, op *ast.S } else if propName == "Action" { // Action is a polymorphic node, not a scalar — it goes through the // same builder CREATE PAGE uses rather than being written as a value. - action, err := convertASTAction(ctx, mutator, value, moduleName, moduleID) + action, err := convertASTAction(ctx, mutator, value, moduleName, moduleID, op.Target.Widget) if err != nil { return err } @@ -478,7 +478,7 @@ func applySetProperties(ctx *ExecContext, mutator backend.PageMutator, op *ast.S // Same builder as `Action`; the mutator checks the key is an // action-typed property of the stored widget. Through // SetWidgetProperty it would be stringified into a PrimitiveValue. - action, err := convertASTAction(ctx, mutator, value, moduleName, moduleID) + action, err := convertASTAction(ctx, mutator, value, moduleName, moduleID, op.Target.Widget) if err != nil { return err } @@ -589,7 +589,10 @@ func convertASTDataSource(value interface{}) (pages.DataSource, error) { // buildWidgetsFromAST is. Without them a `$Param` argument cannot be told from an // expression and is written as one, which Studio Pro does not bind // (mendixlabs/mxcli#1317, the #1140 rule). -func convertASTAction(ctx *ExecContext, mutator backend.PageMutator, value any, moduleName string, moduleID model.ID) (pages.ClientAction, error) { +// +// widgetRef is the widget the action is set on: a `returns ( … )` attribute +// target belongs to the object of the data container around it. +func convertASTAction(ctx *ExecContext, mutator backend.PageMutator, value any, moduleName string, moduleID model.ID, widgetRef string) (pages.ClientAction, error) { action, ok := value.(*ast.ActionV3) if !ok { return nil, mdlerrors.NewValidation("Action value must be an action expression, " + @@ -611,6 +614,9 @@ func convertASTAction(ctx *ExecContext, mutator backend.PageMutator, value any, isDocumentWidget: alterDocumentWidgets(mutator, nil), isSnippet: mutator.ContainerType() == backend.ContainerSnippet, } + if len(action.Returns) > 0 { + pb.entityContext, _ = alterEntityContext(ctx, mutator, widgetRef, false, moduleName, moduleID) + } return pb.buildClientActionV3(action) } diff --git a/mdl/executor/cmd_fragments.go b/mdl/executor/cmd_fragments.go index 6123160bb4..cc4f97d482 100644 --- a/mdl/executor/cmd_fragments.go +++ b/mdl/executor/cmd_fragments.go @@ -242,7 +242,7 @@ func formatASTPropertyValue(v interface{}) string { case *ast.DataSourceV3: return formatDataSourceV3(val) case *ast.ActionV3: - return formatActionV3(val) + formatActionSettingsV3(val.Settings) + return formatActionV3(val) + formatFlowReturnsV3(val.Returns) + formatActionSettingsV3(val.Settings) case *ast.WidgetExpressionV3: return val.Text default: diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index e4886e26c6..3d139afb89 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -1742,9 +1742,17 @@ func (pb *pageBuilder) getNanoflowReturnEntityName(qualifiedName string) string // buildClientActionV3 converts a V3 Action AST to a pages.ClientAction. func (pb *pageBuilder) buildClientActionV3(action *ast.ActionV3) (pages.ClientAction, error) { a, err := pb.buildClientActionV3Base(action) - if err != nil || a == nil || action.Settings == nil { + if err != nil || a == nil { return a, err } + if len(action.Returns) > 0 { + if err := pb.applyFlowReturns(a, action.Returns); err != nil { + return nil, err + } + } + if action.Settings == nil { + return a, nil + } if err := applyActionSettings(a, action.Settings); err != nil { return nil, err } diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index aebd86267a..27cd0fe190 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -880,9 +880,10 @@ type rawWidget struct { // (ako/mxcli#977). NumberOfLines *int64 Autocomplete *bool - // OutputMappings counts the output mappings (Mendix 11.5) of a button's - // flow call, which MDL cannot show (ako/mxcli#977). - OutputMappings int + // UnsayableOutputMappings counts the output mappings (Mendix 11.5) of a + // button's flow call that describe could not print as `returns ( … )` + // (ako/mxcli#977). + UnsayableOutputMappings int // ValidationExpression / ValidationMessage are the two fields of // Forms$WidgetValidation, the per-widget validation Studio Pro stores on // input widgets. diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 8dddaf99b8..339cbfa58a 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -1655,6 +1655,9 @@ func renderClientActionBaseMDL(ctx *ExecContext, action map[string]any) string { if params != "" { result += "(" + params + ")" } + if returns, ok := storedFlowReturns(settings); ok { + result += returns + } return result } } @@ -1667,6 +1670,9 @@ func renderClientActionBaseMDL(ctx *ExecContext, action map[string]any) string { if params != "" { result += "(" + params + ")" } + if returns, ok := storedFlowReturns(action); ok { + result += returns + } return result } return "call nanoflow" @@ -2267,16 +2273,15 @@ func appendReadOnlyStyle(props []string, w rawWidget) []string { } // outputMappingsNote says a button's flow call has output mappings (Mendix -// 11.5), which MDL has no syntax for. A rewrite keeps the stored ones while the -// button calls the same flow (canon.CarryOutputMappings), so the description -// still re-executes; the note is there so a reader knows they exist -// (ako/mxcli#977). +// 11.5) that describe could not print as `returns ( … )` — a shape that syntax +// does not write, such as a path over an association. Nothing carries them +// through a rewrite, so the note says re-running drops them (ako/mxcli#977). func outputMappingsNote(w rawWidget) string { - switch w.OutputMappings { + switch w.UnsayableOutputMappings { case 0: return "" case 1: - return fmt.Sprintf("-- %s: 1 output mapping, which MDL cannot show; kept on a rewrite while the button calls the same flow", w.Name) + return fmt.Sprintf("-- %s: 1 output mapping has no MDL form; re-running this statement drops it", w.Name) } - return fmt.Sprintf("-- %s: %d output mappings, which MDL cannot show; kept on a rewrite while the button calls the same flow", w.Name, w.OutputMappings) + return fmt.Sprintf("-- %s: %d output mappings have no MDL form; re-running this statement drops them", w.Name, w.UnsayableOutputMappings) } diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 733b2245b8..73462d9f52 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -345,7 +345,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.RenderMode = rt } widget.Icon, widget.IconType, widget.IconCode = extractButtonIcon(w) - widget.OutputMappings = countOutputMappings(w["Action"]) + widget.UnsayableOutputMappings = unsayableOutputMappings(w["Action"]) return []rawWidget{widget} case "Forms$Header", "Pages$Header": @@ -1555,24 +1555,3 @@ func describeFileManagerProps(ctx *ExecContext, w map[string]any) []string { } return props } - -// countOutputMappings counts the output mappings of a button's action: a -// microflow call holds them in its MicroflowSettings, a nanoflow call directly -// (ako/mxcli#977). -func countOutputMappings(action any) int { - a, ok := action.(map[string]any) - if !ok { - return 0 - } - holder := a - if ms, ok := a["MicroflowSettings"].(map[string]any); ok { - holder = ms - } - n := 0 - for _, e := range getBsonArrayElements(holder["OutputMappings"]) { - if _, ok := e.(map[string]any); ok { - n++ - } - } - return n -} diff --git a/mdl/executor/flow_returns_977_test.go b/mdl/executor/flow_returns_977_test.go new file mode 100644 index 0000000000..b4b1f04db9 --- /dev/null +++ b/mdl/executor/flow_returns_977_test.go @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#977 item 3: a flow call's output mappings (Mendix 11.5, Studio Pro's +// "Return value mappings") had no MDL. describe → exec could only carry the +// stored ones, and a script could not write one. `returns ( … )` spells them; +// these tests hold the stored shape to what Studio Pro writes for each kind of +// row (ako/TestApp Pages.OutputMappingButton, 11.15) and the round trip to a +// no-op. +package executor + +import ( + "bytes" + "database/sql" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" +) + +const flowReturnsPrelude = `create persistent entity MyFirstModule.Vehicle (Brand: String(200)); +create microflow MyFirstModule.ACT_Vehicle_Get () returns MyFirstModule.Vehicle +begin + $Vehicle = create MyFirstModule.Vehicle; + return $Vehicle; +end; +create microflow MyFirstModule.ACT_String_Get () returns String +begin + return 'x'; +end; +create nanoflow MyFirstModule.NF_Vehicle_Get () returns MyFirstModule.Vehicle +begin + $Vehicle = create MyFirstModule.Vehicle; + return $Vehicle; +end; +` + +// flowReturnsPage is the TestApp page in MDL: one button per kind of row, plus +// a call with no mappings as the control. +const flowReturnsPage = `create or modify page MyFirstModule.OM_Page ( + Title: 'Output mappings', Layout: Atlas_Core.Atlas_Default, + Params: ($Vehicle: MyFirstModule.Vehicle), + Variables: ($stringResult: String = 'empty') +) { + dataview dvVehicle (DataSource: $Vehicle) { + actionbutton btnAttributeToAttribute (Caption: 'A', Action: call microflow MyFirstModule.ACT_Vehicle_Get returns (Brand = $ActionReturnValue/Brand)) + actionbutton btnToPageVariable (Caption: 'V', Action: call microflow MyFirstModule.ACT_String_Get returns ($stringResult = $ActionReturnValue)) + actionbutton btnExpressionMapping (Caption: 'E', Action: call microflow MyFirstModule.ACT_Vehicle_Get returns (Brand = 'Demo brand')) + actionbutton btnNanoflowMapping (Caption: 'N', Action: call nanoflow MyFirstModule.NF_Vehicle_Get returns (Brand = $ActionReturnValue/Brand)) + actionbutton btnControlNoMapping (Caption: 'C', Action: call microflow MyFirstModule.ACT_Vehicle_Get) + } +}; +` + +// openPedAppFixtureAt is openPedAppFixture with the project recorded as made +// by Mendix version: an attribute target needs 11.15 (mx 11.13 and 11.14 report +// CE7006 on it), and PedApp is 11.13. +func openPedAppFixtureAt(t *testing.T, version string) (*Executor, *bytes.Buffer) { + t.Helper() + src := filepath.Join("..", "..", "testdata", "pedapp") + if _, err := os.Stat(filepath.Join(src, "PedApp.mpr")); err != nil { + t.Skipf("PedApp fixture not found: %v", err) + } + dir := t.TempDir() + mpr := filepath.Join(dir, "PedApp.mpr") + if err := copyPedAppFile(filepath.Join(src, "PedApp.mpr"), mpr); err != nil { + t.Fatal(err) + } + if err := copyPedAppTree(filepath.Join(src, "mprcontents"), filepath.Join(dir, "mprcontents")); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", mpr) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec("UPDATE _MetaData SET _ProductVersion = ?", version); err != nil { + t.Fatalf("set _ProductVersion: %v", err) + } + db.Close() + out := &bytes.Buffer{} + exec := New(out) + exec.SetBackendFactory(func() backend.FullBackend { return modelsdkbackend.New() }) + if err := exec.Execute(&ast.ConnectStmt{Path: mpr}); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = exec.Execute(&ast.DisconnectStmt{}) }) + if !strings.Contains(out.String(), "Mendix "+version) { + t.Fatalf("the fixture did not open as %s:\n%s", version, out.String()) + } + return exec, out +} + +// storedOutputMapping is one Forms$OutputMapping, flattened to the four slots. +type storedOutputMapping struct { + Button, AttributeRef, SourceAttributeRef, SourceVariable, Expression string + Keys string +} + +func attributeOf(v any) string { + if d, ok := v.(bson.D); ok { + s, _ := afGet(d, "Attribute").(string) + return s + } + return "" +} + +// storedOutputMappings returns every stored Forms$OutputMapping on the page, by +// the button that owns it. +func storedOutputMappings(t *testing.T, exec *Executor, page string) []storedOutputMapping { + t.Helper() + _, raw := pageUnitByName(t, exec, page) + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + var out []storedOutputMapping + var walk func(v any, button string) + walk = func(v any, button string) { + switch n := v.(type) { + case bson.D: + if afGet(n, "$Type") == "Forms$ActionButton" { + button, _ = afGet(n, "Name").(string) + } + if afGet(n, "$Type") == "Forms$OutputMapping" { + m := storedOutputMapping{Button: button, + AttributeRef: attributeOf(afGet(n, "AttributeRef")), + SourceAttributeRef: attributeOf(afGet(n, "SourceAttributeRef")), + } + if sv, ok := afGet(n, "SourceVariable").(bson.D); ok { + m.SourceVariable, _ = afGet(sv, "LocalVariable").(string) + } + m.Expression, _ = afGet(n, "Expression").(string) + var keys []string + for _, e := range n { + keys = append(keys, e.Key) + } + sort.Strings(keys) // the set Studio Pro writes; order is not content + m.Keys = strings.Join(keys, ",") + out = append(out, m) + } + for _, e := range n { + walk(e.Value, button) + } + case bson.A: + for _, e := range n { + walk(e, button) + } + } + } + walk(doc, "") + return out +} + +func TestFlowReturnsWriteStudioProShape(t *testing.T) { + exec, out := openPedAppFixtureAt(t, "11.15.0") + if err := afRun(t, exec, "mdl 1;\n"+flowReturnsPrelude+flowReturnsPage); err != nil { + t.Fatalf("exec: %v\n%s", err, out.String()) + } + got := storedOutputMappings(t, exec, "OM_Page") + const keys = "$ID,$Type,AttributeRef,Expression,SourceAttributeRef,SourceVariable" + want := []storedOutputMapping{ + {Button: "btnAttributeToAttribute", AttributeRef: "MyFirstModule.Vehicle.Brand", SourceAttributeRef: "MyFirstModule.Vehicle.Brand", Keys: keys}, + {Button: "btnToPageVariable", SourceVariable: "stringResult", Keys: keys}, + {Button: "btnExpressionMapping", AttributeRef: "MyFirstModule.Vehicle.Brand", Expression: "'Demo brand'", Keys: keys}, + {Button: "btnNanoflowMapping", AttributeRef: "MyFirstModule.Vehicle.Brand", SourceAttributeRef: "MyFirstModule.Vehicle.Brand", Keys: keys}, + } + if len(got) != len(want) { + t.Fatalf("stored output mappings:\n%+v\nwant:\n%+v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("mapping %d:\n got %+v\nwant %+v", i, got[i], want[i]) + } + } + + // describe prints them, and executing the description is a no-op. + out.Reset() + if err := afRun(t, exec, "describe page MyFirstModule.OM_Page;"); err != nil { + t.Fatal(err) + } + described := out.String() + for _, s := range []string{ + "call microflow MyFirstModule.ACT_Vehicle_Get returns (Brand = $ActionReturnValue/Brand)", + "call microflow MyFirstModule.ACT_String_Get returns ($stringResult = $ActionReturnValue)", + "call microflow MyFirstModule.ACT_Vehicle_Get returns (Brand = 'Demo brand')", + "call nanoflow MyFirstModule.NF_Vehicle_Get returns (Brand = $ActionReturnValue/Brand)", + } { + if !strings.Contains(described, s) { + t.Errorf("describe does not print %q:\n%s", s, described) + } + } + if strings.Contains(described, "output mapping") { + t.Errorf("describe still notes output mappings MDL can now say:\n%s", described) + } + out.Reset() + if err := afRun(t, exec, described); err != nil { + t.Fatalf("re-exec of the description: %v", err) + } + if !strings.Contains(out.String(), "Unchanged page") { + t.Errorf("re-executing the description was not a no-op:\n%s", out.String()) + } + + // Control: a statement that drops a returns clause drops the mapping — + // the statement is the authority, nothing is carried behind its back. + if err := afRun(t, exec, "mdl 1;\n"+strings.Replace(flowReturnsPage, + " returns (Brand = 'Demo brand')", "", 1)); err != nil { + t.Fatalf("exec without one mapping: %v", err) + } + if got := storedOutputMappings(t, exec, "OM_Page"); len(got) != 3 { + t.Errorf("after removing one returns clause the page has %d mappings, want 3: %+v", len(got), got) + } +} + +func TestFlowReturnsAlterSet(t *testing.T) { + exec, out := openPedAppFixtureAt(t, "11.15.0") + if err := afRun(t, exec, "mdl 1;\n"+flowReturnsPrelude+flowReturnsPage); err != nil { + t.Fatalf("exec: %v\n%s", err, out.String()) + } + if err := afRun(t, exec, "alter page MyFirstModule.OM_Page { set (Action: call microflow MyFirstModule.ACT_Vehicle_Get returns (Brand = $ActionReturnValue/Brand)) on btnControlNoMapping };"); err != nil { + t.Fatalf("alter set: %v", err) + } + var n int + for _, m := range storedOutputMappings(t, exec, "OM_Page") { + if m.Button == "btnControlNoMapping" { + n++ + if m.AttributeRef != "MyFirstModule.Vehicle.Brand" || m.SourceAttributeRef != "MyFirstModule.Vehicle.Brand" { + t.Errorf("alter set wrote %+v", m) + } + } + } + if n != 1 { + t.Errorf("alter set wrote %d mappings on btnControlNoMapping, want 1", n) + } +} + +func TestFlowReturnsRefusals(t *testing.T) { + exec, out := openPedAppFixtureAt(t, "11.15.0") + if err := afRun(t, exec, "mdl 1;\n"+flowReturnsPrelude); err != nil { + t.Fatalf("exec: %v\n%s", err, out.String()) + } + page := func(action string, inDataView bool) string { + button := "actionbutton b1 (Caption: 'Go', Action: " + action + ")" + if inDataView { + button = "dataview dv (DataSource: $Vehicle) { " + button + " }" + } + return "mdl 1;\ncreate or modify page MyFirstModule.R_Page (Title: 'R', Layout: Atlas_Core.Atlas_Default, " + + "Params: ($Vehicle: MyFirstModule.Vehicle), Variables: ($s: String = 'empty')) { " + button + " };" + } + for _, tc := range []struct { + name, action string + inDataView bool + want string + }{ + {"attribute target outside a data container", + "call microflow MyFirstModule.ACT_Vehicle_Get returns (Brand = $ActionReturnValue/Brand)", false, "data container"}, + {"unknown page variable", + "call microflow MyFirstModule.ACT_String_Get returns ($nope = $ActionReturnValue)", true, "$nope"}, + {"attribute of a flow that returns no object", + "call microflow MyFirstModule.ACT_String_Get returns (Brand = $ActionReturnValue/Brand)", true, "returns no object"}, + {"attribute the context entity does not have", + "call microflow MyFirstModule.ACT_Vehicle_Get returns (Colour = 'red')", true, "Colour"}, + } { + err := afRun(t, exec, page(tc.action, tc.inDataView)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: err = %v, want a refusal naming %q", tc.name, err, tc.want) + } + } + // Control: the same page with a valid mapping is accepted. + if err := afRun(t, exec, page("call microflow MyFirstModule.ACT_String_Get returns ($s = $ActionReturnValue)", true)); err != nil { + t.Errorf("a valid mapping was refused: %v", err) + } +} + +// mx 11.13 and 11.14 report CE7006 on an attribute target that 11.15 accepts, +// byte for byte; a page-variable target checks clean on all three. +func TestFlowReturnsAttributeTargetFloor(t *testing.T) { + exec, out := openPedAppFixtureAt(t, "11.13.0") + if err := afRun(t, exec, "mdl 1;\n"+flowReturnsPrelude); err != nil { + t.Fatalf("exec: %v\n%s", err, out.String()) + } + page := func(returns string) string { + return "mdl 1;\ncreate or modify page MyFirstModule.R_Page (Title: 'R', Layout: Atlas_Core.Atlas_Default, " + + "Params: ($Vehicle: MyFirstModule.Vehicle), Variables: ($s: String = 'empty')) { " + + "dataview dv (DataSource: $Vehicle) { actionbutton b1 (Caption: 'Go', Action: call microflow MyFirstModule." + returns + ") } };" + } + err := afRun(t, exec, page("ACT_Vehicle_Get returns (Brand = $ActionReturnValue/Brand)")) + if err == nil || !strings.Contains(err.Error(), "11.15.0") { + t.Errorf("an attribute target on 11.13: err = %v, want a refusal naming 11.15.0", err) + } + if err := afRun(t, exec, page("ACT_String_Get returns ($s = $ActionReturnValue)")); err != nil { + t.Errorf("a page-variable target on 11.13 was refused: %v", err) + } +} diff --git a/mdl/executor/page_flow_returns.go b/mdl/executor/page_flow_returns.go new file mode 100644 index 0000000000..b1142cf41d --- /dev/null +++ b/mdl/executor/page_flow_returns.go @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// actionReturnValue is how Studio Pro's "Return value mappings" table names +// the flow's result, and how `returns ( … )` reads it. +const actionReturnValue = "$ActionReturnValue" + +// applyFlowReturns builds a flow call's `returns ( … )` list into the action's +// output mappings: Forms$OutputMapping, Mendix 11.5 (ako/mxcli#977 item 3). +// +// Each entry is target = source, and which target and which source it names +// decide the stored slots. Measured on ako/TestApp's Pages.OutputMappingButton +// (Studio Pro 11.15), one row of each kind: +// +// Brand = $ActionReturnValue/Brand AttributeRef + SourceAttributeRef +// $stringResult = $ActionReturnValue SourceVariable alone +// Brand = 'Demo brand' AttributeRef + Expression +// +// An attribute target belongs to the data container's object, a `$name` target +// is a page variable. The return value itself fills no source slot; one of its +// attributes is SourceAttributeRef, qualified with the flow's return entity; +// anything else is an Expression. +// +// The attribute target has its own, later floor: mx 11.13 and 11.14 report +// CE7006 on the same bytes 11.15 accepts (measured; sdk/versions/mendix-11.yaml). +// +// Every target and source is checked here because nothing later will: the +// mapping is stored by name, and a wrong one is a valid-looking page that maps +// nothing. +func (pb *pageBuilder) applyFlowReturns(a pages.ClientAction, returns []ast.FlowReturnV3) error { + var flow, returnEntity string + var out *[]*pages.FlowOutputMapping + switch x := a.(type) { + case *pages.MicroflowClientAction: + flow, out = x.MicroflowName, &x.OutputMappings + returnEntity = pb.getMicroflowReturnEntityName(flow) + case *pages.NanoflowClientAction: + flow, out = x.NanoflowName, &x.OutputMappings + returnEntity = pb.getNanoflowReturnEntityName(flow) + default: + return mdlerrors.NewValidation("returns ( … ) maps a flow's result back onto the page: it belongs to `call microflow` and `call nanoflow` only") + } + if err := pb.checkGate(flowReturnsGate); err != nil { + return err + } + index := pb.attributeIndex() + for _, r := range returns { + m := &pages.FlowOutputMapping{} + if r.Variable { + if !pb.localVariables[r.Target] { + return mdlerrors.NewValidationf("returns: $%s is not a variable of this page — "+ + "a mapping writes into a page variable (Variables: ( $%s: … )) or an attribute of the data container's object", r.Target, r.Target) + } + m.TargetVariable = r.Target + } else { + if err := pb.checkGate(flowReturnsToAttributeGate); err != nil { + return err + } + if pb.entityContext == "" { + return mdlerrors.NewValidationf("returns: %s = …: an attribute target needs the widget inside a data container, "+ + "whose object it writes; outside one, write into a page variable ($name = …)", r.Target) + } + if found, known := index.declares(pb.entityContext, r.Target); known && !found { + return mdlerrors.NewValidationf("returns: %s has no attribute %s", pb.entityContext, r.Target) + } + m.TargetAttribute = pb.resolveAttributePath(r.Target) + } + src := strings.TrimSpace(r.Source) + if attr, ok := returnValueAttribute(src); ok { + if returnEntity == "" { + return mdlerrors.NewValidationf("returns: %s returns no object, so %s/%s has nothing to read — "+ + "map %s itself, or an expression", flow, actionReturnValue, attr, actionReturnValue) + } + if found, known := index.declares(returnEntity, attr); known && !found { + return mdlerrors.NewValidationf("returns: %s returns a %s, which has no attribute %s", flow, returnEntity, attr) + } + m.SourceAttribute = pb.resolveAttributePathForEntity(attr, returnEntity) + } else if !strings.EqualFold(src, actionReturnValue) { + m.Expression = src + } + *out = append(*out, m) + } + return nil +} + +// checkGate applies one of the statement's version gates again at build time, +// for widgets the statement-level walk does not see (a fragment's). +func (pb *pageBuilder) checkGate(g versionGate) error { + if pb.ctx == nil { + return nil + } + return checkGate(pb.ctx, g) +} + +// returnValueAttribute splits `$ActionReturnValue/Attr` into Attr. Anything +// longer — a path over an association, an operator — is an expression. +func returnValueAttribute(src string) (string, bool) { + if len(src) <= len(actionReturnValue)+1 || !strings.EqualFold(src[:len(actionReturnValue)+1], actionReturnValue+"/") { + return "", false + } + attr := unquoteIdentifier(src[len(actionReturnValue)+1:]) + for _, c := range attr { + if c != '_' && !('a' <= c && c <= 'z') && !('A' <= c && c <= 'Z') && !('0' <= c && c <= '9') { + return "", false + } + } + return attr, attr != "" +} + +// formatFlowReturnsV3 renders a parsed `returns ( … )` list as written, "" +// when there is none. +func formatFlowReturnsV3(returns []ast.FlowReturnV3) string { + if len(returns) == 0 { + return "" + } + parts := make([]string, 0, len(returns)) + for _, r := range returns { + target := visitor.ParameterNameSpelling(r.Target) + if r.Variable { + target = "$" + r.Target + } + parts = append(parts, target+" = "+r.Source) + } + return " returns (" + strings.Join(parts, ", ") + ")" +} + +// storedFlowReturns reads a flow call's stored output mappings back as the +// `returns ( … )` that builds them; holder is a microflow call's +// MicroflowSettings or the nanoflow call itself. "" with ok is a call without +// mappings. ok is false when a row has a shape this syntax does not write — a +// path over an association, a target in both slots, an expression that reads +// like the shorthand — and then the caller prints none of them and says so, +// rather than describe a different mapping. +func storedFlowReturns(holder map[string]any) (string, bool) { + var parts []string + for _, e := range getBsonArrayElements(holder["OutputMappings"]) { + m, ok := e.(map[string]any) + if !ok { + continue + } + part, ok := storedFlowReturn(m) + if !ok { + return "", false + } + parts = append(parts, part) + } + if len(parts) == 0 { + return "", true + } + return " returns (" + strings.Join(parts, ", ") + ")", true +} + +// storedFlowReturn is one Forms$OutputMapping as `Target = Source`. +func storedFlowReturn(m map[string]any) (string, bool) { + targetAttr, ok := plainAttributeRef(m, "AttributeRef") + if !ok { + return "", false + } + sourceAttr, ok := plainAttributeRef(m, "SourceAttributeRef") + if !ok { + return "", false + } + targetVar := "" + if sv := actionMapForKey(m, "SourceVariable"); sv != nil { + targetVar = extractString(sv["LocalVariable"]) + if useAll, _ := sv["UseAllPages"].(bool); targetVar == "" || useAll || + extractString(sv["PageParameter"]) != "" || extractString(sv["SnippetParameter"]) != "" || + extractString(sv["Widget"]) != "" || extractString(sv["SubKey"]) != "" { + return "", false + } + } + expr := strings.TrimSpace(extractString(m["Expression"])) + + var target, source string + switch { + case targetVar != "" && targetAttr == "": + target = "$" + targetVar + case targetAttr != "" && targetVar == "": + target = visitor.ParameterNameSpelling(shortAttributeName(targetAttr)) + default: + return "", false + } + switch { + case sourceAttr != "" && expr != "": + return "", false + case sourceAttr != "": + source = actionReturnValue + "/" + mdlIdent(shortAttributeName(sourceAttr)) + case expr != "": + // Built again, an expression that reads like the shorthand would land + // in the shorthand's slot instead of this one. + if _, ok := returnValueAttribute(expr); ok || strings.EqualFold(expr, actionReturnValue) { + return "", false + } + source = expr + default: + source = actionReturnValue + } + return target + " = " + source, true +} + +// plainAttributeRef is the qualified attribute of m[key], "" when the slot is +// null; ok is false for a reference this syntax cannot write — one over an +// association (an EntityRef beside it) or one that is not qualified. +func plainAttributeRef(m map[string]any, key string) (string, bool) { + ref := actionMapForKey(m, key) + if ref == nil { + return "", true + } + attr := extractString(ref["Attribute"]) + if actionMapForKey(ref, "EntityRef") != nil || strings.Count(attr, ".") < 2 { + return "", false + } + return attr, true +} + +// unsayableOutputMappings counts the output mappings of a widget's flow call +// when describe could not print them as `returns ( … )` — 0 when there are +// none or when it could. +func unsayableOutputMappings(action any) int { + a, ok := action.(map[string]any) + if !ok { + return 0 + } + holder := a + if ms, ok := a["MicroflowSettings"].(map[string]any); ok { + holder = ms + } + if _, ok := storedFlowReturns(holder); ok { + return 0 + } + n := 0 + for _, e := range getBsonArrayElements(holder["OutputMappings"]) { + if _, ok := e.(map[string]any); ok { + n++ + } + } + return n +} diff --git a/mdl/executor/page_flow_returns_test.go b/mdl/executor/page_flow_returns_test.go new file mode 100644 index 0000000000..d4d3f24a35 --- /dev/null +++ b/mdl/executor/page_flow_returns_test.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +func outputMappingRow(target, targetVar, source, expr string) map[string]any { + ref := func(attr string) any { + if attr == "" { + return nil + } + return map[string]any{"$Type": "DomainModels$AttributeRef", "Attribute": attr, "EntityRef": nil} + } + var sv any + if targetVar != "" { + sv = map[string]any{"$Type": "Forms$PageVariable", "LocalVariable": targetVar, + "PageParameter": "", "SnippetParameter": "", "SubKey": "", "UseAllPages": false, "Widget": ""} + } + return map[string]any{"$Type": "Forms$OutputMapping", "AttributeRef": ref(target), + "SourceAttributeRef": ref(source), "SourceVariable": sv, "Expression": expr} +} + +func TestStoredFlowReturns(t *testing.T) { + overAssociation := outputMappingRow("M.Vehicle.Brand", "", "", "") + overAssociation["AttributeRef"].(map[string]any)["EntityRef"] = map[string]any{"$Type": "DomainModels$IndirectEntityRef"} + snippetVar := outputMappingRow("", "s", "", "") + snippetVar["SourceVariable"].(map[string]any)["SnippetParameter"] = "s" + for _, tc := range []struct { + name string + row map[string]any + want string // "" = not sayable + }{ + {"attribute from attribute", outputMappingRow("M.Vehicle.Brand", "", "M.Vehicle.Brand", ""), "Brand = $ActionReturnValue/Brand"}, + {"variable from the value", outputMappingRow("", "stringResult", "", ""), "$stringResult = $ActionReturnValue"}, + {"attribute from an expression", outputMappingRow("M.Vehicle.Brand", "", "", "'Demo brand'"), "Brand = 'Demo brand'"}, + {"attribute from the value", outputMappingRow("M.Vehicle.Brand", "", "", ""), "Brand = $ActionReturnValue"}, + {"keyword attribute", outputMappingRow("M.Vehicle.Type", "", "M.Vehicle.Type", ""), `Type = $ActionReturnValue/"Type"`}, + {"over an association", overAssociation, ""}, + {"a target in both slots", outputMappingRow("M.Vehicle.Brand", "s", "", ""), ""}, + {"no target", outputMappingRow("", "", "M.Vehicle.Brand", ""), ""}, + {"an expression reading like the shorthand", outputMappingRow("M.Vehicle.Brand", "", "", "$ActionReturnValue/Brand"), ""}, + {"a snippet parameter slot", snippetVar, ""}, + } { + holder := map[string]any{"OutputMappings": []any{int32(3), tc.row}} + got, ok := storedFlowReturns(holder) + if tc.want == "" { + if ok { + t.Errorf("%s: described as %q, want it reported as having no MDL form", tc.name, got) + } + if n := unsayableOutputMappings(map[string]any{"MicroflowSettings": holder}); n != 1 { + t.Errorf("%s: unsayableOutputMappings = %d, want 1", tc.name, n) + } + continue + } + if want := " returns (" + tc.want + ")"; !ok || got != want { + t.Errorf("%s: got %q (ok=%v), want %q", tc.name, got, ok, want) + } + if n := unsayableOutputMappings(map[string]any{"MicroflowSettings": holder}); n != 0 { + t.Errorf("%s: unsayableOutputMappings = %d, want 0", tc.name, n) + } + } + // A call without mappings says nothing and notes nothing. + if got, ok := storedFlowReturns(map[string]any{"OutputMappings": []any{int32(3)}}); !ok || got != "" { + t.Errorf("empty list: got %q (ok=%v)", got, ok) + } +} + +// Below Mendix 11.5 a flow call has no OutputMappings to store a mapping in. +func TestFlowReturnsVersionFloor(t *testing.T) { + for _, minor := range []int{4, 5} { + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ProjectVersionFunc: func() *types.ProjectVersion { + return &types.ProjectVersion{ProductVersion: fmt.Sprintf("11.%d.0", minor), MajorVersion: 11, MinorVersion: minor} + }, + } + ctx, _ := newMockCtx(t, withBackend(mb)) + pb := &pageBuilder{ctx: ctx, backend: mb, localVariables: map[string]bool{"s": true}} + a := &pages.MicroflowClientAction{MicroflowName: "M.F"} + err := pb.applyFlowReturns(a, []ast.FlowReturnV3{{Target: "s", Variable: true, Source: "$ActionReturnValue"}}) + if minor == 4 && (err == nil || !strings.Contains(err.Error(), "11.5.0")) { + t.Errorf("11.4: err = %v, want a refusal naming 11.5.0", err) + } + if minor == 5 && (err != nil || len(a.OutputMappings) != 1) { + t.Errorf("11.5: err = %v, mappings %d, want one mapping", err, len(a.OutputMappings)) + } + } +} + +// check -p and exec's pre-flight read the same gates the builder applies, so a +// script check passes is one exec does not then refuse (mendixlabs/mxcli#1434). +func TestFlowReturnsStatementGates(t *testing.T) { + button := func(returns string) string { + return "actionbutton b (Caption: 'Go', Action: call microflow M.F" + returns + ")" + } + for _, tc := range []struct { + name, src string + want []string + }{ + {"page, variable target", "create page M.P (Title: 'T', Layout: A.L) { " + button(" returns ($v = $ActionReturnValue)") + " };", + []string{"flow_call_output_mappings"}}, + {"page, attribute target", "create page M.P (Title: 'T', Layout: A.L) { dataview dv (DataSource: $E) { " + button(" returns (Name = $ActionReturnValue/Name)") + " } };", + []string{"flow_call_output_mappings", "flow_call_output_mappings_to_attribute"}}, + {"snippet", "create snippet M.S { " + button(" returns ($v = $ActionReturnValue)") + " };", + []string{"flow_call_output_mappings"}}, + {"alter page set", "alter page M.P { set (Action: call microflow M.F returns (Name = 'x')) on b };", + []string{"flow_call_output_mappings", "flow_call_output_mappings_to_attribute"}}, + {"control: no returns", "create page M.P (Title: 'T', Layout: A.L) { " + button("") + " };", nil}, + } { + prog, errs := visitor.Build(tc.src) + if len(errs) > 0 { + t.Fatalf("%s: parse: %v", tc.name, errs) + } + var got []string + for _, g := range statementVersionGates(prog.Statements[0]) { + got = append(got, g.name) + } + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("%s: gates %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/mdl/executor/version_gates.go b/mdl/executor/version_gates.go index 7ec112375c..d667ab3ef2 100644 --- a/mdl/executor/version_gates.go +++ b/mdl/executor/version_gates.go @@ -127,11 +127,15 @@ func statementVersionGates(stmt ast.Statement) []versionGate { case *ast.CreateAgentStmt: return []versionGate{agentDocumentGate("agent", "create agent")} case *ast.CreatePageStmtV3: + var gates []versionGate // Pages$PageParameter is 9.4+ (mendixlabs/mxcli#1121): 9.0–9.3 has none. if len(s.Parameters) > 0 { - return []versionGate{{"pages", "page_parameters", "create page with parameters", - "pass data via a non-persistent entity or microflow parameter instead"}} + gates = append(gates, versionGate{"pages", "page_parameters", "create page with parameters", + "pass data via a non-persistent entity or microflow parameter instead"}) } + return append(gates, flowReturnsGates(s)...) + case *ast.CreateSnippetStmtV3, *ast.AlterPageStmt: + return flowReturnsGates(s) case *ast.CreateMicroflowStmt: var gates []versionGate // SEND EMAIL writes the 11.13+ shape (StringTemplate subject and @@ -199,6 +203,53 @@ func statementVersionGates(stmt ast.Statement) []versionGate { return nil } +// The floors of a flow call's `returns ( … )` mappings (ako/mxcli#977). The +// builder applies the same two, for the widgets this walk cannot see (a +// fragment's, expanded at build time). +var ( + flowReturnsGate = versionGate{"pages", "flow_call_output_mappings", + "returns ( … ) on a flow call", "set the values from inside the flow instead"} + flowReturnsToAttributeGate = versionGate{"pages", "flow_call_output_mappings_to_attribute", + "returns ( … ) into an attribute", "map into a page variable ($name = …) instead"} +) + +// flowReturnsGates are the gates of the `returns ( … )` mappings a page, +// snippet or alter page statement writes: 11.5 for any, 11.15 for one into an +// attribute — mx 11.13 and 11.14 report CE7006 on it. +func flowReturnsGates(stmt ast.Statement) []versionGate { + var mapped, toAttribute bool + visit := func(v any) { + for a, _ := v.(*ast.ActionV3); a != nil; a = a.ThenAction { + for _, r := range a.Returns { + mapped = true + toAttribute = toAttribute || !r.Variable + } + } + } + forEachWidget(stmt, func(w *ast.WidgetV3, _ string) { + for _, v := range w.Properties { + visit(v) + } + }) + if s, ok := stmt.(*ast.AlterPageStmt); ok { + for _, op := range s.Operations { + if o, ok := op.(*ast.SetPropertyOp); ok { + for _, v := range o.Properties { + visit(v) + } + } + } + } + var gates []versionGate + if mapped { + gates = append(gates, flowReturnsGate) + } + if toAttribute { + gates = append(gates, flowReturnsToAttributeGate) + } + return gates +} + // oqlStringAggCallRe is a STRING_AGG call in query text as written. var oqlStringAggCallRe = regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_."` + "`" + `])string_agg\s*\(`) diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index a88356c02e..e0bb412dce 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -709,10 +709,10 @@ actionExprV3 | CREATE_OBJECT /* @alias MDL-DEPR020 */ qualifiedName (THEN actionExprV3 | actionSettingsV3)? | SHOW PAGE qualifiedName microflowArgsV3? actionSettingsV3? // show page Module.Page (Param: val) | SHOW_PAGE /* @alias MDL-DEPR020 */ qualifiedName microflowArgsV3? actionSettingsV3? - | CALL MICROFLOW qualifiedName microflowArgsV3? actionSettingsV3? // call microflow Module.Flow - | MICROFLOW /* @alias MDL-DEPR020 */ qualifiedName microflowArgsV3? actionSettingsV3? - | CALL NANOFLOW qualifiedName microflowArgsV3? actionSettingsV3? // call nanoflow Module.Flow - | NANOFLOW /* @alias MDL-DEPR020 */ qualifiedName microflowArgsV3? actionSettingsV3? + | CALL MICROFLOW qualifiedName microflowArgsV3? actionReturnsV3? actionSettingsV3? // call microflow Module.Flow + | MICROFLOW /* @alias MDL-DEPR020 */ qualifiedName microflowArgsV3? actionReturnsV3? actionSettingsV3? + | CALL NANOFLOW qualifiedName microflowArgsV3? actionReturnsV3? actionSettingsV3? // call nanoflow Module.Flow + | NANOFLOW /* @alias MDL-DEPR020 */ qualifiedName microflowArgsV3? actionReturnsV3? actionSettingsV3? | openLinkV3 STRING_LITERAL actionSettingsV3? // open link 'https://...' | openLinkV3 VARIABLE SLASH attributePathV3 actionSettingsV3? // open link $currentObject/URL (address read from an attribute) | SIGN_OUT actionSettingsV3? // sign out @@ -734,6 +734,19 @@ actionSettingsV3 : WITH LPAREN actionSettingV3 (COMMA actionSettingV3)* COMMA? RPAREN ; +// A flow call's return value mappings (Mendix 11.5, Studio Pro's "Return value +// mappings"): `returns (Brand = $ActionReturnValue/Brand, $result = $ActionReturnValue, +// Brand = 'fixed')`. Target = source, like the argument list: the target is an +// attribute of the data container's object or a page variable, the source the +// return value, one of its attributes, or an expression (ako/mxcli#977). +actionReturnsV3 + : RETURNS LPAREN actionReturnV3 (COMMA actionReturnV3)* COMMA? RPAREN + ; + +actionReturnV3 + : (VARIABLE | parameterName) EQUALS expression + ; + actionSettingV3 : identifierOrKeyword COLON (STRING_LITERAL | NONE | identifierOrKeyword) ; diff --git a/mdl/visitor/visitor_flow_returns_test.go b/mdl/visitor/visitor_flow_returns_test.go new file mode 100644 index 0000000000..100adaffea --- /dev/null +++ b/mdl/visitor/visitor_flow_returns_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#977 item 3: a flow call's output mappings (Mendix 11.5, Studio Pro's +// "Return value mappings") had no MDL, so describe → exec could only carry them +// and a script could not write one. `returns ( … )` after the arguments spells +// them, target = source like the argument list. +package visitor + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestFlowReturns(t *testing.T) { + for _, call := range []string{"call microflow", "call nanoflow"} { + a, errs := buttonAction(t, call+" M.F(Vehicle = $currentObject) returns ("+ + "Brand = $ActionReturnValue/Brand, $stringResult = $ActionReturnValue, "+ + "\"Type\" = 'Demo brand',) with (ProgressBar: blocking)") + if len(errs) > 0 { + t.Fatalf("%s: parse: %v", call, errs) + } + want := []ast.FlowReturnV3{ + {Target: "Brand", Source: "$ActionReturnValue/Brand"}, + {Target: "stringResult", Variable: true, Source: "$ActionReturnValue"}, + {Target: "Type", Source: "'Demo brand'"}, + } + if !reflect.DeepEqual(a.Returns, want) { + t.Errorf("%s: Returns = %+v, want %+v", call, a.Returns, want) + } + if len(a.Args) != 1 || a.Settings == nil || a.Settings.ProgressBar != "Blocking" { + t.Errorf("%s: the arguments or settings around returns were lost: %+v", call, a) + } + } +} + +// Control: a call without the list has no mappings. +func TestFlowReturns_AbsentIsNil(t *testing.T) { + a, errs := buttonAction(t, "call microflow M.F") + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if a.Returns != nil { + t.Errorf("Returns = %+v, want nil", a.Returns) + } +} diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index 3128765009..d56a9ec6a4 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -1265,6 +1265,9 @@ func buildActionV3(ctx parser.IActionExprV3Context) *ast.ActionV3 { action.OutcomeValue = unquoteStringLit(str) } } + if rc := actCtx.ActionReturnsV3(); rc != nil { + action.Returns = buildActionReturnsV3(rc) + } if sc := actCtx.ActionSettingsV3(); sc != nil { action.Settings = buildActionSettingsV3(sc) } @@ -1278,6 +1281,26 @@ func buildActionV3(ctx parser.IActionExprV3Context) *ast.ActionV3 { return action } +// buildActionReturnsV3 builds a flow call's `returns ( … )` list. +func buildActionReturnsV3(ctx parser.IActionReturnsV3Context) []ast.FlowReturnV3 { + var out []ast.FlowReturnV3 + for _, rc := range ctx.(*parser.ActionReturnsV3Context).AllActionReturnV3() { + r := rc.(*parser.ActionReturnV3Context) + var ret ast.FlowReturnV3 + if v := r.VARIABLE(); v != nil { + ret.Target = strings.TrimPrefix(v.GetText(), "$") + ret.Variable = true + } else if pn := r.ParameterName(); pn != nil { + ret.Target = parameterNameText(pn) + } + if expr := r.Expression(); expr != nil { + ret.Source = expressionSourceText(expr) + } + out = append(out, ret) + } + return out +} + // buildMicroflowArgsV3 builds flow arguments from the parse context. func buildMicroflowArgsV3(ctx parser.IMicroflowArgsV3Context) []ast.FlowArgV3 { if ctx == nil { diff --git a/modelsdk/canon/identity.go b/modelsdk/canon/identity.go index 2455e38154..8a8cb0c960 100644 --- a/modelsdk/canon/identity.go +++ b/modelsdk/canon/identity.go @@ -126,12 +126,6 @@ func Reconcile(contents, stored []byte, opts ...Option) (out []byte, unchanged b contents = CarryTranslations(contents, stored) } - // A flow call's output mappings (Mendix 11.5), which MDL has no syntax for: - // a rebuild wrote the empty list and dropped them (ako/mxcli#977). Like the - // translations, it changes the document's length, so it runs before the - // transplant. - contents = CarryOutputMappings(contents, stored) - // The same reasoning one level down, for the writes that do land: a rebuild // mints a fresh $ID for every sub-element, so a two-line change reads in // version control as a whole-document replacement. TransplantIDs puts the diff --git a/modelsdk/canon/outputmappings.go b/modelsdk/canon/outputmappings.go deleted file mode 100644 index 6d25d95354..0000000000 --- a/modelsdk/canon/outputmappings.go +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package canon - -import ( - "sort" - "strconv" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -// CarryOutputMappings copies onto a rebuilt document the output mappings of a -// flow call that the statement had no way to say. -// -// # Why anything is lost -// -// Mendix 11.5 lets a call-microflow or call-nanoflow action map what the flow -// returns back onto the page (Forms$MicroflowSettings.OutputMappings, -// Forms$CallNanoflowClientAction.OutputMappings). MDL has no syntax for them, -// so a rebuild writes the empty list Studio Pro stores on every other call, and -// describe → exec dropped every mapping: "Replaced page", the mapping gone, -// mx check silent (ako/mxcli#977). This is the carry Reconcile already does for -// translations, for the same reason: the rebuild is authoritative about what -// the statement said and silent about what it could not say. -// -// # How lists are paired -// -// By the named element that owns the call and the path from it — "the button -// btnGo, /Action/MicroflowSettings" — and only where that address occurs once -// in each document and is reached without passing through a list. A list is -// carried only when -// -// - the rebuilt list is empty: a statement that wrote mappings is the -// authority for them; -// - the call still names the same flow: mappings read the flow's result, so -// they mean nothing for another one, and carrying them would bind a page to -// outputs the new flow does not have; -// - none of the carried elements' ids is already in the rebuilt document. -// Rebuilt ids are freshly minted, so this does not happen in practice, but a -// duplicate id makes the unit unopenable, which is not a risk worth taking -// for a carry. -// -// The elements are carried verbatim, ids included, so the canonical form sees -// the same elements it saw before and an unchanged button is an unchanged -// document. Anything that cannot be read returns the contents untouched. -func CarryOutputMappings(contents, stored []byte) []byte { - if len(contents) == 0 || len(stored) == 0 { - return contents - } - var storedDoc, newDoc bson.D - if bson.Unmarshal(stored, &storedDoc) != nil { - return contents - } - storedLists := outputMappingHolders(storedDoc) - if len(storedLists) == 0 { - return contents - } - if bson.Unmarshal(contents, &newDoc) != nil { - return contents - } - newLists := outputMappingHolders(newDoc) - used := elementIDs(newDoc) - - addrs := make([]string, 0, len(storedLists)) - for a := range storedLists { - addrs = append(addrs, a) - } - sort.Strings(addrs) - - changed := false - for _, addr := range addrs { - s, n := storedLists[addr], newLists[addr] - if n == nil || flowOf(s) == "" || flowOf(s) != flowOf(n) { - continue - } - have, ok := docLookup(n, "OutputMappings").(bson.A) - if !ok || len(listElements(have)) > 0 { - continue - } - carried := listElements(docLookup(s, "OutputMappings").(bson.A)) - if len(carried) == 0 || anyIDUsed(carried, used) { - continue - } - for _, e := range carried { - markIDs(e, used) - have = append(have, e) - } - for i := range n { - if n[i].Key == "OutputMappings" { - n[i].Value = have - } - } - changed = true - } - if !changed { - return contents - } - out, err := bson.Marshal(newDoc) - if err != nil { - return contents - } - return out -} - -// outputMappingHolders addresses every flow call holding an OutputMappings list -// by its owning named element and the path from it, keeping only the addresses -// that are exact: owned by an element with a $Type and a Name, reached from it -// without passing through a list, and occurring once in the document. In the -// stored document only calls with a non-empty list matter, but the rebuilt one -// needs every call, so both are returned and the caller filters. -func outputMappingHolders(doc bson.D) map[string]bson.D { - out := map[string]bson.D{} - count := map[string]int{} - var walk func(v any, element, rel string, indexed bool) - walk = func(v any, element, rel string, indexed bool) { - switch n := v.(type) { - case bson.D: - ty, _ := docLookup(n, "$Type").(string) - if ty == "Forms$MicroflowSettings" || ty == "Forms$CallNanoflowClientAction" { - if _, ok := docLookup(n, "OutputMappings").(bson.A); ok && element != "" && !indexed { - key := element + "\x00" + rel - count[key]++ - out[key] = n - } - } - if name, _ := docLookup(n, "Name").(string); name != "" && ty != "" { - element, rel, indexed = ty+"\x00"+name, "", false - } - for _, e := range n { - walk(e.Value, element, rel+"/"+e.Key, indexed) - } - case bson.A: - for i, e := range n { - walk(e, element, rel+"/"+strconv.Itoa(i), true) - } - } - } - walk(doc, "", "", false) - for key, c := range count { - if c > 1 { - delete(out, key) - } - } - return out -} - -// flowOf is the flow a call names: a MicroflowSettings' Microflow or a -// nanoflow call's Nanoflow, both by qualified name. -func flowOf(d bson.D) string { - if s, _ := docLookup(d, "Microflow").(string); s != "" { - return s - } - s, _ := docLookup(d, "Nanoflow").(string) - return s -} - -// listElements returns a stored list's elements, without its leading marker. -func listElements(a bson.A) []bson.D { - var out []bson.D - for _, e := range a { - if d, ok := e.(bson.D); ok { - out = append(out, d) - } - } - return out -} - -func anyIDUsed(elems []bson.D, used map[string]bool) bool { - for _, e := range elems { - for id := range elementIDs(e) { - if used[id] { - return true - } - } - } - return false -} - -func markIDs(e bson.D, used map[string]bool) { - for id := range elementIDs(e) { - used[id] = true - } -} diff --git a/modelsdk/canon/outputmappings_977_test.go b/modelsdk/canon/outputmappings_977_test.go deleted file mode 100644 index b7750de92f..0000000000 --- a/modelsdk/canon/outputmappings_977_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package canon - -import ( - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -// ako/mxcli#977 item 3: a button's output mappings (Mendix 11.5) have no MDL, so -// a rebuild writes the empty list and describe → exec dropped every mapping -// Studio Pro stored — "Replaced page", the mapping gone, mx check silent. -// CarryOutputMappings keeps them while the button still calls the same flow. - -func outputMapping(id byte, expr string) bson.D { - return bson.D{ - {Key: "$ID", Value: bin(id)}, - {Key: "$Type", Value: "Forms$OutputMapping"}, - {Key: "AttributeRef", Value: nil}, - {Key: "Expression", Value: expr}, - {Key: "SourceAttributeRef", Value: nil}, - {Key: "SourceVariable", Value: nil}, - } -} - -// buttonPage is a page with one button named name whose action calls flow -// with the given output mappings. nanoflow selects a CallNanoflowClientAction, -// otherwise a MicroflowClientAction with MicroflowSettings. -func buttonPage(t *testing.T, base byte, name, flow string, nanoflow bool, mappings ...bson.D) []byte { - t.Helper() - list := bson.A{int32(3)} - for _, m := range mappings { - list = append(list, m) - } - var action bson.D - if nanoflow { - action = bson.D{ - {Key: "$ID", Value: bin(base + 2)}, - {Key: "$Type", Value: "Forms$CallNanoflowClientAction"}, - {Key: "Nanoflow", Value: flow}, - {Key: "OutputMappings", Value: list}, - } - } else { - action = bson.D{ - {Key: "$ID", Value: bin(base + 2)}, - {Key: "$Type", Value: "Forms$MicroflowClientAction"}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: bin(base + 3)}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Microflow", Value: flow}, - {Key: "OutputMappings", Value: list}, - }}, - } - } - return marshal(t, bson.D{ - {Key: "$Type", Value: "Forms$Page"}, - {Key: "$ID", Value: bin(1)}, - {Key: "Name", Value: "Go_Page"}, - {Key: "Widgets", Value: bson.A{int32(2), bson.D{ - {Key: "$ID", Value: bin(base + 1)}, - {Key: "$Type", Value: "Forms$ActionButton"}, - {Key: "Name", Value: name}, - {Key: "Action", Value: action}, - }}}, - }) -} - -// outputMappingExprs lists the Expression of every Forms$OutputMapping. -func outputMappingExprs(t *testing.T, raw []byte) []string { - t.Helper() - var doc bson.D - if err := bson.Unmarshal(raw, &doc); err != nil { - t.Fatal(err) - } - var out []string - var walk func(v any) - walk = func(v any) { - switch n := v.(type) { - case bson.D: - if docLookup(n, "$Type") == "Forms$OutputMapping" { - s, _ := docLookup(n, "Expression").(string) - out = append(out, s) - } - for _, e := range n { - walk(e.Value) - } - case bson.A: - for _, e := range n { - walk(e) - } - } - } - walk(doc) - return out -} - -func TestCarryOutputMappings(t *testing.T) { - for _, nanoflow := range []bool{false, true} { - stored := buttonPage(t, 10, "btnGo", "M.ACT_Go", nanoflow, outputMapping(40, "'a'"), outputMapping(41, "'b'")) - for _, tc := range []struct { - name string - rebuilt []byte - want int - }{ - // The reported round trip: the rebuild wrote the empty list. - {"same button, same flow", buttonPage(t, 20, "btnGo", "M.ACT_Go", nanoflow), 2}, - // Controls: what the statement said wins. - {"the flow changed", buttonPage(t, 20, "btnGo", "M.ACT_Other", nanoflow), 0}, - {"the statement wrote its own", buttonPage(t, 20, "btnGo", "M.ACT_Go", nanoflow, outputMapping(50, "'mine'")), 1}, - {"another button", buttonPage(t, 20, "btnOther", "M.ACT_Go", nanoflow), 0}, - } { - got := outputMappingExprs(t, CarryOutputMappings(tc.rebuilt, stored)) - if len(got) != tc.want { - t.Errorf("nanoflow=%v %s: output mappings %v, want %d", nanoflow, tc.name, got, tc.want) - } - } - // The whole point: an unchanged button is an unchanged document. - if _, unchanged := Reconcile(buttonPage(t, 20, "btnGo", "M.ACT_Go", nanoflow), stored); !unchanged { - t.Errorf("nanoflow=%v: a rebuild that only dropped the output mappings was written", nanoflow) - } - } -} diff --git a/sdk/pages/pages_widgets_action.go b/sdk/pages/pages_widgets_action.go index 518372782c..981dece764 100644 --- a/sdk/pages/pages_widgets_action.go +++ b/sdk/pages/pages_widgets_action.go @@ -191,6 +191,21 @@ type FlowCallSettings struct { FormValidations string `json:"formValidations,omitempty"` // microflow only: "" (All) | All | Widget | None } +// FlowOutputMapping is Forms$OutputMapping, one row of Studio Pro's "Return +// value mappings": a value read from the flow's result into the page. +// +// The target is an attribute of the data container's object (TargetAttribute, +// qualified) or a page variable (TargetVariable, stored in the mapping's +// SourceVariable despite the name). The source is an attribute of the returned +// object (SourceAttribute, qualified) or an Expression; neither set means the +// return value itself. +type FlowOutputMapping struct { + TargetAttribute string `json:"targetAttribute,omitempty"` + TargetVariable string `json:"targetVariable,omitempty"` + SourceAttribute string `json:"sourceAttribute,omitempty"` + Expression string `json:"expression,omitempty"` +} + // ConfirmationInfo is Forms$ConfirmationInfo: the question asked before a flow // call runs, and the captions of its two buttons. type ConfirmationInfo struct { @@ -280,6 +295,9 @@ type MicroflowClientAction struct { MicroflowID model.ID `json:"microflowId"` MicroflowName string `json:"microflowName,omitempty"` // Qualified name for BSON serialization ParameterMappings []*MicroflowParameterMapping `json:"parameterMappings,omitempty"` + // OutputMappings are the call's return value mappings (Mendix 11.5), + // stored in its MicroflowSettings. + OutputMappings []*FlowOutputMapping `json:"outputMappings,omitempty"` } func (MicroflowClientAction) isClientAction() {} @@ -305,6 +323,8 @@ type NanoflowClientAction struct { NanoflowID model.ID `json:"nanoflowId"` NanoflowName string `json:"nanoflowName,omitempty"` // Qualified name for BSON serialization ParameterMappings []*NanoflowParameterMapping `json:"parameterMappings,omitempty"` + // OutputMappings are the call's return value mappings (Mendix 11.5). + OutputMappings []*FlowOutputMapping `json:"outputMappings,omitempty"` } func (NanoflowClientAction) isClientAction() {} diff --git a/sdk/versions/mendix-11.yaml b/sdk/versions/mendix-11.yaml index d3cb1be1ef..f3c5b6583e 100644 --- a/sdk/versions/mendix-11.yaml +++ b/sdk/versions/mendix-11.yaml @@ -99,6 +99,22 @@ features: mdl: "CREATE PAGE Module.Name (Param: Type, ...) { ... }" page_variables: min_version: "11.0.0" + flow_call_output_mappings: + # Studio Pro's "Return value mappings" on a call microflow / call + # nanoflow action: Forms$MicroflowSettings.OutputMappings and + # Forms$CallNanoflowClientAction.OutputMappings are introduced in 11.5.0 + # (modelsdk/gen/pages/version.go, from the reflection data). + min_version: "11.5.0" + mdl: "call microflow M.F returns ($var = $ActionReturnValue)" + flow_call_output_mappings_to_attribute: + # A return value mapping whose target is an attribute of the data + # container's object. Measured, not from the metamodel (the property set + # is the same from 11.5): the identical bytes check clean on mx + # 11.15.0-rc.4 and fail CE7006 "Selected value is not valid for + # attribute" on mx 11.13.0 and 11.14.0, before and after `mx convert`. + # A page-variable target checks clean on all three (ako/mxcli#977). + min_version: "11.15.0" + mdl: "call microflow M.F returns (Attr = $ActionReturnValue/Attr)" pluggable_widgets: min_version: "10.0.0" notes: "Widget templates are currently extracted from 11.6; may cause CE0463 on 10.x" From e9fb55ce6e0ff3ec18252c43c743c920305622ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:22:39 +0000 Subject: [PATCH 29/40] feat(workflows): give a timer boundary event a caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `boundary event interrupting timer caption 'Escalate' { … }` was a parse error, so every timer boundary event mxcli wrote had an empty caption in Studio Pro, and a describe -> create or modify blanked one Studio Pro had set. A timer now takes the `caption '…'` clause every activity takes, after its delay, in CREATE and in ALTER's insert. The delay may be omitted, so `caption` (and its alias `comment`) is excluded from the delay's first token, as `non` already was. The caption reaches the writers through BoundaryEvent.Caption and a caption argument on the workflow mutator's InsertBoundaryEvent (wfmutator, MCP, mock), and describe prints it. With that, the rewrite guard of #1713 no longer refuses a stored timer caption. Measured on 11.15.0: both captions stored as written, mx check 0 errors, describe prints them, replaying describe reports "Unchanged workflow"; an ALTER insert with a caption stores it, 0 errors. Fixes #1715 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xfGeNtrQ5BJrH2Ew64rt5 --- ...26-10-26-timer-boundary-event-caption.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 2 +- cmd/mxcli/syntax/features_workflow.go | 4 +- .../src/reference/workflow/create-workflow.md | 4 +- docs/01-project/MDL_QUICK_REFERENCE.md | 4 +- .../1715-timer-boundary-event-caption.mdl | 54 +++++++++++++++ mdl/ast/ast_workflow.go | 1 + mdl/backend/mcp/workflow.go | 6 +- mdl/backend/mcp/workflow_member_kind_test.go | 2 +- mdl/backend/mcp/workflow_test.go | 4 +- mdl/backend/mcp/workflow_timer_test.go | 6 +- mdl/backend/mock/mock_workflow_mutator.go | 6 +- mdl/backend/mutation.go | 2 +- mdl/backend/wfmutator/member_kind_test.go | 6 +- mdl/backend/wfmutator/mutator.go | 4 +- mdl/backend/wfmutator/mutator_test.go | 27 ++++++-- mdl/executor/cmd_alter_workflow.go | 2 +- mdl/executor/cmd_workflows.go | 11 +++- mdl/executor/cmd_workflows_describe_test.go | 27 ++++++++ mdl/executor/validate_workflow_unstatable.go | 16 ++--- .../validate_workflow_unstatable_test.go | 20 ++++-- .../workflow_interrupting_end_test.go | 2 +- mdl/grammar/domains/MDLWorkflow.g4 | 11 ++-- mdl/visitor/visitor_alter_workflow.go | 2 +- mdl/visitor/visitor_workflow.go | 2 + .../visitor_workflow_timer_caption_test.go | 65 +++++++++++++++++++ 26 files changed, 236 insertions(+), 55 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-timer-boundary-event-caption.json create mode 100644 mdl-examples/bug-tests/1715-timer-boundary-event-caption.mdl create mode 100644 mdl/visitor/visitor_workflow_timer_caption_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-timer-boundary-event-caption.json b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-timer-boundary-event-caption.json new file mode 100644 index 0000000000..28a69943af --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-timer-boundary-event-caption.json @@ -0,0 +1 @@ +{"area": "mdl/grammar", "date": "2026-10-26", "symptom": "`boundary event interrupting timer addDays([%CurrentDateTime%], 3) caption 'Escalate' { … }` failed with \"mismatched input 'caption' expecting ';'\" and a misleading hint that 'Caption' is a keyword to quote; every timer boundary event mxcli wrote had an empty Caption in Studio Pro, and the rewrite guard of #1713 had to refuse a stored one", "cause": "workflowBoundaryEventClause gave a timer no caption slot, although Workflows$InterruptingTimerBoundaryEvent / NonInterruptingTimerBoundaryEvent have Caption and every writer already emitted the key (always \"\"); the reader read it and describe never printed it", "file": "`mdl/grammar/domains/MDLWorkflow.g4` (workflowBoundaryEventClause, workflowTimerDelay predicate), `mdl/visitor/visitor_workflow.go` (buildBoundaryEventNode), `mdl/visitor/visitor_alter_workflow.go`, `mdl/ast/ast_workflow.go` (InsertBoundaryEventOp.Caption), `mdl/backend/mutation.go` + `wfmutator/mutator.go` + `mcp/workflow.go` + `mock` (InsertBoundaryEvent caption), `mdl/executor/cmd_workflows.go` (formatBoundaryEvents), `mdl/executor/validate_workflow_unstatable.go`", "insight": "A delay is optional and is an expression, so a new keyword after it must be excluded from the delay's first token too: `interrupting timer caption 'X'` would otherwise parse `caption` as the delay expression (the same reason `non` already was, ako/mxcli#753) — add CAPTION and its alias COMMENT to the workflowTimerDelay predicate and test the no-delay form. Reuse workflowCaption rather than a bare string: notification events already use the bare string as caption, so a timer's string would read as its delay. Changing the WorkflowMutator interface means three implementations (wfmutator, mcp, mock) and every test call site; a small arg-aware script beats sed for the call sites because delays contain commas", "fix": "Accept workflowCaption after a timer's delay (create and alter insert), build it into BoundaryEvent.Caption, pass it through InsertBoundaryEvent to the writers, print `caption '…'` in describe, and drop the timer-caption reason from the #1713 refusal", "refs": ["ako/mxcli#1715", "ako/mxcli#1713"], "ce": []} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index f1274fdc81..702239b0c6 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -235,7 +235,7 @@ begin user task Review 'Review' page Module.ReviewPage outcomes 'Done' { } - boundary event interrupting timer addDays([%CurrentDateTime%], 3) { + boundary event interrupting timer addDays([%CurrentDateTime%], 3) caption 'Escalate' { call microflow Module.ACT_Escalate; }; end workflow; diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 7425453471..70ac8ed629 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -438,8 +438,8 @@ func init() { // 'Workflows$TimerBoundaryEvent' could not be found"). This entry showed // the bare form, with 'P3D' — not a valid timer expression — as its delay. // ako/view-entity-examples FINDINGS §7. - Syntax: "-- inline, as a clause of a USER TASK or CALL MICROFLOW (after OUTCOMES),\n-- a CALL WORKFLOW or a WAIT FOR NOTIFICATION:\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER { }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [''] { }\n\n-- A notification boundary event (Mendix 11.11+) is triggered by `notify workflow`\n-- targeting its name, so the name is unique in the workflow. ALTER cannot insert\n-- one yet; restate the workflow instead.\n\n-- or add a timer to an existing task:\nALTER WORKFLOW { INSERT INTO { BOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER { } } };\n\n-- Name the kind: a bare TIMER is refused on Mendix 11 (MDL-WF07) — it writes a\n-- type the runtime cannot load. The delay is an expression that yields a\n-- DateTime, e.g. addDays([%CurrentDateTime%], 3). mxcli ends every boundary\n-- path with Mendix's end-of-path marker, so a path may end in a call; use\n-- JUMP TO to return to the task instead.", - Example: "user task ReviewTask 'Review'\n page Module.WF_Review\n outcomes 'Done' { }\n boundary event interrupting timer addDays([%CurrentDateTime%], 3) {\n call microflow Module.WF_Escalate;\n };", + Syntax: "-- inline, as a clause of a USER TASK or CALL MICROFLOW (after OUTCOMES),\n-- a CALL WORKFLOW or a WAIT FOR NOTIFICATION:\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER [CAPTION ''] { }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [''] { }\n\n-- A notification boundary event (Mendix 11.11+) is triggered by `notify workflow`\n-- targeting its name, so the name is unique in the workflow. ALTER cannot insert\n-- one yet; restate the workflow instead.\n\n-- or add a timer to an existing task:\nALTER WORKFLOW { INSERT INTO { BOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER [CAPTION ''] { } } };\n\n-- Name the kind: a bare TIMER is refused on Mendix 11 (MDL-WF07) — it writes a\n-- type the runtime cannot load. The delay is an expression that yields a\n-- DateTime, e.g. addDays([%CurrentDateTime%], 3). mxcli ends every boundary\n-- path with Mendix's end-of-path marker, so a path may end in a call; use\n-- JUMP TO to return to the task instead.", + Example: "user task ReviewTask 'Review'\n page Module.WF_Review\n outcomes 'Done' { }\n boundary event interrupting timer addDays([%CurrentDateTime%], 3) caption 'Escalate' {\n call microflow Module.WF_Escalate;\n };", MinVersion: "10.6.0", SeeAlso: []string{"workflow.user-task", "workflow.notification", "workflow.event-subprocess"}, }) diff --git a/docs-site/src/reference/workflow/create-workflow.md b/docs-site/src/reference/workflow/create-workflow.md index f12c450a47..eda09e0f8c 100644 --- a/docs-site/src/reference/workflow/create-workflow.md +++ b/docs-site/src/reference/workflow/create-workflow.md @@ -115,10 +115,12 @@ User tasks, call microflow activities, and wait-for-notification activities supp USER TASK name 'caption' PAGE module.Page OUTCOMES 'Done' { } - BOUNDARY EVENT INTERRUPTING TIMER addHours([%CurrentDateTime%], 1) { activities } + BOUNDARY EVENT INTERRUPTING TIMER addHours([%CurrentDateTime%], 1) CAPTION 'Escalate' { activities } BOUNDARY EVENT NON INTERRUPTING TIMER addMinutes([%CurrentDateTime%], 30) { activities } ``` +`CAPTION ''` after the delay is the text Studio Pro shows on the timer; it is optional. + ## Parameters `module.Name` diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 7e5a7f2acb..cc2f02ac5c 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -768,7 +768,7 @@ mandatory and a misplaced clause failed with a token error - `notification [] [caption ''];` — an intermediate notification event (Mendix 11.11+) - `end workflow [caption ''];` — only inside a `{ }` block; ends the whole workflow - `caption '…'` sets the caption Studio Pro shows on an activity; `comment '…'`, the old spelling, is a deprecated alias (`MDL-DEPR104`) -- Boundary events, after `outcomes`: `boundary event [non] interrupting timer { … }` or `boundary event [non] interrupting notification [''] { … }` (11.11+). One interrupting event per activity (CE6697, MDL-WF15). +- Boundary events, after `outcomes`: `boundary event [non] interrupting timer [caption ''] { … }` or `boundary event [non] interrupting notification [''] { … }` (11.11+). One interrupting event per activity (CE6697, MDL-WF15). - **Variables in a workflow expression**: `$WorkflowContext` (or the header's declared name) and `$WorkflowInstance`, the running `System.Workflow` — `call microflow M.ACT(Workflow = $WorkflowInstance)`. Anything else (`$Workflow`, `$currentUser`, `$WorkflowUserTask`, …) is CE0117 (MDL-WF19); the workflow's own `due date` sees only the context. **Notifying a workflow** (a microflow statement): `[$Notified =] notify workflow $Workflow target Module.Workflow.ElementName;` — the element is a notification-started event sub-process's start, a notification activity, a notification boundary event or a wait for notification, and mxcli resolves which. The target is required (CE0166, MDL-WF16). @@ -862,7 +862,7 @@ Modify an existing workflow's properties, activities, outcomes, paths, condition | Insert user-task outcome | `insert into activity { outcomes 'name' { body } }` | User task only | | Insert decision outcome | `insert into activity { outcomes 'Module.Enum.Value' -> { body } }` (or `true`, `false`, `default`) | Decision or call microflow | | Insert path | `insert into activity { path { body } }` | Parallel split; `path n` must be the next number | -| Insert boundary event | `insert into activity { boundary event interrupting timer { body } }` | Boundary timer | +| Insert boundary event | `insert into activity { boundary event interrupting timer [caption ''] { body } }` | Boundary timer | | Drop outcome | `drop activity outcome 'name';` / `drop activity outcome true;` | `true`, `false`, `default` for a decision's Boolean or default outcome | | Drop path | `drop activity path 2;` | Parallel split path by number, as `describe` numbers them; refused on any other activity (a user task's branches are its outcomes) | | Drop boundary event | `drop activity boundary event;` | For an activity with one boundary event; with several, refused under `mdl 1` and the first dropped with warning MDL-V1-BOUNDARYDROP under `mdl 0` | diff --git a/mdl-examples/bug-tests/1715-timer-boundary-event-caption.mdl b/mdl-examples/bug-tests/1715-timer-boundary-event-caption.mdl new file mode 100644 index 0000000000..7b1b773df6 --- /dev/null +++ b/mdl-examples/bug-tests/1715-timer-boundary-event-caption.mdl @@ -0,0 +1,54 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1715 — "Timer boundary events cannot be given a caption" +-- ============================================================================ +-- +-- A timer boundary event takes the `caption '…'` clause every activity takes, +-- after its delay — the text Studio Pro shows in "Edit Interrupting Timer +-- Boundary Event". Before, `caption` there was a parse error ("mismatched input +-- 'caption' expecting ';'"), and every timer mxcli wrote had an empty caption. +-- Measured on mxbuild 11.15.0: the captions below are stored as written, mx +-- check reports 0 errors, describe prints them, and replaying describe reports +-- "Unchanged workflow". `alter workflow … insert into Review { boundary event … +-- caption '…' { … } }` writes one too. +-- +-- EXPECTED: `mxcli check` passes this file; `describe workflow` prints both +-- `caption` clauses. +-- Guard: mdl/visitor/visitor_workflow_timer_caption_test.go, +-- mdl/executor/cmd_workflows_describe_test.go (TimerBoundaryEventCaptionReparses). +-- ============================================================================ + +create module BugTimerCaption1715; + +create persistent entity BugTimerCaption1715.Request ( + Title: String(200) +); + +create page BugTimerCaption1715.WF_Review +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + actionbutton btnDone (Caption: 'Done', Action: complete task 'Done') + } +}; + +create microflow BugTimerCaption1715.ACT_Remind ($Context: BugTimerCaption1715.Request) +begin + log info node 'WF' 'remind'; +end; + +create workflow BugTimerCaption1715.Approval + parameter $WorkflowContext: BugTimerCaption1715.Request +begin + user task Review 'Review' page BugTimerCaption1715.WF_Review + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + outcomes 'Done' { } + boundary event interrupting timer addDays([%CurrentDateTime%], 3) caption 'Escalate' { } + boundary event non interrupting timer addDays([%CurrentDateTime%], 1) caption 'Remind' { + call microflow BugTimerCaption1715.ACT_Remind; + }; +end workflow; diff --git a/mdl/ast/ast_workflow.go b/mdl/ast/ast_workflow.go index 7a71a2617d..2d9f3223d6 100644 --- a/mdl/ast/ast_workflow.go +++ b/mdl/ast/ast_workflow.go @@ -430,6 +430,7 @@ type InsertBoundaryEventOp struct { AtPosition int EventType string // "InterruptingTimer", "NonInterruptingTimer" Delay string + Caption string Activities []WorkflowActivityNode } diff --git a/mdl/backend/mcp/workflow.go b/mdl/backend/mcp/workflow.go index 40c2fe82f2..7f715828ad 100644 --- a/mdl/backend/mcp/workflow.go +++ b/mdl/backend/mcp/workflow.go @@ -2016,7 +2016,7 @@ func (m *mcpWorkflowMutator) DropBranch(activityRef string, atPos int, branchNam // InsertBoundaryEvent attaches a (non-)interrupting timer boundary event, with an // optional handler sub-flow, to a user task or call-microflow activity. -func (m *mcpWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType, delay string, activities []workflows.WorkflowActivity) error { +func (m *mcpWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType, delay, caption string, activities []workflows.WorkflowActivity) error { loc, err := m.resolveMember(activityRef, atPos, "insert boundary event", backend.WorkflowBoundaryEvents) if err != nil { return err @@ -2029,7 +2029,7 @@ func (m *mcpWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, return "" }).Activities wfnames.Dedup(activities, loc.taken) - be := &workflows.BoundaryEvent{EventType: eventType, TimerDelay: delay} + be := &workflows.BoundaryEvent{EventType: eventType, TimerDelay: delay, Caption: caption} if be.IsNotification() { // The op carries no name, and a notification event is reached by its name. return fmt.Errorf("insert boundary event: %q boundary events cannot be inserted by ALTER WORKFLOW", eventType) @@ -2249,7 +2249,7 @@ func boundaryEventElement(be *workflows.BoundaryEvent) (map[string]any, error) { } return el, nil } - el := map[string]any{"$Type": typeName, "caption": ""} + el := map[string]any{"$Type": typeName, "caption": be.Caption} switch typeName { case "Workflows$InterruptingTimerBoundaryEvent": el["isInsideOfParallelSplit"] = false diff --git a/mdl/backend/mcp/workflow_member_kind_test.go b/mdl/backend/mcp/workflow_member_kind_test.go index 97a05f0d47..ba748f1e79 100644 --- a/mdl/backend/mcp/workflow_member_kind_test.go +++ b/mdl/backend/mcp/workflow_member_kind_test.go @@ -41,7 +41,7 @@ func TestWFInsertMember_OnWrongKindIsRefused(t *testing.T) { "outcome on a decision": func(m *mcpWorkflowMutator) error { return m.InsertOutcome("Decision", 0, "X", nil) }, "branch on a user task": func(m *mcpWorkflowMutator) error { return m.InsertBranch("ReviewOrder", 0, "true", nil) }, "boundary on a decision": func(m *mcpWorkflowMutator) error { - return m.InsertBoundaryEvent("Decision", 0, "InterruptingTimer", "", nil) + return m.InsertBoundaryEvent("Decision", 0, "InterruptingTimer", "", "", nil) }, } for name, op := range cases { diff --git a/mdl/backend/mcp/workflow_test.go b/mdl/backend/mcp/workflow_test.go index 936f878c31..c9419aa6fe 100644 --- a/mdl/backend/mcp/workflow_test.go +++ b/mdl/backend/mcp/workflow_test.go @@ -396,7 +396,7 @@ func jumpShadowFake(t *testing.T) (*fakePED, *mcpWorkflowMutator) { // caption matched the reference as well as the target's name. func TestWFResolve_NameBeatsJumpCaption(t *testing.T) { f, m := jumpShadowFake(t) - if err := m.InsertBoundaryEvent("bugSplitJump", 0, "NonInterruptingTimer", "addHours([%CurrentDateTime%], 1)", nil); err != nil { + if err := m.InsertBoundaryEvent("bugSplitJump", 0, "NonInterruptingTimer", "addHours([%CurrentDateTime%], 1)", "", nil); err != nil { t.Fatal(err) } if ops := wfUpdateOps(t, f); !strings.Contains(ops, `"path":"/flow/activities/0/boundaryEvents"`) { @@ -526,7 +526,7 @@ func TestWFDropBranch(t *testing.T) { func TestWFBoundaryEvent(t *testing.T) { f, m := wfMutatorFake(t) - if err := m.InsertBoundaryEvent("ReviewOrder", 0, "NonInterruptingTimer", "addHours([%CurrentDateTime%], 1)", nil); err != nil { + if err := m.InsertBoundaryEvent("ReviewOrder", 0, "NonInterruptingTimer", "addHours([%CurrentDateTime%], 1)", "", nil); err != nil { t.Fatal(err) } ops := wfUpdateOps(t, f) diff --git a/mdl/backend/mcp/workflow_timer_test.go b/mdl/backend/mcp/workflow_timer_test.go index f58a0d87fd..59e5752271 100644 --- a/mdl/backend/mcp/workflow_timer_test.go +++ b/mdl/backend/mcp/workflow_timer_test.go @@ -306,7 +306,7 @@ func TestUpdateWorkflow_TimerOnSingleUserTaskIsReaddedThenFinished(t *testing.T) func TestWFInsertBoundaryEvent_InterruptingTimerJump(t *testing.T) { f, m := wfMutatorFake(t) jump := timerJump("jumpBack", "ReviewOrder") - if err := m.InsertBoundaryEvent("ReviewOrder", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 3)", []workflows.WorkflowActivity{jump}); err != nil { + if err := m.InsertBoundaryEvent("ReviewOrder", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 3)", "", []workflows.WorkflowActivity{jump}); err != nil { t.Fatal(err) } // The activity already had one event; the store prepends, so the new one is at 0. @@ -324,7 +324,7 @@ func TestWFInsertBoundaryEvent_InterruptingTimerJump(t *testing.T) { // marker) is refused before anything is sent, naming the endings that work. func TestWFInsertBoundaryEvent_InterruptingTimerRunningToItsEndIsRefused(t *testing.T) { f, m := wfMutatorFake(t) - err := m.InsertBoundaryEvent("ReviewOrder", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 3)", nil) + err := m.InsertBoundaryEvent("ReviewOrder", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 3)", "", nil) if err == nil || !strings.Contains(err.Error(), "`end workflow;` or `jump to `") || !strings.Contains(err.Error(), "on ReviewOrder") { t.Fatalf("want a refusal naming the endings and the activity, got %v", err) } @@ -358,7 +358,7 @@ func TestWFInsertBoundaryEvent_InsideSplit(t *testing.T) { }) m := &mcpWorkflowMutator{backend: &Backend{client: f.connectClient(t)}, moduleName: "M", workflowName: "WF"} jump := timerJump("jumpSplit", "splitWait") - if err := m.InsertBoundaryEvent("splitWait", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 4)", []workflows.WorkflowActivity{jump}); err != nil { + if err := m.InsertBoundaryEvent("splitWait", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 4)", "", []workflows.WorkflowActivity{jump}); err != nil { t.Fatal(err) } const ev = "/flow/activities/0/outcomes/0/flow/activities/0/boundaryEvents" diff --git a/mdl/backend/mock/mock_workflow_mutator.go b/mdl/backend/mock/mock_workflow_mutator.go index 12e29df58a..ea9805521c 100644 --- a/mdl/backend/mock/mock_workflow_mutator.go +++ b/mdl/backend/mock/mock_workflow_mutator.go @@ -27,7 +27,7 @@ type MockWorkflowMutator struct { DropPathFunc func(activityRef string, atPos int, pathCaption string) error InsertBranchFunc func(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error DropBranchFunc func(activityRef string, atPos int, branchName string) error - InsertBoundaryEventFunc func(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error + InsertBoundaryEventFunc func(activityRef string, atPos int, eventType string, delay string, caption string, activities []workflows.WorkflowActivity) error DropBoundaryEventFunc func(activityRef string, atPos int) error SaveFunc func() error } @@ -133,9 +133,9 @@ func (m *MockWorkflowMutator) DropBranch(activityRef string, atPos int, branchNa return nil } -func (m *MockWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error { +func (m *MockWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, caption string, activities []workflows.WorkflowActivity) error { if m.InsertBoundaryEventFunc != nil { - return m.InsertBoundaryEventFunc(activityRef, atPos, eventType, delay, activities) + return m.InsertBoundaryEventFunc(activityRef, atPos, eventType, delay, caption, activities) } return nil } diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index 3ce9378429..5e6aaeefd7 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -340,7 +340,7 @@ type WorkflowMutator interface { // --- Boundary event operations --- // InsertBoundaryEvent adds a boundary event to the referenced activity. - InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error + InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, caption string, activities []workflows.WorkflowActivity) error // DropBoundaryEvent removes the boundary event from the referenced activity. DropBoundaryEvent(activityRef string, atPos int) error diff --git a/mdl/backend/wfmutator/member_kind_test.go b/mdl/backend/wfmutator/member_kind_test.go index b0a2e5d1d6..a4badef0fc 100644 --- a/mdl/backend/wfmutator/member_kind_test.go +++ b/mdl/backend/wfmutator/member_kind_test.go @@ -139,10 +139,10 @@ func TestInsertMember_OnWrongKindIsRefused(t *testing.T) { {"insert branch on a user task", func(m *Mutator) error { return m.InsertBranch("userTask1", 0, "true", nil) }, "userTask1"}, {"insert branch on a split", func(m *Mutator) error { return m.InsertBranch("split1", 0, "true", nil) }, "split1"}, {"insert boundary event on a split", func(m *Mutator) error { - return m.InsertBoundaryEvent("split1", 0, "InterruptingTimer", "", nil) + return m.InsertBoundaryEvent("split1", 0, "InterruptingTimer", "", "", nil) }, "split1"}, {"insert boundary event on a timer", func(m *Mutator) error { - return m.InsertBoundaryEvent("timer1", 0, "InterruptingTimer", "", nil) + return m.InsertBoundaryEvent("timer1", 0, "InterruptingTimer", "", "", nil) }, "timer1"}, } for _, tc := range cases { @@ -175,7 +175,7 @@ func TestInsertMember_OnRightKindLands(t *testing.T) { if err := m.InsertBranch("decision1", 0, "true", nil); err != nil { t.Errorf("insert branch on a decision: %v", err) } - if err := m.InsertBoundaryEvent("userTask1", 0, "InterruptingTimer", "", nil); err != nil { + if err := m.InsertBoundaryEvent("userTask1", 0, "InterruptingTimer", "", "", nil); err != nil { t.Errorf("insert boundary event on a user task: %v", err) } } diff --git a/mdl/backend/wfmutator/mutator.go b/mdl/backend/wfmutator/mutator.go index 742162c695..9b66269b48 100644 --- a/mdl/backend/wfmutator/mutator.go +++ b/mdl/backend/wfmutator/mutator.go @@ -540,7 +540,7 @@ func (m *Mutator) DropBranch(activityRef string, atPos int, branchName string) e // Boundary event operations // --------------------------------------------------------------------------- -func (m *Mutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error { +func (m *Mutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, caption string, activities []workflows.WorkflowActivity) error { actDoc, err := m.findMemberTarget(activityRef, atPos, "insert boundary event", backend.WorkflowBoundaryEvents) if err != nil { return err @@ -556,7 +556,7 @@ func (m *Mutator) InsertBoundaryEvent(activityRef string, atPos int, eventType s eventDoc := bson.D{ {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, {Key: "$Type", Value: typeName}, - {Key: "Caption", Value: ""}, + {Key: "Caption", Value: caption}, } if delay != "" { diff --git a/mdl/backend/wfmutator/mutator_test.go b/mdl/backend/wfmutator/mutator_test.go index d54eb5d8d5..93975a8ea8 100644 --- a/mdl/backend/wfmutator/mutator_test.go +++ b/mdl/backend/wfmutator/mutator_test.go @@ -1102,7 +1102,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_InterruptingTimer(t *testing.T) { act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) m := newMutator(makeWorkflowDoc(act)) - if err := m.InsertBoundaryEvent("Review", 0, "InterruptingTimer", "PT1H", nil); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, "InterruptingTimer", "PT1H", "", nil); err != nil { t.Fatalf("InsertBoundaryEvent failed: %v", err) } @@ -1120,12 +1120,29 @@ func TestWorkflowMutator_InsertBoundaryEvent_InterruptingTimer(t *testing.T) { } } +// ako/mxcli#1715: ALTER's insert writes a timer's caption; it was always "". +func TestWorkflowMutator_InsertBoundaryEvent_Caption(t *testing.T) { + for _, caption := range []string{"Escalate", ""} { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) + m := newMutator(makeWorkflowDoc(act)) + if err := m.InsertBoundaryEvent("Review", 0, "InterruptingTimer", "PT1H", caption, nil); err != nil { + t.Fatalf("InsertBoundaryEvent failed: %v", err) + } + actDoc, _ := m.findActivityByCaption("Review", 0) + eDoc := bsonnav.DGetArrayElements(bsonnav.DGet(actDoc, "BoundaryEvents"))[0].(bson.D) + if got := bsonnav.DGetString(eDoc, "Caption"); got != caption { + t.Errorf("Caption = %q, want %q", got, caption) + } + } +} + func TestWorkflowMutator_InsertBoundaryEvent_NonInterruptingTimer(t *testing.T) { act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) m := newMutator(makeWorkflowDoc(act)) - if err := m.InsertBoundaryEvent("Review", 0, "NonInterruptingTimer", "PT30M", nil); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, "NonInterruptingTimer", "PT30M", "", nil); err != nil { t.Fatalf("InsertBoundaryEvent NonInterrupting failed: %v", err) } @@ -1153,7 +1170,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_WithActivities(t *testing.T) { m := newMutator(makeWorkflowDoc(act)) subAct := makeTestWorkflowActivity("evt_act", "EventAct") - if err := m.InsertBoundaryEvent("Review", 0, "Timer", "", []workflows.WorkflowActivity{subAct}); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, "Timer", "", "", []workflows.WorkflowActivity{subAct}); err != nil { t.Fatalf("InsertBoundaryEvent with activities failed: %v", err) } @@ -1171,7 +1188,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_NoDelay(t *testing.T) { act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) m := newMutator(makeWorkflowDoc(act)) - if err := m.InsertBoundaryEvent("Review", 0, "Timer", "", nil); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, "Timer", "", "", nil); err != nil { t.Fatalf("InsertBoundaryEvent no delay failed: %v", err) } @@ -1518,7 +1535,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_EndsWithMarker(t *testing.T) { act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) m := newMutator(makeWorkflowDoc(act)) - if err := m.InsertBoundaryEvent("Review", 0, tc.eventType, "addHours([%CurrentDateTime%], 1)", tc.acts); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, tc.eventType, "addHours([%CurrentDateTime%], 1)", "", tc.acts); err != nil { t.Fatalf("%s: InsertBoundaryEvent: %v", name, err) } actDoc, _ := m.findActivityByCaption("Review", 0) diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index 8ee33b367a..592f76559b 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -220,7 +220,7 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { "restate the workflow with CREATE OR MODIFY WORKFLOW, which accepts `boundary event interrupting notification '' { … }`") } acts := ends.endInsertedBoundaryPath(buildAndBindActivities(ctx, o.Activities), o.EventType, o.ActivityRef, o.AtPosition) - if err := mutator.InsertBoundaryEvent(o.ActivityRef, o.AtPosition, o.EventType, normalizeWorkflowContextExpr(o.Delay), acts); err != nil { + if err := mutator.InsertBoundaryEvent(o.ActivityRef, o.AtPosition, o.EventType, normalizeWorkflowContextExpr(o.Delay), o.Caption, acts); err != nil { return mdlerrors.NewBackend("insert boundary event", err) } diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index 6ba9f35af9..1d3bfad4cb 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -259,10 +259,15 @@ func formatBoundaryEvents(ctx *ExecContext, events []*workflows.BoundaryEvent, i header += " " + mdlQuoted(event.Caption) } lines = append(lines, header) - } else if event.TimerDelay != "" { - lines = append(lines, fmt.Sprintf("%s%s %s", indent, keyword, workflowExpressionMDL(event.TimerDelay))) } else { - lines = append(lines, fmt.Sprintf("%s%s", indent, keyword)) + header := indent + keyword + if event.TimerDelay != "" { + header += " " + workflowExpressionMDL(event.TimerDelay) + } + if event.Caption != "" { + header += " caption " + mdlQuoted(event.Caption) + } + lines = append(lines, header) } if event.Flow != nil && len(event.Flow.Activities) > 0 { lines = append(lines, fmt.Sprintf("%s{", indent)) diff --git a/mdl/executor/cmd_workflows_describe_test.go b/mdl/executor/cmd_workflows_describe_test.go index 828e06fb01..d10e4cc28b 100644 --- a/mdl/executor/cmd_workflows_describe_test.go +++ b/mdl/executor/cmd_workflows_describe_test.go @@ -367,3 +367,30 @@ func TestDescribeWorkflow_NestedEndIsEmitted(t *testing.T) { t.Errorf("a nested End with the default caption must describe as a bare `end workflow;`, got:\n%s", out) } } + +// ako/mxcli#1715: describe prints a timer boundary event's caption, and the +// output re-parses with it, so describe -> create or modify keeps it. +func TestWorkflowDescribe_TimerBoundaryEventCaptionReparses(t *testing.T) { + task := &workflows.UserTask{Page: "M.P", Outcomes: []*workflows.UserTaskOutcome{{Value: "Ok"}}} + task.Name, task.Caption = "T", "Task" + task.BoundaryEvents = []*workflows.BoundaryEvent{ + {EventType: "InterruptingTimer", TimerDelay: "addDays([%CurrentDateTime%], 3)", Caption: "Escalate"}, + {EventType: "NonInterruptingTimer", Caption: "It's late"}, + {EventType: "NonInterruptingTimer", TimerDelay: "addDays([%CurrentDateTime%], 1)"}, // control: no caption + } + body := strings.Join(formatSingleActivity(task, " "), "\n") + src := "create workflow M.W parameter $C: M.E\nbegin\n" + body + "\nend workflow;" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("describe output does not parse: %v\n%s", errs, src) + } + got := prog.Statements[0].(*ast.CreateWorkflowStmt).Activities[0].(*ast.WorkflowUserTaskNode).BoundaryEvents + if len(got) != 3 { + t.Fatalf("boundary events after re-parse = %d, want 3\n%s", len(got), src) + } + for i, want := range []string{"Escalate", "It's late", ""} { + if got[i].Caption != want { + t.Errorf("event %d caption = %q, want %q\n%s", i, got[i].Caption, want, src) + } + } +} diff --git a/mdl/executor/validate_workflow_unstatable.go b/mdl/executor/validate_workflow_unstatable.go index 0c58187108..7481db6413 100644 --- a/mdl/executor/validate_workflow_unstatable.go +++ b/mdl/executor/validate_workflow_unstatable.go @@ -17,13 +17,13 @@ import ( // // a user task's task name 'Review {1}' (caption 'Review') reset to 'Review' // its parameter {1} = $WorkflowContext/Name dropped -// a timer boundary event's caption blanked +// a timer boundary event's caption blanked (stated since #1715) // a non-interrupting timer's recurrence dropped (a one-shot reminder) // // The rebuild takes a user task's name from its caption, writes a template's -// text without its parameters, and writes a timer boundary event with neither a -// caption nor a recurrence. Each is refused until MDL can state it — the issue -// named after each reason adds the syntax. +// text without its parameters, and writes a timer boundary event without a +// recurrence. Each is refused until MDL can state it — the issue named after +// each reason adds the syntax. // workflowTemplateLabel names a stored string template by its key, as MDL calls // the clause that states its text. @@ -98,7 +98,8 @@ func rawUserTaskTemplateLosses(d map[string]any, caption string) []string { } // rawBoundaryEventLosses lists what rebuilding a stored activity resets on its -// boundary events: a timer's caption and recurrence, and an annotation. +// boundary events: a timer's recurrence, and an annotation. A timer's caption +// is stated with `caption '…'` and printed by describe (ako/mxcli#1715). func rawBoundaryEventLosses(d map[string]any) []string { var out []string for _, be := range rawList(d["BoundaryEvents"]) { @@ -107,11 +108,6 @@ func rawBoundaryEventLosses(d map[string]any) []string { continue } kind := rawBoundaryEventKind(bm) - if strings.HasSuffix(kind, "timer") { - if c, _ := bm["Caption"].(string); c != "" { - out = append(out, fmt.Sprintf("its %s boundary event has caption '%s', which MDL cannot state for a timer yet (ako/mxcli#1715)", kind, c)) - } - } if r, ok := bm["Recurrence"].(map[string]any); ok { out = append(out, fmt.Sprintf("its %s boundary event repeats (every %v %v, at most %v times), which MDL cannot state yet (ako/mxcli#1716)", kind, r["Interval"], r["IntervalType"], r["MaxExecutions"])) diff --git a/mdl/executor/validate_workflow_unstatable_test.go b/mdl/executor/validate_workflow_unstatable_test.go index 0bf59c69dd..6eb239f061 100644 --- a/mdl/executor/validate_workflow_unstatable_test.go +++ b/mdl/executor/validate_workflow_unstatable_test.go @@ -72,8 +72,6 @@ func TestWorkflowRewrite_RefusesResettingUnstatableSettings(t *testing.T) { {"workflow display parameter", func(wf, _, _, _ map[string]any) { wf["WorkflowName"].(map[string]any)["Parameters"] = templateParam("$WorkflowContext/Title") }, "display 'W' with ({1} = $WorkflowContext/Title)"}, - {"timer caption", func(_, _, timer, _ map[string]any) { timer["Caption"] = "Escalate" }, - "interrupting timer boundary event has caption 'Escalate'"}, {"recurrence", func(_, _, _, reminder map[string]any) { reminder["Recurrence"] = map[string]any{"$Type": "Workflows$LinearRecurrence", "Interval": int32(1), "IntervalType": "Day", "MaxExecutions": int32(5)} }, "repeats (every 1 Day, at most 5 times)"}, @@ -140,9 +138,21 @@ func TestAlterWorkflow_ReplaceRefusesToResetUnstatableSettings(t *testing.T) { if got := validateAlterReplaceKeepsStudioProState(storedReplaceCtx(t, wf), replace()); len(got) != 0 { t.Fatalf("control: nothing to lose, got %v", got) } - timer["Caption"] = "Escalate" + timer["Annotation"] = map[string]any{"$Type": "Workflows$Annotation", "Description": "escalation"} got := validateAlterReplaceKeepsStudioProState(storedReplaceCtx(t, wf), replace()) - if len(got) != 1 || !strings.Contains(got[0], "caption 'Escalate'") { - t.Errorf("must be refused naming the timer's caption, got %v", got) + if len(got) != 1 || !strings.Contains(got[0], "annotation 'escalation'") { + t.Errorf("must be refused naming the timer's annotation, got %v", got) + } +} + +// ako/mxcli#1715: a timer's caption is stated now, so a stored one no longer +// refuses the rewrite — describe prints it, and the statement restates it. +func TestWorkflowRewrite_AllowsATimerCaption(t *testing.T) { + wf, _, timer, _ := unstatableWorkflow() + timer["Caption"] = "Escalate" + restated := strings.Replace(wfUnstatableRestated, "interrupting timer addDays([%CurrentDateTime%], 3) { }", + "interrupting timer addDays([%CurrentDateTime%], 3) caption 'Escalate' { }", 1) + if err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, wf), "wf1", "M.W", parseWorkflowStmt(t, restated)); err != nil { + t.Errorf("a restated timer caption must be allowed, got %v", err) } } diff --git a/mdl/executor/workflow_interrupting_end_test.go b/mdl/executor/workflow_interrupting_end_test.go index bbd8a2ece9..4e263d4201 100644 --- a/mdl/executor/workflow_interrupting_end_test.go +++ b/mdl/executor/workflow_interrupting_end_test.go @@ -114,7 +114,7 @@ func TestAlterInsertBoundaryEvent_InterruptingPathEndsLikeStudioPro(t *testing.T ResolveAlterTargetFunc: func(tg backend.AlterTarget) (backend.AlterTargetMatch, error) { return backend.AlterTargetMatch{Kind: "user task", Name: backend.WorkflowTargetText(tg)}, nil }, - InsertBoundaryEventFunc: func(ref string, _ int, eventType, _ string, acts []workflows.WorkflowActivity) error { + InsertBoundaryEventFunc: func(ref string, _ int, eventType, _, _ string, acts []workflows.WorkflowActivity) error { got[ref+" "+eventType] = acts return nil }, diff --git a/mdl/grammar/domains/MDLWorkflow.g4 b/mdl/grammar/domains/MDLWorkflow.g4 index d91bd647b8..1e3a095cc4 100644 --- a/mdl/grammar/domains/MDLWorkflow.g4 +++ b/mdl/grammar/domains/MDLWorkflow.g4 @@ -182,10 +182,11 @@ workflowExpression * start with the `non` of `non interrupting timer`, a word the expression * grammar admits as a name: `interrupting timer non interrupting timer 'x'` is * two events, the first without a delay, as it was when only a string could be - * the delay (ako/mxcli#753). + * the delay (ako/mxcli#753). Nor may it start with the `caption` (or its alias + * `comment`) of a timer with no delay: `interrupting timer caption 'Escalate'`. */ workflowTimerDelay - : {p.GetTokenStream().LA(1) != MDLParserNON}? workflowExpression + : {p.GetTokenStream().LA(1) != MDLParserNON && p.GetTokenStream().LA(1) != MDLParserCAPTION && p.GetTokenStream().LA(1) != MDLParserCOMMENT}? workflowExpression ; /** @@ -264,9 +265,9 @@ workflowFallbackClause * with two boundary events did not parse. */ workflowBoundaryEventClause - : INTERRUPTING TIMER workflowTimerDelay? (LBRACE workflowBody RBRACE)? - | NON INTERRUPTING TIMER workflowTimerDelay? (LBRACE workflowBody RBRACE)? - | TIMER workflowTimerDelay? (LBRACE workflowBody RBRACE)? + : INTERRUPTING TIMER workflowTimerDelay? workflowCaption? (LBRACE workflowBody RBRACE)? + | NON INTERRUPTING TIMER workflowTimerDelay? workflowCaption? (LBRACE workflowBody RBRACE)? + | TIMER workflowTimerDelay? workflowCaption? (LBRACE workflowBody RBRACE)? // A notification boundary event is triggered by `notify workflow … target // `, so its name is what matters; the string is its caption. | INTERRUPTING NOTIFICATION workflowActivityName? STRING_LITERAL? (LBRACE workflowBody RBRACE)? diff --git a/mdl/visitor/visitor_alter_workflow.go b/mdl/visitor/visitor_alter_workflow.go index cd5b80172b..f60fe30197 100644 --- a/mdl/visitor/visitor_alter_workflow.go +++ b/mdl/visitor/visitor_alter_workflow.go @@ -165,7 +165,7 @@ func buildAlterWorkflowMember(ctx *parser.AlterWorkflowMemberContext, ref string be := buildBoundaryEventNode(ctx.WorkflowBoundaryEventClause()) return []ast.AlterWorkflowOp{&ast.InsertBoundaryEventOp{ ActivityRef: ref, AtPosition: pos, - EventType: be.EventType, Delay: be.Delay, Activities: be.Activities, + EventType: be.EventType, Delay: be.Delay, Caption: be.Caption, Activities: be.Activities, }} } return nil diff --git a/mdl/visitor/visitor_workflow.go b/mdl/visitor/visitor_workflow.go index 3877f76961..3821bb895a 100644 --- a/mdl/visitor/visitor_workflow.go +++ b/mdl/visitor/visitor_workflow.go @@ -977,6 +977,8 @@ func buildBoundaryEventNode(beCtx parser.IWorkflowBoundaryEventClauseContext) as if d := beCtx2.WorkflowTimerDelay(); d != nil { be.Delay = workflowExpressionText(d.WorkflowExpression()) } + // A timer's caption is the clause every activity takes (ako/mxcli#1715). + be.Caption = workflowCaptionText(beCtx2.WorkflowCaption()) } if notification { be.Name = workflowActivityNameText(beCtx2.WorkflowActivityName()) diff --git a/mdl/visitor/visitor_workflow_timer_caption_test.go b/mdl/visitor/visitor_workflow_timer_caption_test.go new file mode 100644 index 0000000000..7420e6ad6b --- /dev/null +++ b/mdl/visitor/visitor_workflow_timer_caption_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#1715: a timer boundary event takes the `caption '…'` clause every +// activity takes, after its delay. Before, `caption` there was a parse error. +func TestWorkflowVisitor_TimerBoundaryEventCaption(t *testing.T) { + for _, tc := range []struct { + name, events string + want []ast.WorkflowBoundaryEventNode + }{ + {"after the delay", + "boundary event interrupting timer addDays([%CurrentDateTime%], 3) caption 'Escalate'", + []ast.WorkflowBoundaryEventNode{{EventType: "InterruptingTimer", Delay: "addDays([%CurrentDateTime%], 3)", Caption: "Escalate"}}}, + {"non-interrupting, then a body", + "boundary event non interrupting timer addDays([%CurrentDateTime%], 1) caption 'Remind' { }", + []ast.WorkflowBoundaryEventNode{{EventType: "NonInterruptingTimer", Delay: "addDays([%CurrentDateTime%], 1)", Caption: "Remind"}}}, + {"with no delay — `caption` is not taken for the delay", + "boundary event interrupting timer caption 'Escalate'", + []ast.WorkflowBoundaryEventNode{{EventType: "InterruptingTimer", Caption: "Escalate"}}}, + {"two events, the first captioned", + "boundary event interrupting timer addDays([%CurrentDateTime%], 3) caption 'Escalate' non interrupting timer addDays([%CurrentDateTime%], 1)", + []ast.WorkflowBoundaryEventNode{ + {EventType: "InterruptingTimer", Delay: "addDays([%CurrentDateTime%], 3)", Caption: "Escalate"}, + {EventType: "NonInterruptingTimer", Delay: "addDays([%CurrentDateTime%], 1)"}, + }}, + // CONTROL: no caption stays empty. + {"no caption", "boundary event interrupting timer addDays([%CurrentDateTime%], 3)", + []ast.WorkflowBoundaryEventNode{{EventType: "InterruptingTimer", Delay: "addDays([%CurrentDateTime%], 3)"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + src := wfHead + "begin\n user task R 'R'\n outcomes 'Done' { }\n " + tc.events + ";\nend workflow;" + prog := mustBuild(t, src) + got := prog.Statements[0].(*ast.CreateWorkflowStmt).Activities[0].(*ast.WorkflowUserTaskNode).BoundaryEvents + if len(got) != len(tc.want) { + t.Fatalf("built %d events, want %d: %+v", len(got), len(tc.want), got) + } + for i, w := range tc.want { + g := got[i] + if g.EventType != w.EventType || g.Caption != w.Caption || strings.ReplaceAll(g.Delay, " ", "") != strings.ReplaceAll(w.Delay, " ", "") { + t.Errorf("event %d = {%s %q %q}, want {%s %q %q}", i, g.EventType, g.Delay, g.Caption, w.EventType, w.Delay, w.Caption) + } + } + }) + } +} + +// ALTER WORKFLOW's insert carries the caption to the mutator. +func TestAlterWorkflow_InsertTimerBoundaryEventCaption(t *testing.T) { + prog := mustBuild(t, `alter workflow M.WF { insert into Review { boundary event interrupting timer addDays([%CurrentDateTime%], 3) caption 'Escalate' { } } };`) + op, ok := prog.Statements[0].(*ast.AlterWorkflowStmt).Operations[0].(*ast.InsertBoundaryEventOp) + if !ok { + t.Fatalf("got %T, want *ast.InsertBoundaryEventOp", prog.Statements[0].(*ast.AlterWorkflowStmt).Operations[0]) + } + if op.Caption != "Escalate" { + t.Errorf("Caption = %q, want Escalate", op.Caption) + } +} From eef0be0e9d1be9e460bb7c1ca9fbd4ee5577344e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:05:36 +0000 Subject: [PATCH 30/40] style: gofmt the flow-call returns test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- mdl/executor/flow_returns_977_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mdl/executor/flow_returns_977_test.go b/mdl/executor/flow_returns_977_test.go index b4b1f04db9..f308cbeb9c 100644 --- a/mdl/executor/flow_returns_977_test.go +++ b/mdl/executor/flow_returns_977_test.go @@ -99,7 +99,7 @@ func openPedAppFixtureAt(t *testing.T, version string) (*Executor, *bytes.Buffer // storedOutputMapping is one Forms$OutputMapping, flattened to the four slots. type storedOutputMapping struct { Button, AttributeRef, SourceAttributeRef, SourceVariable, Expression string - Keys string + Keys string } func attributeOf(v any) string { From d7484225a6b2a5a47af2be1c8de85bb2be547f06 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:05:45 +0000 Subject: [PATCH 31/40] fix: describe fragment prints arguments and the whole action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describe fragment printed `call microflow M.F` for `call microflow M.F(Order = $currentObject)`: flow and page arguments were dropped, as were a microflow/nanoflow data source's arguments. The same printer also wrote `create`, `openLink` and `param` for create object … then …, open link and a fragment action parameter, left out the fragment's own ($p: datasource, $q: action) header, and did not double a quote in a string property, so such output did not even parse. It now prints each as written. The test parses describe's output again and compares it with the fragment that was defined; before the fix it failed on every one of these. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- ...-26-describe-fragment-drops-arguments.json | 1 + mdl/executor/cmd_fragments.go | 48 ++++++++-- .../cmd_fragments_describe_args_test.go | 96 +++++++++++++++++++ 3 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-fragment-drops-arguments.json create mode 100644 mdl/executor/cmd_fragments_describe_args_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-fragment-drops-arguments.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-fragment-drops-arguments.json new file mode 100644 index 0000000000..f8218d58d9 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-fragment-drops-arguments.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "describe fragment printed `call microflow M.F` for `call microflow M.F(Order = $currentObject)` — flow and page arguments dropped, as were a microflow/nanoflow data source's arguments, `create object E then show page P` (printed `create`), `open link …` (printed `openLink`), a fragment action parameter (printed `param`), the fragment's own `($p: datasource, $q: action)` header, and a doubled quote in a string property; the output was not the fragment `use fragment` expands, and with a quote in it did not parse", "cause": "formatActionV3 / formatDataSourceV3 / formatASTPropertyValue in mdl/executor/cmd_fragments.go were written for the bare action kinds and fell through to the AST type name for the rest; describeFragment never printed Params", "file": "mdl/executor/cmd_fragments.go (describeFragment header, formatFlowArgsV3, formatActionV3, formatDataSourceV3, string quoting via quoteExpressionLiteral); test mdl/executor/cmd_fragments_describe_args_test.go", "insight": "For an AST printer the test that finds every gap at once is 'parse the output and compare the AST with what was parsed in', not a list of expected substrings: one round-trip test over a fragment using each action kind exposed seven drops in one run, where the original report named only the arguments. A `default: return a.Type` in a printer is a silent drop waiting for the next kind", "refs": ["ako/mxcli#1731"]} diff --git a/mdl/executor/cmd_fragments.go b/mdl/executor/cmd_fragments.go index cc4f97d482..0e74adc62c 100644 --- a/mdl/executor/cmd_fragments.go +++ b/mdl/executor/cmd_fragments.go @@ -10,6 +10,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/mendixlabs/mxcli/sdk/pages" ) @@ -59,7 +60,15 @@ func describeFragment(ctx *ExecContext, name ast.QualifiedName) error { return mdlerrors.NewNotFound("fragment", name.Name) } - fmt.Fprintf(ctx.Output, "create fragment %s as {\n", frag.Name) + params := "" + if len(frag.Params) > 0 { + parts := make([]string, 0, len(frag.Params)) + for _, p := range frag.Params { + parts = append(parts, "$"+p.Name+": "+p.Kind) + } + params = " (" + strings.Join(parts, ", ") + ")" + } + fmt.Fprintf(ctx.Output, "create fragment %s%s as {\n", frag.Name, params) for _, w := range frag.Widgets { outputASTWidgetMDL(ctx.Output, w, 1) } @@ -227,7 +236,7 @@ func formatASTWidgetProps(w *ast.WidgetV3) string { func formatASTPropertyValue(v interface{}) string { switch val := v.(type) { case string: - return fmt.Sprintf("'%s'", val) + return quoteExpressionLiteral(val) case int: return fmt.Sprintf("%d", val) case int64: @@ -267,9 +276,9 @@ func formatDataSourceV3(ds *ast.DataSourceV3) string { case "database": return "database " + databaseSourceFrom(ds) case "microflow": - return "microflow " + ds.Reference + return "microflow " + ds.Reference + formatFlowArgsV3(ds.Args) case "nanoflow": - return "nanoflow " + ds.Reference + return "nanoflow " + ds.Reference + formatFlowArgsV3(ds.Args) case "association": return "association " + ds.Reference case "selection": @@ -279,6 +288,19 @@ func formatDataSourceV3(ds *ast.DataSourceV3) string { } } +// formatFlowArgsV3 renders a flow or page call's arguments as written, +// `(Name = value, …)`, or "" when there are none. +func formatFlowArgsV3(args []ast.FlowArgV3) string { + if len(args) == 0 { + return "" + } + parts := make([]string, 0, len(args)) + for _, arg := range args { + parts = append(parts, visitor.ParameterNameSpelling(arg.Name)+" = "+fmt.Sprint(arg.Value)) + } + return "(" + strings.Join(parts, ", ") + ")" +} + func formatActionV3(a *ast.ActionV3) string { switch a.Type { case "none": @@ -300,12 +322,24 @@ func formatActionV3(a *ast.ActionV3) string { return "delete close page" } return "delete" + case "param": + return "$" + a.Target + case "create": + if a.ThenAction != nil { + return "create object " + a.Target + " then " + formatActionV3(a.ThenAction) + } + return "create object " + a.Target case "showPage": - return "show page " + a.Target + return "show page " + a.Target + formatFlowArgsV3(a.Args) case "microflow": - return "call microflow " + a.Target + return "call microflow " + a.Target + formatFlowArgsV3(a.Args) case "nanoflow": - return "call nanoflow " + a.Target + return "call nanoflow " + a.Target + formatFlowArgsV3(a.Args) + case "openLink": + if a.LinkVariable != "" { + return "open link " + a.LinkVariable + "/" + a.LinkAttribute + } + return "open link " + quoteExpressionLiteral(a.LinkURL) case "signOut": return "sign out" case "completeTask": diff --git a/mdl/executor/cmd_fragments_describe_args_test.go b/mdl/executor/cmd_fragments_describe_args_test.go new file mode 100644 index 0000000000..a0e4840067 --- /dev/null +++ b/mdl/executor/cmd_fragments_describe_args_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// describe fragment printed `call microflow M.F` for `call microflow M.F(Order +// = $currentObject)` — every flow and page argument dropped, as were a flow +// data source's arguments, what a create object opens, an open link's address, +// a fragment action parameter and the fragment's own parameters. The fragment +// it described was not the one `use fragment` expands. Its output must parse +// back to the same fragment. +const fragmentWithArgs = `create fragment OrderTools ($data: datasource, $onSave: action) as { + dataview dvOrder (DataSource: microflow Shop.DS_Order(Customer = $Customer, Limit = 10)) { + actionbutton btnDelete (Caption: 'Delete it''s order', Action: call microflow Shop.ACT_Order_Delete(Order = $currentObject) with (ProgressBar: Blocking)) + actionbutton btnCheck (Caption: 'Check', Action: call nanoflow Shop.NF_Order_Check(Order = $currentObject, Strict = true) returns ($result = $ActionReturnValue)) + actionbutton btnOpen (Caption: 'Open', Action: show page Shop.Order_Edit(Order = $currentObject)) + actionbutton btnNew (Caption: 'New', Action: create object Shop.Line then show page Shop.Line_Edit(Line = $currentObject)) + actionbutton btnWeb (Caption: 'Web', Action: open link 'https://example.com/?q=a''b') + actionbutton btnUrl (Caption: 'Url', Action: open link $currentObject/Website) + actionbutton btnSave (Caption: 'Save', Action: $onSave) + } + listview lvLines (DataSource: nanoflow Shop.NF_Lines(Order = $Order)) { + dynamictext txt (Content: 'x') + } +};` + +func fragmentDescribedAndReparsed(t *testing.T, src string) (*ast.DefineFragmentStmt, *ast.DefineFragmentStmt, string) { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse the fragment: %v", errs) + } + frag := prog.Statements[0].(*ast.DefineFragmentStmt) + ctx, buf := newMockCtx(t) + assertNoError(t, execDefineFragment(ctx, frag)) + buf.Reset() + assertNoError(t, describeFragment(ctx, ast.QualifiedName{Name: frag.Name})) + described := buf.String() + again, errs := visitor.Build(described) + if len(errs) > 0 { + t.Fatalf("describe fragment printed MDL that does not parse: %v\n%s", errs, described) + } + return frag, again.Statements[0].(*ast.DefineFragmentStmt), described +} + +func TestDescribeFragmentPrintsArguments(t *testing.T) { + frag, again, described := fragmentDescribedAndReparsed(t, fragmentWithArgs) + for _, want := range []string{ + "microflow Shop.DS_Order(Customer = $Customer, Limit = 10)", + "call microflow Shop.ACT_Order_Delete(Order = $currentObject) with (ProgressBar: Blocking)", + "call nanoflow Shop.NF_Order_Check(Order = $currentObject, Strict = true) returns ($result = $ActionReturnValue)", + "show page Shop.Order_Edit(Order = $currentObject)", + "nanoflow Shop.NF_Lines(Order = $Order)", + } { + if !strings.Contains(described, want) { + t.Errorf("describe fragment does not print %q:\n%s", want, described) + } + } + if !reflect.DeepEqual(again.Params, frag.Params) { + t.Errorf("parameters: described %+v, defined %+v", again.Params, frag.Params) + } + var walk func(a, b []*ast.WidgetV3) + walk = func(a, b []*ast.WidgetV3) { + if len(a) != len(b) { + t.Fatalf("described %d widgets, defined %d:\n%s", len(b), len(a), described) + } + for i := range a { + if !reflect.DeepEqual(a[i].Properties, b[i].Properties) { + t.Errorf("%s: described\n %#v\ndefined\n %#v", a[i].Name, b[i].Properties, a[i].Properties) + } + walk(a[i].Children, b[i].Children) + } + } + walk(frag.Widgets, again.Widgets) +} + +// Control: a fragment with nothing to drop described the same before. +func TestDescribeFragmentPlainActionsUnchanged(t *testing.T) { + _, _, described := fragmentDescribedAndReparsed(t, `create fragment Plain as { + actionbutton btnSave (Caption: 'Save', Action: save changes close page) + actionbutton btnGo (Caption: 'Go', Action: call microflow Shop.ACT_Go) +};`) + for _, want := range []string{"create fragment Plain as {", "Action: save changes close page", "Action: call microflow Shop.ACT_Go,"} { + if !strings.Contains(described, want) { + t.Errorf("describe fragment does not print %q:\n%s", want, described) + } + } +} From 22da6427d2e4449b74291f1ee7e4420a2b9d9289 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:46:09 +0000 Subject: [PATCH 32/40] feat(workflows): fill workflow and task texts from expressions, and name a task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `display 'Approval for {1}' with ({1} = $WorkflowContext/Title)` was a parse error, and a user task's task name (what the task inbox shows) was always its caption, so neither could be written, and a describe -> create or modify reset a Studio Pro task name and its parameters. The workflow's display and description, and a user task's new `task name '…'` clause and its description, take `with ({1} = …)` like a log message's template, in CREATE and in ALTER (`set (Display: …)`, `set (TaskName: …) on `). The writer stores each parameter as a Microflows$TemplateParameter, the reader returns them, and describe prints them (a task name only when it is not the caption). `name` is read as an identifier after TASK, not made a keyword. Measured with mx check on 11.15.0 (it parses these expressions): display and description see $WorkflowContext only, a task's name and description also $WorkflowInstance; $WorkflowUserTask and $currentUser are CE0117. MDL-WF19 now checks these sites. A placeholder no parameter fills is CE0720 and {0} CE0716: the new MDL-WF21 refuses both, and a parameter numbering other than {1}..{n}. MCP refuses parameters rather than writing the text without them. The #1713 rewrite refusal drops its template and task-name rows. End to end on 11.15.0: a workflow using every template builds with 0 errors, describe prints them, replaying describe reports "Unchanged workflow"; the ALTER forms build clean too. Fixes #1714 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xfGeNtrQ5BJrH2Ew64rt5 --- ...-26-workflow-text-template-parameters.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 2 +- cmd/mxcli/syntax/features_workflow.go | 18 +- .../src/reference/workflow/create-workflow.md | 7 +- docs/01-project/MDL_QUICK_REFERENCE.md | 3 +- .../1714-workflow-text-templates.mdl | 63 +++++++ mdl/ast/ast_workflow.go | 27 ++- mdl/backend/mcp/workflow.go | 30 ++- mdl/backend/mcp/workflow_test.go | 27 +++ mdl/backend/mock/mock_workflow_mutator.go | 8 + mdl/backend/modelsdk/workflow_read.go | 66 +++++-- .../modelsdk/workflow_templates_test.go | 73 ++++++++ mdl/backend/modelsdk/workflow_write.go | 20 +- mdl/backend/mutation.go | 5 + mdl/backend/wfmutator/mutator.go | 64 +++++++ mdl/backend/wfmutator/template_params_test.go | 81 ++++++++ mdl/executor/cmd_alter_workflow.go | 26 +++ mdl/executor/cmd_workflows.go | 32 +++- mdl/executor/cmd_workflows_describe_test.go | 31 ++++ mdl/executor/cmd_workflows_write.go | 40 ++++ mdl/executor/validate_workflow.go | 2 + mdl/executor/validate_workflow_rewrite.go | 4 +- mdl/executor/validate_workflow_scope.go | 10 +- mdl/executor/validate_workflow_templates.go | 173 ++++++++++++++++++ .../validate_workflow_templates_test.go | 136 ++++++++++++++ mdl/executor/validate_workflow_unstatable.go | 123 ++----------- .../validate_workflow_unstatable_test.go | 29 +-- mdl/grammar/domains/MDLWorkflow.g4 | 13 +- mdl/visitor/visitor_alter_workflow.go | 32 +++- mdl/visitor/visitor_workflow.go | 9 + mdl/visitor/visitor_workflow_clauses.go | 2 + mdl/visitor/visitor_workflow_expressions.go | 35 ++++ .../visitor_workflow_templates_test.go | 114 ++++++++++++ mdl/visitor/visitor_xpath_brackets.go | 10 + sdk/workflows/workflow.go | 14 +- 35 files changed, 1140 insertions(+), 190 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-workflow-text-template-parameters.json create mode 100644 mdl-examples/bug-tests/1714-workflow-text-templates.mdl create mode 100644 mdl/backend/modelsdk/workflow_templates_test.go create mode 100644 mdl/backend/wfmutator/template_params_test.go create mode 100644 mdl/executor/validate_workflow_templates.go create mode 100644 mdl/executor/validate_workflow_templates_test.go create mode 100644 mdl/visitor/visitor_workflow_templates_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-workflow-text-template-parameters.json b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-workflow-text-template-parameters.json new file mode 100644 index 0000000000..f1c6286d48 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-workflow-text-template-parameters.json @@ -0,0 +1 @@ +{"area": "mdl/grammar", "date": "2026-10-26", "symptom": "`create workflow … display 'Approval for {1} by {2}' with ({1} = $WorkflowContext/Title, …)` failed with \"extraneous input 'WITH' expecting one of {ON, BEGIN, …}\" and a misleading hint about `/`; a user task's task name was always its caption, so the inbox text Studio Pro shows could not be set, and a describe -> create or modify reset a Studio Pro task name and its parameters (the #1713 refusal)", "cause": "The header's DISPLAY/DESCRIPTION and the user task's DESCRIPTION took a bare STRING_LITERAL; there was no task-name clause; the writer wrote every Microflows$StringTemplate with an empty Parameters list and the reader read only Text", "file": "`mdl/grammar/domains/MDLWorkflow.g4` (header, workflowUserTaskClause, alterWorkflowValue), `mdl/visitor/visitor_workflow*.go` (buildWorkflowTemplateParams, TASK clause), `mdl/ast/ast_workflow.go` (WorkflowTemplateParam), `sdk/workflows/workflow.go` (…Params), `mdl/backend/modelsdk/workflow_write.go` + `workflow_read.go`, `mdl/executor/cmd_workflows.go` (describe), `mdl/executor/validate_workflow_templates.go` (MDL-WF21), `validate_workflow_scope.go` (MDL-WF19 sites), `mdl/backend/mutation.go` + `wfmutator` + `mcp` + `mock` (SetTemplateParameters)", "insight": "mx check is the oracle that made this cheap without Studio Pro: it PARSES a template parameter's expression, so patching a stored unit and running it settled both the shape (Microflows$TemplateParameter{Expression} under StringTemplate.Parameters — gen calls it TemplateArgument, whose storage name it is; the REST writer's stringTemplateElem already wrote it) and the scope table (display/description: $WorkflowContext only; task name/description: plus $WorkflowInstance; $WorkflowUserTask and $currentUser CE0117), plus CE0720 for an unfilled placeholder and CE0716 for {0}. Contrast mx check accepting a bogus recurrence IntervalType (#1716): an oracle proves only what it validates — run a bogus control first. `name` is an attribute name everywhere, so `task name` is TASK + IDENTIFIER checked in the visitor rather than a NAME keyword. MCP refuses parameters instead of writing the text without them, since PED's list shape for them is unmeasured", "fix": "`with ({1} = , …)` after display/description/task name/description in CREATE and in ALTER `set (Display|Description|TaskName: '…' with (…))`; writer emits TemplateParameter children, reader returns them, describe prints them (task name only when it is not the caption); MDL-WF21 refuses unfilled/zero placeholders and gapped numbering, MDL-WF19 checks the parameters' scope; the #1713 rewrite refusal drops its template rows", "refs": ["ako/mxcli#1714", "ako/mxcli#1713"], "ce": ["CE0720", "CE0716", "CE0117"]} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index 702239b0c6..4751acedbd 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -57,7 +57,7 @@ named neither the clause nor the rule. See `ako/mxcli#586`.) mdl 1; create workflow Module.ApprovalFlow parameter $Context: Module.Request -- REQUIRED: must be a $-variable + context entity - display 'Request Approval' -- optional human-readable name + display 'Approval for {1}' with ({1} = $Context/Title) -- optional; `with` fills {n} description 'Approves incoming requests' -- optional export level Hidden -- optional: Hidden | API (default Hidden) overview page Module.WF_Overview -- optional; takes a System.Workflow param diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 70ac8ed629..5f5db76944 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -35,8 +35,8 @@ func init() { Syntax: "CREATE [OR MODIFY] WORKFLOW Module.Name\n" + " [FOLDER 'path']\n" + " PARAMETER $Context: Module.Entity\n" + - " [DISPLAY '']\n" + - " [DESCRIPTION '']\n" + + " [DISPLAY '' [WITH ({1} = , ...)]]\n" + + " [DESCRIPTION '' [WITH ({1} = , ...)]]\n" + " [EXPORT LEVEL Hidden | API]\n" + " [OVERVIEW PAGE Module.OverviewPage]\n" + " [DUE DATE ]\n" + @@ -49,10 +49,13 @@ func init() { "-- rule.) The event handlers are the exception and may repeat.\n" + "-- A clause written twice is reported by name, e.g.\n" + "-- duplicate DISPLAY clause on workflow M.W (already given on line 3)\n\n" + + "-- DISPLAY and DESCRIPTION are templates: WITH fills their {1}, {2}, ...\n" + + "-- from expressions over $WorkflowContext only ($WorkflowInstance is CE0117\n" + + "-- here, MDL-WF19). A placeholder no parameter fills is CE0720 (MDL-WF21).\n\n" + "-- The OVERVIEW PAGE must accept a System.Workflow parameter, or the\n" + "-- build fails CE7410 \"The selected page should accept a parameter of\n" + "-- type 'Workflow'\" (measured on mxbuild 11.6.6).", - Example: "CREATE WORKFLOW Module.ApprovalFlow\n PARAMETER $Context: Module.Request\n OVERVIEW PAGE Module.WF_Overview\nBEGIN\n USER TASK ReviewTask 'Review the request'\n PAGE Module.ReviewPage\n OUTCOMES 'Approve' { } 'Reject' { };\nEND WORKFLOW;", + Example: "CREATE WORKFLOW Module.ApprovalFlow\n PARAMETER $Context: Module.Request\n DISPLAY 'Approval for {1}' WITH ({1} = $Context/Title)\n OVERVIEW PAGE Module.WF_Overview\nBEGIN\n USER TASK ReviewTask 'Review the request'\n PAGE Module.ReviewPage\n OUTCOMES 'Approve' { } 'Reject' { };\nEND WORKFLOW;", SeeAlso: []string{"workflow.user-task", "workflow.event-handlers", "workflow.decision", "workflow.drop"}, }) @@ -129,12 +132,17 @@ func init() { " [ON CREATED MICROFLOW Module.MF] -- (System.WorkflowUserTask, ), returns nothing\n" + " [ENTITY Module.Entity]\n" + " [DUE DATE ]\n" + - " [DESCRIPTION '']\n" + + " [TASK NAME '' [WITH ({1} = , ...)]] -- what the task inbox shows; default: the caption\n" + + " [DESCRIPTION '' [WITH ({1} = , ...)]]\n" + " OUTCOMES '' { } '' { };\n\n" + "-- The clauses are a SET: write them in ANY order, each at most once\n" + "-- (ako/mxcli#586). The two TARGETING spellings are ONE clause — a task\n" + "-- stores one user source — so writing both is refused rather than\n" + "-- letting the second silently win. OUTCOMES and BOUNDARY EVENT are\n" + "-- list-valued and may repeat.\n\n" + + "-- TASK NAME and DESCRIPTION are templates: WITH fills {1}, {2}, ... from\n" + + "-- expressions over $WorkflowContext and $WorkflowInstance ($WorkflowUserTask\n" + + "-- is CE0117, MDL-WF19); a placeholder no parameter fills is CE0720\n" + + "-- (MDL-WF21). ALTER sets them with SET (TaskName: '…' WITH (…)) ON .\n\n" + "-- AUTO ASSIGN SINGLE USER is Studio Pro's \"Auto-assign when targeting\n" + "-- results in one user\". Omitted, it is off, as Studio Pro creates a task;\n" + "-- CREATE OR MODIFY refuses to turn a stored one off unless restated.\n\n" + @@ -145,7 +153,7 @@ func init() { "-- Other parameters may sit alongside it.", Example: "-- The task page takes the task:\n" + "CREATE PAGE HR.ReviewPage (\n title: 'Review',\n layout: Atlas_Core.Atlas_Default,\n params: ( $WorkflowUserTask: System.WorkflowUserTask )\n) { };\n\n" + - "USER TASK ReviewTask 'Review the request'\n PAGE HR.ReviewPage\n TARGETING USERS XPATH [Active = true()]\n OUTCOMES 'Approve' { } 'Reject' { };", + "USER TASK ReviewTask 'Review the request'\n PAGE HR.ReviewPage\n TARGETING USERS XPATH [Active = true()]\n TASK NAME 'Review {1}' WITH ({1} = $WorkflowContext/Title)\n OUTCOMES 'Approve' { } 'Reject' { };", SeeAlso: []string{"workflow.user-task.targeting", "workflow.multi-user-task", "workflow.create"}, }) diff --git a/docs-site/src/reference/workflow/create-workflow.md b/docs-site/src/reference/workflow/create-workflow.md index eda09e0f8c..9efed7402b 100644 --- a/docs-site/src/reference/workflow/create-workflow.md +++ b/docs-site/src/reference/workflow/create-workflow.md @@ -6,8 +6,8 @@ CREATE [ OR MODIFY ] WORKFLOW module.Name PARAMETER $Variable: module.Entity [ OVERVIEW PAGE module.PageName ] - [ DISPLAY 'caption' ] - [ DESCRIPTION 'text' ] + [ DISPLAY 'caption' [ WITH ( {1} = expression, ... ) ] ] + [ DESCRIPTION 'text' [ WITH ( {1} = expression, ... ) ] ] BEGIN activities END WORKFLOW @@ -138,6 +138,9 @@ USER TASK name 'caption' `DESCRIPTION 'text'` : Optional description of the workflow's purpose. +`WITH ( {1} = expression, ... )` +: Fills the `{1}`, `{2}`, … placeholders of `DISPLAY` or `DESCRIPTION` from expressions over `$WorkflowContext`, as a log message's template does: `DISPLAY 'Approval for {1}' WITH ({1} = $WorkflowContext/Title)`. A user task's `TASK NAME '…'` (what the task inbox shows; the caption when omitted) and `DESCRIPTION '…'` take the same clause, and may also use `$WorkflowInstance`. A placeholder no parameter fills is CE0720, which `mxcli check` reports as MDL-WF21. + ## Examples Simple approval workflow: diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index cc2f02ac5c..c431f61c0a 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -750,8 +750,9 @@ mandatory and a misplaced clause failed with a token error (`mismatched input 'ON' expecting ';'`) that named neither the clause nor the rule. **Workflow Activity Types:** -- `[multi] user task '' [page Mod.Page] [targeting [users|groups] microflow Mod.MF] [targeting [users|groups] xpath []] [auto assign single user] [on created microflow Mod.MF] [entity Mod.Entity] [due date ] [description ''] [participants all|| percent] [decide by ] [await all users] [outcomes '' { } ...] [boundary event …];` +- `[multi] user task '' [page Mod.Page] [targeting [users|groups] microflow Mod.MF] [targeting [users|groups] xpath []] [auto assign single user] [on created microflow Mod.MF] [entity Mod.Entity] [due date ] [task name '' [with ({1} = , …)]] [description '' [with ({1} = , …)]] [participants all|| percent] [decide by ] [await all users] [outcomes '' { } ...] [boundary event …];` - **Multi-user only:** `decide by consensus|majority more than half|majority most chosen|threshold percent|votes fallback ''`, `decide by veto ''`, `decide by microflow Mod.MF`. A fallback is required for consensus, majority and threshold (CE1866), a veto needs its outcome (CE1867), and a decision microflow returns String (CE5012) — all `MDL-WF13` / check. Omitted: all participants, consensus on the first outcome, not waiting. + - `task name` is what the task inbox shows; omitted, it is the caption. It and `description` are templates — `with ({1} = …)` fills their placeholders from expressions over `$WorkflowContext` and `$WorkflowInstance`, as the workflow header's `display` and `description` do over `$WorkflowContext` alone (MDL-WF19). A placeholder no parameter fills is CE0720 (MDL-WF21). `alter workflow … { set (TaskName: '…' with (…)) on ; }`. - `auto assign single user` is Studio Pro's "Auto-assign when targeting results in one user" (either task kind). Omitted it is off, as Studio Pro creates a task; `create or modify` refuses to drop a stored one that is not restated. - The **task page** must take a `System.WorkflowUserTask` parameter — none at all is CE7410, none of that type is CE7412; extra parameters are allowed. - A **targeting microflow** takes exactly `System.Workflow` + the context entity (or a generalization of it), in either order — anything else is CE6677. Users targeting returns a list of `System.User`, groups a list of `System.WorkflowGroup`. diff --git a/mdl-examples/bug-tests/1714-workflow-text-templates.mdl b/mdl-examples/bug-tests/1714-workflow-text-templates.mdl new file mode 100644 index 0000000000..56ae8a9d60 --- /dev/null +++ b/mdl-examples/bug-tests/1714-workflow-text-templates.mdl @@ -0,0 +1,63 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1714 — "Workflow and user-task names and descriptions cannot take +-- parameters: `display 'Review {1}' with ({1} = $WorkflowContext/Title)` is a +-- parse error" +-- ============================================================================ +-- +-- The workflow's display and description, and a user task's task name (what +-- the task inbox shows; the caption when omitted) and description, are +-- templates. `with ({1} = , …)` fills their placeholders, as a log +-- message's does. Measured with mx check on mxbuild 11.15.0: +-- +-- display / description parameters $WorkflowContext only ($WorkflowInstance CE0117) +-- task name / description parameters $WorkflowContext and $WorkflowInstance +-- $WorkflowUserTask, $currentUser CE0117 everywhere (MDL-WF19) +-- a placeholder no parameter fills CE0720 (MDL-WF21); {0} is CE0716 +-- +-- The workflow below builds with 0 errors, describe prints every template, and +-- replaying describe reports "Unchanged workflow". +-- +-- EXPECTED: `mxcli check` passes this file. Changing `'Review {1}'` to +-- `'Review {1} {2}'` is refused as MDL-WF21; `$WorkflowInstance` in the +-- workflow's display as MDL-WF19. +-- Guard: mdl/executor/validate_workflow_templates_test.go, +-- mdl/visitor/visitor_workflow_templates_test.go, +-- mdl/backend/modelsdk/workflow_templates_test.go. +-- ============================================================================ + +create module BugTemplates1714; + +create persistent entity BugTemplates1714.Request ( + Title: String(200), + Amount: Decimal +); + +create page BugTemplates1714.WF_Review +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + actionbutton btnDone (Caption: 'Done', Action: complete task 'Done') + } +}; + +create workflow BugTemplates1714.Approval + parameter $WorkflowContext: BugTemplates1714.Request + display 'Approval for {1}' with ({1} = $WorkflowContext/Title) + description 'Worth {1}' with ({1} = toString($WorkflowContext/Amount)) +begin + user task Review 'Review' + page BugTemplates1714.WF_Review + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + task name 'Review {1}' with ({1} = $WorkflowContext/Title) + description 'Started as {1}' with ({1} = $WorkflowInstance/Name) + outcomes 'Done' { }; +end workflow; + +alter workflow BugTemplates1714.Approval { + set (TaskName: 'Check {1}' with ({1} = $WorkflowContext/Title)) on Review; +}; diff --git a/mdl/ast/ast_workflow.go b/mdl/ast/ast_workflow.go index 2d9f3223d6..6c5a0ca1a8 100644 --- a/mdl/ast/ast_workflow.go +++ b/mdl/ast/ast_workflow.go @@ -18,7 +18,11 @@ type CreateWorkflowStmt struct { // Display metadata DisplayName string // from DISPLAY 'text' Description string // from DESCRIPTION 'text' - ExportLevel string // "Hidden" or "API", from EXPORT LEVEL identifier + // DisplayParams and DescriptionParams fill the {n} placeholders of the two + // texts: `display 'Approval for {1}' with ({1} = $WorkflowContext/Title)`. + DisplayParams []WorkflowTemplateParam + DescriptionParams []WorkflowTemplateParam + ExportLevel string // "Hidden" or "API", from EXPORT LEVEL identifier // Optional metadata OverviewPage QualifiedName // qualified name of overview page @@ -109,7 +113,12 @@ type WorkflowUserTaskNode struct { IsMultiUser bool // Issue #8: true if MULTI USER TASK BoundaryEvents []WorkflowBoundaryEventNode // Issue #7 TaskDescription string // from DESCRIPTION 'text' - AutoAssign bool // `auto assign single user` (AutoAssignSingleTargetUser) + // TaskName is `task name '…'`, what the task inbox shows; "" means the + // caption, which is what a task without the clause is given. + TaskName string + TaskNameParams []WorkflowTemplateParam + TaskDescriptionParams []WorkflowTemplateParam + AutoAssign bool // `auto assign single user` (AutoAssignSingleTargetUser) // Multi-user task only. Participants *WorkflowParticipantsNode // `participants …`; nil = all @@ -311,18 +320,30 @@ type SetWorkflowPropertyOp struct { Property string // "DISPLAY", "DESCRIPTION", "EXPORT_LEVEL", "DUE_DATE", "OVERVIEW_PAGE", "PARAMETER" Value string // string value Entity QualifiedName // for OVERVIEW_PAGE / PARAMETER: the qualified name + // Params fills a DISPLAY or DESCRIPTION text's placeholders. + Params []WorkflowTemplateParam } func (o *SetWorkflowPropertyOp) alterWorkflowOp() {} +// WorkflowTemplateParam is one `{n} = ` of a workflow text template +// (ako/mxcli#1714). The expression is kept as written, as every workflow +// expression is. +type WorkflowTemplateParam struct { + Index int + Expression string +} + // SetActivityPropertyOp sets a property on a named activity. type SetActivityPropertyOp struct { ActivityRef string // activity caption AtPosition int // positional disambiguation (0 = no disambiguation) - Property string // "PAGE", "DESCRIPTION", "TARGETING_MICROFLOW", "TARGETING_XPATH", "DUE_DATE" + Property string // "PAGE", "DESCRIPTION", "TASK_NAME", "TARGETING_MICROFLOW", "TARGETING_XPATH", "DUE_DATE" Value string // string value PageName QualifiedName // for PAGE property Microflow QualifiedName // for TARGETING MICROFLOW + // Params fills a TASK_NAME or DESCRIPTION text's placeholders. + Params []WorkflowTemplateParam } func (o *SetActivityPropertyOp) alterWorkflowOp() {} diff --git a/mdl/backend/mcp/workflow.go b/mdl/backend/mcp/workflow.go index 7f715828ad..ace0c3cbf1 100644 --- a/mdl/backend/mcp/workflow.go +++ b/mdl/backend/mcp/workflow.go @@ -418,6 +418,9 @@ func (b *Backend) mapWorkflow(wf *workflows.Workflow) (map[string]any, error) { // in the context shape (Studio Pro 11.14: `context`, `caption`, string // `workflowName`) or the parameter-element shape older servers take. func (b *Backend) mapWorkflowContent(wf *workflows.Workflow, contextShape bool) (map[string]any, error) { + if len(wf.WorkflowNameParams)+len(wf.WorkflowDescriptionParams) > 0 { + return nil, errMCPTemplateParams("the workflow's display or description") + } flow, err := b.mapWorkflowFlow(wf.Flow) if err != nil { return nil, err @@ -1097,6 +1100,9 @@ func mapWorkflowActivity(a workflows.WorkflowActivity) (map[string]any, error) { "outcomes": outcomes, }, nil case *workflows.UserTask: + if len(act.TaskNameParams)+len(act.TaskDescriptionParams) > 0 { + return nil, errMCPTemplateParams(fmt.Sprintf("user task %q's task name or description", act.Name)) + } bevents, err := mapBoundaryEvents(act.BoundaryEvents) if err != nil { return nil, err @@ -1754,17 +1760,29 @@ func (m *mcpWorkflowMutator) apply(ops ...pedOpEntry) error { // PED forbids replacing a nested element wholesale, so a property whose change // would swap an element's *kind* (e.g. switching user targeting from XPath to // Microflow) is rejected rather than silently dropped. +// SetTemplateParameters: PED's list editing for a template's parameters has +// not been measured, so a text with parameters is refused rather than written +// without them (ako/mxcli#1714). Setting a text without parameters is a no-op +// here; one the stored text already had is left as it was. +func (m *mcpWorkflowMutator) SetTemplateParameters(activityRef string, atPos int, prop string, params []string) error { + if len(params) == 0 { + return nil + } + return fmt.Errorf("ALTER WORKFLOW: a %s with template parameters (`with ({1} = …)`) is not supported by the MCP backend yet", prop) +} + func (m *mcpWorkflowMutator) SetActivityProperty(activityRef string, atPos int, prop, value string) error { // Reject an unsupported property before the (live) index resolution. leaf := map[string]string{ "page": "/taskPage/page", "description": "/taskDescription/text", + "task_name": "/taskName/text", "due_date": "/dueDate", }[strings.ToLower(prop)] switch strings.ToLower(prop) { - case "page", "description", "due_date", "targeting_microflow", "targeting_xpath": + case "page", "description", "task_name", "due_date", "targeting_microflow", "targeting_xpath": default: - return fmt.Errorf("ALTER WORKFLOW set activity %s is not supported by the MCP backend (supported: page, description, due_date, targeting_microflow, targeting_xpath)", prop) + return fmt.Errorf("ALTER WORKFLOW set activity %s is not supported by the MCP backend (supported: page, description, task_name, due_date, targeting_microflow, targeting_xpath)", prop) } loc, err := m.resolve(activityRef, atPos) if err != nil { @@ -2258,3 +2276,11 @@ func boundaryEventElement(be *workflows.BoundaryEvent) (map[string]any, error) { } return el, nil } + +// errMCPTemplateParams refuses a workflow text with template parameters over +// MCP: how PED takes a StringTemplate's parameter list has not been measured, +// and writing the text without them would drop them silently (ako/mxcli#1714). +func errMCPTemplateParams(what string) error { + return fmt.Errorf("%s has template parameters (`with ({1} = …)`), which the MCP backend cannot write yet — "+ + "create the workflow without --mcp, or set the parameters in Studio Pro", what) +} diff --git a/mdl/backend/mcp/workflow_test.go b/mdl/backend/mcp/workflow_test.go index c9419aa6fe..3cefd81baa 100644 --- a/mdl/backend/mcp/workflow_test.go +++ b/mdl/backend/mcp/workflow_test.go @@ -714,3 +714,30 @@ func TestWFNestedActivityResolution(t *testing.T) { t.Errorf("nested drop op wrong: %s", raw) } } + +// ako/mxcli#1714: how PED takes a template's parameter list is unmeasured, so +// a text with parameters is refused over MCP rather than written without them. +func TestMCPWorkflow_RefusesTemplateParameters(t *testing.T) { + task := &workflows.UserTask{Page: "M.P", TaskName: "Review {1}", TaskNameParams: []string{"$WorkflowContext/Title"}} + task.Name, task.Caption = "Review", "Review" + if _, err := mapWorkflowActivity(task); err == nil || !strings.Contains(err.Error(), "template parameters") { + t.Errorf("create with task name parameters: got %v, want a refusal", err) + } + wf := &workflows.Workflow{Name: "W", WorkflowName: "W {1}", WorkflowNameParams: []string{"$WorkflowContext/Title"}} + if _, err := (&Backend{}).mapWorkflowContent(wf, true); err == nil || !strings.Contains(err.Error(), "template parameters") { + t.Errorf("create with display parameters: got %v, want a refusal", err) + } + m := &mcpWorkflowMutator{backend: &Backend{}, moduleName: "M", workflowName: "WF"} + if err := m.SetTemplateParameters("", 0, "display", []string{"$WorkflowContext/Title"}); err == nil { + t.Error("alter with display parameters must be refused") + } + // CONTROL: no parameters is what MCP wrote before. + if err := m.SetTemplateParameters("", 0, "display", nil); err != nil { + t.Errorf("alter without parameters: %v", err) + } + plain := &workflows.UserTask{Page: "M.P"} + plain.Name, plain.Caption = "Plain", "Plain" + if _, err := mapWorkflowActivity(plain); err != nil { + t.Errorf("a task without parameters: %v", err) + } +} diff --git a/mdl/backend/mock/mock_workflow_mutator.go b/mdl/backend/mock/mock_workflow_mutator.go index ea9805521c..c3c4328b2c 100644 --- a/mdl/backend/mock/mock_workflow_mutator.go +++ b/mdl/backend/mock/mock_workflow_mutator.go @@ -27,6 +27,7 @@ type MockWorkflowMutator struct { DropPathFunc func(activityRef string, atPos int, pathCaption string) error InsertBranchFunc func(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error DropBranchFunc func(activityRef string, atPos int, branchName string) error + SetTemplateParametersFunc func(activityRef string, atPos int, prop string, params []string) error InsertBoundaryEventFunc func(activityRef string, atPos int, eventType string, delay string, caption string, activities []workflows.WorkflowActivity) error DropBoundaryEventFunc func(activityRef string, atPos int) error SaveFunc func() error @@ -153,3 +154,10 @@ func (m *MockWorkflowMutator) Save() error { } return nil } + +func (m *MockWorkflowMutator) SetTemplateParameters(activityRef string, atPos int, prop string, params []string) error { + if m.SetTemplateParametersFunc != nil { + return m.SetTemplateParametersFunc(activityRef, atPos, prop, params) + } + return nil +} diff --git a/mdl/backend/modelsdk/workflow_read.go b/mdl/backend/modelsdk/workflow_read.go index a88f75b408..7dea012d80 100644 --- a/mdl/backend/modelsdk/workflow_read.go +++ b/mdl/backend/modelsdk/workflow_read.go @@ -54,6 +54,9 @@ func workflowFromGen(g *genWf.Workflow, containerID model.ID) *workflows.Workflo DueDate: g.DueDate(), WorkflowName: workflowTemplateText(g.WorkflowName()), WorkflowDescription: workflowTemplateText(g.WorkflowDescription()), + + WorkflowNameParams: workflowTemplateParams(g.WorkflowName()), + WorkflowDescriptionParams: workflowTemplateParams(g.WorkflowDescription()), } w.ID = model.ID(g.ID()) w.TypeName = "Workflows$Workflow" @@ -173,13 +176,15 @@ func workflowActivityFromGen(el element.Element) workflows.WorkflowActivity { switch a := el.(type) { case *genWf.UserTask: t := &workflows.UserTask{ - Page: a.PageQualifiedName(), - UserTaskEntity: a.UserTaskEntityQualifiedName(), - TaskName: workflowTemplateText(a.TaskName()), - TaskDescription: workflowTemplateText(a.TaskDescription()), - DueDate: a.DueDate(), - UserSource: userSourceFromGen(a.UserSource(), nil), - OnCreated: microflowEventName(a.OnCreatedEvent()), + Page: a.PageQualifiedName(), + UserTaskEntity: a.UserTaskEntityQualifiedName(), + TaskName: workflowTemplateText(a.TaskName()), + TaskDescription: workflowTemplateText(a.TaskDescription()), + TaskNameParams: workflowTemplateParams(a.TaskName()), + TaskDescriptionParams: workflowTemplateParams(a.TaskDescription()), + DueDate: a.DueDate(), + UserSource: userSourceFromGen(a.UserSource(), nil), + OnCreated: microflowEventName(a.OnCreatedEvent()), } setWfBase(&t.BaseWorkflowActivity, a.ID(), a.Name(), a.Caption(), a.Annotation(), "Workflows$UserTask") t.AutoAssignSingleTargetUser = a.AutoAssignSingleTargetUser() @@ -190,12 +195,14 @@ func workflowActivityFromGen(el element.Element) workflows.WorkflowActivity { return t case *genWf.SingleUserTaskActivity: t := &workflows.UserTask{ - TaskName: workflowTemplateText(a.TaskName()), - TaskDescription: workflowTemplateText(a.TaskDescription()), - DueDate: a.DueDate(), - UserSource: userSourceFromGen(a.UserSource(), a.UserTargeting()), - OnCreated: microflowEventName(a.OnCreatedEvent()), - Page: taskPageName(a.TaskPage()), + TaskName: workflowTemplateText(a.TaskName()), + TaskDescription: workflowTemplateText(a.TaskDescription()), + TaskNameParams: workflowTemplateParams(a.TaskName()), + TaskDescriptionParams: workflowTemplateParams(a.TaskDescription()), + DueDate: a.DueDate(), + UserSource: userSourceFromGen(a.UserSource(), a.UserTargeting()), + OnCreated: microflowEventName(a.OnCreatedEvent()), + Page: taskPageName(a.TaskPage()), } setWfBase(&t.BaseWorkflowActivity, a.ID(), a.Name(), a.Caption(), a.Annotation(), "Workflows$SingleUserTaskActivity") t.AutoAssignSingleTargetUser = a.AutoAssignSingleTargetUser() @@ -204,13 +211,15 @@ func workflowActivityFromGen(el element.Element) workflows.WorkflowActivity { return t case *genWf.MultiUserTaskActivity: t := &workflows.UserTask{ - IsMulti: true, - TaskName: workflowTemplateText(a.TaskName()), - TaskDescription: workflowTemplateText(a.TaskDescription()), - DueDate: a.DueDate(), - UserSource: userSourceFromGen(a.UserSource(), a.UserTargeting()), - OnCreated: microflowEventName(a.OnCreatedEvent()), - Page: taskPageName(a.TaskPage()), + IsMulti: true, + TaskName: workflowTemplateText(a.TaskName()), + TaskDescription: workflowTemplateText(a.TaskDescription()), + TaskNameParams: workflowTemplateParams(a.TaskName()), + TaskDescriptionParams: workflowTemplateParams(a.TaskDescription()), + DueDate: a.DueDate(), + UserSource: userSourceFromGen(a.UserSource(), a.UserTargeting()), + OnCreated: microflowEventName(a.OnCreatedEvent()), + Page: taskPageName(a.TaskPage()), } setWfBase(&t.BaseWorkflowActivity, a.ID(), a.Name(), a.Caption(), a.Annotation(), "Workflows$MultiUserTaskActivity") t.AutoAssignSingleTargetUser = a.AutoAssignSingleTargetUser() @@ -537,6 +546,23 @@ func workflowTemplateText(el element.Element) string { return "" } +// workflowTemplateParams is the expressions filling a workflow text's {1}, +// {2}, … in order. The stored Microflows$TemplateParameter decodes as gen's +// TemplateArgument, whose storage name it is. +func workflowTemplateParams(el element.Element) []string { + st, ok := el.(*genMf.StringTemplate) + if !ok || st == nil { + return nil + } + var out []string + for _, a := range st.ArgumentsItems() { + if ta, ok := a.(*genMf.TemplateArgument); ok && ta != nil { + out = append(out, ta.Expression()) + } + } + return out +} + // setWfBase fills a semantic BaseWorkflowActivity from common gen accessors. func setWfBase(a *workflows.BaseWorkflowActivity, id element.ID, name, caption string, annotation element.Element, typeName string) { a.ID = model.ID(id) diff --git a/mdl/backend/modelsdk/workflow_templates_test.go b/mdl/backend/modelsdk/workflow_templates_test.go new file mode 100644 index 0000000000..a737769269 --- /dev/null +++ b/mdl/backend/modelsdk/workflow_templates_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/modelsdk/codec" + genWf "github.com/mendixlabs/mxcli/modelsdk/gen/workflows" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// ako/mxcli#1714: a workflow text's parameters are written as +// Microflows$TemplateParameter children and read back in order — the display +// and description, and every user task's task name and description. +func TestWorkflowTemplates_RoundTrip(t *testing.T) { + single := &workflows.UserTask{Page: "M.P", + TaskName: "Review {1}", TaskNameParams: []string{"$WorkflowContext/Title"}, + TaskDescription: "{1} by {2}", TaskDescriptionParams: []string{"$WorkflowContext/Title", "$WorkflowInstance/Name"}} + single.Name, single.Caption = "Review", "Review" + single.Outcomes = []*workflows.UserTaskOutcome{{Value: "Done"}} + multi := &workflows.UserTask{IsMulti: true, Page: "M.P", + TaskName: "Vote on {1}", TaskNameParams: []string{"$WorkflowContext/Title"}} + multi.Name, multi.Caption = "Board", "Board" + multi.Outcomes = []*workflows.UserTaskOutcome{{Value: "Yes"}, {Value: "No"}} + plain := &workflows.UserTask{Page: "M.P"} // control: no parameters + plain.Name, plain.Caption = "Plain", "Plain" + plain.Outcomes = []*workflows.UserTaskOutcome{{Value: "Done"}} + + wf := &workflows.Workflow{ + Name: "WF", + Parameter: &workflows.WorkflowParameter{EntityRef: "M.Ctx"}, + WorkflowName: "Approval for {1}", + WorkflowNameParams: []string{"$WorkflowContext/Title"}, + WorkflowDescription: "Plain", + Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{single, multi, plain}}, + } + raw, err := (&codec.Encoder{}).Encode(workflowToGen(wf)) + if err != nil { + t.Fatalf("encode: %v", err) + } + el, err := codec.NewDecoder(codec.DefaultRegistry).Decode(raw) + if err != nil { + t.Fatalf("decode: %v", err) + } + back := workflowFromGen(el.(*genWf.Workflow), "") + + check := func(what string, got, want any) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Errorf("%s = %#v, want %#v", what, got, want) + } + } + check("display params", back.WorkflowNameParams, []string{"$WorkflowContext/Title"}) + check("description params", back.WorkflowDescriptionParams, []string(nil)) + acts := back.Flow.Activities + var tasks []*workflows.UserTask + for _, a := range acts { + if ut, ok := a.(*workflows.UserTask); ok { + tasks = append(tasks, ut) + } + } + if len(tasks) != 3 { + t.Fatalf("read %d user tasks, want 3", len(tasks)) + } + check("task name", tasks[0].TaskName, "Review {1}") + check("task name params", tasks[0].TaskNameParams, []string{"$WorkflowContext/Title"}) + check("task description params", tasks[0].TaskDescriptionParams, []string{"$WorkflowContext/Title", "$WorkflowInstance/Name"}) + check("multi task name params", tasks[1].TaskNameParams, []string{"$WorkflowContext/Title"}) + check("plain task name", tasks[2].TaskName, "Plain") // the caption, as the writer falls back to + check("plain task name params", tasks[2].TaskNameParams, []string(nil)) +} diff --git a/mdl/backend/modelsdk/workflow_write.go b/mdl/backend/modelsdk/workflow_write.go index 508aa68a27..20e25a0588 100644 --- a/mdl/backend/modelsdk/workflow_write.go +++ b/mdl/backend/modelsdk/workflow_write.go @@ -292,9 +292,9 @@ func workflowToGen(wf *workflows.Workflow) element.Element { title = wf.Name } addStr(g, "Title", title) - addPart(g, "WorkflowDescription", workflowStringTemplate(wf.WorkflowDescription)) + addPart(g, "WorkflowDescription", workflowStringTemplate(wf.WorkflowDescription, wf.WorkflowDescriptionParams)) // WorkflowMetaData: null (via NullFields). - addPart(g, "WorkflowName", workflowStringTemplate(wf.WorkflowName)) + addPart(g, "WorkflowName", workflowStringTemplate(wf.WorkflowName, wf.WorkflowNameParams)) addBool(g, "WorkflowV2", false) return g } @@ -442,12 +442,12 @@ func userTaskToGen(a *workflows.UserTask) element.Element { addFreshPersistentID(g) addStr(g, "RelativeMiddlePoint", workflowZeroPoint) addStr(g, "Size", workflowZeroPoint) - addPart(g, "TaskDescription", workflowStringTemplate(a.TaskDescription)) + addPart(g, "TaskDescription", workflowStringTemplate(a.TaskDescription, a.TaskDescriptionParams)) taskName := a.TaskName if taskName == "" { taskName = a.Caption } - addPart(g, "TaskName", workflowStringTemplate(taskName)) + addPart(g, "TaskName", workflowStringTemplate(taskName, a.TaskNameParams)) addPart(g, "TaskPage", pageReferenceElem(a.Page)) if a.IsMulti { addPart(g, "TargetUserInput", targetUserInputToGen(a.TargetUserInput)) @@ -791,12 +791,12 @@ func annotationElem(text string) element.Element { return g } -// workflowStringTemplate builds a minimal Microflows$StringTemplate (empty -// Parameters list via MandatoryListMarkers + Text). -func workflowStringTemplate(text string) element.Element { - g := newElem("Microflows$StringTemplate", "") - addStr(g, "Text", text) - return g +// workflowStringTemplate builds a workflow text: a Microflows$StringTemplate +// whose Parameters are Microflows$TemplateParameter children, one Expression +// each — the shape mx check parses (an invalid expression is CE0117 at the +// task, 11.15.0) — or an empty list via MandatoryListMarkers (ako/mxcli#1714). +func workflowStringTemplate(text string, params []string) element.Element { + return stringTemplateElem(text, params) } // addActivityBaseFields adds the common Annotation(null)/PersistentId/ diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index 5e6aaeefd7..fce9138e3d 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -301,6 +301,11 @@ type WorkflowMutator interface { // caption and optional position index. SetActivityProperty(activityRef string, atPos int, prop string, value string) error + // SetTemplateParameters sets the expressions filling a text's {1}, {2}, … + // in order: the workflow's "display" or "description" when activityRef is + // "", else a user task's "task_name" or "description". Empty clears them. + SetTemplateParameters(activityRef string, atPos int, prop string, params []string) error + // InsertAfterActivity inserts new activities after the referenced activity. InsertAfterActivity(activityRef string, atPos int, activities []workflows.WorkflowActivity) error diff --git a/mdl/backend/wfmutator/mutator.go b/mdl/backend/wfmutator/mutator.go index 9b66269b48..eaf563456b 100644 --- a/mdl/backend/wfmutator/mutator.go +++ b/mdl/backend/wfmutator/mutator.go @@ -155,6 +155,62 @@ func (m *Mutator) SetPropertyWithEntity(prop string, value string, entity string // Activity operations // --------------------------------------------------------------------------- +// templateKeys maps SetTemplateParameters' property to the key of the +// Microflows$StringTemplate it fills, on the workflow and on a user task. +var templateKeys = map[string][2]string{ + "display": {"WorkflowName", ""}, + "description": {"WorkflowDescription", "TaskDescription"}, + "task_name": {"", "TaskName"}, +} + +// SetTemplateParameters replaces a text's parameters with one +// Microflows$TemplateParameter per expression — the shape the create path +// writes (ako/mxcli#1714). +func (m *Mutator) SetTemplateParameters(activityRef string, atPos int, prop string, params []string) error { + keys, ok := templateKeys[strings.ToLower(prop)] + if !ok { + return fmt.Errorf("%s is not a text with parameters", prop) + } + owner, key := m.rawData, keys[0] + if activityRef != "" { + actDoc, err := m.findActivityByCaption(activityRef, atPos) + if err != nil { + return err + } + owner, key = actDoc, keys[1] + } + if key == "" { + return fmt.Errorf("%s has no parameters here", prop) + } + tmpl := bsonnav.DGetDoc(owner, key) + if tmpl == nil { + if len(params) == 0 { + return nil + } + return fmt.Errorf("%s has no %s to give parameters", orWorkflow(activityRef), key) + } + list := bson.A{int32(2)} + for _, p := range params { + list = append(list, bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Microflows$TemplateParameter"}, + {Key: "Expression", Value: p}, + }) + } + if !bsonnav.DSet(tmpl, "Parameters", list) { + return fmt.Errorf("%s's %s has no parameter list", orWorkflow(activityRef), key) + } + return nil +} + +// orWorkflow names what SetTemplateParameters edits, for its errors. +func orWorkflow(activityRef string) string { + if activityRef == "" { + return "the workflow" + } + return "activity " + activityRef +} + func (m *Mutator) SetActivityProperty(activityRef string, atPos int, prop string, value string) error { actDoc, err := m.findActivityByCaption(activityRef, atPos) if err != nil { @@ -186,6 +242,14 @@ func (m *Mutator) SetActivityProperty(activityRef string, atPos int, prop string } return nil + case "task_name": + taskName := bsonnav.DGetDoc(actDoc, "TaskName") + if taskName == nil { + return fmt.Errorf("activity %q has no task name", activityRef) + } + bsonnav.DSet(taskName, "Text", value) + return nil + case "targeting_microflow": userTargeting := bson.D{ {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, diff --git a/mdl/backend/wfmutator/template_params_test.go b/mdl/backend/wfmutator/template_params_test.go new file mode 100644 index 0000000000..c10854ecf8 --- /dev/null +++ b/mdl/backend/wfmutator/template_params_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wfmutator + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/mdl/bsonutil" +) + +// stringTemplate is a stored Microflows$StringTemplate with no parameters. +func stringTemplate(text string) bson.D { + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Microflows$StringTemplate"}, + {Key: "Parameters", Value: bson.A{int32(2)}}, + {Key: "Text", Value: text}, + } +} + +func templateExprs(t *testing.T, tmpl bson.D) []string { + t.Helper() + var out []string + for _, p := range bsonnav.DGetArrayElements(bsonnav.DGet(tmpl, "Parameters")) { + pd := p.(bson.D) + if got := bsonnav.DGetString(pd, "$Type"); got != "Microflows$TemplateParameter" { + t.Errorf("parameter $Type = %q, want Microflows$TemplateParameter", got) + } + out = append(out, bsonnav.DGetString(pd, "Expression")) + } + return out +} + +// ako/mxcli#1714: ALTER sets a text's parameters — a user task's task name and +// description, and the workflow's display — and a text set without them has +// none. +func TestWorkflowMutator_SetTemplateParameters(t *testing.T) { + act := makeWfActivity("Workflows$SingleUserTaskActivity", "Review", "Review") + act = append(act, bson.E{Key: "TaskName", Value: stringTemplate("Review")}, bson.E{Key: "TaskDescription", Value: stringTemplate("")}) + doc := makeWorkflowDoc(act) + bsonnav.DSet(doc, "WorkflowName", stringTemplate("WF")) + m := newMutator(doc) + + if err := m.SetActivityProperty("Review", 0, "task_name", "Review {1}"); err != nil { + t.Fatal(err) + } + if err := m.SetTemplateParameters("Review", 0, "task_name", []string{"$WorkflowContext/Title"}); err != nil { + t.Fatal(err) + } + if err := m.SetTemplateParameters("Review", 0, "description", []string{"$WorkflowContext/Title", "$WorkflowInstance/Name"}); err != nil { + t.Fatal(err) + } + if err := m.SetTemplateParameters("", 0, "display", []string{"$WorkflowContext/Title"}); err != nil { + t.Fatal(err) + } + actDoc, _ := m.findActivityByCaption("Review", 0) + if got := bsonnav.DGetString(bsonnav.DGetDoc(actDoc, "TaskName"), "Text"); got != "Review {1}" { + t.Errorf("task name text = %q", got) + } + if got := templateExprs(t, bsonnav.DGetDoc(actDoc, "TaskName")); len(got) != 1 || got[0] != "$WorkflowContext/Title" { + t.Errorf("task name params = %q", got) + } + if got := templateExprs(t, bsonnav.DGetDoc(actDoc, "TaskDescription")); len(got) != 2 || got[1] != "$WorkflowInstance/Name" { + t.Errorf("task description params = %q", got) + } + if got := templateExprs(t, bsonnav.DGetDoc(m.rawData, "WorkflowName")); len(got) != 1 { + t.Errorf("display params = %q", got) + } + + // A text set without parameters clears the ones it had. + if err := m.SetTemplateParameters("Review", 0, "task_name", nil); err != nil { + t.Fatal(err) + } + actDoc, _ = m.findActivityByCaption("Review", 0) + if got := templateExprs(t, bsonnav.DGetDoc(actDoc, "TaskName")); len(got) != 0 { + t.Errorf("cleared task name params = %q", got) + } +} diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index 592f76559b..e952bd22ac 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -46,6 +46,9 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { if vs := checkWorkflowExprScope(alterWorkflowExprSites(s), "", workflowLocation(s.Name)); len(vs) > 0 { return mdlerrors.NewValidationf("workflow '%s': %s\n → %s", s.Name.String(), vs[0].Message, vs[0].Suggestion) } + if vs := checkWorkflowTemplates(alterWorkflowTemplateSites(s), workflowLocation(s.Name)); len(vs) > 0 { + return mdlerrors.NewValidationf("workflow '%s': %s [%s]\n → %s", s.Name.String(), vs[0].Message, vs[0].RuleID, vs[0].Suggestion) + } if refErrors := validateAlterWorkflowRefs(ctx, s, nil); len(refErrors) > 0 { return mdlerrors.NewValidationf("workflow '%s' has reference errors:\n - %s", s.Name.String(), strings.Join(refErrors, "\n - ")) @@ -126,6 +129,13 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { if err := mutator.SetProperty(o.Property, value); err != nil { return mdlerrors.NewBackend("set "+o.Property, err) } + // A text states its whole value: its parameters, or none + // (ako/mxcli#1714). + if o.Property == "display" || o.Property == "description" { + if err := mutator.SetTemplateParameters("", 0, o.Property, alterTemplateExprs(o.Params)); err != nil { + return mdlerrors.NewBackend("set "+o.Property, err) + } + } } case *ast.SetActivityPropertyOp: @@ -141,6 +151,11 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { if err := mutator.SetActivityProperty(o.ActivityRef, o.AtPosition, o.Property, value); err != nil { return mdlerrors.NewBackend("set activity", err) } + if o.Property == "description" || o.Property == "task_name" { + if err := mutator.SetTemplateParameters(o.ActivityRef, o.AtPosition, o.Property, alterTemplateExprs(o.Params)); err != nil { + return mdlerrors.NewBackend("set activity", err) + } + } case *ast.InsertAfterOp: acts := buildAndBindActivities(ctx, o.NewActivities) @@ -359,3 +374,14 @@ func workflowActivityNodeLabels(a ast.WorkflowActivityNode) (name, caption strin } return name, caption } + +// alterTemplateExprs is an ALTER's template parameters in placeholder order, +// in the casings MDL-WF19 accepts. ALTER has no header alias to rewrite: the +// stored context is $WorkflowContext. +func alterTemplateExprs(params []ast.WorkflowTemplateParam) []string { + exprs := workflowTemplateExprs(params) + for i, e := range exprs { + exprs[i] = normalizeWorkflowContextExpr(e) + } + return exprs +} diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index 1d3bfad4cb..d009b672dc 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -144,13 +144,13 @@ func describeWorkflowToString(ctx *ExecContext, name ast.QualifiedName) (string, } // Display name - if targetWf.WorkflowName != "" { - lines = append(lines, fmt.Sprintf(" display %s", mdlQuoted(targetWf.WorkflowName))) + if targetWf.WorkflowName != "" || len(targetWf.WorkflowNameParams) > 0 { + lines = append(lines, " display "+workflowTemplateMDL(targetWf.WorkflowName, targetWf.WorkflowNameParams)) } // Description - if targetWf.WorkflowDescription != "" { - lines = append(lines, fmt.Sprintf(" description %s", mdlQuoted(targetWf.WorkflowDescription))) + if targetWf.WorkflowDescription != "" || len(targetWf.WorkflowDescriptionParams) > 0 { + lines = append(lines, " description "+workflowTemplateMDL(targetWf.WorkflowDescription, targetWf.WorkflowDescriptionParams)) } // Export level (only emit when non-empty) @@ -551,9 +551,15 @@ func formatUserTask(ctx *ExecContext, a *workflows.UserTask, indent string) []st lines = append(lines, fmt.Sprintf("%s due date %s", indent, workflowExpressionMDL(a.DueDate))) } + // Task name: printed only when it is not what a task without the clause + // gets, its caption (ako/mxcli#1714). + if (a.TaskName != "" && a.TaskName != caption) || len(a.TaskNameParams) > 0 { + lines = append(lines, indent+" task name "+workflowTemplateMDL(a.TaskName, a.TaskNameParams)) + } + // Task description - if a.TaskDescription != "" { - lines = append(lines, fmt.Sprintf("%s description %s", indent, mdlQuoted(a.TaskDescription))) + if a.TaskDescription != "" || len(a.TaskDescriptionParams) > 0 { + lines = append(lines, indent+" description "+workflowTemplateMDL(a.TaskDescription, a.TaskDescriptionParams)) } if a.IsMulti { @@ -954,6 +960,20 @@ func targetingXPathMDL(ctx *ExecContext, xpath string) string { // the MDL expression grammar cannot parse) is written in the deprecated string // form, which carries it byte for byte: describe must never alter a stored // expression. +// workflowTemplateMDL is a workflow text as MDL writes it: the quoted text, then +// `with ({1} = …, …)` when expressions fill its placeholders (ako/mxcli#1714). +func workflowTemplateMDL(text string, params []string) string { + out := mdlQuoted(text) + if len(params) == 0 { + return out + } + parts := make([]string, len(params)) + for i, p := range params { + parts[i] = fmt.Sprintf("{%d} = %s", i+1, p) + } + return out + " with (" + strings.Join(parts, ", ") + ")" +} + func workflowExpressionMDL(expr string) string { if visitor.BareExpression(expr) { return expr diff --git a/mdl/executor/cmd_workflows_describe_test.go b/mdl/executor/cmd_workflows_describe_test.go index d10e4cc28b..8261ce60bf 100644 --- a/mdl/executor/cmd_workflows_describe_test.go +++ b/mdl/executor/cmd_workflows_describe_test.go @@ -394,3 +394,34 @@ func TestWorkflowDescribe_TimerBoundaryEventCaptionReparses(t *testing.T) { } } } + +// ako/mxcli#1714: describe prints a text's parameters and a task name that is +// not the caption, and the output re-parses into the same values. +func TestWorkflowDescribe_TextTemplatesReparse(t *testing.T) { + task := &workflows.UserTask{Page: "M.P", Outcomes: []*workflows.UserTaskOutcome{{Value: "Ok"}}, + TaskName: "Review {1}", TaskNameParams: []string{"$WorkflowContext/Name"}, + TaskDescription: "It's {1} by {2}", TaskDescriptionParams: []string{"toString($WorkflowContext/Amount)", "$WorkflowInstance/Name"}} + task.Name, task.Caption = "T", "Task" + same := &workflows.UserTask{Page: "M.P", Outcomes: []*workflows.UserTaskOutcome{{Value: "Ok"}}, TaskName: "Same"} + same.Name, same.Caption = "S", "Same" // control: the task name is the caption, so no clause + body := strings.Join(append(formatSingleActivity(task, " "), formatSingleActivity(same, " ")...), "\n") + if strings.Count(body, "task name") != 1 { + t.Errorf("want one `task name` clause (not for the task named by its caption):\n%s", body) + } + src := "create workflow M.W parameter $C: M.E\n display " + workflowTemplateMDL("For {1}", []string{"$WorkflowContext/Name"}) + "\nbegin\n" + body + "\nend workflow;" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("describe output does not parse: %v\n%s", errs, src) + } + wf := prog.Statements[0].(*ast.CreateWorkflowStmt) + got := wf.Activities[0].(*ast.WorkflowUserTaskNode) + if got.TaskName != "Review {1}" || strings.Join(workflowTemplateExprs(got.TaskNameParams), ",") != "$WorkflowContext/Name" { + t.Errorf("task name re-parsed as %q %v\n%s", got.TaskName, got.TaskNameParams, src) + } + if got.TaskDescription != "It's {1} by {2}" || strings.Join(workflowTemplateExprs(got.TaskDescriptionParams), ",") != "toString($WorkflowContext/Amount),$WorkflowInstance/Name" { + t.Errorf("description re-parsed as %q %v\n%s", got.TaskDescription, got.TaskDescriptionParams, src) + } + if strings.Join(workflowTemplateExprs(wf.DisplayParams), ",") != "$WorkflowContext/Name" { + t.Errorf("display re-parsed as %v", wf.DisplayParams) + } +} diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index 473db3e644..764dfa1cb4 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -6,6 +6,7 @@ package executor import ( "fmt" "regexp" + "sort" "strings" "unicode" @@ -84,6 +85,11 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { if vs := ValidateWorkflowExpressionScope(s); len(vs) > 0 { return mdlerrors.NewValidationf("workflow '%s': %s\n → %s", s.Name.String(), vs[0].Message, vs[0].Suggestion) } + // MDL-WF21: a placeholder no parameter fills is CE0720 at build time + // (ako/mxcli#1714). + if vs := checkWorkflowTemplates(workflowTemplateSites(s), workflowLocation(s.Name)); len(vs) > 0 { + return mdlerrors.NewValidationf("workflow '%s': %s [%s]\n → %s", s.Name.String(), vs[0].Message, vs[0].RuleID, vs[0].Suggestion) + } if err := checkVersionGates(ctx, s); err != nil { return err } @@ -178,6 +184,11 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { // Display metadata wf.WorkflowName = s.DisplayName wf.WorkflowDescription = s.Description + // Their parameters are expressions over the context, normalized like the + // due date's (ako/mxcli#1714). + headerNorm := newContextExprNormalizer(s.ParameterVar) + wf.WorkflowNameParams = headerNorm.rewriteAll(workflowTemplateExprs(s.DisplayParams)) + wf.WorkflowDescriptionParams = headerNorm.rewriteAll(workflowTemplateExprs(s.DescriptionParams)) wf.Annotation = s.Annotation if s.ExportLevel != "" { wf.ExportLevel = s.ExportLevel @@ -505,6 +516,9 @@ func buildUserTask(n *ast.WorkflowUserTaskNode) *workflows.UserTask { task.Caption = n.Caption task.DueDate = n.DueDate task.TaskDescription = n.TaskDescription + task.TaskDescriptionParams = workflowTemplateExprs(n.TaskDescriptionParams) + task.TaskName = n.TaskName // "" is the caption, which the writer falls back to + task.TaskNameParams = workflowTemplateExprs(n.TaskNameParams) task.IsMulti = n.IsMultiUser task.AutoAssignSingleTargetUser = n.AutoAssign if n.IsMultiUser { @@ -1119,6 +1133,8 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA // Sanitize name a.Name = sanitizeActivityName(a.Name) a.DueDate = norm.rewrite(a.DueDate) + a.TaskNameParams = norm.rewriteAll(a.TaskNameParams) + a.TaskDescriptionParams = norm.rewriteAll(a.TaskDescriptionParams) if xp, ok := a.UserSource.(*workflows.XPathBasedUserSource); ok { xp.XPath = norm.rewrite(xp.XPath) } @@ -1305,6 +1321,30 @@ func (n contextExprNormalizer) rewrite(expr string) string { return normalizeWorkflowContextExpr(expr) } +// rewriteAll rewrites each expression of a template's parameter list. +func (n contextExprNormalizer) rewriteAll(exprs []string) []string { + for i, e := range exprs { + exprs[i] = n.rewrite(e) + } + return exprs +} + +// workflowTemplateExprs is a text template's parameter expressions in +// placeholder order. Numbering gaps and repeats are refused at check time +// (MDL-WF21); here the lowest index simply comes first. +func workflowTemplateExprs(params []ast.WorkflowTemplateParam) []string { + if len(params) == 0 { + return nil + } + sorted := append([]ast.WorkflowTemplateParam(nil), params...) + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Index < sorted[j].Index }) + out := make([]string, len(sorted)) + for i, p := range sorted { + out[i] = p.Expression + } + return out +} + // autoBindCallWorkflow resolves workflow parameters and generates ParameterMappings. func autoBindCallWorkflow(ctx *ExecContext, act *workflows.CallWorkflowActivity) { // Sanitize name diff --git a/mdl/executor/validate_workflow.go b/mdl/executor/validate_workflow.go index 70ec5ec75f..ba46498a3d 100644 --- a/mdl/executor/validate_workflow.go +++ b/mdl/executor/validate_workflow.go @@ -129,6 +129,7 @@ func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { out = append(out, ValidateWorkflowCompletionRules(stmt)...) out = append(out, ValidateWorkflowEventSubProcesses(stmt)...) out = append(out, ValidateWorkflowExpressionScope(stmt)...) + out = append(out, checkWorkflowTemplates(workflowTemplateSites(stmt), loc)...) return out } @@ -435,5 +436,6 @@ func ValidateAlterWorkflow(stmt *ast.AlterWorkflowStmt) []linter.Violation { // MDL-WF19 over every expression the statement writes. ALTER cannot see the // header's declared context name, so only $WorkflowContext is accepted for it. out = append(out, checkWorkflowExprScope(alterWorkflowExprSites(stmt), "", loc)...) + out = append(out, checkWorkflowTemplates(alterWorkflowTemplateSites(stmt), loc)...) return append(out, ValidateAlterWorkflowEnds(stmt)...) } diff --git a/mdl/executor/validate_workflow_rewrite.go b/mdl/executor/validate_workflow_rewrite.go index 5b0009db7f..dbb9676e2f 100644 --- a/mdl/executor/validate_workflow_rewrite.go +++ b/mdl/executor/validate_workflow_rewrite.go @@ -54,7 +54,7 @@ func checkNoDroppedWorkflowConstructs(ctx *ExecContext, workflowID model.ID, qua cannotExpress = append(cannotExpress, fmt.Sprintf("%d event sub-process(es) with no start event, which it would delete", n)) } cannotExpress = append(cannotExpress, studioProOnlyWorkflowState(raw)...) - cannotExpress = append(cannotExpress, rawUnstatableWorkflowState(raw, stmt)...) + cannotExpress = append(cannotExpress, rawUnstatableWorkflowState(raw)...) if len(cannotExpress) > 0 { return mdlerrors.NewUnsupported(fmt.Sprintf( "workflow %s holds state MDL cannot express, and rewriting it would delete or reset it silently:\n - %s\n"+ @@ -469,7 +469,7 @@ func validateAlterReplaceKeepsStudioProState(ctx *ExecContext, s *ast.AlterWorkf if n, _ := d["Name"].(string); n != "" && n == act.GetName() { repl := replacementFor(o.NewActivities, n) reasons = append(reasons, rawReplaceLosses(d, repl)...) - reasons = append(reasons, rawUnstatableActivityState(d, repl)...) + reasons = append(reasons, rawUnstatableActivityState(d)...) if mf := rawOnCreatedMicroflow(d); mf != "" && !restatesOnCreated(repl) { reasons = append(reasons, fmt.Sprintf( "it runs on-created microflow %s, which the replacement does not restate (add `on created microflow %s`)", mf, mf)) diff --git a/mdl/executor/validate_workflow_scope.go b/mdl/executor/validate_workflow_scope.go index 63785f869a..e6afdcc0f0 100644 --- a/mdl/executor/validate_workflow_scope.go +++ b/mdl/executor/validate_workflow_scope.go @@ -12,7 +12,9 @@ import ( ) // MDL-WF19: a variable a workflow expression cannot see (mendixlabs/mxcli#1529, -// #1523). +// #1523). A text template's parameters are expressions too (ako/mxcli#1714): +// the workflow's display and description are header scope, a user task's task +// name and description activity scope — measured on 11.15.0. // // A workflow declares no variables. What an expression in it can name is fixed // by Mendix, and anything else is CE0117 "Error(s) in expression." at build time @@ -130,12 +132,14 @@ func ValidateWorkflowExpressionScope(stmt *ast.CreateWorkflowStmt) []linter.Viol if stmt.DueDate != "" { sites = append(sites, wfExprSite{where: "the workflow's due date", expr: stmt.DueDate, header: true}) } + sites = append(sites, workflowTemplateExprSites(workflowTemplateSites(stmt), isWorkflowHeaderTemplate)...) return checkWorkflowExprScope(sites, stmt.ParameterVar, workflowLocation(stmt.Name)) } // alterWorkflowExprSites collects the expressions an ALTER WORKFLOW writes. func alterWorkflowExprSites(stmt *ast.AlterWorkflowStmt) []wfExprSite { sites := workflowActivityExprSites(alterWorkflowAddedActivities(stmt)) + sites = append(sites, workflowTemplateExprSites(alterWorkflowTemplateSites(stmt), isWorkflowHeaderTemplate)...) for _, op := range stmt.Operations { switch o := op.(type) { case *ast.InsertBoundaryEventOp: @@ -192,10 +196,10 @@ func workflowScopeSuggestion(name string, header, isInstance bool) string { const scope = "A workflow expression can name only the context, $WorkflowContext (or the name the header declares for it), and the running workflow, $WorkflowInstance (System.Workflow)." switch { case isInstance && header: - return "The workflow's own due date is evaluated before the instance exists: only $WorkflowContext is in scope there. Use [%CurrentDateTime%] or an attribute of the context, e.g. addDays([%CurrentDateTime%], 3)." + return "The workflow's own due date, display and description see only $WorkflowContext, not the running instance. Use an attribute of the context — for a due date, e.g. addDays([%CurrentDateTime%], 3)." case strings.EqualFold(name, "Workflow"): if header { - return "Only $WorkflowContext is in scope in the workflow's own due date." + return "Only $WorkflowContext is in scope in the workflow's own due date, display and description." } return "The running workflow is $WorkflowInstance, not $Workflow — pass it to a System.Workflow parameter as `(Workflow = $WorkflowInstance)`. " + scope case strings.EqualFold(name, "currentUser"), strings.EqualFold(name, "currentSession"): diff --git a/mdl/executor/validate_workflow_templates.go b/mdl/executor/validate_workflow_templates.go new file mode 100644 index 0000000000..5adbe7f21d --- /dev/null +++ b/mdl/executor/validate_workflow_templates.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// MDL-WF21: a workflow text whose placeholders its parameters do not fill +// (ako/mxcli#1714). The workflow's display and description, and a user task's +// task name and description, are templates: `'Review {1}' with ({1} = +// $WorkflowContext/Title)`. Measured with mx check on 11.15.0: +// +// 'Review {1} {2}' with one parameter CE0720 "Place holder index 2 is greater than 1, the number of parameter(s)." +// 'Review {1}' with none CE0720 (index 1 is greater than 0) — the workflow's display too +// 'Review {0}' CE0716 "Place holder indices start at one (1)." +// 'Review' with one parameter clean (an unused parameter) +// 'Review {1} {1}' with one clean +// +// The parameters are stored by position, so `with ({1} = …, {3} = …)` has no +// meaning to write: a numbering other than {1} to {n}, each once, is refused +// with the rest. What a parameter's expression may name is MDL-WF19's. +const wfTemplateRule = "MDL-WF21" + +// wfTemplateSite is one text template a workflow statement writes. +type wfTemplateSite struct { + where string // "the task name of user task 'Review'" + text string + params []ast.WorkflowTemplateParam +} + +var wfPlaceholderRe = regexp.MustCompile(`\{(\d+)\}`) + +// checkWorkflowTemplates reports each template whose placeholders and +// parameters do not match. +func checkWorkflowTemplates(sites []wfTemplateSite, loc linter.Location) []linter.Violation { + var out []linter.Violation + report := func(msg, suggestion string) { + out = append(out, linter.Violation{ + RuleID: wfTemplateRule, Severity: linter.SeverityError, Location: loc, + Message: msg, Suggestion: suggestion, + }) + } + for _, s := range sites { + n := len(s.params) + indices := make([]int, 0, n) + for _, p := range s.params { + indices = append(indices, p.Index) + } + sort.Ints(indices) + for i, idx := range indices { + if idx != i+1 { + report(fmt.Sprintf("%s numbers its parameters %s — they are stored by position, so they must be %s, each once", + s.where, placeholderList(indices), placeholderRange(n)), + fmt.Sprintf("Number them {1} to {%d} in the order the text uses them.", n)) + break + } + } + maxRef := 0 + for _, m := range wfPlaceholderRe.FindAllStringSubmatch(s.text, -1) { + idx, _ := strconv.Atoi(m[1]) + if idx == 0 { + report(fmt.Sprintf("%s has the placeholder {0} — MxBuild rejects it (CE0716 \"Place holder indices start at one (1).\")", s.where), + "Number the placeholders from {1}.") + } + if idx > maxRef { + maxRef = idx + } + } + if maxRef > n { + report(fmt.Sprintf("%s uses the placeholder {%d} but has %d parameter(s) — MxBuild rejects it (CE0720 \"Place holder index %d is greater than %d, the number of parameter(s).\")", + s.where, maxRef, n, maxRef, n), + fmt.Sprintf("Fill every placeholder: `'…' with ({1} = $WorkflowContext/Attr%s)`.", moreParams(maxRef))) + } + } + return out +} + +func placeholderList(indices []int) string { + parts := make([]string, len(indices)) + for i, idx := range indices { + parts[i] = fmt.Sprintf("{%d}", idx) + } + return strings.Join(parts, ", ") +} + +// placeholderRange is "{1}" or "{1} to {n}". +func placeholderRange(n int) string { + if n == 1 { + return "{1}" + } + return fmt.Sprintf("{1} to {%d}", n) +} + +func moreParams(n int) string { + if n <= 1 { + return "" + } + return fmt.Sprintf(", …, {%d} = …", n) +} + +// workflowTemplateSites collects the templates a CREATE WORKFLOW writes: the +// header's display and description, and every user task's task name and +// description, nested ones included. +func workflowTemplateSites(stmt *ast.CreateWorkflowStmt) []wfTemplateSite { + sites := []wfTemplateSite{ + {where: "the workflow's display", text: stmt.DisplayName, params: stmt.DisplayParams}, + {where: "the workflow's description", text: stmt.Description, params: stmt.DescriptionParams}, + } + return append(sites, userTaskTemplateSites(workflowStatementActivities(stmt))...) +} + +func userTaskTemplateSites(acts []ast.WorkflowActivityNode) []wfTemplateSite { + var sites []wfTemplateSite + walkWorkflowActivities(acts, func(a ast.WorkflowActivityNode) { + n, ok := a.(*ast.WorkflowUserTaskNode) + if !ok { + return + } + label := workflowUserTaskLabel(n) + name := n.TaskName + if name == "" { + name = n.Caption // what a task without `task name` is given + } + sites = append(sites, + wfTemplateSite{where: "the task name of user task " + label, text: name, params: n.TaskNameParams}, + wfTemplateSite{where: "the description of user task " + label, text: n.TaskDescription, params: n.TaskDescriptionParams}) + }) + return sites +} + +// alterWorkflowTemplateSites collects the templates an ALTER WORKFLOW writes. +func alterWorkflowTemplateSites(stmt *ast.AlterWorkflowStmt) []wfTemplateSite { + sites := userTaskTemplateSites(alterWorkflowAddedActivities(stmt)) + for _, op := range stmt.Operations { + switch o := op.(type) { + case *ast.SetWorkflowPropertyOp: + if o.Property == "display" || o.Property == "description" { + sites = append(sites, wfTemplateSite{where: "the workflow's " + o.Property, text: o.Value, params: o.Params}) + } + case *ast.SetActivityPropertyOp: + if o.Property == "description" || o.Property == "task_name" { + sites = append(sites, wfTemplateSite{where: "the " + strings.ReplaceAll(o.Property, "_", " ") + " of " + o.ActivityRef, text: o.Value, params: o.Params}) + } + } + } + return sites +} + +// workflowTemplateExprSites are a template's parameters as MDL-WF19 sites: +// header ones see only the context (measured on 11.15.0: $WorkflowInstance is +// CE0117 in the display and description, accepted in a task's name and +// description; $WorkflowUserTask and $currentUser are CE0117 in all four). +func workflowTemplateExprSites(sites []wfTemplateSite, header func(wfTemplateSite) bool) []wfExprSite { + var out []wfExprSite + for _, s := range sites { + for _, p := range s.params { + out = append(out, wfExprSite{where: fmt.Sprintf("{%d} of %s", p.Index, s.where), expr: p.Expression, header: header(s)}) + } + } + return out +} + +func isWorkflowHeaderTemplate(s wfTemplateSite) bool { + return strings.HasPrefix(s.where, "the workflow's ") +} diff --git a/mdl/executor/validate_workflow_templates_test.go b/mdl/executor/validate_workflow_templates_test.go new file mode 100644 index 0000000000..3ab362b87d --- /dev/null +++ b/mdl/executor/validate_workflow_templates_test.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +func wfRuleViolations(vs []linter.Violation, rule string) []linter.Violation { + var out []linter.Violation + for _, v := range vs { + if v.RuleID == rule { + out = append(out, v) + } + } + return out +} + +const wfTemplateHead = "create workflow M.W parameter $WorkflowContext: M.Item " + +// MDL-WF21 (ako/mxcli#1714). Each flagged row was measured with mx check on +// 11.15.0 — CE0720, or CE0716 for {0} — and each clean row builds. +func TestWorkflowTemplates_PlaceholdersAndParameters(t *testing.T) { + cases := []struct{ name, src, want string }{ + {"placeholder beyond the parameters (CE0720)", + wfTemplateHead + "begin user task R 'R' task name 'Review {1} {2}' with ({1} = $WorkflowContext/Name) outcomes 'Done' { }; end workflow;", + "CE0720"}, + {"a placeholder with no parameters at all (CE0720)", + wfTemplateHead + "display 'Doc {1}' begin user task R 'R' outcomes 'Done' { }; end workflow;", + "{1} but has 0 parameter(s)"}, + {"a caption that is the task name (CE0720)", + wfTemplateHead + "begin user task R 'Review {1}' outcomes 'Done' { }; end workflow;", + "task name of user task"}, + {"placeholder {0} (CE0716)", + wfTemplateHead + "begin user task R 'R' description 'Due {0}' with ({1} = $WorkflowContext/Name) outcomes 'Done' { }; end workflow;", + "CE0716"}, + {"parameters numbered with a gap", + wfTemplateHead + "description 'A {1} B {2}' with ({1} = $WorkflowContext/Name, {3} = $WorkflowContext/Name) begin user task R 'R' outcomes 'Done' { }; end workflow;", + "{1} to {2}, each once"}, + {"in a nested task", + wfTemplateHead + "begin decision d1 $WorkflowContext/Ok outcomes true -> { user task R 'R' task name 'X {2}' with ({1} = $WorkflowContext/Name) outcomes 'Done' { }; } false -> { }; end workflow;", + "{2} but has 1"}, + // --- clean --- + {"filled", wfTemplateHead + "display 'Doc {1}' with ({1} = $WorkflowContext/Name) begin user task R 'R' task name 'Review {1} {1}' with ({1} = $WorkflowContext/Name) outcomes 'Done' { }; end workflow;", ""}, + {"an unused parameter", wfTemplateHead + "begin user task R 'R' description 'Due' with ({1} = $WorkflowContext/Name) outcomes 'Done' { }; end workflow;", ""}, + {"no templates", wfTemplateHead + "display 'Doc' begin user task R 'R' outcomes 'Done' { }; end workflow;", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + vs := wfRuleViolations(ValidateWorkflow(parseWorkflowStmt(t, c.src)), wfTemplateRule) + switch { + case c.want == "" && len(vs) > 0: + t.Errorf("reported a filled template: %v", vs) + case c.want != "" && (len(vs) == 0 || !strings.Contains(vs[0].Message+vs[0].Suggestion, c.want)): + t.Errorf("want a violation naming %q, got %v", c.want, vs) + } + }) + } +} + +// What a parameter may name, measured with mx check on 11.15.0: the context +// everywhere; $WorkflowInstance in a task's name and description but CE0117 in +// the workflow's display and description; $WorkflowUserTask and $currentUser +// CE0117 in all four (MDL-WF19). +func TestWorkflowTemplates_ExpressionScope(t *testing.T) { + cases := []struct{ name, src, want string }{ + {"instance in the display", wfTemplateHead + "display 'D {1}' with ({1} = $WorkflowInstance/Name) begin user task R 'R' outcomes 'Done' { }; end workflow;", "$WorkflowInstance"}, + {"instance in the description", wfTemplateHead + "description 'D {1}' with ({1} = $WorkflowInstance/Name) begin user task R 'R' outcomes 'Done' { }; end workflow;", "$WorkflowInstance"}, + {"user task in a task name", wfTemplateHead + "begin user task R 'R' task name 'T {1}' with ({1} = $WorkflowUserTask/Name) outcomes 'Done' { }; end workflow;", "$WorkflowUserTask"}, + {"current user in a description", wfTemplateHead + "begin user task R 'R' description 'T {1}' with ({1} = $currentUser/Name) outcomes 'Done' { }; end workflow;", "$currentUser"}, + {"instance in a task name", wfTemplateHead + "begin user task R 'R' task name 'T {1}' with ({1} = $WorkflowInstance/Name) outcomes 'Done' { }; end workflow;", ""}, + {"context in the display", wfTemplateHead + "display 'D {1}' with ({1} = $WorkflowContext/Name) begin user task R 'R' outcomes 'Done' { }; end workflow;", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + vs := wfRuleViolations(ValidateWorkflow(parseWorkflowStmt(t, c.src)), wfExprScopeRule) + switch { + case c.want == "" && len(vs) > 0: + t.Errorf("reported an expression in scope: %v", vs) + case c.want != "" && (len(vs) == 0 || !strings.Contains(vs[0].Message, c.want)): + t.Errorf("want a violation naming %q, got %v", c.want, vs) + } + }) + } +} + +// ALTER's texts are checked the same way. +func TestWorkflowTemplates_Alter(t *testing.T) { + alter := func(src string) *ast.AlterWorkflowStmt { + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + return prog.Statements[0].(*ast.AlterWorkflowStmt) + } + vs := ValidateAlterWorkflow(alter(`alter workflow M.W { set (TaskName: 'Check {1} {2}' with ({1} = $WorkflowContext/Name)) on Review; };`)) + if len(wfRuleViolations(vs, wfTemplateRule)) != 1 { + t.Errorf("task name {2} with one parameter: %v", vs) + } + vs = ValidateAlterWorkflow(alter(`alter workflow M.W { set (Display: 'D {1}' with ({1} = $WorkflowInstance/Name)); };`)) + if len(wfRuleViolations(vs, wfExprScopeRule)) != 1 { + t.Errorf("display with $WorkflowInstance: %v", vs) + } + vs = ValidateAlterWorkflow(alter(`alter workflow M.W { set (Display: 'D {1}' with ({1} = $WorkflowContext/Name)); set (Description: 'T {1}' with ({1} = $WorkflowInstance/Name)) on Review; };`)) + if len(wfRuleViolations(vs, wfTemplateRule))+len(wfRuleViolations(vs, wfExprScopeRule)) != 0 { + t.Errorf("control: %v", vs) + } +} + +// The builder writes the parameters in placeholder order, with the header's +// declared context name rewritten to $WorkflowContext like every workflow +// expression. +func TestWorkflowTemplates_BuilderNormalizesTheContext(t *testing.T) { + stmt := parseWorkflowStmt(t, `create workflow M.W parameter $Doc: M.Item + display 'For {1} {2}' with ({2} = $Doc/Amount, {1} = $doc/Name) +begin + user task R 'R' task name 'Review {1}' with ({1} = $Doc/Name) outcomes 'Done' { }; +end workflow;`) + wf := &workflows.Workflow{} + norm := newContextExprNormalizer(stmt.ParameterVar) + wf.WorkflowNameParams = norm.rewriteAll(workflowTemplateExprs(stmt.DisplayParams)) + if strings.Join(wf.WorkflowNameParams, ",") != "$WorkflowContext/Name,$WorkflowContext/Amount" { + t.Errorf("normalized display params = %q", wf.WorkflowNameParams) + } + task := buildUserTask(stmt.Activities[0].(*ast.WorkflowUserTaskNode)) + autoBindActivitiesInFlow(nil, []workflows.WorkflowActivity{task}, norm) + if task.TaskName != "Review {1}" || strings.Join(task.TaskNameParams, ",") != "$WorkflowContext/Name" { + t.Errorf("task name = %q %q", task.TaskName, task.TaskNameParams) + } +} diff --git a/mdl/executor/validate_workflow_unstatable.go b/mdl/executor/validate_workflow_unstatable.go index 7481db6413..aea2c8707e 100644 --- a/mdl/executor/validate_workflow_unstatable.go +++ b/mdl/executor/validate_workflow_unstatable.go @@ -5,8 +5,6 @@ package executor import ( "fmt" "strings" - - "github.com/mendixlabs/mxcli/mdl/ast" ) // Settings a Studio Pro author can give a workflow that MDL has no words for, so @@ -15,87 +13,16 @@ import ( // check; describe printed none of them, and replaying the description with // `create or modify` reported "Modified workflow" and left none of them: // -// a user task's task name 'Review {1}' (caption 'Review') reset to 'Review' -// its parameter {1} = $WorkflowContext/Name dropped +// a user task's task name 'Review {1}' (caption 'Review') reset to 'Review' (stated since #1714) +// its parameter {1} = $WorkflowContext/Name dropped (stated since #1714) // a timer boundary event's caption blanked (stated since #1715) // a non-interrupting timer's recurrence dropped (a one-shot reminder) // -// The rebuild takes a user task's name from its caption, writes a template's -// text without its parameters, and writes a timer boundary event without a -// recurrence. Each is refused until MDL can state it — the issue named after -// each reason adds the syntax. - -// workflowTemplateLabel names a stored string template by its key, as MDL calls -// the clause that states its text. -var workflowTemplateLabel = map[string]string{ - "WorkflowName": "display", - "WorkflowDescription": "description", - "TaskName": "task name", - "TaskDescription": "description", -} - -// rawTemplateParams describes a stored string template whose placeholders are -// filled from expressions — `'Review {1}' with ({1} = $WorkflowContext/Name)` — -// or returns "" when it has none. -func rawTemplateParams(v any) string { - st, ok := v.(map[string]any) - if !ok { - return "" - } - var params []string - for _, p := range rawList(st["Parameters"]) { - if pm, ok := p.(map[string]any); ok { - expr, _ := pm["Expression"].(string) - params = append(params, fmt.Sprintf("{%d} = %s", len(params)+1, expr)) - } - } - if len(params) == 0 { - return "" - } - text, _ := st["Text"].(string) - return fmt.Sprintf("'%s' with (%s)", text, strings.Join(params, ", ")) -} - -// rawWorkflowTemplateLosses lists the workflow's own templates whose parameters -// a rebuild drops. -func rawWorkflowTemplateLosses(raw map[string]any) []string { - var out []string - for _, k := range []string{"WorkflowName", "WorkflowDescription"} { - if p := rawTemplateParams(raw[k]); p != "" { - out = append(out, fmt.Sprintf("the workflow's %s %s fills its placeholders from expressions, which MDL cannot state yet (ako/mxcli#1714)", - workflowTemplateLabel[k], p)) - } - } - return out -} - -func isRawUserTask(d map[string]any) bool { - switch t, _ := d["$Type"].(string); t { - case "Workflows$SingleUserTaskActivity", "Workflows$MultiUserTaskActivity", "Workflows$UserTask": - return true - } - return false -} - -// rawUserTaskTemplateLosses lists what rebuilding a stored user task from a -// statement resets in its task name and description; caption is the caption the -// statement gives it, which the rebuild takes the task name from. -func rawUserTaskTemplateLosses(d map[string]any, caption string) []string { - var out []string - if tn, ok := d["TaskName"].(map[string]any); ok { - if text, _ := tn["Text"].(string); text != "" && text != caption { - out = append(out, fmt.Sprintf("its task name '%s' is not its caption '%s', and the rebuilt task takes its name from the caption (ako/mxcli#1714)", - text, caption)) - } - } - for _, k := range []string{"TaskName", "TaskDescription"} { - if p := rawTemplateParams(d[k]); p != "" { - out = append(out, fmt.Sprintf("its %s %s fills its placeholders from expressions, which MDL cannot state yet (ako/mxcli#1714)", - workflowTemplateLabel[k], p)) - } - } - return out -} +// The rebuild writes a timer boundary event without a recurrence, and a +// boundary event without its annotation; each is refused until MDL can state it +// — the issue named after the reason adds the syntax. A user task's task name +// and the texts' parameters are stated since ako/mxcli#1714 (`task name '…'`, +// `… with ({1} = …)`), and printed by describe. // rawBoundaryEventLosses lists what rebuilding a stored activity resets on its // boundary events: a timer's recurrence, and an annotation. A timer's caption @@ -136,26 +63,14 @@ func rawBoundaryEventKind(bm map[string]any) string { } // rawUnstatableWorkflowState lists, for CREATE OR MODIFY WORKFLOW, what the -// stored workflow holds that rebuilding it from stmt resets. A user task's name -// and description count only when the statement restates the task — dropping -// the task drops them on purpose — while boundary-event state counts wherever it -// is, since a boundary event the statement does not restate is refused already. -func rawUnstatableWorkflowState(raw map[string]any, stmt *ast.CreateWorkflowStmt) []string { - out := rawWorkflowTemplateLosses(raw) - captions := map[string]string{} // user task name -> the caption the statement gives it - walkWorkflowActivities(workflowStatementActivities(stmt), func(a ast.WorkflowActivityNode) { - if t, ok := a.(*ast.WorkflowUserTaskNode); ok { - captions[t.Name] = t.Caption - } - }) +// stored workflow holds that rebuilding it resets. Boundary-event state counts +// wherever it is, since a boundary event the statement does not restate is +// refused already. +func rawUnstatableWorkflowState(raw map[string]any) []string { + var out []string walkRawDocs(raw, func(d map[string]any) { name, _ := d["Name"].(string) - var reasons []string - if caption, restated := captions[name]; restated && isRawUserTask(d) { - reasons = append(reasons, rawUserTaskTemplateLosses(d, caption)...) - } - reasons = append(reasons, rawBoundaryEventLosses(d)...) - for _, r := range reasons { + for _, r := range rawBoundaryEventLosses(d) { out = append(out, fmt.Sprintf("activity '%s': %s", name, r)) } }) @@ -164,14 +79,6 @@ func rawUnstatableWorkflowState(raw map[string]any, stmt *ast.CreateWorkflowStmt // rawUnstatableActivityState lists, for REPLACE ACTIVITY, what the stored // activity holds that rebuilding it from the replacement resets. -func rawUnstatableActivityState(d map[string]any, replacement ast.WorkflowActivityNode) []string { - var out []string - if isRawUserTask(d) { - caption := "" - if t, ok := replacement.(*ast.WorkflowUserTaskNode); ok { - caption = t.Caption - } - out = append(out, rawUserTaskTemplateLosses(d, caption)...) - } - return append(out, rawBoundaryEventLosses(d)...) +func rawUnstatableActivityState(d map[string]any) []string { + return rawBoundaryEventLosses(d) } diff --git a/mdl/executor/validate_workflow_unstatable_test.go b/mdl/executor/validate_workflow_unstatable_test.go index 6eb239f061..8d3f03e0ae 100644 --- a/mdl/executor/validate_workflow_unstatable_test.go +++ b/mdl/executor/validate_workflow_unstatable_test.go @@ -60,18 +60,6 @@ func TestWorkflowRewrite_RefusesResettingUnstatableSettings(t *testing.T) { set func(wf, task, timer, reminder map[string]any) want string }{ - {"task name parameter", func(_, task, _, _ map[string]any) { - task["TaskName"].(map[string]any)["Parameters"] = templateParam("$WorkflowContext/Name") - }, "'Review' with ({1} = $WorkflowContext/Name)"}, - {"task name other than the caption", func(_, task, _, _ map[string]any) { - task["TaskName"].(map[string]any)["Text"] = "Review the request" - }, "task name 'Review the request' is not its caption 'Review'"}, - {"task description parameter", func(_, task, _, _ map[string]any) { - task["TaskDescription"].(map[string]any)["Parameters"] = templateParam("$WorkflowContext/Title") - }, "description '' with ({1} = $WorkflowContext/Title)"}, - {"workflow display parameter", func(wf, _, _, _ map[string]any) { - wf["WorkflowName"].(map[string]any)["Parameters"] = templateParam("$WorkflowContext/Title") - }, "display 'W' with ({1} = $WorkflowContext/Title)"}, {"recurrence", func(_, _, _, reminder map[string]any) { reminder["Recurrence"] = map[string]any{"$Type": "Workflows$LinearRecurrence", "Interval": int32(1), "IntervalType": "Day", "MaxExecutions": int32(5)} }, "repeats (every 1 Day, at most 5 times)"}, @@ -156,3 +144,20 @@ func TestWorkflowRewrite_AllowsATimerCaption(t *testing.T) { t.Errorf("a restated timer caption must be allowed, got %v", err) } } + +// ako/mxcli#1714: a task name, and the texts' parameters, are stated now, so a +// stored one no longer refuses the rewrite — describe prints them and the +// statement restates them. +func TestWorkflowRewrite_AllowsTemplateParameters(t *testing.T) { + wf, task, _, _ := unstatableWorkflow() + wf["WorkflowName"].(map[string]any)["Parameters"] = templateParam("$WorkflowContext/Title") + task["TaskName"].(map[string]any)["Text"] = "Review {1}" + task["TaskName"].(map[string]any)["Parameters"] = templateParam("$WorkflowContext/Name") + task["TaskDescription"].(map[string]any)["Parameters"] = templateParam("$WorkflowContext/Title") + restated := strings.Replace(wfUnstatableRestated, " display 'W'\n", " display 'W {1}' with ({1} = $WorkflowContext/Title)\n", 1) + restated = strings.Replace(restated, "page M.P outcomes", "page M.P task name 'Review {1}' with ({1} = $WorkflowContext/Name) "+ + "description '{1}' with ({1} = $WorkflowContext/Title) outcomes", 1) + if err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, wf), "wf1", "M.W", parseWorkflowStmt(t, restated)); err != nil { + t.Errorf("restated templates must be allowed, got %v", err) + } +} diff --git a/mdl/grammar/domains/MDLWorkflow.g4 b/mdl/grammar/domains/MDLWorkflow.g4 index 1e3a095cc4..a892dd7da9 100644 --- a/mdl/grammar/domains/MDLWorkflow.g4 +++ b/mdl/grammar/domains/MDLWorkflow.g4 @@ -32,8 +32,10 @@ createWorkflowStatement workflowHeaderClause : FOLDER folder=STRING_LITERAL | PARAMETER VARIABLE COLON qualifiedName - | DISPLAY display=STRING_LITERAL - | DESCRIPTION description=STRING_LITERAL + // `with ({1} = , …)` fills the placeholders, as a log message's + // template does (ako/mxcli#1714). + | DISPLAY display=STRING_LITERAL templateParams? + | DESCRIPTION description=STRING_LITERAL templateParams? // HIDDEN_KW is listed beside IDENTIFIER because `Hidden` used to lex as an // identifier and stopped when the microflow clauses made it a keyword. // Anything matching a bare IDENTIFIER here is one token away from the same @@ -214,7 +216,11 @@ workflowUserTaskClause | ON CREATED MICROFLOW qualifiedName | ENTITY qualifiedName | DUE DATE_TYPE workflowExpression - | DESCRIPTION STRING_LITERAL + | DESCRIPTION STRING_LITERAL templateParams? + // `task name '…'`: what the task inbox shows, when it is not the caption. + // `name` is not a keyword — it is an attribute name everywhere — so the + // word is read as an identifier and checked by the visitor (ako/mxcli#1714). + | TASK taskNameWord=IDENTIFIER STRING_LITERAL templateParams? | OUTCOMES workflowUserTaskOutcome+ | BOUNDARY EVENT workflowBoundaryEventClause ((BOUNDARY EVENT)? workflowBoundaryEventClause)* | AUTO ASSIGN SINGLE USER // "Auto-assign when targeting results in one user" @@ -438,6 +444,7 @@ alterWorkflowValue | XPATH xpathConstraint+ // Targeting: xpath [Active = true()] | XPATH STRING_LITERAL /* @alias MDL-DEPR031 */ // Targeting: xpath '[Active = true()]' | qualifiedName // Page: M.P, ExportLevel: API + | STRING_LITERAL templateParams // Display: 'x {1}' with ({1} = …) | workflowExpression // Display: 'x', DueDate: addDays(…) ; diff --git a/mdl/visitor/visitor_alter_workflow.go b/mdl/visitor/visitor_alter_workflow.go index f60fe30197..e064920c9d 100644 --- a/mdl/visitor/visitor_alter_workflow.go +++ b/mdl/visitor/visitor_alter_workflow.go @@ -44,13 +44,14 @@ var workflowPropertyKeys = map[string]string{ var activityPropertyKeys = map[string]string{ "page": "page", "description": "description", + "taskname": "task_name", "targeting": "targeting", "duedate": "due_date", } const ( workflowKeysText = "Display, Description, ExportLevel, DueDate, OverviewPage, Parameter" - activityKeysText = "Page, Description, Targeting, DueDate" + activityKeysText = "Page, Description, TaskName, Targeting, DueDate" ) // buildAlterWorkflowOperation builds one generic operation. A `set` list and a @@ -274,9 +275,24 @@ func alterWorkflowStringValue(v *parser.AlterWorkflowValueContext) (string, bool if we := v.WorkflowExpression(); we != nil && we.STRING_LITERAL() != nil { return unquoteStringLit(we.STRING_LITERAL()), true } + if v.TemplateParams() != nil && v.XPATH() == nil && v.STRING_LITERAL() != nil { + return unquoteStringLit(v.STRING_LITERAL()), true + } return "", false } +// alterWorkflowTemplateValue reads a text with the parameters that fill its +// placeholders: `Display: 'Approval for {1}' with ({1} = $WorkflowContext/Title)` +// (ako/mxcli#1714). A text written without them has none, so setting it clears +// any it had — the statement states the whole value. +func alterWorkflowTemplateValue(v *parser.AlterWorkflowValueContext) (string, []ast.WorkflowTemplateParam, bool) { + s, ok := alterWorkflowStringValue(v) + if !ok { + return "", nil, false + } + return s, buildWorkflowTemplateParams(v.TemplateParams()), true +} + // alterWorkflowExpressionValue reads an expression-valued property (a due // date): the expression written bare, or — the deprecated form, MDL-DEPR080 — // its string's content, as workflowExpression reads it everywhere else. @@ -299,12 +315,12 @@ func (b *Builder) buildWorkflowSet(key, prop string, v *parser.AlterWorkflowValu op := &ast.SetWorkflowPropertyOp{Property: prop} switch prop { case "display", "description": - s, ok := alterWorkflowStringValue(v) + s, params, ok := alterWorkflowTemplateValue(v) if !ok { - b.alterWorkflowValueError(key, "a string: `"+key+": '…'`") + b.alterWorkflowValueError(key, "a string: `"+key+": '…'`, or `"+key+": '… {1}' with ({1} = $WorkflowContext/Attr)`") return nil } - op.Value = s + op.Value, op.Params = s, params case "export_level": qn := v.QualifiedName() if qn == nil || v.VARIABLE() != nil || v.MICROFLOW() != nil || strings.Contains(qn.GetText(), ".") { @@ -350,13 +366,13 @@ func (b *Builder) buildActivitySet(key, prop string, v *parser.AlterWorkflowValu return nil } op.PageName = buildQualifiedName(qn) - case "description": - s, ok := alterWorkflowStringValue(v) + case "description", "task_name": + s, params, ok := alterWorkflowTemplateValue(v) if !ok { - b.alterWorkflowValueError(key, "a string: `Description: '…'`") + b.alterWorkflowValueError(key, "a string: `"+key+": '…'`, or `"+key+": '… {1}' with ({1} = $WorkflowContext/Attr)`") return nil } - op.Value = s + op.Value, op.Params = s, params case "targeting": switch { case v.MICROFLOW() != nil: diff --git a/mdl/visitor/visitor_workflow.go b/mdl/visitor/visitor_workflow.go index 3821bb895a..c55aaa9bd1 100644 --- a/mdl/visitor/visitor_workflow.go +++ b/mdl/visitor/visitor_workflow.go @@ -53,10 +53,12 @@ func (b *Builder) ExitCreateWorkflowStatement(ctx *parser.CreateWorkflowStatemen if tok := hc.GetDisplay(); tok != nil { stmt.DisplayName = unquoteStringLit(tok) } + stmt.DisplayParams = buildWorkflowTemplateParams(hc.TemplateParams()) case hc.DESCRIPTION() != nil: if tok := hc.GetDescription(); tok != nil { stmt.Description = unquoteStringLit(tok) } + stmt.DescriptionParams = buildWorkflowTemplateParams(hc.TemplateParams()) case hc.EXPORT() != nil && hc.LEVEL() != nil: // HIDDEN_KW is read alongside IDENTIFIER because `Hidden` was an // ordinary identifier here until the microflow header clauses made it @@ -696,6 +698,13 @@ func applyWorkflowUserTaskClause(node *ast.WorkflowUserTaskNode, clause parser.I if str := c.STRING_LITERAL(); str != nil { node.TaskDescription = unquoteStringLit(str) } + node.TaskDescriptionParams = buildWorkflowTemplateParams(c.TemplateParams()) + case c.TASK() != nil: + // The word after TASK is checked by ExitWorkflowUserTaskClause. + if str := c.STRING_LITERAL(); str != nil { + node.TaskName = unquoteStringLit(str) + } + node.TaskNameParams = buildWorkflowTemplateParams(c.TemplateParams()) case c.OUTCOMES() != nil: for _, outcomeCtx := range c.AllWorkflowUserTaskOutcome() { node.Outcomes = append(node.Outcomes, buildWorkflowUserTaskOutcome(outcomeCtx)) diff --git a/mdl/visitor/visitor_workflow_clauses.go b/mdl/visitor/visitor_workflow_clauses.go index f5b251aa78..b8b5bf8316 100644 --- a/mdl/visitor/visitor_workflow_clauses.go +++ b/mdl/visitor/visitor_workflow_clauses.go @@ -120,6 +120,8 @@ func workflowUserTaskClauseKind(c *parser.WorkflowUserTaskClauseContext) string return "DUE DATE" case c.DESCRIPTION() != nil: return "DESCRIPTION" + case c.TASK() != nil: + return "TASK NAME" case c.OUTCOMES() != nil: return "OUTCOMES" case c.AUTO() != nil: diff --git a/mdl/visitor/visitor_workflow_expressions.go b/mdl/visitor/visitor_workflow_expressions.go index c919c56ec1..791cd9b95c 100644 --- a/mdl/visitor/visitor_workflow_expressions.go +++ b/mdl/visitor/visitor_workflow_expressions.go @@ -3,6 +3,7 @@ package visitor import ( + "strconv" "strings" "unicode" @@ -150,3 +151,37 @@ func gluesToWord(s string) bool { } return false } + +// buildWorkflowTemplateParams reads the `with ({1} = , …)` that +// fills a workflow text's placeholders (ako/mxcli#1714). Each expression is kept +// as written, as workflowExpressionText keeps every workflow expression; the +// deprecated `parameters ['…', …]` numbers its values in order. +func buildWorkflowTemplateParams(ctx parser.ITemplateParamsContext) []ast.WorkflowTemplateParam { + if ctx == nil { + return nil + } + pc, ok := ctx.(*parser.TemplateParamsContext) + if !ok { + return nil + } + var out []ast.WorkflowTemplateParam + for _, p := range pc.AllTemplateParam() { + tp, ok := p.(*parser.TemplateParamContext) + if !ok || tp.NUMBER_LITERAL() == nil || tp.Expression() == nil { + continue // a syntax error, already reported + } + index, err := strconv.Atoi(tp.NUMBER_LITERAL().GetText()) + if err != nil { + continue + } + out = append(out, ast.WorkflowTemplateParam{Index: index, Expression: bareArgumentText(tp.Expression())}) + } + if arr := pc.ArrayLiteral(); arr != nil { + if al, ok := arr.(*parser.ArrayLiteralContext); ok { + for i, lit := range al.AllLiteral() { + out = append(out, ast.WorkflowTemplateParam{Index: i + 1, Expression: lit.GetText()}) + } + } + } + return out +} diff --git a/mdl/visitor/visitor_workflow_templates_test.go b/mdl/visitor/visitor_workflow_templates_test.go new file mode 100644 index 0000000000..35ac68923d --- /dev/null +++ b/mdl/visitor/visitor_workflow_templates_test.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#1714: a workflow's display and description, and a user task's task +// name and description, take `with ({1} = , …)`. Before, `with` +// after `display '…'` was "extraneous input 'WITH'". +func TestWorkflowVisitor_TextTemplates(t *testing.T) { + src := `create workflow M.WF + parameter $WorkflowContext: M.E + display 'Approval for {1} by {2}' with ({1} = $WorkflowContext/Title, {2} = $WorkflowContext/SubmittedBy) + description 'Worth {1}' with ({1} = toString($WorkflowContext/Value)) +begin + user task Review 'Review' + task name 'Review {1}' with ({1} = $WorkflowContext/Title) + description 'Started {1}' with ({1} = $WorkflowInstance/Name) + outcomes 'Done' { }; + multi user task Board 'Board' + task name 'Board' + outcomes 'Done' { }; +end workflow;` + wf := mustBuild(t, src).Statements[0].(*ast.CreateWorkflowStmt) + p := func(pairs ...string) []ast.WorkflowTemplateParam { + var out []ast.WorkflowTemplateParam + for i, e := range pairs { + out = append(out, ast.WorkflowTemplateParam{Index: i + 1, Expression: e}) + } + return out + } + check := func(what string, got, want any) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Errorf("%s = %#v, want %#v", what, got, want) + } + } + check("display", wf.DisplayName, "Approval for {1} by {2}") + check("display params", wf.DisplayParams, p("$WorkflowContext/Title", "$WorkflowContext/SubmittedBy")) + check("description params", wf.DescriptionParams, p("toString($WorkflowContext/Value)")) + review := wf.Activities[0].(*ast.WorkflowUserTaskNode) + check("task name", review.TaskName, "Review {1}") + check("task name params", review.TaskNameParams, p("$WorkflowContext/Title")) + check("task description params", review.TaskDescriptionParams, p("$WorkflowInstance/Name")) + board := wf.Activities[1].(*ast.WorkflowUserTaskNode) + check("multi task name", board.TaskName, "Board") + check("multi task name params", len(board.TaskNameParams), 0) +} + +// CONTROL: a workflow without the new clauses builds as before. +func TestWorkflowVisitor_TextTemplatesAbsent(t *testing.T) { + wf := mustBuild(t, "create workflow M.WF parameter $WorkflowContext: M.E display 'Plain' begin user task R 'R' outcomes 'Done' { }; end workflow;").Statements[0].(*ast.CreateWorkflowStmt) + r := wf.Activities[0].(*ast.WorkflowUserTaskNode) + if wf.DisplayName != "Plain" || wf.DisplayParams != nil || r.TaskName != "" || r.TaskNameParams != nil { + t.Errorf("built %+v / %+v", wf, r) + } +} + +// `task ` parses for any identifier; only `name` is a clause. +func TestWorkflowVisitor_TaskNameWordIsChecked(t *testing.T) { + _, errs := Build("create workflow M.WF parameter $WorkflowContext: M.E begin user task R 'R' task nam 'X' outcomes 'Done' { }; end workflow;") + if len(errs) == 0 || !strings.Contains(errs[0].Error(), "task name '…'") { + t.Errorf("want an error pointing at `task name`, got %v", errs) + } + if _, errs := Build("create workflow M.WF parameter $WorkflowContext: M.E begin user task R 'R' TASK NAME 'X' outcomes 'Done' { }; end workflow;"); len(errs) != 0 { + t.Errorf("`TASK NAME` in upper case must parse: %v", errs) + } + _, errs = Build("create workflow M.WF parameter $WorkflowContext: M.E begin user task R 'R' task name 'X' task name 'Y' outcomes 'Done' { }; end workflow;") + if len(errs) == 0 { + t.Error("a repeated `task name` must be refused like every other repeated clause") + } +} + +func TestAlterWorkflow_SetTextTemplates(t *testing.T) { + stmt := mustBuild(t, `alter workflow M.WF { + set (Display: 'Approve {1}' with ({1} = $WorkflowContext/Title), Description: 'plain'); + set (TaskName: 'Check {1}' with ({1} = $WorkflowContext/Title), Description: 'Due {1}' with ({1} = $WorkflowInstance/Name)) on Review; +};`).Statements[0].(*ast.AlterWorkflowStmt) + var got []string + for _, op := range stmt.Operations { + switch o := op.(type) { + case *ast.SetWorkflowPropertyOp: + got = append(got, o.Property+"="+o.Value+paramsText(o.Params)) + case *ast.SetActivityPropertyOp: + got = append(got, o.ActivityRef+"."+o.Property+"="+o.Value+paramsText(o.Params)) + } + } + want := []string{ + "display=Approve {1}[$WorkflowContext/Title]", + "description=plain", + "Review.task_name=Check {1}[$WorkflowContext/Title]", + "Review.description=Due {1}[$WorkflowInstance/Name]", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("ops\n got %q\nwant %q", got, want) + } +} + +func paramsText(ps []ast.WorkflowTemplateParam) string { + if len(ps) == 0 { + return "" + } + var parts []string + for _, p := range ps { + parts = append(parts, p.Expression) + } + return "[" + strings.Join(parts, ",") + "]" +} diff --git a/mdl/visitor/visitor_xpath_brackets.go b/mdl/visitor/visitor_xpath_brackets.go index e4fc0fbb26..8823e30335 100644 --- a/mdl/visitor/visitor_xpath_brackets.go +++ b/mdl/visitor/visitor_xpath_brackets.go @@ -3,6 +3,7 @@ package visitor import ( + "fmt" "strings" "github.com/antlr4-go/antlr/v4" @@ -212,6 +213,15 @@ func (b *Builder) ExitWorkflowUserTaskClause(ctx *parser.WorkflowUserTaskClauseC if ctx.TARGETING() != nil && ctx.XPATH() != nil { b.recordQuotedTargetingXPath(ctx.STRING_LITERAL()) } + // `task name '…'`: `name` is read as an identifier, so any other word + // parses here and is refused (ako/mxcli#1714). + if ctx.TASK() != nil { + if w := ctx.GetTaskNameWord(); w != nil && !strings.EqualFold(w.GetText(), "name") { + b.addErrorWithExample(fmt.Sprintf("line %d:%d: a user task's `task %s` is not a clause — did you mean `task name '…'`, the text the task inbox shows?", + w.GetLine(), w.GetColumn(), w.GetText()), + "user task Review 'Review'\n task name 'Review {1}' with ({1} = $WorkflowContext/Title)\n outcomes 'Done' { };") + } + } } // ExitActivitySetProperty records `alter workflow … set activity … targeting diff --git a/sdk/workflows/workflow.go b/sdk/workflows/workflow.go index a2ac21bfd9..a58ea00b67 100644 --- a/sdk/workflows/workflow.go +++ b/sdk/workflows/workflow.go @@ -19,6 +19,9 @@ type Workflow struct { Excluded bool `json:"excluded"` WorkflowName string `json:"workflowName,omitempty"` // Template string for display name WorkflowDescription string `json:"workflowDescription,omitempty"` // Template string for description + // The expressions filling {1}, {2}, … of the two templates, in order. + WorkflowNameParams []string `json:"workflowNameParams,omitempty"` + WorkflowDescriptionParams []string `json:"workflowDescriptionParams,omitempty"` // OverviewPage is the qualified name of the workflow's overview page — the // `overview page` clause. It is stored under the BSON key **AdminPage**, as a @@ -152,10 +155,13 @@ type UserTask struct { Outcomes []*UserTaskOutcome `json:"outcomes,omitempty"` // Task outcomes TaskName string `json:"taskName,omitempty"` // Template string for task display name TaskDescription string `json:"taskDescription,omitempty"` // Template string for task description - DueDate string `json:"dueDate,omitempty"` // Due date expression - UserTaskEntity string `json:"userTaskEntity,omitempty"` // Qualified name of user task entity - OnCreated string `json:"onCreated,omitempty"` // Microflow called on task creation - BoundaryEvents []*BoundaryEvent `json:"boundaryEvents,omitempty"` // Boundary events (e.g., timers) + // The expressions filling {1}, {2}, … of the two templates, in order. + TaskNameParams []string `json:"taskNameParams,omitempty"` + TaskDescriptionParams []string `json:"taskDescriptionParams,omitempty"` + DueDate string `json:"dueDate,omitempty"` // Due date expression + UserTaskEntity string `json:"userTaskEntity,omitempty"` // Qualified name of user task entity + OnCreated string `json:"onCreated,omitempty"` // Microflow called on task creation + BoundaryEvents []*BoundaryEvent `json:"boundaryEvents,omitempty"` // Boundary events (e.g., timers) // AutoAssignSingleTargetUser is Studio Pro's "Auto-assign when targeting // results in one user". Off by default, as Studio Pro creates a task. From 4e4afeccd1421ea5b2ce441259cc3bbf88a0bcb1 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 26 Oct 2026 21:28:39 +0000 Subject: [PATCH 33/40] fix: check misses CE1406 for a retrieve or create of a Hidden entity of a Protected module (ako/mxcli#1699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flow in an app module that ran `retrieve $l from AddOn.Internal`, `$o = create AddOn.Internal` or `$l = create list of AddOn.Internal` on a Hidden entity of a Protected (add-on) module passed `check -p --references` and exec, and mxbuild refused it with CE1406 "Retrieve object(s) activity / Create object activity / Create list activity … is not allowed to use entity 'AddOn.Internal' because app modules cannot reference hidden elements in add-on modules" (upstream mendixlabs/mxcli#1652). Cause: validateProtectedModuleRefs judged an entity only in a flow's signature. checkFlowMembers judged the MEMBERS of a create or retrieve, never the entity itself, so an activity naming nothing but its entity reached no check. Fix: checkFlowMembers calls the existing entityRef for the entity of a create object, a database retrieve and a create list, in the walkTypedFlow visitor that already runs only for flows outside the Protected module. It is the one verdict check and exec's preflight both run, so exec now refuses the script before writing it. Verification: - Measured on Mendix 11.13.0 (module set Protected via UpdateModuleSettings, one microflow per shape): retrieve (list, limit 1), create (bare, with members) and create list of a Hidden entity are refused in microflows and nanoflows; the same against a Usable entity and inside the add-on are 0 errors; an association retrieve over a Usable association to a Hidden entity, and commit/delete/count of such an object, are not refused. - Tests in TestValidateProtectedModuleRefs, with Usable, in-module, ordinary module, script-made-usable and unknown-entity controls; reverting the fix fails the 8 refused cases with no error reported. - Real 11.13.0 app: the issue's script passed check (baseline) and mx check reported 3 CE1406; with the fix check reports the same 3 and exec refuses; with the hint's `alter entity … set export level api` check passes and mx check reports 0 errors. Co-Authored-By: Claude Opus 5.5 --- ...-hidden-entity-retrieve-create-ce1406.json | 1 + .../write-microflows/reference/pitfalls.md | 5 +- CHANGELOG.md | 1 + cmd/mxcli/syntax/features_domain_model.go | 3 +- ...ess-1699-hidden-entity-retrieve-create.mdl | 73 +++++++++++++++++++ .../validate_protected_module_members.go | 23 +++++- .../validate_protected_module_refs.go | 8 +- .../validate_protected_module_refs_test.go | 41 +++++++++++ 8 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-protected-module-hidden-entity-retrieve-create-ce1406.json create mode 100644 mdl-examples/bug-tests/access-1699-hidden-entity-retrieve-create.mdl diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-protected-module-hidden-entity-retrieve-create-ce1406.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-protected-module-hidden-entity-retrieve-create-ce1406.json new file mode 100644 index 0000000000..3c5032ca9b --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-protected-module-hidden-entity-retrieve-create-ce1406.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-26","symptom":"`check -p --references` passed a flow in an app module that ran a database `retrieve … from AddOn.Internal`, a `create AddOn.Internal` or a `create list of AddOn.Internal` on a Hidden entity of a Protected (add-on) module; exec wrote it and mxbuild refused CE1406 \"Retrieve object(s) activity … / Create object activity … / Create list activity … is not allowed to use entity 'AddOn.Internal' because app modules cannot reference hidden elements in add-on modules\" (ako/mxcli#1699, mendixlabs/mxcli#1652).","cause":"validateProtectedModuleRefs judged an entity only in a flow's signature (checkFlow → entityRef on parameter and return types); the body walk collected pages and microflows, and checkFlowMembers judged only the MEMBERS of a create or retrieve — so an activity naming nothing but the entity reached no check.","fix":"checkFlowMembers (validate_protected_module_members.go) calls the existing entityRef for the entity of a CreateObjectStmt, of a database RetrieveStmt (no StartVariable) and of a CreateListStmt, inside the walkTypedFlow visitor that already runs only for flows outside the Protected module — one verdict, which check and exec's preflight both run.","file":"mdl/executor/validate_protected_module_members.go","insight":"Enumerate the activities that NAME an element, not just the expressions that read one: CE1406 is 'any use', and the earlier fix covered members and signatures but no activity whose only reference is its entity. Measured on 11.13.0 with the module set Protected through UpdateModuleSettings (a 30-line helper; MDL cannot make a module Protected), one microflow per shape because mx check names the activity but not the document: retrieve (list, limit 1, where), create (bare, with members), create list — each refused in microflows and nanoflows, the Usable entity and in-module use clean. NOT refused, so do not add them: an association retrieve over a Usable association whose far end is Hidden, and commit/delete/count of such an object (they name no entity). A flow in ANOTHER Protected module is refused too ('add-on modules cannot reference hidden elements in add-on modules'), which the checker still skips for every shape — a sibling gap.","refs":["ako/mxcli#1699","mendixlabs/mxcli#1652","mendixlabs/mxcli#1498"],"ce":["CE1406"]} diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index 0bfbfd069c..fbf94b132b 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -793,7 +793,10 @@ end; that module is **CE5400**. `check -p --references` reports both (mendixlabs/mxcli#1498), including a flow's use of a Hidden attribute or association — a change / create member, an association retrieve, an XPath - step or a `$v/…` path — with the `alter` statement that makes it usable. + step or a `$v/…` path — and the Hidden entity a database `retrieve`, a + `create` or a `create list of` names (ako/mxcli#1699), with the `alter` + statement that makes it usable. Inside the Protected module itself every one + of these is fine. Page widgets binding a Hidden member are not checked yet. A page there cannot be made usable (mxbuild refuses it even when stored as API) — reach it through an `api` microflow in the module that shows it. Entities, attributes, diff --git a/CHANGELOG.md b/CHANGELOG.md index b291e68f24..bccf2c8840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`check -p --references` reports CE1406 for a retrieve, create or create list of a Hidden entity of a Protected module** (ako/mxcli#1699, mendixlabs/mxcli#1652) — a flow in an app module that ran `retrieve $l from AddOn.Internal`, `$o = create AddOn.Internal` or `$l = create list of AddOn.Internal` on a Hidden entity of a Protected (add-on) module passed `check` and `exec`, and mxbuild refused it: `[CE1406] "Retrieve object(s) activity 'Retrieve list of Internal from database' is not allowed to use entity 'AddOn.Internal' because app modules cannot reference hidden elements in add-on modules."` (likewise "Create object activity", "Create list activity"). The check judged an entity only as a parameter or return type; it now judges the entity these three activities name, in microflows and nanoflows, and `exec` refuses the script before writing it. Measured on Mendix 11.13.0: each shape refused against a Hidden entity, 0 errors against a Usable one and inside the add-on; an association retrieve that reaches a Hidden entity over a Usable association names only the association and is not refused. - **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. diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index 906574a726..691a0b0809 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -439,7 +439,8 @@ func init() { " association or microflow of it.\n" + "In an ordinary module mxbuild accepts either level. `check -p --references`\n" + "reports both, a flow's attribute and association uses included (change /\n" + - "create members, association retrieves, XPath, `$v/…` expressions).\n\n" + + "create members, association retrieves, XPath, `$v/…` expressions), and the\n" + + "entity a database retrieve, create or create list names.\n\n" + "An ABSENT clause keeps the stored level, so re-running describe output or a\n" + "`create or modify` that says nothing about it never hides a usable element.\n" + "`describe` prints `export level api` and omits the default. Needs Mendix 9.3+.", diff --git a/mdl-examples/bug-tests/access-1699-hidden-entity-retrieve-create.mdl b/mdl-examples/bug-tests/access-1699-hidden-entity-retrieve-create.mdl new file mode 100644 index 0000000000..40d25403a6 --- /dev/null +++ b/mdl-examples/bug-tests/access-1699-hidden-entity-retrieve-create.mdl @@ -0,0 +1,73 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1699 (mendixlabs/mxcli#1652) — a database retrieve, a create or a +-- create list of a Hidden entity of a Protected (add-on) module (CE1406) +-- ============================================================================ +-- +-- `check -p --references` reported CE1406 for a Hidden entity used as a +-- parameter type, and for a Hidden attribute or association a flow uses — but +-- never for the ENTITY an activity names. A flow in an app module that +-- retrieved or created a Hidden entity passed check and exec, and mxbuild +-- refused it: +-- +-- Retrieve object(s) activity 'Retrieve list of Internal from database' is +-- not allowed to use entity 'BugAccess1699.Internal' because app modules +-- cannot reference hidden elements in add-on modules. (CE1406) +-- Create object activity 'Create Internal' … (CE1406) +-- Create list activity 'Create list of Internal' … (CE1406) +-- +-- Measured on a blank Mendix 11.13.0 app, the module set to Protected, one +-- microflow per shape: the three below are refused in a microflow and in a +-- nanoflow, with or without a where / limit / member list; the same against +-- the Usable Request are 0 errors, and so are all of them inside the add-on. +-- Not refused: an association retrieve over a Usable association to a Hidden +-- entity, or a commit / delete / count of such an object — they do not name +-- the entity. A flow in ANOTHER Protected module is refused as well ("add-on +-- modules cannot reference …"); check does not judge that case. +-- +-- How to reproduce: run this file against an app, set BugAccess1699 to +-- Protected (Studio Pro: module settings, export level "Protected", type +-- Add-on), then run it again with `mxcli check … -p app.mpr --references`: +-- three CE1406 (statements 4–6), and exec refuses. As an ordinary module the +-- whole file is valid, which is what `make check-mdl` runs. +-- ============================================================================ + +CREATE MODULE BugAccess1699; + +CREATE OR MODIFY PERSISTENT ENTITY BugAccess1699.Request ( + Code: String(50) EXPORT LEVEL api, + Secret: String(50) +) EXPORT LEVEL api; +CREATE OR MODIFY PERSISTENT ENTITY BugAccess1699.Internal ( Note: String(50) ); + +-- Refused once the module is Protected: Internal is Hidden. +CREATE OR MODIFY MICROFLOW MyFirstModule.Bug1699_RetrieveHidden () +RETURNS Boolean +BEGIN + RETRIEVE $Internals FROM BugAccess1699.Internal; + RETURN true; +END; + +CREATE OR MODIFY MICROFLOW MyFirstModule.Bug1699_CreateHidden () +RETURNS Boolean +BEGIN + $Internal = CREATE BugAccess1699.Internal; + RETURN $Internal != empty; +END; + +CREATE OR MODIFY MICROFLOW MyFirstModule.Bug1699_CreateListHidden () +RETURNS Boolean +BEGIN + $Internals = CREATE LIST OF BugAccess1699.Internal; + RETURN true; +END; + +-- Control: the same three against the Usable Request stay clean. +CREATE OR MODIFY MICROFLOW MyFirstModule.Bug1699_UsableControls () +RETURNS Boolean +BEGIN + RETRIEVE $Requests FROM BugAccess1699.Request; + $Request = CREATE BugAccess1699.Request; + $More = CREATE LIST OF BugAccess1699.Request; + RETURN true; +END; diff --git a/mdl/executor/validate_protected_module_members.go b/mdl/executor/validate_protected_module_members.go index 97f0fa5b60..0048b9efb2 100644 --- a/mdl/executor/validate_protected_module_members.go +++ b/mdl/executor/validate_protected_module_members.go @@ -24,6 +24,18 @@ import ( // retrieve … where Payload = … Retrieve … attribute (and an association step) // return $r/Payload, $i/AddOn.Assoc End event … attribute / association // +// The ENTITY an activity names is the same rule (ako/mxcli#1699). Measured on +// 11.13.0 the same way, a Hidden AddOn.Internal beside a Usable AddOn.Request, +// in microflows and nanoflows alike: +// +// retrieve $l from AddOn.Internal [where …|limit 1] Retrieve object(s) activity … entity +// $o = create AddOn.Internal [(…)] Create object activity … entity +// $l = create list of AddOn.Internal Create list activity … entity +// +// and NOT refused: an association retrieve over a Usable association whose far +// end is Hidden, or a commit, delete or aggregate of such an object — none of +// them names the entity. +// // A member is reported only when it is established: its variable's entity is // known, the member resolves on it (an association hop through // associationTargetFrom), and its level is known — a level the script states, @@ -55,6 +67,7 @@ func (r *protectedRefResolver) checkFlowMembers(name ast.QualifiedName, params [ switch s := st.(type) { case *ast.CreateObjectStmt: who := "create " + s.EntityType.String() + out = appendNonEmpty(out, r.entityRef(who, s.EntityType, "app modules")) for _, ch := range s.Changes { m, ok := r.changeMember(s.EntityType.String(), ch.Attribute) report(who, m, ok) @@ -71,13 +84,21 @@ func (r *protectedRefResolver) checkFlowMembers(name ast.QualifiedName, params [ who := "retrieve $" + s.Variable if s.StartVariable != "" { report(who, protectedMember{kind: "association", qn: s.Source.String()}, s.Source.Module != "") - } else if s.Where != nil { + break + } + // A database retrieve names its entity (ako/mxcli#1699). One over a + // Usable association to a Hidden entity names only the association, + // and is not refused (measured). + out = appendNonEmpty(out, r.entityRef(who, s.Source, "app modules")) + if s.Where != nil { for _, steps := range xpathMemberPaths(s.Where) { for _, m := range r.pathMembers(s.Source.String(), steps) { report(who, m, true) } } } + case *ast.CreateListStmt: + out = appendNonEmpty(out, r.entityRef("create list $"+s.Variable, s.EntityType, "app modules")) case *ast.ReturnStmt: exprs("the end event", vars, s.Value) case *ast.DeclareStmt: diff --git a/mdl/executor/validate_protected_module_refs.go b/mdl/executor/validate_protected_module_refs.go index 9744c830c3..30aad8fca1 100644 --- a/mdl/executor/validate_protected_module_refs.go +++ b/mdl/executor/validate_protected_module_refs.go @@ -33,12 +33,16 @@ import ( // // Covered shapes: navigation home pages, home flows, login / not-found pages and // menu items; menu documents; microflow and nanoflow parameter and return -// types, call microflow and show page in their bodies; and the attributes and +// types, call microflow and show page in their bodies; and the entities a +// database retrieve, create object and create list name and the attributes and // associations a flow uses (checkFlowMembers). Everything else is left // alone, and so is anything whose level cannot be established — settings that // do not read, a target that does not resolve (the not-found checks own that), // a nanoflow target (mxcli does not read a nanoflow's export level), or a -// reference between two Protected modules (not measured). +// reference from one Protected module into another — which mxbuild does refuse +// ("add-on modules cannot reference hidden elements in add-on modules", +// measured on 11.13.0 for a retrieve, a create and a member read), and which +// this check does not judge yet. func validateProtectedModuleRefs(ctx *ExecContext, prog *ast.Program) []error { if prog == nil || !ctx.Connected() || !hasProtectedModuleCandidates(prog) { return nil diff --git a/mdl/executor/validate_protected_module_refs_test.go b/mdl/executor/validate_protected_module_refs_test.go index bf5decf230..7cfcd7bb75 100644 --- a/mdl/executor/validate_protected_module_refs_test.go +++ b/mdl/executor/validate_protected_module_refs_test.go @@ -266,6 +266,47 @@ func TestValidateProtectedModuleRefs(t *testing.T) { []string{"statement 2", "CE1406", "association 'MyAddOn.Pub_Fresh'"}}, {"inside the add-on a hidden member is fine", "create microflow MyAddOn.Inner ($p: MyAddOn.Pub) begin change $p (Inner = 'x'); retrieve $q from $p/MyAddOn.Pub_Pub2; end;", nil}, + + // --- ako/mxcli#1699: the ENTITY a database retrieve, a create object or a + // create list names (measured 11.13.0, one microflow per shape: each + // refused into the Hidden AddOn.Internal, the Usable AddOn.Request + // beside it clean, all of them clean inside the add-on) + {"retrieve of hidden entity", + "create microflow MyFirstModule.R () begin retrieve $l from MyAddOn.Request; end;", + []string{"CE1406", "retrieve $l is not allowed to use entity 'MyAddOn.Request'", "app modules cannot reference hidden elements", + "alter entity MyAddOn.Request set export level api"}}, + {"retrieve first of hidden entity", + "create microflow MyFirstModule.R () begin retrieve $o from MyAddOn.Request limit 1; end;", + []string{"CE1406", "entity 'MyAddOn.Request'"}}, + {"retrieve of hidden entity with a where", + "create microflow MyFirstModule.R () begin retrieve $l from MyAddOn.Secret where id = empty; end;", + []string{"CE1406", "entity 'MyAddOn.Secret'"}}, + {"retrieve of usable entity", "create microflow MyFirstModule.R () begin retrieve $l from MyAddOn.Pub; end;", nil}, + {"bare create of hidden entity", + "create microflow MyFirstModule.C () begin $r = create MyAddOn.Request; end;", + []string{"CE1406", "create MyAddOn.Request is not allowed to use entity 'MyAddOn.Request'"}}, + {"bare create of usable entity", "create microflow MyFirstModule.C () begin $r = create MyAddOn.Pub; end;", nil}, + {"create list of hidden entity", + "create microflow MyFirstModule.C () begin $l = create list of MyAddOn.Request; end;", + []string{"CE1406", "create list $l is not allowed to use entity 'MyAddOn.Request'"}}, + {"create list of usable entity", "create microflow MyFirstModule.C () begin $l = create list of MyAddOn.Pub; end;", nil}, + {"nanoflow create of hidden entity", + "create nanoflow MyFirstModule.N () begin $r = create MyAddOn.Request; end;", + []string{"CE1406", "entity 'MyAddOn.Request'"}}, + {"hidden entity in a nested block", + "create microflow MyFirstModule.C () begin if true then retrieve $l from MyAddOn.Request; end if; end;", + []string{"CE1406", "entity 'MyAddOn.Request'"}}, + {"inside the add-on a hidden entity is fine", + "create microflow MyAddOn.Inner () begin retrieve $l from MyAddOn.Request; $r = create MyAddOn.Request; $k = create list of MyAddOn.Request; end;", nil}, + {"open module hidden entity retrieve and create", + "create microflow MyFirstModule.C () begin retrieve $l from Open.E; $e = create Open.E; end;", nil}, + {"script makes the entity usable", + "alter entity MyAddOn.Request set export level api;\ncreate microflow MyFirstModule.C () begin retrieve $l from MyAddOn.Request; $r = create MyAddOn.Request; end;", nil}, + {"script-created entity is hidden", + "create persistent entity MyAddOn.Fresh (X: String(10));\ncreate microflow MyFirstModule.C () begin $f = create MyAddOn.Fresh; end;", + []string{"statement 2", "CE1406", "entity 'MyAddOn.Fresh'"}}, + {"unknown entity is the not-found check's", + "create microflow MyFirstModule.C () begin retrieve $l from MyAddOn.Nope; $n = create MyAddOn.Nope; end;", nil}, {"open module hidden attribute", "create microflow MyFirstModule.C ($e: Open.E) begin change $e (Inner = 'x'); end;", nil}, {"untyped variable is silent", "create microflow MyFirstModule.C () begin change $nobody (Inner = 'x'); end;", nil}, From 97ccc02373b9db7da437e4788abdf0d27bc629e6 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 26 Oct 2026 21:39:41 +0000 Subject: [PATCH 34/40] fix: check does not type a call microflow result, so a Hidden attribute read on it passes (ako/mxcli#1701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$r = call microflow AddOn.Request_GetFirst(); declare $s String = $r/Secret;` in an app module passed `check -p --references` and exec while Secret was a Hidden attribute of a Protected (add-on) module, and mxbuild refused it with CE1406 "Create variable activity 'Create String variable' is not allowed to use attribute 'AddOn.Request.Secret' because app modules cannot reference hidden elements in add-on modules" (upstream mendixlabs/mxcli#1654). The same read through a parameter was reported. Cause: walkTypedFlow, the one variable-typing walk the --references member checks share, typed parameters, create, retrieve and loop iterators but not the output variable of `call microflow`. `$r/Secret` had no entity, so both the Protected-module member check and the member-existence check were silent on every call result. Fix: walkTypedFlow takes the scriptContext validateProgram already builds and types a call's output from the callee's object or list return type (scriptContext.microflowReturnEntity): the script's own definition of the callee first, since a `create or modify` changes the signature exec writes, else the stored microflow, read by name and cached per pass. An unresolvable callee leaves the variable untyped. validateProtectedModuleRefs receives the sc. Same shared verdict for check and exec's preflight. Verification: - Measured on Mendix 11.13.0 (module set Protected, one microflow per shape): a declare, an end event and a change on a call result, and a change on a loop over a returned list, each CE1406 on the Hidden attribute; the Usable attribute and the same reads inside the add-on 0 errors. - Tests: TestValidateProtectedModuleRefs (object, list/loop, association, script-created callee, script rewrite of the return type; controls for a Usable attribute, in-module, ordinary module, non-object and unknown callee) and validate_call_result_typing_test.go for the existence check. Removing the typing fails 8 cases with nothing reported; disabling the script-first branch makes the existence check falsely report a member of the callee's NEW return type as missing. - Real 11.13.0 app: the issue's script passed check (baseline) and mx check reported 2 CE1406; with the fix check reports the same 2 and exec refuses; with the hint's `modify attribute … export level api` check passes and mx check reports 0 errors. Co-Authored-By: Claude Opus 5.5 --- ...esult-untyped-hidden-attribute-ce1406.json | 1 + .../write-microflows/reference/pitfalls.md | 4 +- CHANGELOG.md | 1 + cmd/mxcli/syntax/features_domain_model.go | 5 +- ...cess-1701-call-result-hidden-attribute.mdl | 62 ++++++++++++++ mdl/executor/validate.go | 7 +- .../validate_call_result_typing_test.go | 81 +++++++++++++++++++ mdl/executor/validate_member_refs.go | 58 ++++++++++++- .../validate_protected_module_members.go | 9 ++- .../validate_protected_module_refs.go | 4 +- .../validate_protected_module_refs_test.go | 67 ++++++++++++++- 11 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-call-result-untyped-hidden-attribute-ce1406.json create mode 100644 mdl-examples/bug-tests/access-1701-call-result-hidden-attribute.mdl create mode 100644 mdl/executor/validate_call_result_typing_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-call-result-untyped-hidden-attribute-ce1406.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-call-result-untyped-hidden-attribute-ce1406.json new file mode 100644 index 0000000000..e13ac12ec4 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-call-result-untyped-hidden-attribute-ce1406.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-26","symptom":"`check -p --references` passed `$r = call microflow AddOn.Request_GetFirst(); declare $s String = $r/Secret;` in an app module while Secret was a Hidden attribute of a Protected (add-on) module; exec wrote it and mxbuild refused CE1406 \"Create variable activity 'Create String variable' is not allowed to use attribute 'AddOn.Request.Secret' because app modules cannot reference hidden elements in add-on modules\". The same read through a parameter was reported (ako/mxcli#1701, mendixlabs/mxcli#1654).","cause":"walkTypedFlow — the one variable-typing walk the --references member checks share — typed parameters, create, retrieve and loop iterators but not the output variable of `call microflow`, so `$r/Secret` had no entity and pathMembers(\"\") returned nothing; the member-existence check (checkFlowMembers in validate_member_refs.go) was blind on call results the same way.","fix":"walkTypedFlow takes the scriptContext and types a call's output from the callee's object/list return type via scriptContext.microflowReturnEntity: the script's own definition first (sc.microflows + sc.flowParams), else the stored microflow read by name (microflowsNamed + hierarchy module match), cached per validation pass. validateProtectedModuleRefs now receives the sc validateProgram already builds.","file":"mdl/executor/validate_member_refs.go","insight":"Typing a variable is where a member check's reach is decided — a member check is only as wide as the walk that types its variables, and it fails silent, so widening the walk is the fix, not another check. But a type is a claim: typing from the STORED callee when the script rewrites it with another return type would judge members against the old entity and refuse a correct script (proved by stubbing the script-first branch: the existence check then reported 'Shop.Order has no member Name' for a callee rewritten to return Shop.Customer). So the script's signature must win, and an unresolvable callee must leave the variable untyped. Measured on 11.13.0 (module set Protected via UpdateModuleSettings, one microflow per shape): declare, end event, change on a call result and change on a loop over a returned list all CE1406; the Usable attribute clean.","refs":["ako/mxcli#1701","mendixlabs/mxcli#1654","mendixlabs/mxcli#1498"],"ce":["CE1406","CE1613"]} diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index fbf94b132b..6dc3430904 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -793,7 +793,9 @@ end; that module is **CE5400**. `check -p --references` reports both (mendixlabs/mxcli#1498), including a flow's use of a Hidden attribute or association — a change / create member, an association retrieve, an XPath - step or a `$v/…` path — and the Hidden entity a database `retrieve`, a + step or a `$v/…` path, on a parameter, a retrieved or created object, a loop + iterator or a `call microflow` result (ako/mxcli#1701) — and the Hidden + entity a database `retrieve`, a `create` or a `create list of` names (ako/mxcli#1699), with the `alter` statement that makes it usable. Inside the Protected module itself every one of these is fine. diff --git a/CHANGELOG.md b/CHANGELOG.md index bccf2c8840..d635f89cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`check -p --references` types the result of `call microflow`, so a Hidden attribute read on it is reported (CE1406)** (ako/mxcli#1701, mendixlabs/mxcli#1654) — `$Request = call microflow AddOn.Request_GetFirst(); declare $Secret String = $Request/Secret;` in an app module passed `check` and `exec` while `Secret` was a Hidden attribute of a Protected (add-on) module, and mxbuild refused it: `[CE1406] "Create variable activity 'Create String variable' is not allowed to use attribute 'AddOn.Request.Secret' because app modules cannot reference hidden elements in add-on modules."` The same read through a parameter was reported. The variable-typing walk the `--references` member checks share typed parameters, `create`, `retrieve` and loop iterators but not a call's output; it now takes the callee's object or list return type — the script's own definition of the callee first (a `create or modify` changes what exec writes), else the stored microflow. The member-existence check gains the same reach: a CHANGE on a call result naming a member the returned entity does not have now reports CE1613 instead of passing. Measured on Mendix 11.13.0: a declare, an end event and a change on the result, and a change on a loop over a returned list, each refused on the Hidden attribute; the Usable one beside it is 0 errors. - **`check -p --references` reports CE1406 for a retrieve, create or create list of a Hidden entity of a Protected module** (ako/mxcli#1699, mendixlabs/mxcli#1652) — a flow in an app module that ran `retrieve $l from AddOn.Internal`, `$o = create AddOn.Internal` or `$l = create list of AddOn.Internal` on a Hidden entity of a Protected (add-on) module passed `check` and `exec`, and mxbuild refused it: `[CE1406] "Retrieve object(s) activity 'Retrieve list of Internal from database' is not allowed to use entity 'AddOn.Internal' because app modules cannot reference hidden elements in add-on modules."` (likewise "Create object activity", "Create list activity"). The check judged an entity only as a parameter or return type; it now judges the entity these three activities name, in microflows and nanoflows, and `exec` refuses the script before writing it. Measured on Mendix 11.13.0: each shape refused against a Hidden entity, 0 errors against a Usable one and inside the add-on; an association retrieve that reaches a Hidden entity over a Usable association names only the association and is not refused. - **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. diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index 691a0b0809..08768d22a1 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -439,8 +439,9 @@ func init() { " association or microflow of it.\n" + "In an ordinary module mxbuild accepts either level. `check -p --references`\n" + "reports both, a flow's attribute and association uses included (change /\n" + - "create members, association retrieves, XPath, `$v/…` expressions), and the\n" + - "entity a database retrieve, create or create list names.\n\n" + + "create members, association retrieves, XPath, `$v/…` expressions — on a\n" + + "call microflow result too), and the entity a database retrieve, create or\n" + + "create list names.\n\n" + "An ABSENT clause keeps the stored level, so re-running describe output or a\n" + "`create or modify` that says nothing about it never hides a usable element.\n" + "`describe` prints `export level api` and omits the default. Needs Mendix 9.3+.", diff --git a/mdl-examples/bug-tests/access-1701-call-result-hidden-attribute.mdl b/mdl-examples/bug-tests/access-1701-call-result-hidden-attribute.mdl new file mode 100644 index 0000000000..d169fefa4d --- /dev/null +++ b/mdl-examples/bug-tests/access-1701-call-result-hidden-attribute.mdl @@ -0,0 +1,62 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1701 (mendixlabs/mxcli#1654) — a Hidden attribute of a Protected +-- (add-on) module read on the result of `call microflow` (CE1406) +-- ============================================================================ +-- +-- `check -p --references` typed the variables of parameters, create, retrieve +-- and loop iterators, but not the output of `call microflow`. A Hidden +-- attribute read on a call result was therefore not reported, while the same +-- read through a parameter was; mxbuild refused it: +-- +-- Create variable activity 'Create String variable' is not allowed to use +-- attribute 'BugAccess1701.Request.Secret' because app modules cannot +-- reference hidden elements in add-on modules. (CE1406) +-- +-- Measured on a blank Mendix 11.13.0 app, the module set to Protected, one +-- microflow per shape: a declare, an end event and a change on the result of +-- a Usable microflow, and a change on the iterator of a loop over a returned +-- list, were each refused on the Hidden Secret; Code (Usable) beside them was +-- 0 errors, and so was all of it inside the add-on. The member-existence check +-- had the same blind spot: a misspelled member a CHANGE assigns on a call +-- result now reports CE1613, as it does on a parameter. +-- +-- How to reproduce: run this file against an app, set BugAccess1701 to +-- Protected (Studio Pro: module settings, export level "Protected", type +-- Add-on), then run it again with `mxcli check … -p app.mpr --references`: +-- one CE1406 (statement 4), and exec refuses. As an ordinary module the whole +-- file is valid, which is what `make check-mdl` runs. +-- ============================================================================ + +CREATE MODULE BugAccess1701; + +CREATE OR MODIFY PERSISTENT ENTITY BugAccess1701.Request ( + Code: String(50) EXPORT LEVEL api, + Secret: String(50) +) EXPORT LEVEL api; + +CREATE OR MODIFY MICROFLOW BugAccess1701.Request_GetFirst () +RETURNS BugAccess1701.Request AS $Request +EXPORT LEVEL api +BEGIN + $Request = CREATE BugAccess1701.Request (Code = 'A'); + RETURN $Request; +END; + +-- Refused once the module is Protected: Secret is Hidden. +CREATE OR MODIFY MICROFLOW MyFirstModule.Bug1701_CallResultSecret () +RETURNS String AS $Secret +BEGIN + $Request = CALL MICROFLOW BugAccess1701.Request_GetFirst(); + DECLARE $Secret String = $Request/Secret; + RETURN $Secret; +END; + +-- Control: the Usable Code on the same call result stays clean. +CREATE OR MODIFY MICROFLOW MyFirstModule.Bug1701_CallResultCode () +RETURNS String AS $Code +BEGIN + $Request = CALL MICROFLOW BugAccess1701.Request_GetFirst(); + DECLARE $Code String = $Request/Code; + RETURN $Code; +END; diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 9163fac704..87cfc4d7fe 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -123,6 +123,11 @@ type scriptContext struct { // widget over one can be judged before it is built (MDL-ASSOCDS01). associationShapes map[string]assocShape + // storedReturns caches the entity each stored microflow a call names + // returns, for typing a call result (microflowReturnEntity); filled on + // demand. + storedReturns map[string]string + // warnings are findings that do not block: dangling references in an // EXCLUDED document, which Mendix itself does not validate. Reported so // that relaxing the check hides nothing. @@ -562,7 +567,7 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // A reference into a Protected (add-on) module's Hidden element, and a // Usable microflow there exposing a Hidden entity: mxbuild's CE1406 and // CE5400, after exec has written the model (mendixlabs/mxcli#1498). - errors = append(errors, validateProtectedModuleRefs(ctx, prog)...) + errors = append(errors, validateProtectedModuleRefs(ctx, prog, sc)...) // A business event message attribute named like a member its entity // inherits (BusinessEvents.PublishedBusinessEvent's, read from the project): // mxbuild reports the duplicate after exec wrote it (mendixlabs/mxcli#1525). diff --git a/mdl/executor/validate_call_result_typing_test.go b/mdl/executor/validate_call_result_typing_test.go new file mode 100644 index 0000000000..ad899b2263 --- /dev/null +++ b/mdl/executor/validate_call_result_typing_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// ako/mxcli#1701: walkTypedFlow did not type the output of `call microflow`, so +// every --references check built on it was silent on a call result — the +// Protected-module member check (TestValidateProtectedModuleRefs) and the +// member-existence check here alike. + +// callResultFixture is memberFixture plus a stored Shop.GetOrder returning +// Shop.Order. +func callResultFixture(t *testing.T) *ExecContext { + t.Helper() + ctx := memberFixture(t) + shop, err := ctx.Backend.GetModuleByName("Shop") + if err != nil { + t.Fatal(err) + } + get := mkMicroflow(shop.ID, "GetOrder") + get.ReturnType = µflows.ObjectType{EntityQualifiedName: "Shop.Order"} + ctx.Backend.(*mock.MockBackend).ListMicroflowsFunc = func() ([]*microflows.Microflow, error) { + return []*microflows.Microflow{get}, nil + } + return ctx +} + +func memberMessages(t *testing.T, ctx *ExecContext, src string) []string { + t.Helper() + prog := parseMDL(t, src) + sc := newScriptContext() + sc.collectDefinitions(prog) + return validateMemberReferences(ctx, prog, sc) +} + +func TestValidateMemberReferences_TypesACallResult(t *testing.T) { + ctx := callResultFixture(t) + msgs := memberMessages(t, ctx, + "create microflow Shop.ACT () begin $o = call microflow Shop.GetOrder(); change $o (IsArchived = true); end;") + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1: %v", len(msgs), msgs) + } + for _, want := range []string{"Shop.Order", "IsArchived", "CE1613"} { + if !strings.Contains(msgs[0], want) { + t.Errorf("message should mention %q: %s", want, msgs[0]) + } + } +} + +// CONTROL: a call result types only as far as the callee is established, and +// the script's own signature for it wins over the stored one — that is the one +// exec writes. Typing a rewritten callee from the stored return type would +// refuse a member the new return type has: a false refusal that blocks exec. +func TestValidateMemberReferences_CallResultStaysSilentWhereUnestablished(t *testing.T) { + ctx := callResultFixture(t) + for _, tc := range []struct{ name, src string }{ + {"a member the returned entity has", + "create microflow Shop.ACT () begin $o = call microflow Shop.GetOrder(); change $o (Status = 'x'); end;"}, + {"a callee rewritten to return another entity", + "create or modify microflow Shop.GetOrder () returns Shop.Customer begin $c = create Shop.Customer; return $c; end;\n" + + "create microflow Shop.ACT () begin $o = call microflow Shop.GetOrder(); change $o (Name = 'x'); end;"}, + {"a callee rewritten to return no object", + "create or modify microflow Shop.GetOrder () returns Boolean begin return true; end;\n" + + "create microflow Shop.ACT () begin $o = call microflow Shop.GetOrder(); change $o (Name = 'x'); end;"}, + {"a callee the project does not have", + "create microflow Shop.ACT () begin $o = call microflow Shop.Nope(); change $o (Whatever = 'x'); end;"}, + } { + t.Run(tc.name, func(t *testing.T) { + if msgs := memberMessages(t, ctx, tc.src); len(msgs) != 0 { + t.Errorf("want no message, got %v", msgs) + } + }) + } +} diff --git a/mdl/executor/validate_member_refs.go b/mdl/executor/validate_member_refs.go index 7cbe774855..eb4ac188fe 100644 --- a/mdl/executor/validate_member_refs.go +++ b/mdl/executor/validate_member_refs.go @@ -346,7 +346,7 @@ func checkFlowMembers(ctx *ExecContext, sc *scriptContext, authored map[string]b flowQN string, params []ast.MicroflowParam, body []ast.MicroflowStatement) []string { var out []string - walkTypedFlow(ctx, params, body, func(st ast.MicroflowStatement, vars map[string]string) { + walkTypedFlow(ctx, sc, params, body, func(st ast.MicroflowStatement, vars map[string]string) { switch s := st.(type) { case *ast.CreateObjectStmt: out = append(out, checkMembers(ctx, sc, authored, flowQN, @@ -373,7 +373,10 @@ func checkFlowMembers(ctx *ExecContext, sc *scriptContext, authored map[string]b // (checkFlowMembers, the Protected-module member check): two copies of it would // drift on which statements type a variable, and a check that loses a variable // goes silent rather than wrong. -func walkTypedFlow(ctx *ExecContext, params []ast.MicroflowParam, body []ast.MicroflowStatement, +// +// sc resolves a `call microflow` output against the script's own definition of +// the callee before the stored one; nil leaves call results untyped. +func walkTypedFlow(ctx *ExecContext, sc *scriptContext, params []ast.MicroflowParam, body []ast.MicroflowStatement, visit func(st ast.MicroflowStatement, vars map[string]string)) { vars := map[string]string{} @@ -419,6 +422,14 @@ func walkTypedFlow(ctx *ExecContext, params []ast.MicroflowParam, body []ast.Mic } } } + case *ast.CallMicroflowStmt: + // The callee's return type (ako/mxcli#1701): untyped, every + // member read on a call result was silent, in both checks. + if s.OutputVariable != "" && sc != nil { + if qn := sc.microflowReturnEntity(ctx, s.MicroflowName); qn != "" { + vars[s.OutputVariable] = qn + } + } case *ast.IfStmt: walk(s.ThenBody) walk(s.ElseBody) @@ -442,6 +453,49 @@ func walkTypedFlow(ctx *ExecContext, params []ast.MicroflowParam, body []ast.Mic walk(body) } +// microflowReturnEntity is the entity a call of qn yields — its object or list +// return type — or "" when that is not established. The script's definition +// wins: a `create or modify` is the signature exec writes, and typing the +// call from the stored one would judge members against an entity the callee +// no longer returns. Else the stored microflow, read once per name. +func (sc *scriptContext) microflowReturnEntity(ctx *ExecContext, qn ast.QualifiedName) string { + if qn.Module == "" { + return "" + } + if sc.microflows[qn.String()] { + if sig := sc.flowParams[strings.ToLower(qn.String())]; sig != nil { + return sig.Returns + } + return "" + } + if e, ok := sc.storedReturns[qn.String()]; ok { + return e + } + e := storedMicroflowReturnEntity(ctx, qn) + if sc.storedReturns == nil { + sc.storedReturns = map[string]string{} + } + sc.storedReturns[qn.String()] = e + return e +} + +func storedMicroflowReturnEntity(ctx *ExecContext, qn ast.QualifiedName) string { + h, err := getHierarchy(ctx) + if err != nil || h == nil { + return "" + } + mfs, err := microflowsNamed(ctx, qn.Name) + if err != nil { + return "" + } + for _, mf := range mfs { + if mf != nil && h.GetModuleName(h.FindModuleID(mf.ContainerID)) == qn.Module { + return sdkDataTypeEntity(mf.ReturnType) + } + } + return "" +} + // checkMembers resolves each member of one CREATE / CHANGE. func checkMembers(ctx *ExecContext, sc *scriptContext, authored map[string]bool, flowQN, where, entityQN string, changes []ast.ChangeItem) []string { diff --git a/mdl/executor/validate_protected_module_members.go b/mdl/executor/validate_protected_module_members.go index 0048b9efb2..0b1725cb0b 100644 --- a/mdl/executor/validate_protected_module_members.go +++ b/mdl/executor/validate_protected_module_members.go @@ -36,6 +36,13 @@ import ( // end is Hidden, or a commit, delete or aggregate of such an object — none of // them names the entity. // +// A member read on the result of `call microflow` is no different: measured on +// 11.13.0, a declare, an end event and a change on the result of a Usable +// microflow, and a change on a loop over a returned list, were each refused on +// a Hidden attribute (ako/mxcli#1701). walkTypedFlow types such a variable from +// the callee's return type — the script's definition first, else the stored +// one. +// // A member is reported only when it is established: its variable's entity is // known, the member resolves on it (an association hop through // associationTargetFrom), and its level is known — a level the script states, @@ -63,7 +70,7 @@ func (r *protectedRefResolver) checkFlowMembers(name ast.QualifiedName, params [ } } - walkTypedFlow(r.ctx, params, body, func(st ast.MicroflowStatement, vars map[string]string) { + walkTypedFlow(r.ctx, r.sc, params, body, func(st ast.MicroflowStatement, vars map[string]string) { switch s := st.(type) { case *ast.CreateObjectStmt: who := "create " + s.EntityType.String() diff --git a/mdl/executor/validate_protected_module_refs.go b/mdl/executor/validate_protected_module_refs.go index 30aad8fca1..779ed28396 100644 --- a/mdl/executor/validate_protected_module_refs.go +++ b/mdl/executor/validate_protected_module_refs.go @@ -43,7 +43,7 @@ import ( // ("add-on modules cannot reference hidden elements in add-on modules", // measured on 11.13.0 for a retrieve, a create and a member read), and which // this check does not judge yet. -func validateProtectedModuleRefs(ctx *ExecContext, prog *ast.Program) []error { +func validateProtectedModuleRefs(ctx *ExecContext, prog *ast.Program, sc *scriptContext) []error { if prog == nil || !ctx.Connected() || !hasProtectedModuleCandidates(prog) { return nil } @@ -53,6 +53,7 @@ func validateProtectedModuleRefs(ctx *ExecContext, prog *ast.Program) []error { } r := &protectedRefResolver{ ctx: ctx, + sc: sc, protected: protected, scriptFlows: map[string]*string{}, scriptEntity: map[string]*string{}, @@ -134,6 +135,7 @@ func moduleProtections(ctx *ExecContext) map[string]string { type protectedRefResolver struct { ctx *ExecContext + sc *scriptContext // what the script defines, for typing call results protected map[string]bool // What the script defines: a flow's stated export level (nil = not stated), diff --git a/mdl/executor/validate_protected_module_refs_test.go b/mdl/executor/validate_protected_module_refs_test.go index 7cfcd7bb75..20536a2cb2 100644 --- a/mdl/executor/validate_protected_module_refs_test.go +++ b/mdl/executor/validate_protected_module_refs_test.go @@ -35,12 +35,23 @@ func protectedModuleFixture(t *testing.T, settingsErr error) *ExecContext { e.ExportLevel = level return e } + // Usable microflows returning a Usable entity, one object and one list, for + // the members read on a call result (ako/mxcli#1701); Open.GetE is the + // ordinary-module control. + returning := func(m *microflows.Microflow, ret microflows.DataType) *microflows.Microflow { + m.ReturnType = ret + return m + } flows := []*microflows.Microflow{ mf(addOn, "ACT_Home", "API"), mf(addOn, "ACT_HomeHidden", "Hidden"), mf(addOn, "Hidden_MF", ""), // unset is Hidden: mxcli and mxbuild both read it so mf(addOn, "API_MF", "API"), mf(open, "MF", "Hidden"), + returning(mf(addOn, "API_GetPub", "API"), µflows.ObjectType{EntityQualifiedName: "MyAddOn.Pub"}), + returning(mf(addOn, "API_ListPub", "API"), µflows.ListType{EntityQualifiedName: "MyAddOn.Pub"}), + returning(mf(addOn, "API_Flag", "API"), µflows.BooleanType{}), + returning(mf(open, "GetE", "Hidden"), µflows.ObjectType{EntityQualifiedName: "Open.E"}), } // Members, for the CE1406 member cases: Pub and Pub2 are Usable entities // with one Usable and one Hidden attribute each, joined by a Hidden and a @@ -307,6 +318,47 @@ func TestValidateProtectedModuleRefs(t *testing.T) { []string{"statement 2", "CE1406", "entity 'MyAddOn.Fresh'"}}, {"unknown entity is the not-found check's", "create microflow MyFirstModule.C () begin retrieve $l from MyAddOn.Nope; $n = create MyAddOn.Nope; end;", nil}, + + // --- ako/mxcli#1701: the members read on a CALL MICROFLOW result, typed + // from the callee's return type (measured 11.13.0: a declare, an end + // event, a change and a change on a loop over a returned list were each + // refused on the Hidden attribute; the Usable one beside it was clean) + {"hidden attribute on a call result", + "create microflow MyFirstModule.G () returns String begin $p = call microflow MyAddOn.API_GetPub(); declare $s String = $p/Inner; return $s; end;", + []string{"CE1406", "declare $s is not allowed to use attribute 'MyAddOn.Pub.Inner'"}}, + {"usable attribute on a call result", + "create microflow MyFirstModule.G () returns String begin $p = call microflow MyAddOn.API_GetPub(); declare $s String = $p/Shown; return $s; end;", nil}, + {"hidden attribute returned from a call result", + "create microflow MyFirstModule.G () returns String begin $p = call microflow MyAddOn.API_GetPub(); return $p/Inner; end;", + []string{"CE1406", "the end event is not allowed to use attribute 'MyAddOn.Pub.Inner'"}}, + {"change of a hidden attribute on a call result", + "create microflow MyFirstModule.G () begin $p = call microflow MyAddOn.API_GetPub(); change $p (Inner = 'x'); end;", + []string{"CE1406", "change $p is not allowed to use attribute 'MyAddOn.Pub.Inner'"}}, + {"hidden attribute on a loop over a returned list", + "create microflow MyFirstModule.G () begin $l = call microflow MyAddOn.API_ListPub(); loop $x in $l begin change $x (Inner = 'x'); end loop; end;", + []string{"CE1406", "change $x is not allowed to use attribute 'MyAddOn.Pub.Inner'"}}, + {"hidden association on a call result", + "create microflow MyFirstModule.G () returns Boolean begin $p = call microflow MyAddOn.API_GetPub(); return $p/MyAddOn.Pub_Pub2 != empty; end;", + []string{"CE1406", "association 'MyAddOn.Pub_Pub2'"}}, + {"call result of a script-created callee", + "create microflow MyAddOn.API_New () returns MyAddOn.Pub export level api begin $n = create MyAddOn.Pub; return $n; end;\n" + + "create microflow MyFirstModule.G () returns String begin $p = call microflow MyAddOn.API_New(); return $p/Inner; end;", + []string{"statement 2", "CE1406", "attribute 'MyAddOn.Pub.Inner'"}}, + // The script's signature is the one exec writes: a rewrite of the stored + // API_GetPub returning Pub2 types $p as Pub2, so Hid (Pub2's) is reported + // and Inner (Pub's, the stored return type) is not resolvable — silent. + {"script rewrite of the callee's return type wins", + "create or modify microflow MyAddOn.API_GetPub () returns MyAddOn.Pub2 export level api begin $n = create MyAddOn.Pub2; return $n; end;\n" + + "create microflow MyFirstModule.G () returns String begin $p = call microflow MyAddOn.API_GetPub(); return $p/Hid + $p/Inner; end;", + []string{"statement 2", "CE1406", "attribute 'MyAddOn.Pub2.Hid'"}}, + {"call result inside the add-on is fine", + "create microflow MyAddOn.Inner () returns String begin $p = call microflow MyAddOn.API_GetPub(); return $p/Inner; end;", nil}, + {"call result of an open module", + "create microflow MyFirstModule.G () returns String begin $e = call microflow Open.GetE(); return $e/Inner; end;", nil}, + {"call result of a non-object return is silent", + "create microflow MyFirstModule.G () returns Boolean begin $p = call microflow MyAddOn.API_Flag(); return $p; end;", nil}, + {"call result of an unknown microflow is silent", + "create microflow MyFirstModule.G () returns String begin $p = call microflow MyAddOn.Nope(); return $p/Inner; end;", nil}, {"open module hidden attribute", "create microflow MyFirstModule.C ($e: Open.E) begin change $e (Inner = 'x'); end;", nil}, {"untyped variable is silent", "create microflow MyFirstModule.C () begin change $nobody (Inner = 'x'); end;", nil}, @@ -323,7 +375,7 @@ func TestValidateProtectedModuleRefs(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { var got []string - for _, err := range validateProtectedModuleRefs(ctx, parseMDL(t, c.src)) { + for _, err := range protectedRefErrors(t, ctx, c.src) { got = append(got, err.Error()) } if c.want == nil { @@ -344,12 +396,21 @@ func TestValidateProtectedModuleRefs(t *testing.T) { } } +// protectedRefErrors runs the check on src the way validateProgram does, with +// the script's definitions collected. +func protectedRefErrors(t *testing.T, ctx *ExecContext, src string) []error { + t.Helper() + prog := parseMDL(t, src) + sc := newScriptContext() + sc.collectDefinitions(prog) + return validateProtectedModuleRefs(ctx, prog, sc) +} + // Unreadable module settings mean no module can be shown to be Protected, so // nothing is reported — a false refusal would be worse than no check. func TestValidateProtectedModuleRefs_SilentWithoutSettings(t *testing.T) { ctx := protectedModuleFixture(t, errors.New("boom")) - prog := parseMDL(t, "create or modify navigation Responsive home page MyAddOn.Home_Page;") - if errs := validateProtectedModuleRefs(ctx, prog); len(errs) != 0 { + if errs := protectedRefErrors(t, ctx, "create or modify navigation Responsive home page MyAddOn.Home_Page;"); len(errs) != 0 { t.Fatalf("want no errors without module settings, got %v", errs) } } From fc69a5b68c14a20fc68976af9d87add4c1adace7 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 26 Oct 2026 21:39:54 +0000 Subject: [PATCH 35/40] =?UTF-8?q?fix:=20log=20and=20send=20email=20store?= =?UTF-8?q?=20an=20expression=20with=20`with=20(=E2=80=A6)`=20as=20its=20s?= =?UTF-8?q?ource=20text=20(ako/mxcli#1683)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `log info node 'P' $Msg with ({1} = 'x');` logged the literal text `$Msg`, and `send email (… Subject: $Subj with ({1} = '42') …)` sent the subject `$Subj`. `show message` and `validation feedback` stored the same statement as '{1}' with the expression and then the stated parameters, so 'x' became a {2} no placeholder reads and the message showed its own unfilled {1}. check, exec and mx check (0 errors, 11.13.0) passed all four. Cause: a message template is a text with {n} placeholders; an expression has none for `with (…)` to fill, and Mendix cannot store an expression as a template's text. addLogMessageAction and emailTemplate (mdl/executor/cmd_microflows_builder_calls.go) fell back to fb.exprToString(message) as the template text when parameters were stated; addShowMessageAction / addValidationFeedbackAction appended them after the expression's {1}. Nothing in check refused the shape. Fix: one decision, messageTemplateRefusals (mdl/executor/message_templates.go), refuses an expression followed by `with (…)` (or the deprecated `objects [...]`) on all four statements — the shape MDL-REST05 and MDL-JA02 refuse for REST calls and Java action arguments. The builders call it (exec, for microflows, nanoflows, rules and alter fragments alike), and check reports it as MDL-TEMPLATE01 from the microflow, nanoflow, alter-fragment and rule validators. Sibling on the same branch: a multi-line string-literal email Body with parameters arrives as a SourceExpr and stored its source, quotes included, as the template; it is now stored as the literal's value. Verification: message_templates_test.go covers check and exec on every statement with controls (a string template, an expression without `with (…)`), every flow kind, and the multi-line body; reverting the production change fails all refused cases and the body case. On a blank 11.13.0 app the baseline wrote the four flows (describe printed '$Msg' / '$Subj'; mx check 0 errors); the fix refuses them at check and at exec --no-check, nothing written. The controls build at mx check 0 errors. Co-Authored-By: Claude Opus 5.5 --- ...ion-with-params-stored-as-source-text.json | 1 + .../write-microflows/reference/pitfalls.md | 22 +++ cmd/mxcli/syntax/features_microflow.go | 7 +- docs-site/src/appendixes/error-messages.md | 24 +++ docs/01-project/MDL_QUICK_REFERENCE.md | 8 +- ...email-1683-expression-with-params.fail.mdl | 34 ++++ mdl/executor/cmd_microflows_builder_calls.go | 24 ++- mdl/executor/message_templates.go | 110 +++++++++++++ mdl/executor/message_templates_test.go | 145 ++++++++++++++++++ mdl/executor/validate_alter_flow.go | 3 + mdl/executor/validate_microflow.go | 3 + mdl/executor/validate_nanoflow.go | 3 + mdl/executor/validate_program.go | 3 + 13 files changed, 379 insertions(+), 8 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-log-email-expression-with-params-stored-as-source-text.json create mode 100644 mdl-examples/bug-tests/log-email-1683-expression-with-params.fail.mdl create mode 100644 mdl/executor/message_templates.go create mode 100644 mdl/executor/message_templates_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-log-email-expression-with-params-stored-as-source-text.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-log-email-expression-with-params-stored-as-source-text.json new file mode 100644 index 0000000000..2fecfd90e0 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-log-email-expression-with-params-stored-as-source-text.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "`log info node 'P' $Msg with ({1} = 'x');` logs the literal text `$Msg`; `send email (… Subject: $Subj with ({1} = '42') …)` sends the subject `$Subj` (describe prints them back as the literals '$Msg' / '$Subj'). `show message $Msg with ({1} = 'x')` and `validation feedback $o/A message $Msg with (…)` store '{1}' with ($Msg, 'x'): 'x' is a {2} nothing reads and the message shows its own unfilled {1}. check, exec and mx check (0 errors, 11.13.0) pass all four", "cause": "A message template is a text with {n} placeholders; an expression has none for `with (…)` to fill. `addLogMessageAction` and `emailTemplate` (mdl/executor/cmd_microflows_builder_calls.go) fell back to `fb.exprToString(message)` as the template TEXT when the message was not a string literal but parameters were stated; `addShowMessageAction` / `addValidationFeedbackAction` made it '{1}' with the expression and appended the stated parameters after it. Nothing in check refused the shape", "file": "`mdl/executor/message_templates.go` (`messageTemplateRefusals` = the one decision, `checkMessageTemplates` = MDL-TEMPLATE01, `refuseMessageTemplates` for the builder), called from the four builders in `cmd_microflows_builder_calls.go`, `validate_microflow.go`, `validate_nanoflow.go`, `validate_alter_flow.go` and the rule branch of `validate_program.go`; tests `mdl/executor/message_templates_test.go`; fixture `mdl-examples/bug-tests/log-email-1683-expression-with-params.fail.mdl`", "insight": "Third instance of one class after `call rest service` (MDL-REST05, ako/mxcli#1444) and a Java action's text-template argument (MDL-JA02, ako/mxcli#1451): grep for `exprToString(` assigned to a template's TEXT, and for parameters appended after a '{1}' made from an expression. The #1444 finding claimed these four sites 'already applied' the literal-or-{1} rule — true only without `with (…)`; read the with-parameters branch, not just the plain one. The check has to run where each statement can appear: microflow walkBody covers only microflows, so the rule is a separate walk (walkFlowStatements, error-handler bodies included) wired into the nanoflow validator, the alter-fragment validator and the rule branch too; the builder refusal covers exec for all of them. Sibling found on the same branch and fixed: a multi-line string-literal email Body with parameters arrives as a SourceExpr around the literal and stored its SOURCE, quotes included, as the template — unwrap with restTemplateText, as #1444 did for the REST body", "refs": ["ako/mxcli#1683", "ako/mxcli#1444", "ako/mxcli#1451"], "rules": ["MDL-TEMPLATE01", "MDL-REST05", "MDL-JA02"], "test": "TestMessageTemplate_ExpressionWithParametersIsRefused, TestMessageTemplate_RefusedInEveryFlowKind, TestMessageTemplateRefusals_OtherStatementsPass, TestEmailBody_MultiLineLiteralWithParametersIsTheTemplateText"} diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index 0bfbfd069c..e1960f03a2 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -471,6 +471,28 @@ has 3 paths". Those change where the flow's paths end or meet; rebuild the flow deliberately (drop the microflow and create it in the same script) or edit it in Studio Pro. Inside a loop body a decision is the loop's to replace (§11). +### 16. A Message Template Is a String, Not an Expression With `with (…)` + +The text of a `log` message, a `send email` subject or body, a `show message` and a +`validation feedback` message is a **template**: a string with `{n}` placeholders that +`with (…)` fills. An expression is the whole text — stored as the template `'{1}'` +with the expression as its parameter — so there is nothing left for `with (…)` to +fill, and Mendix cannot store an expression as a template's text. An expression +followed by `with (…)` is refused by `check` and `exec` (MDL-TEMPLATE01, +ako/mxcli#1683); it used to log the characters `$Msg`. + +```mdl +-- ❌ WRONG: $Msg is not a template; this is refused (MDL-TEMPLATE01) +log info node 'Orders' $Msg with ({1} = $Order/Number); + +-- ✅ CORRECT: the template is a string, or the expression stands alone +log info node 'Orders' 'Order {1} shipped' with ({1} = $Order/Number); +log info node 'Orders' 'Order ' + $Order/Number + ' shipped'; +``` + +A template kept in a variable cannot be filled by `with (…)` at run time: write the +template in the statement, or build the whole text in the expression. + ## Implicit Variable Creation (CE0111 Duplicate Variable) These statements **implicitly create a new variable** with the name on the left side: diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index ab9d43bb20..1f12a16bf2 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -61,7 +61,8 @@ func init() { "template '...', like a REST body. Placeholders {1}, {2} ... are filled by\n" + "WITH; each parameter must be a String (CE0117) and every placeholder needs\n" + "one (CE0720). Any other expression is stored as '{1}' with the expression\n" + - "as its parameter.\n\n" + + "as its parameter, so an expression followed by WITH is refused: it has no\n" + + "{n} to fill (MDL-TEMPLATE01).\n\n" + "Headers are plain strings, not expressions: letters, digits and hyphens in\n" + "the name, one line in the value (MDL-EMAIL03). Attachment names a\n" + "System.FileDocument variable, a list of them, or a specialization.\n" + @@ -596,7 +597,7 @@ func init() { "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);\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).", + 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).\n-- An expression message (LOG $Msg;) is stored as '{1}' with it as the parameter,\n-- so an expression followed by WITH (...) has no {n} to fill and is refused\n-- (MDL-TEMPLATE01) — as for SHOW MESSAGE, VALIDATION FEEDBACK and SEND EMAIL.", 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;", }) @@ -856,7 +857,7 @@ func init() { "validation", "feedback", "validation feedback", "error message", "field error", "form validation", }, - Syntax: "VALIDATION FEEDBACK $Obj/Attr MESSAGE 'error text';\nVALIDATION FEEDBACK $Obj/Attr MESSAGE '{1} is invalid'\n WITH ({1} = $Value);", + Syntax: "VALIDATION FEEDBACK $Obj/Attr MESSAGE 'error text';\nVALIDATION FEEDBACK $Obj/Attr MESSAGE '{1} is invalid'\n WITH ({1} = $Value);\n\n-- The message is a template: an expression (MESSAGE $Text) is stored as '{1}'\n-- with it as the parameter, and one followed by WITH is refused (MDL-TEMPLATE01).", Example: "VALIDATION FEEDBACK $Order/Quantity MESSAGE 'Quantity must be positive';\nVALIDATION FEEDBACK $Customer/Email MESSAGE '{1} is not valid'\n WITH ({1} = $Customer/Email);", SeeAlso: []string{"microflow.error-handling"}, }) diff --git a/docs-site/src/appendixes/error-messages.md b/docs-site/src/appendixes/error-messages.md index 5c060bae91..89142f7bc7 100644 --- a/docs-site/src/appendixes/error-messages.md +++ b/docs-site/src/appendixes/error-messages.md @@ -443,6 +443,30 @@ ignored or the header fails only when a mail is sent. `CheckServerIdentity`; rename the header, e.g. `Headers: ('X-Correlation-Id': 'value')`. +### MDL-TEMPLATE01: A message written as an expression followed by `with (…)` + +``` +log: the message is an expression, so `with (…)` has no template to fill — the +parameters fill the {n} placeholders of a message written as a string. [MDL-TEMPLATE01] +``` + +**Cause:** The text of a `log` message, a `send email` subject or body, a `show message` +and a `validation feedback` message is a template: a string whose `{n}` placeholders +`with (…)` fills. An expression is the whole text, stored as the template `'{1}'` with the +expression as its parameter, so it has no placeholder for further parameters, and Mendix +cannot store an expression as a template's text. Before ako/mxcli#1683, `log` and +`send email` stored the expression's source as the template — the log line read `$Msg` — +and `show message` / `validation feedback` stored the parameters where no placeholder read +them. `check`, `exec` and `mx check` passed all four. `check` and `exec` now refuse it with +one shared function, as MDL-REST05 does for `call rest service`. + +**Solution:** Write the template as a string, or the expression without `with (…)`: + +```mdl +log info node 'Orders' 'Order {1} shipped' with ({1} = $Order/Number); +log info node 'Orders' $Msg; +``` + ### MDL-JSONNUM01: A locale-dependent number in hand-built JSON ``` diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 7e5a7f2acb..0e8e7f9dd5 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -595,13 +595,13 @@ it is for pages. | Show page | `show page Module.PageName (Param = $value);` | `Param = expression`, as at every call site. `($Param = …)` and `(Param: …)` are deprecated (MDL-DEPR006/007) | | Close page | `close page [n];` | `n` pages, default 1 (stored as `NumberOfPagesToClose`) | | Download file | `download file $FileDocument [show in browser];` | Streams a `System.FileDocument` | -| Show message | `show message 'text' [type Information\|Warning\|Error] [with ({1} = $a, {2} = $b)] [blocking];` | `blocking` halts the client until the user dismisses it — Studio Pro's checkbox. It goes after the `with` list and before `on error`. `with ({1} = $a, {2} = $b)` is the deprecated spelling of the list (MDL-DEPR009). Without it, a describe → exec round trip turned a blocking message into a non-blocking one (16 microflows measured) | +| Show message | `show message 'text' [type Information\|Warning\|Error] [with ({1} = $a, {2} = $b)] [blocking];` | `blocking` halts the client until the user dismisses it — Studio Pro's checkbox. It goes after the `with` list and before `on error`. `with ({1} = $a, {2} = $b)` is the deprecated spelling of the list (MDL-DEPR009). Without it, a describe → exec round trip turned a blocking message into a non-blocking one (16 microflows measured). An expression message (`show message $Text`) is stored as `'{1}'` with it as the parameter; one followed by `with (…)` is refused (MDL-TEMPLATE01) | | Database connection | `create database connection Mod.Db ( Type: 'PostgreSQL', ConnectionString: @Mod.Url, Username: @Mod.User, Password: @Mod.Pass ) { query Q ( Sql: $$…$$, Parameters: ( p: Integer default '0' ), Returns: Mod.E, Map: ( Attr = column ) ) }` | Properties in `( )`, queries as children in `{ }` (R2). The clause form with `begin … end` still parses and warns (MDL-DEPR127) | | Database connection credentials | `ConnectionString: @Mod.Const`, `Username: @Mod.Const`, `Password: @Mod.Const` | Constant **references** only. A literal writes an unopenable project — MDL058 | -| Send email (11.13+, microflow only) | `send email ( From: , To: , [Cc: …, Bcc: …,] Subject: 'Order {1}' with ({1} = …), [Body: template '…', HtmlBody: template '…', Headers: ('X-Name': 'value'), Attachment: $Doc,] Host: , Port: , [SecurityType: none\|ssl\|tls, CheckServerIdentity: true, ConnectionTimeout: ms, Authentication: basic (Username: …, Password: …)] );` | Built-in Send Email activity (`Microflows$SendEmailAction`), SMTP without the Email Connector. One settings list (ADR-0013); From, Host, Port and one of To/Cc/Bcc are required (CE0166). Addresses, host and credentials are String expressions, port Integer/Long (E009 at check, CE9528 at build). `mxcli syntax microflow.send-email` | +| Send email (11.13+, microflow only) | `send email ( From: , To: , [Cc: …, Bcc: …,] Subject: 'Order {1}' with ({1} = …), [Body: template '…', HtmlBody: template '…', Headers: ('X-Name': 'value'), Attachment: $Doc,] Host: , Port: , [SecurityType: none\|ssl\|tls, CheckServerIdentity: true, ConnectionTimeout: ms, Authentication: basic (Username: …, Password: …)] );` | Built-in Send Email activity (`Microflows$SendEmailAction`), SMTP without the Email Connector. One settings list (ADR-0013); From, Host, Port and one of To/Cc/Bcc are required (CE0166). Addresses, host and credentials are String expressions, port Integer/Long (E009 at check, CE9528 at build). A subject or body written as an expression is stored as `'{1}'` with it as the parameter; one followed by `with (…)` is refused (MDL-TEMPLATE01). `mxcli syntax microflow.send-email` | | 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 | +| Validation | `validation feedback $entity/attribute message 'message {1}' [with ({1} = $a)];` | Requires attribute path + MESSAGE. An expression message followed by `with (…)` is refused (MDL-TEMPLATE01) | +| Log | `log [info\|warning\|error] [node 'name'] 'message';` | Level `info` and node `'Application'` are the defaults; `describe` leaves them out. The message is a template (`'… {1}' with ({1} = …)`); an expression (`log info $Msg;`) is stored as `'{1}'` with it as the parameter, and one followed by `with (…)` has no `{n}` to fill and is refused (MDL-TEMPLATE01, ako/mxcli#1683) | | 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) | diff --git a/mdl-examples/bug-tests/log-email-1683-expression-with-params.fail.mdl b/mdl-examples/bug-tests/log-email-1683-expression-with-params.fail.mdl new file mode 100644 index 0000000000..cbb8d4a841 --- /dev/null +++ b/mdl-examples/bug-tests/log-email-1683-expression-with-params.fail.mdl @@ -0,0 +1,34 @@ +-- Negative test (ako/mxcli#1683): a `log` message, a `send email` subject or +-- body, a `show message` message or a `validation feedback` message written as +-- an expression followed by `with (…)` must be REFUSED (MDL-TEMPLATE01). +-- +-- Each is a template whose {n} placeholders the `with (…)` parameters fill. +-- An expression is not a template: alone it is stored as the template '{1}' +-- with the expression as its parameter, so there is nothing left for further +-- parameters to fill, and Mendix cannot store an expression as a template's +-- text. `log` and `send email` used to store the expression's source text as +-- the template, so the log line read "$Msg" and the subject "$Subj"; `show +-- message` and `validation feedback` stored the parameters after the +-- expression, where no placeholder read them. check, exec and mx check passed. +-- +-- The forms that work: 'value is {1}' with ({1} = $V), or $Msg alone. + +mdl 1; + +create module Probe; + +create microflow Probe.LogExpr () +begin + declare $Msg String = 'value is {1}'; + log info node 'Probe' $Msg with ({1} = 'x'); +end; + +create microflow Probe.MailExpr () +begin + declare $Subj String = 'Order {1}'; + send email ( + From: 'a@example.com', To: 'b@example.com', + Subject: $Subj with ({1} = '42'), + Host: 'smtp.example.com', Port: 25, + ); +end; diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index 1eba05c950..bbca7c291c 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -43,6 +43,11 @@ func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID { var templateText string var templateParams []string + // The same function `mxcli check` runs (MDL-TEMPLATE01): an expression + // that also states `with (…)` has no template for the parameters to fill; + // its source text used to become the template (ako/mxcli#1683). + fb.refuseMessageTemplates(s) + if len(s.Template) > 0 { // Use provided template parameters if lit, ok := s.Message.(*ast.LiteralExpr); ok && lit.Kind == ast.LiteralString { @@ -1261,6 +1266,11 @@ func (fb *flowBuilder) addShowMessageAction(s *ast.ShowMessageStmt) model.ID { var templateText string var templateParams []string + // MDL-TEMPLATE01: an expression followed by `with (…)` stored '{1}' with + // the expression and then the parameters, which no placeholder read + // (ako/mxcli#1683). + fb.refuseMessageTemplates(s) + if lit, ok := s.Message.(*ast.LiteralExpr); ok && lit.Kind == ast.LiteralString { templateText = fmt.Sprintf("%v", lit.Value) } else { @@ -1466,6 +1476,9 @@ func (fb *flowBuilder) addValidationFeedbackAction(s *ast.ValidationFeedbackStmt var templateText string var templateParams []string + // MDL-TEMPLATE01, as show message (ako/mxcli#1683). + fb.refuseMessageTemplates(s) + if lit, ok := s.Message.(*ast.LiteralExpr); ok && lit.Kind == ast.LiteralString { // Simple string literal - use raw value directly as template text templateText = fmt.Sprintf("%v", lit.Value) @@ -2304,6 +2317,9 @@ func mappingRootPathCrossesArray(jsonPath string) bool { // addSendEmailAction creates a SEND EMAIL activity (Microflows$SendEmailAction). func (fb *flowBuilder) addSendEmailAction(s *ast.SendEmailStmt) model.ID { + // MDL-TEMPLATE01: a subject or body written as an expression with + // `with (…)` (ako/mxcli#1683). + fb.refuseMessageTemplates(s) action := µflows.SendEmailAction{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, ErrorHandlingType: microflows.ErrorHandlingTypeRollback, @@ -2389,7 +2405,13 @@ func (fb *flowBuilder) emailTemplate(c *ast.EmailTemplateClause) microflows.Emai if len(c.Params) == 0 { return microflows.EmailTemplate{Text: "{1}", Parameters: []string{fb.exprToString(c.Text)}} } - return microflows.EmailTemplate{Text: fb.exprToString(c.Text), Parameters: fb.templateParamStrings(c.Params)} + // With parameters the text must be a string: a literal kept with its + // source because it spans lines is still one, stored as its value — + // its source, quotes and all, was the template. Any other expression + // is refused (MDL-TEMPLATE01, ako/mxcli#1683); its source text used to + // be stored as the template. + text, _ := restTemplateText(c.Text) + return microflows.EmailTemplate{Text: text, Parameters: fb.templateParamStrings(c.Params)} } return microflows.EmailTemplate{Text: fmt.Sprintf("%v", lit.Value), Parameters: fb.templateParamStrings(c.Params)} } diff --git a/mdl/executor/message_templates.go b/mdl/executor/message_templates.go new file mode 100644 index 0000000000..e644058169 --- /dev/null +++ b/mdl/executor/message_templates.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 + +// The message templates of `log`, `send email`, `show message` and +// `validation feedback`. +// +// Each stores its text as a template: a text with {n} placeholders and one +// parameter expression per placeholder (a Texts$Text or a StringTemplate). +// MDL spells the text two ways: +// +// 'value is {1}' with ({1} = $V) the template, as Studio Pro shows it +// $Msg an expression: the whole text +// +// An expression is stored as the template '{1}' with the expression as its one +// parameter. An expression followed by `with (…)` has no meaning Mendix can +// store: the expression is not a template, so there is no {n} for the +// parameters to fill, and a template's text cannot be an expression. +// +// ako/mxcli#1683: `log` and `send email` stored the expression's SOURCE TEXT as +// the template — `log info node 'P' $Msg with ({1} = 'x')` logged "$Msg", +// `Subject: $Subj with ({1} = '42')` sent the subject "$Subj" — and `show +// message` / `validation feedback` stored '{1}' with the expression and then +// the stated parameters, so 'x' became a {2} nothing reads and the message +// showed its own unfilled "{1}". check, exec and mx check passed all four. +// The same shape as `call rest service` (MDL-REST05, ako/mxcli#1444) and a Java +// action's text-template argument (MDL-JA02, ako/mxcli#1451). +package executor + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// messageTemplateRule is the rule ID for a message template written as an +// expression followed by `with (…)`. +const messageTemplateRule = "MDL-TEMPLATE01" + +// messageTemplateRefusals returns why each template of stmt cannot be stored — +// one written as an expression that also states parameters — or nil. Only +// `log`, `send email`, `show message` and `validation feedback` have such a +// template; any other statement is not this function's business. +// +// This is the single decision: the flow builder calls it before building the +// action, and the check pass calls it through checkMessageTemplates, so a +// script cannot pass one and fail the other. +func messageTemplateRefusals(stmt ast.MicroflowStatement) []string { + var out []string + refuse := func(statement, what, write string, text ast.Expression, hasParams bool) { + if text == nil || !hasParams { + return + } + if _, ok := restTemplateText(text); ok { + return + } + out = append(out, fmt.Sprintf("%s: the %s is an expression, so `with (…)` has no template to fill — "+ + "the parameters fill the {n} placeholders of a %s written as a string.\n"+ + " Write %s, or the expression without `with (…)`", statement, what, what, write)) + } + switch s := stmt.(type) { + case *ast.LogStmt: + refuse("log", "message", "the message as a template, 'value is {1}' with ({1} = …)", + s.Message, len(s.Template) > 0) + case *ast.ShowMessageStmt: + refuse("show message", "message", "the message as a template, 'value is {1}' with ({1} = …)", + s.Message, len(s.TemplateArgs) > 0) + case *ast.ValidationFeedbackStmt: + refuse("validation feedback", "message", "the message as a template, 'value is {1}' with ({1} = …)", + s.Message, len(s.TemplateArgs) > 0) + case *ast.SendEmailStmt: + refuse("send email", "subject", "Subject: 'Order {1}' with ({1} = …)", + s.Subject.Text, len(s.Subject.Params) > 0) + if b := s.BodyText; b != nil { + refuse("send email", "body", "Body: template '…{1}…' with ({1} = …)", + b.Text, len(b.Params) > 0) + } + if b := s.BodyHTML; b != nil { + refuse("send email", "HTML body", "HtmlBody: template '…{1}…' with ({1} = …)", + b.Text, len(b.Params) > 0) + } + } + return out +} + +// checkMessageTemplates surfaces messageTemplateRefusals as MDL-TEMPLATE01 for +// every statement of body, nested and error-handler bodies included. +func (v *microflowValidator) checkMessageTemplates(body []ast.MicroflowStatement) { + walkFlowStatements(body, func(s ast.MicroflowStatement) { + for _, msg := range messageTemplateRefusals(s) { + v.addViolation(messageTemplateRule, linter.SeverityError, msg, + "Write the text as a string with {n} placeholders, or the expression alone without `with (…)`") + } + }) +} + +// messageTemplateViolations runs MDL-TEMPLATE01 alone over a flow that does not +// go through ValidateMicroflow — a rule (see setTargetViolations). +func messageTemplateViolations(docType, name string, body []ast.MicroflowStatement) []linter.Violation { + v := µflowValidator{mfName: name, docType: docType} + v.checkMessageTemplates(body) + return v.violations +} + +// refuseMessageTemplates records messageTemplateRefusals as build errors, so +// exec refuses what check reports as MDL-TEMPLATE01. +func (fb *flowBuilder) refuseMessageTemplates(stmt ast.MicroflowStatement) { + for _, msg := range messageTemplateRefusals(stmt) { + fb.addError("%s", msg) + } +} diff --git a/mdl/executor/message_templates_test.go b/mdl/executor/message_templates_test.go new file mode 100644 index 0000000000..a2890b61db --- /dev/null +++ b/mdl/executor/message_templates_test.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#1683: a `log` message or a `send email` subject written as an +// expression followed by `with (…)` was stored as the expression's SOURCE +// TEXT: `log info node 'P' $Msg with ({1} = 'x')` logged the five characters +// "$Msg", `Subject: $Subj with ({1} = '42')` sent the subject "$Subj". `show +// message` and `validation feedback` made the same statement into the template +// '{1}' with the expression as parameter 1 and the stated parameters after it, +// so 'x' became a {2} no placeholder reads and the message showed its own +// unfilled "{1}". check, exec and mx check all passed every one of them. +// +// A template is a text with {n} placeholders; an expression has none for +// `with (…)` to fill, and Mendix cannot store an expression as a template's +// text. The shape ako/mxcli#1444 refused for `call rest service` (MDL-REST05) +// and ako/mxcli#1451 for a Java action's text-template argument (MDL-JA02). + +func messageTemplateMicroflow(stmt string) string { + return "create microflow M.Probe ($Msg: String, $P: System.User)\nbegin\n " + stmt + ";\nend;\n" +} + +func sendEmailWith(settings string) string { + return "send email (\n From: 'a@example.com', To: 'b@example.com',\n " + settings + + ",\n Host: 'smtp.example.com', Port: 25,\n )" +} + +func TestMessageTemplate_ExpressionWithParametersIsRefused(t *testing.T) { + for _, c := range []struct { + name, stmt string + refused bool + }{ + // The two reported statements. + {"log", `log info node 'Probe' $Msg with ({1} = 'x')`, true}, + {"email subject", sendEmailWith(`Subject: $Msg with ({1} = '42')`), true}, + // The same shape on the other templates of those statements and of + // the two that store a message the same way. + {"email plain body", sendEmailWith(`Subject: 'S', Body: $Msg with ({1} = '42')`), true}, + {"email html body", sendEmailWith(`Subject: 'S', HtmlBody: $Msg + '!' with ({1} = '42')`), true}, + {"show message", `show message $Msg with ({1} = 'x')`, true}, + {"show message, deprecated objects", `show message $Msg objects ['x']`, true}, + {"validation feedback", `validation feedback $P/Name message $Msg with ({1} = 'x')`, true}, + {"log, any expression", `log info $Msg + ' {1}' with ({1} = 'x')`, true}, + // Controls: a template written as a string, and an expression without + // parameters — stored as '{1}' with it as parameter 1 — stay as they were. + {"control: log template", `log info node 'Probe' 'value is {1}' with ({1} = $Msg)`, false}, + {"control: log expression", `log info node 'Probe' $Msg`, false}, + {"control: log with stack trace", `log error $Msg with stacktrace`, false}, + {"control: email subject template", sendEmailWith(`Subject: 'Order {1}' with ({1} = $Msg)`), false}, + {"control: email subject expression", sendEmailWith(`Subject: $Msg`), false}, + {"control: email body template", sendEmailWith(`Subject: 'S', Body: template 'Hi {1}' with ({1} = $Msg)`), false}, + {"control: show message template", `show message 'Hi {1}' with ({1} = $Msg)`, false}, + {"control: show message expression", `show message $Msg`, false}, + {"control: validation feedback template", `validation feedback $P/Name message 'Bad {1}' with ({1} = $Msg)`, false}, + } { + t.Run(c.name, func(t *testing.T) { + create := firstCreateMicroflow(t, messageTemplateMicroflow(c.stmt)) + + var rule string + for _, v := range ValidateMicroflow(create) { + if v.RuleID == messageTemplateRule { + rule = v.Message + } + } + if (rule != "") != c.refused { + t.Errorf("check: %s reported = %v, want %v (%q)", messageTemplateRule, rule != "", c.refused, rule) + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, + measurer: &layoutMeasurer{}, varTypes: map[string]string{"P": "System.User"}, + declaredVars: map[string]string{"Msg": "String"}} + fb.buildFlowGraph(create.Body, create.ReturnType) + var execErr string + for _, e := range fb.GetErrors() { + if strings.Contains(e, "has no template to fill") { + execErr = e + } + } + if (execErr != "") != c.refused { + t.Errorf("exec: refused = %v, want %v (errors: %q)", execErr != "", c.refused, fb.GetErrors()) + } + if c.refused && rule != execErr { + t.Errorf("check and exec disagree:\n check: %q\n exec: %q", rule, execErr) + } + }) + } +} + +// The refusal holds in every flow `check` validates that can hold one of these +// statements, not only a microflow: a nanoflow logs and shows messages, a rule +// logs, and an `alter … insert` adds the statement to a stored flow. +func TestMessageTemplate_RefusedInEveryFlowKind(t *testing.T) { + for _, c := range []struct{ name, src string }{ + {"microflow", messageTemplateMicroflow(`log info $Msg with ({1} = 'x')`)}, + {"nanoflow", "create nanoflow M.Probe ($Msg: String)\nbegin\n show message $Msg with ({1} = 'x');\nend;\n"}, + {"rule", "create rule M.Probe ($Msg: String) returns Boolean\nbegin\n log info $Msg with ({1} = 'x');\n return true;\nend;\n"}, + {"alter fragment", "alter microflow M.Probe {\n insert after $Result begin\n log info $Msg with ({1} = 'x');\n end;\n};\n"}, + } { + t.Run(c.name, func(t *testing.T) { + var found bool + for _, v := range ValidateProgram(parseMDL(t, c.src), "") { + if v.RuleID == messageTemplateRule { + found = true + } + } + if !found { + t.Errorf("%s not reported for a %s", messageTemplateRule, c.name) + } + }) + } +} + +// messageTemplateRefusals is the one decision; the statements it does not know +// are not its business. +func TestMessageTemplateRefusals_OtherStatementsPass(t *testing.T) { + if got := messageTemplateRefusals(&ast.ReturnStmt{}); len(got) != 0 { + t.Errorf("return: %q, want none", got) + } + if got := messageTemplateRefusals(nil); len(got) != 0 { + t.Errorf("nil: %q, want none", got) + } +} + +// A body's string literal that spans lines reaches the builder kept with its +// source (shouldPreserveExpressionSource). With parameters it is still the +// template, and is stored as its value: the source — quotes and all — was +// stored as the template text, the same fallback the refusal closes for an +// expression. +func TestEmailBody_MultiLineLiteralWithParametersIsTheTemplateText(t *testing.T) { + src := messageTemplateMicroflow(sendEmailWith("Subject: 'S', Body: 'line one\nline two {1}' with ({1} = $Msg)")) + a := sendEmailActionOf(t, buildMicroflowFromMDL(t, src)) + if want := "line one\nline two {1}"; a.BodyPlainText.Text != want { + t.Errorf("body template = %q, want %q", a.BodyPlainText.Text, want) + } + if want := []string{"$Msg"}; len(a.BodyPlainText.Parameters) != 1 || a.BodyPlainText.Parameters[0] != want[0] { + t.Errorf("body parameters = %q, want %q", a.BodyPlainText.Parameters, want) + } +} diff --git a/mdl/executor/validate_alter_flow.go b/mdl/executor/validate_alter_flow.go index 682463bd6b..2efb625fd2 100644 --- a/mdl/executor/validate_alter_flow.go +++ b/mdl/executor/validate_alter_flow.go @@ -34,6 +34,9 @@ func ValidateAlterFlow(s *ast.AlterFlowStmt) []linter.Violation { for _, op := range s.Operations { v.walkAnnotations(op.Body) v.checkAnnotationLabels(op.Body) + // One statement at a time, so a fragment is judged as a whole flow + // is (MDL-TEMPLATE01, ako/mxcli#1683). + v.checkMessageTemplates(op.Body) } return v.violations } diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index a8b403afdf..0129bf5327 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -126,6 +126,9 @@ func (v *microflowValidator) validate(body []ast.MicroflowStatement) { v.checkRetrieveLimitOneAsList(body) v.checkRetrieveListAsObject(body) v.checkShowMessageType(body) + // MDL-TEMPLATE01: a message written as an expression with `with (…)` + // (ako/mxcli#1683). Same function exec calls. + v.checkMessageTemplates(body) v.checkRetrieveOverSystemAssociation(body) v.checkSetOnObjectVariable(v.params, body) v.checkSystemMemberChange(body) diff --git a/mdl/executor/validate_nanoflow.go b/mdl/executor/validate_nanoflow.go index 0091a8fa5c..f03cc8b55f 100644 --- a/mdl/executor/validate_nanoflow.go +++ b/mdl/executor/validate_nanoflow.go @@ -50,6 +50,9 @@ func validateNanoflowWith(stmt *ast.CreateNanoflowStmt, voids *voidCodeActions) v.checkDuplicateVariableNames(v.params, stmt.Body) // A nanoflow's show message has the same ShowMessageType (mendixlabs/mxcli#1280). v.checkShowMessageType(stmt.Body) + // MDL-TEMPLATE01: a nanoflow's log, show message and validation feedback + // store the same templates (ako/mxcli#1683). + v.checkMessageTemplates(stmt.Body) v.checkVoidCallOutputUse(v.params, stmt.Body) // MDL-SET01: a nanoflow's Change variable is as primitive-only (CE7247). v.checkSetOnObjectVariable(v.params, stmt.Body) diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index 5fbf7f6dc3..537e71b221 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -139,6 +139,9 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { ValidateFlowParameterAnnotations("rule '"+ruleStmt.Name.String()+"'", ruleStmt.Parameters)...) violations = append(violations, setTargetViolations("rule", ruleStmt.Name.String(), ruleStmt.Parameters, ruleStmt.Body)...) + // A rule logs and may send email (MDL-TEMPLATE01, ako/mxcli#1683). + violations = append(violations, + messageTemplateViolations("rule", ruleStmt.Name.String(), ruleStmt.Body)...) } // Check workflow for constructs MxBuild rejects (missing page, // single-outcome-with-activities, invalid decision outcome names) From d8f461b384feeb8167e2b32f79ee5e8889e38630 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 26 Oct 2026 22:06:41 +0000 Subject: [PATCH 36/40] fix: create java action writes the .java file without the class javadoc (ako/mxcli#1700) mxbuild writes a Java action's documentation as a javadoc block above `public class`. The file mxcli wrote never had it: a new action got none, and `create or modify java action` removed the one a build had written, so the next build put it back and every rewrite left a pair of unrelated VCS diffs. The model itself was intact. Mirror of mendixlabs/mxcli#1653. Cause: GenerateSource (sdk/javaactions/source.go) took no documentation, and neither did backend.JavaBackend.WriteJavaSourceFile or its executor caller, although the executor had the documentation at hand. Fix: GenerateSource and WriteJavaSourceFile take the documentation; the executor passes ja.Documentation, the one the model ends up with, so a rewrite without a doc comment keeps the block. classJavadoc writes the block as measured on mxbuild 11.13.0 with documentation set straight in the model: `\r\n`, `\r` and `\n` each end a ` * ` line (so leading/trailing breaks give empty lines), whitespace is kept, whitespace-only documentation still gets a block, and a backslash is doubled unless `u`/`U` and four hex digits follow it. mxbuild refuses `*/` in the documentation as a model error. Mock, mcp and modelsdk stubs regenerated. Verification: 14 goldens from an mxbuild deploy build (testdata/mxbuild/JA_Doc*.java.golden, two of them generated from no file) are reproduced byte for byte; executor tests check the stated and the carried documentation reach the writer. Reverting the generator and the executor argument fails all of them except the no-documentation control. Real 11.13.0 app, 15 actions: after `create or modify` over mxbuild's files, 13 of 15 are byte-identical to mxbuild's with the fix (the other two differ only in the user-code padding mxcli has always written) against 1 of 15 with the baseline; the next mxbuild build rewrote 0 of 15 files after the fix and 14 of 15 after the baseline. Co-Authored-By: Claude Opus 5.5 --- ...a-action-source-missing-class-javadoc.json | 1 + .claude/skills/mendix/java-actions/SKILL.md | 6 ++ cmd/mxcli/syntax/features_integration.go | 2 +- .../reference/microflow/create-java-action.md | 9 ++ docs/01-project/MDL_QUICK_REFERENCE.md | 2 +- .../java-action-1700-class-javadoc.mdl | 53 +++++++++++ mdl/backend/java.go | 4 +- mdl/backend/mcp/unsupported_gen.go | 2 +- mdl/backend/mock/backend.go | 2 +- mdl/backend/mock/mock_java.go | 4 +- mdl/backend/modelsdk/java_write.go | 7 +- mdl/backend/modelsdk/unimplemented_gen.go | 2 +- mdl/executor/cmd_javaactions.go | 6 +- .../cmd_javaactions_bare_enum_935_test.go | 2 +- .../cmd_javaactions_class_javadoc_test.go | 93 ++++++++++++++++++ .../cmd_javaactions_export_level_test.go | 4 +- .../cmd_javaactions_expose_preserve_test.go | 2 +- .../cmd_javaactions_param_description_test.go | 2 +- .../describe_java_action_type_params_test.go | 2 +- sdk/javaactions/classdoc_golden_test.go | 94 +++++++++++++++++++ sdk/javaactions/mxbuild_golden_test.go | 4 +- sdk/javaactions/retain_test.go | 2 +- sdk/javaactions/source.go | 63 ++++++++++++- .../testdata/mxbuild/JA_Doc1.java.golden | 53 +++++++++++ .../testdata/mxbuild/JA_Doc10.java.golden | 47 ++++++++++ .../testdata/mxbuild/JA_Doc11.java.golden | 47 ++++++++++ .../testdata/mxbuild/JA_Doc12.java.golden | 51 ++++++++++ .../testdata/mxbuild/JA_Doc13.java.golden | 47 ++++++++++ .../testdata/mxbuild/JA_Doc14.java.golden | 48 ++++++++++ .../testdata/mxbuild/JA_Doc15.java.golden | 47 ++++++++++ .../testdata/mxbuild/JA_Doc2.java.golden | 47 ++++++++++ .../testdata/mxbuild/JA_Doc3.java.golden | 48 ++++++++++ .../testdata/mxbuild/JA_Doc4.java.golden | 48 ++++++++++ .../testdata/mxbuild/JA_Doc6.java.golden | 45 +++++++++ .../testdata/mxbuild/JA_Doc7.java.golden | 49 ++++++++++ .../testdata/mxbuild/JA_Doc8.java.golden | 48 ++++++++++ .../testdata/mxbuild/JA_Doc9.java.golden | 44 +++++++++ 37 files changed, 1015 insertions(+), 22 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/sdk/2026-10-26-java-action-source-missing-class-javadoc.json create mode 100644 mdl-examples/bug-tests/java-action-1700-class-javadoc.mdl create mode 100644 mdl/executor/cmd_javaactions_class_javadoc_test.go create mode 100644 sdk/javaactions/classdoc_golden_test.go create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc1.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc10.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc11.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc12.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc13.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc14.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc15.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc2.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc3.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc4.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc6.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc7.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc8.java.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JA_Doc9.java.golden diff --git a/.claude/skills/fix-issue/findings/sdk/2026-10-26-java-action-source-missing-class-javadoc.json b/.claude/skills/fix-issue/findings/sdk/2026-10-26-java-action-source-missing-class-javadoc.json new file mode 100644 index 0000000000..d48d2dff76 --- /dev/null +++ b/.claude/skills/fix-issue/findings/sdk/2026-10-26-java-action-source-missing-class-javadoc.json @@ -0,0 +1 @@ +{"area": "sdk/javaactions", "date": "2026-10-26", "symptom": "`create java action` with a `/** … */` doc comment writes a .java file with no class javadoc, and `create or modify java action` removes the one mxbuild wrote above `public class`; the next build puts it back, so every rewrite is a pair of unrelated VCS diffs. describe shows the documentation and the unit has it — the model is intact", "cause": "`GenerateSource` (sdk/javaactions/source.go) had no documentation argument: it wrote the imports, a blank line, then `public class`. Neither `WriteJavaSourceFile` (backend.JavaBackend, mdl/backend/modelsdk/java_write.go) nor its caller in mdl/executor/cmd_javaactions.go passed the documentation, though the executor had it at hand", "file": "`sdk/javaactions/source.go` (`classJavadoc`, `escapeJavadocBackslashes`), `mdl/backend/java.go` + `modelsdk/java_write.go` (new `documentation` argument; mock, mcp unsupported_gen.go and modelsdk unimplemented_gen.go regenerated), `mdl/executor/cmd_javaactions.go` (passes ja.Documentation — the carried one); goldens `sdk/javaactions/testdata/mxbuild/JA_Doc*.java.golden`, tests `classdoc_golden_test.go`, `mdl/executor/cmd_javaactions_class_javadoc_test.go`", "insight": "Measure the format against mxbuild with documentation set straight in the model, not through a `/** */` doc comment, which trims every line and cannot hold `*/` — the upstream proposal's format was right on line structure but missed two things only an exact string shows: `\\r` alone also ends a line, and a backslash is DOUBLED unless `u`/`U` + four hex digits follow it (`\\u0041` and `\\U0041` stay, `\\user` and `\\uuu0041` double) so javac cannot read a broken unicode escape in the comment. Whitespace-only documentation is still a block. mxbuild refuses `*/` in a Java action's documentation as a model error ('Documentation contains invalid characters, */ is not allowed'), so the block cannot be closed early in a project that builds. Pass the documentation the model ENDS UP with (ja.Documentation after carriedDocumentation), not the statement's, or `create or modify` without a doc comment still strips the block. The proof that VCS churn is gone is the next build: on 15 measured actions mxbuild rewrote 14 files after the baseline's rewrite and 0 after the fix's", "refs": ["ako/mxcli#1700", "mendixlabs/mxcli#1653", "mendixlabs/mxcli#1018"], "test": "TestGenerateSource_ClassJavadocMatchesMxbuild, TestGenerateSource_ClassJavadocIsRegeneratedNotRetained, TestCreateJavaAction_WritesTheDocumentationIntoTheSource, TestCreateOrModifyJavaAction_CarriesTheDocumentationIntoTheSource"} diff --git a/.claude/skills/mendix/java-actions/SKILL.md b/.claude/skills/mendix/java-actions/SKILL.md index 44874a3d76..3d76addd4e 100644 --- a/.claude/skills/mendix/java-actions/SKILL.md +++ b/.claude/skills/mendix/java-actions/SKILL.md @@ -101,6 +101,12 @@ A trailing `-- text` after a parameter is an ordinary comment and is **not store On `create or modify`, leaving either out keeps what is stored; a new action gets no descriptions and `ReturnValueName`. The same forms apply to JavaScript actions. +The action's own `/** … */` (before `create`) is its documentation, and the `.java` +file carries it as the class javadoc above `public class`, byte for byte as mxbuild +writes it — so a rewrite followed by a build leaves the file unchanged +(ako/mxcli#1700). A rewrite without one keeps the stored documentation, in the +model and in the file. + ### Type Parameters (Generics) Type parameters let Java actions accept any entity type dynamically. Use `entity ` in a parameter type to declare the type parameter inline. That parameter becomes the **entity type selector** (receives the entity type name, e.g., `'Module.Entity'`). Bare `pEntity` parameters become **parameterized entity** params (receive entity instances, e.g., `$Variable`). diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index 5bb4610787..030867addb 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -657,7 +657,7 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "create java action", "or modify java action", "type parameter", "entity parameter", "exposed as", "returns", "generics", "drop java action", }, - Syntax: "CREATE [OR MODIFY] JAVA ACTION Module.Name[] [FOLDER 'path'](\n [/** description */] Param: Type [NOT NULL],\n EntityType: ENTITY NOT NULL,\n Obj: pEntity\n) RETURNS ReturnType [AS $ReturnName]\n[EXPOSED AS 'Label' IN 'Category'\n [ICON 'icon.png'] [ICON DARK 'icon-dark.png']\n [IMAGE 'image.png'] [IMAGE DARK 'image-dark.png']]\n[NOT EXPOSED]\nAS $$\n// Java code — AS $$ ... $$ is mandatory, cannot be omitted\n$$;\n\nOR MODIFY: updates signature/body in-place, preserves UUID.\n\nEXPOSED AS puts the action in Studio Pro's toolbox. The icon is a 64x64 PNG\nand the image a 256x192 PNG, read from disk relative to the .mdl file's own directory.\nAn OMITTED clause preserves what is stored — including bitmaps set in Studio\nPro — so removing an entry is NOT EXPOSED, and clearing one bitmap is\nDROP ICON / DROP IMAGE [DARK].\n\nA parameter's description is a /** ... */ doc comment before it, as on an\nentity attribute; AS $ReturnName names the return value (the default name of\nthe variable a microflow call stores it in), as on a microflow. Leaving either\nout keeps what is stored; a new action gets no description and ReturnValueName.\n\nType parameters: an ENTITY parameter declares T. A type parameter no\nENTITY parameter names is declared in the header, Module.Name; an\nunqualified type that is neither is refused (MDL-JA01), since it would be\nwritten as an entity with no module (CE1613).\n\nA bare Module.Name type is an entity or an enumeration, whichever the project\n(or an earlier statement of the script) has; ENUM Module.Name says enumeration\nexplicitly. A name that is neither is refused by check --references and exec,\nsince it would be written as an entity that does not exist (CE1613).\n\nRETURNS takes Boolean, Integer, Long, Decimal, String, DateTime, an enumeration,\nan entity, a list, a type parameter or Void; leaving it out returns Boolean.\nAny other type is refused (MDL-JA03) — StringTemplate(...) is a parameter\ntype only.", + Syntax: "CREATE [OR MODIFY] JAVA ACTION Module.Name[] [FOLDER 'path'](\n [/** description */] Param: Type [NOT NULL],\n EntityType: ENTITY NOT NULL,\n Obj: pEntity\n) RETURNS ReturnType [AS $ReturnName]\n[EXPOSED AS 'Label' IN 'Category'\n [ICON 'icon.png'] [ICON DARK 'icon-dark.png']\n [IMAGE 'image.png'] [IMAGE DARK 'image-dark.png']]\n[NOT EXPOSED]\nAS $$\n// Java code — AS $$ ... $$ is mandatory, cannot be omitted\n$$;\n\nOR MODIFY: updates signature/body in-place, preserves UUID.\n\nEXPOSED AS puts the action in Studio Pro's toolbox. The icon is a 64x64 PNG\nand the image a 256x192 PNG, read from disk relative to the .mdl file's own directory.\nAn OMITTED clause preserves what is stored — including bitmaps set in Studio\nPro — so removing an entry is NOT EXPOSED, and clearing one bitmap is\nDROP ICON / DROP IMAGE [DARK].\n\nA parameter's description is a /** ... */ doc comment before it, as on an\nentity attribute; AS $ReturnName names the return value (the default name of\nthe variable a microflow call stores it in), as on a microflow. Leaving either\nout keeps what is stored; a new action gets no description and ReturnValueName.\n\nThe .java file is written as mxbuild writes it, so a build leaves it unchanged:\nthe action's own /** ... */ (before CREATE) is the class javadoc, and a rewrite\nwithout one keeps the stored documentation.\n\nType parameters: an ENTITY parameter declares T. A type parameter no\nENTITY parameter names is declared in the header, Module.Name; an\nunqualified type that is neither is refused (MDL-JA01), since it would be\nwritten as an entity with no module (CE1613).\n\nA bare Module.Name type is an entity or an enumeration, whichever the project\n(or an earlier statement of the script) has; ENUM Module.Name says enumeration\nexplicitly. A name that is neither is refused by check --references and exec,\nsince it would be written as an entity that does not exist (CE1613).\n\nRETURNS takes Boolean, Integer, Long, Decimal, String, DateTime, an enumeration,\nan entity, a list, a type parameter or Void; leaving it out returns Boolean.\nAny other type is refused (MDL-JA03) — StringTemplate(...) is a parameter\ntype only.", Example: "mdl 1;\nCREATE JAVA ACTION Utils.FormatCurrency(\n /** The amount to format. */\n Amount: Decimal NOT NULL\n) RETURNS String AS $FormattedAmount\nEXPOSED AS 'Format Currency' IN 'Formatting'\n ICON 'assets/currency-64.png'\n IMAGE 'assets/currency-256.png'\nAS $$\nreturn String.format(\"%.2f\", Amount);\n$$;\n\n-- Generic entity validator with type parameter\nCREATE JAVA ACTION Utils.IsValid(\n EntityType: ENTITY NOT NULL,\n Obj: pEntity NOT NULL\n) RETURNS Boolean\nAS $$\nreturn Obj != null;\n$$;\n\n-- Type parameter with no entity selector: the argument's entity binds it\nCREATE JAVA ACTION Utils.Describe(\n Obj: T NOT NULL\n) RETURNS String\nAS $$\nreturn Obj.toString();\n$$;\n\n-- Idempotent update (preserves UUID)\nCREATE OR MODIFY JAVA ACTION Utils.FormatCurrency(\n Amount: Decimal NOT NULL,\n Decimals: Integer NOT NULL\n) RETURNS String\nAS $$\nreturn String.format(\"%.\" + Decimals + \"f\", Amount);\n$$;", SeeAlso: []string{"java-action", "javascript-action"}, }) diff --git a/docs-site/src/reference/microflow/create-java-action.md b/docs-site/src/reference/microflow/create-java-action.md index 4e4bb9dff3..2f1e655e63 100644 --- a/docs-site/src/reference/microflow/create-java-action.md +++ b/docs-site/src/reference/microflow/create-java-action.md @@ -40,6 +40,15 @@ On `CREATE OR MODIFY`, leaving either out keeps what is stored; an empty `/** */ clears a description. A new action gets no descriptions and the return value name `ReturnValueName`, as in Studio Pro. +### The Java Source File + +The statement writes `javasource//actions/.java` the way mxbuild +generates it, byte for byte, so the next build leaves it unchanged. The action's +documentation -- the `/** ... */` before `CREATE` -- is the class javadoc above +`public class`; a rewrite without one keeps the stored documentation, in the model +and in the file. (Before ako/mxcli#1700 the file had no javadoc, so every rewrite +removed the one a build had written and the next build put it back.) + ### Exposed Actions The optional `EXPOSED AS` clause makes the action visible in the Studio Pro toolbox under the specified category, allowing modelers to use it directly in microflows. diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 0e8e7f9dd5..785ab05c50 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1449,7 +1449,7 @@ Module.OrderResponse_CustomerInfo/Module.CustomerInfo as customer { |-----------|--------|-------| | Show Java actions | `list java actions [in module];` | List all or filtered by module | | Describe Java action | `describe java action Module.Name;` | Full MDL output with signature | -| Create Java action | `create [or modify] java action Module.Name [folder 'path'](params) returns type as $$ ... $$;` | OR MODIFY updates signature/body, preserves UUID | +| Create Java action | `create [or modify] java action Module.Name [folder 'path'](params) returns type as $$ ... $$;` | OR MODIFY updates signature/body, preserves UUID. The `.java` file is written as mxbuild writes it, the action's `/** … */` documentation as the class javadoc (ako/mxcli#1700) | | Create with type params | `create java action Module.Name(EntityType: entity , Obj: pEntity) ...;` | Generic type parameters | | Describe a parameter, name the return value | `create java action Module.Name(/** The text. */ Input: String) returns String as $Result as $$ ... $$;` | `/** … */` before a parameter is its description; `as $Name` the return value's name. Omitted on `or modify`, both keep what is stored | | Create exposed action | `... exposed as 'caption' in 'Category' as $$ ... $$;` | Toolbox-visible in Studio Pro | diff --git a/mdl-examples/bug-tests/java-action-1700-class-javadoc.mdl b/mdl-examples/bug-tests/java-action-1700-class-javadoc.mdl new file mode 100644 index 0000000000..fdf0837177 --- /dev/null +++ b/mdl-examples/bug-tests/java-action-1700-class-javadoc.mdl @@ -0,0 +1,53 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1700 (mendixlabs/mxcli#1653): the .java file mxcli writes has no +-- class javadoc +-- ============================================================================ +-- +-- Symptom (before fix): +-- mxbuild writes a Java action's documentation as a javadoc block above +-- `public class`. The file `create [or modify] java action` wrote never had +-- it: a new action got none, and a rewrite removed the one a build had +-- written. The next build put it back, so every rewrite was a pair of +-- unrelated diffs in version control. +-- +-- After fix: +-- The file holds the block exactly as mxbuild writes it, so a build after +-- the statement leaves the file unchanged — and the second statement, which +-- states no documentation, keeps the stored one in the model and the file. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/java-action-1700-class-javadoc.mdl -p app.mpr +-- grep -B3 'public class FormatAmount' javasource/bugtest1700/actions/FormatAmount.java +-- Expected, above the class line: +-- /** +-- * Formats an amount as text with two decimals. +-- * +-- * Rounds half up. +-- */ +-- Then build (mxcli docker build, or mxbuild --target=deploy): the file is +-- byte-identical before and after. +-- ============================================================================ + +create module BugTest1700; + +/** + * Formats an amount as text with two decimals. + * + * Rounds half up. + */ +create java action BugTest1700.FormatAmount( + /** The amount to format. */ + Amount: Decimal not null +) returns String as $FormattedAmount +as $$ +return String.format("%.2f", Amount); +$$; + +-- No doc comment: the stored documentation is kept, and so is the javadoc. +create or modify java action BugTest1700.FormatAmount( + Amount: Decimal not null +) returns String as $FormattedAmount +as $$ +return String.format("%.2f", Amount); +$$; diff --git a/mdl/backend/java.go b/mdl/backend/java.go index b906eabb99..b11e9dc354 100644 --- a/mdl/backend/java.go +++ b/mdl/backend/java.go @@ -20,7 +20,9 @@ type JavaBackend interface { CreateJavaAction(ja *javaactions.JavaAction) error UpdateJavaAction(ja *javaactions.JavaAction) error DeleteJavaAction(id model.ID) error - WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string) error + // WriteJavaSourceFile writes javasource//actions/.java as + // mxbuild generates it, documentation as the class javadoc. + WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string, documentation string) error DeleteJavaSourceFile(moduleName, actionName string) error RenameJavaSourceFile(moduleName, oldName, newName string) error ReadJavaSourceFile(moduleName, actionName string) (string, error) diff --git a/mdl/backend/mcp/unsupported_gen.go b/mdl/backend/mcp/unsupported_gen.go index 0be8f9a70a..8f34e838b0 100644 --- a/mdl/backend/mcp/unsupported_gen.go +++ b/mdl/backend/mcp/unsupported_gen.go @@ -1450,7 +1450,7 @@ func (unsupportedBackend) WriteJavaScriptSourceFile(_ string, _ string, _ string return } -func (unsupportedBackend) WriteJavaSourceFile(_ string, _ string, _ string, _ []*types.JavaActionParameter, _ types.CodeActionReturnType, _ []string, _ string) (err0 error) { +func (unsupportedBackend) WriteJavaSourceFile(_ string, _ string, _ string, _ []*types.JavaActionParameter, _ types.CodeActionReturnType, _ []string, _ string, _ string) (err0 error) { err0 = errUnsupported("WriteJavaSourceFile") return } diff --git a/mdl/backend/mock/backend.go b/mdl/backend/mock/backend.go index 3df8fc29dc..cc04eeefc6 100644 --- a/mdl/backend/mock/backend.go +++ b/mdl/backend/mock/backend.go @@ -260,7 +260,7 @@ type MockBackend struct { CreateJavaActionFunc func(ja *javaactions.JavaAction) error UpdateJavaActionFunc func(ja *javaactions.JavaAction) error DeleteJavaActionFunc func(id model.ID) error - WriteJavaSourceFileFunc func(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string) error + WriteJavaSourceFileFunc func(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string, documentation string) error DeleteJavaSourceFileFunc func(moduleName, actionName string) error RenameJavaSourceFileFunc func(moduleName, oldName, newName string) error ReadJavaSourceFileFunc func(moduleName, actionName string) (string, error) diff --git a/mdl/backend/mock/mock_java.go b/mdl/backend/mock/mock_java.go index 81965ccd1f..17d2479ecf 100644 --- a/mdl/backend/mock/mock_java.go +++ b/mdl/backend/mock/mock_java.go @@ -64,9 +64,9 @@ func (m *MockBackend) DeleteJavaAction(id model.ID) error { return nil } -func (m *MockBackend) WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string) error { +func (m *MockBackend) WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string, documentation string) error { if m.WriteJavaSourceFileFunc != nil { - return m.WriteJavaSourceFileFunc(moduleName, actionName, javaCode, params, returnType, extraImports, extraCode) + return m.WriteJavaSourceFileFunc(moduleName, actionName, javaCode, params, returnType, extraImports, extraCode, documentation) } return nil } diff --git a/mdl/backend/modelsdk/java_write.go b/mdl/backend/modelsdk/java_write.go index 488f904ce3..c649a4ee7d 100644 --- a/mdl/backend/modelsdk/java_write.go +++ b/mdl/backend/modelsdk/java_write.go @@ -91,8 +91,9 @@ func (b *Backend) UpdateJavaAction(ja *javaactions.JavaAction) error { // WriteJavaSourceFile writes the .java stub for a java action under // javasource//actions/.java, using the shared generator so the -// output is byte-identical to the legacy engine. -func (b *Backend) WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string) error { +// output is byte-identical to what mxbuild generates — the action's +// documentation as the class javadoc included (ako/mxcli#1700). +func (b *Backend) WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string, documentation string) error { if b.path == "" { return fmt.Errorf("WriteJavaSourceFile: no project path") } @@ -107,7 +108,7 @@ func (b *Backend) WriteJavaSourceFile(moduleName, actionName string, javaCode st // into Java that does not compile (ako/mxcli#705). existing, _ := os.ReadFile(path) extraImports, extraCode = javaactions.RetainSections(string(existing), extraImports, extraCode) - source := javaactions.GenerateSource(moduleName, actionName, javaCode, params, returnType, extraImports, extraCode) + source := javaactions.GenerateSource(moduleName, actionName, javaCode, params, returnType, extraImports, extraCode, documentation) changed, err := javaactions.WriteSourceIfChanged(path, source) b.noteFileWrite(changed) if err != nil { diff --git a/mdl/backend/modelsdk/unimplemented_gen.go b/mdl/backend/modelsdk/unimplemented_gen.go index 531969ef61..48a2787539 100644 --- a/mdl/backend/modelsdk/unimplemented_gen.go +++ b/mdl/backend/modelsdk/unimplemented_gen.go @@ -1292,7 +1292,7 @@ func (unimplemented) WriteJavaScriptSourceFile(_ string, _ string, _ string, _ [ return errUnimplemented("WriteJavaScriptSourceFile") } -func (unimplemented) WriteJavaSourceFile(_ string, _ string, _ string, _ []*types.JavaActionParameter, _ types.CodeActionReturnType, _ []string, _ string) error { +func (unimplemented) WriteJavaSourceFile(_ string, _ string, _ string, _ []*types.JavaActionParameter, _ types.CodeActionReturnType, _ []string, _ string, _ string) error { return errUnimplemented("WriteJavaSourceFile") } diff --git a/mdl/executor/cmd_javaactions.go b/mdl/executor/cmd_javaactions.go index ea98f08f6b..0db667c2c4 100644 --- a/mdl/executor/cmd_javaactions.go +++ b/mdl/executor/cmd_javaactions.go @@ -690,7 +690,11 @@ func execCreateJavaAction(ctx *ExecContext, s *ast.CreateJavaActionStmt) error { // module's compiled one. The statement says nothing about the code, so the // file is left alone. if s.JavaCode != "" && strings.TrimSpace(s.JavaCode) != javaSourceOmittedBody { - if err := ctx.Backend.WriteJavaSourceFile(moduleName, s.Name.Name, s.JavaCode, ja.Parameters, ja.ReturnType, s.Imports, s.ExtraCode); err != nil { + // ja.Documentation, not the statement's: a rewrite without a doc + // comment carries the stored one, and mxbuild writes it as the class + // javadoc — leaving it out removed the block a build had written, and + // the next build put it back (ako/mxcli#1700). + if err := ctx.Backend.WriteJavaSourceFile(moduleName, s.Name.Name, s.JavaCode, ja.Parameters, ja.ReturnType, s.Imports, s.ExtraCode, ja.Documentation); err != nil { return mdlerrors.NewBackend("write java source file", err) } } diff --git a/mdl/executor/cmd_javaactions_bare_enum_935_test.go b/mdl/executor/cmd_javaactions_bare_enum_935_test.go index 97ecbef4d1..3961357c9d 100644 --- a/mdl/executor/cmd_javaactions_bare_enum_935_test.go +++ b/mdl/executor/cmd_javaactions_bare_enum_935_test.go @@ -58,7 +58,7 @@ func newCodeActionProject() *codeActionProject { return nil }, WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string) error { + javaactions.CodeActionReturnType, []string, string, string) error { return nil }, WriteJavaScriptSourceFileFunc: func(string, string, string, []*types.JavaActionParameter, types.CodeActionReturnType) error { diff --git a/mdl/executor/cmd_javaactions_class_javadoc_test.go b/mdl/executor/cmd_javaactions_class_javadoc_test.go new file mode 100644 index 0000000000..186d89d7cb --- /dev/null +++ b/mdl/executor/cmd_javaactions_class_javadoc_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/javaactions" +) + +// ako/mxcli#1700 (mendixlabs/mxcli#1653): mxbuild writes a Java action's +// documentation as the class javadoc, and the .java file mxcli wrote had none — +// a new action got no block, and `create or modify` removed the one a build had +// written, so every rewrite was a pair of unrelated diffs. The generator now +// writes it (sdk/javaactions, measured against mxbuild); this is the executor +// handing it the documentation the model ends up with. + +// writtenJavaDoc runs s against a backend holding stored (nil: none) and +// returns the documentation WriteJavaSourceFile was given. +func writtenJavaDoc(t *testing.T, s *ast.CreateJavaActionStmt, stored *javaactions.JavaAction) string { + t.Helper() + mod := mkModule("MyModule") + h := mkHierarchy(mod) + var light []*types.JavaAction + if stored != nil { + stored.ContainerID = mod.ID + withContainer(h, stored.ContainerID, mod.ID) + light = []*types.JavaAction{{ + BaseElement: model.BaseElement{ID: stored.ID}, + ContainerID: stored.ContainerID, + Name: stored.Name, + Documentation: stored.Documentation, + }} + } + written, called := "", false + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListJavaActionsFunc: func() ([]*types.JavaAction, error) { return light, nil }, + ReadJavaActionByNameFunc: func(string) (*javaactions.JavaAction, error) { return stored, nil }, + CreateJavaActionFunc: func(*javaactions.JavaAction) error { return nil }, + UpdateJavaActionFunc: func(*javaactions.JavaAction) error { return nil }, + WriteJavaSourceFileFunc: func(_, _, _ string, _ []*javaactions.JavaActionParameter, + _ javaactions.CodeActionReturnType, _ []string, _ string, documentation string) error { + written, called = documentation, true + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, execCreateJavaAction(ctx, s)) + if !called { + t.Fatal("WriteJavaSourceFile was never called") + } + return written +} + +func TestCreateJavaAction_WritesTheDocumentationIntoTheSource(t *testing.T) { + s := parseOne[*ast.CreateJavaActionStmt](t, `/** + * Formats an amount as text with two decimals. + * + * Second paragraph. + */ +create java action MyModule.FormatAmount(Amount: Decimal not null) returns String +as $$ +return String.format("%.2f", Amount); +$$;`) + if got, want := writtenJavaDoc(t, s, nil), "Formats an amount as text with two decimals.\n\nSecond paragraph."; got != want { + t.Errorf("documentation written = %q, want %q", got, want) + } +} + +// A rewrite without a doc comment keeps the stored documentation (#1018), so +// the file keeps the block too — the report's `create or modify` removed it. +func TestCreateOrModifyJavaAction_CarriesTheDocumentationIntoTheSource(t *testing.T) { + stored := &javaactions.JavaAction{Name: "JA_Ping", Documentation: "Stored in Studio Pro."} + stored.ID = nextID("ja") + if got := writtenJavaDoc(t, rewriteStmt(false), stored); got != "Stored in Studio Pro." { + t.Errorf("documentation written = %q, want the stored one", got) + } + + // Control: a stated doc comment replaces it, in the model and the file. + s := rewriteStmt(false) + s.Documentation, s.DocumentationSet = "Stated.", true + stored = &javaactions.JavaAction{Name: "JA_Ping", Documentation: "Stored in Studio Pro."} + stored.ID = nextID("ja") + if got := writtenJavaDoc(t, s, stored); got != "Stated." { + t.Errorf("documentation written = %q, want the stated one", got) + } +} diff --git a/mdl/executor/cmd_javaactions_export_level_test.go b/mdl/executor/cmd_javaactions_export_level_test.go index 2d08d7e9c1..7142c9eccf 100644 --- a/mdl/executor/cmd_javaactions_export_level_test.go +++ b/mdl/executor/cmd_javaactions_export_level_test.go @@ -58,7 +58,7 @@ func TestCreateJavaActionWritesStudioProDefaults(t *testing.T) { return nil }, WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string) error { + javaactions.CodeActionReturnType, []string, string, string) error { return nil }, } @@ -160,7 +160,7 @@ func TestJavaActionBody_PlaceholderIsNotWritten(t *testing.T) { ctx, _ := exposeRewriteCtx(t, stored, mod) wrote := false ctx.Backend.(*mock.MockBackend).WriteJavaSourceFileFunc = func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string) error { + javaactions.CodeActionReturnType, []string, string, string) error { wrote = true return nil } diff --git a/mdl/executor/cmd_javaactions_expose_preserve_test.go b/mdl/executor/cmd_javaactions_expose_preserve_test.go index 477365376a..f179dd4a7b 100644 --- a/mdl/executor/cmd_javaactions_expose_preserve_test.go +++ b/mdl/executor/cmd_javaactions_expose_preserve_test.go @@ -71,7 +71,7 @@ func exposeRewriteCtx(t *testing.T, stored *javaactions.JavaAction, mod *model.M return nil }, WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string) error { + javaactions.CodeActionReturnType, []string, string, string) error { return nil }, } diff --git a/mdl/executor/cmd_javaactions_param_description_test.go b/mdl/executor/cmd_javaactions_param_description_test.go index b06e3b58ff..b292cb3322 100644 --- a/mdl/executor/cmd_javaactions_param_description_test.go +++ b/mdl/executor/cmd_javaactions_param_description_test.go @@ -63,7 +63,7 @@ func TestCreateJavaAction_WritesStatedParameterDescriptionAndReturnName(t *testi return nil }, WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string) error { + javaactions.CodeActionReturnType, []string, string, string) error { return nil }, } diff --git a/mdl/executor/describe_java_action_type_params_test.go b/mdl/executor/describe_java_action_type_params_test.go index 996c66e70c..cd1bfe30a5 100644 --- a/mdl/executor/describe_java_action_type_params_test.go +++ b/mdl/executor/describe_java_action_type_params_test.go @@ -50,7 +50,7 @@ func createJACtx(t *testing.T) (*ExecContext, **javaactions.JavaAction) { return nil }, WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string) error { + javaactions.CodeActionReturnType, []string, string, string) error { return nil }, } diff --git a/sdk/javaactions/classdoc_golden_test.go b/sdk/javaactions/classdoc_golden_test.go new file mode 100644 index 0000000000..6d7a58be9c --- /dev/null +++ b/sdk/javaactions/classdoc_golden_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +package javaactions + +import ( + "strings" + "testing" +) + +// ako/mxcli#1700 (mendixlabs/mxcli#1653): mxbuild writes a Java action's +// documentation as a javadoc block above `public class`, and GenerateSource +// wrote none — so every `create [or modify] java action` removed the block a +// build had written, and the next build put it back. +// +// The goldens JA_Doc*.java.golden are the files mxbuild 11.13.0 +// (`--target=deploy`) left for actions whose documentation was set to exactly +// the strings below — written straight into the model, since a `/** … */` doc +// comment trims each line and cannot hold a `*/`. JA_Doc2 and JA_Doc6 were +// deleted before the build, so mxbuild generated them from nothing; the others +// it regenerated over the file mxcli had written without a block. What the +// documentation became, measured: +// +// - one ` * ` line per line, `\r\n`, `\r` and `\n` each ending one — so a +// leading or trailing break gives an empty ` * ` line, and spaces and tabs +// are kept as they are, trailing ones included; +// - a backslash is doubled unless a unicode escape follows it — `u` or `U` +// and four hex digits — so javac cannot read `\user` as a broken one +// (`\uuu0041`, legal Java, is doubled too); +// - no documentation, no block; whitespace alone is documentation. +// +// A parameter's description is not written into the file (JA_Doc1 has one), +// and mxbuild refuses a documentation holding `*/` as a model error +// ("Documentation contains invalid characters, */ is not allowed"). +var classDocCases = []struct { + name string + doc string + userCode string // "" — generated by mxbuild from no file at all + params []*JavaActionParameter +}{ + {"JA_Doc1", "Formats an amount as text with two decimals.", "return null;", + []*JavaActionParameter{{Name: "Amount", Description: "The amount to format.", ParameterType: &DecimalType{}}}}, + {"JA_Doc2", "Line one.\n\nLine three.", "", nil}, + {"JA_Doc3", "Ends with a newline.\n", "return null;", nil}, + {"JA_Doc4", " Leading spaces\ttab\nTrailing spaces ", "return null;", nil}, + {"JA_Doc6", "Café — naïve ünïcode, emoji \U0001F600, nbsp\u00a0and line separator \u2028 kept", "", nil}, + {"JA_Doc7", "CRLF one\r\nCRLF two\r\n", "return null;", nil}, + {"JA_Doc8", "\nStarts with a newline", "return null;", nil}, + {"JA_Doc9", "", "return null;", nil}, + {"JA_Doc10", " ", "return null;", nil}, + {"JA_Doc11", `doubled: a \user b, \n, \u004G, \uuu0041, \u00 x; kept: \u00e9 \U0041 \uAbCd \u00411; end\`, "return null;", nil}, + {"JA_Doc12", "Lone CR\rhere\n\n\nthree newlines", "return null;", nil}, + {"JA_Doc13", `three \\\u0041 four \\\\u0041`, "return null;", nil}, + {"JA_Doc14", " * star line\n**bold** /** nested @param x {@code y} html", "return null;", nil}, + {"JA_Doc15", "\t", "return null;", nil}, +} + +func TestGenerateSource_ClassJavadocMatchesMxbuild(t *testing.T) { + stub := `throw new com.mendix.systemwideinterfaces.MendixRuntimeException("Java action was not implemented");` + for _, c := range classDocCases { + t.Run(c.name, func(t *testing.T) { + golden := readGolden(t, c.name) + existing, userCode := golden, c.userCode + if userCode == "" { + existing, userCode = "", stub + } + imports, extra := RetainSections(existing, nil, "") + got := GenerateSource("MyFirstModule", c.name, userCode, c.params, &StringType{}, imports, extra, c.doc) + if c.userCode == "" { + // mxcli pads the user code with a blank line on each side. + got = strings.Replace(got, "// BEGIN USER CODE\r\n\r\n", "// BEGIN USER CODE\r\n", 1) + got = strings.Replace(got, "\r\n\r\n\t\t// END USER CODE", "\r\n\t\t// END USER CODE", 1) + } + if got != golden { + t.Errorf("generated source differs from mxbuild's:\n%s", lineDiff(golden, got)) + } + }) + } +} + +// The block replaces the one in the file: regenerating with other +// documentation leaves one block, and with none, no block — the documentation +// is the model's, not one of the sections a regeneration retains. +func TestGenerateSource_ClassJavadocIsRegeneratedNotRetained(t *testing.T) { + golden := readGolden(t, "JA_Doc2") + imports, extra := RetainSections(golden, nil, "") + got := GenerateSource("MyFirstModule", "JA_Doc2", "return null;", nil, &StringType{}, imports, extra, "Other.") + if n := strings.Count(got, "/**\r\n * "); n != 1 || !strings.Contains(got, "/**\r\n * Other.\r\n */\r\npublic class JA_Doc2") { + t.Errorf("want one block holding the new documentation (%d found):\n%s", n, got) + } + got = GenerateSource("MyFirstModule", "JA_Doc2", "return null;", nil, &StringType{}, imports, extra, "") + if strings.Contains(got, "Line one.") || !strings.Contains(got, "UserAction;\r\n\r\npublic class JA_Doc2") { + t.Errorf("want no block without documentation:\n%s", got) + } +} diff --git a/sdk/javaactions/mxbuild_golden_test.go b/sdk/javaactions/mxbuild_golden_test.go index 85a0252521..4046afd43d 100644 --- a/sdk/javaactions/mxbuild_golden_test.go +++ b/sdk/javaactions/mxbuild_golden_test.go @@ -109,7 +109,7 @@ func TestGenerateSource_RegeneratesWhatMxbuildWrote(t *testing.T) { t.Run(c.name, func(t *testing.T) { golden := readGolden(t, c.name) imports, extra := RetainSections(golden, nil, "") - got := GenerateSource("MyFirstModule", c.name, c.userCode, c.params, c.ret, imports, extra) + got := GenerateSource("MyFirstModule", c.name, c.userCode, c.params, c.ret, imports, extra, "") if got != golden { t.Errorf("generated source differs from mxbuild's:\n%s", lineDiff(golden, got)) } @@ -129,7 +129,7 @@ func TestGenerateSource_NewFileMatchesMxbuild(t *testing.T) { t.Run(c.name, func(t *testing.T) { golden := readGolden(t, c.name) imports, extra := RetainSections("", nil, "") - got := GenerateSource("MyFirstModule", c.name, stub, c.params, c.ret, imports, extra) + got := GenerateSource("MyFirstModule", c.name, stub, c.params, c.ret, imports, extra, "") // mxcli pads the user code with a blank line on each side. got = strings.Replace(got, "// BEGIN USER CODE\r\n\r\n", "// BEGIN USER CODE\r\n", 1) got = strings.Replace(got, "\r\n\r\n\t\t// END USER CODE", "\r\n\t\t// END USER CODE", 1) diff --git a/sdk/javaactions/retain_test.go b/sdk/javaactions/retain_test.go index 48cb3e6737..8ac23cdf78 100644 --- a/sdk/javaactions/retain_test.go +++ b/sdk/javaactions/retain_test.go @@ -94,7 +94,7 @@ func TestRetainSections_MergesWithWhatTheStatementSaid(t *testing.T) { func TestGenerateSource_WithRetainedSectionsKeepsTheHelper(t *testing.T) { imports, extra := RetainSections(studioProSource, nil, "") src := GenerateSource("FeedbackModule", "XSS_Sanitizer", "return sanitize(stringToSanitize);", - nil, &StringType{}, imports, extra) + nil, &StringType{}, imports, extra, "") for _, want := range []string{ "import java.util.regex.Pattern;", "public static String sanitize(String input) {", diff --git a/sdk/javaactions/source.go b/sdk/javaactions/source.go index 572be82a44..f2c17c4146 100644 --- a/sdk/javaactions/source.go +++ b/sdk/javaactions/source.go @@ -33,7 +33,10 @@ const ( // RetainSections. The imports the generated code needs and the list lacks are // appended, sorted. That is what mxbuild does: it never reorders or drops an // import, so for a new file it writes just the needed ones, sorted. -func GenerateSource(moduleName, actionName string, userCode string, params []*JavaActionParameter, returnType CodeActionReturnType, imports []string, extraCode string) string { +// +// documentation is the action's, written as the class javadoc (classJavadoc); +// "" writes none. +func GenerateSource(moduleName, actionName string, userCode string, params []*JavaActionParameter, returnType CodeActionReturnType, imports []string, extraCode string, documentation string) string { moduleNameLower := strings.ToLower(moduleName) returnTypeStr := javaReturnType(returnType) @@ -56,6 +59,7 @@ func GenerateSource(moduleName, actionName string, userCode string, params []*Ja } sb.WriteString("\n") + sb.WriteString(classJavadoc(documentation)) sb.WriteString(fmt.Sprintf("public class %s extends UserAction<%s>\n", actionName, returnTypeStr)) sb.WriteString("{\n") @@ -154,6 +158,63 @@ func GenerateSource(moduleName, actionName string, userCode string, params []*Ja return strings.ReplaceAll(src, "\n", "\r\n") } +// classJavadoc is the javadoc block mxbuild writes above the class for an +// action's documentation, LF-terminated like the rest of the builder's output: +// `/**`, one ` * ` per line, ` */` (ako/mxcli#1700). Measured on +// mxbuild 11.13.0 (testdata/mxbuild/JA_Doc*): `\r\n`, `\r` and `\n` each end +// a line, so a leading or trailing break is an empty ` * ` line, and spaces +// and tabs are kept as they stand; a backslash is doubled unless a unicode +// escape (`u` or `U` and four hex digits) follows it, so javac cannot read +// one as a broken escape. No documentation is no block; whitespace alone is +// documentation. (mxbuild refuses a documentation holding `*/` as a model +// error, so the block is never closed early by a project that builds.) +func classJavadoc(documentation string) string { + if documentation == "" { + return "" + } + var sb strings.Builder + sb.WriteString("/**\n") + text := strings.ReplaceAll(documentation, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + for line := range strings.SplitSeq(text, "\n") { + sb.WriteString(" * ") + sb.WriteString(escapeJavadocBackslashes(line)) + sb.WriteString("\n") + } + sb.WriteString(" */\n") + return sb.String() +} + +// escapeJavadocBackslashes doubles every backslash in s that does not start a +// unicode escape — `u` or `U` followed by four hex digits — as mxbuild does. +func escapeJavadocBackslashes(s string) string { + if !strings.Contains(s, "\\") { + return s + } + var sb strings.Builder + for i := 0; i < len(s); i++ { + sb.WriteByte(s[i]) + if s[i] == '\\' && !startsUnicodeEscape(s[i+1:]) { + sb.WriteByte('\\') + } + } + return sb.String() +} + +// startsUnicodeEscape reports whether s begins with `u` or `U` and four hex +// digits. +func startsUnicodeEscape(s string) bool { + if len(s) < 5 || (s[0] != 'u' && s[0] != 'U') { + return false + } + for _, c := range []byte(s[1:5]) { + if !('0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F') { + return false + } + } + return true +} + // completeImports returns imports, deduplicated, with the ones the generated // code needs and imports lacks appended in sorted order. func completeImports(imports []string, params []*JavaActionParameter, returnType CodeActionReturnType) []string { diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc1.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc1.java.golden new file mode 100644 index 0000000000..6ead51f5d5 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc1.java.golden @@ -0,0 +1,53 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * Formats an amount as text with two decimals. + */ +public class JA_Doc1 extends UserAction +{ + private final java.math.BigDecimal Amount; + + public JA_Doc1( + IContext context, + java.math.BigDecimal _amount + ) + { + super(context); + this.Amount = _amount; + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc1"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc10.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc10.java.golden new file mode 100644 index 0000000000..c31ce56040 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc10.java.golden @@ -0,0 +1,47 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * + */ +public class JA_Doc10 extends UserAction +{ + public JA_Doc10(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc10"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc11.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc11.java.golden new file mode 100644 index 0000000000..8259b16355 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc11.java.golden @@ -0,0 +1,47 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * doubled: a \\user b, \\n, \\u004G, \\uuu0041, \\u00 x; kept: \u00e9 \U0041 \uAbCd \u00411; end\\ + */ +public class JA_Doc11 extends UserAction +{ + public JA_Doc11(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc11"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc12.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc12.java.golden new file mode 100644 index 0000000000..d97109b088 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc12.java.golden @@ -0,0 +1,51 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * Lone CR + * here + * + * + * three newlines + */ +public class JA_Doc12 extends UserAction +{ + public JA_Doc12(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc12"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc13.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc13.java.golden new file mode 100644 index 0000000000..fff9f960d1 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc13.java.golden @@ -0,0 +1,47 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * three \\\\\u0041 four \\\\\\\u0041 + */ +public class JA_Doc13 extends UserAction +{ + public JA_Doc13(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc13"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc14.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc14.java.golden new file mode 100644 index 0000000000..7dc1a69ca8 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc14.java.golden @@ -0,0 +1,48 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * * star line + * **bold** /** nested @param x {@code y} html + */ +public class JA_Doc14 extends UserAction +{ + public JA_Doc14(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc14"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc15.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc15.java.golden new file mode 100644 index 0000000000..fc4b47ca3b --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc15.java.golden @@ -0,0 +1,47 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * + */ +public class JA_Doc15 extends UserAction +{ + public JA_Doc15(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc15"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc2.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc2.java.golden new file mode 100644 index 0000000000..983fc3eb4c --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc2.java.golden @@ -0,0 +1,47 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * Line one. + * + * Line three. + */ +public class JA_Doc2 extends UserAction +{ + public JA_Doc2(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + throw new com.mendix.systemwideinterfaces.MendixRuntimeException("Java action was not implemented"); + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc2"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc3.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc3.java.golden new file mode 100644 index 0000000000..3eae8cb6fb --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc3.java.golden @@ -0,0 +1,48 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * Ends with a newline. + * + */ +public class JA_Doc3 extends UserAction +{ + public JA_Doc3(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc3"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc4.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc4.java.golden new file mode 100644 index 0000000000..d0f9655e85 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc4.java.golden @@ -0,0 +1,48 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * Leading spaces tab + * Trailing spaces + */ +public class JA_Doc4 extends UserAction +{ + public JA_Doc4(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc4"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc6.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc6.java.golden new file mode 100644 index 0000000000..d74b3e4173 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc6.java.golden @@ -0,0 +1,45 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * Café — naïve ünïcode, emoji 😀, nbsp and line separator 
 kept + */ +public class JA_Doc6 extends UserAction +{ + public JA_Doc6(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + throw new com.mendix.systemwideinterfaces.MendixRuntimeException("Java action was not implemented"); + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc6"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc7.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc7.java.golden new file mode 100644 index 0000000000..4e0645d5dc --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc7.java.golden @@ -0,0 +1,49 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * CRLF one + * CRLF two + * + */ +public class JA_Doc7 extends UserAction +{ + public JA_Doc7(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc7"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc8.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc8.java.golden new file mode 100644 index 0000000000..63dd913233 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc8.java.golden @@ -0,0 +1,48 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +/** + * + * Starts with a newline + */ +public class JA_Doc8 extends UserAction +{ + public JA_Doc8(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc8"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Doc9.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Doc9.java.golden new file mode 100644 index 0000000000..fe3bd9b6c3 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Doc9.java.golden @@ -0,0 +1,44 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package myfirstmodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; + +public class JA_Doc9 extends UserAction +{ + public JA_Doc9(IContext context) + { + super(context); + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + return null; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Doc9"; + } + + // BEGIN EXTRA CODE + // END EXTRA CODE +} From 8ed83a7c88f9a3b7b79596437ee71b70a185b7ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:26:59 +0000 Subject: [PATCH 37/40] feat(workflows): repeat a non-interrupting timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio Pro's recurring reminder had no MDL spelling: every non-interrupting timer mxcli wrote fired once, and a describe -> create or modify turned a Studio Pro reminder into a one-shot timer. `repeat every at most times` now follows a non-interrupting timer's delay — a boundary event, in CREATE and in ALTER's insert — and a timer event sub-process start's expression. It is written as Workflows$LinearRecurrence, read back, and printed by describe. repeat, every and times are keywords that stay usable as names; `repeat` is excluded from a delay's first token. The values come from mxbuild 11.15.0 itself: the runtime's scheduled-event interval enum (Second..Year as stored), and Studio Pro's model validation, which allows minute, hour, day, week or month (MW0005) and at least 2 executions counting the first (MW0004). mx check enforces none of it, so MDL-WF22 refuses an interrupting timer, any other unit, an interval below 1 and fewer than 2 executions. Measured: 0 errors on 11.15.0, describe -> replay "Unchanged workflow", an ALTER insert stores a weekly one. At runtime (every minute, at most 3 / 2 times) the boundary timer fired 3 times on 11.13 and on 11.15; the event sub-process start fired twice on 11.15 but once on 11.13, whose runtime model has no recurrence on the start, so that form is gated at 11.15. MCP refuses a recurrence rather than writing a one-shot timer. The #1713 rewrite refusal drops its recurrence row. Fixes #1716 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019xfGeNtrQ5BJrH2Ew64rt5 --- .../2026-10-26-recurring-workflow-timers.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 27 ++-- cmd/mxcli/lsp_completions_gen.go | 3 + cmd/mxcli/syntax/features_workflow.go | 4 +- .../src/reference/workflow/create-workflow.md | 3 + docs/01-project/MDL_QUICK_REFERENCE.md | 4 +- .../bug-tests/1716-recurring-timers.mdl | 62 +++++++++ mdl/ast/ast_workflow.go | 16 ++- mdl/backend/mcp/workflow.go | 19 ++- mdl/backend/mcp/workflow_member_kind_test.go | 2 +- mdl/backend/mcp/workflow_test.go | 21 ++- mdl/backend/mcp/workflow_timer_test.go | 6 +- mdl/backend/mock/mock_workflow_mutator.go | 6 +- mdl/backend/modelsdk/workflow_read.go | 16 ++- .../modelsdk/workflow_recurrence_test.go | 61 +++++++++ mdl/backend/modelsdk/workflow_write.go | 19 ++- mdl/backend/mutation.go | 2 +- mdl/backend/wfmutator/member_kind_test.go | 6 +- mdl/backend/wfmutator/mutator.go | 16 ++- mdl/backend/wfmutator/mutator_test.go | 12 +- mdl/backend/wfmutator/template_params_test.go | 23 ++++ mdl/executor/cmd_alter_workflow.go | 5 +- mdl/executor/cmd_workflows.go | 11 ++ mdl/executor/cmd_workflows_write.go | 17 +++ mdl/executor/validate_workflow.go | 2 + mdl/executor/validate_workflow_recurrence.go | 123 ++++++++++++++++++ .../validate_workflow_recurrence_test.go | 91 +++++++++++++ mdl/executor/validate_workflow_unstatable.go | 20 ++- .../validate_workflow_unstatable_test.go | 15 ++- mdl/executor/version_gates.go | 8 ++ mdl/executor/version_gates_test.go | 4 + .../workflow_interrupting_end_test.go | 2 +- mdl/grammar/MDLLexer.g4 | 5 + mdl/grammar/domains/MDLSettings.g4 | 1 + mdl/grammar/domains/MDLWorkflow.g4 | 24 +++- mdl/visitor/visitor_alter_workflow.go | 2 +- mdl/visitor/visitor_workflow.go | 2 + mdl/visitor/visitor_workflow_expressions.go | 20 +++ .../visitor_workflow_recurrence_test.go | 74 +++++++++++ sdk/versions/mendix-11.yaml | 4 + sdk/workflows/workflow.go | 45 +++++++ 41 files changed, 736 insertions(+), 68 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-recurring-workflow-timers.json create mode 100644 mdl-examples/bug-tests/1716-recurring-timers.mdl create mode 100644 mdl/backend/modelsdk/workflow_recurrence_test.go create mode 100644 mdl/executor/validate_workflow_recurrence.go create mode 100644 mdl/executor/validate_workflow_recurrence_test.go create mode 100644 mdl/visitor/visitor_workflow_recurrence_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-recurring-workflow-timers.json b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-recurring-workflow-timers.json new file mode 100644 index 0000000000..ac3aa2340c --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-26-recurring-workflow-timers.json @@ -0,0 +1 @@ +{"area": "mdl/grammar", "date": "2026-10-26", "symptom": "A non-interrupting timer could not repeat: MDL had no spelling for Studio Pro's recurring reminder, mxcli always wrote Recurrence: null, and a describe -> create or modify turned a Studio Pro reminder into a one-shot timer (the #1713 refusal). A timer-started event sub-process's recurrence was neither written nor read", "cause": "No grammar for Workflows$LinearRecurrence; the writers hard-coded null; the reader skipped it; the vendored metamodel predates the event sub-process start types, so their Recurrence was not even known", "file": "`mdl/grammar/MDLLexer.g4` (REPEAT, EVERY, TIMES, usable as names), `mdl/grammar/domains/MDLWorkflow.g4` (workflowRecurrence on boundary timers and timer event sub-process starts; workflowTimerDelay predicate), `sdk/workflows/workflow.go` (Recurrence, RecurrenceIntervalType), `mdl/backend/modelsdk/workflow_write.go` + `workflow_read.go`, `mdl/backend/wfmutator/mutator.go` + `mcp/workflow.go` + `mock` (InsertBoundaryEvent recurrence), `mdl/executor/validate_workflow_recurrence.go` (MDL-WF22), `mdl/executor/version_gates.go` + `sdk/versions/mendix-11.yaml` (recurring_timer_event_subprocesses 11.15)", "insight": "The IntervalType values are not in the vendored metamodel and mx check validates neither them nor the bounds (it accepted 'Bogus'), so mx check could not establish anything. Two sources inside the mxbuild distribution did: the runtime jar's enum (com.mendix.model.scheduledevents.ModelIntervalType, javap: stringValue Second..Year) gave the stored strings, and the Studio Pro web model bundle (modeler/ide-client/registry/main.js — grep the property name) gave the schema defaults and the validation Studio Pro applies (MW0004 maxExecutions >= 2 counting the first occurrence, MW0005 Minute/Hour/Day/Week/Month only). Then a runtime boot settled what mx check could not: on 11.13 the boundary recurrence fired 3 of 3 but the event sub-process start fired once — `strings` on the 11.13 workflows-metamodel.jar showed ModelNonInterruptingTimerEventSubProcessStartActivity has no recurrence there, 11.15's does — a version gap mx check cannot see. Making repeat/every/times keywords needed the delay predicate (`timer repeat …` would read `repeat` as the delay) and the keyword-as-name list; MCP refuses a recurrence because PED's timer constructors drop what they do not take", "fix": "`repeat every at most times` after a non-interrupting timer's delay (boundary event, alter insert) and after a timer event sub-process start's expression (gated 11.15); writer emits Workflows$LinearRecurrence, reader returns it, describe prints it; MDL-WF22 refuses an interrupting timer, a unit other than minute..month, interval < 1 and fewer than 2 executions; the #1713 rewrite refusal drops its recurrence row", "refs": ["ako/mxcli#1716", "ako/mxcli#1713"], "ce": []} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index 4751acedbd..4406f7db81 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -12,18 +12,18 @@ description: "Author Mendix workflows in MDL — user tasks, decisions, parallel |---|---| | 36-47 | When to Use This Skill | | 48-118 | Syntax — CREATE WORKFLOW | -| 119-301 | Activities | -| 302-308 | DROP WORKFLOW | -| 309-374 | ALTER WORKFLOW | -| 375-395 | DESCRIBE round-trip | -| 396-416 | Activity names, and why `jump to` depends on them | -| 417-437 | Rewriting an existing workflow | -| 438-447 | Task pages | -| 448-535 | Microflow statements for workflow tasks | -| 536-556 | System-module enumerations are synthesized, not stored | -| 557-681 | Platform rules | -| 682-712 | Observing a running workflow | -| 713-722 | Validate before presenting | +| 119-300 | Activities | +| 301-307 | DROP WORKFLOW | +| 308-373 | ALTER WORKFLOW | +| 374-394 | DESCRIBE round-trip | +| 395-415 | Activity names, and why `jump to` depends on them | +| 416-436 | Rewriting an existing workflow | +| 437-446 | Task pages | +| 447-534 | Microflow statements for workflow tasks | +| 535-555 | System-module enumerations are synthesized, not stored | +| 556-680 | Platform rules | +| 681-711 | Observing a running workflow | +| 712-721 | Validate before presenting | # Mendix Workflows Skill @@ -246,8 +246,7 @@ end workflow; runtime then **refuses to start the application** ("Class 'Workflows$TimerBoundaryEvent' could not be found"). mxcli refuses the bare form on Mendix 11 (MDL-WF07). -- **The delay is a DateTime expression**, such as `'addDays([%CurrentDateTime%], 3)'` - — not an ISO duration like `'P3D'`. +- **The delay is a DateTime expression** (`addDays([%CurrentDateTime%], 3)`, not `'P3D'`). A **reminder** repeats a non-interrupting timer: `… timer addDays([%CurrentDateTime%], 1) repeat every 1 day at most 5 times { … }` — minute(s) to month(s), the count includes the first firing and is at least 2 (MDL-WF22); a timer event sub-process start takes it on 11.15+. - **Every boundary path must end**, and mxcli ends one that runs to its last activity as Studio Pro does (ako/mxcli#1712): an **interrupting** path with an End — the timeout ends the workflow; `describe` shows `end workflow;` — and a diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index c21ba79730..dabe5c35f4 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -654,6 +654,9 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "AWAIT", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, {Label: "AUTO", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, {Label: "ASSIGN", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, + {Label: "REPEAT", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, + {Label: "EVERY", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, + {Label: "TIMES", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, {Label: "DATA", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, {Label: "TRANSFORM", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, {Label: "TRANSFORMER", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 5f5db76944..d4546c436e 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -446,7 +446,7 @@ func init() { // 'Workflows$TimerBoundaryEvent' could not be found"). This entry showed // the bare form, with 'P3D' — not a valid timer expression — as its delay. // ako/view-entity-examples FINDINGS §7. - Syntax: "-- inline, as a clause of a USER TASK or CALL MICROFLOW (after OUTCOMES),\n-- a CALL WORKFLOW or a WAIT FOR NOTIFICATION:\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER [CAPTION ''] { }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [''] { }\n\n-- A notification boundary event (Mendix 11.11+) is triggered by `notify workflow`\n-- targeting its name, so the name is unique in the workflow. ALTER cannot insert\n-- one yet; restate the workflow instead.\n\n-- or add a timer to an existing task:\nALTER WORKFLOW { INSERT INTO { BOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER [CAPTION ''] { } } };\n\n-- Name the kind: a bare TIMER is refused on Mendix 11 (MDL-WF07) — it writes a\n-- type the runtime cannot load. The delay is an expression that yields a\n-- DateTime, e.g. addDays([%CurrentDateTime%], 3). mxcli ends every boundary\n-- path with Mendix's end-of-path marker, so a path may end in a call; use\n-- JUMP TO to return to the task instead.", + Syntax: "-- inline, as a clause of a USER TASK or CALL MICROFLOW (after OUTCOMES),\n-- a CALL WORKFLOW or a WAIT FOR NOTIFICATION:\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER [REPEAT EVERY AT MOST TIMES] [CAPTION ''] { }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [''] { }\n\n-- A notification boundary event (Mendix 11.11+) is triggered by `notify workflow`\n-- targeting its name, so the name is unique in the workflow. ALTER cannot insert\n-- one yet; restate the workflow instead.\n\n-- or add a timer to an existing task:\nALTER WORKFLOW { INSERT INTO { BOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER [CAPTION ''] { } } };\n\n-- REPEAT EVERY repeats a NON INTERRUPTING timer (a reminder): unit minute(s),\n-- hour(s), day(s), week(s) or month(s); the count includes the first firing\n-- and is at least 2. MDL-WF22 refuses what Studio Pro refuses (mx check does\n-- not): an interrupting timer, another unit, fewer than 2.\n\n-- Name the kind: a bare TIMER is refused on Mendix 11 (MDL-WF07) — it writes a\n-- type the runtime cannot load. The delay is an expression that yields a\n-- DateTime, e.g. addDays([%CurrentDateTime%], 3). mxcli ends every boundary\n-- path with Mendix's end-of-path marker, so a path may end in a call; use\n-- JUMP TO to return to the task instead.", Example: "user task ReviewTask 'Review'\n page Module.WF_Review\n outcomes 'Done' { }\n boundary event interrupting timer addDays([%CurrentDateTime%], 3) caption 'Escalate' {\n call microflow Module.WF_Escalate;\n };", MinVersion: "10.6.0", SeeAlso: []string{"workflow.user-task", "workflow.notification", "workflow.event-subprocess"}, @@ -464,7 +464,7 @@ func init() { " ON (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [] ['']\n" + " { };\n" + "EVENT SUBPROCESS ['']\n" + - " ON (INTERRUPTING | NON INTERRUPTING) TIMER [AS ] [CAPTION '']\n" + + " ON (INTERRUPTING | NON INTERRUPTING) TIMER [REPEAT EVERY AT MOST TIMES] [AS ] [CAPTION '']\n" + " { };\n\n" + "-- Interrupting cancels every active path first; non-interrupting runs alongside.\n" + "-- A notification start is what `notify workflow` targets. The body's End is\n" + diff --git a/docs-site/src/reference/workflow/create-workflow.md b/docs-site/src/reference/workflow/create-workflow.md index 9efed7402b..f8ac74fa25 100644 --- a/docs-site/src/reference/workflow/create-workflow.md +++ b/docs-site/src/reference/workflow/create-workflow.md @@ -117,8 +117,11 @@ USER TASK name 'caption' OUTCOMES 'Done' { } BOUNDARY EVENT INTERRUPTING TIMER addHours([%CurrentDateTime%], 1) CAPTION 'Escalate' { activities } BOUNDARY EVENT NON INTERRUPTING TIMER addMinutes([%CurrentDateTime%], 30) { activities } + BOUNDARY EVENT NON INTERRUPTING TIMER addDays([%CurrentDateTime%], 1) REPEAT EVERY 1 DAY AT MOST 5 TIMES { activities } ``` +`REPEAT EVERY AT MOST TIMES` makes a non-interrupting timer a repeating reminder: it fires at the delay, then every `` minutes, hours, days, weeks or months, `` times in all (the first firing counts, so `` is at least 2). A timer-started event sub-process takes the same clause on Mendix 11.15 and later. + `CAPTION ''` after the delay is the text Studio Pro shows on the timer; it is optional. ## Parameters diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index ec1522f6a1..cfca52af17 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -769,14 +769,14 @@ mandatory and a misplaced clause failed with a token error - `notification [] [caption ''];` — an intermediate notification event (Mendix 11.11+) - `end workflow [caption ''];` — only inside a `{ }` block; ends the whole workflow - `caption '…'` sets the caption Studio Pro shows on an activity; `comment '…'`, the old spelling, is a deprecated alias (`MDL-DEPR104`) -- Boundary events, after `outcomes`: `boundary event [non] interrupting timer [caption ''] { … }` or `boundary event [non] interrupting notification [''] { … }` (11.11+). One interrupting event per activity (CE6697, MDL-WF15). +- Boundary events, after `outcomes`: `boundary event [non] interrupting timer [repeat every at most times] [caption ''] { … }` or `boundary event [non] interrupting notification [''] { … }` (11.11+). One interrupting event per activity (CE6697, MDL-WF15). - **Variables in a workflow expression**: `$WorkflowContext` (or the header's declared name) and `$WorkflowInstance`, the running `System.Workflow` — `call microflow M.ACT(Workflow = $WorkflowInstance)`. Anything else (`$Workflow`, `$currentUser`, `$WorkflowUserTask`, …) is CE0117 (MDL-WF19); the workflow's own `due date` sees only the context. **Notifying a workflow** (a microflow statement): `[$Notified =] notify workflow $Workflow target Module.Workflow.ElementName;` — the element is a notification-started event sub-process's start, a notification activity, a notification boundary event or a wait for notification, and mxcli resolves which. The target is required (CE0166, MDL-WF16). **Completing a user task** (a microflow statement): `set task outcome $Task 'Approve';` — the outcome is a quoted **literal**, by design: Mendix stores it by name, a reference to one of the task's outcomes resolved at build time, so a variable is a parse error. A shared claim-and-complete flow branches once per outcome (`if $Outcome = 'Approve' then set task outcome $Task 'Approve'; else …`). Claim the task first (MDL-WORKFLOW10). On a task page the same is the button action `complete task 'Approve'`, one per outcome; check warns MDL-WF20 when a task's page cannot complete one of its outcomes, and the user must have claimed the task either way. -**Event sub-processes**, after the main body: `event subprocess [''] on [non] interrupting notification [] [''] { … };` (11.8+) or `… on [non] interrupting timer [as ] [caption ''] { … };` (11.13+). The body's End is implicit; a `jump to` stays in its own sub-process (CE6682, MDL-WF05); a timer needs its expression (CE0126, MDL-WF14). +**Event sub-processes**, after the main body: `event subprocess [''] on [non] interrupting notification [] [''] { … };` (11.8+) or `… on [non] interrupting timer [repeat every at most times] [as ] [caption ''] { … };` (11.13+; `repeat` 11.15+). `repeat every` repeats a non-interrupting timer — unit minute(s) … month(s), the count includes the first firing and is at least 2 (MDL-WF22). The body's End is implicit; a `jump to` stays in its own sub-process (CE6682, MDL-WF05); a timer needs its expression (CE0126, MDL-WF14). **Workflow event handlers.** `on workflow events (UserTaskStarted, UserTaskEnded) microflow Mod.MF as 'Task audit'` in the header runs the microflow for each listed diff --git a/mdl-examples/bug-tests/1716-recurring-timers.mdl b/mdl-examples/bug-tests/1716-recurring-timers.mdl new file mode 100644 index 0000000000..57402644f1 --- /dev/null +++ b/mdl-examples/bug-tests/1716-recurring-timers.mdl @@ -0,0 +1,62 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1716 — "Non-interrupting timer boundary events cannot recur +-- (Studio Pro's repeating reminder)" +-- ============================================================================ +-- +-- `repeat every at most times` repeats a non-interrupting timer: +-- it fires at the delay, then every , times in all — the first +-- firing counts, as Studio Pro's MaxExecutions does. Studio Pro 11.15's model +-- validation (shipped with mxbuild) allows minute, hour, day, week or month +-- (MW0005) and at least 2 executions (MW0004); mx check enforces neither, so +-- MDL-WF22 does. +-- +-- Measured: the workflow below builds with 0 errors on 11.15.0, describe prints +-- the recurrence, replaying describe reports "Unchanged workflow". At runtime +-- (every 1 minute, at most 3 times) the boundary timer fired exactly 3 times on +-- 11.13 and on 11.15. A repeating event sub-process start fired twice of 2 on +-- 11.15 but ONCE on 11.13, whose runtime model has no recurrence on the start — +-- so that form is gated at 11.15. +-- +-- EXPECTED: `mxcli check` passes this file. `repeat every 30 seconds` or +-- `at most 1 times`, or a recurrence on an interrupting timer, is refused as +-- MDL-WF22. +-- Guard: mdl/executor/validate_workflow_recurrence_test.go, +-- mdl/visitor/visitor_workflow_recurrence_test.go, +-- mdl/backend/modelsdk/workflow_recurrence_test.go. +-- ============================================================================ + +create module BugRecurrence1716; + +create persistent entity BugRecurrence1716.Request ( + Title: String(200) +); + +create page BugRecurrence1716.WF_Review +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + actionbutton btnDone (Caption: 'Done', Action: complete task 'Done') + } +}; + +create microflow BugRecurrence1716.ACT_Remind ($Context: BugRecurrence1716.Request) +begin + log info node 'WF' 'reminder'; +end; + +create workflow BugRecurrence1716.Approval + parameter $WorkflowContext: BugRecurrence1716.Request +begin + user task Review 'Review' + page BugRecurrence1716.WF_Review + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + outcomes 'Done' { } + boundary event non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 1 day at most 5 times caption 'Daily reminder' { + call microflow BugRecurrence1716.ACT_Remind; + }; +end workflow; diff --git a/mdl/ast/ast_workflow.go b/mdl/ast/ast_workflow.go index 6c5a0ca1a8..383ed8ddfe 100644 --- a/mdl/ast/ast_workflow.go +++ b/mdl/ast/ast_workflow.go @@ -54,7 +54,18 @@ type WorkflowEventSubProcessNode struct { StartCaption string // FirstExecutionTime is a timer start's expression. FirstExecutionTime string - Activities []WorkflowActivityNode + // Recurrence is a non-interrupting timer start's `repeat every …`. + Recurrence *WorkflowRecurrenceNode + Activities []WorkflowActivityNode +} + +// WorkflowRecurrenceNode is `repeat every at most times` +// on a non-interrupting timer (ako/mxcli#1716). Unit is the word as written — +// "day", "days", … — checked by MDL-WF22. +type WorkflowRecurrenceNode struct { + Interval int + Unit string + MaxExecutions int } // WorkflowEventHandlerNode is one `on workflow events (…) microflow M as '…'` or @@ -285,6 +296,8 @@ type WorkflowBoundaryEventNode struct { Name string // notification events: what `notify workflow … target` names Caption string // notification events Activities []WorkflowActivityNode // Sub-flow activities inside the boundary event + // Recurrence is a timer's `repeat every …` (a non-interrupting one's only). + Recurrence *WorkflowRecurrenceNode } // WorkflowAnnotationActivityNode represents an ANNOTATION activity in a workflow. @@ -452,6 +465,7 @@ type InsertBoundaryEventOp struct { EventType string // "InterruptingTimer", "NonInterruptingTimer" Delay string Caption string + Recurrence *WorkflowRecurrenceNode Activities []WorkflowActivityNode } diff --git a/mdl/backend/mcp/workflow.go b/mdl/backend/mcp/workflow.go index ace0c3cbf1..c436122365 100644 --- a/mdl/backend/mcp/workflow.go +++ b/mdl/backend/mcp/workflow.go @@ -989,6 +989,9 @@ func mapWorkflowActivity(a workflows.WorkflowActivity) (map[string]any, error) { case *workflows.NotificationActivity: return map[string]any{"$Type": "Workflows$NotificationActivity", "name": act.Name, "caption": act.Caption}, nil case *workflows.EventSubProcessStartActivity: + if act.Recurrence != nil { + return nil, errMCPRecurrence("event sub-process start " + act.Name) + } m := map[string]any{"$Type": act.StorageType(), "name": act.Name, "caption": act.Caption} if act.Timer { m["firstExecutionTime"] = act.FirstExecutionTime @@ -2034,7 +2037,7 @@ func (m *mcpWorkflowMutator) DropBranch(activityRef string, atPos int, branchNam // InsertBoundaryEvent attaches a (non-)interrupting timer boundary event, with an // optional handler sub-flow, to a user task or call-microflow activity. -func (m *mcpWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType, delay, caption string, activities []workflows.WorkflowActivity) error { +func (m *mcpWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType, delay, caption string, recurrence *workflows.Recurrence, activities []workflows.WorkflowActivity) error { loc, err := m.resolveMember(activityRef, atPos, "insert boundary event", backend.WorkflowBoundaryEvents) if err != nil { return err @@ -2047,7 +2050,7 @@ func (m *mcpWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, return "" }).Activities wfnames.Dedup(activities, loc.taken) - be := &workflows.BoundaryEvent{EventType: eventType, TimerDelay: delay, Caption: caption} + be := &workflows.BoundaryEvent{EventType: eventType, TimerDelay: delay, Caption: caption, Recurrence: recurrence} if be.IsNotification() { // The op carries no name, and a notification event is reached by its name. return fmt.Errorf("insert boundary event: %q boundary events cannot be inserted by ALTER WORKFLOW", eventType) @@ -2267,6 +2270,9 @@ func boundaryEventElement(be *workflows.BoundaryEvent) (map[string]any, error) { } return el, nil } + if be.Recurrence != nil { + return nil, errMCPRecurrence("a boundary event") + } el := map[string]any{"$Type": typeName, "caption": be.Caption} switch typeName { case "Workflows$InterruptingTimerBoundaryEvent": @@ -2284,3 +2290,12 @@ func errMCPTemplateParams(what string) error { return fmt.Errorf("%s has template parameters (`with ({1} = …)`), which the MCP backend cannot write yet — "+ "create the workflow without --mcp, or set the parameters in Studio Pro", what) } + +// errMCPRecurrence refuses a recurring timer over MCP: PED's timer constructors +// drop properties they do not take (firstExecutionTime, measured on 11.14), and +// whether they take a recurrence is unmeasured — writing the timer without it +// would make a repeating reminder fire once (ako/mxcli#1716). +func errMCPRecurrence(what string) error { + return fmt.Errorf("%s repeats (`repeat every …`), which the MCP backend cannot write yet — "+ + "create the workflow without --mcp, or set the recurrence in Studio Pro", what) +} diff --git a/mdl/backend/mcp/workflow_member_kind_test.go b/mdl/backend/mcp/workflow_member_kind_test.go index ba748f1e79..216a0c32e0 100644 --- a/mdl/backend/mcp/workflow_member_kind_test.go +++ b/mdl/backend/mcp/workflow_member_kind_test.go @@ -41,7 +41,7 @@ func TestWFInsertMember_OnWrongKindIsRefused(t *testing.T) { "outcome on a decision": func(m *mcpWorkflowMutator) error { return m.InsertOutcome("Decision", 0, "X", nil) }, "branch on a user task": func(m *mcpWorkflowMutator) error { return m.InsertBranch("ReviewOrder", 0, "true", nil) }, "boundary on a decision": func(m *mcpWorkflowMutator) error { - return m.InsertBoundaryEvent("Decision", 0, "InterruptingTimer", "", "", nil) + return m.InsertBoundaryEvent("Decision", 0, "InterruptingTimer", "", "", nil, nil) }, } for name, op := range cases { diff --git a/mdl/backend/mcp/workflow_test.go b/mdl/backend/mcp/workflow_test.go index 3cefd81baa..69014c88b4 100644 --- a/mdl/backend/mcp/workflow_test.go +++ b/mdl/backend/mcp/workflow_test.go @@ -396,7 +396,7 @@ func jumpShadowFake(t *testing.T) (*fakePED, *mcpWorkflowMutator) { // caption matched the reference as well as the target's name. func TestWFResolve_NameBeatsJumpCaption(t *testing.T) { f, m := jumpShadowFake(t) - if err := m.InsertBoundaryEvent("bugSplitJump", 0, "NonInterruptingTimer", "addHours([%CurrentDateTime%], 1)", "", nil); err != nil { + if err := m.InsertBoundaryEvent("bugSplitJump", 0, "NonInterruptingTimer", "addHours([%CurrentDateTime%], 1)", "", nil, nil); err != nil { t.Fatal(err) } if ops := wfUpdateOps(t, f); !strings.Contains(ops, `"path":"/flow/activities/0/boundaryEvents"`) { @@ -526,7 +526,7 @@ func TestWFDropBranch(t *testing.T) { func TestWFBoundaryEvent(t *testing.T) { f, m := wfMutatorFake(t) - if err := m.InsertBoundaryEvent("ReviewOrder", 0, "NonInterruptingTimer", "addHours([%CurrentDateTime%], 1)", "", nil); err != nil { + if err := m.InsertBoundaryEvent("ReviewOrder", 0, "NonInterruptingTimer", "addHours([%CurrentDateTime%], 1)", "", nil, nil); err != nil { t.Fatal(err) } ops := wfUpdateOps(t, f) @@ -741,3 +741,20 @@ func TestMCPWorkflow_RefusesTemplateParameters(t *testing.T) { t.Errorf("a task without parameters: %v", err) } } + +// ako/mxcli#1716: PED's timer constructors drop what they do not take, so a +// recurrence is refused over MCP rather than written as a one-shot timer. +func TestMCPWorkflow_RefusesRecurrence(t *testing.T) { + r := &workflows.Recurrence{IntervalType: "Day", Interval: 1, MaxExecutions: 3} + if _, err := boundaryEventElement(&workflows.BoundaryEvent{EventType: "NonInterruptingTimer", Recurrence: r}); err == nil { + t.Error("a repeating boundary event must be refused over MCP") + } + start := &workflows.EventSubProcessStartActivity{Timer: true, Recurrence: r} + start.Name = "S" + if _, err := mapWorkflowActivity(start); err == nil { + t.Error("a repeating event sub-process start must be refused over MCP") + } + if _, err := boundaryEventElement(&workflows.BoundaryEvent{EventType: "NonInterruptingTimer"}); err != nil { + t.Errorf("control, no recurrence: %v", err) + } +} diff --git a/mdl/backend/mcp/workflow_timer_test.go b/mdl/backend/mcp/workflow_timer_test.go index 59e5752271..a8a1597a3b 100644 --- a/mdl/backend/mcp/workflow_timer_test.go +++ b/mdl/backend/mcp/workflow_timer_test.go @@ -306,7 +306,7 @@ func TestUpdateWorkflow_TimerOnSingleUserTaskIsReaddedThenFinished(t *testing.T) func TestWFInsertBoundaryEvent_InterruptingTimerJump(t *testing.T) { f, m := wfMutatorFake(t) jump := timerJump("jumpBack", "ReviewOrder") - if err := m.InsertBoundaryEvent("ReviewOrder", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 3)", "", []workflows.WorkflowActivity{jump}); err != nil { + if err := m.InsertBoundaryEvent("ReviewOrder", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 3)", "", nil, []workflows.WorkflowActivity{jump}); err != nil { t.Fatal(err) } // The activity already had one event; the store prepends, so the new one is at 0. @@ -324,7 +324,7 @@ func TestWFInsertBoundaryEvent_InterruptingTimerJump(t *testing.T) { // marker) is refused before anything is sent, naming the endings that work. func TestWFInsertBoundaryEvent_InterruptingTimerRunningToItsEndIsRefused(t *testing.T) { f, m := wfMutatorFake(t) - err := m.InsertBoundaryEvent("ReviewOrder", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 3)", "", nil) + err := m.InsertBoundaryEvent("ReviewOrder", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 3)", "", nil, nil) if err == nil || !strings.Contains(err.Error(), "`end workflow;` or `jump to `") || !strings.Contains(err.Error(), "on ReviewOrder") { t.Fatalf("want a refusal naming the endings and the activity, got %v", err) } @@ -358,7 +358,7 @@ func TestWFInsertBoundaryEvent_InsideSplit(t *testing.T) { }) m := &mcpWorkflowMutator{backend: &Backend{client: f.connectClient(t)}, moduleName: "M", workflowName: "WF"} jump := timerJump("jumpSplit", "splitWait") - if err := m.InsertBoundaryEvent("splitWait", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 4)", "", []workflows.WorkflowActivity{jump}); err != nil { + if err := m.InsertBoundaryEvent("splitWait", 0, "InterruptingTimer", "addDays([%CurrentDateTime%], 4)", "", nil, []workflows.WorkflowActivity{jump}); err != nil { t.Fatal(err) } const ev = "/flow/activities/0/outcomes/0/flow/activities/0/boundaryEvents" diff --git a/mdl/backend/mock/mock_workflow_mutator.go b/mdl/backend/mock/mock_workflow_mutator.go index c3c4328b2c..5a3401df70 100644 --- a/mdl/backend/mock/mock_workflow_mutator.go +++ b/mdl/backend/mock/mock_workflow_mutator.go @@ -28,7 +28,7 @@ type MockWorkflowMutator struct { InsertBranchFunc func(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error DropBranchFunc func(activityRef string, atPos int, branchName string) error SetTemplateParametersFunc func(activityRef string, atPos int, prop string, params []string) error - InsertBoundaryEventFunc func(activityRef string, atPos int, eventType string, delay string, caption string, activities []workflows.WorkflowActivity) error + InsertBoundaryEventFunc func(activityRef string, atPos int, eventType string, delay string, caption string, recurrence *workflows.Recurrence, activities []workflows.WorkflowActivity) error DropBoundaryEventFunc func(activityRef string, atPos int) error SaveFunc func() error } @@ -134,9 +134,9 @@ func (m *MockWorkflowMutator) DropBranch(activityRef string, atPos int, branchNa return nil } -func (m *MockWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, caption string, activities []workflows.WorkflowActivity) error { +func (m *MockWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, caption string, recurrence *workflows.Recurrence, activities []workflows.WorkflowActivity) error { if m.InsertBoundaryEventFunc != nil { - return m.InsertBoundaryEventFunc(activityRef, atPos, eventType, delay, caption, activities) + return m.InsertBoundaryEventFunc(activityRef, atPos, eventType, delay, caption, recurrence, activities) } return nil } diff --git a/mdl/backend/modelsdk/workflow_read.go b/mdl/backend/modelsdk/workflow_read.go index 7dea012d80..f4b7da1814 100644 --- a/mdl/backend/modelsdk/workflow_read.go +++ b/mdl/backend/modelsdk/workflow_read.go @@ -362,6 +362,9 @@ func workflowSimpleActivityFromGen(el element.Element) workflows.WorkflowActivit setBase(&a.BaseWorkflowActivity) if timer { a.FirstExecutionTime = genWf.RawFieldString(raw, "FirstExecutionTime") + if child, err := codec.DecodeChild(raw, "Recurrence"); err == nil { + a.Recurrence = recurrenceFromGen(child) + } } return a } @@ -631,11 +634,12 @@ func boundaryEventFromGen(el element.Element) *workflows.BoundaryEvent { if be.TimerDelay == "" { be.TimerDelay = t.Delay() } - switch el.(type) { + switch ni := el.(type) { case *genWf.InterruptingTimerBoundaryEvent: be.EventType = "InterruptingTimer" case *genWf.NonInterruptingTimerBoundaryEvent: be.EventType = "NonInterruptingTimer" + be.Recurrence = recurrenceFromGen(ni.Recurrence()) case *genWf.TimerBoundaryEvent: be.EventType = "Timer" default: @@ -646,3 +650,13 @@ func boundaryEventFromGen(el element.Element) *workflows.BoundaryEvent { } return be } + +// recurrenceFromGen reads a stored Workflows$LinearRecurrence; nil for none +// (ako/mxcli#1716). +func recurrenceFromGen(el element.Element) *workflows.Recurrence { + lr, ok := el.(*genWf.LinearRecurrence) + if !ok || lr == nil { + return nil + } + return &workflows.Recurrence{IntervalType: lr.IntervalType(), Interval: int(lr.Interval()), MaxExecutions: int(lr.MaxExecutions())} +} diff --git a/mdl/backend/modelsdk/workflow_recurrence_test.go b/mdl/backend/modelsdk/workflow_recurrence_test.go new file mode 100644 index 0000000000..f2af2294c7 --- /dev/null +++ b/mdl/backend/modelsdk/workflow_recurrence_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/modelsdk/codec" + genWf "github.com/mendixlabs/mxcli/modelsdk/gen/workflows" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// ako/mxcli#1716: a recurrence is written as a Workflows$LinearRecurrence on a +// non-interrupting timer boundary event and on a non-interrupting timer event +// sub-process start, and read back. +func TestWorkflowRecurrence_RoundTrip(t *testing.T) { + daily := &workflows.Recurrence{IntervalType: "Day", Interval: 1, MaxExecutions: 5} + hourly := &workflows.Recurrence{IntervalType: "Hour", Interval: 2, MaxExecutions: 3} + task := &workflows.UserTask{Page: "M.P", Outcomes: []*workflows.UserTaskOutcome{{Value: "Done"}}} + task.Name, task.Caption = "Review", "Review" + task.BoundaryEvents = []*workflows.BoundaryEvent{ + {EventType: "NonInterruptingTimer", TimerDelay: "addDays([%CurrentDateTime%], 1)", Recurrence: daily}, + {EventType: "NonInterruptingTimer", TimerDelay: "addDays([%CurrentDateTime%], 2)"}, // control: fires once + } + start := &workflows.EventSubProcessStartActivity{Timer: true, FirstExecutionTime: "addHours([%CurrentDateTime%], 2)", Recurrence: hourly} + start.Name, start.Caption = "NudgeStart", "NudgeStart" + esp := &workflows.EventSubProcess{Name: "Nudge", Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{start}}} + wf := &workflows.Workflow{Name: "WF", Parameter: &workflows.WorkflowParameter{EntityRef: "M.Ctx"}, + Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{task}}, EventSubProcesses: []*workflows.EventSubProcess{esp}} + + raw, err := (&codec.Encoder{}).Encode(workflowToGen(wf)) + if err != nil { + t.Fatalf("encode: %v", err) + } + el, err := codec.NewDecoder(codec.DefaultRegistry).Decode(raw) + if err != nil { + t.Fatalf("decode: %v", err) + } + back := workflowFromGen(el.(*genWf.Workflow), "") + var rt *workflows.UserTask + for _, a := range back.Flow.Activities { + if ut, ok := a.(*workflows.UserTask); ok { + rt = ut + } + } + if rt == nil || len(rt.BoundaryEvents) != 2 { + t.Fatalf("read back %+v", rt) + } + if r := rt.BoundaryEvents[0].Recurrence; r == nil || *r != *daily { + t.Errorf("boundary recurrence = %+v, want %+v", r, daily) + } + if r := rt.BoundaryEvents[1].Recurrence; r != nil { + t.Errorf("control fires once, read %+v", r) + } + if len(back.EventSubProcesses) != 1 || back.EventSubProcesses[0].Start() == nil { + t.Fatalf("event sub-process not read back: %+v", back.EventSubProcesses) + } + if r := back.EventSubProcesses[0].Start().Recurrence; r == nil || *r != *hourly { + t.Errorf("event sub-process start recurrence = %+v, want %+v", r, hourly) + } +} diff --git a/mdl/backend/modelsdk/workflow_write.go b/mdl/backend/modelsdk/workflow_write.go index 20e25a0588..da2b9f410e 100644 --- a/mdl/backend/modelsdk/workflow_write.go +++ b/mdl/backend/modelsdk/workflow_write.go @@ -376,6 +376,9 @@ func activityToGen(act workflows.WorkflowActivity) element.Element { addStr(g, "FirstExecutionTime", a.FirstExecutionTime) } addStr(g, "Name", a.Name) + if a.Recurrence != nil && a.Timer && !a.Interrupting { + addPart(g, "Recurrence", linearRecurrenceToGen(a.Recurrence)) + } return g default: return nil @@ -731,7 +734,10 @@ func boundaryEventsToGen(events []*workflows.BoundaryEvent) []element.Element { addStr(g, "Name", ev.Name) } addFreshPersistentID(g) - // Recurrence: null on NonInterrupting (via NullFields). + // Recurrence: null on NonInterrupting (via NullFields) unless it repeats. + if ev.Recurrence != nil && typeName == "Workflows$NonInterruptingTimerBoundaryEvent" { + addPart(g, "Recurrence", linearRecurrenceToGen(ev.Recurrence)) + } out = append(out, g) } return out @@ -846,3 +852,14 @@ func activityIDOrFresh(id string) string { func outcomeID(id model.ID) string { return activityIDOrFresh(string(id)) } + +// linearRecurrenceToGen builds a Workflows$LinearRecurrence (ako/mxcli#1716): +// the shape mx check accepts on 11.15.0, with IntervalType read by the runtime +// as the scheduled-event enumeration (Minute, Hour, Day, Week, Month). +func linearRecurrenceToGen(r *workflows.Recurrence) element.Element { + g := newElem("Workflows$LinearRecurrence", "") + addInt32(g, "Interval", int32(r.Interval)) + addStr(g, "IntervalType", r.IntervalType) + addInt32(g, "MaxExecutions", int32(r.MaxExecutions)) + return g +} diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index fce9138e3d..0ce09e5545 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -345,7 +345,7 @@ type WorkflowMutator interface { // --- Boundary event operations --- // InsertBoundaryEvent adds a boundary event to the referenced activity. - InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, caption string, activities []workflows.WorkflowActivity) error + InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, caption string, recurrence *workflows.Recurrence, activities []workflows.WorkflowActivity) error // DropBoundaryEvent removes the boundary event from the referenced activity. DropBoundaryEvent(activityRef string, atPos int) error diff --git a/mdl/backend/wfmutator/member_kind_test.go b/mdl/backend/wfmutator/member_kind_test.go index a4badef0fc..940bf64ceb 100644 --- a/mdl/backend/wfmutator/member_kind_test.go +++ b/mdl/backend/wfmutator/member_kind_test.go @@ -139,10 +139,10 @@ func TestInsertMember_OnWrongKindIsRefused(t *testing.T) { {"insert branch on a user task", func(m *Mutator) error { return m.InsertBranch("userTask1", 0, "true", nil) }, "userTask1"}, {"insert branch on a split", func(m *Mutator) error { return m.InsertBranch("split1", 0, "true", nil) }, "split1"}, {"insert boundary event on a split", func(m *Mutator) error { - return m.InsertBoundaryEvent("split1", 0, "InterruptingTimer", "", "", nil) + return m.InsertBoundaryEvent("split1", 0, "InterruptingTimer", "", "", nil, nil) }, "split1"}, {"insert boundary event on a timer", func(m *Mutator) error { - return m.InsertBoundaryEvent("timer1", 0, "InterruptingTimer", "", "", nil) + return m.InsertBoundaryEvent("timer1", 0, "InterruptingTimer", "", "", nil, nil) }, "timer1"}, } for _, tc := range cases { @@ -175,7 +175,7 @@ func TestInsertMember_OnRightKindLands(t *testing.T) { if err := m.InsertBranch("decision1", 0, "true", nil); err != nil { t.Errorf("insert branch on a decision: %v", err) } - if err := m.InsertBoundaryEvent("userTask1", 0, "InterruptingTimer", "", "", nil); err != nil { + if err := m.InsertBoundaryEvent("userTask1", 0, "InterruptingTimer", "", "", nil, nil); err != nil { t.Errorf("insert boundary event on a user task: %v", err) } } diff --git a/mdl/backend/wfmutator/mutator.go b/mdl/backend/wfmutator/mutator.go index eaf563456b..b0f1ae388a 100644 --- a/mdl/backend/wfmutator/mutator.go +++ b/mdl/backend/wfmutator/mutator.go @@ -604,7 +604,7 @@ func (m *Mutator) DropBranch(activityRef string, atPos int, branchName string) e // Boundary event operations // --------------------------------------------------------------------------- -func (m *Mutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, caption string, activities []workflows.WorkflowActivity) error { +func (m *Mutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, caption string, recurrence *workflows.Recurrence, activities []workflows.WorkflowActivity) error { actDoc, err := m.findMemberTarget(activityRef, atPos, "insert boundary event", backend.WorkflowBoundaryEvents) if err != nil { return err @@ -637,7 +637,19 @@ func (m *Mutator) InsertBoundaryEvent(activityRef string, atPos int, eventType s eventDoc = append(eventDoc, bson.E{Key: "PersistentId", Value: bsonutil.NewIDBsonBinary()}) if typeName == "Workflows$NonInterruptingTimerBoundaryEvent" { - eventDoc = append(eventDoc, bson.E{Key: "Recurrence", Value: nil}) + var rec any // null: fires once + if recurrence != nil { + rec = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$LinearRecurrence"}, + {Key: "Interval", Value: int32(recurrence.Interval)}, + {Key: "IntervalType", Value: recurrence.IntervalType}, + {Key: "MaxExecutions", Value: int32(recurrence.MaxExecutions)}, + } + } + eventDoc = append(eventDoc, bson.E{Key: "Recurrence", Value: rec}) + } else if recurrence != nil { + return fmt.Errorf("insert boundary event: only a non-interrupting timer repeats") } events := bsonnav.DGetArrayElements(bsonnav.DGet(actDoc, "BoundaryEvents")) diff --git a/mdl/backend/wfmutator/mutator_test.go b/mdl/backend/wfmutator/mutator_test.go index 93975a8ea8..45f4d9e8dc 100644 --- a/mdl/backend/wfmutator/mutator_test.go +++ b/mdl/backend/wfmutator/mutator_test.go @@ -1102,7 +1102,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_InterruptingTimer(t *testing.T) { act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) m := newMutator(makeWorkflowDoc(act)) - if err := m.InsertBoundaryEvent("Review", 0, "InterruptingTimer", "PT1H", "", nil); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, "InterruptingTimer", "PT1H", "", nil, nil); err != nil { t.Fatalf("InsertBoundaryEvent failed: %v", err) } @@ -1126,7 +1126,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_Caption(t *testing.T) { act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) m := newMutator(makeWorkflowDoc(act)) - if err := m.InsertBoundaryEvent("Review", 0, "InterruptingTimer", "PT1H", caption, nil); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, "InterruptingTimer", "PT1H", caption, nil, nil); err != nil { t.Fatalf("InsertBoundaryEvent failed: %v", err) } actDoc, _ := m.findActivityByCaption("Review", 0) @@ -1142,7 +1142,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_NonInterruptingTimer(t *testing.T) act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) m := newMutator(makeWorkflowDoc(act)) - if err := m.InsertBoundaryEvent("Review", 0, "NonInterruptingTimer", "PT30M", "", nil); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, "NonInterruptingTimer", "PT30M", "", nil, nil); err != nil { t.Fatalf("InsertBoundaryEvent NonInterrupting failed: %v", err) } @@ -1170,7 +1170,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_WithActivities(t *testing.T) { m := newMutator(makeWorkflowDoc(act)) subAct := makeTestWorkflowActivity("evt_act", "EventAct") - if err := m.InsertBoundaryEvent("Review", 0, "Timer", "", "", []workflows.WorkflowActivity{subAct}); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, "Timer", "", "", nil, []workflows.WorkflowActivity{subAct}); err != nil { t.Fatalf("InsertBoundaryEvent with activities failed: %v", err) } @@ -1188,7 +1188,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_NoDelay(t *testing.T) { act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) m := newMutator(makeWorkflowDoc(act)) - if err := m.InsertBoundaryEvent("Review", 0, "Timer", "", "", nil); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, "Timer", "", "", nil, nil); err != nil { t.Fatalf("InsertBoundaryEvent no delay failed: %v", err) } @@ -1535,7 +1535,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_EndsWithMarker(t *testing.T) { act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) m := newMutator(makeWorkflowDoc(act)) - if err := m.InsertBoundaryEvent("Review", 0, tc.eventType, "addHours([%CurrentDateTime%], 1)", "", tc.acts); err != nil { + if err := m.InsertBoundaryEvent("Review", 0, tc.eventType, "addHours([%CurrentDateTime%], 1)", "", nil, tc.acts); err != nil { t.Fatalf("%s: InsertBoundaryEvent: %v", name, err) } actDoc, _ := m.findActivityByCaption("Review", 0) diff --git a/mdl/backend/wfmutator/template_params_test.go b/mdl/backend/wfmutator/template_params_test.go index c10854ecf8..09007b6260 100644 --- a/mdl/backend/wfmutator/template_params_test.go +++ b/mdl/backend/wfmutator/template_params_test.go @@ -5,6 +5,8 @@ package wfmutator import ( "testing" + "github.com/mendixlabs/mxcli/sdk/workflows" + "go.mongodb.org/mongo-driver/v2/bson" "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" @@ -79,3 +81,24 @@ func TestWorkflowMutator_SetTemplateParameters(t *testing.T) { t.Errorf("cleared task name params = %q", got) } } + +// ako/mxcli#1716: ALTER's insert writes a non-interrupting timer's recurrence, +// and refuses one on an interrupting timer. +func TestWorkflowMutator_InsertBoundaryEvent_Recurrence(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) + m := newMutator(makeWorkflowDoc(act)) + r := &workflows.Recurrence{IntervalType: "Week", Interval: 1, MaxExecutions: 4} + if err := m.InsertBoundaryEvent("Review", 0, "NonInterruptingTimer", "PT1H", "", r, nil); err != nil { + t.Fatal(err) + } + actDoc, _ := m.findActivityByCaption("Review", 0) + ev := bsonnav.DGetArrayElements(bsonnav.DGet(actDoc, "BoundaryEvents"))[0].(bson.D) + rec := bsonnav.DGetDoc(ev, "Recurrence") + if rec == nil || bsonnav.DGetString(rec, "$Type") != "Workflows$LinearRecurrence" || bsonnav.DGetString(rec, "IntervalType") != "Week" { + t.Errorf("recurrence = %v", rec) + } + if err := m.InsertBoundaryEvent("Review", 0, "InterruptingTimer", "PT1H", "", r, nil); err == nil { + t.Error("a repeating interrupting timer must be refused") + } +} diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index e952bd22ac..78a62cfa97 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -49,6 +49,9 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { if vs := checkWorkflowTemplates(alterWorkflowTemplateSites(s), workflowLocation(s.Name)); len(vs) > 0 { return mdlerrors.NewValidationf("workflow '%s': %s [%s]\n → %s", s.Name.String(), vs[0].Message, vs[0].RuleID, vs[0].Suggestion) } + if vs := checkWorkflowRecurrences(alterWorkflowRecurrenceSites(s), workflowLocation(s.Name)); len(vs) > 0 { + return mdlerrors.NewValidationf("workflow '%s': %s [%s]\n → %s", s.Name.String(), vs[0].Message, vs[0].RuleID, vs[0].Suggestion) + } if refErrors := validateAlterWorkflowRefs(ctx, s, nil); len(refErrors) > 0 { return mdlerrors.NewValidationf("workflow '%s' has reference errors:\n - %s", s.Name.String(), strings.Join(refErrors, "\n - ")) @@ -235,7 +238,7 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { "restate the workflow with CREATE OR MODIFY WORKFLOW, which accepts `boundary event interrupting notification '' { … }`") } acts := ends.endInsertedBoundaryPath(buildAndBindActivities(ctx, o.Activities), o.EventType, o.ActivityRef, o.AtPosition) - if err := mutator.InsertBoundaryEvent(o.ActivityRef, o.AtPosition, o.EventType, normalizeWorkflowContextExpr(o.Delay), o.Caption, acts); err != nil { + if err := mutator.InsertBoundaryEvent(o.ActivityRef, o.AtPosition, o.EventType, normalizeWorkflowContextExpr(o.Delay), o.Caption, recurrenceFromNode(o.Recurrence), acts); err != nil { return mdlerrors.NewBackend("insert boundary event", err) } diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index d009b672dc..d3452adb3d 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -264,6 +264,9 @@ func formatBoundaryEvents(ctx *ExecContext, events []*workflows.BoundaryEvent, i if event.TimerDelay != "" { header += " " + workflowExpressionMDL(event.TimerDelay) } + if event.Recurrence != nil { + header += " " + recurrenceMDL(event.Recurrence) + } if event.Caption != "" { header += " caption " + mdlQuoted(event.Caption) } @@ -302,6 +305,9 @@ func formatEventSubProcesses(ctx *ExecContext, esps []*workflows.EventSubProcess } if start.Timer { header += fmt.Sprintf(" on %s timer %s", trigger, workflowExpressionMDL(start.FirstExecutionTime)) + if start.Recurrence != nil { + header += " " + recurrenceMDL(start.Recurrence) + } if start.Name != "" { header += " as " + mdlIdent(start.Name) } @@ -960,6 +966,11 @@ func targetingXPathMDL(ctx *ExecContext, xpath string) string { // the MDL expression grammar cannot parse) is written in the deprecated string // form, which carries it byte for byte: describe must never alter a stored // expression. +// recurrenceMDL is a timer's recurrence as MDL writes it (ako/mxcli#1716). +func recurrenceMDL(r *workflows.Recurrence) string { + return fmt.Sprintf("repeat every %d %s at most %d times", r.Interval, workflows.RecurrenceUnitWord(r.IntervalType, r.Interval), r.MaxExecutions) +} + // workflowTemplateMDL is a workflow text as MDL writes it: the quoted text, then // `with ({1} = …, …)` when expressions fill its placeholders (ako/mxcli#1714). func workflowTemplateMDL(text string, params []string) string { diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index 764dfa1cb4..4022eef9f8 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -90,6 +90,11 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { if vs := checkWorkflowTemplates(workflowTemplateSites(s), workflowLocation(s.Name)); len(vs) > 0 { return mdlerrors.NewValidationf("workflow '%s': %s [%s]\n → %s", s.Name.String(), vs[0].Message, vs[0].RuleID, vs[0].Suggestion) } + // MDL-WF22: a recurrence Studio Pro refuses and mx check lets through + // (ako/mxcli#1716). + if vs := checkWorkflowRecurrences(workflowRecurrenceSites(s), workflowLocation(s.Name)); len(vs) > 0 { + return mdlerrors.NewValidationf("workflow '%s': %s [%s]\n → %s", s.Name.String(), vs[0].Message, vs[0].RuleID, vs[0].Suggestion) + } if err := checkVersionGates(ctx, s); err != nil { return err } @@ -348,6 +353,7 @@ func buildBoundaryEvents(nodes []ast.WorkflowBoundaryEventNode) []*workflows.Bou TimerDelay: be.Delay, Name: be.Name, Caption: be.Caption, + Recurrence: recurrenceFromNode(be.Recurrence), } if event.IsNotification() { // Mendix requires both: the name is what a notify action targets @@ -881,6 +887,7 @@ func buildEventSubProcesses(nodes []ast.WorkflowEventSubProcessNode) []*workflow Interrupting: n.Interrupting, Timer: n.Timer, FirstExecutionTime: n.FirstExecutionTime, + Recurrence: recurrenceFromNode(n.Recurrence), } start.ID = model.ID(generateWorkflowUUID()) start.Name = n.StartName @@ -1403,3 +1410,13 @@ func hasStandaloneWorkflowAnnotation(acts []ast.WorkflowActivityNode) bool { }) return found } + +// recurrenceFromNode is a `repeat every …` as stored; nil for none. An unknown +// unit is refused before this by MDL-WF22. +func recurrenceFromNode(n *ast.WorkflowRecurrenceNode) *workflows.Recurrence { + if n == nil { + return nil + } + unit, _ := workflows.RecurrenceIntervalType(n.Unit) + return &workflows.Recurrence{IntervalType: unit, Interval: n.Interval, MaxExecutions: n.MaxExecutions} +} diff --git a/mdl/executor/validate_workflow.go b/mdl/executor/validate_workflow.go index ba46498a3d..c24c3af134 100644 --- a/mdl/executor/validate_workflow.go +++ b/mdl/executor/validate_workflow.go @@ -130,6 +130,7 @@ func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { out = append(out, ValidateWorkflowEventSubProcesses(stmt)...) out = append(out, ValidateWorkflowExpressionScope(stmt)...) out = append(out, checkWorkflowTemplates(workflowTemplateSites(stmt), loc)...) + out = append(out, checkWorkflowRecurrences(workflowRecurrenceSites(stmt), loc)...) return out } @@ -437,5 +438,6 @@ func ValidateAlterWorkflow(stmt *ast.AlterWorkflowStmt) []linter.Violation { // header's declared context name, so only $WorkflowContext is accepted for it. out = append(out, checkWorkflowExprScope(alterWorkflowExprSites(stmt), "", loc)...) out = append(out, checkWorkflowTemplates(alterWorkflowTemplateSites(stmt), loc)...) + out = append(out, checkWorkflowRecurrences(alterWorkflowRecurrenceSites(stmt), loc)...) return append(out, ValidateAlterWorkflowEnds(stmt)...) } diff --git a/mdl/executor/validate_workflow_recurrence.go b/mdl/executor/validate_workflow_recurrence.go new file mode 100644 index 0000000000..80a2369e4d --- /dev/null +++ b/mdl/executor/validate_workflow_recurrence.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// MDL-WF22: a timer recurrence Studio Pro refuses (ako/mxcli#1716). +// `repeat every at most times` repeats a non-interrupting timer +// — a boundary event or an event sub-process's start. Studio Pro 11.15's model +// validation (the workflow schema shipped with mxbuild) allows: +// +// unit minute, hour, day, week, month (MW0005; second and year are in +// the enumeration but refused) +// interval at least 1 +// max at least 2, counting the first firing (MW0004) +// +// and only a non-interrupting timer has a Recurrence. mx check enforces none of +// these — it accepted IntervalType 'Bogus' — so they are checked here. +const wfRecurrenceRule = "MDL-WF22" + +// wfRecurrenceSite is one `repeat every …` a workflow statement writes. +type wfRecurrenceSite struct { + where string + r *ast.WorkflowRecurrenceNode + interrupting bool + interruptLabel string // "an interrupting timer" or "a timer that names no kind" +} + +func checkWorkflowRecurrences(sites []wfRecurrenceSite, loc linter.Location) []linter.Violation { + var out []linter.Violation + report := func(msg, suggestion string) { + out = append(out, linter.Violation{RuleID: wfRecurrenceRule, Severity: linter.SeverityError, Location: loc, Message: msg, Suggestion: suggestion}) + } + for _, s := range sites { + if s.interrupting { + report(fmt.Sprintf("%s repeats, but it is %s — only a non-interrupting timer has a recurrence; an interrupting one ends its activity the first time it fires", s.where, s.interruptLabel), + "Make it `non interrupting timer … repeat every …`, or drop the `repeat every` clause.") + continue + } + unit, known := workflows.RecurrenceIntervalType(s.r.Unit) + switch { + case !known || unit == "Second" || unit == "Year": + report(fmt.Sprintf("%s repeats every %d %s — a recurrence repeats every minute, hour, day, week or month (Studio Pro MW0005; mx check does not catch it)", s.where, s.r.Interval, s.r.Unit), + "Write the unit as minute(s), hour(s), day(s), week(s) or month(s).") + case s.r.Interval < 1: + report(fmt.Sprintf("%s repeats every %d %s — the interval is at least 1", s.where, s.r.Interval, s.r.Unit), "Repeat every 1 or more.") + } + if s.r.MaxExecutions < 2 { + report(fmt.Sprintf("%s repeats at most %d time(s) — the count includes the first firing, so it is at least 2 (Studio Pro MW0004; mx check does not catch it)", s.where, s.r.MaxExecutions), + "`at most 2 times` is the first firing and one repeat.") + } + } + return out +} + +// boundaryRecurrenceSites collects the recurrences of the boundary events on +// the activities given, at any depth. +func boundaryRecurrenceSites(acts []ast.WorkflowActivityNode) []wfRecurrenceSite { + var sites []wfRecurrenceSite + walkWorkflowActivities(acts, func(a ast.WorkflowActivityNode) { + for _, be := range workflowActivityBoundaryEvents(a) { + if be.Recurrence != nil { + sites = append(sites, boundaryRecurrenceSite("a timer boundary event on "+boundaryHostLabel(a), be.EventType, be.Recurrence)) + } + } + }) + return sites +} + +func boundaryRecurrenceSite(where, eventType string, r *ast.WorkflowRecurrenceNode) wfRecurrenceSite { + s := wfRecurrenceSite{where: where, r: r} + switch eventType { + case "InterruptingTimer": + s.interrupting, s.interruptLabel = true, "an interrupting timer" + case "Timer": + s.interrupting, s.interruptLabel = true, "a timer that names no kind (written as interrupting)" + } + return s +} + +// workflowRecurrenceSites collects the recurrences a CREATE WORKFLOW writes. +func workflowRecurrenceSites(stmt *ast.CreateWorkflowStmt) []wfRecurrenceSite { + sites := boundaryRecurrenceSites(workflowStatementActivities(stmt)) + for _, esp := range stmt.EventSubProcesses { + if esp.Recurrence == nil { + continue + } + s := wfRecurrenceSite{where: "the timer start of event sub-process " + esp.Name, r: esp.Recurrence} + if esp.Interrupting { + s.interrupting, s.interruptLabel = true, "an interrupting start" + } + sites = append(sites, s) + } + return sites +} + +// alterWorkflowRecurrenceSites collects the recurrences an ALTER WORKFLOW writes. +func alterWorkflowRecurrenceSites(stmt *ast.AlterWorkflowStmt) []wfRecurrenceSite { + sites := boundaryRecurrenceSites(alterWorkflowAddedActivities(stmt)) + for _, op := range stmt.Operations { + if o, ok := op.(*ast.InsertBoundaryEventOp); ok && o.Recurrence != nil { + sites = append(sites, boundaryRecurrenceSite("the timer inserted on "+o.ActivityRef, o.EventType, o.Recurrence)) + } + } + return sites +} + +// boundaryHostLabel names an activity that carries boundary events. +func boundaryHostLabel(a ast.WorkflowActivityNode) string { + switch n := a.(type) { + case *ast.WorkflowCallWorkflowNode: + return "call workflow " + n.Workflow.String() + case *ast.WorkflowWaitForNotificationNode: + return "wait for notification" + } + return branchingLabel(a) +} diff --git a/mdl/executor/validate_workflow_recurrence_test.go b/mdl/executor/validate_workflow_recurrence_test.go new file mode 100644 index 0000000000..300557c67d --- /dev/null +++ b/mdl/executor/validate_workflow_recurrence_test.go @@ -0,0 +1,91 @@ +// 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/workflows" +) + +// MDL-WF22 (ako/mxcli#1716): the limits Studio Pro 11.15's model validation +// puts on a recurrence (MW0004, MW0005), which mx check does not enforce. +func TestWorkflowRecurrence_Limits(t *testing.T) { + task := func(timer string) string { + return wfTemplateHead + "begin user task R 'R' outcomes 'Done' { } boundary event " + timer + " { }; end workflow;" + } + cases := []struct{ name, src, want string }{ + {"interrupting", task("interrupting timer addDays([%CurrentDateTime%], 1) repeat every 1 day at most 3 times"), "only a non-interrupting timer"}, + {"no kind named", task("timer addDays([%CurrentDateTime%], 1) repeat every 1 day at most 3 times"), "only a non-interrupting timer"}, + {"seconds", task("non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 30 seconds at most 3 times"), "minute, hour, day, week or month"}, + {"years", task("non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 1 year at most 3 times"), "MW0005"}, + {"an unknown unit", task("non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 1 fortnight at most 3 times"), "fortnight"}, + {"interval 0", task("non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 0 days at most 3 times"), "at least 1"}, + {"once in all", task("non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 1 day at most 1 times"), "MW0004"}, + {"interrupting event sub-process", wfTemplateHead + "begin user task R 'R' outcomes 'Done' { }; event subprocess E 'E' on interrupting timer addDays([%CurrentDateTime%], 1) repeat every 1 day at most 3 times { }; end workflow;", "only a non-interrupting timer"}, + // --- clean: the bounds themselves --- + {"every minute, twice", task("non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 1 minute at most 2 times"), ""}, + {"months, plural", task("non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 3 Months at most 12 times"), ""}, + {"event sub-process", wfTemplateHead + "begin user task R 'R' outcomes 'Done' { }; event subprocess E 'E' on non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 1 week at most 4 times { }; end workflow;", ""}, + {"no recurrence", task("interrupting timer addDays([%CurrentDateTime%], 1)"), ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + vs := wfRuleViolations(ValidateWorkflow(parseWorkflowStmt(t, c.src)), wfRecurrenceRule) + switch { + case c.want == "" && len(vs) > 0: + t.Errorf("reported a valid recurrence: %v", vs) + case c.want != "" && (len(vs) == 0 || !strings.Contains(vs[0].Message, c.want)): + t.Errorf("want a violation naming %q, got %v", c.want, vs) + } + }) + } + prog, errs := visitor.Build(`alter workflow M.W { insert into Review { boundary event interrupting timer addDays([%CurrentDateTime%], 7) repeat every 1 week at most 4 times { } } };`) + if len(errs) > 0 { + t.Fatal(errs) + } + if vs := wfRuleViolations(ValidateAlterWorkflow(prog.Statements[0].(*ast.AlterWorkflowStmt)), wfRecurrenceRule); len(vs) != 1 { + t.Errorf("alter insert of a repeating interrupting timer: %v", vs) + } +} + +// The builder stores the unit as the IntervalType the runtime reads. +func TestWorkflowRecurrence_Builder(t *testing.T) { + for word, want := range map[string]string{"minute": "Minute", "Hours": "Hour", "day": "Day", "WEEKS": "Week", "month": "Month"} { + got := recurrenceFromNode(&ast.WorkflowRecurrenceNode{Interval: 2, Unit: word, MaxExecutions: 3}) + if got.IntervalType != want || got.Interval != 2 || got.MaxExecutions != 3 { + t.Errorf("%s -> %+v, want %s", word, got, want) + } + } + if recurrenceFromNode(nil) != nil { + t.Error("no recurrence must build none") + } +} + +// Describe prints a recurrence on a boundary event and on an event +// sub-process start, and the output re-parses into the same values. +func TestWorkflowDescribe_RecurrenceReparses(t *testing.T) { + task := &workflows.UserTask{Page: "M.P", Outcomes: []*workflows.UserTaskOutcome{{Value: "Ok"}}} + task.Name, task.Caption = "T", "Task" + task.BoundaryEvents = []*workflows.BoundaryEvent{ + {EventType: "NonInterruptingTimer", TimerDelay: "addDays([%CurrentDateTime%], 1)", Caption: "Remind", + Recurrence: &workflows.Recurrence{IntervalType: "Day", Interval: 1, MaxExecutions: 5}}, + {EventType: "NonInterruptingTimer", TimerDelay: "addDays([%CurrentDateTime%], 2)", + Recurrence: &workflows.Recurrence{IntervalType: "Hour", Interval: 6, MaxExecutions: 3}}, + } + body := strings.Join(formatSingleActivity(task, " "), "\n") + if !strings.Contains(body, "repeat every 1 day at most 5 times caption 'Remind'") || !strings.Contains(body, "repeat every 6 hours at most 3 times") { + t.Errorf("describe:\n%s", body) + } + prog, errs := visitor.Build("create workflow M.W parameter $C: M.E\nbegin\n" + body + "\nend workflow;") + if len(errs) > 0 { + t.Fatalf("describe output does not parse: %v\n%s", errs, body) + } + got := prog.Statements[0].(*ast.CreateWorkflowStmt).Activities[0].(*ast.WorkflowUserTaskNode).BoundaryEvents + if r := recurrenceFromNode(got[1].Recurrence); r == nil || *r != (workflows.Recurrence{IntervalType: "Hour", Interval: 6, MaxExecutions: 3}) { + t.Errorf("re-parsed recurrence = %+v", r) + } +} diff --git a/mdl/executor/validate_workflow_unstatable.go b/mdl/executor/validate_workflow_unstatable.go index aea2c8707e..6d5a71b507 100644 --- a/mdl/executor/validate_workflow_unstatable.go +++ b/mdl/executor/validate_workflow_unstatable.go @@ -16,17 +16,17 @@ import ( // a user task's task name 'Review {1}' (caption 'Review') reset to 'Review' (stated since #1714) // its parameter {1} = $WorkflowContext/Name dropped (stated since #1714) // a timer boundary event's caption blanked (stated since #1715) -// a non-interrupting timer's recurrence dropped (a one-shot reminder) +// a non-interrupting timer's recurrence dropped (stated since #1716) // -// The rebuild writes a timer boundary event without a recurrence, and a -// boundary event without its annotation; each is refused until MDL can state it -// — the issue named after the reason adds the syntax. A user task's task name -// and the texts' parameters are stated since ako/mxcli#1714 (`task name '…'`, -// `… with ({1} = …)`), and printed by describe. +// The rebuild writes a boundary event without its annotation, which is refused +// until MDL can state it. A user task's task name and the texts' parameters +// are stated since ako/mxcli#1714 (`task name '…'`, `… with ({1} = …)`), and a +// timer's recurrence since ako/mxcli#1716 (`repeat every …`); describe prints +// them. // rawBoundaryEventLosses lists what rebuilding a stored activity resets on its -// boundary events: a timer's recurrence, and an annotation. A timer's caption -// is stated with `caption '…'` and printed by describe (ako/mxcli#1715). +// boundary events: an annotation. A timer's caption (ako/mxcli#1715) and +// recurrence (#1716) are stated, and printed by describe. func rawBoundaryEventLosses(d map[string]any) []string { var out []string for _, be := range rawList(d["BoundaryEvents"]) { @@ -35,10 +35,6 @@ func rawBoundaryEventLosses(d map[string]any) []string { continue } kind := rawBoundaryEventKind(bm) - if r, ok := bm["Recurrence"].(map[string]any); ok { - out = append(out, fmt.Sprintf("its %s boundary event repeats (every %v %v, at most %v times), which MDL cannot state yet (ako/mxcli#1716)", - kind, r["Interval"], r["IntervalType"], r["MaxExecutions"])) - } if a, ok := bm["Annotation"].(map[string]any); ok { text, _ := a["Description"].(string) out = append(out, fmt.Sprintf("its %s boundary event has annotation '%s', which MDL cannot state", kind, text)) diff --git a/mdl/executor/validate_workflow_unstatable_test.go b/mdl/executor/validate_workflow_unstatable_test.go index 8d3f03e0ae..62b2f7a7b4 100644 --- a/mdl/executor/validate_workflow_unstatable_test.go +++ b/mdl/executor/validate_workflow_unstatable_test.go @@ -60,9 +60,6 @@ func TestWorkflowRewrite_RefusesResettingUnstatableSettings(t *testing.T) { set func(wf, task, timer, reminder map[string]any) want string }{ - {"recurrence", func(_, _, _, reminder map[string]any) { - reminder["Recurrence"] = map[string]any{"$Type": "Workflows$LinearRecurrence", "Interval": int32(1), "IntervalType": "Day", "MaxExecutions": int32(5)} - }, "repeats (every 1 Day, at most 5 times)"}, {"boundary event annotation", func(_, _, _, reminder map[string]any) { reminder["Annotation"] = map[string]any{"$Type": "Workflows$Annotation", "Description": "nudge"} }, "annotation 'nudge'"}, @@ -161,3 +158,15 @@ func TestWorkflowRewrite_AllowsTemplateParameters(t *testing.T) { t.Errorf("restated templates must be allowed, got %v", err) } } + +// ako/mxcli#1716: a recurrence is stated now, so a stored one no longer refuses +// the rewrite — describe prints it and the statement restates it. +func TestWorkflowRewrite_AllowsARecurrence(t *testing.T) { + wf, _, _, reminder := unstatableWorkflow() + reminder["Recurrence"] = map[string]any{"$Type": "Workflows$LinearRecurrence", "Interval": int32(1), "IntervalType": "Day", "MaxExecutions": int32(5)} + restated := strings.Replace(wfUnstatableRestated, "non interrupting timer addDays([%CurrentDateTime%], 1) { }", + "non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 1 day at most 5 times { }", 1) + if err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, wf), "wf1", "M.W", parseWorkflowStmt(t, restated)); err != nil { + t.Errorf("a restated recurrence must be allowed, got %v", err) + } +} diff --git a/mdl/executor/version_gates.go b/mdl/executor/version_gates.go index d667ab3ef2..8d6fe0ae5d 100644 --- a/mdl/executor/version_gates.go +++ b/mdl/executor/version_gates.go @@ -183,6 +183,14 @@ func statementVersionGates(stmt ast.Statement) []versionGate { break } } + for _, esp := range s.EventSubProcesses { + if esp.Recurrence != nil { + gates = append(gates, versionGate{"workflows", "recurring_timer_event_subprocesses", "event subprocess … repeat every", + "a repeating event sub-process start needs Mendix 11.15 or later — the 11.13 runtime ignores the recurrence and starts it once; " + + "repeat a non-interrupting boundary timer instead (`boundary event non interrupting timer … repeat every …`)"}) + break + } + } if workflowUsesNotificationEvents(activities) { gates = append(gates, notificationEventsGate) } diff --git a/mdl/executor/version_gates_test.go b/mdl/executor/version_gates_test.go index b26b8d99ac..18b555be7b 100644 --- a/mdl/executor/version_gates_test.go +++ b/mdl/executor/version_gates_test.go @@ -58,6 +58,10 @@ func TestCheckVersionGates_ReportsEveryHandlerGate(t *testing.T) { &ast.WorkflowCallMicroflowNode{Agent: true}}}, [3]int{11, 8, 0}, "call agent microflow"}, {"event subprocess", &ast.CreateWorkflowStmt{EventSubProcesses: []ast.WorkflowEventSubProcessNode{{Name: "E"}}}, [3]int{11, 7, 0}, "event subprocess"}, {"timer event subprocess", &ast.CreateWorkflowStmt{EventSubProcesses: []ast.WorkflowEventSubProcessNode{{Name: "E", Timer: true}}}, [3]int{11, 12, 0}, "on … timer"}, + // ako/mxcli#1716: the 11.13 runtime has no recurrence on the start and + // fires it once; the 11.15 runtime repeats it. + {"repeating event subprocess", &ast.CreateWorkflowStmt{EventSubProcesses: []ast.WorkflowEventSubProcessNode{{Name: "E", Timer: true, + Recurrence: &ast.WorkflowRecurrenceNode{Interval: 1, Unit: "day", MaxExecutions: 3}}}}, [3]int{11, 14, 0}, "repeat every"}, {"notification", &ast.CreateWorkflowStmt{Activities: []ast.WorkflowActivityNode{&ast.WorkflowNotificationNode{}}}, [3]int{11, 10, 0}, "notification activities"}, // mendixlabs/mxcli#1498: a domain-model export level is 9.3+. {"entity export level", &ast.CreateEntityStmt{ExportLevel: &api}, [3]int{9, 2, 0}, "export level on an entity"}, diff --git a/mdl/executor/workflow_interrupting_end_test.go b/mdl/executor/workflow_interrupting_end_test.go index 4e263d4201..c29cdb0e27 100644 --- a/mdl/executor/workflow_interrupting_end_test.go +++ b/mdl/executor/workflow_interrupting_end_test.go @@ -114,7 +114,7 @@ func TestAlterInsertBoundaryEvent_InterruptingPathEndsLikeStudioPro(t *testing.T ResolveAlterTargetFunc: func(tg backend.AlterTarget) (backend.AlterTargetMatch, error) { return backend.AlterTargetMatch{Kind: "user task", Name: backend.WorkflowTargetText(tg)}, nil }, - InsertBoundaryEventFunc: func(ref string, _ int, eventType, _, _ string, acts []workflows.WorkflowActivity) error { + InsertBoundaryEventFunc: func(ref string, _ int, eventType, _, _ string, _ *workflows.Recurrence, acts []workflows.WorkflowActivity) error { got[ref+" "+eventType] = acts return nil }, diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index 97b9483a11..cb6c8049ae 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -923,6 +923,11 @@ AWAIT: A W A I T; // `auto assign single user` on a user task (AutoAssignSingleTargetUser). AUTO: A U T O; ASSIGN: A S S I G N; +// `repeat every 1 day at most 5 times` on a non-interrupting timer +// (Workflows$LinearRecurrence, ako/mxcli#1716). Words, so still usable as names. +REPEAT: R E P E A T; +EVERY: E V E R Y; +TIMES: T I M E S; // Data transformer tokens DATA: D A T A; diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index db0da34b8e..41969cb34e 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -668,6 +668,7 @@ keyword // Workflow user task settings — words, so still usable as names | DECIDE | CONSENSUS | MAJORITY | MORE_KW | THAN | HALF | MOST | CHOSEN | THRESHOLD | PERCENT_KW | VOTES | VETO | FALLBACK | PARTICIPANTS | AWAIT | AUTO | ASSIGN + | REPEAT | EVERY | TIMES // Microflow / Nanoflow | MICROFLOW | MICROFLOWS | NANOFLOW | NANOFLOWS diff --git a/mdl/grammar/domains/MDLWorkflow.g4 b/mdl/grammar/domains/MDLWorkflow.g4 index a892dd7da9..51f211482f 100644 --- a/mdl/grammar/domains/MDLWorkflow.g4 +++ b/mdl/grammar/domains/MDLWorkflow.g4 @@ -73,7 +73,7 @@ workflowEventSubProcess */ workflowEventSubProcessTrigger : NOTIFICATION workflowActivityName? STRING_LITERAL? - | TIMER workflowExpression (AS workflowActivityName)? workflowCaption? + | TIMER workflowExpression workflowRecurrence? (AS workflowActivityName)? workflowCaption? ; /** @@ -185,10 +185,22 @@ workflowExpression * grammar admits as a name: `interrupting timer non interrupting timer 'x'` is * two events, the first without a delay, as it was when only a string could be * the delay (ako/mxcli#753). Nor may it start with the `caption` (or its alias - * `comment`) of a timer with no delay: `interrupting timer caption 'Escalate'`. + * `comment`) of a timer with no delay: `interrupting timer caption 'Escalate'`, + * or with its `repeat`. */ workflowTimerDelay - : {p.GetTokenStream().LA(1) != MDLParserNON && p.GetTokenStream().LA(1) != MDLParserCAPTION && p.GetTokenStream().LA(1) != MDLParserCOMMENT}? workflowExpression + : {p.GetTokenStream().LA(1) != MDLParserNON && p.GetTokenStream().LA(1) != MDLParserCAPTION && p.GetTokenStream().LA(1) != MDLParserCOMMENT && p.GetTokenStream().LA(1) != MDLParserREPEAT}? workflowExpression + ; + +/** + * A non-interrupting timer's recurrence, Workflows$LinearRecurrence + * (ako/mxcli#1716): after its first firing the timer fires every + * , times in all — the first firing counts, as Studio Pro's + * MaxExecutions does. The unit is a name (minute(s) … month(s)), not a + * keyword, and the visitor and MDL-WF22 check it. + */ +workflowRecurrence + : REPEAT EVERY interval=NUMBER_LITERAL unit=identifierOrKeyword AT_KW MOST maxExecutions=NUMBER_LITERAL TIMES ; /** @@ -271,9 +283,9 @@ workflowFallbackClause * with two boundary events did not parse. */ workflowBoundaryEventClause - : INTERRUPTING TIMER workflowTimerDelay? workflowCaption? (LBRACE workflowBody RBRACE)? - | NON INTERRUPTING TIMER workflowTimerDelay? workflowCaption? (LBRACE workflowBody RBRACE)? - | TIMER workflowTimerDelay? workflowCaption? (LBRACE workflowBody RBRACE)? + : INTERRUPTING TIMER workflowTimerDelay? workflowRecurrence? workflowCaption? (LBRACE workflowBody RBRACE)? + | NON INTERRUPTING TIMER workflowTimerDelay? workflowRecurrence? workflowCaption? (LBRACE workflowBody RBRACE)? + | TIMER workflowTimerDelay? workflowRecurrence? workflowCaption? (LBRACE workflowBody RBRACE)? // A notification boundary event is triggered by `notify workflow … target // `, so its name is what matters; the string is its caption. | INTERRUPTING NOTIFICATION workflowActivityName? STRING_LITERAL? (LBRACE workflowBody RBRACE)? diff --git a/mdl/visitor/visitor_alter_workflow.go b/mdl/visitor/visitor_alter_workflow.go index e064920c9d..fd8b605f55 100644 --- a/mdl/visitor/visitor_alter_workflow.go +++ b/mdl/visitor/visitor_alter_workflow.go @@ -166,7 +166,7 @@ func buildAlterWorkflowMember(ctx *parser.AlterWorkflowMemberContext, ref string be := buildBoundaryEventNode(ctx.WorkflowBoundaryEventClause()) return []ast.AlterWorkflowOp{&ast.InsertBoundaryEventOp{ ActivityRef: ref, AtPosition: pos, - EventType: be.EventType, Delay: be.Delay, Caption: be.Caption, Activities: be.Activities, + EventType: be.EventType, Delay: be.Delay, Caption: be.Caption, Recurrence: be.Recurrence, Activities: be.Activities, }} } return nil diff --git a/mdl/visitor/visitor_workflow.go b/mdl/visitor/visitor_workflow.go index c55aaa9bd1..22741ff753 100644 --- a/mdl/visitor/visitor_workflow.go +++ b/mdl/visitor/visitor_workflow.go @@ -988,6 +988,7 @@ func buildBoundaryEventNode(beCtx parser.IWorkflowBoundaryEventClauseContext) as } // A timer's caption is the clause every activity takes (ako/mxcli#1715). be.Caption = workflowCaptionText(beCtx2.WorkflowCaption()) + be.Recurrence = buildWorkflowRecurrence(beCtx2.WorkflowRecurrence()) } if notification { be.Name = workflowActivityNameText(beCtx2.WorkflowActivityName()) @@ -1027,6 +1028,7 @@ func buildWorkflowEventSubProcess(ctx parser.IWorkflowEventSubProcessContext) as if node.Timer { node.FirstExecutionTime = workflowExpressionText(t.WorkflowExpression()) node.StartCaption = workflowCaptionText(t.WorkflowCaption()) + node.Recurrence = buildWorkflowRecurrence(t.WorkflowRecurrence()) } else if s := t.STRING_LITERAL(); s != nil { node.StartCaption = unquoteStringLit(s) } diff --git a/mdl/visitor/visitor_workflow_expressions.go b/mdl/visitor/visitor_workflow_expressions.go index 791cd9b95c..5e6ac4d65b 100644 --- a/mdl/visitor/visitor_workflow_expressions.go +++ b/mdl/visitor/visitor_workflow_expressions.go @@ -185,3 +185,23 @@ func buildWorkflowTemplateParams(ctx parser.ITemplateParamsContext) []ast.Workfl } return out } + +// buildWorkflowRecurrence reads `repeat every at most times` +// (ako/mxcli#1716); nil when the timer has none. The unit is kept as written. +func buildWorkflowRecurrence(ctx parser.IWorkflowRecurrenceContext) *ast.WorkflowRecurrenceNode { + rc, ok := ctx.(*parser.WorkflowRecurrenceContext) + if !ok || rc == nil { + return nil + } + r := &ast.WorkflowRecurrenceNode{} + if t := rc.GetInterval(); t != nil { + r.Interval, _ = strconv.Atoi(t.GetText()) + } + if t := rc.GetMaxExecutions(); t != nil { + r.MaxExecutions, _ = strconv.Atoi(t.GetText()) + } + if u := rc.GetUnit(); u != nil { + r.Unit = identifierOrKeywordText(u) + } + return r +} diff --git a/mdl/visitor/visitor_workflow_recurrence_test.go b/mdl/visitor/visitor_workflow_recurrence_test.go new file mode 100644 index 0000000000..f3d7278141 --- /dev/null +++ b/mdl/visitor/visitor_workflow_recurrence_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#1716: a non-interrupting timer — a boundary event or an event +// sub-process's start — takes `repeat every at most times`. +func TestWorkflowVisitor_Recurrence(t *testing.T) { + src := wfHead + `begin + user task R 'R' + outcomes 'Done' { } + boundary event non interrupting timer addDays([%CurrentDateTime%], 1) repeat every 1 day at most 5 times caption 'Remind' { } + boundary event non interrupting timer repeat every 2 Hours at most 3 times; + event subprocess Nudge 'Nudge' + on non interrupting timer addHours([%CurrentDateTime%], 2) repeat every 1 week at most 4 times as NudgeStart { + }; +end workflow;` + wf := mustBuild(t, src).Statements[0].(*ast.CreateWorkflowStmt) + events := wf.Activities[0].(*ast.WorkflowUserTaskNode).BoundaryEvents + if len(events) != 2 { + t.Fatalf("built %d boundary events, want 2", len(events)) + } + want := func(what string, got *ast.WorkflowRecurrenceNode, interval int, unit string, max int) { + t.Helper() + if got == nil || got.Interval != interval || got.Unit != unit || got.MaxExecutions != max { + t.Errorf("%s recurrence = %+v, want every %d %s at most %d", what, got, interval, unit, max) + } + } + want("first event", events[0].Recurrence, 1, "day", 5) + if events[0].Caption != "Remind" { + t.Errorf("caption after the recurrence = %q", events[0].Caption) + } + want("second event (no delay)", events[1].Recurrence, 2, "Hours", 3) + if events[1].Delay != "" { + t.Errorf("`repeat` was read as a delay: %q", events[1].Delay) + } + want("event sub-process", wf.EventSubProcesses[0].Recurrence, 1, "week", 4) + if wf.EventSubProcesses[0].StartName != "NudgeStart" { + t.Errorf("start name after the recurrence = %q", wf.EventSubProcesses[0].StartName) + } + + op := mustBuild(t, `alter workflow M.WF { insert into Review { boundary event non interrupting timer addDays([%CurrentDateTime%], 7) repeat every 1 week at most 4 times { } } };`). + Statements[0].(*ast.AlterWorkflowStmt).Operations[0].(*ast.InsertBoundaryEventOp) + want("alter insert", op.Recurrence, 1, "week", 4) +} + +// CONTROL: a timer without the clause has none. +func TestWorkflowVisitor_NoRecurrence(t *testing.T) { + wf := mustBuild(t, wfHead+"begin user task R 'R' outcomes 'Done' { } boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { }; end workflow;").Statements[0].(*ast.CreateWorkflowStmt) + if r := wf.Activities[0].(*ast.WorkflowUserTaskNode).BoundaryEvents[0].Recurrence; r != nil { + t.Errorf("recurrence = %+v, want none", r) + } +} + +// `repeat`, `every` and `times` became keywords; they stay usable as names. +func TestWorkflowRecurrence_KeywordsAreStillNames(t *testing.T) { + for _, src := range []string{ + "create persistent entity M.Schedule (Repeat: Boolean, Every: Integer, Times: Integer);", + "create persistent entity M.Times (Name: String(20));", + `create microflow M.F () returns Boolean begin + retrieve $S from M.Schedule where [Times > 1 and Repeat = true]; + return true; +end;`, + } { + if _, errs := Build(src); len(errs) > 0 { + t.Errorf("%s\n%v", src, errs) + } + } +} diff --git a/sdk/versions/mendix-11.yaml b/sdk/versions/mendix-11.yaml index f3c5b6583e..687a18b3b5 100644 --- a/sdk/versions/mendix-11.yaml +++ b/sdk/versions/mendix-11.yaml @@ -256,6 +256,10 @@ features: min_version: "11.13.0" mdl: "EVENT SUBPROCESS ESP_Remind ON NON INTERRUPTING TIMER 'addDays([%CurrentDateTime%], 1)' { … };" notes: "(Non)InterruptingTimerEventSubProcessStartActivity: TypeCacheUnknownTypeException on the 11.10, 11.11 and 11.12 mxbuilds, loads on 11.13." + recurring_timer_event_subprocesses: + min_version: "11.15.0" + mdl: "EVENT SUBPROCESS ESP_Remind ON NON INTERRUPTING TIMER addDays([%CurrentDateTime%], 1) REPEAT EVERY 1 DAY AT MOST 5 TIMES { … };" + notes: "NonInterruptingTimerEventSubProcessStartActivity.recurrence (ako/mxcli#1716). The 11.13 runtime model has no recurrence on the start (com.mendix.workflows-metamodel.jar) and fires it once — mx check passes; the 11.15 runtime has it. 11.14 unmeasured. A non-interrupting timer BOUNDARY event's recurrence is in the 11.12, 11.13 and 11.15 runtimes." notification_events: min_version: "11.11.0" mdl: "NOTIFICATION Received; … BOUNDARY EVENT INTERRUPTING NOTIFICATION Withdrawn { … }" diff --git a/sdk/workflows/workflow.go b/sdk/workflows/workflow.go index a58ea00b67..46d57088b8 100644 --- a/sdk/workflows/workflow.go +++ b/sdk/workflows/workflow.go @@ -316,6 +316,8 @@ type EventSubProcessStartActivity struct { Interrupting bool `json:"interrupting"` Timer bool `json:"timer"` FirstExecutionTime string `json:"firstExecutionTime,omitempty"` // timer starts only + // Recurrence repeats a non-interrupting timer start; nil fires it once. + Recurrence *Recurrence `json:"recurrence,omitempty"` } // ActivityType returns the type name, e.g. "InterruptingNotificationEventSubProcessStart". @@ -635,6 +637,49 @@ type BoundaryEvent struct { // EventType is "InterruptingTimer", "NonInterruptingTimer", "Timer", // "InterruptingNotification" or "NonInterruptingNotification". EventType string `json:"eventType,omitempty"` + // Recurrence repeats a non-interrupting timer; nil fires it once. + Recurrence *Recurrence `json:"recurrence,omitempty"` +} + +// Recurrence is a non-interrupting timer's repetition, Workflows$LinearRecurrence +// (ako/mxcli#1716): after its first firing it fires every Interval IntervalType, +// MaxExecutions times in all — the first firing included. Studio Pro allows +// IntervalType Minute, Hour, Day, Week or Month (MW0005), Interval at least 1 +// and MaxExecutions at least 2 (MW0004); mx check enforces none of them. +type Recurrence struct { + IntervalType string `json:"intervalType"` + Interval int `json:"interval"` + MaxExecutions int `json:"maxExecutions"` +} + +// recurrenceUnits maps the unit words MDL accepts, singular and plural, to the +// stored IntervalType: the scheduled-event enumeration the runtime reads it as +// (com.mendix.model.scheduledevents.ModelIntervalType, 11.15.0). Second and +// Year are in that enumeration but not allowed for a recurrence. +var recurrenceUnits = map[string]string{ + "second": "Second", "minute": "Minute", "hour": "Hour", "day": "Day", + "week": "Week", "month": "Month", "year": "Year", +} + +// RecurrenceIntervalType is the stored IntervalType for a unit word — "day", +// "Days", … — and whether the word names one. +func RecurrenceIntervalType(word string) (string, bool) { + w := strings.ToLower(word) + if t, ok := recurrenceUnits[w]; ok { + return t, true + } + t, ok := recurrenceUnits[strings.TrimSuffix(w, "s")] + return t, ok +} + +// RecurrenceUnitWord is how MDL writes an IntervalType after a count: +// "day" for 1, "days" otherwise. +func RecurrenceUnitWord(intervalType string, count int) string { + w := strings.ToLower(intervalType) + if count != 1 { + w += "s" + } + return w } // boundaryEventStorageTypes maps EventType to the stored $Type. Every writer From cc14ec2f1a2dcce51ae54872f978677c4924d491 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:34:12 +0000 Subject: [PATCH 38/40] fix: describe an action with no target selected as a note (#1728) An action whose flow, page or entity is not selected yet was described as a bare `call nanoflow`, `call microflow`, `show page` or `create object`. None of these parses, so one unwired button made the whole page's description unusable. ako/TestApp's Pages.OutputMappingButton failed with "extraneous input ')'". Studio Pro stores this state for placeholders: 16 in ako/TestApp and 5 in PedApp, all in page templates except that one page, which inherits them when made from a template. mx check rejects it on a page (CE0571 no nanoflow or microflow, CE0566 no page, CE0460 no entity), so it gets a NOT re-executable note on its own line rather than new syntax. A re-run writes no action there. Navigation's menu items keep their existing message, and the rewrite keeps the stored action as before. Named action slots and object-list item actions printed the rendered action as `key: value`, which a note would have broken; they now go through actionProp like Action and OnClick. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- ...26-10-26-describe-unset-action-target.json | 1 + mdl/executor/cmd_navigation.go | 6 + mdl/executor/cmd_pages_describe_output.go | 37 ++++-- .../describe_unset_action_target_1728_test.go | 115 ++++++++++++++++++ 4 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-unset-action-target.json create mode 100644 mdl/executor/describe_unset_action_target_1728_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-unset-action-target.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-unset-action-target.json new file mode 100644 index 0000000000..6fd9b5ddcc --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-unset-action-target.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "describe page printed `Action: call nanoflow)` (also bare `call microflow`, `show page`, `create object`) for an action whose flow, page or entity is not selected; none parses, so one unwired button made the whole page's description unusable (ako/TestApp Pages.OutputMappingButton)", "cause": "renderClientActionBaseMDL fell back to the bare keyword when the stored target was empty; only the navigation describer checked that the rendered action parses (menuOnClickParses)", "file": "mdl/executor/cmd_pages_describe_output.go (unsetTargetNote, renderClientActionBaseMDL, appendNamedActionProps and object-list items through actionProp), mdl/executor/cmd_navigation.go (menuItemActionMDL keeps its message); test mdl/executor/describe_unset_action_target_1728_test.go", "insight": "Measure before choosing note vs syntax: the state is common in Studio Pro (16 in TestApp, 5 in PedApp) but all in Forms$PageTemplate placeholders except one hand-made page, and mx check rejects it on a page (CE0571 no nanoflow/microflow, CE0566 no page, CE0460 no entity) — an error state that blocks a deploy is not worth MDL syntax, so a NOT re-executable note on its own line. When a rendered action can become a note, check every caller that embeds it: two printed `key: value` directly (named action slots, object-list items) and would have swallowed the rest of the line. A fix already existed one subsystem over (navigation's menuOnClickParses) — grep for the symptom's shape, not just the reported widget", "refs": ["ako/mxcli#1728"], "ce": ["CE0571", "CE0566", "CE0460"]} diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index 160f2c24fc..3fa10990af 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -356,6 +356,12 @@ func menuItemActionMDL(ctx *ExecContext, item *types.NavMenuItem) (onClick, note typeName, _ := doc["$Type"].(string) rendered := renderClientActionMDL(ctx, doc) switch { + case strings.HasSuffix(rendered, unsetTargetNoteTail): + // A stored action with its target unset (ako/mxcli#1728): say so and + // keep it. + what := strings.TrimSuffix(strings.TrimPrefix(rendered, "-- NOT re-executable: the action "), unsetTargetNoteTail) + return "", fmt.Sprintf("-- menu item %s: its action (%s) %s, so it has no target MDL can name; "+ + "a rewrite keeps the stored action while the item states none", menuCaptionNote(item.Caption), typeName, what) case strings.HasPrefix(rendered, "--"): return "", fmt.Sprintf("-- menu item %s: %s; a rewrite keeps the stored action while the item states none", menuCaptionNote(item.Caption), strings.TrimSpace(strings.TrimPrefix(rendered, "--"))) diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 339cbfa58a..346b276e0b 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -969,7 +969,8 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { itemProps = appendDataSourceProp(ctx, itemProps, item.DataSource) for _, p := range item.Props { if p.IsRef { - itemProps = append(itemProps, fmt.Sprintf("%s: %s", p.Key, p.Value)) + // An action that renders as a note stands on its own line. + itemProps = append(itemProps, actionProp(p.Key, p.Value)) } else { itemProps = append(itemProps, fmt.Sprintf("%s: %s", p.Key, mdlQuote(ctx, p.Value))) } @@ -1566,6 +1567,21 @@ func renderClientActionMDL(ctx *ExecContext, action map[string]any) string { // renderClientActionBaseMDL renders the action itself; renderClientActionMDL // adds its `with ( … )` settings. +// unsetTargetNoteTail ends every note unsetTargetNote writes, so a caller that +// says something else about such an action can tell it apart. +const unsetTargetNoteTail = "; MDL cannot name a missing target, so re-running this script drops the action" + +// unsetTargetNote describes an action whose flow, page or entity is not +// selected yet. Studio Pro stores that state for a placeholder: its page +// templates ship them, and a page made from one inherits them until a target +// is picked. mx check reports each (CE0571 no nanoflow or microflow, CE0566 no +// page, CE0460 no entity). The bare `call nanoflow` describe used to print does +// not parse, so one unwired button made the whole page's description unusable +// (ako/mxcli#1728); a note, which actionProp puts on its own line, does. +func unsetTargetNote(what string) string { + return "-- NOT re-executable: the action " + what + unsetTargetNoteTail +} + func renderClientActionBaseMDL(ctx *ExecContext, action map[string]any) string { if action == nil { return "" @@ -1593,13 +1609,14 @@ func renderClientActionBaseMDL(ctx *ExecContext, action map[string]any) string { } return result case "Forms$CreateObjectClientAction", "Pages$CreateObjectClientAction": - result := "create object" - // Extract entity reference + entityName := "" if entityRef, ok := action["EntityRef"].(map[string]any); ok { - if entityName, ok := entityRef["Entity"].(string); ok && entityName != "" { - result += " " + entityName - } + entityName, _ = entityRef["Entity"].(string) + } + if entityName == "" { + return unsetTargetNote("creates an object but no entity is selected (CE0460)") } + result := "create object " + entityName // Extract page reference from PageSettings (Forms$FormSettings) if pageSettings, ok := action["PageSettings"].(map[string]any); ok { // The page is stored in "Form" field as a qualified name string (BY_NAME_REFERENCE) @@ -1644,7 +1661,7 @@ func renderClientActionBaseMDL(ctx *ExecContext, action map[string]any) string { return "show page " + pageName } } - return "show page" + return unsetTargetNote("shows a page but none is selected (CE0566)") case "Forms$MicroflowAction", "Pages$MicroflowClientAction": // Extract microflow reference from MicroflowSettings if settings, ok := action["MicroflowSettings"].(map[string]any); ok { @@ -1661,7 +1678,7 @@ func renderClientActionBaseMDL(ctx *ExecContext, action map[string]any) string { return result } } - return "call microflow" + return unsetTargetNote("calls a microflow but none is selected (CE0571)") case "Forms$CallNanoflowClientAction", "Pages$CallNanoflowClientAction": if nfName, ok := action["Nanoflow"].(string); ok && nfName != "" { result := "call nanoflow " + nfName @@ -1675,7 +1692,7 @@ func renderClientActionBaseMDL(ctx *ExecContext, action map[string]any) string { } return result } - return "call nanoflow" + return unsetTargetNote("calls a nanoflow but none is selected (CE0571)") case "Forms$SetTaskOutcomeClientAction", "Pages$SetTaskOutcomeClientAction": outcomeValue, _ := action["OutcomeValue"].(string) return "complete task '" + strings.ReplaceAll(outcomeValue, "'", "''") + "'" @@ -2114,7 +2131,7 @@ func dataViewHasFooterBlock(w rawWidget) bool { // emitted as `onClick:` / `OnChange:` (#956). func appendNamedActionProps(props []string, w rawWidget) []string { for _, na := range w.NamedActions { - props = append(props, fmt.Sprintf("%s: %s", na.Key, na.MDL)) + props = append(props, actionProp(na.Key, na.MDL)) } return props } diff --git a/mdl/executor/describe_unset_action_target_1728_test.go b/mdl/executor/describe_unset_action_target_1728_test.go new file mode 100644 index 0000000000..be39b5f9a0 --- /dev/null +++ b/mdl/executor/describe_unset_action_target_1728_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#1728: an action whose flow, page or entity is not selected yet — +// the state Studio Pro's page templates ship and a page made from one inherits +// — described as a bare `call nanoflow`, `call microflow`, `show page` or +// `create object`, none of which parses, so one unwired button made the whole +// page's description unusable. mx check reports each as an error (CE0571, +// CE0566, CE0460); MDL has no spelling for it, so describe says so instead. +package executor + +import ( + "context" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +const unsetTargetSetup = `mdl 1; +create persistent entity MyFirstModule.Thing (Name: String(50)); +create microflow MyFirstModule.ACT_Go () begin end; +create nanoflow MyFirstModule.NF_Go () begin end; +create page MyFirstModule.Target_Page (Title: 'Target', Layout: Atlas_Core.Atlas_Default) { + dynamictext t (Content: 'x') +}; +create page MyFirstModule.Calls_Page (Title: 'Calls', Layout: Atlas_Core.Atlas_Default) { + actionbutton btnNanoflow (Caption: 'Nanoflow', Action: call nanoflow MyFirstModule.NF_Go with (ProgressBar: Blocking)) + actionbutton btnMicroflow (Caption: 'Microflow', Action: call microflow MyFirstModule.ACT_Go) + actionbutton btnPage (Caption: 'Page', Action: show page MyFirstModule.Target_Page) + actionbutton btnCreate (Caption: 'New', Action: create object MyFirstModule.Thing then show page MyFirstModule.Target_Page) + actionbutton btnControl (Caption: 'Save', Action: save changes) +}; +` + +// unsetActionTargets blanks what each action on the page targets, the way +// Studio Pro stores an action whose flow, page or entity is not selected. +func unsetActionTargets(t *testing.T, exec *Executor, page string) { + t.Helper() + id, raw := pageUnitByName(t, exec, page) + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + walkStoredDocs(doc, func(d bson.D) { + for i := range d { + switch { + case afGet(d, "$Type") == "Forms$CallNanoflowClientAction" && d[i].Key == "Nanoflow", + afGet(d, "$Type") == "Forms$MicroflowSettings" && d[i].Key == "Microflow", + afGet(d, "$Type") == "Forms$FormSettings" && d[i].Key == "Form": + d[i].Value = "" + case afGet(d, "$Type") == "Forms$CreateObjectClientAction" && d[i].Key == "EntityRef": + d[i].Value = nil + } + } + }) + b, err := bson.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := exec.newExecContext(context.Background()).Backend.UpdateRawUnit(string(id), b); err != nil { + t.Fatal(err) + } +} + +func TestDescribeActionWithUnsetTarget(t *testing.T) { + exec, out := openPedAppFixture(t) + if err := afRun(t, exec, unsetTargetSetup); err != nil { + t.Fatalf("exec: %v\n%s", err, out.String()) + } + unsetActionTargets(t, exec, "Calls_Page") + + out.Reset() + if err := afRun(t, exec, "describe page MyFirstModule.Calls_Page;"); err != nil { + t.Fatal(err) + } + described := out.String() + if _, errs := visitor.Build(described); len(errs) > 0 { + t.Fatalf("the description does not parse: %v\n%s", errs, described) + } + for _, want := range []string{ + "btnNanoflow", "calls a nanoflow but none is selected (CE0571)", + "calls a microflow but none is selected (CE0571)", + "shows a page but none is selected (CE0566)", + "creates an object but no entity is selected (CE0460)", + "Action: save changes", // control: a complete action is described as before + } { + if !strings.Contains(described, want) { + t.Errorf("describe does not print %q:\n%s", want, described) + } + } + if strings.Contains(described, "with (ProgressBar") { + t.Errorf("the settings of an action described as a note were printed on their own:\n%s", described) + } + out.Reset() + if err := afRun(t, exec, described); err != nil { + t.Fatalf("re-executing the description: %v", err) + } +} + +// The two other places a rendered action is embedded print it as `key: value`; +// a note there has to stand on its own line, as it does under `Action:`. +func TestNamedActionNoteStandsAlone(t *testing.T) { + note := "-- NOT re-executable: x" + got := appendNamedActionProps(nil, rawWidget{NamedActions: []rawNamedAction{{Key: "onClickAction", MDL: note}}}) + if len(got) != 1 || got[0] != note { + t.Errorf("named action note = %q, want the note alone", got) + } + // Control: an ordinary action keeps its key. + got = appendNamedActionProps(nil, rawWidget{NamedActions: []rawNamedAction{{Key: "onClickAction", MDL: "close page"}}}) + if len(got) != 1 || got[0] != "onClickAction: close page" { + t.Errorf("named action = %q", got) + } +} From 9a10cee08ba32e061f9568885cf3143d26800449 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 26 Oct 2026 22:51:55 +0000 Subject: [PATCH 39/40] feat: extra code clause for create java action, and describe prints it (ako/mxcli#1702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio Pro keeps three parts of a Java action's .java file when it regenerates it: the import list, the USER CODE and the EXTRA CODE section. MDL could author the first two (an import line in the body is hoisted to the import list) but had no clause for the third, and `describe java action` printed only the body. `create or modify` kept a hand-written helper in place, but executing the describe output on another project wrote a body calling a helper that no longer existed, and the build failed. Mirror of mendixlabs/mxcli#1658. Cause: createJavaActionStatement ended at `AS $$ … $$` (the AST's ExtraCode field was never set), and describeJavaAction read only the USER CODE section. Fix: create or modify java action ModJ.JA_Twice(N: Integer not null) returns Integer as $$ import java.util.Objects; return twice(N); $$ extra code $$ private static long twice(long n) { return Objects.requireNonNull(n) * 2L; } $$; `extra code $$ … $$` (new EXTRA/CODE keywords, both still names) states the section, kept as written apart from its blank edge lines; an import line in it goes to the import list too. Omitted on `create or modify`, the stored section is kept, as an omitted EXPOSED AS keeps the toolbox entry; `extra code $$ $$` clears it, so WriteJavaSourceFile/RetainSections take the section as a *string (nil keeps). describe prints the section and, at the top of the body, the shortest leading part of the stored import list that regenerates it (javaactions.StatedImports) — nothing for mxbuild's own list. Verification: visitor, sdk and executor tests, including describe → exec of the report's file rebuilding it byte for byte and the omitted-clause control handing the writer nil; stubbing the visitor, describe and RetainSections changes fails them. Real 11.13.0 app with the report's steps: the baseline replay on a fresh project lost the import and helper and mxbuild failed with `cannot find symbol`; the fix's replay is byte-identical to the source file, builds, and the build leaves it unchanged. `create or modify` without the clause leaves the file unchanged. 07-java-action-examples.mdl passes exec + mx check with the new example. Co-Authored-By: Claude Opus 5.5 --- ...a-action-loses-extra-code-and-imports.json | 1 + .claude/skills/mendix/java-actions/SKILL.md | 36 ++++- cmd/mxcli/lsp_completions_gen.go | 2 + cmd/mxcli/syntax/features_integration.go | 5 +- .../reference/microflow/create-java-action.md | 34 +++- docs/01-project/MDL_QUICK_REFERENCE.md | 1 + .../java-action-1702-extra-code-roundtrip.mdl | 48 ++++++ .../doctype-tests/07-java-action-examples.mdl | 18 +++ mdl/ast/ast_javaaction.go | 3 +- mdl/backend/java.go | 5 +- mdl/backend/mcp/unsupported_gen.go | 2 +- mdl/backend/mock/backend.go | 2 +- mdl/backend/mock/mock_java.go | 2 +- mdl/backend/modelsdk/java_write.go | 13 +- mdl/backend/modelsdk/unimplemented_gen.go | 2 +- mdl/executor/cmd_javaactions.go | 50 ++++-- .../cmd_javaactions_bare_enum_935_test.go | 2 +- .../cmd_javaactions_class_javadoc_test.go | 2 +- .../cmd_javaactions_export_level_test.go | 4 +- .../cmd_javaactions_expose_preserve_test.go | 2 +- .../cmd_javaactions_extra_code_test.go | 152 ++++++++++++++++++ .../cmd_javaactions_param_description_test.go | 2 +- .../describe_java_action_type_params_test.go | 2 +- mdl/grammar/MDLLexer.g4 | 4 + mdl/grammar/domains/MDLMicroflow.g4 | 9 +- mdl/grammar/domains/MDLSettings.g4 | 3 + mdl/visitor/visitor_javaaction.go | 35 ++++ ...visitor_javaaction_extra_code_1702_test.go | 83 ++++++++++ sdk/javaactions/classdoc_golden_test.go | 4 +- sdk/javaactions/mxbuild_golden_test.go | 4 +- sdk/javaactions/retain.go | 47 +++++- sdk/javaactions/retain_extra_test.go | 79 +++++++++ sdk/javaactions/retain_test.go | 12 +- 33 files changed, 612 insertions(+), 58 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-java-action-loses-extra-code-and-imports.json create mode 100644 mdl-examples/bug-tests/java-action-1702-extra-code-roundtrip.mdl create mode 100644 mdl/executor/cmd_javaactions_extra_code_test.go create mode 100644 mdl/visitor/visitor_javaaction_extra_code_1702_test.go create mode 100644 sdk/javaactions/retain_extra_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-java-action-loses-extra-code-and-imports.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-java-action-loses-extra-code-and-imports.json new file mode 100644 index 0000000000..934ba9a35b --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-describe-java-action-loses-extra-code-and-imports.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "`describe java action` prints only the body: a helper in the .java file's EXTRA CODE section and an import a developer added are missing. `create or modify` keeps both in place, but executing the describe output on a fresh project writes a body calling a helper that no longer exists — mxbuild fails with `cannot find symbol`. `create java action` had no clause for the section at all", "cause": "The grammar (`createJavaActionStatement`, mdl/grammar/domains/MDLMicroflow.g4) ended at `AS $$ … $$`, though the AST already had an ExtraCode field and the generator already wrote one; `describeJavaAction` (mdl/executor/cmd_javaactions.go) read only the USER CODE section. RetainSections treated \"\" as 'keep', so an empty section could not even be stated", "file": "grammar `javaActionExtraCode: EXTRA CODE DOLLAR_STRING` (new EXTRA/CODE tokens, both in the keyword rule); `mdl/visitor/visitor_javaaction.go` (`extractJavaExtraCode`, ExtraCodeSet); `mdl/executor/cmd_javaactions.go` (describe prints StatedImports at the top of the body and `extra code $$ … $$`; exec passes nil when the clause is absent); `sdk/javaactions/retain.go` (`RetainSections(..., extraCode *string)`, `StatedImports`); WriteJavaSourceFile takes `extraCode *string`; tests `sdk/javaactions/retain_extra_test.go`, `mdl/visitor/visitor_javaaction_extra_code_1702_test.go`, `mdl/executor/cmd_javaactions_extra_code_test.go`; fixture `mdl-examples/bug-tests/java-action-1702-extra-code-roundtrip.mdl`", "insight": "A describe round trip is lossless only if describe prints EVERYTHING the regeneration retains — here three sections, of which MDL spelled one; check the file banner ('Only the following code will be retained…') against the statement whenever a code action's describe is touched. Two non-obvious choices: (1) imports must round-trip in ORDER, because the generator appends what a list lacks rather than inserting it — so describe prints the shortest leading part of the stored list that regenerates it (StatedImports): nothing for mxbuild's own list, but the generated IContext/UserAction lines too when a hand-added import follows them; (2) the visitor must keep the extra code's indentation as written (only blank edge lines and the spaces beside an inline `$$` go), because describe prints the section exactly as RetainedSections reads it — dedenting would shift every in-place regeneration away from the describe → exec one. `extra code $$ $$` clears the section, so 'absent' needs its own value (a nil *string), not \"\". Measured on 11.13.0: the baseline replay failed the build with `cannot find symbol`; the fix's replay was byte-identical to the source file, built, and the build left it unchanged", "refs": ["ako/mxcli#1702", "mendixlabs/mxcli#1658", "ako/mxcli#705"], "test": "TestJavaAction_ExtraCodeClause, TestJavaAction_ExtraCodeOmittedOrEmpty, TestJavaAction_ExtraAndCodeStayNames, TestRetainSections_ExtraCodeClause, TestStatedImports, TestDescribeJavaAction_ExtraCodeAndImportsRoundTrip, TestCreateJavaAction_ExtraCodeClauseReachesTheWriter"} diff --git a/.claude/skills/mendix/java-actions/SKILL.md b/.claude/skills/mendix/java-actions/SKILL.md index 3d76addd4e..80377cc0f7 100644 --- a/.claude/skills/mendix/java-actions/SKILL.md +++ b/.claude/skills/mendix/java-actions/SKILL.md @@ -13,12 +13,12 @@ description: "Create and call custom Java actions — extending Mendix with serv | 28-39 | Reference files | | 40-48 | When to Use This Skill | | 49-55 | Overview | -| 56-355 | Part 2.5: Creating Java Actions in MDL | -| 356-549 | Part 3: Calling Java Actions from MDL | -| 550-563 | Validation Checklist | -| 564-574 | Common Errors | -| 575-582 | Related Documentation | -| 583-664 | Quick Reference | +| 56-385 | Part 2.5: Creating Java Actions in MDL | +| 386-579 | Part 3: Calling Java Actions from MDL | +| 580-593 | Validation Checklist | +| 594-604 | Common Errors | +| 605-612 | Related Documentation | +| 613-694 | Quick Reference | # Mendix Java Actions Skill @@ -107,6 +107,30 @@ writes it — so a rewrite followed by a build leaves the file unchanged (ako/mxcli#1700). A rewrite without one keeps the stored documentation, in the model and in the file. +### Helper Code and Imports + +A helper the body calls goes in the file's EXTRA CODE section, beside +`executeAction()`: `extra code $$ … $$` after the body. An `import` line in the +body goes to the file's import list. Studio Pro keeps both when it regenerates +the file, and so does `create or modify` without the clause — `extra code $$ $$` +clears the section. `describe java action` prints both, so its output rebuilds +the same file on another project (ako/mxcli#1702): + +```mdl +mdl 1; +create java action Module.Twice(N: integer not null) returns integer +as $$ +import java.util.Objects; + +return twice(Objects.requireNonNull(N)); +$$ +extra code $$ +private static long twice(long n) { + return n * 2L; +} +$$; +``` + ### Type Parameters (Generics) Type parameters let Java actions accept any entity type dynamically. Use `entity ` in a parameter type to declare the type parameter inline. That parameter becomes the **entity type selector** (receives the entity type name, e.g., `'Module.Entity'`). Bare `pEntity` parameters become **parameterized entity** params (receive entity instances, e.g., `$Variable`). diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index c21ba79730..f090315fee 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -484,6 +484,8 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "TABLES", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, {Label: "VIEWS", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, {Label: "EXPOSED", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, + {Label: "EXTRA", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, + {Label: "CODE", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, {Label: "PLATFORM", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, {Label: "PARAMETER", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, {Label: "PARAMETERS", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index 030867addb..2f0502c550 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -656,9 +656,10 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, Keywords: []string{ "create java action", "or modify java action", "type parameter", "entity parameter", "exposed as", "returns", "generics", "drop java action", + "extra code", "helper method", "import", }, - Syntax: "CREATE [OR MODIFY] JAVA ACTION Module.Name[] [FOLDER 'path'](\n [/** description */] Param: Type [NOT NULL],\n EntityType: ENTITY NOT NULL,\n Obj: pEntity\n) RETURNS ReturnType [AS $ReturnName]\n[EXPOSED AS 'Label' IN 'Category'\n [ICON 'icon.png'] [ICON DARK 'icon-dark.png']\n [IMAGE 'image.png'] [IMAGE DARK 'image-dark.png']]\n[NOT EXPOSED]\nAS $$\n// Java code — AS $$ ... $$ is mandatory, cannot be omitted\n$$;\n\nOR MODIFY: updates signature/body in-place, preserves UUID.\n\nEXPOSED AS puts the action in Studio Pro's toolbox. The icon is a 64x64 PNG\nand the image a 256x192 PNG, read from disk relative to the .mdl file's own directory.\nAn OMITTED clause preserves what is stored — including bitmaps set in Studio\nPro — so removing an entry is NOT EXPOSED, and clearing one bitmap is\nDROP ICON / DROP IMAGE [DARK].\n\nA parameter's description is a /** ... */ doc comment before it, as on an\nentity attribute; AS $ReturnName names the return value (the default name of\nthe variable a microflow call stores it in), as on a microflow. Leaving either\nout keeps what is stored; a new action gets no description and ReturnValueName.\n\nThe .java file is written as mxbuild writes it, so a build leaves it unchanged:\nthe action's own /** ... */ (before CREATE) is the class javadoc, and a rewrite\nwithout one keeps the stored documentation.\n\nType parameters: an ENTITY parameter declares T. A type parameter no\nENTITY parameter names is declared in the header, Module.Name; an\nunqualified type that is neither is refused (MDL-JA01), since it would be\nwritten as an entity with no module (CE1613).\n\nA bare Module.Name type is an entity or an enumeration, whichever the project\n(or an earlier statement of the script) has; ENUM Module.Name says enumeration\nexplicitly. A name that is neither is refused by check --references and exec,\nsince it would be written as an entity that does not exist (CE1613).\n\nRETURNS takes Boolean, Integer, Long, Decimal, String, DateTime, an enumeration,\nan entity, a list, a type parameter or Void; leaving it out returns Boolean.\nAny other type is refused (MDL-JA03) — StringTemplate(...) is a parameter\ntype only.", - Example: "mdl 1;\nCREATE JAVA ACTION Utils.FormatCurrency(\n /** The amount to format. */\n Amount: Decimal NOT NULL\n) RETURNS String AS $FormattedAmount\nEXPOSED AS 'Format Currency' IN 'Formatting'\n ICON 'assets/currency-64.png'\n IMAGE 'assets/currency-256.png'\nAS $$\nreturn String.format(\"%.2f\", Amount);\n$$;\n\n-- Generic entity validator with type parameter\nCREATE JAVA ACTION Utils.IsValid(\n EntityType: ENTITY NOT NULL,\n Obj: pEntity NOT NULL\n) RETURNS Boolean\nAS $$\nreturn Obj != null;\n$$;\n\n-- Type parameter with no entity selector: the argument's entity binds it\nCREATE JAVA ACTION Utils.Describe(\n Obj: T NOT NULL\n) RETURNS String\nAS $$\nreturn Obj.toString();\n$$;\n\n-- Idempotent update (preserves UUID)\nCREATE OR MODIFY JAVA ACTION Utils.FormatCurrency(\n Amount: Decimal NOT NULL,\n Decimals: Integer NOT NULL\n) RETURNS String\nAS $$\nreturn String.format(\"%.\" + Decimals + \"f\", Amount);\n$$;", + Syntax: "CREATE [OR MODIFY] JAVA ACTION Module.Name[] [FOLDER 'path'](\n [/** description */] Param: Type [NOT NULL],\n EntityType: ENTITY NOT NULL,\n Obj: pEntity\n) RETURNS ReturnType [AS $ReturnName]\n[EXPOSED AS 'Label' IN 'Category'\n [ICON 'icon.png'] [ICON DARK 'icon-dark.png']\n [IMAGE 'image.png'] [IMAGE DARK 'image-dark.png']]\n[NOT EXPOSED]\nAS $$\n// Java code — AS $$ ... $$ is mandatory, cannot be omitted\n$$\n[EXTRA CODE $$\n// helper methods, constants, nested classes beside executeAction()\n$$];\n\nOR MODIFY: updates signature/body in-place, preserves UUID.\n\nEXPOSED AS puts the action in Studio Pro's toolbox. The icon is a 64x64 PNG\nand the image a 256x192 PNG, read from disk relative to the .mdl file's own directory.\nAn OMITTED clause preserves what is stored — including bitmaps set in Studio\nPro — so removing an entry is NOT EXPOSED, and clearing one bitmap is\nDROP ICON / DROP IMAGE [DARK].\n\nA parameter's description is a /** ... */ doc comment before it, as on an\nentity attribute; AS $ReturnName names the return value (the default name of\nthe variable a microflow call stores it in), as on a microflow. Leaving either\nout keeps what is stored; a new action gets no description and ReturnValueName.\n\nThe .java file is written as mxbuild writes it, so a build leaves it unchanged:\nthe action's own /** ... */ (before CREATE) is the class javadoc, and a rewrite\nwithout one keeps the stored documentation.\n\nThe file has two more sections a regeneration keeps, and the statement states\nboth: an import line in the body goes to the import list, and EXTRA CODE $$ ... $$\nis the EXTRA CODE section (helpers the body calls). An OMITTED EXTRA CODE keeps\nthe stored section; EXTRA CODE $$ $$ clears it. DESCRIBE prints the imports a\ndeveloper added and the section, so its output rebuilds the same file\n(ako/mxcli#1702).\n\nType parameters: an ENTITY parameter declares T. A type parameter no\nENTITY parameter names is declared in the header, Module.Name; an\nunqualified type that is neither is refused (MDL-JA01), since it would be\nwritten as an entity with no module (CE1613).\n\nA bare Module.Name type is an entity or an enumeration, whichever the project\n(or an earlier statement of the script) has; ENUM Module.Name says enumeration\nexplicitly. A name that is neither is refused by check --references and exec,\nsince it would be written as an entity that does not exist (CE1613).\n\nRETURNS takes Boolean, Integer, Long, Decimal, String, DateTime, an enumeration,\nan entity, a list, a type parameter or Void; leaving it out returns Boolean.\nAny other type is refused (MDL-JA03) — StringTemplate(...) is a parameter\ntype only.", + Example: "mdl 1;\nCREATE JAVA ACTION Utils.FormatCurrency(\n /** The amount to format. */\n Amount: Decimal NOT NULL\n) RETURNS String AS $FormattedAmount\nEXPOSED AS 'Format Currency' IN 'Formatting'\n ICON 'assets/currency-64.png'\n IMAGE 'assets/currency-256.png'\nAS $$\nreturn String.format(\"%.2f\", Amount);\n$$;\n\n-- Generic entity validator with type parameter\nCREATE JAVA ACTION Utils.IsValid(\n EntityType: ENTITY NOT NULL,\n Obj: pEntity NOT NULL\n) RETURNS Boolean\nAS $$\nreturn Obj != null;\n$$;\n\n-- Type parameter with no entity selector: the argument's entity binds it\nCREATE JAVA ACTION Utils.Describe(\n Obj: T NOT NULL\n) RETURNS String\nAS $$\nreturn Obj.toString();\n$$;\n\n-- A helper in the EXTRA CODE section, and the import it needs\nCREATE JAVA ACTION Utils.Twice(N: Integer NOT NULL) RETURNS Integer\nAS $$\nimport java.util.Objects;\nreturn twice(Objects.requireNonNull(N));\n$$\nEXTRA CODE $$\nprivate static long twice(long n) {\n\treturn n * 2L;\n}\n$$;\n\n-- Idempotent update (preserves UUID)\nCREATE OR MODIFY JAVA ACTION Utils.FormatCurrency(\n Amount: Decimal NOT NULL,\n Decimals: Integer NOT NULL\n) RETURNS String\nAS $$\nreturn String.format(\"%.\" + Decimals + \"f\", Amount);\n$$;", SeeAlso: []string{"java-action", "javascript-action"}, }) diff --git a/docs-site/src/reference/microflow/create-java-action.md b/docs-site/src/reference/microflow/create-java-action.md index 2f1e655e63..214d7f01bf 100644 --- a/docs-site/src/reference/microflow/create-java-action.md +++ b/docs-site/src/reference/microflow/create-java-action.md @@ -7,6 +7,7 @@ CREATE [ OR MODIFY ] JAVA ACTION module.Name [ < T [, ...] > ] [ FOLDER 'folder_ RETURNS type [ AS $return_name ] [ EXPOSED AS 'caption' IN 'category' ] AS $$ java_code $$ + [ EXTRA CODE $$ helper_code $$ ] DROP JAVA ACTION module.Name; ``` @@ -49,6 +50,16 @@ documentation -- the `/** ... */` before `CREATE` -- is the class javadoc above and in the file. (Before ako/mxcli#1700 the file had no javadoc, so every rewrite removed the one a build had written and the next build put it back.) +Studio Pro keeps three parts of the file when it regenerates it, and the statement +states all three: the code between `BEGIN USER CODE` and `END USER CODE` is the +`AS $$ ... $$` body; an `import` line in the body goes to the file's import list; +and `EXTRA CODE $$ ... $$` is the section between `BEGIN EXTRA CODE` and +`END EXTRA CODE` -- helper methods, constants and nested classes the body calls. +On `CREATE OR MODIFY` an omitted `EXTRA CODE` keeps the stored section, as an +omitted `EXPOSED AS` keeps the toolbox entry; `EXTRA CODE $$ $$` clears it. +`DESCRIBE JAVA ACTION` prints the imports a developer added and the section, so +executing its output on another project rebuilds the same file (ako/mxcli#1702). + ### Exposed Actions The optional `EXPOSED AS` clause makes the action visible in the Studio Pro toolbox under the specified category, allowing modelers to use it directly in microflows. @@ -85,7 +96,10 @@ The optional `EXPOSED AS` clause makes the action visible in the Studio Pro tool : Optional. Makes the action visible in the toolbox with the given caption and category. `AS $$ java_code $$` -: The Java code body, enclosed in `$$` delimiters. **Mandatory** — the body cannot be omitted. +: The Java code body, enclosed in `$$` delimiters. **Mandatory** — the body cannot be omitted. An `import` line in it goes to the file's import list. + +`EXTRA CODE $$ helper_code $$` +: Optional. The file's EXTRA CODE section, beside `executeAction()`. Omitted, the stored section is kept; empty, it is cleared. `OR MODIFY` : Makes the statement idempotent. If the Java action already exists, updates its parameters, return type, exposed-as settings, and body. Without this clause, creating a duplicate Java action is an error. @@ -119,6 +133,24 @@ AS $$ $$; ``` +Java action with a helper in the EXTRA CODE section, and the import it needs: + +```sql +CREATE JAVA ACTION MyModule.JA_Twice ( + N: Integer NOT NULL +) RETURNS Integer +AS $$ + import java.util.Objects; + + return twice(Objects.requireNonNull(N)); +$$ +EXTRA CODE $$ +private static long twice(long n) { + return n * 2L; +} +$$; +``` + Java action with type parameters for generic entity handling: ```sql diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 785ab05c50..b202b07ba0 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1451,6 +1451,7 @@ Module.OrderResponse_CustomerInfo/Module.CustomerInfo as customer { | Describe Java action | `describe java action Module.Name;` | Full MDL output with signature | | Create Java action | `create [or modify] java action Module.Name [folder 'path'](params) returns type as $$ ... $$;` | OR MODIFY updates signature/body, preserves UUID. The `.java` file is written as mxbuild writes it, the action's `/** … */` documentation as the class javadoc (ako/mxcli#1700) | | Create with type params | `create java action Module.Name(EntityType: entity , Obj: pEntity) ...;` | Generic type parameters | +| Helper code and imports | `create java action Module.Name(N: Integer) returns Integer as $$ import java.util.Objects; return twice(N); $$ extra code $$ private static long twice(long n) { return n * 2L; } $$;` | `extra code $$ … $$` is the .java file's EXTRA CODE section; an `import` line in the body goes to the import list. Omitted on `or modify`, the stored section is kept; `extra code $$ $$` clears it. `describe` prints both, so its output rebuilds the same file (ako/mxcli#1702) | | Describe a parameter, name the return value | `create java action Module.Name(/** The text. */ Input: String) returns String as $Result as $$ ... $$;` | `/** … */` before a parameter is its description; `as $Name` the return value's name. Omitted on `or modify`, both keep what is stored | | Create exposed action | `... exposed as 'caption' in 'Category' as $$ ... $$;` | Toolbox-visible in Studio Pro | | Exposed action with bitmaps | `... exposed as 'caption' in 'Category' icon 'i.png' icon dark 'id.png' image 'm.png' image dark 'md.png' as $$ ... $$;` | Icon 64x64 PNG, image 256x192; paths relative to the .mdl file's own directory. A wrong size warns and is written; a non-PNG is refused | diff --git a/mdl-examples/bug-tests/java-action-1702-extra-code-roundtrip.mdl b/mdl-examples/bug-tests/java-action-1702-extra-code-roundtrip.mdl new file mode 100644 index 0000000000..ccde4992ca --- /dev/null +++ b/mdl-examples/bug-tests/java-action-1702-extra-code-roundtrip.mdl @@ -0,0 +1,48 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1702 (mendixlabs/mxcli#1658): a describe round trip loses the +-- Java action's EXTRA CODE section +-- ============================================================================ +-- +-- Symptom (before fix): +-- `create java action` had no clause for the .java file's EXTRA CODE +-- section, and `describe java action` printed only the body — not the +-- section, nor an import a developer added. `create or modify` kept both in +-- place, but executing the describe output on another project wrote a body +-- calling a helper that no longer existed, and the build failed +-- ("cannot find symbol"). +-- +-- After fix: +-- `extra code $$ … $$` states the section, an import line in the body goes +-- to the import list, and describe prints both — so describe → exec on a +-- fresh project rebuilds the same file. The second statement below has no +-- clause and keeps the section, as before. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/java-action-1702-extra-code-roundtrip.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe java action BugTest1702.JA_Twice" > twice.mdl +-- Expected: the body starts with the import list (… import java.util.Objects;) +-- and the statement ends with `extra code $$ private static long twice … $$;`. +-- Executing twice.mdl (after `create module BugTest1702;`) on a blank project +-- writes a byte-identical javasource/bugtest1702/actions/JA_Twice.java. +-- ============================================================================ + +create module BugTest1702; + +create java action BugTest1702.JA_Twice(N: Integer not null) returns Integer +as $$ +import java.util.Objects; + +return twice(Objects.requireNonNull(N)); +$$ +extra code $$ +private static long twice(long n) { + return n * 2L; +} +$$; + +-- No `extra code` clause: the stored section, and the helper, are kept. +create or modify java action BugTest1702.JA_Twice(N: Integer not null) returns Integer +as $$ +return twice(N) + 0L; +$$; diff --git a/mdl-examples/doctype-tests/07-java-action-examples.mdl b/mdl-examples/doctype-tests/07-java-action-examples.mdl index 032e137efe..18b05b0b4f 100644 --- a/mdl-examples/doctype-tests/07-java-action-examples.mdl +++ b/mdl-examples/doctype-tests/07-java-action-examples.mdl @@ -156,6 +156,24 @@ return InputText.toUpperCase(); $$; +/** + * Doubles a number. The helper lives in the .java file's EXTRA CODE section, + * beside executeAction(); `extra code $$ ... $$` states it, and an import line + * goes to the file's import list (ako/mxcli#1702). Leaving the clause out of a + * later `create or modify` keeps the section as it is. + */ +create java action JaTest.Twice(N: integer not null) returns integer +as $$ +import java.util.Objects; + +return twice(Objects.requireNonNull(N)); +$$ +extra code $$ +private static long twice(long n) { + return n * 2L; +} +$$; + /** Concatenates two strings with an optional separator. */ create java action JaTest.Concatenate(FirstPart: string not null, SecondPart: string not null, Separator: string) returns string as $$ diff --git a/mdl/ast/ast_javaaction.go b/mdl/ast/ast_javaaction.go index 162e43e640..42c8cc43bf 100644 --- a/mdl/ast/ast_javaaction.go +++ b/mdl/ast/ast_javaaction.go @@ -34,7 +34,8 @@ type CreateJavaActionStmt struct { ReturnType DataType // Return type (can be nil for void) ReturnName string // `returns T as $Name`: the return value's name; "" keeps the stored one (mendixlabs/mxcli#1588) JavaCode string // The executeAction() body - ExtraCode string // Optional extra code section + ExtraCode string // `extra code $$ … $$`: the EXTRA CODE section + ExtraCodeSet bool // the clause was written: absent keeps the stored section, empty clears it (ako/mxcli#1702) Imports []string // Optional additional imports Documentation string // Optional documentation comment DocumentationSet bool // see mendixlabs/mxcli#1018: absent preserves, empty clears diff --git a/mdl/backend/java.go b/mdl/backend/java.go index b11e9dc354..49f77c192e 100644 --- a/mdl/backend/java.go +++ b/mdl/backend/java.go @@ -21,8 +21,9 @@ type JavaBackend interface { UpdateJavaAction(ja *javaactions.JavaAction) error DeleteJavaAction(id model.ID) error // WriteJavaSourceFile writes javasource//actions/.java as - // mxbuild generates it, documentation as the class javadoc. - WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string, documentation string) error + // mxbuild generates it, documentation as the class javadoc. extraCode nil + // keeps the file's EXTRA CODE section; anything else replaces it. + WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode *string, documentation string) error DeleteJavaSourceFile(moduleName, actionName string) error RenameJavaSourceFile(moduleName, oldName, newName string) error ReadJavaSourceFile(moduleName, actionName string) (string, error) diff --git a/mdl/backend/mcp/unsupported_gen.go b/mdl/backend/mcp/unsupported_gen.go index 8f34e838b0..cb76a41b55 100644 --- a/mdl/backend/mcp/unsupported_gen.go +++ b/mdl/backend/mcp/unsupported_gen.go @@ -1450,7 +1450,7 @@ func (unsupportedBackend) WriteJavaScriptSourceFile(_ string, _ string, _ string return } -func (unsupportedBackend) WriteJavaSourceFile(_ string, _ string, _ string, _ []*types.JavaActionParameter, _ types.CodeActionReturnType, _ []string, _ string, _ string) (err0 error) { +func (unsupportedBackend) WriteJavaSourceFile(_ string, _ string, _ string, _ []*types.JavaActionParameter, _ types.CodeActionReturnType, _ []string, _ *string, _ string) (err0 error) { err0 = errUnsupported("WriteJavaSourceFile") return } diff --git a/mdl/backend/mock/backend.go b/mdl/backend/mock/backend.go index cc04eeefc6..05f6a71098 100644 --- a/mdl/backend/mock/backend.go +++ b/mdl/backend/mock/backend.go @@ -260,7 +260,7 @@ type MockBackend struct { CreateJavaActionFunc func(ja *javaactions.JavaAction) error UpdateJavaActionFunc func(ja *javaactions.JavaAction) error DeleteJavaActionFunc func(id model.ID) error - WriteJavaSourceFileFunc func(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string, documentation string) error + WriteJavaSourceFileFunc func(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode *string, documentation string) error DeleteJavaSourceFileFunc func(moduleName, actionName string) error RenameJavaSourceFileFunc func(moduleName, oldName, newName string) error ReadJavaSourceFileFunc func(moduleName, actionName string) (string, error) diff --git a/mdl/backend/mock/mock_java.go b/mdl/backend/mock/mock_java.go index 17d2479ecf..63eca805c4 100644 --- a/mdl/backend/mock/mock_java.go +++ b/mdl/backend/mock/mock_java.go @@ -64,7 +64,7 @@ func (m *MockBackend) DeleteJavaAction(id model.ID) error { return nil } -func (m *MockBackend) WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string, documentation string) error { +func (m *MockBackend) WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode *string, documentation string) error { if m.WriteJavaSourceFileFunc != nil { return m.WriteJavaSourceFileFunc(moduleName, actionName, javaCode, params, returnType, extraImports, extraCode, documentation) } diff --git a/mdl/backend/modelsdk/java_write.go b/mdl/backend/modelsdk/java_write.go index c649a4ee7d..b28b9fe07f 100644 --- a/mdl/backend/modelsdk/java_write.go +++ b/mdl/backend/modelsdk/java_write.go @@ -93,7 +93,7 @@ func (b *Backend) UpdateJavaAction(ja *javaactions.JavaAction) error { // javasource//actions/.java, using the shared generator so the // output is byte-identical to what mxbuild generates — the action's // documentation as the class javadoc included (ako/mxcli#1700). -func (b *Backend) WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode string, documentation string) error { +func (b *Backend) WriteJavaSourceFile(moduleName, actionName string, javaCode string, params []*javaactions.JavaActionParameter, returnType javaactions.CodeActionReturnType, extraImports []string, extraCode *string, documentation string) error { if b.path == "" { return fmt.Errorf("WriteJavaSourceFile: no project path") } @@ -103,12 +103,13 @@ func (b *Backend) WriteJavaSourceFile(moduleName, actionName string, javaCode st } path := filepath.Join(javaDir, actionName+".java") // Regenerate the way Studio Pro does: the existing file's import list and - // EXTRA CODE section are retained, since MDL cannot express either. Keeping - // only the user code turned an action that calls a helper in its extra code - // into Java that does not compile (ako/mxcli#705). + // EXTRA CODE section are retained — the section unless the statement states + // it (extraCode non-nil, ako/mxcli#1702). Keeping only the user code turned + // an action that calls a helper in its extra code into Java that does not + // compile (ako/mxcli#705). existing, _ := os.ReadFile(path) - extraImports, extraCode = javaactions.RetainSections(string(existing), extraImports, extraCode) - source := javaactions.GenerateSource(moduleName, actionName, javaCode, params, returnType, extraImports, extraCode, documentation) + extraImports, extra := javaactions.RetainSections(string(existing), extraImports, extraCode) + source := javaactions.GenerateSource(moduleName, actionName, javaCode, params, returnType, extraImports, extra, documentation) changed, err := javaactions.WriteSourceIfChanged(path, source) b.noteFileWrite(changed) if err != nil { diff --git a/mdl/backend/modelsdk/unimplemented_gen.go b/mdl/backend/modelsdk/unimplemented_gen.go index 48a2787539..01241eb571 100644 --- a/mdl/backend/modelsdk/unimplemented_gen.go +++ b/mdl/backend/modelsdk/unimplemented_gen.go @@ -1292,7 +1292,7 @@ func (unimplemented) WriteJavaScriptSourceFile(_ string, _ string, _ string, _ [ return errUnimplemented("WriteJavaScriptSourceFile") } -func (unimplemented) WriteJavaSourceFile(_ string, _ string, _ string, _ []*types.JavaActionParameter, _ types.CodeActionReturnType, _ []string, _ string, _ string) error { +func (unimplemented) WriteJavaSourceFile(_ string, _ string, _ string, _ []*types.JavaActionParameter, _ types.CodeActionReturnType, _ []string, _ *string, _ string) error { return errUnimplemented("WriteJavaSourceFile") } diff --git a/mdl/executor/cmd_javaactions.go b/mdl/executor/cmd_javaactions.go index 0db667c2c4..a5d2a77bf9 100644 --- a/mdl/executor/cmd_javaactions.go +++ b/mdl/executor/cmd_javaactions.go @@ -120,14 +120,32 @@ func describeJavaAction(ctx *ExecContext, name ast.QualifiedName) error { // The grammar requires an `as $$ ... $$` body for CREATE JAVA ACTION, so always // emit one. When the .java source can't be read (e.g. add-on modules ship without // source on disk), emit a placeholder body so DESCRIBE output still re-parses (#637). - javaCode := readJavaActionUserCode(ctx.MprPath, name.Module, name.Name) + // + // Everything else a regeneration keeps is printed too, so that executing the + // output rebuilds the same file (ako/mxcli#1702): the imports a developer + // added, at the top of the body — exec moves an import line to the import + // list — and the EXTRA CODE section as `extra code $$ … $$`. Without them a + // describe run on another project wrote a body calling a helper that no + // longer existed. + source := readJavaActionSource(ctx.MprPath, name.Module, name.Name) + javaCode := javaActionUserCode(source) sb.WriteString("\nas $$\n") if javaCode != "" { + if imports := javaactions.StatedImports(source, ja.Parameters, ja.ReturnType); len(imports) > 0 { + sb.WriteString(strings.Join(imports, "\n")) + sb.WriteString("\n\n") + } sb.WriteString(javaCode) } else { sb.WriteString(javaSourceOmittedBody) } - sb.WriteString("\n$$;") + sb.WriteString("\n$$") + if _, extra := javaactions.RetainedSections(source); javaCode != "" && extra != "" { + sb.WriteString("\nextra code $$\n") + sb.WriteString(extra) + sb.WriteString("\n$$") + } + sb.WriteString(";") // Output the complete statement. A shape MDL has no form for is printed // commented out: a statement that parses into something else is worse than @@ -225,27 +243,31 @@ const javaSourceOmittedBody = "// Java source not available from this project; b // readJavaActionUserCode reads the Java source file and extracts the user code section. func readJavaActionUserCode(mprPath, moduleName, actionName string) string { + return javaActionUserCode(readJavaActionSource(mprPath, moduleName, actionName)) +} + +// readJavaActionSource reads an action's .java file with LF line endings (Studio +// Pro and mxbuild write CRLF), or "" when it cannot. +func readJavaActionSource(mprPath, moduleName, actionName string) string { if mprPath == "" { return "" } - - // Build path to Java source file - projectRoot := filepath.Dir(mprPath) - moduleNameLower := strings.ToLower(moduleName) - javaPath := filepath.Join(projectRoot, "javasource", moduleNameLower, "actions", actionName+".java") - - // Read the file + javaPath := filepath.Join(filepath.Dir(mprPath), "javasource", strings.ToLower(moduleName), "actions", actionName+".java") content, err := os.ReadFile(javaPath) if err != nil { return "" } + return strings.ReplaceAll(string(content), "\r\n", "\n") +} +// javaActionUserCode extracts the user code section of an action's source. +func javaActionUserCode(source string) string { // Extract user code between the BEGIN USER CODE and END USER CODE markers. // Matched case-insensitively, as for JavaScript actions: a sweep that // lowercased MDL keywords in output also lowercased these literals, so no // Studio Pro file matched, DESCRIBE always printed the placeholder body, and // re-executing that output replaced the action's code with it (#705). - userCode, ok := sliceBetweenFold(string(content), "// BEGIN USER CODE", "// END USER CODE") + userCode, ok := sliceBetweenFold(source, "// BEGIN USER CODE", "// END USER CODE") if !ok { return "" } @@ -694,7 +716,13 @@ func execCreateJavaAction(ctx *ExecContext, s *ast.CreateJavaActionStmt) error { // comment carries the stored one, and mxbuild writes it as the class // javadoc — leaving it out removed the block a build had written, and // the next build put it back (ako/mxcli#1700). - if err := ctx.Backend.WriteJavaSourceFile(moduleName, s.Name.Name, s.JavaCode, ja.Parameters, ja.ReturnType, s.Imports, s.ExtraCode, ja.Documentation); err != nil { + // `extra code $$ … $$` states the EXTRA CODE section; without it the + // file's section is kept (ako/mxcli#1702). + var extraCode *string + if s.ExtraCodeSet { + extraCode = &s.ExtraCode + } + if err := ctx.Backend.WriteJavaSourceFile(moduleName, s.Name.Name, s.JavaCode, ja.Parameters, ja.ReturnType, s.Imports, extraCode, ja.Documentation); err != nil { return mdlerrors.NewBackend("write java source file", err) } } diff --git a/mdl/executor/cmd_javaactions_bare_enum_935_test.go b/mdl/executor/cmd_javaactions_bare_enum_935_test.go index 3961357c9d..b1c544adfc 100644 --- a/mdl/executor/cmd_javaactions_bare_enum_935_test.go +++ b/mdl/executor/cmd_javaactions_bare_enum_935_test.go @@ -58,7 +58,7 @@ func newCodeActionProject() *codeActionProject { return nil }, WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string, string) error { + javaactions.CodeActionReturnType, []string, *string, string) error { return nil }, WriteJavaScriptSourceFileFunc: func(string, string, string, []*types.JavaActionParameter, types.CodeActionReturnType) error { diff --git a/mdl/executor/cmd_javaactions_class_javadoc_test.go b/mdl/executor/cmd_javaactions_class_javadoc_test.go index 186d89d7cb..016e5a3099 100644 --- a/mdl/executor/cmd_javaactions_class_javadoc_test.go +++ b/mdl/executor/cmd_javaactions_class_javadoc_test.go @@ -45,7 +45,7 @@ func writtenJavaDoc(t *testing.T, s *ast.CreateJavaActionStmt, stored *javaactio CreateJavaActionFunc: func(*javaactions.JavaAction) error { return nil }, UpdateJavaActionFunc: func(*javaactions.JavaAction) error { return nil }, WriteJavaSourceFileFunc: func(_, _, _ string, _ []*javaactions.JavaActionParameter, - _ javaactions.CodeActionReturnType, _ []string, _ string, documentation string) error { + _ javaactions.CodeActionReturnType, _ []string, _ *string, documentation string) error { written, called = documentation, true return nil }, diff --git a/mdl/executor/cmd_javaactions_export_level_test.go b/mdl/executor/cmd_javaactions_export_level_test.go index 7142c9eccf..3ea4437e2f 100644 --- a/mdl/executor/cmd_javaactions_export_level_test.go +++ b/mdl/executor/cmd_javaactions_export_level_test.go @@ -58,7 +58,7 @@ func TestCreateJavaActionWritesStudioProDefaults(t *testing.T) { return nil }, WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string, string) error { + javaactions.CodeActionReturnType, []string, *string, string) error { return nil }, } @@ -160,7 +160,7 @@ func TestJavaActionBody_PlaceholderIsNotWritten(t *testing.T) { ctx, _ := exposeRewriteCtx(t, stored, mod) wrote := false ctx.Backend.(*mock.MockBackend).WriteJavaSourceFileFunc = func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string, string) error { + javaactions.CodeActionReturnType, []string, *string, string) error { wrote = true return nil } diff --git a/mdl/executor/cmd_javaactions_expose_preserve_test.go b/mdl/executor/cmd_javaactions_expose_preserve_test.go index f179dd4a7b..1a34d56dc6 100644 --- a/mdl/executor/cmd_javaactions_expose_preserve_test.go +++ b/mdl/executor/cmd_javaactions_expose_preserve_test.go @@ -71,7 +71,7 @@ func exposeRewriteCtx(t *testing.T, stored *javaactions.JavaAction, mod *model.M return nil }, WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string, string) error { + javaactions.CodeActionReturnType, []string, *string, string) error { return nil }, } diff --git a/mdl/executor/cmd_javaactions_extra_code_test.go b/mdl/executor/cmd_javaactions_extra_code_test.go new file mode 100644 index 0000000000..e76c3c161a --- /dev/null +++ b/mdl/executor/cmd_javaactions_extra_code_test.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/javaactions" +) + +// ako/mxcli#1702 (mendixlabs/mxcli#1658): `describe java action` printed only +// the body. A helper in the .java file's EXTRA CODE section, and the import it +// needs, were kept in place by `create or modify` — but executing the describe +// output on another project wrote a body calling a helper that no longer +// existed, and the project stopped compiling. describe now prints the imports a +// developer added at the top of the body and the section as `extra code $$ … $$`, +// so describe → exec rebuilds the same file. + +const twiceHelper = "private static long twice(long n) {\n\treturn Objects.requireNonNull(n) * 2L;\n}" + +func twiceAction(mod *model.Module) *javaactions.JavaAction { + ja := &javaactions.JavaAction{ + ContainerID: mod.ID, + Name: "JA_Twice", + ReturnType: &javaactions.IntegerType{}, + Parameters: []*javaactions.JavaActionParameter{ + {Name: "N", IsRequired: true, ParameterType: &javaactions.IntegerType{}}, + }, + } + ja.ID = nextID("ja") + return ja +} + +// writeTwiceSource writes the file of the report: generated by mxbuild, then a +// helper and an import added by hand. +func writeTwiceSource(t *testing.T, ja *javaactions.JavaAction) (mprPath, source string) { + t.Helper() + dir := t.TempDir() + mprPath = filepath.Join(dir, "App.mpr") + source = javaactions.GenerateSource("ModJ", "JA_Twice", "return twice(N);", ja.Parameters, ja.ReturnType, + []string{ + "import com.mendix.systemwideinterfaces.core.IContext;", + "import com.mendix.systemwideinterfaces.core.UserAction;", + "import java.util.Objects;", + }, twiceHelper, "") + javaDir := filepath.Join(dir, "javasource", "modj", "actions") + if err := os.MkdirAll(javaDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(javaDir, "JA_Twice.java"), []byte(source), 0o644); err != nil { + t.Fatal(err) + } + return mprPath, source +} + +type writtenSource struct { + code string + params []*javaactions.JavaActionParameter + ret javaactions.CodeActionReturnType + imports []string + extraCode *string + doc string +} + +// execJavaActionOnFreshProject runs s against a project that has module ModJ +// and no action, and returns what it handed WriteJavaSourceFile. +func execJavaActionOnFreshProject(t *testing.T, s *ast.CreateJavaActionStmt) writtenSource { + t.Helper() + mod := mkModule("ModJ") + var w *writtenSource + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListJavaActionsFunc: func() ([]*types.JavaAction, error) { return nil, nil }, + CreateJavaActionFunc: func(*javaactions.JavaAction) error { return nil }, + WriteJavaSourceFileFunc: func(_, _, code string, params []*javaactions.JavaActionParameter, + ret javaactions.CodeActionReturnType, imports []string, extraCode *string, doc string) error { + w = &writtenSource{code, params, ret, imports, extraCode, doc} + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + assertNoError(t, execCreateJavaAction(ctx, s)) + if w == nil { + t.Fatal("WriteJavaSourceFile was never called") + } + return *w +} + +func TestDescribeJavaAction_ExtraCodeAndImportsRoundTrip(t *testing.T) { + mod := mkModule("ModJ") + ja := twiceAction(mod) + mprPath, source := writeTwiceSource(t, ja) + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ReadJavaActionByNameFunc: func(string) (*javaactions.JavaAction, error) { return ja, nil }, + } + ctx, buf := newMockCtx(t, withBackend(mb), withMprPath(mprPath)) + assertNoError(t, describeJavaAction(ctx, ast.QualifiedName{Module: "ModJ", Name: "JA_Twice"})) + out := buf.String() + for _, want := range []string{"import java.util.Objects;", "\nextra code $$\n" + twiceHelper + "\n$$;"} { + if !strings.Contains(out, want) { + t.Errorf("describe output lacks %q:\n%s", want, out) + } + } + + // Executed on a project without the file, the output rebuilds it byte for + // byte — what the backend does with what exec hands it. + s := findStmt[*ast.CreateJavaActionStmt](t, reparse(t, out), out) + w := execJavaActionOnFreshProject(t, s) + imports, extra := javaactions.RetainSections("", w.imports, w.extraCode) + got := javaactions.GenerateSource("ModJ", "JA_Twice", w.code, w.params, w.ret, imports, extra, w.doc) + if got != source { + t.Errorf("describe → exec on a fresh project wrote another file:\n--- want\n%s\n--- got\n%s", source, got) + } +} + +// An omitted clause still keeps the stored section: exec says nothing about it +// (nil), and the backend then retains what the file holds. A stated one — even +// empty — replaces it. +func TestCreateJavaAction_ExtraCodeClauseReachesTheWriter(t *testing.T) { + for _, c := range []struct { + name, clause string + want *string + }{ + {"omitted keeps the stored section", "", nil}, + {"stated", " extra code $$ int x; $$", strPtrExec("int x;")}, + {"stated empty clears it", " extra code $$ $$", strPtrExec("")}, + } { + t.Run(c.name, func(t *testing.T) { + s := parseOne[*ast.CreateJavaActionStmt](t, + "create java action ModJ.JA_Twice(N: Integer not null) returns Integer as $$ return N; $$"+c.clause+";") + w := execJavaActionOnFreshProject(t, s) + switch { + case c.want == nil && w.extraCode != nil: + t.Errorf("extraCode = %q, want nil — an omitted clause must keep the stored section", *w.extraCode) + case c.want != nil && (w.extraCode == nil || *w.extraCode != *c.want): + t.Errorf("extraCode = %v, want %q", w.extraCode, *c.want) + } + }) + } +} + +func strPtrExec(s string) *string { return &s } diff --git a/mdl/executor/cmd_javaactions_param_description_test.go b/mdl/executor/cmd_javaactions_param_description_test.go index b292cb3322..b9aabc9a94 100644 --- a/mdl/executor/cmd_javaactions_param_description_test.go +++ b/mdl/executor/cmd_javaactions_param_description_test.go @@ -63,7 +63,7 @@ func TestCreateJavaAction_WritesStatedParameterDescriptionAndReturnName(t *testi return nil }, WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string, string) error { + javaactions.CodeActionReturnType, []string, *string, string) error { return nil }, } diff --git a/mdl/executor/describe_java_action_type_params_test.go b/mdl/executor/describe_java_action_type_params_test.go index cd1bfe30a5..7c2894883f 100644 --- a/mdl/executor/describe_java_action_type_params_test.go +++ b/mdl/executor/describe_java_action_type_params_test.go @@ -50,7 +50,7 @@ func createJACtx(t *testing.T) (*ExecContext, **javaactions.JavaAction) { return nil }, WriteJavaSourceFileFunc: func(string, string, string, []*javaactions.JavaActionParameter, - javaactions.CodeActionReturnType, []string, string, string) error { + javaactions.CodeActionReturnType, []string, *string, string) error { return nil }, } diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index 97b9483a11..d384aab100 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -719,6 +719,10 @@ EXEC: E X E C; TABLES: T A B L E S; VIEWS: V I E W S; EXPOSED: E X P O S E D; +// `extra code $$ … $$`: a Java action's EXTRA CODE section (ako/mxcli#1702). +// Both stay names (keyword rule): `Code` is a common attribute. +EXTRA: E X T R A; +CODE: C O D E; PLATFORM: P L A T F O R M; PARAMETER: P A R A M E T E R; PARAMETERS: P A R A M E T E R S; diff --git a/mdl/grammar/domains/MDLMicroflow.g4 b/mdl/grammar/domains/MDLMicroflow.g4 index a2a4371000..63484356ef 100644 --- a/mdl/grammar/domains/MDLMicroflow.g4 +++ b/mdl/grammar/domains/MDLMicroflow.g4 @@ -65,7 +65,14 @@ createJavaActionStatement LPAREN javaActionParameterList? RPAREN javaActionReturnType? javaActionExposedClause? - AS DOLLAR_STRING SEMICOLON? + AS DOLLAR_STRING javaActionExtraCode? SEMICOLON? + ; + +/** The .java file's EXTRA CODE section — helper methods, constants and nested + * classes beside executeAction(), which a regeneration keeps. Omitted, the + * stored section is kept; `extra code $$ $$` clears it (ako/mxcli#1702). */ +javaActionExtraCode + : EXTRA CODE DOLLAR_STRING ; javaActionParameterList diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index db0da34b8e..6173e7bd8c 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -781,6 +781,9 @@ keyword // OData / REST / API | API | SAMPLE | BASE | BODY | CHANNELS | CLIENT | CLIENTS | CONTRACT | OPENAPI | DEPRECATED | EXPOSE | EXPOSED | EXTERNAL | HEADERS | JSON + // A Java action's `extra code $$ … $$`. CODE has to stay a name: `Code` is + // one of the most common attribute names there is. + | EXTRA | CODE | MAP | MAPPING | MAPPINGS | MESSAGES | METHOD | NAMESPACE_KW | NOT_SUPPORTED | ODATA | OAUTH | OPERATION | PAGING | PARAMETER | PARAMETERS | PATH | PLATFORM | PUBLISH | PUBLISHED diff --git a/mdl/visitor/visitor_javaaction.go b/mdl/visitor/visitor_javaaction.go index 5a080bda2c..7b569576b7 100644 --- a/mdl/visitor/visitor_javaaction.go +++ b/mdl/visitor/visitor_javaaction.go @@ -80,6 +80,17 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat stmt.JavaCode, stmt.Imports = extractJavaImports(code) } + // `extra code $$ … $$`: the file's EXTRA CODE section (ako/mxcli#1702). + // Kept as written apart from the blank lines around it — describe prints + // the section as the file holds it, so trimming indentation would shift + // every round trip. An import line goes to the import list, as in the body. + if ec := ctx.JavaActionExtraCode(); ec != nil && ec.DOLLAR_STRING() != nil { + var imports []string + stmt.ExtraCode, imports = extractJavaExtraCode(ec.DOLLAR_STRING().GetText()) + stmt.Imports = append(stmt.Imports, imports...) + stmt.ExtraCodeSet = true + } + // Check for documentation comment and OR MODIFY/REPLACE from parent createStatement if parent, ok := ctx.GetParent().(*parser.CreateStatementContext); ok { if docComment := parent.DocComment(); docComment != nil { @@ -249,6 +260,30 @@ func extractJavaImports(code string) (body string, imports []string) { return strings.TrimSpace(strings.Join(bodyLines, "\n")), imports } +// extractJavaExtraCode returns the text of an `extra code $$ … $$` clause, its +// import lines taken out, with the blank lines at either end dropped and every +// other line as written — apart from the spaces between a `$$` and text on its +// own line, which are not indentation. +func extractJavaExtraCode(dollar string) (code string, imports []string) { + dollar = strings.TrimSuffix(strings.TrimPrefix(dollar, "$$"), "$$") + dollar = strings.TrimRight(strings.TrimLeft(dollar, " \t"), " \t") + var lines []string + for _, line := range strings.Split(strings.ReplaceAll(dollar, "\r\n", "\n"), "\n") { + if t := strings.TrimSpace(line); strings.HasPrefix(t, "import ") && strings.HasSuffix(t, ";") { + imports = append(imports, t) + continue + } + lines = append(lines, line) + } + for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { + lines = lines[1:] + } + for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines = lines[:len(lines)-1] + } + return strings.Join(lines, "\n"), imports +} + // buildJavaActionParams reads a Java or JavaScript action's parameter list — // one helper for both, since they share the grammar rule and a field read by // one handler only is a silent drop in the other. diff --git a/mdl/visitor/visitor_javaaction_extra_code_1702_test.go b/mdl/visitor/visitor_javaaction_extra_code_1702_test.go new file mode 100644 index 0000000000..0a9e1afccf --- /dev/null +++ b/mdl/visitor/visitor_javaaction_extra_code_1702_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#1702 (mendixlabs/mxcli#1658): `create java action` had no clause +// for the .java file's EXTRA CODE section. `extra code $$ … $$` after the body +// states it; omitted, the stored section is kept. + +func parseJavaAction(t *testing.T, src string) *ast.CreateJavaActionStmt { + t.Helper() + prog, errs := Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs, src) + } + stmt, ok := prog.Statements[0].(*ast.CreateJavaActionStmt) + if !ok { + t.Fatalf("got %T, want *ast.CreateJavaActionStmt", prog.Statements[0]) + } + return stmt +} + +func TestJavaAction_ExtraCodeClause(t *testing.T) { + s := parseJavaAction(t, `create or modify java action ModJ.JA_Twice(N: Integer not null) returns Integer +as $$ +import java.util.Objects; + +return twice(N); +$$ +extra code $$ + + // indentation is kept as written + private static long twice(long n) { + return Objects.requireNonNull(n) * 2L; + } +import java.util.List; + +$$;`) + if !s.ExtraCodeSet { + t.Fatal("ExtraCodeSet = false, want true") + } + want := " // indentation is kept as written\n\tprivate static long twice(long n) {\n" + + "\t\treturn Objects.requireNonNull(n) * 2L;\n\t}" + if s.ExtraCode != want { + t.Errorf("ExtraCode = %q, want %q", s.ExtraCode, want) + } + if s.JavaCode != "return twice(N);" { + t.Errorf("JavaCode = %q", s.JavaCode) + } + // An import line belongs to the file's import list wherever it is written. + if want := []string{"import java.util.Objects;", "import java.util.List;"}; !reflect.DeepEqual(s.Imports, want) { + t.Errorf("Imports = %q, want %q", s.Imports, want) + } +} + +func TestJavaAction_ExtraCodeOmittedOrEmpty(t *testing.T) { + s := parseJavaAction(t, "create java action M.A() returns Boolean as $$ return true; $$;") + if s.ExtraCodeSet || s.ExtraCode != "" { + t.Errorf("omitted clause: ExtraCodeSet = %v, ExtraCode = %q — want unset, so the stored section is kept", s.ExtraCodeSet, s.ExtraCode) + } + s = parseJavaAction(t, "create java action M.A() returns Boolean as $$ return true; $$ extra code $$ $$;") + if !s.ExtraCodeSet || s.ExtraCode != "" { + t.Errorf("empty clause: ExtraCodeSet = %v, ExtraCode = %q — want set and empty, which clears the section", s.ExtraCodeSet, s.ExtraCode) + } +} + +// CODE and EXTRA are new keywords; both stay usable as names. +func TestJavaAction_ExtraAndCodeStayNames(t *testing.T) { + src := `create persistent entity M.Product (Code: String(20), Extra: Integer); +create microflow M.F ($Product: M.Product) returns String +begin + return $Product/Code; +end;` + if _, errs := Build(src); len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } +} diff --git a/sdk/javaactions/classdoc_golden_test.go b/sdk/javaactions/classdoc_golden_test.go index 6d7a58be9c..11371393bd 100644 --- a/sdk/javaactions/classdoc_golden_test.go +++ b/sdk/javaactions/classdoc_golden_test.go @@ -63,7 +63,7 @@ func TestGenerateSource_ClassJavadocMatchesMxbuild(t *testing.T) { if userCode == "" { existing, userCode = "", stub } - imports, extra := RetainSections(existing, nil, "") + imports, extra := RetainSections(existing, nil, nil) got := GenerateSource("MyFirstModule", c.name, userCode, c.params, &StringType{}, imports, extra, c.doc) if c.userCode == "" { // mxcli pads the user code with a blank line on each side. @@ -82,7 +82,7 @@ func TestGenerateSource_ClassJavadocMatchesMxbuild(t *testing.T) { // is the model's, not one of the sections a regeneration retains. func TestGenerateSource_ClassJavadocIsRegeneratedNotRetained(t *testing.T) { golden := readGolden(t, "JA_Doc2") - imports, extra := RetainSections(golden, nil, "") + imports, extra := RetainSections(golden, nil, nil) got := GenerateSource("MyFirstModule", "JA_Doc2", "return null;", nil, &StringType{}, imports, extra, "Other.") if n := strings.Count(got, "/**\r\n * "); n != 1 || !strings.Contains(got, "/**\r\n * Other.\r\n */\r\npublic class JA_Doc2") { t.Errorf("want one block holding the new documentation (%d found):\n%s", n, got) diff --git a/sdk/javaactions/mxbuild_golden_test.go b/sdk/javaactions/mxbuild_golden_test.go index 4046afd43d..039d219c50 100644 --- a/sdk/javaactions/mxbuild_golden_test.go +++ b/sdk/javaactions/mxbuild_golden_test.go @@ -108,7 +108,7 @@ func TestGenerateSource_RegeneratesWhatMxbuildWrote(t *testing.T) { } t.Run(c.name, func(t *testing.T) { golden := readGolden(t, c.name) - imports, extra := RetainSections(golden, nil, "") + imports, extra := RetainSections(golden, nil, nil) got := GenerateSource("MyFirstModule", c.name, c.userCode, c.params, c.ret, imports, extra, "") if got != golden { t.Errorf("generated source differs from mxbuild's:\n%s", lineDiff(golden, got)) @@ -128,7 +128,7 @@ func TestGenerateSource_NewFileMatchesMxbuild(t *testing.T) { } t.Run(c.name, func(t *testing.T) { golden := readGolden(t, c.name) - imports, extra := RetainSections("", nil, "") + imports, extra := RetainSections("", nil, nil) got := GenerateSource("MyFirstModule", c.name, stub, c.params, c.ret, imports, extra, "") // mxcli pads the user code with a blank line on each side. got = strings.Replace(got, "// BEGIN USER CODE\r\n\r\n", "// BEGIN USER CODE\r\n", 1) diff --git a/sdk/javaactions/retain.go b/sdk/javaactions/retain.go index 9329a4b7d2..a50de257e6 100644 --- a/sdk/javaactions/retain.go +++ b/sdk/javaactions/retain.go @@ -51,15 +51,17 @@ func importList(source string) []string { // retains, the way mxbuild regenerates an action: the stored import list is kept // as it stands — order included, and the imports the generator needs wherever // they sit — with the statement's new imports added after it, and the stored -// extra code is kept unless the statement supplies its own. existing is "" when -// there is no source file yet. GenerateSource appends whatever the generated -// code needs that the list still lacks. +// extra code is kept unless the statement supplies its own: extraCode nil keeps +// it, anything else replaces it, "" included (`extra code $$ $$` clears the +// section, ako/mxcli#1702). existing is "" when there is no source file yet. +// GenerateSource appends whatever the generated code needs that the list still +// lacks. // // Before this, a rewrite kept only the user code, so an action whose user code // calls a helper in its EXTRA CODE section — FeedbackModule.XSS_Sanitizer in the // Blank template — was regenerated into Java that does not compile // (ako/mxcli#705). -func RetainSections(existing string, imports []string, extraCode string) ([]string, string) { +func RetainSections(existing string, imports []string, extraCode *string) ([]string, string) { kept := importList(existing) _, keptExtra := RetainedSections(existing) seen := make(map[string]bool, len(kept)+len(imports)) @@ -72,10 +74,41 @@ func RetainSections(existing string, imports []string, extraCode string) ([]stri seen[imp] = true out = append(out, imp) } - if extraCode == "" { - extraCode = keptExtra + if extraCode != nil { + keptExtra = *extraCode } - return out, extraCode + return out, keptExtra +} + +// StatedImports is the import list describe states for source: the shortest +// leading part of the file's list that the generator turns back into the whole +// of it — nil for the list mxbuild writes on its own, and the generated +// imports too when one a developer added follows them, since the generator +// appends what a list lacks rather than inserting it. A list no leading part +// regenerates (one missing an import the code needs) is stated whole. +func StatedImports(source string, params []*JavaActionParameter, returnType CodeActionReturnType) []string { + list := importList(source) + for k := 0; k <= len(list); k++ { + if equalStrings(completeImports(list[:k], params, returnType), list) { + if k == 0 { + return nil + } + return list[:k] + } + } + return list +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true } // trimSection drops the blank lines around a marker section and the one tab of diff --git a/sdk/javaactions/retain_extra_test.go b/sdk/javaactions/retain_extra_test.go new file mode 100644 index 0000000000..7820a954f5 --- /dev/null +++ b/sdk/javaactions/retain_extra_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +package javaactions + +import ( + "reflect" + "strings" + "testing" +) + +// ako/mxcli#1702 (mendixlabs/mxcli#1658): MDL had no clause for the EXTRA CODE +// section and `describe java action` printed neither it nor the imports a +// developer added, so describe → exec on another project wrote a body calling +// a helper that no longer existed. `extra code $$ … $$` states the section; +// an omitted clause keeps the stored one, as before, and an empty one clears it. + +func strPtr(s string) *string { return &s } + +func TestRetainSections_ExtraCodeClause(t *testing.T) { + for _, c := range []struct { + name string + extraCode *string + want string + }{ + {"omitted keeps the stored section", nil, storedExtra}, + {"stated replaces it", strPtr("private static int x;"), "private static int x;"}, + {"stated empty clears it", strPtr(""), ""}, + } { + t.Run(c.name, func(t *testing.T) { + if _, got := RetainSections(studioProSource, nil, c.extraCode); got != c.want { + t.Errorf("extra code = %q, want %q", got, c.want) + } + }) + } +} + +// storedExtra is studioProSource's EXTRA CODE section as RetainedSections +// reads it. +var storedExtra = func() string { _, e := RetainedSections(studioProSource); return e }() + +// StatedImports is what describe prints: the shortest leading part of the +// import list that the generator turns back into the whole list — nothing for +// the list mxbuild writes on its own, and the generated imports too when a +// developer's import follows them, since the generator appends what is missing +// rather than inserting it. +func TestStatedImports(t *testing.T) { + gen := []string{importIContext, importUserAction} + src := func(imports ...string) string { + return "package m.actions;\n\n" + strings.Join(imports, "\n") + "\n\npublic class A extends UserAction\n{\n}\n" + } + for _, c := range []struct { + name string + imports []string + want []string + }{ + {"generated only", gen, nil}, + {"one added after", append(append([]string{}, gen...), "import java.util.Objects;"), + append(append([]string{}, gen...), "import java.util.Objects;")}, + {"one added before", append([]string{"import java.util.List;"}, gen...), []string{"import java.util.List;"}}, + {"no file", nil, nil}, + } { + t.Run(c.name, func(t *testing.T) { + source := "" + if c.imports != nil { + source = src(c.imports...) + } + got := StatedImports(source, nil, &BooleanType{}) + if !reflect.DeepEqual(got, c.want) { + t.Errorf("StatedImports = %q, want %q", got, c.want) + } + // What describe prints regenerates the file's list. + if source != "" { + if regen := completeImports(got, nil, &BooleanType{}); !reflect.DeepEqual(regen, importList(source)) { + t.Errorf("regenerated imports %q, want the file's %q", regen, importList(source)) + } + } + }) + } +} diff --git a/sdk/javaactions/retain_test.go b/sdk/javaactions/retain_test.go index 8ac23cdf78..95705b7871 100644 --- a/sdk/javaactions/retain_test.go +++ b/sdk/javaactions/retain_test.go @@ -14,8 +14,8 @@ import ( // between BEGIN EXTRA CODE and END EXTRA CODE". mxcli's regeneration kept only // the user code, so rewriting an action whose user code calls a helper in its // EXTRA CODE section — the Blank template's FeedbackModule.XSS_Sanitizer calls -// sanitize() — produced Java that does not compile (ako/mxcli#705). MDL has no -// spelling for extra code, so it can only be retained. +// sanitize() — produced Java that does not compile (ako/mxcli#705). A statement +// without `extra code $$ … $$` (ako/mxcli#1702) retains the stored section. const studioProSource = `// This file was generated by Mendix Studio Pro. package feedbackmodule.actions; @@ -61,7 +61,7 @@ func TestRetainedSections_ReadsImportsAndExtraCode(t *testing.T) { func TestRetainSections_MergesWithWhatTheStatementSaid(t *testing.T) { imports, extra := RetainSections(studioProSource, - []string{"import java.util.regex.Pattern;", "import java.util.List;"}, "") + []string{"import java.util.regex.Pattern;", "import java.util.List;"}, nil) // The stored list as it stands — mxbuild neither reorders it nor moves the // imports it generates itself to the top — plus the new one. want := []string{ @@ -80,11 +80,11 @@ func TestRetainSections_MergesWithWhatTheStatementSaid(t *testing.T) { } // Extra code the statement DOES provide wins. - if _, extra := RetainSections(studioProSource, nil, "int x;"); extra != "int x;" { + if _, extra := RetainSections(studioProSource, nil, strPtr("int x;")); extra != "int x;" { t.Errorf("extra code = %q, want the authored one", extra) } // No existing file: only what the statement said. - if imports, extra := RetainSections("", []string{"import a.B;"}, ""); !reflect.DeepEqual(imports, []string{"import a.B;"}) || extra != "" { + if imports, extra := RetainSections("", []string{"import a.B;"}, nil); !reflect.DeepEqual(imports, []string{"import a.B;"}) || extra != "" { t.Errorf("no existing file: got %q / %q", imports, extra) } } @@ -92,7 +92,7 @@ func TestRetainSections_MergesWithWhatTheStatementSaid(t *testing.T) { // End to end through the generator: the regenerated file compiles against the // same helper and imports. func TestGenerateSource_WithRetainedSectionsKeepsTheHelper(t *testing.T) { - imports, extra := RetainSections(studioProSource, nil, "") + imports, extra := RetainSections(studioProSource, nil, nil) src := GenerateSource("FeedbackModule", "XSS_Sanitizer", "return sanitize(stringToSanitize);", nil, &StringType{}, imports, extra, "") for _, want := range []string{ From 933717ad3b8d6f90e0f1e8858bce30f1fda2040e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 23:07:50 +0000 Subject: [PATCH 40/40] fix: keep a microflow data source's FormValidations (#1729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A microflow data source stores its call settings in a Forms$MicroflowSettings, as a call-microflow action does, but the writer always wrote that data source's FormValidations as All. ako/TestApp's Pages.OutputMappingButton has a data view whose source stores None: describe → exec rewrote it as All and reported "Replaced page", and that was the only difference on the page. A microflow data source now takes `with (FormValidations: All|Widget| None)`, the spelling an action already has. It is written, described when it is not All, and settable through alter page set DataSource. It is the only setting a data source takes. Across 44 TestApp and 4 PedApp sources every other setting is at its default, so the rest are refused on a data source as an action's. The action path already kept a stored None. With #1728, the TestApp page now round-trips as "Unchanged page" once its unwired nanoflow button has a nanoflow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DRueZhZoBML5iWuJTvUnjL --- ...2026-10-26-datasource-formvalidations.json | 1 + .../mendix/create-page/reference/widgets.md | 1 + cmd/mxcli/syntax/features_page.go | 2 +- docs-site/src/reference/page/create-page.md | 2 +- docs/01-project/MDL_QUICK_REFERENCE.md | 1 + mdl/ast/ast_page_v3.go | 5 + mdl/backend/mcp/page_widgets.go | 3 + mdl/backend/modelsdk/widget_write.go | 10 +- mdl/executor/cmd_alter_page.go | 2 +- mdl/executor/cmd_fragments.go | 3 + mdl/executor/cmd_pages_builder_v3.go | 1 + mdl/executor/cmd_pages_describe.go | 3 + mdl/executor/cmd_pages_describe_datasource.go | 9 +- .../datasource_formvalidations_1729_test.go | 187 ++++++++++++++++++ mdl/grammar/domains/MDLPage.g4 | 2 +- mdl/visitor/visitor_action_settings.go | 13 +- mdl/visitor/visitor_page_v3.go | 5 + sdk/pages/pages_datasources.go | 3 + 18 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-26-datasource-formvalidations.json create mode 100644 mdl/executor/datasource_formvalidations_1729_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-datasource-formvalidations.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-datasource-formvalidations.json new file mode 100644 index 0000000000..de2b473f3d --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-26-datasource-formvalidations.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-26", "symptom": "A data view's microflow data source stored with FormValidations None (ako/TestApp Pages.OutputMappingButton dataView1, Forms$MicroflowSource.MicroflowSettings) was rewritten as All on describe → exec: Replaced page, the only difference on the page", "cause": "The data-source writer called microflowSettingsToGen with nil settings, which writes Mendix's defaults, and neither the grammar nor describe had a place for a data source's call settings", "file": "mdl/grammar/domains/MDLPage.g4 (dataSourceExprV3 MICROFLOW … actionSettingsV3?), mdl/visitor/visitor_page_v3.go + visitor_action_settings.go (data source takes FormValidations only), sdk/pages/pages_datasources.go (MicroflowSource.FormValidations), mdl/executor/cmd_pages_builder_v3.go + cmd_alter_page.go, mdl/backend/modelsdk/widget_write.go (3 microflow-source sites), mdl/executor/cmd_pages_describe_datasource.go, cmd_fragments.go; test mdl/executor/datasource_formvalidations_1729_test.go", "insight": "Tally every field of the shared element under each parent before choosing a syntax: Forms$MicroflowSettings under a data source held non-default values in FormValidations only (44 TestApp + 4 PedApp sources, every other setting default), so the data source reuses the action's `with ( … )` rule with one key allowed rather than a new property. Before the grammar change `DataSource: microflow M.F with (…)` parsed without error as the deprecated `microflow M.F` ACTION form and left the widget with no source — an alternative that fails in one rule can succeed in a fallback rule, so a parse test with no errors proves nothing about which rule took it", "refs": ["ako/mxcli#1729"]} diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 4249962a48..20f3a43a3a 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -404,6 +404,7 @@ column (caption: 'Actions') { | `datasource: $Variable` | Variable bound (requires DATAVIEW parent with entity) | | `datasource: microflow Module.GetData` | Microflow datasource with **no parameters** — the name alone | | `datasource: microflow Module.GetData(Param = $Param)` | Microflow datasource **with** parameters — one **named** argument per parameter, required. Mendix does not auto-map an object in scope, so leaving it out is **CE1571**. The positional form `GetData($Param)` is refused: write `Param = …` naming the parameter | +| `datasource: microflow Module.GetData with (FormValidations: None)` | The source call's FormValidations (`All` default, `Widget`, `None`) — the only setting a data source takes; the others are an action's and are refused | | `datasource: nanoflow Module.GetData` | Nanoflow datasource (client-side, no server roundtrip) — same rule: the name alone only when it takes no parameters | | `datasource: selection widgetName` | Listen to selection from another widget | | `datasource: association path` | Retrieve by association from context (ByAssociation) | diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index e98de25c99..e761c6ab09 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -276,7 +276,7 @@ LIST IMPACT OF htmlelement; "datasource", "data source", "database", "microflow", "selection", "variable", "binding", "binds", "association", "data from context", }, - Syntax: "DataSource: $Variable -- Parameter/variable binding\nDataSource: DATABASE Module.Entity -- Database query\nDataSource: DATABASE Module.Entity WHERE [Attr != ''] SORT BY Attr ASC\n -- ...optionally constrained and sorted\nDataSource: DATABASE Module.Entity SORT BY Module.Assoc/Attr ASC\n -- ...sorted over an association. Name the\n -- hop when two reach the same entity —\n -- the wrong one builds cleanly and sorts\n -- by the wrong thing.\nDataSource: DATABASE Module.Entity SEARCH BY Attr, Attr2\n -- LIST VIEW only: the attributes its\n -- search bar filters on. Mirrors SORT BY,\n -- but takes no direction.\nDataSource: DATABASE FROM $Ctx/Module.Assoc/Module.Entity [WHERE …] [SORT BY …] [SEARCH BY …]\n -- LIST VIEW only: a DATABASE retrieve of\n -- what the association reaches, keeping\n -- XPath, sort and search. Not the same as\n -- $Ctx/Module.Assoc below (in memory, none\n -- of the three). Name the entity after\n -- each association.\n -- Mendix 9: a pluggable widget (repeater,\n -- gallery, grid) takes it too, as Studio\n -- Pro 9's \"Database\" source; its WHERE is\n -- then simple rows only — Attr op value,\n -- joined by AND (no OR, paths, variables).\nDataSource: MICROFLOW Module.MF -- Microflow datasource, no parameters\nDataSource: MICROFLOW Module.MF(Param = $P) -- ...one argument per PARAMETER, required,\n -- and NAMED: the positional form\n -- MF($P) is refused. Mendix does\n -- NOT auto-map an object in scope, not\n -- even one of the exact type, so a\n -- missing argument is CE1571\nDataSource: SELECTION widgetName -- Selection from another widget\nDataSource: $currentObject/Module.Assoc -- Over an association (\"data from context\")\n -- list widget → to-many collection\n -- nested DATAVIEW → the to-one referenced object\nAttribute: AttributeName -- Attribute binding (inputs)", + Syntax: "DataSource: $Variable -- Parameter/variable binding\nDataSource: DATABASE Module.Entity -- Database query\nDataSource: DATABASE Module.Entity WHERE [Attr != ''] SORT BY Attr ASC\n -- ...optionally constrained and sorted\nDataSource: DATABASE Module.Entity SORT BY Module.Assoc/Attr ASC\n -- ...sorted over an association. Name the\n -- hop when two reach the same entity —\n -- the wrong one builds cleanly and sorts\n -- by the wrong thing.\nDataSource: DATABASE Module.Entity SEARCH BY Attr, Attr2\n -- LIST VIEW only: the attributes its\n -- search bar filters on. Mirrors SORT BY,\n -- but takes no direction.\nDataSource: DATABASE FROM $Ctx/Module.Assoc/Module.Entity [WHERE …] [SORT BY …] [SEARCH BY …]\n -- LIST VIEW only: a DATABASE retrieve of\n -- what the association reaches, keeping\n -- XPath, sort and search. Not the same as\n -- $Ctx/Module.Assoc below (in memory, none\n -- of the three). Name the entity after\n -- each association.\n -- Mendix 9: a pluggable widget (repeater,\n -- gallery, grid) takes it too, as Studio\n -- Pro 9's \"Database\" source; its WHERE is\n -- then simple rows only — Attr op value,\n -- joined by AND (no OR, paths, variables).\nDataSource: MICROFLOW Module.MF -- Microflow datasource, no parameters\nDataSource: MICROFLOW Module.MF with (FormValidations: None)\n -- ...its call's FormValidations (All, the\n -- default, Widget or None); the only\n -- setting a data source takes (#1729)\nDataSource: MICROFLOW Module.MF(Param = $P) -- ...one argument per PARAMETER, required,\n -- and NAMED: the positional form\n -- MF($P) is refused. Mendix does\n -- NOT auto-map an object in scope, not\n -- even one of the exact type, so a\n -- missing argument is CE1571\nDataSource: SELECTION widgetName -- Selection from another widget\nDataSource: $currentObject/Module.Assoc -- Over an association (\"data from context\")\n -- list widget → to-many collection\n -- nested DATAVIEW → the to-one referenced object\nAttribute: AttributeName -- Attribute binding (inputs)", Example: "-- Database datasource with grid\nDATAGRID grid (DataSource: DATABASE Module.Customer) {\n COLUMN (Attribute: Name, Caption: 'Name')\n}\n\n-- Microflow datasource\nDATAVIEW dv (DataSource: MICROFLOW Module.GetData) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n}\n\n-- Over an association: a nested DataView shows the referenced (to-one) object\nDATAVIEW dvOrder (DataSource: $Order) {\n DATAVIEW dvCustomer (DataSource: $currentObject/Order_Customer) {\n TEXTBOX txtCustName (Label: 'Name', Attribute: Name)\n }\n}\n\n-- Over an association: a list widget shows the (to-many) collection\nLISTVIEW lvLines (DataSource: $currentObject/Order_OrderLine) {\n DYNAMICTEXT dtLine (Content: 'Line')\n}", SeeAlso: []string{"page.widgets", "page.create"}, }) diff --git a/docs-site/src/reference/page/create-page.md b/docs-site/src/reference/page/create-page.md index 8ba2c8bfa4..94c5600cda 100644 --- a/docs-site/src/reference/page/create-page.md +++ b/docs-site/src/reference/page/create-page.md @@ -111,7 +111,7 @@ The `DataSource` property determines how a data widget obtains its data: | Parameter | `DataSource: $ParamName` | Binds to a page parameter or enclosing data view | | Database | `DataSource: DATABASE Module.Entity` | Retrieves from database | | Selection | `DataSource: SELECTION widgetName` | Listens to selection on another widget | -| Microflow | `DataSource: MICROFLOW Module.MFName` | Calls a microflow for data | +| Microflow | `DataSource: MICROFLOW Module.MFName` | Calls a microflow for data. Takes `with (FormValidations: All\|Widget\|None)` like a microflow action (default `All`); no other setting | ### Action Types diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index ec1522f6a1..5dac7c70cf 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1527,6 +1527,7 @@ MDL uses explicit property declarations for pages: | Glyph icon | `icon: glyph ` | `actionbutton btn (caption: 'Home', action: nothing, icon: glyph 57377)` — a font code point with no name. Codes are sparse; an undefined one fails only at `mxbuild --target=deploy`, naming the **page**, so **MDL078** checks it. Browse with `list glyphs` | | Clickable container | `onclick: action` (alias of `action:`) | `container card (onclick: call microflow Mod.ACT_Open) { ... }` — takes an argument list like a button: `action: call nanoflow Mod.ACT_Ship(Order = $dgOrders)` | | Action arguments | every parameter needs one | A flow action with an unfilled parameter is **CE1571**. An enclosing data container of its type supplies it; a data grid's **control bar** does not (not row-scoped) — pass the grid's selection, `$dgOrders` | +| Microflow source settings | `datasource: microflow Name(…) with (FormValidations: All\|Widget\|None)` | `dataview dv (datasource: microflow Mod.DS_Vehicle_Get with (FormValidations: None))` — a microflow data source stores its call settings as a microflow action does; `FormValidations` is the only one it takes (the rest are an action's, refused here). Omitted means `All`, and describe prints it only when it differs. Before this, describe → exec rewrote a stored `None` as `All` (ako/mxcli#1729) | | Database source | `datasource: database entity` | `datagrid dg (datasource: database Module.Entity)` | | Database source, constrained and sorted | `datasource: database entity where [...] sort by Attr asc` | `listview lv (datasource: database from Mod.Vehicle where [Brand != ''] sort by Brand asc)` | | List view search bar | `... search by Attr, Attr2` | `listview lv (datasource: database from Mod.Vehicle search by Brand, Model)` — **list view only**; mirrors `sort by` and takes no direction | diff --git a/mdl/ast/ast_page_v3.go b/mdl/ast/ast_page_v3.go index bca8426256..141ff12716 100644 --- a/mdl/ast/ast_page_v3.go +++ b/mdl/ast/ast_page_v3.go @@ -299,6 +299,11 @@ type DataSourceV3 struct { // (`$Ctx/Mod.Assoc`), which is an in-memory retrieve with no XPath, sort or // search (ako/mxcli#721 L5). AssociationPath string + // FormValidations is a microflow source's `with (FormValidations: …)`: All, + // Widget or None, "" when not written (All). A data source stores its call + // settings in a Forms$MicroflowSettings as an action does, and this is the + // one that differs in Studio Pro-authored pages (ako/mxcli#1729). + FormValidations string } // FlowArgV3 represents an argument for microflow/nanoflow/page calls. diff --git a/mdl/backend/mcp/page_widgets.go b/mdl/backend/mcp/page_widgets.go index 1a8d8e020d..56ba2d6a76 100644 --- a/mdl/backend/mcp/page_widgets.go +++ b/mdl/backend/mcp/page_widgets.go @@ -405,6 +405,9 @@ func mapDataViewSource(ds pages.DataSource) (map[string]any, error) { if s.Microflow == "" { return nil, fmt.Errorf("microflow data source has no microflow") } + if s.FormValidations != "" && s.FormValidations != "All" { + return nil, fmt.Errorf("microflow data source %s: FormValidations %s is not yet supported by the MCP backend", s.Microflow, s.FormValidations) + } return map[string]any{ "$Type": "Pages$MicroflowSource", "forceFullObjects": false, diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 9f82ef658a..77169eec26 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -1685,7 +1685,7 @@ func dataViewSourceToGen(ds pages.DataSource) (element.Element, error) { } assignID(ms) ms.SetForceFullObjects(false) - ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, nil, nil)) + ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, &pages.FlowCallSettings{FormValidations: d.FormValidations}, nil)) return ms, nil // A NANOFLOW data source. Flat, unlike the microflow source above: the @@ -1834,7 +1834,7 @@ func listViewSourceToGen(ds pages.DataSource) (element.Element, error) { } assignID(ms) ms.SetForceFullObjects(false) - ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, nil, nil)) + ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, &pages.FlowCallSettings{FormValidations: d.FormValidations}, nil)) return ms, nil // A NANOFLOW data source. Flat, unlike the microflow source above: the @@ -1917,7 +1917,7 @@ func customWidgetDataSourceToGen(ds pages.DataSource) (element.Element, error) { } assignID(ms) ms.SetForceFullObjects(false) - ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, nil, nil)) + ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings, &pages.FlowCallSettings{FormValidations: d.FormValidations}, nil)) return ms, nil // A NANOFLOW data source. Flat, unlike the microflow source above: the @@ -2057,8 +2057,8 @@ func bindParameterMappingValue(m parameterMappingTarget, variable, kind, express // left a parameterized button/row invoking its microflow with no argument — the // widget no-ops at runtime yet mx check passes clean (Bug 1). // -// settings carries what a call-microflow action's `with ( … )` says; nil (the -// data-source case, which has no MDL spelling for them) writes Mendix's defaults. +// settings carries what a call-microflow action's `with ( … )` says, or for a +// data source its FormValidations (ako/mxcli#1729); nil writes Mendix's defaults. // outputs are its `returns ( … )` mappings (ako/mxcli#977 item 3). func microflowSettingsToGen(microflowName string, mappings []*pages.MicroflowParameterMapping, settings *pages.FlowCallSettings, outputs []*pages.FlowOutputMapping) element.Element { if settings == nil { diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index e57db33062..8bf889b0b7 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -557,7 +557,7 @@ func convertASTDataSource(value interface{}) (pages.DataSource, error) { } return &pages.DatabaseSource{EntityName: ds.Reference}, nil case "microflow": - return &pages.MicroflowSource{Microflow: ds.Reference}, nil + return &pages.MicroflowSource{Microflow: ds.Reference, FormValidations: ds.FormValidations}, nil case "nanoflow": return &pages.NanoflowSource{Nanoflow: ds.Reference}, nil case "parameter": diff --git a/mdl/executor/cmd_fragments.go b/mdl/executor/cmd_fragments.go index 0e74adc62c..5a74e234cc 100644 --- a/mdl/executor/cmd_fragments.go +++ b/mdl/executor/cmd_fragments.go @@ -276,6 +276,9 @@ func formatDataSourceV3(ds *ast.DataSourceV3) string { case "database": return "database " + databaseSourceFrom(ds) case "microflow": + if ds.FormValidations != "" { + return "microflow " + ds.Reference + formatFlowArgsV3(ds.Args) + " with (FormValidations: " + ds.FormValidations + ")" + } return "microflow " + ds.Reference + formatFlowArgsV3(ds.Args) case "nanoflow": return "nanoflow " + ds.Reference + formatFlowArgsV3(ds.Args) diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 3d139afb89..ba051213d8 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -998,6 +998,7 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource MicroflowID: mfID, Microflow: ds.Reference, ParameterMappings: pb.flowArgsToParameterMappings(ds.Args), + FormValidations: ds.FormValidations, }, entityName, nil case "nanoflow": diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 27cd0fe190..ce1e52aa0d 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -690,6 +690,9 @@ type rawDataSource struct { // exactly as a call action does (#835) — describing it without them yields // MDL that rebuilds into CE1571 (mxcli-formula1 §57.2). Args []rawDataSourceArg + // FormValidations is a microflow source's MicroflowSettings setting, "" when + // not stored (ako/mxcli#1729). + FormValidations string // Unsupported carries the stored $Type of a datasource that has no MDL // spelling. Set instead of guessing a Type: describe reports it as a // comment, so the binding is visible in the output without producing a diff --git a/mdl/executor/cmd_pages_describe_datasource.go b/mdl/executor/cmd_pages_describe_datasource.go index 280d47abfb..1f0d22f25d 100644 --- a/mdl/executor/cmd_pages_describe_datasource.go +++ b/mdl/executor/cmd_pages_describe_datasource.go @@ -88,7 +88,11 @@ func parseDataSource(ds map[string]any) *rawDataSource { switch { case dsType == dsTypeMicroflow: if mf := microflowSourceRef(ds); mf != "" { - return &rawDataSource{Type: "microflow", Reference: mf, Args: flowSourceArgs(ds, "MicroflowSettings", mf)} + raw := &rawDataSource{Type: "microflow", Reference: mf, Args: flowSourceArgs(ds, "MicroflowSettings", mf)} + if settings, ok := ds["MicroflowSettings"].(map[string]any); ok { + raw.FormValidations = extractString(settings["FormValidations"]) + } + return raw } case dsType == dsTypeNanoflow: if nf := nanoflowSourceRef(ds); nf != "" { @@ -503,6 +507,9 @@ func dataSourceExpr(ctx *ExecContext, ds *rawDataSource) string { } expr += "(" + strings.Join(parts, ", ") + ")" } + if ds.FormValidations != "" && ds.FormValidations != "All" { + expr += " with (FormValidations: " + ds.FormValidations + ")" + } return expr case "parameter": if ds.Reference == "" { diff --git a/mdl/executor/datasource_formvalidations_1729_test.go b/mdl/executor/datasource_formvalidations_1729_test.go new file mode 100644 index 0000000000..349ae89a99 --- /dev/null +++ b/mdl/executor/datasource_formvalidations_1729_test.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#1729: a microflow data source stores its call settings in a +// Forms$MicroflowSettings, as a call-microflow action does, and the writer wrote +// FormValidations All there whatever was stored. ako/TestApp's +// Pages.OutputMappingButton has a data view whose source stores None; describe +// → exec rewrote it as All ("Replaced page"). It is the one setting that +// differs on a data source in the fixtures (44 + 4 sources, every other setting +// at its default), so a data source takes `with (FormValidations: …)`, the +// spelling the action already has. +package executor + +import ( + "context" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +const dsFormValidationsSetup = `mdl 1; +create persistent entity MyFirstModule.Thing (Name: String(50)); +create microflow MyFirstModule.DS_Thing () returns MyFirstModule.Thing as $out +begin + $out = create MyFirstModule.Thing; + return $out; +end; +create microflow MyFirstModule.DS_Things () returns list of MyFirstModule.Thing as $out +begin + retrieve $out from MyFirstModule.Thing; + return $out; +end; +create microflow MyFirstModule.ACT_Go () begin end; +` + +const dsFormValidationsPage = `create or modify page MyFirstModule.FV_Page (Title: 'FV', Layout: Atlas_Core.Atlas_Default) { + dataview dvNone (DataSource: microflow MyFirstModule.DS_Thing with (FormValidations: None)) { + dynamictext t1 (Content: 'x') + } + listview lvWidget (DataSource: microflow MyFirstModule.DS_Things with (FormValidations: Widget)) { + dynamictext t2 (Content: 'y') + } + dataview dvAll (DataSource: microflow MyFirstModule.DS_Thing) { + dynamictext t3 (Content: 'z') + } + actionbutton btnNone (Caption: 'Go', Action: call microflow MyFirstModule.ACT_Go with (FormValidations: None)) +}; +` + +// storedFormValidations maps each named widget to the FormValidations of the +// Forms$MicroflowSettings it holds (its data source's, or its action's). +func storedFormValidations(t *testing.T, exec *Executor, page string) map[string]string { + t.Helper() + _, raw := pageUnitByName(t, exec, page) + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + out := map[string]string{} + var walk func(v any, owner string) + walk = func(v any, owner string) { + switch n := v.(type) { + case bson.D: + if name, _ := afGet(n, "Name").(string); name != "" { + owner = name + } + if afGet(n, "$Type") == "Forms$MicroflowSettings" { + out[owner], _ = afGet(n, "FormValidations").(string) + } + for _, e := range n { + walk(e.Value, owner) + } + case bson.A: + for _, e := range n { + walk(e, owner) + } + } + } + walk(doc, "") + return out +} + +func TestDataSourceFormValidations(t *testing.T) { + exec, out := openPedAppFixture(t) + if err := afRun(t, exec, dsFormValidationsSetup+dsFormValidationsPage); err != nil { + t.Fatalf("exec: %v\n%s", err, out.String()) + } + want := map[string]string{"dvNone": "None", "lvWidget": "Widget", "dvAll": "All", "btnNone": "None"} + got := storedFormValidations(t, exec, "FV_Page") + for w, v := range want { + if got[w] != v { + t.Errorf("%s: stored FormValidations %q, want %q (all: %v)", w, got[w], v, got) + } + } + + out.Reset() + if err := afRun(t, exec, "describe page MyFirstModule.FV_Page;"); err != nil { + t.Fatal(err) + } + described := out.String() + for _, s := range []string{ + "DataSource: microflow MyFirstModule.DS_Thing with (FormValidations: None)", + "DataSource: microflow MyFirstModule.DS_Things with (FormValidations: Widget)", + "call microflow MyFirstModule.ACT_Go with (FormValidations: None)", + } { + if !strings.Contains(described, s) { + t.Errorf("describe does not print %q:\n%s", s, described) + } + } + // Control: the default is not printed. + if strings.Contains(described, "DS_Thing with (FormValidations: All)") { + t.Errorf("describe printed the default:\n%s", described) + } + out.Reset() + if err := afRun(t, exec, described); err != nil { + t.Fatalf("re-exec of the description: %v", err) + } + if !strings.Contains(out.String(), "Unchanged page") { + t.Errorf("describe → exec was not a no-op:\n%s", out.String()) + } +} + +// The reported shape: a data source Studio Pro stored with None, read back. +func TestDataSourceFormValidationsStoredByStudioPro(t *testing.T) { + exec, out := openPedAppFixture(t) + page := strings.Replace(dsFormValidationsPage, " with (FormValidations: None))", ")", 1) + if err := afRun(t, exec, dsFormValidationsSetup+page); err != nil { + t.Fatalf("exec: %v\n%s", err, out.String()) + } + id, raw := pageUnitByName(t, exec, "FV_Page") + var doc bson.D + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + walkStoredDocs(doc, func(d bson.D) { + if afGet(d, "$Type") != "Forms$MicroflowSource" { + return + } + ms, _ := afGet(d, "MicroflowSettings").(bson.D) + if afGet(ms, "Microflow") != "MyFirstModule.DS_Thing" { + return + } + for i := range ms { + if ms[i].Key == "FormValidations" && afGet(ms, "FormValidations") == "All" { + ms[i].Value = "None" + break // only dvNone's; dvAll keeps All + } + } + }) + b, _ := bson.Marshal(doc) + if err := exec.newExecContext(context.Background()).Backend.UpdateRawUnit(string(id), b); err != nil { + t.Fatal(err) + } + before := storedFormValidations(t, exec, "FV_Page") + + out.Reset() + if err := afRun(t, exec, "describe page MyFirstModule.FV_Page;"); err != nil { + t.Fatal(err) + } + described := out.String() + out.Reset() + if err := afRun(t, exec, described); err != nil { + t.Fatalf("re-exec of the description: %v", err) + } + if !strings.Contains(out.String(), "Unchanged page") { + t.Errorf("describe → exec of a stored None was not a no-op:\n%s\n%s", out.String(), described) + } + if after := storedFormValidations(t, exec, "FV_Page"); after["dvNone"] != before["dvNone"] || before["dvNone"] != "None" { + t.Errorf("dvNone FormValidations: stored %q, after describe → exec %q", before["dvNone"], after["dvNone"]) + } +} + +// A data source takes FormValidations only: the other settings are an action's. +func TestDataSourceSettingsRefused(t *testing.T) { + for _, setting := range []string{"ProgressBar: Blocking", "Asynchronous: true", "DisabledDuringExecution: false", "Confirmation: 'Sure?'"} { + _, errs := visitor.Build("create page M.P (Title: 'T', Layout: A.L) {\n dataview dv (DataSource: microflow M.F with (" + setting + ")) { }\n}") + if len(errs) == 0 || !strings.Contains(errs[0].Error(), "data source") { + t.Errorf("%s on a data source: errs %v, want a refusal naming the data source", setting, errs) + } + } + // Control: FormValidations is accepted, and a nanoflow source takes none. + if _, errs := visitor.Build("create page M.P (Title: 'T', Layout: A.L) {\n dataview dv (DataSource: microflow M.F with (FormValidations: None)) { }\n}"); len(errs) > 0 { + t.Errorf("FormValidations on a microflow data source was refused: %v", errs) + } +} diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index e0bb412dce..6db6b3a82b 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -671,7 +671,7 @@ dataSourceExprV3 (WHERE (xpathConstraint (andOrXpath? xpathConstraint)* | expression))? (SORT_BY sortColumn (COMMA sortColumn)*)? (SEARCH_BY searchAttribute (COMMA searchAttribute)*)? - | MICROFLOW qualifiedName microflowArgsV3? // MICROFLOW Module.Flow + | MICROFLOW qualifiedName microflowArgsV3? actionSettingsV3? // MICROFLOW Module.Flow [with (FormValidations: None)] | NANOFLOW qualifiedName microflowArgsV3? // NANOFLOW Module.Flow | ASSOCIATION associationPathV3 // ASSOCIATION Module.Assoc (explicit form) | ASSOCIATION VARIABLE SLASH associationPathV3 // ASSOCIATION $currentObject/Module.Assoc (keyword + sugar) diff --git a/mdl/visitor/visitor_action_settings.go b/mdl/visitor/visitor_action_settings.go index cdccb875b6..b5e9e7f976 100644 --- a/mdl/visitor/visitor_action_settings.go +++ b/mdl/visitor/visitor_action_settings.go @@ -141,6 +141,10 @@ func (b *Builder) ExitActionSettingsV3(ctx *parser.ActionSettingsV3Context) { kind = "nanoflow" } } + // A microflow data source stores the same Forms$MicroflowSettings, but of + // its settings only FormValidations ever differs from the default in a + // Studio Pro-authored page (ako/mxcli#1729): the rest are an action's. + _, dataSource := ctx.GetParent().(*parser.DataSourceExprV3Context) seen := map[string]bool{} for _, raw := range ctx.AllActionSettingV3() { s := raw.(*parser.ActionSettingV3Context) @@ -161,11 +165,16 @@ func (b *Builder) ExitActionSettingsV3(ctx *parser.ActionSettingsV3Context) { continue } seen[key] = true - if spec.mfOnly && kind != "microflow" { + if dataSource && key != "formvalidations" { + b.addError(fmt.Errorf("%s: %s is a setting of a flow call, not of a data source; "+ + "a microflow data source takes FormValidations only", pos, spec.canonical)) + continue + } + if spec.mfOnly && kind != "microflow" && !dataSource { b.addError(fmt.Errorf("%s: %s is a setting of `call microflow` only", pos, spec.canonical)) continue } - if spec.flowOnly && kind == "other" { + if spec.flowOnly && kind == "other" && !dataSource { b.addError(fmt.Errorf("%s: %s is a setting of `call microflow` and `call nanoflow` only", pos, spec.canonical)) continue diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index d56a9ec6a4..c7b1e0a250 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -1105,6 +1105,11 @@ func buildDataSourceV3(ctx parser.IDataSourceExprV3Context) *ast.DataSourceV3 { if argsCtx := dsCtx.MicroflowArgsV3(); argsCtx != nil { ds.Args = buildMicroflowArgsV3(argsCtx) } + if sc := dsCtx.ActionSettingsV3(); sc != nil { + if s := buildActionSettingsV3(sc); s != nil { + ds.FormValidations = s.FormValidations + } + } } else if dsCtx.NANOFLOW() != nil { // NANOFLOW Module.Flow ds.Type = "nanoflow" diff --git a/sdk/pages/pages_datasources.go b/sdk/pages/pages_datasources.go index e198dab753..ca5fa9d147 100644 --- a/sdk/pages/pages_datasources.go +++ b/sdk/pages/pages_datasources.go @@ -96,6 +96,9 @@ type MicroflowSource struct { // argument for every parameter; dropping these builds to CE1571 "No argument // has been selected for parameter 'X'" (#835). ParameterMappings []*MicroflowParameterMapping `json:"parameterMappings,omitempty"` + // FormValidations is its MicroflowSettings' setting: "" (All) | All | + // Widget | None (ako/mxcli#1729). + FormValidations string `json:"formValidations,omitempty"` } func (MicroflowSource) isDataSource() {}