diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 85535a966..044d182f0 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -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 | diff --git a/mdl-examples/bug-tests/854-assoc-datasource-qualified-name.mdl b/mdl-examples/bug-tests/854-assoc-datasource-qualified-name.mdl new file mode 100644 index 000000000..ab29cf1b6 --- /dev/null +++ b/mdl-examples/bug-tests/854-assoc-datasource-qualified-name.mdl @@ -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') + } + } + } } } +} diff --git a/mdl/executor/cmd_pages_builder_assoc_datasource_test.go b/mdl/executor/cmd_pages_builder_assoc_datasource_test.go new file mode 100644 index 000000000..12d2db2dc --- /dev/null +++ b/mdl/executor/cmd_pages_builder_assoc_datasource_test.go @@ -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) + } + }) + } +} diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index e2409483e..0d3e48584 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -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