Skip to content

Commit 9481789

Browse files
committed
fix: preserve compact reverse-association retrieves
Symptom: describing a reverse-association retrieve stored as a database retrieve with a simple association XPath expanded it into a verbose where-clause retrieve instead of preserving the compact retrieve-from-association form. Root cause: the formatter only recognized explicit AssociationRetrieveSource values. Some Mendix models encode the same association traversal as a DatabaseRetrieveSource with a single predicate of the form [Module.Association = $Variable] and RangeType All. Fix: detect that narrow database-retrieve shape, verify the association points at the retrieved entity through the backend domain model, and emit retrieve $Result from $Variable/Module.Association when it is safe. Complex predicates, sorting, non-All ranges, non-variable right-hand sides, and non-matching association targets keep the existing database retrieve output. Tests: add synthetic format-action regressions for the compact form, range fallback, target-entity fallback, and parser rejection of complex predicates.
1 parent d871691 commit 9481789

2 files changed

Lines changed: 270 additions & 0 deletions

File tree

mdl/executor/cmd_microflows_format_action.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,10 @@ func formatAction(
299299
entityName = "Entity"
300300
}
301301

302+
if startVar, assocName, ok := parseReverseAssociationRetrieve(ctx, dbSource, entityName); ok {
303+
return fmt.Sprintf("retrieve $%s from $%s/%s;", outputVar, startVar, assocName)
304+
}
305+
302306
stmt := fmt.Sprintf("retrieve $%s from %s", outputVar, entityName)
303307

304308
if dbSource.XPathConstraint != "" {
@@ -1137,6 +1141,156 @@ func formatTransformJsonAction(a *microflows.TransformJsonAction) string {
11371141
return sb.String()
11381142
}
11391143

1144+
func parseReverseAssociationRetrieve(
1145+
ctx *ExecContext,
1146+
source *microflows.DatabaseRetrieveSource,
1147+
entityName string,
1148+
) (string, string, bool) {
1149+
if ctx == nil || ctx.Backend == nil || source == nil || entityName == "" {
1150+
return "", "", false
1151+
}
1152+
if len(source.Sorting) > 0 || !isRangeAllOrNil(source.Range) {
1153+
return "", "", false
1154+
}
1155+
1156+
assocName, startVar, ok := parseReverseAssociationXPath(source.XPathConstraint)
1157+
if !ok || !databaseRetrieveMatchesAssociationTarget(ctx, entityName, assocName) {
1158+
return "", "", false
1159+
}
1160+
return startVar, assocName, true
1161+
}
1162+
1163+
func isRangeAllOrNil(r *microflows.Range) bool {
1164+
return r == nil || r.RangeType == "" || r.RangeType == microflows.RangeTypeAll
1165+
}
1166+
1167+
func parseReverseAssociationXPath(raw string) (string, string, bool) {
1168+
parts, ok := splitTopLevelXPathPredicates(raw)
1169+
if !ok || len(parts) != 1 {
1170+
return "", "", false
1171+
}
1172+
1173+
condition := strings.TrimSpace(parts[0])
1174+
if strings.ContainsAny(condition, "<>!") || strings.Count(condition, "=") != 1 {
1175+
return "", "", false
1176+
}
1177+
1178+
sides := strings.SplitN(condition, "=", 2)
1179+
assocName := strings.TrimSpace(sides[0])
1180+
startVar := strings.TrimSpace(sides[1])
1181+
if !isQualifiedAssociationName(assocName) || !strings.HasPrefix(startVar, "$") {
1182+
return "", "", false
1183+
}
1184+
1185+
startVar = strings.TrimPrefix(startVar, "$")
1186+
if !isSimpleMendixName(startVar) {
1187+
return "", "", false
1188+
}
1189+
return assocName, startVar, true
1190+
}
1191+
1192+
func isQualifiedAssociationName(name string) bool {
1193+
parts := strings.Split(name, ".")
1194+
return len(parts) == 2 && isSimpleMendixName(parts[0]) && isSimpleMendixName(parts[1])
1195+
}
1196+
1197+
func isSimpleMendixName(name string) bool {
1198+
if name == "" {
1199+
return false
1200+
}
1201+
for i, r := range name {
1202+
if r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9' {
1203+
continue
1204+
}
1205+
return false
1206+
}
1207+
return true
1208+
}
1209+
1210+
func databaseRetrieveMatchesAssociationTarget(ctx *ExecContext, entityName, assocQualifiedName string) bool {
1211+
moduleName, assocName, ok := strings.Cut(assocQualifiedName, ".")
1212+
if !ok {
1213+
return false
1214+
}
1215+
1216+
mod, err := ctx.Backend.GetModuleByName(moduleName)
1217+
if err != nil || mod == nil {
1218+
return false
1219+
}
1220+
dm, err := ctx.Backend.GetDomainModel(mod.ID)
1221+
if err != nil || dm == nil {
1222+
return false
1223+
}
1224+
1225+
entityNames := make(map[model.ID]string, len(dm.Entities))
1226+
for _, entity := range dm.Entities {
1227+
entityNames[entity.ID] = moduleName + "." + entity.Name
1228+
}
1229+
for _, assoc := range dm.Associations {
1230+
if assoc.Name == assocName {
1231+
return entityNames[assoc.ParentID] == entityName
1232+
}
1233+
}
1234+
return false
1235+
}
1236+
1237+
func splitTopLevelXPathPredicates(raw string) ([]string, bool) {
1238+
var parts []string
1239+
input := strings.TrimSpace(raw)
1240+
if input == "" {
1241+
return nil, false
1242+
}
1243+
1244+
i := 0
1245+
for i < len(input) {
1246+
for i < len(input) && (input[i] == ' ' || input[i] == '\t' || input[i] == '\r' || input[i] == '\n') {
1247+
i++
1248+
}
1249+
if i >= len(input) {
1250+
break
1251+
}
1252+
if input[i] != '[' {
1253+
return nil, false
1254+
}
1255+
1256+
start := i + 1
1257+
depth := 1
1258+
var quote byte
1259+
for i = start; i < len(input); i++ {
1260+
ch := input[i]
1261+
if quote != 0 {
1262+
if ch == quote {
1263+
quote = 0
1264+
}
1265+
continue
1266+
}
1267+
switch ch {
1268+
case '\'', '"':
1269+
quote = ch
1270+
case '[':
1271+
depth++
1272+
case ']':
1273+
depth--
1274+
if depth == 0 {
1275+
part := strings.TrimSpace(input[start:i])
1276+
parts = append(parts, part)
1277+
i++
1278+
goto nextPredicate
1279+
}
1280+
}
1281+
}
1282+
return nil, false
1283+
1284+
nextPredicate:
1285+
}
1286+
1287+
if len(parts) == 0 {
1288+
return nil, false
1289+
}
1290+
1291+
return parts, true
1292+
}
1293+
11401294
// --- Executor method wrappers for callers in unmigrated code and tests ---
11411295

11421296
func (e *Executor) formatActivity(obj microflows.MicroflowObject, entityNames map[model.ID]string, microflowNames map[model.ID]string) string {

mdl/executor/cmd_microflows_format_action_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package executor
55
import (
66
"testing"
77

8+
"github.com/mendixlabs/mxcli/mdl/backend/mock"
89
"github.com/mendixlabs/mxcli/model"
10+
"github.com/mendixlabs/mxcli/sdk/domainmodel"
911
"github.com/mendixlabs/mxcli/sdk/microflows"
1012
)
1113

@@ -673,3 +675,117 @@ func TestFormatAction_Retrieve_Association(t *testing.T) {
673675
t.Errorf("got %q, want %q", got, want)
674676
}
675677
}
678+
679+
func TestFormatAction_Retrieve_ReverseAssociationDatabaseSourceUsesCompactForm(t *testing.T) {
680+
e := newTestExecutor()
681+
e.backend = reverseAssociationBackend(t)
682+
action := &microflows.RetrieveAction{
683+
OutputVariable: "Domains",
684+
Source: &microflows.DatabaseRetrieveSource{
685+
EntityQualifiedName: "SampleRuntime.Domain",
686+
XPathConstraint: "[SampleRuntime.Domain_Runtime = $Runtime]",
687+
Range: &microflows.Range{RangeType: microflows.RangeTypeAll},
688+
},
689+
}
690+
691+
got := e.formatAction(action, nil, nil)
692+
want := "retrieve $Domains from $Runtime/SampleRuntime.Domain_Runtime;"
693+
if got != want {
694+
t.Errorf("got %q, want %q", got, want)
695+
}
696+
}
697+
698+
func TestFormatAction_Retrieve_ReverseAssociationRequiresSimpleAllRange(t *testing.T) {
699+
e := newTestExecutor()
700+
e.backend = reverseAssociationBackend(t)
701+
action := &microflows.RetrieveAction{
702+
OutputVariable: "Domains",
703+
Source: &microflows.DatabaseRetrieveSource{
704+
EntityQualifiedName: "SampleRuntime.Domain",
705+
XPathConstraint: "[SampleRuntime.Domain_Runtime = $Runtime]",
706+
Range: &microflows.Range{RangeType: microflows.RangeTypeFirst},
707+
},
708+
}
709+
710+
got := e.formatAction(action, nil, nil)
711+
want := "retrieve $Domains from SampleRuntime.Domain\n where SampleRuntime.Domain_Runtime = $Runtime\n limit 1;"
712+
if got != want {
713+
t.Errorf("got %q, want %q", got, want)
714+
}
715+
}
716+
717+
func TestFormatAction_Retrieve_ReverseAssociationRequiresMatchingEntity(t *testing.T) {
718+
e := newTestExecutor()
719+
e.backend = reverseAssociationBackend(t)
720+
action := &microflows.RetrieveAction{
721+
OutputVariable: "Domains",
722+
Source: &microflows.DatabaseRetrieveSource{
723+
EntityQualifiedName: "SampleRuntime.Runtime",
724+
XPathConstraint: "[SampleRuntime.Domain_Runtime = $Runtime]",
725+
},
726+
}
727+
728+
got := e.formatAction(action, nil, nil)
729+
want := "retrieve $Domains from SampleRuntime.Runtime\n where SampleRuntime.Domain_Runtime = $Runtime;"
730+
if got != want {
731+
t.Errorf("got %q, want %q", got, want)
732+
}
733+
}
734+
735+
func TestParseReverseAssociationXPathRejectsComplexPredicates(t *testing.T) {
736+
tests := []string{
737+
"[SampleRuntime.Domain_Runtime = $Runtime][Active = true]",
738+
"[SampleRuntime.Domain_Runtime != $Runtime]",
739+
"[SampleRuntime.Domain_Runtime = $Runtime/Other.Assoc]",
740+
"[SampleRuntime.Domain_Runtime = 'literal']",
741+
"SampleRuntime.Domain_Runtime = $Runtime",
742+
}
743+
744+
for _, tt := range tests {
745+
if assoc, start, ok := parseReverseAssociationXPath(tt); ok {
746+
t.Fatalf("parseReverseAssociationXPath(%q) = %q, %q, true; want false", tt, assoc, start)
747+
}
748+
}
749+
}
750+
751+
func reverseAssociationBackend(t *testing.T) *mock.MockBackend {
752+
t.Helper()
753+
moduleID := model.ID("sample-runtime-module")
754+
return &mock.MockBackend{
755+
GetModuleByNameFunc: func(name string) (*model.Module, error) {
756+
if name != "SampleRuntime" {
757+
return nil, nil
758+
}
759+
return &model.Module{
760+
BaseElement: model.BaseElement{ID: moduleID},
761+
Name: "SampleRuntime",
762+
}, nil
763+
},
764+
GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) {
765+
if id != moduleID {
766+
return nil, nil
767+
}
768+
return &domainmodel.DomainModel{
769+
ContainerID: moduleID,
770+
Entities: []*domainmodel.Entity{
771+
{
772+
BaseElement: model.BaseElement{ID: "domain-entity"},
773+
Name: "Domain",
774+
},
775+
{
776+
BaseElement: model.BaseElement{ID: "runtime-entity"},
777+
Name: "Runtime",
778+
},
779+
},
780+
Associations: []*domainmodel.Association{
781+
{
782+
Name: "Domain_Runtime",
783+
ParentID: "domain-entity",
784+
ChildID: "runtime-entity",
785+
Type: domainmodel.AssociationTypeReference,
786+
},
787+
},
788+
}, nil
789+
},
790+
}
791+
}

0 commit comments

Comments
 (0)