Skip to content

Commit ed4b670

Browse files
authored
Merge pull request #356 from hjotha/submit/retrieve-reverse-association-compact-form
fix: preserve compact reverse-association retrieves
2 parents 4d6a628 + c10d06d commit ed4b670

5 files changed

Lines changed: 521 additions & 6 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
-- ============================================================================
2+
-- Bug #352 (part): Compact reverse-association retrieve formatter
3+
-- ============================================================================
4+
--
5+
-- Symptom (before fix):
6+
-- Some Mendix models store a compact association retrieve as a
7+
-- `DatabaseRetrieveSource` with a single XPath predicate of the form
8+
-- [Module.Association = $Variable]
9+
-- and `Range = All`. When described, the formatter emitted the verbose
10+
-- shape
11+
-- retrieve $Domains from Module.Domain
12+
-- where Module.Domain_Runtime = $Runtime;
13+
-- On `mxcli exec`, that verbose shape is rebuilt as XPath. Studio Pro
14+
-- then accepts it but `describe → exec → describe` is no longer a
15+
-- fixpoint, and tooling that expects the compact form sees drift.
16+
--
17+
-- After fix:
18+
-- When the source is a database retrieve over a single equality
19+
-- predicate against a known association whose other side matches the
20+
-- start variable's type, and `Range = All`, the formatter emits the
21+
-- compact form `retrieve $Out from $Var/Module.Association`.
22+
--
23+
-- Usage:
24+
-- mxcli exec mdl-examples/bug-tests/352-retrieve-compact-reverse-association.mdl -p app.mpr
25+
-- mxcli -p app.mpr -c "describe microflow BugTest352a.MF_FetchDomains"
26+
-- The describe output must use the compact `from $Runtime/Mod.Assoc`
27+
-- form, and `mx check` must report 0 errors.
28+
-- ============================================================================
29+
30+
create module BugTest352a;
31+
32+
create entity BugTest352a.Runtime (
33+
Name : string(100)
34+
);
35+
/
36+
37+
create entity BugTest352a.Domain (
38+
Name : string(100)
39+
);
40+
/
41+
42+
create association BugTest352a.Domain_Runtime
43+
from BugTest352a.Domain
44+
to BugTest352a.Runtime;
45+
/
46+
47+
-- Compact reverse-association retrieve. Describe → exec → describe must
48+
-- preserve the `from $Runtime/BugTest352a.Domain_Runtime` shape.
49+
create microflow BugTest352a.MF_FetchDomains (
50+
$Runtime: BugTest352a.Runtime
51+
)
52+
returns list of BugTest352a.Domain as $Domains
53+
begin
54+
retrieve $Domains from $Runtime/BugTest352a.Domain_Runtime;
55+
end;
56+
/

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
303303
}
304304

305305
if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReference &&
306+
assocInfo.Owner != domainmodel.AssociationOwnerBoth &&
307+
assocInfo.parentPersistable &&
306308
assocInfo.childEntityQN != "" && startVarType == assocInfo.childEntityQN {
307309
// Reverse traversal on Reference: child → parent (one-to-many)
308310
// Use DatabaseRetrieveSource with XPath to get a list of parent entities
@@ -330,6 +332,18 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
330332
otherEntity = assocInfo.parentEntityQN
331333
}
332334
fb.varTypes[s.Variable] = otherEntity
335+
} else if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReferenceSet {
336+
// ReferenceSet traversal returns a list of the entity on the other side,
337+
// not a list typed as the association itself.
338+
otherEntity := assocInfo.childEntityQN
339+
if startVarType == assocInfo.childEntityQN {
340+
otherEntity = assocInfo.parentEntityQN
341+
}
342+
if otherEntity != "" {
343+
fb.varTypes[s.Variable] = "List of " + otherEntity
344+
} else {
345+
fb.varTypes[s.Variable] = "List of " + assocQN
346+
}
333347
} else {
334348
// ReferenceSet or unknown: returns a list
335349
fb.varTypes[s.Variable] = "List of " + assocQN
@@ -865,9 +879,12 @@ func resolveMemberChangeFallback(mc *microflows.MemberChange, memberName string,
865879

866880
// assocLookupResult holds resolved association metadata.
867881
type assocLookupResult struct {
868-
Type domainmodel.AssociationType
869-
parentEntityQN string // Qualified name of the parent (FROM/owner) entity
870-
childEntityQN string // Qualified name of the child (TO/referenced) entity
882+
Type domainmodel.AssociationType
883+
Owner domainmodel.AssociationOwner
884+
parentEntityQN string // Qualified name of the parent (FROM/owner) entity
885+
childEntityQN string // Qualified name of the child (TO/referenced) entity
886+
parentPersistable bool
887+
childPersistable bool
871888
}
872889

873890
// lookupAssociation finds an association by module and name, returning its type
@@ -888,16 +905,21 @@ func (fb *flowBuilder) lookupAssociation(moduleName, assocName string) *assocLoo
888905

889906
// Build entity ID → qualified name map
890907
entityNames := make(map[model.ID]string, len(dm.Entities))
908+
entityPersistable := make(map[model.ID]bool, len(dm.Entities))
891909
for _, e := range dm.Entities {
892910
entityNames[e.ID] = moduleName + "." + e.Name
911+
entityPersistable[e.ID] = e.Persistable
893912
}
894913

895914
for _, a := range dm.Associations {
896915
if a.Name == assocName {
897916
return &assocLookupResult{
898-
Type: a.Type,
899-
parentEntityQN: entityNames[a.ParentID],
900-
childEntityQN: entityNames[a.ChildID],
917+
Type: a.Type,
918+
Owner: a.Owner,
919+
parentEntityQN: entityNames[a.ParentID],
920+
childEntityQN: entityNames[a.ChildID],
921+
parentPersistable: entityPersistable[a.ParentID],
922+
childPersistable: entityPersistable[a.ChildID],
901923
}
902924
}
903925
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package executor
4+
5+
import (
6+
"testing"
7+
8+
"github.com/mendixlabs/mxcli/mdl/ast"
9+
"github.com/mendixlabs/mxcli/mdl/backend/mock"
10+
"github.com/mendixlabs/mxcli/model"
11+
"github.com/mendixlabs/mxcli/sdk/domainmodel"
12+
"github.com/mendixlabs/mxcli/sdk/microflows"
13+
)
14+
15+
func TestAddRetrieveAction_ReverseReferenceOwnerBothUsesAssociationSource(t *testing.T) {
16+
fb := newRetrieveAssociationFlowBuilder(domainmodel.AssociationOwnerBoth)
17+
fb.varTypes["Child"] = "Sample.Child"
18+
19+
fb.addRetrieveAction(&ast.RetrieveStmt{
20+
Variable: "Parent",
21+
StartVariable: "Child",
22+
Source: ast.QualifiedName{Module: "Sample", Name: "Parent_Child"},
23+
})
24+
25+
action := onlyRetrieveAction(t, fb)
26+
source, ok := action.Source.(*microflows.AssociationRetrieveSource)
27+
if !ok {
28+
t.Fatalf("owner-both reverse retrieve source = %T, want AssociationRetrieveSource", action.Source)
29+
}
30+
if source.StartVariable != "Child" || source.AssociationQualifiedName != "Sample.Parent_Child" {
31+
t.Fatalf("association source = %#v", source)
32+
}
33+
if got := fb.varTypes["Parent"]; got != "Sample.Parent" {
34+
t.Fatalf("result var type = %q, want Sample.Parent", got)
35+
}
36+
}
37+
38+
func TestAddRetrieveAction_ReverseReferenceDefaultOwnerUsesDatabaseSource(t *testing.T) {
39+
fb := newRetrieveAssociationFlowBuilder(domainmodel.AssociationOwnerDefault)
40+
fb.varTypes["Child"] = "Sample.Child"
41+
42+
fb.addRetrieveAction(&ast.RetrieveStmt{
43+
Variable: "Parents",
44+
StartVariable: "Child",
45+
Source: ast.QualifiedName{Module: "Sample", Name: "Parent_Child"},
46+
})
47+
48+
action := onlyRetrieveAction(t, fb)
49+
source, ok := action.Source.(*microflows.DatabaseRetrieveSource)
50+
if !ok {
51+
t.Fatalf("default-owner reverse retrieve source = %T, want DatabaseRetrieveSource", action.Source)
52+
}
53+
if source.EntityQualifiedName != "Sample.Parent" || source.XPathConstraint != "[Sample.Parent_Child = $Child]" {
54+
t.Fatalf("database source = %#v", source)
55+
}
56+
if got := fb.varTypes["Parents"]; got != "List of Sample.Parent" {
57+
t.Fatalf("result var type = %q, want List of Sample.Parent", got)
58+
}
59+
}
60+
61+
func TestAddRetrieveAction_ReverseReferenceNonPersistableParentUsesAssociationSource(t *testing.T) {
62+
fb := newRetrieveAssociationFlowBuilderWithPersistability(domainmodel.AssociationOwnerDefault, false, true)
63+
fb.varTypes["Child"] = "Sample.Child"
64+
65+
fb.addRetrieveAction(&ast.RetrieveStmt{
66+
Variable: "Parents",
67+
StartVariable: "Child",
68+
Source: ast.QualifiedName{Module: "Sample", Name: "Parent_Child"},
69+
})
70+
71+
action := onlyRetrieveAction(t, fb)
72+
source, ok := action.Source.(*microflows.AssociationRetrieveSource)
73+
if !ok {
74+
t.Fatalf("non-persistable reverse retrieve source = %T, want AssociationRetrieveSource", action.Source)
75+
}
76+
if source.StartVariable != "Child" || source.AssociationQualifiedName != "Sample.Parent_Child" {
77+
t.Fatalf("association source = %#v", source)
78+
}
79+
if got := fb.varTypes["Parents"]; got != "Sample.Parent" {
80+
t.Fatalf("result var type = %q, want Sample.Parent", got)
81+
}
82+
}
83+
84+
func TestAddRetrieveAction_ReferenceSetRegistersOtherEntityListType(t *testing.T) {
85+
fb := newRetrieveAssociationFlowBuilderWithType(domainmodel.AssociationTypeReferenceSet, domainmodel.AssociationOwnerBoth, true, true)
86+
fb.varTypes["Parent"] = "Sample.Parent"
87+
88+
fb.addRetrieveAction(&ast.RetrieveStmt{
89+
Variable: "Children",
90+
StartVariable: "Parent",
91+
Source: ast.QualifiedName{Module: "Sample", Name: "Parent_Child"},
92+
})
93+
94+
action := onlyRetrieveAction(t, fb)
95+
source, ok := action.Source.(*microflows.AssociationRetrieveSource)
96+
if !ok {
97+
t.Fatalf("reference-set retrieve source = %T, want AssociationRetrieveSource", action.Source)
98+
}
99+
if source.StartVariable != "Parent" || source.AssociationQualifiedName != "Sample.Parent_Child" {
100+
t.Fatalf("association source = %#v", source)
101+
}
102+
if got := fb.varTypes["Children"]; got != "List of Sample.Child" {
103+
t.Fatalf("result var type = %q, want List of Sample.Child", got)
104+
}
105+
}
106+
107+
func newRetrieveAssociationFlowBuilder(owner domainmodel.AssociationOwner) *flowBuilder {
108+
return newRetrieveAssociationFlowBuilderWithPersistability(owner, true, true)
109+
}
110+
111+
func newRetrieveAssociationFlowBuilderWithPersistability(owner domainmodel.AssociationOwner, parentPersistable, childPersistable bool) *flowBuilder {
112+
return newRetrieveAssociationFlowBuilderWithType(domainmodel.AssociationTypeReference, owner, parentPersistable, childPersistable)
113+
}
114+
115+
func newRetrieveAssociationFlowBuilderWithType(associationType domainmodel.AssociationType, owner domainmodel.AssociationOwner, parentPersistable, childPersistable bool) *flowBuilder {
116+
moduleID := model.ID("sample-module")
117+
parentID := model.ID("parent-entity")
118+
childID := model.ID("child-entity")
119+
return &flowBuilder{
120+
varTypes: map[string]string{},
121+
backend: &mock.MockBackend{
122+
GetModuleByNameFunc: func(name string) (*model.Module, error) {
123+
if name != "Sample" {
124+
return nil, nil
125+
}
126+
return &model.Module{BaseElement: model.BaseElement{ID: moduleID}, Name: name}, nil
127+
},
128+
GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) {
129+
if id != moduleID {
130+
return nil, nil
131+
}
132+
return &domainmodel.DomainModel{
133+
ContainerID: moduleID,
134+
Entities: []*domainmodel.Entity{
135+
{BaseElement: model.BaseElement{ID: parentID}, Name: "Parent", Persistable: parentPersistable},
136+
{BaseElement: model.BaseElement{ID: childID}, Name: "Child", Persistable: childPersistable},
137+
},
138+
Associations: []*domainmodel.Association{
139+
{
140+
Name: "Parent_Child",
141+
ParentID: parentID,
142+
ChildID: childID,
143+
Type: associationType,
144+
Owner: owner,
145+
},
146+
},
147+
}, nil
148+
},
149+
},
150+
}
151+
}
152+
153+
func onlyRetrieveAction(t *testing.T, fb *flowBuilder) *microflows.RetrieveAction {
154+
t.Helper()
155+
if len(fb.objects) != 1 {
156+
t.Fatalf("got %d objects, want 1", len(fb.objects))
157+
}
158+
activity, ok := fb.objects[0].(*microflows.ActionActivity)
159+
if !ok {
160+
t.Fatalf("got object %T, want *microflows.ActionActivity", fb.objects[0])
161+
}
162+
action, ok := activity.Action.(*microflows.RetrieveAction)
163+
if !ok {
164+
t.Fatalf("got action %T, want *microflows.RetrieveAction", activity.Action)
165+
}
166+
return action
167+
}

0 commit comments

Comments
 (0)