Move::perform()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 25
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 12
c 1
b 0
f 0
nc 3
nop 0
dl 0
loc 25
rs 9.8666
1
<?php
2
3
namespace Encima\Albero;
4
5
use Illuminate\Database\Eloquent\Model;
6
use Illuminate\Contracts\Events\Dispatcher;
7
8
/**
9
* Move
10
*/
11
class Move
12
{
13
    /** @var \Illuminate\Database\Eloquent\Model */
14
    protected $node = null;
15
16
    /** @var \Illuminate\Database\Eloquent\Model|int */
17
    protected $target = null;
18
19
    /** @var string|null */
20
    protected $position = null;
21
22
    /** @var int|null */
23
    protected $_bound1 = null;
24
25
    /** @var int|null */
26
    protected $_bound2 = null;
27
28
    /** @var array|null */
29
    protected $_boundaries = null;
30
31
    /** @var \Illuminate\Events\Dispatcher */
32
    protected static $dispatcher;
33
34
    /**
35
     * Create a new Move class instance.
36
     *
37
     * @param   \Illuminate\Database\Eloquent\Model      $node
38
     * @param   \Illuminate\Database\Eloquent\Model|int  $target
39
     * @param   string          $position
40
     * @return  void
41
     */
42
    public function __construct(Model $node, $target, string $position)
43
    {
44
        $this->node = $node;
45
        $this->target = $this->resolveNode($target);
46
        $this->position = $position;
47
        $this->setEventDispatcher($node->getEventDispatcher());
48
    }
49
50
    /**
51
     * Easy static accessor for performing a move operation.
52
     *
53
     * @param   \Illuminate\Database\Eloquent\Model      $node
54
     * @param   \Illuminate\Database\Eloquent\Model|int  $target
55
     * @param   string          $position
56
     * @return \Illuminate\Database\Eloquent\Model
57
     */
58
    public static function to(Model $node, $target, string $position): Model
59
    {
60
        $instance = new static($node, $target, $position);
61
62
        return $instance->perform();
63
    }
64
65
    /**
66
     * Perform the move operation.
67
     *
68
     * @return \Illuminate\Database\Eloquent\Model
69
     */
70
    public function perform(): Model
71
    {
72
        $this->guardAgainstImpossibleMove();
73
74
        if ($this->fireMoveEvent('moving') === false) {
75
            return $this->node;
76
        }
77
78
        if ($this->hasChange()) {
79
            $self = $this;
80
81
            $this->node->getConnection()->transaction(function () use ($self) {
82
                $self->updateStructure();
83
            });
84
85
            $this->target->reload();
86
87
            $this->node->setDepthWithSubtree();
88
89
            $this->node->reload();
90
        }
91
92
        $this->fireMoveEvent('moved', false);
93
94
        return $this->node;
95
    }
96
97
    /**
98
     * Runs the SQL query associated with the update of the indexes affected
99
     * by the move operation.
100
     *
101
     * @return int
102
     */
103
    public function updateStructure(): int
104
    {
105
        list($a, $b, $c, $d) = $this->boundaries();
106
107
        // select the rows between the leftmost & the rightmost boundaries and apply a lock
108
        $this->applyLockBetween($a, $d);
109
110
        $connection = $this->node->getConnection();
111
        $grammar = $connection->getQueryGrammar();
112
113
        $currentId = $this->quoteIdentifier($this->node->getKey());
114
        $parentId = $this->quoteIdentifier($this->parentId());
115
        $leftColumn = $this->node->getLeftColumnName();
116
        $rightColumn = $this->node->getRightColumnName();
117
        $parentColumn = $this->node->getParentColumnName();
118
        $wrappedLeft = $grammar->wrap($leftColumn);
0 ignored issues
show
Bug introduced by
It seems like $leftColumn can also be of type Illuminate\Database\Eloquent\Builder; however, parameter $value of Illuminate\Database\Query\Grammars\Grammar::wrap() does only seem to accept Illuminate\Database\Query\Expression|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

118
        $wrappedLeft = $grammar->wrap(/** @scrutinizer ignore-type */ $leftColumn);
Loading history...
119
        $wrappedRight = $grammar->wrap($rightColumn);
120
        $wrappedParent = $grammar->wrap($parentColumn);
121
        $wrappedId = $grammar->wrap($this->node->getKeyName());
122
123
        $lftSql = "CASE
124
      WHEN ${wrappedLeft} BETWEEN ${a} AND ${b} THEN ${wrappedLeft} + ${d} - ${b}
125
      WHEN ${wrappedLeft} BETWEEN ${c} AND ${d} THEN ${wrappedLeft} + ${a} - ${c}
126
      ELSE ${wrappedLeft} END";
127
128
        $rgtSql = "CASE
129
      WHEN ${wrappedRight} BETWEEN ${a} AND ${b} THEN ${wrappedRight} + ${d} - ${b}
130
      WHEN ${wrappedRight} BETWEEN ${c} AND ${d} THEN ${wrappedRight} + ${a} - ${c}
131
      ELSE ${wrappedRight} END";
132
133
        $parentSql = "CASE
134
      WHEN ${wrappedId} = ${currentId} THEN ${parentId}
135
      ELSE ${wrappedParent} END";
136
137
        $updateConditions = [
138
      $leftColumn => $connection->raw($lftSql),
139
      $rightColumn => $connection->raw($rgtSql),
140
      $parentColumn => $connection->raw($parentSql),
141
    ];
142
143
        if ($this->node->timestamps) {
144
            $updateConditions[$this->node->getUpdatedAtColumn()] = $this->node->freshTimestamp();
145
        }
146
147
        return $this->node
148
                ->newNestedSetQuery()
149
                ->where(function ($query) use ($leftColumn, $rightColumn, $a, $d) {
150
                    $query->whereBetween($leftColumn, [$a, $d])
151
                        ->orWhereBetween($rightColumn, [$a, $d]);
152
                })
153
                ->update($updateConditions);
154
    }
155
156
    /**
157
     * Resolves suplied node. Basically returns the node unchanged if
158
     * supplied parameter is an instance of \Illuminate\Database\Eloquent\Model. Otherwise it will try
159
     * to find the node in the database.
160
     *
161
     * @param   \Illuminate\Database\Eloquent\Model|int
162
     * @return  \Illuminate\Database\Eloquent\Model
163
     */
164
    protected function resolveNode($node): ?Model
165
    {
166
        if (is_object($node)) {
167
            return $node->reload();
168
        }
169
170
        return $this->node->newNestedSetQuery()->find($node);
171
    }
172
173
    /**
174
     * Check wether the current move is possible and if not, rais an exception.
175
     *
176
     * @return void
177
     */
178
    protected function guardAgainstImpossibleMove(): void
179
    {
180
        if (!$this->node->exists) {
181
            throw new MoveNotPossibleException('A new node cannot be moved.');
182
        }
183
184
        if (array_search($this->position, ['child', 'left', 'right', 'root']) === false) {
185
            throw new MoveNotPossibleException("Position should be one of ['child', 'left', 'right'] but is {$this->position}.");
186
        }
187
188
        if (!$this->promotingToRoot()) {
189
            if (is_null($this->target)) {
190
                if ($this->position === 'left' || $this->position === 'right') {
191
                    throw new MoveNotPossibleException("Could not resolve target node. This node cannot move any further to the {$this->position}.");
192
                }
193
194
                throw new MoveNotPossibleException('Could not resolve target node.');
195
            }
196
197
            if ($this->node->equals($this->target)) {
198
                throw new MoveNotPossibleException('A node cannot be moved to itself.');
199
            }
200
201
            if ($this->target->insideSubtree($this->node)) {
202
                throw new MoveNotPossibleException('A node cannot be moved to a descendant of itself (inside moved tree).');
203
            }
204
205
            if (!$this->node->inSameScope($this->target)) {
206
                throw new MoveNotPossibleException('A node cannot be moved to a different scope.');
207
            }
208
        }
209
    }
210
211
    /**
212
     * Computes the boundary.
213
     *
214
     * @return int
215
     */
216
    protected function bound1(): int
217
    {
218
        if (!is_null($this->_bound1)) {
219
            return $this->_bound1;
220
        }
221
222
        switch ($this->position) {
223
      case 'child':
224
        $this->_bound1 = $this->target->getRight();
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->target->getRight() can also be of type Illuminate\Database\Eloquent\Builder. However, the property $_bound1 is declared as type integer|null. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
225
226
        break;
227
228
      case 'left':
229
        $this->_bound1 = $this->target->getLeft();
230
231
        break;
232
233
      case 'right':
234
        $this->_bound1 = $this->target->getRight() + 1;
235
236
        break;
237
238
      case 'root':
239
        $this->_bound1 = $this->node->newNestedSetQuery()->max($this->node->getRightColumnName()) + 1;
240
241
        break;
242
    }
243
244
        $this->_bound1 = (($this->_bound1 > $this->node->getRight()) ? $this->_bound1 - 1 : $this->_bound1);
245
246
        return $this->_bound1;
247
    }
248
249
    /**
250
     * Computes the other boundary.
251
     * TODO: Maybe find a better name for this... ¿?
252
     *
253
     * @return int
254
     */
255
    protected function bound2(): int
256
    {
257
        if (!is_null($this->_bound2)) {
258
            return $this->_bound2;
259
        }
260
261
        $this->_bound2 = (($this->bound1() > $this->node->getRight()) ? $this->node->getRight() + 1 : $this->node->getLeft() - 1);
262
263
        return $this->_bound2;
264
    }
265
266
    /**
267
     * Computes the boundaries array.
268
     *
269
     * @return array
270
     */
271
    protected function boundaries(): array
272
    {
273
        if (!is_null($this->_boundaries)) {
274
            return $this->_boundaries;
275
        }
276
277
        // we have defined the boundaries of two non-overlapping intervals,
278
        // so sorting puts both the intervals and their boundaries in order
279
        $this->_boundaries = [
280
      $this->node->getLeft(),
281
      $this->node->getRight(),
282
      $this->bound1(),
283
      $this->bound2(),
284
    ];
285
        sort($this->_boundaries);
286
287
        return $this->_boundaries;
288
    }
289
290
    /**
291
     * Computes the new parent id for the node being moved.
292
     *
293
     * @return int|string|null
294
     */
295
    protected function parentId()
296
    {
297
        switch ($this->position) {
298
      case 'root':
299
        return null;
300
301
      case 'child':
302
        return $this->target->getKey();
303
304
      default:
305
        return $this->target->getParentId();
306
    }
307
    }
308
309
    /**
310
     * Check wether there should be changes in the downward tree structure.
311
     *
312
     * @return boolean
313
     */
314
    protected function hasChange(): bool
315
    {
316
        return !($this->bound1() == $this->node->getRight() || $this->bound1() == $this->node->getLeft());
317
    }
318
319
    /**
320
     * Check if we are promoting the provided instance to a root node.
321
     *
322
     * @return boolean
323
     */
324
    protected function promotingToRoot(): bool
325
    {
326
        return ($this->position == 'root');
327
    }
328
329
    /**
330
     * Get the event dispatcher instance.
331
     *
332
     * @return \Illuminate\Contracts\Events\Dispatcher
333
     */
334
    public static function getEventDispatcher(): Dispatcher
335
    {
336
        return static::$dispatcher;
337
    }
338
339
    /**
340
     * Set the event dispatcher instance.
341
     *
342
     * @param  \Illuminate\Events\Dispatcher
343
     * @return void
344
     */
345
    public static function setEventDispatcher(Dispatcher $dispatcher): void
346
    {
347
        static::$dispatcher = $dispatcher;
0 ignored issues
show
Documentation Bug introduced by
$dispatcher is of type Illuminate\Contracts\Events\Dispatcher, but the property $dispatcher was declared to be of type Illuminate\Events\Dispatcher. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
348
    }
349
350
    /**
351
     * Fire the given move event for the model.
352
     *
353
     * @param  string $event
354
     * @param  bool   $halt
355
     * @return mixed
356
     */
357
    protected function fireMoveEvent(string $event, bool $halt = true)
358
    {
359
        if (!isset(static::$dispatcher)) {
360
            return true;
361
        }
362
363
        // Basically the same as \Illuminate\Database\Eloquent\Model->fireModelEvent
364
        // but we relay the event into the node instance.
365
        $event = "eloquent.{$event}: ".get_class($this->node);
366
367
        $method = $halt ? 'until' : 'dispatch';
368
        // dd(static::$dispatcher);
369
370
        return static::$dispatcher->{$method}($event, $this->node);
371
    }
372
373
    /**
374
     * Quotes an identifier for being used in a database query.
375
     *
376
     * @param mixed $value
377
     * @return string
378
     */
379
    protected function quoteIdentifier($value): string
380
    {
381
        if (is_null($value)) {
382
            return 'NULL';
383
        }
384
385
        $connection = $this->node->getConnection();
386
387
        $pdo = $connection->getPdo();
388
389
        return $pdo->quote($value);
390
    }
391
392
    /**
393
     * Applies a lock to the rows between the supplied index boundaries.
394
     *
395
     * @param   int   $lft
396
     * @param   int   $rgt
397
     * @return  void
398
     */
399
    protected function applyLockBetween(int $lft, int $rgt): void
400
    {
401
        $this->node->newQuery()
402
      ->where($this->node->getLeftColumnName(), '>=', $lft)
403
      ->where($this->node->getRightColumnName(), '<=', $rgt)
404
      ->select($this->node->getKeyName())
405
      ->lockForUpdate()
406
      ->get();
407
    }
408
}
409