Skip to content

Commit 0ce615b

Browse files
authored
Merge pull request #1 from wol-soft/loops
Add loops
2 parents 75051b9 + f1e4a41 commit 0ce615b

16 files changed

Lines changed: 1495 additions & 305 deletions

README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ Bonus: you will get an execution log for each executed workflow - if you want to
2121
* [Stages](#Stages)
2222
* [Workflow control](#Workflow-control)
2323
* [Nested workflows](#Nested-workflows)
24+
* [Loops](#Loops)
2425
* [Error handling, logging and debugging](#Error-handling-logging-and-debugging)
26+
* [Tests](#Tests)
2527

2628
## Installation
2729

@@ -284,6 +286,14 @@ public function failWorkflow(string $reason): void;
284286
// it's handled like a skipped step.
285287
public function skipWorkflow(string $reason): void;
286288

289+
// Useful when using loops to cancel the current iteration (all upcoming steps).
290+
// If used outside a loop, it behaves like skipStep.
291+
public function continue(string $reason): void;
292+
293+
// Useful when using loops to break the loop (all upcoming steps and iterations).
294+
// If used outside a loop, it behaves like skipStep.
295+
public function break(string $reason): void;
296+
287297
// Attach any additional debug info to your current step.
288298
// The infos will be shown in the workflow debug log.
289299
public function attachStepInfo(string $info): void
@@ -327,6 +337,82 @@ The nested workflow will gain access to a merged **WorkflowContainer** which pro
327337
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.
328338
For example your implementations of the steps used in the nested workflow will have access to the keys `nested-data` and `parent-data`.
329339

340+
## Loops
341+
342+
If you handle multiple entities in your workflows at once you may need loops.
343+
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.
344+
But if there are multiple steps required to be executed in the loop you may want to split the step into various steps.
345+
By using the `Loop` class you can execute multiple steps in a loop.
346+
For example let's assume our `AddSongToPlaylist` becomes a `AddSongsToPlaylist` workflow which can add multiple songs at once:
347+
348+
```php
349+
$workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist'))
350+
->validate(new CurrentUserIsAllowedToEditPlaylistValidator())
351+
->process(
352+
(new \PHPWorkflow\Step\Loop(new SongLoop()))
353+
->addStep(new AddSongToPlaylist())
354+
->addStep(new ClearSongCache())
355+
)
356+
->onSuccess(new NotifySubscribers())
357+
->executeWorkflow($parentWorkflowContainer);
358+
```
359+
360+
Our process step now implements a loop controlled by the `SongLoop` class.
361+
The loop contains our two steps `AddSongToPlaylist` and `ClearSongCache`.
362+
The implementation of the `SongLoop` class must implement the `PHPWorkflow\Step\LoopControl` interface.
363+
Let's have a look at an example implementation:
364+
365+
```php
366+
class SongLoop implements \PHPWorkflow\Step\LoopControl {
367+
/**
368+
* As well as each step also each loop must provide a description.
369+
*/
370+
public function getDescription(): string
371+
{
372+
return 'Loop over all provided songs';
373+
}
374+
375+
/**
376+
* This method will be called before each loop run.
377+
* $iteration will contain the current iteration (0 on first run etc)
378+
* You have access to the WorkflowControl and the WorkflowContainer.
379+
* If the method returns true the next iteration will be executed.
380+
* Otherwise the loop is completed.
381+
*/
382+
public function executeNextIteration(
383+
int $iteration,
384+
\PHPWorkflow\WorkflowControl $control,
385+
\PHPWorkflow\State\WorkflowContainer $container
386+
): bool {
387+
$songs = $container->get('songs');
388+
389+
// no songs in container - end the loop
390+
if (empty($songs)) {
391+
return false;
392+
}
393+
394+
// add the current song to the container so the steps
395+
// of the loop can access the entry
396+
$container->set('currentSong', array_shift($songs));
397+
398+
// update the songs entry to handle the songs step by step
399+
$container->set('songs', $songs);
400+
401+
return true;
402+
}
403+
}
404+
```
405+
406+
A loop step may contain a nested workflow if you need more complex steps.
407+
408+
To control the flow of the loop from the steps you can use the `continue` and `break` methods on the `WorkflowControl` object.
409+
410+
By default, a loop is stopped if a step fails.
411+
You can set the second parameter of the `Loop` class (`$continueOnError`) to true to continue the execution with the next iteration.
412+
If you enable this option a failed step will not result in a failed workflow.
413+
Instead, a warning will be added to the process log.
414+
Calls to `failWorkflow` and `skipWorkflow` will always cancel the loop (and consequently the workflow) independent of the option.
415+
330416
## Error handling, logging and debugging
331417

332418
The **executeWorkflow** method returns an **WorkflowResult** object which provides the following methods to determine the result of the workflow:
@@ -412,3 +498,11 @@ Summary:
412498

413499
In this example the **AcceptOpenSuggestionForSong** step found a matching open suggestion and successfully accepted the suggestion.
414500
Consequently, the further workflow execution is skipped.
501+
502+
503+
## Tests ##
504+
505+
The library is tested via [PHPUnit](https://phpunit.de/).
506+
507+
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).
508+
The test names are optimized for the usage of the `--testdox` output.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PHPWorkflow\Exception\WorkflowControl;
6+
7+
class BreakException extends LoopControlException
8+
{
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PHPWorkflow\Exception\WorkflowControl;
6+
7+
class ContinueException extends LoopControlException
8+
{
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PHPWorkflow\Exception\WorkflowControl;
6+
7+
abstract class LoopControlException extends SkipStepException
8+
{
9+
}

src/Stage/Next/AllowNextExecuteWorkflow.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@ public function executeWorkflow(
4949
);
5050

5151
if ($exception instanceof SkipWorkflowException) {
52-
return new WorkflowResult($workflowState->getWorkflowName(), true, $workflowState);
52+
return $workflowState->close(true);
5353
}
5454

55-
$result = new WorkflowResult($workflowState->getWorkflowName(), false, $workflowState, $exception);
55+
$result = $workflowState->close(false, $exception);
5656

5757
if ($throwOnFailure) {
5858
throw new WorkflowException(
@@ -65,6 +65,6 @@ public function executeWorkflow(
6565
return $result;
6666
}
6767

68-
return new WorkflowResult($workflowState->getWorkflowName(), true, $workflowState);
68+
return $workflowState->close(true);
6969
}
7070
}

src/Stage/Stage.php

Lines changed: 3 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,14 @@
44

55
namespace PHPWorkflow\Stage;
66

7-
use Exception;
8-
use PHPWorkflow\Exception\WorkflowControl\FailStepException;
9-
use PHPWorkflow\Exception\WorkflowControl\SkipStepException;
10-
use PHPWorkflow\Exception\WorkflowControl\SkipWorkflowException;
11-
use PHPWorkflow\State\ExecutionLog\ExecutionLog;
127
use PHPWorkflow\State\WorkflowState;
13-
use PHPWorkflow\Step\WorkflowStep;
8+
use PHPWorkflow\Step\StepExecutionTrait;
149
use PHPWorkflow\Workflow;
1510

1611
abstract class Stage
1712
{
13+
use StepExecutionTrait;
14+
1815
protected ?Stage $nextStage = null;
1916
protected Workflow $workflow;
2017

@@ -24,61 +21,4 @@ public function __construct(Workflow $workflow)
2421
}
2522

2623
abstract protected function runStage(WorkflowState $workflowState): ?Stage;
27-
28-
protected function wrapStepExecution(WorkflowStep $step, WorkflowState $workflowState): void {
29-
try {
30-
($this->resolveMiddleware($step, $workflowState))();
31-
} catch (SkipStepException | FailStepException $exception) {
32-
$workflowState->addExecutionLog(
33-
$step->getDescription(),
34-
$exception instanceof FailStepException ? ExecutionLog::STATE_FAILED : ExecutionLog::STATE_SKIPPED,
35-
$exception->getMessage(),
36-
);
37-
38-
if ($exception instanceof FailStepException) {
39-
// cancel the workflow during preparation
40-
if ($workflowState->getStage() <= WorkflowState::STAGE_PROCESS) {
41-
throw $exception;
42-
}
43-
44-
$workflowState->getExecutionLog()->addWarning(sprintf('Step failed (%s)', get_class($step)), true);
45-
}
46-
47-
return;
48-
} catch (Exception $exception) {
49-
$workflowState->addExecutionLog(
50-
$step->getDescription(),
51-
$exception instanceof SkipWorkflowException ? ExecutionLog::STATE_SKIPPED : ExecutionLog::STATE_FAILED,
52-
$exception->getMessage(),
53-
);
54-
55-
// cancel the workflow during preparation
56-
if ($workflowState->getStage() <= WorkflowState::STAGE_PROCESS) {
57-
throw $exception;
58-
}
59-
60-
if (!($exception instanceof SkipWorkflowException)) {
61-
$workflowState->getExecutionLog()->addWarning(sprintf('Step failed (%s)', get_class($step)), true);
62-
}
63-
64-
return;
65-
}
66-
67-
$workflowState->addExecutionLog($step->getDescription());
68-
}
69-
70-
private function resolveMiddleware(WorkflowStep $step, WorkflowState $workflowState): callable
71-
{
72-
$tip = fn () => $step->run($workflowState->getWorkflowControl(), $workflowState->getWorkflowContainer());
73-
74-
foreach ($workflowState->getMiddlewares() as $middleware) {
75-
$tip = fn () => $middleware(
76-
$tip,
77-
$workflowState->getWorkflowControl(),
78-
$workflowState->getWorkflowContainer(),
79-
);
80-
}
81-
82-
return $tip;
83-
}
8424
}

src/State/WorkflowResult.php

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,26 @@
55
namespace PHPWorkflow\State;
66

77
use Exception;
8+
use PHPWorkflow\State\ExecutionLog\ExecutionLog;
89

910
class WorkflowResult
1011
{
1112
private bool $success;
12-
private WorkflowState $workflowState;
1313
private ?Exception $exception;
1414
private string $workflowName;
15+
private ExecutionLog $executionLog;
16+
private WorkflowContainer $workflowContainer;
1517

1618
public function __construct(
17-
string $workflowName,
18-
bool $success,
1919
WorkflowState $workflowState,
20+
bool $success,
2021
?Exception $exception = null
2122
) {
22-
$this->workflowName = $workflowName;
23+
$this->workflowName = $workflowState->getWorkflowName();
24+
$this->executionLog = $workflowState->getExecutionLog();
25+
$this->workflowContainer = $workflowState->getWorkflowContainer();
26+
2327
$this->success = $success;
24-
$this->workflowState = $workflowState;
2528
$this->exception = $exception;
2629
}
2730

@@ -46,15 +49,15 @@ public function success(): bool
4649
*/
4750
public function debug(): string
4851
{
49-
return (string) $this->workflowState->getExecutionLog();
52+
return (string) $this->executionLog;
5053
}
5154

5255
/**
5356
* Check if the workflow execution has triggered warnings
5457
*/
5558
public function hasWarnings(): bool
5659
{
57-
return count($this->workflowState->getExecutionLog()->getWarnings()) > 0;
60+
return count($this->executionLog->getWarnings()) > 0;
5861
}
5962

6063
/**
@@ -65,7 +68,7 @@ public function hasWarnings(): bool
6568
*/
6669
public function getWarnings(): array
6770
{
68-
return $this->workflowState->getExecutionLog()->getWarnings();
71+
return $this->executionLog->getWarnings();
6972
}
7073

7174
/**
@@ -82,6 +85,6 @@ public function getException(): ?Exception
8285
*/
8386
public function getContainer(): WorkflowContainer
8487
{
85-
return $this->workflowState->getWorkflowContainer();
88+
return $this->workflowContainer;
8689
}
8790
}

src/State/WorkflowState.php

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,38 @@ class WorkflowState
2020
public const STAGE_SUMMARY = 7;
2121

2222
private ?Exception $processException = null;
23+
24+
private string $workflowName;
2325
private int $stage = self::STAGE_PREPARE;
26+
private int $inLoop = 0;
27+
2428
private WorkflowControl $workflowControl;
2529
private WorkflowContainer $workflowContainer;
26-
2730
private ExecutionLog $executionLog;
28-
private string $workflowName;
31+
2932
private array $middlewares = [];
3033

34+
private static array $runningWorkflows = [];
35+
3136
public function __construct(WorkflowContainer $workflowContainer)
3237
{
3338
$this->executionLog = new ExecutionLog($this);
34-
$this->workflowControl = new WorkflowControl($this->executionLog);
39+
$this->workflowControl = new WorkflowControl($this);
3540
$this->workflowContainer = $workflowContainer;
41+
42+
self::$runningWorkflows[] = $this;
43+
}
44+
45+
public function close(bool $success, ?Exception $exception = null): WorkflowResult
46+
{
47+
array_pop(self::$runningWorkflows);
48+
49+
return new WorkflowResult($this, $success, $exception);
50+
}
51+
52+
public static function getRunningWorkflow(): ?self
53+
{
54+
return self::$runningWorkflows ? end(self::$runningWorkflows) : null;
3655
}
3756

3857
public function getProcessException(): ?Exception
@@ -97,4 +116,14 @@ public function getMiddlewares(): array
97116
{
98117
return $this->middlewares;
99118
}
119+
120+
public function isInLoop(): bool
121+
{
122+
return $this->inLoop > 0;
123+
}
124+
125+
public function setInLoop(bool $inLoop): void
126+
{
127+
$this->inLoop += $inLoop ? 1 : -1;
128+
}
100129
}

0 commit comments

Comments
 (0)