Skip to content

Commit efeefea

Browse files
hjothamendixclaude
andcommitted
fix: preserve cross-module associations on CREATE object actions
A `create TargetMod.Entity (OwnerMod.Assoc = $Ref)` statement round-tripped as a plain attribute assignment when the association lived in a module other than the create target. Two sides of the same bug: 1. **Describer** (`formatAction` on `CreateObjectAction`) stripped every association member down to its bare name (`Module.Assoc` → `Assoc`), so the authored cross-module qualifier was lost. 2. **Builder** (`resolveMemberChange`) then queried the create target's module for the bare name. The association was not defined there, so the lookup fell through to the attribute slot and Studio Pro reopened the MPR with CE1613 "selected attribute no longer exists". Fixes: - Describer now keeps the `Module.` prefix on any association whose owning module differs from the create target's module. - Builder now uses the authored module (when the member name is qualified) to look up the association, matching how the describer expresses it. Added builder unit test \`TestResolveMemberChange_CrossModuleAssociationKeepsAssociationSlot\` for the synthetic cross-module pattern. Part of #352. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d73e9c9 commit efeefea

3 files changed

Lines changed: 105 additions & 9 deletions

File tree

mdl/executor/cmd_microflows_builder_actions.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,20 +1187,29 @@ func (fb *flowBuilder) resolveMemberChange(mc *microflows.MemberChange, memberNa
11871187
}
11881188
moduleName := parts[0]
11891189

1190-
// If memberName is already qualified (e.g., "MfTest.Order_Customer"),
1191-
// extract the bare name for association lookup.
1190+
// If memberName is already qualified (e.g., "Module.Assoc"), the qualifier
1191+
// is the module that OWNS the association, not the create/change target's
1192+
// module. Associations can live in any module (see cross-association
1193+
// lookups below), so prefer the authored module when present.
11921194
bareName := memberName
11931195
qualifiedName := memberName
1196+
lookupModule := moduleName
11941197
if dot := strings.Index(memberName, "."); dot >= 0 {
1198+
lookupModule = memberName[:dot]
11951199
bareName = memberName[dot+1:]
11961200
// qualifiedName is already set to the full memberName
11971201
} else {
11981202
qualifiedName = moduleName + "." + memberName
11991203
}
12001204

1201-
// Query domain model to check if this member is an association
1205+
// Query the authored (or target) module's domain model first. When the
1206+
// association actually lives in a different module — the common case for
1207+
// cross-module associations like `OtherModule.Assoc_Name` on a
1208+
// `TargetModule.Entity` entity — keep the qualified name so the writer
1209+
// serialises `Association` correctly instead of falling back to
1210+
// `Attribute` and triggering Studio Pro CE1613 on re-open.
12021211
if fb.backend != nil {
1203-
if mod, err := fb.backend.GetModuleByName(moduleName); err == nil && mod != nil {
1212+
if mod, err := fb.backend.GetModuleByName(lookupModule); err == nil && mod != nil {
12041213
if dm, err := fb.backend.GetDomainModel(mod.ID); err == nil && dm != nil {
12051214
for _, a := range dm.Associations {
12061215
if a.Name == bareName {
@@ -1214,9 +1223,11 @@ func (fb *flowBuilder) resolveMemberChange(mc *microflows.MemberChange, memberNa
12141223
return
12151224
}
12161225
}
1217-
// Not an association — it's an attribute
1226+
// Not an association in the authored module — if the author
1227+
// qualified it (e.g. `Module.Attr`) the qualification is an
1228+
// error we must preserve rather than silently dropping; the
1229+
// writer will surface it during mx check.
12181230
if strings.Contains(memberName, ".") {
1219-
// Already qualified, don't double-qualify
12201231
mc.AttributeQualifiedName = memberName
12211232
} else if attrQN, ok := fb.resolveAttributeInEntityHierarchy(entityQN, memberName); ok {
12221233
mc.AttributeQualifiedName = attrQN
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package executor
4+
5+
import (
6+
"testing"
7+
8+
"github.com/mendixlabs/mxcli/mdl/backend/mock"
9+
"github.com/mendixlabs/mxcli/model"
10+
"github.com/mendixlabs/mxcli/sdk/domainmodel"
11+
"github.com/mendixlabs/mxcli/sdk/microflows"
12+
)
13+
14+
// TestResolveMemberChange_CrossModuleAssociationKeepsAssociationSlot guards
15+
// against describe → exec → describe dropping cross-module associations into
16+
// the Attribute slot on CREATE/CHANGE actions. A CREATE
17+
// `TargetMod.Entity (OwnerMod.Assoc = $Ref)` refers to an association whose
18+
// owning module (`OwnerMod`) is not the create target's module
19+
// (`TargetMod`). Before the fix the resolver only looked up the target's
20+
// module, failed to find the association, and fell through to
21+
// `AttributeQualifiedName`, causing Studio Pro to raise CE1613 "selected
22+
// attribute no longer exists" on the re-opened MPR.
23+
func TestResolveMemberChange_CrossModuleAssociationKeepsAssociationSlot(t *testing.T) {
24+
brandModuleID := model.ID("synthetic-brand-module")
25+
ownerModuleID := model.ID("synthetic-owner-module")
26+
27+
backend := &mock.MockBackend{
28+
GetModuleByNameFunc: func(name string) (*model.Module, error) {
29+
switch name {
30+
case "SyntheticBrand":
31+
return &model.Module{BaseElement: model.BaseElement{ID: brandModuleID}, Name: name}, nil
32+
case "SyntheticOwner":
33+
return &model.Module{BaseElement: model.BaseElement{ID: ownerModuleID}, Name: name}, nil
34+
}
35+
return nil, nil
36+
},
37+
GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) {
38+
switch id {
39+
case brandModuleID:
40+
return &domainmodel.DomainModel{
41+
ContainerID: brandModuleID,
42+
Entities: []*domainmodel.Entity{
43+
{Name: "Brand"},
44+
},
45+
}, nil
46+
case ownerModuleID:
47+
return &domainmodel.DomainModel{
48+
ContainerID: ownerModuleID,
49+
Associations: []*domainmodel.Association{
50+
{Name: "Company_Brand", Type: domainmodel.AssociationTypeReference},
51+
},
52+
}, nil
53+
}
54+
return nil, nil
55+
},
56+
}
57+
58+
fb := &flowBuilder{backend: backend}
59+
var mc microflows.MemberChange
60+
fb.resolveMemberChange(&mc, "SyntheticOwner.Company_Brand", "SyntheticBrand.Brand")
61+
62+
if mc.AssociationQualifiedName != "SyntheticOwner.Company_Brand" {
63+
t.Errorf("AssociationQualifiedName = %q, want %q — cross-module association dropped",
64+
mc.AssociationQualifiedName, "SyntheticOwner.Company_Brand")
65+
}
66+
if mc.AttributeQualifiedName != "" {
67+
t.Errorf("AttributeQualifiedName = %q, want empty — association leaked into attribute slot",
68+
mc.AttributeQualifiedName)
69+
}
70+
}

mdl/executor/cmd_microflows_format_action.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,14 +193,29 @@ func formatAction(
193193

194194
if len(a.InitialMembers) > 0 {
195195
var members []string
196+
// entityModule is the module of the create target (e.g. "TargetModule"
197+
// for `create TargetModule.Entity`). Associations in other modules
198+
// must keep their module prefix so the re-exec resolver finds them.
199+
entityModule := ""
200+
if parts := strings.SplitN(entityName, ".", 2); len(parts) == 2 {
201+
entityModule = parts[0]
202+
}
196203
for _, m := range a.InitialMembers {
197204
var memberName string
198205
// Check if this is an association change or an attribute change
199206
if m.AssociationQualifiedName != "" {
200-
// Association: extract just the association name
207+
// Association: keep the module prefix when the association
208+
// is defined in a different module than the create target.
209+
// The authored form `Module.Assoc` is preserved so re-exec
210+
// resolves against the association's owning module rather
211+
// than defaulting to the create target's module, which
212+
// would cause Studio Pro CE1613 "attribute no longer exists"
213+
// at re-open.
201214
memberName = m.AssociationQualifiedName
202-
if parts := strings.Split(memberName, "."); len(parts) > 0 {
203-
memberName = parts[len(parts)-1]
215+
if parts := strings.SplitN(memberName, ".", 2); len(parts) == 2 {
216+
if parts[0] == entityModule {
217+
memberName = parts[1]
218+
}
204219
}
205220
} else {
206221
// Attribute: extract just the attribute name

0 commit comments

Comments
 (0)