Skip to content

Commit 212d064

Browse files
akoclaude
andcommitted
fix: bulk-create primitive collection NPEs (mendixlabs#143)
OData properties of type Collection(Edm.X) have no direct Mendix equivalent. Studio Pro models them as a non-persistent entity holding the values, plus an association from the parent. For TripPin this adds the TripTag entity for Trip.Tags = Collection(Edm.String). This commit: - Adds the TripTag-style NPE flow: walk each entity's properties, detect Collection(Edm.X), create a non-persistent entity using Rest\$ODataPrimitiveCollectionEntitySource, with one attribute carrying Rest\$ODataMappedPrimitiveCollectionValue. - Creates the parent → NPE association using a brand-new source type Rest\$ODataPrimitiveCollectionAssociationSource (a marker type with no fields, paired with the NPE's source type). - Skips primitive collection NPEs whose parent is persistable: Mendix forbids associations from a persistable to a non-persistable entity (CE0001), and Studio Pro doesn't create them either (Person.Emails is dropped, but Trip.Tags becomes TripTag because Trip is itself non-persistable). - Formats the RemoteType the way Studio Pro stores it for primitive collections, e.g. "Collection([Edm.String Nullable=False Unicode=True])". - Sets per-attribute Updatable based on whether the parent is a top-level entity-set entity (false, matching service capabilities) or a derived/contained entity-type source (true). Writer/parser additions: - The "is external entity" check used by serializeAttribute now also recognises Rest\$ODataEntityTypeSource and Rest\$ODataPrimitiveCollectionEntitySource so attributes on derived and primitive-collection entities serialize as Rest\$ODataMappedValue / Rest\$ODataMappedPrimitiveCollectionValue instead of StoredValue. - serializeAssociation handles the new Rest\$ODataPrimitiveCollectionAssociationSource source type. - parseAttributeValue and parseAssociation round-trip both new types. Validated against TripPin: 10 entities matching Studio Pro's TripPinTest2 reference exactly. mx check error count down to 7, all remaining are CE6630 from unparsed OData Capabilities annotations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4a5c86d commit 212d064

4 files changed

Lines changed: 251 additions & 11 deletions

File tree

mdl/executor/cmd_contract.go

Lines changed: 200 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,13 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
544544
})
545545
}
546546

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.
552+
defaultUpdatable := !isTopLevel
553+
547554
// Build attributes from merged properties
548555
var attrs []*domainmodel.Attribute
549556
for _, p := range mergedProps {
@@ -572,10 +579,9 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
572579
Filterable: true,
573580
Sortable: true,
574581
// 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.
582+
// these per-attribute. Defaults match TripPin's pattern.
577583
Creatable: true,
578-
Updatable: false,
584+
Updatable: defaultUpdatable,
579585
}
580586
attr.ID = model.ID(mpr.GenerateID())
581587
attrs = append(attrs, attr)
@@ -613,9 +619,20 @@ func (e *Executor) createExternalEntities(s *ast.CreateExternalEntitiesStmt) err
613619
}
614620
}
615621

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).
622+
// Second pass: create primitive-collection NPEs (e.g. TripTag for
623+
// Trip.Tags = Collection(Edm.String)) and the association from the
624+
// parent entity to each NPE.
625+
dm, err = e.reader.GetDomainModel(module.ID)
626+
if err == nil {
627+
npesCreated := e.createPrimitiveCollectionNPEs(dm, doc, typeByQualified, esMap, serviceRef)
628+
if npesCreated > 0 {
629+
fmt.Fprintf(e.output, "Created %d primitive-collection NPEs\n", npesCreated)
630+
}
631+
}
632+
633+
// Third pass: walk navigation properties and create associations between
634+
// the entities we just created. Re-read the domain model so the NPEs
635+
// from the previous pass are visible.
619636
dm, err = e.reader.GetDomainModel(module.ID)
620637
if err == nil {
621638
assocsCreated := e.createNavigationAssociations(dm, doc, typeByQualified, esMap, serviceRef)
@@ -636,6 +653,183 @@ type assocKey struct {
636653
parent, name string
637654
}
638655

656+
// createPrimitiveCollectionNPEs walks each entity type's properties and, for
657+
// each Collection(Edm.X) property, creates a non-persistent entity to hold
658+
// the values plus an association from the parent entity. This mirrors how
659+
// Studio Pro handles e.g. Trip.Tags = Collection(Edm.String) by creating a
660+
// TripTag NPE and a Trip_TripTag ReferenceSet.
661+
func (e *Executor) createPrimitiveCollectionNPEs(
662+
dm *domainmodel.DomainModel,
663+
doc *mpr.EdmxDocument,
664+
typeByQualified map[string]*mpr.EdmEntityType,
665+
esMap map[string]string,
666+
serviceRef string,
667+
) int {
668+
// Lookup parent Mendix entity by EDMX type qualified name.
669+
parentByQN := make(map[string]*domainmodel.Entity)
670+
for qn, et := range typeByQualified {
671+
mendixName := et.Name
672+
if es := esMap[qn]; es != "" {
673+
mendixName = es
674+
}
675+
for _, ent := range dm.Entities {
676+
if ent.Name == mendixName {
677+
parentByQN[qn] = ent
678+
break
679+
}
680+
}
681+
}
682+
683+
count := 0
684+
for _, schema := range doc.Schemas {
685+
for _, et := range schema.EntityTypes {
686+
parentEnt := parentByQN[schema.Namespace+"."+et.Name]
687+
if parentEnt == nil {
688+
continue
689+
}
690+
691+
// Studio Pro only creates primitive collection NPEs when the
692+
// parent entity is non-persistable (Rest$ODataEntityTypeSource).
693+
// Mendix forbids associations from a persistable to a non-
694+
// persistable entity (CE0001), so a top-level entity-set entity
695+
// can't own an NPE child.
696+
if parentEnt.Persistable {
697+
continue
698+
}
699+
700+
// Collect inherited properties from BaseType chain (only for the
701+
// derived type itself — base types iterate independently).
702+
merged, _ := mergedPropertiesWithKey(et, typeByQualified)
703+
for _, p := range merged {
704+
if !strings.HasPrefix(p.Type, "Collection(Edm.") {
705+
continue
706+
}
707+
708+
// Skip if a property of the same name was inherited from a
709+
// base type (the base type's iteration already created the NPE).
710+
if isInheritedProperty(et, p.Name, typeByQualified) {
711+
continue
712+
}
713+
714+
npeName := parentEnt.Name + singular(p.Name)
715+
716+
// Skip if NPE already exists (idempotent re-runs)
717+
if findEntityByName(dm, npeName) != nil {
718+
continue
719+
}
720+
721+
// Build the inner attribute type from the element type
722+
innerType := p.Type[len("Collection(") : len(p.Type)-1]
723+
innerProp := &mpr.EdmProperty{
724+
Name: singular(p.Name),
725+
Type: innerType,
726+
MaxLength: p.MaxLength,
727+
Scale: p.Scale,
728+
}
729+
730+
attr := &domainmodel.Attribute{
731+
Name: singular(p.Name),
732+
Type: edmToDomainModelAttrType(innerProp, false),
733+
RemoteName: p.Name,
734+
RemoteType: primitiveCollectionRemoteType(innerType, p.Nullable),
735+
IsPrimitiveCollection: true,
736+
}
737+
attr.ID = model.ID(mpr.GenerateID())
738+
739+
npe := &domainmodel.Entity{
740+
Name: npeName,
741+
Persistable: false,
742+
Location: model.Point{X: parentEnt.Location.X + 200, Y: parentEnt.Location.Y + 100},
743+
Attributes: []*domainmodel.Attribute{attr},
744+
Source: "Rest$ODataPrimitiveCollectionEntitySource",
745+
RemoteServiceName: serviceRef,
746+
}
747+
npe.ID = model.ID(mpr.GenerateID())
748+
749+
if err := e.writer.CreateEntity(dm.ID, npe); err != nil {
750+
fmt.Fprintf(e.output, " NPE FAILED: %s — %v\n", npeName, err)
751+
continue
752+
}
753+
count++
754+
755+
// Create the association from the parent entity to the NPE.
756+
// Studio Pro names this <ParentEntityName>_<NPEName> and uses
757+
// Rest$ODataPrimitiveCollectionAssociationSource (a marker
758+
// type with no fields, paired with the NPE's source type).
759+
assocName := parentEnt.Name + "_" + npeName
760+
assoc := &domainmodel.Association{
761+
Name: assocName,
762+
ParentID: parentEnt.ID,
763+
ChildID: npe.ID,
764+
Type: domainmodel.AssociationTypeReferenceSet,
765+
Owner: domainmodel.AssociationOwnerDefault,
766+
StorageFormat: domainmodel.StorageFormatColumn,
767+
Source: "Rest$ODataPrimitiveCollectionAssociationSource",
768+
}
769+
assoc.ID = model.ID(mpr.GenerateID())
770+
if err := e.writer.CreateAssociation(dm.ID, assoc); err != nil {
771+
fmt.Fprintf(e.output, " NPE ASSOC FAILED: %s — %v\n", assocName, err)
772+
}
773+
}
774+
}
775+
}
776+
return count
777+
}
778+
779+
// isInheritedProperty reports whether a property name comes from one of the
780+
// entity type's base types (rather than being defined on the type itself).
781+
func isInheritedProperty(et *mpr.EdmEntityType, propName string, byQN map[string]*mpr.EdmEntityType) bool {
782+
for _, p := range et.Properties {
783+
if p.Name == propName {
784+
return false
785+
}
786+
}
787+
return true
788+
}
789+
790+
// findEntityByName returns a domain model entity by name, or nil if not found.
791+
func findEntityByName(dm *domainmodel.DomainModel, name string) *domainmodel.Entity {
792+
for _, ent := range dm.Entities {
793+
if ent.Name == name {
794+
return ent
795+
}
796+
}
797+
return nil
798+
}
799+
800+
// primitiveCollectionRemoteType formats the OData remote type string the way
801+
// Studio Pro stores it for a Collection(Edm.X) — bracketed with the Nullable
802+
// and Unicode attributes spelled out, e.g.
803+
//
804+
// Collection([Edm.String Nullable=False Unicode=True])
805+
// Collection([Edm.Int32 Nullable=False])
806+
func primitiveCollectionRemoteType(innerType string, nullable *bool) string {
807+
nullableStr := "True"
808+
if nullable != nil && !*nullable {
809+
nullableStr = "False"
810+
}
811+
if innerType == "Edm.String" {
812+
return fmt.Sprintf("Collection([%s Nullable=%s Unicode=True])", innerType, nullableStr)
813+
}
814+
return fmt.Sprintf("Collection([%s Nullable=%s])", innerType, nullableStr)
815+
}
816+
817+
// singular returns a naive singular form of an English plural by stripping a
818+
// trailing "s". Good enough for OData property names like "Tags" → "Tag".
819+
// Doesn't handle irregular plurals.
820+
func singular(name string) string {
821+
if strings.HasSuffix(name, "ies") {
822+
return name[:len(name)-3] + "y"
823+
}
824+
if strings.HasSuffix(name, "es") && !strings.HasSuffix(name, "ses") {
825+
return name[:len(name)-2]
826+
}
827+
if strings.HasSuffix(name, "s") {
828+
return name[:len(name)-1]
829+
}
830+
return name
831+
}
832+
639833
// createNavigationAssociations walks the navigation properties of every entity
640834
// type in the schema and creates a corresponding Mendix association for each
641835
// one whose target also exists as an entity in this domain model. Inherited

sdk/domainmodel/domainmodel.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ type Attribute struct {
178178
Sortable bool `json:"sortable,omitempty"`
179179
Creatable bool `json:"creatable,omitempty"`
180180
Updatable bool `json:"updatable,omitempty"`
181+
182+
// IsPrimitiveCollection marks the single attribute of a primitive
183+
// collection NPE (e.g. TripTag.Tag). When set, the writer emits
184+
// Rest$ODataMappedPrimitiveCollectionValue instead of Rest$ODataMappedValue.
185+
IsPrimitiveCollection bool `json:"isPrimitiveCollection,omitempty"`
181186
}
182187

183188
// GetName returns the attribute's name.

sdk/mpr/parser_domainmodel.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,18 @@ func parseAttribute(raw map[string]any) *domainmodel.Attribute {
272272

273273
// For external entities, the Value is a Rest$ODataMappedValue that
274274
// carries the OData property name, type, and capability flags.
275-
if extractString(val["$Type"]) == "Rest$ODataMappedValue" {
275+
switch extractString(val["$Type"]) {
276+
case "Rest$ODataMappedValue":
276277
attr.RemoteName = extractString(val["RemoteName"])
277278
attr.RemoteType = extractString(val["RemoteType"])
278279
attr.Filterable = extractBool(val["Filterable"], false)
279280
attr.Sortable = extractBool(val["Sortable"], false)
280281
attr.Creatable = extractBool(val["Creatable"], false)
281282
attr.Updatable = extractBool(val["Updatable"], false)
283+
case "Rest$ODataMappedPrimitiveCollectionValue":
284+
attr.RemoteName = extractString(val["RemoteName"])
285+
attr.RemoteType = extractString(val["RemoteType"])
286+
attr.IsPrimitiveCollection = true
282287
}
283288
}
284289

@@ -320,6 +325,13 @@ func parseAttributeValue(raw map[string]any) *domainmodel.AttributeValue {
320325
}
321326
val.ID = valueID
322327
return val
328+
case "Rest$ODataMappedPrimitiveCollectionValue":
329+
val := &domainmodel.AttributeValue{
330+
Type: "ODataMappedPrimitiveCollectionValue",
331+
DefaultValue: extractString(raw["DefaultValueDesignTime"]),
332+
}
333+
val.ID = valueID
334+
return val
323335
default:
324336
val := &domainmodel.AttributeValue{
325337
DefaultValue: defaultValue,
@@ -431,7 +443,8 @@ func parseAssociation(raw map[string]any) *domainmodel.Association {
431443

432444
// Parse OData remote association source
433445
if sourceMap, ok := raw["Source"].(map[string]any); ok {
434-
if extractString(sourceMap["$Type"]) == "Rest$ODataRemoteAssociationSource" {
446+
switch extractString(sourceMap["$Type"]) {
447+
case "Rest$ODataRemoteAssociationSource":
435448
assoc.Source = "Rest$ODataRemoteAssociationSource"
436449
assoc.RemoteParentNavigationProperty = extractString(sourceMap["RemoteParentNavigationProperty"])
437450
assoc.RemoteChildNavigationProperty = extractString(sourceMap["RemoteChildNavigationProperty"])
@@ -440,6 +453,8 @@ func parseAssociation(raw map[string]any) *domainmodel.Association {
440453
assoc.UpdatableFromParent = extractBool(sourceMap["UpdatableFromParent"], false)
441454
assoc.UpdatableFromChild = extractBool(sourceMap["UpdatableFromChild"], false)
442455
assoc.Navigability2 = extractString(sourceMap["Navigability2"])
456+
case "Rest$ODataPrimitiveCollectionAssociationSource":
457+
assoc.Source = "Rest$ODataPrimitiveCollectionAssociationSource"
443458
}
444459
}
445460

sdk/mpr/writer_domainmodel.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,12 @@ func (w *Writer) serializeDomainModel(dm *domainmodel.DomainModel) ([]byte, erro
606606
}
607607

608608
func serializeEntity(e *domainmodel.Entity, moduleName string, pv *version.ProjectVersion) bson.D {
609-
isExternal := e.Source == "Rest$ODataRemoteEntitySource"
609+
// Any of the three OData source types means the attributes need OData
610+
// mapped value serialization (Rest$ODataMappedValue or its primitive
611+
// collection variant), not the regular DomainModels$StoredValue.
612+
isExternal := e.Source == "Rest$ODataRemoteEntitySource" ||
613+
e.Source == "Rest$ODataEntityTypeSource" ||
614+
e.Source == "Rest$ODataPrimitiveCollectionEntitySource"
610615

611616
// Attributes array with version prefix 3
612617
attrs := bson.A{int32(3)}
@@ -1015,6 +1020,19 @@ func serializeAttribute(a *domainmodel.Attribute, isExternalEntity bool) bson.D
10151020
{Key: "Microflow", Value: microflowRef},
10161021
{Key: "PassEntity", Value: microflowRef != ""},
10171022
}
1023+
} else if isExternalEntity && a.IsPrimitiveCollection {
1024+
// Single attribute of a primitive collection NPE (e.g. TripTag.Tag)
1025+
defaultValue := ""
1026+
if a.Value != nil && a.Value.DefaultValue != "" {
1027+
defaultValue = a.Value.DefaultValue
1028+
}
1029+
valueDoc = bson.D{
1030+
{Key: "$ID", Value: idToBsonBinary(valueID)},
1031+
{Key: "$Type", Value: "Rest$ODataMappedPrimitiveCollectionValue"},
1032+
{Key: "DefaultValueDesignTime", Value: defaultValue},
1033+
{Key: "RemoteName", Value: a.RemoteName},
1034+
{Key: "RemoteType", Value: a.RemoteType},
1035+
}
10181036
} else if isExternalEntity && a.RemoteName != "" {
10191037
// External entity attribute backed by an OData property - use ODataMappedValue
10201038
defaultValue := ""
@@ -1081,7 +1099,8 @@ func serializeAssociation(a *domainmodel.Association) bson.M {
10811099
"StorageFormat": storageFormat,
10821100
"DeleteBehavior": serializeDeleteBehavior(a.ParentDeleteBehavior, a.ChildDeleteBehavior),
10831101
}
1084-
if a.Source == "Rest$ODataRemoteAssociationSource" {
1102+
switch a.Source {
1103+
case "Rest$ODataRemoteAssociationSource":
10851104
nav := a.Navigability2
10861105
if nav == "" {
10871106
nav = "ParentToChild"
@@ -1097,7 +1116,14 @@ func serializeAssociation(a *domainmodel.Association) bson.M {
10971116
"UpdatableFromChild": a.UpdatableFromChild,
10981117
"UpdatableFromParent": a.UpdatableFromParent,
10991118
}
1100-
} else {
1119+
case "Rest$ODataPrimitiveCollectionAssociationSource":
1120+
// Studio Pro emits this with no extra fields — it's a marker that
1121+
// pairs with Rest$ODataPrimitiveCollectionEntitySource on the child.
1122+
doc["Source"] = bson.M{
1123+
"$ID": idToBsonBinary(generateUUID()),
1124+
"$Type": "Rest$ODataPrimitiveCollectionAssociationSource",
1125+
}
1126+
default:
11011127
doc["Source"] = nil
11021128
}
11031129
return doc

0 commit comments

Comments
 (0)