diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 50e467af8..92fb3581f 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -702,4 +702,5 @@ {"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"} {"area": "mdl/executor", "symptom": "describe → exec of Administration.Account_New fails `mx check` with [CE0642] \"Property 'Caption' is required.\" at Combo box 'comboBox2' (Account_Edit comboBox4 too); check and exec report success", "cause": "The ComboBox caption is an EXPRESSION (optionsSourceAssociationCaptionType=expression, optionsSourceAssociationCaptionExpression='$currentObject/Description'); describe read only the attribute caption, so the widget was rewritten with none. The write side already worked via the explicit-property pass, but MDL-WIDGET06 claimed both keys 'will be dropped'", "file": "`mdl/executor/cmd_pages_describe_parse.go` (combobox branch), `cmd_pages_describe_output.go`, `validate_widget_explicit_writable.go` (`persistedByExplicitPass`), `validate_widgets.go` (MDL-WIDGET06)", "insight": "**Resolve pluggable-widget properties by key before theorising**: a 20-line script mapping each Property's TypePointer to its PropertyKey through the widget's own Type.ObjectType settled the cause in one run, where the ndsl dump shows only anonymous values. Then TEST THE WRITE SIDE before building one: adding the two storage keys to the describe output by hand persisted both and built clean, which shrank the fix to describe + a false warning — no new syntax. A validator rule that asserts 'not persisted' must be tied to what the write path handles (here: the explicit pass writes Expression/TextTemplate/Attribute and scalar types), or it goes stale when the writer grows; the #643 test pinned the stale claim. A dedicated extractor for a 'known' pluggable widget silently drops every property it does not map — generic widgets emit them, known ones do not", "refs": ["ako/mxcli#664", "ako/mxcli#643"], "ce": ["CE0642"], "rules": ["MDL-WIDGET06"], "date": "2026-09-25"} diff --git a/.claude/skills/mendix/check-syntax/SKILL.md b/.claude/skills/mendix/check-syntax/SKILL.md index be4b1cee6..973912c2e 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 bdb7e8a4a..3aad84837 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 000000000..37b8d32ae --- /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 000000000..ead207825 --- /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 000000000..83c2c2f00 --- /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 94e80bdd8..6c15d5b80 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 907315610..2921ad002 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 d468f89b1..77e70dc33 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 12b57cda5..2c7f7c86f 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 1cefe7fe0..cf6060b5c 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 73547933d..4b71321b0 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 66940cace..5508d3814 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 8ccd8cc98..7d7a64acf 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -1868,7 +1868,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 } @@ -2085,5 +2085,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 9ecda6310..481d0256c 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": @@ -477,7 +479,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 @@ -704,7 +708,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: @@ -1035,7 +1041,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 5daa553c9..25f28226c 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) } } } @@ -1119,7 +1119,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)) } } } @@ -1188,7 +1188,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 000000000..dd63af4e5 --- /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 dd2a4e1fd..37cc6e3e6 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 b225f2236..9508a966c 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 abf45b751..f1ebeee7d 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 2b772141c..373a4b04f 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 9c1002321..e717507a5 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 3bdc4a31e..94ffd8be4 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())) }