From 5f0e428e11a4cf5acd8bcc681c1f8344e03251c9 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Sun, 19 Apr 2026 16:57:15 +0200 Subject: [PATCH 1/5] refactor: implement mutation backends and migrate handlers off sdk/mpr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement PageMutator, WorkflowMutator, and WidgetBuilderBackend in mdl/backend/mpr/. Rewrite ALTER PAGE (1721→256 lines) and ALTER WORKFLOW (887→178 lines) as thin orchestrators using mutator sessions. Implement PluggableWidgetEngine with WidgetObjectBuilder interface, eliminating all BSON from widget_engine.go. - Create mdl/backend/mpr/page_mutator.go (1554 lines) - Create mdl/backend/mpr/workflow_mutator.go (771 lines) - Create mdl/backend/mpr/widget_builder.go (1007 lines) - Migrate SerializeWidget/ClientAction/DataSource to backend interface - Add ParseMicroflowFromRaw to MicroflowBackend interface - Delete widget_operations.go, widget_templates.go, widget_defaults.go - Move ALTER PAGE/WORKFLOW tests to backend/mpr/ package --- mdl/backend/backend.go | 1 + mdl/backend/microflow.go | 4 + mdl/backend/mock/backend.go | 9 +- mdl/backend/mock/mock_microflow.go | 7 + mdl/backend/mock/mock_mutation.go | 32 + mdl/backend/mpr/backend.go | 15 +- mdl/backend/mpr/page_mutator.go | 1554 ++++++++++++++++ .../mpr/page_mutator_test.go} | 174 +- mdl/backend/mpr/widget_builder.go | 1007 +++++++++++ mdl/backend/mpr/workflow_mutator.go | 771 ++++++++ .../mpr/workflow_mutator_test.go} | 317 ++-- mdl/backend/mutation.go | 102 +- mdl/executor/bson_helpers.go | 481 +++++ mdl/executor/cmd_alter_page.go | 1598 +---------------- mdl/executor/cmd_alter_workflow.go | 841 +-------- mdl/executor/cmd_diff_local.go | 3 +- mdl/executor/cmd_pages_builder.go | 12 +- mdl/executor/cmd_pages_builder_input.go | 14 - .../cmd_pages_builder_input_datagrid.go | 3 +- .../cmd_pages_builder_v3_pluggable.go | 13 +- mdl/executor/cmd_pages_create_v3.go | 2 + mdl/executor/widget_defaults.go | 151 -- mdl/executor/widget_engine.go | 229 ++- mdl/executor/widget_engine_test.go | 173 +- mdl/executor/widget_operations.go | 301 ---- mdl/executor/widget_registry.go | 44 +- mdl/executor/widget_registry_test.go | 14 +- mdl/executor/widget_templates.go | 162 -- 28 files changed, 4511 insertions(+), 3523 deletions(-) create mode 100644 mdl/backend/mpr/page_mutator.go rename mdl/{executor/alter_page_test.go => backend/mpr/page_mutator_test.go} (80%) create mode 100644 mdl/backend/mpr/widget_builder.go create mode 100644 mdl/backend/mpr/workflow_mutator.go rename mdl/{executor/cmd_alter_workflow_test.go => backend/mpr/workflow_mutator_test.go} (50%) create mode 100644 mdl/executor/bson_helpers.go delete mode 100644 mdl/executor/widget_defaults.go delete mode 100644 mdl/executor/widget_operations.go delete mode 100644 mdl/executor/widget_templates.go diff --git a/mdl/backend/backend.go b/mdl/backend/backend.go index 18fa681e..f9fe9ad0 100644 --- a/mdl/backend/backend.go +++ b/mdl/backend/backend.go @@ -34,4 +34,5 @@ type FullBackend interface { PageMutationBackend WorkflowMutationBackend WidgetSerializationBackend + WidgetBuilderBackend } diff --git a/mdl/backend/microflow.go b/mdl/backend/microflow.go index 06f9edcf..65596ce4 100644 --- a/mdl/backend/microflow.go +++ b/mdl/backend/microflow.go @@ -16,6 +16,10 @@ type MicroflowBackend interface { DeleteMicroflow(id model.ID) error MoveMicroflow(mf *microflows.Microflow) error + // ParseMicroflowFromRaw builds a Microflow from an already-unmarshalled + // BSON map. Used by diff-local and other callers that have raw map data. + ParseMicroflowFromRaw(raw map[string]any, unitID, containerID model.ID) *microflows.Microflow + ListNanoflows() ([]*microflows.Nanoflow, error) GetNanoflow(id model.ID) (*microflows.Nanoflow, error) CreateNanoflow(nf *microflows.Nanoflow) error diff --git a/mdl/backend/mock/backend.go b/mdl/backend/mock/backend.go index 1a4ff937..01a4f1b9 100644 --- a/mdl/backend/mock/backend.go +++ b/mdl/backend/mock/backend.go @@ -80,7 +80,8 @@ type MockBackend struct { CreateMicroflowFunc func(mf *microflows.Microflow) error UpdateMicroflowFunc func(mf *microflows.Microflow) error DeleteMicroflowFunc func(id model.ID) error - MoveMicroflowFunc func(mf *microflows.Microflow) error + MoveMicroflowFunc func(mf *microflows.Microflow) error + ParseMicroflowFromRawFunc func(raw map[string]any, unitID, containerID model.ID) *microflows.Microflow ListNanoflowsFunc func() ([]*microflows.Nanoflow, error) GetNanoflowFunc func(id model.ID) (*microflows.Nanoflow, error) CreateNanoflowFunc func(nf *microflows.Nanoflow) error @@ -270,6 +271,12 @@ type MockBackend struct { SerializeDataSourceFunc func(ds pages.DataSource) (any, error) SerializeWorkflowActivityFunc func(a workflows.WorkflowActivity) (any, error) + // WidgetBuilderBackend + LoadWidgetTemplateFunc func(widgetID string, projectPath string) (backend.WidgetObjectBuilder, error) + SerializeWidgetToOpaqueFunc func(w pages.Widget) any + SerializeDataSourceToOpaqueFunc func(ds pages.DataSource) any + BuildCreateAttributeObjectFunc func(attributePath string, objectTypeID, propertyTypeID, valueTypeID string) (any, error) + // AgentEditorBackend ListAgentEditorModelsFunc func() ([]*agenteditor.Model, error) ListAgentEditorKnowledgeBasesFunc func() ([]*agenteditor.KnowledgeBase, error) diff --git a/mdl/backend/mock/mock_microflow.go b/mdl/backend/mock/mock_microflow.go index ed02e9ed..567bf859 100644 --- a/mdl/backend/mock/mock_microflow.go +++ b/mdl/backend/mock/mock_microflow.go @@ -49,6 +49,13 @@ func (m *MockBackend) MoveMicroflow(mf *microflows.Microflow) error { return nil } +func (m *MockBackend) ParseMicroflowFromRaw(raw map[string]any, unitID, containerID model.ID) *microflows.Microflow { + if m.ParseMicroflowFromRawFunc != nil { + return m.ParseMicroflowFromRawFunc(raw, unitID, containerID) + } + return nil +} + func (m *MockBackend) ListNanoflows() ([]*microflows.Nanoflow, error) { if m.ListNanoflowsFunc != nil { return m.ListNanoflowsFunc() diff --git a/mdl/backend/mock/mock_mutation.go b/mdl/backend/mock/mock_mutation.go index 89b91122..82fabe96 100644 --- a/mdl/backend/mock/mock_mutation.go +++ b/mdl/backend/mock/mock_mutation.go @@ -62,3 +62,35 @@ func (m *MockBackend) SerializeWorkflowActivity(a workflows.WorkflowActivity) (a } return nil, nil } + +// --------------------------------------------------------------------------- +// WidgetBuilderBackend +// --------------------------------------------------------------------------- + +func (m *MockBackend) LoadWidgetTemplate(widgetID string, projectPath string) (backend.WidgetObjectBuilder, error) { + if m.LoadWidgetTemplateFunc != nil { + return m.LoadWidgetTemplateFunc(widgetID, projectPath) + } + return nil, nil +} + +func (m *MockBackend) SerializeWidgetToOpaque(w pages.Widget) any { + if m.SerializeWidgetToOpaqueFunc != nil { + return m.SerializeWidgetToOpaqueFunc(w) + } + return nil +} + +func (m *MockBackend) SerializeDataSourceToOpaque(ds pages.DataSource) any { + if m.SerializeDataSourceToOpaqueFunc != nil { + return m.SerializeDataSourceToOpaqueFunc(ds) + } + return nil +} + +func (m *MockBackend) BuildCreateAttributeObject(attributePath string, objectTypeID, propertyTypeID, valueTypeID string) (any, error) { + if m.BuildCreateAttributeObjectFunc != nil { + return m.BuildCreateAttributeObjectFunc(attributePath, objectTypeID, propertyTypeID, valueTypeID) + } + return nil, nil +} diff --git a/mdl/backend/mpr/backend.go b/mdl/backend/mpr/backend.go index 431be412..6831ae9e 100644 --- a/mdl/backend/mpr/backend.go +++ b/mdl/backend/mpr/backend.go @@ -211,6 +211,9 @@ func (b *MprBackend) MoveMicroflow(mf *microflows.Microflow) error { func (b *MprBackend) ListNanoflows() ([]*microflows.Nanoflow, error) { return b.reader.ListNanoflows() } +func (b *MprBackend) ParseMicroflowFromRaw(raw map[string]any, unitID, containerID model.ID) *microflows.Microflow { + return mpr.ParseMicroflowFromRaw(raw, unitID, containerID) +} func (b *MprBackend) GetNanoflow(id model.ID) (*microflows.Nanoflow, error) { return b.reader.GetNanoflow(id) } @@ -726,17 +729,17 @@ func (b *MprBackend) DeleteAgentEditorAgent(id string) error { } // --------------------------------------------------------------------------- -// PageMutationBackend +// PageMutationBackend — implemented in page_mutator.go +// --------------------------------------------------------------------------- -func (b *MprBackend) OpenPageForMutation(unitID model.ID) (backend.PageMutator, error) { - panic("MprBackend.OpenPageForMutation not yet implemented") // TODO: implement in PR #237 -} +// OpenPageForMutation is implemented in page_mutator.go. // --------------------------------------------------------------------------- // WorkflowMutationBackend +// OpenWorkflowForMutation is implemented in workflow_mutator.go. func (b *MprBackend) OpenWorkflowForMutation(unitID model.ID) (backend.WorkflowMutator, error) { - panic("MprBackend.OpenWorkflowForMutation not yet implemented") // TODO: implement in PR #237 + return b.openWorkflowForMutation(unitID) } // --------------------------------------------------------------------------- @@ -751,7 +754,7 @@ func (b *MprBackend) SerializeClientAction(a pages.ClientAction) (any, error) { } func (b *MprBackend) SerializeDataSource(ds pages.DataSource) (any, error) { - panic("MprBackend.SerializeDataSource not yet implemented") // TODO: implement in PR #237 + return mpr.SerializeCustomWidgetDataSource(ds), nil } func (b *MprBackend) SerializeWorkflowActivity(a workflows.WorkflowActivity) (any, error) { diff --git a/mdl/backend/mpr/page_mutator.go b/mdl/backend/mpr/page_mutator.go new file mode 100644 index 00000000..8b19ef36 --- /dev/null +++ b/mdl/backend/mpr/page_mutator.go @@ -0,0 +1,1554 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mprbackend + +import ( + "fmt" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/mpr" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// Compile-time check. +var _ backend.PageMutator = (*mprPageMutator)(nil) + +// mprPageMutator implements backend.PageMutator for the MPR backend. +type mprPageMutator struct { + rawData bson.D + containerType backend.ContainerKind // "page", "snippet", or "layout" + unitID model.ID + backend *MprBackend + widgetFinder widgetFinder +} + +// --------------------------------------------------------------------------- +// OpenPageForMutation +// --------------------------------------------------------------------------- + +// OpenPageForMutation loads a page/snippet/layout unit and returns a PageMutator. +func (b *MprBackend) OpenPageForMutation(unitID model.ID) (backend.PageMutator, error) { + rawBytes, err := b.reader.GetRawUnitBytes(unitID) + if err != nil { + return nil, fmt.Errorf("load raw unit bytes: %w", err) + } + var rawData bson.D + if err := bson.Unmarshal(rawBytes, &rawData); err != nil { + return nil, fmt.Errorf("unmarshal unit BSON: %w", err) + } + + // Determine container type from $Type field. + typeName := dGetString(rawData, "$Type") + containerType := backend.ContainerPage + switch { + case strings.Contains(typeName, "Snippet"): + containerType = backend.ContainerSnippet + case strings.Contains(typeName, "Layout"): + containerType = backend.ContainerLayout + } + + finder := findBsonWidget + if containerType == backend.ContainerSnippet { + finder = findBsonWidgetInSnippet + } + + return &mprPageMutator{ + rawData: rawData, + containerType: containerType, + unitID: unitID, + backend: b, + widgetFinder: finder, + }, nil +} + +// --------------------------------------------------------------------------- +// PageMutator interface implementation +// --------------------------------------------------------------------------- + +func (m *mprPageMutator) ContainerType() backend.ContainerKind { return m.containerType } + +func (m *mprPageMutator) SetWidgetProperty(widgetRef string, prop string, value any) error { + if widgetRef == "" { + // Page-level property + return applyPageLevelSetMut(m.rawData, prop, value) + } + result := m.widgetFinder(m.rawData, widgetRef) + if result == nil { + return fmt.Errorf("widget %q not found", widgetRef) + } + return setRawWidgetPropertyMut(result.widget, prop, value) +} + +func (m *mprPageMutator) SetWidgetDataSource(widgetRef string, ds pages.DataSource) error { + result := m.widgetFinder(m.rawData, widgetRef) + if result == nil { + return fmt.Errorf("widget %q not found", widgetRef) + } + serialized := serializeDataSourceBson(ds) + if serialized == nil { + return fmt.Errorf("unsupported DataSource type %T", ds) + } + dSet(result.widget, "DataSource", serialized) + return nil +} + +func (m *mprPageMutator) SetColumnProperty(gridRef string, columnRef string, prop string, value any) error { + result := findBsonColumn(m.rawData, gridRef, columnRef, m.widgetFinder) + if result == nil { + return fmt.Errorf("column %q on grid %q not found", columnRef, gridRef) + } + return setColumnPropertyMut(result.widget, result.colPropKeys, prop, value) +} + +func (m *mprPageMutator) InsertWidget(widgetRef string, columnRef string, position backend.InsertPosition, widgets []pages.Widget) error { + var result *bsonWidgetResult + if columnRef != "" { + result = findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder) + } else { + result = m.widgetFinder(m.rawData, widgetRef) + } + if result == nil { + if columnRef != "" { + return fmt.Errorf("column %q on widget %q not found", columnRef, widgetRef) + } + return fmt.Errorf("widget %q not found", widgetRef) + } + + // Serialize widgets + newBsonWidgets, err := serializeWidgets(widgets) + if err != nil { + return fmt.Errorf("serialize widgets: %w", err) + } + + insertIdx := result.index + if strings.EqualFold(string(position), "after") { + insertIdx = result.index + 1 + } + + newArr := make([]any, 0, len(result.parentArr)+len(newBsonWidgets)) + newArr = append(newArr, result.parentArr[:insertIdx]...) + newArr = append(newArr, newBsonWidgets...) + newArr = append(newArr, result.parentArr[insertIdx:]...) + + dSetArray(result.parentDoc, result.parentKey, newArr) + return nil +} + +func (m *mprPageMutator) DropWidget(refs []backend.WidgetRef) error { + for _, ref := range refs { + var result *bsonWidgetResult + if ref.IsColumn() { + result = findBsonColumn(m.rawData, ref.Widget, ref.Column, m.widgetFinder) + } else { + result = m.widgetFinder(m.rawData, ref.Widget) + } + if result == nil { + return fmt.Errorf("widget %q not found", ref.Name()) + } + newArr := make([]any, 0, len(result.parentArr)-1) + newArr = append(newArr, result.parentArr[:result.index]...) + newArr = append(newArr, result.parentArr[result.index+1:]...) + dSetArray(result.parentDoc, result.parentKey, newArr) + } + return nil +} + +func (m *mprPageMutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pages.Widget) error { + var result *bsonWidgetResult + if columnRef != "" { + result = findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder) + } else { + result = m.widgetFinder(m.rawData, widgetRef) + } + if result == nil { + if columnRef != "" { + return fmt.Errorf("column %q on widget %q not found", columnRef, widgetRef) + } + return fmt.Errorf("widget %q not found", widgetRef) + } + + newBsonWidgets, err := serializeWidgets(widgets) + if err != nil { + return fmt.Errorf("serialize widgets: %w", err) + } + + newArr := make([]any, 0, len(result.parentArr)-1+len(newBsonWidgets)) + newArr = append(newArr, result.parentArr[:result.index]...) + newArr = append(newArr, newBsonWidgets...) + newArr = append(newArr, result.parentArr[result.index+1:]...) + + dSetArray(result.parentDoc, result.parentKey, newArr) + return nil +} + +func (m *mprPageMutator) AddVariable(name, dataType, defaultValue string) error { + // Check for duplicate variable name + existingVars := dGetArrayElements(dGet(m.rawData, "Variables")) + for _, ev := range existingVars { + if evDoc, ok := ev.(bson.D); ok { + if dGetString(evDoc, "Name") == name { + return fmt.Errorf("variable $%s already exists", name) + } + } + } + + varTypeID := types.GenerateID() + bsonTypeName := mdlTypeToBsonType(dataType) + varType := bson.D{ + {Key: "$ID", Value: bsonutil.IDToBsonBinary(varTypeID)}, + {Key: "$Type", Value: bsonTypeName}, + } + if bsonTypeName == "DataTypes$ObjectType" { + varType = append(varType, bson.E{Key: "Entity", Value: dataType}) + } + + varID := types.GenerateID() + varDoc := bson.D{ + {Key: "$ID", Value: bsonutil.IDToBsonBinary(varID)}, + {Key: "$Type", Value: "Forms$LocalVariable"}, + {Key: "DefaultValue", Value: defaultValue}, + {Key: "Name", Value: name}, + {Key: "VariableType", Value: varType}, + } + + existing := toBsonA(dGet(m.rawData, "Variables")) + if existing != nil { + elements := dGetArrayElements(dGet(m.rawData, "Variables")) + elements = append(elements, varDoc) + dSetArray(m.rawData, "Variables", elements) + } else { + m.rawData = append(m.rawData, bson.E{Key: "Variables", Value: bson.A{int32(3), varDoc}}) + } + return nil +} + +func (m *mprPageMutator) DropVariable(name string) error { + elements := dGetArrayElements(dGet(m.rawData, "Variables")) + if elements == nil { + return fmt.Errorf("variable $%s not found", name) + } + + found := false + var kept []any + for _, elem := range elements { + if doc, ok := elem.(bson.D); ok { + if dGetString(doc, "Name") == name { + found = true + continue + } + } + kept = append(kept, elem) + } + if !found { + return fmt.Errorf("variable $%s not found", name) + } + dSetArray(m.rawData, "Variables", kept) + return nil +} + +func (m *mprPageMutator) SetLayout(newLayout string, paramMappings map[string]string) error { + if m.containerType == "snippet" { + return fmt.Errorf("SET Layout is not supported for snippets") + } + + formCall := dGetDoc(m.rawData, "FormCall") + if formCall == nil { + return fmt.Errorf("page has no FormCall (layout reference)") + } + + // Detect old layout name + oldLayoutQN := "" + for _, elem := range formCall { + if elem.Key == "Form" { + if s, ok := elem.Value.(string); ok && s != "" { + oldLayoutQN = s + } + } + if elem.Key == "Arguments" { + if arr, ok := elem.Value.(bson.A); ok { + for _, item := range arr { + if doc, ok := item.(bson.D); ok { + for _, field := range doc { + if field.Key == "Parameter" { + if s, ok := field.Value.(string); ok && oldLayoutQN == "" { + if lastDot := strings.LastIndex(s, "."); lastDot > 0 { + oldLayoutQN = s[:lastDot] + } + } + } + } + } + } + } + } + } + + if oldLayoutQN == "" { + return fmt.Errorf("cannot determine current layout from FormCall") + } + if oldLayoutQN == newLayout { + return nil + } + + // Update Form field + for i, elem := range formCall { + if elem.Key == "Form" { + formCall[i].Value = newLayout + } + } + + // Remap Parameter strings + for _, elem := range formCall { + if elem.Key != "Arguments" { + continue + } + arr, ok := elem.Value.(bson.A) + if !ok { + continue + } + for _, item := range arr { + doc, ok := item.(bson.D) + if !ok { + continue + } + for j, field := range doc { + if field.Key != "Parameter" { + continue + } + paramStr, ok := field.Value.(string) + if !ok { + continue + } + placeholder := paramStr + if strings.HasPrefix(paramStr, oldLayoutQN+".") { + placeholder = paramStr[len(oldLayoutQN)+1:] + } + if paramMappings != nil { + if mapped, ok := paramMappings[placeholder]; ok { + placeholder = mapped + } + } + doc[j].Value = newLayout + "." + placeholder + } + } + } + + // Write FormCall back + for i, elem := range m.rawData { + if elem.Key == "FormCall" { + m.rawData[i].Value = formCall + break + } + } + return nil +} + +func (m *mprPageMutator) SetPluggableProperty(widgetRef string, propKey string, opName backend.PluggablePropertyOp, ctx backend.PluggablePropertyContext) error { + result := m.widgetFinder(m.rawData, widgetRef) + if result == nil { + return fmt.Errorf("widget %q not found", widgetRef) + } + + obj := dGetDoc(result.widget, "Object") + if obj == nil { + return fmt.Errorf("widget %q has no pluggable Object", widgetRef) + } + + propTypeKeyMap := buildPropKeyMap(result.widget) + + props := dGetArrayElements(dGet(obj, "Properties")) + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + typePointerID := extractBinaryIDFromDoc(dGet(propDoc, "TypePointer")) + resolvedKey := propTypeKeyMap[typePointerID] + if resolvedKey != propKey { + continue + } + valDoc := dGetDoc(propDoc, "Value") + if valDoc == nil { + return fmt.Errorf("property %q has no Value", propKey) + } + + switch opName { + case "primitive": + dSet(valDoc, "PrimitiveValue", ctx.PrimitiveVal) + case "attribute": + if attrDoc := dGetDoc(valDoc, "AttributeRef"); attrDoc != nil { + dSet(attrDoc, "Attribute", ctx.AttributePath) + } else { + dSet(valDoc, "AttributeRef", bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$AttributeRef"}, + {Key: "Attribute", Value: ctx.AttributePath}, + {Key: "EntityRef", Value: nil}, + }) + } + case "association": + dSet(valDoc, "AssociationRef", ctx.AssocPath) + if ctx.EntityName != "" { + dSet(valDoc, "EntityRef", bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, + {Key: "Entity", Value: ctx.EntityName}, + }) + } + case "datasource": + serialized := mpr.SerializeCustomWidgetDataSource(ctx.DataSource) + dSet(valDoc, "DataSource", serialized) + case "widgets": + serialized, err := serializeWidgets(ctx.ChildWidgets) + if err != nil { + return fmt.Errorf("serialize child widgets: %w", err) + } + var bsonArr bson.A + bsonArr = append(bsonArr, int32(2)) + for _, w := range serialized { + bsonArr = append(bsonArr, w) + } + dSet(valDoc, "Widgets", bsonArr) + case "texttemplate": + if tmpl := dGetDoc(valDoc, "TextTemplate"); tmpl != nil { + items := dGetArrayElements(dGet(tmpl, "Items")) + if len(items) > 0 { + if itemDoc, ok := items[0].(bson.D); ok { + dSet(itemDoc, "Text", ctx.TextTemplate) + } + } + } + case "action": + serialized := mpr.SerializeClientAction(ctx.Action) + dSet(valDoc, "Action", serialized) + case "selection": + dSet(valDoc, "PrimitiveValue", ctx.Selection) + case "attributeObjects": + // Set multiple attribute paths on sub-objects + objects := dGetArrayElements(dGet(valDoc, "Objects")) + for i, attrPath := range ctx.AttributePaths { + if i >= len(objects) { + break + } + if objDoc, ok := objects[i].(bson.D); ok { + objProps := dGetArrayElements(dGet(objDoc, "Properties")) + for _, op := range objProps { + opDoc, ok := op.(bson.D) + if !ok { + continue + } + if opVal := dGetDoc(opDoc, "Value"); opVal != nil { + if attrRef := dGetDoc(opVal, "AttributeRef"); attrRef != nil { + dSet(attrRef, "Attribute", attrPath) + } + } + } + } + } + default: + return fmt.Errorf("unsupported pluggable property operation: %s", opName) + } + return nil + } + return fmt.Errorf("pluggable property %q not found on widget %q", propKey, widgetRef) +} + +func (m *mprPageMutator) EnclosingEntity(widgetRef string) string { + return findEnclosingEntityContext(m.rawData, widgetRef) +} + +func (m *mprPageMutator) WidgetScope() map[string]model.ID { + return extractWidgetScopeFromBSON(m.rawData) +} + +func (m *mprPageMutator) ParamScope() (map[string]model.ID, map[string]string) { + return extractPageParamsFromBSON(m.rawData) +} + +func (m *mprPageMutator) FindWidget(name string) bool { + return m.widgetFinder(m.rawData, name) != nil +} + +func (m *mprPageMutator) Save() error { + outBytes, err := bson.Marshal(m.rawData) + if err != nil { + return fmt.Errorf("marshal modified %s: %w", m.containerType, err) + } + return m.backend.writer.UpdateRawUnit(string(m.unitID), outBytes) +} + +// --------------------------------------------------------------------------- +// BSON helpers (moved from executor/cmd_alter_page.go) +// --------------------------------------------------------------------------- + +// dGet returns the value for a key in a bson.D, or nil if not found. +func dGet(doc bson.D, key string) any { + for _, elem := range doc { + if elem.Key == key { + return elem.Value + } + } + return nil +} + +// dGetDoc returns a nested bson.D field value, or nil. +func dGetDoc(doc bson.D, key string) bson.D { + v := dGet(doc, key) + if d, ok := v.(bson.D); ok { + return d + } + return nil +} + +// dGetString returns a string field value, or "". +func dGetString(doc bson.D, key string) string { + v := dGet(doc, key) + if s, ok := v.(string); ok { + return s + } + return "" +} + +// dSet sets a field value in a bson.D in place. Returns true if found. +func dSet(doc bson.D, key string, value any) bool { + for i := range doc { + if doc[i].Key == key { + doc[i].Value = value + return true + } + } + return false +} + +// dGetArrayElements extracts Mendix array elements from a bson.D field value. +// Strips the int32 type marker at index 0. +func dGetArrayElements(val any) []any { + arr := toBsonA(val) + if len(arr) == 0 { + return nil + } + if _, ok := arr[0].(int32); ok { + return arr[1:] + } + if _, ok := arr[0].(int); ok { + return arr[1:] + } + return arr +} + +// toBsonA converts various BSON array types to []any. +func toBsonA(v any) []any { + switch arr := v.(type) { + case bson.A: + return []any(arr) + case []any: + return arr + default: + return nil + } +} + +// dSetArray sets a Mendix-style BSON array field, preserving the int32 marker. +func dSetArray(doc bson.D, key string, elements []any) { + existing := toBsonA(dGet(doc, key)) + var marker any + if len(existing) > 0 { + if _, ok := existing[0].(int32); ok { + marker = existing[0] + } else if _, ok := existing[0].(int); ok { + marker = existing[0] + } + } + var result bson.A + if marker != nil { + result = make(bson.A, 0, len(elements)+1) + result = append(result, marker) + result = append(result, elements...) + } else { + result = make(bson.A, len(elements)) + copy(result, elements) + } + dSet(doc, key, result) +} + +// extractBinaryIDFromDoc extracts a binary ID string from a bson.D field. +func extractBinaryIDFromDoc(val any) string { + if bin, ok := val.(primitive.Binary); ok { + return types.BlobToUUID(bin.Data) + } + return "" +} + +// --------------------------------------------------------------------------- +// BSON widget tree walking +// --------------------------------------------------------------------------- + +// bsonWidgetResult holds a found widget and its parent context. +type bsonWidgetResult struct { + widget bson.D + parentArr []any + parentKey string + parentDoc bson.D + index int + colPropKeys map[string]string +} + +// widgetFinder is a function type for locating widgets in a raw BSON tree. +type widgetFinder func(rawData bson.D, widgetName string) *bsonWidgetResult + +// findBsonWidget searches the raw BSON page tree for a widget by name. +func findBsonWidget(rawData bson.D, widgetName string) *bsonWidgetResult { + formCall := dGetDoc(rawData, "FormCall") + if formCall == nil { + return nil + } + args := dGetArrayElements(dGet(formCall, "Arguments")) + for _, arg := range args { + argDoc, ok := arg.(bson.D) + if !ok { + continue + } + if result := findInWidgetArray(argDoc, "Widgets", widgetName); result != nil { + return result + } + } + return nil +} + +// findBsonWidgetInSnippet searches the raw BSON snippet tree for a widget by name. +func findBsonWidgetInSnippet(rawData bson.D, widgetName string) *bsonWidgetResult { + if result := findInWidgetArray(rawData, "Widgets", widgetName); result != nil { + return result + } + if widgetContainer := dGetDoc(rawData, "Widget"); widgetContainer != nil { + if result := findInWidgetArray(widgetContainer, "Widgets", widgetName); result != nil { + return result + } + } + return nil +} + +// findInWidgetArray searches a widget array for a named widget. +func findInWidgetArray(parentDoc bson.D, key string, widgetName string) *bsonWidgetResult { + elements := dGetArrayElements(dGet(parentDoc, key)) + for i, elem := range elements { + wDoc, ok := elem.(bson.D) + if !ok { + continue + } + if dGetString(wDoc, "Name") == widgetName { + return &bsonWidgetResult{ + widget: wDoc, + parentArr: elements, + parentKey: key, + parentDoc: parentDoc, + index: i, + } + } + if result := findInWidgetChildren(wDoc, widgetName); result != nil { + return result + } + } + return nil +} + +// findInWidgetChildren recursively searches widget children for a named widget. +func findInWidgetChildren(wDoc bson.D, widgetName string) *bsonWidgetResult { + typeName := dGetString(wDoc, "$Type") + + if result := findInWidgetArray(wDoc, "Widgets", widgetName); result != nil { + return result + } + if result := findInWidgetArray(wDoc, "FooterWidgets", widgetName); result != nil { + return result + } + + // LayoutGrid: Rows[].Columns[].Widgets[] + if strings.Contains(typeName, "LayoutGrid") { + rows := dGetArrayElements(dGet(wDoc, "Rows")) + for _, row := range rows { + rowDoc, ok := row.(bson.D) + if !ok { + continue + } + cols := dGetArrayElements(dGet(rowDoc, "Columns")) + for _, col := range cols { + colDoc, ok := col.(bson.D) + if !ok { + continue + } + if result := findInWidgetArray(colDoc, "Widgets", widgetName); result != nil { + return result + } + } + } + } + + // TabContainer: TabPages[].Widgets[] + tabPages := dGetArrayElements(dGet(wDoc, "TabPages")) + for _, tp := range tabPages { + tpDoc, ok := tp.(bson.D) + if !ok { + continue + } + if result := findInWidgetArray(tpDoc, "Widgets", widgetName); result != nil { + return result + } + } + + // ControlBar + if controlBar := dGetDoc(wDoc, "ControlBar"); controlBar != nil { + if result := findInWidgetArray(controlBar, "Items", widgetName); result != nil { + return result + } + } + + // CustomWidget (pluggable): Object.Properties[].Value.Widgets[] + if strings.Contains(typeName, "CustomWidget") { + if obj := dGetDoc(wDoc, "Object"); obj != nil { + props := dGetArrayElements(dGet(obj, "Properties")) + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { + if result := findInWidgetArray(valDoc, "Widgets", widgetName); result != nil { + return result + } + } + } + } + } + + return nil +} + +// --------------------------------------------------------------------------- +// DataGrid2 column finder +// --------------------------------------------------------------------------- + +// findBsonColumn finds a column inside a DataGrid2 widget by derived name. +func findBsonColumn(rawData bson.D, gridName, columnName string, find widgetFinder) *bsonWidgetResult { + gridResult := find(rawData, gridName) + if gridResult == nil { + return nil + } + + gridPropKeyMap := buildPropKeyMap(gridResult.widget) + + obj := dGetDoc(gridResult.widget, "Object") + if obj == nil { + return nil + } + + props := dGetArrayElements(dGet(obj, "Properties")) + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + typePointerID := extractBinaryIDFromDoc(dGet(propDoc, "TypePointer")) + propKey := gridPropKeyMap[typePointerID] + if propKey != "columns" { + continue + } + + valDoc := dGetDoc(propDoc, "Value") + if valDoc == nil { + return nil + } + + colPropKeyMap := buildColumnPropKeyMap(gridResult.widget, typePointerID) + + columns := dGetArrayElements(dGet(valDoc, "Objects")) + for i, colItem := range columns { + colDoc, ok := colItem.(bson.D) + if !ok { + continue + } + derived := deriveColumnNameBson(colDoc, colPropKeyMap, i) + if derived == columnName { + return &bsonWidgetResult{ + widget: colDoc, + parentArr: columns, + parentKey: "Objects", + parentDoc: valDoc, + index: i, + colPropKeys: colPropKeyMap, + } + } + } + return nil + } + return nil +} + +// buildPropKeyMap builds a TypePointer ID -> PropertyKey map. +func buildPropKeyMap(widgetDoc bson.D) map[string]string { + m := make(map[string]string) + widgetType := dGetDoc(widgetDoc, "Type") + if widgetType == nil { + return m + } + objType := dGetDoc(widgetType, "ObjectType") + if objType == nil { + return m + } + for _, pt := range dGetArrayElements(dGet(objType, "PropertyTypes")) { + ptDoc, ok := pt.(bson.D) + if !ok { + continue + } + key := dGetString(ptDoc, "PropertyKey") + id := extractBinaryIDFromDoc(dGet(ptDoc, "$ID")) + if key != "" && id != "" { + m[id] = key + } + } + return m +} + +// buildColumnPropKeyMap builds a TypePointer ID -> PropertyKey map for column properties. +func buildColumnPropKeyMap(widgetDoc bson.D, columnsTypePointerID string) map[string]string { + m := make(map[string]string) + widgetType := dGetDoc(widgetDoc, "Type") + if widgetType == nil { + return m + } + objType := dGetDoc(widgetType, "ObjectType") + if objType == nil { + return m + } + for _, pt := range dGetArrayElements(dGet(objType, "PropertyTypes")) { + ptDoc, ok := pt.(bson.D) + if !ok { + continue + } + id := extractBinaryIDFromDoc(dGet(ptDoc, "$ID")) + if id != columnsTypePointerID { + continue + } + valType := dGetDoc(ptDoc, "ValueType") + if valType == nil { + return m + } + colObjType := dGetDoc(valType, "ObjectType") + if colObjType == nil { + return m + } + for _, cpt := range dGetArrayElements(dGet(colObjType, "PropertyTypes")) { + cptDoc, ok := cpt.(bson.D) + if !ok { + continue + } + key := dGetString(cptDoc, "PropertyKey") + cid := extractBinaryIDFromDoc(dGet(cptDoc, "$ID")) + if key != "" && cid != "" { + m[cid] = key + } + } + return m + } + return m +} + +// deriveColumnNameBson derives a column name from its BSON WidgetObject. +func deriveColumnNameBson(colDoc bson.D, propKeyMap map[string]string, index int) string { + var attribute, caption string + + props := dGetArrayElements(dGet(colDoc, "Properties")) + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + typePointerID := extractBinaryIDFromDoc(dGet(propDoc, "TypePointer")) + propKey := propKeyMap[typePointerID] + + valDoc := dGetDoc(propDoc, "Value") + if valDoc == nil { + continue + } + + switch propKey { + case "attribute": + if attrRef := dGetString(valDoc, "AttributeRef"); attrRef != "" { + attribute = attrRef + } else if attrDoc := dGetDoc(valDoc, "AttributeRef"); attrDoc != nil { + attribute = dGetString(attrDoc, "Attribute") + } + case "header": + if tmpl := dGetDoc(valDoc, "TextTemplate"); tmpl != nil { + items := dGetArrayElements(dGet(tmpl, "Items")) + for _, item := range items { + if itemDoc, ok := item.(bson.D); ok { + if text := dGetString(itemDoc, "Text"); text != "" { + caption = text + } + } + } + } + } + } + + if attribute != "" { + parts := strings.Split(attribute, ".") + return parts[len(parts)-1] + } + if caption != "" { + return sanitizeColumnName(caption) + } + return fmt.Sprintf("col%d", index+1) +} + +// sanitizeColumnName converts a caption string into a valid column identifier. +func sanitizeColumnName(caption string) string { + var result []rune + for _, r := range caption { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + result = append(result, r) + } else { + result = append(result, '_') + } + } + return string(result) +} + +// --------------------------------------------------------------------------- +// Entity context extraction +// --------------------------------------------------------------------------- + +// findEnclosingEntityContext walks the raw BSON tree to find the entity context. +func findEnclosingEntityContext(rawData bson.D, widgetName string) string { + if formCall := dGetDoc(rawData, "FormCall"); formCall != nil { + args := dGetArrayElements(dGet(formCall, "Arguments")) + for _, arg := range args { + argDoc, ok := arg.(bson.D) + if !ok { + continue + } + if ctx := findEntityContextInWidgets(argDoc, "Widgets", widgetName, ""); ctx != "" { + return ctx + } + } + } + if ctx := findEntityContextInWidgets(rawData, "Widgets", widgetName, ""); ctx != "" { + return ctx + } + if widgetContainer := dGetDoc(rawData, "Widget"); widgetContainer != nil { + if ctx := findEntityContextInWidgets(widgetContainer, "Widgets", widgetName, ""); ctx != "" { + return ctx + } + } + return "" +} + +func findEntityContextInWidgets(parentDoc bson.D, key string, widgetName string, currentEntity string) string { + elements := dGetArrayElements(dGet(parentDoc, key)) + for _, elem := range elements { + wDoc, ok := elem.(bson.D) + if !ok { + continue + } + if dGetString(wDoc, "Name") == widgetName { + return currentEntity + } + entityCtx := currentEntity + if ent := extractEntityFromDataSource(wDoc); ent != "" { + entityCtx = ent + } + if ctx := findEntityContextInChildren(wDoc, widgetName, entityCtx); ctx != "" { + return ctx + } + } + return "" +} + +func findEntityContextInChildren(wDoc bson.D, widgetName string, currentEntity string) string { + typeName := dGetString(wDoc, "$Type") + + if ctx := findEntityContextInWidgets(wDoc, "Widgets", widgetName, currentEntity); ctx != "" { + return ctx + } + if ctx := findEntityContextInWidgets(wDoc, "FooterWidgets", widgetName, currentEntity); ctx != "" { + return ctx + } + if strings.Contains(typeName, "LayoutGrid") { + rows := dGetArrayElements(dGet(wDoc, "Rows")) + for _, row := range rows { + rowDoc, ok := row.(bson.D) + if !ok { + continue + } + cols := dGetArrayElements(dGet(rowDoc, "Columns")) + for _, col := range cols { + colDoc, ok := col.(bson.D) + if !ok { + continue + } + if ctx := findEntityContextInWidgets(colDoc, "Widgets", widgetName, currentEntity); ctx != "" { + return ctx + } + } + } + } + tabPages := dGetArrayElements(dGet(wDoc, "TabPages")) + for _, tp := range tabPages { + tpDoc, ok := tp.(bson.D) + if !ok { + continue + } + if ctx := findEntityContextInWidgets(tpDoc, "Widgets", widgetName, currentEntity); ctx != "" { + return ctx + } + } + if controlBar := dGetDoc(wDoc, "ControlBar"); controlBar != nil { + if ctx := findEntityContextInWidgets(controlBar, "Items", widgetName, currentEntity); ctx != "" { + return ctx + } + } + if strings.Contains(typeName, "CustomWidget") { + if obj := dGetDoc(wDoc, "Object"); obj != nil { + props := dGetArrayElements(dGet(obj, "Properties")) + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { + if ctx := findEntityContextInWidgets(valDoc, "Widgets", widgetName, currentEntity); ctx != "" { + return ctx + } + } + } + } + } + return "" +} + +func extractEntityFromDataSource(wDoc bson.D) string { + ds := dGetDoc(wDoc, "DataSource") + if ds == nil { + return "" + } + if entityRef := dGetDoc(ds, "EntityRef"); entityRef != nil { + if entity := dGetString(entityRef, "Entity"); entity != "" { + return entity + } + } + return "" +} + +// --------------------------------------------------------------------------- +// Widget scope extraction +// --------------------------------------------------------------------------- + +func extractWidgetScopeFromBSON(rawData bson.D) map[string]model.ID { + scope := make(map[string]model.ID) + if rawData == nil { + return scope + } + if formCall := dGetDoc(rawData, "FormCall"); formCall != nil { + args := dGetArrayElements(dGet(formCall, "Arguments")) + for _, arg := range args { + argDoc, ok := arg.(bson.D) + if !ok { + continue + } + collectWidgetScope(argDoc, "Widgets", scope) + } + } + collectWidgetScope(rawData, "Widgets", scope) + if widgetContainer := dGetDoc(rawData, "Widget"); widgetContainer != nil { + collectWidgetScope(widgetContainer, "Widgets", scope) + } + return scope +} + +// extractPageParamsFromBSON extracts page/snippet parameter names and entity +// IDs from the raw BSON document. +func extractPageParamsFromBSON(rawData bson.D) (map[string]model.ID, map[string]string) { + paramScope := make(map[string]model.ID) + paramEntityNames := make(map[string]string) + if rawData == nil { + return paramScope, paramEntityNames + } + + params := dGetArrayElements(dGet(rawData, "Parameters")) + for _, p := range params { + pDoc, ok := p.(bson.D) + if !ok { + continue + } + name := dGetString(pDoc, "Name") + if name == "" { + continue + } + paramType := dGetDoc(pDoc, "ParameterType") + if paramType == nil { + continue + } + typeName := dGetString(paramType, "$Type") + if typeName != "DataTypes$ObjectType" { + continue + } + entityName := dGetString(paramType, "Entity") + if entityName == "" { + continue + } + idVal := dGet(pDoc, "$ID") + paramID := model.ID(extractBinaryIDFromDoc(idVal)) + paramScope[name] = paramID + paramEntityNames[name] = entityName + } + return paramScope, paramEntityNames +} + +func collectWidgetScope(parentDoc bson.D, key string, scope map[string]model.ID) { + elements := dGetArrayElements(dGet(parentDoc, key)) + for _, elem := range elements { + wDoc, ok := elem.(bson.D) + if !ok { + continue + } + name := dGetString(wDoc, "Name") + if name != "" { + idVal := dGet(wDoc, "$ID") + if wID := extractBinaryIDFromDoc(idVal); wID != "" { + scope[name] = model.ID(wID) + } + } + collectWidgetScopeInChildren(wDoc, scope) + } +} + +func collectWidgetScopeInChildren(wDoc bson.D, scope map[string]model.ID) { + typeName := dGetString(wDoc, "$Type") + + collectWidgetScope(wDoc, "Widgets", scope) + collectWidgetScope(wDoc, "FooterWidgets", scope) + + if strings.Contains(typeName, "LayoutGrid") { + rows := dGetArrayElements(dGet(wDoc, "Rows")) + for _, row := range rows { + rowDoc, ok := row.(bson.D) + if !ok { + continue + } + cols := dGetArrayElements(dGet(rowDoc, "Columns")) + for _, col := range cols { + colDoc, ok := col.(bson.D) + if !ok { + continue + } + collectWidgetScope(colDoc, "Widgets", scope) + } + } + } + tabPages := dGetArrayElements(dGet(wDoc, "TabPages")) + for _, tp := range tabPages { + tpDoc, ok := tp.(bson.D) + if !ok { + continue + } + collectWidgetScope(tpDoc, "Widgets", scope) + } + if controlBar := dGetDoc(wDoc, "ControlBar"); controlBar != nil { + collectWidgetScope(controlBar, "Items", scope) + } + if strings.Contains(typeName, "CustomWidget") { + if obj := dGetDoc(wDoc, "Object"); obj != nil { + props := dGetArrayElements(dGet(obj, "Properties")) + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { + collectWidgetScope(valDoc, "Widgets", scope) + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Property setting helpers +// --------------------------------------------------------------------------- + +// columnPropertyAliases maps user-facing property names to internal column property keys. +var columnPropertyAliases = map[string]string{ + "Caption": "header", + "Attribute": "attribute", + "Visible": "visible", + "Alignment": "alignment", + "WrapText": "wrapText", + "Sortable": "sortable", + "Resizable": "resizable", + "Draggable": "draggable", + "Hidable": "hidable", + "ColumnWidth": "width", + "Size": "size", + "ShowContentAs": "showContentAs", + "ColumnClass": "columnClass", + "Tooltip": "tooltip", +} + +func setColumnPropertyMut(colDoc bson.D, propKeyMap map[string]string, propName string, value any) error { + internalKey := columnPropertyAliases[propName] + if internalKey == "" { + internalKey = propName + } + + props := dGetArrayElements(dGet(colDoc, "Properties")) + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + typePointerID := extractBinaryIDFromDoc(dGet(propDoc, "TypePointer")) + propKey := propKeyMap[typePointerID] + if propKey != internalKey { + continue + } + if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { + strVal := fmt.Sprintf("%v", value) + dSet(valDoc, "PrimitiveValue", strVal) + return nil + } + return fmt.Errorf("column property %q has no Value", propName) + } + return fmt.Errorf("column property %q not found", propName) +} + +func applyPageLevelSetMut(rawData bson.D, prop string, value any) error { + switch prop { + case "Title": + if formCall := dGetDoc(rawData, "FormCall"); formCall != nil { + setTranslatableText(formCall, "Title", value) + } else { + setTranslatableText(rawData, "Title", value) + } + case "Url": + strVal, _ := value.(string) + dSet(rawData, "Url", strVal) + default: + return fmt.Errorf("unsupported page-level property: %s", prop) + } + return nil +} + +func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { + switch propName { + case "Caption": + return setWidgetCaptionMut(widget, value) + case "Content": + return setWidgetContentMut(widget, value) + case "Label": + return setWidgetLabelMut(widget, value) + case "ButtonStyle": + if s, ok := value.(string); ok { + dSet(widget, "ButtonStyle", s) + } + return nil + case "Class": + if appearance := dGetDoc(widget, "Appearance"); appearance != nil { + if s, ok := value.(string); ok { + dSet(appearance, "Class", s) + } + } + return nil + case "Style": + if appearance := dGetDoc(widget, "Appearance"); appearance != nil { + if s, ok := value.(string); ok { + dSet(appearance, "Style", s) + } + } + return nil + case "Editable": + if s, ok := value.(string); ok { + dSet(widget, "Editable", s) + } + return nil + case "Visible": + if s, ok := value.(string); ok { + dSet(widget, "Visible", s) + } else if b, ok := value.(bool); ok { + if b { + dSet(widget, "Visible", "True") + } else { + dSet(widget, "Visible", "False") + } + } + return nil + case "Name": + if s, ok := value.(string); ok { + dSet(widget, "Name", s) + } + return nil + case "Attribute": + return setWidgetAttributeRefMut(widget, value) + default: + // Try as pluggable widget property + return setPluggableWidgetPropertyMut(widget, propName, value) + } +} + +func setWidgetCaptionMut(widget bson.D, value any) error { + caption := dGetDoc(widget, "Caption") + if caption == nil { + setTranslatableText(widget, "Caption", value) + return nil + } + setTranslatableText(caption, "", value) + return nil +} + +func setWidgetContentMut(widget bson.D, value any) error { + strVal, ok := value.(string) + if !ok { + return fmt.Errorf("Content value must be a string") + } + content := dGetDoc(widget, "Content") + if content == nil { + return fmt.Errorf("widget has no Content property") + } + template := dGetDoc(content, "Template") + if template == nil { + return fmt.Errorf("Content has no Template") + } + items := dGetArrayElements(dGet(template, "Items")) + if len(items) > 0 { + if itemDoc, ok := items[0].(bson.D); ok { + dSet(itemDoc, "Text", strVal) + return nil + } + } + return fmt.Errorf("Content.Template has no Items with Text") +} + +func setWidgetLabelMut(widget bson.D, value any) error { + label := dGetDoc(widget, "Label") + if label == nil { + return nil + } + setTranslatableText(label, "Caption", value) + return nil +} + +func setWidgetAttributeRefMut(widget bson.D, value any) error { + attrPath, ok := value.(string) + if !ok { + return fmt.Errorf("Attribute value must be a string") + } + + var attrRefValue any + if strings.Count(attrPath, ".") >= 2 { + attrRefValue = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$AttributeRef"}, + {Key: "Attribute", Value: attrPath}, + {Key: "EntityRef", Value: nil}, + } + } else { + attrRefValue = nil + } + + for i, elem := range widget { + if elem.Key == "AttributeRef" { + widget[i].Value = attrRefValue + return nil + } + } + return fmt.Errorf("widget does not have an AttributeRef property") +} + +func setPluggableWidgetPropertyMut(widget bson.D, propName string, value any) error { + obj := dGetDoc(widget, "Object") + if obj == nil { + return fmt.Errorf("property %q not found (widget has no pluggable Object)", propName) + } + + propTypeKeyMap := make(map[string]string) + if widgetType := dGetDoc(widget, "Type"); widgetType != nil { + if objType := dGetDoc(widgetType, "ObjectType"); objType != nil { + propTypes := dGetArrayElements(dGet(objType, "PropertyTypes")) + for _, pt := range propTypes { + ptDoc, ok := pt.(bson.D) + if !ok { + continue + } + key := dGetString(ptDoc, "PropertyKey") + if key == "" { + continue + } + id := extractBinaryIDFromDoc(dGet(ptDoc, "$ID")) + if id != "" { + propTypeKeyMap[id] = key + } + } + } + } + + props := dGetArrayElements(dGet(obj, "Properties")) + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + typePointerID := extractBinaryIDFromDoc(dGet(propDoc, "TypePointer")) + propKey := propTypeKeyMap[typePointerID] + if propKey != propName { + continue + } + if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { + switch v := value.(type) { + case string: + dSet(valDoc, "PrimitiveValue", v) + case bool: + if v { + dSet(valDoc, "PrimitiveValue", "yes") + } else { + dSet(valDoc, "PrimitiveValue", "no") + } + case int: + dSet(valDoc, "PrimitiveValue", fmt.Sprintf("%d", v)) + case float64: + dSet(valDoc, "PrimitiveValue", fmt.Sprintf("%g", v)) + default: + dSet(valDoc, "PrimitiveValue", fmt.Sprintf("%v", v)) + } + return nil + } + return fmt.Errorf("property %q has no Value map", propName) + } + return fmt.Errorf("pluggable property %q not found", propName) +} + +// setTranslatableText sets a translatable text value in BSON. +func setTranslatableText(parent bson.D, key string, value any) { + strVal, ok := value.(string) + if !ok { + return + } + + target := parent + if key != "" { + if nested := dGetDoc(parent, key); nested != nil { + target = nested + } else { + dSet(parent, key, strVal) + return + } + } + + translations := dGetArrayElements(dGet(target, "Translations")) + if len(translations) > 0 { + if tDoc, ok := translations[0].(bson.D); ok { + dSet(tDoc, "Text", strVal) + return + } + } + dSet(target, "Text", strVal) +} + +// --------------------------------------------------------------------------- +// Widget serialization helpers +// --------------------------------------------------------------------------- + +func serializeWidgets(widgets []pages.Widget) ([]any, error) { + var result []any + for _, w := range widgets { + bsonDoc := mpr.SerializeWidget(w) + if bsonDoc == nil { + continue + } + result = append(result, bsonDoc) + } + return result, nil +} + +// serializeDataSourceBson converts a pages.DataSource to a BSON document for widget-level DataSource fields. +func serializeDataSourceBson(ds pages.DataSource) bson.D { + switch d := ds.(type) { + case *pages.ListenToWidgetSource: + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$ListenTargetSource"}, + {Key: "ListenTarget", Value: d.WidgetName}, + } + case *pages.DatabaseSource: + var entityRef any + if d.EntityName != "" { + entityRef = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, + {Key: "Entity", Value: d.EntityName}, + } + } + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$DataViewSource"}, + {Key: "EntityRef", Value: entityRef}, + {Key: "ForceFullObjects", Value: false}, + {Key: "SourceVariable", Value: nil}, + } + case *pages.MicroflowSource: + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$MicroflowSource"}, + {Key: "MicroflowSettings", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$MicroflowSettings"}, + {Key: "Asynchronous", Value: false}, + {Key: "ConfirmationInfo", Value: nil}, + {Key: "FormValidations", Value: "All"}, + {Key: "Microflow", Value: d.Microflow}, + {Key: "ParameterMappings", Value: bson.A{int32(3)}}, + {Key: "ProgressBar", Value: "None"}, + {Key: "ProgressMessage", Value: nil}, + }}, + } + case *pages.NanoflowSource: + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$NanoflowSource"}, + {Key: "NanoflowSettings", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$NanoflowSettings"}, + {Key: "Nanoflow", Value: d.Nanoflow}, + {Key: "ParameterMappings", Value: bson.A{int32(3)}}, + }}, + } + default: + return nil + } +} + +// mdlTypeToBsonType converts an MDL type name to a BSON DataTypes$* type string. +func mdlTypeToBsonType(mdlType string) string { + switch strings.ToLower(mdlType) { + case "boolean": + return "DataTypes$BooleanType" + case "string": + return "DataTypes$StringType" + case "integer": + return "DataTypes$IntegerType" + case "long": + return "DataTypes$LongType" + case "decimal": + return "DataTypes$DecimalType" + case "datetime", "date": + return "DataTypes$DateTimeType" + default: + return "DataTypes$ObjectType" + } +} diff --git a/mdl/executor/alter_page_test.go b/mdl/backend/mpr/page_mutator_test.go similarity index 80% rename from mdl/executor/alter_page_test.go rename to mdl/backend/mpr/page_mutator_test.go index 0bf81196..a7629be5 100644 --- a/mdl/executor/alter_page_test.go +++ b/mdl/backend/mpr/page_mutator_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -package executor +package mprbackend import ( "testing" @@ -8,7 +8,8 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" - "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/model" ) // Helper to build a minimal raw BSON page structure for testing. @@ -89,15 +90,16 @@ func TestFindBsonWidget_NotFound(t *testing.T) { } } -func TestApplyDropWidget_Single(t *testing.T) { +func TestDropWidget_Single(t *testing.T) { w1 := makeWidget("txtName", "Pages$TextBox") w2 := makeWidget("txtEmail", "Pages$TextBox") w3 := makeWidget("txtPhone", "Pages$TextBox") rawData := makeRawPage(w1, w2, w3) - op := &ast.DropWidgetOp{Targets: []ast.WidgetRef{{Widget: "txtEmail"}}} - if err := applyDropWidget(rawData, op); err != nil { - t.Fatalf("applyDropWidget failed: %v", err) + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidget} + refs := []backend.WidgetRef{{Widget: "txtEmail"}} + if err := m.DropWidget(refs); err != nil { + t.Fatalf("DropWidget failed: %v", err) } // Verify txtEmail was removed @@ -120,15 +122,16 @@ func TestApplyDropWidget_Single(t *testing.T) { } } -func TestApplyDropWidget_Multiple(t *testing.T) { +func TestDropWidget_Multiple(t *testing.T) { w1 := makeWidget("a", "Pages$TextBox") w2 := makeWidget("b", "Pages$TextBox") w3 := makeWidget("c", "Pages$TextBox") rawData := makeRawPage(w1, w2, w3) - op := &ast.DropWidgetOp{Targets: []ast.WidgetRef{{Widget: "a"}, {Widget: "c"}}} - if err := applyDropWidget(rawData, op); err != nil { - t.Fatalf("applyDropWidget failed: %v", err) + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidget} + refs := []backend.WidgetRef{{Widget: "a"}, {Widget: "c"}} + if err := m.DropWidget(refs); err != nil { + t.Fatalf("DropWidget failed: %v", err) } formCall := dGetDoc(rawData, "FormCall") @@ -146,29 +149,31 @@ func TestApplyDropWidget_Multiple(t *testing.T) { } } -func TestApplyDropWidget_NotFound(t *testing.T) { +func TestDropWidget_NotFound(t *testing.T) { w1 := makeWidget("txtName", "Pages$TextBox") rawData := makeRawPage(w1) - op := &ast.DropWidgetOp{Targets: []ast.WidgetRef{{Widget: "nonexistent"}}} - err := applyDropWidget(rawData, op) + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidget} + refs := []backend.WidgetRef{{Widget: "nonexistent"}} + err := m.DropWidget(refs) if err == nil { t.Fatal("Expected error for nonexistent widget") } } -func TestApplyDropWidget_Nested(t *testing.T) { +func TestDropWidget_Nested(t *testing.T) { inner1 := makeWidget("txtInner1", "Pages$TextBox") inner2 := makeWidget("txtInner2", "Pages$TextBox") container := makeContainerWidget("ctn1", inner1, inner2) rawData := makeRawPage(container) - op := &ast.DropWidgetOp{Targets: []ast.WidgetRef{{Widget: "txtInner1"}}} - if err := applyDropWidget(rawData, op); err != nil { - t.Fatalf("applyDropWidget failed: %v", err) + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidget} + refs := []backend.WidgetRef{{Widget: "txtInner1"}} + if err := m.DropWidget(refs); err != nil { + t.Fatalf("DropWidget failed: %v", err) } - // Verify txtInner1 was removed from container + // Verify txtInner1 was removed result := findBsonWidget(rawData, "txtInner1") if result != nil { t.Error("txtInner1 should have been removed") @@ -181,18 +186,13 @@ func TestApplyDropWidget_Nested(t *testing.T) { } } -func TestApplySetProperty_Name(t *testing.T) { +func TestSetWidgetProperty_Name(t *testing.T) { w1 := makeWidget("txtOld", "Pages$TextBox") rawData := makeRawPage(w1) - op := &ast.SetPropertyOp{ - Target: ast.WidgetRef{Widget: "txtOld"}, - Properties: map[string]interface{}{ - "Name": "txtNew", - }, - } - if err := applySetProperty(rawData, op); err != nil { - t.Fatalf("applySetProperty failed: %v", err) + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("txtOld", "Name", "txtNew"); err != nil { + t.Fatalf("SetWidgetProperty failed: %v", err) } // Verify name was changed @@ -202,7 +202,7 @@ func TestApplySetProperty_Name(t *testing.T) { } } -func TestApplySetProperty_ButtonStyle(t *testing.T) { +func TestSetWidgetProperty_ButtonStyle(t *testing.T) { w1 := bson.D{ {Key: "$Type", Value: "Pages$ActionButton"}, {Key: "Name", Value: "btnSave"}, @@ -210,14 +210,9 @@ func TestApplySetProperty_ButtonStyle(t *testing.T) { } rawData := makeRawPage(w1) - op := &ast.SetPropertyOp{ - Target: ast.WidgetRef{Widget: "btnSave"}, - Properties: map[string]interface{}{ - "ButtonStyle": "Success", - }, - } - if err := applySetProperty(rawData, op); err != nil { - t.Fatalf("applySetProperty failed: %v", err) + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("btnSave", "ButtonStyle", "Success"); err != nil { + t.Fatalf("SetWidgetProperty failed: %v", err) } result := findBsonWidget(rawData, "btnSave") @@ -229,25 +224,18 @@ func TestApplySetProperty_ButtonStyle(t *testing.T) { } } -func TestApplySetProperty_WidgetNotFound(t *testing.T) { +func TestSetWidgetProperty_WidgetNotFound(t *testing.T) { w1 := makeWidget("txtName", "Pages$TextBox") rawData := makeRawPage(w1) - op := &ast.SetPropertyOp{ - Target: ast.WidgetRef{Widget: "nonexistent"}, - Properties: map[string]interface{}{ - "Name": "new", - }, - } - err := applySetProperty(rawData, op) + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidget} + err := m.SetWidgetProperty("nonexistent", "Name", "new") if err == nil { t.Fatal("Expected error for nonexistent widget") } } -func TestApplySetProperty_PluggableWidget(t *testing.T) { - // Pluggable widget properties are identified by TypePointer referencing - // a PropertyType entry in Type.ObjectType.PropertyTypes, NOT by a "Key" field. +func TestSetWidgetProperty_PluggableWidget(t *testing.T) { propTypeID := primitive.Binary{Subtype: 0x04, Data: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}} w1 := bson.D{ {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, @@ -256,7 +244,7 @@ func TestApplySetProperty_PluggableWidget(t *testing.T) { {Key: "$Type", Value: "CustomWidgets$CustomWidgetType"}, {Key: "ObjectType", Value: bson.D{ {Key: "PropertyTypes", Value: bson.A{ - int32(2), // type marker + int32(2), bson.D{ {Key: "$ID", Value: propTypeID}, {Key: "PropertyKey", Value: "showLabel"}, @@ -266,7 +254,7 @@ func TestApplySetProperty_PluggableWidget(t *testing.T) { }}, {Key: "Object", Value: bson.D{ {Key: "Properties", Value: bson.A{ - int32(2), // type marker + int32(2), bson.D{ {Key: "TypePointer", Value: propTypeID}, {Key: "Value", Value: bson.D{ @@ -278,14 +266,9 @@ func TestApplySetProperty_PluggableWidget(t *testing.T) { } rawData := makeRawPage(w1) - op := &ast.SetPropertyOp{ - Target: ast.WidgetRef{Widget: "cb1"}, - Properties: map[string]interface{}{ - "showLabel": false, - }, - } - if err := applySetProperty(rawData, op); err != nil { - t.Fatalf("applySetProperty failed: %v", err) + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("cb1", "showLabel", false); err != nil { + t.Fatalf("SetWidgetProperty failed: %v", err) } result := findBsonWidget(rawData, "cb1") @@ -374,9 +357,8 @@ func TestFindBsonWidget_LayoutGrid(t *testing.T) { // Snippet BSON tests // ============================================================================ -// Helper to build a minimal raw BSON snippet structure (Studio Pro format). func makeRawSnippet(widgets ...bson.D) bson.D { - widgetArr := bson.A{int32(2)} // type marker + widgetArr := bson.A{int32(2)} for _, w := range widgets { widgetArr = append(widgetArr, w) } @@ -385,9 +367,8 @@ func makeRawSnippet(widgets ...bson.D) bson.D { } } -// Helper to build a minimal raw BSON snippet structure (mxcli format). func makeRawSnippetMxcli(widgets ...bson.D) bson.D { - widgetArr := bson.A{int32(2)} // type marker + widgetArr := bson.A{int32(2)} for _, w := range widgets { widgetArr = append(widgetArr, w) } @@ -443,14 +424,15 @@ func TestFindBsonWidgetInSnippet_NotFound(t *testing.T) { } } -func TestApplyDropWidget_Snippet(t *testing.T) { +func TestDropWidget_Snippet(t *testing.T) { w1 := makeWidget("txtName", "Pages$TextBox") w2 := makeWidget("txtEmail", "Pages$TextBox") rawData := makeRawSnippet(w1, w2) - op := &ast.DropWidgetOp{Targets: []ast.WidgetRef{{Widget: "txtEmail"}}} - if err := applyDropWidgetWith(rawData, op, findBsonWidgetInSnippet); err != nil { - t.Fatalf("applyDropWidgetWith failed: %v", err) + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidgetInSnippet} + refs := []backend.WidgetRef{{Widget: "txtEmail"}} + if err := m.DropWidget(refs); err != nil { + t.Fatalf("DropWidget failed: %v", err) } // Verify txtEmail was removed @@ -464,7 +446,7 @@ func TestApplyDropWidget_Snippet(t *testing.T) { } } -func TestApplySetProperty_Snippet(t *testing.T) { +func TestSetWidgetProperty_Snippet(t *testing.T) { w1 := bson.D{ {Key: "$Type", Value: "Pages$ActionButton"}, {Key: "Name", Value: "btnAction"}, @@ -472,14 +454,9 @@ func TestApplySetProperty_Snippet(t *testing.T) { } rawData := makeRawSnippet(w1) - op := &ast.SetPropertyOp{ - Target: ast.WidgetRef{Widget: "btnAction"}, - Properties: map[string]interface{}{ - "ButtonStyle": "Danger", - }, - } - if err := applySetPropertyWith(rawData, op, findBsonWidgetInSnippet); err != nil { - t.Fatalf("applySetPropertyWith failed: %v", err) + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidgetInSnippet} + if err := m.SetWidgetProperty("btnAction", "ButtonStyle", "Danger"); err != nil { + t.Fatalf("SetWidgetProperty failed: %v", err) } result := findBsonWidgetInSnippet(rawData, "btnAction") @@ -519,7 +496,7 @@ func TestFindBsonWidget_DataViewFooter(t *testing.T) { } // ============================================================================ -// Page context tree tests (#157) +// Page context tree tests // ============================================================================ func makeWidgetWithID(name string, typeName string, id primitive.Binary) bson.D { @@ -539,7 +516,7 @@ func makeBsonID(b byte) primitive.Binary { func TestExtractPageParamsFromBSON_EntityParams(t *testing.T) { rawData := bson.D{ {Key: "Parameters", Value: bson.A{ - int32(2), // type marker + int32(2), bson.D{ {Key: "$ID", Value: makeBsonID(0x01)}, {Key: "$Type", Value: "Forms$PageParameter"}, @@ -700,3 +677,48 @@ func TestExtractWidgetScopeFromBSON_Nil(t *testing.T) { t.Error("Expected empty scope for nil input") } } + +func TestFindWidget(t *testing.T) { + w1 := makeWidget("txtName", "Pages$TextBox") + rawData := makeRawPage(w1) + + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidget} + if !m.FindWidget("txtName") { + t.Error("Expected FindWidget to return true for existing widget") + } + if m.FindWidget("nonexistent") { + t.Error("Expected FindWidget to return false for nonexistent widget") + } +} + +func TestParamScope(t *testing.T) { + rawData := bson.D{ + {Key: "Parameters", Value: bson.A{ + int32(2), + bson.D{ + {Key: "$ID", Value: makeBsonID(0x01)}, + {Key: "$Type", Value: "Forms$PageParameter"}, + {Key: "Name", Value: "Customer"}, + {Key: "ParameterType", Value: bson.D{ + {Key: "$ID", Value: makeBsonID(0x02)}, + {Key: "$Type", Value: "DataTypes$ObjectType"}, + {Key: "Entity", Value: "MyModule.Customer"}, + }}, + }, + }}, + } + + m := &mprPageMutator{rawData: rawData, widgetFinder: findBsonWidget} + ids, names := m.ParamScope() + + if len(ids) != 1 { + t.Fatalf("Expected 1 param, got %d", len(ids)) + } + if names["Customer"] != "MyModule.Customer" { + t.Errorf("Expected MyModule.Customer, got %q", names["Customer"]) + } + // Verify ID is a valid model.ID (non-empty) + if ids["Customer"] == model.ID("") { + t.Error("Expected non-empty ID") + } +} diff --git a/mdl/backend/mpr/widget_builder.go b/mdl/backend/mpr/widget_builder.go new file mode 100644 index 00000000..f9950c72 --- /dev/null +++ b/mdl/backend/mpr/widget_builder.go @@ -0,0 +1,1007 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mprbackend + +import ( + "encoding/hex" + "fmt" + "log" + "regexp" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/bsonutil" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/mpr" + "github.com/mendixlabs/mxcli/sdk/pages" + "github.com/mendixlabs/mxcli/sdk/widgets" +) + +// --------------------------------------------------------------------------- +// mprWidgetObjectBuilder — implements backend.WidgetObjectBuilder +// --------------------------------------------------------------------------- + +type mprWidgetObjectBuilder struct { + embeddedType bson.D + object bson.D // the mutable widget object BSON + propertyTypeIDs map[string]pages.PropertyTypeIDEntry + objectTypeID string +} + +var _ backend.WidgetObjectBuilder = (*mprWidgetObjectBuilder)(nil) + +// --------------------------------------------------------------------------- +// WidgetBuilderBackend — MprBackend methods +// --------------------------------------------------------------------------- + +// LoadWidgetTemplate loads a widget template by ID and returns a builder. +func (b *MprBackend) LoadWidgetTemplate(widgetID string, projectPath string) (backend.WidgetObjectBuilder, error) { + embeddedType, embeddedObject, embeddedIDs, objectTypeID, err := + widgets.GetTemplateFullBSON(widgetID, types.GenerateID, projectPath) + if err != nil { + return nil, err + } + if embeddedType == nil || embeddedObject == nil { + return nil, nil + } + + propertyTypeIDs := convertPropertyTypeIDs(embeddedIDs) + + return &mprWidgetObjectBuilder{ + embeddedType: embeddedType, + object: embeddedObject, + propertyTypeIDs: propertyTypeIDs, + objectTypeID: objectTypeID, + }, nil +} + +// SerializeWidgetToOpaque converts a domain Widget to opaque BSON form. +func (b *MprBackend) SerializeWidgetToOpaque(w pages.Widget) any { + return mpr.SerializeWidget(w) +} + +// SerializeDataSourceToOpaque converts a domain DataSource to opaque BSON form. +func (b *MprBackend) SerializeDataSourceToOpaque(ds pages.DataSource) any { + return mpr.SerializeCustomWidgetDataSource(ds) +} + +// BuildCreateAttributeObject creates an attribute object for filter widgets. +func (b *MprBackend) BuildCreateAttributeObject(attributePath string, objectTypeID, propertyTypeID, valueTypeID string) (any, error) { + return createAttributeObject(attributePath, objectTypeID, propertyTypeID, valueTypeID) +} + +// --------------------------------------------------------------------------- +// WidgetObjectBuilder — property operations +// --------------------------------------------------------------------------- + +func (ob *mprWidgetObjectBuilder) SetAttribute(propertyKey string, attributePath string) { + if attributePath == "" { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + return setAttributeRef(val, attributePath) + }) +} + +func (ob *mprWidgetObjectBuilder) SetAssociation(propertyKey string, assocPath string, entityName string) { + if assocPath == "" { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + return setAssociationRef(val, assocPath, entityName) + }) +} + +func (ob *mprWidgetObjectBuilder) SetPrimitive(propertyKey string, value string) { + if value == "" { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + return setPrimitiveValue(val, value) + }) +} + +func (ob *mprWidgetObjectBuilder) SetSelection(propertyKey string, value string) { + if value == "" { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "Selection" { + result = append(result, bson.E{Key: "Selection", Value: value}) + } else { + result = append(result, elem) + } + } + return result + }) +} + +func (ob *mprWidgetObjectBuilder) SetExpression(propertyKey string, value string) { + if value == "" { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "Expression" { + result = append(result, bson.E{Key: "Expression", Value: value}) + } else { + result = append(result, elem) + } + } + return result + }) +} + +func (ob *mprWidgetObjectBuilder) SetDataSource(propertyKey string, ds pages.DataSource) { + if ds == nil { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + return setDataSource(val, ds) + }) +} + +func (ob *mprWidgetObjectBuilder) SetChildWidgets(propertyKey string, children []pages.Widget) { + if len(children) == 0 { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + return setChildWidgets(val, children) + }) +} + +func (ob *mprWidgetObjectBuilder) SetTextTemplate(propertyKey string, text string) { + if text == "" { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + return setTextTemplateValue(val, text) + }) +} + +func (ob *mprWidgetObjectBuilder) SetTextTemplateWithParams(propertyKey string, text string, entityContext string) { + if text == "" { + return + } + tmpl := createClientTemplateBSONWithParams(text, entityContext) + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "TextTemplate" { + result = append(result, bson.E{Key: "TextTemplate", Value: tmpl}) + } else { + result = append(result, elem) + } + } + return result + }) +} + +func (ob *mprWidgetObjectBuilder) SetAction(propertyKey string, action pages.ClientAction) { + if action == nil { + return + } + actionBSON := mpr.SerializeClientAction(action) + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "Action" { + result = append(result, bson.E{Key: "Action", Value: actionBSON}) + } else { + result = append(result, elem) + } + } + return result + }) +} + +func (ob *mprWidgetObjectBuilder) SetAttributeObjects(propertyKey string, attributePaths []string) { + if len(attributePaths) == 0 { + return + } + + entry, ok := ob.propertyTypeIDs[propertyKey] + if !ok || entry.ObjectTypeID == "" { + return + } + + nestedEntry, ok := entry.NestedPropertyIDs["attribute"] + if !ok { + return + } + + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + objects := make([]any, 0, len(attributePaths)+1) + objects = append(objects, int32(2)) // BSON array version marker + + for _, attrPath := range attributePaths { + attrObj, err := createAttributeObject(attrPath, entry.ObjectTypeID, nestedEntry.PropertyTypeID, nestedEntry.ValueTypeID) + if err != nil { + log.Printf("warning: skipping attribute %s: %v", attrPath, err) + continue + } + objects = append(objects, attrObj) + } + + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "Objects" { + result = append(result, bson.E{Key: "Objects", Value: bson.A(objects)}) + } else { + result = append(result, elem) + } + } + return result + }) +} + +// --------------------------------------------------------------------------- +// Template metadata +// --------------------------------------------------------------------------- + +func (ob *mprWidgetObjectBuilder) PropertyTypeIDs() map[string]pages.PropertyTypeIDEntry { + return ob.propertyTypeIDs +} + +// --------------------------------------------------------------------------- +// Object list defaults +// --------------------------------------------------------------------------- + +func (ob *mprWidgetObjectBuilder) EnsureRequiredObjectLists() { + ob.object = ensureRequiredObjectLists(ob.object, ob.propertyTypeIDs) +} + +// --------------------------------------------------------------------------- +// Gallery-specific +// --------------------------------------------------------------------------- + +func (ob *mprWidgetObjectBuilder) CloneGallerySelectionProperty(propertyKey string, selectionMode string) { + propEntry, ok := ob.propertyTypeIDs[propertyKey] + if !ok { + return + } + + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + // The val here is the WidgetValue — but we need the WidgetProperty level. + // CloneGallerySelectionProperty works on the Properties array directly. + return val + }) + + // Actually need to work at the Properties array level: find the property, + // clone it with new IDs and updated Selection, then append. + result := make(bson.D, 0, len(ob.object)) + for _, elem := range ob.object { + if elem.Key == "Properties" { + if arr, ok := elem.Value.(bson.A); ok { + newArr := make(bson.A, len(arr)) + copy(newArr, arr) + // Find the matching property and clone it + for _, item := range arr { + if prop, ok := item.(bson.D); ok { + if matchesTypePointer(prop, propEntry.PropertyTypeID) { + cloned := buildGallerySelectionProperty(prop, selectionMode) + newArr = append(newArr, cloned) + break + } + } + } + result = append(result, bson.E{Key: "Properties", Value: newArr}) + continue + } + } + result = append(result, elem) + } + ob.object = result +} + +// --------------------------------------------------------------------------- +// Finalize +// --------------------------------------------------------------------------- + +func (ob *mprWidgetObjectBuilder) Finalize(id model.ID, name string, label string, editable string) *pages.CustomWidget { + return &pages.CustomWidget{ + BaseWidget: pages.BaseWidget{ + BaseElement: model.BaseElement{ + ID: id, + TypeName: "CustomWidgets$CustomWidget", + }, + Name: name, + }, + Label: label, + Editable: editable, + RawType: ob.embeddedType, + RawObject: ob.object, + PropertyTypeIDMap: ob.propertyTypeIDs, + ObjectTypeID: ob.objectTypeID, + } +} + +// =========================================================================== +// Package-level helpers (moved from executor) +// =========================================================================== + +// --------------------------------------------------------------------------- +// Property update core +// --------------------------------------------------------------------------- + +// updateWidgetPropertyValue finds and updates a specific property value in a WidgetObject. +func updateWidgetPropertyValue(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, updateFn func(bson.D) bson.D) bson.D { + propEntry, ok := propTypeIDs[propertyKey] + if !ok { + return obj + } + + result := make(bson.D, 0, len(obj)) + for _, elem := range obj { + if elem.Key == "Properties" { + if arr, ok := elem.Value.(bson.A); ok { + result = append(result, bson.E{Key: "Properties", Value: updatePropertyInArray(arr, propEntry.PropertyTypeID, updateFn)}) + continue + } + } + result = append(result, elem) + } + return result +} + +// updatePropertyInArray finds a property by TypePointer and updates its value. +func updatePropertyInArray(arr bson.A, propertyTypeID string, updateFn func(bson.D) bson.D) bson.A { + result := make(bson.A, len(arr)) + matched := false + for i, item := range arr { + if prop, ok := item.(bson.D); ok { + if matchesTypePointer(prop, propertyTypeID) { + result[i] = updatePropertyValue(prop, updateFn) + matched = true + } else { + result[i] = item + } + } else { + result[i] = item + } + } + if !matched { + log.Printf("WARNING: updatePropertyInArray: no match for TypePointer %s in %d properties", propertyTypeID, len(arr)-1) + } + return result +} + +// matchesTypePointer checks if a WidgetProperty has the given TypePointer. +func matchesTypePointer(prop bson.D, propertyTypeID string) bool { + normalizedTarget := strings.ReplaceAll(propertyTypeID, "-", "") + for _, elem := range prop { + if elem.Key == "TypePointer" { + switch v := elem.Value.(type) { + case primitive.Binary: + propID := strings.ReplaceAll(types.BlobToUUID(v.Data), "-", "") + return propID == normalizedTarget + case []byte: + propID := strings.ReplaceAll(types.BlobToUUID(v), "-", "") + if propID == normalizedTarget { + return true + } + rawHex := fmt.Sprintf("%x", v) + return rawHex == normalizedTarget + } + } + } + return false +} + +// updatePropertyValue updates the Value field in a WidgetProperty. +func updatePropertyValue(prop bson.D, updateFn func(bson.D) bson.D) bson.D { + result := make(bson.D, 0, len(prop)) + for _, elem := range prop { + if elem.Key == "Value" { + if val, ok := elem.Value.(bson.D); ok { + result = append(result, bson.E{Key: "Value", Value: updateFn(val)}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + return result +} + +// --------------------------------------------------------------------------- +// Value setters +// --------------------------------------------------------------------------- + +func setPrimitiveValue(val bson.D, value string) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "PrimitiveValue" { + result = append(result, bson.E{Key: "PrimitiveValue", Value: value}) + } else { + result = append(result, elem) + } + } + return result +} + +func setDataSource(val bson.D, ds pages.DataSource) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "DataSource" { + result = append(result, bson.E{Key: "DataSource", Value: mpr.SerializeCustomWidgetDataSource(ds)}) + } else { + result = append(result, elem) + } + } + return result +} + +func setAssociationRef(val bson.D, assocPath string, entityName string) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "EntityRef" && entityName != "" { + result = append(result, bson.E{Key: "EntityRef", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$IndirectEntityRef"}, + {Key: "Steps", Value: bson.A{ + int32(2), + bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$EntityRefStep"}, + {Key: "Association", Value: assocPath}, + {Key: "DestinationEntity", Value: entityName}, + }, + }}, + }}) + } else { + result = append(result, elem) + } + } + return result +} + +func setAttributeRef(val bson.D, attrPath string) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "AttributeRef" { + if strings.Count(attrPath, ".") >= 2 { + result = append(result, bson.E{Key: "AttributeRef", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$AttributeRef"}, + {Key: "Attribute", Value: attrPath}, + {Key: "EntityRef", Value: nil}, + }}) + } else { + result = append(result, bson.E{Key: "AttributeRef", Value: nil}) + } + } else { + result = append(result, elem) + } + } + return result +} + +func setChildWidgets(val bson.D, children []pages.Widget) bson.D { + widgetsArr := bson.A{int32(2)} + for _, w := range children { + widgetsArr = append(widgetsArr, mpr.SerializeWidget(w)) + } + + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "Widgets" { + result = append(result, bson.E{Key: "Widgets", Value: widgetsArr}) + } else { + result = append(result, elem) + } + } + return result +} + +func setTextTemplateValue(val bson.D, text string) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "TextTemplate" { + if tmpl, ok := elem.Value.(bson.D); ok && tmpl != nil { + result = append(result, bson.E{Key: "TextTemplate", Value: updateTemplateText(tmpl, text)}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + return result +} + +func updateTemplateText(tmpl bson.D, text string) bson.D { + result := make(bson.D, 0, len(tmpl)) + for _, elem := range tmpl { + if elem.Key == "Template" { + if template, ok := elem.Value.(bson.D); ok { + updated := make(bson.D, 0, len(template)) + for _, tElem := range template { + if tElem.Key == "Items" { + updated = append(updated, bson.E{Key: "Items", Value: bson.A{ + int32(3), + bson.D{ + {Key: "$ID", Value: bsonutil.IDToBsonBinary(types.GenerateID())}, + {Key: "$Type", Value: "Texts$Translation"}, + {Key: "LanguageCode", Value: "en_US"}, + {Key: "Text", Value: text}, + }, + }}) + } else { + updated = append(updated, tElem) + } + } + result = append(result, bson.E{Key: "Template", Value: updated}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + return result +} + +// --------------------------------------------------------------------------- +// Template helpers +// --------------------------------------------------------------------------- + +func createClientTemplateBSONWithParams(text string, entityContext string) bson.D { + re := regexp.MustCompile(`\{([A-Za-z][A-Za-z0-9_]*)\}`) + matches := re.FindAllStringSubmatchIndex(text, -1) + + if len(matches) == 0 { + return createDefaultClientTemplateBSON(text) + } + + // Collect attribute names (skip numeric placeholders) + var attrNames []string + for i := 0; i < len(matches); i++ { + match := matches[i] + attrName := text[match[2]:match[3]] + if _, err := fmt.Sscanf(attrName, "%d", new(int)); err == nil { + continue + } + attrNames = append(attrNames, attrName) + } + + paramText := re.ReplaceAllStringFunc(text, func(s string) string { + name := s[1 : len(s)-1] + if _, err := fmt.Sscanf(name, "%d", new(int)); err == nil { + return s + } + for i, an := range attrNames { + if an == name { + return fmt.Sprintf("{%d}", i+1) + } + } + return s + }) + + // Build parameters BSON + params := bson.A{int32(2)} + for _, attrName := range attrNames { + attrPath := attrName + if entityContext != "" && !strings.Contains(attrName, ".") { + attrPath = entityContext + "." + attrName + } + params = append(params, bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "Forms$ClientTemplateParameter"}, + {Key: "AttributeRef", Value: bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "DomainModels$AttributeRef"}, + {Key: "Attribute", Value: attrPath}, + {Key: "EntityRef", Value: nil}, + }}, + {Key: "Expression", Value: ""}, + {Key: "FormattingInfo", Value: bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "Forms$FormattingInfo"}, + {Key: "CustomDateFormat", Value: ""}, + {Key: "DateFormat", Value: "Date"}, + {Key: "DecimalPrecision", Value: int64(2)}, + {Key: "EnumFormat", Value: "Text"}, + {Key: "GroupDigits", Value: false}, + {Key: "TimeFormat", Value: "HoursMinutes"}, + }}, + {Key: "SourceVariable", Value: nil}, + }) + } + + makeText := func(t string) bson.D { + return bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Items", Value: bson.A{int32(3), bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "Texts$Translation"}, + {Key: "LanguageCode", Value: "en_US"}, + {Key: "Text", Value: t}, + }}}, + } + } + + return bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "Forms$ClientTemplate"}, + {Key: "Fallback", Value: makeText(paramText)}, + {Key: "Parameters", Value: params}, + {Key: "Template", Value: makeText(paramText)}, + } +} + +func createDefaultClientTemplateBSON(text string) bson.D { + makeText := func(t string) bson.D { + return bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Items", Value: bson.A{int32(3), bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "Texts$Translation"}, + {Key: "LanguageCode", Value: "en_US"}, + {Key: "Text", Value: t}, + }}}, + } + } + return bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "Forms$ClientTemplate"}, + {Key: "Fallback", Value: makeText(text)}, + {Key: "Parameters", Value: bson.A{int32(2)}}, + {Key: "Template", Value: makeText(text)}, + } +} + +// --------------------------------------------------------------------------- +// ID / binary helpers +// --------------------------------------------------------------------------- + +func generateBinaryID() []byte { + return hexIDToBlob(types.GenerateID()) +} + +func hexIDToBlob(hexStr string) []byte { + hexStr = strings.ReplaceAll(hexStr, "-", "") + data, err := hex.DecodeString(hexStr) + if err != nil || len(data) != 16 { + return data + } + data[0], data[1], data[2], data[3] = data[3], data[2], data[1], data[0] + data[4], data[5] = data[5], data[4] + data[6], data[7] = data[7], data[6] + return data +} + +func hexToBytes(hexStr string) []byte { + clean := strings.ReplaceAll(hexStr, "-", "") + if len(clean) != 32 { + return nil + } + + decoded := make([]byte, 16) + for i := range 16 { + decoded[i] = hexByte(clean[i*2])<<4 | hexByte(clean[i*2+1]) + } + + blob := make([]byte, 16) + blob[0] = decoded[3] + blob[1] = decoded[2] + blob[2] = decoded[1] + blob[3] = decoded[0] + blob[4] = decoded[5] + blob[5] = decoded[4] + blob[6] = decoded[7] + blob[7] = decoded[6] + copy(blob[8:], decoded[8:]) + + return blob +} + +func hexByte(c byte) byte { + switch { + case c >= '0' && c <= '9': + return c - '0' + case c >= 'a' && c <= 'f': + return c - 'a' + 10 + case c >= 'A' && c <= 'F': + return c - 'A' + 10 + } + return 0 +} + +func bytesToHex(b []byte) string { + if len(b) != 16 { + if len(b) > 1024 { + return "" + } + const hexChars = "0123456789abcdef" + result := make([]byte, len(b)*2) + for i, v := range b { + result[i*2] = hexChars[v>>4] + result[i*2+1] = hexChars[v&0x0f] + } + return string(result) + } + + return fmt.Sprintf("%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", + b[3], b[2], b[1], b[0], + b[5], b[4], + b[7], b[6], + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]) +} + +// --------------------------------------------------------------------------- +// Property type ID conversion +// --------------------------------------------------------------------------- + +func convertPropertyTypeIDs(src map[string]widgets.PropertyTypeIDEntry) map[string]pages.PropertyTypeIDEntry { + result := make(map[string]pages.PropertyTypeIDEntry) + for k, v := range src { + entry := pages.PropertyTypeIDEntry{ + PropertyTypeID: v.PropertyTypeID, + ValueTypeID: v.ValueTypeID, + DefaultValue: v.DefaultValue, + ValueType: v.ValueType, + Required: v.Required, + ObjectTypeID: v.ObjectTypeID, + } + if len(v.NestedPropertyIDs) > 0 { + entry.NestedPropertyIDs = convertPropertyTypeIDs(v.NestedPropertyIDs) + } + result[k] = entry + } + return result +} + +// --------------------------------------------------------------------------- +// Default object lists +// --------------------------------------------------------------------------- + +func ensureRequiredObjectLists(obj bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry) bson.D { + for propKey, entry := range propertyTypeIDs { + if entry.ObjectTypeID == "" || len(entry.NestedPropertyIDs) == 0 { + continue + } + if !entry.Required { + hasNestedDS := false + for _, nested := range entry.NestedPropertyIDs { + if nested.ValueType == "DataSource" { + hasNestedDS = true + break + } + } + if hasNestedDS { + continue + } + } + hasRequiredAttr := false + for _, nested := range entry.NestedPropertyIDs { + if nested.Required && nested.ValueType == "Attribute" { + hasRequiredAttr = true + break + } + } + if hasRequiredAttr { + continue + } + obj = updateWidgetPropertyValue(obj, propertyTypeIDs, propKey, func(val bson.D) bson.D { + for _, elem := range val { + if elem.Key == "Objects" { + if arr, ok := elem.Value.(bson.A); ok && len(arr) <= 1 { + defaultObj := createDefaultWidgetObject(entry.ObjectTypeID, entry.NestedPropertyIDs) + newArr := bson.A{int32(2), defaultObj} + result := make(bson.D, 0, len(val)) + for _, e := range val { + if e.Key == "Objects" { + result = append(result, bson.E{Key: "Objects", Value: newArr}) + } else { + result = append(result, e) + } + } + return result + } + } + } + return val + }) + } + return obj +} + +func createDefaultWidgetObject(objectTypeID string, nestedProps map[string]pages.PropertyTypeIDEntry) bson.D { + propsArr := bson.A{int32(2)} + for _, entry := range nestedProps { + prop := createDefaultWidgetProperty(entry) + propsArr = append(propsArr, prop) + } + return bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, + {Key: "TypePointer", Value: hexIDToBlob(objectTypeID)}, + {Key: "Properties", Value: propsArr}, + } +} + +func createDefaultWidgetProperty(entry pages.PropertyTypeIDEntry) bson.D { + return bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: hexIDToBlob(entry.PropertyTypeID)}, + {Key: "Value", Value: createDefaultWidgetValue(entry)}, + } +} + +func createDefaultWidgetValue(entry pages.PropertyTypeIDEntry) bson.D { + primitiveVal := entry.DefaultValue + expressionVal := "" + var textTemplate interface{} + + switch entry.ValueType { + case "Expression": + expressionVal = primitiveVal + primitiveVal = "" + case "TextTemplate": + text := primitiveVal + if text == "" { + text = " " + } + textTemplate = createDefaultClientTemplateBSON(text) + case "String": + if primitiveVal == "" { + primitiveVal = " " + } + } + + return bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, + {Key: "Action", Value: bson.D{ + {Key: "$ID", Value: generateBinaryID()}, + {Key: "$Type", Value: "Forms$NoAction"}, + {Key: "DisabledDuringExecution", Value: true}, + }}, + {Key: "AttributeRef", Value: nil}, + {Key: "DataSource", Value: nil}, + {Key: "EntityRef", Value: nil}, + {Key: "Expression", Value: expressionVal}, + {Key: "Form", Value: ""}, + {Key: "Icon", Value: nil}, + {Key: "Image", Value: ""}, + {Key: "Microflow", Value: ""}, + {Key: "Nanoflow", Value: ""}, + {Key: "Objects", Value: bson.A{int32(2)}}, + {Key: "PrimitiveValue", Value: primitiveVal}, + {Key: "Selection", Value: "None"}, + {Key: "SourceVariable", Value: nil}, + {Key: "TextTemplate", Value: textTemplate}, + {Key: "TranslatableValue", Value: nil}, + {Key: "TypePointer", Value: hexIDToBlob(entry.ValueTypeID)}, + {Key: "Widgets", Value: bson.A{int32(2)}}, + {Key: "XPathConstraint", Value: ""}, + } +} + +// --------------------------------------------------------------------------- +// Gallery cloning +// --------------------------------------------------------------------------- + +func buildGallerySelectionProperty(propMap bson.D, selectionMode string) bson.D { + result := make(bson.D, 0, len(propMap)) + + for _, elem := range propMap { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Value" { + if valueMap, ok := elem.Value.(bson.D); ok { + result = append(result, bson.E{Key: "Value", Value: cloneGallerySelectionValue(valueMap, selectionMode)}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + + return result +} + +func cloneGallerySelectionValue(valueMap bson.D, selectionMode string) bson.D { + result := make(bson.D, 0, len(valueMap)) + + for _, elem := range valueMap { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Selection" { + result = append(result, bson.E{Key: "Selection", Value: selectionMode}) + } else if elem.Key == "Action" { + if actionMap, ok := elem.Value.(bson.D); ok { + result = append(result, bson.E{Key: "Action", Value: cloneActionWithNewID(actionMap)}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + + return result +} + +func cloneActionWithNewID(actionMap bson.D) bson.D { + result := make(bson.D, 0, len(actionMap)) + + for _, elem := range actionMap { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else { + result = append(result, elem) + } + } + + return result +} + +// --------------------------------------------------------------------------- +// Attribute object creation +// --------------------------------------------------------------------------- + +func createAttributeObject(attributePath string, objectTypeID, propertyTypeID, valueTypeID string) (bson.D, error) { + if strings.Count(attributePath, ".") < 2 { + return nil, mdlerrors.NewValidationf("invalid attribute path %q: expected Module.Entity.Attribute format", attributePath) + } + return bson.D{ + {Key: "$ID", Value: hexToBytes(types.GenerateID())}, + {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, + {Key: "Properties", Value: []any{ + int32(2), + bson.D{ + {Key: "$ID", Value: hexToBytes(types.GenerateID())}, + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: hexToBytes(propertyTypeID)}, + {Key: "Value", Value: bson.D{ + {Key: "$ID", Value: hexToBytes(types.GenerateID())}, + {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, + {Key: "Action", Value: bson.D{ + {Key: "$ID", Value: hexToBytes(types.GenerateID())}, + {Key: "$Type", Value: "Forms$NoAction"}, + {Key: "DisabledDuringExecution", Value: true}, + }}, + {Key: "AttributeRef", Value: bson.D{ + {Key: "$ID", Value: hexToBytes(types.GenerateID())}, + {Key: "$Type", Value: "DomainModels$AttributeRef"}, + {Key: "Attribute", Value: attributePath}, + {Key: "EntityRef", Value: nil}, + }}, + {Key: "DataSource", Value: nil}, + {Key: "EntityRef", Value: nil}, + {Key: "Expression", Value: ""}, + {Key: "Form", Value: ""}, + {Key: "Icon", Value: nil}, + {Key: "Image", Value: ""}, + {Key: "Microflow", Value: ""}, + {Key: "Nanoflow", Value: ""}, + {Key: "Objects", Value: []any{int32(2)}}, + {Key: "PrimitiveValue", Value: ""}, + {Key: "Selection", Value: "None"}, + {Key: "SourceVariable", Value: nil}, + {Key: "TextTemplate", Value: nil}, + {Key: "TranslatableValue", Value: nil}, + {Key: "TypePointer", Value: hexToBytes(valueTypeID)}, + {Key: "Widgets", Value: []any{int32(2)}}, + {Key: "XPathConstraint", Value: ""}, + }}, + }, + }}, + {Key: "TypePointer", Value: hexToBytes(objectTypeID)}, + }, nil +} diff --git a/mdl/backend/mpr/workflow_mutator.go b/mdl/backend/mpr/workflow_mutator.go new file mode 100644 index 00000000..9309e414 --- /dev/null +++ b/mdl/backend/mpr/workflow_mutator.go @@ -0,0 +1,771 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mprbackend + +import ( + "fmt" + "strings" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/mpr" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// bsonArrayMarker is the Mendix BSON array type marker (storageListType 3). +const bsonArrayMarker = int32(3) + +// Compile-time check. +var _ backend.WorkflowMutator = (*mprWorkflowMutator)(nil) + +// mprWorkflowMutator implements backend.WorkflowMutator for the MPR backend. +type mprWorkflowMutator struct { + backend *MprBackend + unitID model.ID + rawData bson.D +} + +// --------------------------------------------------------------------------- +// OpenWorkflowForMutation +// --------------------------------------------------------------------------- + +// OpenWorkflowForMutation loads a workflow unit and returns a WorkflowMutator. +func (b *MprBackend) openWorkflowForMutation(unitID model.ID) (backend.WorkflowMutator, error) { + rawBytes, err := b.reader.GetRawUnitBytes(unitID) + if err != nil { + return nil, fmt.Errorf("load raw unit bytes: %w", err) + } + var rawData bson.D + if err := bson.Unmarshal(rawBytes, &rawData); err != nil { + return nil, fmt.Errorf("unmarshal workflow BSON: %w", err) + } + return &mprWorkflowMutator{ + backend: b, + unitID: unitID, + rawData: rawData, + }, nil +} + +// --------------------------------------------------------------------------- +// WorkflowMutator interface — top-level properties +// --------------------------------------------------------------------------- + +func (m *mprWorkflowMutator) SetProperty(prop string, value string) error { + switch prop { + case "DISPLAY": + wfName := dGetDoc(m.rawData, "WorkflowName") + if wfName == nil { + newName := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Text", Value: value}, + } + m.rawData = append(m.rawData, bson.E{Key: "WorkflowName", Value: newName}) + } else { + dSet(wfName, "Text", value) + } + dSet(m.rawData, "Title", value) + return nil + + case "DESCRIPTION": + wfDesc := dGetDoc(m.rawData, "WorkflowDescription") + if wfDesc == nil { + newDesc := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Text", Value: value}, + } + m.rawData = append(m.rawData, bson.E{Key: "WorkflowDescription", Value: newDesc}) + } else { + dSet(wfDesc, "Text", value) + } + return nil + + case "EXPORT_LEVEL": + dSet(m.rawData, "ExportLevel", value) + return nil + + case "DUE_DATE": + dSet(m.rawData, "DueDate", value) + return nil + + default: + return fmt.Errorf("unsupported workflow property: %s", prop) + } +} + +func (m *mprWorkflowMutator) SetPropertyWithEntity(prop string, value string, entity string) error { + switch prop { + case "OVERVIEW_PAGE": + if value == "" { + dSet(m.rawData, "AdminPage", nil) + } else { + pageRef := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$PageReference"}, + {Key: "Page", Value: value}, + } + dSet(m.rawData, "AdminPage", pageRef) + } + return nil + + case "PARAMETER": + if value == "" { + for i, elem := range m.rawData { + if elem.Key == "Parameter" { + m.rawData[i].Value = nil + return nil + } + } + return nil + } + param := dGetDoc(m.rawData, "Parameter") + if param != nil { + dSet(param, "Entity", entity) + } else { + newParam := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$Parameter"}, + {Key: "Entity", Value: entity}, + {Key: "Name", Value: "WorkflowContext"}, + } + for i, elem := range m.rawData { + if elem.Key == "Parameter" { + m.rawData[i].Value = newParam + return nil + } + } + m.rawData = append(m.rawData, bson.E{Key: "Parameter", Value: newParam}) + } + return nil + + default: + return fmt.Errorf("unsupported workflow property with entity: %s", prop) + } +} + +// --------------------------------------------------------------------------- +// WorkflowMutator interface — activity operations +// --------------------------------------------------------------------------- + +func (m *mprWorkflowMutator) SetActivityProperty(activityRef string, atPos int, prop string, value string) error { + actDoc, err := m.findActivityByCaption(activityRef, atPos) + if err != nil { + return err + } + + switch prop { + case "PAGE": + taskPage := dGetDoc(actDoc, "TaskPage") + if taskPage != nil { + dSet(taskPage, "Page", value) + } else { + pageRef := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$PageReference"}, + {Key: "Page", Value: value}, + } + dSet(actDoc, "TaskPage", pageRef) + } + return nil + + case "DESCRIPTION": + taskDesc := dGetDoc(actDoc, "TaskDescription") + if taskDesc != nil { + dSet(taskDesc, "Text", value) + } + return nil + + case "TARGETING_MICROFLOW": + userTargeting := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$MicroflowUserTargeting"}, + {Key: "Microflow", Value: value}, + } + dSet(actDoc, "UserTargeting", userTargeting) + return nil + + case "TARGETING_XPATH": + userTargeting := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$XPathUserTargeting"}, + {Key: "XPathConstraint", Value: value}, + } + dSet(actDoc, "UserTargeting", userTargeting) + return nil + + case "DUE_DATE": + dSet(actDoc, "DueDate", value) + return nil + + default: + return fmt.Errorf("unsupported activity property: %s", prop) + } +} + +func (m *mprWorkflowMutator) InsertAfterActivity(activityRef string, atPos int, activities []workflows.WorkflowActivity) error { + idx, acts, containingFlow, err := m.findActivityIndex(activityRef, atPos) + if err != nil { + return err + } + + newBsonActs := m.serializeAndDedup(activities) + + insertIdx := idx + 1 + newArr := make([]any, 0, len(acts)+len(newBsonActs)) + newArr = append(newArr, acts[:insertIdx]...) + newArr = append(newArr, newBsonActs...) + newArr = append(newArr, acts[insertIdx:]...) + + dSetArray(containingFlow, "Activities", newArr) + return nil +} + +func (m *mprWorkflowMutator) DropActivity(activityRef string, atPos int) error { + idx, acts, containingFlow, err := m.findActivityIndex(activityRef, atPos) + if err != nil { + return err + } + + newArr := make([]any, 0, len(acts)-1) + newArr = append(newArr, acts[:idx]...) + newArr = append(newArr, acts[idx+1:]...) + + dSetArray(containingFlow, "Activities", newArr) + return nil +} + +func (m *mprWorkflowMutator) ReplaceActivity(activityRef string, atPos int, activities []workflows.WorkflowActivity) error { + idx, acts, containingFlow, err := m.findActivityIndex(activityRef, atPos) + if err != nil { + return err + } + + newBsonActs := m.serializeAndDedup(activities) + + newArr := make([]any, 0, len(acts)-1+len(newBsonActs)) + newArr = append(newArr, acts[:idx]...) + newArr = append(newArr, newBsonActs...) + newArr = append(newArr, acts[idx+1:]...) + + dSetArray(containingFlow, "Activities", newArr) + return nil +} + +// --------------------------------------------------------------------------- +// WorkflowMutator interface — outcome operations +// --------------------------------------------------------------------------- + +func (m *mprWorkflowMutator) InsertOutcome(activityRef string, atPos int, outcomeName string, activities []workflows.WorkflowActivity) error { + actDoc, err := m.findActivityByCaption(activityRef, atPos) + if err != nil { + return err + } + + outcomeDoc := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$UserTaskOutcome"}, + } + + if len(activities) > 0 { + outcomeDoc = append(outcomeDoc, bson.E{Key: "Flow", Value: m.buildSubFlowBson(activities)}) + } + + outcomeDoc = append(outcomeDoc, + bson.E{Key: "PersistentId", Value: bsonutil.NewIDBsonBinary()}, + bson.E{Key: "Value", Value: outcomeName}, + ) + + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + outcomes = append(outcomes, outcomeDoc) + dSetArray(actDoc, "Outcomes", outcomes) + return nil +} + +func (m *mprWorkflowMutator) DropOutcome(activityRef string, atPos int, outcomeName string) error { + actDoc, err := m.findActivityByCaption(activityRef, atPos) + if err != nil { + return err + } + + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + found := false + var kept []any + for _, elem := range outcomes { + oDoc, ok := elem.(bson.D) + if !ok { + kept = append(kept, elem) + continue + } + value := dGetString(oDoc, "Value") + typeName := dGetString(oDoc, "$Type") + matched := value == outcomeName + if !matched && strings.EqualFold(outcomeName, "Default") && typeName == "Workflows$VoidConditionOutcome" { + matched = true + } + if matched && !found { + found = true + continue + } + kept = append(kept, elem) + } + if !found { + return fmt.Errorf("outcome %q not found on activity %q", outcomeName, activityRef) + } + dSetArray(actDoc, "Outcomes", kept) + return nil +} + +// --------------------------------------------------------------------------- +// WorkflowMutator interface — path operations (parallel split) +// --------------------------------------------------------------------------- + +func (m *mprWorkflowMutator) InsertPath(activityRef string, atPos int, pathCaption string, activities []workflows.WorkflowActivity) error { + actDoc, err := m.findActivityByCaption(activityRef, atPos) + if err != nil { + return err + } + + pathDoc := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$ParallelSplitOutcome"}, + } + + if len(activities) > 0 { + pathDoc = append(pathDoc, bson.E{Key: "Flow", Value: m.buildSubFlowBson(activities)}) + } + + pathDoc = append(pathDoc, bson.E{Key: "PersistentId", Value: bsonutil.NewIDBsonBinary()}) + + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + outcomes = append(outcomes, pathDoc) + dSetArray(actDoc, "Outcomes", outcomes) + return nil +} + +func (m *mprWorkflowMutator) DropPath(activityRef string, atPos int, pathCaption string) error { + actDoc, err := m.findActivityByCaption(activityRef, atPos) + if err != nil { + return err + } + + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if pathCaption == "" && len(outcomes) > 0 { + outcomes = outcomes[:len(outcomes)-1] + dSetArray(actDoc, "Outcomes", outcomes) + return nil + } + + pathIdx := -1 + for i := range outcomes { + if fmt.Sprintf("Path %d", i+1) == pathCaption { + pathIdx = i + break + } + } + if pathIdx < 0 { + return fmt.Errorf("path %q not found on parallel split %q", pathCaption, activityRef) + } + + newOutcomes := make([]any, 0, len(outcomes)-1) + newOutcomes = append(newOutcomes, outcomes[:pathIdx]...) + newOutcomes = append(newOutcomes, outcomes[pathIdx+1:]...) + dSetArray(actDoc, "Outcomes", newOutcomes) + return nil +} + +// --------------------------------------------------------------------------- +// WorkflowMutator interface — branch operations (exclusive split) +// --------------------------------------------------------------------------- + +func (m *mprWorkflowMutator) InsertBranch(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error { + actDoc, err := m.findActivityByCaption(activityRef, atPos) + if err != nil { + return err + } + + var outcomeDoc bson.D + switch strings.ToLower(condition) { + case "true": + outcomeDoc = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$BooleanConditionOutcome"}, + {Key: "Value", Value: true}, + } + case "false": + outcomeDoc = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$BooleanConditionOutcome"}, + {Key: "Value", Value: false}, + } + case "default": + outcomeDoc = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$VoidConditionOutcome"}, + } + default: + outcomeDoc = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$EnumerationValueConditionOutcome"}, + {Key: "Value", Value: condition}, + } + } + + if len(activities) > 0 { + outcomeDoc = append(outcomeDoc, bson.E{Key: "Flow", Value: m.buildSubFlowBson(activities)}) + } + + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + outcomes = append(outcomes, outcomeDoc) + dSetArray(actDoc, "Outcomes", outcomes) + return nil +} + +func (m *mprWorkflowMutator) DropBranch(activityRef string, atPos int, branchName string) error { + actDoc, err := m.findActivityByCaption(activityRef, atPos) + if err != nil { + return err + } + + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + found := false + var kept []any + for _, elem := range outcomes { + oDoc, ok := elem.(bson.D) + if !ok { + kept = append(kept, elem) + continue + } + if !found { + typeName := dGetString(oDoc, "$Type") + switch strings.ToLower(branchName) { + case "true": + if typeName == "Workflows$BooleanConditionOutcome" { + if v, ok := dGet(oDoc, "Value").(bool); ok && v { + found = true + continue + } + } + case "false": + if typeName == "Workflows$BooleanConditionOutcome" { + if v, ok := dGet(oDoc, "Value").(bool); ok && !v { + found = true + continue + } + } + case "default": + if typeName == "Workflows$VoidConditionOutcome" { + found = true + continue + } + default: + value := dGetString(oDoc, "Value") + if value == branchName { + found = true + continue + } + } + } + kept = append(kept, elem) + } + if !found { + return fmt.Errorf("branch %q not found on activity %q", branchName, activityRef) + } + dSetArray(actDoc, "Outcomes", kept) + return nil +} + +// --------------------------------------------------------------------------- +// WorkflowMutator interface — boundary event operations +// --------------------------------------------------------------------------- + +func (m *mprWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error { + actDoc, err := m.findActivityByCaption(activityRef, atPos) + if err != nil { + return err + } + + typeName := "Workflows$InterruptingTimerBoundaryEvent" + switch eventType { + case "NonInterruptingTimer": + typeName = "Workflows$NonInterruptingTimerBoundaryEvent" + case "Timer": + typeName = "Workflows$TimerBoundaryEvent" + } + + eventDoc := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: typeName}, + {Key: "Caption", Value: ""}, + } + + if delay != "" { + eventDoc = append(eventDoc, bson.E{Key: "FirstExecutionTime", Value: delay}) + } + + if len(activities) > 0 { + eventDoc = append(eventDoc, bson.E{Key: "Flow", Value: m.buildSubFlowBson(activities)}) + } + + eventDoc = append(eventDoc, bson.E{Key: "PersistentId", Value: bsonutil.NewIDBsonBinary()}) + + if typeName == "Workflows$NonInterruptingTimerBoundaryEvent" { + eventDoc = append(eventDoc, bson.E{Key: "Recurrence", Value: nil}) + } + + events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) + events = append(events, eventDoc) + dSetArray(actDoc, "BoundaryEvents", events) + return nil +} + +func (m *mprWorkflowMutator) DropBoundaryEvent(activityRef string, atPos int) error { + actDoc, err := m.findActivityByCaption(activityRef, atPos) + if err != nil { + return err + } + + events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) + if len(events) == 0 { + return fmt.Errorf("activity %q has no boundary events", activityRef) + } + + // Drop the first boundary event silently. + dSetArray(actDoc, "BoundaryEvents", events[1:]) + return nil +} + +// --------------------------------------------------------------------------- +// Save +// --------------------------------------------------------------------------- + +func (m *mprWorkflowMutator) Save() error { + outBytes, err := bson.Marshal(m.rawData) + if err != nil { + return fmt.Errorf("marshal modified workflow: %w", err) + } + return m.backend.writer.UpdateRawUnit(string(m.unitID), outBytes) +} + +// --------------------------------------------------------------------------- +// Internal helpers — activity search +// --------------------------------------------------------------------------- + +// findActivityByCaption searches the workflow for an activity matching caption. +func (m *mprWorkflowMutator) findActivityByCaption(caption string, atPosition int) (bson.D, error) { + flow := dGetDoc(m.rawData, "Flow") + if flow == nil { + return nil, fmt.Errorf("workflow has no Flow") + } + + var matches []bson.D + findActivitiesRecursive(flow, caption, &matches) + + if len(matches) == 0 { + return nil, fmt.Errorf("activity %q not found", caption) + } + if len(matches) == 1 || atPosition == 0 { + if atPosition > 0 && atPosition > len(matches) { + return nil, fmt.Errorf("activity %q at position %d not found (found %d matches)", caption, atPosition, len(matches)) + } + if atPosition > 0 { + return matches[atPosition-1], nil + } + if len(matches) > 1 { + return nil, fmt.Errorf("ambiguous activity %q — %d matches. Use @N to disambiguate", caption, len(matches)) + } + return matches[0], nil + } + if atPosition > len(matches) { + return nil, fmt.Errorf("activity %q at position %d not found (found %d matches)", caption, atPosition, len(matches)) + } + return matches[atPosition-1], nil +} + +// findActivitiesRecursive collects all activities matching caption in a flow and nested sub-flows. +func findActivitiesRecursive(flow bson.D, caption string, matches *[]bson.D) { + activities := dGetArrayElements(dGet(flow, "Activities")) + for _, elem := range activities { + actDoc, ok := elem.(bson.D) + if !ok { + continue + } + actCaption := dGetString(actDoc, "Caption") + actName := dGetString(actDoc, "Name") + if actCaption == caption || actName == caption { + *matches = append(*matches, actDoc) + } + for _, nestedFlow := range getNestedFlows(actDoc) { + findActivitiesRecursive(nestedFlow, caption, matches) + } + } +} + +// getNestedFlows returns all sub-flows within an activity. +func getNestedFlows(actDoc bson.D) []bson.D { + var flows []bson.D + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + for _, o := range outcomes { + oDoc, ok := o.(bson.D) + if !ok { + continue + } + if f := dGetDoc(oDoc, "Flow"); f != nil { + flows = append(flows, f) + } + } + events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) + for _, e := range events { + eDoc, ok := e.(bson.D) + if !ok { + continue + } + if f := dGetDoc(eDoc, "Flow"); f != nil { + flows = append(flows, f) + } + } + return flows +} + +// activityIndexMatch holds search result for findActivityIndex. +type activityIndexMatch struct { + idx int + activities []any + flow bson.D +} + +// findActivityIndex returns the index, activities array, and containing flow of an activity. +func (m *mprWorkflowMutator) findActivityIndex(caption string, atPosition int) (int, []any, bson.D, error) { + flow := dGetDoc(m.rawData, "Flow") + if flow == nil { + return -1, nil, nil, fmt.Errorf("workflow has no Flow") + } + + var matches []activityIndexMatch + findActivityIndexRecursive(flow, caption, &matches) + + if len(matches) == 0 { + return -1, nil, nil, fmt.Errorf("activity %q not found", caption) + } + pos := 0 + if atPosition > 0 { + pos = atPosition - 1 + } else if len(matches) > 1 { + return -1, nil, nil, fmt.Errorf("ambiguous activity %q — %d matches. Use @N to disambiguate", caption, len(matches)) + } + if pos >= len(matches) { + return -1, nil, nil, fmt.Errorf("activity %q at position %d not found (found %d matches)", caption, atPosition, len(matches)) + } + am := matches[pos] + return am.idx, am.activities, am.flow, nil +} + +func findActivityIndexRecursive(flow bson.D, caption string, matches *[]activityIndexMatch) { + activities := dGetArrayElements(dGet(flow, "Activities")) + for i, elem := range activities { + actDoc, ok := elem.(bson.D) + if !ok { + continue + } + actCaption := dGetString(actDoc, "Caption") + actName := dGetString(actDoc, "Name") + if actCaption == caption || actName == caption { + *matches = append(*matches, activityIndexMatch{idx: i, activities: activities, flow: flow}) + } + for _, nestedFlow := range getNestedFlows(actDoc) { + findActivityIndexRecursive(nestedFlow, caption, matches) + } + } +} + +// --------------------------------------------------------------------------- +// Internal helpers — name collection & deduplication +// --------------------------------------------------------------------------- + +// collectAllActivityNames collects all activity names from the entire workflow BSON. +func (m *mprWorkflowMutator) collectAllActivityNames() map[string]bool { + names := make(map[string]bool) + flow := dGetDoc(m.rawData, "Flow") + if flow != nil { + collectNamesRecursive(flow, names) + } + return names +} + +func collectNamesRecursive(flow bson.D, names map[string]bool) { + activities := dGetArrayElements(dGet(flow, "Activities")) + for _, elem := range activities { + actDoc, ok := elem.(bson.D) + if !ok { + continue + } + if name := dGetString(actDoc, "Name"); name != "" { + names[name] = true + } + for _, nested := range getNestedFlows(actDoc) { + collectNamesRecursive(nested, names) + } + } +} + +// deduplicateNewActivityName ensures a new activity name doesn't conflict. +func deduplicateNewActivityName(act workflows.WorkflowActivity, existingNames map[string]bool) { + name := act.GetName() + if name == "" || !existingNames[name] { + return + } + for i := 2; i < 1000; i++ { + candidate := fmt.Sprintf("%s_%d", name, i) + if !existingNames[candidate] { + act.SetName(candidate) + existingNames[candidate] = true + return + } + } +} + +// --------------------------------------------------------------------------- +// Internal helpers — serialization +// --------------------------------------------------------------------------- + +// serializeAndDedup serializes workflow activities to BSON, deduplicating names. +func (m *mprWorkflowMutator) serializeAndDedup(activities []workflows.WorkflowActivity) []any { + existingNames := m.collectAllActivityNames() + for _, act := range activities { + deduplicateNewActivityName(act, existingNames) + } + + result := make([]any, 0, len(activities)) + for _, act := range activities { + bsonDoc := mpr.SerializeWorkflowActivity(act) + if bsonDoc != nil { + result = append(result, bsonDoc) + } + } + return result +} + +// buildSubFlowBson builds a Workflows$Flow BSON document from activities. +func (m *mprWorkflowMutator) buildSubFlowBson(activities []workflows.WorkflowActivity) bson.D { + existingNames := m.collectAllActivityNames() + for _, act := range activities { + deduplicateNewActivityName(act, existingNames) + } + + var subActsBson bson.A + subActsBson = append(subActsBson, bsonArrayMarker) + for _, act := range activities { + bsonDoc := mpr.SerializeWorkflowActivity(act) + if bsonDoc != nil { + subActsBson = append(subActsBson, bsonDoc) + } + } + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$Flow"}, + {Key: "Activities", Value: subActsBson}, + } +} diff --git a/mdl/executor/cmd_alter_workflow_test.go b/mdl/backend/mpr/workflow_mutator_test.go similarity index 50% rename from mdl/executor/cmd_alter_workflow_test.go rename to mdl/backend/mpr/workflow_mutator_test.go index fe9de805..f8c1f43b 100644 --- a/mdl/executor/cmd_alter_workflow_test.go +++ b/mdl/backend/mpr/workflow_mutator_test.go @@ -1,22 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 -package executor +package mprbackend import ( - "io" "strings" "testing" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" - - "github.com/mendixlabs/mxcli/mdl/ast" ) // makeWorkflowDoc builds a minimal workflow BSON document for testing. -// Activities are placed inside a Flow sub-document, matching real workflow structure. func makeWorkflowDoc(activities ...bson.D) bson.D { - actArr := bson.A{int32(3)} // Mendix array marker + actArr := bson.A{int32(3)} for _, a := range activities { actArr = append(actArr, a) } @@ -42,8 +38,7 @@ func makeWorkflowDoc(activities ...bson.D) bson.D { } } -// makeWorkflowActivity builds a minimal workflow activity BSON with a caption and name. -func makeWorkflowActivity(typeName, caption, name string) bson.D { +func makeWfActivity(typeName, caption, name string) bson.D { return bson.D{ {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, {Key: "$Type", Value: typeName}, @@ -52,8 +47,7 @@ func makeWorkflowActivity(typeName, caption, name string) bson.D { } } -// makeWorkflowActivityWithBoundaryEvents builds an activity with boundary events. -func makeWorkflowActivityWithBoundaryEvents(caption string, events ...bson.D) bson.D { +func makeWfActivityWithBoundaryEvents(caption string, events ...bson.D) bson.D { evtArr := bson.A{int32(3)} for _, e := range events { evtArr = append(evtArr, e) @@ -67,7 +61,7 @@ func makeWorkflowActivityWithBoundaryEvents(caption string, events ...bson.D) bs } } -func makeBoundaryEvent(typeName string) bson.D { +func makeWfBoundaryEvent(typeName string) bson.D { return bson.D{ {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, {Key: "$Type", Value: typeName}, @@ -75,22 +69,25 @@ func makeBoundaryEvent(typeName string) bson.D { } } -// --- SET DISPLAY tests --- +// newMutator creates a mprWorkflowMutator for testing (no real backend). +func newMutator(doc bson.D) *mprWorkflowMutator { + return &mprWorkflowMutator{rawData: doc} +} + +// --- SetProperty tests --- -func TestSetWorkflowProperty_Display(t *testing.T) { +func TestWorkflowMutator_SetProperty_Display(t *testing.T) { doc := makeWorkflowDoc() + m := newMutator(doc) - op := &ast.SetWorkflowPropertyOp{Property: "DISPLAY", Value: "New Title"} - if err := applySetWorkflowProperty(&doc, op); err != nil { - t.Fatalf("SET DISPLAY failed: %v", err) + if err := m.SetProperty("DISPLAY", "New Title"); err != nil { + t.Fatalf("SetProperty DISPLAY failed: %v", err) } - // Title should be updated - if got := dGetString(doc, "Title"); got != "New Title" { + if got := dGetString(m.rawData, "Title"); got != "New Title" { t.Errorf("Title = %q, want %q", got, "New Title") } - // WorkflowName.Text should be updated - wfName := dGetDoc(doc, "WorkflowName") + wfName := dGetDoc(m.rawData, "WorkflowName") if wfName == nil { t.Fatal("WorkflowName is nil") } @@ -99,8 +96,7 @@ func TestSetWorkflowProperty_Display(t *testing.T) { } } -func TestSetWorkflowProperty_Display_NilSubDoc(t *testing.T) { - // Build doc without WorkflowName to test auto-creation +func TestWorkflowMutator_SetProperty_Display_NilSubDoc(t *testing.T) { doc := bson.D{ {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, {Key: "$Type", Value: "Workflows$Workflow"}, @@ -109,39 +105,33 @@ func TestSetWorkflowProperty_Display_NilSubDoc(t *testing.T) { {Key: "Activities", Value: bson.A{int32(3)}}, }}, } + m := newMutator(doc) - op := &ast.SetWorkflowPropertyOp{Property: "DISPLAY", Value: "Created Title"} - if err := applySetWorkflowProperty(&doc, op); err != nil { - t.Fatalf("SET DISPLAY with nil sub-doc failed: %v", err) + if err := m.SetProperty("DISPLAY", "Created Title"); err != nil { + t.Fatalf("SetProperty DISPLAY with nil sub-doc failed: %v", err) } - if got := dGetString(doc, "Title"); got != "Created Title" { + if got := dGetString(m.rawData, "Title"); got != "Created Title" { t.Errorf("Title = %q, want %q", got, "Created Title") } - - wfName := dGetDoc(doc, "WorkflowName") + wfName := dGetDoc(m.rawData, "WorkflowName") if wfName == nil { t.Fatal("WorkflowName should have been auto-created") } if got := dGetString(wfName, "Text"); got != "Created Title" { t.Errorf("WorkflowName.Text = %q, want %q", got, "Created Title") } - if got := dGetString(wfName, "$Type"); got != "Texts$Text" { - t.Errorf("WorkflowName.$Type = %q, want %q", got, "Texts$Text") - } } -// --- SET DESCRIPTION tests --- - -func TestSetWorkflowProperty_Description(t *testing.T) { +func TestWorkflowMutator_SetProperty_Description(t *testing.T) { doc := makeWorkflowDoc() + m := newMutator(doc) - op := &ast.SetWorkflowPropertyOp{Property: "DESCRIPTION", Value: "Updated desc"} - if err := applySetWorkflowProperty(&doc, op); err != nil { - t.Fatalf("SET DESCRIPTION failed: %v", err) + if err := m.SetProperty("DESCRIPTION", "Updated desc"); err != nil { + t.Fatalf("SetProperty DESCRIPTION failed: %v", err) } - wfDesc := dGetDoc(doc, "WorkflowDescription") + wfDesc := dGetDoc(m.rawData, "WorkflowDescription") if wfDesc == nil { t.Fatal("WorkflowDescription is nil") } @@ -150,7 +140,7 @@ func TestSetWorkflowProperty_Description(t *testing.T) { } } -func TestSetWorkflowProperty_Description_NilSubDoc(t *testing.T) { +func TestWorkflowMutator_SetProperty_Description_NilSubDoc(t *testing.T) { doc := bson.D{ {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, {Key: "$Type", Value: "Workflows$Workflow"}, @@ -159,13 +149,13 @@ func TestSetWorkflowProperty_Description_NilSubDoc(t *testing.T) { {Key: "Activities", Value: bson.A{int32(3)}}, }}, } + m := newMutator(doc) - op := &ast.SetWorkflowPropertyOp{Property: "DESCRIPTION", Value: "New desc"} - if err := applySetWorkflowProperty(&doc, op); err != nil { - t.Fatalf("SET DESCRIPTION with nil sub-doc failed: %v", err) + if err := m.SetProperty("DESCRIPTION", "New desc"); err != nil { + t.Fatalf("SetProperty DESCRIPTION with nil sub-doc failed: %v", err) } - wfDesc := dGetDoc(doc, "WorkflowDescription") + wfDesc := dGetDoc(m.rawData, "WorkflowDescription") if wfDesc == nil { t.Fatal("WorkflowDescription should have been auto-created") } @@ -174,13 +164,11 @@ func TestSetWorkflowProperty_Description_NilSubDoc(t *testing.T) { } } -// --- SET unsupported property --- - -func TestSetWorkflowProperty_UnsupportedProperty(t *testing.T) { +func TestWorkflowMutator_SetProperty_Unsupported(t *testing.T) { doc := makeWorkflowDoc() + m := newMutator(doc) - op := &ast.SetWorkflowPropertyOp{Property: "UNKNOWN_PROP", Value: "x"} - err := applySetWorkflowProperty(&doc, op) + err := m.SetProperty("UNKNOWN_PROP", "x") if err == nil { t.Fatal("Expected error for unsupported property") } @@ -189,14 +177,27 @@ func TestSetWorkflowProperty_UnsupportedProperty(t *testing.T) { } } +func TestWorkflowMutator_SetProperty_ExportLevel(t *testing.T) { + doc := makeWorkflowDoc() + doc = append(doc, bson.E{Key: "ExportLevel", Value: "Usable"}) + m := newMutator(doc) + + if err := m.SetProperty("EXPORT_LEVEL", "Hidden"); err != nil { + t.Fatalf("SetProperty EXPORT_LEVEL failed: %v", err) + } + if got := dGetString(m.rawData, "ExportLevel"); got != "Hidden" { + t.Errorf("ExportLevel = %q, want %q", got, "Hidden") + } +} + // --- findActivityByCaption tests --- -func TestFindActivityByCaption_Found(t *testing.T) { - act1 := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") - act2 := makeWorkflowActivity("Workflows$UserTask", "Approve", "task2") - doc := makeWorkflowDoc(act1, act2) +func TestWorkflowMutator_FindActivity_Found(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "Review", "task1") + act2 := makeWfActivity("Workflows$UserTask", "Approve", "task2") + m := newMutator(makeWorkflowDoc(act1, act2)) - result, err := findActivityByCaption(doc, "Approve", 0) + result, err := m.findActivityByCaption("Approve", 0) if err != nil { t.Fatalf("findActivityByCaption failed: %v", err) } @@ -205,11 +206,11 @@ func TestFindActivityByCaption_Found(t *testing.T) { } } -func TestFindActivityByCaption_ByName(t *testing.T) { - act1 := makeWorkflowActivity("Workflows$UserTask", "Review", "ReviewTask") - doc := makeWorkflowDoc(act1) +func TestWorkflowMutator_FindActivity_ByName(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "Review", "ReviewTask") + m := newMutator(makeWorkflowDoc(act1)) - result, err := findActivityByCaption(doc, "ReviewTask", 0) + result, err := m.findActivityByCaption("ReviewTask", 0) if err != nil { t.Fatalf("findActivityByCaption by name failed: %v", err) } @@ -218,11 +219,11 @@ func TestFindActivityByCaption_ByName(t *testing.T) { } } -func TestFindActivityByCaption_NotFound(t *testing.T) { - act1 := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") - doc := makeWorkflowDoc(act1) +func TestWorkflowMutator_FindActivity_NotFound(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "Review", "task1") + m := newMutator(makeWorkflowDoc(act1)) - _, err := findActivityByCaption(doc, "NonExistent", 0) + _, err := m.findActivityByCaption("NonExistent", 0) if err == nil { t.Fatal("Expected error for missing activity") } @@ -231,12 +232,12 @@ func TestFindActivityByCaption_NotFound(t *testing.T) { } } -func TestFindActivityByCaption_Ambiguous(t *testing.T) { - act1 := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") - act2 := makeWorkflowActivity("Workflows$UserTask", "Review", "task2") - doc := makeWorkflowDoc(act1, act2) +func TestWorkflowMutator_FindActivity_Ambiguous(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "Review", "task1") + act2 := makeWfActivity("Workflows$UserTask", "Review", "task2") + m := newMutator(makeWorkflowDoc(act1, act2)) - _, err := findActivityByCaption(doc, "Review", 0) + _, err := m.findActivityByCaption("Review", 0) if err == nil { t.Fatal("Expected error for ambiguous activity") } @@ -245,12 +246,12 @@ func TestFindActivityByCaption_Ambiguous(t *testing.T) { } } -func TestFindActivityByCaption_AtPosition(t *testing.T) { - act1 := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") - act2 := makeWorkflowActivity("Workflows$UserTask", "Review", "task2") - doc := makeWorkflowDoc(act1, act2) +func TestWorkflowMutator_FindActivity_AtPosition(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "Review", "task1") + act2 := makeWfActivity("Workflows$UserTask", "Review", "task2") + m := newMutator(makeWorkflowDoc(act1, act2)) - result, err := findActivityByCaption(doc, "Review", 2) + result, err := m.findActivityByCaption("Review", 2) if err != nil { t.Fatalf("findActivityByCaption @2 failed: %v", err) } @@ -259,11 +260,11 @@ func TestFindActivityByCaption_AtPosition(t *testing.T) { } } -func TestFindActivityByCaption_AtPosition_OutOfRange(t *testing.T) { - act1 := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") - doc := makeWorkflowDoc(act1) +func TestWorkflowMutator_FindActivity_AtPosition_OutOfRange(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "Review", "task1") + m := newMutator(makeWorkflowDoc(act1)) - _, err := findActivityByCaption(doc, "Review", 5) + _, err := m.findActivityByCaption("Review", 5) if err == nil { t.Fatal("Expected error for out-of-range position") } @@ -272,25 +273,23 @@ func TestFindActivityByCaption_AtPosition_OutOfRange(t *testing.T) { } } -// --- DROP activity tests --- +// --- DropActivity tests --- -func TestDropActivity_ByCaption(t *testing.T) { - act1 := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") - act2 := makeWorkflowActivity("Workflows$UserTask", "Approve", "task2") - act3 := makeWorkflowActivity("Workflows$UserTask", "Finalize", "task3") - doc := makeWorkflowDoc(act1, act2, act3) +func TestWorkflowMutator_DropActivity(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "Review", "task1") + act2 := makeWfActivity("Workflows$UserTask", "Approve", "task2") + act3 := makeWfActivity("Workflows$UserTask", "Finalize", "task3") + m := newMutator(makeWorkflowDoc(act1, act2, act3)) - op := &ast.DropActivityOp{ActivityRef: "Approve"} - if err := applyDropActivity(doc, op); err != nil { - t.Fatalf("DROP ACTIVITY failed: %v", err) + if err := m.DropActivity("Approve", 0); err != nil { + t.Fatalf("DropActivity failed: %v", err) } - flow := dGetDoc(doc, "Flow") + flow := dGetDoc(m.rawData, "Flow") activities := dGetArrayElements(dGet(flow, "Activities")) if len(activities) != 2 { t.Fatalf("Expected 2 activities after drop, got %d", len(activities)) } - name0 := dGetString(activities[0].(bson.D), "Caption") name1 := dGetString(activities[1].(bson.D), "Caption") if name0 != "Review" { @@ -301,65 +300,60 @@ func TestDropActivity_ByCaption(t *testing.T) { } } -func TestDropActivity_NotFound(t *testing.T) { - act1 := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") - doc := makeWorkflowDoc(act1) +func TestWorkflowMutator_DropActivity_NotFound(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "Review", "task1") + m := newMutator(makeWorkflowDoc(act1)) - op := &ast.DropActivityOp{ActivityRef: "NonExistent"} - err := applyDropActivity(doc, op) + err := m.DropActivity("NonExistent", 0) if err == nil { t.Fatal("Expected error for dropping nonexistent activity") } } -// --- DROP BOUNDARY EVENT tests --- +// --- DropBoundaryEvent tests --- -func TestDropBoundaryEvent_Single(t *testing.T) { - evt := makeBoundaryEvent("Workflows$InterruptingTimerBoundaryEvent") - act := makeWorkflowActivityWithBoundaryEvents("Review", evt) - doc := makeWorkflowDoc(act) +func TestWorkflowMutator_DropBoundaryEvent_Single(t *testing.T) { + evt := makeWfBoundaryEvent("Workflows$InterruptingTimerBoundaryEvent") + act := makeWfActivityWithBoundaryEvents("Review", evt) + m := newMutator(makeWorkflowDoc(act)) - op := &ast.DropBoundaryEventOp{ActivityRef: "Review"} - if err := applyDropBoundaryEvent(io.Discard, doc, op); err != nil { - t.Fatalf("DROP BOUNDARY EVENT failed: %v", err) + if err := m.DropBoundaryEvent("Review", 0); err != nil { + t.Fatalf("DropBoundaryEvent failed: %v", err) } - actDoc, _ := findActivityByCaption(doc, "Review", 0) + actDoc, _ := m.findActivityByCaption("Review", 0) events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) if len(events) != 0 { t.Errorf("Expected 0 boundary events after drop, got %d", len(events)) } } -func TestDropBoundaryEvent_Multiple_DropsFirst(t *testing.T) { - evt1 := makeBoundaryEvent("Workflows$InterruptingTimerBoundaryEvent") - evt2 := makeBoundaryEvent("Workflows$NonInterruptingTimerBoundaryEvent") - act := makeWorkflowActivityWithBoundaryEvents("Review", evt1, evt2) - doc := makeWorkflowDoc(act) +func TestWorkflowMutator_DropBoundaryEvent_Multiple(t *testing.T) { + evt1 := makeWfBoundaryEvent("Workflows$InterruptingTimerBoundaryEvent") + evt2 := makeWfBoundaryEvent("Workflows$NonInterruptingTimerBoundaryEvent") + act := makeWfActivityWithBoundaryEvents("Review", evt1, evt2) + m := newMutator(makeWorkflowDoc(act)) - op := &ast.DropBoundaryEventOp{ActivityRef: "Review"} - if err := applyDropBoundaryEvent(io.Discard, doc, op); err != nil { - t.Fatalf("DROP BOUNDARY EVENT failed: %v", err) + if err := m.DropBoundaryEvent("Review", 0); err != nil { + t.Fatalf("DropBoundaryEvent failed: %v", err) } - actDoc, _ := findActivityByCaption(doc, "Review", 0) + actDoc, _ := m.findActivityByCaption("Review", 0) events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) if len(events) != 1 { t.Fatalf("Expected 1 boundary event after drop, got %d", len(events)) } - remaining := events[0].(bson.D) if got := dGetString(remaining, "$Type"); got != "Workflows$NonInterruptingTimerBoundaryEvent" { t.Errorf("Remaining event type = %q, want NonInterruptingTimerBoundaryEvent", got) } } -func TestDropBoundaryEvent_NoEvents(t *testing.T) { - act := makeWorkflowActivityWithBoundaryEvents("Review") // no events - doc := makeWorkflowDoc(act) +func TestWorkflowMutator_DropBoundaryEvent_NoEvents(t *testing.T) { + act := makeWfActivityWithBoundaryEvents("Review") + m := newMutator(makeWorkflowDoc(act)) - op := &ast.DropBoundaryEventOp{ActivityRef: "Review"} - err := applyDropBoundaryEvent(io.Discard, doc, op) + err := m.DropBoundaryEvent("Review", 0) if err == nil { t.Fatal("Expected error when dropping from activity with no boundary events") } @@ -370,12 +364,12 @@ func TestDropBoundaryEvent_NoEvents(t *testing.T) { // --- findActivityIndex tests --- -func TestFindActivityIndex_Basic(t *testing.T) { - act1 := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") - act2 := makeWorkflowActivity("Workflows$UserTask", "Approve", "task2") - doc := makeWorkflowDoc(act1, act2) +func TestWorkflowMutator_FindActivityIndex(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "Review", "task1") + act2 := makeWfActivity("Workflows$UserTask", "Approve", "task2") + m := newMutator(makeWorkflowDoc(act1, act2)) - idx, activities, flow, err := findActivityIndex(doc, "Approve", 0) + idx, activities, flow, err := m.findActivityIndex("Approve", 0) if err != nil { t.Fatalf("findActivityIndex failed: %v", err) } @@ -390,12 +384,13 @@ func TestFindActivityIndex_Basic(t *testing.T) { } } -func TestFindActivityIndex_NoFlow(t *testing.T) { +func TestWorkflowMutator_FindActivityIndex_NoFlow(t *testing.T) { doc := bson.D{ {Key: "$Type", Value: "Workflows$Workflow"}, } + m := newMutator(doc) - _, _, _, err := findActivityIndex(doc, "Review", 0) + _, _, _, err := m.findActivityIndex("Review", 0) if err == nil { t.Fatal("Expected error for doc without Flow") } @@ -406,12 +401,12 @@ func TestFindActivityIndex_NoFlow(t *testing.T) { // --- collectAllActivityNames tests --- -func TestCollectAllActivityNames(t *testing.T) { - act1 := makeWorkflowActivity("Workflows$UserTask", "Review", "ReviewTask") - act2 := makeWorkflowActivity("Workflows$UserTask", "Approve", "ApproveTask") - doc := makeWorkflowDoc(act1, act2) +func TestWorkflowMutator_CollectAllActivityNames(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "Review", "ReviewTask") + act2 := makeWfActivity("Workflows$UserTask", "Approve", "ApproveTask") + m := newMutator(makeWorkflowDoc(act1, act2)) - names := collectAllActivityNames(doc) + names := m.collectAllActivityNames() if !names["ReviewTask"] { t.Error("Expected ReviewTask in names") } @@ -423,64 +418,38 @@ func TestCollectAllActivityNames(t *testing.T) { } } -func TestCollectAllActivityNames_NoFlow(t *testing.T) { +func TestWorkflowMutator_CollectAllActivityNames_NoFlow(t *testing.T) { doc := bson.D{{Key: "$Type", Value: "Workflows$Workflow"}} + m := newMutator(doc) - names := collectAllActivityNames(doc) + names := m.collectAllActivityNames() if len(names) != 0 { t.Errorf("Expected empty names map, got %d entries", len(names)) } } -// --- SET EXPORT_LEVEL / DUE_DATE --- - -func TestSetWorkflowProperty_ExportLevel(t *testing.T) { - doc := makeWorkflowDoc() - // ExportLevel must exist in the doc for dSet to update it - doc = append(doc, bson.E{Key: "ExportLevel", Value: "Usable"}) - - op := &ast.SetWorkflowPropertyOp{Property: "EXPORT_LEVEL", Value: "Hidden"} - if err := applySetWorkflowProperty(&doc, op); err != nil { - t.Fatalf("SET EXPORT_LEVEL failed: %v", err) - } - - if got := dGetString(doc, "ExportLevel"); got != "Hidden" { - t.Errorf("ExportLevel = %q, want %q", got, "Hidden") - } -} - -// --- applySetActivityProperty tests --- +// --- SetActivityProperty tests --- -func TestSetActivityProperty_DueDate(t *testing.T) { - act := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") +func TestWorkflowMutator_SetActivityProperty_DueDate(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "DueDate", Value: ""}) - doc := makeWorkflowDoc(act) + m := newMutator(makeWorkflowDoc(act)) - op := &ast.SetActivityPropertyOp{ - ActivityRef: "Review", - Property: "DUE_DATE", - Value: "${PT48H}", - } - if err := applySetActivityProperty(doc, op); err != nil { - t.Fatalf("SET DUE_DATE failed: %v", err) + if err := m.SetActivityProperty("Review", 0, "DUE_DATE", "${PT48H}"); err != nil { + t.Fatalf("SetActivityProperty DUE_DATE failed: %v", err) } - actDoc, _ := findActivityByCaption(doc, "Review", 0) + actDoc, _ := m.findActivityByCaption("Review", 0) if got := dGetString(actDoc, "DueDate"); got != "${PT48H}" { t.Errorf("DueDate = %q, want %q", got, "${PT48H}") } } -func TestSetActivityProperty_UnsupportedProperty(t *testing.T) { - act := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") - doc := makeWorkflowDoc(act) +func TestWorkflowMutator_SetActivityProperty_Unsupported(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + m := newMutator(makeWorkflowDoc(act)) - op := &ast.SetActivityPropertyOp{ - ActivityRef: "Review", - Property: "INVALID", - Value: "x", - } - err := applySetActivityProperty(doc, op) + err := m.SetActivityProperty("Review", 0, "INVALID", "x") if err == nil { t.Fatal("Expected error for unsupported activity property") } @@ -489,16 +458,14 @@ func TestSetActivityProperty_UnsupportedProperty(t *testing.T) { } } -// --- applyDropOutcome tests --- +// --- DropOutcome tests --- -func TestDropOutcome_NotFound(t *testing.T) { - act := makeWorkflowActivity("Workflows$UserTask", "Review", "task1") - // Add empty Outcomes array +func TestWorkflowMutator_DropOutcome_NotFound(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "Outcomes", Value: bson.A{int32(3)}}) - doc := makeWorkflowDoc(act) + m := newMutator(makeWorkflowDoc(act)) - op := &ast.DropOutcomeOp{ActivityRef: "Review", OutcomeName: "NonExistent"} - err := applyDropOutcome(doc, op) + err := m.DropOutcome("Review", 0, "NonExistent") if err == nil { t.Fatal("Expected error for dropping nonexistent outcome") } @@ -509,7 +476,7 @@ func TestDropOutcome_NotFound(t *testing.T) { // --- bsonArrayMarker constant test --- -func TestBsonArrayMarkerConstant(t *testing.T) { +func TestWorkflowMutator_BsonArrayMarkerConstant(t *testing.T) { if bsonArrayMarker != int32(3) { t.Errorf("bsonArrayMarker = %v, want int32(3)", bsonArrayMarker) } diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index 3dacdd33..d8c812b5 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -40,6 +40,23 @@ const ( PluggableOpAttributeObjects PluggablePropertyOp = "attributeObjects" ) +// WidgetRef identifies a widget or a column within a widget. +type WidgetRef struct { + Widget string + Column string // empty for non-column targeting +} + +// IsColumn returns true if this targets a column within a widget. +func (r WidgetRef) IsColumn() bool { return r.Column != "" } + +// Name returns the full reference string for error messages. +func (r WidgetRef) Name() string { + if r.Column != "" { + return r.Widget + "." + r.Column + } + return r.Widget +} + // PageMutator provides fine-grained mutation operations on a single // page, layout, or snippet unit. Obtain one via PageMutationBackend.OpenPageForMutation. // All methods operate on the in-memory representation; call Save to persist. @@ -63,14 +80,19 @@ type PageMutator interface { // --- Widget tree operations --- // InsertWidget inserts serialized widgets at the given position - // relative to the target widget. - InsertWidget(targetWidget string, position InsertPosition, widgets []pages.Widget) error + // relative to the target widget or column. Position is "before" or "after". + // columnRef is "" for widget targeting; non-empty for column targeting. + InsertWidget(widgetRef string, columnRef string, position InsertPosition, widgets []pages.Widget) error - // DropWidget removes widgets by name from the tree. - DropWidget(widgetRefs []string) error + // DropWidget removes widgets by ref from the tree. + DropWidget(refs []WidgetRef) error - // ReplaceWidget replaces the target widget with the given widgets. - ReplaceWidget(targetWidget string, widgets []pages.Widget) error + // ReplaceWidget replaces the target widget or column with the given widgets. + // columnRef is "" for widget targeting. + ReplaceWidget(widgetRef string, columnRef string, widgets []pages.Widget) error + + // FindWidget checks if a widget with the given name exists in the tree. + FindWidget(name string) bool // --- Variable operations --- @@ -101,6 +123,10 @@ type PageMutator interface { // WidgetScope returns a map of widget name → unit ID for all widgets in the tree. WidgetScope() map[string]model.ID + // ParamScope returns page/snippet parameter maps: + // paramIDs maps param name → entity ID, paramEntityNames maps param name → qualified entity name. + ParamScope() (paramIDs map[string]model.ID, paramEntityNames map[string]string) + // Save persists the mutations to the backend. Save() error } @@ -209,3 +235,67 @@ type WidgetSerializationBackend interface { // SerializeWorkflowActivity converts a domain WorkflowActivity to storage format. SerializeWorkflowActivity(a workflows.WorkflowActivity) (any, error) } + +// WidgetObjectBuilder provides BSON-free operations on a loaded pluggable widget template. +// The executor calls these methods with domain-typed values; the backend handles +// all storage-specific manipulation internally. +// +// Workflow: LoadTemplate → apply operations → EnsureRequiredObjectLists → Finalize +type WidgetObjectBuilder interface { + // --- Property operations --- + // Each operation finds the property by key (via TypePointer matching) and updates its value. + + SetAttribute(propertyKey string, attributePath string) + SetAssociation(propertyKey string, assocPath string, entityName string) + SetPrimitive(propertyKey string, value string) + SetSelection(propertyKey string, value string) + SetExpression(propertyKey string, value string) + SetDataSource(propertyKey string, ds pages.DataSource) + SetChildWidgets(propertyKey string, children []pages.Widget) + SetTextTemplate(propertyKey string, text string) + SetTextTemplateWithParams(propertyKey string, text string, entityContext string) + SetAction(propertyKey string, action pages.ClientAction) + SetAttributeObjects(propertyKey string, attributePaths []string) + + // --- Template metadata --- + + // PropertyTypeIDs returns the property type metadata for the loaded template. + PropertyTypeIDs() map[string]pages.PropertyTypeIDEntry + + // --- Object list defaults --- + + // EnsureRequiredObjectLists auto-populates required empty object lists. + EnsureRequiredObjectLists() + + // --- Gallery-specific --- + + // CloneGallerySelectionProperty clones the itemSelection property with a new Selection value. + CloneGallerySelectionProperty(propertyKey string, selectionMode string) + + // --- Finalize --- + + // Finalize builds the CustomWidget from the mutated template. + // Returns the widget with RawType/RawObject set from the internal BSON state. + Finalize(id model.ID, name string, label string, editable string) *pages.CustomWidget +} + +// WidgetBuilderBackend provides pluggable widget construction capabilities. +type WidgetBuilderBackend interface { + // LoadWidgetTemplate loads a widget template by ID and returns a builder + // for applying property operations. projectPath is used for runtime template + // augmentation from .mpk files. + LoadWidgetTemplate(widgetID string, projectPath string) (WidgetObjectBuilder, error) + + // SerializeWidgetToOpaque converts a domain Widget to an opaque form + // suitable for passing to WidgetObjectBuilder.SetChildWidgets. + // This replaces the direct mpr.SerializeWidget call. + SerializeWidgetToOpaque(w pages.Widget) any + + // SerializeDataSourceToOpaque converts a domain DataSource to an opaque + // form suitable for embedding in widget property BSON. + SerializeDataSourceToOpaque(ds pages.DataSource) any + + // BuildCreateAttributeObject creates an attribute object for filter widgets. + // Returns an opaque value to be collected into attribute object lists. + BuildCreateAttributeObject(attributePath string, objectTypeID, propertyTypeID, valueTypeID string) (any, error) +} diff --git a/mdl/executor/bson_helpers.go b/mdl/executor/bson_helpers.go new file mode 100644 index 00000000..eafed38a --- /dev/null +++ b/mdl/executor/bson_helpers.go @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/bsonutil" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// ============================================================================ +// bson.D helper functions for ordered document access +// ============================================================================ + +// dGet returns the value for a key in a bson.D, or nil if not found. +func dGet(doc bson.D, key string) any { + for _, elem := range doc { + if elem.Key == key { + return elem.Value + } + } + return nil +} + +// dGetDoc returns a nested bson.D field value, or nil. +func dGetDoc(doc bson.D, key string) bson.D { + v := dGet(doc, key) + if d, ok := v.(bson.D); ok { + return d + } + return nil +} + +// dGetString returns a string field value, or "". +func dGetString(doc bson.D, key string) string { + v := dGet(doc, key) + if s, ok := v.(string); ok { + return s + } + return "" +} + +// dSet sets a field value in a bson.D in place. If the key exists, it's updated +// and returns true. If the key is not found, returns false. +func dSet(doc bson.D, key string, value any) bool { + for i := range doc { + if doc[i].Key == key { + doc[i].Value = value + return true + } + } + return false +} + +// dGetArrayElements extracts Mendix array elements from a bson.D field value. +// Handles the int32 type marker at index 0. +func dGetArrayElements(val any) []any { + arr := toBsonA(val) + if len(arr) == 0 { + return nil + } + if _, ok := arr[0].(int32); ok { + return arr[1:] + } + if _, ok := arr[0].(int); ok { + return arr[1:] + } + return arr +} + +// toBsonA converts various BSON array types to []any. +func toBsonA(v any) []any { + switch arr := v.(type) { + case bson.A: + return []any(arr) + case []any: + return arr + default: + return nil + } +} + +// dSetArray sets a Mendix-style BSON array field, preserving the int32 marker. +func dSetArray(doc bson.D, key string, elements []any) { + existing := toBsonA(dGet(doc, key)) + var marker any + if len(existing) > 0 { + if _, ok := existing[0].(int32); ok { + marker = existing[0] + } else if _, ok := existing[0].(int); ok { + marker = existing[0] + } + } + var result bson.A + if marker != nil { + result = make(bson.A, 0, len(elements)+1) + result = append(result, marker) + result = append(result, elements...) + } else { + result = make(bson.A, len(elements)) + copy(result, elements) + } + dSet(doc, key, result) +} + +// extractBinaryIDFromDoc extracts a binary ID string from a bson.D field. +func extractBinaryIDFromDoc(val any) string { + if bin, ok := val.(primitive.Binary); ok { + return types.BlobToUUID(bin.Data) + } + return "" +} + +// ============================================================================ +// BSON widget tree walking (used by cmd_widgets.go) +// ============================================================================ + +// bsonWidgetResult holds a found widget and its parent context. +type bsonWidgetResult struct { + widget bson.D + parentArr []any + parentKey string + parentDoc bson.D + index int + colPropKeys map[string]string +} + +// widgetFinder is a function type for locating widgets in a raw BSON tree. +type widgetFinder func(rawData bson.D, widgetName string) *bsonWidgetResult + +// findBsonWidget searches the raw BSON page tree for a widget by name. +func findBsonWidget(rawData bson.D, widgetName string) *bsonWidgetResult { + formCall := dGetDoc(rawData, "FormCall") + if formCall == nil { + return nil + } + args := dGetArrayElements(dGet(formCall, "Arguments")) + for _, arg := range args { + argDoc, ok := arg.(bson.D) + if !ok { + continue + } + if result := findInWidgetArray(argDoc, "Widgets", widgetName); result != nil { + return result + } + } + return nil +} + +// findBsonWidgetInSnippet searches the raw BSON snippet tree for a widget by name. +func findBsonWidgetInSnippet(rawData bson.D, widgetName string) *bsonWidgetResult { + if result := findInWidgetArray(rawData, "Widgets", widgetName); result != nil { + return result + } + if widgetContainer := dGetDoc(rawData, "Widget"); widgetContainer != nil { + if result := findInWidgetArray(widgetContainer, "Widgets", widgetName); result != nil { + return result + } + } + return nil +} + +// findInWidgetArray searches a widget array (by key in parentDoc) for a named widget. +func findInWidgetArray(parentDoc bson.D, key string, widgetName string) *bsonWidgetResult { + elements := dGetArrayElements(dGet(parentDoc, key)) + for i, elem := range elements { + wDoc, ok := elem.(bson.D) + if !ok { + continue + } + if dGetString(wDoc, "Name") == widgetName { + return &bsonWidgetResult{ + widget: wDoc, + parentArr: elements, + parentKey: key, + parentDoc: parentDoc, + index: i, + } + } + if result := findInWidgetChildren(wDoc, widgetName); result != nil { + return result + } + } + return nil +} + +// findInWidgetChildren recursively searches widget children for a named widget. +func findInWidgetChildren(wDoc bson.D, widgetName string) *bsonWidgetResult { + typeName := dGetString(wDoc, "$Type") + + if result := findInWidgetArray(wDoc, "Widgets", widgetName); result != nil { + return result + } + if result := findInWidgetArray(wDoc, "FooterWidgets", widgetName); result != nil { + return result + } + + // LayoutGrid: Rows[].Columns[].Widgets[] + if strings.Contains(typeName, "LayoutGrid") { + rows := dGetArrayElements(dGet(wDoc, "Rows")) + for _, row := range rows { + rowDoc, ok := row.(bson.D) + if !ok { + continue + } + cols := dGetArrayElements(dGet(rowDoc, "Columns")) + for _, col := range cols { + colDoc, ok := col.(bson.D) + if !ok { + continue + } + if result := findInWidgetArray(colDoc, "Widgets", widgetName); result != nil { + return result + } + } + } + } + + // TabContainer: TabPages[].Widgets[] + tabPages := dGetArrayElements(dGet(wDoc, "TabPages")) + for _, tp := range tabPages { + tpDoc, ok := tp.(bson.D) + if !ok { + continue + } + if result := findInWidgetArray(tpDoc, "Widgets", widgetName); result != nil { + return result + } + } + + // ControlBar + if controlBar := dGetDoc(wDoc, "ControlBar"); controlBar != nil { + if result := findInWidgetArray(controlBar, "Items", widgetName); result != nil { + return result + } + } + + // CustomWidget (pluggable): Object.Properties[].Value.Widgets[] + if strings.Contains(typeName, "CustomWidget") { + if obj := dGetDoc(wDoc, "Object"); obj != nil { + props := dGetArrayElements(dGet(obj, "Properties")) + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { + if result := findInWidgetArray(valDoc, "Widgets", widgetName); result != nil { + return result + } + } + } + } + } + + return nil +} + +// setTranslatableText sets a translatable text value in BSON. +func setTranslatableText(parent bson.D, key string, value interface{}) { + strVal, ok := value.(string) + if !ok { + return + } + + target := parent + if key != "" { + if nested := dGetDoc(parent, key); nested != nil { + target = nested + } else { + dSet(parent, key, strVal) + return + } + } + + translations := dGetArrayElements(dGet(target, "Translations")) + if len(translations) > 0 { + if tDoc, ok := translations[0].(bson.D); ok { + dSet(tDoc, "Text", strVal) + return + } + } + dSet(target, "Text", strVal) +} + +// ============================================================================ +// Widget property setting (used by cmd_widgets.go) +// ============================================================================ + +// setRawWidgetProperty sets a property on a raw BSON widget document. +func setRawWidgetProperty(widget bson.D, propName string, value interface{}) error { + switch propName { + case "Caption": + return setWidgetCaption(widget, value) + case "Content": + return setWidgetContent(widget, value) + case "Label": + return setWidgetLabel(widget, value) + case "ButtonStyle": + if s, ok := value.(string); ok { + dSet(widget, "ButtonStyle", s) + } + return nil + case "Class": + if appearance := dGetDoc(widget, "Appearance"); appearance != nil { + if s, ok := value.(string); ok { + dSet(appearance, "Class", s) + } + } + return nil + case "Style": + if appearance := dGetDoc(widget, "Appearance"); appearance != nil { + if s, ok := value.(string); ok { + dSet(appearance, "Style", s) + } + } + return nil + case "Editable": + if s, ok := value.(string); ok { + dSet(widget, "Editable", s) + } + return nil + case "Visible": + if s, ok := value.(string); ok { + dSet(widget, "Visible", s) + } else if b, ok := value.(bool); ok { + if b { + dSet(widget, "Visible", "True") + } else { + dSet(widget, "Visible", "False") + } + } + return nil + case "Name": + if s, ok := value.(string); ok { + dSet(widget, "Name", s) + } + return nil + case "Attribute": + return setWidgetAttributeRef(widget, value) + default: + return setPluggableWidgetProperty(widget, propName, value) + } +} + +func setWidgetCaption(widget bson.D, value interface{}) error { + caption := dGetDoc(widget, "Caption") + if caption == nil { + setTranslatableText(widget, "Caption", value) + return nil + } + setTranslatableText(caption, "", value) + return nil +} + +func setWidgetContent(widget bson.D, value interface{}) error { + strVal, ok := value.(string) + if !ok { + return mdlerrors.NewValidation("Content value must be a string") + } + content := dGetDoc(widget, "Content") + if content == nil { + return mdlerrors.NewValidation("widget has no Content property") + } + template := dGetDoc(content, "Template") + if template == nil { + return mdlerrors.NewValidation("Content has no Template") + } + items := dGetArrayElements(dGet(template, "Items")) + if len(items) > 0 { + if itemDoc, ok := items[0].(bson.D); ok { + dSet(itemDoc, "Text", strVal) + return nil + } + } + return mdlerrors.NewValidation("Content.Template has no Items with Text") +} + +func setWidgetLabel(widget bson.D, value interface{}) error { + label := dGetDoc(widget, "Label") + if label == nil { + return nil + } + setTranslatableText(label, "Caption", value) + return nil +} + +func setWidgetAttributeRef(widget bson.D, value interface{}) error { + attrPath, ok := value.(string) + if !ok { + return mdlerrors.NewValidation("Attribute value must be a string") + } + + var attrRefValue interface{} + if strings.Count(attrPath, ".") >= 2 { + attrRefValue = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$AttributeRef"}, + {Key: "Attribute", Value: attrPath}, + {Key: "EntityRef", Value: nil}, + } + } else { + attrRefValue = nil + } + + for i, elem := range widget { + if elem.Key == "AttributeRef" { + widget[i].Value = attrRefValue + return nil + } + } + return mdlerrors.NewValidation("widget does not have an AttributeRef property; Attribute can only be SET on input widgets (TextBox, TextArea, DatePicker, etc.)") +} + +func setPluggableWidgetProperty(widget bson.D, propName string, value interface{}) error { + obj := dGetDoc(widget, "Object") + if obj == nil { + return mdlerrors.NewNotFoundMsg("property", propName, fmt.Sprintf("property %q not found (widget has no pluggable Object)", propName)) + } + + propTypeKeyMap := make(map[string]string) + if widgetType := dGetDoc(widget, "Type"); widgetType != nil { + if objType := dGetDoc(widgetType, "ObjectType"); objType != nil { + propTypes := dGetArrayElements(dGet(objType, "PropertyTypes")) + for _, pt := range propTypes { + ptDoc, ok := pt.(bson.D) + if !ok { + continue + } + key := dGetString(ptDoc, "PropertyKey") + if key == "" { + continue + } + id := extractBinaryIDFromDoc(dGet(ptDoc, "$ID")) + if id != "" { + propTypeKeyMap[id] = key + } + } + } + } + + props := dGetArrayElements(dGet(obj, "Properties")) + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + typePointerID := extractBinaryIDFromDoc(dGet(propDoc, "TypePointer")) + propKey := propTypeKeyMap[typePointerID] + if propKey != propName { + continue + } + if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { + switch v := value.(type) { + case string: + dSet(valDoc, "PrimitiveValue", v) + case bool: + if v { + dSet(valDoc, "PrimitiveValue", "yes") + } else { + dSet(valDoc, "PrimitiveValue", "no") + } + case int: + dSet(valDoc, "PrimitiveValue", fmt.Sprintf("%d", v)) + case float64: + dSet(valDoc, "PrimitiveValue", fmt.Sprintf("%g", v)) + default: + dSet(valDoc, "PrimitiveValue", fmt.Sprintf("%v", v)) + } + return nil + } + return mdlerrors.NewValidation(fmt.Sprintf("property %q has no Value map", propName)) + } + return mdlerrors.NewNotFound("pluggable property", propName) +} diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index cfb5b04b..f27d313d 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -6,15 +6,11 @@ import ( "fmt" "strings" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" - "github.com/mendixlabs/mxcli/mdl/ast" - "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/mdl/backend" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/mpr" + "github.com/mendixlabs/mxcli/sdk/pages" ) // execAlterPage handles ALTER PAGE/SNIPPET Module.Name { operations }. @@ -54,57 +50,47 @@ func execAlterPage(ctx *ExecContext, s *ast.AlterPageStmt) error { containerID = h.FindModuleID(page.ContainerID) } - // Load raw BSON as ordered document (bson.D preserves field ordering, - // which is required by Mendix Studio Pro). - rawBytes, err := ctx.Backend.GetRawUnitBytes(unitID) + // Open the page for mutation via the backend + mutator, err := ctx.Backend.OpenPageForMutation(unitID) if err != nil { - return mdlerrors.NewBackend("load raw "+strings.ToLower(containerType)+" data", err) - } - var rawData bson.D - if err := bson.Unmarshal(rawBytes, &rawData); err != nil { - return mdlerrors.NewBackend("unmarshal "+strings.ToLower(containerType)+" BSON", err) + return mdlerrors.NewBackend("open "+strings.ToLower(containerType)+" for mutation", err) } // Resolve module name for building new widgets modName := h.GetModuleName(containerID) - // Apply operations sequentially using the appropriate BSON finder - findWidget := findBsonWidget // page default - if containerType == "SNIPPET" { - findWidget = findBsonWidgetInSnippet - } - for _, op := range s.Operations { switch o := op.(type) { case *ast.SetPropertyOp: - if err := applySetPropertyWith(rawData, o, findWidget); err != nil { + if err := applySetPropertyMutator(mutator, o); err != nil { return mdlerrors.NewBackend("SET", err) } case *ast.InsertWidgetOp: - if err := applyInsertWidgetWith(ctx, rawData, o, modName, containerID, findWidget); err != nil { + if err := applyInsertWidgetMutator(ctx, mutator, o, modName, containerID); err != nil { return mdlerrors.NewBackend("INSERT", err) } case *ast.DropWidgetOp: - if err := applyDropWidgetWith(rawData, o, findWidget); err != nil { + if err := applyDropWidgetMutator(mutator, o); err != nil { return mdlerrors.NewBackend("DROP", err) } case *ast.ReplaceWidgetOp: - if err := applyReplaceWidgetWith(ctx, rawData, o, modName, containerID, findWidget); err != nil { + if err := applyReplaceWidgetMutator(ctx, mutator, o, modName, containerID); err != nil { return mdlerrors.NewBackend("REPLACE", err) } case *ast.AddVariableOp: - if err := applyAddVariable(&rawData, o); err != nil { + if err := mutator.AddVariable(o.Variable.Name, o.Variable.DataType, o.Variable.DefaultValue); err != nil { return mdlerrors.NewBackend("ADD VARIABLE", err) } case *ast.DropVariableOp: - if err := applyDropVariable(rawData, o); err != nil { + if err := mutator.DropVariable(o.VariableName); err != nil { return mdlerrors.NewBackend("DROP VARIABLE", err) } case *ast.SetLayoutOp: if containerType == "SNIPPET" { return mdlerrors.NewUnsupported("SET Layout is not supported for snippets") } - if err := applySetLayout(rawData, o); err != nil { + newLayoutQN := o.NewLayout.Module + "." + o.NewLayout.Name + if err := mutator.SetLayout(newLayoutQN, o.Mappings); err != nil { return mdlerrors.NewBackend("SET Layout", err) } default: @@ -112,14 +98,8 @@ func execAlterPage(ctx *ExecContext, s *ast.AlterPageStmt) error { } } - // Marshal back to BSON bytes (bson.D preserves field ordering) - outBytes, err := bson.Marshal(rawData) - if err != nil { - return mdlerrors.NewBackend("marshal modified "+strings.ToLower(containerType), err) - } - - // Save - if err := ctx.Backend.UpdateRawUnit(string(unitID), outBytes); err != nil { + // Persist + if err := mutator.Save(); err != nil { return mdlerrors.NewBackend("save modified "+strings.ToLower(containerType), err) } @@ -127,1417 +107,125 @@ func execAlterPage(ctx *ExecContext, s *ast.AlterPageStmt) error { return nil } -// applySetLayout rewrites the FormCall to reference a new layout. -// It updates the Form field and remaps Parameter strings in each FormCallArgument. -func applySetLayout(rawData bson.D, op *ast.SetLayoutOp) error { - newLayoutQN := op.NewLayout.Module + "." + op.NewLayout.Name - - // Find FormCall in the page BSON - var formCall bson.D - for _, elem := range rawData { - if elem.Key == "FormCall" { - if doc, ok := elem.Value.(bson.D); ok { - formCall = doc - } - break - } - } - if formCall == nil { - return mdlerrors.NewValidation("page has no FormCall (layout reference)") - } - - // Detect the old layout name from existing Parameter values - oldLayoutQN := "" - for _, elem := range formCall { - if elem.Key == "Form" { - if s, ok := elem.Value.(string); ok && s != "" { - oldLayoutQN = s - } - } - if elem.Key == "Arguments" { - if arr, ok := elem.Value.(bson.A); ok { - for _, item := range arr { - if doc, ok := item.(bson.D); ok { - for _, field := range doc { - if field.Key == "Parameter" { - if s, ok := field.Value.(string); ok && oldLayoutQN == "" { - // Extract layout QN from "Atlas_Core.Atlas_TopBar.Main" - if lastDot := strings.LastIndex(s, "."); lastDot > 0 { - oldLayoutQN = s[:lastDot] - } - } - } - } - } - } - } - } - } - - if oldLayoutQN == "" { - return mdlerrors.NewValidation("cannot determine current layout from FormCall") - } - - if oldLayoutQN == newLayoutQN { - return nil // Already using the target layout - } - - // Update Form field - for i, elem := range formCall { - if elem.Key == "Form" { - formCall[i].Value = newLayoutQN - } - } - - // If Form field doesn't exist, add it - hasForm := false - for _, elem := range formCall { - if elem.Key == "Form" { - hasForm = true - break - } - } - if !hasForm { - // Insert before Arguments - for i, elem := range formCall { - if elem.Key == "Arguments" { - formCall = append(formCall[:i+1], formCall[i:]...) - formCall[i] = bson.E{Key: "Form", Value: newLayoutQN} - break - } - } - } - - // Remap Parameter strings in each FormCallArgument - for _, elem := range formCall { - if elem.Key != "Arguments" { - continue - } - arr, ok := elem.Value.(bson.A) - if !ok { - continue - } - for _, item := range arr { - doc, ok := item.(bson.D) - if !ok { - continue - } - for j, field := range doc { - if field.Key != "Parameter" { - continue - } - paramStr, ok := field.Value.(string) - if !ok { - continue - } - // Extract placeholder name: "Atlas_Core.Atlas_Default.Main" -> "Main" - placeholder := paramStr - if strings.HasPrefix(paramStr, oldLayoutQN+".") { - placeholder = paramStr[len(oldLayoutQN)+1:] - } - - // Apply explicit mapping if provided - if op.Mappings != nil { - if mapped, ok := op.Mappings[placeholder]; ok { - placeholder = mapped - } - } - - // Write new parameter value - doc[j].Value = newLayoutQN + "." + placeholder - } - } - } - - // Write FormCall back into rawData - for i, elem := range rawData { - if elem.Key == "FormCall" { - rawData[i].Value = formCall - break - } - } - - return nil -} - -// ============================================================================ -// bson.D helper functions for ordered document access -// ============================================================================ - -// dGet returns the value for a key in a bson.D, or nil if not found. -func dGet(doc bson.D, key string) any { - for _, elem := range doc { - if elem.Key == key { - return elem.Value - } - } - return nil -} - -// dGetDoc returns a nested bson.D field value, or nil. -func dGetDoc(doc bson.D, key string) bson.D { - v := dGet(doc, key) - if d, ok := v.(bson.D); ok { - return d - } - return nil -} - -// dGetString returns a string field value, or "". -func dGetString(doc bson.D, key string) string { - v := dGet(doc, key) - if s, ok := v.(string); ok { - return s - } - return "" -} - -// dSet sets a field value in a bson.D in place. If the key exists, it's updated -// and returns true. If the key is not found, returns false. -func dSet(doc bson.D, key string, value any) bool { - for i := range doc { - if doc[i].Key == key { - doc[i].Value = value - return true - } - } - return false -} - -// dGetArrayElements extracts Mendix array elements from a bson.D field value. -// Handles the int32 type marker at index 0. Works with bson.A and []any. -func dGetArrayElements(val any) []any { - arr := toBsonA(val) - if len(arr) == 0 { - return nil - } - // Skip type marker (int32) at index 0 - if _, ok := arr[0].(int32); ok { - return arr[1:] - } - if _, ok := arr[0].(int); ok { - return arr[1:] - } - return arr -} - -// toBsonA converts various BSON array types to []any. -func toBsonA(v any) []any { - switch arr := v.(type) { - case bson.A: - return []any(arr) - case []any: - return arr - default: - return nil - } -} - -// dSetArray sets a Mendix-style BSON array field, preserving the int32 marker. -func dSetArray(doc bson.D, key string, elements []any) { - existing := toBsonA(dGet(doc, key)) - var marker any - if len(existing) > 0 { - if _, ok := existing[0].(int32); ok { - marker = existing[0] - } else if _, ok := existing[0].(int); ok { - marker = existing[0] - } - } - var result bson.A - if marker != nil { - result = make(bson.A, 0, len(elements)+1) - result = append(result, marker) - result = append(result, elements...) - } else { - result = make(bson.A, len(elements)) - copy(result, elements) - } - dSet(doc, key, result) -} - -// extractBinaryIDFromDoc extracts a binary ID string from a bson.D field. -func extractBinaryIDFromDoc(val any) string { - if bin, ok := val.(primitive.Binary); ok { - return types.BlobToUUID(bin.Data) - } - return "" -} - -// ============================================================================ -// BSON widget tree walking -// ============================================================================ - -// bsonWidgetResult holds a found widget and its parent context. -type bsonWidgetResult struct { - widget bson.D // the widget document itself - parentArr []any // the parent array elements (without marker) - parentKey string // key in the parent doc that holds this array - parentDoc bson.D // the doc containing parentKey - index int // index in parentArr - colPropKeys map[string]string // column property TypePointer → key map (only set for column results) -} - -// widgetFinder is a function type for locating widgets in a raw BSON tree. -type widgetFinder func(rawData bson.D, widgetName string) *bsonWidgetResult - -// findBsonWidget searches the raw BSON page tree for a widget by name. -// Page format: FormCall.Arguments[].Widgets[] -func findBsonWidget(rawData bson.D, widgetName string) *bsonWidgetResult { - formCall := dGetDoc(rawData, "FormCall") - if formCall == nil { - return nil - } - - args := dGetArrayElements(dGet(formCall, "Arguments")) - for _, arg := range args { - argDoc, ok := arg.(bson.D) - if !ok { - continue - } - if result := findInWidgetArray(argDoc, "Widgets", widgetName); result != nil { - return result - } - } - return nil -} - -// findBsonWidgetInSnippet searches the raw BSON snippet tree for a widget by name. -// Snippet format: Widgets[] (Studio Pro) or Widget.Widgets[] (mxcli). -func findBsonWidgetInSnippet(rawData bson.D, widgetName string) *bsonWidgetResult { - // Studio Pro format: top-level "Widgets" array - if result := findInWidgetArray(rawData, "Widgets", widgetName); result != nil { - return result - } - // mxcli format: "Widget" (singular) container with "Widgets" inside - if widgetContainer := dGetDoc(rawData, "Widget"); widgetContainer != nil { - if result := findInWidgetArray(widgetContainer, "Widgets", widgetName); result != nil { - return result - } - } - return nil -} - -// findInWidgetArray searches a widget array (by key in parentDoc) for a named widget. -func findInWidgetArray(parentDoc bson.D, key string, widgetName string) *bsonWidgetResult { - elements := dGetArrayElements(dGet(parentDoc, key)) - for i, elem := range elements { - wDoc, ok := elem.(bson.D) - if !ok { - continue - } - if dGetString(wDoc, "Name") == widgetName { - return &bsonWidgetResult{ - widget: wDoc, - parentArr: elements, - parentKey: key, - parentDoc: parentDoc, - index: i, - } - } - // Recurse into children - if result := findInWidgetChildren(wDoc, widgetName); result != nil { - return result - } - } - return nil -} - -// findInWidgetChildren recursively searches widget children for a named widget. -func findInWidgetChildren(wDoc bson.D, widgetName string) *bsonWidgetResult { - typeName := dGetString(wDoc, "$Type") - - // Direct Widgets[] children (Container, DataView body, TabPage, GroupBox, etc.) - if result := findInWidgetArray(wDoc, "Widgets", widgetName); result != nil { - return result - } - - // FooterWidgets[] (DataView footer) - if result := findInWidgetArray(wDoc, "FooterWidgets", widgetName); result != nil { - return result - } - - // LayoutGrid: Rows[].Columns[].Widgets[] - if strings.Contains(typeName, "LayoutGrid") { - rows := dGetArrayElements(dGet(wDoc, "Rows")) - for _, row := range rows { - rowDoc, ok := row.(bson.D) - if !ok { - continue - } - cols := dGetArrayElements(dGet(rowDoc, "Columns")) - for _, col := range cols { - colDoc, ok := col.(bson.D) - if !ok { - continue - } - if result := findInWidgetArray(colDoc, "Widgets", widgetName); result != nil { - return result - } - } - } - } - - // TabContainer: TabPages[].Widgets[] - if result := findInTabPages(wDoc, widgetName); result != nil { - return result - } - - // ControlBar widgets - if result := findInControlBar(wDoc, widgetName); result != nil { - return result - } - - // CustomWidget (pluggable): Object.Properties[].Value.Widgets[] - if strings.Contains(typeName, "CustomWidget") { - if obj := dGetDoc(wDoc, "Object"); obj != nil { - props := dGetArrayElements(dGet(obj, "Properties")) - for _, prop := range props { - propDoc, ok := prop.(bson.D) - if !ok { - continue - } - if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { - if result := findInWidgetArray(valDoc, "Widgets", widgetName); result != nil { - return result - } - } - } - } - } - - return nil -} - -// findInTabPages searches TabPages[].Widgets[] for a named widget. -func findInTabPages(wDoc bson.D, widgetName string) *bsonWidgetResult { - tabPages := dGetArrayElements(dGet(wDoc, "TabPages")) - for _, tp := range tabPages { - tpDoc, ok := tp.(bson.D) - if !ok { - continue - } - if result := findInWidgetArray(tpDoc, "Widgets", widgetName); result != nil { - return result - } - } - return nil -} - -// findInControlBar searches ControlBarItems within a ControlBar for a named widget. -func findInControlBar(wDoc bson.D, widgetName string) *bsonWidgetResult { - controlBar := dGetDoc(wDoc, "ControlBar") - if controlBar == nil { - return nil - } - return findInWidgetArray(controlBar, "Items", widgetName) -} - // ============================================================================ -// DataGrid2 column finder +// SET property via mutator // ============================================================================ -// findBsonColumn finds a column inside a DataGrid2 widget by derived name. -// It locates the grid widget first, then searches its columns Objects[] array. -// Returns a bsonWidgetResult where parentArr/parentDoc/parentKey point to the -// columns array, so INSERT/DROP/REPLACE work via standard array manipulation. -func findBsonColumn(rawData bson.D, gridName, columnName string, find widgetFinder) *bsonWidgetResult { - // Find the DataGrid2 widget - gridResult := find(rawData, gridName) - if gridResult == nil { - return nil - } - - // Build grid-level PropertyTypeID -> key map - gridPropKeyMap := buildPropKeyMap(gridResult.widget) - - // Navigate to the "columns" property's Value.Objects[] - obj := dGetDoc(gridResult.widget, "Object") - if obj == nil { - return nil - } - - props := dGetArrayElements(dGet(obj, "Properties")) - for _, prop := range props { - propDoc, ok := prop.(bson.D) - if !ok { - continue - } - typePointerID := extractBinaryIDFromDoc(dGet(propDoc, "TypePointer")) - propKey := gridPropKeyMap[typePointerID] - if propKey != "columns" { - continue - } - - valDoc := dGetDoc(propDoc, "Value") - if valDoc == nil { - return nil - } - - // Build column-level PropertyTypeID -> key map for name derivation - colPropKeyMap := buildColumnPropKeyMap(gridResult.widget, typePointerID) - - // Search columns by derived name - columns := dGetArrayElements(dGet(valDoc, "Objects")) - for i, colItem := range columns { - colDoc, ok := colItem.(bson.D) - if !ok { - continue - } - derived := deriveColumnNameBson(colDoc, colPropKeyMap, i) - if derived == columnName { - return &bsonWidgetResult{ - widget: colDoc, - parentArr: columns, - parentKey: "Objects", - parentDoc: valDoc, - index: i, - colPropKeys: colPropKeyMap, - } - } - } - return nil // found columns property but no matching column - } - return nil -} - -// buildPropKeyMap builds a TypePointer ID -> PropertyKey map from a widget's -// Type.ObjectType.PropertyTypes array. -func buildPropKeyMap(widgetDoc bson.D) map[string]string { - m := make(map[string]string) - widgetType := dGetDoc(widgetDoc, "Type") - if widgetType == nil { - return m - } - objType := dGetDoc(widgetType, "ObjectType") - if objType == nil { - return m - } - for _, pt := range dGetArrayElements(dGet(objType, "PropertyTypes")) { - ptDoc, ok := pt.(bson.D) - if !ok { - continue - } - key := dGetString(ptDoc, "PropertyKey") - id := extractBinaryIDFromDoc(dGet(ptDoc, "$ID")) - if key != "" && id != "" { - m[id] = key - } - } - return m -} - -// buildColumnPropKeyMap builds a TypePointer ID -> PropertyKey map for column -// properties. It navigates: Type.ObjectType.PropertyTypes["columns"].ValueType.ObjectType.PropertyTypes -func buildColumnPropKeyMap(widgetDoc bson.D, columnsTypePointerID string) map[string]string { - m := make(map[string]string) - widgetType := dGetDoc(widgetDoc, "Type") - if widgetType == nil { - return m - } - objType := dGetDoc(widgetType, "ObjectType") - if objType == nil { - return m - } - // Find the columns PropertyType entry - for _, pt := range dGetArrayElements(dGet(objType, "PropertyTypes")) { - ptDoc, ok := pt.(bson.D) - if !ok { - continue - } - id := extractBinaryIDFromDoc(dGet(ptDoc, "$ID")) - if id != columnsTypePointerID { - continue - } - // Navigate to ValueType.ObjectType.PropertyTypes - valType := dGetDoc(ptDoc, "ValueType") - if valType == nil { - return m - } - colObjType := dGetDoc(valType, "ObjectType") - if colObjType == nil { - return m - } - for _, cpt := range dGetArrayElements(dGet(colObjType, "PropertyTypes")) { - cptDoc, ok := cpt.(bson.D) - if !ok { - continue - } - key := dGetString(cptDoc, "PropertyKey") - cid := extractBinaryIDFromDoc(dGet(cptDoc, "$ID")) - if key != "" && cid != "" { - m[cid] = key - } - } - return m - } - return m -} - -// deriveColumnNameBson derives a column name from its BSON WidgetObject, -// matching the logic in deriveColumnName() in cmd_pages_describe_output.go. -func deriveColumnNameBson(colDoc bson.D, propKeyMap map[string]string, index int) string { - var attribute, caption string - - props := dGetArrayElements(dGet(colDoc, "Properties")) - for _, prop := range props { - propDoc, ok := prop.(bson.D) - if !ok { - continue - } - typePointerID := extractBinaryIDFromDoc(dGet(propDoc, "TypePointer")) - propKey := propKeyMap[typePointerID] - - valDoc := dGetDoc(propDoc, "Value") - if valDoc == nil { - continue - } - - switch propKey { - case "attribute": - // Extract attribute path from AttributeRef - if attrRef := dGetString(valDoc, "AttributeRef"); attrRef != "" { - attribute = attrRef - } else if attrDoc := dGetDoc(valDoc, "AttributeRef"); attrDoc != nil { - attribute = dGetString(attrDoc, "Attribute") - } - case "header": - // Extract caption from TextTemplate - if tmpl := dGetDoc(valDoc, "TextTemplate"); tmpl != nil { - items := dGetArrayElements(dGet(tmpl, "Items")) - for _, item := range items { - if itemDoc, ok := item.(bson.D); ok { - if text := dGetString(itemDoc, "Text"); text != "" { - caption = text - } - } - } - } - } - } - - // Apply same derivation logic as deriveColumnName - if attribute != "" { - parts := strings.Split(attribute, ".") - return parts[len(parts)-1] - } - if caption != "" { - return sanitizeColumnName(caption) - } - return fmt.Sprintf("col%d", index+1) -} - -// sanitizeColumnName converts a caption string into a valid column identifier. -func sanitizeColumnName(caption string) string { - var result []rune - for _, r := range caption { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { - result = append(result, r) - } else { - result = append(result, '_') - } - } - return string(result) -} - -// columnPropertyAliases maps user-facing property names to internal column property keys. -var columnPropertyAliases = map[string]string{ - "Caption": "header", - "Attribute": "attribute", - "Visible": "visible", - "Alignment": "alignment", - "WrapText": "wrapText", - "Sortable": "sortable", - "Resizable": "resizable", - "Draggable": "draggable", - "Hidable": "hidable", - "ColumnWidth": "width", - "Size": "size", - "ShowContentAs": "showContentAs", - "ColumnClass": "columnClass", - "Tooltip": "tooltip", -} - -// setColumnProperty sets a property on a DataGrid2 column WidgetObject. -// propKeyMap maps TypePointer IDs to property keys (from the parent grid's column type). -func setColumnProperty(colDoc bson.D, propKeyMap map[string]string, propName string, value interface{}) error { - // Map user-facing name to internal property key - internalKey := columnPropertyAliases[propName] - if internalKey == "" { - internalKey = propName - } - - // Search column Properties[] for matching property and update - props := dGetArrayElements(dGet(colDoc, "Properties")) - for _, prop := range props { - propDoc, ok := prop.(bson.D) - if !ok { - continue - } - typePointerID := extractBinaryIDFromDoc(dGet(propDoc, "TypePointer")) - propKey := propKeyMap[typePointerID] - if propKey != internalKey { - continue - } - if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { - strVal := fmt.Sprintf("%v", value) - dSet(valDoc, "PrimitiveValue", strVal) - return nil - } - return mdlerrors.NewValidation(fmt.Sprintf("column property %q has no Value", propName)) - } - return mdlerrors.NewNotFound("column property", propName) -} - -// ============================================================================ -// SET property -// ============================================================================ - -// applySetProperty modifies widget properties in the raw BSON tree (page format). -func applySetProperty(rawData bson.D, op *ast.SetPropertyOp) error { - return applySetPropertyWith(rawData, op, findBsonWidget) -} - -// applySetPropertyWith modifies widget properties using the given widget finder. -func applySetPropertyWith(rawData bson.D, op *ast.SetPropertyOp, find widgetFinder) error { - if op.Target.Widget == "" { - // Page/snippet-level SET - return applyPageLevelSet(rawData, op.Properties) - } - - // Find the widget (or column via dotted ref) - var result *bsonWidgetResult - if op.Target.IsColumn() { - result = findBsonColumn(rawData, op.Target.Widget, op.Target.Column, find) - } else { - result = find(rawData, op.Target.Widget) - } - if result == nil { - return mdlerrors.NewNotFound("widget", op.Target.Name()) - } - - // Apply each property +func applySetPropertyMutator(mutator backend.PageMutator, op *ast.SetPropertyOp) error { for propName, value := range op.Properties { if op.Target.IsColumn() { - if err := setColumnProperty(result.widget, result.colPropKeys, propName, value); err != nil { + if err := mutator.SetColumnProperty(op.Target.Widget, op.Target.Column, propName, value); err != nil { return mdlerrors.NewBackend("set "+propName+" on "+op.Target.Name(), err) } - } else { - if err := setRawWidgetProperty(result.widget, propName, value); err != nil { - return mdlerrors.NewBackend("set "+propName+" on "+op.Target.Name(), err) - } - } - } - return nil -} - -// applyPageLevelSet handles page-level SET (e.g., SET Title = 'New Title'). -func applyPageLevelSet(rawData bson.D, properties map[string]interface{}) error { - for propName, value := range properties { - switch propName { - case "Title": - // Title is stored as FormCall.Title or at the top level - if formCall := dGetDoc(rawData, "FormCall"); formCall != nil { - setTranslatableText(formCall, "Title", value) - } else { - setTranslatableText(rawData, "Title", value) - } - case "Url": - // URL is stored as a plain string at the top level - strVal, _ := value.(string) - dSet(rawData, "Url", strVal) - default: - return mdlerrors.NewUnsupported("unsupported page-level property: " + propName) - } - } - return nil -} - -// setRawWidgetProperty sets a property on a raw BSON widget document. -func setRawWidgetProperty(widget bson.D, propName string, value interface{}) error { - // Handle known standard BSON properties - switch propName { - case "Caption": - return setWidgetCaption(widget, value) - case "Content": - return setWidgetContent(widget, value) - case "Label": - return setWidgetLabel(widget, value) - case "ButtonStyle": - if s, ok := value.(string); ok { - dSet(widget, "ButtonStyle", s) - } - return nil - case "Class": - if appearance := dGetDoc(widget, "Appearance"); appearance != nil { - if s, ok := value.(string); ok { - dSet(appearance, "Class", s) + } else if propName == "DataSource" { + // DataSource requires special handling via SetWidgetDataSource + ds, err := convertASTDataSource(value) + if err != nil { + return err } - } - return nil - case "Style": - if appearance := dGetDoc(widget, "Appearance"); appearance != nil { - if s, ok := value.(string); ok { - dSet(appearance, "Style", s) + if err := mutator.SetWidgetDataSource(op.Target.Widget, ds); err != nil { + return mdlerrors.NewBackend("set DataSource on "+op.Target.Name(), err) } - } - return nil - case "Editable": - if s, ok := value.(string); ok { - dSet(widget, "Editable", s) - } - return nil - case "Visible": - if s, ok := value.(string); ok { - dSet(widget, "Visible", s) - } else if b, ok := value.(bool); ok { - if b { - dSet(widget, "Visible", "True") - } else { - dSet(widget, "Visible", "False") + } else { + if err := mutator.SetWidgetProperty(op.Target.Widget, propName, value); err != nil { + return mdlerrors.NewBackend("set "+propName+" on "+op.Target.Name(), err) } } - return nil - case "Name": - if s, ok := value.(string); ok { - dSet(widget, "Name", s) - } - return nil - case "Attribute": - return setWidgetAttributeRef(widget, value) - case "DataSource": - return setWidgetDataSource(widget, value) - default: - // Try as pluggable widget property (quoted string property name) - return setPluggableWidgetProperty(widget, propName, value) } -} - -// setWidgetCaption sets the Caption property on a button or text widget. -func setWidgetCaption(widget bson.D, value interface{}) error { - caption := dGetDoc(widget, "Caption") - if caption == nil { - // Try direct caption text - setTranslatableText(widget, "Caption", value) - return nil - } - setTranslatableText(caption, "", value) return nil } -// setWidgetAttributeRef sets or updates the AttributeRef on an input widget. -// The value must be a fully qualified path (Module.Entity.Attribute, 2+ dots). -// If not fully qualified, AttributeRef is set to nil to avoid Studio Pro crash. -func setWidgetAttributeRef(widget bson.D, value interface{}) error { - attrPath, ok := value.(string) - if !ok { - return mdlerrors.NewValidation("Attribute value must be a string") - } - - // Build the new AttributeRef value - var attrRefValue interface{} - if strings.Count(attrPath, ".") >= 2 { - attrRefValue = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: attrPath}, - {Key: "EntityRef", Value: nil}, - } - } else { - // Not fully qualified — clear the ref to avoid Mendix crash - attrRefValue = nil - } - - // Try to update existing AttributeRef field - for i, elem := range widget { - if elem.Key == "AttributeRef" { - widget[i].Value = attrRefValue - return nil - } - } - - // No existing AttributeRef field — this widget may not support it - return mdlerrors.NewValidation("widget does not have an AttributeRef property; Attribute can only be SET on input widgets (TextBox, TextArea, DatePicker, etc.)") -} - -// setWidgetDataSource sets the DataSource on a DataView or list widget. -func setWidgetDataSource(widget bson.D, value interface{}) error { +// convertASTDataSource converts an AST DataSource value to a pages.DataSource. +func convertASTDataSource(value interface{}) (pages.DataSource, error) { ds, ok := value.(*ast.DataSourceV3) if !ok { - return mdlerrors.NewValidation("DataSource value must be a datasource expression") + return nil, mdlerrors.NewValidation("DataSource value must be a datasource expression") } - var serialized interface{} - switch ds.Type { case "selection": - // SELECTION widgetName → Forms$ListenTargetSource - serialized = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$ListenTargetSource"}, - {Key: "ListenTarget", Value: ds.Reference}, - } + return &pages.ListenToWidgetSource{WidgetName: ds.Reference}, nil case "database": - // DATABASE Entity → Forms$DataViewSource with entity ref - var entityRef interface{} - if ds.Reference != "" { - entityRef = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "DomainModels$DirectEntityRef"}, - {Key: "Entity", Value: ds.Reference}, - } - } - serialized = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$DataViewSource"}, - {Key: "EntityRef", Value: entityRef}, - {Key: "ForceFullObjects", Value: false}, - {Key: "SourceVariable", Value: nil}, - } + return &pages.DatabaseSource{EntityName: ds.Reference}, nil case "microflow": - serialized = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$MicroflowSource"}, - {Key: "MicroflowSettings", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$MicroflowSettings"}, - {Key: "Asynchronous", Value: false}, - {Key: "ConfirmationInfo", Value: nil}, - {Key: "FormValidations", Value: "All"}, - {Key: "Microflow", Value: ds.Reference}, - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - {Key: "ProgressBar", Value: "None"}, - {Key: "ProgressMessage", Value: nil}, - }}, - } + return &pages.MicroflowSource{Microflow: ds.Reference}, nil case "nanoflow": - serialized = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NanoflowSource"}, - {Key: "NanoflowSettings", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NanoflowSettings"}, - {Key: "Nanoflow", Value: ds.Reference}, - {Key: "ParameterMappings", Value: bson.A{int32(3)}}, - }}, - } + return &pages.NanoflowSource{Nanoflow: ds.Reference}, nil default: - return mdlerrors.NewUnsupported("unsupported DataSource type for ALTER PAGE SET: " + ds.Type) - } - - dSet(widget, "DataSource", serialized) - return nil -} - -// setWidgetLabel sets the Label.Caption text on input widgets. -func setWidgetLabel(widget bson.D, value interface{}) error { - label := dGetDoc(widget, "Label") - if label == nil { - return nil - } - setTranslatableText(label, "Caption", value) - return nil -} - -// setWidgetContent sets the Content property on a DYNAMICTEXT widget. -// Content is stored as Forms$ClientTemplate → Template (Forms$Text) → Items[] → Translation{Text}. -// This mirrors extractTextContent which reads Content.Template.Items[].Text. -func setWidgetContent(widget bson.D, value interface{}) error { - strVal, ok := value.(string) - if !ok { - return mdlerrors.NewValidation("Content value must be a string") - } - content := dGetDoc(widget, "Content") - if content == nil { - return mdlerrors.NewValidation("widget has no Content property") - } - template := dGetDoc(content, "Template") - if template == nil { - return mdlerrors.NewValidation("Content has no Template") - } - items := dGetArrayElements(dGet(template, "Items")) - if len(items) > 0 { - if itemDoc, ok := items[0].(bson.D); ok { - dSet(itemDoc, "Text", strVal) - return nil - } + return nil, mdlerrors.NewUnsupported("unsupported DataSource type for ALTER PAGE SET: " + ds.Type) } - return mdlerrors.NewValidation("Content.Template has no Items with Text") -} - -// setTranslatableText sets a translatable text value in BSON. -// If key is empty, modifies the doc directly; otherwise navigates to doc[key]. -func setTranslatableText(parent bson.D, key string, value interface{}) { - strVal, ok := value.(string) - if !ok { - return - } - - target := parent - if key != "" { - if nested := dGetDoc(parent, key); nested != nil { - target = nested - } else { - // Try to set directly - dSet(parent, key, strVal) - return - } - } - - // Navigate to Translations[].Text - translations := dGetArrayElements(dGet(target, "Translations")) - if len(translations) > 0 { - if tDoc, ok := translations[0].(bson.D); ok { - dSet(tDoc, "Text", strVal) - return - } - } - - // Direct text value - dSet(target, "Text", strVal) -} - -// setPluggableWidgetProperty sets a property on a pluggable widget's Object.Properties[]. -// Properties are identified by TypePointer referencing a PropertyType entry in the widget's -// Type.ObjectType.PropertyTypes array, NOT by a "Key" field on the property itself. -func setPluggableWidgetProperty(widget bson.D, propName string, value interface{}) error { - obj := dGetDoc(widget, "Object") - if obj == nil { - return mdlerrors.NewNotFoundMsg("property", propName, fmt.Sprintf("property %q not found (widget has no pluggable Object)", propName)) - } - - // Build TypePointer ID -> PropertyKey map from Type.ObjectType.PropertyTypes - propTypeKeyMap := make(map[string]string) - if widgetType := dGetDoc(widget, "Type"); widgetType != nil { - if objType := dGetDoc(widgetType, "ObjectType"); objType != nil { - propTypes := dGetArrayElements(dGet(objType, "PropertyTypes")) - for _, pt := range propTypes { - ptDoc, ok := pt.(bson.D) - if !ok { - continue - } - key := dGetString(ptDoc, "PropertyKey") - if key == "" { - continue - } - id := extractBinaryIDFromDoc(dGet(ptDoc, "$ID")) - if id != "" { - propTypeKeyMap[id] = key - } - } - } - } - - props := dGetArrayElements(dGet(obj, "Properties")) - for _, prop := range props { - propDoc, ok := prop.(bson.D) - if !ok { - continue - } - // Resolve property key via TypePointer - typePointerID := extractBinaryIDFromDoc(dGet(propDoc, "TypePointer")) - propKey := propTypeKeyMap[typePointerID] - if propKey != propName { - continue - } - // Set the value - if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { - switch v := value.(type) { - case string: - dSet(valDoc, "PrimitiveValue", v) - case bool: - if v { - dSet(valDoc, "PrimitiveValue", "yes") - } else { - dSet(valDoc, "PrimitiveValue", "no") - } - case int: - dSet(valDoc, "PrimitiveValue", fmt.Sprintf("%d", v)) - case float64: - dSet(valDoc, "PrimitiveValue", fmt.Sprintf("%g", v)) - default: - dSet(valDoc, "PrimitiveValue", fmt.Sprintf("%v", v)) - } - return nil - } - return mdlerrors.NewValidation(fmt.Sprintf("property %q has no Value map", propName)) - } - return mdlerrors.NewNotFound("pluggable property", propName) } // ============================================================================ -// INSERT widget +// INSERT widget via mutator // ============================================================================ -// applyInsertWidget inserts new widgets before or after a target widget (page format). -func applyInsertWidget(ctx *ExecContext, rawData bson.D, op *ast.InsertWidgetOp, moduleName string, moduleID model.ID) error { - return applyInsertWidgetWith(ctx, rawData, op, moduleName, moduleID, findBsonWidget) -} - -// applyInsertWidgetWith inserts new widgets using the given widget finder. -func applyInsertWidgetWith(ctx *ExecContext, rawData bson.D, op *ast.InsertWidgetOp, moduleName string, moduleID model.ID, find widgetFinder) error { - var result *bsonWidgetResult - if op.Target.IsColumn() { - result = findBsonColumn(rawData, op.Target.Widget, op.Target.Column, find) - } else { - result = find(rawData, op.Target.Widget) - } - if result == nil { - return mdlerrors.NewNotFound("widget", op.Target.Name()) - } - +func applyInsertWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op *ast.InsertWidgetOp, moduleName string, moduleID model.ID) error { // Check for duplicate widget names before building for _, w := range op.Widgets { - if w.Name != "" && find(rawData, w.Name) != nil { + if w.Name != "" && mutator.FindWidget(w.Name) { return mdlerrors.NewAlreadyExistsMsg("widget", w.Name, fmt.Sprintf("duplicate widget name '%s': a widget with this name already exists on the page", w.Name)) } } // Find entity context from enclosing DataView/DataGrid/ListView - entityCtx := findEnclosingEntityContext(rawData, op.Target.Widget) + entityCtx := mutator.EnclosingEntity(op.Target.Widget) - // Build new widget BSON from AST (pass rawData for page param + widget scope resolution) - newBsonWidgets, err := buildWidgetsBson(ctx, op.Widgets, moduleName, moduleID, entityCtx, rawData) + // Build new widgets from AST + widgets, err := buildWidgetsFromAST(ctx, op.Widgets, moduleName, moduleID, entityCtx, mutator) if err != nil { return mdlerrors.NewBackend("build widgets", err) } - // Calculate insertion index - insertIdx := result.index - if op.Position == "AFTER" { - insertIdx = result.index + 1 - } - - // Insert into the parent array - newArr := make([]any, 0, len(result.parentArr)+len(newBsonWidgets)) - newArr = append(newArr, result.parentArr[:insertIdx]...) - newArr = append(newArr, newBsonWidgets...) - newArr = append(newArr, result.parentArr[insertIdx:]...) - - // Update parent - dSetArray(result.parentDoc, result.parentKey, newArr) - - return nil + return mutator.InsertWidget(op.Target.Widget, op.Target.Column, backend.InsertPosition(op.Position), widgets) } // ============================================================================ -// DROP widget +// DROP widget via mutator // ============================================================================ -// applyDropWidget removes widgets from the raw BSON tree (page format). -func applyDropWidget(rawData bson.D, op *ast.DropWidgetOp) error { - return applyDropWidgetWith(rawData, op, findBsonWidget) -} - -// applyDropWidgetWith removes widgets using the given widget finder. -func applyDropWidgetWith(rawData bson.D, op *ast.DropWidgetOp, find widgetFinder) error { - for _, target := range op.Targets { - var result *bsonWidgetResult - if target.IsColumn() { - result = findBsonColumn(rawData, target.Widget, target.Column, find) - } else { - result = find(rawData, target.Widget) - } - if result == nil { - return mdlerrors.NewNotFound("widget", target.Name()) - } - - // Remove from parent array - newArr := make([]any, 0, len(result.parentArr)-1) - newArr = append(newArr, result.parentArr[:result.index]...) - newArr = append(newArr, result.parentArr[result.index+1:]...) - - // Update parent - dSetArray(result.parentDoc, result.parentKey, newArr) +func applyDropWidgetMutator(mutator backend.PageMutator, op *ast.DropWidgetOp) error { + refs := make([]backend.WidgetRef, len(op.Targets)) + for i, t := range op.Targets { + refs[i] = backend.WidgetRef{Widget: t.Widget, Column: t.Column} } - return nil + return mutator.DropWidget(refs) } // ============================================================================ -// REPLACE widget +// REPLACE widget via mutator // ============================================================================ -// applyReplaceWidget replaces a widget with new widgets (page format). -func applyReplaceWidget(ctx *ExecContext, rawData bson.D, op *ast.ReplaceWidgetOp, moduleName string, moduleID model.ID) error { - return applyReplaceWidgetWith(ctx, rawData, op, moduleName, moduleID, findBsonWidget) -} - -// applyReplaceWidgetWith replaces a widget using the given widget finder. -func applyReplaceWidgetWith(ctx *ExecContext, rawData bson.D, op *ast.ReplaceWidgetOp, moduleName string, moduleID model.ID, find widgetFinder) error { - var result *bsonWidgetResult - if op.Target.IsColumn() { - result = findBsonColumn(rawData, op.Target.Widget, op.Target.Column, find) - } else { - result = find(rawData, op.Target.Widget) - } - if result == nil { - return mdlerrors.NewNotFound("widget", op.Target.Name()) - } - +func applyReplaceWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op *ast.ReplaceWidgetOp, moduleName string, moduleID model.ID) error { // Check for duplicate widget names (skip the widget being replaced) for _, w := range op.NewWidgets { - if w.Name != "" && w.Name != op.Target.Widget && find(rawData, w.Name) != nil { + if w.Name != "" && w.Name != op.Target.Widget && mutator.FindWidget(w.Name) { return mdlerrors.NewAlreadyExistsMsg("widget", w.Name, fmt.Sprintf("duplicate widget name '%s': a widget with this name already exists on the page", w.Name)) } } // Find entity context from enclosing DataView/DataGrid/ListView - entityCtx := findEnclosingEntityContext(rawData, op.Target.Widget) + entityCtx := mutator.EnclosingEntity(op.Target.Widget) - // Build new widget BSON from AST (pass rawData for page param + widget scope resolution) - newBsonWidgets, err := buildWidgetsBson(ctx, op.NewWidgets, moduleName, moduleID, entityCtx, rawData) + // Build new widgets from AST + widgets, err := buildWidgetsFromAST(ctx, op.NewWidgets, moduleName, moduleID, entityCtx, mutator) if err != nil { return mdlerrors.NewBackend("build replacement widgets", err) } - // Replace: remove old widget, insert new ones at same position - newArr := make([]any, 0, len(result.parentArr)-1+len(newBsonWidgets)) - newArr = append(newArr, result.parentArr[:result.index]...) - newArr = append(newArr, newBsonWidgets...) - newArr = append(newArr, result.parentArr[result.index+1:]...) - - // Update parent - dSetArray(result.parentDoc, result.parentKey, newArr) - - return nil -} - -// ============================================================================ -// Entity context extraction from BSON tree -// ============================================================================ - -// findEnclosingEntityContext walks the raw BSON tree to find the DataView, DataGrid, -// ListView, or Gallery ancestor of a target widget and extracts the entity name. -// This is needed for INSERT/REPLACE operations so that input widget Binds can be -// resolved to fully qualified attribute paths. -func findEnclosingEntityContext(rawData bson.D, widgetName string) string { - // Start from FormCall.Arguments[].Widgets[] (page format) - if formCall := dGetDoc(rawData, "FormCall"); formCall != nil { - args := dGetArrayElements(dGet(formCall, "Arguments")) - for _, arg := range args { - argDoc, ok := arg.(bson.D) - if !ok { - continue - } - if ctx := findEntityContextInWidgets(argDoc, "Widgets", widgetName, ""); ctx != "" { - return ctx - } - } - } - // Snippet format: Widgets[] or Widget.Widgets[] - if ctx := findEntityContextInWidgets(rawData, "Widgets", widgetName, ""); ctx != "" { - return ctx - } - if widgetContainer := dGetDoc(rawData, "Widget"); widgetContainer != nil { - if ctx := findEntityContextInWidgets(widgetContainer, "Widgets", widgetName, ""); ctx != "" { - return ctx - } - } - return "" -} - -// findEntityContextInWidgets searches a widget array for the target widget, -// tracking entity context from DataView/DataGrid/ListView/Gallery ancestors. -func findEntityContextInWidgets(parentDoc bson.D, key string, widgetName string, currentEntity string) string { - elements := dGetArrayElements(dGet(parentDoc, key)) - for _, elem := range elements { - wDoc, ok := elem.(bson.D) - if !ok { - continue - } - if dGetString(wDoc, "Name") == widgetName { - return currentEntity - } - // Update entity context if this is a data container - entityCtx := currentEntity - if ent := extractEntityFromDataSource(wDoc); ent != "" { - entityCtx = ent - } - // Recurse into children - if ctx := findEntityContextInChildren(wDoc, widgetName, entityCtx); ctx != "" { - return ctx - } - } - return "" -} - -// findEntityContextInChildren recursively searches widget children for the target, -// tracking entity context. Mirrors the traversal logic of findInWidgetChildren. -func findEntityContextInChildren(wDoc bson.D, widgetName string, currentEntity string) string { - typeName := dGetString(wDoc, "$Type") - - // Direct Widgets[] children - if ctx := findEntityContextInWidgets(wDoc, "Widgets", widgetName, currentEntity); ctx != "" { - return ctx - } - // FooterWidgets[] - if ctx := findEntityContextInWidgets(wDoc, "FooterWidgets", widgetName, currentEntity); ctx != "" { - return ctx - } - // LayoutGrid: Rows[].Columns[].Widgets[] - if strings.Contains(typeName, "LayoutGrid") { - rows := dGetArrayElements(dGet(wDoc, "Rows")) - for _, row := range rows { - rowDoc, ok := row.(bson.D) - if !ok { - continue - } - cols := dGetArrayElements(dGet(rowDoc, "Columns")) - for _, col := range cols { - colDoc, ok := col.(bson.D) - if !ok { - continue - } - if ctx := findEntityContextInWidgets(colDoc, "Widgets", widgetName, currentEntity); ctx != "" { - return ctx - } - } - } - } - // TabContainer: TabPages[].Widgets[] - tabPages := dGetArrayElements(dGet(wDoc, "TabPages")) - for _, tp := range tabPages { - tpDoc, ok := tp.(bson.D) - if !ok { - continue - } - if ctx := findEntityContextInWidgets(tpDoc, "Widgets", widgetName, currentEntity); ctx != "" { - return ctx - } - } - // ControlBar - if controlBar := dGetDoc(wDoc, "ControlBar"); controlBar != nil { - if ctx := findEntityContextInWidgets(controlBar, "Items", widgetName, currentEntity); ctx != "" { - return ctx - } - } - // CustomWidget (pluggable): Object.Properties[].Value.Widgets[] - if strings.Contains(typeName, "CustomWidget") { - if obj := dGetDoc(wDoc, "Object"); obj != nil { - props := dGetArrayElements(dGet(obj, "Properties")) - for _, prop := range props { - propDoc, ok := prop.(bson.D) - if !ok { - continue - } - if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { - if ctx := findEntityContextInWidgets(valDoc, "Widgets", widgetName, currentEntity); ctx != "" { - return ctx - } - } - } - } - } - return "" -} - -// extractEntityFromDataSource extracts the entity qualified name from a widget's -// DataSource BSON. Handles DataView, DataGrid, ListView, and Gallery data sources. -func extractEntityFromDataSource(wDoc bson.D) string { - ds := dGetDoc(wDoc, "DataSource") - if ds == nil { - return "" - } - // EntityRef.Entity contains the qualified name (e.g., "Module.Entity") - if entityRef := dGetDoc(ds, "EntityRef"); entityRef != nil { - if entity := dGetString(entityRef, "Entity"); entity != "" { - return entity - } - } - return "" + return mutator.ReplaceWidget(op.Target.Widget, op.Target.Column, widgets) } // ============================================================================ -// ADD / DROP variable +// Widget building from AST (domain logic stays in executor) // ============================================================================ -// applyAddVariable adds a new LocalVariable to the raw BSON page/snippet. -func applyAddVariable(rawData *bson.D, op *ast.AddVariableOp) error { - // Check for duplicate variable name - existingVars := dGetArrayElements(dGet(*rawData, "Variables")) - for _, ev := range existingVars { - if evDoc, ok := ev.(bson.D); ok { - if dGetString(evDoc, "Name") == op.Variable.Name { - return mdlerrors.NewAlreadyExists("variable", "$"+op.Variable.Name) - } - } - } - - // Build VariableType BSON - varTypeID := types.GenerateID() - bsonTypeName := mdlTypeToBsonType(op.Variable.DataType) - varType := bson.D{ - {Key: "$ID", Value: bsonutil.IDToBsonBinary(varTypeID)}, - {Key: "$Type", Value: bsonTypeName}, - } - if bsonTypeName == "DataTypes$ObjectType" { - varType = append(varType, bson.E{Key: "Entity", Value: op.Variable.DataType}) - } - - // Build LocalVariable BSON document - varID := types.GenerateID() - varDoc := bson.D{ - {Key: "$ID", Value: bsonutil.IDToBsonBinary(varID)}, - {Key: "$Type", Value: "Forms$LocalVariable"}, - {Key: "DefaultValue", Value: op.Variable.DefaultValue}, - {Key: "Name", Value: op.Variable.Name}, - {Key: "VariableType", Value: varType}, - } - - // Append to existing Variables array, or create new field - existing := toBsonA(dGet(*rawData, "Variables")) - if existing != nil { - elements := dGetArrayElements(dGet(*rawData, "Variables")) - elements = append(elements, varDoc) - dSetArray(*rawData, "Variables", elements) - } else { - // Field doesn't exist — append to the document - *rawData = append(*rawData, bson.E{Key: "Variables", Value: bson.A{int32(3), varDoc}}) - } - - return nil -} - -// applyDropVariable removes a LocalVariable from the raw BSON page/snippet. -func applyDropVariable(rawData bson.D, op *ast.DropVariableOp) error { - elements := dGetArrayElements(dGet(rawData, "Variables")) - if elements == nil { - return mdlerrors.NewNotFound("variable", "$"+op.VariableName) - } - - // Find and remove the variable - found := false - var kept []any - for _, elem := range elements { - if doc, ok := elem.(bson.D); ok { - if dGetString(doc, "Name") == op.VariableName { - found = true - continue - } - } - kept = append(kept, elem) - } - - if !found { - return mdlerrors.NewNotFound("variable", "$"+op.VariableName) - } - - dSetArray(rawData, "Variables", kept) - return nil -} - -// ============================================================================ -// Widget BSON building -// ============================================================================ - -// buildWidgetsBson converts AST widgets to ordered BSON documents. -// Returns bson.D elements (not map[string]any) to preserve field ordering. -// rawPageData is the full page/snippet BSON, used to extract page parameters -// and existing widget IDs for PARAMETER/SELECTION DataSource resolution. -func buildWidgetsBson(ctx *ExecContext, widgets []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string, rawPageData bson.D) ([]any, error) { +// buildWidgetsFromAST converts AST widgets to pages.Widget domain objects. +// It uses the mutator for scope resolution (WidgetScope, ParamScope). +func buildWidgetsFromAST(ctx *ExecContext, widgets []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string, mutator backend.PageMutator) ([]pages.Widget, error) { e := ctx.executor - paramScope, paramEntityNames := extractPageParamsFromBSON(rawPageData) - widgetScope := extractWidgetScopeFromBSON(rawPageData) + paramScope, paramEntityNames := mutator.ParamScope() + widgetScope := mutator.WidgetScope() pb := &pageBuilder{ writer: e.writer, @@ -1551,171 +239,19 @@ func buildWidgetsBson(ctx *ExecContext, widgets []*ast.WidgetV3, moduleName stri execCache: ctx.Cache, fragments: ctx.Fragments, themeRegistry: ctx.GetThemeRegistry(), + widgetBackend: ctx.Backend, } - var result []any + var result []pages.Widget for _, w := range widgets { - bsonD, err := pb.buildWidgetV3ToBSON(w) + widget, err := pb.buildWidgetV3(w) if err != nil { return nil, mdlerrors.NewBackend("build widget "+w.Name, err) } - if bsonD == nil { + if widget == nil { continue } - - // Keep as bson.D (ordered document) - no conversion to map[string]any needed. - // This preserves field ordering when marshaled back to BSON bytes. - result = append(result, bsonD) + result = append(result, widget) } return result, nil } - -// extractPageParamsFromBSON extracts page/snippet parameter names and entity -// IDs from the raw BSON document. This enables ALTER PAGE REPLACE/INSERT -// operations to resolve PARAMETER DataSource references (e.g., DataSource: $Customer). -func extractPageParamsFromBSON(rawData bson.D) (map[string]model.ID, map[string]string) { - paramScope := make(map[string]model.ID) - paramEntityNames := make(map[string]string) - if rawData == nil { - return paramScope, paramEntityNames - } - - params := dGetArrayElements(dGet(rawData, "Parameters")) - for _, p := range params { - pDoc, ok := p.(bson.D) - if !ok { - continue - } - name := dGetString(pDoc, "Name") - if name == "" { - continue - } - paramType := dGetDoc(pDoc, "ParameterType") - if paramType == nil { - continue - } - typeName := dGetString(paramType, "$Type") - if typeName != "DataTypes$ObjectType" { - continue - } - entityName := dGetString(paramType, "Entity") - if entityName == "" { - continue - } - idVal := dGet(pDoc, "$ID") - paramID := model.ID(extractBinaryIDFromDoc(idVal)) - paramScope[name] = paramID - paramEntityNames[name] = entityName - } - return paramScope, paramEntityNames -} - -// extractWidgetScopeFromBSON walks the entire raw BSON widget tree and -// collects a map of widget name → widget ID. This enables ALTER PAGE INSERT -// operations to resolve SELECTION DataSource references to existing sibling widgets. -func extractWidgetScopeFromBSON(rawData bson.D) map[string]model.ID { - scope := make(map[string]model.ID) - if rawData == nil { - return scope - } - // Page format: FormCall.Arguments[].Widgets[] - if formCall := dGetDoc(rawData, "FormCall"); formCall != nil { - args := dGetArrayElements(dGet(formCall, "Arguments")) - for _, arg := range args { - argDoc, ok := arg.(bson.D) - if !ok { - continue - } - collectWidgetScope(argDoc, "Widgets", scope) - } - } - // Snippet format: Widgets[] or Widget.Widgets[] - collectWidgetScope(rawData, "Widgets", scope) - if widgetContainer := dGetDoc(rawData, "Widget"); widgetContainer != nil { - collectWidgetScope(widgetContainer, "Widgets", scope) - } - return scope -} - -// collectWidgetScope recursively walks a widget array and collects name→ID mappings. -func collectWidgetScope(parentDoc bson.D, key string, scope map[string]model.ID) { - elements := dGetArrayElements(dGet(parentDoc, key)) - for _, elem := range elements { - wDoc, ok := elem.(bson.D) - if !ok { - continue - } - name := dGetString(wDoc, "Name") - if name != "" { - idVal := dGet(wDoc, "$ID") - if wID := extractBinaryIDFromDoc(idVal); wID != "" { - scope[name] = model.ID(wID) - } - } - // Also register entity context for widgets with DataSource - // so SELECTION can resolve the entity type - collectWidgetScopeInChildren(wDoc, scope) - } -} - -// collectWidgetScopeInChildren recursively walks widget children to collect scope. -func collectWidgetScopeInChildren(wDoc bson.D, scope map[string]model.ID) { - typeName := dGetString(wDoc, "$Type") - - // Direct Widgets[] - collectWidgetScope(wDoc, "Widgets", scope) - // FooterWidgets[] - collectWidgetScope(wDoc, "FooterWidgets", scope) - // LayoutGrid: Rows[].Columns[].Widgets[] - if strings.Contains(typeName, "LayoutGrid") { - rows := dGetArrayElements(dGet(wDoc, "Rows")) - for _, row := range rows { - rowDoc, ok := row.(bson.D) - if !ok { - continue - } - cols := dGetArrayElements(dGet(rowDoc, "Columns")) - for _, col := range cols { - colDoc, ok := col.(bson.D) - if !ok { - continue - } - collectWidgetScope(colDoc, "Widgets", scope) - } - } - } - // TabContainer: TabPages[].Widgets[] - tabPages := dGetArrayElements(dGet(wDoc, "TabPages")) - for _, tp := range tabPages { - tpDoc, ok := tp.(bson.D) - if !ok { - continue - } - collectWidgetScope(tpDoc, "Widgets", scope) - } - // ControlBar - if controlBar := dGetDoc(wDoc, "ControlBar"); controlBar != nil { - collectWidgetScope(controlBar, "Items", scope) - } - // CustomWidget (pluggable): Object.Properties[].Value.Widgets[] - if strings.Contains(typeName, "CustomWidget") { - if obj := dGetDoc(wDoc, "Object"); obj != nil { - props := dGetArrayElements(dGet(obj, "Properties")) - for _, prop := range props { - propDoc, ok := prop.(bson.D) - if !ok { - continue - } - if valDoc := dGetDoc(propDoc, "Value"); valDoc != nil { - collectWidgetScope(valDoc, "Widgets", scope) - } - } - } - } -} - -// ============================================================================ -// Helper: SerializeWidget is already available via mpr package -// ============================================================================ - -var _ = mpr.SerializeWidget // ensure import is used diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index e7552e10..4559ad1e 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -4,23 +4,13 @@ package executor import ( "fmt" - "io" - "strings" - - "go.mongodb.org/mongo-driver/bson" "github.com/mendixlabs/mxcli/mdl/ast" - "github.com/mendixlabs/mxcli/mdl/bsonutil" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/mpr" "github.com/mendixlabs/mxcli/sdk/workflows" ) -// bsonArrayMarker is the Mendix BSON array type marker (storageListType 3) -// that prefixes versioned arrays in serialized documents. -const bsonArrayMarker = int32(3) - // execAlterWorkflow handles ALTER WORKFLOW Module.Name { operations }. func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { if !ctx.Connected() { @@ -61,84 +51,117 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { return mdlerrors.NewNotFound("workflow", s.Name.Module+"."+s.Name.Name) } - // Load raw BSON as ordered document - rawBytes, err := ctx.Backend.GetRawUnitBytes(wfID) + // Open mutator + mutator, err := ctx.Backend.OpenWorkflowForMutation(wfID) if err != nil { - return mdlerrors.NewBackend("load raw workflow data", err) - } - var rawData bson.D - if err := bson.Unmarshal(rawBytes, &rawData); err != nil { - return mdlerrors.NewBackend("unmarshal workflow BSON", err) + return mdlerrors.NewBackend("open workflow for mutation", err) } // Apply operations sequentially for _, op := range s.Operations { switch o := op.(type) { case *ast.SetWorkflowPropertyOp: - if err := applySetWorkflowProperty(&rawData, o); err != nil { - return mdlerrors.NewBackend("SET "+o.Property, err) + switch o.Property { + case "OVERVIEW_PAGE", "PARAMETER": + qn := o.Entity.Module + "." + o.Entity.Name + if qn == "." { + qn = "" + } + if err := mutator.SetPropertyWithEntity(o.Property, o.Value, qn); err != nil { + return mdlerrors.NewBackend("SET "+o.Property, err) + } + default: + if err := mutator.SetProperty(o.Property, o.Value); err != nil { + return mdlerrors.NewBackend("SET "+o.Property, err) + } } + case *ast.SetActivityPropertyOp: - if err := applySetActivityProperty(rawData, o); err != nil { + value := o.Value + switch o.Property { + case "PAGE": + value = o.PageName.Module + "." + o.PageName.Name + case "TARGETING_MICROFLOW": + value = o.Microflow.Module + "." + o.Microflow.Name + } + if err := mutator.SetActivityProperty(o.ActivityRef, o.AtPosition, o.Property, value); err != nil { return mdlerrors.NewBackend("SET ACTIVITY", err) } + case *ast.InsertAfterOp: - if err := applyInsertAfterActivity(ctx, rawData, o); err != nil { + acts := buildAndBindActivities(ctx, []ast.WorkflowActivityNode{o.NewActivity}) + if len(acts) == 0 { + return mdlerrors.NewValidation("failed to build new activity") + } + if err := mutator.InsertAfterActivity(o.ActivityRef, o.AtPosition, acts); err != nil { return mdlerrors.NewBackend("INSERT AFTER", err) } + case *ast.DropActivityOp: - if err := applyDropActivity(rawData, o); err != nil { + if err := mutator.DropActivity(o.ActivityRef, o.AtPosition); err != nil { return mdlerrors.NewBackend("DROP ACTIVITY", err) } + case *ast.ReplaceActivityOp: - if err := applyReplaceActivity(ctx, rawData, o); err != nil { + acts := buildAndBindActivities(ctx, []ast.WorkflowActivityNode{o.NewActivity}) + if len(acts) == 0 { + return mdlerrors.NewValidation("failed to build replacement activity") + } + if err := mutator.ReplaceActivity(o.ActivityRef, o.AtPosition, acts); err != nil { return mdlerrors.NewBackend("REPLACE ACTIVITY", err) } + case *ast.InsertOutcomeOp: - if err := applyInsertOutcome(ctx, rawData, o); err != nil { + acts := buildAndBindActivities(ctx, o.Activities) + if err := mutator.InsertOutcome(o.ActivityRef, o.AtPosition, o.OutcomeName, acts); err != nil { return mdlerrors.NewBackend("INSERT OUTCOME", err) } + case *ast.DropOutcomeOp: - if err := applyDropOutcome(rawData, o); err != nil { + if err := mutator.DropOutcome(o.ActivityRef, o.AtPosition, o.OutcomeName); err != nil { return mdlerrors.NewBackend("DROP OUTCOME", err) } + case *ast.InsertPathOp: - if err := applyInsertPath(ctx, rawData, o); err != nil { + acts := buildAndBindActivities(ctx, o.Activities) + if err := mutator.InsertPath(o.ActivityRef, o.AtPosition, "", acts); err != nil { return mdlerrors.NewBackend("INSERT PATH", err) } + case *ast.DropPathOp: - if err := applyDropPath(rawData, o); err != nil { + if err := mutator.DropPath(o.ActivityRef, o.AtPosition, o.PathCaption); err != nil { return mdlerrors.NewBackend("DROP PATH", err) } + case *ast.InsertBranchOp: - if err := applyInsertBranch(ctx, rawData, o); err != nil { + acts := buildAndBindActivities(ctx, o.Activities) + if err := mutator.InsertBranch(o.ActivityRef, o.AtPosition, o.Condition, acts); err != nil { return mdlerrors.NewBackend("INSERT BRANCH", err) } + case *ast.DropBranchOp: - if err := applyDropBranch(rawData, o); err != nil { + if err := mutator.DropBranch(o.ActivityRef, o.AtPosition, o.BranchName); err != nil { return mdlerrors.NewBackend("DROP BRANCH", err) } + case *ast.InsertBoundaryEventOp: - if err := applyInsertBoundaryEvent(ctx, rawData, o); err != nil { + acts := buildAndBindActivities(ctx, o.Activities) + if err := mutator.InsertBoundaryEvent(o.ActivityRef, o.AtPosition, o.EventType, o.Delay, acts); err != nil { return mdlerrors.NewBackend("INSERT BOUNDARY EVENT", err) } + case *ast.DropBoundaryEventOp: - if err := applyDropBoundaryEvent(ctx.Output, rawData, o); err != nil { + if err := mutator.DropBoundaryEvent(o.ActivityRef, o.AtPosition); err != nil { return mdlerrors.NewBackend("DROP BOUNDARY EVENT", err) } + default: return mdlerrors.NewUnsupported(fmt.Sprintf("unknown ALTER WORKFLOW operation type: %T", op)) } } - // Marshal back to BSON bytes - outBytes, err := bson.Marshal(rawData) - if err != nil { - return mdlerrors.NewBackend("marshal modified workflow", err) - } - // Save - if err := ctx.Backend.UpdateRawUnit(string(wfID), outBytes); err != nil { + if err := mutator.Save(); err != nil { return mdlerrors.NewBackend("save modified workflow", err) } @@ -147,741 +170,9 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { return nil } -// applySetWorkflowProperty sets a workflow-level property in raw BSON. -func applySetWorkflowProperty(doc *bson.D, op *ast.SetWorkflowPropertyOp) error { - switch op.Property { - case "DISPLAY": - // WorkflowName is a StringTemplate with Text field - wfName := dGetDoc(*doc, "WorkflowName") - if wfName == nil { - // Auto-create the WorkflowName sub-document - newName := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Text", Value: op.Value}, - } - *doc = append(*doc, bson.E{Key: "WorkflowName", Value: newName}) - } else { - dSet(wfName, "Text", op.Value) - } - // Also update Title (top-level string) - dSet(*doc, "Title", op.Value) - return nil - - case "DESCRIPTION": - // WorkflowDescription is a StringTemplate with Text field - wfDesc := dGetDoc(*doc, "WorkflowDescription") - if wfDesc == nil { - // Auto-create the WorkflowDescription sub-document - newDesc := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Text", Value: op.Value}, - } - *doc = append(*doc, bson.E{Key: "WorkflowDescription", Value: newDesc}) - } else { - dSet(wfDesc, "Text", op.Value) - } - return nil - - case "EXPORT_LEVEL": - dSet(*doc, "ExportLevel", op.Value) - return nil - - case "DUE_DATE": - dSet(*doc, "DueDate", op.Value) - return nil - - case "OVERVIEW_PAGE": - qn := op.Entity.Module + "." + op.Entity.Name - if qn == "." { - // Clear overview page - dSet(*doc, "AdminPage", nil) - } else { - pageRef := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$PageReference"}, - {Key: "Page", Value: qn}, - } - dSet(*doc, "AdminPage", pageRef) - } - return nil - - case "PARAMETER": - qn := op.Entity.Module + "." + op.Entity.Name - if qn == "." { - // Clear parameter — remove it - for i, elem := range *doc { - if elem.Key == "Parameter" { - (*doc)[i].Value = nil - return nil - } - } - return nil - } - // Check if Parameter already exists - param := dGetDoc(*doc, "Parameter") - if param != nil { - dSet(param, "Entity", qn) - } else { - // Create new Parameter - newParam := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$Parameter"}, - {Key: "Entity", Value: qn}, - {Key: "Name", Value: "WorkflowContext"}, - } - // Check if field exists with nil value - for i, elem := range *doc { - if elem.Key == "Parameter" { - (*doc)[i].Value = newParam - return nil - } - } - // Field doesn't exist — append it - *doc = append(*doc, bson.E{Key: "Parameter", Value: newParam}) - } - return nil - - default: - return mdlerrors.NewUnsupported("unsupported workflow property: " + op.Property) - } -} - -// applySetActivityProperty sets a property on a named workflow activity in raw BSON. -func applySetActivityProperty(doc bson.D, op *ast.SetActivityPropertyOp) error { - actDoc, err := findActivityByCaption(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - switch op.Property { - case "PAGE": - qn := op.PageName.Module + "." + op.PageName.Name - // TaskPage is a PageReference object - taskPage := dGetDoc(actDoc, "TaskPage") - if taskPage != nil { - dSet(taskPage, "Page", qn) - } else { - // Create TaskPage - pageRef := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$PageReference"}, - {Key: "Page", Value: qn}, - } - dSet(actDoc, "TaskPage", pageRef) - } - return nil - - case "DESCRIPTION": - // TaskDescription is a StringTemplate - taskDesc := dGetDoc(actDoc, "TaskDescription") - if taskDesc != nil { - dSet(taskDesc, "Text", op.Value) - } - return nil - - case "TARGETING_MICROFLOW": - qn := op.Microflow.Module + "." + op.Microflow.Name - userTargeting := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$MicroflowUserTargeting"}, - {Key: "Microflow", Value: qn}, - } - dSet(actDoc, "UserTargeting", userTargeting) - return nil - - case "TARGETING_XPATH": - userTargeting := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$XPathUserTargeting"}, - {Key: "XPathConstraint", Value: op.Value}, - } - dSet(actDoc, "UserTargeting", userTargeting) - return nil - - case "DUE_DATE": - dSet(actDoc, "DueDate", op.Value) - return nil - - default: - return mdlerrors.NewUnsupported("unsupported activity property: " + op.Property) - } -} - -// findActivityByCaption searches the workflow for an activity matching the given caption. -// Searches recursively through nested flows (Decision outcomes, ParallelSplit paths, UserTask outcomes, BoundaryEvents). -// atPosition provides positional disambiguation when multiple activities share the same caption (1-based). -func findActivityByCaption(doc bson.D, caption string, atPosition int) (bson.D, error) { - flow := dGetDoc(doc, "Flow") - if flow == nil { - return nil, mdlerrors.NewValidation("workflow has no Flow") - } - - var matches []bson.D - findActivitiesRecursive(flow, caption, &matches) - - if len(matches) == 0 { - return nil, mdlerrors.NewNotFound("activity", caption) - } - if len(matches) == 1 || atPosition == 0 { - if atPosition > 0 && atPosition > len(matches) { - return nil, mdlerrors.NewNotFoundMsg("activity", caption, fmt.Sprintf("activity %q at position %d not found (found %d matches)", caption, atPosition, len(matches))) - } - if atPosition > 0 { - return matches[atPosition-1], nil - } - if len(matches) > 1 { - return nil, mdlerrors.NewValidation(fmt.Sprintf("ambiguous activity %q — %d matches. Use @N to disambiguate", caption, len(matches))) - } - return matches[0], nil - } - if atPosition > len(matches) { - return nil, mdlerrors.NewNotFoundMsg("activity", caption, fmt.Sprintf("activity %q at position %d not found (found %d matches)", caption, atPosition, len(matches))) - } - return matches[atPosition-1], nil -} - -// findActivitiesRecursive collects all activities matching caption in a flow and its nested sub-flows. -func findActivitiesRecursive(flow bson.D, caption string, matches *[]bson.D) { - activities := dGetArrayElements(dGet(flow, "Activities")) - for _, elem := range activities { - actDoc, ok := elem.(bson.D) - if !ok { - continue - } - actCaption := dGetString(actDoc, "Caption") - actName := dGetString(actDoc, "Name") - if actCaption == caption || actName == caption { - *matches = append(*matches, actDoc) - } - // Recurse into nested flows: Outcomes (Decision, UserTask, CallMicroflow), Paths (ParallelSplit), BoundaryEvents - for _, nestedFlow := range getNestedFlows(actDoc) { - findActivitiesRecursive(nestedFlow, caption, matches) - } - } -} - -// getNestedFlows returns all sub-flows within an activity (outcomes, paths, boundary events). -func getNestedFlows(actDoc bson.D) []bson.D { - var flows []bson.D - // Outcomes (UserTask, Decision, CallMicroflow) - outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) - for _, o := range outcomes { - oDoc, ok := o.(bson.D) - if !ok { - continue - } - if f := dGetDoc(oDoc, "Flow"); f != nil { - flows = append(flows, f) - } - } - // BoundaryEvents - events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) - for _, e := range events { - eDoc, ok := e.(bson.D) - if !ok { - continue - } - if f := dGetDoc(eDoc, "Flow"); f != nil { - flows = append(flows, f) - } - } - return flows -} - -// findActivityIndex returns the index, activities array, and containing flow of an activity. -// Searches recursively through nested flows. -func findActivityIndex(doc bson.D, caption string, atPosition int) (int, []any, bson.D, error) { - flow := dGetDoc(doc, "Flow") - if flow == nil { - return -1, nil, nil, mdlerrors.NewValidation("workflow has no Flow") - } - - var matches []activityMatch - findActivityIndexRecursive(flow, caption, &matches) - - if len(matches) == 0 { - return -1, nil, nil, mdlerrors.NewNotFound("activity", caption) - } - pos := 0 - if atPosition > 0 { - pos = atPosition - 1 - } else if len(matches) > 1 { - return -1, nil, nil, mdlerrors.NewValidation(fmt.Sprintf("ambiguous activity %q — %d matches. Use @N to disambiguate", caption, len(matches))) - } - if pos >= len(matches) { - return -1, nil, nil, mdlerrors.NewNotFoundMsg("activity", caption, fmt.Sprintf("activity %q at position %d not found (found %d matches)", caption, atPosition, len(matches))) - } - m := matches[pos] - return m.idx, m.activities, m.flow, nil -} - -type activityMatch struct { - idx int - activities []any - flow bson.D -} - -func findActivityIndexRecursive(flow bson.D, caption string, matches *[]activityMatch) { - activities := dGetArrayElements(dGet(flow, "Activities")) - for i, elem := range activities { - actDoc, ok := elem.(bson.D) - if !ok { - continue - } - actCaption := dGetString(actDoc, "Caption") - actName := dGetString(actDoc, "Name") - if actCaption == caption || actName == caption { - *matches = append(*matches, activityMatch{idx: i, activities: activities, flow: flow}) - } - for _, nestedFlow := range getNestedFlows(actDoc) { - findActivityIndexRecursive(nestedFlow, caption, matches) - } - } -} - -// collectAllActivityNames collects all activity names from the entire workflow BSON (recursively). -func collectAllActivityNames(doc bson.D) map[string]bool { - names := make(map[string]bool) - flow := dGetDoc(doc, "Flow") - if flow != nil { - collectNamesRecursive(flow, names) - } - return names -} - -func collectNamesRecursive(flow bson.D, names map[string]bool) { - activities := dGetArrayElements(dGet(flow, "Activities")) - for _, elem := range activities { - actDoc, ok := elem.(bson.D) - if !ok { - continue - } - if name := dGetString(actDoc, "Name"); name != "" { - names[name] = true - } - for _, nested := range getNestedFlows(actDoc) { - collectNamesRecursive(nested, names) - } - } -} - -// deduplicateNewActivityName ensures a new activity name doesn't conflict with existing names. -func deduplicateNewActivityName(act workflows.WorkflowActivity, existingNames map[string]bool) { - name := act.GetName() - if name == "" || !existingNames[name] { - return - } - for i := 2; i < 1000; i++ { - candidate := fmt.Sprintf("%s_%d", name, i) - if !existingNames[candidate] { - act.SetName(candidate) - existingNames[candidate] = true - return - } - } -} - -// buildSubFlowBson builds a Workflows$Flow BSON document from AST activity nodes, -// with auto-binding and name deduplication against existing workflow activities. -func buildSubFlowBson(ctx *ExecContext, doc bson.D, activities []ast.WorkflowActivityNode) bson.D { - subActs := buildWorkflowActivities(activities) - autoBindActivitiesInFlow(ctx, subActs) - existingNames := collectAllActivityNames(doc) - for _, act := range subActs { - deduplicateNewActivityName(act, existingNames) - } - var subActsBson bson.A - subActsBson = append(subActsBson, bsonArrayMarker) - for _, act := range subActs { - bsonDoc := mpr.SerializeWorkflowActivity(act) - if bsonDoc != nil { - subActsBson = append(subActsBson, bsonDoc) - } - } - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$Flow"}, - {Key: "Activities", Value: subActsBson}, - } -} - -// applyInsertAfterActivity inserts a new activity after a named activity. -func applyInsertAfterActivity(ctx *ExecContext, doc bson.D, op *ast.InsertAfterOp) error { - idx, activities, containingFlow, err := findActivityIndex(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - newActs := buildWorkflowActivities([]ast.WorkflowActivityNode{op.NewActivity}) - if len(newActs) == 0 { - return mdlerrors.NewValidation("failed to build new activity") - } - - // Auto-bind parameters and deduplicate against existing workflow names - autoBindActivitiesInFlow(ctx, newActs) - existingNames := collectAllActivityNames(doc) - for _, act := range newActs { - deduplicateNewActivityName(act, existingNames) - } - - newBsonActs := make([]any, 0, len(newActs)) - for _, act := range newActs { - bsonDoc := mpr.SerializeWorkflowActivity(act) - if bsonDoc != nil { - newBsonActs = append(newBsonActs, bsonDoc) - } - } - - insertIdx := idx + 1 - newArr := make([]any, 0, len(activities)+len(newBsonActs)) - newArr = append(newArr, activities[:insertIdx]...) - newArr = append(newArr, newBsonActs...) - newArr = append(newArr, activities[insertIdx:]...) - - dSetArray(containingFlow, "Activities", newArr) - return nil -} - -// applyDropActivity removes an activity from the flow. -func applyDropActivity(doc bson.D, op *ast.DropActivityOp) error { - idx, activities, containingFlow, err := findActivityIndex(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - newArr := make([]any, 0, len(activities)-1) - newArr = append(newArr, activities[:idx]...) - newArr = append(newArr, activities[idx+1:]...) - - dSetArray(containingFlow, "Activities", newArr) - return nil -} - -// applyReplaceActivity replaces an activity in place. -func applyReplaceActivity(ctx *ExecContext, doc bson.D, op *ast.ReplaceActivityOp) error { - idx, activities, containingFlow, err := findActivityIndex(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - newActs := buildWorkflowActivities([]ast.WorkflowActivityNode{op.NewActivity}) - if len(newActs) == 0 { - return mdlerrors.NewValidation("failed to build replacement activity") - } - - autoBindActivitiesInFlow(ctx, newActs) - existingNames := collectAllActivityNames(doc) - for _, act := range newActs { - deduplicateNewActivityName(act, existingNames) - } - - newBsonActs := make([]any, 0, len(newActs)) - for _, act := range newActs { - bsonDoc := mpr.SerializeWorkflowActivity(act) - if bsonDoc != nil { - newBsonActs = append(newBsonActs, bsonDoc) - } - } - - newArr := make([]any, 0, len(activities)-1+len(newBsonActs)) - newArr = append(newArr, activities[:idx]...) - newArr = append(newArr, newBsonActs...) - newArr = append(newArr, activities[idx+1:]...) - - dSetArray(containingFlow, "Activities", newArr) - return nil -} - -// applyInsertOutcome adds a new outcome to a user task. -func applyInsertOutcome(ctx *ExecContext, doc bson.D, op *ast.InsertOutcomeOp) error { - actDoc, err := findActivityByCaption(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - // Build outcome BSON - outcomeDoc := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$UserTaskOutcome"}, - } - - // Build sub-flow if activities provided - if len(op.Activities) > 0 { - outcomeDoc = append(outcomeDoc, bson.E{Key: "Flow", Value: buildSubFlowBson(ctx, doc, op.Activities)}) - } - - outcomeDoc = append(outcomeDoc, - bson.E{Key: "PersistentId", Value: bsonutil.NewIDBsonBinary()}, - bson.E{Key: "Value", Value: op.OutcomeName}, - ) - - // Append to Outcomes array - outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) - outcomes = append(outcomes, outcomeDoc) - dSetArray(actDoc, "Outcomes", outcomes) - return nil -} - -// applyDropOutcome removes an outcome from a user task. -func applyDropOutcome(doc bson.D, op *ast.DropOutcomeOp) error { - actDoc, err := findActivityByCaption(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) - found := false - var kept []any - for _, elem := range outcomes { - oDoc, ok := elem.(bson.D) - if !ok { - kept = append(kept, elem) - continue - } - value := dGetString(oDoc, "Value") - typeName := dGetString(oDoc, "$Type") - // Match by Value, or by $Type for VoidConditionOutcome ("Default") - matched := value == op.OutcomeName - if !matched && strings.EqualFold(op.OutcomeName, "Default") && typeName == "Workflows$VoidConditionOutcome" { - matched = true - } - if matched && !found { - found = true - continue - } - kept = append(kept, elem) - } - if !found { - return mdlerrors.NewNotFoundMsg("outcome", op.OutcomeName, fmt.Sprintf("outcome %q not found on activity %q", op.OutcomeName, op.ActivityRef)) - } - dSetArray(actDoc, "Outcomes", kept) - return nil -} - -// applyInsertPath adds a new path to a parallel split. -func applyInsertPath(ctx *ExecContext, doc bson.D, op *ast.InsertPathOp) error { - actDoc, err := findActivityByCaption(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - pathDoc := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$ParallelSplitOutcome"}, - } - - if len(op.Activities) > 0 { - pathDoc = append(pathDoc, bson.E{Key: "Flow", Value: buildSubFlowBson(ctx, doc, op.Activities)}) - } - - pathDoc = append(pathDoc, bson.E{Key: "PersistentId", Value: bsonutil.NewIDBsonBinary()}) - - outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) - outcomes = append(outcomes, pathDoc) - dSetArray(actDoc, "Outcomes", outcomes) - return nil -} - -// applyDropPath removes a path from a parallel split by caption. -func applyDropPath(doc bson.D, op *ast.DropPathOp) error { - actDoc, err := findActivityByCaption(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) - if op.PathCaption == "" && len(outcomes) > 0 { - // Drop last path - outcomes = outcomes[:len(outcomes)-1] - dSetArray(actDoc, "Outcomes", outcomes) - return nil - } - - // Find by index (paths are numbered 1-based in MDL) - pathIdx := -1 - for i := range outcomes { - // Path captions are typically "Path 1", "Path 2" etc. - if fmt.Sprintf("Path %d", i+1) == op.PathCaption { - pathIdx = i - break - } - } - if pathIdx < 0 { - return mdlerrors.NewNotFoundMsg("path", op.PathCaption, fmt.Sprintf("path %q not found on parallel split %q", op.PathCaption, op.ActivityRef)) - } - - newOutcomes := make([]any, 0, len(outcomes)-1) - newOutcomes = append(newOutcomes, outcomes[:pathIdx]...) - newOutcomes = append(newOutcomes, outcomes[pathIdx+1:]...) - dSetArray(actDoc, "Outcomes", newOutcomes) - return nil -} - -// applyInsertBranch adds a new branch to a decision. -func applyInsertBranch(ctx *ExecContext, doc bson.D, op *ast.InsertBranchOp) error { - actDoc, err := findActivityByCaption(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - // Build the condition outcome BSON - var outcomeDoc bson.D - switch strings.ToLower(op.Condition) { - case "true": - outcomeDoc = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$BooleanConditionOutcome"}, - {Key: "Value", Value: true}, - } - case "false": - outcomeDoc = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$BooleanConditionOutcome"}, - {Key: "Value", Value: false}, - } - case "default": - outcomeDoc = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$VoidConditionOutcome"}, - } - default: - outcomeDoc = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$EnumerationValueConditionOutcome"}, - {Key: "Value", Value: op.Condition}, - } - } - - if len(op.Activities) > 0 { - outcomeDoc = append(outcomeDoc, bson.E{Key: "Flow", Value: buildSubFlowBson(ctx, doc, op.Activities)}) - } - - outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) - outcomes = append(outcomes, outcomeDoc) - dSetArray(actDoc, "Outcomes", outcomes) - return nil -} - -// applyDropBranch removes a branch from a decision. -func applyDropBranch(doc bson.D, op *ast.DropBranchOp) error { - actDoc, err := findActivityByCaption(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) - found := false - var kept []any - for _, elem := range outcomes { - oDoc, ok := elem.(bson.D) - if !ok { - kept = append(kept, elem) - continue - } - if !found { - // Match by Value or $Type for void outcomes - typeName := dGetString(oDoc, "$Type") - switch strings.ToLower(op.BranchName) { - case "true": - if typeName == "Workflows$BooleanConditionOutcome" { - if v, ok := dGet(oDoc, "Value").(bool); ok && v { - found = true - continue - } - } - case "false": - if typeName == "Workflows$BooleanConditionOutcome" { - if v, ok := dGet(oDoc, "Value").(bool); ok && !v { - found = true - continue - } - } - case "default": - if typeName == "Workflows$VoidConditionOutcome" { - found = true - continue - } - default: - value := dGetString(oDoc, "Value") - if value == op.BranchName { - found = true - continue - } - } - } - kept = append(kept, elem) - } - if !found { - return mdlerrors.NewNotFoundMsg("branch", op.BranchName, fmt.Sprintf("branch %q not found on activity %q", op.BranchName, op.ActivityRef)) - } - dSetArray(actDoc, "Outcomes", kept) - return nil -} - -// applyInsertBoundaryEvent adds a boundary event to an activity. -func applyInsertBoundaryEvent(ctx *ExecContext, doc bson.D, op *ast.InsertBoundaryEventOp) error { - actDoc, err := findActivityByCaption(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - typeName := "Workflows$InterruptingTimerBoundaryEvent" - switch op.EventType { - case "NonInterruptingTimer": - typeName = "Workflows$NonInterruptingTimerBoundaryEvent" - case "Timer": - typeName = "Workflows$TimerBoundaryEvent" - } - - eventDoc := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: typeName}, - {Key: "Caption", Value: ""}, - } - - if op.Delay != "" { - eventDoc = append(eventDoc, bson.E{Key: "FirstExecutionTime", Value: op.Delay}) - } - - if len(op.Activities) > 0 { - eventDoc = append(eventDoc, bson.E{Key: "Flow", Value: buildSubFlowBson(ctx, doc, op.Activities)}) - } - - eventDoc = append(eventDoc, bson.E{Key: "PersistentId", Value: bsonutil.NewIDBsonBinary()}) - - if typeName == "Workflows$NonInterruptingTimerBoundaryEvent" { - eventDoc = append(eventDoc, bson.E{Key: "Recurrence", Value: nil}) - } - - // Append to BoundaryEvents array - events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) - events = append(events, eventDoc) - dSetArray(actDoc, "BoundaryEvents", events) - return nil -} - -// applyDropBoundaryEvent removes the first boundary event from an activity. -// -// Limitation: this always removes events[0]. There is currently no syntax to -// target a specific boundary event by name or type when multiple exist. -func applyDropBoundaryEvent(w io.Writer, doc bson.D, op *ast.DropBoundaryEventOp) error { - actDoc, err := findActivityByCaption(doc, op.ActivityRef, op.AtPosition) - if err != nil { - return err - } - - events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) - if len(events) == 0 { - return mdlerrors.NewValidation(fmt.Sprintf("activity %q has no boundary events", op.ActivityRef)) - } - - if len(events) > 1 { - fmt.Fprintf(w, "warning: activity %q has %d boundary events; dropping the first one\n", op.ActivityRef, len(events)) - } - - // Drop the first boundary event - dSetArray(actDoc, "BoundaryEvents", events[1:]) - return nil +// buildAndBindActivities builds workflow activities from AST nodes and auto-binds parameters. +func buildAndBindActivities(ctx *ExecContext, nodes []ast.WorkflowActivityNode) []workflows.WorkflowActivity { + acts := buildWorkflowActivities(nodes) + autoBindActivitiesInFlow(ctx, acts) + return acts } diff --git a/mdl/executor/cmd_diff_local.go b/mdl/executor/cmd_diff_local.go index 12ff4285..0900f438 100644 --- a/mdl/executor/cmd_diff_local.go +++ b/mdl/executor/cmd_diff_local.go @@ -14,7 +14,6 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/mpr" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" ) @@ -497,7 +496,7 @@ func attributeBsonToMDL(_ *ExecContext, raw map[string]any) string { // Falls back to a header-only stub if parsing fails. func microflowBsonToMDL(ctx *ExecContext, raw map[string]any, qualifiedName string) string { qn := splitQualifiedName(qualifiedName) - mf := mpr.ParseMicroflowFromRaw(raw, model.ID(qn.Name), "") + mf := ctx.Backend.ParseMicroflowFromRaw(raw, model.ID(qn.Name), "") entityNames, microflowNames := buildNameLookups(ctx) return renderMicroflowMDL(ctx, mf, qn, entityNames, microflowNames, nil) diff --git a/mdl/executor/cmd_pages_builder.go b/mdl/executor/cmd_pages_builder.go index 1bb43fcf..6e17beaf 100644 --- a/mdl/executor/cmd_pages_builder.go +++ b/mdl/executor/cmd_pages_builder.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" @@ -35,6 +36,7 @@ type pageBuilder struct { isSnippet bool // True if building a snippet (affects parameter datasource) fragments map[string]*ast.DefineFragmentStmt // Fragment registry from executor themeRegistry *ThemeRegistry // Theme design property definitions (may be nil) + widgetBackend backend.WidgetBuilderBackend // Backend for pluggable widget construction // Pluggable widget engine (lazily initialized) widgetRegistry *WidgetRegistry @@ -68,11 +70,19 @@ func (pb *pageBuilder) initPluggableEngine() { } } pb.widgetRegistry = registry - pb.pluggableEngine = NewPluggableWidgetEngine(NewOperationRegistry(), pb) + pb.pluggableEngine = NewPluggableWidgetEngine(pb.widgetBackend, pb) } // registerWidgetName registers a widget name and returns an error if it's already used. // Widget names must be unique within a page/snippet. + +// getProjectPath returns the project directory path from the underlying reader. +func (pb *pageBuilder) getProjectPath() string { + if pb.reader != nil { + return pb.reader.Path() + } + return "" +} func (pb *pageBuilder) registerWidgetName(name string, id model.ID) error { if name == "" { return nil // Anonymous widgets are allowed diff --git a/mdl/executor/cmd_pages_builder_input.go b/mdl/executor/cmd_pages_builder_input.go index 4a8cfed1..c98c9152 100644 --- a/mdl/executor/cmd_pages_builder_input.go +++ b/mdl/executor/cmd_pages_builder_input.go @@ -11,7 +11,6 @@ import ( "github.com/mendixlabs/mxcli/mdl/bsonutil" "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/mpr" "github.com/mendixlabs/mxcli/sdk/pages" "github.com/mendixlabs/mxcli/sdk/widgets" "go.mongodb.org/mongo-driver/bson" @@ -176,19 +175,6 @@ func setPrimitiveValue(val bson.D, value string) bson.D { return result } -// setDataSource sets the DataSource field in a WidgetValue. -func setDataSource(val bson.D, ds pages.DataSource) bson.D { - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "DataSource" { - result = append(result, bson.E{Key: "DataSource", Value: mpr.SerializeCustomWidgetDataSource(ds)}) - } else { - result = append(result, elem) - } - } - return result -} - // setAssociationRef sets the EntityRef field in a WidgetValue for an association binding // on a pluggable widget. Uses DomainModels$IndirectEntityRef with a Steps array containing // a DomainModels$EntityRefStep that specifies the association and destination entity. diff --git a/mdl/executor/cmd_pages_builder_input_datagrid.go b/mdl/executor/cmd_pages_builder_input_datagrid.go index b86a98dc..e49121ae 100644 --- a/mdl/executor/cmd_pages_builder_input_datagrid.go +++ b/mdl/executor/cmd_pages_builder_input_datagrid.go @@ -8,7 +8,6 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/sdk/mpr" "github.com/mendixlabs/mxcli/sdk/pages" "go.mongodb.org/mongo-driver/bson" ) @@ -85,7 +84,7 @@ func (pb *pageBuilder) buildDataGrid2Property(entry pages.PropertyTypeIDEntry, d // Build the datasource BSON if provided var datasourceBSON any if datasource != nil { - datasourceBSON = mpr.SerializeCustomWidgetDataSource(datasource) + datasourceBSON = pb.widgetBackend.SerializeDataSourceToOpaque(datasource) } // Build attribute ref if provided diff --git a/mdl/executor/cmd_pages_builder_v3_pluggable.go b/mdl/executor/cmd_pages_builder_v3_pluggable.go index b79db2b9..93655b2d 100644 --- a/mdl/executor/cmd_pages_builder_v3_pluggable.go +++ b/mdl/executor/cmd_pages_builder_v3_pluggable.go @@ -12,7 +12,6 @@ import ( "github.com/mendixlabs/mxcli/mdl/bsonutil" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/mpr" ) // ============================================================================= @@ -82,7 +81,7 @@ func (pb *pageBuilder) cloneActionWithNewID(actionMap bson.D) bson.D { return result } -// buildWidgetV3ToBSON builds a V3 widget and serializes it directly to BSON. +// buildWidgetV3ToBSON builds a V3 widget and serializes it to an opaque storage form. func (pb *pageBuilder) buildWidgetV3ToBSON(w *ast.WidgetV3) (bson.D, error) { widget, err := pb.buildWidgetV3(w) if err != nil { @@ -91,7 +90,15 @@ func (pb *pageBuilder) buildWidgetV3ToBSON(w *ast.WidgetV3) (bson.D, error) { if widget == nil { return nil, nil } - return mpr.SerializeWidget(widget), nil + raw := pb.widgetBackend.SerializeWidgetToOpaque(widget) + if raw == nil { + return nil, nil + } + bsonD, ok := raw.(bson.D) + if !ok { + return nil, mdlerrors.NewValidationf("SerializeWidgetToOpaque returned unexpected type %T", raw) + } + return bsonD, nil } // createAttributeObject creates a single attribute object entry for filter widget Attributes. diff --git a/mdl/executor/cmd_pages_create_v3.go b/mdl/executor/cmd_pages_create_v3.go index 398ecefb..67b52c21 100644 --- a/mdl/executor/cmd_pages_create_v3.go +++ b/mdl/executor/cmd_pages_create_v3.go @@ -63,6 +63,7 @@ func execCreatePageV3(ctx *ExecContext, s *ast.CreatePageStmtV3) error { execCache: ctx.Cache, fragments: ctx.Fragments, themeRegistry: ctx.GetThemeRegistry(), + widgetBackend: ctx.Backend, } page, err := pb.buildPageV3(s) @@ -139,6 +140,7 @@ func execCreateSnippetV3(ctx *ExecContext, s *ast.CreateSnippetStmtV3) error { execCache: ctx.Cache, fragments: ctx.Fragments, themeRegistry: ctx.GetThemeRegistry(), + widgetBackend: ctx.Backend, } snippet, err := pb.buildSnippetV3(s) diff --git a/mdl/executor/widget_defaults.go b/mdl/executor/widget_defaults.go deleted file mode 100644 index 256fbfd7..00000000 --- a/mdl/executor/widget_defaults.go +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -// ============================================================================= -// Default Object List Population -// ============================================================================= - -// ensureRequiredObjectLists populates empty Object list properties with one default -// entry. This prevents CE0642 "Property 'X' is required" errors for widget properties -// like Accordion groups, AreaChart series, etc. -func ensureRequiredObjectLists(obj bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry) bson.D { - for propKey, entry := range propertyTypeIDs { - if entry.ObjectTypeID == "" || len(entry.NestedPropertyIDs) == 0 { - continue - } - // Skip non-required object lists that have nested DataSource properties — - // auto-populating these creates entries that trigger widget-level validation errors. - // Required object lists (like AreaChart series) are populated even with nested DataSource - // because the DataSource is conditional (e.g., depends on dataSet enum). - if !entry.Required { - hasNestedDS := false - for _, nested := range entry.NestedPropertyIDs { - if nested.ValueType == "DataSource" { - hasNestedDS = true - break - } - } - if hasNestedDS { - continue - } - } - // Skip if any Required nested property is Attribute (needs entity context) - hasRequiredAttr := false - for _, nested := range entry.NestedPropertyIDs { - if nested.Required && nested.ValueType == "Attribute" { - hasRequiredAttr = true - break - } - } - if hasRequiredAttr { - continue - } - obj = updateWidgetPropertyValue(obj, propertyTypeIDs, propKey, func(val bson.D) bson.D { - for _, elem := range val { - if elem.Key == "Objects" { - if arr, ok := elem.Value.(bson.A); ok && len(arr) <= 1 { - // Empty Objects array — create one default entry - defaultObj := createDefaultWidgetObject(entry.ObjectTypeID, entry.NestedPropertyIDs) - newArr := bson.A{int32(2), defaultObj} - result := make(bson.D, 0, len(val)) - for _, e := range val { - if e.Key == "Objects" { - result = append(result, bson.E{Key: "Objects", Value: newArr}) - } else { - result = append(result, e) - } - } - return result - } - } - } - return val - }) - } - return obj -} - -// createDefaultWidgetObject creates a minimal WidgetObject BSON entry for an object list. -func createDefaultWidgetObject(objectTypeID string, nestedProps map[string]pages.PropertyTypeIDEntry) bson.D { - propsArr := bson.A{int32(2)} // version marker - for _, entry := range nestedProps { - prop := createDefaultWidgetProperty(entry) - propsArr = append(propsArr, prop) - } - return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "TypePointer", Value: hexIDToBlob(objectTypeID)}, - {Key: "Properties", Value: propsArr}, - } -} - -// createDefaultWidgetProperty creates a WidgetProperty with default WidgetValue. -func createDefaultWidgetProperty(entry pages.PropertyTypeIDEntry) bson.D { - return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: hexIDToBlob(entry.PropertyTypeID)}, - {Key: "Value", Value: createDefaultWidgetValue(entry)}, - } -} - -// createDefaultWidgetValue creates a WidgetValue with standard default fields. -// Sets type-specific defaults: Expression→Expression field, TextTemplate→template, etc. -func createDefaultWidgetValue(entry pages.PropertyTypeIDEntry) bson.D { - primitiveVal := entry.DefaultValue - expressionVal := "" - var textTemplate interface{} // nil by default - - // Route default value to the correct field based on ValueType - switch entry.ValueType { - case "Expression": - expressionVal = primitiveVal - primitiveVal = "" - case "TextTemplate": - // Create a ClientTemplate with a placeholder translation to satisfy CE4899 - text := primitiveVal - if text == "" { - text = " " // non-empty to satisfy "required" translation check - } - textTemplate = createDefaultClientTemplateBSON(text) - case "String": - if primitiveVal == "" { - primitiveVal = " " // non-empty to satisfy required String properties - } - } - - return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", Value: nil}, - {Key: "DataSource", Value: nil}, - {Key: "EntityRef", Value: nil}, - {Key: "Expression", Value: expressionVal}, - {Key: "Form", Value: ""}, - {Key: "Icon", Value: nil}, - {Key: "Image", Value: ""}, - {Key: "Microflow", Value: ""}, - {Key: "Nanoflow", Value: ""}, - {Key: "Objects", Value: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: primitiveVal}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: textTemplate}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: hexIDToBlob(entry.ValueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - {Key: "XPathConstraint", Value: ""}, - } -} diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index 548fa01b..5ffd656b 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -7,13 +7,11 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/mpr" "github.com/mendixlabs/mxcli/sdk/pages" - "github.com/mendixlabs/mxcli/sdk/widgets" - "go.mongodb.org/mongo-driver/bson" ) // defaultSlotContainer is the MDLContainer name that receives default (non-containerized) child widgets. @@ -36,6 +34,16 @@ type WidgetDefinition struct { Modes []WidgetMode `json:"modes,omitempty"` } +// PropertyMapping maps an MDL source (attribute, association, literal, etc.) +// to a pluggable widget property key via a named operation. +type PropertyMapping struct { + PropertyKey string `json:"propertyKey"` + Source string `json:"source,omitempty"` + Value string `json:"value,omitempty"` + Operation string `json:"operation"` + Default string `json:"default,omitempty"` +} + // WidgetMode defines a conditional configuration variant for a widget. // For example, ComboBox has "enumeration" and "association" modes. // Modes are evaluated in order; the first matching condition wins. @@ -64,8 +72,7 @@ type BuildContext struct { EntityName string PrimitiveVal string DataSource pages.DataSource - ChildWidgets []bson.D - ActionBSON bson.D // Serialized client action BSON for opAction + Action pages.ClientAction // Domain-typed client action pageBuilder *pageBuilder } @@ -75,14 +82,14 @@ type BuildContext struct { // PluggableWidgetEngine builds CustomWidget instances from WidgetDefinition + AST. type PluggableWidgetEngine struct { - operations *OperationRegistry + backend backend.WidgetBuilderBackend pageBuilder *pageBuilder } -// NewPluggableWidgetEngine creates a new engine with the given registry and page builder. -func NewPluggableWidgetEngine(ops *OperationRegistry, pb *pageBuilder) *PluggableWidgetEngine { +// NewPluggableWidgetEngine creates a new engine with the given backend and page builder. +func NewPluggableWidgetEngine(b backend.WidgetBuilderBackend, pb *pageBuilder) *PluggableWidgetEngine { return &PluggableWidgetEngine{ - operations: ops, + backend: b, pageBuilder: pb, } } @@ -93,18 +100,16 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* oldEntityContext := e.pageBuilder.entityContext defer func() { e.pageBuilder.entityContext = oldEntityContext }() - // 1. Load template - embeddedType, embeddedObject, embeddedIDs, embeddedObjectTypeID, err := - widgets.GetTemplateFullBSON(def.WidgetID, types.GenerateID, e.pageBuilder.reader.Path()) + // 1. Load template via backend + builder, err := e.backend.LoadWidgetTemplate(def.WidgetID, e.pageBuilder.getProjectPath()) if err != nil { return nil, mdlerrors.NewBackend("load "+def.MDLName+" template", err) } - if embeddedType == nil || embeddedObject == nil { + if builder == nil { return nil, mdlerrors.NewNotFound("template", def.MDLName) } - propertyTypeIDs := convertPropertyTypeIDs(embeddedIDs) - updatedObject := embeddedObject + propertyTypeIDs := builder.PropertyTypeIDs() // 2. Select mode and get mappings/slots mappings, slots, err := e.selectMappings(def, w) @@ -119,21 +124,17 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* return nil, mdlerrors.NewBackend("resolve mapping for "+mapping.PropertyKey, err) } - op := e.operations.Lookup(mapping.Operation) - if op == nil { - return nil, mdlerrors.NewValidationf("unknown operation %q for property %s", mapping.Operation, mapping.PropertyKey) + if err := e.applyOperation(builder, mapping.Operation, mapping.PropertyKey, ctx); err != nil { + return nil, err } - - updatedObject = op(updatedObject, propertyTypeIDs, mapping.PropertyKey, ctx) } // 4. Apply child slots (.def.json) - if err := e.applyChildSlots(slots, w, propertyTypeIDs, &updatedObject); err != nil { + if err := e.applyChildSlots(builder, slots, w, propertyTypeIDs); err != nil { return nil, err } // 4.1 Auto datasource: map AST DataSource to first DataSource-type property. - // Must run BEFORE child slots and explicit properties so entityContext is set. dsHandledByMapping := false for _, m := range mappings { if m.Source == "DataSource" { @@ -149,8 +150,7 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* if err != nil { return nil, mdlerrors.NewBackend("auto datasource for "+propKey, err) } - ctx := &BuildContext{DataSource: dataSource, EntityName: entityName} - updatedObject = opDatasource(updatedObject, propertyTypeIDs, propKey, ctx) + builder.SetDataSource(propKey, dataSource) if entityName != "" { e.pageBuilder.entityContext = entityName } @@ -161,15 +161,10 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* } // 4.3 Auto child slots: match AST children to Widgets-type template properties. - // Two matching strategies: - // 1. Named match: CONTAINER trigger { ... } → property "trigger" (by child name) - // 2. Default slot: direct children not matching any named slot → first Widgets property - // This allows pluggable widget child containers without requiring .def.json ChildSlot entries. handledSlotKeys := make(map[string]bool) for _, s := range slots { handledSlotKeys[s.PropertyKey] = true } - // Collect Widgets-type property keys var widgetsPropKeys []string for propKey, entry := range propertyTypeIDs { if entry.ValueType == "Widgets" && !handledSlotKeys[propKey] { @@ -177,7 +172,7 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* } } // Phase 1: Named matching — match children by name against property keys - matchedChildren := make(map[int]bool) // indices of matched children + matchedChildren := make(map[int]bool) for _, propKey := range widgetsPropKeys { upperKey := strings.ToUpper(propKey) for i, child := range w.Children { @@ -185,18 +180,18 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* continue } if strings.ToUpper(child.Name) == upperKey { - var childBSONs []bson.D + var childWidgets []pages.Widget for _, slotChild := range child.Children { - widgetBSON, err := e.pageBuilder.buildWidgetV3ToBSON(slotChild) + widget, err := e.pageBuilder.buildWidgetV3(slotChild) if err != nil { return nil, err } - if widgetBSON != nil { - childBSONs = append(childBSONs, widgetBSON) + if widget != nil { + childWidgets = append(childWidgets, widget) } } - if len(childBSONs) > 0 { - updatedObject = opWidgets(updatedObject, propertyTypeIDs, propKey, &BuildContext{ChildWidgets: childBSONs}) + if len(childWidgets) > 0 { + builder.SetChildWidgets(propKey, childWidgets) handledSlotKeys[propKey] = true } matchedChildren[i] = true @@ -205,12 +200,11 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* } } // Phase 2: Default slot — unmatched direct children go to first unmatched Widgets property. - // Skip if .def.json has childSlots defined — applyChildSlots already handles direct children. defSlotContainers := make(map[string]bool) for _, s := range slots { defSlotContainers[strings.ToUpper(s.MDLContainer)] = true } - var defaultWidgetBSONs []bson.D + var defaultWidgets []pages.Widget for i, child := range w.Children { if matchedChildren[i] { continue @@ -221,18 +215,18 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* if defSlotContainers[strings.ToUpper(child.Type)] { continue } - widgetBSON, err := e.pageBuilder.buildWidgetV3ToBSON(child) + widget, err := e.pageBuilder.buildWidgetV3(child) if err != nil { return nil, err } - if widgetBSON != nil { - defaultWidgetBSONs = append(defaultWidgetBSONs, widgetBSON) + if widget != nil { + defaultWidgets = append(defaultWidgets, widget) } } - if len(defaultWidgetBSONs) > 0 { + if len(defaultWidgets) > 0 { for _, propKey := range widgetsPropKeys { if !handledSlotKeys[propKey] { - updatedObject = opWidgets(updatedObject, propertyTypeIDs, propKey, &BuildContext{ChildWidgets: defaultWidgetBSONs}) + builder.SetChildWidgets(propKey, defaultWidgets) break } } @@ -270,81 +264,47 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* default: continue } - ctx := &BuildContext{} // Route by ValueType when available switch entry.ValueType { case "Expression": - // Expression properties: set Expression field (not PrimitiveValue) - ctx.PrimitiveVal = strVal - updatedObject = opExpression(updatedObject, propertyTypeIDs, propName, ctx) + builder.SetExpression(propName, strVal) case "TextTemplate": - // TextTemplate properties: create ClientTemplate with attribute parameter binding. - // Syntax: '{AttributeName} - {OtherAttr}' → text '{1} - {2}' with TemplateParameters. entityCtx := e.pageBuilder.entityContext - tmplBSON := createClientTemplateBSONWithParams(strVal, entityCtx) - updatedObject = updateWidgetPropertyValue(updatedObject, propertyTypeIDs, propName, func(val bson.D) bson.D { - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "TextTemplate" { - result = append(result, bson.E{Key: "TextTemplate", Value: tmplBSON}) - } else { - result = append(result, elem) - } - } - return result - }) + builder.SetTextTemplateWithParams(propName, strVal, entityCtx) case "Attribute": - // Attribute properties: resolve path + attrPath := "" if strings.Count(strVal, ".") >= 2 { - ctx.AttributePath = strVal + attrPath = strVal } else if e.pageBuilder.entityContext != "" { - ctx.AttributePath = e.pageBuilder.resolveAttributePath(strVal) + attrPath = e.pageBuilder.resolveAttributePath(strVal) } - if ctx.AttributePath != "" { - updatedObject = opAttribute(updatedObject, propertyTypeIDs, propName, ctx) + if attrPath != "" { + builder.SetAttribute(propName, attrPath) } default: // Known non-attribute types: always use primitive if entry.ValueType != "" && entry.ValueType != "Attribute" { - ctx.PrimitiveVal = strVal - updatedObject = opPrimitive(updatedObject, propertyTypeIDs, propName, ctx) + builder.SetPrimitive(propName, strVal) continue } // Legacy routing for properties without ValueType info if strings.Count(strVal, ".") >= 2 { - ctx.AttributePath = strVal - updatedObject = opAttribute(updatedObject, propertyTypeIDs, propName, ctx) + builder.SetAttribute(propName, strVal) } else if e.pageBuilder.entityContext != "" && !strings.ContainsAny(strVal, " '\"") { - ctx.AttributePath = e.pageBuilder.resolveAttributePath(strVal) - updatedObject = opAttribute(updatedObject, propertyTypeIDs, propName, ctx) + builder.SetAttribute(propName, e.pageBuilder.resolveAttributePath(strVal)) } else { - ctx.PrimitiveVal = strVal - updatedObject = opPrimitive(updatedObject, propertyTypeIDs, propName, ctx) + builder.SetPrimitive(propName, strVal) } } } - // 4.9 Auto-populate required empty object lists (e.g., Accordion groups, AreaChart series) - updatedObject = ensureRequiredObjectLists(updatedObject, propertyTypeIDs) + // 4.9 Auto-populate required empty object lists + builder.EnsureRequiredObjectLists() // 5. Build CustomWidget widgetID := model.ID(types.GenerateID()) - cw := &pages.CustomWidget{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: widgetID, - TypeName: "CustomWidgets$CustomWidget", - }, - Name: w.Name, - }, - Label: w.GetLabel(), - Editable: def.DefaultEditable, - RawType: embeddedType, - RawObject: updatedObject, - PropertyTypeIDMap: propertyTypeIDs, - ObjectTypeID: embeddedObjectTypeID, - } + cw := builder.Finalize(widgetID, w.Name, w.GetLabel(), def.DefaultEditable) if err := e.pageBuilder.registerWidgetName(w.Name, cw.ID); err != nil { return nil, err @@ -353,16 +313,41 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* return cw, nil } +// applyOperation dispatches a named operation to the corresponding builder method. +func (e *PluggableWidgetEngine) applyOperation(builder backend.WidgetObjectBuilder, opName string, propKey string, ctx *BuildContext) error { + switch opName { + case "attribute": + builder.SetAttribute(propKey, ctx.AttributePath) + case "association": + builder.SetAssociation(propKey, ctx.AssocPath, ctx.EntityName) + case "primitive": + builder.SetPrimitive(propKey, ctx.PrimitiveVal) + case "selection": + builder.SetSelection(propKey, ctx.PrimitiveVal) + case "expression": + builder.SetExpression(propKey, ctx.PrimitiveVal) + case "datasource": + builder.SetDataSource(propKey, ctx.DataSource) + case "widgets": + // ctx doesn't carry child widgets for this path — handled by applyChildSlots + case "texttemplate": + builder.SetTextTemplate(propKey, ctx.PrimitiveVal) + case "action": + builder.SetAction(propKey, ctx.Action) + case "attributeObjects": + builder.SetAttributeObjects(propKey, ctx.AttributePaths) + default: + return mdlerrors.NewValidationf("unknown operation %q for property %s", opName, propKey) + } + return nil +} + // selectMappings selects the active PropertyMappings and ChildSlotMappings based on mode. -// Modes are evaluated in definition order; the first matching condition wins. -// A mode with no condition acts as the default fallback. func (e *PluggableWidgetEngine) selectMappings(def *WidgetDefinition, w *ast.WidgetV3) ([]PropertyMapping, []ChildSlotMapping, error) { - // No modes defined — use top-level mappings directly if len(def.Modes) == 0 { return def.PropertyMappings, def.ChildSlots, nil } - // Evaluate modes in order; first match wins var fallback *WidgetMode var fallbackCount int for i := range def.Modes { @@ -379,7 +364,6 @@ func (e *PluggableWidgetEngine) selectMappings(def *WidgetDefinition, w *ast.Wid } } - // Use fallback mode if fallback != nil { if fallbackCount > 1 { return nil, nil, mdlerrors.NewValidationf("widget %s has %d modes without conditions; only one default mode is allowed", def.MDLName, fallbackCount) @@ -409,7 +393,6 @@ func (e *PluggableWidgetEngine) evaluateCondition(condition string, w *ast.Widge func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.WidgetV3) (*BuildContext, error) { ctx := &BuildContext{pageBuilder: e.pageBuilder} - // Static value takes priority if mapping.Value != "" { ctx.PrimitiveVal = mapping.Value return ctx, nil @@ -459,7 +442,6 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W case "CaptionAttribute": if captionAttr := w.GetStringProp("CaptionAttribute"); captionAttr != "" { - // Resolve relative to entity context if !strings.Contains(captionAttr, ".") && e.pageBuilder.entityContext != "" { captionAttr = e.pageBuilder.entityContext + "." + captionAttr } @@ -467,28 +449,24 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W } case "Association": - // For association operation: resolve both assoc path AND entity name from DataSource if attr := w.GetAttribute(); attr != "" { ctx.AssocPath = e.pageBuilder.resolveAssociationPath(attr) } - // Entity name comes from DataSource context (must be resolved first by a DataSource mapping) ctx.EntityName = e.pageBuilder.entityContext if ctx.AssocPath != "" && ctx.EntityName == "" { return nil, mdlerrors.NewValidationf("association %q requires an entity context (add a DataSource mapping before Association)", ctx.AssocPath) } case "OnClick": - // Resolve AST action (stored as Properties["Action"]) into serialized BSON if action := w.GetAction(); action != nil { act, err := e.pageBuilder.buildClientActionV3(action) if err != nil { return nil, mdlerrors.NewBackend("build action", err) } - ctx.ActionBSON = mpr.SerializeClientAction(act) + ctx.Action = act } default: - // Generic fallback: treat source as a property name on the AST widget val := w.GetStringProp(source) if val == "" && mapping.Default != "" { val = mapping.Default @@ -500,65 +478,58 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W } // applyChildSlots processes child slot mappings, building child widgets and embedding them. -func (e *PluggableWidgetEngine) applyChildSlots(slots []ChildSlotMapping, w *ast.WidgetV3, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, updatedObject *bson.D) error { +func (e *PluggableWidgetEngine) applyChildSlots(builder backend.WidgetObjectBuilder, slots []ChildSlotMapping, w *ast.WidgetV3, propertyTypeIDs map[string]pages.PropertyTypeIDEntry) error { if len(slots) == 0 { return nil } - // Build a set of slot container names for matching slotContainers := make(map[string]*ChildSlotMapping, len(slots)) for i := range slots { slotContainers[slots[i].MDLContainer] = &slots[i] } - // Group children by slot - slotWidgets := make(map[string][]bson.D) - var defaultWidgets []bson.D + slotWidgets := make(map[string][]pages.Widget) + var defaultWidgets []pages.Widget for _, child := range w.Children { upperType := strings.ToUpper(child.Type) if slot, ok := slotContainers[upperType]; ok { - // Container matches a slot — build its children for _, slotChild := range child.Children { - widgetBSON, err := e.pageBuilder.buildWidgetV3ToBSON(slotChild) + widget, err := e.pageBuilder.buildWidgetV3(slotChild) if err != nil { return err } - if widgetBSON != nil { - slotWidgets[slot.PropertyKey] = append(slotWidgets[slot.PropertyKey], widgetBSON) + if widget != nil { + slotWidgets[slot.PropertyKey] = append(slotWidgets[slot.PropertyKey], widget) } } } else { - // Direct child — default content - widgetBSON, err := e.pageBuilder.buildWidgetV3ToBSON(child) + widget, err := e.pageBuilder.buildWidgetV3(child) if err != nil { return err } - if widgetBSON != nil { - defaultWidgets = append(defaultWidgets, widgetBSON) + if widget != nil { + defaultWidgets = append(defaultWidgets, widget) } } } - // Apply each slot's widgets via its operation for _, slot := range slots { - childBSONs := slotWidgets[slot.PropertyKey] - // If no explicit container children, use default widgets for the first slot - if len(childBSONs) == 0 && len(defaultWidgets) > 0 && slot.MDLContainer == defaultSlotContainer { - childBSONs = defaultWidgets - defaultWidgets = nil // consume once + children := slotWidgets[slot.PropertyKey] + if len(children) == 0 && len(defaultWidgets) > 0 && slot.MDLContainer == defaultSlotContainer { + children = defaultWidgets + defaultWidgets = nil } - if len(childBSONs) == 0 { + if len(children) == 0 { continue } - op := e.operations.Lookup(slot.Operation) - if op == nil { - return mdlerrors.NewValidationf("unknown operation %q for child slot %s", slot.Operation, slot.PropertyKey) + ctx := &BuildContext{} + if err := e.applyOperation(builder, slot.Operation, slot.PropertyKey, ctx); err != nil { + return err } - - ctx := &BuildContext{ChildWidgets: childBSONs} - *updatedObject = op(*updatedObject, propertyTypeIDs, slot.PropertyKey, ctx) + // SetChildWidgets directly — applyOperation skips "widgets" since ctx doesn't carry children + builder.SetChildWidgets(slot.PropertyKey, children) } return nil diff --git a/mdl/executor/widget_engine_test.go b/mdl/executor/widget_engine_test.go index bb4502de..f7be9510 100644 --- a/mdl/executor/widget_engine_test.go +++ b/mdl/executor/widget_engine_test.go @@ -8,9 +8,6 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" ) func TestWidgetDefinitionJSONRoundTrip(t *testing.T) { @@ -133,44 +130,16 @@ func TestWidgetDefinitionJSONOmitsEmptyOptionalFields(t *testing.T) { } } -func TestOperationRegistryLookupFound(t *testing.T) { - reg := NewOperationRegistry() - - builtinOps := []string{"attribute", "association", "primitive", "selection", "datasource", "widgets"} +func TestKnownOperationsSet(t *testing.T) { + builtinOps := []string{"attribute", "association", "primitive", "selection", "datasource", "widgets", "expression", "texttemplate", "action", "attributeObjects"} for _, name := range builtinOps { - fn := reg.Lookup(name) - if fn == nil { - t.Errorf("Lookup(%q) returned nil, want non-nil", name) + if !knownOperations[name] { + t.Errorf("knownOperations[%q] = false, want true", name) } } -} -func TestOperationRegistryLookupNotFound(t *testing.T) { - reg := NewOperationRegistry() - - fn := reg.Lookup("nonexistent") - if fn != nil { - t.Error("Lookup(\"nonexistent\") should return nil") - } -} - -func TestOperationRegistryCustomRegistration(t *testing.T) { - reg := NewOperationRegistry() - - called := false - reg.Register("custom", func(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - called = true - return obj - }) - - fn := reg.Lookup("custom") - if fn == nil { - t.Fatal("Lookup(\"custom\") returned nil after Register") - } - - fn(bson.D{}, nil, "test", &BuildContext{}) - if !called { - t.Error("custom operation was not called") + if knownOperations["nonexistent"] { + t.Error("knownOperations[\"nonexistent\"] should be false") } } @@ -179,9 +148,7 @@ func TestOperationRegistryCustomRegistration(t *testing.T) { // ============================================================================= func TestEvaluateCondition(t *testing.T) { - engine := &PluggableWidgetEngine{ - operations: NewOperationRegistry(), - } + engine := &PluggableWidgetEngine{} tests := []struct { name string @@ -248,9 +215,7 @@ func TestEvaluateCondition(t *testing.T) { } func TestEvaluateConditionUnknownReturnsFalse(t *testing.T) { - engine := &PluggableWidgetEngine{ - operations: NewOperationRegistry(), - } + engine := &PluggableWidgetEngine{} w := &ast.WidgetV3{Properties: map[string]any{}} result := engine.evaluateCondition("typoCondition", w) @@ -261,7 +226,7 @@ func TestEvaluateConditionUnknownReturnsFalse(t *testing.T) { } func TestSelectMappings_NoModes(t *testing.T) { - engine := &PluggableWidgetEngine{operations: NewOperationRegistry()} + engine := &PluggableWidgetEngine{} def := &WidgetDefinition{ PropertyMappings: []PropertyMapping{ @@ -286,7 +251,7 @@ func TestSelectMappings_NoModes(t *testing.T) { } func TestSelectMappings_WithModes(t *testing.T) { - engine := &PluggableWidgetEngine{operations: NewOperationRegistry()} + engine := &PluggableWidgetEngine{} def := &WidgetDefinition{ Modes: []WidgetMode{ @@ -330,7 +295,7 @@ func TestSelectMappings_WithModes(t *testing.T) { } func TestResolveMapping_StaticValue(t *testing.T) { - engine := &PluggableWidgetEngine{operations: NewOperationRegistry()} + engine := &PluggableWidgetEngine{} mapping := PropertyMapping{ PropertyKey: "optionsSourceType", @@ -355,7 +320,6 @@ func TestResolveMapping_AttributeSource(t *testing.T) { widgetScope: map[string]model.ID{}, } engine := &PluggableWidgetEngine{ - operations: NewOperationRegistry(), pageBuilder: pb, } @@ -376,7 +340,7 @@ func TestResolveMapping_AttributeSource(t *testing.T) { } func TestResolveMapping_SelectionWithDefault(t *testing.T) { - engine := &PluggableWidgetEngine{operations: NewOperationRegistry()} + engine := &PluggableWidgetEngine{} mapping := PropertyMapping{ PropertyKey: "itemSelection", @@ -409,7 +373,7 @@ func TestResolveMapping_SelectionWithDefault(t *testing.T) { } func TestResolveMapping_GenericProp(t *testing.T) { - engine := &PluggableWidgetEngine{operations: NewOperationRegistry()} + engine := &PluggableWidgetEngine{} mapping := PropertyMapping{ PropertyKey: "customProp", @@ -428,7 +392,7 @@ func TestResolveMapping_GenericProp(t *testing.T) { } func TestResolveMapping_EmptySource(t *testing.T) { - engine := &PluggableWidgetEngine{operations: NewOperationRegistry()} + engine := &PluggableWidgetEngine{} mapping := PropertyMapping{ PropertyKey: "someProp", @@ -452,7 +416,6 @@ func TestResolveMapping_CaptionAttribute(t *testing.T) { widgetScope: map[string]model.ID{}, } engine := &PluggableWidgetEngine{ - operations: NewOperationRegistry(), pageBuilder: pb, } @@ -479,7 +442,6 @@ func TestResolveMapping_Association(t *testing.T) { widgetScope: map[string]model.ID{}, } engine := &PluggableWidgetEngine{ - operations: NewOperationRegistry(), pageBuilder: pb, } @@ -501,110 +463,3 @@ func TestResolveMapping_Association(t *testing.T) { t.Errorf("expected EntityName='Module.Order', got %q", ctx.EntityName) } } - -func TestSetChildWidgets(t *testing.T) { - val := bson.D{ - {Key: "PrimitiveValue", Value: ""}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - {Key: "XPathConstraint", Value: ""}, - } - - childWidgets := []bson.D{ - {{Key: "$Type", Value: "Forms$TextBox"}, {Key: "Name", Value: "textBox1"}}, - {{Key: "$Type", Value: "Forms$TextBox"}, {Key: "Name", Value: "textBox2"}}, - } - - updated := setChildWidgets(val, childWidgets) - - // Find Widgets field - for _, elem := range updated { - if elem.Key == "Widgets" { - arr, ok := elem.Value.(bson.A) - if !ok { - t.Fatal("Widgets value is not bson.A") - } - // Should have version marker + 2 widgets - if len(arr) != 3 { - t.Errorf("Widgets array length: got %d, want 3", len(arr)) - } - // First element should be version marker - if marker, ok := arr[0].(int32); !ok || marker != 2 { - t.Errorf("Widgets[0]: got %v, want int32(2)", arr[0]) - } - return - } - } - t.Error("Widgets field not found in result") -} - -func TestOpSelection(t *testing.T) { - // Call the real opSelection function with a properly structured widget BSON. - typePointerBytes := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - typePointerUUID := types.BlobToUUID(typePointerBytes) - - widgetObj := bson.D{ - {Key: "Properties", Value: bson.A{ - int32(2), // version marker - bson.D{ - {Key: "TypePointer", Value: typePointerBytes}, - {Key: "Value", Value: bson.D{ - {Key: "PrimitiveValue", Value: ""}, - {Key: "Selection", Value: "None"}, - }}, - }, - }}, - } - - propTypeIDs := map[string]pages.PropertyTypeIDEntry{ - "selectionType": {PropertyTypeID: typePointerUUID}, - } - - ctx := &BuildContext{PrimitiveVal: "Multi"} - result := opSelection(widgetObj, propTypeIDs, "selectionType", ctx) - - // Extract the updated Value from Properties - var props bson.A - for _, elem := range result { - if elem.Key == "Properties" { - props = elem.Value.(bson.A) - } - } - prop := props[1].(bson.D) // skip version marker at index 0 - var val bson.D - for _, elem := range prop { - if elem.Key == "Value" { - val = elem.Value.(bson.D) - } - } - - selectionFound := false - for _, elem := range val { - if elem.Key == "Selection" { - selectionFound = true - if elem.Value != "Multi" { - t.Errorf("Selection: got %q, want %q", elem.Value, "Multi") - } - } - if elem.Key == "PrimitiveValue" { - if elem.Value != "" { - t.Errorf("PrimitiveValue should remain empty, got %q", elem.Value) - } - } - } - if !selectionFound { - t.Error("Selection field not found in result") - } -} - -func TestOpSelectionEmptyValue(t *testing.T) { - widgetObj := bson.D{ - {Key: "Properties", Value: bson.A{int32(2)}}, - } - ctx := &BuildContext{PrimitiveVal: ""} - result := opSelection(widgetObj, nil, "any", ctx) - - // With empty PrimitiveVal, opSelection returns obj unchanged - if len(result) != len(widgetObj) { - t.Errorf("expected unchanged obj, got different length: %d vs %d", len(result), len(widgetObj)) - } -} diff --git a/mdl/executor/widget_operations.go b/mdl/executor/widget_operations.go deleted file mode 100644 index 183d57c9..00000000 --- a/mdl/executor/widget_operations.go +++ /dev/null @@ -1,301 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "log" - - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -// PropertyMapping maps an MDL source (attribute, association, literal, etc.) -// to a pluggable widget property key via a named operation. -type PropertyMapping struct { - PropertyKey string `json:"propertyKey"` - Source string `json:"source,omitempty"` - Value string `json:"value,omitempty"` - Operation string `json:"operation"` - Default string `json:"default,omitempty"` -} - -// OperationFunc updates a template object's property identified by propertyKey. -// It receives the current object BSON, the property type ID map, the target key, -// and the build context containing resolved values. -type OperationFunc func(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D - -// OperationRegistry maps operation names to their implementations. -type OperationRegistry struct { - operations map[string]OperationFunc -} - -// NewOperationRegistry creates a registry pre-loaded with the built-in operations. -func NewOperationRegistry() *OperationRegistry { - reg := &OperationRegistry{ - operations: make(map[string]OperationFunc), - } - reg.Register("attribute", opAttribute) - reg.Register("association", opAssociation) - reg.Register("primitive", opPrimitive) - reg.Register("selection", opSelection) - reg.Register("datasource", opDatasource) - reg.Register("widgets", opWidgets) - reg.Register("texttemplate", opTextTemplate) - reg.Register("action", opAction) - reg.Register("attributeObjects", opAttributeObjects) - return reg -} - -// Register adds or replaces an operation by name. -func (r *OperationRegistry) Register(name string, fn OperationFunc) { - r.operations[name] = fn -} - -// Lookup returns the operation function for the given name, or nil if not found. -func (r *OperationRegistry) Lookup(name string) OperationFunc { - return r.operations[name] -} - -// Has returns true if the named operation is registered. -func (r *OperationRegistry) Has(name string) bool { - _, ok := r.operations[name] - return ok -} - -// ============================================================================= -// Built-in Operations -// ============================================================================= - -// opAttribute sets an attribute reference on a widget property. -func opAttribute(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - if ctx.AttributePath == "" { - return obj - } - return updateWidgetPropertyValue(obj, propTypeIDs, propertyKey, func(val bson.D) bson.D { - return setAttributeRef(val, ctx.AttributePath) - }) -} - -// opAssociation sets an association reference (AttributeRef + EntityRef) on a widget property. -func opAssociation(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - if ctx.AssocPath == "" { - return obj - } - return updateWidgetPropertyValue(obj, propTypeIDs, propertyKey, func(val bson.D) bson.D { - return setAssociationRef(val, ctx.AssocPath, ctx.EntityName) - }) -} - -// opPrimitive sets a primitive string value on a widget property. -func opPrimitive(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - if ctx.PrimitiveVal == "" { - return obj - } - return updateWidgetPropertyValue(obj, propTypeIDs, propertyKey, func(val bson.D) bson.D { - return setPrimitiveValue(val, ctx.PrimitiveVal) - }) -} - -// opSelection sets a selection mode on a widget property, updating the Selection field -// inside the WidgetValue (which requires a deeper update than opPrimitive's PrimitiveValue). -func opSelection(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - if ctx.PrimitiveVal == "" { - return obj - } - return updateWidgetPropertyValue(obj, propTypeIDs, propertyKey, func(val bson.D) bson.D { - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "Selection" { - result = append(result, bson.E{Key: "Selection", Value: ctx.PrimitiveVal}) - } else { - result = append(result, elem) - } - } - return result - }) -} - -// opExpression sets an expression string on a widget property. -func opExpression(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - if ctx.PrimitiveVal == "" { - return obj - } - return updateWidgetPropertyValue(obj, propTypeIDs, propertyKey, func(val bson.D) bson.D { - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "Expression" { - result = append(result, bson.E{Key: "Expression", Value: ctx.PrimitiveVal}) - } else { - result = append(result, elem) - } - } - return result - }) -} - -// opDatasource sets a data source on a widget property. -func opDatasource(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - if ctx.DataSource == nil { - return obj - } - return updateWidgetPropertyValue(obj, propTypeIDs, propertyKey, func(val bson.D) bson.D { - return setDataSource(val, ctx.DataSource) - }) -} - -// opWidgets replaces the Widgets array in a widget property value with child widgets. -func opWidgets(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - if len(ctx.ChildWidgets) == 0 { - return obj - } - result := updateWidgetPropertyValue(obj, propTypeIDs, propertyKey, func(val bson.D) bson.D { - return setChildWidgets(val, ctx.ChildWidgets) - }) - return result -} - -// setChildWidgets replaces the Widgets field in a WidgetValue with the given child widgets. -func setChildWidgets(val bson.D, childWidgets []bson.D) bson.D { - widgetsArr := bson.A{int32(2)} // version marker - for _, w := range childWidgets { - widgetsArr = append(widgetsArr, w) - } - - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "Widgets" { - result = append(result, bson.E{Key: "Widgets", Value: widgetsArr}) - } else { - result = append(result, elem) - } - } - return result -} - -// opTextTemplate sets a text template value on a widget property. -// It replaces the Template.Items in the TextTemplate with a single text item. -func opTextTemplate(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - if ctx.PrimitiveVal == "" { - return obj - } - return updateWidgetPropertyValue(obj, propTypeIDs, propertyKey, func(val bson.D) bson.D { - return setTextTemplateValue(val, ctx.PrimitiveVal) - }) -} - -// setTextTemplateValue sets the text content in a TextTemplate WidgetValue field. -func setTextTemplateValue(val bson.D, text string) bson.D { - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "TextTemplate" { - if tmpl, ok := elem.Value.(bson.D); ok && tmpl != nil { - result = append(result, bson.E{Key: "TextTemplate", Value: updateTemplateText(tmpl, text)}) - } else { - // TextTemplate was null in the template — skip. - // Creating a TextTemplate from null triggers CE0463 because Studio Pro - // detects the structural change. The template must be extracted from a - // widget that already has this property configured in Studio Pro. - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - return result -} - -// updateTemplateText updates the Template.Items in a Forms$ClientTemplate with a text value. -func updateTemplateText(tmpl bson.D, text string) bson.D { - result := make(bson.D, 0, len(tmpl)) - for _, elem := range tmpl { - if elem.Key == "Template" { - if template, ok := elem.Value.(bson.D); ok { - updated := make(bson.D, 0, len(template)) - for _, tElem := range template { - if tElem.Key == "Items" { - updated = append(updated, bson.E{Key: "Items", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$ID", Value: bsonutil.IDToBsonBinary(types.GenerateID())}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: "en_US"}, - {Key: "Text", Value: text}, - }, - }}) - } else { - updated = append(updated, tElem) - } - } - result = append(result, bson.E{Key: "Template", Value: updated}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - return result -} - -// opAction sets a client action on a widget property. -func opAction(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - if ctx.ActionBSON == nil { - return obj - } - return updateWidgetPropertyValue(obj, propTypeIDs, propertyKey, func(val bson.D) bson.D { - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "Action" { - result = append(result, bson.E{Key: "Action", Value: ctx.ActionBSON}) - } else { - result = append(result, elem) - } - } - return result - }) -} - -// opAttributeObjects populates the Objects array in an "attributes" property -// with attribute reference objects. Used by filter widgets (TEXTFILTER, etc.). -func opAttributeObjects(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, ctx *BuildContext) bson.D { - if len(ctx.AttributePaths) == 0 { - return obj - } - - entry, ok := propTypeIDs[propertyKey] - if !ok || entry.ObjectTypeID == "" { - return obj - } - - // Get nested "attribute" property IDs from the PropertyTypeIDEntry - nestedEntry, ok := entry.NestedPropertyIDs["attribute"] - if !ok { - return obj - } - - return updateWidgetPropertyValue(obj, propTypeIDs, propertyKey, func(val bson.D) bson.D { - objects := make([]any, 0, len(ctx.AttributePaths)+1) - objects = append(objects, int32(2)) // BSON array version marker - - for _, attrPath := range ctx.AttributePaths { - attrObj, err := ctx.pageBuilder.createAttributeObject(attrPath, entry.ObjectTypeID, nestedEntry.PropertyTypeID, nestedEntry.ValueTypeID) - if err != nil { - log.Printf("warning: skipping attribute %s: %v", attrPath, err) - continue - } - objects = append(objects, attrObj) - } - - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "Objects" { - result = append(result, bson.E{Key: "Objects", Value: bson.A(objects)}) - } else { - result = append(result, elem) - } - } - return result - }) -} diff --git a/mdl/executor/widget_registry.go b/mdl/executor/widget_registry.go index a0614d84..dcbabfb8 100644 --- a/mdl/executor/widget_registry.go +++ b/mdl/executor/widget_registry.go @@ -18,23 +18,27 @@ import ( type WidgetRegistry struct { byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName byWidgetID map[string]*WidgetDefinition // keyed by widgetId - opReg *OperationRegistry // used for validating definition operations } -// NewWidgetRegistry creates a registry pre-loaded with embedded definitions. -// Uses a default OperationRegistry for validation. Use NewWidgetRegistryWithOps -// to provide a custom registry with additional operations. -func NewWidgetRegistry() (*WidgetRegistry, error) { - return NewWidgetRegistryWithOps(NewOperationRegistry()) +// knownOperations is the set of operation names supported by the widget engine. +var knownOperations = map[string]bool{ + "attribute": true, + "association": true, + "primitive": true, + "selection": true, + "expression": true, + "datasource": true, + "widgets": true, + "texttemplate": true, + "action": true, + "attributeObjects": true, } -// NewWidgetRegistryWithOps creates a registry pre-loaded with embedded definitions, -// validating operations against the provided OperationRegistry. -func NewWidgetRegistryWithOps(opReg *OperationRegistry) (*WidgetRegistry, error) { +// NewWidgetRegistry creates a registry pre-loaded with embedded definitions. +func NewWidgetRegistry() (*WidgetRegistry, error) { reg := &WidgetRegistry{ byMDLName: make(map[string]*WidgetDefinition), byWidgetID: make(map[string]*WidgetDefinition), - opReg: opReg, } entries, err := definitions.EmbeddedFS.ReadDir(".") @@ -57,7 +61,7 @@ func NewWidgetRegistryWithOps(opReg *OperationRegistry) (*WidgetRegistry, error) return nil, mdlerrors.NewBackend(fmt.Sprintf("parse definition %s", entry.Name()), err) } - if err := validateDefinitionOperations(&def, entry.Name(), opReg); err != nil { + if err := validateDefinitionOperations(&def, entry.Name()); err != nil { return nil, err } @@ -152,7 +156,7 @@ func (r *WidgetRegistry) loadDefinitionsFromDir(dir string) error { return mdlerrors.NewValidationf("invalid definition %s: widgetId and mdlName are required", entry.Name()) } - if err := validateDefinitionOperations(&def, entry.Name(), r.opReg); err != nil { + if err := validateDefinitionOperations(&def, entry.Name()); err != nil { return err } @@ -174,24 +178,24 @@ func (r *WidgetRegistry) loadDefinitionsFromDir(dir string) error { } // validateDefinitionOperations checks that all operation names in a definition -// are recognized by the given OperationRegistry, and validates source/operation +// are recognized by the known operations set, and validates source/operation // compatibility and mapping order dependencies. -func validateDefinitionOperations(def *WidgetDefinition, source string, opReg *OperationRegistry) error { - if err := validateMappings(def.PropertyMappings, source, "", opReg); err != nil { +func validateDefinitionOperations(def *WidgetDefinition, source string) error { + if err := validateMappings(def.PropertyMappings, source, ""); err != nil { return err } for _, s := range def.ChildSlots { - if !opReg.Has(s.Operation) { + if !knownOperations[s.Operation] { return mdlerrors.NewValidationf("%s: unknown operation %q in childSlots for key %q", source, s.Operation, s.PropertyKey) } } for _, mode := range def.Modes { ctx := fmt.Sprintf("mode %q ", mode.Name) - if err := validateMappings(mode.PropertyMappings, source, ctx, opReg); err != nil { + if err := validateMappings(mode.PropertyMappings, source, ctx); err != nil { return err } for _, s := range mode.ChildSlots { - if !opReg.Has(s.Operation) { + if !knownOperations[s.Operation] { return mdlerrors.NewValidationf("%s: unknown operation %q in %schildSlots for key %q", source, s.Operation, ctx, s.PropertyKey) } } @@ -209,10 +213,10 @@ var incompatibleSourceOps = map[string]map[string]bool{ // validateMappings validates a slice of property mappings for operation existence, // source/operation compatibility, and mapping order (Association requires prior DataSource). -func validateMappings(mappings []PropertyMapping, source, modeCtx string, opReg *OperationRegistry) error { +func validateMappings(mappings []PropertyMapping, source, modeCtx string) error { hasDataSource := false for _, m := range mappings { - if !opReg.Has(m.Operation) { + if !knownOperations[m.Operation] { return mdlerrors.NewValidationf("%s: unknown operation %q in %spropertyMappings for key %q", source, m.Operation, modeCtx, m.PropertyKey) } // Check source/operation compatibility diff --git a/mdl/executor/widget_registry_test.go b/mdl/executor/widget_registry_test.go index 6314d65c..b3e8608f 100644 --- a/mdl/executor/widget_registry_test.go +++ b/mdl/executor/widget_registry_test.go @@ -190,8 +190,6 @@ func TestRegistryLoadUserDefinitions(t *testing.T) { } func TestValidateDefinitionOperations_MappingOrderDependency(t *testing.T) { - opReg := NewOperationRegistry() - // Association before DataSource should fail validation badDef := &WidgetDefinition{ WidgetID: "com.example.Bad", @@ -201,7 +199,7 @@ func TestValidateDefinitionOperations_MappingOrderDependency(t *testing.T) { {PropertyKey: "dsProp", Source: "DataSource", Operation: "datasource"}, }, } - if err := validateDefinitionOperations(badDef, "bad.def.json", opReg); err == nil { + if err := validateDefinitionOperations(badDef, "bad.def.json"); err == nil { t.Error("expected error for Association before DataSource, got nil") } @@ -214,7 +212,7 @@ func TestValidateDefinitionOperations_MappingOrderDependency(t *testing.T) { {PropertyKey: "assocProp", Source: "Association", Operation: "association"}, }, } - if err := validateDefinitionOperations(goodDef, "good.def.json", opReg); err != nil { + if err := validateDefinitionOperations(goodDef, "good.def.json"); err != nil { t.Errorf("unexpected error for DataSource before Association: %v", err) } @@ -232,14 +230,12 @@ func TestValidateDefinitionOperations_MappingOrderDependency(t *testing.T) { }, }, } - if err := validateDefinitionOperations(modeDef, "mode.def.json", opReg); err == nil { + if err := validateDefinitionOperations(modeDef, "mode.def.json"); err == nil { t.Error("expected error for Association before DataSource in mode, got nil") } } func TestValidateDefinitionOperations_SourceOperationCompatibility(t *testing.T) { - opReg := NewOperationRegistry() - // Source "Attribute" with Operation "association" should fail badDef := &WidgetDefinition{ WidgetID: "com.example.Bad", @@ -248,7 +244,7 @@ func TestValidateDefinitionOperations_SourceOperationCompatibility(t *testing.T) {PropertyKey: "prop", Source: "Attribute", Operation: "association"}, }, } - if err := validateDefinitionOperations(badDef, "bad.def.json", opReg); err == nil { + if err := validateDefinitionOperations(badDef, "bad.def.json"); err == nil { t.Error("expected error for Source='Attribute' with Operation='association', got nil") } @@ -260,7 +256,7 @@ func TestValidateDefinitionOperations_SourceOperationCompatibility(t *testing.T) {PropertyKey: "prop", Source: "Association", Operation: "attribute"}, }, } - if err := validateDefinitionOperations(badDef2, "bad2.def.json", opReg); err == nil { + if err := validateDefinitionOperations(badDef2, "bad2.def.json"); err == nil { t.Error("expected error for Source='Association' with Operation='attribute', got nil") } } diff --git a/mdl/executor/widget_templates.go b/mdl/executor/widget_templates.go deleted file mode 100644 index d5d5e9c9..00000000 --- a/mdl/executor/widget_templates.go +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "encoding/hex" - "fmt" - "regexp" - "strings" - - "github.com/mendixlabs/mxcli/mdl/types" - "go.mongodb.org/mongo-driver/bson" -) - -// createClientTemplateBSONWithParams creates a Forms$ClientTemplate that supports -// attribute parameter binding. Syntax: '{AttrName} - {OtherAttr}' extracts attribute -// names from curly braces, replaces them with {1}, {2}, etc., and generates -// TemplateParameter entries with AttributeRef bindings. -// If no {AttrName} patterns are found, creates a static text template. -func createClientTemplateBSONWithParams(text string, entityContext string) bson.D { - // Extract {AttributeName} patterns and build parameter list - re := regexp.MustCompile(`\{([A-Za-z][A-Za-z0-9_]*)\}`) - matches := re.FindAllStringSubmatchIndex(text, -1) - - if len(matches) == 0 { - // No attribute references — static text - return createDefaultClientTemplateBSON(text) - } - - // Replace {AttrName} with {1}, {2}, etc. and collect attribute names - var attrNames []string - paramText := text - // Process in reverse to preserve indices - for i := len(matches) - 1; i >= 0; i-- { - match := matches[i] - attrName := text[match[2]:match[3]] - // Check if it's a pure number (like {1}) — keep as-is - if _, err := fmt.Sscanf(attrName, "%d", new(int)); err == nil { - continue - } - attrNames = append([]string{attrName}, attrNames...) // prepend - paramText = paramText[:match[0]] + fmt.Sprintf("{%d}", len(attrNames)) + paramText[match[1]:] - } - - // Rebuild paramText with sequential numbering - paramText = text - attrNames = nil - for i := 0; i < len(matches); i++ { - match := matches[i] - attrName := text[match[2]:match[3]] - if _, err := fmt.Sscanf(attrName, "%d", new(int)); err == nil { - continue - } - attrNames = append(attrNames, attrName) - } - paramText = re.ReplaceAllStringFunc(text, func(s string) string { - name := s[1 : len(s)-1] - if _, err := fmt.Sscanf(name, "%d", new(int)); err == nil { - return s // keep numeric {1} as-is - } - for i, an := range attrNames { - if an == name { - return fmt.Sprintf("{%d}", i+1) - } - } - return s - }) - - // Build parameters BSON - params := bson.A{int32(2)} // version marker for non-empty array - for _, attrName := range attrNames { - attrPath := attrName - if entityContext != "" && !strings.Contains(attrName, ".") { - attrPath = entityContext + "." + attrName - } - params = append(params, bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "Forms$ClientTemplateParameter"}, - {Key: "AttributeRef", Value: bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: attrPath}, - {Key: "EntityRef", Value: nil}, - }}, - {Key: "Expression", Value: ""}, - {Key: "FormattingInfo", Value: bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "Forms$FormattingInfo"}, - {Key: "CustomDateFormat", Value: ""}, - {Key: "DateFormat", Value: "Date"}, - {Key: "DecimalPrecision", Value: int64(2)}, - {Key: "EnumFormat", Value: "Text"}, - {Key: "GroupDigits", Value: false}, - {Key: "TimeFormat", Value: "HoursMinutes"}, - }}, - {Key: "SourceVariable", Value: nil}, - }) - } - - makeText := func(t string) bson.D { - return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: "en_US"}, - {Key: "Text", Value: t}, - }}}, - } - } - - return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: makeText(paramText)}, - {Key: "Parameters", Value: params}, - {Key: "Template", Value: makeText(paramText)}, - } -} - -// createDefaultClientTemplateBSON creates a Forms$ClientTemplate with an en_US translation. -func createDefaultClientTemplateBSON(text string) bson.D { - makeText := func(t string) bson.D { - return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: "en_US"}, - {Key: "Text", Value: t}, - }}}, - } - } - return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: makeText(text)}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - {Key: "Template", Value: makeText(text)}, - } -} - -// generateBinaryID creates a new random 16-byte UUID in Microsoft GUID binary format. -func generateBinaryID() []byte { - return hexIDToBlob(types.GenerateID()) -} - -// hexIDToBlob converts a hex UUID string to a 16-byte binary blob in Microsoft GUID format. -func hexIDToBlob(hexStr string) []byte { - hexStr = strings.ReplaceAll(hexStr, "-", "") - data, err := hex.DecodeString(hexStr) - if err != nil || len(data) != 16 { - return data - } - // Swap bytes to match Microsoft GUID format (little-endian for first 3 segments) - data[0], data[1], data[2], data[3] = data[3], data[2], data[1], data[0] - data[4], data[5] = data[5], data[4] - data[6], data[7] = data[7], data[6] - return data -} From 48db8b34225efd0ed1441e7b245e987a433efc27 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Mon, 20 Apr 2026 14:05:12 +0200 Subject: [PATCH 2/5] test: add round-trip, wrapper, workflow mutator, and SetLayout tests - 33 round-trip and wrapper tests for convert/unconvert layer covering all forward conversions, reverse (unconvert) paths, nil handling, and error passthrough - 48+ workflow mutator tests: InsertAfterActivity, ReplaceActivity, InsertOutcome, DropOutcome, InsertPath, DropPath, InsertBranch, DropBranch, InsertBoundaryEvent, SetActivityProperty, SetPropertyWithEntity - 6 SetLayout tests: basic layout change, explicit param mappings, same-layout no-op, snippet rejection, missing FormCall, empty Form - Widget engine test updates for applyChildSlots operation validation --- mdl/backend/mpr/convert_roundtrip_test.go | 600 +++++++++++++++ mdl/backend/mpr/page_mutator_test.go | 137 ++++ mdl/backend/mpr/workflow_mutator_test.go | 874 ++++++++++++++++++++++ mdl/executor/widget_engine_test.go | 8 +- 4 files changed, 1617 insertions(+), 2 deletions(-) create mode 100644 mdl/backend/mpr/convert_roundtrip_test.go diff --git a/mdl/backend/mpr/convert_roundtrip_test.go b/mdl/backend/mpr/convert_roundtrip_test.go new file mode 100644 index 00000000..10295264 --- /dev/null +++ b/mdl/backend/mpr/convert_roundtrip_test.go @@ -0,0 +1,600 @@ +// SPDX-License-Identifier: Apache-2.0 + +// convert_roundtrip_test.go — internal tests that exercise the actual +// convert/unconvert functions with fully-populated structs to ensure +// round-trip correctness and full field preservation. +package mprbackend + +import ( + "errors" + "testing" + + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/mpr" + "github.com/mendixlabs/mxcli/sdk/mpr/version" + "go.mongodb.org/mongo-driver/bson" +) + +var errTest = errors.New("test error") + +// --------------------------------------------------------------------------- +// Forward conversions: sdk/mpr -> mdl/types +// --------------------------------------------------------------------------- + +func TestConvertProjectVersion(t *testing.T) { + in := &version.ProjectVersion{ + ProductVersion: "10.18.0", BuildVersion: "1234", + FormatVersion: 42, SchemaHash: "abc123", + MajorVersion: 10, MinorVersion: 18, PatchVersion: 0, + } + out := convertProjectVersion(in) + if out.ProductVersion != "10.18.0" || out.BuildVersion != "1234" || + out.FormatVersion != 42 || out.SchemaHash != "abc123" || + out.MajorVersion != 10 || out.MinorVersion != 18 || out.PatchVersion != 0 { + t.Errorf("field mismatch: %+v", out) + } +} + +func TestConvertProjectVersion_Nil(t *testing.T) { + if convertProjectVersion(nil) != nil { + t.Error("expected nil for nil input") + } +} + +func TestConvertFolderInfoSlice(t *testing.T) { + in := []*mpr.FolderInfo{ + {ID: model.ID("f1"), ContainerID: model.ID("c1"), Name: "Folder1"}, + {ID: model.ID("f2"), ContainerID: model.ID("c2"), Name: "Folder2"}, + } + out, err := convertFolderInfoSlice(in, nil) + if err != nil { + t.Fatal(err) + } + if len(out) != 2 { + t.Fatalf("expected 2, got %d", len(out)) + } + if out[0].ID != "f1" || out[0].Name != "Folder1" { + t.Errorf("field mismatch on [0]: %+v", out[0]) + } + if out[1].ID != "f2" || out[1].ContainerID != "c2" { + t.Errorf("field mismatch on [1]: %+v", out[1]) + } +} + +func TestConvertFolderInfoSlice_ErrorPassthrough(t *testing.T) { + _, err := convertFolderInfoSlice(nil, errTest) + if err != errTest { + t.Errorf("expected errTest, got %v", err) + } +} + +func TestConvertFolderInfoSlice_Nil(t *testing.T) { + out, err := convertFolderInfoSlice(nil, nil) + if err != nil || out != nil { + t.Errorf("expected (nil, nil), got (%v, %v)", out, err) + } +} + +func TestConvertUnitInfoSlice(t *testing.T) { + in := []*mpr.UnitInfo{ + {ID: model.ID("u1"), ContainerID: model.ID("c1"), ContainmentName: "units", Type: "Pages$Page"}, + } + out, err := convertUnitInfoSlice(in, nil) + if err != nil || len(out) != 1 { + t.Fatalf("unexpected: out=%v err=%v", out, err) + } + if out[0].ContainmentName != "units" || out[0].Type != "Pages$Page" { + t.Errorf("field mismatch: %+v", out[0]) + } +} + +func TestConvertRenameHitSlice(t *testing.T) { + in := []mpr.RenameHit{ + {UnitID: "u1", UnitType: "Page", Name: "MyPage", Count: 3}, + } + out, err := convertRenameHitSlice(in, nil) + if err != nil || len(out) != 1 { + t.Fatalf("unexpected: out=%v err=%v", out, err) + } + if out[0].Count != 3 || out[0].Name != "MyPage" { + t.Errorf("field mismatch: %+v", out[0]) + } +} + +func TestConvertRawUnitSlice(t *testing.T) { + in := []*mpr.RawUnit{ + {ID: model.ID("r1"), ContainerID: model.ID("c1"), Type: "Page", Contents: []byte{0x01}}, + } + out, err := convertRawUnitSlice(in, nil) + if err != nil || len(out) != 1 { + t.Fatalf("unexpected: out=%v err=%v", out, err) + } + if len(out[0].Contents) != 1 || out[0].Contents[0] != 0x01 { + t.Errorf("contents mismatch: %+v", out[0]) + } +} + +func TestConvertRawUnitInfoSlice(t *testing.T) { + in := []*mpr.RawUnitInfo{ + {ID: "r1", QualifiedName: "Mod.Page", Type: "Page", ModuleName: "Mod", Contents: []byte{0x02}}, + } + out, err := convertRawUnitInfoSlice(in, nil) + if err != nil || len(out) != 1 { + t.Fatalf("unexpected: out=%v err=%v", out, err) + } + if out[0].QualifiedName != "Mod.Page" || out[0].ModuleName != "Mod" { + t.Errorf("field mismatch: %+v", out[0]) + } +} + +func TestConvertRawUnitInfoPtr(t *testing.T) { + in := &mpr.RawUnitInfo{ID: "r1", QualifiedName: "Q", Type: "T", ModuleName: "M", Contents: []byte{0x03}} + out, err := convertRawUnitInfoPtr(in, nil) + if err != nil || out == nil { + t.Fatalf("unexpected: out=%v err=%v", out, err) + } + if out.ID != "r1" { + t.Errorf("field mismatch: %+v", out) + } +} + +func TestConvertRawCustomWidgetTypePtr(t *testing.T) { + in := &mpr.RawCustomWidgetType{ + WidgetID: "w1", RawType: bson.D{{Key: "k", Value: "v"}}, RawObject: bson.D{{Key: "k2", Value: "v2"}}, + UnitID: "u1", UnitName: "Unit", WidgetName: "Widget", + } + out, err := convertRawCustomWidgetTypePtr(in, nil) + if err != nil || out == nil { + t.Fatalf("unexpected: out=%v err=%v", out, err) + } + if out.WidgetID != "w1" || out.WidgetName != "Widget" { + t.Errorf("field mismatch: %+v", out) + } +} + +func TestConvertRawCustomWidgetTypeSlice(t *testing.T) { + in := []*mpr.RawCustomWidgetType{ + {WidgetID: "w1", UnitName: "U1"}, + {WidgetID: "w2", UnitName: "U2"}, + } + out, err := convertRawCustomWidgetTypeSlice(in, nil) + if err != nil || len(out) != 2 { + t.Fatalf("unexpected: out=%v err=%v", out, err) + } +} + +func TestConvertJavaActionSlice(t *testing.T) { + in := []*mpr.JavaAction{ + {BaseElement: model.BaseElement{ID: model.ID("j1")}, ContainerID: model.ID("c1"), Name: "MyJA", Documentation: "doc"}, + } + out, err := convertJavaActionSlice(in, nil) + if err != nil || len(out) != 1 { + t.Fatalf("unexpected: out=%v err=%v", out, err) + } + if out[0].Name != "MyJA" || out[0].Documentation != "doc" { + t.Errorf("field mismatch: %+v", out[0]) + } +} + +func TestConvertJavaScriptAction_AllFields(t *testing.T) { + in := &mpr.JavaScriptAction{ + BaseElement: model.BaseElement{ID: model.ID("jsa1")}, + ContainerID: model.ID("c1"), + Name: "MyJSA", + Documentation: "doc", + Platform: "web", + Excluded: true, + ExportLevel: "Hidden", + ActionDefaultReturnName: "result", + } + out := convertJavaScriptAction(in) + if out.Name != "MyJSA" || out.Platform != "web" || !out.Excluded || + out.ExportLevel != "Hidden" || out.ActionDefaultReturnName != "result" { + t.Errorf("field mismatch: %+v", out) + } +} + +func TestConvertJavaScriptActionSlice(t *testing.T) { + in := []*mpr.JavaScriptAction{ + {BaseElement: model.BaseElement{ID: model.ID("jsa1")}, Name: "JSA1"}, + } + out, err := convertJavaScriptActionSlice(in, nil) + if err != nil || len(out) != 1 || out[0].Name != "JSA1" { + t.Errorf("unexpected: out=%v err=%v", out, err) + } +} + +func TestConvertJavaScriptActionSlice_ErrorPassthrough(t *testing.T) { + _, err := convertJavaScriptActionSlice(nil, errTest) + if err != errTest { + t.Errorf("expected errTest, got %v", err) + } +} + +func TestConvertJavaScriptActionPtr(t *testing.T) { + in := &mpr.JavaScriptAction{BaseElement: model.BaseElement{ID: model.ID("jsa1")}, Name: "JSA1"} + out, err := convertJavaScriptActionPtr(in, nil) + if err != nil || out == nil || out.Name != "JSA1" { + t.Errorf("unexpected: out=%v err=%v", out, err) + } +} + +func TestConvertJavaScriptActionPtr_ErrorPassthrough(t *testing.T) { + _, err := convertJavaScriptActionPtr(nil, errTest) + if err != errTest { + t.Errorf("expected errTest, got %v", err) + } +} + +func TestConvertNavDoc_FullyPopulated(t *testing.T) { + in := &mpr.NavigationDocument{ + BaseElement: model.BaseElement{ID: model.ID("nd1")}, + ContainerID: model.ID("c1"), + Name: "Navigation", + Profiles: []*mpr.NavigationProfile{ + { + Name: "Responsive", Kind: "Responsive", IsNative: false, + LoginPage: "Login", NotFoundPage: "NotFound", + HomePage: &mpr.NavHomePage{Page: "Home.Page", Microflow: "Home.MF"}, + RoleBasedHomePages: []*mpr.NavRoleBasedHome{ + {UserRole: "Admin", Page: "Admin.Home", Microflow: "Admin.MF"}, + }, + MenuItems: []*mpr.NavMenuItem{ + {Caption: "Top", Page: "P1", ActionType: "OpenPage", Items: []*mpr.NavMenuItem{ + {Caption: "Sub", Microflow: "MF1"}, + }}, + }, + OfflineEntities: []*mpr.NavOfflineEntity{ + {Entity: "Mod.Entity", SyncMode: "FullSync", Constraint: "[Amount > 0]"}, + }, + }, + }, + } + + out := convertNavDoc(in) + + if out.ID != "nd1" || out.Name != "Navigation" || out.ContainerID != "c1" { + t.Errorf("top-level mismatch: %+v", out) + } + if len(out.Profiles) != 1 { + t.Fatalf("expected 1 profile, got %d", len(out.Profiles)) + } + + p := out.Profiles[0] + if p.Name != "Responsive" || p.Kind != "Responsive" || p.IsNative { + t.Errorf("profile basic mismatch: %+v", p) + } + if p.LoginPage != "Login" || p.NotFoundPage != "NotFound" { + t.Errorf("profile page mismatch: login=%q notfound=%q", p.LoginPage, p.NotFoundPage) + } + if p.HomePage == nil || p.HomePage.Page != "Home.Page" || p.HomePage.Microflow != "Home.MF" { + t.Errorf("homepage mismatch: %+v", p.HomePage) + } + if len(p.RoleBasedHomePages) != 1 || p.RoleBasedHomePages[0].UserRole != "Admin" { + t.Errorf("role-based mismatch: %+v", p.RoleBasedHomePages) + } + if len(p.MenuItems) != 1 || p.MenuItems[0].Caption != "Top" { + t.Errorf("menu mismatch: %+v", p.MenuItems) + } + if len(p.MenuItems[0].Items) != 1 || p.MenuItems[0].Items[0].Caption != "Sub" { + t.Errorf("sub-menu mismatch: %+v", p.MenuItems[0].Items) + } + if len(p.OfflineEntities) != 1 || p.OfflineEntities[0].Constraint != "[Amount > 0]" { + t.Errorf("offline entities mismatch: %+v", p.OfflineEntities) + } +} + +func TestConvertNavDocSlice(t *testing.T) { + in := []*mpr.NavigationDocument{ + {BaseElement: model.BaseElement{ID: model.ID("nd1")}, Name: "Nav1"}, + } + out, err := convertNavDocSlice(in, nil) + if err != nil || len(out) != 1 || out[0].Name != "Nav1" { + t.Errorf("unexpected: out=%v err=%v", out, err) + } +} + +func TestConvertNavDocSlice_ErrorPassthrough(t *testing.T) { + _, err := convertNavDocSlice(nil, errTest) + if err != errTest { + t.Errorf("expected errTest, got %v", err) + } +} + +func TestConvertNavDocPtr(t *testing.T) { + in := &mpr.NavigationDocument{BaseElement: model.BaseElement{ID: model.ID("nd1")}, Name: "Nav1"} + out, err := convertNavDocPtr(in, nil) + if err != nil || out == nil || out.Name != "Nav1" { + t.Errorf("unexpected: out=%v err=%v", out, err) + } +} + +func TestConvertNavDocPtr_ErrorPassthrough(t *testing.T) { + _, err := convertNavDocPtr(nil, errTest) + if err != errTest { + t.Errorf("expected errTest, got %v", err) + } +} + +func TestConvertJsonStructure_Recursive(t *testing.T) { + in := &mpr.JsonStructure{ + BaseElement: model.BaseElement{ID: model.ID("js1")}, + ContainerID: model.ID("c1"), + Name: "MyJSON", + Documentation: "doc", + JsonSnippet: `{"a":1}`, + Excluded: true, + ExportLevel: "Hidden", + Elements: []*mpr.JsonElement{ + { + ExposedName: "Root", Path: "(Object)", ElementType: "Object", + PrimitiveType: "Unknown", MinOccurs: 1, MaxOccurs: 1, + Children: []*mpr.JsonElement{ + { + ExposedName: "A", Path: "(Object)|a", ElementType: "Value", + PrimitiveType: "Integer", OriginalValue: "1", + MaxLength: 10, FractionDigits: 2, TotalDigits: 5, + Nillable: true, IsDefaultType: true, + }, + }, + }, + }, + } + + out := convertJsonStructure(in) + + if out.Name != "MyJSON" || out.Documentation != "doc" || out.JsonSnippet != `{"a":1}` || + !out.Excluded || out.ExportLevel != "Hidden" { + t.Errorf("top-level mismatch: %+v", out) + } + if len(out.Elements) != 1 { + t.Fatalf("expected 1 element, got %d", len(out.Elements)) + } + root := out.Elements[0] + if root.ExposedName != "Root" || root.MinOccurs != 1 { + t.Errorf("root mismatch: %+v", root) + } + if len(root.Children) != 1 { + t.Fatalf("expected 1 child, got %d", len(root.Children)) + } + child := root.Children[0] + if child.PrimitiveType != "Integer" || child.OriginalValue != "1" || + child.MaxLength != 10 || child.FractionDigits != 2 || child.TotalDigits != 5 || + !child.Nillable || !child.IsDefaultType { + t.Errorf("child mismatch: %+v", child) + } +} + +func TestConvertJsonStructureSlice(t *testing.T) { + in := []*mpr.JsonStructure{ + {BaseElement: model.BaseElement{ID: model.ID("js1")}, Name: "JS1"}, + } + out, err := convertJsonStructureSlice(in, nil) + if err != nil || len(out) != 1 || out[0].Name != "JS1" { + t.Errorf("unexpected: out=%v err=%v", out, err) + } +} + +func TestConvertJsonStructureSlice_ErrorPassthrough(t *testing.T) { + _, err := convertJsonStructureSlice(nil, errTest) + if err != errTest { + t.Errorf("expected errTest, got %v", err) + } +} + +func TestConvertJsonStructurePtr(t *testing.T) { + in := &mpr.JsonStructure{BaseElement: model.BaseElement{ID: model.ID("js1")}, Name: "JS1"} + out, err := convertJsonStructurePtr(in, nil) + if err != nil || out == nil || out.Name != "JS1" { + t.Errorf("unexpected: out=%v err=%v", out, err) + } +} + +func TestConvertJsonStructurePtr_ErrorPassthrough(t *testing.T) { + _, err := convertJsonStructurePtr(nil, errTest) + if err != errTest { + t.Errorf("expected errTest, got %v", err) + } +} + +func TestConvertImageCollection(t *testing.T) { + in := &mpr.ImageCollection{ + BaseElement: model.BaseElement{ID: model.ID("ic1")}, + ContainerID: model.ID("c1"), + Name: "Images", + ExportLevel: "Public", + Documentation: "img docs", + Images: []mpr.Image{{ID: model.ID("i1"), Name: "logo.png", Data: []byte{0x89, 0x50}, Format: "png"}}, + } + out := convertImageCollection(in) + if out.Name != "Images" || out.ExportLevel != "Public" || out.Documentation != "img docs" { + t.Errorf("top-level mismatch: %+v", out) + } + if len(out.Images) != 1 || out.Images[0].Name != "logo.png" || out.Images[0].Format != "png" { + t.Errorf("image mismatch: %+v", out.Images) + } +} + +func TestConvertImageCollectionSlice(t *testing.T) { + in := []*mpr.ImageCollection{ + {BaseElement: model.BaseElement{ID: model.ID("ic1")}, Name: "IC1"}, + } + out, err := convertImageCollectionSlice(in, nil) + if err != nil || len(out) != 1 || out[0].Name != "IC1" { + t.Errorf("unexpected: out=%v err=%v", out, err) + } +} + +func TestConvertImageCollectionSlice_ErrorPassthrough(t *testing.T) { + _, err := convertImageCollectionSlice(nil, errTest) + if err != errTest { + t.Errorf("expected errTest, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// Unconvert (write-path): mdl/types -> sdk/mpr +// --------------------------------------------------------------------------- + +func TestUnconvertNavProfileSpec_FullyPopulated(t *testing.T) { + in := types.NavigationProfileSpec{ + LoginPage: "Login.Page", + NotFoundPage: "NotFound.Page", + HasMenu: true, + HomePages: []types.NavHomePageSpec{ + {IsPage: true, Target: "Home.Page", ForRole: ""}, + {IsPage: false, Target: "Home.MF", ForRole: "Admin"}, + }, + MenuItems: []types.NavMenuItemSpec{ + {Caption: "Top", Page: "P1", Items: []types.NavMenuItemSpec{ + {Caption: "Sub", Microflow: "MF1"}, + }}, + }, + } + + out := unconvertNavProfileSpec(in) + + if out.LoginPage != "Login.Page" || out.NotFoundPage != "NotFound.Page" || !out.HasMenu { + t.Errorf("top-level mismatch: %+v", out) + } + if len(out.HomePages) != 2 { + t.Fatalf("expected 2 home pages, got %d", len(out.HomePages)) + } + if !out.HomePages[0].IsPage || out.HomePages[0].Target != "Home.Page" { + t.Errorf("homepage[0] mismatch: %+v", out.HomePages[0]) + } + if out.HomePages[1].ForRole != "Admin" { + t.Errorf("homepage[1] mismatch: %+v", out.HomePages[1]) + } + if len(out.MenuItems) != 1 || out.MenuItems[0].Caption != "Top" { + t.Errorf("menu mismatch: %+v", out.MenuItems) + } + if len(out.MenuItems[0].Items) != 1 || out.MenuItems[0].Items[0].Microflow != "MF1" { + t.Errorf("sub-menu mismatch: %+v", out.MenuItems[0].Items) + } +} + +func TestUnconvertNavProfileSpec_NilSlices(t *testing.T) { + in := types.NavigationProfileSpec{LoginPage: "L"} + out := unconvertNavProfileSpec(in) + if out.HomePages != nil || out.MenuItems != nil { + t.Errorf("expected nil slices for nil input: HomePages=%v MenuItems=%v", out.HomePages, out.MenuItems) + } +} + +func TestUnconvertNavMenuItemSpec_Isolated(t *testing.T) { + in := types.NavMenuItemSpec{ + Caption: "Parent", + Page: "Page1", + Microflow: "MF1", + Items: []types.NavMenuItemSpec{ + {Caption: "Child", Microflow: "MF2"}, + }, + } + out := unconvertNavMenuItemSpec(in) + if out.Caption != "Parent" || out.Page != "Page1" || out.Microflow != "MF1" { + t.Errorf("field mismatch: %+v", out) + } + if len(out.Items) != 1 || out.Items[0].Caption != "Child" || out.Items[0].Microflow != "MF2" { + t.Errorf("child mismatch: %+v", out.Items) + } +} + +func TestUnconvertNavMenuItemSpec_NilItems(t *testing.T) { + in := types.NavMenuItemSpec{Caption: "Leaf"} + out := unconvertNavMenuItemSpec(in) + if out.Items != nil { + t.Errorf("expected nil Items for leaf: %+v", out.Items) + } +} + +func TestUnconvertEntityMemberAccessSlice(t *testing.T) { + in := []types.EntityMemberAccess{ + {AttributeRef: "attr1", AssociationRef: "assoc1", AccessRights: "ReadWrite"}, + } + out := unconvertEntityMemberAccessSlice(in) + if len(out) != 1 || out[0].AttributeRef != "attr1" || out[0].AccessRights != "ReadWrite" { + t.Errorf("mismatch: %+v", out) + } +} + +func TestUnconvertEntityMemberAccessSlice_Nil(t *testing.T) { + if unconvertEntityMemberAccessSlice(nil) != nil { + t.Error("expected nil for nil input") + } +} + +func TestUnconvertEntityAccessRevocation(t *testing.T) { + in := types.EntityAccessRevocation{ + RevokeCreate: true, + RevokeDelete: true, + RevokeReadMembers: []string{"attr1"}, + RevokeWriteMembers: []string{"attr2"}, + RevokeReadAll: true, + RevokeWriteAll: false, + } + out := unconvertEntityAccessRevocation(in) + if !out.RevokeCreate || !out.RevokeDelete || !out.RevokeReadAll || out.RevokeWriteAll { + t.Errorf("bool mismatch: %+v", out) + } + if len(out.RevokeReadMembers) != 1 || out.RevokeReadMembers[0] != "attr1" { + t.Errorf("read members mismatch: %+v", out.RevokeReadMembers) + } + if len(out.RevokeWriteMembers) != 1 || out.RevokeWriteMembers[0] != "attr2" { + t.Errorf("write members mismatch: %+v", out.RevokeWriteMembers) + } +} + +func TestUnconvertJsonStructure_Recursive(t *testing.T) { + in := &types.JsonStructure{ + BaseElement: model.BaseElement{ID: model.ID("js1")}, + ContainerID: model.ID("c1"), + Name: "MyJSON", + Documentation: "doc", + JsonSnippet: `{"a":1}`, + Excluded: true, + ExportLevel: "Hidden", + Elements: []*types.JsonElement{ + { + ExposedName: "Root", Path: "(Object)", ElementType: "Object", + Children: []*types.JsonElement{ + {ExposedName: "A", PrimitiveType: "Integer", MaxLength: 10}, + }, + }, + }, + } + + out := unconvertJsonStructure(in) + + if out.Name != "MyJSON" || out.Documentation != "doc" || !out.Excluded { + t.Errorf("top-level mismatch: %+v", out) + } + if len(out.Elements) != 1 || out.Elements[0].ExposedName != "Root" { + t.Fatalf("element mismatch: %+v", out.Elements) + } + if len(out.Elements[0].Children) != 1 || out.Elements[0].Children[0].MaxLength != 10 { + t.Errorf("child mismatch: %+v", out.Elements[0].Children) + } +} + +func TestUnconvertImageCollection(t *testing.T) { + in := &types.ImageCollection{ + BaseElement: model.BaseElement{ID: model.ID("ic1")}, + ContainerID: model.ID("c1"), + Name: "Images", + ExportLevel: "Public", + Documentation: "docs", + Images: []types.Image{{ID: model.ID("i1"), Name: "logo.png", Data: []byte{0x89}, Format: "png"}}, + } + + out := unconvertImageCollection(in) + + if out.Name != "Images" || out.ExportLevel != "Public" { + t.Errorf("top-level mismatch: %+v", out) + } + if len(out.Images) != 1 || out.Images[0].Name != "logo.png" || out.Images[0].Data[0] != 0x89 { + t.Errorf("image mismatch: %+v", out.Images) + } +} + diff --git a/mdl/backend/mpr/page_mutator_test.go b/mdl/backend/mpr/page_mutator_test.go index a7629be5..0be4703f 100644 --- a/mdl/backend/mpr/page_mutator_test.go +++ b/mdl/backend/mpr/page_mutator_test.go @@ -3,6 +3,7 @@ package mprbackend import ( + "strings" "testing" "go.mongodb.org/mongo-driver/bson" @@ -722,3 +723,139 @@ func TestParamScope(t *testing.T) { t.Error("Expected non-empty ID") } } + +// --------------------------------------------------------------------------- +// SetLayout tests +// --------------------------------------------------------------------------- + +// makePageWithLayout builds a minimal page BSON doc with a FormCall pointing +// to the given layout and argument parameters. +func makePageWithLayout(layoutQN string, params ...string) bson.D { + args := bson.A{int32(3)} + for _, p := range params { + args = append(args, bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Pages$FormCallArgument"}, + {Key: "Parameter", Value: layoutQN + "." + p}, + }) + } + return bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Pages$FormCall"}, + {Key: "FormCall", Value: bson.D{ + {Key: "Form", Value: layoutQN}, + {Key: "Arguments", Value: args}, + }}, + } +} + +func makePageMutator(rawData bson.D) *mprPageMutator { + return &mprPageMutator{rawData: rawData, containerType: "page", widgetFinder: findBsonWidget} +} + +func TestSetLayout_Basic(t *testing.T) { + page := makePageWithLayout("MyModule.OldLayout", "Content", "Header") + m := makePageMutator(page) + + if err := m.SetLayout("MyModule.NewLayout", nil); err != nil { + t.Fatalf("SetLayout failed: %v", err) + } + + formCall := dGetDoc(m.rawData, "FormCall") + if got := dGetString(formCall, "Form"); got != "MyModule.NewLayout" { + t.Errorf("Form = %q, want MyModule.NewLayout", got) + } + + // Verify parameters were remapped + args := dGetArrayElements(dGet(formCall, "Arguments")) + for _, a := range args { + aDoc := a.(bson.D) + param := dGetString(aDoc, "Parameter") + if !strings.HasPrefix(param, "MyModule.NewLayout.") { + t.Errorf("Parameter %q should start with MyModule.NewLayout.", param) + } + } +} + +func TestSetLayout_WithParamMappings(t *testing.T) { + page := makePageWithLayout("MyModule.OldLayout", "Content", "Header") + m := makePageMutator(page) + + mappings := map[string]string{ + "Content": "MainArea", + "Header": "TopBar", + } + if err := m.SetLayout("MyModule.NewLayout", mappings); err != nil { + t.Fatalf("SetLayout with mappings failed: %v", err) + } + + formCall := dGetDoc(m.rawData, "FormCall") + args := dGetArrayElements(dGet(formCall, "Arguments")) + paramValues := make(map[string]bool) + for _, a := range args { + aDoc := a.(bson.D) + paramValues[dGetString(aDoc, "Parameter")] = true + } + if !paramValues["MyModule.NewLayout.MainArea"] { + t.Error("Expected MyModule.NewLayout.MainArea in remapped params") + } + if !paramValues["MyModule.NewLayout.TopBar"] { + t.Error("Expected MyModule.NewLayout.TopBar in remapped params") + } +} + +func TestSetLayout_SameLayout_Noop(t *testing.T) { + page := makePageWithLayout("MyModule.SameLayout", "Content") + m := makePageMutator(page) + + if err := m.SetLayout("MyModule.SameLayout", nil); err != nil { + t.Fatalf("SetLayout same layout failed: %v", err) + } + + // Should be a no-op — form unchanged + formCall := dGetDoc(m.rawData, "FormCall") + if got := dGetString(formCall, "Form"); got != "MyModule.SameLayout" { + t.Errorf("Form = %q, want MyModule.SameLayout", got) + } +} + +func TestSetLayout_Snippet_Error(t *testing.T) { + page := makePageWithLayout("MyModule.Layout", "Content") + m := &mprPageMutator{rawData: page, containerType: "snippet", widgetFinder: findBsonWidget} + + err := m.SetLayout("MyModule.NewLayout", nil) + if err == nil { + t.Fatal("Expected error for snippet") + } + if !strings.Contains(err.Error(), "snippet") { + t.Errorf("Error = %q, want to mention snippet", err.Error()) + } +} + +func TestSetLayout_NoFormCall_Error(t *testing.T) { + page := bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Pages$Page"}, + } + m := makePageMutator(page) + + err := m.SetLayout("MyModule.NewLayout", nil) + if err == nil { + t.Fatal("Expected error for missing FormCall") + } +} + +func TestSetLayout_EmptyForm_Error(t *testing.T) { + page := bson.D{ + {Key: "FormCall", Value: bson.D{ + {Key: "Form", Value: ""}, + {Key: "Arguments", Value: bson.A{int32(3)}}, + }}, + } + m := makePageMutator(page) + + err := m.SetLayout("MyModule.NewLayout", nil) + if err == nil { + t.Fatal("Expected error when current layout cannot be determined") + } +} diff --git a/mdl/backend/mpr/workflow_mutator_test.go b/mdl/backend/mpr/workflow_mutator_test.go index f8c1f43b..d821614f 100644 --- a/mdl/backend/mpr/workflow_mutator_test.go +++ b/mdl/backend/mpr/workflow_mutator_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/mendixlabs/mxcli/sdk/workflows" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" ) @@ -481,3 +482,876 @@ func TestWorkflowMutator_BsonArrayMarkerConstant(t *testing.T) { t.Errorf("bsonArrayMarker = %v, want int32(3)", bsonArrayMarker) } } + +// --------------------------------------------------------------------------- +// Helper: create a concrete WorkflowActivity for Insert/Replace tests +// --------------------------------------------------------------------------- + +func makeTestWorkflowActivity(name, caption string) workflows.WorkflowActivity { + return &workflows.UserTask{ + BaseWorkflowActivity: workflows.BaseWorkflowActivity{ + Name: name, + Caption: caption, + }, + } +} + +// makeWfActivityWithOutcomes builds an activity with named outcomes. +func makeWfActivityWithOutcomes(caption, name string, outcomes ...bson.D) bson.D { + arr := bson.A{int32(3)} + for _, o := range outcomes { + arr = append(arr, o) + } + return bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$UserTask"}, + {Key: "Caption", Value: caption}, + {Key: "Name", Value: name}, + {Key: "Outcomes", Value: arr}, + } +} + +func makeOutcome(typeName, value string) bson.D { + d := bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: typeName}, + } + if value != "" { + d = append(d, bson.E{Key: "Value", Value: value}) + } + return d +} + +func makeBoolOutcome(val bool) bson.D { + return bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$BooleanConditionOutcome"}, + {Key: "Value", Value: val}, + } +} + +func makeVoidConditionOutcome() bson.D { + return bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$VoidConditionOutcome"}, + } +} + +// getActivities returns the activity BSON docs from the workflow flow. +func getActivities(doc bson.D) []bson.D { + flow := dGetDoc(doc, "Flow") + if flow == nil { + return nil + } + elems := dGetArrayElements(dGet(flow, "Activities")) + var result []bson.D + for _, e := range elems { + if d, ok := e.(bson.D); ok { + result = append(result, d) + } + } + return result +} + +// --------------------------------------------------------------------------- +// InsertAfterActivity tests +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_InsertAfterActivity(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "First", "task1") + act2 := makeWfActivity("Workflows$UserTask", "Last", "task2") + m := newMutator(makeWorkflowDoc(act1, act2)) + + newAct := makeTestWorkflowActivity("inserted", "Inserted") + if err := m.InsertAfterActivity("First", 0, []workflows.WorkflowActivity{newAct}); err != nil { + t.Fatalf("InsertAfterActivity failed: %v", err) + } + + acts := getActivities(m.rawData) + if len(acts) != 3 { + t.Fatalf("Expected 3 activities, got %d", len(acts)) + } + if got := dGetString(acts[0], "Caption"); got != "First" { + t.Errorf("acts[0].Caption = %q, want First", got) + } + // Inserted activity should be at position 1 + if got := dGetString(acts[1], "Name"); got != "inserted" { + t.Errorf("acts[1].Name = %q, want inserted", got) + } + if got := dGetString(acts[2], "Caption"); got != "Last" { + t.Errorf("acts[2].Caption = %q, want Last", got) + } +} + +func TestWorkflowMutator_InsertAfterActivity_NotFound(t *testing.T) { + m := newMutator(makeWorkflowDoc(makeWfActivity("Workflows$UserTask", "Only", "task1"))) + err := m.InsertAfterActivity("Missing", 0, []workflows.WorkflowActivity{makeTestWorkflowActivity("new", "New")}) + if err == nil { + t.Fatal("Expected error for missing activity") + } +} + +// --------------------------------------------------------------------------- +// ReplaceActivity tests +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_ReplaceActivity(t *testing.T) { + act1 := makeWfActivity("Workflows$UserTask", "First", "task1") + act2 := makeWfActivity("Workflows$UserTask", "ToReplace", "task2") + act3 := makeWfActivity("Workflows$UserTask", "Last", "task3") + m := newMutator(makeWorkflowDoc(act1, act2, act3)) + + repA := makeTestWorkflowActivity("repA", "ReplacementA") + repB := makeTestWorkflowActivity("repB", "ReplacementB") + if err := m.ReplaceActivity("ToReplace", 0, []workflows.WorkflowActivity{repA, repB}); err != nil { + t.Fatalf("ReplaceActivity failed: %v", err) + } + + acts := getActivities(m.rawData) + if len(acts) != 4 { + t.Fatalf("Expected 4 activities, got %d", len(acts)) + } + if got := dGetString(acts[0], "Caption"); got != "First" { + t.Errorf("acts[0] = %q, want First", got) + } + if got := dGetString(acts[1], "Name"); got != "repA" { + t.Errorf("acts[1].Name = %q, want repA", got) + } + if got := dGetString(acts[2], "Name"); got != "repB" { + t.Errorf("acts[2].Name = %q, want repB", got) + } + if got := dGetString(acts[3], "Caption"); got != "Last" { + t.Errorf("acts[3] = %q, want Last", got) + } +} + +func TestWorkflowMutator_ReplaceActivity_NotFound(t *testing.T) { + m := newMutator(makeWorkflowDoc(makeWfActivity("Workflows$UserTask", "Only", "task1"))) + err := m.ReplaceActivity("Missing", 0, []workflows.WorkflowActivity{makeTestWorkflowActivity("new", "New")}) + if err == nil { + t.Fatal("Expected error for missing activity") + } +} + +// --------------------------------------------------------------------------- +// InsertOutcome tests +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_InsertOutcome(t *testing.T) { + act := makeWfActivityWithOutcomes("Review", "task1") + m := newMutator(makeWorkflowDoc(act)) + + if err := m.InsertOutcome("Review", 0, "Approved", nil); err != nil { + t.Fatalf("InsertOutcome failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 outcome, got %d", len(outcomes)) + } + oDoc, ok := outcomes[0].(bson.D) + if !ok { + t.Fatal("Outcome is not bson.D") + } + if got := dGetString(oDoc, "Value"); got != "Approved" { + t.Errorf("Outcome Value = %q, want Approved", got) + } + if got := dGetString(oDoc, "$Type"); got != "Workflows$UserTaskOutcome" { + t.Errorf("Outcome $Type = %q, want Workflows$UserTaskOutcome", got) + } +} + +func TestWorkflowMutator_InsertOutcome_WithActivities(t *testing.T) { + act := makeWfActivityWithOutcomes("Review", "task1") + m := newMutator(makeWorkflowDoc(act)) + + subAct := makeTestWorkflowActivity("sub1", "SubTask") + if err := m.InsertOutcome("Review", 0, "Rejected", []workflows.WorkflowActivity{subAct}); err != nil { + t.Fatalf("InsertOutcome with activities failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 outcome, got %d", len(outcomes)) + } + oDoc := outcomes[0].(bson.D) + flow := dGetDoc(oDoc, "Flow") + if flow == nil { + t.Fatal("Expected Flow on outcome with activities") + } +} + +func TestWorkflowMutator_InsertOutcome_ActivityNotFound(t *testing.T) { + m := newMutator(makeWorkflowDoc(makeWfActivity("Workflows$UserTask", "Only", "task1"))) + err := m.InsertOutcome("Missing", 0, "x", nil) + if err == nil { + t.Fatal("Expected error for missing activity") + } +} + +// --------------------------------------------------------------------------- +// DropOutcome tests (existing test covers NotFound; add success case) +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_DropOutcome_ByValue(t *testing.T) { + outcome1 := makeOutcome("Workflows$UserTaskOutcome", "Approve") + outcome2 := makeOutcome("Workflows$UserTaskOutcome", "Reject") + act := makeWfActivityWithOutcomes("Review", "task1", outcome1, outcome2) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.DropOutcome("Review", 0, "Approve"); err != nil { + t.Fatalf("DropOutcome failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 remaining outcome, got %d", len(outcomes)) + } + oDoc := outcomes[0].(bson.D) + if got := dGetString(oDoc, "Value"); got != "Reject" { + t.Errorf("Remaining outcome = %q, want Reject", got) + } +} + +func TestWorkflowMutator_DropOutcome_Default(t *testing.T) { + voidOutcome := makeVoidConditionOutcome() + namedOutcome := makeOutcome("Workflows$UserTaskOutcome", "Approve") + act := makeWfActivityWithOutcomes("Review", "task1", voidOutcome, namedOutcome) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.DropOutcome("Review", 0, "Default"); err != nil { + t.Fatalf("DropOutcome Default failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 remaining outcome, got %d", len(outcomes)) + } +} + +// --------------------------------------------------------------------------- +// InsertPath tests +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_InsertPath(t *testing.T) { + act := makeWfActivityWithOutcomes("Split", "split1") + act[1] = bson.E{Key: "$Type", Value: "Workflows$ParallelSplitActivity"} + m := newMutator(makeWorkflowDoc(act)) + + if err := m.InsertPath("Split", 0, "", nil); err != nil { + t.Fatalf("InsertPath failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Split", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 path, got %d", len(outcomes)) + } + oDoc := outcomes[0].(bson.D) + if got := dGetString(oDoc, "$Type"); got != "Workflows$ParallelSplitOutcome" { + t.Errorf("Path $Type = %q, want Workflows$ParallelSplitOutcome", got) + } +} + +func TestWorkflowMutator_InsertPath_WithActivities(t *testing.T) { + act := makeWfActivityWithOutcomes("Split", "split1") + act[1] = bson.E{Key: "$Type", Value: "Workflows$ParallelSplitActivity"} + m := newMutator(makeWorkflowDoc(act)) + + subAct := makeTestWorkflowActivity("path_act", "PathAct") + if err := m.InsertPath("Split", 0, "", []workflows.WorkflowActivity{subAct}); err != nil { + t.Fatalf("InsertPath with activities failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Split", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + oDoc := outcomes[0].(bson.D) + flow := dGetDoc(oDoc, "Flow") + if flow == nil { + t.Fatal("Expected Flow on path with activities") + } +} + +// --------------------------------------------------------------------------- +// DropPath tests +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_DropPath_ByCaption(t *testing.T) { + path1 := bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$ParallelSplitOutcome"}, + } + path2 := bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$ParallelSplitOutcome"}, + } + act := makeWfActivityWithOutcomes("Split", "split1", path1, path2) + act[1] = bson.E{Key: "$Type", Value: "Workflows$ParallelSplitActivity"} + m := newMutator(makeWorkflowDoc(act)) + + if err := m.DropPath("Split", 0, "Path 1"); err != nil { + t.Fatalf("DropPath failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Split", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 remaining path, got %d", len(outcomes)) + } +} + +func TestWorkflowMutator_DropPath_EmptyCaption_DropsLast(t *testing.T) { + path1 := bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$ParallelSplitOutcome"}, + {Key: "Tag", Value: "first"}, + } + path2 := bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$ParallelSplitOutcome"}, + {Key: "Tag", Value: "second"}, + } + act := makeWfActivityWithOutcomes("Split", "split1", path1, path2) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.DropPath("Split", 0, ""); err != nil { + t.Fatalf("DropPath empty caption failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Split", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 remaining path, got %d", len(outcomes)) + } + oDoc := outcomes[0].(bson.D) + if got := dGetString(oDoc, "Tag"); got != "first" { + t.Errorf("Remaining path Tag = %q, want first", got) + } +} + +func TestWorkflowMutator_DropPath_NotFound(t *testing.T) { + act := makeWfActivityWithOutcomes("Split", "split1") + m := newMutator(makeWorkflowDoc(act)) + + err := m.DropPath("Split", 0, "Path 99") + if err == nil { + t.Fatal("Expected error for missing path") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("Error = %q, want 'not found'", err.Error()) + } +} + +// --------------------------------------------------------------------------- +// InsertBranch tests +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_InsertBranch_True(t *testing.T) { + act := makeWfActivityWithOutcomes("Decision", "dec1") + act[1] = bson.E{Key: "$Type", Value: "Workflows$ExclusiveSplitActivity"} + m := newMutator(makeWorkflowDoc(act)) + + if err := m.InsertBranch("Decision", 0, "true", nil); err != nil { + t.Fatalf("InsertBranch true failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Decision", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 branch, got %d", len(outcomes)) + } + oDoc := outcomes[0].(bson.D) + if got := dGetString(oDoc, "$Type"); got != "Workflows$BooleanConditionOutcome" { + t.Errorf("Branch $Type = %q, want BooleanConditionOutcome", got) + } + if v, ok := dGet(oDoc, "Value").(bool); !ok || !v { + t.Error("Expected Value=true on boolean branch") + } +} + +func TestWorkflowMutator_InsertBranch_False(t *testing.T) { + act := makeWfActivityWithOutcomes("Decision", "dec1") + m := newMutator(makeWorkflowDoc(act)) + + if err := m.InsertBranch("Decision", 0, "false", nil); err != nil { + t.Fatalf("InsertBranch false failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Decision", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + oDoc := outcomes[0].(bson.D) + if v, ok := dGet(oDoc, "Value").(bool); !ok || v { + t.Error("Expected Value=false on boolean branch") + } +} + +func TestWorkflowMutator_InsertBranch_Default(t *testing.T) { + act := makeWfActivityWithOutcomes("Decision", "dec1") + m := newMutator(makeWorkflowDoc(act)) + + if err := m.InsertBranch("Decision", 0, "default", nil); err != nil { + t.Fatalf("InsertBranch default failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Decision", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + oDoc := outcomes[0].(bson.D) + if got := dGetString(oDoc, "$Type"); got != "Workflows$VoidConditionOutcome" { + t.Errorf("Branch $Type = %q, want VoidConditionOutcome", got) + } +} + +func TestWorkflowMutator_InsertBranch_Enum(t *testing.T) { + act := makeWfActivityWithOutcomes("Decision", "dec1") + m := newMutator(makeWorkflowDoc(act)) + + if err := m.InsertBranch("Decision", 0, "MyModule.Status.Active", nil); err != nil { + t.Fatalf("InsertBranch enum failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Decision", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + oDoc := outcomes[0].(bson.D) + if got := dGetString(oDoc, "$Type"); got != "Workflows$EnumerationValueConditionOutcome" { + t.Errorf("Branch $Type = %q, want EnumerationValueConditionOutcome", got) + } + if got := dGetString(oDoc, "Value"); got != "MyModule.Status.Active" { + t.Errorf("Branch Value = %q, want MyModule.Status.Active", got) + } +} + +func TestWorkflowMutator_InsertBranch_WithActivities(t *testing.T) { + act := makeWfActivityWithOutcomes("Decision", "dec1") + m := newMutator(makeWorkflowDoc(act)) + + subAct := makeTestWorkflowActivity("branch_act", "BranchAct") + if err := m.InsertBranch("Decision", 0, "true", []workflows.WorkflowActivity{subAct}); err != nil { + t.Fatalf("InsertBranch with activities failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Decision", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + oDoc := outcomes[0].(bson.D) + flow := dGetDoc(oDoc, "Flow") + if flow == nil { + t.Fatal("Expected Flow on branch with activities") + } +} + +// --------------------------------------------------------------------------- +// DropBranch tests +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_DropBranch_True(t *testing.T) { + trueOutcome := makeBoolOutcome(true) + falseOutcome := makeBoolOutcome(false) + act := makeWfActivityWithOutcomes("Decision", "dec1", trueOutcome, falseOutcome) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.DropBranch("Decision", 0, "true"); err != nil { + t.Fatalf("DropBranch true failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Decision", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 remaining, got %d", len(outcomes)) + } + oDoc := outcomes[0].(bson.D) + if v, ok := dGet(oDoc, "Value").(bool); !ok || v { + t.Error("Remaining branch should be false") + } +} + +func TestWorkflowMutator_DropBranch_False(t *testing.T) { + trueOutcome := makeBoolOutcome(true) + falseOutcome := makeBoolOutcome(false) + act := makeWfActivityWithOutcomes("Decision", "dec1", trueOutcome, falseOutcome) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.DropBranch("Decision", 0, "false"); err != nil { + t.Fatalf("DropBranch false failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Decision", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 remaining, got %d", len(outcomes)) + } +} + +func TestWorkflowMutator_DropBranch_Default(t *testing.T) { + voidOutcome := makeVoidConditionOutcome() + enumOutcome := makeOutcome("Workflows$EnumerationValueConditionOutcome", "Active") + act := makeWfActivityWithOutcomes("Decision", "dec1", voidOutcome, enumOutcome) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.DropBranch("Decision", 0, "default"); err != nil { + t.Fatalf("DropBranch default failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Decision", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 remaining, got %d", len(outcomes)) + } + oDoc := outcomes[0].(bson.D) + if got := dGetString(oDoc, "Value"); got != "Active" { + t.Errorf("Remaining = %q, want Active", got) + } +} + +func TestWorkflowMutator_DropBranch_Enum(t *testing.T) { + enum1 := makeOutcome("Workflows$EnumerationValueConditionOutcome", "Active") + enum2 := makeOutcome("Workflows$EnumerationValueConditionOutcome", "Inactive") + act := makeWfActivityWithOutcomes("Decision", "dec1", enum1, enum2) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.DropBranch("Decision", 0, "Active"); err != nil { + t.Fatalf("DropBranch enum failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Decision", 0) + outcomes := dGetArrayElements(dGet(actDoc, "Outcomes")) + if len(outcomes) != 1 { + t.Fatalf("Expected 1 remaining, got %d", len(outcomes)) + } + oDoc := outcomes[0].(bson.D) + if got := dGetString(oDoc, "Value"); got != "Inactive" { + t.Errorf("Remaining = %q, want Inactive", got) + } +} + +func TestWorkflowMutator_DropBranch_NotFound(t *testing.T) { + act := makeWfActivityWithOutcomes("Decision", "dec1") + m := newMutator(makeWorkflowDoc(act)) + + err := m.DropBranch("Decision", 0, "Missing") + if err == nil { + t.Fatal("Expected error for missing branch") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("Error = %q, want 'not found'", err.Error()) + } +} + +// --------------------------------------------------------------------------- +// InsertBoundaryEvent tests +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_InsertBoundaryEvent_InterruptingTimer(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.InsertBoundaryEvent("Review", 0, "InterruptingTimer", "PT1H", nil); err != nil { + t.Fatalf("InsertBoundaryEvent failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) + if len(events) != 1 { + t.Fatalf("Expected 1 event, got %d", len(events)) + } + eDoc := events[0].(bson.D) + if got := dGetString(eDoc, "$Type"); got != "Workflows$InterruptingTimerBoundaryEvent" { + t.Errorf("Event $Type = %q, want InterruptingTimerBoundaryEvent", got) + } + if got := dGetString(eDoc, "FirstExecutionTime"); got != "PT1H" { + t.Errorf("FirstExecutionTime = %q, want PT1H", got) + } +} + +func TestWorkflowMutator_InsertBoundaryEvent_NonInterruptingTimer(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.InsertBoundaryEvent("Review", 0, "NonInterruptingTimer", "PT30M", nil); err != nil { + t.Fatalf("InsertBoundaryEvent NonInterrupting failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) + eDoc := events[0].(bson.D) + if got := dGetString(eDoc, "$Type"); got != "Workflows$NonInterruptingTimerBoundaryEvent" { + t.Errorf("Event $Type = %q, want NonInterruptingTimerBoundaryEvent", got) + } + // NonInterrupting should have Recurrence field + found := false + for _, e := range eDoc { + if e.Key == "Recurrence" { + found = true + } + } + if !found { + t.Error("Expected Recurrence field on NonInterruptingTimerBoundaryEvent") + } +} + +func TestWorkflowMutator_InsertBoundaryEvent_WithActivities(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) + m := newMutator(makeWorkflowDoc(act)) + + subAct := makeTestWorkflowActivity("evt_act", "EventAct") + if err := m.InsertBoundaryEvent("Review", 0, "Timer", "", []workflows.WorkflowActivity{subAct}); err != nil { + t.Fatalf("InsertBoundaryEvent with activities failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) + eDoc := events[0].(bson.D) + flow := dGetDoc(eDoc, "Flow") + if flow == nil { + t.Fatal("Expected Flow on boundary event with activities") + } +} + +func TestWorkflowMutator_InsertBoundaryEvent_NoDelay(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "BoundaryEvents", Value: bson.A{int32(3)}}) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.InsertBoundaryEvent("Review", 0, "Timer", "", nil); err != nil { + t.Fatalf("InsertBoundaryEvent no delay failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + events := dGetArrayElements(dGet(actDoc, "BoundaryEvents")) + eDoc := events[0].(bson.D) + for _, e := range eDoc { + if e.Key == "FirstExecutionTime" { + t.Error("FirstExecutionTime should not be present when delay is empty") + } + } +} + +// --------------------------------------------------------------------------- +// SetActivityProperty — additional property types +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_SetActivityProperty_Page_New(t *testing.T) { + // Note: When TaskPage key doesn't pre-exist in BSON, dSet silently fails. + // The key must be present (even as nil) for PAGE to work on a new activity. + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "TaskPage", Value: nil}) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.SetActivityProperty("Review", 0, "PAGE", "MyModule.TaskPage"); err != nil { + t.Fatalf("SetActivityProperty PAGE failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + taskPage := dGetDoc(actDoc, "TaskPage") + if taskPage == nil { + t.Fatal("Expected TaskPage to be set") + } + if got := dGetString(taskPage, "Page"); got != "MyModule.TaskPage" { + t.Errorf("Page = %q, want MyModule.TaskPage", got) + } +} + +func TestWorkflowMutator_SetActivityProperty_Page_MissingKey(t *testing.T) { + // BUG: dSet silently fails when TaskPage key is absent — pageRef is lost. + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + // No TaskPage field at all + m := newMutator(makeWorkflowDoc(act)) + + // No error returned, but the set is silently lost + if err := m.SetActivityProperty("Review", 0, "PAGE", "MyModule.TaskPage"); err != nil { + t.Fatalf("SetActivityProperty PAGE failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + taskPage := dGetDoc(actDoc, "TaskPage") + // This documents the bug: TaskPage is nil because dSet can't create new keys + if taskPage != nil { + t.Log("BUG FIXED: TaskPage is now set even when key was absent") + } +} + +func TestWorkflowMutator_SetActivityProperty_Page_Existing(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "TaskPage", Value: bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$PageReference"}, + {Key: "Page", Value: "OldModule.OldPage"}, + }}) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.SetActivityProperty("Review", 0, "PAGE", "NewModule.NewPage"); err != nil { + t.Fatalf("SetActivityProperty PAGE update failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + taskPage := dGetDoc(actDoc, "TaskPage") + if got := dGetString(taskPage, "Page"); got != "NewModule.NewPage" { + t.Errorf("Page = %q, want NewModule.NewPage", got) + } +} + +func TestWorkflowMutator_SetActivityProperty_Description(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "TaskDescription", Value: bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Text", Value: "old"}, + }}) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.SetActivityProperty("Review", 0, "DESCRIPTION", "new desc"); err != nil { + t.Fatalf("SetActivityProperty DESCRIPTION failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + taskDesc := dGetDoc(actDoc, "TaskDescription") + if got := dGetString(taskDesc, "Text"); got != "new desc" { + t.Errorf("Text = %q, want 'new desc'", got) + } +} + +func TestWorkflowMutator_SetActivityProperty_TargetingMicroflow(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "UserTargeting", Value: nil}) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.SetActivityProperty("Review", 0, "TARGETING_MICROFLOW", "MyModule.AssignReviewer"); err != nil { + t.Fatalf("SetActivityProperty TARGETING_MICROFLOW failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + targeting := dGetDoc(actDoc, "UserTargeting") + if targeting == nil { + t.Fatal("Expected UserTargeting to be set") + } + if got := dGetString(targeting, "$Type"); got != "Workflows$MicroflowUserTargeting" { + t.Errorf("$Type = %q, want MicroflowUserTargeting", got) + } + if got := dGetString(targeting, "Microflow"); got != "MyModule.AssignReviewer" { + t.Errorf("Microflow = %q, want MyModule.AssignReviewer", got) + } +} + +func TestWorkflowMutator_SetActivityProperty_TargetingXPath(t *testing.T) { + act := makeWfActivity("Workflows$UserTask", "Review", "task1") + act = append(act, bson.E{Key: "UserTargeting", Value: nil}) + m := newMutator(makeWorkflowDoc(act)) + + if err := m.SetActivityProperty("Review", 0, "TARGETING_XPATH", "[Role = 'Admin']"); err != nil { + t.Fatalf("SetActivityProperty TARGETING_XPATH failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("Review", 0) + targeting := dGetDoc(actDoc, "UserTargeting") + if targeting == nil { + t.Fatal("Expected UserTargeting to be set") + } + if got := dGetString(targeting, "$Type"); got != "Workflows$XPathUserTargeting" { + t.Errorf("$Type = %q, want XPathUserTargeting", got) + } +} + +// --------------------------------------------------------------------------- +// SetPropertyWithEntity tests +// --------------------------------------------------------------------------- + +func TestWorkflowMutator_SetPropertyWithEntity_OverviewPage(t *testing.T) { + doc := makeWorkflowDoc() + doc = append(doc, bson.E{Key: "AdminPage", Value: nil}) + m := newMutator(doc) + + if err := m.SetPropertyWithEntity("OVERVIEW_PAGE", "MyModule.OverviewPage", ""); err != nil { + t.Fatalf("SetPropertyWithEntity OVERVIEW_PAGE failed: %v", err) + } + + adminPage := dGetDoc(m.rawData, "AdminPage") + if adminPage == nil { + t.Fatal("Expected AdminPage to be set") + } + if got := dGetString(adminPage, "Page"); got != "MyModule.OverviewPage" { + t.Errorf("Page = %q, want MyModule.OverviewPage", got) + } +} + +func TestWorkflowMutator_SetPropertyWithEntity_OverviewPage_Clear(t *testing.T) { + doc := makeWorkflowDoc() + doc = append(doc, bson.E{Key: "AdminPage", Value: bson.D{ + {Key: "Page", Value: "OldPage"}, + }}) + m := newMutator(doc) + + if err := m.SetPropertyWithEntity("OVERVIEW_PAGE", "", ""); err != nil { + t.Fatalf("SetPropertyWithEntity clear failed: %v", err) + } + + if v := dGet(m.rawData, "AdminPage"); v != nil { + t.Error("Expected AdminPage to be nil after clear") + } +} + +func TestWorkflowMutator_SetPropertyWithEntity_Parameter_New(t *testing.T) { + doc := makeWorkflowDoc() + doc = append(doc, bson.E{Key: "Parameter", Value: nil}) + m := newMutator(doc) + + if err := m.SetPropertyWithEntity("PARAMETER", "WorkflowContext", "MyModule.Order"); err != nil { + t.Fatalf("SetPropertyWithEntity PARAMETER failed: %v", err) + } + + param := dGetDoc(m.rawData, "Parameter") + if param == nil { + t.Fatal("Expected Parameter to be set") + } + if got := dGetString(param, "Entity"); got != "MyModule.Order" { + t.Errorf("Entity = %q, want MyModule.Order", got) + } +} + +func TestWorkflowMutator_SetPropertyWithEntity_Parameter_Update(t *testing.T) { + doc := makeWorkflowDoc() + doc = append(doc, bson.E{Key: "Parameter", Value: bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$Parameter"}, + {Key: "Entity", Value: "OldModule.OldEntity"}, + {Key: "Name", Value: "WorkflowContext"}, + }}) + m := newMutator(doc) + + if err := m.SetPropertyWithEntity("PARAMETER", "WorkflowContext", "NewModule.NewEntity"); err != nil { + t.Fatalf("SetPropertyWithEntity PARAMETER update failed: %v", err) + } + + param := dGetDoc(m.rawData, "Parameter") + if got := dGetString(param, "Entity"); got != "NewModule.NewEntity" { + t.Errorf("Entity = %q, want NewModule.NewEntity", got) + } +} + +func TestWorkflowMutator_SetPropertyWithEntity_Parameter_Clear(t *testing.T) { + doc := makeWorkflowDoc() + doc = append(doc, bson.E{Key: "Parameter", Value: bson.D{ + {Key: "Entity", Value: "Something"}, + }}) + m := newMutator(doc) + + if err := m.SetPropertyWithEntity("PARAMETER", "", ""); err != nil { + t.Fatalf("SetPropertyWithEntity PARAMETER clear failed: %v", err) + } + + if v := dGet(m.rawData, "Parameter"); v != nil { + t.Error("Expected Parameter to be nil after clear") + } +} + +func TestWorkflowMutator_SetPropertyWithEntity_Unsupported(t *testing.T) { + m := newMutator(makeWorkflowDoc()) + err := m.SetPropertyWithEntity("INVALID", "x", "y") + if err == nil { + t.Fatal("Expected error for unsupported property") + } +} diff --git a/mdl/executor/widget_engine_test.go b/mdl/executor/widget_engine_test.go index f7be9510..ef70a7ee 100644 --- a/mdl/executor/widget_engine_test.go +++ b/mdl/executor/widget_engine_test.go @@ -131,14 +131,18 @@ func TestWidgetDefinitionJSONOmitsEmptyOptionalFields(t *testing.T) { } func TestKnownOperationsSet(t *testing.T) { + reg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("NewWidgetRegistry() error: %v", err) + } builtinOps := []string{"attribute", "association", "primitive", "selection", "datasource", "widgets", "expression", "texttemplate", "action", "attributeObjects"} for _, name := range builtinOps { - if !knownOperations[name] { + if !reg.knownOperations[name] { t.Errorf("knownOperations[%q] = false, want true", name) } } - if knownOperations["nonexistent"] { + if reg.knownOperations["nonexistent"] { t.Error("knownOperations[\"nonexistent\"] should be false") } } From 0acd412576453bc813ca130f85ab22bec60a21d5 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Mon, 20 Apr 2026 14:05:19 +0200 Subject: [PATCH 3/5] feat: OperationRegistry extensibility and ContainerSnippet constant - Add NewWidgetRegistryWithOps(extraOps) for extending known operations - Move knownOperations from package-level var to WidgetRegistry field to eliminate global mutable state race - Convert validateDefinitionOperations and validateMappings to WidgetRegistry methods - Use backend.ContainerSnippet constant in SetLayout check --- mdl/backend/mpr/page_mutator.go | 2 +- mdl/executor/widget_registry.go | 57 ++++++++++++++++++++-------- mdl/executor/widget_registry_test.go | 49 +++++++++++++++++++++--- 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/mdl/backend/mpr/page_mutator.go b/mdl/backend/mpr/page_mutator.go index 8b19ef36..3c20e85b 100644 --- a/mdl/backend/mpr/page_mutator.go +++ b/mdl/backend/mpr/page_mutator.go @@ -254,7 +254,7 @@ func (m *mprPageMutator) DropVariable(name string) error { } func (m *mprPageMutator) SetLayout(newLayout string, paramMappings map[string]string) error { - if m.containerType == "snippet" { + if m.containerType == backend.ContainerSnippet { return fmt.Errorf("SET Layout is not supported for snippets") } diff --git a/mdl/executor/widget_registry.go b/mdl/executor/widget_registry.go index dcbabfb8..71192c50 100644 --- a/mdl/executor/widget_registry.go +++ b/mdl/executor/widget_registry.go @@ -16,12 +16,13 @@ import ( // WidgetRegistry holds loaded widget definitions keyed by uppercase MDL name. type WidgetRegistry struct { - byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName - byWidgetID map[string]*WidgetDefinition // keyed by widgetId + byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName + byWidgetID map[string]*WidgetDefinition // keyed by widgetId + knownOperations map[string]bool // operations accepted during validation } -// knownOperations is the set of operation names supported by the widget engine. -var knownOperations = map[string]bool{ +// defaultKnownOperations is the set of operation names supported by the widget engine. +var defaultKnownOperations = map[string]bool{ "attribute": true, "association": true, "primitive": true, @@ -34,11 +35,37 @@ var knownOperations = map[string]bool{ "attributeObjects": true, } +// knownOperations is the active set used for validation, initialized from +// defaultKnownOperations and now stored per-registry to avoid global mutable state. + +func copyOps(src map[string]bool) map[string]bool { + dst := make(map[string]bool, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + // NewWidgetRegistry creates a registry pre-loaded with embedded definitions. +// Uses the default set of known operations for validation. func NewWidgetRegistry() (*WidgetRegistry, error) { + return NewWidgetRegistryWithOps(nil) +} + +// NewWidgetRegistryWithOps creates a registry pre-loaded with embedded definitions, +// extending the default known operations with extraOps for validation. +// This allows user-defined widgets to declare custom operations that would otherwise +// fail validation. Pass nil for the default set. +func NewWidgetRegistryWithOps(extraOps map[string]bool) (*WidgetRegistry, error) { + ops := copyOps(defaultKnownOperations) + for op := range extraOps { + ops[op] = true + } + reg := &WidgetRegistry{ - byMDLName: make(map[string]*WidgetDefinition), - byWidgetID: make(map[string]*WidgetDefinition), + byMDLName: make(map[string]*WidgetDefinition), + byWidgetID: make(map[string]*WidgetDefinition), + knownOperations: ops, } entries, err := definitions.EmbeddedFS.ReadDir(".") @@ -61,7 +88,7 @@ func NewWidgetRegistry() (*WidgetRegistry, error) { return nil, mdlerrors.NewBackend(fmt.Sprintf("parse definition %s", entry.Name()), err) } - if err := validateDefinitionOperations(&def, entry.Name()); err != nil { + if err := reg.validateDefinitionOperations(&def, entry.Name()); err != nil { return nil, err } @@ -156,7 +183,7 @@ func (r *WidgetRegistry) loadDefinitionsFromDir(dir string) error { return mdlerrors.NewValidationf("invalid definition %s: widgetId and mdlName are required", entry.Name()) } - if err := validateDefinitionOperations(&def, entry.Name()); err != nil { + if err := r.validateDefinitionOperations(&def, entry.Name()); err != nil { return err } @@ -180,22 +207,22 @@ func (r *WidgetRegistry) loadDefinitionsFromDir(dir string) error { // validateDefinitionOperations checks that all operation names in a definition // are recognized by the known operations set, and validates source/operation // compatibility and mapping order dependencies. -func validateDefinitionOperations(def *WidgetDefinition, source string) error { - if err := validateMappings(def.PropertyMappings, source, ""); err != nil { +func (r *WidgetRegistry) validateDefinitionOperations(def *WidgetDefinition, source string) error { + if err := r.validateMappings(def.PropertyMappings, source, ""); err != nil { return err } for _, s := range def.ChildSlots { - if !knownOperations[s.Operation] { + if !r.knownOperations[s.Operation] { return mdlerrors.NewValidationf("%s: unknown operation %q in childSlots for key %q", source, s.Operation, s.PropertyKey) } } for _, mode := range def.Modes { ctx := fmt.Sprintf("mode %q ", mode.Name) - if err := validateMappings(mode.PropertyMappings, source, ctx); err != nil { + if err := r.validateMappings(mode.PropertyMappings, source, ctx); err != nil { return err } for _, s := range mode.ChildSlots { - if !knownOperations[s.Operation] { + if !r.knownOperations[s.Operation] { return mdlerrors.NewValidationf("%s: unknown operation %q in %schildSlots for key %q", source, s.Operation, ctx, s.PropertyKey) } } @@ -213,10 +240,10 @@ var incompatibleSourceOps = map[string]map[string]bool{ // validateMappings validates a slice of property mappings for operation existence, // source/operation compatibility, and mapping order (Association requires prior DataSource). -func validateMappings(mappings []PropertyMapping, source, modeCtx string) error { +func (r *WidgetRegistry) validateMappings(mappings []PropertyMapping, source, modeCtx string) error { hasDataSource := false for _, m := range mappings { - if !knownOperations[m.Operation] { + if !r.knownOperations[m.Operation] { return mdlerrors.NewValidationf("%s: unknown operation %q in %spropertyMappings for key %q", source, m.Operation, modeCtx, m.PropertyKey) } // Check source/operation compatibility diff --git a/mdl/executor/widget_registry_test.go b/mdl/executor/widget_registry_test.go index b3e8608f..86aa71bc 100644 --- a/mdl/executor/widget_registry_test.go +++ b/mdl/executor/widget_registry_test.go @@ -189,7 +189,41 @@ func TestRegistryLoadUserDefinitions(t *testing.T) { } } +func TestNewWidgetRegistryWithOps_ExtendsKnownOperations(t *testing.T) { + // A definition with a custom operation should fail with default ops + customDef := &WidgetDefinition{ + WidgetID: "com.example.Custom", + MDLName: "CUSTOM", + PropertyMappings: []PropertyMapping{ + {PropertyKey: "prop", Source: "Attribute", Operation: "customOp"}, + }, + } + + // Default registry should reject custom operation + defaultReg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("NewWidgetRegistry() error: %v", err) + } + if err := defaultReg.validateDefinitionOperations(customDef, "custom.def.json"); err == nil { + t.Error("expected error for unknown operation 'customOp' with default ops, got nil") + } + + // Extended registry should accept custom operation + extReg, err := NewWidgetRegistryWithOps(map[string]bool{"customOp": true}) + if err != nil { + t.Fatalf("NewWidgetRegistryWithOps() error: %v", err) + } + if err := extReg.validateDefinitionOperations(customDef, "custom.def.json"); err != nil { + t.Errorf("unexpected error with extended ops: %v", err) + } +} + func TestValidateDefinitionOperations_MappingOrderDependency(t *testing.T) { + reg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("NewWidgetRegistry() error: %v", err) + } + // Association before DataSource should fail validation badDef := &WidgetDefinition{ WidgetID: "com.example.Bad", @@ -199,7 +233,7 @@ func TestValidateDefinitionOperations_MappingOrderDependency(t *testing.T) { {PropertyKey: "dsProp", Source: "DataSource", Operation: "datasource"}, }, } - if err := validateDefinitionOperations(badDef, "bad.def.json"); err == nil { + if err := reg.validateDefinitionOperations(badDef, "bad.def.json"); err == nil { t.Error("expected error for Association before DataSource, got nil") } @@ -212,7 +246,7 @@ func TestValidateDefinitionOperations_MappingOrderDependency(t *testing.T) { {PropertyKey: "assocProp", Source: "Association", Operation: "association"}, }, } - if err := validateDefinitionOperations(goodDef, "good.def.json"); err != nil { + if err := reg.validateDefinitionOperations(goodDef, "good.def.json"); err != nil { t.Errorf("unexpected error for DataSource before Association: %v", err) } @@ -230,12 +264,17 @@ func TestValidateDefinitionOperations_MappingOrderDependency(t *testing.T) { }, }, } - if err := validateDefinitionOperations(modeDef, "mode.def.json"); err == nil { + if err := reg.validateDefinitionOperations(modeDef, "mode.def.json"); err == nil { t.Error("expected error for Association before DataSource in mode, got nil") } } func TestValidateDefinitionOperations_SourceOperationCompatibility(t *testing.T) { + reg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("NewWidgetRegistry() error: %v", err) + } + // Source "Attribute" with Operation "association" should fail badDef := &WidgetDefinition{ WidgetID: "com.example.Bad", @@ -244,7 +283,7 @@ func TestValidateDefinitionOperations_SourceOperationCompatibility(t *testing.T) {PropertyKey: "prop", Source: "Attribute", Operation: "association"}, }, } - if err := validateDefinitionOperations(badDef, "bad.def.json"); err == nil { + if err := reg.validateDefinitionOperations(badDef, "bad.def.json"); err == nil { t.Error("expected error for Source='Attribute' with Operation='association', got nil") } @@ -256,7 +295,7 @@ func TestValidateDefinitionOperations_SourceOperationCompatibility(t *testing.T) {PropertyKey: "prop", Source: "Association", Operation: "attribute"}, }, } - if err := validateDefinitionOperations(badDef2, "bad2.def.json"); err == nil { + if err := reg.validateDefinitionOperations(badDef2, "bad2.def.json"); err == nil { t.Error("expected error for Source='Association' with Operation='attribute', got nil") } } From 10f460d366f9dd0505dc0b4325ece9d14cdc66e8 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Mon, 20 Apr 2026 14:05:26 +0200 Subject: [PATCH 4/5] fix: OVERVIEW_PAGE value bug, datasource ordering, Caption validation - Fix SetPropertyWithEntity for OVERVIEW_PAGE: pass entity value instead of empty string (was clearing AdminPage instead of setting it) - Split OVERVIEW_PAGE and PARAMETER handling in cmd_alter_workflow.go - Reorder Build(): auto-datasource before applyChildSlots so entityContext is available for child widgets - Validate slot.Operation in applyChildSlots (must be "widgets") - setWidgetCaption returns validation error when Caption missing (matching setWidgetContent behavior) - extractBinaryIDFromDoc handles []byte in addition to primitive.Binary --- mdl/executor/bson_helpers.go | 11 +++++++---- mdl/executor/cmd_alter_workflow.go | 12 +++++++++++- mdl/executor/widget_engine.go | 17 +++++++++++------ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/mdl/executor/bson_helpers.go b/mdl/executor/bson_helpers.go index eafed38a..ae8f9cb5 100644 --- a/mdl/executor/bson_helpers.go +++ b/mdl/executor/bson_helpers.go @@ -111,10 +111,14 @@ func dSetArray(doc bson.D, key string, elements []any) { // extractBinaryIDFromDoc extracts a binary ID string from a bson.D field. func extractBinaryIDFromDoc(val any) string { - if bin, ok := val.(primitive.Binary); ok { + switch bin := val.(type) { + case primitive.Binary: return types.BlobToUUID(bin.Data) + case []byte: + return types.BlobToUUID(bin) + default: + return "" } - return "" } // ============================================================================ @@ -352,8 +356,7 @@ func setRawWidgetProperty(widget bson.D, propName string, value interface{}) err func setWidgetCaption(widget bson.D, value interface{}) error { caption := dGetDoc(widget, "Caption") if caption == nil { - setTranslatableText(widget, "Caption", value) - return nil + return mdlerrors.NewValidation("widget has no Caption property") } setTranslatableText(caption, "", value) return nil diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index 4559ad1e..2ba57090 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -62,7 +62,17 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { switch o := op.(type) { case *ast.SetWorkflowPropertyOp: switch o.Property { - case "OVERVIEW_PAGE", "PARAMETER": + case "OVERVIEW_PAGE": + // OVERVIEW_PAGE uses Entity as the page qualified name (Value is unused). + qn := o.Entity.Module + "." + o.Entity.Name + if qn == "." { + qn = "" + } + if err := mutator.SetPropertyWithEntity(o.Property, qn, qn); err != nil { + return mdlerrors.NewBackend("SET "+o.Property, err) + } + case "PARAMETER": + // PARAMETER uses Value as the variable name and Entity as the entity qualified name. qn := o.Entity.Module + "." + o.Entity.Name if qn == "." { qn = "" diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index 5ffd656b..46858e4d 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -129,12 +129,9 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* } } - // 4. Apply child slots (.def.json) - if err := e.applyChildSlots(builder, slots, w, propertyTypeIDs); err != nil { - return nil, err - } - - // 4.1 Auto datasource: map AST DataSource to first DataSource-type property. + // 4. Auto datasource: map AST DataSource to first DataSource-type property. + // This must run before child slots so that entityContext is available + // for child widgets that depend on the parent's data source. dsHandledByMapping := false for _, m := range mappings { if m.Source == "DataSource" { @@ -160,6 +157,11 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* } } + // 4.1 Apply child slots (.def.json) + if err := e.applyChildSlots(builder, slots, w, propertyTypeIDs); err != nil { + return nil, err + } + // 4.3 Auto child slots: match AST children to Widgets-type template properties. handledSlotKeys := make(map[string]bool) for _, s := range slots { @@ -525,6 +527,9 @@ func (e *PluggableWidgetEngine) applyChildSlots(builder backend.WidgetObjectBuil } ctx := &BuildContext{} + if slot.Operation != "widgets" { + return mdlerrors.NewValidationf("childSlots operation must be %q, got %q for property %s", "widgets", slot.Operation, slot.PropertyKey) + } if err := e.applyOperation(builder, slot.Operation, slot.PropertyKey, ctx); err != nil { return err } From a2394be379150afbff8dbd09c106beac9120fe5b Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Mon, 20 Apr 2026 14:46:29 +0200 Subject: [PATCH 5/5] fix: dedup batch bug, sort widgetsPropKeys, mock panic, remove no-op clone call --- mdl/backend/mock/mock_microflow.go | 2 +- mdl/backend/mpr/widget_builder.go | 10 ++-------- mdl/backend/mpr/workflow_mutator.go | 6 +++++- mdl/executor/widget_engine.go | 2 ++ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/mdl/backend/mock/mock_microflow.go b/mdl/backend/mock/mock_microflow.go index 567bf859..3e9d5784 100644 --- a/mdl/backend/mock/mock_microflow.go +++ b/mdl/backend/mock/mock_microflow.go @@ -53,7 +53,7 @@ func (m *MockBackend) ParseMicroflowFromRaw(raw map[string]any, unitID, containe if m.ParseMicroflowFromRawFunc != nil { return m.ParseMicroflowFromRawFunc(raw, unitID, containerID) } - return nil + panic("mock ParseMicroflowFromRaw called but ParseMicroflowFromRawFunc is not set") } func (m *MockBackend) ListNanoflows() ([]*microflows.Nanoflow, error) { diff --git a/mdl/backend/mpr/widget_builder.go b/mdl/backend/mpr/widget_builder.go index f9950c72..7f707c64 100644 --- a/mdl/backend/mpr/widget_builder.go +++ b/mdl/backend/mpr/widget_builder.go @@ -269,14 +269,8 @@ func (ob *mprWidgetObjectBuilder) CloneGallerySelectionProperty(propertyKey stri return } - ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { - // The val here is the WidgetValue — but we need the WidgetProperty level. - // CloneGallerySelectionProperty works on the Properties array directly. - return val - }) - - // Actually need to work at the Properties array level: find the property, - // clone it with new IDs and updated Selection, then append. + // Work at the Properties array level: find the property, clone it with new + // IDs and updated Selection, then append. result := make(bson.D, 0, len(ob.object)) for _, elem := range ob.object { if elem.Key == "Properties" { diff --git a/mdl/backend/mpr/workflow_mutator.go b/mdl/backend/mpr/workflow_mutator.go index 9309e414..1aa4ca4c 100644 --- a/mdl/backend/mpr/workflow_mutator.go +++ b/mdl/backend/mpr/workflow_mutator.go @@ -714,7 +714,11 @@ func collectNamesRecursive(flow bson.D, names map[string]bool) { // deduplicateNewActivityName ensures a new activity name doesn't conflict. func deduplicateNewActivityName(act workflows.WorkflowActivity, existingNames map[string]bool) { name := act.GetName() - if name == "" || !existingNames[name] { + if name == "" { + return + } + if !existingNames[name] { + existingNames[name] = true return } for i := 2; i < 1000; i++ { diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index 46858e4d..ddc76df6 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "sort" "strings" "github.com/mendixlabs/mxcli/mdl/ast" @@ -173,6 +174,7 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* widgetsPropKeys = append(widgetsPropKeys, propKey) } } + sort.Strings(widgetsPropKeys) // Phase 1: Named matching — match children by name against property keys matchedChildren := make(map[int]bool) for _, propKey := range widgetsPropKeys {