From 1984c9c168e21029480e1dbf71645e2251d7b381 Mon Sep 17 00:00:00 2001 From: Ako Date: Fri, 25 Sep 2026 09:05:59 +0000 Subject: [PATCH 1/2] fix(pages): round-trip a page over a flow the project lacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describe → exec of Feedback v4.0.2's EXCLUDED FeedbackModule.ShareFeedback_Logo was refused: "nanoflow not found: FeedbackModule.DS_FeedbackForm (data source)". Forcing it through left a project `mx check` could not LOAD (ArgumentNullException setting 'Attribute'). With the flow missing, DESCRIBE had no context entity and printed every binding inside the data view bare (`Attribute: Subject`, `{1} = ImageB64`, `Visible: _showEmail in (…)`). Exec had nothing to qualify them against. The stored model always has the full names. - DESCRIBE keeps the stored Module.Entity.Attr for bindings inside a data view, list view or gallery whose flow cannot be resolved. This covers Attribute:, template parameters, pluggable attribute properties, object-list items and the attribute visibility condition. - `Visible: Module.Entity.Attr in (…)` is accepted and needs no entity in scope. - On an excluded page a missing data-source flow is kept by name, like the action targets in the previous commit. - The check reports it as a warning when every binding inside is qualified. Otherwise it refuses and names the bare binding and its widget. - As a last line, the page and snippet writer refuses any DomainModels$AttributeRef that is not Module.Entity.Attr. Studio Pro qualifies every one (72 of 72 across the project), and a bare one takes the loader down. ShareFeedback_Logo now round-trips: warnings only, all 6 attribute references identical to the stored ones, the page still excluded, and `mx check` 0 errors. A forced-fault variant with one binding hand-edited back to bare is refused, naming the widget. All 17 pages of the project now exec. Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-executor.jsonl | 1 + .claude/skills/mendix/check-syntax/SKILL.md | 10 +- docs-site/src/tutorial/validation.md | 8 +- ...age-unresolved-flow-qualified-bindings.mdl | 46 ++++++++ .../modelsdk/page_bare_attributeref.go | 59 ++++++++++ .../modelsdk/page_bare_attributeref_test.go | 47 ++++++++ mdl/backend/modelsdk/page_write.go | 9 +- mdl/backend/modelsdk/snippet_write.go | 9 +- mdl/executor/cmd_pages_builder.go | 13 +-- mdl/executor/cmd_pages_builder_v3.go | 8 +- .../cmd_pages_builder_visible_when.go | 24 +++-- .../cmd_pages_describe_flowcontext.go | 37 +++++++ mdl/executor/cmd_pages_describe_objectlist.go | 2 +- mdl/executor/cmd_pages_describe_output.go | 10 +- mdl/executor/cmd_pages_describe_parse.go | 31 ++++-- mdl/executor/cmd_pages_describe_pluggable.go | 8 +- ..._pages_describe_unresolved_context_test.go | 94 ++++++++++++++++ mdl/executor/cmd_pages_visible_when_test.go | 25 +++++ mdl/executor/exec_context.go | 6 ++ mdl/executor/validate.go | 102 ++++++++++++++++-- mdl/executor/validate_excluded_page_test.go | 63 +++++++++-- mdl/grammar/domains/MDLPage.g4 | 2 +- mdl/visitor/visitor_page_v3.go | 2 +- 23 files changed, 560 insertions(+), 56 deletions(-) create mode 100644 mdl-examples/bug-tests/excluded-page-unresolved-flow-qualified-bindings.mdl create mode 100644 mdl/backend/modelsdk/page_bare_attributeref.go create mode 100644 mdl/backend/modelsdk/page_bare_attributeref_test.go create mode 100644 mdl/executor/cmd_pages_describe_unresolved_context_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 8d6bc88da1..acbb71c1ca 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -702,3 +702,4 @@ {"area": "mdl/executor", "date": "2026-09-25", "symptom": "`container c1 (dynamicclasses: [ if $currentObject/Featured then 'a' else 'b' ])` - the first-class spelling mendixlabs/mxcli#750 proposes - checked clean and `exec` reported `Created page`, but the widget was stored with no DynamicClasses at all. `alter page ... set DynamicClasses = [ ... ] on w` reported `Altered page` and changed nothing; a column's `DynamicCellClass` on ALTER stored `[if$x/Ythen'a'else'b']` as its expression", "cause": "`[ ... ]` parses as propertyValueV3's array alternative, so the value reaches the AST as a []string. The create writers read only a string (GetStringProp for DynamicClasses, `v.(string)` for columnClass); the mutator's dynamicclasses case returned nil when the type check failed; setColumnPropertyMut formatted the list with %v, after the visitor's GetText() had already fused its tokens", "file": "`mdl/executor/validate_widget_expression_list.go` (MDL-WIDGET32), `mdl/backend/pagemutator/mutator.go` (`errExpressionNotAString`)", "insight": "**A proposal's example syntax is worth running through `check` before designing around it** - here the proposed spelling already parsed, and the parse was the bug. Same class as #999 / MDL-WIDGET27 (empty `[]`): a value shape no writer claims. Fix at both ends and they stay in step: a no-project check rule for CREATE, and an error (not a nil return) from the ALTER setter, which `check -p` reports for free because validateAlterSetProperties dry-runs the setter. Key the rule on the property, never on the brackets - `visible: [cond]` is valid MDL. Measured with pre-fix and fixed binaries on copies of ako/TestApp: pre-fix `describe` shows the container without DynamicClasses and the quoted control intact. Tests `validate_widget_expression_list_test.go`, `pagemutator/expression_list_value_test.go`", "refs": ["mendixlabs/mxcli#750", "#999"], "rules": ["MDL-WIDGET32"]} {"area": "mdl/executor", "symptom": "describe → exec of Administration.Account_Edit takes the page's 8 Enumerations$Condition entries to 0: every widget with Studio Pro's \"Visible: based on attribute value\" becomes ALWAYS visible; check, exec and mx check all report success", "cause": "MDL had no spelling for attribute-based conditional visibility. extractConditionalSettings read only Expression, conditionalVisibilityToGen wrote only Expression, and the settings' list markers were the default [3] where Studio Pro stores Conditions [2] and ModuleRoles [1]", "file": "`mdl/grammar/domains/MDLPage.g4` (`VISIBLE COLON attributePathV3 IN (…)`), `mdl/executor/cmd_pages_builder_visible_when.go` (`applyVisibleWhen`), `mdl/backend/modelsdk/widget_write.go` (`conditionalVisibilityToGen`, TypeDefaults), `mdl/executor/cmd_pages_describe_parse.go` / `_output.go` (`visibleWhenProp`)", "insight": "**A dropped visibility setting is invisible to every check**: the model stays valid, the widget just shows for everyone — count `Enumerations$Condition` in `bson dump --format ndsl` before and after a round trip, since mx check never will. Survey the corpus before designing syntax: all 12 settings in the stock project were attribute-based (booleans and one enum), none role-based or editability, which scoped the feature. Studio Pro stores EVERY value (enum values in declaration order plus \"(empty)\", or true/false) with a flag, so MDL lists only the shown values and the writer fills the rest from the domain model — and a byte-identical before/after diff of the settings block (markers included) on 3 pages is the proof. mdlIdent quotes `empty`/`true`/`false` as keywords; emit them bare in a value list. A new AST property key must be added to the known-property list (validate_widgets.go) or MDL-WIDGET07 falsely warns it is dropped", "refs": ["Administration.Account_Edit", "Administration.ScheduledEvents"], "rules": ["MDL-WIDGET07"], "date": "2026-09-25"} {"area":"mdl/executor","date":"2026-09-25","symptom":"describe → exec of an EXCLUDED page (Feedback v4.0.2 FeedbackModule.ShareFeedback_Logo, 11.13.0) refused: `page '…' has reference errors: - nanoflow not found: FeedbackModule.DS_FeedbackForm …`, though the untouched project passes mx check at 0 errors (Mendix does not validate excluded documents). With --no-check the page builder refused the same names again (`failed to resolve nanoflow`).","cause":"Two refusals, not one: validate.go's CreatePageStmtV3/CreateSnippetStmtV3 cases ignored exclusion (microflow/nanoflow/rule had been exempt since #312, silently), and pageBuilder.resolveMicroflow/resolveNanoflowByName/resolvePageRef/resolveSnippetRef fail on a missing name though the writer only ever stores the qualified NAME (IDs are never serialized).","file":"mdl/executor/validate.go (relaxExcludedWidgetRefs, carriedExclusion, warnExcluded), mdl/executor/cmd_pages_builder.go (tolerateDanglingRefs/danglingRefOK), cmd/mxcli/cmd_exec.go + cmd_check.go (ValidateProgramWithWarnings)","insight":"Relaxing the check is NOT safe for a DATA SOURCE, and only a real run shows it: the source flow's return type is the entity in scope, describe prints the nested bindings as bare names (`Attribute: Subject`, `ImageUrlParams: [{1} = ImageB64]`), and writing them without the entity left a bare `ImageB64` AttributeRef that made mx unable to LOAD the project (ArgumentNullException setting 'Attribute') — excluded page or not, where the pre-fix refusal had been protecting it by accident. So dangling action targets/snippet calls are warnings, dangling data sources and entities still block with the reason. 'Excluded' must mean what exec WRITES — @excluded OR the #914 carry (every stored namesake excluded) — or check and exec disagree. A/B on 11.13.0: identical page with 3 dangling action targets, excluded → 0 errors; live → 3x CE1613. Follow-up not fixed: describe loses attribute qualification inside a container whose flow is unresolvable, so ShareFeedback_Logo itself still cannot round-trip.","refs":["mdl-examples/bug-tests/excluded-page-dangling-references.mdl","#312","#914"]} +{"area": "mdl/executor", "symptom": "describe → exec of Feedback v4.0.2's EXCLUDED FeedbackModule.ShareFeedback_Logo refused `nanoflow not found: FeedbackModule.DS_FeedbackForm (data source)`; forcing it through left a project `mx check` could not LOAD (ArgumentNullException setting 'Attribute')", "cause": "The data view's flow is not in the project, so DESCRIBE had no context entity and printed every binding inside it bare (`Attribute: Subject`, `{1} = ImageB64`, `Visible: _showEmail in (…)`); exec had nothing to qualify them against, and a bare DomainModels$AttributeRef makes Mendix's loader throw. The stored model always had the full names", "file": "`mdl/executor/cmd_pages_describe_flowcontext.go` (`withQualifiedAttrs`, `describeAttr`), `mdl/executor/validate.go` (`unscopedBindings`), `mdl/executor/cmd_pages_builder_v3.go` (dangling DS flow kept by name), `mdl/backend/modelsdk/page_bare_attributeref.go` (`refuseBareAttributeRefs`)", "insight": "**The information was never lost — DESCRIBE threw it away**: every AttributeRef inside the unresolvable container still stored Module.Entity.Attr; shortening to the bare name is only safe where the reader can re-derive the entity, so key the shortening on whether the context resolved, not on habit. Measure the loader's tolerance before adding a write guard: 72 of 72 Studio Pro AttributeRefs in the project are qualified, so refusing a bare one refuses only writes that were already fatal — and turns a load-time stack trace into a statement-level error naming the attribute. Pair a blanket AST-level refusal with the exact failing slots (Attribute, CaptionAttribute, Visible-in, *Params) so the refusal names the widget, and keep the writer guard as the net for slots the walk does not know. Forced-fault run: hand-edit one qualified binding back to bare and confirm the refusal names it", "refs": ["ako/mxcli#675", "FeedbackModule.ShareFeedback_Logo"], "date": "2026-09-25"} diff --git a/.claude/skills/mendix/check-syntax/SKILL.md b/.claude/skills/mendix/check-syntax/SKILL.md index be4b1cee6f..973912c2e6 100644 --- a/.claude/skills/mendix/check-syntax/SKILL.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -63,9 +63,13 @@ does not validate excluded documents (Feedback v4.0.2 ships an excluded page bou to nanoflows it lacks, and the project checks at 0 errors), so `check` and `exec` print them as `Reference warning` lines for excluded microflows, nanoflows, rules, and pages/snippets exec will write excluded (`@excluded`, or a stored namesake that -is). **A page's or snippet's missing data source (or entity) still blocks:** the -widgets inside bind against it, and written without it their bindings are bare -names — on 11.13.0 that left a project `mx` could not load. +is). **A missing data-source flow is a warning only when the bindings inside it are +qualified** (`Attribute: Module.Entity.Attr`, `{1} = Module.Entity.Attr`, +`Visible: Module.Entity.Attr in (…)`) — the form `describe` writes there. The +widgets inside bind against the entity that flow returns, so with the flow missing +a bare binding cannot be resolved; it is refused, naming the widget, because on +11.13.0 a bare attribute reference left a project `mx` could not load. A missing +entity still blocks. ### It also reports a name the PROJECT already has diff --git a/docs-site/src/tutorial/validation.md b/docs-site/src/tutorial/validation.md index bdb7e8a4ab..3aad848373 100644 --- a/docs-site/src/tutorial/validation.md +++ b/docs-site/src/tutorial/validation.md @@ -50,9 +50,11 @@ This is the check you should run before executing a script. It's fast (reads the References inside an **excluded** document (`@excluded`, or a page or snippet that stays excluded because its stored namesake is) are reported as `Reference warnings` rather than errors, because Mendix does not validate -excluded documents. A page's or snippet's missing *data source* still fails the -check: the widgets inside it bind against that source's entity, and cannot be -written without it. +excluded documents. A missing *data-source flow* is a warning too, but only when +every binding inside that container is qualified (`Module.Entity.Attribute`) — +`describe page` writes them that way there. The widgets bind against the entity +the flow returns, so with the flow missing a bare binding cannot be resolved and +the check fails, naming the widget. A missing entity still fails the check. ### Name conflicts with the project diff --git a/mdl-examples/bug-tests/excluded-page-unresolved-flow-qualified-bindings.mdl b/mdl-examples/bug-tests/excluded-page-unresolved-flow-qualified-bindings.mdl new file mode 100644 index 0000000000..37b8d32ae1 --- /dev/null +++ b/mdl-examples/bug-tests/excluded-page-unresolved-flow-qualified-bindings.mdl @@ -0,0 +1,46 @@ +-- ============================================================================ +-- An excluded page over a flow the project lacks: bindings kept qualified +-- ============================================================================ +-- +-- Symptom: describe → exec of FeedbackModule.ShareFeedback_Logo (Feedback +-- v4.0.2, Mendix 11.13.0), an EXCLUDED example page whose data view is sourced +-- by a nanoflow the module does not ship, was refused: +-- nanoflow not found: FeedbackModule.DS_FeedbackForm (data source) +-- and writing it anyway left a project `mx check` could not LOAD +-- (ArgumentNullException setting 'Attribute'), because DESCRIBE printed the +-- bindings inside that data view bare (`Attribute: Subject`, `{1} = ImageB64`) +-- and with the flow missing there is no entity to qualify them against. +-- +-- Fix: DESCRIBE keeps the stored Module.Entity.Attr inside a data container +-- whose flow cannot be resolved (and `Visible: Mod.Entity.Attr in (…)` accepts +-- it); the missing flow is then a warning on an excluded page. A bare binding +-- there is still refused, naming the widget, and the page writer refuses any +-- bare attribute reference outright. +-- +-- Verify: exec on a project WITHOUT MyFirstModule.DS_MissingForm → warning +-- only, "Created page"; `mxcli docker check` — 0 errors (the page stays +-- excluded). Replace one qualified binding with a bare name → refused. +-- ============================================================================ + +create entity MyFirstModule.FeedbackDraft ( + Subject: String(200), + ShowEmail: Boolean default false, + Email: String(200) +); +/ + +@excluded +create or modify page MyFirstModule.FeedbackDraft_Example +( Title: 'Feedback (example)', Layout: Atlas_Core.Atlas_Default ) +{ + dataview dv (DataSource: nanoflow MyFirstModule.DS_MissingForm) { + textbox txtSubject (Label: 'Subject', Attribute: MyFirstModule.FeedbackDraft.Subject) + dynamictext txtEcho (Content: 'About: {1}', ContentParams: [{1} = MyFirstModule.FeedbackDraft.Subject]) + textbox txtEmail ( + Label: 'Email', + Attribute: MyFirstModule.FeedbackDraft.Email, + Visible: MyFirstModule.FeedbackDraft.ShowEmail in (true) + ) + } +} +/ diff --git a/mdl/backend/modelsdk/page_bare_attributeref.go b/mdl/backend/modelsdk/page_bare_attributeref.go new file mode 100644 index 0000000000..ead2078258 --- /dev/null +++ b/mdl/backend/modelsdk/page_bare_attributeref.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "fmt" + "strings" + + "go.mongodb.org/mongo-driver/bson" +) + +// refuseBareAttributeRefs refuses a page or snippet whose encoded form holds a +// DomainModels$AttributeRef that is not Module.Entity.Attribute. +// +// Mendix rebuilds each stored reference into a typed identifier as it loads, +// and an attribute that does not parse as one takes the loader down before any +// validation runs: a bare `ImageB64` image parameter left `mx check` unable to +// load the project (ArgumentNullException setting 'Attribute', 11.13.0) — the +// page was excluded, which does not help, as loading is not validating. Studio +// Pro qualifies every one (72 of 72 across a stock project's pages, snippets +// and layouts). A bare name reaches here when nothing could qualify it — inside +// a data container whose flow the project lacks — so this is the last line +// under the check that refuses it first (checkUnscopedBindings). +func refuseBareAttributeRefs(contents []byte) error { + var bad []string + var walk func(v bson.RawValue, path string) + walk = func(v bson.RawValue, path string) { + switch v.Type { + case bson.TypeEmbeddedDocument: + doc := v.Document() + if t, ok := doc.Lookup("$Type").StringValueOK(); ok && t == "DomainModels$AttributeRef" { + if a, ok := doc.Lookup("Attribute").StringValueOK(); ok && a != "" && strings.Count(a, ".") < 2 { + bad = append(bad, fmt.Sprintf("%q at %s", a, path)) + } + } + name, _ := doc.Lookup("Name").StringValueOK() + elems, _ := doc.Elements() + for _, e := range elems { + p := path + "/" + e.Key() + if name != "" { + p = path + "/" + name + "." + e.Key() + } + walk(e.Value(), p) + } + case bson.TypeArray: + vals, _ := v.Array().Values() + for _, x := range vals { + walk(x, path) + } + } + } + walk(bson.RawValue{Type: bson.TypeEmbeddedDocument, Value: contents}, "") + if len(bad) == 0 { + return nil + } + return fmt.Errorf("attribute reference not qualified as Module.Entity.Attribute — Mendix cannot load a "+ + "project holding one, so it is not written: %s. Qualify it in the script; inside a data container "+ + "whose flow the project lacks there is no entity to resolve a bare name against", strings.Join(bad, "; ")) +} diff --git a/mdl/backend/modelsdk/page_bare_attributeref_test.go b/mdl/backend/modelsdk/page_bare_attributeref_test.go new file mode 100644 index 0000000000..83c2c2f001 --- /dev/null +++ b/mdl/backend/modelsdk/page_bare_attributeref_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +// A DomainModels$AttributeRef whose Attribute is not Module.Entity.Attr makes +// the project unloadable: an image URL parameter written as a bare `ImageB64` +// (Feedback v4.0.2's ShareFeedback_Logo, under a data view whose flow the +// project lacks) left `mx check` unable to LOAD the project — +// ArgumentNullException setting 'Attribute', Mendix 11.13.0 — while the page +// was excluded. Studio Pro qualifies every one: 72 of 72 AttributeRefs across +// that project's pages, snippets and layouts. So the writer refuses the bare +// form, naming it, instead of storing it. +func TestRefuseBareAttributeRefs(t *testing.T) { + attrRef := func(a string) bson.D { + return bson.D{{Key: "$Type", Value: "DomainModels$AttributeRef"}, {Key: "Attribute", Value: a}, {Key: "EntityRef", Value: nil}} + } + doc := func(a string) []byte { + b, err := bson.Marshal(bson.D{ + {Key: "$Type", Value: "Forms$Page"}, + {Key: "Widgets", Value: bson.A{int32(2), bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Name", Value: "image1"}, + {Key: "Params", Value: bson.A{int32(2), bson.D{{Key: "AttributeRef", Value: attrRef(a)}}}}, + }}}, + }) + if err != nil { + t.Fatal(err) + } + return b + } + err := refuseBareAttributeRefs(doc("ImageB64")) + if err == nil || !strings.Contains(err.Error(), "ImageB64") { + t.Fatalf("a bare attribute reference must be refused, naming it; got %v", err) + } + for _, ok := range []string{"FeedbackModule.Feedback.ImageB64", ""} { + if err := refuseBareAttributeRefs(doc(ok)); err != nil { + t.Errorf("%q must be accepted: %v", ok, err) + } + } +} diff --git a/mdl/backend/modelsdk/page_write.go b/mdl/backend/modelsdk/page_write.go index 94e80bdd89..6c15d5b803 100644 --- a/mdl/backend/modelsdk/page_write.go +++ b/mdl/backend/modelsdk/page_write.go @@ -256,7 +256,14 @@ func encodePage(page *pages.Page, pv *types.ProjectVersion, carry func(*genPg.Pa if carry != nil { carry(g) } - return docEncoder("Forms$Page", pv).Encode(g) + contents, err := docEncoder("Forms$Page", pv).Encode(g) + if err != nil { + return nil, err + } + if err := refuseBareAttributeRefs(contents); err != nil { + return nil, fmt.Errorf("page %q: %w", page.Name, err) + } + return contents, nil } // pageToGen builds the full gen Page: header, layout call, the widget tree (under diff --git a/mdl/backend/modelsdk/snippet_write.go b/mdl/backend/modelsdk/snippet_write.go index 907315610c..2921ad0025 100644 --- a/mdl/backend/modelsdk/snippet_write.go +++ b/mdl/backend/modelsdk/snippet_write.go @@ -33,7 +33,14 @@ func encodeSnippet(snippet *pages.Snippet, pv *types.ProjectVersion) ([]byte, er return nil, err } g.SetID(element.ID(snippet.ID)) - return docEncoder("Forms$Snippet", pv).Encode(g) + contents, err := docEncoder("Forms$Snippet", pv).Encode(g) + if err != nil { + return nil, err + } + if err := refuseBareAttributeRefs(contents); err != nil { // see page_bare_attributeref.go + return nil, fmt.Errorf("snippet %q: %w", snippet.Name, err) + } + return contents, nil } // CreateSnippet inserts a new Forms$Snippet document — a reusable widget tree with diff --git a/mdl/executor/cmd_pages_builder.go b/mdl/executor/cmd_pages_builder.go index d468f89b16..77e70dc33a 100644 --- a/mdl/executor/cmd_pages_builder.go +++ b/mdl/executor/cmd_pages_builder.go @@ -81,12 +81,13 @@ type pageBuilder struct { // page. The writer stores an ACTION's or snippet call's target BY NAME, so // an unresolved one is kept as written instead of failing the build. // - // A DATA SOURCE is deliberately not tolerated: its flow's return type is - // what puts an entity in scope, and without it every attribute binding - // inside the container is written unqualified. Measured on Mendix 11.13.0: - // a bare `ImageB64` in an image's URL parameter made `mx check` fail to - // LOAD the project (ArgumentNullException setting 'Attribute'), even - // though the page was excluded. See validateExcludedWidgetRefs. + // A data-source FLOW is kept by name too, but it is what puts an entity in + // scope: without it a bare attribute binding inside the container cannot + // be qualified, and one written bare made `mx check` fail to LOAD the + // project (ArgumentNullException setting 'Attribute', Mendix 11.13.0). + // DESCRIBE writes those bindings qualified there, the check refuses a bare + // one (checkUnscopedBindings), and the page writer refuses any bare + // attribute reference as a last line (refuseBareAttributeRefs). tolerateDanglingRefs bool // Local page/snippet variables (Variables: { $name: Type = 'default' }). diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 12b57cda58..2c7f7c86f3 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -916,7 +916,11 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource case "microflow": // Microflow source mfID, err := pb.resolveMicroflow(ds.Reference) - if err != nil { + // An excluded page may name a flow the project lacks (see + // tolerateDanglingRefs). It is written by name with NO entity in scope, + // so the bindings inside must be qualified; checkUnscopedBindings + // refuses a bare one before anything is written. + if err != nil && !pb.danglingRefOK(err) { return nil, "", mdlerrors.NewBackend("resolve microflow", err) } @@ -936,7 +940,7 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource case "nanoflow": // Nanoflow source - resolve by listing all nanoflows nfID, err := pb.resolveNanoflowByName(ds.Reference) - if err != nil { + if err != nil && !pb.danglingRefOK(err) { // kept by name: see the microflow case return nil, "", mdlerrors.NewBackend("resolve nanoflow", err) } diff --git a/mdl/executor/cmd_pages_builder_visible_when.go b/mdl/executor/cmd_pages_builder_visible_when.go index 1cefe7fe0a..cf6060b5c9 100644 --- a/mdl/executor/cmd_pages_builder_visible_when.go +++ b/mdl/executor/cmd_pages_builder_visible_when.go @@ -44,18 +44,28 @@ func (pb *pageBuilder) applyVisibleWhen(widget pages.Widget, w *ast.WidgetV3) er return mdlerrors.NewValidationf("%s %s: `Visible: %s in (…)` is not supported on this widget", w.Type, w.Name, vw.Attribute) } where := fmt.Sprintf("%s %s: Visible: %s in (…)", w.Type, w.Name, vw.Attribute) - if pb.entityContext == "" { - return mdlerrors.NewValidationf("%s: the attribute is read from the enclosing data container's object — place the widget inside a data container", where) + if strings.Contains(vw.Attribute, "/") { + return mdlerrors.NewValidationf("%s: association paths are not supported — name an attribute of the data container's own entity", where) } - if strings.ContainsAny(vw.Attribute, "/.") { - return mdlerrors.NewValidationf("%s: name an attribute of the data container's own entity (%s); association paths are not supported", where, pb.entityContext) + + // The entity is the data container's, or — qualified, Module.Entity.Attr — + // named outright. DESCRIBE writes the qualified form under a container whose + // flow cannot be resolved, where there is no entity in scope at all. + entity, attrName := pb.entityContext, vw.Attribute + if parts := strings.Split(vw.Attribute, "."); len(parts) == 3 { + entity, attrName = parts[0]+"."+parts[1], parts[2] + } else if len(parts) != 1 { + return mdlerrors.NewValidationf("%s: name the attribute bare, or as Module.Entity.Attribute", where) + } + if entity == "" { + return mdlerrors.NewValidationf("%s: the attribute is read from the enclosing data container's object — place the widget inside a data container, or qualify it (Module.Entity.%s)", where, attrName) } - declaring, ok := pb.declaringEntityFor(pb.entityContext, vw.Attribute) + declaring, ok := pb.declaringEntityFor(entity, attrName) if !ok { - return mdlerrors.NewValidationf("%s: %s has no attribute %s", where, pb.entityContext, vw.Attribute) + return mdlerrors.NewValidationf("%s: %s has no attribute %s", where, entity, attrName) } - attrQN := declaring + "." + vw.Attribute + attrQN := declaring + "." + attrName var all []string switch t := pb.findAttributeType(attrQN).(type) { diff --git a/mdl/executor/cmd_pages_describe_flowcontext.go b/mdl/executor/cmd_pages_describe_flowcontext.go index 73547933de..4b71321b07 100644 --- a/mdl/executor/cmd_pages_describe_flowcontext.go +++ b/mdl/executor/cmd_pages_describe_flowcontext.go @@ -3,6 +3,8 @@ package executor import ( + "strings" + "github.com/mendixlabs/mxcli/sdk/microflows" ) @@ -30,6 +32,41 @@ func dataSourceEntityContext(ctx *ExecContext, ds *rawDataSource) string { return ds.Reference } +// flowContextUnresolved reports whether a data container's source is a flow +// whose returned entity cannot be determined — the flow is not in the project +// (Feedback v4.0.2's excluded ShareFeedback_Logo names a nanoflow the module +// does not ship), or it returns no object. +func flowContextUnresolved(ctx *ExecContext, ds *rawDataSource) bool { + if ds == nil || ds.Reference == "" || (ds.Type != "microflow" && ds.Type != "nanoflow") { + return false + } + return flowReturnEntity(ctx, ds.Type, ds.Reference) == "" +} + +// withQualifiedAttrs runs parse with attribute bindings kept qualified when +// ds leaves no entity in scope, restoring the previous setting afterwards. +func withQualifiedAttrs[T any](ctx *ExecContext, ds *rawDataSource, parse func() T) T { + if ctx == nil || !flowContextUnresolved(ctx, ds) { + return parse() + } + prev := ctx.describeQualifyAttrs + ctx.describeQualifyAttrs = true + defer func() { ctx.describeQualifyAttrs = prev }() + return parse() +} + +// describeAttr renders a stored attribute name for MDL: bare, as exec resolves +// it against the data container's entity — or, where there is no entity to +// resolve against (describeQualifyAttrs), the stored Module.Entity.Attr. A bare +// name there cannot be qualified on the way back: written anyway, a bare image +// parameter left a project `mx check` could not load. +func describeAttr(ctx *ExecContext, qn string) string { + if ctx != nil && ctx.describeQualifyAttrs && strings.Count(qn, ".") >= 2 { + return qn + } + return shortAttributeName(qn) +} + // flowReturnEntity resolves the entity a microflow or nanoflow returns, by object // or list return type. Returns "" when the project is unavailable, the flow is not // found, or it returns something other than an object/list. diff --git a/mdl/executor/cmd_pages_describe_objectlist.go b/mdl/executor/cmd_pages_describe_objectlist.go index 66940caced..5508d38144 100644 --- a/mdl/executor/cmd_pages_describe_objectlist.go +++ b/mdl/executor/cmd_pages_describe_objectlist.go @@ -212,7 +212,7 @@ func extractObjectListItem(ctx *ExecContext, itemObj map[string]any, nestedMap m // Attribute binding (staticXAttribute, staticYAttribute, …). if attrRef, ok := value["AttributeRef"].(map[string]any); ok && len(attrRef) > 0 { if a := extractString(attrRef["Attribute"]); a != "" { - item.Props = append(item.Props, rawExplicitProp{Key: objectListMDLKey(key), Value: shortAttributeName(a), IsRef: true}) + item.Props = append(item.Props, rawExplicitProp{Key: objectListMDLKey(key), Value: describeAttr(ctx, a), IsRef: true}) } continue } diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 676ecd0958..e0225d8686 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -1860,7 +1860,7 @@ func extractClientTemplateParameters(ctx *ExecContext, w map[string]any, fieldNa result = append(result, "$"+sourceVarName+"."+attrName) } else { // No SourceVariable - use short attribute name - result = append(result, shortAttributeName(attr)) + result = append(result, describeAttr(ctx, attr)) } continue } @@ -2077,5 +2077,11 @@ func visibleWhenProp(w rawWidget) string { } vals[i] = mdlIdent(v) } - return fmt.Sprintf("Visible: %s in (%s)", mdlIdent(w.VisibleAttr), strings.Join(vals, ", ")) + attr := mdlIdent(w.VisibleAttr) + if strings.Contains(w.VisibleAttr, ".") { + // Qualified (Module.Entity.Attr) where no entity is in scope — written + // as a qualified name, which takes keyword segments bare. + attr = w.VisibleAttr + } + return fmt.Sprintf("Visible: %s in (%s)", attr, strings.Join(vals, ", ")) } diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 5ddf678c8a..f2b78c1215 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -27,7 +27,7 @@ func asActionMap(v any) map[string]any { // parseRawWidget parses a raw widget map into rawWidget structs. // extractConditionalSettings extracts ConditionalVisibility/Editability from raw BSON. -func extractConditionalSettings(widget *rawWidget, w map[string]any) { +func extractConditionalSettings(ctx *ExecContext, widget *rawWidget, w map[string]any) { if cvs, ok := w["ConditionalVisibilitySettings"].(map[string]any); ok && cvs != nil { if expr, ok := cvs["Expression"].(string); ok && expr != "" { widget.VisibleIf = expr @@ -36,7 +36,7 @@ func extractConditionalSettings(widget *rawWidget, w map[string]any) { // setting was never read, so describe → exec wrote the widget always // visible (Administration.Account_Edit: 8 conditions → 0). if attr, ok := cvs["Attribute"].(string); ok && attr != "" { - widget.VisibleAttr = shortAttributeName(attr) + widget.VisibleAttr = describeAttr(ctx, attr) for _, c := range getBsonArrayElements(cvs["Conditions"]) { cm, ok := c.(map[string]any) if !ok { @@ -117,7 +117,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s } widget.DesignProperties = extractDesignProperties(appearance) } - extractConditionalSettings(&widget, w) + extractConditionalSettings(ctx, &widget, w) // Regions are five named slots, not a list: Top, Right, Bottom, Left // and CenterRegion (the last spelled differently from its siblings). // Each occupied one becomes a synthetic intermediate widget, the same @@ -182,7 +182,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s } widget.DesignProperties = extractDesignProperties(appearance) } - extractConditionalSettings(&widget, w) + extractConditionalSettings(ctx, &widget, w) for _, tp := range getBsonArrayElements(w["TabPages"]) { tpMap, ok := tp.(map[string]any) if !ok { @@ -253,7 +253,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.HeaderMode = headerMode } } - extractConditionalSettings(&widget, w) + extractConditionalSettings(ctx, &widget, w) children := getBsonArrayElements(w["Widgets"]) if children != nil { for _, c := range children { @@ -269,7 +269,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s Type: typeName, Name: name, } - extractConditionalSettings(&widget, w) + extractConditionalSettings(ctx, &widget, w) // Extract CSS class, style, and design properties from Appearance if appearance, ok := w["Appearance"].(map[string]any); ok { @@ -351,7 +351,9 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.LabelWidth = extractDataViewLabelWidth(w) widget.ReadOnlyStyle = extractReadOnlyStyle(ctx, w) widget.ShowFooter, _ = w["ShowFooter"].(bool) - widget.Children = parseDataViewChildren(ctx, w, widget.EntityContext) + widget.Children = withQualifiedAttrs(ctx, widget.DataSource, func() []rawWidget { + return parseDataViewChildren(ctx, w, widget.EntityContext) + }) return []rawWidget{widget} case "Forms$TextBox", "Pages$TextBox": @@ -469,7 +471,9 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s } else if inheritedCtx != "" { widget.EntityContext = inheritedCtx } - widget.Children = extractGalleryContent(ctx, w, widget.EntityContext) + widget.Children = withQualifiedAttrs(ctx, widget.DataSource, func() []rawWidget { + return extractGalleryContent(ctx, w, widget.EntityContext) + }) widget.FilterWidgets = extractGalleryFilters(ctx, w) } // For filter widgets, extract filter attributes and expression @@ -696,7 +700,9 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s if onClick := asActionMap(w["ClickAction"]); onClick != nil { widget.Action = extractButtonAction(ctx, map[string]any{"Action": onClick}) } - widget.Children = parseListViewContent(ctx, w, widget.EntityContext) + widget.Children = withQualifiedAttrs(ctx, widget.DataSource, func() []rawWidget { + return parseListViewContent(ctx, w, widget.EntityContext) + }) return []rawWidget{widget} default: @@ -1027,7 +1033,12 @@ func extractAttributeRef(ctx *ExecContext, w map[string]any) string { if _, ok := attrRef["Attribute"].(string); !ok { return "" } - return columnAttributeFromRef(attrRef) + path := columnAttributeFromRef(attrRef) + if !strings.Contains(path, "/") { + // A plain binding: qualified where no entity is in scope (describeAttr). + return describeAttr(ctx, extractString(attrRef["Attribute"])) + } + return path } // parseGalleryContent extracts the content widget from a Gallery. diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index ecbded2054..24258e5cb8 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -634,7 +634,7 @@ func extractTextTemplateParameters(ctx *ExecContext, textTemplate map[string]any result = append(result, "$"+sourceVarName+"."+attrName) } else { // No SourceVariable - use short attribute name - result = append(result, shortAttributeName(attr)) + result = append(result, describeAttr(ctx, attr)) } continue } @@ -893,7 +893,7 @@ func extractCustomWidgetPropertyAttributeRef(ctx *ExecContext, w map[string]any, } if attrRef, ok := value["AttributeRef"].(map[string]any); ok && attrRef != nil { if attr, ok := attrRef["Attribute"].(string); ok && attr != "" { - return shortAttributeName(attr) + return describeAttr(ctx, attr) } } } @@ -1098,7 +1098,7 @@ func extractCustomWidgetPropertyAttributes(ctx *ExecContext, w map[string]any, p // Check for AttributeRef if attrRef, ok := objValue["AttributeRef"].(map[string]any); ok && attrRef != nil { if attr, ok := attrRef["Attribute"].(string); ok && attr != "" { - result = append(result, shortAttributeName(attr)) + result = append(result, describeAttr(ctx, attr)) } } } @@ -1167,7 +1167,7 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any) []rawExplicit if attr := extractString(attrRef["Attribute"]); attr != "" { result = append(result, rawExplicitProp{ Key: propKey, - Value: shortAttributeName(attr), + Value: describeAttr(ctx, attr), IsRef: true, }) continue diff --git a/mdl/executor/cmd_pages_describe_unresolved_context_test.go b/mdl/executor/cmd_pages_describe_unresolved_context_test.go new file mode 100644 index 0000000000..72f2fbede4 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_unresolved_context_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// A data view over a flow the project does not contain — Feedback v4.0.2's +// excluded ShareFeedback_Logo, over FeedbackModule.DS_FeedbackForm — has no +// entity in scope for DESCRIBE or for exec. Every binding inside it still +// carries its full name in storage (`FeedbackModule.Feedback.Subject`), but +// DESCRIBE printed the bare attribute, which exec cannot qualify: written +// anyway, a bare `ImageB64` image parameter left a project `mx check` could not +// LOAD. So inside such a container the qualified name is kept. +func unresolvedContextDataView(ds map[string]any) map[string]any { + attrRef := func(qn string) map[string]any { + return map[string]any{"$Type": "DomainModels$AttributeRef", "Attribute": qn, "EntityRef": nil} + } + return map[string]any{ + "$Type": "Forms$DataView", + "Name": "dataView5", + "DataSource": ds, + "Widgets": []any{int32(2), + map[string]any{"$Type": "Forms$TextBox", "Name": "feedback_subject", + "AttributeRef": attrRef("FeedbackModule.Feedback.Subject")}, + map[string]any{"$Type": "Forms$DynamicText", "Name": "text1", + "Content": map[string]any{"$Type": "Forms$ClientTemplate", + "Template": map[string]any{"$Type": "Texts$Text", "Items": []any{int32(3), + map[string]any{"$Type": "Texts$Translation", "LanguageCode": "en_US", "Text": "Image: {1}"}}}, + "Parameters": []any{int32(2), + map[string]any{"$Type": "Forms$ClientTemplateParameter", "AttributeRef": attrRef("FeedbackModule.Feedback.ImageB64")}}, + }}, + map[string]any{"$Type": "Forms$TextBox", "Name": "textBox1", + "AttributeRef": attrRef("FeedbackModule.Feedback.SubmitterEmail"), + "ConditionalVisibilitySettings": map[string]any{ + "$Type": "Forms$ConditionalVisibilitySettings", + "Attribute": "FeedbackModule.Feedback._showEmail", + "Conditions": []any{int32(2), + map[string]any{"$Type": "Enumerations$Condition", "AttributeValue": "true", "EditableVisible": true}, + map[string]any{"$Type": "Enumerations$Condition", "AttributeValue": "false", "EditableVisible": false}}, + }}, + }, + } +} + +func describeWidget(t *testing.T, w map[string]any) string { + t.Helper() + var buf bytes.Buffer + ctx := (&Executor{}).newExecContext(context.Background()) + ctx.Output = &buf + for _, rw := range parseRawWidget(ctx, w) { + outputWidgetMDLV3(ctx, rw, 1) + } + return buf.String() +} + +func TestDescribe_UnresolvedFlowContext_KeepsQualifiedBindings(t *testing.T) { + got := describeWidget(t, unresolvedContextDataView(map[string]any{ + "$Type": "Forms$NanoflowSource", "Nanoflow": "FeedbackModule.DS_FeedbackForm", + })) + for _, want := range []string{ + "Attribute: FeedbackModule.Feedback.Subject", + "{1} = FeedbackModule.Feedback.ImageB64", + "Attribute: FeedbackModule.Feedback.SubmitterEmail", + "Visible: FeedbackModule.Feedback._showEmail in (true)", + } { + if !strings.Contains(got, want) { + t.Errorf("describe output lacks %q — a bare binding under an unresolvable flow cannot be qualified on exec:\n%s", want, got) + } + } + if _, errs := visitor.Build("create page M.P (Title: 'x', Layout: A.L) {\n" + got + "}\n"); len(errs) > 0 { + t.Fatalf("describe output does not parse: %v\n%s", errs, got) + } +} + +// Where the entity IS known the short form is unchanged. +func TestDescribe_ResolvedContext_KeepsShortBindings(t *testing.T) { + got := describeWidget(t, unresolvedContextDataView(map[string]any{ + "$Type": "Forms$DataViewSource", + "EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "FeedbackModule.Feedback"}, + "SourceVariable": map[string]any{"$Type": "Forms$PageVariable", "PageParameter": "Feedback"}, + })) + for _, want := range []string{"Attribute: Subject", "{1} = ImageB64", "Visible: _showEmail in (true)"} { + if !strings.Contains(got, want) { + t.Errorf("describe output lacks %q:\n%s", want, got) + } + } +} diff --git a/mdl/executor/cmd_pages_visible_when_test.go b/mdl/executor/cmd_pages_visible_when_test.go index dd2a4e1fde..37cc6e3e69 100644 --- a/mdl/executor/cmd_pages_visible_when_test.go +++ b/mdl/executor/cmd_pages_visible_when_test.go @@ -150,3 +150,28 @@ func TestDescribe_VisibleWhen(t *testing.T) { t.Errorf("describe output does not parse: %v\n%s", errs, got) } } + +// Under a data container whose flow cannot be resolved there is no entity in +// scope, and DESCRIBE emits the attribute qualified: `Visible: +// Mod.Entity.Attr in (…)` must build from the name alone. +func TestVisibleWhen_QualifiedWithoutContext(t *testing.T) { + cvs, err := buildVisibleWhen(t, visibleWhenPB(""), "M.Job.IsLocal", "true") + if err != nil { + t.Fatalf("build: %v", err) + } + if cvs == nil || cvs.Attribute != "M.Job.IsLocal" || len(cvs.Conditions) != 2 { + t.Fatalf("got %+v", cvs) + } + // An inherited attribute named on the specialization is stored against + // the DECLARING entity, as the bare form is. + cvs, err = buildVisibleWhen(t, visibleWhenPB(""), "M.SpecialJob.Status", "Running") + if err != nil { + t.Fatalf("build: %v", err) + } + if cvs.Attribute != "M.Job.Status" { + t.Errorf("Attribute = %q, want M.Job.Status", cvs.Attribute) + } + if _, err := buildVisibleWhen(t, visibleWhenPB(""), "M.Job.Nope", "true"); err == nil { + t.Error("an unknown qualified attribute must be refused") + } +} diff --git a/mdl/executor/exec_context.go b/mdl/executor/exec_context.go index b225f2236a..9508a966c2 100644 --- a/mdl/executor/exec_context.go +++ b/mdl/executor/exec_context.go @@ -34,6 +34,12 @@ type ExecContext struct { // Output is the writer for user-visible output (with line-limit guard). Output io.Writer + // describeQualifyAttrs is set by DESCRIBE PAGE while it reads the widgets + // inside a data container whose flow cannot be resolved: no entity is in + // scope there, so an attribute binding keeps its stored Module.Entity.Attr + // name rather than the bare one exec could not qualify. See describeAttr. + describeQualifyAttrs bool + // Format controls output formatting (table, json, etc.). Format OutputFormat diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index abf45b7515..f1ebeee7dc 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -898,7 +898,14 @@ func (sc *scriptContext) relaxExcludedWidgetRefs(kind, name string, widgets []*a ref := e[strings.LastIndex(e, ": ")+2:] switch { case refs.dataSources[ref]: - blocking = append(blocking, e+" (data source)") + // The flow is kept by name, but it is what puts an entity in scope: + // only a container whose bindings are all qualified can be written. + if bare := unscopedBindings(widgets, ref); len(bare) > 0 { + blocking = append(blocking, e+" (data source) — and these bindings inside it are not qualified, "+ + "so nothing can resolve them: "+strings.Join(bare, ", ")) + continue + } + warnings = append(warnings, e+" (data source; the bindings inside it are qualified)") case strings.HasPrefix(e, "entity not found"): // The builder resolves an entity to write it (create_object and the // like), so exec would refuse it anyway; say so here instead of @@ -915,11 +922,94 @@ func (sc *scriptContext) relaxExcludedWidgetRefs(kind, name string, widgets []*a return nil } return mdlerrors.NewValidationf("%s '%s' is excluded, but a data source or entity it names does not exist:\n - %s\n"+ - " An excluded document may keep a dangling action target, but not a dangling data source: the\n"+ - " flow or entity decides what the widgets inside it bind to, and without it those bindings are\n"+ - " written unqualified — which leaves a project Mendix cannot load. Create the missing document,\n"+ - " or leave this %s as it is stored.", - kind, name, strings.Join(blocking, "\n - "), kind) + " An excluded document may keep a dangling flow, but the widgets inside a data container bind\n"+ + " against the entity its flow returns — with the flow missing, a bare binding cannot be\n"+ + " qualified, and one written bare leaves a project Mendix cannot load. Qualify those bindings\n"+ + " (Module.Entity.Attribute, as `describe` writes them there), or create the missing document.", + kind, name, strings.Join(blocking, "\n - ")) +} + +// unscopedBindings names the attribute bindings that cannot be resolved inside +// the data container(s) whose data source is the missing flow ref: bare +// attributes, `$currentObject/…` paths and association hops, all of which +// resolve against the entity that flow would have returned. Descent stops at a +// nested container with a data source of its own, which scopes its children. +// +// Covered: `Attribute:`, `CaptionAttribute:`, `Visible: Attr in (…)` and +// template parameters (`…Params: [{1} = Attr]`). Anything else is caught by +// the page writer's refusal of a bare attribute reference. +func unscopedBindings(widgets []*ast.WidgetV3, ref string) []string { + var out []string + var inScope func(ws []*ast.WidgetV3) + inScope = func(ws []*ast.WidgetV3) { + for _, w := range ws { + if w == nil { + continue + } + if _, own := w.Properties["DataSource"].(*ast.DataSourceV3); own { + continue // its own data source decides its children's scope + } + for _, b := range bareBindingsOf(w) { + out = append(out, fmt.Sprintf("%s `%s` (%s)", strings.ToLower(w.Type), w.Name, b)) + } + inScope(w.Children) + } + } + var find func(ws []*ast.WidgetV3) + find = func(ws []*ast.WidgetV3) { + for _, w := range ws { + if w == nil { + continue + } + if ds, ok := w.Properties["DataSource"].(*ast.DataSourceV3); ok && ds.Reference == ref && + (ds.Type == "microflow" || ds.Type == "nanoflow") { + for _, b := range bareBindingsOf(w) { // the container's own bindings, e.g. its visibility + out = append(out, fmt.Sprintf("%s `%s` (%s)", strings.ToLower(w.Type), w.Name, b)) + } + inScope(w.Children) + continue + } + find(w.Children) + } + } + find(widgets) + return out +} + +// bareBindingsOf lists a widget's attribute bindings that need an entity in +// scope to resolve. +func bareBindingsOf(w *ast.WidgetV3) []string { + var out []string + needsScope := func(v string) bool { + switch { + case v == "", strings.HasPrefix(v, "'"): + return false // unset, or a literal + case strings.HasPrefix(v, "$"): + return strings.HasPrefix(strings.ToLower(v), "$currentobject/") + } + return strings.Contains(v, "/") || strings.Count(v, ".") < 2 + } + for _, key := range []string{"Attribute", "CaptionAttribute"} { + if v, ok := w.Properties[key].(string); ok && needsScope(v) { + out = append(out, key+": "+v) + } + } + if vw, ok := w.Properties["VisibleWhen"].(*ast.VisibleWhenV3); ok && needsScope(vw.Attribute) { + out = append(out, "Visible: "+vw.Attribute+" in (…)") + } + for key, v := range w.Properties { + params, ok := v.([]ast.ParamAssignmentV3) + if !ok { + continue + } + for _, p := range params { + if s, ok := p.Value.(string); ok && needsScope(s) { + out = append(out, fmt.Sprintf("%s {%d} = %s", key, p.Index, s)) + } + } + } + sort.Strings(out) + return out } // carriedExclusion reports whether exec will write the page or snippet named diff --git a/mdl/executor/validate_excluded_page_test.go b/mdl/executor/validate_excluded_page_test.go index 2b772141c8..373a4b04fe 100644 --- a/mdl/executor/validate_excluded_page_test.go +++ b/mdl/executor/validate_excluded_page_test.go @@ -143,15 +143,27 @@ func TestBuildExcludedPage_DanglingFlowIsKeptByName(t *testing.T) { }) } - // A data source is never tolerated, excluded or not. + // A dangling data-source flow on an excluded page is kept by name too, with + // no entity in scope: the bindings inside must then be qualified, which + // DESCRIBE now emits there and the check verifies (checkUnscopedBindings). ds := &ast.DataSourceV3{Type: "nanoflow", Reference: "Feedback.DS_FeedbackForm"} - if _, _, err := newPB(true).buildDataSourceV3(ds); err == nil { - t.Error("a dangling data source must be refused even on an excluded page") + src, entity, err := newPB(true).buildDataSourceV3(ds) + if err != nil { + t.Fatalf("excluded page, dangling data-source flow: %v", err) + } + if nf, ok := src.(*pages.NanoflowSource); !ok || nf.Nanoflow != "Feedback.DS_FeedbackForm" || entity != "" { + t.Errorf("want the nanoflow source kept by name with no entity in scope; got %#v, %q", src, entity) + } + // CONTROL: a live page still refuses it. + if _, _, err := newPB(false).buildDataSourceV3(ds); err == nil { + t.Error("a live page must still refuse a dangling data source") } } -// A dangling data source on an excluded page still blocks, with the reason; -// the action targets beside it are still only warnings. +// A dangling data source on an excluded page blocks when a binding inside it is +// BARE — no entity is in scope to qualify it, and a bare attribute reference +// makes the project unloadable; the refusal names it. The action targets +// beside it are still only warnings. func TestValidateExcludedPage_DanglingDataSourceBlocks(t *testing.T) { ctx, _ := newMockCtx(t) sc := newScriptContext() @@ -162,12 +174,13 @@ func TestValidateExcludedPage_DanglingDataSourceBlocks(t *testing.T) { Properties: map[string]any{ "DataSource": &ast.DataSourceV3{Type: "nanoflow", Reference: "Feedback.DS_FeedbackForm"}, }, - Children: actionWidget("nanoflow", "Feedback.ACT_ClearForm"), + Children: append(actionWidget("nanoflow", "Feedback.ACT_ClearForm"), + &ast.WidgetV3{Name: "feedback_subject", Type: "textbox", Properties: map[string]any{"Attribute": "Subject"}}), }} err := validateWithContext(ctx, s, sc) - if err == nil || !strings.Contains(err.Error(), "nanoflow not found: Feedback.DS_FeedbackForm (data source)") || - !strings.Contains(err.Error(), "is excluded, but a data source") { + if err == nil || !strings.Contains(err.Error(), "Feedback.DS_FeedbackForm") || + !strings.Contains(err.Error(), "feedback_subject") || !strings.Contains(err.Error(), "Subject") { t.Fatalf("want the data source refused with its reason; got %v", err) } if strings.Contains(err.Error(), "ACT_ClearForm") { @@ -177,3 +190,37 @@ func TestValidateExcludedPage_DanglingDataSourceBlocks(t *testing.T) { t.Errorf("the action target must still be a warning; got %q", sc.warnings) } } + +// The same page with every binding inside the container QUALIFIED — the form +// DESCRIBE emits under an unresolvable flow — is written: the missing flow is a +// warning like any other dangling reference on an excluded page. +func TestValidateExcludedPage_DanglingDataSource_QualifiedBindingsAreWarnings(t *testing.T) { + ctx, _ := newMockCtx(t) + sc := newScriptContext() + sc.modules["Feedback"] = true + s := excludedPageStmt(true) + s.Widgets = []*ast.WidgetV3{{ + Name: "dv", Type: "dataview", + Properties: map[string]any{ + "DataSource": &ast.DataSourceV3{Type: "nanoflow", Reference: "Feedback.DS_FeedbackForm"}, + }, + Children: []*ast.WidgetV3{ + {Name: "feedback_subject", Type: "textbox", Properties: map[string]any{"Attribute": "Feedback.Feedback.Subject"}}, + {Name: "textBox1", Type: "textbox", Properties: map[string]any{ + "Attribute": "Feedback.Feedback.SubmitterEmail", + "VisibleWhen": &ast.VisibleWhenV3{Attribute: "Feedback.Feedback._showEmail", Values: []string{"true"}}, + }}, + {Name: "text1", Type: "dynamictext", Properties: map[string]any{ + "Content": "Image: {1}", + "ContentParams": []ast.ParamAssignmentV3{{Index: 1, Value: "Feedback.Feedback.ImageB64"}}, + }}, + }, + }} + if err := validateWithContext(ctx, s, sc); err != nil { + t.Fatalf("qualified bindings under a dangling flow must not block an excluded page; got:\n%v", err) + } + joined := strings.Join(sc.warnings, "\n") + if !strings.Contains(joined, "Feedback.DS_FeedbackForm") { + t.Errorf("the missing data-source flow must still be reported; got %q", sc.warnings) + } +} diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index 9c10023216..e717507a53 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -513,7 +513,7 @@ widgetPropertyV3 | WIDTH COLON NUMBER_LITERAL // Width: 200 | HEIGHT COLON NUMBER_LITERAL // Height: 100 | VISIBLE COLON xpathConstraint // Visible: [IsActive = true] - | VISIBLE COLON attributePathV3 IN LPAREN visibleValueV3 (COMMA visibleValueV3)* RPAREN // Visible: Status in (Running, empty) + | VISIBLE COLON qualifiedName IN LPAREN visibleValueV3 (COMMA visibleValueV3)* RPAREN // Visible: Status in (Running, empty) | Mod.Entity.Attr in (…) | VISIBLE COLON propertyValueV3 // Visible: false | EDITABLE COLON xpathConstraint // Editable: [Status != 'Closed'] | EDITABLE COLON propertyValueV3 // Editable: Never | Always diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index 3bdc4a31e0..94ffd8be4e 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -833,7 +833,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg if propCtx.VISIBLE() != nil { // `Visible: Attr in (v1, …)` — Studio Pro's "based on attribute value". if propCtx.IN() != nil { - vw := &ast.VisibleWhenV3{Attribute: buildAttributePathV3(propCtx.AttributePathV3())} + vw := &ast.VisibleWhenV3{Attribute: getQualifiedNameText(propCtx.QualifiedName())} for _, v := range propCtx.AllVisibleValueV3() { vw.Values = append(vw.Values, unquoteIdentifier(v.GetText())) } From db949df8441453f94135783c6f8aa4d7bf75ae6b Mon Sep 17 00:00:00 2001 From: Ako Date: Fri, 25 Sep 2026 09:36:59 +0000 Subject: [PATCH 2/2] style: gofmt the unresolved-context describe test Co-Authored-By: Claude Opus 5.5 --- mdl/executor/cmd_pages_describe_unresolved_context_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mdl/executor/cmd_pages_describe_unresolved_context_test.go b/mdl/executor/cmd_pages_describe_unresolved_context_test.go index 72f2fbede4..dd63af4e5a 100644 --- a/mdl/executor/cmd_pages_describe_unresolved_context_test.go +++ b/mdl/executor/cmd_pages_describe_unresolved_context_test.go @@ -82,8 +82,8 @@ func TestDescribe_UnresolvedFlowContext_KeepsQualifiedBindings(t *testing.T) { // Where the entity IS known the short form is unchanged. func TestDescribe_ResolvedContext_KeepsShortBindings(t *testing.T) { got := describeWidget(t, unresolvedContextDataView(map[string]any{ - "$Type": "Forms$DataViewSource", - "EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "FeedbackModule.Feedback"}, + "$Type": "Forms$DataViewSource", + "EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "FeedbackModule.Feedback"}, "SourceVariable": map[string]any{"$Type": "Forms$PageVariable", "PageParameter": "Feedback"}, })) for _, want := range []string{"Attribute: Subject", "{1} = ImageB64", "Visible: _showEmail in (true)"} {