├── docs ├── _config.yml ├── exceptions.md ├── reusability.md ├── traversability.md ├── callbacks.md ├── serialization.md ├── citizens.md ├── events.md ├── index.md ├── usage.md └── interruptions.md ├── .gitignore ├── mkdocs.yml ├── tests ├── bootstrap.php ├── DummyClass.php ├── DummyCallback.php ├── SendToTest.php ├── StructuralTest.php ├── NodeTest.php └── FlowBranchTest.php ├── src ├── NodalFlowException.php ├── Flows │ ├── FlowIdInterface.php │ ├── FlowIdTrait.php │ ├── FlowRegistryInterface.php │ ├── FlowAbstract.php │ ├── FlowMapInterface.php │ ├── FlowStatusInterface.php │ ├── InterrupterInterface.php │ ├── FlowAncestryAbstract.php │ ├── FlowStatus.php │ ├── FlowRegistry.php │ ├── FlowInterruptAbstract.php │ ├── FlowInterface.php │ └── FlowEventAbstract.php ├── Nodes │ ├── BranchNodeInterface.php │ ├── ExecNodeInterface.php │ ├── TraversableNodeInterface.php │ ├── PayloadNodeInterface.php │ ├── AggregateNodeInterface.php │ ├── PayloadNodeFactoryInterface.php │ ├── InterruptNodeInterface.php │ ├── BranchNode.php │ ├── CallableInterruptNode.php │ ├── ClosureNode.php │ ├── PayloadNodeAbstract.php │ ├── CallableNode.php │ ├── InterruptNodeAbstract.php │ ├── NodeInterface.php │ ├── AggregateNode.php │ └── NodeAbstract.php ├── Events │ ├── FlowEvent.php │ ├── FlowEventInterface.php │ ├── CallbackWrapper.php │ └── FlowEventProxyTrait.php ├── Callbacks │ ├── CallbackInterface.php │ └── CallbackAbstract.php ├── PayloadNodeFactory.php ├── Interrupter.php └── NodalFlow.php ├── phpunit.xml ├── LICENSE ├── composer.json ├── .github └── workflows │ ├── qa.yml │ └── ci.yml ├── .gitattributes ├── .php_cs ├── .php-cs-fixer.dist.php └── README.md /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | .*.cache 3 | composer.lock 4 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: 'NodalFlow' 2 | pages: 3 | - 'NodalFlow Documentation': 'index.md' 4 | - 'Usage': 'usage.md' 5 | - 'NodalFlow Citizens': 'citizens.md' 6 | - 'Traversability': 'traversability.md' 7 | - 'Serialization': 'serialization.md' 8 | - 'Interruptions': 'interruptions.md' 9 | - 'Events': 'events.md' 10 | - 'Callbacks': 'callbacks.md' 11 | - 'Exceptions': 'exceptions.md' 12 | - 'Reusability': 'reusability.md' 13 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | exec($param); 7 | ``` 8 | 9 | or wrapped in a flow: 10 | 11 | ```php 12 | (new NodalFlow)->add($node)->exec($param); 13 | ``` 14 | 15 | And the same goes with Traversable Nodes: 16 | 17 | ```php 18 | foreach ($traversableNode->getTraversable($param) as $value) { 19 | // do something with $value 20 | } 21 | ``` 22 | 23 | All this means that while implementing flows, you create other opportunities either withing or outside the flow which will save more and more time over time, as long as you need some sort of flows. 24 | 25 | And in fact, the overhead of doing so is very small, especially if your Traversable Node is a [`Generator`](http://php.net/Generator) yielding values. 26 | -------------------------------------------------------------------------------- /src/Nodes/PayloadNodeFactoryInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | src/ 19 | 20 | 21 | 22 | 23 | ./tests/ 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Nodes/InterruptNodeInterface.php: -------------------------------------------------------------------------------- 1 | 2nd traversable 1st records -> last traversable every records ...). 6 | 7 | Upon each iteration, the remaining Nodes in the flow will be recurred over. This is for example useful when a data generator needs some kind of manipulation(s) and / or actions on each of his "records". 8 | -------------------------------------------------------------------------------- /src/Callbacks/CallbackInterface.php: -------------------------------------------------------------------------------- 1 | payload->exec($param); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/qa.yml: -------------------------------------------------------------------------------- 1 | name: QA 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | types: [ opened, synchronize ] 8 | jobs: 9 | tests: 10 | name: NodalFlow QA 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: 8.1 21 | extensions: mbstring, dom, fileinfo, gmp, bcmath 22 | coverage: xdebug 23 | 24 | - name: Get composer cache directory 25 | id: composer-cache 26 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 27 | 28 | - name: Cache composer dependencies 29 | uses: actions/cache@v3 30 | with: 31 | path: ${{ steps.composer-cache.outputs.dir }} 32 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 33 | restore-keys: ${{ runner.os }}-composer- 34 | 35 | - name: Remove composer.lock 36 | run: rm -f composer.lock 37 | 38 | - name: Install Composer dependencies 39 | run: composer install --no-progress --prefer-dist --optimize-autoloader 40 | 41 | - name: Check code style 42 | run: vendor/bin/php-cs-fixer fix --config=./.php-cs-fixer.dist.php --verbose --dry-run --using-cache=no 43 | 44 | - name: Compute Coverage 45 | run: vendor/bin/phpunit --coverage-clover ./coverage.xml 46 | 47 | - name: Upload coverage to Codecov 48 | uses: codecov/codecov-action@v3 49 | with: 50 | files: ./coverage.xml 51 | flags: unittests 52 | name: codecov-nodalflow 53 | -------------------------------------------------------------------------------- /src/Nodes/CallableInterruptNode.php: -------------------------------------------------------------------------------- 1 | interrupter = $interrupter; 35 | parent::__construct(); 36 | } 37 | 38 | /** 39 | * @param mixed $param 40 | * 41 | * @return InterrupterInterface|null|bool `null` do do nothing, eg let the Flow proceed untouched 42 | * `true` to trigger a continue on the carrier Flow (not ancestors) 43 | * `false` to trigger a break on the carrier Flow (not ancestors) 44 | * `InterrupterInterface` to trigger an interrupt to propagate up to a target (which may be one ancestor) 45 | */ 46 | public function interrupt($param) 47 | { 48 | return \call_user_func($this->interrupter, $param); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Nodes/ClosureNode.php: -------------------------------------------------------------------------------- 1 | payload; 45 | foreach ($callable($param) as $value) { 46 | yield $value; 47 | } 48 | } 49 | 50 | /** 51 | * Execute this Node (payload must be consistent for the usage) 52 | * 53 | * @param mixed|null $param 54 | * 55 | * @return mixed 56 | */ 57 | public function exec($param = null) 58 | { 59 | $callable = $this->payload; 60 | 61 | return $callable($param); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Nodes/PayloadNodeAbstract.php: -------------------------------------------------------------------------------- 1 | payload = $payload; 40 | $this->isAFlow = (bool) ($payload instanceof FlowInterface); 41 | $this->isAReturningVal = (bool) $isAReturningVal; 42 | // let wrong traversability be enforced by parent 43 | $this->isATraversable = (bool) $isATraversable; 44 | 45 | parent::__construct(); 46 | } 47 | 48 | /** 49 | * Get this Node's Payload 50 | * 51 | * @return object|callable 52 | */ 53 | public function getPayload() 54 | { 55 | return $this->payload; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Nodes/CallableNode.php: -------------------------------------------------------------------------------- 1 | payload, $param); 51 | } 52 | 53 | /** 54 | * Get this Node's Traversable 55 | * 56 | * @param mixed $param 57 | * 58 | * @return Generator 59 | */ 60 | public function getTraversable($param = null): iterable 61 | { 62 | foreach (\call_user_func($this->payload, $param) as $value) { 63 | yield $value; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Callbacks/CallbackAbstract.php: -------------------------------------------------------------------------------- 1 | getFlowStatus()->isDirty()) { 53 | * // a node broke the flow 54 | * }` 55 | */ 56 | } 57 | 58 | /** 59 | * Triggered when a Flow fails 60 | * 61 | * @param FlowInterface $flow 62 | */ 63 | public function fail(FlowInterface $flow) 64 | { 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/PayloadNodeFactory.php: -------------------------------------------------------------------------------- 1 | id = null; 42 | // need to detach node from carrier 43 | if ($this instanceof NodeInterface) { 44 | $this->setCarrier(null); 45 | } 46 | } 47 | 48 | /** 49 | * Return the immutable unique Flow / Node id 50 | * Since this method is not used in the actual 51 | * flow execution loop, but only when an interruption 52 | * is raised, it's not a performance issue to add an if. 53 | * And it's more convenient to lazy generate as this 54 | * trait does not need any init/construct logic. 55 | * 56 | * @throws Exception 57 | * 58 | * @return string Immutable unique id 59 | */ 60 | public function getId(): string 61 | { 62 | if ($this->id === null) { 63 | return $this->id = SoUuid::generate()->getString(); 64 | } 65 | 66 | return $this->id; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Flows/FlowRegistryInterface.php: -------------------------------------------------------------------------------- 1 | getTraversable(null) as $value) { 73 | $result = $result && $i === $value; 74 | ++$i; 75 | } 76 | 77 | return $result; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # laravel default 2 | *.css linguist-vendored 3 | *.less linguist-vendored 4 | 5 | # These settings are for any web project 6 | 7 | # Handle line endings automatically for files detected as text 8 | # and leave all files detected as binary untouched. 9 | * text=auto 10 | 11 | # 12 | # The above will handle all files NOT found below 13 | # 14 | 15 | # 16 | ## These files are text and should be normalized (Convert crlf => lf) 17 | # 18 | 19 | # source code 20 | *.php text 21 | *.css text 22 | *.sass text 23 | *.scss text 24 | *.less text 25 | *.styl text 26 | *.js text 27 | *.coffee text 28 | *.json text 29 | *.htm text 30 | *.html text 31 | *.xml text 32 | *.svg text 33 | *.txt text 34 | *.ini text 35 | *.inc text 36 | *.pl text 37 | *.rb text 38 | *.py text 39 | *.scm text 40 | *.sql text 41 | *.sh text 42 | *.bat text 43 | 44 | # templates 45 | *.ejs text 46 | *.hbt text 47 | *.jade text 48 | *.haml text 49 | *.hbs text 50 | *.dot text 51 | *.tmpl text 52 | *.phtml text 53 | 54 | # server config 55 | .htaccess text 56 | 57 | # git config 58 | .gitattributes text 59 | .gitignore text 60 | 61 | # code analysis config 62 | .jshintrc text 63 | .jscsrc text 64 | .jshintignore text 65 | .csslintrc text 66 | 67 | # misc config 68 | *.yaml text 69 | *.yml text 70 | .editorconfig text 71 | 72 | # build config 73 | *.npmignore text 74 | *.bowerrc text 75 | 76 | # Heroku 77 | Procfile text 78 | .slugignore text 79 | 80 | # Documentation 81 | *.md text 82 | LICENSE text 83 | AUTHORS text 84 | 85 | # 86 | ## These files are binary and should be left untouched 87 | # 88 | 89 | # (binary is a macro for -text -diff) 90 | *.png binary 91 | *.jpg binary 92 | *.jpeg binary 93 | *.gif binary 94 | *.ico binary 95 | *.mov binary 96 | *.mp4 binary 97 | *.mp3 binary 98 | *.flv binary 99 | *.fla binary 100 | *.swf binary 101 | *.gz binary 102 | *.zip binary 103 | *.7z binary 104 | *.ttf binary 105 | *.eot binary 106 | *.woff binary 107 | *.pyc binary 108 | *.pdf binary 109 | -------------------------------------------------------------------------------- /docs/callbacks.md: -------------------------------------------------------------------------------- 1 | # Callbacks (deprecated) 2 | 3 | Although _deprecated_, Callbacks works just exactly as before, but you should consider using the new [Event handling implementation](events.md) for future work. 4 | 5 | NodalFlow implements a KISS callback interface you can use to trigger callback events in various steps of the process. 6 | 7 | - the `start($flow)` method is triggered when the Flow starts 8 | - the `progress($flow, $node)` method is triggered each `$progressMod` time a full Flow iterates, which may occur whenever a `Traversable` node iterates. 9 | - the `success($flow)` method is triggered when the Flow completes successfully 10 | - the `fail($flow)` method is triggered when an exception was raised during the flow's execution. The exception is caught to perform few operations and re-thrown as is. 11 | 12 | Each of these trigger slots takes current flow as first argument, for each slot to allow control of the carrying flow. Please note that the flow provided may be a branch in some upstream flow. `progress($flow, $node)` additionally gets the current node as second argument which allows you to eventually get more insights about what is going on. 13 | Please note that there is no guarantee that you will see each node in `progress()` as this method is only triggered each `$progressMod` time the flow iterates, and this can occur in any `Traversable` node. 14 | 15 | NodalFlow also implements two protected method that will be triggered just before and after the flow's execution, `flowStrat()` and `flowEnd($success)`. You can override them to add more logic. These are not treated as events as they are always used by NodalFlow to provide with basic statistics. 16 | 17 | To use a callback, just implement `CallbackInterface` and inject it in the flow. 18 | 19 | ```php 20 | $flow = new NodalFlow; 21 | $callback = new ClassImplementingCallbackInterface; 22 | 23 | $flow->setCallBack($callback); 24 | ``` 25 | 26 | A `CallbackAbstract` providing with a NoOp implementation of `CallbackInterface` was added in case you only need to override few of the interface methods without implementing the others. 27 | -------------------------------------------------------------------------------- /src/Nodes/InterruptNodeAbstract.php: -------------------------------------------------------------------------------- 1 | interrupt($param); 51 | if ($flowInterrupt === null) { 52 | // do nothing, let the flow proceed 53 | return; 54 | } 55 | 56 | if ($flowInterrupt instanceof InterrupterInterface) { 57 | $flowInterruptType = $flowInterrupt->getType(); 58 | } elseif ($flowInterrupt) { 59 | $flowInterruptType = InterrupterInterface::TYPE_CONTINUE; 60 | $flowInterrupt = null; 61 | } else { 62 | $flowInterruptType = InterrupterInterface::TYPE_BREAK; 63 | $flowInterrupt = null; 64 | } 65 | 66 | /* @var null|InterrupterInterface $flowInterrupt */ 67 | $this->carrier->interruptFlow($flowInterruptType, $flowInterrupt); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Flows/FlowAbstract.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | public function getStats(): array 28 | { 29 | return $this->flowMap->getStats(); 30 | } 31 | 32 | /** 33 | * Get the stats array with latest Node stats 34 | * 35 | * @return FlowMapInterface 36 | */ 37 | public function getFlowMap(): FlowMapInterface 38 | { 39 | return $this->flowMap; 40 | } 41 | 42 | /** 43 | * Get the Node array 44 | * 45 | * @return NodeInterface[] 46 | */ 47 | public function getNodes(): array 48 | { 49 | return $this->nodes; 50 | } 51 | 52 | /** 53 | * Get/Generate Node Map 54 | * 55 | * @return array 56 | */ 57 | public function getNodeMap(): array 58 | { 59 | return $this->flowMap->getNodeMap(); 60 | } 61 | 62 | /** 63 | * The Flow status can either indicate be: 64 | * - clean (isClean()): everything went well 65 | * - dirty (isDirty()): one Node broke the flow 66 | * - exception (isException()): an exception was raised during the flow 67 | * 68 | * @return FlowStatusInterface 69 | */ 70 | public function getFlowStatus(): ? FlowStatusInterface 71 | { 72 | return $this->flowStatus; 73 | } 74 | 75 | /** 76 | * getId() alias for backward compatibility 77 | * 78 | * @throws Exception 79 | * 80 | * @return string 81 | * 82 | * @deprecated use `getId` instead 83 | */ 84 | public function getFlowId(): string 85 | { 86 | return $this->getId(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Flows/FlowMapInterface.php: -------------------------------------------------------------------------------- 1 | 87 | */ 88 | public function getStats(): array; 89 | } 90 | -------------------------------------------------------------------------------- /src/Flows/FlowStatusInterface.php: -------------------------------------------------------------------------------- 1 | nodes for 29 | * node additions and the current node index when executing the flow 30 | * 31 | * @var int 32 | */ 33 | protected $nodeIdx = 0; 34 | 35 | /** 36 | * The last index value 37 | * 38 | * @var int 39 | */ 40 | protected $lastIdx = 0; 41 | 42 | /** 43 | * The parent Flow, only set when branched 44 | * 45 | * @var FlowInterface|null 46 | */ 47 | protected $parent; 48 | 49 | /** 50 | * Set parent Flow, happens only when branched 51 | * 52 | * @param FlowInterface $flow 53 | * 54 | * @return $this 55 | */ 56 | public function setParent(FlowInterface $flow): FlowInterface 57 | { 58 | $this->parent = $flow; 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * Get eventual parent Flow 65 | * 66 | * @return FlowInterface 67 | */ 68 | public function getParent(): FlowInterface 69 | { 70 | return $this->parent; 71 | } 72 | 73 | /** 74 | * Tells if this flow has a parent 75 | * 76 | * @return bool 77 | */ 78 | public function hasParent(): bool 79 | { 80 | return isset($this->parent); 81 | } 82 | 83 | /** 84 | * Get this Flow's root Flow 85 | * 86 | * @param FlowInterface $flow Root Flow, or self if root flow 87 | * 88 | * @return FlowInterface 89 | */ 90 | public function getRootFlow(FlowInterface $flow): FlowInterface 91 | { 92 | while ($flow->hasParent()) { 93 | $flow = $flow->getParent(); 94 | } 95 | 96 | return $flow; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Events/CallbackWrapper.php: -------------------------------------------------------------------------------- 1 | callBack = $callBack; 35 | } 36 | 37 | /** 38 | * @return array 39 | */ 40 | public static function getSubscribedEvents() 41 | { 42 | return [ 43 | FlowEventInterface::FLOW_START => ['start', 0], 44 | FlowEventInterface::FLOW_PROGRESS => ['progress', 0], 45 | FlowEventInterface::FLOW_SUCCESS => ['success', 0], 46 | FlowEventInterface::FLOW_FAIL => ['fail', 0], 47 | ]; 48 | } 49 | 50 | /** 51 | * Triggered when a Flow starts 52 | * 53 | * @param FlowEventInterface $event 54 | */ 55 | public function start(FlowEventInterface $event) 56 | { 57 | $this->callBack->start($event->getFlow()); 58 | } 59 | 60 | /** 61 | * Triggered when a Flow progresses, 62 | * eg exec once or generates once 63 | * 64 | * @param FlowEventInterface $event 65 | */ 66 | public function progress(FlowEventInterface $event) 67 | { 68 | $this->callBack->progress($event->getFlow(), /* @scrutinizer ignore-type */ $event->getNode()); 69 | } 70 | 71 | /** 72 | * Triggered when a Flow completes without exceptions 73 | * 74 | * @param FlowEventInterface $event 75 | */ 76 | public function success(FlowEventInterface $event) 77 | { 78 | $this->callBack->success($event->getFlow()); 79 | } 80 | 81 | /** 82 | * Triggered when a Flow fails 83 | * 84 | * @param FlowEventInterface $event 85 | */ 86 | public function fail(FlowEventInterface $event) 87 | { 88 | $this->callBack->fail($event->getFlow()); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/DummyCallback.php: -------------------------------------------------------------------------------- 1 | hasStarted = true; 47 | } 48 | 49 | /** 50 | * Triggered when a Flow progresses, 51 | * eg exec once or generates once 52 | * 53 | * @param FlowInterface $flow 54 | * @param NodeInterface $node 55 | */ 56 | public function progress(FlowInterface $flow, NodeInterface $node) 57 | { 58 | ++$this->numProgress; 59 | } 60 | 61 | /** 62 | * Triggered when a Flow completes without exceptions 63 | * 64 | * @param FlowInterface $flow 65 | */ 66 | public function success(FlowInterface $flow) 67 | { 68 | $this->hasSucceeded = true; 69 | } 70 | 71 | /** 72 | * Triggered when a Flow fails 73 | * 74 | * @param FlowInterface $flow 75 | */ 76 | public function fail(FlowInterface $flow) 77 | { 78 | $this->hasFailed = true; 79 | } 80 | 81 | /** 82 | * @return bool 83 | */ 84 | public function hasStarted() 85 | { 86 | return $this->hasStarted; 87 | } 88 | 89 | /** 90 | * @return bool 91 | */ 92 | public function hasSucceeded() 93 | { 94 | return $this->hasSucceeded; 95 | } 96 | 97 | /** 98 | * @return bool 99 | */ 100 | public function hasFailed() 101 | { 102 | return $this->hasFailed; 103 | } 104 | 105 | /** 106 | * @return int 107 | */ 108 | public function getNumProgress() 109 | { 110 | return $this->numProgress; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Events/FlowEventProxyTrait.php: -------------------------------------------------------------------------------- 1 | flow = $flow; 46 | $this->node = $node; 47 | } 48 | 49 | /** 50 | * @return FlowInterface 51 | */ 52 | public function getFlow(): FlowInterface 53 | { 54 | return $this->flow; 55 | } 56 | 57 | /** 58 | * @return NodeInterface|null 59 | */ 60 | public function getNode(): ? NodeInterface 61 | { 62 | return $this->node; 63 | } 64 | 65 | /** 66 | * @param NodeInterface|null $node 67 | * 68 | * @return FlowEventInterface 69 | */ 70 | public function setNode(NodeInterface $node = null): FlowEventInterface 71 | { 72 | $this->node = $node; 73 | 74 | /* @var FlowEventInterface $this */ 75 | return $this; 76 | } 77 | 78 | /** 79 | * @return array 80 | */ 81 | public static function getEventList(): array 82 | { 83 | /* @var FlowEventInterface $this */ 84 | if (!isset(static::$eventList)) { 85 | static::$eventList = [ 86 | FlowEventInterface::FLOW_START => FlowEventInterface::FLOW_START, 87 | FlowEventInterface::FLOW_PROGRESS => FlowEventInterface::FLOW_PROGRESS, 88 | FlowEventInterface::FLOW_CONTINUE => FlowEventInterface::FLOW_CONTINUE, 89 | FlowEventInterface::FLOW_BREAK => FlowEventInterface::FLOW_BREAK, 90 | FlowEventInterface::FLOW_SUCCESS => FlowEventInterface::FLOW_SUCCESS, 91 | FlowEventInterface::FLOW_FAIL => FlowEventInterface::FLOW_FAIL, 92 | ]; 93 | } 94 | 95 | return static::$eventList; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Nodes/NodeInterface.php: -------------------------------------------------------------------------------- 1 | int 87 | * to add keyName as increment, starting at int 88 | * or : 89 | * 'keyName' => 'existingIncrement' 90 | * to assign keyName as a reference to an existingIncrement 91 | * 92 | * @return array 93 | */ 94 | public function getNodeIncrements(): array; 95 | } 96 | -------------------------------------------------------------------------------- /src/Nodes/AggregateNode.php: -------------------------------------------------------------------------------- 1 | isATraversable = true; 40 | } 41 | 42 | /** 43 | * Add a traversable to the aggregate 44 | * 45 | * @param TraversableNodeInterface $node 46 | * 47 | * @throws NodalFlowException 48 | * 49 | * @return $this 50 | */ 51 | public function addTraversable(TraversableNodeInterface $node): AggregateNodeInterface 52 | { 53 | $this->payload->add($node); 54 | 55 | return $this; 56 | } 57 | 58 | /** 59 | * Get the traversable to traverse within the Flow 60 | * 61 | * @param mixed $param 62 | * 63 | * @return Generator 64 | */ 65 | public function getTraversable($param = null): iterable 66 | { 67 | $value = null; 68 | /** @var $nodes TraversableNodeInterface[] */ 69 | $nodes = $this->payload->getNodes(); 70 | foreach ($nodes as $node) { 71 | $returnVal = $node->isReturningVal(); 72 | foreach ($node->getTraversable($param) as $value) { 73 | if ($returnVal) { 74 | yield $value; 75 | continue; 76 | } 77 | 78 | yield $param; 79 | } 80 | 81 | if ($returnVal) { 82 | // since this node is returning something 83 | // we will pass its last yield to the next 84 | // traversable. It will be up to him to 85 | // do whatever is necessary with it, including 86 | // nothing 87 | $param = $value; 88 | } 89 | } 90 | } 91 | 92 | /** 93 | * Execute the BranchNode 94 | * 95 | * @param mixed $param 96 | * 97 | * @throws NodalFlowException 98 | */ 99 | public function exec($param = null) 100 | { 101 | throw new NodalFlowException('AggregateNode cannot be executed, use getTraversable to iterate instead'); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/SendToTest.php: -------------------------------------------------------------------------------- 1 | getNoOpClosure(), true, false); 25 | $node1Id = $noOpNode1->getId(); 26 | $noOpNode2 = PayloadNodeFactory::create($this->getNoOpClosure(), true, false); 27 | $node2Id = $noOpNode2->getId(); 28 | $noOpNode3 = PayloadNodeFactory::create($this->getNoOpClosure(), true, false); 29 | $node3Id = $noOpNode3->getId(); 30 | 31 | $flow = (new NodalFlow)->add($noOpNode1) 32 | ->add($noOpNode2) 33 | ->add($noOpNode3); 34 | 35 | $this->assertSame(42, $flow->sendTo($node2Id, 42)); 36 | $nodeMap = $flow->getNodeMap(); 37 | $flowStats = $flow->getStats(); 38 | 39 | $this->assertSame(1, $nodeMap[$node2Id]['num_exec']); 40 | $this->assertSame(1, $nodeMap[$node3Id]['num_exec']); 41 | $this->assertSame(0, $nodeMap[$node1Id]['num_exec']); 42 | $this->assertSame(0, $flowStats['num_exec']); 43 | } 44 | 45 | /** 46 | * @throws NodalFlowException 47 | * @throws Exception 48 | */ 49 | public function testSendNode() 50 | { 51 | $flow = new NodalFlow; 52 | $sendTo = function ($record) use ($flow) { 53 | $nodes = $flow->getNodes(); 54 | $node1 = $nodes[0]; 55 | $this->assertSame(1337, $node1->sendTo($flow->getId(), $nodes[2]->getId(), 1337)); 56 | 57 | return $record; 58 | }; 59 | 60 | $noOpNode1 = PayloadNodeFactory::create($sendTo, true, false); 61 | $node1Id = $noOpNode1->getId(); 62 | $noOpNode2 = PayloadNodeFactory::create($this->getNoOpClosure(), true, false); 63 | $node2Id = $noOpNode2->getId(); 64 | $noOpNode3 = PayloadNodeFactory::create($this->getNoOpClosure(), true, false); 65 | $node3Id = $noOpNode3->getId(); 66 | 67 | $this->assertSame(42, $flow->add($noOpNode1) 68 | ->add($noOpNode2) 69 | ->add($noOpNode3) 70 | ->exec(42)); 71 | 72 | $nodeMap = $flow->getNodeMap(); 73 | $flowStats = $flow->getStats(); 74 | 75 | $this->assertSame(1, $nodeMap[$node2Id]['num_exec']); 76 | $this->assertSame(2, $nodeMap[$node3Id]['num_exec']); 77 | $this->assertSame(1, $nodeMap[$node1Id]['num_exec']); 78 | $this->assertSame(1, $flowStats['num_exec']); 79 | 80 | $this->assertSame(42, $sendTo(42)); 81 | 82 | $nodeMap = $flow->getNodeMap(); 83 | $flowStats = $flow->getStats(); 84 | 85 | $this->assertSame(1, $nodeMap[$node2Id]['num_exec']); 86 | $this->assertSame(3, $nodeMap[$node3Id]['num_exec']); 87 | $this->assertSame(1, $nodeMap[$node1Id]['num_exec']); 88 | $this->assertSame(1, $flowStats['num_exec']); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /docs/serialization.md: -------------------------------------------------------------------------------- 1 | # Serialization 2 | 3 | Flow Serialization comes with some interesting challenges, especially since Flows may contain Flows. The problematic is tight with a very fundamental aspect of NodalFlow's design: Node Instances can only be carried by a single Flow at a time. 4 | So there is no way around it, Node instances _must_ be unique among _every_ Flows in the process. For the good part, this brings immutable instances ids, but this also introduces some interesting challenges and exotic cases. 5 | 6 | To enforce such a strong requirement among Flows that may have no other relation than to reside into the same php process require some sort of global state. In NodalFlow, this global state is embodied by a `static` variable of a `FlowRegistry` instance hold by each Flow's `FlowMap` instance. 7 | Each `FlowMap` instance additionally holds references to the portion of the registry that belong to its carrying Flow, so that at any time, each Flow is bound to the relevant global state part through a `FlowMap` instance, abstracting the entries stored in the Global state to those relevant to the Flow. 8 | This is where it start dealing with serialization, as static variables are not serialized. But, as each Flow holds a `FlowMap` instance carrying the relevant portion of the Global state by reference, the global state is actually serialized by relevant portions within each serialized `FlowMap`. 9 | 10 | When un-serializing a Flow, the global state is restored bit by bit, as more member or independent Flows gets un-serialized or instantiated within the process. It's no real overhead since it's already required to make sure that uniqueness is not violated, so it's only a matter of registering each Nodes and Flows in the global state upon un-serialization and setting references again. 11 | 12 | ## About the why 13 | 14 | Serializing a Flow is not useful in all cases. For example, if you where to trigger some data processing Flow within a worker, it would certainly be simpler to just pass parameters to some job function in charge of instantiating and executing the proper Flow with proper parameters. 15 | 16 | On the other hand, it could be handy to dynamically generate and store Flows if you where to use a lot of them. You could even support multiple implementations and versions of your Flows all together this way. 17 | 18 | ## Exoticism and RTFM 19 | 20 | This uniqueness requirement comes with a limitation: it is not possible to un-serialize a Flow if it was already un-serialized within the same process, even if you `unset` the Flow before that. This is a tricky case because php does not necessarily call it's garbage collector right away when you `unset` an object, it may occur later. 21 | 22 | Supporting this quite particular case would require to implement and manually call the root Flow's `__destruct()` method and have it call the underlying `FlowMap` instance `__destruct()` where references and consistency would also need to be maintained. While it's not impossible in principle, the details can become very interesting, especially since the global state also carries each Flows and Nodes instances. It's just a circle that felt a bit too much to square for the purpose. 23 | 24 | This also implies that un-serializing a Flow may only occur once per process. 25 | 26 | All together, it's not such a big limitation, and this case is treated like any other Flow and Node duplication: an exception is thrown. So it should be obvious enough not to become a real issue. 27 | 28 | ## Closures 29 | 30 | Closure serialization is not natively supported by PHP, but there are ways around it like [Opis Closure](https://github.com/opis/closure) 31 | -------------------------------------------------------------------------------- /tests/StructuralTest.php: -------------------------------------------------------------------------------- 1 | expectException(NodalFlowException::class); 23 | $flow = new NodalFlow; 24 | $flow->add(new BranchNode($flow, true)); 25 | } 26 | 27 | /** 28 | * @throws NodalFlowException 29 | */ 30 | public function testFlowReuse1() 31 | { 32 | $this->expectException(NodalFlowException::class); 33 | $rootFlow = new NodalFlow; 34 | $branchFlow = new NodalFlow; 35 | $rootFlow->add(new BranchNode($branchFlow, true)); 36 | $rootFlow->add(new BranchNode($branchFlow, true)); 37 | } 38 | 39 | /** 40 | * @throws NodalFlowException 41 | */ 42 | public function testFlowReuse2() 43 | { 44 | $this->expectException(NodalFlowException::class); 45 | $rootFlow = new NodalFlow; 46 | $branchFlow1 = new NodalFlow; 47 | $branchFlow2 = new NodalFlow; 48 | $rootFlow->add(new BranchNode($branchFlow1, true)); 49 | $rootFlow->add(new BranchNode($branchFlow2, true)); 50 | $branchFlow2->add(new BranchNode($rootFlow, true)); 51 | } 52 | 53 | /** 54 | * @throws NodalFlowException 55 | */ 56 | public function testNodeReuse() 57 | { 58 | $this->expectException(NodalFlowException::class); 59 | $flow = new NodalFlow; 60 | $node = PayloadNodeFactory::create(function ($record) { 61 | return $record; 62 | }, true, false); 63 | 64 | $flow->add($node); 65 | $flow->add($node); 66 | } 67 | 68 | /** 69 | * @throws NodalFlowException 70 | */ 71 | public function testNodeReuseInBranch1() 72 | { 73 | $this->expectException(NodalFlowException::class); 74 | $flow = new NodalFlow; 75 | $branchFlow = new NodalFlow; 76 | $node = PayloadNodeFactory::create(function ($record) { 77 | return $record; 78 | }, true, false); 79 | 80 | $flow->add($node); 81 | $branchFlow->add($node); 82 | } 83 | 84 | /** 85 | * @throws NodalFlowException 86 | */ 87 | public function testNodeReuseInBranch2() 88 | { 89 | $this->expectException(NodalFlowException::class); 90 | $flow = new NodalFlow; 91 | $branchFlow = new NodalFlow; 92 | $node = PayloadNodeFactory::create(function ($record) { 93 | return $record; 94 | }, true, false); 95 | 96 | $flow->add($node)->add(new BranchNode($branchFlow, true)); 97 | $branchFlow->add($node); 98 | } 99 | 100 | /** 101 | * @throws NodalFlowException 102 | */ 103 | public function testNodeReuseInBranch3() 104 | { 105 | $this->expectException(NodalFlowException::class); 106 | $flow = new NodalFlow; 107 | $branchFlow1 = new NodalFlow; 108 | $branchFlow2 = new NodalFlow; 109 | $node = PayloadNodeFactory::create(function ($record) { 110 | return $record; 111 | }, true, false); 112 | 113 | $flow->add($node)->add(new BranchNode($branchFlow1, true)); 114 | $branchFlow1->add(new BranchNode($branchFlow2, true)); 115 | $branchFlow2->add($node); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [pull_request] 3 | jobs: 4 | tests: 5 | name: NodalFlow (PHP ${{ matrix.php-versions }} / dispatcher ${{ matrix.dispatcher-versions }}) 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | php-versions: [ '7.2', '7.3', '7.4', '8.0', '8.1', '8.2' ] 10 | dispatcher-versions: ['3.4.*', '4.0.*', '4.1.*', '4.2.*', '4.3.*', '4.4.*', '5.0.*', '5.1.*', '5.2.*', '5.3.*', '5.4.*', '6.0.*', '6.1.*', '6.2.*'] 11 | exclude: 12 | - php-versions: 7.2 13 | dispatcher-versions: 6.0.* 14 | - php-versions: 7.2 15 | dispatcher-versions: 6.1.* 16 | - php-versions: 7.2 17 | dispatcher-versions: 6.2.* 18 | - php-versions: 7.3 19 | dispatcher-versions: 6.0.* 20 | - php-versions: 7.3 21 | dispatcher-versions: 6.1.* 22 | - php-versions: 7.3 23 | dispatcher-versions: 6.2.* 24 | - php-versions: 7.4 25 | dispatcher-versions: 6.0.* 26 | - php-versions: 7.4 27 | dispatcher-versions: 6.1.* 28 | - php-versions: 7.4 29 | dispatcher-versions: 6.2.* 30 | - php-versions: 8.0 31 | dispatcher-versions: 3.4.* 32 | - php-versions: 8.0 33 | dispatcher-versions: 4.0.* 34 | - php-versions: 8.0 35 | dispatcher-versions: 4.1.* 36 | - php-versions: 8.0 37 | dispatcher-versions: 4.2.* 38 | - php-versions: 8.0 39 | dispatcher-versions: 4.3.* 40 | - php-versions: 8.0 41 | dispatcher-versions: 6.1.* 42 | - php-versions: 8.0 43 | dispatcher-versions: 6.2.* 44 | - php-versions: 8.1 45 | dispatcher-versions: 3.4.* 46 | - php-versions: 8.1 47 | dispatcher-versions: 4.0.* 48 | - php-versions: 8.1 49 | dispatcher-versions: 4.1.* 50 | - php-versions: 8.1 51 | dispatcher-versions: 4.2.* 52 | - php-versions: 8.1 53 | dispatcher-versions: 4.3.* 54 | - php-versions: 8.2 55 | dispatcher-versions: 3.4.* 56 | - php-versions: 8.2 57 | dispatcher-versions: 4.0.* 58 | - php-versions: 8.2 59 | dispatcher-versions: 4.1.* 60 | - php-versions: 8.2 61 | dispatcher-versions: 4.2.* 62 | - php-versions: 8.2 63 | dispatcher-versions: 4.3.* 64 | 65 | steps: 66 | - name: Checkout 67 | uses: actions/checkout@v3 68 | 69 | - name: Setup PHP 70 | uses: shivammathur/setup-php@v2 71 | with: 72 | php-version: ${{ matrix.php-versions }} 73 | extensions: mbstring, dom, fileinfo, gmp, bcmath 74 | 75 | - name: Get composer cache directory 76 | id: composer-cache 77 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 78 | 79 | - name: Cache composer dependencies 80 | uses: actions/cache@v3 81 | with: 82 | path: ${{ steps.composer-cache.outputs.dir }} 83 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 84 | restore-keys: ${{ runner.os }}-composer- 85 | 86 | - name: Remove php-cs-fixer dependency 87 | run: composer remove "friendsofphp/php-cs-fixer" --dev --no-update 88 | 89 | - name: Remove composer.lock 90 | run: rm -f composer.lock 91 | 92 | - name: Install Symfony dispatcher ${{ matrix.dispatcher-versions }} 93 | run: composer require "symfony/event-dispatcher:${{ matrix.dispatcher-versions }}" --dev --no-update 94 | 95 | - name: Install Composer dependencies 96 | run: composer install --no-progress --prefer-dist --optimize-autoloader 97 | 98 | - name: Test with phpunit 99 | run: vendor/bin/phpunit 100 | -------------------------------------------------------------------------------- /src/Flows/FlowStatus.php: -------------------------------------------------------------------------------- 1 | self::FLOW_RUNNING, 47 | self::FLOW_CLEAN => self::FLOW_CLEAN, 48 | self::FLOW_DIRTY => self::FLOW_DIRTY, 49 | self::FLOW_EXCEPTION => self::FLOW_EXCEPTION, 50 | ]; 51 | 52 | /** 53 | * Instantiate a Flow Status 54 | * 55 | * @param string $status The flow status 56 | * @param Exception|null $e 57 | * 58 | * @throws NodalFlowException 59 | */ 60 | public function __construct(string $status, Exception $e = null) 61 | { 62 | if (!isset($this->flowStatuses[$status])) { 63 | throw new NodalFlowException('$status must be one of :' . \implode(', ', $this->flowStatuses)); 64 | } 65 | 66 | $this->status = $status; 67 | $this->exception = $e; 68 | } 69 | 70 | /** 71 | * Get a string representation of the Flow status 72 | * 73 | * @return string The flow status 74 | */ 75 | public function __toString(): string 76 | { 77 | return $this->getStatus(); 78 | } 79 | 80 | /** 81 | * Indicate that the flow is currently running 82 | * useful for branched flow to find out what is 83 | * their parent up to and distinguish between top 84 | * parent end and branch end 85 | * 86 | * @return bool True If the flow is currently running 87 | */ 88 | public function isRunning(): bool 89 | { 90 | return $this->status === static::FLOW_RUNNING; 91 | } 92 | 93 | /** 94 | * Tells if the Flow went smoothly 95 | * 96 | * @return bool True If everything went well during the flow 97 | */ 98 | public function isClean(): bool 99 | { 100 | return $this->status === static::FLOW_CLEAN; 101 | } 102 | 103 | /** 104 | * Indicate that the flow was interrupted by a Node 105 | * s 106 | * 107 | * @return bool True If the flow was interrupted without exception 108 | */ 109 | public function isDirty(): bool 110 | { 111 | return $this->status === static::FLOW_DIRTY; 112 | } 113 | 114 | /** 115 | * Indicate that an exception was raised during the Flow execution 116 | * 117 | * @return bool True If the flow was interrupted with exception 118 | */ 119 | public function isException(): bool 120 | { 121 | return $this->status === static::FLOW_EXCEPTION; 122 | } 123 | 124 | /** 125 | * Return the Flow status 126 | * 127 | * @return string The flow status 128 | */ 129 | public function getStatus(): string 130 | { 131 | return $this->status; 132 | } 133 | 134 | /** 135 | * Return the eventual exception throw during the flow execution 136 | * 137 | * @return Exception|null 138 | */ 139 | public function getException(): ? Exception 140 | { 141 | return $this->exception; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Flows/FlowRegistry.php: -------------------------------------------------------------------------------- 1 | registerFlow($flow); 60 | $flowId = $flow->getId(); 61 | static::$registry[$flowId] = $entry; 62 | 63 | foreach ($flow->getNodes() as $node) { 64 | $this->registerNode($node); 65 | } 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * @param FlowInterface $flow 72 | * 73 | * @throws NodalFlowException 74 | * 75 | * @return $this 76 | */ 77 | public function registerFlow(FlowInterface $flow): FlowRegistryInterface 78 | { 79 | $flowId = $flow->getId(); 80 | if (isset(static::$flows[$flowId])) { 81 | throw new NodalFlowException('Duplicate Flow instances are not allowed', 1, null, [ 82 | 'flowClass' => get_class($flow), 83 | 'flowId' => $flowId, 84 | ]); 85 | } 86 | 87 | static::$flows[$flowId] = $flow; 88 | 89 | return $this; 90 | } 91 | 92 | /** 93 | * @param NodeInterface $node 94 | * 95 | * @throws NodalFlowException 96 | * 97 | * @return $this 98 | */ 99 | public function registerNode(NodeInterface $node): FlowRegistryInterface 100 | { 101 | $nodeId = $node->getId(); 102 | if (isset(static::$nodes[$nodeId])) { 103 | throw new NodalFlowException('Duplicate Node instances are not allowed', 1, null, [ 104 | 'nodeClass' => get_class($node), 105 | 'nodeId' => $nodeId, 106 | ]); 107 | } 108 | 109 | static::$nodes[$nodeId] = $node; 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * @param string $flowId 116 | * 117 | * @return FlowInterface|null 118 | */ 119 | public function getFlow(string $flowId): ? FlowInterface 120 | { 121 | return isset(static::$flows[$flowId]) ? static::$flows[$flowId] : null; 122 | } 123 | 124 | /** 125 | * @param string $nodeId 126 | * 127 | * @return NodeInterface|null 128 | */ 129 | public function getNode(string $nodeId): ? NodeInterface 130 | { 131 | return isset(static::$nodes[$nodeId]) ? static::$nodes[$nodeId] : null; 132 | } 133 | 134 | /** 135 | * @param NodeInterface $node 136 | * 137 | * @return $this 138 | */ 139 | public function removeNode(NodeInterface $node): FlowRegistryInterface 140 | { 141 | static::$nodes[$node->getId()] = null; 142 | 143 | return $this; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Flows/FlowInterruptAbstract.php: -------------------------------------------------------------------------------- 1 | interruptFlow(InterrupterInterface::TYPE_BREAK, $flowInterrupt); 70 | } 71 | 72 | /** 73 | * Continue the flow's execution, conceptually similar to continuing 74 | * a regular loop 75 | * 76 | * @param InterrupterInterface|null $flowInterrupt 77 | * 78 | * @throws NodalFlowException 79 | * 80 | * @return $this 81 | */ 82 | public function continueFlow(InterrupterInterface $flowInterrupt = null): FlowInterface 83 | { 84 | return $this->interruptFlow(InterrupterInterface::TYPE_CONTINUE, $flowInterrupt); 85 | } 86 | 87 | /** 88 | * @param string $interruptType 89 | * @param InterrupterInterface|null $flowInterrupt 90 | * 91 | * @throws NodalFlowException 92 | * 93 | * @return $this 94 | */ 95 | public function interruptFlow(string $interruptType, InterrupterInterface $flowInterrupt = null): FlowInterface 96 | { 97 | $node = isset($this->nodes[$this->nodeIdx]) ? $this->nodes[$this->nodeIdx] : null; 98 | switch ($interruptType) { 99 | case InterrupterInterface::TYPE_CONTINUE: 100 | $this->continue = true; 101 | $this->flowMap->incrementFlow('num_continue'); 102 | $this->triggerEvent(FlowEventInterface::FLOW_CONTINUE, $node); 103 | break; 104 | case InterrupterInterface::TYPE_BREAK: 105 | $this->flowStatus = new FlowStatus(FlowStatus::FLOW_DIRTY); 106 | $this->break = true; 107 | $this->flowMap->incrementFlow('num_break'); 108 | $this->triggerEvent(FlowEventInterface::FLOW_BREAK, $node); 109 | break; 110 | default: 111 | throw new NodalFlowException('FlowInterrupt Type missing'); 112 | } 113 | 114 | if ($flowInterrupt) { 115 | $flowInterrupt->setType($interruptType)->propagate($this); 116 | } 117 | 118 | return $this; 119 | } 120 | 121 | /** 122 | * Used to set the eventual Node Target of an Interrupt signal 123 | * set to : 124 | * - A Node Id to target 125 | * - true to interrupt every upstream nodes 126 | * in this Flow 127 | * - false to only interrupt up to the first 128 | * upstream Traversable in this Flow 129 | * 130 | * @param null|string|bool $interruptNodeId 131 | * 132 | * @throws NodalFlowException 133 | * 134 | * @return $this 135 | */ 136 | public function setInterruptNodeId($interruptNodeId): FlowInterface 137 | { 138 | if ($interruptNodeId !== null && !is_bool($interruptNodeId) && !$this->registry->getNode($interruptNodeId)) { 139 | throw new NodalFlowException('Targeted Node not found in target Flow for Interruption', 1, null, [ 140 | 'targetFlow' => $this->getId(), 141 | 'targetNode' => $interruptNodeId, 142 | ]); 143 | } 144 | 145 | $this->interruptNodeId = $interruptNodeId; 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * @param NodeInterface $node 152 | * 153 | * @return bool 154 | */ 155 | protected function interruptNode(NodeInterface $node): bool 156 | { 157 | // if we have an interruptNodeId, bubble up until we match a node 158 | // else stop propagation 159 | return $this->interruptNodeId ? $this->interruptNodeId !== $node->getId() : false; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Flows/FlowInterface.php: -------------------------------------------------------------------------------- 1 | files() 4 | ->in(__DIR__ . '/src') 5 | ->in(__DIR__ . '/tests') 6 | ->name('*.php'); 7 | 8 | 9 | $header = <<<'EOF' 10 | This file is part of NodalFlow. 11 | (c) Fabrice de Stefanis / https://github.com/fab2s/NodalFlow 12 | This source file is licensed under the MIT license which you will 13 | find in the LICENSE file or at https://opensource.org/licenses/MIT 14 | EOF; 15 | 16 | return PhpCsFixer\Config::create() 17 | ->setRiskyAllowed(true) 18 | ->setRules([ 19 | '@PSR2' => true, 20 | 'strict_param' => true, 21 | 'array_syntax' => array('syntax' => 'short'), 22 | 'binary_operator_spaces' => [ 23 | 'align_double_arrow' => true, 24 | 'align_equals' => true 25 | ], 26 | 'blank_line_after_namespace' => true, 27 | 'blank_line_after_opening_tag' => true, 28 | 'blank_line_before_return' => true, 29 | 'braces' => true, 30 | 'cast_spaces' => true, 31 | 'class_definition' => array('singleLine' => true), 32 | 'class_keyword_remove' => false, 33 | 'combine_consecutive_unsets' => true, 34 | 'concat_space' => ['spacing' => 'one'], 35 | 'declare_equal_normalize' => true, 36 | //'declare_strict_types' => true, 37 | 'elseif' => true, 38 | 'encoding' => true, 39 | 'full_opening_tag' => true, 40 | 'function_declaration' => true, 41 | 'function_typehint_space' => true, 42 | 'hash_to_slash_comment' => true, 43 | 'header_comment' => array('header' => $header), 44 | 'include' => true, 45 | 'indentation_type' => true, 46 | 'line_ending' => true, 47 | 'linebreak_after_opening_tag' => true, 48 | 'lowercase_cast' => true, 49 | 'lowercase_constants' => true, 50 | 'lowercase_keywords' => true, 51 | 'method_argument_space' => true, 52 | 'method_separation' => true, 53 | 'native_function_casing' => true, 54 | 'new_with_braces' => false, 55 | 'no_blank_lines_after_class_opening' => true, 56 | 'no_blank_lines_after_phpdoc' => true, 57 | 'no_closing_tag' => true, 58 | 'no_empty_comment' => true, 59 | 'no_empty_phpdoc' => true, 60 | 'no_empty_statement' => true, 61 | 'no_extra_consecutive_blank_lines' => [ 62 | 'break', 63 | 'continue', 64 | 'extra', 65 | 'return', 66 | 'throw', 67 | 'use', 68 | 'parenthesis_brace_block', 69 | 'square_brace_block', 70 | 'curly_brace_block' 71 | ], 72 | 'no_leading_import_slash' => true, 73 | 'no_leading_namespace_whitespace' => true, 74 | 'no_mixed_echo_print' => array('use' => 'echo'), 75 | 'no_multiline_whitespace_around_double_arrow' => true, 76 | 'no_multiline_whitespace_before_semicolons' => true, 77 | 'no_short_bool_cast' => true, 78 | 'no_singleline_whitespace_before_semicolons' => true, 79 | 'no_spaces_after_function_name' => true, 80 | 'no_spaces_around_offset' => true, 81 | 'no_spaces_inside_parenthesis' => true, 82 | 'no_trailing_comma_in_list_call' => true, 83 | 'no_trailing_comma_in_singleline_array' => true, 84 | 'no_trailing_whitespace' => true, 85 | 'no_trailing_whitespace_in_comment' => true, 86 | 'no_unneeded_control_parentheses' => true, 87 | 'no_unused_imports' => true, 88 | 'no_useless_else' => true, 89 | 'no_useless_return' => true, 90 | 'no_whitespace_before_comma_in_array' => true, 91 | 'no_whitespace_in_blank_line' => true, 92 | 'normalize_index_brace' => true, 93 | 'object_operator_without_whitespace' => true, 94 | 'ordered_class_elements' => true, 95 | 'ordered_imports' => true, 96 | 'php_unit_fqcn_annotation' => true, 97 | 'phpdoc_add_missing_param_annotation' => true, 98 | 'phpdoc_align' => true, 99 | 'phpdoc_annotation_without_dot' => true, 100 | 'phpdoc_indent' => true, 101 | 'phpdoc_inline_tag' => true, 102 | 'phpdoc_no_access' => true, 103 | 'phpdoc_no_alias_tag' => true, 104 | 'phpdoc_no_empty_return' => true, 105 | 'phpdoc_no_package' => true, 106 | 'phpdoc_no_useless_inheritdoc' => true, 107 | 'phpdoc_order' => true, 108 | 'phpdoc_return_self_reference' => true, 109 | 'phpdoc_scalar' => true, 110 | 'phpdoc_separation' => true, 111 | 'phpdoc_single_line_var_spacing' => true, 112 | 'phpdoc_summary' => false, 113 | 'phpdoc_to_comment' => true, 114 | 'phpdoc_trim' => true, 115 | 'phpdoc_types' => true, 116 | 'phpdoc_var_without_name' => true, 117 | 'pre_increment' => true, 118 | 'psr4' => true, 119 | 'return_type_declaration' => true, 120 | 'self_accessor' => true, 121 | 'semicolon_after_instruction' => true, 122 | 'short_scalar_cast' => true, 123 | 'single_blank_line_at_eof' => true, 124 | 'single_blank_line_before_namespace' => true, 125 | 'single_class_element_per_statement' => true, 126 | 'single_import_per_statement' => true, 127 | 'single_line_after_imports' => true, 128 | 'single_quote' => true, 129 | 'space_after_semicolon' => true, 130 | 'standardize_not_equals' => true, 131 | 'switch_case_semicolon_to_colon' => true, 132 | 'switch_case_space' => true, 133 | 'ternary_operator_spaces' => true, 134 | 'trailing_comma_in_multiline_array' => true, 135 | 'trim_array_spaces' => true, 136 | 'unary_operator_spaces' => true, 137 | 'visibility_required' => true, 138 | 'whitespace_after_comma_in_array' => true, 139 | 140 | ]) 141 | ->setFinder($finder); -------------------------------------------------------------------------------- /src/Interrupter.php: -------------------------------------------------------------------------------- 1 | 1, 44 | InterrupterInterface::TYPE_BREAK => 1, 45 | ]; 46 | 47 | /** 48 | * Interrupter constructor. 49 | * 50 | * @param null|string|FlowInterface $flowTarget , target up to Targeted Flow id or InterrupterInterface::TARGET_TOP to interrupt every parent 51 | * @param null|string|NodeInterface $nodeTarget 52 | * @param null|string $type 53 | */ 54 | public function __construct($flowTarget = null, $nodeTarget = null, ?string $type = null) 55 | { 56 | $this->flowTarget = $flowTarget instanceof FlowInterface ? $flowTarget->getId() : $flowTarget; 57 | $this->nodeTarget = $nodeTarget instanceof NodeInterface ? $nodeTarget->getId() : $nodeTarget; 58 | 59 | if ($type !== null) { 60 | $this->setType($type); 61 | } 62 | } 63 | 64 | /** 65 | * @return string 66 | */ 67 | public function getType(): string 68 | { 69 | return $this->type; 70 | } 71 | 72 | /** 73 | * @param string $type 74 | * 75 | * @throws InvalidArgumentException 76 | * 77 | * @return $this 78 | */ 79 | public function setType(string $type): InterrupterInterface 80 | { 81 | if (!isset($this->types[$type])) { 82 | throw new InvalidArgumentException('type must be one of:' . implode(', ', array_keys($this->types))); 83 | } 84 | 85 | $this->type = $type; 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Trigger the Interrupt of each ancestor Flows up to a specific one, the root one 92 | * or none if : 93 | * - No FlowInterrupt is set 94 | * - FlowInterrupt is set at InterrupterInterface::TARGET_SELF 95 | * - FlowInterrupt is set at this Flow's Id 96 | * - FlowInterrupt is set as InterrupterInterface::TARGET_TOP and this has no parent 97 | * 98 | * Throw an exception if we reach the top after bubbling and FlowInterrupt != InterrupterInterface::TARGET_TOP 99 | * 100 | * @param FlowInterface $flow 101 | * 102 | * @throws NodalFlowException 103 | * 104 | * @return FlowInterface 105 | */ 106 | public function propagate(FlowInterface $flow): FlowInterface 107 | { 108 | // evacuate edge cases 109 | if ($this->isEdgeInterruptCase($flow)) { 110 | // if anything had to be done, it was done first hand already 111 | // just make sure we propagate the eventual nodeTarget 112 | return $flow->setInterruptNodeId($this->nodeTarget); 113 | } 114 | 115 | $InterrupterFlowId = $flow->getId(); 116 | if (!$this->type) { 117 | throw new NodalFlowException('No interrupt type set', 1, null, [ 118 | 'InterrupterFlowId' => $InterrupterFlowId, 119 | ]); 120 | } 121 | 122 | do { 123 | $lastFlowId = $flow->getId(); 124 | if ($this->flowTarget === $lastFlowId) { 125 | // interrupting $flow 126 | return $flow->setInterruptNodeId($this->nodeTarget)->interruptFlow($this->type); 127 | } 128 | 129 | // Set interruptNodeId to true in order to make sure 130 | // we do not match any nodes in this flow (as it is not the target) 131 | $flow->setInterruptNodeId(true)->interruptFlow($this->type); 132 | } while ($flow->hasParent() && $flow = $flow->getParent()); 133 | 134 | if ($this->flowTarget !== InterrupterInterface::TARGET_TOP) { 135 | throw new NodalFlowException('Interruption target missed', 1, null, [ 136 | 'interruptAt' => $this->flowTarget, 137 | 'InterrupterFlowId' => $InterrupterFlowId, 138 | 'lastFlowId' => $lastFlowId, 139 | ]); 140 | } 141 | 142 | return $flow; 143 | } 144 | 145 | /** 146 | * @param NodeInterface|null $node 147 | * 148 | * @return bool 149 | */ 150 | public function interruptNode(NodeInterface $node = null): bool 151 | { 152 | return $node ? $this->nodeTarget === $node->getId() : false; 153 | } 154 | 155 | /** 156 | * @param FlowInterface $flow 157 | * 158 | * @return bool 159 | */ 160 | protected function isEdgeInterruptCase(FlowInterface $flow): bool 161 | { 162 | return !$this->flowTarget || 163 | ( 164 | // asked to stop right here 165 | $this->flowTarget === InterrupterInterface::TARGET_SELF || 166 | $this->flowTarget === $flow->getId() || 167 | ( 168 | // target root when this Flow is root already 169 | $this->flowTarget === InterrupterInterface::TARGET_TOP && 170 | !$flow->hasParent() 171 | ) 172 | ); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/Nodes/NodeAbstract.php: -------------------------------------------------------------------------------- 1 | enforceIsATraversable(); 71 | $this->registry = new FlowRegistry; 72 | } 73 | 74 | /** 75 | * Indicate if this Node is Traversable 76 | * 77 | * @return bool 78 | */ 79 | public function isTraversable(): bool 80 | { 81 | return (bool) $this->isATraversable; 82 | } 83 | 84 | /** 85 | * Indicate if this Node is a Flow (Branch) 86 | * 87 | * @return bool true if this node instanceof FlowInterface 88 | */ 89 | public function isFlow(): bool 90 | { 91 | return (bool) $this->isAFlow; 92 | } 93 | 94 | /** 95 | * Indicate if this Node is returning a value 96 | * 97 | * @return bool true if this node is expected to return 98 | * something to pass on next node as param. 99 | * If nothing is returned, the previously 100 | * returned value will be use as param 101 | * for next nodes. 102 | */ 103 | public function isReturningVal(): bool 104 | { 105 | return (bool) $this->isAReturningVal; 106 | } 107 | 108 | /** 109 | * Set/Reset carrying Flow 110 | * 111 | * @param FlowInterface|null $flow 112 | * 113 | * @return $this 114 | */ 115 | public function setCarrier(FlowInterface $flow = null): NodeInterface 116 | { 117 | $this->carrier = $flow; 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * Get carrying Flow 124 | * 125 | * @return FlowInterface 126 | */ 127 | public function getCarrier(): ? FlowInterface 128 | { 129 | return $this->carrier; 130 | } 131 | 132 | /** 133 | * Get this Node's hash, must be deterministic and unique 134 | * 135 | * @throws \Exception 136 | * 137 | * @return string 138 | * 139 | * @deprecated use `getId` instead 140 | */ 141 | public function getNodeHash(): string 142 | { 143 | return $this->getId(); 144 | } 145 | 146 | /** 147 | * Get the custom Node increments to be considered during 148 | * Flow execution 149 | * To set additional increment keys, use : 150 | * 'keyName' => int 151 | * to add keyName as increment, starting at int 152 | * or : 153 | * 'keyName' => 'existingIncrement' 154 | * to assign keyName as a reference to an existingIncrement 155 | * 156 | * @return array 157 | */ 158 | public function getNodeIncrements(): array 159 | { 160 | return $this->nodeIncrements; 161 | } 162 | 163 | /** 164 | * @param string $flowId 165 | * @param string|null $nodeId 166 | * @param mixed|null $param 167 | * 168 | * @throws NodalFlowException 169 | * 170 | * @return mixed 171 | */ 172 | public function sendTo(string $flowId, string $nodeId = null, $param = null) 173 | { 174 | if (!($flow = $this->registry->getFlow($flowId))) { 175 | throw new NodalFlowException('Cannot sendTo without valid Flow target', 1, null, [ 176 | 'flowId' => $flowId, 177 | 'nodeId' => $nodeId, 178 | ]); 179 | } 180 | 181 | return $flow->sendTo($nodeId, $param); 182 | } 183 | 184 | /** 185 | * Make sure this Node is consistent 186 | * 187 | * @throws NodalFlowException 188 | * 189 | * @return $this 190 | */ 191 | protected function enforceIsATraversable(): self 192 | { 193 | if ($this->isFlow()) { 194 | if ($this->isATraversable) { 195 | throw new NodalFlowException('Cannot Traverse a Branch'); 196 | } 197 | 198 | return $this; 199 | } 200 | 201 | if ($this->isATraversable) { 202 | if (!($this instanceof TraversableNodeInterface)) { 203 | throw new NodalFlowException('Cannot Traverse a Node that does not implement TraversableNodeInterface'); 204 | } 205 | 206 | return $this; 207 | } 208 | 209 | if (!($this instanceof ExecNodeInterface)) { 210 | throw new NodalFlowException('Cannot Exec a Node that does not implement ExecNodeInterface'); 211 | } 212 | 213 | return $this; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /tests/NodeTest.php: -------------------------------------------------------------------------------- 1 | [ 39 | [ 40 | 'payload' => $lambda, 41 | ], 42 | [ 43 | 'payload' => function () { 44 | return 42; 45 | }, 46 | // forcing these two will bypass comboing 47 | 'isAReturningVal' => true, 48 | 'isATraversable' => false, 49 | 'closureAssertTrue' => function (ExecNodeInterface $node) { 50 | return $node->exec(null) === 42; 51 | }, 52 | ], 53 | [ 54 | 'payload' => function () { 55 | for ($i = 1; $i < 6; ++$i) { 56 | yield $i; 57 | } 58 | }, 59 | 'isAReturningVal' => true, 60 | 'isATraversable' => true, 61 | 'closureAssertTrue' => $yielderValidator, 62 | ], 63 | $closure, 64 | [ 65 | 'payload' => function () use ($use) { 66 | return $use; 67 | }, 68 | 'isAReturningVal' => true, 69 | 'isATraversable' => false, 70 | 'closureAssertTrue' => function (ExecNodeInterface $node) use ($use) { 71 | return $node->exec(null) === $use; 72 | }, 73 | ], 74 | $function, 75 | [ 76 | 'payload' => $callableInstance, 77 | ], 78 | [ 79 | 'payload' => $callableInstance, 80 | 'isAReturningVal' => true, 81 | 'isATraversable' => false, 82 | 'closureAssertTrue' => function (ExecNodeInterface $node) { 83 | return $node->exec(null) === 'dummyMethod'; 84 | }, 85 | ], 86 | [ 87 | 'payload' => [new DummyClass, 'dummyInstanceYielder'], 88 | 'isAReturningVal' => true, 89 | 'isATraversable' => true, 90 | 'closureAssertTrue' => $yielderValidator, 91 | ], 92 | $callableStatic, 93 | [ 94 | 'payload' => $callableStaticYielder, 95 | 'isAReturningVal' => true, 96 | 'isATraversable' => true, 97 | 'closureAssertTrue' => $yielderValidator, 98 | ], 99 | ], 100 | 'ClosureNode' => $closure, 101 | 'BranchNode' => [ 102 | [ 103 | 'payload' => new NodalFlow, 104 | 'isAReturningVal' => true, 105 | 'isATraversable' => false, 106 | ], 107 | ], 108 | ]; 109 | 110 | $result = []; 111 | foreach ($this->nodes as $nodeName => $className) { 112 | $payloadSetup = $payloads[$nodeName]; 113 | $entries = []; 114 | $entry = [ 115 | 'class' => $className, 116 | ]; 117 | 118 | if (is_array($payloadSetup)) { 119 | foreach ($payloadSetup as $setup) { 120 | if (is_array($setup)) { 121 | $entries[] = $entry + $setup; 122 | } else { 123 | $entries[] = $entry + ['payload' => $setup]; 124 | } 125 | } 126 | } else { 127 | $entry['payload'] = $payloads[$nodeName]; 128 | $entries[] = $entry; 129 | } 130 | 131 | foreach ($entries as $entry) { 132 | if (!isset($entry['isAReturningVal'], $entry['isATraversable'])) { 133 | foreach ($this->iserCombos as $combo) { 134 | $result[] = $entry + $combo; 135 | } 136 | } else { 137 | $result[] = $entry; 138 | } 139 | } 140 | } 141 | 142 | return $result; 143 | } 144 | 145 | /** 146 | * @dataProvider nodeProvider 147 | * 148 | * @param string $class 149 | * @param mixed $payload 150 | * @param bool $isAReturningVal 151 | * @param bool $isATraversable 152 | * @param null|mixed $closureAssertTrue 153 | */ 154 | public function testNodes($class, $payload, $isAReturningVal, $isATraversable, $closureAssertTrue = null) 155 | { 156 | $node = new $class($payload, $isAReturningVal, $isATraversable); 157 | $this->validateNode($node, $isAReturningVal, $isATraversable, $closureAssertTrue); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /docs/citizens.md: -------------------------------------------------------------------------------- 1 | # NodalFlow Citizens 2 | 3 | A Flow is an executable workflow composed of a set of executable Nodes. They all carry a somehow executable logic and can be of several kinds : 4 | 5 | ## Exec Nodes: 6 | 7 | An Exec Node is a node implementing `ExecNodeInterface` and thus exposing an `exec()` method which accepts one parameter and eventually returns a value that may or may not be used as argument to the next node in the flow. The eventual return value usage is defined when creating the Node, which means that a Node that returns a value may still be used as if if was not. 8 | 9 | ## Traversable Node: 10 | 11 | A Traversable Node is a node that exposes a `getTraversable()` method from the `TraversableNodeInterface`. It's accepting one parameter and returning with a `Traversable` which may or may not spit values that may or may not be used as argument to the next node in the flow. 12 | 13 | ## Aggregate Node: 14 | 15 | An Aggregate Node is a node that will aggregate several Traversable Node as if they where a single Node. Each Traversable Node in the Aggregate may or may not spit values that may or may not be used as argument to the next node in the Aggregate. And the Aggregate itself may also do the same with next nodes in the Flow. 16 | 17 | ## Payload Nodes: 18 | 19 | A Payload Node is a node carrying an underlying payload which holds the execution logic. It is used to allow things like `Callable` Nodes where the execution logic is fully generic and cannot implement `NodeInterface` directly. It acts kind of like a proxy between the business payload and the workflow. Payload Nodes may be executable and / or Traversable depending on their initialization. In the Callable Node case, NodalFlow cannot predict if the underlying payload is Traversable, so it's up to the developer to properly initialize the node in such case. 20 | Payload Nodes are meant to be immutable, and thus have no setters on $isAReturningVal and $isATraversable. Each usable Payload Nodes in NodalFlow extends from `PayloadNodeAbstract` using this constructor(the branch node currently forces `$isATraversable` to `false`): 21 | 22 | ```php 23 | /** 24 | * A Payload Node is supposed to be immutable, and thus 25 | * have no setters on $isAReturningVal and $isATraversable 26 | * 27 | * @param mixed $payload 28 | * @param bool $isAReturningVal 29 | * @param bool $isATraversable 30 | * 31 | * @throws \Exception 32 | */ 33 | public function __construct($payload, $isAReturningVal, $isATraversable = false); 34 | ``` 35 | 36 | ## Branch Node: 37 | 38 | A Branch Node is a Payload Node using a Flow as payload. It will be treated as an exec node which may return a value that may (which results in executing the branch within the parent's Flow's flow, as if it was part of it) or may not (which result in a true branch which starts from a specific location in the parent's Flow's flow) be used as argument to the next node in the flow. 39 | Branch Nodes cannot be traversed. It is not a technical limitation, but rather something that requires further thinking and may be later implemented. 40 | 41 | ## Interrupter Node 42 | 43 | An Interrupter Node is a Node implementing `InterruptNodeInterface`, partially implemented by `InterruptNodeAbstract` and fully implemented by `CallableInterruptNode`. Extending from `InterruptNodeAbstract`, you would be left with implementing : 44 | 45 | ```php 46 | /** 47 | * @param mixed $param 48 | * 49 | * @return InterrupterInterface|null|bool `null` do do nothing, eg let the Flow proceed untouched 50 | * `true` to trigger a continue on the carrier Flow (not ancestors) 51 | * `false` to trigger a break on the carrier Flow (not ancestors) 52 | * `InterrupterInterface` to trigger an interrupt to propagate up to a target (which may be one ancestor) 53 | */ 54 | public function interrupt($param); 55 | ``` 56 | 57 | The interface contract is simple, `interrupt` will be passed with the incoming record as argument and should return : 58 | 59 | - `true` to skip the record and continue with the Flow 60 | - `false` to break the Flow 61 | - `null` to let the Flow proceed with that particular record (or anything else actually, but `null`, or `void`, it 's php after all, should be _preferred_ as it may be later enforced) 62 | - An instance of `InterrupterInterface` to target any Node (or none) in the carrier Flow and its eventual ancestor. Have a look at the **Interruptions** section of this doc to find out more about targeted interruptions. 63 | 64 | As it may have crossed your mind already, `CallableInterruptNode` will just use its Callable payload to compute the result of `interrupt` : 65 | 66 | ```php 67 | /** 68 | * @param mixed $param 69 | * 70 | * @return InterrupterInterface|null|bool `null` do do nothing, eg let the Flow proceed untouched 71 | * `true` to trigger a continue on the carrier Flow (not ancestors) 72 | * `false` to trigger a break on the carrier Flow (not ancestors) 73 | * `InterrupterInterface` to trigger an interrupt to propagate up to a target (which may be one ancestor) 74 | */ 75 | public function interrupt($param) 76 | { 77 | return \call_user_func($this->interrupter, $param); 78 | } 79 | ``` 80 | 81 | Example : 82 | 83 | ```php 84 | $interruptNode = new CallableInterruptNode(function($record) { 85 | // assuming that we deal with array in this case 86 | if ($record['is_free']) { 87 | // hum, not paying, okay, don't send the refund ^^ 88 | return true; 89 | } 90 | 91 | // doing nothing will let the flow proceed with the record 92 | }); 93 | ``` 94 | 95 | This Node increases separation of concerns, by isolating control conditions and direct manipulation (through `$this->carrier` to trigger `continueFlow` and `breakFlow`). 96 | 97 | Such node can be used as gate (typically as first node of a branch), conditionally allowing the Flow to proceed further for a given value passing through, or/and as an interrupter, conditionally stopping the Flow execution (or at least the branch it is put in) at a specific value (most likely to be one of the upstream Node's return value). By isolating such condition in this Node, you keep other node more agnostic and re-usable. 98 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | # FlowEvents 2 | 3 | NodalFlow implements a series of events through [`symfony/event-dispatcher`](https://symfony.com/doc/current/components/event_dispatcher.html). You can easily register any existing dispatcher implementing Symfony's `EventDispatcherInterface`. 4 | 5 | NodalFlow events are compatible and tested with [symfony/event-dispatcher](https://symfony.com/doc/current/components/event_dispatcher.html) versions `2.8.*`, `3.4.*` and `4.0.*` (php > 7.1). 6 | 7 | NodalFlow provides each `dispatch()` call with a `FlowEvent` instance, extending Symfony `Event` and implementing `FlowEventInterface`. Each FlowEvent instance carries the dispatcher's Flow instance, and eventually a Node instance, when the event is tied to a specific Node. 8 | 9 | To increase performance, a hash map of active event is build when the Flow is about to start, and event dispatch calls are wrapped with a costless `isset` call on this tiny hash map. 10 | In addition, the same event instance is used for all dispatch, only the Node gets set to either `null` or current Node instance at runtime. 11 | 12 | ## Usage 13 | 14 | In order to make it simple to use any kind of `EventDispatcherInterface` implementation, NodalFlow does not instantiate the default Symfony implementation until you actually call `$flow->getDispatcher()` or register a Callback (the old way). 15 | 16 | This means that you can set your own dispatcher before you use it to register NodalFlow events (or just set it already setup) : 17 | 18 | ```php 19 | $flow->setDispatcher(new CustomDispatcher); 20 | ``` 21 | 22 | or : 23 | 24 | ```php 25 | $flow->setDispatcher($alreadySetupDispatcher); 26 | ``` 27 | 28 | But you can also just let NodalFlow handle instantiation : 29 | 30 | ```php 31 | $flow->getDispatcher()->addListener('flow.event.name', function(FlowEventInterface $event) { 32 | // always set 33 | $flow = $event->getFlow(); 34 | // not always set 35 | $node = $event->getNode(); 36 | 37 | // do stuff ... 38 | }); 39 | ``` 40 | 41 | or even : 42 | 43 | ```php 44 | $flow->getDispatcher()->addSubscriber(new EventSubscriberInterfaceImplementation()); 45 | ``` 46 | 47 | It is **important** to note that _each_ Flow instance carries _its own_ dispatcher instance. In most cases, it is ok to just register events on the Root Flow as it is the one controlling the executions of all its eventual children. You still get the big picture with Root Flow events, such as start, end and exceptions, but you do not have access to children iteration events (the `FlowEvent::FLOW_PROGRESS` event). 48 | If you need more granularity, you will need to register events in each Flow you want to observe. 49 | 50 | ## `FlowEvent::FLOW_START` 51 | 52 | Triggered when the Flow starts, the event only carries the flow instance. 53 | 54 | ```php 55 | $flow->getDispatcher()->addListener(FlowEvent::FLOW_START, function(FlowEventInterface $event) { 56 | $flow = $event->getFlow(); 57 | // do stuff ... 58 | }); 59 | ``` 60 | 61 | ## `FlowEvent::FLOW_PROGRESS` 62 | 63 | Triggered when node iterates in the Flow, the event carries the flow and the iterating node instances. 64 | 65 | ```php 66 | $flow->getDispatcher()->addListener(FlowEvent::FLOW_PROGRESS, function(FlowEventInterface $event) { 67 | $flow = $event->getFlow(); 68 | $node = $event->getNode(); 69 | // do stuff ... 70 | }); 71 | ``` 72 | 73 | As this is the most called event, a modulo is implemented to only fire it once every `$progressMod` iteration, plus one at the first record. The default is 1024, you can set it directly on the Flow : 74 | 75 | ```php 76 | // increase to 100k 77 | $flow->setProgressMod(100000); 78 | // or for full granularity 79 | $flow->setProgressMod(1); 80 | ``` 81 | 82 | Since the `$progressMod` modulo is applied to each iterating node iteration count, each iterating node will have an opportunity to fire the event. 83 | For example, using a `$progressMod` of 10 and extracting 10 categories chained to another extractor extracting items in these categories, the `FlowEvent::FLOW_PROGRESS` event will be fired once with the first extractor and each 10 items found in each categories from the second extractor. 84 | 85 | ## `FlowEvent::FLOW_CONTINUE` 86 | 87 | Triggered when a node triggers a `continue` on the Flow, the event carries the flow and the node instance triggering the `continue`. 88 | 89 | ```php 90 | $flow->getDispatcher()->addListener(FlowEvent::FLOW_CONTINUE, function(FlowEventInterface $event) { 91 | $flow = $event->getFlow(); 92 | $node = $event->getNode(); 93 | // do stuff ... 94 | }); 95 | ``` 96 | 97 | ## `FlowEvent::FLOW_BREAK` 98 | 99 | Triggered when a node triggers a `break` on the Flow, the event carries the flow and the node instance triggering the `break`. 100 | 101 | ```php 102 | $flow->getDispatcher()->addListener(FlowEvent::FLOW_BREAK, function(FlowEventInterface $event) { 103 | $flow = $event->getFlow(); 104 | $node = $event->getNode(); 105 | // do stuff ... 106 | }); 107 | ``` 108 | 109 | ## `FlowEvent::FLOW_SUCCESS` 110 | 111 | Triggered when the Flow completes successfully (eg with no exceptions), the event only carries the flow instance. 112 | 113 | ```php 114 | $flow->getDispatcher()->addListener(FlowEvent::FLOW_SUCCESS, function(FlowEventInterface $event) { 115 | $flow = $event->getFlow(); 116 | // do stuff ... 117 | }); 118 | ``` 119 | 120 | ## `FlowEvent::FLOW_FAIL` 121 | 122 | Triggered when an exception is raised during Flow execution, the event carries the flow instance, and the node current Node instance in the Flow when the exception was thrown. 123 | 124 | ```php 125 | $flow->getDispatcher()->addListener(FlowEvent::FLOW_FAIL, function(FlowEventInterface $event) { 126 | $flow = $event->getFlow(); 127 | $node = $event->getNode(); 128 | // if you need to inspect the exeption 129 | $exception = $flow->getFlowStatus()->getException(); 130 | // do stuff ... 131 | }); 132 | ``` 133 | 134 | The original exception is [re-thrown by NodalFlow](exceptions.md) after the execution of FlowEvent::FLOW_FAIL events. 135 | 136 | ## Compatibility 137 | 138 | The event implementation is fully compatible with the old [Callback strategy](callbacks.md). Although deprecated, you do not have to do anything to continue using your `CallbackInterface` implementations. 139 | 140 | Under the hood, a `CallbackWrapper` implementation of Symfony `EventSubscriberInterface` is used as a proxy to the `CallbackInterface` implementation. 141 | -------------------------------------------------------------------------------- /src/Flows/FlowEventAbstract.php: -------------------------------------------------------------------------------- 1 | progressMod; 68 | } 69 | 70 | /** 71 | * Define the progress modulo, Progress Callback will be 72 | * triggered upon each iteration in the flow modulo $progressMod 73 | * 74 | * @param int $progressMod 75 | * 76 | * @return $this 77 | */ 78 | public function setProgressMod(int $progressMod): self 79 | { 80 | $this->progressMod = max(1, $progressMod); 81 | 82 | return $this; 83 | } 84 | 85 | /** 86 | * @throws ReflectionException 87 | * 88 | * @return EventDispatcherInterface 89 | */ 90 | public function getDispatcher(): EventDispatcherInterface 91 | { 92 | if ($this->dispatcher === null) { 93 | $this->dispatcher = new EventDispatcher; 94 | $this->initDispatchArgs(EventDispatcher::class); 95 | } 96 | 97 | return $this->dispatcher; 98 | } 99 | 100 | /** 101 | * @param EventDispatcherInterface $dispatcher 102 | * 103 | * @throws ReflectionException 104 | * 105 | * @return $this 106 | */ 107 | public function setDispatcher(EventDispatcherInterface $dispatcher): self 108 | { 109 | $this->dispatcher = $dispatcher; 110 | 111 | return $this->initDispatchArgs(\get_class($dispatcher)); 112 | } 113 | 114 | /** 115 | * Register callback class 116 | * 117 | * @param CallbackInterface $callBack 118 | * 119 | * @throws ReflectionException 120 | * 121 | * @return $this 122 | * 123 | * @deprecated Use Flow events & dispatcher instead 124 | */ 125 | public function setCallBack(CallbackInterface $callBack): self 126 | { 127 | $this->getDispatcher()->addSubscriber(new CallbackWrapper($callBack)); 128 | 129 | return $this; 130 | } 131 | 132 | /** 133 | * @param string $eventName 134 | * @param NodeInterface|null $node 135 | * 136 | * @return $this 137 | */ 138 | protected function triggerEvent(string $eventName, NodeInterface $node = null): self 139 | { 140 | if (isset($this->activeEvents[$eventName])) { 141 | $this->dispatchArgs[$this->eventNameKey] = $eventName; 142 | $this->dispatchArgs[$this->eventInstanceKey]->setNode($node); 143 | $this->dispatcher->dispatch(/* @scrutinizer ignore-type */ ...$this->dispatchArgs); 144 | } 145 | 146 | return $this; 147 | } 148 | 149 | /** 150 | * @param bool $reload 151 | * 152 | * @return $this 153 | */ 154 | protected function listActiveEvent(bool $reload = false): self 155 | { 156 | if (!isset($this->dispatcher) || (isset($this->activeEvents) && !$reload)) { 157 | return $this; 158 | } 159 | 160 | $this->activeEvents = []; 161 | $eventList = FlowEvent::getEventList(); 162 | $sortedListeners = $this->dispatcher->getListeners(); 163 | foreach ($sortedListeners as $eventName => $listeners) { 164 | if (isset($eventList[$eventName]) && !empty($listeners)) { 165 | $this->activeEvents[$eventName] = 1; 166 | } 167 | } 168 | 169 | return $this; 170 | } 171 | 172 | /** 173 | * I am really wondering wtf happening in their mind when 174 | * they decided to flip argument order on such a low level 175 | * foundation. 176 | * 177 | * This is just one of those cases where usability should win 178 | * over academic principles. Setting name upon event instance is 179 | * just not more convenient than setting it at call time, it's 180 | * just a useless mutation in most IRL cases where the event is 181 | * the same throughout many event slots (you know, practice). 182 | * It's not even so obvious that coupling event with their 183 | * usage is such a good idea, academically speaking. 184 | * 185 | * Now if you add that this results in: 186 | * - duplicated code in symfony itself 187 | * - hackish tricks to maintain BC 188 | * - loss of argument type hinting 189 | * - making it harder to support multiple version 190 | * while this is supposed to achieve better compatibility 191 | * AND no actual feature was added for so long ... 192 | * 193 | * This is pretty close to achieving the opposite of the 194 | * original purpose IMHO 195 | * 196 | * PS: 197 | * Okay, this is also a tribute to Linus memorable rants, but ... 198 | * 199 | * @param string $class 200 | * 201 | * @throws ReflectionException 202 | * 203 | * @return FlowEventAbstract 204 | */ 205 | protected function initDispatchArgs(string $class): self 206 | { 207 | $reflection = new ReflectionMethod($class, 'dispatch'); 208 | $firstParam = $reflection->getParameters()[0]; 209 | $this->dispatchArgs = [ 210 | new FlowEvent($this), 211 | null, 212 | ]; 213 | 214 | if ($firstParam->getName() !== 'event') { 215 | $this->eventInstanceKey = 1; 216 | $this->eventNameKey = 0; 217 | $this->dispatchArgs = array_reverse($this->dispatchArgs); 218 | } 219 | 220 | return $this; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('vendor') 6 | ->name('*.php') 7 | ->ignoreDotFiles(true) 8 | ->ignoreVCS(true); 9 | $config = new PhpCsFixer\Config(); 10 | 11 | $header = <<<'EOF' 12 | This file is part of NodalFlow. 13 | (c) Fabrice de Stefanis / https://github.com/fab2s/NodalFlow 14 | This source file is licensed under the MIT license which you will 15 | find in the LICENSE file or at https://opensource.org/licenses/MIT 16 | EOF; 17 | 18 | return $config 19 | ->setUsingCache(true) 20 | ->setRules([ 21 | 'header_comment' => ['header' => $header], 22 | 'array_syntax' => ['syntax' => 'short'], 23 | 'binary_operator_spaces' => [ 24 | 'default' => 'align_single_space', 25 | ], 26 | 'blank_line_after_namespace' => true, 27 | 'blank_line_after_opening_tag' => true, 28 | 'blank_line_before_statement' => [ 29 | 'statements' => ['return'], 30 | ], 31 | 'braces' => true, 32 | 'cast_spaces' => true, 33 | 'combine_consecutive_unsets' => true, 34 | 'class_attributes_separation' => [ 35 | 'elements' => ['const' => 'only_if_meta', 'trait_import' => 'one', 'property' => 'only_if_meta'], 36 | ], 37 | 'class_definition' => true, 38 | 'concat_space' => [ 39 | 'spacing' => 'one', 40 | ], 41 | 'declare_equal_normalize' => true, 42 | 'elseif' => true, 43 | 'encoding' => true, 44 | 'full_opening_tag' => true, 45 | 'fully_qualified_strict_types' => true, 46 | 'function_declaration' => true, 47 | 'function_typehint_space' => true, 48 | 'heredoc_to_nowdoc' => true, 49 | 'include' => true, 50 | 'increment_style' => ['style' => 'pre'], 51 | 'indentation_type' => true, 52 | 'linebreak_after_opening_tag' => true, 53 | 'line_ending' => true, 54 | 'lowercase_cast' => true, 55 | 'constant_case' => ['case' => 'lower'], 56 | 'lowercase_keywords' => true, 57 | 'lowercase_static_reference' => true, 58 | 'magic_method_casing' => true, 59 | 'magic_constant_casing' => true, 60 | 'method_argument_space' => true, 61 | 'multiline_whitespace_before_semicolons' => [ 62 | 'strategy' => 'no_multi_line', 63 | ], 64 | 'native_function_casing' => true, 65 | 'no_extra_blank_lines' => [ 66 | 'tokens' => [ 67 | 'extra', 68 | 'throw', 69 | 'use', 70 | ], 71 | ], 72 | 'no_blank_lines_after_class_opening' => true, 73 | 'no_blank_lines_after_phpdoc' => true, 74 | 'no_closing_tag' => true, 75 | 'no_empty_phpdoc' => true, 76 | 'no_empty_statement' => true, 77 | 'no_leading_import_slash' => true, 78 | 'no_leading_namespace_whitespace' => true, 79 | 'no_mixed_echo_print' => [ 80 | 'use' => 'echo', 81 | ], 82 | 'no_multiline_whitespace_around_double_arrow' => true, 83 | 'multiline_whitespace_before_semicolons' => true, 84 | 'no_short_bool_cast' => true, 85 | 'no_singleline_whitespace_before_semicolons' => true, 86 | 'no_spaces_after_function_name' => true, 87 | 'no_spaces_around_offset' => true, 88 | 'no_spaces_inside_parenthesis' => true, 89 | 'no_trailing_comma_in_singleline' => true, 90 | 'no_trailing_whitespace' => true, 91 | 'no_trailing_whitespace_in_comment' => true, 92 | 'no_unneeded_control_parentheses' => true, 93 | 'no_unneeded_curly_braces' => true, 94 | 'no_useless_else' => true, 95 | 'no_useless_return' => true, 96 | 'no_whitespace_before_comma_in_array' => true, 97 | 'no_whitespace_in_blank_line' => true, 98 | 'normalize_index_brace' => true, 99 | 'object_operator_without_whitespace' => true, 100 | 'ordered_class_elements' => true, 101 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 102 | 'php_unit_fqcn_annotation' => true, 103 | 'phpdoc_add_missing_param_annotation' => true, 104 | 'phpdoc_align' => true, 105 | 'phpdoc_indent' => true, 106 | 'phpdoc_annotation_without_dot' => true, 107 | 'phpdoc_inline_tag_normalizer' => true, 108 | 'phpdoc_no_alias_tag' => true, 109 | 'general_phpdoc_tag_rename' => true, 110 | 'phpdoc_no_empty_return' => true, 111 | 'phpdoc_tag_type' => true, 112 | 'phpdoc_no_access' => true, 113 | 'phpdoc_no_package' => true, 114 | 'phpdoc_no_useless_inheritdoc' => true, 115 | 'phpdoc_order' => true, 116 | 'phpdoc_scalar' => true, 117 | 'phpdoc_separation' => true, 118 | 'phpdoc_single_line_var_spacing' => true, 119 | 'phpdoc_to_comment' => true, 120 | 'phpdoc_summary' => false, 121 | 'phpdoc_trim' => true, 122 | 'phpdoc_types' => true, 123 | 'phpdoc_var_without_name' => true, 124 | 'semicolon_after_instruction' => true, 125 | 'single_blank_line_at_eof' => true, 126 | 'single_blank_line_before_namespace' => true, 127 | 'single_class_element_per_statement' => true, 128 | 'single_import_per_statement' => true, 129 | 'no_unused_imports' => true, 130 | 'single_line_after_imports' => true, 131 | 'single_line_comment_style' => [ 132 | 'comment_types' => ['hash'], 133 | ], 134 | 'single_quote' => true, 135 | 'space_after_semicolon' => true, 136 | 'standardize_not_equals' => true, 137 | 'switch_case_semicolon_to_colon' => true, 138 | 'switch_case_space' => true, 139 | 'ternary_operator_spaces' => true, 140 | 'trailing_comma_in_multiline' => [ 141 | 'elements' => ['arrays'] 142 | ], 143 | 'trim_array_spaces' => true, 144 | 'unary_operator_spaces' => true, 145 | 'visibility_required' => [ 146 | 'elements' => ['method', 'property'], 147 | ], 148 | 'whitespace_after_comma_in_array' => true, 149 | ]) 150 | ->setFinder($finder); 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NodalFlow 2 | 3 | [![Documentation Status](https://readthedocs.org/projects/nodalflow/badge/?version=latest)](http://nodalflow.readthedocs.io/en/latest/?badge=latest) [![CI](https://github.com/fab2s/NodalFlow/actions/workflows/ci.yml/badge.svg)](https://github.com/fab2s/NodalFlow/actions/workflows/ci.yml) [![QA](https://github.com/fab2s/NodalFlow/actions/workflows/qa.yml/badge.svg)](https://github.com/fab2s/NodalFlow/actions/workflows/qa.yml) [![Total Downloads](https://poser.pugx.org/fab2s/nodalflow/downloads)](https://packagist.org/packages/fab2s/nodalflow) [![Monthly Downloads](https://poser.pugx.org/fab2s/nodalflow/d/monthly)](https://packagist.org/packages/fab2s/nodalflow) [![Latest Stable Version](https://poser.pugx.org/fab2s/nodalflow/v/stable)](https://packagist.org/packages/fab2s/nodalflow) [![Code Climate](https://codeclimate.com/github/fab2s/NodalFlow/badges/gpa.svg)](https://codeclimate.com/github/fab2s/NodalFlow) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/fab2s/NodalFlow/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/fab2s/NodalFlow/?branch=master) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com) [![License](https://poser.pugx.org/fab2s/nodalflow/license)](https://packagist.org/packages/fab2s/nodalflow) 4 | 5 | `NodalFlow` is a generic Workflow that can execute chained tasks. It is designed around simple interfaces that specifies a flow composed of executable Nodes and Flows. Nodes can be executed or traversed. They accept a single parameter as argument and can be set to pass or not their result as an argument for the next node. 6 | Flows also accept one argument and may be set to pass their result to be used or not as an argument for their first Node. 7 | 8 | ``` 9 | +--------------------------+Flow Execution+-----------------------------> 10 | 11 | +-----------------+ +------------------+ +---------------+ 12 | | scalar node +--------> trarersable node +---------> next node +-------->... 13 | +-----------------+ +------------------+ +---------------+ 14 | | 15 | | +---------------+ 16 | +---------> next node +-------->... 17 | | +---------------+ 18 | | 19 | | +---------------+ 20 | +---------> next node +-------->... 21 | | +---------------+ 22 | | 23 | +--------->... 24 | 25 | ``` 26 | 27 | Nodes are linked together by the fact they return a value or not. When a node is returning a value (by declaration), it will be used as argument to the next node (but not necessarily used by it). When it doesn't, the current parameter (if any) will be used as argument by the next node, and so on until one node returns a result intended to be used as argument to the next node. 28 | 29 | ``` 30 | +--------+ Result 1 +--------+ Result 3 31 | | Node 1 +----+-----> Node 3 +--------->... 32 | +--------+ | +--------+ 33 | | 34 | | 35 | +----v---+ 36 | | Node 2 | 37 | +--------+ 38 | 39 | ``` 40 | 41 | In this flow, as node 2 (which may as well be a whole flow or branch) is not returning a value, it is executed "outside" of the main execution line. 42 | 43 | In other words, `NodalFlow` implements a directed graph structure in the form of a tree composed of nodes that can be, but not always are, branches or leaves. 44 | 45 | `NodalFlow` also goes beyond that by allowing any Flow or Node to send whatever parameter to any part of any Flow alive within the same PHP process. The feature shares similarities with the `Generator`'s [`sendTo()`](/docs/usage.md#the-sendto-methods) method and makes it possible to turn Flows into _executable networks_ of Nodes (and Flows). 46 | 47 | ``` 48 | +-------------------------+-------+----------+ 49 | | |--> | | | 50 | +-+Node1+->tNode|-->Node3+> bNode +-->NodeN+-> 51 | |FlowA ^ |--> | | | | 52 | +------------|----------------|--------------+ 53 | | | v | 54 | | | Node1 | 55 | | | | | 56 | | | v | 57 | +---sendTo()-+ Node2 | 58 | | +-+-+ | 59 | | | | | | 60 | | v v v | 61 | | Node3 | 62 | +---|--------------+ 63 | | v | | 64 | | bNode +-->Node1+-> 65 | | | | | | 66 | +---|--------------+ 67 | | | | | 68 | +---v---+ | 69 | | 70 | +-------sendTo()---------+ 71 | | 72 | +-------------|----------------+ 73 | | v | 74 | +--Node1-->Node2-->NodeN--...+-> 75 | | FlowB | 76 | +------------------------------+ 77 | ``` 78 | 79 | `NodalFlow` aims at organizing and simplifying data processing workflow's where arbitrary amount of data may come from various generators, pass through several data processors and / or end up in various places and formats. But it can as well be the foundation to organizing pretty much any sequence of tasks (`NodalFlow` could easily become Turing complete after all). It makes it possible to dynamically configure and execute complex scenario in an organized and repeatable manner (`NodalFlow` is [serializable](/docs/serialization.md)). And even more important, to write Nodes that will be reusable in any other workflow you may think of. 80 | 81 | `NodalFlow` enforces minimalistic requirements upon nodes. This means that in most cases, you should extend `NodalFlow` to implement the required constraints and grammar for your use case. 82 | 83 | [YaEtl](https://github.com/fab2s/YaEtl) is an example of a more specified workflow build upon [NodalFlow](https://github.com/fab2s/NodalFlow). 84 | 85 | `NodalFlow` shares conceptual similarities with [Transducers](https://clojure.org/reference/transducers) (if you are interested, also have a look at [Transducers PHP](https://github.com/mtdowling/transducers.php)) as it allow basic interaction chaining, especially when dealing with `ExecNodes`, but the comparison diverges quickly. 86 | 87 | ## NodalFlow Documentation 88 | 89 | [![Documentation Status](https://readthedocs.org/projects/nodalflow/badge/?version=latest)](http://nodalflow.readthedocs.io/en/latest/?badge=latest) Documentation can be found at [ReadTheDocs](http://nodalflow.readthedocs.io/en/latest/?badge=latest) 90 | 91 | ## Installation 92 | 93 | `NodalFlow` can be installed using composer: 94 | 95 | ``` 96 | composer require "fab2s/nodalflow" 97 | ``` 98 | If you want to specifically install the php >=7.2.0 version, use: 99 | 100 | ``` 101 | composer require "fab2s/nodalflow" ^2 102 | ``` 103 | 104 | If you want to specifically install the php 5.6/7.1 version, use: 105 | 106 | ``` 107 | composer require "fab2s/nodalflow" ^1 108 | ``` 109 | 110 | Once done, you can start playing: 111 | 112 | ```php 113 | $nodalFlow = new NodalFlow; 114 | $result = $nodalFlow->addPayload(('SomeClass::someTraversableMethod', true, true)) 115 | ->addPayload('intval', true) 116 | // or ->add(new CallableNode('intval', false)) 117 | // or ->add(new PayloadNodeFactory('intval', false)) 118 | ->addPayload(function($param) { 119 | return $param + 1; 120 | }, true) 121 | ->addPayload(function($param) { 122 | for($i = 1; $i < 1024; $i++) { 123 | yield $param + $i; 124 | } 125 | }, true, true) 126 | ->addPayload($anotherNodalFlow, false) 127 | // or ->add(new BranchNode($anotherNodalFlow, false)) 128 | // or ->add(new PayloadNodeFactory($anotherNodalFlow, false)) 129 | ->addPayload([$someObject, 'someMethod'], false) 130 | ->exec($wateverParam); 131 | ``` 132 | 133 | ## Requirements 134 | 135 | `NodalFlow` is tested against php 7.2, 7.3 and 7.4 8.0, 8.1 and 8.2 136 | 137 | ## Contributing 138 | 139 | Contributions are welcome, do not hesitate to open issues and submit pull requests. 140 | 141 | ## License 142 | 143 | `NodalFlow` is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT). 144 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # NodalFlow 2 | 3 | [![Documentation Status](https://readthedocs.org/projects/nodalflow/badge/?version=latest)](http://nodalflow.readthedocs.io/en/latest/?badge=latest) [![Build Status](https://travis-ci.org/fab2s/NodalFlow.svg?branch=master)](https://travis-ci.org/fab2s/NodalFlow) [![Total Downloads](https://poser.pugx.org/fab2s/nodalflow/downloads)](https://packagist.org/packages/fab2s/nodalflow) [![Monthly Downloads](https://poser.pugx.org/fab2s/nodalflow/d/monthly)](https://packagist.org/packages/fab2s/nodalflow) [![Latest Stable Version](https://poser.pugx.org/fab2s/nodalflow/v/stable)](https://packagist.org/packages/fab2s/nodalflow) [![SensioLabsInsight](https://insight.sensiolabs.com/projects/b75124fb-5efd-4182-9ec5-42cd8cd2bb25/mini.png)](https://insight.sensiolabs.com/projects/b75124fb-5efd-4182-9ec5-42cd8cd2bb25) [![Code Climate](https://codeclimate.com/github/fab2s/NodalFlow/badges/gpa.svg)](https://codeclimate.com/github/fab2s/NodalFlow) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/0a68622246734a16983616188eeefa01)](https://www.codacy.com/app/fab2s/NodalFlow) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/fab2s/NodalFlow/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/fab2s/NodalFlow/?branch=master) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com) [![PHPPackages Referenced By](http://phppackages.org/p/fab2s/nodalflow/badge/referenced-by.svg)](http://phppackages.org/p/fab2s/nodalflow) [![License](https://poser.pugx.org/fab2s/nodalflow/license)](https://packagist.org/packages/fab2s/nodalflow) 4 | 5 | `NodalFlow` is a generic Workflow that can execute chained tasks. It is designed around simple interfaces that specifies a flow composed of executable Nodes and Flows. Nodes can be executed or traversed. They accept a single parameter as argument and can be set to pass or not their result as an argument for the next node. 6 | Flows also accept one argument and may be set to pass their result to be used or not as an argument for their first Node. 7 | 8 | ``` 9 | +--------------------------+Flow Execution+-----------------------------> 10 | 11 | +-----------------+ +------------------+ +---------------+ 12 | | scalar node +--------> trarersable node +---------> next node +-------->... 13 | +-----------------+ +------------------+ +---------------+ 14 | | 15 | | +---------------+ 16 | +---------> next node +-------->... 17 | | +---------------+ 18 | | 19 | | +---------------+ 20 | +---------> next node +-------->... 21 | | +---------------+ 22 | | 23 | +--------->... 24 | 25 | ``` 26 | 27 | Nodes are linked together by the fact they return a value or not. When a node is returning a value (by declaration), it will be used as argument to the next node (but not necessarily used by it). When it doesn't, the current parameter (if any) will be used as argument by the next node, and so on until one node returns a result intended to be used as argument to the next node. 28 | 29 | ``` 30 | +--------+ Result 1 +--------+ Result 3 31 | | Node 1 +----+-----> Node 3 +--------->... 32 | +--------+ | +--------+ 33 | | 34 | | 35 | +----v---+ 36 | | Node 2 | 37 | +--------+ 38 | 39 | ``` 40 | 41 | In this flow, as node 2 (which may as well be a whole flow or branch) is not returning a value, it is executed "outside" of the main execution line. 42 | 43 | In other words, `NodalFlow` implements a directed graph structure in the form of a tree composed of nodes that can be, but not always are, branches or leaves. 44 | 45 | `NodalFlow` also goes beyond that by allowing any Flow or Node to send whatever parameter to any part of any Flow alive within the same PHP process. The feature shares similarities with the `Generator`'s [`sendTo()`](/docs/usage.md#the-sendto-methods) method and makes it possible to turn Flows into _executable networks_ of Nodes (and Flows). 46 | 47 | ``` 48 | +-------------------------+-------+----------+ 49 | | |--> | | | 50 | +-+Node1+->tNode|-->Node3+> bNode +-->NodeN+-> 51 | |FlowA ^ |--> | | | | 52 | +------------|----------------|--------------+ 53 | | | v | 54 | | | Node1 | 55 | | | | | 56 | | | v | 57 | +---sendTo()-+ Node2 | 58 | | +-+-+ | 59 | | | | | | 60 | | v v v | 61 | | Node3 | 62 | +---|--------------+ 63 | | v | | 64 | | bNode +-->Node1+-> 65 | | | | | | 66 | +---|--------------+ 67 | | | | | 68 | +---v---+ | 69 | | 70 | +-------sendTo()---------+ 71 | | 72 | +-------------|----------------+ 73 | | v | 74 | +--Node1-->Node2-->NodeN--...+-> 75 | | FlowB | 76 | +------------------------------+ 77 | ``` 78 | 79 | `NodalFlow` aims at organizing and simplifying data processing workflow's where arbitrary amount of data may come from various generators, pass through several data processors and / or end up in various places and formats. But it can as well be the foundation to organizing pretty much any sequence of tasks (`NodalFlow` could easily become Turing complete after all). It makes it possible to dynamically configure and execute complex scenario in an organized and repeatable manner (`NodalFlow` is [serializable](/docs/serialization.md)). And even more important, to write Nodes that will be reusable in any other workflow you may think of. 80 | 81 | `NodalFlow` enforces minimalistic requirements upon nodes. This means that in most cases, you should extend `NodalFlow` to implement the required constraints and grammar for your use case. 82 | 83 | [YaEtl](https://github.com/fab2s/YaEtl) is an example of a more specified workflow build upon [NodalFlow](https://github.com/fab2s/NodalFlow). 84 | 85 | `NodalFlow` shares conceptual similarities with [Transducers](https://clojure.org/reference/transducers) (if you are interested, also have a look at [Transducers PHP](https://github.com/mtdowling/transducers.php)) as it allow basic interaction chaining, especially when dealing with `ExecNodes`, but the comparison diverges quickly. 86 | 87 | ## NodalFlow Documentation 88 | 89 | [![Documentation Status](https://readthedocs.org/projects/nodalflow/badge/?version=latest)](http://nodalflow.readthedocs.io/en/latest/?badge=latest) Documentation can be found at [ReadTheDocs](http://nodalflow.readthedocs.io/en/latest/?badge=latest) 90 | 91 | ## Installation 92 | 93 | `NodalFlow` can be installed using composer: 94 | 95 | ``` 96 | composer require "fab2s/nodalflow" 97 | ``` 98 | If you want to specifically install the php >=7.1.0 version, use: 99 | 100 | ``` 101 | composer require "fab2s/nodalflow" ^2 102 | ``` 103 | 104 | If you want to specifically install the php 5.6/7.0 version, use: 105 | 106 | ``` 107 | composer require "fab2s/nodalflow" ^1 108 | ``` 109 | 110 | Once done, you can start playing: 111 | 112 | ```php 113 | $nodalFlow = new NodalFlow; 114 | $result = $nodalFlow->addPayload(('SomeClass::someTraversableMethod', true, true)) 115 | ->addPayload('intval', true) 116 | // or ->add(new CallableNode('intval', false)) 117 | // or ->add(new PayloadNodeFactory('intval', false)) 118 | ->addPayload(function($param) { 119 | return $param + 1; 120 | }, true) 121 | ->addPayload(function($param) { 122 | for($i = 1; $i < 1024; $i++) { 123 | yield $param + $i; 124 | } 125 | }, true, true) 126 | ->addPayload($anotherNodalFlow, false) 127 | // or ->add(new BranchNode($anotherNodalFlow, false)) 128 | // or ->add(new PayloadNodeFactory($anotherNodalFlow, false)) 129 | ->addPayload([$someObject, 'someMethod'], false) 130 | ->exec($wateverParam); 131 | ``` 132 | 133 | ## Requirements 134 | 135 | `NodalFlow` is tested against php 7.1, 7.2, 7.3 and 7.4 136 | 137 | ## Contributing 138 | 139 | Contributions are welcome, do not hesitate to open issues and submit pull requests. 140 | 141 | ## License 142 | 143 | `NodalFlow` is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT). 144 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | The current version comes with directly usable Payload Nodes, which are also used to build tests. 4 | 5 | ## CallableNode 6 | 7 | For convenience, `CallableNode` implements both `ExecNodeInterface` and `TraversableNodeInterface`. It's thus up to you to use a suitable Callable for each case. 8 | 9 | ```php 10 | use fab2s\NodalFlow\Nodes\CallableNode; 11 | 12 | $callableExecNode = new CallableNode(function($param) { 13 | return $param + 1; 14 | }, true); 15 | 16 | // which allows us to call the closure using 17 | $result = $callableExecNode->exec($param); 18 | 19 | $callableTraversableNode = new CallableNode(function($param) { 20 | for($i = 1; $i < 1024; $i++) { 21 | yield $param + $i; 22 | } 23 | }, true, true); 24 | 25 | // which allows us to call the closure using 26 | foreach ($callableTraversableNode->getTraversable(null) as $result) { 27 | // do something 28 | } 29 | ``` 30 | 31 | ## BranchNode 32 | 33 | ```php 34 | use fab2s\NodalFlow\Nodes\BranchNode; 35 | 36 | $rootFlow = new ClassImplementingFlwoInterface; 37 | 38 | $branchFlow = new ClassImplementingFlwoInterface; 39 | // feed the flow 40 | // ... 41 | 42 | $rootFlow->addNode(new BranchNode($flow, false)); 43 | ``` 44 | 45 | ## AggregateNode 46 | 47 | ```php 48 | use fab2s\NodalFlow\Nodes\AggregateNode; 49 | 50 | $firstTraversable = new ClassImplementingTraversableNodeInterface; 51 | // ... 52 | $nthTraversable = new ClassImplementingTraversableNodeInterface; 53 | 54 | // aggregate node may or may not return a value 55 | // but is always a Traversable Node 56 | $isAReturningVal = true; 57 | $aggregateNode = new AggregateNode($isAReturningVal); 58 | $aggregateNode->addTraversable($firstTraversable) 59 | //... 60 | ->addTraversable($nthTraversable); 61 | 62 | // attach to a Flow 63 | $flow->add($aggregateNode); 64 | ``` 65 | 66 | ## ClosureNode 67 | 68 | ClosureNode is not bringing anything really other than providing with another example. It is very similar to CallableNode except it will only accept a strict Closure as payload. 69 | 70 | NodalFlow also comes with a PayloadNodeFactory to ease Payload Node usage : 71 | 72 | ```php 73 | use fab2s\NodalFlow\PayloadNodeFactory; 74 | 75 | $node = new PayloadNodeFactory(function($param) { 76 | return $param + 1; 77 | }, true); 78 | 79 | $node = new PayloadNodeFactory('trim', true); 80 | 81 | $node = new PayloadNodeFactory([$someObject, 'someMethod'], true); 82 | 83 | $node = new PayloadNodeFactory('SomeClass::someTraversableMethod', true, true); 84 | 85 | 86 | $branchFlow = new ClassImplementingFlwoInterface; 87 | // feed the flow 88 | // ... 89 | 90 | $node = new PayloadNodeFactory($branchFlow, true); 91 | 92 | // .. 93 | ``` 94 | 95 | ## Interruptions 96 | 97 | Have a look at the [Interruption section](/docs/interruptions.md) of the documentation 98 | 99 | ## The `sendTo()` methods 100 | 101 | The `sendTo()` method is a Flow method that can send a parameter to any of its Node. When `sendTo()` is called, the Flow will start a new recursion starting at the targeted Node position in the Flow. This means that everything will happen like if the Flow only contained the target Node and the ones after it. The return value will be the Flow return value of its targeted portion if any involved Node returns something. 102 | 103 | The Flow method uses two optional arguments, a Node Id to target within the Flow (absence is identical to full execution) and an eventual argument to pass to the target: 104 | 105 | ```php 106 | /** 107 | * @param string|null $nodeId 108 | * @param mixed|null $param 109 | * 110 | * @throws NodalFlowException 111 | * 112 | * @return mixed 113 | */ 114 | public function sendTo($nodeId = null, $param = null); 115 | ``` 116 | 117 | In practice: 118 | 119 | ```php 120 | $node1 = new Node1; 121 | $node2 = new Node2; 122 | $nodeN = new NodeN; 123 | 124 | $flow = (new NodalFlow) 125 | ->add($node1) 126 | ->add($node2) 127 | ->add($nodeN); 128 | 129 | // exec the whole Flow with $something as initial parameter 130 | // and get the $result, being the return value of the last 131 | // Node returning a value (by declaration), or exactely 132 | // $something in case none are 133 | $result = $flow->exec($something); 134 | // same as 135 | $result = $flow->sendTo(null, $something); 136 | 137 | // execute the Flow as if it did not contain $node1 138 | $partialResult = $flow->sendTo($node2->getId(), $something); 139 | ``` 140 | 141 | For convenience, a version of `sendTo()` is also present in `NodeInterface` and implemented in `NodeAbstract` as a proxy to the Flow method. Its purpose is to ease Flow targeting outside of the Node's carrier Flow (since targeting the carrier is already trivial using Node's `getCarrier()`). The Node version of `sendTo()` thus uses one more argument to target the Flow: 142 | 143 | ```php 144 | /** 145 | * @param string $flowId 146 | * @param string|null $nodeId 147 | * @param string|null $param 148 | * 149 | * @throws NodalFlowException 150 | * 151 | * @return mixed 152 | */ 153 | public function sendTo($flowId, $nodeId = null, $param = null); 154 | ``` 155 | 156 | This means that from _any_ Node you can send _any_ parameter to _any_ Flow in the same process at _any_ Node position. This effectively can turn any set of Nodal(work)Flow residing into the same PHP process into an _Executable Network_ of Nodes and Flows. 157 | 158 | ## The Flow 159 | 160 | ```php 161 | use fab2s\NodalFlow\NodalFlow; 162 | use fab2s\NodalFlow\PayloadNodeFactory; 163 | use fab2s\NodalFlow\Nodes\CallableNode; 164 | 165 | $branchFlow = new ClassImplementingFlwoInterface; 166 | // feed the branch flow 167 | // adding Nodes 168 | $branchFlow->add(new CallableNode(function ($param = null) use ($whatever) { 169 | return doSomething($param); 170 | }, true)); 171 | // or internally using the PayloadNodeFactory 172 | $branchFlow->addPayload(function ($param = null) use ($whatever) { 173 | return doSomething($param); 174 | }, true); 175 | // ... 176 | 177 | // Then the root flow 178 | $nodalFlow = new NodalFlow; 179 | $result = $nodalFlow->addPayload(('SomeClass::someTraversableMethod', true, true)) 180 | ->addPayload('intval', true) 181 | // or ->add(new CallableNode('intval', false)) 182 | // or ->add(new PayloadNodeFactory('intval', false)) 183 | ->addPayload(function($param) { 184 | return $param + 1; 185 | }, true) 186 | ->addPayload(function($param) { 187 | for($i = 1; $i < 1024; $i++) { 188 | yield $param + $i; 189 | } 190 | }, true, true) 191 | ->addPayload($branchFlow, false) 192 | // or ->add(new BranchNode($branchFlow, false)) 193 | // or ->add(new PayloadNodeFactory($branchFlow, false)) 194 | ->addPayload([$someObject, 'someMethod'], false) 195 | ->exec($wateverParam); 196 | ``` 197 | 198 | As you can see, it is possible to dynamically generate and organize tasks which may or may not be linked together by their argument and return values. 199 | 200 | ## Flow Status 201 | 202 | NodalFlow uses a `FlowStatusInterface` to expose its exec state. A `FlowStatus` instance is maintained at all time by the flow and can be used to find out how things went. The status can reflect three states : 203 | 204 | ### Clean 205 | 206 | That is if everything went well up to this point: 207 | 208 | ```php 209 | $isClean = $flow->getFlowStatus()->isClean(); 210 | ``` 211 | 212 | ### Dirty 213 | 214 | That is if the flow was broken by a node: 215 | 216 | ```php 217 | $isDirty = $flow->getFlowStatus()->isDirty(); 218 | ``` 219 | 220 | ### Exception 221 | 222 | That is if a node raised an exception during the execution: 223 | 224 | ```php 225 | $isDirty = $flow->getFlowStatus()->isException(); 226 | ``` 227 | 228 | When an exception was thrown during the flow execution, it will be stored in the `FlowStatus` instance and can be easily retrieved : 229 | 230 | ```php 231 | $exception = $flow->getFlowStatus()->getException(); 232 | ``` 233 | 234 | This can be useful to find out what is going on within Flow events as they carry the Flow instance. 235 | 236 | ## Flow Map and Registry 237 | 238 | Each Flow holds its `FlowMap`, in charge of handling increment and tracking Flow structure. Each `FlowMap` is bound by reference to a global `FlowRegistry` acting as a global state and enforcing the strong uniqueness requirement among Nodes and Flow instances. As the global state is kept withing a static member of every `FlowRegistry` instances (acting as an instance proxy to static data), you can at any time and anywhere instantiate a `FlowRegistry` instance and access the complete hash map of all Nodes and Flows, including usage statistics and actual instances. A more detailed presentation of `FlowMap` and `FlowRegistry` together with some of the design decisions explanation can be found in the [serialization documentation](/docs/serialization.md). 239 | 240 | Anywhere at any time you can: 241 | 242 | ```php 243 | $registry = new FlowRegistry; 244 | 245 | // get any Flow instance by Id 246 | $registry->getFlow($flowId); 247 | 248 | // get any Node instance by Id 249 | $registry->getNode($nodeId); 250 | 251 | // get the underlying array struct for a given Flow Id 252 | $registry->get($flowId); 253 | ``` 254 | -------------------------------------------------------------------------------- /docs/interruptions.md: -------------------------------------------------------------------------------- 1 | # Interruptions 2 | 3 | Interruption are implemented in a way that keeps them conceptually similar to the regular `continue` and `break` operation on a loop. In both case `continue` and `break` becomes equal when the iteration is performed over one value or in our case over a fully scalar Flow. 4 | The main difference comes From the eventual presence of execution branches outside of the Interrupting Flow's ancestors. 5 | NodalFlow comes with a `FlowInterrupt` class, implementing `FlowInterruptInterface`, which create a way to accurately control the Interrupt signal propagation among Flow ancestors. 6 | When a Node issues an Interrupt signal, it is caught by is direct carrier Flow. When triggering the Interrupt, you can provide with a `FlowInterrupt` instance that can be set to propagate the interrupt signal up to a specific ancestor Flow. You can for example target the root Flow directly, or any of the ancestor. 7 | 8 | Let's consider the following example Flow, composed of three Flows: 9 | 10 | ``` 11 | +-------------------------+-------+-----------------+ 12 | | |--> | | | 13 | +--Node1-->tNode|-->Node3-> bNode +-->iNode--....+--> 14 | |RootFlow |--> | | | | 15 | +---------------------------------------------------+ 16 | | v | 17 | | Node1 | 18 | | | | 19 | | v | 20 | | tNode | 21 | | ----- | 22 | | | | | | 23 | | v v v | 24 | | iNode | 25 | +---+-----------------------------------+ 26 | | v | | 27 | | bNode +-->Node1-->iNode-->NodeN--...--> 28 | | | | branchFlowB| 29 | +---------------------------------------+ 30 | | | | 31 | | | | 32 | | | | 33 | | | | 34 | | | | 35 | | | | 36 | +---v---+ 37 | branchFlowA 38 | iNode : InterruptNode 39 | bNode : BranchNode 40 | tNode : TraversableNode 41 | 42 | ``` 43 | 44 | **In this example, RootFlow's iNode can:** 45 | 46 | - Trigger a `continue`: The current RootFlow's parameter is skipped for all of iNode's successor nodes, meaning that both branchFlowA and branchFlowB will get the full set, including the record having been skipped, since it occurred after. 47 | - Trigger a `break`: The signal bubbles up to the first upstream `Traversable` Node, tNode, and `break` its loop, resulting in halting the RootFlow. Like with the `continue` case, both branchFlowA and branchFlowB would still process the $record triggering the break, unlike iNode's successors, as this occurs before the break signal. It is though possible to implement some rollback mechanism based on interrupt signal detection if required by the usage. 48 | 49 | **branchFlowA's iNode can:** 50 | 51 | - Trigger a _default_ `continue`: The current branchFlowA's parameter is skipped for all of iNode's successor nodes, including branchFlowB 52 | - Trigger a _targeted_ `continue`: 53 | - Target branchFlowA (by id or targeting self flow) : same as _default_ `continue` 54 | There is no real point in also targeting a Node in branchFlowA as it only carries one Traversable. If there where more, targeting any of them would `break` every Traversable in between the Node triggering and the target, and then `continue` on the Traversable target itself. 55 | - Target RootFlow (by id or targeting root flow) : The signal bubbles up to RootFlow's bNode, being branchFlowA's carrier Node, resulting in skipping current RootFlow's parameter for all of bNode's successor nodes, and in skipping current branchFlowA's parameter for all of iNode's successor nodes and in breaking branchFlowA's Node2 since it's current parameter is to be skipped. 56 | - Trigger a _default_ `break`: The signal bubbles up to the first upstream `Traversable` Node, tNode, and `break` its loop, resulting in halting the branchFlowB for this RootFlow's parameter. 57 | - Trigger a _targeted_ `break`: 58 | - Target branchFlowA (by id or targeting self flow) : same as _default_ `break` 59 | There is no real point in also targeting a Node in branchFlowA as it only carries one Traversable. If there where more, targeting any of them would `break` every Traversable in between the Node triggering and the target, and then `break` on the Traversable target itself. 60 | - Target RootFlow (by id or targeting root flow) : The signal bubbles up to RootFlow's bNode, being branchFlowA's carrier Node, resulting in halting RootFlow as there are no upstream Traversable left to generate more parameters. If there where more Traversable above bNode in RootFlow, we could target any of them. 61 | 62 | **branchFlowB's iNode can:** 63 | 64 | - Trigger a _default_ `continue`: The current branchFlowB's parameter is skipped for all of iNode's successor nodes 65 | - Trigger a _targeted_ `continue`: 66 | - Target branchFlowB (by id or targeting self flow) : same as _default_ `continue` 67 | - Targeting branchFlowA (by id): The signal bubbles up to branchFlowA's bNode, being branchFlowB's carrier Node, resulting in skipping current branchFlowA's parameter for all of bNode's successor nodes, and in skipping current branchFlowA's parameter for all of iNode's successor nodes. Targeting any Node in branchFlowA would result in the same effect as there is only one Traversable above bNode. 68 | - Targeting RootFlow (by id or targeting root flow): The signal bubbles up to branchFlowA's bNode and to RootFlow's bNode, resulting in skipping current RootFlow's parameter for all of both bNode's successor nodes and skipping current branchFlowB's parameter for all of iNode's successor nodes. Likewise, targeting any Node in branchFlowA would result in the same effect as there is only one Traversable above bNode. 69 | - Trigger a _default_ `break`: The signal bubbles up to the the top of branchFlowB and halts it. 70 | - Trigger a _targeted_ `break`: 71 | - Target branchFlowB (by id or targeting self flow) : same as _default_ `break` 72 | - Targeting branchFlowA (by id): The signal bubbles up to the first `Traversable` Node above bNode in branchFlowA, tNode, and `break` its loop, resulting in halting the branchFlowA for this RootFlow's parameter. Targeting any Node in RootFlow would result in the same effect as there is only one Traversable above bNode. 73 | - Targeting RootFlow (by id or targeting root flow): The signal bubbles up to RootFlow's first upstream (read above bNode) Traversable, tNode, and `break` its loop, resulting in halting the RootFlow and all it's children Flows at their respective point of execution. Likewise, targeting any Node in RootFlow would result in the same effect as there is only one Traversable above bNode. 74 | 75 | ## In practice 76 | 77 | NodalFlow comes with an `InterruptNodeInterface` which you can implement by extending `InterruptNodeAbstract` leaving you with a single method to implement : 78 | 79 | ```php 80 | /** 81 | * @param mixed $param 82 | * 83 | * @return InterrupterInterface|null|bool `null` do do nothing, eg let the Flow proceed untouched 84 | * `true` to trigger a continue on the carrier Flow (not ancestors) 85 | * `false` to trigger a break on the carrier Flow (not ancestors) 86 | * `InterrupterInterface` to trigger an interrupt to propagate up to a target (which may be one ancestor) 87 | */ 88 | public function interrupt($param); 89 | ``` 90 | 91 | As you can see, the basics are simple, just return `null` to let the Flow proceed, `true` to skip (`continue`) the current record (`$param`) at the current point of execution or `false` to `break` the first upstream `Traversable` in the carrying Flow. 92 | 93 | By returning an `InterrupterInterface` instance, implemented as the `Interrupter` class, you can accurately target any Flow and / or Node among the Node carrier Flow's ancestors. 94 | 95 | For example, returning `true` is equivalent to returning: 96 | 97 | ```php 98 | new Interrupter(null, null, InterrupterInterface::TYPE_CONTINUE); 99 | ``` 100 | 101 | and returning false is equivalent to returning: 102 | 103 | ```php 104 | new Interrupter(null, null, InterrupterInterface::TYPE_BREAK); 105 | ``` 106 | 107 | The first two parameters respectively stands for Flow and Node instances or ids. 108 | 109 | ### Targeting a Flow 110 | 111 | Within an `InterruptNodeInterface` Node, targeting the carrier Flow can be done by either using as first parameter of the constructor: 112 | 113 | - `null` 114 | - `$this->getCarrier()` 115 | - `$this->getCarrier()->getId()` 116 | - `InterrupterInterface::TARGET_SELF` 117 | 118 | You can target any ancestor of the carrying Flow either by Instance or Id, and you can target the root Flow directly by using `InterrupterInterface::TARGET_TOP`. 119 | 120 | In each of these cases, the signal will bubble up to the targeted Flow and will: 121 | - for `continue` signals: skip the current record for all Nodes that may be found after the `BranchNode` where the signal showed up 122 | - for `break` signals: continue to bubble up among upstream Nodes in the target Flow until a `Traversable` Node is found, in which case its loop is halted, or up to the first Node of the targeted Flow in which case the Flow is halted. 123 | 124 | If you feed the `Interrupter` with something that does not match any Flow among the carrier's ancestors, a `NodalFlowException` will be thrown. 125 | 126 | ### Targeting a Node 127 | 128 | You can additionally target a particular Node within the targeted Flow. Obviously, it will only do something if the targeted Flow is reached. You can target Node by feeding `Interrupter` with: 129 | 130 | - a Node Instance in the target Flow 131 | - a Node Id in the target Flow 132 | - `false|null` to target the branching point on the target Flow 133 | - `true` to target the first Node in the target Flow 134 | 135 | There is _no_ magic aliases like `InterrupterInterface::TARGET_TOP` for Nodes. 136 | 137 | When an Interrupt signal reaches its target Flow and there is a target Node, the signal will bubble up to the targeted Node. Internally, this is done by resolving recursions without altering the record and continuing any traversable on the way up to the target where the signal is finally processed. 138 | 139 | If a target Node was set and it was not found on the way, a `NodalFlowException` will be thrown. 140 | 141 | ## Lowest level 142 | 143 | Each nodes is filled with it's carrier Flow when it is attached to it. Any Node implementing `NodeInterface` can interrupt any Node in its carrier Flow and ancestors : 144 | 145 | ```php 146 | // skip this very action 147 | $this->getCarrier()->continueFlow(); 148 | $this->getCarrier()->interruptFlow(InterrupterInterface::TYPE_CONTINUE); 149 | // propagate skip to root Flow 150 | $this->getCarrier()->continueFlow(new Interrupter(InterrupterInterface::TARGET_TOP)); 151 | // propagate skip to Flow id $targetFlowId 152 | $this->getCarrier()->continueFlow(new Interrupter($targetFlowId)); 153 | // propagate skip at $targetNode in Flow $targetFlow by instance 154 | $this->getCarrier()->continueFlow(new Interrupter($targetFlow, $targetNode)); 155 | // same, but using lower level interruptFlow and by id 156 | $this->getCarrier()->interruptFlow(InterrupterInterface::TYPE_CONTINUE, new Interrupter($targetFlow->getId(), $targetNode->getId()))); 157 | ``` 158 | 159 | or 160 | 161 | ```php 162 | // stop the carrrier Flow right here 163 | $this->getCarrier()->breakFlow(); 164 | $this->getCarrier()->interruptFlow(FlowInterruptInterface::TYPE_BREAK); 165 | // ... 166 | ``` 167 | 168 | whenever you need to, when `getTraversable()` and / or `exec()` methods are triggered to `continue` or `break` the flow. 169 | -------------------------------------------------------------------------------- /src/NodalFlow.php: -------------------------------------------------------------------------------- 1 | flowMap = new FlowMap($this, $this->flowIncrements); 49 | $this->registry = new FlowRegistry; 50 | } 51 | 52 | /** 53 | * Adds a Node to the flow 54 | * 55 | * @param NodeInterface $node 56 | * 57 | * @throws NodalFlowException 58 | * 59 | * @return $this 60 | */ 61 | public function add(NodeInterface $node): FlowInterface 62 | { 63 | if ($node instanceof BranchNodeInterface) { 64 | // this node is a branch 65 | $childFlow = $node->getPayload(); 66 | $this->branchFlowCheck($childFlow); 67 | $childFlow->setParent($this); 68 | } 69 | 70 | $node->setCarrier($this); 71 | 72 | $this->flowMap->register($node, $this->nodeIdx); 73 | $this->nodes[$this->nodeIdx] = $node; 74 | 75 | ++$this->nodeIdx; 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * Adds a Payload Node to the Flow 82 | * 83 | * @param callable $payload 84 | * @param mixed $isAReturningVal 85 | * @param mixed $isATraversable 86 | * 87 | * @throws NodalFlowException 88 | * 89 | * @return $this 90 | */ 91 | public function addPayload(callable $payload, bool $isAReturningVal, bool $isATraversable = false): FlowInterface 92 | { 93 | $node = PayloadNodeFactory::create($payload, $isAReturningVal, $isATraversable); 94 | 95 | $this->add($node); 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * Replaces a node with another one 102 | * 103 | * @param int $nodeIdx 104 | * @param NodeInterface $node 105 | * 106 | * @throws NodalFlowException 107 | * 108 | * @return static 109 | */ 110 | public function replace(int $nodeIdx, NodeInterface $node): FlowInterface 111 | { 112 | if (!isset($this->nodes[$nodeIdx])) { 113 | throw new NodalFlowException('Argument 1 should be a valid index in nodes', 1, null, [ 114 | 'nodeIdx' => $nodeIdx, 115 | 'node' => get_class($node), 116 | ]); 117 | } 118 | 119 | $node->setCarrier($this); 120 | $this->nodes[$nodeIdx] = $node; 121 | $this->flowMap->register($node, $nodeIdx, true); 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * @param string|null $nodeId 128 | * @param mixed|null $param 129 | * 130 | * @throws Exception 131 | * @throws NodalFlowException 132 | * 133 | * @return mixed 134 | */ 135 | public function sendTo(string $nodeId = null, $param = null) 136 | { 137 | $nodeIndex = 0; 138 | if ($nodeId !== null) { 139 | if (!($nodeIndex = $this->flowMap->getNodeIndex($nodeId))) { 140 | throw new NodalFlowException('Cannot sendTo without valid Node target', 1, null, [ 141 | 'flowId' => $this->getId(), 142 | 'nodeId' => $nodeId, 143 | ]); 144 | } 145 | } 146 | 147 | return $this->rewind()->recurse($param, $nodeIndex); 148 | } 149 | 150 | /** 151 | * Execute the flow 152 | * 153 | * @param null|mixed $param The eventual init argument to the first node 154 | * or, in case of a branch, the last relevant 155 | * argument from upstream Flow 156 | * 157 | * @throws NodalFlowException 158 | * 159 | * @return mixed the last result of the 160 | * last returning value node 161 | */ 162 | public function exec($param = null) 163 | { 164 | try { 165 | $result = $this->rewind() 166 | ->flowStart() 167 | ->recurse($param); 168 | 169 | // set flowStatus to make sure that we have the proper 170 | // value in flowEnd even when overridden without (or when 171 | // improperly) calling parent 172 | if ($this->flowStatus->isRunning()) { 173 | $this->flowStatus = new FlowStatus(FlowStatus::FLOW_CLEAN); 174 | } 175 | 176 | $this->flowEnd(); 177 | 178 | return $result; 179 | } catch (Exception $e) { 180 | $this->flowStatus = new FlowStatus(FlowStatus::FLOW_EXCEPTION, $e); 181 | $this->flowEnd(); 182 | 183 | throw $e; 184 | } 185 | } 186 | 187 | /** 188 | * Rewinds the Flow 189 | * 190 | * @return $this 191 | */ 192 | public function rewind(): FlowInterface 193 | { 194 | $this->nodeCount = count($this->nodes); 195 | $this->lastIdx = $this->nodeCount - 1; 196 | $this->break = false; 197 | $this->continue = false; 198 | $this->interruptNodeId = null; 199 | 200 | return $this; 201 | } 202 | 203 | /** 204 | * @param FlowInterface $flow 205 | * 206 | * @throws NodalFlowException 207 | */ 208 | protected function branchFlowCheck(FlowInterface $flow) 209 | { 210 | if ( 211 | // this flow has parent already 212 | $flow->hasParent() || 213 | // adding root flow in itself 214 | $this->getRootFlow($flow)->getId() === $this->getRootFlow($this)->getId() 215 | ) { 216 | throw new NodalFlowException('Cannot reuse Flow within Branches', 1, null, [ 217 | 'flowId' => $this->getId(), 218 | 'BranchFlowId' => $flow->getId(), 219 | 'BranchFlowParentId' => $flow->hasParent() ? $flow->getParent()->getId() : null, 220 | ]); 221 | } 222 | } 223 | 224 | /** 225 | * Triggered just before the flow starts 226 | * 227 | * 228 | * @return $this 229 | */ 230 | protected function flowStart(): self 231 | { 232 | $this->flowMap->incrementFlow('num_exec')->flowStart(); 233 | $this->listActiveEvent(!$this->hasParent())->triggerEvent(FlowEventInterface::FLOW_START); 234 | // flow started status kicks in after Event start to hint eventual children 235 | // this way, root flow is only running when a record hits a branch 236 | // and triggers a child flow flowStart() call 237 | $this->flowStatus = new FlowStatus(FlowStatus::FLOW_RUNNING); 238 | 239 | return $this; 240 | } 241 | 242 | /** 243 | * Triggered right after the flow stops 244 | * 245 | * @return $this 246 | */ 247 | protected function flowEnd(): self 248 | { 249 | $this->flowMap->flowEnd(); 250 | $eventName = FlowEventInterface::FLOW_SUCCESS; 251 | $node = null; 252 | if ($this->flowStatus->isException()) { 253 | $eventName = FlowEventInterface::FLOW_FAIL; 254 | $node = $this->nodes[$this->nodeIdx]; 255 | } 256 | 257 | // restore nodeIdx 258 | $this->nodeIdx = $this->lastIdx + 1; 259 | $this->triggerEvent($eventName, $node); 260 | 261 | return $this; 262 | } 263 | 264 | /** 265 | * Recurse over nodes which may as well be Flows and Traversable ... 266 | * Welcome to the abysses of recursion or iter-recursion ^^ 267 | * 268 | * `recurse` perform kind of an hybrid recursion as the Flow 269 | * is effectively iterating and recurring over its Nodes, 270 | * which may as well be seen as over itself 271 | * 272 | * Iterating tends to limit the amount of recursion levels: 273 | * recursion is only triggered when executing a Traversable 274 | * Node's downstream Nodes while every consecutive exec 275 | * Nodes are executed within the while loop. 276 | * The size of the recursion context is kept to a minimum 277 | * as pretty much everything is done by the iterating instance 278 | * 279 | * @param mixed $param 280 | * @param int $nodeIdx 281 | * 282 | * @return mixed the last value returned by the last 283 | * returning value Node in the flow 284 | */ 285 | protected function recurse($param = null, int $nodeIdx = 0) 286 | { 287 | while ($nodeIdx <= $this->lastIdx) { 288 | $node = $this->nodes[$nodeIdx]; 289 | $this->nodeIdx = $nodeIdx; 290 | $nodeStats = &$this->flowMap->getNodeStat($node->getId()); 291 | $returnVal = $node->isReturningVal(); 292 | 293 | if ($node->isTraversable()) { 294 | /** @var TraversableNodeInterface $node */ 295 | foreach ($node->getTraversable($param) as $value) { 296 | if ($returnVal) { 297 | // pass current $value as next param 298 | $param = $value; 299 | } 300 | 301 | ++$nodeStats['num_iterate']; 302 | if (!($nodeStats['num_iterate'] % $this->progressMod)) { 303 | $this->triggerEvent(FlowEventInterface::FLOW_PROGRESS, $node); 304 | } 305 | 306 | $param = $this->recurse($param, $nodeIdx + 1); 307 | if ($this->continue) { 308 | if ($this->continue = $this->interruptNode($node)) { 309 | // since we want to bubble the continue upstream 310 | // we break here waiting for next $param if any 311 | ++$nodeStats['num_break']; 312 | break; 313 | } 314 | 315 | // we drop one iteration 316 | ++$nodeStats['num_continue']; 317 | continue; 318 | } 319 | 320 | if ($this->break) { 321 | // we drop all subsequent iterations 322 | ++$nodeStats['num_break']; 323 | $this->break = $this->interruptNode($node); 324 | break; 325 | } 326 | } 327 | 328 | // we reached the end of this Traversable and executed all its downstream Nodes 329 | ++$nodeStats['num_exec']; 330 | 331 | return $param; 332 | } 333 | 334 | /** @var ExecNodeInterface $node */ 335 | $value = $node->exec($param); 336 | ++$nodeStats['num_exec']; 337 | 338 | if ($this->continue) { 339 | ++$nodeStats['num_continue']; 340 | // a continue does not need to bubble up unless 341 | // it specifically targets a node in this flow 342 | // or targets an upstream flow 343 | $this->continue = $this->interruptNode($node); 344 | 345 | return $param; 346 | } 347 | 348 | if ($this->break) { 349 | ++$nodeStats['num_break']; 350 | // a break always need to bubble up to the first upstream Traversable if any 351 | return $param; 352 | } 353 | 354 | if ($returnVal) { 355 | // pass current $value as next param 356 | $param = $value; 357 | } 358 | 359 | ++$nodeIdx; 360 | } 361 | 362 | // we reached the end of this recursion 363 | return $param; 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /tests/FlowBranchTest.php: -------------------------------------------------------------------------------- 1 | $nodeSetup) { 32 | if (isset($nodeSetup['aggregate'])) { 33 | $node = $nodeSetup['aggregate']; 34 | } elseif (isset($nodeSetup['branch'])) { 35 | $node = $nodeSetup['branch']; 36 | } else { 37 | $node = new $nodeSetup['nodeClass']($nodeSetup['payload'], $nodeSetup['isAReturningVal'], $nodeSetup['isATraversable']); 38 | } 39 | /* @var $node NodeInterface */ 40 | $this->validateNode($node, $nodeSetup['isAReturningVal'], $nodeSetup['isATraversable'], $nodeSetup['validate']); 41 | 42 | $flow->add($node); 43 | $nodes[$key]['hash'] = $node->getId(); 44 | } 45 | 46 | $result = $flow->exec($param); 47 | $nodeMap = $flow->getNodeMap(); 48 | 49 | foreach ($nodes as $nodeSetup) { 50 | $nodeStats = $nodeMap[$nodeSetup['hash']]; 51 | // assert that each node has effectively been called 52 | // as many time as reported internally. 53 | // Coupled with overall result provides with a 54 | // pretty good guaranty about what happened. 55 | // It is for example making sure that params 56 | // where properly passed since we try all return 57 | // val combos and the result is pretty unique for 58 | // each combo 59 | if (isset($nodeSetup['payloadSetup'])) { 60 | $payloadSetup = $nodeSetup['payloadSetup']; 61 | // get spy's invocations 62 | // check multi phpunit versions support 63 | if (is_callable([$payloadSetup['spy'], 'getInvocations'])) { 64 | $invocations = $payloadSetup['spy']->getInvocations(); 65 | $spyInvocations = count($invocations); 66 | } else { 67 | $spyInvocations = $payloadSetup['spy']->getInvocationCount(); 68 | } 69 | 70 | $nodeMap[$nodeSetup['hash']] += [ 71 | 'isAReturningVal' => $nodeSetup['isAReturningVal'], 72 | 'isATraversable' => $nodeSetup['isATraversable'], 73 | 'spy' => $payloadSetup['spy'], 74 | ]; 75 | $this->assertSame($spyInvocations, $nodeStats['num_exec'], "Node num_exec {$nodeStats['num_exec']} does not match spy's invocation $spyInvocations"); 76 | 77 | if ($nodeStats['num_iterate']) { 78 | // make sure we iterated as expected 79 | $this->assertSame($nodeStats['num_iterate'], $this->traversableIterations * $nodeStats['num_exec'], "Node num_iterate {$nodeStats['num_iterate']} does not match expected \$this->traversableIterations * num_exec = $this->traversableIterations * {$nodeStats['num_exec']}"); 80 | } 81 | } 82 | } 83 | 84 | $this->assertSame($expected, $result); 85 | 86 | // Flow must be repeatable 87 | $this->assertSame($expected, $flow->exec($param)); 88 | } 89 | 90 | /** 91 | * @return array 92 | */ 93 | protected function getFlowCases() 94 | { 95 | // here we assert that an aggregate node will actually combine 96 | // two traversable and properly pass the param 97 | $cases = [ 98 | 'single1' => [ 99 | 'flowName' => 'NodalFlow', 100 | 'nodes' => [ 101 | [ 102 | 'branch' => true, 103 | 'nodes' => ['getExecInstance', 'getExecInstance'], 104 | 'isATraversable' => false, 105 | 'validate' => null, 106 | ], 107 | 'execInstance', 108 | ], 109 | 'expectations' => [ 110 | [ 111 | 'isAReturningVal' => [true, true], 112 | 'cases' => [ 113 | [ 114 | 'param' => null, 115 | 'expected' => 3 * $this->ExecConst, 116 | ], 117 | [ 118 | 'param' => $this->flowParam, 119 | 'expected' => 3 * $this->ExecConst + $this->flowParam, 120 | ], 121 | ], 122 | ], 123 | [ 124 | 'isAReturningVal' => [false, true], 125 | 'cases' => [ 126 | [ 127 | 'param' => null, 128 | 'expected' => $this->ExecConst, 129 | ], 130 | [ 131 | 'param' => $this->flowParam, 132 | 'expected' => $this->ExecConst + $this->flowParam, 133 | ], 134 | ], 135 | ], 136 | [ 137 | 'isAReturningVal' => [false, false], 138 | 'cases' => [ 139 | [ 140 | 'param' => null, 141 | 'expected' => null, 142 | ], 143 | [ 144 | 'param' => $this->flowParam, 145 | 'expected' => $this->flowParam, 146 | ], 147 | ], 148 | ], 149 | ], 150 | ], 151 | 'single2' => [ 152 | 'flowName' => 'NodalFlow', 153 | 'nodes' => [ 154 | 'execInstance', 155 | [ 156 | 'branch' => true, 157 | 'nodes' => ['getExecInstance', 'getExecInstance'], 158 | 'isATraversable' => false, 159 | 'validate' => null, 160 | ], 161 | ], 162 | 'expectations' => [ 163 | [ 164 | 'isAReturningVal' => [true, true], 165 | 'cases' => [ 166 | [ 167 | 'param' => null, 168 | 'expected' => 3 * $this->ExecConst, 169 | ], 170 | [ 171 | 'param' => $this->flowParam, 172 | 'expected' => 3 * $this->ExecConst + $this->flowParam, 173 | ], 174 | ], 175 | ], 176 | [ 177 | 'isAReturningVal' => [false, true], 178 | 'cases' => [ 179 | [ 180 | 'param' => null, 181 | 'expected' => 2 * $this->ExecConst, 182 | ], 183 | [ 184 | 'param' => $this->flowParam, 185 | 'expected' => 2 * $this->ExecConst + $this->flowParam, 186 | ], 187 | ], 188 | ], 189 | [ 190 | 'isAReturningVal' => [false, false], 191 | 'cases' => [ 192 | [ 193 | 'param' => null, 194 | 'expected' => null, 195 | ], 196 | [ 197 | 'param' => $this->flowParam, 198 | 'expected' => $this->flowParam, 199 | ], 200 | ], 201 | ], 202 | ], 203 | ], 204 | 'single3' => [ 205 | 'flowName' => 'NodalFlow', 206 | 'nodes' => [ 207 | [ 208 | 'branch' => true, 209 | 'nodes' => ['getTraversableInstance', 'getTraversableInstance'], 210 | 'isATraversable' => false, 211 | 'validate' => null, 212 | ], 213 | 'execInstance', 214 | ], 215 | 'expectations' => [ 216 | [ 217 | 'isAReturningVal' => [true, true], 218 | 'cases' => [ 219 | [ 220 | 'param' => null, 221 | 'expected' => $this->traversableIterations * 2 + $this->ExecConst, 222 | ], 223 | [ 224 | 'param' => $this->flowParam, 225 | 'expected' => $this->traversableIterations * 2 + $this->ExecConst + $this->flowParam, 226 | ], 227 | ], 228 | ], 229 | [ 230 | 'isAReturningVal' => [false, true], 231 | 'cases' => [ 232 | [ 233 | 'param' => null, 234 | 'expected' => $this->ExecConst, 235 | ], 236 | [ 237 | 'param' => $this->flowParam, 238 | 'expected' => $this->ExecConst + $this->flowParam, 239 | ], 240 | ], 241 | ], 242 | [ 243 | 'isAReturningVal' => [false, false], 244 | 'cases' => [ 245 | [ 246 | 'param' => null, 247 | 'expected' => null, 248 | ], 249 | [ 250 | 'param' => $this->flowParam, 251 | 'expected' => $this->flowParam, 252 | ], 253 | ], 254 | ], 255 | ], 256 | ], 257 | 'single4' => [ 258 | 'flowName' => 'NodalFlow', 259 | 'nodes' => [ 260 | 'execInstance', 261 | [ 262 | 'branch' => true, 263 | 'nodes' => ['getTraversableInstance', 'getTraversableInstance'], 264 | 'isATraversable' => false, 265 | 'validate' => null, 266 | ], 267 | ], 268 | 'expectations' => [ 269 | [ 270 | 'isAReturningVal' => [true, true], 271 | 'cases' => [ 272 | [ 273 | 'param' => null, 274 | 'expected' => $this->traversableIterations * 2 + $this->ExecConst, 275 | ], 276 | [ 277 | 'param' => $this->flowParam, 278 | 'expected' => $this->traversableIterations * 2 + $this->ExecConst + $this->flowParam, 279 | ], 280 | ], 281 | ], 282 | [ 283 | 'isAReturningVal' => [false, true], 284 | 'cases' => [ 285 | [ 286 | 'param' => null, 287 | 'expected' => $this->traversableIterations * 2, 288 | ], 289 | [ 290 | 'param' => $this->flowParam, 291 | 'expected' => $this->traversableIterations * 2 + $this->flowParam, 292 | ], 293 | ], 294 | ], 295 | [ 296 | 'isAReturningVal' => [false, false], 297 | 'cases' => [ 298 | [ 299 | 'param' => null, 300 | 'expected' => null, 301 | ], 302 | [ 303 | 'param' => $this->flowParam, 304 | 'expected' => $this->flowParam, 305 | ], 306 | ], 307 | ], 308 | ], 309 | ], 310 | ]; 311 | 312 | return $cases; 313 | } 314 | } 315 | --------------------------------------------------------------------------------