Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/skills/fix-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions .claude/skills/mendix/write-lint-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 19 additions & 1 deletion cmd/mxcli/docker/localboot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions cmd/mxcli/docker/localboot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <launcher> <deploydir>; 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)
Expand Down
14 changes: 12 additions & 2 deletions cmd/mxcli/docker/m2ee.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 8 additions & 5 deletions cmd/mxcli/docker/m2ee_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 3 additions & 1 deletion cmd/mxcli/docker/oql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/mxcli/docker/runlocal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/appendixes/quick-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;` | |
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/examples/view-entities.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
23 changes: 22 additions & 1 deletion docs-site/src/language/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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:
Expand Down
11 changes: 8 additions & 3 deletions mdl/linter/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -686,14 +686,17 @@ 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).
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, '') = ''
Expand All @@ -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
}
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions mdl/linter/starlark.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
}

Expand Down
Loading
Loading