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/mdl-examples/bug-tests/349-custom-error-handler-routing.mdl b/mdl-examples/bug-tests/349-custom-error-handler-routing.mdl new file mode 100644 index 00000000..2acc97b1 --- /dev/null +++ b/mdl-examples/bug-tests/349-custom-error-handler-routing.mdl @@ -0,0 +1,64 @@ +-- ============================================================================ +-- Bug #349: Custom error-handler routing during microflow roundtrip +-- ============================================================================ +-- +-- Symptom (before fix): +-- Custom error-handler bodies that did NOT explicitly return were +-- rebuilt as DETACHED terminal paths — they got their own EndEvent and +-- never rejoined the next activity. Empty custom handlers could either +-- lose their error flow entirely or rejoin into statements that read +-- an output variable absent on the error path. +-- +-- Root cause: +-- The microflow builder handled custom error handlers immediately at +-- the source activity. It did not retain pending handler state until +-- the next safe continuation was known, so a later handler could +-- overwrite an earlier pending handler. +-- +-- After fix: +-- - Pending custom-handler state is queued. +-- - Non-terminal handler bodies rejoin through a merge before the +-- next safe continuation, instead of fabricating detached EndEvents. +-- - Empty custom-handler error flows are preserved. +-- - Output-producing handlers terminate before output-dependent +-- continuation in void microflows. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/349-custom-error-handler-routing.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest349.MF_Caller" +-- `mx check` against the resulting MPR must report 0 errors and the +-- non-terminal handler must rejoin the continuation activity. +-- ============================================================================ + +create module BugTest349; + +create microflow BugTest349.MF_RefreshData ( + $Token: string +) +begin + log info node 'BugTest349' 'refreshed: ' + $Token; +end; +/ + +create microflow BugTest349.MF_NextBatch () +begin + log info node 'BugTest349' 'next batch'; +end; +/ + +-- Caller with non-terminal custom error handler. The handler body logs +-- but does not return, so its tail must rejoin the continuation +-- (`call microflow ... MF_NextBatch ()`) instead of becoming a detached +-- terminal path. +create microflow BugTest349.MF_Caller ( + $Token: string +) +begin + call microflow BugTest349.MF_RefreshData (Token = $Token) + on error without rollback { + log error node 'BugTest349' 'refresh failed'; + }; + + call microflow BugTest349.MF_NextBatch (); +end; +/ 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/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 824d5313..d0e6576f 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -267,6 +267,7 @@ type IfStmt struct { Condition Expression // IF condition ThenBody []MicroflowStatement // THEN branch ElseBody []MicroflowStatement // ELSE branch (optional) + HasElse bool // true when the source contained ELSE, even if the body is empty Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation } diff --git a/mdl/executor/cmd_diff_mdl.go b/mdl/executor/cmd_diff_mdl.go index eb007746..6cfa9f96 100644 --- a/mdl/executor/cmd_diff_mdl.go +++ b/mdl/executor/cmd_diff_mdl.go @@ -423,7 +423,7 @@ func microflowStatementToMDL(ctx *ExecContext, stmt ast.MicroflowStatement, inde for _, thenStmt := range s.ThenBody { lines = append(lines, microflowStatementToMDL(ctx, thenStmt, indent+1)...) } - if len(s.ElseBody) > 0 { + if s.HasElse || len(s.ElseBody) > 0 { lines = append(lines, indentStr+"else") for _, elseStmt := range s.ElseBody { lines = append(lines, microflowStatementToMDL(ctx, elseStmt, indent+1)...) diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index c69456ea..10cc5555 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -23,8 +23,10 @@ type flowBuilder struct { posY int baseY int // Base Y position (for returning after ELSE branches) spacing int - returnValue string // Return value expression for RETURN statement (used by buildFlowGraph final EndEvent) + returnValue string // Return value expression for RETURN statement (used by buildFlowGraph final EndEvent) + returnType *ast.MicroflowReturnType endsWithReturn bool // True if the flow already ends with EndEvent(s) from RETURN statements + lastReturnEndID model.ID // Last explicit RETURN EndEvent, used as a fallback error-handler target varTypes map[string]string // Variable name -> entity qualified name (for CHANGE statements) declaredVars map[string]string // Declared primitive variables: name -> type (e.g., "$IsValid" -> "Boolean") errors []string // Validation errors collected during build @@ -55,6 +57,20 @@ type flowBuilder struct { nanoflowsCacheLoaded bool manualLoopBackTarget model.ID isNanoflow bool // true when building a nanoflow — default error handling is "" not "Rollback" + // Pending custom error-handler routing uses two representations: the + // currently active handler lives in the flat fields below, while handlers + // postponed across branch boundaries are queued in pendingErrorHandlers. + // Mutate this state through the helper methods in builder_flows.go so the + // active/queued invariant stays synchronized. + emptyErrorHandlerFrom model.ID + errorHandlerTailFrom model.ID + errorHandlerSource model.ID + errorHandlerSkipVar string + errorHandlerTailCase string + errorHandlerTailAnchor *ast.FlowAnchors + errorHandlerTailIsSource bool + errorHandlerReturnValue string + pendingErrorHandlers []pendingErrorHandlerState } // addError records a validation error during flow building. @@ -72,6 +88,10 @@ func (fb *flowBuilder) GetErrors() []string { return fb.errors } +func (fb *flowBuilder) hasDeclaredReturnValue() bool { + return fb.returnType != nil && fb.returnType.Type.Kind != ast.TypeVoid +} + // errorExampleDeclareVariable returns an example for declaring a variable. func errorExampleDeclareVariable(varName string) string { // Remove $ prefix if present for cleaner display diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 8b6cdf12..0e0ba437 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -134,12 +134,7 @@ func (fb *flowBuilder) addCreateObjectAction(s *ast.CreateObjectStmt) model.ID { fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.Variable) return activity.ID } @@ -170,12 +165,7 @@ func (fb *flowBuilder) addCommitAction(s *ast.MfCommitStmt) model.ID { fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, "") return activity.ID } @@ -204,12 +194,7 @@ func (fb *flowBuilder) addDeleteAction(s *ast.DeleteObjectStmt) model.ID { fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, "") return activity.ID } @@ -684,12 +669,7 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.Variable) return activity.ID } diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index 68fc3d52..ea13b5e9 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -229,6 +229,7 @@ func (fb *flowBuilder) addEndEventWithReturn(s *ast.ReturnStmt) model.ID { fb.objects = append(fb.objects, endEvent) fb.endsWithReturn = true + fb.lastReturnEndID = endEvent.ID fb.posX += fb.spacing / 2 return endEvent.ID } diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index a8394a3f..373f0947 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -160,12 +160,7 @@ func (fb *flowBuilder) addCallMicroflowAction(s *ast.CallMicroflowStmt) model.ID fb.registerResultVariableType(s.OutputVariable, fb.lookupMicroflowReturnType(mfQN)) } - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -220,12 +215,7 @@ func (fb *flowBuilder) addCallNanoflowAction(s *ast.CallNanoflowStmt) model.ID { fb.registerResultVariableType(s.OutputVariable, fb.lookupNanoflowReturnType(nfQN)) } - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -349,12 +339,7 @@ func (fb *flowBuilder) addCallJavaActionAction(s *ast.CallJavaActionStmt) model. fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -451,12 +436,7 @@ func (fb *flowBuilder) addCallJavaScriptActionAction(s *ast.CallJavaScriptAction fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -587,12 +567,7 @@ func (fb *flowBuilder) addCallExternalActionAction(s *ast.CallExternalActionStmt fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -1093,12 +1068,7 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -1302,12 +1272,7 @@ func (fb *flowBuilder) addExecuteDatabaseQueryAction(s *ast.ExecuteDatabaseQuery fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -1374,11 +1339,7 @@ func (fb *flowBuilder) addImportFromMappingAction(s *ast.ImportFromMappingStmt) } } - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -1410,11 +1371,7 @@ func (fb *flowBuilder) addTransformJsonAction(s *ast.TransformJsonStmt) model.ID fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, "") return activity.ID } @@ -1448,11 +1405,7 @@ func (fb *flowBuilder) addExportToMappingAction(s *ast.ExportToMappingStmt) mode fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, "") return activity.ID } diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index e9cbd7c0..afb16e84 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -31,9 +31,11 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { // Check if branches end with RETURN (creating their own EndEvents) thenReturns := lastStmtIsReturn(s.ThenBody) - hasElseBody := len(s.ElseBody) > 0 + hasElseBody := s.HasElse || len(s.ElseBody) > 0 elseReturns := hasElseBody && lastStmtIsReturn(s.ElseBody) bothReturn := hasElseBody && thenReturns && elseReturns + thenNeedsErrorMerge := thenReturns && bodyHasContinuingCustomErrorHandler(s.ThenBody) + elseNeedsErrorMerge := elseReturns && bodyHasContinuingCustomErrorHandler(s.ElseBody) // Save/restore endsWithReturn around branch processing to avoid // a branch's RETURN affecting the parent flow state prematurely @@ -86,7 +88,7 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { needMerge := false if !bothReturn { if hasElseBody { - needMerge = !thenReturns && !elseReturns // both branches continue → 2 inputs + needMerge = (!thenReturns && !elseReturns) || thenNeedsErrorMerge || elseNeedsErrorMerge } else { needMerge = !thenReturns // THEN continues + FALSE path → 2 inputs } @@ -193,6 +195,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { applyUserAnchors(flow, trueBranchAnchor, trueBranchAnchor) fb.flows = append(fb.flows, flow) } + } else if thenReturns && needMerge { + fb.addPendingErrorHandlerFlowTo(mergeID) } // Process ELSE body (below the THEN path) @@ -269,6 +273,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { applyUserAnchors(flow, originAnchor, destAnchor) fb.flows = append(fb.flows, flow) } + } else if elseReturns && needMerge { + fb.addPendingErrorHandlerFlowTo(mergeID) } if !needMerge { if thenReturns && !elseReturns { @@ -445,6 +451,48 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { return splitID } +func bodyHasContinuingCustomErrorHandler(stmts []ast.MicroflowStatement) bool { + for _, stmt := range stmts { + if eh := statementErrorHandling(stmt); eh != nil { + if isContinuingCustomErrorHandler(eh) || bodyHasContinuingCustomErrorHandler(eh.Body) { + return true + } + } + switch s := stmt.(type) { + case *ast.IfStmt: + if bodyHasContinuingCustomErrorHandler(s.ThenBody) || bodyHasContinuingCustomErrorHandler(s.ElseBody) { + return true + } + case *ast.LoopStmt: + if bodyHasContinuingCustomErrorHandler(s.Body) { + return true + } + case *ast.WhileStmt: + if bodyHasContinuingCustomErrorHandler(s.Body) { + return true + } + } + } + return false +} + +func isContinuingCustomErrorHandler(eh *ast.ErrorHandlingClause) bool { + if eh == nil { + return false + } + if eh.Type != ast.ErrorHandlingCustom && eh.Type != ast.ErrorHandlingCustomWithoutRollback { + return false + } + return len(eh.Body) == 0 || !lastStmtIsReturn(eh.Body) +} + +func errorBody(eh *ast.ErrorHandlingClause) []ast.MicroflowStatement { + if eh == nil { + return nil + } + return eh.Body +} + // addLoopStatement creates a LOOP statement using LoopedActivity. // Layout: Auto-sizes the loop box to fit content with padding func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID { @@ -594,6 +642,47 @@ func containsBreakForCurrentLoop(stmts []ast.MicroflowStatement) bool { return false } +func statementErrorHandling(stmt ast.MicroflowStatement) *ast.ErrorHandlingClause { + switch s := stmt.(type) { + case *ast.RetrieveStmt: + return s.ErrorHandling + case *ast.CreateObjectStmt: + return s.ErrorHandling + case *ast.MfCommitStmt: + return s.ErrorHandling + case *ast.DeleteObjectStmt: + return s.ErrorHandling + case *ast.CallMicroflowStmt: + return s.ErrorHandling + case *ast.CallNanoflowStmt: + return s.ErrorHandling + case *ast.CallJavaActionStmt: + return s.ErrorHandling + case *ast.CallJavaScriptActionStmt: + return s.ErrorHandling + case *ast.CallWebServiceStmt: + return s.ErrorHandling + case *ast.ExecuteDatabaseQueryStmt: + return s.ErrorHandling + case *ast.CallExternalActionStmt: + return s.ErrorHandling + case *ast.DownloadFileStmt: + return s.ErrorHandling + case *ast.RestCallStmt: + return s.ErrorHandling + case *ast.SendRestRequestStmt: + return s.ErrorHandling + case *ast.ImportFromMappingStmt: + return s.ErrorHandling + case *ast.ExportToMappingStmt: + return s.ErrorHandling + case *ast.TransformJsonStmt: + return s.ErrorHandling + default: + return nil + } +} + func containsContinueStmt(stmts []ast.MicroflowStatement) bool { for _, stmt := range stmts { switch s := stmt.(type) { diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index bf4eecba..5a556c11 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -39,15 +39,511 @@ func (fb *flowBuilder) ehType(eh *ast.ErrorHandlingClause) microflows.ErrorHandl return convertErrorHandlingType(eh) } +func isEmptyCustomErrorHandler(eh *ast.ErrorHandlingClause) bool { + if eh == nil || len(eh.Body) != 0 { + return false + } + return eh.Type == ast.ErrorHandlingCustom || eh.Type == ast.ErrorHandlingCustomWithoutRollback +} + +func (fb *flowBuilder) finishCustomErrorHandler(activityID model.ID, activityX int, eh *ast.ErrorHandlingClause, outputVar string) { + if eh == nil { + return + } + if len(eh.Body) > 0 { + mergeID := fb.addErrorHandlerFlow(activityID, activityX, eh.Body) + fb.handleErrorHandlerMergeWithSkip(mergeID, activityID, outputVar) + return + } + fb.registerEmptyCustomErrorHandlerWithSkip(activityID, eh, outputVar) +} + +func (fb *flowBuilder) registerEmptyCustomErrorHandlerWithSkip(activityID model.ID, eh *ast.ErrorHandlingClause, skipVar string) { + if !isEmptyCustomErrorHandler(eh) { + return + } + fb.queueActivePendingErrorHandler() + if skipVar == "" { + fb.emptyErrorHandlerFrom = activityID + return + } + fb.errorHandlerSource = activityID + fb.errorHandlerTailFrom = activityID + fb.errorHandlerSkipVar = skipVar + fb.errorHandlerTailIsSource = true +} + +type pendingErrorHandlerState struct { + emptyFrom model.ID + tailFrom model.ID + source model.ID + skipVar string + tailCase string + tailAnchor *ast.FlowAnchors + tailIsSource bool + returnValue string +} + +func (s pendingErrorHandlerState) activeIsEmpty() bool { + return s.emptyFrom == "" && s.tailFrom == "" && s.source == "" && s.skipVar == "" +} + +func (fb *flowBuilder) activePendingErrorHandler() pendingErrorHandlerState { + return pendingErrorHandlerState{ + emptyFrom: fb.emptyErrorHandlerFrom, + tailFrom: fb.errorHandlerTailFrom, + source: fb.errorHandlerSource, + skipVar: fb.errorHandlerSkipVar, + tailCase: fb.errorHandlerTailCase, + tailAnchor: fb.errorHandlerTailAnchor, + tailIsSource: fb.errorHandlerTailIsSource, + returnValue: fb.errorHandlerReturnValue, + } +} + +func (fb *flowBuilder) setActivePendingErrorHandler(state pendingErrorHandlerState) { + fb.emptyErrorHandlerFrom = state.emptyFrom + fb.errorHandlerTailFrom = state.tailFrom + fb.errorHandlerSource = state.source + fb.errorHandlerSkipVar = state.skipVar + fb.errorHandlerTailCase = state.tailCase + fb.errorHandlerTailAnchor = state.tailAnchor + fb.errorHandlerTailIsSource = state.tailIsSource + fb.errorHandlerReturnValue = state.returnValue +} + +func (fb *flowBuilder) queueActivePendingErrorHandler() { + state := fb.activePendingErrorHandler() + if state.activeIsEmpty() { + return + } + fb.pendingErrorHandlers = append(fb.pendingErrorHandlers, state) + fb.setActivePendingErrorHandler(pendingErrorHandlerState{}) +} + +func (fb *flowBuilder) rewritePendingErrorHandlers(rewrite func(pendingErrorHandlerState) pendingErrorHandlerState) { + queue := fb.pendingErrorHandlers[:0] + for _, state := range fb.pendingErrorHandlers { + state = rewrite(state) + if !state.activeIsEmpty() { + queue = append(queue, state) + } + } + fb.pendingErrorHandlers = queue + + active := rewrite(fb.activePendingErrorHandler()) + fb.setActivePendingErrorHandler(active) +} + +func (fb *flowBuilder) addPendingErrorHandlerFlowForStatement(originID, destinationID model.ID, stmt ast.MicroflowStatement, futureReferencesSkipVar ...bool) { + futureReferences := len(futureReferencesSkipVar) > 0 && futureReferencesSkipVar[0] + fb.rewritePendingErrorHandlers(func(state pendingErrorHandlerState) pendingErrorHandlerState { + return fb.addPendingErrorHandlerFlowForState(state, originID, destinationID, stmt, futureReferences) + }) +} + +func (fb *flowBuilder) addPendingErrorHandlerFlowTo(destinationID model.ID) { + if destinationID == "" { + return + } + fb.rewritePendingErrorHandlers(func(state pendingErrorHandlerState) pendingErrorHandlerState { + if state.emptyFrom != "" { + fb.addEmptyErrorHandlerRejoinFlowFrom(state.emptyFrom, state.emptyFrom, destinationID) + state.emptyFrom = "" + } + if state.source != "" && state.tailFrom != "" { + fb.addErrorHandlerRejoinFlowForState(state, state.source, destinationID) + state.source = "" + state.tailFrom = "" + state.skipVar = "" + state.tailCase = "" + state.tailAnchor = nil + state.tailIsSource = false + state.returnValue = "" + } + return state + }) +} + +func (fb *flowBuilder) addPendingErrorHandlerFlowForState(state pendingErrorHandlerState, originID, destinationID model.ID, stmt ast.MicroflowStatement, futureReferencesSkipVar bool) pendingErrorHandlerState { + if destinationID == "" { + return state + } + if state.emptyFrom != "" { + if state.emptyFrom != originID { + return state + } + fb.addEmptyErrorHandlerRejoinFlowFrom(originID, state.emptyFrom, destinationID) + state.emptyFrom = "" + } + if state.tailFrom == "" { + return state + } + if state.source != "" && destinationID == state.source { + return state + } + if state.skipVar != "" { + if statementReferencesVar(stmt, state.skipVar) { + if !fb.hasDeclaredReturnValue() { + if derivedVar := outputDerivedVariable(stmt, state.skipVar); derivedVar != "" { + state.skipVar = derivedVar + } + return state + } + return state + } + if futureReferencesSkipVar { + return state + } + fb.addErrorHandlerRejoinFlowForState(state, originID, destinationID) + return pendingErrorHandlerState{} + } + if state.source != "" && state.source == originID { + fb.addErrorHandlerRejoinFlowForState(state, originID, destinationID) + return pendingErrorHandlerState{} + } + return state +} + +type errorHandlerTail struct { + id model.ID + caseValue string + flowAnchor *ast.FlowAnchors +} + +func (fb *flowBuilder) addEmptyErrorHandlerRejoinFlowFrom(normalOriginID, errorOriginID, destinationID model.ID) { + existingIdx := -1 + for i := len(fb.flows) - 1; i >= 0; i-- { + flow := fb.flows[i] + if !flow.IsErrorHandler && flow.OriginID == normalOriginID && flow.DestinationID == destinationID { + existingIdx = i + break + } + } + if existingIdx == -1 { + if mergeID := fb.findExistingRejoinMerge(normalOriginID, destinationID); mergeID != "" { + fb.flows = append(fb.flows, newErrorHandlerFlow(errorOriginID, mergeID)) + return + } + fb.flows = append(fb.flows, newErrorHandlerFlow(errorOriginID, destinationID)) + return + } + + existing := fb.flows[existingIdx] + fb.flows = append(fb.flows[:existingIdx], fb.flows[existingIdx+1:]...) + + merge := µflows.ExclusiveMerge{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: fb.posX - HorizontalSpacing/2, Y: fb.baseY}, + Size: model.Size{Width: MergeSize, Height: MergeSize}, + }, + } + fb.objects = append(fb.objects, merge) + + normalFlow := newHorizontalFlow(normalOriginID, merge.ID) + normalFlow.OriginConnectionIndex = existing.OriginConnectionIndex + normalFlow.CaseValue = existing.CaseValue + fb.flows = append(fb.flows, normalFlow) + fb.flows = append(fb.flows, newErrorHandlerFlow(errorOriginID, merge.ID)) + + mergeFlow := newHorizontalFlow(merge.ID, destinationID) + mergeFlow.DestinationConnectionIndex = existing.DestinationConnectionIndex + fb.flows = append(fb.flows, mergeFlow) +} + +func (fb *flowBuilder) addErrorHandlerRejoinFlowForState(state pendingErrorHandlerState, originID, destinationID model.ID) { + existingIdx := -1 + for i := len(fb.flows) - 1; i >= 0; i-- { + flow := fb.flows[i] + if !flow.IsErrorHandler && flow.OriginID == originID && flow.DestinationID == destinationID { + existingIdx = i + break + } + } + if existingIdx == -1 { + if mergeID := fb.findExistingRejoinMerge(originID, destinationID); mergeID != "" { + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, mergeID)) + } else { + flow := newUpwardFlow(state.tailFrom, mergeID) + applyDeferredFlowCase(flow, state.tailCase, state.tailAnchor) + fb.flows = append(fb.flows, flow) + } + return + } + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, destinationID)) + } else { + flow := newHorizontalFlow(state.tailFrom, destinationID) + applyDeferredFlowCase(flow, state.tailCase, state.tailAnchor) + fb.flows = append(fb.flows, flow) + } + return + } + + existing := fb.flows[existingIdx] + fb.flows = append(fb.flows[:existingIdx], fb.flows[existingIdx+1:]...) + + merge := µflows.ExclusiveMerge{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: fb.posX - HorizontalSpacing/2, Y: fb.baseY}, + Size: model.Size{Width: MergeSize, Height: MergeSize}, + }, + } + fb.objects = append(fb.objects, merge) + + normalFlow := newHorizontalFlow(originID, merge.ID) + normalFlow.OriginConnectionIndex = existing.OriginConnectionIndex + normalFlow.CaseValue = existing.CaseValue + fb.flows = append(fb.flows, normalFlow) + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, merge.ID)) + } else { + flow := newUpwardFlow(state.tailFrom, merge.ID) + applyDeferredFlowCase(flow, state.tailCase, state.tailAnchor) + fb.flows = append(fb.flows, flow) + } + + mergeFlow := newHorizontalFlow(merge.ID, destinationID) + mergeFlow.DestinationConnectionIndex = existing.DestinationConnectionIndex + fb.flows = append(fb.flows, mergeFlow) +} + +func applyDeferredFlowCase(flow *microflows.SequenceFlow, caseValue string, anchor *ast.FlowAnchors) { + if flow == nil { + return + } + if caseValue != "" { + flow.CaseValue = microflows.EnumerationCase{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Value: caseValue, + } + } + applyUserAnchors(flow, anchor, anchor) +} + +func (fb *flowBuilder) findExistingRejoinMerge(originID, destinationID model.ID) model.ID { + // Error-handler rejoins are rare and microflows are small enough that an + // O(objects*flows) scan keeps the write path simpler than maintaining an + // incremental merge index. + for _, flow := range fb.flows { + if flow.OriginID != originID || flow.IsErrorHandler { + continue + } + if !fb.isExclusiveMerge(flow.DestinationID) { + continue + } + for _, mergeFlow := range fb.flows { + if mergeFlow.OriginID == flow.DestinationID && mergeFlow.DestinationID == destinationID && !mergeFlow.IsErrorHandler { + return flow.DestinationID + } + } + } + return "" +} + +func (fb *flowBuilder) isExclusiveMerge(id model.ID) bool { + for _, obj := range fb.objects { + if obj.GetID() != id { + continue + } + _, ok := obj.(*microflows.ExclusiveMerge) + return ok + } + return false +} + +func statementReferencesVar(stmt ast.MicroflowStatement, varName string) bool { + if stmt == nil || varName == "" { + return false + } + for _, ref := range errorHandlerStatementVarRefs(stmt) { + if ref == varName { + return true + } + } + return false +} + +func statementsReferenceVar(stmts []ast.MicroflowStatement, varName string) bool { + if varName == "" { + return false + } + for _, stmt := range stmts { + if statementReferencesVar(stmt, varName) { + return true + } + } + return false +} + +func callArgumentVarRefs(args []ast.CallArgument) []string { + var refs []string + for _, arg := range args { + refs = append(refs, exprVarRefs(arg.Value)...) + } + return refs +} + +func templateParamVarRefs(params []ast.TemplateParam) []string { + var refs []string + for _, param := range params { + refs = append(refs, exprVarRefs(param.Value)...) + } + return refs +} + +func errorHandlerStatementVarRefs(stmt ast.MicroflowStatement) []string { + var refs []string + switch s := stmt.(type) { + case *ast.DeclareStmt: + refs = append(refs, exprVarRefs(s.InitialValue)...) + case *ast.ReturnStmt: + refs = append(refs, exprVarRefs(s.Value)...) + case *ast.LogStmt: + refs = append(refs, exprVarRefs(s.Node)...) + refs = append(refs, exprVarRefs(s.Message)...) + refs = append(refs, templateParamVarRefs(s.Template)...) + case *ast.ShowMessageStmt: + refs = append(refs, exprVarRefs(s.Message)...) + for _, arg := range s.TemplateArgs { + refs = append(refs, exprVarRefs(arg)...) + } + case *ast.IfStmt: + refs = append(refs, exprVarRefs(s.Condition)...) + refs = append(refs, errorHandlerStatementsVarRefs(s.ThenBody)...) + refs = append(refs, errorHandlerStatementsVarRefs(s.ElseBody)...) + case *ast.WhileStmt: + refs = append(refs, exprVarRefs(s.Condition)...) + refs = append(refs, errorHandlerStatementsVarRefs(s.Body)...) + case *ast.LoopStmt: + refs = append(refs, s.ListVariable) + refs = append(refs, errorHandlerStatementsVarRefs(s.Body)...) + case *ast.MfSetStmt: + refs = append(refs, extractVarName(s.Target)) + refs = append(refs, exprVarRefs(s.Value)...) + case *ast.ChangeObjectStmt: + refs = append(refs, s.Variable) + for _, change := range s.Changes { + refs = append(refs, exprVarRefs(change.Value)...) + } + case *ast.CreateObjectStmt: + for _, change := range s.Changes { + refs = append(refs, exprVarRefs(change.Value)...) + } + case *ast.RetrieveStmt: + if s.StartVariable != "" { + refs = append(refs, s.StartVariable) + } + refs = append(refs, exprVarRefs(s.Where)...) + case *ast.CallMicroflowStmt: + refs = append(refs, callArgumentVarRefs(s.Arguments)...) + case *ast.CallNanoflowStmt: + refs = append(refs, callArgumentVarRefs(s.Arguments)...) + case *ast.CallJavaActionStmt: + refs = append(refs, callArgumentVarRefs(s.Arguments)...) + case *ast.CallJavaScriptActionStmt: + refs = append(refs, callArgumentVarRefs(s.Arguments)...) + case *ast.CallWebServiceStmt: + refs = append(refs, exprVarRefs(s.Timeout)...) + case *ast.ExecuteDatabaseQueryStmt: + refs = append(refs, callArgumentVarRefs(s.Arguments)...) + refs = append(refs, callArgumentVarRefs(s.ConnectionArguments)...) + case *ast.CallExternalActionStmt: + refs = append(refs, callArgumentVarRefs(s.Arguments)...) + case *ast.RestCallStmt: + refs = append(refs, exprVarRefs(s.URL)...) + refs = append(refs, templateParamVarRefs(s.URLParams)...) + for _, header := range s.Headers { + refs = append(refs, exprVarRefs(header.Value)...) + } + if s.Auth != nil { + refs = append(refs, exprVarRefs(s.Auth.Username)...) + refs = append(refs, exprVarRefs(s.Auth.Password)...) + } + if s.Body != nil { + refs = append(refs, exprVarRefs(s.Body.Template)...) + refs = append(refs, templateParamVarRefs(s.Body.TemplateParams)...) + if s.Body.SourceVariable != "" { + refs = append(refs, s.Body.SourceVariable) + } + } + refs = append(refs, exprVarRefs(s.Timeout)...) + case *ast.SendRestRequestStmt: + for _, param := range s.Parameters { + refs = append(refs, sourceAttributeVarRefs(param.Expression)...) + } + if s.BodyVariable != "" { + refs = append(refs, s.BodyVariable) + } + case *ast.ImportFromMappingStmt: + if s.SourceVariable != "" { + refs = append(refs, s.SourceVariable) + } + case *ast.ExportToMappingStmt: + if s.SourceVariable != "" { + refs = append(refs, s.SourceVariable) + } + case *ast.TransformJsonStmt: + if s.InputVariable != "" { + refs = append(refs, s.InputVariable) + } + case *ast.MfCommitStmt: + refs = append(refs, s.Variable) + case *ast.DeleteObjectStmt: + refs = append(refs, s.Variable) + case *ast.DownloadFileStmt: + refs = append(refs, s.FileDocument) + case *ast.ValidationFeedbackStmt: + if s.AttributePath != nil { + refs = append(refs, s.AttributePath.Variable) + } + refs = append(refs, exprVarRefs(s.Message)...) + for _, arg := range s.TemplateArgs { + refs = append(refs, exprVarRefs(arg)...) + } + case *ast.AddToListStmt: + refs = append(refs, s.Item, s.List) + case *ast.RemoveFromListStmt: + refs = append(refs, s.Item, s.List) + } + return refs +} + +func outputDerivedVariable(stmt ast.MicroflowStatement, sourceVar string) string { + declare, ok := stmt.(*ast.DeclareStmt) + if !ok || declare.Variable == "" { + return "" + } + for _, ref := range exprVarRefs(declare.InitialValue) { + if ref == sourceVar { + return declare.Variable + } + } + return "" +} + +func errorHandlerStatementsVarRefs(stmts []ast.MicroflowStatement) []string { + var refs []string + for _, stmt := range stmts { + refs = append(refs, errorHandlerStatementVarRefs(stmt)...) + } + return refs +} + // newErrorHandlerFlow creates a SequenceFlow with IsErrorHandler=true, -// connecting from the bottom of the source activity to the left of the error handler. +// connecting from the bottom of the source activity to the top of the handler. +// Studio Pro lays custom error handlers below their source, so the destination +// anchor enters from above rather than from the normal left-side continuation. func newErrorHandlerFlow(originID, destinationID model.ID) *microflows.SequenceFlow { return µflows.SequenceFlow{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, OriginID: originID, DestinationID: destinationID, OriginConnectionIndex: AnchorBottom, - DestinationConnectionIndex: AnchorLeft, + DestinationConnectionIndex: AnchorTop, IsErrorHandler: true, } } @@ -56,9 +552,9 @@ func newErrorHandlerFlow(originID, destinationID model.ID) *microflows.SequenceF // positions them below the source activity, and connects them with an error handler flow. // Returns the last activity ID if the error handler should merge back to the main flow. // Returns empty model.ID if the error handler terminates (via RAISE ERROR or RETURN). -func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX int, errorBody []ast.MicroflowStatement) model.ID { +func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX int, errorBody []ast.MicroflowStatement) errorHandlerTail { if len(errorBody) == 0 { - return "" + return errorHandlerTail{} } // Position error handler below the main flow @@ -71,6 +567,7 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in posY: errorY, baseY: errorY, spacing: HorizontalSpacing, + returnType: fb.returnType, varTypes: fb.varTypes, declaredVars: fb.declaredVars, measurer: fb.measurer, @@ -81,6 +578,8 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in } var lastErrID model.ID + var lastErrCase string + var lastErrAnchor *ast.FlowAnchors for _, stmt := range errorBody { actID := errBuilder.addStatement(stmt) if actID != "" { @@ -93,9 +592,15 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in } if errBuilder.nextConnectionPoint != "" { lastErrID = errBuilder.nextConnectionPoint + lastErrCase = errBuilder.nextFlowCase + lastErrAnchor = errBuilder.nextFlowAnchor errBuilder.nextConnectionPoint = "" + errBuilder.nextFlowCase = "" + errBuilder.nextFlowAnchor = nil } else { lastErrID = actID + lastErrCase = "" + lastErrAnchor = nil } } } @@ -107,32 +612,31 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in // If the error handler ends with RAISE ERROR or RETURN, it terminates there. // Otherwise, return the last activity ID so caller can create a merge. if errBuilder.endsWithReturn { - return "" // Error handler terminates, no merge needed + return errorHandlerTail{} // Error handler terminates, no merge needed } - return lastErrID // Error handler should merge back to main flow + return errorHandlerTail{id: lastErrID, caseValue: lastErrCase, flowAnchor: lastErrAnchor} // Error handler should merge back to main flow } // handleErrorHandlerMerge creates an EndEvent for error handlers that want to merge back. // This is a fallback until full merge support is implemented. Caller should pass -// the ID returned by addErrorHandlerFlow and the error handler Y position. -func (fb *flowBuilder) handleErrorHandlerMerge(lastErrID model.ID, activityID model.ID, errorY int) { - if lastErrID == "" { +// the tail returned by addErrorHandlerFlow and the error handler Y position. +func (fb *flowBuilder) handleErrorHandlerMerge(tail errorHandlerTail, activityID model.ID, errorY int) { + _ = errorY + fb.handleErrorHandlerMergeWithSkip(tail, activityID, "") +} + +func (fb *flowBuilder) handleErrorHandlerMergeWithSkip(tail errorHandlerTail, activityID model.ID, skipVar string) { + if tail.id == "" { return // No merge needed (error handler terminates with RETURN or RAISE ERROR) } - // Error handler doesn't end with RETURN/RAISE — create EndEvent to terminate the path. - // When the microflow has a return type, use the return value from a prior RETURN statement - // if available to avoid "Return value required" errors. If no RETURN has been seen yet, - // fall back to empty (works for void microflows). - endEvent := µflows.EndEvent{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, - Position: model.Point{X: fb.posX, Y: errorY}, - Size: model.Size{Width: EventSize, Height: EventSize}, - }, - ReturnValue: fb.returnValue, - } - fb.objects = append(fb.objects, endEvent) - fb.flows = append(fb.flows, newHorizontalFlow(lastErrID, endEvent.ID)) + fb.queueActivePendingErrorHandler() + fb.errorHandlerSource = activityID + fb.errorHandlerTailFrom = tail.id + fb.errorHandlerSkipVar = skipVar + fb.errorHandlerTailCase = tail.caseValue + fb.errorHandlerTailAnchor = tail.flowAnchor + fb.errorHandlerTailIsSource = false + fb.errorHandlerReturnValue = fb.returnValue } // newHorizontalFlow creates a SequenceFlow with anchors for horizontal left-to-right connection diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index 321a85f5..80e6e93e 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -27,6 +27,7 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a fb.objectInputVariables = collectObjectInputVariables(stmts) } // Set return value expression for error handler EndEvents + fb.returnType = returns if returns != nil && returns.Variable != "" { fb.returnValue = "$" + returns.Variable } @@ -64,7 +65,7 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a // deferred split→nextActivity flow honours @anchor(true: ..., false: ...). pendingCase := "" var pendingFlowAnchor *ast.FlowAnchors - for _, stmt := range stmts { + for i, stmt := range stmts { // Snapshot the current statement's anchor annotation before addStatement // can reset pendingAnnotations via recursive processing. The incoming // side (To) is applied when this statement is the destination of the @@ -94,6 +95,7 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a pendingFlowAnchor = nil applyUserAnchors(flow, originAnchor, destAnchor) fb.flows = append(fb.flows, flow) + fb.addPendingErrorHandlerFlowForStatement(lastID, activityID, stmt, statementsReferenceVar(stmts[i+1:], fb.errorHandlerSkipVar)) fb.previousStmtAnchor = stmtAnchor // For compound statements (IF, LOOP), the exit point differs from entry point @@ -155,7 +157,15 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a } applyUserAnchors(endFlow, originAnchor, nil) fb.flows = append(fb.flows, endFlow) + var endStmt ast.MicroflowStatement + if len(fb.returnValue) > 1 && fb.returnValue[0] == '$' { + endStmt = &ast.ReturnStmt{Value: &ast.VariableExpr{Name: fb.returnValue[1:]}} + } + fb.addPendingErrorHandlerFlowForStatement(lastID, endEvent.ID, endStmt) + fb.terminatePendingErrorHandlersAtEnd(returns) fb.previousStmtAnchor = nil + } else { + fb.terminatePendingErrorHandlersAtEnd(returns) } return µflows.MicroflowObjectCollection{ @@ -417,6 +427,59 @@ func sourceAttributeVarRefs(source string) []string { return refs } +func (fb *flowBuilder) terminatePendingErrorHandlersAtEnd(returns *ast.MicroflowReturnType) { + fb.rewritePendingErrorHandlers(func(state pendingErrorHandlerState) pendingErrorHandlerState { + if state.emptyFrom != "" { + if returns != nil && returns.Type.Kind != ast.TypeVoid && fb.lastReturnEndID != "" { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.emptyFrom, fb.lastReturnEndID)) + } else { + endID := fb.addTerminalEndEventForPendingHandler(returns, state.returnValue) + fb.flows = append(fb.flows, newErrorHandlerFlow(state.emptyFrom, endID)) + } + state.emptyFrom = "" + state.returnValue = "" + } + if state.tailFrom != "" { + if returns != nil && returns.Type.Kind != ast.TypeVoid && state.returnValue == "" && fb.lastReturnEndID != "" { + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, fb.lastReturnEndID)) + } else { + fb.flows = append(fb.flows, newHorizontalFlow(state.tailFrom, fb.lastReturnEndID)) + } + } else { + endID := fb.addTerminalEndEventForPendingHandler(returns, state.returnValue) + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, endID)) + } else { + fb.flows = append(fb.flows, newHorizontalFlow(state.tailFrom, endID)) + } + } + state.source = "" + state.tailFrom = "" + state.skipVar = "" + state.tailIsSource = false + state.returnValue = "" + } + return state + }) +} + +func (fb *flowBuilder) addTerminalEndEventForPendingHandler(returns *ast.MicroflowReturnType, returnValue string) model.ID { + if returnValue == "" && returns != nil && returns.Type.Kind != ast.TypeVoid { + returnValue = fb.returnValue + } + end := µflows.EndEvent{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: fb.posX + HorizontalSpacing/2, Y: fb.baseY + VerticalSpacing}, + Size: model.Size{Width: EventSize, Height: EventSize}, + }, + ReturnValue: returnValue, + } + fb.objects = append(fb.objects, end) + return end.ID +} + // addStatement converts an AST statement to a microflow activity and returns its ID. func (fb *flowBuilder) addStatement(stmt ast.MicroflowStatement) model.ID { // Extract annotations from the statement and merge into pendingAnnotations diff --git a/mdl/executor/cmd_microflows_builder_terminal_test.go b/mdl/executor/cmd_microflows_builder_terminal_test.go index 48c512be..bf5982c2 100644 --- a/mdl/executor/cmd_microflows_builder_terminal_test.go +++ b/mdl/executor/cmd_microflows_builder_terminal_test.go @@ -3,9 +3,11 @@ package executor import ( + "strings" "testing" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" ) @@ -290,3 +292,643 @@ func TestLastStmtIsReturn_EnumSplitWithoutElseNonTerminalCase_NotTerminal(t *tes t.Error("ENUM split without ELSE must not be terminal when any emitted case can continue") } } + +func TestBuildFlowGraph_NonTerminalCustomHandlerRejoinsContinuation(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "RefreshExternalData"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogError, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "refresh failed"}}, + }, + }, + }, + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "ContinueWithNextBatch"}, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph(body, nil) + + var sourceID, handlerLogID, nextID model.ID + for _, obj := range oc.Objects { + activity, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + switch action := activity.Action.(type) { + case *microflows.MicroflowCallAction: + if action.MicroflowCall != nil && action.MicroflowCall.Microflow == "SampleSync.RefreshExternalData" { + sourceID = activity.ID + } + if action.MicroflowCall != nil && action.MicroflowCall.Microflow == "SampleSync.ContinueWithNextBatch" { + nextID = activity.ID + } + case *microflows.LogMessageAction: + if action.LogLevel == "Error" { + handlerLogID = activity.ID + } + } + } + if sourceID == "" || handlerLogID == "" || nextID == "" { + t.Fatalf("expected source call, handler log, and continuation; got source=%q log=%q next=%q", sourceID, handlerLogID, nextID) + } + if !flowPathExists(oc.Flows, handlerLogID, nextID) { + t.Fatal("non-terminal custom error handler should rejoin the next safe continuation") + } + for _, flow := range oc.Flows { + if flow.IsErrorHandler && flow.OriginID == sourceID && flow.DestinationID == nextID { + t.Fatal("custom handler must execute its body before rejoining") + } + } +} + +func TestBuildFlowGraph_ConsecutiveCustomHandlersEachRejoinsContinuation(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "RetryFirstBatch"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogError, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "first failed"}}, + }, + }, + }, + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "RetrySecondBatch"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogError, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "second failed"}}, + }, + }, + }, + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "RetryFinalBatch"}, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph(body, nil) + + callIDs := map[string]model.ID{} + logIDs := map[string]model.ID{} + for _, obj := range oc.Objects { + activity, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + switch action := activity.Action.(type) { + case *microflows.MicroflowCallAction: + if action.MicroflowCall != nil { + callIDs[action.MicroflowCall.Microflow] = activity.ID + } + case *microflows.LogMessageAction: + if action.MessageTemplate != nil { + logIDs[action.MessageTemplate.Translations["en_US"]] = activity.ID + } + } + } + + firstLog := logIDs["first failed"] + secondLog := logIDs["second failed"] + secondCall := callIDs["SampleSync.RetrySecondBatch"] + finalCall := callIDs["SampleSync.RetryFinalBatch"] + if firstLog == "" || secondLog == "" || secondCall == "" || finalCall == "" { + t.Fatalf("expected all handler logs and continuation calls; logs=%#v calls=%#v", logIDs, callIDs) + } + if !flowPathExists(oc.Flows, firstLog, secondCall) { + t.Fatal("first pending handler must rejoin before the second call instead of being overwritten") + } + if !flowPathExists(oc.Flows, secondLog, finalCall) { + t.Fatal("second pending handler must rejoin before the final continuation") + } +} + +func TestBodyHasContinuingCustomErrorHandler_CoversActionStatements(t *testing.T) { + continuingHandler := &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogError, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "failed"}}, + }, + } + + tests := []struct { + name string + stmt ast.MicroflowStatement + }{ + {name: "retrieve", stmt: &ast.RetrieveStmt{ErrorHandling: continuingHandler}}, + {name: "create object", stmt: &ast.CreateObjectStmt{ErrorHandling: continuingHandler}}, + {name: "commit", stmt: &ast.MfCommitStmt{ErrorHandling: continuingHandler}}, + {name: "delete", stmt: &ast.DeleteObjectStmt{ErrorHandling: continuingHandler}}, + {name: "call microflow", stmt: &ast.CallMicroflowStmt{ErrorHandling: continuingHandler}}, + {name: "call nanoflow", stmt: &ast.CallNanoflowStmt{ErrorHandling: continuingHandler}}, + {name: "call java action", stmt: &ast.CallJavaActionStmt{ErrorHandling: continuingHandler}}, + {name: "call javascript action", stmt: &ast.CallJavaScriptActionStmt{ErrorHandling: continuingHandler}}, + {name: "call web service", stmt: &ast.CallWebServiceStmt{ErrorHandling: continuingHandler}}, + {name: "execute database query", stmt: &ast.ExecuteDatabaseQueryStmt{ErrorHandling: continuingHandler}}, + {name: "call external action", stmt: &ast.CallExternalActionStmt{ErrorHandling: continuingHandler}}, + {name: "download file", stmt: &ast.DownloadFileStmt{ErrorHandling: continuingHandler}}, + {name: "rest call", stmt: &ast.RestCallStmt{ErrorHandling: continuingHandler}}, + {name: "send rest request", stmt: &ast.SendRestRequestStmt{ErrorHandling: continuingHandler}}, + {name: "import mapping", stmt: &ast.ImportFromMappingStmt{ErrorHandling: continuingHandler}}, + {name: "export mapping", stmt: &ast.ExportToMappingStmt{ErrorHandling: continuingHandler}}, + {name: "transform json", stmt: &ast.TransformJsonStmt{ErrorHandling: continuingHandler}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !bodyHasContinuingCustomErrorHandler([]ast.MicroflowStatement{tt.stmt}) { + t.Fatalf("%T must be visible to continuing custom handler detection", tt.stmt) + } + }) + } +} + +func TestStatementReferencesVar_CoversActionStatementInputs(t *testing.T) { + ref := &ast.VariableExpr{Name: "SkippedOutput"} + tests := []struct { + name string + stmt ast.MicroflowStatement + }{ + {name: "call nanoflow", stmt: &ast.CallNanoflowStmt{Arguments: []ast.CallArgument{{Name: "value", Value: ref}}}}, + {name: "call javascript action", stmt: &ast.CallJavaScriptActionStmt{Arguments: []ast.CallArgument{{Name: "value", Value: ref}}}}, + {name: "call web service timeout", stmt: &ast.CallWebServiceStmt{Timeout: ref}}, + {name: "execute database query argument", stmt: &ast.ExecuteDatabaseQueryStmt{Arguments: []ast.CallArgument{{Name: "value", Value: ref}}}}, + {name: "execute database query connection argument", stmt: &ast.ExecuteDatabaseQueryStmt{ConnectionArguments: []ast.CallArgument{{Name: "value", Value: ref}}}}, + {name: "call external action", stmt: &ast.CallExternalActionStmt{Arguments: []ast.CallArgument{{Name: "value", Value: ref}}}}, + {name: "rest auth", stmt: &ast.RestCallStmt{Auth: &ast.RestAuth{Username: ref}}}, + {name: "send rest request parameter", stmt: &ast.SendRestRequestStmt{Parameters: []ast.SendRestParamDef{{Name: "value", Expression: "$SkippedOutput/Name"}}}}, + {name: "send rest request body", stmt: &ast.SendRestRequestStmt{BodyVariable: "SkippedOutput"}}, + {name: "import mapping", stmt: &ast.ImportFromMappingStmt{SourceVariable: "SkippedOutput"}}, + {name: "export mapping", stmt: &ast.ExportToMappingStmt{SourceVariable: "SkippedOutput"}}, + {name: "transform json", stmt: &ast.TransformJsonStmt{InputVariable: "SkippedOutput"}}, + {name: "download file", stmt: &ast.DownloadFileStmt{FileDocument: "SkippedOutput"}}, + {name: "validation feedback target", stmt: &ast.ValidationFeedbackStmt{AttributePath: &ast.AttributePathExpr{Variable: "SkippedOutput", Path: []string{"Name"}}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !statementReferencesVar(tt.stmt, "SkippedOutput") { + t.Fatalf("%T must expose input variable references for custom handler routing", tt.stmt) + } + }) + } +} + +func TestBuildFlowGraph_EmptyOutputHandlerTerminatesBeforeOutputDependentTail(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallJavaActionStmt{ + OutputVariable: "ProcessedCount", + ActionName: ast.QualifiedName{Module: "SampleMigration", Name: "CountProcessedItems"}, + ErrorHandling: &ast.ErrorHandlingClause{Type: ast.ErrorHandlingCustomWithoutRollback}, + }, + &ast.LogStmt{ + Level: ast.LogInfo, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "processed {1}"}, + Template: []ast.TemplateParam{{ + Index: 1, + Value: &ast.VariableExpr{Name: "ProcessedCount"}, + }}, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph(body, nil) + + var javaID, logID model.ID + endIDs := map[model.ID]bool{} + for _, obj := range oc.Objects { + switch o := obj.(type) { + case *microflows.ActionActivity: + switch action := o.Action.(type) { + case *microflows.JavaActionCallAction: + if action.ResultVariableName == "ProcessedCount" { + javaID = o.ID + } + case *microflows.LogMessageAction: + if action.MessageTemplate != nil && action.MessageTemplate.Translations["en_US"] == "processed {1}" { + logID = o.ID + } + } + case *microflows.EndEvent: + endIDs[o.ID] = true + } + } + if javaID == "" || logID == "" || len(endIDs) == 0 { + t.Fatalf("expected java action, output-dependent log, and end event; got java=%q log=%q ends=%v", javaID, logID, endIDs) + } + + var errorFlowTerminates bool + for _, flow := range oc.Flows { + if !flow.IsErrorHandler || flow.OriginID != javaID { + continue + } + if flowPathExists(oc.Flows, flow.DestinationID, logID) { + t.Fatal("empty output handler must not rejoin before a statement that reads the missing output") + } + for endID := range endIDs { + if flow.DestinationID == endID || flowPathExists(oc.Flows, flow.DestinationID, endID) { + errorFlowTerminates = true + } + } + } + if !errorFlowTerminates { + t.Fatal("empty output handler should terminate at an EndEvent before the output-dependent tail") + } +} + +func TestBuildFlowGraph_OutputHandlerTerminatesBeforeDeclareReferencingOutput(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + OutputVariable: "CreatedRecord", + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "CreateRecord"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogError, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "create failed"}}, + }, + }, + }, + &ast.DeclareStmt{ + Variable: "SuccessMessage", + Type: ast.DataType{Kind: ast.TypeString}, + InitialValue: &ast.BinaryExpr{ + Left: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "Created "}, + Operator: "+", + Right: &ast.AttributePathExpr{Variable: "CreatedRecord", Path: []string{"Name"}}, + }, + }, + } + + if !statementReferencesVar(body[1], "CreatedRecord") { + t.Fatal("DECLARE initial values must be visible to custom handler skip-var routing") + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph(body, nil) + + var callID, declareID model.ID + endIDs := map[model.ID]bool{} + for _, obj := range oc.Objects { + switch o := obj.(type) { + case *microflows.ActionActivity: + switch action := o.Action.(type) { + case *microflows.MicroflowCallAction: + if action.ResultVariableName == "CreatedRecord" { + callID = o.ID + } + case *microflows.CreateVariableAction: + if action.VariableName == "SuccessMessage" { + declareID = o.ID + } + } + case *microflows.EndEvent: + endIDs[o.ID] = true + } + } + if callID == "" || declareID == "" || len(endIDs) == 0 { + t.Fatalf("expected call, output-dependent declare, and end event; got call=%q declare=%q ends=%v", callID, declareID, endIDs) + } + + var errorFlowTerminates bool + for _, flow := range oc.Flows { + if !flow.IsErrorHandler || flow.OriginID != callID { + continue + } + if flowPathExists(oc.Flows, flow.DestinationID, declareID) { + t.Fatal("custom handler must not rejoin before a DECLARE that reads the missing output") + } + for endID := range endIDs { + if flow.DestinationID == endID || flowPathExists(oc.Flows, flow.DestinationID, endID) { + errorFlowTerminates = true + } + } + } + if !errorFlowTerminates { + t.Fatal("custom handler should terminate at an EndEvent before the output-dependent declare") + } +} + +func TestBuildFlowGraph_OutputHandlerInReturningBranchSkipsDerivedSuccessTail(t *testing.T) { + successMessage := &ast.VariableExpr{Name: "SuccessMessage"} + body := []ast.MicroflowStatement{ + &ast.DeclareStmt{ + Variable: "ErrorMessage", + Type: ast.DataType{Kind: ast.TypeString}, + InitialValue: &ast.LiteralExpr{Kind: ast.LiteralString, Value: ""}, + }, + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "CanCreate"}, + ThenBody: []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + OutputVariable: "CreatedRecord", + MicroflowName: ast.QualifiedName{Module: "SampleService", Name: "CreateRecord"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.MfSetStmt{ + Target: "ErrorMessage", + Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "create failed"}, + }, + }, + }, + }, + &ast.DeclareStmt{ + Variable: "SuccessMessage", + Type: ast.DataType{Kind: ast.TypeString}, + InitialValue: &ast.BinaryExpr{ + Left: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "Created "}, + Operator: "+", + Right: &ast.AttributePathExpr{Variable: "CreatedRecord", Path: []string{"Name"}}, + }, + }, + &ast.LogStmt{ + Level: ast.LogInfo, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"}, + Template: []ast.TemplateParam{ + {Index: 1, Value: successMessage}, + }, + }, + &ast.ShowMessageStmt{ + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"}, + Type: "Information", + TemplateArgs: []ast.Expression{successMessage}, + }, + &ast.ClosePageStmt{}, + &ast.ReturnStmt{}, + }, + ElseBody: []ast.MicroflowStatement{ + &ast.MfSetStmt{ + Target: "ErrorMessage", + Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "not found"}, + }, + }, + }, + &ast.LogStmt{ + Level: ast.LogError, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"}, + Template: []ast.TemplateParam{ + {Index: 1, Value: &ast.VariableExpr{Name: "ErrorMessage"}}, + }, + }, + &ast.ShowMessageStmt{ + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"}, + Type: "Error", + TemplateArgs: []ast.Expression{&ast.VariableExpr{Name: "ErrorMessage"}}, + }, + &ast.ReturnStmt{}, + } + + if !statementReferencesVar(body[1].(*ast.IfStmt).ThenBody[3], "SuccessMessage") { + t.Fatal("SHOW MESSAGE arguments must be visible to custom handler skip-var routing") + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + declaredVars: map[string]string{"CanCreate": "Boolean"}, + measurer: &layoutMeasurer{}, + } + oc := fb.buildFlowGraph(body, nil) + + var callID, handlerSetID, successDeclareID, errorLogID model.ID + for _, obj := range oc.Objects { + activity, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + switch action := activity.Action.(type) { + case *microflows.MicroflowCallAction: + if action.ResultVariableName == "CreatedRecord" { + callID = activity.ID + } + case *microflows.ChangeVariableAction: + if action.VariableName == "ErrorMessage" && strings.Contains(action.Value, "create failed") { + handlerSetID = activity.ID + } + case *microflows.CreateVariableAction: + if action.VariableName == "SuccessMessage" { + successDeclareID = activity.ID + } + case *microflows.LogMessageAction: + if action.LogLevel == "Error" { + errorLogID = activity.ID + } + } + } + if callID == "" || handlerSetID == "" || successDeclareID == "" || errorLogID == "" { + t.Fatalf("expected source call, handler set, success declare, and error log; got call=%q handler=%q declare=%q errorLog=%q", callID, handlerSetID, successDeclareID, errorLogID) + } + if !flowPathExists(oc.Flows, callID, handlerSetID) { + t.Fatal("source call must connect to the custom handler body") + } + if flowPathExists(oc.Flows, handlerSetID, successDeclareID) { + t.Fatal("custom handler must skip the output-derived success tail") + } + if !flowPathExists(oc.Flows, handlerSetID, errorLogID) { + t.Fatal("custom handler in a returning branch must rejoin at the shared safe continuation") + } +} + +func TestBuildFlowGraph_EmptyNoOutputHandlerRejoinsAtNextAction(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleMigration", Name: "RefreshCache"}, + ErrorHandling: &ast.ErrorHandlingClause{Type: ast.ErrorHandlingCustomWithoutRollback}, + }, + &ast.CallJavaActionStmt{ + OutputVariable: "ProcessedCount", + ActionName: ast.QualifiedName{Module: "SampleMigration", Name: "CountProcessedItems"}, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph(body, nil) + + var callID, javaID model.ID + for _, obj := range oc.Objects { + activity, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + switch action := activity.Action.(type) { + case *microflows.MicroflowCallAction: + if action.MicroflowCall != nil && action.MicroflowCall.Microflow == "SampleMigration.RefreshCache" { + callID = activity.ID + } + case *microflows.JavaActionCallAction: + if action.ResultVariableName == "ProcessedCount" { + javaID = activity.ID + } + } + } + if callID == "" || javaID == "" { + t.Fatalf("expected no-output call and output-producing java action; got call=%q java=%q", callID, javaID) + } + for _, flow := range oc.Flows { + if flow.IsErrorHandler && flow.OriginID == callID && flowPathExists(oc.Flows, flow.DestinationID, javaID) { + return + } + } + t.Fatal("empty no-output handler should rejoin at the next action") +} + +func TestBuildFlowGraph_ErrorHandlerEmptyElseKeepsFalseCaseOnRejoin(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "Synthetic", Name: "PatchRemoteState"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustom, + Body: []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "HasResponse"}, + HasElse: true, + ThenBody: []ast.MicroflowStatement{ + &ast.ReturnStmt{Value: &ast.VariableExpr{Name: "ErrorResponse"}}, + }, + }, + }, + }, + }, + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "Synthetic", Name: "ContinueAfterPatch"}, + }, + &ast.ReturnStmt{Value: &ast.LiteralExpr{Kind: ast.LiteralNull}}, + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + declaredVars: map[string]string{"HasResponse": "Boolean", "ErrorResponse": "Synthetic.Error"}, + measurer: &layoutMeasurer{}, + } + oc := fb.buildFlowGraph(body, &ast.MicroflowReturnType{Type: ast.DataType{Kind: ast.TypeEntity, EntityRef: &ast.QualifiedName{Module: "Synthetic", Name: "Error"}}}) + + var splitID, continuationID model.ID + for _, obj := range oc.Objects { + switch o := obj.(type) { + case *microflows.ExclusiveSplit: + if condition, ok := o.SplitCondition.(*microflows.ExpressionSplitCondition); ok && condition.Expression == "$HasResponse" { + splitID = o.ID + } + case *microflows.ActionActivity: + if action, ok := o.Action.(*microflows.MicroflowCallAction); ok && action.MicroflowCall != nil && action.MicroflowCall.Microflow == "Synthetic.ContinueAfterPatch" { + continuationID = o.ID + } + } + } + if splitID == "" || continuationID == "" { + t.Fatalf("expected error-handler split and continuation call, got split=%q continuation=%q", splitID, continuationID) + } + + for _, flow := range oc.Flows { + if flow.OriginID != splitID || flowCaseString(flow.CaseValue) != "false" { + continue + } + if flow.DestinationID == continuationID || flowPathExists(oc.Flows, flow.DestinationID, continuationID) { + return + } + } + t.Fatal("expected deferred custom error-handler ELSE path to retain CaseValue=false when rejoining") +} + +func TestBuildFlowGraph_ExplicitEmptyElseProvidesFalseContinuation(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "HasResponse"}, + HasElse: true, + ThenBody: []ast.MicroflowStatement{ + &ast.ReturnStmt{Value: &ast.VariableExpr{Name: "ErrorResponse"}}, + }, + }, + &ast.ReturnStmt{Value: &ast.LiteralExpr{Kind: ast.LiteralNull}}, + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + declaredVars: map[string]string{"HasResponse": "Boolean", "ErrorResponse": "Synthetic.Error"}, + measurer: &layoutMeasurer{}, + } + oc := fb.buildFlowGraph(body, &ast.MicroflowReturnType{Type: ast.DataType{Kind: ast.TypeEntity, EntityRef: &ast.QualifiedName{Module: "Synthetic", Name: "Error"}}}) + + var splitID, emptyEndID model.ID + for _, obj := range oc.Objects { + switch o := obj.(type) { + case *microflows.ExclusiveSplit: + splitID = o.ID + case *microflows.EndEvent: + if o.ReturnValue == "empty" { + emptyEndID = o.ID + } + } + } + if splitID == "" || emptyEndID == "" { + t.Fatalf("expected split and empty return end event, got split=%q emptyEnd=%q", splitID, emptyEndID) + } + + for _, flow := range oc.Flows { + if flow.OriginID != splitID || flowCaseString(flow.CaseValue) != "false" { + continue + } + if flowPathExists(oc.Flows, flow.DestinationID, emptyEndID) { + return + } + } + t.Fatal("expected explicit empty else to produce a false-path continuation") +} + +func flowCaseString(caseValue microflows.CaseValue) string { + switch c := caseValue.(type) { + case microflows.EnumerationCase: + return c.Value + case *microflows.EnumerationCase: + if c != nil { + return c.Value + } + case microflows.BooleanCase: + if c.Value { + return "true" + } + return "false" + case *microflows.BooleanCase: + if c != nil && c.Value { + return "true" + } + if c != nil { + return "false" + } + } + return "" +} + +func flowPathExists(flows []*microflows.SequenceFlow, startID, targetID model.ID) bool { + if startID == "" || targetID == "" { + return false + } + seen := map[model.ID]bool{} + queue := []model.ID{startID} + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + if id == targetID { + return true + } + if seen[id] { + continue + } + seen[id] = true + for _, flow := range flows { + if flow.OriginID == id && !seen[flow.DestinationID] { + queue = append(queue, flow.DestinationID) + } + } + } + return false +} diff --git a/mdl/executor/cmd_microflows_builder_workflow.go b/mdl/executor/cmd_microflows_builder_workflow.go index b25adb94..f43c47e0 100644 --- a/mdl/executor/cmd_microflows_builder_workflow.go +++ b/mdl/executor/cmd_microflows_builder_workflow.go @@ -26,11 +26,7 @@ func (fb *flowBuilder) wrapAction(action microflows.MicroflowAction, errorHandli fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - if errorHandling != nil && len(errorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, errorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, errorHandling, "") return activity.ID } diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 296a45c8..1f831611 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -1572,42 +1572,105 @@ func collectErrorHandlerStatements( ) []string { var statements []string visited := make(map[model.ID]bool) + stopID := firstReachableErrorHandlerMerge(startID, activityMap, flowsByOrigin) + splitMergeMap := findErrorHandlerSplitMergePoints(ctx, activityMap, flowsByOrigin) - var traverse func(id model.ID) - traverse = func(id model.ID) { - if id == "" || visited[id] { + var traverse func(id model.ID, boundary model.ID, indent int) + traverse = func(id model.ID, boundary model.ID, indent int) { + if id == "" || id == boundary || visited[id] { return } - obj := activityMap[id] if obj == nil { return } - - // Stop at merge points (rejoin with main flow) or end events if _, isMerge := obj.(*microflows.ExclusiveMerge); isMerge { return } - visited[id] = true - stmt := formatActivity(ctx, obj, entityNames, microflowNames) - if stmt != "" { - statements = append(statements, stmt) + indentStr := strings.Repeat(" ", indent) + if _, isSplit := obj.(*microflows.ExclusiveSplit); isSplit { + stmt := formatActivity(ctx, obj, entityNames, microflowNames) + if stmt != "" { + statements = append(statements, indentStr+stmt) + } + nestedMergeID := splitMergeMap[id] + trueFlow, falseFlow := findBranchFlows(flowsByOrigin[id]) + if trueFlow != nil { + traverse(trueFlow.DestinationID, nestedMergeID, indent+1) + } + if falseFlow != nil { + statements = append(statements, indentStr+"else") + if falseFlow.DestinationID != nestedMergeID { + traverse(falseFlow.DestinationID, nestedMergeID, indent+1) + } + } + if stmt != "" { + statements = append(statements, indentStr+"end if;") + } + if nestedMergeID != "" && nestedMergeID != boundary { + visited[nestedMergeID] = true + for _, flow := range findNormalFlows(flowsByOrigin[nestedMergeID]) { + traverse(flow.DestinationID, boundary, indent) + } + } + return } - // Follow normal (non-error) flows - flows := flowsByOrigin[id] - normalFlows := findNormalFlows(flows) - for _, flow := range normalFlows { - traverse(flow.DestinationID) + if stmt := formatActivity(ctx, obj, entityNames, microflowNames); stmt != "" { + statements = append(statements, indentStr+stmt) + } + for _, flow := range findNormalFlows(flowsByOrigin[id]) { + traverse(flow.DestinationID, boundary, indent) } } - traverse(startID) + traverse(startID, stopID, 0) return statements } +func findErrorHandlerSplitMergePoints( + ctx *ExecContext, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, +) map[model.ID]model.ID { + result := make(map[model.ID]model.ID) + for id, obj := range activityMap { + if _, isSplit := obj.(*microflows.ExclusiveSplit); !isSplit { + continue + } + if mergeID := findMergeForSplit(ctx, id, flowsByOrigin, activityMap); mergeID != "" { + result[id] = mergeID + } + } + return result +} + +func firstReachableErrorHandlerMerge( + startID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, +) model.ID { + visited := make(map[model.ID]bool) + queue := []model.ID{startID} + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + if id == "" || visited[id] { + continue + } + visited[id] = true + if _, isMerge := activityMap[id].(*microflows.ExclusiveMerge); isMerge { + return id + } + for _, flow := range findNormalFlows(flowsByOrigin[id]) { + queue = append(queue, flow.DestinationID) + } + } + return "" +} + // loopEndKeyword returns "END WHILE" for WHILE loops and "END LOOP" for FOR-EACH loops. func loopEndKeyword(loop *microflows.LoopedActivity) string { if _, isWhile := loop.LoopSource.(*microflows.WhileLoopCondition); isWhile { diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index defb8a9d..f6bd5cfb 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -715,6 +715,44 @@ func TestCollectErrorHandlerStatements_StopsAtMerge(t *testing.T) { } } +func TestCollectErrorHandlerStatements_StructuredIfEmitsEndIf(t *testing.T) { + e := newTestExecutor() + + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$latestHttpResponse != empty"}, + }, + mkID("return_error"): µflows.EndEvent{ + BaseMicroflowObject: mkObj("return_error"), + ReturnValue: "latestHttpResponse", + }, + mkID("merge"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("merge")}, + mkID("after"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("after")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'Synthetic'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "after"}}}, + }, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("split"): { + mkBranchFlow("split", "return_error", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("split", "merge", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("merge"): {mkFlow("merge", "after")}, + } + + stmts := e.collectErrorHandlerStatements(mkID("split"), activityMap, flowsByOrigin, nil, nil) + got := strings.Join(stmts, "\n") + + assertContains(t, got, "if $latestHttpResponse != empty then") + assertContains(t, got, "return $latestHttpResponse;") + assertContains(t, got, "else") + assertContains(t, got, "end if;") + if strings.Contains(got, "after") { + t.Fatalf("error handler traversal crossed the rejoin merge: %s", got) + } +} + func TestCollectErrorHandlerStatements_EmptyID(t *testing.T) { e := newTestExecutor() stmts := e.collectErrorHandlerStatements("", nil, nil, nil, nil) 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/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index e380d738..9a298f14 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -1238,7 +1238,10 @@ func buildIfStatement(ctx parser.IIfStatementContext) *ast.IfStmt { } // Last body is ELSE if there's no ELSIF or if there are more bodies than expressions if len(bodies) > len(exprs) { + stmt.HasElse = true stmt.ElseBody = buildMicroflowBody(bodies[len(bodies)-1]) + } else if ifCtx.ELSE() != nil { + stmt.HasElse = true } return stmt diff --git a/mdl/visitor/visitor_test.go b/mdl/visitor/visitor_test.go index 4152db41..3ad36b1b 100644 --- a/mdl/visitor/visitor_test.go +++ b/mdl/visitor/visitor_test.go @@ -542,6 +542,41 @@ END;` t.Log("IF/THEN/ELSE parsed correctly - actions in correct branches") } +func TestIfThenEmptyElsePreservesElsePresence(t *testing.T) { + input := `create microflow Test.EmptyElse() +returns String +begin + if $latestHttpResponse != empty then + return 'error'; + else + end if; + return empty; +end;` + + prog, errs := Build(input) + if len(errs) > 0 { + for _, err := range errs { + t.Errorf("Parse error: %v", err) + } + return + } + + stmt := prog.Statements[0].(*ast.CreateMicroflowStmt) + if len(stmt.Body) == 0 { + t.Fatal("expected microflow body") + } + ifStmt, ok := stmt.Body[0].(*ast.IfStmt) + if !ok { + t.Fatalf("expected first statement to be IfStmt, got %T", stmt.Body[0]) + } + if !ifStmt.HasElse { + t.Fatal("expected explicit empty else to be preserved") + } + if len(ifStmt.ElseBody) != 0 { + t.Fatalf("empty else body length = %d, want 0", len(ifStmt.ElseBody)) + } +} + // TestValidationFeedbackInsideIf verifies VALIDATION FEEDBACK works inside IF blocks. // Bug Report: "VALIDATION FEEDBACK Not Recognized" func TestValidationFeedbackInsideIf(t *testing.T) {