From 4e33c32b744e03fc34befad29adebe91d4ad0eec Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 2 Nov 2021 17:30:27 +0100 Subject: [PATCH 1/3] Add basic loop implementation --- src/Stage/Next/AllowNextExecuteWorkflow.php | 6 +- src/Stage/Stage.php | 66 +------ src/State/WorkflowResult.php | 21 +- src/State/WorkflowState.php | 16 ++ src/Step/Loop.php | 49 +++++ src/Step/LoopControl.php | 21 ++ src/Step/StepExecutionTrait.php | 72 +++++++ tests/LoopTest.php | 150 ++++++++++++++ tests/NestedWorkflowTest.php | 208 ++++++++++++++++++++ tests/WorkflowTest.php | 192 ------------------ tests/WorkflowTestTrait.php | 38 +++- 11 files changed, 564 insertions(+), 275 deletions(-) create mode 100644 src/Step/Loop.php create mode 100644 src/Step/LoopControl.php create mode 100644 src/Step/StepExecutionTrait.php create mode 100644 tests/LoopTest.php create mode 100644 tests/NestedWorkflowTest.php diff --git a/src/Stage/Next/AllowNextExecuteWorkflow.php b/src/Stage/Next/AllowNextExecuteWorkflow.php index 29a1007..19cd173 100644 --- a/src/Stage/Next/AllowNextExecuteWorkflow.php +++ b/src/Stage/Next/AllowNextExecuteWorkflow.php @@ -49,10 +49,10 @@ public function executeWorkflow( ); if ($exception instanceof SkipWorkflowException) { - return new WorkflowResult($workflowState->getWorkflowName(), true, $workflowState); + return $workflowState->close(true); } - $result = new WorkflowResult($workflowState->getWorkflowName(), false, $workflowState, $exception); + $result = $workflowState->close(false, $exception); if ($throwOnFailure) { throw new WorkflowException( @@ -65,6 +65,6 @@ public function executeWorkflow( return $result; } - return new WorkflowResult($workflowState->getWorkflowName(), true, $workflowState); + return $workflowState->close(true); } } diff --git a/src/Stage/Stage.php b/src/Stage/Stage.php index 0b67885..043f328 100644 --- a/src/Stage/Stage.php +++ b/src/Stage/Stage.php @@ -4,17 +4,14 @@ namespace PHPWorkflow\Stage; -use Exception; -use PHPWorkflow\Exception\WorkflowControl\FailStepException; -use PHPWorkflow\Exception\WorkflowControl\SkipStepException; -use PHPWorkflow\Exception\WorkflowControl\SkipWorkflowException; -use PHPWorkflow\State\ExecutionLog\ExecutionLog; use PHPWorkflow\State\WorkflowState; -use PHPWorkflow\Step\WorkflowStep; +use PHPWorkflow\Step\StepExecutionTrait; use PHPWorkflow\Workflow; abstract class Stage { + use StepExecutionTrait; + protected ?Stage $nextStage = null; protected Workflow $workflow; @@ -24,61 +21,4 @@ public function __construct(Workflow $workflow) } abstract protected function runStage(WorkflowState $workflowState): ?Stage; - - protected function wrapStepExecution(WorkflowStep $step, WorkflowState $workflowState): void { - try { - ($this->resolveMiddleware($step, $workflowState))(); - } catch (SkipStepException | FailStepException $exception) { - $workflowState->addExecutionLog( - $step->getDescription(), - $exception instanceof FailStepException ? ExecutionLog::STATE_FAILED : ExecutionLog::STATE_SKIPPED, - $exception->getMessage(), - ); - - if ($exception instanceof FailStepException) { - // cancel the workflow during preparation - if ($workflowState->getStage() <= WorkflowState::STAGE_PROCESS) { - throw $exception; - } - - $workflowState->getExecutionLog()->addWarning(sprintf('Step failed (%s)', get_class($step)), true); - } - - return; - } catch (Exception $exception) { - $workflowState->addExecutionLog( - $step->getDescription(), - $exception instanceof SkipWorkflowException ? ExecutionLog::STATE_SKIPPED : ExecutionLog::STATE_FAILED, - $exception->getMessage(), - ); - - // cancel the workflow during preparation - if ($workflowState->getStage() <= WorkflowState::STAGE_PROCESS) { - throw $exception; - } - - if (!($exception instanceof SkipWorkflowException)) { - $workflowState->getExecutionLog()->addWarning(sprintf('Step failed (%s)', get_class($step)), true); - } - - return; - } - - $workflowState->addExecutionLog($step->getDescription()); - } - - private function resolveMiddleware(WorkflowStep $step, WorkflowState $workflowState): callable - { - $tip = fn () => $step->run($workflowState->getWorkflowControl(), $workflowState->getWorkflowContainer()); - - foreach ($workflowState->getMiddlewares() as $middleware) { - $tip = fn () => $middleware( - $tip, - $workflowState->getWorkflowControl(), - $workflowState->getWorkflowContainer(), - ); - } - - return $tip; - } } diff --git a/src/State/WorkflowResult.php b/src/State/WorkflowResult.php index 79f22a7..38ae4a7 100644 --- a/src/State/WorkflowResult.php +++ b/src/State/WorkflowResult.php @@ -5,23 +5,26 @@ namespace PHPWorkflow\State; use Exception; +use PHPWorkflow\State\ExecutionLog\ExecutionLog; class WorkflowResult { private bool $success; - private WorkflowState $workflowState; private ?Exception $exception; private string $workflowName; + private ExecutionLog $executionLog; + private WorkflowContainer $workflowContainer; public function __construct( - string $workflowName, - bool $success, WorkflowState $workflowState, + bool $success, ?Exception $exception = null ) { - $this->workflowName = $workflowName; + $this->workflowName = $workflowState->getWorkflowName(); + $this->executionLog = $workflowState->getExecutionLog(); + $this->workflowContainer = $workflowState->getWorkflowContainer(); + $this->success = $success; - $this->workflowState = $workflowState; $this->exception = $exception; } @@ -46,7 +49,7 @@ public function success(): bool */ public function debug(): string { - return (string) $this->workflowState->getExecutionLog(); + return (string) $this->executionLog; } /** @@ -54,7 +57,7 @@ public function debug(): string */ public function hasWarnings(): bool { - return count($this->workflowState->getExecutionLog()->getWarnings()) > 0; + return count($this->executionLog->getWarnings()) > 0; } /** @@ -65,7 +68,7 @@ public function hasWarnings(): bool */ public function getWarnings(): array { - return $this->workflowState->getExecutionLog()->getWarnings(); + return $this->executionLog->getWarnings(); } /** @@ -82,6 +85,6 @@ public function getException(): ?Exception */ public function getContainer(): WorkflowContainer { - return $this->workflowState->getWorkflowContainer(); + return $this->workflowContainer; } } diff --git a/src/State/WorkflowState.php b/src/State/WorkflowState.php index 2becc79..9fbdc78 100644 --- a/src/State/WorkflowState.php +++ b/src/State/WorkflowState.php @@ -28,11 +28,27 @@ class WorkflowState private string $workflowName; private array $middlewares = []; + private static array $runningWorkflows = []; + public function __construct(WorkflowContainer $workflowContainer) { $this->executionLog = new ExecutionLog($this); $this->workflowControl = new WorkflowControl($this->executionLog); $this->workflowContainer = $workflowContainer; + + self::$runningWorkflows[] = $this; + } + + public function close(bool $success, ?Exception $exception = null): WorkflowResult + { + array_pop(self::$runningWorkflows); + + return new WorkflowResult($this, $success, $exception); + } + + public static function getRunningWorkflow(): ?self + { + return self::$runningWorkflows ? end(self::$runningWorkflows) : null; } public function getProcessException(): ?Exception diff --git a/src/Step/Loop.php b/src/Step/Loop.php new file mode 100644 index 0000000..e6a81b1 --- /dev/null +++ b/src/Step/Loop.php @@ -0,0 +1,49 @@ +loopControl = $loopControl; + } + + public function addStep(WorkflowStep $step): self + { + $this->steps[] = $step; + + return $this; + } + + public function getDescription(): string + { + return $this->loopControl->getDescription(); + } + + public function run(WorkflowControl $control, WorkflowContainer $container) + { + $iteration = 0; + + while ($this->loopControl->executeNextIteration($iteration, $control, $container)) { + foreach ($this->steps as $step) { + $this->wrapStepExecution($step, WorkflowState::getRunningWorkflow()); + } + + WorkflowState::getRunningWorkflow()->addExecutionLog(sprintf("Loop iteration #%s", ++$iteration)); + } + + $control->attachStepInfo(sprintf("Loop finished after %s iterations", $iteration)); + } +} diff --git a/src/Step/LoopControl.php b/src/Step/LoopControl.php new file mode 100644 index 0000000..490a3a3 --- /dev/null +++ b/src/Step/LoopControl.php @@ -0,0 +1,21 @@ +resolveMiddleware($step, $workflowState))(); + } catch (SkipStepException | FailStepException $exception) { + $workflowState->addExecutionLog( + $step->getDescription(), + $exception instanceof FailStepException ? ExecutionLog::STATE_FAILED : ExecutionLog::STATE_SKIPPED, + $exception->getMessage(), + ); + + if ($exception instanceof FailStepException) { + // cancel the workflow during preparation + if ($workflowState->getStage() <= WorkflowState::STAGE_PROCESS) { + throw $exception; + } + + $workflowState->getExecutionLog()->addWarning(sprintf('Step failed (%s)', get_class($step)), true); + } + + return; + } catch (Exception $exception) { + $workflowState->addExecutionLog( + $step->getDescription(), + $exception instanceof SkipWorkflowException ? ExecutionLog::STATE_SKIPPED : ExecutionLog::STATE_FAILED, + $exception->getMessage(), + ); + + // cancel the workflow during preparation + if ($workflowState->getStage() <= WorkflowState::STAGE_PROCESS) { + throw $exception; + } + + if (!($exception instanceof SkipWorkflowException)) { + $workflowState->getExecutionLog()->addWarning(sprintf('Step failed (%s)', get_class($step)), true); + } + + return; + } + + $workflowState->addExecutionLog($step->getDescription()); + } + + private function resolveMiddleware(WorkflowStep $step, WorkflowState $workflowState): callable + { + $tip = fn () => $step->run($workflowState->getWorkflowControl(), $workflowState->getWorkflowContainer()); + + foreach ($workflowState->getMiddlewares() as $middleware) { + $tip = fn () => $middleware( + $tip, + $workflowState->getWorkflowControl(), + $workflowState->getWorkflowContainer(), + ); + } + + return $tip; + } +} diff --git a/tests/LoopTest.php b/tests/LoopTest.php new file mode 100644 index 0000000..c772f5f --- /dev/null +++ b/tests/LoopTest.php @@ -0,0 +1,150 @@ +set('entries', ['a', 'b', 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop( + $this->setupLoop( + 'process-loop', + fn (WorkflowControl $control, WorkflowContainer $container) => + !empty($container->get('entries')) + ), + ))->addStep( + $this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + $entries = $container->get('entries'); + $entry = array_shift($entries); + $container->set('entries', $entries); + + $control->attachStepInfo("Process entry $entry"); + }, + ) + ) + ) + ->executeWorkflow($container); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<process( + (new Loop($this->setupLoop('process-loop', fn () => false))) + ->addStep($this->setupEmptyStep('process-test')), + ) + ->executeWorkflow(); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('entries', ['a', 'b']); + + $result = (new Workflow('test')) + ->process( + (new Loop( + $this->setupLoop( + 'process-loop', + function (WorkflowControl $control, WorkflowContainer $container): bool { + $entries = $container->get('entries'); + + if (empty($entries)) { + return false; + } + + $container->set('entry', array_shift($entries)); + $container->set('entries', $entries); + + return true; + }, + ), + ))->addStep( + $this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + $control->attachStepInfo("Process entry " . $container->get('entry')); + }, + ), + )->addStep($this->setupEmptyStep('process-test-2')), + ) + ->executeWorkflow($container); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('testdata', 'Hello World'); + + $result = (new Workflow('test')) + ->before(new NestedWorkflow( + (new Workflow('nested-test')) + ->before($this->setupStep( + 'nested-before-test', + fn (WorkflowControl $control, WorkflowContainer $container) => + $container->set('additional-data', 'from-nested'), + )) + ->process($this->setupStep( + 'nested-process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + $control->warning('nested-warning-message'); + $control->attachStepInfo($container->get('testdata')); + }, + )) + )) + ->process($this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + $control->warning('warning-message'); + $control->attachStepInfo($container->get('additional-data')); + }, + )) + ->executeWorkflow($container); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('testdata', 'Hello World'); + + $result = (new Workflow('test')) + ->before(new NestedWorkflow( + (new Workflow('nested-test')) + ->process($this->setupStep( + 'nested-process-test', + function () { + throw new InvalidArgumentException('exception-message'); + }, + )) + )) + ->process($this->setupEmptyStep('process-test')) + ->executeWorkflow($container, false); + + $this->assertFalse($result->success()); + $this->assertDebugLog( + <<set('set-parent', 'set-parent-data'); + + $nestedContainer = new class () extends WorkflowContainer { + public function getNestedData(): string + { + return 'nested-data'; + } + }; + $nestedContainer->set('set-nested', 'set-nested-data'); + + + $result = (new Workflow('test')) + ->before(new NestedWorkflow( + (new Workflow('nested-test')) + ->before($this->setupStep( + 'nested-before-test', + function (WorkflowControl $control, WorkflowContainer $container) { + $control->attachStepInfo($container->getParentData()); + $control->attachStepInfo($container->getNestedData()); + $control->attachStepInfo($container->get('set-parent')); + $control->attachStepInfo($container->get('set-nested')); + + $container->set('additional-data', 'from-nested'); + }, + )) + ->process($this->setupStep( + 'nested-process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + $control->attachStepInfo($container->get('additional-data')); + }, + )), + $nestedContainer, + )) + ->process($this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + $control->attachStepInfo($container->get('additional-data')); + $control->attachStepInfo($container->get('set-parent')); + $control->attachStepInfo(var_export($container->get('set-nested'), true)); + }, + )) + ->executeWorkflow($parentContainer); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<assertSame('from-nested', $result->getContainer()->get('additional-data')); + $this->assertSame('set-parent-data', $result->getContainer()->get('set-parent')); + $this->assertNull($result->getContainer()->get('set-nested')); + } +} diff --git a/tests/WorkflowTest.php b/tests/WorkflowTest.php index 4429654..3a88f84 100644 --- a/tests/WorkflowTest.php +++ b/tests/WorkflowTest.php @@ -11,7 +11,6 @@ use PHPWorkflow\Exception\WorkflowValidationException; use PHPWorkflow\Middleware\ProfileStep; use PHPWorkflow\State\WorkflowContainer; -use PHPWorkflow\Step\NestedWorkflow; use PHPWorkflow\Workflow; use PHPWorkflow\WorkflowControl; @@ -559,197 +558,6 @@ public function testSkipWorkflow(): void ); } - public function testNestedWorkflow(): void - { - $container = (new WorkflowContainer())->set('testdata', 'Hello World'); - - $result = (new Workflow('test')) - ->before(new NestedWorkflow( - (new Workflow('nested-test')) - ->before($this->setupStep( - 'nested-before-test', - fn (WorkflowControl $control, WorkflowContainer $container) => - $container->set('additional-data', 'from-nested'), - )) - ->process($this->setupStep( - 'nested-process-test', - function (WorkflowControl $control, WorkflowContainer $container) { - $control->warning('nested-warning-message'); - $control->attachStepInfo($container->get('testdata')); - }, - )) - )) - ->process($this->setupStep( - 'process-test', - function (WorkflowControl $control, WorkflowContainer $container) { - $control->warning('warning-message'); - $control->attachStepInfo($container->get('additional-data')); - }, - )) - ->executeWorkflow($container); - - $this->assertTrue($result->success()); - $this->assertDebugLog( - <<set('testdata', 'Hello World'); - - $result = (new Workflow('test')) - ->before(new NestedWorkflow( - (new Workflow('nested-test')) - ->process($this->setupStep( - 'nested-process-test', - function () { - throw new InvalidArgumentException('exception-message'); - }, - )) - )) - ->process($this->setupEmptyStep('process-test')) - ->executeWorkflow($container, false); - - $this->assertFalse($result->success()); - $this->assertDebugLog( - <<set('set-parent', 'set-parent-data'); - - $nestedContainer = new class () extends WorkflowContainer { - public function getNestedData(): string - { - return 'nested-data'; - } - }; - $nestedContainer->set('set-nested', 'set-nested-data'); - - - $result = (new Workflow('test')) - ->before(new NestedWorkflow( - (new Workflow('nested-test')) - ->before($this->setupStep( - 'nested-before-test', - function (WorkflowControl $control, WorkflowContainer $container) { - $control->attachStepInfo($container->getParentData()); - $control->attachStepInfo($container->getNestedData()); - $control->attachStepInfo($container->get('set-parent')); - $control->attachStepInfo($container->get('set-nested')); - - $container->set('additional-data', 'from-nested'); - }, - )) - ->process($this->setupStep( - 'nested-process-test', - function (WorkflowControl $control, WorkflowContainer $container) { - $control->attachStepInfo($container->get('additional-data')); - }, - )), - $nestedContainer, - )) - ->process($this->setupStep( - 'process-test', - function (WorkflowControl $control, WorkflowContainer $container) { - $control->attachStepInfo($container->get('additional-data')); - $control->attachStepInfo($container->get('set-parent')); - $control->attachStepInfo(var_export($container->get('set-nested'), true)); - }, - )) - ->executeWorkflow($parentContainer); - - $this->assertTrue($result->success()); - $this->assertDebugLog( - <<assertSame('from-nested', $result->getContainer()->get('additional-data')); - $this->assertSame('set-parent-data', $result->getContainer()->get('set-parent')); - $this->assertNull($result->getContainer()->get('set-nested')); - } - public function testProfilingMiddleware(): void { $result = (new Workflow('test', new ProfileStep())) diff --git a/tests/WorkflowTestTrait.php b/tests/WorkflowTestTrait.php index db4a484..369b602 100644 --- a/tests/WorkflowTestTrait.php +++ b/tests/WorkflowTestTrait.php @@ -6,6 +6,7 @@ use PHPWorkflow\State\WorkflowContainer; use PHPWorkflow\State\WorkflowResult; +use PHPWorkflow\Step\LoopControl; use PHPWorkflow\Step\WorkflowStep; use PHPWorkflow\WorkflowControl; @@ -40,16 +41,37 @@ public function run(WorkflowControl $control, WorkflowContainer $container) }; } - private function assertDebugLog(string $expected, WorkflowResult $result): void + private function setupLoop(string $description, callable $callable): LoopControl { - $this->assertSame($expected, preg_replace('#[\w\\\\]+@anonymous[^)]+#', 'anonClass', preg_replace('/[\d.]+ms/', '*', $result->debug()))); + return new class ($description, $callable) implements LoopControl { + private string $description; + private $callable; + + public function __construct(string $description, callable $callable) + { + $this->description = $description; + $this->callable = $callable; + } - return; + public function getDescription(): string + { + return $this->description; + } + public function executeNextIteration( + int $iteration, + WorkflowControl $control, + WorkflowContainer $container + ): bool { + return ($this->callable)($control, $container); + } + }; + } - $this->assertSame($expected, preg_replace( - ['/[\d.]+ms/', '/[\w\\]+@anonymous[\w\\/:\-\.\$]+/'], - ['*', 'anonClass'], - $result->debug(), - )); + private function assertDebugLog(string $expected, WorkflowResult $result): void + { + $this->assertSame( + $expected, + preg_replace('#[\w\\\\]+@anonymous[^)]+#', 'anonClass', preg_replace('/[\d.]+ms/', '*', $result->debug())), + ); } } From 11e77a9d303764faba875657ba8c14bc2d97430a Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 4 Nov 2021 15:01:33 +0100 Subject: [PATCH 2/3] Add loops to readme, add additional loop test cases --- README.md | 69 ++++++++++ tests/LoopTest.php | 324 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 369 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index f5090da..1802672 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Bonus: you will get an execution log for each executed workflow - if you want to * [Stages](#Stages) * [Workflow control](#Workflow-control) * [Nested workflows](#Nested-workflows) +* [Loops](#Loops) * [Error handling, logging and debugging](#Error-handling-logging-and-debugging) ## Installation @@ -327,6 +328,74 @@ The nested workflow will gain access to a merged **WorkflowContainer** which pro If you add additional data to the merged container the data will be present in your main workflow container after the nested workflow execution has been completed. For example your implementations of the steps used in the nested workflow will have access to the keys `nested-data` and `parent-data`. +## Loops + +If you handle multiple entities in your workflows at once you may need loops. +An approach would be to set up a single step which contains the loop and all logic which is required to be executed in a loop. +But if there are multiple steps required to be executed in the loop you may want to split the step into various steps. +By using the `Loop` class you can execute multiple steps in a loop. +For example let's assume our `AddSongToPlaylist` becomes a `AddSongsToPlaylist` workflow which can add multiple songs at once: + +```php +$workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist')) + ->validate(new CurrentUserIsAllowedToEditPlaylistValidator()) + ->process( + (new \PHPWorkflow\Step\Loop(new SongLoop())) + ->addStep(new AddSongToPlaylist()) + ->addStep(new ClearSongCache()) + ) + ->onSuccess(new NotifySubscribers()) + ->executeWorkflow($parentWorkflowContainer); +``` + +Our process step now implements a loop controlled by the `SongLoop` class. +The loop contains our two steps `AddSongToPlaylist` and `ClearSongCache`. +The implementation of the `SongLoop` class must implement the `PHPWorkflow\Step\LoopControl` interface. +Let's have a look at an example implementation: + +```php +class SongLoop implements \PHPWorkflow\Step\LoopControl { + /** + * As well as each step also each loop must provide a description. + */ + public function getDescription(): string + { + return 'Loop over all provided songs'; + } + + /** + * This method will be called before each loop run. + * $iteration will contain the current iteration (0 on first run etc) + * You have access to the WorkflowControl and the WorkflowContainer. + * If the method returns true the next iteration will be executed. + * Otherwise the loop is completed. + */ + public function executeNextIteration( + int $iteration, + \PHPWorkflow\WorkflowControl $control, + \PHPWorkflow\State\WorkflowContainer $container + ): bool { + $songs = $container->get('songs'); + + // no songs in container - end the loop + if (empty($songs)) { + return false; + } + + // add the current song to the container so the steps + // of the loop can access the entry + $container->set('currentSong', array_shift($songs)); + + // update the songs entry to handle the songs step by step + $container->set('songs', $songs); + + return true; + } +} +``` + +A loop step may contain a nested workflow if you need more complex steps. + ## Error handling, logging and debugging The **executeWorkflow** method returns an **WorkflowResult** object which provides the following methods to determine the result of the workflow: diff --git a/tests/LoopTest.php b/tests/LoopTest.php index c772f5f..6c39128 100644 --- a/tests/LoopTest.php +++ b/tests/LoopTest.php @@ -7,6 +7,9 @@ use PHPUnit\Framework\TestCase; use PHPWorkflow\State\WorkflowContainer; use PHPWorkflow\Step\Loop; +use PHPWorkflow\Step\LoopControl; +use PHPWorkflow\Step\NestedWorkflow; +use PHPWorkflow\Step\WorkflowStep; use PHPWorkflow\Workflow; use PHPWorkflow\WorkflowControl; @@ -97,30 +100,9 @@ public function testMultipleStepsInLoop(): void $result = (new Workflow('test')) ->process( - (new Loop( - $this->setupLoop( - 'process-loop', - function (WorkflowControl $control, WorkflowContainer $container): bool { - $entries = $container->get('entries'); - - if (empty($entries)) { - return false; - } - - $container->set('entry', array_shift($entries)); - $container->set('entries', $entries); - - return true; - }, - ), - ))->addStep( - $this->setupStep( - 'process-test', - function (WorkflowControl $control, WorkflowContainer $container) { - $control->attachStepInfo("Process entry " . $container->get('entry')); - }, - ), - )->addStep($this->setupEmptyStep('process-test-2')), + (new Loop($this->entryLoopControl())) + ->addStep($this->processEntry()) + ->addStep($this->setupEmptyStep('process-test-2')), ) ->executeWorkflow($container); @@ -147,4 +129,298 @@ function (WorkflowControl $control, WorkflowContainer $container) { $result, ); } + + public function testNestedWorkflowInLoop(): void + { + $container = (new WorkflowContainer())->set('entries', ['a', 'b']); + + $result = (new Workflow('test')) + ->process( + (new Loop($this->entryLoopControl())) + ->addStep( + new NestedWorkflow( + (new Workflow('nested-workflow'))->process($this->processEntry()), + ), + ), + ) + ->executeWorkflow($container); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('entries', ['a', null, 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop($this->entryLoopControl())) + ->addStep( + $this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + if (!$container->get('entry')) { + $control->skipStep('no entry'); + } + }, + ) + ) + ) + ->executeWorkflow($container); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<process( + (new Loop( + $this->setupLoop( + 'process-loop', + fn (WorkflowControl $control) => $control->skipStep('skip reason'), + ) + )) + ->addStep($this->processEntry()) + ) + ->executeWorkflow(); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('entries', ['a', null, 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop($this->entryLoopControl())) + ->addStep( + $this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + if (!$container->get('entry')) { + $control->skipWorkflow('no entry'); + } + }, + ) + ) + ) + ->executeWorkflow($container); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<process( + (new Loop( + $this->setupLoop( + 'process-loop', + fn (WorkflowControl $control) => $control->skipWorkflow('skip reason'), + ) + )) + ->addStep($this->processEntry()) + ) + ->executeWorkflow(); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('entries', ['a', null, 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop($this->entryLoopControl())) + ->addStep( + $this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + if (!$container->get('entry')) { + $control->attachStepInfo( + sprintf('got value %s', var_export($container->get('entry'), true)) + ); + + $control->failStep('no entry'); + } + }, + ) + ) + ) + ->executeWorkflow($container, false); + + $this->assertFalse($result->success()); + $this->assertNotNull($result->getException()); + $this->assertDebugLog( + <<set('entries', ['a', null, 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop( + $this->setupLoop( + 'process-loop', + fn (WorkflowControl $control) => $control->failStep('fail reason'), + ) + )) + ->addStep($this->processEntry()) + ) + ->executeWorkflow($container, false); + + $this->assertFalse($result->success()); + $this->assertNotNull($result->getException()); + $this->assertDebugLog( + <<setupLoop( + 'process-loop', + function (WorkflowControl $control, WorkflowContainer $container): bool { + $entries = $container->get('entries'); + + if (empty($entries)) { + return false; + } + + $container->set('entry', array_shift($entries)); + $container->set('entries', $entries); + + return true; + }, + ); + } + + private function processEntry(): WorkflowStep + { + return $this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + $control->attachStepInfo("Process entry " . $container->get('entry')); + }, + ); + } } From f1e4a41aacd066325e2f85467b28fed9a781d005 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 11 Nov 2021 17:02:15 +0100 Subject: [PATCH 3/3] Add loop control via continue and break Add loop option $continueOnError Add additional test cases --- README.md | 25 ++ .../WorkflowControl/BreakException.php | 9 + .../WorkflowControl/ContinueException.php | 9 + .../WorkflowControl/LoopControlException.php | 9 + src/State/WorkflowState.php | 19 +- src/Step/Loop.php | 62 ++- src/Step/StepExecutionTrait.php | 6 + src/WorkflowControl.php | 44 +- tests/LoopTest.php | 414 +++++++++++++++++- tests/WorkflowTest.php | 43 +- tests/WorkflowTestTrait.php | 24 +- 11 files changed, 610 insertions(+), 54 deletions(-) create mode 100644 src/Exception/WorkflowControl/BreakException.php create mode 100644 src/Exception/WorkflowControl/ContinueException.php create mode 100644 src/Exception/WorkflowControl/LoopControlException.php diff --git a/README.md b/README.md index 1802672..99b8158 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Bonus: you will get an execution log for each executed workflow - if you want to * [Nested workflows](#Nested-workflows) * [Loops](#Loops) * [Error handling, logging and debugging](#Error-handling-logging-and-debugging) +* [Tests](#Tests) ## Installation @@ -285,6 +286,14 @@ public function failWorkflow(string $reason): void; // it's handled like a skipped step. public function skipWorkflow(string $reason): void; +// Useful when using loops to cancel the current iteration (all upcoming steps). +// If used outside a loop, it behaves like skipStep. +public function continue(string $reason): void; + +// Useful when using loops to break the loop (all upcoming steps and iterations). +// If used outside a loop, it behaves like skipStep. +public function break(string $reason): void; + // Attach any additional debug info to your current step. // The infos will be shown in the workflow debug log. public function attachStepInfo(string $info): void @@ -396,6 +405,14 @@ class SongLoop implements \PHPWorkflow\Step\LoopControl { A loop step may contain a nested workflow if you need more complex steps. +To control the flow of the loop from the steps you can use the `continue` and `break` methods on the `WorkflowControl` object. + +By default, a loop is stopped if a step fails. +You can set the second parameter of the `Loop` class (`$continueOnError`) to true to continue the execution with the next iteration. +If you enable this option a failed step will not result in a failed workflow. +Instead, a warning will be added to the process log. +Calls to `failWorkflow` and `skipWorkflow` will always cancel the loop (and consequently the workflow) independent of the option. + ## Error handling, logging and debugging The **executeWorkflow** method returns an **WorkflowResult** object which provides the following methods to determine the result of the workflow: @@ -481,3 +498,11 @@ Summary: In this example the **AcceptOpenSuggestionForSong** step found a matching open suggestion and successfully accepted the suggestion. Consequently, the further workflow execution is skipped. + + +## Tests ## + +The library is tested via [PHPUnit](https://phpunit.de/). + +After installing the dependencies of the library via `composer update` you can execute the tests with `./vendor/bin/phpunit` (Linux) or `vendor\bin\phpunit.bat` (Windows). +The test names are optimized for the usage of the `--testdox` output. \ No newline at end of file diff --git a/src/Exception/WorkflowControl/BreakException.php b/src/Exception/WorkflowControl/BreakException.php new file mode 100644 index 0000000..c9a69d8 --- /dev/null +++ b/src/Exception/WorkflowControl/BreakException.php @@ -0,0 +1,9 @@ +executionLog = new ExecutionLog($this); - $this->workflowControl = new WorkflowControl($this->executionLog); + $this->workflowControl = new WorkflowControl($this); $this->workflowContainer = $workflowContainer; self::$runningWorkflows[] = $this; @@ -113,4 +116,14 @@ public function getMiddlewares(): array { return $this->middlewares; } + + public function isInLoop(): bool + { + return $this->inLoop > 0; + } + + public function setInLoop(bool $inLoop): void + { + $this->inLoop += $inLoop ? 1 : -1; + } } diff --git a/src/Step/Loop.php b/src/Step/Loop.php index e6a81b1..a42c41b 100644 --- a/src/Step/Loop.php +++ b/src/Step/Loop.php @@ -4,6 +4,12 @@ namespace PHPWorkflow\Step; +use Exception; +use PHPWorkflow\Exception\WorkflowControl\BreakException; +use PHPWorkflow\Exception\WorkflowControl\ContinueException; +use PHPWorkflow\Exception\WorkflowControl\FailWorkflowException; +use PHPWorkflow\Exception\WorkflowControl\SkipWorkflowException; +use PHPWorkflow\State\ExecutionLog\ExecutionLog; use PHPWorkflow\State\WorkflowContainer; use PHPWorkflow\State\WorkflowState; use PHPWorkflow\WorkflowControl; @@ -14,10 +20,12 @@ class Loop implements WorkflowStep protected array $steps = []; protected LoopControl $loopControl; + private bool $continueOnError; - public function __construct(LoopControl $loopControl) + public function __construct(LoopControl $loopControl, bool $continueOnError = false) { $this->loopControl = $loopControl; + $this->continueOnError = $continueOnError; } public function addStep(WorkflowStep $step): self @@ -36,14 +44,56 @@ public function run(WorkflowControl $control, WorkflowContainer $container) { $iteration = 0; - while ($this->loopControl->executeNextIteration($iteration, $control, $container)) { - foreach ($this->steps as $step) { - $this->wrapStepExecution($step, WorkflowState::getRunningWorkflow()); + WorkflowState::getRunningWorkflow()->setInLoop(true); + while (true) { + $loopState = ExecutionLog::STATE_SUCCESS; + $reason = null; + + try { + if (!$this->loopControl->executeNextIteration($iteration, $control, $container)) { + break; + } + + foreach ($this->steps as $step) { + $this->wrapStepExecution($step, WorkflowState::getRunningWorkflow()); + } + + $iteration++; + } catch (Exception $exception) { + $iteration++; + $reason = $exception->getMessage(); + + if ($exception instanceof ContinueException) { + $loopState = ExecutionLog::STATE_SKIPPED; + } else { + if ($exception instanceof BreakException) { + WorkflowState::getRunningWorkflow()->addExecutionLog( + "Loop iteration #$iteration", + ExecutionLog::STATE_SKIPPED, + $reason, + ); + + $control->attachStepInfo("Loop break in iteration #$iteration"); + + break; + } + + if (!$this->continueOnError || + $exception instanceof SkipWorkflowException || + $exception instanceof FailWorkflowException + ) { + throw $exception; + } + + $control->warning("Loop iteration #$iteration failed. Continued execution."); + $loopState = ExecutionLog::STATE_FAILED; + } } - WorkflowState::getRunningWorkflow()->addExecutionLog(sprintf("Loop iteration #%s", ++$iteration)); + WorkflowState::getRunningWorkflow()->addExecutionLog("Loop iteration #$iteration", $loopState, $reason); } + WorkflowState::getRunningWorkflow()->setInLoop(false); - $control->attachStepInfo(sprintf("Loop finished after %s iterations", $iteration)); + $control->attachStepInfo("Loop finished after $iteration iteration" . ($iteration === 1 ? '' : 's')); } } diff --git a/src/Step/StepExecutionTrait.php b/src/Step/StepExecutionTrait.php index 5ac5fe3..0c4087c 100644 --- a/src/Step/StepExecutionTrait.php +++ b/src/Step/StepExecutionTrait.php @@ -6,6 +6,7 @@ use Exception; use PHPWorkflow\Exception\WorkflowControl\FailStepException; +use PHPWorkflow\Exception\WorkflowControl\LoopControlException; use PHPWorkflow\Exception\WorkflowControl\SkipStepException; use PHPWorkflow\Exception\WorkflowControl\SkipWorkflowException; use PHPWorkflow\State\ExecutionLog\ExecutionLog; @@ -32,6 +33,11 @@ protected function wrapStepExecution(WorkflowStep $step, WorkflowState $workflow $workflowState->getExecutionLog()->addWarning(sprintf('Step failed (%s)', get_class($step)), true); } + // bubble up the exception so the loop control can handle the exception + if ($exception instanceof LoopControlException) { + throw $exception; + } + return; } catch (Exception $exception) { $workflowState->addExecutionLog( diff --git a/src/WorkflowControl.php b/src/WorkflowControl.php index d9ba7a4..0a0a99b 100644 --- a/src/WorkflowControl.php +++ b/src/WorkflowControl.php @@ -5,19 +5,21 @@ namespace PHPWorkflow; use Exception; +use PHPWorkflow\Exception\WorkflowControl\BreakException; +use PHPWorkflow\Exception\WorkflowControl\ContinueException; use PHPWorkflow\Exception\WorkflowControl\FailStepException; use PHPWorkflow\Exception\WorkflowControl\FailWorkflowException; use PHPWorkflow\Exception\WorkflowControl\SkipStepException; use PHPWorkflow\Exception\WorkflowControl\SkipWorkflowException; -use PHPWorkflow\State\ExecutionLog\ExecutionLog; +use PHPWorkflow\State\WorkflowState; class WorkflowControl { - private ExecutionLog $executionLog; + private WorkflowState $workflowState; - public function __construct(ExecutionLog $executionLog) + public function __construct(WorkflowState $workflowState) { - $this->executionLog = $executionLog; + $this->workflowState = $workflowState; } /** @@ -65,6 +67,36 @@ public function skipWorkflow(string $reason): void throw new SkipWorkflowException($reason); } + /** + * If in a loop the current iteration is cancelled and the next iteration is started. If the step is not part of a + * loop the step is skipped. + * + * @param string $reason + */ + public function continue(string $reason): void + { + if ($this->workflowState->isInLoop()) { + throw new ContinueException($reason); + } + + $this->skipStep($reason); + } + + /** + * If in a loop the loop is cancelled and the next step after the loop is executed. If the step is not part of a + * loop the step is skipped. + * + * @param string $reason + */ + public function break(string $reason): void + { + if ($this->workflowState->isInLoop()) { + throw new BreakException($reason); + } + + $this->skipStep($reason); + } + /** * Attach any additional debug info to your current step. * Info will be shown in the workflow debug log. @@ -73,7 +105,7 @@ public function skipWorkflow(string $reason): void */ public function attachStepInfo(string $info): void { - $this->executionLog->attachStepInfo($info); + $this->workflowState->getExecutionLog()->attachStepInfo($info); } /** @@ -97,6 +129,6 @@ public function warning(string $message, ?Exception $exception = null): void ); } - $this->executionLog->addWarning($message); + $this->workflowState->getExecutionLog()->addWarning($message); } } diff --git a/tests/LoopTest.php b/tests/LoopTest.php index 6c39128..1a96a26 100644 --- a/tests/LoopTest.php +++ b/tests/LoopTest.php @@ -4,6 +4,7 @@ namespace PHPWorkflow\Tests; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; use PHPWorkflow\State\WorkflowContainer; use PHPWorkflow\Step\Loop; @@ -181,6 +182,209 @@ public function testNestedWorkflowInLoop(): void ); } + public function testContinue(): void + { + $container = (new WorkflowContainer())->set('entries', ['a', 'b', 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop($this->entryLoopControl())) + ->addStep( + $this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + if ($container->get('entry') === 'b') { + $control->continue('Skip reason'); + } + + $control->attachStepInfo('Process entry'); + }, + ) + ) + ->addStep($this->setupEmptyStep('Post-Process entry')) + ) + ->executeWorkflow($container); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('entries', ['a', 'b', 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop( + $this->setupLoop( + 'process-loop', + function (WorkflowControl $control, WorkflowContainer $container) { + $entries = $container->get('entries'); + + if (empty($entries)) { + return false; + } + + $entry = array_shift($entries); + $container->set('entries', $entries); + + if ($entry === 'b') { + $control->continue('Skip reason'); + } + + return true; + }, + ) + )) + ->addStep($this->setupEmptyStep('process 1')) + ->addStep($this->setupEmptyStep('process 2')) + ) + ->executeWorkflow($container); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('entries', ['a', 'b', 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop($this->entryLoopControl())) + ->addStep( + $this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) { + if ($container->get('entry') === 'b') { + $control->break('break reason'); + } + + $control->attachStepInfo('Process entry'); + }, + ) + ) + ->addStep($this->setupEmptyStep('Post-Process entry')) + ) + ->executeWorkflow($container); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('entries', ['a', 'b', 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop( + $this->setupLoop( + 'process-loop', + function (WorkflowControl $control, WorkflowContainer $container) { + $entries = $container->get('entries'); + + if (empty($entries)) { + return false; + } + + $entry = array_shift($entries); + $container->set('entries', $entries); + + if ($entry === 'b') { + $control->break('Break reason'); + } + + return true; + }, + ) + )) + ->addStep($this->setupEmptyStep('process 1')) + ->addStep($this->setupEmptyStep('process 2')) + ) + ->executeWorkflow($container); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('entries', ['a', null, 'c']); @@ -252,13 +456,17 @@ public function testSkipStepInLoopControl(): void ); } - public function testSkipWorkflowInLoop(): void + /** + * @dataProvider continueOnErrorDataProvider test with both settings as skipWorkflow must work independently of the + * selected error handling mode + */ + public function testSkipWorkflowInLoop(bool $continueOnError): void { $container = (new WorkflowContainer())->set('entries', ['a', null, 'c']); $result = (new Workflow('test')) ->process( - (new Loop($this->entryLoopControl())) + (new Loop($this->entryLoopControl(), $continueOnError)) ->addStep( $this->setupStep( 'process-test', @@ -290,7 +498,11 @@ function (WorkflowControl $control, WorkflowContainer $container) { ); } - public function testSkipWorkflowInLoopControl(): void + /** + * @dataProvider continueOnErrorDataProvider test with both settings as skipWorkflow must work independently of the + * selected error handling mode + */ + public function testSkipWorkflowInLoopControl(bool $continueOnError): void { $result = (new Workflow('test')) ->process( @@ -298,7 +510,8 @@ public function testSkipWorkflowInLoopControl(): void $this->setupLoop( 'process-loop', fn (WorkflowControl $control) => $control->skipWorkflow('skip reason'), - ) + ), + $continueOnError, )) ->addStep($this->processEntry()) ) @@ -319,7 +532,10 @@ public function testSkipWorkflowInLoopControl(): void ); } - public function testFailInLoop(): void + /** + * @dataProvider failDataProvider + */ + public function testFailInLoop(callable $failingStep): void { $container = (new WorkflowContainer())->set('entries', ['a', null, 'c']); @@ -329,13 +545,13 @@ public function testFailInLoop(): void ->addStep( $this->setupStep( 'process-test', - function (WorkflowControl $control, WorkflowContainer $container) { + function (WorkflowControl $control, WorkflowContainer $container) use ($failingStep) { if (!$container->get('entry')) { $control->attachStepInfo( sprintf('got value %s', var_export($container->get('entry'), true)) ); - $control->failStep('no entry'); + $failingStep($control); } }, ) @@ -351,19 +567,138 @@ function (WorkflowControl $control, WorkflowContainer $container) { Process: - process-test: ok - Loop iteration #1: ok - - process-test: failed (no entry) + - process-test: failed (Fail Message) - got value NULL - - process-loop: failed (no entry) + - process-loop: failed (Fail Message) + + Summary: + - Workflow execution: failed (Fail Message) + - Execution time: * + DEBUG, + $result, + ); + } + + /** + * @dataProvider failStepDataProvider + */ + public function testFailInLoopWithContinueOnError(callable $failingStep): void + { + $container = (new WorkflowContainer())->set('entries', ['a', null, 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop($this->entryLoopControl(), true)) + ->addStep( + $this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container) use ($failingStep) { + if (!$container->get('entry')) { + $failingStep($control); + } + }, + ) + ) + ) + ->executeWorkflow($container, false); + + $this->assertTrue($result->success()); + $this->assertDebugLog( + <<set('entries', ['a', null, 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop($this->entryLoopControl(), true)) + ->addStep( + $this->setupStep( + 'process-test', + function (WorkflowControl $control, WorkflowContainer $container): void { + if (!$container->get('entry')) { + $control->failWorkflow('Fail Message'); + } + }, + ) + ) + ) + ->executeWorkflow($container, false); + + $this->assertFalse($result->success()); + $this->assertNotNull($result->getException()); + $this->assertDebugLog( + <<set('entries', ['a', null, 'c']); + + $result = (new Workflow('test')) + ->process( + (new Loop($this->setupLoop('process-loop', $failingStep))) + ->addStep($this->processEntry()) + ) + ->executeWorkflow($container, false); + + $this->assertFalse($result->success()); + $this->assertNotNull($result->getException()); + $this->assertDebugLog( + <<set('entries', ['a', null, 'c']); @@ -372,29 +707,74 @@ public function testFailInLoopControl(): void (new Loop( $this->setupLoop( 'process-loop', - fn (WorkflowControl $control) => $control->failStep('fail reason'), - ) + function (WorkflowControl $control, WorkflowContainer $container) use ($failingStep): bool { + $entries = $container->get('entries'); + + if (empty($entries)) { + return false; + } + + $container->set('entry', array_shift($entries)); + $container->set('entries', $entries); + + if (!$container->get('entry')) { + $failingStep($control); + } + + return true; + }, + ), + true, )) ->addStep($this->processEntry()) ) ->executeWorkflow($container, false); - $this->assertFalse($result->success()); - $this->assertNotNull($result->getException()); + $this->assertTrue($result->success()); + $this->assertDebugLog( << [false], + "Continue on failure" => [true], + ]; + } + + + public function failStepDataProvider(): array + { + return [ + 'By Exception' => [function (): void { + throw new InvalidArgumentException('Fail Message'); + }], + 'By failing step' => [fn (WorkflowControl $control) => $control->failStep('Fail Message')], + ]; + } + private function entryLoopControl(): LoopControl { return $this->setupLoop( diff --git a/tests/WorkflowTest.php b/tests/WorkflowTest.php index 3a88f84..dbf9ae7 100644 --- a/tests/WorkflowTest.php +++ b/tests/WorkflowTest.php @@ -156,7 +156,7 @@ public function testFailingWorkflowThrowsAnExceptionByDefault(): void } /** - * @dataProvider failingStepDataProvider + * @dataProvider failDataProvider */ public function testFailingPreparationCancelsWorkflow(callable $failingStep): void { @@ -185,7 +185,7 @@ public function testFailingPreparationCancelsWorkflow(callable $failingStep): vo } /** - * @dataProvider failingStepDataProvider + * @dataProvider failDataProvider */ public function testFailingHardValidationCancelsWorkflow(callable $failingStep): void { @@ -214,7 +214,7 @@ public function testFailingHardValidationCancelsWorkflow(callable $failingStep): } /** - * @dataProvider failingStepDataProvider + * @dataProvider failDataProvider */ public function testFailingSoftValidationsAreCollected(callable $failingStep): void { @@ -255,7 +255,7 @@ public function testFailingSoftValidationsAreCollected(callable $failingStep): v /** - * @dataProvider failingStepDataProvider + * @dataProvider failDataProvider */ public function testFailingBeforeCancelsWorkflow(callable $failingStep): void { @@ -284,7 +284,7 @@ public function testFailingBeforeCancelsWorkflow(callable $failingStep): void } /** - * @dataProvider failingStepDataProvider + * @dataProvider failDataProvider */ public function testFailingProcess(callable $failingStep): void { @@ -362,7 +362,7 @@ public function testSuccessfulProcess(): void } /** - * @dataProvider failingStepDataProvider + * @dataProvider failDataProvider */ public function testFailuresAfterProcessDontAffectOtherStepsForFailedProcess(callable $failingStep): void { @@ -400,7 +400,7 @@ public function testFailuresAfterProcessDontAffectOtherStepsForFailedProcess(cal } /** - * @dataProvider failingStepDataProvider + * @dataProvider failDataProvider */ public function testFailuresAfterProcessDontAffectOtherStepsForSuccessfulProcess(callable $failingStep): void { @@ -437,17 +437,6 @@ public function testFailuresAfterProcessDontAffectOtherStepsForSuccessfulProcess ); } - public function failingStepDataProvider(): array - { - return [ - 'By Exception' => [function (WorkflowControl $control) { - throw new InvalidArgumentException('Fail Message'); - }], - 'By failing step' => [fn (WorkflowControl $control) => $control->failStep('Fail Message')], - 'By failing workflow' => [fn (WorkflowControl $control) => $control->failWorkflow('Fail Message')], - ]; - } - public function testInjectingWorkflowContainer(): void { $container = (new WorkflowContainer())->set('testdata', 'Hello World'); @@ -491,17 +480,20 @@ public function testInjectingWorkflowContainer(): void ); } - public function testSkipStep(): void + /** + * @dataProvider skipFunctionDataProvider + */ + public function testSkipStep(string $skipFunction): void { $result = (new Workflow('test')) ->validate($this->setupStep( 'validate-test1', - fn (WorkflowControl $control) => $control->skipStep('skip-reason 1'), + fn (WorkflowControl $control) => $control->$skipFunction('skip-reason 1'), ), true) ->validate($this->setupEmptyStep('validate-test2')) ->validate($this->setupStep( 'validate-test3', - fn (WorkflowControl $control) => $control->skipStep('skip-reason 2'), + fn (WorkflowControl $control) => $control->$skipFunction('skip-reason 2'), )) ->process($this->setupEmptyStep('process-test')) ->executeWorkflow(null, false); @@ -525,6 +517,15 @@ public function testSkipStep(): void ); } + public function skipFunctionDataProvider(): array + { + return [ + 'skipStep' => ['skipStep'], + 'continue' => ['continue'], + 'break' => ['break'], + ]; + } + public function testSkipWorkflow(): void { $result = (new Workflow('test')) diff --git a/tests/WorkflowTestTrait.php b/tests/WorkflowTestTrait.php index 369b602..72f5df7 100644 --- a/tests/WorkflowTestTrait.php +++ b/tests/WorkflowTestTrait.php @@ -4,6 +4,7 @@ namespace PHPWorkflow\Tests; +use InvalidArgumentException; use PHPWorkflow\State\WorkflowContainer; use PHPWorkflow\State\WorkflowResult; use PHPWorkflow\Step\LoopControl; @@ -67,11 +68,32 @@ public function executeNextIteration( }; } + public function failDataProvider(): array + { + return [ + 'By Exception' => [function () { + throw new InvalidArgumentException('Fail Message'); + }], + 'By failing step' => [fn (WorkflowControl $control) => $control->failStep('Fail Message')], + 'By failing workflow' => [fn (WorkflowControl $control) => $control->failWorkflow('Fail Message')], + ]; + } + private function assertDebugLog(string $expected, WorkflowResult $result): void { $this->assertSame( $expected, - preg_replace('#[\w\\\\]+@anonymous[^)]+#', 'anonClass', preg_replace('/[\d.]+ms/', '*', $result->debug())), + preg_replace( + [ + '#[\w\\\\]+@anonymous[^)]+#', + '/[\d.]+ms/', + ], + [ + 'anonClass', + '*', + ], + $result->debug(), + ), ); } }