Skip to content

Commit 1d7620f

Browse files
authored
Merge pull request #105 from engalar/fix/cross-association-v2
fix: remove invalid ParentConnection/ChildConnection from CrossAssociation BSON (#50)
2 parents 942673c + 5119a09 commit 1d7620f

4 files changed

Lines changed: 201 additions & 15 deletions

File tree

.claude/skills/debug-bson.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ This skill provides a systematic workflow for debugging BSON serialization error
55
## When to Use This Skill
66

77
Use when encountering:
8+
- **Studio Pro crash** `System.InvalidOperationException: Sequence contains no matching element` at `MprProperty..ctor`
9+
- **CE1613** "The selected attribute/enumeration no longer exists"
810
- **CE0463** "The definition of this widget has changed"
911
- **CE0642** "Property X is required"
1012
- **CE0091** validation errors on widget properties
@@ -135,6 +137,67 @@ Templates must include both `type` (PropertyTypes schema) AND `object` (default
135137

136138
## Common Error Patterns
137139

140+
### Studio Pro Crash: InvalidOperationException in MprProperty..ctor
141+
142+
**Symptom**: Studio Pro crashes when opening a project with `System.InvalidOperationException: Sequence contains no matching element` at `Mendix.Modeler.Storage.Mpr.MprProperty..ctor`.
143+
144+
**Root cause**: A BSON document contains a property (field name) that does not exist in the Mendix type definition for its `$Type`. Studio Pro's `MprProperty` constructor uses `First()` to look up each BSON field in the type cache, and crashes on unrecognized fields.
145+
146+
**Diagnosis workflow**:
147+
148+
1. **Collect all (type, property) pairs from the crash project** (requires `pip install pymongo`):
149+
```python
150+
import bson, os
151+
from collections import defaultdict
152+
153+
type_props = defaultdict(set)
154+
155+
def walk_bson(obj, tp):
156+
if isinstance(obj, dict):
157+
t = obj.get("$Type", "")
158+
if t:
159+
for k in obj.keys():
160+
if k not in ("$Type", "$ID"):
161+
tp[t].add(k)
162+
for v in obj.values():
163+
walk_bson(v, tp)
164+
elif isinstance(obj, list):
165+
for item in obj:
166+
walk_bson(item, tp)
167+
168+
for root, dirs, files in os.walk("mprcontents"):
169+
for f in files:
170+
if f.endswith(".mxunit"):
171+
with open(os.path.join(root, f), "rb") as fh:
172+
walk_bson(bson.decode(fh.read()), type_props)
173+
```
174+
175+
2. **Compare against a known-good baseline project** (e.g., GenAIDemo):
176+
```python
177+
# Collect baseline_props the same way, then:
178+
for t, props in crash_props.items():
179+
if t in baseline_props:
180+
extra = props - baseline_props[t]
181+
if extra:
182+
print(f"{t}: EXTRA props = {sorted(extra)}")
183+
```
184+
185+
3. **Extra properties = the crash cause**. The fix is to remove those fields from the writer function.
186+
187+
**Example**: `DomainModels$CrossAssociation` had `ParentConnection` and `ChildConnection` copied from `DomainModels$Association`, but these fields don't exist on `CrossAssociation`. Removing them fixed the crash.
188+
189+
**Key principle**: When copying serialization code between similar types (e.g., Association → CrossAssociation), always verify which fields belong to each type by checking a baseline project's BSON.
190+
191+
### CE1613: Selected Attribute/Enumeration No Longer Exists
192+
193+
**Symptom**: `mx check` reports `[CE1613] "The selected attribute 'Module.Entity.AssocName' no longer exists."` or `"The selected enumeration 'Module.Entity' no longer exists."`
194+
195+
**Root cause**: Two variants:
196+
197+
1. **Association stored as Attribute**: In `ChangeActionItem` BSON, an association name was written to the `Attribute` field instead of the `Association` field. Check the executor code that builds `MemberChange` — it must query the domain model to distinguish associations from attributes.
198+
199+
2. **Entity treated as Enumeration**: In `CreateVariableAction` BSON, an entity qualified name was used as `DataTypes$EnumerationType` instead of `DataTypes$ObjectType`. Check `buildDataType()` in the visitor — bare qualified names default to `TypeEnumeration` and need catalog-based disambiguation.
200+
138201
### CE0463: Widget Definition Changed
139202

140203
**Root cause**: Object property values inconsistent with mode-dependent visibility rules.

mdl/executor/bugfix_regression_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,3 +390,36 @@ func TestDeriveColumnName_CaptionSpecialChars(t *testing.T) {
390390
t.Errorf("deriveColumnName(special chars) = %q, want %q", got, "Order__ID__main")
391391
}
392392
}
393+
394+
// =============================================================================
395+
// Issue #50: Association misidentified as Attribute (fallback without reader)
396+
// =============================================================================
397+
398+
// TestResolveMemberChange_FallbackWithoutReader verifies that resolveMemberChange
399+
// falls back to dot-contains heuristic when no reader is available.
400+
// Regression: https://github.com/mendixlabs/mxcli/issues/50
401+
func TestResolveMemberChange_FallbackWithoutReader(t *testing.T) {
402+
fb := &flowBuilder{
403+
// reader is nil — simulates no project context
404+
}
405+
406+
// Without reader: a name without dot should default to attribute
407+
mc := &microflows.MemberChange{}
408+
fb.resolveMemberChange(mc, "Label", "Demo.Child")
409+
if mc.AttributeQualifiedName != "Demo.Child.Label" {
410+
t.Errorf("expected attribute 'Demo.Child.Label', got %q", mc.AttributeQualifiedName)
411+
}
412+
if mc.AssociationQualifiedName != "" {
413+
t.Errorf("expected empty association, got %q", mc.AssociationQualifiedName)
414+
}
415+
416+
// With a dot in the name: should be treated as fully-qualified association (fallback)
417+
mc2 := &microflows.MemberChange{}
418+
fb.resolveMemberChange(mc2, "Demo.Child_Parent", "Demo.Child")
419+
if mc2.AssociationQualifiedName != "Demo.Child_Parent" {
420+
t.Errorf("expected association 'Demo.Child_Parent', got %q", mc2.AssociationQualifiedName)
421+
}
422+
if mc2.AttributeQualifiedName != "" {
423+
t.Errorf("expected empty attribute, got %q", mc2.AttributeQualifiedName)
424+
}
425+
}

sdk/mpr/writer_domainmodel.go

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -921,22 +921,23 @@ func serializeCrossAssociation(ca *domainmodel.CrossModuleAssociation) bson.M {
921921
if storageFormat == "" {
922922
storageFormat = "Column"
923923
}
924+
// CrossAssociation does NOT have ParentConnection/ChildConnection properties
925+
// (unlike Association). Writing them causes Studio Pro to crash with
926+
// InvalidOperationException in MprProperty..ctor.
924927
return bson.M{
925-
"$ID": idToBsonBinary(string(ca.ID)),
926-
"$Type": "DomainModels$CrossAssociation",
927-
"Name": ca.Name,
928-
"Documentation": ca.Documentation,
929-
"ExportLevel": "Hidden",
930-
"GUID": idToBsonBinary(string(ca.ID)),
931-
"ParentPointer": idToBsonBinary(string(ca.ParentID)),
932-
"Child": ca.ChildRef,
933-
"Type": string(ca.Type),
934-
"Owner": string(ca.Owner),
935-
"ParentConnection": "0;50",
936-
"ChildConnection": "100;50",
937-
"StorageFormat": storageFormat,
938-
"Source": nil,
939-
"DeleteBehavior": serializeDeleteBehavior(ca.ParentDeleteBehavior, ca.ChildDeleteBehavior),
928+
"$ID": idToBsonBinary(string(ca.ID)),
929+
"$Type": "DomainModels$CrossAssociation",
930+
"Name": ca.Name,
931+
"Documentation": ca.Documentation,
932+
"ExportLevel": "Hidden",
933+
"GUID": idToBsonBinary(string(ca.ID)),
934+
"ParentPointer": idToBsonBinary(string(ca.ParentID)),
935+
"Child": ca.ChildRef,
936+
"Type": string(ca.Type),
937+
"Owner": string(ca.Owner),
938+
"StorageFormat": storageFormat,
939+
"Source": nil,
940+
"DeleteBehavior": serializeDeleteBehavior(ca.ParentDeleteBehavior, ca.ChildDeleteBehavior),
940941
}
941942
}
942943

sdk/mpr/writer_domainmodel_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package mpr
4+
5+
import (
6+
"testing"
7+
8+
"github.com/mendixlabs/mxcli/sdk/domainmodel"
9+
)
10+
11+
// =============================================================================
12+
// Issue #50: CrossAssociation must NOT include ParentConnection/ChildConnection
13+
// =============================================================================
14+
15+
// TestSerializeCrossAssociation_NoConnectionFields verifies that
16+
// serializeCrossAssociation does NOT emit ParentConnection or ChildConnection.
17+
// These properties only exist on DomainModels$Association, not on
18+
// DomainModels$CrossAssociation. Writing them causes Studio Pro to crash with
19+
// System.InvalidOperationException: Sequence contains no matching element.
20+
func TestSerializeCrossAssociation_NoConnectionFields(t *testing.T) {
21+
ca := &domainmodel.CrossModuleAssociation{
22+
Name: "Child_Parent",
23+
ParentID: "parent-entity-id",
24+
ChildRef: "OtherModule.Parent",
25+
Type: domainmodel.AssociationTypeReference,
26+
Owner: domainmodel.AssociationOwnerDefault,
27+
}
28+
ca.ID = "test-cross-assoc-id"
29+
30+
result := serializeCrossAssociation(ca)
31+
32+
// Must NOT contain these fields
33+
for key := range result {
34+
if key == "ParentConnection" {
35+
t.Error("serializeCrossAssociation must NOT include ParentConnection (only valid for Association)")
36+
}
37+
if key == "ChildConnection" {
38+
t.Error("serializeCrossAssociation must NOT include ChildConnection (only valid for Association)")
39+
}
40+
}
41+
42+
// Must contain all expected fields (exhaustive structural contract)
43+
expectedKeys := []string{"$ID", "$Type", "Name", "Child", "ParentPointer", "Type", "Owner",
44+
"Documentation", "ExportLevel", "GUID", "StorageFormat", "Source", "DeleteBehavior"}
45+
for _, key := range expectedKeys {
46+
if _, ok := result[key]; !ok {
47+
t.Errorf("serializeCrossAssociation missing expected field %q", key)
48+
}
49+
}
50+
51+
// $Type must be CrossAssociation
52+
if got := result["$Type"]; got != "DomainModels$CrossAssociation" {
53+
t.Errorf("$Type = %q, want %q", got, "DomainModels$CrossAssociation")
54+
}
55+
}
56+
57+
// TestSerializeAssociation_HasConnectionFields verifies that the regular
58+
// serializeAssociation DOES include ParentConnection and ChildConnection
59+
// (to ensure we didn't accidentally remove them from the wrong function).
60+
func TestSerializeAssociation_HasConnectionFields(t *testing.T) {
61+
a := &domainmodel.Association{
62+
Name: "Child_Parent",
63+
ParentID: "parent-entity-id",
64+
ChildID: "child-entity-id",
65+
Type: domainmodel.AssociationTypeReference,
66+
Owner: domainmodel.AssociationOwnerDefault,
67+
}
68+
a.ID = "test-assoc-id"
69+
70+
result := serializeAssociation(a)
71+
72+
hasParentConn := false
73+
hasChildConn := false
74+
for key := range result {
75+
if key == "ParentConnection" {
76+
hasParentConn = true
77+
}
78+
if key == "ChildConnection" {
79+
hasChildConn = true
80+
}
81+
}
82+
83+
if !hasParentConn {
84+
t.Error("serializeAssociation must include ParentConnection")
85+
}
86+
if !hasChildConn {
87+
t.Error("serializeAssociation must include ChildConnection")
88+
}
89+
}

0 commit comments

Comments
 (0)