Skip to content

Commit 5da682d

Browse files
akoclaude
andcommitted
Fix MPR corruption from dangling GUIDs after attribute drop/add (#4)
The BSON parser was discarding $ID fields from StoredValue, CalculatedValue, OqlViewValue, and AttributeType objects. The writer then always generated new UUIDs, leaving orphaned GUID references that caused KeyNotFoundException in Studio Pro and MxBuild. Preserve existing $ID through parse→serialize roundtrips; only generate new UUIDs for newly created attributes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 68e1662 commit 5da682d

4 files changed

Lines changed: 195 additions & 15 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Issue #4: MPR Data Corruption After Attribute Drop/Add with Enumeration Default
2+
3+
**Source**: https://github.com/mendixlabs/mxcli/issues/4
4+
**Severity**: Critical (data loss)
5+
**Date**: 2026-03-18
6+
7+
## Symptoms
8+
9+
After dropping and re-adding an entity attribute with an enumeration default value, the MPR becomes corrupted. MxBuild/Studio Pro fails with:
10+
11+
```
12+
System.Collections.Generic.KeyNotFoundException: The given key '3622ee3a-8d34-4495-9788-6e6462f0ab3c' was not present in the dictionary
13+
```
14+
15+
## Reproduction
16+
17+
```mdl
18+
ALTER ENTITY MaisonElegance.FormSubmission DROP ATTRIBUTE SubmissionStatus;
19+
ALTER ENTITY MaisonElegance.FormSubmission ADD ATTRIBUTE SubmissionStatus:
20+
Enumeration(MaisonElegance.FormSubmissionStatus) DEFAULT
21+
MaisonElegance.FormSubmissionStatus.StatusNew;
22+
```
23+
24+
## Root Cause
25+
26+
A two-part synchronization failure between the BSON parser and writer for the `$ID` field inside `StoredValue` (and `CalculatedValue`, `OqlViewValue`) objects.
27+
28+
### Part 1: Parser drops the `$ID`
29+
30+
**File**: `sdk/mpr/parser_domainmodel.go`, function `parseAttributeValue()` (lines 224-249)
31+
32+
The parser extracts `$Type` and `DefaultValue` from BSON but never reads `$ID`. The `AttributeValue` struct embeds `BaseElement` (which has an `ID` field via `model.BaseElement`), but the parser never populates it:
33+
34+
```go
35+
case "DomainModels$StoredValue":
36+
return &domainmodel.AttributeValue{
37+
Type: "StoredValue",
38+
DefaultValue: defaultValue,
39+
// $ID is never extracted from raw — lost here
40+
}
41+
```
42+
43+
### Part 2: Writer always generates a new GUID
44+
45+
**File**: `sdk/mpr/writer_domainmodel.go`, function `serializeAttribute()` (lines 823-827)
46+
47+
The writer always calls `generateUUID()` for the StoredValue's `$ID`, never checking if an existing ID should be preserved:
48+
49+
```go
50+
valueDoc = bson.D{
51+
{Key: "$ID", Value: idToBsonBinary(generateUUID())}, // always new
52+
{Key: "$Type", Value: "DomainModels$StoredValue"},
53+
{Key: "DefaultValue", Value: defaultValue},
54+
}
55+
```
56+
57+
### The Corruption Flow
58+
59+
1. **Read MPR**: StoredValue has `$ID: "3622ee3a-..."` in BSON
60+
2. **Parser drops `$ID`**: `AttributeValue.ID` is empty string
61+
3. **DROP ATTRIBUTE**: Removes the attribute from the entity's attribute list, but the old GUID remains referenced elsewhere in the MPR (GUID registry, cross-references)
62+
4. **ADD ATTRIBUTE**: Writer generates a brand new UUID for the StoredValue
63+
5. **Old GUID becomes orphaned**: Studio Pro resolves all GUID references, finds `"3622ee3a-..."` pointing to nothing → `KeyNotFoundException`
64+
65+
## Impact Scope
66+
67+
- Affects **all** attribute value types, not just enumerations (StoredValue, CalculatedValue, OqlViewValue)
68+
- Any DROP + ADD attribute cycle on an attribute with a default value will produce orphaned GUIDs
69+
- Multiple drop/add cycles compound the problem with more orphaned GUIDs
70+
- Corrupted MPR cannot be opened in Studio Pro or built with MxBuild
71+
- No workaround exists beyond reverting to backups
72+
73+
## Fix Required
74+
75+
1. **`sdk/mpr/parser_domainmodel.go`** — Extract `$ID` from the BSON `raw` map and set it on `AttributeValue.BaseElement.ID` for all value types (StoredValue, CalculatedValue, OqlViewValue)
76+
2. **`sdk/mpr/writer_domainmodel.go`** — If `a.Value.ID` is non-empty, preserve it instead of generating a new UUID. Only generate a new UUID for truly new attributes (where `ID` is empty).

mdl/executor/roundtrip_mxcheck_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,68 @@ func TestMxCheck_CE0066_Scenarios(t *testing.T) {
458458
}
459459
}
460460

461+
// TestMxCheck_DropAddEnumAttribute validates that dropping and re-adding an attribute
462+
// with an enumeration default value does not corrupt the MPR (GitHub issue #4).
463+
// Before the fix, the StoredValue $ID was lost during parsing and a new GUID was
464+
// generated on write, leaving dangling references that caused KeyNotFoundException.
465+
func TestMxCheck_DropAddEnumAttribute(t *testing.T) {
466+
if !mxCheckAvailable() {
467+
t.Skip("mx command not available")
468+
}
469+
470+
env := setupTestEnv(t)
471+
defer env.teardown()
472+
473+
mod := testModule
474+
475+
// Step 1: Create an enumeration and an entity with an enumeration attribute
476+
setupMDL := strings.Join([]string{
477+
`CREATE ENUMERATION ` + mod + `.SubmissionStatus (StatusNew 'New', StatusInProgress 'In Progress', StatusDone 'Done');`,
478+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.Issue4Entity (
479+
Name: String(100),
480+
Status: Enumeration(` + mod + `.SubmissionStatus) DEFAULT ` + mod + `.SubmissionStatus.StatusNew
481+
);`,
482+
}, "\n")
483+
484+
prog, errs := visitor.Build(setupMDL)
485+
if len(errs) > 0 {
486+
t.Fatalf("Parse failed: %v\nMDL:\n%s", errs[0], setupMDL)
487+
}
488+
if err := env.executor.ExecuteProgram(prog); err != nil {
489+
t.Fatalf("Setup failed: %v", err)
490+
}
491+
492+
// Step 2: Drop the attribute and re-add it with a different default
493+
alterMDL := strings.Join([]string{
494+
`ALTER ENTITY ` + mod + `.Issue4Entity DROP ATTRIBUTE Status;`,
495+
`ALTER ENTITY ` + mod + `.Issue4Entity ADD ATTRIBUTE Status: Enumeration(` + mod + `.SubmissionStatus) DEFAULT ` + mod + `.SubmissionStatus.StatusInProgress;`,
496+
}, "\n")
497+
498+
prog, errs = visitor.Build(alterMDL)
499+
if len(errs) > 0 {
500+
t.Fatalf("Parse failed: %v\nMDL:\n%s", errs[0], alterMDL)
501+
}
502+
if err := env.executor.ExecuteProgram(prog); err != nil {
503+
t.Fatalf("Alter failed: %v", err)
504+
}
505+
506+
// Step 3: Flush to disk and validate with mx check
507+
env.executor.Execute(&ast.DisconnectStmt{})
508+
509+
output, err := runMxCheck(t, env.projectPath)
510+
if err != nil {
511+
if strings.Contains(output, "KeyNotFoundException") || strings.Contains(output, "not present in the dictionary") {
512+
t.Errorf("Issue #4 regression: dangling GUID reference after drop/add enum attribute:\n%s", output)
513+
} else if strings.Contains(output, "error") || strings.Contains(output, "Error") {
514+
t.Errorf("mx check found errors:\n%s", output)
515+
} else {
516+
t.Logf("mx check output (non-zero exit but no errors):\n%s", output)
517+
}
518+
} else {
519+
t.Logf("mx check passed")
520+
}
521+
}
522+
461523
// TestMxCheck_RetrieveWithDateTimeToken validates that RETRIEVE with [%CurrentDateTime%]
462524
// in a WHERE clause produces correctly quoted XPath (GitHub issue #1).
463525
// The token must be quoted as '[%CurrentDateTime%]' in XPath constraints.

sdk/mpr/parser_domainmodel.go

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -224,56 +224,77 @@ func parseAttribute(raw map[string]any) *domainmodel.Attribute {
224224
func parseAttributeValue(raw map[string]any) *domainmodel.AttributeValue {
225225
typeName := extractString(raw["$Type"])
226226
defaultValue := extractString(raw["DefaultValue"])
227+
valueID := model.ID(extractBsonID(raw["$ID"]))
227228

228229
switch typeName {
229230
case "DomainModels$StoredValue":
230-
return &domainmodel.AttributeValue{
231+
val := &domainmodel.AttributeValue{
231232
Type: "StoredValue",
232233
DefaultValue: defaultValue,
233234
}
235+
val.ID = valueID
236+
return val
234237
case "DomainModels$CalculatedValue":
235-
return &domainmodel.AttributeValue{
238+
val := &domainmodel.AttributeValue{
236239
Type: "CalculatedValue",
237240
MicroflowID: model.ID(extractBsonID(raw["Microflow"])),
238241
}
242+
val.ID = valueID
243+
return val
239244
case "DomainModels$OqlViewValue":
240-
return &domainmodel.AttributeValue{
245+
val := &domainmodel.AttributeValue{
241246
Type: "OqlViewValue",
242247
ViewReference: extractString(raw["Reference"]),
243248
}
249+
val.ID = valueID
250+
return val
244251
default:
245-
return &domainmodel.AttributeValue{
252+
val := &domainmodel.AttributeValue{
246253
DefaultValue: defaultValue,
247254
}
255+
val.ID = valueID
256+
return val
248257
}
249258
}
250259

251260
func parseAttributeType(raw map[string]any) domainmodel.AttributeType {
252261
typeName, _ := raw["$Type"].(string)
262+
typeID := model.ID(extractBsonID(raw["$ID"]))
253263

254264
switch typeName {
255265
case "DomainModels$StringAttributeType":
256266
t := &domainmodel.StringAttributeType{}
267+
t.ID = typeID
257268
if length, ok := raw["Length"].(int32); ok {
258269
t.Length = int(length)
259270
}
260271
return t
261272
case "DomainModels$IntegerAttributeType":
262-
return &domainmodel.IntegerAttributeType{}
273+
t := &domainmodel.IntegerAttributeType{}
274+
t.ID = typeID
275+
return t
263276
case "DomainModels$LongAttributeType":
264-
return &domainmodel.LongAttributeType{}
277+
t := &domainmodel.LongAttributeType{}
278+
t.ID = typeID
279+
return t
265280
case "DomainModels$DecimalAttributeType":
266-
return &domainmodel.DecimalAttributeType{}
281+
t := &domainmodel.DecimalAttributeType{}
282+
t.ID = typeID
283+
return t
267284
case "DomainModels$BooleanAttributeType":
268-
return &domainmodel.BooleanAttributeType{}
285+
t := &domainmodel.BooleanAttributeType{}
286+
t.ID = typeID
287+
return t
269288
case "DomainModels$DateTimeAttributeType":
270289
t := &domainmodel.DateTimeAttributeType{}
290+
t.ID = typeID
271291
if localize, ok := raw["LocalizeDate"].(bool); ok {
272292
t.LocalizeDate = localize
273293
}
274294
return t
275295
case "DomainModels$EnumerationAttributeType":
276296
t := &domainmodel.EnumerationAttributeType{}
297+
t.ID = typeID
277298
// Enumeration is stored as qualified name string (BY_NAME_REFERENCE)
278299
if enumRef, ok := raw["Enumeration"].(string); ok {
279300
t.EnumerationRef = enumRef
@@ -282,13 +303,21 @@ func parseAttributeType(raw map[string]any) domainmodel.AttributeType {
282303
}
283304
return t
284305
case "DomainModels$AutoNumberAttributeType":
285-
return &domainmodel.AutoNumberAttributeType{}
306+
t := &domainmodel.AutoNumberAttributeType{}
307+
t.ID = typeID
308+
return t
286309
case "DomainModels$BinaryAttributeType":
287-
return &domainmodel.BinaryAttributeType{}
310+
t := &domainmodel.BinaryAttributeType{}
311+
t.ID = typeID
312+
return t
288313
case "DomainModels$HashedStringAttributeType":
289-
return &domainmodel.HashedStringAttributeType{}
314+
t := &domainmodel.HashedStringAttributeType{}
315+
t.ID = typeID
316+
return t
290317
default:
291-
return &domainmodel.StringAttributeType{} // Default fallback
318+
t := &domainmodel.StringAttributeType{} // Default fallback
319+
t.ID = typeID
320+
return t
292321
}
293322
}
294323

sdk/mpr/writer_domainmodel.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -785,8 +785,14 @@ func serializeAttribute(a *domainmodel.Attribute) bson.D {
785785
typeName = "DomainModels$" + a.Type.GetTypeName() + "AttributeType"
786786
}
787787

788+
attrTypeID := generateUUID()
789+
if a.Type != nil {
790+
if elem, ok := a.Type.(model.Element); ok && elem.GetID() != "" {
791+
attrTypeID = string(elem.GetID())
792+
}
793+
}
788794
attrType := bson.D{
789-
{Key: "$ID", Value: idToBsonBinary(generateUUID())},
795+
{Key: "$ID", Value: idToBsonBinary(attrTypeID)},
790796
{Key: "$Type", Value: typeName},
791797
}
792798
// Add type-specific properties
@@ -807,10 +813,17 @@ func serializeAttribute(a *domainmodel.Attribute) bson.D {
807813

808814
// Determine value type: OqlViewValue for view entities, StoredValue for regular entities
809815
var valueDoc bson.D
816+
valueID := ""
817+
if a.Value != nil && a.Value.ID != "" {
818+
valueID = string(a.Value.ID)
819+
}
820+
if valueID == "" {
821+
valueID = generateUUID()
822+
}
810823
if a.Value != nil && a.Value.ViewReference != "" {
811824
// View entity attribute - use OqlViewValue
812825
valueDoc = bson.D{
813-
{Key: "$ID", Value: idToBsonBinary(generateUUID())},
826+
{Key: "$ID", Value: idToBsonBinary(valueID)},
814827
{Key: "$Type", Value: "DomainModels$OqlViewValue"},
815828
{Key: "Reference", Value: a.Value.ViewReference},
816829
}
@@ -821,7 +834,7 @@ func serializeAttribute(a *domainmodel.Attribute) bson.D {
821834
defaultValue = a.Value.DefaultValue
822835
}
823836
valueDoc = bson.D{
824-
{Key: "$ID", Value: idToBsonBinary(generateUUID())},
837+
{Key: "$ID", Value: idToBsonBinary(valueID)},
825838
{Key: "$Type", Value: "DomainModels$StoredValue"},
826839
{Key: "DefaultValue", Value: defaultValue},
827840
}

0 commit comments

Comments
 (0)