Skip to content

Commit 6eb9969

Browse files
authored
Merge pull request #243 from retran/fix/dset-silent-failure
fix: handle missing TaskPage key in workflow activity mutation
2 parents 4da5973 + 9899bda commit 6eb9969

7 files changed

Lines changed: 130 additions & 26 deletions

File tree

mdl/backend/mpr/backend.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,11 @@ func (b *MprBackend) Path() string { return b.path }
8585
// for new code.
8686
func (b *MprBackend) MprReader() *mpr.Reader { return b.reader }
8787

88-
func (b *MprBackend) Version() types.MPRVersion { return convertMPRVersion(b.reader.Version()) }
89-
func (b *MprBackend) ProjectVersion() *types.ProjectVersion { return convertProjectVersion(b.reader.ProjectVersion()) }
90-
func (b *MprBackend) GetMendixVersion() (string, error) { return b.reader.GetMendixVersion() }
88+
func (b *MprBackend) Version() types.MPRVersion { return convertMPRVersion(b.reader.Version()) }
89+
func (b *MprBackend) ProjectVersion() *types.ProjectVersion {
90+
return convertProjectVersion(b.reader.ProjectVersion())
91+
}
92+
func (b *MprBackend) GetMendixVersion() (string, error) { return b.reader.GetMendixVersion() }
9193

9294
// Commit is a no-op — the MPR writer auto-commits on each write operation.
9395
func (b *MprBackend) Commit() error { return nil }
@@ -112,7 +114,9 @@ func (b *MprBackend) DeleteModuleWithCleanup(id model.ID, moduleName string) err
112114
// FolderBackend
113115
// ---------------------------------------------------------------------------
114116

115-
func (b *MprBackend) ListFolders() ([]*types.FolderInfo, error) { return convertFolderInfoSlice(b.reader.ListFolders()) }
117+
func (b *MprBackend) ListFolders() ([]*types.FolderInfo, error) {
118+
return convertFolderInfoSlice(b.reader.ListFolders())
119+
}
116120
func (b *MprBackend) CreateFolder(folder *model.Folder) error { return b.writer.CreateFolder(folder) }
117121
func (b *MprBackend) DeleteFolder(id model.ID) error { return b.writer.DeleteFolder(id) }
118122
func (b *MprBackend) MoveFolder(id model.ID, newContainerID model.ID) error {
@@ -678,8 +682,10 @@ func (b *MprBackend) UpdateRawUnit(unitID string, contents []byte) error {
678682
// MetadataBackend
679683
// ---------------------------------------------------------------------------
680684

681-
func (b *MprBackend) ListAllUnitIDs() ([]string, error) { return b.reader.ListAllUnitIDs() }
682-
func (b *MprBackend) ListUnits() ([]*types.UnitInfo, error) { return convertUnitInfoSlice(b.reader.ListUnits()) }
685+
func (b *MprBackend) ListAllUnitIDs() ([]string, error) { return b.reader.ListAllUnitIDs() }
686+
func (b *MprBackend) ListUnits() ([]*types.UnitInfo, error) {
687+
return convertUnitInfoSlice(b.reader.ListUnits())
688+
}
683689
func (b *MprBackend) GetUnitTypes() (map[string]int, error) { return b.reader.GetUnitTypes() }
684690
func (b *MprBackend) GetProjectRootID() (string, error) { return b.reader.GetProjectRootID() }
685691
func (b *MprBackend) ContentsDir() string { return b.reader.ContentsDir() }

mdl/backend/mpr/convert_roundtrip_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,4 +648,3 @@ func TestFieldCountDrift(t *testing.T) {
648648
assertFieldCount(t, "mpr.EntityAccessRevocation", mpr.EntityAccessRevocation{}, 6)
649649
assertFieldCount(t, "types.EntityAccessRevocation", types.EntityAccessRevocation{}, 6)
650650
}
651-

mdl/backend/mpr/workflow_mutator.go

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,19 @@ func (m *mprWorkflowMutator) SetActivityProperty(activityRef string, atPos int,
162162
case "page":
163163
taskPage := dGetDoc(actDoc, "TaskPage")
164164
if taskPage != nil {
165+
// TaskPage exists and has a value — update the Page field in place.
165166
dSet(taskPage, "Page", value)
166-
} else {
167-
pageRef := bson.D{
168-
{Key: "$ID", Value: bsonutil.NewIDBsonBinary()},
169-
{Key: "$Type", Value: "Workflows$PageReference"},
170-
{Key: "Page", Value: value},
171-
}
172-
dSet(actDoc, "TaskPage", pageRef)
167+
return nil
168+
}
169+
pageRef := bson.D{
170+
{Key: "$ID", Value: bsonutil.NewIDBsonBinary()},
171+
{Key: "$Type", Value: "Workflows$PageReference"},
172+
{Key: "Page", Value: value},
173+
}
174+
if !dSet(actDoc, "TaskPage", pageRef) {
175+
// TaskPage key absent — append to activity and replace in BSON tree.
176+
actDoc = append(actDoc, bson.E{Key: "TaskPage", Value: pageRef})
177+
m.replaceActivity(actDoc)
173178
}
174179
return nil
175180

@@ -555,6 +560,42 @@ func (m *mprWorkflowMutator) Save() error {
555560
// Internal helpers — activity search
556561
// ---------------------------------------------------------------------------
557562

563+
// replaceActivity replaces an activity document in the workflow's BSON tree
564+
// by matching on $ID. This is needed when appending new keys to an activity
565+
// document, because the slice header returned by findActivityByCaption cannot
566+
// propagate appends back to the parent bson.A.
567+
func (m *mprWorkflowMutator) replaceActivity(updated bson.D) {
568+
actID := extractBinaryIDFromDoc(dGet(updated, "$ID"))
569+
if actID == "" {
570+
return
571+
}
572+
flow := dGetDoc(m.rawData, "Flow")
573+
if flow == nil {
574+
return
575+
}
576+
replaceActivityRecursive(flow, actID, updated)
577+
}
578+
579+
func replaceActivityRecursive(flow bson.D, actID string, updated bson.D) bool {
580+
elements := dGetArrayElements(dGet(flow, "Activities"))
581+
for i, elem := range elements {
582+
actDoc, ok := elem.(bson.D)
583+
if !ok {
584+
continue
585+
}
586+
if extractBinaryIDFromDoc(dGet(actDoc, "$ID")) == actID {
587+
elements[i] = updated
588+
return true
589+
}
590+
for _, nestedFlow := range getNestedFlows(actDoc) {
591+
if replaceActivityRecursive(nestedFlow, actID, updated) {
592+
return true
593+
}
594+
}
595+
}
596+
return false
597+
}
598+
558599
// findActivityByCaption searches the workflow for an activity matching caption.
559600
func (m *mprWorkflowMutator) findActivityByCaption(caption string, atPosition int) (bson.D, error) {
560601
flow := dGetDoc(m.rawData, "Flow")

mdl/backend/mpr/workflow_mutator_test.go

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,8 +1136,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_NoDelay(t *testing.T) {
11361136
// ---------------------------------------------------------------------------
11371137

11381138
func TestWorkflowMutator_SetActivityProperty_Page_New(t *testing.T) {
1139-
// Note: When TaskPage key doesn't pre-exist in BSON, dSet silently fails.
1140-
// The key must be present (even as nil) for PAGE to work on a new activity.
1139+
// TaskPage key present with nil value — should be replaced with a new PageReference.
11411140
act := makeWfActivity("Workflows$UserTask", "Review", "task1")
11421141
act = append(act, bson.E{Key: "TaskPage", Value: nil})
11431142
m := newMutator(makeWorkflowDoc(act))
@@ -1157,7 +1156,8 @@ func TestWorkflowMutator_SetActivityProperty_Page_New(t *testing.T) {
11571156
}
11581157

11591158
func TestWorkflowMutator_SetActivityProperty_Page_MissingKey(t *testing.T) {
1160-
// BUG: dSet silently fails when TaskPage key is absent — pageRef is lost.
1159+
// Regression test: dSet silently failed when TaskPage key was absent.
1160+
// Fixed by appending the key to the activity and replacing it in the BSON tree.
11611161
act := makeWfActivity("Workflows$UserTask", "Review", "task1")
11621162
// No TaskPage field at all
11631163
m := newMutator(makeWorkflowDoc(act))
@@ -1169,9 +1169,67 @@ func TestWorkflowMutator_SetActivityProperty_Page_MissingKey(t *testing.T) {
11691169

11701170
actDoc, _ := m.findActivityByCaption("Review", 0)
11711171
taskPage := dGetDoc(actDoc, "TaskPage")
1172-
// This documents the bug: TaskPage is nil because dSet can't create new keys
1173-
if taskPage != nil {
1174-
t.Log("BUG FIXED: TaskPage is now set even when key was absent")
1172+
if taskPage == nil {
1173+
t.Fatal("TaskPage should be set even when key was absent")
1174+
}
1175+
if got := dGetString(taskPage, "Page"); got != "MyModule.TaskPage" {
1176+
t.Errorf("Page = %q, want MyModule.TaskPage", got)
1177+
}
1178+
}
1179+
1180+
func TestWorkflowMutator_SetActivityProperty_Page_MissingKey_NestedSubFlow(t *testing.T) {
1181+
// Exercises the recursive replaceActivity path: the target activity lives
1182+
// inside an outcome's sub-flow, not at the top level.
1183+
// Use distinct $IDs so replaceActivity cannot accidentally match the parent.
1184+
parentID := primitive.Binary{Subtype: 0x04, Data: []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}}
1185+
nestedID := primitive.Binary{Subtype: 0x04, Data: []byte{2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}}
1186+
1187+
nestedAct := bson.D{
1188+
{Key: "$ID", Value: nestedID},
1189+
{Key: "$Type", Value: "Workflows$UserTask"},
1190+
{Key: "Caption", Value: "NestedReview"},
1191+
{Key: "Name", Value: "nested1"},
1192+
}
1193+
// No TaskPage field at all on the nested activity.
1194+
1195+
outcome := bson.D{
1196+
{Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}},
1197+
{Key: "$Type", Value: "Workflows$BooleanOutcome"},
1198+
{Key: "Flow", Value: bson.D{
1199+
{Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}},
1200+
{Key: "$Type", Value: "Workflows$Flow"},
1201+
{Key: "Activities", Value: bson.A{int32(3), nestedAct}},
1202+
}},
1203+
}
1204+
parentAct := bson.D{
1205+
{Key: "$ID", Value: parentID},
1206+
{Key: "$Type", Value: "Workflows$Decision"},
1207+
{Key: "Caption", Value: "Check"},
1208+
{Key: "Name", Value: "decision1"},
1209+
{Key: "Outcomes", Value: bson.A{int32(3), outcome}},
1210+
}
1211+
m := newMutator(makeWorkflowDoc(parentAct))
1212+
1213+
if err := m.SetActivityProperty("NestedReview", 0, "PAGE", "MyModule.NestedPage"); err != nil {
1214+
t.Fatalf("SetActivityProperty PAGE on nested activity failed: %v", err)
1215+
}
1216+
1217+
actDoc, _ := m.findActivityByCaption("NestedReview", 0)
1218+
taskPage := dGetDoc(actDoc, "TaskPage")
1219+
if taskPage == nil {
1220+
t.Fatal("TaskPage should be set on nested activity even when key was absent")
1221+
}
1222+
if got := dGetString(taskPage, "Page"); got != "MyModule.NestedPage" {
1223+
t.Errorf("Page = %q, want MyModule.NestedPage", got)
1224+
}
1225+
1226+
// Verify parent decision still has its Outcomes intact.
1227+
parentDoc, _ := m.findActivityByCaption("Check", 0)
1228+
if parentDoc == nil {
1229+
t.Fatal("parent decision activity should still exist")
1230+
}
1231+
if outcomes := dGet(parentDoc, "Outcomes"); outcomes == nil {
1232+
t.Fatal("parent decision Outcomes should still be present")
11751233
}
11761234
}
11771235

mdl/executor/widget_registry.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import (
1616

1717
// WidgetRegistry holds loaded widget definitions keyed by uppercase MDL name.
1818
type WidgetRegistry struct {
19-
byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName
20-
byWidgetID map[string]*WidgetDefinition // keyed by widgetId
19+
byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName
20+
byWidgetID map[string]*WidgetDefinition // keyed by widgetId
2121
knownOperations map[string]bool // operations accepted during validation
2222
}
2323

mdl/types/edmx_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,9 +217,9 @@ func TestFindEntityType(t *testing.T) {
217217

218218
func TestResolveNavType(t *testing.T) {
219219
tests := []struct {
220-
input string
221-
typeName string
222-
isMany bool
220+
input string
221+
typeName string
222+
isMany bool
223223
}{
224224
{"Collection(NS.Order)", "Order", true},
225225
{"NS.Customer", "Customer", false},

mdl/types/id_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func TestValidateID(t *testing.T) {
156156
{"AABBCCDD-EEFF-1122-3344-556677889900", true},
157157
{"", false},
158158
{"too-short", false},
159-
{"a1b2c3d4-e5f6-7890-abcd-ef123456789", false}, // 35 chars
159+
{"a1b2c3d4-e5f6-7890-abcd-ef123456789", false}, // 35 chars
160160
{"a1b2c3d4-e5f6-7890-abcd-ef12345678901", false}, // 37 chars
161161
{"a1b2c3d4xe5f6-7890-abcd-ef1234567890", false}, // wrong separator
162162
{"g1b2c3d4-e5f6-7890-abcd-ef1234567890", false}, // invalid hex

0 commit comments

Comments
 (0)