├── src ├── Transition.php ├── Exception │ ├── MissingState.php │ ├── DuplicateEvent.php │ ├── DuplicateState.php │ └── IncompleteArrow.php ├── Event.php ├── State.php ├── Name │ ├── NamedEvent.php │ ├── NamedState.php │ ├── EventName.php │ └── StateName.php ├── DrawnArrow.php ├── Events.php ├── FSM.php ├── Arrow.php ├── TransitionOp.php └── Graph.php ├── .gitignore ├── infection.json.dist ├── tests └── unit │ ├── Fixture │ ├── TestEvent1.php │ ├── TestEvent2.php │ ├── TestState1.php │ ├── TestState2.php │ ├── NoopTransition.php │ ├── TestTransition12.php │ ├── TestTransition21.php │ └── TestGraph.php │ ├── ShoppingCart │ ├── Fixture │ │ ├── Event │ │ │ ├── Cancel.php │ │ │ ├── Checkout.php │ │ │ ├── Confirm.php │ │ │ ├── PlaceOrder.php │ │ │ ├── Select.php │ │ │ └── SelectCard.php │ │ ├── State │ │ │ ├── NoItems.php │ │ │ ├── OrderPlaced.php │ │ │ ├── NoCard.php │ │ │ ├── HasItems.php │ │ │ ├── CardConfirmed.php │ │ │ └── CardSelected.php │ │ ├── Item.php │ │ ├── Card.php │ │ ├── Items.php │ │ └── Transition │ │ │ ├── DoCheckout.php │ │ │ ├── AddItem.php │ │ │ ├── DoPlaceOrder.php │ │ │ ├── DoSelectCard.php │ │ │ ├── AddFirstItem.php │ │ │ ├── ConfirmCard.php │ │ │ └── DoCancel.php │ └── ShoppingCartTest.php │ ├── Name │ ├── EventNameTest.php │ └── StateNameTest.php │ ├── DrawnArrowTest.php │ ├── EventsTest.php │ ├── ArrowTest.php │ ├── TransitionOpTest.php │ ├── FSMTest.php │ └── GraphTest.php ├── .codeclimate.yml ├── psalm.xml ├── phpunit.xml.dist ├── .travis.yml ├── LICENSE ├── changelog.md ├── composer.json └── readme.md /src/Transition.php: -------------------------------------------------------------------------------- 1 | eventName = $eventName; 14 | } 15 | 16 | public function unWrap(): string 17 | { 18 | return $this->eventName; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Name/StateName.php: -------------------------------------------------------------------------------- 1 | stateName = $stateName; 14 | } 15 | 16 | public function unWrap(): string 17 | { 18 | return $this->stateName; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Item.php: -------------------------------------------------------------------------------- 1 | name = $name; 14 | } 15 | 16 | public function getName(): string 17 | { 18 | return $this->name; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/unit/Name/EventNameTest.php: -------------------------------------------------------------------------------- 1 | unWrap()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/unit/Name/StateNameTest.php: -------------------------------------------------------------------------------- 1 | unWrap()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Event/Select.php: -------------------------------------------------------------------------------- 1 | item = $item; 20 | } 21 | 22 | public function getItem(): Item 23 | { 24 | return $this->item; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Event/SelectCard.php: -------------------------------------------------------------------------------- 1 | card = $card; 20 | } 21 | 22 | public function getCard(): Card 23 | { 24 | return $this->card; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/State/NoCard.php: -------------------------------------------------------------------------------- 1 | items = $items; 20 | } 21 | 22 | public function getItems(): Items 23 | { 24 | return $this->items; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/State/HasItems.php: -------------------------------------------------------------------------------- 1 | items = $items; 20 | } 21 | 22 | public function getItems(): Items 23 | { 24 | return $this->items; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Card.php: -------------------------------------------------------------------------------- 1 | type = $type; 16 | $this->number = $number; 17 | } 18 | 19 | public function getType(): string 20 | { 21 | return $this->type; 22 | } 23 | 24 | public function getNumber(): string 25 | { 26 | return $this->number; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/unit/Fixture/TestTransition12.php: -------------------------------------------------------------------------------- 1 | transition($state, $event); 19 | } 20 | 21 | private function transition(TestState1 $state, TestEvent1 $event): TestState2 22 | { 23 | return new TestState2; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/unit/Fixture/TestTransition21.php: -------------------------------------------------------------------------------- 1 | transition($state, $event); 19 | } 20 | 21 | private function transition(TestState2 $state, TestEvent2 $event): TestState1 22 | { 23 | return new TestState1; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/unit/Fixture/TestGraph.php: -------------------------------------------------------------------------------- 1 | from(TestState1::name())->via(new TestTransition12), 20 | (new Arrow(TestEvent2::name()))->from(TestState2::name())->via(new TestTransition21), 21 | ]; 22 | 23 | return (new Graph(...$stateNames))->drawArrows(...$arrows); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./tests/unit 13 | 14 | 15 | 16 | 17 | ./src 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/State/CardConfirmed.php: -------------------------------------------------------------------------------- 1 | items = $items; 23 | $this->card = $card; 24 | } 25 | 26 | public function getItems(): Items 27 | { 28 | return $this->items; 29 | } 30 | 31 | public function getCard(): Card 32 | { 33 | return $this->card; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/State/CardSelected.php: -------------------------------------------------------------------------------- 1 | items = $items; 23 | $this->card = $card; 24 | } 25 | 26 | public function getItems(): Items 27 | { 28 | return $this->items; 29 | } 30 | 31 | public function getCard(): Card 32 | { 33 | return $this->card; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: php 4 | 5 | php: 6 | - 7.2 7 | - 7.3 8 | 9 | before_install: 10 | - travis_retry composer self-update 11 | 12 | install: 13 | - travis_retry composer install --no-interaction --prefer-dist 14 | 15 | before_script: 16 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 17 | - chmod +x ./cc-test-reporter 18 | - ./cc-test-reporter before-build 19 | - echo 'date.timezone = "Europe/London"' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini 20 | 21 | script: 22 | - vendor/bin/phpcs --standard=PSR2 --warning-severity=0 src 23 | - vendor/bin/phpstan analyse -l 7 src 24 | - vendor/bin/phpunit --coverage-clover=clover.xml 25 | 26 | after_script: 27 | - bash <(curl -s https://codecov.io/bash) 28 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 29 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Items.php: -------------------------------------------------------------------------------- 1 | items = array_merge([$item], array_values($items)); 20 | $this->count = count($this->items); 21 | } 22 | 23 | public function toList(): array 24 | { 25 | return $this->items; 26 | } 27 | 28 | public function count(): int 29 | { 30 | return $this->count; 31 | } 32 | 33 | public function getIterator(): Generator 34 | { 35 | yield from $this->items; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Transition/DoCheckout.php: -------------------------------------------------------------------------------- 1 | Checkout -> NoCard 14 | final class DoCheckout implements Transition 15 | { 16 | public function __invoke(State $state, Event $event): State 17 | { 18 | /** 19 | * @var HasItems $state 20 | * @var Checkout $event 21 | */ 22 | return self::transition($state, $event); 23 | } 24 | 25 | private static function transition(HasItems $state, Checkout $event): NoCard 26 | { 27 | return new NoCard($state->getItems()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/unit/DrawnArrowTest.php: -------------------------------------------------------------------------------- 1 | getEventName()->unWrap()); 26 | self::assertSame('fromState', $drawnArrow->getStateName()->unWrap()); 27 | self::assertInstanceOf(Transition::class, $drawnArrow->getTransition()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Transition/AddItem.php: -------------------------------------------------------------------------------- 1 | Select -> HasItems 14 | final class AddItem implements Transition 15 | { 16 | public function __invoke(State $state, Event $event): State 17 | { 18 | /** 19 | * @var HasItems $state 20 | * @var Select $event 21 | */ 22 | return self::transition($state, $event); 23 | } 24 | 25 | private static function transition(HasItems $state, Select $event): HasItems 26 | { 27 | return new HasItems(new Items($event->getItem(), ...$state->getItems())); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Transition/DoPlaceOrder.php: -------------------------------------------------------------------------------- 1 | PlaceOrder -> OrderPlaced 14 | final class DoPlaceOrder implements Transition 15 | { 16 | public function __invoke(State $state, Event $event): State 17 | { 18 | /** 19 | * @var CardConfirmed $state 20 | * @var PlaceOrder $event 21 | */ 22 | return self::transition($state, $event); 23 | } 24 | 25 | private static function transition(CardConfirmed $state, PlaceOrder $event): OrderPlaced 26 | { 27 | return new OrderPlaced(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Transition/DoSelectCard.php: -------------------------------------------------------------------------------- 1 | SelectCard -> CardSelected 14 | final class DoSelectCard implements Transition 15 | { 16 | public function __invoke(State $state, Event $event): State 17 | { 18 | /** 19 | * @var NoCard $state 20 | * @var SelectCard $event 21 | */ 22 | return self::transition($state, $event); 23 | } 24 | 25 | private static function transition(NoCard $state, SelectCard $event): CardSelected 26 | { 27 | return new CardSelected($state->getItems(), $event->getCard()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Transition/AddFirstItem.php: -------------------------------------------------------------------------------- 1 | Select -> HasItems 15 | final class AddFirstItem implements Transition 16 | { 17 | public function __invoke(State $state, Event $event): State 18 | { 19 | /** 20 | * @var NoItems $state 21 | * @var Select $event 22 | */ 23 | return self::transition($state, $event); 24 | } 25 | 26 | private static function transition(NoItems $state, Select $event): HasItems 27 | { 28 | return new HasItems(new Items($event->getItem())); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Transition/ConfirmCard.php: -------------------------------------------------------------------------------- 1 | Confirm -> CardConfirmed 14 | final class ConfirmCard implements Transition 15 | { 16 | public function __invoke(State $state, Event $event): State 17 | { 18 | /** 19 | * @var CardSelected $state 20 | * @var Confirm $event 21 | */ 22 | return self::transition($state, $event); 23 | } 24 | 25 | private static function transition(CardSelected $state, Confirm $event): CardConfirmed 26 | { 27 | return new CardConfirmed($state->getItems(), $state->getCard()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/DrawnArrow.php: -------------------------------------------------------------------------------- 1 | eventName = $eventName; 24 | $this->stateName = $stateName; 25 | $this->transition = $transition; 26 | } 27 | 28 | public function getEventName(): EventName 29 | { 30 | return $this->eventName; 31 | } 32 | 33 | public function getStateName(): StateName 34 | { 35 | return $this->stateName; 36 | } 37 | 38 | public function getTransition(): Transition 39 | { 40 | return $this->transition; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Events.php: -------------------------------------------------------------------------------- 1 | events = array_values($events); 23 | $this->count = count($this->events); 24 | } 25 | 26 | public function addEvent(Event $event): self 27 | { 28 | return new self(...array_merge($this->events, [$event])); 29 | } 30 | 31 | public function toList(): array 32 | { 33 | return $this->events; 34 | } 35 | 36 | public function count(): int 37 | { 38 | return $this->count; 39 | } 40 | 41 | public function getIterator(): Generator 42 | { 43 | yield from $this->events; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Zsolt Szende 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [4.0.0] - 2019-03-12 8 | ### Changed 9 | * `State` and `Event` became interfaces of real types as opposed to Enums. They have uniquely identifying names 10 | * `Transition` is an interface of for functions of type `S -> E -> S` (as opposed to being predicates) 11 | * The underlying `Graph` has been factored out of the `FSM` 12 | * `StateOp` was renamed to `TransitionOp` 13 | 14 | ## [3.0.0] - 2018-09-28 15 | ### Changed 16 | * `array` type hints have been replaced with `string ...` for more explicit control. This is a breaking change for clients! 17 | * The `StateOp` object now has a `->getLastEvent()` method 18 | 19 | ## [2.1.0] - 2018-09-28 20 | ### Changed 21 | * The `StateOp` object now has the events applied 22 | 23 | ## [2.0.0] - 2018-05-24 24 | ### Changed 25 | * `->deriveState()` now returns a `StateOp` object to indicate success/failure 26 | 27 | ## [1.0.0] - 2018-05-18 28 | ### Added 29 | * Initial release 30 | -------------------------------------------------------------------------------- /src/FSM.php: -------------------------------------------------------------------------------- 1 | graph = $graph; 16 | } 17 | 18 | public function run(State $state, Events $events): TransitionOp 19 | { 20 | return array_reduce($events->toList(), function (TransitionOp $transitionOp, Event $event): TransitionOp { 21 | return $transitionOp->isSuccess() 22 | ? $this->transition($transitionOp, $event) 23 | : $transitionOp; 24 | }, TransitionOp::success($state, new Events())); 25 | } 26 | 27 | private function transition(TransitionOp $transitionOp, Event $event): TransitionOp 28 | { 29 | $state = $transitionOp->getState(); 30 | $events = $transitionOp->getEvents()->addEvent($event); 31 | $transition = $this->graph->getTransition($state, $event); 32 | 33 | return $transition instanceof Transition 34 | ? TransitionOp::success($transition($state, $event), $events) 35 | : TransitionOp::failure($state, $events); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/Fixture/Transition/DoCancel.php: -------------------------------------------------------------------------------- 1 | Cancel -> HasItems 16 | // state -> Cancel -> state 17 | final class DoCancel implements Transition 18 | { 19 | public function __invoke(State $state, Event $event): State 20 | { 21 | /** 22 | * @var State $state 23 | * @var Cancel $event 24 | */ 25 | return self::transition($state, $event); 26 | 27 | } 28 | 29 | private static function transition(State $state, Cancel $event): State 30 | { 31 | if ($state instanceof NoCard || 32 | $state instanceof CardSelected || 33 | $state instanceof CardConfirmed) { 34 | return new HasItems($state->getItems()); 35 | } 36 | return $state; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Arrow.php: -------------------------------------------------------------------------------- 1 | eventName = $eventName; 22 | } 23 | 24 | public function from(StateName $stateName): self 25 | { 26 | $this->stateName = $stateName; 27 | return $this; 28 | } 29 | 30 | public function via(Transition $transition): self 31 | { 32 | $this->transition = $transition; 33 | return $this; 34 | } 35 | 36 | public function draw(): DrawnArrow 37 | { 38 | if (! $this->stateName instanceof StateName || ! $this->transition instanceof Transition) { 39 | throw new IncompleteArrow('Arrow must be fully defined in order to draw it.'); 40 | } 41 | 42 | return new DrawnArrow( 43 | $this->eventName, 44 | $this->stateName, 45 | $this->transition 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/TransitionOp.php: -------------------------------------------------------------------------------- 1 | success; 28 | } 29 | 30 | public function getState(): State 31 | { 32 | return $this->state; 33 | } 34 | 35 | public function getEvents(): Events 36 | { 37 | return $this->events; 38 | } 39 | 40 | public function getLastEvent(): ?Event 41 | { 42 | /** @var Event[] $events */ 43 | $events = $this->events->toList(); 44 | return $events[$this->events->count() - 1] ?? null; 45 | } 46 | 47 | private function __construct( 48 | bool $success, 49 | State $state, 50 | Events $events 51 | ) { 52 | $this->success = $success; 53 | $this->state = $state; 54 | $this->events = $events; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pwm/s-flow", 3 | "description": "A lightweight library for defining state machines", 4 | "type": "library", 5 | "keywords": [ 6 | "fsm", 7 | "state-machine", 8 | "workflow", 9 | "event-sourcing" 10 | ], 11 | "homepage": "https://github.com/pwm/s-flow", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Zsolt Szende", 16 | "email": "zs@szende.me" 17 | } 18 | ], 19 | "require": { 20 | "php": ">=7.2.0" 21 | }, 22 | "require-dev": { 23 | "squizlabs/php_codesniffer": "^3.0", 24 | "phpstan/phpstan": "^0.10", 25 | "phpunit/phpunit": "^6.1", 26 | "infection/infection": "^0.10", 27 | "vimeo/psalm": "^3.0" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Pwm\\SFlow\\": "src/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Pwm\\SFlow\\": "tests/unit/" 37 | } 38 | }, 39 | "extra": { 40 | "branch-alias": { 41 | "dev-master": "4.0-dev" 42 | } 43 | }, 44 | "scripts": { 45 | "phpunit": "vendor/bin/phpunit --coverage-text", 46 | "phpcs": "vendor/bin/phpcs --standard=PSR2 --warning-severity=0 src", 47 | "phpcbf": "vendor/bin/phpcbf --standard=PSR2 --warning-severity=0 src", 48 | "phpstan": "vendor/bin/phpstan analyse --ansi -l 7 src", 49 | "infection": "vendor/bin/infection --ansi --only-covered", 50 | "psalm": "vendor/bin/psalm --show-info=false" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/unit/EventsTest.php: -------------------------------------------------------------------------------- 1 | toList()); 22 | } 23 | 24 | /** 25 | * @test 26 | */ 27 | public function it_creates(): void 28 | { 29 | $events = new Events(new TestEvent1, new TestEvent2); 30 | 31 | self::assertInstanceOf(Events::class, $events); 32 | self::assertCount(2, $events); 33 | 34 | self::assertSame([TestEvent1::class, TestEvent2::class], array_map(function (Event $event): string { 35 | return $event::name()->unWrap(); 36 | }, $events->toList())); 37 | 38 | foreach ($events as $event) { 39 | self::assertInstanceOf(Event::class, $event); 40 | } 41 | } 42 | 43 | /** 44 | * @test 45 | */ 46 | public function new_event_can_be_added(): void 47 | { 48 | $events = new Events(new TestEvent1); 49 | 50 | self::assertInstanceOf(Events::class, $events); 51 | self::assertCount(1, $events); 52 | 53 | $events = $events->addEvent(new TestEvent2); 54 | 55 | self::assertInstanceOf(Events::class, $events); 56 | self::assertCount(2, $events); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/unit/ArrowTest.php: -------------------------------------------------------------------------------- 1 | from(new StateName('fromState')) 22 | ->via(new NoopTransition) 23 | ->draw(); 24 | 25 | self::assertInstanceOf(DrawnArrow::class, $drawnArrow); 26 | self::assertSame('someEvent', $drawnArrow->getEventName()->unWrap()); 27 | self::assertSame('fromState', $drawnArrow->getStateName()->unWrap()); 28 | self::assertInstanceOf(Transition::class, $drawnArrow->getTransition()); 29 | } 30 | 31 | /** 32 | * @test 33 | */ 34 | public function it_throws_on_incomplete_draw(): void 35 | { 36 | /** @var Arrow[] $incompleteArrows */ 37 | $incompleteArrows = [ 38 | new Arrow(new EventName('someEvent')), 39 | (new Arrow(new EventName('someEvent')))->from(new StateName('fromState')), 40 | (new Arrow(new EventName('someEvent')))->via(new NoopTransition), 41 | ]; 42 | 43 | foreach ($incompleteArrows as $incompleteArrow) { 44 | try { 45 | $incompleteArrow->draw(); 46 | self::assertTrue(false); 47 | } catch (Throwable $e) { 48 | self::assertInstanceOf(IncompleteArrow::class, $e); 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/unit/TransitionOpTest.php: -------------------------------------------------------------------------------- 1 | isSuccess()); 25 | self::assertSame(TestState1::class, $transitionOp->getState()::name()->unWrap()); 26 | self::assertSame([TestEvent1::class, TestEvent2::class], array_map(function (Event $event): string { 27 | return $event::name()->unWrap(); 28 | }, $transitionOp->getEvents()->toList())); 29 | self::assertSame(TestEvent2::class, $transitionOp->getLastEvent()::name()->unWrap()); 30 | } 31 | 32 | /** 33 | * @test 34 | */ 35 | public function it_creates_from_failure(): void 36 | { 37 | $transitionOp = TransitionOp::failure( 38 | new TestState1, 39 | new Events(new TestEvent1, new TestEvent2) 40 | ); 41 | 42 | self::assertInstanceOf(TransitionOp::class, $transitionOp); 43 | self::assertFalse($transitionOp->isSuccess()); 44 | self::assertSame(TestState1::class, $transitionOp->getState()::name()->unWrap()); 45 | self::assertSame([TestEvent1::class, TestEvent2::class], array_map(function (Event $event): string { 46 | return $event::name()->unWrap(); 47 | }, $transitionOp->getEvents()->toList())); 48 | self::assertSame(TestEvent2::class, $transitionOp->getLastEvent()::name()->unWrap()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Graph.php: -------------------------------------------------------------------------------- 1 | graph[$stateName->unWrap()])) { 22 | throw new DuplicateState(sprintf('Duplicate State %s.', $stateName->unWrap())); 23 | } 24 | $this->graph[$stateName->unWrap()] = []; 25 | } 26 | } 27 | 28 | public function drawArrows(Arrow ...$arrows): self 29 | { 30 | return array_reduce($arrows, function (self $graph, Arrow $arrow): self { 31 | return $graph->drawArrow($arrow); 32 | }, $this); 33 | } 34 | 35 | public function drawArrow(Arrow $arrow): self 36 | { 37 | return $this->addArrow($arrow->draw()); 38 | } 39 | 40 | public function addArrows(DrawnArrow ...$drawnArrows): self 41 | { 42 | return array_reduce($drawnArrows, function (self $graph, DrawnArrow $drawnArrow): self { 43 | return $graph->addArrow($drawnArrow); 44 | }, $this); 45 | } 46 | 47 | public function addArrow(DrawnArrow $drawnArrow): self 48 | { 49 | $stateName = $drawnArrow->getStateName()->unWrap(); 50 | $eventName = $drawnArrow->getEventName()->unWrap(); 51 | 52 | if (! isset($this->graph[$stateName])) { 53 | throw new MissingState(sprintf('State %s is unknown.', $stateName)); 54 | } 55 | if (isset($this->graph[$stateName][$eventName])) { 56 | throw new DuplicateEvent(sprintf('Duplicate Event %s from state %s.', $eventName, $stateName)); 57 | } 58 | 59 | $this->graph[$stateName][$eventName] = $drawnArrow->getTransition(); 60 | 61 | return $this; 62 | } 63 | 64 | public function getTransition(State $state, Event $event): ?Transition 65 | { 66 | return $this->graph[$state::name()->unWrap()][$event::name()->unWrap()] ?? null; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/unit/FSMTest.php: -------------------------------------------------------------------------------- 1 | run( 35 | new TestState1, 36 | new Events() 37 | ); 38 | 39 | self::assertInstanceOf(TransitionOp::class, $transitionOp); 40 | self::assertTrue($transitionOp->isSuccess()); 41 | self::assertSame(TestState1::class, $transitionOp->getState()::name()->unWrap()); 42 | self::assertSame([], $transitionOp->getEvents()->toList()); 43 | self::assertNull($transitionOp->getLastEvent()); 44 | 45 | // 1 step 46 | 47 | $transitionOp = $fsm->run( 48 | new TestState1, 49 | new Events( 50 | new TestEvent1 51 | ) 52 | ); 53 | 54 | self::assertInstanceOf(TransitionOp::class, $transitionOp); 55 | self::assertTrue($transitionOp->isSuccess()); 56 | self::assertSame(TestState2::class, $transitionOp->getState()::name()->unWrap()); 57 | self::assertSame([TestEvent1::class], array_map(function (Event $event): string { 58 | return $event::name()->unWrap(); 59 | }, $transitionOp->getEvents()->toList())); 60 | self::assertSame(TestEvent1::class, $transitionOp->getLastEvent()::name()->unWrap()); 61 | 62 | // 2 steps 63 | 64 | $transitionOp = $fsm->run( 65 | new TestState1, 66 | new Events( 67 | new TestEvent1, 68 | new TestEvent2 69 | ) 70 | ); 71 | 72 | self::assertInstanceOf(TransitionOp::class, $transitionOp); 73 | self::assertTrue($transitionOp->isSuccess()); 74 | self::assertSame(TestState1::class, $transitionOp->getState()::name()->unWrap()); 75 | self::assertSame([TestEvent1::class, TestEvent2::class], array_map(function (Event $event): string { 76 | return $event::name()->unWrap(); 77 | }, $transitionOp->getEvents()->toList())); 78 | self::assertSame(TestEvent2::class, $transitionOp->getLastEvent()::name()->unWrap()); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/unit/ShoppingCart/ShoppingCartTest.php: -------------------------------------------------------------------------------- 1 | from(State\NoItems::name())->via(new Transition\AddFirstItem), 42 | (new Arrow(Event\Select::name()))->from(State\HasItems::name())->via(new Transition\AddItem), 43 | (new Arrow(Event\Checkout::name()))->from(State\HasItems::name())->via(new Transition\DoCheckout), 44 | (new Arrow(Event\SelectCard::name()))->from(State\NoCard::name())->via(new Transition\DoSelectCard), 45 | (new Arrow(Event\Confirm::name()))->from(State\CardSelected::name())->via(new Transition\ConfirmCard), 46 | (new Arrow(Event\PlaceOrder::name()))->from(State\CardConfirmed::name())->via(new Transition\DoPlaceOrder), 47 | (new Arrow(Event\Cancel::name()))->from(State\NoCard::name())->via(new Transition\DoCancel), 48 | (new Arrow(Event\Cancel::name()))->from(State\CardSelected::name())->via(new Transition\DoCancel), 49 | (new Arrow(Event\Cancel::name()))->from(State\CardConfirmed::name())->via(new Transition\DoCancel), 50 | ]; 51 | 52 | // Build the graph from the above building blocks and build the FSM using the graph. 53 | $fsm = new FSM((new Graph(...$stateNames))->drawArrows(...$arrows)); 54 | 55 | // Run a shopping simulation 56 | $transitionOp = $fsm->run( 57 | new State\NoItems(), 58 | new Events( 59 | new Event\Select(new Item('foo')), 60 | new Event\Select(new Item('bar')), 61 | new Event\Select(new Item('baz')), 62 | new Event\Checkout(), 63 | new Event\SelectCard(new Card('Visa', '1234567812345678')), 64 | new Event\Confirm(), 65 | new Event\PlaceOrder() 66 | ) 67 | ); 68 | 69 | // The simulation was successful, the final state is OrderPlaced and the last event was PlaceOrder 70 | self::assertTrue($transitionOp->isSuccess()); 71 | self::assertInstanceOf(State\OrderPlaced::class, $transitionOp->getState()); 72 | self::assertInstanceOf(Event\PlaceOrder::class, $transitionOp->getLastEvent()); 73 | 74 | // The list of expected events match the list from the result of the simulation 75 | self::assertSame([ 76 | Event\Select::class, 77 | Event\Select::class, 78 | Event\Select::class, 79 | Event\Checkout::class, 80 | Event\SelectCard::class, 81 | Event\Confirm::class, 82 | Event\PlaceOrder::class, 83 | ], array_map(function (EventType $event): string { 84 | return $event::name()->unWrap(); 85 | }, $transitionOp->getEvents()->toList())); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/unit/GraphTest.php: -------------------------------------------------------------------------------- 1 | from(TestState1::name())->via(new TestTransition12), 58 | (new Arrow(TestEvent2::name()))->from(TestState2::name())->via(new TestTransition21), 59 | ]; 60 | 61 | $graph = (new Graph(...$stateNames))->drawArrows(...$arrows); 62 | 63 | self::assertInstanceOf(Graph::class, $graph); 64 | self::assertInstanceOf(TestTransition12::class, $graph->getTransition(new TestState1, new TestEvent1)); 65 | self::assertInstanceOf(TestTransition21::class, $graph->getTransition(new TestState2, new TestEvent2)); 66 | self::assertNull($graph->getTransition(new TestState1, new TestEvent2)); 67 | self::assertNull($graph->getTransition(new TestState2, new TestEvent1)); 68 | } 69 | 70 | /** 71 | * @test 72 | */ 73 | public function arrow_can_be_drawn(): void 74 | { 75 | $stateNames = [ 76 | TestState1::name(), 77 | TestState2::name(), 78 | ]; 79 | 80 | $arrow = (new Arrow(TestEvent1::name()))->from(TestState1::name())->via(new NoopTransition); 81 | 82 | $graph = (new Graph(...$stateNames))->drawArrow($arrow); 83 | 84 | self::assertInstanceOf(Graph::class, $graph); 85 | self::assertInstanceOf(NoopTransition::class, $graph->getTransition(new TestState1, new TestEvent1)); 86 | } 87 | 88 | /** 89 | * @test 90 | */ 91 | public function arrows_can_be_added(): void 92 | { 93 | $stateNames = [ 94 | TestState1::name(), 95 | TestState2::name(), 96 | ]; 97 | 98 | $drawnArrows = [ 99 | new DrawnArrow(TestEvent1::name(), TestState1::name(), new TestTransition12), 100 | new DrawnArrow(TestEvent2::name(), TestState2::name(), new TestTransition21), 101 | ]; 102 | 103 | $graph = (new Graph(...$stateNames))->addArrows(...$drawnArrows); 104 | 105 | self::assertInstanceOf(Graph::class, $graph); 106 | self::assertInstanceOf(TestTransition12::class, $graph->getTransition(new TestState1, new TestEvent1)); 107 | self::assertInstanceOf(TestTransition21::class, $graph->getTransition(new TestState2, new TestEvent2)); 108 | self::assertNull($graph->getTransition(new TestState1, new TestEvent2)); 109 | self::assertNull($graph->getTransition(new TestState2, new TestEvent1)); 110 | } 111 | 112 | /** 113 | * @test 114 | */ 115 | public function arrow_can_be_added(): void 116 | { 117 | $stateNames = [ 118 | TestState1::name(), 119 | TestState2::name(), 120 | ]; 121 | 122 | $arrow = new DrawnArrow(TestEvent1::name(), TestState1::name(), new NoopTransition); 123 | 124 | $graph = (new Graph(...$stateNames))->addArrow($arrow); 125 | 126 | self::assertInstanceOf(Graph::class, $graph); 127 | self::assertInstanceOf(NoopTransition::class, $graph->getTransition(new TestState1, new TestEvent1)); 128 | } 129 | 130 | /** 131 | * @test 132 | * @expectedException \Pwm\SFlow\Exception\MissingState 133 | */ 134 | public function cannot_add_arrow_from_an_unknown_state(): void 135 | { 136 | (new Graph(TestState1::name())) 137 | ->addArrow(new DrawnArrow(TestEvent1::name(), TestState2::name(), new NoopTransition)); 138 | } 139 | 140 | /** 141 | * @test 142 | * @expectedException \Pwm\SFlow\Exception\DuplicateEvent 143 | */ 144 | public function events_must_be_unique_coming_out_of_a_state(): void 145 | { 146 | (new Graph(TestState1::name(), TestState2::name())) 147 | ->addArrow(new DrawnArrow(TestEvent1::name(), TestState1::name(), new NoopTransition)) 148 | ->addArrow(new DrawnArrow(TestEvent1::name(), TestState1::name(), new NoopTransition)); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # S-Flow 2 | 3 | [![Build Status](https://travis-ci.org/pwm/s-flow.svg?branch=master)](https://travis-ci.org/pwm/s-flow) 4 | [![codecov](https://codecov.io/gh/pwm/s-flow/branch/master/graph/badge.svg)](https://codecov.io/gh/pwm/s-flow) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/7d68d8bee2ecbcf3277c/maintainability)](https://codeclimate.com/github/pwm/s-flow/maintainability) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/7d68d8bee2ecbcf3277c/test_coverage)](https://codeclimate.com/github/pwm/s-flow/test_coverage) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 8 | 9 | S-Flow is a lightweight library for defining finite state machines (FSM). Once defined the machine can be run by giving it a start state and a sequence of events to derive some end state. One of the main design goals of S-Flow was to be able to define FSMs declaratively as a single top level definition. This makes the structure of the underlying graph clear and explicit which in turn helps with understanding and maintenance. S-Flow can be used for many things, eg. to define workflows or to build event sourced systems. 10 | 11 | ## Table of Contents 12 | 13 | * [Why](#why) 14 | * [Requirements](#requirements) 15 | * [Installation](#installation) 16 | * [Usage](#usage) 17 | * [How it works](#how-it-works) 18 | * [Tests](#tests) 19 | * [Todo](#todo) 20 | * [Changelog](#changelog) 21 | * [Licence](#licence) 22 | 23 | ## Why 24 | 25 | If you ever named a variable, object property or database field *"status"* or *"state"* then read on... 26 | 27 | #### Claim #1: 28 | 29 | Much grief in software development arises from our inability to control state. 30 | 31 | #### Evidence: 32 | 33 | Q: What do we do when our code breaks? 34 | 35 | A: We debug it. 36 | 37 | Q: What does debugging mean? 38 | 39 | A: Observing our program's internal state trying to figure out where it went wrong. 40 | 41 | #### Claim #2: 42 | 43 | If we could better control state in our programs we would have less bugs and as a result we would spend less time debugging. 44 | 45 | S-Flow can help controlling state by making it easy to build state machines. 46 | 47 | ## Requirements 48 | 49 | PHP 7.2+ 50 | 51 | ## Installation 52 | 53 | $ composer require pwm/s-flow 54 | 55 | ## Usage 56 | 57 | There is a fully worked example under [tests/unit/ShoppingCart](tests/unit/ShoppingCart) that simulates the process of purchasing items from an imaginary shop. Below is the definition of the FSM from it: 58 | 59 | ```php 60 | // S, E and T are short for State, Event and Transition 61 | 62 | // A list of state names that identify the states 63 | $stateNames = [ 64 | S\NoItems::name(), 65 | S\HasItems::name(), 66 | S\NoCard::name(), 67 | S\CardSelected::name(), 68 | S\CardConfirmed::name(), 69 | S\OrderPlaced::name(), 70 | ]; 71 | 72 | // A list of arrows labelled by event names 73 | // An arrow goes from a start state via a transition to an end state 74 | $arrows = [ 75 | (new Arrow(E\Select::name()))->from(S\NoItems::name())->via(new T\AddFirstItem), 76 | (new Arrow(E\Select::name()))->from(S\HasItems::name())->via(new T\AddItem), 77 | (new Arrow(E\Checkout::name()))->from(S\HasItems::name())->via(new T\DoCheckout), 78 | (new Arrow(E\SelectCard::name()))->from(S\NoCard::name())->via(new T\DoSelectCard), 79 | (new Arrow(E\Confirm::name()))->from(S\CardSelected::name())->via(new T\ConfirmCard), 80 | (new Arrow(E\PlaceOrder::name()))->from(S\CardConfirmed::name())->via(new T\DoPlaceOrder), 81 | (new Arrow(E\Cancel::name()))->from(S\NoCard::name())->via(new T\DoCancel), 82 | (new Arrow(E\Cancel::name()))->from(S\CardSelected::name())->via(new T\DoCancel), 83 | (new Arrow(E\Cancel::name()))->from(S\CardConfirmed::name())->via(new T\DoCancel), 84 | ]; 85 | 86 | // Build a graph from the above 87 | $graph = (new Graph(...$stateNames))->drawArrows(...$arrows); 88 | 89 | // Build an FSM using the graph 90 | $shoppingCartFSM = new FSM($graph); 91 | 92 | // Run a simulation of purchasing 3 items 93 | $result = $shoppingCartFSM->run( 94 | new S\NoItems, 95 | new Events( 96 | new E\Select(new Item('foo')), 97 | new E\Select(new Item('bar')), 98 | new E\Select(new Item('baz')), 99 | new E\Checkout, 100 | new E\SelectCard(new Card('Visa', '1234567812345678')), 101 | new E\Confirm, 102 | new E\PlaceOrder 103 | ) 104 | ); 105 | 106 | // Observe the results 107 | assert($result->isSuccess() === true); 108 | assert($result->getState() instanceof S\OrderPlaced); 109 | assert($result->getLastEvent() instanceof E\PlaceOrder); 110 | ``` 111 | 112 | ## How it works 113 | 114 | A state machine is defined as a directed graph. Vertices of this graph are called states and arrows between them are called transitions. Transitions are labelled so that they can be identified. We call those labels events. 115 | 116 | Running the machine, ie. deriving an end state given a start state and a sequence of events, means walking the graph from the start state via a sequence of transitions leading to the desired end state. In the end we either reach it or stop when there is no way forward. 117 | 118 | Transitions, acting as the arrows of the graph, are functions of type `(State, Event) -> State`. They are uniquely identified by a `(StateName, EventName)` pair, ie. given a state name and an event name (which is the label of the arrow) we can get the corresponding transition function, if it exists. The the absence of the transition function automatically results in a failed transition, keeping the current state. 119 | 120 | Success and failure is captured using the `TransitionOp` type. It also keeps track of the current state as well as the sequence of events leading up to it. 121 | 122 | ## Tests 123 | 124 | $ composer phpunit 125 | $ composer phpcs 126 | $ composer phpstan 127 | $ composer psalm 128 | $ composer infection 129 | 130 | ## Todo 131 | 132 | Once return type covariance lands in PHP ([as part of this RFC](https://wiki.php.net/rfc/covariant-returns-and-contravariant-parameters)) we will be able to specify the actual return type of `__invoke` in `Transition` implementations. This would enable to easily dump the FSM into various text formats, eg. as a DOT file, etc... 133 | 134 | ## Changelog 135 | 136 | [Click here](changelog.md) 137 | 138 | ## Licence 139 | 140 | [MIT](LICENSE) 141 | --------------------------------------------------------------------------------