Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/executor", "date": "2026-10-18", "symptom": "`datagrid dg (DataSource: database Shop.Ord, itemSelection: Single)` passed `check -p`, `exec` reported \"Created page\" and `mx check` found 0 errors, but the grid had no selection: the stored itemSelection read PrimitiveValue 'Single' and Selection 'None'. `Selection: Single` stored it correctly. On a Gallery `itemSelection: None` left the Selection at the mapping default Single, and `itemSelection: Multi` stored Selection Single. Separately `gallery g (…, advanced: true)` stored nothing and said nothing (mendixlabs/mxcli#1618).", "cause": "Two passes disagreed about one property. resolveMapping read a selection mapping only from its SOURCE keyword (`Selection:`), and the explicit-key pass skipped names the mapping pass had handled by checking them against mappedKeys, which holds the mappings' SOURCE keywords — so the property's KEY (`itemSelection`) matched neither and fell to the scalar branch, where every non-attribute value type is written with SetPrimitive (PrimitiveValue, a field a selection property never reads). Separately, refuseUndeclaredMappedKey exempted every mapping with a definition constant (`value`), including one the script had overridden by its key, so `advanced: true` on a Gallery 3.x (which declares no `advanced`) was dropped by the builder's skip-unknown-key.", "file": "mdl/executor/widget_selection_value.go (one reader for both spellings: validates against the template's SelectionTypes, refuses a disagreement — MDL-WIDGET50, check and exec); widget_engine.go (resolveMapping selection branch, the explicit pass's selectionKeys and its Selection case, refuseUndeclaredMappedKey with BuildContext.ScriptAuthored); validate_widget_gallery_click.go reads the key too; modelsdk/widgets/loader.go fills PropertyTypeIDEntry.SelectionTypes", "insight": "A set of \"already handled\" names must be keyed the same way as the lookup that consults it. mappedKeys was built from mapping.Source and consulted with the name the author wrote, which for a widget key is the PropertyKey; the two coincide only for keywords, so the by-key path silently re-wrote the property by whatever its value type fell to. When a by-key write 'works' but the widget behaves as if unset, compare the stored WidgetValue field (Selection vs PrimitiveValue) against a Studio Pro-authored or keyword-authored control, ids stripped — mx check cannot tell, because both fields are valid. Other value types still reach the same scalar default in the explicit pass (Association, Action, DataSource, Widgets, Icon, Image, Object, DateTime given a plain string); this fix routes Selection only.", "refs": ["mdl-examples/bug-tests/1618-datagrid-itemselection-by-key.mdl", "mdl-examples/bug-tests/1618-datagrid-itemselection-by-key.fail.mdl", "mdl/executor/widget_itemselection_by_key_test.go"], "rules": ["MDL-WIDGET50", "MDL-WIDGET49"]}
5 changes: 5 additions & 0 deletions .claude/skills/mendix/create-page/reference/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,11 @@ gallery cards (datasource: database Module.Product, selection: Single, onClickTr
```
`onSelectionChange:` and `onConfigurationChange:` take an action the same way.

**Selection by key.** `selection:` is the keyword for the widget's `itemSelection`
property, on a gallery and on a data grid 2 alike; `itemSelection: Multi` names the
same property and stores the same thing. Write one of the two: a value that is not
`None`, `Single` or `Multi`, or two spellings that disagree, is refused (MDL-WIDGET50).

### Filter Widgets

A filter widget lives in one of two places: **inside a DATA GRID column's own braces**,
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- **A selection property written by its widget key — `itemSelection: Single` on a Data grid 2 or a Gallery — is stored as a selection** (mendixlabs/mxcli#1618) — `datagrid dg (DataSource: database Shop.Ord, itemSelection: Single)` passed `check -p`, `exec` reported "Created page" and `mx check` found nothing, but the grid had no selection: the value was written as a plain primitive (`PrimitiveValue 'Single'`, `Selection 'None'`), and a selection property keeps it in `Selection`. The keyword spelling, `Selection: Single`, was always right; on a Gallery the key also lost to the default Single (`itemSelection: None` stored Single). Both spellings now store the same document, ids aside, for `None`, `Single` and `Multi`.
- **A mapped property written by its key that the installed widget does not declare is refused instead of dropped** (mendixlabs/mxcli#1618) — `gallery g (…, advanced: true)`: the Gallery definition maps `advanced` to a constant, Gallery 3.x declares no such property, and the value vanished with check, exec and `mx check` all clean. `check` says so too (MDL-WIDGET49) rather than passing what `exec` then refuses. An authored value equal to the constant (`advanced: false`) still passes, as it drops nothing.
- **A specialization of a non-persistent entity reads as non-persistent** (mendixlabs/mxcli#1600) — `describe entity Probe.Child` printed `create or modify persistent entity Probe.Child extends Probe.Base (` when `Probe.Base` is non-persistent, so a reader took the child for a table and the output stated a combination Mendix does not have. A specialization stores no persistable flag of its own; it takes its root generalization's, and the reader defaulted it to persistent. The flag is now resolved along the generalization chain, across modules and into the System module (whose `HttpRequest`, `HttpResponse` and `SoapFault` read persistent for the same reason), and the fix reaches everything that reads it: `describe`, the catalog's `EntityType`, the domain-model diagrams, and `alter entities … where persistent`, which used to add its attribute to such a child as well. Re-running the describe output leaves the entity unchanged.
- **`go mod tidy` runs again, and the expression round-trip test runs for the first time** — `mdl/exprcheck/roundtrip_test.go` imported `mdl/backend/mpr` (the legacy engine, deleted) plus `executor.GetHierarchyForMining` and `executor.DescribeMicroflowToString` (both removed). It never broke a build because it sat behind `//go:build roundtrip`, a tag no Makefile target and no CI job has ever passed — the only file in the tree using it. `go mod tidy` considers every build tag, so it was the one command that still saw the file, and it failed with "no matching versions for query latest" on the deleted package, which reads as a dependency problem rather than a dead test. The test is ported to `mdl/executor/exprcheck_roundtrip_test.go` under the `integration` tag, where `setupTestEnv`/`describeMDL` already provide the real-project harness and `make test-integration-executor` actually runs it; the removed hierarchy helper is replaced by building the container-to-module map from `backend.ListModules()`. Its assertion is worth keeping: describing every microflow and re-parsing each expression with the robust parser catches grammar gaps — a missing `parsePrimary` branch, a missing `funcTable` entry, a missing slot constraint — that the unit tests cannot, since those only feed the parser expressions someone thought to write down. It now also fails rather than passing silently when the fixture project yields no microflows or no expression slots.

Expand Down
28 changes: 28 additions & 0 deletions mdl-examples/bug-tests/1618-datagrid-itemselection-by-key.fail.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
mdl 1;
-- ============================================================================
-- (mendixlabs/mxcli#1618) — a selection property given a value it cannot hold
-- ============================================================================
--
-- NEGATIVE TEST: this script must FAIL `mxcli check` (MDL-WIDGET50).
--
-- Both gallery statements below used to pass check and exec. `itemSelection:
-- Bogus` was stored as a primitive nothing reads; `Selection: Single` together
-- with `itemSelection: Multi` stored Single in one field and Multi in another.
-- The widget declares three selections (None, Single, Multi), and the keyword and
-- the key name ONE property, so a value outside the three, or two values for it,
-- is refused instead of guessed at.

create module BugIselFail;
create persistent entity BugIselFail.Ord ( Code: String(20) );

create page BugIselFail.Invalid ( Title: 'Invalid', Layout: Atlas_Core.Atlas_Default ) {
gallery galBogus ( DataSource: database BugIselFail.Ord, itemSelection: Bogus ) {
template { dynamictext txtBogus (Content: 'x') }
}
};

create page BugIselFail.Conflict ( Title: 'Conflict', Layout: Atlas_Core.Atlas_Default ) {
gallery galBoth ( DataSource: database BugIselFail.Ord, Selection: Single, itemSelection: Multi ) {
template { dynamictext txtBoth (Content: 'x') }
}
};
62 changes: 62 additions & 0 deletions mdl-examples/bug-tests/1618-datagrid-itemselection-by-key.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
mdl 1;
-- ============================================================================
-- (mendixlabs/mxcli#1618) — a selection property written by its widget key
-- ============================================================================
--
-- `itemSelection: Single` on a Data grid 2 passed check, exec reported
-- "Created page" and mx check found nothing, but the grid had no selection:
-- the stored property read PrimitiveValue 'Single' and Selection 'None'. A
-- selection-typed property keeps its value in `Selection`; only the mapped
-- keyword (`Selection: Single`) wrote it there. The key fell through to the
-- scalar route and was written as a primitive the widget never reads.
--
-- Expected now: each pair below stores the SAME bytes (ids aside) — by key and
-- by keyword — with Selection Single / Multi and an empty PrimitiveValue. A
-- gallery's `itemSelection: None` turns off the Single it selects by default.
--
-- Verify against a project (-p), then read the stored value:
-- mxcli exec 1618-datagrid-itemselection-by-key.mdl -p app.mpr
-- mxcli bson dump -p app.mpr --type page --object "BugIsel.ByKey" --format json
-- -> the itemSelection property: Selection 'Single' / 'Multi', PrimitiveValue ''
-- mx check app.mpr -> 0 errors
--
-- Refused, by check and exec (MDL-WIDGET50) — see
-- 1618-datagrid-itemselection-by-key.fail.mdl:
-- itemSelection: Bogus (not one of None, Single, Multi)
-- Selection: Single, itemSelection: Multi (two spellings that disagree)

create module BugIsel;
create persistent entity BugIsel.Ord ( Code: String(20) );

create page BugIsel.ByKey ( Title: 'By key', Layout: Atlas_Core.Atlas_Default ) {
datagrid dgSingle ( DataSource: database BugIsel.Ord, itemSelection: Single ) {
column (Attribute: Code)
}
datagrid dgMulti ( DataSource: database BugIsel.Ord, itemSelection: Multi ) {
column (Attribute: Code)
}
gallery galNone ( DataSource: database BugIsel.Ord, itemSelection: None ) {
template { dynamictext txtNone (Content: 'none') }
}
gallery galMulti ( DataSource: database BugIsel.Ord, itemSelection: Multi ) {
template { dynamictext txtMulti (Content: 'multi') }
}
};

-- The mapped keyword: the control the key must equal.
create page BugIsel.ByKeyword ( Title: 'By keyword', Layout: Atlas_Core.Atlas_Default ) {
datagrid dgSingle ( DataSource: database BugIsel.Ord, Selection: Single ) {
column (Attribute: Code)
}
datagrid dgMulti ( DataSource: database BugIsel.Ord, Selection: Multiple ) {
column (Attribute: Code)
}
gallery galNone ( DataSource: database BugIsel.Ord, Selection: None ) {
template { dynamictext txtNone (Content: 'none') }
}
gallery galMulti ( DataSource: database BugIsel.Ord, Selection: Multiple ) {
template { dynamictext txtMulti (Content: 'multi') }
}
};

describe page BugIsel.ByKey;
120 changes: 120 additions & 0 deletions mdl/executor/validate_widget_authored_constant.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: Apache-2.0

package executor

import (
"fmt"
"path/filepath"
"strings"
"sync"

"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/mdl/linter"
"github.com/mendixlabs/mxcli/modelsdk/widgets/mpk"
)

// authoredConstantUndeclared is the rule ID for a value the script writes, by the
// property's key, over a definition constant the installed widget has no property
// for.
const authoredConstantUndeclared = "MDL-WIDGET49"

// validateAuthoredConstantUndeclared is `check`'s twin of the build-time refusal
// in refuseUndeclaredMappedKey: a mapping that carries a constant (`value` in the
// definition) is overridden when the script names the property by its key, and
// when the installed widget declares no such property the builder skips it —
// `gallery g (…, advanced: true)` on a Gallery 3.x, which has no `advanced`,
// checked clean and then failed exec (or, before the refusal, stored nothing).
//
// An authored value equal to the constant is left alone, as the builder leaves
// it: it drops nothing the definition would not have written anyway.
//
// The widget's declared keys come from the project's installed package when
// there is one, and otherwise from the embedded template the builder starts
// from; with neither, nothing is known and nothing is reported.
func validateAuthoredConstantUndeclared(w *ast.WidgetV3, def *WidgetDefinition, projectPath, locationPrefix string) []linter.Violation {
if w == nil || def == nil {
return nil
}
var declared map[string]bool
var out []linter.Violation
seen := map[string]bool{}
check := func(ms []PropertyMapping) {
for _, m := range ms {
if m.Value == "" || m.Operation == "selection" || seen[m.PropertyKey] {
continue
}
raw, ok := lookupProperty(w.Properties, m.PropertyKey)
if !ok {
continue
}
authored := stringifyAny(raw)
if authored == "" || authored == m.Value {
continue
}
if declared == nil {
declared = declaredWidgetKeys(projectPath, def.WidgetID)
}
if len(declared) == 0 || declared[strings.ToLower(m.PropertyKey)] {
continue
}
seen[m.PropertyKey] = true
out = append(out, linter.Violation{
RuleID: authoredConstantUndeclared,
Severity: linter.SeverityError,
Message: fmt.Sprintf(
"%s: %s has no property `%s` in the installed widget — its definition maps it to the constant %q, "+
"and the value `%s` written here would be dropped",
locationPrefix, widgetLabel(w.Name, def.MDLName), m.PropertyKey, m.Value, authored),
Suggestion: "Remove the property, or update the widget package if a newer version declares it.",
})
}
}
check(def.PropertyMappings)
for _, mode := range def.Modes {
check(mode.PropertyMappings)
}
return out
}

var (
declaredWidgetKeysMu sync.Mutex
declaredWidgetKeysCache = map[string]map[string]bool{}
)

// declaredWidgetKeys returns the lower-cased top-level property keys a widget
// declares: the project's installed package first, then the embedded template.
// Empty when neither is known.
func declaredWidgetKeys(projectPath, widgetID string) map[string]bool {
cacheKey := projectPath + "\x00" + widgetID
declaredWidgetKeysMu.Lock()
if d, ok := declaredWidgetKeysCache[cacheKey]; ok {
declaredWidgetKeysMu.Unlock()
return d
}
declaredWidgetKeysMu.Unlock()

out := map[string]bool{}
if projectPath != "" {
projectDir := projectPath
if strings.EqualFold(filepath.Ext(projectDir), ".mpr") {
projectDir = filepath.Dir(projectDir)
}
if mpkPath, err := mpk.FindMPK(projectDir, widgetID); err == nil && mpkPath != "" {
if wd, err := mpk.ParseMPKForWidget(mpkPath, widgetID); err == nil && wd != nil {
for _, p := range wd.Properties {
out[strings.ToLower(p.Key)] = true
}
}
}
}
if len(out) == 0 {
for k := range widgetPropertyTypes(widgetID) {
out[k] = true
}
}

declaredWidgetKeysMu.Lock()
declaredWidgetKeysCache[cacheKey] = out
declaredWidgetKeysMu.Unlock()
return out
}
6 changes: 3 additions & 3 deletions mdl/executor/validate_widget_explicit_writable.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import (

// explicitPassWritableTypes are the property value types the widget engine's
// explicit-property pass (PluggableWidgetEngine.Build, step 4.6) writes by
// storage key: Expression, TextTemplate and Attribute through their own
// setters, and the scalar types as a primitive. A value of any other type
// storage key: Expression, TextTemplate, Attribute and Selection through their
// own setters, and the scalar types as a primitive. A value of any other type
// (objects, widgets, icons, images, actions, datasources…) has no correct
// route there, so MDL-WIDGET06 stays true for it.
var explicitPassWritableTypes = map[string]bool{
"expression": true, "texttemplate": true, "attribute": true,
"expression": true, "texttemplate": true, "attribute": true, "selection": true,
"string": true, "boolean": true, "integer": true, "decimal": true, "enumeration": true,
}

Expand Down
4 changes: 4 additions & 0 deletions mdl/executor/validate_widget_gallery_click.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ func validateGalleryClickAmbiguity(w *ast.WidgetV3, locationPrefix string) []lin
selection := "Single"
if v, ok := lookupProperty(w.Properties, "Selection"); ok {
selection = stringifyAny(v)
} else if v, ok := lookupProperty(w.Properties, "itemSelection"); ok {
// The property's own key stores the same selection as the keyword; reading
// only the keyword called a `itemSelection: None` gallery a selecting one.
selection = stringifyAny(v)
}
trigger := "single"
if v, ok := lookupProperty(w.Properties, "onClickTrigger"); ok {
Expand Down
6 changes: 6 additions & 0 deletions mdl/executor/validate_widgets.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc
out = append(out, validateWidgetActionSlot(w, locationPrefix)...)
// #842: a Gallery row click the widget itself calls ambiguous.
out = append(out, validateGalleryClickAmbiguity(w, locationPrefix)...)
// A selection property given a value its widget does not offer, or two
// spellings that disagree (`Selection:` and `itemSelection:`).
out = append(out, validateSelectionProperties(w, lookupWidgetDef(w, registry), locationPrefix)...)
// A definition constant the script overrides by key, on a widget with no such
// property: the builder drops it, and exec refuses (refuseUndeclaredMappedKey).
out = append(out, validateAuthoredConstantUndeclared(w, lookupWidgetDef(w, registry), registry.projectPath, locationPrefix)...)
// #928: contentparams with no `{N}` placeholder to consume them.
if lookupWidgetDef(w, registry) != nil {
out = append(out, validatePluggableContentParams(w, locationPrefix)...)
Expand Down
Loading
Loading