diff --git a/.claude/skills/debug-bson.md b/.claude/skills/debug-bson.md index f0d5cd3..37ecd32 100644 --- a/.claude/skills/debug-bson.md +++ b/.claude/skills/debug-bson.md @@ -5,6 +5,8 @@ This skill provides a systematic workflow for debugging BSON serialization error ## When to Use This Skill Use when encountering: +- **Studio Pro crash** `System.InvalidOperationException: Sequence contains no matching element` at `MprProperty..ctor` +- **CE1613** "The selected attribute/enumeration no longer exists" - **CE0463** "The definition of this widget has changed" - **CE0642** "Property X is required" - **CE0091** validation errors on widget properties @@ -135,6 +137,67 @@ Templates must include both `type` (PropertyTypes schema) AND `object` (default ## Common Error Patterns +### Studio Pro Crash: InvalidOperationException in MprProperty..ctor + +**Symptom**: Studio Pro crashes when opening a project with `System.InvalidOperationException: Sequence contains no matching element` at `Mendix.Modeler.Storage.Mpr.MprProperty..ctor`. + +**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. + +**Diagnosis workflow**: + +1. **Collect all (type, property) pairs from the crash project** (requires `pip install pymongo`): +```python +import bson, os +from collections import defaultdict + +type_props = defaultdict(set) + +def walk_bson(obj, tp): + if isinstance(obj, dict): + t = obj.get("$Type", "") + if t: + for k in obj.keys(): + if k not in ("$Type", "$ID"): + tp[t].add(k) + for v in obj.values(): + walk_bson(v, tp) + elif isinstance(obj, list): + for item in obj: + walk_bson(item, tp) + +for root, dirs, files in os.walk("mprcontents"): + for f in files: + if f.endswith(".mxunit"): + with open(os.path.join(root, f), "rb") as fh: + walk_bson(bson.decode(fh.read()), type_props) +``` + +2. **Compare against a known-good baseline project** (e.g., GenAIDemo): +```python +# Collect baseline_props the same way, then: +for t, props in crash_props.items(): + if t in baseline_props: + extra = props - baseline_props[t] + if extra: + print(f"{t}: EXTRA props = {sorted(extra)}") +``` + +3. **Extra properties = the crash cause**. The fix is to remove those fields from the writer function. + +**Example**: `DomainModels$CrossAssociation` had `ParentConnection` and `ChildConnection` copied from `DomainModels$Association`, but these fields don't exist on `CrossAssociation`. Removing them fixed the crash. + +**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. + +### CE1613: Selected Attribute/Enumeration No Longer Exists + +**Symptom**: `mx check` reports `[CE1613] "The selected attribute 'Module.Entity.AssocName' no longer exists."` or `"The selected enumeration 'Module.Entity' no longer exists."` + +**Root cause**: Two variants: + +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. + +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. + ### CE0463: Widget Definition Changed **Root cause**: Object property values inconsistent with mode-dependent visibility rules. diff --git a/mdl/executor/bugfix_regression_test.go b/mdl/executor/bugfix_regression_test.go index 4fe691d..fd13a26 100644 --- a/mdl/executor/bugfix_regression_test.go +++ b/mdl/executor/bugfix_regression_test.go @@ -390,3 +390,36 @@ func TestDeriveColumnName_CaptionSpecialChars(t *testing.T) { t.Errorf("deriveColumnName(special chars) = %q, want %q", got, "Order__ID__main") } } + +// ============================================================================= +// Issue #50: Association misidentified as Attribute (fallback without reader) +// ============================================================================= + +// TestResolveMemberChange_FallbackWithoutReader verifies that resolveMemberChange +// falls back to dot-contains heuristic when no reader is available. +// Regression: https://github.com/mendixlabs/mxcli/issues/50 +func TestResolveMemberChange_FallbackWithoutReader(t *testing.T) { + fb := &flowBuilder{ + // reader is nil — simulates no project context + } + + // Without reader: a name without dot should default to attribute + mc := µflows.MemberChange{} + fb.resolveMemberChange(mc, "Label", "Demo.Child") + if mc.AttributeQualifiedName != "Demo.Child.Label" { + t.Errorf("expected attribute 'Demo.Child.Label', got %q", mc.AttributeQualifiedName) + } + if mc.AssociationQualifiedName != "" { + t.Errorf("expected empty association, got %q", mc.AssociationQualifiedName) + } + + // With a dot in the name: should be treated as fully-qualified association (fallback) + mc2 := µflows.MemberChange{} + fb.resolveMemberChange(mc2, "Demo.Child_Parent", "Demo.Child") + if mc2.AssociationQualifiedName != "Demo.Child_Parent" { + t.Errorf("expected association 'Demo.Child_Parent', got %q", mc2.AssociationQualifiedName) + } + if mc2.AttributeQualifiedName != "" { + t.Errorf("expected empty attribute, got %q", mc2.AttributeQualifiedName) + } +} diff --git a/sdk/mpr/writer_domainmodel.go b/sdk/mpr/writer_domainmodel.go index 672082c..109bcce 100644 --- a/sdk/mpr/writer_domainmodel.go +++ b/sdk/mpr/writer_domainmodel.go @@ -921,22 +921,23 @@ func serializeCrossAssociation(ca *domainmodel.CrossModuleAssociation) bson.M { if storageFormat == "" { storageFormat = "Column" } + // CrossAssociation does NOT have ParentConnection/ChildConnection properties + // (unlike Association). Writing them causes Studio Pro to crash with + // InvalidOperationException in MprProperty..ctor. return bson.M{ - "$ID": idToBsonBinary(string(ca.ID)), - "$Type": "DomainModels$CrossAssociation", - "Name": ca.Name, - "Documentation": ca.Documentation, - "ExportLevel": "Hidden", - "GUID": idToBsonBinary(string(ca.ID)), - "ParentPointer": idToBsonBinary(string(ca.ParentID)), - "Child": ca.ChildRef, - "Type": string(ca.Type), - "Owner": string(ca.Owner), - "ParentConnection": "0;50", - "ChildConnection": "100;50", - "StorageFormat": storageFormat, - "Source": nil, - "DeleteBehavior": serializeDeleteBehavior(ca.ParentDeleteBehavior, ca.ChildDeleteBehavior), + "$ID": idToBsonBinary(string(ca.ID)), + "$Type": "DomainModels$CrossAssociation", + "Name": ca.Name, + "Documentation": ca.Documentation, + "ExportLevel": "Hidden", + "GUID": idToBsonBinary(string(ca.ID)), + "ParentPointer": idToBsonBinary(string(ca.ParentID)), + "Child": ca.ChildRef, + "Type": string(ca.Type), + "Owner": string(ca.Owner), + "StorageFormat": storageFormat, + "Source": nil, + "DeleteBehavior": serializeDeleteBehavior(ca.ParentDeleteBehavior, ca.ChildDeleteBehavior), } } diff --git a/sdk/mpr/writer_domainmodel_test.go b/sdk/mpr/writer_domainmodel_test.go new file mode 100644 index 0000000..4d9d417 --- /dev/null +++ b/sdk/mpr/writer_domainmodel_test.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// ============================================================================= +// Issue #50: CrossAssociation must NOT include ParentConnection/ChildConnection +// ============================================================================= + +// TestSerializeCrossAssociation_NoConnectionFields verifies that +// serializeCrossAssociation does NOT emit ParentConnection or ChildConnection. +// These properties only exist on DomainModels$Association, not on +// DomainModels$CrossAssociation. Writing them causes Studio Pro to crash with +// System.InvalidOperationException: Sequence contains no matching element. +func TestSerializeCrossAssociation_NoConnectionFields(t *testing.T) { + ca := &domainmodel.CrossModuleAssociation{ + Name: "Child_Parent", + ParentID: "parent-entity-id", + ChildRef: "OtherModule.Parent", + Type: domainmodel.AssociationTypeReference, + Owner: domainmodel.AssociationOwnerDefault, + } + ca.ID = "test-cross-assoc-id" + + result := serializeCrossAssociation(ca) + + // Must NOT contain these fields + for key := range result { + if key == "ParentConnection" { + t.Error("serializeCrossAssociation must NOT include ParentConnection (only valid for Association)") + } + if key == "ChildConnection" { + t.Error("serializeCrossAssociation must NOT include ChildConnection (only valid for Association)") + } + } + + // Must contain all expected fields (exhaustive structural contract) + expectedKeys := []string{"$ID", "$Type", "Name", "Child", "ParentPointer", "Type", "Owner", + "Documentation", "ExportLevel", "GUID", "StorageFormat", "Source", "DeleteBehavior"} + for _, key := range expectedKeys { + if _, ok := result[key]; !ok { + t.Errorf("serializeCrossAssociation missing expected field %q", key) + } + } + + // $Type must be CrossAssociation + if got := result["$Type"]; got != "DomainModels$CrossAssociation" { + t.Errorf("$Type = %q, want %q", got, "DomainModels$CrossAssociation") + } +} + +// TestSerializeAssociation_HasConnectionFields verifies that the regular +// serializeAssociation DOES include ParentConnection and ChildConnection +// (to ensure we didn't accidentally remove them from the wrong function). +func TestSerializeAssociation_HasConnectionFields(t *testing.T) { + a := &domainmodel.Association{ + Name: "Child_Parent", + ParentID: "parent-entity-id", + ChildID: "child-entity-id", + Type: domainmodel.AssociationTypeReference, + Owner: domainmodel.AssociationOwnerDefault, + } + a.ID = "test-assoc-id" + + result := serializeAssociation(a) + + hasParentConn := false + hasChildConn := false + for key := range result { + if key == "ParentConnection" { + hasParentConn = true + } + if key == "ChildConnection" { + hasChildConn = true + } + } + + if !hasParentConn { + t.Error("serializeAssociation must include ParentConnection") + } + if !hasChildConn { + t.Error("serializeAssociation must include ChildConnection") + } +}