Skip to content

Commit fb176b7

Browse files
akoclaude
andcommitted
fix(retrieve): keep memory vs database retrieves distinct (#726)
A retrieve-by-association (in-memory) and a database retrieve with an equivalent reverse-association XPath were indistinguishable in MDL, and re-running a script turned the memory retrieve into a database retrieve — silently changing behavior and breaking round-trip. Two root causes, both from conflating the two forms: 1. Build (cmd_microflows_builder_actions.go): a reverse-Reference `from $var/Assoc` was rewritten to a DatabaseRetrieveSource, on the assumption that "an AssociationRetrieveSource always returns a single object." That's false — Mendix returns a *list* for reverse traversal and mxbuild accepts the AssociationRetrieveSource (verified: a reverse association retrieve feeding count() passes mx check with 0 errors). Now `from $var/Assoc` is ALWAYS an AssociationRetrieveSource; only the output variable's type varies by direction (single vs list). 2. Describe (cmd_microflows_format_action.go): a DatabaseRetrieveSource whose XPath was a reverse-association constraint was rendered with the association shorthand `from $var/Assoc`. Now a database retrieve ALWAYS renders as `from Entity where …`. Verified end-to-end on 11.12: the two retrieves render distinctly, keep their source types (AssociationRetrieveSource vs DatabaseRetrieveSource), round-trip identically, and mx check reports 0 errors. Updated the reverse-reference builder/format tests that encoded the old behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8f240e9 commit fb176b7

5 files changed

Lines changed: 125 additions & 80 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
-- ============================================================================
2+
-- Bug #726 — database and memory (association) retrieves indistinguishable
3+
-- ============================================================================
4+
--
5+
-- Symptom (before fix):
6+
-- A retrieve-by-association (in-memory) and a database retrieve whose XPath is
7+
-- a reverse-association constraint both rendered as the association shorthand
8+
-- `retrieve $x from $Order/Module.Assoc`, so they were indistinguishable. And
9+
-- re-running the script turned BOTH into database retrieves — the memory
10+
-- retrieve was silently lost.
11+
--
12+
-- Two root causes:
13+
-- 1. Build: a reverse-Reference `from $var/Assoc` was rewritten to a
14+
-- DatabaseRetrieveSource (the heuristic wrongly assumed an
15+
-- AssociationRetrieveSource always returns a single object). Mendix in
16+
-- fact returns a list for reverse traversal and mxbuild accepts it.
17+
-- 2. Describe: a DatabaseRetrieveSource with a reverse-association XPath was
18+
-- rendered using the association shorthand.
19+
--
20+
-- After fix:
21+
-- `from $var/Assoc` is ALWAYS an AssociationRetrieveSource (memory); a
22+
-- database retrieve ALWAYS renders as `from Entity where …`. The two are
23+
-- distinct and round-trip stably.
24+
--
25+
-- Usage:
26+
-- mxcli exec mdl-examples/bug-tests/726-retrieve-db-vs-memory-distinct.mdl -p app.mpr
27+
-- mxcli -p app.mpr -c "describe microflow TestBug726.ACT_get_test"
28+
-- The two retrieves must render differently (one `from $Order/…`, one
29+
-- `from …OrderLine where …`), and mx check must report 0 errors.
30+
-- ============================================================================
31+
32+
create module TestBug726;
33+
34+
create persistent entity TestBug726.Order ( OrderNo: string(50) );
35+
create persistent entity TestBug726.OrderLine ( LineNo: integer );
36+
create association TestBug726.OrderLine_Order
37+
from TestBug726.OrderLine to TestBug726.Order
38+
type reference owner default;
39+
/
40+
41+
create microflow TestBug726.ACT_get_test ( $Order: TestBug726.Order )
42+
begin
43+
-- memory / retrieve-by-association (reverse Reference -> list of OrderLine)
44+
retrieve $OrderLineList_M from $Order/TestBug726.OrderLine_Order;
45+
-- database retrieve with the equivalent XPath
46+
retrieve $OrderLineList_DB from TestBug726.OrderLine
47+
where TestBug726.OrderLine_Order = $Order;
48+
return;
49+
end;
50+
/

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 42 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -820,76 +820,59 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
820820
startVarType = fb.varTypes[s.StartVariable]
821821
}
822822

823-
outputUsedAsList := fb.listInputVariables != nil && fb.listInputVariables[s.Variable]
824823
outputUsedAsObject := fb.objectInputVariables != nil && fb.objectInputVariables[s.Variable]
825824
// startsFromChildSide is true when the retrieve's start variable is the
826825
// child side of the association (or a subclass of it). Inheritance has
827826
// to be honoured so traversals like `$httpRequest/System.HttpHeaders`
828827
// — where HttpRequest extends HttpMessage and HttpHeaders has child
829-
// HttpMessage — are still classified as reverse traversal.
828+
// HttpMessage — are still classified as reverse traversal (which returns
829+
// a list, so the output variable is typed accordingly below).
830830
startsFromChildSide := assocInfo != nil &&
831831
assocInfo.childEntityQN != "" &&
832832
fb.entityIsSubtypeOf(startVarType, assocInfo.childEntityQN)
833-
// Owner-both Reference associations need later usage context: the same
834-
// compact retrieve can be consumed as either a list or a single object.
835-
// Owner="" means metadata was unavailable, so keep the association source.
836-
expandReverseReference := assocInfo != nil &&
837-
assocInfo.Type == domainmodel.AssociationTypeReference &&
838-
assocInfo.Owner != "" &&
839-
assocInfo.parentPersistable &&
840-
assocInfo.childEntityQN != "" &&
841-
startsFromChildSide &&
842-
(assocInfo.Owner != domainmodel.AssociationOwnerBoth || (outputUsedAsList && !outputUsedAsObject))
843-
844-
if expandReverseReference {
845-
// Reverse traversal on Reference: child → parent (one-to-many)
846-
// Use DatabaseRetrieveSource with XPath to get a list of parent entities
847-
dbSource := &microflows.DatabaseRetrieveSource{
848-
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
849-
EntityQualifiedName: assocInfo.parentEntityQN,
850-
XPathConstraint: "[" + assocQN + " = $" + s.StartVariable + "]",
851-
}
852-
source = dbSource
853-
if fb.varTypes != nil {
854-
fb.varTypes[s.Variable] = "List of " + assocInfo.parentEntityQN
855-
}
856-
} else {
857-
// Forward traversal or ReferenceSet: use AssociationRetrieveSource
858-
source = &microflows.AssociationRetrieveSource{
859-
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
860-
StartVariable: s.StartVariable,
861-
AssociationQualifiedName: assocQN,
862-
}
863-
if fb.varTypes != nil {
864-
if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReference {
865-
// Forward Reference traversal returns a single object. Legacy or
866-
// non-persistable reverse traversal can still use association
867-
// source syntax, but keeps list typing for downstream actions.
868-
otherEntity := assocInfo.childEntityQN
869-
if startsFromChildSide {
870-
otherEntity = assocInfo.parentEntityQN
871-
}
872-
if startsFromChildSide && !outputUsedAsObject {
873-
fb.varTypes[s.Variable] = "List of " + otherEntity
874-
} else {
875-
fb.varTypes[s.Variable] = otherEntity
876-
}
877-
} else if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReferenceSet {
878-
// ReferenceSet traversal returns a list of the entity on the other side,
879-
// not a list typed as the association itself.
880-
otherEntity := assocInfo.childEntityQN
881-
if startsFromChildSide {
882-
otherEntity = assocInfo.parentEntityQN
883-
}
884-
if otherEntity != "" {
885-
fb.varTypes[s.Variable] = "List of " + otherEntity
886-
} else {
887-
fb.varTypes[s.Variable] = "List of " + assocQN
888-
}
833+
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
889870
} else {
890-
// ReferenceSet or unknown: returns a list
891871
fb.varTypes[s.Variable] = "List of " + assocQN
892872
}
873+
} else {
874+
// ReferenceSet or unknown: returns a list
875+
fb.varTypes[s.Variable] = "List of " + assocQN
893876
}
894877
}
895878
} 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,7 +12,12 @@ import (
1212
"github.com/mendixlabs/mxcli/sdk/microflows"
1313
)
1414

15-
func TestAddRetrieveAction_ReverseReferenceOwnerBothUsesDatabaseSource(t *testing.T) {
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) {
1621
fb := newRetrieveAssociationFlowBuilder(domainmodel.AssociationOwnerBoth)
1722
fb.varTypes["Child"] = "Sample.Child"
1823
fb.listInputVariables = map[string]bool{"Parents": true}
@@ -24,12 +29,12 @@ func TestAddRetrieveAction_ReverseReferenceOwnerBothUsesDatabaseSource(t *testin
2429
})
2530

2631
action := onlyRetrieveAction(t, fb)
27-
source, ok := action.Source.(*microflows.DatabaseRetrieveSource)
32+
source, ok := action.Source.(*microflows.AssociationRetrieveSource)
2833
if !ok {
29-
t.Fatalf("owner-both reverse retrieve source = %T, want DatabaseRetrieveSource", action.Source)
34+
t.Fatalf("owner-both reverse retrieve source = %T, want AssociationRetrieveSource", action.Source)
3035
}
31-
if source.EntityQualifiedName != "Sample.Parent" || source.XPathConstraint != "[Sample.Parent_Child = $Child]" {
32-
t.Fatalf("database source = %#v", source)
36+
if source.StartVariable != "Child" || source.AssociationQualifiedName != "Sample.Parent_Child" {
37+
t.Fatalf("association source = %#v", source)
3338
}
3439
if got := fb.varTypes["Parents"]; got != "List of Sample.Parent" {
3540
t.Fatalf("result var type = %q, want List of Sample.Parent", got)
@@ -60,7 +65,7 @@ func TestAddRetrieveAction_ReverseReferenceOwnerBothObjectUsagePreservesAssociat
6065
}
6166
}
6267

63-
func TestAddRetrieveAction_ReverseReferenceDefaultOwnerUsesDatabaseSource(t *testing.T) {
68+
func TestAddRetrieveAction_ReverseReferenceDefaultOwnerUsesAssociationSource(t *testing.T) {
6469
fb := newRetrieveAssociationFlowBuilder(domainmodel.AssociationOwnerDefault)
6570
fb.varTypes["Child"] = "Sample.Child"
6671

@@ -71,12 +76,12 @@ func TestAddRetrieveAction_ReverseReferenceDefaultOwnerUsesDatabaseSource(t *tes
7176
})
7277

7378
action := onlyRetrieveAction(t, fb)
74-
source, ok := action.Source.(*microflows.DatabaseRetrieveSource)
79+
source, ok := action.Source.(*microflows.AssociationRetrieveSource)
7580
if !ok {
76-
t.Fatalf("default-owner reverse retrieve source = %T, want DatabaseRetrieveSource", action.Source)
81+
t.Fatalf("default-owner reverse retrieve source = %T, want AssociationRetrieveSource", action.Source)
7782
}
78-
if source.EntityQualifiedName != "Sample.Parent" || source.XPathConstraint != "[Sample.Parent_Child = $Child]" {
79-
t.Fatalf("database source = %#v", source)
83+
if source.StartVariable != "Child" || source.AssociationQualifiedName != "Sample.Parent_Child" {
84+
t.Fatalf("association source = %#v", source)
8085
}
8186
if got := fb.varTypes["Parents"]; got != "List of Sample.Parent" {
8287
t.Fatalf("result var type = %q, want List of Sample.Parent", got)
@@ -190,7 +195,7 @@ func TestBuildFlowGraph_ReverseReferenceOwnerBothAttributeUsagePreservesAssociat
190195
}
191196
}
192197

193-
func TestBuildFlowGraph_ReverseReferenceOwnerBothLoopUsageUsesDatabaseSource(t *testing.T) {
198+
func TestBuildFlowGraph_ReverseReferenceOwnerBothLoopUsageUsesAssociationSource(t *testing.T) {
194199
fb := newRetrieveAssociationFlowBuilder(domainmodel.AssociationOwnerBoth)
195200
fb.posX = 200
196201
fb.posY = 200
@@ -212,12 +217,12 @@ func TestBuildFlowGraph_ReverseReferenceOwnerBothLoopUsageUsesDatabaseSource(t *
212217
fb.buildFlowGraph(stmts, nil)
213218

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

mdl/executor/cmd_microflows_format_action.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -396,10 +396,13 @@ func formatAction(
396396
entityName = "Entity"
397397
}
398398

399-
if startVar, assocName, ok := parseReverseAssociationRetrieve(ctx, dbSource, entityName); ok {
400-
return fmt.Sprintf("retrieve $%s from $%s/%s;", outputVar, startVar, assocName)
401-
}
402-
399+
// A DatabaseRetrieveSource always renders in database form
400+
// (`from Entity where …`). It must NOT be collapsed to the
401+
// association shorthand `from $var/Assoc` even when its XPath is a
402+
// reverse-association constraint — that shorthand is the in-memory
403+
// retrieve-by-association form, and rendering a database retrieve
404+
// that way makes the two indistinguishable and breaks round-trip
405+
// (issue #726).
403406
stmt := fmt.Sprintf("retrieve $%s from %s", outputVar, entityName)
404407

405408
if dbSource.XPathConstraint != "" {

mdl/executor/cmd_microflows_format_action_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,11 @@ func TestFormatAction_Retrieve_Association(t *testing.T) {
790790
}
791791
}
792792

793-
func TestFormatAction_Retrieve_ReverseAssociationDatabaseSourceUsesCompactForm(t *testing.T) {
793+
// A DatabaseRetrieveSource always renders in database form, even when its XPath
794+
// is a reverse-association constraint — it must NOT collapse to the association
795+
// shorthand `from $var/Assoc`, which is the in-memory retrieve form. Rendering a
796+
// database retrieve that way made the two indistinguishable (issue #726).
797+
func TestFormatAction_Retrieve_ReverseAssociationDatabaseSourceUsesDatabaseForm(t *testing.T) {
794798
e := newTestExecutor()
795799
e.backend = reverseAssociationBackend(t)
796800
action := &microflows.RetrieveAction{
@@ -803,7 +807,7 @@ func TestFormatAction_Retrieve_ReverseAssociationDatabaseSourceUsesCompactForm(t
803807
}
804808

805809
got := e.formatAction(action, nil, nil)
806-
want := "retrieve $Domains from $Runtime/SampleRuntime.Domain_Runtime;"
810+
want := "retrieve $Domains from SampleRuntime.Domain\n where SampleRuntime.Domain_Runtime = $Runtime;"
807811
if got != want {
808812
t.Errorf("got %q, want %q", got, want)
809813
}

0 commit comments

Comments
 (0)