Skip to content

Commit 2a5be7f

Browse files
authored
Merge pull request #258 from hjotha/submit/corruption-fix-and-v1-hash
fix: preserve MPR v1 contents hash and UnitID across DROP+CREATE
2 parents 1abdc5a + 9767368 commit 2a5be7f

9 files changed

Lines changed: 541 additions & 18 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
-- ============================================================================
2+
-- Bug #258: DROP + CREATE OR MODIFY microflow UnitID preservation
3+
-- ============================================================================
4+
--
5+
-- Symptom (before fix):
6+
-- A DROP MICROFLOW X followed by CREATE OR MODIFY MICROFLOW X in the same
7+
-- session produced a *different* UnitID for the new row. Studio Pro treats
8+
-- Unit rows with a different UnitID as unrelated documents and refuses to
9+
-- open the project:
10+
-- ".mpr does not look like a Mendix Studio Pro project file"
11+
--
12+
-- After fix:
13+
-- The executor remembers the dropped UnitID (and ContainerID, AllowedRoles)
14+
-- and reuses them on the subsequent CREATE so Studio Pro sees an in-place
15+
-- update. Also exercises v1 ContentsHash persistence — MPR v1 projects need
16+
-- the hash populated on every write or Studio Pro rejects the file as
17+
-- corrupt.
18+
--
19+
-- Usage:
20+
-- mxcli exec mdl-examples/bug-tests/258-drop-create-microflow-unitid-preservation.mdl -p app.mpr
21+
-- Open the project in Studio Pro — it must load without corruption errors
22+
-- and the microflow MF_Replaced must be visible under BugTest258.
23+
-- ============================================================================
24+
25+
create module BugTest258;
26+
27+
create microflow BugTest258.MF_Original
28+
returns string
29+
(
30+
set $return = 'original'
31+
);
32+
33+
-- Drop + recreate in the same session. Before the fix, this produced a
34+
-- dangling Unit row with a fresh UUID and the project became unopenable in
35+
-- Studio Pro. After the fix, the new microflow reuses the original UnitID.
36+
drop microflow BugTest258.MF_Original;
37+
38+
create or modify microflow BugTest258.MF_Original
39+
returns string
40+
(
41+
set $return = 'replaced'
42+
);
43+
44+
-- DESCRIBE must round-trip cleanly (no placeholder UUIDs leaking).
45+
describe microflow BugTest258.MF_Original;

mdl/executor/cmd_microflows_create.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error {
5757
// Check if microflow with same name already exists in this module
5858
var existingID model.ID
5959
var existingContainerID model.ID
60+
var existingAllowedRoles []model.ID
61+
preserveAllowedRoles := false
6062
existingMicroflows, err := ctx.Backend.ListMicroflows()
6163
if err != nil {
6264
return mdlerrors.NewBackend("check existing microflows", err)
@@ -68,18 +70,35 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error {
6870
}
6971
existingID = existing.ID
7072
existingContainerID = existing.ContainerID
73+
existingAllowedRoles = cloneRoleIDs(existing.AllowedModuleRoles)
74+
preserveAllowedRoles = true
7175
break
7276
}
7377
}
7478

7579
// For CREATE OR REPLACE/MODIFY, reuse the existing ID to preserve references
80+
qualifiedName := s.Name.Module + "." + s.Name.Name
7681
microflowID := model.ID(types.GenerateID())
7782
if existingID != "" {
7883
microflowID = existingID
7984
// Keep the original folder unless a new folder is explicitly specified
8085
if s.Folder == "" {
8186
containerID = existingContainerID
8287
}
88+
} else if dropped := consumeDroppedMicroflow(ctx, qualifiedName); dropped != nil {
89+
// A prior DROP MICROFLOW in the same session removed the unit. Reuse
90+
// its original UnitID and (unless a new folder is specified)
91+
// ContainerID so that Studio Pro sees the rewrite as an in-place
92+
// update rather than a delete+insert pair, which produces
93+
// ".mpr does not look like a Mendix Studio Pro project file" errors.
94+
microflowID = dropped.ID
95+
if s.Folder == "" && dropped.ContainerID != "" {
96+
containerID = dropped.ContainerID
97+
}
98+
// consumeDroppedMicroflow removed the cache entry, so we own this
99+
// slice — no need to clone it again.
100+
existingAllowedRoles = dropped.AllowedRoles
101+
preserveAllowedRoles = true
83102
}
84103

85104
// Build the microflow
@@ -94,6 +113,9 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error {
94113
MarkAsUsed: false,
95114
Excluded: s.Excluded,
96115
}
116+
if preserveAllowedRoles {
117+
mf.AllowedModuleRoles = existingAllowedRoles
118+
}
97119

98120
// Build entity resolver function for parameter/return types
99121
entityResolver := func(qn ast.QualifiedName) model.ID {

mdl/executor/cmd_microflows_drop.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,17 @@ func execDropMicroflow(ctx *ExecContext, s *ast.DropMicroflowStmt) error {
3232
modID := h.FindModuleID(mf.ContainerID)
3333
modName := h.GetModuleName(modID)
3434
if modName == s.Name.Module && mf.Name == s.Name.Name {
35+
qualifiedName := s.Name.Module + "." + s.Name.Name
36+
// Remember the UnitID and ContainerID *before* deletion so that a
37+
// subsequent CREATE OR REPLACE/MODIFY for the same qualified name
38+
// can reuse them. This keeps Studio Pro compatible by turning
39+
// delete+insert into an in-place update from the file's
40+
// perspective — same UnitID, same folder, just new bytes.
41+
rememberDroppedMicroflow(ctx, qualifiedName, mf.ID, mf.ContainerID, mf.AllowedModuleRoles)
3542
if err := ctx.Backend.DeleteMicroflow(mf.ID); err != nil {
3643
return mdlerrors.NewBackend("delete microflow", err)
3744
}
3845
// Clear executor-level caches so subsequent CREATE sees fresh state
39-
qualifiedName := s.Name.Module + "." + s.Name.Name
4046
if ctx.Cache != nil && ctx.Cache.createdMicroflows != nil {
4147
delete(ctx.Cache.createdMicroflows, qualifiedName)
4248
}

mdl/executor/cmd_write_handlers_mock_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/mendixlabs/mxcli/sdk/domainmodel"
1313
"github.com/mendixlabs/mxcli/sdk/microflows"
1414
"github.com/mendixlabs/mxcli/sdk/pages"
15+
"github.com/mendixlabs/mxcli/sdk/security"
1516
)
1617

1718
func TestExecCreateModule_Mock(t *testing.T) {
@@ -435,3 +436,175 @@ func TestExecDropAssociation_Mock_NotFound(t *testing.T) {
435436
Name: ast.QualifiedName{Module: "MyModule", Name: "NonExistent"},
436437
}))
437438
}
439+
440+
// TestDropThenCreatePreservesMicroflowUnitID is a regression test for the
441+
// MPR corruption bug documented in docs/MXCLI_MPR_CORRUPTION_PROMPT_0015.md.
442+
//
443+
// When a script runs `DROP MICROFLOW X; CREATE OR MODIFY MICROFLOW X ...` in
444+
// the same session, the executor used to delete the Unit row and then insert
445+
// a new one with a freshly generated UUID. Studio Pro treats the rewritten
446+
// ContainerID/UnitID pair as an unrelated document and refuses to open the
447+
// resulting .mpr ("file does not look like a Mendix Studio Pro project").
448+
//
449+
// The fix records the UnitID of dropped microflows on the executor cache and
450+
// reuses it when a subsequent CREATE OR REPLACE/MODIFY targets the same
451+
// qualified name, so the delete+insert behaves like an in-place update.
452+
func TestDropThenCreatePreservesMicroflowUnitID(t *testing.T) {
453+
mod := mkModule("MyModule")
454+
mf := mkMicroflow(mod.ID, "DoSomething")
455+
originalID := mf.ID
456+
mf.AllowedModuleRoles = []model.ID{"MyModule.Admin", "MyModule.User"}
457+
458+
h := mkHierarchy(mod)
459+
withContainer(h, mf.ContainerID, mod.ID)
460+
461+
listedMicroflows := []*microflows.Microflow{mf}
462+
var createdID model.ID
463+
var createdRoles []model.ID
464+
465+
mb := &mock.MockBackend{
466+
IsConnectedFunc: func() bool { return true },
467+
ListModulesFunc: func() ([]*model.Module, error) {
468+
return []*model.Module{mod}, nil
469+
},
470+
ListMicroflowsFunc: func() ([]*microflows.Microflow, error) {
471+
return listedMicroflows, nil
472+
},
473+
DeleteMicroflowFunc: func(id model.ID) error {
474+
// Simulate real deletion: hide the microflow from subsequent
475+
// ListMicroflows calls so CREATE OR MODIFY sees no existing unit
476+
// (matching the bug reproduction exactly).
477+
listedMicroflows = nil
478+
return nil
479+
},
480+
CreateMicroflowFunc: func(m *microflows.Microflow) error {
481+
createdID = m.ID
482+
createdRoles = cloneRoleIDs(m.AllowedModuleRoles)
483+
return nil
484+
},
485+
GetModuleSecurityFunc: func(moduleID model.ID) (*security.ModuleSecurity, error) {
486+
return &security.ModuleSecurity{
487+
BaseElement: model.BaseElement{ID: nextID("ms")},
488+
ContainerID: moduleID,
489+
}, nil
490+
},
491+
AddModuleRoleFunc: func(moduleSecurityID model.ID, name, description string) error {
492+
return nil
493+
},
494+
ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) {
495+
return nil, nil
496+
},
497+
ListConsumedRestServicesFunc: func() ([]*model.ConsumedRestService, error) {
498+
return nil, nil
499+
},
500+
}
501+
502+
// Need an Executor so ctx.executor is set (trackCreatedMicroflow uses it).
503+
exec := New(&bytesWriter{})
504+
ctx := &ExecContext{
505+
Context: t.Context(),
506+
Backend: mb,
507+
Output: exec.output,
508+
Format: FormatTable,
509+
executor: exec,
510+
}
511+
exec.backend = mb
512+
withHierarchy(h)(ctx)
513+
514+
if err := execDropMicroflow(ctx, &ast.DropMicroflowStmt{
515+
Name: ast.QualifiedName{Module: "MyModule", Name: "DoSomething"},
516+
}); err != nil {
517+
t.Fatalf("DROP MICROFLOW failed: %v", err)
518+
}
519+
520+
// The UnitID and ContainerID must have been stashed on the cache before deletion.
521+
if ctx.Cache == nil || ctx.Cache.droppedMicroflows == nil ||
522+
ctx.Cache.droppedMicroflows["MyModule.DoSomething"] == nil ||
523+
ctx.Cache.droppedMicroflows["MyModule.DoSomething"].ID != originalID {
524+
t.Fatalf("expected droppedMicroflows[MyModule.DoSomething].ID = %q, got cache=%+v",
525+
originalID, ctx.Cache)
526+
}
527+
528+
// CREATE OR MODIFY with the same qualified name must reuse the dropped ID.
529+
createStmt := &ast.CreateMicroflowStmt{
530+
Name: ast.QualifiedName{Module: "MyModule", Name: "DoSomething"},
531+
CreateOrModify: true,
532+
Body: nil, // empty body is fine for this test
533+
}
534+
if err := execCreateMicroflow(ctx, createStmt); err != nil {
535+
t.Fatalf("CREATE OR MODIFY MICROFLOW failed: %v", err)
536+
}
537+
538+
if createdID != originalID {
539+
t.Fatalf("CREATE OR MODIFY must reuse dropped UnitID: got %q, want %q",
540+
createdID, originalID)
541+
}
542+
if len(createdRoles) != 2 || createdRoles[0] != "MyModule.Admin" || createdRoles[1] != "MyModule.User" {
543+
t.Fatalf("CREATE OR MODIFY must preserve dropped allowed roles: got %v", createdRoles)
544+
}
545+
546+
// The cache entry must be consumed so repeated CREATEs don't collide.
547+
if ctx.Cache != nil && ctx.Cache.droppedMicroflows != nil {
548+
if _, stillThere := ctx.Cache.droppedMicroflows["MyModule.DoSomething"]; stillThere {
549+
t.Errorf("droppedMicroflows entry should be cleared after reuse")
550+
}
551+
}
552+
}
553+
554+
func TestCreateOrModifyMicroflowPreservesAllowedRoles(t *testing.T) {
555+
mod := mkModule("MyModule")
556+
mf := mkMicroflow(mod.ID, "DoSomething")
557+
mf.AllowedModuleRoles = []model.ID{"MyModule.Admin"}
558+
559+
h := mkHierarchy(mod)
560+
withContainer(h, mf.ContainerID, mod.ID)
561+
562+
var updatedRoles []model.ID
563+
mb := &mock.MockBackend{
564+
IsConnectedFunc: func() bool { return true },
565+
ListModulesFunc: func() ([]*model.Module, error) {
566+
return []*model.Module{mod}, nil
567+
},
568+
ListMicroflowsFunc: func() ([]*microflows.Microflow, error) {
569+
return []*microflows.Microflow{mf}, nil
570+
},
571+
UpdateMicroflowFunc: func(updated *microflows.Microflow) error {
572+
updatedRoles = cloneRoleIDs(updated.AllowedModuleRoles)
573+
return nil
574+
},
575+
ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) {
576+
return nil, nil
577+
},
578+
ListConsumedRestServicesFunc: func() ([]*model.ConsumedRestService, error) {
579+
return nil, nil
580+
},
581+
}
582+
583+
exec := New(&bytesWriter{})
584+
ctx := &ExecContext{
585+
Context: t.Context(),
586+
Backend: mb,
587+
Output: exec.output,
588+
Format: FormatTable,
589+
executor: exec,
590+
}
591+
exec.backend = mb
592+
withHierarchy(h)(ctx)
593+
594+
if err := execCreateMicroflow(ctx, &ast.CreateMicroflowStmt{
595+
Name: ast.QualifiedName{Module: "MyModule", Name: "DoSomething"},
596+
CreateOrModify: true,
597+
}); err != nil {
598+
t.Fatalf("CREATE OR MODIFY MICROFLOW failed: %v", err)
599+
}
600+
601+
if len(updatedRoles) != 1 || updatedRoles[0] != "MyModule.Admin" {
602+
t.Fatalf("expected existing allowed roles to be preserved, got %v", updatedRoles)
603+
}
604+
}
605+
606+
// bytesWriter is a trivial io.Writer used to satisfy New() in the regression
607+
// test above. We don't care about captured output for this test.
608+
type bytesWriter struct{}
609+
610+
func (*bytesWriter) Write(p []byte) (int, error) { return len(p), nil }

mdl/executor/executor.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ type executorCache struct {
3535
createdPages map[string]*createdPageInfo // qualifiedName -> info
3636
createdSnippets map[string]*createdSnippetInfo // qualifiedName -> info
3737

38+
// Track items dropped during this session so that a subsequent
39+
// CREATE OR REPLACE/MODIFY with the same qualified name can reuse the
40+
// original UnitID and ContainerID. Studio Pro treats Unit rows with a
41+
// different UnitID (or the same UnitID under a different container) as
42+
// unrelated documents, producing broken projects on delete+insert
43+
// rewrites. Reusing both keeps the rewrite semantically equivalent to an
44+
// in-place update.
45+
droppedMicroflows map[string]*droppedUnitInfo // qualifiedName -> original IDs
46+
3847
// Track domain models modified during this session for finalization
3948
modifiedDomainModels map[model.ID]string // domain model unit ID -> module name
4049

@@ -69,6 +78,15 @@ type createdSnippetInfo struct {
6978
ContainerID model.ID
7079
}
7180

81+
// droppedUnitInfo remembers the original UnitID and ContainerID of a document
82+
// dropped during this session so that a subsequent CREATE OR REPLACE/MODIFY
83+
// with the same qualified name can reuse them instead of generating new UUIDs.
84+
type droppedUnitInfo struct {
85+
ID model.ID
86+
ContainerID model.ID
87+
AllowedRoles []model.ID
88+
}
89+
7290
// getEntityNames returns the entity name lookup map, using the pre-warmed cache if available.
7391
func getEntityNames(ctx *ExecContext, h *ContainerHierarchy) map[model.ID]string {
7492
if ctx.Cache != nil && len(ctx.Cache.entityNames) > 0 {
@@ -416,3 +434,53 @@ func (e *Executor) ensureCache() {
416434
e.cache = &executorCache{}
417435
}
418436
}
437+
438+
// rememberDroppedMicroflow records the UnitID and ContainerID of a microflow
439+
// that is about to be deleted via DROP MICROFLOW. A follow-up CREATE OR
440+
// REPLACE/MODIFY for the same qualified name will reuse both instead of
441+
// generating a fresh UUID and defaulting to the module root, so Studio Pro
442+
// continues to see the unit as "updated in place" rather than a delete+insert
443+
// pair.
444+
func rememberDroppedMicroflow(ctx *ExecContext, qualifiedName string, id, containerID model.ID, allowedRoles []model.ID) {
445+
if ctx == nil || qualifiedName == "" || id == "" {
446+
return
447+
}
448+
if ctx.Cache == nil {
449+
ctx.Cache = &executorCache{}
450+
if ctx.executor != nil {
451+
ctx.executor.cache = ctx.Cache
452+
}
453+
}
454+
if ctx.Cache.droppedMicroflows == nil {
455+
ctx.Cache.droppedMicroflows = make(map[string]*droppedUnitInfo)
456+
}
457+
ctx.Cache.droppedMicroflows[qualifiedName] = &droppedUnitInfo{
458+
ID: id,
459+
ContainerID: containerID,
460+
AllowedRoles: cloneRoleIDs(allowedRoles),
461+
}
462+
}
463+
464+
func cloneRoleIDs(roles []model.ID) []model.ID {
465+
if len(roles) == 0 {
466+
return nil
467+
}
468+
cloned := make([]model.ID, len(roles))
469+
copy(cloned, roles)
470+
return cloned
471+
}
472+
473+
// consumeDroppedMicroflow returns the original IDs of a microflow dropped
474+
// earlier in this session (if any) and removes the entry so repeated CREATEs
475+
// don't collide on the same ID. Returns nil when nothing was remembered.
476+
func consumeDroppedMicroflow(ctx *ExecContext, qualifiedName string) *droppedUnitInfo {
477+
if ctx == nil || ctx.Cache == nil || ctx.Cache.droppedMicroflows == nil {
478+
return nil
479+
}
480+
info, ok := ctx.Cache.droppedMicroflows[qualifiedName]
481+
if !ok {
482+
return nil
483+
}
484+
delete(ctx.Cache.droppedMicroflows, qualifiedName)
485+
return info
486+
}

0 commit comments

Comments
 (0)