From 18ff06807b12d0698422dfafb80ed30ab444f22b Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Sun, 19 Apr 2026 17:43:17 +0200 Subject: [PATCH 1/2] refactor: decouple executor from storage layer, extract remaining BSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace *mpr.Reader/*mpr.Writer with backend.FullBackend throughout executor. Inject BackendFactory to remove mprbackend import from executor_connect.go. Move all remaining write-path BSON construction (DataGrid2, filters, cloning, widget property updates) behind backend interface. - Remove writer/reader fields from Executor struct - Add BackendFactory injection pattern for connect/disconnect - Create mdl/backend/mpr/datagrid_builder.go (1260 lines) - Add BuildDataGrid2Widget, BuildFilterWidget to WidgetBuilderBackend - Delete bson_helpers.go, cmd_pages_builder_input_cloning.go, cmd_pages_builder_input_datagrid.go, cmd_pages_builder_v3_pluggable.go - Remaining BSON: 3 read-only files (describe, diff) — WASM-safe --- cmd/mxcli/main.go | 3 + examples/create_datagrid2_page/main.go | 3 + mdl/backend/mock/backend.go | 2 + mdl/backend/mock/mock_mutation.go | 20 +- mdl/backend/mpr/backend.go | 10 + mdl/backend/mpr/convert.go | 32 +- mdl/backend/mpr/convert_roundtrip_test.go | 55 +- mdl/backend/mpr/datagrid_builder.go | 1260 +++++++++++++++++ .../mpr/datagrid_builder_test.go} | 10 +- mdl/backend/mpr/page_mutator.go | 13 +- mdl/backend/mutation.go | 36 + mdl/executor/bson_helpers.go | 484 ------- mdl/executor/cmd_alter_page.go | 4 +- mdl/executor/cmd_catalog.go | 8 +- mdl/executor/cmd_diff_local.go | 3 + mdl/executor/cmd_export_mappings.go | 19 +- mdl/executor/cmd_features.go | 2 +- mdl/executor/cmd_import_mappings.go | 19 +- mdl/executor/cmd_lint.go | 2 +- mdl/executor/cmd_microflows_builder.go | 4 +- mdl/executor/cmd_microflows_create.go | 2 +- mdl/executor/cmd_pages_builder.go | 32 +- mdl/executor/cmd_pages_builder_input.go | 183 +-- .../cmd_pages_builder_input_cloning.go | 272 ---- .../cmd_pages_builder_input_datagrid.go | 1133 --------------- .../cmd_pages_builder_input_filters.go | 91 -- mdl/executor/cmd_pages_builder_v3.go | 10 +- .../cmd_pages_builder_v3_pluggable.go | 316 ----- mdl/executor/cmd_pages_builder_v3_widgets.go | 199 +-- mdl/executor/cmd_pages_create_v3.go | 6 +- mdl/executor/cmd_widgets.go | 98 +- mdl/executor/executor.go | 70 +- mdl/executor/executor_connect.go | 54 +- mdl/executor/hierarchy.go | 57 +- mdl/executor/widget_property.go | 254 +--- mdl/repl/repl.go | 6 +- sdk/mpr/writer_navigation.go | 23 +- 37 files changed, 1615 insertions(+), 3180 deletions(-) create mode 100644 mdl/backend/mpr/datagrid_builder.go rename mdl/{executor/cmd_pages_builder_input_cloning_test.go => backend/mpr/datagrid_builder_test.go} (91%) delete mode 100644 mdl/executor/bson_helpers.go delete mode 100644 mdl/executor/cmd_pages_builder_input_cloning.go delete mode 100644 mdl/executor/cmd_pages_builder_input_datagrid.go delete mode 100644 mdl/executor/cmd_pages_builder_v3_pluggable.go diff --git a/cmd/mxcli/main.go b/cmd/mxcli/main.go index 6b1949e4..2ec38505 100644 --- a/cmd/mxcli/main.go +++ b/cmd/mxcli/main.go @@ -9,6 +9,8 @@ import ( "os" "strings" + "github.com/mendixlabs/mxcli/mdl/backend" + mprbackend "github.com/mendixlabs/mxcli/mdl/backend/mpr" "github.com/mendixlabs/mxcli/mdl/diaglog" "github.com/mendixlabs/mxcli/mdl/executor" "github.com/mendixlabs/mxcli/mdl/repl" @@ -194,6 +196,7 @@ func resolveFormat(cmd *cobra.Command, defaultFormat string) string { func newLoggedExecutor(mode string) (*executor.Executor, *diaglog.Logger) { logger := diaglog.Init(version, mode) exec := executor.New(os.Stdout) + exec.SetBackendFactory(func() backend.FullBackend { return mprbackend.New() }) exec.SetLogger(logger) if globalJSONFlag { exec.SetFormat(executor.FormatJSON) diff --git a/examples/create_datagrid2_page/main.go b/examples/create_datagrid2_page/main.go index aa7a0b2c..705c7c3d 100644 --- a/examples/create_datagrid2_page/main.go +++ b/examples/create_datagrid2_page/main.go @@ -22,6 +22,8 @@ import ( "os" "strings" + "github.com/mendixlabs/mxcli/mdl/backend" + mprbackend "github.com/mendixlabs/mxcli/mdl/backend/mpr" "github.com/mendixlabs/mxcli/mdl/executor" "github.com/mendixlabs/mxcli/mdl/visitor" ) @@ -51,6 +53,7 @@ func main() { // Create the MDL executor with stdout for output exec := executor.New(os.Stdout) + exec.SetBackendFactory(func() backend.FullBackend { return mprbackend.New() }) // Define the MDL script to create a page with DataGrid2 // Note: Adjust module name, entity name, and attributes to match your project diff --git a/mdl/backend/mock/backend.go b/mdl/backend/mock/backend.go index 01a4f1b9..0b32c931 100644 --- a/mdl/backend/mock/backend.go +++ b/mdl/backend/mock/backend.go @@ -276,6 +276,8 @@ type MockBackend struct { SerializeWidgetToOpaqueFunc func(w pages.Widget) any SerializeDataSourceToOpaqueFunc func(ds pages.DataSource) any BuildCreateAttributeObjectFunc func(attributePath string, objectTypeID, propertyTypeID, valueTypeID string) (any, error) + BuildDataGrid2WidgetFunc func(id model.ID, name string, spec backend.DataGridSpec, projectPath string) (*pages.CustomWidget, error) + BuildFilterWidgetFunc func(spec backend.FilterWidgetSpec, projectPath string) (pages.Widget, error) // AgentEditorBackend ListAgentEditorModelsFunc func() ([]*agenteditor.Model, error) diff --git a/mdl/backend/mock/mock_mutation.go b/mdl/backend/mock/mock_mutation.go index 82fabe96..59b90728 100644 --- a/mdl/backend/mock/mock_mutation.go +++ b/mdl/backend/mock/mock_mutation.go @@ -3,6 +3,8 @@ package mock import ( + "fmt" + "github.com/mendixlabs/mxcli/mdl/backend" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/pages" @@ -17,7 +19,7 @@ func (m *MockBackend) OpenPageForMutation(unitID model.ID) (backend.PageMutator, if m.OpenPageForMutationFunc != nil { return m.OpenPageForMutationFunc(unitID) } - return nil, nil + return nil, fmt.Errorf("MockBackend.OpenPageForMutation not configured") } // --------------------------------------------------------------------------- @@ -28,7 +30,7 @@ func (m *MockBackend) OpenWorkflowForMutation(unitID model.ID) (backend.Workflow if m.OpenWorkflowForMutationFunc != nil { return m.OpenWorkflowForMutationFunc(unitID) } - return nil, nil + return nil, fmt.Errorf("MockBackend.OpenWorkflowForMutation not configured") } // --------------------------------------------------------------------------- @@ -94,3 +96,17 @@ func (m *MockBackend) BuildCreateAttributeObject(attributePath string, objectTyp } return nil, nil } + +func (m *MockBackend) BuildDataGrid2Widget(id model.ID, name string, spec backend.DataGridSpec, projectPath string) (*pages.CustomWidget, error) { + if m.BuildDataGrid2WidgetFunc != nil { + return m.BuildDataGrid2WidgetFunc(id, name, spec, projectPath) + } + return nil, fmt.Errorf("MockBackend.BuildDataGrid2Widget not configured") +} + +func (m *MockBackend) BuildFilterWidget(spec backend.FilterWidgetSpec, projectPath string) (pages.Widget, error) { + if m.BuildFilterWidgetFunc != nil { + return m.BuildFilterWidgetFunc(spec, projectPath) + } + return nil, fmt.Errorf("MockBackend.BuildFilterWidget not configured") +} diff --git a/mdl/backend/mpr/backend.go b/mdl/backend/mpr/backend.go index 6831ae9e..da20193b 100644 --- a/mdl/backend/mpr/backend.go +++ b/mdl/backend/mpr/backend.go @@ -34,6 +34,11 @@ type MprBackend struct { path string } +// New creates a new unconnected MprBackend. Call Connect(path) to open a project. +func New() *MprBackend { + return &MprBackend{} +} + // Wrap creates an MprBackend that wraps an existing Writer (and its Reader). // This is used during migration when the Executor already owns the Writer // and we want to expose it through the Backend interface without opening @@ -75,6 +80,11 @@ func (b *MprBackend) Disconnect() error { func (b *MprBackend) IsConnected() bool { return b.writer != nil } func (b *MprBackend) Path() string { return b.path } +// MprReader returns the underlying *mpr.Reader for callers that still +// require direct SDK access (e.g. linter rules). Prefer Backend methods +// for new code. +func (b *MprBackend) MprReader() *mpr.Reader { return b.reader } + func (b *MprBackend) Version() types.MPRVersion { return convertMPRVersion(b.reader.Version()) } func (b *MprBackend) ProjectVersion() *types.ProjectVersion { return convertProjectVersion(b.reader.ProjectVersion()) } func (b *MprBackend) GetMendixVersion() (string, error) { return b.reader.GetMendixVersion() } diff --git a/mdl/backend/mpr/convert.go b/mdl/backend/mpr/convert.go index 96c7766d..472b213e 100644 --- a/mdl/backend/mpr/convert.go +++ b/mdl/backend/mpr/convert.go @@ -263,36 +263,10 @@ func convertNavMenuItem(in *mpr.NavMenuItem) *types.NavMenuItem { // Conversion helpers: mdl/types -> sdk/mpr (for write methods) // --------------------------------------------------------------------------- +// unconvertNavProfileSpec is now a pass-through since mpr.NavigationProfileSpec +// is aliased to types.NavigationProfileSpec. func unconvertNavProfileSpec(s types.NavigationProfileSpec) mpr.NavigationProfileSpec { - out := mpr.NavigationProfileSpec{ - LoginPage: s.LoginPage, - NotFoundPage: s.NotFoundPage, - HasMenu: s.HasMenu, - } - if s.HomePages != nil { - out.HomePages = make([]mpr.NavHomePageSpec, len(s.HomePages)) - for i, hp := range s.HomePages { - out.HomePages[i] = mpr.NavHomePageSpec{IsPage: hp.IsPage, Target: hp.Target, ForRole: hp.ForRole} - } - } - if s.MenuItems != nil { - out.MenuItems = make([]mpr.NavMenuItemSpec, len(s.MenuItems)) - for i, mi := range s.MenuItems { - out.MenuItems[i] = unconvertNavMenuItemSpec(mi) - } - } - return out -} - -func unconvertNavMenuItemSpec(in types.NavMenuItemSpec) mpr.NavMenuItemSpec { - out := mpr.NavMenuItemSpec{Caption: in.Caption, Page: in.Page, Microflow: in.Microflow} - if in.Items != nil { - out.Items = make([]mpr.NavMenuItemSpec, len(in.Items)) - for i, sub := range in.Items { - out.Items[i] = unconvertNavMenuItemSpec(sub) - } - } - return out + return s } func unconvertEntityMemberAccessSlice(in []types.EntityMemberAccess) []mpr.EntityMemberAccess { diff --git a/mdl/backend/mpr/convert_roundtrip_test.go b/mdl/backend/mpr/convert_roundtrip_test.go index 10295264..a10391fc 100644 --- a/mdl/backend/mpr/convert_roundtrip_test.go +++ b/mdl/backend/mpr/convert_roundtrip_test.go @@ -7,6 +7,7 @@ package mprbackend import ( "errors" + "reflect" "testing" "github.com/mendixlabs/mxcli/mdl/types" @@ -492,7 +493,9 @@ func TestUnconvertNavMenuItemSpec_Isolated(t *testing.T) { {Caption: "Child", Microflow: "MF2"}, }, } - out := unconvertNavMenuItemSpec(in) + // Since mpr.NavMenuItemSpec is aliased to types.NavMenuItemSpec, + // unconvert is now a pass-through. Verify the alias holds. + var out mpr.NavMenuItemSpec = in if out.Caption != "Parent" || out.Page != "Page1" || out.Microflow != "MF1" { t.Errorf("field mismatch: %+v", out) } @@ -503,7 +506,7 @@ func TestUnconvertNavMenuItemSpec_Isolated(t *testing.T) { func TestUnconvertNavMenuItemSpec_NilItems(t *testing.T) { in := types.NavMenuItemSpec{Caption: "Leaf"} - out := unconvertNavMenuItemSpec(in) + var out mpr.NavMenuItemSpec = in if out.Items != nil { t.Errorf("expected nil Items for leaf: %+v", out.Items) } @@ -598,3 +601,51 @@ func TestUnconvertImageCollection(t *testing.T) { } } +// ============================================================================ +// Field-count drift assertions +// ============================================================================ +// +// These tests catch silent field drift: if a struct gains a new field but +// the convert/unconvert function is not updated, the test fails. + +func assertFieldCount(t *testing.T, name string, v any, expected int) { + t.Helper() + actual := reflect.TypeOf(v).NumField() + if actual != expected { + t.Errorf("%s field count changed: expected %d, got %d — update convert.go and this test", name, expected, actual) + } +} + +func TestFieldCountDrift(t *testing.T) { + // mpr → types pairs (manually copied in convert.go). + // If a struct gains a field, update the convert function AND this count. + assertFieldCount(t, "mpr.FolderInfo", mpr.FolderInfo{}, 3) + assertFieldCount(t, "types.FolderInfo", types.FolderInfo{}, 3) + assertFieldCount(t, "mpr.UnitInfo", mpr.UnitInfo{}, 4) + assertFieldCount(t, "types.UnitInfo", types.UnitInfo{}, 4) + assertFieldCount(t, "mpr.RenameHit", mpr.RenameHit{}, 4) + assertFieldCount(t, "types.RenameHit", types.RenameHit{}, 4) + assertFieldCount(t, "mpr.RawUnit", mpr.RawUnit{}, 4) + assertFieldCount(t, "types.RawUnit", types.RawUnit{}, 4) + assertFieldCount(t, "mpr.RawUnitInfo", mpr.RawUnitInfo{}, 5) + assertFieldCount(t, "types.RawUnitInfo", types.RawUnitInfo{}, 5) + assertFieldCount(t, "mpr.RawCustomWidgetType", mpr.RawCustomWidgetType{}, 6) + assertFieldCount(t, "types.RawCustomWidgetType", types.RawCustomWidgetType{}, 6) + assertFieldCount(t, "mpr.JavaAction", mpr.JavaAction{}, 4) + assertFieldCount(t, "types.JavaAction", types.JavaAction{}, 4) + assertFieldCount(t, "mpr.JavaScriptAction", mpr.JavaScriptAction{}, 12) + assertFieldCount(t, "types.JavaScriptAction", types.JavaScriptAction{}, 12) + assertFieldCount(t, "mpr.NavigationDocument", mpr.NavigationDocument{}, 4) + assertFieldCount(t, "types.NavigationDocument", types.NavigationDocument{}, 4) + assertFieldCount(t, "mpr.JsonStructure", mpr.JsonStructure{}, 8) + assertFieldCount(t, "types.JsonStructure", types.JsonStructure{}, 8) + assertFieldCount(t, "mpr.JsonElement", mpr.JsonElement{}, 14) + assertFieldCount(t, "types.JsonElement", types.JsonElement{}, 14) + assertFieldCount(t, "mpr.ImageCollection", mpr.ImageCollection{}, 6) + assertFieldCount(t, "types.ImageCollection", types.ImageCollection{}, 6) + assertFieldCount(t, "mpr.EntityMemberAccess", mpr.EntityMemberAccess{}, 3) + assertFieldCount(t, "types.EntityMemberAccess", types.EntityMemberAccess{}, 3) + assertFieldCount(t, "mpr.EntityAccessRevocation", mpr.EntityAccessRevocation{}, 6) + assertFieldCount(t, "types.EntityAccessRevocation", types.EntityAccessRevocation{}, 6) +} + diff --git a/mdl/backend/mpr/datagrid_builder.go b/mdl/backend/mpr/datagrid_builder.go new file mode 100644 index 00000000..511ff9fa --- /dev/null +++ b/mdl/backend/mpr/datagrid_builder.go @@ -0,0 +1,1260 @@ +// 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" + 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" +) + +// BuildDataGrid2Widget builds a complete DataGrid2 CustomWidget from domain-typed inputs. +func (b *MprBackend) BuildDataGrid2Widget(id model.ID, name string, spec backend.DataGridSpec, projectPath string) (*pages.CustomWidget, error) { + // Load embedded template + embeddedType, embeddedObject, embeddedIDs, embeddedObjectTypeID, err := + widgets.GetTemplateFullBSON(pages.WidgetIDDataGrid2, types.GenerateID, projectPath) + if err != nil { + return nil, mdlerrors.NewBackend("load DataGrid2 template", err) + } + if embeddedType == nil || embeddedObject == nil { + return nil, mdlerrors.NewNotFound("widget template", "DataGrid2") + } + + propertyTypeIDs := convertPropertyTypeIDs(embeddedIDs) + + // Build the object + var updatedObject bson.D + if len(spec.Columns) > 0 || len(spec.HeaderWidgets) > 0 { + updatedObject = b.updateDataGrid2Object(embeddedObject, propertyTypeIDs, spec) + } else { + updatedObject = b.cloneDataGrid2ObjectWithDatasourceOnly(embeddedObject, propertyTypeIDs, spec.DataSource) + } + + // Apply paging overrides + if len(spec.PagingOverrides) > 0 { + updatedObject = b.applyDataGridPagingProps(updatedObject, propertyTypeIDs, spec.PagingOverrides) + } + + // Apply selection mode + if spec.SelectionMode != "" { + updatedObject = b.applyDataGridSelectionProp(updatedObject, propertyTypeIDs, spec.SelectionMode) + } + + grid := &pages.CustomWidget{ + BaseWidget: pages.BaseWidget{ + BaseElement: model.BaseElement{ + ID: id, + TypeName: "CustomWidgets$CustomWidget", + }, + Name: name, + }, + Editable: "Always", + RawType: embeddedType, + RawObject: updatedObject, + PropertyTypeIDMap: propertyTypeIDs, + ObjectTypeID: embeddedObjectTypeID, + } + + return grid, nil +} + +// BuildFilterWidget builds a filter widget for DataGrid2. +func (b *MprBackend) BuildFilterWidget(spec backend.FilterWidgetSpec, projectPath string) (pages.Widget, error) { + bsonD := b.buildFilterWidgetBSON(spec.WidgetID, spec.FilterName, projectPath) + + // Wrap the BSON in a CustomWidget + w := &pages.CustomWidget{ + BaseWidget: pages.BaseWidget{ + BaseElement: model.BaseElement{ + ID: model.ID(types.GenerateID()), + TypeName: "CustomWidgets$CustomWidget", + }, + Name: spec.FilterName, + }, + Editable: "Inherited", + RawObject: getBsonField(bsonD, "Object"), + RawType: getBsonField(bsonD, "Type"), + } + return w, nil +} + +// =========================================================================== +// DataGrid2 BSON construction (moved from executor) +// =========================================================================== + +func (b *MprBackend) updateDataGrid2Object(templateObject bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, spec backend.DataGridSpec) bson.D { + result := make(bson.D, 0, len(templateObject)) + + for _, elem := range templateObject { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Properties" { + if propsArr, ok := elem.Value.(bson.A); ok { + updatedProps := b.updateDataGrid2Properties(propsArr, propertyTypeIDs, spec) + result = append(result, bson.E{Key: "Properties", Value: updatedProps}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + + return result +} + +func (b *MprBackend) updateDataGrid2Properties(props bson.A, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, spec backend.DataGridSpec) bson.A { + result := bson.A{int32(2)} + + datasourceEntry := propertyTypeIDs["datasource"] + columnsEntry := propertyTypeIDs["columns"] + filtersPlaceholderEntry := propertyTypeIDs["filtersPlaceholder"] + + // Serialize header widgets to BSON + var headerWidgetsBSON []bson.D + for _, w := range spec.HeaderWidgets { + headerWidgetsBSON = append(headerWidgetsBSON, mpr.SerializeWidget(w)) + } + + for _, propVal := range props { + if _, ok := propVal.(int32); ok { + continue + } + + propMap, ok := propVal.(bson.D) + if !ok { + continue + } + + typePointer := getTypePointerFromProperty(propMap) + + if typePointer == datasourceEntry.PropertyTypeID { + result = append(result, buildDataGrid2Property(datasourceEntry, spec.DataSource, "", "", b)) + } else if typePointer == columnsEntry.PropertyTypeID { + result = append(result, b.cloneAndUpdateColumnsProperty(propMap, columnsEntry, propertyTypeIDs, spec.Columns)) + } else if typePointer == filtersPlaceholderEntry.PropertyTypeID && len(headerWidgetsBSON) > 0 { + result = append(result, buildFiltersPlaceholderProperty(filtersPlaceholderEntry, headerWidgetsBSON)) + } else { + result = append(result, clonePropertyWithNewIDs(propMap)) + } + } + + return result +} + +func (b *MprBackend) cloneAndUpdateColumnsProperty(templateProp bson.D, columnsEntry pages.PropertyTypeIDEntry, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, columns []backend.DataGridColumnSpec) bson.D { + // Extract template column object + var templateColumnObj bson.D + for _, elem := range templateProp { + if elem.Key == "Value" { + if valMap, ok := elem.Value.(bson.D); ok { + for _, ve := range valMap { + if ve.Key == "Objects" { + if objArr, ok := ve.Value.(bson.A); ok { + for _, obj := range objArr { + if colObj, ok := obj.(bson.D); ok { + templateColumnObj = colObj + break + } + } + } + } + } + } + } + } + + columnObjects := bson.A{int32(2)} + for i := range columns { + col := &columns[i] + if templateColumnObj != nil { + columnObjects = append(columnObjects, b.cloneAndUpdateColumnObject(templateColumnObj, col, columnsEntry.NestedPropertyIDs)) + } else { + columnObjects = append(columnObjects, b.buildDataGrid2ColumnObject(col, columnsEntry.ObjectTypeID, columnsEntry.NestedPropertyIDs)) + } + } + + result := make(bson.D, 0, len(templateProp)) + for _, elem := range templateProp { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Value" { + if valMap, ok := elem.Value.(bson.D); ok { + newVal := make(bson.D, 0, len(valMap)) + for _, ve := range valMap { + if ve.Key == "$ID" { + newVal = append(newVal, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if ve.Key == "Objects" { + newVal = append(newVal, bson.E{Key: "Objects", Value: columnObjects}) + } else if ve.Key == "Action" { + if actionMap, ok := ve.Value.(bson.D); ok { + newVal = append(newVal, bson.E{Key: "Action", Value: deepCloneWithNewIDs(actionMap)}) + } else { + newVal = append(newVal, ve) + } + } else { + newVal = append(newVal, ve) + } + } + result = append(result, bson.E{Key: "Value", Value: newVal}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + + return result +} + +func (b *MprBackend) cloneAndUpdateColumnObject(templateCol bson.D, col *backend.DataGridColumnSpec, columnPropertyIDs map[string]pages.PropertyTypeIDEntry) bson.D { + attrPath := col.Attribute + caption := col.Caption + if caption == "" { + caption = col.Attribute + } + + // Serialize child widgets to BSON + var contentWidgets []bson.D + for _, child := range col.ChildWidgets { + contentWidgets = append(contentWidgets, mpr.SerializeWidget(child)) + } + + result := make(bson.D, 0, len(templateCol)) + for _, elem := range templateCol { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Properties" { + if propsArr, ok := elem.Value.(bson.A); ok { + result = append(result, bson.E{Key: "Properties", Value: b.cloneAndUpdateColumnProperties(propsArr, columnPropertyIDs, col, attrPath, caption, contentWidgets)}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + return result +} + +func (b *MprBackend) cloneAndUpdateColumnProperties(templateProps bson.A, columnPropertyIDs map[string]pages.PropertyTypeIDEntry, col *backend.DataGridColumnSpec, attrPath, caption string, contentWidgets []bson.D) bson.A { + result := bson.A{int32(2)} + + addedProps := make(map[string]bool) + hasCustomContent := len(contentWidgets) > 0 + + for _, propVal := range templateProps { + if _, ok := propVal.(int32); ok { + continue + } + propMap, ok := propVal.(bson.D) + if !ok { + continue + } + + typePointer := getTypePointerFromProperty(propMap) + + propKey := "" + for key, entry := range columnPropertyIDs { + if entry.PropertyTypeID == typePointer { + addedProps[key] = true + propKey = key + break + } + } + + switch propKey { + case "showContentAs": + if hasCustomContent { + result = append(result, clonePropertyWithPrimitiveValue(propMap, "customContent")) + } else { + result = append(result, clonePropertyWithNewIDs(propMap)) + } + case "attribute": + if attrPath != "" { + entry := columnPropertyIDs["attribute"] + result = append(result, buildColumnAttributeProperty(entry, attrPath)) + } else { + result = append(result, clonePropertyWithNewIDs(propMap)) + } + case "header": + entry := columnPropertyIDs["header"] + result = append(result, buildColumnHeaderProperty(entry, caption)) + case "content": + if hasCustomContent { + entry := columnPropertyIDs["content"] + result = append(result, buildColumnContentProperty(entry, contentWidgets)) + } else { + result = append(result, clonePropertyWithNewIDs(propMap)) + } + case "visible": + visExpr := "true" + if col.Properties != nil { + if v, ok := col.Properties["Visible"]; ok { + if sv, isStr := v.(string); isStr && sv != "" { + visExpr = sv + } + } + } + result = append(result, clonePropertyWithExpression(propMap, visExpr)) + + case "columnClass": + classExpr := "" + if col.Properties != nil { + if v, ok := col.Properties["DynamicCellClass"]; ok { + if sv, isStr := v.(string); isStr { + classExpr = sv + } + } + } + result = append(result, clonePropertyWithExpression(propMap, classExpr)) + + case "tooltip": + if hasCustomContent { + result = append(result, clonePropertyClearingTextTemplate(propMap)) + } else { + tooltipText := "" + if col.Properties != nil { + if v, ok := col.Properties["Tooltip"]; ok { + if sv, isStr := v.(string); isStr { + tooltipText = sv + } + } + } + if tooltipText != "" { + entry := columnPropertyIDs["tooltip"] + result = append(result, buildColumnHeaderProperty(entry, tooltipText)) + } else { + result = append(result, clonePropertyWithNewIDs(propMap)) + } + } + case "exportValue": + if hasCustomContent { + entry := columnPropertyIDs["exportValue"] + result = append(result, buildColumnHeaderProperty(entry, "")) + } else { + result = append(result, clonePropertyWithNewIDs(propMap)) + } + case "allowEventPropagation": + result = append(result, clonePropertyWithNewIDs(propMap)) + + case "sortable": + defaultSortable := "false" + if attrPath != "" { + defaultSortable = "true" + } + sortVal := colPropBool(col.Properties, "Sortable", defaultSortable) + result = append(result, clonePropertyWithPrimitiveValue(propMap, sortVal)) + + case "resizable": + resVal := colPropBool(col.Properties, "Resizable", "true") + result = append(result, clonePropertyWithPrimitiveValue(propMap, resVal)) + + case "draggable": + dragVal := colPropBool(col.Properties, "Draggable", "true") + result = append(result, clonePropertyWithPrimitiveValue(propMap, dragVal)) + + case "hidable": + hidVal := colPropString(col.Properties, "Hidable", "yes") + result = append(result, clonePropertyWithPrimitiveValue(propMap, hidVal)) + + case "width": + widthVal := colPropString(col.Properties, "ColumnWidth", "autoFill") + result = append(result, clonePropertyWithPrimitiveValue(propMap, widthVal)) + + case "size": + sizeVal := colPropInt(col.Properties, "Size", "1") + result = append(result, clonePropertyWithPrimitiveValue(propMap, sizeVal)) + + case "wrapText": + wrapVal := "false" + if col.Properties != nil { + if v, ok := col.Properties["WrapText"]; ok { + if bv, isBool := v.(bool); isBool && bv { + wrapVal = "true" + } else if sv, isStr := v.(string); isStr { + wrapVal = strings.ToLower(sv) + } + } + } + result = append(result, clonePropertyWithPrimitiveValue(propMap, wrapVal)) + + case "alignment": + alignVal := "left" + if col.Properties != nil { + if v, ok := col.Properties["Alignment"]; ok { + if sv, isStr := v.(string); isStr && sv != "" { + alignVal = strings.ToLower(sv) + } + } + } + result = append(result, clonePropertyWithPrimitiveValue(propMap, alignVal)) + + default: + result = append(result, clonePropertyWithNewIDs(propMap)) + } + } + + // Add required properties that were missing from template + if !addedProps["visible"] { + if visibleEntry, ok := columnPropertyIDs["visible"]; ok { + visExpr := "true" + if col.Properties != nil { + if v, ok := col.Properties["Visible"]; ok { + if sv, isStr := v.(string); isStr && sv != "" { + visExpr = sv + } + } + } + result = append(result, buildColumnExpressionProperty(visibleEntry, visExpr)) + } + } + + return result +} + +func (b *MprBackend) buildDataGrid2ColumnObject(col *backend.DataGridColumnSpec, columnObjectTypeID string, columnPropertyIDs map[string]pages.PropertyTypeIDEntry) bson.D { + attrPath := col.Attribute + + // Serialize child widgets to BSON + var contentWidgets []bson.D + for _, child := range col.ChildWidgets { + contentWidgets = append(contentWidgets, mpr.SerializeWidget(child)) + } + hasCustomContent := len(contentWidgets) > 0 + + properties := bson.A{int32(2)} + + for key, entry := range columnPropertyIDs { + switch key { + case "showContentAs": + if hasCustomContent { + properties = append(properties, buildColumnPrimitiveProperty(entry, "customContent")) + } else { + properties = append(properties, buildColumnPrimitiveProperty(entry, "attribute")) + } + + case "attribute": + if attrPath != "" { + properties = append(properties, buildColumnAttributeProperty(entry, attrPath)) + } else { + properties = append(properties, buildColumnDefaultProperty(entry)) + } + + case "header": + if col.Caption != "" { + properties = append(properties, buildColumnHeaderProperty(entry, col.Caption)) + } else { + properties = append(properties, buildColumnHeaderProperty(entry, col.Attribute)) + } + + case "content": + if hasCustomContent { + properties = append(properties, buildColumnContentProperty(entry, contentWidgets)) + } else { + properties = append(properties, buildColumnContentProperty(entry, nil)) + } + + case "filter": + properties = append(properties, buildColumnContentProperty(entry, nil)) + + case "visible": + visExpr := "true" + if col.Properties != nil { + if v, ok := col.Properties["Visible"]; ok { + if sv, isStr := v.(string); isStr && sv != "" { + visExpr = sv + } + } + } + properties = append(properties, buildColumnExpressionProperty(entry, visExpr)) + + case "columnClass": + classExpr := "" + if col.Properties != nil { + if v, ok := col.Properties["DynamicCellClass"]; ok { + if sv, isStr := v.(string); isStr { + classExpr = sv + } + } + } + properties = append(properties, buildColumnExpressionProperty(entry, classExpr)) + + case "sortable": + defaultSortable := "false" + if attrPath != "" { + defaultSortable = "true" + } + sortVal := colPropBool(col.Properties, "Sortable", defaultSortable) + properties = append(properties, buildColumnPrimitiveProperty(entry, sortVal)) + + case "resizable": + resVal := colPropBool(col.Properties, "Resizable", "true") + properties = append(properties, buildColumnPrimitiveProperty(entry, resVal)) + + case "draggable": + dragVal := colPropBool(col.Properties, "Draggable", "true") + properties = append(properties, buildColumnPrimitiveProperty(entry, dragVal)) + + case "wrapText": + wrapVal := colPropBool(col.Properties, "WrapText", "false") + properties = append(properties, buildColumnPrimitiveProperty(entry, wrapVal)) + + case "alignment": + alignVal := colPropString(col.Properties, "Alignment", "left") + properties = append(properties, buildColumnPrimitiveProperty(entry, alignVal)) + + case "width": + widthVal := colPropString(col.Properties, "ColumnWidth", "autoFill") + properties = append(properties, buildColumnPrimitiveProperty(entry, widthVal)) + + case "minWidth": + properties = append(properties, buildColumnPrimitiveProperty(entry, "auto")) + + case "size": + sizeVal := colPropInt(col.Properties, "Size", "1") + properties = append(properties, buildColumnPrimitiveProperty(entry, sizeVal)) + + case "hidable": + hidVal := colPropString(col.Properties, "Hidable", "yes") + properties = append(properties, buildColumnPrimitiveProperty(entry, hidVal)) + + case "tooltip": + if hasCustomContent { + properties = append(properties, buildColumnDefaultProperty(entry)) + } else { + tooltipText := "" + if col.Properties != nil { + if v, ok := col.Properties["Tooltip"]; ok { + if sv, isStr := v.(string); isStr { + tooltipText = sv + } + } + } + if tooltipText != "" { + properties = append(properties, buildColumnHeaderProperty(entry, tooltipText)) + } else { + properties = append(properties, buildColumnDefaultProperty(entry)) + } + } + + default: + switch entry.ValueType { + case "Expression": + properties = append(properties, buildColumnExpressionProperty(entry, "")) + default: + properties = append(properties, buildColumnDefaultProperty(entry)) + } + } + } + + var typePointer any + if columnObjectTypeID != "" { + typePointer = bsonutil.IDToBsonBinary(columnObjectTypeID) + } + + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, + {Key: "Properties", Value: properties}, + {Key: "TypePointer", Value: typePointer}, + } +} + +func (b *MprBackend) cloneDataGrid2ObjectWithDatasourceOnly(templateObject bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, datasource pages.DataSource) bson.D { + result := make(bson.D, 0, len(templateObject)) + + for _, elem := range templateObject { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Properties" { + if propsArr, ok := elem.Value.(bson.A); ok { + updatedProps := b.updateOnlyDatasource(propsArr, propertyTypeIDs, datasource) + result = append(result, bson.E{Key: "Properties", Value: updatedProps}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + + return result +} + +func (b *MprBackend) updateOnlyDatasource(props bson.A, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, datasource pages.DataSource) bson.A { + result := bson.A{int32(2)} + datasourceEntry := propertyTypeIDs["datasource"] + + for _, propVal := range props { + if _, ok := propVal.(int32); ok { + continue + } + propMap, ok := propVal.(bson.D) + if !ok { + continue + } + + typePointer := getTypePointerFromProperty(propMap) + if typePointer == datasourceEntry.PropertyTypeID { + result = append(result, buildDataGrid2Property(datasourceEntry, datasource, "", "", b)) + } else { + result = append(result, clonePropertyWithNewIDs(propMap)) + } + } + + return result +} + +// applyDataGridPagingProps applies paging property overrides to a DataGrid2 BSON object. +func (b *MprBackend) applyDataGridPagingProps(obj bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, overrides map[string]string) bson.D { + if len(overrides) == 0 { + return obj + } + + typePointerToKey := make(map[string]string) + for widgetKey, entry := range propertyTypeIDs { + typePointerToKey[entry.PropertyTypeID] = widgetKey + } + + result := make(bson.D, 0, len(obj)) + for _, elem := range obj { + if elem.Key == "Properties" { + if propsArr, ok := elem.Value.(bson.A); ok { + updatedProps := bson.A{propsArr[0]} + for _, propVal := range propsArr[1:] { + propMap, ok := propVal.(bson.D) + if !ok { + updatedProps = append(updatedProps, propVal) + continue + } + tp := getTypePointerFromProperty(propMap) + widgetKey := typePointerToKey[tp] + if newVal, hasOverride := overrides[widgetKey]; hasOverride { + updatedProps = append(updatedProps, clonePropertyWithPrimitiveValue(propMap, newVal)) + } else { + updatedProps = append(updatedProps, propMap) + } + } + result = append(result, bson.E{Key: "Properties", Value: updatedProps}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + return result +} + +// applyDataGridSelectionProp applies the Selection mode to a DataGrid2 object. +func (b *MprBackend) applyDataGridSelectionProp(obj bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, selectionMode string) bson.D { + itemSelectionEntry, ok := propertyTypeIDs["itemSelection"] + if !ok { + return obj + } + + result := make(bson.D, 0, len(obj)) + for _, elem := range obj { + if elem.Key == "Properties" { + if propsArr, ok := elem.Value.(bson.A); ok { + updatedProps := bson.A{propsArr[0]} + for _, propVal := range propsArr[1:] { + propMap, ok := propVal.(bson.D) + if !ok { + updatedProps = append(updatedProps, propVal) + continue + } + tp := getTypePointerFromProperty(propMap) + if tp == itemSelectionEntry.PropertyTypeID { + updatedProps = append(updatedProps, buildGallerySelectionProperty(propMap, selectionMode)) + } else { + updatedProps = append(updatedProps, propMap) + } + } + result = append(result, bson.E{Key: "Properties", Value: updatedProps}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + return result +} + +// =========================================================================== +// BSON property builders (package-level, no receiver needed) +// =========================================================================== + +func buildDataGrid2Property(entry pages.PropertyTypeIDEntry, datasource pages.DataSource, attrRef string, primitiveValue string, _ *MprBackend) bson.D { + var datasourceBSON any + if datasource != nil { + datasourceBSON = mpr.SerializeCustomWidgetDataSource(datasource) + } + + var attrRefBSON any + if attrRef != "" { + attrRefBSON = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$AttributeRef"}, + {Key: "Attribute", Value: attrRef}, + {Key: "EntityRef", Value: nil}, + } + } + + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, + {Key: "Value", Value: buildDefaultWidgetValueBSON(entry, datasourceBSON, attrRefBSON, primitiveValue, nil, nil)}, + } +} + +func buildFiltersPlaceholderProperty(entry pages.PropertyTypeIDEntry, widgetsBSON []bson.D) bson.D { + widgetsArray := bson.A{int32(2)} + for _, w := range widgetsBSON { + widgetsArray = append(widgetsArray, w) + } + + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, + {Key: "Value", Value: buildDefaultWidgetValueBSON(entry, nil, nil, "", nil, widgetsArray)}, + } +} + +func buildColumnPrimitiveProperty(entry pages.PropertyTypeIDEntry, value string) bson.D { + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, + {Key: "Value", Value: buildDefaultWidgetValueBSON(entry, nil, nil, value, nil, nil)}, + } +} + +func buildColumnExpressionProperty(entry pages.PropertyTypeIDEntry, expression string) bson.D { + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, + {Key: "Value", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, + {Key: "Action", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$NoAction"}, + {Key: "DisabledDuringExecution", Value: true}, + }}, + {Key: "AttributeRef", Value: nil}, + {Key: "DataSource", Value: nil}, + {Key: "EntityRef", Value: nil}, + {Key: "Expression", Value: expression}, + {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: ""}, + {Key: "Selection", Value: "None"}, + {Key: "SourceVariable", Value: nil}, + {Key: "TextTemplate", Value: nil}, + {Key: "TranslatableValue", Value: nil}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, + {Key: "Widgets", Value: bson.A{int32(2)}}, + {Key: "XPathConstraint", Value: ""}, + }}, + } +} + +func buildColumnAttributeProperty(entry pages.PropertyTypeIDEntry, attrPath string) bson.D { + var attributeRef any + if strings.Count(attrPath, ".") >= 2 { + attributeRef = bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "DomainModels$AttributeRef"}, + {Key: "Attribute", Value: attrPath}, + {Key: "EntityRef", Value: nil}, + } + } + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, + {Key: "Value", Value: buildDefaultWidgetValueBSON(entry, nil, attributeRef, "", nil, nil)}, + } +} + +func buildColumnHeaderProperty(entry pages.PropertyTypeIDEntry, caption string) bson.D { + textTemplate := buildClientTemplateWithText(caption) + + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, + {Key: "Value", Value: buildDefaultWidgetValueBSON(entry, nil, nil, "", textTemplate, nil)}, + } +} + +func buildColumnContentProperty(entry pages.PropertyTypeIDEntry, widgetsList any) bson.D { + widgetsArray := bson.A{int32(2)} + switch w := widgetsList.(type) { + case bson.D: + if w != nil { + widgetsArray = append(widgetsArray, w) + } + case []bson.D: + for _, widget := range w { + widgetsArray = append(widgetsArray, widget) + } + } + + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, + {Key: "Value", Value: buildDefaultWidgetValueBSON(entry, nil, nil, "", nil, widgetsArray)}, + } +} + +func buildColumnDefaultProperty(entry pages.PropertyTypeIDEntry) bson.D { + var textTemplate any + if entry.ValueType == "TextTemplate" { + textTemplate = buildEmptyClientTemplate() + } + + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, + {Key: "Value", Value: buildDefaultWidgetValueBSON(entry, nil, nil, entry.DefaultValue, textTemplate, nil)}, + } +} + +// buildDefaultWidgetValueBSON builds a WidgetValue BSON with the given overrides. +// nil values use defaults. +func buildDefaultWidgetValueBSON(entry pages.PropertyTypeIDEntry, datasourceBSON any, attrRefBSON any, primitiveValue string, textTemplate any, widgetsArray bson.A) bson.D { + if widgetsArray == nil { + widgetsArray = bson.A{int32(2)} + } + + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, + {Key: "Action", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$NoAction"}, + {Key: "DisabledDuringExecution", Value: true}, + }}, + {Key: "AttributeRef", Value: attrRefBSON}, + {Key: "DataSource", Value: datasourceBSON}, + {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: bson.A{int32(2)}}, + {Key: "PrimitiveValue", Value: primitiveValue}, + {Key: "Selection", Value: "None"}, + {Key: "SourceVariable", Value: nil}, + {Key: "TextTemplate", Value: textTemplate}, + {Key: "TranslatableValue", Value: nil}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, + {Key: "Widgets", Value: widgetsArray}, + {Key: "XPathConstraint", Value: ""}, + } +} + +func buildClientTemplateWithText(text string) bson.D { + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$ClientTemplate"}, + {Key: "Fallback", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Items", Value: bson.A{int32(3)}}, + }}, + {Key: "Parameters", Value: bson.A{int32(2)}}, + {Key: "Template", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Items", Value: bson.A{ + int32(3), + bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Translation"}, + {Key: "LanguageCode", Value: "en_US"}, + {Key: "Text", Value: text}, + }, + }}, + }}, + } +} + +func buildEmptyClientTemplate() bson.D { + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$ClientTemplate"}, + {Key: "Fallback", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Items", Value: bson.A{int32(3)}}, + }}, + {Key: "Parameters", Value: bson.A{int32(2)}}, + {Key: "Template", Value: bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Texts$Text"}, + {Key: "Items", Value: bson.A{int32(3)}}, + }}, + } +} + +// =========================================================================== +// Cloning helpers (package-level) +// =========================================================================== + +func getTypePointerFromProperty(prop bson.D) string { + for _, elem := range prop { + if elem.Key == "TypePointer" { + switch v := elem.Value.(type) { + case primitive.Binary: + return bsonutil.BsonBinaryToID(v) + case []byte: + return types.BlobToUUID(v) + } + } + } + return "" +} + +func clonePropertyWithNewIDs(prop bson.D) bson.D { + result := make(bson.D, 0, len(prop)) + for _, elem := range prop { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Value" { + if valMap, ok := elem.Value.(bson.D); ok { + result = append(result, bson.E{Key: "Value", Value: deepCloneWithNewIDs(valMap)}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + return result +} + +func clonePropertyWithPrimitiveValue(prop bson.D, newValue string) bson.D { + result := make(bson.D, 0, len(prop)) + for _, elem := range prop { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Value" { + if valMap, ok := elem.Value.(bson.D); ok { + result = append(result, bson.E{Key: "Value", Value: cloneValueWithUpdatedPrimitive(valMap, newValue)}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + return result +} + +func cloneValueWithUpdatedPrimitive(val bson.D, newValue string) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "PrimitiveValue" { + result = append(result, bson.E{Key: "PrimitiveValue", Value: newValue}) + } else { + result = append(result, bson.E{Key: elem.Key, Value: deepCloneValue(elem.Value)}) + } + } + return result +} + +func clonePropertyWithExpression(prop bson.D, newExpr string) bson.D { + result := make(bson.D, 0, len(prop)) + for _, elem := range prop { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Value" { + if valMap, ok := elem.Value.(bson.D); ok { + result = append(result, bson.E{Key: "Value", Value: cloneValueWithUpdatedExpression(valMap, newExpr)}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + return result +} + +func cloneValueWithUpdatedExpression(val bson.D, newExpr string) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Expression" { + result = append(result, bson.E{Key: "Expression", Value: newExpr}) + } else { + result = append(result, bson.E{Key: elem.Key, Value: deepCloneValue(elem.Value)}) + } + } + return result +} + +func clonePropertyClearingTextTemplate(prop bson.D) bson.D { + result := make(bson.D, 0, len(prop)) + for _, elem := range prop { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "Value" { + if valMap, ok := elem.Value.(bson.D); ok { + result = append(result, bson.E{Key: "Value", Value: cloneValueClearingTextTemplate(valMap)}) + } else { + result = append(result, elem) + } + } else { + result = append(result, elem) + } + } + return result +} + +func cloneValueClearingTextTemplate(val bson.D) bson.D { + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else if elem.Key == "TextTemplate" { + result = append(result, bson.E{Key: "TextTemplate", Value: nil}) + } else { + result = append(result, bson.E{Key: elem.Key, Value: deepCloneValue(elem.Value)}) + } + } + return result +} + +// =========================================================================== +// Deep cloning +// =========================================================================== + +func deepCloneWithNewIDs(doc bson.D) bson.D { + result := make(bson.D, 0, len(doc)) + for _, elem := range doc { + if elem.Key == "$ID" { + result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) + } else { + result = append(result, bson.E{Key: elem.Key, Value: deepCloneValue(elem.Value)}) + } + } + return result +} + +func deepCloneValue(v any) any { + switch val := v.(type) { + case bson.D: + return deepCloneWithNewIDs(val) + case bson.A: + return deepCloneArray(val) + case []any: + return deepCloneSlice(val) + default: + return v + } +} + +func deepCloneArray(arr bson.A) bson.A { + result := make(bson.A, len(arr)) + for i, elem := range arr { + result[i] = deepCloneValue(elem) + } + return result +} + +func deepCloneSlice(arr []any) []any { + result := make([]any, len(arr)) + for i, elem := range arr { + result[i] = deepCloneValue(elem) + } + return result +} + +// =========================================================================== +// Column property helpers (domain logic — moved from executor) +// =========================================================================== + +func colPropBool(props map[string]any, key string, defaultVal string) string { + if props == nil { + return defaultVal + } + v, ok := props[key] + if !ok { + return defaultVal + } + switch bv := v.(type) { + case bool: + if bv { + return "true" + } + return "false" + case string: + lower := strings.ToLower(bv) + if lower == "true" || lower == "false" { + return lower + } + return defaultVal + default: + return defaultVal + } +} + +func colPropString(props map[string]any, key string, defaultVal string) string { + if props == nil { + return defaultVal + } + v, ok := props[key] + if !ok { + return defaultVal + } + if sv, isStr := v.(string); isStr && sv != "" { + return strings.ToLower(sv) + } + return defaultVal +} + +func colPropInt(props map[string]any, key string, defaultVal string) string { + if props == nil { + return defaultVal + } + v, ok := props[key] + if !ok { + return defaultVal + } + switch n := v.(type) { + case int: + return fmt.Sprintf("%d", n) + case int64: + return fmt.Sprintf("%d", n) + case float64: + return fmt.Sprintf("%d", int(n)) + case string: + if n != "" { + return n + } + return defaultVal + default: + return defaultVal + } +} + +// =========================================================================== +// Filter widget BSON construction +// =========================================================================== + +func (b *MprBackend) buildFilterWidgetBSON(widgetID, filterName string, projectPath string) bson.D { + rawType, rawObject, _, _, err := widgets.GetTemplateFullBSON(widgetID, types.GenerateID, projectPath) + if err != nil || rawType == nil { + return b.buildMinimalFilterWidgetBSON(widgetID, filterName) + } + + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Editable", Value: "Inherited"}, + {Key: "Name", Value: filterName}, + {Key: "Object", Value: rawObject}, + {Key: "TabIndex", Value: int32(0)}, + {Key: "Type", Value: rawType}, + } +} + +func (b *MprBackend) buildMinimalFilterWidgetBSON(widgetID, filterName string) bson.D { + typeID := types.GenerateID() + objectTypeID := types.GenerateID() + objectID := types.GenerateID() + + var widgetTypeName string + switch widgetID { + case pages.WidgetIDDataGridTextFilter: + widgetTypeName = "Text filter" + case pages.WidgetIDDataGridNumberFilter: + widgetTypeName = "Number filter" + case pages.WidgetIDDataGridDateFilter: + widgetTypeName = "Date filter" + case pages.WidgetIDDataGridDropdownFilter: + widgetTypeName = "Drop-down filter" + default: + widgetTypeName = "Text filter" + } + + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Editable", Value: "Inherited"}, + {Key: "Name", Value: filterName}, + {Key: "Object", Value: bson.D{ + {Key: "$ID", Value: bsonutil.IDToBsonBinary(objectID)}, + {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, + {Key: "Properties", Value: bson.A{int32(2)}}, + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(objectTypeID)}, + }}, + {Key: "TabIndex", Value: int32(0)}, + {Key: "Type", Value: bson.D{ + {Key: "$ID", Value: bsonutil.IDToBsonBinary(typeID)}, + {Key: "$Type", Value: "CustomWidgets$CustomWidgetType"}, + {Key: "HelpUrl", Value: ""}, + {Key: "ObjectType", Value: bson.D{ + {Key: "$ID", Value: bsonutil.IDToBsonBinary(objectTypeID)}, + {Key: "$Type", Value: "CustomWidgets$WidgetObjectType"}, + {Key: "PropertyTypes", Value: bson.A{int32(2)}}, + }}, + {Key: "OfflineCapable", Value: true}, + {Key: "StudioCategory", Value: "Data Controls"}, + {Key: "StudioProCategory", Value: "Data controls"}, + {Key: "SupportedPlatform", Value: "Web"}, + {Key: "WidgetDescription", Value: ""}, + {Key: "WidgetId", Value: widgetID}, + {Key: "WidgetName", Value: widgetTypeName}, + {Key: "WidgetNeedsEntityContext", Value: false}, + {Key: "WidgetPluginWidget", Value: true}, + }}, + } +} + +// =========================================================================== +// BSON field helpers +// =========================================================================== + +func getBsonField(d bson.D, key string) bson.D { + for _, elem := range d { + if elem.Key == key { + if nested, ok := elem.Value.(bson.D); ok { + return nested + } + } + } + return nil +} diff --git a/mdl/executor/cmd_pages_builder_input_cloning_test.go b/mdl/backend/mpr/datagrid_builder_test.go similarity index 91% rename from mdl/executor/cmd_pages_builder_input_cloning_test.go rename to mdl/backend/mpr/datagrid_builder_test.go index 0e11bf8b..b6ac2c43 100644 --- a/mdl/executor/cmd_pages_builder_input_cloning_test.go +++ b/mdl/backend/mpr/datagrid_builder_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -package executor +package mprbackend import ( "testing" @@ -33,7 +33,6 @@ func TestDeepCloneWithNewIDs_RegeneratesAllIDs(t *testing.T) { cloned := deepCloneWithNewIDs(doc) - // Verify structure is preserved if dGetString(cloned, "$Type") != "Forms$TextBox" { t.Error("$Type not preserved") } @@ -41,13 +40,11 @@ func TestDeepCloneWithNewIDs_RegeneratesAllIDs(t *testing.T) { t.Error("Name not preserved") } - // Verify top-level $ID was regenerated clonedID1 := cloned[0].Value if binaryEqual(clonedID1, origID1) { t.Error("top-level $ID was not regenerated") } - // Verify nested AttributeRef $ID was regenerated attrRef := dGetDoc(cloned, "AttributeRef") if attrRef == nil { t.Fatal("AttributeRef missing") @@ -60,7 +57,6 @@ func TestDeepCloneWithNewIDs_RegeneratesAllIDs(t *testing.T) { t.Error("Attribute value not preserved") } - // Verify deeply nested EntityRef $ID was regenerated entityRef := dGetDoc(attrRef, "EntityRef") if entityRef == nil { t.Fatal("EntityRef missing") @@ -92,7 +88,6 @@ func TestDeepCloneWithNewIDs_HandlesArrays(t *testing.T) { cloned := deepCloneWithNewIDs(doc) - // Check array item's $ID was regenerated items := cloned[1].Value.(bson.A) if len(items) != 2 { t.Fatalf("expected 2 items, got %d", len(items)) @@ -124,7 +119,8 @@ func TestDeepCloneWithNewIDs_PreservesNil(t *testing.T) { } } -// binaryEqual compares two BSON binary values. +// Test helpers + func binaryEqual(a, b any) bool { ab, aOk := a.(primitive.Binary) bb, bOk := b.(primitive.Binary) diff --git a/mdl/backend/mpr/page_mutator.go b/mdl/backend/mpr/page_mutator.go index 3c20e85b..c89a7058 100644 --- a/mdl/backend/mpr/page_mutator.go +++ b/mdl/backend/mpr/page_mutator.go @@ -9,6 +9,8 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/backend" "github.com/mendixlabs/mxcli/mdl/bsonutil" "github.com/mendixlabs/mxcli/mdl/types" @@ -580,10 +582,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 "" } // --------------------------------------------------------------------------- @@ -1305,8 +1311,7 @@ func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { func setWidgetCaptionMut(widget bson.D, value any) 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/backend/mutation.go b/mdl/backend/mutation.go index d8c812b5..5a6986d5 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -279,6 +279,32 @@ type WidgetObjectBuilder interface { Finalize(id model.ID, name string, label string, editable string) *pages.CustomWidget } +// DataGridColumnSpec carries pre-resolved column data for DataGrid2 construction. +// All attribute paths are fully qualified. Child widgets are already built as +// domain objects; the backend serializes them to storage format internally. +type DataGridColumnSpec struct { + Attribute string // Fully qualified attribute path (empty for action/custom-content columns) + Caption string // Column header caption + ChildWidgets []pages.Widget // Pre-built child widgets (for custom-content columns) + Properties map[string]any // Column properties (Sortable, Resizable, Visible, etc.) +} + +// DataGridSpec carries all inputs needed to build a DataGrid2 widget object. +type DataGridSpec struct { + DataSource pages.DataSource + Columns []DataGridColumnSpec + HeaderWidgets []pages.Widget // Pre-built CONTROLBAR widgets for filtersPlaceholder + // Paging overrides (empty string = use template default) + PagingOverrides map[string]string // camelCase widget key → string value + SelectionMode string // empty = no override +} + +// FilterWidgetSpec carries inputs for building a filter widget. +type FilterWidgetSpec struct { + WidgetID string // e.g. pages.WidgetIDDataGridTextFilter + FilterName string // widget name +} + // WidgetBuilderBackend provides pluggable widget construction capabilities. type WidgetBuilderBackend interface { // LoadWidgetTemplate loads a widget template by ID and returns a builder @@ -298,4 +324,14 @@ type WidgetBuilderBackend interface { // 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) + + // BuildDataGrid2Widget builds a complete DataGrid2 CustomWidget from domain-typed inputs. + // The backend loads the template, constructs the BSON object with columns, + // datasource, header widgets, paging, and selection, and returns a fully + // assembled CustomWidget. Returns the widget with an opaque RawType/RawObject. + BuildDataGrid2Widget(id model.ID, name string, spec DataGridSpec, projectPath string) (*pages.CustomWidget, error) + + // BuildFilterWidget builds a filter widget (text, number, date, or dropdown filter) + // for use inside DataGrid2 filtersPlaceholder or CONTROLBAR sections. + BuildFilterWidget(spec FilterWidgetSpec, projectPath string) (pages.Widget, error) } diff --git a/mdl/executor/bson_helpers.go b/mdl/executor/bson_helpers.go deleted file mode 100644 index ae8f9cb5..00000000 --- a/mdl/executor/bson_helpers.go +++ /dev/null @@ -1,484 +0,0 @@ -// 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 { - switch bin := val.(type) { - case primitive.Binary: - return types.BlobToUUID(bin.Data) - case []byte: - return types.BlobToUUID(bin) - default: - 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 { - return mdlerrors.NewValidation("widget has no Caption property") - } - 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 f27d313d..46113b92 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -223,13 +223,11 @@ func applyReplaceWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op // 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 := mutator.ParamScope() widgetScope := mutator.WidgetScope() pb := &pageBuilder{ - writer: e.writer, - reader: e.reader, + backend: ctx.Backend, moduleID: moduleID, moduleName: moduleName, entityContext: entityContext, diff --git a/mdl/executor/cmd_catalog.go b/mdl/executor/cmd_catalog.go index 86722751..dedf3b92 100644 --- a/mdl/executor/cmd_catalog.go +++ b/mdl/executor/cmd_catalog.go @@ -702,12 +702,12 @@ func captureDescribeParallel(ctx *ExecContext, objectType string, qualifiedName MprPath: ctx.MprPath, } // If a backing Executor exists, create a local one for handlers that still - // need e.reader/e.output (e.g., describeMicroflow via writeDescribeJSON). + // need e.backend/e.output (e.g., describeMicroflow via writeDescribeJSON). if ctx.executor != nil { local := &Executor{ - reader: ctx.executor.reader, - output: &buf, - cache: ctx.Cache, + backend: ctx.executor.backend, + output: &buf, + cache: ctx.Cache, } localCtx.executor = local } diff --git a/mdl/executor/cmd_diff_local.go b/mdl/executor/cmd_diff_local.go index 0900f438..3aa71efe 100644 --- a/mdl/executor/cmd_diff_local.go +++ b/mdl/executor/cmd_diff_local.go @@ -497,6 +497,9 @@ func attributeBsonToMDL(_ *ExecContext, raw map[string]any) string { func microflowBsonToMDL(ctx *ExecContext, raw map[string]any, qualifiedName string) string { qn := splitQualifiedName(qualifiedName) mf := ctx.Backend.ParseMicroflowFromRaw(raw, model.ID(qn.Name), "") + if mf == nil { + return fmt.Sprintf("MICROFLOW %s\n -- parse failed --\nEND MICROFLOW\n", qualifiedName) + } entityNames, microflowNames := buildNameLookups(ctx) return renderMicroflowMDL(ctx, mf, qn, entityNames, microflowNames, nil) diff --git a/mdl/executor/cmd_export_mappings.go b/mdl/executor/cmd_export_mappings.go index 2633b97c..035ab0b3 100644 --- a/mdl/executor/cmd_export_mappings.go +++ b/mdl/executor/cmd_export_mappings.go @@ -9,10 +9,10 @@ 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/model" "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/mpr" ) // showExportMappings prints a table of all export mapping documents. @@ -178,7 +178,6 @@ func printExportMappingElement(w io.Writer, elem *model.ExportMappingElement, de // execCreateExportMapping creates a new export mapping. func execCreateExportMapping(ctx *ExecContext, s *ast.CreateExportMappingStmt) error { - e := ctx.executor if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } @@ -217,7 +216,7 @@ func execCreateExportMapping(ctx *ExecContext, s *ast.CreateExportMappingStmt) e // Build element tree from the AST definition, cloning JSON structure properties if s.RootElement != nil { - root := buildExportMappingElementModel(s.Name.Module, s.RootElement, "", "(Object)", jsElems, e.reader, true) + root := buildExportMappingElementModel(s.Name.Module, s.RootElement, "", "(Object)", jsElems, ctx.Backend, true) em.Elements = append(em.Elements, root) } @@ -233,10 +232,10 @@ func execCreateExportMapping(ctx *ExecContext, s *ast.CreateExportMappingStmt) e // buildExportMappingElementModel converts an AST element definition to a model element. // It clones properties from the matching JSON structure element and adds mapping bindings. -func buildExportMappingElementModel(moduleName string, def *ast.ExportMappingElementDef, parentEntity, parentPath string, jsElems map[string]*types.JsonElement, reader *mpr.Reader, isRoot bool) *model.ExportMappingElement { +func buildExportMappingElementModel(moduleName string, def *ast.ExportMappingElementDef, parentEntity, parentPath string, jsElems map[string]*types.JsonElement, b backend.FullBackend, isRoot bool) *model.ExportMappingElement { elem := &model.ExportMappingElement{ BaseElement: model.BaseElement{ - ID: model.ID(mpr.GenerateID()), + ID: model.ID(types.GenerateID()), }, } @@ -308,7 +307,7 @@ func buildExportMappingElementModel(moduleName string, def *ast.ExportMappingEle itemElem := &model.ExportMappingElement{ BaseElement: model.BaseElement{ - ID: model.ID(mpr.GenerateID()), + ID: model.ID(types.GenerateID()), TypeName: "ExportMappings$ObjectMappingElement", }, Kind: "Object", @@ -327,13 +326,13 @@ func buildExportMappingElementModel(moduleName string, def *ast.ExportMappingEle } // Item's children are the value elements for _, valChild := range itemDef.Children { - itemElem.Children = append(itemElem.Children, buildExportMappingElementModel(moduleName, valChild, itemEntity, itemPath, jsElems, reader, false)) + itemElem.Children = append(itemElem.Children, buildExportMappingElementModel(moduleName, valChild, itemEntity, itemPath, jsElems, b, false)) } elem.Children = append(elem.Children, itemElem) } else { // Fallback: treat children as direct item children (no intermediate entity) for _, child := range def.Children { - elem.Children = append(elem.Children, buildExportMappingElementModel(moduleName, child, entity, itemPath, jsElems, reader, false)) + elem.Children = append(elem.Children, buildExportMappingElementModel(moduleName, child, entity, itemPath, jsElems, b, false)) } } } else { @@ -342,14 +341,14 @@ func buildExportMappingElementModel(moduleName string, def *ast.ExportMappingEle elem.Association = assoc elem.ObjectHandling = handling for _, child := range def.Children { - elem.Children = append(elem.Children, buildExportMappingElementModel(moduleName, child, entity, lookupPath, jsElems, reader, false)) + elem.Children = append(elem.Children, buildExportMappingElementModel(moduleName, child, entity, lookupPath, jsElems, b, false)) } } } else { // Value mapping — bind to attribute elem.Kind = "Value" elem.TypeName = "ExportMappings$ValueMappingElement" - elem.DataType = resolveAttributeType(parentEntity, def.Attribute, reader) + elem.DataType = resolveAttributeType(parentEntity, def.Attribute, b) attr := def.Attribute if parentEntity != "" && !strings.Contains(attr, ".") { attr = parentEntity + "." + attr diff --git a/mdl/executor/cmd_features.go b/mdl/executor/cmd_features.go index ccf58809..370d93a0 100644 --- a/mdl/executor/cmd_features.go +++ b/mdl/executor/cmd_features.go @@ -13,7 +13,7 @@ import ( // checkFeature verifies that a feature is available in the connected project's // version. Returns nil if available, or an actionable error with the version -// requirement and a hint. Safe to call when e.reader is nil (returns nil). +// requirement and a hint. Safe to call when not connected (returns nil). func checkFeature(ctx *ExecContext, area, name, statement, hint string) error { if !ctx.Connected() { diff --git a/mdl/executor/cmd_import_mappings.go b/mdl/executor/cmd_import_mappings.go index 4837a8c3..c0a7a8c1 100644 --- a/mdl/executor/cmd_import_mappings.go +++ b/mdl/executor/cmd_import_mappings.go @@ -9,10 +9,10 @@ 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/model" "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/mpr" ) // showImportMappings prints a table of all import mapping documents. @@ -191,7 +191,6 @@ func printImportMappingElement(w io.Writer, elem *model.ImportMappingElement, de // execCreateImportMapping creates a new import mapping. func execCreateImportMapping(ctx *ExecContext, s *ast.CreateImportMappingStmt) error { - e := ctx.executor if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } @@ -226,7 +225,7 @@ func execCreateImportMapping(ctx *ExecContext, s *ast.CreateImportMappingStmt) e // Build element tree from the AST definition, cloning JSON structure properties if s.RootElement != nil { - root := buildImportMappingElementModel(s.Name.Module, s.RootElement, "", "(Object)", e.reader, jsElementsByPath, true) + root := buildImportMappingElementModel(s.Name.Module, s.RootElement, "", "(Object)", ctx.Backend, jsElementsByPath, true) im.Elements = append(im.Elements, root) } @@ -244,10 +243,10 @@ func execCreateImportMapping(ctx *ExecContext, s *ast.CreateImportMappingStmt) e // It clones properties from the matching JSON structure element (ExposedName, JsonPath, // MaxOccurs, ElementType, etc.) and adds mapping-specific bindings (Entity, Attribute, // Association, ObjectHandling). -func buildImportMappingElementModel(moduleName string, def *ast.ImportMappingElementDef, parentEntity, parentPath string, reader *mpr.Reader, jsElems map[string]*types.JsonElement, isRoot bool) *model.ImportMappingElement { +func buildImportMappingElementModel(moduleName string, def *ast.ImportMappingElementDef, parentEntity, parentPath string, b backend.FullBackend, jsElems map[string]*types.JsonElement, isRoot bool) *model.ImportMappingElement { elem := &model.ImportMappingElement{ BaseElement: model.BaseElement{ - ID: model.ID(mpr.GenerateID()), + ID: model.ID(types.GenerateID()), }, } @@ -318,13 +317,13 @@ func buildImportMappingElementModel(moduleName string, def *ast.ImportMappingEle } for _, child := range def.Children { - elem.Children = append(elem.Children, buildImportMappingElementModel(moduleName, child, entity, childPath, reader, jsElems, false)) + elem.Children = append(elem.Children, buildImportMappingElementModel(moduleName, child, entity, childPath, b, jsElems, false)) } } else { // Value mapping — bind to attribute elem.Kind = "Value" elem.TypeName = "ImportMappings$ValueMappingElement" - elem.DataType = resolveAttributeType(parentEntity, def.Attribute, reader) + elem.DataType = resolveAttributeType(parentEntity, def.Attribute, b) elem.IsKey = def.IsKey attr := def.Attribute if parentEntity != "" && !strings.Contains(attr, ".") { @@ -349,15 +348,15 @@ func buildJsonElementPathMap(elems []*types.JsonElement, m map[string]*types.Jso // resolveAttributeType looks up the data type of an entity attribute from the project. // Returns "String" as default if the attribute cannot be found. -func resolveAttributeType(entityQN, attrName string, reader *mpr.Reader) string { - if reader == nil || entityQN == "" { +func resolveAttributeType(entityQN, attrName string, b backend.DomainModelBackend) string { + if b == nil || entityQN == "" { return "String" } parts := strings.SplitN(entityQN, ".", 2) if len(parts) != 2 { return "String" } - dms, err := reader.ListDomainModels() + dms, err := b.ListDomainModels() if err != nil { return "String" } diff --git a/mdl/executor/cmd_lint.go b/mdl/executor/cmd_lint.go index 7b7ca383..246d7a3b 100644 --- a/mdl/executor/cmd_lint.go +++ b/mdl/executor/cmd_lint.go @@ -35,7 +35,7 @@ func execLint(ctx *ExecContext, s *ast.LintStmt) error { // Create lint context lintCtx := linter.NewLintContext(ctx.Catalog) - lintCtx.SetReader(e.reader) + lintCtx.SetReader(e.Reader()) // Load configuration projectDir := filepath.Dir(ctx.MprPath) diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index e4f49597..7f0d95ff 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -8,9 +8,9 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/sdk/mpr" ) // flowBuilder helps construct the flow graph from AST statements. @@ -30,7 +30,7 @@ type flowBuilder struct { measurer *layoutMeasurer // For measuring statement dimensions nextConnectionPoint model.ID // For compound statements: the exit point differs from entry point nextFlowCase string // If set, next connecting flow uses this case value (for merge-less splits) - reader *mpr.Reader // For looking up page/microflow references + reader backend.FullBackend // For looking up page/microflow references hierarchy *ContainerHierarchy // For resolving container IDs to module names pendingAnnotations *ast.ActivityAnnotations // Pending annotations to attach to next activity restServices []*model.ConsumedRestService // Cached REST services for parameter classification diff --git a/mdl/executor/cmd_microflows_create.go b/mdl/executor/cmd_microflows_create.go index dedaa545..dd85ae1a 100644 --- a/mdl/executor/cmd_microflows_create.go +++ b/mdl/executor/cmd_microflows_create.go @@ -213,7 +213,7 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { varTypes: varTypes, declaredVars: declaredVars, measurer: &layoutMeasurer{varTypes: varTypes}, - reader: e.reader, + reader: ctx.Backend, hierarchy: hierarchy, restServices: restServices, } diff --git a/mdl/executor/cmd_pages_builder.go b/mdl/executor/cmd_pages_builder.go index 6e17beaf..5c00608e 100644 --- a/mdl/executor/cmd_pages_builder.go +++ b/mdl/executor/cmd_pages_builder.go @@ -15,7 +15,6 @@ import ( "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/domainmodel" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/sdk/mpr" "github.com/mendixlabs/mxcli/sdk/pages" ) @@ -25,8 +24,7 @@ import ( // pageBuilder constructs pages from AST. type pageBuilder struct { - writer *mpr.Writer - reader *mpr.Reader + backend backend.FullBackend moduleID model.ID moduleName string widgetScope map[string]model.ID // widget name -> widget ID @@ -64,8 +62,8 @@ func (pb *pageBuilder) initPluggableEngine() { log.Printf("warning: %v", pb.pluggableEngineErr) return } - if pb.reader != nil { - if loadErr := registry.LoadUserDefinitions(pb.reader.Path()); loadErr != nil { + if pb.backend != nil { + if loadErr := registry.LoadUserDefinitions(pb.backend.Path()); loadErr != nil { log.Printf("warning: loading user widget definitions: %v", loadErr) } } @@ -76,10 +74,10 @@ func (pb *pageBuilder) initPluggableEngine() { // 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. +// getProjectPath returns the project directory path from the backend. func (pb *pageBuilder) getProjectPath() string { - if pb.reader != nil { - return pb.reader.Path() + if pb.backend != nil { + return pb.backend.Path() } return "" } @@ -99,7 +97,7 @@ func (pb *pageBuilder) getModules() []*model.Module { if pb.execCache != nil && pb.execCache.modules != nil { return pb.execCache.modules } - modules, _ := pb.reader.ListModules() + modules, _ := pb.backend.ListModules() if pb.execCache != nil { pb.execCache.modules = modules } @@ -111,7 +109,7 @@ func (pb *pageBuilder) getHierarchy() (*ContainerHierarchy, error) { if pb.execCache != nil && pb.execCache.hierarchy != nil { return pb.execCache.hierarchy, nil } - h, err := NewContainerHierarchy(pb.reader) + h, err := NewContainerHierarchyFromBackend(pb.backend) if err != nil { return nil, err } @@ -125,7 +123,7 @@ func (pb *pageBuilder) getHierarchy() (*ContainerHierarchy, error) { func (pb *pageBuilder) getLayouts() ([]*pages.Layout, error) { if pb.layoutsCache == nil { var err error - pb.layoutsCache, err = pb.reader.ListLayouts() + pb.layoutsCache, err = pb.backend.ListLayouts() if err != nil { return nil, err } @@ -138,7 +136,7 @@ func (pb *pageBuilder) getDomainModels() ([]*domainmodel.DomainModel, error) { if pb.execCache != nil && pb.execCache.domainModels != nil { return pb.execCache.domainModels, nil } - domainModels, err := pb.reader.ListDomainModels() + domainModels, err := pb.backend.ListDomainModels() if err != nil { return nil, err } @@ -152,7 +150,7 @@ func (pb *pageBuilder) getDomainModels() ([]*domainmodel.DomainModel, error) { func (pb *pageBuilder) getPages() ([]*pages.Page, error) { if pb.pagesCache == nil { var err error - pb.pagesCache, err = pb.reader.ListPages() + pb.pagesCache, err = pb.backend.ListPages() if err != nil { return nil, err } @@ -164,7 +162,7 @@ func (pb *pageBuilder) getPages() ([]*pages.Page, error) { func (pb *pageBuilder) getMicroflows() ([]*microflows.Microflow, error) { if pb.microflowsCache == nil { var err error - pb.microflowsCache, err = pb.reader.ListMicroflows() + pb.microflowsCache, err = pb.backend.ListMicroflows() if err != nil { return nil, err } @@ -267,7 +265,7 @@ func (pb *pageBuilder) getMainPlaceholderRef(layoutName string) string { func (pb *pageBuilder) getFolders() ([]*types.FolderInfo, error) { if pb.foldersCache == nil { var err error - pb.foldersCache, err = pb.reader.ListFolders() + pb.foldersCache, err = pb.backend.ListFolders() if err != nil { return nil, err } @@ -332,14 +330,14 @@ func (pb *pageBuilder) resolveFolder(folderPath string) (model.ID, error) { func (pb *pageBuilder) createFolder(name string, containerID model.ID) (model.ID, error) { folder := &model.Folder{ BaseElement: model.BaseElement{ - ID: model.ID(mpr.GenerateID()), + ID: model.ID(types.GenerateID()), TypeName: "Projects$Folder", }, ContainerID: containerID, Name: name, } - if err := pb.writer.CreateFolder(folder); err != nil { + if err := pb.backend.CreateFolder(folder); err != nil { return "", err } diff --git a/mdl/executor/cmd_pages_builder_input.go b/mdl/executor/cmd_pages_builder_input.go index c98c9152..0b07d61e 100644 --- a/mdl/executor/cmd_pages_builder_input.go +++ b/mdl/executor/cmd_pages_builder_input.go @@ -3,18 +3,10 @@ package executor import ( - "fmt" - "log" "strings" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/pages" - "github.com/mendixlabs/mxcli/sdk/widgets" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" ) // unquoteIdentifier strips surrounding double-quotes or backticks from a quoted identifier. @@ -75,179 +67,6 @@ func (pb *pageBuilder) resolveAssociationPath(assocName string) string { return assocName } -// updateWidgetPropertyValue finds and updates a specific property value in a WidgetObject. -// The updateFn is called with the WidgetValue and should return the modified value. -func updateWidgetPropertyValue(obj bson.D, propTypeIDs map[string]pages.PropertyTypeIDEntry, propertyKey string, updateFn func(bson.D) bson.D) bson.D { - // Find the PropertyTypeID for this key - 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 { - // Normalize: strip dashes for comparison (BlobToUUID returns dashed format, - // but propertyTypeIDs from template loader use undashed 32-char hex). - normalizedTarget := strings.ReplaceAll(propertyTypeID, "-", "") - for _, elem := range prop { - if elem.Key == "TypePointer" { - // Handle both primitive.Binary (from MPR) and []byte (from JSON templates) - 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 - } - // Also try raw hex encoding (no GUID swap) for templates - 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 -} - -// setPrimitiveValue sets the PrimitiveValue field in a WidgetValue. -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 -} - -// 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. -// MxBuild requires the EntityRef to resolve the association target (CE0642). -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), // version marker - 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 -} - -// setAttributeRef sets the AttributeRef field in a WidgetValue. -// The attrPath must be fully qualified (Module.Entity.Attribute, 2+ dots). -// If not fully qualified, AttributeRef is set to nil to avoid Studio Pro crash. -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 -} - -// convertPropertyTypeIDs converts widgets.PropertyTypeIDEntry to pages.PropertyTypeIDEntry. -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, - } - // Convert nested property IDs if present - if len(v.NestedPropertyIDs) > 0 { - entry.NestedPropertyIDs = convertPropertyTypeIDs(v.NestedPropertyIDs) - } - result[k] = entry - } - return result -} - // resolveSnippetRef resolves a snippet qualified name to its ID. func (pb *pageBuilder) resolveSnippetRef(snippetRef string) (model.ID, error) { if snippetRef == "" { @@ -264,7 +83,7 @@ func (pb *pageBuilder) resolveSnippetRef(snippetRef string) (model.ID, error) { snippetName = snippetRef } - snippets, err := pb.reader.ListSnippets() + snippets, err := pb.backend.ListSnippets() if err != nil { return "", err } diff --git a/mdl/executor/cmd_pages_builder_input_cloning.go b/mdl/executor/cmd_pages_builder_input_cloning.go deleted file mode 100644 index af8681e2..00000000 --- a/mdl/executor/cmd_pages_builder_input_cloning.go +++ /dev/null @@ -1,272 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/mdl/types" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -// cloneDataGrid2ObjectWithDatasourceOnly clones a template Object, only updating the datasource. -// This is for testing to isolate whether column building is the issue. -func (pb *pageBuilder) cloneDataGrid2ObjectWithDatasourceOnly(templateObject bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, datasource pages.DataSource) bson.D { - result := make(bson.D, 0, len(templateObject)) - - for _, elem := range templateObject { - if elem.Key == "$ID" { - // Generate new ID for the object - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "Properties" { - // Update only datasource property - if propsArr, ok := elem.Value.(bson.A); ok { - updatedProps := pb.updateOnlyDatasource(propsArr, propertyTypeIDs, datasource) - result = append(result, bson.E{Key: "Properties", Value: updatedProps}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - - return result -} - -// updateOnlyDatasource only updates the datasource property, keeping everything else as-is. -func (pb *pageBuilder) updateOnlyDatasource(props bson.A, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, datasource pages.DataSource) bson.A { - result := bson.A{int32(2)} // Version marker - datasourceEntry := propertyTypeIDs["datasource"] - - for _, propVal := range props { - if _, ok := propVal.(int32); ok { - continue // Skip version markers - } - propMap, ok := propVal.(bson.D) - if !ok { - continue - } - - typePointer := pb.getTypePointerFromProperty(propMap) - if typePointer == datasourceEntry.PropertyTypeID { - // Replace datasource - result = append(result, pb.buildDataGrid2Property(datasourceEntry, datasource, "", "")) - } else { - // Keep as-is but with new IDs - result = append(result, pb.clonePropertyWithNewIDs(propMap)) - } - } - - return result -} - -// getTypePointerFromProperty extracts the TypePointer ID from a WidgetProperty. -func (pb *pageBuilder) getTypePointerFromProperty(prop bson.D) string { - for _, elem := range prop { - if elem.Key == "TypePointer" { - switch v := elem.Value.(type) { - case primitive.Binary: - return bsonutil.BsonBinaryToID(v) - case []byte: - // When loaded from JSON template, binary is []byte instead of primitive.Binary - return types.BlobToUUID(v) - } - } - } - return "" -} - -// clonePropertyWithNewIDs clones a WidgetProperty with new IDs. -func (pb *pageBuilder) clonePropertyWithNewIDs(prop bson.D) bson.D { - result := make(bson.D, 0, len(prop)) - for _, elem := range prop { - if elem.Key == "$ID" { - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "Value" { - if valMap, ok := elem.Value.(bson.D); ok { - result = append(result, bson.E{Key: "Value", Value: pb.cloneValueWithNewIDs(valMap)}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - return result -} - -// cloneValueWithNewIDs clones a WidgetValue with new IDs. -// Recursively regenerates $ID fields in all nested bson.D documents -// (AttributeRef, EntityRef, SortItems, DesignProperties, etc.). -func (pb *pageBuilder) cloneValueWithNewIDs(val bson.D) bson.D { - return deepCloneWithNewIDs(val) -} - -// clonePropertyWithPrimitiveValue clones a WidgetProperty with new IDs and an updated PrimitiveValue. -// This preserves the template's exact structure (TextTemplate, Objects, etc.) while only changing the value. -func (pb *pageBuilder) clonePropertyWithPrimitiveValue(prop bson.D, newValue string) bson.D { - result := make(bson.D, 0, len(prop)) - for _, elem := range prop { - if elem.Key == "$ID" { - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "Value" { - if valMap, ok := elem.Value.(bson.D); ok { - result = append(result, bson.E{Key: "Value", Value: pb.cloneValueWithUpdatedPrimitive(valMap, newValue)}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - return result -} - -// cloneValueWithUpdatedPrimitive clones a WidgetValue with new IDs and an updated PrimitiveValue. -func (pb *pageBuilder) cloneValueWithUpdatedPrimitive(val bson.D, newValue string) bson.D { - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "$ID" { - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "PrimitiveValue" { - result = append(result, bson.E{Key: "PrimitiveValue", Value: newValue}) - } else { - result = append(result, bson.E{Key: elem.Key, Value: deepCloneValue(elem.Value)}) - } - } - return result -} - -// clonePropertyClearingTextTemplate clones a WidgetProperty with new IDs but sets TextTemplate to nil. -// Used for mode-dependent properties where TextTemplate should not be present. -func (pb *pageBuilder) clonePropertyClearingTextTemplate(prop bson.D) bson.D { - result := make(bson.D, 0, len(prop)) - for _, elem := range prop { - if elem.Key == "$ID" { - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "Value" { - if valMap, ok := elem.Value.(bson.D); ok { - result = append(result, bson.E{Key: "Value", Value: pb.cloneValueClearingTextTemplate(valMap)}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - return result -} - -// cloneValueClearingTextTemplate clones a WidgetValue with new IDs and TextTemplate set to nil. -func (pb *pageBuilder) cloneValueClearingTextTemplate(val bson.D) bson.D { - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "$ID" { - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "TextTemplate" { - result = append(result, bson.E{Key: "TextTemplate", Value: nil}) - } else { - result = append(result, bson.E{Key: elem.Key, Value: deepCloneValue(elem.Value)}) - } - } - return result -} - -// cloneWithNewID clones a BSON document, recursively regenerating all $ID fields. -func (pb *pageBuilder) cloneWithNewID(doc bson.D) bson.D { - return deepCloneWithNewIDs(doc) -} - -// cloneTextTemplateWithNewIDs clones a Forms$ClientTemplate with new IDs. -func (pb *pageBuilder) cloneTextTemplateWithNewIDs(tt bson.D) bson.D { - return deepCloneWithNewIDs(tt) -} - -// clonePropertyWithExpression clones a WidgetProperty with new IDs and an updated Expression. -// Same as clonePropertyWithPrimitiveValue but replaces the Expression field instead. -func (pb *pageBuilder) clonePropertyWithExpression(prop bson.D, newExpr string) bson.D { - result := make(bson.D, 0, len(prop)) - for _, elem := range prop { - if elem.Key == "$ID" { - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "Value" { - if valMap, ok := elem.Value.(bson.D); ok { - result = append(result, bson.E{Key: "Value", Value: pb.cloneValueWithUpdatedExpression(valMap, newExpr)}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - return result -} - -// cloneValueWithUpdatedExpression clones a WidgetValue with new IDs and an updated Expression. -func (pb *pageBuilder) cloneValueWithUpdatedExpression(val bson.D, newExpr string) bson.D { - result := make(bson.D, 0, len(val)) - for _, elem := range val { - if elem.Key == "$ID" { - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "Expression" { - result = append(result, bson.E{Key: "Expression", Value: newExpr}) - } else { - result = append(result, bson.E{Key: elem.Key, Value: deepCloneValue(elem.Value)}) - } - } - return result -} - -// ============================================================================ -// Deep recursive ID regeneration -// ============================================================================ - -// deepCloneWithNewIDs deep-clones a bson.D, regenerating every $ID field -// throughout the entire nested structure. This ensures no stale GUIDs -// from templates or old widgets persist in the output. -func deepCloneWithNewIDs(doc bson.D) bson.D { - result := make(bson.D, 0, len(doc)) - for _, elem := range doc { - if elem.Key == "$ID" { - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else { - result = append(result, bson.E{Key: elem.Key, Value: deepCloneValue(elem.Value)}) - } - } - return result -} - -// deepCloneValue recursively clones a BSON value, regenerating $ID fields -// in any nested bson.D documents. Non-document values are returned as-is. -func deepCloneValue(v any) any { - switch val := v.(type) { - case bson.D: - return deepCloneWithNewIDs(val) - case bson.A: - return deepCloneArray(val) - case []any: - return deepCloneSlice(val) - default: - return v - } -} - -// deepCloneArray clones a bson.A, recursing into nested documents. -func deepCloneArray(arr bson.A) bson.A { - result := make(bson.A, len(arr)) - for i, elem := range arr { - result[i] = deepCloneValue(elem) - } - return result -} - -// deepCloneSlice clones a []any, recursing into nested documents. -func deepCloneSlice(arr []any) []any { - result := make([]any, len(arr)) - for i, elem := range arr { - result[i] = deepCloneValue(elem) - } - return result -} diff --git a/mdl/executor/cmd_pages_builder_input_datagrid.go b/mdl/executor/cmd_pages_builder_input_datagrid.go deleted file mode 100644 index e49121ae..00000000 --- a/mdl/executor/cmd_pages_builder_input_datagrid.go +++ /dev/null @@ -1,1133 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/mdl/ast" - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" -) - -// colPropBool reads a bool/string property from col.Properties and returns "true"/"false". -func colPropBool(props map[string]any, key string, defaultVal string) string { - if props == nil { - return defaultVal - } - v, ok := props[key] - if !ok { - return defaultVal - } - switch bv := v.(type) { - case bool: - if bv { - return "true" - } - return "false" - case string: - lower := strings.ToLower(bv) - if lower == "true" || lower == "false" { - return lower - } - return defaultVal - default: - return defaultVal - } -} - -// colPropString reads a string property from col.Properties and lowercases it. -func colPropString(props map[string]any, key string, defaultVal string) string { - if props == nil { - return defaultVal - } - v, ok := props[key] - if !ok { - return defaultVal - } - if sv, isStr := v.(string); isStr && sv != "" { - return strings.ToLower(sv) - } - return defaultVal -} - -// colPropInt reads an int/float/string property from col.Properties and returns its string form. -func colPropInt(props map[string]any, key string, defaultVal string) string { - if props == nil { - return defaultVal - } - v, ok := props[key] - if !ok { - return defaultVal - } - switch n := v.(type) { - case int: - return fmt.Sprintf("%d", n) - case int64: - return fmt.Sprintf("%d", n) - case float64: - return fmt.Sprintf("%d", int(n)) - case string: - if n != "" { - return n - } - return defaultVal - default: - return defaultVal - } -} - -// buildDataGrid2Property creates a WidgetProperty BSON for DataGrid2. -func (pb *pageBuilder) buildDataGrid2Property(entry pages.PropertyTypeIDEntry, datasource pages.DataSource, attrRef string, primitiveValue string) bson.D { - // Build the datasource BSON if provided - var datasourceBSON any - if datasource != nil { - datasourceBSON = pb.widgetBackend.SerializeDataSourceToOpaque(datasource) - } - - // Build attribute ref if provided - var attrRefBSON any - if attrRef != "" { - attrRefBSON = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: attrRef}, - {Key: "EntityRef", Value: nil}, - } - } - - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, - {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", Value: attrRefBSON}, - {Key: "DataSource", Value: datasourceBSON}, - {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: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: primitiveValue}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - {Key: "XPathConstraint", Value: ""}, - }}, - } -} - -func (pb *pageBuilder) updateDataGrid2Object(templateObject bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, datasource pages.DataSource, columns []ast.DataGridColumnDef, headerWidgets []bson.D) bson.D { - // Clone the template object with new IDs - result := make(bson.D, 0, len(templateObject)) - - for _, elem := range templateObject { - if elem.Key == "$ID" { - // Generate new ID for the object - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "Properties" { - // Update properties - if propsArr, ok := elem.Value.(bson.A); ok { - updatedProps := pb.updateDataGrid2Properties(propsArr, propertyTypeIDs, datasource, columns, headerWidgets) - result = append(result, bson.E{Key: "Properties", Value: updatedProps}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - - return result -} - -func (pb *pageBuilder) updateDataGrid2Properties(props bson.A, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, datasource pages.DataSource, columns []ast.DataGridColumnDef, headerWidgets []bson.D) bson.A { - result := bson.A{int32(2)} // Version marker - - // Get the property type IDs for datasource, columns, and filtersPlaceholder - datasourceEntry := propertyTypeIDs["datasource"] - columnsEntry := propertyTypeIDs["columns"] - filtersPlaceholderEntry := propertyTypeIDs["filtersPlaceholder"] - - // Process each property from the template - for _, propVal := range props { - // Skip version markers - if _, ok := propVal.(int32); ok { - continue - } - - propMap, ok := propVal.(bson.D) - if !ok { - continue - } - - // Check if this is the datasource, columns, or filtersPlaceholder property by matching TypePointer - typePointer := pb.getTypePointerFromProperty(propMap) - - if typePointer == datasourceEntry.PropertyTypeID { - // Replace with our datasource - result = append(result, pb.buildDataGrid2Property(datasourceEntry, datasource, "", "")) - } else if typePointer == columnsEntry.PropertyTypeID { - // Clone the columns property and update with our column data - result = append(result, pb.cloneAndUpdateColumnsProperty(propMap, columnsEntry, propertyTypeIDs, columns)) - } else if typePointer == filtersPlaceholderEntry.PropertyTypeID && len(headerWidgets) > 0 { - // Replace with our header widgets (for HEADER section support) - result = append(result, pb.buildFiltersPlaceholderProperty(filtersPlaceholderEntry, headerWidgets)) - } else { - // Keep the template property as-is, but regenerate IDs - result = append(result, pb.clonePropertyWithNewIDs(propMap)) - } - } - - return result -} - -func (pb *pageBuilder) cloneAndUpdateColumnsProperty(templateProp bson.D, columnsEntry pages.PropertyTypeIDEntry, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, columns []ast.DataGridColumnDef) bson.D { - // Extract template column object from the property - var templateColumnObj bson.D - for _, elem := range templateProp { - if elem.Key == "Value" { - if valMap, ok := elem.Value.(bson.D); ok { - for _, ve := range valMap { - if ve.Key == "Objects" { - if objArr, ok := ve.Value.(bson.A); ok { - // Find first WidgetObject in the array - for _, obj := range objArr { - if colObj, ok := obj.(bson.D); ok { - templateColumnObj = colObj - break - } - } - } - } - } - } - } - } - - // Build column objects by cloning template and updating - columnObjects := bson.A{int32(2)} // Version marker - for i := range columns { - col := &columns[i] - if templateColumnObj != nil { - // Clone template column and update (preserves all template properties) - columnObjects = append(columnObjects, pb.cloneAndUpdateColumnObject(templateColumnObj, col, columnsEntry.NestedPropertyIDs)) - } else { - // Build from scratch (no template available) - columnObjects = append(columnObjects, pb.buildDataGrid2ColumnObject(col, columnsEntry.ObjectTypeID, columnsEntry.NestedPropertyIDs)) - } - } - - // Clone the property structure with new IDs and our column objects - result := make(bson.D, 0, len(templateProp)) - for _, elem := range templateProp { - if elem.Key == "$ID" { - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "Value" { - if valMap, ok := elem.Value.(bson.D); ok { - newVal := make(bson.D, 0, len(valMap)) - for _, ve := range valMap { - if ve.Key == "$ID" { - newVal = append(newVal, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if ve.Key == "Objects" { - newVal = append(newVal, bson.E{Key: "Objects", Value: columnObjects}) - } else if ve.Key == "Action" { - // Clone Action with new ID - if actionMap, ok := ve.Value.(bson.D); ok { - newVal = append(newVal, bson.E{Key: "Action", Value: pb.cloneWithNewID(actionMap)}) - } else { - newVal = append(newVal, ve) - } - } else { - newVal = append(newVal, ve) - } - } - result = append(result, bson.E{Key: "Value", Value: newVal}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - - return result -} - -func (pb *pageBuilder) cloneAndUpdateColumnObject(templateCol bson.D, col *ast.DataGridColumnDef, columnPropertyIDs map[string]pages.PropertyTypeIDEntry) bson.D { - attrPath := pb.resolveAttributePath(col.Attribute) - caption := col.Caption - if caption == "" { - caption = col.Attribute - } - - // Build content widgets if there are child widgets - var contentWidgets []bson.D - for _, child := range col.ChildrenV3 { - widgetBSON, err := pb.buildWidgetV3ToBSON(child) - if err != nil { - // Log error and continue (don't fail the entire column) - continue - } - if widgetBSON != nil { - contentWidgets = append(contentWidgets, widgetBSON) - } - } - - result := make(bson.D, 0, len(templateCol)) - for _, elem := range templateCol { - if elem.Key == "$ID" { - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "Properties" { - // Update properties - if propsArr, ok := elem.Value.(bson.A); ok { - result = append(result, bson.E{Key: "Properties", Value: pb.cloneAndUpdateColumnProperties(propsArr, columnPropertyIDs, col, attrPath, caption, contentWidgets)}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - return result -} - -func (pb *pageBuilder) cloneAndUpdateColumnProperties(templateProps bson.A, columnPropertyIDs map[string]pages.PropertyTypeIDEntry, col *ast.DataGridColumnDef, attrPath, caption string, contentWidgets []bson.D) bson.A { - result := bson.A{int32(2)} // Version marker - - // Track which properties were added - addedProps := make(map[string]bool) - - hasCustomContent := len(contentWidgets) > 0 - - for _, propVal := range templateProps { - if _, ok := propVal.(int32); ok { - continue // Skip version markers - } - propMap, ok := propVal.(bson.D) - if !ok { - continue - } - - typePointer := pb.getTypePointerFromProperty(propMap) - - // Find which property key this TypePointer corresponds to - propKey := "" - for key, entry := range columnPropertyIDs { - if entry.PropertyTypeID == typePointer { - addedProps[key] = true - propKey = key - break - } - } - - // Clone template properties, adjusting for the column mode. - // - // The editorConfig.js in the widget mpk defines mode-dependent visibility: - // attribute mode: tooltip VISIBLE, content/allowEventPropagation/exportValue HIDDEN - // customContent mode: tooltip HIDDEN, content/allowEventPropagation/exportValue VISIBLE - // - // Properties must have mode-appropriate values or CE0463 is triggered. - // See docs/03-development/PAGE_BSON_SERIALIZATION.md for details. - switch propKey { - case "showContentAs": - if hasCustomContent { - result = append(result, pb.clonePropertyWithPrimitiveValue(propMap, "customContent")) - } else { - result = append(result, pb.clonePropertyWithNewIDs(propMap)) - } - case "attribute": - if attrPath != "" { - entry := columnPropertyIDs["attribute"] - result = append(result, pb.buildColumnAttributeProperty(entry, attrPath)) - } else { - result = append(result, pb.clonePropertyWithNewIDs(propMap)) - } - case "header": - entry := columnPropertyIDs["header"] - result = append(result, pb.buildColumnHeaderProperty(entry, caption)) - case "content": - if hasCustomContent { - entry := columnPropertyIDs["content"] - result = append(result, pb.buildColumnContentProperty(entry, contentWidgets)) - } else { - result = append(result, pb.clonePropertyWithNewIDs(propMap)) - } - case "visible": - visExpr := "true" - if col.Properties != nil { - if v, ok := col.Properties["Visible"]; ok { - if sv, isStr := v.(string); isStr && sv != "" { - visExpr = sv - } - } - } - result = append(result, pb.clonePropertyWithExpression(propMap, visExpr)) - - case "columnClass": - classExpr := "" - if col.Properties != nil { - if v, ok := col.Properties["DynamicCellClass"]; ok { - if sv, isStr := v.(string); isStr { - classExpr = sv - } - } - } - result = append(result, pb.clonePropertyWithExpression(propMap, classExpr)) - - // Mode-dependent properties: adjust for customContent vs attribute mode - case "tooltip": - if hasCustomContent { - // tooltip is HIDDEN in customContent mode — clear TextTemplate - result = append(result, pb.clonePropertyClearingTextTemplate(propMap)) - } else { - tooltipText := "" - if col.Properties != nil { - if v, ok := col.Properties["Tooltip"]; ok { - if sv, isStr := v.(string); isStr { - tooltipText = sv - } - } - } - if tooltipText != "" { - entry := columnPropertyIDs["tooltip"] - result = append(result, pb.buildColumnHeaderProperty(entry, tooltipText)) - } else { - result = append(result, pb.clonePropertyWithNewIDs(propMap)) - } - } - case "exportValue": - if hasCustomContent { - // exportValue is VISIBLE in customContent mode — ensure it has a TextTemplate - entry := columnPropertyIDs["exportValue"] - result = append(result, pb.buildColumnHeaderProperty(entry, "")) - } else { - result = append(result, pb.clonePropertyWithNewIDs(propMap)) - } - case "allowEventPropagation": - // allowEventPropagation is VISIBLE in customContent mode (hidden in attribute mode). - // Clone from template preserving its default value. - result = append(result, pb.clonePropertyWithNewIDs(propMap)) - - case "sortable": - defaultSortable := "false" - if attrPath != "" { - defaultSortable = "true" - } - sortVal := colPropBool(col.Properties, "Sortable", defaultSortable) - result = append(result, pb.clonePropertyWithPrimitiveValue(propMap, sortVal)) - - case "resizable": - resVal := colPropBool(col.Properties, "Resizable", "true") - result = append(result, pb.clonePropertyWithPrimitiveValue(propMap, resVal)) - - case "draggable": - dragVal := colPropBool(col.Properties, "Draggable", "true") - result = append(result, pb.clonePropertyWithPrimitiveValue(propMap, dragVal)) - - case "hidable": - hidVal := colPropString(col.Properties, "Hidable", "yes") - result = append(result, pb.clonePropertyWithPrimitiveValue(propMap, hidVal)) - - case "width": - widthVal := colPropString(col.Properties, "ColumnWidth", "autoFill") - result = append(result, pb.clonePropertyWithPrimitiveValue(propMap, widthVal)) - - case "size": - sizeVal := colPropInt(col.Properties, "Size", "1") - result = append(result, pb.clonePropertyWithPrimitiveValue(propMap, sizeVal)) - - case "wrapText": - wrapVal := "false" - if col.Properties != nil { - if v, ok := col.Properties["WrapText"]; ok { - if bv, isBool := v.(bool); isBool && bv { - wrapVal = "true" - } else if sv, isStr := v.(string); isStr { - wrapVal = strings.ToLower(sv) - } - } - } - result = append(result, pb.clonePropertyWithPrimitiveValue(propMap, wrapVal)) - - case "alignment": - alignVal := "left" - if col.Properties != nil { - if v, ok := col.Properties["Alignment"]; ok { - if sv, isStr := v.(string); isStr && sv != "" { - alignVal = strings.ToLower(sv) - } - } - } - result = append(result, pb.clonePropertyWithPrimitiveValue(propMap, alignVal)) - - default: - // Clone all other properties from template with regenerated IDs - result = append(result, pb.clonePropertyWithNewIDs(propMap)) - } - } - - // Add required properties that were missing from template - if !addedProps["visible"] { - if visibleEntry, ok := columnPropertyIDs["visible"]; ok { - visExpr := "true" - if col.Properties != nil { - if v, ok := col.Properties["Visible"]; ok { - if sv, isStr := v.(string); isStr && sv != "" { - visExpr = sv - } - } - } - result = append(result, pb.buildColumnExpressionProperty(visibleEntry, visExpr)) - } - } - - return result -} - -func (pb *pageBuilder) buildDataGrid2Object(propertyTypeIDs map[string]pages.PropertyTypeIDEntry, objectTypeID string, datasource pages.DataSource, columns []ast.DataGridColumnDef, headerWidgets []bson.D) bson.D { - properties := bson.A{int32(2)} // Version marker for non-empty array - - // Create properties for ALL entries in propertyTypeIDs - // This ensures Studio Pro can display the widget's properties panel - for key, entry := range propertyTypeIDs { - switch key { - case "datasource": - // Use actual datasource value - properties = append(properties, pb.buildDataGrid2Property(entry, datasource, "", "")) - case "columns": - // Use actual columns - properties = append(properties, pb.buildDataGrid2ColumnsProperty(entry, propertyTypeIDs, columns)) - case "filtersPlaceholder": - // Use header widgets if provided (for HEADER section support) - if len(headerWidgets) > 0 { - properties = append(properties, pb.buildFiltersPlaceholderProperty(entry, headerWidgets)) - } else { - properties = append(properties, pb.buildDataGrid2DefaultProperty(entry)) - } - default: - // Create property with default value from template - properties = append(properties, pb.buildDataGrid2DefaultProperty(entry)) - } - } - - // Build TypePointer - references the WidgetObjectType - var typePointer any - if objectTypeID != "" { - typePointer = bsonutil.IDToBsonBinary(objectTypeID) - } - - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: properties}, - {Key: "TypePointer", Value: typePointer}, - } -} - -func (pb *pageBuilder) buildDataGrid2DefaultProperty(entry pages.PropertyTypeIDEntry) bson.D { - // Determine default values based on value type - var selectionValue string = "None" - if entry.ValueType == "Selection" { - // Selection type defaults to "None" - selectionValue = "None" - } - - // For TextTemplate properties, create a proper Forms$ClientTemplate structure - // Studio Pro expects this even for empty values, otherwise it shows "widget definition changed" - var textTemplate any - if entry.ValueType == "TextTemplate" { - textTemplate = pb.buildEmptyClientTemplate() - } - - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, - {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", 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: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: entry.DefaultValue}, - {Key: "Selection", Value: selectionValue}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: textTemplate}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - {Key: "XPathConstraint", Value: ""}, - }}, - } -} - -func (pb *pageBuilder) buildEmptyClientTemplate() bson.D { - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, // Empty items with version marker - }}, - {Key: "Parameters", Value: bson.A{int32(2)}}, // Empty parameters - {Key: "Template", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, // Empty items with version marker - }}, - } -} - -func (pb *pageBuilder) buildClientTemplateWithText(text string) bson.D { - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$ClientTemplate"}, - {Key: "Fallback", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{int32(3)}}, - }}, - {Key: "Parameters", Value: bson.A{int32(2)}}, - {Key: "Template", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Texts$Text"}, - {Key: "Items", Value: bson.A{ - int32(3), - bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Texts$Translation"}, - {Key: "LanguageCode", Value: "en_US"}, - {Key: "Text", Value: text}, - }, - }}, - }}, - } -} - -func (pb *pageBuilder) buildFiltersPlaceholderProperty(entry pages.PropertyTypeIDEntry, widgetsBSON []bson.D) bson.D { - // Build the Widgets array with version marker - widgetsArray := bson.A{int32(2)} // Version marker for non-empty array - for _, w := range widgetsBSON { - widgetsArray = append(widgetsArray, w) - } - - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, - {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", 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: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: ""}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, - {Key: "Widgets", Value: widgetsArray}, - {Key: "XPathConstraint", Value: ""}, - }}, - } -} - -func (pb *pageBuilder) buildDataGrid2ColumnsProperty(entry pages.PropertyTypeIDEntry, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, columns []ast.DataGridColumnDef) bson.D { - // Build column objects using nested property IDs - columnObjects := bson.A{int32(2)} // Version marker - for i := range columns { - columnObjects = append(columnObjects, pb.buildDataGrid2ColumnObject(&columns[i], entry.ObjectTypeID, entry.NestedPropertyIDs)) - } - - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, - {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", 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: columnObjects}, - {Key: "PrimitiveValue", Value: ""}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - {Key: "XPathConstraint", Value: ""}, - }}, - } -} - -func (pb *pageBuilder) buildDataGrid2ColumnObject(col *ast.DataGridColumnDef, columnObjectTypeID string, columnPropertyIDs map[string]pages.PropertyTypeIDEntry) bson.D { - attrPath := pb.resolveAttributePath(col.Attribute) - - // Build content widgets if there are child widgets - var contentWidgets []bson.D - for _, child := range col.ChildrenV3 { - widgetBSON, err := pb.buildWidgetV3ToBSON(child) - if err != nil { - // Log error and continue (don't fail the entire column) - continue - } - if widgetBSON != nil { - contentWidgets = append(contentWidgets, widgetBSON) - } - } - hasCustomContent := len(contentWidgets) > 0 - - // Column properties array - MUST include ALL properties from columnPropertyIDs - properties := bson.A{int32(2)} // Version marker - - // Iterate through ALL column property types and create each one - for key, entry := range columnPropertyIDs { - switch key { - case "showContentAs": - // Set to "customContent" if we have custom widgets, otherwise "attribute" - if hasCustomContent { - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, "customContent")) - } else { - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, "attribute")) - } - - case "attribute": - // The actual attribute path - if attrPath != "" { - properties = append(properties, pb.buildColumnAttributeProperty(entry, attrPath)) - } else { - properties = append(properties, pb.buildColumnDefaultProperty(entry)) - } - - case "header": - // Caption for the column (TextTemplate type) - if col.Caption != "" { - properties = append(properties, pb.buildColumnHeaderProperty(entry, col.Caption)) - } else { - // Use attribute name as default caption - properties = append(properties, pb.buildColumnHeaderProperty(entry, col.Attribute)) - } - - case "content": - // Content property with widgets (if any) - if hasCustomContent { - properties = append(properties, pb.buildColumnContentProperty(entry, contentWidgets)) - } else { - properties = append(properties, pb.buildColumnContentProperty(entry, nil)) - } - - case "filter": - // Filter property should have empty widget arrays (like Studio Pro) - properties = append(properties, pb.buildColumnContentProperty(entry, nil)) - - case "visible": - // Expression-type property - visExpr := "true" - if col.Properties != nil { - if v, ok := col.Properties["Visible"]; ok { - if sv, isStr := v.(string); isStr && sv != "" { - visExpr = sv - } - } - } - properties = append(properties, pb.buildColumnExpressionProperty(entry, visExpr)) - - case "columnClass": - // Expression-type property - classExpr := "" - if col.Properties != nil { - if v, ok := col.Properties["DynamicCellClass"]; ok { - if sv, isStr := v.(string); isStr { - classExpr = sv - } - } - } - properties = append(properties, pb.buildColumnExpressionProperty(entry, classExpr)) - - case "sortable": - defaultSortable := "false" - if attrPath != "" { - defaultSortable = "true" - } - sortVal := colPropBool(col.Properties, "Sortable", defaultSortable) - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, sortVal)) - - case "resizable": - resVal := colPropBool(col.Properties, "Resizable", "true") - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, resVal)) - - case "draggable": - dragVal := colPropBool(col.Properties, "Draggable", "true") - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, dragVal)) - - case "wrapText": - wrapVal := colPropBool(col.Properties, "WrapText", "false") - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, wrapVal)) - - case "alignment": - alignVal := colPropString(col.Properties, "Alignment", "left") - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, alignVal)) - - case "width": - widthVal := colPropString(col.Properties, "ColumnWidth", "autoFill") - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, widthVal)) - - case "minWidth": - // Enumeration-type property - "auto", "setByContent", or "manual" - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, "auto")) - - case "size": - sizeVal := colPropInt(col.Properties, "Size", "1") - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, sizeVal)) - - case "hidable": - hidVal := colPropString(col.Properties, "Hidable", "yes") - properties = append(properties, pb.buildColumnPrimitiveProperty(entry, hidVal)) - - case "tooltip": - if hasCustomContent { - // tooltip is HIDDEN in customContent mode — use empty TextTemplate - properties = append(properties, pb.buildColumnDefaultProperty(entry)) - } else { - tooltipText := "" - if col.Properties != nil { - if v, ok := col.Properties["Tooltip"]; ok { - if sv, isStr := v.(string); isStr { - tooltipText = sv - } - } - } - if tooltipText != "" { - properties = append(properties, pb.buildColumnHeaderProperty(entry, tooltipText)) - } else { - properties = append(properties, pb.buildColumnDefaultProperty(entry)) - } - } - - default: - // All other properties: use default value based on valueType - switch entry.ValueType { - case "Expression": - // Expression properties need an expression value - properties = append(properties, pb.buildColumnExpressionProperty(entry, "")) - case "TextTemplate": - // TextTemplate properties need proper structure - properties = append(properties, pb.buildColumnDefaultProperty(entry)) - default: - // Other types use default builder - properties = append(properties, pb.buildColumnDefaultProperty(entry)) - } - } - } - - // Column ObjectType pointer - var typePointer any - if columnObjectTypeID != "" { - typePointer = bsonutil.IDToBsonBinary(columnObjectTypeID) - } - - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: properties}, - {Key: "TypePointer", Value: typePointer}, - } -} - -func (pb *pageBuilder) buildColumnDefaultProperty(entry pages.PropertyTypeIDEntry) bson.D { - // For TextTemplate properties, create a proper Forms$ClientTemplate structure - var textTemplate any - if entry.ValueType == "TextTemplate" { - textTemplate = pb.buildEmptyClientTemplate() - } - - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, - {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", 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: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: entry.DefaultValue}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: textTemplate}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - {Key: "XPathConstraint", Value: ""}, - }}, - } -} - -func (pb *pageBuilder) buildColumnPrimitiveProperty(entry pages.PropertyTypeIDEntry, value string) bson.D { - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, - {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", 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: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: value}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - {Key: "XPathConstraint", Value: ""}, - }}, - } -} - -func (pb *pageBuilder) buildColumnExpressionProperty(entry pages.PropertyTypeIDEntry, expression string) bson.D { - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, - {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", Value: nil}, - {Key: "DataSource", Value: nil}, - {Key: "EntityRef", Value: nil}, - {Key: "Expression", Value: expression}, - {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: ""}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - {Key: "XPathConstraint", Value: ""}, - }}, - } -} - -func (pb *pageBuilder) buildColumnAttributeProperty(entry pages.PropertyTypeIDEntry, attrPath string) bson.D { - // AttributeRef requires a fully qualified path (Module.Entity.Attribute, 2+ dots). - // If the path is not fully qualified, set AttributeRef to nil to avoid Studio Pro crash. - var attributeRef any - if strings.Count(attrPath, ".") >= 2 { - attributeRef = bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "DomainModels$AttributeRef"}, - {Key: "Attribute", Value: attrPath}, - {Key: "EntityRef", Value: nil}, - } - } - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, - {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", Value: attributeRef}, - {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: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: ""}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - {Key: "XPathConstraint", Value: ""}, - }}, - } -} - -func (pb *pageBuilder) buildColumnHeaderProperty(entry pages.PropertyTypeIDEntry, caption string) bson.D { - // Create the text template with the caption - textTemplate := pb.buildClientTemplateWithText(caption) - - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, - {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", 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: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: ""}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: textTemplate}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, - {Key: "Widgets", Value: bson.A{int32(2)}}, - {Key: "XPathConstraint", Value: ""}, - }}, - } -} - -func (pb *pageBuilder) buildColumnContentProperty(entry pages.PropertyTypeIDEntry, widgets any) bson.D { - // Widgets array containing the widgets - widgetsArray := bson.A{int32(2)} - switch w := widgets.(type) { - case bson.D: - if w != nil { - widgetsArray = append(widgetsArray, w) - } - case []bson.D: - for _, widget := range w { - widgetsArray = append(widgetsArray, widget) - } - } - - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.PropertyTypeID)}, - {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, - {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Forms$NoAction"}, - {Key: "DisabledDuringExecution", Value: true}, - }}, - {Key: "AttributeRef", 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: bson.A{int32(2)}}, - {Key: "PrimitiveValue", Value: ""}, - {Key: "Selection", Value: "None"}, - {Key: "SourceVariable", Value: nil}, - {Key: "TextTemplate", Value: nil}, - {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(entry.ValueTypeID)}, - {Key: "Widgets", Value: widgetsArray}, - {Key: "XPathConstraint", Value: ""}, - }}, - } -} diff --git a/mdl/executor/cmd_pages_builder_input_filters.go b/mdl/executor/cmd_pages_builder_input_filters.go index eadf5943..15831f2e 100644 --- a/mdl/executor/cmd_pages_builder_input_filters.go +++ b/mdl/executor/cmd_pages_builder_input_filters.go @@ -5,12 +5,8 @@ package executor import ( "strings" - "github.com/mendixlabs/mxcli/mdl/bsonutil" - "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/sdk/domainmodel" "github.com/mendixlabs/mxcli/sdk/pages" - "github.com/mendixlabs/mxcli/sdk/widgets" - "go.mongodb.org/mongo-driver/bson" ) func (pb *pageBuilder) getFilterWidgetIDForAttribute(attrPath string) string { @@ -104,90 +100,3 @@ func (pb *pageBuilder) findAttributeType(attrPath string) domainmodel.AttributeT return nil } - -func (pb *pageBuilder) buildFilterWidgetBSON(widgetID, filterName string) bson.D { - // Load the filter widget template - rawType, rawObject, propertyTypeIDs, objectTypeID, err := widgets.GetTemplateFullBSON(widgetID, types.GenerateID, pb.reader.Path()) - if err != nil || rawType == nil { - // Fallback: create minimal filter widget structure - return pb.buildMinimalFilterWidgetBSON(widgetID, filterName) - } - - // The widget structure is: CustomWidgets$CustomWidget with Type and Object - widgetBSON := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, - {Key: "Editable", Value: "Inherited"}, - {Key: "Name", Value: filterName}, - {Key: "Object", Value: rawObject}, - {Key: "TabIndex", Value: int32(0)}, - {Key: "Type", Value: rawType}, - } - - // Set the "linkedDs" property to "auto" mode (which links to parent datasource) - if propertyTypeIDs != nil && rawObject != nil { - widgetBSON = pb.setFilterWidgetLinkedDsAuto(widgetBSON, propertyTypeIDs, objectTypeID) - } - - return widgetBSON -} - -func (pb *pageBuilder) setFilterWidgetLinkedDsAuto(widget bson.D, propertyTypeIDs map[string]widgets.PropertyTypeIDEntry, objectTypeID string) bson.D { - // The filter widgets have an "attrChoice" property that should be set to "auto" - // which makes them automatically link to the parent datasource - return widget -} - -func (pb *pageBuilder) buildMinimalFilterWidgetBSON(widgetID, filterName string) bson.D { - typeID := types.GenerateID() - objectTypeID := types.GenerateID() - objectID := types.GenerateID() - - // Get widget type name based on ID - var widgetTypeName string - switch widgetID { - case pages.WidgetIDDataGridTextFilter: - widgetTypeName = "Text filter" - case pages.WidgetIDDataGridNumberFilter: - widgetTypeName = "Number filter" - case pages.WidgetIDDataGridDateFilter: - widgetTypeName = "Date filter" - case pages.WidgetIDDataGridDropdownFilter: - widgetTypeName = "Drop-down filter" - default: - widgetTypeName = "Text filter" - } - - return bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, - {Key: "Editable", Value: "Inherited"}, - {Key: "Name", Value: filterName}, - {Key: "Object", Value: bson.D{ - {Key: "$ID", Value: bsonutil.IDToBsonBinary(objectID)}, - {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: bson.A{int32(2)}}, - {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(objectTypeID)}, - }}, - {Key: "TabIndex", Value: int32(0)}, - {Key: "Type", Value: bson.D{ - {Key: "$ID", Value: bsonutil.IDToBsonBinary(typeID)}, - {Key: "$Type", Value: "CustomWidgets$CustomWidgetType"}, - {Key: "HelpUrl", Value: ""}, - {Key: "ObjectType", Value: bson.D{ - {Key: "$ID", Value: bsonutil.IDToBsonBinary(objectTypeID)}, - {Key: "$Type", Value: "CustomWidgets$WidgetObjectType"}, - {Key: "PropertyTypes", Value: bson.A{int32(2)}}, - }}, - {Key: "OfflineCapable", Value: true}, - {Key: "StudioCategory", Value: "Data Controls"}, - {Key: "StudioProCategory", Value: "Data controls"}, - {Key: "SupportedPlatform", Value: "Web"}, - {Key: "WidgetDescription", Value: ""}, - {Key: "WidgetId", Value: widgetID}, - {Key: "WidgetName", Value: widgetTypeName}, - {Key: "WidgetNeedsEntityContext", Value: false}, - {Key: "WidgetPluginWidget", Value: true}, - }}, - } -} diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index e4c0a8e7..1d108b9b 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -658,7 +658,7 @@ func (pb *pageBuilder) resolveAssociationDestination(assocQN, contextEntity stri } modName, assocName := parts[0], parts[1] - domainModels, err := pb.reader.ListDomainModels() + domainModels, err := pb.backend.ListDomainModels() if err != nil { return "" } @@ -701,7 +701,7 @@ func (pb *pageBuilder) entityQNByID(entityID model.ID) string { if entityID == "" { return "" } - domainModels, err := pb.reader.ListDomainModels() + domainModels, err := pb.backend.ListDomainModels() if err != nil { return "" } @@ -725,7 +725,7 @@ func (pb *pageBuilder) moduleNameByID(moduleID model.ID) string { if moduleID == "" { return "" } - modules, err := pb.reader.ListModules() + modules, err := pb.backend.ListModules() if err != nil { return "" } @@ -809,7 +809,7 @@ func (pb *pageBuilder) getNanoflowReturnEntityName(qualifiedName string) string name = qualifiedName } - nanoflows, err := pb.reader.ListNanoflows() + nanoflows, err := pb.backend.ListNanoflows() if err != nil { return "" } @@ -1134,7 +1134,7 @@ func (pb *pageBuilder) resolveNanoflowByName(nfName string) (model.ID, error) { name = nfName } - nanoflows, err := pb.reader.ListNanoflows() + nanoflows, err := pb.backend.ListNanoflows() if err != nil { return "", mdlerrors.NewBackend("list nanoflows", err) } diff --git a/mdl/executor/cmd_pages_builder_v3_pluggable.go b/mdl/executor/cmd_pages_builder_v3_pluggable.go deleted file mode 100644 index 93655b2d..00000000 --- a/mdl/executor/cmd_pages_builder_v3_pluggable.go +++ /dev/null @@ -1,316 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "fmt" - "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/mdl/types" -) - -// ============================================================================= -// Custom/Pluggable Widget Builders V3 -// ============================================================================= - -// buildGallerySelectionProperty clones an itemSelection property and updates the Selection value. -func (pb *pageBuilder) buildGallerySelectionProperty(propMap bson.D, selectionMode string) bson.D { - result := make(bson.D, 0, len(propMap)) - - for _, elem := range propMap { - if elem.Key == "$ID" { - // Generate new ID - result = append(result, bson.E{Key: "$ID", Value: bsonutil.NewIDBsonBinary()}) - } else if elem.Key == "Value" { - // Clone Value and update Selection - if valueMap, ok := elem.Value.(bson.D); ok { - result = append(result, bson.E{Key: "Value", Value: pb.cloneGallerySelectionValue(valueMap, selectionMode)}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - - return result -} - -// cloneGallerySelectionValue clones a WidgetValue and updates the Selection field. -func (pb *pageBuilder) 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" { - // Update selection mode - result = append(result, bson.E{Key: "Selection", Value: selectionMode}) - } else if elem.Key == "Action" { - // Clone action with new ID - if actionMap, ok := elem.Value.(bson.D); ok { - result = append(result, bson.E{Key: "Action", Value: pb.cloneActionWithNewID(actionMap)}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - - return result -} - -// cloneActionWithNewID clones an action (e.g., NoAction) with a new ID. -func (pb *pageBuilder) 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 -} - -// 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 { - return nil, err - } - if widget == nil { - return nil, 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. -// Used by the widget engine's opAttributeObjects operation. -// The structure follows CustomWidgets$WidgetObject with a nested WidgetProperty for "attribute". -// TypePointers reference the Type's PropertyType IDs (not regenerated). -func (pb *pageBuilder) 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 -} - -// BSON helper functions for filter properties - -func getBsonField(d bson.D, key string) bson.D { - for _, elem := range d { - if elem.Key == key { - if nested, ok := elem.Value.(bson.D); ok { - return nested - } - } - } - return nil -} - -func getBsonArray(d bson.D, key string) []any { - for _, elem := range d { - if elem.Key == key { - switch v := elem.Value.(type) { - case []any: - return v - case bson.A: - return []any(v) - } - } - } - return nil -} - -func getBsonString(d bson.D, key string) string { - for _, elem := range d { - if elem.Key == key { - if s, ok := elem.Value.(string); ok { - return s - } - } - } - return "" -} - -func getBsonBinaryID(d bson.D, key string) string { - for _, elem := range d { - if elem.Key == key { - if b, ok := elem.Value.([]byte); ok { - return bytesToHex(b) - } - } - } - return "" -} - -func setBsonPrimitiveValue(propMap bson.D, value string) bson.D { - for i, elem := range propMap { - if elem.Key == "Value" { - if valueMap, ok := elem.Value.(bson.D); ok { - for j, vElem := range valueMap { - if vElem.Key == "PrimitiveValue" { - valueMap[j].Value = value - break - } - } - propMap[i].Value = valueMap - } - break - } - } - return propMap -} - -func setBsonArrayField(d bson.D, key string, value []any) bson.D { - for i, elem := range d { - if elem.Key == key { - d[i].Value = value - return d - } - } - return d -} - -func setBsonField(d bson.D, key string, value bson.D) bson.D { - for i, elem := range d { - if elem.Key == key { - d[i].Value = value - return d - } - } - return d -} - -// hexToBytes converts a hex string (with or without dashes) to a 16-byte blob -// in Microsoft GUID format (little-endian for first 3 segments). -// This matches the format used by Mendix and uuidToBlob in sdk/mpr/writer_core.go. -func hexToBytes(hexStr string) []byte { - // Remove dashes if present (UUID format) - clean := strings.ReplaceAll(hexStr, "-", "") - if len(clean) != 32 { - return nil - } - - // Decode hex to bytes - decoded := make([]byte, 16) - for i := range 16 { - decoded[i] = hexByte(clean[i*2])<<4 | hexByte(clean[i*2+1]) - } - - // Swap bytes to Microsoft GUID format (little-endian for first 3 segments) - blob := make([]byte, 16) - // First 4 bytes: reversed - blob[0] = decoded[3] - blob[1] = decoded[2] - blob[2] = decoded[1] - blob[3] = decoded[0] - // Next 2 bytes: reversed - blob[4] = decoded[5] - blob[5] = decoded[4] - // Next 2 bytes: reversed - blob[6] = decoded[7] - blob[7] = decoded[6] - // Last 8 bytes: unchanged - 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 -} - -// bytesToHex converts a 16-byte blob from Microsoft GUID format to a hex string. -// This reverses the byte swapping done by hexToBytes, matching blobToUUID in sdk/mpr/reader.go. -func bytesToHex(b []byte) string { - if len(b) != 16 { - // Fallback for non-standard lengths - if len(b) > 1024 { - return "" // reject unreasonably large inputs - } - 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) - } - - // Reverse Microsoft GUID byte swapping to get canonical hex - 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], // First 4 bytes: reversed - b[5], b[4], // Next 2 bytes: reversed - b[7], b[6], // Next 2 bytes: reversed - b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]) // Last 8 bytes: unchanged -} diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index 52fa365c..192e384a 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -7,14 +7,12 @@ import ( "regexp" "strings" - "go.mongodb.org/mongo-driver/bson" - "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/pages" - "github.com/mendixlabs/mxcli/sdk/widgets" ) func (pb *pageBuilder) buildDataViewV3(w *ast.WidgetV3) (*pages.DataView, error) { @@ -89,22 +87,8 @@ func (pb *pageBuilder) buildDataViewV3(w *ast.WidgetV3) (*pages.DataView, error) } func (pb *pageBuilder) buildDataGridV3(w *ast.WidgetV3) (*pages.CustomWidget, error) { - // Build DataGrid2 as a CustomWidget (pluggable widget) like V2 does. - // The built-in DataGrid (Forms$DataGrid) has serialization issues. widgetID := model.ID(types.GenerateID()) - // Load embedded template (required for pluggable widgets to work) - embeddedType, embeddedObject, embeddedIDs, embeddedObjectTypeID, err := widgets.GetTemplateFullBSON(pages.WidgetIDDataGrid2, types.GenerateID, pb.reader.Path()) - if err != nil { - return nil, mdlerrors.NewBackend("load DataGrid2 template", err) - } - if embeddedType == nil || embeddedObject == nil { - return nil, mdlerrors.NewNotFound("widget template", "DataGrid2") - } - - // Convert widget IDs to pages.PropertyTypeIDEntry format - propertyTypeIDs := convertPropertyTypeIDs(embeddedIDs) - // Build datasource from V3 DataSource property var datasource pages.DataSource if ds := w.GetDataSource(); ds != nil { @@ -121,71 +105,73 @@ func (pb *pageBuilder) buildDataGridV3(w *ast.WidgetV3) (*pages.CustomWidget, er } // Extract column definitions and CONTROLBAR widgets from children - var columns []ast.DataGridColumnDef - var headerWidgets []bson.D + var columns []backend.DataGridColumnSpec + var headerWidgets []pages.Widget for _, child := range w.Children { switch strings.ToUpper(child.Type) { case "COLUMN": attr := child.GetAttribute() - // Sugar: when no explicit Attribute: property is given, fall back to - // the column's name. This lets `COLUMN Sku (Caption: 'SKU')` work - // without repeating `Attribute: Sku`. Skip for custom-content columns - // (those with a body of child widgets), which don't bind to an attribute. if attr == "" && child.Name != "" && len(child.Children) == 0 { attr = child.Name } - col := ast.DataGridColumnDef{ - Attribute: attr, + col := backend.DataGridColumnSpec{ + Attribute: pb.resolveAttributePath(attr), Caption: child.GetCaption(), - ChildrenV3: child.Children, // Child widgets for custom content columns Properties: child.Properties, } + // Build child widgets for custom content columns + for _, grandchild := range child.Children { + childWidget, err := pb.buildWidgetV3(grandchild) + if err != nil { + return nil, mdlerrors.NewBackend("build column child widget", err) + } + if childWidget != nil { + col.ChildWidgets = append(col.ChildWidgets, childWidget) + } + } columns = append(columns, col) case "CONTROLBAR": - // Build CONTROLBAR widgets as BSON for the filtersPlaceholder property for _, controlBarChild := range child.Children { - widgetBSON, err := pb.buildWidgetV3ToBSON(controlBarChild) + childWidget, err := pb.buildWidgetV3(controlBarChild) if err != nil { return nil, mdlerrors.NewBackend("build controlbar widget", err) } - if widgetBSON != nil { - headerWidgets = append(headerWidgets, widgetBSON) + if childWidget != nil { + headerWidgets = append(headerWidgets, childWidget) } } } } - // Update the template object with datasource, columns, and header widgets - var updatedObject bson.D - if len(columns) > 0 || len(headerWidgets) > 0 { - // Use full update that replaces columns and/or header widgets - updatedObject = pb.updateDataGrid2Object(embeddedObject, propertyTypeIDs, datasource, columns, headerWidgets) - } else { - // No columns or header widgets defined, use template columns - updatedObject = pb.cloneDataGrid2ObjectWithDatasourceOnly(embeddedObject, propertyTypeIDs, datasource) + // Collect paging overrides from AST properties + pagingOverrides := make(map[string]string) + for mdlKey, widgetKey := range dataGridPagingPropMap { + if v := w.GetStringProp(mdlKey); v != "" { + pagingOverrides[widgetKey] = v + } else if iv := w.GetIntProp(mdlKey); iv > 0 { + pagingOverrides[widgetKey] = fmt.Sprintf("%d", iv) + } else if bv, ok := w.Properties[mdlKey]; ok { + if boolVal, isBool := bv.(bool); isBool { + if boolVal { + pagingOverrides[widgetKey] = "yes" + } else { + pagingOverrides[widgetKey] = "no" + } + } + } } - // Apply paging properties from AST if specified - updatedObject = pb.applyDataGridPagingProps(updatedObject, propertyTypeIDs, w) - - // Apply selection mode if specified - if selection := w.GetSelection(); selection != "" { - updatedObject = pb.applyDataGridSelectionProp(updatedObject, propertyTypeIDs, selection) + spec := backend.DataGridSpec{ + DataSource: datasource, + Columns: columns, + HeaderWidgets: headerWidgets, + PagingOverrides: pagingOverrides, + SelectionMode: w.GetSelection(), } - grid := &pages.CustomWidget{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: widgetID, - TypeName: "CustomWidgets$CustomWidget", - }, - Name: w.Name, - }, - Editable: "Always", - RawType: embeddedType, - RawObject: updatedObject, - PropertyTypeIDMap: propertyTypeIDs, - ObjectTypeID: embeddedObjectTypeID, + grid, err := pb.widgetBackend.BuildDataGrid2Widget(widgetID, w.Name, spec, pb.backend.Path()) + if err != nil { + return nil, err } if err := pb.registerWidgetName(w.Name, grid.ID); err != nil { @@ -905,102 +891,3 @@ var dataGridPagingPropMap = map[string]string{ // "ShowNumberOfRows" is defined in DataGrid2 type but not yet fully supported; // setting it to a non-default value causes CE0463 "widget definition changed". } - -// applyDataGridPagingProps applies paging properties from the AST to the DataGrid2 BSON object. -// It iterates through the object's Properties array, matching TypePointers to known paging -// property keys, and replaces PrimitiveValue when a corresponding AST property is set. -func (pb *pageBuilder) applyDataGridPagingProps(obj bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, w *ast.WidgetV3) bson.D { - // Collect overrides: camelCase key -> string value - overrides := make(map[string]string) - for mdlKey, widgetKey := range dataGridPagingPropMap { - if v := w.GetStringProp(mdlKey); v != "" { - overrides[widgetKey] = v - } else if iv := w.GetIntProp(mdlKey); iv > 0 { - overrides[widgetKey] = fmt.Sprintf("%d", iv) - } else if bv, ok := w.Properties[mdlKey]; ok { - if boolVal, isBool := bv.(bool); isBool { - if boolVal { - overrides[widgetKey] = "yes" - } else { - overrides[widgetKey] = "no" - } - } - } - } - if len(overrides) == 0 { - return obj - } - - // Build reverse map: TypePointer ID -> widget property key - typePointerToKey := make(map[string]string) - for widgetKey, entry := range propertyTypeIDs { - typePointerToKey[entry.PropertyTypeID] = widgetKey - } - - // Walk the object and replace properties that have overrides - result := make(bson.D, 0, len(obj)) - for _, elem := range obj { - if elem.Key == "Properties" { - if propsArr, ok := elem.Value.(bson.A); ok { - updatedProps := bson.A{propsArr[0]} // Keep version marker - for _, propVal := range propsArr[1:] { - propMap, ok := propVal.(bson.D) - if !ok { - updatedProps = append(updatedProps, propVal) - continue - } - tp := pb.getTypePointerFromProperty(propMap) - widgetKey := typePointerToKey[tp] - if newVal, hasOverride := overrides[widgetKey]; hasOverride { - updatedProps = append(updatedProps, pb.clonePropertyWithPrimitiveValue(propMap, newVal)) - } else { - updatedProps = append(updatedProps, propMap) - } - } - result = append(result, bson.E{Key: "Properties", Value: updatedProps}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - return result -} - -// applyDataGridSelectionProp applies the Selection mode to a DataGrid2 object. -// DataGrid2 uses the same "itemSelection" property key as Gallery. -func (pb *pageBuilder) applyDataGridSelectionProp(obj bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, selectionMode string) bson.D { - itemSelectionEntry, ok := propertyTypeIDs["itemSelection"] - if !ok { - return obj - } - - result := make(bson.D, 0, len(obj)) - for _, elem := range obj { - if elem.Key == "Properties" { - if propsArr, ok := elem.Value.(bson.A); ok { - updatedProps := bson.A{propsArr[0]} // Keep version marker - for _, propVal := range propsArr[1:] { - propMap, ok := propVal.(bson.D) - if !ok { - updatedProps = append(updatedProps, propVal) - continue - } - tp := pb.getTypePointerFromProperty(propMap) - if tp == itemSelectionEntry.PropertyTypeID { - updatedProps = append(updatedProps, pb.buildGallerySelectionProperty(propMap, selectionMode)) - } else { - updatedProps = append(updatedProps, propMap) - } - } - result = append(result, bson.E{Key: "Properties", Value: updatedProps}) - } else { - result = append(result, elem) - } - } else { - result = append(result, elem) - } - } - return result -} diff --git a/mdl/executor/cmd_pages_create_v3.go b/mdl/executor/cmd_pages_create_v3.go index 67b52c21..50fb0a21 100644 --- a/mdl/executor/cmd_pages_create_v3.go +++ b/mdl/executor/cmd_pages_create_v3.go @@ -53,8 +53,7 @@ func execCreatePageV3(ctx *ExecContext, s *ast.CreatePageStmtV3) error { // Build the page BEFORE deleting the old one (atomic: if build fails, old page is preserved) pb := &pageBuilder{ - writer: e.writer, - reader: e.reader, + backend: ctx.Backend, moduleID: moduleID, moduleName: s.Name.Module, widgetScope: make(map[string]model.ID), @@ -130,8 +129,7 @@ func execCreateSnippetV3(ctx *ExecContext, s *ast.CreateSnippetStmtV3) error { // Build the snippet BEFORE deleting the old one (atomic: if build fails, old snippet is preserved) pb := &pageBuilder{ - writer: e.writer, - reader: e.reader, + backend: ctx.Backend, moduleID: moduleID, moduleName: s.Name.Module, widgetScope: make(map[string]model.ID), diff --git a/mdl/executor/cmd_widgets.go b/mdl/executor/cmd_widgets.go index ac037c35..dad61d4a 100644 --- a/mdl/executor/cmd_widgets.go +++ b/mdl/executor/cmd_widgets.go @@ -7,8 +7,6 @@ import ( "fmt" "strings" - "go.mongodb.org/mongo-driver/bson" - "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/model" @@ -197,43 +195,31 @@ func groupWidgetsByContainer(widgets []widgetRef) map[string][]widgetRef { return containers } -// updateWidgetsInContainer updates widgets within a single page or snippet. +// updateWidgetsInContainer updates widgets within a single page or snippet +// using the PageMutator backend (no direct BSON manipulation). func updateWidgetsInContainer(ctx *ExecContext, containerID string, widgetRefs []widgetRef, assignments []ast.WidgetPropertyAssignment, dryRun bool) (int, error) { if len(widgetRefs) == 0 { return 0, nil } - containerType := widgetRefs[0].ContainerType containerName := widgetRefs[0].ContainerName - // Load the page or snippet - if strings.ToLower(containerType) == "page" { - return updateWidgetsInPage(ctx, containerID, containerName, widgetRefs, assignments, dryRun) - } else if strings.ToLower(containerType) == "snippet" { - return updateWidgetsInSnippet(ctx, containerID, containerName, widgetRefs, assignments, dryRun) - } - - return 0, mdlerrors.NewUnsupported(fmt.Sprintf("unsupported container type: %s", containerType)) -} - -// updateWidgetsInPage updates widgets in a page using raw BSON. -func updateWidgetsInPage(ctx *ExecContext, containerID, containerName string, widgetRefs []widgetRef, assignments []ast.WidgetPropertyAssignment, dryRun bool) (int, error) { - - // Load raw BSON as ordered document (preserves field ordering) - rawBytes, err := ctx.Backend.GetRawUnitBytes(model.ID(containerID)) + // Open the container (page, layout, or snippet) through the backend mutator. + mutator, err := ctx.Backend.OpenPageForMutation(model.ID(containerID)) if err != nil { - return 0, mdlerrors.NewBackend(fmt.Sprintf("load page %s", containerName), err) + return 0, mdlerrors.NewBackend(fmt.Sprintf("open %s for mutation", containerName), err) } - var rawData bson.D - if err := bson.Unmarshal(rawBytes, &rawData); err != nil { - return 0, mdlerrors.NewBackend(fmt.Sprintf("unmarshal page %s", containerName), err) + if mutator == nil { + return 0, mdlerrors.NewBackend(fmt.Sprintf("open %s for mutation", containerName), + fmt.Errorf("backend returned nil mutator for %s", containerID)) } updated := 0 for _, ref := range widgetRefs { - result := findBsonWidget(rawData, ref.Name) - if result == nil { - fmt.Fprintf(ctx.Output, " Warning: Widget %q not found in page %s\n", ref.Name, containerName) + // Verify the widget exists before attempting assignments. + if !mutator.FindWidget(ref.Name) { + fmt.Fprintf(ctx.Output, " Warning: Widget %q not found in %s %s\n", + ref.Name, mutator.ContainerType(), containerName) continue } for _, assignment := range assignments { @@ -241,7 +227,7 @@ func updateWidgetsInPage(ctx *ExecContext, containerID, containerName string, wi fmt.Fprintf(ctx.Output, " Would set '%s' = %v on %s (%s) in %s\n", assignment.PropertyPath, assignment.Value, ref.Name, ref.WidgetType, containerName) } else { - if err := setRawWidgetProperty(result.widget, assignment.PropertyPath, assignment.Value); err != nil { + if err := mutator.SetWidgetProperty(ref.Name, assignment.PropertyPath, assignment.Value); err != nil { fmt.Fprintf(ctx.Output, " Warning: Failed to set '%s' on %s: %v\n", assignment.PropertyPath, ref.Name, err) } @@ -250,62 +236,10 @@ func updateWidgetsInPage(ctx *ExecContext, containerID, containerName string, wi updated++ } - // Save back via raw BSON (bson.D preserves field ordering) + // Persist changes via the mutator. if !dryRun && updated > 0 { - outBytes, err := bson.Marshal(rawData) - if err != nil { - return updated, mdlerrors.NewBackend(fmt.Sprintf("marshal page %s", containerName), err) - } - if err := ctx.Backend.UpdateRawUnit(containerID, outBytes); err != nil { - return updated, mdlerrors.NewBackend(fmt.Sprintf("save page %s", containerName), err) - } - } - - return updated, nil -} - -// updateWidgetsInSnippet updates widgets in a snippet using raw BSON. -func updateWidgetsInSnippet(ctx *ExecContext, containerID, containerName string, widgetRefs []widgetRef, assignments []ast.WidgetPropertyAssignment, dryRun bool) (int, error) { - - // Load raw BSON as ordered document (preserves field ordering) - rawBytes, err := ctx.Backend.GetRawUnitBytes(model.ID(containerID)) - if err != nil { - return 0, mdlerrors.NewBackend(fmt.Sprintf("load snippet %s", containerName), err) - } - var rawData bson.D - if err := bson.Unmarshal(rawBytes, &rawData); err != nil { - return 0, mdlerrors.NewBackend(fmt.Sprintf("unmarshal snippet %s", containerName), err) - } - - updated := 0 - for _, ref := range widgetRefs { - result := findBsonWidgetInSnippet(rawData, ref.Name) - if result == nil { - fmt.Fprintf(ctx.Output, " Warning: Widget %q not found in snippet %s\n", ref.Name, containerName) - continue - } - for _, assignment := range assignments { - if dryRun { - fmt.Fprintf(ctx.Output, " Would set '%s' = %v on %s (%s) in %s\n", - assignment.PropertyPath, assignment.Value, ref.Name, ref.WidgetType, containerName) - } else { - if err := setRawWidgetProperty(result.widget, assignment.PropertyPath, assignment.Value); err != nil { - fmt.Fprintf(ctx.Output, " Warning: Failed to set '%s' on %s: %v\n", - assignment.PropertyPath, ref.Name, err) - } - } - } - updated++ - } - - // Save back via raw BSON (bson.D preserves field ordering) - if !dryRun && updated > 0 { - outBytes, err := bson.Marshal(rawData) - if err != nil { - return updated, mdlerrors.NewBackend(fmt.Sprintf("marshal snippet %s", containerName), err) - } - if err := ctx.Backend.UpdateRawUnit(containerID, outBytes); err != nil { - return updated, mdlerrors.NewBackend(fmt.Sprintf("save snippet %s", containerName), err) + if err := mutator.Save(); err != nil { + return updated, mdlerrors.NewBackend(fmt.Sprintf("save %s", containerName), err) } } diff --git a/mdl/executor/executor.go b/mdl/executor/executor.go index e68fa89a..16193676 100644 --- a/mdl/executor/executor.go +++ b/mdl/executor/executor.go @@ -146,24 +146,26 @@ const ( executeTimeout = 5 * time.Minute ) +// BackendFactory creates a new backend instance for connecting to a project. +type BackendFactory func() backend.FullBackend + // Executor executes MDL statements against a Mendix project. type Executor struct { - writer *mpr.Writer - reader *mpr.Reader - backend backend.FullBackend // domain backend (populated on Connect) - output io.Writer - guard *outputGuard // line-limit wrapper around output - mprPath string - settings map[string]any - cache *executorCache - catalog *catalog.Catalog - quiet bool // suppress connection and status messages - format OutputFormat // output format (table, json) - logger *diaglog.Logger // session diagnostics logger (nil = no logging) - fragments map[string]*ast.DefineFragmentStmt // script-scoped fragment definitions - sqlMgr *sqllib.Manager // external SQL connection manager (lazy init) - themeRegistry *ThemeRegistry // cached theme design property definitions (lazy init) - registry *Registry // statement dispatch registry + backend backend.FullBackend // domain backend (populated on Connect) + backendFactory BackendFactory // factory for creating new backend instances + output io.Writer + guard *outputGuard // line-limit wrapper around output + mprPath string + settings map[string]any + cache *executorCache + catalog *catalog.Catalog + quiet bool // suppress connection and status messages + format OutputFormat // output format (table, json) + logger *diaglog.Logger // session diagnostics logger (nil = no logging) + fragments map[string]*ast.DefineFragmentStmt // script-scoped fragment definitions + sqlMgr *sqllib.Manager // external SQL connection manager (lazy init) + themeRegistry *ThemeRegistry // cached theme design property definitions (lazy init) + registry *Registry // statement dispatch registry } // New creates a new executor with the given output writer. @@ -177,6 +179,11 @@ func New(output io.Writer) *Executor { } } +// SetBackendFactory sets the factory function used to create backend instances on Connect. +func (e *Executor) SetBackendFactory(f BackendFactory) { + e.backendFactory = f +} + // SetQuiet enables or disables quiet mode (suppresses connection/status messages). func (e *Executor) SetQuiet(quiet bool) { e.quiet = quiet @@ -250,7 +257,7 @@ func (e *Executor) ExecuteProgram(prog *ast.Program) error { // trackModifiedDomainModel records a domain model that was modified during execution, // so it can be reconciled at the end of the program. func (e *Executor) trackModifiedDomainModel(moduleID model.ID, moduleName string) { - if e.writer == nil { + if e.backend == nil || !e.backend.IsConnected() { return } if e.cache == nil { @@ -265,17 +272,17 @@ func (e *Executor) trackModifiedDomainModel(moduleID model.ID, moduleName string // finalizeProgramExecution runs post-execution reconciliation on modified domain models. func (e *Executor) finalizeProgramExecution() error { - if e.writer == nil || e.cache == nil || len(e.cache.modifiedDomainModels) == 0 { + if e.backend == nil || !e.backend.IsConnected() || e.cache == nil || len(e.cache.modifiedDomainModels) == 0 { return nil } for moduleID, moduleName := range e.cache.modifiedDomainModels { - dm, err := e.reader.GetDomainModel(moduleID) + dm, err := e.backend.GetDomainModel(moduleID) if err != nil { continue // module may not have a domain model } - count, err := e.writer.ReconcileMemberAccesses(dm.ID, moduleName) + count, err := e.backend.ReconcileMemberAccesses(dm.ID, moduleName) if err != nil { return mdlerrors.NewBackend(fmt.Sprintf("reconcile security for module %s", moduleName), err) } @@ -295,28 +302,37 @@ func (e *Executor) Catalog() *catalog.Catalog { } // Reader returns the MPR reader, or nil if not connected. +// Deprecated: External callers should migrate to using Backend methods directly. func (e *Executor) Reader() *mpr.Reader { - return e.reader + if e.backend == nil { + return nil + } + type readerProvider interface { + MprReader() *mpr.Reader + } + if rp, ok := e.backend.(readerProvider); ok { + return rp.MprReader() + } + return nil } // IsConnected returns true if connected to a project. func (e *Executor) IsConnected() bool { - return e.writer != nil + return e.backend != nil && e.backend.IsConnected() } // Close closes the connection to the project and all SQL connections. func (e *Executor) Close() error { - if e.writer != nil { - e.writer.Close() - e.writer = nil - e.reader = nil + var closeErr error + if e.backend != nil && e.backend.IsConnected() { + closeErr = e.backend.Disconnect() e.backend = nil } if e.sqlMgr != nil { e.sqlMgr.CloseAll() e.sqlMgr = nil } - return nil + return closeErr } // ---------------------------------------------------------------------------- diff --git a/mdl/executor/executor_connect.go b/mdl/executor/executor_connect.go index 74ad9894..d56889d4 100644 --- a/mdl/executor/executor_connect.go +++ b/mdl/executor/executor_connect.go @@ -6,30 +6,29 @@ import ( "fmt" "github.com/mendixlabs/mxcli/mdl/ast" - mprbackend "github.com/mendixlabs/mxcli/mdl/backend/mpr" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/sdk/mpr" ) func execConnect(ctx *ExecContext, s *ast.ConnectStmt) error { e := ctx.executor - if ctx.ConnectedForWrite() { - e.writer.Close() + if e.backend != nil && e.backend.IsConnected() { + if err := e.backend.Disconnect(); err != nil { + fmt.Fprintf(ctx.Output, "Warning: disconnect error: %v\n", err) + } } - writer, err := mpr.NewWriter(s.Path) - if err != nil { + if e.backendFactory == nil { + return mdlerrors.NewBackend("connect", fmt.Errorf("no backend factory configured")) + } + b := e.backendFactory() + if err := b.Connect(s.Path); err != nil { return mdlerrors.NewBackend("connect", err) } - e.writer = writer - e.reader = writer.Reader() + e.backend = b e.mprPath = s.Path e.cache = &executorCache{} // Initialize fresh cache - // Wrap the writer in an MprBackend for ctx.Backend propagation. - e.backend = mprbackend.Wrap(writer, s.Path) - // Propagate connection state back to ctx so subsequent code in this // dispatch cycle sees the updated values. ctx.Backend = e.backend @@ -44,7 +43,7 @@ func execConnect(ctx *ExecContext, s *ast.ConnectStmt) error { ctx.ThemeRegistry = nil // Display connection info with version - pv := e.reader.ProjectVersion() + pv := e.backend.ProjectVersion() if !ctx.Quiet { fmt.Fprintf(ctx.Output, "Connected to: %s (Mendix %s)\n", s.Path, pv.ProductVersion) } @@ -63,20 +62,23 @@ func reconnect(ctx *ExecContext) error { } // Close existing connection - if ctx.ConnectedForWrite() { - e.writer.Close() + if e.backend != nil && e.backend.IsConnected() { + if err := e.backend.Disconnect(); err != nil { + fmt.Fprintf(ctx.Output, "Warning: disconnect error: %v\n", err) + } } // Reopen connection - writer, err := mpr.NewWriter(e.mprPath) - if err != nil { + if e.backendFactory == nil { + return mdlerrors.NewBackend("reconnect", fmt.Errorf("no backend factory configured")) + } + b := e.backendFactory() + if err := b.Connect(e.mprPath); err != nil { return mdlerrors.NewBackend("reconnect", err) } - e.writer = writer - e.reader = writer.Reader() + e.backend = b e.cache = &executorCache{} // Reset cache - e.backend = mprbackend.Wrap(writer, e.mprPath) // Propagate reconnection state back to ctx. ctx.Backend = e.backend @@ -93,7 +95,7 @@ func reconnect(ctx *ExecContext) error { func execDisconnect(ctx *ExecContext) error { e := ctx.executor - if !ctx.ConnectedForWrite() { + if e.backend == nil || !e.backend.IsConnected() { fmt.Fprintln(ctx.Output, "Not connected") return nil } @@ -103,10 +105,10 @@ func execDisconnect(ctx *ExecContext) error { fmt.Fprintf(ctx.Output, "Warning: finalization error: %v\n", err) } - e.writer.Close() + if err := e.backend.Disconnect(); err != nil { + fmt.Fprintf(ctx.Output, "Warning: disconnect error: %v\n", err) + } fmt.Fprintf(ctx.Output, "Disconnected from: %s\n", e.mprPath) - e.writer = nil - e.reader = nil e.mprPath = "" e.cache = nil e.backend = nil @@ -125,19 +127,19 @@ func execDisconnect(ctx *ExecContext) error { func execStatus(ctx *ExecContext) error { e := ctx.executor - if !ctx.ConnectedForWrite() { + if e.backend == nil || !e.backend.IsConnected() { fmt.Fprintln(ctx.Output, "Status: Not connected") return nil } - pv := e.reader.ProjectVersion() + pv := e.backend.ProjectVersion() fmt.Fprintf(ctx.Output, "Status: Connected\n") fmt.Fprintf(ctx.Output, "Project: %s\n", e.mprPath) fmt.Fprintf(ctx.Output, "Mendix Version: %s\n", pv.ProductVersion) fmt.Fprintf(ctx.Output, "MPR Format: v%d\n", pv.FormatVersion) // Show module count - modules, err := e.reader.ListModules() + modules, err := e.backend.ListModules() if err == nil { fmt.Fprintf(ctx.Output, "Modules: %d\n", len(modules)) } diff --git a/mdl/executor/hierarchy.go b/mdl/executor/hierarchy.go index 700a4c66..628c91ca 100644 --- a/mdl/executor/hierarchy.go +++ b/mdl/executor/hierarchy.go @@ -7,10 +7,18 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/mpr" ) +// hierarchySource is the minimal interface needed to build a ContainerHierarchy. +// Both *mpr.Reader and backend.FullBackend satisfy this. +type hierarchySource interface { + ListModules() ([]*model.Module, error) + ListUnits() ([]*types.UnitInfo, error) + ListFolders() ([]*types.FolderInfo, error) +} + // ContainerHierarchy provides efficient module and folder resolution for documents. // It caches the container hierarchy to avoid repeated lookups. type ContainerHierarchy struct { @@ -20,43 +28,18 @@ type ContainerHierarchy struct { folderNames map[model.ID]string } -// NewContainerHierarchy creates a new hierarchy from the reader. -func NewContainerHierarchy(reader *mpr.Reader) (*ContainerHierarchy, error) { - h := &ContainerHierarchy{ - moduleIDs: make(map[model.ID]bool), - moduleNames: make(map[model.ID]string), - containerParent: make(map[model.ID]model.ID), - folderNames: make(map[model.ID]string), - } - - // Load modules - modules, err := reader.ListModules() - if err != nil { - return nil, err - } - for _, m := range modules { - h.moduleIDs[m.ID] = true - h.moduleNames[m.ID] = m.Name - } - - // Load units for container hierarchy - units, _ := reader.ListUnits() - for _, u := range units { - h.containerParent[u.ID] = u.ContainerID - } - - // Load folders - folders, _ := reader.ListFolders() - for _, f := range folders { - h.folderNames[f.ID] = f.Name - h.containerParent[f.ID] = f.ContainerID - } - - return h, nil +// NewContainerHierarchy creates a new hierarchy from any source that provides +// modules, units, and folders (e.g. *mpr.Reader or backend.FullBackend). +func NewContainerHierarchy(src hierarchySource) (*ContainerHierarchy, error) { + return newContainerHierarchyImpl(src) } // NewContainerHierarchyFromBackend creates a new hierarchy from a Backend interface. func NewContainerHierarchyFromBackend(b backend.FullBackend) (*ContainerHierarchy, error) { + return newContainerHierarchyImpl(b) +} + +func newContainerHierarchyImpl(src hierarchySource) (*ContainerHierarchy, error) { h := &ContainerHierarchy{ moduleIDs: make(map[model.ID]bool), moduleNames: make(map[model.ID]string), @@ -64,7 +47,7 @@ func NewContainerHierarchyFromBackend(b backend.FullBackend) (*ContainerHierarch folderNames: make(map[model.ID]string), } - modules, err := b.ListModules() + modules, err := src.ListModules() if err != nil { return nil, err } @@ -73,12 +56,12 @@ func NewContainerHierarchyFromBackend(b backend.FullBackend) (*ContainerHierarch h.moduleNames[m.ID] = m.Name } - units, _ := b.ListUnits() + units, _ := src.ListUnits() for _, u := range units { h.containerParent[u.ID] = u.ContainerID } - folders, _ := b.ListFolders() + folders, _ := src.ListFolders() for _, f := range folders { h.folderNames[f.ID] = f.Name h.containerParent[f.ID] = f.ContainerID diff --git a/mdl/executor/widget_property.go b/mdl/executor/widget_property.go index e44652a8..75bdafc4 100644 --- a/mdl/executor/widget_property.go +++ b/mdl/executor/widget_property.go @@ -1,254 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -// Package executor - Widget property navigation for UPDATE WIDGETS +// Package executor - Widget tree walking for styling and introspection commands. package executor import ( - "fmt" - "reflect" - "strings" - - mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/pages" - "go.mongodb.org/mongo-driver/bson" ) -// getWidgetID extracts the ID from a widget. -func getWidgetID(widget any) string { - if widget == nil { - return "" - } - - // Use reflection to get the ID field - v := reflect.ValueOf(widget) - if v.Kind() == reflect.Pointer { - v = v.Elem() - } - if v.Kind() != reflect.Struct { - return "" - } - - // Try to get BaseWidget.ID - if baseWidget := v.FieldByName("BaseWidget"); baseWidget.IsValid() { - if idField := baseWidget.FieldByName("ID"); idField.IsValid() { - return fmt.Sprintf("%v", idField.Interface()) - } - } - - // Direct ID field - if idField := v.FieldByName("ID"); idField.IsValid() { - return fmt.Sprintf("%v", idField.Interface()) - } - - return "" -} - -// setWidgetProperty sets a property value on a widget by path. -func setWidgetProperty(widget any, path string, value any) error { - if widget == nil { - return mdlerrors.NewValidation("widget is nil") - } - - // Handle CustomWidget specifically - if cw, ok := widget.(*pages.CustomWidget); ok { - return setCustomWidgetProperty(cw, path, value) - } - - // For other widget types, use reflection to set simple fields - return setWidgetFieldByReflection(widget, path, value) -} - -// setCustomWidgetProperty sets a property on a CustomWidget. -func setCustomWidgetProperty(cw *pages.CustomWidget, path string, value any) error { - // CustomWidget can have properties in RawObject (BSON) or WidgetObject (structured) - if cw.RawObject != nil { - return updateBSONWidgetProperty(cw.RawObject, path, value) - } - - if cw.WidgetObject != nil { - return updateStructuredWidgetProperty(cw.WidgetObject, cw.PropertyTypeIDMap, path, value) - } - - return mdlerrors.NewValidation("widget has no property data") -} - -// updateBSONWidgetProperty updates a property in a BSON document. -func updateBSONWidgetProperty(doc bson.D, path string, value any) error { - // Find the "Object" field which contains widget properties - for i := range doc { - if doc[i].Key == "Object" { - if objDoc, ok := doc[i].Value.(bson.D); ok { - // Find Properties array - for j := range objDoc { - if objDoc[j].Key == "Properties" { - if props, ok := objDoc[j].Value.(bson.A); ok { - return updateBSONPropertyByKey(props, path, value) - } - } - } - } - } - } - return mdlerrors.NewNotFound("property", path) -} - -// updateBSONPropertyByKey finds and updates a property by key in a BSON array. -func updateBSONPropertyByKey(props bson.A, path string, value any) error { - for i, prop := range props { - if propDoc, ok := prop.(bson.D); ok { - // Find the Key field - for _, field := range propDoc { - if field.Key == "Key" { - if key, ok := field.Value.(string); ok && key == path { - // Found the property, now update its Value - return updateBSONPropertyValueAtIndex(props, i, value) - } - } - } - } - } - return mdlerrors.NewNotFound("property", path) -} - -// updateBSONPropertyValueAtIndex updates the Value field of a property at the given index. -func updateBSONPropertyValueAtIndex(props bson.A, index int, newValue any) error { - propDoc, ok := props[index].(bson.D) - if !ok { - return mdlerrors.NewValidation("property is not a BSON document") - } - - for i := range propDoc { - if propDoc[i].Key == "Value" { - if valueDoc, ok := propDoc[i].Value.(bson.D); ok { - // Find PrimitiveValue in the Value document - for j := range valueDoc { - if valueDoc[j].Key == "PrimitiveValue" { - valueDoc[j].Value = convertToBSONValue(newValue) - propDoc[i].Value = valueDoc - props[index] = propDoc - return nil - } - } - // PrimitiveValue not found, try to add it - valueDoc = append(valueDoc, bson.E{Key: "PrimitiveValue", Value: convertToBSONValue(newValue)}) - propDoc[i].Value = valueDoc - props[index] = propDoc - return nil - } - } - } - - return mdlerrors.NewValidation("Value field not found in property") -} - -// convertToBSONValue converts a Go value to appropriate BSON format. -func convertToBSONValue(value any) any { - switch v := value.(type) { - case bool: - if v { - return "true" - } - return "false" - case string: - return v - case int, int64, int32: - return fmt.Sprintf("%d", v) - case float64, float32: - return fmt.Sprintf("%v", v) - default: - return fmt.Sprintf("%v", v) - } -} - -// updateStructuredWidgetProperty updates a property in a structured WidgetObject. -func updateStructuredWidgetProperty(obj *pages.WidgetObject, typeMap map[string]pages.PropertyTypeIDEntry, path string, value any) error { - if obj == nil || obj.Properties == nil { - return mdlerrors.NewValidation("widget object has no properties") - } - - bsonValue := convertToBSONValue(value) - strValue, ok := bsonValue.(string) - if !ok { - strValue = fmt.Sprintf("%v", bsonValue) - } - - // Find the property by PropertyKey - for i, prop := range obj.Properties { - if prop.PropertyKey == path { - // Update the primitive value - if prop.Value != nil { - obj.Properties[i].Value.PrimitiveValue = strValue - return nil - } - // Create a value if it doesn't exist - obj.Properties[i].Value = &pages.WidgetValue{ - PrimitiveValue: strValue, - } - return nil - } - } - - // Check if we can find it via the type map (case-insensitive) - if typeMap != nil { - for key := range typeMap { - if strings.EqualFold(key, path) { - // Find the actual property - for i, prop := range obj.Properties { - if prop.PropertyKey == key { - if prop.Value != nil { - obj.Properties[i].Value.PrimitiveValue = strValue - return nil - } - obj.Properties[i].Value = &pages.WidgetValue{ - PrimitiveValue: strValue, - } - return nil - } - } - } - } - } - - return mdlerrors.NewNotFound("property", path) -} - -// setWidgetFieldByReflection sets a simple field on a widget using reflection. -func setWidgetFieldByReflection(widget any, fieldName string, value any) error { - v := reflect.ValueOf(widget) - if v.Kind() == reflect.Pointer { - v = v.Elem() - } - if v.Kind() != reflect.Struct { - return mdlerrors.NewValidation("widget is not a struct") - } - - field := v.FieldByName(fieldName) - if !field.IsValid() { - return mdlerrors.NewNotFound("field", fieldName) - } - if !field.CanSet() { - return mdlerrors.NewValidationf("field not settable: %s", fieldName) - } - - // Convert value to field type - fieldType := field.Type() - valueVal := reflect.ValueOf(value) - - if valueVal.Type().ConvertibleTo(fieldType) { - field.Set(valueVal.Convert(fieldType)) - return nil - } - - // Handle string conversion for common types - if fieldType.Kind() == reflect.String { - field.SetString(fmt.Sprintf("%v", value)) - return nil - } - - return mdlerrors.NewValidationf("cannot convert %T to %s", value, fieldType) -} - // walkPageWidgets walks all widgets in a page and calls the visitor function. func walkPageWidgets(page *pages.Page, visitor func(widget any) error) error { if page == nil || page.LayoutCall == nil { @@ -365,13 +123,3 @@ func walkWidget(widget pages.Widget, visitor func(widget any) error) error { return nil } - -// getSnippetByID finds a snippet by ID from the list. -func getSnippetByID(snippets []*pages.Snippet, id model.ID) *pages.Snippet { - for _, s := range snippets { - if s.ID == id { - return s - } - } - return nil -} diff --git a/mdl/repl/repl.go b/mdl/repl/repl.go index 5f0a9224..d82313d7 100644 --- a/mdl/repl/repl.go +++ b/mdl/repl/repl.go @@ -14,6 +14,8 @@ import ( "strings" "github.com/chzyer/readline" + "github.com/mendixlabs/mxcli/mdl/backend" + mprbackend "github.com/mendixlabs/mxcli/mdl/backend/mpr" "github.com/mendixlabs/mxcli/mdl/diaglog" "github.com/mendixlabs/mxcli/mdl/executor" "github.com/mendixlabs/mxcli/mdl/visitor" @@ -37,8 +39,10 @@ func (r *REPL) SetLogger(l *diaglog.Logger) { // New creates a new REPL with the given input and output. func New(input io.Reader, output io.Writer) *REPL { + exec := executor.New(output) + exec.SetBackendFactory(func() backend.FullBackend { return mprbackend.New() }) return &REPL{ - executor: executor.New(output), + executor: exec, input: input, output: output, prompt: "mdl> ", diff --git a/sdk/mpr/writer_navigation.go b/sdk/mpr/writer_navigation.go index 85845743..c24a4bb9 100644 --- a/sdk/mpr/writer_navigation.go +++ b/sdk/mpr/writer_navigation.go @@ -6,34 +6,21 @@ import ( "fmt" "strings" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "go.mongodb.org/mongo-driver/bson" ) // NavigationProfileSpec describes the desired state for a navigation profile. -type NavigationProfileSpec struct { - HomePages []NavHomePageSpec - LoginPage string // qualified page name, empty to clear - NotFoundPage string // qualified page name, empty to clear - MenuItems []NavMenuItemSpec - HasMenu bool // true = replace menu (even if empty) -} +// Aliased from mdl/types to avoid duplicate definitions. +type NavigationProfileSpec = types.NavigationProfileSpec // NavHomePageSpec describes a home page entry. -type NavHomePageSpec struct { - IsPage bool - Target string // qualified name - ForRole string // empty for default -} +type NavHomePageSpec = types.NavHomePageSpec // NavMenuItemSpec describes a menu item. -type NavMenuItemSpec struct { - Caption string - Page string - Microflow string - Items []NavMenuItemSpec -} +type NavMenuItemSpec = types.NavMenuItemSpec // UpdateNavigationProfile patches a navigation profile's home pages, login page, and menu. func (w *Writer) UpdateNavigationProfile(navDocID model.ID, profileName string, spec NavigationProfileSpec) error { From 7fbff06795ae9651d1cf2068c2999bb0552769e3 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Sun, 19 Apr 2026 19:25:09 +0200 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20improve=20code=20quality=20?= =?UTF-8?q?=E2=80=94=20deterministic=20output,=20doc=20comments,=20naming?= =?UTF-8?q?=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure deterministic map iteration order for serialization output. Add doc comments on all exported backend interfaces. Deduplicate IDToBsonBinary into single mdl/bsonutil implementation. Rename reader references to backend across executor. Guard float64-to-int64 cast with safe precision bounds. Apply go fmt formatting. --- .gitignore | 1 + mdl/ast/ast_agenteditor.go | 60 +++---- mdl/backend/connection.go | 12 ++ mdl/backend/infrastructure.go | 24 ++- mdl/backend/mapping.go | 2 + mdl/backend/microflow.go | 2 +- mdl/backend/mock/backend.go | 28 ++-- mdl/backend/mock/mock_infrastructure.go | 2 +- mdl/backend/mock/mock_java.go | 2 +- mdl/backend/mock/mock_mapping.go | 2 +- mdl/backend/mock/mock_navigation.go | 2 +- mdl/backend/mock/mock_security.go | 2 +- mdl/backend/mock/mock_workflow.go | 2 +- mdl/backend/mpr/datagrid_builder.go | 20 ++- mdl/backend/mpr/datagrid_builder_test.go | 14 +- mdl/backend/mpr/page_mutator.go | 8 + mdl/backend/mpr/widget_builder.go | 157 ++++++------------ mdl/backend/mpr/workflow_mutator.go | 3 +- mdl/backend/mutation.go | 52 ++++-- mdl/backend/page.go | 2 +- mdl/backend/workflow.go | 20 --- mdl/bsonutil/bsonutil.go | 28 +++- mdl/bsonutil/bsonutil_test.go | 33 ++++ mdl/catalog/builder.go | 2 +- mdl/executor/bugfix_regression_test.go | 2 +- mdl/executor/bugfix_test.go | 2 +- mdl/executor/cmd_alter_page.go | 11 +- mdl/executor/cmd_businessevents.go | 2 +- mdl/executor/cmd_catalog.go | 2 +- mdl/executor/cmd_entities.go | 6 +- mdl/executor/cmd_export_mappings.go | 2 +- mdl/executor/cmd_folders.go | 2 +- mdl/executor/cmd_import_mappings.go | 2 +- mdl/executor/cmd_microflows_builder.go | 4 +- .../cmd_microflows_builder_actions.go | 24 +-- .../cmd_microflows_builder_annotations.go | 2 +- mdl/executor/cmd_microflows_builder_calls.go | 18 +- .../cmd_microflows_builder_control.go | 6 +- mdl/executor/cmd_microflows_builder_flows.go | 4 +- mdl/executor/cmd_microflows_builder_graph.go | 2 +- .../cmd_microflows_builder_workflow.go | 2 +- mdl/executor/cmd_microflows_create.go | 4 +- mdl/executor/cmd_microflows_helpers.go | 6 +- mdl/executor/cmd_pages_builder.go | 1 + mdl/executor/cmd_pages_builder_input.go | 2 +- mdl/executor/cmd_pages_builder_v3.go | 9 +- mdl/executor/cmd_pages_builder_v3_layout.go | 5 +- mdl/executor/cmd_security_write.go | 6 +- mdl/executor/cmd_structure.go | 16 +- mdl/executor/cmd_workflows_write.go | 2 +- mdl/executor/executor.go | 7 +- mdl/executor/executor_connect.go | 3 +- mdl/executor/mock_test_helpers_test.go | 4 +- mdl/executor/widget_engine_test.go | 11 -- mdl/executor/widget_registry_test.go | 2 +- mdl/types/asyncapi.go | 4 +- mdl/types/doc.go | 7 +- mdl/types/edmx.go | 8 +- mdl/types/id.go | 32 ++-- mdl/types/json_utils.go | 20 ++- sdk/mpr/reader_types.go | 2 +- sdk/mpr/utils.go | 9 +- sdk/mpr/writer_agenteditor_agent.go | 24 +-- sdk/mpr/writer_core.go | 3 +- sdk/mpr/writer_jsonstructure.go | 2 - 65 files changed, 419 insertions(+), 343 deletions(-) diff --git a/.gitignore b/.gitignore index d3c2da75..a26e134d 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ grammardoc # Snap tool binary (used for BSON fixture generation) snap-bson +.playwright-cli/ diff --git a/mdl/ast/ast_agenteditor.go b/mdl/ast/ast_agenteditor.go index 1edd7a05..d1585840 100644 --- a/mdl/ast/ast_agenteditor.go +++ b/mdl/ast/ast_agenteditor.go @@ -16,16 +16,16 @@ package ast // [, DeepLinkURL: '...'] // ); type CreateModelStmt struct { - Name QualifiedName - Documentation string - Provider string // "MxCloudGenAI" by default - Key *QualifiedName // qualified name of the String constant holding the Portal key - DisplayName string // optional Portal-populated metadata - KeyName string // optional Portal-populated metadata - KeyID string // optional Portal-populated metadata - Environment string // optional Portal-populated metadata - ResourceName string // optional Portal-populated metadata - DeepLinkURL string // optional Portal-populated metadata + Name QualifiedName + Documentation string + Provider string // "MxCloudGenAI" by default + Key *QualifiedName // qualified name of the String constant holding the Portal key + DisplayName string // optional Portal-populated metadata + KeyName string // optional Portal-populated metadata + KeyID string // optional Portal-populated metadata + Environment string // optional Portal-populated metadata + ResourceName string // optional Portal-populated metadata + DeepLinkURL string // optional Portal-populated metadata } func (s *CreateModelStmt) isStatement() {} @@ -93,21 +93,21 @@ func (s *DropKnowledgeBaseStmt) isStatement() {} // CreateAgentStmt represents CREATE AGENT Module.Name (...) [{ body }]. type CreateAgentStmt struct { - Name QualifiedName + Name QualifiedName Documentation string - UsageType string // "Task" or "Conversational" - Description string - Model *QualifiedName // reference to a Model document - Entity *QualifiedName // reference to a domain entity - MaxTokens *int - ToolChoice string - Temperature *float64 - TopP *float64 - SystemPrompt string - UserPrompt string - Variables []AgentVarDef - Tools []AgentToolDef - KBTools []AgentKBToolDef + UsageType string // "Task" or "Conversational" + Description string + Model *QualifiedName // reference to a Model document + Entity *QualifiedName // reference to a domain entity + MaxTokens *int + ToolChoice string + Temperature *float64 + TopP *float64 + SystemPrompt string + UserPrompt string + Variables []AgentVarDef + Tools []AgentToolDef + KBTools []AgentKBToolDef } func (s *CreateAgentStmt) isStatement() {} @@ -136,10 +136,10 @@ type AgentToolDef struct { // AgentKBToolDef represents a KNOWLEDGE BASE block in CREATE AGENT body. type AgentKBToolDef struct { - Name string // per-agent identifier - Source *QualifiedName - Collection string - MaxResults int - Description string - Enabled bool + Name string // per-agent identifier + Source *QualifiedName + Collection string + MaxResults int + Description string + Enabled bool } diff --git a/mdl/backend/connection.go b/mdl/backend/connection.go index c1d3594a..02b4cb16 100644 --- a/mdl/backend/connection.go +++ b/mdl/backend/connection.go @@ -25,24 +25,36 @@ type ConnectionBackend interface { // ProjectVersion returns the Mendix project version. ProjectVersion() *types.ProjectVersion // GetMendixVersion returns the Mendix version string. + // NOTE: uses Get prefix unlike Version()/ProjectVersion() for historical SDK compatibility. GetMendixVersion() (string, error) } // ModuleBackend provides module-level operations. type ModuleBackend interface { + // ListModules returns all modules in the project. ListModules() ([]*model.Module, error) + // GetModule returns a module by ID. GetModule(id model.ID) (*model.Module, error) + // GetModuleByName returns a module by name. GetModuleByName(name string) (*model.Module, error) + // CreateModule adds a new module to the project. CreateModule(module *model.Module) error + // UpdateModule persists changes to an existing module. UpdateModule(module *model.Module) error + // DeleteModule removes a module by ID. DeleteModule(id model.ID) error + // DeleteModuleWithCleanup removes a module and cleans up associated documents. DeleteModuleWithCleanup(id model.ID, moduleName string) error } // FolderBackend provides folder operations. type FolderBackend interface { + // ListFolders returns all folders in the project. ListFolders() ([]*types.FolderInfo, error) + // CreateFolder adds a new folder. CreateFolder(folder *model.Folder) error + // DeleteFolder removes a folder by ID. DeleteFolder(id model.ID) error + // MoveFolder moves a folder to a new container. MoveFolder(id model.ID, newContainerID model.ID) error } diff --git a/mdl/backend/infrastructure.go b/mdl/backend/infrastructure.go index 779a2b19..af1db489 100644 --- a/mdl/backend/infrastructure.go +++ b/mdl/backend/infrastructure.go @@ -16,7 +16,7 @@ type RenameBackend interface { } // RawUnitBackend provides low-level unit access for operations that -// manipulate raw BSON (e.g. widget patching, alter page/workflow). +// manipulate raw unit contents (e.g. widget patching, alter page/workflow). type RawUnitBackend interface { GetRawUnit(id model.ID) (map[string]any, error) GetRawUnitBytes(id model.ID) ([]byte, error) @@ -24,6 +24,8 @@ type RawUnitBackend interface { ListRawUnits(objectType string) ([]*types.RawUnitInfo, error) GetRawUnitByName(objectType, qualifiedName string) (*types.RawUnitInfo, error) GetRawMicroflowByName(qualifiedName string) ([]byte, error) + // UpdateRawUnit replaces the contents of a unit by ID. + // Takes string (not model.ID) to match the SDK writer layer convention. UpdateRawUnit(unitID string, contents []byte) error } @@ -45,6 +47,7 @@ type WidgetBackend interface { } // AgentEditorBackend provides agent editor document operations. +// Delete methods take string IDs to match the SDK writer layer convention. type AgentEditorBackend interface { ListAgentEditorModels() ([]*agenteditor.Model, error) ListAgentEditorKnowledgeBases() ([]*agenteditor.KnowledgeBase, error) @@ -59,3 +62,22 @@ type AgentEditorBackend interface { CreateAgentEditorAgent(a *agenteditor.Agent) error DeleteAgentEditorAgent(id string) error } + +// SettingsBackend provides project settings operations. +type SettingsBackend interface { + GetProjectSettings() (*model.ProjectSettings, error) + UpdateProjectSettings(ps *model.ProjectSettings) error +} + +// ImageBackend provides image collection operations. +type ImageBackend interface { + ListImageCollections() ([]*types.ImageCollection, error) + CreateImageCollection(ic *types.ImageCollection) error + DeleteImageCollection(id string) error +} + +// ScheduledEventBackend provides scheduled event operations. +type ScheduledEventBackend interface { + ListScheduledEvents() ([]*model.ScheduledEvent, error) + GetScheduledEvent(id model.ID) (*model.ScheduledEvent, error) +} diff --git a/mdl/backend/mapping.go b/mdl/backend/mapping.go index 9bbf1efe..1018f39b 100644 --- a/mdl/backend/mapping.go +++ b/mdl/backend/mapping.go @@ -26,5 +26,7 @@ type MappingBackend interface { ListJsonStructures() ([]*types.JsonStructure, error) GetJsonStructureByQualifiedName(moduleName, name string) (*types.JsonStructure, error) CreateJsonStructure(js *types.JsonStructure) error + // DeleteJsonStructure removes a JSON structure by ID. + // Takes string (not model.ID) to match the SDK writer layer convention. DeleteJsonStructure(id string) error } diff --git a/mdl/backend/microflow.go b/mdl/backend/microflow.go index 65596ce4..1b788720 100644 --- a/mdl/backend/microflow.go +++ b/mdl/backend/microflow.go @@ -17,7 +17,7 @@ type MicroflowBackend interface { 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. + // 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) diff --git a/mdl/backend/mock/backend.go b/mdl/backend/mock/backend.go index 0b32c931..0a30eed9 100644 --- a/mdl/backend/mock/backend.go +++ b/mdl/backend/mock/backend.go @@ -7,12 +7,12 @@ package mock import ( "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/agenteditor" "github.com/mendixlabs/mxcli/sdk/domainmodel" "github.com/mendixlabs/mxcli/sdk/javaactions" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/sdk/pages" "github.com/mendixlabs/mxcli/sdk/security" "github.com/mendixlabs/mxcli/sdk/workflows" @@ -75,19 +75,19 @@ type MockBackend struct { UpdateEnumerationRefsInAllDomainModelsFunc func(oldQualifiedName, newQualifiedName string) error // MicroflowBackend - ListMicroflowsFunc func() ([]*microflows.Microflow, error) - GetMicroflowFunc func(id model.ID) (*microflows.Microflow, error) - CreateMicroflowFunc func(mf *microflows.Microflow) error - UpdateMicroflowFunc func(mf *microflows.Microflow) error - DeleteMicroflowFunc func(id model.ID) 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 - UpdateNanoflowFunc func(nf *microflows.Nanoflow) error - DeleteNanoflowFunc func(id model.ID) error - MoveNanoflowFunc func(nf *microflows.Nanoflow) error + ListMicroflowsFunc func() ([]*microflows.Microflow, error) + GetMicroflowFunc func(id model.ID) (*microflows.Microflow, error) + CreateMicroflowFunc func(mf *microflows.Microflow) error + UpdateMicroflowFunc func(mf *microflows.Microflow) error + DeleteMicroflowFunc func(id model.ID) 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 + UpdateNanoflowFunc func(nf *microflows.Nanoflow) error + DeleteNanoflowFunc func(id model.ID) error + MoveNanoflowFunc func(nf *microflows.Nanoflow) error // PageBackend ListPagesFunc func() ([]*pages.Page, error) diff --git a/mdl/backend/mock/mock_infrastructure.go b/mdl/backend/mock/mock_infrastructure.go index 0e033f08..c643077a 100644 --- a/mdl/backend/mock/mock_infrastructure.go +++ b/mdl/backend/mock/mock_infrastructure.go @@ -3,9 +3,9 @@ package mock import ( + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/agenteditor" - "github.com/mendixlabs/mxcli/mdl/types" ) // --------------------------------------------------------------------------- diff --git a/mdl/backend/mock/mock_java.go b/mdl/backend/mock/mock_java.go index c44ce357..a37c3987 100644 --- a/mdl/backend/mock/mock_java.go +++ b/mdl/backend/mock/mock_java.go @@ -3,9 +3,9 @@ package mock import ( + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/javaactions" - "github.com/mendixlabs/mxcli/mdl/types" ) func (m *MockBackend) ListJavaActions() ([]*types.JavaAction, error) { diff --git a/mdl/backend/mock/mock_mapping.go b/mdl/backend/mock/mock_mapping.go index 27b7b0bc..8fd6704b 100644 --- a/mdl/backend/mock/mock_mapping.go +++ b/mdl/backend/mock/mock_mapping.go @@ -3,8 +3,8 @@ package mock import ( - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" ) func (m *MockBackend) ListImportMappings() ([]*model.ImportMapping, error) { diff --git a/mdl/backend/mock/mock_navigation.go b/mdl/backend/mock/mock_navigation.go index e993bc80..b0940d9a 100644 --- a/mdl/backend/mock/mock_navigation.go +++ b/mdl/backend/mock/mock_navigation.go @@ -3,8 +3,8 @@ package mock import ( - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" ) func (m *MockBackend) ListNavigationDocuments() ([]*types.NavigationDocument, error) { diff --git a/mdl/backend/mock/mock_security.go b/mdl/backend/mock/mock_security.go index 214f907e..cbe18112 100644 --- a/mdl/backend/mock/mock_security.go +++ b/mdl/backend/mock/mock_security.go @@ -4,8 +4,8 @@ package mock import ( "github.com/mendixlabs/mxcli/mdl/backend" - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/security" ) diff --git a/mdl/backend/mock/mock_workflow.go b/mdl/backend/mock/mock_workflow.go index 73ea9fe0..c29a4539 100644 --- a/mdl/backend/mock/mock_workflow.go +++ b/mdl/backend/mock/mock_workflow.go @@ -3,8 +3,8 @@ package mock import ( - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/workflows" ) diff --git a/mdl/backend/mpr/datagrid_builder.go b/mdl/backend/mpr/datagrid_builder.go index 511ff9fa..5768aabc 100644 --- a/mdl/backend/mpr/datagrid_builder.go +++ b/mdl/backend/mpr/datagrid_builder.go @@ -4,6 +4,8 @@ package mprbackend import ( "fmt" + "log" + "sort" "strings" "go.mongodb.org/mongo-driver/bson" @@ -437,7 +439,15 @@ func (b *MprBackend) buildDataGrid2ColumnObject(col *backend.DataGridColumnSpec, properties := bson.A{int32(2)} - for key, entry := range columnPropertyIDs { + // Sort keys for deterministic BSON output. + colKeys := make([]string, 0, len(columnPropertyIDs)) + for k := range columnPropertyIDs { + colKeys = append(colKeys, k) + } + sort.Strings(colKeys) + + for _, key := range colKeys { + entry := columnPropertyIDs[key] switch key { case "showContentAs": if hasCustomContent { @@ -632,7 +642,7 @@ func (b *MprBackend) applyDataGridPagingProps(obj bson.D, propertyTypeIDs map[st result := make(bson.D, 0, len(obj)) for _, elem := range obj { if elem.Key == "Properties" { - if propsArr, ok := elem.Value.(bson.A); ok { + if propsArr, ok := elem.Value.(bson.A); ok && len(propsArr) > 0 { updatedProps := bson.A{propsArr[0]} for _, propVal := range propsArr[1:] { propMap, ok := propVal.(bson.D) @@ -699,6 +709,9 @@ func (b *MprBackend) applyDataGridSelectionProp(obj bson.D, propertyTypeIDs map[ // BSON property builders (package-level, no receiver needed) // =========================================================================== +// buildDataGrid2Property builds a single property BSON document for a DataGrid2 column. +// attrRef and primitiveValue are reserved for future column types that require direct +// attribute references or primitive default values; current callers pass empty strings. func buildDataGrid2Property(entry pages.PropertyTypeIDEntry, datasource pages.DataSource, attrRef string, primitiveValue string, _ *MprBackend) bson.D { var datasourceBSON any if datasource != nil { @@ -1177,6 +1190,9 @@ func colPropInt(props map[string]any, key string, defaultVal string) string { func (b *MprBackend) buildFilterWidgetBSON(widgetID, filterName string, projectPath string) bson.D { rawType, rawObject, _, _, err := widgets.GetTemplateFullBSON(widgetID, types.GenerateID, projectPath) if err != nil || rawType == nil { + if err != nil { + log.Printf("WARNING: failed to load template for widget %s: %v; using minimal fallback", widgetID, err) + } return b.buildMinimalFilterWidgetBSON(widgetID, filterName) } diff --git a/mdl/backend/mpr/datagrid_builder_test.go b/mdl/backend/mpr/datagrid_builder_test.go index b6ac2c43..963331d5 100644 --- a/mdl/backend/mpr/datagrid_builder_test.go +++ b/mdl/backend/mpr/datagrid_builder_test.go @@ -3,11 +3,13 @@ package mprbackend import ( + "bytes" "testing" - "github.com/mendixlabs/mxcli/mdl/bsonutil" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/bsonutil" ) func TestDeepCloneWithNewIDs_RegeneratesAllIDs(t *testing.T) { @@ -127,13 +129,5 @@ func binaryEqual(a, b any) bool { if !aOk || !bOk { return false } - if len(ab.Data) != len(bb.Data) { - return false - } - for i := range ab.Data { - if ab.Data[i] != bb.Data[i] { - return false - } - } - return true + return bytes.Equal(ab.Data, bb.Data) } diff --git a/mdl/backend/mpr/page_mutator.go b/mdl/backend/mpr/page_mutator.go index c89a7058..e6f85ff7 100644 --- a/mdl/backend/mpr/page_mutator.go +++ b/mdl/backend/mpr/page_mutator.go @@ -145,6 +145,7 @@ func (m *mprPageMutator) InsertWidget(widgetRef string, columnRef string, positi func (m *mprPageMutator) DropWidget(refs []backend.WidgetRef) error { for _, ref := range refs { + // Re-find widget each iteration because previous drops mutate the tree. var result *bsonWidgetResult if ref.IsColumn() { result = findBsonColumn(m.rawData, ref.Widget, ref.Column, m.widgetFinder) @@ -519,6 +520,11 @@ func dGetString(doc bson.D, key string) string { } // dSet sets a field value in a bson.D in place. Returns true if found. +// NOTE: callers generally do not check the return value because the keys +// are structurally guaranteed by the widgetFinder traversal. If a key +// is absent, the mutation is silently skipped — this is intentional for +// optional fields (e.g. Appearance, DataSource) that may not be present +// on every widget type. func dSet(doc bson.D, key string, value any) bool { for i := range doc { if doc[i].Key == key { @@ -1340,6 +1346,8 @@ func setWidgetContentMut(widget bson.D, value any) error { return fmt.Errorf("Content.Template has no Items with Text") } +// setWidgetLabelMut sets the widget's Label caption. Returns nil without error +// if the widget has no Label field — not all widget types support labels. func setWidgetLabelMut(widget bson.D, value any) error { label := dGetDoc(widget, "Label") if label == nil { diff --git a/mdl/backend/mpr/widget_builder.go b/mdl/backend/mpr/widget_builder.go index 7f707c64..d3f167b2 100644 --- a/mdl/backend/mpr/widget_builder.go +++ b/mdl/backend/mpr/widget_builder.go @@ -3,10 +3,10 @@ package mprbackend import ( - "encoding/hex" "fmt" "log" "regexp" + "sort" "strings" "go.mongodb.org/mongo-driver/bson" @@ -225,6 +225,7 @@ func (ob *mprWidgetObjectBuilder) SetAttributeObjects(propertyKey string, attrib for _, attrPath := range attributePaths { attrObj, err := createAttributeObject(attrPath, entry.ObjectTypeID, nestedEntry.PropertyTypeID, nestedEntry.ValueTypeID) if err != nil { + // TODO(shared-types): propagate error instead of logging — requires interface change. log.Printf("warning: skipping attribute %s: %v", attrPath, err) continue } @@ -363,6 +364,7 @@ func updatePropertyInArray(arr bson.A, propertyTypeID string, updateFn func(bson } } if !matched { + // TODO(shared-types): propagate warning instead of logging — requires interface change. log.Printf("WARNING: updatePropertyInArray: no match for TypePointer %s in %d properties", propertyTypeID, len(arr)-1) } return result @@ -589,17 +591,17 @@ func createClientTemplateBSONWithParams(text string, entityContext string) bson. attrPath = entityContext + "." + attrName } params = append(params, bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "Forms$ClientTemplateParameter"}, {Key: "AttributeRef", Value: bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {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: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "Forms$FormattingInfo"}, {Key: "CustomDateFormat", Value: ""}, {Key: "DateFormat", Value: "Date"}, @@ -614,10 +616,10 @@ func createClientTemplateBSONWithParams(text string, entityContext string) bson. makeText := func(t string) bson.D { return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "Texts$Text"}, {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "Texts$Translation"}, {Key: "LanguageCode", Value: "en_US"}, {Key: "Text", Value: t}, @@ -626,7 +628,7 @@ func createClientTemplateBSONWithParams(text string, entityContext string) bson. } return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "Forms$ClientTemplate"}, {Key: "Fallback", Value: makeText(paramText)}, {Key: "Parameters", Value: params}, @@ -637,10 +639,10 @@ func createClientTemplateBSONWithParams(text string, entityContext string) bson. func createDefaultClientTemplateBSON(text string) bson.D { makeText := func(t string) bson.D { return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "Texts$Text"}, {Key: "Items", Value: bson.A{int32(3), bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "Texts$Translation"}, {Key: "LanguageCode", Value: "en_US"}, {Key: "Text", Value: t}, @@ -648,7 +650,7 @@ func createDefaultClientTemplateBSON(text string) bson.D { } } return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "Forms$ClientTemplate"}, {Key: "Fallback", Value: makeText(text)}, {Key: "Parameters", Value: bson.A{int32(2)}}, @@ -656,84 +658,6 @@ func createDefaultClientTemplateBSON(text string) bson.D { } } -// --------------------------------------------------------------------------- -// 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 // --------------------------------------------------------------------------- @@ -762,7 +686,15 @@ func convertPropertyTypeIDs(src map[string]widgets.PropertyTypeIDEntry) map[stri // --------------------------------------------------------------------------- func ensureRequiredObjectLists(obj bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry) bson.D { - for propKey, entry := range propertyTypeIDs { + // Sort keys for deterministic BSON output. + keys := make([]string, 0, len(propertyTypeIDs)) + for k := range propertyTypeIDs { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, propKey := range keys { + entry := propertyTypeIDs[propKey] if entry.ObjectTypeID == "" || len(entry.NestedPropertyIDs) == 0 { continue } @@ -814,23 +746,30 @@ func ensureRequiredObjectLists(obj bson.D, propertyTypeIDs map[string]pages.Prop func createDefaultWidgetObject(objectTypeID string, nestedProps map[string]pages.PropertyTypeIDEntry) bson.D { propsArr := bson.A{int32(2)} - for _, entry := range nestedProps { + // Sort keys for deterministic BSON output. + nestedKeys := make([]string, 0, len(nestedProps)) + for k := range nestedProps { + nestedKeys = append(nestedKeys, k) + } + sort.Strings(nestedKeys) + for _, k := range nestedKeys { + entry := nestedProps[k] prop := createDefaultWidgetProperty(entry) propsArr = append(propsArr, prop) } return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "TypePointer", Value: hexIDToBlob(objectTypeID)}, + {Key: "TypePointer", Value: types.UUIDToBlob(objectTypeID)}, {Key: "Properties", Value: propsArr}, } } func createDefaultWidgetProperty(entry pages.PropertyTypeIDEntry) bson.D { return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: hexIDToBlob(entry.PropertyTypeID)}, + {Key: "TypePointer", Value: types.UUIDToBlob(entry.PropertyTypeID)}, {Key: "Value", Value: createDefaultWidgetValue(entry)}, } } @@ -838,7 +777,7 @@ func createDefaultWidgetProperty(entry pages.PropertyTypeIDEntry) bson.D { func createDefaultWidgetValue(entry pages.PropertyTypeIDEntry) bson.D { primitiveVal := entry.DefaultValue expressionVal := "" - var textTemplate interface{} + var textTemplate any switch entry.ValueType { case "Expression": @@ -857,10 +796,10 @@ func createDefaultWidgetValue(entry pages.PropertyTypeIDEntry) bson.D { } return bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: generateBinaryID()}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "Forms$NoAction"}, {Key: "DisabledDuringExecution", Value: true}, }}, @@ -879,7 +818,7 @@ func createDefaultWidgetValue(entry pages.PropertyTypeIDEntry) bson.D { {Key: "SourceVariable", Value: nil}, {Key: "TextTemplate", Value: textTemplate}, {Key: "TranslatableValue", Value: nil}, - {Key: "TypePointer", Value: hexIDToBlob(entry.ValueTypeID)}, + {Key: "TypePointer", Value: types.UUIDToBlob(entry.ValueTypeID)}, {Key: "Widgets", Value: bson.A{int32(2)}}, {Key: "XPathConstraint", Value: ""}, } @@ -954,24 +893,24 @@ func createAttributeObject(attributePath string, objectTypeID, propertyTypeID, v return nil, mdlerrors.NewValidationf("invalid attribute path %q: expected Module.Entity.Attribute format", attributePath) } return bson.D{ - {Key: "$ID", Value: hexToBytes(types.GenerateID())}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, - {Key: "Properties", Value: []any{ + {Key: "Properties", Value: bson.A{ int32(2), bson.D{ - {Key: "$ID", Value: hexToBytes(types.GenerateID())}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, - {Key: "TypePointer", Value: hexToBytes(propertyTypeID)}, + {Key: "TypePointer", Value: types.UUIDToBlob(propertyTypeID)}, {Key: "Value", Value: bson.D{ - {Key: "$ID", Value: hexToBytes(types.GenerateID())}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, {Key: "Action", Value: bson.D{ - {Key: "$ID", Value: hexToBytes(types.GenerateID())}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "Forms$NoAction"}, {Key: "DisabledDuringExecution", Value: true}, }}, {Key: "AttributeRef", Value: bson.D{ - {Key: "$ID", Value: hexToBytes(types.GenerateID())}, + {Key: "$ID", Value: types.UUIDToBlob(types.GenerateID())}, {Key: "$Type", Value: "DomainModels$AttributeRef"}, {Key: "Attribute", Value: attributePath}, {Key: "EntityRef", Value: nil}, @@ -984,18 +923,18 @@ func createAttributeObject(attributePath string, objectTypeID, propertyTypeID, v {Key: "Image", Value: ""}, {Key: "Microflow", Value: ""}, {Key: "Nanoflow", Value: ""}, - {Key: "Objects", Value: []any{int32(2)}}, + {Key: "Objects", Value: bson.A{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: "TypePointer", Value: types.UUIDToBlob(valueTypeID)}, + {Key: "Widgets", Value: bson.A{int32(2)}}, {Key: "XPathConstraint", Value: ""}, }}, }, }}, - {Key: "TypePointer", Value: hexToBytes(objectTypeID)}, + {Key: "TypePointer", Value: types.UUIDToBlob(objectTypeID)}, }, nil } diff --git a/mdl/backend/mpr/workflow_mutator.go b/mdl/backend/mpr/workflow_mutator.go index 1aa4ca4c..01c00238 100644 --- a/mdl/backend/mpr/workflow_mutator.go +++ b/mdl/backend/mpr/workflow_mutator.go @@ -15,7 +15,8 @@ import ( "github.com/mendixlabs/mxcli/sdk/workflows" ) -// bsonArrayMarker is the Mendix BSON array type marker (storageListType 3). +// bsonArrayMarker is the Mendix BSON array type marker (storageListType 3 = reference/association lists). +// Contrast with int32(2) used for object lists (storageListType 2). const bsonArrayMarker = int32(3) // Compile-time check. diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index 5a6986d5..37f35c0b 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -51,7 +51,7 @@ 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 != "" { + if r.IsColumn() { return r.Widget + "." + r.Column } return r.Widget @@ -60,6 +60,11 @@ func (r WidgetRef) Name() string { // 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. +// +// Widget addressing: most methods accept a widgetRef string (widget name). +// Column-aware operations additionally accept a columnRef string. +// DropWidget uses []WidgetRef to support mixed widget/column targets in a +// single call. type PageMutator interface { // ContainerType returns the kind of container (page, layout, or snippet). ContainerType() ContainerKind @@ -134,16 +139,16 @@ type PageMutator interface { // PluggablePropertyContext carries operation-specific values for // SetPluggableProperty. Only fields relevant to the operation are used. type PluggablePropertyContext struct { - AttributePath string // "attribute", "association" - AttributePaths []string // "attributeObjects" - AssocPath string // "association" - EntityName string // "association" - PrimitiveVal string // "primitive" - DataSource pages.DataSource // "datasource" - ChildWidgets []pages.Widget // "widgets" + AttributePath string // "attribute", "association" + AttributePaths []string // "attributeObjects" + AssocPath string // "association" + EntityName string // "association" + PrimitiveVal string // "primitive" + DataSource pages.DataSource // "datasource" + ChildWidgets []pages.Widget // "widgets" Action pages.ClientAction // "action" - TextTemplate string // "texttemplate" - Selection string // "selection" + TextTemplate string // "texttemplate" + Selection string // "selection" } // WorkflowMutator provides fine-grained mutation operations on a single @@ -185,17 +190,26 @@ type WorkflowMutator interface { // --- Path operations (parallel split) --- + // InsertPath adds a new path to a parallel split activity. InsertPath(activityRef string, atPos int, pathCaption string, activities []workflows.WorkflowActivity) error + + // DropPath removes a path by caption from a parallel split activity. DropPath(activityRef string, atPos int, pathCaption string) error // --- Branch operations (exclusive split) --- + // InsertBranch adds a new branch with a condition to an exclusive split activity. InsertBranch(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error + + // DropBranch removes a branch by name from an exclusive split activity. DropBranch(activityRef string, atPos int, branchName string) error // --- Boundary event operations --- + // InsertBoundaryEvent adds a boundary event to the referenced activity. InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error + + // DropBoundaryEvent removes the boundary event from the referenced activity. DropBoundaryEvent(activityRef string, atPos int) error // Save persists the mutations to the backend. @@ -236,7 +250,7 @@ type WidgetSerializationBackend interface { SerializeWorkflowActivity(a workflows.WorkflowActivity) (any, error) } -// WidgetObjectBuilder provides BSON-free operations on a loaded pluggable widget template. +// WidgetObjectBuilder provides storage-agnostic operations on a loaded pluggable widget template. // The executor calls these methods with domain-typed values; the backend handles // all storage-specific manipulation internally. // @@ -244,6 +258,8 @@ type WidgetSerializationBackend interface { type WidgetObjectBuilder interface { // --- Property operations --- // Each operation finds the property by key (via TypePointer matching) and updates its value. + // Set* methods do not return errors — invalid operations are logged as warnings + // and deferred to Finalize, which returns the aggregate result. SetAttribute(propertyKey string, attributePath string) SetAssociation(propertyKey string, assocPath string, entityName string) @@ -275,7 +291,7 @@ type WidgetObjectBuilder interface { // --- Finalize --- // Finalize builds the CustomWidget from the mutated template. - // Returns the widget with RawType/RawObject set from the internal BSON state. + // Returns the widget with RawType/RawObject populated from internal state. Finalize(id model.ID, name string, label string, editable string) *pages.CustomWidget } @@ -283,10 +299,10 @@ type WidgetObjectBuilder interface { // All attribute paths are fully qualified. Child widgets are already built as // domain objects; the backend serializes them to storage format internally. type DataGridColumnSpec struct { - Attribute string // Fully qualified attribute path (empty for action/custom-content columns) - Caption string // Column header caption - ChildWidgets []pages.Widget // Pre-built child widgets (for custom-content columns) - Properties map[string]any // Column properties (Sortable, Resizable, Visible, etc.) + Attribute string // Fully qualified attribute path (empty for action/custom-content columns) + Caption string // Column header caption + ChildWidgets []pages.Widget // Pre-built child widgets (for custom-content columns) + Properties map[string]any // Column properties (Sortable, Resizable, Visible, etc.) } // DataGridSpec carries all inputs needed to build a DataGrid2 widget object. @@ -318,7 +334,7 @@ type WidgetBuilderBackend interface { SerializeWidgetToOpaque(w pages.Widget) any // SerializeDataSourceToOpaque converts a domain DataSource to an opaque - // form suitable for embedding in widget property BSON. + // form suitable for embedding in widget properties. SerializeDataSourceToOpaque(ds pages.DataSource) any // BuildCreateAttributeObject creates an attribute object for filter widgets. @@ -326,7 +342,7 @@ type WidgetBuilderBackend interface { BuildCreateAttributeObject(attributePath string, objectTypeID, propertyTypeID, valueTypeID string) (any, error) // BuildDataGrid2Widget builds a complete DataGrid2 CustomWidget from domain-typed inputs. - // The backend loads the template, constructs the BSON object with columns, + // The backend loads the template, constructs the storage object with columns, // datasource, header widgets, paging, and selection, and returns a fully // assembled CustomWidget. Returns the widget with an opaque RawType/RawObject. BuildDataGrid2Widget(id model.ID, name string, spec DataGridSpec, projectPath string) (*pages.CustomWidget, error) diff --git a/mdl/backend/page.go b/mdl/backend/page.go index 9aeb4b80..21f5a890 100644 --- a/mdl/backend/page.go +++ b/mdl/backend/page.go @@ -24,7 +24,7 @@ type PageBackend interface { UpdateLayout(layout *pages.Layout) error DeleteLayout(id model.ID) error - // Snippets + // Snippets — no GetSnippet: snippets are resolved by qualified name via ListSnippets. ListSnippets() ([]*pages.Snippet, error) CreateSnippet(snippet *pages.Snippet) error UpdateSnippet(snippet *pages.Snippet) error diff --git a/mdl/backend/workflow.go b/mdl/backend/workflow.go index 87ce241d..77879976 100644 --- a/mdl/backend/workflow.go +++ b/mdl/backend/workflow.go @@ -3,7 +3,6 @@ package backend import ( - "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/workflows" ) @@ -15,22 +14,3 @@ type WorkflowBackend interface { CreateWorkflow(wf *workflows.Workflow) error DeleteWorkflow(id model.ID) error } - -// SettingsBackend provides project settings operations. -type SettingsBackend interface { - GetProjectSettings() (*model.ProjectSettings, error) - UpdateProjectSettings(ps *model.ProjectSettings) error -} - -// ImageBackend provides image collection operations. -type ImageBackend interface { - ListImageCollections() ([]*types.ImageCollection, error) - CreateImageCollection(ic *types.ImageCollection) error - DeleteImageCollection(id string) error -} - -// ScheduledEventBackend provides scheduled event operations. -type ScheduledEventBackend interface { - ListScheduledEvents() ([]*model.ScheduledEvent, error) - GetScheduledEvent(id model.ID) (*model.ScheduledEvent, error) -} diff --git a/mdl/bsonutil/bsonutil.go b/mdl/bsonutil/bsonutil.go index 19120acb..4f5bb18e 100644 --- a/mdl/bsonutil/bsonutil.go +++ b/mdl/bsonutil/bsonutil.go @@ -6,21 +6,34 @@ package bsonutil import ( + "fmt" + "github.com/mendixlabs/mxcli/mdl/types" "go.mongodb.org/mongo-driver/bson/primitive" ) // IDToBsonBinary converts a UUID string to a BSON binary value. // Panics if id is not a valid UUID — an invalid ID at this layer is always a programming error. +// For user-supplied IDs where invalid input is expected, use IDToBsonBinaryErr. func IDToBsonBinary(id string) primitive.Binary { + bin, err := IDToBsonBinaryErr(id) + if err != nil { + panic("bsonutil.IDToBsonBinary: " + err.Error()) + } + return bin +} + +// IDToBsonBinaryErr converts a UUID string to a BSON binary value, returning an error +// for invalid input instead of panicking. Use this for user-supplied or untrusted IDs. +func IDToBsonBinaryErr(id string) (primitive.Binary, error) { blob := types.UUIDToBlob(id) if blob == nil || len(blob) != 16 { - panic("bsonutil.IDToBsonBinary: invalid UUID: " + id) + return primitive.Binary{}, fmt.Errorf("invalid UUID: %q", id) } return primitive.Binary{ Subtype: 0x00, Data: blob, - } + }, nil } // BsonBinaryToID converts a BSON binary value to a hex UUID string. @@ -29,6 +42,17 @@ func BsonBinaryToID(bin primitive.Binary) string { } // NewIDBsonBinary generates a new unique ID and returns it as a BSON binary value. +// Panics if the OS entropy source fails. For callers that can handle failure, use NewIDBsonBinaryErr. func NewIDBsonBinary() primitive.Binary { return IDToBsonBinary(types.GenerateID()) } + +// NewIDBsonBinaryErr generates a new unique ID and returns it as a BSON binary value, +// returning an error instead of panicking on failure. +func NewIDBsonBinaryErr() (primitive.Binary, error) { + id, err := types.GenerateIDErr() + if err != nil { + return primitive.Binary{}, fmt.Errorf("generating ID: %w", err) + } + return IDToBsonBinaryErr(id) +} diff --git a/mdl/bsonutil/bsonutil_test.go b/mdl/bsonutil/bsonutil_test.go index 144642c2..19a4a1cf 100644 --- a/mdl/bsonutil/bsonutil_test.go +++ b/mdl/bsonutil/bsonutil_test.go @@ -73,3 +73,36 @@ func TestNewIDBsonBinary_Uniqueness(t *testing.T) { seen[id] = true } } + +func TestIDToBsonBinaryErr_ValidUUID(t *testing.T) { + id := "550e8400-e29b-41d4-a716-446655440000" + bin, err := IDToBsonBinaryErr(id) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if bin.Subtype != 0x00 { + t.Errorf("expected subtype 0x00, got 0x%02x", bin.Subtype) + } + if len(bin.Data) != 16 { + t.Errorf("expected 16 bytes, got %d", len(bin.Data)) + } + // Roundtrip + got := BsonBinaryToID(bin) + if got != id { + t.Errorf("roundtrip failed: got %q, want %q", got, id) + } +} + +func TestIDToBsonBinaryErr_InvalidUUID(t *testing.T) { + _, err := IDToBsonBinaryErr("not-a-uuid") + if err == nil { + t.Fatal("expected error for invalid UUID, got nil") + } +} + +func TestIDToBsonBinaryErr_EmptyString(t *testing.T) { + _, err := IDToBsonBinaryErr("") + if err == nil { + t.Fatal("expected error for empty string, got nil") + } +} diff --git a/mdl/catalog/builder.go b/mdl/catalog/builder.go index 433fba80..8b64d675 100644 --- a/mdl/catalog/builder.go +++ b/mdl/catalog/builder.go @@ -8,11 +8,11 @@ import ( "strings" "time" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/domainmodel" "github.com/mendixlabs/mxcli/sdk/javaactions" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/sdk/pages" "github.com/mendixlabs/mxcli/sdk/security" "github.com/mendixlabs/mxcli/sdk/workflows" diff --git a/mdl/executor/bugfix_regression_test.go b/mdl/executor/bugfix_regression_test.go index fd13a268..d71df9a0 100644 --- a/mdl/executor/bugfix_regression_test.go +++ b/mdl/executor/bugfix_regression_test.go @@ -403,7 +403,7 @@ func TestResolveMemberChange_FallbackWithoutReader(t *testing.T) { // reader is nil — simulates no project context } - // Without reader: a name without dot should default to attribute + // Without backend: a name without dot should default to attribute mc := µflows.MemberChange{} fb.resolveMemberChange(mc, "Label", "Demo.Child") if mc.AttributeQualifiedName != "Demo.Child.Label" { diff --git a/mdl/executor/bugfix_test.go b/mdl/executor/bugfix_test.go index d04abf76..025686a2 100644 --- a/mdl/executor/bugfix_test.go +++ b/mdl/executor/bugfix_test.go @@ -503,7 +503,7 @@ func TestResolveAssociationPaths(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fb := &flowBuilder{ - reader: nil, // nil reader → no resolution, path unchanged + backend: nil, // nil backend → no resolution, path unchanged } got := fb.resolvePathSegments(tt.path) diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index 46113b92..fc462b15 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "sort" "strings" "github.com/mendixlabs/mxcli/mdl/ast" @@ -112,7 +113,15 @@ func execAlterPage(ctx *ExecContext, s *ast.AlterPageStmt) error { // ============================================================================ func applySetPropertyMutator(mutator backend.PageMutator, op *ast.SetPropertyOp) error { - for propName, value := range op.Properties { + // Sort property names for deterministic application order. + propNames := make([]string, 0, len(op.Properties)) + for k := range op.Properties { + propNames = append(propNames, k) + } + sort.Strings(propNames) + + for _, propName := range propNames { + value := op.Properties[propName] if op.Target.IsColumn() { if err := mutator.SetColumnProperty(op.Target.Widget, op.Target.Column, propName, value); err != nil { return mdlerrors.NewBackend("set "+propName+" on "+op.Target.Name(), err) diff --git a/mdl/executor/cmd_businessevents.go b/mdl/executor/cmd_businessevents.go index 28fe7eaa..ab9e7714 100644 --- a/mdl/executor/cmd_businessevents.go +++ b/mdl/executor/cmd_businessevents.go @@ -8,8 +8,8 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" ) // showBusinessEventServices displays a table of all business event service documents. diff --git a/mdl/executor/cmd_catalog.go b/mdl/executor/cmd_catalog.go index dedf3b92..eb40e77c 100644 --- a/mdl/executor/cmd_catalog.go +++ b/mdl/executor/cmd_catalog.go @@ -680,7 +680,7 @@ func captureDescribe(ctx *ExecContext, objectType string, qualifiedName string) // captureDescribeParallel is a goroutine-safe version of captureDescribe. // It creates a lightweight ExecContext clone per call with its own output buffer, -// sharing the reader and pre-warmed cache. Call preWarmCache() before using +// sharing the backend and pre-warmed cache. Call preWarmCache() before using // this from multiple goroutines. func captureDescribeParallel(ctx *ExecContext, objectType string, qualifiedName string) (string, error) { parts := strings.SplitN(qualifiedName, ".", 2) diff --git a/mdl/executor/cmd_entities.go b/mdl/executor/cmd_entities.go index 9517c446..255da64a 100644 --- a/mdl/executor/cmd_entities.go +++ b/mdl/executor/cmd_entities.go @@ -152,7 +152,7 @@ func execCreateEntity(ctx *ExecContext, s *ast.CreateEntityStmt) error { if a.CalculatedMicroflow != nil { mfID, err := resolveMicroflowByName(ctx, a.CalculatedMicroflow.String()) if err != nil { - return fmt.Errorf("attribute '%s': %w", a.Name, err) + return mdlerrors.NewBackend(fmt.Sprintf("attribute '%s'", a.Name), err) } attrValue.MicroflowID = mfID attrValue.MicroflowName = a.CalculatedMicroflow.String() @@ -543,7 +543,7 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { if a.CalculatedMicroflow != nil { mfID, err := resolveMicroflowByName(ctx, a.CalculatedMicroflow.String()) if err != nil { - return fmt.Errorf("attribute '%s': %w", a.Name, err) + return mdlerrors.NewBackend(fmt.Sprintf("attribute '%s'", a.Name), err) } attrValue.MicroflowID = mfID attrValue.MicroflowName = a.CalculatedMicroflow.String() @@ -635,7 +635,7 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { if s.CalculatedMicroflow != nil { mfID, err := resolveMicroflowByName(ctx, s.CalculatedMicroflow.String()) if err != nil { - return fmt.Errorf("attribute '%s': %w", s.AttributeName, err) + return mdlerrors.NewBackend(fmt.Sprintf("attribute '%s'", s.AttributeName), err) } attrValue.MicroflowID = mfID attrValue.MicroflowName = s.CalculatedMicroflow.String() diff --git a/mdl/executor/cmd_export_mappings.go b/mdl/executor/cmd_export_mappings.go index 035ab0b3..f2579973 100644 --- a/mdl/executor/cmd_export_mappings.go +++ b/mdl/executor/cmd_export_mappings.go @@ -11,8 +11,8 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/backend" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" ) // showExportMappings prints a table of all export mapping documents. diff --git a/mdl/executor/cmd_folders.go b/mdl/executor/cmd_folders.go index 15e373eb..95dbe91a 100644 --- a/mdl/executor/cmd_folders.go +++ b/mdl/executor/cmd_folders.go @@ -9,8 +9,8 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" ) // findFolderByPath walks a folder path under a module and returns the folder ID. diff --git a/mdl/executor/cmd_import_mappings.go b/mdl/executor/cmd_import_mappings.go index c0a7a8c1..68daf7e2 100644 --- a/mdl/executor/cmd_import_mappings.go +++ b/mdl/executor/cmd_import_mappings.go @@ -11,8 +11,8 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/backend" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" ) // showImportMappings prints a table of all import mapping documents. diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index 7f0d95ff..fd3e662b 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -30,7 +30,7 @@ type flowBuilder struct { measurer *layoutMeasurer // For measuring statement dimensions nextConnectionPoint model.ID // For compound statements: the exit point differs from entry point nextFlowCase string // If set, next connecting flow uses this case value (for merge-less splits) - reader backend.FullBackend // For looking up page/microflow references + backend backend.FullBackend // For looking up page/microflow references hierarchy *ContainerHierarchy // For resolving container IDs to module names pendingAnnotations *ast.ActivityAnnotations // Pending annotations to attach to next activity restServices []*model.ConsumedRestService // Cached REST services for parameter classification @@ -137,7 +137,7 @@ func (fb *flowBuilder) resolveAssociationPaths(expr ast.Expression) ast.Expressi // For each segment that is a qualified association name (Module.AssocName), it looks up // the association's target entity and inserts it after the association. func (fb *flowBuilder) resolvePathSegments(path []string) []string { - if fb.reader == nil || len(path) == 0 { + if fb.backend == nil || len(path) == 0 { return path } diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index d60a9142..9e1104fb 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -18,7 +18,7 @@ import ( func (fb *flowBuilder) addCreateVariableAction(s *ast.DeclareStmt) model.ID { // Resolve TypeEnumeration → TypeEntity ambiguity using the domain model declType := s.Type - if declType.Kind == ast.TypeEnumeration && declType.EnumRef != nil && fb.reader != nil { + if declType.Kind == ast.TypeEnumeration && declType.EnumRef != nil && fb.backend != nil { if fb.isEntity(declType.EnumRef.Module, declType.EnumRef.Name) { declType = ast.DataType{Kind: ast.TypeEntity, EntityRef: declType.EnumRef} } @@ -734,14 +734,14 @@ func (fb *flowBuilder) addRemoveFromListAction(s *ast.RemoveFromListStmt) model. // isEntity checks whether a qualified name refers to an entity in the domain model. func (fb *flowBuilder) isEntity(moduleName, entityName string) bool { - if fb.reader == nil { + if fb.backend == nil { return false } - mod, err := fb.reader.GetModuleByName(moduleName) + mod, err := fb.backend.GetModuleByName(moduleName) if err != nil || mod == nil { return false } - dm, err := fb.reader.GetDomainModel(mod.ID) + dm, err := fb.backend.GetDomainModel(mod.ID) if err != nil || dm == nil { return false } @@ -755,7 +755,7 @@ func (fb *flowBuilder) isEntity(moduleName, entityName string) bool { // resolveMemberChange determines whether a member name is an association or attribute // and sets the appropriate field on the MemberChange. It queries the domain model -// to check if the name matches an association on the entity; if no reader is available, +// to check if the name matches an association on the entity; if no backend is available, // it falls back to the dot-contains heuristic. // // memberName can be either bare ("Order_Customer") or qualified ("MfTest.Order_Customer"). @@ -784,9 +784,9 @@ func (fb *flowBuilder) resolveMemberChange(mc *microflows.MemberChange, memberNa } // Query domain model to check if this member is an association - if fb.reader != nil { - if mod, err := fb.reader.GetModuleByName(moduleName); err == nil && mod != nil { - if dm, err := fb.reader.GetDomainModel(mod.ID); err == nil && dm != nil { + if fb.backend != nil { + if mod, err := fb.backend.GetModuleByName(moduleName); err == nil && mod != nil { + if dm, err := fb.backend.GetDomainModel(mod.ID); err == nil && dm != nil { for _, a := range dm.Associations { if a.Name == bareName { mc.AssociationQualifiedName = qualifiedName @@ -828,16 +828,16 @@ type assocLookupResult struct { // lookupAssociation finds an association by module and name, returning its type // and the qualified names of its parent and child entities. Returns nil if the -// association cannot be found (e.g., reader is nil or module doesn't exist). +// association cannot be found (e.g., backend is nil or module doesn't exist). func (fb *flowBuilder) lookupAssociation(moduleName, assocName string) *assocLookupResult { - if fb.reader == nil { + if fb.backend == nil { return nil } - mod, err := fb.reader.GetModuleByName(moduleName) + mod, err := fb.backend.GetModuleByName(moduleName) if err != nil || mod == nil { return nil } - dm, err := fb.reader.GetDomainModel(mod.ID) + dm, err := fb.backend.GetDomainModel(mod.ID) if err != nil || dm == nil { return nil } diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index 476863ee..ed9b0e32 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -5,9 +5,9 @@ package executor import ( "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/mdl/types" ) // getStatementAnnotations extracts the annotations field from any microflow statement. diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index a03b21e0..4db6bf55 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -8,10 +8,10 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/javaactions" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/mdl/types" ) // addLogMessageAction creates a LOG statement as a LogMessageAction. @@ -155,8 +155,8 @@ func (fb *flowBuilder) addCallJavaActionAction(s *ast.CallJavaActionStmt) model. // Try to look up the Java action definition to detect EntityTypeParameterType parameters var jaDef *javaactions.JavaAction - if fb.reader != nil { - jaDef, _ = fb.reader.ReadJavaActionByName(actionQN) + if fb.backend != nil { + jaDef, _ = fb.backend.ReadJavaActionByName(actionQN) } // Build a map of parameter name -> param type for the Java action @@ -691,11 +691,11 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { // looking at the JSON structure it references. If the root JSON element is // an Object, the mapping produces one object; if it is an Array, a list. singleObject := false - if fb.reader != nil { - if im, err := fb.reader.GetImportMappingByQualifiedName(s.Result.MappingName.Module, s.Result.MappingName.Name); err == nil && im.JsonStructure != "" { + if fb.backend != nil { + if im, err := fb.backend.GetImportMappingByQualifiedName(s.Result.MappingName.Module, s.Result.MappingName.Name); err == nil && im.JsonStructure != "" { // im.JsonStructure is "Module.Name" — split and look up the JSON structure. if parts := strings.SplitN(im.JsonStructure, ".", 2); len(parts) == 2 { - if js, err := fb.reader.GetJsonStructureByQualifiedName(parts[0], parts[1]); err == nil && len(js.Elements) > 0 { + if js, err := fb.backend.GetJsonStructureByQualifiedName(parts[0], parts[1]); err == nil && len(js.Elements) > 0 { singleObject = js.Elements[0].ElementType == "Object" } } @@ -990,12 +990,12 @@ func (fb *flowBuilder) addImportFromMappingAction(s *ast.ImportFromMappingStmt) } // Determine single vs list and result entity from the import mapping - if fb.reader != nil { - if im, err := fb.reader.GetImportMappingByQualifiedName(s.Mapping.Module, s.Mapping.Name); err == nil { + if fb.backend != nil { + if im, err := fb.backend.GetImportMappingByQualifiedName(s.Mapping.Module, s.Mapping.Name); err == nil { if im.JsonStructure != "" { parts := strings.SplitN(im.JsonStructure, ".", 2) if len(parts) == 2 { - if js, err := fb.reader.GetJsonStructureByQualifiedName(parts[0], parts[1]); err == nil && len(js.Elements) > 0 { + if js, err := fb.backend.GetJsonStructureByQualifiedName(parts[0], parts[1]); err == nil && len(js.Elements) > 0 { if js.Elements[0].ElementType == "Array" { resultHandling.SingleObject = false } diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index 98edcf49..8f4d20cd 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -7,9 +7,9 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/mdl/types" ) // addIfStatement creates an IF/THEN/ELSE statement using ExclusiveSplit and ExclusiveMerge. @@ -285,7 +285,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID { varTypes: fb.varTypes, // Share variable scope declaredVars: fb.declaredVars, // Share declared vars (fixes nil map panic) measurer: fb.measurer, // Share measurer - reader: fb.reader, // Share reader + backend: fb.backend, // Share backend hierarchy: fb.hierarchy, // Share hierarchy restServices: fb.restServices, // Share REST services for parameter classification } @@ -359,7 +359,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID { varTypes: fb.varTypes, declaredVars: fb.declaredVars, measurer: fb.measurer, - reader: fb.reader, + backend: fb.backend, hierarchy: fb.hierarchy, restServices: fb.restServices, } diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 78d96239..385a21bc 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -5,9 +5,9 @@ package executor import ( "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/mdl/types" ) // convertErrorHandlingType converts AST error handling type to SDK error handling type. @@ -64,7 +64,7 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in varTypes: fb.varTypes, declaredVars: fb.declaredVars, measurer: fb.measurer, - reader: fb.reader, + backend: fb.backend, hierarchy: fb.hierarchy, restServices: fb.restServices, } diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index 2b0737ba..3bc79346 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -5,9 +5,9 @@ package executor import ( "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/mdl/types" ) // buildFlowGraph converts AST statements to a Microflow flow graph. diff --git a/mdl/executor/cmd_microflows_builder_workflow.go b/mdl/executor/cmd_microflows_builder_workflow.go index aacf40e0..d29c1173 100644 --- a/mdl/executor/cmd_microflows_builder_workflow.go +++ b/mdl/executor/cmd_microflows_builder_workflow.go @@ -4,9 +4,9 @@ package executor import ( "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/mdl/types" ) // wrapAction wraps a MicroflowAction in an ActionActivity with standard positioning. diff --git a/mdl/executor/cmd_microflows_create.go b/mdl/executor/cmd_microflows_create.go index dd85ae1a..d8d10a2f 100644 --- a/mdl/executor/cmd_microflows_create.go +++ b/mdl/executor/cmd_microflows_create.go @@ -23,7 +23,7 @@ func isBuiltinModuleEntity(moduleName string) bool { } // execCreateMicroflow handles CREATE MICROFLOW statements. -// loadRestServices returns all consumed REST services, or nil if no reader. +// loadRestServices returns all consumed REST services, or nil if no backend. func loadRestServices(ctx *ExecContext) ([]*model.ConsumedRestService, error) { if !ctx.Connected() { return nil, nil @@ -213,7 +213,7 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { varTypes: varTypes, declaredVars: declaredVars, measurer: &layoutMeasurer{varTypes: varTypes}, - reader: ctx.Backend, + backend: ctx.Backend, hierarchy: hierarchy, restServices: restServices, } diff --git a/mdl/executor/cmd_microflows_helpers.go b/mdl/executor/cmd_microflows_helpers.go index b9286b6a..a8fbfe8b 100644 --- a/mdl/executor/cmd_microflows_helpers.go +++ b/mdl/executor/cmd_microflows_helpers.go @@ -227,18 +227,18 @@ func (fb *flowBuilder) memberExpressionToString(expr ast.Expression, entityQN, a // for an attribute if it is an enumeration type. Returns "" if the attribute is not // an enumeration or if the domain model is not available. func (fb *flowBuilder) lookupEnumRef(entityQN, attrName string) string { - if fb.reader == nil || entityQN == "" || attrName == "" { + if fb.backend == nil || entityQN == "" || attrName == "" { return "" } parts := strings.SplitN(entityQN, ".", 2) if len(parts) != 2 { return "" } - mod, err := fb.reader.GetModuleByName(parts[0]) + mod, err := fb.backend.GetModuleByName(parts[0]) if err != nil || mod == nil { return "" } - dm, err := fb.reader.GetDomainModel(mod.ID) + dm, err := fb.backend.GetDomainModel(mod.ID) if err != nil || dm == nil { return "" } diff --git a/mdl/executor/cmd_pages_builder.go b/mdl/executor/cmd_pages_builder.go index 5c00608e..6a8920c0 100644 --- a/mdl/executor/cmd_pages_builder.go +++ b/mdl/executor/cmd_pages_builder.go @@ -320,6 +320,7 @@ func (pb *pageBuilder) resolveFolder(folderPath string) (model.ID, error) { ContainerID: parentContainerID, Name: part, }) + currentContainerID = newFolderID } } diff --git a/mdl/executor/cmd_pages_builder_input.go b/mdl/executor/cmd_pages_builder_input.go index 0b07d61e..b286b7cf 100644 --- a/mdl/executor/cmd_pages_builder_input.go +++ b/mdl/executor/cmd_pages_builder_input.go @@ -122,7 +122,7 @@ func (pb *pageBuilder) resolveMicroflow(qualifiedName string) (model.ID, error) } } - // Get microflows from reader cache + // Get microflows from backend mfs, err := pb.getMicroflows() if err != nil { return "", mdlerrors.NewBackend("list microflows", err) diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 1d108b9b..2e2bc64b 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -4,14 +4,15 @@ package executor import ( "fmt" + "log" "strings" "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/domainmodel" "github.com/mendixlabs/mxcli/sdk/microflows" - "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/sdk/pages" ) @@ -60,7 +61,7 @@ func (pb *pageBuilder) buildPageV3(s *ast.CreatePageStmtV3) (*pages.Page, error) layoutID, err := pb.resolveLayout(s.Layout) if err != nil { // Layout not found is not fatal - page will work but may not render correctly - fmt.Printf("Warning: layout %s not found\n", s.Layout) + log.Printf("warning: layout %s not found", s.Layout) } else { page.LayoutID = layoutID @@ -740,7 +741,7 @@ func (pb *pageBuilder) moduleNameByID(moduleID model.ID) string { // getMicroflowReturnEntityName looks up a microflow and returns its return type entity name. // Returns empty string if the microflow doesn't return an entity or list of entities. func (pb *pageBuilder) getMicroflowReturnEntityName(qualifiedName string) string { - // First, check if the microflow was created during this session (not yet in reader cache) + // First, check if the microflow was created during this session (not yet in backend cache) if pb.execCache != nil && pb.execCache.createdMicroflows != nil { if info, ok := pb.execCache.createdMicroflows[qualifiedName]; ok { return info.ReturnEntityName @@ -755,7 +756,7 @@ func (pb *pageBuilder) getMicroflowReturnEntityName(qualifiedName string) string moduleName := parts[0] mfName := strings.Join(parts[1:], ".") - // Get microflows from reader cache + // Get microflows from backend mfs, err := pb.getMicroflows() if err != nil { return "" diff --git a/mdl/executor/cmd_pages_builder_v3_layout.go b/mdl/executor/cmd_pages_builder_v3_layout.go index a7dca672..1b49e223 100644 --- a/mdl/executor/cmd_pages_builder_v3_layout.go +++ b/mdl/executor/cmd_pages_builder_v3_layout.go @@ -5,11 +5,10 @@ package executor import ( "strings" - "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/pages" - - "github.com/mendixlabs/mxcli/mdl/ast" ) func (pb *pageBuilder) buildLayoutGridV3(w *ast.WidgetV3) (*pages.LayoutGrid, error) { diff --git a/mdl/executor/cmd_security_write.go b/mdl/executor/cmd_security_write.go index e97bf593..327d2345 100644 --- a/mdl/executor/cmd_security_write.go +++ b/mdl/executor/cmd_security_write.go @@ -10,8 +10,8 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/backend" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/security" ) @@ -781,7 +781,7 @@ func execRevokeWorkflowAccess(ctx *ExecContext, s *ast.RevokeWorkflowAccessStmt) func validateModuleRole(ctx *ExecContext, role ast.QualifiedName) error { module, err := findModule(ctx, role.Module) if err != nil { - return fmt.Errorf("module not found for role %s.%s: %w", role.Module, role.Name, err) + return mdlerrors.NewBackend(fmt.Sprintf("module not found for role %s.%s", role.Module, role.Name), err) } ms, err := ctx.Backend.GetModuleSecurity(module.ID) @@ -856,7 +856,7 @@ func execCreateDemoUser(ctx *ExecContext, s *ast.CreateDemoUserStmt) error { // Validate password against project password policy if err := ps.PasswordPolicy.ValidatePassword(s.Password); err != nil { - return fmt.Errorf("password policy violation for demo user '%s': %w\nhint: check your project's password policy with SHOW PROJECT SECURITY", s.UserName, err) + return mdlerrors.NewValidationf("password policy violation for demo user '%s': %v\nhint: check your project's password policy with SHOW PROJECT SECURITY", s.UserName, err) } // Check if user already exists diff --git a/mdl/executor/cmd_structure.go b/mdl/executor/cmd_structure.go index fa26ffac..20872700 100644 --- a/mdl/executor/cmd_structure.go +++ b/mdl/executor/cmd_structure.go @@ -75,8 +75,8 @@ func structureDepth1JSON(ctx *ExecContext, modules []structureModule) error { odataClientCounts := queryCountByModule(ctx, "odata_clients") odataServiceCounts := queryCountByModule(ctx, "odata_services") beServiceCounts := queryCountByModule(ctx, "business_event_services") - constantCounts := countByModuleFromReader(ctx, "constants") - scheduledEventCounts := countByModuleFromReader(ctx, "scheduled_events") + constantCounts := countByModuleFromBackend(ctx, "constants") + scheduledEventCounts := countByModuleFromBackend(ctx, "scheduled_events") tr := &TableResult{ Columns: []string{ @@ -195,9 +195,9 @@ func structureDepth1(ctx *ExecContext, modules []structureModule) error { odataServiceCounts := queryCountByModule(ctx, "odata_services") beServiceCounts := queryCountByModule(ctx, "business_event_services") - // Get constants and scheduled events from reader (no catalog tables) - constantCounts := countByModuleFromReader(ctx, "constants") - scheduledEventCounts := countByModuleFromReader(ctx, "scheduled_events") + // Get constants and scheduled events from backend (no catalog tables) + constantCounts := countByModuleFromBackend(ctx, "constants") + scheduledEventCounts := countByModuleFromBackend(ctx, "scheduled_events") // Calculate name column width for alignment nameWidth := 0 @@ -272,8 +272,8 @@ func queryCountByModule(ctx *ExecContext, tableAndWhere string) map[string]int { return counts } -// countByModuleFromReader counts elements per module using the reader (for types without catalog tables). -func countByModuleFromReader(ctx *ExecContext, kind string) map[string]int { +// countByModuleFromBackend counts elements per module using the backend (for types without catalog tables). +func countByModuleFromBackend(ctx *ExecContext, kind string) map[string]int { counts := make(map[string]int) h, err := getHierarchy(ctx) if err != nil { @@ -314,7 +314,7 @@ func pluralize(count int, singular, plural string) string { // ============================================================================ func structureDepth2(ctx *ExecContext, modules []structureModule) error { - // Pre-load data that needs the reader + // Pre-load data from the backend h, err := getHierarchy(ctx) if err != nil { return mdlerrors.NewBackend("build hierarchy", err) diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index ce2d116d..6a86e9a3 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -10,8 +10,8 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/workflows" ) diff --git a/mdl/executor/executor.go b/mdl/executor/executor.go index 16193676..d781c85f 100644 --- a/mdl/executor/executor.go +++ b/mdl/executor/executor.go @@ -303,6 +303,7 @@ func (e *Executor) Catalog() *catalog.Catalog { // Reader returns the MPR reader, or nil if not connected. // Deprecated: External callers should migrate to using Backend methods directly. +// TODO(shared-types): remove once all callers use Backend — target: v0.next milestone. func (e *Executor) Reader() *mpr.Reader { if e.backend == nil { return nil @@ -341,7 +342,7 @@ func (e *Executor) Close() error { // trackCreatedMicroflow registers a microflow that was created during this session. // This allows subsequent page creations to resolve references to the microflow -// even though the reader cache hasn't been updated. +// even though the backend cache hasn't been updated. func (e *Executor) trackCreatedMicroflow(moduleName, mfName string, id, containerID model.ID, returnEntityName string) { e.ensureCache() if e.cache.createdMicroflows == nil { @@ -359,7 +360,7 @@ func (e *Executor) trackCreatedMicroflow(moduleName, mfName string, id, containe // trackCreatedPage registers a page that was created during this session. // This allows subsequent page creations to resolve SHOW_PAGE references -// even though the reader cache hasn't been updated. +// even though the backend cache hasn't been updated. func (e *Executor) trackCreatedPage(moduleName, pageName string, id, containerID model.ID) { e.ensureCache() if e.cache.createdPages == nil { @@ -376,7 +377,7 @@ func (e *Executor) trackCreatedPage(moduleName, pageName string, id, containerID // trackCreatedSnippet registers a snippet that was created during this session. // This allows subsequent creations to resolve snippet references -// even though the reader cache hasn't been updated. +// even though the backend cache hasn't been updated. func (e *Executor) trackCreatedSnippet(moduleName, snippetName string, id, containerID model.ID) { e.ensureCache() if e.cache.createdSnippets == nil { diff --git a/mdl/executor/executor_connect.go b/mdl/executor/executor_connect.go index d56889d4..12e7c13e 100644 --- a/mdl/executor/executor_connect.go +++ b/mdl/executor/executor_connect.go @@ -3,6 +3,7 @@ package executor import ( + "errors" "fmt" "github.com/mendixlabs/mxcli/mdl/ast" @@ -18,7 +19,7 @@ func execConnect(ctx *ExecContext, s *ast.ConnectStmt) error { } if e.backendFactory == nil { - return mdlerrors.NewBackend("connect", fmt.Errorf("no backend factory configured")) + return mdlerrors.NewBackend("connect", errors.New("no backend factory configured")) } b := e.backendFactory() if err := b.Connect(s.Path); err != nil { diff --git a/mdl/executor/mock_test_helpers_test.go b/mdl/executor/mock_test_helpers_test.go index 288290d1..c5fc328d 100644 --- a/mdl/executor/mock_test_helpers_test.go +++ b/mdl/executor/mock_test_helpers_test.go @@ -102,8 +102,8 @@ func mkHierarchy(modules ...*model.Module) *ContainerHierarchy { // a module ID, this call is technically redundant (the module is found directly // in moduleIDs), but it keeps test setup explicit about parentage. For // intermediate containers (folders, units) this call is required. -func withContainer(h *ContainerHierarchy, containerID, parentID model.ID) { - h.containerParent[containerID] = parentID +func withContainer(h *ContainerHierarchy, containerID, parentContainerID model.ID) { + h.containerParent[containerID] = parentContainerID } // --- Model factories --- diff --git a/mdl/executor/widget_engine_test.go b/mdl/executor/widget_engine_test.go index ef70a7ee..6f32f333 100644 --- a/mdl/executor/widget_engine_test.go +++ b/mdl/executor/widget_engine_test.go @@ -218,17 +218,6 @@ func TestEvaluateCondition(t *testing.T) { } } -func TestEvaluateConditionUnknownReturnsFalse(t *testing.T) { - engine := &PluggableWidgetEngine{} - - w := &ast.WidgetV3{Properties: map[string]any{}} - result := engine.evaluateCondition("typoCondition", w) - - if result != false { - t.Errorf("expected false for unknown condition, got %v", result) - } -} - func TestSelectMappings_NoModes(t *testing.T) { engine := &PluggableWidgetEngine{} diff --git a/mdl/executor/widget_registry_test.go b/mdl/executor/widget_registry_test.go index 86aa71bc..da9ba32c 100644 --- a/mdl/executor/widget_registry_test.go +++ b/mdl/executor/widget_registry_test.go @@ -347,7 +347,7 @@ func TestRegistryUserDefinitionOverrideLogsWarning(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) - defer log.SetOutput(nil) + defer log.SetOutput(os.Stderr) projectPath := filepath.Join(tmpDir, "App.mpr") if err := reg.LoadUserDefinitions(projectPath); err != nil { diff --git a/mdl/types/asyncapi.go b/mdl/types/asyncapi.go index 9baf57e3..ffd64227 100644 --- a/mdl/types/asyncapi.go +++ b/mdl/types/asyncapi.go @@ -151,12 +151,14 @@ func asyncRefName(ref string) string { } func resolveAsyncSchemaProperties(schema yamlAsyncSchema) []*AsyncAPIProperty { + // Sort property names for deterministic output names := make([]string, 0, len(schema.Properties)) for name := range schema.Properties { names = append(names, name) } sort.Strings(names) - var props []*AsyncAPIProperty + + props := make([]*AsyncAPIProperty, 0, len(names)) for _, name := range names { prop := schema.Properties[name] props = append(props, &AsyncAPIProperty{ diff --git a/mdl/types/doc.go b/mdl/types/doc.go index fe45c6ad..8d0d83f1 100644 --- a/mdl/types/doc.go +++ b/mdl/types/doc.go @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Package types defines shared value types used in backend interfaces. -// These types are decoupled from sdk/mpr to enable WASM compilation of -// the mdl/ subtree. Conversion functions between these types and their -// sdk/mpr counterparts live in mdl/backend/mpr/. +// These types are decoupled from sdk/mpr to avoid pulling in CGO +// dependencies, keeping the mdl/ subtree dependency-light. +// Conversion functions between these types and their sdk/mpr +// counterparts live in mdl/backend/mpr/. package types diff --git a/mdl/types/edmx.go b/mdl/types/edmx.go index 17367b86..eb19c1d2 100644 --- a/mdl/types/edmx.go +++ b/mdl/types/edmx.go @@ -401,12 +401,12 @@ func ResolveNavType(t string) (typeName string, isMany bool) { // ============================================================================ type xmlEdmx struct { - XMLName xml.Name `xml:"Edmx"` - Version string `xml:"Version,attr"` - DataServices []xmlDataServices `xml:"DataServices"` + XMLName xml.Name `xml:"Edmx"` + Version string `xml:"Version,attr"` + DataServices []xmlDataService `xml:"DataServices"` } -type xmlDataServices struct { +type xmlDataService struct { Schemas []xmlSchema `xml:"Schema"` } diff --git a/mdl/types/id.go b/mdl/types/id.go index bb99dd27..e8ecf219 100644 --- a/mdl/types/id.go +++ b/mdl/types/id.go @@ -11,10 +11,22 @@ import ( ) // GenerateID generates a new unique UUID v4 for model elements. +// Panics if the OS entropy source fails — this is a fatal condition. +// For callers that can handle failure gracefully, use GenerateIDErr. func GenerateID() string { + id, err := GenerateIDErr() + if err != nil { + panic(err.Error()) + } + return id +} + +// GenerateIDErr generates a new unique UUID v4 for model elements, returning +// an error instead of panicking if the OS entropy source fails. +func GenerateIDErr() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { - panic("crypto/rand.Read failed: " + err.Error()) + return "", fmt.Errorf("crypto/rand.Read failed: %w", err) } b[6] = (b[6] & 0x0f) | 0x40 // Version 4 b[8] = (b[8] & 0x3f) | 0x80 // Variant is 10 @@ -24,7 +36,7 @@ func GenerateID() string { b[4], b[5], b[6], b[7], b[8], b[9], - b[10], b[11], b[12], b[13], b[14], b[15]) + b[10], b[11], b[12], b[13], b[14], b[15]), nil } // GenerateDeterministicID generates a stable UUID v4 from a seed string. @@ -38,7 +50,8 @@ func GenerateDeterministicID(seed string) string { h[0:4], h[4:6], h[6:8], h[8:10], h[10:16]) } -// BlobToUUID converts a 16-byte binary ID blob to a UUID string. +// BlobToUUID converts a 16-byte blob in Microsoft GUID format to a UUID string. +// For non-16-byte input, returns a hex-encoded string as a best-effort fallback. func BlobToUUID(data []byte) string { if len(data) != 16 { return hex.EncodeToString(data) @@ -56,14 +69,8 @@ func UUIDToBlob(uuid string) []byte { if uuid == "" { return nil } - var clean strings.Builder - clean.Grow(32) - for i := 0; i < len(uuid); i++ { - if uuid[i] != '-' { - clean.WriteByte(uuid[i]) - } - } - decoded, err := hex.DecodeString(clean.String()) + clean := strings.ReplaceAll(uuid, "-", "") + decoded, err := hex.DecodeString(clean) if err != nil || len(decoded) != 16 { return nil } @@ -100,6 +107,9 @@ func ValidateID(id string) bool { } // Hash computes a hash for content (used for content deduplication). +// TODO: replace with SHA-256 (or similar) — the current positional checksum is +// weak and produces collisions easily. Deferred to avoid breaking callers that +// may depend on the output format/length. func Hash(content []byte) string { var sum uint64 for i, b := range content { diff --git a/mdl/types/json_utils.go b/mdl/types/json_utils.go index acb40a62..2bcdff96 100644 --- a/mdl/types/json_utils.go +++ b/mdl/types/json_utils.go @@ -219,7 +219,9 @@ func (b *snippetBuilder) buildElementFromRawValue(exposedName, path, jsonKey str // Primitive — unmarshal to determine type var val any - json.Unmarshal(raw, &val) + if err := json.Unmarshal(raw, &val); err != nil { + return buildValueElement(exposedName, path, "Unknown", string(raw)) + } switch v := val.(type) { case string: @@ -262,10 +264,14 @@ func (b *snippetBuilder) buildElementFromRawRootArray(exposedName, path, rawJSON } dec := json.NewDecoder(strings.NewReader(rawJSON)) - dec.Token() // opening [ + if _, err := dec.Token(); err != nil { // opening [ + return arrayElem + } if dec.More() { var firstItem json.RawMessage - dec.Decode(&firstItem) + if err := dec.Decode(&firstItem); err != nil { + return arrayElem + } itemPath := path + "|(Object)" trimmed := strings.TrimSpace(string(firstItem)) @@ -305,10 +311,14 @@ func (b *snippetBuilder) buildElementFromRawArray(exposedName, path, jsonKey, ra // Decode array and get first element as raw JSON dec := json.NewDecoder(strings.NewReader(rawJSON)) - dec.Token() // opening [ + if _, err := dec.Token(); err != nil { // opening [ + return arrayElem + } if dec.More() { var firstItem json.RawMessage - dec.Decode(&firstItem) + if err := dec.Decode(&firstItem); err != nil { + return arrayElem + } trimmed := strings.TrimSpace(string(firstItem)) diff --git a/sdk/mpr/reader_types.go b/sdk/mpr/reader_types.go index 1bf115a5..4eb3909f 100644 --- a/sdk/mpr/reader_types.go +++ b/sdk/mpr/reader_types.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -// Package mpr - Additional types and utility methods for Reader. +// Package mpr - Reader methods for listing and querying model units. package mpr import ( diff --git a/sdk/mpr/utils.go b/sdk/mpr/utils.go index fc3e317b..5042cb14 100644 --- a/sdk/mpr/utils.go +++ b/sdk/mpr/utils.go @@ -3,8 +3,10 @@ package mpr import ( - "github.com/mendixlabs/mxcli/mdl/types" "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/mdl/types" ) // GenerateID generates a new unique ID for model elements. @@ -23,13 +25,16 @@ func BlobToUUID(data []byte) string { } // IDToBsonBinary converts a UUID string to a BSON binary value. +// For invalid or empty UUIDs (e.g. test placeholders), falls back to generating +// a random ID to maintain backward compatibility with existing serialization paths. +// For strict validation, use bsonutil.IDToBsonBinaryErr. func IDToBsonBinary(id string) primitive.Binary { return idToBsonBinary(id) } // BsonBinaryToID converts a BSON binary value to a UUID string. func BsonBinaryToID(bin primitive.Binary) string { - return types.BlobToUUID(bin.Data) + return bsonutil.BsonBinaryToID(bin) } // Hash computes a hash for content (used for content deduplication). diff --git a/sdk/mpr/writer_agenteditor_agent.go b/sdk/mpr/writer_agenteditor_agent.go index 4d944e3c..22b548b7 100644 --- a/sdk/mpr/writer_agenteditor_agent.go +++ b/sdk/mpr/writer_agenteditor_agent.go @@ -82,19 +82,19 @@ func encodeAgentContents(a *agenteditor.Agent) (string, error) { MaxResults int `json:"maxResults,omitempty"` } type contentsShape struct { - Description string `json:"description"` - SystemPrompt string `json:"systemPrompt"` - UserPrompt string `json:"userPrompt"` - UsageType string `json:"usageType"` + Description string `json:"description"` + SystemPrompt string `json:"systemPrompt"` + UserPrompt string `json:"userPrompt"` + UsageType string `json:"usageType"` Variables []agenteditor.AgentVar `json:"variables"` - Tools []toolEntry `json:"tools"` - KnowledgebaseTools []kbToolEntry `json:"knowledgebaseTools"` - Model *agenteditor.DocRef `json:"model,omitempty"` - Entity *agenteditor.DocRef `json:"entity,omitempty"` - MaxTokens *int `json:"maxTokens,omitempty"` - ToolChoice string `json:"toolChoice,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"topP,omitempty"` + Tools []toolEntry `json:"tools"` + KnowledgebaseTools []kbToolEntry `json:"knowledgebaseTools"` + Model *agenteditor.DocRef `json:"model,omitempty"` + Entity *agenteditor.DocRef `json:"entity,omitempty"` + MaxTokens *int `json:"maxTokens,omitempty"` + ToolChoice string `json:"toolChoice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` } // Convert typed slices (ensure non-nil so JSON emits [] not null). diff --git a/sdk/mpr/writer_core.go b/sdk/mpr/writer_core.go index 2f191150..5146496a 100644 --- a/sdk/mpr/writer_core.go +++ b/sdk/mpr/writer_core.go @@ -15,7 +15,8 @@ import ( ) // idToBsonBinary converts a UUID string to BSON Binary format. -// Mendix stores IDs as Binary with Subtype 0. +// For invalid or empty UUIDs (e.g. test placeholders), generates a random ID +// to maintain backward compatibility with existing serialization paths. func idToBsonBinary(id string) primitive.Binary { blob := types.UUIDToBlob(id) if blob == nil || len(blob) != 16 { diff --git a/sdk/mpr/writer_jsonstructure.go b/sdk/mpr/writer_jsonstructure.go index ee5b9428..581aaa2b 100644 --- a/sdk/mpr/writer_jsonstructure.go +++ b/sdk/mpr/writer_jsonstructure.go @@ -89,5 +89,3 @@ func serializeJsonElement(elem *JsonElement) bson.D { {Key: "WarningMessage", Value: ""}, } } - -