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/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index b611216d..112fdc51 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -249,6 +249,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 57f0f7d8..5ad30d67 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 d7013287..1c3b99f2 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 @@ -54,6 +56,20 @@ type flowBuilder struct { nanoflowsCache []*microflows.Nanoflow nanoflowsCacheLoaded bool manualLoopBackTarget model.ID + // 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. @@ -71,6 +87,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 d3129851..2a38a9ae 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -131,12 +131,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 } @@ -167,12 +162,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 } @@ -201,12 +191,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 } @@ -468,12 +453,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 9e53aeae..d2a1acf3 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -225,6 +225,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 eb9cb6a9..d6604fcb 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -158,12 +158,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 } @@ -218,12 +213,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 } @@ -325,12 +315,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 } @@ -427,12 +412,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 } @@ -478,12 +458,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 } @@ -978,12 +953,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 } @@ -1187,12 +1157,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 } @@ -1259,11 +1224,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 } @@ -1295,11 +1256,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 } @@ -1333,11 +1290,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 e47ca4f7..cd346f46 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 } @@ -160,6 +162,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) @@ -206,6 +210,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { applyUserAnchors(flow, prevElseAnchor, nil) fb.flows = append(fb.flows, flow) } + } else if elseReturns && needMerge { + fb.addPendingErrorHandlerFlowTo(mergeID) } } else { // IF WITHOUT ELSE: FALSE path horizontal (happy path), TRUE path below @@ -322,6 +328,71 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { return splitID } +func bodyHasContinuingCustomErrorHandler(stmts []ast.MicroflowStatement) bool { + for _, stmt := range stmts { + switch s := stmt.(type) { + case *ast.CallMicroflowStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.CallJavaActionStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.RestCallStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.ImportFromMappingStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.CreateObjectStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.MfCommitStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.DeleteObjectStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + 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 { diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 9077fb6a..a2e37c55 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -29,15 +29,462 @@ func convertErrorHandlingType(eh *ast.ErrorHandlingClause) microflows.ErrorHandl } } +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 { + errorY := fb.posY + VerticalSpacing + mergeID := fb.addErrorHandlerFlow(activityID, activityX, eh.Body) + fb.handleErrorHandlerMergeWithSkip(mergeID, activityID, errorY, 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 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)...) + for _, param := range s.Template { + refs = append(refs, exprVarRefs(param.Value)...) + } + 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: + for _, arg := range s.Arguments { + refs = append(refs, exprVarRefs(arg.Value)...) + } + case *ast.CallJavaActionStmt: + for _, arg := range s.Arguments { + refs = append(refs, exprVarRefs(arg.Value)...) + } + case *ast.RestCallStmt: + refs = append(refs, exprVarRefs(s.URL)...) + for _, param := range s.URLParams { + refs = append(refs, exprVarRefs(param.Value)...) + } + for _, header := range s.Headers { + refs = append(refs, exprVarRefs(header.Value)...) + } + if s.Body != nil { + refs = append(refs, exprVarRefs(s.Body.Template)...) + for _, param := range s.Body.TemplateParams { + refs = append(refs, exprVarRefs(param.Value)...) + } + if s.Body.SourceVariable != "" { + refs = append(refs, s.Body.SourceVariable) + } + } + refs = append(refs, exprVarRefs(s.Timeout)...) + case *ast.MfCommitStmt: + refs = append(refs, s.Variable) + case *ast.DeleteObjectStmt: + refs = append(refs, s.Variable) + 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, } } @@ -46,9 +493,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 @@ -61,6 +508,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, @@ -70,6 +518,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 != "" { @@ -82,9 +532,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 } } } @@ -96,32 +552,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 == "" { + fb.handleErrorHandlerMergeWithSkip(errorHandlerTail{id: lastErrID}, activityID, errorY, "") +} + +func (fb *flowBuilder) handleErrorHandlerMergeWithSkip(tail errorHandlerTail, activityID model.ID, errorY int, 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)) + _ = errorY + 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 f2c421c7..b661fe1d 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 2829e092..e84aa82b 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" ) @@ -247,3 +249,572 @@ func TestBuildFlowGraph_ManualWhileTrueTerminalDoesNotAddFallthroughEnd(t *testi } } } + +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 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 d0505d84..3f02fed1 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -1380,42 +1380,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/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index ca788eea..8ef2e452 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -1163,7 +1163,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 4012678c..781c21bd 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) {