@@ -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.
632738func (pb * pageBuilder ) getMicroflowReturnEntityName (qualifiedName string ) string {
0 commit comments