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
1 change: 1 addition & 0 deletions .claude/skills/fix-issue/findings/mdl-executor.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -868,4 +868,5 @@
{"area": "mdl/executor", "date": "2026-10-05", "symptom": "On Mendix 11.15, `TestMxCheck_DoctypeScripts/40-message-definition-examples.mdl` fails with CE0270 \"No root element could be found in the schema\" at MsgTest.IMM_Order. On a converted or Studio Pro 11.15 project, `list message definition collections` finds nothing, and `describe import mapping` prints no source", "cause": "Mendix 11.15 replaced `MessageDefinitions$MessageDefinitionCollection` with one `MessageDefinitions$MessageDefinition2` document per definition. `mx convert` turns the collection into a `Projects$Folder` with the same unit ID and name, and the ExposedEntity tree is byte-identical apart from $IDs. mxcli read and wrote only collections, so on 11.15 there was nothing to resolve a mapping against", "file": "`mdl/backend/modelsdk/messagedefinition_document.go` (read/write, reusing the collection's exposedNodeFromGen/messageNodeToGen), `mdl/executor/cmd_messagedefinition_documents.go` (create/describe/drop/list + version refusals), `mdl/executor/mapping_messagedefinition.go` (`findMessageDefinition(ctx, ref)` returns the stored reference), `cmd_messagedefinitions.go` (alter target resolution, association-drop guard), grammar `createMessageDefinitionStatement` / `DROP|DESCRIBE MESSAGE DEFINITION` / `LIST MESSAGE DEFINITIONS`", "insight": "**`mx convert` is the oracle for a storage change.** Converting a known 11.14 project gives a Studio Pro-authored 11.15 reference for the new shape and for how references move, so no guessing is needed. **Under the revert, 11.15 fails earlier than CE0270.** A two-part name written into the old `MessageDefinition` key makes mxbuild refuse to load the project (`StorageLoadException: ... is not a valid OldMessageDefinitionIdentifier`). The old key is typed as a three-part identifier even though 11.15 no longer reads it. The doctype script uses `-- @version: ..11.14` / `11.15+` sections, and the 11.15 mapping needs a different name from the older section's, because a plain `check` sees both sections and reports MDL-DUPDEF", "refs": ["ako/mxcli#987"]}
{"area": "mdl/executor", "date": "2026-10-06", "symptom": "v0.25.0: `describe microflow` 4-8x slower than v0.24.0 on a flow arranged by hand in Studio Pro, and the cost grows faster than the flow (60 if/else blocks: 4.7 s -> 35.6 s); flows laid out by mxcli barely change", "cause": "The canonical describe's layout derivation (#748) re-renders the description every round, and a hand-laid flow takes gatedLayoutRounds+2 = 8 rounds. Each render re-ran the stored flow's split/merge analysis (findSplitMergePoints, labelCrossedMerges) and the body warnings (postDominators via microflowgraph.Analyze) - superlinear, and independent of which annotations the round keeps", "file": "`mdl/executor/cmd_microflows_derived_layout.go` (flowAnalysisMemo, installed by useDerivedFlowLayout, rebuildOnly view for the rounds), `mdl/executor/cmd_microflows_show.go` (findSplitMergePoints / labels / microflowBodyWarnings go through it)", "insight": "**Profile before believing the issue's theory.** The report blamed the round count, but rounds were already capped at 8; the rebuild itself was cheap. The CPU profile showed the cost was the *render* inside each round - the stored flow's graph analysis, redone per round. Memoising it per describe (keyed on the stored collection pointer) and leaving the `-- WARNING` comment lines out of rounds (the rebuild never reads them) gave 14.2 s -> 1.7 s at 60 blocks, with the printed description byte-identical to the unfixed build. A wall-clock assertion would be flaky; the test counts split/merge analyses per describe (18 over 8 rounds before, 2 after), with the round count as its control", "refs": ["mendixlabs/mxcli#1301"]}
{"area":"mdl-executor","date":"2026-10-05","symptom":"A view entity selecting a **non-localized** DateTime column (Studio Pro's \"Localize\" unticked — the normal choice for a calendar date) passes `mxcli check --references` and exec, then `mx check` fails CE6770 \"View Entity is out of sync with the OQL Query.\" The view attribute was always written LocalizeDate = true, and MDL has no spelling for the flag, so describe -> exec re-broke it on every run","cause":"execCreateViewEntity built every view attribute with convertDataType, whose DateTime is Mendix's default LocalizeDate = true; nothing looked at the attribute the column reads","file":"`mdl/executor/oql_view_localize_date.go` (viewDateTimeLocalize), `mdl/executor/cmd_entities.go` (execCreateViewEntity); test `mdl/executor/view_entity_localize_date_test.go`; bug-test `mdl-examples/bug-tests/1297-view-entity-non-localized-datetime.mdl`","insight":"**Derive, don't add syntax**: on a view entity the column's localization is a property of the query, so the fix reads it from the source attribute and describe -> exec round-trips (`Unchanged`) with nothing new to spell — same reasoning as the view association (the column is the declaration). **Measure the shapes before choosing the scope**: one project, one view per shape, mxbuild 11.12.5 — pass-through, `s/Attr`, MIN, MAX and CASE over a non-localized source are ALL CE6770 when written localized and 0 errors when patched to false, so a pass-through-only rule (the obvious one, mirroring the string-length rule) would have fixed the report and left MAX(date) broken. Sources that disagree (coalesce of a localized and a non-localized column) are unmeasured and keep the default. **Repro needs a Studio Pro-authored flag**: mxcli writes every persistent DateTime localized, so the bug is invisible from MDL alone — the reporter's pymongo patch flipping Sale.SaleDate is the cheapest stand-in. Control: stubbing the assignment fails 5 of 6 cases with `LocalizeDate = true`; pre-fix binary on the same project gives 5 × CE6770, fixed gives 0","refs":["mendixlabs/mxcli#1297"]}
{"area": "mdl-executor", "date": "2026-10-06", "symptom": "`create constant M.Flag (Type: Boolean, DefaultValue: true)` (or `True`) stores DefaultValue \"true\". Studio Pro stores \"True\"/\"False\"; its constant dialog shows a stored \"true\" as False while the runtime reads it as true, so the developer sees one value and the app runs with another. `mx check` is silent and `describe constant` prints `true` for both, so only the stored unit shows it. Quoted `'True'` was the workaround. `alter settings constant @M.Flag value true` (or 'true') wrote the configuration override the same way", "cause": "createConstant rendered the AST literal with fmt.Sprintf(\"%v\"), so a Go bool became Go's lowercase \"true\"; the quoted string passed through verbatim, which is why only 'True' was right. The settings path stored the visitor's token text verbatim and never looked at the constant's type", "file": "`mdl/executor/cmd_constants.go` (storedConstantDefault, used by the create and create-or-modify branches, and by `alterSettingsConstant` in `mdl/executor/cmd_settings.go` via settingsConstantType); test `mdl/executor/cmd_constant_boolean_default_test.go`; bug-test `mdl-examples/bug-tests/1321-boolean-constant-default-case.mdl`", "insight": "**`%v` on an AST literal is Go's spelling, not Mendix's**: any place that stringifies a parsed value into a stored property must normalise to the platform's form. The case-insensitive reader (formatDefaultValue's EqualFold) hid the writer's defect from describe, so a round-trip test could not catch it; assert on the value handed to the backend. Neither `mx check` nor the runtime distinguishes the two spellings — only Studio Pro's dialog does — so a build cannot verify this; the evidence is the reporter's Studio Pro 11.12.4 measurement plus the stored unit. Control: test written before the fix failed 5 of 6 spellings with `stored as \"true\"`/`\"false\"`, the quoted 'True' control passing; exec on a copy of testdata/pedapp then stores True/True/True/False. **Enumerate every write path for the value, not just the reported one**: the configuration override holds the same typed value and had the same defect; it needs the constant's type looked up (a String constant holding 'true' must stay lowercase). Control: the settings test failed 3 of 5 cases with `stored as \"true\"`/`\"FALSE\"` before the fix, the 'True' and String-constant controls passing; the stored Settings unit on a pedapp copy read 'true' for `value true` and `value 'true'` before, 'True' after, the String override 'true' both times", "refs": ["mendixlabs/mxcli#1321"]}
{"area": "mdl/executor", "date": "2026-10-07", "symptom": "A button added to a snippet with `alter snippet … insert` (or replace) calling `call microflow M.F(P = $P)` with a snippet parameter fails mxbuild with CE0115 \"The arguments that are passed to microflow 'M.F' do not match the expected parameters and need to be refreshed\" on that button only; the same button in CREATE SNIPPET or via ALTER SNIPPET SET is clean", "cause": "`buildWidgetsFromAST` and `buildColumnSpecsFromAST` (mdl/executor/cmd_alter_page.go) seeded paramScope and localVariables from the mutator but not `isSnippet`, so `classifyFlowArgValue` returned kind \"parameter\" and the Forms$PageVariable named PageParameter instead of SnippetParameter", "file": "`mdl/executor/cmd_alter_page.go` (isSnippet from mutator.ContainerType() in both builders)", "insight": "**A pageBuilder built outside CREATE needs THREE scope fields, not two** — paramScope, localVariables and isSnippet; #1317 found the SET builder missing all three and this one missing the last. When adding a builder, copy the seeding from convertASTAction rather than from memory. **Unlike the Expression-form defect (#1140/#1317), mxbuild DOES catch the wrong slot** — CE0115 rather than CE1571 — so one script exercising CREATE, INSERT and SET on the same snippet with `mx check` is a complete A/B: only the broken path's button is named. Verified on 11.12.2: 1 error before, 0 after", "refs": ["mendixlabs/mxcli#1317", "mendixlabs/mxcli#1140"], "ce": ["CE0115"]}
28 changes: 28 additions & 0 deletions mdl-examples/bug-tests/1321-boolean-constant-default-case.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
mdl 1;
-- ============================================================================
-- mendixlabs/mxcli#1321: Boolean constant DefaultValue stored in Studio Pro's case
-- ============================================================================
--
-- Before: `DefaultValue: true` and `DefaultValue: True` were stored as "true".
-- Studio Pro stores a Boolean default as "True" / "False"; its constant dialog
-- shows a stored "true" as False while the runtime reads it as true, so the
-- value a developer sees and the value the app runs with differed. `mx check`
-- reports nothing either way and `describe constant` prints both identically,
-- so the difference only shows in the stored unit.
--
-- After: every spelling below is stored as "True" or "False".

create module BugTest1321;

create constant BugTest1321.FlagA (Type: Boolean, DefaultValue: true);
create constant BugTest1321.FlagB (Type: Boolean, DefaultValue: True);
create constant BugTest1321.FlagC (Type: Boolean, DefaultValue: 'True');
create constant BugTest1321.FlagD (Type: Boolean, DefaultValue: false);

-- A configuration override of a Boolean constant had the same defect: the
-- value was stored as typed ("true"). It is now stored as "True" / "False";
-- a String constant's override is stored as written.
create constant BugTest1321.Label (Type: String, DefaultValue: 'x');
alter settings constant @BugTest1321.FlagD value true;
alter settings constant @BugTest1321.FlagA value 'false';
alter settings constant @BugTest1321.Label value 'true';
167 changes: 167 additions & 0 deletions mdl/executor/cmd_constant_boolean_default_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: Apache-2.0

package executor

import (
"testing"

"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/mdl/backend/mock"
"github.com/mendixlabs/mxcli/model"
)

// A Boolean constant's DefaultValue is stored as "True" / "False" — that is what
// Studio Pro writes, and its constant dialog reads a stored "true" as False while
// the runtime reads it as true (mendixlabs/mxcli#1321). The literal `true` reached
// the backend as Go's fmt rendering "true"; only the quoted 'True' was right.

var booleanConstantDefaults = []struct {
name, mdl, want string
}{
{"lower true", "true", "True"},
{"title True", "True", "True"},
{"lower false", "false", "False"},
{"upper FALSE", "FALSE", "False"},
{"quoted lower", "'true'", "True"},
// Already in Studio Pro's form: the control.
{"quoted True", "'True'", "True"},
}

func TestCreateConstant_BooleanDefaultStoredAsStudioProCase(t *testing.T) {
for _, tc := range booleanConstantDefaults {
t.Run(tc.name, func(t *testing.T) {
mod := mkModule("MyModule")
var captured *model.Constant
mb := &mock.MockBackend{
IsConnectedFunc: func() bool { return true },
ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil },
ListConstantsFunc: func() ([]*model.Constant, error) { return nil, nil },
CreateConstantFunc: func(c *model.Constant) error {
captured = c
return nil
},
}
ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod)))

prog := parseMDL(t, "create constant MyModule.Flag (Type: Boolean, DefaultValue: "+tc.mdl+");")
if err := createConstant(ctx, prog.Statements[0].(*ast.CreateConstantStmt)); err != nil {
t.Fatalf("createConstant: %v", err)
}
if captured == nil {
t.Fatal("CreateConstant was not called")
}
if captured.DefaultValue != tc.want {
t.Errorf("DefaultValue: %s stored as %q, want %q", tc.mdl, captured.DefaultValue, tc.want)
}
})
}
}

// `create or modify` rewrites an existing constant through UpdateConstant — the
// second write path the same value takes.
func TestCreateOrModifyConstant_BooleanDefaultStoredAsStudioProCase(t *testing.T) {
mod := mkModule("MyModule")
existing := &model.Constant{
ContainerID: mod.ID,
Name: "Flag",
Type: model.ConstantDataType{Kind: "Boolean"},
DefaultValue: "False",
}
var captured *model.Constant
mb := &mock.MockBackend{
IsConnectedFunc: func() bool { return true },
ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil },
ListConstantsFunc: func() ([]*model.Constant, error) { return []*model.Constant{existing}, nil },
UpdateConstantFunc: func(c *model.Constant) error {
captured = c
return nil
},
}
ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod)))

prog := parseMDL(t, "create or modify constant MyModule.Flag (Type: Boolean, DefaultValue: true);")
_ = createConstant(ctx, prog.Statements[0].(*ast.CreateConstantStmt)) // folder handling may warn; the write is under test
if captured == nil {
t.Fatal("UpdateConstant was not called")
}
if captured.DefaultValue != "True" {
t.Errorf("DefaultValue: true stored as %q, want %q", captured.DefaultValue, "True")
}
}

// A configuration override of a Boolean constant is stored the same way as its
// default, and `alter settings constant @M.Flag value true` wrote the token text
// "true" verbatim — the #1321 defect on the second write path. Only a Boolean
// constant is normalised: a String constant holding the text 'true' is left alone.
func TestAlterSettingsConstant_BooleanValueStoredAsStudioProCase(t *testing.T) {
cases := []struct {
name, constantID, value, want string
existing bool
}{
{"new override true", "Mod.Flag", "true", "True", false},
{"new override FALSE", "Mod.Flag", "FALSE", "False", false},
{"existing override true", "Mod.Flag", "true", "True", true},
// Already in Studio Pro's form: the control.
{"already True", "Mod.Flag", "True", "True", false},
// Not a Boolean constant: must pass through unchanged.
{"string constant", "Mod.Label", "true", "true", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mod := mkModule("Mod")
cfg := &model.ServerConfiguration{Name: "Default"}
if tc.existing {
cfg.ConstantValues = []*model.ConstantValue{{ConstantId: tc.constantID, Value: "False"}}
}
var wrote *model.ProjectSettings
mb := &mock.MockBackend{
IsConnectedFunc: func() bool { return true },
ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil },
ListConstantsFunc: func() ([]*model.Constant, error) {
return []*model.Constant{
{ContainerID: mod.ID, Name: "Flag", Type: model.ConstantDataType{Kind: "Boolean"}},
{ContainerID: mod.ID, Name: "Label", Type: model.ConstantDataType{Kind: "String"}},
}, nil
},
GetProjectSettingsFunc: func() (*model.ProjectSettings, error) {
ps := &model.ProjectSettings{
Configuration: &model.ConfigurationSettings{
Configurations: []*model.ServerConfiguration{cfg},
},
}
ps.RawParts = []map[string]any{{"$Type": "Settings$ConfigurationSettings"}}
return ps, nil
},
UpdateProjectSettingsFunc: func(ps *model.ProjectSettings) error {
wrote = ps
return nil
},
}
ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod)))

if err := alterSettings(ctx, &ast.AlterSettingsStmt{
Section: "constant",
ConfigName: "Default",
ConstantId: tc.constantID,
Value: tc.value,
}); err != nil {
t.Fatalf("alterSettings: %v", err)
}
if wrote == nil {
t.Fatal("UpdateProjectSettings was not called")
}
var got *model.ConstantValue
for _, cv := range wrote.Configuration.Configurations[0].ConstantValues {
if cv.ConstantId == tc.constantID {
got = cv
}
}
if got == nil {
t.Fatalf("no override for %s written", tc.constantID)
}
if got.Value != tc.want {
t.Errorf("value %s stored as %q, want %q", tc.value, got.Value, tc.want)
}
})
}
}
17 changes: 17 additions & 0 deletions mdl/executor/cmd_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,22 @@ func formatDefaultValue(dt model.ConstantDataType, value string) string {
}
}

// storedConstantDefault puts a default value into the form Studio Pro stores. A
// Boolean is "True" / "False": Studio Pro's constant dialog reads a stored "true" as
// False while the runtime reads it as true, so the literal `true` (rendered by fmt
// as "true") made the app run with a value the developer never sees (#1321).
func storedConstantDefault(dt model.ConstantDataType, value string) string {
if dt.Kind == "Boolean" {
switch {
case strings.EqualFold(value, "true"):
return "True"
case strings.EqualFold(value, "false"):
return "False"
}
}
return value
}

// createConstant handles CREATE CONSTANT command.
func createConstant(ctx *ExecContext, stmt *ast.CreateConstantStmt) error {
if !ctx.ConnectedForWrite() {
Expand All @@ -289,6 +305,7 @@ func createConstant(ctx *ExecContext, stmt *ast.CreateConstantStmt) error {
if stmt.DefaultValue != nil {
defaultValue = fmt.Sprintf("%v", stmt.DefaultValue)
}
defaultValue = storedConstantDefault(constType, defaultValue)

// Check if constant already exists in this module
existingConstants, err := ctx.Backend.ListConstants()
Expand Down
Loading
Loading