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.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,3 +427,4 @@ extracting `OffsetExpression`/`LimitExpression`.
| `create odata client` with credentials given as constants (`HttpUsername: '@Module.ApiUser'`) still gets HTTP 401 and an empty client, after the fix that made literal credentials work. Sharpened by the same release making a constant `ServiceUrl` mandatory — the shape the tool insists on is the shape whose credentials it will not read | `resolveCredential` trusted the visitor's isLiteral flag. `'@Module.ApiUser'` **is** a STRING_LITERAL, so the flag said "literal" and the previous code sent the fifteen characters `@Module.ApiUser` as the username — and the unresolved-credential note did not fire either, because as far as the code knew nothing was unresolved | `mdl/executor/cmd_odata.go` (`resolveCredential`, `constantReference`, `designTimeConstants`) | **A syntactic classification is not a semantic one.** The visitor can say "this was a quoted string"; only the executor can say "this quoted string names a constant". Any flag of the form isLiteral needs the consumer to ask what the literal *contains* before treating it as a value. **The fix is to resolve, not to refuse**: a constant's design-time default is exactly what Studio Pro sends on the same fetch, so reading it is the value rather than a workaround — and mxcli already has the project open. Three spellings must all work (`'v'`, `@M.C`, `'@M.C'`); a dotted literal like a password containing a dot must not be mistaken for a reference. Tests `cmd_odata_metadata_auth_test.go`. mxcli-formula1 #23 follow-up |
| An app themed dark still shows light-mode drop shadows under the datagrid's filter-operator popover and dropdown filter lists | The generated widget layer re-pointed `.column-selectors` but not the four rules in `_datagrid-filters.scss` that bake the same two-layer shadow. Each already takes its *background* from `--bg-color-secondary`, so Atlas re-colours the panel and leaves the shadow — which is why it reads as a partial fix rather than an untouched widget | `cmd/mxcli/theme/assets/*/files/theme/web/_mxcli-widgets.scss` | **Read the selectors out of the shipped `themesource/`, not the bug report** — the fourth here is `.dropdown-container .dropdown-list`, nested under a parent the report did not name. **Verify in the compiled CSS, never the source** (the §33 rule): apply the theme to a real project, run `mxbuild --target=deploy --java-home=… --java-exe-path=…`, then grep `theme-cache/web/theme.compiled.css` and check the *line number* — the fix must land after the widget module's own rule (30794 vs 27765 here) or the cascade eats it. A theme-cache file older than the SCSS you just wrote is a stale compile, and mtime is the cheapest way to catch it. mxcli-formula1 #33 / suggested issue 4 |
| A `MOVE` cannot be confirmed and a module's layout cannot be reviewed: `SHOW STRUCTURE` groups by document type at every depth and never names a folder, `DESCRIBE` answers for one document at a time, so checking where things ended up means opening the `.mpr` as SQLite | The read side of folders was simply never built. `MOVE`/`DROP FOLDER` write containment; nothing read it back | New `mdl/executor/cmd_list_folders.go` + grammar (`FOLDERS` lexer token, `showOrList FOLDERS (IN …)?` in `MDLCatalog.g4`, `FOLDERS` added to the `keyword` rule so it stays usable as an identifier), `mdl/ast/ast_query.go`, `mdl/visitor/visitor_query.go`, `mdl/executor/executor_query.go` | **A layout listing must show what is *not* there**: empty folders (`[0]`) and documents still at the module root, or it cannot be diffed against an intended layout — that is the whole use. Documents are indexed by `ContainerID` across every list call the backend offers, each best-effort, so a backend that cannot answer one kind yields a listing missing that kind rather than no listing. **Do not stub the hierarchy in the test** — `mkHierarchy` populates `moduleNames` but not `folderNames`, so `BuildFolderPath` returns `""` and every folder silently collapses into the module root, which looks exactly like the bug. Build it from the mock's `ListModules`/`ListUnits`/`ListFolders`, as `getHierarchy` does. Tests `cmd_list_folders_test.go`, example in `18-folder-examples.mdl`. mxcli-formula1 issue #2 |
| A page written by mxcli will not open in Studio Pro, and `mx check` dies during *load* with `ArgumentNullException` at `EntityRefStep.set_AssociationId` — no page error, no CE code, the whole project is down rather than one document | An association DATASOURCE wrote the association name exactly as authored. Both halves of an `EntityRefStep` are BY_NAME and Mendix resolves either one it cannot find to null, but only `DestinationEntity` was guarded (issuetracker #14) — a bare `Order_Line` reached BSON unqualified. The **explicit-destination** form is the live trap: supplying the destination satisfies that guard, so nothing stood between a bare name and the crash — and `Assoc/Module.Entity` is exactly what the guard's error tells the author to write | `mdl/executor/cmd_pages_builder_v3.go` (`buildDataSourceV3`, `case "association"`: qualify via `resolveAssociationPathIn`, then verify an author-supplied destination's association exists via `associationEndpoints`) | **Guard every BY_NAME half of a ref, not the one that was reported.** #854 reported the destination; the association beside it failed identically and was reachable through the fix's own advice. **Diff the two properties in the dump** (`grep -A1 '"Key": "(Association\|DestinationEntity)"'`) rather than reading the exception name — the loader reports whichever it touches first. **Hold the input constant across builds**: the pre-fix binary was first run with bare names and post-fix with qualified ones, which "reproduced" nothing; re-running the *same qualified* script on both is what isolated cross-module (`""`) from same-module (resolved). `mx check` exits 0 on this failure — assert on the `contains: N errors` line, never `$?`. Tests `cmd_pages_builder_assoc_datasource_test.go`, example `mdl-examples/bug-tests/854-assoc-datasource-qualified-name.mdl`. upstream #854 follow-on |
82 changes: 82 additions & 0 deletions mdl-examples/bug-tests/854-assoc-datasource-qualified-name.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
-- ============================================================================
-- Upstream #854 (follow-on): an association DATASOURCE wrote the association
-- name unqualified, making the .mpr unloadable
-- ============================================================================
--
-- #854 itself — a cross-module association datasource writing
-- `DestinationEntity: ""` — no longer reproduces: the destination now resolves
-- through `CrossAssociations` (f0d1aea) and an unresolved one is refused
-- outright (18de30b, see it-14-assoc-destination-entity.mdl).
--
-- What survived is the OTHER half of the same EntityRefStep. Both halves are
-- BY_NAME references and Mendix resolves either one it cannot find to null, but
-- only the destination was being guarded. A bare association name was written
-- through verbatim:
--
-- System.InvalidOperationException: An error occurred when trying to set the
-- 'Association' property of a Entity ref step in a Page with ID ...
-- ---> System.ArgumentNullException: Value cannot be null. (Parameter 'value')
-- at ...DomainModels.Refs.EntityRefStep.set_AssociationId
--
-- Same unopenable project as #854, one property over. Not a build error: the
-- loader dies before validation, so `mx check` reports nothing about the page.
--
-- The explicit-destination form below is the one that mattered. Supplying the
-- destination satisfied the empty-DestinationEntity guard, so nothing else stood
-- between a bare name and the crash — and `Assoc/Module.Entity` is precisely the
-- spelling that guard's error message tells the author to use.
--
-- Fix: qualify a bare association with the context entity's module (the rule
-- attribute-path hops already follow), and verify an author-supplied
-- destination's association actually exists rather than taking it on trust.
--
-- Verified on Mendix 11.13.0: this script checks 0 errors and the project opens.
-- ============================================================================

create module M854A;
create module M854B;

create persistent entity M854A.Order (OrderNumber: String(50));
create persistent entity M854A.Note (Body: String(200));
create persistent entity M854B.Line (Product: String(100));

-- control: both ends in the same module
create association M854A.Order_Note from M854A.Order to M854A.Note type ReferenceSet;
-- subject: destination lives in another module
create association M854A.Order_Line from M854A.Order to M854B.Line type ReferenceSet;

create page M854A.OrderDetail (
Params: { $Order: M854A.Order },
Title: 'Order Detail',
Layout: Atlas_Core.Atlas_Default
) {
layoutgrid g { row r { column c (DesktopWidth: 12) {
dataview dvOrder (datasource: $Order) {
textbox tbNumber (attribute: OrderNumber, label: 'Order #')

-- bare name, same module
datagrid dgNotes (datasource: association Order_Note) {
column colBody (attribute: Body, caption: 'Note')
}

-- bare name, destination in another module
datagrid dgLines (datasource: association Order_Line) {
column colProduct (attribute: Product, caption: 'Product')
}

-- bare name + explicit destination: the guard's own suggested spelling,
-- and the form that used to crash the loader
datagrid dgLines2 (datasource: association Order_Line/M854B.Line) {
column colProduct2 (attribute: Product, caption: 'Product')
}

-- fully qualified, with and without an explicit destination
datagrid dgLines3 (datasource: association M854A.Order_Line) {
column colProduct3 (attribute: Product, caption: 'Product')
}
datagrid dgLines4 (datasource: association M854A.Order_Line/M854B.Line) {
column colProduct4 (attribute: Product, caption: 'Product')
}
}
} } }
}
166 changes: 166 additions & 0 deletions mdl/executor/cmd_pages_builder_assoc_datasource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// SPDX-License-Identifier: Apache-2.0

package executor

import (
"strings"
"testing"

"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/domainmodel"
"github.com/mendixlabs/mxcli/sdk/pages"
)

// upstream #854 (follow-on): an association DATASOURCE wrote the association
// name into the EntityRefStep exactly as authored. A bare name — the spelling
// attribute paths accept, and the spelling the unresolved-destination guard's
// own error message suggests (`Assoc/Module.Entity`) — therefore reached BSON
// unqualified. Mendix resolves an unqualified AssociationIdentifier to null and
// the loader throws
//
// ArgumentNullException at EntityRefStep.set_AssociationId
//
// so the .mpr will not open in Studio Pro and `mx check` dies before validating
// anything. Same unopenable-project outcome as the empty DestinationEntity that
// #854 reported; a different property of the same step.
//
// The explicit-destination form is the dangerous one: supplying the destination
// satisfies the guard, so nothing else stood between a bare name and the crash.
func TestAssociationDataSource_QualifiesAssociationName(t *testing.T) {
const (
modAID = model.ID("mod-a")
modBID = model.ID("mod-b")
orderID = model.ID("e-order")
noteID = model.ID("e-note")
lineID = model.ID("e-line")
)

newPB := func() *pageBuilder {
return &pageBuilder{
entityContext: "ModA.Order",
execCache: &executorCache{
hierarchy: &ContainerHierarchy{moduleNames: map[model.ID]string{
modAID: "ModA",
modBID: "ModB",
}},
domainModels: []*domainmodel.DomainModel{
{
ContainerID: modAID,
Entities: []*domainmodel.Entity{
{BaseElement: model.BaseElement{ID: orderID}, Name: "Order"},
{BaseElement: model.BaseElement{ID: noteID}, Name: "Note"},
},
Associations: []*domainmodel.Association{
{Name: "Order_Note", ParentID: orderID, ChildID: noteID, Type: domainmodel.AssociationTypeReferenceSet},
},
CrossAssociations: []*domainmodel.CrossModuleAssociation{
{Name: "Order_Line", ParentID: orderID, ChildRef: "ModB.Line", Type: domainmodel.AssociationTypeReferenceSet},
},
},
{
ContainerID: modBID,
Entities: []*domainmodel.Entity{
{BaseElement: model.BaseElement{ID: lineID}, Name: "Line"},
},
},
},
},
}
}

tests := []struct {
name string
reference string
wantPath string // EntityPath = "Module.Assoc/Module.DestEntity"
}{
{
name: "bare same-module association",
reference: "Order_Note",
wantPath: "ModA.Order_Note/ModA.Note",
},
{
name: "bare cross-module association",
reference: "Order_Line",
wantPath: "ModA.Order_Line/ModB.Line",
},
{
name: "bare name with explicit destination (the guard's suggested spelling)",
reference: "Order_Line/ModB.Line",
wantPath: "ModA.Order_Line/ModB.Line",
},
{
name: "already-qualified name is left alone",
reference: "ModA.Order_Line",
wantPath: "ModA.Order_Line/ModB.Line",
},
{
name: "qualified name with explicit destination",
reference: "ModA.Order_Line/ModB.Line",
wantPath: "ModA.Order_Line/ModB.Line",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ds, childCtx, err := newPB().buildDataSourceV3(&ast.DataSourceV3{
Type: "association",
Reference: tc.reference,
})
if err != nil {
t.Fatalf("buildDataSourceV3(%q) = error %v", tc.reference, err)
}
src, ok := ds.(*pages.AssociationSource)
if !ok {
t.Fatalf("got %T, want *pages.AssociationSource", ds)
}
if src.EntityPath != tc.wantPath {
t.Errorf("EntityPath = %q, want %q", src.EntityPath, tc.wantPath)
}
// The association half must be qualified: an unqualified
// AssociationIdentifier is what Mendix resolves to null.
assoc := strings.SplitN(src.EntityPath, "/", 2)[0]
if !strings.Contains(assoc, ".") {
t.Errorf("association %q is unqualified — the .mpr will not open", assoc)
}
wantCtx := strings.SplitN(tc.wantPath, "/", 2)[1]
if childCtx != wantCtx {
t.Errorf("child entity context = %q, want %q", childCtx, wantCtx)
}
})
}
}

// A destination the author supplies explicitly must not be taken on trust: it
// satisfies the empty-DestinationEntity guard, so a misspelled association would
// otherwise be written qualified-but-nonexistent, which Mendix again resolves to
// null. Refuse at author time instead.
func TestAssociationDataSource_RejectsUnknownAssociation(t *testing.T) {
pb := &pageBuilder{
entityContext: "ModA.Order",
execCache: &executorCache{
hierarchy: &ContainerHierarchy{moduleNames: map[model.ID]string{
model.ID("mod-a"): "ModA",
}},
domainModels: []*domainmodel.DomainModel{{
ContainerID: model.ID("mod-a"),
Entities: []*domainmodel.Entity{
{BaseElement: model.BaseElement{ID: model.ID("e-order")}, Name: "Order"},
},
}},
},
}

for _, ref := range []string{"Order_Lnie/ModB.Line", "ModA.Order_Lnie/ModB.Line"} {
t.Run(ref, func(t *testing.T) {
_, _, err := pb.buildDataSourceV3(&ast.DataSourceV3{Type: "association", Reference: ref})
if err == nil {
t.Fatalf("buildDataSourceV3(%q) succeeded; want a refusal — "+
"a nonexistent association writes a null AssociationId and the project will not open", ref)
}
if !strings.Contains(err.Error(), "Order_Lnie") {
t.Errorf("error %q does not name the offending association", err)
}
})
}
}
25 changes: 24 additions & 1 deletion mdl/executor/cmd_pages_builder_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -793,8 +793,31 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource
if idx := strings.Index(path, "/"); idx >= 0 {
destEntity = path[idx+1:]
path = path[:idx]
} else {
}

// Both halves of an EntityRefStep are BY_NAME references, and Mendix
// resolves either one it cannot find to null — so the association half
// needs the same care as the destination. A bare name (the spelling
// attribute paths accept, and the one the guard below suggests) reached
// BSON unqualified and the loader threw ArgumentNullException at
// `EntityRefStep.set_AssociationId`: same unopenable project as an empty
// DestinationEntity, a different property. Qualify with the context
// entity's module, exactly as attribute-path hops do (upstream #854).
path = pb.resolveAssociationPathIn(path, pb.entityContext)

if destEntity == "" {
destEntity = pb.resolveAssociationDestination(path, pb.entityContext)
} else if _, _, ok := pb.associationEndpoints(path); !ok {
// An author-supplied destination satisfies the guard below, so it is
// the one path where a misspelled — or wrongly-moduled — association
// would sail through and be written qualified-but-nonexistent, which
// Mendix resolves to null just the same. Verify it exists.
return nil, "", mdlerrors.NewValidationf(
"association %q for datasource %q does not exist — "+
"writing it would produce a project Mendix cannot open; "+
"a bare name is qualified with the module of the context entity (%s), "+
"so an association declared elsewhere must be named in full",
path, ds.Reference, pb.entityContext)
}

// An empty DestinationEntity is a by-name reference Mendix resolves to
Expand Down
Loading