-
Notifications
You must be signed in to change notification settings - Fork 2
/
StateMachine.php
328 lines (285 loc) · 9.88 KB
/
StateMachine.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
<?php
/**
* User: Paris Theofanidis
* Date: 04/06/16
* Time: 22:55
*/
namespace ptheofan\statemachine;
use Alom\Graphviz\Digraph;
use Exception;
use ptheofan\statemachine\exceptions\InvalidSchemaException;
use ptheofan\statemachine\exceptions\InvalidSchemaSourceException;
use ptheofan\statemachine\exceptions\StateMachineNotFoundException;
use ptheofan\statemachine\exceptions\StateNotFoundException;
use ptheofan\statemachine\interfaces\StateMachineContext;
use ptheofan\statemachine\interfaces\StateMachineEvent;
use ptheofan\statemachine\interfaces\StateMachineJournal;
use SimpleXMLElement;
use yii;
use yii\base\Component;
/**
* Class StateMachine
*
* @package ptheofan\statemachine
*/
class StateMachine extends Component
{
/**
* The model that represents journal entries - set to null to disable journal
* @var string
*/
public $modelJournal = 'ptheofan\\statemachine\\dbmodels\\SmJournal';
/**
* @var string
*/
public $modelTimeout = 'ptheofan\\statemachine\\dbmodels\\SmTimeout';
/**
* The class tha represents the timeout
* @var string
*/
public $timeout = 'Timeout';
/**
* The class tha represents the context
* @var string
*/
public $context = 'Context';
/**
* The class that represents a state
* @var string|array
*/
public $state = 'State';
/**
* The class that represents an Event
* @var string
*/
public $event = 'Event';
/**
* The schema of the StateMachine that describes the states, events, etc.
* This can be
* string - xml
* string - path/to/file.xml
* SimpleXMLElement - Loaded XML Element
* callable - function returning any of the above. Accepts 1 parameter, the state machine itself
* ie. mySchemaLoader(StateMachine $sm)
*
* @var string|SimpleXMLElement|callable
*/
public $schemaSource;
/**
* @var string - The name of the state machine
*/
public $name;
/**
* @var string - default namespace for the commands (will be used if provided commands do not already provide namespace)
*/
public $commandsNamespace = '\\ptheofan\\statemachine\\commands';
/**
* @var string - default namespace for the conditions (will be used if provided conditions do not already provide namespace)
*/
public $conditionsNamespace = '\\ptheofan\\statemachine\\conditions';
/**
* The cached states
* @var State[]
*/
private $states = [];
/**
* The value of the initial state
* @var string
*/
private $__initialStateValue;
/**
* Always use the getter to access $__xml as it needs to be initialized
* @var SimpleXMLElement - the loaded XML
*/
private $__xml;
/**
* @return string
* @throws InvalidSchemaException
*/
public function getInitialStateValue()
{
if (!$this->__initialStateValue) {
$this->__initialStateValue = (string)$this->getXml()->attributes()->initialState;
if (!$this->__initialStateValue) {
throw new InvalidSchemaException("The initial state value is missing - should be registered in the XML schema");
}
}
return $this->__initialStateValue;
}
/**
* @return SimpleXMLElement
* @throws InvalidSchemaException
* @throws InvalidSchemaSourceException
* @throws StateMachineNotFoundException
*/
public function getXml()
{
if (!($this->__xml instanceof SimpleXMLElement)) {
$schemaSource = $this->schemaSource;
// Callable?
if (is_callable($schemaSource)) {
$schemaSource = call_user_func($this->schemaSource, $this);
}
// SimpleXMLElement
if ($schemaSource instanceof SimpleXMLElement) {
$this->__xml = $schemaSource;
} elseif (substr($schemaSource, 0, 5) === '<?xml') {
// XML in a string
$this->__xml = simplexml_load_string($schemaSource);
} else {
// XML file
$file = Yii::getAlias($schemaSource);
if (!file_exists($file)) {
throw new InvalidSchemaSourceException("The file `{$file}` does not exist");
}
$this->__xml = simplexml_load_file($file);
}
// Focus on xml part that represents this state machine
$this->__xml = $this->__xml->xpath('//state-machine[@ name="'.$this->name.'"]');
if (empty($this->__xml)) {
throw new StateMachineNotFoundException("State machine does not exist in the provided schema");
}
$this->__xml = $this->__xml[0];
}
return $this->__xml;
}
/**
* @param StateMachineEvent $event
* @param StateMachineContext $context
* @return bool
* @throws Exception
* @throws \yii\db\Exception
*/
public function transition(StateMachineEvent $event, StateMachineContext $context)
{
// Let the context know which event triggered the transition
$context->setEvent($event);
try {
// Leaving current state...
foreach ($event->getState()->getExitCommands() as $command) {
if (!$command->execute($context)) {
return false;
}
}
// Cleanup old timeouts
Timeout::cleanUp($context);
// Entering new state... (ignore if exit commands deleted the model)
if (!$context->isModelDeleted()) {
$context->getModel()->{$context->getAttr()} = $event->getTarget();
if ($context->getModel()->hasMethod('save')) {
$context->getModel()->save(false, [$context->getAttr()]);
}
foreach ($event->getTargetState()->getEnterCommands() as $command) {
if (!$command->execute($context)) {
return false;
}
}
}
// Register timeouts only if model is not deleted
if (!$context->isModelDeleted()) {
// Register the new timeouts
foreach ($event->getTargetState()->getTimeOuts() as $timeout) {
$timeout->register($context);
}
}
// Update Journal - if applicable
if ($this->modelJournal) {
$journal = $this->modelJournal;
/** @var StateMachineJournal $journal */
$journal::nu($context, $event);
}
// transition completed successfully
return true;
} catch (Exception $e) {
$context->attachException($e);
throw $e;
}
}
/**
* Call this when a model is entering the state machine for the first time.
* @param StateMachineContext $context
* @return bool
* @throws Exception
* @throws \yii\db\Exception
*/
public function initStateMachineAttribute(StateMachineContext $context)
{
/** @var yii\db\Transaction|false $txn */
try {
// Entering state...
if (empty($context->getModel()->{$context->getAttr()})) {
// Set StateMachine initial value if no value is already set
$context->getModel()->{$context->getAttr()} = $this->getInitialStateValue();
}
$state = $this->getState($this->getInitialStateValue());
foreach ($state->getEnterCommands() as $command) {
if (!$command->execute($context)) {
return false;
}
}
// Register the new timeouts
foreach ($state->getTimeOuts() as $timeout) {
$timeout->register($context);
}
// Persist the context's model data
if ($context->getModel()->hasMethod('save')) {
$context->getModel()->save(false, [$context->getAttr()]);
}
// Update Journal - if applicable
if ($this->modelJournal) {
$journal = $this->modelJournal;
/** @var StateMachineJournal $journal */
$journal::nu($context, null);
}
// transition completed successfully
return true;
} catch (Exception $e) {
$context->attachException($e);
throw $e;
}
}
/**
* @param string $value
* @return State|null
* @throws InvalidSchemaException
* @throws StateMachineNotFoundException
* @throws StateNotFoundException
*/
public function getState($value)
{
if ($value === null) {
$value = $this->getInitialStateValue();
}
if (isset($this->states[$value])) {
return $this->states[$value];
}
$xml = $this->getXml();
$stateXml = $xml->xpath('state[@ value="'.$value.'"]');
if (empty($stateXml)) {
throw new StateNotFoundException("State with value {$value} not found");
}
if (count($stateXml) > 1) {
throw new InvalidSchemaException("All states in a state machine must be unique. State with value {$value} is not unique.");
}
$this->states[$value] = State::fromXml($stateXml[0], $this);
return $this->states[$value];
}
/**
* @return State[]
* @throws InvalidSchemaException
* @throws StateMachineNotFoundException
* @throws StateNotFoundException
*/
public function getStates()
{
$xml = $this->getXml();
$stateXml = $xml->xpath('state');
$rVal = [];
foreach ($stateXml as $item) {
$value = (string)$item->attributes()->value;
$rVal[] = $this->getState($value);
$this->getState($value);
}
return $rVal;
}
}