Skip to content

Commit

Permalink
Merge pull request #1 from wol-soft/loops
Browse files Browse the repository at this point in the history
Add loops
  • Loading branch information
wol-soft authored Nov 11, 2021
2 parents 75051b9 + f1e4a41 commit 0ce615b
Show file tree
Hide file tree
Showing 16 changed files with 1,495 additions and 305 deletions.
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ 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)
* [Tests](#Tests)

## Installation

Expand Down Expand Up @@ -284,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
Expand Down Expand Up @@ -327,6 +337,82 @@ 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.

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:
Expand Down Expand Up @@ -412,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.
9 changes: 9 additions & 0 deletions src/Exception/WorkflowControl/BreakException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace PHPWorkflow\Exception\WorkflowControl;

class BreakException extends LoopControlException
{
}
9 changes: 9 additions & 0 deletions src/Exception/WorkflowControl/ContinueException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace PHPWorkflow\Exception\WorkflowControl;

class ContinueException extends LoopControlException
{
}
9 changes: 9 additions & 0 deletions src/Exception/WorkflowControl/LoopControlException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace PHPWorkflow\Exception\WorkflowControl;

abstract class LoopControlException extends SkipStepException
{
}
6 changes: 3 additions & 3 deletions src/Stage/Next/AllowNextExecuteWorkflow.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -65,6 +65,6 @@ public function executeWorkflow(
return $result;
}

return new WorkflowResult($workflowState->getWorkflowName(), true, $workflowState);
return $workflowState->close(true);
}
}
66 changes: 3 additions & 63 deletions src/Stage/Stage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
}
21 changes: 12 additions & 9 deletions src/State/WorkflowResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -46,15 +49,15 @@ public function success(): bool
*/
public function debug(): string
{
return (string) $this->workflowState->getExecutionLog();
return (string) $this->executionLog;
}

/**
* Check if the workflow execution has triggered warnings
*/
public function hasWarnings(): bool
{
return count($this->workflowState->getExecutionLog()->getWarnings()) > 0;
return count($this->executionLog->getWarnings()) > 0;
}

/**
Expand All @@ -65,7 +68,7 @@ public function hasWarnings(): bool
*/
public function getWarnings(): array
{
return $this->workflowState->getExecutionLog()->getWarnings();
return $this->executionLog->getWarnings();
}

/**
Expand All @@ -82,6 +85,6 @@ public function getException(): ?Exception
*/
public function getContainer(): WorkflowContainer
{
return $this->workflowState->getWorkflowContainer();
return $this->workflowContainer;
}
}
35 changes: 32 additions & 3 deletions src/State/WorkflowState.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,38 @@ class WorkflowState
public const STAGE_SUMMARY = 7;

private ?Exception $processException = null;

private string $workflowName;
private int $stage = self::STAGE_PREPARE;
private int $inLoop = 0;

private WorkflowControl $workflowControl;
private WorkflowContainer $workflowContainer;

private ExecutionLog $executionLog;
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->workflowControl = new WorkflowControl($this);
$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
Expand Down Expand Up @@ -97,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;
}
}
Loading

0 comments on commit 0ce615b

Please sign in to comment.