Skip to content

Commit 2b29d32

Browse files
committed
fix: preserve indirect sort entity references
Symptom: retrieve statements sorted by an attribute on a related entity either fail builder validation or roundtrip without the DomainModels$IndirectEntityRef needed by Studio Pro. Root cause: SortItem only stored the final attribute name. The parser dropped EntityRef steps from BSON, the writer could not emit them, and the builder rejected qualified sort attributes whose entity differed from the retrieve source. Fix: carry EntityRefStep metadata on microflow sort items, parse and serialize indirect entity refs, and infer a one-hop association step when building retrieves sorted through a known domain association. Tests: add SDK parser/writer coverage for indirect entity refs and builder coverage for sorting a retrieve through a related entity. make build, make lint-go, and make test pass.
1 parent db1b1e9 commit 2b29d32

6 files changed

Lines changed: 294 additions & 6 deletions

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,7 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
617617
for _, col := range s.SortColumns {
618618
// Resolve attribute path - if just a simple name, prefix with entity
619619
attrPath := col.Attribute
620+
var entityRefSteps []microflows.EntityRefStep
620621
if !strings.Contains(attrPath, ".") {
621622
attrPath = entityQN + "." + attrPath
622623
} else {
@@ -627,20 +628,24 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
627628
// Extract entity from attribute path (first two parts)
628629
attrEntityQN := parts[0] + "." + parts[1]
629630
if attrEntityQN != entityQN {
630-
fb.addError("sort by attribute '%s' does not belong to entity '%s'", col.Attribute, entityQN)
631-
continue // Skip this sort column but continue processing others
631+
entityRefSteps = fb.inferSortEntityRefSteps(entityQN, attrPath)
632+
if len(entityRefSteps) == 0 {
633+
fb.addError("sort by attribute '%s' does not belong to entity '%s'", col.Attribute, entityQN)
634+
continue // Skip this sort column but continue processing others
635+
}
632636
}
633637
}
634638
}
635639

636640
direction := microflows.SortDirectionAscending
637-
if col.Order == "desc" {
641+
if strings.EqualFold(col.Order, "desc") {
638642
direction = microflows.SortDirectionDescending
639643
}
640644

641645
dbSource.Sorting = append(dbSource.Sorting, &microflows.SortItem{
642646
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
643647
AttributeQualifiedName: attrPath,
648+
EntityRefSteps: entityRefSteps,
644649
Direction: direction,
645650
})
646651
}
@@ -694,6 +699,54 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
694699
return activity.ID
695700
}
696701

702+
func (fb *flowBuilder) inferSortEntityRefSteps(sourceEntityQN, attrPath string) []microflows.EntityRefStep {
703+
attrEntityQN := entityQualifiedNameFromAttribute(attrPath)
704+
if attrEntityQN == "" || attrEntityQN == sourceEntityQN {
705+
return nil
706+
}
707+
parts := strings.SplitN(sourceEntityQN, ".", 2)
708+
if len(parts) != 2 || parts[0] == "" {
709+
return nil
710+
}
711+
if fb.backend == nil {
712+
return nil
713+
}
714+
mod, err := fb.backend.GetModuleByName(parts[0])
715+
if err != nil || mod == nil {
716+
return nil
717+
}
718+
dm, err := fb.backend.GetDomainModel(mod.ID)
719+
if err != nil || dm == nil {
720+
return nil
721+
}
722+
entityNames := make(map[model.ID]string, len(dm.Entities))
723+
for _, e := range dm.Entities {
724+
entityNames[e.ID] = parts[0] + "." + e.Name
725+
}
726+
for _, assoc := range dm.Associations {
727+
parentQN := entityNames[assoc.ParentID]
728+
childQN := entityNames[assoc.ChildID]
729+
if parentQN == sourceEntityQN && childQN == attrEntityQN {
730+
return []microflows.EntityRefStep{{Association: parts[0] + "." + assoc.Name, DestinationEntity: childQN}}
731+
}
732+
}
733+
for _, assoc := range dm.CrossAssociations {
734+
parentQN := entityNames[assoc.ParentID]
735+
if parentQN == sourceEntityQN && assoc.ChildRef == attrEntityQN {
736+
return []microflows.EntityRefStep{{Association: parts[0] + "." + assoc.Name, DestinationEntity: assoc.ChildRef}}
737+
}
738+
}
739+
return nil
740+
}
741+
742+
func entityQualifiedNameFromAttribute(attrPath string) string {
743+
parts := strings.Split(attrPath, ".")
744+
if len(parts) < 3 {
745+
return ""
746+
}
747+
return parts[0] + "." + parts[1]
748+
}
749+
697750
// addListOperationAction creates list operations like HEAD, TAIL, FIND, etc.
698751
func (fb *flowBuilder) addListOperationAction(s *ast.ListOperationStmt) model.ID {
699752
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"},

sdk/mpr/writer_microflow_actions.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,9 @@ func serializeListOperation(op microflows.ListOperation) bson.D {
10611061
{Key: "$Type", Value: "DomainModels$AttributeRef"},
10621062
{Key: "Attribute", Value: item.AttributeQualifiedName}, // BY_NAME_REFERENCE stored as string
10631063
}
1064+
if len(item.EntityRefSteps) > 0 {
1065+
attrRef = append(attrRef, bson.E{Key: "EntityRef", Value: serializeIndirectEntityRef(item.EntityRefSteps)})
1066+
}
10641067
sortItem = append(sortItem, bson.E{Key: "AttributeRef", Value: attrRef})
10651068
}
10661069
sortings = append(sortings, sortItem)
@@ -1217,6 +1220,9 @@ func serializeSortItem(s *microflows.SortItem) bson.D {
12171220
{Key: "$Type", Value: "DomainModels$AttributeRef"},
12181221
{Key: "Attribute", Value: s.AttributeQualifiedName}, // BY_NAME_REFERENCE stored as string
12191222
}
1223+
if len(s.EntityRefSteps) > 0 {
1224+
attrRef = append(attrRef, bson.E{Key: "EntityRef", Value: serializeIndirectEntityRef(s.EntityRefSteps)})
1225+
}
12201226
doc = append(doc, bson.E{Key: "AttributeRef", Value: attrRef})
12211227
} else if s.AttributeID != "" {
12221228
// Legacy fallback: binary ID reference
@@ -1227,6 +1233,23 @@ func serializeSortItem(s *microflows.SortItem) bson.D {
12271233
return doc
12281234
}
12291235

1236+
func serializeIndirectEntityRef(steps []microflows.EntityRefStep) bson.D {
1237+
items := bson.A{int32(2)}
1238+
for _, step := range steps {
1239+
items = append(items, bson.D{
1240+
{Key: "$ID", Value: idToBsonBinary(generateUUID())},
1241+
{Key: "$Type", Value: "DomainModels$EntityRefStep"},
1242+
{Key: "Association", Value: step.Association},
1243+
{Key: "DestinationEntity", Value: step.DestinationEntity},
1244+
})
1245+
}
1246+
return bson.D{
1247+
{Key: "$ID", Value: idToBsonBinary(generateUUID())},
1248+
{Key: "$Type", Value: "DomainModels$IndirectEntityRef"},
1249+
{Key: "Steps", Value: items},
1250+
}
1251+
}
1252+
12301253
// serializeCodeActionParameterValue serializes a CodeActionParameterValue to BSON.
12311254
func serializeCodeActionParameterValue(v microflows.CodeActionParameterValue) bson.D {
12321255
switch value := v.(type) {

0 commit comments

Comments
 (0)