├── AInvalidStateException.php ├── AState.php ├── AStateException.php ├── AStateMachine.php ├── AStateTransition.php ├── README.md └── tests └── AStateMachineTest.php /AInvalidStateException.php: -------------------------------------------------------------------------------- 1 | setName($name); 36 | $this->setMachine($owner); 37 | } 38 | 39 | /** 40 | * Invoked before the state is transitioned to 41 | * @return boolean true if the event is valid and the transition should be allowed to continue 42 | */ 43 | public function beforeEnter() 44 | { 45 | $transition = new AStateTransition($this); 46 | $transition->to = $this; 47 | $transition->from = $this->_machine->getState(); 48 | $this->onBeforeEnter($transition); 49 | 50 | return $transition->isValid; 51 | } 52 | /** 53 | * This event is raised before the state is transitioned to 54 | * @param AStateTransition $transition the state transition 55 | */ 56 | public function onBeforeEnter($transition) 57 | { 58 | $this->raiseEvent("onBeforeEnter",$transition); 59 | } 60 | 61 | /** 62 | * Invoked after the state is transitioned to 63 | * @param AState $from The state we're transitioning from 64 | */ 65 | public function afterEnter(AState $from) 66 | { 67 | $transition = new AStateTransition($this); 68 | $transition->to = $this; 69 | $transition->from = $from; 70 | $this->onAfterEnter($transition); 71 | } 72 | /** 73 | * This event is raised after the state is transitioned to 74 | * @param AStateTransition $transition the state transition 75 | */ 76 | public function onAfterEnter($transition) 77 | { 78 | $this->raiseEvent("onAfterEnter",$transition); 79 | } 80 | /** 81 | * Invoked before the state is transitioned from 82 | * @param AState $toState The state we're transitioning to 83 | * @return boolean true if the event is valid and the transition should be allowed to continue 84 | */ 85 | public function beforeExit(AState $toState) 86 | { 87 | $transition = new AStateTransition($this); 88 | $transition->to = $toState; 89 | $transition->from = $this; 90 | 91 | if ($this->_machine->checkTransitionMap && !in_array($toState->name, $this->transitsTo)) { 92 | $transition->isValid = false; 93 | } 94 | 95 | $this->onBeforeExit($transition); 96 | 97 | return $transition->isValid; 98 | } 99 | /** 100 | * This event is raised before the state is transitioned from 101 | * @param AStateTransition $transition the state transition 102 | */ 103 | public function onBeforeExit($transition) 104 | { 105 | $this->raiseEvent("onBeforeExit",$transition); 106 | } 107 | 108 | /** 109 | * Invoked after the state is transitioned from 110 | */ 111 | public function afterExit() 112 | { 113 | $transition = new AStateTransition($this); 114 | $transition->from = $this; 115 | $transition->to = $this->_machine->getState(); 116 | $this->onAfterExit($transition); 117 | } 118 | /** 119 | * This event is raised after the state is transitioned from 120 | * @param AStateTransition $transition the state transition 121 | */ 122 | public function onAfterExit($transition) 123 | { 124 | $this->raiseEvent("onAfterExit",$transition); 125 | } 126 | 127 | /** 128 | * Sets the name for this state 129 | * @param string $name 130 | */ 131 | public function setName($name) 132 | { 133 | return $this->_name = $name; 134 | } 135 | 136 | /** 137 | * Gets the name for this state 138 | * @return string 139 | */ 140 | public function getName() 141 | { 142 | return $this->_name; 143 | } 144 | 145 | /** 146 | * Sets the state machine that this state belongs to 147 | * @param AStateMachine $owner the state machine this state belongs to 148 | * @return AStateMachine the state machine 149 | */ 150 | public function setMachine($owner) 151 | { 152 | return $this->_machine = $owner; 153 | } 154 | 155 | /** 156 | * Gets the state machine the state belongs to 157 | * @return AStateMachine 158 | */ 159 | public function getMachine() 160 | { 161 | return $this->_machine; 162 | } 163 | 164 | /** 165 | * 166 | * @param mixed $states 167 | */ 168 | public function setTransitsTo($states) 169 | { 170 | $transitsTo = $states; 171 | 172 | if (!is_array($states)) { 173 | if (is_string($states)) { 174 | if (strstr($states, ',') !== FALSE) { 175 | $transitsTo = explode(',', preg_replace('/\s+/', '', $states)); 176 | }else 177 | $transitsTo = array(trim($states)); 178 | } else { 179 | throw new AStateException('Invalide transitsTo format: ' . print_r($states, true)); 180 | } 181 | } 182 | 183 | $this->_transitsTo = $transitsTo; 184 | } 185 | 186 | public function getTransitsTo() 187 | { 188 | return ($this->_transitsTo) ? $this->_transitsTo : array(); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /AStateException.php: -------------------------------------------------------------------------------- 1 | 9 | * $stateMachine = new AStateMachine; 10 | * $stateMachine->setStates(array( 11 | * new ExampleEnabledState("enabled",$stateMachine), 12 | * new ExampleDisabledState("disabled",$stateMachine), 13 | * )); 14 | * $stateMachine->defaultStateName = "enabled"; 15 | * $model = new User; 16 | * $model->attachBehavior("status", $stateMachine); 17 | * echo $model->status->is("enabled") ? "true" : "false"; // "true" 18 | * $model->transition("disabled"); 19 | * echo $model->status->getState(); // "disabled" 20 | * $model->status->enable(); // assuming enable is a method on ExampleDisabledState 21 | * echo $model->status->getState(); // "enabled" 22 | * 23 | * 24 | * 25 | * @author Charles Pick 26 | * @package packages.stateMachine 27 | */ 28 | class AStateMachine extends CBehavior implements IApplicationComponent 29 | { 30 | /** 31 | * Holds the name of the default state. 32 | * Defaults to "default" 33 | * @var string 34 | */ 35 | public $defaultStateName = "default"; 36 | 37 | /** 38 | * Whether to track state transition history or not. 39 | * The state history will be stored in a stack and will not be persisted between requests. 40 | * This is set to false by default. 41 | * @var boolean 42 | */ 43 | public $enableTransitionHistory = false; 44 | 45 | /** 46 | * The maximum size of the state history 47 | * This is useful when performing lots of transitions. 48 | * Defaults to null, meaning there is no maximum history size 49 | * @var integer|null 50 | */ 51 | public $maximumTransitionHistorySize; 52 | 53 | /** 54 | * Defines whather to use AState.transitsTo attribute to check transition validity. 55 | * If it was set to TRUE than you should specify which states can be reached from current. 56 | * For example: 57 | *
58 | * $machine = new AStateMachine(); 59 | * $machine->setStates(array( 60 | * array( 61 | * 'name'=>'not_saved', 62 | * 'transitsTo'=>'published' 63 | * ), 64 | * array( 65 | * 'name'=>'published', 66 | * 'transitsTo'=>'registration, canceled', 67 | * ), 68 | * array( 69 | * 'name'=>'registration', 70 | * 'transitsTo'=>'published, processing, canceled' 71 | * ), 72 | * array( 73 | * 'name'=>'processing', 74 | * 'transitsTo'=>'finished, canceled' 75 | * ), 76 | * array('name'=>'finished'), 77 | * array('name'=>'canceled') 78 | * )); 79 | * $machine->checkTransitionMap = true; 80 | *81 | * 82 | * @var boolean 83 | */ 84 | public $checkTransitionMap = false; 85 | 86 | /** 87 | * Holds the transition history 88 | * @var CList 89 | */ 90 | protected $_transitionHistory; 91 | 92 | /** 93 | * The name of the current state 94 | * @var string 95 | */ 96 | protected $_stateName; 97 | /** 98 | * The supported states 99 | * @var AState[] 100 | */ 101 | protected $_states = array(); 102 | /** 103 | * Whether the state machine is initialized or not 104 | * @var boolean 105 | */ 106 | protected $_isInitialized = false; 107 | 108 | /** 109 | * The unique id for this state machine. 110 | * This is used when the machine is attached as a behavior 111 | * @var string 112 | */ 113 | protected $_uniqueID; 114 | 115 | /** 116 | * Constructor. 117 | * The default implementation calls the init() method 118 | */ 119 | public function __construct() 120 | { 121 | $this->init(); 122 | } 123 | 124 | /** 125 | * Initializes the state machine. 126 | * The default implementation merely sets the $this->_isInitialized property to true 127 | * Child classes that override this method should call the parent implementation 128 | * This method is required by IApplicationComponent 129 | */ 130 | public function init() 131 | { 132 | $this->_isInitialized = true; 133 | } 134 | /** 135 | * Attaches the state machine to a component 136 | * @param CComponent $owner the component to attach to 137 | */ 138 | public function attach($owner) 139 | { 140 | parent::attach($owner); 141 | if ($this->_uniqueID === null) { 142 | $this->_uniqueID = uniqid(); 143 | } 144 | if (($state = $this->getState()) !== null) { 145 | $owner->attachBehavior($this->_uniqueID."_".$state->name,$state); 146 | } 147 | } 148 | /** 149 | * Detaches the state machine from a component 150 | * @param CComponent $owner the component to detach from 151 | */ 152 | public function detach($owner) 153 | { 154 | parent::detach($owner); 155 | if ($this->_uniqueID !== null) { 156 | $owner->detachBehavior($this->_uniqueID."_".$this->getStateName()); 157 | } 158 | } 159 | 160 | /** 161 | * Determines whether the state machine has been initialized or not 162 | * @return boolean 163 | */ 164 | public function getIsInitialized() 165 | { 166 | return $this->_isInitialized; 167 | } 168 | /** 169 | * Sets the possible states for this machine 170 | * @param AState[] $states an array of possible states 171 | */ 172 | public function setStates($states) 173 | { 174 | $this->_states = array(); 175 | foreach ($states as $state) { 176 | $this->addState($state); 177 | } 178 | 179 | return $this->_states; 180 | } 181 | /** 182 | * Gets an array of possible states for this machine 183 | * @return AState[] the possible states for the machine 184 | */ 185 | public function getStates() 186 | { 187 | return $this->_states; 188 | } 189 | 190 | /** 191 | * Adds a state to the machine 192 | * @param AState|array $state The state to add, either an instance of AState or a configuration array for an AState 193 | * @return AState the added state 194 | */ 195 | public function addState($state) 196 | { 197 | if (is_array($state)) { 198 | if (!isset($state['class'])) { 199 | $state['class'] = "AState"; 200 | } 201 | $state = Yii::createComponent($state,$state['name'],$this); 202 | } 203 | 204 | return $this->_states[$state->getName()] = $state; 205 | } 206 | /** 207 | * Removes a state with the given name 208 | * @param string $stateName the name of the state to remove 209 | * @return AState|null the removed state, or null if there was no state by that name 210 | */ 211 | public function removeState($stateName) 212 | { 213 | if (!$this->hasState($stateName)) { 214 | return null; 215 | } 216 | $state = $this->_states[$stateName]; 217 | unset($this->_states[$stateName]); 218 | $this->_stateName = $this->defaultStateName; 219 | 220 | return $state; 221 | } 222 | /** 223 | * Sets the name of the current state but doesn't trigger the transition events 224 | * @param string $state the name of the state to change to 225 | */ 226 | public function setStateName($state) 227 | { 228 | $this->_stateName = $state; 229 | } 230 | 231 | /** 232 | * Gets the name of the current state 233 | * @return string 234 | */ 235 | public function getStateName() 236 | { 237 | if ($this->_stateName === null) { 238 | return $this->defaultStateName; 239 | } 240 | 241 | return $this->_stateName; 242 | } 243 | /** 244 | * Gets the default state 245 | * @return AState|null the default state, or null if no state is set 246 | */ 247 | public function getDefaultState() 248 | { 249 | if (is_null($this->defaultStateName) || !$this->hasState($this->defaultStateName)) { 250 | return null; 251 | } 252 | 253 | return $this->_states[$this->defaultStateName]; 254 | } 255 | 256 | /** 257 | * Gets the current state 258 | * @return AState|null the current state, or null if there is no state set 259 | */ 260 | public function getState() 261 | { 262 | $stateName = $this->getStateName(); 263 | if (!isset($this->_states[$stateName])) { 264 | return null; 265 | } 266 | 267 | return $this->_states[$stateName]; 268 | } 269 | /** 270 | * Transitions to a 271 | * @param string $state the name of the state 272 | * @return boolean true if the state exists, otherwise false 273 | */ 274 | public function hasState($state) 275 | { 276 | return isset($this->_states[$state]); 277 | } 278 | 279 | /** 280 | * Transitions the state machine to the specified state 281 | * @throws AInvalidStateException if the state doesn't exist 282 | * @param string $to The name of the state we're transitioning to 283 | * @param mixed $params additional parameters for the before/after Transition events 284 | * @return boolean true if the transition succeeded or false if it failed 285 | */ 286 | public function transition($to, $params=null) 287 | { 288 | if (!$this->hasState($to)) { 289 | throw new AInvalidStateException("No such state: ".$to); 290 | } 291 | $toState = $this->_states[$to]; 292 | $fromState = $this->getState(); 293 | 294 | if (!$this->canTransit($to, $params)) { 295 | return false; 296 | } 297 | 298 | if (($owner = $this->getOwner()) !== null) { 299 | 300 | // we need to attach the current state to the owner 301 | $owner->detachBehavior($this->_uniqueID."_".$this->getStateName()); 302 | $this->setStateName($to); 303 | $owner->attachBehavior($this->_uniqueID."_".$to,$toState); 304 | } else { 305 | $this->setStateName($to); 306 | } 307 | 308 | if ($this->enableTransitionHistory) { 309 | if ($this->maximumTransitionHistorySize !== null && ($c = $this->getTransitionHistory()->count() - $this->maximumTransitionHistorySize) >= 0) { 310 | for ($i = 0; $i <= $c; $i++) { 311 | $this->getTransitionHistory()->removeAt(0); 312 | } 313 | 314 | } 315 | $this->getTransitionHistory()->add($to); 316 | } 317 | $this->afterTransition($fromState, $params); 318 | 319 | return true; 320 | } 321 | 322 | /** 323 | * Checks can the state machine transite to the specified state 324 | * @throws AInvalidStateException if the state doesn't exist 325 | * @param string $to The name of the state we're transitioning to 326 | * @param mixed $params additional parameters for the before/after Transition events 327 | * @return boolean true if the transition succeeded or false if it failed 328 | */ 329 | public function canTransit($to, $params=null) 330 | { 331 | if (!$this->hasState($to)) { 332 | throw new AInvalidStateException("No such state: ".$to); 333 | } 334 | $toState = $this->_states[$to]; 335 | 336 | if (!$this->beforeTransition($toState, $params)) { 337 | return false; 338 | } 339 | 340 | return true; 341 | } 342 | 343 | /** 344 | * Invoked before a state transition 345 | * @param AState $toState The state we're transitioning to 346 | * @param mixed $params additional parameters for the event 347 | * @return boolean true if the event is valid and the transition should be allowed to continue 348 | */ 349 | public function beforeTransition(AState $toState, $params=null) 350 | { 351 | if (!$this->getState()->beforeExit($toState) || !$toState->beforeEnter()) { 352 | return false; 353 | } 354 | $transition = new AStateTransition($this, $params); 355 | $transition->to = $toState; 356 | $transition->from = $this->getState(); 357 | $this->onBeforeTransition($transition); 358 | 359 | return $transition->isValid; 360 | } 361 | /** 362 | * This event is raised before a state transition 363 | * @param AStateTransition $transition the state transition 364 | */ 365 | public function onBeforeTransition($transition) 366 | { 367 | $this->raiseEvent("onBeforeTransition",$transition); 368 | } 369 | 370 | /** 371 | * Invoked after a state transition 372 | * @param AState $from The state we're transitioning from 373 | * @param mixed $params additional parameters for the event 374 | */ 375 | public function afterTransition(AState $fromState, $params=null) 376 | { 377 | $fromState->afterExit(); 378 | $this->getState()->afterEnter($fromState); 379 | 380 | $transition = new AStateTransition($this, $params); 381 | $transition->to = $this->getState(); 382 | $transition->from = $fromState; 383 | $this->onAfterTransition($transition); 384 | } 385 | 386 | /** 387 | * This event is raised after a state transition 388 | * @param AStateTransition $transition the state transition 389 | */ 390 | public function onAfterTransition($transition) 391 | { 392 | $this->raiseEvent("onAfterTransition",$transition); 393 | } 394 | /** 395 | * Returns a property value based on its name. 396 | * @param string $name the property name or event name 397 | * @return mixed the property value, event handlers attached to the event, or the named behavior (since version 1.0.2) 398 | * @throws CException if the property or event is not defined 399 | * @see CComponent::__get() 400 | */ 401 | 402 | public function __get($name) 403 | { 404 | $state = $this->getState(); 405 | if ($state !== null && (property_exists($state,$name) || $state->canGetProperty($name))) { 406 | return $state->{$name}; 407 | } 408 | 409 | return parent::__get($name); 410 | } 411 | 412 | /** 413 | * Sets a property value based on its name. 414 | * @param string $name the property name or event name 415 | * @param mixed $value the property value 416 | * @return mixed the property value, event handlers attached to the event, or the named behavior (since version 1.0.2) 417 | * @throws CException if the property or event is not defined 418 | * @see CComponent::__get() 419 | */ 420 | public function __set($name,$value) 421 | { 422 | $state = $this->getState(); 423 | if ($state !== null && (property_exists($state,$name) || $state->canSetProperty($name))) { 424 | return $state->{$name} = $value; 425 | } 426 | 427 | return parent::__set($name,$value); 428 | } 429 | 430 | /** 431 | * Checks if a property value is null. 432 | * Do not call this method. This is a PHP magic method that we override 433 | * to allow using isset() to detect if a component property is set or not. 434 | * @param string $name the property name or the event name 435 | * @return boolean 436 | * @since 1.0.1 437 | */ 438 | public function __isset($name) 439 | { 440 | $state = $this->getState(); 441 | if ($state !== null && (property_exists($state,$name) || $state->canGetProperty($name))) { 442 | return true; 443 | } 444 | 445 | return parent::__isset($name); 446 | } 447 | 448 | /** 449 | * Sets a component property to be null. 450 | * @param string $name the property name or event name 451 | * @return mixed the property value, event handlers attached to the event, or the named behavior (since version 1.0.2) 452 | * @throws CException if the property or event is not defined 453 | * @see CComponent::__get() 454 | */ 455 | public function __unset($name) 456 | { 457 | $state = $this->getState(); 458 | if ($state !== null && (property_exists($state,$name) || $state->canSetProperty($name))) { 459 | return $state->{$name} = null; 460 | } 461 | 462 | return parent::__unset($name); 463 | } 464 | 465 | /** 466 | * Calls the named method which is not a class method. 467 | * Do not call this method. This is a PHP magic method that we override 468 | * to implement the states feature. 469 | * @param string $name the method name 470 | * @param array $parameters method parameters 471 | * @return mixed the method return value 472 | */ 473 | public function __call($name,$parameters) 474 | { 475 | $state = $this->getState(); 476 | if (is_object($state) && method_exists($state,$name)) { 477 | return call_user_func_array(array($state,$name),$parameters); 478 | } 479 | 480 | return parent::__call($name,$parameters); 481 | } 482 | 483 | /** 484 | * Determines whether the current state matches the given name 485 | * @param string $stateName the name of the state to check against 486 | * @return boolean true if the state names match 487 | */ 488 | public function is($stateName) 489 | { 490 | return $this->getStateName() == $stateName; 491 | } 492 | 493 | /** 494 | * Gets the transition history 495 | * @return CList the transition history 496 | */ 497 | public function getTransitionHistory() 498 | { 499 | if ($this->_transitionHistory === null) { 500 | $this->_transitionHistory = new CList(array($this->getStateName())); 501 | } 502 | 503 | return $this->_transitionHistory; 504 | } 505 | 506 | /** 507 | * Returns available states that can be reached from current. 508 | * It is usefull when you want allow user to chose next state somewhere in 509 | * an UI. 510 | * 511 | * @return array 512 | */ 513 | public function getAvailableStates() 514 | { 515 | $result = array(); 516 | 517 | foreach ($this->states as $state) 518 | if($this->canTransit($state->name)) 519 | $result[] = $state->name; 520 | 521 | return $result; 522 | } 523 | } 524 | -------------------------------------------------------------------------------- /AStateTransition.php: -------------------------------------------------------------------------------- 1 | Yii State Machine 2 | An object oriented state machine for Yii. It can be used independently or as a behavior to augment your existing components / models. 3 | 4 | 5 |
53 | /**
54 | * A state that applies when the user's account is pending activation
55 | */
56 | class UserPendingState extends AState {
57 | /**
58 | * Activates the user's account
59 | */
60 | public function activate() {
61 | $machine = $this->getMachine();
62 | $user = $machine->getOwner();
63 | $user->status = "active";
64 | $user->save();
65 | $machine->transition("active");
66 | }
67 | }
68 |
69 |
70 | /**
71 | * A state that applies when the user's account is active
72 | */
73 | class UserActiveState extends AState {
74 | /**
75 | * Deactivates the user's account
76 | */
77 | public function deactivate() {
78 | $machine = $this->getMachine();
79 | $user = $machine->getOwner();
80 | $user->status = "inactive";
81 | $user->save();
82 | $machine->transition("inactive");
83 | }
84 | /**
85 | * Raised when the state is transitioned to
86 | * @param AState $from the previous state
87 | */
88 | protected function afterEnter(AState $from) {
89 | parent::afterEnter($from);
90 | if ($from->name == "pending") {
91 | // send welcome email
92 | }
93 | else {
94 | // send welcome back email
95 | }
96 | }
97 | }
98 |
99 | /**
100 | * A state that applies when the user's account is deactivated
101 | */
102 | class UserInactiveState extends AState {
103 | /**
104 | * Reactivates the user's account
105 | */
106 | public function reactivate() {
107 | $machine = $this->getMachine();
108 | $user = $machine->getOwner();
109 | $user->status = "active";
110 | $user->save();
111 | $machine->transition("active");
112 | }
113 | /**
114 | * Invoked before the state is transitioned to
115 | */
116 | protected function beforeEnter() {
117 | if ($this->getMachine()->getState()->name == "pending") {
118 | // invalid state transition, user cannot go pending -> deactivated
119 | return false;
120 | }
121 | return parent::beforeEnter();
122 | }
123 | /**
124 | * Raised when the state is transitioned to
125 | * @param AState $from the previous state
126 | */
127 | protected function afterEnter(AState $from) {
128 | parent::afterEnter($from);
129 | Yii::log($this->getMachine()->getOwner()->name." deactivated their account :(");
130 | }
131 | }
132 |
133 |
134 |
135 |
138 | /**
139 | * Your user model
140 | * @property string $status either pending, active or inactive
141 | */
142 | class User extends CActiveRecord {
143 | /**
144 | * Declares the behaviors for the model
145 | * @return array the behavior configuration
146 | */
147 | public function behaviors() {
148 | return array(
149 | "activationStatus" => array(
150 | "class" => "AStateMachine",
151 | "states" => array(
152 | array(
153 | "class" => "UserPendingState",
154 | "name" => "pending",
155 | ),
156 | array(
157 | "class" => "UserActiveState",
158 | "name" => "active",
159 | ),
160 | array(
161 | "class" => "UserInactiveState",
162 | "name" => "inactive",
163 | ),
164 | ),
165 | "defaultStateName" => "pending",
166 | "stateName" => $this->status,
167 | )
168 | );
169 | }
170 | ...
171 | }
172 |
173 |
174 |
177 | $user = new User;
178 | $user->name = "Test User";
179 | $user->email = "test@example.com";
180 | $user->activate(); // activates the user, transitions to the "active" state
181 | $user->activate(); // throws exception, no such method
182 |
183 | $user->deactivate(); // deactivates the user
184 | $user->reactivate(); // reactivates the user
185 |
186 | $user->activationStatus->deactivate(); // call the state machine directly
187 |
188 |
189 | 195 | /** 196 | * Invoked before the state is transitioned to 197 | */ 198 | protected function beforeEnter() { 199 | if ($this->getMachine()->getState()->name == "pending") { 200 | // invalid state transition, user cannot go pending -> deactivated 201 | return false; 202 | } 203 | return parent::beforeEnter(); 204 | } 205 |206 | 207 | This approach is very flexible but it may make you crazy if you should describe a big graph of states. 208 | In this case you can free your time by setting *AStateMachine.checkTransitionMap* to *TRUE* 209 | and specifying *AState.transitsTo* attribute for all states. This attribute describes which states can 210 | be reached from current. See example below. 211 | 212 |
213 | /** 214 | * Represents deputy election process. 215 | */ 216 | class Election extends CActiveRecord { 217 | 218 | public static $statuses = array( 219 | Election::STATUS_PUBLISHED => 'Published', 220 | Election::STATUS_REGISTRATION => 'Registration', 221 | Election::STATUS_ELECTION => 'Election', 222 | Election::STATUS_FINISHED => 'Finished', 223 | Election::STATUS_CANCELED => 'Canceled', 224 | ); 225 | 226 | public function getStatusName() { 227 | return self::$statuses[$this->status]; 228 | } 229 | 230 | /** 231 | * ... other methods ... 232 | */ 233 | public function behaviors() { 234 | return array( 235 | "state" => array( 236 | "class" => "AStateMachine", 237 | "states" => array( 238 | array( 239 | 'name'=>'not_saved', 240 | 'transitsTo'=>'Published' 241 | ), 242 | array( 243 | 'name'=>'Published', 244 | 'transitsTo'=>'Registration, Canceled' 245 | ), 246 | array( 247 | 'name'=>'Registration', 248 | 'transitsTo'=>'Published, Election, Canceled' 249 | ), 250 | array( 251 | 'name'=>'Election', 252 | 'transitsTo'=>'Finished, Canceled' 253 | ), 254 | array( 255 | 'name'=>'Finished', 256 | 'class'=>'ElectionFinishedState' 257 | ), 258 | array('name'=>'Canceled') 259 | ), 260 | "defaultStateName" => "not_saved", 261 | "checkTransitionMap" => true, 262 | "stateName" => $this->statusName, 263 | ) 264 | ); 265 | } 266 | /** 267 | * ... other methods ... 268 | */ 269 | } 270 | 271 | class ElectionFinishedState extends AState { 272 | 273 | public function finish() { 274 | // ... 275 | } 276 | 277 | // ... 278 | 279 | public function afterEnter(AState $from) { 280 | parent::afterEnter($from); 281 | $this->finish(); 282 | } 283 | } 284 |285 | 286 | So lets see which states can be reached. 287 | 288 |
289 | $election = new Election; 290 | echo $election->stateName; // "not_saved" 291 | echo $election->canTransit('Published'); // true 292 | 293 | echo $election->canTransit('Registration'); // false 294 | echo $election->canTransit('Finished'); // false 295 | // ... Election and Canceled will return false too 296 | 297 | $election->transition('Published'); 298 | echo $election->canTransit('Published'); // false because we already here 299 | 300 | echo $election->canTransit('Registration'); // true 301 | echo $election->canTransit('Election'); // false 302 | echo $election->canTransit('Finished'); // false 303 | echo $election->canTransit('Canceled'); // true 304 | 305 | $election->transition('Registration'); 306 | $election->availableStates; // return array('Published', 'Election', 'Canceled') 307 | 308 | $election->transition('Election'); 309 | $election->availableStates; // return array('Finished', 'Canceled') 310 | 311 | $election->transition('Finished'); 312 | $election->availableStates; // return array() 313 |314 | 315 | Here we saw *availableStates* attribute ( or *getAvailableStates()* ). This is useful 316 | method when we want provide ability to switch state by user in an UI. -------------------------------------------------------------------------------- /tests/AStateMachineTest.php: -------------------------------------------------------------------------------- 1 | setStates(array( 17 | new ExampleEnabledState("enabled",$machine), 18 | new ExampleDisabledState("disabled",$machine), 19 | )); 20 | $machine->defaultStateName = "enabled"; 21 | $this->assertTrue($machine->is("enabled")); 22 | $this->assertFalse($machine->is("disabled")); 23 | $this->assertTrue($machine->isEnabled); 24 | $this->assertTrue(isset($machine->testProperty)); 25 | $machine->disable(); 26 | $this->assertTrue($machine->is("disabled")); 27 | $this->assertFalse($machine->is("enabled")); 28 | $this->assertFalse($machine->isEnabled); 29 | $this->assertFalse(isset($machine->testProperty)); 30 | } 31 | 32 | /** 33 | * Tests adding and removing states from a state machine 34 | */ 35 | public function testAddRemoveStates() 36 | { 37 | $machine = new AStateMachine(); 38 | 39 | $machine->addState(new ExampleEnabledState("enabled",$machine)); 40 | $this->assertFalse(isset($machine->testProperty)); 41 | $machine->defaultStateName = "enabled"; 42 | $this->assertTrue(isset($machine->testProperty)); 43 | $machine->removeState("enabled"); 44 | $this->assertFalse(isset($machine->testProperty)); 45 | $this->assertNull($machine->getState()); 46 | } 47 | 48 | /** 49 | * Tests the transition events 50 | */ 51 | public function testTransitions() 52 | { 53 | $machine = new AStateMachine(); 54 | $machine->setStates(array( 55 | new ExampleEnabledState("enabled",$machine), 56 | new ExampleDisabledState("disabled",$machine), 57 | new ExampleIntermediateState("intermediate", $machine), 58 | )); 59 | $machine->defaultStateName = "enabled"; 60 | $machine->enableTransitionHistory = true; 61 | $machine->maximumTransitionHistorySize = 2; 62 | $this->assertFalse($machine->transition("intermediate")); // intermediate state blocks transition from enabled -> intermediate 63 | $this->assertTrue($machine->transition("disabled")); 64 | $this->assertEquals(2, $machine->getTransitionHistory()->count()); 65 | $this->assertTrue($machine->transition("intermediate")); // should work 66 | $this->assertEquals(2, $machine->getTransitionHistory()->count()); 67 | } 68 | 69 | public function testCanTransit() 70 | { 71 | $machine = new AStateMachine(); 72 | $machine->setStates(array( 73 | new ExampleEnabledState("enabled",$machine), 74 | new ExampleDisabledState("disabled",$machine), 75 | new ExampleIntermediateState("intermediate", $machine), 76 | )); 77 | $machine->defaultStateName = "enabled"; 78 | 79 | $this->assertFalse($machine->canTransit("intermediate")); // intermediate state blocks transition from enabled -> intermediate 80 | 81 | $this->assertTrue($machine->canTransit("disabled")); 82 | $this->assertTrue($machine->transition("disabled")); 83 | 84 | $this->assertTrue($machine->canTransit("intermediate")); 85 | $this->assertTrue($machine->transition("intermediate")); // should work 86 | } 87 | 88 | public function testCanTransitWithTransitionsMapSpecified() 89 | { 90 | $machine = new AStateMachine(); 91 | $machine->setStates(array( 92 | array( 93 | 'name'=>'published', 94 | 'transitsTo'=>'registration, canceled' 95 | ), 96 | array( 97 | 'name'=>'registration', 98 | 'transitsTo'=>'published, processing, canceled' 99 | ), 100 | array( 101 | 'name'=>'processing', 102 | 'transitsTo'=>'finished, canceled' 103 | ), 104 | array('name'=>'finished'), 105 | array('name'=>'canceled') 106 | )); 107 | $machine->defaultStateName = "published"; 108 | $machine->checkTransitionMap = true; 109 | 110 | $this->assertFalse($machine->canTransit("processing")); 111 | $this->assertFalse($machine->canTransit("finished")); 112 | $this->assertFalse($machine->canTransit("published")); 113 | 114 | $this->assertTrue($machine->canTransit("registration")); 115 | $this->assertTrue($machine->canTransit("canceled")); 116 | 117 | $this->assertTrue($machine->transition("registration")); 118 | 119 | $this->assertFalse($machine->canTransit("finished")); 120 | $this->assertFalse($machine->canTransit("registration")); 121 | 122 | $this->assertTrue($machine->canTransit("published")); 123 | $this->assertTrue($machine->canTransit("processing")); 124 | $this->assertTrue($machine->canTransit("canceled")); 125 | 126 | $this->assertTrue($machine->transition("processing")); 127 | 128 | $this->assertFalse($machine->canTransit("processing")); 129 | $this->assertFalse($machine->canTransit("registration")); 130 | $this->assertFalse($machine->canTransit("published")); 131 | 132 | $this->assertTrue($machine->canTransit("finished")); 133 | $this->assertTrue($machine->canTransit("canceled")); 134 | 135 | $this->assertTrue($machine->transition("finished")); 136 | 137 | $this->assertFalse($machine->canTransit("finished")); 138 | $this->assertFalse($machine->canTransit("processing")); 139 | $this->assertFalse($machine->canTransit("registration")); 140 | $this->assertFalse($machine->canTransit("published")); 141 | $this->assertFalse($machine->canTransit("canceled")); 142 | } 143 | 144 | public function testGetAvailableStates() 145 | { 146 | $machine = new AStateMachine(); 147 | $machine->setStates(array( 148 | array( 149 | 'name'=>'not_saved', 150 | 'transitsTo'=>'published' 151 | ), 152 | array( 153 | 'name'=>'published', 154 | 'transitsTo'=>'registration, canceled', 155 | ), 156 | array( 157 | 'name'=>'registration', 158 | 'transitsTo'=>'published, processing, canceled' 159 | ), 160 | array( 161 | 'name'=>'processing', 162 | 'transitsTo'=>'finished, canceled' 163 | ), 164 | array('name'=>'finished'), 165 | array('name'=>'canceled') 166 | )); 167 | $machine->checkTransitionMap = true; 168 | $machine->defaultStateName = 'not_saved'; 169 | 170 | $this->checkStates(array('published'), $machine->availableStates); 171 | 172 | $machine->transition('published'); 173 | $this->checkStates(array('registration', 'canceled'), $machine->availableStates); 174 | 175 | $machine->transition('registration'); 176 | $this->checkStates(array('published', 'processing', 'canceled'), $machine->availableStates); 177 | 178 | $machine->transition('processing'); 179 | $this->checkStates(array('finished', 'canceled'), $machine->availableStates); 180 | 181 | $machine->transition('finished'); 182 | $this->checkStates(array(), $machine->availableStates); 183 | } 184 | 185 | protected function checkStates($shouldBeAvailable, $states) 186 | { 187 | $this->assertCount(count($shouldBeAvailable), $states); 188 | foreach ($shouldBeAvailable as $state) 189 | $this->assertContains($state, $states); 190 | } 191 | 192 | /** 193 | * Tests for the behavior functionality 194 | */ 195 | public function testBehavior() 196 | { 197 | $machine = new AStateMachine(); 198 | $machine->setStates(array( 199 | new ExampleEnabledState("enabled",$machine), 200 | new ExampleDisabledState("disabled",$machine), 201 | )); 202 | $machine->defaultStateName = "enabled"; 203 | 204 | $component = new CComponent(); 205 | $component->attachBehavior("status",$machine); 206 | $this->assertTrue($component->is("enabled")); 207 | $this->assertTrue($component->demoMethod()); 208 | $this->assertTrue($component->transition("disabled")); 209 | $this->assertTrue($component->status->is("disabled")); 210 | } 211 | 212 | public function testEvents() 213 | { 214 | $machine = $this->getMock("AStateMachine", array("onBeforeTransition", "onAfterTransition")); 215 | 216 | $enabled = $this->getMock("AState", array("onBeforeEnter", "onBeforeExit", "onAfterEnter", "onAfterExit"), array("enabled", $machine)); 217 | $disabled = $this->getMock("AState", array("onBeforeEnter", "onBeforeExit", "onAfterEnter", "onAfterExit"), array("disabled", $machine)); 218 | 219 | $machine->setStates(array($enabled,$disabled)); 220 | $machine->defaultStateName = "enabled"; 221 | 222 | $params = array("param"=>1, "param"=>2); 223 | $transition = new AStateTransition($machine, $params); 224 | $transition->to = $disabled; 225 | $transition->from = $enabled; 226 | 227 | $machine->expects($this->once()) 228 | ->method("onBeforeTransition") 229 | ->with($transition); 230 | 231 | $machine->expects($this->once()) 232 | ->method("onAfterTransition") 233 | ->with($transition); 234 | 235 | $enabledTransition = new AStateTransition($enabled); 236 | $enabledTransition->to = $disabled; 237 | $enabledTransition->from = $enabled; 238 | 239 | $enabled->expects($this->never()) 240 | ->method("onBeforeEnter"); 241 | $enabled->expects($this->never()) 242 | ->method("onAfterEnter"); 243 | $enabled->expects($this->once()) 244 | ->method("onBeforeExit") 245 | ->with($enabledTransition); 246 | $enabled->expects($this->once()) 247 | ->method("onAfterExit") 248 | ->with($enabledTransition); 249 | 250 | $disabledTransition = new AStateTransition($disabled); 251 | $disabledTransition->to = $disabled; 252 | $disabledTransition->from = $enabled; 253 | 254 | $disabled->expects($this->never()) 255 | ->method("onBeforeExit"); 256 | $disabled->expects($this->never()) 257 | ->method("onAfterExit"); 258 | $disabled->expects($this->once()) 259 | ->method("onBeforeEnter") 260 | ->with($disabledTransition); 261 | $disabled->expects($this->once()) 262 | ->method("onAfterEnter") 263 | ->with($disabledTransition); 264 | 265 | $this->assertTrue($machine->transition("disabled", $params)); 266 | } 267 | } 268 | 269 | /** 270 | * An example of an enabled state 271 | * @author Charles Pick 272 | * @package packages.stateMachine.tests 273 | */ 274 | class ExampleEnabledState extends AState 275 | { 276 | /** 277 | * An example of a state property 278 | * @var boolean 279 | */ 280 | public $isEnabled = true; 281 | 282 | /** 283 | * An example of a state property 284 | * @var boolean 285 | */ 286 | public $testProperty = true; 287 | 288 | /** 289 | * Sets the state to disabled 290 | */ 291 | public function disable() 292 | { 293 | $this->_machine->transition("disabled"); 294 | } 295 | 296 | public function demoMethod() 297 | { 298 | return true; 299 | } 300 | } 301 | /** 302 | * An example of a disabled state 303 | * @author Charles Pick 304 | * @package packages.stateMachine.tests 305 | */ 306 | class ExampleDisabledState extends AState 307 | { 308 | /** 309 | * An example of a state property 310 | * @var boolean 311 | */ 312 | public $isEnabled = false; 313 | /** 314 | * Sets the state to enabled 315 | */ 316 | public function enable() 317 | { 318 | $this->_machine->transition("enabled"); 319 | } 320 | } 321 | 322 | /** 323 | * An example of an intermediate state 324 | * @author Charles Pick 325 | * @package packages.stateMachine.tests 326 | */ 327 | class ExampleIntermediateState extends AState 328 | { 329 | /** 330 | * An example of a state property 331 | * @var boolean 332 | */ 333 | public $isEnabled = null; 334 | 335 | /** 336 | * Blocks the transition from enabled to intermediate 337 | * @param AState $fromState the state we're transitioning from 338 | * @return boolean whether the transition should continue 339 | */ 340 | public function beforeEnter() 341 | { 342 | $fromState = $this->_machine->getState(); 343 | if ($fromState->getName() == "enabled") { 344 | return false; 345 | } 346 | 347 | return parent::beforeEnter(); 348 | } 349 | } 350 | --------------------------------------------------------------------------------