Skip to content

Commit 2252534

Browse files
akoclaude
andcommitted
fix(odata-import): skip existing nav associations on re-import (no duplicates)
Re-importing external entities (or running an import script twice) created a numerically-suffixed duplicate of every OData navigation association on each run — People's Friends/BestFriend became Friends/Friends2/Friends3, etc. A full TripPin re-import inflated the module from 8 nav associations to 25. createNavigationAssociations only ensured association-name *uniqueness*: when a nav property's association already existed, uniqueAssocName appended a suffix and created a new one, rather than recognizing it as already-imported. The modelsdk read doesn't preserve RemoteParentNavigationProperty, so the dedup can't rely on it alone. Skip a nav property when the parent entity already has an association for it — matched by RemoteParentNavigationProperty when available (legacy read) and by the natural association name (== nav-property name) as the engine-agnostic fallback. Verified end-to-end: a triple TripPin import now yields 8 nav associations (was 25), and re-imports create 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ab625c9 commit 2252534

2 files changed

Lines changed: 75 additions & 1 deletion

File tree

mdl/executor/cmd_contract.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -954,13 +954,23 @@ func createNavigationAssociations(
954954
}
955955

956956
// Track associations we've already created to avoid duplicates from
957-
// inherited nav properties.
957+
// inherited nav properties. existingAssocs is keyed by association name (for
958+
// uniqueness/renaming); existingNav is keyed by the OData nav-property name
959+
// so a re-import skips a nav property that already has an association instead
960+
// of creating a numerically-suffixed duplicate (Friends, Friends2, …). The
961+
// nav-property key relies on RemoteParentNavigationProperty, which the
962+
// legacy read preserves; the modelsdk read does not, so the natural
963+
// association name (== nav-property name) is the fallback skip signal.
958964
existingAssocs := make(map[assocKey]bool)
965+
existingNav := make(map[assocKey]bool)
959966
for _, a := range dm.Associations {
960967
// Find parent entity name for this association
961968
for _, ent := range dm.Entities {
962969
if ent.ID == a.ParentID {
963970
existingAssocs[assocKey{ent.Name, a.Name}] = true
971+
if a.RemoteParentNavigationProperty != "" {
972+
existingNav[assocKey{ent.Name, a.RemoteParentNavigationProperty}] = true
973+
}
964974
break
965975
}
966976
}
@@ -1004,6 +1014,17 @@ func createNavigationAssociations(
10041014
continue
10051015
}
10061016

1017+
// Re-import dedup: if this nav property already has an
1018+
// association on the parent, skip it — otherwise a second import
1019+
// creates a numerically-suffixed duplicate (Friends, Friends2,
1020+
// Friends3 …). Match by the OData nav-property name when the read
1021+
// preserves it (legacy), else by the natural association name
1022+
// (== nav-property name; the modelsdk read drops the nav prop).
1023+
if existingNav[assocKey{parentEnt.Name, np.Name}] ||
1024+
existingAssocs[assocKey{parentEnt.Name, np.Name}] {
1025+
continue
1026+
}
1027+
10071028
// An association name must be unique within a module and may
10081029
// not collide with any entity name (CE0065). When the OData
10091030
// nav property name collides with an existing entity, append
@@ -1059,6 +1080,7 @@ func createNavigationAssociations(
10591080
continue
10601081
}
10611082
existingAssocs[assocKey{parentEnt.Name, assocName}] = true
1083+
existingNav[assocKey{parentEnt.Name, np.Name}] = true
10621084
count++
10631085
}
10641086
}

mdl/executor/cmd_contract_test.go

Lines changed: 52 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/mdl/types"
10+
"github.com/mendixlabs/mxcli/model"
911
"github.com/mendixlabs/mxcli/sdk/domainmodel"
1012
)
1113

@@ -80,3 +82,53 @@ func TestApplyExternalEntityFields_ConservativeCapabilityDefault(t *testing.T) {
8082
t.Error("Creatable = false, want true (explicit InsertRestrictions=true)")
8183
}
8284
}
85+
86+
// TestCreateNavigationAssociations_NoDuplicateOnReimport guards against the
87+
// re-import duplication where each OData nav property spawned a numerically-
88+
// suffixed copy (Friends, Friends2, Friends3 …) on every import. A nav property
89+
// that already has an association must be skipped.
90+
func TestCreateNavigationAssociations_NoDuplicateOnReimport(t *testing.T) {
91+
person := &types.EdmEntityType{
92+
Name: "Person",
93+
NavigationProperties: []*types.EdmNavigationProperty{
94+
{Name: "Friends", Type: "Collection(NS.Person)"},
95+
{Name: "BestFriend", Type: "NS.Person"},
96+
},
97+
}
98+
doc := &types.EdmxDocument{
99+
Schemas: []*types.EdmSchema{{Namespace: "NS", EntityTypes: []*types.EdmEntityType{person}}},
100+
EntitySets: []*types.EdmEntitySet{{Name: "People", EntityType: "NS.Person"}},
101+
}
102+
people := &domainmodel.Entity{Name: "People", Persistable: true}
103+
people.ID = model.ID("ent-people")
104+
dm := &domainmodel.DomainModel{Entities: []*domainmodel.Entity{people}}
105+
dm.ID = model.ID("dm-1")
106+
107+
typeByQualified := map[string]*types.EdmEntityType{"NS.Person": person}
108+
esMap := map[string]string{"NS.Person": "People"}
109+
110+
var created []*domainmodel.Association
111+
mb := &mock.MockBackend{
112+
CreateAssociationFunc: func(_ model.ID, a *domainmodel.Association) error {
113+
created = append(created, a)
114+
return nil
115+
},
116+
}
117+
ctx, _ := newMockCtx(t, withBackend(mb))
118+
119+
// First import creates both nav associations.
120+
if n := createNavigationAssociations(ctx, dm, doc, typeByQualified, esMap, "TripPin.Client"); n != 2 {
121+
t.Fatalf("first import created %d associations, want 2", n)
122+
}
123+
// Persist them into the domain model, as a re-read between imports would.
124+
dm.Associations = append(dm.Associations, created...)
125+
countAfterFirst := len(created)
126+
127+
// Second import must create nothing — no Friends2 / BestFriend2 duplicates.
128+
if n := createNavigationAssociations(ctx, dm, doc, typeByQualified, esMap, "TripPin.Client"); n != 0 {
129+
t.Errorf("re-import created %d associations, want 0 (duplicates)", n)
130+
}
131+
if extra := len(created) - countAfterFirst; extra != 0 {
132+
t.Errorf("re-import persisted %d new associations via the backend, want 0", extra)
133+
}
134+
}

0 commit comments

Comments
 (0)