Skip to content

Commit 20f5a51

Browse files
claudeako
authored andcommitted
fix(pages): expand multi-hop association XPath paths to avoid CE1613
A datasource constrained two associations away with the natural shorthand database from Travel.Activity where [Travel.Day_Activity/Travel.Trip_Day = $Trip] failed with CE1613 'The selected entity Travel.Trip_Day no longer exists': Mendix XPath requires the intermediate entity between two association hops (Assoc1/Entity/Assoc2), and mxcli stored the shorthand verbatim so mxbuild read the second association as an entity. buildDataSourceV3 now expands consecutive association segments, inserting the intermediate entity resolved from each association's target (resolveAssociationDestination). Safe by construction: only runs of slash-joined qualified names anchored at an association from the datasource entity are touched, and an entity is inserted only between two segments that both resolve as associations — so the already-correct full form, single-hop constraints, and attribute paths are returned verbatim. Verified against the reporter's Travel schema: the two-hop Timeline datasource resolves, mx check 0 errors. Known related (separate): attribute-over-association shorthand ([Assoc/Attribute = x], needs [Assoc/Entity/Attribute = x]) is not yet expanded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
1 parent 91b054b commit 20f5a51

2 files changed

Lines changed: 114 additions & 2 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
-- ============================================================================
2+
-- Bug: multi-hop association XPath in a datasource trips CE1613
3+
-- ============================================================================
4+
--
5+
-- Symptom (from a Travel itinerary project):
6+
-- A Timeline over Activities constrained to a Trip two associations away:
7+
-- datasource: database from Travel.Activity
8+
-- where [Travel.Day_Activity/Travel.Trip_Day = $Trip]
9+
-- failed `mx check` / the deploy build with:
10+
-- [CE1613] "The selected entity 'Travel.Trip_Day' no longer exists."
11+
-- mxbuild read the second association segment as an ENTITY.
12+
--
13+
-- Root cause:
14+
-- Mendix XPath requires the intermediate entity between two association hops
15+
-- — `[Assoc1/Entity/Assoc2 = $x]`. The MDL shorthand `[Assoc1/Assoc2 = $x]`
16+
-- (natural, and what an LLM emits) omits it, and mxcli stored the constraint
17+
-- verbatim, so mxbuild couldn't resolve the second hop.
18+
--
19+
-- Fix:
20+
-- buildDataSourceV3 now expands consecutive association segments, inserting the
21+
-- intermediate entity (resolved from each association's target). The full form,
22+
-- single-hop constraints, and attribute paths are left verbatim, so it never
23+
-- corrupts a valid constraint.
24+
--
25+
-- Verify:
26+
-- mxcli exec mdl-examples/bug-tests/xpath-assoc-path-expansion.mdl -p app.mpr
27+
-- mx check app.mpr # 0 errors — the two-hop path resolves
28+
-- ============================================================================
29+
30+
create module Travel;
31+
/
32+
create entity Travel.Trip ( Title: String );
33+
create entity Travel.Day ( DayNumber: Integer );
34+
create entity Travel.Activity ( Name: String, StartTime: DateTime );
35+
/
36+
-- Day belongs to Trip; Activity belongs to Day.
37+
create association Travel.Trip_Day from Travel.Day to Travel.Trip;
38+
create association Travel.Day_Activity from Travel.Activity to Travel.Day;
39+
/
40+
create or replace page Travel.Trip_Detail (
41+
params: { $Trip: Travel.Trip },
42+
layout: Atlas_Core.Atlas_Default
43+
) {
44+
-- one-hop reverse association (already worked; must stay working)
45+
pluggablewidget 'com.mendix.widget.web.treenode.TreeNode' tnDays (
46+
datasource: database from Travel.Day where [Travel.Trip_Day = $Trip] sort by DayNumber asc,
47+
headerType: 'text', headerCaption: 'Day {DayNumber}', hasChildren: false, showIcon: 'left'
48+
)
49+
-- two-hop reverse association shorthand — expanded to Assoc/Entity/Assoc.
50+
pluggablewidget 'com.mendix.widget.web.timeline.Timeline' tlTrip (
51+
datasource: database from Travel.Activity where [Travel.Day_Activity/Travel.Trip_Day = $Trip] sort by StartTime asc,
52+
title: '{Name}',
53+
groupEvents: false,
54+
customVisualization: false
55+
)
56+
}

mdl/executor/cmd_pages_builder_v3.go

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package executor
55
import (
66
"fmt"
77
"log"
8+
"regexp"
89
"strings"
910

1011
"github.com/mendixlabs/mxcli/mdl/ast"
@@ -663,9 +664,11 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource
663664
EntityName: ds.Reference,
664665
}
665666

666-
// Handle WHERE clause
667+
// Handle WHERE clause. Expand association-only paths to the Assoc/Entity/Assoc
668+
// form Mendix requires (see expandXPathAssociationPath), so the shorthand
669+
// `[Mod.Assoc1/Mod.Assoc2 = $x]` doesn't trip CE1613 at build time.
667670
if ds.Where != "" {
668-
dbSource.XPathConstraint = ds.Where
671+
dbSource.XPathConstraint = pb.expandXPathAssociationPath(ds.Where, ds.Reference)
669672
}
670673

671674
// Handle ORDER BY
@@ -787,6 +790,59 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource
787790
//
788791
// Convention (per CLAUDE.md): ParentID = FROM entity, ChildID = TO entity.
789792
// For `Module.OrderLine_Order` (`FROM OrderLine TO Order`), context=Order → dest=OrderLine (parent side).
793+
// xpathAssocRunRe matches a run of two or more slash-joined qualified names
794+
// (Module.Name/Module.Name[/...]) — an association/entity path. Single qualified
795+
// names (one-hop `[Mod.Assoc = $x]`) and unqualified attribute steps (`Mod.Assoc/Attr`)
796+
// don't match, so they're never rewritten.
797+
var xpathAssocRunRe = regexp.MustCompile(`[A-Za-z_]\w*\.[A-Za-z_]\w*(?:/[A-Za-z_]\w*\.[A-Za-z_]\w*)+`)
798+
799+
// expandXPathAssociationPath rewrites an XPath constraint so that consecutive
800+
// association segments get the intermediate entity Mendix requires between them:
801+
// `[Mod.Assoc1/Mod.Assoc2 = $x]` → `[Mod.Assoc1/Mod.Entity/Mod.Assoc2 = $x]`. Without
802+
// this, mxbuild reads the second association as an entity and fails with CE1613
803+
// ("selected entity ... no longer exists").
804+
//
805+
// The rewrite is safe by construction: it only touches runs of slash-joined qualified
806+
// names anchored at an association from the datasource entity, and inserts an entity
807+
// only between two segments that both resolve as associations. The already-correct
808+
// full form (where the middle segment is an entity, not an association), single-hop
809+
// constraints, attribute paths, and anything unresolvable are returned verbatim.
810+
func (pb *pageBuilder) expandXPathAssociationPath(constraint, contextEntity string) string {
811+
if contextEntity == "" || !strings.Contains(constraint, "/") {
812+
return constraint
813+
}
814+
return xpathAssocRunRe.ReplaceAllStringFunc(constraint, func(run string) string {
815+
segs := strings.Split(run, "/")
816+
// Only expand a run that starts with an association reachable from the
817+
// datasource entity; otherwise leave it untouched (it may be an unrelated
818+
// qualified path we don't understand).
819+
if pb.resolveAssociationDestination(segs[0], contextEntity) == "" {
820+
return run
821+
}
822+
out := make([]string, 0, len(segs)*2)
823+
ctx := contextEntity
824+
for i, seg := range segs {
825+
dest := pb.resolveAssociationDestination(seg, ctx)
826+
out = append(out, seg)
827+
if dest == "" {
828+
// Not an association from here — treat as an entity segment.
829+
ctx = seg
830+
continue
831+
}
832+
// Association: if the next segment is also an association (i.e. the
833+
// intermediate entity was omitted), insert the destination entity.
834+
if i+1 < len(segs) {
835+
next := segs[i+1]
836+
if next != dest && pb.resolveAssociationDestination(next, dest) != "" {
837+
out = append(out, dest)
838+
}
839+
}
840+
ctx = dest
841+
}
842+
return strings.Join(out, "/")
843+
})
844+
}
845+
790846
func (pb *pageBuilder) resolveAssociationDestination(assocQN, contextEntity string) string {
791847
if assocQN == "" {
792848
return ""

0 commit comments

Comments
 (0)