Skip to content

Commit

Permalink
Implement state machine concept (#3)
Browse files Browse the repository at this point in the history
* Several fixes and implement state machine concept

* Update readme and composer

* Fix readme link on gfm
  • Loading branch information
uuf6429 committed Jul 24, 2021
1 parent 0c85fa9 commit f227ab2
Show file tree
Hide file tree
Showing 29 changed files with 559 additions and 316 deletions.
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/.gitattributes export-ignore
/.github export-ignore
/.gitignore export-ignore
/phpunit.xml.dist export-ignore
/tests export-ignore
75 changes: 54 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
# State Engine (PHP)
# State Engine / Machine (PHP)

[![CI](https://github.com/uuf6429/state-engine-php/actions/workflows/ci.yml/badge.svg)](https://github.com/uuf6429/state-engine-php/actions/workflows/ci.yml)
[![Minimum PHP Version](https://img.shields.io/badge/php-%5E7.4%20%7C%20%5E8-8892BF.svg)](https://php.net/)
[![License](http://poser.pugx.org/uuf6429/state-engine/license)](https://packagist.org/packages/uuf6429/state-engine)
[![Latest Stable Version](http://poser.pugx.org/uuf6429/state-engine/v)](https://packagist.org/packages/uuf6429/state-engine)
[![Latest Unstable Version](http://poser.pugx.org/uuf6429/state-engine/v/unstable)](https://packagist.org/packages/uuf6429/state-engine)

This library provides some interfaces and a basic implementation of a State Engine.
This library provides some interfaces and a basic implementation of a State Engine or State Machine.

**Highlights:**
- Dual functionality:
1. Either as a basic state engine; switching to a desired state as long the transition is defined)
([see "JiraIssueTest"](#jiraissuetest-state-engine))
2. Or a more sophisticated state machine; same as above but matching data for any state
([see "TurnstileTest"](#turnstiletest-state-machine))
- Highly composable - everything can be replaced as desired
- [PSR-14](http://www.php-fig.org/psr/psr-14/) (Event Dispatcher) compatible
- Fluent builder interface ([see "From Scratch"](#from-scratch))
Expand All @@ -19,7 +24,7 @@ This library provides some interfaces and a basic implementation of a State Engi
The recommended and easiest way to install this library is through [Composer](https://getcomposer.org/):

```bash
composer require uuf6429/state-engine-php "^1.0"
composer require uuf6429/state-engine-php "^2.0"
```

## Why?
Expand Down Expand Up @@ -60,32 +65,26 @@ In this case, having the [`StateTraversion`](https://github.com/uuf6429/state-en
Here's a quick & dirty example with the provided implementation (that assumes that there is a "door" model):

```php
use App\Models\Door; // example model
use App\Models\Door; // example model that implements StateAwareInterface

use uuf6429\StateEngine\Implementation\Builder;
use uuf6429\StateEngine\Implementation\Entities\State;

$doorStateManager = Builder::create()
->addState('open', 'Open')
->addState('closed', 'Closed')
->addState('locked', 'Locked')
->addTransition('open', 'closed', 'Close the door')
->addTransition('closed', 'locked', 'Lock the door')
->addTransition('locked', 'closed', 'Unlock the door')
->addTransition('closed', 'open', 'Open the door')
->defState('open', 'Open')
->defState('closed', 'Closed')
->defState('locked', 'Locked')
->defTransition('open', 'closed', 'Close the door')
->defTransition('closed', 'locked', 'Lock the door')
->defTransition('locked', 'closed', 'Unlock the door')
->defTransition('closed', 'open', 'Open the door')
->getEngine(); // you can pass an event dispatcher to the engine here

// find Door 123 (laravel-style repository-model)
$door = Door::find(123);

// build a state mutator (useful when the model does not have get/setState)
$doorStateMutator = Builder::stateMutator(
static fn(): State => new State($door->status), // getter
static fn(State $newState) => $door->update(['status' => $newState->getName()]) // setter
);

// close the door :)
$doorStateManager->changeState($doorStateMutator, new State('closed'));
$doorStateManager->changeState($door, new State('closed'));
```

### From Scratch (Custom)
Expand All @@ -100,6 +99,7 @@ For example, you could store states or transitions in a database, in which case
The library provides some flexibility so that you can connect your existing code with it. In more complicated scenarios,
you may have to build a small layer to bridge the gap. The example below illustrates how one can handle models with
flags instead of a single state.

```php
use App\Models\Door; // example model

Expand All @@ -108,7 +108,7 @@ use uuf6429\StateEngine\Implementation\Entities\State;

$door = Door::find(123);

$doorStateMutator = Builder::stateMutator(
$doorStateMutator = Builder::makeStateMutator(
static function () use ($door): State { // getter
if ($door->is_locked) {
return new State('locked');
Expand All @@ -125,12 +125,45 @@ $doorStateMutator = Builder::stateMutator(
]);
}
);

// assumes engine $doorStateManager was already defined
$doorStateManager->changeState($doorStateMutator, new State('closed'));
```

## Examples & Testing

The [`JiraIssueTest`](https://github.com/uuf6429/state-engine-php/blob/main/test/JiraIssueTest.php) class serves as a test as well as a realistic example of how Jira Issue states could be set up.
### [`JiraIssueTest`](https://github.com/uuf6429/state-engine-php/blob/main/tests/JiraIssueTest.php) State Engine

This test provides a realistic example of how Jira Issue states could be set up.

The test also generates the PlantUML diagram below (embedded as an image due to GFM limitations):

![example](https://www.planttext.com/api/plantuml/svg/TPBDRiCW48JlFCKUauDV88SgZgfAlLIrymGqJ2rK31PiBENjYurfux_hpZVB370EB3tVMoF4uI9lFyOrHogA5pgKLff7qE589xgWqPRaD5cIxvPUqG_ScmnSi8ygVJjF2ZsCwrfO5a_xHbCDgHuZDNcpJZVNTWQCbUNlr1FLuBktn8w-qb0i5wuwV02AMkSHOx7K9cnR_ikaqhCEMLmqgCg1lyAg8L5Lxe8r36J0nbNvfEmwfqnNTjqyqZn5hf0IfGQCmDes8i-tDrTbZAGDr1xtb3sodpA4WTtG9rzmfeTAZpKg8vsdwmTr7QmGvtY9yJV-0W00)
![jira issue example](https://www.planttext.com/api/plantuml/svg/TPBDRiCW48JlFCKUauDV88SgZgfAlLIrymGqJ2rK31PiBENjYurfux_hpZVB370EB3tVMoF4uI9lFyOrHogA5pgKLff7qE589xgWqPRaD5cIxvPUqG_ScmnSi8ygVJjF2ZsCwrfO5a_xHbCDgHuZDNcpJZVNTWQCbUNlr1FLuBktn8w-qb0i5wuwV02AMkSHOx7K9cnR_ikaqhCEMLmqgCg1lyAg8L5Lxe8r36J0nbNvfEmwfqnNTjqyqZn5hf0IfGQCmDes8i-tDrTbZAGDr1xtb3sodpA4WTtG9rzmfeTAZpKg8vsdwmTr7QmGvtY9yJV-0W00)

### [`TurnstileTest`](https://github.com/uuf6429/state-engine-php/blob/main/tests/JiraIssueTest.php) State Machine

This test illustrates how a [state machine](https://en.wikipedia.org/wiki/Finite-state_machine) can be used to model a [turnstile gate](https://en.wikipedia.org/wiki/Turnstile).
As before, here's the generated diagram:

![turnstile example](https://www.planttext.com/api/plantuml/svg/SoWkIImgAStDuUBIyCmjI2mkJapAITLKqDMrKz08W7Ej59ppC_CK2d8IarDJk90amEgGDLef1AGM5UVdAPGdvcGNAvHa5EMNfcTmSJcavgM0h040)

Here's how the state machine definition looks like and is used:
```php
use App\Models\Turnstile; // example model that implements StateAwareInterface

use uuf6429\StateEngine\Implementation\Builder;

$turnstileStateMachine = Builder::create()
// make states
->defState('locked', 'Impassable')
->defState('open', 'Passable')
// make transitions
->defDataTransition('locked', ['insert_coin'], 'open', 'Coin placed')
->defDataTransition('open', ['walk_through'], 'locked', 'Person walks through')
->getMachine();

$turnstile = Turnstile::find(123);

// put coin in turnstile (notice that the final state is not mentioned)
$turnstileStateMachine->processInput($turnstile, ['insert_coin']);
```
66 changes: 37 additions & 29 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
{
"name": "uuf6429/state-engine",
"type": "library",
"homepage": "https://github.com/uuf6429/state-engine-php",
"readme": "README.md",
"license": "MIT",
"description": "A library providing interfaces and basic implementation of a State Engine",
"keywords": ["state", "engine", "state-engine", "uuf6429"],
"authors": [
{
"name": "Christian Sciberras",
"email": "christian@sciberras.me"
}
],
"require": {
"php": "^7.4 || ^8.0",
"psr/event-dispatcher": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5"
},
"autoload": {
"psr-4": {
"uuf6429\\StateEngine\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"uuf6429\\StateEngine\\": "test/"
}
"name": "uuf6429/state-engine",
"type": "library",
"homepage": "https://github.com/uuf6429/state-engine-php",
"readme": "README.md",
"license": "MIT",
"description": "A library providing interfaces and basic implementation of a State Engine or Machine",
"keywords": [
"state",
"engine",
"state-engine",
"machine",
"state-machine",
"workflow",
"uuf6429"
],
"authors": [
{
"name": "Christian Sciberras",
"email": "christian@sciberras.me"
}
],
"require": {
"php": "^7.4 || ^8.0",
"psr/event-dispatcher": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5"
},
"autoload": {
"psr-4": {
"uuf6429\\StateEngine\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"uuf6429\\StateEngine\\": "tests/"
}
}
}
2 changes: 1 addition & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd">
<testsuites>
<testsuite name="All Tests">
<directory>./test</directory>
<directory>./tests</directory>
</testsuite>
</testsuites>
<coverage>
Expand Down
6 changes: 4 additions & 2 deletions src/Exceptions/BuilderStateAlreadyDeclaredException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

namespace uuf6429\StateEngine\Exceptions;

use uuf6429\StateEngine\Interfaces\StateInterface;

class BuilderStateAlreadyDeclaredException extends InvalidArgumentException
{
public function __construct(string $name)
public function __construct(StateInterface $state)
{
parent::__construct("Cannot add state \"$name\", it has already been declared.");
parent::__construct("Cannot add state \"$state\", it has already been declared.");
}
}
6 changes: 4 additions & 2 deletions src/Exceptions/BuilderStateNotDeclaredException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

namespace uuf6429\StateEngine\Exceptions;

use uuf6429\StateEngine\Interfaces\StateInterface;

class BuilderStateNotDeclaredException extends InvalidArgumentException
{
public function __construct(string $name)
public function __construct(StateInterface $state)
{
parent::__construct("Cannot use state \"$name\", since it has not been declared yet.");
parent::__construct("Cannot use state \"$state\", since it has not been declared yet.");
}
}
6 changes: 4 additions & 2 deletions src/Exceptions/BuilderTransitionAlreadyDeclaredException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

namespace uuf6429\StateEngine\Exceptions;

use uuf6429\StateEngine\Interfaces\TransitionInterface;

class BuilderTransitionAlreadyDeclaredException extends InvalidArgumentException
{
public function __construct(string $oldState, string $newState)
public function __construct(TransitionInterface $transition)
{
parent::__construct("Cannot add transition from \"$oldState\" to \"$newState\", it has already been declared.");
parent::__construct("Cannot add transition \"$transition\", it has already been declared.");
}
}
17 changes: 0 additions & 17 deletions src/Exceptions/TransitionNotAllowedException.php

This file was deleted.

13 changes: 13 additions & 0 deletions src/Exceptions/TransitionNotDeclaredException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace uuf6429\StateEngine\Exceptions;

use uuf6429\StateEngine\Interfaces\TransitionInterface;

class TransitionNotDeclaredException extends RuntimeException
{
public function __construct(TransitionInterface $transition)
{
parent::__construct("Cannot apply transition \"$transition\"; no such transition was defined.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
namespace uuf6429\StateEngine\Implementation;

use Psr\EventDispatcher\EventDispatcherInterface;
use uuf6429\StateEngine\Exceptions\TransitionNotAllowedException;
use uuf6429\StateEngine\Exceptions\TransitionNotDeclaredException;
use uuf6429\StateEngine\Interfaces\EngineInterface;
use uuf6429\StateEngine\Interfaces\StateInterface;
use uuf6429\StateEngine\Interfaces\StateAwareInterface;
use uuf6429\StateEngine\Interfaces\TransitionInterface;
use uuf6429\StateEngine\Interfaces\TransitionRepositoryInterface;

class Engine implements EngineInterface
abstract class AbstractEngine implements EngineInterface
{
private TransitionRepositoryInterface $repository;

Expand All @@ -22,22 +22,16 @@ public function __construct(TransitionRepositoryInterface $repository, ?EventDis
$this->dispatcher = $dispatcher;
}

public function changeState(StateAwareInterface $item, StateInterface $newState): void
public function execute(StateAwareInterface $item, TransitionInterface $transition): void
{
$transition = new Entities\Transition($item->getState(), $newState);
$this->applyTransition($item, $transition);
}

public function applyTransition(StateAwareInterface $item, TransitionInterface $transition): void
{
if (!$this->repository->has($transition)) {
throw new TransitionNotAllowedException($transition);
if (!($matched = $this->repository->find($transition))) {
throw new TransitionNotDeclaredException($transition);
}

$this->dispatcher && $this->dispatcher->dispatch(new Events\StateChanging($item, $transition->getNewState()));
$this->dispatcher && $this->dispatcher->dispatch(new Events\StateChanging($item, $matched->getNewState()));

$item->setState($transition->getNewState());
$item->setState($matched->getNewState());

$this->dispatcher && $this->dispatcher->dispatch(new Events\StateChanged($item, $transition->getOldState()));
$this->dispatcher && $this->dispatcher->dispatch(new Events\StateChanged($item, $matched->getOldState()));
}
}
Loading

0 comments on commit f227ab2

Please sign in to comment.