Skip to content

Commit 5fbead4

Browse files
authored
Merge pull request #370 from hjotha/submit/retrieve-sort-indirect-entity-ref
fix: preserve indirect sort entity references
2 parents 027000d + 75f7c2d commit 5fbead4

7 files changed

Lines changed: 349 additions & 6 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
-- ============================================================================
2+
-- Bug #367: Retrieve sort items lost indirect entity references
3+
-- ============================================================================
4+
--
5+
-- Symptom (before fix):
6+
-- A retrieve source can sort by an attribute whose owning entity differs
7+
-- from the retrieved entity, as long as Studio Pro has an association
8+
-- path for that attribute. Example: retrieve DeploymentTarget sorted by
9+
-- ApplicationView.CreatedAt through DeploymentTarget_ApplicationView.
10+
-- The SDK shape only stored the final attribute reference, so parser/
11+
-- writer roundtrip dropped the `DomainModels$IndirectEntityRef` steps,
12+
-- and the MDL builder rejected qualified sort attributes when the
13+
-- attribute entity differed from the retrieve entity.
14+
--
15+
-- After fix:
16+
-- - Parser/writer now preserve `DomainModels$IndirectEntityRef` steps.
17+
-- - The MDL builder infers a one-hop association sort reference and
18+
-- emits the IndirectEntityRef path. Unrelated qualified attributes
19+
-- are still rejected.
20+
--
21+
-- Usage:
22+
-- mxcli exec mdl-examples/bug-tests/367-retrieve-sort-indirect-entity-ref.mdl -p app.mpr
23+
-- mxcli -p app.mpr -c "describe microflow BugTest367.MF_FetchSorted"
24+
-- `mx check` against the resulting MPR must report 0 errors and the
25+
-- describe output must keep the qualified `Module.Entity.Attribute`
26+
-- sort form.
27+
-- ============================================================================
28+
29+
create module BugTest367;
30+
31+
create entity BugTest367.ApplicationView (
32+
CreatedAt : datetime
33+
);
34+
/
35+
36+
create entity BugTest367.DeploymentTarget (
37+
Name : string(100)
38+
);
39+
/
40+
41+
create association BugTest367.DeploymentTarget_ApplicationView
42+
from BugTest367.DeploymentTarget
43+
to BugTest367.ApplicationView;
44+
/
45+
46+
-- Sort uses an attribute on a related entity. The builder must emit the
47+
-- IndirectEntityRef step through DeploymentTarget_ApplicationView so the
48+
-- BSON parses cleanly in Studio Pro.
49+
create microflow BugTest367.MF_FetchSorted ()
50+
returns list of BugTest367.DeploymentTarget as $Targets
51+
begin
52+
retrieve $Targets from BugTest367.DeploymentTarget
53+
sort by BugTest367.ApplicationView.CreatedAt desc, Name asc;
54+
end;
55+
/

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
602602
for _, col := range s.SortColumns {
603603
// Resolve attribute path - if just a simple name, prefix with entity
604604
attrPath := col.Attribute
605+
var entityRefSteps []microflows.EntityRefStep
605606
if !strings.Contains(attrPath, ".") {
606607
attrPath = entityQN + "." + attrPath
607608
} else {
@@ -612,20 +613,24 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
612613
// Extract entity from attribute path (first two parts)
613614
attrEntityQN := parts[0] + "." + parts[1]
614615
if attrEntityQN != entityQN {
615-
fb.addError("sort by attribute '%s' does not belong to entity '%s'", col.Attribute, entityQN)
616-
continue // Skip this sort column but continue processing others
616+
entityRefSteps = fb.inferSortEntityRefSteps(entityQN, attrPath)
617+
if len(entityRefSteps) == 0 {
618+
fb.addError("sort by attribute '%s' does not belong to entity '%s'", col.Attribute, entityQN)
619+
continue // Skip this sort column but continue processing others
620+
}
617621
}
618622
}
619623
}
620624

621625
direction := microflows.SortDirectionAscending
622-
if col.Order == "desc" {
626+
if strings.EqualFold(col.Order, "desc") {
623627
direction = microflows.SortDirectionDescending
624628
}
625629

626630
dbSource.Sorting = append(dbSource.Sorting, &microflows.SortItem{
627631
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
628632
AttributeQualifiedName: attrPath,
633+
EntityRefSteps: entityRefSteps,
629634
Direction: direction,
630635
})
631636
}
@@ -674,6 +679,54 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
674679
return activity.ID
675680
}
676681

682+
func (fb *flowBuilder) inferSortEntityRefSteps(sourceEntityQN, attrPath string) []microflows.EntityRefStep {
683+
attrEntityQN := entityQualifiedNameFromAttribute(attrPath)
684+
if attrEntityQN == "" || attrEntityQN == sourceEntityQN {
685+
return nil
686+
}
687+
parts := strings.SplitN(sourceEntityQN, ".", 2)
688+
if len(parts) != 2 || parts[0] == "" {
689+
return nil
690+
}
691+
if fb.backend == nil {
692+
return nil
693+
}
694+
mod, err := fb.backend.GetModuleByName(parts[0])
695+
if err != nil || mod == nil {
696+
return nil
697+
}
698+
dm, err := fb.backend.GetDomainModel(mod.ID)
699+
if err != nil || dm == nil {
700+
return nil
701+
}
702+
entityNames := make(map[model.ID]string, len(dm.Entities))
703+
for _, e := range dm.Entities {
704+
entityNames[e.ID] = parts[0] + "." + e.Name
705+
}
706+
for _, assoc := range dm.Associations {
707+
parentQN := entityNames[assoc.ParentID]
708+
childQN := entityNames[assoc.ChildID]
709+
if parentQN == sourceEntityQN && childQN == attrEntityQN {
710+
return []microflows.EntityRefStep{{Association: parts[0] + "." + assoc.Name, DestinationEntity: childQN}}
711+
}
712+
}
713+
for _, assoc := range dm.CrossAssociations {
714+
parentQN := entityNames[assoc.ParentID]
715+
if parentQN == sourceEntityQN && assoc.ChildRef == attrEntityQN {
716+
return []microflows.EntityRefStep{{Association: parts[0] + "." + assoc.Name, DestinationEntity: assoc.ChildRef}}
717+
}
718+
}
719+
return nil
720+
}
721+
722+
func entityQualifiedNameFromAttribute(attrPath string) string {
723+
parts := strings.Split(attrPath, ".")
724+
if len(parts) < 3 {
725+
return ""
726+
}
727+
return parts[0] + "." + parts[1]
728+
}
729+
677730
// addListOperationAction creates list operations like HEAD, TAIL, FIND, etc.
678731
func (fb *flowBuilder) addListOperationAction(s *ast.ListOperationStmt) model.ID {
679732
var operation microflows.ListOperation
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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_AllowsAssociationPathSortAttribute(t *testing.T) {
16+
moduleID := model.ID("sample-module")
17+
parentID := model.ID("parent-entity")
18+
childID := model.ID("child-entity")
19+
fb := &flowBuilder{
20+
varTypes: map[string]string{},
21+
backend: &mock.MockBackend{
22+
GetModuleByNameFunc: func(name string) (*model.Module, error) {
23+
if name != "SampleApps" {
24+
return nil, nil
25+
}
26+
return &model.Module{BaseElement: model.BaseElement{ID: moduleID}, Name: name}, nil
27+
},
28+
GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) {
29+
if id != moduleID {
30+
return nil, nil
31+
}
32+
return &domainmodel.DomainModel{
33+
ContainerID: moduleID,
34+
Entities: []*domainmodel.Entity{
35+
{BaseElement: model.BaseElement{ID: parentID}, Name: "DeploymentTarget"},
36+
{BaseElement: model.BaseElement{ID: childID}, Name: "ApplicationView"},
37+
},
38+
Associations: []*domainmodel.Association{
39+
{
40+
Name: "DeploymentTarget_ApplicationView",
41+
ParentID: parentID,
42+
ChildID: childID,
43+
Type: domainmodel.AssociationTypeReference,
44+
},
45+
},
46+
}, nil
47+
},
48+
},
49+
}
50+
51+
fb.addRetrieveAction(&ast.RetrieveStmt{
52+
Variable: "DeploymentTargetList",
53+
Source: ast.QualifiedName{
54+
Module: "SampleApps",
55+
Name: "DeploymentTarget",
56+
},
57+
SortColumns: []ast.SortColumnDef{
58+
{Attribute: "SampleApps.ApplicationView.CreatedAt", Order: "DESC"},
59+
{Attribute: "Name", Order: "ASC"},
60+
},
61+
})
62+
63+
if len(fb.errors) > 0 {
64+
t.Fatalf("unexpected builder errors: %v", fb.errors)
65+
}
66+
if len(fb.objects) != 1 {
67+
t.Fatalf("got %d objects, want 1", len(fb.objects))
68+
}
69+
70+
activity, ok := fb.objects[0].(*microflows.ActionActivity)
71+
if !ok {
72+
t.Fatalf("got object %T, want *microflows.ActionActivity", fb.objects[0])
73+
}
74+
action, ok := activity.Action.(*microflows.RetrieveAction)
75+
if !ok {
76+
t.Fatalf("got action %T, want *microflows.RetrieveAction", activity.Action)
77+
}
78+
source, ok := action.Source.(*microflows.DatabaseRetrieveSource)
79+
if !ok {
80+
t.Fatalf("got source %T, want *microflows.DatabaseRetrieveSource", action.Source)
81+
}
82+
if len(source.Sorting) != 2 {
83+
t.Fatalf("got %d sort items, want 2", len(source.Sorting))
84+
}
85+
if got := source.Sorting[0].AttributeQualifiedName; got != "SampleApps.ApplicationView.CreatedAt" {
86+
t.Fatalf("first sort attribute = %q", got)
87+
}
88+
if got := source.Sorting[0].EntityRefSteps; len(got) != 1 || got[0].Association != "SampleApps.DeploymentTarget_ApplicationView" || got[0].DestinationEntity != "SampleApps.ApplicationView" {
89+
t.Fatalf("first sort entity ref steps = %#v", got)
90+
}
91+
if got := source.Sorting[0].Direction; got != microflows.SortDirectionDescending {
92+
t.Fatalf("first sort direction = %q, want %q", got, microflows.SortDirectionDescending)
93+
}
94+
if got := source.Sorting[1].AttributeQualifiedName; got != "SampleApps.DeploymentTarget.Name" {
95+
t.Fatalf("second sort attribute = %q", got)
96+
}
97+
if got := source.Sorting[1].EntityRefSteps; len(got) != 0 {
98+
t.Fatalf("second sort entity ref steps = %#v, want none", got)
99+
}
100+
}

sdk/microflows/microflows_actions.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,15 @@ const (
156156
// SortItem represents a sort specification.
157157
type SortItem struct {
158158
model.BaseElement
159-
AttributeID model.ID `json:"attributeId"`
160-
AttributeQualifiedName string `json:"attributeQualifiedName,omitempty"` // BY_NAME_REFERENCE: Module.Entity.Attribute
161-
Direction SortDirection `json:"direction"`
159+
AttributeID model.ID `json:"attributeId"`
160+
AttributeQualifiedName string `json:"attributeQualifiedName,omitempty"` // BY_NAME_REFERENCE: Module.Entity.Attribute
161+
EntityRefSteps []EntityRefStep `json:"entityRefSteps,omitempty"`
162+
Direction SortDirection `json:"direction"`
163+
}
164+
165+
type EntityRefStep struct {
166+
Association string `json:"association,omitempty"`
167+
DestinationEntity string `json:"destinationEntity,omitempty"`
162168
}
163169

164170
// SortDirection represents sort order.

sdk/mpr/parser_microflow.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,7 @@ func parseSortItems(raw map[string]any) []*microflows.SortItem {
807807
} else {
808808
sortItem.AttributeID = model.ID(extractBsonID(attrRefMap["Attribute"]))
809809
}
810+
sortItem.EntityRefSteps = parseEntityRefSteps(attrRefMap["EntityRef"])
810811
}
811812

812813
// Fall back to AttributePath (legacy)
@@ -826,6 +827,32 @@ func parseSortItems(raw map[string]any) []*microflows.SortItem {
826827
return result
827828
}
828829

830+
func parseEntityRefSteps(raw any) []microflows.EntityRefStep {
831+
entityRefMap := extractBsonMap(raw)
832+
if entityRefMap == nil {
833+
return nil
834+
}
835+
items := extractBsonSlice(entityRefMap["Steps"])
836+
if len(items) == 0 {
837+
return nil
838+
}
839+
var steps []microflows.EntityRefStep
840+
for _, item := range items {
841+
itemMap := extractBsonMap(item)
842+
if itemMap == nil {
843+
continue
844+
}
845+
step := microflows.EntityRefStep{
846+
Association: extractString(itemMap["Association"]),
847+
DestinationEntity: extractString(itemMap["DestinationEntity"]),
848+
}
849+
if step.Association != "" || step.DestinationEntity != "" {
850+
steps = append(steps, step)
851+
}
852+
}
853+
return steps
854+
}
855+
829856
func parseRetrieveSource(raw map[string]any) microflows.RetrieveSource {
830857
typeName, _ := raw["$Type"].(string)
831858

sdk/mpr/parser_microflow_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,85 @@ func bsonDMap(doc primitive.D) map[string]any {
241241
}
242242
return out
243243
}
244+
245+
func TestSerializeSortItemPreservesIndirectEntityRef(t *testing.T) {
246+
doc := serializeSortItem(&microflows.SortItem{
247+
BaseElement: model.BaseElement{ID: model.ID("sort-1")},
248+
AttributeQualifiedName: "SampleApps.ApplicationView.CreatedAt",
249+
EntityRefSteps: []microflows.EntityRefStep{
250+
{
251+
Association: "SampleApps.DeploymentTarget_ApplicationView",
252+
DestinationEntity: "SampleApps.ApplicationView",
253+
},
254+
},
255+
Direction: microflows.SortDirectionDescending,
256+
})
257+
258+
attrRef, ok := bsonDMap(doc)["AttributeRef"].(primitive.D)
259+
if !ok {
260+
t.Fatalf("AttributeRef missing or wrong type: %T", bsonDMap(doc)["AttributeRef"])
261+
}
262+
entityRef, ok := bsonDMap(attrRef)["EntityRef"].(primitive.D)
263+
if !ok {
264+
t.Fatalf("EntityRef missing or wrong type: %T", bsonDMap(attrRef)["EntityRef"])
265+
}
266+
if got := bsonDMap(entityRef)["$Type"]; got != "DomainModels$IndirectEntityRef" {
267+
t.Fatalf("EntityRef.$Type = %v, want DomainModels$IndirectEntityRef", got)
268+
}
269+
steps, ok := bsonDMap(entityRef)["Steps"].(primitive.A)
270+
if !ok || len(steps) != 2 {
271+
t.Fatalf("Steps = %#v, want marker plus one step", bsonDMap(entityRef)["Steps"])
272+
}
273+
step, ok := steps[1].(primitive.D)
274+
if !ok {
275+
t.Fatalf("step type = %T, want primitive.D", steps[1])
276+
}
277+
stepFields := bsonDMap(step)
278+
if got := stepFields["Association"]; got != "SampleApps.DeploymentTarget_ApplicationView" {
279+
t.Fatalf("Association = %v", got)
280+
}
281+
if got := stepFields["DestinationEntity"]; got != "SampleApps.ApplicationView" {
282+
t.Fatalf("DestinationEntity = %v", got)
283+
}
284+
}
285+
286+
func TestParseSortItemsPreservesIndirectEntityRef(t *testing.T) {
287+
got := parseSortItems(map[string]any{
288+
"NewSortings": map[string]any{
289+
"Sortings": []any{
290+
int32(2),
291+
map[string]any{
292+
"$ID": "sort-1",
293+
"$Type": "Microflows$RetrieveSorting",
294+
"SortOrder": "Descending",
295+
"AttributeRef": map[string]any{
296+
"$Type": "DomainModels$AttributeRef",
297+
"Attribute": "SampleApps.ApplicationView.CreatedAt",
298+
"EntityRef": map[string]any{
299+
"$Type": "DomainModels$IndirectEntityRef",
300+
"Steps": []any{
301+
int32(2),
302+
map[string]any{
303+
"$Type": "DomainModels$EntityRefStep",
304+
"Association": "SampleApps.DeploymentTarget_ApplicationView",
305+
"DestinationEntity": "SampleApps.ApplicationView",
306+
},
307+
},
308+
},
309+
},
310+
},
311+
},
312+
},
313+
})
314+
315+
if len(got) != 1 {
316+
t.Fatalf("got %d sort items, want 1", len(got))
317+
}
318+
if steps := got[0].EntityRefSteps; len(steps) != 1 || steps[0].Association != "SampleApps.DeploymentTarget_ApplicationView" || steps[0].DestinationEntity != "SampleApps.ApplicationView" {
319+
t.Fatalf("EntityRefSteps = %#v", steps)
320+
}
321+
}
322+
244323
func TestParseActionActivityPreservesWebServiceActionRawBSONOrder(t *testing.T) {
245324
rawAction := primitive.D{
246325
{Key: "$ID", Value: "web-service-action-ordered"},

0 commit comments

Comments
 (0)