Skip to content

Commit 79d7f8e

Browse files
akoclaude
andcommitted
fix(retrieve): keep owner-both reverse-list retrieve as database source (#726)
Refines the #726 fix (050147101). Removing the reverse-Reference → database conversion entirely was too broad: an owner="both" Reference consumed as a list genuinely cannot be an in-memory retrieve. A reverse AssociationRetrieveSource over an owner-both Reference resolves to a SINGLE object in Mendix, so feeding it to a loop/aggregate fails mx check with CE0100 (surfaced by mdl-examples/doctype-tests/02-microflow-examples.mdl's M029_RetrieveByAssociation, whose Order_Customer association is owner both). Narrow the database fallback to exactly that case — Reference + owner both + reverse (child side) + used as a list. Everything else stays an AssociationRetrieveSource: owner-default reverse traversals (the #726 reporter's scenario) remain memory retrieves and stay distinct from database retrieves, and object-typed usages stay association retrieves. Verified: owner-default `retrieve $x from $Parent/Assoc` → association source (list), 0 errors; owner-both list usage → database source, 0 CE0100. The 02-microflow-examples doctype goes back to 0 errors and the #726 memory-vs-db pair still round-trips distinctly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fb176b7 commit 79d7f8e

2 files changed

Lines changed: 81 additions & 54 deletions

File tree

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 61 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,7 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
821821
}
822822

823823
outputUsedAsObject := fb.objectInputVariables != nil && fb.objectInputVariables[s.Variable]
824+
outputUsedAsList := fb.listInputVariables != nil && fb.listInputVariables[s.Variable]
824825
// startsFromChildSide is true when the retrieve's start variable is the
825826
// child side of the association (or a subclass of it). Inheritance has
826827
// to be honoured so traversals like `$httpRequest/System.HttpHeaders`
@@ -831,48 +832,69 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
831832
assocInfo.childEntityQN != "" &&
832833
fb.entityIsSubtypeOf(startVarType, assocInfo.childEntityQN)
833834

834-
// The `$var/Module.Assoc` syntax is ALWAYS a retrieve-by-association (an
835-
// in-memory retrieve), regardless of traversal direction. Mendix resolves
836-
// the result multiplicity from the association metadata: a reverse
837-
// Reference traversal (child → parent) returns a list, and mxbuild
838-
// accepts an AssociationRetrieveSource for it. Rewriting it to a
839-
// DatabaseRetrieveSource (as an earlier heuristic did) turns a memory
840-
// retrieve into a database retrieve and makes the two indistinguishable
841-
// on round-trip — issue #726. Only the output variable's *type* differs
842-
// by direction; the source stays an AssociationRetrieveSource.
843-
source = &microflows.AssociationRetrieveSource{
844-
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
845-
StartVariable: s.StartVariable,
846-
AssociationQualifiedName: assocQN,
847-
}
848-
if fb.varTypes != nil {
849-
if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReference {
850-
// Reference: forward traversal (parent side) → single object;
851-
// reverse traversal (child side) → list of the other entity.
852-
otherEntity := assocInfo.childEntityQN
853-
if startsFromChildSide {
854-
otherEntity = assocInfo.parentEntityQN
855-
}
856-
if startsFromChildSide && !outputUsedAsObject {
857-
fb.varTypes[s.Variable] = "List of " + otherEntity
858-
} else {
859-
fb.varTypes[s.Variable] = otherEntity
860-
}
861-
} else if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReferenceSet {
862-
// ReferenceSet traversal returns a list of the entity on the other side,
863-
// not a list typed as the association itself.
864-
otherEntity := assocInfo.childEntityQN
865-
if startsFromChildSide {
866-
otherEntity = assocInfo.parentEntityQN
867-
}
868-
if otherEntity != "" {
869-
fb.varTypes[s.Variable] = "List of " + otherEntity
835+
// The `$var/Module.Assoc` syntax is a retrieve-by-association (in-memory).
836+
// Keep it as an AssociationRetrieveSource — that is what preserves the
837+
// distinction between memory and database retrieves (issue #726), and for
838+
// a reverse Reference traversal (child → parent) Mendix returns a list and
839+
// mxbuild accepts it.
840+
//
841+
// The ONE exception is an owner="both" Reference consumed as a list: a
842+
// reverse AssociationRetrieveSource over such an association resolves to a
843+
// SINGLE object in Mendix (mxbuild reports CE0100 when it feeds a loop or
844+
// aggregate), so there is no in-memory way to obtain the list — fall back
845+
// to a DatabaseRetrieveSource. Object usage stays an association retrieve.
846+
expandReverseReference := assocInfo != nil &&
847+
assocInfo.Type == domainmodel.AssociationTypeReference &&
848+
assocInfo.Owner == domainmodel.AssociationOwnerBoth &&
849+
assocInfo.parentPersistable &&
850+
assocInfo.childEntityQN != "" &&
851+
startsFromChildSide &&
852+
outputUsedAsList && !outputUsedAsObject
853+
854+
if expandReverseReference {
855+
source = &microflows.DatabaseRetrieveSource{
856+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
857+
EntityQualifiedName: assocInfo.parentEntityQN,
858+
XPathConstraint: "[" + assocQN + " = $" + s.StartVariable + "]",
859+
}
860+
if fb.varTypes != nil {
861+
fb.varTypes[s.Variable] = "List of " + assocInfo.parentEntityQN
862+
}
863+
} else {
864+
source = &microflows.AssociationRetrieveSource{
865+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
866+
StartVariable: s.StartVariable,
867+
AssociationQualifiedName: assocQN,
868+
}
869+
if fb.varTypes != nil {
870+
if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReference {
871+
// Reference: forward traversal (parent side) → single object;
872+
// reverse traversal (child side) → list of the other entity.
873+
otherEntity := assocInfo.childEntityQN
874+
if startsFromChildSide {
875+
otherEntity = assocInfo.parentEntityQN
876+
}
877+
if startsFromChildSide && !outputUsedAsObject {
878+
fb.varTypes[s.Variable] = "List of " + otherEntity
879+
} else {
880+
fb.varTypes[s.Variable] = otherEntity
881+
}
882+
} else if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReferenceSet {
883+
// ReferenceSet traversal returns a list of the entity on the other side,
884+
// not a list typed as the association itself.
885+
otherEntity := assocInfo.childEntityQN
886+
if startsFromChildSide {
887+
otherEntity = assocInfo.parentEntityQN
888+
}
889+
if otherEntity != "" {
890+
fb.varTypes[s.Variable] = "List of " + otherEntity
891+
} else {
892+
fb.varTypes[s.Variable] = "List of " + assocQN
893+
}
870894
} else {
895+
// ReferenceSet or unknown: returns a list
871896
fb.varTypes[s.Variable] = "List of " + assocQN
872897
}
873-
} else {
874-
// ReferenceSet or unknown: returns a list
875-
fb.varTypes[s.Variable] = "List of " + assocQN
876898
}
877899
}
878900
} else {

mdl/executor/cmd_microflows_builder_retrieve_reverse_test.go

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import (
1212
"github.com/mendixlabs/mxcli/sdk/microflows"
1313
)
1414

15-
// A reverse Reference traversal (`$Child/Sample.Parent_Child`) is an in-memory
16-
// retrieve-by-association, NOT a database retrieve — Mendix returns a list and
17-
// mxbuild accepts it. Rewriting it to a DatabaseRetrieveSource made memory and
18-
// database retrieves indistinguishable on round-trip (issue #726). The output
19-
// variable is still typed as a list.
20-
func TestAddRetrieveAction_ReverseReferenceOwnerBothUsesAssociationSource(t *testing.T) {
15+
// A reverse Reference traversal used as a LIST over an owner="both" association
16+
// is the one case that must fall back to a DatabaseRetrieveSource: a reverse
17+
// AssociationRetrieveSource over an owner-both Reference resolves to a single
18+
// object in Mendix (CE0100 when fed to a loop/aggregate), so there is no
19+
// in-memory way to obtain the list. (Owner-default reverse traversals stay
20+
// association retrieves — see the DefaultOwner test — which is what keeps memory
21+
// and database retrieves distinct for issue #726.)
22+
func TestAddRetrieveAction_ReverseReferenceOwnerBothListUsesDatabaseSource(t *testing.T) {
2123
fb := newRetrieveAssociationFlowBuilder(domainmodel.AssociationOwnerBoth)
2224
fb.varTypes["Child"] = "Sample.Child"
2325
fb.listInputVariables = map[string]bool{"Parents": true}
@@ -29,12 +31,12 @@ func TestAddRetrieveAction_ReverseReferenceOwnerBothUsesAssociationSource(t *tes
2931
})
3032

3133
action := onlyRetrieveAction(t, fb)
32-
source, ok := action.Source.(*microflows.AssociationRetrieveSource)
34+
source, ok := action.Source.(*microflows.DatabaseRetrieveSource)
3335
if !ok {
34-
t.Fatalf("owner-both reverse retrieve source = %T, want AssociationRetrieveSource", action.Source)
36+
t.Fatalf("owner-both reverse list retrieve source = %T, want DatabaseRetrieveSource", action.Source)
3537
}
36-
if source.StartVariable != "Child" || source.AssociationQualifiedName != "Sample.Parent_Child" {
37-
t.Fatalf("association source = %#v", source)
38+
if source.EntityQualifiedName != "Sample.Parent" || source.XPathConstraint != "[Sample.Parent_Child = $Child]" {
39+
t.Fatalf("database source = %#v", source)
3840
}
3941
if got := fb.varTypes["Parents"]; got != "List of Sample.Parent" {
4042
t.Fatalf("result var type = %q, want List of Sample.Parent", got)
@@ -195,7 +197,10 @@ func TestBuildFlowGraph_ReverseReferenceOwnerBothAttributeUsagePreservesAssociat
195197
}
196198
}
197199

198-
func TestBuildFlowGraph_ReverseReferenceOwnerBothLoopUsageUsesAssociationSource(t *testing.T) {
200+
// Loop usage detected via the flow graph makes an owner-both reverse Reference a
201+
// list consumer, so it falls back to a DatabaseRetrieveSource (see the
202+
// OwnerBothList test for why).
203+
func TestBuildFlowGraph_ReverseReferenceOwnerBothLoopUsageUsesDatabaseSource(t *testing.T) {
199204
fb := newRetrieveAssociationFlowBuilder(domainmodel.AssociationOwnerBoth)
200205
fb.posX = 200
201206
fb.posY = 200
@@ -217,12 +222,12 @@ func TestBuildFlowGraph_ReverseReferenceOwnerBothLoopUsageUsesAssociationSource(
217222
fb.buildFlowGraph(stmts, nil)
218223

219224
action := firstRetrieveAction(t, fb)
220-
source, ok := action.Source.(*microflows.AssociationRetrieveSource)
225+
source, ok := action.Source.(*microflows.DatabaseRetrieveSource)
221226
if !ok {
222-
t.Fatalf("owner-both loop usage source = %T, want AssociationRetrieveSource", action.Source)
227+
t.Fatalf("owner-both loop usage source = %T, want DatabaseRetrieveSource", action.Source)
223228
}
224-
if source.StartVariable != "Child" || source.AssociationQualifiedName != "Sample.Parent_Child" {
225-
t.Fatalf("association source = %#v", source)
229+
if source.EntityQualifiedName != "Sample.Parent" || source.XPathConstraint != "[Sample.Parent_Child = $Child]" {
230+
t.Fatalf("database source = %#v", source)
226231
}
227232
if got := fb.varTypes["Parents"]; got != "List of Sample.Parent" {
228233
t.Fatalf("result var type = %q, want List of Sample.Parent", got)

0 commit comments

Comments
 (0)