Skip to content

Commit e678735

Browse files
akoclaude
andcommitted
fix: CREATE ASSOCIATION rejects duplicates for cross-module associations (#389)
The duplicate check existed only in the same-module (else) branch. The cross-module (isCrossModule) branch went straight to CreateCrossAssociation with no guard, silently creating a second association with the same name. Added the same duplicate check — scanning both CrossAssociations and Associations by name — before the cross-module create path. Added a regression test seeding a CrossModuleAssociation in the DM and verifying that a second CREATE returns "already exists". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fc5f677 commit e678735

2 files changed

Lines changed: 68 additions & 0 deletions

File tree

mdl/executor/cmd_associations.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,18 @@ func execCreateAssociation(ctx *ExecContext, s *ast.CreateAssociationStmt) error
141141
}
142142

143143
if isCrossModule {
144+
if !s.CreateOrModify {
145+
for _, ca := range dm.CrossAssociations {
146+
if ca.Name == s.Name.Name {
147+
return mdlerrors.NewAlreadyExists("association", s.Name.String())
148+
}
149+
}
150+
for _, assoc := range dm.Associations {
151+
if assoc.Name == s.Name.Name {
152+
return mdlerrors.NewAlreadyExists("association", s.Name.String())
153+
}
154+
}
155+
}
144156
childRef := childModule + "." + s.Child.Name
145157
ca := &domainmodel.CrossModuleAssociation{
146158
Name: s.Name.Name,

mdl/executor/cmd_associations_mock_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,62 @@ func TestCreateAssociation_OrModify_UpdatesInPlace(t *testing.T) {
166166
}
167167
}
168168

169+
// Issue #389 — cross-module CREATE ASSOCIATION must also reject duplicates.
170+
func TestCreateAssociation_CrossModule_AlreadyExists_Issue389(t *testing.T) {
171+
mod1 := mkModule("ModA")
172+
mod2 := mkModule("ModB")
173+
ent1 := mkEntity(mod1.ID, "Order")
174+
ent2 := mkEntity(mod2.ID, "Product")
175+
176+
existingCA := &domainmodel.CrossModuleAssociation{
177+
BaseElement: model.BaseElement{ID: nextID("ca")},
178+
ContainerID: nextID("dm1"),
179+
Name: "Order_Product",
180+
ChildRef: "ModB.Product",
181+
}
182+
dm1 := &domainmodel.DomainModel{
183+
BaseElement: model.BaseElement{ID: nextID("dm1")},
184+
ContainerID: mod1.ID,
185+
Entities: []*domainmodel.Entity{ent1},
186+
CrossAssociations: []*domainmodel.CrossModuleAssociation{existingCA},
187+
}
188+
dm2 := &domainmodel.DomainModel{
189+
BaseElement: model.BaseElement{ID: nextID("dm2")},
190+
ContainerID: mod2.ID,
191+
Entities: []*domainmodel.Entity{ent2},
192+
}
193+
h := mkHierarchy(mod1, mod2)
194+
withContainer(h, dm1.ID, mod1.ID)
195+
withContainer(h, dm2.ID, mod2.ID)
196+
withContainer(h, ent1.ContainerID, dm1.ID)
197+
withContainer(h, ent2.ContainerID, dm2.ID)
198+
199+
mb := &mock.MockBackend{
200+
IsConnectedFunc: func() bool { return true },
201+
ListModulesFunc: func() ([]*model.Module, error) {
202+
return []*model.Module{mod1, mod2}, nil
203+
},
204+
ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) {
205+
return []*domainmodel.DomainModel{dm1, dm2}, nil
206+
},
207+
GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) {
208+
if id == mod1.ID {
209+
return dm1, nil
210+
}
211+
return dm2, nil
212+
},
213+
}
214+
215+
ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h))
216+
err := execCreateAssociation(ctx, &ast.CreateAssociationStmt{
217+
Name: ast.QualifiedName{Module: "ModA", Name: "Order_Product"},
218+
Parent: ast.QualifiedName{Module: "ModA", Name: "Order"},
219+
Child: ast.QualifiedName{Module: "ModB", Name: "Product"},
220+
})
221+
assertError(t, err)
222+
assertContainsStr(t, err.Error(), "already exists")
223+
}
224+
169225
func TestCreateAssociation_AlreadyExists_NoOrModify(t *testing.T) {
170226
mod := mkModule("MyModule")
171227
ent1 := mkEntity(mod.ID, "Order")

0 commit comments

Comments
 (0)