Skip to content

Commit e904929

Browse files
akoclaude
andcommitted
fix: bulk-create derived/abstract/contained external entities (mendixlabs#143)
Studio Pro materialises every entity type from the OData contract, even those that have no entity set (abstract bases like PlanItem, derived types like Flight/Event/PublicTransportation, contained nav targets like Trip via Person.Trips ContainsTarget=true). mxcli was skipping them, leading to a 4-entity import where Studio Pro would create 9. This commit closes the gap (9/9, only TripTag from primitive collections remains for a follow-up). Studio Pro uses three distinct source types for external entities: Rest\$ODataRemoteEntitySource top-level entity-set entities Rest\$ODataEntityTypeSource derived/abstract/contained types Rest\$ODataPrimitiveCollectionEntitySource primitive-collection NPEs The first two require very different BSON layouts and Persistable flags (true vs false). This commit adds writer support for the second and third source types and round-trips them through the parser. EDMX parser now reads BaseType, Abstract, OpenType on entity types and ContainsTarget on navigation properties — needed to walk inheritance chains. Bulk command rewrite: - For each entity type, classify by entity-set presence and emit either Rest\$ODataRemoteEntitySource or Rest\$ODataEntityTypeSource. - Walk the BaseType chain to merge inherited properties (so Flight gets PlanItemId, ConfirmationCode, StartsAt, EndsAt, SeatNumber, FlightNumber) and inherit the key from the chain root. - Drop Edm.Duration properties (no Mendix equivalent; Studio Pro skips them too). Validated against the TripPin reference service: - mxcli now produces 9 entities (Airlines, Airports, Event, Flight, People, Photos, PlanItem, PublicTransportation, Trip) — matches Studio Pro's TripPinTest2 (which has 10, the extra one being TripTag for Trip.Tags primitive collection). - mx check error count unchanged at 4 (all CE6630 on Airports, blocked on parsing Capabilities annotations). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dbe19f5 commit e904929

5 files changed

Lines changed: 253 additions & 121 deletions

File tree

mdl/executor/cmd_contract.go

Lines changed: 123 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -487,49 +487,47 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
487487
existing[ent.Name] = ent
488488
}
489489

490+
// Build a global type lookup so we can resolve BaseType references across schemas.
491+
typeByQualified := make(map[string]*mpr.EdmEntityType)
492+
for _, schema := range doc.Schemas {
493+
for _, et := range schema.EntityTypes {
494+
typeByQualified[schema.Namespace+"."+et.Name] = et
495+
}
496+
}
497+
490498
serviceRef := s.ServiceRef.String()
491499
var created, updated, skipped, failed int
492500

493501
for _, schema := range doc.Schemas {
494502
for _, et := range schema.EntityTypes {
495-
// Apply entity name filter (matched against entity set name when available)
496503
entitySetName := esMap[schema.Namespace+"."+et.Name]
504+
isTopLevel := entitySetName != ""
497505

498-
// Skip abstract types (no entity set) — Studio Pro creates them but
499-
// points them at a parent's entity set; we don't yet handle this.
500-
if entitySetName == "" {
501-
if len(filterSet) > 0 {
502-
// Only skip silently if the user didn't ask for it
503-
if !filterSet[strings.ToLower(et.Name)] {
504-
continue
505-
}
506-
}
507-
fmt.Fprintf(e.output, " SKIPPED: %s (no entity set; abstract or derived type)\n", et.Name)
508-
skipped++
509-
continue
506+
// Mendix entity name: entity set name when present (e.g. "People"),
507+
// otherwise the type name (e.g. "PlanItem", "Flight", "Trip").
508+
mendixName := et.Name
509+
if isTopLevel {
510+
mendixName = entitySetName
510511
}
511512

512-
// The Mendix entity name should be the entity set name (e.g. "People"),
513-
// not the entity type name ("Person").
514-
mendixName := entitySetName
515-
516-
if len(filterSet) > 0 && !filterSet[strings.ToLower(et.Name)] && !filterSet[strings.ToLower(entitySetName)] {
513+
// Apply entity name filter (matched against type name OR entity set name)
514+
if len(filterSet) > 0 && !filterSet[strings.ToLower(et.Name)] && !filterSet[strings.ToLower(mendixName)] {
517515
continue
518516
}
519517

520-
// keyPropSet helps both for building Source.Key and for forcing key
521-
// string attributes to have a non-zero length (CE6121).
518+
// Resolve the merged property and key set by walking the BaseType chain.
519+
mergedProps, keyProps := mergedPropertiesWithKey(et, typeByQualified)
520+
522521
keyPropSet := make(map[string]bool)
523-
for _, k := range et.KeyProperties {
522+
for _, k := range keyProps {
524523
keyPropSet[k] = true
525524
}
526525

527-
// Build key parts (used both for Source.Key and to skip emitting key
528-
// properties as plain attributes when they would collide with reserved names)
526+
// Build key parts from the resolved key (root entity in the chain)
529527
var keyParts []*domainmodel.RemoteKeyPart
530-
for _, keyName := range et.KeyProperties {
528+
for _, keyName := range keyProps {
531529
var keyProp *mpr.EdmProperty
532-
for _, p := range et.Properties {
530+
for _, p := range mergedProps {
533531
if p.Name == keyName {
534532
keyProp = p
535533
break
@@ -546,21 +544,24 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
546544
})
547545
}
548546

549-
// Build attributes from properties
547+
// Build attributes from merged properties
550548
var attrs []*domainmodel.Attribute
551-
for _, p := range et.Properties {
552-
// Drop collection-of-primitive (e.g. Collection(Edm.String)) — Studio Pro
553-
// stores these via separate primitive collection entities; we skip for now.
549+
for _, p := range mergedProps {
550+
// Drop collection-of-primitive — handled separately as primitive
551+
// collection NPEs (not yet implemented).
554552
if strings.HasPrefix(p.Type, "Collection(") {
555553
continue
556554
}
557-
558-
// Drop non-Edm types (complex types, entity refs) — they need to be
559-
// modelled as separate non-persistent entities or external entities,
560-
// which we don't yet handle. Studio Pro skips them or creates NPEs.
555+
// Drop non-Edm types (complex types and entity refs) — they need
556+
// to be modelled as NPEs/associations, not implemented yet.
561557
if !strings.HasPrefix(p.Type, "Edm.") {
562558
continue
563559
}
560+
// Drop Edm.Duration — Mendix has no native duration type and
561+
// Studio Pro skips these properties.
562+
if p.Type == "Edm.Duration" {
563+
continue
564+
}
564565

565566
attrName := attrNameForOData(p.Name, et.Name)
566567
attr := &domainmodel.Attribute{
@@ -570,9 +571,9 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
570571
RemoteType: p.Type,
571572
Filterable: true,
572573
Sortable: true,
573-
// TODO: parse Org.OData.Capabilities.V1 annotations from $metadata
574-
// to derive these per-attribute. For now, defaults assume
575-
// create-on-insert but no in-place update.
574+
// TODO: parse Org.OData.Capabilities.V1 annotations to set
575+
// these per-attribute. Current defaults assume create-on-insert
576+
// but no in-place update.
576577
Creatable: true,
577578
Updatable: false,
578579
}
@@ -586,19 +587,7 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
586587
skipped++
587588
continue
588589
}
589-
existingEntity.Source = "Rest$ODataRemoteEntitySource"
590-
existingEntity.RemoteServiceName = serviceRef
591-
existingEntity.RemoteEntitySet = entitySetName
592-
existingEntity.RemoteEntityName = et.Name
593-
existingEntity.Countable = true
594-
existingEntity.Creatable = true
595-
existingEntity.Deletable = false
596-
existingEntity.Updatable = false
597-
existingEntity.SkipSupported = true
598-
existingEntity.TopSupported = true
599-
existingEntity.CreateChangeLocally = false
600-
existingEntity.RemoteKeyParts = keyParts
601-
existingEntity.Attributes = attrs
590+
applyExternalEntityFields(existingEntity, et, isTopLevel, serviceRef, entitySetName, keyParts, attrs)
602591
if err := e.writer.UpdateEntity(dm.ID, existingEntity); err != nil {
603592
fmt.Fprintf(e.output, " FAILED: %s.%s — %v\n", targetModule, mendixName, err)
604593
failed++
@@ -610,24 +599,11 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
610599

611600
location := model.Point{X: 100 + (created+updated)*150, Y: 100}
612601
newEntity := &domainmodel.Entity{
613-
Name: mendixName,
614-
Persistable: true, // Studio Pro stores external entities as persistable
615-
Location: location,
616-
Attributes: attrs,
617-
Source: "Rest$ODataRemoteEntitySource",
618-
RemoteServiceName: serviceRef,
619-
RemoteEntitySet: entitySetName,
620-
RemoteEntityName: et.Name,
621-
Countable: true,
622-
Creatable: true, // TODO: parse Capabilities annotations
623-
Deletable: false,
624-
Updatable: false,
625-
SkipSupported: true,
626-
TopSupported: true,
627-
CreateChangeLocally: false,
628-
RemoteKeyParts: keyParts,
602+
Name: mendixName,
603+
Location: location,
629604
}
630605
newEntity.ID = model.ID(mpr.GenerateID())
606+
applyExternalEntityFields(newEntity, et, isTopLevel, serviceRef, entitySetName, keyParts, attrs)
631607
if err := e.writer.CreateEntity(dm.ID, newEntity); err != nil {
632608
fmt.Fprintf(e.output, " FAILED: %s.%s — %v\n", targetModule, mendixName, err)
633609
failed++
@@ -643,6 +619,88 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
643619
return nil
644620
}
645621

622+
// applyExternalEntityFields stamps the Source/RemoteServiceName/Key/Attributes
623+
// fields on a domain model entity, choosing the right BSON source type based on
624+
// whether the entity has its own entity set.
625+
//
626+
// Top-level entities (have an entity set) → Rest$ODataRemoteEntitySource.
627+
// Derived/abstract/contained types → Rest$ODataEntityTypeSource.
628+
func applyExternalEntityFields(
629+
ent *domainmodel.Entity,
630+
et *mpr.EdmEntityType,
631+
isTopLevel bool,
632+
serviceRef, entitySetName string,
633+
keyParts []*domainmodel.RemoteKeyPart,
634+
attrs []*domainmodel.Attribute,
635+
) {
636+
ent.RemoteServiceName = serviceRef
637+
ent.RemoteEntityName = et.Name
638+
ent.RemoteKeyParts = keyParts
639+
ent.Attributes = attrs
640+
641+
if isTopLevel {
642+
ent.Source = "Rest$ODataRemoteEntitySource"
643+
ent.Persistable = true
644+
ent.RemoteEntitySet = entitySetName
645+
ent.Countable = true
646+
ent.Creatable = true // TODO: parse Capabilities annotations per-entity
647+
ent.Deletable = false
648+
ent.Updatable = false
649+
ent.SkipSupported = true
650+
ent.TopSupported = true
651+
ent.CreateChangeLocally = false
652+
return
653+
}
654+
655+
// Derived / abstract / contained-target entity (no entity set)
656+
ent.Source = "Rest$ODataEntityTypeSource"
657+
ent.Persistable = false
658+
ent.IsOpen = et.IsOpen
659+
ent.RemoteEntitySet = ""
660+
// CRUD/skip/top fields are not used for entity-type sources; clear them
661+
// in case we're updating an existing entity that previously had them.
662+
ent.Countable = false
663+
ent.Creatable = false
664+
ent.Deletable = false
665+
ent.Updatable = false
666+
ent.SkipSupported = false
667+
ent.TopSupported = false
668+
ent.CreateChangeLocally = false
669+
}
670+
671+
// mergedPropertiesWithKey walks the BaseType chain of an entity type and
672+
// returns the merged property list (base properties first, then derived) along
673+
// with the key property names from the root of the chain.
674+
func mergedPropertiesWithKey(et *mpr.EdmEntityType, byQualified map[string]*mpr.EdmEntityType) ([]*mpr.EdmProperty, []string) {
675+
// Walk to the root, collecting types in order from base → derived.
676+
chain := []*mpr.EdmEntityType{et}
677+
current := et
678+
for current.BaseType != "" {
679+
parent := byQualified[current.BaseType]
680+
if parent == nil {
681+
break
682+
}
683+
chain = append([]*mpr.EdmEntityType{parent}, chain...)
684+
current = parent
685+
}
686+
687+
var merged []*mpr.EdmProperty
688+
seen := make(map[string]bool)
689+
for _, t := range chain {
690+
for _, p := range t.Properties {
691+
if seen[p.Name] {
692+
continue
693+
}
694+
seen[p.Name] = true
695+
merged = append(merged, p)
696+
}
697+
}
698+
699+
// The key always comes from the root of the chain.
700+
keyProps := chain[0].KeyProperties
701+
return merged, keyProps
702+
}
703+
646704
// attrNameForOData returns a Mendix-safe attribute name for an OData property.
647705
// Reserved names like Id and Name collide with Mendix's built-in entity members,
648706
// so they get prefixed with the entity name (e.g. "Id" → "PhotoId").

sdk/domainmodel/domainmodel.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,18 @@ type Entity struct {
9191
GeneralizationRef string `json:"generalizationRef,omitempty"`
9292

9393
// OData remote entity source fields (for external entities)
94-
RemoteServiceName string `json:"remoteServiceName,omitempty"` // Qualified name of consumed OData service
95-
RemoteEntitySet string `json:"remoteEntitySet,omitempty"` // Entity set name
96-
RemoteEntityName string `json:"remoteEntityName,omitempty"` // Remote type name
97-
Countable bool `json:"countable,omitempty"`
98-
Creatable bool `json:"creatable,omitempty"`
99-
Deletable bool `json:"deletable,omitempty"`
100-
Updatable bool `json:"updatable,omitempty"`
101-
SkipSupported bool `json:"skipSupported,omitempty"`
102-
TopSupported bool `json:"topSupported,omitempty"`
103-
CreateChangeLocally bool `json:"createChangeLocally,omitempty"`
104-
RemoteKeyParts []*RemoteKeyPart `json:"remoteKeyParts,omitempty"` // OData key properties
94+
RemoteServiceName string `json:"remoteServiceName,omitempty"` // Qualified name of consumed OData service
95+
RemoteEntitySet string `json:"remoteEntitySet,omitempty"` // Entity set name (Rest$ODataRemoteEntitySource only)
96+
RemoteEntityName string `json:"remoteEntityName,omitempty"` // Remote type name
97+
Countable bool `json:"countable,omitempty"`
98+
Creatable bool `json:"creatable,omitempty"`
99+
Deletable bool `json:"deletable,omitempty"`
100+
Updatable bool `json:"updatable,omitempty"`
101+
SkipSupported bool `json:"skipSupported,omitempty"`
102+
TopSupported bool `json:"topSupported,omitempty"`
103+
CreateChangeLocally bool `json:"createChangeLocally,omitempty"`
104+
IsOpen bool `json:"isOpen,omitempty"` // Rest$ODataEntityTypeSource: <EntityType OpenType="true">
105+
RemoteKeyParts []*RemoteKeyPart `json:"remoteKeyParts,omitempty"` // OData key properties
105106
}
106107

107108
// RemoteKeyPart describes one key property of an external entity, used to

sdk/mpr/edmx.go

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ type EdmSchema struct {
2727
// EdmEntityType represents an entity type definition.
2828
type EdmEntityType struct {
2929
Name string
30+
BaseType string // Qualified name of base type (e.g. "Microsoft...PlanItem"), empty if none
31+
IsAbstract bool // True if <EntityType Abstract="true">
32+
IsOpen bool // True if <EntityType OpenType="true">
3033
KeyProperties []string
3134
Properties []*EdmProperty
3235
NavigationProperties []*EdmNavigationProperty
@@ -45,11 +48,12 @@ type EdmProperty struct {
4548

4649
// EdmNavigationProperty represents a navigation property (association).
4750
type EdmNavigationProperty struct {
48-
Name string
49-
Type string // OData4: "DefaultNamespace.Customer" or "Collection(DefaultNamespace.Part)"
50-
Partner string // OData4 partner property name
51-
TargetType string // Resolved target entity type name (without namespace/Collection)
52-
IsMany bool // true if Collection()
51+
Name string
52+
Type string // OData4: "DefaultNamespace.Customer" or "Collection(DefaultNamespace.Part)"
53+
Partner string // OData4 partner property name
54+
TargetType string // Resolved target entity type name (without namespace/Collection)
55+
IsMany bool // true if Collection()
56+
ContainsTarget bool // true if <NavigationProperty ContainsTarget="true">
5357
// OData3 fields (from Association)
5458
Relationship string
5559
FromRole string
@@ -221,7 +225,10 @@ func (d *EdmxDocument) FindEntityType(name string) *EdmEntityType {
221225

222226
func parseXmlEntityType(et *xmlEntityType) *EdmEntityType {
223227
entityType := &EdmEntityType{
224-
Name: et.Name,
228+
Name: et.Name,
229+
BaseType: et.BaseType,
230+
IsAbstract: et.Abstract == "true",
231+
IsOpen: et.OpenType == "true",
225232
}
226233

227234
// Parse key
@@ -265,12 +272,13 @@ func parseXmlEntityType(et *xmlEntityType) *EdmEntityType {
265272
// Parse navigation properties
266273
for _, np := range et.NavigationProperties {
267274
nav := &EdmNavigationProperty{
268-
Name: np.Name,
269-
Type: np.Type,
270-
Partner: np.Partner,
271-
Relationship: np.Relationship,
272-
FromRole: np.FromRole,
273-
ToRole: np.ToRole,
275+
Name: np.Name,
276+
Type: np.Type,
277+
Partner: np.Partner,
278+
ContainsTarget: np.ContainsTarget == "true",
279+
Relationship: np.Relationship,
280+
FromRole: np.FromRole,
281+
ToRole: np.ToRole,
274282
}
275283

276284
// Resolve target type from OData4 Type field
@@ -323,6 +331,9 @@ type xmlSchema struct {
323331

324332
type xmlEntityType struct {
325333
Name string `xml:"Name,attr"`
334+
BaseType string `xml:"BaseType,attr"`
335+
Abstract string `xml:"Abstract,attr"`
336+
OpenType string `xml:"OpenType,attr"`
326337
Key *xmlKey `xml:"Key"`
327338
Properties []xmlProperty `xml:"Property"`
328339
NavigationProperties []xmlNavigationProperty `xml:"NavigationProperty"`
@@ -348,12 +359,13 @@ type xmlProperty struct {
348359
}
349360

350361
type xmlNavigationProperty struct {
351-
Name string `xml:"Name,attr"`
352-
Type string `xml:"Type,attr"` // OData4
353-
Partner string `xml:"Partner,attr"` // OData4
354-
Relationship string `xml:"Relationship,attr"` // OData3
355-
FromRole string `xml:"FromRole,attr"` // OData3
356-
ToRole string `xml:"ToRole,attr"` // OData3
362+
Name string `xml:"Name,attr"`
363+
Type string `xml:"Type,attr"` // OData4
364+
Partner string `xml:"Partner,attr"` // OData4
365+
ContainsTarget string `xml:"ContainsTarget,attr"` // OData4: contained nav target (e.g. Person.Trips)
366+
Relationship string `xml:"Relationship,attr"` // OData3
367+
FromRole string `xml:"FromRole,attr"` // OData3
368+
ToRole string `xml:"ToRole,attr"` // OData3
357369
}
358370

359371
type xmlDocumentation struct {

0 commit comments

Comments
 (0)