Skip to content

Commit

Permalink
✨ Protect transitions using guards (#5)
Browse files Browse the repository at this point in the history
* ✨ Protect transactions with guards

* ✅ Add unit tests for guards

* 📝 Add guard section to the doc

* 🎨 Fix styling

Co-authored-by: Mouad Ziani <[email protected]>
  • Loading branch information
mouadziani and mouadziani authored Jul 17, 2022
1 parent e3b5a02 commit fd28f2c
Show file tree
Hide file tree
Showing 5 changed files with 87 additions and 40 deletions.
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ XState is a state machine library to play with any complex behavior of your PHP

### Installation

The recommended way to install Xstate is through
The recommended way to install Xstate is through
[Composer](https://getcomposer.org/).

```bash
Expand Down Expand Up @@ -58,6 +58,21 @@ The `Transition` class expect 3 required params:
- **From**: Expect a string for a single / or array for multiple initial allowed states
- **To**: Expect string which is the next target state *(should match one of the defined allowed states)*

#### Guards (optional)

You can either define a guard callback for a specific transition using `guard` method, which must return a bool. If a guard returns false, the transition cannot be performed.

```php
use \Mouadziani\XState\Transition;
$video->transitions([
(new Transition('PLAY', ['stopped', 'paused'], 'playing'))
->guard(function ($from, $to) {
return true;
})
]);
```


### 💡 You can define the whole workflow with a single statement:

```php
Expand Down Expand Up @@ -122,8 +137,8 @@ $video->addTransition(new Transition('TURN_OFF', 'playing', 'stopped'));

## Upcoming features

- [x] Add the ability to define guard for a specific transition
- [ ] Define/handle hooks before/after triggering transition
- [ ] Add the ability to define gates for a specific transition


## Testing
Expand Down
3 changes: 1 addition & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"pestphp/pest": "^1.20",
"spatie/ray": "^1.28"
"pestphp/pest": "^1.20"
},
"autoload": {
"psr-4": {
Expand Down
33 changes: 7 additions & 26 deletions src/StateMachine.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace Mouadziani\XState;

use Closure;
use Mouadziani\XState\Exceptions\TransitionNotAllowedException;
use Mouadziani\XState\Exceptions\TransitionNotDefinedException;

Expand All @@ -16,10 +15,6 @@ class StateMachine

private ?string $currentState = null;

private ?Closure $beforeEachTransition = null;

private ?Closure $afterEachTransition = null;

public static function make(): self
{
return new static();
Expand Down Expand Up @@ -61,20 +56,6 @@ public function addTransition(Transition $transition): self
return $this;
}

public function beforeEachTransition(Closure $beforeEachTransition): self
{
$this->beforeEachTransition = $beforeEachTransition;

return $this;
}

public function afterEachTransition(Closure $afterTransition): self
{
$this->afterTransition = $afterTransition;

return $this;
}

public function currentState(): string
{
return $this->currentState;
Expand All @@ -92,7 +73,7 @@ public function transitionTo(string $trigger): self
throw new TransitionNotAllowedException('Transition not allowed');
}

$transition->handle($this->currentState, $this->beforeEachTransition, $this->afterEachTransition);
$transition->handle($this->currentState);

return $this;
}
Expand All @@ -101,16 +82,16 @@ public function canTransisteTo(string $trigger): bool
{
$transition = $this->findTransition($trigger);

return $transition && in_array($trigger, $this->allowedTransitions());
return $transition->allowed();

return $transition && $transition->allowed() && in_array($trigger, $this->allowedTransitions());
}

public function allowedTransitions(): array
{
$allowedTransitions = array_filter(
$this->transitions,
fn ($transition) =>
in_array($this->currentState(), is_array($transition->from) ? $transition->from : [$transition->from])
);
$allowedTransitions = array_filter($this->transitions, function ($transition) {
return in_array($this->currentState(), is_array($transition->from) ? $transition->from : [$transition->from]);
});

return array_map(fn ($transition) => $transition->trigger, array_values($allowedTransitions));
}
Expand Down
53 changes: 43 additions & 10 deletions src/Transition.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,65 @@
namespace Mouadziani\XState;

use Closure;
use Mouadziani\XState\Exceptions\TransitionNotAllowedException;

class Transition
{
public string $trigger;

public string|array $from;

public string $to;
public ?Closure $beforeHook;
public ?Closure $afterHook;

public function __construct(string $trigger, string|array $from, string $to, ?Closure $beforeHook = null, ?Closure $afterHook = null)
private ?Closure $guard = null;

private ?Closure $before = null;

private ?Closure $after = null;

public function __construct(string $trigger, string|array $from, string $to)
{
$this->trigger = $trigger;
$this->from = $from;
$this->to = $to;
$this->beforeHook = $beforeHook;
$this->afterHook = $afterHook;
}

public function handle(string &$currentState, ?Closure $beforeTransition = null, ?Closure $afterTransition = null): void
public function guard(Closure $guard)
{
$beforeTransition && $beforeTransition($this->from, $this->to);
$this->beforeHook && call_user_func($this->beforeHook, $this->from, $this->to);
$this->guard = $guard;

return $this;
}

public function before(Closure $before)
{
$this->before = $before;

return $this;
}

public function after(Closure $before)
{
$this->before = $before;

return $this;
}

public function allowed(): bool
{
return ! $this->guard || call_user_func($this->guard, $this->from, $this->to);
}

public function handle(string &$currentState): void
{
if (! $this->allowed()) {
throw new TransitionNotAllowedException();
}

$this->before && call_user_func($this->before, $this->from, $this->to);

$currentState = $this->to;

$afterTransition && $afterTransition($this->from, $this->to);
$this->afterHook && call_user_func($this->afterHook, $this->from, $this->to);
$this->after && call_user_func($this->after, $this->from, $this->to);
}
}
19 changes: 19 additions & 0 deletions tests/StateMachineTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,22 @@

$video->transitionTo('TURN_OFF');
})->throws(TransitionNotDefinedException::class);

it('Uses guard to check if the transition is allowed', function () {
$video = StateMachine::make()
->defaultState('stopped')
->states(['playing', 'stopped', 'paused'])
->transitions([
(new Transition('PLAY', ['stopped', 'paused'], 'playing'))
->guard(fn ($from, $to) => false),

(new Transition('TURN_ON', ['stopped', 'paused'], 'playing'))
->guard(fn ($from, $to) => true),
]);

expect(false)->toBe($video->canTransisteTo('PLAY'));
expect(true)->toBe($video->canTransisteTo('TURN_ON'));

$video->transitionTo('TURN_ON');
expect($video->currentState())->toBe('playing');
});

0 comments on commit fd28f2c

Please sign in to comment.