diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 8fc82282d..5cebf01a0 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -714,3 +714,4 @@ {"area":"mdl/executor","date":"2026-09-25","symptom":"`alter page FeedbackModule.ShareFeedback_Logo { insert after textBox1 { image zzImg (ImageType: imageUrl, ImageUrl: '{1}', ImageUrlParams: [{1} = ImageB64]) } }` passed `check --references`; exec wrote a bare AttributeRef and `mx check` could not LOAD the project (ArgumentNullException setting 'Attribute'). Same at page top level outside any data container; a text box's `Attribute:` there is silently dropped (CE7005).","cause":"ALTER's entity context comes from the STORED document (nearest enclosing data source, or a flow source's return type via resolveDataSourceFlowEntity). With the flow missing (Feedback v4.0.2 ships no DS_FeedbackForm) or no container at all, entityContext is \"\" and resolveAttributePath returns the bare name. CREATE PAGE refused this at check time (relaxExcludedWidgetRefs/unscopedBindings, #678); ALTER's check never opened the document, so nothing could know the scope.","file":"mdl/executor/validate_alter_unscoped.go, mdl/executor/cmd_alter_page.go (alterEntityContext), mdl/executor/validate.go (bindingsWithoutScope)","insight":"For ALTER, scope is a property of the stored document, not the statement: a check-time question about it must open the document (OpenPageForMutation, never Save; validate_alter_set.go already does this) and ask through the SAME function exec uses, so the INSERT/REPLACE entity resolution was lifted into alterEntityContext rather than restated, and the binding walk lifted out of unscopedBindings (bindingsWithoutScope) rather than copied. Controls that keep it from blocking working scripts: skip a target the stored doc lacks (added earlier in the script), a flow the script declares with an entity return (sc.flowParams), DataGrid2 column and list-view-template paths, and documents the script creates. Reproduce with a Studio Pro-authored page whose flow is genuinely absent.","refs":["#678","#685"]} {"area": "mdl/executor", "date": "2026-09-25", "symptom": "`describe page` on a File Uploader (files mode) emits `DataSource: association …`, and exec of that output fails: \"widget `upFiles` (fileuploader) exposes 2 datasources, so a generic `datasource:` clause is ambiguous — name the one you mean: associatedFiles, associatedImages\"", "cause": "DESCRIBE chose generic vs named by counting CONFIGURED datasources (namedCustomWidgetDataSources drops unset ones, so files mode = 1), while the builder's refuseAmbiguousGenericDataSource counts DECLARED datasource mappings (a generated def maps every top-level datasource = 2). Two sides of one round trip deciding the same question from different evidence.", "file": "mdl/executor/cmd_pages_describe_parse.go", "insight": "When describe and build each decide 'is this ambiguous?', they must count the same set. Fix read the DECLARED count from the stored schema (PropertyTypes with ValueType.Type=DataSource, excluding IsLinked) and excluded widgets with an embedded .def.json — those are hand-written and pick one datasource mapping per mode, so a database-mode ComboBox (two declared) must keep the generic clause. The #956 bug-test script itself authored the refused generic clause on a File Uploader: a bug-test that only runs `mxcli check` without a project can't see an exec-time refusal, so grep bug-tests for the old spelling whenever a builder starts refusing one.", "refs": ["mendixlabs/mxcli#1199", "mendixlabs/mxcli#956", "mendixlabs/mxcli#1109"], "rules": []} {"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe output that does not re-parse or loses data (ako/mxcli#707): an entity string default or validation message containing ' was emitted unescaped; so were module-role descriptions, published OData/REST Path/Version/Namespace/Summary/Folder, and REST client BaseUrl/Path/header values; an agent `mcp service` block with a Description lacked the comma after `Enabled`; workflow decision / parallel split captions came back only as `-- caption` comments (replay reset them to 'Decision' / 'Parallel split'); `describe demo user` emitted `password '***'`, which replay stored as the password; `describe settings` printed `DatabasePassword = ''`.", "cause": "Hand-rolled `'%s'` emit sites that put the quotes and the escaping in different places (the #1006 source-scan guard covered only cmd_workflows.go); a block emitter with no separator logic, unlike its sibling; captions treated as commentary although the grammar has `comment '…'` for both activities; secrets printed as data, with a placeholder the writer took literally.", "file": "mdl/executor/cmd_entities_describe.go, cmd_security.go, cmd_security_write.go, cmd_odata.go, cmd_published_rest.go, cmd_rest_clients.go, cmd_agenteditor_agents.go, cmd_workflows.go, cmd_settings.go", "fix": "Every emit site uses mdlQuoted; TestDescribers_HaveNoHandRolledStringLiterals now scans all seven describer files. MCP block writes the comma like the tool block. workflowCaptionClauses emits `comment '…'` for a non-default caption and computes the name clause against the caption the writer will store. DatabasePassword is omitted with a comment (create or modify is a patch, so replay keeps it). Demo users are described as `create or modify … password '***'`, and the executor treats '***' as 'keep the stored password', refusing it for a user that does not exist.", "insight": "Assert round trips by reparsing describe output with the real visitor and comparing the AST value to the stored one, not by substring. For secrets the right placeholder is one the WRITER understands: omission works where the create is a patch (configuration); where the grammar requires the value (demo user) give the placeholder a meaning (keep stored) and refuse it where that meaning is empty, so a replay can neither leak nor silently set a credential.", "test": "mdl/executor/issue707_describe_roundtrip_test.go"} +{"area": "mdl/executor", "date": "2026-09-26", "symptom": "describe microflow \u2026 with handles (ako/mxcli#713) printed no handle for an activity inside an `on error { \u2026 }` block, and the alter-target resolver counted such activities after the whole main flow: on SUB_Feedback_PostToAppInsights `return * @1` picked `return $Response`, although describe prints the handler's `return empty` first. Also `$Response` did not address a REST call whose output is on its result handling (and cast, create list, web service, workflow, XML/JSON, database-query outputs).", "cause": "Error-handler bodies are rendered by collectErrorHandlerStatements, a second describer that returned bare strings and never wrote the source map, so those nodes had no line to rank or print a handle at; unranked candidates were appended last. The output-variable switch was copied from actionOutputVariableName, which had drifted from the formatter.", "file": "mdl/executor/cmd_microflows_show_helpers.go; mdl/backend/mfmutator/target.go", "fix": "collectErrorHandlerStatementSpans reports each handler-body object's statement span; emitActivityStatement and emitCommentedErrorHandler record them in the source map (additive entries in ELK sourceMap too). mfmutator.OutputVariable reads the variable where the formatter does, for every action it prints as `$X = \u2026`. A comment rendering (`-- Unsupported \u2026`) is no statement, so it never becomes a handle.", "insight": "Any node the describer prints through a side path must enter the source map, or everything keyed on print order (ordinals, handles, ELK highlighting) silently disagrees with the text. Check ranking against a Studio Pro flow that has a handler body, not just VAL_Feedback.", "test": "TestDescribeWithHandles_ErrorHandlerBody, TestMicroflowTargets_PedAppEveryFlowRanksAndResolves, TestOutputVariable_EveryActionDescribePrintsAnAssignmentFor, TestCandidate_CommentRenderingIsNoStatement"} diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index 5904ebce8..739fd48d8 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -30,6 +30,7 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "MODIFY", Kind: protocol.CompletionItemKindKeyword, Detail: "DDL keyword"}, {Label: "MERGE", Kind: protocol.CompletionItemKindKeyword, Detail: "DDL keyword"}, {Label: "NORMALIZED", Kind: protocol.CompletionItemKindKeyword, Detail: "DDL keyword"}, + {Label: "HANDLES", Kind: protocol.CompletionItemKindKeyword, Detail: "DDL keyword"}, {Label: "ENTITY", Kind: protocol.CompletionItemKindKeyword, Detail: "DDL keyword"}, {Label: "PERSISTENT", Kind: protocol.CompletionItemKindKeyword, Detail: "DDL keyword"}, {Label: "VIEW", Kind: protocol.CompletionItemKindKeyword, Detail: "DDL keyword"}, diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index 92618abde..bc7dc3a74 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -248,6 +248,37 @@ func init() { SeeAlso: []string{"microflow.merge-join", "microflow.control-flow"}, }) + Register(SyntaxFeature{ + Path: "microflow.describe-handles", + Summary: "DESCRIBE MICROFLOW ... WITH HANDLES: print each activity's content address", + Keywords: []string{ + "handles", "handle", "describe", "target", "address", "alter microflow", + "ordinal", "content addressing", "wildcard", + }, + Syntax: "DESCRIBE MICROFLOW Module.Name WITH HANDLES;\n\n" + + "-- Prints '-- handle: <target>' above each activity: the address that\n" + + "-- selects it in an ALTER MICROFLOW target (ADR-0012). Activities have no\n" + + "-- names, so a target names one by content, in this order of preference:\n" + + "-- $Var the activity whose output variable is $Var\n" + + "-- 'Caption' a split, or an activity with a custom caption\n" + + "-- commit $Order a statement pattern; * matches any run of\n" + + "-- log * node 'Debug' * tokens, and the pattern spans the WHOLE\n" + + "-- statement (end with * to match a prefix)\n" + + "-- A target matching several activities is an error that lists each with\n" + + "-- its ordinal (@1, @2, ... in describe order); it is never a guess.\n" + + "-- The handles are comments, so the output still executes unchanged.\n" + + "-- Cannot be combined with NORMALIZED, whose graph is not the stored one.", + Example: "DESCRIBE MICROFLOW FeedbackModule.VAL_Feedback WITH HANDLES;\n\n" + + "-- emits, among others:\n" + + "-- -- handle: $IsValidEmail\n" + + "-- $IsValidEmail = call java action FeedbackModule.ValidateEmail(...);\n" + + "-- -- handle: 'Email is Valid?'\n" + + "-- if not($IsValidEmail) then\n" + + "-- -- handle: set $ValidFeedback = false @3\n" + + "-- set $ValidFeedback = false;", + SeeAlso: []string{"microflow.normalized-describe"}, + }) + Register(SyntaxFeature{ Path: "microflow.merge-join", Summary: "Named join points: MERGE <label> and JOIN <label>", diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 1c5977d8a..efe95a55b 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -473,6 +473,7 @@ rather than updating the first. | Show nanoflows | `show nanoflows [in module];` | List all or filter by module | | Describe microflow | `describe microflow Module.Name;` | Full MDL with activities | | Describe microflow (normalized) | `describe microflow Module.Name normalized;` | Folds crossed branches into one condition instead of flattening them. Opt-in: the output re-executes to an equivalent graph with fewer nodes and a different layout | +| Describe microflow (with handles) | `describe microflow Module.Name with handles;` | Prints `-- handle: <target>` above each activity: its content address for `alter microflow` — output `$Var`, `'Caption'`, or a statement pattern with `*` wildcards (anchored at both ends), plus `@n` when several match. Comments only; cannot be combined with `normalized` | | Describe nanoflow | `describe nanoflow Module.Name;` | Full MDL with activities | | Rename microflow | `rename microflow Module.Old to New;` | Updates all references | | Rename nanoflow | `rename nanoflow Module.Old to New;` | Updates all references | diff --git a/mdl/ast/ast_query.go b/mdl/ast/ast_query.go index 50974ccfb..3ffc6e74b 100644 --- a/mdl/ast/ast_query.go +++ b/mdl/ast/ast_query.go @@ -312,6 +312,11 @@ type DescribeStmt struct { // and silently reshaping someone's diagram because they asked to read it // is its own guard-don't-drop violation. Normalized bool + // WithHandles makes DESCRIBE MICROFLOW print, above each activity, the + // content address an `alter microflow` target would use for it (output + // variable, caption or statement, with an ordinal when needed). The + // handles are comments, so the output still executes unchanged. + WithHandles bool } func (s *DescribeStmt) isStatement() {} diff --git a/mdl/backend/mfmutator/resolve.go b/mdl/backend/mfmutator/resolve.go new file mode 100644 index 000000000..1e9c14563 --- /dev/null +++ b/mdl/backend/mfmutator/resolve.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "fmt" + "sort" + "strings" +) + +// NotFoundError reports a target that addresses no activity. +type NotFoundError struct { + Target Target + // Hint lists what the target could have meant, e.g. the output variables + // the flow does have. May be empty. + Hint string +} + +func (e *NotFoundError) Error() string { + msg := fmt.Sprintf("no activity matches %s %s", e.Target.Kind, e.Target.Text) + if e.Hint != "" { + msg += "; " + e.Hint + } + return msg +} + +// AmbiguousError reports a target that addresses more than one activity and +// carries no ordinal (or an ordinal past the last match). It lists each match +// under the address that selects it. +type AmbiguousError struct { + Target Target + Matches []Candidate +} + +func (e *AmbiguousError) Error() string { + var b strings.Builder + if e.Target.Ordinal > len(e.Matches) { + fmt.Fprintf(&b, "%s %s @%d: there are only %d matches:", e.Target.Kind, e.Target.Text, e.Target.Ordinal, len(e.Matches)) + } else { + fmt.Fprintf(&b, "%s %s matches %d activities; add an ordinal to choose one:", e.Target.Kind, e.Target.Text, len(e.Matches)) + } + for i, c := range e.Matches { + fmt.Fprintf(&b, "\n %s @%d -- %s", e.Target.Text, i+1, describeCandidate(c)) + } + return b.String() +} + +// Resolve returns the one candidate t addresses. Zero matches is a +// NotFoundError; more than one without an ordinal, or an ordinal past the +// last match, is an AmbiguousError. It never picks among matches on its own. +func Resolve(cands []Candidate, t Target) (Candidate, error) { + idx := matchIndices(cands, t) + switch { + case len(idx) == 0: + return Candidate{}, &NotFoundError{Target: t, Hint: notFoundHint(cands, t)} + case t.Ordinal > len(idx): + return Candidate{}, &AmbiguousError{Target: t, Matches: pick(cands, idx)} + case t.Ordinal > 0: + return cands[idx[t.Ordinal-1]], nil + case len(idx) == 1: + return cands[idx[0]], nil + default: + return Candidate{}, &AmbiguousError{Target: t, Matches: pick(cands, idx)} + } +} + +// ResolveText parses text as a target and resolves it. +func ResolveText(cands []Candidate, text string) (Candidate, error) { + t, err := ParseTarget(text) + if err != nil { + return Candidate{}, err + } + return Resolve(cands, t) +} + +func matchIndices(cands []Candidate, t Target) []int { + var idx []int + for i := range cands { + if cands[i].matches(t) { + idx = append(idx, i) + } + } + return idx +} + +func pick(cands []Candidate, idx []int) []Candidate { + out := make([]Candidate, len(idx)) + for i, j := range idx { + out[i] = cands[j] + } + return out +} + +// notFoundHint names the addresses of the same kind that do exist, which is +// usually enough to spot a typo, and says when an anchored pattern only +// failed because the statement goes on. +func notFoundHint(cands []Candidate, t Target) string { + switch t.Kind { + case ByOutputVariable: + return listHint("output variables", cands, func(c Candidate) string { + if c.OutputVariable == "" { + return "" + } + return "$" + c.OutputVariable + }) + case ByCaption: + return listHint("captions", cands, func(c Candidate) string { + if c.Caption == "" { + return "" + } + return quote(c.Caption) + }) + default: + open := append(append([]Token{}, t.Pattern...), Token{Kind: TokStar, Text: "*"}) + for i := range cands { + if cands[i].matchesPattern(open) { + return fmt.Sprintf("patterns match the whole statement: end it with * to match %q", cands[i].Statement) + } + } + return "" + } +} + +func listHint(what string, cands []Candidate, name func(Candidate) string) string { + seen := map[string]bool{} + var names []string + for _, c := range cands { + if n := name(c); n != "" && !seen[n] { + seen[n] = true + names = append(names, n) + } + } + if len(names) == 0 { + return "the microflow has no " + what + } + sort.Strings(names) + return "the microflow's " + what + " are " + strings.Join(names, ", ") +} + +// Handle returns the preferred address of cands[i]: its output variable, else +// its caption, else its statement — the first of those that selects it alone. +// When none is unique it returns the first available form with the ordinal +// that selects it. It returns "" for a candidate nothing can address (an end +// event describe renders as nothing). +// +// Handles are what `describe … with handles` prints, so each one is checked +// by resolving it rather than assumed: a statement containing `*` (a +// multiplication) reads back as a wildcard pattern and may select more than +// its own activity. +func Handle(cands []Candidate, i int) string { + c := cands[i] + var forms []string + if c.OutputVariable != "" { + forms = append(forms, "$"+c.OutputVariable) + } + if c.Caption != "" && !strings.ContainsAny(c.Caption, "\r\n") { + // A handle is printed on one comment line; a multi-line caption + // cannot be, so such an activity is addressed by its statement. + forms = append(forms, quote(c.Caption)) + } + if c.Statement != "" { + forms = append(forms, c.Statement) + } + + fallback := "" + for _, form := range forms { + t, err := ParseTarget(form) + if err != nil || t.Ordinal != 0 { + // A statement ending in `@<digits>` would read back with an + // ordinal; it cannot serve as its own handle. + continue + } + idx := matchIndices(cands, t) + pos := -1 + for k, j := range idx { + if j == i { + pos = k + break + } + } + if pos < 0 { + continue + } + if len(idx) == 1 { + return form + } + if fallback == "" { + fallback = fmt.Sprintf("%s @%d", form, pos+1) + } + } + return fallback +} diff --git a/mdl/backend/mfmutator/target.go b/mdl/backend/mfmutator/target.go new file mode 100644 index 000000000..020eadd2a --- /dev/null +++ b/mdl/backend/mfmutator/target.go @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package mfmutator holds the engine-agnostic microflow ALTER logic, alongside +// pagemutator and wfmutator. It starts with the piece every microflow patch +// needs first: finding the activity an operation is aimed at. +// +// # Content addressing +// +// A page addresses a widget by its name. Microflow activities have no names, +// so an `alter microflow` target names an activity by what it is +// (ADR-0012, decision 2), in this order of preference: +// +// 1. its output variable: `$Lines`; +// 2. its caption: `'Email is valid?'` — for splits, and for activities whose +// caption is custom rather than generated; +// 3. a statement pattern, where `*` stands for any run of tokens: +// `commit $Order`, `log * node 'Debug' *`. +// +// When an address matches more than one activity, that is an error listing +// every match with the ordinal (`@2`) that picks it. The resolver never +// guesses: a patch applied to the wrong activity of a Studio Pro-authored flow +// is a silent change of behaviour, which is worse than a refusal. +// +// # What is matched against what +// +// The resolver works on Candidates: one per activity in the stored object +// collection (loop bodies included), each carrying its output variable, its +// custom caption and its statement as `describe` renders it. Rendering a +// statement is the executor's job, so this package takes the text as input and +// stays free of any executor dependency; the order of the candidates is the +// order ordinals count in, which the caller sets to the order `describe` prints +// the activities. +package mfmutator + +import ( + "fmt" + "strconv" + "strings" + "unicode" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// Kind says which of the three addressing forms a Target uses. +type Kind int + +const ( + // ByOutputVariable addresses the activity that outputs `$Name`. + ByOutputVariable Kind = iota + // ByCaption addresses a split or activity by its (custom) caption. + ByCaption + // ByPattern addresses an activity by its statement, with `*` wildcards. + ByPattern +) + +func (k Kind) String() string { + switch k { + case ByOutputVariable: + return "output variable" + case ByCaption: + return "caption" + default: + return "statement pattern" + } +} + +// Target is a parsed content address. +type Target struct { + // Text is the address as written, ordinal excluded; used in messages. + Text string + Kind Kind + // Variable is the output variable name without `$` (ByOutputVariable). + Variable string + // Caption is the unquoted caption (ByCaption). + Caption string + // Pattern is the tokenised statement pattern (ByPattern). A token whose + // text is `*` is a wildcard. + Pattern []Token + // Ordinal is the 1-based `@n` suffix, or 0 when none was written. + Ordinal int +} + +// ParseTarget parses a content address: `$Var`, `'Caption'` or a statement +// pattern, each optionally followed by an ordinal `@n`. +// +// A lone variable is always an output-variable address and a lone string +// always a caption address; anything longer is a statement pattern. The forms +// do not fall back on one another — a `$Var` that outputs nothing is an error, +// not a pattern search — so an address means one thing only. +func ParseTarget(text string) (Target, error) { + s := strings.TrimSpace(text) + s = strings.TrimSuffix(s, ";") + s = strings.TrimSpace(s) + + var t Target + if at := strings.LastIndex(s, "@"); at >= 0 { + digits := strings.TrimSpace(s[at+1:]) + if digits != "" && isDigits(digits) && !insideString(s, at) { + n, err := strconv.Atoi(digits) + if err != nil || n < 1 { + return Target{}, fmt.Errorf("target %q: ordinal @%s must be 1 or more", text, digits) + } + t.Ordinal = n + s = strings.TrimSpace(s[:at]) + } + } + if s == "" { + return Target{}, fmt.Errorf("target %q is empty: address an activity by $variable, 'caption' or statement pattern", text) + } + t.Text = s + + toks, err := Tokenize(s) + if err != nil { + return Target{}, fmt.Errorf("target %q: %w", text, err) + } + switch { + case len(toks) == 1 && toks[0].Kind == TokVariable: + t.Kind = ByOutputVariable + t.Variable = strings.TrimPrefix(toks[0].Text, "$") + case len(toks) == 1 && toks[0].Kind == TokString: + t.Kind = ByCaption + t.Caption = toks[0].Text + default: + t.Kind = ByPattern + t.Pattern = toks + } + return t, nil +} + +func isDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return s != "" +} + +// insideString reports whether byte offset i of s falls inside a '…' literal. +func insideString(s string, i int) bool { + in := false + for j := 0; j < i; j++ { + if s[j] == '\'' { + in = !in + } + } + return in +} + +// Candidate is one addressable activity of a microflow. +type Candidate struct { + ID model.ID + Object microflows.MicroflowObject + // OutputVariable is the variable the activity outputs, without `$`, or "". + OutputVariable string + // Caption is the custom caption, or "" when the caption is generated. + Caption string + // Statement is the activity's statement as `describe` prints it, folded + // onto one line. Empty for an object describe prints as nothing (a void + // flow's end event). A handle built from the statement uses this form. + Statement string + // Alternates are other renderings of the same activity a pattern also + // matches. Describe does not always print the stored statement verbatim — + // an `if` whose then-branch is empty is printed with the condition negated + // and the branches swapped — so the stored form is kept here and both + // match: a reader may have either in front of them. + Alternates []string + + forms [][]Token // tokenised Statement and Alternates, built lazily +} + +// SetPrinted records the statement as describe actually printed it. When it +// differs from the rendering the candidate was built with, that rendering +// becomes an alternate rather than being lost. +func (c *Candidate) SetPrinted(printed string) { + printed = normalizeStatement(printed) + if printed == "" || printed == c.Statement { + return + } + if c.Statement != "" { + c.Alternates = append(c.Alternates, c.Statement) + } + c.Statement = printed + c.forms = nil +} + +// NewCandidate builds the Candidate for obj, taking its statement text from +// render. It returns false for objects that are not addressable activities +// (start events, merges, annotations). +func NewCandidate(obj microflows.MicroflowObject, render func(microflows.MicroflowObject) string) (Candidate, bool) { + c := Candidate{Object: obj} + switch o := obj.(type) { + case *microflows.ActionActivity: + c.OutputVariable = OutputVariable(o.Action) + if !o.AutoGenerateCaption { + c.Caption = o.Caption + } + case *microflows.ExclusiveSplit: + c.Caption = o.Caption + case *microflows.InheritanceSplit: + c.Caption = o.Caption + case *microflows.LoopedActivity, *microflows.EndEvent, *microflows.ErrorEvent, + *microflows.BreakEvent, *microflows.ContinueEvent: + default: + return Candidate{}, false + } + c.ID = obj.GetID() + if render != nil { + c.Statement = normalizeStatement(render(obj)) + } + return c, true +} + +// Collect returns a Candidate for every addressable activity in oc, loop +// bodies included (an address is unique across the whole microflow, not per +// scope), in storage order. Callers that show ordinals to a user reorder the +// result with OrderBy. +func Collect(oc *microflows.MicroflowObjectCollection, render func(microflows.MicroflowObject) string) []Candidate { + var out []Candidate + var walk func(*microflows.MicroflowObjectCollection) + walk = func(oc *microflows.MicroflowObjectCollection) { + if oc == nil { + return + } + for _, obj := range oc.Objects { + if obj == nil { + continue + } + if c, ok := NewCandidate(obj, render); ok { + out = append(out, c) + } + if loop, ok := obj.(*microflows.LoopedActivity); ok { + walk(loop.ObjectCollection) + } + } + } + walk(oc) + return out +} + +// OrderBy sorts cands by rank (ascending); candidates without a rank keep +// their relative order after all ranked ones. The executor ranks by the line +// `describe` prints each activity on, so ordinals count the way a reader does. +func OrderBy(cands []Candidate, rank map[model.ID]int) []Candidate { + out := make([]Candidate, 0, len(cands)) + var unranked []Candidate + for _, c := range cands { + if _, ok := rank[c.ID]; ok { + out = append(out, c) + } else { + unranked = append(unranked, c) + } + } + // Insertion sort keeps it stable without pulling in sort.SliceStable's + // closure for a list of a few dozen. + for i := 1; i < len(out); i++ { + for j := i; j > 0 && rank[out[j].ID] < rank[out[j-1].ID]; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return append(out, unranked...) +} + +// OutputVariable returns the name of the variable an action outputs, without +// `$`, or "" when it outputs none. A `declare` counts: the variable is what it +// produces, and `after $ValidFeedback` is how a reader would name it. It reads +// the variable where describe does, so every action printed as `$X = …` is +// addressed by `$X`; an action missing here is not found by its variable. +func OutputVariable(action microflows.MicroflowAction) string { + switch a := action.(type) { + case *microflows.CreateVariableAction: + return a.VariableName + case *microflows.CreateObjectAction: + return a.OutputVariable + case *microflows.RetrieveAction: + return a.OutputVariable + case *microflows.JavaActionCallAction: + if a.UseReturnVariable { + return a.ResultVariableName + } + case *microflows.MicroflowCallAction: + if a.UseReturnVariable { + return a.ResultVariableName + } + case *microflows.NanoflowCallAction: + if a.UseReturnVariable { + return a.OutputVariableName + } + case *microflows.JavaScriptActionCallAction: + if a.UseReturnVariable { + return a.OutputVariableName + } + case *microflows.AggregateListAction: + return a.OutputVariable + case *microflows.ListOperationAction: + return a.OutputVariable + case *microflows.RestCallAction: + if a.OutputVariable != "" { + return a.OutputVariable + } + return restResultVariable(a.ResultHandling) + case *microflows.ImportMappingCallAction: + return a.OutputVariable + case *microflows.ExportMappingCallAction: + return a.OutputVariable + case *microflows.CallExternalAction: + if a.UseReturnVariable { + return a.ResultVariableName + } + case *microflows.CreateListAction: + return a.OutputVariable + case *microflows.CastAction: + // Stored with or without the `$`; describe prints one either way. + return strings.TrimPrefix(a.OutputVariable, "$") + case *microflows.WebServiceCallAction: + return a.OutputVariable + case *microflows.RestOperationCallAction: + if a.OutputVariable != nil { + return a.OutputVariable.VariableName + } + case *microflows.ExecuteDatabaseQueryAction: + return a.OutputVariableName + case *microflows.ImportXmlAction: + if a.ResultHandling != nil { + return a.ResultHandling.ResultVariable + } + case *microflows.ExportXmlAction: + return a.OutputVariable + case *microflows.TransformJsonAction: + return a.OutputVariableName + case *microflows.WorkflowCallAction: + if a.UseReturnVariable { + return a.OutputVariableName + } + case *microflows.GetWorkflowDataAction: + return a.OutputVariableName + case *microflows.GetWorkflowsAction: + return a.OutputVariableName + case *microflows.GetWorkflowActivityRecordsAction: + return a.OutputVariableName + case *microflows.NotifyWorkflowAction: + return a.OutputVariableName + } + return "" +} + +// restResultVariable is where a REST call keeps its output variable when it is +// not on the action itself: on its result handling, as describe reads it. +func restResultVariable(rh microflows.ResultHandling) string { + switch h := rh.(type) { + case *microflows.ResultHandlingString: + return h.VariableName + case *microflows.ResultHandlingHttpResponse: + return h.VariableName + case *microflows.ResultHandlingMapping: + return h.ResultVariable + case *microflows.ResultHandlingFileDocument: + return h.VariableName + } + return "" +} + +// normalizeStatement folds a rendered statement onto one line: describe breaks +// long expressions across lines, and a handle has to fit in a comment. +// +// A rendering that is an MDL line comment — what describe prints for an +// activity it cannot render — is no statement: it would make a handle the +// lexer swallows, and a pattern that matches it matches nothing a reader can +// write. Such an activity is addressed by its variable or caption, or listed +// in an ambiguity error, never by pattern. +func normalizeStatement(s string) string { + s = strings.Join(strings.Fields(s), " ") + if strings.HasPrefix(s, "--") { + return "" + } + return strings.TrimSpace(strings.TrimSuffix(s, ";")) +} + +// statementForms returns the token lists a pattern is matched against: the +// printed statement, then each alternate. +func (c *Candidate) statementForms() [][]Token { + if c.forms == nil { + c.forms = [][]Token{} + for _, s := range append([]string{c.Statement}, c.Alternates...) { + if s == "" { + continue + } + // A rendering that does not tokenise can still be addressed by + // variable or caption; it simply never matches a pattern. + if toks, err := Tokenize(s); err == nil { + c.forms = append(c.forms, toks) + } + } + } + return c.forms +} + +// matchesPattern reports whether any form of c's statement matches pattern. +func (c *Candidate) matchesPattern(pattern []Token) bool { + for _, toks := range c.statementForms() { + if globTokens(pattern, toks) { + return true + } + } + return false +} + +// matches reports whether c is addressed by t, ignoring t's ordinal. +func (c *Candidate) matches(t Target) bool { + switch t.Kind { + case ByOutputVariable: + return c.OutputVariable != "" && c.OutputVariable == t.Variable + case ByCaption: + return c.Caption != "" && c.Caption == t.Caption + default: + return c.matchesPattern(t.Pattern) + } +} + +// describeCandidate is how a candidate is listed in an error. +func describeCandidate(c Candidate) string { + stmt := c.Statement + if stmt == "" { + stmt = "<" + strings.TrimPrefix(fmt.Sprintf("%T", c.Object), "*microflows.") + ">" + } + if c.Caption != "" { + stmt = fmt.Sprintf("%s (caption %s)", stmt, quote(c.Caption)) + } + if c.Object != nil { + p := c.Object.GetPosition() + stmt = fmt.Sprintf("%s at (%d, %d)", stmt, p.X, p.Y) + } + return stmt +} + +// quote renders s as an MDL string literal. +func quote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// isIdentRune reports whether r continues a bare word. +func isIdentRune(r rune) bool { + return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) +} diff --git a/mdl/backend/mfmutator/target_test.go b/mdl/backend/mfmutator/target_test.go new file mode 100644 index 000000000..b0b560452 --- /dev/null +++ b/mdl/backend/mfmutator/target_test.go @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "errors" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// The fixture mirrors FeedbackModule.VAL_Feedback's shape: one output +// variable, captioned splits, and a statement (`set $ValidFeedback = false`) +// that occurs three times, which is the case the resolver must refuse to guess. +func feedbackCandidates() []Candidate { + mk := func(id string, obj microflows.MicroflowObject, stmt string) Candidate { + c, ok := NewCandidate(obj, func(microflows.MicroflowObject) string { return stmt }) + if !ok { + panic("not addressable: " + id) + } + return c + } + act := func(id string, x int, action microflows.MicroflowAction) *microflows.ActionActivity { + a := &microflows.ActionActivity{Action: action} + a.ID = model.ID(id) + a.Position = model.Point{X: x, Y: 200} + a.AutoGenerateCaption = true + return a + } + split := func(id, caption string) *microflows.ExclusiveSplit { + s := &microflows.ExclusiveSplit{Caption: caption} + s.ID = model.ID(id) + return s + } + end := &microflows.EndEvent{ReturnValue: "$ValidFeedback"} + end.ID = "end" + + return []Candidate{ + mk("declare", act("declare", -390, &microflows.CreateVariableAction{VariableName: "ValidFeedback"}), "declare $ValidFeedback Boolean = true;"), + mk("s1", split("s1", "Subject not empty?"), "if trim($Feedback/Subject) != empty and\ntrim($Feedback/Subject) != '' then"), + mk("vf1", act("vf1", -40, &microflows.ValidationFeedbackAction{}), "validation feedback $Feedback/Subject message 'Subject is too long';"), + mk("set1", act("set1", 135, &microflows.ChangeVariableAction{}), "set $ValidFeedback = false;"), + mk("set2", act("set2", 420, &microflows.ChangeVariableAction{}), "set $ValidFeedback = false;"), + mk("java", act("java", 980, &microflows.JavaActionCallAction{UseReturnVariable: true, ResultVariableName: "IsValidEmail"}), + "$IsValidEmail = call java action FeedbackModule.ValidateEmail(EmailAddress = $Feedback/SubmitterEmail);"), + mk("s2", split("s2", "Email is Valid?"), "if not($IsValidEmail) then"), + mk("vf2", act("vf2", 1155, &microflows.ValidationFeedbackAction{}), "validation feedback $Feedback/SubmitterEmail message 'Email is not valid';"), + mk("set3", act("set3", 1305, &microflows.ChangeVariableAction{}), "set $ValidFeedback = false;"), + mk("vf3", act("vf3", 770, &microflows.ValidationFeedbackAction{}), "validation feedback $Feedback/SubmitterEmail message 'Email is required';"), + mk("end", end, "return $ValidFeedback;"), + } +} + +func TestResolve_EachAddressForm(t *testing.T) { + cands := feedbackCandidates() + cases := []struct { + target string + want model.ID + }{ + {"$IsValidEmail", "java"}, + {"$ValidFeedback", "declare"}, + {"'Email is Valid?'", "s2"}, + {"if not($IsValidEmail) then", "s2"}, + {"set $ValidFeedback = false @2", "set2"}, + {"set $ValidFeedback=false@3", "set3"}, + {"validation feedback $Feedback/SubmitterEmail * 'Email is required'", "vf3"}, + {"validation feedback * 'Subject is too long'", "vf1"}, + {"$IsValidEmail = call java action *", "java"}, + {"CALL JAVA ACTION feedbackmodule.validateemail * @1", ""}, // wildcard needed at the front + {"* call java action feedbackmodule.validateemail *", "java"}, + {"return $ValidFeedback;", "end"}, + {"if trim($Feedback/Subject) != empty and trim($Feedback/Subject) != '' then", "s1"}, + } + for _, tc := range cases { + got, err := ResolveText(cands, tc.target) + if tc.want == "" { + if err == nil { + t.Errorf("%q: resolved to %s, want an error", tc.target, got.ID) + } + continue + } + if err != nil { + t.Errorf("%q: %v", tc.target, err) + continue + } + if got.ID != tc.want { + t.Errorf("%q: resolved to %s, want %s", tc.target, got.ID, tc.want) + } + } +} + +// The resolver never guesses: three identical statements are an error that +// lists all three, each under the address that selects it. +func TestResolve_AmbiguityIsAnErrorListingOrdinals(t *testing.T) { + _, err := ResolveText(feedbackCandidates(), "set $ValidFeedback = false") + var amb *AmbiguousError + if !errors.As(err, &amb) { + t.Fatalf("want *AmbiguousError, got %v", err) + } + if len(amb.Matches) != 3 { + t.Fatalf("want 3 matches, got %d", len(amb.Matches)) + } + msg := err.Error() + for _, want := range []string{ + "matches 3 activities", + "set $ValidFeedback = false @1 -- set $ValidFeedback = false at (135, 200)", + "set $ValidFeedback = false @2 -- set $ValidFeedback = false at (420, 200)", + "set $ValidFeedback = false @3 -- set $ValidFeedback = false at (1305, 200)", + } { + if !strings.Contains(msg, want) { + t.Errorf("error lacks %q:\n%s", want, msg) + } + } +} + +func TestResolve_OrdinalPastLastMatch(t *testing.T) { + _, err := ResolveText(feedbackCandidates(), "set $ValidFeedback = false @4") + var amb *AmbiguousError + if !errors.As(err, &amb) || !strings.Contains(err.Error(), "there are only 3 matches") { + t.Fatalf("want an 'only 3 matches' error, got %v", err) + } +} + +// A lone $var is an output-variable address and nothing else: `set` assigns +// $ValidFeedback without outputting it, and a pattern search must not step in +// when the variable is not an output. +func TestResolve_FormsDoNotFallBack(t *testing.T) { + cands := feedbackCandidates() + _, err := ResolveText(cands, "$Feedback") + var nf *NotFoundError + if !errors.As(err, &nf) { + t.Fatalf("want *NotFoundError, got %v", err) + } + if !strings.Contains(err.Error(), "$IsValidEmail, $ValidFeedback") { + t.Errorf("hint should list the output variables: %v", err) + } + + _, err = ResolveText(cands, "'Email is valid?'") // case differs from the stored caption + if !errors.As(err, &nf) || !strings.Contains(err.Error(), "'Email is Valid?'") { + t.Errorf("caption is matched exactly and the hint lists the captions: %v", err) + } +} + +// A pattern is anchored at both ends; the error says so when only the tail +// is missing. +func TestResolve_PatternIsAnchored(t *testing.T) { + _, err := ResolveText(feedbackCandidates(), "if not($IsValidEmail)") + var nf *NotFoundError + if !errors.As(err, &nf) { + t.Fatalf("want *NotFoundError, got %v", err) + } + if !strings.Contains(err.Error(), "end it with *") { + t.Errorf("error should suggest a trailing *: %v", err) + } +} + +func TestParseTarget(t *testing.T) { + cases := []struct { + in string + kind Kind + ordinal int + }{ + {"$Lines", ByOutputVariable, 0}, + {"$Lines @2", ByOutputVariable, 2}, + {"'Email is valid?'", ByCaption, 0}, + {"'it''s @2'", ByCaption, 0}, // an @ inside a literal is not an ordinal + {"commit $Order", ByPattern, 0}, + {"log * node 'Debug' * @3", ByPattern, 3}, + } + for _, tc := range cases { + got, err := ParseTarget(tc.in) + if err != nil { + t.Errorf("%q: %v", tc.in, err) + continue + } + if got.Kind != tc.kind || got.Ordinal != tc.ordinal { + t.Errorf("%q: got kind %v ordinal %d, want %v %d", tc.in, got.Kind, got.Ordinal, tc.kind, tc.ordinal) + } + } + if got, _ := ParseTarget("'it''s @2'"); got.Caption != "it's @2" { + t.Errorf("caption unescaping: got %q", got.Caption) + } + for _, bad := range []string{"", " ", "@2", "$X @0", "'unterminated"} { + if _, err := ParseTarget(bad); err == nil { + t.Errorf("%q: want an error", bad) + } + } +} + +// Every handle must resolve back to the activity it was printed for, and must +// prefer output variable, then caption, then statement. +func TestHandle_RoundTripsAndPrefersNamedForms(t *testing.T) { + cands := feedbackCandidates() + want := map[model.ID]string{ + "declare": "$ValidFeedback", + "s1": "'Subject not empty?'", + "java": "$IsValidEmail", + "s2": "'Email is Valid?'", + "set1": "set $ValidFeedback = false @1", + "set2": "set $ValidFeedback = false @2", + "set3": "set $ValidFeedback = false @3", + "vf3": "validation feedback $Feedback/SubmitterEmail message 'Email is required'", + "end": "return $ValidFeedback", + } + for i, c := range cands { + h := Handle(cands, i) + if w, ok := want[c.ID]; ok && h != w { + t.Errorf("%s: handle %q, want %q", c.ID, h, w) + } + got, err := ResolveText(cands, h) + if err != nil { + t.Errorf("%s: handle %q does not resolve: %v", c.ID, h, err) + continue + } + if got.ID != c.ID { + t.Errorf("%s: handle %q resolves to %s", c.ID, h, got.ID) + } + } +} + +// A statement containing a multiplication reads back as a wildcard pattern, +// so it can only be its own handle when that pattern still selects it alone. +func TestHandle_MultiplicationIsCheckedNotAssumed(t *testing.T) { + a := &microflows.ActionActivity{Action: &microflows.ChangeVariableAction{}} + a.ID = "a" + b := &microflows.ActionActivity{Action: &microflows.ChangeVariableAction{}} + b.ID = "b" + ca, _ := NewCandidate(a, func(microflows.MicroflowObject) string { return "set $X = $Y * 2;" }) + cb, _ := NewCandidate(b, func(microflows.MicroflowObject) string { return "set $X = $Y + $Z * 2;" }) + cands := []Candidate{ca, cb} + for i := range cands { + h := Handle(cands, i) + got, err := ResolveText(cands, h) + if err != nil || got.ID != cands[i].ID { + t.Errorf("handle %q for %s resolved to %v, %v", h, cands[i].ID, got.ID, err) + } + } +} + +func TestCollect_IncludesLoopBodiesAndSkipsStructure(t *testing.T) { + start := &microflows.StartEvent{} + start.ID = "start" + merge := &microflows.ExclusiveMerge{} + merge.ID = "merge" + inner := &microflows.ActionActivity{Action: &microflows.CommitObjectsAction{}} + inner.ID = "inner" + loop := &microflows.LoopedActivity{ObjectCollection: &microflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{inner}, + }} + loop.ID = "loop" + oc := &microflows.MicroflowObjectCollection{Objects: []microflows.MicroflowObject{start, loop, merge}} + + got := Collect(oc, func(o microflows.MicroflowObject) string { return string(o.GetID()) }) + var ids []string + for _, c := range got { + ids = append(ids, string(c.ID)) + } + if strings.Join(ids, ",") != "loop,inner" { + t.Errorf("got %v, want [loop inner]", ids) + } + + ordered := OrderBy(got, map[model.ID]int{"inner": 1}) + if ordered[0].ID != "inner" || ordered[1].ID != "loop" { + t.Errorf("OrderBy: ranked first, unranked after; got %s, %s", ordered[0].ID, ordered[1].ID) + } +} + +// Every action describe prints as `$X = …` outputs $X, so `$X` must address +// it. The list is enumerated per action type (the field is named differently +// on each), and it had drifted: a REST call whose result is on its result +// handling, a cast, a create list and a web service call all printed `$X = …` +// and were not found by `$X`. +func TestOutputVariable_EveryActionDescribePrintsAnAssignmentFor(t *testing.T) { + cases := []struct { + name string + action microflows.MicroflowAction + want string + }{ + {"create list", &microflows.CreateListAction{OutputVariable: "Lines"}, "Lines"}, + {"cast", &microflows.CastAction{ObjectVariable: "Obj", OutputVariable: "Specific"}, "Specific"}, + {"cast stored with $", &microflows.CastAction{OutputVariable: "$Specific"}, "Specific"}, + {"rest call on the action", &microflows.RestCallAction{OutputVariable: "Resp"}, "Resp"}, + {"rest call, mapping result", &microflows.RestCallAction{ResultHandling: &microflows.ResultHandlingMapping{ResultVariable: "Response"}}, "Response"}, + {"rest call, string result", &microflows.RestCallAction{ResultHandling: &microflows.ResultHandlingString{VariableName: "Body"}}, "Body"}, + {"rest call, http response", &microflows.RestCallAction{ResultHandling: &microflows.ResultHandlingHttpResponse{VariableName: "Http"}}, "Http"}, + {"rest call, file document", &microflows.RestCallAction{ResultHandling: &microflows.ResultHandlingFileDocument{VariableName: "File"}}, "File"}, + {"rest operation call", &microflows.RestOperationCallAction{OutputVariable: &microflows.RestOutputVar{VariableName: "Op"}}, "Op"}, + {"web service call", &microflows.WebServiceCallAction{OutputVariable: "Ws"}, "Ws"}, + {"database query", &microflows.ExecuteDatabaseQueryAction{OutputVariableName: "Rows"}, "Rows"}, + {"import xml", &microflows.ImportXmlAction{ResultHandling: &microflows.ResultHandlingMapping{ResultVariable: "Imported"}}, "Imported"}, + {"export xml", &microflows.ExportXmlAction{OutputVariable: "Xml"}, "Xml"}, + {"transform json", &microflows.TransformJsonAction{OutputVariableName: "Json"}, "Json"}, + {"call workflow", &microflows.WorkflowCallAction{UseReturnVariable: true, OutputVariableName: "Wf"}, "Wf"}, + {"call workflow, no return", &microflows.WorkflowCallAction{OutputVariableName: "Wf"}, ""}, + {"get workflow data", &microflows.GetWorkflowDataAction{OutputVariableName: "Ctx"}, "Ctx"}, + {"get workflows", &microflows.GetWorkflowsAction{OutputVariableName: "Wfs"}, "Wfs"}, + {"get activity records", &microflows.GetWorkflowActivityRecordsAction{OutputVariableName: "Recs"}, "Recs"}, + {"notify workflow", &microflows.NotifyWorkflowAction{OutputVariableName: "Ok"}, "Ok"}, + {"set is not an output", &microflows.ChangeVariableAction{VariableName: "N"}, ""}, + } + for _, tc := range cases { + if got := OutputVariable(tc.action); got != tc.want { + t.Errorf("%s: OutputVariable = %q, want %q", tc.name, got, tc.want) + } + } +} + +// Describe prints an activity it cannot render as an MDL line comment. That +// comment is not a statement: as a handle it would be swallowed by the lexer +// in the `alter microflow` it is pasted into, and `*` must not match it. +func TestCandidate_CommentRenderingIsNoStatement(t *testing.T) { + a := &microflows.ActionActivity{Action: &microflows.UnknownAction{TypeName: "Foo"}} + a.ID = "unknown" + a.AutoGenerateCaption = true + c, ok := NewCandidate(a, func(microflows.MicroflowObject) string { return "-- Unsupported action type: Foo" }) + if !ok { + t.Fatal("an action activity is addressable") + } + c.SetPrinted("-- Unsupported action type: Foo") + if c.Statement != "" || len(c.Alternates) != 0 { + t.Errorf("statement %q, alternates %q: want none", c.Statement, c.Alternates) + } + cands := []Candidate{c} + if h := Handle(cands, 0); h != "" { + t.Errorf("handle %q, want none", h) + } + if _, err := ResolveText(cands, "*"); err == nil { + t.Error("* matched a comment") + } +} diff --git a/mdl/backend/mfmutator/tokens.go b/mdl/backend/mfmutator/tokens.go new file mode 100644 index 000000000..3bd888ff6 --- /dev/null +++ b/mdl/backend/mfmutator/tokens.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +// TokenKind classifies a Token. +type TokenKind int + +const ( + // TokWord is a bare or "quoted" identifier, keyword or number. Compared + // case-insensitively, as MDL keywords are. + TokWord TokenKind = iota + // TokVariable is `$Name`. Compared exactly. + TokVariable + // TokString is a '…' literal; Text holds the unescaped content. Compared + // exactly: a caption or log message differing in case is a different one. + TokString + // TokStar is `*`: a wildcard in a pattern, multiplication in a statement. + TokStar + // TokPunct is any other single character. + TokPunct +) + +// Token is one lexical unit of a statement or pattern. +type Token struct { + Kind TokenKind + Text string +} + +// Tokenize splits MDL statement text into Tokens. It is deliberately coarser +// than the MDL lexer — multi-character operators come out as single +// characters — because it only has to agree with itself: a pattern and a +// statement are tokenised the same way, so `!=` in one matches `!=` in the +// other whatever the spacing. +func Tokenize(s string) ([]Token, error) { + var out []Token + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + switch { + case unicode.IsSpace(r): + i += size + case r == '\'': + text, n, err := scanQuoted(s[i:], '\'') + if err != nil { + return nil, err + } + out = append(out, Token{Kind: TokString, Text: text}) + i += n + case r == '"': + text, n, err := scanQuoted(s[i:], '"') + if err != nil { + return nil, err + } + out = append(out, Token{Kind: TokWord, Text: text}) + i += n + case r == '$': + j := i + size + for j < len(s) { + r2, sz := utf8.DecodeRuneInString(s[j:]) + if !isIdentRune(r2) { + break + } + j += sz + } + if j == i+size { + out = append(out, Token{Kind: TokPunct, Text: "$"}) + } else { + out = append(out, Token{Kind: TokVariable, Text: s[i:j]}) + } + i = j + case isIdentRune(r): + j := i + for j < len(s) { + r2, sz := utf8.DecodeRuneInString(s[j:]) + if !isIdentRune(r2) { + break + } + j += sz + } + out = append(out, Token{Kind: TokWord, Text: s[i:j]}) + i = j + case r == '*': + out = append(out, Token{Kind: TokStar, Text: "*"}) + i += size + default: + out = append(out, Token{Kind: TokPunct, Text: string(r)}) + i += size + } + } + return out, nil +} + +// scanQuoted reads a literal delimited by q, where a doubled q is an escaped +// one (MDL's only escape for both string literals and quoted identifiers). It +// returns the unescaped content and the number of bytes consumed. +func scanQuoted(s string, q byte) (string, int, error) { + var b strings.Builder + for i := 1; i < len(s); i++ { + if s[i] != q { + b.WriteByte(s[i]) + continue + } + if i+1 < len(s) && s[i+1] == q { + b.WriteByte(q) + i++ + continue + } + return b.String(), i + 1, nil + } + return "", 0, fmt.Errorf("unterminated %c literal in %q", q, s) +} + +// sameToken reports whether a pattern token matches a statement token. +func sameToken(p, s Token) bool { + if p.Kind != s.Kind { + return false + } + if p.Kind == TokWord { + return strings.EqualFold(p.Text, s.Text) + } + return p.Text == s.Text +} + +// globTokens reports whether pattern matches the whole of stmt, a pattern +// TokStar matching any run of statement tokens (including none). +// +// The match is anchored at both ends. `commit $Order` therefore does not match +// `commit $Order with events` — write `commit $Order *` — so that adding a +// word to a pattern can only ever narrow what it matches. +func globTokens(pattern, stmt []Token) bool { + p, s := 0, 0 + star, mark := -1, 0 + for s < len(stmt) { + switch { + case p < len(pattern) && pattern[p].Kind == TokStar: + star, mark = p, s + p++ + case p < len(pattern) && sameToken(pattern[p], stmt[s]): + p++ + s++ + case star >= 0: + p = star + 1 + mark++ + s = mark + default: + return false + } + } + for p < len(pattern) && pattern[p].Kind == TokStar { + p++ + } + return p == len(pattern) +} diff --git a/mdl/executor/cmd_microflows_handles.go b/mdl/executor/cmd_microflows_handles.go new file mode 100644 index 000000000..2172169fa --- /dev/null +++ b/mdl/executor/cmd_microflows_handles.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// microflowTargets returns the content-addressable activities of a stored +// microflow, in the order `describe microflow` prints them, which is the order +// `@n` ordinals count in. The statement each is matched against is the one +// formatActivity renders for describe, so what a reader sees is what an +// `alter microflow` target matches. +// +// It also returns the describe body it rendered to establish that order and +// the body line each activity starts on (annotations included), so describe +// … with handles need not render twice. +func microflowTargets( + ctx *ExecContext, + mf *microflows.Microflow, + entityNames map[model.ID]string, + microflowNames map[model.ID]string, +) (cands []mfmutator.Candidate, warnings, body []string, startLine map[model.ID]int) { + if mf == nil || mf.ObjectCollection == nil { + return nil, nil, nil, nil + } + // formatActivity renders an end event differently in a flow with a return + // value; set the flag the way describe does, whoever the caller is. + prev := ctx.DescribingMicroflowHasReturnValue + ctx.DescribingMicroflowHasReturnValue = microflowHasReturnValue(mf) + defer func() { ctx.DescribingMicroflowHasReturnValue = prev }() + + sourceMap := map[string]elkSourceRange{} + warnings, body = formatMicroflowBodyWithSourceMap(ctx, mf, entityNames, microflowNames, sourceMap, 0) + + startLine = make(map[model.ID]int, len(sourceMap)) + ranges := make(map[model.ID]elkSourceRange, len(sourceMap)) + for key, r := range sourceMap { + if id, ok := strings.CutPrefix(key, "node-"); ok { + startLine[model.ID(id)] = r.StartLine + ranges[model.ID(id)] = r + } + } + + render := func(obj microflows.MicroflowObject) string { + return formatActivity(ctx, obj, entityNames, microflowNames) + } + cands = mfmutator.OrderBy(mfmutator.Collect(mf.ObjectCollection, render), startLine) + for i := range cands { + if r, ok := ranges[cands[i].ID]; ok { + cands[i].SetPrinted(printedStatement(cands[i].Object, body, r)) + } + } + return cands, warnings, body, startLine +} + +// printedStatement extracts, from the describe lines an activity occupies, +// the statement itself: the annotation and comment lines before it are +// skipped, and the statement runs to the line that ends it, continuation +// lines included — describe breaks a long condition without indenting the +// rest. An `if` ends at `then`; an action at `;` (or the `{` of an error +// handler block); a loop, an enumeration `case` and a `split type` open a +// block on the next line, so they are their first line. +// +// This is what the reader sees, which is not always what formatActivity +// returns: an if with an empty then-branch is printed negated, with its +// branches swapped. Returns "" when no terminated statement is found. +func printedStatement(obj microflows.MicroflowObject, body []string, r elkSourceRange) string { + var parts []string + for i := r.StartLine; i <= r.EndLine && i < len(body); i++ { + line := strings.TrimSpace(body[i]) + if len(parts) == 0 && (line == "" || strings.HasPrefix(line, "@") || strings.HasPrefix(line, "--")) { + continue + } + parts = append(parts, line) + switch obj.(type) { + case *microflows.LoopedActivity, *microflows.InheritanceSplit: + return line + case *microflows.ExclusiveSplit: + if len(parts) == 1 && strings.HasPrefix(line, "case ") { + return line + } + if strings.HasSuffix(line, " then") { + return strings.Join(parts, " ") + } + default: + if strings.HasSuffix(line, ";") || strings.HasSuffix(line, "{") { + return strings.Join(parts, " ") + } + } + if len(parts) >= 50 { + break + } + } + return "" +} + +// formatMicroflowActivitiesWithHandles is formatMicroflowActivities with a +// `-- handle: <target>` comment above each activity: the address that selects +// it in an `alter microflow` target (ADR-0012, decision 2). The handles are +// comments, so the output is the plain description plus those lines and +// executes the same. +// +// An activity inside an error handler block — commented-out ones included — +// gets its handle like any other: the handler bodies are in the source map, +// so they are ranked where they are printed. An activity describe prints as +// nothing (a void end event) or only as a comment gets no handle line; it is +// still listed, with its ordinal, by an ambiguity error. +func formatMicroflowActivitiesWithHandles( + ctx *ExecContext, + mf *microflows.Microflow, + entityNames map[model.ID]string, + microflowNames map[model.ID]string, +) []string { + cands, warnings, body, startLine := microflowTargets(ctx, mf, entityNames, microflowNames) + + handlesAt := map[int][]string{} + for i, c := range cands { + line, ok := startLine[c.ID] + if !ok || line < 0 || line >= len(body) { + continue + } + if h := mfmutator.Handle(cands, i); h != "" { + handlesAt[line] = append(handlesAt[line], h) + } + } + + out := make([]string, 0, len(warnings)+len(body)+len(handlesAt)) + out = append(out, warnings...) + for i, line := range body { + indent := line[:len(line)-len(strings.TrimLeft(line, " "))] + for _, h := range handlesAt[i] { + out = append(out, indent+"-- handle: "+h) + } + out = append(out, line) + } + return out +} diff --git a/mdl/executor/cmd_microflows_handles_pedapp_test.go b/mdl/executor/cmd_microflows_handles_pedapp_test.go new file mode 100644 index 000000000..ebce0e2c3 --- /dev/null +++ b/mdl/executor/cmd_microflows_handles_pedapp_test.go @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// pedAppEnv names a pristine, Studio Pro-authored PedApp.mpr. The target +// resolver has to be proven on a flow Studio Pro drew: its object order, +// merges and captions are what an mxcli-authored flow cannot reproduce. +// Until the fixture is committed (ako/mxcli#703) the test runs only when this +// points at a local copy; the project is copied before it is opened. +const pedAppEnv = "MXCLI_PEDAPP_MPR" + +func openPedApp(t *testing.T) (*Executor, *bytes.Buffer) { + t.Helper() + src := os.Getenv(pedAppEnv) + if src == "" { + t.Skipf("set %s to a pristine PedApp.mpr to run the Studio Pro-authored resolver test (fixture pending ako/mxcli#703)", pedAppEnv) + } + dir := t.TempDir() + dst := filepath.Join(dir, filepath.Base(src)) + if err := copyPedAppFile(src, dst); err != nil { + t.Fatalf("copy %s: %v", src, err) + } + if contents := filepath.Join(filepath.Dir(src), "mprcontents"); dirExists(contents) { + if err := copyPedAppTree(contents, filepath.Join(dir, "mprcontents")); err != nil { + t.Fatalf("copy mprcontents: %v", err) + } + } + + out := &bytes.Buffer{} + exec := New(out) + exec.SetBackendFactory(func() backend.FullBackend { return modelsdkbackend.New() }) + if err := exec.Execute(&ast.ConnectStmt{Path: dst}); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = exec.Execute(&ast.DisconnectStmt{}) }) + return exec, out +} + +func dirExists(p string) bool { + st, err := os.Stat(p) + return err == nil && st.IsDir() +} + +func copyPedAppFile(src, dst string) error { + b, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, b, 0o644) +} + +func copyPedAppTree(src, dst string) error { + return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, p) + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyPedAppFile(p, target) + }) +} + +// valFeedbackTargets returns the resolver candidates of the stored +// FeedbackModule.VAL_Feedback, in describe order. +func valFeedbackTargets(t *testing.T, exec *Executor) []mfmutator.Candidate { + 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) + } + microflowNames := getMicroflowNames(ctx, h) + var mf *microflows.Microflow + for _, m := range all { + microflowNames[m.ID] = h.GetQualifiedName(m.ContainerID, m.Name) + if m.Name == "VAL_Feedback" && h.GetModuleName(h.FindModuleID(m.ContainerID)) == "FeedbackModule" { + mf = m + } + } + if mf == nil { + t.Fatal("FeedbackModule.VAL_Feedback not found: is this PedApp?") + } + cands, _, _, _ := microflowTargets(ctx, mf, getEntityNames(ctx, h), microflowNames) + return cands +} + +func pos(c mfmutator.Candidate) string { + p := c.Object.GetPosition() + return fmt.Sprintf("(%d, %d)", p.X, p.Y) +} + +// Each addressing form, on the flow Studio Pro drew. Positions identify the +// activities: they are Studio Pro's, and describe prints them as @position. +func TestMicroflowTargets_PedAppValFeedback(t *testing.T) { + exec, _ := openPedApp(t) + cands := valFeedbackTargets(t, exec) + + resolves := []struct { + target, at string + kind any + }{ + {"$IsValidEmail", "(980, 200)", &microflows.ActionActivity{}}, + {"$ValidFeedback", "(-390, 200)", &microflows.ActionActivity{}}, + {"'Email is Valid?'", "(1155, 200)", &microflows.ExclusiveSplit{}}, + {"'Subject not empty?'", "(-215, 200)", &microflows.ExclusiveSplit{}}, + {"if not($IsValidEmail) then", "(1155, 200)", &microflows.ExclusiveSplit{}}, // as describe prints it + {"if $IsValidEmail then", "(1155, 200)", &microflows.ExclusiveSplit{}}, // as it is stored + {"$IsValidEmail = call java action FeedbackModule.ValidateEmail *", "(980, 200)", &microflows.ActionActivity{}}, + {"validation feedback $Feedback/SubmitterEmail * 'Email is required'", "(770, 325)", &microflows.ActionActivity{}}, + {"validation feedback $Feedback/SubmitterEmail * @2", "(770, 325)", &microflows.ActionActivity{}}, + {"set $ValidFeedback = false @3", "(1305, 460)", &microflows.ActionActivity{}}, + {"return $ValidFeedback", "(1640, 200)", &microflows.EndEvent{}}, + } + for _, tc := range resolves { + c, err := mfmutator.ResolveText(cands, tc.target) + if err != nil { + t.Errorf("%s: %v", tc.target, err) + continue + } + if pos(c) != tc.at || fmt.Sprintf("%T", c.Object) != fmt.Sprintf("%T", tc.kind) { + t.Errorf("%s: resolved to %T at %s, want %T at %s", tc.target, c.Object, pos(c), tc.kind, tc.at) + } + } + + // `set $ValidFeedback = false` occurs three times. The resolver refuses, + // and lists the three in describe order with the ordinal for each. + _, err := mfmutator.ResolveText(cands, "set $ValidFeedback = false") + var amb *mfmutator.AmbiguousError + if !errors.As(err, &amb) { + t.Fatalf("set $ValidFeedback = false: want an ambiguity error, got %v", err) + } + var at []string + for _, m := range amb.Matches { + at = append(at, pos(m)) + } + if got, want := strings.Join(at, " "), "(135, 460) (420, 460) (1305, 460)"; got != want { + t.Errorf("ambiguous matches at %s, want %s", got, want) + } + for i := 1; i <= 3; i++ { + if !strings.Contains(err.Error(), fmt.Sprintf("set $ValidFeedback = false @%d", i)) { + t.Errorf("error does not offer @%d:\n%v", i, err) + } + } + + // A $variable that no activity outputs is not found, even though `set` + // assigns one of that name; the forms do not fall back on each other. + if _, err := mfmutator.ResolveText(cands, "$Feedback"); err == nil { + t.Error("$Feedback is a parameter, not an activity output: want an error") + } +} + +var positionLine = regexp.MustCompile(`^\s*@position\((-?\d+), (-?\d+)\)`) + +// Every handle `describe … with handles` prints resolves to the activity it is +// printed above, and removing the handles leaves the plain description. +func TestDescribeWithHandles_PedAppValFeedback(t *testing.T) { + exec, out := openPedApp(t) + cands := valFeedbackTargets(t, exec) + + describe := func(src string) string { + out.Reset() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse %q: %v", src, errs[0]) + } + for _, s := range prog.Statements { + if err := exec.Execute(s); err != nil { + t.Fatalf("%s: %v", src, err) + } + } + return out.String() + } + withHandles := describe("describe microflow FeedbackModule.VAL_Feedback with handles;") + plain := describe("describe microflow FeedbackModule.VAL_Feedback;") + + lines := strings.Split(withHandles, "\n") + handles := 0 + var kept []string + for i, line := range lines { + h, ok := strings.CutPrefix(strings.TrimSpace(line), "-- handle: ") + if !ok { + kept = append(kept, line) + continue + } + handles++ + c, err := mfmutator.ResolveText(cands, h) + if err != nil { + t.Errorf("handle %q: %v", h, err) + continue + } + m := positionLine.FindStringSubmatch(lines[i+1]) + if m == nil { + t.Errorf("handle %q is not followed by the activity's @position:\n%s", h, lines[i+1]) + continue + } + if want := "(" + m[1] + ", " + m[2] + ")"; pos(c) != want { + t.Errorf("handle %q printed above the activity at %s resolves to the one at %s", h, want, pos(c)) + } + } + // 16 activities, the void-less end event included; merges and the start + // event are not addressable. + if handles != len(cands) || handles != 16 { + t.Errorf("printed %d handles for %d addressable activities, want 16", handles, len(cands)) + } + if strings.Join(kept, "\n") != plain { + t.Errorf("with handles minus the handle lines differs from plain describe") + } +} + +// Across every PedApp microflow — SUB_Feedback_PostToAppInsights has a custom +// error handler with a body — each activity describe prints is ranked where it +// is printed, and its handle resolves back to it. A handler body used to be +// unranked: no handle, and ordinals that counted it after the main flow. +func TestMicroflowTargets_PedAppEveryFlowRanksAndResolves(t *testing.T) { + exec, _ := openPedApp(t) + 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) + } + microflowNames := getMicroflowNames(ctx, h) + for _, m := range all { + microflowNames[m.ID] = h.GetQualifiedName(m.ContainerID, m.Name) + } + handlerBodies := 0 + for _, mf := range all { + name := microflowNames[mf.ID] + cands, _, body, startLine := microflowTargets(ctx, mf, getEntityNames(ctx, h), microflowNames) + prev := -1 + for i, c := range cands { + line, ranked := startLine[c.ID] + if !ranked { + if c.Statement != "" { + t.Errorf("%s: %q at %s is printed but not ranked", name, c.Statement, pos(c)) + } + continue + } + if line < prev { + t.Errorf("%s: %q at %s is ranked before the activity printed above it", name, c.Statement, pos(c)) + } + prev = line + if strings.HasPrefix(strings.TrimSpace(body[line]), "log error node") && strings.Contains(name, "PostToAppInsights") { + handlerBodies++ + } + handle := mfmutator.Handle(cands, i) + if handle == "" { + continue + } + got, err := mfmutator.ResolveText(cands, handle) + if err != nil || got.ID != c.ID { + t.Errorf("%s: handle %q of the activity at %s resolves to %v (%v)", name, handle, pos(c), got.ID, err) + } + } + } + if handlerBodies != 1 { + t.Errorf("want the log in SUB_Feedback_PostToAppInsights' error handler ranked, found %d", handlerBodies) + } +} diff --git a/mdl/executor/cmd_microflows_handles_test.go b/mdl/executor/cmd_microflows_handles_test.go new file mode 100644 index 000000000..056cdd56e --- /dev/null +++ b/mdl/executor/cmd_microflows_handles_test.go @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "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/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// handlesFixture is a flow with a duplicated output variable (so describe +// prepends a warning above the body) and a statement that occurs twice (so two +// handles need an ordinal). +func handlesFixture() *microflows.Microflow { + act := func(id string, x int, a microflows.MicroflowAction) *microflows.ActionActivity { + return &microflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(id)}, + Position: model.Point{X: x, Y: 100}, + }, + AutoGenerateCaption: true, + }, + Action: a, + } + } + split := &microflows.ExclusiveSplit{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "split"}, + Position: model.Point{X: 400, Y: 100}, + }, + Caption: "Enough?", + SplitCondition: &microflows.ExpressionSplitCondition{Expression: "$N > 1"}, + } + oc := &microflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + &microflows.StartEvent{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "start"}, Position: model.Point{X: 0, Y: 100}}}, + act("item1", 100, &microflows.CreateObjectAction{OutputVariable: "Item", EntityQualifiedName: "Synthetic.Item"}), + act("item2", 200, &microflows.CreateObjectAction{OutputVariable: "Item", EntityQualifiedName: "Synthetic.Item"}), + act("declare", 250, &microflows.CreateVariableAction{VariableName: "N", DataType: &microflows.IntegerType{}, InitialValue: "0"}), + act("set1", 300, &microflows.ChangeVariableAction{VariableName: "N", Value: "$N + 1"}), + split, + act("set2", 500, &microflows.ChangeVariableAction{VariableName: "N", Value: "$N + 1"}), + &microflows.ExclusiveMerge{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "merge"}, Position: model.Point{X: 600, Y: 100}}}, + &microflows.EndEvent{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "end"}, Position: model.Point{X: 700, Y: 100}}}, + }, + Flows: []*microflows.SequenceFlow{ + {OriginID: "start", DestinationID: "item1"}, + {OriginID: "item1", DestinationID: "item2"}, + {OriginID: "item2", DestinationID: "declare"}, + {OriginID: "declare", DestinationID: "set1"}, + {OriginID: "set1", DestinationID: "split"}, + {OriginID: "split", DestinationID: "set2", CaseValue: &microflows.ExpressionCase{Expression: "true"}}, + {OriginID: "split", DestinationID: "merge", CaseValue: &microflows.ExpressionCase{Expression: "false"}, OriginConnectionIndex: 2}, + {OriginID: "set2", DestinationID: "merge"}, + {OriginID: "merge", DestinationID: "end"}, + }, + } + return &microflows.Microflow{ObjectCollection: oc} +} + +// Each handle line sits directly above the activity it names — the warning +// describe prepends must not shift the handles off their statements — and +// resolves back to that activity. +func TestFormatMicroflowActivitiesWithHandles_AboveEachActivity(t *testing.T) { + ctx := &ExecContext{} + mf := handlesFixture() + lines := formatMicroflowActivitiesWithHandles(ctx, mf, nil, nil) + got := strings.Join(lines, "\n") + if !strings.Contains(got, "-- WARNING: duplicate output variable $Item") { + t.Fatalf("fixture should trigger the duplicate-output warning:\n%s", got) + } + + cands, _, _, _ := microflowTargets(ctx, mf, nil, nil) + wantAbove := map[string]string{ + "$Item @1": "(100, 100)", + "$Item @2": "(200, 100)", + "$N": "(250, 100)", + "set $N = $N + 1 @1": "(300, 100)", + "'Enough?'": "(400, 100)", + "set $N = $N + 1 @2": "(500, 100)", + } + seen := map[string]bool{} + for i, line := range lines { + h, ok := strings.CutPrefix(strings.TrimSpace(line), "-- handle: ") + if !ok { + continue + } + seen[h] = true + if i+1 >= len(lines) || !strings.Contains(lines[i+1], "@position"+wantAbove[h]) { + t.Errorf("handle %q is not directly above the activity at %s:\n%s", h, wantAbove[h], got) + } + c, err := mfmutator.ResolveText(cands, h) + if err != nil { + t.Errorf("handle %q does not resolve: %v", h, err) + continue + } + p := c.Object.GetPosition() + if want := wantAbove[h]; want != "" && want != fmt.Sprintf("(%d, %d)", p.X, p.Y) { + t.Errorf("handle %q resolves to the activity at (%d, %d), want %s", h, p.X, p.Y, want) + } + } + for h := range wantAbove { + if !seen[h] { + t.Errorf("missing handle %q:\n%s", h, got) + } + } + + // Control: take the handle lines away and what remains is exactly the + // plain description, so `with handles` adds comments and changes nothing. + plain := formatMicroflowActivities(ctx, handlesFixture(), nil, nil) + var stripped []string + for _, line := range lines { + if !strings.HasPrefix(strings.TrimSpace(line), "-- handle: ") { + stripped = append(stripped, line) + } + } + if strings.Join(stripped, "\n") != strings.Join(plain, "\n") { + t.Errorf("with handles minus the handle lines differs from plain describe:\n--- with handles\n%s\n--- plain\n%s", + strings.Join(stripped, "\n"), strings.Join(plain, "\n")) + } +} + +// The statement parses end to end: grammar, visitor and executor. +func TestDescribeMicroflowWithHandles_Statement(t *testing.T) { + prog, errs := visitor.Build("describe microflow MyModule.ACT_Count with handles;") + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + stmt, ok := prog.Statements[0].(*ast.DescribeStmt) + if !ok || !stmt.WithHandles || stmt.Normalized { + t.Fatalf("want a DescribeStmt with WithHandles, got %#v", prog.Statements[0]) + } + + mod := mkModule("MyModule") + mf := handlesFixture() + mf.BaseElement = model.BaseElement{ID: "mf"} + mf.ContainerID = mod.ID + mf.Name = "ACT_Count" + h := mkHierarchy(mod) + withContainer(h, mf.ContainerID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return []*microflows.Microflow{mf}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return nil, nil }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + } + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, describeMicroflowMode(ctx, stmt.Name, describeMicroflowOptions{Handles: true})) + assertContainsStr(t, buf.String(), " -- handle: 'Enough?'\n") + + // normalized shows a graph other than the stored one; its handles would + // address nothing, so the combination is refused. + if err := describeMicroflowMode(ctx, stmt.Name, describeMicroflowOptions{Handles: true, Normalized: true}); err == nil { + t.Error("normalized with handles: want an error") + } +} + +// Describe prints an if whose then-branch is empty with its condition negated +// and the branches swapped. A target written from that output must still find +// the split, and so must one written from the stored condition. +func TestMicroflowTargets_NegatedIfMatchesBothForms(t *testing.T) { + mf := handlesFixture() + // Swap the branches: true now goes straight to the merge. + for _, f := range mf.ObjectCollection.Flows { + if f.OriginID == "split" { + if f.DestinationID == "set2" { + f.DestinationID = "merge" + } else { + f.DestinationID = "set2" + } + } + } + ctx := &ExecContext{} + got := strings.Join(formatMicroflowActivities(ctx, mf, nil, nil), "\n") + if !strings.Contains(got, "if not($N > 1) then") { + t.Fatalf("fixture should describe the split negated:\n%s", got) + } + cands, _, _, _ := microflowTargets(ctx, mf, nil, nil) + for _, target := range []string{"if not($N > 1) then", "if $N > 1 then", "if * then"} { + c, err := mfmutator.ResolveText(cands, target) + if err != nil { + t.Errorf("%s: %v", target, err) + continue + } + if c.ID != "split" { + t.Errorf("%s: resolved to %s, want split", target, c.ID) + } + } + // The printed form is the one a handle shows. + for i, c := range cands { + if c.ID == "split" { + c.Caption = "" + cands[i] = c + if h := mfmutator.Handle(cands, i); h != "if not($N > 1) then" { + t.Errorf("handle %q, want the printed form", h) + } + } + } +} + +// errorHandlerFixture is a flow whose custom error handler holds a statement +// that also occurs after the handled activity. Describe prints the handler's +// copy first, inside the `on error { … }` block. +func errorHandlerFixture() *microflows.Microflow { + act := func(id string, x, y int, a microflows.MicroflowAction) *microflows.ActionActivity { + return &microflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(id)}, + Position: model.Point{X: x, Y: y}, + }, + AutoGenerateCaption: true, + }, + Action: a, + } + } + end := func(id string, x, y int) *microflows.EndEvent { + return &microflows.EndEvent{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(id)}, Position: model.Point{X: x, Y: y}}} + } + oc := &microflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + &microflows.StartEvent{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "start"}, Position: model.Point{X: 0, Y: 100}}}, + act("declare", 100, 100, &microflows.CreateVariableAction{VariableName: "N", DataType: &microflows.IntegerType{}, InitialValue: "0"}), + act("create", 200, 100, &microflows.CreateObjectAction{ + OutputVariable: "Obj", EntityQualifiedName: "Synthetic.Item", + ErrorHandlingType: microflows.ErrorHandlingTypeCustomWithoutRollback, + }), + // Stored before the main-path copy, so storage order agrees with + // describe order and cannot mask a ranking that ignores the handler. + act("mset", 300, 100, &microflows.ChangeVariableAction{VariableName: "N", Value: "$N + 1"}), + act("hset", 200, 250, &microflows.ChangeVariableAction{VariableName: "N", Value: "$N + 1"}), + end("hend", 300, 250), + end("end", 400, 100), + }, + Flows: []*microflows.SequenceFlow{ + {OriginID: "start", DestinationID: "declare"}, + {OriginID: "declare", DestinationID: "create"}, + {OriginID: "create", DestinationID: "mset"}, + {OriginID: "create", DestinationID: "hset", IsErrorHandler: true}, + {OriginID: "hset", DestinationID: "hend"}, + {OriginID: "mset", DestinationID: "end"}, + }, + } + return &microflows.Microflow{ObjectCollection: oc} +} + +// An activity inside an `on error { … }` block is printed, so it gets a handle +// directly above it, and ordinals count it where it is printed: before the +// main-path copy that follows the block. Ranking handler bodies after +// everything else would make `@1` pick the activity a reader counts second. +func TestDescribeWithHandles_ErrorHandlerBody(t *testing.T) { + ctx := &ExecContext{} + lines := formatMicroflowActivitiesWithHandles(ctx, errorHandlerFixture(), nil, nil) + got := strings.Join(lines, "\n") + + var setLines []int + for i, line := range lines { + if strings.TrimSpace(line) == "set $N = $N + 1;" { + setLines = append(setLines, i) + } + } + if len(setLines) != 2 || !strings.Contains(got, "on error without rollback {") { + t.Fatalf("fixture should print the handler's set inside the block, then the main one:\n%s", got) + } + + cands, _, _, _ := microflowTargets(ctx, errorHandlerFixture(), nil, nil) + for i, wantID := range []model.ID{"hset", "mset"} { + target := fmt.Sprintf("set $N = $N + 1 @%d", i+1) + c, err := mfmutator.ResolveText(cands, target) + if err != nil { + t.Fatalf("%s: %v", target, err) + } + if c.ID != wantID { + t.Errorf("%s resolves to %s, want %s (the %s one printed)", target, c.ID, wantID, []string{"first", "second"}[i]) + } + // The handle naming it sits directly above the printed statement. + above := strings.TrimSpace(lines[setLines[i]-1]) + for j := setLines[i] - 1; j >= 0 && strings.HasPrefix(strings.TrimSpace(lines[j]), "@"); j-- { + above = strings.TrimSpace(lines[j-1]) + } + if above != "-- handle: "+target { + t.Errorf("line above the %s set is %q, want the handle %q:\n%s", wantID, above, target, got) + } + } + + // Control: the handles are the only addition. + var stripped []string + for _, line := range lines { + if !strings.HasPrefix(strings.TrimSpace(line), "-- handle: ") { + stripped = append(stripped, line) + } + } + if plain := formatMicroflowActivities(ctx, errorHandlerFixture(), nil, nil); strings.Join(stripped, "\n") != strings.Join(plain, "\n") { + t.Errorf("with handles minus the handle lines differs from plain describe:\n%s\n---\n%s", strings.Join(stripped, "\n"), strings.Join(plain, "\n")) + } +} diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index bbbe13a01..75304ea8b 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -197,12 +197,26 @@ func calculateNanoflowComplexity(nf *microflows.Nanoflow) int { // describeMicroflow renders a microflow as MDL (Mode 1 / Mode 2). It keeps this // exact signature because the catalog dispatches on it by name. func describeMicroflow(ctx *ExecContext, name ast.QualifiedName) error { - return describeMicroflowMode(ctx, name, false) + return describeMicroflowMode(ctx, name, describeMicroflowOptions{}) } -// describeMicroflowMode adds Mode 3: with normalized set, a recombinable -// irreducible split is folded into a single condition rather than flattened. -func describeMicroflowMode(ctx *ExecContext, name ast.QualifiedName, normalized bool) error { +// describeMicroflowOptions selects the optional renderings of DESCRIBE MICROFLOW. +type describeMicroflowOptions struct { + // Normalized is Mode 3: a recombinable irreducible split is folded into a + // single condition rather than flattened. + Normalized bool + // Handles prints each activity's `alter microflow` target above it. + Handles bool +} + +// describeMicroflowMode renders DESCRIBE MICROFLOW with the given options. +func describeMicroflowMode(ctx *ExecContext, name ast.QualifiedName, opts describeMicroflowOptions) error { + normalized := opts.Normalized + if opts.Normalized && opts.Handles { + // A handle addresses an activity of the STORED flow; a normalized + // description shows a different graph, with guards that exist nowhere. + return mdlerrors.NewValidation("describe microflow: 'normalized' and 'with handles' cannot be combined; handles address the stored flow, which 'normalized' does not show") + } // Get hierarchy for module/folder resolution h, err := getHierarchy(ctx) if err != nil { @@ -327,7 +341,12 @@ func describeMicroflowMode(ctx *ExecContext, name ast.QualifiedName, normalized // Generate activities if targetMf.ObjectCollection != nil && len(targetMf.ObjectCollection.Objects) > 0 { - activityLines := formatMicroflowActivities(ctx, targetMf, entityNames, microflowNames) + var activityLines []string + if opts.Handles { + activityLines = formatMicroflowActivitiesWithHandles(ctx, targetMf, entityNames, microflowNames) + } else { + activityLines = formatMicroflowActivities(ctx, targetMf, entityNames, microflowNames) + } activityLines = prependFreeAnnotationLines(targetMf.ObjectCollection, activityLines) for _, line := range activityLines { lines = append(lines, " "+line) @@ -1009,8 +1028,25 @@ func formatMicroflowActivitiesWithSourceMap( sourceMap map[string]elkSourceRange, headerLineCount int, ) []string { + warnings, body := formatMicroflowBodyWithSourceMap(ctx, mf, entityNames, microflowNames, sourceMap, headerLineCount) + return append(warnings, body...) +} + +// formatMicroflowBodyWithSourceMap is formatMicroflowActivitiesWithSourceMap +// with the warnings kept apart from the body. The source map is recorded while +// the body is emitted, before the warnings are prepended, so its line numbers +// index the body alone; a caller that needs them exact (describe … with +// handles) takes the two separately. +func formatMicroflowBodyWithSourceMap( + ctx *ExecContext, + mf *microflows.Microflow, + entityNames map[model.ID]string, + microflowNames map[model.ID]string, + sourceMap map[string]elkSourceRange, + headerLineCount int, +) (warnings, body []string) { if mf.ObjectCollection == nil { - return []string{"-- debug: ObjectCollection is nil"} + return nil, []string{"-- debug: ObjectCollection is nil"} } activityMap := make(map[model.ID]microflows.MicroflowObject) @@ -1058,9 +1094,8 @@ func formatMicroflowActivitiesWithSourceMap( traverseFlow(ctx, startID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, &lines, 0, sourceMap, headerLineCount, annotationsByTarget, labels) declaredCrossed := emitCrossedMergeSections(ctx, mf.ObjectCollection, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, &lines, sourceMap, headerLineCount, annotationsByTarget, labels) - lines = append(microflowBodyWarnings(ctx, mf, labels, declaredCrossed), lines...) - return lines + return microflowBodyWarnings(ctx, mf, labels, declaredCrossed), lines } // findSplitMergePoints finds the corresponding merge point for each exclusive split. diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 38c18d4f0..ae62096b9 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -821,6 +821,8 @@ func emitActivityStatement( indentStr string, annotationsByTarget *annotationEmitter, labels mergeLabels, + sourceMap map[string]elkSourceRange, + headerLineCount int, ) { if stmt == "" { return @@ -843,7 +845,7 @@ func emitActivityStatement( // render it commented-out, so the artifact still shows what the model // holds. Guard-don't-drop, in a path that cannot round-trip. emitCommentedErrorHandler( - ctx, obj, flowsByOrigin, activityMap, entityNames, microflowNames, lines, indentStr, annotationsByTarget, labels) + ctx, obj, flowsByOrigin, activityMap, entityNames, microflowNames, lines, indentStr, annotationsByTarget, labels, sourceMap, headerLineCount) return } @@ -864,7 +866,7 @@ func emitActivityStatement( suffix := formatErrorHandlingSuffix(errType) if errorHandlerFlow != nil && hasCustomErrorHandler(errType) { - errStmts := collectErrorHandlerStatements( + errStmts, errSpans := collectErrorHandlerStatementSpans( ctx, errorHandlerFlow.DestinationID, activityMap, flowsByOrigin, entityNames, microflowNames, annotationsByTarget, labels, @@ -881,6 +883,7 @@ func emitActivityStatement( *lines = append(*lines, indentStr+stmtWithoutSemi+errorSuffix+" { };") } else { *lines = append(*lines, indentStr+stmtWithoutSemi+errorSuffix+" {") + recordErrorHandlerSpans(sourceMap, errSpans, len(*lines)+headerLineCount) for _, errStmt := range errStmts { *lines = append(*lines, indentStr+" "+errStmt) } @@ -914,6 +917,8 @@ func emitCommentedErrorHandler( indentStr string, annotationsByTarget *annotationEmitter, labels mergeLabels, + sourceMap map[string]elkSourceRange, + headerLineCount int, ) { errorHandlerFlow := findErrorHandlerFlow(flowsByOrigin[obj.GetID()]) if errorHandlerFlow == nil { @@ -929,13 +934,14 @@ func emitCommentedErrorHandler( suffix = "on error without rollback" } - errStmts := collectErrorHandlerStatements( + errStmts, errSpans := collectErrorHandlerStatementSpans( ctx, errorHandlerFlow.DestinationID, activityMap, flowsByOrigin, entityNames, microflowNames, annotationsByTarget, labels) if len(errStmts) == 0 { *lines = append(*lines, indentStr+"-- "+suffix+" { };") return } *lines = append(*lines, indentStr+"-- "+suffix+" {") + recordErrorHandlerSpans(sourceMap, errSpans, len(*lines)+headerLineCount) for _, errStmt := range errStmts { *lines = append(*lines, indentStr+"-- "+strings.TrimSpace(errStmt)) } @@ -1175,7 +1181,7 @@ func traverseFlow( // Regular activity startLine := len(*lines) + headerLineCount normalFlows := findNormalFlows(flowsByOrigin[currentID]) - emitActivityStatement(ctx, obj, stmt, flowsByOrigin, flowsByDest, activityMap, entityNames, microflowNames, lines, indentStr, annotationsByTarget, labels) + emitActivityStatement(ctx, obj, stmt, flowsByOrigin, flowsByDest, activityMap, entityNames, microflowNames, lines, indentStr, annotationsByTarget, labels, sourceMap, headerLineCount) recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1) // Follow normal (non-error-handler) outgoing flows @@ -1363,7 +1369,7 @@ func traverseFlowUntilMerge( // Regular activity startLine := len(*lines) + headerLineCount normalFlows := findNormalFlows(flowsByOrigin[currentID]) - emitActivityStatement(ctx, obj, stmt, flowsByOrigin, flowsByDest, activityMap, entityNames, microflowNames, lines, indentStr, annotationsByTarget, labels) + emitActivityStatement(ctx, obj, stmt, flowsByOrigin, flowsByDest, activityMap, entityNames, microflowNames, lines, indentStr, annotationsByTarget, labels, sourceMap, headerLineCount) recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1) // Follow normal (non-error-handler) outgoing flows until merge @@ -2333,7 +2339,42 @@ func collectErrorHandlerStatements( annotationsByTarget *annotationEmitter, labels mergeLabels, ) []string { + statements, _ := collectErrorHandlerStatementSpans(ctx, startID, activityMap, flowsByOrigin, entityNames, microflowNames, annotationsByTarget, labels) + return statements +} + +// errorHandlerSpan is where, among the statements of an error handler block, +// one handler-body object is printed: its notes and its statement. +type errorHandlerSpan struct { + id model.ID + start, end int +} + +// recordErrorHandlerSpans enters the handler-body objects into the source map, +// base being the absolute line of the block's first statement. Without them a +// handler-body activity has no line at all, so `describe … with handles` +// printed no handle for it and ranked it after every other activity — making +// an `@n` ordinal count differently from the order describe prints in. +func recordErrorHandlerSpans(sourceMap map[string]elkSourceRange, spans []errorHandlerSpan, base int) { + for _, sp := range spans { + recordSourceMap(sourceMap, sp.id, base+sp.start, base+sp.end) + } +} + +// collectErrorHandlerStatementSpans is collectErrorHandlerStatements that also +// reports, per object printed, which statements it occupies. +func collectErrorHandlerStatementSpans( + ctx *ExecContext, + startID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + entityNames map[model.ID]string, + microflowNames map[model.ID]string, + annotationsByTarget *annotationEmitter, + labels mergeLabels, +) ([]string, []errorHandlerSpan) { var statements []string + var spans []errorHandlerSpan visited := make(map[model.ID]bool) stopID := firstReachableErrorHandlerMerge(startID, activityMap, flowsByOrigin) @@ -2382,8 +2423,10 @@ func collectErrorHandlerStatements( if _, isSplit := obj.(*microflows.ExclusiveSplit); isSplit { stmt := formatActivity(ctx, obj, entityNames, microflowNames) if stmt != "" { + start := len(statements) notes(obj, indentStr) statements = append(statements, indentStr+stmt) + spans = append(spans, errorHandlerSpan{id: id, start: start, end: len(statements) - 1}) } nestedMergeID := splitMergeMap[id] trueFlow, falseFlow := findBranchFlows(flowsByOrigin[id]) @@ -2409,8 +2452,10 @@ func collectErrorHandlerStatements( } if stmt := formatActivity(ctx, obj, entityNames, microflowNames); stmt != "" { + start := len(statements) notes(obj, indentStr) statements = append(statements, indentStr+stmt) + spans = append(spans, errorHandlerSpan{id: id, start: start, end: len(statements) - 1}) } for _, flow := range findNormalFlows(flowsByOrigin[id]) { traverse(flow.DestinationID, boundary, indent) @@ -2418,7 +2463,7 @@ func collectErrorHandlerStatements( } traverse(startID, stopID, 0) - return statements + return statements, spans } func findErrorHandlerSplitMergePoints( diff --git a/mdl/executor/executor_query.go b/mdl/executor/executor_query.go index 417981622..7e7a7345e 100644 --- a/mdl/executor/executor_query.go +++ b/mdl/executor/executor_query.go @@ -211,7 +211,7 @@ func execDescribe(ctx *ExecContext, s *ast.DescribeStmt) error { case ast.DescribeAssociation: return describeAssociation(ctx, s.Name) case ast.DescribeMicroflow: - return describeMicroflowMode(ctx, s.Name, s.Normalized) + return describeMicroflowMode(ctx, s.Name, describeMicroflowOptions{Normalized: s.Normalized, Handles: s.WithHandles}) case ast.DescribeNanoflow: return describeNanoflow(ctx, s.Name) case ast.DescribeRule: diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index 72531c848..cf037486c 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -70,6 +70,10 @@ MERGE: M E R G E; // entity or variable called "normalized" still parses. NORMALIZED: N O R M A L I Z E D; +// `describe microflow X with handles` prints the content address of each +// activity (ADR-0012). In `keyword` too, so "handles" still parses as a name. +HANDLES: H A N D L E S; + ENTITY: E N T I T Y; PERSISTENT: P E R S I S T E N T; VIEW: V I E W; diff --git a/mdl/grammar/domains/MDLCatalog.g4 b/mdl/grammar/domains/MDLCatalog.g4 index d5e1a0bf4..17ef5a1c7 100644 --- a/mdl/grammar/domains/MDLCatalog.g4 +++ b/mdl/grammar/domains/MDLCatalog.g4 @@ -159,7 +159,7 @@ describeStatement | DESCRIBE CONTRACT MESSAGE qualifiedName // DESCRIBE CONTRACT MESSAGE Module.Service.MessageName | DESCRIBE ENTITY qualifiedName | DESCRIBE ASSOCIATION qualifiedName - | DESCRIBE MICROFLOW qualifiedName NORMALIZED? // NORMALIZED folds a recombinable irreducible graph into nested ifs (Mode 3) + | DESCRIBE MICROFLOW qualifiedName NORMALIZED? (WITH HANDLES)? // NORMALIZED folds a recombinable irreducible graph into nested ifs (Mode 3); WITH HANDLES prints each activity's alter target | DESCRIBE NANOFLOW qualifiedName | DESCRIBE RULE qualifiedName | DESCRIBE WORKFLOW qualifiedName diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index 3184f820d..87da00016 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -634,6 +634,7 @@ keyword | NOTHING | EXPRESSION | JAVASCRIPT | MERGE | NORMALIZED + | HANDLES // Query / SQL | SELECT | FROM | WHERE | JOIN | LEFT | RIGHT | INNER | OUTER | FULL | CROSS diff --git a/mdl/visitor/visitor_query.go b/mdl/visitor/visitor_query.go index e8bbf273a..90c0f078d 100644 --- a/mdl/visitor/visitor_query.go +++ b/mdl/visitor/visitor_query.go @@ -1168,9 +1168,10 @@ func (b *Builder) ExitDescribeStatement(ctx *parser.DescribeStatementContext) { }) } else if ctx.MICROFLOW() != nil { b.statements = append(b.statements, &ast.DescribeStmt{ - ObjectType: ast.DescribeMicroflow, - Name: name, - Normalized: ctx.NORMALIZED() != nil, + ObjectType: ast.DescribeMicroflow, + Name: name, + Normalized: ctx.NORMALIZED() != nil, + WithHandles: ctx.HANDLES() != nil, }) } else if ctx.NANOFLOW() != nil { b.statements = append(b.statements, &ast.DescribeStmt{