diff --git a/composer.json b/composer.json index b08aa9b8..7ec379e2 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,6 @@ "require": { "php": "^8.1", "laravel/framework": "^9.0|^10.0|^11.0|^12.0", - "spatie/laravel-model-states": "^2.0", "react/promise": "^2.9|^3.0" }, "require-dev": { diff --git a/src/ChildWorkflow.php b/src/ChildWorkflow.php index 04f15b4d..631b3c5a 100644 --- a/src/ChildWorkflow.php +++ b/src/ChildWorkflow.php @@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Middleware\WithoutOverlappingMiddleware; use Workflow\Models\StoredWorkflow; @@ -62,7 +63,7 @@ public function handle() } else { $workflow->next($this->index, $this->now, $this->storedWorkflow->class, $this->return); } - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { if ($workflow->running()) { $this->release(); } diff --git a/src/ChildWorkflowStub.php b/src/ChildWorkflowStub.php index f9c1e6e4..a0cba645 100644 --- a/src/ChildWorkflowStub.php +++ b/src/ChildWorkflowStub.php @@ -8,6 +8,7 @@ use React\Promise\Deferred; use React\Promise\PromiseInterface; use function React\Promise\resolve; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Serializers\Serializer; final class ChildWorkflowStub @@ -69,7 +70,7 @@ public static function make($workflow, ...$arguments): PromiseInterface if ($childWorkflow->running() && ! $childWorkflow->created()) { try { $childWorkflow->resume(); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { // already running } } elseif (! $childWorkflow->completed()) { diff --git a/src/Exception.php b/src/Exception.php index d5613a21..c2756528 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Middleware\WithoutOverlappingMiddleware; use Workflow\Models\StoredWorkflow; @@ -55,7 +56,7 @@ public function handle() } else { $workflow->next($this->index, $this->now, self::class, $this->exception); } - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { if ($workflow->running()) { $this->release(); } diff --git a/src/Exceptions/TransitionNotFound.php b/src/Exceptions/TransitionNotFound.php new file mode 100644 index 00000000..c324184c --- /dev/null +++ b/src/Exceptions/TransitionNotFound.php @@ -0,0 +1,42 @@ +from = $from; + $exception->to = $to; + $exception->modelClass = $modelClass; + + return $exception; + } + + public function getFrom(): string + { + return $this->from; + } + + public function getTo(): string + { + return $this->to; + } + + public function getModelClass(): string + { + return $this->modelClass; + } +} diff --git a/src/Middleware/ActivityMiddleware.php b/src/Middleware/ActivityMiddleware.php index 3e4d7ed4..4ee91262 100644 --- a/src/Middleware/ActivityMiddleware.php +++ b/src/Middleware/ActivityMiddleware.php @@ -10,6 +10,7 @@ use Workflow\Events\ActivityCompleted; use Workflow\Events\ActivityFailed; use Workflow\Events\ActivityStarted; +use Workflow\Exceptions\TransitionNotFound; final class ActivityMiddleware { @@ -73,7 +74,7 @@ public function onUnlock(bool $shouldSignal): void } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $throwable) { $this->job->storedWorkflow->toWorkflow() ->fail($throwable); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { if ($this->job->storedWorkflow->toWorkflow()->running()) { $this->job->release(); } diff --git a/src/Models/StoredWorkflow.php b/src/Models/StoredWorkflow.php index aba0b86b..fd8d0a3f 100644 --- a/src/Models/StoredWorkflow.php +++ b/src/Models/StoredWorkflow.php @@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Prunable; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Arr; -use Spatie\ModelStates\HasStates; +use Workflow\States\HasStates; use Workflow\States\WorkflowContinuedStatus; use Workflow\States\WorkflowStatus; use Workflow\WorkflowMetadata; diff --git a/src/Signal.php b/src/Signal.php index df0b05ea..a350b023 100644 --- a/src/Signal.php +++ b/src/Signal.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Middleware\WithoutOverlappingMiddleware; use Workflow\Models\StoredWorkflow; @@ -56,7 +57,7 @@ public function handle(): void try { $workflow->resume(); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { if ($workflow->running()) { $this->release(); } diff --git a/src/States/HasStates.php b/src/States/HasStates.php new file mode 100644 index 00000000..1d0a45c1 --- /dev/null +++ b/src/States/HasStates.php @@ -0,0 +1,142 @@ +setStateDefaults(); + }); + } + + public function initializeHasStates(): void + { + $this->setStateDefaults(); + } + + public static function getStates(): Collection + { + $model = static::make(); + + return collect($model->getStateConfigs()) + ->map(static function (StateConfig $stateConfig) { + return $stateConfig->baseStateClass::getStateMapping()->keys(); + }); + } + + public static function getDefaultStates(): Collection + { + $model = static::make(); + + return collect($model->getStateConfigs()) + ->map(static function (StateConfig $stateConfig) { + $defaultStateClass = $stateConfig->defaultStateClass; + + if ($defaultStateClass === null) { + return null; + } + + return $defaultStateClass::getMorphClass(); + }); + } + + public static function getDefaultStateFor(string $fieldName): ?string + { + return static::getDefaultStates()[$fieldName] ?? null; + } + + public static function getStatesFor(string $fieldName): Collection + { + return collect(static::getStates()[$fieldName] ?? []); + } + + public function scopeWhereState(Builder $builder, string $column, $states): Builder + { + $states = Arr::wrap($states); + $field = Str::afterLast($column, '.'); + + return $builder->whereIn($column, $this->getStateNamesForQuery($field, $states)); + } + + public function scopeWhereNotState(Builder $builder, string $column, $states): Builder + { + $states = Arr::wrap($states); + $field = Str::afterLast($column, '.'); + + return $builder->whereNotIn($column, $this->getStateNamesForQuery($field, $states)); + } + + public function scopeOrWhereState(Builder $builder, string $column, $states): Builder + { + $states = Arr::wrap($states); + $field = Str::afterLast($column, '.'); + + return $builder->orWhereIn($column, $this->getStateNamesForQuery($field, $states)); + } + + public function scopeOrWhereNotState(Builder $builder, string $column, $states): Builder + { + $states = Arr::wrap($states); + $field = Str::afterLast($column, '.'); + + return $builder->orWhereNotIn($column, $this->getStateNamesForQuery($field, $states)); + } + + /** + * @return array + */ + private function getStateConfigs(): array + { + $states = []; + + foreach ($this->getCasts() as $field => $state) { + if (! is_subclass_of($state, State::class)) { + continue; + } + + $states[$field] = $state::config(); + } + + return $states; + } + + private function getStateNamesForQuery(string $field, array $states): Collection + { + $stateConfig = $this->getStateConfigs()[$field] ?? null; + + if ($stateConfig === null) { + return collect([]); + } + + return $stateConfig->baseStateClass::getStateMapping() + ->filter(static function (string $className, string $morphName) use ($states) { + return in_array($className, $states, true) + || in_array($morphName, $states, true); + }) + ->keys(); + } + + private function setStateDefaults(): void + { + foreach ($this->getStateConfigs() as $field => $stateConfig) { + if ($this->{$field} !== null) { + continue; + } + + if ($stateConfig->defaultStateClass === null) { + continue; + } + + $this->{$field} = $stateConfig->defaultStateClass; + } + } +} diff --git a/src/States/State.php b/src/States/State.php new file mode 100644 index 00000000..af395296 --- /dev/null +++ b/src/States/State.php @@ -0,0 +1,252 @@ +> + */ + private static array $stateMapping = []; + + public function __construct($model) + { + $this->model = $model; + $this->stateConfig = static::config(); + } + + public function __toString(): string + { + return $this->getValue(); + } + + public static function config(): StateConfig + { + $reflection = new ReflectionClass(static::class); + $baseClass = $reflection->name; + + while (! $reflection->isAbstract() && ($parent = $reflection->getParentClass()) instanceof ReflectionClass) { + $reflection = $parent; + $baseClass = $reflection->name; + } + + return new StateConfig($baseClass); + } + + public static function castUsing(array $arguments) + { + return new StateCaster(static::class); + } + + public static function getMorphClass(): string + { + $defaultProperties = (new ReflectionClass(static::class))->getDefaultProperties(); + $name = $defaultProperties['name'] ?? null; + + return is_string($name) ? $name : static::class; + } + + public static function getStateMapping(): Collection + { + if (! isset(self::$stateMapping[static::class])) { + self::$stateMapping[static::class] = static::resolveStateMapping(); + } + + return collect(self::$stateMapping[static::class]); + } + + public static function resolveStateClass($state): ?string + { + if ($state === null) { + return null; + } + + if ($state instanceof self) { + return $state::class; + } + + if (is_string($state) && is_subclass_of($state, static::class)) { + return $state; + } + + foreach (static::getStateMapping() as $morphClass => $stateClass) { + // Loose comparison is needed to support values casted to strings by Eloquent. + if ($morphClass === $state) { + return $stateClass; + } + } + + return is_string($state) ? $state : null; + } + + public static function make(string $name, $model): self + { + $stateClass = static::resolveStateClass($name); + + if (! is_string($stateClass) || ! is_subclass_of($stateClass, static::class)) { + throw new InvalidArgumentException("{$name} does not extend " . static::class . '.'); + } + + return new $stateClass($model); + } + + public function getModel() + { + return $this->model; + } + + public function getField(): string + { + return $this->field; + } + + public static function all(): Collection + { + return collect(self::resolveStateMapping()); + } + + public function setField(string $field): self + { + $this->field = $field; + + return $this; + } + + public function transitionTo($newState, ...$transitionArgs) + { + $newState = $this->resolveStateObject($newState); + + $from = static::getMorphClass(); + $to = $newState::getMorphClass(); + + if (! $this->stateConfig->isTransitionAllowed($from, $to)) { + throw TransitionNotFound::make($from, $to, $this->model::class); + } + + $this->model->{$this->field} = $newState; + $this->model->save(); + + $currentState = $this->model->{$this->field}; + + if ($currentState instanceof self) { + $currentState->setField($this->field); + } + + return $this->model; + } + + public function canTransitionTo($newState, ...$transitionArgs): bool + { + $newState = $this->resolveStateObject($newState); + + return $this->stateConfig->isTransitionAllowed(static::getMorphClass(), $newState::getMorphClass()); + } + + public function getValue(): string + { + return static::getMorphClass(); + } + + public function equals(...$otherStates): bool + { + foreach ($otherStates as $otherState) { + $otherState = $this->resolveStateObject($otherState); + + if ( + $this->stateConfig->baseStateClass === $otherState->stateConfig->baseStateClass + && $this->getValue() === $otherState->getValue() + ) { + return true; + } + } + + return false; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->getValue(); + } + + private function resolveStateObject($state): self + { + if (is_object($state) && is_subclass_of($state, $this->stateConfig->baseStateClass)) { + return $state; + } + + $stateClassName = $this->stateConfig->baseStateClass::resolveStateClass($state); + + if (! is_string($stateClassName) || ! is_subclass_of($stateClassName, $this->stateConfig->baseStateClass)) { + throw new InvalidArgumentException("{$state} does not extend {$this->stateConfig->baseStateClass}."); + } + + return new $stateClassName($this->model); + } + + /** + * @return array + */ + private static function resolveStateMapping(): array + { + $reflection = new ReflectionClass(static::class); + $stateConfig = static::config(); + + $fileName = (string) $reflection->getFileName(); + $files = @scandir(dirname($fileName)); + + if ($files === false) { + return []; + } + + $namespace = $reflection->getNamespaceName(); + $resolvedStates = []; + + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + + $fileInfo = pathinfo($file); + $extension = $fileInfo['extension'] ?? null; + $className = $fileInfo['filename'] ?? null; + + if ($extension !== 'php' || ! is_string($className)) { + continue; + } + + $stateClass = $namespace . '\\' . $className; + + if (! class_exists($stateClass)) { + continue; + } + + if (! is_subclass_of($stateClass, $stateConfig->baseStateClass)) { + continue; + } + + $resolvedStates[$stateClass::getMorphClass()] = $stateClass; + } + + foreach ($stateConfig->registeredStates as $stateClass) { + $resolvedStates[$stateClass::getMorphClass()] = $stateClass; + } + + return $resolvedStates; + } +} diff --git a/src/States/StateCaster.php b/src/States/StateCaster.php new file mode 100644 index 00000000..35adab5c --- /dev/null +++ b/src/States/StateCaster.php @@ -0,0 +1,76 @@ +baseStateClass = $baseStateClass; + } + + public function get($model, string $key, $value, array $attributes) + { + if ($value === null) { + return null; + } + + $stateClassName = $this->baseStateClass::resolveStateClass($value); + + if (! is_string($stateClassName) || ! is_subclass_of($stateClassName, $this->baseStateClass)) { + throw new InvalidArgumentException("Unknown state `{$value}` for `{$this->baseStateClass}`."); + } + + $state = new $stateClassName($model); + $state->setField($key); + + return $state; + } + + public function set($model, string $key, $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + if ($value instanceof $this->baseStateClass) { + $value->setField($key); + + return $value::getMorphClass(); + } + + if (is_string($value) && is_subclass_of($value, $this->baseStateClass)) { + return $value::getMorphClass(); + } + + $mapping = $this->getStateMapping(); + + if (! $mapping->has($value)) { + throw new InvalidArgumentException("Unknown state `{$value}` for `{$this->baseStateClass}`."); + } + + /** @var string $stateClass */ + $stateClass = $mapping->get($value); + + return $stateClass::getMorphClass(); + } + + public function serialize($model, string $key, $value, array $attributes) + { + return $value instanceof State ? $value->jsonSerialize() : $value; + } + + private function getStateMapping(): Collection + { + return $this->baseStateClass::getStateMapping(); + } +} diff --git a/src/States/StateConfig.php b/src/States/StateConfig.php new file mode 100644 index 00000000..8f62f7c8 --- /dev/null +++ b/src/States/StateConfig.php @@ -0,0 +1,149 @@ + + */ + public array $allowedTransitions = []; + + /** + * @var string[] + */ + public array $registeredStates = []; + + public bool $shouldIgnoreSameState = false; + + public function __construct(string $baseStateClass) + { + $this->baseStateClass = $baseStateClass; + } + + public function default(string $defaultStateClass): self + { + $this->defaultStateClass = $defaultStateClass; + + return $this; + } + + public function ignoreSameState(): self + { + $this->shouldIgnoreSameState = true; + + return $this; + } + + public function allowTransition($from, string $to, ?string $transition = null): self + { + if (is_array($from)) { + foreach ($from as $fromState) { + $this->allowTransition($fromState, $to, $transition); + } + + return $this; + } + + if (! is_subclass_of($from, $this->baseStateClass)) { + throw new InvalidArgumentException("{$from} does not extend {$this->baseStateClass}."); + } + + if (! is_subclass_of($to, $this->baseStateClass)) { + throw new InvalidArgumentException("{$to} does not extend {$this->baseStateClass}."); + } + + $this->allowedTransitions[$this->createTransitionKey($from, $to)] = $transition; + + return $this; + } + + /** + * @param array> $transitions + */ + public function allowTransitions(array $transitions): self + { + foreach ($transitions as $transition) { + $this->allowTransition($transition[0], $transition[1], $transition[2] ?? null); + } + + return $this; + } + + public function isTransitionAllowed(string $fromMorphClass, string $toMorphClass): bool + { + if ($this->shouldIgnoreSameState && $fromMorphClass === $toMorphClass) { + return true; + } + + return $this->stateMachine() + ->canTransition($fromMorphClass, $toMorphClass); + } + + /** + * @return string[] + */ + public function transitionableStates(string $fromMorphClass): array + { + return $this->stateMachine() + ->transitionableStates($fromMorphClass); + } + + /** + * @param string|string[] $stateClass + */ + public function registerState($stateClass): self + { + if (is_array($stateClass)) { + foreach ($stateClass as $state) { + $this->registerState($state); + } + + return $this; + } + + if (! is_subclass_of($stateClass, $this->baseStateClass)) { + throw new InvalidArgumentException("{$stateClass} does not extend {$this->baseStateClass}."); + } + + $this->registeredStates[] = $stateClass; + + return $this; + } + + private function createTransitionKey(string $from, string $to): string + { + if (is_subclass_of($from, $this->baseStateClass)) { + $from = $from::getMorphClass(); + } + + if (is_subclass_of($to, $this->baseStateClass)) { + $to = $to::getMorphClass(); + } + + return "{$from}->{$to}"; + } + + private function stateMachine(): StateMachine + { + $stateMachine = new StateMachine(); + + foreach (array_keys($this->allowedTransitions) as $allowedTransition) { + [$from, $to] = explode('->', $allowedTransition, 2); + + $stateMachine->addState($from); + $stateMachine->addState($to); + $stateMachine->addTransition($allowedTransition, $from, $to); + } + + return $stateMachine; + } +} diff --git a/src/States/StateMachine.php b/src/States/StateMachine.php new file mode 100644 index 00000000..5c261ff7 --- /dev/null +++ b/src/States/StateMachine.php @@ -0,0 +1,99 @@ + + */ + private array $transitions = []; + + private ?string $currentState = null; + + public function addState(string $state): void + { + if (! in_array($state, $this->states, true)) { + $this->states[] = $state; + } + } + + public function addTransition(string $action, string $fromState, string $toState): void + { + $this->transitions[$action] = [ + 'from' => $fromState, + 'to' => $toState, + ]; + } + + public function initialize(?string $initialState = null): void + { + if ($initialState !== null) { + $this->currentState = $initialState; + + return; + } + + if (count($this->states) > 0) { + $this->currentState = $this->states[0]; + } + } + + public function getCurrentState(): ?string + { + return $this->currentState; + } + + public function canApply(string $action): bool + { + return isset($this->transitions[$action]) + && $this->transitions[$action]['from'] === $this->currentState; + } + + public function apply(string $action): void + { + if (! $this->canApply($action)) { + throw new Exception('Transition not found.'); + } + + $this->currentState = $this->transitions[$action]['to']; + } + + public function canTransition(string $fromState, string $toState): bool + { + foreach ($this->transitions as $transition) { + if ($transition['from'] === $fromState && $transition['to'] === $toState) { + return true; + } + } + + return false; + } + + /** + * @return string[] + */ + public function transitionableStates(string $fromState): array + { + $states = []; + + foreach ($this->transitions as $transition) { + if ($transition['from'] !== $fromState) { + continue; + } + + $states[] = $transition['to']; + } + + return $states; + } +} diff --git a/src/States/WorkflowStatus.php b/src/States/WorkflowStatus.php index 7ac117fb..dcca98f3 100644 --- a/src/States/WorkflowStatus.php +++ b/src/States/WorkflowStatus.php @@ -4,9 +4,6 @@ namespace Workflow\States; -use Spatie\ModelStates\State; -use Spatie\ModelStates\StateConfig; - abstract class WorkflowStatus extends State { public static function config(): StateConfig diff --git a/src/Workflow.php b/src/Workflow.php index 5ebecc0d..60baf4dd 100644 --- a/src/Workflow.php +++ b/src/Workflow.php @@ -19,6 +19,7 @@ use React\Promise\PromiseInterface; use Throwable; use Workflow\Events\WorkflowCompleted; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Middleware\WithoutOverlappingMiddleware; use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; @@ -158,7 +159,7 @@ public function failed(Throwable $throwable): void try { $this->storedWorkflow->toWorkflow() ->fail($throwable); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { return; } } @@ -175,7 +176,7 @@ public function handle(): void if (! $this->replaying) { $this->storedWorkflow->status->transitionTo(WorkflowRunningStatus::class); } - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { if ($this->storedWorkflow->toWorkflow()->running()) { $this->release(); } diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 8e43143c..a8b401c9 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -11,6 +11,7 @@ use SplFileObject; use Workflow\Events\WorkflowFailed; use Workflow\Events\WorkflowStarted; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; use Workflow\States\WorkflowCompletedStatus; @@ -290,7 +291,7 @@ public function fail($exception): void try { $parentWorkflow->toWorkflow() ->fail($exception); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { return; } }); @@ -379,7 +380,7 @@ private function dispatch(): void try { $this->storedWorkflow->status->transitionTo(WorkflowPendingStatus::class); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound $exception) { + } catch (TransitionNotFound $exception) { $this->storedWorkflow->refresh(); if ($this->status() !== WorkflowPendingStatus::class) { diff --git a/tests/Fixtures/StateMachine.php b/tests/Fixtures/StateMachine.php deleted file mode 100644 index cd735a37..00000000 --- a/tests/Fixtures/StateMachine.php +++ /dev/null @@ -1,50 +0,0 @@ -states[] = $state; - } - - public function addTransition($action, $fromState, $toState) - { - $this->transitions[$action] = [ - 'from' => $fromState, - 'to' => $toState, - ]; - } - - public function initialize() - { - if (count($this->states) > 0) { - $this->currentState = $this->states[0]; - } - } - - public function getCurrentState() - { - return $this->currentState; - } - - public function apply($action) - { - if (isset($this->transitions[$action]) && $this->transitions[$action]['from'] === $this->currentState) { - $this->currentState = $this->transitions[$action]['to']; - } else { - throw new Exception('Transition not found.'); - } - } -} diff --git a/tests/Fixtures/TestStateMachineWorkflow.php b/tests/Fixtures/TestStateMachineWorkflow.php index 89a8db02..e170e352 100644 --- a/tests/Fixtures/TestStateMachineWorkflow.php +++ b/tests/Fixtures/TestStateMachineWorkflow.php @@ -7,6 +7,7 @@ use function Workflow\await; use Workflow\Models\StoredWorkflow; use Workflow\SignalMethod; +use Workflow\States\StateMachine; use Workflow\Workflow; class TestStateMachineWorkflow extends Workflow diff --git a/tests/Unit/ChildWorkflowStubTest.php b/tests/Unit/ChildWorkflowStubTest.php index 6f5a3f9c..3aaf74d3 100644 --- a/tests/Unit/ChildWorkflowStubTest.php +++ b/tests/Unit/ChildWorkflowStubTest.php @@ -5,11 +5,11 @@ namespace Tests\Unit; use Mockery; -use Spatie\ModelStates\Exceptions\TransitionNotFound; use Tests\Fixtures\TestChildWorkflow; use Tests\Fixtures\TestParentWorkflow; use Tests\TestCase; use Workflow\ChildWorkflowStub; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; use Workflow\States\WorkflowPendingStatus; diff --git a/tests/Unit/States/HasStatesTest.php b/tests/Unit/States/HasStatesTest.php new file mode 100644 index 00000000..f1b51868 --- /dev/null +++ b/tests/Unit/States/HasStatesTest.php @@ -0,0 +1,126 @@ +assertSame(WorkflowCreatedStatus::$name, $defaults->get('status')); + $this->assertNull($defaults->get('aux_state')); + $this->assertSame(WorkflowCreatedStatus::$name, HasStatesCoverageModel::getDefaultStateFor('status')); + $this->assertNull(HasStatesCoverageModel::getDefaultStateFor('missing')); + + $statusStates = HasStatesCoverageModel::getStatesFor('status')->all(); + $this->assertContains(WorkflowCreatedStatus::$name, $statusStates); + $this->assertContains(WorkflowRunningStatus::$name, $statusStates); + $this->assertSame([], HasStatesCoverageModel::getStatesFor('missing')->all()); + + $model = HasStatesCoverageModel::make([ + 'class' => 'default-state-test', + ]); + + $this->assertInstanceOf(WorkflowCreatedStatus::class, $model->status); + $this->assertNull($model->getAttribute('aux_state')); + } + + public function testQueryScopesUseStateMappings(): void + { + HasStatesCoverageModel::create([ + 'class' => 'scope-query-test', + 'status' => WorkflowCreatedStatus::class, + ]); + HasStatesCoverageModel::create([ + 'class' => 'scope-query-test', + 'status' => WorkflowRunningStatus::class, + ]); + HasStatesCoverageModel::create([ + 'class' => 'scope-query-test', + 'status' => WorkflowFailedStatus::class, + ]); + + $this->assertSame( + 1, + HasStatesCoverageModel::query() + ->where('class', 'scope-query-test') + ->whereState('status', WorkflowRunningStatus::$name) + ->count() + ); + $this->assertSame( + 2, + HasStatesCoverageModel::query() + ->where('class', 'scope-query-test') + ->whereNotState('status', WorkflowRunningStatus::class) + ->count() + ); + $this->assertSame( + 2, + HasStatesCoverageModel::query() + ->where('class', 'scope-query-test') + ->whereState('status', WorkflowCreatedStatus::$name) + ->orWhereState('status', WorkflowRunningStatus::class) + ->count() + ); + $this->assertSame( + 3, + HasStatesCoverageModel::query() + ->where('class', 'scope-query-test') + ->whereState('status', WorkflowCreatedStatus::$name) + ->orWhereNotState('status', WorkflowCreatedStatus::class) + ->count() + ); + + $sql = HasStatesCoverageModel::query() + ->whereState('unknown_state', WorkflowRunningStatus::class) + ->toSql(); + + $this->assertStringContainsString('0 = 1', $sql); + } +} + +abstract class HasStatesCoverageAuxState extends State +{ + public static function config(): StateConfig + { + return parent::config() + ->allowTransition(HasStatesCoverageAuxActiveState::class, HasStatesCoverageAuxPausedState::class); + } +} + +final class HasStatesCoverageAuxActiveState extends HasStatesCoverageAuxState +{ + public static string $name = 'aux-active'; +} + +final class HasStatesCoverageAuxPausedState extends HasStatesCoverageAuxState +{ + public static string $name = 'aux-paused'; +} + +final class HasStatesCoverageModel extends Model +{ + use HasStates; + + protected $table = 'workflows'; + + protected $guarded = []; + + protected $casts = [ + 'status' => WorkflowStatus::class, + 'aux_state' => HasStatesCoverageAuxState::class, + ]; +} diff --git a/tests/Unit/States/StateInfrastructureTest.php b/tests/Unit/States/StateInfrastructureTest.php new file mode 100644 index 00000000..1a1ea703 --- /dev/null +++ b/tests/Unit/States/StateInfrastructureTest.php @@ -0,0 +1,418 @@ +assertSame('from-state', $exception->getFrom()); + $this->assertSame('to-state', $exception->getTo()); + $this->assertSame(StateInfraModel::class, $exception->getModelClass()); + } + + public function testStateMachineInitializesAppliesAndListsTransitionableStates(): void + { + $stateMachine = new StateMachine(); + $stateMachine->addState('draft'); + $stateMachine->addState('published'); + $stateMachine->addTransition('publish', 'draft', 'published'); + + $stateMachine->initialize(); + $this->assertSame('draft', $stateMachine->getCurrentState()); + $this->assertTrue($stateMachine->canApply('publish')); + + $stateMachine->apply('publish'); + $this->assertSame('published', $stateMachine->getCurrentState()); + $this->assertSame(['published'], $stateMachine->transitionableStates('draft')); + $this->assertSame([], $stateMachine->transitionableStates('missing')); + + $stateMachine->initialize('draft'); + $this->assertSame('draft', $stateMachine->getCurrentState()); + $this->assertFalse($stateMachine->canApply('publish-missing')); + } + + public function testStateMachineThrowsWhenTransitionCannotBeApplied(): void + { + $stateMachine = new StateMachine(); + $stateMachine->addState('draft'); + $stateMachine->initialize('draft'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Transition not found.'); + + $stateMachine->apply('publish'); + } + + public function testStateConfigSupportsArraysAndTransitionLookup(): void + { + $config = new StateConfig(StateInfraState::class); + + $this->assertSame($config, $config->ignoreSameState()); + $this->assertSame( + $config, + $config->allowTransition( + [StateInfraInitialState::class, StateInfraNextState::class], + StateInfraTerminalState::class + ) + ); + $this->assertSame( + $config, + $config->allowTransitions([ + [StateInfraTerminalState::class, StateInfraNextState::class], + [StateInfraNextState::class, StateInfraInitialState::class], + ]) + ); + $this->assertSame( + $config, + $config->registerState([StateInfraInitialState::class, StateInfraNextState::class]) + ); + + $this->assertTrue($config->isTransitionAllowed(StateInfraInitialState::$name, StateInfraInitialState::$name)); + $this->assertContains( + StateInfraTerminalState::$name, + $config->transitionableStates(StateInfraInitialState::$name) + ); + $this->assertContains(StateInfraInitialState::class, $config->registeredStates); + $this->assertContains(StateInfraNextState::class, $config->registeredStates); + } + + public function testStateConfigRejectsInvalidTransitionFromState(): void + { + $config = new StateConfig(StateInfraState::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('stdClass does not extend'); + + $config->allowTransition(\stdClass::class, StateInfraNextState::class); + } + + public function testStateConfigRejectsInvalidTransitionToState(): void + { + $config = new StateConfig(StateInfraState::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('stdClass does not extend'); + + $config->allowTransition(StateInfraInitialState::class, \stdClass::class); + } + + public function testStateConfigRejectsInvalidRegisteredState(): void + { + $config = new StateConfig(StateInfraState::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('stdClass does not extend'); + + $config->registerState(\stdClass::class); + } + + public function testStateCasterCastsSetAndSerializesValues(): void + { + $stateCaster = new StateCaster(StateInfraState::class); + $model = new StateInfraModel(); + + $this->assertNull($stateCaster->get($model, 'status', null, [])); + + $state = $stateCaster->get($model, 'status', StateInfraInitialState::class, []); + $this->assertInstanceOf(StateInfraInitialState::class, $state); + $this->assertSame('status', $state->getField()); + + $this->assertNull($stateCaster->set($model, 'status', null, [])); + $this->assertSame(StateInfraInitialState::$name, $stateCaster->set($model, 'status', $state, [])); + $this->assertSame( + StateInfraNextState::$name, + $stateCaster->set($model, 'status', StateInfraNextState::class, []) + ); + $this->assertSame(StateInfraInitialState::$name, $stateCaster->serialize($model, 'status', $state, [])); + $this->assertSame('raw', $stateCaster->serialize($model, 'status', 'raw', [])); + } + + public function testStateCasterRejectsUnknownValues(): void + { + $stateCaster = new StateCaster(StateInfraState::class); + $model = new StateInfraModel(); + + try { + $stateCaster->get($model, 'status', 'unknown-state', []); + $this->fail('Expected invalid state exception to be thrown for get().'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString('Unknown state `unknown-state`', $exception->getMessage()); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown state `unknown-state`'); + $stateCaster->set($model, 'status', 'unknown-state', []); + } + + public function testStateUtilityMethods(): void + { + $model = new StateInfraModel(); + $state = new StateInfraInitialState($model); + $state->setField('status'); + + $this->assertSame(StateInfraInitialState::$name, (string) $state); + $this->assertSame($model, $state->getModel()); + $this->assertSame('status', $state->getField()); + $this->assertSame(StateInfraInitialState::$name, $state->jsonSerialize()); + $this->assertInstanceOf(StateCaster::class, StateInfraState::castUsing([])); + $this->assertSame(StateInfraInitialState::class, StateInfraState::resolveStateClass($state)); + $this->assertSame( + StateInfraInitialState::class, + StateInfraState::resolveStateClass(StateInfraInitialState::class) + ); + $this->assertSame( + StateInfraInitialState::$name, + StateInfraState::resolveStateClass(StateInfraInitialState::$name) + ); + $this->assertNull(StateInfraState::resolveStateClass(null)); + $this->assertNull(StateInfraState::resolveStateClass(123)); + $this->assertSame('custom-state', StateInfraState::resolveStateClass('custom-state')); + $this->assertSame( + WorkflowRunningStatus::class, + WorkflowStatus::resolveStateClass(WorkflowRunningStatus::$name) + ); + $this->assertNull(StateInfraState::all()->get(StateInfraInitialState::$name)); + $this->assertInstanceOf( + StateInfraInitialState::class, + StateInfraState::make(StateInfraInitialState::class, $model) + ); + } + + public function testStateTransitionsAndEqualityChecks(): void + { + $model = new StateInfraModel(); + $state = new StateInfraInitialState($model); + $state->setField('status'); + $model->status = $state; + + $this->assertTrue($state->canTransitionTo(StateInfraNextState::class)); + $this->assertFalse($state->canTransitionTo(StateInfraTerminalState::class)); + $this->assertTrue($state->equals(StateInfraInitialState::class)); + $this->assertTrue($state->equals(new StateInfraInitialState($model))); + $this->assertFalse($state->equals(StateInfraNextState::class)); + $this->assertFalse($state->equals(new StateInfraNextState($model))); + + $result = $state->transitionTo(StateInfraNextState::class); + $this->assertSame($model, $result); + $this->assertTrue($model->saved); + $this->assertInstanceOf(StateInfraNextState::class, $model->status); + $this->assertSame('status', $model->status->getField()); + + $model->saved = false; + $nextState = $model->status; + $nextState->transitionTo(new StateInfraTerminalState($model)); + + $this->assertTrue($model->saved); + $this->assertInstanceOf(StateInfraTerminalState::class, $model->status); + + try { + $model->status->transitionTo(StateInfraInitialState::class); + $this->fail('Expected transition to fail.'); + } catch (TransitionNotFound $exception) { + $this->assertSame(StateInfraTerminalState::$name, $exception->getFrom()); + $this->assertSame(StateInfraInitialState::$name, $exception->getTo()); + $this->assertSame(StateInfraModel::class, $exception->getModelClass()); + } + } + + public function testStateRejectsUnknownTransitionTarget(): void + { + $model = new StateInfraModel(); + $state = new StateInfraInitialState($model); + $state->setField('status'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('does not extend'); + + $state->canTransitionTo('unknown-transition'); + } + + public function testStateMakeRejectsUnknownState(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('does not extend'); + + StateInfraState::make('unknown-state', new StateInfraModel()); + } + + public function testStateMappingHandlesEvalAndMissingDirectoryScenarios(): void + { + $suffix = str_replace('.', '', uniqid('', true)); + + $evalClass = 'StateInfraEval' . $suffix; + eval('namespace ' . __NAMESPACE__ . '; abstract class ' . $evalClass . ' extends \\Workflow\\States\\State {}'); + $evalClassName = __NAMESPACE__ . '\\' . $evalClass; + $this->assertSame([], $evalClassName::getStateMapping()->all()); + + $namespace = __NAMESPACE__ . '\\Deleted' . $suffix; + $directory = $this->createTempDirectory('state-deleted-'); + $class = 'DeletedState' . $suffix; + $file = $directory . '/' . $class . '.php'; + + file_put_contents( + $file, + "assertSame([], $className::getStateMapping()->all()); + } + + public function testStateMappingSkipsUnsupportedFilesAndIncludesRegisteredStates(): void + { + $suffix = str_replace('.', '', uniqid('', true)); + $namespace = __NAMESPACE__ . '\\Scan' . $suffix; + $directory = $this->createTempDirectory('state-scan-'); + + $baseClass = 'ScanBase' . $suffix; + $alphaClass = 'ScanAlpha' . $suffix; + $registeredClass = 'ScanRegistered' . $suffix; + $irrelevantClass = 'ScanIrrelevant' . $suffix; + + file_put_contents( + $directory . '/' . $baseClass . '.php', + "registerState({$registeredClass}::class);\n }\n}\n" + ); + file_put_contents( + $directory . '/' . $alphaClass . '.php', + "all(); + + $this->assertSame($alphaClassName, $mapping['scan-alpha-' . $suffix] ?? null); + $this->assertSame($registeredClassName, $mapping['scan-registered-' . $suffix] ?? null); + + $this->removeDirectory($directory); + } + + private function createTempDirectory(string $prefix): string + { + $directory = sys_get_temp_dir() . '/' . $prefix . str_replace('.', '', uniqid('', true)); + + if (! mkdir($directory, 0777, true) && ! is_dir($directory)) { + throw new \RuntimeException('Could not create temporary directory: ' . $directory); + } + + return $directory; + } + + private function removeDirectory(string $directory): void + { + if (! is_dir($directory)) { + return; + } + + $files = scandir($directory); + + if ($files === false) { + return; + } + + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + + $path = $directory . '/' . $file; + + if (is_file($path)) { + unlink($path); + } + } + + rmdir($directory); + } +} + +final class StateInfraModel +{ + public $status = null; + + public bool $saved = false; + + public function save(): void + { + $this->saved = true; + } +} + +abstract class StateInfraState extends State +{ + public static function config(): StateConfig + { + return parent::config() + ->default(StateInfraInitialState::class) + ->allowTransition(StateInfraInitialState::class, StateInfraNextState::class) + ->allowTransition(StateInfraNextState::class, StateInfraTerminalState::class) + ->registerState(StateInfraRegisteredState::class); + } +} + +final class StateInfraInitialState extends StateInfraState +{ + public static string $name = 'infra-initial'; +} + +final class StateInfraNextState extends StateInfraState +{ + public static string $name = 'infra-next'; +} + +final class StateInfraTerminalState extends StateInfraState +{ + public static string $name = 'infra-terminal'; +} + +final class StateInfraRegisteredState extends StateInfraState +{ + public static string $name = 'infra-registered'; +} + +abstract class StateInfraOtherState extends State +{ +} + +final class StateInfraOtherInitialState extends StateInfraOtherState +{ + public static string $name = 'infra-other-initial'; +}