diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-28-same-kind-activity-replace-mints-new-ids.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-28-same-kind-activity-replace-mints-new-ids.json new file mode 100644 index 0000000000..576a61b5a5 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-28-same-kind-activity-replace-mints-new-ids.json @@ -0,0 +1 @@ +{"area":"mdl/backend","date":"2026-10-28","symptom":"Re-running `create or modify microflow|nanoflow` with only a filter's expression changed gave the activity, its ListOperationsAction and its FilterByExpression new $IDs, while a decision whose condition changed the same way kept its own; exec printed `spliced: 1 replaced` for both, and check, mx check, diff and describe were clean, so only a BSON dump or Studio Pro's version control (one activity removed, another added) showed it","cause":"The statement diff turns a changed activity into a Replace, and Mutator.Replace at top level appended the fragment's freshly built activity and removed the stored one. The in-place swaps that existed (swapInPlace: loop bodies #1106, handled activities #1489) kept the stored slot and geometry but still wrote the fragment's $IDs. The patch write (UpdateRawUnitPatch) deliberately skips canon.TransplantIDs, so nothing downstream re-paired them. Separately, two adjacent changed activities form one run, which became 'replace the first by both, drop the second' and could never be an edit","file":"mdl/backend/mfmutator/splice_edit.go (sameKind, swapKeeping, editInPlace, carryPairs, pairEntries, applyCarried), mdl/backend/mfmutator/splice.go (Replace, replaceInLoop, replaceKeepingHandler, Bytes), mdl/executor/cmd_alter_flow.go (noteEdited), mdl/executor/cmd_flow_modify.go (patchSummary, pairwiseSameKind)","fix":"A replace by one activity of the same activity and action $Type is written into the stored slot and paired structurally with the stored activity: same $Type at the same key, list entries by what they name (Attribute/Association plus Type, Parameter, LanguageCode, Name, Key), unnamed entries by position only for equal $Type sequences. The pairs are applied at Bytes over every binary in the unit, so the executor can keep naming the fragment's $ID (LinkNote of a stored note) until then. MicroflowMutator.EditedInPlace feeds the report: `edited in place` vs `replaced with new element IDs`. A run of N changed statements pairwise of one plain kind is split into N one-for-one gaps","test":"mdl/backend/mfmutator/splice_edit_in_place_test.go (six kinds byte-exact, members by name, another kind/operation is new, notes, loop body, error handler); mdl/executor/flow_splice_edit_in_place_1761_pedapp_test.go (six kinds x microflow/nanoflow on the real codec, a control that a log for a filter is new, and PedApp's Studio Pro-authored PopulateUserAttributes edited in one run). With the carry stubbed out the engine and executor tests fail with new $IDs; measured on 10.24.24: unfixed 1ccaa1aa->1c24b792 for the activity, fixed all kept, mx check 0 errors","insight":"The identity carry has to happen where the pairing is known, and that is the splice, not canon: the patch writer is told the splice owns its $IDs (ContentsOwnElementIDs), so a fix that hopes Reconcile will re-pair finds it switched off on purpose. Pair by name before position — a member list paired by index hands a removed member's $ID to its neighbour, which is the GUID-class mistake one level down. And apply the carry late: the executor still names the fragment's $IDs after Replace returns (linkStoredNotes draws a stored note's line to the fragment's activity), so the stored $IDs go on at Bytes, where one pass rewrites every reference too. The report half matters as much: a count that prints 'replaced' for both outcomes is why the bug read as harmless; the test that a decision 'kept its IDs' and a filter did not was only visible in a BSON dump","refs":["ako/mxcli#1761","mendixlabs/mxcli#1667","ako/mxcli#1106","mendixlabs/mxcli#1489","ako/mxcli#888"]} diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-29-alter-set-targeting-noop-below-11-2.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-29-alter-set-targeting-noop-below-11-2.json new file mode 100644 index 0000000000..bc94ff2140 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-29-alter-set-targeting-noop-below-11-2.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-29", "symptom": "On Mendix 9.x to 11.1, `alter workflow M.W { set (Targeting: xpath […]) on Task; }`, and the deprecated `set activity 'Task' targeting …` form, printed 'Altered workflow' and changed nothing. describe showed the old targeting, and mx check stayed clean, because the old targeting was still valid", "cause": "Mutator.SetActivityProperty set the targeting with bsonnav.DSet(actDoc, \"UserTargeting\", …), and DSet only replaces a key that already exists. Below 11.2 a stored user task holds its targeting as UserSource, so no key matched, nothing changed, and the op returned nil", "file": "`mdl/backend/wfmutator/mutator.go` (setUserTargeting: replaces UserTargeting or UserSource in place, else appends; the write lowers it per version in canon.LowerToProjectShape)", "insight": "**A setter that only replaces an existing key is a silent no-op on a document in another version's shape.** bsonnav.DSet reports false, and every caller here ignored it. When a storage key is renamed at a version (ako/mxcli#1771: UserSource → UserTargeting at 11.2), grep the raw mutators for the new key: each one that DSets it is broken on the older versions, and the success message hides it. Have the mutator write the CURRENT shape in place of whichever key holds the fact, and let canon.LowerToProjectShape convert it. A second copy of the old shape in the mutator would be a second mapping to keep in step. **The control needs a pre-fix binary for the exact bug-test**: rebuilding with the committed file swapped in (go build -o scratch) showed 'Altered workflow' x2 with `[Name = 'admin']` kept on 9.24.30 and 10.24.24. Measured after the fix: 9.24.30 and 10.24.24 describe the new targeting, with 0 errors and the version's key set (Workflows$UserTask with UserSource on 9.24). 11.13.0 is unchanged. Tests: TestWorkflowMutator_SetTargetingReplacesAStoredUserSource and …AddsAMissingTargeting (both failed before the fix), TestNormalize_EditedMendix9UserTaskTargeting; mdl-examples/bug-tests/1771-alter-user-task-targeting.mdl", "refs": ["#1771"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-28-timeline-hidden-enumeration-switch-arms.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-28-timeline-hidden-enumeration-switch-arms.json new file mode 100644 index 0000000000..ee7743e728 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-28-timeline-hidden-enumeration-switch-arms.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-28","symptom":"A Timeline enumeration the widget hides, set to a non-default value — `customVisualization: true, groupByDayOptions: 'dayMonth'`, or `groupByKey: 'day', groupByMonthOptions: 'monthYear'` — passes `mxcli check` and `exec`, and mx check reports \"[CE0463] The definition of this widget has changed\" at Timeline 'tlD' / 'tlG'. `mxcli widget describe timeline` says \"2 of 6 editor hide-rules recognized\"; `groupEvents: false` plus a hidden value was already an MDL-WIDGET10 error (ako/mxcli#1762, mendixlabs/mxcli#1668)","cause":"Two editorConfig shapes went unread. terser folds the statements before a `switch` into its head — `switch(e.customVisualization ? hide([…]) : hide([…]), e.groupEvents || hide([…]), e.groupByKey)` — so the THEN branch's guard is bounded by the `switch(` paren, which parseGuard rejected as a conditional group (the else branch was lifted, through trailingExpr). And a hide in a `case`/`default` arm had no reading at all: its `case \"day\":` colon reached the ternary-else path and found no `?`. The template warnings the reporter saw in custom mode came from the hand-written fallback table in widget_defs.go, which listed only the TextTemplate keys of that same hide, on the stated premise that enumerations cannot cause CE0463","file":"mdl/executor/editorconfig_switch.go (switchArmCondition, switchArms, statementHeadParen, atFunctionStatementLevel), editorconfig_extract.go (the switch-arm branch before parseGuard; parseGuard's `(` boundary), validate_widgets.go (visibilityCondWord in/notin), cmd_pages_describe_pluggable.go (withoutHiddenDefaults), widget_engine.go (WidgetDefGeneratorVersion 20)","insight":"**A known rule elsewhere is not the extractor knowing it.** The custom-mode warning made rule (1) look recognised, and it was the reason to ask why the enumerations in the same list were missed: `widget describe` (extractor only) showed no `title` rule, while the generated .def.json had one, merged from the hand table. Compare the two sources first when a rule half-works. **The reporter's framing (`the true branch of a ternary`) was not the cause**: the existing TimelinePattern test already lifted a statement-level THEN branch, and only the minified file showed that terser had moved it into the `switch(` head. Read the shipped JS, not the de-minified source. **Measure the matrix, then make it the oracle**: one widget per variant through `mx check --no-update-widgets` on 11.13.0 gave 6 CE0463 and 3 clean, and the extractor test, the check test and the bug-test all encode those nine. **Policy follows the existing split, not a second one**: hidden + non-default is refused (MDL-WIDGET10 error, exec preflight) and hidden + left out is written at the default, as rule (3) and the column-SET reset (mendixlabs/mxcli#1519) already do. **Knowing more rules made describe noisy**: every Timeline hides one of its two formats, and the generic describe printed every stored primitive, so its own output drew MDL-WIDGET10 warnings. describe now drops a hidden property at its default, using the same registry rules the check reads. **Bound the blast radius by dumping lifted rules for every installed .mpk before and after**: only Timeline and HeatMap changed. HeatMap folds into an `if(` head the same way, and its new rule is correct. **The scratchpad can be shared between agents**: work in a private subdirectory","refs":["ako/mxcli#1762","mendixlabs/mxcli#1668","ako/mxcli#258","mendixlabs/mxcli#1583"],"ce":["CE0463"],"rules":["MDL-WIDGET10"],"test":"mdl/executor/editorconfig_switch_1762_test.go; validate_timeline_hidden_1762_test.go; cmd_pages_describe_timeline_hidden_1762_test.go; cmd/mxcli/exec_preflight_timeline_1762_test.go; mdl-examples/bug-tests/1762-timeline-hidden-enumeration.mdl"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-29-alter-loop-find-over-association-list-by-expression.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-29-alter-loop-find-over-association-list-by-expression.json new file mode 100644 index 0000000000..266f7ebcb7 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-29-alter-loop-find-over-association-list-by-expression.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-29", "symptom": "ako/mxcli#1769 (mendixlabs/mxcli#1673, 11.12.4): `alter microflow … { replace loop $Lookup in $Lookups with begin loop … begin $Line = find $Lines by Code = $Lookup/Code; … end loop; end; }`, $Lines retrieved over an association before the loop, stored the find as Microflows$FindByExpression `Code = $Lookup/Code`. check --references and exec reported nothing (\"Altered microflow\"), describe printed `find $Lines where Code = …`, mx check failed CE0117. create microflow writes Microflows$Find on MyModule.OrderLine.Code; the same find inserted on the main line was refused (#891's guard). `replace body of loop` had the same hole.", "cause": "Two. (1) #891's flowBuilder.qualifiedMembersOnly is set on the fragment's builder, but the loop, while and error-handler bodies are built by fresh flowBuilders whose fields each site listed by hand, and none listed the flag, so every member a nested body names (find/filter by member, sort, aggregate, change) at any depth was written bare. (2) alterFlowContext.storedVariables, the variables a fragment starts from, typed parameters, declares, creates, database retrieves and calls but left a retrieve over an association 'Unknown' (direction was the stated reason), so the member could not be qualified at all. Stored loop iterators were not typed either.", "file": "mdl/executor/cmd_microflows_builder.go, mdl/executor/cmd_microflows_builder_control.go, mdl/executor/cmd_microflows_builder_flows.go, mdl/executor/cmd_alter_flow.go, mdl/executor/cmd_microflows_builder_actions.go", "fix": "flowBuilder.nestedBuilder is the one constructor for loop, while and error-handler bodies and inherits qualifiedMembersOnly with the rest of the shared state (the handler still adds returnType and labelReg). storedVariables types a retrieve over an association from its start variable through flowBuilder.associationRetrieveType (lookupAssociation + entityIsSubtypeOf + associationRetrieveYieldsList, the answer the full build and check take: from the TO end the list of the FROM entity, from the FROM end the TO object, a reference set a list either way, owner Both one object) and a loop's iterator from its list, to a fixed point so candidate order does not matter. A self-reference, an unknown association, or a name two loops or retrieves give different origins stays untyped and its member is refused.", "test": "mdl/executor/cmd_microflows_builder_nested_test.go TestNestedBuilder_InheritsSharedState (every flowBuilder field classified inherited or own, checked against nestedBuilder by reflection); flow_splice_nested_guard_test.go TestSpliceFragment_NestedBodiesRefuseUnqualifiedMembers (find/filter/sort/aggregate/change in loop, while, handler, loop in loop, handler in loop, while in handler, if in loop; typed control); flow_splice_assoc_retrieve_test.go (both directions, reference set, one-to-one, chain out of order, iterator, self-reference, ambiguous iterator); flow_splice_nested_guard_1769_pedapp_test.go (PedApp, real codec: replace loop, replace body, insert in body, insert on main line all write Microflows$Find; over an untyped list all four refused with nothing written; describe->exec Unchanged). Controls: flag left out of nestedBuilder -> every nested shape written and the structural test names the field; storedVariables reverted -> the reported shape refused; ambiguity guard off -> $ItOrder typed. Platform 11.13.0: unfixed CE0117 (1 error); fix (1) alone refused, exit 1, stored flow byte-identical; fixed Microflows$Find on MyModule.OrderLine.Code, mx check 0 errors, describe->exec Unchanged.", "insight": "A guard set on a builder is only as good as the places that construct builders: grep `&flowBuilder{` before trusting a flowBuilder flag, and where a field is copied by hand at several sites, replace the copies with one constructor and a test that makes every field be classified. The reported statement was not the blast radius: every member-writing statement in every nested body, and `replace body of`, had it. Separately, an unqualifiable member is the symptom of an untyped variable; read what the fragment's starting scope types (storedVariables) before adding another refusal. Check reports a fragment-builder refusal as 'not predicted' with the reason, not MDL090, as it does #891's main-line refusal.", "ce": ["CE0117"], "refs": ["ako/mxcli#1769", "mendixlabs/mxcli#1673", "ako/mxcli#891", "ako/mxcli#885"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-29-consecutive-error-handler-rows-overlap.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-29-consecutive-error-handler-rows-overlap.json new file mode 100644 index 0000000000..aff06834ab --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-29-consecutive-error-handler-rows-overlap.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-29","symptom":"Two consecutive activities with custom error handlers and no @position drew their handler rows on top of each other: the first handler's end event and the second handler's first activity both at 680;330 ($First 360;200, $Body 520;200, $Second 680;200). layout flows --dry-run said 'already laid out' (same builder), MPR008 said nothing (end events not compared), mx check 0 errors. A @position on a later statement also moved every unplaced statement before it onto that statement.","cause":"addErrorHandlerFlow built the row 130px under the activity, running right, but never reserved the lower lane, while clearLowerLane already made the next activity with a handler wait for a reserved lane (the #1154 guard design); measureStatements likewise reserved only for guards and left handler rows out of the extent that fitContainerSize counts, so a loop with a long trailing row was built 110px wider than measured. Case 2: buildFlowGraph's start pre-scan took the first statement WITH an @position instead of the first statement.","file":"mdl/executor/layout_lanes.go (handlerRowWidth, reserveHandlerRow), mdl/executor/layout.go (measureStatements), mdl/executor/cmd_microflows_builder_flows.go (addErrorHandlerFlow), mdl/executor/cmd_microflows_builder_graph.go (start pre-scan), mdl/linter/rules/mpr008_overlapping_activities.go","insight":"The lane model already stated the rule ('an activity with an error handler waits for the lane'); one of the lane's two occupants never registered itself. Look for the occupant that does not reserve before inventing a mechanism. Reserve from the measurer, in builder and measurer alike: the builder-only half (control) left 12 shapes with a merge landing on an activity. Studio Pro reference: all 8 multi-object handlers in PedApp and ako/TestApp are COLUMNS straight under the activity (80-130px per step), none is a row and none has a consecutive pair; a column layout would make the collision impossible but moves every flow with a 2+ statement handler and needs a vertical layout for compound handler bodies, so the lane was kept (2 of 182 example flows move). Two older measurer gaps surfaced by the new invariant test and were left alone: an IF is measured 45px short (the split's centre is counted as its left edge in guardBranchInset and measureIfStatement), and a `join` is measured as an activity plus an end event although it draws nothing, so a handler row ending in joins reserves about twice its drawn width. Check overlaps with the whole node set: MPR008 skipped events, which hid exactly this overlap; on 292 Studio Pro flows the wider set adds 0 findings.","refs":["ako/mxcli#1768","mendixlabs/mxcli#1672"],"test":"TestSecondErrorHandlerRowClearsTheFirst, TestErrorHandlerRowsNeverOverlap, TestLoopAroundHandlerRowsIsMeasuredAsBuilt, TestStatementsBeforeAPlacedOneKeepTheirDerivedPlaces, TestOverlapComparesEvents"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-29-user-task-features-gated-by-version.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-29-user-task-features-gated-by-version.json new file mode 100644 index 0000000000..f65e83190f --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-29-user-task-features-gated-by-version.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-29", "symptom": "`mxcli check -p` passed scripts with user-task features the project's version cannot store, and exec wrote them: group targeting below 11.2 (CE1859 on 10.24, the targeting emptied by mx convert), and multi-user task rules and `await all users` below the version that introduced them. Once the user task was lowered to its pre-10.12 shape, these were refused only at write time, part-way through a script", "cause": "statementVersionGates had no gate for anything inside a workflow user task. The generated version data floors properties, never types, and the rule types (MajorityCompletionCriteria, ThresholdCompletionCriteria 10.0; MicroflowCompletionCriteria 10.3), the group targetings (11.2) and the multi-user task itself (UserTaskCompletion 9.22) are type-level introductions", "file": "`mdl/executor/version_gates.go` (userTaskGates, called from the CreateWorkflowStmt and AlterWorkflowStmt cases); `sdk/versions/mendix-9|10|11.yaml` (workflows.multi_user_task 9.22.0, multi_user_task_majority_threshold 10.0.0, multi_user_task_await_all 10.2.0, multi_user_task_microflow_rule 10.3.0, group_targeting 11.2.0)", "insight": "**A write-time refusal is the last line, not the check.** canon.LowerToProjectShape refuses what the older shape cannot hold, but by then the statements before it are written. Each such refusal wants a matching MDL-VERSION01 gate, so `check -p` reports it and exec refuses the script before writing anything ('Refusing to execute: 4 error(s) above. Nothing was written.' on 9.24.30). **Gate the clause, not the statement**: a workflow with a consensus or veto multi-user task is fine from 9.22, so the gates walk the activities (create, and every activity alter workflow inserts) and fire only for a rule, a targeting kind or `await all users` the version lacks. **Read the type floors from mendixmodelsdk's StructureVersionInfo** (gen/workflows.js; text-extract it, never execute it). The generated version.go has no row for a type. Measured with the bug-test: check -p on 9.24.30 reports WF_Majority (10.0), WF_Await (10.2), WF_Microflow (10.3) and WF_Groups (11.2). 10.24.24 reports WF_Groups only. 11.13.0 reports none, exec writes all five, and mx check gives 0 errors. Control: TestCheckVersionGates_UserTaskFeatures failed for every case before the gates. Tests: TestCheckVersionGates_UserTaskFeatures, …AtTheirFloors; mdl-examples/bug-tests/1771-user-task-features-by-version.mdl", "refs": ["#1771"]} diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-10-28-parallel-split-marker-below-10-21.json b/.claude/skills/fix-issue/findings/modelsdk/2026-10-28-parallel-split-marker-below-10-21.json new file mode 100644 index 0000000000..56b20bd0e6 --- /dev/null +++ b/.claude/skills/fix-issue/findings/modelsdk/2026-10-28-parallel-split-marker-below-10-21.json @@ -0,0 +1 @@ +{"area": "modelsdk", "date": "2026-10-28", "symptom": "On Mendix 9.x and 10.0–10.20 a workflow with a `parallel split` made the project unloadable: exec reported \"Created workflow\", then mx check (9.24.30, 10.12.19) died with `TypeCacheUnknownTypeException: The type cache does not contain a type with qualified name Workflows$EndOfParallelSplitPathActivity`", "cause": "The builder (workflows.EndParallelSplitPath) and the ALTER mutator (InsertPath) end every path with Workflows$EndOfParallelSplitPathActivity, which 10.21+ needs to run the path. The TYPE is introduced in 10.21.0 (mendixmodelsdk: EndOfParallelSplitPathActivity.versionInfo). The generated version data holds property floors only, so neither the floor nor any gate ever compared a written $Type with the project's version", "file": "`modelsdk/canon/versionshape.go` (LowerToProjectShape: dropEndOfPathMarkers on every Workflows$Flow below 10.21, endOfParallelSplitPathSince)", "insight": "**For a type newer than the project, find the older shape in a Marketplace SAMPLE, not a module**: `mxcli marketplace search workflow` lists Type=Sample apps whose .mpk holds a whole Studio Pro-authored .mpr; \"Example project workflow extension\" 2.0.0 (117680, Studio Pro 9.4.0) has a parallel split whose ParallelSplitOutcome is `{$ID,$Type,Flow}` and whose paths hold their activities and nothing else — exactly what the lowering produces. Workflow Commons, Employee Onboarding (9.1.1) and Multilevel Dynamic Workflow (9.24.3) hold workflows but no split. **The convert-up oracle does not reconstruct writer-only markers**: `mx convert -p` 10.24.24 of the lowered 9.24 project added 16 PersistentIds, BoundaryEvents and OnWorkflowEvent but no end-of-path marker, and 10.24's mx check is clean on it — so it cannot tell you whether 10.21+ needs the marker (the 11.14 runtime measurement in workflows.EndParallelSplitPath does), and a Studio Pro 9 split upgraded that way is unmarked too. Lower strictly below 10.21. **Lower, don't gate**: the older shape is exact and measured, unlike the published OData service, which is a different document. **Audit what else the writers emit against the SDK's type-level `introduced`** (a text extractor over gen/*.js — never execute it): the same run found user tasks written as Workflows$SingleUserTaskActivity (10.12; Studio Pro 9 stores Workflows$UserTask — 9.24 TypeCacheUnknownTypeException) and user targeting under UserTargeting (11.2, declared on the abstract UserTaskActivity so the floor misses it): on 10.12.19 and 10.24.24 a `targeting xpath […]` user task is CE1859 \"XPath constraint is required\", and mx convert 10.12 rewrites it to UserSource with the constraint EMPTIED. Not fixed here. Measured: bug-test on fresh 9.24.30 and 10.12.19 apps → before TypeCacheUnknownTypeException, after 0 errors, mx convert changes no key; describe → exec on 9.24 \"Unchanged workflow\"; 10.24.24 and 11.13.0 keep 4 markers, 0 errors, key sets identical before/after. Control: stubbing the lowering fails TestNormalize_ParallelSplitPathsBelowMendix10_21 with the marker on 9.24 and 10.20. mdl-examples/bug-tests/1163-parallel-split-below-10-21.mdl", "refs": ["#1163"]} diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-10-28-widget-return-type-and-group-box-rows-misattributed.json b/.claude/skills/fix-issue/findings/modelsdk/2026-10-28-widget-return-type-and-group-box-rows-misattributed.json new file mode 100644 index 0000000000..46deef833c --- /dev/null +++ b/.claude/skills/fix-issue/findings/modelsdk/2026-10-28-widget-return-type-and-group-box-rows-misattributed.json @@ -0,0 +1 @@ +{"area": "modelsdk", "date": "2026-10-28", "symptom": "On Mendix 9.0–9.19 every pluggable widget's schema lost the Type of its return types and kept an AssignableTo the version does not have (49 return types across the embedded templates); a CustomWidgets$WidgetReturnType reaching the floor there was refused: `this Mendix 9.18.0 project cannot store CustomWidgets$WidgetReturnType.Type (Mendix 9.20.0+)`. Forms$GroupBox had caption at 8.10 and no row for headerMode", "cause": "The vendored generator (modelsdk/gen/*/version.go) gave a property the row of the NEXT property in the SDK's versionInfo and left that one out, as it did Microflows$AggregateAction.aggregateFunction: mendixmodelsdk 4.117.0 has WidgetReturnType `type: {}`, `assignableTo: {introduced 9.20.0}` and pages.GroupBox `caption: {}`, `headerMode: {introduced 8.10.0}`. canon.PropertyDeclared reads the same table, and widgetTypeForVersion uses it to drop undeclared keys from the embedded 11.6 widget schema, so the wrong row dropped the right key", "file": "`modelsdk/gen/customwidgets/version.go`, `modelsdk/gen/pages/version.go` (fixed by hand), `modelsdk/version/metamodel_versions_gen.go` (go generate ./modelsdk/version)", "insight": "**Compare the whole table with the SDK, then read each hit's neighbour**: a text extractor over the SDK's gen/*.js StructureVersionInfo (map Pages$ to Forms$ for storage names; never execute the SDK) found 50 differences; most are SDK data newer than the vendored generator (11.11–11.16 rows it lacks), and five are this misattribution, always 'property gets its neighbour's version, neighbour missing': AggregateAction.aggregateFunction, WidgetReturnType.type, GroupBox.caption, and on Deleted LayoutGridRow.columns (← class, 8.0 — the row propertyfloor.go cites as why generated deletions are not trusted) and DynamicImageViewer.showAsThumbnail (← onClickBehavior, 7.18), plus a never-looked-up SDK-named duplicate row Microflows$AggregateListAction. The Deleted ones and the duplicate change nothing the floor does and were left. Workflows$CallMicroflowTask.boundaryEvents was NOT a mismatch (10.14.0 on both sides): the earlier list's fourth row was a false positive. **Check which BSON key a row is looked up by before predicting its effect**: the floor keys by the lower-cased BSON key, and a group box's caption is stored as CaptionTemplate, so the wrong caption row never applied to anything; headerMode's 8.10 is below every version mxcli writes — the GroupBox fix corrects the data and changes no write. **Studio Pro-authored bytes settle a row faster than a platform run when no mxbuild of that version is cached**: Marketplace samples carry whole apps — Employee Onboarding (9.1.1) stores {EntityProperty,IsList,Type} on 208 of 208 return types, Example project workflow extension 2.0.0 (9.4.0) on 326 of 326, Multilevel Dynamic Workflow (9.24.3) adds AssignableTo on 57 of 57. For boundaryEvents the oracle was a floor-off build on 10.12.19: mxcli then writes `BoundaryEvents: [2]` on every call, mx check passes, mx convert 10.12.19 strips it — the existing floor's drop-empty is what keeps the write clean, and a stated boundary event is refused. Tests: TestTemplateWidgetReturnTypeKeysAcrossMendix9_20 (fails on the old row with 49 wrong key sets at 9.18), TestNormalize_WidgetReturnTypeAcrossMendix9_20, TestPropertyDeclared_RowsTheGeneratorMisattributed, TestNormalize_CallMicroflowBoundaryEventsBelowMendix10_14 (control: removing the row fails it). Seen in passing, not fixed: on 9.24.30 a page with a slider or a data grid 2, written by mxcli, stops mx check (and its update-widgets step) with `NullReferenceException at WidgetValue.IsDefault() (WidgetValue.cs:879)`, with or without these fixes; 10.24.24 and 11.13.0 check the same pages at 0 errors. Bug-test: mdl-examples/bug-tests/1163-widget-return-type-below-9-20.mdl", "refs": ["#1163"]} diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-10-29-user-task-targeting-below-11-2.json b/.claude/skills/fix-issue/findings/modelsdk/2026-10-29-user-task-targeting-below-11-2.json new file mode 100644 index 0000000000..fd5df29998 --- /dev/null +++ b/.claude/skills/fix-issue/findings/modelsdk/2026-10-29-user-task-targeting-below-11-2.json @@ -0,0 +1 @@ +{"area": "modelsdk", "date": "2026-10-29", "symptom": "On Mendix 10.12 to 11.1 (measured on 10.12.19 and 10.24.24) every user task mxcli wrote failed mx check with CE1859 \"XPath constraint is required\". That included tasks targeted by a microflow, and multi-user tasks. `mxcli check` and `exec` were clean, and `mx convert -p` 10.24.24 rewrote each task with an empty XPathBasedUserSource, so the targeting was silently lost", "cause": "userTaskToGen writes the targeting as UserTargeting (XPathUserTargeting / MicroflowUserTargeting / NoUserTargeting), the 11.2 property. Before 11.2 a user task stores UserSource (XPathBasedUserSource / MicroflowBasedUserSource / EmptyUserSource) (mendixmodelsdk: UserTaskActivity.userSource deleted, userTargeting introduced 11.2.0). Both are declared on the abstract Workflows$UserTaskActivity, and the floor looks up the concrete type only, so it never saw them. 10.24 loads the task and ignores the unknown key, then fills the required UserSource with its default, an XPath source with no constraint", "file": "`modelsdk/canon/versionshape_usertask.go` (lowerUserTargeting, userSourceOf), hooked in `modelsdk/canon/versionshape.go` (LowerToProjectShape: userSource below 11.2)", "insight": "**Read a version's ERROR as a read of the default, not of what was written.** CE1859 named the XPath constraint even on microflow-targeted tasks. 10.24 had dropped the whole key and read the required property's default, an XPath source. So 'XPath constraint is required' on a task with no XPath points at the property holding it, not the constraint. **The convert chain is the oracle for a renamed property**: Marketplace samples (Employee Onboarding 117785, 9.1.1 XPath; Multilevel Dynamic Workflow 221741, 9.24.3 microflow) converted 9.1.1 → 9.24.30 → 10.24.24 → 11.13.0 keep the targeting's $ID and value byte for byte and change only $Type and the holding key. So the lowering is a rename. 9.1.1 does not convert straight to 10.24 ('version not supported'), so go through 9.24.30 first. Converting this fix's 10.24 output up to 11.13 turns EmptyUserSource into NoUserTargeting, which confirms the third pair. Workflow Commons (2.6.0, 3.7.0, 3.8.0) holds no workflow at all. Group targeting has no older form and is refused naming the task. Measured: bug-test on a fresh 10.24.24 app, before CE1859 x3 and after 0 errors. `mx convert -p` 10.24.24 changes no key and no value. describe → exec prints 'Unchanged workflow'. 11.13.0 gives 0 errors with the same key sets before and after. Control: stubbing the lowering fails TestNormalize_UserTaskTargetingBelowMendix11_2 24 times with UserTargeting still on the task. Tests: TestNormalize_UserTaskTargetingBelowMendix11_2, TestNormalize_RefusesGroupTargetingBelowMendix11_2, TestWorkflowRead_UserTaskTargetingInTheMendix10Shape; mdl-examples/bug-tests/1771-user-task-targeting-below-11-2.mdl", "refs": ["#1771"], "ce": ["CE1859"]} diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-10-29-user-task-type-below-10-12.json b/.claude/skills/fix-issue/findings/modelsdk/2026-10-29-user-task-type-below-10-12.json new file mode 100644 index 0000000000..137796213e --- /dev/null +++ b/.claude/skills/fix-issue/findings/modelsdk/2026-10-29-user-task-type-below-10-12.json @@ -0,0 +1 @@ +{"area": "modelsdk", "date": "2026-10-29", "symptom": "On Mendix 9.x a workflow with a user task made the project unloadable. exec reported 'Created workflow', then mx check 9.24.30 and Studio Pro died with `TypeCacheUnknownTypeException: The type cache does not contain a type with qualified name Workflows$SingleUserTaskActivity`. The same happened on 10.11.0 (measured), and on all of 10.0–10.11 by the SDK's version data", "cause": "userTaskToGen (and the ALTER mutator's inserts, through it) writes Workflows$SingleUserTaskActivity / MultiUserTaskActivity, TYPES introduced in 10.12.0, which also deleted Workflows$UserTask (mendixmodelsdk 4.117.0). The generated version data holds property floors only, so nothing compared the written $Type with the project's version. Before 10.12 the kind of task is the UserTask's UserTaskCompletion (9.22): SingleInputCompletion, or MultiInputCompletion {CompletionCriteria, TargetUserInput, AwaitAllUsers from 10.2}", "file": "`modelsdk/canon/versionshape_usertask.go` (lowerUserTaskType, dropTaskBoundaryEvents, userTaskActivitySince, completionCriteriaSince), `mdl/backend/modelsdk/workflow_read.go` (UserTask case reads the MultiInputCompletion), `mdl/executor/validate_workflow_rewrite.go` (rawMultiUserTaskSettings sees a 9.x multi-user task)", "insight": "**A type rename can also be a restructure.** Converting MultiUserTaskActivity down is more than a $Type swap. Three of its keys move into a nested MultiInputCompletion, and the outcomes stay on the task, so the CompletionCriteria's outcome pointers still resolve. Studio Pro 9.24.3's key set is the target: Multilevel Dynamic Workflow (Marketplace 221741) stores `$ID,$Type,Annotation,AutoAssignSingleTargetUser,Caption,DueDate,Name,OnCreatedEvent,Outcomes,TaskDescription,TaskName,TaskPage,UserSource,UserTaskCompletion`. **With no Studio Pro sample of a 9.x multi-user task, convert UP as the oracle.** The 9.24 output, after `mx convert -p` 10.24.24, has user tasks identical in keys and values to what mxcli writes on 10.24 directly, rule and participants included. **Every reader of the old shape needs the restructure too.** describe read a 9.x multi-user task as a single one, and the create-or-modify guard looked for the rule only on MultiUserTaskActivity, so a 9.24 rewrite would reset the rule silently. **Give an element the lowering ADDS a derived $ID** (completionID from the task's $ID), so the same task lowers to the same bytes. The boundary events on a user task (10.14, declared on the abstract UserTaskActivity) slipped past the floor the same way: an empty list is dropped below 10.14, and a boundary event is refused. Measured: bug-test on fresh apps. 9.24.30 before TypeCacheUnknownTypeException, after 0 errors. `mx convert -p` 9.24.30 changes no key and no value. describe → exec 'Unchanged workflow'. 10.24.24 and 11.13.0 give 0 errors. The CE2513 'Java 11' after converting up is in an untouched 9.24 app too. 10.11.0.37074 (downloaded for this, then deleted): before (lowering stubbed) TypeCacheUnknownTypeException, which confirms the SDK's 10.12 type boundary on the platform. After, 0 errors with AwaitAllUsers in the MultiInputCompletion, mx convert 10.11 changes nothing, and describe → exec 'Unchanged workflow'. Majority, await all users and a microflow rule written on 10.11 check clean. Control: stubbing lowerUserTaskType fails TestNormalize_UserTaskIsAWorkflowsUserTaskBelowMendix10_12, …MultiUserTask… and …RefusesWhatAnOlderUserTaskCannotHold with the 10.12 types on 9.24. The reader and guard tests failed before their fixes. Tests: those three, TestNormalize_UserTaskCompletionIDIsStable, TestWorkflowRead_UserTaskInTheMendix9Shape, TestWorkflowRewrite_SeesAMendix9MultiUserTasksSettings; mdl-examples/bug-tests/1771-user-task-below-10-12.mdl", "refs": ["#1771", "#1163"]} diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index 8e3fccb1e0..9cc999c031 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -69,7 +69,7 @@ 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 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 + patched in place (a move keeps the node's flows; an activity changed into one of the same kind — an expression, a `where`, a member value, an argument — keeps its `$ID` and its action's, reported `edited in place`), 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)); a decision `alter … insert` put in is taken out by a re-run, `drop` or `replace` ([pitfalls](reference/pitfalls.md#15-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)). diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index 3d812a9a35..8f1cd83428 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -305,8 +305,9 @@ 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 +loop moves along. Only the new activities get new `$ID`s — one replaced by the same +kind of activity is the stored one edited and keeps its `$ID` and its action's +(ako/mxcli#1761); 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. diff --git a/.claude/skills/mendix/write-nanoflows/SKILL.md b/.claude/skills/mendix/write-nanoflows/SKILL.md index 6740be6af0..e035dd784a 100644 --- a/.claude/skills/mendix/write-nanoflows/SKILL.md +++ b/.claude/skills/mendix/write-nanoflows/SKILL.md @@ -11,21 +11,21 @@ description: "Nanoflow syntax in MDL — shared with microflows, but client-side | lines | section | |---|---| | 35-44 | When to Use This Skill | -| 45-81 | Changing an Existing Nanoflow | -| 82-100 | When to Use a Nanoflow vs a Microflow | -| 101-115 | Key Differences from Microflows | -| 116-139 | Nanoflow Structure | -| 140-148 | Naming Convention | -| 149-217 | Supported Activities | -| 218-484 | Real-World Patterns | -| 485-555 | NanoflowCommons JavaScript Actions Reference | -| 556-594 | Disallowed Activities | -| 595-598 | Return Type Restrictions | -| 599-643 | Error Handling in Nanoflows | -| 644-651 | Security (GRANT/REVOKE) | -| 652-664 | Management Commands | -| 665-675 | Common Mistakes | -| 676-690 | Validation Checklist | +| 45-83 | Changing an Existing Nanoflow | +| 84-102 | When to Use a Nanoflow vs a Microflow | +| 103-117 | Key Differences from Microflows | +| 118-141 | Nanoflow Structure | +| 142-150 | Naming Convention | +| 151-219 | Supported Activities | +| 220-486 | Real-World Patterns | +| 487-557 | NanoflowCommons JavaScript Actions Reference | +| 558-596 | Disallowed Activities | +| 597-600 | Return Type Restrictions | +| 601-645 | Error Handling in Nanoflows | +| 646-653 | Security (GRANT/REVOKE) | +| 654-666 | Management Commands | +| 667-677 | Common Mistakes | +| 678-692 | Validation Checklist | # Mendix Nanoflow Skill @@ -52,7 +52,9 @@ Choose the mode by who owns the nanoflow ([choose-edit-mode](../choose-edit-mode (targets by output variable, caption or statement pattern). `create or modify` of `describe` output also works as a patch: an unchanged definition writes nothing, and an inserted, replaced or dropped statement (at the top level or in an `if` branch) is spliced in, - leaving every other node, merge and curve as stored — a guard clause (`if … then return; + leaving every other node, merge and curve as stored — a statement changed into one of + the same kind (an expression, a `where`, a member value, an argument) edits the stored + activity, which keeps its `$ID` and its action's (reported `edited in place`); a guard clause (`if … then return; end if;`) too, its return a new end event, and so is the `return` ending a new activity's error handler; a changed `return` value or `if` condition is set on the stored end event or decision, and a body without a trailing `return` means the stored end. The diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index da3b5719ea..6ea88b119b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -26,6 +26,7 @@ jobs: - '11.13.0' - '11.14.0' - '11.15.0' + - '11.16.0' fail-fast: false name: test (Mendix ${{ matrix.mendix-version }}) steps: diff --git a/.gitignore b/.gitignore index 5b15d750c9..0ae724663d 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,4 @@ snap-bson go-test-output.txt # MPR v2 unit-type index, written next to any project mxcli opens — including # the testdata/ fixtures tests open in place. -.mxcli/unit-types.idx +**/.mxcli/unit-types.idx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff88359ae..1084df4b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **User-task features an older Mendix cannot store are refused up front** (ako/mxcli#1771) — `mxcli check -p` and `exec` report MDL-VERSION01 before anything is written, naming the clause. The floors are group targeting (`targeting groups …`) 11.2, a multi-user task 9.22, `decide by majority` or `threshold` 10.0, `await all users` 10.2, and `decide by microflow` 10.3. Before, group targeting was written on 10.24 and failed `mx check` with CE1859. The others were refused only at write time, after the statements before them were written. Measured on 9.24.30 and 10.24.24 (each refused, nothing written) and on 11.13.0 (all written, 0 errors). +- **`alter workflow … set (Targeting: …)` changes a user task's targeting on Mendix 9 to 11.1** (ako/mxcli#1771) — it reported "Altered workflow" and changed nothing. Before 11.2 a user task stores its targeting under another key, and the edit only replaced a key that was already there. The edit now replaces the stored targeting, whichever key holds it, and the write stores it in the version's shape. Measured on 9.24.30 and 10.24.24: `describe` shows the new targeting, with 0 errors. 11.2 and later are unchanged. +- **A workflow with a user task no longer makes a Mendix 9 or 10.0–10.11 project unloadable** (ako/mxcli#1771) — `exec` reported success, then `mx check` and Studio Pro could not load the project at all: "TypeCacheUnknownTypeException: The type cache does not contain a type with qualified name Workflows$SingleUserTaskActivity". mxcli wrote a user task as a `SingleUserTaskActivity` or `MultiUserTaskActivity`, types introduced in 10.12. Before 10.12 both are a `Workflows$UserTask`, as Studio Pro 9.24 stores it. Its completion says which kind it is, and holds a multi-user task's rule, participants and, from 10.2, `await all users`. That is what is written now, for `create`, `create or modify` and a task `alter workflow` inserts. `describe` reads a multi-user task in that shape, and `create or modify` no longer resets its rule silently. What the older type cannot hold is refused, naming the task: a multi-user task below 9.22, `decide by majority` or `threshold` below 10.0, `decide by microflow` below 10.3, `await all users` below 10.2, and a boundary event on a user task below 10.14. Measured on 9.24.30 and 10.11.0: 0 errors, `mx convert` changes nothing, and `describe` → `exec` reports the workflow unchanged. Converted up to 10.24, the tasks are the ones mxcli writes there directly. 10.12 and later are unchanged. +- **A user task keeps its targeting on Mendix 10.12 to 11.1, including 10.24** (ako/mxcli#1771) — every user task mxcli wrote there failed `mx check` with CE1859 "XPath constraint is required", including tasks targeted by a microflow and multi-user tasks. `exec` and `mxcli check` were clean. mxcli stored the targeting as `UserTargeting`, which Mendix introduced in 11.2. The older versions read the task as having no targeting at all, and `mx convert` replaced it with an empty XPath constraint. Before 11.2 the same fact is a `UserSource` holding the same value, measured by converting Studio Pro-authored 9.x workflows up to 10.24 and 11.13. That is what is written now, for users by XPath, users by a microflow, and no targeting. Group targeting is 11.2 and is refused below it, naming the task. Measured on 10.24.24: 0 errors, `mx convert` changes nothing, and `describe` → `exec` reports the workflow unchanged. 11.2 and later are unchanged. +- **A Timeline enumeration the widget hides is refused when set to a non-default value, instead of failing the build** (ako/mxcli#1762, mendixlabs/mxcli#1668) — a hidden pluggable-widget property must hold its default, or mx check reports CE0463 "The definition of this widget has changed". `mxcli check` already refused this for the one Timeline hide rule it could read (`groupEvents: false`), but it read only 2 of the 6 (`widget describe timeline`: "2 of 6 editor hide-rules recognized"). So `customVisualization: true, groupByDayOptions: 'dayMonth'` and `groupByKey: 'day', groupByMonthOptions: 'monthYear'` passed `check` and `exec` and then failed mx check. The editorConfig's other rules sit in shapes the extractor did not read: a `switch` on `groupByKey` with `case`/`default` arms, and a ternary that terser folds into the `switch(…)` head, where the head's paren was taken for a conditional group. Both are read now, as are fall-through and grouped labels, and an `if(…)` head written the same way (HeatMap's `playground`). Timeline is 6 of 6 (3.1.1, 3.2.2 and 3.2.3), and each hidden enumeration gets the verdict `groupEvents` already had: an MDL-WIDGET10 **error** when set to anything but its default, which `exec` refuses before writing; a warning at its default; written at its default when left out. `describe` no longer prints a hidden property that holds its default, so its own output re-checks without warnings. MDL-WIDGET10 now says "is none of "day", "month"" for a default arm instead of "matches". The warning the reporter saw on hidden `title`/`description`/`timeIndication` came from a hand-written fallback table that listed only the text templates of that same hide. Measured on Mendix 11.13.0 with Timeline 3.2.2, one widget per variant: all six hidden variants were CE0463 before, all three controls 0 errors, and `check` now refuses exactly those six. The corrected pages build with 0 errors under `--no-update-widgets`, and describe → exec reports them unchanged. The File Uploader shape of ako/mxcli#258 (a ternary-wrapped `hidePropertiesIn` gating `createImageAction`/`createFileAction` on `uploadMode`) was already read via the else-if chain support of mendixlabs/mxcli#1583, and is now pinned by a test against File Uploader 2.5.0. +- **An activity edited by `create or modify` keeps its element IDs** (ako/mxcli#1761, mendixlabs/mxcli#1667) — re-running `create or modify microflow|nanoflow` with only a filter's expression changed replaced the activity: the activity, its action and its operation all got new `$ID`s, while a decision whose condition changed the same way kept its own. `check`, `mx check`, `diff` and `describe` all looked fine, but Studio Pro compares and merges by `$ID`, so it showed the filter as one activity removed and another added, and a teammate's edit of the same activity on another branch became a conflict. An activity replaced by one of the same kind (the same action) is now edited in place: it keeps its `$ID`, its place in the flow and its size, and so do its action and every element under it that pairs up — a member change by its attribute or association, an argument by its parameter, a nested element by its key and type. A removed member's `$ID` goes onto no other member; a different operation (a `find` where a `filter` was) is new. This covers a filter or find expression, a retrieve's `where`, change and create member values, call arguments and any other same-kind edit, at top level, in a loop body and on an activity with an error handler, and `alter … replace` by one activity of the same kind does the same. Several adjacent activities each changed in their own place are each edited so; before, the run was replaced as a whole. `exec` now tells the two outcomes apart: `spliced: 1 edited in place` for a node that kept its `$ID` (an activity, a decision's condition, a return's value), `replaced with new element IDs` for one that did not. +- **The error handlers of two consecutive activities no longer share a row** (ako/mxcli#1768, mendixlabs/mxcli#1672) — without `@position` a custom error handler is a row 130 px under its activity, running right, and the main line carries on one spacing after the activity however long that row is. When the next activity had a handler too, its row started at its own x, inside the first: two REST calls with three-element handlers stored the first handler's end event and the second handler's first activity both at 680;330. Neither check saw it — `layout flows --dry-run` re-derives with the same builder and called the flow already laid out, and MPR008 did not compare end events. A handler's row now occupies the lane under the main line the way a guard's branch already did, so whatever reaches down into that lane — the next activity with a handler, a decision, a loop — waits for it to clear, and a plain activity does not. The reported flow keeps `$First`, its row and `$Body` where they were and places `$Second` at 790;200, its row at 790..1110;330. The measurer counts the row too, so a loop or a branch holding handler rows is sized to them: a loop whose last handler row reached past its last activity was built 110 px wider than it was measured. Measured on 182 flows built from the `02*` and `06` doctype examples: 2 move — each an IF that followed a handler row lying under its split — and none gains an overlap; `mx check` 0 errors on 11.13.0 before and after. +- **A `@position` on a later statement no longer moves the statements before it onto it** (ako/mxcli#1768, mendixlabs/mxcli#1672) — `declare $A String = $Url; @position(840, 200) declare $B String = $A;` stored both declares at 840;200, and MPR008 reported the overlap. The start event is placed one spacing left of the first statement when that statement carries `@position`, but the builder took the first statement that had one, wherever it was, and the unplaced statements before it follow the start. Only the first statement moves the start now, as `alter`'s patch and the syntax help already assumed: the flow above starts at 200;200 with `$A` at 360;200 and `$B` at 840;200. The syntax help and the language reference now say what happens to the statements before a placed one. +- **MPR008 reports an activity drawn on an event** (ako/mxcli#1768, mendixlabs/mxcli#1672) — the overlap rule compared activities, loops, decisions and merges only, so an error handler's activity lying on another handler's end event at 680;330 passed, and with one more statement in that handler it reported one of the two overlaps, the one that happened to be activity on activity. Start, end, error, break and continue events and type splits are compared too, each with its stored size (an event that stores none is taken as Studio Pro's 20×20, not as an activity). On the 292 flows of the Studio Pro-authored PedApp and ako/TestApp fixtures, and on 182 flows built from the `02*` and `06` doctype examples, the wider rule adds no finding. +- **A find in a loop an `alter microflow` writes is stored as a find by attribute, not as a find by an invalid expression** (ako/mxcli#1769, mendixlabs/mxcli#1673) — `alter microflow … { replace loop … with begin … end; }` whose loop body held `$Line = find $Lines by Code = $Lookup/Code;`, with `$Lines` retrieved over an association before the loop, stored `Microflows$FindByExpression` with the bare text `Code = $Lookup/Code`: `check --references` and `exec` reported nothing, `describe` printed `find $Lines where …`, and mx check failed CE0117 "Error(s) in expression." `create microflow` stores the same find as `Microflows$Find` on `MyModule.OrderLine.Code`. Two causes. The guard that refuses a member a spliced fragment cannot qualify (ako/mxcli#891) applied on the fragment's main line only, not in a loop, while loop or error handler inside it, and so did not cover `replace body of loop` either: there any find, filter, sort, aggregate or change member was written bare. And the fragment did not know what a stored retrieve over an association holds, nor a stored loop's iterator. Both are now typed as `create microflow` types them: from the association's TO end a retrieve yields the list of its FROM entity, from the FROM end the one TO object. So the reported alter writes `Microflows$Find`, and what still cannot be typed (a self-reference, a list operation's output) is refused with nothing written, wherever the statement sits. Measured on Mendix 11.13.0: CE0117 before; `Microflows$Find` on `MyModule.OrderLine.Code`, mx check 0 errors, and describe → exec "Unchanged" after. - **A navigation profile's application title and icon can be set and are described** (ako/mxcli#1756, mendixlabs/mxcli#1664) — the title (Studio Pro's *Application title*, `Mendix` in a new project) is what the runtime puts in every browser tab as `Mendix - `, and it had no MDL form: `create or modify navigation Responsive app title 'EWO' …` failed with "extraneous input 'app'", `describe navigation` printed neither it nor the *Application icon*, and `create or modify translations … for en_US` refuses the source language, so every generated app kept "Mendix" in the tab. `create or modify navigation` now takes `app title ''` and `app icon ` (`app icon none` clears it), in any order with the other clauses; `app` and `icon` stay usable as names. The title is written as the project default language's item, the title's other languages kept; the icon is resolved like any image reference, by `check --references` and before exec writes; an omitted clause keeps the stored value; describe prints both. A native profile has neither, so both are refused there. - **A test whose earlier `@expect` fails is no longer reported PASS on the after-startup runner** (ako/mxcli#1753) — every Docker run and `--local --legacy-runner` logged a verdict line per assertion, and the result was taken from the last one: a failing first assertion followed by a passing second reported PASS, with the failure message erased, and `mxcli test` exited 0. A failure now sticks, and the test reports the first one, plus how many more failed. The endpoint runner (`--local`, `--attach`, `--watch`) was not affected. - **Two tests that assign the same variable with a lower-case `call` or `create` run on the after-startup runner** (found with ako/mxcli#1753) — that runner compiles every test into one microflow and renames each test's variables, but it recognised only an upper-case `CALL` or `CREATE`. `$r = call microflow …`, the spelling mdl 1 and `fmt` write, was left as it was, so two tests assigning `$r` declared it twice and injection failed with MDL063 (CE0111 from mxbuild). 8 of the 16 test files in `mdl-examples/` hit this under Docker or `--legacy-runner`. A variable first assigned by `retrieve`, `declare` or a list operation is still not renamed there. @@ -20,8 +30,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **A page passing `$currentObject` to a microflow or nanoflow is written on Mendix 9 and on 10.0–10.9** (ako/mxcli#1163) — `call microflow M.F(P = $currentObject)` on a button or a data grid column was refused with "cannot store Forms$MicroflowParameterMapping.Expression (Mendix 10.10.0+)". An argument held as an expression arrived in 10.10. Before it, Studio Pro stores no argument for the enclosing object and infers it. Without the refusal, 9.24 could not even load the page, because the mapping had no `Variable`. Below 10.10 that argument is now left out, and a page parameter argument is stored as before. Any other expression argument is refused, naming the parameter. - **A constant override in a configuration is written on Mendix 9 and on 10.0–10.8** (ako/mxcli#1163) — `alter settings constant @M.C value '…' in configuration 'Default'` was refused with "cannot store Settings$ConstantValue.SharedOrPrivateValue (Mendix 10.9.0+)". Before 10.9 an override's value is a flat `Value` string; Studio Pro 9.24 does not know the nested form and empties the override on load. A new override is now written flat there, and an existing one is updated in its own shape. A private value has no form before 10.9 and is refused, naming the constant. - **A workflow written to Mendix 9.24 has Studio Pro's property set** (ako/mxcli#1163) — its two state-change event handlers (`WorkflowOnStateChangeEvent`, `UsertaskOnStateChangeEvent`, removed in 11.0) were completed only from 10.24, where they were measured. `mx convert` 9.24.30 adds both as well, and Mendix's merge engine refuses to compare documents whose property names differ (#1373). -- **Workflows are written on Mendix 9 and on 10.0–10.20** (ako/mxcli#1163) — every workflow was refused with "cannot store Workflows$Workflow.PersistentId (Mendix 10.21.0+)". Workflows exist from 9.0, but the persistent ID on a workflow, its activities, outcomes and boundary events arrived in 10.21. The activities' and outcomes' IDs were never refused, and `mx convert` 9.24 stripped them. mxcli mints these IDs itself, so below 10.21 it no longer writes them, for `create`, `create or modify` and the activities `alter workflow` inserts. A `parallel split` is still unwritable below 10.21: its paths end in an activity type that version does not have. +- **Workflows are written on Mendix 9 and on 10.0–10.20** (ako/mxcli#1163) — every workflow was refused with "cannot store Workflows$Workflow.PersistentId (Mendix 10.21.0+)". Workflows exist from 9.0, but the persistent ID on a workflow, its activities, outcomes and boundary events arrived in 10.21. The activities' and outcomes' IDs were never refused, and `mx convert` 9.24 stripped them. mxcli mints these IDs itself, so below 10.21 it no longer writes them, for `create`, `create or modify` and the activities `alter workflow` inserts. +- **A workflow with a `parallel split` no longer makes a Mendix 9 or 10.0–10.20 project unloadable** (ako/mxcli#1163) — `exec` reported success, then `mx check` and Studio Pro could not load the project at all: "TypeCacheUnknownTypeException: The type cache does not contain a type with qualified name Workflows$EndOfParallelSplitPathActivity". mxcli ends every path with that marker, because from 10.21 the workflow engine runs a path only up to it, but the type itself arrived in 10.21. Before 10.21 Studio Pro stores a path as its activities and nothing more (measured on a Studio Pro 9.4 app). Below 10.21 the marker is no longer written, for `create`, `create or modify` and a path `alter workflow` inserts. Measured on 9.24.30 and 10.12.19: 0 errors. On 9.24, `describe` → `exec` reports the workflow unchanged. 10.21 and later are unchanged. - **Aggregates in microflows are written on Mendix 9** (ako/mxcli#1163) — every `count`, `sum`, `average`, `minimum` and `maximum` on a 9.x project was refused with "cannot store Microflows$AggregateAction.AggregateFunction (Mendix 10.0.0+)". The version data had the aggregate's function introduced in 10.0, which belongs to the fold `reduce` added. Studio Pro 9.24 stores it on every aggregate. An aggregate over an expression, `reduce`, `all` and `any` are Mendix 10.0 and are still refused below it, now naming the fold. +- **Pluggable widgets keep the `Type` of their return types on Mendix 9.0–9.19** (ako/mxcli#1163) — the version data mxcli vendors from the Model SDK had a return type's `Type` introduced in 9.20. That version belongs to `AssignableTo`, which the data left out. A widget schema written to a 9.0–9.19 project therefore lost the `Type` of every return type and kept an `AssignableTo` that version does not have. A return type that reached the property floor was refused with "cannot store CustomWidgets$WidgetReturnType.Type (Mendix 9.20.0+)". Studio Pro 9.1 and 9.4 store `Type` on every return type and no `AssignableTo`. The same mistake gave a group box's caption the 8.10 version of its header mode. That row changed nothing mxcli writes, because the caption is stored under another name and mxcli supports no version that old. - **Pages on Mendix 9.24 and 10.x are read and written in the version's own shape** (ako/mxcli#1209) — several page properties are stored differently below a version: a design property's value before 10.2, a data view's page parameter before 10.0, its editability before 10.7, and a text box's placeholder before 10.11. `describe` read only the newer form, so on 9.24 a describe → exec round trip removed every design property and placeholder. Writing any page with a data view or a design property was refused, and `alter styling` on a stored design property reported success and changed nothing. Every write now converts to the project's form, and `describe` reads both. What the older form cannot hold is refused, naming the widget: a Spacing or colour design property, conditional editability, a placeholder with parameters. - **A text box's `KeyboardType` survives describe → exec** (ako/mxcli#1209) — the keyboard a native app opens had no MDL spelling and was written as `Default`, so a rewrite reset it. `textbox t (Attribute: Email, KeyboardType: EmailAddress)` (`Default`, `NumberPad`, `DecimalPad`, `EmailAddress`, `PhonePad`, `URL`); an unknown value is MDL-WIDGET18. - **A button's workflow action survives a rewrite** (ako/mxcli#832) — `describe` printed no action for Show user task page, Show workflow admin page or Call workflow, so re-executing a page turned them into no action (on ako/TestApp's TaskInbox and TaskDashboard, 2 of 6 user-task buttons each). They are now `open user task [with (AssignOnOpen, OpenWhenAssigned)]`, `open workflow with (DefaultPage: M.Page)` and `call workflow M.WF [with (…)]`. `complete task` takes `with (ClosePage, Commit)` instead of always writing both true. `open workflow` without a page is CE1834 and is refused. diff --git a/cmd/mxcli/exec_preflight_timeline_1762_test.go b/cmd/mxcli/exec_preflight_timeline_1762_test.go new file mode 100644 index 0000000000..e948758ed6 --- /dev/null +++ b/cmd/mxcli/exec_preflight_timeline_1762_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/deprecation" + "github.com/mendixlabs/mxcli/mdl/executor" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// ako/mxcli#1762 (mendixlabs/mxcli#1668), the reporter's script. Reported: check +// and exec both pass, and mx check says "[CE0463] The definition of this widget +// has changed" at Timeline 'tlD' (groupByDayOptions, hidden in custom mode) and +// 'tlG' (groupByMonthOptions, hidden when groupByKey is 'day'). exec must refuse +// it before writing anything, naming both — as it already did for the one +// Timeline rule it knew, `groupEvents: false`. +const timeline1762Setup = `create module ModTL; +create non-persistent entity ModTL.Ev ( Line: String(200), WhoText: String(200), WhenText: String(50), At: DateTime ); +create microflow ModTL.DS_Events () returns List of ModTL.Ev as $Events +begin + $Events = create list of ModTL.Ev; + $E = create ModTL.Ev ( Line = 'Created', WhoText = 'someone', WhenText = '10:00', At = [%CurrentDateTime%] ); + add $E to $Events; + return $Events; +end; +` + +func timeline1762Pages(dayOptions, monthOptions string) string { + return `create page ModTL.PageD ( Title: 'D', Layout: Atlas_Core.Atlas_Default ) { + timeline tlD ( + data: MICROFLOW ModTL.DS_Events, + customVisualization: true, groupEvents: true, groupAttribute: At, + groupByKey: 'day', ` + dayOptions + `ungroupedEventsPosition: 'end' + ) { + customtitle d2 { dynamictext d2t (Content: '{1}', ContentParams: ({1} = Line)) } + } +}; +create page ModTL.PageG ( Title: 'G', Layout: Atlas_Core.Atlas_Default ) { + timeline tlG ( + data: MICROFLOW ModTL.DS_Events, + title: '{1}', titleParams: ({1} = $currentObject/Line), + customVisualization: false, groupEvents: true, groupAttribute: At, + groupByKey: 'day', ` + monthOptions + `ungroupedEventsPosition: 'end' + ) +}; +` +} + +func TestExecPreflightRefusesHiddenTimelineEnumerations(t *testing.T) { + src := filepath.Join("..", "..", "testdata", "expr-checker") + if _, err := os.Stat(filepath.Join(src, "minimal.mpr")); err != nil { + t.Skipf("expr-checker fixture not found: %v", err) + } + dir := t.TempDir() + if err := os.CopyFS(dir, os.DirFS(src)); err != nil { + t.Fatal(err) + } + mpr := filepath.Join(dir, "minimal.mpr") + exec := executor.New(io.Discard) + 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{}) }) + + preflight := func(script string) (string, string) { + t.Helper() + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatal(errs[0]) + } + var out bytes.Buffer + refusal := execPreflight(exec, prog, mpr, "s.mdl", false, false, false, deprecation.Warn, &out, false) + return refusal, out.String() + } + + refusal, out := preflight(timeline1762Setup + + timeline1762Pages("groupByDayOptions: 'dayMonth', ", "groupByMonthOptions: 'monthYear', ")) + if refusal == "" || !strings.Contains(refusal, "Nothing was written") { + t.Fatalf("want the pre-flight to refuse the script, got %q\n%s", refusal, out) + } + for _, want := range []string{"`tlD`", "`groupByDayOptions`", "`tlG`", "`groupByMonthOptions`"} { + if !strings.Contains(out, want) { + t.Errorf("pre-flight output must name %s:\n%s", want, out) + } + } + + // CONTROL: the same pages without the hidden values (variant b of the + // report, which mx check passes) run. + if refusal, out := preflight(timeline1762Setup + timeline1762Pages("", "")); refusal != "" { + t.Errorf("the pages without hidden values were refused: %s\n%s", refusal, out) + } +} diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index 1f12a16bf2..505b2bcde6 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -624,6 +624,12 @@ 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" + + "-- REPLACE by one activity of the same kind (the same action: a filter's expression, a\n" + + "-- retrieve's where, a member's value, a call's argument changed) edits the stored\n" + + "-- activity: it keeps its $ID and those of its action and the parts that pair up — a\n" + + "-- member by its attribute or association, an argument by its parameter (ako/mxcli#1761).\n" + + "-- CREATE OR MODIFY reports it as `edited in place`; anything else replaced gets new\n" + + "-- element IDs, reported as `replaced with new element IDs`.\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" + @@ -926,11 +932,16 @@ func init() { "right and wraps onto a new row past 2880px; the line joining two rows leaves\n" + "the bottom of one and arrives on top of the next. A guard (if … return; end\n" + "if) drops its branch into the lane below and the main line carries straight\n" + - "on over it. A CASE of four or more branches leaves the split in three groups\n" + - "— top, right, bottom — so its lines do not cross. A statement that carries\n" + - "@position is never moved, and starts the row for what follows it. Prefer no\n" + - "@position at all to a few: hand-placed statements are not measured against\n" + - "what the builder puts around them. (#1154)\n\n" + + "on over it; a custom error handler's row hangs in that lane too, 130px under\n" + + "its activity. A statement that reaches into the lane — a decision, a loop, an\n" + + "activity with a handler of its own — waits for it to clear; a plain activity\n" + + "does not (ako/mxcli#1768). A CASE of four or more branches leaves the split\n" + + "in three groups — top, right, bottom — so its lines do not cross. A\n" + + "statement that carries @position is never moved, and starts the row for what\n" + + "follows it; the statements BEFORE it keep the places the builder derives for\n" + + "them, and only a @position on the first statement moves the start event.\n" + + "Prefer no @position at all to a few: hand-placed statements are not measured\n" + + "against what the builder puts around them. (#1154)\n\n" + "@start and @merge position the two nodes that have no statement of their\n" + "own, so each is written on the statement it belongs to. Omit @start and the\n" + "start is placed one spacing unit left of the first activity, on its centre\n" + diff --git a/docs-site/src/appendixes/version-compatibility.md b/docs-site/src/appendixes/version-compatibility.md index e6b4cd22a9..83fc73373d 100644 --- a/docs-site/src/appendixes/version-compatibility.md +++ b/docs-site/src/appendixes/version-compatibility.md @@ -119,6 +119,11 @@ body on Mendix 10 too, and Studio Pro could not open the project. |---------|-----------|-----|-------| | Basic workflows | `CREATE WORKFLOW` | Yes | Yes | | User tasks | `USER TASK ... OUTCOMES (...)` | Yes | Yes | +| Multi-user tasks | `MULTI USER TASK ...` | 9.22+ | Yes | +| Multi-user task rules majority and threshold | `DECIDE BY MAJORITY ...`, `DECIDE BY THRESHOLD ...` | No | Yes | +| Waiting for all users | `AWAIT ALL USERS` | No | 10.2+ | +| Deciding by a microflow | `DECIDE BY MICROFLOW ...` | No | 10.3+ | +| Group targeting | `TARGETING GROUPS XPATH ...`, `TARGETING GROUPS MICROFLOW ...` | No | 11.2+ | | Parallel splits | `PARALLEL SPLIT` | Yes | Yes | Below Mendix 11.3 a workflow's main flow has no stored start or end activity: @@ -186,6 +191,9 @@ Some properties kept their meaning and changed how they are stored. mxcli builds | A text box's, text area's or date picker's placeholder | 10.11 | `Placeholder` text, instead of a `PlaceholderTemplate` | | Any data source's `ForceFullObjects` | 10.8 | Not stored | | The `PersistentId` of a workflow, activity, outcome or boundary event | 10.21 | Not stored | +| The end of a parallel split path (`Workflows$EndOfParallelSplitPathActivity`, a type introduced in 10.21) | 10.21 | Not stored: a path holds its activities and nothing more (Studio Pro 9.4) | +| A user task (`Workflows$SingleUserTaskActivity` / `MultiUserTaskActivity`, types introduced in 10.12) | 10.12 | A `Workflows$UserTask` whose `UserTaskCompletion` is a `SingleInputCompletion`, or a `MultiInputCompletion` holding the rule, the participants and, from 10.2, `AwaitAllUsers` (Studio Pro 9.24, [ako/mxcli#1771](https://github.com/ako/mxcli/issues/1771)) | +| A user task's targeting (`targeting users xpath […]`, `targeting users microflow …`, none) | 11.2 | A `UserSource` (`XPathBasedUserSource`, `MicroflowBasedUserSource`, `EmptyUserSource`) holding the same value, instead of a `UserTargeting` ([ako/mxcli#1771](https://github.com/ako/mxcli/issues/1771)) | | A constant's value in a configuration (`alter settings constant … in configuration`) | 10.9 | A flat `Value` string on the override, instead of a `Settings$SharedValue` under `SharedOrPrivateValue` | | A page's flow call argument that is the enclosing object (`call microflow M.F(P = $currentObject)`, `call nanoflow …`) | 10.10 | Not stored: Studio Pro infers it. A page parameter, snippet parameter or widget argument is stored as before, as a `Variable` | @@ -196,6 +204,8 @@ What an older shape cannot hold is refused, naming the element, rather than drop - a data view fed by another widget below 10.0; - a private constant value in a configuration below 10.9; - a page's flow call argument that is any other expression (a literal, an attribute path) below 10.10; +- a user task targeting groups (`targeting groups xpath …`, `targeting groups microflow …`) below 11.2, where a task targets users only; +- a multi-user task below 9.22, `decide by majority` or `decide by threshold` below 10.0, `decide by microflow` below 10.3, `await all users` below 10.2, and a boundary event on a user task below 10.14. The user-task features are also refused up front, by `check -p` and by `exec` before anything is written, with MDL-VERSION01 ([ako/mxcli#1771](https://github.com/ako/mxcli/issues/1771)); - an aggregate over an expression (`sum $L of …`), `reduce`, `all` or `any` below 10.0. The plain `count`, `sum`, `average`, `minimum` and `maximum` are written on every version ([ako/mxcli#1163](https://github.com/ako/mxcli/issues/1163)). Before this, `describe` on 9.24 printed none of these properties, so a describe → exec round trip removed them. Writing a data view on 9.24 was refused outright. @@ -206,6 +216,8 @@ A published OData service is not an older shape of the same document. Before 10. Pluggable widget PropertyTypes change between Mendix versions. A widget template extracted from 11.6 may have more or fewer properties than the widget installed in a 10.24 project. The [widget augmentation system](../internals/widget-templates.md) handles this by reconciling templates against the project's `.mpk` files. +The schema itself also gained keys over time. A key the project's version does not declare is left out of the schema mxcli writes, by the same version data the property floor uses. For example, a return type's `AssignableTo` arrived in 9.20, so a 9.0–9.19 schema stores `EntityProperty`, `IsList` and `Type`, as Studio Pro 9.1 and 9.4 do ([ako/mxcli#1163](https://github.com/ako/mxcli/issues/1163)). + ## Widget Template Versions Embedded widget templates are extracted from Mendix 11.6 and cover: diff --git a/docs-site/src/guides/pluggable-widgets.md b/docs-site/src/guides/pluggable-widgets.md index a83a59eb09..6d0812a4db 100644 --- a/docs-site/src/guides/pluggable-widgets.md +++ b/docs-site/src/guides/pluggable-widgets.md @@ -51,6 +51,19 @@ version you actually have at the moment you create the widget. rules** and applies them, so a freshly-created widget carries exactly the defaults Studio Pro would give it. + A property the widget hides must hold its default, or the build fails with CE0463. + So a hidden property you leave out is written at its default; one you set to its + default draws an MDL-WIDGET10 warning (the value is ignored); and one you set to + anything else is an MDL-WIDGET10 **error**, which `exec` refuses before writing. + `describe` leaves out a hidden property that holds its default. For example, a + Timeline grouped by `'day'` hides `groupByMonthOptions`, and one with + `customVisualization: true` hides both format enumerations. + + This only covers rules mxcli recognises. `mxcli widget describe -p app.mpr` + ends with "*N* of *M* editor hide-rules recognized". A rule it cannot read hides + nothing as far as `check` knows, so a value set behind one reaches `mx check` + unwarned. + The result: widgets created by mxcli open cleanly across Mendix 10.x and 11.x with the widget packages bundled with those releases — verified on Mendix 11.12 with Data Widgets 3.4: a project authored entirely by mxcli reports **0** CE0463. diff --git a/docs-site/src/language/microflow-structure.md b/docs-site/src/language/microflow-structure.md index d7494c2b5e..1eca953c28 100644 --- a/docs-site/src/language/microflow-structure.md +++ b/docs-site/src/language/microflow-structure.md @@ -152,11 +152,17 @@ $Order = CREATE Sales.Order (Status = 'New'); Positions are optional. A microflow written without them is laid out by mxcli: the main line runs left to right and wraps onto a new row once it passes two canvas widths (2880 px); a guard — `if … then …; return; end if` — drops its branch into the -lane below while the main line carries on above it; and a `case` of four or more -branches leaves the decision in three groups (top, right, bottom) so its lines do not -cross. A statement that carries `@position` is never moved, and becomes the start of -the row for the statements after it — so either place everything or nothing: a few -hand-placed statements are not measured against what is laid out around them. +lane below while the main line carries on above it; a custom error handler's row hangs +in that same lane, 130 px under its activity; and a `case` of four or more branches +leaves the decision in three groups (top, right, bottom) so its lines do not cross. +Whatever reaches down into the lane — a decision, a loop, an activity with an error +handler of its own — waits for the lane to clear, so two consecutive handlers never +share a row; a plain activity does not wait. A statement that carries `@position` is +never moved, and becomes the start of the row for the statements after it; the +statements before it keep the places the builder derives for them, and only a +`@position` on the first statement moves the start event. So either place everything +or nothing: a few hand-placed statements are not measured against what is laid out +around them. To re-arrange a flow that already exists — one drawn in Studio Pro, or one whose positions no longer fit after edits — run `mxcli layout flows`. It applies this diff --git a/docs-site/src/reference/microflow/alter-microflow.md b/docs-site/src/reference/microflow/alter-microflow.md index 69536e27f7..1f3685207d 100644 --- a/docs-site/src/reference/microflow/alter-microflow.md +++ b/docs-site/src/reference/microflow/alter-microflow.md @@ -32,6 +32,20 @@ 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`. +### Editing an activity + +`REPLACE` by one activity of the same kind — the same action, with a +filter's or find's expression, a retrieve's `WHERE`, a member's value or a +call's argument changed — edits the stored activity rather than putting a new +one in its place. The activity keeps its element ID, its place and its size, +and so do its action and every element under it that pairs up: a member of a +change or create by its attribute or association, an argument by its +parameter, anything else by its property and type. A member taken out goes +with its ID, which no other member inherits; a part of another type (a `FIND` +where a `FILTER` was) is new. Studio Pro, which compares and merges by element +ID, then shows the activity as changed rather than one removed and another +added. An activity replaced by another kind of activity, or by several, is new. + ### Inside a loop body Inside a loop body, plain activities are inserted, replaced and dropped the @@ -84,6 +98,13 @@ 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. +A statement changed where it stands edits its stored activity, as above, and +so does each of several adjacent statements changed in their own places. The +report says which: `Modified microflow: M.F (spliced: 1 edited in place)` for +a node that kept its element ID — an activity of the same kind, a decision's +condition, a return's value — and `replaced with new element IDs` for one +that did not. + ## Examples Insert, replace and drop at top level: diff --git a/mdl-examples/bug-tests/1106-create-or-modify-edit-inside-loop-body.mdl b/mdl-examples/bug-tests/1106-create-or-modify-edit-inside-loop-body.mdl index c3df1a7a6f..4fb535f2b3 100644 --- a/mdl-examples/bug-tests/1106-create-or-modify-edit-inside-loop-body.mdl +++ b/mdl-examples/bug-tests/1106-create-or-modify-edit-inside-loop-body.mdl @@ -14,9 +14,10 @@ -- To reproduce: exec this file against a project, then change -- `formatDecimal($It/Amount, '0.#')` to `toString(round($It/Amount, 1))` and -- exec it again. The second run now reports --- Modified microflow: BugTest1106.BuildJson (spliced: 1 replaced) +-- Modified microflow: BugTest1106.BuildJson (spliced: 1 edited in place) -- and a third run writes nothing. The loop and the other activities keep their --- $IDs and positions; only the edited `set` is replaced, in place. +-- $IDs and positions; the edited `set` is the stored activity with its new +-- expression, and keeps its $ID too (ako/mxcli#1761). -- -- Still refused (needs the body laid out again): a statement added, removed, -- reordered or moved inside the body, or one kind of statement swapped for diff --git a/mdl-examples/bug-tests/1163-parallel-split-below-10-21.mdl b/mdl-examples/bug-tests/1163-parallel-split-below-10-21.mdl new file mode 100644 index 0000000000..fc0f86d914 --- /dev/null +++ b/mdl-examples/bug-tests/1163-parallel-split-below-10-21.mdl @@ -0,0 +1,64 @@ +mdl 1; +-- ako/mxcli#1163: on Mendix 9.x and on 10.0–10.20 a workflow with a +-- `parallel split` made the project unloadable. `exec` reported success, then +-- mx check (and Studio Pro) could not load the project at all: +-- +-- TypeCacheUnknownTypeException: The type cache does not contain a type +-- with qualified name Workflows$EndOfParallelSplitPathActivity. +-- +-- mxcli ends every parallel split path with that marker, because from 10.21 +-- the workflow engine runs a path only up to it. The type itself arrived in +-- 10.21 (mendixmodelsdk), and the property floor checks property versions, +-- never a type's own. Before 10.21 Studio Pro stores a path as its activities +-- and nothing else (Studio Pro 9.4.0: the Marketplace's "Example project +-- workflow extension" 2.0.0, a split whose two paths hold a user task each), +-- so below 10.21 the marker is no longer written. +-- +-- Measured on fresh apps (`mxcli new … --skip-build`), exec then `mxcli docker +-- check`: +-- 9.24.30.51827 before: TypeCacheUnknownTypeException; after: 0 errors. +-- `mx convert -p` 9.24.30 changes no key; describe → exec +-- prints "Unchanged workflow". +-- 10.12.19.81612 before: TypeCacheUnknownTypeException; after: 0 errors; +-- `mx convert -p` 10.12.19 changes no key. +-- 10.24.24, 11.13.0 every path still ends in the marker, 0 errors, the +-- same key sets as before the fix. +-- +-- The ALTER below inserts a path through the mutator (InsertPath), outside +-- CREATE's encoder; the lowering runs on every unit write, so it covers both. + +create module Probe1163Ps; + +create persistent entity Probe1163Ps.Request ( + Title: String(100), + Amount: Integer +); + +create microflow Probe1163Ps.ACT_Left ($WorkflowContext: Probe1163Ps.Request) +begin + log info node 'Probe1163Ps' 'left'; +end; + +create microflow Probe1163Ps.ACT_Right ($WorkflowContext: Probe1163Ps.Request) +begin + log info node 'Probe1163Ps' 'right'; +end; + +create workflow Probe1163Ps.WF_Split + parameter $WorkflowContext: Probe1163Ps.Request + display 'Split' +begin + call microflow Probe1163Ps.ACT_Left; + parallel split + path 1 { call microflow Probe1163Ps.ACT_Left; } + path 2 { call microflow Probe1163Ps.ACT_Right; } + path 3 { } + ; + call microflow Probe1163Ps.ACT_Right; +end workflow; + +alter workflow Probe1163Ps.WF_Split { + insert into 'Parallel split' { path { + call microflow Probe1163Ps.ACT_Right; + } } +}; diff --git a/mdl-examples/bug-tests/1163-widget-return-type-below-9-20.mdl b/mdl-examples/bug-tests/1163-widget-return-type-below-9-20.mdl new file mode 100644 index 0000000000..42451968f0 --- /dev/null +++ b/mdl-examples/bug-tests/1163-widget-return-type-below-9-20.mdl @@ -0,0 +1,61 @@ +mdl 1; +-- ako/mxcli#1163: two more rows of the version data mxcli vendors from the +-- Model SDK gave a property the version of the property after it, and left +-- that one out — the same generator mistake as AggregateFunction: +-- +-- CustomWidgets$WidgetReturnType Type 9.20.0 (belongs to AssignableTo) +-- Forms$GroupBox Caption 8.10.0 (belongs to HeaderMode) +-- +-- mendixmodelsdk 4.117.0: WidgetReturnType `type: {}`, `assignableTo: +-- {introduced: "9.20.0"}`; pages.GroupBox `caption: {}`, `headerMode: +-- {introduced: "8.10.0"}`. +-- +-- What it changed: a pluggable widget's schema is written with only the keys +-- the project's version declares, by the same data. On 9.0–9.19 every return +-- type in it lost its Type and kept AssignableTo (49 return types across the +-- embedded templates; the slider below has 3). Studio Pro stores +-- {EntityProperty, IsList, Type} on 9.1.1 (208 of 208, the Marketplace's +-- Employee Onboarding app) and 9.4.0 (326 of 326, "Example project workflow +-- extension" 2.0.0), and {AssignableTo, EntityProperty, IsList, Type} on +-- 9.24.3 (57 of 57, Multilevel Dynamic Workflow). The group box row changed +-- nothing mxcli writes: the caption is stored as CaptionTemplate, which the +-- table never keyed, and 8.10 is below every version mxcli supports. +-- +-- Measured, exec then `mxcli docker check` on fresh apps, before and after: +-- 10.24.24 and 11.13.0 report 0 errors and store the same key sets. 9.24.30 +-- stores Studio Pro 9.24.3's return type key set both times, but its mx check +-- stops with a NullReferenceException in WidgetValue.IsDefault on this page, +-- and on a data grid 2 page. That happens with the build before either +-- ako/mxcli#1163 fix as well, so it is a separate defect. Not run on a +-- 9.0–9.19 platform: those mxbuilds are Mono builds (mx.exe), and no Mono +-- runtime is installed. + +create module Probe1163Rt; + +create enumeration Probe1163Rt.Country ( + NL 'Netherlands', + BE 'Belgium' +); + +create persistent entity Probe1163Rt.Customer ( + Name: String(100), + Country: enumeration(Probe1163Rt.Country), + Score: Integer +); + +create page Probe1163Rt.Customer_Edit +( + params: ( $Customer: Probe1163Rt.Customer ), + title: 'Customer', + layout: Atlas_Core.PopupLayout +) +{ + dataview dvCustomer (datasource: $Customer) { + groupbox gbCustomer (caption: 'Customer', HeaderMode: H3, Collapsible: YesExpanded) { + textbox txtName (label: 'Name', attribute: Name) + pluggablewidget 'com.mendix.widget.custom.slider.Slider' sldScore ( + valueAttribute: Score + ) + } + } +}; diff --git a/mdl-examples/bug-tests/1163-workflow-on-mendix9.mdl b/mdl-examples/bug-tests/1163-workflow-on-mendix9.mdl index ecea673ad0..6d2529ef77 100644 --- a/mdl-examples/bug-tests/1163-workflow-on-mendix9.mdl +++ b/mdl-examples/bug-tests/1163-workflow-on-mendix9.mdl @@ -16,9 +16,9 @@ mdl 1; -- then `mxcli docker check` reports the blank app's error count, and `mx -- convert -p` strips no PersistentId. -- --- Not covered: a `parallel split`. Its paths end in a --- Workflows$EndOfParallelSplitPathActivity, a type Mendix introduced in 10.21, --- so below 10.21 the project does not load (TypeCacheUnknownTypeException). +-- A `parallel split` is in 1163-parallel-split-below-10-21.mdl: its paths end +-- in a Workflows$EndOfParallelSplitPathActivity, a type Mendix introduced in +-- 10.21, which below 10.21 is no longer written. create module Probe1163Wf; diff --git a/mdl-examples/bug-tests/1489-error-handler-activity-splices.mdl b/mdl-examples/bug-tests/1489-error-handler-activity-splices.mdl index 26da19f0c0..712d8f341e 100644 --- a/mdl-examples/bug-tests/1489-error-handler-activity-splices.mdl +++ b/mdl-examples/bug-tests/1489-error-handler-activity-splices.mdl @@ -13,8 +13,8 @@ -- -- To reproduce: exec this file, then change `P = $P` to `P = 'x'` (or the -- handler's log text) and exec it again. The second run reports --- `Modified microflow: BugTest1489.Flow (spliced: 1 replaced)`; the handler and --- the merge it rejoins at keep their $IDs and positions. A third run writes +-- `Modified microflow: BugTest1489.Flow (spliced: 1 edited in place)`; the call, +-- the handler and the merge it rejoins at keep their $IDs and positions. A third run writes -- nothing. Changing the handler's kind is still refused. mdl 1; diff --git a/mdl-examples/bug-tests/1517-notes-on-decision-and-end-event-splice.mdl b/mdl-examples/bug-tests/1517-notes-on-decision-and-end-event-splice.mdl index 0e4e40e0a4..bb0b0ef43b 100644 --- a/mdl-examples/bug-tests/1517-notes-on-decision-and-end-event-splice.mdl +++ b/mdl-examples/bug-tests/1517-notes-on-decision-and-end-event-splice.mdl @@ -11,7 +11,7 @@ -- To reproduce: exec this file, then add `@annotation 'guard'` on the line -- before `if` (or `@annotation 'the end'` before `return 'done';`) and exec it -- again. The second run reports `Modified microflow: BugTest1517.F (spliced: --- 1 replaced)`; the decision and the end event keep their $IDs and flows, and +-- 1 edited in place)`; the decision and the end event keep their $IDs and flows, and -- the note is drawn where a full build would draw it. A third run writes -- nothing. A note shared with another activity (`id:`) is still refused. diff --git a/mdl-examples/bug-tests/1610-decision-caption-change-splices.mdl b/mdl-examples/bug-tests/1610-decision-caption-change-splices.mdl index 86b3f8f825..9bb0254bce 100644 --- a/mdl-examples/bug-tests/1610-decision-caption-change-splices.mdl +++ b/mdl-examples/bug-tests/1610-decision-caption-change-splices.mdl @@ -14,7 +14,7 @@ -- -- To reproduce: exec this file against a project, then add -- `@caption 'Big?'` on the line before `if` and exec it again. The second run --- reports `Modified microflow: BugTest1610.F (spliced: 1 replaced)`, and a +-- reports `Modified microflow: BugTest1610.F (spliced: 1 edited in place)`, and a -- third run writes nothing. Taking the caption off again captions the -- decision with its condition, as a new one would be. diff --git a/mdl-examples/bug-tests/1761-same-kind-activity-edit-keeps-ids.mdl b/mdl-examples/bug-tests/1761-same-kind-activity-edit-keeps-ids.mdl new file mode 100644 index 0000000000..735410c301 --- /dev/null +++ b/mdl-examples/bug-tests/1761-same-kind-activity-edit-keeps-ids.mdl @@ -0,0 +1,64 @@ +-- ============================================================================ +-- ako/mxcli#1761 (mendixlabs/mxcli#1667) — an edited filter gets new $IDs +-- ============================================================================ +-- +-- Re-running `create or modify microflow` where the only change is a filter's +-- expression did not edit the activity, it replaced it. Measured after the +-- second run (Mendix 10.24.28): +-- +-- $BigList: the Microflows$ActionActivity, Microflows$ListOperationsAction +-- and Microflows$FilterByExpression all got new $IDs. [...] The decision's +-- ExclusiveSplit and SplitCondition kept theirs. +-- +-- and exec reported both as `spliced: 1 replaced`. Studio Pro compares and +-- merges by $ID, so it showed the filter as one activity removed and another +-- added; check, mx check, diff and describe all looked fine. +-- +-- An activity replaced by one of the same kind — the same action — is now an +-- edit of the stored one: it keeps its $ID, and so do its action and every +-- element under it that pairs up (a member by its attribute or association, +-- an argument by its parameter). Several activities each changed in their own +-- place are each edited so. +-- +-- To reproduce: exec this file against a project, then change every `> 10` +-- to `>= 10`, `= 7` to `= 8` and `Factor = 2` to `Factor = 3`, and exec it +-- again. The second run reports +-- Modified microflow: BugTest1761.FilterSales (spliced: 1 edited in place) +-- Modified microflow: BugTest1761.Decide (spliced: 1 edited in place) +-- Modified microflow: BugTest1761.Kinds (spliced: 3 edited in place) +-- and `mxcli bson dump --type microflow --object BugTest1761.FilterSales` +-- shows the same $IDs as before it; a third run writes nothing. An activity +-- replaced by another kind — +-- a log where the filter was — still reports `replaced with new element IDs`. + +mdl 1; + +create module BugTest1761; + +create or modify persistent entity BugTest1761.Sale ( Amount: Integer, Note: String(200) ); + +create or modify microflow BugTest1761.FilterSales ( $SaleList: List of BugTest1761.Sale ) +returns List of BugTest1761.Sale as $BigList +begin + $SmallList = filter $SaleList where $currentObject/Amount < 5; + $BigList = filter $SaleList where $currentObject/Amount > 10; + return $BigList; +end; + +create or modify microflow BugTest1761.Decide ( $N: Integer ) returns Boolean as $Result +begin + if $N > 10 then return true; else return false; end if; +end; + +create or modify microflow BugTest1761.Sub ( $Sale: BugTest1761.Sale, $Factor: Integer ) returns Boolean as $Ok +begin + return true; +end; + +create or modify microflow BugTest1761.Kinds ( $SaleList: List of BugTest1761.Sale ) returns Boolean as $Ok +begin + $Found = find $SaleList where $currentObject/Amount > 10; + change $Found (Amount = 7, Note = 'x'); + $Ok = call microflow BugTest1761.Sub (Sale = $Found, Factor = 2); + return $Ok; +end; diff --git a/mdl-examples/bug-tests/1762-timeline-hidden-enumeration.mdl b/mdl-examples/bug-tests/1762-timeline-hidden-enumeration.mdl new file mode 100644 index 0000000000..3cbb491b37 --- /dev/null +++ b/mdl-examples/bug-tests/1762-timeline-hidden-enumeration.mdl @@ -0,0 +1,114 @@ +mdl 1; +-- ============================================================================ +-- Issue ako/mxcli#1762 (mendixlabs/mxcli#1668) — a Timeline enumeration the +-- widget hides, set to a non-default value, fails the build with CE0463 +-- ============================================================================ +-- +-- The Timeline's editorConfig hides properties six ways: +-- +-- e.customVisualization ? hide(title, description, icon, timeIndication, +-- groupByDayOptions, groupByMonthOptions) (1) +-- : hide(customIcon, customTitle, …) (2) +-- e.groupEvents || hide(groupAttribute, groupByKey, groupByDayOptions, …) (3) +-- switch (e.groupByKey) { case "day": hide(groupByMonthOptions) (4) +-- case "month": hide(groupByDayOptions) (5) +-- default: hide(groupByDayOptions, +-- groupByMonthOptions) } (6) +-- +-- mxcli read (2) and (3) only. Measured on mxbuild 11.13.0 with Timeline 3.2.2, +-- one widget per variant, nothing else hidden set: +-- +-- customVisualization: true, groupByDayOptions: 'dayMonth' → CE0463 (1) +-- customVisualization: true, groupByKey: 'month', +-- groupByMonthOptions: 'monthYear' → CE0463 (1) +-- groupByKey: 'day', groupByMonthOptions: 'monthYear' → CE0463 (4) +-- groupByKey: 'month', groupByDayOptions: 'fullDate' → CE0463 (5) +-- groupByKey: 'year', groupByDayOptions / groupByMonthOptions set → CE0463 (6) +-- the same values where they are visible, or left out → 0 errors +-- +-- `check` and `exec` passed all of them. Now each is refused with MDL-WIDGET10 +-- — the verdict (3) already got — naming the property, the rule and the +-- default; one the script leaves out is written at that default. +-- +-- Verify (needs -p: the rules are lifted from the project's installed .mpk): +-- +-- mxcli check 1762-timeline-hidden-enumeration.mdl -p app.mpr --references +-- → passes; uncomment a statement below to see the MDL-WIDGET10 error +-- mxcli exec 1762-timeline-hidden-enumeration.mdl -p app.mpr +-- mxcli docker check -p app.mpr --no-update-widgets # 0 errors +-- describe page M1762.PageD, then exec it # unchanged +-- ============================================================================ + +create module M1762; + +create non-persistent entity M1762.Ev ( + Line: String(200), + WhoText: String(200), + At: DateTime +); + +create microflow M1762.DS_Events () returns List of M1762.Ev as $Events +begin + $Events = create list of M1762.Ev; + $E = create M1762.Ev ( Line = 'Created', WhoText = 'someone', At = [%CurrentDateTime%] ); + add $E to $Events; + return $Events; +end; + +-- The reporter's tlD, corrected: custom mode hides the format enumerations, so +-- they are left out (written at their defaults). +create or modify page M1762.PageD ( Title: 'D', Layout: Atlas_Core.Atlas_Default ) { + timeline tlD ( + data: MICROFLOW M1762.DS_Events, + customVisualization: true, groupEvents: true, groupAttribute: At, + groupByKey: 'day', ungroupedEventsPosition: 'end' + ) { + customtitle d2 { dynamictext d2t (Content: '{1}', ContentParams: ({1} = Line)) } + } +}; + +-- The reporter's tlG, corrected: grouping by day shows only groupByDayOptions. +create or modify page M1762.PageG ( Title: 'G', Layout: Atlas_Core.Atlas_Default ) { + timeline tlG ( + data: MICROFLOW M1762.DS_Events, + title: '{1}', titleParams: ({1} = $currentObject/Line), + customVisualization: false, groupEvents: true, groupAttribute: At, + groupByKey: 'day', groupByDayOptions: 'fullDate', ungroupedEventsPosition: 'end' + ) +}; + +-- Grouping by month shows only groupByMonthOptions. +create or modify page M1762.PageM ( Title: 'M', Layout: Atlas_Core.Atlas_Default ) { + timeline tlM ( + data: MICROFLOW M1762.DS_Events, + title: '{1}', titleParams: ({1} = $currentObject/Line), + groupAttribute: At, groupByKey: 'month', groupByMonthOptions: 'monthYear' + ) +}; + +-- The failing shapes, kept as documentation. Uncomment one and re-run +-- `mxcli check ... -p app.mpr`: MDL-WIDGET10 is an error, and `mx check` +-- reports CE0463 on the same widget. +-- +-- (1) hidden in custom mode: +-- +-- create or modify page M1762.PageD1 ( Title: 'x', Layout: Atlas_Core.Atlas_Default ) { +-- timeline tlD1 ( data: MICROFLOW M1762.DS_Events, customVisualization: true, +-- groupAttribute: At, groupByKey: 'day', groupByDayOptions: 'dayMonth' ) { +-- customtitle d3 { dynamictext d3t (Content: '{1}', ContentParams: ({1} = Line)) } +-- } +-- }; +-- +-- (4) grouping by day hides the month format: +-- +-- create or modify page M1762.PageG1 ( Title: 'x', Layout: Atlas_Core.Atlas_Default ) { +-- timeline tlG1 ( data: MICROFLOW M1762.DS_Events, title: '{1}', titleParams: ({1} = $currentObject/Line), +-- groupAttribute: At, groupByKey: 'day', groupByMonthOptions: 'monthYear' ) +-- }; +-- +-- (6) grouping by year hides both: +-- +-- create or modify page M1762.PageY1 ( Title: 'x', Layout: Atlas_Core.Atlas_Default ) { +-- timeline tlY1 ( data: MICROFLOW M1762.DS_Events, title: '{1}', titleParams: ({1} = $currentObject/Line), +-- groupAttribute: At, groupByKey: 'year', groupByDayOptions: 'dayMonth' ) +-- }; diff --git a/mdl-examples/bug-tests/1768-error-handler-rows-share-a-lane.mdl b/mdl-examples/bug-tests/1768-error-handler-rows-share-a-lane.mdl new file mode 100644 index 0000000000..5d064f0026 --- /dev/null +++ b/mdl-examples/bug-tests/1768-error-handler-rows-share-a-lane.mdl @@ -0,0 +1,80 @@ +-- ============================================================================ +-- ako/mxcli#1768 (mendixlabs/mxcli#1672) — the error handlers of two +-- consecutive activities were drawn in one row, the second on the first +-- ============================================================================ +-- +-- Without @position a custom error handler is a row 130px under its activity, +-- running right, and the main line carries on one spacing after the activity +-- whatever the row's length. A second activity with a handler started its row +-- at its own x, inside the first. Reported, from `describe … with handles`: +-- +-- first handler: $FirstFailed 360;330, log 520;330, end event 680;330 +-- $Second 680;200, second handler: $SecondFailed 680;330 <- on the end event +-- +-- `layout flows --dry-run` called the flow already laid out, and MPR008 said +-- nothing, because it did not compare end events. +-- +-- Expected after exec: `describe microflow BugTest1768.SUB_TwoHandlers with +-- handles` keeps $First, its handler row and $Body where they were (360;200, +-- 360..680;330, 520;200) and places $Second at 790;200 with its row at +-- 790..1110;330 — clear of the first row's end, with the gap two activities +-- keep. `lint -r MPR008` reports nothing, and `layout flows --dry-run` calls +-- every flow below already laid out except SUB_PositionedLater (layout flows +-- drops its @position). +-- +-- SUB_PositionedLater is the issue's second case: a @position on a later +-- statement used to move the start event to it and every unplaced statement +-- before it along with the start, so `declare $A` and `declare $B` were both +-- stored at 840;200. Expected now: start 200;200, $A 360;200, $B 840;200. + +mdl 1; + +create module BugTest1768; + +create non-persistent entity BugTest1768.Result (IsDone: Boolean, Code: Integer); + +create or modify microflow BugTest1768.SUB_TwoHandlers ($Url: String) returns BugTest1768.Result as $Result +begin + $First = call rest service get '{1}' with ({1} = $Url) (Timeout: 30) returns response on error without rollback begin + $FirstFailed = create BugTest1768.Result (Code = 1); + log error node 'BugTest1768' 'first failed'; + return $FirstFailed; + end error; + declare $Body String = $First/Content; + $Second = call rest service get '{1}' with ({1} = $Body) (Timeout: 30) returns response on error without rollback begin + $SecondFailed = create BugTest1768.Result (Code = 2); + log error node 'BugTest1768' 'second failed'; + return $SecondFailed; + end error; + $Done = create BugTest1768.Result (IsDone = true); + return $Done; +end; + +-- One more statement in the first handler: its row then reached 840;330, two +-- objects deep into the second row, and MPR008 reported only the one of the two +-- overlaps that was activity on activity. +create or modify microflow BugTest1768.SUB_TwoHandlersLonger ($Url: String) returns BugTest1768.Result as $Result +begin + $First = call rest service get '{1}' with ({1} = $Url) (Timeout: 30) returns response on error without rollback begin + $FirstFailed = create BugTest1768.Result (Code = 1); + log error node 'BugTest1768' 'first failed'; + log info node 'BugTest1768' 'first failed, details'; + return $FirstFailed; + end error; + declare $Body String = $First/Content; + $Second = call rest service get '{1}' with ({1} = $Body) (Timeout: 30) returns response on error without rollback begin + $SecondFailed = create BugTest1768.Result (Code = 2); + log error node 'BugTest1768' 'second failed'; + return $SecondFailed; + end error; + $Done = create BugTest1768.Result (IsDone = true); + return $Done; +end; + +create or modify microflow BugTest1768.SUB_PositionedLater ($Url: String) returns String as $B +begin + declare $A String = $Url; + @position(840, 200) + declare $B String = $A; + return $B; +end; diff --git a/mdl-examples/bug-tests/1769-alter-loop-find-over-association-list.mdl b/mdl-examples/bug-tests/1769-alter-loop-find-over-association-list.mdl new file mode 100644 index 0000000000..3c21e20a6c --- /dev/null +++ b/mdl-examples/bug-tests/1769-alter-loop-find-over-association-list.mdl @@ -0,0 +1,61 @@ +mdl 1; +-- ako/mxcli#1769 (mendixlabs/mxcli#1673) — a find in a loop an alter writes +-- +-- `alter microflow … { replace loop … with begin … end; }` whose loop body +-- finds by member in a list retrieved over an association before the loop +-- (`retrieve $Lines from $Order/MyModule.OrderLine_Order`) stored the find as +-- Microflows$FindByExpression with the bare text `Code = $Lookup/Code`: check +-- --references and exec reported nothing, and mx check failed CE0117 "Error(s) +-- in expression." `create microflow` stores the same find as Microflows$Find +-- on MyModule.OrderLine.Code. +-- +-- Two causes. The member guard a spliced fragment has (ako/mxcli#891) was not +-- inherited by the builder of a body nested in it — a loop, a while loop, an +-- error handler — so it wrote a member it could not qualify where the main +-- line refused it. And the fragment did not know what a stored retrieve over +-- an association holds, so it could not qualify the member. A retrieve from +-- the TO end of a reference (Order here) yields the list of the FROM entity +-- (OrderLine); from the FROM end, the one TO object. +-- +-- Run on any project: +-- mxcli exec mdl-examples/bug-tests/1769-alter-loop-find-over-association-list.mdl -p app.mpr +-- mxcli bson dump -p app.mpr --type microflow --object MyModule.SUB_MatchLines +-- Expected: "Altered microflow MyModule.SUB_MatchLines"; the dump shows +-- "$Type": "Microflows$Find" with "Attribute": "MyModule.OrderLine.Code" and +-- "Expression": "$Lookup/Code"; mx check reports no error for the flow, and +-- exec of `describe microflow MyModule.SUB_MatchLines` reports Unchanged. +-- Before the fix: Microflows$FindByExpression "Code = $Lookup/Code" and CE0117 +-- (measured on 11.13.0). + +create module MyModule; +create persistent entity MyModule.Order (Code: String(50)); +create persistent entity MyModule.OrderLine (Code: String(50), Status: String(20)); +create association MyModule.OrderLine_Order from MyModule.OrderLine to MyModule.Order; +create non-persistent entity MyModule.Lookup (Code: String(50)); + +create microflow MyModule.SUB_MatchLines ($Order: MyModule.Order, $Lookups: List of MyModule.Lookup) returns Integer as $Found +begin + declare $Found Integer = 0; + retrieve $Lines from $Order/MyModule.OrderLine_Order; + loop $Lookup in $Lookups + begin + $Line = find $Lines by Code = $Lookup/Code; + if $Line != empty then + set $Found = $Found + 1; + end if; + end loop; + return $Found; +end; + +-- The loop replaced with its own text. +alter microflow MyModule.SUB_MatchLines { + replace loop $Lookup in $Lookups with begin + loop $Lookup in $Lookups + begin + $Line = find $Lines by Code = $Lookup/Code; + if $Line != empty then + set $Found = $Found + 1; + end if; + end loop; + end; +}; diff --git a/mdl-examples/bug-tests/1771-alter-user-task-targeting.mdl b/mdl-examples/bug-tests/1771-alter-user-task-targeting.mdl new file mode 100644 index 0000000000..31bb7b7101 --- /dev/null +++ b/mdl-examples/bug-tests/1771-alter-user-task-targeting.mdl @@ -0,0 +1,62 @@ +mdl 1; +-- ako/mxcli#1771: on Mendix 9.x to 11.1, `alter workflow … { set (Targeting: …) +-- on … }` reported "Altered workflow" and changed nothing. describe +-- printed the old targeting afterwards, and mx check stayed clean, because +-- the old targeting was still valid. +-- +-- Below 11.2 a stored user task holds its targeting as UserSource. The ALTER +-- mutator set UserTargeting, the 11.2 key, and only where the task already had +-- one, so on those versions it found none and did nothing. It now replaces the +-- stored targeting, whichever key holds it. The write then stores it in the +-- version's shape (see 1771-user-task-targeting-below-11-2.mdl). +-- +-- Measured on fresh apps (`mxcli new … --skip-build`): exec, then describe, +-- then `mxcli docker check`. +-- 9.24.30.51827, 10.24.24.119653 before: the old targeting was kept. After: +-- `[Name = 'reviewer']` and ACT_GetReviewers. 0 errors, with +-- the version's key set (Workflows$UserTask on 9.24). +-- 10.11.0.37074 after: the new targeting, 0 errors. +-- 11.13.0 the new targeting both before and after, 0 errors. + +create module Probe1771At; + +create persistent entity Probe1771At.Request ( Title: String(100) ); + +create microflow Probe1771At.ACT_GetUsers ($Workflow: System.Workflow, $Context: Probe1771At.Request) +returns list of System.User as $Users +begin + retrieve $Users from System.User; + return $Users; +end; + +create microflow Probe1771At.ACT_GetReviewers ($Workflow: System.Workflow, $Context: Probe1771At.Request) +returns list of System.User as $Users +begin + retrieve $Users from System.User where Name = 'reviewer'; + return $Users; +end; + +create page Probe1771At.TaskPage ( + title: 'Task', + layout: Atlas_Core.Atlas_Default, + params: ( $WorkflowUserTask: System.WorkflowUserTask ) +) { + dynamictext txt1 (content: 'Task') +}; + +create workflow Probe1771At.WF_Edit + parameter $WorkflowContext: Probe1771At.Request + display 'Edit' +begin + user task Review 'Review' + page Probe1771At.TaskPage + targeting users xpath [Name = 'admin'] + outcomes 'Done' { }; + user task Handle 'Handle' + page Probe1771At.TaskPage + targeting users xpath [Name = 'admin'] + outcomes 'Done' { }; +end workflow; + +alter workflow Probe1771At.WF_Edit { set (Targeting: xpath [Name = 'reviewer']) on Review; }; +alter workflow Probe1771At.WF_Edit { set (Targeting: microflow Probe1771At.ACT_GetReviewers) on Handle; }; diff --git a/mdl-examples/bug-tests/1771-user-task-below-10-12.mdl b/mdl-examples/bug-tests/1771-user-task-below-10-12.mdl new file mode 100644 index 0000000000..d5c3934ebc --- /dev/null +++ b/mdl-examples/bug-tests/1771-user-task-below-10-12.mdl @@ -0,0 +1,84 @@ +mdl 1; +-- ako/mxcli#1771: on Mendix 9.x a workflow with a user task made the project +-- unloadable. `exec` reported "Created workflow", then mx check and Studio Pro +-- could not load the project at all: +-- +-- TypeCacheUnknownTypeException: The type cache does not contain a type with +-- qualified name Workflows$SingleUserTaskActivity. +-- +-- mxcli writes a user task as a Workflows$SingleUserTaskActivity or +-- MultiUserTaskActivity. Both types arrived in 10.12, which also deleted the +-- one before them (mendixmodelsdk). Before 10.12 a user task is a +-- Workflows$UserTask, and its UserTaskCompletion (9.22) says whether it is a +-- single-user or a multi-user task. Studio Pro 9.24.3 stores `{…, UserSource, +-- UserTaskCompletion: SingleInputCompletion}` (the Marketplace's Multilevel +-- Dynamic Workflow). A multi-user task's rule, participants and, from 10.2, +-- `await all users` sit in a MultiInputCompletion, and its outcomes stay on the +-- task. What the older type cannot hold is refused, naming the task: a rule +-- added later (majority and threshold in 10.0, microflow in 10.3), `await all +-- users` below 10.2, and a boundary event on a user task below 10.14. +-- +-- Measured on fresh apps (`mxcli new … --skip-build`), exec then +-- `mxcli docker check`: +-- 9.24.30.51827 before: TypeCacheUnknownTypeException. After: 0 errors. +-- `mx convert -p` 9.24.30 changes no key and no value. +-- describe → exec prints "Unchanged workflow". Converted up +-- to 10.24.24, the tasks are those mxcli writes on 10.24 +-- directly. +-- 10.11.0.37074 before: TypeCacheUnknownTypeException. After: 0 errors, +-- each multi-user task's completion with AwaitAllUsers (10.2+). +-- `mx convert -p` 10.11 changes nothing. describe → exec +-- prints "Unchanged workflow". +-- 10.24.24, 11.13.0 0 errors, with the same key sets as before the fix. + +create module Probe1771Ut; + +create persistent entity Probe1771Ut.Request ( Title: String(100) ); + +create microflow Probe1771Ut.ACT_GetUsers ($Workflow: System.Workflow, $Context: Probe1771Ut.Request) +returns list of System.User as $Users +begin + retrieve $Users from System.User; + return $Users; +end; + +create page Probe1771Ut.TaskPage ( + title: 'Task', + layout: Atlas_Core.Atlas_Default, + params: ( $WorkflowUserTask: System.WorkflowUserTask ) +) { + dynamictext txt1 (content: 'Task') +}; + +create workflow Probe1771Ut.WF_Tasks + parameter $WorkflowContext: Probe1771Ut.Request + display 'Tasks' +begin + user task Review 'Review' + page Probe1771Ut.TaskPage + targeting users xpath [Name = 'admin'] + outcomes 'Approve' { } 'Reject' { }; + user task Handle 'Handle' + page Probe1771Ut.TaskPage + targeting users microflow Probe1771Ut.ACT_GetUsers + outcomes 'Done' { }; + multi user task Vote 'Vote' + page Probe1771Ut.TaskPage + targeting users xpath [Name = 'admin'] + participants 2 + decide by veto 'Reject' + outcomes 'Approve' { } 'Reject' { }; + multi user task Agree 'Agree' + page Probe1771Ut.TaskPage + targeting users microflow Probe1771Ut.ACT_GetUsers + outcomes 'Yes' { } 'No' { }; +end workflow; + +-- ALTER inserts a user task through the mutator, outside CREATE's encoder; the +-- lowering runs on every unit write, so it covers both. +alter workflow Probe1771Ut.WF_Tasks { + insert after Handle { user task Confirm 'Confirm' + page Probe1771Ut.TaskPage + targeting users xpath [Name = 'admin'] + outcomes 'OK' { }; } +}; diff --git a/mdl-examples/bug-tests/1771-user-task-features-by-version.mdl b/mdl-examples/bug-tests/1771-user-task-features-by-version.mdl new file mode 100644 index 0000000000..d7bf181e97 --- /dev/null +++ b/mdl-examples/bug-tests/1771-user-task-features-by-version.mdl @@ -0,0 +1,95 @@ +mdl 1; +-- ako/mxcli#1771: what an older user task cannot store is refused up front +-- with MDL-VERSION01, by `mxcli check -p` and by exec before anything is +-- written. Before, these were written as they are on 11.2+: +-- - group targeting below 11.2. mx check 10.24.24 then reported CE1859 +-- "XPath constraint is required", and `mx convert` emptied the +-- targeting. Before 11.2 a user task targets users only. +-- - a multi-user task below 10.12, with the 10.12 types. mx check 9.24.30 +-- could not load the project (TypeCacheUnknownTypeException). +-- Below 10.12 a multi-user task is now written as a Workflows$UserTask (see +-- 1771-user-task-below-10-12.mdl), which has no room for what came later: +-- multi user task 9.22 +-- decide by majority / threshold 10.0 +-- await all users 10.2 +-- decide by microflow 10.3 +-- targeting groups xpath / microflow 11.2 +-- (mendixmodelsdk 4.117.0 StructureVersionInfo.) +-- +-- EXPECTED: `mxcli check` passes this file. With -p on a project below a +-- floor, each workflow that states the feature is reported as MDL-VERSION01, +-- naming it. Measured on fresh apps: +-- 9.24.30.51827 WF_Majority, WF_Await, WF_Microflow, WF_Groups; exec +-- writes nothing. +-- 10.11.0.37074 WF_Groups. Without it, exec writes the other four as +-- Workflows$UserTask with a MultiInputCompletion, and mx +-- check reports 0 errors. +-- 10.24.24.119653 WF_Groups. +-- 11.13.0 none; exec writes all five, and mx check reports 0 errors. + +create module Probe1771Gt; + +create persistent entity Probe1771Gt.Request ( Title: String(100) ); + +create microflow Probe1771Gt.ACT_Decide ($WorkflowUserTask: System.WorkflowUserTask) +returns String as $Outcome +begin + return 'Yes'; +end; + +create page Probe1771Gt.TaskPage ( + title: 'Task', + layout: Atlas_Core.Atlas_Default, + params: ( $WorkflowUserTask: System.WorkflowUserTask ) +) { + dynamictext txt1 (content: 'Task') +}; + +create workflow Probe1771Gt.WF_Veto + parameter $WorkflowContext: Probe1771Gt.Request +begin + multi user task Vote 'Vote' + page Probe1771Gt.TaskPage + targeting users xpath [Name = 'admin'] + decide by veto 'No' + outcomes 'Yes' { } 'No' { }; +end workflow; + +create workflow Probe1771Gt.WF_Majority + parameter $WorkflowContext: Probe1771Gt.Request +begin + multi user task Vote 'Vote' + page Probe1771Gt.TaskPage + targeting users xpath [Name = 'admin'] + decide by majority more than half fallback 'No' + outcomes 'Yes' { } 'No' { }; +end workflow; + +create workflow Probe1771Gt.WF_Await + parameter $WorkflowContext: Probe1771Gt.Request +begin + multi user task Vote 'Vote' + page Probe1771Gt.TaskPage + targeting users xpath [Name = 'admin'] + await all users + outcomes 'Yes' { } 'No' { }; +end workflow; + +create workflow Probe1771Gt.WF_Microflow + parameter $WorkflowContext: Probe1771Gt.Request +begin + multi user task Vote 'Vote' + page Probe1771Gt.TaskPage + targeting users xpath [Name = 'admin'] + decide by microflow Probe1771Gt.ACT_Decide + outcomes 'Yes' { } 'No' { }; +end workflow; + +create workflow Probe1771Gt.WF_Groups + parameter $WorkflowContext: Probe1771Gt.Request +begin + user task Review 'Review' + page Probe1771Gt.TaskPage + targeting groups xpath [Name = 'Reviewers'] + outcomes 'Done' { }; +end workflow; diff --git a/mdl-examples/bug-tests/1771-user-task-targeting-below-11-2.mdl b/mdl-examples/bug-tests/1771-user-task-targeting-below-11-2.mdl new file mode 100644 index 0000000000..86737d195a --- /dev/null +++ b/mdl-examples/bug-tests/1771-user-task-targeting-below-11-2.mdl @@ -0,0 +1,71 @@ +mdl 1; +-- ako/mxcli#1771: on Mendix 10.12 to 11.1 every user task mxcli wrote failed +-- mx check with CE1859 "XPath constraint is required". That included tasks +-- targeted by a microflow. `exec` and `mxcli check` were clean. +-- +-- mxcli stored the targeting as UserTargeting, the 11.2 property. Before 11.2 a +-- user task stores it as UserSource. UserTargeting is declared on the abstract +-- UserTaskActivity, which the property floor does not look up, so the key went +-- into every version. The older versions read the task as having no user +-- source. `mx convert -p` 10.24.24 then dropped the targeting and stored an +-- empty XPathBasedUserSource in its place, so the targeting was silently lost. +-- +-- The older shape, measured with `mx convert -p`. The sources are Studio +-- Pro-authored Marketplace samples: Employee Onboarding (9.1.1, XPath +-- targeting) and Multilevel Dynamic Workflow (9.24.3, microflow targeting). +-- Each was converted 9.24.30 → 10.24.24 → 11.13.0. The value is carried byte +-- for byte, and only its holder changes: +-- UserSource: XPathBasedUserSource ↔ UserTargeting: XPathUserTargeting +-- UserSource: MicroflowBasedUserSource ↔ UserTargeting: MicroflowUserTargeting +-- A task with no targeting is an EmptyUserSource (9.10+), and from 11.2 a +-- NoUserTargeting. Group targeting is 11.2. Before 11.2 it is refused with +-- MDL-VERSION01 by check -p, and at write time. +-- +-- Measured on fresh apps (`mxcli new … --skip-build`): exec, then +-- `mxcli docker check`. +-- 10.24.24.119653 before: CE1859 on every task. After: 0 errors. +-- `mx convert -p` 10.24.24 changes no key and no value. +-- describe → exec prints "Unchanged workflow". +-- 10.11.0.37074 0 errors. The task is a Workflows$UserTask there (see +-- 1771-user-task-below-10-12.mdl), with the same UserSource. +-- 11.13.0 0 errors before and after, with the same key sets. + +create module Probe1771Tg; + +create persistent entity Probe1771Tg.Request ( Title: String(100) ); + +create microflow Probe1771Tg.ACT_GetUsers ($Workflow: System.Workflow, $Context: Probe1771Tg.Request) +returns list of System.User as $Users +begin + retrieve $Users from System.User; + return $Users; +end; + +create page Probe1771Tg.TaskPage ( + title: 'Task', + layout: Atlas_Core.Atlas_Default, + params: ( $WorkflowUserTask: System.WorkflowUserTask ) +) { + dynamictext txt1 (content: 'Task') +}; + +create workflow Probe1771Tg.WF_Targeting + parameter $WorkflowContext: Probe1771Tg.Request + display 'Targeting' +begin + user task ByXPath 'By XPath' + page Probe1771Tg.TaskPage + targeting users xpath [Name = 'admin'] + outcomes 'Done' { }; + user task ByMicroflow 'By microflow' + page Probe1771Tg.TaskPage + targeting users microflow Probe1771Tg.ACT_GetUsers + outcomes 'Done' { }; + user task Untargeted 'No targeting' + page Probe1771Tg.TaskPage + outcomes 'Done' { }; + multi user task MultiByXPath 'Multi by XPath' + page Probe1771Tg.TaskPage + targeting users xpath [Name = 'admin'] + outcomes 'Approve' { } 'Reject' { }; +end workflow; diff --git a/mdl-examples/bug-tests/splice-856-list-operation-where-bare-member.mdl b/mdl-examples/bug-tests/splice-856-list-operation-where-bare-member.mdl index 18ab2f0ecd..29ee651efe 100644 --- a/mdl-examples/bug-tests/splice-856-list-operation-where-bare-member.mdl +++ b/mdl-examples/bug-tests/splice-856-list-operation-where-bare-member.mdl @@ -25,10 +25,10 @@ -- -- To reproduce: exec this file against a project. Expected: -- Created microflow: BugTest856.F --- Modified microflow: BugTest856.F (spliced: 1 replaced) +-- Modified microflow: BugTest856.F (spliced: 1 edited in place) -- Before the fix the second statement was refused and exec rolled the whole -- script back. Running the file again sets the declare back and forth, and --- each statement again replaces the declare alone (spliced: 1 replaced). +-- each statement again edits the declare alone (spliced: 1 edited in place). mdl 1; diff --git a/mdl-examples/doctype-tests/24b-workflow-mendix10-examples.mdl b/mdl-examples/doctype-tests/24b-workflow-mendix10-examples.mdl index 8d504c3046..665a85a5a5 100644 --- a/mdl-examples/doctype-tests/24b-workflow-mendix10-examples.mdl +++ b/mdl-examples/doctype-tests/24b-workflow-mendix10-examples.mdl @@ -19,6 +19,13 @@ mdl 1; -- `annotation` is the workflow's own note; from 11.3 they are stored and the -- note is the start activity's. A branch's `end workflow` is stored on every -- version. +-- +-- User tasks (end of file) hid ako/mxcli#1771 the same way. Below 11.2 a task's +-- targeting is a UserSource, and every user task mxcli wrote on 10.24 failed mx +-- check with CE1859 "XPath constraint is required". Only the features 10.0 +-- has are used: user targeting, and the consensus, veto, majority and +-- threshold rules. Group targeting is 11.2, `await all users` 10.2 and +-- `decide by microflow` 10.3. -- ============================================================================ create module WfTen; @@ -75,3 +82,68 @@ begin false -> { end workflow; } ; end workflow; + +-- User tasks: users by XPath, users by a microflow, no targeting, and +-- multi-user tasks with each rule 10.0 has. +create microflow WfTen.ACT_Approvers ($Workflow: System.Workflow, $Context: WfTen.Request) +returns list of System.User as $Users +begin + retrieve $Users from System.User; + return $Users; +end; + +create page WfTen.TaskPage ( + title: 'Task', + layout: Atlas_Core.Atlas_Default, + params: ( $WorkflowUserTask: System.WorkflowUserTask ) +) { + dynamictext txtTask (content: 'Task') +}; + +create workflow WfTen.WF_Review + parameter $WorkflowContext: WfTen.Request + display 'Review' +begin + user task Review 'Review' + page WfTen.TaskPage + targeting users xpath [Name = 'admin'] + outcomes 'Approve' { } 'Reject' { end workflow; }; + user task Handle 'Handle' + page WfTen.TaskPage + targeting users microflow WfTen.ACT_Approvers + outcomes 'Done' { }; + user task Anyone 'Anyone' + page WfTen.TaskPage + outcomes 'Done' { }; + multi user task Agree 'Agree' + page WfTen.TaskPage + targeting users microflow WfTen.ACT_Approvers + outcomes 'Yes' { } 'No' { }; + multi user task Vote 'Vote' + page WfTen.TaskPage + targeting users xpath [Name = 'admin'] + participants 2 + decide by veto 'No' + outcomes 'Yes' { } 'No' { }; + multi user task Poll 'Poll' + page WfTen.TaskPage + targeting users microflow WfTen.ACT_Approvers + participants 50 percent + decide by majority more than half fallback 'No' + outcomes 'Yes' { } 'No' { }; + multi user task Quorum 'Quorum' + page WfTen.TaskPage + targeting users microflow WfTen.ACT_Approvers + decide by threshold 60 percent fallback 'No' + outcomes 'Yes' { } 'No' { }; +end workflow; + +-- ALTER inserts a user task through the mutator, and changes a task's +-- targeting in whichever key the version stores it. +alter workflow WfTen.WF_Review { + insert after Handle { user task Confirm 'Confirm' + page WfTen.TaskPage + targeting users xpath [Name = 'admin'] + outcomes 'OK' { }; } +}; +alter workflow WfTen.WF_Review { set (Targeting: xpath [Name = 'reviewer']) on Review; }; diff --git a/mdl/backend/mfmutator/splice.go b/mdl/backend/mfmutator/splice.go index a1f7a1f6fb..e43e7e5c49 100644 --- a/mdl/backend/mfmutator/splice.go +++ b/mdl/backend/mfmutator/splice.go @@ -17,10 +17,14 @@ package mfmutator // every other property of a rewired flow (its $ID, case value, error-handler // flag, the curve at its other end) is kept; // - nodes are moved only to make room, and only by a translation; -// - nothing else is touched, and no $ID is ever rewritten. A removed element -// leaves no reference behind: Save refuses a document in which any binary -// still names an element that was removed (CLAUDE.md rule 1), and one in -// which two elements share an $ID. +// - nothing else is touched, and no stored $ID is ever rewritten. A removed +// element leaves no reference behind: Save refuses a document in which any +// binary still names an element that was removed (CLAUDE.md rule 1), and +// one in which two elements share an $ID; +// - an activity replaced by one of the same kind is edited, not replaced: +// the replacement takes the stored activity's $IDs, and every reference to +// it is rewritten with them in the same pass (splice_edit.go, +// ako/mxcli#1761). // // What the splice cannot do safely it refuses, with the reason: an insert // after a decision (which branch?), before an activity several flows enter @@ -81,6 +85,11 @@ type Mutator struct { // 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 + // carried maps the $ID a same-kind replacement was built with to the + // stored $ID it takes over, put on at Bytes (ako/mxcli#1761, + // splice_edit.go); edited are the stored activities a Replace edited so. + carried map[string][]byte + edited map[string]bool } var _ backend.MicroflowMutator = (*Mutator)(nil) @@ -96,11 +105,15 @@ func New(doc bson.D, unitID model.ID, deps Deps) (*Mutator, error) { if dDoc(doc, "ObjectCollection") == nil { return nil, fmt.Errorf("unit %s has no object collection", unitID) } - return &Mutator{deps: deps, unitID: unitID, doc: doc, removed: map[string]bool{}}, nil + return &Mutator{deps: deps, unitID: unitID, doc: doc, removed: map[string]bool{}, + carried: map[string][]byte{}, edited: map[string]bool{}}, nil } // Bytes returns the patched unit, after the integrity checks Save applies. +// An activity a replace edited in place takes its stored identity back first +// (applyCarried). func (m *Mutator) Bytes() ([]byte, error) { + m.applyCarried() if err := m.checkIntegrity(); err != nil { return nil, err } @@ -467,6 +480,11 @@ func (m *Mutator) Replace(target model.ID, frag *backend.MicroflowFragment) erro if err != nil { return err } + // One activity of the same kind is an edit of the stored one, which keeps + // its identity (ako/mxcli#1761). + if edited, err := m.editInPlace(g, x, frag); edited || err != nil { + return err + } y := g.nodes[out.dest] if y == nil { return fmt.Errorf("flow %s points at an object that is not in the flow", out.id) @@ -528,7 +546,8 @@ func (m *Mutator) Replace(target model.ID, frag *backend.MicroflowFragment) erro // 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 +// nothing else in the body moves; of the same kind, it also takes the stored +// one's identity (ako/mxcli#1761). 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 @@ -554,7 +573,7 @@ func (m *Mutator) replaceInLoop(g *graph, x *node, frag *backend.MicroflowFragme 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) + return m.swapKeeping(g, x, dDoc(loop.doc, "ObjectCollection"), d, frag.Entry) } } in := g.incoming(x.id) @@ -828,7 +847,8 @@ func (m *Mutator) dropInLoop(g *graph, x *node) error { // handler by one activity handling errors the same way, in place // (mendixlabs/mxcli#1489): the new activity takes the stored one's place, is // drawn where it was at the size it was, and every flow that entered or left -// it — its error flow included — now enters or leaves the new one. The +// it — its error flow included — now enters or leaves the new one; of the +// same kind, it takes the stored one's identity too (ako/mxcli#1761). The // handler's own activities, and the merge its path rejoins at, are not // touched: changing one of them is a change of its own, addressed by its own // handle. Anything else — more than one activity, or one that does not raise @@ -864,22 +884,12 @@ func (m *Mutator) replaceKeepingHandler(g *graph, x *node, frag *backend.Microfl return fmt.Errorf("cannot replace %s by a %s: it has an error handler, which the splice keeps only on an "+ "activity", describeNode(x), t) } - if err := m.swapInPlace(g, x, dDoc(m.doc, "ObjectCollection"), d, frag.Entry); err != nil { + if err := m.swapKeeping(g, x, dDoc(m.doc, "ObjectCollection"), d, frag.Entry); err != nil { return err } - if notes == nil { - return nil - } - // A note drawn relative to the activity moves with it, from where the - // builder drew the activity to where the stored one is; one whose canvas - // position the script states stays there (mendixlabs/mxcli#1598). - for _, n := range notes.frag.Objects { - if !notes.frag.Pinned[n.GetID()] { - p := n.GetPosition() - n.SetPosition(model.Point{X: p.X + x.pos.X - notes.entryAt.X, Y: p.Y + x.pos.Y - notes.entryAt.Y}) - } - } - return m.addFragment(notes.frag) + // The notes move with the activity, from where the builder drew it to + // where the stored one is. + return m.addNotesAt(notes, x.pos) } // fragmentNotes are the notes a one-activity fragment attaches to its diff --git a/mdl/backend/mfmutator/splice_edit.go b/mdl/backend/mfmutator/splice_edit.go new file mode 100644 index 0000000000..45830c4191 --- /dev/null +++ b/mdl/backend/mfmutator/splice_edit.go @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "bytes" + "fmt" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/model" +) + +// # Editing an activity in place (ako/mxcli#1761) +// +// A replace by one activity of the same kind — the same activity $Type and +// the same action $Type — is an edit of the stored activity, the way Studio +// Pro edits one: the activity keeps its $ID, and so does every element under +// it that the new content has at the same place. Studio Pro compares and +// merges by $ID, so a replace that minted new ones showed an edited filter as +// one activity removed and another added, and turned a teammate's edit of the +// same activity on another branch into a conflict — while a decision whose +// condition changed the same way kept its $IDs (SetCondition). +// +// The replacement is serialized from the statement as for any replace and put +// where the stored activity is (swapInPlace). Its elements are then paired +// with the stored ones structurally (carryPairs), never by a shared value: +// +// - two elements pair when they have the same $Type at the same place: the +// activity itself, or the same key of two paired elements; +// - in a list, entries that name what they are about pair by that name — a +// member change by its attribute or association and whether it sets, adds +// or removes; a parameter mapping by its parameter; a translation by its +// language — so a reordered member keeps its $ID, and a removed member's +// $ID goes onto no other member. A list of unnamed entries pairs by +// position, and only when both lists hold the same $Types in the same +// order; +// - anything else is new: an element whose $Type differs (a find where a +// filter was, a custom range where a constant one was), and everything +// under it. Its stored counterpart is removed, so a reference left to it +// is refused at Save as for any removed element. +// +// The stored $IDs go on at Bytes, not at the replace. Until then the +// replacement carries the $IDs its fragment was built with, which the +// executor still names it by: a stored note linked to it after the replace +// (ako/mxcli#1695), a note the statement states. At Bytes every reference to +// a carried element is rewritten with it, in one pass over every binary in +// the unit, so no pointer is left naming the built $ID (CLAUDE.md rule 1). +// +// What is written is the replacement's content, as before: the statement +// states the activity whole (describe prints every property MDL has a +// spelling for), and the writer completes the property set Studio Pro writes +// (canon.CompletePropertySets). Only which $IDs it carries changes. + +// sameKind reports whether d, a serialized replacement, is the same kind of +// activity as stored: both action activities, whose actions have one $Type. +func sameKind(d, stored bson.D) bool { + const activity = "Microflows$ActionActivity" + if dString(d, "$Type") != activity || dString(stored, "$Type") != activity { + return false + } + na, sa := dDoc(d, "Action"), dDoc(stored, "Action") + return na != nil && sa != nil && dString(na, "$Type") != "" && dString(na, "$Type") == dString(sa, "$Type") +} + +// swapKeeping is swapInPlace that, when d is the same kind of activity as x +// (sameKind), carries x's identity onto it: x's $ID and those of the elements +// under it that pair up (carryPairs), put on at Bytes. +func (m *Mutator) swapKeeping(g *graph, x *node, oc, d bson.D, id model.ID) error { + same := sameKind(d, x.doc) + if err := m.swapInPlace(g, x, oc, d, id); err != nil { + return err + } + if same { + carryPairs(d, x.doc, m.carried) + m.edited[x.id] = true + } + return nil +} + +// EditedInPlace reports whether a Replace of target edited the stored +// activity, keeping its identity, rather than putting new elements in its +// place. +func (m *Mutator) EditedInPlace(target model.ID) bool { return m.edited[string(target)] } + +// editInPlace replaces the top-level activity x by frag's one activity when +// the two are the same kind, and reports whether it did. The replacement is +// written where x is in the collection, drawn where the script states — or, +// when it states nothing, where x is — at x's size (a Studio Pro activity may +// have been resized), and takes x's identity (swapKeeping). The notes frag +// states for the activity come with it, drawn where they are relative to it. +// Anything else — more than one activity, or another kind — is left to the +// replace, with nothing changed. +func (m *Mutator) editInPlace(g *graph, x *node, frag *backend.MicroflowFragment) (bool, error) { + rest, notes := withoutNotes(frag) + if !singleActivity(rest) { + return false, nil + } + obj := rest.Objects[0] + d, err := m.deps.SerializeObject(obj) + if err != nil { + return false, fmt.Errorf("serialize %T: %w", obj, err) + } + if d == nil || !sameKind(d, x.doc) { + return false, nil + } + at := x.pos + if frag.Placed { + p := obj.GetPosition() + at = point{p.X, p.Y} + } + obj.SetPosition(model.Point{X: at.X, Y: at.Y}) + dSet(d, "RelativeMiddlePoint", at.String()) + dSet(d, "Size", x.size.String()) + if err := m.swapKeeping(g, x, dDoc(m.doc, "ObjectCollection"), d, rest.Entry); err != nil { + return true, err + } + return true, m.addNotesAt(notes, at) +} + +// addNotesAt adds the notes a one-activity fragment attaches to its activity, +// now drawn at at: a note drawn relative to the activity moves with it, from +// where the builder drew the activity; one whose canvas position the script +// states stays there (mendixlabs/mxcli#1598). +func (m *Mutator) addNotesAt(notes *fragmentNotes, at point) error { + if notes == nil { + return nil + } + for _, n := range notes.frag.Objects { + if !notes.frag.Pinned[n.GetID()] { + p := n.GetPosition() + n.SetPosition(model.Point{X: p.X + at.X - notes.entryAt.X, Y: p.Y + at.Y - notes.entryAt.Y}) + } + } + return m.addFragment(notes.frag) +} + +// carryPairs records in into, for d and every element under it, the $ID of +// the element of stored it stands for: built $ID (its 16 bytes as a string) → +// stored $ID. d stands for stored when the two have one $Type; see the +// package comment above for how their children and list entries pair. +func carryPairs(d, stored bson.D, into map[string][]byte) { + if t := dString(d, "$Type"); t == "" || t != dString(stored, "$Type") { + return + } + if n, s := elementID(d), elementID(stored); n != nil && s != nil { + into[string(n)] = bytes.Clone(s) + } + for _, e := range d { + if e.Key == "$ID" || e.Key == "$Type" { + continue + } + switch nv := e.Value.(type) { + case bson.D: + if sd, ok := dGet(stored, e.Key).(bson.D); ok { + carryPairs(nv, sd, into) + } + case bson.A: + if sa, ok := dGet(stored, e.Key).(bson.A); ok { + for _, p := range pairEntries(nv, sa) { + carryPairs(p[0], p[1], into) + } + } + } + } +} + +// elementID is d's 16-byte binary $ID, or nil. +func elementID(d bson.D) []byte { + if b, ok := dGet(d, "$ID").(bson.Binary); ok && len(b.Data) == 16 { + return b.Data + } + return nil +} + +// entryNames are the properties by which a list entry names what it is about, +// in the order they are tried: a member change names its attribute or its +// association (one of the two is empty), a parameter mapping its parameter, a +// translation its language. +var entryNames = []string{"Attribute", "Association", "Parameter", "LanguageCode", "Name", "Key"} + +// entryKey is what a list entry is about — its $Type, the first of +// entryNames it states, and its Type where it has one (a member change that +// sets an association is not one that adds to it) — or "" for an entry that +// names nothing. +func entryKey(d bson.D) string { + for _, k := range entryNames { + if s := dString(d, k); s != "" { + key := dString(d, "$Type") + "\x00" + k + "=" + s + if t := dString(d, "Type"); t != "" { + key += "\x00" + t + } + return key + } + } + return "" +} + +// pairEntries pairs the entries of two lists of elements: by entryKey when +// every entry on both sides names what it is about (the n-th entry with a key +// with the n-th stored one with that key), by position when none does and the +// two lists hold the same $Types in the same order, and not at all otherwise. +func pairEntries(news, olds bson.A) [][2]bson.D { + nd, ok1 := entryDocs(news) + od, ok2 := entryDocs(olds) + if !ok1 || !ok2 || len(nd) == 0 || len(od) == 0 { + return nil + } + named, unnamed := 0, 0 + for _, d := range append(append([]bson.D(nil), nd...), od...) { + if entryKey(d) == "" { + unnamed++ + } else { + named++ + } + } + var out [][2]bson.D + switch { + case unnamed == 0: + byKey := map[string][]bson.D{} + for _, d := range od { + k := entryKey(d) + byKey[k] = append(byKey[k], d) + } + for _, d := range nd { + k := entryKey(d) + if q := byKey[k]; len(q) > 0 { + out = append(out, [2]bson.D{d, q[0]}) + byKey[k] = q[1:] + } + } + case named == 0: + if len(nd) != len(od) { + return nil + } + for i := range nd { + if dString(nd[i], "$Type") != dString(od[i], "$Type") { + return nil + } + } + for i := range nd { + out = append(out, [2]bson.D{nd[i], od[i]}) + } + } + return out +} + +// entryDocs returns the elements of a Mendix list, without its marker; false +// when it holds anything but elements (strings, pointers). +func entryDocs(a bson.A) ([]bson.D, bool) { + var out []bson.D + for i, v := range a { + switch t := v.(type) { + case bson.D: + if dString(t, "$Type") == "" { + return nil, false + } + out = append(out, t) + case int32: + if i != 0 { + return nil, false + } + default: + return nil, false + } + } + return out, true +} + +// applyCarried puts on each carried element the stored $ID it takes over, and +// rewrites every reference to it in the same pass: every 16-byte binary in the +// unit that holds the built $ID. A pair applies only while the element is in +// the unit and no element holds the stored $ID — an operation after the +// replace that took the replacement out again leaves the stored $ID removed. +// The stored $IDs that go back on are no longer removed elements. +func (m *Mutator) applyCarried() { + if len(m.carried) == 0 { + return + } + held := map[string]bool{} + collectIDs(m.doc, held) + sub := map[string][]byte{} + taken := map[string]bool{} + for n, s := range m.carried { + if held[n] && !held[string(s)] && !taken[string(s)] { + sub[n] = s + taken[string(s)] = true + } + } + substituteIDs(m.doc, sub) + for _, s := range sub { + delete(m.removed, string(s)) + } + m.carried = map[string][]byte{} +} + +// substituteIDs rewrites, in place, every 16-byte binary in v that sub maps. +func substituteIDs(v any, sub map[string][]byte) { + repl := func(x any) (any, bool) { + b, ok := x.(bson.Binary) + if !ok || len(b.Data) != 16 { + return nil, false + } + if s, ok := sub[string(b.Data)]; ok { + return bson.Binary{Subtype: b.Subtype, Data: bytes.Clone(s)}, true + } + return nil, true + } + switch t := v.(type) { + case bson.D: + for i := range t { + if r, isBin := repl(t[i].Value); isBin { + if r != nil { + t[i].Value = r + } + continue + } + substituteIDs(t[i].Value, sub) + } + case bson.A: + for i := range t { + if r, isBin := repl(t[i]); isBin { + if r != nil { + t[i] = r + } + continue + } + substituteIDs(t[i], sub) + } + } +} diff --git a/mdl/backend/mfmutator/splice_edit_in_place_test.go b/mdl/backend/mfmutator/splice_edit_in_place_test.go new file mode 100644 index 0000000000..df1e364924 --- /dev/null +++ b/mdl/backend/mfmutator/splice_edit_in_place_test.go @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "bytes" + "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#1761 (mendixlabs/mxcli#1667): re-running `create or modify +// microflow` with only a filter's expression changed did not edit the +// activity, it replaced it — the activity, its action and its operation all +// got new $IDs, while a decision whose condition changed the same way kept its +// own. Studio Pro compares and merges by $ID, so it showed the edited filter +// as one activity removed and another added. +// +// A replace by one activity of the same kind (same activity and action $Type) +// is an edit of the stored activity: the new content is written where the +// stored one is, and the stored $IDs are carried onto every element that pairs +// up — the same key of two paired elements, a list entry with the same name +// (an attribute, an association, a parameter), or the same position in a list +// of unnamed entries of the same length. What does not pair is new. + +// editDeps serializes an activity the way fakeDeps does, with action as its +// Action — the document the codec would write for the fragment's statement. +type editDeps struct { + fakeDeps + action bson.D +} + +func (d *editDeps) SerializeObject(o microflows.MicroflowObject) (bson.D, error) { + doc, err := d.fakeDeps.SerializeObject(o) + if err != nil || d.action == nil { + return doc, err + } + if _, ok := o.(*microflows.ActionActivity); ok && !dSet(doc, "Action", d.action) { + doc = append(doc, bson.E{Key: "Action", Value: d.action}) + } + return doc, nil +} + +// el is an element: its $ID is the named one, or a freshly minted one for "", +// as the builder mints them for a fragment. +func el(name, typ string, props ...bson.E) bson.D { + id := bin(name) + if name == "" { + id = bson.Binary{Data: types.UUIDToBlob(types.GenerateID())} + } + return append(bson.D{{Key: "$ID", Value: id}, {Key: "$Type", Value: typ}}, props...) +} + +func kv(k string, v any) bson.E { return bson.E{Key: k, Value: v} } + +// list is a stored list with its marker. +func list(marker int32, els ...bson.D) bson.A { + out := bson.A{marker} + for _, e := range els { + out = append(out, e) + } + return out +} + +// The six kinds the issue names. Each returns the action with the given $IDs +// ("" mints) and the value under test. +func filterAction(act, op, expr string) bson.D { + return el(act, "Microflows$ListOperationsAction", kv("ErrorHandlingType", "Rollback"), + kv("NewOperation", el(op, "Microflows$FilterByExpression", kv("Expression", expr), kv("ListName", "SaleList"))), + kv("ResultVariableName", "BigList")) +} + +func findAction(act, op, expr string) bson.D { + return el(act, "Microflows$ListOperationsAction", kv("ErrorHandlingType", "Rollback"), + kv("NewOperation", el(op, "Microflows$FindByExpression", kv("Expression", expr), kv("ListName", "SaleList"))), + kv("ResultVariableName", "Found")) +} + +func retrieveAction(act, src, rng, sorts, xpath string) bson.D { + return el(act, "Microflows$RetrieveAction", kv("ErrorHandlingType", "Rollback"), + kv("RetrieveSource", el(src, "Microflows$DatabaseRetrieveSource", kv("Entity", "M.Sale"), + kv("Range", el(rng, "Microflows$ConstantRange", kv("SingleObject", false))), + kv("XpathConstraint", xpath), + kv("NewSortings", el(sorts, "Microflows$SortingsList", kv("Sortings", list(2)))))), + kv("ResultVariableName", "Big")) +} + +func memberItem(id, attr, assoc, value string) bson.D { + return el(id, "Microflows$ChangeActionItem", kv("Attribute", attr), kv("Association", assoc), + kv("Type", "Set"), kv("Value", value)) +} + +func changeAction(typ, act string, items ...bson.D) bson.D { + return el(act, typ, kv("ErrorHandlingType", "Rollback"), kv("Items", list(2, items...)), + kv("RefreshInClient", false), kv("Commit", "No"), kv("ChangeVariableName", "Found")) +} + +func mapping(id, param, arg string) bson.D { + return el(id, "Microflows$MicroflowCallParameterMapping", kv("Argument", arg), kv("Parameter", param)) +} + +func callAction(act, call string, mappings ...bson.D) bson.D { + return el(act, "Microflows$MicroflowCallAction", kv("ErrorHandlingType", "Rollback"), + kv("MicroflowCall", el(call, "Microflows$MicroflowCall", kv("Microflow", "M.SUB"), + kv("ParameterMappings", list(2, mappings...)))), + kv("ResultVariableName", "Ok")) +} + +// withAction is line() with the activity a carrying action. +func withAction(action bson.D) []bson.D { + objs := line() + objs[1] = append(objs[1], bson.E{Key: "Action", Value: action}) + return objs +} + +// replaceA replaces a, which holds stored, by one activity serialized with +// replacement, and returns the mutator, what was saved and the fragment. +func replaceA(t *testing.T, stored, replacement bson.D) (*Mutator, []byte, *backend.MicroflowFragment) { + t.Helper() + raw, err := bson.Marshal(unit(withAction(stored), lineFlows())) + if err != nil { + t.Fatal(err) + } + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + deps := &editDeps{action: replacement} + m, err := New(d, "unit", deps) + if err != nil { + t.Fatal(err) + } + frag := oneActivity() + if err := m.Replace(model.ID(uid("a")), frag); err != nil { + t.Fatalf("replace: %v", err) + } + if err := m.Save(); err != nil { + t.Fatalf("save: %v", err) + } + return m, deps.saved, frag +} + +func TestSplice_ReplaceBySameKindEditsTheStoredActivity(t *testing.T) { + for _, tc := range []struct { + name string + // stored is a's action as stored; replacement the fragment's, with + // fresh $IDs; want the replacement with the stored $IDs. + stored, replacement, want bson.D + }{ + {"filter expression", + filterAction("act", "op", "$currentObject/Amount > 10"), + filterAction("", "", "$currentObject/Amount >= 10"), + filterAction("act", "op", "$currentObject/Amount >= 10")}, + {"find expression", + findAction("act", "op", "$currentObject/Amount = 3"), + findAction("", "", "$currentObject/Amount = 4"), + findAction("act", "op", "$currentObject/Amount = 4")}, + {"retrieve where", + retrieveAction("act", "src", "rng", "srt", "[Amount > 100]"), + retrieveAction("", "", "", "", "[Amount > 200]"), + retrieveAction("act", "src", "rng", "srt", "[Amount > 200]")}, + {"change member value", + changeAction("Microflows$ChangeAction", "act", memberItem("i1", "M.Sale.Amount", "", "7"), memberItem("i2", "M.Sale.Note", "", "'x'")), + changeAction("Microflows$ChangeAction", "", memberItem("", "M.Sale.Amount", "", "8"), memberItem("", "M.Sale.Note", "", "'x'")), + changeAction("Microflows$ChangeAction", "act", memberItem("i1", "M.Sale.Amount", "", "8"), memberItem("i2", "M.Sale.Note", "", "'x'"))}, + {"create member value", + changeAction("Microflows$CreateChangeAction", "act", memberItem("i1", "M.Sale.Amount", "", "1")), + changeAction("Microflows$CreateChangeAction", "", memberItem("", "M.Sale.Amount", "", "2")), + changeAction("Microflows$CreateChangeAction", "act", memberItem("i1", "M.Sale.Amount", "", "2"))}, + {"call microflow argument", + callAction("act", "call", mapping("p1", "M.SUB.Sale", "$New"), mapping("p2", "M.SUB.Factor", "2")), + callAction("", "", mapping("", "M.SUB.Sale", "$New"), mapping("", "M.SUB.Factor", "3")), + callAction("act", "call", mapping("p1", "M.SUB.Sale", "$New"), mapping("p2", "M.SUB.Factor", "3"))}, + } { + t.Run(tc.name, func(t *testing.T) { + m, saved, _ := replaceA(t, tc.stored, tc.replacement) + // What the edit should leave: the stored unit with a's action + // holding the new value under the stored $IDs — a keeps its + // $ID, its slot in the collection, its place and size, and the + // flows through it are untouched. + want, _ := bson.Marshal(unit(withAction(tc.want), lineFlows())) + if !bytes.Equal(saved, want) { + var got bson.D + _ = bson.Unmarshal(saved, &got) + t.Errorf("the replace did not edit the stored activity in place:\n got %v\nwant %v", got, unit(withAction(tc.want), lineFlows())) + } + if !m.EditedInPlace(model.ID(uid("a"))) { + t.Error("EditedInPlace reports a swapped activity") + } + }) + } +} + +// itemIDs maps each entry of the list at path under a's action to its $ID, by +// the entry's name property. +func itemIDs(t *testing.T, saved []byte, nameKey string, path ...string) map[string]string { + t.Helper() + var d bson.D + if err := bson.Unmarshal(saved, &d); err != nil { + t.Fatal(err) + } + m, _ := New(d, "unit", &fakeDeps{}) + a := m.graph().nodes[uid("a")] + if a == nil { + t.Fatal("a is not in the unit: the activity got a new $ID") + } + cur := dDoc(a.doc, "Action") + for _, k := range path[:len(path)-1] { + cur = dDoc(cur, k) + } + out := map[string]string{} + for _, e := range arrayElements(dGet(cur, path[len(path)-1])) { + if ed, ok := e.(bson.D); ok { + out[dString(ed, nameKey)+dString(ed, "Association")] = binaryID(dGet(ed, "$ID")) + } + } + return out +} + +// A member list pairs by member, never by position: a reordered member keeps +// its own $ID, a removed member's $ID goes with it — onto no other member — +// and an added member is new. +func TestSplice_ReplaceBySameKindPairsMembersByName(t *testing.T) { + t.Run("change members reordered, one dropped, one added", func(t *testing.T) { + _, saved, _ := replaceA(t, + changeAction("Microflows$ChangeAction", "act", + memberItem("i1", "M.Sale.Amount", "", "7"), + memberItem("i2", "M.Sale.Note", "", "'x'"), + memberItem("i3", "", "M.Sale_Customer", "$Cust")), + changeAction("Microflows$ChangeAction", "", + memberItem("", "M.Sale.Note", "", "'y'"), + memberItem("", "M.Sale.Amount", "", "8"), + memberItem("", "M.Sale.Code", "", "'c'"))) + ids := itemIDs(t, saved, "Attribute", "Items") + if ids["M.Sale.Note"] != uid("i2") || ids["M.Sale.Amount"] != uid("i1") { + t.Errorf("a reordered member lost its $ID: %v", ids) + } + if c := ids["M.Sale.Code"]; c == uid("i1") || c == uid("i2") || c == uid("i3") || c == "" { + t.Errorf("the added member took a stored $ID: %s", c) + } + if bytes.Contains(saved, types.UUIDToBlob(uid("i3"))) { + t.Error("the dropped association member's $ID is still in the unit") + } + }) + t.Run("call arguments reordered, one dropped", func(t *testing.T) { + _, saved, _ := replaceA(t, + callAction("act", "call", mapping("p1", "M.SUB.Sale", "$New"), mapping("p2", "M.SUB.Factor", "2")), + callAction("", "", mapping("", "M.SUB.Factor", "3"))) + ids := itemIDs(t, saved, "Parameter", "MicroflowCall", "ParameterMappings") + if len(ids) != 1 || ids["M.SUB.Factor"] != uid("p2") { + t.Errorf("want Factor alone, keeping p2's $ID, got %v", ids) + } + if bytes.Contains(saved, types.UUIDToBlob(uid("p1"))) { + t.Error("the dropped argument's $ID is still in the unit") + } + }) + t.Run("an association set member is not an add member", func(t *testing.T) { + add := memberItem("", "", "M.Sale_Customer", "$Cust") + dSet(add, "Type", "Add") + _, saved, _ := replaceA(t, + changeAction("Microflows$ChangeAction", "act", memberItem("i3", "", "M.Sale_Customer", "$Cust")), + changeAction("Microflows$ChangeAction", "", add)) + if bytes.Contains(saved, types.UUIDToBlob(uid("i3"))) { + t.Error("a member that now adds to the association kept the $ID of the one that set it") + } + }) +} + +// A replace by another kind of activity is not an edit: the activity is new, +// with new $IDs throughout, and the stored one is gone. The same action with +// another operation keeps the activity and the action, and only the operation +// is new. +func TestSplice_ReplaceByAnotherKindIsNew(t *testing.T) { + t.Run("another action", func(t *testing.T) { + m, saved, frag := replaceA(t, + filterAction("act", "op", "$currentObject/Amount > 10"), + changeAction("Microflows$ChangeAction", "", memberItem("", "M.Sale.Amount", "", "8"))) + for _, gone := range []string{"a", "act", "op"} { + if bytes.Contains(saved, types.UUIDToBlob(uid(gone))) { + t.Errorf("%s's $ID is still in the unit", gone) + } + } + if !edges(t, saved)[[2]string{uid("start"), string(frag.Entry)}] { + t.Error("the flow into the replaced activity does not enter the new one") + } + if m.EditedInPlace(model.ID(uid("a"))) { + t.Error("EditedInPlace reports an activity of another kind as edited") + } + }) + t.Run("another operation", func(t *testing.T) { + _, saved, _ := replaceA(t, + filterAction("act", "op", "$currentObject/Amount > 10"), + findAction("", "", "$currentObject/Amount > 10")) + var d bson.D + _ = bson.Unmarshal(saved, &d) + m, _ := New(d, "unit", &fakeDeps{}) + a := m.graph().nodes[uid("a")] + if a == nil { + t.Fatal("the activity got a new $ID") + } + act := dDoc(a.doc, "Action") + if binaryID(dGet(act, "$ID")) != uid("act") { + t.Error("the action got a new $ID") + } + op := dDoc(act, "NewOperation") + if dString(op, "$Type") != "Microflows$FindByExpression" || binaryID(dGet(op, "$ID")) == uid("op") { + t.Errorf("the find kept the filter's $ID: %v", op) + } + if bytes.Contains(saved, types.UUIDToBlob(uid("op"))) { + t.Error("the filter's $ID is still in the unit") + } + }) +} + +// The notes a create or modify states for the activity come with the edit, +// their lines attached to the stored activity; and a stored note linked to the +// fragment's activity after the replace (LinkNote, ako/mxcli#1695) is linked +// to the stored one. +func TestSplice_ReplaceBySameKindKeepsNotesOnTheStoredActivity(t *testing.T) { + objs := append(withAction(filterAction("act", "op", "x > 10")), obj("old", "Microflows$Annotation", 250, 330)) + raw, _ := bson.Marshal(unit(objs, lineFlows())) + var d bson.D + _ = bson.Unmarshal(raw, &d) + deps := &editDeps{action: filterAction("", "", "x >= 10")} + m, err := New(d, "unit", deps) + if err != nil { + t.Fatal(err) + } + frag := oneActivity() + note := µflows.Annotation{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: 360, Y: 100}, + }, Caption: "edited"} + frag.Objects = append(frag.Objects, note) + frag.AnnotationFlows = []*microflows.AnnotationFlow{{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + OriginID: note.ID, DestinationID: frag.Entry, + }} + if err := m.Replace(model.ID(uid("a")), frag); err != nil { + t.Fatal(err) + } + if err := m.LinkNote(model.ID(uid("old")), frag.Entry); err != nil { + t.Fatalf("link the stored note to the replacement: %v", err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + if bytes.Contains(deps.saved, types.UUIDToBlob(string(frag.Entry))) { + t.Error("the fragment's activity $ID is in the unit: the stored activity was not kept") + } + var got bson.D + _ = bson.Unmarshal(deps.saved, &got) + g, _ := New(got, "unit", &fakeDeps{}) + lines := map[string]bool{} + for _, f := range g.graph().annotationFlows(uid("a")) { + lines[f.origin] = true + } + if !lines[string(note.ID)] || !lines[uid("old")] { + t.Errorf("want the stated note and the stored one attached to a, got %v", lines) + } + if p := g.graph().nodes[string(note.ID)]; p == nil || p.pos != (point{250, 100}) { + t.Errorf("the note is not drawn where it is relative to the activity: %v", p) + } +} + +// Inside a loop body, and on an activity with a custom error handler, the +// replace already swapped one activity in place (ako/mxcli#1106, +// mendixlabs/mxcli#1489); of the same kind, it now keeps the stored identity +// there too. +func TestSplice_ReplaceBySameKindInPlaceKeepsIdentityEverywhere(t *testing.T) { + t.Run("in a loop body", func(t *testing.T) { + inner := append(obj("inner", "Microflows$ActionActivity", 100, 60), bson.E{Key: "Action", Value: filterAction("act", "op", "x > 10")}) + loop := append(obj("loop", "Microflows$LoopedActivity", 420, 200), 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, obj("inner2", "Microflows$ActionActivity", 260, 60)}}, + }}) + raw, _ := bson.Marshal(unit([]bson.D{obj("start", "Microflows$StartEvent", 100, 200), loop}, + []bson.D{flow("f1", "start", "loop", 1, 3, false), flow("fi", "inner", "inner2", 1, 3, false)})) + var d bson.D + _ = bson.Unmarshal(raw, &d) + deps := &editDeps{action: filterAction("", "", "x >= 10")} + m, err := New(d, "unit", deps) + if err != nil { + t.Fatal(err) + } + if err := m.Replace(model.ID(uid("inner")), oneActivity()); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + for _, kept := range []string{"inner", "act", "op"} { + if !bytes.Contains(deps.saved, types.UUIDToBlob(uid(kept))) { + t.Errorf("%s's $ID is gone", kept) + } + } + if !edges(t, deps.saved)[[2]string{uid("inner"), uid("inner2")}] { + t.Error("the body flow no longer leaves the stored activity") + } + if !m.EditedInPlace(model.ID(uid("inner"))) { + t.Error("EditedInPlace reports a swapped activity") + } + }) + t.Run("with an error handler", func(t *testing.T) { + objs, flows := handlerFlow() + act := callAction("act", "call", mapping("p1", "M.SUB.Factor", "2")) + dSet(act, "ErrorHandlingType", string(microflows.ErrorHandlingTypeCustomWithoutRollback)) + objs[1] = append(objs[1][:len(objs[1])-1], bson.E{Key: "Action", Value: act}) + raw, _ := bson.Marshal(unit(objs, flows)) + var d bson.D + _ = bson.Unmarshal(raw, &d) + repl := callAction("", "", mapping("", "M.SUB.Factor", "3")) + dSet(repl, "ErrorHandlingType", string(microflows.ErrorHandlingTypeCustomWithoutRollback)) + deps := &editDeps{action: repl} + m, err := New(d, "unit", deps) + if err != nil { + t.Fatal(err) + } + if err := m.Replace(model.ID(uid("a")), oneActivityEH(microflows.ErrorHandlingTypeCustomWithoutRollback)); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + for _, kept := range []string{"a", "act", "call", "p1"} { + if !bytes.Contains(deps.saved, types.UUIDToBlob(uid(kept))) { + t.Errorf("%s's $ID is gone", kept) + } + } + e := edges(t, deps.saved) + if !e[[2]string{uid("a"), uid("h")}] || !e[[2]string{uid("start"), uid("a")}] { + t.Errorf("the flows through the activity, its error flow included, do not run through the stored one: %v", e) + } + }) +} diff --git a/mdl/backend/mfmutator/splice_handler_test.go b/mdl/backend/mfmutator/splice_handler_test.go index 5f0df41dde..7e4eb00991 100644 --- a/mdl/backend/mfmutator/splice_handler_test.go +++ b/mdl/backend/mfmutator/splice_handler_test.go @@ -60,35 +60,69 @@ func handlerFlow() ([]bson.D, []bson.D) { } func TestSplice_ReplaceKeepsTheErrorHandler(t *testing.T) { - objs, flows := handlerFlow() - m, deps := newMutator(t, unit(objs, flows)) - frag := oneActivityEH(microflows.ErrorHandlingTypeCustomWithoutRollback) - if err := m.Replace(model.ID(uid("a")), frag); err != nil { - t.Fatal(err) - } - if err := m.Save(); err != nil { - t.Fatalf("save: %v", err) - } - // What the replace should leave: the stored unit with a's document - // swapped for the new one, drawn where a was at a's size, and the three - // flows through a — in, out, and the error flow — pointing at it. - newID := bson.Binary{Data: types.UUIDToBlob(string(frag.Entry))} - wantObjs, wantFlows := handlerFlow() - wantObjs[1] = withEH(bson.D{ - {Key: "$ID", Value: newID}, - {Key: "$Type", Value: "Microflows$ActionActivity"}, - {Key: "RelativeMiddlePoint", Value: "250;200"}, - {Key: "Size", Value: "160;60"}, - }, microflows.ErrorHandlingTypeCustomWithoutRollback) - dSet(wantFlows[0], "DestinationPointer", newID) - dSet(wantFlows[1], "OriginPointer", newID) - dSet(wantFlows[2], "OriginPointer", newID) - raw, _ := bson.Marshal(unit(wantObjs, wantFlows)) - if !bytes.Equal(deps.saved, raw) { - var got bson.D - _ = bson.Unmarshal(deps.saved, &got) - t.Errorf("the replace changed more than the activity and the flows through it:\n got %v\nwant %v", got, unit(wantObjs, wantFlows)) - } + // The same kind of activity (a call for a call) is an edit of the stored + // one, which keeps its $ID (ako/mxcli#1761): the replacement equal to it, + // the unit is the stored one, byte for byte. + t.Run("the same kind of activity", func(t *testing.T) { + objs, flows := handlerFlow() + m, deps := newMutator(t, unit(objs, flows)) + if err := m.Replace(model.ID(uid("a")), oneActivityEH(microflows.ErrorHandlingTypeCustomWithoutRollback)); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatalf("save: %v", err) + } + raw, _ := bson.Marshal(unit(handlerFlow())) + if !bytes.Equal(deps.saved, raw) { + var got bson.D + _ = bson.Unmarshal(deps.saved, &got) + t.Errorf("the replace by the same activity changed the unit:\n got %v\nwant %v", got, unit(handlerFlow())) + } + }) + // Another kind is a new activity: what the replace should leave is the + // stored unit with a's document swapped for the new one, drawn where a + // was at a's size, and the three flows through a — in, out, and the + // error flow — pointing at it. + t.Run("another kind of activity", func(t *testing.T) { + objs, flows := handlerFlow() + raw, _ := bson.Marshal(unit(objs, flows)) + var d bson.D + _ = bson.Unmarshal(raw, &d) + java := bson.D{ + {Key: "$Type", Value: "Microflows$JavaActionCallAction"}, + {Key: "ErrorHandlingType", Value: string(microflows.ErrorHandlingTypeCustomWithoutRollback)}, + } + deps := &editDeps{action: java} + m, err := New(d, "unit", deps) + if err != nil { + t.Fatal(err) + } + frag := oneActivityEH(microflows.ErrorHandlingTypeCustomWithoutRollback) + if err := m.Replace(model.ID(uid("a")), frag); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatalf("save: %v", err) + } + newID := bson.Binary{Data: types.UUIDToBlob(string(frag.Entry))} + wantObjs, wantFlows := handlerFlow() + wantObjs[1] = bson.D{ + {Key: "$ID", Value: newID}, + {Key: "$Type", Value: "Microflows$ActionActivity"}, + {Key: "RelativeMiddlePoint", Value: "250;200"}, + {Key: "Size", Value: "160;60"}, + {Key: "Action", Value: java}, + } + dSet(wantFlows[0], "DestinationPointer", newID) + dSet(wantFlows[1], "OriginPointer", newID) + dSet(wantFlows[2], "OriginPointer", newID) + want, _ := bson.Marshal(unit(wantObjs, wantFlows)) + if !bytes.Equal(deps.saved, want) { + var got bson.D + _ = bson.Unmarshal(deps.saved, &got) + t.Errorf("the replace changed more than the activity and the flows through it:\n got %v\nwant %v", got, unit(wantObjs, wantFlows)) + } + }) } func TestSplice_ReplaceOfAHandledActivityRefusals(t *testing.T) { diff --git a/mdl/backend/microflow_mutation.go b/mdl/backend/microflow_mutation.go index 8b8b4f0859..9ae785682f 100644 --- a/mdl/backend/microflow_mutation.go +++ b/mdl/backend/microflow_mutation.go @@ -47,8 +47,16 @@ type MicroflowMutator interface { InsertAfter(target model.ID, frag *MicroflowFragment) error // InsertBefore splices frag onto the one flow that enters target. InsertBefore(target model.ID, frag *MicroflowFragment) error - // Replace puts frag in target's place and removes target. + // Replace puts frag in target's place and removes target. A target + // replaced by one activity of the same kind — the same activity and + // action — is edited instead: the new content is written onto the stored + // activity, which keeps its $ID and those of the elements under it that + // pair up (ako/mxcli#1761). Replace(target model.ID, frag *MicroflowFragment) error + // EditedInPlace reports whether a Replace of target edited the stored + // activity, keeping its identity, rather than putting new elements in + // its place — what the report of the change says. + EditedInPlace(target model.ID) bool // 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. diff --git a/mdl/backend/modelsdk/widget_template_version_test.go b/mdl/backend/modelsdk/widget_template_version_test.go index ea77896491..7edeea39da 100644 --- a/mdl/backend/modelsdk/widget_template_version_test.go +++ b/mdl/backend/modelsdk/widget_template_version_test.go @@ -184,6 +184,75 @@ func TestTemplateWidgetValueTypeKeysAt10_15(t *testing.T) { } } +// typeKeySets returns the key set of every element of typeName in an encoded +// document. +func typeKeySets(t *testing.T, b []byte, typeName string) [][]string { + t.Helper() + var doc bsonv2.D + if err := bsonv2.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + var out [][]string + var walk func(v any) + walk = func(v any) { + switch x := v.(type) { + case bsonv2.D: + var keys []string + match := false + for _, e := range x { + keys = append(keys, e.Key) + if e.Key == "$Type" && e.Value == typeName { + match = true + } + walk(e.Value) + } + if match { + sort.Strings(keys) + out = append(out, keys) + } + case bsonv2.A: + for _, e := range x { + walk(e) + } + } + } + walk(doc) + return out +} + +// CustomWidgets$WidgetReturnType: Type on every version, AssignableTo from +// 9.20.0 (mendixmodelsdk 4.117.0). The vendored generator gave Type the row of +// AssignableTo and left AssignableTo out, so on 9.0–9.19 the template's return +// types lost the Type every one of them holds and kept an AssignableTo the +// version does not have (ako/mxcli#1163). Studio Pro stores {EntityProperty, +// IsList, Type} on 9.1.1 (208 of 208, the Marketplace's Employee Onboarding +// app) and 9.4.0 (326 of 326, "Example project workflow extension" 2.0.0), and +// adds AssignableTo by 9.24.3 (57 of 57, Multilevel Dynamic Workflow 1.0.0). +func TestTemplateWidgetReturnTypeKeysAcrossMendix9_20(t *testing.T) { + for _, tt := range []struct { + pv *types.ProjectVersion + want string + }{ + {v(9, 18, 0), "$ID,$Type,EntityProperty,IsList,Type"}, + {v(9, 20, 0), "$ID,$Type,AssignableTo,EntityProperty,IsList,Type"}, + {v(9, 24, 30), "$ID,$Type,AssignableTo,EntityProperty,IsList,Type"}, + {v(11, 13, 0), "$ID,$Type,AssignableTo,EntityProperty,IsList,Type"}, // control + } { + seen := 0 + for _, id := range mwidgets.ListAvailableTemplates() { + for _, got := range typeKeySets(t, encodeTemplateWidget(t, id, tt.pv), "CustomWidgets$WidgetReturnType") { + seen++ + if strings.Join(got, ",") != tt.want { + t.Errorf("%d.%d %s: WidgetReturnType keys %v, want %s", tt.pv.MajorVersion, tt.pv.MinorVersion, id, got, tt.want) + } + } + } + if seen == 0 { + t.Fatalf("%d.%d: no template holds a WidgetReturnType", tt.pv.MajorVersion, tt.pv.MinorVersion) + } + } +} + func containsKey(s []string, k string) bool { for _, x := range s { if x == k { diff --git a/mdl/backend/modelsdk/workflow_read.go b/mdl/backend/modelsdk/workflow_read.go index 5da51c1cd2..31e79e9f3b 100644 --- a/mdl/backend/modelsdk/workflow_read.go +++ b/mdl/backend/modelsdk/workflow_read.go @@ -192,6 +192,14 @@ func workflowActivityFromGen(el element.Element) workflows.WorkflowActivity { t.Page = taskPageName(a.TaskPage()) } t.Outcomes = userTaskOutcomesFromGen(a.OutcomesItems()) + // Below 10.12 a multi-user task is a UserTask whose completion holds + // what MultiUserTaskActivity holds itself from 10.12 (ako/mxcli#1771). + if mc, ok := a.UserTaskCompletion().(*genWf.MultiInputCompletion); ok && mc != nil { + t.IsMulti = true + t.AwaitAllUsers = mc.AwaitAllUsers() + t.CompletionCriteria = completionCriteriaFromGen(mc.CompletionCriteria(), t.Outcomes) + t.TargetUserInput = targetUserInputFromGen(mc.TargetUserInput()) + } return t case *genWf.SingleUserTaskActivity: t := &workflows.UserTask{ diff --git a/mdl/backend/modelsdk/workflow_usertask_shape_test.go b/mdl/backend/modelsdk/workflow_usertask_shape_test.go new file mode 100644 index 0000000000..21914ac10a --- /dev/null +++ b/mdl/backend/modelsdk/workflow_usertask_shape_test.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/modelsdk/canon" + "github.com/mendixlabs/mxcli/modelsdk/codec" + genWf "github.com/mendixlabs/mxcli/modelsdk/gen/workflows" + "github.com/mendixlabs/mxcli/modelsdk/version" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// ako/mxcli#1771: below 11.2 a user task's targeting is stored as UserSource, +// the shape canon.LowerToProjectShape writes on every unit write. describe +// reads what is stored, so a task written to such a project must read back as +// the targeting the statement gave it — or describe → exec drops it. + +// storedUserTask writes act as a workflow for a project of version pv — the +// encoder, then the lowering and the floor every unit write passes — and reads +// the stored document back. +func storedUserTask(t *testing.T, act *workflows.UserTask, pv *types.ProjectVersion) *workflows.UserTask { + t.Helper() + wf := &workflows.Workflow{ + Name: "WF", + Parameter: &workflows.WorkflowParameter{EntityRef: "M.Ctx"}, + Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{act}}, + } + raw, err := encodeWorkflow(wf, pv) + if err != nil { + t.Fatalf("encode: %v", err) + } + v := version.Version{Major: pv.MajorVersion, Minor: pv.MinorVersion, Patch: pv.PatchVersion} + if raw, err = canon.LowerToProjectShape(raw, &v); err != nil { + t.Fatalf("%s: lower: %v", v, err) + } + if raw, err = canon.StripUndeclaredProperties(raw, &v); err != nil { + t.Fatalf("%s: floor: %v", v, err) + } + el, err := codec.NewDecoder(codec.DefaultRegistry).Decode(raw) + if err != nil { + t.Fatalf("decode: %v", err) + } + f, _ := el.(*genWf.Workflow).Flow().(*genWf.Flow) + back := workflowFlowFromGen(f) + if len(back.Activities) != 1 { + t.Fatalf("%s: read %d activities, want 1", v, len(back.Activities)) + } + ut, ok := back.Activities[0].(*workflows.UserTask) + if !ok { + t.Fatalf("%s: read back %T, want a user task", v, back.Activities[0]) + } + return ut +} + +// Below 10.12 a user task is a Workflows$UserTask, and a multi-user task's +// rule, participants and `await all users` live in its UserTaskCompletion (a +// Workflows$MultiInputCompletion). Each reads back as the statement gave it, +// as on 10.12 and later. +func TestWorkflowRead_UserTaskInTheMendix9Shape(t *testing.T) { + outcomes := func() []*workflows.UserTaskOutcome { + return []*workflows.UserTaskOutcome{{Value: "Approve"}, {Value: "Reject"}} + } + for _, tc := range []struct { + name string + pv *types.ProjectVersion + multi bool + cc *workflows.CompletionCriteria + input *workflows.TargetUserInput + await bool + }{ + {"single", &types.ProjectVersion{MajorVersion: 9, MinorVersion: 24, PatchVersion: 30}, false, nil, nil, false}, + {"consensus", &types.ProjectVersion{MajorVersion: 9, MinorVersion: 24, PatchVersion: 30}, true, + &workflows.CompletionCriteria{Kind: "Consensus", FallbackOutcome: "Reject"}, &workflows.TargetUserInput{Kind: "All"}, false}, + {"veto, two participants", &types.ProjectVersion{MajorVersion: 9, MinorVersion: 24, PatchVersion: 30}, true, + &workflows.CompletionCriteria{Kind: "Veto", VetoOutcome: "Reject"}, &workflows.TargetUserInput{Kind: "Absolute", Amount: 2}, false}, + {"majority, await all", &types.ProjectVersion{MajorVersion: 10, MinorVersion: 6}, true, + &workflows.CompletionCriteria{Kind: "Majority", CompletionType: "Absolute", FallbackOutcome: "Approve"}, + &workflows.TargetUserInput{Kind: "Percentage", Percentage: 50}, true}, + {"control: 10.24", &types.ProjectVersion{MajorVersion: 10, MinorVersion: 24, PatchVersion: 24}, true, + &workflows.CompletionCriteria{Kind: "Veto", VetoOutcome: "Reject"}, &workflows.TargetUserInput{Kind: "Absolute", Amount: 2}, true}, + } { + ut := &workflows.UserTask{Page: "M.TaskPage", IsMulti: tc.multi, Outcomes: outcomes(), + UserSource: &workflows.XPathBasedUserSource{XPath: "[Name = 'admin']"}, + CompletionCriteria: tc.cc, TargetUserInput: tc.input, AwaitAllUsers: tc.await} + ut.Name, ut.Caption = "Review", "Review" + + got := storedUserTask(t, ut, tc.pv) + if got.IsMulti != tc.multi { + t.Errorf("%s: read back multi=%v, want %v", tc.name, got.IsMulti, tc.multi) + } + if !reflect.DeepEqual(got.UserSource, ut.UserSource) { + t.Errorf("%s: targeting read back as %#v", tc.name, got.UserSource) + } + if !tc.multi { + continue + } + if got.CompletionCriteria == nil || *got.CompletionCriteria != *tc.cc { + t.Errorf("%s: rule read back as %+v, want %+v", tc.name, got.CompletionCriteria, tc.cc) + } + if got.TargetUserInput == nil || *got.TargetUserInput != *tc.input { + t.Errorf("%s: participants read back as %+v, want %+v", tc.name, got.TargetUserInput, tc.input) + } + if got.AwaitAllUsers != tc.await { + t.Errorf("%s: await all users read back as %v, want %v", tc.name, got.AwaitAllUsers, tc.await) + } + } +} + +func TestWorkflowRead_UserTaskTargetingInTheMendix10Shape(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: "[Name = 'admin']"}}, + {"none", &workflows.NoUserSource{}}, + } { + for _, pv := range []*types.ProjectVersion{ + {MajorVersion: 10, MinorVersion: 12, PatchVersion: 19}, + {MajorVersion: 10, MinorVersion: 24, PatchVersion: 24}, + {MajorVersion: 11, MinorVersion: 1}, + {MajorVersion: 11, MinorVersion: 13}, // control: the 11.2 shape + } { + 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"}} + + got := storedUserTask(t, ut, pv) + if got.IsMulti != multi { + t.Errorf("%s %d.%d multi=%v: read back multi=%v", tc.name, pv.MajorVersion, pv.MinorVersion, multi, got.IsMulti) + } + if !reflect.DeepEqual(got.UserSource, tc.source) { + t.Errorf("%s %d.%d multi=%v: targeting read back as %#v, want %#v", + tc.name, pv.MajorVersion, pv.MinorVersion, multi, got.UserSource, tc.source) + } + } + } + } +} diff --git a/mdl/backend/wfmutator/mutator.go b/mdl/backend/wfmutator/mutator.go index 00a04e75a0..2dc409d3f5 100644 --- a/mdl/backend/wfmutator/mutator.go +++ b/mdl/backend/wfmutator/mutator.go @@ -313,21 +313,19 @@ func (m *Mutator) SetActivityProperty(activityRef string, atPos int, prop string return nil case "targeting_microflow": - userTargeting := bson.D{ + m.setUserTargeting(actDoc, bson.D{ {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, {Key: "$Type", Value: "Workflows$MicroflowUserTargeting"}, {Key: "Microflow", Value: value}, - } - bsonnav.DSet(actDoc, "UserTargeting", userTargeting) + }) return nil case "targeting_xpath": - userTargeting := bson.D{ + m.setUserTargeting(actDoc, bson.D{ {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, {Key: "$Type", Value: "Workflows$XPathUserTargeting"}, {Key: "XPathConstraint", Value: value}, - } - bsonnav.DSet(actDoc, "UserTargeting", userTargeting) + }) return nil case "due_date": @@ -768,6 +766,22 @@ func (m *Mutator) Save() error { // matching on $ID. Needed when appending new keys to an activity document, // because the slice header returned by findActivityByCaption cannot propagate // appends back to the parent bson.A. +// setUserTargeting replaces a user task's targeting with targeting, in the +// current (11.2) shape and in the place of whichever key stores it: below 11.2 +// that is UserSource (ako/mxcli#1771), which the write lowers it back to +// (canon.LowerToProjectShape). Setting UserTargeting only where it already +// existed changed nothing on such a task, and the statement still reported +// success. +func (m *Mutator) setUserTargeting(actDoc bson.D, targeting bson.D) { + for i := range actDoc { + if actDoc[i].Key == "UserTargeting" || actDoc[i].Key == "UserSource" { + actDoc[i] = bson.E{Key: "UserTargeting", Value: targeting} + return + } + } + m.replaceActivity(append(actDoc, bson.E{Key: "UserTargeting", Value: targeting})) +} + func (m *Mutator) replaceActivity(updated bson.D) { actID := bsonnav.ExtractBinaryIDFromDoc(bsonnav.DGet(updated, "$ID")) if actID == "" { diff --git a/mdl/backend/wfmutator/set_targeting_1771_test.go b/mdl/backend/wfmutator/set_targeting_1771_test.go new file mode 100644 index 0000000000..4b60857a95 --- /dev/null +++ b/mdl/backend/wfmutator/set_targeting_1771_test.go @@ -0,0 +1,79 @@ +// 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" +) + +// ako/mxcli#1771: below Mendix 11.2 a stored user task holds its targeting as +// UserSource, not UserTargeting. `alter workflow … set activity '…' targeting +// …` set UserTargeting only where the key already existed, so on 9.24 and +// 10.24 it found none, changed nothing and still reported "Altered workflow" +// (measured: describe printed the old targeting afterwards). The edit replaces +// the stored targeting, whichever key holds it, in the current shape; the +// write lowers it to the project's (canon.LowerToProjectShape). +func TestWorkflowMutator_SetTargetingReplacesAStoredUserSource(t *testing.T) { + for _, tc := range []struct { + prop, value, wantType, valueKey string + }{ + {"targeting_xpath", "[Name = 'reviewer']", "Workflows$XPathUserTargeting", "XPathConstraint"}, + {"targeting_microflow", "M.ACT_Reviewers", "Workflows$MicroflowUserTargeting", "Microflow"}, + } { + for _, typ := range []string{"Workflows$UserTask", "Workflows$SingleUserTaskActivity"} { + act := makeWfActivity(typ, "Review", "task1") + act = append(act, + bson.E{Key: "UserSource", Value: bson.D{{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$XPathBasedUserSource"}, {Key: "XPathConstraint", Value: "[Name = 'admin']"}}}, + bson.E{Key: "UserTaskCompletion", Value: nil}) + sourceAt := keyIndex(act, "UserSource") + m := newMutator(makeWorkflowDoc(act)) + + if err := m.SetActivityProperty("Review", 0, tc.prop, tc.value); err != nil { + t.Fatalf("%s on a %s: %v", tc.prop, typ, err) + } + actDoc, _ := m.findActivityByCaption("Review", 0) + if bsonnav.DGet(actDoc, "UserSource") != nil { + t.Errorf("%s on a %s: the stored UserSource is still there beside the new targeting", tc.prop, typ) + } + targeting := bsonnav.DGetDoc(actDoc, "UserTargeting") + if got := bsonnav.DGetString(targeting, "$Type"); got != tc.wantType { + t.Errorf("%s on a %s: targeting %q, want %s", tc.prop, typ, got, tc.wantType) + continue + } + if got := bsonnav.DGetString(targeting, tc.valueKey); got != tc.value { + t.Errorf("%s on a %s: %s = %q, want %q", tc.prop, typ, tc.valueKey, got, tc.value) + } + // In the stored key's place, so the lowered document keeps its order. + if i := keyIndex(actDoc, "UserTargeting"); i != sourceAt { + t.Errorf("%s on a %s: targeting at %d, want the stored source's place %d", tc.prop, typ, i, sourceAt) + } + } + } +} + +// A task holding neither key gets the targeting added. +func TestWorkflowMutator_SetTargetingAddsAMissingTargeting(t *testing.T) { + m := newMutator(makeWorkflowDoc(makeWfActivity("Workflows$SingleUserTaskActivity", "Review", "task1"))) + if err := m.SetActivityProperty("Review", 0, "targeting_xpath", "[Name = 'reviewer']"); err != nil { + t.Fatal(err) + } + actDoc, _ := m.findActivityByCaption("Review", 0) + if got := bsonnav.DGetString(bsonnav.DGetDoc(actDoc, "UserTargeting"), "XPathConstraint"); got != "[Name = 'reviewer']" { + t.Errorf("targeting %q, want it added", got) + } +} + +func keyIndex(d bson.D, key string) int { + for i, e := range d { + if e.Key == key { + return i + } + } + return -1 +} diff --git a/mdl/backend/widgetobj/builder.go b/mdl/backend/widgetobj/builder.go index f89bf4e2db..8f1d0408ce 100644 --- a/mdl/backend/widgetobj/builder.go +++ b/mdl/backend/widgetobj/builder.go @@ -882,9 +882,11 @@ func (ob *Builder) EnsureRequiredObjectLists() {} // ApplyPropertyVisibility nulls the TextTemplate of any TextTemplate-typed // property the rules mark as hidden under the widget's current configuration. -// Non-TextTemplate properties are left untouched: only the populated-vs-null -// ClientTemplate choice triggers CE0463, so Phase 1 scopes the action to -// TextTemplate. The widget's current primitive values (read from the assembled +// Non-TextTemplate properties are left untouched HERE, not because they are +// harmless: a hidden enumeration at a non-default value is CE0463 too +// (ako/mxcli#1762). The engine writes one the script does not name at its +// declared default (hiddenUnnamedProperties), and MDL-WIDGET10 refuses one it +// does. The widget's current primitive values (read from the assembled // object) drive rule evaluation, so a rule keyed on e.g. `type` sees the value // just set from MDL. @@ -897,8 +899,8 @@ func (ob *Builder) ApplyPropertyVisibility(rules []types.WidgetVisibilityRule) { // the updated object. It is the engine-agnostic form of ApplyPropertyVisibility // so build paths that construct a widget object without a full Builder (e.g. the // filter-widget build path) can apply the same #574 nulling. Non-TextTemplate -// properties are left untouched — only the populated-vs-null ClientTemplate -// choice triggers CE0463. +// properties are left untouched here; see ApplyPropertyVisibility for where a +// hidden enumeration's default is enforced instead. func ApplyVisibilityRules(object bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, rules []types.WidgetVisibilityRule) bson.D { if len(rules) == 0 { return object diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go index 06ed6c4deb..1205acecfa 100644 --- a/mdl/executor/cmd_alter_flow.go +++ b/mdl/executor/cmd_alter_flow.go @@ -151,6 +151,7 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato if err := a.setNodeNotes(ctx, mut, op, target); err != nil { return fail(err) } + a.noteEdited(op) continue } if op.SetCondition { @@ -164,6 +165,7 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato return fail(err) } a.noteRewritten(target, "if "+expr) + a.noteEdited(op) continue } if ret, ok := returnValueEdit(op, target); ok { @@ -177,6 +179,7 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato return fail(err) } a.noteRewritten(target, "return "+value) + a.noteEdited(op) continue } frag, vars, err := a.buildFragmentVars(ctx, op.Body) @@ -234,7 +237,11 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato return nil }) } - err = mut.Replace(target.ID, frag) + if err = mut.Replace(target.ID, frag); err == nil && mut.EditedInPlace(target.ID) { + // One activity of the same kind: the stored one, edited + // (ako/mxcli#1761). + a.noteEdited(op) + } default: err = fmt.Errorf("unknown operation") } @@ -309,6 +316,21 @@ type alterFlowContext struct { // 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 + + // edited are the replace operations that edited the stored node in place + // — a decision's condition, a return's value, a node's notes, an activity + // replaced by one of the same kind — so that it kept its $ID, as against + // one whose target was swapped for new elements (ako/mxcli#1761). The + // report of a `create or modify` tells the two apart. + edited map[*ast.AlterFlowOperation]bool +} + +// noteEdited records that op edited its stored target in place. +func (a *alterFlowContext) noteEdited(op *ast.AlterFlowOperation) { + if a.edited == nil { + a.edited = map[*ast.AlterFlowOperation]bool{} + } + a.edited[op] = true } // candidatesOf returns the stored candidates with the given $IDs. @@ -965,7 +987,18 @@ func (a *alterFlowContext) storedVariables(ctx *ExecContext) (varTypes, declared for _, p := range a.mf.Parameters { add(p.Name, p.Type) } + // What is typed from another variable — a retrieve over an association + // from its start, a loop's iterator from its list — is typed once the + // rest are, below. + deps := storedDependents{} + fb := &flowBuilder{backend: ctx.Backend} for _, c := range a.cands { + if loop, ok := c.Object.(*microflows.LoopedActivity); ok { + if src, ok := loop.LoopSource.(*microflows.IterableList); ok { + deps.add(src.VariableName, storedDependent{from: src.ListVariableName}) + } + continue + } act, ok := c.Object.(*microflows.ActionActivity) if !ok || c.OutputVariable == "" { continue @@ -983,7 +1016,13 @@ func (a *alterFlowContext) storedVariables(ctx *ExecContext) (varTypes, declared } case *microflows.RetrieveAction: // A database retrieve names its entity; one over an association - // depends on which side it starts from and stays untyped here. + // yields an end of it, which depends on the end it starts from + // and is typed below (ako/mxcli#1769). + if src, ok := x.Source.(*microflows.AssociationRetrieveSource); ok { + declared[c.OutputVariable] = "Unknown" + deps.add(c.OutputVariable, storedDependent{from: src.StartVariable, assoc: src.AssociationQualifiedName}) + break + } src, ok := x.Source.(*microflows.DatabaseRetrieveSource) entity := "" if ok { @@ -1004,7 +1043,7 @@ func (a *alterFlowContext) storedVariables(ctx *ExecContext) (varTypes, declared // A call's result has the called microflow's return type. var rt microflows.DataType if x.MicroflowCall != nil { - rt = (&flowBuilder{backend: ctx.Backend}).lookupMicroflowReturnType(x.MicroflowCall.Microflow) + rt = fb.lookupMicroflowReturnType(x.MicroflowCall.Microflow) } switch rt.(type) { case *microflows.ObjectType, *microflows.ListType: @@ -1016,9 +1055,72 @@ func (a *alterFlowContext) storedVariables(ctx *ExecContext) (varTypes, declared declared[c.OutputVariable] = "Unknown" } } + deps.typeAll(fb, varTypes, declared) return varTypes, declared } +// storedDependent is a stored variable whose type follows from another's: the +// output of a retrieve over assoc from the variable from, or — assoc empty — +// the iterator of a loop over the list from. +type storedDependent struct { + from, assoc string +} + +// storedDependents are the stored flow's dependent variables by name. A name +// two of them give different origins is nil: which one a fragment means +// depends on where it goes (two loops can share an iterator name), and a guess +// would qualify a member with the wrong entity. It stays untyped and the +// member is refused. +type storedDependents map[string]*storedDependent + +func (d storedDependents) add(name string, dep storedDependent) { + if name == "" { + return + } + if old, seen := d[name]; seen { + if old == nil || *old != dep { + d[name] = nil + } + return + } + d[name] = &dep +} + +// typeAll types every dependent whose origin is typed — a parameter, a +// database retrieve, another dependent — whatever order the stored flow lists +// them in, until a pass types nothing new. A name the rest of the stored flow +// already types keeps that type. +func (d storedDependents) typeAll(fb *flowBuilder, varTypes, declared map[string]string) { + for progress := true; progress; { + progress = false + for name, dep := range d { + if dep == nil { + continue + } + if _, typed := varTypes[name]; typed { + continue + } + from, ok := varTypes[dep.from] + if !ok { + continue + } + t := "" + if dep.assoc == "" { + if elem, isList := strings.CutPrefix(from, "List of "); isList { + t = elem + } + } else { + t = fb.associationRetrieveType(from, dep.assoc) + } + if t != "" { + varTypes[name] = t + delete(declared, name) + progress = true + } + } + } +} + // systemVariables are in scope everywhere they exist at all; the platform // reports a misuse (a $latestError outside an error handler) itself. var systemVariables = map[string]bool{ 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 856646225e..4f1e2c358e 100644 --- a/mdl/executor/cmd_flow_loop_body_edit_pedapp_test.go +++ b/mdl/executor/cmd_flow_loop_body_edit_pedapp_test.go @@ -114,7 +114,7 @@ func TestCreateOrModify_EditInsideLoopBody(t *testing.T) { if err := agreeExec(t, exec, edited); err != nil { t.Fatalf("an expression edit inside a loop body is refused: %v", err) } - if !strings.Contains(out.String(), "Modified microflow: MyFirstModule.LoopEdit (spliced: 1 replaced)") { + if !strings.Contains(out.String(), "Modified microflow: MyFirstModule.LoopEdit (spliced: 1 edited in place)") { t.Errorf("want the edit reported as one splice, got:\n%s", out.String()) } desc := describeFlow(t, exec, "LoopEdit") @@ -122,8 +122,9 @@ func TestCreateOrModify_EditInsideLoopBody(t *testing.T) { t.Errorf("the edit was not written:\n%s", desc) } - // Everything but the replaced activity keeps its $ID and its place: the - // loop, its other activity, and the flow around it. + // Every object keeps its $ID and its place: the loop, its other + // activity, and the edited activity itself, which is the stored one + // with a new expression (ako/mxcli#1761) — the same kind of activity. after := flowObjectIDs(loopEditFlowOf(t, exec, "LoopEdit").ObjectCollection) var gone, added []model.ID for id := range before { @@ -138,11 +139,8 @@ func TestCreateOrModify_EditInsideLoopBody(t *testing.T) { t.Errorf("object %s moved from %v to %v", id, q, p) } } - if len(gone) != 1 || len(added) != 1 { - t.Fatalf("want exactly the edited activity replaced, got %d gone, %d added", len(gone), len(added)) - } - if before[gone[0]] != after[added[0]] { - t.Errorf("the replacement is drawn at %v, the activity it replaces at %v", after[added[0]], before[gone[0]]) + if len(gone) != 0 || len(added) != 0 { + t.Fatalf("want the edited activity kept, got %d gone, %d added", len(gone), len(added)) } // The re-run is a fixed point. @@ -200,8 +198,8 @@ func TestCreateOrModify_EditInsideLoopBody(t *testing.T) { if err := agreeExec(t, exec, src); err != nil { t.Fatalf("refused: %v", err) } - if !strings.Contains(out.String(), "2 replaced") { - t.Errorf("want two in-place replaces, got:\n%s", out.String()) + if !strings.Contains(out.String(), "2 edited in place") { + t.Errorf("want two in-place edits, got:\n%s", out.String()) } desc := describeFlow(t, exec, "LoopEdit") if !strings.Contains(desc, "'still in loop'") || !strings.Contains(desc, "'start'") { diff --git a/mdl/executor/cmd_flow_modify.go b/mdl/executor/cmd_flow_modify.go index d6ceb4329b..5e93c59f44 100644 --- a/mdl/executor/cmd_flow_modify.go +++ b/mdl/executor/cmd_flow_modify.go @@ -187,7 +187,7 @@ func modifyFlowInPlace(ctx *ExecContext, d *flowDecl) (handled bool, err error) containerID = to } - switch summary := patchSummary(ops, moves, set); { + switch summary := patchSummary(ops, a.edited, moves, set); { case summary != "": ctx.ReportMutation("Modified", "%s: %s (%s)", d.kind(), d.name, summary) case moved: @@ -1029,6 +1029,17 @@ func (pd *patchDiff) gap(ins []ast.MicroflowStatement, stored []ast.MicroflowSta } } } + // A run of activities each changed in its own place is that many changes + // of one activity, so each can be edited in place (ako/mxcli#1761) rather + // than the run being replaced as a whole. + if pairwiseSameKind(ins, del) { + for i := range ins { + if err := pd.gap(ins[i:i+1], stored, si+i, si+i+1); err != nil { + return err + } + } + return nil + } cands := make([]mfmutator.Candidate, len(del)) for i, st := range del { c, err := pd.loc.locate(st) @@ -1453,6 +1464,16 @@ func (pd *patchDiff) bodyGap(lc mfmutator.Candidate, at ast.MicroflowStatement, return nil } } + // A run of activities each changed in its own place: one at a time, as + // at top level (ako/mxcli#1761). + if pairwiseSameKind(ins, del) { + for i := range ins { + if err := pd.bodyGap(lc, at, ins[i:i+1], stored, si+i, si+i+1); err != nil { + return err + } + } + 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 @@ -1612,6 +1633,24 @@ func replaceableInPlace(declared, stored ast.MicroflowStatement) bool { return true } +// pairwiseSameKind reports whether a run of more than one changed statement +// is each declared statement standing where the stored one of the same kind +// stood: as many of each, every pair the same kind of plain activity (not a +// decision, a loop, a return, a break or continue, nor one with an error +// handler). Each pair is then a change of one activity, which the splice +// edits in place when the activity stays the same kind (ako/mxcli#1761). +func pairwiseSameKind(ins, del []ast.MicroflowStatement) bool { + if len(ins) < 2 || len(ins) != len(del) { + return false + } + for i := range ins { + if statementKind(ins[i]) != statementKind(del[i]) || notPlainInBody(ins[i]) != "" || notPlainInBody(del[i]) != "" { + return false + } + } + return true +} + // customErrorHandler reports whether a statement has an `on error` with a // body: a handler path of its own, drawn in the body beside it. func customErrorHandler(st ast.MicroflowStatement) bool { @@ -1988,15 +2027,23 @@ func targetLabel(c mfmutator.Candidate) string { } // patchSummary says what a patch did, e.g. "spliced: 1 inserted, 2 moved; -// set: ExportLevel", or "" when it did nothing. -func patchSummary(ops []*ast.AlterFlowOperation, moves []flowMove, set []string) string { - var ins, rep, drop int +// set: ExportLevel", or "" when it did nothing. A replace that edited its +// stored node in place (edited) — which keeps its $ID, and so reads in Studio +// Pro's version control as that node changed — is told apart from one that +// put new elements in its place, which reads as one removed and another added +// (ako/mxcli#1761). +func patchSummary(ops []*ast.AlterFlowOperation, edited map[*ast.AlterFlowOperation]bool, moves []flowMove, set []string) string { + var ins, edit, rep, drop int for _, op := range ops { switch op.Op { case ast.AlterFlowInsertAfter, ast.AlterFlowInsertBefore: ins++ case ast.AlterFlowReplace, ast.AlterFlowReplaceBody: - rep++ + if edited[op] { + edit++ + } else { + rep++ + } case ast.AlterFlowDrop: drop++ } @@ -2005,7 +2052,7 @@ func patchSummary(ops []*ast.AlterFlowOperation, moves []flowMove, set []string) for _, p := range []struct { n int verb string - }{{ins, "inserted"}, {rep, "replaced"}, {drop, "dropped"}, {len(moves), "moved"}} { + }{{ins, "inserted"}, {edit, "edited in place"}, {rep, "replaced with new element IDs"}, {drop, "dropped"}, {len(moves), "moved"}} { if p.n > 0 { parts = append(parts, fmt.Sprintf("%d %s", p.n, p.verb)) } diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index 4862606c1d..8533b4d698 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -200,6 +200,60 @@ type flowBuilder struct { joinScopes map[string]flowBuilderVariableState } +// nestedBuilder returns the builder for a body nested in this one — a loop's, +// a while loop's, an error handler's — whose first activity is centred at +// (x, y). The body has its own position, graph and pending state, and +// inherits what says which flow is being built and how its statements are +// written: the variable scope, the backend and project lookups, the language, +// the notes, and the guards. Every nested body is built here, so a field is +// inherited by all of them or by none — bar what the one site that needs it +// sets (an error handler's return type and label table) — and +// TestNestedBuilder_InheritsSharedState fails until a new field is put on one +// side or the other. +// +// ako/mxcli#1769: each site listed the fields it copied, and none listed +// qualifiedMembersOnly, so a fragment spliced into a stored flow refused an +// unqualifiable member on its main line and wrote it in every body nested in +// it — `find $L by Code = …` in a replaced loop became a find by the invalid +// expression `Code = …` (CE0117). +func (fb *flowBuilder) nestedBuilder(x, y int) *flowBuilder { + return &flowBuilder{ + posX: x, + posY: y, + baseY: y, + spacing: HorizontalSpacing, + + varTypes: fb.varTypes, // one variable scope + declaredVars: fb.declaredVars, // (fixes a nil map panic) + textLang: fb.textLang, + measurer: fb.measurer, + backend: fb.backend, + quiet: fb.quiet, + hierarchy: fb.hierarchy, + restServices: fb.restServices, // for parameter classification + isNanoflow: fb.isNanoflow, + self: fb.self, + scriptFlows: fb.scriptFlows, + // The note registry, so a note declared outside the body and + // referenced on an activity in it attaches to the SAME Annotation + // rather than being refused. The describer emits exactly that (its + // label state is shared across the loop overlay), so a builder that + // refused it would reject its own DESCRIBE output. + // + // Across a loop boundary the result — Annotation in the parent + // collection, both AnnotationFlows in the parent collection, one + // destination inside the loop — was measured at 0 errors on mxbuild + // 11.13. mxbuild is the only oracle available here; Studio Pro is + // stricter in general, though this is the same split mxcli already + // ships for an ordinary loop-body note, whose flow is hoisted to the + // parent. (#1077) + annotationsByLabel: fb.annotationsByLabel, + storedNotes: fb.storedNotes, + // A body of a spliced fragment is spliced too (ako/mxcli#1769). + qualifiedMembersOnly: fb.qualifiedMembersOnly, + } +} + type flowBuilderVariableState struct { varTypes map[string]string declaredVars map[string]string diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index cbdf6f8161..55dd0c3c8b 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -2833,6 +2833,51 @@ func (fb *flowBuilder) lookupAssociation(moduleName, assocName string) *assocLoo return nil } +// associationRetrieveType is what `retrieve $X from $Start/assocQN` binds when +// $Start holds startType ("Module.Entity"): the list or the object at the +// other end, by associationRetrieveYieldsList — the answer addRetrieveAction +// and check --references take. Mind the inversion: an association's parent is +// its FROM end and its child the TO end, so a retrieve from the TO end yields +// the FROM entity. "" when it cannot be established: the association or the +// start unknown, the start a list, or the start no end of it — or, over a +// reference, both ends (a self-reference), where which end it starts from is +// not known. +func (fb *flowBuilder) associationRetrieveType(startType, assocQN string) string { + if startType == "" || strings.HasPrefix(startType, "List of ") { + return "" + } + module, name, ok := strings.Cut(assocQN, ".") + if !ok || module == "" || name == "" { + return "" + } + info := fb.lookupAssociation(module, name) + if info == nil || info.parentEntityQN == "" || info.childEntityQN == "" { + return "" + } + refSet := info.Type == domainmodel.AssociationTypeReferenceSet + fromTo := fb.entityIsSubtypeOf(startType, info.childEntityQN) + fromFrom := fb.entityIsSubtypeOf(startType, info.parentEntityQN) + switch { + case fromTo && fromFrom: + // Either end: a reference set to itself is a list of it whichever + // end it starts from, and nothing else is known. + if refSet && info.parentEntityQN == info.childEntityQN { + return "List of " + info.parentEntityQN + } + return "" + case !fromTo && !fromFrom: + return "" + } + other := info.childEntityQN + if fromTo { + other = info.parentEntityQN + } + if associationRetrieveYieldsList(refSet, info.Owner == domainmodel.AssociationOwnerBoth, fromTo) { + return "List of " + other + } + return other +} + // qualifyIteratorAttributes rewrites a bare attribute name in a FILTER/FIND // predicate into `$currentObject/`. // diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index c5d9e66a76..73f65951e2 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -711,38 +711,11 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID { } } - // Build nested ObjectCollection for loop body - loopBuilder := &flowBuilder{ - textLang: fb.textLang, - posX: innerStartX, - posY: innerStartY, - baseY: innerStartY, - spacing: HorizontalSpacing, - varTypes: fb.varTypes, // Share variable scope - declaredVars: fb.declaredVars, // Share declared vars (fixes nil map panic) - measurer: fb.measurer, // Share measurer - backend: fb.backend, // Share backend - quiet: fb.quiet, - hierarchy: fb.hierarchy, // Share hierarchy - restServices: fb.restServices, // Share REST services for parameter classification - isNanoflow: fb.isNanoflow, - self: fb.self, - scriptFlows: fb.scriptFlows, - // Share the note registry, so a note declared outside the loop and - // referenced on a body activity attaches to the SAME Annotation rather - // than being refused. The describer emits exactly that (its label state - // is shared across the loop overlay), so a builder that refused it would - // reject its own DESCRIBE output. - // - // The resulting shape — Annotation in the parent collection, both - // AnnotationFlows in the parent collection, one destination inside the - // loop — was measured at 0 errors on mxbuild 11.13. mxbuild is the only - // oracle available here; Studio Pro is stricter in general, though this - // 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, - } + // Build nested ObjectCollection for loop body. It shares the variable + // scope, the note registry and the guards (nestedBuilder), and not the + // label table: the LoopedActivity owns a collection a sequence flow may + // not leave. + loopBuilder := fb.nestedBuilder(innerStartX, innerStartY) // Process loop body statements and connect them with flows. // pendingCase carries the deferred case value a merge-less split leaves for @@ -1092,37 +1065,8 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID { loopLeftX = loopCenterX - loopWidth/2 } - loopBuilder := &flowBuilder{ - textLang: fb.textLang, - posX: innerStartX, - posY: innerStartY, - baseY: innerStartY, - spacing: HorizontalSpacing, - varTypes: fb.varTypes, - declaredVars: fb.declaredVars, - measurer: fb.measurer, - backend: fb.backend, - quiet: fb.quiet, - hierarchy: fb.hierarchy, - restServices: fb.restServices, - isNanoflow: fb.isNanoflow, - self: fb.self, - scriptFlows: fb.scriptFlows, - // Share the note registry, so a note declared outside the loop and - // referenced on a body activity attaches to the SAME Annotation rather - // than being refused. The describer emits exactly that (its label state - // is shared across the loop overlay), so a builder that refused it would - // reject its own DESCRIBE output. - // - // The resulting shape — Annotation in the parent collection, both - // AnnotationFlows in the parent collection, one destination inside the - // loop — was measured at 0 errors on mxbuild 11.13. mxbuild is the only - // oracle available here; Studio Pro is stricter in general, though this - // 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, - } + // A WHILE body is a loop body: same sharing as addLoopStatement's. + loopBuilder := fb.nestedBuilder(innerStartX, innerStartY) // Body bookkeeping is addLoopStatement's, verbatim: a WHILE body is a loop // body, so the same merge-less split needs the same handling. pendingCase diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 00aa4dcb71..2b8c23a15b 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -867,35 +867,20 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in errorX := sourceX // Build error handler activities - errBuilder := &flowBuilder{ - textLang: fb.textLang, - posX: errorX, - posY: errorY, - baseY: errorY, - spacing: HorizontalSpacing, - returnType: fb.returnType, - varTypes: fb.varTypes, - declaredVars: fb.declaredVars, - measurer: fb.measurer, - backend: fb.backend, - quiet: fb.quiet, - hierarchy: fb.hierarchy, - restServices: fb.restServices, - isNanoflow: fb.isNanoflow, - self: fb.self, - scriptFlows: fb.scriptFlows, - // A handler's activities are merged into the PARENT's object collection - // below, so a note declared outside the handler and referenced inside it - // (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 - // registry is a shared pointer rather than per-builder state. - labelReg: fb.labels(), - } + // A handler's activities are merged into the PARENT's object collection + // below, so a note declared outside the handler and referenced inside it + // (or the reverse) lands in one collection — sharing the note registry + // (nestedBuilder) is sound here in a way it is not across a loop boundary + // (#1077). + errBuilder := fb.nestedBuilder(errorX, errorY) + // The handler keeps the flow's return type, which its pending-handler + // routing and the undeclared-`set` advice read; a loop body never had it. + errBuilder.returnType = fb.returnType + // 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 + // registry is a shared pointer rather than per-builder state. + errBuilder.labelReg = fb.labels() var lastErrID model.ID var lastErrCase string @@ -1015,6 +1000,10 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in fb.returnEndIDs[id] = true } + // The row now occupies the lane under this activity's line, so the next + // statement that reaches into that lane waits for it (ako/mxcli#1768). + fb.reserveHandlerRow(sourceX, errorBody) + // If the error handler ends with RAISE ERROR or RETURN, it terminates there. // Otherwise, return the last activity ID so caller can create a merge. if errBuilder.endsWithReturn { diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index 1a4ca5122d..b8d681a81b 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -36,15 +36,22 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a // Set baseY for branch restoration (this is the center line) fb.baseY = fb.posY - // Pre-scan: if the first statement carries an @position annotation, shift the - // StartEvent to be one spacing unit to the left of that position so it doesn't - // end up behind activities that use explicit coordinates. - for _, stmt := range stmts { - if ann := getStatementAnnotations(stmt); ann != nil && ann.Position != nil { + // If the first statement carries an @position annotation, shift the StartEvent + // to be one spacing unit to the left of that position so it doesn't end up + // behind activities that use explicit coordinates. + // + // The first statement only. This used to take the first statement that had an + // @position, wherever it was, so a position on a later statement moved the + // start — and every unplaced statement before it, which follows the start — to + // that statement's place: `declare $A …; @position(840, 200) declare $B …` + // stored both at 840;200 (ako/mxcli#1768). Statements before a placed one keep + // the places the builder derives for them, as alter's patch already assumed + // (patchDiff.startEvent reads declared[0]). + if len(stmts) > 0 { + if ann := getStatementAnnotations(stmts[0]); ann != nil && ann.Position != nil { fb.posX = ann.Position.X - fb.spacing fb.posY = ann.Position.Y fb.baseY = fb.posY - break } } diff --git a/mdl/executor/cmd_microflows_builder_nested_test.go b/mdl/executor/cmd_microflows_builder_nested_test.go new file mode 100644 index 0000000000..460f13947d --- /dev/null +++ b/mdl/executor/cmd_microflows_builder_nested_test.go @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "reflect" + "sort" + "testing" + "unsafe" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" +) + +// nestedBuilderInherits classifies every field of flowBuilder: true when the +// builder of a nested body (a loop's, a while loop's, an error handler's) +// inherits it from its parent — nestedBuilder copies it — and false when the +// body keeps its own. A field added to flowBuilder fails +// TestNestedBuilder_InheritsSharedState until it is put here, which is the +// point: ako/mxcli#1769 was a field (qualifiedMembersOnly) that three +// hand-written copies each left out. +var nestedBuilderInherits = map[string]bool{ + // Which flow is built, and how its statements are written. + "varTypes": true, + "declaredVars": true, + "textLang": true, + "measurer": true, + "backend": true, + "quiet": true, + "hierarchy": true, + "restServices": true, + "isNanoflow": true, + "self": true, + "scriptFlows": true, + "annotationsByLabel": true, + "storedNotes": true, + "qualifiedMembersOnly": true, + + // Where the body is drawn: nestedBuilder's arguments, and the body's own + // lanes and rows (a loop box is sized to fit, so it never wraps). + "posX": false, "posY": false, "baseY": false, "spacing": false, + "lowerLane": false, "allowWrap": false, "row": false, + + // What the body builds, merged into the parent by the site. + "objects": false, "flows": false, "annotationFlows": false, "errors": false, + "statedNotes": false, "curveByOrigin": false, "rejoinMergeAt": false, + "returnEndIDs": false, "fallThroughEndID": false, "endsWithReturn": false, + "lastReturnEndID": false, "returnValue": false, + + // Per-statement pending state. + "nextConnectionPoint": false, "incomingRedirect": false, "nextFlowCase": false, + "nextFlowAnchor": false, "atBodyTail": false, "pendingAnnotations": false, + "previousStmtAnchor": false, "manualLoopBackTarget": false, "pendingJoin": false, + "emptyErrorHandlerFrom": false, "errorHandlerTailFrom": false, "errorHandlerSource": false, + "errorHandlerSkipVar": false, "errorHandlerTailCase": false, "errorHandlerTailAnchor": false, + "errorHandlerTailIsSource": false, "errorHandlerReturnValue": false, "pendingErrorHandlers": false, + + // Set by the one site that shares it: an error handler's body is in its + // parent's collection and on its path (addErrorHandlerFlow); a loop's is + // in a collection a sequence flow may not leave. + "returnType": false, "labelReg": false, + + // The whole flow's: the stored start event's position, and what + // buildFlowGraph collects from the body it builds. + "startPosition": false, "listInputVariables": false, "objectInputVariables": false, + "generatedVars": false, + + // Caches, filled on first use. + "assocCatalog": false, "microflowsCache": false, "microflowsCacheLoaded": false, + "nanoflowsCache": false, "nanoflowsCacheLoaded": false, + + // validateFlowBody's, which builds no nested builder. + "duplicateNamesOwnedElsewhere": false, "checkAssocShapes": false, + "assocObjectVars": false, "joinScopes": false, +} + +func TestNestedBuilder_InheritsSharedState(t *testing.T) { + typ := reflect.TypeOf(flowBuilder{}) + fields := map[string]bool{} + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + fields[name] = true + if _, ok := nestedBuilderInherits[name]; !ok { + t.Errorf("flowBuilder.%s is not classified: say in nestedBuilderInherits whether a nested body "+ + "inherits it (and copy it in nestedBuilder) or keeps its own", name) + } + } + var stale []string + for name := range nestedBuilderInherits { + if !fields[name] { + stale = append(stale, name) + } + } + sort.Strings(stale) + for _, name := range stale { + t.Errorf("nestedBuilderInherits names %s, which flowBuilder no longer has", name) + } + + parent := &flowBuilder{} + pv := reflect.ValueOf(parent).Elem() + for i := 0; i < pv.NumField(); i++ { + setNonZero(t, typ.Field(i).Name, settable(pv.Field(i))) + } + child := parent.nestedBuilder(11, 22) + cv := reflect.ValueOf(child).Elem() + placed := map[string]int64{"posX": 11, "posY": 22, "baseY": 22, "spacing": HorizontalSpacing} + for i := 0; i < cv.NumField(); i++ { + name := typ.Field(i).Name + p, c := settable(pv.Field(i)), settable(cv.Field(i)) + switch { + case placed[name] != 0: + if c.Int() != placed[name] { + t.Errorf("nestedBuilder(11, 22).%s = %d, want %d", name, c.Int(), placed[name]) + } + case nestedBuilderInherits[name]: + if !sameValue(p, c) { + t.Errorf("a nested body does not inherit flowBuilder.%s", name) + } + case !c.IsZero(): + t.Errorf("a nested body inherits flowBuilder.%s, classified as its own", name) + } + } +} + +// settable returns v — a field of an addressable struct, exported or not — +// as a value that can be read and set. +func settable(v reflect.Value) reflect.Value { + return reflect.NewAt(v.Type(), unsafe.Pointer(v.UnsafeAddr())).Elem() +} + +// setNonZero gives v a value distinguishable from its zero value and, for a +// reference, from any other instance of its type. +func setNonZero(t *testing.T, name string, v reflect.Value) { + t.Helper() + switch v.Kind() { + case reflect.Bool: + v.SetBool(true) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + v.SetInt(7) + case reflect.String: + v.SetString("x") + case reflect.Map: + v.Set(reflect.MakeMap(v.Type())) + case reflect.Slice: + v.Set(reflect.MakeSlice(v.Type(), 1, 1)) + case reflect.Pointer: + v.Set(reflect.New(v.Type().Elem())) + case reflect.Interface: + mb := reflect.ValueOf(&mock.MockBackend{}) + if !mb.Type().AssignableTo(v.Type()) { + t.Fatalf("flowBuilder.%s: no test value for %s", name, v.Type()) + } + v.Set(mb) + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + setNonZero(t, name, settable(v.Field(i))) + } + default: + t.Fatalf("flowBuilder.%s: no test value for kind %s", name, v.Kind()) + } +} + +// sameValue reports whether b is a's value — for a reference, the same +// instance, not an equal one. +func sameValue(a, b reflect.Value) bool { + switch a.Kind() { + case reflect.Map, reflect.Slice, reflect.Pointer: + return a.Pointer() == b.Pointer() + case reflect.Interface: + return !a.IsNil() && !b.IsNil() && a.Elem().Pointer() == b.Elem().Pointer() + } + return reflect.DeepEqual(a.Interface(), b.Interface()) +} diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 91eed9d63d..7c15790738 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -6,6 +6,8 @@ import ( "context" "strings" "sync" + + "github.com/mendixlabs/mxcli/mdl/types" ) // buildPropertyTypeKeyMap builds a map from PropertyType $ID to PropertyKey for a CustomWidget. @@ -1320,6 +1322,8 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any, enclosingEnti } var result []rawExplicitProp + stored := map[string]string{} // primitive values, for the visibility rules + defaults := map[string]string{} // the stored schema's DefaultValue per key props := getBsonArrayElements(obj["Properties"]) for _, prop := range props { propMap, ok := prop.(map[string]any) @@ -1335,6 +1339,10 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any, enclosingEnti if !ok { continue } + if pv := extractString(value["PrimitiveValue"]); pv != "" { + stored[propKey] = pv + } + defaults[propKey] = schemas[typePointerID].Default // Check for AttributeRef (attribute binding) if attrRef, ok := value["AttributeRef"].(map[string]any); ok && attrRef != nil { @@ -1401,7 +1409,53 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any, enclosingEnti }) } } - return result + return withoutHiddenDefaults(ctx, extractCustomWidgetID(ctx, w), result, stored, defaults) +} + +// withoutHiddenDefaults drops the described properties the widget's own +// editorConfig hides in its stored configuration while they hold their +// default. Such a property says nothing — exec writes a hidden property the +// script leaves out at that same default (hiddenUnnamedProperties) — and +// printing it drew an MDL-WIDGET10 warning on describe's own output: every +// Timeline hides one of its two format enumerations (ako/mxcli#1762). +// +// The rules are the ones `check` evaluates (the project's widget registry), so +// the two cannot disagree about what is hidden. A hidden property holding a +// NON-default value is kept: that widget fails mx check with CE0463, and +// printing the value is what lets `check` say so instead of describe hiding it. +func withoutHiddenDefaults(ctx *ExecContext, widgetID string, props []rawExplicitProp, stored, defaults map[string]string) []rawExplicitProp { + reg := ctx.GetWidgetRegistry() + if reg == nil || widgetID == "" || len(props) == 0 { + return props + } + def, ok := reg.GetByWidgetID(widgetID) + if !ok { + return props + } + hidden := map[string]bool{} + for _, rule := range visibilityRulesFor(def, reg) { + if rule.Nested() || rule.HiddenWhen == nil { + continue + } + fires, determinable := rule.Fires(func(c types.WidgetVisibilityCondition) (string, bool) { + v, ok := stored[c.PropertyKey] + return v, ok + }) + if determinable && fires { + hidden[rule.PropertyKey] = true + } + } + if len(hidden) == 0 { + return props + } + out := props[:0:0] + for _, p := range props { + if hidden[p.Key] && !p.IsRef && len(p.Params) == 0 && defaults[p.Key] != "" && p.Value == defaults[p.Key] { + continue + } + out = append(out, p) + } + return out } // extractImageProperties extracts properties from a pluggable Image CustomWidget. diff --git a/mdl/executor/cmd_pages_describe_timeline_hidden_1762_test.go b/mdl/executor/cmd_pages_describe_timeline_hidden_1762_test.go new file mode 100644 index 0000000000..a462154052 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_timeline_hidden_1762_test.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" +) + +// describe → check → exec on a Timeline, once the hide rules are known +// (ako/mxcli#1762). One of the two format enumerations is ALWAYS hidden — by +// customVisualization, or by the groupByKey switch — and a generic pluggable +// widget was described with every stored primitive, so each Timeline came back +// with a hidden property at its default and drew an MDL-WIDGET10 warning on +// mxcli's own output. Such a property carries nothing: exec writes a hidden +// property the script does not name at that same default. +func TestTimelineDescribe_HiddenDefaultsAreNotPrinted(t *testing.T) { + exec, out := pageFixtureExecutor(t) + run(t, exec, timelineSetup) + variants := []struct{ name, props, mustPrint, mustNotPrint string }{ + {"tlB", `customVisualization: true`, "customVisualization", "groupByDayOptions"}, + {"tlS", `groupByKey: 'year'`, "groupByKey: 'year'", "groupByMonthOptions"}, + {"tlM", `groupByKey: 'month', groupByMonthOptions: 'monthYear'`, "groupByMonthOptions: 'monthYear'", "groupByDayOptions"}, + } + for _, v := range variants { + run(t, exec, timelinePage(v.name, v.props)) + } + for _, v := range variants { + id := unitIDByName(t, exec, "page", "P_"+v.name) + before := storedBytes(t, exec, id) + + out.Reset() + run(t, exec, "describe page ModTL.P_"+v.name) + described := out.String() + if !strings.Contains(described, v.mustPrint) { + t.Errorf("%s: describe lost %q:\n%s", v.name, v.mustPrint, described) + } + if strings.Contains(described, v.mustNotPrint) { + t.Errorf("%s: describe printed the hidden %s:\n%s", v.name, v.mustNotPrint, described) + } + + prog, errs := visitor.Build(described) + if len(errs) > 0 { + t.Fatalf("%s: describe output does not parse: %v\n%s", v.name, errs, described) + } + if got := widget10For(ValidateWidgetProperties(prog, exec.backend.Path()), v.name); len(got) > 0 { + t.Errorf("%s: check of describe's own output reports %+v", v.name, got) + } + + if err := runErr(t, exec, described); err != nil { + t.Fatalf("%s: exec of the description: %v", v.name, err) + } + if !bytes.Equal(before, storedBytes(t, exec, id)) { + t.Errorf("%s: describe → exec changed the stored page", v.name) + } + } +} + +func storedBytes(t *testing.T, exec *Executor, unitID string) []byte { + t.Helper() + raw, err := exec.backend.GetRawUnitBytes(model.ID(unitID)) + if err != nil { + t.Fatal(err) + } + return append([]byte(nil), raw...) +} diff --git a/mdl/executor/editorconfig_extract.go b/mdl/executor/editorconfig_extract.go index 6ced442799..2450a8b721 100644 --- a/mdl/executor/editorconfig_extract.go +++ b/mdl/executor/editorconfig_extract.go @@ -127,8 +127,9 @@ func extractVisibilityRulesFromMPK(mpkPath, widgetID string) []types.WidgetVisib // widget's compiled editorConfig.js into declarative WidgetVisibilityRules. // // It recognizes the dominant `getProperties` idioms — `"V"===ref && hide(...)`, -// `"V"!==ref && hide(...)`, `ref && hide(...)`, `ref || hide(...)`, and -// `ref ? hide(...) : …` — where `ref` is `obj.prop` or a locally-aliased +// `"V"!==ref && hide(...)`, `ref && hide(...)`, `ref || hide(...)`, +// `ref ? hide(...) : …`, and a hide in a `switch(ref){case "V": …}` arm — where +// `ref` is `obj.prop` or a locally-aliased // identifier resolved within the enclosing function scope. Everything it cannot // lift (object-list-nested hides, compound/computed guards, unresolved aliases) // is counted in the returned stats so callers can gauge coverage; unrecognized @@ -159,7 +160,21 @@ func extractVisibilityRulesFromJS(js string) ([]types.WidgetVisibilityRule, edit if listKey != "" { itemIdent = enclosingForEachParam(js, callStart) } - cond, guardText, ok, conjunctive, chain := parseGuard(js, callStart, itemIdent) + // A hide in a `switch` arm runs on the arm's case values, not on a guard + // beside it, so it is read from the switch and never from parseGuard — + // whose `:` handling would take the arm's label for a ternary's else. + var ( + cond types.WidgetVisibilityCondition + guardText string + conjunctive bool + chain []types.WidgetVisibilityCondition + ) + armCond, inSwitchArm, armOK := switchArmCondition(js, callStart, itemIdent) + if inSwitchArm { + cond, ok = armCond, armOK + } else { + cond, guardText, ok, conjunctive, chain = parseGuard(js, callStart, itemIdent) + } // A guard inside `outer ? ( … inner && hide(x) … )` states only the INNER // term; the branch runs on outer too. Collect the enclosing group guards // so the rule carries the whole conjunction — Combo box nests three deep, @@ -254,13 +269,19 @@ func extractVisibilityRulesFromJS(js string) ([]types.WidgetVisibilityRule, edit // guard alone would mark both branches hidden together (#956). if len(condKeys) > 0 { aliases := enclosingAliases(js, callStart) + // In a switch arm the element's condition holds only where the arm + // runs, so the arm's condition is a term of its own. + condExtra := extra + if inSwitchArm { + condExtra = append([]types.WidgetVisibilityCondition{cond}, extra...) + } for _, tk := range condKeys { c, ok := guardToCondition(tk.guard, tk.falsy, aliases, itemIdent) if !ok { stats.SkippedComplex++ continue } - sig := listKey + "\x00" + tk.key + "\x00" + c.PropertyKey + c.Operator + c.Value + c.Scope + condsSig(extra) + sig := listKey + "\x00" + tk.key + "\x00" + c.PropertyKey + c.Operator + c.Value + c.Scope + condsSig(condExtra) if seen[sig] { continue } @@ -270,7 +291,7 @@ func extractVisibilityRulesFromJS(js string) ([]types.WidgetVisibilityRule, edit PropertyKey: tk.key, ListPropertyKey: listKey, HiddenWhen: &cc, - And: extra, + And: condExtra, }) } } @@ -810,6 +831,22 @@ func parseGuard(js string, callStart int, itemIdent string) (types.WidgetVisibil if !falsy { return types.WidgetVisibilityCondition{}, guard, false, false, nil } + case '(': + // The paren heads a STATEMENT rather than grouping a guarded branch: + // terser folds the expression statements before a `switch`/`if` into + // its head, and their contents run unconditionally. Timeline's first + // hide is the THEN branch of + // + // switch(e.customVisualization ? hide([…]) : hide([…]), e.groupEvents || hide([…]), e.groupByKey){…} + // + // and was dropped as "one operand of a compound condition" while its + // else branch, read through trailingExpr, was lifted (ako/mxcli#1762). + // A call's paren is not this case — the call can itself be guarded. + // boundaryAt indexes js only while pre is still a prefix of it, which + // an else-branch guard (cut out by ternaryCondition) is not. + if elseBranch || !strings.HasPrefix(js, pre) || !statementHeadParen(js, boundaryAt) { + return types.WidgetVisibilityCondition{}, guard, false, false, nil + } default: return types.WidgetVisibilityCondition{}, guard, false, false, nil } diff --git a/mdl/executor/editorconfig_switch.go b/mdl/executor/editorconfig_switch.go new file mode 100644 index 0000000000..056bff4dd5 --- /dev/null +++ b/mdl/executor/editorconfig_switch.go @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +// A widget's getProperties can choose what to hide with a `switch` on one of its +// enumerations. Timeline 3.2 does, and three of its six hide rules were unread +// (ako/mxcli#1762, mendixlabs/mxcli#1668): +// +// switch (…, e.groupByKey) { +// case "day": hidePropertyIn(t, e, "groupByMonthOptions"); break; +// case "month": hidePropertyIn(t, e, "groupByDayOptions"); break; +// default: hidePropertiesIn(t, e, ["groupByDayOptions", "groupByMonthOptions"]); +// } +// +// A hide in an arm fires on the values that enter the arm: its own labels, the +// labels of every arm that falls through into it, and — when a default arm is +// among those — every value no case names. That is one `eq`/`in` condition, or +// one `ne`/`notin` over the cases the switch does name. +// +// Exact or nothing, like the rest of the extractor: a hide carrying its own +// guard inside the arm, a label that is not a string literal, a switch that is +// not a statement of a function body, or a discriminant that is not one of the +// widget's properties yields no rule — which reads as "not hidden", the state +// every one of these was in before. + +// switchArm is one `case`/`default` arm of a switch body. +type switchArm struct { + labels []string // the case values; empty for a lone default + isDefault bool + start, end int // the arm's statements, as offsets into the switch body + exit armExit // whether control falls into the next arm +} + +// armExit is how control leaves a switch arm. +type armExit int + +const ( + armFallsThrough armExit = iota // runs on into the next arm + armTerminates // ends in break/return/throw/continue + armUnknown // cannot be told from its text (a block, a conditional break) +) + +// switchArmCondition reports whether the hide at callStart sits in a switch arm +// (inArm) and, when it does, the condition under which that arm runs. ok is +// false for an arm whose condition cannot be stated exactly; the caller then +// emits no rule and does NOT fall back to the guard parser, whose reading of +// the arm's `case "v":` label as a ternary's else could only be wrong. +func switchArmCondition(js string, callStart int, itemIdent string) (cond types.WidgetVisibilityCondition, inArm, ok bool) { + none := types.WidgetVisibilityCondition{} + open := enclosingOpenerQuoted(js, callStart) + if open < 0 || js[open] != '{' { + return none, false, false + } + head := strings.TrimRight(js[:open], " ") + if !strings.HasSuffix(head, ")") { + return none, false, false + } + parenOpen := matchingOpenParen(head) + if parenOpen < 0 { + return none, false, false + } + kwHead := strings.TrimRight(head[:parenOpen], " ") + if !hasKeywordSuffix(kwHead, "switch") { + return none, false, false + } + + // From here on the hide is in a switch arm. + kwStart := len(kwHead) - len("switch") + if !atFunctionStatementLevel(js, kwStart) { + return none, true, false // the switch runs only under some outer condition + } + // terser folds the preceding statements into the head — `switch(A, B, e.k)` — + // and the switch is on the LAST of them. + parts := splitTopLevelCommas(head[parenOpen+1 : len(head)-1]) + m := refRE.FindStringSubmatch(strings.TrimSpace(parts[len(parts)-1])) + if m == nil || m[1] == "!" { + return none, true, false + } + key, scope, resolved := resolveRef(m[2], enclosingAliases(js, kwStart), itemIdent) + if !resolved { + return none, true, false // the platform argument, or a computed local + } + body, balanced := balancedArgs(js, open+1) + if !balanced { + return none, true, false + } + arms, parsed := switchArms(body) + if !parsed { + return none, true, false + } + at := callStart - (open + 1) + idx := -1 + for i, a := range arms { + if at >= a.start && at < a.end { + idx = i + } + } + if idx < 0 || !onlyHideStatementsBefore(body[arms[idx].start:at]) { + return none, true, false + } + + first := idx +walk: + for first > 0 { + switch arms[first-1].exit { + case armTerminates: + break walk + case armUnknown: + // Treating it either way could be wrong: as falling through, this + // rule claims its values; as terminating, it misses them. + return none, true, false + } + first-- // that arm falls through into this one + } + var entered []string + hasDefault := false + for _, a := range arms[first : idx+1] { + entered = append(entered, a.labels...) + hasDefault = hasDefault || a.isDefault + } + op, vals := "in", uniqueStrings(entered) + if hasDefault { + // Entered by every value no case names, plus this run's own cases: the + // complement of the OTHER arms' cases. + var all []string + for _, a := range arms { + all = append(all, a.labels...) + } + op, vals = "notin", withoutStrings(uniqueStrings(all), vals) + } + switch { + case len(vals) == 0: + return none, true, false // every value enters: unconditional, not a condition + case len(vals) == 1 && op == "in": + return types.WidgetVisibilityCondition{PropertyKey: key, Operator: "eq", Value: vals[0], Scope: scope}, true, true + case len(vals) == 1: + return types.WidgetVisibilityCondition{PropertyKey: key, Operator: "ne", Value: vals[0], Scope: scope}, true, true + } + for _, v := range vals { + if strings.ContainsRune(v, ',') { + return none, true, false // the joined set would decode wrong + } + } + return types.WidgetVisibilityCondition{PropertyKey: key, Operator: op, Value: strings.Join(vals, ","), Scope: scope}, true, true +} + +// switchArms splits a switch body into its arms. ok is false for anything but +// `case "literal":` and `default:` labels, or for text before the first label. +func switchArms(body string) ([]switchArm, bool) { + var arms []switchArm + depth := 0 + inStr := byte(0) + for i := 0; i < len(body); i++ { + c := body[i] + if inStr != 0 { + if c == '\\' { + i++ + } else if c == inStr { + inStr = 0 + } + continue + } + switch c { + case '"', '\'', '`': + inStr = c + continue + case '(', '[', '{': + depth++ + continue + case ')', ']', '}': + depth-- + continue + } + if depth != 0 || (i > 0 && (isIdentByte(body[i-1]) || body[i-1] == '.')) { + continue + } + var arm switchArm + var next int + switch { + case hasKeywordAt(body, i, "case"): + label, end, ok := switchCaseLabel(body, i+len("case")) + if !ok { + return nil, false + } + arm.labels, next = []string{label}, end + case hasKeywordAt(body, i, "default"): + j := skipSpaces(body, i+len("default")) + if j >= len(body) || body[j] != ':' { + return nil, false + } + arm.isDefault, next = true, j+1 + default: + continue + } + if len(arms) == 0 { + if strings.TrimSpace(body[:i]) != "" { + return nil, false + } + } else { + arms[len(arms)-1].end = i + } + arm.start, arm.end = next, len(body) + arms = append(arms, arm) + i = next - 1 + } + if len(arms) == 0 { + return nil, false + } + for i := range arms { + arms[i].exit = exitOf(body[arms[i].start:arms[i].end]) + } + return arms, true +} + +// switchCaseLabel reads the `"value":` after a `case` keyword, returning the value and +// the offset just past the colon. +func switchCaseLabel(body string, from int) (string, int, bool) { + j := skipSpaces(body, from) + if j >= len(body) || (body[j] != '"' && body[j] != '\'') { + return "", 0, false + } + q := body[j] + k := strings.IndexByte(body[j+1:], q) + if k < 0 { + return "", 0, false + } + value := body[j+1 : j+1+k] + if strings.ContainsRune(value, '\\') { + return "", 0, false + } + end := skipSpaces(body, j+1+k+1) + if end >= len(body) || body[end] != ':' { + return "", 0, false + } + return value, end + 1, true +} + +// abruptKeywords leave a switch arm. +var abruptKeywords = []string{"break", "return", "throw", "continue"} + +// exitOf reports how control leaves an arm. Only two readings are certain: the +// last statement is itself a break/return/throw/continue, or the arm holds none +// of them and no block that could hide one. Anything else — `{ …; break }`, +// `if (c) break; …` — is unknown. +func exitOf(arm string) armExit { + stmts := splitTopLevel(arm, ';') + for i := len(stmts) - 1; i >= 0; i-- { + s := strings.TrimSpace(stmts[i]) + if s == "" { + continue + } + for _, kw := range abruptKeywords { + if hasKeywordAt(s, 0, kw) { + return armTerminates + } + } + if mayLeaveEarly(arm) { + return armUnknown + } + return armFallsThrough + } + return armFallsThrough // an empty arm (a grouped label) falls through +} + +// mayLeaveEarly reports whether s, outside its string literals, holds a block or +// an abrupt keyword anywhere. +func mayLeaveEarly(s string) bool { + inStr := byte(0) + for i := 0; i < len(s); i++ { + c := s[i] + if inStr != 0 { + if c == '\\' { + i++ + } else if c == inStr { + inStr = 0 + } + continue + } + switch c { + case '"', '\'', '`': + inStr = c + continue + case '{': + return true + } + if i > 0 && (isIdentByte(s[i-1]) || s[i-1] == '.') { + continue + } + for _, kw := range abruptKeywords { + if hasKeywordAt(s, i, kw) { + return true + } + } + } + return false +} + +// onlyHideStatementsBefore reports whether the text of an arm before a hide is +// nothing but earlier hide calls — `case "v": hide(a), hide(b); hide(c)` — so +// the hide is guarded by the arm alone and not by a condition of its own. +func onlyHideStatementsBefore(prefix string) bool { + p := trimNamespacePrefix(strings.TrimRight(prefix, " ")) + for { + if strings.TrimSpace(p) == "" { + return true + } + if !strings.HasSuffix(p, ";") && !strings.HasSuffix(p, ",") { + return false + } + call := strings.TrimRight(p[:len(p)-1], " ") + if !strings.HasSuffix(call, ")") { + return false + } + open := matchingOpenParen(call) + if open < 0 { + return false + } + name := strings.TrimRight(call[:open], " ") + loc := hideCallTailRE.FindStringIndex(name) + if loc == nil || loc[1] != len(name) { + return false + } + p = trimNamespacePrefix(strings.TrimRight(name[:loc[0]], " ")) + } +} + +func trimNamespacePrefix(p string) string { + if loc := nsPrefixRE.FindStringIndex(p); loc != nil { + return strings.TrimRight(p[:loc[0]], " ") + } + return p +} + +// statementHeadParen reports whether the '(' at open is the head of a `switch` +// or `if` statement sitting directly in a function body. Its contents then run +// whenever the function does, so a guard bounded by it is statement-level. +func statementHeadParen(js string, open int) bool { + head := strings.TrimRight(js[:open], " ") + for _, kw := range []string{"switch", "if"} { + if hasKeywordSuffix(head, kw) { + return atFunctionStatementLevel(js, len(head)-len(kw)) + } + } + return false +} + +// atFunctionStatementLevel reports whether the statement starting at pos is one +// of a function body's own statements — not an `else`'s, not under an `if` +// without braces, not inside a nested block. Anything else runs under a +// condition the rule would not carry. +func atFunctionStatementLevel(js string, pos int) bool { + before := strings.TrimRight(js[:pos], " ") + if before != "" { + switch before[len(before)-1] { + case '{', ';', '}': + default: + return false + } + } + open := enclosingOpenerQuoted(js, pos) + return open >= 0 && js[open] == '{' && isFunctionBody(js, open) +} + +// isFunctionBody reports whether the '{' at open opens a function's body: +// `function(…){`, `function name(…){`, or an arrow's `=>{`. +func isFunctionBody(js string, open int) bool { + head := strings.TrimRight(js[:open], " ") + if strings.HasSuffix(head, "=>") { + return true + } + if !strings.HasSuffix(head, ")") { + return false + } + params := matchingOpenParen(head) + if params < 0 { + return false + } + name := strings.TrimRight(head[:params], " ") + if hasKeywordSuffix(name, "function") { + return true + } + i := len(name) + for i > 0 && isIdentByte(name[i-1]) { + i-- + } + return i < len(name) && hasKeywordSuffix(strings.TrimRight(name[:i], " "), "function") +} + +// enclosingOpenerQuoted is enclosingOpener skipping quoted strings, so a caption +// holding a bracket does not throw the count off. +func enclosingOpenerQuoted(js string, pos int) int { + depth := 0 + inStr := byte(0) + for i := pos - 1; i >= 0; i-- { + c := js[i] + if inStr != 0 { + if c == inStr && (i == 0 || js[i-1] != '\\') { + inStr = 0 + } + continue + } + switch c { + case '"', '\'': + inStr = c + case '}', ')', ']': + depth++ + case '{', '(', '[': + if depth == 0 { + return i + } + depth-- + } + } + return -1 +} + +// hasKeywordSuffix reports whether s ends with the keyword kw as a whole word. +func hasKeywordSuffix(s, kw string) bool { + if !strings.HasSuffix(s, kw) { + return false + } + i := len(s) - len(kw) + return i == 0 || (!isIdentByte(s[i-1]) && s[i-1] != '.') +} + +// hasKeywordAt reports whether the keyword kw starts at s[i] as a whole word. +func hasKeywordAt(s string, i int, kw string) bool { + if !strings.HasPrefix(s[i:], kw) { + return false + } + end := i + len(kw) + return end == len(s) || !isIdentByte(s[end]) +} + +func skipSpaces(s string, i int) int { + for i < len(s) && s[i] == ' ' { + i++ + } + return i +} + +// splitTopLevel splits s on sep where it is not nested in a bracket or string. +func splitTopLevel(s string, sep byte) []string { + var parts []string + depth := 0 + inStr := byte(0) + start := 0 + for i := 0; i < len(s); i++ { + c := s[i] + if inStr != 0 { + if c == '\\' { + i++ + } else if c == inStr { + inStr = 0 + } + continue + } + switch c { + case '"', '\'', '`': + inStr = c + case '(', '[', '{': + depth++ + case ')', ']', '}': + depth-- + case sep: + if depth == 0 { + parts = append(parts, s[start:i]) + start = i + 1 + } + } + } + return append(parts, s[start:]) +} + +func withoutStrings(ss, drop []string) []string { + out := ss[:0:0] + for _, s := range ss { + keep := true + for _, d := range drop { + if s == d { + keep = false + break + } + } + if keep { + out = append(out, s) + } + } + return out +} diff --git a/mdl/executor/editorconfig_switch_1762_test.go b/mdl/executor/editorconfig_switch_1762_test.go new file mode 100644 index 0000000000..6864c6c3cc --- /dev/null +++ b/mdl/executor/editorconfig_switch_1762_test.go @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 + +// The Timeline's hide rules (ako/mxcli#1762, mendixlabs/mxcli#1668). +// +// A pluggable widget's enumeration that its editorConfig hides must be stored at +// its XML default, or mx check reports CE0463. mxcli refuses that for the one +// Timeline rule it recognised (`groupEvents: false`), and passed the rest: +// `mxcli widget describe timeline` reported "2 of 6 editor hide-rules +// recognized". Measured on mxbuild 11.13.0 with Timeline 3.2.2, one widget per +// variant, nothing else hidden on the page: +// +// customVisualization: true, groupByDayOptions: 'dayMonth' → CE0463 (1) +// customVisualization: true, groupByKey: 'month', groupByMonthOptions: 'monthYear' → CE0463 (1) +// groupByKey: 'day', groupByMonthOptions: 'monthYear' → CE0463 (4) +// groupByKey: 'month', groupByDayOptions: 'fullDate' → CE0463 (5) +// groupByKey: 'year', groupByDayOptions: 'dayMonth' → CE0463 (6) +// groupByKey: 'year', groupByMonthOptions: 'monthYear' → CE0463 (6) +// controls — custom mode with neither set; 'day' + groupByDayOptions; +// 'month' + groupByMonthOptions → 0 errors +// +// That matrix is this file's oracle. Two shapes went unread. terser folds the +// statements before the `switch` into its head, `switch(A ? hide : hide, B || +// hide, e.groupByKey)`, so rule (1)'s guard is bounded by the `switch(` paren, +// which the guard parser took for a conditional group. And the `case`/`default` +// arms of a switch had no reading at all. +package executor + +import ( + "slices" + "testing" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +// timelineGetProperties is Timeline 3.2.3's getProperties, verbatim from the +// marketplace package (3.2.2 differs only in the namespace identifier). +const timelineGetProperties = `exports.getProperties=function(e,t){switch(e.customVisualization?I.hidePropertiesIn(t,e,["title","description","icon","timeIndication","groupByDayOptions","groupByMonthOptions"]):I.hidePropertiesIn(t,e,["customIcon","customTitle","customEventDateTime","customDescription","customGroupHeader"]),e.groupEvents||I.hidePropertiesIn(t,e,["groupAttribute","groupByKey","groupByDayOptions","groupByMonthOptions","ungroupedEventsPosition","customGroupHeader"]),e.groupByKey){case"day":I.hidePropertyIn(t,e,"groupByMonthOptions");break;case"month":I.hidePropertyIn(t,e,"groupByDayOptions");break;default:I.hidePropertiesIn(t,e,["groupByDayOptions","groupByMonthOptions"])}return t};` + +// timelineCase is one configuration and whether prop is hidden under it. +type timelineCase struct { + prop string + cv string // customVisualization + ge string // groupEvents + key string // groupByKey + hidden bool +} + +var timelineCases = []timelineCase{ + // Rule (1), the ternary's THEN branch inside the switch head. + {"groupByDayOptions", "true", "true", "day", true}, // measured: CE0463 + {"groupByMonthOptions", "true", "true", "month", true}, // measured: CE0463 + {"title", "true", "true", "day", true}, + {"description", "true", "true", "day", true}, + {"timeIndication", "true", "true", "day", true}, + {"icon", "true", "true", "day", true}, + {"title", "false", "true", "day", false}, + {"icon", "false", "true", "day", false}, + // Rule (2), its ELSE branch — recognised before; the control. + {"customTitle", "false", "true", "day", true}, + {"customTitle", "true", "true", "day", false}, + // Rule (3) — recognised before; the control. + {"ungroupedEventsPosition", "false", "false", "day", true}, + {"ungroupedEventsPosition", "false", "true", "day", false}, + // Rules (4)–(6), the switch's arms. + {"groupByMonthOptions", "false", "true", "day", true}, // (4) measured: CE0463 + {"groupByDayOptions", "false", "true", "month", true}, // (5) measured: CE0463 + {"groupByDayOptions", "false", "true", "year", true}, // (6) measured: CE0463 + {"groupByMonthOptions", "false", "true", "year", true}, // (6) measured: CE0463 + {"groupByDayOptions", "false", "true", "day", false}, // measured: clean + {"groupByMonthOptions", "false", "true", "month", false}, // measured: clean +} + +func checkTimelineCases(t *testing.T, rules []types.WidgetVisibilityRule) { + t.Helper() + for _, c := range timelineCases { + values := map[string]string{"customVisualization": c.cv, "groupEvents": c.ge, "groupByKey": c.key} + hidden, found := ruleHidesUnder(rules, c.prop, values) + if !found && c.hidden { + t.Errorf("%s: no rule at all; want hidden under %v", c.prop, values) + continue + } + if hidden != c.hidden { + t.Errorf("%s under %v: hidden=%v, want %v", c.prop, values, hidden, c.hidden) + } + } +} + +func TestTimelineHideRules_AllSixRecognised(t *testing.T) { + rules, stats := extractVisibilityRulesFromJS(timelineGetProperties) + if stats.TotalHideCalls != 6 || stats.Recognized != 6 { + t.Errorf("coverage %d of %d; want 6 of 6", stats.Recognized, stats.TotalHideCalls) + } + checkTimelineCases(t, rules) +} + +// The same through the committed package (testdata/expr-checker ships Timeline +// 3.2.2), which is what `widget describe` and the .def.json generator read. +func TestTimelineHideRules_FromTheInstalledPackage(t *testing.T) { + const mpkPath = "../../testdata/expr-checker/widgets/com.mendix.widget.web.Timeline.mpk" + rules, recognized, total := ExtractWidgetVisibilityStats(mpkPath, "com.mendix.widget.web.timeline.Timeline") + if total != 6 || recognized != 6 { + t.Errorf("%d of %d editor hide-rules recognized; want 6 of 6", recognized, total) + } + checkTimelineCases(t, rules) +} + +// A switch's arms, including the shapes Timeline does not use: grouped labels, +// fall-through into the next arm, a default arm that is not last, comma and +// semicolon siblings. +func TestSwitchArms_FallThroughAndDefault(t *testing.T) { + js := `exports.getProperties=function(e,t){switch(e.k){` + + `case"a":x.hidePropertyIn(t,e,"p1");` + // falls through into b/c + `case"b":case"c":x.hidePropertyIn(t,e,"p2");break;` + + `case"d":x.hidePropertyIn(t,e,"p3"),x.hidePropertyIn(t,e,"p4");x.hidePropertyIn(t,e,"p5");break;` + + `default:x.hidePropertyIn(t,e,"p6");` + // falls through into e + `case"e":x.hidePropertyIn(t,e,"p7")` + + `}return t}` + rules, stats := extractVisibilityRulesFromJS(js) + if stats.Recognized != stats.TotalHideCalls { + t.Errorf("coverage %d of %d", stats.Recognized, stats.TotalHideCalls) + } + hiddenFor := map[string][]string{ + "p1": {"a"}, + "p2": {"a", "b", "c"}, + "p3": {"d"}, "p4": {"d"}, "p5": {"d"}, + "p6": {"z"}, // only a value no case names + "p7": {"e", "z"}, // its own case, and the default that falls into it + } + for prop, want := range hiddenFor { + for _, k := range []string{"a", "b", "c", "d", "e", "z"} { + hidden, _ := ruleHidesUnder(rules, prop, map[string]string{"k": k}) + if hidden != slices.Contains(want, k) { + t.Errorf("%s with k=%q: hidden=%v, want %v", prop, k, hidden, slices.Contains(want, k)) + } + } + } +} + +// Shapes a switch rule cannot state soundly are lifted as nothing — the safe +// direction, which is what each was before. +func TestSwitchArms_UnrepresentableShapesProduceNoRule(t *testing.T) { + cases := map[string]string{ + // the arm's hide carries its own guard: hidden on `k=="a" AND flag` + "q1": `function(e,t){switch(e.k){case"a":e.flag&&x.hidePropertyIn(t,e,"q1");break}return t}`, + // a label that is not a literal makes the default arm's complement unknown + "q2": `function(e,t){switch(e.k){case n:x.hidePropertyIn(t,e,"q0");break;default:x.hidePropertyIn(t,e,"q2")}return t}`, + // a switch under an `if` runs only when the if holds + "q3": `function(e,t){if(e.on){switch(e.k){case"a":x.hidePropertyIn(t,e,"q3")}}return t}`, + // the discriminant is not one of the widget's properties (the platform) + "q4": `function(e,t,r){switch(r){case"web":x.hidePropertyIn(t,e,"q4")}return t}`, + // an arm every value reaches is unconditional, which no condition states + "q5": `function(e,t){switch(e.k){default:x.hidePropertyIn(t,e,"q5")}return t}`, + // whether the arm before falls through cannot be read off its last + // statement — a block, a conditional break — so q6's arm is not known + "q6": `function(e,t){switch(e.k){case"a":{x.hidePropertyIn(t,e,"q0");break}case"b":x.hidePropertyIn(t,e,"q6")}return t}`, + "q7": `function(e,t){switch(e.k){case"a":if(e.f)break;x.hidePropertyIn(t,e,"q0");case"b":x.hidePropertyIn(t,e,"q7")}return t}`, + } + for prop, js := range cases { + rules, _ := extractVisibilityRulesFromJS(js) + if r := findRule(rules, prop); r != nil { + t.Errorf("%s: lifted %+v from %s; want no rule", prop, r, js) + } + } +} + +// A switch inside an object list's forEach reads the item. +func TestSwitchArms_NestedItemScope(t *testing.T) { + js := `exports.getProperties=function(e,t){e.items.forEach((function(r,n){switch(r.mode){` + + `case"a":x.hideNestedPropertiesIn(t,e,"items",n,["s1"]);break;` + + `default:x.hidePropertyIn(t,e,"items",n,"s2")}}));return t}` + rules, _ := extractVisibilityRulesFromJS(js) + s1, s2 := findRule(rules, "s1"), findRule(rules, "s2") + if s1 == nil || s1.ListPropertyKey != "items" || s1.HiddenWhen == nil || + *s1.HiddenWhen != (types.WidgetVisibilityCondition{PropertyKey: "mode", Operator: "eq", Value: "a", Scope: types.ConditionScopeItem}) { + t.Errorf("s1 = %+v; want items/s1 hidden when the item's mode is \"a\"", s1) + } + if s2 == nil || s2.ListPropertyKey != "items" || s2.HiddenWhen == nil || + *s2.HiddenWhen != (types.WidgetVisibilityCondition{PropertyKey: "mode", Operator: "ne", Value: "a", Scope: types.ConditionScopeItem}) { + t.Errorf("s2 = %+v; want items/s2 hidden when the item's mode is not \"a\"", s2) + } +} + +// terser folds statements into an `if(` head the same way it does a `switch(`; +// a CALL's paren is different, because the call can itself be conditional. +func TestStatementHeadParenIsNotAGroup(t *testing.T) { + js := `exports.getProperties=function(e,t){` + + `if(e.a?x.hidePropertyIn(t,e,"r1"):x.hidePropertyIn(t,e,"r2"),e.b)return t;` + + `e.y&&foo(e.x&&x.hidePropertyIn(t,e,"r3"));return t}` + rules, _ := extractVisibilityRulesFromJS(js) + if h, _ := ruleHidesUnder(rules, "r1", map[string]string{"a": "true"}); !h { + t.Error("r1: want hidden when a is set") + } + if h, _ := ruleHidesUnder(rules, "r1", map[string]string{"a": "false"}); h { + t.Error("r1: hidden when a is not set") + } + if h, _ := ruleHidesUnder(rules, "r2", map[string]string{"a": "false"}); !h { + t.Error("r2 (the else branch, the control): want hidden when a is not set") + } + if r := findRule(rules, "r3"); r != nil { + t.Errorf("r3: lifted %+v; a guard inside a call's arguments is one conjunct of `e.y && …`", r) + } +} + +// fileUploaderGetProperties is File Uploader 2.5.0's getProperties, verbatim from +// the marketplace package (content 235351). +const fileUploaderGetProperties = `exports.getProperties=function(e,t){return e.readOnlyMode?A.hidePropertiesIn(t,e,["files"===e.uploadMode?"associatedImages":"associatedFiles","createImageAction","createFileAction","allowedFileFormats","maxFilesPerUpload","maxFilesPerBatch","maxFileSize","objectCreationTimeout"]):"files"===e.uploadMode?(A.hidePropertiesIn(t,e,["associatedImages","createImageAction"]),function(e,t){var r;e.allowedFileFormats.forEach(function(r,n){"simple"===r.configMode?A.hideNestedPropertiesIn(t,e,"allowedFileFormats",n,["mimeType","extensions","typeFormatDescription"]):A.hideNestedPropertiesIn(t,e,"allowedFileFormats",n,["predefinedType"])});var n=null===(r=t[0].properties)||void 0===r?void 0:r.find(function(e){return"allowedFileFormats"===e.key});if(n){var o;n.objectHeaders=["File type",""],null===(o=n.objects)||void 0===o||o.forEach(function(t,r){var n=e.allowedFileFormats[r];"simple"===n.configMode?t.captions=[E[n.predefinedType].description," "]:t.captions=[n.typeFormatDescription,"".concat(z(n.mimeType,n.extensions))]})}}(e,t)):A.hidePropertiesIn(t,e,["associatedFiles","createFileAction","allowedFileFormats"]),"files"===e.uploadMode?A.hidePropertiesIn(t,e,["onUploadSuccessImage","onUploadFailureImage"]):A.hidePropertiesIn(t,e,["onUploadSuccessFile","onUploadFailureFile"]),e.enableCustomButtons?e.customButtons.forEach(function(r,n){A.hideNestedPropertiesIn(t,e,"customButtons",n,"files"===e.uploadMode?["buttonActionImage"]:["buttonActionFile"])}):A.hidePropertiesIn(t,e,["customButtons"]),t};` + +// ako/mxcli#258 item 1: the ternary-wrapped `hidePropertiesIn` that gates the +// create* actions on uploadMode. It was unread when filed ("6 of 9"); the +// else-if chain reading added for mendixlabs/mxcli#1583 lifts it, so this pins +// that, and that the switch/statement-head changes for #1762 leave it alone. +func TestFileUploaderUploadModeGate_258(t *testing.T) { + rules, _ := extractVisibilityRulesFromJS(fileUploaderGetProperties) + cases := []struct { + prop, readOnly, mode string + hidden bool + }{ + {"createImageAction", "false", "files", true}, + {"createImageAction", "false", "images", false}, + {"createImageAction", "true", "images", true}, + {"createFileAction", "false", "images", true}, + {"createFileAction", "false", "files", false}, + {"createFileAction", "true", "files", true}, + {"onUploadSuccessImage", "false", "files", true}, + {"onUploadSuccessImage", "false", "images", false}, + {"onUploadSuccessFile", "false", "images", true}, + {"onUploadSuccessFile", "false", "files", false}, + } + for _, c := range cases { + values := map[string]string{"readOnlyMode": c.readOnly, "uploadMode": c.mode} + if hidden, _ := ruleHidesUnder(rules, c.prop, values); hidden != c.hidden { + t.Errorf("%s under %v: hidden=%v, want %v", c.prop, values, hidden, c.hidden) + } + } +} diff --git a/mdl/executor/flow_splice_assoc_retrieve_test.go b/mdl/executor/flow_splice_assoc_retrieve_test.go new file mode 100644 index 0000000000..be5dd7aadc --- /dev/null +++ b/mdl/executor/flow_splice_assoc_retrieve_test.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// ako/mxcli#1769 (mendixlabs/mxcli#1673): a fragment spliced into a stored +// flow is built knowing the variables the stored flow declares +// (storedVariables), and a retrieve over an association was left untyped +// there, though a database retrieve and a list parameter were typed. A find by +// member in a list retrieved over an association could not be qualified, so +// the fragment refused it — or, in a loop the fragment brought, wrote it as a +// find by the invalid expression `Code = …` (CE0117). + +// orderFixture is the reported domain model: OrderLine_Order runs FROM +// OrderLine TO Order (ParentPointer = OrderLine, ChildPointer = Order), so a +// retrieve over it from an Order yields the list of its lines and one from an +// OrderLine yields its one order. Order_Tag is a reference set FROM Order TO +// Tag; Order_Invoice a one-to-one (owner Both) reference FROM Order TO +// Invoice; Order_Parent a reference from Order to itself. +func orderFixture(t *testing.T) (*ExecContext, *alterFlowContext) { + t.Helper() + mod := mkModule("Mod") + attr := func(name string) *domainmodel.Attribute { + return &domainmodel.Attribute{BaseElement: model.BaseElement{ID: nextID("attr")}, Name: name} + } + order := mkEntity(mod.ID, "Order") + order.Attributes = []*domainmodel.Attribute{attr("Code")} + line := mkEntity(mod.ID, "OrderLine") + line.Attributes = []*domainmodel.Attribute{attr("Code"), attr("Status")} + lookup := mkEntity(mod.ID, "Lookup") + lookup.Attributes = []*domainmodel.Attribute{attr("Code")} + tag := mkEntity(mod.ID, "Tag") + tag.Attributes = []*domainmodel.Attribute{attr("Label")} + invoice := mkEntity(mod.ID, "Invoice") + invoice.Attributes = []*domainmodel.Attribute{attr("Number")} + dm := mkDomainModel(mod.ID, order, line, lookup, tag, invoice) + tags := mkAssociation(dm.ID, "Order_Tag", order.ID, tag.ID) + tags.Type = domainmodel.AssociationTypeReferenceSet + oneToOne := mkAssociation(dm.ID, "Order_Invoice", order.ID, invoice.ID) + oneToOne.Owner = domainmodel.AssociationOwnerBoth + dm.Associations = []*domainmodel.Association{ + mkAssociation(dm.ID, "OrderLine_Order", line.ID, order.ID), + tags, + oneToOne, + mkAssociation(dm.ID, "Order_Parent", order.ID, order.ID), + } + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetModuleByNameFunc: func(name string) (*model.Module, error) { + if name == "Mod" { + return mod, nil + } + return nil, nil + }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + + obj := func(e string) microflows.DataType { return µflows.ObjectType{EntityQualifiedName: "Mod." + e} } + over := func(out, start, assoc string) mfmutator.Candidate { + return mfmutator.Candidate{OutputVariable: out, Object: µflows.ActionActivity{Action: µflows.RetrieveAction{ + Source: µflows.AssociationRetrieveSource{StartVariable: start, AssociationQualifiedName: "Mod." + assoc}}}} + } + loop := func(iterator, list string) mfmutator.Candidate { + return mfmutator.Candidate{Object: µflows.LoopedActivity{ + LoopSource: µflows.IterableList{VariableName: iterator, ListVariableName: list}}} + } + a := &alterFlowContext{ + stmt: &ast.AlterFlowStmt{}, + mf: µflows.Microflow{Parameters: []*microflows.MicroflowParameter{ + {Name: "Order", Type: obj("Order")}, + {Name: "Line", Type: obj("OrderLine")}, + {Name: "Tag", Type: obj("Tag")}, + {Name: "Invoice", Type: obj("Invoice")}, + {Name: "Lookups", Type: µflows.ListType{EntityQualifiedName: "Mod.Lookup"}}, + }}, + entityNames: map[model.ID]string{order.ID: "Mod.Order", line.ID: "Mod.OrderLine"}, + cands: []mfmutator.Candidate{ + over("Lines", "Order", "OrderLine_Order"), // from the TO end: the lines + over("TheOrder", "Line", "OrderLine_Order"), // from the FROM end: the order + over("Tags", "Order", "Order_Tag"), // reference set, forward + over("TagOrders", "Tag", "Order_Tag"), // reference set, reverse + over("TheInvoice", "Order", "Order_Invoice"), // one-to-one, forward + over("InvoiceOrder", "Invoice", "Order_Invoice"), // one-to-one, reverse + // Starts from a variable a later candidate types: the order of the + // candidates is not the order a variable is typed in. + over("SiblingLines", "LineOrder", "OrderLine_Order"), + over("LineOrder", "Line", "OrderLine_Order"), + over("Parent", "Order", "Order_Parent"), // self-reference: which end is unknown + over("Nowhere", "Order", "Order_Missing"), // no such association + over("Unstarted", "NoSuchVariable", "OrderLine_Order"), + // A loop's iterator is an element of its list, and a retrieve in + // the body can start from it. + loop("EachLine", "Lines"), + over("EachOrder", "EachLine", "OrderLine_Order"), + // Two loops whose iterators share a name over different lists: + // which one a fragment means depends on where it goes. + loop("It", "Lines"), + loop("It", "Tags"), + over("ItOrder", "It", "OrderLine_Order"), + }, + } + return ctx, a +} + +// TestStoredVariables_AssociationRetrieveIsTyped: the output of a stored +// retrieve over an association has the type the association gives it from +// the end it starts at — a list of the FROM entity from the TO end, the one +// TO object from the FROM end, a list either way over a reference set, one +// object either way over a one-to-one. What cannot be established stays +// untyped, so a member of it is refused, not guessed. +func TestStoredVariables_AssociationRetrieveIsTyped(t *testing.T) { + ctx, a := orderFixture(t) + varTypes, declared := a.storedVariables(ctx) + want := map[string]string{ + "Lines": "List of Mod.OrderLine", + "TheOrder": "Mod.Order", + "Tags": "List of Mod.Tag", + "TagOrders": "List of Mod.Order", + "TheInvoice": "Mod.Invoice", + "InvoiceOrder": "Mod.Order", + "SiblingLines": "List of Mod.OrderLine", + "LineOrder": "Mod.Order", + "EachLine": "Mod.OrderLine", + "EachOrder": "Mod.Order", + } + for v, w := range want { + if got := varTypes[v]; got != w { + t.Errorf("$%s: typed %q, want %q", v, got, w) + } + } + for _, v := range []string{"Parent", "Nowhere", "Unstarted", "ItOrder"} { + if got, ok := varTypes[v]; ok { + t.Errorf("$%s: typed %q, want it left untyped", v, got) + } + if declared[v] != "Unknown" { + t.Errorf("$%s: declared %q, want Unknown", v, declared[v]) + } + } + if got, ok := varTypes["It"]; ok { + t.Errorf("$It, the iterator of two loops over different lists: typed %q, want it left untyped", got) + } +} + +// TestSpliceFragment_ReplacedLoopFindsOverAssociationList is the reported +// alter: a replaced loop whose body finds in a list retrieved over an +// association before the loop is written as a find by attribute, the way +// `create microflow` writes it — not as a find by the expression `Code = …`. +func TestSpliceFragment_ReplacedLoopFindsOverAssociationList(t *testing.T) { + ctx, a := orderFixture(t) + frag, err := a.buildFragment(ctx, parsedFlowBody(t, `mdl 1; +create microflow Mod.Fragment () +begin + loop $Lookup in $Lookups + begin + $Line = find $Lines by Code = $Lookup/Code; + change $TheOrder (Code = $Lookup/Code); + end loop; +end;`)) + if err != nil { + t.Fatalf("the replaced loop: %v", err) + } + ops := fragmentListOperations(frag.Objects) + if len(ops) != 1 { + t.Fatalf("want one list operation, got %d", len(ops)) + } + f, ok := ops[0].(*microflows.FindByAttributeOperation) + if !ok || f.Attribute != "Mod.OrderLine.Code" || f.Expression != "$Lookup/Code" { + t.Fatalf("the find was written as %#v, want a find by attribute Mod.OrderLine.Code = $Lookup/Code", ops[0]) + } + var changed []string + for _, obj := range frag.Objects { + loop, ok := obj.(*microflows.LoopedActivity) + if !ok { + continue + } + for _, inner := range loop.ObjectCollection.Objects { + if act, ok := inner.(*microflows.ActionActivity); ok { + if ch, ok := act.Action.(*microflows.ChangeObjectAction); ok { + for _, m := range ch.Changes { + changed = append(changed, m.AttributeQualifiedName) + } + } + } + } + } + if strings.Join(changed, ",") != "Mod.Order.Code" { + t.Errorf("the change over the forward retrieve wrote %v, want [Mod.Order.Code]", changed) + } +} diff --git a/mdl/executor/flow_splice_decision_1694_pedapp_test.go b/mdl/executor/flow_splice_decision_1694_pedapp_test.go index e14fae3977..a44db8580b 100644 --- a/mdl/executor/flow_splice_decision_1694_pedapp_test.go +++ b/mdl/executor/flow_splice_decision_1694_pedapp_test.go @@ -96,10 +96,12 @@ func TestSpliceDecision1694_ReRunTakesTheInsertedDecisionOut(t *testing.T) { }) } - // 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.) + // The control for the $ID assertion: an activity the script replaces by + // another kind 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; nor is a changed log text, which edits the stored activity in + // place and keeps its $ID, ako/mxcli#1761.) t.Run("control: a replaced activity is churn", func(t *testing.T) { const name = "D1694Control" original := fmt.Sprintf(dec1694Flow, name) @@ -107,7 +109,7 @@ func TestSpliceDecision1694_ReRunTakesTheInsertedDecisionOut(t *testing.T) { t.Fatal(err) } before := flowObjectIDs(loopEditFlowOf(t, exec, name).ObjectCollection) - if err := agreeExec(t, exec, strings.Replace(original, "'start'", "'begin'", 1)); err != nil { + if err := agreeExec(t, exec, strings.Replace(original, "log info node 'X' 'start';", "commit $F;", 1)); err != nil { t.Fatal(err) } if missing, extra := sameIDs(before, flowObjectIDs(loopEditFlowOf(t, exec, name).ObjectCollection)); missing != 1 || extra != 1 { diff --git a/mdl/executor/flow_splice_edit_in_place_1761_pedapp_test.go b/mdl/executor/flow_splice_edit_in_place_1761_pedapp_test.go new file mode 100644 index 0000000000..e0973e3ce1 --- /dev/null +++ b/mdl/executor/flow_splice_edit_in_place_1761_pedapp_test.go @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +// ako/mxcli#1761 (mendixlabs/mxcli#1667, reported on Mendix 10.24.28): re-run +// `create or modify microflow` with only a filter's expression changed, and +// mxcli did not edit the activity, it replaced it: +// +// $BigList: the Microflows$ActionActivity, Microflows$ListOperationsAction +// and Microflows$FilterByExpression all got new $IDs. [...] The decision's +// ExclusiveSplit and SplitCondition kept theirs. +// +// and exec reported both as `spliced: 1 replaced`. Studio Pro compares and +// merges by $ID, so it showed the filter as one activity removed and another +// added. An activity replaced by one of the same kind is now an edit of the +// stored one, which keeps its $ID and those of its action and the elements +// under it; exec says which it was. + +const edit1761Setup = `mdl 1; +create persistent entity MyFirstModule.Sale1761 ( Amount: Integer, Note: String(200) ); +create persistent entity MyFirstModule.Customer1761 ( Name: String(200) ); +create association MyFirstModule.Sale1761_Customer1761 from MyFirstModule.Sale1761 to MyFirstModule.Customer1761; +create microflow MyFirstModule.SUB1761 ( $Sale: MyFirstModule.Sale1761, $Factor: Integer ) returns Boolean as $Ok +begin + return true; +end; +create nanoflow MyFirstModule.NSUB1761 ( $Sale: MyFirstModule.Sale1761, $Factor: Integer ) returns Boolean as $Ok +begin + return true; +end;` + +// edit1761Flow is the flow under test, a microflow or a nanoflow (kind), which +// calls callee with call ("call microflow", "call nanoflow"). +func edit1761Flow(kind, call, callee string) string { + return fmt.Sprintf(`mdl 1; +create or modify %s MyFirstModule.Edit1761 ( $SaleList: List of MyFirstModule.Sale1761, $Cust: MyFirstModule.Customer1761 ) +returns Boolean as $Ok +begin + $Small = filter $SaleList where $currentObject/Amount < 5; + $Found = find $SaleList where $currentObject/Amount = 3; + retrieve $Big from MyFirstModule.Sale1761 where Amount > 100; + change $Found (Amount = 7, Note = 'x', MyFirstModule.Sale1761_Customer1761 = $Cust); + $New = create MyFirstModule.Sale1761 (Amount = 1, Note = 'n'); + $Ok = %s MyFirstModule.%s (Sale = $New, Factor = 2); + return $Ok; +end;`, kind, call, callee) +} + +// storedFlowUnit returns the stored bytes of the microflow or nanoflow +// module.name. +func storedFlowUnit(t *testing.T, exec *Executor, module, name string) []byte { + t.Helper() + ctx := exec.newExecContext(t.Context()) + h, err := getHierarchy(ctx) + if err != nil { + t.Fatal(err) + } + mfs, err := ctx.Backend.ListMicroflows() + if err != nil { + t.Fatal(err) + } + for _, m := range mfs { + if m.Name == name && h.GetModuleName(h.FindModuleID(m.ContainerID)) == module { + raw, err := ctx.Backend.GetRawUnitBytes(m.ID) + if err != nil { + t.Fatal(err) + } + return append([]byte(nil), raw...) + } + } + nfs, err := ctx.Backend.ListNanoflows() + if err != nil { + t.Fatal(err) + } + for _, n := range nfs { + if n.Name == name && h.GetModuleName(h.FindModuleID(n.ContainerID)) == module { + raw, err := ctx.Backend.GetRawUnitBytes(n.ID) + if err != nil { + t.Fatal(err) + } + return append([]byte(nil), raw...) + } + } + t.Fatalf("%s.%s not found", module, name) + return nil +} + +// storedActivity is one activity of a stored flow: its $ID, the $ID of every +// element under it by path, and how many sequence flows enter and leave it. +type storedActivity struct { + id string + ids map[string]string + in, out int + contents []byte +} + +// findActivity returns the top-level activity whose action is an actType that +// outputs v. +func findActivity(t *testing.T, raw []byte, actType, v string) storedActivity { + t.Helper() + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + oc, _ := afGet(d, "ObjectCollection").(bson.D) + var found bson.D + for _, el := range afList(afGet(oc, "Objects")) { + o, _ := el.(bson.D) + a, _ := afGet(o, "Action").(bson.D) + if a == nil || afGet(a, "$Type") != actType { + continue + } + for _, k := range []string{"ResultVariableName", "VariableName", "ChangeVariableName", "OutputVariableName"} { + if s, _ := afGet(a, k).(string); s == v { + found = o + } + } + } + if found == nil { + t.Fatalf("no %s outputting $%s in the stored flow", actType, v) + } + sa := storedActivity{id: afIDOf(afGet(found, "$ID")), ids: map[string]string{}} + sa.contents, _ = bson.Marshal(found) + var walk func(v any, path string) + walk = func(v any, path string) { + switch n := v.(type) { + case bson.D: + if id, ok := afGet(n, "$ID").(bson.Binary); ok { + sa.ids[path] = types.BlobToUUID(id.Data) + } + for _, e := range n { + walk(e.Value, path+"/"+e.Key) + } + case bson.A: + for i, el := range n { + name := fmt.Sprint(i) + if ed, ok := el.(bson.D); ok { + for _, k := range []string{"Attribute", "Association", "Parameter"} { + if s, _ := afGet(ed, k).(string); s != "" { + name = s + } + } + } + walk(el, path+"["+name+"]") + } + } + } + walk(found, "") + for _, el := range afList(afGet(d, "Flows")) { + f, _ := el.(bson.D) + if afGet(f, "$Type") != "Microflows$SequenceFlow" { + continue + } + if afIDOf(afGet(f, "OriginPointer")) == sa.id { + sa.out++ + } + if afIDOf(afGet(f, "DestinationPointer")) == sa.id { + sa.in++ + } + } + return sa +} + +// assertSameIdentity fails unless after is before with every element $ID +// kept and its flows still running through it. +func assertSameIdentity(t *testing.T, before, after storedActivity) { + t.Helper() + if after.id != before.id { + t.Errorf("the activity got a new $ID: %s -> %s", before.id, after.id) + } + for path, id := range before.ids { + if got := after.ids[path]; got != id { + t.Errorf("%s: $ID %s -> %s", path, id, got) + } + } + for path := range after.ids { + if _, ok := before.ids[path]; !ok { + t.Errorf("%s: an element that was not there", path) + } + } + if after.in != before.in || after.out != before.out { + t.Errorf("flows through the activity: %d in, %d out, want %d in, %d out", after.in, after.out, before.in, before.out) + } +} + +func TestCreateOrModify_SameKindActivityIsEditedInPlace(t *testing.T) { + for _, kind := range []struct{ flow, call, callee string }{ + {"microflow", "call microflow", "SUB1761"}, + {"nanoflow", "call nanoflow", "NSUB1761"}, + } { + base := edit1761Flow(kind.flow, kind.call, kind.callee) + for _, tc := range []struct { + name, from, to string + actType, v string + want string // in the stored activity after the edit + }{ + {"filter expression", "$currentObject/Amount < 5", "$currentObject/Amount <= 5", + "Microflows$ListOperationsAction", "Small", "$currentObject/Amount <= 5"}, + {"find expression", "$currentObject/Amount = 3", "$currentObject/Amount = 4", + "Microflows$ListOperationsAction", "Found", "$currentObject/Amount = 4"}, + {"retrieve where", "where Amount > 100", "where Amount > 200", + "Microflows$RetrieveAction", "Big", "[Amount > 200]"}, + {"change member value", "Amount = 7", "Amount = 8", + "Microflows$ChangeAction", "Found", "8"}, + {"create member value", "Amount = 1", "Amount = 2", + "Microflows$CreateChangeAction", "New", "2"}, + {"call argument", "Factor = 2", "Factor = 3", + "", "Ok", "3"}, + } { + t.Run(kind.flow+"/"+tc.name, func(t *testing.T) { + actType := tc.actType + if actType == "" { + actType = "Microflows$MicroflowCallAction" + if kind.flow == "nanoflow" { + actType = "Microflows$NanoflowCallAction" + } + } + exec, out, _ := openPedAppCopy(t) + if err := agreeExec(t, exec, edit1761Setup); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + if err := agreeExec(t, exec, base); err != nil { + t.Fatalf("create: %v\n%s", err, out.String()) + } + before := findActivity(t, storedFlowUnit(t, exec, "MyFirstModule", "Edit1761"), actType, tc.v) + + edited := strings.Replace(base, tc.from, tc.to, 1) + if edited == base { + t.Fatalf("%q is not in the script", tc.from) + } + out.Reset() + if err := agreeExec(t, exec, edited); err != nil { + t.Fatalf("the edit is refused: %v\n%s", err, out.String()) + } + if want := "(spliced: 1 edited in place)"; !strings.Contains(out.String(), want) { + t.Errorf("want %q, got:\n%s", want, out.String()) + } + after := findActivity(t, storedFlowUnit(t, exec, "MyFirstModule", "Edit1761"), actType, tc.v) + assertSameIdentity(t, before, after) + if !bytes.Contains(after.contents, []byte(tc.want)) { + t.Errorf("the new value %q was not written", tc.want) + } + // The edit is the whole of the change: running it again + // writes nothing. + out.Reset() + if err := agreeExec(t, exec, edited); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Unchanged") { + t.Errorf("re-running the edited script wrote again:\n%s", out.String()) + } + }) + } + } +} + +// Control: an activity replaced by another kind of activity is new — new +// $IDs, and the report says so. +func TestCreateOrModify_AnotherKindOfActivityIsReplacedWithNewIDs(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + if err := agreeExec(t, exec, edit1761Setup); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + base := edit1761Flow("microflow", "call microflow", "SUB1761") + if err := agreeExec(t, exec, base); err != nil { + t.Fatalf("create: %v\n%s", err, out.String()) + } + before := findActivity(t, storedFlowUnit(t, exec, "MyFirstModule", "Edit1761"), "Microflows$ListOperationsAction", "Small") + + // A find in place of the filter: the same action, another operation — + // the activity and its action are the stored ones, the operation is new. + out.Reset() + if err := agreeExec(t, exec, strings.Replace(base, "$Small = filter", "$Small = find", 1)); err != nil { + t.Fatalf("%v\n%s", err, out.String()) + } + after := findActivity(t, storedFlowUnit(t, exec, "MyFirstModule", "Edit1761"), "Microflows$ListOperationsAction", "Small") + if after.id != before.id || after.ids["/Action"] != before.ids["/Action"] { + t.Errorf("the activity or its action got a new $ID") + } + if after.ids["/Action/NewOperation"] == before.ids["/Action/NewOperation"] { + t.Errorf("the find kept the filter's $ID") + } + + // A log in place of the filter is another activity. + out.Reset() + if err := agreeExec(t, exec, strings.Replace(base, "$Small = filter $SaleList where $currentObject/Amount < 5;", + "log info node 'T' 'no filter';", 1)); err != nil { + t.Fatalf("%v\n%s", err, out.String()) + } + if want := "(spliced: 1 replaced with new element IDs)"; !strings.Contains(out.String(), want) { + t.Errorf("want %q, got:\n%s", want, out.String()) + } + if raw := storedFlowUnit(t, exec, "MyFirstModule", "Edit1761"); bytes.Contains(raw, types.UUIDToBlob(before.id)) { + t.Error("the filter's $ID is still in the flow") + } +} + +// The subject that can fail: a flow Studio Pro drew (PedApp's +// FeedbackModule.PopulateUserAttributes), whose retrieve and change share one +// note. Its describe output, with the retrieve's where and one change member +// edited, keeps both activities, their actions and every element under them, +// and the note stays on both. +func TestCreateOrModify_StudioProActivitiesAreEditedInPlace(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + var buf bytes.Buffer + saved := exec.output + exec.output = &buf + if err := afRun(t, exec, "describe microflow FeedbackModule.PopulateUserAttributes;"); err != nil { + t.Fatal(err) + } + exec.output = saved + desc := buf.String() + desc = desc[:strings.Index(desc, "\nend;")+len("\nend;")] + + raw := storedFlowUnit(t, exec, "FeedbackModule", "PopulateUserAttributes") + retrieve := findActivity(t, raw, "Microflows$RetrieveAction", "CurrentUser") + change := findActivity(t, raw, "Microflows$ChangeAction", "Feedback") + notes := func(raw []byte) map[string]int { + var d bson.D + _ = bson.Unmarshal(raw, &d) + n := map[string]int{} + for _, el := range afList(afGet(d, "Flows")) { + f, _ := el.(bson.D) + if afGet(f, "$Type") == "Microflows$AnnotationFlow" { + n[afIDOf(afGet(f, "OriginPointer"))+">"+afIDOf(afGet(f, "DestinationPointer"))]++ + } + } + return n + } + notesBefore := notes(raw) + + edited := strings.Replace(desc, "where id = $currentUser", "where Name = $currentUser/Name", 1) + edited = strings.Replace(edited, "SubmitterDisplayName = $CurrentUser/Name", "SubmitterDisplayName = trim($CurrentUser/Name)", 1) + if edited == desc || strings.Count(edited, "trim(") != 1 || !strings.Contains(edited, "where Name = $currentUser/Name") { + t.Fatalf("the describe output does not hold the statements under test:\n%s", desc) + } + out.Reset() + if err := agreeExec(t, exec, edited); err != nil { + t.Fatalf("%v\n%s", err, out.String()) + } + if want := "(spliced: 2 edited in place)"; !strings.Contains(out.String(), want) { + t.Errorf("want %q, got:\n%s", want, out.String()) + } + raw = storedFlowUnit(t, exec, "FeedbackModule", "PopulateUserAttributes") + assertSameIdentity(t, retrieve, findActivity(t, raw, "Microflows$RetrieveAction", "CurrentUser")) + assertSameIdentity(t, change, findActivity(t, raw, "Microflows$ChangeAction", "Feedback")) + if got := notes(raw); fmt.Sprint(got) != fmt.Sprint(notesBefore) { + t.Errorf("the note's lines changed: %v -> %v", notesBefore, got) + } + if !bytes.Contains(raw, []byte("trim($CurrentUser/Name)")) || !bytes.Contains(raw, []byte("[Name = $currentUser/Name]")) { + t.Error("the edits were not written") + } +} diff --git a/mdl/executor/flow_splice_error_handler_pedapp_test.go b/mdl/executor/flow_splice_error_handler_pedapp_test.go index 794668a5ae..2e0a9423ee 100644 --- a/mdl/executor/flow_splice_error_handler_pedapp_test.go +++ b/mdl/executor/flow_splice_error_handler_pedapp_test.go @@ -70,9 +70,11 @@ func handlerObjects(t *testing.T, exec *Executor, name string) (map[model.ID]mod } // spliceStep runs src — check first, then exec — and asserts it is spliced -// with exactly `changed` objects replaced and `added` new ones, nothing else -// rebuilt or moved, that describe shows want, and that a second run is -// Unchanged. +// with exactly `changed` objects replaced by new ones and `added` new ones, +// nothing else rebuilt or moved, that describe shows want, and that a second +// run is Unchanged. An activity changed into the same kind of activity is +// edited in place and keeps its $ID (ako/mxcli#1761), so it is not among the +// `changed`: describe showing want is what says it was written. func spliceStep(t *testing.T, exec *Executor, out *bytes.Buffer, name, src string, changed, added int, want ...string) { t.Helper() before, _ := handlerObjects(t, exec, name) @@ -133,19 +135,21 @@ func TestCreateOrModify_ActivityWithErrorHandlerIsSplicedInPlace(t *testing.T) { return "mdl 1;\n" + src[strings.Index(src, "create or modify microflow MyFirstModule.HFlow"):] } + // The call is edited in place (ako/mxcli#1761): it keeps its $ID, and + // describe shows the new argument. t.Run("T1 call argument changed, handler unchanged", func(t *testing.T) { _, call := handlerObjects(t, exec, "HFlow") - spliceStep(t, exec, out, "HFlow", flow(strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1)), 1, 0, + spliceStep(t, exec, out, "HFlow", flow(strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1)), 0, 0, "HSub(P = 'x') on error without rollback begin", "log warning node 'Repro' 'failed';") - if _, now := handlerObjects(t, exec, "HFlow"); now == call { - t.Error("the call kept its $ID: the change was not written") + if _, now := handlerObjects(t, exec, "HFlow"); now != call { + t.Error("the call got a new $ID for a changed argument") } }) t.Run("T3 only the handler's log changed", func(t *testing.T) { _, call := handlerObjects(t, exec, "HFlow") src := strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1) src = strings.Replace(src, "'failed';", "'failed again';", 1) - spliceStep(t, exec, out, "HFlow", flow(src), 1, 0, "'failed again'", "HSub(P = 'x') on error") + spliceStep(t, exec, out, "HFlow", flow(src), 0, 0, "'failed again'", "HSub(P = 'x') on error") if _, now := handlerObjects(t, exec, "HFlow"); now != call { t.Error("the call was replaced for a change inside its handler") } @@ -153,7 +157,7 @@ func TestCreateOrModify_ActivityWithErrorHandlerIsSplicedInPlace(t *testing.T) { t.Run("call and handler changed together", func(t *testing.T) { src := strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'y') on", 1) src = strings.Replace(src, "'failed';", "'failed twice';", 1) - spliceStep(t, exec, out, "HFlow", flow(src), 2, 0, "HSub(P = 'y') on error", "'failed twice'") + spliceStep(t, exec, out, "HFlow", flow(src), 0, 0, "HSub(P = 'y') on error", "'failed twice'") }) // A note on the activity is part of it: added or reworded with the // activity unchanged otherwise, it is written, not dropped with the @@ -162,21 +166,21 @@ func TestCreateOrModify_ActivityWithErrorHandlerIsSplicedInPlace(t *testing.T) { src := strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'y') on", 1) src = strings.Replace(src, "'failed';", "'failed twice';", 1) src = strings.Replace(src, " call microflow MyFirstModule.HSub(", " @annotation 'calls the sub'\n call microflow MyFirstModule.HSub(", 1) - spliceStep(t, exec, out, "HFlow", flow(src), 1, 1, "@annotation 'calls the sub'", "HSub(P = 'y') on error") + spliceStep(t, exec, out, "HFlow", flow(src), 0, 1, "@annotation 'calls the sub'", "HSub(P = 'y') on error") }) t.Run("note on the call reworded", func(t *testing.T) { src := strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'y') on", 1) src = strings.Replace(src, "'failed';", "'failed twice';", 1) src = strings.Replace(src, " call microflow MyFirstModule.HSub(", " @annotation 'calls the sub, once'\n call microflow MyFirstModule.HSub(", 1) - // The stored note goes out with the activity, the reworded one comes - // in: two objects replaced. - spliceStep(t, exec, out, "HFlow", flow(src), 2, 0, "@annotation 'calls the sub, once'") + // The stored note goes out, the reworded one comes in: one object + // replaced. The call itself is the stored one. + spliceStep(t, exec, out, "HFlow", flow(src), 1, 0, "@annotation 'calls the sub, once'") if desc := describeFlow(t, exec, "HFlow"); strings.Contains(desc, "'calls the sub'") { t.Errorf("the old note is still there:\n%s", desc) } }) t.Run("T4 handler that returns", func(t *testing.T) { - spliceStep(t, exec, out, "HRet", strings.Replace(handlerRetSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1), 1, 0, + spliceStep(t, exec, out, "HRet", strings.Replace(handlerRetSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1), 0, 0, "HSub(P = 'x') on error without rollback begin") }) @@ -248,9 +252,17 @@ func TestAlter_ReplaceOfActivityWithErrorHandler(t *testing.T) { if err := agreeExec(t, exec, src); err != nil { t.Fatalf("refused: %v", err) } + // The same kind of activity: the stored call, edited in place + // (ako/mxcli#1761) — every object keeps its $ID, and describe shows + // the new argument. after, now := handlerObjects(t, exec, "HFlow") - if now == call || len(after) != len(before) { - t.Errorf("want the call alone replaced: %d -> %d objects", len(before), len(after)) + if now != call || len(after) != len(before) { + t.Errorf("want the call edited in place: call %s -> %s, %d -> %d objects", call, now, len(before), len(after)) + } + for id := range before { + if _, ok := after[id]; !ok { + t.Errorf("object %s got a new $ID", id) + } } for id, p := range before { if q, ok := after[id]; ok && p != q { diff --git a/mdl/executor/flow_splice_list_op_where_856_pedapp_test.go b/mdl/executor/flow_splice_list_op_where_856_pedapp_test.go index 9bbbd7ac4e..ca39b19fbf 100644 --- a/mdl/executor/flow_splice_list_op_where_856_pedapp_test.go +++ b/mdl/executor/flow_splice_list_op_where_856_pedapp_test.go @@ -58,15 +58,16 @@ func TestCreateOrModify_ListOperationWhereBareMemberIsItsStoredActivity(t *testi if strings.Contains(out.String(), "MDL-V1-REBUILD") { t.Errorf("the change fell back to rebuilding the flow:\n%s", out.String()) } - if !strings.Contains(out.String(), "Modified microflow: MyFirstModule.ListOpWhere (spliced: 1 replaced)") { - t.Errorf("want only the changed declare replaced, got:\n%s", out.String()) + if !strings.Contains(out.String(), "Modified microflow: MyFirstModule.ListOpWhere (spliced: 1 edited in place)") { + t.Errorf("want only the changed declare edited, got:\n%s", out.String()) } if got := describeFlow(t, exec, "ListOpWhere"); !strings.Contains(got, "declare $N Integer = 1;") { t.Errorf("the change was not written:\n%s", got) } - // Only the declare is new: the find, the filter and the rest keep - // their $IDs and places. + // Nothing is new: the find, the filter and the rest keep their + // $IDs and places, and so does the declare, edited in place + // (ako/mxcli#1761). after := flowObjectIDs(loopEditFlowOf(t, exec, "ListOpWhere").ObjectCollection) var gone, added []model.ID for id := range before { @@ -81,8 +82,8 @@ func TestCreateOrModify_ListOperationWhereBareMemberIsItsStoredActivity(t *testi t.Errorf("object %s moved from %v to %v", id, q, p) } } - if len(gone) != 1 || len(added) != 1 { - t.Errorf("want exactly the declare replaced (1 gone, 1 new), got %d gone, %d new", len(gone), len(added)) + if len(gone) != 0 || len(added) != 0 { + t.Errorf("want every object kept (0 gone, 0 new), got %d gone, %d new", len(gone), len(added)) } }) } @@ -103,8 +104,8 @@ func TestCreateOrModify_ListOperationWhereChangedPredicateIsAChange(t *testing.T if err := agreeExec(t, exec, strings.Replace(listOpWhereFlow, c.from, c.to, 1)); err != nil { t.Fatalf("%s → %s: %v", c.from, c.to, err) } - if !strings.Contains(out.String(), "(spliced: 1 replaced)") { - t.Errorf("%s → %s: want the operation replaced, got:\n%s", c.from, c.to, out.String()) + if !strings.Contains(out.String(), "(spliced: 1 edited in place)") { + t.Errorf("%s → %s: want the operation edited, got:\n%s", c.from, c.to, out.String()) } // Back to the stored statement for the next case. if err := agreeExec(t, exec, listOpWhereFlow); err != 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 index a319980c80..1a1899fc9d 100644 --- a/mdl/executor/flow_splice_loop_body_1693_pedapp_test.go +++ b/mdl/executor/flow_splice_loop_body_1693_pedapp_test.go @@ -215,7 +215,7 @@ func TestSpliceLoopBody1693_CreateOrModify(t *testing.T) { {"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}, + "spliced: 1 replaced with new element IDs", []string{"set $S", "'one'", "'two'", "commit $It"}, 1, 2}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/mdl/executor/flow_splice_nested_guard_1769_pedapp_test.go b/mdl/executor/flow_splice_nested_guard_1769_pedapp_test.go new file mode 100644 index 0000000000..11680b697c --- /dev/null +++ b/mdl/executor/flow_splice_nested_guard_1769_pedapp_test.go @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// ako/mxcli#1769 (mendixlabs/mxcli#1673) on the Studio Pro-authored PedApp, +// through the real codec: the reported alter, which replaces a loop with its +// own text, wrote the find in its body as Microflows$FindByExpression `Code = +// $Lookup/Code` — check and exec silent, CE0117 from mx check — where `create +// microflow` writes Microflows$Find on MyModule.OrderLine.Code. + +const matchLinesSetup = `mdl 1; +create module MyModule; +create persistent entity MyModule.Order (Code: String(50)); +create persistent entity MyModule.OrderLine (Code: String(50), Status: String(20)); +create association MyModule.OrderLine_Order from MyModule.OrderLine to MyModule.Order; +create non-persistent entity MyModule.Lookup (Code: String(50)); +create microflow MyModule.SUB_MatchLines ($Order: MyModule.Order, $Lookups: List of MyModule.Lookup) returns Integer as $Found +begin + declare $Found Integer = 0; + retrieve $Lines from $Order/MyModule.OrderLine_Order; + $Open = filter $Lines by Status = 'open'; + loop $Lookup in $Lookups + begin + $Line = find $Lines by Code = $Lookup/Code; + if $Line != empty then + set $Found = $Found + 1; + end if; + end loop; + return $Found; +end;` + +// matchLinesFindIn returns, for each way an alter can write a find, the +// statement that writes one in list: the reported replace of the loop with +// its own text, a replace of its body, and plain inserts in the stored body +// and on the main line, which the guard of ako/mxcli#891 always covered. +func matchLinesFindIn(list string) map[string]string { + find := `$Line = find ` + list + ` by Code = $Lookup/Code;` + return map[string]string{ + "replace loop": `mdl 1; +alter microflow MyModule.SUB_MatchLines { + replace loop $Lookup in $Lookups with begin + loop $Lookup in $Lookups + begin + ` + find + ` + if $Line != empty then + set $Found = $Found + 1; + end if; + end loop; + end; +};`, + "replace body of loop": `mdl 1; +alter microflow MyModule.SUB_MatchLines { + replace body of loop $Lookup in $Lookups with begin + ` + find + ` + if $Line != empty then + set $Found = $Found + 1; + end if; + end; +};`, + "insert in the loop body": `mdl 1; +alter microflow MyModule.SUB_MatchLines { + insert after $Line begin $Again = find ` + list + ` by Code = $Lookup/Code; end; +};`, + "insert on the main line": `mdl 1; +alter microflow MyModule.SUB_MatchLines { + insert after $Open begin $First = find ` + list + ` by Code = 'x'; end; +};`, + } +} + +func openMatchLinesFixture(t *testing.T) (*Executor, *bytes.Buffer, string) { + t.Helper() + exec, out, dir := openPedAppCopy(t) + if err := agreeExec(t, exec, matchLinesSetup); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + return exec, out, dir +} + +// TestAlterMicroflow_FindOverUntypedListIsRefused: over a list whose entity +// the fragment does not know — the stored flow does not type a list +// operation's output — every alter is refused and writes nothing. On the main +// line and in a stored loop body it always was (ako/mxcli#891); in a loop the +// fragment brings, it was written as a find by expression. Check does not +// pass it silently: like the main-line refusal, it names the change as not +// predicted with exec's reason. +func TestAlterMicroflow_FindOverUntypedListIsRefused(t *testing.T) { + const want = `cannot qualify member "Code"` + for name, src := range matchLinesFindIn("$Open") { + t.Run(name, func(t *testing.T) { + exec, _, dir := openMatchLinesFixture(t) + before := projectFiles(t, dir) + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs[0], src) + } + r := exec.CheckFlowVerdictsReport(prog) + var said []string + for _, u := range r.Unpredicted { + said = append(said, u.Reason) + } + for _, v := range r.Violations { + said = append(said, v.Message) + } + if !strings.Contains(strings.Join(said, "\n"), want) { + t.Errorf("check must name the refusal %q, said: %v", want, said) + } + if err := exec.ExecuteProgram(prog); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("exec must refuse with %q, got: %v", want, err) + } + if changed := diffProjectFiles(before, projectFiles(t, dir)); len(changed) != 0 { + t.Errorf("a refused alter wrote %v", changed) + } + }) + } +} + +// matchLinesFlow returns the stored MyModule.SUB_MatchLines. +func matchLinesFlow(t *testing.T, exec *Executor) *microflows.Microflow { + t.Helper() + ctx := exec.newExecContext(context.Background()) + h, err := getHierarchy(ctx) + if err != nil { + t.Fatal(err) + } + all, err := ctx.Backend.ListMicroflows() + if err != nil { + t.Fatal(err) + } + for _, m := range all { + if m.Name == "SUB_MatchLines" && h.GetModuleName(h.FindModuleID(m.ContainerID)) == "MyModule" { + mf, err := ctx.Backend.GetMicroflow(m.ID) + if err != nil { + t.Fatal(err) + } + return mf + } + } + t.Fatal("MyModule.SUB_MatchLines not found") + return nil +} + +// TestAlterMicroflow_FindOverAssociationListIsByAttribute: every alter that +// writes `find $Lines by Code = …`, where $Lines is retrieved over an +// association before it, writes Microflows$Find on MyModule.OrderLine.Code, +// as `create microflow` does; check predicts it and exec writes it. +func TestAlterMicroflow_FindOverAssociationListIsByAttribute(t *testing.T) { + for name, src := range matchLinesFindIn("$Lines") { + t.Run(name, func(t *testing.T) { + exec, _, _ := openMatchLinesFixture(t) + predictedAgree(t, exec, src, "") + var finds int + for _, op := range fragmentListOperations(matchLinesFlow(t, exec).ObjectCollection.Objects) { + switch f := op.(type) { + case *microflows.FindOperation: + t.Errorf("a find over $%s was stored by the expression %q", f.ListVariable, f.Expression) + case *microflows.FindByAttributeOperation: + finds++ + if f.Attribute != "MyModule.OrderLine.Code" { + t.Errorf("a find over $%s is on %q, want MyModule.OrderLine.Code", f.ListVariable, f.Attribute) + } + } + } + if finds == 0 { + t.Errorf("no find by attribute was stored") + } + }) + } +} + +// The reported alter writes what describe prints back unchanged: exec of the +// describe output reports Unchanged. +func TestAlterMicroflow_ReplacedLoopDescribeRoundTrips(t *testing.T) { + exec, out, _ := openMatchLinesFixture(t) + predictedAgree(t, exec, matchLinesFindIn("$Lines")["replace loop"], "") + var desc bytes.Buffer + saved := exec.output + exec.output = &desc + err := afRun(t, exec, "describe microflow MyModule.SUB_MatchLines;") + exec.output = saved + if err != nil { + t.Fatal(err) + } + if !strings.Contains(desc.String(), "find $Lines by Code = $Lookup/Code;") { + t.Errorf("describe does not print the find by attribute:\n%s", desc.String()) + } + out.Reset() + if err := afRun(t, exec, "mdl 1;\n"+desc.String()); err != nil { + t.Fatalf("exec of the describe output: %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "Unchanged") { + t.Errorf("exec of the describe output must report Unchanged, got:\n%s", out.String()) + } +} + +// A stored loop's iterator is typed from its list, so a member of it in a +// fragment inserted in the loop body is qualified; it was refused. +func TestAlterMicroflow_ChangeOnStoredIteratorIsQualified(t *testing.T) { + exec, _, _ := openMatchLinesFixture(t) + predictedAgree(t, exec, `mdl 1; +alter microflow MyModule.SUB_MatchLines { + insert after $Line begin change $Lookup (Code = 'x'); end; +};`, "") + var changed []string + var walk func(objs []microflows.MicroflowObject) + walk = func(objs []microflows.MicroflowObject) { + for _, obj := range objs { + switch o := obj.(type) { + case *microflows.ActionActivity: + if ch, ok := o.Action.(*microflows.ChangeObjectAction); ok { + for _, m := range ch.Changes { + changed = append(changed, m.AttributeQualifiedName) + } + } + case *microflows.LoopedActivity: + walk(o.ObjectCollection.Objects) + } + } + } + walk(matchLinesFlow(t, exec).ObjectCollection.Objects) + if strings.Join(changed, ",") != "MyModule.Lookup.Code" { + t.Errorf("the change was stored on %v, want [MyModule.Lookup.Code]", changed) + } +} diff --git a/mdl/executor/flow_splice_nested_guard_test.go b/mdl/executor/flow_splice_nested_guard_test.go new file mode 100644 index 0000000000..f43add3be9 --- /dev/null +++ b/mdl/executor/flow_splice_nested_guard_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// ako/mxcli#1769 (mendixlabs/mxcli#1673): a fragment spliced into a stored +// flow refuses a member it cannot qualify (qualifiedMembersOnly, #885) — but +// only on its own main line. Every body nested in it (a loop's, a while +// loop's, an error handler's) was built by a fresh flowBuilder that did not +// inherit the flag, so `find $L by Code = …` in a replaced loop over a list +// whose entity the fragment does not know was written as a find by the +// expression `Code = …`: check and exec silent, CE0117 from mx check. + +// nestedShapes wraps one statement in each body a fragment can nest it in. +// %s is the statement; $Cs is a typed list to iterate, $C a typed object. +var nestedShapes = map[string]string{ + "loop body": `loop $It in $Cs + begin + %s + end loop;`, + "while body": `while $C != empty + begin + %s + end while;`, + "error handler": `$H = call microflow Mod.GetItem() on error without rollback begin + %s + end error;`, + "loop in a loop": `loop $It in $Cs + begin + loop $Jt in $Cs + begin + %s + end loop; + end loop;`, + "error handler in a loop": `loop $It in $Cs + begin + $H = call microflow Mod.GetItem() on error without rollback begin + %s + end error; + end loop;`, + "while in an error handler": `$H = call microflow Mod.GetItem() on error without rollback begin + while $C != empty + begin + %s + end while; + end error;`, + "if in a loop": `loop $It in $Cs + begin + if $It != empty then + %s + end if; + end loop;`, +} + +// memberStatements are the statements that name a member of their +// variable's entity. %[1]s is a list variable, %[2]s an object variable. +var memberStatements = map[string]string{ + "find": `$F = find %[1]s by Name = 'x';`, + "filter": `$G = filter %[1]s by Name = 'x';`, + "sort": `$S = sort %[1]s by Name asc;`, + "aggregate": `$M = maximum %[1]s by Name;`, + "change": `change %[2]s (Name = 'x');`, +} + +func nestedFragmentBody(t *testing.T, shape, stmt string) []ast.MicroflowStatement { + t.Helper() + src := "mdl 1;\ncreate microflow Mod.Fragment ()\nbegin\n " + + strings.ReplaceAll(nestedShapes[shape], "%s", stmt) + "\nend;" + return parsedFlowBody(t, src) +} + +// TestSpliceFragment_NestedBodiesRefuseUnqualifiedMembers: every member a +// nested body names over a variable the fragment cannot type is refused, as +// it is on the fragment's main line. Control: over the typed $Cs / $C the +// same fragments build, and the find is a find by attribute. +func TestSpliceFragment_NestedBodiesRefuseUnqualifiedMembers(t *testing.T) { + ctx, a := spliceFixture(t) + for shape := range nestedShapes { + for name, stmt := range memberStatements { + untyped := strings.ReplaceAll(strings.ReplaceAll(stmt, "%[1]s", "$A"), "%[2]s", "$A") + _, err := a.buildFragment(ctx, nestedFragmentBody(t, shape, untyped)) + if err == nil || !strings.Contains(err.Error(), `cannot qualify member "Name"`) { + t.Errorf("%s in the %s over an untyped variable: want the refusal, got %v", name, shape, err) + } + + typed := strings.ReplaceAll(strings.ReplaceAll(stmt, "%[1]s", "$Cs"), "%[2]s", "$C") + frag, err := a.buildFragment(ctx, nestedFragmentBody(t, shape, typed)) + if err != nil { + t.Errorf("control: %s in the %s over a typed variable: %v", name, shape, err) + continue + } + if name != "find" { + continue + } + ops := fragmentListOperations(frag.Objects) + if len(ops) != 1 { + t.Errorf("control: find in the %s: want one list operation, got %d", shape, len(ops)) + continue + } + if f, ok := ops[0].(*microflows.FindByAttributeOperation); !ok || f.Attribute != "Mod.Item.Name" { + t.Errorf("control: find in the %s wrote %#v, want a find by attribute Mod.Item.Name", shape, ops[0]) + } + } + } +} + +// fragmentListOperations returns the list operations among objs, loop bodies +// included. +func fragmentListOperations(objs []microflows.MicroflowObject) []microflows.ListOperation { + var out []microflows.ListOperation + for _, obj := range objs { + switch o := obj.(type) { + case *microflows.ActionActivity: + if lo, ok := o.Action.(*microflows.ListOperationAction); ok { + out = append(out, lo.Operation) + } + case *microflows.LoopedActivity: + if o.ObjectCollection != nil { + out = append(out, fragmentListOperations(o.ObjectCollection.Objects)...) + } + } + } + return out +} diff --git a/mdl/executor/layout.go b/mdl/executor/layout.go index 1431cbe1f2..55e2ec4598 100644 --- a/mdl/executor/layout.go +++ b/mdl/executor/layout.go @@ -98,6 +98,14 @@ func (m *layoutMeasurer) measureStatements(stmts []ast.MicroflowStatement) Bound } else { cursor = left + bounds.Width } + if row := m.handlerRowWidth(stmt); row > 0 { + // The handler's row hangs in the same lane, from the activity's left + // edge, and the main line goes on above it — as reserveHandlerRow + // records it for the builder. + rowRight := left + row + laneBusy = max(laneBusy, rowRight+laneGap) + extent = max(extent, rowRight) + } extent = max(extent, cursor) prev = stmt } diff --git a/mdl/executor/layout_handler_lane_1768_test.go b/mdl/executor/layout_handler_lane_1768_test.go new file mode 100644 index 0000000000..11d7a73137 --- /dev/null +++ b/mdl/executor/layout_handler_lane_1768_test.go @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// A custom error handler is drawn as a row under its activity, ErrorHandlerPitch +// below the main line. The main line does not wait for that row — the statement +// after the activity is one pitch further on, however long the row is — so when +// that next statement had a handler too, its row started inside the first one +// (ako/mxcli#1768, mendixlabs/mxcli#1672). Reported, from `describe … with +// handles`: the first handler's end event and the second handler's first +// activity both on 680;330. +const twoHandlersFlow = `create microflow MyModule.SUB_TwoHandlers ($Url: String) returns MyModule.Result as $Result +begin + $First = call rest service get '{1}' with ({1} = $Url) (Timeout: 30) returns response on error without rollback begin + $FirstFailed = create MyModule.Result (Code = 1); + log error node 'MyModule' 'first failed'; + return $FirstFailed; + end error; + declare $Body String = $First/Content; + $Second = call rest service get '{1}' with ({1} = $Body) (Timeout: 30) returns response on error without rollback begin + $SecondFailed = create MyModule.Result (Code = 2); + log error node 'MyModule' 'second failed'; + return $SecondFailed; + end error; + $Done = create MyModule.Result (IsDone = true); + return $Done; +end;` + +// canvasBoxOf is an object's box on its canvas: a Mendix position is the centre. +func canvasBoxOf(o microflows.MicroflowObject) canvasBox { + p, sz := o.GetPosition(), objectSize(o) + return canvasBox{p.X - sz.Width/2, p.Y - sz.Height/2, p.X + sz.Width/2, p.Y + sz.Height/2} +} + +func canvasLabel(o microflows.MicroflowObject) string { + p := o.GetPosition() + return fmt.Sprintf("%s %d;%d", typeShort(o), p.X, p.Y) +} + +// canvasOverlaps lists every pair of objects that share space on the same canvas. +// A loop's children are positioned in the loop's own frame, so each loop body is +// its own canvas, checked separately; notes are left out. +func canvasOverlaps(objects []microflows.MicroflowObject) []string { + var out []string + var plane []microflows.MicroflowObject + for _, o := range objects { + if _, ok := o.(*microflows.Annotation); ok { + continue + } + plane = append(plane, o) + if loop, ok := o.(*microflows.LoopedActivity); ok && loop.ObjectCollection != nil { + for _, inner := range canvasOverlaps(loop.ObjectCollection.Objects) { + out = append(out, "in loop: "+inner) + } + } + } + for i := range plane { + for j := i + 1; j < len(plane); j++ { + a, b := canvasBoxOf(plane[i]), canvasBoxOf(plane[j]) + if a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom { + out = append(out, canvasLabel(plane[i])+" overlaps "+canvasLabel(plane[j])) + } + } + } + return out +} + +func TestSecondErrorHandlerRowClearsTheFirst(t *testing.T) { + oc := buildLayout(t, twoHandlersFlow) + if overlaps := canvasOverlaps(oc.Objects); len(overlaps) > 0 { + t.Errorf("objects share a place:\n %s", strings.Join(overlaps, "\n ")) + } + + got := map[string]bool{} + for _, o := range oc.Objects { + got[canvasLabel(o)] = true + } + // Everything up to the second handler stays where it was: the first activity, + // its row, and the plain activity after it, which does not reach into the lane + // and so does not wait for it. + for _, want := range []string{ + "ActionActivity 360;200", // $First + "ActionActivity 360;330", // $FirstFailed + "ActionActivity 520;330", // log error 'first failed' + "EndEvent 680;330", // return $FirstFailed + "ActionActivity 520;200", // $Body + } { + if !got[want] { + t.Errorf("missing %s — a position that did not overlap anything moved", want) + } + } + // $Second reaches into the lane with its own row, so it waits for the first row + // to clear: that row ends at 690 (the end event's right edge), plus the 40px gap + // two activities keep, plus half an activity. The literal is the measurement, + // not the builder's constants, so it cannot agree with whatever they say. + for _, want := range []string{ + "ActionActivity 790;200", // $Second + "ActionActivity 790;330", // $SecondFailed, straight under its activity + } { + if !got[want] { + t.Errorf("missing %s", want) + } + } +} + +// handlerRuns are runs of activities whose error handler rows share a lane. The +// builder places a handler row the same way wherever the run is — on the main +// line, in a loop body, in either branch of a decision — so each is checked in +// all of them. +var handlerRuns = []struct { + name, body string + // rejoins: the handlers fall through to the main line instead of ending. + rejoins bool +}{ + {name: "two handlers with a plain activity between", body: ` + retrieve $A from M.Item on error without rollback begin + log error node 'M' 'a1'; + log error node 'M' 'a2'; + return; + end error; + log info node 'M' 'between'; + retrieve $B from M.Item on error without rollback begin + log error node 'M' 'b1'; + log error node 'M' 'b2'; + return; + end error; + log info node 'M' 'done';`}, + {name: "the first handler longer", body: ` + retrieve $A from M.Item on error without rollback begin + log error node 'M' 'a1'; + log error node 'M' 'a2'; + log error node 'M' 'a3'; + return; + end error; + log info node 'M' 'between'; + retrieve $B from M.Item on error without rollback begin + log error node 'M' 'b1'; + log error node 'M' 'b2'; + return; + end error; + log info node 'M' 'done';`}, + {name: "three handlers back to back", body: ` + retrieve $A from M.Item on error without rollback begin + log error node 'M' 'a1'; + return; + end error; + retrieve $B from M.Item on error without rollback begin + log error node 'M' 'b1'; + log error node 'M' 'b2'; + return; + end error; + retrieve $C from M.Item on error without rollback begin + log error node 'M' 'c1'; + return; + end error; + log info node 'M' 'done';`}, + {name: "handlers that rejoin the main line", rejoins: true, body: ` + retrieve $A from M.Item on error without rollback begin + log error node 'M' 'a1'; + log error node 'M' 'a2'; + end error; + retrieve $B from M.Item on error without rollback begin + log error node 'M' 'b1'; + log error node 'M' 'b2'; + end error; + log info node 'M' 'done';`}, +} + +// lanePartners put a handler row next to the lane's other occupants: a guard's +// branch, a decision's ELSE row, a handler row inside a decision's branch. +// +// They are checked where the builder places the statement after the decision +// itself — the main line and a loop body. Inside a branch the enclosing merge is +// placed from the measured width of that decision, and the measurer counts a +// split's x as its left edge, so every decision is measured 45px short whether +// or not a handler is involved: `if … else … end if; log …` in a THEN branch puts +// the closing merge 5px into that log. That is its own defect, not this one. +var lanePartners = []struct{ name, body string }{ + {"a guard after a handler", ` + retrieve $A from M.Item on error without rollback begin + log error node 'M' 'a1'; + log error node 'M' 'a2'; + return; + end error; + if $Flag then + log info node 'M' 'refused'; + return; + end if; + log info node 'M' 'done';`}, + {"a decision after a handler", ` + retrieve $A from M.Item on error without rollback begin + log error node 'M' 'a1'; + log error node 'M' 'a2'; + return; + end error; + if $Flag then + log info node 'M' 'yes'; + else + log info node 'M' 'no'; + end if; + log info node 'M' 'done';`}, + {"a handler after a guard", ` + if $Flag then + log info node 'M' 'refused'; + log info node 'M' 'refused, details'; + return; + end if; + retrieve $B from M.Item on error without rollback begin + log error node 'M' 'b1'; + return; + end error; + log info node 'M' 'done';`}, + {"a handler row in a branch, a handler after the decision", ` + if $Flag then + retrieve $A from M.Item on error without rollback begin + log error node 'M' 'a1'; + log error node 'M' 'a2'; + log error node 'M' 'a3'; + return; + end error; + else + log info node 'M' 'no'; + end if; + retrieve $B from M.Item on error without rollback begin + log error node 'M' 'b1'; + return; + end error; + log info node 'M' 'done';`}, +} + +type shapeContext struct { + name string + wrap func(body string) string +} + +var ( + onMainLine = shapeContext{"main line", func(b string) string { return b }} + inLoopBody = shapeContext{"loop body", func(b string) string { + return "loop $It in $Items begin\n" + b + "\nend loop;\nlog info node 'M' 'after';" + }} + inThenBranch = shapeContext{"then branch", func(b string) string { + return "if $Other then\n" + b + "\nelse\n log info node 'M' 'else';\nend if;\nlog info node 'M' 'after';" + }} + inElseBranch = shapeContext{"else branch", func(b string) string { + return "if $Other then\n log info node 'M' 'then';\nelse\n" + b + "\nend if;\nlog info node 'M' 'after';" + }} + inThenWithoutElse = shapeContext{"then branch without else", func(b string) string { + return "if $Other then\n" + b + "\nend if;\nlog info node 'M' 'after';" + }} +) + +func handlerShapeFlow(body string) string { + return "create microflow M.F ($Flag: Boolean, $Other: Boolean, $Items: list of M.Item) begin\n" + body + "\nend;" +} + +// assertNoSharedPlace builds a flow and checks that no two of its objects share a +// place on any canvas. The builder places each statement and the measurer sizes +// the branch or loop it sits in; when the two disagree about where a statement +// went, a merge lands on it, which this catches too. +func assertNoSharedPlace(t *testing.T, src string, checkMergeDirection bool) { + t.Helper() + oc := buildLayout(t, src) + if overlaps := canvasOverlaps(oc.Objects); len(overlaps) > 0 { + t.Errorf("objects share a place:\n %s", strings.Join(overlaps, "\n ")) + } + if checkMergeDirection { + assertLayoutInvariants(t, t.Name(), oc) + } +} + +func TestErrorHandlerRowsNeverOverlap(t *testing.T) { + for _, run := range handlerRuns { + for _, ctx := range []shapeContext{onMainLine, inLoopBody, inThenBranch, inElseBranch, inThenWithoutElse} { + t.Run(run.name+" / "+ctx.name, func(t *testing.T) { + // A handler that falls through rejoins at a merge placed between its + // activity and the next one, left of a row of two or more: a + // backward edge, but not a shared place, and older than this test. + assertNoSharedPlace(t, handlerShapeFlow(ctx.wrap(run.body)), !run.rejoins) + }) + } + } + for _, shape := range lanePartners { + for _, ctx := range []shapeContext{onMainLine, inLoopBody} { + t.Run(shape.name+" / "+ctx.name, func(t *testing.T) { + assertNoSharedPlace(t, handlerShapeFlow(ctx.wrap(shape.body)), true) + }) + } + } +} + +// The measurer sizes a container before its body exists; the builder sizes the +// box from what it built, handler rows included. A loop around each run must come +// out exactly as wide as it was measured, or an IF holding the loop places its +// merge inside the box (the mendixlabs/mxcli#1598 class) — and once a handler row +// makes the next one wait, the measurer has to know about the wait as well as the +// builder. +func TestLoopAroundHandlerRowsIsMeasuredAsBuilt(t *testing.T) { + for _, run := range handlerRuns { + t.Run(run.name, func(t *testing.T) { + prog, errs := visitor.Build(handlerShapeFlow("loop $It in $Items begin\n" + run.body + "\nend loop;")) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + mf := prog.Statements[0].(*ast.CreateMicroflowStmt) + var loopStmt *ast.LoopStmt + for _, s := range mf.Body { + if l, ok := s.(*ast.LoopStmt); ok { + loopStmt = l + } + } + measured := (&layoutMeasurer{}).measureStatement(loopStmt) + + fb := &flowBuilder{posX: 200, posY: 200, spacing: HorizontalSpacing} + oc := fb.buildFlowGraph(mf.Body, mf.ReturnType) + var built model.Size + for _, o := range oc.Objects { + if l, ok := o.(*microflows.LoopedActivity); ok { + built = l.Size + } + } + if built.Width != measured.Width { + t.Errorf("loop built %dpx wide, measured %dpx", built.Width, measured.Width) + } + }) + } +} diff --git a/mdl/executor/layout_lanes.go b/mdl/executor/layout_lanes.go index 827efc98b2..50edbdd599 100644 --- a/mdl/executor/layout_lanes.go +++ b/mdl/executor/layout_lanes.go @@ -13,6 +13,9 @@ // remembers how far it is taken. Only an element that reaches down into that lane — // another decision, a loop box, an activity with an error handler under it — has to // wait for it to clear; a plain activity does not. +// +// An error handler's row is the lane's other occupant: it hangs ErrorHandlerPitch +// under its activity and the main line carries on over it the same way. package executor import "github.com/mendixlabs/mxcli/mdl/ast" @@ -50,6 +53,37 @@ func reachesBelowMainLine(stmt ast.MicroflowStatement, bounds Bounds) bool { return len(getErrorHandlerBody(stmt)) > 0 } +// handlerRowWidth is how wide the row of a statement's custom error handler is, +// measured from the activity's left edge, or 0 when it has no handler body. The +// row's first element is centred under the activity (addErrorHandlerFlow), so +// the row starts where the activity does and runs right like a branch, end +// event included. +func (m *layoutMeasurer) handlerRowWidth(stmt ast.MicroflowStatement) int { + body := getErrorHandlerBody(stmt) + if len(body) == 0 { + return 0 + } + return m.measureBranch(body).Width +} + +// reserveHandlerRow marks the lane under the activity at activityX taken by its +// error handler's row (ako/mxcli#1768, mendixlabs/mxcli#1672). +// +// The row is laid out ErrorHandlerPitch under its activity and runs right, and the +// main line goes on one pitch after the activity regardless — which is right for a +// plain activity, whose row is above the handler's. But the next activity with a +// handler of its own started its row at its own x, inside this one: two consecutive +// REST calls each with a three-element handler put the first handler's end event +// and the second handler's first activity on the same point. clearLowerLane already +// made such a statement wait for a reserved lane; the row just never reserved it. +func (fb *flowBuilder) reserveHandlerRow(activityX int, body []ast.MicroflowStatement) { + if fb.measurer == nil || len(body) == 0 { + return + } + rowRight := activityX - ActivityWidth/2 + fb.measurer.measureBranch(body).Width + fb.reserveLowerLane(fb.posY, rowRight+laneGap) +} + // leftHalfOf is the distance from a statement's x to its left edge, as gapBetween // counts it. func leftHalfOf(stmt ast.MicroflowStatement) int { diff --git a/mdl/executor/layout_position_later_1768_test.go b/mdl/executor/layout_position_later_1768_test.go new file mode 100644 index 0000000000..1e3e9f7d55 --- /dev/null +++ b/mdl/executor/layout_position_later_1768_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// A statement placed with @position starts the row for what follows it, and the +// start event sits one spacing left of the first statement when THAT one is +// placed. The builder found "the first statement with an @position" instead of +// looking at the first statement, so a position written on a later statement +// moved the start event to it — and every unplaced statement before it along +// with the start, onto the placed one (ako/mxcli#1768 case 2, mendixlabs/mxcli#1672). +// Reported: `describe … with handles` printed @position(840, 200) for both +// `declare $A` and `declare $B`, and MPR008 reported the overlap. +func TestStatementsBeforeAPlacedOneKeepTheirDerivedPlaces(t *testing.T) { + oc := buildLayout(t, `create microflow MyModule.SUB_PositionedLater ($Url: String) returns String as $B +begin + declare $A String = $Url; + @position(840, 200) + declare $B String = $A; + return $B; +end;`) + if overlaps := canvasOverlaps(oc.Objects); len(overlaps) > 0 { + t.Errorf("objects share a place:\n %s", strings.Join(overlaps, "\n ")) + } + got := map[string]bool{} + for _, o := range oc.Objects { + got[canvasLabel(o)] = true + } + for _, want := range []string{ + "StartEvent 200;200", // where a flow with no placed first statement starts + "ActionActivity 360;200", // declare $A: one spacing after the start + "ActionActivity 840;200", // declare $B: where the script put it + "EndEvent 1000;200", // one spacing after $B + } { + if !got[want] { + t.Errorf("missing %s", want) + } + } +} + +// Control for the rule above: a placed FIRST statement still carries the start +// event with it, one spacing to its left on its centre line. +func TestPlacedFirstStatementStillCarriesTheStartEvent(t *testing.T) { + oc := buildLayout(t, `create microflow MyModule.SUB_PositionedFirst ($Url: String) returns String as $B +begin + @position(840, 300) + declare $A String = $Url; + declare $B String = $A; + return $B; +end;`) + got := map[string]bool{} + for _, o := range oc.Objects { + got[canvasLabel(o)] = true + } + for _, want := range []string{ + "StartEvent 680;300", + "ActionActivity 840;300", + "ActionActivity 1000;300", + } { + if !got[want] { + t.Errorf("missing %s", want) + } + } +} diff --git a/mdl/executor/validate_timeline_hidden_1762_test.go b/mdl/executor/validate_timeline_hidden_1762_test.go new file mode 100644 index 0000000000..b5e4302ee8 --- /dev/null +++ b/mdl/executor/validate_timeline_hidden_1762_test.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +// A Timeline enumeration its editorConfig hides, set to a non-default value +// (ako/mxcli#1762, mendixlabs/mxcli#1668). Reported: `mxcli check` and `exec` +// both pass, and mx check reports "[CE0463] The definition of this widget has +// changed" at Timeline 'tlD' and 'tlG'. +// +// The check already refused this shape for the one rule it recognised — +// `groupEvents: false` plus a non-default hidden value is an MDL-WIDGET10 +// error, "a non-default value there fails the build with CE0463" — so the +// remaining rules get the same verdict rather than a second policy: refused +// when the script names the value, written at the default when it does not. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// timelineVariants mirrors the measurement in editorconfig_switch_1762_test.go: +// one widget per configuration, with what mx check said about it on 11.13.0. +var timelineVariants = []struct { + name, props string + hiddenProp string // "" for a control mx check passed +}{ + {"tlB", `customVisualization: true`, ""}, + {"tlD", `customVisualization: true, groupByKey: 'day', groupByDayOptions: 'dayMonth'`, "groupByDayOptions"}, + {"tlDm", `customVisualization: true, groupByKey: 'month', groupByMonthOptions: 'monthYear'`, "groupByMonthOptions"}, + {"tlC", `groupByKey: 'day', groupByDayOptions: 'dayMonth'`, ""}, + {"tlM", `groupByKey: 'month', groupByMonthOptions: 'monthYear'`, ""}, + {"tlG", `groupByKey: 'day', groupByMonthOptions: 'monthYear'`, "groupByMonthOptions"}, + {"tlF", `groupByKey: 'month', groupByDayOptions: 'fullDate'`, "groupByDayOptions"}, + {"tlY", `groupByKey: 'year', groupByDayOptions: 'dayMonth'`, "groupByDayOptions"}, + {"tlYm", `groupByKey: 'year', groupByMonthOptions: 'monthYear'`, "groupByMonthOptions"}, +} + +func timelinePage(name, props string) string { + return "create page ModTL.P_" + name + " (Title: 'T', Layout: Atlas_Core.Atlas_Default) {\n" + + " timeline " + name + " (data: MICROFLOW ModTL.DS_Events, groupAttribute: At, " + props + ")\n};\n" +} + +func widget10For(vs []linter.Violation, widget string) []linter.Violation { + var out []linter.Violation + for _, v := range vs { + if v.RuleID == "MDL-WIDGET10" && strings.Contains(v.Message, "widget `"+widget+"`") { + out = append(out, v) + } + } + return out +} + +// check -p against the installed Timeline 3.2.2 agrees with mxbuild on every +// measured variant. +func TestTimelineHiddenEnumeration_CheckAgreesWithMxbuild(t *testing.T) { + project := projectFixture(t) + var src strings.Builder + for _, v := range timelineVariants { + src.WriteString(timelinePage(v.name, v.props)) + } + prog, errs := visitor.Build(src.String()) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + all := ValidateWidgetProperties(prog, project) + + for _, v := range timelineVariants { + got := widget10For(all, v.name) + if v.hiddenProp == "" { + for _, g := range got { + if g.Severity == linter.SeverityError { + t.Errorf("%s (mx check: 0 errors): refused with %s", v.name, g.Message) + } + } + continue + } + refused := false + for _, g := range got { + if g.Severity == linter.SeverityError && strings.Contains(g.Message, "`"+v.hiddenProp+"`") && + strings.Contains(g.Message, "CE0463") { + refused = true + } + } + if !refused { + t.Errorf("%s (mx check: CE0463): want an MDL-WIDGET10 error naming `%s`, got %+v", v.name, v.hiddenProp, got) + } + } + // The default arm's rule says which values hide it, not "matches". + if got := widget10For(all, "tlY"); len(got) == 0 || + !strings.Contains(got[0].Message, "`groupByKey` is none of \"day\", \"month\"") { + t.Errorf("tlY: message %+v; want it to name the values the default arm covers", got) + } +} + +// The value split the check already makes: a hidden enumeration set to its +// default is redundant, not a build failure, so it stays a warning. +func TestTimelineHiddenEnumeration_DefaultIsOnlyAWarning(t *testing.T) { + project := projectFixture(t) + prog, errs := visitor.Build(timelinePage("tlDef", `customVisualization: true, groupByDayOptions: 'dayName'`)) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + got := widget10For(ValidateWidgetProperties(prog, project), "tlDef") + if len(got) != 1 || got[0].Severity != linter.SeverityWarning { + t.Fatalf("got %+v; want one MDL-WIDGET10 warning (the value will be ignored)", got) + } +} + +const timelineSetup = `create module ModTL; +create non-persistent entity ModTL.Ev ( Line: String(200), At: DateTime ); +create microflow ModTL.DS_Events () returns List of ModTL.Ev as $Events +begin + $Events = create list of ModTL.Ev; + return $Events; +end; +` + +// The other half of the policy: a hidden enumeration the script does NOT name is +// written at its default (hiddenUnnamedProperties), now that the rules hiding +// it are known. exec's refusal of a named one is its pre-flight running this +// same check — see cmd/mxcli/exec_preflight_timeline_1762_test.go. +func TestTimelineHiddenEnumeration_UnnamedOnesAreWrittenAtTheirDefault(t *testing.T) { + exec, _ := pageFixtureExecutor(t) + run(t, exec, timelineSetup) + if err := runProgErr(t, exec, timelinePage("tlB", `customVisualization: true`)); err != nil { + t.Fatalf("exec of the variant mx check passed: %v", err) + } + id := unitIDByName(t, exec, "page", "P_tlB") + for key, want := range map[string]string{"groupByDayOptions": "dayName", "groupByMonthOptions": "month"} { + if got := storedPrimitive854(t, exec, id, "tlB", key); got != want { + t.Errorf("%s stored %q; a hidden enumeration must be stored at its default %q", key, got, want) + } + } +} diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 8b82c38787..7f3d8737c5 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -939,11 +939,30 @@ func visibilityCondWord(c *types.WidgetVisibilityCondition) string { return "is enabled" case "falsy": return "is disabled" + case "empty": + return "is empty" + case "notempty": + return "is not empty" + case "in": + return "is one of " + quotedCSV(c.Value) + case "notin": + // A switch's default arm (Timeline's `groupByKey` other than day/month). + return "is none of " + quotedCSV(c.Value) default: return "matches" } } +// quotedCSV renders an `in`/`notin` condition's comma-separated values as a +// quoted, comma-separated list. +func quotedCSV(csv string) string { + parts := strings.Split(csv, ",") + for i, p := range parts { + parts[i] = fmt.Sprintf("%q", p) + } + return strings.Join(parts, ", ") +} + // validateObjectListItemEnums flags an enumeration sub-property of an object-list // item (e.g. a Maps marker `LocationType`, a chart series `Interpolation`) whose // value isn't one of the widget's declared member keys. Studio Pro silently diff --git a/mdl/executor/validate_workflow_rewrite.go b/mdl/executor/validate_workflow_rewrite.go index 6cd7d63eec..ad80144ce1 100644 --- a/mdl/executor/validate_workflow_rewrite.go +++ b/mdl/executor/validate_workflow_rewrite.go @@ -347,7 +347,19 @@ func rawAwaitsAllUsers(d map[string]any) bool { // resets when the statement omits it. func rawMultiUserTaskSettings(raw map[string]any) (rules, participants, await []string) { walkRawDocs(raw, func(d map[string]any) { - if t, _ := d["$Type"].(string); t != "Workflows$MultiUserTaskActivity" { + switch t, _ := d["$Type"].(string); t { + case "Workflows$MultiUserTaskActivity": + case "Workflows$UserTask": + // Below 10.12 a multi-user task is a UserTask, and its settings live + // on its MultiInputCompletion (ako/mxcli#1771). + c, _ := d["UserTaskCompletion"].(map[string]any) + if ct, _ := c["$Type"].(string); ct != "Workflows$MultiInputCompletion" { + return + } + d = map[string]any{"Name": d["Name"], "Outcomes": d["Outcomes"], + "CompletionCriteria": c["CompletionCriteria"], "TargetUserInput": c["TargetUserInput"], + "AwaitAllUsers": c["AwaitAllUsers"]} + default: return } if r := rawCompletionRule(d); r != "" { diff --git a/mdl/executor/validate_workflow_rewrite_1771_test.go b/mdl/executor/validate_workflow_rewrite_1771_test.go new file mode 100644 index 0000000000..e2c653b1bc --- /dev/null +++ b/mdl/executor/validate_workflow_rewrite_1771_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// mendix9MultiUserTaskWorkflow is a workflow as a Mendix 9.x–10.11 project +// stores it (ako/mxcli#1771): a multi-user task is a Workflows$UserTask whose +// UserTaskCompletion, a MultiInputCompletion, holds its rule and participants; +// a single-user task's is a SingleInputCompletion. +func mendix9MultiUserTaskWorkflow() map[string]any { + first := map[string]any{"$ID": "outcome-fast", "$Type": "Workflows$UserTaskOutcome", "Value": "Fast"} + second := map[string]any{"$ID": "outcome-furious", "$Type": "Workflows$UserTaskOutcome", "Value": "Furious"} + return map[string]any{ + "$Type": "Workflows$Workflow", + "Flow": map[string]any{"$Type": "Workflows$Flow", "Activities": []any{ + 3, + map[string]any{"$Type": "Workflows$UserTask", "Name": "userTask1", + "OnCreatedEvent": map[string]any{"$Type": "Workflows$NoEvent"}, + "UserTaskCompletion": map[string]any{"$Type": "Workflows$SingleInputCompletion"}}, + map[string]any{"$Type": "Workflows$UserTask", "Name": "userTask2", + "OnCreatedEvent": map[string]any{"$Type": "Workflows$NoEvent"}, + "Outcomes": []any{3, first, second}, + "UserTaskCompletion": map[string]any{"$Type": "Workflows$MultiInputCompletion", + "CompletionCriteria": map[string]any{"$Type": "Workflows$VetoCompletionCriteria", "VetoOutcomePointer": "outcome-furious"}, + "TargetUserInput": map[string]any{"$Type": "Workflows$AbsoluteAmountUserInput", "Amount": 2}}}, + }}, + } +} + +// The rewrite guard refuses a statement that would reset a stored multi-user +// task's rule or participants. Below 10.12 they are stored on the task's +// UserTaskCompletion, where the guard did not look, so a rewrite on 9.24 reset +// both to the defaults silently. +func TestWorkflowRewrite_SeesAMendix9MultiUserTasksSettings(t *testing.T) { + stmt := parseWorkflowStmt(t, wfRewriteTwoTasks) + err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, mendix9MultiUserTaskWorkflow()), "wf1", "M.W", stmt) + if err == nil || !strings.Contains(err.Error(), "veto") { + t.Fatalf("a rewrite dropping a 9.x multi-user task's veto rule must be refused naming it, got %v", err) + } + + // Control: restating the rule and the participants loses nothing. + restated := parseWorkflowStmt(t, `create or modify workflow M.W parameter $C: M.Ctx +begin + user task userTask1 'User Task' page M.P outcomes 'Good' { } 'Bad' { }; + multi user task userTask2 'Multi' page M.P participants 2 decide by veto 'Furious' outcomes 'Fast' { } 'Furious' { }; +end workflow;`) + if err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, mendix9MultiUserTaskWorkflow()), "wf1", "M.W", restated); err != nil { + t.Fatalf("a rewrite that restates the rule and participants must be allowed, got %v", err) + } +} diff --git a/mdl/executor/version_gates.go b/mdl/executor/version_gates.go index 33514acad5..e2afaec8ec 100644 --- a/mdl/executor/version_gates.go +++ b/mdl/executor/version_gates.go @@ -201,7 +201,7 @@ func statementVersionGates(stmt ast.Statement) []versionGate { if workflowUsesNotificationEvents(activities) { gates = append(gates, notificationEventsGate) } - return gates + return append(gates, userTaskGates(activities)...) case *ast.AlterWorkflowStmt: // Workflows require Mendix 9.12+. gates := []versionGate{{"workflows", "basic", "alter workflow", @@ -213,11 +213,74 @@ func statementVersionGates(stmt ast.Statement) []versionGate { if workflowUsesNotificationEvents(added) { gates = append(gates, notificationEventsGate) } - return gates + return append(gates, userTaskGates(added)...) } return nil } +// userTaskGates are the gates of what the user tasks among activities state +// that an older user task cannot store, each once (ako/mxcli#1771). Below 11.2 +// a task targets users only, and a multi-user task, its later rules and +// `await all users` arrived in 9.22, 10.0, 10.3 and 10.2 (mendixmodelsdk +// 4.117.0). The write refuses them too (canon.LowerToProjectShape); this names +// the statement before anything is written. +func userTaskGates(activities []ast.WorkflowActivityNode) []versionGate { + var groups, multi, majority, threshold, microflow, await bool + walkWorkflowActivities(activities, func(a ast.WorkflowActivityNode) { + ut, ok := a.(*ast.WorkflowUserTaskNode) + if !ok { + return + } + switch ut.Targeting.Kind { + case "group_xpath", "group_microflow": + groups = true + } + if !ut.IsMultiUser { + return + } + multi = true + await = await || ut.AwaitAllUsers + if ut.Completion != nil { + switch ut.Completion.Rule { + case "majority": + majority = true + case "threshold": + threshold = true + case "microflow": + microflow = true + } + } + }) + var gates []versionGate + if groups { + gates = append(gates, versionGate{"workflows", "group_targeting", "targeting groups on a user task", + "Before Mendix 11.2 a user task targets users only: `targeting users xpath […]`, or a microflow " + + "returning users. Workflow groups arrived in 11.2."}) + } + if multi { + gates = append(gates, versionGate{"workflows", "multi_user_task", "multi user task", + "Mendix added multi-user tasks in 9.22: use a user task, or upgrade the project"}) + } + for _, r := range []struct { + used bool + rule string + }{{majority, "majority"}, {threshold, "threshold"}} { + if r.used { + gates = append(gates, versionGate{"workflows", "multi_user_task_majority_threshold", "decide by " + r.rule, + "Mendix added the majority and threshold rules in 10.0: decide by consensus or veto, or upgrade the project"}) + } + } + if microflow { + gates = append(gates, versionGate{"workflows", "multi_user_task_microflow_rule", "decide by microflow", + "Mendix added deciding a multi-user task by a microflow in 10.3: decide by consensus or veto, or upgrade the project"}) + } + if await { + gates = append(gates, versionGate{"workflows", "multi_user_task_await_all", "await all users", + "Mendix added `await all users` in 10.2: leave it out, so the task completes once its rule is met, or upgrade the project"}) + } + return gates +} + // 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). diff --git a/mdl/executor/version_gates_usertask_1771_test.go b/mdl/executor/version_gates_usertask_1771_test.go new file mode 100644 index 0000000000..1d11cf45d7 --- /dev/null +++ b/mdl/executor/version_gates_usertask_1771_test.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#1771: what an older user task cannot store is refused by +// `check -p` and by exec's pre-flight with MDL-VERSION01, before anything is +// written. The write-time refusal in canon.LowerToProjectShape stays as the +// last line; these name the statement and say what to do instead. +// - Group targeting is 11.2. Before it, a user task targets users only. +// - A multi-user task is 9.22. +// - Its rules majority and threshold are 10.0, microflow is 10.3, and +// `await all users` is 10.2 (mendixmodelsdk 4.117.0). +func TestCheckVersionGates_UserTaskFeatures(t *testing.T) { + task := func(f func(*ast.WorkflowUserTaskNode)) *ast.WorkflowUserTaskNode { + n := &ast.WorkflowUserTaskNode{Name: "Review", Caption: "Review"} + f(n) + return n + } + groups := task(func(n *ast.WorkflowUserTaskNode) { + n.Targeting = ast.WorkflowTargetingNode{Kind: "group_xpath", XPath: "[Name = 'Reviewers']"} + }) + groupsMicroflow := task(func(n *ast.WorkflowUserTaskNode) { + n.Targeting = ast.WorkflowTargetingNode{Kind: "group_microflow", Microflow: ast.QualifiedName{Module: "M", Name: "Groups"}} + }) + multi := task(func(n *ast.WorkflowUserTaskNode) { n.IsMultiUser = true }) + rule := func(r string) *ast.WorkflowUserTaskNode { + return task(func(n *ast.WorkflowUserTaskNode) { + n.IsMultiUser = true + n.Completion = &ast.WorkflowCompletionRuleNode{Rule: r} + }) + } + await := task(func(n *ast.WorkflowUserTaskNode) { n.IsMultiUser, n.AwaitAllUsers = true, true }) + + for _, c := range []struct { + name string + act ast.WorkflowActivityNode + below [3]int + want string + }{ + {"groups xpath", groups, [3]int{11, 1, 0}, "targeting groups"}, + {"groups microflow", groupsMicroflow, [3]int{10, 24, 24}, "targeting groups"}, + {"multi user task", multi, [3]int{9, 21, 0}, "multi user task"}, + {"majority", rule("majority"), [3]int{9, 24, 30}, "decide by majority"}, + {"threshold", rule("threshold"), [3]int{9, 24, 30}, "decide by threshold"}, + {"microflow rule", rule("microflow"), [3]int{10, 2, 0}, "decide by microflow"}, + {"await all users", await, [3]int{10, 1, 0}, "await all users"}, + } { + for _, stmt := range []ast.Statement{ + &ast.CreateWorkflowStmt{Activities: []ast.WorkflowActivityNode{c.act}}, + &ast.AlterWorkflowStmt{Operations: []ast.AlterWorkflowOp{ + &ast.InsertAfterOp{NewActivities: []ast.WorkflowActivityNode{c.act}}}}, + } { + prog := &ast.Program{Statements: []ast.Statement{&ast.CreateModuleStmt{Name: "M"}, stmt}} + vs := versionGateViolations(versionCtx(t, c.below[0], c.below[1], c.below[2]), prog) + if len(vs) != 1 || vs[0].RuleID != "MDL-VERSION01" || !strings.Contains(vs[0].Message, c.want) || vs[0].Suggestion == "" { + t.Errorf("%s (%T) below its floor: want one MDL-VERSION01 naming %q with a hint, got %+v", c.name, stmt, c.want, vs) + } + // Control: a version with every feature passes. + if vs := versionGateViolations(versionCtx(t, 11, 99, 0), prog); len(vs) != 0 { + t.Errorf("%s (%T): reported at 11.99: %+v", c.name, stmt, vs) + } + } + } +} + +// The floors are each feature's own: a user task targeting users, and a +// multi-user task deciding by consensus or veto, are written from 9.22. +func TestCheckVersionGates_UserTaskFeaturesAtTheirFloors(t *testing.T) { + prog := &ast.Program{Statements: []ast.Statement{ + &ast.CreateModuleStmt{Name: "M"}, + &ast.CreateWorkflowStmt{Activities: []ast.WorkflowActivityNode{ + &ast.WorkflowUserTaskNode{Name: "A", Targeting: ast.WorkflowTargetingNode{Kind: "xpath", XPath: "[Name = 'a']"}}, + &ast.WorkflowUserTaskNode{Name: "B", Targeting: ast.WorkflowTargetingNode{Kind: "microflow", Microflow: ast.QualifiedName{Module: "M", Name: "U"}}}, + &ast.WorkflowUserTaskNode{Name: "C", IsMultiUser: true, Completion: &ast.WorkflowCompletionRuleNode{Rule: "veto", Veto: "No"}}, + &ast.WorkflowUserTaskNode{Name: "D", IsMultiUser: true, Completion: &ast.WorkflowCompletionRuleNode{Rule: "consensus"}}, + }}, + }} + if vs := versionGateViolations(versionCtx(t, 9, 22, 0), prog); len(vs) != 0 { + t.Errorf("user targeting and consensus/veto multi-user tasks reported on 9.22: %+v", vs) + } + for _, v := range [][3]int{{10, 0, 0}, {10, 2, 0}, {10, 3, 0}, {11, 2, 0}} { + for _, act := range []*ast.WorkflowUserTaskNode{ + {Name: "M", IsMultiUser: true, Completion: &ast.WorkflowCompletionRuleNode{Rule: "majority"}}, + {Name: "G", Targeting: ast.WorkflowTargetingNode{Kind: "group_xpath", XPath: "[Name = 'g']"}}, + } { + floor := [3]int{10, 0, 0} + if act.Name == "G" { + floor = [3]int{11, 2, 0} + } + if v[0] < floor[0] || (v[0] == floor[0] && v[1] < floor[1]) { + continue + } + p := &ast.Program{Statements: []ast.Statement{&ast.CreateModuleStmt{Name: "M"}, + &ast.CreateWorkflowStmt{Activities: []ast.WorkflowActivityNode{act}}}} + if vs := versionGateViolations(versionCtx(t, v[0], v[1], v[2]), p); len(vs) != 0 { + t.Errorf("%s reported on %v, at or past its floor: %+v", act.Name, v, vs) + } + } + } +} diff --git a/mdl/executor/widget_defs.go b/mdl/executor/widget_defs.go index 3aca88263b..f2841c8fa0 100644 --- a/mdl/executor/widget_defs.go +++ b/mdl/executor/widget_defs.go @@ -95,9 +95,8 @@ func RefreshWidgetDefinitions(projectPath string, force bool, output io.Writer) // Lift property-visibility rules from the widget's editorConfig.js // (#574 Phase 2) so the generated .def.json carries the version- // specific applicability logic. Merge with the hand-transcribed table - // rather than replace it: the static extractor skips compound/ternary - // guards (e.g. Timeline's `customVisualization ? hide([title,...])`), - // so the hand-authored fallback fills the keys the extractor misses. + // rather than replace it: the static extractor skips some guard + // shapes, so the hand-authored fallback fills the keys it misses. // Extracted rules win on conflict (they're version-specific). if rules := extractVisibilityRulesFromMPK(mpkPath, mpkDef.ID); len(rules) > 0 { defJSON.PropertyVisibility = mergeVisibilityRules(rules, widgetVisibilityRules[mpkDef.ID]) @@ -442,20 +441,26 @@ func GenerateDefJSON(mpkDef *mpk.WidgetDefinition, mdlName string) *WidgetDefini // configurations. Until the JS extractor lands (#574 Phase 2), these are // transcribed by hand from each widget's compiled editorConfig.js. // -// Only TextTemplate-typed hidden properties need entries: the populated-vs-null -// ClientTemplate choice is what triggers CE0463. Properties hidden as -// Expression/enum/Widgets slots don't carry a ClientTemplate and are omitted. +// The entries cover TextTemplate properties only, and that is NOT a statement +// that only TextTemplates matter. Any hidden property must hold its default: an +// enumeration hidden at a non-default value is CE0463 just the same (Timeline's +// groupByDayOptions in custom mode, ako/mxcli#1762). This table once made that +// rule look recognised — `check` warned about the Timeline's hidden templates, +// from here, while the enumerations in the very same hide went unchecked. // // VideoPlayer (editorConfig.js): // "expression"===e.type && hidePropertiesIn(["videoUrl","posterUrl"]) // Timeline (editorConfig.js): // e.customVisualization ? hidePropertiesIn(["title","description","icon","timeIndication",...]) : ... // +// The extractor now lifts the whole Timeline hide (3.1.1, 3.2.2 and 3.2.3 all +// read 6 of 6), so its entry only matters for a package that ships no +// editorConfig. +// // mergeVisibilityRules returns the extracted rules plus any hand-authored fallback // rules whose PropertyKey the extractor did not cover. Extracted rules are // version-specific (lifted from the installed .mpk's editorConfig.js) and win on -// conflict; the fallback fills the compound/ternary guards the static extractor -// skips (e.g. Timeline `title`/`description` hidden when `customVisualization`). +// conflict; the fallback fills guards the static extractor skips. func mergeVisibilityRules(extracted, fallback []types.WidgetVisibilityRule) []types.WidgetVisibilityRule { if len(fallback) == 0 { return extracted diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index d008a6bcd4..ae79fc5571 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -100,7 +100,12 @@ const defaultSlotContainer = "template" // `editable:` when the package declares no Editability); before, it was // known-but-unmapped and the Calendar's `editable` could not be written // (mendixlabs/mxcli#1578). -const WidgetDefGeneratorVersion = 19 +// 20 — lift editorConfig hides in a `switch` arm, and a guard bounded by the +// head of a `switch(`/`if(` statement (terser folds earlier statements +// into it). Timeline went from 2 to 6 of 6 rules, so its hidden +// enumerations are refused with MDL-WIDGET10 rather than reaching mx check +// as CE0463 (ako/mxcli#1762). Bump forces existing projects to regenerate. +const WidgetDefGeneratorVersion = 20 // WidgetDefinition describes how to construct a pluggable widget from MDL syntax. // Loaded from embedded JSON definition files (*.def.json). diff --git a/mdl/linter/rules/mpr008_overlapping_activities.go b/mdl/linter/rules/mpr008_overlapping_activities.go index 7b0f7152c9..3e3da4dddf 100644 --- a/mdl/linter/rules/mpr008_overlapping_activities.go +++ b/mdl/linter/rules/mpr008_overlapping_activities.go @@ -15,6 +15,11 @@ import ( const activityBoxWidth = 120 const activityBoxHeight = 60 +// eventSize is the side of an event's box (start, end, error, break, continue) +// for one that stores no size: Studio Pro stores 20x20 on every event it draws +// (all 712 in ako/TestApp and PedApp), and so does mxcli. +const eventSize = 20 + // OverlappingActivitiesRule flags microflow activities whose canvas positions overlap. // // The most common cause is writing multiple MDL statements after a single @position @@ -124,6 +129,11 @@ func boxesOverlap(a, b actInfo) bool { func newActInfo(obj microflows.MicroflowObject, caption string) actInfo { p := obj.GetPosition() w, h := activityBoxWidth, activityBoxHeight + switch obj.(type) { + case *microflows.StartEvent, *microflows.EndEvent, *microflows.ErrorEvent, + *microflows.BreakEvent, *microflows.ContinueEvent: + w, h = eventSize, eventSize + } if s, ok := obj.(interface{ GetSize() model.Size }); ok { if sz := s.GetSize(); sz.Width > 0 && sz.Height > 0 { w, h = sz.Width, sz.Height @@ -170,8 +180,24 @@ func overlapPlanes(objects []microflows.MicroflowObject) [][]actInfo { } case *microflows.ExclusiveSplit: plane = append(plane, newActInfo(act, act.Caption)) + case *microflows.InheritanceSplit: + plane = append(plane, newActInfo(act, act.Caption)) case *microflows.ExclusiveMerge: plane = append(plane, newActInfo(act, "(merge)")) + // Events are nodes on the canvas too. Leaving them out hid an + // activity drawn on a handler's end event: two error handler rows + // laid on top of each other were reported only when an activity + // happened to land on an activity (ako/mxcli#1768). + case *microflows.StartEvent: + plane = append(plane, newActInfo(act, "(start)")) + case *microflows.EndEvent: + plane = append(plane, newActInfo(act, "(end)")) + case *microflows.ErrorEvent: + plane = append(plane, newActInfo(act, "(error)")) + case *microflows.BreakEvent: + plane = append(plane, newActInfo(act, "(break)")) + case *microflows.ContinueEvent: + plane = append(plane, newActInfo(act, "(continue)")) } } planes = append(planes, plane) diff --git a/mdl/linter/rules/mpr008_overlapping_activities_test.go b/mdl/linter/rules/mpr008_overlapping_activities_test.go index 20df3a146a..3d9751308a 100644 --- a/mdl/linter/rules/mpr008_overlapping_activities_test.go +++ b/mdl/linter/rules/mpr008_overlapping_activities_test.go @@ -147,3 +147,60 @@ func TestOverlapUsesStoredSizes(t *testing.T) { }) } } + +// ako/mxcli#1768: two error handler rows laid on top of each other put the first +// handler's end event and the second handler's first activity both on 680;330. +// MPR008 compared activities, splits and merges only, so it reported nothing — +// and with one more statement in the first handler it reported one of the two +// overlaps, the one that happened to be activity on activity. Events are nodes +// on the canvas and are compared too, with their own 20x20 box. +func TestOverlapComparesEvents(t *testing.T) { + act := func(x, y int) *microflows.ActionActivity { + a := µflows.ActionActivity{} + a.Position = model.Point{X: x, Y: y} + a.Size = model.Size{Width: 120, Height: 60} + return a + } + end := func(x, y int, sized bool) *microflows.EndEvent { + e := µflows.EndEvent{} + e.Position = model.Point{X: x, Y: y} + if sized { + e.Size = model.Size{Width: 20, Height: 20} + } + return e + } + for _, tc := range []struct { + name string + objs []microflows.MicroflowObject + want bool + }{ + {"end event on an activity (the report)", []microflows.MicroflowObject{end(680, 330, true), act(680, 330)}, true}, + {"end event one pitch after an activity", []microflows.MicroflowObject{act(520, 330), end(680, 330, true)}, false}, + // An event that stores no size is an event, not an activity: judged as a + // 120x60 box it would overlap an activity 80px away. + {"unsized end event 80px from an activity", []microflows.MicroflowObject{act(600, 200), end(680, 200, false)}, false}, + {"unsized end event inside an activity", []microflows.MicroflowObject{act(600, 200), end(640, 200, false)}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + p := overlapPlanes(tc.objs)[0] + if len(p) != 2 { + t.Fatalf("got %d nodes on the canvas, want 2 — the event was left out", len(p)) + } + if got := boxesOverlap(p[0], p[1]); got != tc.want { + t.Errorf("overlap = %v, want %v (%+v, %+v)", got, tc.want, p[0], p[1]) + } + }) + } + + // Every kind of event, and a type split, is on the canvas. + nodes := []microflows.MicroflowObject{ + µflows.StartEvent{}, µflows.EndEvent{}, µflows.ErrorEvent{}, + µflows.BreakEvent{}, µflows.ContinueEvent{}, µflows.InheritanceSplit{}, + } + for i, n := range nodes { + n.SetPosition(model.Point{X: 100 + 200*i, Y: 200}) + } + if got := len(overlapPlanes(nodes)[0]); got != len(nodes) { + t.Errorf("%d of %d nodes are on the canvas", got, len(nodes)) + } +} diff --git a/mdl/roundtrip/flow_commit_events_rerun_test.go b/mdl/roundtrip/flow_commit_events_rerun_test.go index 20ea9eb6c1..1dbc598404 100644 --- a/mdl/roundtrip/flow_commit_events_rerun_test.go +++ b/mdl/roundtrip/flow_commit_events_rerun_test.go @@ -64,8 +64,8 @@ func TestSpliceRerun_CommitWithEventsIsTheStoredCommit(t *testing.T) { if err := h.exec(edited); err != nil { t.Fatalf("change: %v\n%s", err, h.out.String()) } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced)") { - t.Errorf("change: want one statement replaced:\n%s", h.out.String()) + if !strings.Contains(h.out.String(), "(spliced: 1 edited in place)") { + t.Errorf("change: want one statement edited in place:\n%s", h.out.String()) } if len(before.diff(h.snapshot())) == 0 { t.Fatal("change: the real change wrote nothing") diff --git a/mdl/roundtrip/flow_idempotent_shapes_test.go b/mdl/roundtrip/flow_idempotent_shapes_test.go index da0f7e27dd..0b98011bcf 100644 --- a/mdl/roundtrip/flow_idempotent_shapes_test.go +++ b/mdl/roundtrip/flow_idempotent_shapes_test.go @@ -191,7 +191,7 @@ end; want string }{ // The final return: the stored else of the described form. - {"a changed final return", []edit{{" return $N;\nend;", " return $N + $Base;\nend;"}}, "spliced: 1 replaced"}, + {"a changed final return", []edit{{" return $N;\nend;", " return $N + $Base;\nend;"}}, "spliced: 1 edited in place"}, // An activity inserted between the guard and the final return. {"an activity after the guard", []edit{{" return $N;\nend;", " declare $M Integer = $N * 2;\n return $M;\nend;"}}, "spliced: 1 inserted"}, // Two changes straddling the guard's return: one each, never one @@ -199,7 +199,7 @@ end; {"changes on both sides of the guard's return", []edit{ {"declare $Base Integer = 1;", "declare $Base Integer = 2;"}, {"declare $Neg Integer = 0 - $N;", "declare $Neg Integer = 1 - $N;"}, - }, "spliced: 2 replaced"}, + }, "spliced: 2 edited in place"}, } { t.Run(c.name, func(t *testing.T) { h.restore() diff --git a/mdl/roundtrip/flow_modify_end_test.go b/mdl/roundtrip/flow_modify_end_test.go index e219319029..205013eea6 100644 --- a/mdl/roundtrip/flow_modify_end_test.go +++ b/mdl/roundtrip/flow_modify_end_test.go @@ -100,7 +100,7 @@ func TestFlowModify_ReturnValueIsSetInPlace(t *testing.T) { if err := h.exec("mdl 1;\n" + edited); err != nil { t.Fatalf("exec: %v", err) } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced)") { + if !strings.Contains(h.out.String(), "(spliced: 1 edited in place)") { t.Errorf("want a splice report, got:\n%s", h.out.String()) } after := h.flowUnit(t, "ConvertUUIDToURL") @@ -140,7 +140,7 @@ func TestFlowModify_InsertBeforeChangedReturn(t *testing.T) { if err := h.exec("mdl 1;\n" + edited); err != nil { t.Fatalf("exec: %v", err) } - if !strings.Contains(h.out.String(), "(spliced: 1 inserted, 1 replaced)") { + if !strings.Contains(h.out.String(), "(spliced: 1 inserted, 1 edited in place)") { t.Errorf("want a splice report, got:\n%s", h.out.String()) } requireKept(t, before, h.flowUnit(t, "ConvertUUIDToURL"), "") diff --git a/mdl/roundtrip/flow_modify_notes_test.go b/mdl/roundtrip/flow_modify_notes_test.go index 629499f6fa..465963fff9 100644 --- a/mdl/roundtrip/flow_modify_notes_test.go +++ b/mdl/roundtrip/flow_modify_notes_test.go @@ -197,8 +197,8 @@ end; t.Fatalf("exec 1: %v\n%s", err, h.out.String()) } for _, want := range []string{ - "Modified microflow: MyFirstModule.Rerun_Rollback (spliced: 1 replaced)", - "Modified nanoflow: MyFirstModule.Rerun_RollbackNf (spliced: 1 replaced)", + "Modified microflow: MyFirstModule.Rerun_Rollback (spliced: 1 edited in place)", + "Modified nanoflow: MyFirstModule.Rerun_RollbackNf (spliced: 1 edited in place)", } { // The control is in the same line: the change itself is written. if !strings.Contains(h.out.String(), want) { diff --git a/mdl/roundtrip/flow_modify_test.go b/mdl/roundtrip/flow_modify_test.go index 2b5e75556a..5025faf8c4 100644 --- a/mdl/roundtrip/flow_modify_test.go +++ b/mdl/roundtrip/flow_modify_test.go @@ -86,8 +86,8 @@ func TestFlowModify_MDL1ReadsStoredStringsAsStored(t *testing.T) { if bytes.Equal(h.flowUnit(t, "VAL_Feedback"), before) { t.Fatal("mdl 1: a string that states another value than the stored one wrote nothing") } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced)") { - t.Errorf("mdl 1: want the one statement replaced by a splice, got:\n%s", h.out.String()) + if !strings.Contains(h.out.String(), "(spliced: 1 edited in place)") { + t.Errorf("mdl 1: want the one statement edited in place by a splice, got:\n%s", h.out.String()) } if again := h.mustDescribeMdl0(t, valFeedback); !strings.Contains(again, "characters\\\\n';") { t.Errorf("mdl 1: want the backslash stored as written:\n%s", again) @@ -199,7 +199,7 @@ func TestFlowModify_DropAndReplace(t *testing.T) { if err := h.exec("mdl 1;\n" + edited); err != nil { t.Fatalf("exec edited definition: %v", err) } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced, 1 dropped)") { + if !strings.Contains(h.out.String(), "(spliced: 1 edited in place, 1 dropped)") { t.Errorf("want a splice report, got:\n%s", h.out.String()) } after := h.flowUnit(t, "SUB_Feedback_Sanitize") @@ -231,7 +231,7 @@ func TestFlowModify_BranchEditIsSpliced(t *testing.T) { if err := h.exec(edited); err != nil { t.Fatalf("exec edited definition: %v", err) } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced)") { + if !strings.Contains(h.out.String(), "(spliced: 1 edited in place)") { t.Errorf("want a splice report, got:\n%s", h.out.String()) } after := h.flowUnit(t, "VAL_Feedback") @@ -334,7 +334,7 @@ end;` if err := h.exec("mdl 1;\n" + edited); err != nil { t.Fatalf("a value edit in the loop body under mdl 1: %v", err) } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced)") { + if !strings.Contains(h.out.String(), "(spliced: 1 edited in place)") { t.Errorf("a value edit in the loop body: want a splice, got:\n%s", h.out.String()) } described = h.mustDescribeMdl0(t, target) @@ -371,7 +371,7 @@ end;` if err := h.exec("mdl 1;\n" + after); err != nil { t.Fatalf("control under mdl 1: %v", err) } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced)") { + if !strings.Contains(h.out.String(), "(spliced: 1 edited in place)") { t.Errorf("control: want a splice, got:\n%s", h.out.String()) } } diff --git a/mdl/roundtrip/flow_respelling_rerun_test.go b/mdl/roundtrip/flow_respelling_rerun_test.go index 677b60d7c0..19ce7fd378 100644 --- a/mdl/roundtrip/flow_respelling_rerun_test.go +++ b/mdl/roundtrip/flow_respelling_rerun_test.go @@ -188,7 +188,9 @@ func respelledTwice(t *testing.T, h *harness, label, header, flow, setup, script } // Control: a real change outside any loop body is written, and - // moves nothing it does not change. + // moves nothing it does not change. The changed statement is the + // same kind of activity, so it is edited in place and keeps its $ID + // too (ako/mxcli#1761). edited := strings.Replace(script, from, to, 1) if edited == script { t.Fatalf("%s: the script has no %q", label, from) @@ -198,8 +200,8 @@ func respelledTwice(t *testing.T, h *harness, label, header, flow, setup, script if err := h.exec(header + edited); err != nil { t.Fatalf("%s: control: %v\n%s", label, err, h.out.String()) } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced)") { - t.Errorf("%s: control: want one statement replaced:\n%s", label, h.out.String()) + if !strings.Contains(h.out.String(), "(spliced: 1 edited in place)") { + t.Errorf("%s: control: want one statement edited in place:\n%s", label, h.out.String()) } if len(before.diff(h.snapshot())) == 0 { t.Fatalf("%s: control: the real change wrote nothing", label) @@ -209,19 +211,19 @@ func respelledTwice(t *testing.T, h *harness, label, header, flow, setup, script for id, p := range stored { q, ok := after[id] if !ok { - continue // the replaced activity + continue } kept++ if q != p { t.Errorf("%s: control: the unchanged %s moved from %s to %s", label, id, p, q) } } - if kept != len(stored)-1 || len(after) != len(stored) { - t.Errorf("%s: control: %d of %d stored objects kept, %d after; want all but the replaced one", + if kept != len(stored) || len(after) != len(stored) { + t.Errorf("%s: control: %d of %d stored objects kept, %d after; want every one, the edited activity too", label, kept, len(stored), len(after)) } if !samePositions(stored, after) { - t.Errorf("%s: control: the replacement is not drawn where the replaced activity was", label) + t.Errorf("%s: control: the edited activity is not drawn where it was", label) } // The twice-exec rule for the change. second := h.snapshot() diff --git a/mdl/roundtrip/flow_splice_guard_test.go b/mdl/roundtrip/flow_splice_guard_test.go index 3dce8eb4c0..4ff29ee478 100644 --- a/mdl/roundtrip/flow_splice_guard_test.go +++ b/mdl/roundtrip/flow_splice_guard_test.go @@ -249,7 +249,7 @@ func TestSpliceRerun_StudioProDecisionConditionSetInPlace(t *testing.T) { if err := h.exec("mdl 1;\n" + edited); err != nil { t.Fatalf("exec: %v\n%s", err, h.out.String()) } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced)") { + if !strings.Contains(h.out.String(), "(spliced: 1 edited in place)") { t.Errorf("want the condition set, got:\n%s", h.out.String()) } after := h.flowUnit(t, "ChangePassword") @@ -356,7 +356,7 @@ func TestSpliceRerun_ReplaceEndingInGuard(t *testing.T) { if err := h.exec("mdl 1;\n" + edited); err != nil { t.Fatalf("exec: %v\n%s", err, h.out.String()) } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced)") { + if !strings.Contains(h.out.String(), "(spliced: 1 replaced with new element IDs)") { t.Errorf("want one replace, got:\n%s", h.out.String()) } after := h.flowUnit(t, "ShowPasswordForm") @@ -397,7 +397,7 @@ end; if err := h.exec("mdl 1;\n" + guarded); err != nil { t.Fatalf("guard: %v\n%s", err, h.out.String()) } - if !strings.Contains(h.out.String(), "(spliced: 1 replaced)") { + if !strings.Contains(h.out.String(), "(spliced: 1 replaced with new element IDs)") { t.Errorf("want one replace, got:\n%s", h.out.String()) } requireDecisionCases(t, h.flowUnit(t, "Grow_Last")) diff --git a/mdl/roundtrip/flow_splice_handler_return_test.go b/mdl/roundtrip/flow_splice_handler_return_test.go index 8a090b57e4..95672d7194 100644 --- a/mdl/roundtrip/flow_splice_handler_return_test.go +++ b/mdl/roundtrip/flow_splice_handler_return_test.go @@ -54,7 +54,7 @@ begin end error; log info node 'EH' 'done'; end; -`, "(spliced: 1 replaced)", 2}, +`, "(spliced: 1 replaced with new element IDs)", 2}, {"insert, mdl 1, a returned value", "mdl 1;\n", `create or modify microflow MyFirstModule.EH_Insert1 () returns Boolean as $R begin diff --git a/mdl/roundtrip/flow_splice_member_qualified_test.go b/mdl/roundtrip/flow_splice_member_qualified_test.go index bae027fb4a..9325bfda92 100644 --- a/mdl/roundtrip/flow_splice_member_qualified_test.go +++ b/mdl/roundtrip/flow_splice_member_qualified_test.go @@ -95,8 +95,8 @@ func TestFlowModify_SplicedMembersAreQualified(t *testing.T) { case err != nil: t.Errorf("%s: exec: %v", label, err) continue - case !strings.Contains(h.out.String(), "(spliced: 1 replaced)"): - t.Errorf("%s: want one statement replaced by a splice, got:\n%s", label, h.out.String()) + case !strings.Contains(h.out.String(), "(spliced: 1 edited in place)"): + t.Errorf("%s: want one statement edited in place by a splice, got:\n%s", label, h.out.String()) } spliced := flowMembers(t, h.flowUnit(t, "T885_Flow")) for _, m := range spliced { diff --git a/mdl/roundtrip/flow_splice_parity_test.go b/mdl/roundtrip/flow_splice_parity_test.go index 10674fb7aa..cb0ffa3819 100644 --- a/mdl/roundtrip/flow_splice_parity_test.go +++ b/mdl/roundtrip/flow_splice_parity_test.go @@ -137,8 +137,8 @@ func TestPedAppSpliceParity_AuthoredSpellings(t *testing.T) { if err := h.exec("mdl 1;\n" + edited); err != nil { t.Fatalf("exec an edited constraint: %v\n%s", err, h.out.String()) } - if !strings.Contains(h.out.String(), "Modified microflow: MyFirstModule.SpliceParity (spliced: 1 replaced)") { - t.Errorf("an edited constraint must be spliced in as one replaced retrieve:\n%s", h.out.String()) + if !strings.Contains(h.out.String(), "Modified microflow: MyFirstModule.SpliceParity (spliced: 1 edited in place)") { + t.Errorf("an edited constraint must be spliced in as one retrieve edited in place:\n%s", h.out.String()) } if len(created.diff(h.snapshot())) == 0 { t.Error("an edited constraint wrote nothing") diff --git a/mdl/roundtrip/rerun_idempotent_test.go b/mdl/roundtrip/rerun_idempotent_test.go index 5f8d921bfc..7246c3705e 100644 --- a/mdl/roundtrip/rerun_idempotent_test.go +++ b/mdl/roundtrip/rerun_idempotent_test.go @@ -441,7 +441,7 @@ end; } written := len(before.diff(h.snapshot())) switch { - case run == 1 && (written == 0 || !strings.Contains(h.out.String(), "(spliced: 1 replaced)")): + case run == 1 && (written == 0 || !strings.Contains(h.out.String(), "(spliced: 1 edited in place)")): t.Errorf("run 1: want the edited set spliced in, %d unit(s) written:\n%s", written, h.out.String()) case run == 2 && written != 0: t.Errorf("run 2 of the same script wrote %d unit(s):\n%s", written, h.out.String()) diff --git a/modelsdk/canon/versionshape.go b/modelsdk/canon/versionshape.go index 0ee2012481..fbf5097cd3 100644 --- a/modelsdk/canon/versionshape.go +++ b/modelsdk/canon/versionshape.go @@ -53,6 +53,18 @@ import ( // WorkflowActivity, Outcome and BoundaryEvent (ako/mxcli#1163). Workflows // exist from 9.0; `mx convert` 9.24.30 strips every one. The workflow's // own was refused; the others slipped past the floor. +// - Workflows$EndOfParallelSplitPathActivity, a TYPE introduced in 10.21.0, +// which ends every parallel split path mxcli writes (ako/mxcli#1163). +// Before, a path is its activities and nothing more (Studio Pro 9.4.0, the +// Marketplace's "Example project workflow extension" 2.0.0), and a project +// holding the marker does not load (TypeCacheUnknownTypeException). The +// floor checks property versions, never a type's own. +// - Workflows$ user task targeting: UserTargeting from 11.2.0, declared on +// the abstract UserTaskActivity; before, UserSource (ako/mxcli#1771). See +// lowerUserTargeting. +// - Workflows$SingleUserTaskActivity and MultiUserTaskActivity, TYPES +// introduced in 10.12.0; before, a Workflows$UserTask whose +// UserTaskCompletion says which (ako/mxcli#1771). See lowerUserTaskType. // // It runs on every unit write, before the floor, so no writer needs to know // these shapes. What the older shape cannot hold — a design property value @@ -72,15 +84,21 @@ func LowerToProjectShape(contents []byte, pv *version.Version) ([]byte, error) { noPersistentID: !PropertyDeclared("Workflows$Workflow", "PersistentId", *pv), flatConstant: !PropertyDeclared("Settings$ConstantValue", "SharedOrPrivateValue", *pv), variableArgs: !PropertyDeclared("Forms$MicroflowParameterMapping", "Expression", *pv), + unmarkedPaths: pv.Compare(endOfParallelSplitPathSince) < 0, + userSource: !PropertyDeclared("Workflows$UserTaskActivity", "UserTargeting", *pv), + taskBoundaries: !PropertyDeclared("Workflows$UserTaskActivity", "BoundaryEvents", *pv), + userTaskType: pv.Compare(userTaskActivitySince) < 0, } if !l.flatDesignProps && !l.boolEditable && !l.textPlaceholder && !l.namedParameter && !l.noFullObjects && - !l.noPersistentID && !l.flatConstant && !l.variableArgs { + !l.noPersistentID && !l.flatConstant && !l.variableArgs && !l.unmarkedPaths && !l.userSource && + !l.taskBoundaries && !l.userTaskType { return contents, nil } var doc bson.D if err := bson.Unmarshal(contents, &doc); err != nil { return nil, err } + l.root = doc out, err := l.walk(doc) if err != nil { return nil, err @@ -95,10 +113,22 @@ type shapeLowering struct { pv version.Version flatDesignProps, boolEditable, textPlaceholder bool namedParameter, noFullObjects, noPersistentID bool - flatConstant, variableArgs bool + flatConstant, variableArgs, unmarkedPaths bool + userSource, taskBoundaries, userTaskType bool changed bool + // root is the unit, and used the $IDs it holds, for an element the + // lowering adds (see lowerUserTaskType). + root bson.D + used map[string]bool } +// endOfParallelSplitPathSince is the version that introduced the type +// Workflows$EndOfParallelSplitPathActivity (mendixmodelsdk 4.117.0: +// EndOfParallelSplitPathActivity.versionInfo introduced "10.21.0"). It is +// written down here because the generated version data holds property +// versions only, never a type's own. +var endOfParallelSplitPathSince = version.Version{Major: 10, Minor: 21} + var placeholderWidgets = map[string]bool{"Forms$TextBox": true, "Forms$TextArea": true, "Forms$DatePicker": true} // walk returns v with every element below it in the project's shape. @@ -131,6 +161,10 @@ func (l *shapeLowering) walk(v any) (any, error) { if x, err = l.lowerConstantValue(x); err != nil { return nil, err } + case userTaskTypes[typ]: + if x, err = l.lowerUserTask(x, typ); err != nil { + return nil, err + } } if l.noFullObjects { if x, err = l.dropForceFullObjects(x, typ); err != nil { @@ -140,6 +174,9 @@ func (l *shapeLowering) walk(v any) (any, error) { if l.noPersistentID && strings.HasPrefix(typ, "Workflows$") { x = l.dropPersistentID(x) } + if l.unmarkedPaths && typ == "Workflows$Flow" { + l.dropEndOfPathMarkers(x) + } if l.variableArgs { if err = l.lowerFlowArguments(x); err != nil { return nil, err @@ -378,6 +415,34 @@ func (l *shapeLowering) dropPersistentID(d bson.D) bson.D { return append(append(bson.D{}, d[:i]...), d[i+1:]...) } +// dropEndOfPathMarkers removes the Workflows$EndOfParallelSplitPathActivity +// from a flow's activities below 10.21, where the type does not exist. The +// list keeps its leading type marker, so a path that held only the end-of-path +// activity stores `Activities: [3]`, as Studio Pro 9 stores an empty flow. +// Nothing is refused: +// the marker is the writer's, never something a script states, and a path +// before 10.21 is its activities alone (Studio Pro 9.4.0's parallel split). +func (l *shapeLowering) dropEndOfPathMarkers(flow bson.D) { + i := shapeIndex(flow, "Activities") + if i < 0 { + return + } + list, _ := flow[i].Value.(bson.A) + out := make(bson.A, 0, len(list)) + for _, el := range list { + if d, ok := el.(bson.D); ok { + if t, _ := shapeString(d, "$Type"); t == "Workflows$EndOfParallelSplitPathActivity" { + continue + } + } + out = append(out, el) + } + if len(out) != len(list) { + flow[i].Value = out + l.changed = true + } +} + // listItems returns the elements of a stored list, without its type marker. func listItems(v any) bson.A { a, _ := v.(bson.A) diff --git a/modelsdk/canon/versionshape_usertask.go b/modelsdk/canon/versionshape_usertask.go new file mode 100644 index 0000000000..d436e4e4b1 --- /dev/null +++ b/modelsdk/canon/versionshape_usertask.go @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: Apache-2.0 + +package canon + +import ( + "fmt" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/modelsdk/version" +) + +// The workflow user task's older shapes (ako/mxcli#1771), lowered here rather +// than in the workflow encoder because `alter workflow` inserts and edits user +// tasks through the raw mutator, which never passes the encoder: every unit +// write passes LowerToProjectShape. + +// userTaskTypes are the $Types a user task is stored as, in any version. +var userTaskTypes = map[string]bool{ + "Workflows$SingleUserTaskActivity": true, + "Workflows$MultiUserTaskActivity": true, + "Workflows$UserTask": true, +} + +// userSourceOf maps a user targeting (11.2+) to the user source an older +// version stores the same fact in. Measured with `mx convert -p` on +// Studio Pro-authored workflows (Employee Onboarding 9.1.1, Multilevel Dynamic +// Workflow 9.24.3) taken 9.24.30 → 10.24.24 → 11.13.0: the targeting keeps its +// $ID and its one value byte for byte, only its $Type and the key holding it +// change. Group targeting has no user source: it is 11.2, with the groups it +// targets. +var userSourceOf = map[string]string{ + "Workflows$XPathUserTargeting": "Workflows$XPathBasedUserSource", + "Workflows$MicroflowUserTargeting": "Workflows$MicroflowBasedUserSource", + "Workflows$NoUserTargeting": "Workflows$EmptyUserSource", +} + +// emptyUserSourceSince is the version that introduced Workflows$EmptyUserSource, +// a user task with no targeting (mendixmodelsdk 4.117.0: introduced "9.10.0"). +var emptyUserSourceSince = version.Version{Major: 9, Minor: 10} + +// userTaskActivitySince is the version that introduced the types +// Workflows$SingleUserTaskActivity and MultiUserTaskActivity, and deleted +// Workflows$UserTask (mendixmodelsdk 4.117.0). Written down here because the +// generated version data holds property versions only, never a type's own. +var userTaskActivitySince = version.Version{Major: 10, Minor: 12} + +// completionCriteriaSince holds the multi-user task rules introduced after the +// multi-user task itself (9.22.0, with consensus and veto): mendixmodelsdk +// 4.117.0 StructureVersionInfo. +var completionCriteriaSince = map[string]version.Version{ + "Workflows$MajorityCompletionCriteria": {Major: 10}, + "Workflows$ThresholdCompletionCriteria": {Major: 10}, + "Workflows$MicroflowCompletionCriteria": {Major: 10, Minor: 3}, +} + +// completionRule names a completion criteria $Type as the statement that +// writes it does. +var completionRule = map[string]string{ + "Workflows$MajorityCompletionCriteria": "decide by majority", + "Workflows$ThresholdCompletionCriteria": "decide by threshold", + "Workflows$MicroflowCompletionCriteria": "decide by microflow", +} + +// lowerUserTask writes a user task in the shape the project's version stores: +// its targeting, its boundary events and, below 10.12, its type. +func (l *shapeLowering) lowerUserTask(task bson.D, typ string) (bson.D, error) { + var err error + if l.userSource { + if task, err = l.lowerUserTargeting(task); err != nil { + return nil, err + } + } + if l.taskBoundaries { + if task, err = l.dropTaskBoundaryEvents(task); err != nil { + return nil, err + } + } + if l.userTaskType && typ != "Workflows$UserTask" { + return l.lowerUserTaskType(task, typ) + } + return task, nil +} + +// dropTaskBoundaryEvents removes a user task's BoundaryEvents below 10.14, +// where a user task declares none (mendixmodelsdk: UserTaskActivity +// .boundaryEvents introduced 10.14.0; a Workflows$UserTask has none at all). +// The codec writes an empty list on every task, which is dropped; a boundary +// event is refused, naming the task. The floor never saw the key: it is +// declared on the abstract UserTaskActivity. +func (l *shapeLowering) dropTaskBoundaryEvents(task bson.D) (bson.D, error) { + i := shapeIndex(task, "BoundaryEvents") + if i < 0 { + return task, nil + } + if !isEmptyValue(task[i].Value) { + name, _ := shapeString(task, "Name") + return nil, fmt.Errorf("user task %q has a boundary event, which Mendix introduced on user tasks in 10.14 "+ + "(this project is %s)", name, l.pv) + } + l.changed = true + return append(append(bson.D{}, task[:i]...), task[i+1:]...), nil +} + +// lowerUserTaskType rewrites a Workflows$SingleUserTaskActivity or +// MultiUserTaskActivity as the Workflows$UserTask a version before 10.12 +// stores. Studio Pro 9.24.3 stores `{…, UserSource, UserTaskCompletion}` +// (Multilevel Dynamic Workflow, Marketplace 221741), and the completion says +// which kind of task it is (9.22.0): +// +// - a single-user task: a bare Workflows$SingleInputCompletion; +// - a multi-user task: a Workflows$MultiInputCompletion holding what the +// MultiUserTaskActivity holds itself from 10.12: CompletionCriteria (its +// outcome pointers unchanged, the outcomes stay on the task), +// TargetUserInput and, from 10.2.0, AwaitAllUsers. +// +// A 9.24.30 project holding the 10.12 types does not load +// (TypeCacheUnknownTypeException). What the older type cannot hold is refused, +// naming the task: a multi-user task below 9.22, a rule introduced later +// (majority and threshold 10.0, microflow 10.3), and `await all users` below +// 10.2. The completion's $ID is derived from the task's, so the same task +// lowers to the same bytes. +func (l *shapeLowering) lowerUserTaskType(task bson.D, typ string) (bson.D, error) { + name, _ := shapeString(task, "Name") + hasCompletion := PropertyDeclared("Workflows$UserTask", "UserTaskCompletion", l.pv) + completion := bson.D{{Key: "$ID", Value: l.derivedID(task, "UserTaskCompletion")}} + if typ == "Workflows$MultiUserTaskActivity" { + if !hasCompletion { + return nil, fmt.Errorf("multi user task %q: Mendix introduced multi-user tasks in 9.22 (this project is %s)", + name, l.pv) + } + completion = append(completion, bson.E{Key: "$Type", Value: "Workflows$MultiInputCompletion"}) + var await, criteria, input any + task, await = takeKey(task, "AwaitAllUsers") + task, criteria = takeKey(task, "CompletionCriteria") + task, input = takeKey(task, "TargetUserInput") + if PropertyDeclared("Workflows$MultiInputCompletion", "AwaitAllUsers", l.pv) { + completion = append(completion, bson.E{Key: "AwaitAllUsers", Value: await == true}) + } else if await == true { + return nil, fmt.Errorf("multi user task %q waits for all users, which Mendix introduced in 10.2 (this project is %s)", + name, l.pv) + } + criteriaDoc, _ := criteria.(bson.D) + ct, _ := shapeString(criteriaDoc, "$Type") + if since, ok := completionCriteriaSince[ct]; ok && l.pv.Compare(since) < 0 { + return nil, fmt.Errorf("multi user task %q is decided by %s, which Mendix introduced in %d.%d (this project is %s)", + name, completionRule[ct], since.Major, since.Minor, l.pv) + } + completion = append(completion, + bson.E{Key: "CompletionCriteria", Value: criteria}, + bson.E{Key: "TargetUserInput", Value: input}) + } else { + completion = append(completion, bson.E{Key: "$Type", Value: "Workflows$SingleInputCompletion"}) + } + task[shapeIndex(task, "$Type")].Value = "Workflows$UserTask" + l.changed = true + if !hasCompletion { + return task, nil + } + // In Studio Pro's place: after the user source, its neighbour in the + // alphabetical order Studio Pro writes. + at := len(task) + if i := shapeIndex(task, "UserSource"); i >= 0 { + at = i + 1 + } + out := append(append(bson.D{}, task[:at]...), bson.E{Key: "UserTaskCompletion", Value: completion}) + return append(out, task[at:]...), nil +} + +// takeKey removes key from d, returning d without it and the value it held. +func takeKey(d bson.D, key string) (bson.D, any) { + i := shapeIndex(d, key) + if i < 0 { + return d, nil + } + v := d[i].Value + return append(append(bson.D{}, d[:i]...), d[i+1:]...), v +} + +// derivedID is an $ID for an element the lowering adds to owner, derived from +// the owner's $ID and the key it is added under, as CompletePropertySets +// derives the ones it adds: the same owner always gets the same id, and one +// the unit already holds is stepped past. +func (l *shapeLowering) derivedID(owner bson.D, key string) bson.Binary { + if l.used == nil { + l.used = elementIDs(l.root) + } + return completionID(elementIDBytes(owner), "LowerToProjectShape/"+key, l.used) +} + +// lowerUserTargeting replaces a user task's UserTargeting with the UserSource a +// version before 11.2 stores, in its place. mxcli writes UserTargeting on every +// version, and the floor never saw it: it is declared on the abstract +// UserTaskActivity. 10.12.19 and 10.24.24 then read the task as having no user +// source and report CE1859 "XPath constraint is required" on it, whatever its +// targeting was, and `mx convert` stores an empty XPathBasedUserSource in its +// place — the targeting silently gone. Group targeting is refused, naming the +// task. +func (l *shapeLowering) lowerUserTargeting(task bson.D) (bson.D, error) { + i := shapeIndex(task, "UserTargeting") + if i < 0 { + return task, nil + } + targeting, _ := task[i].Value.(bson.D) + var source bson.D + if targeting != nil { + typ, _ := shapeString(targeting, "$Type") + name, _ := shapeString(task, "Name") + sourceType, ok := userSourceOf[typ] + switch { + case !ok: + return nil, fmt.Errorf("user task %q targets %s, which Mendix introduced in 11.2 (this project is %s): "+ + "a user task there targets users, by XPath or by a microflow", name, targetingKind(typ), l.pv) + case sourceType == "Workflows$EmptyUserSource" && l.pv.Compare(emptyUserSourceSince) < 0: + return nil, fmt.Errorf("user task %q has no targeting, which Mendix introduced in 9.10 (this project is %s): "+ + "give it `targeting users xpath […]` or `targeting users microflow …`", name, l.pv) + } + source = append(bson.D{}, targeting...) + source[shapeIndex(source, "$Type")].Value = sourceType + } + task[i] = bson.E{Key: "UserSource", Value: source} + l.changed = true + // A stored UserSource beside it (an older document an edit gave a + // UserTargeting) is the fact the edit replaced. + for j := range task { + if j != i && task[j].Key == "UserSource" { + return append(append(bson.D{}, task[:j]...), task[j+1:]...), nil + } + } + return task, nil +} + +// targetingKind names a targeting $Type as the statement that writes it does. +func targetingKind(typ string) string { + switch typ { + case "Workflows$XPathGroupTargeting": + return "groups by XPath (`targeting groups xpath`)" + case "Workflows$MicroflowGroupTargeting": + return "groups by a microflow (`targeting groups microflow`)" + } + return "with a " + typ +} diff --git a/modelsdk/gen/customwidgets/version.go b/modelsdk/gen/customwidgets/version.go index de213ddd9c..101778f48b 100644 --- a/modelsdk/gen/customwidgets/version.go +++ b/modelsdk/gen/customwidgets/version.go @@ -48,7 +48,13 @@ var VersionInfos = map[string]version.TypeVersionInfo{ }, "CustomWidgets$WidgetReturnType": { Properties: map[string]version.PropertyVersionInfo{ - "type": {Introduced: "9.20.0"}, + // Fixed by hand (ako/mxcli#1163): the vendored generator gave type + // the row of assignableTo, which it then left out. mendixmodelsdk + // 4.117.0 has type with no version and assignableTo introduced + // 9.20.0; Studio Pro 9.1.1 and 9.4.0 store Type and no AssignableTo + // on every return type, Studio Pro 9.24.3 both. + "assignableTo": {Introduced: "9.20.0"}, + "type": {}, }, }, "CustomWidgets$WidgetValue": { diff --git a/modelsdk/gen/pages/version.go b/modelsdk/gen/pages/version.go index 506d0cc1df..eab4e5d9c8 100644 --- a/modelsdk/gen/pages/version.go +++ b/modelsdk/gen/pages/version.go @@ -406,9 +406,15 @@ var VersionInfos = map[string]version.TypeVersionInfo{ }, "Forms$GroupBox": { Properties: map[string]version.PropertyVersionInfo{ - "caption": {Introduced: "8.10.0"}, - "widget": {Deleted: "7.15.0"}, - "widgets": {Introduced: "7.15.0"}, + // Fixed by hand (ako/mxcli#1163): the vendored generator gave caption + // the row of headerMode, which it then left out. mendixmodelsdk + // 4.117.0 has caption with no version and headerMode introduced + // 8.10.0; Studio Pro 9.1.1, 9.4.0 and 9.24.3 store both (the caption + // as CaptionTemplate). + "caption": {}, + "headerMode": {Introduced: "8.10.0"}, + "widget": {Deleted: "7.15.0"}, + "widgets": {Introduced: "7.15.0"}, }, }, "Forms$Header": { diff --git a/modelsdk/mpr/writer_version_shape_1163_test.go b/modelsdk/mpr/writer_version_shape_1163_test.go index 3d33cfcb7b..db8be67f54 100644 --- a/modelsdk/mpr/writer_version_shape_1163_test.go +++ b/modelsdk/mpr/writer_version_shape_1163_test.go @@ -8,7 +8,9 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" + "github.com/mendixlabs/mxcli/modelsdk/canon" "github.com/mendixlabs/mxcli/modelsdk/mpr/version" + mversion "github.com/mendixlabs/mxcli/modelsdk/version" ) // ako/mxcli#1163: what the 9.24 doctype run found mxcli writing that Mendix 9 @@ -82,6 +84,132 @@ func TestNormalize_ReduceFoldIsRefusedBelowMendix10(t *testing.T) { } } +// The vendored generator gave two more properties the row of their type's next +// property and left that one out, as it did AggregateFunction: mendixmodelsdk +// 4.117.0 has CustomWidgets$WidgetReturnType `type: {}` and `assignableTo: +// {introduced: "9.20.0"}`, and Forms$GroupBox (pages.GroupBox) `caption: {}` +// and `headerMode: {introduced: "8.10.0"}`. The table had type 9.20.0 and +// caption 8.10.0, and no row for assignableTo or headerMode. +// Workflows$CallMicroflowTask.boundaryEvents is 10.14.0 in both, and stays. +func TestPropertyDeclared_RowsTheGeneratorMisattributed(t *testing.T) { + for _, tt := range []struct { + typ, key string + v *version.ProjectVersion + want bool + }{ + // Studio Pro 9.1.1 and 9.4.0 store Type on every return type; 9.24.3 + // adds AssignableTo. + {"CustomWidgets$WidgetReturnType", "Type", pv(9, 0, 0), true}, + {"CustomWidgets$WidgetReturnType", "Type", pv(9, 18, 0), true}, + {"CustomWidgets$WidgetReturnType", "AssignableTo", pv(9, 19, 0), false}, + {"CustomWidgets$WidgetReturnType", "AssignableTo", pv(9, 20, 0), true}, + // The caption is stored as CaptionTemplate, which the table never + // keyed: the wrong row applied to nothing. HeaderMode is 8.10, below + // every version mxcli writes. + {"Forms$GroupBox", "CaptionTemplate", pv(8, 0, 0), true}, + {"Forms$GroupBox", "HeaderMode", pv(8, 9, 0), false}, + {"Forms$GroupBox", "HeaderMode", pv(8, 10, 0), true}, + {"Forms$GroupBox", "HeaderMode", pv(9, 0, 0), true}, + // Unchanged: right in the table already. + {"Workflows$CallMicroflowTask", "BoundaryEvents", pv(10, 13, 0), false}, + {"Workflows$CallMicroflowTask", "BoundaryEvents", pv(10, 14, 0), true}, + } { + v := mversion.Version{Major: tt.v.MajorVersion, Minor: tt.v.MinorVersion, Patch: tt.v.PatchVersion} + if got := canon.PropertyDeclared(tt.typ, tt.key, v); got != tt.want { + t.Errorf("%s.%s declared on %s = %v, want %v", tt.typ, tt.key, v, got, tt.want) + } + } +} + +// pageWithReturnType is a page holding one pluggable widget whose Type has a +// return type as the template carries it: Type set, AssignableTo empty. +func pageWithReturnType() bson.D { + rt := bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "CustomWidgets$WidgetReturnType"}, + {Key: "AssignableTo", Value: ""}, {Key: "EntityProperty", Value: ""}, {Key: "IsList", Value: false}, {Key: "Type", Value: "String"}} + vt := bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "CustomWidgets$WidgetValueType"}, {Key: "ReturnType", Value: rt}} + widget := bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, {Key: "Name", Value: "w"}, + {Key: "Type", Value: bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "CustomWidgets$CustomWidgetType"}, + {Key: "ObjectType", Value: bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "CustomWidgets$WidgetObjectType"}, + {Key: "PropertyTypes", Value: bson.A{int32(2), bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "CustomWidgets$WidgetPropertyType"}, + {Key: "ValueType", Value: vt}}}}}}}}} + return bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Forms$Page"}, {Key: "Name", Value: "P"}, + {Key: "Widgets", Value: bson.A{int32(2), widget}}} +} + +// Below 9.20 a return type's Type was refused ("this Mendix 9.18.0 project +// cannot store CustomWidgets$WidgetReturnType.Type (Mendix 9.20.0+)") and its +// AssignableTo passed. Now Type is kept and the empty AssignableTo dropped. +func TestNormalize_WidgetReturnTypeAcrossMendix9_20(t *testing.T) { + for _, tt := range []struct { + v *version.ProjectVersion + want string + }{ + {pv(9, 18, 0), "$ID,$Type,EntityProperty,IsList,Type"}, + {pv(9, 20, 0), "$ID,$Type,AssignableTo,EntityProperty,IsList,Type"}, + {pv(11, 13, 0), "$ID,$Type,AssignableTo,EntityProperty,IsList,Type"}, // control + } { + d, err := normalizeShape(t, tt.v, pageWithReturnType()) + if err != nil { + t.Errorf("%d.%d: a widget with a return type is refused: %v", tt.v.MajorVersion, tt.v.MinorVersion, err) + continue + } + if got := strings.Join(keysOf(find(d, "CustomWidgets$WidgetReturnType")), ","); got != tt.want { + t.Errorf("%d.%d: return type keys %s, want %s", tt.v.MajorVersion, tt.v.MinorVersion, got, tt.want) + } + } +} + +// boundaryEvent is a timer boundary event as mxcli writes one (10.20's type). +func boundaryEvent() bson.D { + return bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$InterruptingTimerBoundaryEvent"}, + {Key: "Caption", Value: "Late"}, {Key: "FirstExecutionTime", Value: "addDays([%CurrentDateTime%], 1)"}, + {Key: "Flow", Value: bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$Flow"}, {Key: "Activities", Value: bson.A{int32(3)}}}}} +} + +// workflowWithCallBoundaryEvents is a workflow holding one microflow call with +// the given BoundaryEvents list. +func workflowWithCallBoundaryEvents(events bson.A) bson.D { + call := bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$CallMicroflowTask"}, + {Key: "BoundaryEvents", Value: events}, {Key: "Caption", Value: "Validate"}, + {Key: "Microflow", Value: "M.Validate"}, {Key: "Name", Value: "call"}} + return bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$Workflow"}, + {Key: "Flow", Value: bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$Flow"}, + {Key: "Activities", Value: bson.A{int32(3), call}}}}, + {Key: "Name", Value: "WF"}, {Key: "Title", Value: "WF"}} +} + +// Workflows$CallMicroflowTask.BoundaryEvents is 10.14.0 (mendixmodelsdk, and +// the table). The writer gives every microflow call an empty list, so below +// 10.14 the empty list is dropped, not refused: Studio Pro 9.24.3 stores no +// BoundaryEvents on a call (Multilevel Dynamic Workflow 1.0.0), and with the +// row's floor removed mxcli writes `BoundaryEvents: [2]` on 10.12.19, which +// `mx convert` 10.12.19 strips. A boundary event the script states is +// refused there, naming the key. +func TestNormalize_CallMicroflowBoundaryEventsBelowMendix10_14(t *testing.T) { + for _, tt := range []struct { + v *version.ProjectVersion + kept bool + }{ + {pv(9, 24, 30), false}, + {pv(10, 13, 0), false}, + {pv(10, 14, 0), true}, + {pv(11, 13, 0), true}, // control + } { + d, err := normalizeShape(t, tt.v, workflowWithCallBoundaryEvents(bson.A{int32(2)})) + if err != nil { + t.Errorf("%d.%d: a microflow call is refused: %v", tt.v.MajorVersion, tt.v.MinorVersion, err) + continue + } + if _, ok := get(find(d, "Workflows$CallMicroflowTask"), "BoundaryEvents"); ok != tt.kept { + t.Errorf("%d.%d: BoundaryEvents kept %v, want %v", tt.v.MajorVersion, tt.v.MinorVersion, ok, tt.kept) + } + } + _, err := normalizeShape(t, pv(10, 13, 0), workflowWithCallBoundaryEvents(bson.A{int32(2), boundaryEvent()})) + if err == nil || !strings.Contains(err.Error(), "Workflows$CallMicroflowTask.BoundaryEvents (Mendix 10.14.0+)") { + t.Errorf("a boundary event on a microflow call on 10.13 was not refused naming the key: %v", err) + } +} + // pid is a fresh PersistentId: a non-zero GUID, as the writer mints one (a zero // GUID is an empty value, which the floor would drop rather than refuse). func pid() bson.Binary { @@ -164,6 +292,108 @@ func TestNormalize_WorkflowPersistentIDsBelowMendix10_21(t *testing.T) { } } +// endOfPath is the marker mxcli ends a parallel split path with +// (workflows.EndParallelSplitPath). +func endOfPath() bson.D { + return bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$EndOfParallelSplitPathActivity"}, + {Key: "Annotation", Value: nil}, {Key: "Caption", Value: "End of parallel split path"}, + {Key: "Name", Value: "EndOfParallelSplitPath"}, {Key: "PersistentId", Value: pid()}} +} + +// workflowWithParallelSplit is a workflow as mxcli builds it: a parallel split +// whose first path calls a microflow and whose second is empty, each ending in +// the end-of-path marker, followed by another microflow call. +func workflowWithParallelSplit() bson.D { + call := func(name string) bson.D { + return bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$CallMicroflowTask"}, + {Key: "BoundaryEvents", Value: bson.A{int32(2)}}, {Key: "Caption", Value: name}, + {Key: "Microflow", Value: "M." + name}, {Key: "Name", Value: name}, {Key: "PersistentId", Value: pid()}} + } + path := func(acts ...bson.D) bson.D { + list := bson.A{int32(3)} + for _, a := range acts { + list = append(list, a) + } + return bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$ParallelSplitOutcome"}, + {Key: "Flow", Value: bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$Flow"}, {Key: "Activities", Value: list}}}, + {Key: "PersistentId", Value: pid()}} + } + split := bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$ParallelSplitActivity"}, + {Key: "Annotation", Value: nil}, {Key: "Caption", Value: "Parallel split"}, {Key: "Name", Value: "split"}, + {Key: "Outcomes", Value: bson.A{int32(3), path(call("Left"), endOfPath()), path(endOfPath())}}, + {Key: "PersistentId", Value: pid()}} + return bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$Workflow"}, + {Key: "Flow", Value: bson.D{{Key: "$ID", Value: bid()}, {Key: "$Type", Value: "Workflows$Flow"}, + {Key: "Activities", Value: bson.A{int32(3), split, call("After")}}}}, + {Key: "Name", Value: "WF"}, + {Key: "PersistentId", Value: pid()}, + {Key: "Title", Value: "WF"}} +} + +// pathActivityTypes returns, per parallel split path, the $Type of each +// activity its flow holds. +func pathActivityTypes(t *testing.T, d bson.D) [][]string { + t.Helper() + outcomes, _ := get(find(d, "Workflows$ParallelSplitActivity"), "Outcomes") + var out [][]string + for _, o := range listItems(outcomes) { + flow, _ := get(o.(bson.D), "Flow") + acts, _ := get(flow.(bson.D), "Activities") + if a, _ := acts.(bson.A); len(a) == 0 || a[0] != int32(3) { + t.Errorf("a path's Activities is %v, want a list with marker 3", acts) + } + types := []string{} + for _, a := range listItems(acts) { + typ, _ := get(a.(bson.D), "$Type") + types = append(types, typ.(string)) + } + out = append(out, types) + } + return out +} + +// Workflows$EndOfParallelSplitPathActivity is introduced in 10.21.0 +// (mendixmodelsdk: the type's own versionInfo). mxcli ends every parallel split +// path with one, because from 10.21 the engine runs a path only up to it — and +// below 10.21 the type does not exist: mx check 9.24.30 does not load the +// project at all ("TypeCacheUnknownTypeException: The type cache does not +// contain a type with qualified name Workflows$EndOfParallelSplitPathActivity"). +// The property floor holds property versions only, so it never saw the type. +// Studio Pro before 10.21 stores a path as its activities and nothing else: +// the Marketplace's "Example project workflow extension" 2.0.0 (Studio Pro +// 9.4.0) has a split whose two paths hold one user task each, no marker, and +// an empty flow stores `Activities: [3]`. So below 10.21 the marker is not +// written; from 10.21 it is, and must be. +func TestNormalize_ParallelSplitPathsBelowMendix10_21(t *testing.T) { + for _, tt := range []struct { + v *version.ProjectVersion + want string + }{ + {pv(9, 24, 30), "Workflows$CallMicroflowTask|"}, + {pv(10, 20, 0), "Workflows$CallMicroflowTask|"}, + {pv(10, 21, 0), "Workflows$CallMicroflowTask,Workflows$EndOfParallelSplitPathActivity|Workflows$EndOfParallelSplitPathActivity"}, + {pv(11, 13, 0), "Workflows$CallMicroflowTask,Workflows$EndOfParallelSplitPathActivity|Workflows$EndOfParallelSplitPathActivity"}, // control + } { + d, err := normalizeShape(t, tt.v, workflowWithParallelSplit()) + if err != nil { + t.Errorf("%d.%d: a parallel split is refused: %v", tt.v.MajorVersion, tt.v.MinorVersion, err) + continue + } + var paths []string + for _, p := range pathActivityTypes(t, d) { + paths = append(paths, strings.Join(p, ",")) + } + if got := strings.Join(paths, "|"); got != tt.want { + t.Errorf("%d.%d: paths hold %s, want %s", tt.v.MajorVersion, tt.v.MinorVersion, got, tt.want) + } + // The rest of the workflow is untouched: the call after the split stays. + acts, _ := get(find(d, "Workflows$Flow"), "Activities") + if n := len(listItems(acts)); n != 2 { + t.Errorf("%d.%d: the main flow holds %d activities, want 2", tt.v.MajorVersion, tt.v.MinorVersion, n) + } + } +} + // settingsWithConstantValue is the project settings unit as `alter settings // constant … in configuration` writes a new override: the value nested in a // Settings$SharedValue (or a Settings$PrivateValue, which holds none). flat is diff --git a/modelsdk/mpr/writer_version_shape_1771_test.go b/modelsdk/mpr/writer_version_shape_1771_test.go new file mode 100644 index 0000000000..1d1ccd49e3 --- /dev/null +++ b/modelsdk/mpr/writer_version_shape_1771_test.go @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "bytes" + "fmt" + "sort" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/modelsdk/mpr/version" +) + +// ako/mxcli#1771: a workflow user task as mxcli builds it is the 11.2 shape, +// and Mendix stored a user task differently before two version boundaries. +// Measured with `mx convert -p` on Studio Pro-authored projects (the +// Marketplace's Employee Onboarding, 9.1.1, XPath targeting; Multilevel Dynamic +// Workflow, 9.24.3, microflow targeting), converted 9.24.30 → 10.24.24 → 11.13.0: +// +// 9.x–10.11 Workflows$UserTask UserSource + UserTaskCompletion +// 10.12–11.1 Workflows$SingleUserTaskActivity UserSource +// 11.2+ Workflows$SingleUserTaskActivity UserTargeting +// +// The targeting value is carried byte-identical across both conversions; only +// its holder changes: XPathBasedUserSource ↔ XPathUserTargeting and +// MicroflowBasedUserSource ↔ MicroflowUserTargeting, each {$ID, $Type, value}. + +var eidSeq byte + +// eid is a distinct, non-zero element $ID, so a test can tell elements apart +// and follow a pointer. +func eid() bson.Binary { + eidSeq++ + return bson.Binary{Subtype: 0, Data: []byte{0x17, 0x71, eidSeq, 3, 4, 5, 0x46, 7, 0x88, 9, 10, 11, 12, 13, 14, eidSeq}} +} + +func xpathUserTargeting(xpath string) bson.D { + return bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$XPathUserTargeting"}, {Key: "XPathConstraint", Value: xpath}} +} + +func microflowUserTargeting(mf string) bson.D { + return bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$MicroflowUserTargeting"}, {Key: "Microflow", Value: mf}} +} + +func noUserTargeting() bson.D { + return bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$NoUserTargeting"}} +} + +func stringTemplate(text string) bson.D { + return bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Microflows$StringTemplate"}, + {Key: "Parameters", Value: bson.A{int32(2)}}, {Key: "Text", Value: text}} +} + +func userTaskOutcome(value string) bson.D { + return bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$UserTaskOutcome"}, + {Key: "Flow", Value: bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$Flow"}, {Key: "Activities", Value: bson.A{int32(3)}}}}, + {Key: "PersistentId", Value: pid()}, {Key: "Value", Value: value}} +} + +// userTaskDoc is a user task as userTaskToGen builds it (below 11.1, where the +// encoder leaves out RelativeMiddlePoint and Size): the keys it writes, in its +// order, with the codec's BoundaryEvents and Annotation defaults last. multi +// adds AwaitAllUsers, a consensus CompletionCriteria falling back to the first +// outcome, and TargetUserInput, as a `multi user task` with no rule. +func userTaskDoc(name string, multi bool, targeting bson.D) bson.D { + typ := "Workflows$SingleUserTaskActivity" + if multi { + typ = "Workflows$MultiUserTaskActivity" + } + approve, reject := userTaskOutcome("Approve"), userTaskOutcome("Reject") + d := bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: typ}, + {Key: "AutoAssignSingleTargetUser", Value: false}} + if multi { + d = append(d, bson.E{Key: "AwaitAllUsers", Value: false}) + } + d = append(d, bson.E{Key: "Caption", Value: name}) + if multi { + approveID, _ := get(approve, "$ID") + d = append(d, bson.E{Key: "CompletionCriteria", Value: bson.D{{Key: "$ID", Value: eid()}, + {Key: "$Type", Value: "Workflows$ConsensusCompletionCriteria"}, {Key: "FallbackOutcomePointer", Value: approveID}}}) + } + d = append(d, + bson.E{Key: "DueDate", Value: ""}, + bson.E{Key: "Name", Value: name}, + bson.E{Key: "OnCreatedEvent", Value: bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$NoEvent"}}}, + bson.E{Key: "Outcomes", Value: bson.A{int32(3), approve, reject}}, + bson.E{Key: "PersistentId", Value: pid()}, + bson.E{Key: "TaskDescription", Value: stringTemplate("")}, + bson.E{Key: "TaskName", Value: stringTemplate(name)}, + bson.E{Key: "TaskPage", Value: bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$PageReference"}, {Key: "Page", Value: "M.TaskPage"}}}) + if multi { + d = append(d, bson.E{Key: "TargetUserInput", Value: bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$AllUserInput"}}}) + } + return append(d, + bson.E{Key: "UserTargeting", Value: targeting}, + bson.E{Key: "BoundaryEvents", Value: bson.A{int32(2)}}, + bson.E{Key: "Annotation", Value: nil}) +} + +// workflowWithUserTasks wraps tasks in a workflow unit. +func workflowWithUserTasks(tasks ...bson.D) bson.D { + acts := bson.A{int32(3)} + for _, t := range tasks { + acts = append(acts, t) + } + return bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$Workflow"}, + {Key: "Flow", Value: bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$Flow"}, {Key: "Activities", Value: acts}}}, + {Key: "Name", Value: "WF"}, + {Key: "Title", Value: "WF"}} +} + +// findNamed returns the first document in v named name. +func findNamed(v any, name string) bson.D { + switch x := v.(type) { + case bson.D: + if n, _ := get(x, "Name"); n == name { + return x + } + for _, e := range x { + if d := findNamed(e.Value, name); d != nil { + return d + } + } + case bson.A: + for _, el := range x { + if d := findNamed(el, name); d != nil { + return d + } + } + } + return nil +} + +// sameID reports whether two stored $IDs (bson.Binary, not comparable with ==) +// are the same. +func sameID(a, b any) bool { + x, ok1 := a.(bson.Binary) + y, ok2 := b.(bson.Binary) + return ok1 && ok2 && bytes.Equal(x.Data, y.Data) +} + +func vlabel(v *version.ProjectVersion) string { + return fmt.Sprintf("%d.%d.%d", v.MajorVersion, v.MinorVersion, v.PatchVersion) +} + +func sortedKeys(d bson.D) string { + ks := keysOf(d) + sort.Strings(ks) + return strings.Join(ks, ",") +} + +// User targeting is UserTargeting from 11.2 and UserSource before it +// (mendixmodelsdk: UserTaskActivity.userSource deleted, userTargeting +// introduced 11.2.0). UserTargeting is declared on the abstract +// UserTaskActivity, which the floor does not look up, so it was written to +// every version: on 10.12.19 and 10.24.24 the task then had no UserSource, and +// mx check reported CE1859 "XPath constraint is required" on every user task — +// the microflow-targeted ones too — while `mx convert` 10.24.24 dropped the +// targeting and stored an empty XPathBasedUserSource in its place. +func TestNormalize_UserTaskTargetingBelowMendix11_2(t *testing.T) { + kinds := []struct { + name string + targeting func() bson.D + sourceType string + valueKey string + value string + }{ + {"users xpath", func() bson.D { return xpathUserTargeting("[Name = 'admin']") }, "Workflows$XPathBasedUserSource", "XPathConstraint", "[Name = 'admin']"}, + {"users microflow", func() bson.D { return microflowUserTargeting("M.ACT_GetUsers") }, "Workflows$MicroflowBasedUserSource", "Microflow", "M.ACT_GetUsers"}, + {"none", noUserTargeting, "Workflows$EmptyUserSource", "", ""}, + } + for _, tt := range []struct { + v *version.ProjectVersion + lowered bool + }{ + {pv(9, 24, 30), true}, + {pv(10, 12, 19), true}, + {pv(10, 24, 24), true}, + {pv(11, 1, 0), true}, + {pv(11, 2, 0), false}, + {pv(11, 13, 0), false}, // control + } { + for _, k := range kinds { + for _, multi := range []bool{false, true} { + targeting := k.targeting() + wantID, _ := get(targeting, "$ID") + d, err := normalizeShape(t, tt.v, workflowWithUserTasks(userTaskDoc("Task", multi, targeting))) + label := vlabel(tt.v) + " " + k.name + if multi { + label += " (multi)" + } + if err != nil { + t.Errorf("%s: refused: %v", label, err) + continue + } + task := findNamed(d, "Task") + _, hasTargeting := get(task, "UserTargeting") + srcAny, hasSource := get(task, "UserSource") + if !tt.lowered { + if !hasTargeting || hasSource { + t.Errorf("%s: keys %v, want UserTargeting and no UserSource", label, keysOf(task)) + } + continue + } + if hasTargeting || !hasSource { + t.Errorf("%s: keys %v, want UserSource and no UserTargeting", label, keysOf(task)) + continue + } + src, _ := srcAny.(bson.D) + if typ, _ := get(src, "$Type"); typ != k.sourceType { + t.Errorf("%s: UserSource is %v, want %s", label, typ, k.sourceType) + } + if id, _ := get(src, "$ID"); !sameID(id, wantID) { + t.Errorf("%s: UserSource $ID %v, want the targeting's own %v", label, id, wantID) + } + wantKeys := "$ID,$Type" + if k.valueKey != "" { + wantKeys += "," + k.valueKey + if v, _ := get(src, k.valueKey); v != k.value { + t.Errorf("%s: %s = %q, want %q", label, k.valueKey, v, k.value) + } + } + if got := strings.Join(keysOf(src), ","); got != wantKeys { + t.Errorf("%s: UserSource keys %s, want %s", label, got, wantKeys) + } + } + } + } +} + +// Group targeting is 11.2 (XPathGroupTargeting, MicroflowGroupTargeting, and +// the workflow groups they target): before, a user task targets users only. +// Refused naming the task, not written as a targeting the version reads as +// "XPath constraint is required". +func TestNormalize_RefusesGroupTargetingBelowMendix11_2(t *testing.T) { + group := func(typ, key, value string) bson.D { + return bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: typ}, {Key: key, Value: value}} + } + for _, g := range []bson.D{ + group("Workflows$XPathGroupTargeting", "XPathConstraint", "[Name = 'Reviewers']"), + group("Workflows$MicroflowGroupTargeting", "Microflow", "M.ACT_GetGroups"), + } { + for _, v := range []*version.ProjectVersion{pv(10, 24, 24), pv(11, 1, 0)} { + _, err := normalizeShape(t, v, workflowWithUserTasks(userTaskDoc("ByGroup", false, g))) + if err == nil || !strings.Contains(err.Error(), "ByGroup") || !strings.Contains(err.Error(), "11.2") { + t.Errorf("%s: group targeting on %s was not refused naming the task and 11.2: %v", g[1].Value, vlabel(v), err) + } + } + if _, err := normalizeShape(t, pv(11, 2, 0), workflowWithUserTasks(userTaskDoc("ByGroup", false, g))); err != nil { + t.Errorf("%s: refused on 11.2: %v", g[1].Value, err) + } + } +} diff --git a/modelsdk/mpr/writer_version_shape_1771_type_test.go b/modelsdk/mpr/writer_version_shape_1771_type_test.go new file mode 100644 index 0000000000..21a21ae5b0 --- /dev/null +++ b/modelsdk/mpr/writer_version_shape_1771_type_test.go @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "bytes" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/modelsdk/mpr/version" +) + +// studioPro924UserTask is the key set Studio Pro 9.24.3 stores on a user task +// (Multilevel Dynamic Workflow, Marketplace 221741). Employee Onboarding +// (9.1.1), converted by mx convert 9.24.30, stores the same set. +const studioPro924UserTask = "$ID,$Type,Annotation,AutoAssignSingleTargetUser,Caption,DueDate,Name,OnCreatedEvent," + + "Outcomes,TaskDescription,TaskName,TaskPage,UserSource,UserTaskCompletion" + +// Below 10.12 a user task is a Workflows$UserTask. mendixmodelsdk deletes +// UserTask and introduces SingleUserTaskActivity and MultiUserTaskActivity in +// 10.12.0. Whether the task is single- or multi-user is its UserTaskCompletion +// (9.22.0): a SingleInputCompletion, or a MultiInputCompletion holding the +// completion criteria, the participants and, from 10.2.0, AwaitAllUsers. +// mxcli wrote the 10.12 types on every version, and 9.24.30 then did not load +// the project (TypeCacheUnknownTypeException … Workflows$SingleUserTaskActivity). +func TestNormalize_UserTaskIsAWorkflowsUserTaskBelowMendix10_12(t *testing.T) { + for _, tt := range []struct { + v *version.ProjectVersion + userTask bool + }{ + {pv(9, 24, 30), true}, + {pv(10, 6, 0), true}, + {pv(10, 11, 0), true}, + {pv(10, 12, 0), false}, + {pv(10, 24, 24), false}, // control + } { + d, err := normalizeShape(t, tt.v, workflowWithUserTasks(userTaskDoc("Single", false, xpathUserTargeting("[Name = 'admin']")))) + if err != nil { + t.Errorf("%s: refused: %v", vlabel(tt.v), err) + continue + } + task := findNamed(d, "Single") + typ, _ := get(task, "$Type") + if !tt.userTask { + if typ != "Workflows$SingleUserTaskActivity" { + t.Errorf("%s: $Type %v, want Workflows$SingleUserTaskActivity", vlabel(tt.v), typ) + } + if _, ok := get(task, "UserTaskCompletion"); ok { + t.Errorf("%s: a SingleUserTaskActivity holds a UserTaskCompletion", vlabel(tt.v)) + } + continue + } + if typ != "Workflows$UserTask" { + t.Errorf("%s: $Type %v, want Workflows$UserTask", vlabel(tt.v), typ) + } + if got := sortedKeys(task); got != studioPro924UserTask { + t.Errorf("%s: user task keys\n %s\nwant Studio Pro 9.24's\n %s", vlabel(tt.v), got, studioPro924UserTask) + } + completion, _ := get(task, "UserTaskCompletion") + cd, _ := completion.(bson.D) + if ct, _ := get(cd, "$Type"); ct != "Workflows$SingleInputCompletion" || len(cd) != 2 { + t.Errorf("%s: UserTaskCompletion %v, want a bare Workflows$SingleInputCompletion", vlabel(tt.v), cd) + } + if id, _ := get(cd, "$ID"); !nonZeroID(id) { + t.Errorf("%s: UserTaskCompletion has no $ID: %v", vlabel(tt.v), id) + } + } +} + +// The completion's $ID is derived from the task's, so writing the same task +// twice produces the same bytes. +func TestNormalize_UserTaskCompletionIDIsStable(t *testing.T) { + doc := workflowWithUserTasks(userTaskDoc("Single", false, xpathUserTargeting("[Name = 'admin']"))) + a, err := normalizeShape(t, pv(9, 24, 30), doc) + if err != nil { + t.Fatal(err) + } + b, err := normalizeShape(t, pv(9, 24, 30), doc) + if err != nil { + t.Fatal(err) + } + ida, _ := get(find(a, "Workflows$SingleInputCompletion"), "$ID") + idb, _ := get(find(b, "Workflows$SingleInputCompletion"), "$ID") + if !sameID(ida, idb) { + t.Errorf("the completion's $ID differs between two writes of the same task: %v, %v", ida, idb) + } +} + +func nonZeroID(v any) bool { + b, ok := v.(bson.Binary) + return ok && len(b.Data) == 16 && !bytes.Equal(b.Data, make([]byte, 16)) +} + +// A multi-user task below 10.12 is a Workflows$UserTask whose +// UserTaskCompletion is a Workflows$MultiInputCompletion. From 10.12 the +// MultiUserTaskActivity holds the same things itself: CompletionCriteria (its +// outcome pointers unchanged), TargetUserInput and, from 10.2, AwaitAllUsers. +func TestNormalize_MultiUserTaskBelowMendix10_12(t *testing.T) { + for _, tt := range []struct { + v *version.ProjectVersion + completion string // keys of the MultiInputCompletion; "" = a MultiUserTaskActivity + }{ + {pv(9, 24, 30), "$ID,$Type,CompletionCriteria,TargetUserInput"}, + {pv(10, 1, 0), "$ID,$Type,CompletionCriteria,TargetUserInput"}, + {pv(10, 2, 0), "$ID,$Type,AwaitAllUsers,CompletionCriteria,TargetUserInput"}, + {pv(10, 11, 0), "$ID,$Type,AwaitAllUsers,CompletionCriteria,TargetUserInput"}, + {pv(10, 12, 0), ""}, + {pv(10, 24, 24), ""}, // control + } { + doc := userTaskDoc("Multi", true, xpathUserTargeting("[Name = 'admin']")) + criteria, _ := get(doc, "CompletionCriteria") + input, _ := get(doc, "TargetUserInput") + d, err := normalizeShape(t, tt.v, workflowWithUserTasks(doc)) + if err != nil { + t.Errorf("%s: refused: %v", vlabel(tt.v), err) + continue + } + task := findNamed(d, "Multi") + typ, _ := get(task, "$Type") + if tt.completion == "" { + if typ != "Workflows$MultiUserTaskActivity" { + t.Errorf("%s: $Type %v, want Workflows$MultiUserTaskActivity", vlabel(tt.v), typ) + } + continue + } + if typ != "Workflows$UserTask" { + t.Errorf("%s: $Type %v, want Workflows$UserTask", vlabel(tt.v), typ) + } + if got := sortedKeys(task); got != studioPro924UserTask { + t.Errorf("%s: user task keys\n %s\nwant\n %s", vlabel(tt.v), got, studioPro924UserTask) + } + completion, _ := get(task, "UserTaskCompletion") + cd, _ := completion.(bson.D) + if ct, _ := get(cd, "$Type"); ct != "Workflows$MultiInputCompletion" { + t.Errorf("%s: UserTaskCompletion is %v, want Workflows$MultiInputCompletion", vlabel(tt.v), ct) + continue + } + if got := strings.Join(keysOf(cd), ","); got != tt.completion { + t.Errorf("%s: MultiInputCompletion keys %s, want %s", vlabel(tt.v), got, tt.completion) + } + gotCriteria, _ := get(cd, "CompletionCriteria") + gotInput, _ := get(cd, "TargetUserInput") + if !sameValue(gotCriteria, criteria) || !sameValue(gotInput, input) { + t.Errorf("%s: criteria %v and participants %v, want the task's own %v and %v", vlabel(tt.v), gotCriteria, gotInput, criteria, input) + } + } +} + +// What a Workflows$UserTask cannot hold is refused, naming the task: +// - a multi-user task below 9.22; +// - a rule Mendix had not introduced yet: majority and threshold are 10.0, +// a microflow rule is 10.3; +// - `await all users` below 10.2; +// - a boundary event on a user task below 10.14. +// +// An empty BoundaryEvents list is the codec's default, and it is dropped. +func TestNormalize_RefusesWhatAnOlderUserTaskCannotHold(t *testing.T) { + withCriteria := func(typ string, extra ...bson.E) bson.D { + doc := userTaskDoc("Multi", true, xpathUserTargeting("[Name = 'admin']")) + cc := bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: typ}} + cc = append(cc, extra...) + doc[indexOf(doc, "CompletionCriteria")].Value = cc + return doc + } + awaiting := userTaskDoc("Multi", true, xpathUserTargeting("[Name = 'admin']")) + awaiting[indexOf(awaiting, "AwaitAllUsers")].Value = true + withBoundary := userTaskDoc("Single", false, xpathUserTargeting("[Name = 'admin']")) + withBoundary[indexOf(withBoundary, "BoundaryEvents")].Value = bson.A{int32(2), + bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$InterruptingTimerBoundaryEvent"}, {Key: "Caption", Value: "Late"}}} + for _, tt := range []struct { + name string + v *version.ProjectVersion + doc bson.D + want string + }{ + {"multi-user task", pv(9, 21, 0), userTaskDoc("Multi", true, xpathUserTargeting("[Name = 'admin']")), "9.22"}, + {"await all users", pv(9, 24, 30), awaiting, "10.2"}, + {"majority", pv(9, 24, 30), withCriteria("Workflows$MajorityCompletionCriteria", bson.E{Key: "CompletionType", Value: "Absolute"}), "10.0"}, + {"threshold", pv(9, 24, 30), withCriteria("Workflows$ThresholdCompletionCriteria", bson.E{Key: "Threshold", Value: int32(2)}), "10.0"}, + {"microflow rule", pv(10, 2, 0), withCriteria("Workflows$MicroflowCompletionCriteria", bson.E{Key: "Microflow", Value: "M.Decide"}), "10.3"}, + {"boundary event 9.24", pv(9, 24, 30), withBoundary, "10.14"}, + {"boundary event 10.13", pv(10, 13, 0), withBoundary, "10.14"}, + } { + name, _ := get(tt.doc, "Name") + _, err := normalizeShape(t, tt.v, workflowWithUserTasks(tt.doc)) + if err == nil || !strings.Contains(err.Error(), name.(string)) || !strings.Contains(err.Error(), tt.want) { + t.Errorf("%s on %s was not refused naming the task and %s: %v", tt.name, vlabel(tt.v), tt.want, err) + } + } + // Control: each is written where Mendix has it. + for _, tt := range []struct { + v *version.ProjectVersion + doc bson.D + }{ + {pv(9, 22, 0), userTaskDoc("Multi", true, xpathUserTargeting("[Name = 'admin']"))}, + {pv(10, 2, 0), awaiting}, + {pv(10, 0, 0), withCriteria("Workflows$MajorityCompletionCriteria", bson.E{Key: "CompletionType", Value: "Absolute"})}, + {pv(10, 3, 0), withCriteria("Workflows$MicroflowCompletionCriteria", bson.E{Key: "Microflow", Value: "M.Decide"})}, + {pv(10, 14, 0), withBoundary}, + } { + if _, err := normalizeShape(t, tt.v, workflowWithUserTasks(tt.doc)); err != nil { + t.Errorf("control %s: refused: %v", vlabel(tt.v), err) + } + } + // The empty list the codec writes on every task is dropped below 10.14. + d, err := normalizeShape(t, pv(10, 13, 0), workflowWithUserTasks(userTaskDoc("Single", false, xpathUserTargeting("[Name = 'admin']")))) + if err != nil { + t.Fatal(err) + } + if _, ok := get(findNamed(d, "Single"), "BoundaryEvents"); ok { + t.Errorf("10.13: an empty BoundaryEvents is kept on a user task, which declares none before 10.14") + } +} + +// `alter workflow … set activity '…' targeting …` on a stored Mendix 9 task +// puts a UserTargeting in the place of its UserSource (the mutator writes the +// current shape). The write lowers it back: the task keeps Studio Pro 9.24's +// key set, with the new targeting value. +func TestNormalize_EditedMendix9UserTaskTargeting(t *testing.T) { + task := bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$UserTask"}, + {Key: "Annotation", Value: nil}, {Key: "AutoAssignSingleTargetUser", Value: false}, {Key: "Caption", Value: "Edited"}, + {Key: "DueDate", Value: ""}, {Key: "Name", Value: "Edited"}, + {Key: "OnCreatedEvent", Value: bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$NoEvent"}}}, + {Key: "Outcomes", Value: bson.A{int32(3), userTaskOutcome("Done")}}, + {Key: "TaskDescription", Value: stringTemplate("")}, {Key: "TaskName", Value: stringTemplate("Edited")}, + {Key: "TaskPage", Value: bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$PageReference"}, {Key: "Page", Value: "M.TaskPage"}}}, + {Key: "UserTargeting", Value: xpathUserTargeting("[Name = 'reviewer']")}, + {Key: "UserTaskCompletion", Value: bson.D{{Key: "$ID", Value: eid()}, {Key: "$Type", Value: "Workflows$SingleInputCompletion"}}}} + d, err := normalizeShape(t, pv(9, 24, 30), workflowWithUserTasks(task)) + if err != nil { + t.Fatal(err) + } + got := findNamed(d, "Edited") + if keys := strings.Join(keysOf(got), ","); keys != studioPro924UserTask { + t.Errorf("edited task keys %s, want %s", keys, studioPro924UserTask) + } + src, _ := get(got, "UserSource") + if x, _ := get(src.(bson.D), "XPathConstraint"); x != "[Name = 'reviewer']" { + t.Errorf("edited targeting %v, want the new XPath", src) + } +} + +func indexOf(d bson.D, key string) int { + for i, e := range d { + if e.Key == key { + return i + } + } + panic("no " + key) +} + +// sameValue compares two stored values by their BSON bytes. +func sameValue(a, b any) bool { + x, err1 := bson.Marshal(bson.D{{Key: "v", Value: a}}) + y, err2 := bson.Marshal(bson.D{{Key: "v", Value: b}}) + return err1 == nil && err2 == nil && bytes.Equal(x, y) +} diff --git a/modelsdk/version/metamodel_versions_gen.go b/modelsdk/version/metamodel_versions_gen.go index 37591339cd..899146f87c 100644 --- a/modelsdk/version/metamodel_versions_gen.go +++ b/modelsdk/version/metamodel_versions_gen.go @@ -341,7 +341,13 @@ var metamodelVersions = []map[string]TypeVersionInfo{ }, "CustomWidgets$WidgetReturnType": { Properties: map[string]PropertyVersionInfo{ - "type": {Introduced: "9.20.0"}, + // Fixed by hand (ako/mxcli#1163): the vendored generator gave type + // the row of assignableTo, which it then left out. mendixmodelsdk + // 4.117.0 has type with no version and assignableTo introduced + // 9.20.0; Studio Pro 9.1.1 and 9.4.0 store Type and no AssignableTo + // on every return type, Studio Pro 9.24.3 both. + "assignableTo": {Introduced: "9.20.0"}, + "type": {}, }, }, "CustomWidgets$WidgetValue": { @@ -2416,9 +2422,15 @@ var metamodelVersions = []map[string]TypeVersionInfo{ }, "Forms$GroupBox": { Properties: map[string]PropertyVersionInfo{ - "caption": {Introduced: "8.10.0"}, - "widget": {Deleted: "7.15.0"}, - "widgets": {Introduced: "7.15.0"}, + // Fixed by hand (ako/mxcli#1163): the vendored generator gave caption + // the row of headerMode, which it then left out. mendixmodelsdk + // 4.117.0 has caption with no version and headerMode introduced + // 8.10.0; Studio Pro 9.1.1, 9.4.0 and 9.24.3 store both (the caption + // as CaptionTemplate). + "caption": {}, + "headerMode": {Introduced: "8.10.0"}, + "widget": {Deleted: "7.15.0"}, + "widgets": {Introduced: "7.15.0"}, }, }, "Forms$Header": { diff --git a/sdk/versions/mendix-10.yaml b/sdk/versions/mendix-10.yaml index 207443cabf..96a25823d4 100644 --- a/sdk/versions/mendix-10.yaml +++ b/sdk/versions/mendix-10.yaml @@ -211,6 +211,22 @@ features: min_version: "9.0.0" user_task: min_version: "9.0.0" + multi_user_task: + min_version: "9.22.0" + mdl: "MULTI USER TASK Vote 'Vote' PAGE M.TaskPage OUTCOMES 'Yes' { } 'No' { };" + notes: "Before 10.12 a Workflows$UserTask whose UserTaskCompletion is a MultiInputCompletion, introduced with consensus and veto in 9.22.0 (mendixmodelsdk 4.117.0; ako/mxcli#1771)." + multi_user_task_majority_threshold: + min_version: "10.0.0" + mdl: "MULTI USER TASK … DECIDE BY MAJORITY MORE THAN HALF | DECIDE BY THRESHOLD 60 PERCENT" + notes: "MajorityCompletionCriteria and ThresholdCompletionCriteria introduced in 10.0.0 (mendixmodelsdk 4.117.0; ako/mxcli#1771)." + multi_user_task_await_all: + min_version: "10.2.0" + mdl: "MULTI USER TASK … AWAIT ALL USERS" + notes: "MultiInputCompletion.awaitAllUsers introduced in 10.2.0, on MultiUserTaskActivity from 10.12 (mendixmodelsdk 4.117.0; ako/mxcli#1771)." + multi_user_task_microflow_rule: + min_version: "10.3.0" + mdl: "MULTI USER TASK … DECIDE BY MICROFLOW M.Decide" + notes: "MicroflowCompletionCriteria introduced in 10.3.0 (mendixmodelsdk 4.117.0; ako/mxcli#1771)." event_handlers: min_version: "10.7.0" mdl: "CREATE WORKFLOW … ON WORKFLOW EVENTS (…) MICROFLOW Module.Handler AS '…'" diff --git a/sdk/versions/mendix-11.yaml b/sdk/versions/mendix-11.yaml index 02a08812e9..ef0935f85e 100644 --- a/sdk/versions/mendix-11.yaml +++ b/sdk/versions/mendix-11.yaml @@ -244,6 +244,26 @@ features: min_version: "9.0.0" user_task: min_version: "9.0.0" + multi_user_task: + min_version: "9.22.0" + mdl: "MULTI USER TASK Vote 'Vote' PAGE M.TaskPage OUTCOMES 'Yes' { } 'No' { };" + notes: "Before 10.12 a Workflows$UserTask whose UserTaskCompletion is a MultiInputCompletion, introduced with consensus and veto in 9.22.0 (mendixmodelsdk 4.117.0; ako/mxcli#1771)." + multi_user_task_majority_threshold: + min_version: "10.0.0" + mdl: "MULTI USER TASK … DECIDE BY MAJORITY MORE THAN HALF | DECIDE BY THRESHOLD 60 PERCENT" + notes: "MajorityCompletionCriteria and ThresholdCompletionCriteria introduced in 10.0.0 (mendixmodelsdk 4.117.0; ako/mxcli#1771)." + multi_user_task_await_all: + min_version: "10.2.0" + mdl: "MULTI USER TASK … AWAIT ALL USERS" + notes: "MultiInputCompletion.awaitAllUsers introduced in 10.2.0, on MultiUserTaskActivity from 10.12 (mendixmodelsdk 4.117.0; ako/mxcli#1771)." + multi_user_task_microflow_rule: + min_version: "10.3.0" + mdl: "MULTI USER TASK … DECIDE BY MICROFLOW M.Decide" + notes: "MicroflowCompletionCriteria introduced in 10.3.0 (mendixmodelsdk 4.117.0; ako/mxcli#1771)." + group_targeting: + min_version: "11.2.0" + mdl: "USER TASK … TARGETING GROUPS XPATH [Name = 'Reviewers'] | TARGETING GROUPS MICROFLOW M.Groups" + notes: "XPathGroupTargeting and MicroflowGroupTargeting introduced in 11.2.0 with UserTaskActivity.userTargeting (mendixmodelsdk 4.117.0). Before 11.2 a user task's UserSource targets users only (ako/mxcli#1771)." ai_agent_task: min_version: "11.9.0" mdl: "CALL AGENT MICROFLOW Module.InvokeAgent WITH (Param = '$WorkflowContext')" diff --git a/sdk/versions/mendix-9.yaml b/sdk/versions/mendix-9.yaml index 7995a0554d..433c275314 100644 --- a/sdk/versions/mendix-9.yaml +++ b/sdk/versions/mendix-9.yaml @@ -61,6 +61,10 @@ features: min_version: "9.0.0" user_task: min_version: "9.0.0" + multi_user_task: + min_version: "9.22.0" + mdl: "MULTI USER TASK Vote 'Vote' PAGE M.TaskPage OUTCOMES 'Yes' { } 'No' { };" + notes: "Before 10.12 a Workflows$UserTask whose UserTaskCompletion is a MultiInputCompletion, introduced with consensus and veto in 9.22.0 (mendixmodelsdk 4.117.0; ako/mxcli#1771)." navigation: profiles: diff --git a/sdk/workflows/workflow.go b/sdk/workflows/workflow.go index dc57904cee..0f6fa8a82c 100644 --- a/sdk/workflows/workflow.go +++ b/sdk/workflows/workflow.go @@ -395,6 +395,11 @@ func (a *EndOfParallelSplitPathActivity) ActivityType() string { return "EndOfPa // the path (a jump, or an end-of-workflow), where a marker would be unreachable. // A nil flow — an empty path — gets a flow holding only the marker, which is how // an empty path is stored. +// +// The marker's type is introduced in Mendix 10.21; before it a path is its +// activities alone, and a project holding the marker does not load. The marker +// is added on every version here and dropped below 10.21 on the way to storage +// (canon.LowerToProjectShape), so the builders need not know the version. func EndParallelSplitPath(flow *Flow, newID func() model.ID) *Flow { if flow == nil { flow = &Flow{}