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-17", "symptom": "`describe page` prints `-- Context: $currentObject (Shop.Order_Customer)` — the ASSOCIATION — under a data view or list view bound over an association (`DataSource: $currentObject/Shop.Order_Customer`), where the entity it reaches (`Shop.Customer`) belongs; the page itself is correct (`mx check` clean) and only the comment, and consumers that qualify names against the context, were wrong (mendixlabs/mxcli#1571)", "cause": "`dataSourceEntityContext` returned `rawDataSource.Reference` for every kind but a flow. The Reference means a different thing per source kind: the entity for a database source, the flow for a microflow/nanoflow source (the 6e59e0e3e fix), the association for an association source, the page parameter's name for a parameter source. `associationSourcePath` read each step's DestinationEntity but kept only the association names, so a single-hop source carried nothing but the association", "file": "`mdl/executor/cmd_pages_describe_flowcontext.go` (`dataSourceEntityContext`), `mdl/executor/cmd_pages_describe.go` (`rawDataSource.Destination`, `associationSourceDestination`), `mdl/executor/cmd_pages_describe_datasource.go` (the two sites that build an association source)", "insight": "Deriving the context entity from a datasource reference needs a per-kind rule, and each kind with a non-entity reference is its own instance of this bug: ask what `Reference` holds before using it as an entity. For an association source the answer is stored, not inferred: Forms$AssociationSource, a DataViewSource and a pluggable XPath source all keep EntityRef.Steps[last].DestinationEntity, which is already right in both directions (owner to child and back), for a reference set, and for a destination that specializes the association's end — so read it rather than working out parent/child from the domain model (ALTER PAGE's `entityFromEntityRef` does the same, FINDINGS #55). A step with no stored destination keeps the old output. Not fixed here, same function: a `parameter` source's Reference is the page parameter's NAME, so a data view over `$Order` prints `(Order)`, not `(Shop.Order)`; the entity is stored in the same DataViewSource's EntityRef. The check-time `childContext` already treats an association source as unresolved on purpose"}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed

- **`describe page` names the entity an association data source reaches, not the association** (mendixlabs/mxcli#1571) — under a data view, list view, grid or gallery bound over an association, the context comment read `-- Context: $currentObject (Shop.Order_Customer)`: the association, where `(Shop.Customer)` belongs. The page was right (`mx check` clean); the comment misled anyone reading `describe` to learn what a nested widget binds, and the same context decides how an association name inside the container is spelled. The entity is the destination stored on the source's last hop, so it is correct from either end of the association, for a reference set, and for a specialization of the association's end. The `DataSource:` text is unchanged. A source with no stored destination prints what it did before.

## [0.27.0] - 2026-10-15

Headline: **`exec` is all or nothing: when a statement fails at write time, the whole run is rolled back, so a script never leaves a project half-changed.** Alongside it: a wave of `describe` → `check` → `exec` fixes from round-trip runs over real apps (the Siemens FSM template, the GenAI showcase, Marketplace modules), Mendix 9 projects can take microflows, nanoflows and pluggable widgets again, `check -p` resolves many more of the references `exec` and the build would refuse, the classic `filemanager` widget and role-based `Visible: roles (…)` are in MDL, and the companion draws a module's domain model. The MongoDB driver is upgraded for the October 2026 BSON advisories.
Expand Down
107 changes: 107 additions & 0 deletions mdl-examples/bug-tests/1571-describe-context-association.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
mdl 1;
-- ============================================================================
-- Bug (mendixlabs/mxcli#1571) — `describe page` named the ASSOCIATION as the
-- context entity of a data container bound over an association:
--
-- dataview dvCustomer (DataSource: $currentObject/CtxAssoc.Order_Customer) {
-- -- Context: $currentObject (CtxAssoc.Order_Customer) <- the association
--
-- The datasource reference was used verbatim as the context entity. That is
-- right for a database source (the reference is the entity) and wrong for an
-- association source, whose reference is the association's qualified name — the
-- same mistake the microflow/nanoflow case had (795b-flow-datasource-context-
-- entity.mdl). The page itself was always correct (`mx check` clean); only the
-- comment, and anything resolving names against the context, was wrong.
--
-- The entity the association reaches is stored on the last hop of the source
-- (EntityRef.Steps[last].DestinationEntity), so describe reads it: the other
-- end for a reference, the element entity for a reference set, and the owner when
-- the page is read from the child's side.
--
-- Describe output is not asserted by CI for a script (Go tests cover it:
-- mdl/executor/cmd_pages_describe_assoc_context_test.go); this script builds the
-- shapes so the output can be read by hand.
--
-- Manual verification (needs a project with Atlas_Core):
--
-- mxcli exec 1571-describe-context-association.mdl -p app.mpr
-- mxcli -p app.mpr -c "describe page CtxAssoc.Probe"
-- mxcli -p app.mpr -c "describe page CtxAssoc.ProbeFromCustomer"
--
-- Expect, under dvCustomer: -- Context: $currentObject (CtxAssoc.Customer)
-- under lvLines: -- Context: $currentObject (CtxAssoc.OrderLine), $lvLines (selection)
-- under lvOrders (Probe-
-- FromCustomer): -- Context: $currentObject (CtxAssoc.Order), $lvOrders (selection)
-- Before the fix each named the association (CtxAssoc.Order_Customer,
-- CtxAssoc.Order_Line, CtxAssoc.Order_Customer). The `DataSource:` text is
-- unchanged, and describe -> exec still reports "Unchanged page".
-- ============================================================================

create module CtxAssoc;
create module role CtxAssoc.User;

@position(100, 100)
create persistent entity CtxAssoc.Customer (
Name: string(200)
);

@position(400, 100)
create persistent entity CtxAssoc.Order (
Number: string(200)
);

@position(700, 100)
create persistent entity CtxAssoc.OrderLine (
Description: string(200)
);

-- A reference: Order -> Customer.
create association CtxAssoc.Order_Customer from CtxAssoc.Order to CtxAssoc.Customer;

-- A reference set: Order -> OrderLine.
create association CtxAssoc.Order_Line from CtxAssoc.Order to CtxAssoc.OrderLine type ReferenceSet;

create or modify page CtxAssoc.Probe (
Title: 'Probe',
Layout: Atlas_Core.Atlas_Default,
Params: ( $Order: CtxAssoc.Order )
) {
layoutgrid lg {
row {
column (DesktopWidth: AutoFill) {
dataview dvOrder (DataSource: $Order) {
-- Over a reference: the context is the Customer, not the association.
dataview dvCustomer (DataSource: $currentObject/CtxAssoc.Order_Customer) {
textbox tbName (Label: 'Name', Attribute: Name)
}
-- Over a reference set: the context is one OrderLine.
listview lvLines (DataSource: $currentObject/CtxAssoc.Order_Line) {
textbox tbDescription (Label: 'Description', Attribute: Description, Editable: Never)
}
}
}
}
}
};

-- The same reference read from the other side: the context is the Order.
create or modify page CtxAssoc.ProbeFromCustomer (
Title: 'Probe',
Layout: Atlas_Core.Atlas_Default,
Params: ( $Customer: CtxAssoc.Customer )
) {
layoutgrid lg {
row {
column (DesktopWidth: AutoFill) {
dataview dvCustomer (DataSource: $Customer) {
listview lvOrders (DataSource: $currentObject/CtxAssoc.Order_Customer) {
textbox tbNumber (Label: 'Number', Attribute: Number, Editable: Never)
}
}
}
}
}
};

describe page CtxAssoc.Probe;
describe page CtxAssoc.ProbeFromCustomer;
25 changes: 25 additions & 0 deletions mdl/executor/cmd_pages_describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,11 @@ type rawDataSource struct {
// are rendered, since both re-parse against the source's own entity.
SearchAttributes []string
ContextVariable string // association source: context variable name (empty → $currentObject)
// Destination is the entity an association source's last hop arrives at, as
// stored (EntityRef.Steps[last].DestinationEntity). Reference is the
// association there, so this is the entity the source puts in scope. Empty
// when the model does not store it.
Destination string
// EntitySteps is set on a database source reached over associations from a
// context object: a Forms$ListViewXPathSource whose EntityRef is an
// IndirectEntityRef. Each hop keeps its stored DestinationEntity, which may
Expand Down Expand Up @@ -645,6 +650,26 @@ type rawDataSourceArg struct {
Value string
}

// associationSourceDestination returns the entity the last hop of an association
// source arrives at, as stored — "" when there is no hop or none is stored.
// associationSourcePath keeps only the association of a single hop, so this
// reads the same steps for the entity.
func associationSourceDestination(ds map[string]any) string {
entityRef, ok := ds["EntityRef"].(map[string]any)
if !ok || entityRef == nil {
return ""
}
steps := getBsonArrayElements(entityRef["Steps"])
if len(steps) == 0 {
return ""
}
last, ok := steps[len(steps)-1].(map[string]any)
if !ok {
return ""
}
return extractString(last["DestinationEntity"])
}

// associationSourcePath reconstructs the association navigation of a
// Forms$AssociationSource (EntityRef = IndirectEntityRef of association steps)
// into its path ("Module.Assoc" or "Module.Assoc/…") plus the context variable
Expand Down
204 changes: 204 additions & 0 deletions mdl/executor/cmd_pages_describe_assoc_context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// SPDX-License-Identifier: Apache-2.0

// A data container bound over an association reported the *association* as its
// context entity — `-- Context: $currentObject (Shop.Order_Customer)` instead of
// `(Shop.Customer)` — because the datasource reference was used verbatim. That is
// right for a database source, where the reference is the entity, and wrong for
// a microflow source (its flow) and for an association source (its association).
// The flow case was fixed first; these tests cover the association case.
//
// Storage carries the answer: every step of an association source's EntityRef
// stores the DestinationEntity it arrives at, in either direction.
package executor

import (
"strings"
"testing"
)

func assocStep(assoc, dest string) map[string]any {
return map[string]any{"$Type": "DomainModels$EntityRefStep", "Association": assoc, "DestinationEntity": dest}
}

func indirectRef(steps ...map[string]any) map[string]any {
items := make([]any, len(steps))
for i, s := range steps {
items[i] = s
}
return map[string]any{"$Type": "DomainModels$IndirectEntityRef", "Steps": items}
}

func pageVariable(param string) map[string]any {
return map[string]any{"$Type": "Forms$PageVariable", "PageParameter": param}
}

// assocDataSource is a Forms$AssociationSource over steps from $currentObject.
func assocDataSource(steps ...map[string]any) map[string]any {
return map[string]any{"$Type": "Forms$AssociationSource", "EntityRef": indirectRef(steps...)}
}

// outerOrderView wraps inner in a data view over page parameter $Order, the way
// the field report nests it.
func outerOrderView(inner map[string]any) map[string]any {
return map[string]any{
"$Type": "Forms$DataView",
"Name": "dvOuter",
"DataSource": map[string]any{
"$Type": "Forms$DataViewSource",
"EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "Shop.Order"},
"SourceVariable": pageVariable("Order"),
},
"Widgets": []any{int32(2), inner},
}
}

// A context comment is written only above a container's body, so each nested
// container carries one child.
func containerChild() map[string]any {
return map[string]any{"$Type": "Forms$TextBox", "Name": "tbInner"}
}

func nestedDataView(ds map[string]any) map[string]any {
return map[string]any{"$Type": "Forms$DataView", "Name": "dvInner", "DataSource": ds, "Widgets": []any{int32(2), containerChild()}}
}

func nestedListView(ds map[string]any) map[string]any {
return map[string]any{"$Type": "Forms$ListView", "Name": "lvInner", "DataSource": ds, "Widgets": []any{int32(2), containerChild()}}
}

// A data view over a reference reaches the association's other end, and a list
// view over a reference set reaches its elements — in both directions.
func TestDescribe_AssociationSourceContextIsTheEntityReached(t *testing.T) {
tests := []struct {
name string
inner map[string]any
wantLine string
wantExpr string
notWanted string
}{
{
name: "reference, owner to child",
inner: nestedDataView(map[string]any{"$Type": "Forms$DataViewSource", "EntityRef": indirectRef(assocStep("Shop.Order_Customer", "Shop.Customer")), "SourceVariable": pageVariable("")}),
wantLine: "-- Context: $currentObject (Shop.Customer)\n",
wantExpr: "DataSource: $currentObject/Shop.Order_Customer",
notWanted: "(Shop.Order_Customer)",
},
{
name: "reference seen from the child's side",
inner: nestedListView(assocDataSource(assocStep("Shop.Order_Customer", "Shop.Order"))),
wantLine: "-- Context: $currentObject (Shop.Order), $lvInner (selection)\n",
wantExpr: "DataSource: $currentObject/Shop.Order_Customer",
notWanted: "(Shop.Order_Customer)",
},
{
name: "reference set",
inner: nestedListView(assocDataSource(assocStep("Shop.Order_Line", "Shop.OrderLine"))),
wantLine: "-- Context: $currentObject (Shop.OrderLine), $lvInner (selection)\n",
wantExpr: "DataSource: $currentObject/Shop.Order_Line",
notWanted: "(Shop.Order_Line)",
},
{
// The stored destination can be a specialization of the association's
// own end, and that — not the end — is the entity in scope.
name: "stored destination is a specialization of the end",
inner: nestedListView(assocDataSource(assocStep("Shop.Order_Line", "Shop.SpecialLine"))),
wantLine: "-- Context: $currentObject (Shop.SpecialLine), $lvInner (selection)\n",
wantExpr: "DataSource: $currentObject/Shop.Order_Line",
notWanted: "(Shop.Order_Line)",
},
{
name: "two hops: the last destination",
inner: nestedListView(assocDataSource(
assocStep("Shop.Order_Customer", "Shop.Customer"),
assocStep("Shop.Address_Customer", "Shop.Address"))),
wantLine: "-- Context: $currentObject (Shop.Address), $lvInner (selection)\n",
wantExpr: "DataSource: $currentObject/Shop.Order_Customer/Shop.Customer/Shop.Address_Customer/Shop.Address",
notWanted: "(Shop.Order_Customer",
},
{
name: "over a page parameter instead of the context object",
inner: nestedDataView(map[string]any{
"$Type": "Forms$DataViewSource",
"EntityRef": indirectRef(assocStep("Shop.Order_Customer", "Shop.Customer")),
"SourceVariable": pageVariable("Order"),
}),
wantLine: "-- Context: $currentObject (Shop.Customer)\n",
wantExpr: "DataSource: $Order/Shop.Order_Customer",
notWanted: "(Shop.Order_Customer)",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := describeWidget(t, outerOrderView(tc.inner))
if !strings.Contains(got, tc.wantLine) {
t.Errorf("describe output lacks %q:\n%s", tc.wantLine, got)
}
if strings.Contains(got, tc.notWanted) {
t.Errorf("describe output still names the association as the context %q:\n%s", tc.notWanted, got)
}
// Only the context comment changes: the datasource text is what
// exec reads back.
if !strings.Contains(got, tc.wantExpr) {
t.Errorf("describe output lacks the unchanged datasource %q:\n%s", tc.wantExpr, got)
}
})
}
}

// The pluggable Gallery / Data grid 2 / chart datasource reads through the same
// parser (#941): an XPath source with no entity and the association hops in
// EntityRef.
func TestParseDataSource_PluggableAssociationSourceContextIsTheEntityReached(t *testing.T) {
ds := parseDataSource(map[string]any{
"$Type": "CustomWidgets$CustomWidgetXPathSource",
"EntityRef": indirectRef(assocStep("Shop.Order_Line", "Shop.OrderLine")),
})
if ds == nil || ds.Type != "association" {
t.Fatalf("parseDataSource = %+v, want an association source", ds)
}
if got := dataSourceEntityContext(nil, ds); got != "Shop.OrderLine" {
t.Errorf("dataSourceEntityContext = %q, want Shop.OrderLine", got)
}
if got := dataSourceExpr(nil, ds); got != "$currentObject/Shop.Order_Line" {
t.Errorf("dataSourceExpr = %q, want the unchanged $currentObject/Shop.Order_Line", got)
}
}

// A source whose destination is not stored keeps the reference: no worse than
// before, as the flow fix does for a flow it cannot resolve.
func TestDataSourceEntityContext_AssociationWithoutStoredDestinationKeepsTheReference(t *testing.T) {
ds := parseDataSource(assocDataSource(assocStep("Shop.Order_Customer", "")))
if ds == nil || ds.Type != "association" {
t.Fatalf("parseDataSource = %+v, want an association source", ds)
}
if got := dataSourceEntityContext(nil, ds); got != "Shop.Order_Customer" {
t.Errorf("dataSourceEntityContext = %q, want the reference kept", got)
}

// Two hops of which one has no stored destination: the path prints the
// associations alone, and so does the fallback.
ds = parseDataSource(assocDataSource(assocStep("Shop.A_B", "Shop.B"), assocStep("Shop.B_C", "")))
if got := dataSourceEntityContext(nil, ds); got != "Shop.A_B/Shop.B_C" {
t.Errorf("dataSourceEntityContext = %q, want the reference kept", got)
}
}

// Controls: a database source keeps naming its own entity, and so does a
// datasource reached over associations that is still a database retrieve.
func TestDescribe_DatabaseSourceContextUnchanged(t *testing.T) {
got := describeWidget(t, outerOrderView(nestedListView(map[string]any{
"$Type": "Forms$ListViewXPathSource",
"EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "Shop.OrderLine"},
})))
if want := "-- Context: $currentObject (Shop.OrderLine), $lvInner (selection)\n"; !strings.Contains(got, want) {
t.Errorf("describe output lacks %q:\n%s", want, got)
}

got = describeWidget(t, outerOrderView(nestedListView(map[string]any{
"$Type": "Forms$ListViewXPathSource",
"EntityRef": indirectRef(assocStep("Shop.Order_Line", "Shop.OrderLine")),
})))
if want := "-- Context: $currentObject (Shop.OrderLine), $lvInner (selection)\n"; !strings.Contains(got, want) {
t.Errorf("describe output lacks %q:\n%s", want, got)
}
}
4 changes: 2 additions & 2 deletions mdl/executor/cmd_pages_describe_datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func parseDataSource(ds map[string]any) *rawDataSource {
}
case dsType == dsTypeAssociation:
if path, ctxVar := associationSourcePath(ds); path != "" {
return &rawDataSource{Type: "association", Reference: path, ContextVariable: ctxVar}
return &rawDataSource{Type: "association", Reference: path, ContextVariable: ctxVar, Destination: associationSourceDestination(ds)}
}
case dsType == dsTypeCustomWidgetDatabase:
if got := parseCustomWidgetDatabaseSource(ds); got != nil {
Expand Down Expand Up @@ -329,7 +329,7 @@ func entityPathExpr(ds *rawDataSource) string {
// or straight from a page or snippet parameter.
func parseContextSource(ds map[string]any) *rawDataSource {
if path, ctxVar := associationSourcePath(ds); path != "" {
return &rawDataSource{Type: "association", Reference: path, ContextVariable: ctxVar}
return &rawDataSource{Type: "association", Reference: path, ContextVariable: ctxVar, Destination: associationSourceDestination(ds)}
}
if sv, ok := ds["SourceVariable"].(map[string]any); ok && sv != nil {
if param := extractString(sv["PageParameter"]); param != "" {
Expand Down
Loading
Loading