diff --git a/mdl/backend/mock/mock_page_mutator.go b/mdl/backend/mock/mock_page_mutator.go new file mode 100644 index 00000000..ddcd0b2d --- /dev/null +++ b/mdl/backend/mock/mock_page_mutator.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mock + +import ( + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +var _ backend.PageMutator = (*MockPageMutator)(nil) + +// MockPageMutator implements backend.PageMutator. Every interface method is +// backed by a public function field. If the field is nil the method returns +// nil error (never panics). ContainerType defaults to ContainerPage when unset; +// all other methods return zero values. +type MockPageMutator struct { + ContainerTypeFunc func() backend.ContainerKind + SetWidgetPropertyFunc func(widgetRef string, prop string, value any) error + SetWidgetDataSourceFunc func(widgetRef string, ds pages.DataSource) error + SetColumnPropertyFunc func(gridRef string, columnRef string, prop string, value any) error + InsertWidgetFunc func(widgetRef string, columnRef string, position backend.InsertPosition, widgets []pages.Widget) error + DropWidgetFunc func(refs []backend.WidgetRef) error + ReplaceWidgetFunc func(widgetRef string, columnRef string, widgets []pages.Widget) error + FindWidgetFunc func(name string) bool + AddVariableFunc func(name, dataType, defaultValue string) error + DropVariableFunc func(name string) error + SetLayoutFunc func(newLayout string, paramMappings map[string]string) error + SetPluggablePropertyFunc func(widgetRef string, propKey string, op backend.PluggablePropertyOp, ctx backend.PluggablePropertyContext) error + EnclosingEntityFunc func(widgetRef string) string + WidgetScopeFunc func() map[string]model.ID + ParamScopeFunc func() (map[string]model.ID, map[string]string) + SaveFunc func() error +} + +func (m *MockPageMutator) ContainerType() backend.ContainerKind { + if m.ContainerTypeFunc != nil { + return m.ContainerTypeFunc() + } + return backend.ContainerPage +} + +func (m *MockPageMutator) SetWidgetProperty(widgetRef string, prop string, value any) error { + if m.SetWidgetPropertyFunc != nil { + return m.SetWidgetPropertyFunc(widgetRef, prop, value) + } + return nil +} + +func (m *MockPageMutator) SetWidgetDataSource(widgetRef string, ds pages.DataSource) error { + if m.SetWidgetDataSourceFunc != nil { + return m.SetWidgetDataSourceFunc(widgetRef, ds) + } + return nil +} + +func (m *MockPageMutator) SetColumnProperty(gridRef string, columnRef string, prop string, value any) error { + if m.SetColumnPropertyFunc != nil { + return m.SetColumnPropertyFunc(gridRef, columnRef, prop, value) + } + return nil +} + +func (m *MockPageMutator) InsertWidget(widgetRef string, columnRef string, position backend.InsertPosition, widgets []pages.Widget) error { + if m.InsertWidgetFunc != nil { + return m.InsertWidgetFunc(widgetRef, columnRef, position, widgets) + } + return nil +} + +func (m *MockPageMutator) DropWidget(refs []backend.WidgetRef) error { + if m.DropWidgetFunc != nil { + return m.DropWidgetFunc(refs) + } + return nil +} + +func (m *MockPageMutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pages.Widget) error { + if m.ReplaceWidgetFunc != nil { + return m.ReplaceWidgetFunc(widgetRef, columnRef, widgets) + } + return nil +} + +func (m *MockPageMutator) FindWidget(name string) bool { + if m.FindWidgetFunc != nil { + return m.FindWidgetFunc(name) + } + return false +} + +func (m *MockPageMutator) AddVariable(name, dataType, defaultValue string) error { + if m.AddVariableFunc != nil { + return m.AddVariableFunc(name, dataType, defaultValue) + } + return nil +} + +func (m *MockPageMutator) DropVariable(name string) error { + if m.DropVariableFunc != nil { + return m.DropVariableFunc(name) + } + return nil +} + +func (m *MockPageMutator) SetLayout(newLayout string, paramMappings map[string]string) error { + if m.SetLayoutFunc != nil { + return m.SetLayoutFunc(newLayout, paramMappings) + } + return nil +} + +func (m *MockPageMutator) SetPluggableProperty(widgetRef string, propKey string, op backend.PluggablePropertyOp, ctx backend.PluggablePropertyContext) error { + if m.SetPluggablePropertyFunc != nil { + return m.SetPluggablePropertyFunc(widgetRef, propKey, op, ctx) + } + return nil +} + +func (m *MockPageMutator) EnclosingEntity(widgetRef string) string { + if m.EnclosingEntityFunc != nil { + return m.EnclosingEntityFunc(widgetRef) + } + return "" +} + +func (m *MockPageMutator) WidgetScope() map[string]model.ID { + if m.WidgetScopeFunc != nil { + return m.WidgetScopeFunc() + } + return nil +} + +func (m *MockPageMutator) ParamScope() (map[string]model.ID, map[string]string) { + if m.ParamScopeFunc != nil { + return m.ParamScopeFunc() + } + return nil, nil +} + +func (m *MockPageMutator) Save() error { + if m.SaveFunc != nil { + return m.SaveFunc() + } + return nil +} diff --git a/mdl/backend/mock/mock_workflow_mutator.go b/mdl/backend/mock/mock_workflow_mutator.go new file mode 100644 index 00000000..b184953b --- /dev/null +++ b/mdl/backend/mock/mock_workflow_mutator.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mock + +import ( + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +var _ backend.WorkflowMutator = (*MockWorkflowMutator)(nil) + +// MockWorkflowMutator implements backend.WorkflowMutator. Every interface +// method is backed by a public function field. If the field is nil the +// method returns zero values / nil error (never panics). +type MockWorkflowMutator struct { + SetPropertyFunc func(prop string, value string) error + SetPropertyWithEntityFunc func(prop string, value string, entity string) error + SetActivityPropertyFunc func(activityRef string, atPos int, prop string, value string) error + InsertAfterActivityFunc func(activityRef string, atPos int, activities []workflows.WorkflowActivity) error + DropActivityFunc func(activityRef string, atPos int) error + ReplaceActivityFunc func(activityRef string, atPos int, activities []workflows.WorkflowActivity) error + InsertOutcomeFunc func(activityRef string, atPos int, outcomeName string, activities []workflows.WorkflowActivity) error + DropOutcomeFunc func(activityRef string, atPos int, outcomeName string) error + InsertPathFunc func(activityRef string, atPos int, pathCaption string, activities []workflows.WorkflowActivity) error + DropPathFunc func(activityRef string, atPos int, pathCaption string) error + InsertBranchFunc func(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error + DropBranchFunc func(activityRef string, atPos int, branchName string) error + InsertBoundaryEventFunc func(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error + DropBoundaryEventFunc func(activityRef string, atPos int) error + SaveFunc func() error +} + +func (m *MockWorkflowMutator) SetProperty(prop string, value string) error { + if m.SetPropertyFunc != nil { + return m.SetPropertyFunc(prop, value) + } + return nil +} + +func (m *MockWorkflowMutator) SetPropertyWithEntity(prop string, value string, entity string) error { + if m.SetPropertyWithEntityFunc != nil { + return m.SetPropertyWithEntityFunc(prop, value, entity) + } + return nil +} + +func (m *MockWorkflowMutator) SetActivityProperty(activityRef string, atPos int, prop string, value string) error { + if m.SetActivityPropertyFunc != nil { + return m.SetActivityPropertyFunc(activityRef, atPos, prop, value) + } + return nil +} + +func (m *MockWorkflowMutator) InsertAfterActivity(activityRef string, atPos int, activities []workflows.WorkflowActivity) error { + if m.InsertAfterActivityFunc != nil { + return m.InsertAfterActivityFunc(activityRef, atPos, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropActivity(activityRef string, atPos int) error { + if m.DropActivityFunc != nil { + return m.DropActivityFunc(activityRef, atPos) + } + return nil +} + +func (m *MockWorkflowMutator) ReplaceActivity(activityRef string, atPos int, activities []workflows.WorkflowActivity) error { + if m.ReplaceActivityFunc != nil { + return m.ReplaceActivityFunc(activityRef, atPos, activities) + } + return nil +} + +func (m *MockWorkflowMutator) InsertOutcome(activityRef string, atPos int, outcomeName string, activities []workflows.WorkflowActivity) error { + if m.InsertOutcomeFunc != nil { + return m.InsertOutcomeFunc(activityRef, atPos, outcomeName, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropOutcome(activityRef string, atPos int, outcomeName string) error { + if m.DropOutcomeFunc != nil { + return m.DropOutcomeFunc(activityRef, atPos, outcomeName) + } + return nil +} + +func (m *MockWorkflowMutator) InsertPath(activityRef string, atPos int, pathCaption string, activities []workflows.WorkflowActivity) error { + if m.InsertPathFunc != nil { + return m.InsertPathFunc(activityRef, atPos, pathCaption, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropPath(activityRef string, atPos int, pathCaption string) error { + if m.DropPathFunc != nil { + return m.DropPathFunc(activityRef, atPos, pathCaption) + } + return nil +} + +func (m *MockWorkflowMutator) InsertBranch(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error { + if m.InsertBranchFunc != nil { + return m.InsertBranchFunc(activityRef, atPos, condition, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropBranch(activityRef string, atPos int, branchName string) error { + if m.DropBranchFunc != nil { + return m.DropBranchFunc(activityRef, atPos, branchName) + } + return nil +} + +func (m *MockWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error { + if m.InsertBoundaryEventFunc != nil { + return m.InsertBoundaryEventFunc(activityRef, atPos, eventType, delay, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropBoundaryEvent(activityRef string, atPos int) error { + if m.DropBoundaryEventFunc != nil { + return m.DropBoundaryEventFunc(activityRef, atPos) + } + return nil +} + +func (m *MockWorkflowMutator) Save() error { + if m.SaveFunc != nil { + return m.SaveFunc() + } + return nil +} diff --git a/mdl/backend/mpr/backend.go b/mdl/backend/mpr/backend.go index da20193b..48e5426b 100644 --- a/mdl/backend/mpr/backend.go +++ b/mdl/backend/mpr/backend.go @@ -85,9 +85,11 @@ func (b *MprBackend) Path() string { return b.path } // 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() } +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() } // Commit is a no-op — the MPR writer auto-commits on each write operation. func (b *MprBackend) Commit() error { return nil } @@ -112,7 +114,9 @@ func (b *MprBackend) DeleteModuleWithCleanup(id model.ID, moduleName string) err // FolderBackend // --------------------------------------------------------------------------- -func (b *MprBackend) ListFolders() ([]*types.FolderInfo, error) { return convertFolderInfoSlice(b.reader.ListFolders()) } +func (b *MprBackend) ListFolders() ([]*types.FolderInfo, error) { + return convertFolderInfoSlice(b.reader.ListFolders()) +} func (b *MprBackend) CreateFolder(folder *model.Folder) error { return b.writer.CreateFolder(folder) } func (b *MprBackend) DeleteFolder(id model.ID) error { return b.writer.DeleteFolder(id) } func (b *MprBackend) MoveFolder(id model.ID, newContainerID model.ID) error { @@ -678,8 +682,10 @@ func (b *MprBackend) UpdateRawUnit(unitID string, contents []byte) error { // MetadataBackend // --------------------------------------------------------------------------- -func (b *MprBackend) ListAllUnitIDs() ([]string, error) { return b.reader.ListAllUnitIDs() } -func (b *MprBackend) ListUnits() ([]*types.UnitInfo, error) { return convertUnitInfoSlice(b.reader.ListUnits()) } +func (b *MprBackend) ListAllUnitIDs() ([]string, error) { return b.reader.ListAllUnitIDs() } +func (b *MprBackend) ListUnits() ([]*types.UnitInfo, error) { + return convertUnitInfoSlice(b.reader.ListUnits()) +} func (b *MprBackend) GetUnitTypes() (map[string]int, error) { return b.reader.GetUnitTypes() } func (b *MprBackend) GetProjectRootID() (string, error) { return b.reader.GetProjectRootID() } func (b *MprBackend) ContentsDir() string { return b.reader.ContentsDir() } diff --git a/mdl/backend/mpr/convert_roundtrip_test.go b/mdl/backend/mpr/convert_roundtrip_test.go index a10391fc..157572f0 100644 --- a/mdl/backend/mpr/convert_roundtrip_test.go +++ b/mdl/backend/mpr/convert_roundtrip_test.go @@ -648,4 +648,3 @@ func TestFieldCountDrift(t *testing.T) { assertFieldCount(t, "mpr.EntityAccessRevocation", mpr.EntityAccessRevocation{}, 6) assertFieldCount(t, "types.EntityAccessRevocation", types.EntityAccessRevocation{}, 6) } - diff --git a/mdl/backend/mpr/workflow_mutator.go b/mdl/backend/mpr/workflow_mutator.go index 01c00238..5a1b78ba 100644 --- a/mdl/backend/mpr/workflow_mutator.go +++ b/mdl/backend/mpr/workflow_mutator.go @@ -162,14 +162,19 @@ func (m *mprWorkflowMutator) SetActivityProperty(activityRef string, atPos int, case "PAGE": taskPage := dGetDoc(actDoc, "TaskPage") if taskPage != nil { + // TaskPage exists and has a value — update the Page field in place. dSet(taskPage, "Page", value) - } else { - pageRef := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$PageReference"}, - {Key: "Page", Value: value}, - } - dSet(actDoc, "TaskPage", pageRef) + return nil + } + pageRef := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$PageReference"}, + {Key: "Page", Value: value}, + } + if !dSet(actDoc, "TaskPage", pageRef) { + // TaskPage key absent — append to activity and replace in BSON tree. + actDoc = append(actDoc, bson.E{Key: "TaskPage", Value: pageRef}) + m.replaceActivity(actDoc) } return nil @@ -555,6 +560,42 @@ func (m *mprWorkflowMutator) Save() error { // Internal helpers — activity search // --------------------------------------------------------------------------- +// replaceActivity replaces an activity document in the workflow's BSON tree +// by matching on $ID. This is needed when appending new keys to an activity +// document, because the slice header returned by findActivityByCaption cannot +// propagate appends back to the parent bson.A. +func (m *mprWorkflowMutator) replaceActivity(updated bson.D) { + actID := extractBinaryIDFromDoc(dGet(updated, "$ID")) + if actID == "" { + return + } + flow := dGetDoc(m.rawData, "Flow") + if flow == nil { + return + } + replaceActivityRecursive(flow, actID, updated) +} + +func replaceActivityRecursive(flow bson.D, actID string, updated bson.D) bool { + elements := dGetArrayElements(dGet(flow, "Activities")) + for i, elem := range elements { + actDoc, ok := elem.(bson.D) + if !ok { + continue + } + if extractBinaryIDFromDoc(dGet(actDoc, "$ID")) == actID { + elements[i] = updated + return true + } + for _, nestedFlow := range getNestedFlows(actDoc) { + if replaceActivityRecursive(nestedFlow, actID, updated) { + return true + } + } + } + return false +} + // findActivityByCaption searches the workflow for an activity matching caption. func (m *mprWorkflowMutator) findActivityByCaption(caption string, atPosition int) (bson.D, error) { flow := dGetDoc(m.rawData, "Flow") diff --git a/mdl/backend/mpr/workflow_mutator_test.go b/mdl/backend/mpr/workflow_mutator_test.go index d821614f..6ffec5e0 100644 --- a/mdl/backend/mpr/workflow_mutator_test.go +++ b/mdl/backend/mpr/workflow_mutator_test.go @@ -1136,8 +1136,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_NoDelay(t *testing.T) { // --------------------------------------------------------------------------- func TestWorkflowMutator_SetActivityProperty_Page_New(t *testing.T) { - // Note: When TaskPage key doesn't pre-exist in BSON, dSet silently fails. - // The key must be present (even as nil) for PAGE to work on a new activity. + // TaskPage key present with nil value — should be replaced with a new PageReference. act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "TaskPage", Value: nil}) m := newMutator(makeWorkflowDoc(act)) @@ -1157,21 +1156,79 @@ func TestWorkflowMutator_SetActivityProperty_Page_New(t *testing.T) { } func TestWorkflowMutator_SetActivityProperty_Page_MissingKey(t *testing.T) { - // BUG: dSet silently fails when TaskPage key is absent — pageRef is lost. + // Regression test: dSet silently failed when TaskPage key was absent. + // Fixed by appending the key to the activity and replacing it in the BSON tree. act := makeWfActivity("Workflows$UserTask", "Review", "task1") // No TaskPage field at all m := newMutator(makeWorkflowDoc(act)) - // No error returned, but the set is silently lost if err := m.SetActivityProperty("Review", 0, "PAGE", "MyModule.TaskPage"); err != nil { t.Fatalf("SetActivityProperty PAGE failed: %v", err) } actDoc, _ := m.findActivityByCaption("Review", 0) taskPage := dGetDoc(actDoc, "TaskPage") - // This documents the bug: TaskPage is nil because dSet can't create new keys - if taskPage != nil { - t.Log("BUG FIXED: TaskPage is now set even when key was absent") + if taskPage == nil { + t.Fatal("TaskPage should be set even when key was absent") + } + if got := dGetString(taskPage, "Page"); got != "MyModule.TaskPage" { + t.Errorf("Page = %q, want MyModule.TaskPage", got) + } +} + +func TestWorkflowMutator_SetActivityProperty_Page_MissingKey_NestedSubFlow(t *testing.T) { + // Exercises the recursive replaceActivity path: the target activity lives + // inside an outcome's sub-flow, not at the top level. + // Use distinct $IDs so replaceActivity cannot accidentally match the parent. + parentID := primitive.Binary{Subtype: 0x04, Data: []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}} + nestedID := primitive.Binary{Subtype: 0x04, Data: []byte{2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}} + + nestedAct := bson.D{ + {Key: "$ID", Value: nestedID}, + {Key: "$Type", Value: "Workflows$UserTask"}, + {Key: "Caption", Value: "NestedReview"}, + {Key: "Name", Value: "nested1"}, + } + // No TaskPage field at all on the nested activity. + + outcome := bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$BooleanOutcome"}, + {Key: "Flow", Value: bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$Flow"}, + {Key: "Activities", Value: bson.A{int32(3), nestedAct}}, + }}, + } + parentAct := bson.D{ + {Key: "$ID", Value: parentID}, + {Key: "$Type", Value: "Workflows$Decision"}, + {Key: "Caption", Value: "Check"}, + {Key: "Name", Value: "decision1"}, + {Key: "Outcomes", Value: bson.A{int32(3), outcome}}, + } + m := newMutator(makeWorkflowDoc(parentAct)) + + if err := m.SetActivityProperty("NestedReview", 0, "PAGE", "MyModule.NestedPage"); err != nil { + t.Fatalf("SetActivityProperty PAGE on nested activity failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("NestedReview", 0) + taskPage := dGetDoc(actDoc, "TaskPage") + if taskPage == nil { + t.Fatal("TaskPage should be set on nested activity even when key was absent") + } + if got := dGetString(taskPage, "Page"); got != "MyModule.NestedPage" { + t.Errorf("Page = %q, want MyModule.NestedPage", got) + } + + // Verify parent decision still has its Outcomes intact. + parentDoc, _ := m.findActivityByCaption("Check", 0) + if parentDoc == nil { + t.Fatal("parent decision activity should still exist") + } + if outcomes := dGet(parentDoc, "Outcomes"); outcomes == nil { + t.Fatal("parent decision Outcomes should still be present") } } diff --git a/mdl/executor/registry_test.go b/mdl/executor/registry_test.go index 4641412f..932e441b 100644 --- a/mdl/executor/registry_test.go +++ b/mdl/executor/registry_test.go @@ -172,9 +172,11 @@ func allKnownStatements() []ast.Statement { &ast.AlterUserRoleStmt{}, &ast.AlterWorkflowStmt{}, &ast.ConnectStmt{}, + &ast.CreateAgentStmt{}, &ast.CreateAssociationStmt{}, &ast.CreateBusinessEventServiceStmt{}, &ast.CreateConfigurationStmt{}, + &ast.CreateConsumedMCPServiceStmt{}, &ast.CreateConstantStmt{}, &ast.CreateDatabaseConnectionStmt{}, &ast.CreateDataTransformerStmt{}, @@ -188,7 +190,9 @@ func allKnownStatements() []ast.Statement { &ast.CreateImportMappingStmt{}, &ast.CreateJavaActionStmt{}, &ast.CreateJsonStructureStmt{}, + &ast.CreateKnowledgeBaseStmt{}, &ast.CreateMicroflowStmt{}, + &ast.CreateModelStmt{}, &ast.CreateModuleRoleStmt{}, &ast.CreateModuleStmt{}, &ast.CreateODataClientStmt{}, @@ -206,9 +210,11 @@ func allKnownStatements() []ast.Statement { &ast.DescribeStmt{}, &ast.DescribeStylingStmt{}, &ast.DisconnectStmt{}, + &ast.DropAgentStmt{}, &ast.DropAssociationStmt{}, &ast.DropBusinessEventServiceStmt{}, &ast.DropConfigurationStmt{}, + &ast.DropConsumedMCPServiceStmt{}, &ast.DropConstantStmt{}, &ast.DropDataTransformerStmt{}, &ast.DropDemoUserStmt{}, @@ -220,7 +226,9 @@ func allKnownStatements() []ast.Statement { &ast.DropImportMappingStmt{}, &ast.DropJavaActionStmt{}, &ast.DropJsonStructureStmt{}, + &ast.DropKnowledgeBaseStmt{}, &ast.DropMicroflowStmt{}, + &ast.DropModelStmt{}, &ast.DropModuleRoleStmt{}, &ast.DropModuleStmt{}, &ast.DropODataClientStmt{}, @@ -286,3 +294,15 @@ func TestNewRegistry_Completeness(t *testing.T) { t.Fatalf("registry is incomplete: %v", err) } } + +// TestNewRegistry_HandlerCountSnapshot verifies that the number of registered +// handlers matches allKnownStatements(). Keep allKnownStatements() in sync with +// known statement types and handler registrations. +func TestNewRegistry_HandlerCountSnapshot(t *testing.T) { + r := NewRegistry() + known := allKnownStatements() + + if got := r.HandlerCount(); got != len(known) { + t.Errorf("handler count mismatch: registry has %d, allKnownStatements has %d — update allKnownStatements or register missing handlers", got, len(known)) + } +} diff --git a/mdl/executor/widget_registry.go b/mdl/executor/widget_registry.go index 71192c50..f65e9b8d 100644 --- a/mdl/executor/widget_registry.go +++ b/mdl/executor/widget_registry.go @@ -16,8 +16,8 @@ import ( // WidgetRegistry holds loaded widget definitions keyed by uppercase MDL name. type WidgetRegistry struct { - byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName - byWidgetID map[string]*WidgetDefinition // keyed by widgetId + byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName + byWidgetID map[string]*WidgetDefinition // keyed by widgetId knownOperations map[string]bool // operations accepted during validation } diff --git a/mdl/types/edmx_test.go b/mdl/types/edmx_test.go index a4159e93..01d2b374 100644 --- a/mdl/types/edmx_test.go +++ b/mdl/types/edmx_test.go @@ -217,9 +217,9 @@ func TestFindEntityType(t *testing.T) { func TestResolveNavType(t *testing.T) { tests := []struct { - input string - typeName string - isMany bool + input string + typeName string + isMany bool }{ {"Collection(NS.Order)", "Order", true}, {"NS.Customer", "Customer", false}, diff --git a/mdl/types/id_test.go b/mdl/types/id_test.go index d30a5e39..aa138154 100644 --- a/mdl/types/id_test.go +++ b/mdl/types/id_test.go @@ -156,7 +156,7 @@ func TestValidateID(t *testing.T) { {"AABBCCDD-EEFF-1122-3344-556677889900", true}, {"", false}, {"too-short", false}, - {"a1b2c3d4-e5f6-7890-abcd-ef123456789", false}, // 35 chars + {"a1b2c3d4-e5f6-7890-abcd-ef123456789", false}, // 35 chars {"a1b2c3d4-e5f6-7890-abcd-ef12345678901", false}, // 37 chars {"a1b2c3d4xe5f6-7890-abcd-ef1234567890", false}, // wrong separator {"g1b2c3d4-e5f6-7890-abcd-ef1234567890", false}, // invalid hex diff --git a/mdl/visitor/visitor_agenteditor_test.go b/mdl/visitor/visitor_agenteditor_test.go new file mode 100644 index 00000000..50117826 --- /dev/null +++ b/mdl/visitor/visitor_agenteditor_test.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateModel(t *testing.T) { + input := `CREATE MODEL MyModule.GPT4 ( + Provider: MxCloudGenAI, + Key: MyModule.APIKey, + DisplayName: 'GPT-4 Turbo' + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateModelStmt) + if !ok { + t.Fatalf("Expected CreateModelStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Module != "MyModule" || stmt.Name.Name != "GPT4" { + t.Errorf("Expected MyModule.GPT4, got %s.%s", stmt.Name.Module, stmt.Name.Name) + } + if stmt.Provider != "MxCloudGenAI" { + t.Errorf("Got Provider %q", stmt.Provider) + } + if stmt.Key == nil || stmt.Key.Name != "APIKey" { + t.Error("Key mismatch") + } + if stmt.DisplayName != "GPT-4 Turbo" { + t.Errorf("Got DisplayName %q", stmt.DisplayName) + } +} + +func TestCreateConsumedMCPService(t *testing.T) { + input := `CREATE CONSUMED MCP SERVICE MyModule.ToolService ( + ProtocolVersion: v2025_03_26, + Version: '0.0.1', + ConnectionTimeoutSeconds: 30, + Documentation: 'MCP tool service' + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateConsumedMCPServiceStmt) + if !ok { + t.Fatalf("Expected CreateConsumedMCPServiceStmt, got %T", prog.Statements[0]) + } + if stmt.ProtocolVersion != "v2025_03_26" { + t.Errorf("Got ProtocolVersion %q", stmt.ProtocolVersion) + } + if stmt.Version != "0.0.1" { + t.Errorf("Got Version %q", stmt.Version) + } + if stmt.ConnectionTimeoutSeconds != 30 { + t.Errorf("Got ConnectionTimeoutSeconds %d", stmt.ConnectionTimeoutSeconds) + } + if stmt.InnerDocumentation != "MCP tool service" { + t.Errorf("Got InnerDocumentation %q", stmt.InnerDocumentation) + } +} + +func TestCreateKnowledgeBase(t *testing.T) { + input := `CREATE KNOWLEDGE BASE MyModule.ProductKB ( + Provider: MxCloudGenAI, + Key: MyModule.KBKey, + ModelDisplayName: 'Product Knowledge', + ModelName: 'product-embeddings' + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateKnowledgeBaseStmt) + if !ok { + t.Fatalf("Expected CreateKnowledgeBaseStmt, got %T", prog.Statements[0]) + } + if stmt.Provider != "MxCloudGenAI" { + t.Errorf("Got Provider %q", stmt.Provider) + } + if stmt.Key == nil || stmt.Key.Name != "KBKey" { + t.Error("Key mismatch") + } + if stmt.ModelDisplayName != "Product Knowledge" { + t.Errorf("Got ModelDisplayName %q", stmt.ModelDisplayName) + } +} + +func TestCreateAgent_Basic(t *testing.T) { + input := `CREATE AGENT MyModule.OrderAgent ( + UsageType: Task, + Model: MyModule.GPT4, + Entity: MyModule.OrderContext, + SystemPrompt: 'You are an order processing assistant.', + ToolChoice: auto, + MaxTokens: 4096, + Temperature: 0.7 + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateAgentStmt) + if !ok { + t.Fatalf("Expected CreateAgentStmt, got %T", prog.Statements[0]) + } + if stmt.UsageType != "Task" { + t.Errorf("Got UsageType %q", stmt.UsageType) + } + if stmt.Model == nil || stmt.Model.Name != "GPT4" { + t.Error("Model mismatch") + } + if stmt.Entity == nil || stmt.Entity.Name != "OrderContext" { + t.Error("Entity mismatch") + } + if stmt.MaxTokens == nil || *stmt.MaxTokens != 4096 { + t.Error("MaxTokens mismatch") + } + if stmt.Temperature == nil || *stmt.Temperature != 0.7 { + t.Error("Temperature mismatch") + } + if stmt.ToolChoice != "auto" { + t.Errorf("Got ToolChoice %q", stmt.ToolChoice) + } +} diff --git a/mdl/visitor/visitor_association_test.go b/mdl/visitor/visitor_association_test.go new file mode 100644 index 00000000..34469deb --- /dev/null +++ b/mdl/visitor/visitor_association_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateAssociation(t *testing.T) { + input := `CREATE ASSOCIATION MyModule.Order_Customer FROM MyModule.Order TO MyModule.Customer TYPE REFERENCE OWNER DEFAULT;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateAssociationStmt) + if !ok { + t.Fatalf("Expected CreateAssociationStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Module != "MyModule" || stmt.Name.Name != "Order_Customer" { + t.Errorf("Got name %s.%s", stmt.Name.Module, stmt.Name.Name) + } + if stmt.Parent.Name != "Order" { + t.Errorf("Got parent %s", stmt.Parent.Name) + } + if stmt.Child.Name != "Customer" { + t.Errorf("Got child %s", stmt.Child.Name) + } + if stmt.Type != ast.AssocReference { + t.Errorf("Expected Reference type, got %v", stmt.Type) + } +} + +func TestCreateAssociation_ReferenceSet(t *testing.T) { + input := `CREATE ASSOCIATION MyModule.Order_Product FROM MyModule.Order TO MyModule.Product TYPE REFERENCE_SET OWNER BOTH;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.CreateAssociationStmt) + if stmt.Type != ast.AssocReferenceSet { + t.Errorf("Expected ReferenceSet, got %v", stmt.Type) + } + if stmt.Owner != ast.OwnerBoth { + t.Errorf("Expected OwnerBoth, got %v", stmt.Owner) + } +} + +func TestAlterAssociation_SetOwner(t *testing.T) { + input := `ALTER ASSOCIATION MyModule.Order_Customer SET OWNER BOTH;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterAssociationStmt) + if !ok { + t.Fatalf("Expected AlterAssociationStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "Order_Customer" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.Owner != ast.OwnerBoth { + t.Errorf("Expected OwnerBoth, got %v", stmt.Owner) + } +} diff --git a/mdl/visitor/visitor_businessevents_test.go b/mdl/visitor/visitor_businessevents_test.go new file mode 100644 index 00000000..cd3ca293 --- /dev/null +++ b/mdl/visitor/visitor_businessevents_test.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateBusinessEventService(t *testing.T) { + input := `CREATE BUSINESS EVENT SERVICE MyModule.OrderEvents ( + ServiceName: 'OrderService', + EventNamePrefix: 'com.example.orders' + ) { + MESSAGE OrderCreated (OrderId: Long) PUBLISH ENTITY MyModule.Order; + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateBusinessEventServiceStmt) + if !ok { + t.Fatalf("Expected CreateBusinessEventServiceStmt, got %T", prog.Statements[0]) + } + if stmt.ServiceName != "OrderService" { + t.Errorf("Got ServiceName %q", stmt.ServiceName) + } + if stmt.EventNamePrefix != "com.example.orders" { + t.Errorf("Got EventNamePrefix %q", stmt.EventNamePrefix) + } + if len(stmt.Messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(stmt.Messages)) + } + if stmt.Messages[0].MessageName != "OrderCreated" { + t.Errorf("Got MessageName %q", stmt.Messages[0].MessageName) + } + if stmt.Messages[0].Operation != "PUBLISH" { + t.Errorf("Got Operation %q", stmt.Messages[0].Operation) + } +} diff --git a/mdl/visitor/visitor_connection_test.go b/mdl/visitor/visitor_connection_test.go new file mode 100644 index 00000000..fe69424f --- /dev/null +++ b/mdl/visitor/visitor_connection_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestConnect_Local(t *testing.T) { + input := `CONNECT LOCAL '/path/to/project';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.ConnectStmt) + if !ok { + t.Fatalf("Expected ConnectStmt, got %T", prog.Statements[0]) + } + if stmt.Path != "/path/to/project" { + t.Errorf("Expected /path/to/project, got %q", stmt.Path) + } +} + +func TestDisconnect(t *testing.T) { + input := `DISCONNECT;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + _, ok := prog.Statements[0].(*ast.DisconnectStmt) + if !ok { + t.Fatalf("Expected DisconnectStmt, got %T", prog.Statements[0]) + } +} diff --git a/mdl/visitor/visitor_datatransformer_test.go b/mdl/visitor/visitor_datatransformer_test.go new file mode 100644 index 00000000..245e5526 --- /dev/null +++ b/mdl/visitor/visitor_datatransformer_test.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateDataTransformer(t *testing.T) { + input := `CREATE DATA TRANSFORMER MyModule.LatExtract SOURCE JSON '{"lat": 51.9}' { + JSLT '{ "latitude": .lat }'; + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateDataTransformerStmt) + if !ok { + t.Fatalf("Expected CreateDataTransformerStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "LatExtract" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.SourceType != "JSON" { + t.Errorf("Got SourceType %q", stmt.SourceType) + } + if len(stmt.Steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(stmt.Steps)) + } + if stmt.Steps[0].Technology != "JSLT" { + t.Errorf("Got Technology %q", stmt.Steps[0].Technology) + } +} diff --git a/mdl/visitor/visitor_dbconnection_test.go b/mdl/visitor/visitor_dbconnection_test.go new file mode 100644 index 00000000..a9fb7aaa --- /dev/null +++ b/mdl/visitor/visitor_dbconnection_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateDatabaseConnection(t *testing.T) { + input := `CREATE DATABASE CONNECTION MyModule.MyDB TYPE 'PostgreSQL' HOST 'localhost' PORT 5432 DATABASE 'mydb' USERNAME 'user' PASSWORD 'pass';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateDatabaseConnectionStmt) + if !ok { + t.Fatalf("Expected CreateDatabaseConnectionStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "MyDB" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.DatabaseType != "PostgreSQL" { + t.Errorf("Got DatabaseType %q", stmt.DatabaseType) + } + if stmt.Host != "localhost" { + t.Errorf("Got Host %q", stmt.Host) + } + if stmt.Port != 5432 { + t.Errorf("Got Port %d", stmt.Port) + } + if stmt.UserName != "user" { + t.Errorf("Got UserName %q", stmt.UserName) + } +} diff --git a/mdl/visitor/visitor_enumeration_test.go b/mdl/visitor/visitor_enumeration_test.go new file mode 100644 index 00000000..33121a12 --- /dev/null +++ b/mdl/visitor/visitor_enumeration_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestAlterEnumeration_AddValue(t *testing.T) { + input := `ALTER ENUMERATION MyModule.Status ADD VALUE Pending CAPTION 'Pending';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterEnumerationStmt) + if !ok { + t.Fatalf("Expected AlterEnumerationStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "Status" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.Operation != ast.AlterEnumAdd { + t.Errorf("Expected Add, got %v", stmt.Operation) + } + if stmt.ValueName != "Pending" { + t.Errorf("Got ValueName %q", stmt.ValueName) + } + if stmt.Caption != "Pending" { + t.Errorf("Got Caption %q", stmt.Caption) + } +} + +func TestAlterEnumeration_RenameValue(t *testing.T) { + input := `ALTER ENUMERATION MyModule.Status RENAME VALUE Active TO Enabled;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.AlterEnumerationStmt) + if stmt.Operation != ast.AlterEnumRename { + t.Errorf("Expected Rename, got %v", stmt.Operation) + } + if stmt.ValueName != "Active" { + t.Errorf("Got ValueName %q", stmt.ValueName) + } + if stmt.NewName != "Enabled" { + t.Errorf("Got NewName %q", stmt.NewName) + } +} + +func TestAlterEnumeration_DropValue(t *testing.T) { + input := `ALTER ENUMERATION MyModule.Status DROP VALUE Obsolete;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.AlterEnumerationStmt) + if stmt.Operation != ast.AlterEnumDrop { + t.Errorf("Expected Drop, got %v", stmt.Operation) + } +} + +func TestCreateConstant(t *testing.T) { + input := `CREATE CONSTANT MyModule.MaxRetries TYPE Integer DEFAULT 3 COMMENT 'Max retry count';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateConstantStmt) + if !ok { + t.Fatalf("Expected CreateConstantStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "MaxRetries" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.Comment != "Max retry count" { + t.Errorf("Got Comment %q", stmt.Comment) + } +} + +func TestCreateConstant_ExposedToClient(t *testing.T) { + input := `CREATE CONSTANT MyModule.AppUrl TYPE String DEFAULT 'https://example.com' EXPOSED TO CLIENT;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.CreateConstantStmt) + if !stmt.ExposedToClient { + t.Error("Expected ExposedToClient true") + } +} diff --git a/mdl/visitor/visitor_export_mapping_test.go b/mdl/visitor/visitor_export_mapping_test.go new file mode 100644 index 00000000..a59e0363 --- /dev/null +++ b/mdl/visitor/visitor_export_mapping_test.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateExportMapping(t *testing.T) { + input := `CREATE EXPORT MAPPING MyModule.PetExport WITH JSON STRUCTURE MyModule.PetSchema { + MyModule.Pet { + name = Name, + id = PetId + } + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateExportMappingStmt) + if !ok { + t.Fatalf("Expected CreateExportMappingStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "PetExport" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.SchemaKind != "JSON_STRUCTURE" { + t.Errorf("Got SchemaKind %q", stmt.SchemaKind) + } + if stmt.RootElement == nil { + t.Fatal("Expected non-nil RootElement") + } +} diff --git a/mdl/visitor/visitor_imagecollection_test.go b/mdl/visitor/visitor_imagecollection_test.go new file mode 100644 index 00000000..c8ac39f5 --- /dev/null +++ b/mdl/visitor/visitor_imagecollection_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateImageCollection(t *testing.T) { + input := `CREATE IMAGE COLLECTION MyModule.Icons EXPORT LEVEL 'Public' ( + IMAGE MyIcon FROM FILE '/images/icon.png' + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateImageCollectionStmt) + if !ok { + t.Fatalf("Expected CreateImageCollectionStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "Icons" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.ExportLevel != "Public" { + t.Errorf("Got ExportLevel %q", stmt.ExportLevel) + } + if len(stmt.Images) != 1 { + t.Fatalf("Expected 1 image, got %d", len(stmt.Images)) + } + if stmt.Images[0].Name != "MyIcon" { + t.Errorf("Got image name %q", stmt.Images[0].Name) + } + if stmt.Images[0].FilePath != "/images/icon.png" { + t.Errorf("Got FilePath %q", stmt.Images[0].FilePath) + } +} diff --git a/mdl/visitor/visitor_import_mapping_test.go b/mdl/visitor/visitor_import_mapping_test.go new file mode 100644 index 00000000..d73eaa10 --- /dev/null +++ b/mdl/visitor/visitor_import_mapping_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateImportMapping(t *testing.T) { + input := `CREATE IMPORT MAPPING MyModule.PetMapping WITH JSON STRUCTURE MyModule.PetSchema { + CREATE MyModule.Pet { + PetId = id KEY, + Name = name + } + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateImportMappingStmt) + if !ok { + t.Fatalf("Expected CreateImportMappingStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "PetMapping" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.SchemaKind != "JSON_STRUCTURE" { + t.Errorf("Got SchemaKind %q", stmt.SchemaKind) + } + if stmt.SchemaRef.Name != "PetSchema" { + t.Errorf("Got SchemaRef %s", stmt.SchemaRef.Name) + } + if stmt.RootElement == nil { + t.Fatal("Expected non-nil RootElement") + } +} diff --git a/mdl/visitor/visitor_jsonstructure_test.go b/mdl/visitor/visitor_jsonstructure_test.go new file mode 100644 index 00000000..2743bc95 --- /dev/null +++ b/mdl/visitor/visitor_jsonstructure_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateJsonStructure(t *testing.T) { + input := `CREATE JSON STRUCTURE MyModule.PetSchema SNIPPET '{"id": 1, "name": "test"}';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateJsonStructureStmt) + if !ok { + t.Fatalf("Expected CreateJsonStructureStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "PetSchema" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.JsonSnippet != `{"id": 1, "name": "test"}` { + t.Errorf("Got JsonSnippet %q", stmt.JsonSnippet) + } +} diff --git a/mdl/visitor/visitor_lint_test.go b/mdl/visitor/visitor_lint_test.go new file mode 100644 index 00000000..6a2e1cab --- /dev/null +++ b/mdl/visitor/visitor_lint_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestLint_All(t *testing.T) { + input := `LINT;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.LintStmt) + if !ok { + t.Fatalf("Expected LintStmt, got %T", prog.Statements[0]) + } + if stmt.Target != nil { + t.Error("Expected nil Target") + } + if stmt.ShowRules { + t.Error("Expected ShowRules false") + } +} + +func TestLint_ModuleOnly(t *testing.T) { + input := `LINT MyModule.*;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.LintStmt) + if !ok { + t.Fatalf("Expected LintStmt, got %T", prog.Statements[0]) + } + if !stmt.ModuleOnly { + t.Error("Expected ModuleOnly true") + } + if stmt.Target == nil { + t.Fatal("Expected non-nil Target") + } + if stmt.Target.Name != "MyModule" { + t.Errorf("Expected Target.Name %q, got %q", "MyModule", stmt.Target.Name) + } + if stmt.Target.Module != "" { + t.Errorf("Expected Target.Module %q, got %q", "", stmt.Target.Module) + } +} + +func TestLint_WithFormat(t *testing.T) { + input := `LINT * FORMAT JSON;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.LintStmt) + if !ok { + t.Fatalf("Expected LintStmt, got %T", prog.Statements[0]) + } + if stmt.Format != "json" { + t.Errorf("Expected json format, got %q", stmt.Format) + } +} + +func TestShowLintRules(t *testing.T) { + input := `SHOW LINT RULES;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.LintStmt) + if !ok { + t.Fatalf("Expected LintStmt, got %T", prog.Statements[0]) + } + if !stmt.ShowRules { + t.Error("Expected ShowRules true") + } +} diff --git a/mdl/visitor/visitor_misc_test.go b/mdl/visitor/visitor_misc_test.go new file mode 100644 index 00000000..97ef1c74 --- /dev/null +++ b/mdl/visitor/visitor_misc_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestSearch(t *testing.T) { + input := `SEARCH 'customer';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.SearchStmt) + if !ok { + t.Fatalf("Expected SearchStmt, got %T", prog.Statements[0]) + } + if stmt.Query != "customer" { + t.Errorf("Got Query %q", stmt.Query) + } +} + +func TestExecuteScript(t *testing.T) { + input := `EXECUTE SCRIPT 'myscript.mdl';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.ExecuteScriptStmt) + if !ok { + t.Fatalf("Expected ExecuteScriptStmt, got %T", prog.Statements[0]) + } + if stmt.Path != "myscript.mdl" { + t.Errorf("Got Path %q", stmt.Path) + } +} + +func TestHelp(t *testing.T) { + input := `help;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + _, ok := prog.Statements[0].(*ast.HelpStmt) + if !ok { + t.Fatalf("Expected HelpStmt, got %T", prog.Statements[0]) + } +} + +func TestUpdate(t *testing.T) { + input := `UPDATE;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + _, ok := prog.Statements[0].(*ast.UpdateStmt) + if !ok { + t.Fatalf("Expected UpdateStmt, got %T", prog.Statements[0]) + } +} diff --git a/mdl/visitor/visitor_module_test.go b/mdl/visitor/visitor_module_test.go new file mode 100644 index 00000000..a43a2fe7 --- /dev/null +++ b/mdl/visitor/visitor_module_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateModule(t *testing.T) { + input := `CREATE MODULE OrderManagement;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + if len(prog.Statements) != 1 { + t.Fatalf("Expected 1 statement, got %d", len(prog.Statements)) + } + stmt, ok := prog.Statements[0].(*ast.CreateModuleStmt) + if !ok { + t.Fatalf("Expected CreateModuleStmt, got %T", prog.Statements[0]) + } + if stmt.Name != "OrderManagement" { + t.Errorf("Expected OrderManagement, got %q", stmt.Name) + } +} diff --git a/mdl/visitor/visitor_move_test.go b/mdl/visitor/visitor_move_test.go new file mode 100644 index 00000000..7225b852 --- /dev/null +++ b/mdl/visitor/visitor_move_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestMovePage_ToFolder(t *testing.T) { + input := `MOVE PAGE MyModule.OldPage TO FOLDER 'Admin/Pages';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.MoveStmt) + if !ok { + t.Fatalf("Expected MoveStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "OldPage" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.Folder != "Admin/Pages" { + t.Errorf("Got Folder %q", stmt.Folder) + } +} + +func TestMoveEntity_ToModule(t *testing.T) { + input := `MOVE ENTITY MyModule.Customer TO OtherModule;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.MoveStmt) + if !ok { + t.Fatalf("Expected MoveStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "Customer" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.TargetModule != "OtherModule" { + t.Errorf("Got TargetModule %q", stmt.TargetModule) + } +} diff --git a/mdl/visitor/visitor_navigation_test.go b/mdl/visitor/visitor_navigation_test.go new file mode 100644 index 00000000..af1bf127 --- /dev/null +++ b/mdl/visitor/visitor_navigation_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateNavigation(t *testing.T) { + input := `CREATE NAVIGATION Responsive + HOME PAGE MyModule.HomePage + LOGIN PAGE MyModule.LoginPage + MENU ( + MENU ITEM 'Dashboard' PAGE MyModule.Dashboard; + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterNavigationStmt) + if !ok { + t.Fatalf("Expected AlterNavigationStmt, got %T", prog.Statements[0]) + } + if stmt.ProfileName != "Responsive" { + t.Errorf("Got ProfileName %q", stmt.ProfileName) + } + if len(stmt.HomePages) != 1 { + t.Fatalf("Expected 1 home page, got %d", len(stmt.HomePages)) + } + if !stmt.HomePages[0].IsPage { + t.Error("Expected IsPage true") + } + if stmt.HomePages[0].Target.Name != "HomePage" { + t.Errorf("Got target %s", stmt.HomePages[0].Target.Name) + } + if stmt.LoginPage == nil || stmt.LoginPage.Name != "LoginPage" { + t.Error("LoginPage mismatch") + } + if !stmt.HasMenuBlock { + t.Error("Expected HasMenuBlock true") + } + if len(stmt.MenuItems) != 1 { + t.Fatalf("Expected 1 menu item, got %d", len(stmt.MenuItems)) + } + if stmt.MenuItems[0].Caption != "Dashboard" { + t.Errorf("Got caption %q", stmt.MenuItems[0].Caption) + } +} diff --git a/mdl/visitor/visitor_odata_test.go b/mdl/visitor/visitor_odata_test.go new file mode 100644 index 00000000..8b92e28d --- /dev/null +++ b/mdl/visitor/visitor_odata_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateODataClient_Basic(t *testing.T) { + input := `CREATE ODATA CLIENT MyModule.PetStore ( + Version: '1.0', + ODataVersion: OData4, + MetadataUrl: 'https://petstore.example.com/odata/$metadata' + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + if len(prog.Statements) != 1 { + t.Fatalf("Expected 1 statement, got %d", len(prog.Statements)) + } + stmt, ok := prog.Statements[0].(*ast.CreateODataClientStmt) + if !ok { + t.Fatalf("Expected CreateODataClientStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Module != "MyModule" || stmt.Name.Name != "PetStore" { + t.Errorf("Expected MyModule.PetStore, got %s.%s", stmt.Name.Module, stmt.Name.Name) + } + if stmt.MetadataUrl != "https://petstore.example.com/odata/$metadata" { + t.Errorf("Got MetadataUrl %q", stmt.MetadataUrl) + } + if stmt.Version != "1.0" { + t.Errorf("Got Version %q", stmt.Version) + } +} + +func TestCreateODataService(t *testing.T) { + input := `CREATE ODATA SERVICE MyModule.ProductAPI ( + Path: '/odata/v1/products', + Version: '1.0.0', + ODataVersion: OData4 + ) AUTHENTICATION Basic, Session + { + PUBLISH ENTITY MyModule.Product AS 'Products' + (ReadMode: 'FromDatabase') + EXPOSE (Name, Price); + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateODataServiceStmt) + if !ok { + t.Fatalf("Expected CreateODataServiceStmt, got %T", prog.Statements[0]) + } + if stmt.Path != "/odata/v1/products" { + t.Errorf("Got Path %q", stmt.Path) + } + if len(stmt.AuthenticationTypes) != 2 { + t.Fatalf("Expected 2 auth types, got %d", len(stmt.AuthenticationTypes)) + } + if len(stmt.Entities) != 1 { + t.Fatalf("Expected 1 entity, got %d", len(stmt.Entities)) + } + if stmt.Entities[0].ExposedName != "Products" { + t.Errorf("Got ExposedName %q", stmt.Entities[0].ExposedName) + } + if len(stmt.Entities[0].Members) != 2 { + t.Errorf("Expected 2 members, got %d", len(stmt.Entities[0].Members)) + } +} + +func TestCreateExternalEntity(t *testing.T) { + input := `CREATE EXTERNAL ENTITY MyModule.RemoteProduct FROM ODATA CLIENT MyModule.PetStore + (EntitySet: 'Products', RemoteName: 'Product', Countable: true) + (Name: String(200), Price: Decimal);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateExternalEntityStmt) + if !ok { + t.Fatalf("Expected CreateExternalEntityStmt, got %T", prog.Statements[0]) + } + if stmt.ServiceRef.Name != "PetStore" { + t.Errorf("Got ServiceRef %s", stmt.ServiceRef.Name) + } + if stmt.EntitySet != "Products" { + t.Errorf("Got EntitySet %q", stmt.EntitySet) + } + if !stmt.Countable { + t.Error("Expected Countable true") + } + if len(stmt.Attributes) != 2 { + t.Errorf("Expected 2 attributes, got %d", len(stmt.Attributes)) + } +} + +func TestCreateExternalEntities(t *testing.T) { + input := `CREATE EXTERNAL ENTITIES FROM MyModule.PetStore INTO Integration ENTITIES (Product, Category);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateExternalEntitiesStmt) + if !ok { + t.Fatalf("Expected CreateExternalEntitiesStmt, got %T", prog.Statements[0]) + } + if stmt.ServiceRef.Name != "PetStore" { + t.Errorf("Got ServiceRef %s", stmt.ServiceRef.Name) + } + if stmt.TargetModule != "Integration" { + t.Errorf("Got TargetModule %q", stmt.TargetModule) + } + if len(stmt.EntityNames) != 2 { + t.Fatalf("Expected 2 entity names, got %d", len(stmt.EntityNames)) + } + if stmt.EntityNames[0] != "Product" || stmt.EntityNames[1] != "Category" { + t.Errorf("Got %v", stmt.EntityNames) + } +} diff --git a/mdl/visitor/visitor_rest_test.go b/mdl/visitor/visitor_rest_test.go new file mode 100644 index 00000000..781b368f --- /dev/null +++ b/mdl/visitor/visitor_rest_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateRestClient_Basic(t *testing.T) { + input := `CREATE REST CLIENT MyModule.PetAPI ( + BaseUrl: 'https://api.example.com/v1' + ) { + OPERATION GetPets { + Method: GET, + Path: '/pets', + Response: JSON AS $PetList + } + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + if len(prog.Statements) != 1 { + t.Fatalf("Expected 1 statement, got %d", len(prog.Statements)) + } + stmt, ok := prog.Statements[0].(*ast.CreateRestClientStmt) + if !ok { + t.Fatalf("Expected CreateRestClientStmt, got %T", prog.Statements[0]) + } + if stmt.BaseUrl != "https://api.example.com/v1" { + t.Errorf("Got BaseUrl %q", stmt.BaseUrl) + } + if stmt.Authentication != nil { + t.Error("Expected nil Authentication") + } + if len(stmt.Operations) != 1 { + t.Fatalf("Expected 1 operation, got %d", len(stmt.Operations)) + } + op := stmt.Operations[0] + if op.Name != "GetPets" { + t.Errorf("Got Name %q", op.Name) + } + if op.Method != "GET" { + t.Errorf("Got Method %q", op.Method) + } +} + +func TestCreatePublishedRestService(t *testing.T) { + input := `CREATE PUBLISHED REST SERVICE MyModule.OrderAPI ( + Path: '/api/v1', + Version: '1.0.0', + ServiceName: 'Orders' + ) { + RESOURCE 'orders' { + GET '/{id}' MICROFLOW MyModule.GetOrder; + POST '/' MICROFLOW MyModule.CreateOrder; + } + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreatePublishedRestServiceStmt) + if !ok { + t.Fatalf("Expected CreatePublishedRestServiceStmt, got %T", prog.Statements[0]) + } + if stmt.Path != "/api/v1" { + t.Errorf("Got Path %q", stmt.Path) + } + if stmt.ServiceName != "Orders" { + t.Errorf("Got ServiceName %q", stmt.ServiceName) + } + if len(stmt.Resources) != 1 { + t.Fatalf("Expected 1 resource, got %d", len(stmt.Resources)) + } + if stmt.Resources[0].Name != "orders" { + t.Errorf("Got resource name %q", stmt.Resources[0].Name) + } + if len(stmt.Resources[0].Operations) != 2 { + t.Errorf("Expected 2 operations, got %d", len(stmt.Resources[0].Operations)) + } +} diff --git a/mdl/visitor/visitor_security_test.go b/mdl/visitor/visitor_security_test.go new file mode 100644 index 00000000..0179bdfe --- /dev/null +++ b/mdl/visitor/visitor_security_test.go @@ -0,0 +1,627 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateModuleRole(t *testing.T) { + input := `CREATE MODULE ROLE MyModule.Editor DESCRIPTION 'Can edit content';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + if len(prog.Statements) != 1 { + t.Fatalf("Expected 1 statement, got %d", len(prog.Statements)) + } + stmt, ok := prog.Statements[0].(*ast.CreateModuleRoleStmt) + if !ok { + t.Fatalf("Expected CreateModuleRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Module != "MyModule" || stmt.Name.Name != "Editor" { + t.Errorf("Expected MyModule.Editor, got %s.%s", stmt.Name.Module, stmt.Name.Name) + } + if stmt.Description != "Can edit content" { + t.Errorf("Expected description 'Can edit content', got %q", stmt.Description) + } +} + +func TestCreateModuleRole_NoDescription(t *testing.T) { + input := `CREATE MODULE ROLE MyModule.Viewer;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateModuleRoleStmt) + if !ok { + t.Fatalf("Expected CreateModuleRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Description != "" { + t.Errorf("Expected empty description, got %q", stmt.Description) + } +} + +func TestDropModuleRole(t *testing.T) { + input := `DROP MODULE ROLE MyModule.Editor;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.DropModuleRoleStmt) + if !ok { + t.Fatalf("Expected DropModuleRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Module != "MyModule" || stmt.Name.Name != "Editor" { + t.Errorf("Expected MyModule.Editor, got %s.%s", stmt.Name.Module, stmt.Name.Name) + } +} + +func TestCreateUserRole(t *testing.T) { + input := `CREATE USER ROLE Administrator (MyModule.Admin, OtherModule.FullAccess) MANAGE ALL ROLES;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateUserRoleStmt) + if !ok { + t.Fatalf("Expected CreateUserRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Name != "Administrator" { + t.Errorf("Expected Administrator, got %s", stmt.Name) + } + if len(stmt.ModuleRoles) != 2 { + t.Fatalf("Expected 2 module roles, got %d", len(stmt.ModuleRoles)) + } + if stmt.ModuleRoles[0].Module != "MyModule" || stmt.ModuleRoles[0].Name != "Admin" { + t.Errorf("Expected MyModule.Admin, got %s.%s", stmt.ModuleRoles[0].Module, stmt.ModuleRoles[0].Name) + } + if !stmt.ManageAllRoles { + t.Error("Expected ManageAllRoles true") + } + if stmt.CreateOrModify { + t.Error("Expected CreateOrModify false") + } +} + +func TestCreateUserRole_OrModify(t *testing.T) { + input := `CREATE OR MODIFY USER ROLE Editor (MyModule.Editor);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateUserRoleStmt) + if !ok { + t.Fatalf("Expected CreateUserRoleStmt, got %T", prog.Statements[0]) + } + if !stmt.CreateOrModify { + t.Error("Expected CreateOrModify true") + } + if stmt.ManageAllRoles { + t.Error("Expected ManageAllRoles false") + } +} + +func TestAlterUserRole_Add(t *testing.T) { + input := `ALTER USER ROLE Administrator ADD MODULE ROLES (NewModule.Admin);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterUserRoleStmt) + if !ok { + t.Fatalf("Expected AlterUserRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Name != "Administrator" { + t.Errorf("Expected Administrator, got %s", stmt.Name) + } + if !stmt.Add { + t.Error("Expected Add true") + } + if len(stmt.ModuleRoles) != 1 { + t.Fatalf("Expected 1 module role, got %d", len(stmt.ModuleRoles)) + } +} + +func TestAlterUserRole_Remove(t *testing.T) { + input := `ALTER USER ROLE Administrator REMOVE MODULE ROLES (OldModule.Legacy);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterUserRoleStmt) + if !ok { + t.Fatalf("Expected AlterUserRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Add { + t.Error("Expected Add false") + } +} + +func TestDropUserRole(t *testing.T) { + input := `DROP USER ROLE Administrator;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.DropUserRoleStmt) + if !ok { + t.Fatalf("Expected DropUserRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Name != "Administrator" { + t.Errorf("Expected Administrator, got %s", stmt.Name) + } +} + +// Table-driven tests for grant/revoke statements — these follow repetitive patterns. + +func TestGrantEntityAccess(t *testing.T) { + input := `GRANT MyModule.Admin ON MyModule.Customer (CREATE, DELETE, READ *, WRITE (Name, Price)) WHERE '[Active = true]';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantEntityAccessStmt) + if !ok { + t.Fatalf("Expected GrantEntityAccessStmt, got %T", prog.Statements[0]) + } + if len(stmt.Roles) != 1 { + t.Fatalf("Expected 1 role, got %d", len(stmt.Roles)) + } + if stmt.Roles[0].Module != "MyModule" || stmt.Roles[0].Name != "Admin" { + t.Errorf("Expected MyModule.Admin role, got %s.%s", stmt.Roles[0].Module, stmt.Roles[0].Name) + } + if stmt.Entity.Module != "MyModule" || stmt.Entity.Name != "Customer" { + t.Errorf("Expected MyModule.Customer entity, got %s.%s", stmt.Entity.Module, stmt.Entity.Name) + } + if len(stmt.Rights) != 4 { + t.Fatalf("Expected 4 rights, got %d", len(stmt.Rights)) + } + if stmt.Rights[0].Type != ast.EntityAccessCreate { + t.Errorf("Expected CREATE, got %v", stmt.Rights[0].Type) + } + if stmt.Rights[1].Type != ast.EntityAccessDelete { + t.Errorf("Expected DELETE, got %v", stmt.Rights[1].Type) + } + if stmt.Rights[2].Type != ast.EntityAccessReadAll { + t.Errorf("Expected READ *, got %v", stmt.Rights[2].Type) + } + if stmt.Rights[3].Type != ast.EntityAccessWriteMembers { + t.Errorf("Expected WRITE members, got %v", stmt.Rights[3].Type) + } + if len(stmt.Rights[3].Members) != 2 { + t.Fatalf("Expected 2 write members, got %d", len(stmt.Rights[3].Members)) + } + if stmt.Rights[3].Members[0] != "Name" || stmt.Rights[3].Members[1] != "Price" { + t.Errorf("Expected [Name, Price], got %v", stmt.Rights[3].Members) + } + if stmt.XPathConstraint != "[Active = true]" { + t.Errorf("Expected '[Active = true]', got %q", stmt.XPathConstraint) + } +} + +func TestGrantEntityAccess_MultipleRoles(t *testing.T) { + input := `GRANT MyModule.Admin, MyModule.Editor ON MyModule.Customer (READ *);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantEntityAccessStmt) + if !ok { + t.Fatalf("Expected GrantEntityAccessStmt, got %T", prog.Statements[0]) + } + if len(stmt.Roles) != 2 { + t.Fatalf("Expected 2 roles, got %d", len(stmt.Roles)) + } + if stmt.XPathConstraint != "" { + t.Errorf("Expected empty XPath, got %q", stmt.XPathConstraint) + } +} + +func TestRevokeEntityAccess_Full(t *testing.T) { + input := `REVOKE MyModule.Admin ON MyModule.Customer;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokeEntityAccessStmt) + if !ok { + t.Fatalf("Expected RevokeEntityAccessStmt, got %T", prog.Statements[0]) + } + if len(stmt.Rights) != 0 { + t.Errorf("Expected 0 rights for full revoke, got %d", len(stmt.Rights)) + } +} + +func TestRevokeEntityAccess_Partial(t *testing.T) { + input := `REVOKE MyModule.Admin ON MyModule.Customer (WRITE *);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokeEntityAccessStmt) + if !ok { + t.Fatalf("Expected RevokeEntityAccessStmt, got %T", prog.Statements[0]) + } + if len(stmt.Rights) != 1 { + t.Fatalf("Expected 1 right, got %d", len(stmt.Rights)) + } + if stmt.Rights[0].Type != ast.EntityAccessWriteAll { + t.Errorf("Expected WRITE *, got %v", stmt.Rights[0].Type) + } +} + +func TestGrantRevokeMicroflow(t *testing.T) { + t.Run("grant", func(t *testing.T) { + input := `GRANT EXECUTE ON MICROFLOW MyModule.ProcessOrder TO MyModule.Admin;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantMicroflowAccessStmt) + if !ok { + t.Fatalf("Expected GrantMicroflowAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Microflow.Module != "MyModule" || stmt.Microflow.Name != "ProcessOrder" { + t.Errorf("Expected MyModule.ProcessOrder, got %s.%s", stmt.Microflow.Module, stmt.Microflow.Name) + } + if len(stmt.Roles) != 1 || stmt.Roles[0].Name != "Admin" { + t.Errorf("Expected 1 role (Admin), got %v", stmt.Roles) + } + }) + + t.Run("revoke", func(t *testing.T) { + input := `REVOKE EXECUTE ON MICROFLOW MyModule.ProcessOrder FROM MyModule.Admin;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokeMicroflowAccessStmt) + if !ok { + t.Fatalf("Expected RevokeMicroflowAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Microflow.Name != "ProcessOrder" { + t.Errorf("Expected ProcessOrder, got %s", stmt.Microflow.Name) + } + }) +} + +func TestGrantRevokePage(t *testing.T) { + t.Run("grant", func(t *testing.T) { + input := `GRANT VIEW ON PAGE MyModule.HomePage TO MyModule.User;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantPageAccessStmt) + if !ok { + t.Fatalf("Expected GrantPageAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Page.Name != "HomePage" { + t.Errorf("Expected HomePage, got %s", stmt.Page.Name) + } + if len(stmt.Roles) != 1 || stmt.Roles[0].Name != "User" { + t.Errorf("Expected 1 role (User), got %v", stmt.Roles) + } + }) + + t.Run("revoke", func(t *testing.T) { + input := `REVOKE VIEW ON PAGE MyModule.AdminPage FROM MyModule.User;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokePageAccessStmt) + if !ok { + t.Fatalf("Expected RevokePageAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Page.Name != "AdminPage" { + t.Errorf("Expected AdminPage, got %s", stmt.Page.Name) + } + }) +} + +func TestGrantRevokeWorkflow(t *testing.T) { + t.Run("grant", func(t *testing.T) { + input := `GRANT EXECUTE ON WORKFLOW MyModule.ApprovalWF TO MyModule.Manager;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantWorkflowAccessStmt) + if !ok { + t.Fatalf("Expected GrantWorkflowAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Workflow.Name != "ApprovalWF" { + t.Errorf("Expected ApprovalWF, got %s", stmt.Workflow.Name) + } + }) + + t.Run("revoke", func(t *testing.T) { + input := `REVOKE EXECUTE ON WORKFLOW MyModule.ApprovalWF FROM MyModule.Manager;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokeWorkflowAccessStmt) + if !ok { + t.Fatalf("Expected RevokeWorkflowAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Workflow.Name != "ApprovalWF" { + t.Errorf("Expected ApprovalWF, got %s", stmt.Workflow.Name) + } + }) +} + +func TestGrantRevokeODataService(t *testing.T) { + t.Run("grant", func(t *testing.T) { + input := `GRANT ACCESS ON ODATA SERVICE MyModule.OrderAPI TO MyModule.APIUser;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantODataServiceAccessStmt) + if !ok { + t.Fatalf("Expected GrantODataServiceAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Service.Name != "OrderAPI" { + t.Errorf("Expected OrderAPI, got %s", stmt.Service.Name) + } + }) + + t.Run("revoke", func(t *testing.T) { + input := `REVOKE ACCESS ON ODATA SERVICE MyModule.OrderAPI FROM MyModule.APIUser;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokeODataServiceAccessStmt) + if !ok { + t.Fatalf("Expected RevokeODataServiceAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Service.Name != "OrderAPI" { + t.Errorf("Expected OrderAPI, got %s", stmt.Service.Name) + } + }) +} + +func TestGrantRevokePublishedRestService(t *testing.T) { + t.Run("grant", func(t *testing.T) { + input := `GRANT ACCESS ON PUBLISHED REST SERVICE MyModule.RestAPI TO MyModule.APIUser;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantPublishedRestServiceAccessStmt) + if !ok { + t.Fatalf("Expected GrantPublishedRestServiceAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Service.Name != "RestAPI" { + t.Errorf("Expected RestAPI, got %s", stmt.Service.Name) + } + }) + + t.Run("revoke", func(t *testing.T) { + input := `REVOKE ACCESS ON PUBLISHED REST SERVICE MyModule.RestAPI FROM MyModule.APIUser;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokePublishedRestServiceAccessStmt) + if !ok { + t.Fatalf("Expected RevokePublishedRestServiceAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Service.Name != "RestAPI" { + t.Errorf("Expected RestAPI, got %s", stmt.Service.Name) + } + }) +} + +func TestAlterProjectSecurity_Level(t *testing.T) { + input := `ALTER PROJECT SECURITY LEVEL PRODUCTION;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterProjectSecurityStmt) + if !ok { + t.Fatalf("Expected AlterProjectSecurityStmt, got %T", prog.Statements[0]) + } + if stmt.SecurityLevel != "Production" { + t.Errorf("Expected Production, got %q", stmt.SecurityLevel) + } + if stmt.DemoUsersEnabled != nil { + t.Error("Expected nil DemoUsersEnabled") + } +} + +func TestAlterProjectSecurity_DemoUsers(t *testing.T) { + input := `ALTER PROJECT SECURITY DEMO USERS ON;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterProjectSecurityStmt) + if !ok { + t.Fatalf("Expected AlterProjectSecurityStmt, got %T", prog.Statements[0]) + } + if stmt.SecurityLevel != "" { + t.Errorf("Expected empty security level, got %q", stmt.SecurityLevel) + } + if stmt.DemoUsersEnabled == nil || !*stmt.DemoUsersEnabled { + t.Error("Expected DemoUsersEnabled true") + } +} + +func TestCreateDemoUser(t *testing.T) { + input := `CREATE DEMO USER 'demo_admin' PASSWORD '1' ENTITY MyModule.Account (Administrator);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateDemoUserStmt) + if !ok { + t.Fatalf("Expected CreateDemoUserStmt, got %T", prog.Statements[0]) + } + if stmt.UserName != "demo_admin" { + t.Errorf("Expected demo_admin, got %q", stmt.UserName) + } + if stmt.Password != "1" { + t.Errorf("Expected '1', got %q", stmt.Password) + } + if stmt.Entity != "MyModule.Account" { + t.Errorf("Expected MyModule.Account, got %q", stmt.Entity) + } + if len(stmt.UserRoles) != 1 || stmt.UserRoles[0] != "Administrator" { + t.Errorf("Expected [Administrator], got %v", stmt.UserRoles) + } +} + +func TestCreateDemoUser_OrModify(t *testing.T) { + input := `CREATE OR MODIFY DEMO USER 'demo_admin' PASSWORD '1' (Administrator);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateDemoUserStmt) + if !ok { + t.Fatalf("Expected CreateDemoUserStmt, got %T", prog.Statements[0]) + } + if !stmt.CreateOrModify { + t.Error("Expected CreateOrModify true") + } +} + +func TestDropDemoUser(t *testing.T) { + input := `DROP DEMO USER 'demo_admin';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.DropDemoUserStmt) + if !ok { + t.Fatalf("Expected DropDemoUserStmt, got %T", prog.Statements[0]) + } + if stmt.UserName != "demo_admin" { + t.Errorf("Expected demo_admin, got %q", stmt.UserName) + } +} + +func TestUpdateSecurity(t *testing.T) { + input := `UPDATE SECURITY;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.UpdateSecurityStmt) + if !ok { + t.Fatalf("Expected UpdateSecurityStmt, got %T", prog.Statements[0]) + } + if stmt.Module != "" { + t.Errorf("Expected empty module, got %q", stmt.Module) + } +} + +func TestUpdateSecurity_InModule(t *testing.T) { + input := `UPDATE SECURITY IN MyModule;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.UpdateSecurityStmt) + if !ok { + t.Fatalf("Expected UpdateSecurityStmt, got %T", prog.Statements[0]) + } + if stmt.Module != "MyModule" { + t.Errorf("Expected MyModule, got %q", stmt.Module) + } +} diff --git a/mdl/visitor/visitor_settings_test.go b/mdl/visitor/visitor_settings_test.go new file mode 100644 index 00000000..5d3b4bf7 --- /dev/null +++ b/mdl/visitor/visitor_settings_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestAlterSettings_Model(t *testing.T) { + input := `ALTER SETTINGS MODEL DefaultLanguage = 'en_US';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterSettingsStmt) + if !ok { + t.Fatalf("Expected AlterSettingsStmt, got %T", prog.Statements[0]) + } + if stmt.Section != "MODEL" { + t.Errorf("Got Section %q", stmt.Section) + } + if stmt.Properties["DefaultLanguage"] != "en_US" { + t.Errorf("Got %v", stmt.Properties["DefaultLanguage"]) + } +} + +func TestAlterSettings_Constant(t *testing.T) { + input := `ALTER SETTINGS CONSTANT 'MyModule.APIEndpoint' VALUE 'https://prod.example.com';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterSettingsStmt) + if !ok { + t.Fatalf("Expected AlterSettingsStmt, got %T", prog.Statements[0]) + } + if stmt.ConstantId != "MyModule.APIEndpoint" { + t.Errorf("Got ConstantId %q", stmt.ConstantId) + } + if stmt.Value != "https://prod.example.com" { + t.Errorf("Got Value %q", stmt.Value) + } +} + +func TestAlterSettings_DropConstant(t *testing.T) { + input := `ALTER SETTINGS DROP CONSTANT 'MyModule.OldConst';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterSettingsStmt) + if !ok { + t.Fatalf("Expected AlterSettingsStmt, got %T", prog.Statements[0]) + } + if !stmt.DropConstant { + t.Error("Expected DropConstant true") + } +} + +func TestCreateConfiguration(t *testing.T) { + input := `CREATE CONFIGURATION 'Acceptance' DatabaseHost = 'db.acc.example.com', DatabaseName = 'myapp_acc';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateConfigurationStmt) + if !ok { + t.Fatalf("Expected CreateConfigurationStmt, got %T", prog.Statements[0]) + } + if stmt.Name != "Acceptance" { + t.Errorf("Got Name %q", stmt.Name) + } + if stmt.Properties["DatabaseHost"] != "db.acc.example.com" { + t.Errorf("Got %v", stmt.Properties["DatabaseHost"]) + } +} diff --git a/mdl/visitor/visitor_widgets_test.go b/mdl/visitor/visitor_widgets_test.go new file mode 100644 index 00000000..47b2a1e7 --- /dev/null +++ b/mdl/visitor/visitor_widgets_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestUpdateWidgets(t *testing.T) { + input := `UPDATE WIDGETS SET 'showLabel' = false WHERE WidgetType LIKE '%textbox%';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.UpdateWidgetsStmt) + if !ok { + t.Fatalf("Expected UpdateWidgetsStmt, got %T", prog.Statements[0]) + } + if len(stmt.Assignments) != 1 { + t.Fatalf("Expected 1 assignment, got %d", len(stmt.Assignments)) + } + if stmt.Assignments[0].PropertyPath != "showLabel" { + t.Errorf("Got PropertyPath %q", stmt.Assignments[0].PropertyPath) + } + if len(stmt.Filters) != 1 { + t.Fatalf("Expected 1 filter, got %d", len(stmt.Filters)) + } + if stmt.DryRun { + t.Error("Expected DryRun false") + } +} + +func TestUpdateWidgets_DryRun(t *testing.T) { + input := `UPDATE WIDGETS SET 'editable' = true WHERE WidgetType = 'TextBox' IN MyModule DRY RUN;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + if len(prog.Statements) == 0 { + t.Fatal("Expected at least one statement") + } + stmt, ok := prog.Statements[0].(*ast.UpdateWidgetsStmt) + if !ok { + t.Fatalf("Expected *ast.UpdateWidgetsStmt, got %T", prog.Statements[0]) + } + if !stmt.DryRun { + t.Error("Expected DryRun true") + } + if stmt.InModule != "MyModule" { + t.Errorf("Got InModule %q", stmt.InModule) + } +}