diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 58a93358a..7c2cc34d1 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -42,6 +42,8 @@ to the symptom table below, so the next similar issue costs fewer reads. | `dynamictext x (Content: '')` builds with **CE0720** "Place holder index 1 is greater than 0" — `mxcli check` ✓, describe shows `Content: '{1}'` with no params | The builder unconditionally defaulted empty content to the template `{1}`, creating a placeholder with no matching parameter (orphaned) | `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildDynamicTextV3`, final `content == ""` guard) | Only default to `{1}` when there IS a parameter (`autoGeneratedParams`/`explicitParams`); empty content with no params is a literal empty template. Test `TestBuildDynamicTextV3_EmptyContent`; repro `mdl-examples/bug-tests/traceops-9-10-17-dynamictext-listview.mdl`. traceops #9 | | `dynamictext s (Content: '$318')` builds with **CE0402/CE1613** ("attribute '$318' no longer exists") — the literal was turned into an unbound `{1}` param | The auto-bind check treated ANY `$`-prefixed content as a variable; `$318` (dollar + digits) is not a valid Mendix variable | `mdl/executor/cmd_pages_builder_v3_widgets.go` (`isDynamicTextVariableRef` / `dynamicTextVariableRe`) | Treat `$` as a variable ONLY when followed by a letter/underscore (`^\$[A-Za-z_]`); `$318` stays literal content. Tests `TestBuildDynamicTextV3_DollarDigitLiteral`, `TestIsDynamicTextVariableRef`. traceops #10 | | `listview lv (… PageSize: 200)` always pages at 20 — `mxcli check` ✓, `mx check` ✓, describe shows no PageSize | The property parsed into the AST but three layers ignored it: `buildListViewV3` hardcoded `PageSize: 20`, the describe parse never read it, and the listview describe formatter never emitted it | `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildListViewV3`) + `cmd_pages_describe_parse.go` (Forms$ListView case) + `cmd_pages_describe_output.go` (listview case) | Read `w.GetIntProp("PageSize")` on write; read `w["PageSize"]` on describe; emit a non-default PageSize in the listview formatter. Test `TestBuildListViewV3_PageSize`. traceops #17 | +| `mxcli oql -p app.mpr "…"` fails against a `mxcli run --local` app — first `admin password required`, then (with the password) `OQL error: Action not found.` and 0 rows — even though the same query works under `mxcli docker up` | Two-part gap. (a) The local runtime booted the JVM with no system properties, so `/dev/preview_execute_oql` was never mounted (docker mode passes `-Dmendix.live-preview=enabled -Dmendix.running.locally.by.studiopro=true` via docker-compose; the local boot did not). (b) `run --local` never printed or wrote the admin password, and `mxcli oql` errored instead of defaulting to it | `cmd/mxcli/docker/localboot.go` (`LocalRuntimeOptions.jvmArgs` — new) + `cmd/mxcli/docker/m2ee.go` (`resolveM2EEDefaults` token fallback) + `cmd/mxcli/docker/oql.go` (Action-not-found hint) + `cmd/mxcli/docker/runlocal.go` (banner) | Always pass the two live-preview `-D` flags in `jvmArgs` (run --local is always DTAPMode=D, matching docker); default the oql admin token to `defaultLocalAdminPass` when nothing else supplies it (admin API is loopback-only); branch the "Action not found" hint to cover the `--local` case; print a `Query data: mxcli oql …` line in the run banner. Tests `TestJVMArgs`, `TestResolveM2EEDefaults_Defaults`. traceops #36 | +| A custom Starlark lint rule can't detect a **microflow-datasource** widget (e.g. a ListView with no DB pushdown) — `widget.microflow_ref` is unavailable, so a rule keyed on it returns zero hits at any threshold, even though `CATALOG.WIDGETS.MicroflowRef` is populated | The linter's `Widget` projection (struct + `Widgets()` query + Starlark dict) dropped `MicroflowRef`/`NanoflowRef` — the catalog records them but they never reached the rule tier | `mdl/linter/context.go` (`Widget` struct, `Widgets()` SELECT+Scan) + `mdl/linter/starlark.go` (`widgetToStarlark`) | Carry `MicroflowRef`/`NanoflowRef` through from the catalog and expose `microflow_ref`/`nanoflow_ref` on the Starlark widget struct (mirroring `entity_ref`). Doc: `.claude/skills/mendix/write-lint-rules.md` widget field table. Test `TestWidgets_ProjectsMicroflowNanoflowRef`. traceops #35 | | A `/** … */` doc comment between `alter entity … add attribute` clauses is a **parse error** (`no viable alternative at input '/**'`) | `alterEntityAction` accepted a doc comment only INSIDE an `attributeDefinition` (after the ADD ATTRIBUTE keyword), not between clauses. `--` line comments are NOT an equivalent workaround — they are discarded, whereas a `/** */` doc comment is persisted as the attribute's Mendix documentation | `mdl/grammar/domains/MDLDomainModel.g4` (`alterEntityAction`) + `mdl/visitor/visitor_entity.go` (`ExitAlterEntityAction` ADD branch) | Add `docComment?` before `ADD ATTRIBUTE`/`ADD COLUMN`; the visitor attaches it as the added attribute's documentation when the attributeDefinition has none. `make grammar` regenerates the parser (not committed). Test `TestAlterEntityAddAttributeDocComment`; repro `mdl-examples/bug-tests/traceops-27-doc-comment-between-clauses.mdl`. traceops #27 | | `combobox (Association: Mod.Ref, …)` drops the binding — `mxcli check` ✓ but MxBuild fails **CE0642** "Property 'Attribute' is required" | The widget engine's `Association` source read the reference only from the `attribute:` keyword (`w.GetAttribute()`), so an explicit `Association:` keyword was ignored and the widget fell back to enumeration mode | `mdl/executor/widget_engine.go` (`case "Association"`) + `mdl/executor/validate_widgets.go` (`validateComboBoxAssociation`) | Read the reference from `Association:` OR `attribute:`; and add MDL-WIDGET16 flagging an association combobox that lacks the required `datasource:` (option list). A complete association combobox needs reference + `datasource:` + `CaptionAttribute:`. Tests `TestValidateComboBoxAssociation`; repro `mdl-examples/bug-tests/traceops-23-combobox-association.mdl`. traceops #23 | | A bare MDL keyword used as a WIDGET name (`container body`, `dynamictext content`) is a parse error (`mismatched input 'body' expecting {IDENTIFIER, QUOTED_IDENTIFIER}`) | `widgetV3`'s name only accepted `IDENTIFIER \| QUOTED_IDENTIFIER`, not `keyword` — unlike `attributeName`/placeholder names | `mdl/grammar/domains/MDLPage.g4` (`widgetV3`) + `mdl/visitor/visitor_page_v3.go` (`buildWidgetV3` name extraction) | Add `keyword` to the widget-name alternatives; the visitor reads `wCtx.Keyword()` too. `make grammar` regenerates the parser. Test `TestKeywordWidgetName`; repro `mdl-examples/bug-tests/traceops-11-12-16-strings-names.mdl`. traceops #12 | diff --git a/.claude/skills/mendix/write-lint-rules.md b/.claude/skills/mendix/write-lint-rules.md index 67a6a76c5..4692bfbee 100644 --- a/.claude/skills/mendix/write-lint-rules.md +++ b/.claude/skills/mendix/write-lint-rules.md @@ -194,6 +194,8 @@ def check(): | `module_name` | string | `"Sales"` | | `entity_ref` | string | Referenced entity qualified name | | `attribute_ref` | string | Referenced attribute path | +| `microflow_ref` | string | Action/datasource microflow qualified name (e.g. a microflow-datasource ListView), else `""` | +| `nanoflow_ref` | string | Action/datasource nanoflow qualified name, else `""` | ### snippet | Property | Type | Example | diff --git a/cmd/mxcli/docker/localboot.go b/cmd/mxcli/docker/localboot.go index a11c9d2b1..08cfa3f9c 100644 --- a/cmd/mxcli/docker/localboot.go +++ b/cmd/mxcli/docker/localboot.go @@ -141,6 +141,24 @@ func (o *LocalRuntimeOptions) launcherJar() string { return filepath.Join(o.runtimeDir(), "launcher", "runtimelauncher.jar") } +// jvmArgs builds the JVM argument list for the local runtime. +// +// The two -Dmendix.* system properties mount the runtime's development servlets, +// including /dev/preview_execute_oql — the endpoint `mxcli oql` calls. Docker +// mode passes the same flags via docker-compose (see +// templates/docker-compose.yml); the local boot must set them too, or `mxcli +// oql` against a `mxcli run --local` app fails with "Action not found" and +// silently returns 0 rows (findings #36). `run --local` is always a development +// loop (it forces DTAPMode=D), so enabling live preview unconditionally matches +// what docker mode already does. +func (o *LocalRuntimeOptions) jvmArgs() []string { + return []string{ + "-Dmendix.live-preview=enabled", + "-Dmendix.running.locally.by.studiopro=true", + "-jar", o.launcherJar(), o.DeployDir, + } +} + // localRuntimeEnv builds the environment for the runtime JVM, layered on the // current process environment. PrepareMxCommand later adds the FreeType fix. func localRuntimeEnv(o LocalRuntimeOptions) []string { @@ -377,7 +395,7 @@ func StartLocalRuntime(opts LocalRuntimeOptions) (*LocalRuntime, error) { // boot and for a restart (config is per-process and must be re-applied). func (rt *LocalRuntime) spawnAndConfigure() error { javaExe := filepath.Join(rt.opts.JavaHome, "bin", "java") - cmd := exec.Command(javaExe, "-jar", rt.opts.launcherJar(), rt.opts.DeployDir) + cmd := exec.Command(javaExe, rt.opts.jvmArgs()...) cmd.Dir = rt.opts.runtimeDir() cmd.Env = localRuntimeEnv(rt.opts) if rt.opts.Trace { diff --git a/cmd/mxcli/docker/localboot_test.go b/cmd/mxcli/docker/localboot_test.go index f67704a1e..1fb48166e 100644 --- a/cmd/mxcli/docker/localboot_test.go +++ b/cmd/mxcli/docker/localboot_test.go @@ -55,6 +55,33 @@ func TestPathHelpers(t *testing.T) { } } +func TestJVMArgs(t *testing.T) { + o := testLocalOpts() + args := o.jvmArgs() + joined := strings.Join(args, " ") + // The live-preview dev flags must be present so `mxcli oql` can reach a + // `run --local` app via /dev/preview_execute_oql (findings #36). + for _, want := range []string{ + "-Dmendix.live-preview=enabled", + "-Dmendix.running.locally.by.studiopro=true", + } { + if !strings.Contains(joined, want) { + t.Errorf("jvmArgs missing %q; got %v", want, args) + } + } + // -jar and its two positional args (launcher jar + deploy dir) must still be + // last, in order, so the JVM launches the runtime. + if len(args) < 3 || args[len(args)-3] != "-jar" { + t.Fatalf("jvmArgs must end with -jar ; got %v", args) + } + if args[len(args)-2] != o.launcherJar() { + t.Errorf("launcher jar = %q, want %q", args[len(args)-2], o.launcherJar()) + } + if args[len(args)-1] != o.DeployDir { + t.Errorf("deploy dir = %q, want %q", args[len(args)-1], o.DeployDir) + } +} + func TestLocalRuntimeEnv(t *testing.T) { o := testLocalOpts() env := localRuntimeEnv(o) diff --git a/cmd/mxcli/docker/m2ee.go b/cmd/mxcli/docker/m2ee.go index 780466566..2c8c24d2f 100644 --- a/cmd/mxcli/docker/m2ee.go +++ b/cmd/mxcli/docker/m2ee.go @@ -373,7 +373,17 @@ func resolveM2EEDefaults(opts *M2EEOptions) error { } } - // Token: flag > env > .env > error + // Token: flag > env > .env > local dev default. + // + // A `mxcli run --local` app has no .docker/.env and never prints the password + // it used, so before this fallback `mxcli oql` against a local run failed with + // "admin password required" (findings #36). The local runtime always boots + // with defaultLocalAdminPass, and the admin API binds to loopback only, so + // using it as the last resort makes `mxcli oql` work against `run --local` + // with no configuration. A docker app that genuinely uses a different password + // records it in .docker/.env (loaded above); if it somehow doesn't, the wrong + // password surfaces as a clear "authentication failed" rather than a silent + // miss. if opts.Token == "" { if v := os.Getenv("M2EE_ADMIN_PASS"); v != "" { opts.Token = v @@ -382,7 +392,7 @@ func resolveM2EEDefaults(opts *M2EEOptions) error { } } if opts.Token == "" { - return fmt.Errorf("admin password required: set --token, M2EE_ADMIN_PASS env var, or configure .docker/.env") + opts.Token = defaultLocalAdminPass } return nil diff --git a/cmd/mxcli/docker/m2ee_test.go b/cmd/mxcli/docker/m2ee_test.go index cf21f3369..ffb0141df 100644 --- a/cmd/mxcli/docker/m2ee_test.go +++ b/cmd/mxcli/docker/m2ee_test.go @@ -113,13 +113,16 @@ func TestResolveM2EEDefaults_FlagsPriority(t *testing.T) { } func TestResolveM2EEDefaults_Defaults(t *testing.T) { + // With no --token, no env var and no .docker/.env, the token falls back to + // the local dev password so `mxcli oql` works against a `run --local` app + // with zero configuration (findings #36). + t.Setenv("M2EE_ADMIN_PASS", "") opts := M2EEOptions{} - err := resolveM2EEDefaults(&opts) - if err == nil { - t.Fatal("expected error for missing token") + if err := resolveM2EEDefaults(&opts); err != nil { + t.Fatalf("resolveM2EEDefaults: %v", err) } - if !strings.Contains(err.Error(), "admin password required") { - t.Errorf("unexpected error: %v", err) + if opts.Token != defaultLocalAdminPass { + t.Errorf("token: got %q, want local dev default %q", opts.Token, defaultLocalAdminPass) } if opts.Host != "localhost" { t.Errorf("host: got %q, want %q", opts.Host, "localhost") diff --git a/cmd/mxcli/docker/oql.go b/cmd/mxcli/docker/oql.go index 7b009117b..6e5d71e0d 100644 --- a/cmd/mxcli/docker/oql.go +++ b/cmd/mxcli/docker/oql.go @@ -130,7 +130,9 @@ func oqlDevError(raw json.RawMessage) string { // existing compose file, so a project generated before the flags were added // keeps starting the runtime without them until it is regenerated. if strings.Contains(strings.ToLower(msg), "not found") { - msg += " -- the running app does not expose the OQL preview endpoint. If your .docker/ predates this fix, regenerate it with `mxcli docker init --force`, then `mxcli docker build && mxcli docker up` (this starts the runtime with the live-preview dev flags)" + msg += " -- the running app does not expose the OQL preview endpoint, which needs the live-preview dev flags at boot." + + " If it was started with `mxcli run --local`, upgrade mxcli to a build that boots the local runtime with live preview (nightly-93 and earlier do not)." + + " If it runs under docker and your .docker/ predates this fix, regenerate it with `mxcli docker init --force`, then `mxcli docker build && mxcli docker up`." } return msg } diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 7678b383f..88a1b4b1a 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -597,6 +597,11 @@ func RunLocal(opts LocalRunOptions) error { defer rt.Stop() fmt.Fprintf(w, "\nApp is running at %s\n", rt.AppURL()) + // The local runtime boots with the live-preview dev flags (see + // LocalRuntimeOptions.jvmArgs), so `mxcli oql` can query it directly — and it + // now defaults to the local admin password, so no M2EE_ADMIN_PASS is needed + // (findings #36). + fmt.Fprintf(w, "Query data: mxcli oql -p %s \"SELECT ...\"\n", opts.ProjectPath) if runtimeLog != "" { fmt.Fprintf(w, "Runtime log: %s\n", runtimeLog) } diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index d8c108a3e..f2699ee64 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -24,7 +24,7 @@ CREATE PERSISTENT ENTITY Module.Photo ( |-----------|--------|-------| | Create entity | `CREATE [OR MODIFY] PERSISTENT\|NON-PERSISTENT ENTITY Module.Name (attrs);` | Persistent is default | | Create with extends | `CREATE PERSISTENT ENTITY Module.Name EXTENDS Parent.Entity (attrs);` | EXTENDS before `(` | -| Create view entity | `CREATE VIEW ENTITY Module.Name (attrs) AS SELECT ...;` | OQL-backed read-only | +| Create view entity | `CREATE VIEW ENTITY Module.Name (attrs) AS SELECT ...;` | OQL-backed; no storage, edit-in-memory, write back via source | | Create external entity | `CREATE EXTERNAL ENTITY Module.Name FROM ODATA CLIENT Module.Client (...) (attrs);` | From consumed OData | | Create external entities | `CREATE [OR MODIFY] EXTERNAL ENTITIES FROM Module.Client [INTO Module] [ENTITIES (...)];` | Bulk from $metadata | | Drop entity | `DROP ENTITY Module.Name;` | | diff --git a/docs-site/src/examples/view-entities.md b/docs-site/src/examples/view-entities.md index 1938a77fd..36031c421 100644 --- a/docs-site/src/examples/view-entities.md +++ b/docs-site/src/examples/view-entities.md @@ -1,6 +1,6 @@ # View Entities -View entities are read-only entities backed by an OQL query. They appear in the domain model but have no database table -- their data is computed from other entities via aggregation and joins. +View entities are backed by an OQL query. They appear in the domain model but have no database table (and no database view either) -- their rows are computed from other entities via aggregation and joins, evaluated by the runtime per query. "Read-only" is a common but imprecise shorthand: a view row has no storage and `commit` does not write it back, yet it *is* editable in memory (it behaves like a non-persistent object), so a form can bind to one and write changes back through the source entity. See [View Entity](../language/entities.md#view-entity) for the full model and the `CE6770` type-match gotcha. ## Sales Summary by Category diff --git a/docs-site/src/language/entities.md b/docs-site/src/language/entities.md index 00a880132..e5886bde1 100644 --- a/docs-site/src/language/entities.md +++ b/docs-site/src/language/entities.md @@ -65,7 +65,7 @@ CREATE NON-PERSISTENT ENTITY Sales.CustomerFilter ( ### View Entity -Defined by an OQL query. View entities are read-only: +Defined by an OQL query: ```sql CREATE VIEW ENTITY Reports.CustomerSummary ( @@ -82,6 +82,27 @@ CREATE VIEW ENTITY Reports.CustomerSummary ( GROUP BY c.Name; ``` +"Read-only" is a common shorthand for view entities, but it conflates three +separate things — and only two of them are true: + +- **No storage.** A view entity has no database table, and it is *not* a + database view either — after deploy, Postgres has no view object for it. The + runtime translates the OQL and issues it per query. +- **No write-back on `commit`.** Committing a view-entity object does nothing; + to persist a change you write through the source entity (retrieve it, change + it, commit it). +- **Not immutable.** A view row *is* editable in memory — it behaves like a + **non-persistent** object, so a form can bind to it and change its + attributes. This makes a view entity a legitimate backing for an *editable* + screen (populate the form with one pushed-down query, write back through the + source in the save flow), not only a read-only report. + +**Gotcha:** each declared attribute type must match its source column exactly. +A `String(60)` attribute over a `String(100)` source column fails the build with +`CE6770 "View Entity is out of sync with the OQL Query."` — widen the attribute +to match. (mxbuild genuinely type-checks the OQL, so a clean build is real +evidence, not just a syntax pass.) + ## CREATE OR MODIFY Creates the entity if it does not exist, or updates it if it does. New attributes are added; existing attributes are preserved: diff --git a/mdl/linter/context.go b/mdl/linter/context.go index e71b8ba85..8ed515f3d 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -686,6 +686,8 @@ type Widget struct { ModuleName string EntityRef string // Qualified name of referenced entity (e.g., "OtherModule.Customer") AttributeRef string + MicroflowRef string // Qualified name of an action/datasource microflow, if any + NanoflowRef string // Qualified name of an action/datasource nanoflow, if any } // Widgets returns an iterator over all widgets (excluding system modules). @@ -693,7 +695,8 @@ func (ctx *LintContext) Widgets() iter.Seq[Widget] { return func(yield func(Widget) bool) { rows, err := ctx.db.Query(` SELECT w.Id, w.Name, w.WidgetType, w.ContainerId, w.ContainerQualifiedName, - w.ContainerType, w.ModuleName, w.EntityRef, w.AttributeRef + w.ContainerType, w.ModuleName, w.EntityRef, w.AttributeRef, + w.MicroflowRef, w.NanoflowRef FROM widgets w LEFT JOIN modules m ON w.ModuleName = m.Name WHERE COALESCE(m.Source, '') = '' @@ -706,9 +709,9 @@ func (ctx *LintContext) Widgets() iter.Seq[Widget] { for rows.Next() { var w Widget - var containerID, containerQName, containerType, entityRef, attrRef sql.NullString + var containerID, containerQName, containerType, entityRef, attrRef, mfRef, nfRef sql.NullString err := rows.Scan(&w.ID, &w.Name, &w.WidgetType, &containerID, &containerQName, - &containerType, &w.ModuleName, &entityRef, &attrRef) + &containerType, &w.ModuleName, &entityRef, &attrRef, &mfRef, &nfRef) if err != nil { continue } @@ -717,6 +720,8 @@ func (ctx *LintContext) Widgets() iter.Seq[Widget] { w.ContainerType = containerType.String w.EntityRef = entityRef.String w.AttributeRef = attrRef.String + w.MicroflowRef = mfRef.String + w.NanoflowRef = nfRef.String if ctx.IsExcluded(w.ModuleName) { continue diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index d7d60fd0d..716b6f73a 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -799,6 +799,12 @@ func widgetToStarlark(w Widget) starlark.Value { "module_name": starlark.String(w.ModuleName), "entity_ref": starlark.String(w.EntityRef), "attribute_ref": starlark.String(w.AttributeRef), + // microflow_ref / nanoflow_ref expose a widget's action or datasource + // flow so custom rules can detect e.g. a microflow-datasource ListView + // (no database pushdown). CATALOG.WIDGETS already records these; before + // they were dropped from the Starlark projection (findings #35). + "microflow_ref": starlark.String(w.MicroflowRef), + "nanoflow_ref": starlark.String(w.NanoflowRef), }) } diff --git a/mdl/linter/widgets_projection_test.go b/mdl/linter/widgets_projection_test.go new file mode 100644 index 000000000..c56c67eb9 --- /dev/null +++ b/mdl/linter/widgets_projection_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter_test + +import ( + "path/filepath" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// TestWidgets_ProjectsMicroflowNanoflowRef guards findings #35: CATALOG.WIDGETS +// records the action/datasource flow of a widget, but the linter's Widget +// projection dropped MicroflowRef/NanoflowRef, so a custom rule could not detect +// a microflow-datasource ListView (no database pushdown). The fields must now be +// carried through from the catalog into the Widget struct. +func TestWidgets_ProjectsMicroflowNanoflowRef(t *testing.T) { + cat, err := catalog.NewFromFile(filepath.Join(t.TempDir(), "cat.db")) + if err != nil { + t.Fatalf("NewFromFile: %v", err) + } + defer cat.Close() + db := cat.CatalogDB() + + if _, err := db.Exec( + `INSERT INTO modules_data (Id, Name, ProjectId, SnapshotId) VALUES (?,?,?,?)`, + "mod-1", "Sales", "default", "s1", + ); err != nil { + t.Fatalf("insert module: %v", err) + } + if _, err := db.Exec( + `INSERT INTO widgets_data + (Id, Name, WidgetType, ContainerId, ContainerQualifiedName, ContainerType, + ModuleName, EntityRef, AttributeRef, MicroflowRef, NanoflowRef, ProjectId, SnapshotId) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + "w-1", "lvOrders", "listview", "c-1", "Sales.Order_Overview", "page", + "Sales", "Sales.Order", "", "Sales.DS_Orders", "", "default", "s1", + ); err != nil { + t.Fatalf("insert widget: %v", err) + } + + ctx := linter.NewLintContext(cat, nil) + var found *linter.Widget + for w := range ctx.Widgets() { + if w.ID == "w-1" { + ww := w + found = &ww + break + } + } + if found == nil { + t.Fatal("widget w-1 not returned by ctx.Widgets()") + } + if found.MicroflowRef != "Sales.DS_Orders" { + t.Errorf("MicroflowRef = %q, want %q", found.MicroflowRef, "Sales.DS_Orders") + } + if found.NanoflowRef != "" { + t.Errorf("NanoflowRef = %q, want empty", found.NanoflowRef) + } +}