Skip to content

Commit 1dda55b

Browse files
akoclaude
andcommitted
fix: propagate OData capability annotations to external entity flags (#201)
CREATE EXTERNAL ENTITIES now matches Studio Pro's conservative defaults: top-level entities and their attributes default to Creatable=false / Updatable=false when the service metadata is silent, rather than optimistically true. Per-property overrides from InsertRestrictions/NonInsertableProperties, UpdateRestrictions/ NonUpdatableProperties, Core.Computed, and Core.Immutable are applied. Also drops a spurious Updatable field from Rest$ODataRemoteEntitySource BSON — Studio Pro never emits it. Verified against test3's ODataClient (Mendix-published service with full annotations) and TripPin (silent metadata) — generated BSON matches Studio Pro byte-for-byte for entity, attribute, and primary association flags. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 19f6b23 commit 1dda55b

4 files changed

Lines changed: 222 additions & 24 deletions

File tree

mdl/executor/cmd_contract.go

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -550,20 +550,36 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
550550
})
551551
}
552552

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).
558-
defaultUpdatable := !isTopLevel
559-
if entitySet != nil && entitySet.Updatable != nil {
560-
defaultUpdatable = *entitySet.Updatable
553+
// Default Creatable / Updatable for attributes. For top-level
554+
// entities the default follows the entity set's Insert/Update
555+
// restrictions — missing annotations mean read-only (Mendix
556+
// Studio Pro applies the same conservative default, and
557+
// mxbuild treats silent metadata as non-writable). For
558+
// non-top-level (contained/derived) entities both default to
559+
// true, matching Studio Pro's output where contained types are
560+
// mutated via their parent's write flow.
561+
defaultCreatable := false
562+
defaultUpdatable := false
563+
if !isTopLevel {
564+
defaultCreatable = true
565+
defaultUpdatable = true
561566
}
562-
// Default Creatable for attributes follows InsertRestrictions.
563-
defaultCreatable := true
564567
if entitySet != nil && entitySet.Insertable != nil {
565568
defaultCreatable = *entitySet.Insertable
566569
}
570+
if entitySet != nil && entitySet.Updatable != nil {
571+
defaultUpdatable = *entitySet.Updatable
572+
}
573+
nonInsertable := make(map[string]bool)
574+
nonUpdatable := make(map[string]bool)
575+
if entitySet != nil {
576+
for _, name := range entitySet.NonInsertableProperties {
577+
nonInsertable[name] = true
578+
}
579+
for _, name := range entitySet.NonUpdatableProperties {
580+
nonUpdatable[name] = true
581+
}
582+
}
567583

568584
// Build attributes from merged properties
569585
var attrs []*domainmodel.Attribute
@@ -584,6 +600,15 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
584600
continue
585601
}
586602

603+
creatable := defaultCreatable
604+
updatable := defaultUpdatable
605+
if nonInsertable[p.Name] || p.Computed {
606+
creatable = false
607+
}
608+
if nonUpdatable[p.Name] || p.Computed || p.Immutable {
609+
updatable = false
610+
}
611+
587612
attrName := attrNameForOData(p.Name, et.Name)
588613
attr := &domainmodel.Attribute{
589614
Name: attrName,
@@ -592,8 +617,8 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
592617
RemoteType: p.Type,
593618
Filterable: true,
594619
Sortable: true,
595-
Creatable: defaultCreatable,
596-
Updatable: defaultUpdatable,
620+
Creatable: creatable,
621+
Updatable: updatable,
597622
}
598623
attr.ID = model.ID(mpr.GenerateID())
599624
attrs = append(attrs, attr)
@@ -1059,14 +1084,15 @@ func applyExternalEntityFields(
10591084
ent.Persistable = true
10601085
ent.RemoteEntitySet = entitySet.Name
10611086
ent.Countable = true
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.
1087+
// Capabilities default to false when the entity set's Capabilities
1088+
// annotations are absent. Silent metadata means the service doesn't
1089+
// advertise write support, so Mendix treats it as read-only — this
1090+
// matches Studio Pro's behaviour and mxbuild's validation rules.
1091+
// Rest$ODataRemoteEntitySource has no Updatable field; updatability
1092+
// is expressed per attribute via Rest$ODataMappedValue.
1093+
ent.Creatable = entitySet.Insertable != nil && *entitySet.Insertable
10691094
ent.Deletable = entitySet.Deletable != nil && *entitySet.Deletable
1095+
ent.Updatable = false
10701096
ent.SkipSupported = true
10711097
ent.TopSupported = true
10721098
ent.CreateChangeLocally = false

sdk/mpr/edmx.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ type EdmProperty struct {
4444
Nullable *bool // nil = not specified (default true)
4545
MaxLength string // e.g. "200", "max"
4646
Scale string // e.g. "variable"
47+
48+
// Capability annotations (OData Core V1). When true, the property is not
49+
// settable by the client:
50+
// Computed = server-computed, not settable on create or update.
51+
// Immutable = settable on create, but not on update.
52+
Computed bool
53+
Immutable bool
4754
}
4855

4956
// EdmNavigationProperty represents a navigation property (association).
@@ -75,6 +82,12 @@ type EdmEntitySet struct {
7582
// Org.OData.Capabilities.V1.{Insert,Update}Restrictions/Non*NavigationProperties.
7683
NonInsertableNavigationProperties []string
7784
NonUpdatableNavigationProperties []string
85+
86+
// Property names listed under
87+
// Org.OData.Capabilities.V1.{Insert,Update}Restrictions/Non*Properties.
88+
// Structural properties named here cannot be set on insert / update.
89+
NonInsertableProperties []string
90+
NonUpdatableProperties []string
7891
}
7992

8093
// EdmAction represents an OData4 action or OData3 function import.
@@ -279,6 +292,14 @@ func parseXmlEntityType(et *xmlEntityType) *EdmEntityType {
279292
v := p.Nullable != "false"
280293
prop.Nullable = &v
281294
}
295+
for _, ann := range p.Annotations {
296+
switch ann.Term {
297+
case "Org.OData.Core.V1.Computed":
298+
prop.Computed = ann.Bool == "" || ann.Bool == "true"
299+
case "Org.OData.Core.V1.Immutable":
300+
prop.Immutable = ann.Bool == "" || ann.Bool == "true"
301+
}
302+
}
282303
entityType.Properties = append(entityType.Properties, prop)
283304
}
284305

@@ -326,6 +347,10 @@ func applyCapabilityAnnotations(es *EdmEntitySet, annotations []xmlCapabilitiesA
326347
if pv.Collection != nil {
327348
es.NonInsertableNavigationProperties = pv.Collection.NavigationPropertyPaths
328349
}
350+
case "NonInsertableProperties":
351+
if pv.Collection != nil {
352+
es.NonInsertableProperties = pv.Collection.PropertyPaths
353+
}
329354
}
330355
}
331356
case "Org.OData.Capabilities.V1.UpdateRestrictions":
@@ -340,6 +365,10 @@ func applyCapabilityAnnotations(es *EdmEntitySet, annotations []xmlCapabilitiesA
340365
if pv.Collection != nil {
341366
es.NonUpdatableNavigationProperties = pv.Collection.NavigationPropertyPaths
342367
}
368+
case "NonUpdatableProperties":
369+
if pv.Collection != nil {
370+
es.NonUpdatableProperties = pv.Collection.PropertyPaths
371+
}
343372
}
344373
}
345374
case "Org.OData.Capabilities.V1.DeleteRestrictions":
@@ -458,7 +487,7 @@ type xmlEntitySet struct {
458487
// <PropertyValue Property="NonInsertableNavigationProperties"><Collection>
459488
// <NavigationPropertyPath>Trips</NavigationPropertyPath></Collection></PropertyValue>.
460489
type xmlCapabilitiesAnnotation struct {
461-
Term string `xml:"Term,attr"`
490+
Term string `xml:"Term,attr"`
462491
Record *xmlCapabilitiesRecord `xml:"Record"`
463492
}
464493

@@ -467,13 +496,14 @@ type xmlCapabilitiesRecord struct {
467496
}
468497

469498
type xmlCapabilitiesPropertyValue struct {
470-
Property string `xml:"Property,attr"`
471-
Bool string `xml:"Bool,attr"`
472-
Collection *xmlCapabilitiesCollection `xml:"Collection"`
499+
Property string `xml:"Property,attr"`
500+
Bool string `xml:"Bool,attr"`
501+
Collection *xmlCapabilitiesCollection `xml:"Collection"`
473502
}
474503

475504
type xmlCapabilitiesCollection struct {
476505
NavigationPropertyPaths []string `xml:"NavigationPropertyPath"`
506+
PropertyPaths []string `xml:"PropertyPath"`
477507
}
478508

479509
type xmlFunctionImport struct {

sdk/mpr/edmx_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,149 @@ func TestParseEdmxEmpty(t *testing.T) {
242242
}
243243
}
244244

245+
const testCapabilitiesMetadata = `<?xml version="1.0" encoding="utf-8"?>
246+
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
247+
<edmx:DataServices>
248+
<Schema Namespace="DefaultNamespace" xmlns="http://docs.oasis-open.org/odata/ns/edm">
249+
<EntityType Name="Order">
250+
<Key><PropertyRef Name="OrderId" /></Key>
251+
<Property Name="OrderId" Type="Edm.Int64" Nullable="false">
252+
<Annotation Term="Org.OData.Core.V1.Computed" Bool="true" />
253+
</Property>
254+
<Property Name="OrderNumber" Type="Edm.String" MaxLength="32">
255+
<Annotation Term="Org.OData.Core.V1.Immutable" Bool="true" />
256+
</Property>
257+
<Property Name="CustomerName" Type="Edm.String" MaxLength="200" />
258+
</EntityType>
259+
<EntityType Name="OrderLine">
260+
<Key><PropertyRef Name="LineId" /></Key>
261+
<Property Name="LineId" Type="Edm.Int64" Nullable="false" />
262+
</EntityType>
263+
<EntityContainer Name="Container">
264+
<EntitySet Name="Orders" EntityType="DefaultNamespace.Order">
265+
<Annotation Term="Org.OData.Capabilities.V1.InsertRestrictions">
266+
<Record>
267+
<PropertyValue Property="Insertable" Bool="true" />
268+
<PropertyValue Property="NonInsertableProperties">
269+
<Collection>
270+
<PropertyPath>OrderId</PropertyPath>
271+
</Collection>
272+
</PropertyValue>
273+
<PropertyValue Property="NonInsertableNavigationProperties">
274+
<Collection>
275+
<NavigationPropertyPath>Lines</NavigationPropertyPath>
276+
</Collection>
277+
</PropertyValue>
278+
</Record>
279+
</Annotation>
280+
<Annotation Term="Org.OData.Capabilities.V1.UpdateRestrictions">
281+
<Record>
282+
<PropertyValue Property="Updatable" Bool="true" />
283+
<PropertyValue Property="NonUpdatableProperties">
284+
<Collection>
285+
<PropertyPath>OrderId</PropertyPath>
286+
<PropertyPath>OrderNumber</PropertyPath>
287+
</Collection>
288+
</PropertyValue>
289+
</Record>
290+
</Annotation>
291+
<Annotation Term="Org.OData.Capabilities.V1.DeleteRestrictions">
292+
<Record><PropertyValue Property="Deletable" Bool="true" /></Record>
293+
</Annotation>
294+
</EntitySet>
295+
<EntitySet Name="OrderLines" EntityType="DefaultNamespace.OrderLine" />
296+
</EntityContainer>
297+
</Schema>
298+
</edmx:DataServices>
299+
</edmx:Edmx>`
300+
301+
func TestParseEdmxCapabilityAnnotations(t *testing.T) {
302+
doc, err := ParseEdmx(testCapabilitiesMetadata)
303+
if err != nil {
304+
t.Fatalf("ParseEdmx failed: %v", err)
305+
}
306+
307+
// Find the Orders entity set.
308+
var orders *EdmEntitySet
309+
for _, es := range doc.EntitySets {
310+
if es.Name == "Orders" {
311+
orders = es
312+
}
313+
}
314+
if orders == nil {
315+
t.Fatal("Orders entity set not found")
316+
}
317+
318+
if orders.Insertable == nil || !*orders.Insertable {
319+
t.Errorf("Orders.Insertable = %v, want true", orders.Insertable)
320+
}
321+
if orders.Updatable == nil || !*orders.Updatable {
322+
t.Errorf("Orders.Updatable = %v, want true", orders.Updatable)
323+
}
324+
if orders.Deletable == nil || !*orders.Deletable {
325+
t.Errorf("Orders.Deletable = %v, want true", orders.Deletable)
326+
}
327+
328+
wantNonIns := []string{"OrderId"}
329+
if !stringSliceEqual(orders.NonInsertableProperties, wantNonIns) {
330+
t.Errorf("NonInsertableProperties = %v, want %v", orders.NonInsertableProperties, wantNonIns)
331+
}
332+
wantNonUpd := []string{"OrderId", "OrderNumber"}
333+
if !stringSliceEqual(orders.NonUpdatableProperties, wantNonUpd) {
334+
t.Errorf("NonUpdatableProperties = %v, want %v", orders.NonUpdatableProperties, wantNonUpd)
335+
}
336+
wantNonInsNav := []string{"Lines"}
337+
if !stringSliceEqual(orders.NonInsertableNavigationProperties, wantNonInsNav) {
338+
t.Errorf("NonInsertableNavigationProperties = %v, want %v", orders.NonInsertableNavigationProperties, wantNonInsNav)
339+
}
340+
341+
// OrderLines has no annotations → all flags unset.
342+
var lines *EdmEntitySet
343+
for _, es := range doc.EntitySets {
344+
if es.Name == "OrderLines" {
345+
lines = es
346+
}
347+
}
348+
if lines == nil {
349+
t.Fatal("OrderLines entity set not found")
350+
}
351+
if lines.Insertable != nil || lines.Updatable != nil || lines.Deletable != nil {
352+
t.Errorf("OrderLines should have nil capability flags, got Insertable=%v Updatable=%v Deletable=%v",
353+
lines.Insertable, lines.Updatable, lines.Deletable)
354+
}
355+
356+
// Per-property Computed/Immutable annotations.
357+
order := doc.FindEntityType("DefaultNamespace.Order")
358+
if order == nil {
359+
t.Fatal("Order entity type not found")
360+
}
361+
propByName := map[string]*EdmProperty{}
362+
for _, p := range order.Properties {
363+
propByName[p.Name] = p
364+
}
365+
if !propByName["OrderId"].Computed {
366+
t.Errorf("OrderId.Computed = false, want true")
367+
}
368+
if !propByName["OrderNumber"].Immutable {
369+
t.Errorf("OrderNumber.Immutable = false, want true")
370+
}
371+
if propByName["CustomerName"].Computed || propByName["CustomerName"].Immutable {
372+
t.Errorf("CustomerName should have no capability flags")
373+
}
374+
}
375+
376+
func stringSliceEqual(a, b []string) bool {
377+
if len(a) != len(b) {
378+
return false
379+
}
380+
for i := range a {
381+
if a[i] != b[i] {
382+
return false
383+
}
384+
}
385+
return true
386+
}
387+
245388
func TestResolveNavType(t *testing.T) {
246389
tests := []struct {
247390
input string

sdk/mpr/writer_domainmodel.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -928,7 +928,6 @@ func serializeODataRemoteEntitySource(e *domainmodel.Entity) bson.D {
928928
bson.E{Key: "SkipSupported", Value: e.SkipSupported},
929929
bson.E{Key: "SourceDocument", Value: e.RemoteServiceName},
930930
bson.E{Key: "TopSupported", Value: e.TopSupported},
931-
bson.E{Key: "Updatable", Value: e.Updatable},
932931
)
933932
return doc
934933
}

0 commit comments

Comments
 (0)