├── 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 | [](https://travis-ci.org/pwm/s-flow)
4 | [](https://codecov.io/gh/pwm/s-flow)
5 | [](https://codeclimate.com/github/pwm/s-flow/maintainability)
6 | [](https://codeclimate.com/github/pwm/s-flow/test_coverage)
7 | [](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 |
--------------------------------------------------------------------------------