Skip to content

Commit 8e824a9

Browse files
akoclaude
andcommitted
fix(retrieve): use DatabaseRetrieveSource for reverse Reference association traversal
AssociationRetrieveSource with a Reference type association always returns a single object in Mendix, regardless of traversal direction. When navigating from the child (non-owner) side — e.g. Customer → Orders via Order_Customer — the intent is to get a list. The executor now detects this case by looking up the association metadata and emits a DatabaseRetrieveSource with an XPath constraint instead, which correctly returns a list. Also treats RETURNS Void as void return type (same as Nothing). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0db2804 commit 8e824a9

1 file changed

Lines changed: 86 additions & 7 deletions

File tree

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/mendixlabs/mxcli/mdl/ast"
1111
"github.com/mendixlabs/mxcli/model"
12+
"github.com/mendixlabs/mxcli/sdk/domainmodel"
1213
"github.com/mendixlabs/mxcli/sdk/microflows"
1314
"github.com/mendixlabs/mxcli/sdk/mpr"
1415
)
@@ -292,14 +293,51 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
292293
if s.StartVariable != "" {
293294
// Association retrieve: RETRIEVE $List FROM $Parent/Module.AssocName
294295
assocQN := s.Source.Module + "." + s.Source.Name
295-
source = &microflows.AssociationRetrieveSource{
296-
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
297-
StartVariable: s.StartVariable,
298-
AssociationQualifiedName: assocQN,
299-
}
300-
// Association retrieve always returns a list
296+
297+
// Look up association to determine type and direction.
298+
// For Reference associations, AssociationRetrieveSource always returns a single
299+
// object (the entity on the other end). When the user navigates from the child
300+
// (non-owner) side, the intent is to get a list of parent entities — we must use
301+
// a DatabaseRetrieveSource with XPath constraint instead.
302+
assocInfo := fb.lookupAssociation(s.Source.Module, s.Source.Name)
303+
startVarType := ""
301304
if fb.varTypes != nil {
302-
fb.varTypes[s.Variable] = "List of " + assocQN
305+
startVarType = fb.varTypes[s.StartVariable]
306+
}
307+
308+
if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReference &&
309+
assocInfo.childEntityQN != "" && startVarType == assocInfo.childEntityQN {
310+
// Reverse traversal on Reference: child → parent (one-to-many)
311+
// Use DatabaseRetrieveSource with XPath to get a list of parent entities
312+
dbSource := &microflows.DatabaseRetrieveSource{
313+
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
314+
EntityQualifiedName: assocInfo.parentEntityQN,
315+
XPathConstraint: "[" + assocQN + " = $" + s.StartVariable + "]",
316+
}
317+
source = dbSource
318+
if fb.varTypes != nil {
319+
fb.varTypes[s.Variable] = "List of " + assocInfo.parentEntityQN
320+
}
321+
} else {
322+
// Forward traversal or ReferenceSet: use AssociationRetrieveSource
323+
source = &microflows.AssociationRetrieveSource{
324+
BaseElement: model.BaseElement{ID: model.ID(mpr.GenerateID())},
325+
StartVariable: s.StartVariable,
326+
AssociationQualifiedName: assocQN,
327+
}
328+
if fb.varTypes != nil {
329+
if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReference {
330+
// Reference forward traversal: returns single object
331+
otherEntity := assocInfo.childEntityQN
332+
if startVarType == assocInfo.childEntityQN {
333+
otherEntity = assocInfo.parentEntityQN
334+
}
335+
fb.varTypes[s.Variable] = otherEntity
336+
} else {
337+
// ReferenceSet or unknown: returns a list
338+
fb.varTypes[s.Variable] = "List of " + assocQN
339+
}
340+
}
303341
}
304342
} else {
305343
// Database retrieve: RETRIEVE $List FROM Module.Entity WHERE ...
@@ -687,3 +725,44 @@ func (fb *flowBuilder) addRemoveFromListAction(s *ast.RemoveFromListStmt) model.
687725
fb.posX += fb.spacing
688726
return activity.ID
689727
}
728+
729+
// assocLookupResult holds resolved association metadata.
730+
type assocLookupResult struct {
731+
Type domainmodel.AssociationType
732+
parentEntityQN string // Qualified name of the parent (FROM/owner) entity
733+
childEntityQN string // Qualified name of the child (TO/referenced) entity
734+
}
735+
736+
// lookupAssociation finds an association by module and name, returning its type
737+
// and the qualified names of its parent and child entities. Returns nil if the
738+
// association cannot be found (e.g., reader is nil or module doesn't exist).
739+
func (fb *flowBuilder) lookupAssociation(moduleName, assocName string) *assocLookupResult {
740+
if fb.reader == nil {
741+
return nil
742+
}
743+
mod, err := fb.reader.GetModuleByName(moduleName)
744+
if err != nil || mod == nil {
745+
return nil
746+
}
747+
dm, err := fb.reader.GetDomainModel(mod.ID)
748+
if err != nil || dm == nil {
749+
return nil
750+
}
751+
752+
// Build entity ID → qualified name map
753+
entityNames := make(map[model.ID]string, len(dm.Entities))
754+
for _, e := range dm.Entities {
755+
entityNames[e.ID] = moduleName + "." + e.Name
756+
}
757+
758+
for _, a := range dm.Associations {
759+
if a.Name == assocName {
760+
return &assocLookupResult{
761+
Type: a.Type,
762+
parentEntityQN: entityNames[a.ParentID],
763+
childEntityQN: entityNames[a.ChildID],
764+
}
765+
}
766+
}
767+
return nil
768+
}

0 commit comments

Comments
 (0)