diff --git a/README.md b/README.md index 47a00fe..a39d90d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,17 @@ composer require cmatosbc/desired-patterns ## Patterns Implemented +Quick Links: +- [1. Singleton Pattern](#1-singleton-pattern) +- [2. Multiton Pattern](#2-multiton-pattern) +- [3. Command Pattern](#3-command-pattern) +- [4. Chain of Responsibility Pattern](#4-chain-of-responsibility-pattern) +- [5. Registry Pattern](#5-registry-pattern) +- [6. Service Locator Pattern](#6-service-locator-pattern) +- [7. Specification Pattern](#7-specification-pattern) +- [8. Strategy Pattern](#8-strategy-pattern) +- [9. State Pattern](#9-state-pattern) + ### 1. Singleton Pattern The Singleton pattern ensures a class has only one instance and provides a global point of access to it. Our implementation uses a trait to make it reusable. @@ -267,7 +278,7 @@ if ($canAccessContent->isSatisfiedBy($user)) { } ``` -## 8. Strategy Pattern +### 8. Strategy Pattern The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it. @@ -443,6 +454,109 @@ $cryptoPayment = $context->executeStrategy([ ]); ``` +### 9. State Pattern +The State pattern allows an object to alter its behavior when its internal state changes. The object will appear to change its class. Our implementation provides a flexible and type-safe way to handle state transitions with context validation. + +```php +use DesiredPatterns\State\StateMachineTrait; +use DesiredPatterns\State\AbstractState; + +// Define your states +class PendingState extends AbstractState +{ + public function getName(): string + { + return 'pending'; + } + + protected array $allowedTransitions = ['processing', 'cancelled']; + + protected array $validationRules = [ + 'order_id' => 'required', + 'amount' => 'type:double' + ]; + + public function handle(array $context): array + { + return [ + 'status' => 'pending', + 'message' => 'Order is being validated', + 'order_id' => $context['order_id'] + ]; + } +} + +// Create your state machine +class Order +{ + use StateMachineTrait; + + public function __construct(string $orderId) + { + // Initialize states + $this->addState(new PendingState(), true) + ->addState(new ProcessingState()) + ->addState(new ShippedState()); + + // Set initial context + $this->updateContext([ + 'order_id' => $orderId, + 'created_at' => date('Y-m-d H:i:s') + ]); + } + + public function process(array $paymentDetails): array + { + $this->transitionTo('processing', $paymentDetails); + return $this->getCurrentState()->handle($this->getContext()); + } +} + +// Usage +$order = new Order('ORD-123'); + +try { + $result = $order->process([ + 'payment_id' => 'PAY-456', + 'amount' => 99.99 + ]); + echo $result['message']; // "Payment verified, preparing shipment" +} catch (StateException $e) { + echo "Error: " . $e->getMessage(); +} +``` + +#### Real-World Example: Order Processing System + +The State pattern is perfect for managing complex workflows like order processing. Each state encapsulates its own rules and behaviors: + +1. **States**: + - `PendingState`: Initial state, validates order details + - `ProcessingState`: Handles payment verification + - `ShippedState`: Manages shipping details + - `DeliveredState`: Handles delivery confirmation + - `CancelledState`: Manages order cancellation + +2. **Features**: + - Context validation per state + - Type-safe state transitions + - State history tracking + - Fluent interface for state machine setup + +3. **Benefits**: + - Clean separation of concerns + - Easy to add new states + - Type-safe state transitions + - Automatic context validation + - Comprehensive state history + +4. **Use Cases**: + - Order Processing Systems + - Document Workflow Management + - Game State Management + - Payment Processing + - Task Management Systems + ## Testing Run the test suite using PHPUnit : diff --git a/examples/State/Order/Order.php b/examples/State/Order/Order.php new file mode 100644 index 0000000..7b72633 --- /dev/null +++ b/examples/State/Order/Order.php @@ -0,0 +1,68 @@ +orderId = $orderId; + + // Initialize all possible states + $this->addState(new PendingState(), true) // Initial state + ->addState(new ProcessingState()) + ->addState(new ShippedState()) + ->addState(new DeliveredState()) + ->addState(new CancelledState()); + + // Set initial context + $this->updateContext([ + 'order_id' => $orderId, + 'created_at' => date('Y-m-d H:i:s') + ]); + } + + public function getOrderId(): string + { + return $this->orderId; + } + + public function process(array $paymentDetails): array + { + $this->transitionTo('processing', $paymentDetails); + return $this->getCurrentState()->handle($this->getContext()); + } + + public function ship(array $shippingDetails): array + { + $this->transitionTo('shipped', $shippingDetails); + return $this->getCurrentState()->handle($this->getContext()); + } + + public function deliver(array $deliveryDetails): array + { + $this->transitionTo('delivered', $deliveryDetails); + return $this->getCurrentState()->handle($this->getContext()); + } + + public function cancel(string $reason): array + { + $this->transitionTo('cancelled', ['cancellation_reason' => $reason]); + return $this->getCurrentState()->handle($this->getContext()); + } +} diff --git a/examples/State/Order/States/CancelledState.php b/examples/State/Order/States/CancelledState.php new file mode 100644 index 0000000..156dd10 --- /dev/null +++ b/examples/State/Order/States/CancelledState.php @@ -0,0 +1,34 @@ + 'required', + 'cancellation_reason' => 'required' + ]; + + public function handle(array $context): array + { + // Process refund and cleanup + return [ + 'status' => 'cancelled', + 'message' => 'Order has been cancelled', + 'order_id' => $context['order_id'], + 'reason' => $context['cancellation_reason'], + 'timestamp' => date('Y-m-d H:i:s') + ]; + } +} diff --git a/examples/State/Order/States/DeliveredState.php b/examples/State/Order/States/DeliveredState.php new file mode 100644 index 0000000..6de91dc --- /dev/null +++ b/examples/State/Order/States/DeliveredState.php @@ -0,0 +1,36 @@ + 'required', + 'delivery_date' => 'required', + 'signature' => 'required' + ]; + + public function handle(array $context): array + { + // Complete the order and trigger post-delivery actions + return [ + 'status' => 'delivered', + 'message' => 'Order has been delivered', + 'order_id' => $context['order_id'], + 'delivery_date' => $context['delivery_date'], + 'signature' => $context['signature'], + 'timestamp' => date('Y-m-d H:i:s') + ]; + } +} diff --git a/examples/State/Order/States/PendingState.php b/examples/State/Order/States/PendingState.php new file mode 100644 index 0000000..dab5564 --- /dev/null +++ b/examples/State/Order/States/PendingState.php @@ -0,0 +1,34 @@ + 'required', + 'total_amount' => 'type:double', + 'items' => 'type:array' + ]; + + public function handle(array $context): array + { + // Validate order and check inventory + return [ + 'status' => 'pending', + 'message' => 'Order is being validated', + 'order_id' => $context['order_id'], + 'timestamp' => date('Y-m-d H:i:s') + ]; + } +} diff --git a/examples/State/Order/States/ProcessingState.php b/examples/State/Order/States/ProcessingState.php new file mode 100644 index 0000000..162c413 --- /dev/null +++ b/examples/State/Order/States/ProcessingState.php @@ -0,0 +1,35 @@ + 'required', + 'payment_id' => 'required', + 'payment_status' => 'required' + ]; + + public function handle(array $context): array + { + // Process payment and prepare for shipping + return [ + 'status' => 'processing', + 'message' => 'Payment verified, preparing shipment', + 'order_id' => $context['order_id'], + 'payment_id' => $context['payment_id'], + 'timestamp' => date('Y-m-d H:i:s') + ]; + } +} diff --git a/examples/State/Order/States/ShippedState.php b/examples/State/Order/States/ShippedState.php new file mode 100644 index 0000000..ea5986d --- /dev/null +++ b/examples/State/Order/States/ShippedState.php @@ -0,0 +1,36 @@ + 'required', + 'tracking_number' => 'required', + 'shipping_address' => 'required' + ]; + + public function handle(array $context): array + { + // Generate shipping label and notify courier + return [ + 'status' => 'shipped', + 'message' => 'Order has been shipped', + 'order_id' => $context['order_id'], + 'tracking_number' => $context['tracking_number'], + 'shipping_address' => $context['shipping_address'], + 'timestamp' => date('Y-m-d H:i:s') + ]; + } +} diff --git a/examples/State/Order/example.php b/examples/State/Order/example.php new file mode 100644 index 0000000..b946005 --- /dev/null +++ b/examples/State/Order/example.php @@ -0,0 +1,59 @@ +process([ + 'payment_id' => 'PAY-456', + 'payment_status' => 'completed', + 'amount' => 99.99 + ]); + echo "Order processed: " . $result['message'] . "\n"; + + // Ship the order + $result = $order->ship([ + 'tracking_number' => 'TRK-789', + 'shipping_address' => '123 Main St, City, Country', + 'carrier' => 'FedEx' + ]); + echo "Order shipped: " . $result['message'] . "\n"; + + // Mark as delivered + $result = $order->deliver([ + 'delivery_date' => date('Y-m-d'), + 'signature' => 'John Doe', + 'notes' => 'Left at front door' + ]); + echo "Order delivered: " . $result['message'] . "\n"; + + // Get the complete state history + $history = $order->getStateHistory(); + echo "\nOrder State History:\n"; + foreach ($history as $transition) { + echo sprintf( + "From %s to %s at %s\n", + $transition['from'] ?? 'start', + $transition['to'], + $transition['timestamp']->format('Y-m-d H:i:s') + ); + } + +} catch (\Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} + +// Example of order cancellation +try { + $order2 = new Order('ORD-124'); + $result = $order2->cancel('Customer requested cancellation'); + echo "\nOrder 2: " . $result['message'] . "\n"; + echo "\nOrder 2 final state: " . $order2->getCurrentState()->getName() . "\n"; +} catch (\Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/src/Contracts/StateInterface.php b/src/Contracts/StateInterface.php new file mode 100644 index 0000000..875487e --- /dev/null +++ b/src/Contracts/StateInterface.php @@ -0,0 +1,51 @@ + List of allowed state names + */ + public function getAllowedTransitions(): array; + + /** + * Get validation rules for this state + * + * @return array Validation rules + */ + public function getValidationRules(): array; +} diff --git a/src/State/AbstractState.php b/src/State/AbstractState.php new file mode 100644 index 0000000..ff9a53a --- /dev/null +++ b/src/State/AbstractState.php @@ -0,0 +1,32 @@ +allowedTransitions; + } + + public function canTransitionTo(string $stateName, array $context): bool + { + return in_array($stateName, $this->allowedTransitions); + } + + public function getValidationRules(): array + { + return $this->validationRules; + } + + abstract public function getName(): string; + abstract public function handle(array $context): array; +} diff --git a/src/State/StateException.php b/src/State/StateException.php new file mode 100644 index 0000000..2b8c5f5 --- /dev/null +++ b/src/State/StateException.php @@ -0,0 +1,5 @@ +getName(); + $this->states[$name] = $state; + + if ($isInitial || $this->currentState === null) { + $this->currentState = $name; + } + + return $this; + } + + /** + * Transition to a new state if possible + * + * @param string $stateName The target state name + * @param array $context Additional context for the transition + * @return bool Whether the transition was successful + * @throws StateException If transition is not allowed + */ + public function transitionTo(string $stateName, array $context = []): bool + { + if (!isset($this->states[$stateName])) { + throw new StateException("Invalid state: $stateName"); + } + + $currentState = $this->states[$this->currentState]; + + if (!$currentState->canTransitionTo($stateName, $this->context)) { + throw new StateException( + "Cannot transition from {$this->currentState} to $stateName" + ); + } + + // Update context + $this->context = array_merge($this->context, $context); + + // Validate new context against target state rules + $targetState = $this->states[$stateName]; + if (!$this->validateContext($targetState->getValidationRules())) { + throw new StateException("Invalid context for state $stateName"); + } + + // Record state change in history + $this->stateHistory[] = [ + 'from' => $this->currentState, + 'to' => $stateName, + 'timestamp' => new \DateTime(), + 'context' => $this->context + ]; + + // Update current state + $this->currentState = $stateName; + + return true; + } + + /** + * Get the current state object + * + * @return StateInterface + */ + public function getCurrentState(): StateInterface + { + return $this->states[$this->currentState]; + } + + /** + * Get available transitions from current state + * + * @return array + */ + public function getAvailableTransitions(): array + { + return $this->getCurrentState()->getAllowedTransitions(); + } + + /** + * Get the complete state history + * + * @return array + */ + public function getStateHistory(): array + { + return $this->stateHistory; + } + + /** + * Update the context + * + * @param array $context New context data + * @return self + */ + public function updateContext(array $context): self + { + $this->context = array_merge($this->context, $context); + + // Validate against current state's rules + if ($this->currentState !== null) { + $currentState = $this->getCurrentState(); + if (!$this->validateContext($currentState->getValidationRules())) { + throw new StateException('Invalid context for current state'); + } + } + + return $this; + } + + /** + * Get current context + * + * @return array + */ + public function getContext(): array + { + return $this->context; + } + + /** + * Validate context against rules + * + * @param array $rules Validation rules + * @return bool + */ + private function validateContext(array $rules): bool + { + foreach ($rules as $key => $rule) { + // Skip validation if no rule is set + if (empty($rule)) { + continue; + } + + // Check if field exists when required + if ($rule === 'required') { + if (!isset($this->context[$key]) || empty($this->context[$key])) { + return false; + } + continue; + } + + // Skip other validations if field is not set and not required + if (!isset($this->context[$key])) { + continue; + } + + $value = $this->context[$key]; + + // Type validation + if (str_starts_with($rule, 'type:')) { + $expectedType = substr($rule, 5); + $actualType = gettype($value); + + // Special handling for 'double' type + if ($expectedType === 'double' && is_numeric($value)) { + continue; + } + + if ($actualType !== $expectedType) { + return false; + } + } + } + + return true; + } +} diff --git a/tests/State/StateMachineTest.php b/tests/State/StateMachineTest.php new file mode 100644 index 0000000..81b6a3d --- /dev/null +++ b/tests/State/StateMachineTest.php @@ -0,0 +1,215 @@ +name = $name; + $this->allowedTransitions = $transitions; + $this->validationRules = $rules; + } + + public function getName(): string + { + return $this->name; + } + + public function handle(array $context): array + { + // Merge passed context with any additional data + $mergedContext = array_merge($this->options, $context); + return [ + 'state' => $this->name, + 'context' => $mergedContext + ]; + } +} + +class MockStateMachine +{ + use StateMachineTrait; +} + +class StateMachineTest extends TestCase +{ + private MockStateMachine $stateMachine; + + protected function setUp(): void + { + $this->stateMachine = new MockStateMachine(); + } + + public function testAddState(): void + { + $state = new MockState('initial'); + $result = $this->stateMachine->addState($state, true); + + $this->assertSame($this->stateMachine, $result); + $this->assertSame($state, $this->stateMachine->getCurrentState()); + } + + public function testAddMultipleStates(): void + { + $initial = new MockState('initial', ['pending']); + $pending = new MockState('pending', ['processing', 'cancelled']); + $processing = new MockState('processing', ['completed']); + $completed = new MockState('completed'); + $cancelled = new MockState('cancelled'); + + $this->stateMachine + ->addState($initial, true) + ->addState($pending) + ->addState($processing) + ->addState($completed) + ->addState($cancelled); + + $this->assertSame($initial, $this->stateMachine->getCurrentState()); + $this->assertContains('pending', $this->stateMachine->getAvailableTransitions()); + } + + public function testValidStateTransition(): void + { + $initial = new MockState('initial', ['pending']); + $pending = new MockState('pending'); + + $this->stateMachine + ->addState($initial, true) + ->addState($pending); + + $result = $this->stateMachine->transitionTo('pending'); + + $this->assertTrue($result); + $this->assertSame('pending', $this->stateMachine->getCurrentState()->getName()); + } + + public function testInvalidStateTransition(): void + { + $this->expectException(StateException::class); + + $initial = new MockState('initial', []); + $pending = new MockState('pending'); + + $this->stateMachine + ->addState($initial, true) + ->addState($pending); + + $this->stateMachine->transitionTo('pending'); + } + + public function testTransitionToNonexistentState(): void + { + $this->expectException(StateException::class); + + $initial = new MockState('initial'); + $this->stateMachine->addState($initial, true); + + $this->stateMachine->transitionTo('nonexistent'); + } + + public function testContextValidation(): void + { + $initial = new MockState('initial', ['pending'], [ + 'order_id' => 'required', + 'amount' => 'type:double' + ]); + $pending = new MockState('pending'); + + $this->stateMachine->addState($initial, true); + $this->stateMachine->addState($pending); + + // Test missing required field + try { + $this->stateMachine->updateContext(['amount' => 99.99]); + $this->fail('Expected StateException for missing required field'); + } catch (StateException $e) { + $this->assertStringContainsString('Invalid context', $e->getMessage()); + } + + // Test invalid type + try { + $this->stateMachine->updateContext([ + 'order_id' => '123', + 'amount' => 'not-a-number' + ]); + $this->fail('Expected StateException for invalid type'); + } catch (StateException $e) { + $this->assertStringContainsString('Invalid context', $e->getMessage()); + } + + // Test valid context + $this->stateMachine->updateContext([ + 'order_id' => '123', + 'amount' => 99.99 + ]); + + // Should be able to transition with valid context + $this->assertTrue($this->stateMachine->transitionTo('pending')); + } + + public function testContextUpdate(): void + { + $initial = new MockState('initial'); + $this->stateMachine->addState($initial, true); + + // Initial context update + $this->stateMachine->updateContext(['key1' => 'value1']); + $context = $this->stateMachine->getContext(); + $this->assertEquals('value1', $context['key1']); + + // Merge new context + $this->stateMachine->updateContext(['key2' => 'value2']); + $context = $this->stateMachine->getContext(); + $this->assertEquals('value1', $context['key1']); + $this->assertEquals('value2', $context['key2']); + + // Override existing key + $this->stateMachine->updateContext(['key1' => 'new_value']); + $context = $this->stateMachine->getContext(); + $this->assertEquals('new_value', $context['key1']); + } + + public function testStateHistory(): void + { + $initial = new MockState('initial', ['pending']); + $pending = new MockState('pending', ['completed']); + $completed = new MockState('completed'); + + $this->stateMachine + ->addState($initial, true) + ->addState($pending) + ->addState($completed); + + $this->stateMachine->transitionTo('pending', ['order_id' => '123']); + $this->stateMachine->transitionTo('completed', ['status' => 'done']); + + $history = $this->stateMachine->getStateHistory(); + + $this->assertCount(2, $history); + $this->assertEquals('initial', $history[0]['from']); + $this->assertEquals('pending', $history[0]['to']); + $this->assertEquals('completed', $history[1]['to']); + } + + public function testGetAvailableTransitions(): void + { + $state = new MockState('test', ['state1', 'state2']); + $this->stateMachine->addState($state, true); + + $transitions = $this->stateMachine->getAvailableTransitions(); + + $this->assertCount(2, $transitions); + $this->assertContains('state1', $transitions); + $this->assertContains('state2', $transitions); + } +}