diff --git a/README.md b/README.md index 1c19ae7..6786d41 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,13 @@ Bonus: you will get an execution log for each executed workflow - if you want to * [Installation](#Installation) * [Example workflow](#Example-workflow) +* [Workflow container](#Workflow-container) * [Stages](#Stages) * [Workflow control](#Workflow-control) * [Nested workflows](#Nested-workflows) * [Loops](#Loops) +* [Step dependencies](#Step-dependencies) + * [Required container values](#Required-container-values) * [Error handling, logging and debugging](#Error-handling-logging-and-debugging) * [Custom output formatter](#Custom-output-formatter) * [Tests](#Tests) @@ -29,6 +32,7 @@ Bonus: you will get an execution log for each executed workflow - if you want to ## Installation The recommended way to install php-workflow is through [Composer](http://getcomposer.org): + ``` $ composer require wol-soft/php-workflow ``` @@ -155,6 +159,8 @@ class AcceptOpenSuggestionForSong implements \PHPWorkflow\Step\WorkflowStep { } ``` +## Workflow container + Now let's have a more detailed look at the **WorkflowContainer** which helps us, to share data and objects between our workflow steps. The relevant objects for our example workflow is the **User** who wants to add the song, the **Song** object of the song to add and the **Playlist** object. Before we execute our workflow we can set up a **WorkflowContainer** which contains all relevant objects: @@ -166,6 +172,22 @@ $workflowContainer = (new \PHPWorkflow\State\WorkflowContainer()) ->set('playlist', (new PlaylistRepository())->getPlaylistById($request->get('playlistId'))); ``` +The workflow container provides the following interface: + +```php +// returns an item or null if the key doesn't exist +public function get(string $key) +// set or update a value +public function set(string $key, $value): self +// remove an entry +public function unset(string $key): self +// check if a key exists +public function has(string $key): bool +``` + +Each workflow step may define requirements, which entries must be present in the workflow container before the step is executed. +For more details have a look at [Required container values](#Required-container-values). + Alternatively to set and get the values from the **WorkflowContainer** via string keys you can extend the **WorkflowContainer** and add typed properties/functions to handle values in a type-safe manner: ```php @@ -192,7 +214,7 @@ $workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist')) ->executeWorkflow($workflowContainer); ``` -Another possibility would be to define a step in the **Prepare** stage (e.g. **PopulateAddSongToPlaylistContainer**) which populates the injected **WorkflowContainer** object. +Another possibility would be to define a step in the **Prepare** stage (e.g. **PopulateAddSongToPlaylistContainer**) which populates the automatically injected empty **WorkflowContainer** object. ## Stages @@ -425,6 +447,40 @@ 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. +## Step dependencies + +Each step implementation may apply dependencies to the step. +By defining dependencies you can set up validation rules which are checked before your step is executed (for example: which data nust be provided in the workflow container). +If any of the dependencies is not fulfilled the step will not be executed and is handled as a failed step. + +Note: as this feature uses [Attributes](https://www.php.net/manual/de/language.attributes.overview.php), it is only available if you use PHP >= 8.0. + +### Required container values + +With the `\PHPWorkflow\Step\Dependency\Required` attribute you can define keys which must be present in the provided workflow container. +The keys consequently must be provided in the initial workflow or be populated by a previous step. +Additionally to the key you can also provide the type of the value (eg. `string`). + +To define the dependency you simply annotate the provided workflow container parameter: + +```php +public function run( + \PHPWorkflow\WorkflowControl $control, + // The key customerId must contain a string + #[\PHPWorkflow\Step\Dependency\Required('customerId', 'string')] + // The customerAge must contain an integer. But also null is accepted. + // Each type definition can be prefixed with a ? to accept null. + #[\PHPWorkflow\Step\Dependency\Required('customerAge', '?int')] + // Objects can also be type hinted + #[\PHPWorkflow\Step\Dependency\Required('created', \DateTime::class)] + \PHPWorkflow\State\WorkflowContainer $container, +) { + // Implementation which can rely on the defined keys to be present in the container. +} +``` + +The following types are supported: `string`, `bool`, `int`, `float`, `object`, `array`, `iterable`, `scalar` as well as object type hints by providing the corresponding FQCN + ## 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/src/Exception/WorkflowStepDependencyNotFulfilledException.php b/src/Exception/WorkflowStepDependencyNotFulfilledException.php new file mode 100644 index 0000000..540914d --- /dev/null +++ b/src/Exception/WorkflowStepDependencyNotFulfilledException.php @@ -0,0 +1,11 @@ +getParameters()[1] ?? null; + + if ($containerParameter) { + foreach ($containerParameter->getAttributes( + StepDependencyInterface::class, + ReflectionAttribute::IS_INSTANCEOF, + ) as $dependencyAttribute + ) { + /** @var StepDependencyInterface $dependency */ + $dependency = $dependencyAttribute->newInstance(); + $dependency->check($container); + } + } + + return $next(); + } +} diff --git a/src/State/WorkflowContainer.php b/src/State/WorkflowContainer.php index c53f2b3..bf0b8a3 100644 --- a/src/State/WorkflowContainer.php +++ b/src/State/WorkflowContainer.php @@ -18,4 +18,15 @@ public function set(string $key, $value): self $this->items[$key] = $value; return $this; } + + public function unset(string $key): self + { + unset($this->items[$key]); + return $this; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->items); + } } diff --git a/src/Step/Dependency/Requires.php b/src/Step/Dependency/Requires.php new file mode 100644 index 0000000..a25b7bd --- /dev/null +++ b/src/Step/Dependency/Requires.php @@ -0,0 +1,49 @@ +has($this->key)) { + throw new WorkflowStepDependencyNotFulfilledException("Missing '$this->key' in container"); + } + + $value = $container->get($this->key); + + if ($this->type === null || (str_starts_with($this->type, '?') && $value === null)) { + return; + } + + $type = str_replace('?', '', $this->type); + + if (preg_match('/^(string|bool|int|float|object|array|iterable|scalar)$/', $type, $matches) === 1) { + $checkMethod = 'is_' . $matches[1]; + + if ($checkMethod($value)) { + return; + } + } elseif (class_exists($type) && ($value instanceof $type)) { + return; + } + + throw new WorkflowStepDependencyNotFulfilledException( + sprintf( + "Value for '%s' has an invalid type. Expected %s, got %s", + $this->key, + $this->type, + gettype($value) . (is_object($value) ? sprintf(' (%s)', $value::class) : ''), + ), + ); + } +} diff --git a/src/Step/Dependency/StepDependencyInterface.php b/src/Step/Dependency/StepDependencyInterface.php new file mode 100644 index 0000000..19b5941 --- /dev/null +++ b/src/Step/Dependency/StepDependencyInterface.php @@ -0,0 +1,16 @@ + $step->run($workflowState->getWorkflowControl(), $workflowState->getWorkflowContainer()); - foreach ($workflowState->getMiddlewares() as $middleware) { + $middlewares = $workflowState->getMiddlewares(); + + if (PHP_MAJOR_VERSION >= 8) { + array_unshift($middlewares, new WorkflowStepDependencyCheck()); + } + + foreach ($middlewares as $middleware) { $tip = fn () => $middleware( $tip, $workflowState->getWorkflowControl(), $workflowState->getWorkflowContainer(), + $step, ); } diff --git a/tests/StepDependencyTest.php b/tests/StepDependencyTest.php new file mode 100644 index 0000000..d955e5f --- /dev/null +++ b/tests/StepDependencyTest.php @@ -0,0 +1,376 @@ += 8.0.0'); + } + } + + public function testMissingKeyFails(): void + { + $result = (new Workflow('test')) + ->process($this->requireCustomerIdStep()) + ->executeWorkflow(null, false); + + $this->assertFalse($result->success()); + + $this->assertDebugLog( + <<process($this->requireCustomerIdStep()) + ->executeWorkflow((new WorkflowContainer())->set('customerId', $input)); + + $this->assertTrue($result->success()); + + $type = gettype($input); + $this->assertDebugLog( + << [null], + 'int' => [10], + 'float' => [10.5], + 'bool' => [true], + 'array' => [[1, 2, 3]], + 'string' => ['Hello'], + 'DateTime' => [new DateTime()], + ]; + } + + /** + * @dataProvider invalidTypedValueDataProvider + */ + public function testInvalidTypedValueFails(WorkflowContainer $container, string $expectedExceptionMessage): void + { + $result = (new Workflow('test')) + ->process($this->requiredTypedCustomerIdStep()) + ->executeWorkflow($container, false); + + $this->assertFalse($result->success()); + + $this->assertDebugLog( + << [(new WorkflowContainer()), "Missing 'customerId' in container"]; + + foreach ([null, false, 10, 0.0, []] as $input) { + yield gettype($input) => [ + (new WorkflowContainer())->set('customerId', $input), + "Value for 'customerId' has an invalid type. Expected string, got " . gettype($input), + ]; + } + } + + public function testProvidedTypedKeySucceeds(): void + { + $result = (new Workflow('test')) + ->process($this->requiredTypedCustomerIdStep()) + ->executeWorkflow((new WorkflowContainer())->set('customerId', 'Hello')); + + $this->assertTrue($result->success()); + + $this->assertDebugLog( + <<process($this->requiredNullableTypedCustomerIdStep()) + ->executeWorkflow($container, false); + + $this->assertFalse($result->success()); + + $this->assertDebugLog( + << [(new WorkflowContainer()), "Missing 'customerId' in container"]; + + foreach ([false, 10, 0.0, []] as $input) { + yield gettype($input) => [ + (new WorkflowContainer())->set('customerId', $input), + "Value for 'customerId' has an invalid type. Expected ?string, got " . gettype($input), + ]; + } + } + + /** + * @dataProvider providedNullableTypedKeyDataProvider + */ + public function testProvidedNullableTypedKeySucceeds(?string $input): void + { + $result = (new Workflow('test')) + ->process($this->requiredNullableTypedCustomerIdStep()) + ->executeWorkflow((new WorkflowContainer())->set('customerId', $input)); + + $this->assertTrue($result->success()); + + $this->assertDebugLog( + << [null], + 'empty string' => [''], + 'numeric string' => ['123'], + 'string' => ['Hello World'], + ]; + } + + /** + * @dataProvider invalidDateTimeValueDataProvider + */ + public function testInvalidDateTimeValueFails( + WorkflowContainer $container, + string $expectedExceptionMessage + ): void { + $result = (new Workflow('test')) + ->process($this->requiredDateTimeStep()) + ->executeWorkflow($container, false); + + $this->assertFalse($result->success()); + + $this->assertDebugLog( + << [(new WorkflowContainer()), "Missing 'created' in container"]; + yield 'updated not provided' => [ + (new WorkflowContainer())->set('created', new DateTime()), + "Missing 'updated' in container", + ]; + + foreach ([null, false, 10, 0.0, [], '', new DateTimeZone('Europe/Berlin')] as $input) { + yield 'Invalid value for created - ' . gettype($input) => [ + (new WorkflowContainer())->set('created', $input), + "Value for 'created' has an invalid type. Expected DateTime, got " + . gettype($input) + . (is_object($input) ? sprintf(' (%s)', get_class($input)) : ''), + ]; + } + + foreach ([false, 10, 0.0, [], '', new DateTimeZone('Europe/Berlin')] as $input) { + yield 'Invalid value for updated - ' . gettype($input) => [ + (new WorkflowContainer())->set('created', new DateTime())->set('updated', $input), + "Value for 'updated' has an invalid type. Expected ?DateTime, got " + . gettype($input) + . (is_object($input) ? sprintf(' (%s)', get_class($input)) : ''), + ]; + } + } + + + /** + * @dataProvider providedDateTimeDataProvider + */ + public function testProvidedDateTimeSucceeds(DateTime $created, ?DateTime $updated): void + { + $result = (new Workflow('test')) + ->process($this->requiredDateTimeStep()) + ->executeWorkflow((new WorkflowContainer())->set('created', $created)->set('updated', $updated)); + + $this->assertTrue($result->success()); + + $this->assertDebugLog( + << [new DateTime(), null], + 'created and updated' => [new DateTime(), new DateTime()], + ]; + } + + public function requireCustomerIdStep(): WorkflowStep + { + return new class () implements WorkflowStep { + public function getDescription(): string + { + return 'test step with simple require'; + } + + public function run( + WorkflowControl $control, + #[Requires('customerId')] + WorkflowContainer $container + ): void { + $control->attachStepInfo('provided type: ' . gettype($container->get('customerId'))); + } + }; + } + + public function requiredTypedCustomerIdStep(): WorkflowStep + { + return new class () implements WorkflowStep { + public function getDescription(): string + { + return 'test step with typed require'; + } + + public function run( + WorkflowControl $control, + #[Requires('customerId', 'string')] + WorkflowContainer $container + ): void {} + }; + } + + public function requiredNullableTypedCustomerIdStep(): WorkflowStep + { + return new class () implements WorkflowStep { + public function getDescription(): string + { + return 'test step with nullable typed require'; + } + + public function run( + WorkflowControl $control, + #[Requires('customerId', '?string')] + WorkflowContainer $container + ): void {} + }; + } + + public function requiredDateTimeStep(): WorkflowStep + { + return new class () implements WorkflowStep { + public function getDescription(): string + { + return 'test step with DateTime require'; + } + + public function run( + WorkflowControl $control, + #[Requires('created', DateTime::class)] + #[Requires('updated', '?' . DateTime::class)] + WorkflowContainer $container + ): void {} + }; + } +} diff --git a/tests/WorkflowContainerTest.php b/tests/WorkflowContainerTest.php new file mode 100644 index 0000000..647a418 --- /dev/null +++ b/tests/WorkflowContainerTest.php @@ -0,0 +1,31 @@ +assertFalse($container->has('non existing key')); + $this->assertNull($container->get('non existing key')); + + $container->set('key', 42); + $this->assertTrue($container->has('key')); + $this->assertSame(42, $container->get('key')); + + $container->set('key', 'Updated'); + $this->assertTrue($container->has('key')); + $this->assertSame('Updated', $container->get('key')); + + $container->unset('key'); + $this->assertFalse($container->has('key')); + $this->assertNull($container->get('key')); + } +}