├── .coveralls.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── UPGRADE-3.0.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Action │ ├── Executor.php │ ├── ExpressionLanguage │ │ └── ActionExpressionLanguage.php │ ├── Reference │ │ ├── ActionReferenceInterface.php │ │ ├── CallableMethod.php │ │ ├── ContainerAwareInterface.php │ │ └── ContainerAwareTrait.php │ ├── Registry.php │ └── ValueObject │ │ └── Action.php ├── Actions │ └── TransitionApplier.php ├── Command │ └── ExecuteActionCommand.php ├── DependencyInjection │ ├── Compiler │ │ └── CheckSubjectManipulatorConfigPass.php │ ├── Configuration.php │ ├── Enum │ │ └── ActionArgumentTypes.php │ └── WorkflowExtensionsExtension.php ├── Entity │ ├── Repository │ │ └── ScheduledJobRepository.php │ └── ScheduledJob.php ├── Exception │ ├── ActionException.php │ ├── InvalidArgumentException.php │ ├── NonUniqueReschedulabeJobFoundException.php │ ├── RuntimeException.php │ ├── SubjectIdRetrievingException.php │ ├── SubjectManipulatorException.php │ ├── SubjectRetrievingFromDomainException.php │ ├── UnsupportedGuardEventException.php │ ├── UnsupportedTriggerEventException.php │ └── WorkflowExceptionInterface.php ├── ExpressionLanguage │ └── ContainerAwareExpressionLanguage.php ├── Guard │ └── ExpressionGuard.php ├── MarkingStore │ └── OrmPersistentMarkingStore.php ├── Resources │ └── config │ │ ├── actions.xml │ │ ├── guard.xml │ │ ├── scheduler.xml │ │ ├── subject_manipulator.xml │ │ └── triggers.xml ├── Schedule │ ├── ActionScheduler.php │ └── ValueObject │ │ └── ScheduledAction.php ├── Trigger │ └── Event │ │ ├── AbstractActionListener.php │ │ ├── AbstractListener.php │ │ ├── ActionListener.php │ │ ├── ExpressionListener.php │ │ └── SchedulerListener.php ├── Utils │ └── ArrayUtils.php ├── WorkflowContext.php ├── WorkflowExtensionsBundle.php └── WorkflowSubject │ └── SubjectManipulator.php └── tests ├── Action └── ExecutorTest.php ├── Actions └── TransitionApplierTest.php ├── DependencyInjection ├── Compiler │ └── CheckSubjectManipulatorConfigPassTest.php └── ConfigurationTest.php ├── Functional ├── Configuration │ ├── BaseConfig │ │ ├── config.yml │ │ └── routing.yml │ ├── EventTriggerWithGuardsCase │ │ ├── Fixtures │ │ │ ├── Event.php │ │ │ └── TargetWorkflowSubject.php │ │ ├── bundles.php │ │ └── config.yml │ └── ScheduleCase │ │ ├── Fixtures │ │ ├── ClientBundle │ │ │ ├── ClientBundle.php │ │ │ ├── DataFixtures │ │ │ │ └── ORM │ │ │ │ │ └── LoadData.php │ │ │ └── Entity │ │ │ │ └── Client.php │ │ └── Event.php │ │ ├── bundles.php │ │ ├── config.yml │ │ └── console ├── EventTriggerWithGuardsCaseTest.php ├── Kernel │ ├── BaseTestKernel.php │ ├── KernelBuilder.php │ └── TestKernelInterface.php ├── ScheduleCaseTest.php └── TestCase.php ├── Guard └── ExpressionGuardTest.php ├── Schedule └── ActionSchedulerTest.php ├── Trigger └── Event │ ├── AbstractActionListenerTest.php │ └── AbstractListenerTest.php └── bootstrap.php /.coveralls.yml: -------------------------------------------------------------------------------- 1 | coverage_clover: build/logs/clover.xml 2 | json_path: coveralls-upload.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | .phpunit.result.cache 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - '7.2' 5 | - '7.3' 6 | - nightly 7 | 8 | matrix: 9 | allow_failures: 10 | - php: nightly 11 | 12 | before_install: 13 | - echo "memory_limit=-1" >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini 14 | 15 | before_script: 16 | - composer self-update 17 | - composer global --no-suggest require hirak/prestissimo 18 | - composer require satooshi/php-coveralls -n 19 | 20 | install: 21 | - composer install --no-suggest --prefer-dist 22 | 23 | script: 24 | - mkdir -p build/logs 25 | - vendor/bin/phpunit --coverage-clover build/logs/clover.xml 26 | 27 | after_script: 28 | - php vendor/bin/coveralls -v 29 | 30 | git: 31 | depth: 5 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 GlobalTradingTechnologies 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | WorkflowExtensionsBundle 2 | ======================== 3 | 4 | [![Build Status](https://travis-ci.org/GlobalTradingTechnologies/workflow-extensions-bundle.svg?branch=master)](https://travis-ci.org/GlobalTradingTechnologies/workflow-extensions-bundle) 5 | [![Coverage Status](https://coveralls.io/repos/github/GlobalTradingTechnologies/workflow-extensions-bundle/badge.svg?branch=master)](https://coveralls.io/github/GlobalTradingTechnologies/workflow-extensions-bundle?branch=master) 6 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/GlobalTradingTechnologies/workflow-extensions-bundle/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/GlobalTradingTechnologies/workflow-extensions-bundle/?branch=master) 7 | [![Latest Stable Version](https://poser.pugx.org/gtt/workflow-extensions-bundle/version)](https://packagist.org/packages/gtt/workflow-extensions-bundle) 8 | [![Latest Unstable Version](https://poser.pugx.org/gtt/workflow-extensions-bundle/v/unstable)](//packagist.org/packages/gtt/workflow-extensions-bundle) 9 | [![License](https://poser.pugx.org/gtt/workflow-extensions-bundle/license)](https://packagist.org/packages/gtt/workflow-extensions-bundle) 10 | 11 | Original Symfony 3 [Workflow component](https://github.com/symfony/workflow) and the [part of Symfony's FrameworkBundle](https://github.com/symfony/symfony/blob/master/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php#L359-L398) that 12 | integrates it in Symfony's ecosystem state explicit declaring all the things as a main concept. 13 | This means that if you need to apply workflow transition you must find target workflow object and call ```$workflow->apply()``` inside your business logic code. 14 | Another example: if you want to block transition you must create a listener that subscribes to GuardEvent and decide whether to block or 15 | allow transition inside. 16 | 17 | But sometimes workflow processes are complex and the code handles transition management grows quickly. 18 | 19 | WorkflowExtensionsBundle provides extensions and additional features to original Symfony 3 Workflow component that can help you to automate some actions you must do manually when you deal with workflow component out of the box: 20 | 21 | 1. [Event-based transitions triggering](#event-based-transitions-processing) 22 | 23 | 2. [Event-based transitions scheduling](#event-based-transitions-triggering) 24 | 25 | 3. [Configurable transition blocking](#event-based-transitions-scheduling) 26 | 27 | Requirements 28 | ============ 29 | 30 | Since Symfony's Workflow component requires PHP 5.5.9+ WorkflowExtensionsBundle supports PHP 5.5.9 and newer. 31 | 32 | Workflow component is integrated in Symfony 3 ecosystem starting from 3.2 version. In order to use it in applications based on Symfony 3.1 and lower you can use [1.x version](https://github.com/GlobalTradingTechnologies/workflow-extensions-bundle/tree/1.x) of the Bundle. 33 | 34 | Besides [symfony/framework-bundle](https://github.com/symfony/framework-bundle) and [symfony/expression-language](https://github.com/symfony/expression-language) packages are required. 35 | 36 | 37 | Installation 38 | ============ 39 | 40 | Bundle should be installed via composer 41 | 42 | ``` 43 | composer require gtt/workflow-extensions-bundle 44 | ``` 45 | After that you need to register the bundle inside your application kernel: 46 | ```php 47 | public function registerBundles() 48 | { 49 | $bundles = array( 50 | // ... 51 | new \Gtt\Bundle\WorkflowExtensionsBundle\WorkflowExtensionsBundle(), 52 | ); 53 | } 54 | ``` 55 | 56 | Configuration and Usage 57 | ======================= 58 | 59 | ## Workflow subjects 60 | First of all you need to tell WorkflowExtensionsBundle what kind of workflow subjects you want it to deal with. 61 | List it inside `subject_manipulator` section. 62 | ```yml 63 | workflow_extensions: 64 | subject_manipulator: 65 | My\Bundle\Entity\Order: ~ 66 | My\Bundle\Entity\Claim: ~ 67 | ``` 68 | 69 | ## Logging 70 | Since all the things WorkflowExtensionsBundle does are basically automated (and even asynchronous) it is reasonable 71 | to log important aspects in details. All the WorkflowExtensionsBundle subsystems log (when it is possible) workflow name, subject class and subject id during execution. 72 | 73 | There is one non-trivial thing here: how to retrieve subject id from subject. More often subject id can be fetched by invoking `getId()` method, - in this case you have nothing to do. 74 | Otherwise (when your subject class has no `getId()` method or there is the other one should be used to get subject's identifier) you need to specify expression to get subject identifier. This expression will be evaluated by [ExpressionLanguage](https://github.com/symfony/expression-language) component with `subject` variable that represents subject object: 75 | ```yaml 76 | workflow_extensions: 77 | subject_manipulator: 78 | My\Bundle\Entity\Order: ~ 79 | My\Bundle\Entity\Claim: 80 | id_from_subject: 'subject.getCustomId()' 81 | ``` 82 | ## Event-based transitions processing 83 | One of the most important use cases of WorkflowExtensionsBundle is to execute some workflow manipulations as a reaction to the particular system events. Any [Symfony's Event](https://github.com/symfony/event-dispatcher/blob/3.1/Event.php) instance can play the role of such firing event. 84 | In order to subscribe workflow processing to such an event you should start with config like this: 85 | ```yaml 86 | workflow_extensions: 87 | workflows: 88 | simple: 89 | triggers: 90 | event: 91 | some.event: 92 | ... 93 | another.event: 94 | ... 95 | complex: 96 | triggers: 97 | event: 98 | some.event: 99 | ... 100 | third.event: 101 | ... 102 | ... 103 | ``` 104 | This config firstly specifies target workflow name (`simple`) that should be equal to one of defined workflows in symfony/framework-bundle or fduch/workflow-bundle config. 105 | For each workflow then you define target event and configure processing details as described in sections below. 106 | 107 | ### Event-based transitions triggering 108 | WorkflowExtensionsBundle makes possible to trigger workflow transitions when particular event is fired. 109 | For example if you want to trigger transition `to_processing` when workflow subject (My\Bundle\Entity\Order instance) is created (order_created.event is fired) the WorkflowExtensionsBundle's config can look like this: 110 | ```yaml 111 | workflow_extensions: 112 | workflows: 113 | simple: 114 | triggers: 115 | event: 116 | order_created.event: 117 | actions: 118 | apply_transition: 119 | - [to_processing] 120 | subject_retrieving_expression: 'event.getOrder()' 121 | subject_manipulator: 122 | My\Bundle\Entity\Order: ~ 123 | ``` 124 | In example above `subject_retrieving_expression` section contains expression (it will be evaluated by [ExpressionLanguage](https://github.com/symfony/expression-language)) used to retrieve workflow subject. 125 | Since expression language that evaluates these expressions has container variable (represents DI Container) enabled you can construct more complicated things for example like this here: ```"container.get('doctrine').getEntityMangerForClass('My\\\\Bundle\\\\Entity\\\\Order').find('My\\\\Bundle\\\\Entity\\\\Order', event.getId())"``` (Lot of backslashes is set due to specialty of [expression language syntax](http://symfony.com/doc/current/components/expression_language/syntax.html)). 126 | 127 | You can also specify more then one transition to be tried to perform when event is fired by using `apply_transitions` construction like this: 128 | ```yaml 129 | apply_transitions: 130 | - [[to_processing, closing]] 131 | ``` 132 | In this case (by default) the first applicable transition would be applied. 133 | You can also consequentially try to apply several transitions without breaking execution after first successfully applied transition using ability to 134 | invoke `apply_transition` action several times in line with different arguments: 135 | ```yaml 136 | apply_transition: 137 | - [to_processing] 138 | - [closing] 139 | ``` 140 | 141 | ### Event-based transitions scheduling 142 | Events can be used not only to immediately apply transitions, - you can also schedule it with specified offset. 143 | WorkflowExtensionsBundle uses [jms/job-queue-bundle](https://packagist.org/packages/jms/job-queue-bundle) as a scheduler engine. 144 | Imagine you need to apply transition `set_problematic` that places workflow subject `Order` into state "Problematic" if it is not correctly processed in 30 days. 145 | Such goal can be achieved using config like this: 146 | 147 | ```yaml 148 | workflow_extensions: 149 | workflows: 150 | simple: 151 | triggers: 152 | event: 153 | order_created.event: 154 | schedule: 155 | apply_transition: 156 | - 157 | arguments: [closing] 158 | offset: P30D 159 | subject_retrieving_expression: 'event.getOrder()' 160 | scheduler: ~ 161 | subject_manipulator: 162 | My\Bundle\Entity\Order: 163 | subject_from_domain: "container.get('doctrine').getManagerForClass(subjectClass).find(subjectClass, subjectId)" 164 | context: 165 | doctrine: ~ 166 | ``` 167 | Configuration above is similar to previous one with several differences. 168 | 169 | The first difference is that `actions` is replaced with `schedule` key to tell the engine that actions below should be executed deferred. 170 | 171 | The second difference is each action's arguments are defined now under explicit `arguments` key (which is automatically set under the hood for [simple triggering](#event-based-transitions-triggering) thanks to [configuration normalization rules](http://symfony.com/doc/current/components/config/definition.html#normalization) and also can be set explicitly there) and 172 | `offset` key that defines time interval (according to [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601#Durations)) started from the moment when corresponding trigger event occurred and after that scheduled transition should be applied. 173 | 174 | The third difference is that you need to configure `scheduler` section to activate scheduler engine. Also it can be used to set particular entity manager to persist scheduler jobs. 175 | 176 | The fourth difference is that you must configure under `subject_manipulator`'s `subject_from_domain` key expression (it will be evaluated by [ExpressionLanguage](https://github.com/symfony/expression-language)) that will be used to retrieve workflow subject when scheduled transition will be tried to be applied. 177 | The subjectClass (for example My\Bundle\Entity\Order) and subjectId (i.e. identifier you can use to fetch the object) are the expression variables here. Moreover you can use DI container here again since it also registered as expression variable. 178 | 179 | Another feature here is that if you have frequent repeatable event that schedules transition then for the first time when event is fired transition would be simply scheduled and next event occurrences will just reset scheduler countdown to restart it from current moment. 180 | This behaviour can be very useful when you need continuously delay particular transition until specific event is fired regularly. You should not configure something specific to achieve this since this feature is enabled by default. 181 | ## Transition blocking 182 | Basically you can prevent transition from applying explicitly by listening special [GuardEvent](https://github.com/symfony/workflow/blob/master/Event/GuardEvent.php) and call its `setBlocked` method inside. With the help of WorkflowExtensionsBundle you can automate things again. 183 | For example if you to block all the transitions invoked by non-ROLE_USER users and allow only managers (ROLE_MANAGER holders) to apply `dangerous` transition you should use config like this: 184 | ```yaml 185 | workflow_extensions: 186 | workflows: 187 | simple: 188 | guard: 189 | expression: 'not container.get("access_checker").isGranted("ROLE_USER")' 190 | transitions: 191 | dangerous: 'not container.get("access_checker").isGranted("ROLE_MANAGER")' 192 | subject_manipulator: 193 | My\Bundle\Entity\Order: ~ 194 | context: 195 | access_checker: ~ 196 | ``` 197 | Note that here again we use expression evaluated by [ExpressionLanguage](https://github.com/symfony/expression-language) with container variable represents DI Container allowing usage of public services to decide whether to block transitions or not. 198 | 199 | ## Contexts 200 | When expressions use some container service it is fetched from container using `container.get()` method. Since Symfony 4 201 | private services can not be fetched from container in such way. To access required service inside the expression the 202 | former must be explicitly exposed in bundle configuration. This is done inside `context` array in bundle 203 | configuration: 204 | 205 | ```yaml 206 | workflow_extensions: 207 | ... 208 | context: 209 | # This will expose "doctrine" service from DI under "doctrine" alias inside expression container 210 | doctrine: ~ 211 | 212 | # This will expose "security.authorization_checker" from DI and make it available under 213 | # "auth_checker" alias inside expression container 214 | auth_checker: 'security.authorization_checker' 215 | 216 | workflows: 217 | simple: 218 | guard: 219 | expression: 'not container.get("auth_checker").isGranted("ROLE_USER")' 220 | transitions: 221 | dangerous: 'not container.get("auth_checker").isGranted("ROLE_MANAGER")' 222 | ``` 223 | 224 | Tests 225 | ===== 226 | WorkflowExtensionsBundle is covered by unit and functional tests. [Functional tests](https://github.com/GlobalTradingTechnologies/workflow-extensions-bundle/tree/master/Tests/Functional) can probably make more clear how the bundle works if you have some misunderstanding. 227 | -------------------------------------------------------------------------------- /UPGRADE-3.0.md: -------------------------------------------------------------------------------- 1 | # Upgrade from 2.x to 3.0 2 | 3 | ## Moving to PSR-11 container 4 | 5 | Under the hood the bundle now uses PSR-11 container implementation for accessing services 6 | inside all expressions. If you implemented `Symfony\Component\DependencyInjection\ContainerAwareInterface` 7 | for actions, you should replace the implementation with `Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ContainerAwareInterface` 8 | and change type-hint to `Psr\Container\ContainerInterface` 9 | 10 | Before: 11 | ```php 12 | use Symfony\Component\DependencyInjection\ContainerAwareInterface; 13 | use Symfony\Component\DependencyInjection\ContainerInterface; 14 | 15 | class MyAction implements ContainerAwareInterface { 16 | public function setContainer(ContainerInterface $container) { 17 | // ... 18 | } 19 | } 20 | ``` 21 | 22 | After: 23 | ```php 24 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ContainerAwareInterface; 25 | use Psr\Container\ContainerInterface; 26 | 27 | class MyAction implements ContainerAwareInterface { 28 | public function setContainer(ContainerInterface $container) { 29 | // ... 30 | } 31 | } 32 | ``` 33 | 34 | If `Symfony\Component\DependencyInjection\ContainerAwareTrait` was used just replace the use to 35 | `Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ContainerAwareTrait`. 36 | 37 | Before: 38 | ```php 39 | use Symfony\Component\DependencyInjection\ContainerAwareInterface; 40 | use Symfony\Component\DependencyInjection\ContainerAwareTrait; 41 | 42 | class MyAction implements ContainerAwareInterface { 43 | use ContainerAwareTrait; 44 | } 45 | ``` 46 | 47 | After: 48 | ```php 49 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ContainerAwareInterface; 50 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ContainerAwareTrait; 51 | 52 | class MyAction implements ContainerAwareInterface { 53 | use ContainerAwareTrait; 54 | } 55 | ``` 56 | 57 | ## Explicitly exposed services 58 | 59 | Prior to symfony 4.0 any service could be fetched from DI container using `$container->get` method. This is 60 | no longer valid. To keep working expressions services must be explicitly defined in bundle configuration: 61 | 62 | Before: 63 | ```yaml 64 | subject_manipulator: 65 | Entity\Client: 66 | subject_from_domain: "container.get('doctrine') ..." 67 | ``` 68 | 69 | After: 70 | ```yaml 71 | subject_manipulator: 72 | Entity\Client: 73 | subject_from_domain: "container.get('doctrine') ..." 74 | context: 75 | doctrine: ~ 76 | ``` 77 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gtt/workflow-extensions-bundle", 3 | "description": "Bundle for extended workflow management and automation", 4 | "type": "symfony-bundle", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Alex Medvedev (fduch)", 9 | "email": "alex.medwedew@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "~7.2", 14 | "psr/container": "^1.0", 15 | "symfony/expression-language": "^4.0", 16 | "symfony/framework-bundle": "^4.0", 17 | "symfony/workflow": "^4.0" 18 | }, 19 | "require-dev": { 20 | "doctrine/doctrine-bundle": "~1.0", 21 | "doctrine/doctrine-fixtures-bundle": "^2.2", 22 | "doctrine/orm": "^2.4.8", 23 | "jms/job-queue-bundle": "^2.0", 24 | "nesbot/carbon": "^1.21", 25 | "phpunit/phpunit": "~8.0", 26 | "symfony/browser-kit": "^4.0", 27 | "symfony/monolog-bundle": "^3.0", 28 | "symfony/property-access": "^4.0", 29 | "symfony/yaml": "^4.0" 30 | }, 31 | "suggest": { 32 | "jms/job-queue-bundle": "Needed for workflow transitions scheduling", 33 | "symfony/console": "Needed for workflow transitions scheduling", 34 | "nesbot/carbon": "Needed for workflow transitions scheduling", 35 | "doctrine/doctrine-bundle": "Required for OrmPersistentMarkingStore usage" 36 | }, 37 | "autoload": { 38 | "psr-4": { "Gtt\\Bundle\\WorkflowExtensionsBundle\\": "src" } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { "Gtt\\Bundle\\WorkflowExtensionsBundle\\": "tests" } 42 | }, 43 | "config": { 44 | "sort-packages": true 45 | }, 46 | "extra": { 47 | "branch-alias": { 48 | "dev-master": "3.x-dev" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | tests 7 | 8 | 9 | 10 | 11 | 12 | src 13 | 14 | ./Resources 15 | ./Tests 16 | ./vendor 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Action/Executor.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 23.09.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Action; 15 | 16 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ActionReferenceInterface; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ContainerAwareInterface; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 19 | use Psr\Container\ContainerInterface; 20 | 21 | /** 22 | * Action executor 23 | * 24 | * @author fduch 25 | */ 26 | class Executor 27 | { 28 | /** 29 | * Action registry 30 | * 31 | * @var Registry 32 | */ 33 | private $registry; 34 | 35 | /** 36 | * Container DI 37 | * 38 | * @var ContainerInterface 39 | */ 40 | private $container; 41 | 42 | /** 43 | * Executor constructor. 44 | * 45 | * @param Registry $registry action registry 46 | * @param ContainerInterface $container container DI 47 | */ 48 | public function __construct(Registry $registry, ContainerInterface $container) 49 | { 50 | $this->registry = $registry; 51 | $this->container = $container; 52 | } 53 | 54 | /** 55 | * Executes action 56 | * 57 | * @param WorkflowContext $workflowContext workflow context 58 | * @param string $actionName action name 59 | * @param array $args action arguments 60 | * 61 | * @return mixed 62 | */ 63 | public function execute(WorkflowContext $workflowContext, string $actionName, array $args = []) 64 | { 65 | $actionReference = $this->registry->get($actionName); 66 | 67 | if ($actionReference->getType() === ActionReferenceInterface::TYPE_WORKFLOW) { 68 | array_unshift($args, $workflowContext); 69 | } 70 | 71 | if ($actionReference instanceof ContainerAwareInterface) { 72 | // container should be injected to allow action to use service as an method owner 73 | $actionReference->setContainer($this->container); 74 | } 75 | 76 | return $actionReference->invoke($args); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Action/ExpressionLanguage/ActionExpressionLanguage.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 01.09.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Action\ExpressionLanguage; 15 | 16 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Executor; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Registry; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\ExpressionLanguage\ContainerAwareExpressionLanguage; 19 | use Psr\Cache\CacheItemPoolInterface; 20 | use Psr\Container\ContainerInterface; 21 | 22 | /** 23 | * Expression language allows to use actions inside expressions 24 | * 25 | * @author fduch 26 | */ 27 | class ActionExpressionLanguage extends ContainerAwareExpressionLanguage 28 | { 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | public function __construct( 33 | Registry $actionRegistry, 34 | Executor $actionExecutor, 35 | ContainerInterface $container, 36 | CacheItemPoolInterface $cache = null, 37 | array $providers = [] 38 | ) { 39 | parent::__construct($container, $cache, $providers); 40 | 41 | foreach ($actionRegistry as $actionName => $action) { 42 | $this->register( 43 | $actionName, 44 | static function () use ($actionName, $actionExecutor) 45 | { 46 | $rawArgs = func_get_args(); 47 | $compiledArgsArray = var_export($rawArgs, true); 48 | 49 | return sprintf( 50 | '$container->get("gtt.workflow.action.executor")->execute($workflowContext, "%s", %s)', 51 | $actionName, 52 | $compiledArgsArray 53 | ); 54 | }, 55 | static function () use ($actionName, $actionExecutor) 56 | { 57 | $args = func_get_args(); 58 | $variables = array_shift($args); 59 | 60 | return $actionExecutor->execute($variables['workflowContext'], $actionName, $args); 61 | } 62 | ); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Action/Reference/ActionReferenceInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 14.09.16 11 | */ 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference; 14 | 15 | /** 16 | * Reference to some method can be treated as an action 17 | * 18 | * @author fduch 19 | */ 20 | interface ActionReferenceInterface 21 | { 22 | /** 23 | * Base default action type 24 | */ 25 | public const TYPE_REGULAR = "regular"; 26 | 27 | /** 28 | * Action type requires WorkflowContext instance as first argument in arguments list 29 | */ 30 | public const TYPE_WORKFLOW = "workflow"; 31 | 32 | /** 33 | * Returns action type 34 | * 35 | * For now can be regular or workflow 36 | * 37 | * @return string 38 | */ 39 | public function getType(): string; 40 | 41 | /** 42 | * Invokes action 43 | * 44 | * @param array $args action args 45 | * 46 | * @return mixed 47 | */ 48 | public function invoke(array $args); 49 | } 50 | -------------------------------------------------------------------------------- /src/Action/Reference/CallableMethod.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 14.09.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference; 15 | 16 | /** 17 | * Reference to some callable 18 | * 19 | * @author fduch 20 | */ 21 | class CallableMethod implements ActionReferenceInterface 22 | { 23 | /** 24 | * Action type 25 | * 26 | * @var string 27 | */ 28 | private $type; 29 | 30 | /** 31 | * Callable which will be used as an action 32 | * 33 | * @var callable 34 | */ 35 | private $method; 36 | 37 | /** 38 | * ActionReference constructor. 39 | * 40 | * @param callable $method Callable which will be used as an action 41 | * @param string $type action reference type 42 | */ 43 | public function __construct(callable $method, string $type = self::TYPE_REGULAR) 44 | { 45 | $this->type = $type; 46 | $this->method = $method; 47 | } 48 | 49 | /** 50 | * Returns action type 51 | * 52 | * @return string 53 | */ 54 | public function getType(): string 55 | { 56 | return $this->type; 57 | } 58 | 59 | /** 60 | * Invokes action 61 | * 62 | * @param array $args action args 63 | * 64 | * @return mixed 65 | */ 66 | public function invoke(array $args) 67 | { 68 | $callable = $this->method; 69 | 70 | return $callable(...$args); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Action/Reference/ContainerAwareInterface.php: -------------------------------------------------------------------------------- 1 | container = $container; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Action/Registry.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 31.08.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Action; 15 | 16 | use ArrayIterator; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ActionReferenceInterface; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\ActionException; 19 | use IteratorAggregate; 20 | 21 | /** 22 | * Workflow action registry 23 | * 24 | * @author fduch 25 | */ 26 | class Registry implements IteratorAggregate 27 | { 28 | /** 29 | * List of workflow actions 30 | * 31 | * @var ActionReferenceInterface[] 32 | */ 33 | private $actions = []; 34 | 35 | /** 36 | * Registry constructor. 37 | * 38 | * @param array $actions list of action names associated to action references 39 | */ 40 | public function __construct(array $actions = []) 41 | { 42 | foreach ($actions as $actionName => $actionReference) { 43 | $this->add($actionName, $actionReference); 44 | } 45 | } 46 | 47 | /** 48 | * Registers action by name in repository 49 | * 50 | * @param string $actionName action name 51 | * @param ActionReferenceInterface $action action reference 52 | */ 53 | public function add($actionName, ActionReferenceInterface $action): void 54 | { 55 | if (array_key_exists($actionName, $this->actions)) { 56 | throw ActionException::actionAlreadyRegistered($actionName); 57 | } 58 | 59 | $this->actions[$actionName] = $action; 60 | } 61 | 62 | /** 63 | * Returns ActionInterface by name 64 | * 65 | * @param string $actionName action name 66 | * 67 | * @return ActionReferenceInterface 68 | */ 69 | public function get(string $actionName): ActionReferenceInterface 70 | { 71 | if (!array_key_exists($actionName, $this->actions)) { 72 | throw ActionException::actionNotFound($actionName); 73 | } 74 | 75 | return $this->actions[$actionName]; 76 | } 77 | 78 | public function getIterator() 79 | { 80 | return new ArrayIterator($this->actions); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Action/ValueObject/Action.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 20.09.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Action\ValueObject; 15 | 16 | /** 17 | * Data Value Object represents action name and action arguments 18 | * 19 | * @author fduch 20 | */ 21 | class Action 22 | { 23 | /** 24 | * Name 25 | * 26 | * @var string 27 | */ 28 | private $name; 29 | 30 | /** 31 | * Arguments 32 | * 33 | * @var array 34 | */ 35 | private $arguments; 36 | 37 | /** 38 | * Action constructor 39 | * 40 | * @param string $name action name 41 | * @param array $arguments action arguments 42 | */ 43 | public function __construct(string $name, array $arguments = []) 44 | { 45 | $this->name = $name; 46 | $this->arguments = $arguments; 47 | } 48 | 49 | /** 50 | * Returns action name 51 | * 52 | * @return string 53 | */ 54 | public function getName(): string 55 | { 56 | return $this->name; 57 | } 58 | 59 | /** 60 | * Returns action arguments 61 | * 62 | * @return array 63 | */ 64 | public function getArguments(): array 65 | { 66 | return $this->arguments; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Actions/TransitionApplier.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 03.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Actions; 14 | 15 | use Exception; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 17 | use Psr\Log\LoggerInterface; 18 | use Symfony\Component\Workflow\Exception\LogicException as WorkflowLogicException; 19 | use Throwable; 20 | 21 | /** 22 | * Applies workflow transitions 23 | */ 24 | class TransitionApplier 25 | { 26 | /** 27 | * Logger 28 | * 29 | * @var LoggerInterface 30 | */ 31 | private $logger; 32 | 33 | /** 34 | * TransitionApplier constructor. 35 | * 36 | * @param LoggerInterface $logger logger 37 | */ 38 | public function __construct(LoggerInterface $logger) 39 | { 40 | $this->logger = $logger; 41 | } 42 | 43 | /** 44 | * Applies single transition 45 | * 46 | * @param WorkflowContext $workflowContext workflow context 47 | * @param string $transition transition to be applied 48 | */ 49 | public function applyTransition(WorkflowContext $workflowContext, string $transition): void 50 | { 51 | $this->applyTransitions($workflowContext, [$transition]); 52 | } 53 | 54 | /** 55 | * Applies list of transitions 56 | * 57 | * @param WorkflowContext $workflowContext workflow context 58 | * @param array $transitions list of transitions to be applied 59 | * @param bool $cascade if this flag is set all the available transitions should be applied (it 60 | * may be cascade); otherwise the first applied transition breaks execution 61 | */ 62 | public function applyTransitions(WorkflowContext $workflowContext, array $transitions = [], bool $cascade = false): void 63 | { 64 | if (!$transitions) { 65 | return; 66 | } 67 | 68 | $workflow = $workflowContext->getWorkflow(); 69 | $subject = $workflowContext->getSubject(); 70 | $loggerContext = $workflowContext->getLoggerContext(); 71 | 72 | $this->logger->debug('Resolved workflow for subject', $loggerContext); 73 | 74 | $applied = false; 75 | foreach ($transitions as $transition) { 76 | try { 77 | // We do not call Workflow:can method here due to performance reasons in order to prevent 78 | // execution of all the doCan-listeners (guards) in case when transition can be applied. 79 | // Therefore we catch LogicException and interpreting it as case when transition can not be applied 80 | $workflow->apply($subject, $transition); 81 | $this->logger->info(sprintf('Workflow successfully applied transition "%s"', $transition), $loggerContext); 82 | $applied = true; 83 | if (!$cascade) { 84 | break; 85 | } 86 | } catch (WorkflowLogicException $e) { 87 | // transition cannot be applied because it is not allowed 88 | $this->logger->info( 89 | sprintf('Workflow transition "%s" cannot be applied due to it is not allowed', $transition), 90 | $loggerContext 91 | ); 92 | } catch (Exception $e) { 93 | $this->logger->error( 94 | sprintf('Workflow cannot apply transition "%s" due to exception. Details: %s', $transition, $e->getMessage()), 95 | $loggerContext 96 | ); 97 | throw $e; 98 | } catch (Throwable $e) { 99 | $this->logger->critical( 100 | sprintf('Workflow cannot apply transition "%s" due to error. Details: %s', $transition, $e->getMessage()), 101 | $loggerContext 102 | ); 103 | throw $e; 104 | } 105 | } 106 | 107 | if (!$applied) { 108 | $this->logger->warning('All transitions to apply are not allowed', $loggerContext); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Command/ExecuteActionCommand.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 28.07.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Command; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Executor as ActionExecutor; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject\SubjectManipulator; 18 | use Symfony\Component\Console\Command\Command; 19 | use Symfony\Component\Console\Input\InputInterface; 20 | use Symfony\Component\Console\Input\InputOption; 21 | use Symfony\Component\Console\Output\OutputInterface; 22 | use Symfony\Component\Workflow\Registry as WorkflowRegistry; 23 | 24 | /** 25 | * Console command executes action 26 | * 27 | * Useful for scheduled execution 28 | */ 29 | class ExecuteActionCommand extends Command 30 | { 31 | /** 32 | * Command name 33 | */ 34 | protected static $defaultName = 'workflow:action:execute'; 35 | 36 | /** 37 | * Action executor 38 | * 39 | * @var ActionExecutor 40 | */ 41 | private $actionExecutor; 42 | 43 | /** 44 | * Workflow registry 45 | * 46 | * @var WorkflowRegistry 47 | */ 48 | private $workflowRegistry; 49 | 50 | /** 51 | * Subject manipulator 52 | * 53 | * @var SubjectManipulator 54 | */ 55 | private $subjectManipulator; 56 | 57 | /** 58 | * ExecuteActionCommand constructor. 59 | * 60 | * @param ActionExecutor $actionExecutor 61 | * @param WorkflowRegistry $workflowRegistry 62 | * @param SubjectManipulator $subjectManipulator 63 | */ 64 | public function __construct(ActionExecutor $actionExecutor, WorkflowRegistry $workflowRegistry, SubjectManipulator $subjectManipulator) 65 | { 66 | $this->actionExecutor = $actionExecutor; 67 | $this->workflowRegistry = $workflowRegistry; 68 | $this->subjectManipulator = $subjectManipulator; 69 | 70 | parent::__construct(); 71 | } 72 | 73 | 74 | /** 75 | * {@inheritdoc} 76 | */ 77 | protected function configure() 78 | { 79 | $this 80 | ->setDefinition(array( 81 | new InputOption( 82 | 'action', 83 | null, 84 | InputOption::VALUE_REQUIRED, 85 | 'Action name should be executed' 86 | ), 87 | new InputOption( 88 | 'arguments', 89 | null, 90 | InputOption::VALUE_REQUIRED, 91 | 'Json-encoded list of action parameters' 92 | ), 93 | new InputOption( 94 | 'workflow', 95 | 'w', 96 | InputOption::VALUE_REQUIRED, 97 | 'Name of the current workflow' 98 | ), 99 | new InputOption( 100 | 'subjectId', 101 | 'sid', 102 | InputOption::VALUE_REQUIRED, 103 | 'Id of the workflow subject' 104 | ), 105 | new InputOption( 106 | 'subjectClass', 107 | null, 108 | InputOption::VALUE_REQUIRED, 109 | 'FQCN of the workflow subject' 110 | ) 111 | )) 112 | ->setDescription('Execute action command') 113 | ->setHelp(<<%command.name% executes action by name with parameters specified 115 | EOT 116 | ); 117 | } 118 | 119 | /** 120 | * Tries to execute action specified 121 | * 122 | * {@inheritdoc} 123 | */ 124 | protected function execute(InputInterface $input, OutputInterface $output) 125 | { 126 | $actionName = $input->getOption('action'); 127 | $encodedParameters = $input->getOption('arguments'); 128 | $parameters = json_decode($encodedParameters); 129 | 130 | $workflowName = $input->getOption('workflow'); 131 | $subjectClass = $input->getOption('subjectClass'); 132 | $subjectId = $input->getOption('subjectId'); 133 | 134 | $subject = $this->subjectManipulator->getSubjectFromDomain($subjectClass, $subjectId); 135 | $workflowContext = new WorkflowContext($this->workflowRegistry->get($subject, $workflowName), $subject, $subjectId); 136 | 137 | $this->actionExecutor->execute($workflowContext, $actionName, $parameters); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/CheckSubjectManipulatorConfigPass.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 08.08.16 10 | */ 11 | 12 | namespace Gtt\Bundle\WorkflowExtensionsBundle\DependencyInjection\Compiler; 13 | 14 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\RuntimeException; 15 | use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; 16 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 17 | use Symfony\Component\DependencyInjection\ContainerBuilder; 18 | use Symfony\Component\DependencyInjection\Reference; 19 | 20 | /** 21 | * Checks that all necessary options for scheduler are set for classes supported by corresponding workflows 22 | */ 23 | class CheckSubjectManipulatorConfigPass implements CompilerPassInterface 24 | { 25 | /** 26 | * Workflow id prefix used in main workflow bundle 27 | */ 28 | public const WORKFLOW_ID_PREFIX = 'workflow.'; 29 | 30 | /** 31 | * Name of the method used to register workflows in registry 32 | */ 33 | public const WORKFLOW_REGISTRY_ADD_WORKFLOW_METHOD_NAME = "add"; 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function process(ContainerBuilder $container) 39 | { 40 | if (false === $container->hasDefinition('workflow.registry')) { 41 | return; 42 | } 43 | 44 | if (false === $container->hasDefinition('gtt.workflow.transition_scheduler')) { 45 | return; 46 | } 47 | 48 | $subjectClassesWithSubjectFromDomain = $container->getParameter('gtt.workflow.subject_classes_with_subject_from_domain'); 49 | $workflowsWithScheduling = $container->getParameter('gtt.workflow.workflows_with_scheduling'); 50 | 51 | $registryDefinition = $container->getDefinition('workflow.registry'); 52 | foreach ($registryDefinition->getMethodCalls() as [$call, $callArgs]) { 53 | if ($call === self::WORKFLOW_REGISTRY_ADD_WORKFLOW_METHOD_NAME) { 54 | if (count($callArgs) !== 2 || !$callArgs[0] instanceof Reference || !is_string($callArgs[1])) { 55 | throw new RuntimeException( 56 | sprintf( 57 | 'Workflow registry service have unsupported signature for "%s" method', 58 | self::WORKFLOW_REGISTRY_ADD_WORKFLOW_METHOD_NAME 59 | ) 60 | ); 61 | } 62 | 63 | /** @var Reference $workflowReference */ 64 | [$workflowReference, $workflowSupportedClass] = $callArgs; 65 | 66 | $workflowIdWithPrefix = (string) $workflowReference; 67 | if (strpos($workflowIdWithPrefix, self::WORKFLOW_ID_PREFIX) !== 0) { 68 | throw new RuntimeException( 69 | sprintf( 70 | "Workflow registry works with workflow id '%s'in unsupported format", 71 | $workflowIdWithPrefix 72 | ) 73 | ); 74 | } 75 | 76 | $workflowId = substr($workflowIdWithPrefix, strlen(self::WORKFLOW_ID_PREFIX)); 77 | 78 | if (\in_array($workflowId, $workflowsWithScheduling) && 79 | !\in_array(ltrim($workflowSupportedClass, "\\"), $subjectClassesWithSubjectFromDomain)) { 80 | throw new InvalidConfigurationException( 81 | sprintf( 82 | 'Workflow "%s" configured to use scheduler so all the supported subject classes for it'. 83 | 'must be configured with "subject_from_domain" option under "subject_manipulator". '. 84 | 'This option for "%s" class is missing.', 85 | $workflowId, 86 | $workflowSupportedClass 87 | ) 88 | ); 89 | } 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/DependencyInjection/Enum/ActionArgumentTypes.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 20.09.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\DependencyInjection\Enum; 15 | 16 | /** 17 | * Holds available argument types for action arguments 18 | * 19 | * @author fduch 20 | */ 21 | class ActionArgumentTypes 22 | { 23 | /** 24 | * Scalar value 25 | */ 26 | public const TYPE_SCALAR = 'scalar'; 27 | 28 | /** 29 | * Expression that will be executed and result will be treated as action argument 30 | * Result must be scalar or non-associate array 31 | */ 32 | public const TYPE_EXPRESSION = 'expression'; 33 | 34 | /** 35 | * Non-associative array of arguments of other types 36 | */ 37 | public const TYPE_ARRAY = 'array'; 38 | } 39 | -------------------------------------------------------------------------------- /src/Entity/Repository/ScheduledJobRepository.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 01.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Entity\Repository; 14 | 15 | use Doctrine\ORM\EntityRepository; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\Entity\ScheduledJob; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\NonUniqueReschedulabeJobFoundException; 18 | use JMS\JobQueueBundle\Entity\Job; 19 | 20 | /** 21 | * ScheduledJobRepository 22 | */ 23 | class ScheduledJobRepository extends EntityRepository 24 | { 25 | /** 26 | * Finds ScheduledJob by original Job can be scheduled earlier 27 | * 28 | * @param $originalJob $originalJob original job 29 | * 30 | * @return ScheduledJob|null 31 | */ 32 | public function findScheduledJobToReschedule(Job $originalJob) 33 | { 34 | // fetching scheduled job that was not started before - it can be rescheduled 35 | $queryString = <<<'QUERY' 36 | SELECT sj FROM WorkflowExtensionsBundle:ScheduledJob sj 37 | JOIN sj.job j 38 | WHERE 39 | j.state in (:stateNew, :statePending) AND 40 | j.command = :command AND 41 | j.args = :args AND 42 | sj.reschedulable = 1 43 | QUERY; 44 | 45 | /** @var ScheduledJob[] $scheduledJobsToReschedule */ 46 | $scheduledJobsToReschedule = $this->_em 47 | ->createQuery($queryString) 48 | ->setParameters([ 49 | 'stateNew' => Job::STATE_NEW, 50 | 'statePending' => Job::STATE_PENDING, 51 | 'command' => $originalJob->getCommand(), 52 | 'args' => json_encode($originalJob->getArgs()) 53 | ]) 54 | ->getResult() 55 | ; 56 | 57 | if (!$scheduledJobsToReschedule) { 58 | return null; 59 | } 60 | 61 | if (\count($scheduledJobsToReschedule) > 1) { 62 | // since there is normally only one scheduled pending/new job here 63 | // (because an attempt to schedule duplicate job raises rescheduling of the first one) 64 | // we throwing exception in case of several results here 65 | // TODO probably we need support several jobs for the same transition, workflow and subject scheduled for different times? 66 | $duplicateReschedulableJobsIds = []; 67 | foreach ($scheduledJobsToReschedule as $scheduledJobToReschedule) { 68 | $duplicateReschedulableJobsIds[] = $scheduledJobToReschedule->getJob()->getId(); 69 | } 70 | 71 | throw new NonUniqueReschedulabeJobFoundException($originalJob, $duplicateReschedulableJobsIds); 72 | } 73 | 74 | return reset($scheduledJobsToReschedule); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Entity/ScheduledJob.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 27.07.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Entity; 14 | 15 | use Doctrine\ORM\Mapping as ORM; 16 | use JMS\JobQueueBundle\Entity\Job; 17 | 18 | /** 19 | * Persists information about scheduled transition (as jms Job) for specified workflow and subject 20 | * and allows to fetch it in order to manipulate or re-schedule 21 | * @see Job 22 | * 23 | * @ORM\Entity(repositoryClass = "Gtt\Bundle\WorkflowExtensionsBundle\Entity\Repository\ScheduledJobRepository") 24 | * 25 | * @ORM\Table(name = "gtt_workflow_scheduled_job") 26 | * 27 | * (c) fduch 28 | */ 29 | class ScheduledJob 30 | { 31 | /** 32 | * Autogenerated id 33 | * 34 | * @var int 35 | * 36 | * @ORM\Id 37 | * @ORM\GeneratedValue(strategy = "AUTO") 38 | * @ORM\Column(type = "integer", options = {"unsigned": true}) 39 | */ 40 | private $id; 41 | 42 | /** 43 | * Reschedulable or not 44 | * 45 | * @var boolean 46 | * 47 | * @ORM\Column(type = "boolean", columnDefinition="COMMENT 'Defines whether related job can be rescheduled or not'") 48 | */ 49 | private $reschedulable = true; 50 | 51 | /** 52 | * Related jms Job to be executed later 53 | * 54 | * @var Job 55 | * 56 | * @ORM\OneToOne(targetEntity="JMS\JobQueueBundle\Entity\Job") 57 | * @ORM\JoinColumn(name="jms_job_id", referencedColumnName="id", nullable=false) 58 | */ 59 | private $job; 60 | 61 | /** 62 | * ScheduledJob constructor. 63 | * 64 | * @param Job $transitionTriggerJob transition trigger job 65 | * @param boolean $reschedulable reschedulable or not 66 | */ 67 | public function __construct(Job $transitionTriggerJob, bool $reschedulable = true) 68 | { 69 | $this->reschedulable = $reschedulable; 70 | $this->job = $transitionTriggerJob; 71 | } 72 | 73 | 74 | /** 75 | * Returns id 76 | * 77 | * @return int 78 | */ 79 | public function getId() 80 | { 81 | return $this->id; 82 | } 83 | 84 | /** 85 | * Return true if scheduled job can be rescheduled 86 | * 87 | * @return boolean 88 | */ 89 | public function isReschedulable(): bool 90 | { 91 | return $this->reschedulable; 92 | } 93 | 94 | /** 95 | * Sets reschedulable flag 96 | * 97 | * @param boolean $reschedulable 98 | */ 99 | public function setReschedulable(bool $reschedulable = true): void 100 | { 101 | $this->reschedulable = $reschedulable; 102 | } 103 | 104 | /** 105 | * Returns Job 106 | * 107 | * @return Job 108 | */ 109 | public function getJob(): Job 110 | { 111 | return $this->job; 112 | } 113 | 114 | /** 115 | * Sets Job 116 | * 117 | * @param Job $job Related jms job 118 | */ 119 | public function setJob(Job $job): void 120 | { 121 | $this->job = $job; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Exception/ActionException.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 02.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Exception; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Utils\ArrayUtils; 16 | 17 | /** 18 | * Action reference runtime exception 19 | */ 20 | class ActionException extends RuntimeException 21 | { 22 | public static function containerUnavailableForServiceMethodReference(string $serviceId): self 23 | { 24 | return new static( 25 | sprintf( 26 | 'Cannot retrieve object for action reference for service id "%s" due to ContainerInterface instance is not set', 27 | $serviceId 28 | ) 29 | ); 30 | } 31 | 32 | public static function actionAlreadyRegistered(string $actionName): self 33 | { 34 | return new static(sprintf('Action reference with name "%s" is already registered', $actionName)); 35 | } 36 | 37 | public static function actionNotFound(string $actionName): self 38 | { 39 | return new static(sprintf('Action reference with name "%s" is not found in action registry', $actionName)); 40 | } 41 | 42 | public static function actionExpressionArgumentIsMalformed(string $actionName, string $expression, $expressionResult): self 43 | { 44 | if (is_array($expressionResult) && ArrayUtils::isArrayAssoc($expressionResult)) { 45 | // assoc array 46 | $actualResultDescription = sprintf('associative array "%s"', json_encode($expressionResult)); 47 | } else { 48 | // non scalar value 49 | $actualResultDescription = gettype($expressionResult); 50 | } 51 | 52 | return new static( 53 | sprintf('Action reference with name "%s" has expression-defined argument "%s" which'. 54 | ' result must be scalar or non-associative array. Actual result is %s', 55 | $actionName, 56 | $expression, 57 | $actualResultDescription 58 | ) 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 02.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Exception; 14 | 15 | /** 16 | * Bundle-level invalid argument exception 17 | */ 18 | class InvalidArgumentException extends \InvalidArgumentException implements WorkflowExceptionInterface 19 | { 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/Exception/NonUniqueReschedulabeJobFoundException.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 03.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Exception; 14 | 15 | use JMS\JobQueueBundle\Entity\Job; 16 | use Throwable; 17 | 18 | /** 19 | * Exception for cases whe several reschedulable jobs for workflow transition and subject found 20 | */ 21 | class NonUniqueReschedulabeJobFoundException extends \RuntimeException implements WorkflowExceptionInterface 22 | { 23 | /** 24 | * NonUniqueReschedulabeJobFoundException constructor 25 | * 26 | * @param Job $originalJob duplicated job 27 | * @param array $jobIds list of ids of reschedulable jms jobs 28 | * @param int $code exception code 29 | * @param Throwable|null $previous previous exception 30 | */ 31 | public function __construct(Job $originalJob, array $jobIds, int $code = 0, Throwable $previous = null) 32 | { 33 | $message = sprintf( 34 | "There are several scheduled '%s' jobs (id's: '%s') available for rescheduling found for command '%s' with args '%s'", 35 | Job::class, 36 | implode(", ", $jobIds), 37 | $originalJob->getCommand(), 38 | json_encode($originalJob->getArgs()) 39 | ); 40 | parent::__construct($message, $code, $previous); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 02.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Exception; 14 | 15 | /** 16 | * Bundle-level runtime exception 17 | */ 18 | class RuntimeException extends \RuntimeException implements WorkflowExceptionInterface 19 | { 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/Exception/SubjectIdRetrievingException.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 03.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Exception; 14 | 15 | /** 16 | * Subject id retrieving faults exception 17 | */ 18 | class SubjectIdRetrievingException extends SubjectManipulatorException 19 | { 20 | /** 21 | * Creates exception instance in case of configuration is not found for subject 22 | * 23 | * @param string $subjectClass subject class 24 | * 25 | * @return static 26 | */ 27 | public static function expressionNotFound(string $subjectClass): self 28 | { 29 | return new static(sprintf('Cannot find expression for retrieving subject id for class "%s"', $subjectClass)); 30 | } 31 | 32 | /** 33 | * Creates exception instance in case of subject specified is not an object 34 | * 35 | * @param mixed $subject subject 36 | * 37 | * @return static 38 | */ 39 | public static function subjectIsNotAnObject($subject): self 40 | { 41 | return new static(sprintf('Subject manipulator cannot operate with non-object subjects. "%s" given', gettype($subject))); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Exception/SubjectManipulatorException.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 03.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Exception; 14 | 15 | /** 16 | * Subject manipulator faults exception 17 | */ 18 | class SubjectManipulatorException extends \RuntimeException implements WorkflowExceptionInterface 19 | { 20 | /** 21 | * Creates exception instance in case of configurations for subject are already set 22 | * 23 | * @param string $subjectClass subject class 24 | * 25 | * @return static 26 | */ 27 | public static function subjectConfigIsAlreadySet(string $subjectClass): self 28 | { 29 | return new static(sprintf('Subject manipulator config is already set for "%s"', $subjectClass)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Exception/SubjectRetrievingFromDomainException.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 03.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Exception; 14 | 15 | /** 16 | * Subject from domain retrieving faults exception 17 | */ 18 | class SubjectRetrievingFromDomainException extends SubjectManipulatorException 19 | { 20 | /** 21 | * Creates exception instance in case of configuration is not found for subject 22 | * 23 | * @param string $subjectClass subject class 24 | * 25 | * @return static 26 | */ 27 | public static function expressionNotFound(string $subjectClass): self 28 | { 29 | return new static( 30 | sprintf('Cannot find expression for retrieving subject from domain for class "%s". '. 31 | 'Probably you forget to set value for "subject_from_domain" in bundle configuration', 32 | $subjectClass) 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Exception/UnsupportedGuardEventException.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Exception; 14 | 15 | /** 16 | * Exception for cases when workflow guard event is not supported 17 | */ 18 | class UnsupportedGuardEventException extends \RuntimeException implements WorkflowExceptionInterface 19 | { 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/Exception/UnsupportedTriggerEventException.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Exception; 14 | 15 | /** 16 | * Exception for cases when workflow trigger event is not supported 17 | */ 18 | class UnsupportedTriggerEventException extends \RuntimeException implements WorkflowExceptionInterface 19 | { 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/Exception/WorkflowExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Exception; 14 | 15 | use Throwable; 16 | 17 | /** 18 | * Base bundle exception marker 19 | */ 20 | interface WorkflowExceptionInterface extends Throwable 21 | { 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/ExpressionLanguage/ContainerAwareExpressionLanguage.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 19.07.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\ExpressionLanguage; 14 | 15 | use Psr\Cache\CacheItemPoolInterface; 16 | use Psr\Container\ContainerInterface; 17 | use Symfony\Component\DependencyInjection\ExpressionLanguage; 18 | use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface; 19 | 20 | /** 21 | * Extends DI Expression Language with container variable holds ContainerInterface implementation 22 | */ 23 | class ContainerAwareExpressionLanguage extends ExpressionLanguage 24 | { 25 | /** 26 | * DI ContainerInterface implementation 27 | * 28 | * @var ContainerInterface 29 | */ 30 | private $container; 31 | 32 | /** 33 | * ContainerAwareExpressionLanguage constructor 34 | * 35 | * @param ContainerInterface $container DI Container 36 | * @param CacheItemPoolInterface $cache cache 37 | * @param ExpressionFunctionProviderInterface[] $providers providers list 38 | */ 39 | public function __construct(ContainerInterface $container, CacheItemPoolInterface $cache = null, array $providers = array()) 40 | { 41 | $this->container = $container; 42 | parent::__construct($cache, $providers); 43 | } 44 | 45 | /** 46 | * Compile an expression with ContainerInterface context 47 | * 48 | * {@inheritdoc} 49 | */ 50 | public function compile($expression, $names = array()) 51 | { 52 | return parent::compile($expression, array_unique(array_merge($names, ["container"]))); 53 | } 54 | 55 | /** 56 | * Evaluate an expression with ContainerInterface context 57 | * 58 | * {@inheritdoc} 59 | */ 60 | public function evaluate($expression, $values = array()) 61 | { 62 | return parent::evaluate($expression, $values + ["container" => $this->container]); 63 | } 64 | 65 | /** 66 | * Parse an expression with ContainerInterface context 67 | * 68 | * {@inheritdoc} 69 | */ 70 | public function parse($expression, $names) 71 | { 72 | return parent::parse($expression, array_unique(array_merge($names, ["container"]))); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Guard/ExpressionGuard.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 14.07.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Guard; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\UnsupportedGuardEventException; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject\SubjectManipulator; 18 | use Psr\Log\LoggerInterface; 19 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 20 | use Symfony\Component\Workflow\Event\GuardEvent; 21 | use Symfony\Component\Workflow\Registry as WorkflowRegistry; 22 | use Throwable; 23 | 24 | /** 25 | * Listener for workflow guard events that can block or allow transition with specified expression 26 | */ 27 | class ExpressionGuard 28 | { 29 | /** 30 | * Expression language 31 | * 32 | * @var ExpressionLanguage 33 | */ 34 | private $language; 35 | 36 | /** 37 | * Subject manipulator 38 | * 39 | * @var SubjectManipulator 40 | */ 41 | private $subjectManipulator; 42 | 43 | /** 44 | * Workflow registry 45 | * 46 | * @var WorkflowRegistry 47 | */ 48 | private $workflowRegistry; 49 | 50 | /** 51 | * Logger 52 | * 53 | * @var LoggerInterface 54 | */ 55 | private $logger; 56 | 57 | /** 58 | * Maps guard event name to expression and workflow used to block or allow transition 59 | * 60 | * @var array 61 | */ 62 | private $supportedEventsConfig = []; 63 | 64 | /** 65 | * ExpressionGuard constructor 66 | * 67 | * @param ExpressionLanguage $language expression language 68 | * @param SubjectManipulator $subjectManipulator subject manipulator 69 | * @param WorkflowRegistry $workflowRegistry workflow registry 70 | * @param LoggerInterface $logger logger 71 | */ 72 | public function __construct( 73 | ExpressionLanguage $language, 74 | SubjectManipulator $subjectManipulator, 75 | WorkflowRegistry $workflowRegistry, 76 | LoggerInterface $logger 77 | ) { 78 | $this->language = $language; 79 | $this->subjectManipulator = $subjectManipulator; 80 | $this->workflowRegistry = $workflowRegistry; 81 | $this->logger = $logger; 82 | } 83 | 84 | /** 85 | * Registers guard expression 86 | * 87 | * @param string $eventName guard event name 88 | * @param string $workflowName workflow name 89 | * @param string $expression guard expression 90 | */ 91 | public function registerGuardExpression(string $eventName, string $workflowName, string $expression): void 92 | { 93 | $this->supportedEventsConfig[$eventName] = [$workflowName, $expression]; 94 | } 95 | 96 | /** 97 | * Blocks or allows workflow transitions by guard expression evaluation 98 | * 99 | * @param GuardEvent $event 100 | * @param string $eventName 101 | * 102 | * @throws \Exception in case of failure 103 | */ 104 | public function guardTransition(GuardEvent $event, string $eventName): void 105 | { 106 | if (!array_key_exists($eventName, $this->supportedEventsConfig)) { 107 | throw new UnsupportedGuardEventException(sprintf("Cannot find registered guard event by name '%s'", $eventName)); 108 | } 109 | 110 | [$workflowName, $expression] = $this->supportedEventsConfig[$eventName]; 111 | $subject = $event->getSubject(); 112 | $workflowContext = new WorkflowContext( 113 | $this->workflowRegistry->get($subject, $workflowName), 114 | $subject, 115 | $this->subjectManipulator->getSubjectId($subject) 116 | ); 117 | $loggerContext = $workflowContext->getLoggerContext(); 118 | 119 | $errorMessage = null; 120 | 121 | try { 122 | $expressionResult = $this->language->evaluate($expression, ['event' => $event]); 123 | } catch (Throwable $e) { 124 | $errorMessage = sprintf( 125 | "Guard expression '%s' for guard event '%s' cannot be evaluated. Details: '%s'", 126 | $expression, 127 | $eventName, 128 | $e->getMessage() 129 | ); 130 | 131 | $this->logger->error($errorMessage, $loggerContext); 132 | 133 | // simply skipping processing here without blocking transition 134 | return; 135 | } 136 | 137 | if (!is_bool($expressionResult)) { 138 | $this->logger->debug( 139 | sprintf("Guard expression '%s' for guard event '%s' evaluated with non-boolean result". 140 | " and will be converted to boolean", $expression, $eventName), 141 | $loggerContext 142 | ); 143 | 144 | $expressionResult = (bool) $expressionResult; 145 | } 146 | 147 | $event->setBlocked($expressionResult); 148 | 149 | if ($expressionResult) { 150 | $this->logger->debug( 151 | sprintf("Transition '%s' is blocked by guard expression '%s' for guard event '%s'", 152 | $event->getTransition()->getName(), 153 | $expression, 154 | $eventName 155 | ), 156 | $loggerContext 157 | ); 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/MarkingStore/OrmPersistentMarkingStore.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 02.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\MarkingStore; 14 | 15 | use Doctrine\Bundle\DoctrineBundle\Registry; 16 | use Symfony\Component\Workflow\Marking; 17 | use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; 18 | 19 | /** 20 | * Decorates original marking store with ORM persisting feature during mutating the subject marking 21 | */ 22 | class OrmPersistentMarkingStore implements MarkingStoreInterface 23 | { 24 | /** 25 | * Origin marking store 26 | * 27 | * @var MarkingStoreInterface 28 | */ 29 | private $originMarkingStore; 30 | 31 | /** 32 | * Doctrine registry 33 | * 34 | * @var Registry 35 | */ 36 | private $doctrineRegistry; 37 | 38 | /** 39 | * OrmPersistentMarkingStore constructor. 40 | * 41 | * @param MarkingStoreInterface $originMarkingStore origin marking store 42 | * @param Registry $doctrineRegistry doctrine registry 43 | */ 44 | public function __construct(MarkingStoreInterface $originMarkingStore, Registry $doctrineRegistry) 45 | { 46 | $this->doctrineRegistry = $doctrineRegistry; 47 | $this->originMarkingStore = $originMarkingStore; 48 | } 49 | 50 | /** 51 | * {@inheritdoc} 52 | */ 53 | public function getMarking($subject) 54 | { 55 | return $this->originMarkingStore->getMarking($subject); 56 | } 57 | 58 | /** 59 | * Updates subject's marking and persists it using ORM 60 | * 61 | * {@inheritdoc} 62 | */ 63 | public function setMarking($subject, Marking $marking) 64 | { 65 | $this->originMarkingStore->setMarking($subject, $marking); 66 | 67 | $manager = $this->doctrineRegistry->getManagerForClass(get_class($subject)); 68 | // for DEFERRED_EXPLICIT change tracking policies we also persisting subject here 69 | $manager->persist($subject); 70 | $manager->flush(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Resources/config/actions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | applyTransitions 17 | 18 | 19 | Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ActionReferenceInterface::TYPE_WORKFLOW 20 | 21 | 22 | 23 | 24 | 25 | 26 | applyTransition 27 | 28 | 29 | Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ActionReferenceInterface::TYPE_WORKFLOW 30 | 31 | 32 | 33 | 34 | 35 | 38 | 39 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /src/Resources/config/guard.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Resources/config/scheduler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Resources/config/subject_manipulator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Resources/config/triggers.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 39 | 40 | 41 | 42 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/Schedule/ActionScheduler.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 28.07.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Schedule; 14 | 15 | use Carbon\Carbon; 16 | use Doctrine\Common\Persistence\ObjectManager; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Entity\Repository\ScheduledJobRepository; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\Entity\ScheduledJob; 19 | use Gtt\Bundle\WorkflowExtensionsBundle\Schedule\ValueObject\ScheduledAction; 20 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 21 | use JMS\JobQueueBundle\Entity\Job; 22 | use Psr\Log\LoggerInterface; 23 | 24 | /** 25 | * Schedules action to be executed after some time 26 | */ 27 | class ActionScheduler 28 | { 29 | /** 30 | * Persistance object manager 31 | * 32 | * @var ObjectManager 33 | */ 34 | private $em; 35 | 36 | /** 37 | * Logger 38 | * 39 | * @var LoggerInterface 40 | */ 41 | private $logger; 42 | 43 | /** 44 | * ActionScheduler constructor. 45 | * 46 | * @param ObjectManager $em entity manager 47 | * @param LoggerInterface $logger logger 48 | */ 49 | public function __construct(ObjectManager $em, LoggerInterface $logger) 50 | { 51 | $this->em = $em; 52 | $this->logger = $logger; 53 | } 54 | 55 | /** 56 | * Schedules actions 57 | * 58 | * @param WorkflowContext $workflowContext workflow context 59 | * @param ScheduledAction $scheduledAction scheduled action 60 | */ 61 | public function scheduleAction(WorkflowContext $workflowContext, ScheduledAction $scheduledAction): void 62 | { 63 | /** @var ScheduledJobRepository $scheduledJobRepository */ 64 | $scheduledJobRepository = $this->em->getRepository(ScheduledJob::class); 65 | 66 | $jobToSchedule = new Job('workflow:action:execute', 67 | [ 68 | '--action=' . $scheduledAction->getName(), 69 | '--arguments=' . json_encode($scheduledAction->getArguments()), 70 | '--workflow=' . $workflowContext->getWorkflow()->getName(), 71 | '--subjectClass=' . get_class($workflowContext->getSubject()), 72 | '--subjectId=' . $workflowContext->getSubjectId(), 73 | ] 74 | ); 75 | 76 | $scheduledJob = null; 77 | if ($scheduledAction->isReschedulable()) { 78 | $scheduledJob = $scheduledJobRepository->findScheduledJobToReschedule($jobToSchedule); 79 | } 80 | 81 | if ($scheduledJob) { 82 | // the job was already scheduled but not executed. Now we need to reschedule it 83 | $this->rescheduleActionJob($scheduledAction, $scheduledJob, $workflowContext); 84 | } else { 85 | // creating new jms job to trigger action 86 | $this->scheduleActionJob($scheduledAction, $jobToSchedule, $workflowContext); 87 | } 88 | } 89 | 90 | /** 91 | * Reschedules already scheduled action 92 | * 93 | * @param ScheduledAction $scheduledAction scheduled action 94 | * @param Job $jobToSchedule job to schedule 95 | * @param WorkflowContext $workflowContext workflow context 96 | */ 97 | private function scheduleActionJob( 98 | ScheduledAction $scheduledAction, 99 | Job $jobToSchedule, 100 | WorkflowContext $workflowContext 101 | ): void { 102 | $executionDate = $this->getActionExecutionDate($scheduledAction); 103 | $jobToSchedule->setExecuteAfter($executionDate); 104 | 105 | $scheduledJob = new ScheduledJob($jobToSchedule, $scheduledAction->isReschedulable()); 106 | 107 | $this->em->persist($jobToSchedule); 108 | $this->em->persist($scheduledJob); 109 | 110 | $this->em->flush(); 111 | 112 | $this->logger->info( 113 | sprintf( 114 | "Workflow successfully scheduled action '%s' with parameters '%s'", 115 | $scheduledAction->getName(), 116 | json_encode($scheduledAction->getArguments()) 117 | ), 118 | $workflowContext->getLoggerContext() 119 | ); 120 | } 121 | 122 | /** 123 | * Reschedules already scheduled action 124 | * 125 | * @param ScheduledAction $scheduledAction scheduled action 126 | * @param ScheduledJob $scheduledJob scheduled job for action 127 | * @param WorkflowContext $workflowContext workflow context 128 | */ 129 | private function rescheduleActionJob( 130 | ScheduledAction $scheduledAction, 131 | ScheduledJob $scheduledJob, 132 | WorkflowContext $workflowContext 133 | ): void { 134 | $actionJob = $scheduledJob->getJob(); 135 | $actionJob->setExecuteAfter($this->getActionExecutionDate($scheduledAction)); 136 | 137 | // since jms Job states DEFERRED_EXPLICIT change tracking policy we should explicitly persist entity now 138 | $this->em->persist($actionJob); 139 | $this->em->flush(); 140 | 141 | $this->logger->info( 142 | sprintf("Workflow successfully rescheduled action '%s' with parameters '%s'", 143 | $scheduledAction->getName(), 144 | json_encode($scheduledAction->getArguments()) 145 | ), 146 | $workflowContext->getLoggerContext() 147 | ); 148 | } 149 | 150 | /** 151 | * Calculates execution date date for scheduled action 152 | * 153 | * @param ScheduledAction $scheduledAction scheduled action 154 | * 155 | * @return \DateTime 156 | */ 157 | private function getActionExecutionDate(ScheduledAction $scheduledAction): \DateTime 158 | { 159 | $executionDate = Carbon::now(); 160 | $executionDate->add($scheduledAction->getOffset()); 161 | 162 | return $executionDate; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Schedule/ValueObject/ScheduledAction.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 20.09.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Schedule\ValueObject; 15 | 16 | use DateInterval; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\ValueObject\Action; 18 | 19 | /** 20 | * Data Value Object represents scheduled action 21 | * 22 | * @author fduch 23 | */ 24 | class ScheduledAction extends Action 25 | { 26 | /** 27 | * Offset for transition 28 | * 29 | * @var DateInterval 30 | */ 31 | private $offset; 32 | 33 | /** 34 | * Flag defines current scheduled action can be rescheduled or not 35 | * 36 | * @var boolean 37 | */ 38 | private $isReschedulable; 39 | 40 | public function __construct(string $name, array $arguments, string $offset, bool $isReschedulable = false) 41 | { 42 | parent::__construct($name, $arguments); 43 | $this->offset = new DateInterval($offset); 44 | $this->isReschedulable = $isReschedulable; 45 | } 46 | 47 | /** 48 | * Returns offset date interval 49 | * 50 | * @return DateInterval 51 | */ 52 | public function getOffset(): DateInterval 53 | { 54 | return $this->offset; 55 | } 56 | 57 | /** 58 | * @return boolean 59 | */ 60 | public function isReschedulable(): bool 61 | { 62 | return $this->isReschedulable; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Trigger/Event/AbstractActionListener.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Trigger\Event; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\ValueObject\Action; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\DependencyInjection\Enum\ActionArgumentTypes; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\ActionException; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\Schedule\ValueObject\ScheduledAction; 19 | use Gtt\Bundle\WorkflowExtensionsBundle\Utils\ArrayUtils; 20 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 21 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject\SubjectManipulator; 22 | use Psr\Log\LoggerInterface; 23 | use Symfony\Component\EventDispatcher\Event; 24 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 25 | use Symfony\Component\Workflow\Registry; 26 | 27 | /** 28 | * Abstract implementation for all action-related listeners 29 | */ 30 | abstract class AbstractActionListener extends AbstractListener 31 | { 32 | /** 33 | * Expression language for execute expressions with actions 34 | * 35 | * @var ExpressionLanguage 36 | */ 37 | private $actionLanguage; 38 | 39 | /** 40 | * AbstractListener constructor. 41 | * 42 | * @param ExpressionLanguage $subjectRetrieverLanguage subject retriever expression language 43 | * @param SubjectManipulator $subjectManipulator subject manipulator 44 | * @param Registry $workflowRegistry workflow registry 45 | * @param LoggerInterface $logger logger 46 | * @param ExpressionLanguage $actionLanguage action expression language 47 | */ 48 | public function __construct( 49 | ExpressionLanguage $subjectRetrieverLanguage, 50 | SubjectManipulator $subjectManipulator, 51 | Registry $workflowRegistry, 52 | LoggerInterface $logger, 53 | ExpressionLanguage $actionLanguage 54 | ) { 55 | parent::__construct($subjectRetrieverLanguage, $subjectManipulator, $workflowRegistry, $logger); 56 | $this->actionLanguage = $actionLanguage; 57 | } 58 | 59 | /** 60 | * Composes Action instances based on bundle actions config 61 | * 62 | * @param array $actions list of action names associated with action arguments 63 | * @param Event $event current event to be handled 64 | * @param WorkflowContext $workflowContext workflow context 65 | * @param bool $scheduled flag defines whether current action is scheduled one or not 66 | * 67 | * @return ScheduledAction[]|Action[] 68 | */ 69 | protected function prepareActions( 70 | array $actions, 71 | Event $event, 72 | WorkflowContext $workflowContext, 73 | bool $scheduled = false 74 | ): array { 75 | $preparedActions = []; 76 | 77 | foreach ($actions as $actionName => $actionCalls) { 78 | foreach ($actionCalls as $actionCall) { 79 | $actionArguments = $this->resolveActionArguments($actionName, $actionCall['arguments'], $event, $workflowContext); 80 | 81 | if ($scheduled) { 82 | $preparedActions[] = new ScheduledAction($actionName, $actionArguments, $actionCall['offset'], $actionCall['reschedulable']); 83 | } else { 84 | $preparedActions[] = new Action($actionName, $actionArguments); 85 | } 86 | } 87 | } 88 | 89 | return $preparedActions; 90 | } 91 | 92 | /** 93 | * Resolves action arguments 94 | * 95 | * NOTE: the input array can be assoc here (but keys would be replaced with sequences) 96 | * because associativity is validated in Configuration.php already 97 | * (@see \Gtt\Bundle\WorkflowExtensionsBundle\DependencyInjection\Configuration::buildActionArgumentsNode) 98 | * Expression results must be non-assoc since expressions are evaluated in runtime 99 | * 100 | * @param string $actionName action name 101 | * @param array $arguments list of raw action arguments 102 | * @param Event $event event instance 103 | * @param WorkflowContext $workflowContext workflow context 104 | * 105 | * @return array 106 | */ 107 | private function resolveActionArguments( 108 | $actionName, 109 | array $arguments, 110 | Event $event, 111 | WorkflowContext $workflowContext 112 | ): array { 113 | $result = []; 114 | foreach ($arguments as $argument) { 115 | switch ($argument['type']) { 116 | case ActionArgumentTypes::TYPE_SCALAR: 117 | $result[] = $argument['value']; 118 | break; 119 | case ActionArgumentTypes::TYPE_EXPRESSION: 120 | $expressionResult = $this->actionLanguage->evaluate($argument['value'], ['event' => $event, 'workflowContext' => $workflowContext]); 121 | $isNonAssocArrayResult = is_array($expressionResult) && !ArrayUtils::isArrayAssoc($expressionResult); 122 | // expression result should be scalar or non assoc Array 123 | if (!($expressionResult === null || \is_scalar($expressionResult) || $isNonAssocArrayResult)) { 124 | throw ActionException::actionExpressionArgumentIsMalformed($actionName, $argument['value'], $expressionResult); 125 | } 126 | $result[] = $expressionResult; 127 | break; 128 | case ActionArgumentTypes::TYPE_ARRAY: 129 | $result[] = $this->resolveActionArguments($actionName, $argument['value'], $event, $workflowContext); 130 | break; 131 | } 132 | } 133 | 134 | return $result; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Trigger/Event/AbstractListener.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Trigger\Event; 14 | 15 | use Exception; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\UnsupportedTriggerEventException; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject\SubjectManipulator; 19 | use Psr\Log\LoggerInterface; 20 | use Symfony\Component\EventDispatcher\Event; 21 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 22 | use Symfony\Component\Workflow\Registry; 23 | use Throwable; 24 | 25 | /** 26 | * Holds base functionality for all workflow event listeners 27 | */ 28 | abstract class AbstractListener 29 | { 30 | /** 31 | * Holds listener configurations for events to be dispatched by current listener 32 | * 33 | * @var array 34 | */ 35 | protected $supportedEventsConfig = []; 36 | 37 | /** 38 | * Logger 39 | * 40 | * @var LoggerInterface 41 | */ 42 | protected $logger; 43 | 44 | /** 45 | * Expression language for retrieving subject from event 46 | * 47 | * @var ExpressionLanguage 48 | */ 49 | private $subjectRetrieverLanguage; 50 | 51 | /** 52 | * Subject manipulator 53 | * 54 | * @var SubjectManipulator 55 | */ 56 | private $subjectManipulator; 57 | 58 | /** 59 | * Workflow registry 60 | * 61 | * @var Registry 62 | */ 63 | private $workflowRegistry; 64 | 65 | /** 66 | * AbstractListener constructor. 67 | * 68 | * @param ExpressionLanguage $subjectRetrieverLanguage subject retriever expression language 69 | * @param SubjectManipulator $subjectManipulator subject manipulator 70 | * @param Registry $workflowRegistry workflow registry 71 | * @param LoggerInterface $logger logger 72 | */ 73 | public function __construct( 74 | ExpressionLanguage $subjectRetrieverLanguage, 75 | SubjectManipulator $subjectManipulator, 76 | Registry $workflowRegistry, 77 | LoggerInterface $logger 78 | ) { 79 | $this->subjectRetrieverLanguage = $subjectRetrieverLanguage; 80 | $this->subjectManipulator = $subjectManipulator; 81 | $this->workflowRegistry = $workflowRegistry; 82 | $this->logger = $logger; 83 | } 84 | 85 | /** 86 | * Sets configs for event to be dispatched by current listener 87 | * 88 | * @param string $eventName event name 89 | * @param string $workflowName workflow name 90 | * @param string $subjectRetrievingExpression expression used to retrieve subject from event 91 | */ 92 | protected function configureSubjectRetrievingForEvent( 93 | string $eventName, 94 | string $workflowName, 95 | string $subjectRetrievingExpression 96 | ): void { 97 | if (!isset($this->supportedEventsConfig[$eventName])) { 98 | $this->supportedEventsConfig[$eventName] = []; 99 | } 100 | 101 | $this->supportedEventsConfig[$eventName][$workflowName] = [ 102 | 'subject_retrieving_expression' => $subjectRetrievingExpression 103 | ]; 104 | } 105 | 106 | /** 107 | * Dispatches registered event 108 | * 109 | * @param Event $event event 110 | * @param string $eventName event name 111 | */ 112 | final public function dispatchEvent(Event $event, string $eventName): void 113 | { 114 | if (!array_key_exists($eventName, $this->supportedEventsConfig)) { 115 | throw new UnsupportedTriggerEventException(sprintf("Cannot find registered trigger event by name '%s'", $eventName)); 116 | } 117 | 118 | foreach ($this->supportedEventsConfig[$eventName] as $workflowName => $eventConfigForWorkflow) { 119 | $subjectRetrievingExpression = $eventConfigForWorkflow['subject_retrieving_expression']; 120 | $subject = $this->retrieveSubjectFromEvent($event, $eventName, $workflowName, $subjectRetrievingExpression); 121 | if (!$subject) { 122 | continue; 123 | } 124 | 125 | $this->handleEvent( 126 | $eventName, 127 | $event, 128 | $eventConfigForWorkflow, 129 | $this->getWorkflowContext($subject, $workflowName) 130 | ); 131 | } 132 | } 133 | 134 | /** 135 | * Reacts on the event occurred with some activity 136 | * 137 | * @param string $eventName event name 138 | * @param Event $event event instance 139 | * @param array $eventConfigForWorkflow registered config for particular event handling 140 | * @param WorkflowContext $workflowContext workflow context 141 | * 142 | * @return void 143 | */ 144 | abstract protected function handleEvent(string $eventName, Event $event, array $eventConfigForWorkflow, WorkflowContext $workflowContext): void; 145 | 146 | /** 147 | * Allows to execute any listener callback with control of internal errors and exceptions handling. 148 | * For now all the errors and exceptions are logged and rethrown. 149 | * There is an ability to configure exception catching in the future in order to make possible next execution. 150 | * 151 | * @param \Closure $closure closure to be executed safely 152 | * @param string $eventName event name 153 | * @param WorkflowContext $workflowContext workflow context 154 | * @param string $activity description of the current listener activity (required for logging 155 | * purposes) 156 | * 157 | * @throws Exception|Throwable in case of failure 158 | */ 159 | protected function execute( 160 | \Closure $closure, 161 | string $eventName, 162 | WorkflowContext $workflowContext, 163 | string $activity = 'react' 164 | ): void { 165 | try { 166 | $closure(); 167 | } catch (Exception $e) { 168 | $this->logger->error( 169 | sprintf('Cannot %s on event "%s" due to exception. Details: %s', $activity, $eventName, $e->getMessage()), 170 | $workflowContext->getLoggerContext() 171 | ); 172 | throw $e; 173 | } catch (Throwable $e) { 174 | $this->logger->critical( 175 | sprintf('Cannot %s on event "%s" due to error. Details: %s', $activity, $eventName, $e->getMessage()), 176 | $workflowContext->getLoggerContext() 177 | ); 178 | throw $e; 179 | } 180 | } 181 | 182 | /** 183 | * Retrieves workflow subject from event 184 | * 185 | * @param Event $event event to be dispatched 186 | * @param string $eventName event name 187 | * @param string $workflowName workflow 188 | * @param string $subjectRetrievingExpression expression used to retrieve subject from event 189 | * 190 | * @return object|null 191 | */ 192 | private function retrieveSubjectFromEvent(Event $event, string $eventName, string $workflowName, string $subjectRetrievingExpression) 193 | { 194 | try { 195 | /** @var object|mixed $subject */ 196 | $subject = $this->subjectRetrieverLanguage->evaluate($subjectRetrievingExpression, ['event' => $event]); 197 | 198 | if (!\is_object($subject)) { 199 | $error = sprintf( 200 | "Subject retrieving from '%s' event by expression '%s' ended with empty or non-object result", 201 | $eventName, 202 | $subjectRetrievingExpression 203 | ); 204 | $this->logger->error($error, ['workflow' => $workflowName]); 205 | } 206 | 207 | $this->logger->debug(sprintf('Retrieved subject from "%s" event', $eventName), ['workflow' => $workflowName]); 208 | 209 | return $subject; 210 | } catch (Throwable $e) { 211 | $error = sprintf( 212 | "Cannot retrieve subject from event '%s' by evaluating expression '%s'. Error: '%s'. Please check retrieving expression", 213 | $eventName, 214 | $subjectRetrievingExpression, 215 | $e->getMessage() 216 | ); 217 | 218 | $this->logger->error($error, ['workflow' => $workflowName]); 219 | 220 | return null; 221 | } 222 | } 223 | 224 | /** 225 | * Creates workflow context 226 | * 227 | * @param object $subject workflow subject 228 | * @param string $workflowName workflow name 229 | * 230 | * @return WorkflowContext 231 | */ 232 | private function getWorkflowContext($subject, string $workflowName): WorkflowContext 233 | { 234 | $workflowContext = new WorkflowContext( 235 | $this->workflowRegistry->get($subject, $workflowName), 236 | $subject, 237 | $this->subjectManipulator->getSubjectId($subject) 238 | ); 239 | 240 | return $workflowContext; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/Trigger/Event/ActionListener.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Trigger\Event; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Executor as ActionExecutor; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\ValueObject\Action; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject\SubjectManipulator; 19 | use Psr\Log\LoggerInterface; 20 | use Symfony\Component\EventDispatcher\Event; 21 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 22 | use Symfony\Component\Workflow\Registry; 23 | 24 | /** 25 | * Executes action as a result of event fired 26 | */ 27 | class ActionListener extends AbstractActionListener 28 | { 29 | /** 30 | * Action executor 31 | * 32 | * @var ActionExecutor 33 | */ 34 | private $actionExecutor; 35 | 36 | /** 37 | * AbstractListener constructor. 38 | * 39 | * @param ExpressionLanguage $subjectRetrieverLanguage subject retriever expression language 40 | * @param SubjectManipulator $subjectManipulator subject manipulator 41 | * @param Registry $workflowRegistry workflow registry 42 | * @param LoggerInterface $logger logger 43 | * @param ExpressionLanguage $actionLanguage action expression language 44 | * @param ActionExecutor $actionExecutor action executor 45 | */ 46 | public function __construct( 47 | ExpressionLanguage $subjectRetrieverLanguage, 48 | SubjectManipulator $subjectManipulator, 49 | Registry $workflowRegistry, 50 | LoggerInterface $logger, 51 | ExpressionLanguage $actionLanguage, 52 | ActionExecutor $actionExecutor 53 | ) { 54 | parent::__construct($subjectRetrieverLanguage, $subjectManipulator, $workflowRegistry, $logger, $actionLanguage); 55 | $this->actionExecutor = $actionExecutor; 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | * 61 | * @param array $actions list of actions to try to apply by specified workflow 62 | */ 63 | public function registerEvent( 64 | string $eventName, 65 | string $workflowName, 66 | string $subjectRetrievingExpression, 67 | array $actions = [] 68 | ): void { 69 | $this->configureSubjectRetrievingForEvent($eventName, $workflowName, $subjectRetrievingExpression); 70 | $this->supportedEventsConfig[$eventName][$workflowName]['actions'] = $actions; 71 | } 72 | 73 | /** 74 | * {@inheritdoc} 75 | */ 76 | protected function handleEvent( 77 | string $eventName, 78 | Event $event, 79 | array $eventConfigForWorkflow, 80 | WorkflowContext $workflowContext 81 | ): void { 82 | $actions = $this->supportedEventsConfig[$eventName][$workflowContext->getWorkflow()->getName()]['actions']; 83 | /** @var Action[] $actions */ 84 | $actions = $this->prepareActions($actions, $event, $workflowContext); 85 | 86 | foreach ($actions as $action) { 87 | $this->execute( 88 | function () use ($workflowContext, $action) { 89 | $this->actionExecutor->execute($workflowContext, $action->getName(), $action->getArguments()); 90 | }, 91 | $eventName, 92 | $workflowContext, 93 | sprintf('execute action "%s" with arguments "%s"', $action->getName(), json_encode($action->getArguments())) 94 | ); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Trigger/Event/ExpressionListener.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Trigger\Event; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject\SubjectManipulator; 17 | use Psr\Log\LoggerInterface; 18 | use Symfony\Component\EventDispatcher\Event; 19 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 20 | use Symfony\Component\Workflow\Registry; 21 | 22 | /** 23 | * Evaluates expression as a result of event fired 24 | */ 25 | class ExpressionListener extends AbstractActionListener 26 | { 27 | /** 28 | * Expression language for execute expressions with actions 29 | * 30 | * @var ExpressionLanguage 31 | */ 32 | private $actionLanguage; 33 | 34 | /** 35 | * AbstractListener constructor. 36 | * 37 | * @param ExpressionLanguage $subjectRetrieverLanguage subject retriever expression language 38 | * @param SubjectManipulator $subjectManipulator subject manipulator 39 | * @param Registry $workflowRegistry workflow registry 40 | * @param LoggerInterface $logger logger 41 | * @param ExpressionLanguage $actionLanguage action expression language 42 | */ 43 | public function __construct( 44 | ExpressionLanguage $subjectRetrieverLanguage, 45 | SubjectManipulator $subjectManipulator, 46 | Registry $workflowRegistry, 47 | LoggerInterface $logger, 48 | ExpressionLanguage $actionLanguage 49 | ) { 50 | parent::__construct($subjectRetrieverLanguage, $subjectManipulator, $workflowRegistry, $logger, $actionLanguage); 51 | $this->actionLanguage = $actionLanguage; 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | * 57 | * @param string $expression expression to be executed by event 58 | */ 59 | public function registerEvent( 60 | string $eventName, 61 | string $workflowName, 62 | string $subjectRetrievingExpression, 63 | string $expression) 64 | { 65 | $this->configureSubjectRetrievingForEvent($eventName, $workflowName, $subjectRetrievingExpression); 66 | $this->supportedEventsConfig[$eventName][$workflowName]['expression'] = $expression; 67 | } 68 | 69 | /** 70 | * {@inheritdoc} 71 | */ 72 | protected function handleEvent(string $eventName, Event $event, array $eventConfigForWorkflow, WorkflowContext $workflowContext): void 73 | { 74 | $expression = $this->supportedEventsConfig[$eventName][$workflowContext->getWorkflow()->getName()]['expression']; 75 | 76 | $this->execute( 77 | function () use ($expression, $event, $workflowContext) { 78 | $this->actionLanguage->evaluate($expression, ['event' => $event, 'workflowContext' => $workflowContext]); 79 | }, 80 | $eventName, 81 | $workflowContext, 82 | sprintf('execute expression "%s"', $expression) 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Trigger/Event/SchedulerListener.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Trigger\Event; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Schedule\ActionScheduler; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\Schedule\ValueObject\ScheduledAction; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject\SubjectManipulator; 19 | use Psr\Log\LoggerInterface; 20 | use Symfony\Component\EventDispatcher\Event; 21 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 22 | use Symfony\Component\Workflow\Registry; 23 | 24 | /** 25 | * Schedules action as a result of event fired 26 | */ 27 | class SchedulerListener extends AbstractActionListener 28 | { 29 | /** 30 | * Action scheduler 31 | * 32 | * @var ActionScheduler 33 | */ 34 | private $actionScheduler; 35 | 36 | /** 37 | * AbstractListener constructor. 38 | * 39 | * @param ExpressionLanguage $subjectRetrieverLanguage subject retriever expression language 40 | * @param SubjectManipulator $subjectManipulator subject manipulator 41 | * @param Registry $workflowRegistry workflow registry 42 | * @param LoggerInterface $logger logger 43 | * @param ExpressionLanguage $actionLanguage action expression language 44 | * @param ActionScheduler $actionScheduler action scheduler 45 | */ 46 | public function __construct( 47 | ExpressionLanguage $subjectRetrieverLanguage, 48 | SubjectManipulator $subjectManipulator, 49 | Registry $workflowRegistry, 50 | LoggerInterface $logger, 51 | ExpressionLanguage $actionLanguage, 52 | ActionScheduler $actionScheduler) 53 | { 54 | parent::__construct($subjectRetrieverLanguage, $subjectManipulator, $workflowRegistry, $logger, $actionLanguage); 55 | $this->actionScheduler = $actionScheduler; 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | * 61 | * @param string $expression expression to be executed by event 62 | */ 63 | public function registerEvent( 64 | string $eventName, 65 | string $workflowName, 66 | string $subjectRetrievingExpression, 67 | array $scheduledActions = []) 68 | { 69 | $this->configureSubjectRetrievingForEvent($eventName, $workflowName, $subjectRetrievingExpression); 70 | $this->supportedEventsConfig[$eventName][$workflowName]['scheduled_actions'] = $scheduledActions; 71 | } 72 | 73 | 74 | /** 75 | * {@inheritdoc} 76 | */ 77 | protected function handleEvent(string $eventName, Event $event, array $eventConfigForWorkflow, WorkflowContext $workflowContext): void 78 | { 79 | $actions = $this->supportedEventsConfig[$eventName][$workflowContext->getWorkflow()->getName()]['scheduled_actions']; 80 | /** @var ScheduledAction[] $actions */ 81 | $actions = $this->prepareActions($actions, $event, $workflowContext, true); 82 | 83 | foreach ($actions as $scheduledAction) { 84 | $this->execute( 85 | function () use ($workflowContext, $scheduledAction) { 86 | $this->actionScheduler->scheduleAction($workflowContext, $scheduledAction); 87 | }, 88 | $eventName, 89 | $workflowContext, 90 | sprintf( 91 | 'schedule action "%s" with arguments "%s"', 92 | $scheduledAction->getName(), 93 | json_encode($scheduledAction->getArguments()) 94 | ) 95 | ); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Utils/ArrayUtils.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 22.09.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Utils; 15 | 16 | /** 17 | * Service array utils 18 | * 19 | * @author fduch 20 | */ 21 | class ArrayUtils 22 | { 23 | /** 24 | * Recursively checks that passed array is associative or not 25 | * 26 | * @param array $array input array 27 | * @param bool $recursiveCheck whether to perform recursive check or not 28 | * 29 | * @return bool 30 | */ 31 | public static function isArrayAssoc(array $array, bool $recursiveCheck = true): bool 32 | { 33 | foreach ($array as $key => $value) { 34 | if (is_string($key)) { 35 | return true; 36 | } 37 | if ($recursiveCheck && is_array($value) && static::isArrayAssoc($value)) { 38 | return true; 39 | } 40 | } 41 | 42 | return false; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/WorkflowContext.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 01.09.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle; 15 | 16 | use Symfony\Component\Workflow\Workflow; 17 | 18 | /** 19 | * Workflow context 20 | * 21 | * Data Value Object holds workflow, subject, subjectId. 22 | * 23 | * TODO: probably we need fetch subject id (using SubjectManipulator) dynamically in order to exclude 24 | * probability of diverging of real subject id with the one stored in WorkflowContext 25 | * (this can be occurred if workflow subject changes it's ID). 26 | * 27 | * @author fduch 28 | */ 29 | class WorkflowContext 30 | { 31 | /** 32 | * Workflow instance 33 | * 34 | * @var Workflow 35 | */ 36 | private $workflow; 37 | 38 | /** 39 | * Workflow subject instance 40 | * 41 | * @var object 42 | */ 43 | private $subject; 44 | 45 | /** 46 | * Subject id 47 | * 48 | * @var string|int 49 | */ 50 | private $subjectId; 51 | 52 | /** 53 | * WorkflowContext constructor. 54 | * 55 | * @param Workflow $workflow 56 | * @param object $subject 57 | * @param string|int $subjectId 58 | */ 59 | public function __construct(Workflow $workflow, $subject, $subjectId) 60 | { 61 | $this->workflow = $workflow; 62 | $this->subject = $subject; 63 | $this->subjectId = $subjectId; 64 | } 65 | 66 | /** 67 | * @return Workflow 68 | */ 69 | public function getWorkflow(): Workflow 70 | { 71 | return $this->workflow; 72 | } 73 | 74 | /** 75 | * @return object 76 | */ 77 | public function getSubject() 78 | { 79 | return $this->subject; 80 | } 81 | 82 | /** 83 | * @return int|string 84 | */ 85 | public function getSubjectId() 86 | { 87 | return $this->subjectId; 88 | } 89 | 90 | /** 91 | * Returns workflow logger context 92 | * 93 | * @return array 94 | */ 95 | public function getLoggerContext(): array 96 | { 97 | return [ 98 | 'workflow' => $this->workflow->getName(), 99 | 'class' => get_class($this->subject), 100 | 'id' => $this->subjectId 101 | ]; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/WorkflowExtensionsBundle.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 17.07.15 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\DependencyInjection\Compiler\CheckSubjectManipulatorConfigPass; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\HttpKernel\Bundle\Bundle; 18 | 19 | /** 20 | * Bundle class 21 | */ 22 | class WorkflowExtensionsBundle extends Bundle 23 | { /** 24 | * {@inheritdoc} 25 | */ 26 | public function build(ContainerBuilder $container) 27 | { 28 | parent::build($container); 29 | $container->addCompilerPass(new CheckSubjectManipulatorConfigPass()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/WorkflowSubject/SubjectManipulator.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.07.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\SubjectIdRetrievingException; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\SubjectManipulatorException; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\SubjectRetrievingFromDomainException; 18 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 19 | 20 | /** 21 | * Class helps to retrieve workflow subject from domain and retrieve id from subject used by scheduler subsystem 22 | */ 23 | class SubjectManipulator 24 | { 25 | /** 26 | * Expression language 27 | * 28 | * @var ExpressionLanguage 29 | */ 30 | private $language; 31 | 32 | /** 33 | * Holds expressions used to retrieve subject from domain and id from subject 34 | * 35 | * @var array 36 | */ 37 | private $supportedSubjectsConfig = []; 38 | 39 | /** 40 | * SubjectManipulator constructor. 41 | * 42 | * @param ExpressionLanguage $language expression language 43 | */ 44 | public function __construct(ExpressionLanguage $language) 45 | { 46 | $this->language = $language; 47 | } 48 | 49 | /** 50 | * Sets expressions for supported subject 51 | * 52 | * @param string $subjectClass subject class 53 | * @param string $idFromSubjectExpression expression used to retrieve subject id from subject object 54 | * @param string|null $subjectFromDomainExpression expression used to retrieve subject from domain 55 | * 56 | * @throws SubjectManipulatorException 57 | */ 58 | public function addSupportedSubject( 59 | string $subjectClass, 60 | string $idFromSubjectExpression, 61 | string $subjectFromDomainExpression = null 62 | ): void { 63 | if (isset($this->supportedSubjectsConfig[$subjectClass])) { 64 | throw SubjectManipulatorException::subjectConfigIsAlreadySet($subjectClass); 65 | } 66 | 67 | $this->supportedSubjectsConfig[$subjectClass] = [ 68 | 'id_from_subject' => $idFromSubjectExpression, 69 | 'subject_from_domain' => $subjectFromDomainExpression 70 | ]; 71 | } 72 | 73 | /** 74 | * Retrieves workflow subject from domain 75 | * 76 | * @param string $subjectClass subject class 77 | * @param int $subjectId subject id 78 | * 79 | * @return object 80 | * 81 | * @throws SubjectRetrievingFromDomainException 82 | */ 83 | public function getSubjectFromDomain(string $subjectClass, $subjectId) 84 | { 85 | $subjectClass = ltrim($subjectClass, "\\"); 86 | if (!array_key_exists($subjectClass, $this->supportedSubjectsConfig) || 87 | !array_key_exists('subject_from_domain', $this->supportedSubjectsConfig[$subjectClass])) { 88 | throw SubjectRetrievingFromDomainException::expressionNotFound($subjectClass); 89 | } 90 | 91 | return $this->language->evaluate( 92 | $this->supportedSubjectsConfig[$subjectClass]['subject_from_domain'], 93 | ['subjectClass' => $subjectClass, 'subjectId' => $subjectId] 94 | ); 95 | } 96 | 97 | /** 98 | * Fetches subject id from subject object 99 | * 100 | * @param object $subject subject 101 | * 102 | * @return int 103 | * 104 | * @throws SubjectIdRetrievingException 105 | */ 106 | public function getSubjectId($subject) 107 | { 108 | if (!is_object($subject)) { 109 | throw SubjectIdRetrievingException::subjectIsNotAnObject($subject); 110 | } 111 | 112 | $subjectClass = get_class($subject); 113 | if (!array_key_exists($subjectClass, $this->supportedSubjectsConfig) || 114 | !array_key_exists('id_from_subject', $this->supportedSubjectsConfig[$subjectClass])) { 115 | throw SubjectIdRetrievingException::expressionNotFound($subjectClass); 116 | } 117 | 118 | return $this->language->evaluate( 119 | $this->supportedSubjectsConfig[$subjectClass]['id_from_subject'], 120 | ['subject' => $subject] 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/Action/ExecutorTest.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 19.10.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Action; 15 | 16 | use Gtt\Bundle\WorkflowExtensionsBundle\Action\Reference\ActionReferenceInterface; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 18 | use PHPUnit\Framework\TestCase; 19 | use Symfony\Component\DependencyInjection\ContainerInterface; 20 | use Symfony\Component\Workflow\Workflow; 21 | 22 | class ExecutorTest extends TestCase 23 | { 24 | /** 25 | * @dataProvider actionReferenceProvider 26 | */ 27 | public function testEvaluatesAction( 28 | string $actionReferenceType, 29 | array $inputArgs, 30 | array $expectedArgs, 31 | WorkflowContext $wc 32 | ): void { 33 | $container = $this->getMockBuilder(ContainerInterface::class)->getMockForAbstractClass(); 34 | 35 | $actionName = 'a1'; 36 | $actionReference = $this->getMockBuilder(ActionReferenceInterface::class)->disableOriginalConstructor()->getMock(); 37 | $actionReference->expects(self::once())->method('invoke')->with($expectedArgs); 38 | $actionReference->expects(self::once())->method('getType')->willReturn($actionReferenceType); 39 | 40 | $actionRegistry = $this->getMockBuilder(Registry::class)->disableOriginalConstructor()->getMock(); 41 | $actionRegistry->expects(self::once())->method('get')->with(self::equalTo($actionName))->willReturn($actionReference); 42 | 43 | $executor = new Executor($actionRegistry, $container); 44 | $executor->execute($wc, $actionName, $inputArgs); 45 | } 46 | 47 | public function actionReferenceProvider(): array 48 | { 49 | $data = []; 50 | 51 | $workflowContext = new WorkflowContext( 52 | $this->getMockBuilder(Workflow::class)->disableOriginalConstructor()->getMock(), 53 | new \StdClass(), 54 | 1 55 | ); 56 | 57 | $defaultInputActionArgs = ['some' => 'arg', 'more']; 58 | 59 | // regular action (non-container-aware) 60 | $data[] = [ActionReferenceInterface::TYPE_REGULAR, $defaultInputActionArgs, $defaultInputActionArgs, $workflowContext]; 61 | 62 | // regular action (container-aware) 63 | $data[] = [ActionReferenceInterface::TYPE_REGULAR, $defaultInputActionArgs, $defaultInputActionArgs, $workflowContext]; 64 | 65 | $workflowActionInputArgs = $defaultInputActionArgs; 66 | array_unshift($workflowActionInputArgs, $workflowContext); 67 | 68 | // workflow action (non-container-aware) 69 | $data[] = [ActionReferenceInterface::TYPE_WORKFLOW, $defaultInputActionArgs, $workflowActionInputArgs, $workflowContext]; 70 | 71 | // workflow action (container-aware) 72 | $data[] = [ActionReferenceInterface::TYPE_WORKFLOW, $defaultInputActionArgs, $workflowActionInputArgs, $workflowContext]; 73 | 74 | return $data; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Actions/TransitionApplierTest.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 08.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Actions; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 16 | use PHPUnit\Framework\TestCase; 17 | use Psr\Log\LoggerInterface; 18 | use Symfony\Component\Workflow\Exception\LogicException; 19 | use Symfony\Component\Workflow\Workflow; 20 | 21 | class TransitionApplierTest extends TestCase 22 | { 23 | /** 24 | * @dataProvider exceptionAndLogLevelProvider 25 | */ 26 | public function testUnavailabilityToPerformTransitionIsReportedByLogger( 27 | \Exception $exception, 28 | string $loggerLevel, 29 | bool $throw = true 30 | ): void { 31 | $transitionName = "t1"; 32 | $subject = new \StdClass(); 33 | 34 | $workflow = $this->getMockBuilder(Workflow::class)->disableOriginalConstructor()->getMock(); 35 | $workflow->expects(self::once())->method('apply')->with($subject, $transitionName)->willThrowException($exception); 36 | if ($throw) { 37 | $this->expectException(get_class($exception)); 38 | } 39 | 40 | $workflowContext = new WorkflowContext($workflow, $subject, "id"); 41 | 42 | $logger = $this->getMockBuilder(LoggerInterface::class)->getMockForAbstractClass(); 43 | $logger->expects(self::once())->method($loggerLevel); 44 | 45 | $applier = new TransitionApplier($logger); 46 | 47 | $applier->applyTransition($workflowContext, $transitionName); 48 | } 49 | 50 | public function exceptionAndLogLevelProvider(): array 51 | { 52 | return [ 53 | // workflow logic exception should trigger info-level logging 54 | [new LogicException(), "info", false], 55 | [new \Exception(), "error"] 56 | ]; 57 | } 58 | 59 | /** 60 | * @dataProvider transitionEnvironmentProvider 61 | */ 62 | public function testSeveralTransitionsHandledCorrectly( 63 | array $transitions = [], 64 | array $transitionsToBeTriedToApply = [], 65 | bool $cascade = false 66 | ): void { 67 | $subject = new \StdClass(); 68 | $workflow = $this->getMockBuilder(Workflow::class)->disableOriginalConstructor()->getMock(); 69 | 70 | $callIndex = 0; 71 | $transitionsToApplyCount = 0; 72 | $workflow->expects(self::at($callIndex))->method('getName'); 73 | $callIndex++; 74 | foreach ($transitionsToBeTriedToApply as $transitionName => $applicationResult) { 75 | $invocationMocker = $workflow->expects(self::at($callIndex))->method('apply')->with($subject, $transitionName); 76 | $transitionsToApplyCount++; 77 | if (is_array($applicationResult)) { 78 | [$exception, $throw] = $applicationResult; 79 | $invocationMocker->willThrowException($exception); 80 | if ($throw) { 81 | $this->expectException(get_class($exception)); 82 | break; 83 | } 84 | } 85 | $callIndex++; 86 | } 87 | $workflow->expects($this->exactly($transitionsToApplyCount))->method('apply'); 88 | 89 | $workflowContext = new WorkflowContext($workflow, $subject, "id"); 90 | 91 | $applier = new TransitionApplier($this->getMockBuilder(LoggerInterface::class)->getMockForAbstractClass()); 92 | 93 | $applier->applyTransitions($workflowContext, $transitions, $cascade); 94 | } 95 | 96 | public function transitionEnvironmentProvider() 97 | { 98 | return [ 99 | [['t1', 't2'], ['t1' => true]], 100 | [['t1', 't2'], ['t1' => [new \Exception(), true], 't2' => true], true], 101 | [['t1', 't2'], ['t1' => [new \Exception(), true], 't2' => true], false], 102 | [['t1', 't2'], ['t1' => [new LogicException(), false], 't2' => true], true], 103 | [['t1', 't2'], ['t1' => [new LogicException(), false], 't2' => true], false], 104 | ]; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/DependencyInjection/Compiler/CheckSubjectManipulatorConfigPassTest.php: -------------------------------------------------------------------------------- 1 | 9 | * Date: 30.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\DependencyInjection\Compiler; 14 | 15 | use PHPUnit\Framework\TestCase; 16 | use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; 17 | use Symfony\Component\DependencyInjection\ContainerBuilder; 18 | use Symfony\Component\DependencyInjection\Definition; 19 | use Symfony\Component\DependencyInjection\Reference; 20 | 21 | class CheckSubjectManipulatorConfigPassTest extends TestCase 22 | { 23 | public function testThrowingExceptionIfTargetSubjectClassesNotConfigured(): void 24 | { 25 | $this->expectException(InvalidConfigurationException::class); 26 | 27 | $container = new ContainerBuilder(); 28 | $container->addCompilerPass(new CheckSubjectManipulatorConfigPass()); 29 | 30 | $registryDefinition = new Definition("RegistryClass"); 31 | $container->setDefinition('workflow.test', new Definition('class')); 32 | $container->setDefinition('gtt.workflow.transition_scheduler', new Definition('class')); 33 | 34 | $workflowName = 'test'; 35 | $registryDefinition->addMethodCall( 36 | CheckSubjectManipulatorConfigPass::WORKFLOW_REGISTRY_ADD_WORKFLOW_METHOD_NAME, 37 | [ 38 | new Reference(CheckSubjectManipulatorConfigPass::WORKFLOW_ID_PREFIX.$workflowName), 39 | '\Some\Target\Class' 40 | ] 41 | ); 42 | $container->setDefinition('workflow.registry', $registryDefinition); 43 | 44 | $container->setParameter('gtt.workflow.subject_classes_with_subject_from_domain', ['\Some\Target\AnotherClass']); 45 | $container->setParameter('gtt.workflow.workflows_with_scheduling', [$workflowName]); 46 | 47 | $container->compile(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/DependencyInjection/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | processConfiguration($config)['context']); 30 | } 31 | 32 | public function contextDataProvider(): iterable 33 | { 34 | $nomatter = ['workflows' => ['some' => []], 'subject_manipulator' => [TargetWorkflowSubject::class => null]]; 35 | 36 | yield [['context' => 'my_service'] + $nomatter, ['my_service' => 'my_service']]; 37 | 38 | yield [['context' => null] + $nomatter, []]; 39 | 40 | yield [$nomatter, []]; 41 | 42 | yield [['context' => ['test' => null]] + $nomatter, ['test' => 'test']]; 43 | 44 | yield [['context' => ['test' => 'another']] + $nomatter, ['test' => 'another']]; 45 | } 46 | 47 | private function processConfiguration(array $config): array 48 | { 49 | return (new Processor())->processConfiguration(new Configuration(), [$config]); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/BaseConfig/config.yml: -------------------------------------------------------------------------------- 1 | framework: 2 | secret: test 3 | router: { resource: "routing.yml" } 4 | test: ~ -------------------------------------------------------------------------------- /tests/Functional/Configuration/BaseConfig/routing.yml: -------------------------------------------------------------------------------- 1 | # no need for routing here -------------------------------------------------------------------------------- /tests/Functional/Configuration/EventTriggerWithGuardsCase/Fixtures/Event.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\EventTriggerWithGuardsCase\Fixtures; 14 | 15 | use Symfony\Component\EventDispatcher\Event as BaseEvent; 16 | 17 | class Event extends BaseEvent 18 | { 19 | private $subject; 20 | 21 | public function __construct(TargetWorkflowSubject $subject) 22 | { 23 | $this->subject = $subject; 24 | } 25 | 26 | public function getSubject() 27 | { 28 | return $this->subject; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/EventTriggerWithGuardsCase/Fixtures/TargetWorkflowSubject.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\EventTriggerWithGuardsCase\Fixtures; 14 | 15 | class TargetWorkflowSubject 16 | { 17 | private $status; 18 | 19 | public function __construct() 20 | { 21 | $this->status = ['inactive' => 1]; 22 | } 23 | 24 | public function getStatus() { 25 | return $this->status; 26 | } 27 | 28 | public function setStatus($status) { 29 | $this->status = $status; 30 | } 31 | 32 | public function getId() { 33 | return "1"; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/EventTriggerWithGuardsCase/bundles.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | return array( 14 | new \Symfony\Bundle\MonologBundle\MonologBundle(), 15 | new \Gtt\Bundle\WorkflowExtensionsBundle\WorkflowExtensionsBundle() 16 | ); 17 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/EventTriggerWithGuardsCase/config.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: "../BaseConfig/config.yml" } 3 | 4 | framework: 5 | workflows: 6 | simple: 7 | type: workflow 8 | marking_store: 9 | type: multiple_state 10 | arguments: 11 | - status 12 | supports: 13 | - Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\EventTriggerWithGuardsCase\Fixtures\TargetWorkflowSubject 14 | places: 15 | - inactive 16 | - processing 17 | - vip 18 | - active 19 | - regular 20 | - closed 21 | transitions: 22 | processing: 23 | from: 24 | - inactive 25 | to: 26 | - processing 27 | activating: 28 | from: 29 | - processing 30 | to: 31 | - active 32 | - regular 33 | viping: 34 | from: 35 | - regular 36 | to: 37 | - vip 38 | closing: 39 | from: 40 | - active 41 | - regular 42 | to: 43 | - closed 44 | closing_vip: 45 | from: 46 | - active 47 | - vip 48 | to: 49 | - closed 50 | crazy_closing: 51 | from: 52 | - active 53 | to: 54 | - closed 55 | 56 | workflow_extensions: 57 | workflows: 58 | simple: 59 | triggers: 60 | event: 61 | processing.event: 62 | actions: 63 | apply_transitions: 64 | - [[processing, activating, closing]] 65 | subject_retrieving_expression: 'event.getSubject()' 66 | viping.event: 67 | actions: 68 | apply_transitions: 69 | - [[viping, closing_vip]] 70 | subject_retrieving_expression: 'event.getSubject()' 71 | crazy_closing.event: 72 | # example of expression usage (it is the same as direct usage of action apply_transition or apply_transitions like above) 73 | expression: "apply_transition('crazy_closing')" 74 | subject_retrieving_expression: 'event.getSubject()' 75 | guard: 76 | # prevent closing vips 77 | expression: 'event.getTransition().getName() == "closing_vip"' 78 | transitions: 79 | # prevent direct transiticn of active=>closed when marking contains other places 80 | crazy_closing: 'event.getMarking().getPlaces() != {"active": 1}' 81 | subject_manipulator: 82 | Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\EventTriggerWithGuardsCase\Fixtures\TargetWorkflowSubject: ~ 83 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/ScheduleCase/Fixtures/ClientBundle/ClientBundle.php: -------------------------------------------------------------------------------- 1 | 10 | * @date 17.07.15 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures\ClientBundle; 15 | 16 | use Symfony\Component\HttpKernel\Bundle\Bundle; 17 | 18 | class ClientBundle extends Bundle 19 | { 20 | 21 | } 22 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/ScheduleCase/Fixtures/ClientBundle/DataFixtures/ORM/LoadData.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 17.07.15 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures\ClientBundle\DataFixtures\ORM; 14 | 15 | use Doctrine\Common\DataFixtures\FixtureInterface; 16 | use Doctrine\Common\Persistence\ObjectManager; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures\ClientBundle\Entity\Client; 18 | 19 | class LoadData implements FixtureInterface 20 | { 21 | /** 22 | * {@inheritDoc} 23 | */ 24 | public function load(ObjectManager $manager) 25 | { 26 | $client = new Client(); 27 | $client->setName("Johnny"); 28 | 29 | $manager->persist($client); 30 | 31 | $manager->flush(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/ScheduleCase/Fixtures/ClientBundle/Entity/Client.php: -------------------------------------------------------------------------------- 1 | status = "inactive"; 42 | } 43 | 44 | /** 45 | * Get id 46 | * 47 | * @return integer 48 | */ 49 | public function getId() 50 | { 51 | return $this->id; 52 | } 53 | 54 | /** 55 | * Set name 56 | * 57 | * @param string $name 58 | * @return Client 59 | */ 60 | public function setName($name) 61 | { 62 | $this->name = $name; 63 | 64 | return $this; 65 | } 66 | 67 | /** 68 | * Get name 69 | * 70 | * @return string 71 | */ 72 | public function getName() 73 | { 74 | return $this->name; 75 | } 76 | 77 | /** 78 | * Returns status 79 | * 80 | * @return string 81 | */ 82 | public function getStatus() 83 | { 84 | return $this->status; 85 | } 86 | 87 | /** 88 | * Sets status marking 89 | * 90 | * @param $status 91 | */ 92 | public function setStatus($status) 93 | { 94 | $this->status = $status; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/ScheduleCase/Fixtures/Event.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures\ClientBundle\Entity\Client; 16 | use Symfony\Component\EventDispatcher\Event as BaseEvent; 17 | 18 | class Event extends BaseEvent 19 | { 20 | private $subject; 21 | 22 | public function __construct(Client $subject) 23 | { 24 | $this->subject = $subject; 25 | } 26 | 27 | public function getSubject() 28 | { 29 | return $this->subject; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/ScheduleCase/bundles.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | return array( 14 | new \Symfony\Bundle\MonologBundle\MonologBundle(), 15 | new \Gtt\Bundle\WorkflowExtensionsBundle\WorkflowExtensionsBundle(), 16 | new \Doctrine\Bundle\DoctrineBundle\DoctrineBundle(), 17 | new \Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures\ClientBundle\ClientBundle(), 18 | new \JMS\JobQueueBundle\JMSJobQueueBundle() 19 | ); 20 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/ScheduleCase/config.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: "../BaseConfig/config.yml" } 3 | 4 | services: 5 | gtt.workflow.marking_store.single_state: 6 | class: Symfony\Component\Workflow\MarkingStore\SingleStateMarkingStore 7 | arguments: 8 | - status 9 | gtt.workflow.marking_store.orm.marking.store: 10 | class: Gtt\Bundle\WorkflowExtensionsBundle\MarkingStore\OrmPersistentMarkingStore 11 | arguments: ["@gtt.workflow.marking_store.single_state", "@doctrine"] 12 | 13 | doctrine: 14 | dbal: 15 | default_connection: default 16 | connections: 17 | default: 18 | driver: pdo_sqlite 19 | path: "%kernel.cache_dir%/database.sqlite" 20 | orm: 21 | auto_generate_proxy_classes: "%kernel.debug%" 22 | naming_strategy: doctrine.orm.naming_strategy.underscore 23 | auto_mapping: true 24 | 25 | framework: 26 | workflows: 27 | simple: 28 | type: workflow 29 | marking_store: 30 | service: gtt.workflow.marking_store.orm.marking.store 31 | supports: 32 | - Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures\ClientBundle\Entity\Client 33 | places: 34 | - inactive 35 | - active 36 | - sleeping 37 | - closed 38 | transitions: 39 | activating: 40 | from: 41 | - inactive 42 | to: 43 | - active 44 | sleeping: 45 | from: 46 | - active 47 | to: 48 | - sleeping 49 | closing: 50 | from: 51 | - sleeping 52 | to: 53 | - closed 54 | 55 | workflow_extensions: 56 | workflows: 57 | simple: 58 | triggers: 59 | event: 60 | activating.event: 61 | actions: 62 | apply_transition: 63 | - [activating] 64 | subject_retrieving_expression: 'event.getSubject()' 65 | # during activation schedule sleeping 66 | workflow.simple.transition.activating: 67 | schedule: 68 | apply_transition: 69 | - 70 | arguments: [sleeping] 71 | offset: PT1S 72 | subject_retrieving_expression: 'event.getSubject()' 73 | # during sleeping schedule closing 74 | workflow.simple.transition.sleeping: 75 | schedule: 76 | apply_transition: 77 | - 78 | arguments: [closing] 79 | offset: PT1S 80 | subject_retrieving_expression: 'event.getSubject()' 81 | # prolongate closing for 1 sec 82 | prolong.event: 83 | schedule: 84 | apply_transition: 85 | - 86 | arguments: [closing] 87 | offset: PT1S 88 | subject_retrieving_expression: 'event.getSubject()' 89 | scheduler: ~ 90 | subject_manipulator: 91 | Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures\ClientBundle\Entity\Client: 92 | subject_from_domain: "container.get('doctrine').getManagerForClass(subjectClass).find(subjectClass, subjectId)" 93 | context: 94 | doctrine: ~ 95 | -------------------------------------------------------------------------------- /tests/Functional/Configuration/ScheduleCase/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | initApplication(); 17 | $application->run(new ArgvInput()); 18 | -------------------------------------------------------------------------------- /tests/Functional/EventTriggerWithGuardsCaseTest.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Functional; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\EventTriggerWithGuardsCase\Fixtures\Event; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\EventTriggerWithGuardsCase\Fixtures\TargetWorkflowSubject; 17 | use Symfony\Component\EventDispatcher\EventDispatcherInterface; 18 | 19 | class EventTriggerWithGuardsCaseTest extends TestCase 20 | { 21 | /** 22 | * WebClient emulator 23 | * 24 | * @var \Symfony\Bundle\FrameworkBundle\Client 25 | */ 26 | protected $client; 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function setUp(): void 32 | { 33 | parent::setUp(); 34 | 35 | $this->client = self::createClient( 36 | array( 37 | "app_name" => "EventTriggerWithGuardsCaseTest", 38 | "test_case" => "EventTriggerWithGuardsCase", 39 | "root_config" => "config.yml", 40 | "config_dir" => __DIR__ . "/Configuration", 41 | "environment" => "test", 42 | "debug" => false 43 | ) 44 | ); 45 | } 46 | 47 | /** 48 | * @group functional 49 | */ 50 | public function testSimple(): void 51 | { 52 | $container = $this->client->getContainer(); 53 | /** @var EventDispatcherInterface $eventDispatcher */ 54 | $eventDispatcher = $container->get('event_dispatcher'); 55 | 56 | $subject = new TargetWorkflowSubject(); 57 | $event = new Event($subject); 58 | 59 | $eventDispatcher->dispatch('processing.event', $event); 60 | self::assertEquals(['processing' => 1], $subject->getStatus()); 61 | 62 | $eventDispatcher->dispatch('processing.event', $event); 63 | self::assertEquals(['active' => 1, "regular" => 1], $subject->getStatus()); 64 | 65 | $eventDispatcher->dispatch('viping.event', $event); 66 | self::assertEquals(['active' => 1, 'vip' => 1], $subject->getStatus()); 67 | 68 | $eventDispatcher->dispatch('processing.event', $event); 69 | // nothing happens 70 | self::assertEquals(['active' => 1, 'vip' => 1], $subject->getStatus()); 71 | 72 | $eventDispatcher->dispatch('viping.event', $event); 73 | // guard prevents closing vips 74 | self::assertEquals(['active' => 1, 'vip' => 1], $subject->getStatus()); 75 | 76 | $eventDispatcher->dispatch('crazy_closing.event', $event); 77 | // guard allows closing only if there is only one place "active" in marking 78 | self::assertEquals(['active' => 1, 'vip' => 1], $subject->getStatus()); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/Functional/Kernel/BaseTestKernel.php: -------------------------------------------------------------------------------- 1 | setRootDir($rootDir); 72 | 73 | $this->configDir = realpath($configDir); 74 | if (!is_dir($this->configDir . '/' . $testCase)) { 75 | throw new \InvalidArgumentException(sprintf('The test case "%s" does not exist.', $testCase)); 76 | } 77 | $this->testCase = $testCase; 78 | 79 | $fs = new Filesystem(); 80 | if (!$fs->isAbsolutePath($rootConfig) && 81 | !file_exists($rootConfig = $this->configDir . '/' . $testCase . '/' . $rootConfig)) { 82 | throw new \InvalidArgumentException(sprintf('The root config "%s" does not exist.', $rootConfig)); 83 | } 84 | $this->rootConfig = $rootConfig; 85 | $this->rawName = $appName; 86 | $this->name = preg_replace('/[^a-zA-Z0-9_]+/', '', $this->rawName)."_". 87 | preg_replace('/[^a-zA-Z0-9_]+/', '', str_replace(DIRECTORY_SEPARATOR, "_", $testCase)); 88 | } 89 | 90 | /** 91 | * Sets kernel root directory 92 | * 93 | * @param string $rootDir kernel root dir 94 | * 95 | * @throws \RuntimeException if directory does not exist and can not be created 96 | * 97 | * @return void 98 | */ 99 | protected function setRootDir($rootDir) 100 | { 101 | if (!is_dir($rootDir) && !mkdir($rootDir, 0777, true) && !is_dir($rootDir)) { 102 | throw new \RuntimeException(sprintf('Unable to create test kernel root directory %s ', $rootDir)); 103 | } 104 | $this->rootDir = realpath($rootDir); 105 | } 106 | 107 | /** 108 | * {@inheritdoc} 109 | */ 110 | public function getRootDir() 111 | { 112 | return $this->rootDir; 113 | } 114 | 115 | /** 116 | * Returns base bundle list 117 | * 118 | * @return array 119 | */ 120 | protected function getBaseBundles() 121 | { 122 | return array( 123 | new \Symfony\Bundle\FrameworkBundle\FrameworkBundle(), 124 | ); 125 | } 126 | 127 | /** 128 | * {@inheritdoc} 129 | */ 130 | public function registerBundles() 131 | { 132 | $bundles = array(); 133 | $baseBundles = $this->getBaseBundles(); 134 | if (file_exists($filename = $this->configDir . '/' . $this->testCase . '/bundles.php')) { 135 | $bundles = include $filename; 136 | } 137 | return array_merge($baseBundles, $bundles); 138 | } 139 | 140 | /** 141 | * {@inheritdoc} 142 | */ 143 | public function init() 144 | { 145 | } 146 | 147 | /** 148 | * {@inheritdoc} 149 | */ 150 | public function getCacheDir() 151 | { 152 | return $this->getTempAppDir()."/".$this->testCase.'/cache/'.$this->environment; 153 | } 154 | 155 | /** 156 | * {@inheritdoc} 157 | */ 158 | public function getLogDir() 159 | { 160 | return $this->getTempAppDir()."/".$this->testCase.'/logs'; 161 | } 162 | 163 | /** 164 | * {@inheritdoc} 165 | */ 166 | public function getTempAppDir(): string 167 | { 168 | return sys_get_temp_dir().'/'.$this->rawName; 169 | } 170 | 171 | /** 172 | * {@inheritdoc} 173 | */ 174 | public function registerContainerConfiguration(LoaderInterface $loader) 175 | { 176 | $loader->load($this->rootConfig); 177 | } 178 | 179 | /** 180 | * {@inheritdoc} 181 | */ 182 | public function serialize() 183 | { 184 | return serialize( 185 | array( 186 | $this->getEnvironment(), 187 | $this->isDebug(), 188 | $this->rawName, 189 | $this->testCase, 190 | $this->configDir, 191 | $this->rootConfig, 192 | $this->rootDir)); 193 | } 194 | 195 | /** 196 | * {@inheritdoc} 197 | */ 198 | public function unserialize($str) 199 | { 200 | [$env, $debug, $appName, $testCase, $configDir, $rootConfig, $rootDir] = unserialize($str, ['allow_classes' => false]); 201 | $this->__construct($env, $debug); 202 | $this->setTestKernelConfiguration($appName, $testCase, $configDir, $rootConfig, $rootDir); 203 | } 204 | 205 | /** 206 | * {@inheritdoc} 207 | */ 208 | protected function getKernelParameters() 209 | { 210 | $parameters = parent::getKernelParameters(); 211 | 212 | $parameters['kernel.test_case'] = $this->testCase; 213 | $parameters['kernel.config_dir'] = $this->configDir; 214 | 215 | return $parameters; 216 | } 217 | 218 | /** 219 | * {@inheritdoc} 220 | */ 221 | public function getName() 222 | { 223 | if ($this->name === null) { 224 | return static::class; 225 | } 226 | 227 | return parent::getName(); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /tests/Functional/Kernel/KernelBuilder.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class KernelBuilder 21 | { 22 | /** 23 | * Defines default test kernel class name 24 | * 25 | * @var string 26 | */ 27 | protected static $defaultTestKernelClass = '\Gtt\Bundle\WorkflowExtensionsBundle\Functional\Kernel\BaseTestKernel'; 28 | 29 | /** 30 | * Returns kernel instance 31 | * 32 | * @param string $kernelClass kernel class name need to be instantiated 33 | * @param array $options kernel configuration options 34 | * base options: 35 | * environment - environment 36 | * debug - debug mode 37 | * test kernel specific options (for TestKernelInterface instances) 38 | * app_name - name of test application kernel 39 | * test_case - directory name where kernel configs are stored 40 | * config_dir - path to directory with test cases configurations 41 | * root_config - name of the application config file 42 | * root_dir - path to root directory of test application. Can be unset 43 | * 44 | * @throws \InvalidArgumentException in case of invalid options specified 45 | * 46 | * @return \Symfony\Component\HttpKernel\KernelInterface 47 | */ 48 | public static function getKernel($kernelClass = null, array $options = array()) 49 | { 50 | if (!($kernelClass === null)) { 51 | if (!class_exists($kernelClass)) { 52 | throw new \InvalidArgumentException("Cannot load $kernelClass"); 53 | } 54 | } else { 55 | $kernelClass = self::$defaultTestKernelClass; 56 | } 57 | 58 | if (defined("KERNEL_ENV")) { 59 | $options['environment'] = KERNEL_ENV; 60 | } 61 | 62 | if (defined("KERNEL_DEBUG")) { 63 | $options['debug'] = KERNEL_DEBUG; 64 | } 65 | 66 | $kernel = new $kernelClass( 67 | isset($options['environment']) ? $options['environment'] : 'test', 68 | isset($options['debug']) ? $options['debug'] : true 69 | ); 70 | 71 | if ($kernel instanceof TestKernelInterface) { 72 | if (!isset($options['app_name'])) { 73 | throw new \InvalidArgumentException('The option "app_name" must be set.'); 74 | } 75 | if (!isset($options['config_dir'])) { 76 | throw new \InvalidArgumentException('The option "config_dir" must be set.'); 77 | } 78 | if (!isset($options['test_case'])) { 79 | throw new \InvalidArgumentException('The option "test_case" must be set.'); 80 | } 81 | 82 | $kernel->setTestKernelConfiguration( 83 | $options['app_name'], 84 | $options['test_case'], 85 | $options['config_dir'], 86 | $options['root_config'] ?? 'config.yml', 87 | $options['root_dir'] ?? null 88 | ); 89 | } 90 | 91 | return $kernel; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/Functional/Kernel/TestKernelInterface.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | interface TestKernelInterface 21 | { 22 | /** 23 | * Sets Kernel configuration 24 | * 25 | * @param string $appName name of test application kernel 26 | * @param string $testCase directory name where kernel configs are stored 27 | * @param string $configDir path to directory with test cases configurations 28 | * @param string $rootConfig name of the application config file 29 | * @param string|null $rootDir path to root directory of test application. Can be unset 30 | * due to backward compatibility. 31 | * 32 | * @return void 33 | */ 34 | public function setTestKernelConfiguration( 35 | string $appName, 36 | string $testCase, 37 | string $configDir, 38 | string $rootConfig, 39 | string $rootDir = null 40 | ): void; 41 | 42 | /** 43 | * Returns temporary application folder that is used to store cache, logs of test kernel. 44 | * This folder is always clear after tests run thanks to 45 | * \Gtt\Bundle\WorkflowExtensionsBundle\Functional\TestCase::tearDownAfterClass - you can always 46 | * change this behaviour by overriding this method. 47 | * Can be used to to store other temporary application data. For example can be used to construct 48 | * root directory if functional tests generate some file stuff that depends on $kernel->getRootDir() folder 49 | * 50 | * @return string 51 | */ 52 | public function getTempAppDir(): string; 53 | } 54 | -------------------------------------------------------------------------------- /tests/Functional/ScheduleCaseTest.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 28.07.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Functional; 14 | 15 | use Doctrine\Bundle\DoctrineBundle\Command\Proxy\CreateSchemaDoctrineCommand; 16 | use Doctrine\Bundle\FixturesBundle\Command\LoadDataFixturesDoctrineCommand; 17 | use Doctrine\ORM\EntityManager; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures\ClientBundle\ClientBundle; 19 | use Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures\ClientBundle\Entity\Client; 20 | use Gtt\Bundle\WorkflowExtensionsBundle\Functional\Configuration\ScheduleCase\Fixtures\Event; 21 | use JMS\JobQueueBundle\Command\RunCommand; 22 | use Symfony\Bundle\FrameworkBundle\Console\Application; 23 | use Symfony\Component\Console\Command\Command; 24 | use Symfony\Component\Console\Input\InputOption; 25 | use Symfony\Component\Console\Tester\CommandTester; 26 | use Symfony\Component\DependencyInjection\ContainerInterface; 27 | use Symfony\Component\EventDispatcher\EventDispatcherInterface; 28 | 29 | class ScheduleCaseTest extends TestCase 30 | { 31 | /** 32 | * WebClient emulator 33 | * 34 | * @var \Symfony\Bundle\FrameworkBundle\Client 35 | */ 36 | protected $client; 37 | 38 | /** 39 | * @var Application 40 | */ 41 | protected $app; 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function setUp(): void 47 | { 48 | $this->initApplication(); 49 | 50 | $this->initDbSchema(); 51 | 52 | // load fixtures 53 | $fixturesBundle = new ClientBundle(); 54 | $fixturesPath = $fixturesBundle->getPath() . '/DataFixtures/ORM'; 55 | $this->loadFixtures($this->client->getContainer(), $fixturesPath); 56 | } 57 | 58 | public function initApplication(): Application 59 | { 60 | if (!class_exists('PDO') || !in_array('sqlite', \PDO::getAvailableDrivers())) { 61 | self::markTestSkipped('This test requires SQLite support in your environment'); 62 | } 63 | 64 | parent::setUp(); 65 | 66 | $this->client = self::createClient( 67 | array( 68 | "app_name" => "ScheduleCaseTest", 69 | "test_case" => "ScheduleCase", 70 | "root_config" => "config.yml", 71 | "config_dir" => __DIR__ . "/Configuration", 72 | "root_dir" => __DIR__ . "/Configuration/ScheduleCase", 73 | "environment" => "test", 74 | "debug" => false 75 | ) 76 | ); 77 | $this->app = new Application($this->client->getKernel()); 78 | // add jms-job-id option to the application in order to be able to run scheduler 79 | $this->app->getDefinition()->addOption( 80 | new InputOption('--jms-job-id', null, InputOption::VALUE_REQUIRED, 'The ID of the Job.') 81 | ); 82 | $this->app->setAutoExit(false); 83 | $this->app->setCatchExceptions(false); 84 | 85 | return $this->app; 86 | } 87 | 88 | /** 89 | * @param string $em 90 | */ 91 | protected function initDbSchema($em = 'default'): void 92 | { 93 | $schemaCreateCommand = new CreateSchemaDoctrineCommand(); 94 | $this->runConsoleCommand($schemaCreateCommand, ["--em" => $em]); 95 | } 96 | 97 | /** 98 | * @param ContainerInterface $container 99 | * @param array|string|false $fixturesPath 100 | * @param string $em 101 | * @param bool|true $append 102 | * 103 | * @return void 104 | */ 105 | protected function loadFixtures( 106 | ContainerInterface $container, 107 | $fixturesPath = false, 108 | string $em = 'default', 109 | bool $append = true 110 | ): void { 111 | $fixtureLoadCommand = new LoadDataFixturesDoctrineCommand(); 112 | $fixtureLoadCommand->setContainer($container); 113 | $params = array( 114 | "--em" => $em, 115 | "--append" => $append 116 | ); 117 | if ($fixturesPath) { 118 | $params["--fixtures"] = $fixturesPath; 119 | } 120 | 121 | $this->runConsoleCommand($fixtureLoadCommand, $params); 122 | } 123 | 124 | /** 125 | * @large 126 | * @group functional 127 | */ 128 | public function testScheduleWorks(): void 129 | { 130 | $container = $this->client->getContainer(); 131 | /** @var EventDispatcherInterface $eventDispatcher */ 132 | $eventDispatcher = $container->get('event_dispatcher'); 133 | 134 | $_SERVER['SYMFONY_CONSOLE_FILE'] = $container->getParameter('kernel.root_dir') . DIRECTORY_SEPARATOR . 'console'; 135 | 136 | /** @var EntityManager $clientEm */ 137 | $clientEm = $container->get("doctrine")->getManagerForClass(Client::class); 138 | $clientRepo = $clientEm->getRepository(Client::class); 139 | /** @var Client $subject */ 140 | $subject = $clientRepo->findOneBy(['name' => "Johnny"]); 141 | $event = new Event($subject); 142 | 143 | // simply check that client can be activated (also sleeping transition should be scheduled (+1s) inside) 144 | $eventDispatcher->dispatch('activating.event', $event); 145 | self::assertEquals('active', $subject->getStatus()); 146 | 147 | // sleep for a while (wait 2 sec to avoid "the same second" collision) 148 | // and run scheduler with 1-sec runtime to check that sleeping transition is executed 149 | sleep(2); 150 | $this->runScheduler(1); 151 | $clientEm->refresh($subject); 152 | self::assertEquals('sleeping', $subject->getStatus()); 153 | 154 | $eventDispatcher->dispatch('prolong.event', $event); 155 | 156 | // sleep for a while (in order to execute scheduler in the time when closing transition should be applied without prolong.event fired) 157 | // and run scheduler with 1-sec runtime to check that closed transition is not executed due to prolongation 158 | usleep(500000); 159 | $this->runScheduler(1); 160 | $clientEm->refresh($subject); 161 | self::assertEquals('sleeping', $subject->getStatus()); 162 | 163 | // sleep for a while (some time we waste during waiting that scheduler finish his work so no need to wait so long) 164 | // and run scheduler with 1-sec runtime to check that closed transition is executed 165 | $this->runScheduler(1); 166 | $clientEm->refresh($subject); 167 | self::assertEquals('closed', $subject->getStatus()); 168 | } 169 | 170 | protected function runScheduler($runtime = 1) 171 | { 172 | $schedulerCommand = new RunCommand(); 173 | $this->runConsoleCommand( 174 | $schedulerCommand, 175 | [ 176 | '--max-runtime' => $runtime, 177 | // set worker name explicitly in order to avoid errors caused by 50 characters name restrictions on 178 | // testing envs like travis 179 | '--worker-name' => uniqid("worker_", true) 180 | ] 181 | ); 182 | } 183 | 184 | private function runConsoleCommand(Command $command, array $params = []): int 185 | { 186 | $command->setApplication($this->app); 187 | // use CommandTester to simple command running 188 | $commandRunner = new CommandTester($command); 189 | return $commandRunner->execute($params); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /tests/Functional/TestCase.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 29.06.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Functional; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Functional\Kernel\KernelBuilder; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\Functional\Kernel\TestKernelInterface; 17 | use Symfony\Bundle\FrameworkBundle\Test\WebTestCase as BaseWebTestCase; 18 | use Symfony\Component\Filesystem\Filesystem; 19 | 20 | class TestCase extends BaseWebTestCase 21 | { 22 | /** 23 | * {@inheritdoc} 24 | */ 25 | public static function tearDownAfterClass(): void 26 | { 27 | static::deleteTmpDirs(); 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | protected static function getKernelClass() 34 | { 35 | if (defined("KERNEL_CLASS")) { 36 | return KERNEL_CLASS; 37 | } else { 38 | return null; 39 | } 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | protected static function createKernel(array $options = array()) 46 | { 47 | return KernelBuilder::getKernel(static::getKernelClass(), $options); 48 | } 49 | 50 | /** 51 | * Clears temporary test kernel application folder in case of kernel implements TestKernelInterface 52 | * @see TestKernelInterface::getTempAppDir 53 | * 54 | * @return void 55 | */ 56 | protected static function deleteTmpDirs(): void 57 | { 58 | if (static::$kernel) { 59 | $kernel = static::$kernel; 60 | $fs = new Filesystem(); 61 | if ($kernel instanceof TestKernelInterface) { 62 | $fs->remove($kernel->getTempAppDir()); 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/Guard/ExpressionGuardTest.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 18.07.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Guard; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\UnsupportedGuardEventException; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject\SubjectManipulator; 17 | use PHPUnit\Framework\MockObject\MockObject; 18 | use PHPUnit\Framework\TestCase; 19 | use Psr\Log\LoggerInterface; 20 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 21 | use Symfony\Component\Workflow\Event\GuardEvent; 22 | use Symfony\Component\Workflow\Registry; 23 | use Symfony\Component\Workflow\Transition; 24 | use Symfony\Component\Workflow\Workflow; 25 | 26 | class ExpressionGuardTest extends TestCase 27 | { 28 | public function testHandlingUnsupportedEventsTriggersException(): void 29 | { 30 | $this->expectException(UnsupportedGuardEventException::class); 31 | $guard = new ExpressionGuard( 32 | $this->getMockBuilder(ExpressionLanguage::class)->disableOriginalConstructor()->getMock(), 33 | $this->getMockBuilder(SubjectManipulator::class)->disableOriginalConstructor()->getMock(), 34 | $this->getMockBuilder(Registry::class)->disableOriginalConstructor()->getMock(), 35 | $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock() 36 | ); 37 | $event = $this->createMockedEvent(); 38 | 39 | $guard->guardTransition($event, "ghost_event"); 40 | } 41 | 42 | public function testGuardExpressionFailuresAreReportedByLogger(): void 43 | { 44 | $logger = $this->getMockBuilder(LoggerInterface::class)->getMockForAbstractClass(); 45 | $logger->expects(self::once())->method('error'); 46 | 47 | $invalidExpression = "expression"; 48 | $language = $this->getMockBuilder(ExpressionLanguage::class)->disableOriginalConstructor()->getMock(); 49 | $language->expects(self::once())->method("evaluate")->with($invalidExpression)->willThrowException(new \Exception()); 50 | 51 | $guard = new ExpressionGuard( 52 | $language, 53 | $this->getMockBuilder(SubjectManipulator::class)->disableOriginalConstructor()->getMock(), 54 | $this->prepareValidWorkflowRegistryMock(), 55 | $logger 56 | ); 57 | 58 | $guard->registerGuardExpression("eventName", "workflow", $invalidExpression); 59 | 60 | $guard->guardTransition( 61 | $this->createMockedEvent(), 62 | "eventName" 63 | ); 64 | } 65 | 66 | public function testGuardExpressionFailuresDoNotBlocksTransition(): void 67 | { 68 | $logger = $this->getMockBuilder(LoggerInterface::class)->getMockForAbstractClass(); 69 | $logger->expects(self::once())->method('error'); 70 | 71 | $invalidExpression = "expression"; 72 | $language = $this->getMockBuilder(ExpressionLanguage::class)->disableOriginalConstructor()->getMock(); 73 | $language->expects(self::once())->method("evaluate")->with($invalidExpression)->willThrowException(new \Exception()); 74 | 75 | $guard = new ExpressionGuard( 76 | $language, 77 | $this->getMockBuilder(SubjectManipulator::class)->disableOriginalConstructor()->getMock(), 78 | $this->prepareValidWorkflowRegistryMock(), 79 | $logger 80 | ); 81 | 82 | $event = $this->createMockedEvent(); 83 | $event->expects(self::never())->method("setBlocked"); 84 | 85 | $guard->registerGuardExpression("eventName", "workflow", "expression"); 86 | $guard->guardTransition($event ,"eventName"); 87 | } 88 | 89 | /** 90 | * @dataProvider expressionProvider 91 | */ 92 | public function testValidExpressionAllowsOrBlocksTransitionWithLogReport( 93 | string $expression, 94 | $expressionResult, 95 | bool $blockTransition, 96 | bool $convertToBoolean = false 97 | ): void { 98 | $logger = $this->getMockBuilder(LoggerInterface::class)->getMockForAbstractClass(); 99 | 100 | $language = $this->getMockBuilder(ExpressionLanguage::class)->disableOriginalConstructor()->getMock(); 101 | $language->expects(self::once())->method("evaluate")->with($expression)->willReturn($expressionResult); 102 | 103 | $guard = new ExpressionGuard( 104 | $language, 105 | $this->getMockBuilder(SubjectManipulator::class)->disableOriginalConstructor()->getMock(), 106 | $this->prepareValidWorkflowRegistryMock(), 107 | $logger 108 | ); 109 | 110 | $event = $this->createMockedEvent(); 111 | $event->expects(self::once())->method("setBlocked")->with($blockTransition); 112 | 113 | $loggerInvocationCount = 0; 114 | if ($convertToBoolean) { 115 | // in case of convertation logger should report about it 116 | $loggerInvocationCount++; 117 | } 118 | if ($blockTransition) { 119 | // in case of transition blocking logger should report about it 120 | $loggerInvocationCount++; 121 | $transition = $this->getMockBuilder(Transition::class)->disableOriginalConstructor()->getMock(); 122 | $transition->expects(self::once())->method("getName"); 123 | $event->expects(self::once())->method("getTransition")->willReturn($transition); 124 | } 125 | $logger->expects($this->exactly($loggerInvocationCount))->method("debug"); 126 | 127 | $guard->registerGuardExpression("eventName", "workflow", $expression); 128 | $guard->guardTransition($event ,"eventName"); 129 | } 130 | 131 | public function expressionProvider(): array 132 | { 133 | return [ 134 | ["boolenFalseExpression", false, false], 135 | ["boolenTrueExpression", true, true], 136 | // convertations to boolean 137 | ["convertableToTrueExpression", "1", true, true], 138 | ["convertableToFalseExpression", "0", false, true], 139 | ["convertableToTrueExpression", 1, true, true], 140 | ["convertableToFalseExpression", 0, false, true], 141 | ]; 142 | } 143 | 144 | /** 145 | * @return MockObject|Registry 146 | */ 147 | private function prepareValidWorkflowRegistryMock(): Registry 148 | { 149 | $workflow = $this->getMockBuilder(Workflow::class) 150 | ->setMethods(['getName']) 151 | ->disableOriginalConstructor() 152 | ->getMock(); 153 | $workflow->expects(self::once())->method('getName')->willReturn('test'); 154 | $workflowRegistry = $this->getMockBuilder(Registry::class) 155 | ->setMethods(['get']) 156 | ->getMockForAbstractClass(); 157 | $workflowRegistry->expects(self::once())->method('get')->willReturn($workflow); 158 | 159 | return $workflowRegistry; 160 | } 161 | 162 | /** 163 | * @return GuardEvent|MockObject 164 | */ 165 | private function createMockedEvent(): GuardEvent 166 | { 167 | $mock = $this->getMockBuilder(GuardEvent::class) 168 | ->setMethods(['getSubject', 'setBlocked', 'getTransition']) 169 | ->disableOriginalConstructor() 170 | ->getMock(); 171 | 172 | $mock->expects(self::any()) 173 | ->method('getSubject') 174 | ->willReturn(new \stdClass()); 175 | 176 | return $mock; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /tests/Schedule/ActionSchedulerTest.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 09.08.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Schedule; 14 | 15 | use Carbon\Carbon; 16 | use Doctrine\Common\Persistence\ObjectManager; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Entity\Repository\ScheduledJobRepository; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\Entity\ScheduledJob; 19 | use Gtt\Bundle\WorkflowExtensionsBundle\Schedule\ValueObject\ScheduledAction; 20 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 21 | use JMS\JobQueueBundle\Entity\Job; 22 | use PHPUnit\Framework\MockObject\MockObject; 23 | use PHPUnit\Framework\TestCase; 24 | use Psr\Log\LoggerInterface; 25 | use Symfony\Component\Workflow\Workflow; 26 | 27 | class ActionSchedulerTest extends TestCase 28 | { 29 | public function tearDown(): void 30 | { 31 | parent::tearDown(); 32 | Carbon::setTestNow(); 33 | } 34 | 35 | /** 36 | * @dataProvider scheduledActionProvider 37 | */ 38 | public function testSchedulerSchedulesCorrectJob(ScheduledAction $action, bool $alreadyScheduled): void 39 | { 40 | /** @var WorkflowContext $workflowContext */ 41 | [$workflowContext, $repository, $em] = $this->setupSchedulerContext(); 42 | 43 | Carbon::setTestNow(Carbon::now()); 44 | $now = Carbon::now(); 45 | 46 | $executeJobAfter = clone $now; 47 | $executeJobAfter->add($action->getOffset()); 48 | 49 | if ($action->isReschedulable()) { 50 | if ($alreadyScheduled) { 51 | // expecting rescheduling 52 | $actionJob = new Job('command', ['--some' => 'args']); 53 | $scheduledJob = new ScheduledJob($actionJob); 54 | 55 | $expectedActionJob = clone $actionJob; 56 | $expectedActionJob->setExecuteAfter($executeJobAfter); 57 | 58 | $repository->expects(self::once())->method('findScheduledJobToReschedule')->willReturn($scheduledJob); 59 | $em->expects(self::once())->method('persist')->with(self::callback(static function (Job $actual) use ($expectedActionJob) { 60 | self::assertEquals($expectedActionJob->getExecuteAfter(), $actual->getExecuteAfter()); 61 | 62 | return true; 63 | })); 64 | } else { 65 | // expecting the new one 66 | $repository->expects(self::once())->method('findScheduledJobToReschedule')->willReturn(null); 67 | $this->configureEmToExpectNewJobCreation($em, $action, $workflowContext, $executeJobAfter); 68 | } 69 | } else { 70 | // non-reschedulable action - expecting creation of the new job 71 | $repository->expects(self::never())->method('findScheduledJobToReschedule')->willReturn(null); 72 | $this->configureEmToExpectNewJobCreation($em, $action, $workflowContext, $executeJobAfter); 73 | } 74 | 75 | $scheduler = new ActionScheduler($em, $this->getMockBuilder(LoggerInterface::class)->getMockForAbstractClass()); 76 | $scheduler->scheduleAction($workflowContext, $action); 77 | } 78 | 79 | public function scheduledActionProvider(): array 80 | { 81 | return [ 82 | [new ScheduledAction('a1', [], 'PT1S'), true], 83 | [new ScheduledAction('a1', [], 'PT1S'), false], 84 | [new ScheduledAction('a1', [], 'PT1S', true), true], 85 | [new ScheduledAction('a1', [], 'PT1S', true), false] 86 | ]; 87 | } 88 | 89 | private function configureEmToExpectNewJobCreation(ObjectManager $em, ScheduledAction $action, WorkflowContext $workflowContext, \DateTime $executeJobAfter): void 90 | { 91 | $expectedActionJob = new Job( 92 | 'workflow:action:execute', 93 | [ 94 | '--action=' . $action->getName(), 95 | '--arguments=' . json_encode($action->getArguments()), 96 | '--workflow=' . $workflowContext->getWorkflow()->getName(), 97 | '--subjectClass=' . get_class($workflowContext->getSubject()), 98 | '--subjectId=' . $workflowContext->getSubjectId() 99 | ] 100 | ); 101 | 102 | $expectedActionJob->setExecuteAfter($executeJobAfter); 103 | 104 | /** @var $em MockObject|ObjectManager */ 105 | $em->expects(self::exactly(2))->method('persist')->withConsecutive( 106 | self::equalTo($expectedActionJob), 107 | self::equalTo(new ScheduledJob($expectedActionJob, $action->isReschedulable())) 108 | ); 109 | } 110 | 111 | /** 112 | * @return array 113 | */ 114 | private function setupSchedulerContext(): array 115 | { 116 | $subject = new \StdClass(); 117 | $subjectId = '1'; 118 | 119 | $workflow = $this->getMockBuilder(Workflow::class)->disableOriginalConstructor()->getMock(); 120 | $workflow->expects(self::any())->method('getName')->willReturn('w1'); 121 | 122 | $workflowContext = new WorkflowContext($workflow, $subject, $subjectId); 123 | 124 | $repository = $this->getMockBuilder(ScheduledJobRepository::class)->disableOriginalConstructor()->getMock(); 125 | 126 | $em = $this->getMockBuilder(ObjectManager::class)->disableOriginalConstructor()->getMock(); 127 | $em->expects(self::once())->method('getRepository')->with(ScheduledJob::class)->willReturn($repository); 128 | 129 | return [$workflowContext, $repository, $em]; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /tests/Trigger/Event/AbstractActionListenerTest.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Date: 17.10.16 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Trigger\Event; 15 | 16 | use Gtt\Bundle\WorkflowExtensionsBundle\DependencyInjection\Enum\ActionArgumentTypes; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\ActionException; 18 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 19 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject\SubjectManipulator; 20 | use PHPUnit\Framework\TestCase; 21 | use Psr\Log\LoggerInterface; 22 | use ReflectionMethod; 23 | use Symfony\Component\EventDispatcher\Event; 24 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 25 | use Symfony\Component\Workflow\Registry; 26 | 27 | class AbstractActionListenerTest extends TestCase 28 | { 29 | /** 30 | * @dataProvider argumentsProvider 31 | */ 32 | public function testResolveActionArguments(array $inputArgArray, $expectedResult, ExpressionLanguage $actionLanguage = null): void 33 | { 34 | if ($actionLanguage) { 35 | /** @var AbstractActionListener $listener */ 36 | $listener = $this->getMockForAbstractClass( 37 | AbstractActionListener::class, 38 | [ 39 | $this->getMockBuilder(ExpressionLanguage::class)->disableOriginalConstructor()->getMock(), 40 | $this->getMockBuilder(SubjectManipulator::class)->disableOriginalConstructor()->getMock(), 41 | $this->getMockBuilder(Registry::class)->disableOriginalConstructor()->getMock(), 42 | $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(), 43 | $actionLanguage 44 | ] 45 | ); 46 | } else { 47 | /** @var AbstractActionListener $listener */ 48 | $listener = $this->getMockForAbstractClass(AbstractActionListener::class, [], "", false); 49 | } 50 | 51 | $resolveActionArgumentsMethodRef = new ReflectionMethod($listener, 'resolveActionArguments'); 52 | $resolveActionArgumentsMethodRef->setAccessible(true); 53 | 54 | $invokeArgs = [ 55 | 'actionName', 56 | $inputArgArray, 57 | new Event(), 58 | $this->getMockBuilder(WorkflowContext::class)->disableOriginalConstructor()->getMock() 59 | ]; 60 | if ($expectedResult instanceof \Exception) { 61 | $this->expectException(get_class($expectedResult)); 62 | $resolveActionArgumentsMethodRef->invokeArgs($listener, $invokeArgs); 63 | } else { 64 | self::assertEquals($expectedResult, $resolveActionArgumentsMethodRef->invokeArgs($listener, $invokeArgs)); 65 | } 66 | } 67 | 68 | public function argumentsProvider(): array 69 | { 70 | $data = []; 71 | 72 | // correct deep array 73 | $validExpression1 = 'e1'; 74 | $validExpression2 = 'e2'; 75 | $expressionResult1 = 'r1'; 76 | $expressionResult2 = 'r2'; 77 | $actionLanguage = $this->getMockBuilder(ExpressionLanguage::class)->disableOriginalConstructor()->getMock(); 78 | $actionLanguage->expects(self::exactly(2))->method('evaluate')->will( 79 | self::onConsecutiveCalls($expressionResult1, $expressionResult2) 80 | ); 81 | 82 | $data[] = [ 83 | [ 84 | [ 85 | 'type' => ActionArgumentTypes::TYPE_EXPRESSION, 86 | 'value' => $validExpression1 87 | ], 88 | [ 89 | 'type' => ActionArgumentTypes::TYPE_SCALAR, 90 | 'value' => 123e4 91 | ], 92 | [ 93 | 'type' => ActionArgumentTypes::TYPE_ARRAY, 94 | 'value' => 95 | [ 96 | [ 97 | 'type' => ActionArgumentTypes::TYPE_ARRAY, 98 | 'value' => 99 | [ 100 | [ 101 | 'type' => ActionArgumentTypes::TYPE_EXPRESSION, 102 | 'value' => $validExpression2 103 | ] 104 | ] 105 | ], 106 | [ 107 | 'type' => ActionArgumentTypes::TYPE_SCALAR, 108 | 'value' => "string" 109 | ], 110 | [ 111 | 'type' => ActionArgumentTypes::TYPE_SCALAR, 112 | 'value' => 12 113 | ] 114 | ] 115 | ] 116 | ], 117 | [ 118 | $expressionResult1, 119 | 123e4, 120 | [ 121 | [ 122 | $expressionResult2 123 | ], 124 | 'string', 125 | 12 126 | ] 127 | ], 128 | $actionLanguage 129 | ]; 130 | 131 | // invalid expression 1 132 | $actionLanguage = $this->getMockBuilder(ExpressionLanguage::class)->disableOriginalConstructor()->getMock(); 133 | $actionLanguage->expects(self::once())->method('evaluate')->willReturn(new \StdClass()); 134 | $data[] = [ 135 | [ 136 | [ 137 | 'type' => ActionArgumentTypes::TYPE_EXPRESSION, 138 | 'value' => "some exp" 139 | ] 140 | ], 141 | new ActionException(), 142 | $actionLanguage 143 | ]; 144 | 145 | // invalid expression 2 146 | $actionLanguage = $this->getMockBuilder(ExpressionLanguage::class)->disableOriginalConstructor()->getMock(); 147 | $actionLanguage->expects(self::once())->method('evaluate')->willReturn(["test" => 1]); 148 | $data[] = [ 149 | [ 150 | [ 151 | 'type' => ActionArgumentTypes::TYPE_EXPRESSION, 152 | 'value' => "some exp" 153 | ] 154 | ], 155 | new ActionException(), 156 | $actionLanguage 157 | ]; 158 | 159 | // associative array keys are dropped 160 | $data[] = [ 161 | [ 162 | [ 163 | 'type' => ActionArgumentTypes::TYPE_ARRAY, 164 | 'value' => [ 165 | "test" => [ 166 | 'type' => ActionArgumentTypes::TYPE_SCALAR, 167 | 'value' => 123 168 | ] 169 | ] 170 | ] 171 | ], 172 | [[123]] 173 | ]; 174 | 175 | return $data; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /tests/Trigger/Event/AbstractListenerTest.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 14.07.16 10 | */ 11 | declare(strict_types=1); 12 | 13 | namespace Gtt\Bundle\WorkflowExtensionsBundle\Trigger\Event; 14 | 15 | use Gtt\Bundle\WorkflowExtensionsBundle\Exception\UnsupportedTriggerEventException; 16 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowContext; 17 | use Gtt\Bundle\WorkflowExtensionsBundle\WorkflowSubject\SubjectManipulator; 18 | use PHPUnit\Framework\MockObject\MockObject; 19 | use PHPUnit\Framework\TestCase; 20 | use Psr\Log\LoggerInterface; 21 | use ReflectionMethod; 22 | use Symfony\Component\EventDispatcher\Event; 23 | use Symfony\Component\ExpressionLanguage\ExpressionLanguage; 24 | use Symfony\Component\Workflow\Registry; 25 | use Symfony\Component\Workflow\Workflow; 26 | 27 | class AbstractListenerTest extends TestCase 28 | { 29 | public function testHandlingUnsupportedEventsTriggersException(): void 30 | { 31 | $this->expectException(UnsupportedTriggerEventException::class); 32 | /** @var AbstractListener $listener */ 33 | $listener = self::getMockForAbstractClass(AbstractListener::class, [], "", false); 34 | $listener->dispatchEvent(new Event(), "ghost_event"); 35 | } 36 | 37 | public function testUnretrievableSubjectIsReportedByLogger() 38 | { 39 | $logger = $this->getMockBuilder(LoggerInterface::class)->getMockForAbstractClass(); 40 | $logger->expects(self::once())->method('error'); 41 | 42 | [$event, $expression, $language] = $this->getEventExpressionAndLanguage(true); 43 | 44 | /** @var AbstractListener $listener */ 45 | $listener = self::getMockForAbstractClass( 46 | AbstractListener::class, 47 | [ 48 | $language, 49 | $this->getMockBuilder(SubjectManipulator::class)->disableOriginalConstructor()->getMock(), 50 | $this->getMockBuilder(Registry::class)->disableOriginalConstructor()->getMock(), 51 | $logger 52 | ] 53 | ); 54 | 55 | $configureEventMethodRef = new ReflectionMethod($listener, 'configureSubjectRetrievingForEvent'); 56 | $configureEventMethodRef->setAccessible(true); 57 | $configureEventMethodRef->invokeArgs($listener, ['eventName', 'workflowName', $expression]); 58 | 59 | $listener->dispatchEvent($event, "eventName"); 60 | } 61 | 62 | public function testSupportedEventIsHandled(): void 63 | { 64 | [$event, $expression, $language] = $this->getEventExpressionAndLanguage(); 65 | $eventName = "eventName"; 66 | 67 | $workflowRegistry = $this->getMockBuilder(Registry::class)->disableOriginalConstructor()->getMock(); 68 | $workflowRegistry->expects(self::once())->method('get')->willReturn( 69 | $this->getMockBuilder(Workflow::class)->disableOriginalConstructor()->getMock() 70 | ); 71 | 72 | /** @var AbstractListener|MockObject $listener */ 73 | $listener = self::getMockForAbstractClass( 74 | AbstractListener::class, 75 | [ 76 | $language, 77 | $this->getMockBuilder(SubjectManipulator::class)->disableOriginalConstructor()->getMock(), 78 | $workflowRegistry, 79 | $this->getMockBuilder(LoggerInterface::class)->getMockForAbstractClass() 80 | ] 81 | ); 82 | 83 | $listener 84 | ->expects(self::once()) 85 | ->method('handleEvent') 86 | ->with( 87 | self::equalTo($eventName), 88 | self::equalTo($event), 89 | // event config 90 | self::isType('array'), 91 | self::isInstanceOf(WorkflowContext::class) 92 | ) 93 | ; 94 | 95 | $configureEventMethodRef = new ReflectionMethod($listener, 'configureSubjectRetrievingForEvent'); 96 | $configureEventMethodRef->setAccessible(true); 97 | $configureEventMethodRef->invokeArgs($listener, ['eventName', 'workflowName', $expression]); 98 | 99 | $listener->dispatchEvent($event, $eventName); 100 | } 101 | 102 | /** 103 | * @return array 104 | */ 105 | private function getEventExpressionAndLanguage(bool $expressionEvaluationShouldThrowException = false): array 106 | { 107 | $subject = new \StdClass(); 108 | $event = new Event(); 109 | 110 | $expression = "expression"; 111 | $language = $this->getMockBuilder(ExpressionLanguage::class)->disableOriginalConstructor()->getMock(); 112 | if ($expressionEvaluationShouldThrowException) { 113 | $language->expects(self::once())->method("evaluate")->with($expression, ['event' => $event])->willThrowException(new \Exception()); 114 | } else { 115 | $language->expects(self::once())->method("evaluate")->with($expression, ['event' => $event])->willReturn($subject); 116 | } 117 | return array($event, $expression, $language); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 9 | * @date 17.07.15 10 | */ 11 | 12 | use Composer\Autoload\ClassLoader; 13 | use Doctrine\Common\Annotations\AnnotationRegistry; 14 | 15 | /** 16 | * @var ClassLoader $loader 17 | */ 18 | $loader = require __DIR__.'/../vendor/autoload.php'; 19 | AnnotationRegistry::registerLoader(array($loader, 'loadClass')); 20 | return $loader; 21 | --------------------------------------------------------------------------------