Skip to content

Commit 09eba0b

Browse files
akoclaude
andcommitted
fix: parse OData Capabilities annotations for entity/association CRUD (mendixlabs#143)
The bulk-create command was hardcoding Creatable/Updatable/Deletable defaults, causing CE6630 errors whenever the OData service marked an entity or navigation property as restricted. This commit reads the relevant Org.OData.Capabilities.V1 annotations from \$metadata so the generated entities and associations match what Studio Pro produces. Result against the TripPin reference: \`mx check\` reports **0 errors** on the bulk-imported TripPinTest module (down from 51 at the start of issue mendixlabs#143). EDMX parser additions: - EdmEntitySet now exposes Insertable, Updatable, Deletable (\*bool, nil = unspecified) along with NonInsertableNavigationProperties and NonUpdatableNavigationProperties from Org.OData.Capabilities.V1.{Insert,Update,Delete}Restrictions records on each \<EntitySet\>. Bulk command updates: - applyExternalEntityFields now takes the EdmEntitySet and applies its Insertable / Updatable / Deletable flags to entity-level capabilities (entity-set-source entities only). - Per-attribute Creatable/Updatable defaults follow the entity set's Insertable/Updatable when present. - Per-association CreatableFromParent / UpdatableFromParent honour the parent entity set's NonInsertableNavigationProperties / NonUpdatableNavigationProperties lists. - For top-level (entity-set source) parents, UpdatableFromParent defaults to false: link updates happen via foreign keys on the entity itself, not by mutating the navigation property. For contained or derived types it stays true (matches Studio Pro's reference). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 212d064 commit 09eba0b

2 files changed

Lines changed: 170 additions & 27 deletions

File tree

mdl/executor/cmd_contract.go

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -456,8 +456,10 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
456456

457457
// Build entity set lookup: entity type qualified name → entity set name
458458
esMap := make(map[string]string)
459+
esByType := make(map[string]*mpr.EdmEntitySet)
459460
for _, es := range doc.EntitySets {
460461
esMap[es.EntityType] = es.Name
462+
esByType[es.EntityType] = es
461463
}
462464

463465
// Build filter set if entity names specified
@@ -500,7 +502,11 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
500502

501503
for _, schema := range doc.Schemas {
502504
for _, et := range schema.EntityTypes {
503-
entitySetName := esMap[schema.Namespace+"."+et.Name]
505+
entitySet := esByType[schema.Namespace+"."+et.Name]
506+
entitySetName := ""
507+
if entitySet != nil {
508+
entitySetName = entitySet.Name
509+
}
504510
isTopLevel := entitySetName != ""
505511

506512
// Mendix entity name: entity set name when present (e.g. "People"),
@@ -544,12 +550,20 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
544550
})
545551
}
546552

547-
// Default Updatable depends on whether the parent will be a top-
548-
// level entity-set entity or a derived/contained entity-type source.
549-
// Top-level entities are typically read-only on writable attributes
550-
// (matches People/Photos/Airlines in TripPin); contained types are
551-
// updatable.
553+
// Default Updatable for attributes follows the entity-set's
554+
// UpdateRestrictions when available; otherwise falls back to true
555+
// for non-top-level entities and false for top-level (matching
556+
// TripPin's pattern where attributes are read-only on writable
557+
// entity sets but mutable on contained types).
552558
defaultUpdatable := !isTopLevel
559+
if entitySet != nil && entitySet.Updatable != nil {
560+
defaultUpdatable = *entitySet.Updatable
561+
}
562+
// Default Creatable for attributes follows InsertRestrictions.
563+
defaultCreatable := true
564+
if entitySet != nil && entitySet.Insertable != nil {
565+
defaultCreatable = *entitySet.Insertable
566+
}
553567

554568
// Build attributes from merged properties
555569
var attrs []*domainmodel.Attribute
@@ -578,10 +592,8 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
578592
RemoteType: p.Type,
579593
Filterable: true,
580594
Sortable: true,
581-
// TODO: parse Org.OData.Capabilities.V1 annotations to set
582-
// these per-attribute. Defaults match TripPin's pattern.
583-
Creatable: true,
584-
Updatable: defaultUpdatable,
595+
Creatable: defaultCreatable,
596+
Updatable: defaultUpdatable,
585597
}
586598
attr.ID = model.ID(mpr.GenerateID())
587599
attrs = append(attrs, attr)
@@ -593,7 +605,7 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
593605
skipped++
594606
continue
595607
}
596-
applyExternalEntityFields(existingEntity, et, isTopLevel, serviceRef, entitySetName, keyParts, attrs)
608+
applyExternalEntityFields(existingEntity, et, isTopLevel, serviceRef, entitySet, keyParts, attrs)
597609
if err := e.writer.UpdateEntity(dm.ID, existingEntity); err != nil {
598610
fmt.Fprintf(e.output, " FAILED: %s.%s — %v\n", targetModule, mendixName, err)
599611
failed++
@@ -609,7 +621,7 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
609621
Location: location,
610622
}
611623
newEntity.ID = model.ID(mpr.GenerateID())
612-
applyExternalEntityFields(newEntity, et, isTopLevel, serviceRef, entitySetName, keyParts, attrs)
624+
applyExternalEntityFields(newEntity, et, isTopLevel, serviceRef, entitySet, keyParts, attrs)
613625
if err := e.writer.CreateEntity(dm.ID, newEntity); err != nil {
614626
fmt.Fprintf(e.output, " FAILED: %s.%s — %v\n", targetModule, mendixName, err)
615627
failed++
@@ -836,14 +848,36 @@ func singular(name string) string {
836848
// navigation properties from BaseType chains are walked too.
837849
//
838850
// Each association uses Rest$ODataRemoteAssociationSource so Studio Pro can
839-
// map it back to the OData navigation property.
851+
// map it back to the OData navigation property. Per-association
852+
// CreatableFromParent / UpdatableFromParent come from the entity set's
853+
// Org.OData.Capabilities.V1.{Insert,Update}Restrictions/Non*NavigationProperties
854+
// annotations.
840855
func (e *Executor) createNavigationAssociations(
841856
dm *domainmodel.DomainModel,
842857
doc *mpr.EdmxDocument,
843858
typeByQualified map[string]*mpr.EdmEntityType,
844859
esMap map[string]string,
845860
serviceRef string,
846861
) int {
862+
// Build per-entity-type lookup of nav property name → restricted flags.
863+
type navRestrictions struct {
864+
nonInsertable map[string]bool
865+
nonUpdatable map[string]bool
866+
}
867+
restrictionsByType := make(map[string]navRestrictions)
868+
for _, es := range doc.EntitySets {
869+
r := navRestrictions{
870+
nonInsertable: make(map[string]bool),
871+
nonUpdatable: make(map[string]bool),
872+
}
873+
for _, name := range es.NonInsertableNavigationProperties {
874+
r.nonInsertable[name] = true
875+
}
876+
for _, name := range es.NonUpdatableNavigationProperties {
877+
r.nonUpdatable[name] = true
878+
}
879+
restrictionsByType[es.EntityType] = r
880+
}
847881
// Build a lookup from EDMX type qualified name → existing Mendix entity.
848882
// An entity type matches by its EntitySet name when present, otherwise by
849883
// its bare type name.
@@ -924,6 +958,22 @@ func (e *Executor) createNavigationAssociations(
924958
assocType = domainmodel.AssociationTypeReferenceSet
925959
}
926960

961+
// Apply per-association capability defaults. For top-level
962+
// entities (entity-set source), Studio Pro defaults
963+
// UpdatableFromParent=false because link updates happen via
964+
// the foreign key on the entity, not by updating the nav
965+
// property. For contained/derived types, both default to true.
966+
creatable := true
967+
updatable := !parentEnt.Persistable
968+
if r, ok := restrictionsByType[parentQN]; ok {
969+
if r.nonInsertable[np.Name] {
970+
creatable = false
971+
}
972+
if r.nonUpdatable[np.Name] {
973+
updatable = false
974+
}
975+
}
976+
927977
assoc := &domainmodel.Association{
928978
Name: assocName,
929979
ParentID: parentEnt.ID,
@@ -934,11 +984,8 @@ func (e *Executor) createNavigationAssociations(
934984
Source: "Rest$ODataRemoteAssociationSource",
935985
RemoteParentNavigationProperty: np.Name,
936986
Navigability2: "ParentToChild",
937-
// TODO: parse Org.OData.Capabilities.V1 annotations to
938-
// derive these per-association. Defaults match TripPin's
939-
// most common case.
940-
CreatableFromParent: true,
941-
UpdatableFromParent: true,
987+
CreatableFromParent: creatable,
988+
UpdatableFromParent: updatable,
942989
}
943990
assoc.ID = model.ID(mpr.GenerateID())
944991

@@ -989,11 +1036,16 @@ func uniqueAssocName(base string, dm *domainmodel.DomainModel, existingAssocs ma
9891036
//
9901037
// Top-level entities (have an entity set) → Rest$ODataRemoteEntitySource.
9911038
// Derived/abstract/contained types → Rest$ODataEntityTypeSource.
1039+
//
1040+
// When entitySet is non-nil, its parsed capability annotations
1041+
// (InsertRestrictions/UpdateRestrictions/DeleteRestrictions) override the
1042+
// optimistic defaults.
9921043
func applyExternalEntityFields(
9931044
ent *domainmodel.Entity,
9941045
et *mpr.EdmEntityType,
9951046
isTopLevel bool,
996-
serviceRef, entitySetName string,
1047+
serviceRef string,
1048+
entitySet *mpr.EdmEntitySet,
9971049
keyParts []*domainmodel.RemoteKeyPart,
9981050
attrs []*domainmodel.Attribute,
9991051
) {
@@ -1005,11 +1057,16 @@ func applyExternalEntityFields(
10051057
if isTopLevel {
10061058
ent.Source = "Rest$ODataRemoteEntitySource"
10071059
ent.Persistable = true
1008-
ent.RemoteEntitySet = entitySetName
1060+
ent.RemoteEntitySet = entitySet.Name
10091061
ent.Countable = true
1010-
ent.Creatable = true // TODO: parse Capabilities annotations per-entity
1011-
ent.Deletable = false
1012-
ent.Updatable = false
1062+
// Capabilities default to true unless the entity set's annotations
1063+
// say otherwise.
1064+
ent.Creatable = entitySet.Insertable == nil || *entitySet.Insertable
1065+
ent.Updatable = entitySet.Updatable == nil || *entitySet.Updatable
1066+
// Deletable defaults to false in TripPin and most read-mostly OData
1067+
// services, so default to the annotation value when present and to
1068+
// false otherwise.
1069+
ent.Deletable = entitySet.Deletable != nil && *entitySet.Deletable
10131070
ent.SkipSupported = true
10141071
ent.TopSupported = true
10151072
ent.CreateChangeLocally = false

sdk/mpr/edmx.go

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ type EdmNavigationProperty struct {
6464
type EdmEntitySet struct {
6565
Name string
6666
EntityType string // Qualified name of entity type
67+
68+
// Capabilities derived from Org.OData.Capabilities.V1 annotations.
69+
// nil = not specified (treat as default true).
70+
Insertable *bool // InsertRestrictions/Insertable
71+
Updatable *bool // UpdateRestrictions/Updatable
72+
Deletable *bool // DeleteRestrictions/Deletable
73+
74+
// Navigation property names listed under
75+
// Org.OData.Capabilities.V1.{Insert,Update}Restrictions/Non*NavigationProperties.
76+
NonInsertableNavigationProperties []string
77+
NonUpdatableNavigationProperties []string
6778
}
6879

6980
// EdmAction represents an OData4 action or OData3 function import.
@@ -137,10 +148,12 @@ func ParseEdmx(metadataXML string) (*EdmxDocument, error) {
137148
// Parse entity container
138149
for _, ec := range s.EntityContainers {
139150
for _, es := range ec.EntitySets {
140-
doc.EntitySets = append(doc.EntitySets, &EdmEntitySet{
151+
entitySet := &EdmEntitySet{
141152
Name: es.Name,
142153
EntityType: es.EntityType,
143-
})
154+
}
155+
applyCapabilityAnnotations(entitySet, es.Annotations)
156+
doc.EntitySets = append(doc.EntitySets, entitySet)
144157
}
145158

146159
// OData3 function imports
@@ -292,6 +305,54 @@ func parseXmlEntityType(et *xmlEntityType) *EdmEntityType {
292305
return entityType
293306
}
294307

308+
// applyCapabilityAnnotations reads Org.OData.Capabilities.V1.{Insert,Update,
309+
// Delete}Restrictions annotations on an entity set and stores the relevant
310+
// flags on the EdmEntitySet.
311+
func applyCapabilityAnnotations(es *EdmEntitySet, annotations []xmlCapabilitiesAnnotation) {
312+
for _, ann := range annotations {
313+
if ann.Record == nil {
314+
continue
315+
}
316+
switch ann.Term {
317+
case "Org.OData.Capabilities.V1.InsertRestrictions":
318+
for _, pv := range ann.Record.PropertyValues {
319+
switch pv.Property {
320+
case "Insertable":
321+
if pv.Bool != "" {
322+
v := pv.Bool == "true"
323+
es.Insertable = &v
324+
}
325+
case "NonInsertableNavigationProperties":
326+
if pv.Collection != nil {
327+
es.NonInsertableNavigationProperties = pv.Collection.NavigationPropertyPaths
328+
}
329+
}
330+
}
331+
case "Org.OData.Capabilities.V1.UpdateRestrictions":
332+
for _, pv := range ann.Record.PropertyValues {
333+
switch pv.Property {
334+
case "Updatable":
335+
if pv.Bool != "" {
336+
v := pv.Bool == "true"
337+
es.Updatable = &v
338+
}
339+
case "NonUpdatableNavigationProperties":
340+
if pv.Collection != nil {
341+
es.NonUpdatableNavigationProperties = pv.Collection.NavigationPropertyPaths
342+
}
343+
}
344+
}
345+
case "Org.OData.Capabilities.V1.DeleteRestrictions":
346+
for _, pv := range ann.Record.PropertyValues {
347+
if pv.Property == "Deletable" && pv.Bool != "" {
348+
v := pv.Bool == "true"
349+
es.Deletable = &v
350+
}
351+
}
352+
}
353+
}
354+
}
355+
295356
// resolveNavType parses "Collection(Namespace.Type)" or "Namespace.Type" into the short type name.
296357
func resolveNavType(t string) (typeName string, isMany bool) {
297358
if strings.HasPrefix(t, "Collection(") && strings.HasSuffix(t, ")") {
@@ -386,8 +447,33 @@ type xmlEntityContainer struct {
386447
}
387448

388449
type xmlEntitySet struct {
389-
Name string `xml:"Name,attr"`
390-
EntityType string `xml:"EntityType,attr"`
450+
Name string `xml:"Name,attr"`
451+
EntityType string `xml:"EntityType,attr"`
452+
Annotations []xmlCapabilitiesAnnotation `xml:"Annotation"`
453+
}
454+
455+
// xmlCapabilitiesAnnotation captures the bits of OData V1 Capabilities
456+
// annotations we care about. The wrapping <Record> contains
457+
// <PropertyValue Property="Insertable" Bool="..."/> and (sometimes)
458+
// <PropertyValue Property="NonInsertableNavigationProperties"><Collection>
459+
// <NavigationPropertyPath>Trips</NavigationPropertyPath></Collection></PropertyValue>.
460+
type xmlCapabilitiesAnnotation struct {
461+
Term string `xml:"Term,attr"`
462+
Record *xmlCapabilitiesRecord `xml:"Record"`
463+
}
464+
465+
type xmlCapabilitiesRecord struct {
466+
PropertyValues []xmlCapabilitiesPropertyValue `xml:"PropertyValue"`
467+
}
468+
469+
type xmlCapabilitiesPropertyValue struct {
470+
Property string `xml:"Property,attr"`
471+
Bool string `xml:"Bool,attr"`
472+
Collection *xmlCapabilitiesCollection `xml:"Collection"`
473+
}
474+
475+
type xmlCapabilitiesCollection struct {
476+
NavigationPropertyPaths []string `xml:"NavigationPropertyPath"`
391477
}
392478

393479
type xmlFunctionImport struct {

0 commit comments

Comments
 (0)