Completed
Pull Request — master (#32)
by Brent
01:13
created

State::canTransitionTo()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 7
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
3
namespace Spatie\ModelStates;
4
5
use Exception;
6
use JsonSerializable;
7
use Illuminate\Support\Collection;
8
use Illuminate\Database\Eloquent\Model;
9
use ReflectionClass;
10
use Spatie\ModelStates\Events\StateChanged;
11
use Spatie\ModelStates\Exceptions\CouldNotPerformTransition;
12
use Spatie\ModelStates\Exceptions\InvalidConfig;
13
14
abstract class State implements JsonSerializable
15
{
16
    /**
17
     * Static cache for generated state maps.
18
     *
19
     * @var array
20
     *
21
     * @see State::resolveStateMapping
22
     */
23
    protected static $generatedMapping = [];
24
25
    /** @var \Illuminate\Database\Eloquent\Model */
26
    protected $model;
27
28
    /** @var string|null */
29
    protected $field;
30
31
    /**
32
     * Create a state object based on a value (classname or name),
33
     * and optionally provide its constructor arguments.
34
     *
35
     * @param string $name
36
     * @param mixed ...$args
37
     *
38
     * @return \Spatie\ModelStates\State
39
     */
40
    public static function make(string $name, Model $model): State
41
    {
42
        $stateClass = static::resolveStateClass($name);
43
44
        if (! is_subclass_of($stateClass, static::class)) {
45
            throw InvalidConfig::doesNotExtendBaseClass($name, static::class);
46
        }
47
48
        return new $stateClass($model);
49
    }
50
51
    public function __construct(Model $model)
52
    {
53
        $this->model = $model;
54
    }
55
56
    public function setField(string $field): State
57
    {
58
        $this->field = $field;
59
60
        return $this;
61
    }
62
63
    public function getField(): string
64
    {
65
        if (! $this->field) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->field of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
66
            throw new Exception("Could not determine the field name of this state class.");
67
        }
68
69
        return $this->field;
70
    }
71
72
    public function getStateConfig(): StateConfig
73
    {
74
        return $this->model::getStateConfig()[$this->field];
75
    }
76
77
    /**
78
     * Create a state object based on a value (classname or name),
79
     * and optionally provide its constructor arguments.
80
     *
81
     * @param string $name
82
     * @param mixed ...$args
83
     *
84
     * @return \Spatie\ModelStates\State
85
     */
86
    public static function find(string $name, Model $model): State
87
    {
88
        return static::make($name, $model);
89
    }
90
91
    /**
92
     * Get all registered state classes.
93
     *
94
     * @return \Illuminate\Support\Collection|string[]|static[] A list of class names.
95
     */
96
    public static function all(): Collection
97
    {
98
        return collect(self::resolveStateMapping());
99
    }
100
101
    /**
102
     * The value that will be saved in the database.
103
     *
104
     * @return string
105
     */
106
    public static function getMorphClass(): string
107
    {
108
        return static::resolveStateName(static::class);
109
    }
110
111
    /**
112
     * The value that will be saved in the database.
113
     *
114
     * @return string
115
     */
116
    public function getValue(): string
117
    {
118
        return static::getMorphClass();
119
    }
120
121
    /**
122
     * Resolve the state class based on a value, for example a stored value in the database.
123
     *
124
     * @param string|\Spatie\ModelStates\State $state
125
     *
126
     * @return string
127
     */
128
    public static function resolveStateClass($state): ?string
129
    {
130
        if ($state === null) {
131
            return null;
132
        }
133
134
        if ($state instanceof State) {
135
            return get_class($state);
136
        }
137
138
        foreach (static::resolveStateMapping() as $stateClass) {
0 ignored issues
show
Bug introduced by
Since resolveStateMapping() is declared private, calling it with static will lead to errors in possible sub-classes. You can either use self, or increase the visibility of resolveStateMapping() to at least protected.

Let’s assume you have a class which uses late-static binding:

class YourClass
{
    private static function getTemperature() {
        return "3422 °C";
}

public static function getSomeVariable()
{
    return static::getTemperature();
}

}

The code above will run fine in your PHP runtime. However, if you now create a sub-class and call the getSomeVariable() on that sub-class, you will receive a runtime error:

class YourSubClass extends YourClass {
      private static function getTemperature() {
        return "-182 °C";
    }
}

print YourSubClass::getSomeVariable(); // Will cause an access error.

In the case above, it makes sense to update SomeClass to use self instead:

class YourClass
{
    private static function getTemperature() {
        return "3422 °C";
    }

    public static function getSomeVariable()
    {
        return self::getTemperature();
    }
}
Loading history...
139
            if (! class_exists($stateClass)) {
140
                continue;
141
            }
142
143
            // Loose comparison is needed here in order to support non-string values,
144
            // Laravel casts their database value automatically to strings if we didn't specify the fields in `$casts`.
145
            if (($stateClass::$name ?? null) == $state) {
146
                return $stateClass;
147
            }
148
        }
149
150
        return $state;
151
    }
152
153
    /**
154
     * Resolve the name of the state, which is the value that will be saved in the database.
155
     *
156
     * Possible names are:
157
     *
158
     *    - The classname, if no explicit name is provided
159
     *    - A name provided in the state class as a public static property:
160
     *      `public static $name = 'dummy'`
161
     *
162
     * @param $state
163
     *
164
     * @return string|null
165
     */
166
    public static function resolveStateName($state): ?string
167
    {
168
        if ($state === null) {
169
            return null;
170
        }
171
172
        if ($state instanceof State) {
173
            $stateClass = get_class($state);
174
        } else {
175
            $stateClass = static::resolveStateClass($state);
176
        }
177
178
        if (class_exists($stateClass) && isset($stateClass::$name)) {
179
            return $stateClass::$name;
180
        }
181
182
        return $stateClass;
183
    }
184
185
    /**
186
     * Determine if the current state is one of an arbitrary number of other states.
187
     * This can be either a classname or a name.
188
     *
189
     * @param string|array ...$stateClasses
190
     *
191
     * @return bool
192
     */
193
    public function isOneOf(...$statesNames): bool
194
    {
195
        $statesNames = collect($statesNames)->flatten()->toArray();
196
197
        foreach ($statesNames as $statesName) {
198
            if ($this->equals($statesName)) {
199
                return true;
200
            }
201
        }
202
203
        return false;
204
    }
205
206
    /**
207
     * Determine if the current state equals another.
208
     * This can be either a classname or a name.
209
     *
210
     * @param string|\Spatie\ModelStates\State $state
211
     *
212
     * @return bool
213
     */
214
    public function equals($state): bool
215
    {
216
        return self::resolveStateClass($state)
217
            === self::resolveStateClass($this);
218
    }
219
220
    /**
221
     * Determine if the current state equals another.
222
     * This can be either a classname or a name.
223
     *
224
     * @param string|\Spatie\ModelStates\State $state
225
     *
226
     * @return bool
227
     */
228
    public function is($state): bool
229
    {
230
        return $this->equals($state);
231
    }
232
233
    public function __toString(): string
234
    {
235
        return static::getMorphClass();
236
    }
237
238
    /**
239
     * @param string|\Spatie\ModelStates\Transition $transitionClass
0 ignored issues
show
Documentation introduced by
There is no parameter named $transitionClass. Did you maybe mean $transition?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function. It has, however, found a similar but not annotated parameter which might be a good fit.

Consider the following example. The parameter $ireland is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $ireland
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was changed, but the annotation was not.

Loading history...
240
     * @param mixed ...$args
241
     *
242
     * @return \Illuminate\Database\Eloquent\Model
243
     */
244
    public function transition($transition, ...$args): Model
245
    {
246
        if (is_string($transition)) {
247
            $transition = new $transition($this->model, ...$args);
248
        }
249
250
        if (method_exists($transition, 'canTransition')) {
251
            if (! $transition->canTransition()) {
252
                throw CouldNotPerformTransition::notAllowed($this->model, $transition);
253
            }
254
        }
255
256
        $mutatedModel = app()->call([$transition, 'handle']);
257
258
        /*
259
         * There's a bug with the `finalState` variable:
260
         *      `$mutatedModel->state`
261
         * was used, but this is wrong because we cannot determine the model field within this state class.
262
         * Hence `state` is hardcoded, but that's wrong.
263
         *
264
         * @see https://github.com/spatie/laravel-model-states/issues/49
265
         */
266
        $finalState = $mutatedModel->state;
267
268
        if (! $finalState instanceof State) {
269
            $finalState = null;
270
        }
271
272
        event(new StateChanged($this, $finalState, $transition, $this->model));
273
274
        return $mutatedModel;
275
    }
276
277
    /**
278
     * @param string|\Spatie\ModelStates\State $state
279
     * @param mixed ...$args
280
     *
281
     * @return \Illuminate\Database\Eloquent\Model
282
     */
283
    public function transitionTo($state, ...$args): Model
284
    {
285
        if (! method_exists($this->model, 'resolveTransitionClass')) {
286
            throw InvalidConfig::resolveTransitionNotFound($this->model);
287
        }
288
289
        $transition = $this->model->resolveTransitionClass(
290
            static::resolveStateClass($this),
291
            static::resolveStateClass($state)
292
        );
293
294
        return $this->transition($transition, ...$args);
295
    }
296
297
    /**
298
     * Check whether the current state can transition to another one
299
     *
300
     * @param string|\Spatie\ModelStates\State $state
301
     *
302
     * @return bool
303
     */
304
    public function canTransitionTo($state): bool
305
    {
306
        return in_array(
307
            static::resolveStateName($state),
308
            $this->transitionableStates()
309
        );
310
    }
311
312
    public function transitionableStates(): array
313
    {
314
        $stateConfig = $this->getStateConfig();
315
316
        return $stateConfig->transitionableStates(get_class($this));
317
    }
318
319
    /**
320
     * This method is used to find all available implementations of a given abstract state class.
321
     * Finding all implementations can be done in two ways:.
322
     *
323
     *    - The developer can define his own mapping directly in abstract state classes
324
     *      via the `protected $states = []` property
325
     *    - If no specific mapping was provided, the same directory where the abstract state class lives
326
     *      is scanned, and all concrete state classes extending the abstract state class will be provided.
327
     *
328
     * @return array
329
     */
330
    private static function resolveStateMapping(): array
331
    {
332
        if (isset(static::$states)) {
333
            return static::$states;
334
        }
335
336
        if (isset(self::$generatedMapping[static::class])) {
337
            return self::$generatedMapping[static::class];
338
        }
339
340
        $reflection = new ReflectionClass(static::class);
341
342
        ['dirname' => $directory] = pathinfo($reflection->getFileName());
0 ignored issues
show
Bug introduced by
The variable $directory does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
343
344
        $files = scandir($directory);
345
346
        unset($files[0], $files[1]);
347
348
        $namespace = $reflection->getNamespaceName();
349
350
        $resolvedStates = [];
351
352
        foreach ($files as $file) {
353
            ['filename' => $className] = pathinfo($file);
0 ignored issues
show
Bug introduced by
The variable $className does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
354
355
            $stateClass = $namespace . '\\' . $className;
356
357
            if (! is_subclass_of($stateClass, static::class)) {
358
                continue;
359
            }
360
361
            $resolvedStates[] = $stateClass;
362
        }
363
364
        self::$generatedMapping[static::class] = $resolvedStates;
365
366
        return self::$generatedMapping[static::class];
367
    }
368
369
    public function jsonSerialize()
370
    {
371
        return $this->getValue();
372
    }
373
}
374