Skip to content

Commit 0583803

Browse files
akoclaude
andcommitted
fix: resolve DestinationEntity for single-segment association DataSource (mendixlabs#198)
When a DataGrid 2 DataSource was written as \`\$currentObject/Module.Assoc\` without the explicit destination entity, the writer emitted BSON with \`DestinationEntity: \"\"\`, which Mendix rejected at load time with: An error occurred when trying to set the 'DestinationEntity' property of a Entity ref step The executor now looks up the association in the domain model and resolves the destination as the entity OPPOSITE to the parent DataView's context entity (following the ParentID=FROM, ChildID=TO convention). The resolved destination is also returned as the child entity context, so column bindings inside the association-sourced widget can resolve short attribute names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 90afa65 commit 0583803

1 file changed

Lines changed: 110 additions & 4 deletions

File tree

mdl/executor/cmd_pages_builder_v3.go

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -587,20 +587,34 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource
587587

588588
case "association":
589589
// Association path source — emits Forms$AssociationSource BSON.
590-
// EntityPath is the qualified association name (and optional /DestinationEntity).
591-
// ContextVariable is the page parameter name (empty when source is $currentObject of parent dataview).
590+
// ds.Reference is either "Module.Assoc" (single-segment) or
591+
// "Module.Assoc/Module.DestEntity" (multi-segment, dest explicit).
592+
// For single-segment, resolve DestinationEntity from the association
593+
// definition (the side opposite to the parent context entity).
592594
ctxVar := ds.ContextVariable
593595
if ctxVar == "currentObject" {
594596
ctxVar = "" // implicit context — no SourceVariable in BSON
595597
}
598+
599+
path := ds.Reference
600+
destEntity := ""
601+
if idx := strings.Index(path, "/"); idx >= 0 {
602+
destEntity = path[idx+1:]
603+
path = path[:idx]
604+
} else {
605+
destEntity = pb.resolveAssociationDestination(path, pb.entityContext)
606+
}
607+
608+
// Return destEntity as the child context so column bindings inside the
609+
// widget can resolve short attribute names against it.
596610
return &pages.AssociationSource{
597611
BaseElement: model.BaseElement{
598612
ID: model.ID(mpr.GenerateID()),
599613
TypeName: "Forms$AssociationSource",
600614
},
601-
EntityPath: ds.Reference,
615+
EntityPath: path + "/" + destEntity,
602616
ContextVariable: ctxVar,
603-
}, "", nil
617+
}, destEntity, nil
604618

605619
case "selection":
606620
// Selection from another widget
@@ -627,6 +641,98 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource
627641
}
628642
}
629643

644+
// resolveAssociationDestination looks up an association by qualified name and returns
645+
// the qualified name of the entity OPPOSITE to contextEntity. Returns "" if the
646+
// association can't be resolved or the context isn't on either end.
647+
//
648+
// Convention (per CLAUDE.md): ParentID = FROM entity, ChildID = TO entity.
649+
// For `Module.OrderLine_Order` (`FROM OrderLine TO Order`), context=Order → dest=OrderLine (parent side).
650+
func (pb *pageBuilder) resolveAssociationDestination(assocQN, contextEntity string) string {
651+
if assocQN == "" {
652+
return ""
653+
}
654+
parts := strings.SplitN(assocQN, ".", 2)
655+
if len(parts) != 2 {
656+
return ""
657+
}
658+
modName, assocName := parts[0], parts[1]
659+
660+
domainModels, err := pb.reader.ListDomainModels()
661+
if err != nil {
662+
return ""
663+
}
664+
for _, dm := range domainModels {
665+
for _, a := range dm.Associations {
666+
if a.Name != assocName {
667+
continue
668+
}
669+
// Resolve entity qualified names for ParentID and ChildID.
670+
// Entity IDs are unique across the project; look up the entity
671+
// in any domain model.
672+
parentEntity := pb.entityQNByID(a.ParentID)
673+
childEntity := pb.entityQNByID(a.ChildID)
674+
// Module-scope safety: the association module must match the first
675+
// segment of the qualified name we were given.
676+
_ = modName
677+
// The "destination" is the end OPPOSITE to the context.
678+
if contextEntity != "" {
679+
if contextEntity == childEntity {
680+
return parentEntity
681+
}
682+
if contextEntity == parentEntity {
683+
return childEntity
684+
}
685+
}
686+
// No context or mismatch — default to the child (TO) side, which
687+
// matches the common FROM=context pattern.
688+
return childEntity
689+
}
690+
}
691+
return ""
692+
}
693+
694+
// entityQNByID returns the qualified name (Module.Entity) for a given entity ID
695+
// by scanning all domain models. Returns "" if not found.
696+
func (pb *pageBuilder) entityQNByID(entityID model.ID) string {
697+
if entityID == "" {
698+
return ""
699+
}
700+
domainModels, err := pb.reader.ListDomainModels()
701+
if err != nil {
702+
return ""
703+
}
704+
for _, dm := range domainModels {
705+
for _, e := range dm.Entities {
706+
if e.ID == entityID {
707+
// Look up module name via the domain model's container
708+
modName := pb.moduleNameByID(dm.ContainerID)
709+
if modName == "" {
710+
return e.Name
711+
}
712+
return modName + "." + e.Name
713+
}
714+
}
715+
}
716+
return ""
717+
}
718+
719+
// moduleNameByID returns the module name for a given module ID. Cached via hierarchy.
720+
func (pb *pageBuilder) moduleNameByID(moduleID model.ID) string {
721+
if moduleID == "" {
722+
return ""
723+
}
724+
modules, err := pb.reader.ListModules()
725+
if err != nil {
726+
return ""
727+
}
728+
for _, m := range modules {
729+
if m.ID == moduleID {
730+
return m.Name
731+
}
732+
}
733+
return ""
734+
}
735+
630736
// getMicroflowReturnEntityName looks up a microflow and returns its return type entity name.
631737
// Returns empty string if the microflow doesn't return an entity or list of entities.
632738
func (pb *pageBuilder) getMicroflowReturnEntityName(qualifiedName string) string {

0 commit comments

Comments
 (0)