Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- ============================================================================
-- Bug #352 (part): Compact reverse-association retrieve formatter
-- ============================================================================
--
-- Symptom (before fix):
-- Some Mendix models store a compact association retrieve as a
-- `DatabaseRetrieveSource` with a single XPath predicate of the form
-- [Module.Association = $Variable]
-- and `Range = All`. When described, the formatter emitted the verbose
-- shape
-- retrieve $Domains from Module.Domain
-- where Module.Domain_Runtime = $Runtime;
-- On `mxcli exec`, that verbose shape is rebuilt as XPath. Studio Pro
-- then accepts it but `describe → exec → describe` is no longer a
-- fixpoint, and tooling that expects the compact form sees drift.
--
-- After fix:
-- When the source is a database retrieve over a single equality
-- predicate against a known association whose other side matches the
-- start variable's type, and `Range = All`, the formatter emits the
-- compact form `retrieve $Out from $Var/Module.Association`.
--
-- Usage:
-- mxcli exec mdl-examples/bug-tests/352-retrieve-compact-reverse-association.mdl -p app.mpr
-- mxcli -p app.mpr -c "describe microflow BugTest352a.MF_FetchDomains"
-- The describe output must use the compact `from $Runtime/Mod.Assoc`
-- form, and `mx check` must report 0 errors.
-- ============================================================================

create module BugTest352a;

create entity BugTest352a.Runtime (
Name : string(100)
);
/

create entity BugTest352a.Domain (
Name : string(100)
);
/

create association BugTest352a.Domain_Runtime
from BugTest352a.Domain
to BugTest352a.Runtime;
/

-- Compact reverse-association retrieve. Describe → exec → describe must
-- preserve the `from $Runtime/BugTest352a.Domain_Runtime` shape.
create microflow BugTest352a.MF_FetchDomains (
$Runtime: BugTest352a.Runtime
)
returns list of BugTest352a.Domain as $Domains
begin
retrieve $Domains from $Runtime/BugTest352a.Domain_Runtime;
end;
/
154 changes: 154 additions & 0 deletions mdl/executor/cmd_microflows_format_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ func formatAction(
entityName = "Entity"
}

if startVar, assocName, ok := parseReverseAssociationRetrieve(ctx, dbSource, entityName); ok {
return fmt.Sprintf("retrieve $%s from $%s/%s;", outputVar, startVar, assocName)
}

stmt := fmt.Sprintf("retrieve $%s from %s", outputVar, entityName)

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

func parseReverseAssociationRetrieve(
ctx *ExecContext,
source *microflows.DatabaseRetrieveSource,
entityName string,
) (string, string, bool) {
if ctx == nil || ctx.Backend == nil || source == nil || entityName == "" {
return "", "", false
}
if len(source.Sorting) > 0 || !isRangeAllOrNil(source.Range) {
return "", "", false
}

assocName, startVar, ok := parseReverseAssociationXPath(source.XPathConstraint)
if !ok || !databaseRetrieveMatchesAssociationTarget(ctx, entityName, assocName) {
return "", "", false
}
return startVar, assocName, true
}

func isRangeAllOrNil(r *microflows.Range) bool {
return r == nil || r.RangeType == "" || r.RangeType == microflows.RangeTypeAll
}

func parseReverseAssociationXPath(raw string) (string, string, bool) {
parts, ok := splitTopLevelXPathPredicates(raw)
if !ok || len(parts) != 1 {
return "", "", false
}

condition := strings.TrimSpace(parts[0])
if strings.ContainsAny(condition, "<>!") || strings.Count(condition, "=") != 1 {
return "", "", false
}

sides := strings.SplitN(condition, "=", 2)
assocName := strings.TrimSpace(sides[0])
startVar := strings.TrimSpace(sides[1])
if !isQualifiedAssociationName(assocName) || !strings.HasPrefix(startVar, "$") {
return "", "", false
}

startVar = strings.TrimPrefix(startVar, "$")
if !isSimpleMendixName(startVar) {
return "", "", false
}
return assocName, startVar, true
}

func isQualifiedAssociationName(name string) bool {
parts := strings.Split(name, ".")
return len(parts) == 2 && isSimpleMendixName(parts[0]) && isSimpleMendixName(parts[1])
}

func isSimpleMendixName(name string) bool {
if name == "" {
return false
}
for i, r := range name {
if r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9' {
continue
}
return false
}
return true
}

func databaseRetrieveMatchesAssociationTarget(ctx *ExecContext, entityName, assocQualifiedName string) bool {
moduleName, assocName, ok := strings.Cut(assocQualifiedName, ".")
if !ok {
return false
}

mod, err := ctx.Backend.GetModuleByName(moduleName)
if err != nil || mod == nil {
return false
}
dm, err := ctx.Backend.GetDomainModel(mod.ID)
if err != nil || dm == nil {
return false
}

entityNames := make(map[model.ID]string, len(dm.Entities))
for _, entity := range dm.Entities {
entityNames[entity.ID] = moduleName + "." + entity.Name
}
for _, assoc := range dm.Associations {
if assoc.Name == assocName {
return entityNames[assoc.ParentID] == entityName
}
}
return false
}

func splitTopLevelXPathPredicates(raw string) ([]string, bool) {
var parts []string
input := strings.TrimSpace(raw)
if input == "" {
return nil, false
}

i := 0
for i < len(input) {
for i < len(input) && (input[i] == ' ' || input[i] == '\t' || input[i] == '\r' || input[i] == '\n') {
i++
}
if i >= len(input) {
break
}
if input[i] != '[' {
return nil, false
}

start := i + 1
depth := 1
var quote byte
for i = start; i < len(input); i++ {
ch := input[i]
if quote != 0 {
if ch == quote {
quote = 0
}
continue
}
switch ch {
case '\'', '"':
quote = ch
case '[':
depth++
case ']':
depth--
if depth == 0 {
part := strings.TrimSpace(input[start:i])
parts = append(parts, part)
i++
goto nextPredicate
}
}
}
return nil, false

nextPredicate:
}

if len(parts) == 0 {
return nil, false
}

return parts, true
}

// --- Executor method wrappers for callers in unmigrated code and tests ---

func (e *Executor) formatActivity(obj microflows.MicroflowObject, entityNames map[model.ID]string, microflowNames map[model.ID]string) string {
Expand Down
116 changes: 116 additions & 0 deletions mdl/executor/cmd_microflows_format_action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ package executor
import (
"testing"

"github.com/mendixlabs/mxcli/mdl/backend/mock"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/domainmodel"
"github.com/mendixlabs/mxcli/sdk/microflows"
)

Expand Down Expand Up @@ -673,3 +675,117 @@ func TestFormatAction_Retrieve_Association(t *testing.T) {
t.Errorf("got %q, want %q", got, want)
}
}

func TestFormatAction_Retrieve_ReverseAssociationDatabaseSourceUsesCompactForm(t *testing.T) {
e := newTestExecutor()
e.backend = reverseAssociationBackend(t)
action := &microflows.RetrieveAction{
OutputVariable: "Domains",
Source: &microflows.DatabaseRetrieveSource{
EntityQualifiedName: "SampleRuntime.Domain",
XPathConstraint: "[SampleRuntime.Domain_Runtime = $Runtime]",
Range: &microflows.Range{RangeType: microflows.RangeTypeAll},
},
}

got := e.formatAction(action, nil, nil)
want := "retrieve $Domains from $Runtime/SampleRuntime.Domain_Runtime;"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}

func TestFormatAction_Retrieve_ReverseAssociationRequiresSimpleAllRange(t *testing.T) {
e := newTestExecutor()
e.backend = reverseAssociationBackend(t)
action := &microflows.RetrieveAction{
OutputVariable: "Domains",
Source: &microflows.DatabaseRetrieveSource{
EntityQualifiedName: "SampleRuntime.Domain",
XPathConstraint: "[SampleRuntime.Domain_Runtime = $Runtime]",
Range: &microflows.Range{RangeType: microflows.RangeTypeFirst},
},
}

got := e.formatAction(action, nil, nil)
want := "retrieve $Domains from SampleRuntime.Domain\n where SampleRuntime.Domain_Runtime = $Runtime\n limit 1;"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}

func TestFormatAction_Retrieve_ReverseAssociationRequiresMatchingEntity(t *testing.T) {
e := newTestExecutor()
e.backend = reverseAssociationBackend(t)
action := &microflows.RetrieveAction{
OutputVariable: "Domains",
Source: &microflows.DatabaseRetrieveSource{
EntityQualifiedName: "SampleRuntime.Runtime",
XPathConstraint: "[SampleRuntime.Domain_Runtime = $Runtime]",
},
}

got := e.formatAction(action, nil, nil)
want := "retrieve $Domains from SampleRuntime.Runtime\n where SampleRuntime.Domain_Runtime = $Runtime;"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}

func TestParseReverseAssociationXPathRejectsComplexPredicates(t *testing.T) {
tests := []string{
"[SampleRuntime.Domain_Runtime = $Runtime][Active = true]",
"[SampleRuntime.Domain_Runtime != $Runtime]",
"[SampleRuntime.Domain_Runtime = $Runtime/Other.Assoc]",
"[SampleRuntime.Domain_Runtime = 'literal']",
"SampleRuntime.Domain_Runtime = $Runtime",
}

for _, tt := range tests {
if assoc, start, ok := parseReverseAssociationXPath(tt); ok {
t.Fatalf("parseReverseAssociationXPath(%q) = %q, %q, true; want false", tt, assoc, start)
}
}
}

func reverseAssociationBackend(t *testing.T) *mock.MockBackend {
t.Helper()
moduleID := model.ID("sample-runtime-module")
return &mock.MockBackend{
GetModuleByNameFunc: func(name string) (*model.Module, error) {
if name != "SampleRuntime" {
return nil, nil
}
return &model.Module{
BaseElement: model.BaseElement{ID: moduleID},
Name: "SampleRuntime",
}, nil
},
GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) {
if id != moduleID {
return nil, nil
}
return &domainmodel.DomainModel{
ContainerID: moduleID,
Entities: []*domainmodel.Entity{
{
BaseElement: model.BaseElement{ID: "domain-entity"},
Name: "Domain",
},
{
BaseElement: model.BaseElement{ID: "runtime-entity"},
Name: "Runtime",
},
},
Associations: []*domainmodel.Association{
{
Name: "Domain_Runtime",
ParentID: "domain-entity",
ChildID: "runtime-entity",
Type: domainmodel.AssociationTypeReference,
},
},
}, nil
},
}
}
Loading