Skip to content

Commit 4a5c86d

Browse files
akoclaude
andcommitted
fix: bulk-create OData navigation associations between external entities (mendixlabs#143)
After all entities are created, walk every entity type's nav properties and emit a Mendix association for each one whose target also exists in the project. The association uses Rest\$ODataRemoteAssociationSource with the OData property name in RemoteParentNavigationProperty. For TripPin this produces 8 associations (Friends, Photo, From, To, Airline, Photos, PlanItems, Airline) — matching Studio Pro's reference. Fixes: - ContainsTarget=true nav properties (e.g. Person.Trips) are skipped: contained entities are reached through the parent, not by association (CE0001 fix). - Persistable→non-persistable associations are skipped (Mendix forbids them; Studio Pro doesn't create them either when target is stored as Rest\$ODataEntityTypeSource). - Association name collisions with entity names get a numeric suffix (e.g. Trip.Photos → Photos_2 to avoid clash with the Photos entity) (CE0065 fix). - Defaults for CreatableFromParent/UpdatableFromParent set to true. Writer/parser additions: - serializeAssociation now emits Source as Rest\$ODataRemoteAssociationSource carrying RemoteParentNavigationProperty, Navigability2, and Creatable/ UpdatableFromParent/Child flags. - parseAssociation round-trips the same fields. mx check: 17 → 7 errors, all remaining are CE6630 from unparsed OData Capabilities annotations (Phase B-3). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e904929 commit 4a5c86d

4 files changed

Lines changed: 218 additions & 2 deletions

File tree

mdl/executor/cmd_contract.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,12 +613,182 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
613613
}
614614
}
615615

616+
// Second pass: walk navigation properties and create associations between
617+
// the entities we just created. Re-read the domain model so we get fresh
618+
// entity IDs (CreateEntity reloads the dm internally on each call).
619+
dm, err = e.reader.GetDomainModel(module.ID)
620+
if err == nil {
621+
assocsCreated := e.createNavigationAssociations(dm, doc, typeByQualified, esMap, serviceRef)
622+
if assocsCreated > 0 {
623+
fmt.Fprintf(e.output, "Created %d navigation associations\n", assocsCreated)
624+
}
625+
}
626+
616627
fmt.Fprintf(e.output, "\nFrom %s into %s: %d created, %d updated, %d skipped, %d failed\n",
617628
svcQN, targetModule, created, updated, skipped, failed)
618629

619630
return nil
620631
}
621632

633+
// assocKey is a (parentEntityName, associationName) pair used to detect
634+
// duplicate associations across passes.
635+
type assocKey struct {
636+
parent, name string
637+
}
638+
639+
// createNavigationAssociations walks the navigation properties of every entity
640+
// type in the schema and creates a corresponding Mendix association for each
641+
// one whose target also exists as an entity in this domain model. Inherited
642+
// navigation properties from BaseType chains are walked too.
643+
//
644+
// Each association uses Rest$ODataRemoteAssociationSource so Studio Pro can
645+
// map it back to the OData navigation property.
646+
func (e *Executor) createNavigationAssociations(
647+
dm *domainmodel.DomainModel,
648+
doc *mpr.EdmxDocument,
649+
typeByQualified map[string]*mpr.EdmEntityType,
650+
esMap map[string]string,
651+
serviceRef string,
652+
) int {
653+
// Build a lookup from EDMX type qualified name → existing Mendix entity.
654+
// An entity type matches by its EntitySet name when present, otherwise by
655+
// its bare type name.
656+
mendixByType := make(map[string]*domainmodel.Entity)
657+
for qn, et := range typeByQualified {
658+
entitySetName := esMap[qn]
659+
mendixName := et.Name
660+
if entitySetName != "" {
661+
mendixName = entitySetName
662+
}
663+
for _, ent := range dm.Entities {
664+
if ent.Name == mendixName {
665+
mendixByType[qn] = ent
666+
break
667+
}
668+
}
669+
}
670+
671+
// Track associations we've already created to avoid duplicates from
672+
// inherited nav properties.
673+
existingAssocs := make(map[assocKey]bool)
674+
for _, a := range dm.Associations {
675+
// Find parent entity name for this association
676+
for _, ent := range dm.Entities {
677+
if ent.ID == a.ParentID {
678+
existingAssocs[assocKey{ent.Name, a.Name}] = true
679+
break
680+
}
681+
}
682+
}
683+
684+
count := 0
685+
for _, schema := range doc.Schemas {
686+
for _, et := range schema.EntityTypes {
687+
parentQN := schema.Namespace + "." + et.Name
688+
parentEnt := mendixByType[parentQN]
689+
if parentEnt == nil {
690+
continue
691+
}
692+
693+
for _, np := range et.NavigationProperties {
694+
// ContainsTarget=true navigation properties refer to OData
695+
// contained entities (e.g. Person.Trips). Studio Pro doesn't
696+
// create an association for these — the contained entities
697+
// are reached via the parent entity, not by association.
698+
if np.ContainsTarget {
699+
continue
700+
}
701+
702+
// Resolve target type qualified name
703+
targetTypeName := np.Type
704+
isMany := false
705+
if strings.HasPrefix(targetTypeName, "Collection(") && strings.HasSuffix(targetTypeName, ")") {
706+
targetTypeName = targetTypeName[len("Collection(") : len(targetTypeName)-1]
707+
isMany = true
708+
}
709+
childEnt := mendixByType[targetTypeName]
710+
if childEnt == nil {
711+
continue // target type isn't in our project
712+
}
713+
714+
// Mendix forbids associations from a persistable entity to a
715+
// non-persistable entity (CE0001). Skip these for now —
716+
// Studio Pro doesn't create them either when the target is
717+
// stored as Rest$ODataEntityTypeSource (Persistable=false).
718+
if parentEnt.Persistable && !childEnt.Persistable {
719+
continue
720+
}
721+
722+
// An association name must be unique within a module and may
723+
// not collide with any entity name (CE0065). When the OData
724+
// nav property name collides with an existing entity, append
725+
// a numeric suffix.
726+
assocName := uniqueAssocName(np.Name, dm, existingAssocs)
727+
728+
assocType := domainmodel.AssociationTypeReference
729+
if isMany {
730+
assocType = domainmodel.AssociationTypeReferenceSet
731+
}
732+
733+
assoc := &domainmodel.Association{
734+
Name: assocName,
735+
ParentID: parentEnt.ID,
736+
ChildID: childEnt.ID,
737+
Type: assocType,
738+
Owner: domainmodel.AssociationOwnerDefault,
739+
StorageFormat: domainmodel.StorageFormatColumn,
740+
Source: "Rest$ODataRemoteAssociationSource",
741+
RemoteParentNavigationProperty: np.Name,
742+
Navigability2: "ParentToChild",
743+
// TODO: parse Org.OData.Capabilities.V1 annotations to
744+
// derive these per-association. Defaults match TripPin's
745+
// most common case.
746+
CreatableFromParent: true,
747+
UpdatableFromParent: true,
748+
}
749+
assoc.ID = model.ID(mpr.GenerateID())
750+
751+
if err := e.writer.CreateAssociation(dm.ID, assoc); err != nil {
752+
fmt.Fprintf(e.output, " ASSOC FAILED: %s.%s — %v\n", parentEnt.Name, assocName, err)
753+
continue
754+
}
755+
existingAssocs[assocKey{parentEnt.Name, assocName}] = true
756+
count++
757+
}
758+
}
759+
}
760+
return count
761+
}
762+
763+
// uniqueAssocName returns a Mendix-safe association name for an OData nav
764+
// property. If the requested name collides with an existing entity name OR an
765+
// already-created association name, append a numeric suffix.
766+
func uniqueAssocName(base string, dm *domainmodel.DomainModel, existingAssocs map[assocKey]bool) string {
767+
collides := func(name string) bool {
768+
for _, ent := range dm.Entities {
769+
if ent.Name == name {
770+
return true
771+
}
772+
}
773+
for k := range existingAssocs {
774+
if k.name == name {
775+
return true
776+
}
777+
}
778+
return false
779+
}
780+
if !collides(base) {
781+
return base
782+
}
783+
for i := 2; i < 100; i++ {
784+
candidate := fmt.Sprintf("%s_%d", base, i)
785+
if !collides(candidate) {
786+
return candidate
787+
}
788+
}
789+
return base
790+
}
791+
622792
// applyExternalEntityFields stamps the Source/RemoteServiceName/Key/Attributes
623793
// fields on a domain model entity, choosing the right BSON source type based on
624794
// whether the entity has its own entity set.

sdk/domainmodel/domainmodel.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,19 @@ type Association struct {
337337
// Delete behavior
338338
ParentDeleteBehavior *DeleteBehavior `json:"parentDeleteBehavior,omitempty"`
339339
ChildDeleteBehavior *DeleteBehavior `json:"childDeleteBehavior,omitempty"`
340+
341+
// External association source (for OData remote associations between
342+
// external entities). When Source = "Rest$ODataRemoteAssociationSource",
343+
// the writer emits a Source block carrying the OData navigation property
344+
// names instead of leaving the association as a plain persistent one.
345+
Source string `json:"source,omitempty"`
346+
RemoteParentNavigationProperty string `json:"remoteParentNavigationProperty,omitempty"`
347+
RemoteChildNavigationProperty string `json:"remoteChildNavigationProperty,omitempty"`
348+
CreatableFromParent bool `json:"creatableFromParent,omitempty"`
349+
CreatableFromChild bool `json:"creatableFromChild,omitempty"`
350+
UpdatableFromParent bool `json:"updatableFromParent,omitempty"`
351+
UpdatableFromChild bool `json:"updatableFromChild,omitempty"`
352+
Navigability2 string `json:"navigability2,omitempty"` // "ParentToChild" or "BothDirections"
340353
}
341354

342355
// GetName returns the association's name.

sdk/mpr/parser_domainmodel.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,20 @@ func parseAssociation(raw map[string]any) *domainmodel.Association {
429429
}
430430
}
431431

432+
// Parse OData remote association source
433+
if sourceMap, ok := raw["Source"].(map[string]any); ok {
434+
if extractString(sourceMap["$Type"]) == "Rest$ODataRemoteAssociationSource" {
435+
assoc.Source = "Rest$ODataRemoteAssociationSource"
436+
assoc.RemoteParentNavigationProperty = extractString(sourceMap["RemoteParentNavigationProperty"])
437+
assoc.RemoteChildNavigationProperty = extractString(sourceMap["RemoteChildNavigationProperty"])
438+
assoc.CreatableFromParent = extractBool(sourceMap["CreatableFromParent"], false)
439+
assoc.CreatableFromChild = extractBool(sourceMap["CreatableFromChild"], false)
440+
assoc.UpdatableFromParent = extractBool(sourceMap["UpdatableFromParent"], false)
441+
assoc.UpdatableFromChild = extractBool(sourceMap["UpdatableFromChild"], false)
442+
assoc.Navigability2 = extractString(sourceMap["Navigability2"])
443+
}
444+
}
445+
432446
return assoc
433447
}
434448

sdk/mpr/writer_domainmodel.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,7 +1065,7 @@ func serializeAssociation(a *domainmodel.Association) bson.M {
10651065
if storageFormat == "" {
10661066
storageFormat = "Column"
10671067
}
1068-
return bson.M{
1068+
doc := bson.M{
10691069
"$ID": idToBsonBinary(string(a.ID)),
10701070
"$Type": "DomainModels$Association",
10711071
"Name": a.Name,
@@ -1079,9 +1079,28 @@ func serializeAssociation(a *domainmodel.Association) bson.M {
10791079
"ParentConnection": "0;50",
10801080
"ChildConnection": "100;50",
10811081
"StorageFormat": storageFormat,
1082-
"Source": nil,
10831082
"DeleteBehavior": serializeDeleteBehavior(a.ParentDeleteBehavior, a.ChildDeleteBehavior),
10841083
}
1084+
if a.Source == "Rest$ODataRemoteAssociationSource" {
1085+
nav := a.Navigability2
1086+
if nav == "" {
1087+
nav = "ParentToChild"
1088+
}
1089+
doc["Source"] = bson.M{
1090+
"$ID": idToBsonBinary(generateUUID()),
1091+
"$Type": "Rest$ODataRemoteAssociationSource",
1092+
"CreatableFromChild": a.CreatableFromChild,
1093+
"CreatableFromParent": a.CreatableFromParent,
1094+
"Navigability2": nav,
1095+
"RemoteChildNavigationProperty": a.RemoteChildNavigationProperty,
1096+
"RemoteParentNavigationProperty": a.RemoteParentNavigationProperty,
1097+
"UpdatableFromChild": a.UpdatableFromChild,
1098+
"UpdatableFromParent": a.UpdatableFromParent,
1099+
}
1100+
} else {
1101+
doc["Source"] = nil
1102+
}
1103+
return doc
10851104
}
10861105

10871106
func serializeCrossAssociation(ca *domainmodel.CrossModuleAssociation) bson.M {

0 commit comments

Comments
 (0)