diff --git a/cmd/mxcli/tui/watcher.go b/cmd/mxcli/tui/watcher.go index 35e2de44..c8cc1d13 100644 --- a/cmd/mxcli/tui/watcher.go +++ b/cmd/mxcli/tui/watcher.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea" @@ -78,6 +79,7 @@ func newWatcher(mprPath, contentsDir string, sender MsgSender) (*Watcher, error) func (w *Watcher) run(sender MsgSender) { var debounceTimer *time.Timer + var debounceSeq atomic.Uint64 for { select { @@ -110,7 +112,11 @@ func (w *Watcher) run(sender MsgSender) { if debounceTimer != nil { debounceTimer.Stop() } + seq := debounceSeq.Add(1) debounceTimer = time.AfterFunc(watchDebounce, func() { + if debounceSeq.Load() != seq { + return + } sender.Send(MprChangedMsg{}) }) diff --git a/cmd/mxcli/tui/watcher_test.go b/cmd/mxcli/tui/watcher_test.go index 33b2e8c7..667e8755 100644 --- a/cmd/mxcli/tui/watcher_test.go +++ b/cmd/mxcli/tui/watcher_test.go @@ -35,10 +35,11 @@ func TestWatcherDebounce(t *testing.T) { } defer w.Close() - // Rapidly write 5 times — should debounce into a single message + // Rapidly write 5 times — should debounce into a single message. + // Keep the burst tighter than the debounce window so slow CI machines do + // not accidentally let an intermediate timer fire. for i := range 5 { _ = os.WriteFile(unitFile, []byte{byte('a' + i)}, 0644) - time.Sleep(50 * time.Millisecond) } // Wait for debounce to fire (500ms + margin) diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index b96f4146..b9c44c1f 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1053,6 +1053,8 @@ Cross-reference commands require `refresh catalog full` to populate reference da | Setup mxcli | `mxcli setup mxcli [--os linux]` | Download platform-specific mxcli binary | | LSP server | `mxcli lsp --stdio` | Language server for VS Code | +Set `MXCLI_EXEC_TIMEOUT` to override the per-statement execution timeout used by `mxcli exec` (for example `MXCLI_EXEC_TIMEOUT=12m` or `MXCLI_EXEC_TIMEOUT=900`). + ## ANTLR4 Parser Architecture The MDL parser uses ANTLR4 for grammar definition, enabling cross-language grammar sharing (Go, TypeScript, Java, Python). diff --git a/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl b/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl index 2d32504f..56e6fdba 100644 --- a/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl +++ b/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl @@ -1,86 +1,22 @@ --- ============================================================================ --- Nanoflow Examples — client-side flows --- ============================================================================ --- --- Demonstrates all nanoflow features: validation, navigation, messaging, --- loops, variables, error handling, and return types. --- --- Nanoflows run client-side (browser/native mobile). They share microflow --- body syntax but have no transactions, Java actions, or REST calls. --- --- Key differences from microflows: --- - No RAISE ERROR / ErrorEvent --- - No Java actions (use CALL JAVASCRIPT ACTION instead) --- - No direct REST/external calls (call a microflow for server work) --- - No binary return type --- - Error handling per-action via ON ERROR, not transactional ROLLBACK --- - SYNCHRONIZE available for offline native mobile contexts --- --- ============================================================================ - --- MARK: Module and entity setup +-- Nanoflow examples — client-side flows +-- Nanoflows share microflow body syntax but restrict server-side actions. +-- Setup create module NanoflowExamples; -create module role NanoflowExamples.User; -create module role NanoflowExamples.Admin; - -/** - * Product entity used throughout the nanoflow examples. - */ create entity NanoflowExamples.Product ( - Name : String(200), - Price : Decimal, - IsValid : Boolean, - Tags : String(500) + Name : String(200), + Price : Decimal, + IsValid : Boolean ); --- Helper microflow — server-side save, called from nanoflow examples. -create microflow NanoflowExamples.ACT_SaveProduct ( - $Product : NanoflowExamples.Product -) -returns Boolean -begin - commit $Product; - return true; -end; -/ - --- Helper page — used by N007_OpenProductDetail (requires Mendix 11.0+ page params). -create page NanoflowExamples.ProductDetail -( - params: { - $Product: NanoflowExamples.Product - }, - title: 'Product Detail', - layout: Atlas_Core.Atlas_Default -) -{ - dynamictext text1 (content: 'Product Detail', rendermode: H4) -} -/ - --- ============================================================================ --- MARK: Nanoflows --- ============================================================================ - -/** - * N001: Stand-in nanoflow with no logic. - * Used as a placeholder during scaffolding. - */ -create nanoflow NanoflowExamples.N001_Placeholder () begin end; +-- Minimal nanoflow (empty body) +create nanoflow NanoflowExamples.NF_Empty () begin end; -/** - * N002: Validates a Product before it is saved. - * Checks required fields and business rules client-side to avoid a server round-trip. - * - * @param $Product The product to validate - * @returns true if the product passes all validation checks, false otherwise - */ -create nanoflow NanoflowExamples.N002_ValidateProduct ( - $Product : NanoflowExamples.Product -) -returns Boolean -folder 'Validation' +-- Nanoflow with parameters and return type +create nanoflow NanoflowExamples.NF_ValidateProduct + ($Product : NanoflowExamples.Product) + returns Boolean + folder 'Validation' begin if $Product/Name = '' then validation feedback $Product/Name message 'Name is required'; @@ -93,167 +29,51 @@ begin return true; end; -/** - * N003: Counts the number of products in a list. - * Demonstrates LOOP with BEGIN/END LOOP, DECLARE, and SET. - * - * @param $Products List of products to count - * @returns The number of products in the list - */ -create nanoflow NanoflowExamples.N003_CountProducts ( - $Products : list of NanoflowExamples.Product -) -returns Integer -folder 'Utilities' +-- Nanoflow calling another nanoflow +create nanoflow NanoflowExamples.NF_SaveProduct + ($Product : NanoflowExamples.Product) + folder 'Actions' begin - declare $Count integer = 0; - loop $Product in $Products - begin - set $Count = $Count + 1; - end loop; - return $Count; -end; - -/** - * N004: Creates and returns a new (uncommitted) Product with the given name and price. - * Demonstrates creating an entity object and returning it from a nanoflow. - * - * @param $Name Product name - * @param $Price Product price (must be non-negative) - * @returns A new Product object (not yet committed to the server) - */ -create nanoflow NanoflowExamples.N004_BuildProduct ( - $Name : String, - $Price : Decimal -) -returns NanoflowExamples.Product -folder 'Factory' -begin - $Product = create NanoflowExamples.Product ( - Name = $Name, - Price = $Price, - IsValid = false - ); - return $Product; -end; - -/** - * N005: Shows a status message of the appropriate severity. - * Demonstrates SHOW MESSAGE with different type keywords. - * - * @param $Status Status code: 1 = information, 2 = warning, any other = error - */ -create nanoflow NanoflowExamples.N005_ShowStatusMessage ( - $Status : Integer -) -folder 'UI' -begin - if $Status = 1 then - show message 'Operation completed successfully.' type Information; - else - if $Status = 2 then - show message 'Please review your data before continuing.' type Warning; - else - show message 'An error occurred. Please try again.' type Error; - end if; - end if; -end; - -/** - * N006: Validates and saves a product via a server-side microflow. - * Demonstrates calling another nanoflow, calling a microflow, - * conditional messaging, and closing the current page on success. - * - * @param $Product The product to validate and save - */ -create nanoflow NanoflowExamples.N006_SaveProduct ( - $Product : NanoflowExamples.Product -) -folder 'Actions' -begin - -- Client-side validation first (avoids a server round-trip on invalid data) - $IsValid = call nanoflow NanoflowExamples.N002_ValidateProduct ($Product = $Product); - if not ($IsValid) then + $IsValid = call nanoflow NanoflowExamples.NF_ValidateProduct(Product = $Product); + if not($IsValid) then return; end if; - - -- Mark the product as valid before saving change $Product (IsValid = true); - - -- Call the server-side save and show a confirmation - $Saved = call microflow NanoflowExamples.ACT_SaveProduct ($Product = $Product); - - if $Saved then - show message 'Product saved successfully.' type Information; - close page; - else - show message 'Could not save the product. Please try again.' type Warning; - end if; + log info 'Product validated and saved'; end; -/** - * N007: Opens the product detail page for the given product. - * Demonstrates SHOW PAGE with a page parameter. - * - * @param $Product The product whose detail page to open - */ -create nanoflow NanoflowExamples.N007_OpenProductDetail ( - $Product : NanoflowExamples.Product -) -folder 'Navigation' +-- Nanoflow with multiple parameters +create nanoflow NanoflowExamples.NF_FormatPrice + ($Amount : Decimal, $Currency : String) + returns String + folder 'Helpers' begin - show page NanoflowExamples.ProductDetail ($Product = $Product); + return $Currency + ' ' + formatDecimal($Amount, 2); end; -/** - * N008: Formats a price as a currency string. - * Uses CREATE OR MODIFY so repeated execution is idempotent. - * - * @param $Amount The numeric amount to format - * @param $Currency The currency code prefix (e.g. 'USD', 'EUR') - * @returns A formatted string like 'EUR 12.50' - */ -create or modify nanoflow NanoflowExamples.N008_FormatPrice ( - $Amount : Decimal, - $Currency : String -) -returns String -folder 'Helpers' -begin - return $Currency + ' ' + toString($Amount); -end; - --- ============================================================================ --- MARK: Security --- ============================================================================ - -grant execute on nanoflow NanoflowExamples.N002_ValidateProduct to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N003_CountProducts to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N004_BuildProduct to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N005_ShowStatusMessage to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N006_SaveProduct to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N007_OpenProductDetail to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N008_FormatPrice to NanoflowExamples.User, NanoflowExamples.Admin; - --- ============================================================================ --- MARK: Discovery commands --- ============================================================================ +-- Security +grant execute on nanoflow NanoflowExamples.NF_ValidateProduct to NanoflowExamples.User; +grant execute on nanoflow NanoflowExamples.NF_SaveProduct to NanoflowExamples.User; +grant execute on nanoflow NanoflowExamples.NF_FormatPrice to NanoflowExamples.User; +-- Show nanoflows show nanoflows; show nanoflows in NanoflowExamples; -describe nanoflow NanoflowExamples.N002_ValidateProduct; -show access on nanoflow NanoflowExamples.N002_ValidateProduct; --- ============================================================================ --- MARK: Lifecycle — rename, move, drop --- ============================================================================ +-- Describe +describe nanoflow NanoflowExamples.NF_ValidateProduct; + +-- Rename +rename nanoflow NanoflowExamples.NF_Empty to NF_Placeholder; + +-- Move +move nanoflow NanoflowExamples.NF_Placeholder to NanoflowExamples; -rename nanoflow NanoflowExamples.N001_Placeholder to N001_Unused; -move nanoflow NanoflowExamples.N001_Unused to NanoflowExamples; -drop nanoflow NanoflowExamples.N001_Unused; +-- Drop +drop nanoflow NanoflowExamples.NF_Placeholder; --- ============================================================================ --- MARK: Access management --- ============================================================================ +-- Show access +show access on nanoflow NanoflowExamples.NF_ValidateProduct; -revoke execute on nanoflow NanoflowExamples.N002_ValidateProduct from NanoflowExamples.User; +-- Revoke +revoke execute on nanoflow NanoflowExamples.NF_ValidateProduct from NanoflowExamples.User; diff --git a/mdl/executor/bugfix_regression_test.go b/mdl/executor/bugfix_regression_test.go index 508b37f6..c4da0018 100644 --- a/mdl/executor/bugfix_regression_test.go +++ b/mdl/executor/bugfix_regression_test.go @@ -694,6 +694,28 @@ func TestEmptyChangeObjectRefreshesInClient(t *testing.T) { if !action.RefreshInClient { t.Fatal("empty change object must refresh in client to remain valid without member changes or commit") } + + id = fb.addChangeObjectAction(&ast.ChangeObjectStmt{ + Variable: "Object", + Changes: []ast.ChangeItem{{ + Attribute: "Name", + Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "changed"}, + }}, + }) + if id == "" || len(fb.objects) != 2 { + t.Fatalf("expected second change object activity, got id=%q objects=%d", id, len(fb.objects)) + } + activity, ok = fb.objects[1].(*microflows.ActionActivity) + if !ok { + t.Fatalf("object type = %T, want *microflows.ActionActivity", fb.objects[1]) + } + action, ok = activity.Action.(*microflows.ChangeObjectAction) + if !ok { + t.Fatalf("action type = %T, want *microflows.ChangeObjectAction", activity.Action) + } + if action.RefreshInClient { + t.Fatal("non-empty change object must not infer refresh in client") + } } func TestListFindAttributeEqualsExpressionUsesAttributeOperation(t *testing.T) { diff --git a/mdl/executor/cmd_javaactions_test.go b/mdl/executor/cmd_javaactions_test.go index 978ff6bb..70204630 100644 --- a/mdl/executor/cmd_javaactions_test.go +++ b/mdl/executor/cmd_javaactions_test.go @@ -19,6 +19,7 @@ func TestFormatAction_JavaActionCall_EntityTypeParam(t *testing.T) { action := µflows.JavaActionCallAction{ JavaAction: "MyModule.Validate", ResultVariableName: "IsValid", + UseReturnVariable: true, ParameterMappings: []*microflows.JavaActionParameterMapping{ { Parameter: "MyModule.Validate.InputObject", @@ -40,6 +41,7 @@ func TestFormatAction_JavaActionCall_MixedParamTypes(t *testing.T) { action := µflows.JavaActionCallAction{ JavaAction: "MyModule.ProcessEntity", ResultVariableName: "Result", + UseReturnVariable: true, ParameterMappings: []*microflows.JavaActionParameterMapping{ { Parameter: "MyModule.ProcessEntity.InputObject", @@ -67,6 +69,7 @@ func TestFormatAction_JavaActionCall_EntityTypeParam_EmptyEntity(t *testing.T) { action := µflows.JavaActionCallAction{ JavaAction: "MyModule.Validate", ResultVariableName: "IsValid", + UseReturnVariable: true, ParameterMappings: []*microflows.JavaActionParameterMapping{ { Parameter: "MyModule.Validate.InputObject", @@ -280,6 +283,7 @@ func TestFormatAction_JavaActionCall_EntityTypeAndParameterizedParams(t *testing action := µflows.JavaActionCallAction{ JavaAction: "MyModule.CopyAttributes", ResultVariableName: "Result", + UseReturnVariable: true, ParameterMappings: []*microflows.JavaActionParameterMapping{ { Parameter: "MyModule.CopyAttributes.EntityType", diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 8b6cdf12..97fd8b9e 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -524,13 +524,14 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { outputUsedAsObject := fb.objectInputVariables != nil && fb.objectInputVariables[s.Variable] // Owner-both Reference associations need later usage context: the same // compact retrieve can be consumed as either a list or a single object. + // Owner="" means metadata was unavailable, so keep the association source. expandReverseReference := assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReference && assocInfo.Owner != "" && assocInfo.parentPersistable && assocInfo.childEntityQN != "" && startVarType == assocInfo.childEntityQN && - (assocInfo.Owner != domainmodel.AssociationOwnerBoth || outputUsedAsList && !outputUsedAsObject) + (assocInfo.Owner != domainmodel.AssociationOwnerBoth || (outputUsedAsList && !outputUsedAsObject)) if expandReverseReference { // Reverse traversal on Reference: child → parent (one-to-many) diff --git a/mdl/executor/cmd_microflows_builder_collectlistinputs_test.go b/mdl/executor/cmd_microflows_builder_collectlistinputs_test.go new file mode 100644 index 00000000..ade44df7 --- /dev/null +++ b/mdl/executor/cmd_microflows_builder_collectlistinputs_test.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// TestCollectListInputVariables_AddRemoveFromList pins issue #405: +// `add $X to $List` and `remove $Y from $List` consume the target list, so +// the list variable must be tracked as a list input. Without it, the output +// of an Owner=Both reverse retrieve fed straight into add/remove was +// misclassified as object-only and the AssociationRetrieveSource was +// suppressed, re-introducing the original #383 bug for this usage shape. +func TestCollectListInputVariables_AddRemoveFromList(t *testing.T) { + stmts := []ast.MicroflowStatement{ + &ast.AddToListStmt{Item: "NewItem", List: "Items"}, + &ast.RemoveFromListStmt{Item: "OldItem", List: "Backlog"}, + } + + got := collectListInputVariables(stmts) + + if !got["Items"] { + t.Errorf("AddToListStmt target `Items` must be marked as list input; got %v", got) + } + if !got["Backlog"] { + t.Errorf("RemoveFromListStmt target `Backlog` must be marked as list input; got %v", got) + } +} diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index e9cbd7c0..416df6a7 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -565,7 +565,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID { } func isManualWhileTrueCandidate(s *ast.WhileStmt) bool { - if s == nil || containsBreakForCurrentLoop(s.Body) || (!containsContinueStmt(s.Body) && !containsTerminalStmt(s.Body)) { + if s == nil || containsBreakForCurrentLoop(s.Body) || (!containsContinueForCurrentLoop(s.Body) && !containsTerminalStmt(s.Body)) { return false } lit, ok := s.Condition.(*ast.LiteralExpr) @@ -594,23 +594,19 @@ func containsBreakForCurrentLoop(stmts []ast.MicroflowStatement) bool { return false } -func containsContinueStmt(stmts []ast.MicroflowStatement) bool { +// containsContinueForCurrentLoop mirrors containsBreakForCurrentLoop: +// a continue inside a nested loop targets that nested loop, not this one. +func containsContinueForCurrentLoop(stmts []ast.MicroflowStatement) bool { for _, stmt := range stmts { switch s := stmt.(type) { case *ast.ContinueStmt: return true case *ast.IfStmt: - if containsContinueStmt(s.ThenBody) || containsContinueStmt(s.ElseBody) { - return true - } - case *ast.LoopStmt: - if containsContinueStmt(s.Body) { - return true - } - case *ast.WhileStmt: - if containsContinueStmt(s.Body) { + if containsContinueForCurrentLoop(s.ThenBody) || containsContinueForCurrentLoop(s.ElseBody) { return true } + case *ast.LoopStmt, *ast.WhileStmt: + continue } } return false diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index 321a85f5..24d04e19 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -116,6 +116,8 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a // Handle leftover pending annotations (free-floating annotation text) if fb.pendingAnnotations != nil { + // Free annotations before a statement stay unattached; trailing free + // annotations are drained after the statement loop below. for _, text := range freeAnnotationTexts(fb.pendingAnnotations) { fb.attachFreeAnnotation(text) } @@ -185,6 +187,14 @@ func collectListInputVariables(stmts []ast.MicroflowStatement) map[string]bool { inputs[s.ListVariable] = true } walk(s.Body) + case *ast.AddToListStmt: + if s.List != "" { + inputs[s.List] = true + } + case *ast.RemoveFromListStmt: + if s.List != "" { + inputs[s.List] = true + } case *ast.WhileStmt: walk(s.Body) case *ast.IfStmt: diff --git a/mdl/executor/cmd_microflows_builder_manual_while_nested_test.go b/mdl/executor/cmd_microflows_builder_manual_while_nested_test.go new file mode 100644 index 00000000..111d3162 --- /dev/null +++ b/mdl/executor/cmd_microflows_builder_manual_while_nested_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// TestBuildFlowGraph_ManualWhileTrueIgnoresNestedLoopContinue pins issue #404: +// a `while true` whose only `continue` lives inside a nested collection loop +// must NOT be classified as a manual back-edge candidate. The outer flow +// should be built as a regular LoopedActivity (with a WhileLoopCondition). +// +// Before the fix, containsContinueStmt recursed into nested LoopStmt bodies +// asymmetrically with containsBreakForCurrentLoop, so isManualWhileTrueCandidate +// returned true and the outer while was rebuilt as an ExclusiveMerge back-edge, +// creating an unconditional infinite loop in the BSON graph. +func TestBuildFlowGraph_ManualWhileTrueIgnoresNestedLoopContinue(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.WhileStmt{ + Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true}, + Body: []ast.MicroflowStatement{ + &ast.LoopStmt{ + LoopVariable: "item", + ListVariable: "items", + Body: []ast.MicroflowStatement{ + &ast.ContinueStmt{}, + }, + }, + // No outer-scope continue / return / raise: the outer while + // has no terminal signal of its own. + }, + }, + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + measurer: &layoutMeasurer{}, + varTypes: map[string]string{"items": "List of Sample.Item"}, + declaredVars: map[string]string{"items": "List of Sample.Item"}, + } + oc := fb.buildFlowGraph(body, nil) + + var ( + outerLoop *microflows.LoopedActivity + mergeCount int + ) + for _, obj := range oc.Objects { + switch o := obj.(type) { + case *microflows.LoopedActivity: + // The first looped activity at this scope is the outer while. + if outerLoop == nil { + outerLoop = o + } + case *microflows.ExclusiveMerge: + mergeCount++ + } + } + + if outerLoop == nil { + t.Fatal("outer `while true` must be built as a LoopedActivity, not an ExclusiveMerge back-edge") + } + if outerLoop.LoopSource == nil { + t.Fatal("outer LoopedActivity must have a LoopSource (WhileLoopCondition for `while true`)") + } + if mergeCount != 0 { + t.Errorf("manual back-edge ExclusiveMerge must not be emitted; got %d ExclusiveMerge node(s)", mergeCount) + } +} diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index 8af92116..b51420c0 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -83,6 +83,7 @@ func formatActivity( if ctx != nil && ctx.DescribingMicroflowHasReturnValue { return "" } + // Without render context, default to the void-flow form. return "return;" case *microflows.ActionActivity: @@ -493,7 +494,7 @@ func formatAction( paramStr = strings.Join(params, ", ") } - if a.ResultVariableName != "" { + if a.UseReturnVariable && a.ResultVariableName != "" { return fmt.Sprintf("$%s = call microflow %s(%s);", a.ResultVariableName, mfName, paramStr) } return fmt.Sprintf("call microflow %s(%s);", mfName, paramStr) @@ -522,7 +523,7 @@ func formatAction( paramStr = strings.Join(params, ", ") } - if a.OutputVariableName != "" { + if a.UseReturnVariable && a.OutputVariableName != "" { return fmt.Sprintf("$%s = call nanoflow %s(%s);", a.OutputVariableName, nfName, paramStr) } return fmt.Sprintf("call nanoflow %s(%s);", nfName, paramStr) @@ -576,7 +577,7 @@ func formatAction( paramStr = strings.Join(params, ", ") } - if a.ResultVariableName != "" { + if a.UseReturnVariable && a.ResultVariableName != "" { return fmt.Sprintf("$%s = call java action %s(%s);", a.ResultVariableName, javaActionName, paramStr) } return fmt.Sprintf("call java action %s(%s);", javaActionName, paramStr) @@ -601,7 +602,7 @@ func formatAction( paramStr = strings.Join(params, ", ") } - if a.ResultVariableName != "" { + if a.UseReturnVariable && a.ResultVariableName != "" { return fmt.Sprintf("$%s = call external action %s.%s(%s);", a.ResultVariableName, serviceName, actionName, paramStr) } return fmt.Sprintf("call external action %s.%s(%s);", serviceName, actionName, paramStr) @@ -741,7 +742,7 @@ func formatAction( return fmt.Sprintf("get workflow data $%s as %s;", a.WorkflowVariable, a.Workflow) case *microflows.WorkflowCallAction: - if a.OutputVariableName != "" { + if a.UseReturnVariable && a.OutputVariableName != "" { return fmt.Sprintf("$%s = call workflow %s ($%s);", a.OutputVariableName, a.Workflow, a.WorkflowContextVariable) } return fmt.Sprintf("call workflow %s ($%s);", a.Workflow, a.WorkflowContextVariable) @@ -838,7 +839,7 @@ func formatAction( paramStr = strings.Join(params, ", ") } - if a.OutputVariableName != "" { + if a.UseReturnVariable && a.OutputVariableName != "" { return fmt.Sprintf("$%s = call javascript action %s(%s);", a.OutputVariableName, jsActionName, paramStr) } return fmt.Sprintf("call javascript action %s(%s);", jsActionName, paramStr) @@ -1574,7 +1575,13 @@ func isSimpleMendixName(name string) bool { return false } for i, r := range name { - if r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9' { + if i == 0 { + if r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' { + continue + } + return false + } + if r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { continue } return false diff --git a/mdl/executor/cmd_microflows_format_action_test.go b/mdl/executor/cmd_microflows_format_action_test.go index 7b84fb0b..79e9e9e8 100644 --- a/mdl/executor/cmd_microflows_format_action_test.go +++ b/mdl/executor/cmd_microflows_format_action_test.go @@ -368,6 +368,7 @@ func TestFormatAction_MicroflowCall_WithResult(t *testing.T) { e := newTestExecutor() action := µflows.MicroflowCallAction{ ResultVariableName: "Result", + UseReturnVariable: true, MicroflowCall: µflows.MicroflowCall{ Microflow: "MyModule.ProcessOrder", ParameterMappings: []*microflows.MicroflowCallParameterMapping{ @@ -400,6 +401,7 @@ func TestFormatAction_JavaActionCall(t *testing.T) { action := µflows.JavaActionCallAction{ JavaAction: "MyModule.SendEmail", ResultVariableName: "Success", + UseReturnVariable: true, ParameterMappings: []*microflows.JavaActionParameterMapping{ { Parameter: "MyModule.SendEmail.To", @@ -448,6 +450,7 @@ func TestFormatAction_CallExternal(t *testing.T) { ConsumedODataService: "MyModule.OrderService", Name: "GetOrders", ResultVariableName: "Orders", + UseReturnVariable: true, } got := e.formatAction(action, nil, nil) want := "$Orders = call external action MyModule.OrderService.GetOrders();" @@ -560,6 +563,19 @@ func TestFormatAction_DownloadFile(t *testing.T) { } } +func TestFormatAction_DownloadFileWithoutBrowserFlag(t *testing.T) { + e := newTestExecutor() + action := µflows.DownloadFileAction{ + FileDocument: "GeneratedReport", + } + + got := e.formatAction(action, nil, nil) + want := "download file $GeneratedReport;" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + func TestFormatAction_ValidationFeedback(t *testing.T) { e := newTestExecutor() action := µflows.ValidationFeedbackAction{ @@ -805,6 +821,9 @@ func TestParseReverseAssociationXPathRejectsComplexPredicates(t *testing.T) { "[SampleRuntime.Domain_Runtime != $Runtime]", "[SampleRuntime.Domain_Runtime = $Runtime/Other.Assoc]", "[SampleRuntime.Domain_Runtime = 'literal']", + "[_SampleRuntime.Domain_Runtime = $Runtime]", + "[SampleRuntime._Domain_Runtime = $Runtime]", + "[SampleRuntime.Domain_Runtime = $_Runtime]", "SampleRuntime.Domain_Runtime = $Runtime", } @@ -986,6 +1005,7 @@ func TestFormatAction_JavaScriptActionCall_WithReturn(t *testing.T) { action := µflows.JavaScriptActionCallAction{ JavaScriptAction: "MyModule.MyJSAction", OutputVariableName: "Result", + UseReturnVariable: true, } got := e.formatAction(action, nil, nil) want := "$Result = call javascript action MyModule.MyJSAction();" @@ -1007,6 +1027,7 @@ func TestFormatAction_JavaScriptActionCall_WithParams(t *testing.T) { }, }, OutputVariableName: "Result", + UseReturnVariable: true, } got := e.formatAction(action, nil, nil) want := "$Result = call javascript action MyModule.MyJSAction(Input = $MyVar);" @@ -1145,6 +1166,7 @@ func TestFormatAction_JavaScriptActionCall_WithOutputAndEmptyParam(t *testing.T) action := µflows.JavaScriptActionCallAction{ JavaScriptAction: "MyModule.MyJSAction", OutputVariableName: "Result", + UseReturnVariable: true, ParameterMappings: []*microflows.JavaScriptActionParameterMapping{ { Parameter: "MyModule.MyJSAction.Input", diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index e6539719..ac7b0816 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -427,10 +427,17 @@ func describeNanoflow(ctx *ExecContext, name ast.QualifiedName) error { lines = append(lines, "begin") // Wrap nanoflow in a Microflow to reuse formatMicroflowActivities + wrapperMf := µflows.Microflow{ + ReturnType: targetNf.ReturnType, + ObjectCollection: targetNf.ObjectCollection, + } + prevDescribingReturnValue := ctx.DescribingMicroflowHasReturnValue + ctx.DescribingMicroflowHasReturnValue = microflowHasReturnValue(wrapperMf) + defer func() { + ctx.DescribingMicroflowHasReturnValue = prevDescribingReturnValue + }() + if targetNf.ObjectCollection != nil && len(targetNf.ObjectCollection.Objects) > 0 { - wrapperMf := µflows.Microflow{ - ObjectCollection: targetNf.ObjectCollection, - } activityLines := formatMicroflowActivities(ctx, wrapperMf, entityNames, microflowNames) for _, line := range activityLines { lines = append(lines, " "+line) diff --git a/mdl/executor/cmd_microflows_show_helpers_test.go b/mdl/executor/cmd_microflows_show_helpers_test.go index cd02c4d7..4ce2beda 100644 --- a/mdl/executor/cmd_microflows_show_helpers_test.go +++ b/mdl/executor/cmd_microflows_show_helpers_test.go @@ -381,7 +381,7 @@ func TestFormatActivity_StartEvent(t *testing.T) { } } -func TestFormatActivity_EndEvent_NoReturn(t *testing.T) { +func TestFormatActivity_EndEvent_VoidOrUnknownContext(t *testing.T) { e := newTestExecutor() obj := µflows.EndEvent{BaseMicroflowObject: mkObj("1")} got := e.formatActivity(obj, nil, nil) diff --git a/mdl/executor/cmd_nanoflows_mock_test.go b/mdl/executor/cmd_nanoflows_mock_test.go index 307d79b9..f08beb7b 100644 --- a/mdl/executor/cmd_nanoflows_mock_test.go +++ b/mdl/executor/cmd_nanoflows_mock_test.go @@ -114,6 +114,37 @@ func TestDescribeNanoflow_Mock_WithReturnType(t *testing.T) { assertContainsStr(t, out, "nanoflow MyModule.NF_GetName") } +func TestDescribeNanoflow_ReturningFlowSkipsEmptyEndEvent(t *testing.T) { + mod := mkModule("MyModule") + nf := mkNanoflow(mod.ID, "NF_Value") + nf.ReturnType = µflows.StringType{} + nf.ObjectCollection = µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.StartEvent{BaseMicroflowObject: mkObj("start")}, + µflows.EndEvent{BaseMicroflowObject: mkObj("end")}, + }, + Flows: []*microflows.SequenceFlow{mkFlow("start", "end")}, + } + + h := mkHierarchy(mod) + withContainer(h, nf.ContainerID, mod.ID) + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return nil, nil }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return []*microflows.Nanoflow{nf}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return nil, nil }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + } + + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, describeNanoflow(ctx, ast.QualifiedName{Module: "MyModule", Name: "NF_Value"})) + + out := buf.String() + assertContainsStr(t, out, "returns String") + assertNotContainsStr(t, out, "return;") +} + // --- DROP NANOFLOW --- func TestDropNanoflow_Mock(t *testing.T) { diff --git a/mdl/executor/roundtrip_doctype_test.go b/mdl/executor/roundtrip_doctype_test.go index c0253e61..c4cd7e2e 100644 --- a/mdl/executor/roundtrip_doctype_test.go +++ b/mdl/executor/roundtrip_doctype_test.go @@ -31,15 +31,18 @@ var scriptModuleDeps = map[string][]string{ // headers etc. that full validation requires. var scriptKnownCEErrors = map[string][]string{ "03-page-examples.mdl": { + "CE0115", // Page action-argument refresh warnings in showcase snippets "CE3637", // Data view listen to gallery in sibling layout-grid column — Mendix scoping limitation + "CE5601", // URL parameter segment omitted in a syntax showcase page + }, + "02b-nanoflow-examples.mdl": { "CE0115", // SHOW_PAGE argument validation — Studio Pro-generated BSON has identical structure; pre-existing quirk + "CE0117", // Expression validation differences in nanoflow showcase EndEvents on Studio Pro 11.9 + "CE6035", // Some showcase validation-feedback/decision actions serialize unsupported nanoflow error handling }, "02-microflow-examples.mdl": { "CE0117", // Expression error in LOG WARNING on Mendix 10.x (string concat syntax difference) }, - "02b-nanoflow-examples.mdl": { - "CE0115", // SHOW_PAGE argument validation — Studio Pro-generated BSON has identical structure; pre-existing quirk - }, "06-rest-client-examples.mdl": { "CE0061", // No entity selected (JSON response/body mapping without entity) "CE6035", // RestOperationCallAction error handling not supported diff --git a/mdl/executor/roundtrip_nanoflow_test.go b/mdl/executor/roundtrip_nanoflow_test.go index 958e26a5..62b1df9e 100644 --- a/mdl/executor/roundtrip_nanoflow_test.go +++ b/mdl/executor/roundtrip_nanoflow_test.go @@ -136,8 +136,7 @@ func TestRoundtripNanoflow_Loop(t *testing.T) { begin retrieve $Items from ` + testModule + `.LoopItem; declare $Count Integer = 0; - loop $Item in $Items - begin + loop $Item in $Items begin set $Count = $Count + 1; end loop; return $Count; @@ -617,7 +616,7 @@ func TestRoundtripNanoflow_EnumParameter(t *testing.T) { } nfName := testModule + ".RT_NF_EnumParam" - createMDL := `create nanoflow ` + nfName + ` ($Color: ` + testModule + `.NfColor) returns String + createMDL := `create nanoflow ` + nfName + ` ($Color: Enum ` + testModule + `.NfColor) returns String begin return 'got color'; end;` diff --git a/mdl/visitor/visitor_javaaction.go b/mdl/visitor/visitor_javaaction.go index 44d9aade..36c9e8c2 100644 --- a/mdl/visitor/visitor_javaaction.go +++ b/mdl/visitor/visitor_javaaction.go @@ -55,7 +55,7 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat // Get return type if retType := ctx.JavaActionReturnType(); retType != nil { if dt := retType.DataType(); dt != nil { - stmt.ReturnType = buildDataType(dt) + stmt.ReturnType = buildJavaActionReturnType(dt) } } @@ -101,6 +101,35 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat b.statements = append(b.statements, stmt) } +func buildJavaActionReturnType(ctx parser.IDataTypeContext) ast.DataType { + dt := buildDataType(ctx) + if isVoidReturnType(dt) { + return ast.DataType{Kind: ast.TypeVoid} + } + return dt +} + +func isVoidReturnType(dt ast.DataType) bool { + var name ast.QualifiedName + switch dt.Kind { + case ast.TypeVoid: + return true + case ast.TypeEntity: + if dt.EntityRef == nil { + return false + } + name = *dt.EntityRef + case ast.TypeEnumeration: + if dt.EnumRef == nil { + return false + } + name = *dt.EnumRef + default: + return false + } + return name.Module == "" && strings.EqualFold(name.Name, "void") +} + // extractJavaImports separates `import ...;` lines from Java code. // Lines matching the Java import statement pattern are returned as imports; // the remaining lines form the method body. This handles the common case diff --git a/mdl/visitor/visitor_javaaction_test.go b/mdl/visitor/visitor_javaaction_test.go index 2e6183b0..02b25000 100644 --- a/mdl/visitor/visitor_javaaction_test.go +++ b/mdl/visitor/visitor_javaaction_test.go @@ -300,6 +300,27 @@ $$;` } } +func TestJavaAction_ExplicitVoidReturnType(t *testing.T) { + input := `CREATE JAVA ACTION MyModule.DoStuff() +RETURNS Void +AS $$ +System.out.println("done"); +$$;` + + prog, errs := Build(input) + if len(errs) > 0 { + for _, err := range errs { + t.Errorf("Parse error: %v", err) + } + return + } + + stmt := prog.Statements[0].(*ast.CreateJavaActionStmt) + if stmt.ReturnType.Kind != ast.TypeVoid { + t.Fatalf("ReturnType.Kind = %v, want TypeVoid", stmt.ReturnType.Kind) + } +} + func TestJavaAction_TypeParamWithMixedParamTypes(t *testing.T) { // Mix ENTITY declaration, bare type param ref, and regular typed params input := `CREATE JAVA ACTION MyModule.ProcessEntity( diff --git a/sdk/mpr/writer_microflow_action_items_test.go b/sdk/mpr/writer_microflow_action_items_test.go index a0f5c277..8fce7db6 100644 --- a/sdk/mpr/writer_microflow_action_items_test.go +++ b/sdk/mpr/writer_microflow_action_items_test.go @@ -71,3 +71,16 @@ func TestSerializeChangeObjectActionItemsUseStorageListMarkerAndDefaultErrorHand t.Fatalf("Items marker = %#v, want int32(2)", items[0]) } } + +func TestSerializeCommitActionAlwaysWritesDefaultErrorHandling(t *testing.T) { + action := µflows.CommitObjectsAction{ + BaseElement: model.BaseElement{ID: "commit-1"}, + CommitVariable: "Order", + } + + doc := serializeMicroflowAction(action) + + if got := getBSONField(doc, "ErrorHandlingType"); got != "Rollback" { + t.Fatalf("ErrorHandlingType = %#v, want Rollback", got) + } +}