Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
56e0ef6
fix(mdl): preserve microflow expression source for division + decimals
claude Jul 28, 2026
85b349e
fix(mdl): drop non-existent year()/month() from expr func whitelist
claude Jul 28, 2026
29b9e09
docs(mdl): fix DELETE_CASCADE and inverted association direction
claude Jul 28, 2026
9b8d6f0
fix(modelsdk): write navigationlist item + caption names
claude Jul 28, 2026
0771639
fix(mdl): reject '/'-as-division (MDL045); scope source-preserve to d…
claude Jul 28, 2026
5766023
feat(mdl): reject client expressions in contentparams/captionparams (…
claude Jul 28, 2026
537137b
feat(mdl): check-time advisories for Mendix expression/XPath/layout g…
claude Jul 28, 2026
f297447
fix(mdl): close verification-round gaps in ledger checks (#17/#25/#27)
claude Jul 28, 2026
c5c724b
fix(mdl): MDL-WIDGET15 — Paragraph renders inline, not block (#29)
claude Jul 28, 2026
97f3ad3
docs+hint: reconcile ledger findings #37/#32/#31/#34
claude Jul 29, 2026
ff8a41e
feat(mdl): flag view-entity pass-through string length mismatch (#36)
claude Jul 29, 2026
8e6aeb2
fix(mdl): view-entity association gap (#41) + orphaned index crash (#39)
claude Jul 29, 2026
5dc4a46
feat(mdl): flag retrieve-by-id (#42) and association-path-as-value (#44)
claude Jul 29, 2026
26b8180
docs: ALTER PAGE limitations (#45) + DataGrid2 styling gotchas (#46)
claude Jul 29, 2026
c1d022f
docs(proposal): mark mxbuild-gap check programme in-progress; fold in…
claude Jul 29, 2026
60e67eb
docs(proposal): defer CE7412 (case 3) until a workflow test app exists
claude Jul 29, 2026
f53e3e9
feat(mdl): flag format-function + association navigation (MDL050, #48)
claude Jul 29, 2026
a908990
feat(mdl): reject break inside a conditional in a loop (MDL051, #52)
claude Jul 29, 2026
bb60e72
Fix string contains() serialized as a list operation (ledger #53)
claude Jul 29, 2026
3cb8ab6
Fix empty datagrid column caption rejected with CE0463 (ledger #54)
claude Jul 29, 2026
22d9136
docs: note database-datasource grids re-sort only on commit refresh (…
claude Jul 29, 2026
399cad3
Catch embedded division-by-variable in MDL045 (ledger #17 round 2)
claude Jul 29, 2026
21489db
Fix attribute-over-association navigation in microflow expressions (l…
claude Jul 29, 2026
8e5ddad
Remove MDL050 (format-function + association) — it was a false positive
claude Jul 29, 2026
aaa277c
Fix string find() serialized as a list operation (ledger #63)
claude Jul 29, 2026
15cf769
Flag reused loop iterator names at check time (ledger #64)
claude Jul 29, 2026
1a46e6a
Serialize pluggable widget action properties (ledger #67)
claude Jul 29, 2026
643ab17
DESCRIBE reads back pluggable widget onClick action (ledger #67 read …
claude Jul 29, 2026
19170ac
DESCRIBE reads pluggable action for generic widgets too (ledger #67 C…
claude Jul 29, 2026
e6aa0ed
Warn on recognized-but-unmapped widget properties (general #67 guard)
claude Jul 29, 2026
e81a7af
Fix empty/absent caption on custom-content datagrid columns (ledger #…
claude Jul 29, 2026
0ed0359
Merge pull request #52 from ako/claude/mxbuild-diagnostics-spike-emta6h
ako Jul 29, 2026
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
20 changes: 20 additions & 0 deletions .claude/skills/fix-issue.md

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions .claude/skills/mendix/alter-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,34 @@ alter page MyModule.Customer_Edit {
| SET on non-existent widget | Widget names are case-sensitive; check with DESCRIBE |
| Missing semicolons between operations | Each operation inside `{ }` ends with `;` |

## Limitations — prefer binding at page creation (ledger finding #45)

`ALTER PAGE` is best for *content* edits (add/remove/retitle widgets). Three things
it cannot do; when you hit them, define the referenced microflows **before** the
page and bind the buttons at creation time instead of rewiring afterwards:

1. **`SET` cannot rewire a button's action.** `set` accepts a fixed property list
(`caption`, `class`, `visible`, …) — `action` is not on it, so
`set Action = microflow … on btnSave` is a parse error. Set the button's action
when the button is created (or `REPLACE` the button subtree).

2. **`REPLACE` cannot reuse a widget name that lives inside the subtree being
replaced.** The replacement is *built* (registering its widget names) before the
old subtree is removed, so reusing e.g. `btnSave` collides with the still-present
old `btnSave` ("duplicate widget name 'btnSave'"). Give the replacement widgets
fresh names, or rebuild the whole page with `create or replace page`.

3. **A footer is not addressable by its author-given name.** A `footer myName { … }`
is a *marker*: its children are hoisted into the data view's footer and the
footer itself is serialized as `footer1`, so `drop widget myName` (and even
`drop widget footer1`) report "not found". To change footer contents, edit the
children by their own names, or `create or replace page`.

**Recommended pattern**: put save/reset microflows in a file that runs *before* the
page definition, and bind the popup/footer buttons to them at creation. The
apparent "page needs microflow, microflow needs page" cycle usually exists only
between *different* microflows, not within the page itself.

## Validation Checklist

1. **Get widget names first**: Run `describe page Module.PageName` to see all widget names
Expand Down
15 changes: 15 additions & 0 deletions .claude/skills/mendix/run-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,21 @@ does nothing until you restart `run --local` (or re-run with `--watch`). `mxbuil
changes, so **never** `rm -rf theme-cache/ .mendix-cache/ deployment/` — clearing
caches is a red herring.

A **theme compile error fails the build step, before the runtime starts** — the
error text is in the serve `/build` output (printed by `run --local`), not a
runtime problem. A common Dart Sass trap: `rgba()` needs a real color, so a
comma-list variable is rejected —

```scss
// WRONG: Dart Sass reports "$color: 168, 50, 30 is not a color"
$cf-over: 168, 50, 30;
background-color: rgba($cf-over, 0.1);

// RIGHT: make the variable a color, then adjust alpha
$cf-over: rgb(168, 50, 30);
background-color: rgba($cf-over, 0.1);
```

## "My edit didn't show up" — stale process, not stale cache

`run --local` refuses to boot when its ports (8080/8090/6543) are already answering,
Expand Down
25 changes: 25 additions & 0 deletions .claude/skills/mendix/theme-styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,31 @@ container ctn (style: 'color: red;') {

This also applies to `alter styling` and `alter page set style` — never target a DYNAMICTEXT widget with Style.

### DataGrid2 Renders ARIA `<div>`s, Not a `<table>` — and `Size` Is a Flex Weight

Two surprises when styling a **DataGrid2** matrix/pivot (ledger finding #46):

1. **It is not a `<table>`.** DataGrid2 emits `role="grid"` / `role="row"` /
`role="gridcell"` **`<div>`s**, so `th, td { … }` selectors match nothing. A
`td { white-space: nowrap }` intended to keep `€ 5,200` on one line does not
apply, and amounts wrap. Target the ARIA roles instead:

```scss
.ledger-matrix [role='gridcell'] { white-space: nowrap; }
```

Playwright/tests see the same DOM: assert on `[role="row"]`, not `tr`.

2. **`Size` is a flex weight, not pixels.** On a column, `ColumnWidth: manual,
Size: 132` does **not** set a 132px width — it divides available width by the
weights across all columns. To give a wide matrix room, set a min-width on the
grid and let it scroll:

```scss
.ledger-matrix [role='grid'] { min-width: 1320px; }
.ledger-matrix { overflow-x: auto; }
```

### Design Property Keys Are Case-Sensitive

Keys must match the `name` field in `design-properties.json` exactly:
Expand Down
61 changes: 60 additions & 1 deletion .claude/skills/mendix/write-microflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,8 @@ commit $Product with events refresh;

**Best Practice**: Use `with events` when you want before/after commit event handlers to execute. Use `refresh` when the committed object is displayed in the client and you want the UI to update immediately.

> **Re-sorting a database-datasource grid needs `refresh`.** A plain `commit $Obj;` updates the committed attribute *values* in the grid, but a grid backed by a **database** datasource does **not** re-run its sort — so after changing a sort key (e.g. a reorder that rewrites a `SequenceNumber`), the row stays in its old position until you `commit $Obj refresh;`. The `refresh` re-queries the datasource, which re-applies the sort. (Ledger #57.)

> **Binding a microflow to an entity event is MDL — you do NOT need to map it manually in Studio Pro.** After writing a handler microflow (e.g. a `BeforeCommit` validation), wire it directly:
> ```mdl
> alter entity Sales.Order
Expand All @@ -590,6 +592,21 @@ add head($SourceItems) to $Items;

Use expression-valued `add` only when the expression returns an object compatible with the target list element type.

### `contains` is overloaded — string vs list

`contains(a, b)` is both a **string** function (`contains(haystack, needle)` → substring test) and a **list** operation (`contains(list, object)` → membership test). mxcli picks the right serialization automatically:

```mdl
-- STRING contains — assign to a PRE-DECLARED Boolean (a Change Variable action)
declare $HasAt Boolean = false;
set $HasAt = contains($Email, '@');

-- LIST contains — do NOT pre-declare the output (the list op creates it)
set $Found = contains($Items, $Item);
```

The distinction: a **literal or computed** second argument is always the string function. When both arguments are plain variables, the input variable's declared type decides — a **String** input becomes the string function (Change Variable, so declare the Boolean first), anything else stays a list operation (which creates its own output variable, so leave it undeclared). Getting the declare wrong is what triggers `CE0111 "Duplicate variable name"`.

## Database Operations

### RETRIEVE Statement
Expand Down Expand Up @@ -734,7 +751,11 @@ $Result = $A * $B; -- Multiplication
$Result = $A div $B; -- Division (use 'div', not '/')
```

**Important**: Use `div` for division, NOT `/`.
**Important**: Use `div` for division, NOT `/`. In a Mendix expression `/` is the
member/association separator (`$obj/Attr`), so `$A / $B` is not division —
`mxcli check` rejects it as **MDL045** (it would fail the build with CE0117).
Integer/decimal division always yields a Decimal; wrap it in `round()`/`trunc()`
for an Integer result (else **MDL041**).

### Comparison

Expand Down Expand Up @@ -762,6 +783,21 @@ if $IsActive and $IsValid and $HasStock then
end if;
```

### Date construction

`dateTime(...)` / `dateTimeUTC(...)` build a date from **literal numeric
constants only** — a variable or computed argument fails the build with CE0117
(`mxcli check` flags it as **MDL046**). To build a date from variables, step off
a literal anchor with `addDays()` / `addMonths()` (which *do* take variables):

```mdl
-- WRONG: variable args to dateTime() (CE0117 / MDL046)
set $D = dateTime(2026, $Month, $Day);

-- RIGHT: anchor on a literal, then step with addMonths/addDays
set $D = addDays(addMonths(dateTime(2026, 1, 1), $Month - 1), $Day - 1);
```

## Logging

```mdl
Expand Down Expand Up @@ -987,6 +1023,29 @@ retrieve $Items from Module.Entity where Active = true;

**Note**: `returns type as $Var` in the microflow signature does NOT create an activity variable — it only names the return value. So `$Var = call java action ...` after `returns as $Var` is fine (one creation).

**Variables are scoped to the branch that creates them.** A variable first created
inside an `if`/`else` arm (including by `$Var = call ...`) is not visible outside
that arm. To use one value after a conditional, `declare` it *before* the
conditional and assign in every branch:

```mdl
-- WRONG: $GTotalText is created only in the `then` arm → not declared in `else`
if $HasVariance then
$GTotalText = call microflow Module.FMT_Variance($v); -- created here only
else
set $GTotalText = 'n/a'; -- error: not declared
end if;

-- CORRECT: declare before, then set in each branch (call into a temp, then set)
declare $GTotalText string = '';
if $HasVariance then
$Tmp = call microflow Module.FMT_Variance($v);
set $GTotalText = $Tmp;
else
set $GTotalText = 'n/a';
end if;
```

**Fallback chains reuse the same name = CE0111.** Because each `$Var = call microflow …` (or retrieve/create) is a *fresh* variable creation, the natural "try A, else try B" shape is invalid — even when the retry is inside an `if`:

```mdl
Expand Down
75 changes: 56 additions & 19 deletions .claude/skills/mendix/write-oql-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,34 +46,65 @@ create view entity Finance.CashFlowProjection (
);
```

**RULE 2: NEVER use ORDER BY or LIMIT in VIEW entity OQL**
**RULE 2: `ORDER BY` requires a `LIMIT` — prefer letting the consuming query sort**

The UI component or microflow using the view will handle sorting and pagination:
`ORDER BY` **alone** is rejected (`mxcli check` → **MDL030**; `mx check` → CE0174).
`ORDER BY` **with** a `LIMIT` is valid and builds clean — that is exactly how you
express a top-N view. As a default, prefer *no* `ORDER BY`/`LIMIT` so the UI
component or microflow can sort and paginate the same view differently; reach for
`ORDER BY … LIMIT` only when the view is intrinsically a top-N.

```sql
-- ❌ WRONG - Hardcoded sorting and limits
-- ❌ WRONG - ORDER BY without LIMIT (MDL030 / CE0174)
create view entity Finance.TopCustomers (...) as (
select c.Name as CustomerName, sum(o.Amount) as TotalSpent
from Finance.Customer as c
inner join Finance.Order_Customer/Finance.Order as o
GROUP by c.Name
ORDER by TotalSpent desc -- Remove this
limit 100 -- Remove this
ORDER by TotalSpent desc -- needs a LIMIT
);

-- ✅ CORRECT - No ORDER BY or LIMIT
-- ✅ CORRECT (preferred) - let the consuming page/microflow sort
create view entity Finance.CustomerTotals (...) as (
select c.Name as CustomerName, sum(o.Amount) as TotalSpent
from Finance.Customer as c
inner join Finance.Order_Customer/Finance.Order as o
GROUP by c.Name
);

-- ✅ ALSO VALID - an intrinsic top-N view (ORDER BY paired with LIMIT)
create view entity Finance.TopCustomers (...) as (
select c.Name as CustomerName, sum(o.Amount) as TotalSpent
from Finance.Customer as c
inner join Finance.Order_Customer/Finance.Order as o
GROUP by c.Name
-- Let the UI component handle sorting and limits
ORDER by TotalSpent desc
LIMIT 100
);
```

**Why these rules matter:**
- **Explicit aliases**: Required for proper OQL-to-entity attribute mapping in Mendix
- **No ORDER BY/LIMIT**: Provides flexibility - different pages/microflows can sort and paginate the same view differently
- **ORDER BY/LIMIT**: Omitting both keeps the view flexible (each page/microflow sorts and paginates as it needs); when you *do* sort, `ORDER BY` must be paired with `LIMIT` (MDL030)

**`UNION` / `UNION ALL` are supported** in view-entity OQL and round-trip cleanly —
use them to combine multiple row kinds in one view (e.g. category rows plus group
subtotals). Column count and types must line up across branches; `ORDER BY` (with
its `LIMIT`) applies to the whole unioned result, not a single branch.

```sql
create or modify view entity Ledger.CategoryAndSubtotals (
Label: string(100), Amount: decimal
) as (
select c.Name as Label, sum(t.Amount) as Amount
from Ledger.Category as c
left join Ledger.Transaction_Category/Ledger.Transaction as t
group by c.Name
union all
select 'TOTAL' as Label, sum(t.Amount) as Amount
from Ledger.Transaction as t
);
```

### 1. Aggregate Functions (MUST BE LOWERCASE)
```sql
Expand Down Expand Up @@ -431,7 +462,7 @@ create view entity Shop.ProductCurrentPrice (
**Key points:**
- Use `pr/Shop.Price_Product = p.ID` (association path with `.ID`)
- Never use bare alias: `pr/Shop.Price_Product = p` will fail
- ORDER BY and LIMIT are valid inside correlated subqueries (just not at the view level)
- ORDER BY and LIMIT are valid inside correlated subqueries; at the view level, ORDER BY is allowed only when paired with LIMIT (MDL030)

### Pattern 10: JOIN with ON Clause (Non-Association)
```sql
Expand Down Expand Up @@ -509,8 +540,8 @@ mxcli check view.mdl -p app.mpr --references
This catches type mismatches (e.g., declaring `long` for a `count()` column that returns `integer`), missing module references, and OQL syntax errors — before they become MxBuild errors like CE6770 ("View Entity is out of sync with the OQL Query").

### Step 9: Final Check
- Remove any ORDER BY, LIMIT, or OFFSET clauses
- These should be handled by the UI component or microflow
- Prefer no ORDER BY/LIMIT/OFFSET — let the UI component or microflow sort and paginate
- If the view is intrinsically a top-N, ORDER BY is allowed **only when paired with a LIMIT** (MDL030 / CE0174)

## Common Mistakes to Avoid

Expand Down Expand Up @@ -583,19 +614,25 @@ where pr/Shop.Price_Product = p
where pr/Shop.Price_Product = p.ID
```

### ❌ Mistake 8: Using ORDER BY or LIMIT in VIEW
### ❌ Mistake 8: ORDER BY without a LIMIT in a VIEW
```sql
-- WRONG - Hardcoded in view
-- WRONG - ORDER BY alone (MDL030 / CE0174)
create view entity Finance.TopItems (...) as (
select ...
ORDER by Amount desc
limit 100
ORDER by Amount desc -- needs a LIMIT
);

-- CORRECT - Let UI handle it
-- CORRECT (preferred) - let the UI sort/paginate
create view entity Finance.ItemTotals (...) as (
select ...
-- no ORDER BY / LIMIT
);

-- ALSO VALID - an intrinsic top-N view
create view entity Finance.TopItems (...) as (
select ...
-- No ORDER BY or LIMIT
ORDER by Amount desc
LIMIT 100
);
```

Expand Down Expand Up @@ -640,7 +677,7 @@ create view entity Shop.MonthlyRevenue (
3. ✅ Proper COUNT: `count(o.OrderId)` not `count(*)`
4. ✅ Comma syntax for DATEPART: `datepart(YEAR, o.OrderDate)`
5. ✅ GROUP BY matches SELECT non-aggregated expressions
6. ✅ No ORDER BY or LIMIT (UI will handle sorting)
6. ✅ ORDER BY omitted (UI sorts) — or, for a top-N view, paired with a LIMIT (MDL030)

## Testing OQL Queries

Expand Down Expand Up @@ -707,7 +744,7 @@ When writing OQL queries for VIEW entities, always verify:

- [ ] **CRITICAL**: Entity has @Position annotation (e.g., @Position(300, 500))
- [ ] **CRITICAL**: All SELECT columns have explicit AS aliases matching entity attributes
- [ ] **CRITICAL**: No ORDER BY, LIMIT, or OFFSET clauses (let UI handle sorting)
- [ ] ORDER BY omitted so the UI sorts — or, for a top-N view, ORDER BY paired with a LIMIT (MDL030 rejects ORDER BY without LIMIT)
- [ ] Aggregate functions are lowercase (`sum`, `avg`, `count`, `max`, `min`)
- [ ] Using `count(entity.ID)` not `count(*)`
- [ ] DATEPART uses comma syntax: `datepart(YEAR, field)`
Expand Down
6 changes: 6 additions & 0 deletions .claude/skills/mendix/xpath-constraints.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ where [not(Module.Order_Customer/Module.Customer)]

**Rule**: Always use the fully qualified association name (`Module.AssociationName`).

> **`= empty` does not work on associations (CE0161 / MDL047).** `= empty` tests
> *attribute* nullability only. To test whether an object *has no* associated
> object, use negated existence: `[not(Module.Order_Customer/Module.Customer)]` —
> **not** `[Module.Order_Customer = empty]`. `mxcli check` flags the association
> `= empty` form as **MDL047** before the build does.

### Variable Paths

```mdl
Expand Down
13 changes: 7 additions & 6 deletions docs/05-mdl-specification/01-language-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ boolean, both, business, by, call, cancel, caption, cascade,
catalog, change, CHILD, close, column, combobox, commit, connect,
configuration, connector, constant, constraint, container, create,
CRUD, datagrid, dataview, date, datetime, declare, default, delete,
delete_behavior, DELETE_BUT_KEEP_REFERENCES, DELETE_CASCADE, demo,
delete_behavior, DELETE_BUT_KEEP_REFERENCES, CASCADE, demo,
depth, desc, DESCENDING, describe, DIFF, disconnect, drop, else,
empty, end, entity, enumeration, error, event, events, execute,
exec, EXIT, export, extends, external, false, folder, footer,
Expand Down Expand Up @@ -585,16 +585,17 @@ create association <qualified-name>
- `Parent` - Parent owns the association
- `Child` - Child owns the association

**Delete Behavior:**
**Delete Behavior:** one of `DELETE_BUT_KEEP_REFERENCES`, `CASCADE`,
`DELETE_IF_NO_REFERENCES`, `DELETE_AND_REFERENCES`, `PREVENT`
- `DELETE_BUT_KEEP_REFERENCES` - Delete object but keep references
- `DELETE_CASCADE` - Delete associated objects
- `CASCADE` - Delete associated objects too (there is **no** `DELETE_CASCADE` keyword)

**Example:**
```sql
/** Links orders to customers */
/** Links orders to customers (Order owns the FK → from Order to Customer) */
create association Sales.Order_Customer
from Sales.Customer
to Sales.Order
from Sales.Order
to Sales.Customer
type reference
owner default
delete_behavior DELETE_BUT_KEEP_REFERENCES;
Expand Down
Loading
Loading