Commit 104f994e authored by Jan Kuchař's avatar Jan Kuchař

Merge branch 'add-order-state-example' into 'master'

docs: add order state example

See merge request !3
parents 69816bb0 9de18e66
Pipeline #14410 passed with stages
in 40 seconds
......@@ -220,7 +220,7 @@ Now type-system knows that every enum value must have method `nextDay()` with re
This approach is very useful when one wants to implement anything state-machine related (see tests for more examples, they are simple and easy to read).
More use cases:
- order state (new, in progress, delivering, delivered) and relations between them
- order state: [show example](tests/Example/OrderState) - shows 5 styles of implementing this
- day of week
- tracking life-cycle
......
......@@ -2,3 +2,12 @@ includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/grifart/phpstan-oneline/config.neon
parameters:
ignoreErrors:
# for phpstan 0.11
# -
# message: '#Strict comparison using === between int\\|string and null will always evaluate to false#'
# path: src/Internal/Meta.php
- '#Strict comparison using === between int\\|string and null will always evaluate to false#'
\ No newline at end of file
......@@ -64,7 +64,7 @@ abstract class Enum
{
\assert(\count($arguments) === 0);
$value = self::getMeta()->getValueForConstantName($constantName);
$value = self::getMeta(FALSE)->getValueForConstantName($constantName);
if($value === NULL) {
throw new \Error('Call to undefined method ' . static::class . '::' . $constantName . '(). Please check that you have provided constant, annotation and value.');
}
......
......@@ -51,6 +51,13 @@ final class Meta
foreach($values as $value) {
$scalar = $value->toScalar();
if ($scalar === NULL) {
throw new UsageException(
"Parent constructor has not been called while constructing one of {$this->getClass()} enum values."
);
}
if (isset($scalarToValues[$scalar])) {
throw new UsageException('You have provided duplicated scalar values.');
}
......
# Order state example
In order state example I would like to demonstrate that there are more then one solution of domain problem of order state which can transition into another states.
## 1. Class constants
[source code](refactoring-1.phpt)
There are public constants on class and you should figure out that you should put them into `canDoTransition()` method. There is nothing on type-level that helps you with that. Please note that all logic is in `OrderService`.
## 2. Dumb type-safe enum
[source code](refactoring-2.phpt)
This test shows usage of explicitly-declared dumb-enum.
I have explicitly declared type for `OrderState`. It is not possible anymore to pass non-sense values into `OrderService`. That is because `OrderState` enum provides no interface for creating non-sense values. So they simply cannot exists.
All logic has been kept in `OrderService`. We still need to handle cas when someone added new value to enum, which we do not count with. (the exception in default case).
## 3. Logic moved into enum
[source code](refactoring-3.phpt)
Here I have moved `OrderService::canDoTransition()` method into enum itself.
Nice thing is that we do not need anymore external service for asking `OrderState`-related questions.
Remaining problem is that there are still lot of ifs and we still need to handle case where someone adds new value into enum which we do not count with.
## 4. Separate instance for each value
[source code](refactoring-4.phpt)
When there is behaviour same for all values of enum, it can be safely placed on enum class. Behaviour can be parametrized by providing necessary information in enum-value constructor.
## 5. Separate class implementation for each value
[source code](refactoring-5.phpt)
Now, new domain requirement:
> I would like to remove person who has been assigned to work on order, when order changes state to cancelled or finished.
1. I have rewritten each value as separate class (as behaviour is different for different values)
2. I have implemented doTransition() on enum parent class as it is only proper way of changing enum value
3. I have added `onActivation(Order $order)` method, which is called whenever state transition occurs.
3. I have overridden `onActivation()` on enum values with desired behaviour.
<?php declare(strict_types=1);
/**
* This test shows implicit enum declaration.
* @see README.md
*/
namespace Grifart\Enum\Example\OrderState\__test_refactoring_1;
require __DIR__ . '/../../bootstrap.php';
use Tester\Assert;
final class InvalidTransitionException extends \RuntimeException {}
class OrderService
{
public const STATE_RECEIVED = 'received';
public const STATE_PROCESSING = 'processing';
public const STATE_FINISHED = 'finished';
public const STATE_CANCELLED = 'cancelled';
public function canDoTransition(string $currentState, string $desiredState): bool
{
if ($currentState === $desiredState) {
return TRUE;
}
switch ($currentState) {
case self::STATE_RECEIVED:
return $desiredState === self::STATE_PROCESSING || $desiredState === self::STATE_CANCELLED;
case self::STATE_PROCESSING:
return $desiredState === self::STATE_FINISHED;
case self::STATE_FINISHED:
return FALSE;
case self::STATE_CANCELLED:
return FALSE;
default:
throw new \LogicException('Should not happen: Unknown state');
}
}
}
$orderService = new OrderService();
// Standard order flow:
Assert::true(
$orderService->canDoTransition(
OrderService::STATE_RECEIVED,
OrderService::STATE_PROCESSING
)
);
Assert::true(
$orderService->canDoTransition(
OrderService::STATE_PROCESSING,
OrderService::STATE_FINISHED
)
);
// Cancellation order flow
Assert::true(
$orderService->canDoTransition(
OrderService::STATE_RECEIVED,
OrderService::STATE_CANCELLED
)
);
// Reflexivity test
Assert::true(
$orderService->canDoTransition(
OrderService::STATE_CANCELLED,
OrderService::STATE_CANCELLED
)
);
// --- NEGATIVE TESTS ---
// Invalid order flow
Assert::false(
$orderService->canDoTransition(
OrderService::STATE_RECEIVED,
OrderService::STATE_FINISHED
)
);
Assert::false(
$orderService->canDoTransition(
OrderService::STATE_PROCESSING,
OrderService::STATE_CANCELLED
)
);
Assert::false(
$orderService->canDoTransition(
OrderService::STATE_FINISHED,
OrderService::STATE_CANCELLED
)
);
// check for completely invalid arguments
Assert::exception(function () use ($orderService) {
$orderService->canDoTransition('invalid', 'non-existing');
}, \LogicException::class);
<?php declare(strict_types=1);
/**
* @see README.md
*/
namespace Grifart\Enum\Example\OrderState\__test_refactoring_2;
require __DIR__ . '/../../bootstrap.php';
use Grifart\Enum\Enum;
use Tester\Assert;
final class InvalidTransitionException extends \RuntimeException {}
/**
* @method static OrderState RECEIVED()
* @method static OrderState PROCESSING()
* @method static OrderState FINISHED()
* @method static OrderState CANCELLED()
*/
final class OrderState extends Enum
{
use \Grifart\Enum\AutoInstances;
protected const
RECEIVED = 'received',
PROCESSING = 'processing',
FINISHED = 'finished',
CANCELLED = 'cancelled';
}
class OrderService
{
public function canDoTransition(OrderState $currentState, OrderState $desiredState): bool
{
if ($currentState === OrderState::RECEIVED()) {
return $desiredState === OrderState::PROCESSING() || $desiredState === OrderState::CANCELLED();
}
if ($currentState === OrderState::PROCESSING()) {
return $desiredState === OrderState::FINISHED();
}
if ($currentState === OrderState::FINISHED()) {
return FALSE;
}
if ($currentState === OrderState::CANCELLED()) {
return FALSE;
}
throw new \LogicException('Should not happen: Unknown state');
}
}
$orderService = new OrderService();
// Standard order flow:
Assert::true(
$orderService->canDoTransition(
OrderState::RECEIVED(),
OrderState::PROCESSING()
)
);
Assert::true(
$orderService->canDoTransition(
OrderState::PROCESSING(),
OrderState::FINISHED()
)
);
// Cancellation order flow
Assert::true(
$orderService->canDoTransition(
OrderState::RECEIVED(),
OrderState::CANCELLED()
)
);
// Reflexivity test
Assert::false(
$orderService->canDoTransition(
OrderState::CANCELLED(),
OrderState::CANCELLED()
)
);
// --- NEGATIVE TESTS ---
// Invalid order flow
Assert::false(
$orderService->canDoTransition(
OrderState::RECEIVED(),
OrderState::FINISHED()
)
);
Assert::false(
$orderService->canDoTransition(
OrderState::PROCESSING(),
OrderState::CANCELLED()
)
);
Assert::false(
$orderService->canDoTransition(
OrderState::FINISHED(),
OrderState::CANCELLED()
)
);
<?php declare(strict_types=1);
/**
* Logic moved into enum
* @see README.md
*/
namespace Grifart\Enum\Example\__test_refactoring_3;
require __DIR__ . '/../../bootstrap.php';
use Grifart\Enum\Enum;
use Tester\Assert;
final class InvalidTransitionException extends \RuntimeException {}
/**
* @method static OrderState RECEIVED()
* @method static OrderState PROCESSING()
* @method static OrderState FINISHED()
* @method static OrderState CANCELLED()
*/
final class OrderState extends Enum
{
use \Grifart\Enum\AutoInstances;
protected const
RECEIVED = 'received',
PROCESSING = 'processing',
FINISHED = 'finished',
CANCELLED = 'cancelled';
public function canDoTransition(OrderState $desiredState): bool
{
if ($this === self::RECEIVED()) {
return $desiredState === self::PROCESSING() || $desiredState === self::CANCELLED();
}
if ($this === self::PROCESSING()) {
return $desiredState === self::FINISHED();
}
if ($this === self::FINISHED()) {
return FALSE;
}
if ($this === self::CANCELLED()) {
return FALSE;
}
throw new \LogicException('Should not happen: Unknown state');
}
}
// Standard order flow:
Assert::true(
OrderState::RECEIVED()->canDoTransition(
OrderState::PROCESSING()
)
);
Assert::true(
OrderState::PROCESSING()->canDoTransition(
OrderState::FINISHED()
)
);
// Cancellation order flow
Assert::true(
OrderState::RECEIVED()->canDoTransition(
OrderState::CANCELLED()
)
);
// Non-reflexivity test
Assert::false(
OrderState::CANCELLED()->canDoTransition(
OrderState::CANCELLED()
)
);
// --- NEGATIVE TESTS ---
// Invalid order flow
Assert::false(
OrderState::RECEIVED()->canDoTransition(
OrderState::FINISHED()
)
);
Assert::false(
OrderState::PROCESSING()->canDoTransition(
OrderState::CANCELLED()
)
);
Assert::false(
OrderState::FINISHED()->canDoTransition(
OrderState::CANCELLED()
)
);
$state1 = OrderState::RECEIVED();
$state2 = OrderState::RECEIVED();
Assert::true($state1 === $state2);
$state3 = OrderState::PROCESSING();
Assert::true($state1 !== $state3);
Assert::true($state2 !== $state3);
<?php declare(strict_types=1);
/**
* Separate instance for each value
* @see README.md
*/
namespace Grifart\Enum\Example\OrderState\__test_refactoring_4;
require __DIR__ . '/../../bootstrap.php';
use Grifart\Enum\Enum;
use Tester\Assert;
final class InvalidTransitionException extends \RuntimeException {}
/**
* @method static OrderState RECEIVED()
* @method static OrderState PROCESSING()
* @method static OrderState FINISHED()
* @method static OrderState CANCELLED()
*/
final class OrderState extends Enum
{
protected const
RECEIVED = 'received',
PROCESSING = 'processing',
FINISHED = 'finished',
CANCELLED = 'cancelled'; // domain logic: can be cancelled before preparation is started
/** @var string[] */
private $nextAllowedStates = [];
/**
* @param string[] $nextAllowedStates
*/
protected function __construct($scalar, array $nextAllowedStates)
{
parent::__construct($scalar);
$this->nextAllowedStates = $nextAllowedStates;
}
public function canDoTransition(OrderState $nextState): bool
{
return \in_array($nextState->toScalar(), $this->nextAllowedStates, TRUE);
}
/** @return self[] */
final protected static function provideInstances(): array
{
// please not that we cannot reference self::PREPARING()
// as it returns class instance, and this will call provideInstances()
// again and you will get infinite loop.
return [
new self(self::RECEIVED, [self::PROCESSING, self::CANCELLED]),
new self(self::PROCESSING, [self::FINISHED]),
new self(self::FINISHED, []),
new self(self::CANCELLED, []),
];
}
}
// Standard order flow:
Assert::true(
OrderState::RECEIVED()->canDoTransition(
OrderState::PROCESSING()
)
);
Assert::true(
OrderState::PROCESSING()->canDoTransition(
OrderState::FINISHED()
)
);
// Cancellation order flow
Assert::true(
OrderState::RECEIVED()->canDoTransition(
OrderState::CANCELLED()
)
);
// Non-reflexivity test
Assert::false(
OrderState::CANCELLED()->canDoTransition(
OrderState::CANCELLED()
)
);
// --- NEGATIVE TESTS ---
// Invalid order flow
Assert::false(
OrderState::RECEIVED()->canDoTransition(
OrderState::FINISHED()
)
);
Assert::false(
OrderState::PROCESSING()->canDoTransition(
OrderState::CANCELLED()
)
);
Assert::false(
OrderState::FINISHED()->canDoTransition(
OrderState::CANCELLED()
)
);
<?php declare(strict_types=1);
/**
* Separate class implementation for each value
* @see README.md
*/
namespace Grifart\Enum\Example\OrderState\__test_refactoring_5;
require __DIR__ . '/../../bootstrap.php';
use Grifart\Enum\Enum;
use Tester\Assert;
final class InvalidTransitionException extends \RuntimeException {}
class Order
{
/** @var string|null */
private $employee = 'employee';
/** @var OrderState */
private $state;
public function __construct() {
$this->state = OrderState::RECEIVED();
}
public function unassignEmployee(): void {
$this->employee = null;
}
public function getEmployee(): ?string {
return $this->employee;
}
/**
* @throws InvalidTransitionException
*/
public function changeState(OrderState $desiredState): void
{
$this->state =
$this->state->doTransition($this, $desiredState);
}
}
/**
* @method static OrderState RECEIVED()
* @method static OrderState PROCESSING()
* @method static OrderState FINISHED()
* @method static OrderState CANCELLED()
*/
abstract class OrderState extends Enum {
protected const
RECEIVED = 'received',
PROCESSING = 'processing',
FINISHED = 'finished',
CANCELLED = 'cancelled'; // domain logic: can be cancelled before preparation is started
/**
* @throws InvalidTransitionException
*/
final public function doTransition(Order $order, OrderState $desiredState): self
{
if ($desiredState !== $this && !$this->canDoTransition($desiredState)) {
throw new InvalidTransitionException();
}
$desiredState->onActivation($order);
return $desiredState;
}
abstract public function canDoTransition(OrderState $nextState): bool;
protected function onActivation(Order $order): void { /* override me */}
/** @return self[] */
final protected static function provideInstances(): array
{
return [
new class(self::RECEIVED) extends OrderState {
public function canDoTransition(OrderState $nextState): bool
{
return $nextState === $this::PROCESSING() || $nextState === $this::CANCELLED();
}
},
new class(self::PROCESSING) extends OrderState {
public function canDoTransition(OrderState $nextState): bool
{
return $nextState === $this::FINISHED();
}
},
new class(self::FINISHED) extends OrderState {
public function canDoTransition(OrderState $nextState): bool
{
return FALSE;
}
protected function onActivation(Order $order): void
{
$order->unassignEmployee();
}
},
new class(self::CANCELLED) extends OrderState {
public function canDoTransition(OrderState $nextState): bool
{
return FALSE;
}
protected function onActivation(Order $order): void
{
$order->unassignEmployee();
}
},
];
}
}
// Standard order flow:
(function() {
$order = new Order();
Assert::same('employee', $order->getEmployee());
$order->changeState(OrderState::PROCESSING());
Assert::same('employee', $order->getEmployee());
$order->changeState(OrderState::FINISHED());
Assert::null($order->getEmployee());
})();
// Cancellation order flow
(function() {
$order = new Order();
Assert::same('employee', $order->getEmployee());
$order->changeState(OrderState::CANCELLED());
Assert::null($order->getEmployee());
})();
// --- NEGATIVE TESTS ---
// Invalid order flow
Assert::exception(function () {
$order = new Order();
$order->changeState(OrderState::FINISHED()); // not allowed
}, InvalidTransitionException::class);
Assert::exception(function () {
$order = new Order();
$order->changeState(OrderState::PROCESSING());
$order->changeState(OrderState::CANCELLED()); // not allowed
}, InvalidTransitionException::class);
Assert::exception(function () {