Passed
Push — master ( 0e0d61...f7d6c6 )
by Petr
03:06
created

NestedSet::isNewParentOutsideCurrentNodeTree()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 22
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 10
c 1
b 0
f 0
dl 0
loc 22
ccs 11
cts 11
cp 1
rs 9.6111
cc 5
nc 5
nop 3
crap 5
1
<?php
2
3
namespace kalanis\nested_tree;
4
5
use kalanis\nested_tree\Sources\SourceInterface;
6
7
/**
8
 * Nested Set class for build left, right, level data.
9
 *
10
 * @see http://mikehillyer.com/articles/managing-hierarchical-data-in-mysql/ Query references.
11
 */
12
class NestedSet
13
{
14 134
    public function __construct(
15
        protected readonly SourceInterface $source,
16
        protected readonly Support\Node $nodeBase = new Support\Node(),
17
        protected readonly Support\TableSettings $tableSettings = new Support\TableSettings(),
18
    ) {
19 134
    }
20
21
    /**
22
     * Add new record to structure
23
     * The position is set to end
24
     *
25
     * Note: Properties of tree will be skipped and filled later. For their change use different methods.
26
     *
27
     * @param Support\Node $node
28
     * @param Support\Options $options
29
     * @return Support\Node
30
     */
31 3
    public function add(Support\Node $node, Support\Options $options = new Support\Options()) : Support\Node
32
    {
33 3
        $node->position = $this->getNewPosition($node->parentId, $options->where);
34
35 3
        return $this->source->add($node, $options->where);
36
    }
37
38
    /**
39
     * Update current record in structure
40
     *
41
     * Note: Properties with Null value will be skipped and stay same in the storage.
42
     * Note: Properties of tree will be skipped too. For their change use different methods.
43
     *
44
     * @param Support\Node $node
45
     * @param Support\Options $options
46
     * @return bool
47
     */
48 5
    public function update(Support\Node $node, Support\Options $options = new Support\Options()) : bool
49
    {
50 5
        return $this->source->updateData($node, $options->where);
51
    }
52
53
    /**
54
     * Change parent node to different one; put the content on the last position
55
     * Parent id can be either some number for existing one or 0/null for root
56
     *
57
     * Return true for pass, false otherwise
58
     *
59
     * @param int<1, max> $nodeId
60
     * @param int<0, max>|null $newParentId
61
     * @param Support\Options $options
62
     * @return bool
63
     */
64 7
    public function changeParent(int $nodeId, ?int $newParentId, Support\Options $options = new Support\Options()) : bool
65
    {
66 7
        $currentNode = $this->getNodeById($nodeId);
67 7
        if (empty($currentNode)) {
68 1
            return false;
69
        }
70
71 7
        if (!empty($newParentId)) {
72 5
            $parentNode = $this->getNodeById($newParentId);
73 5
            if (empty($parentNode)) {
74 5
                return false;
75
            }
76 2
        } elseif (is_null($newParentId)) {
0 ignored issues
show
introduced by
The condition is_null($newParentId) is always true.
Loading history...
77 1
            $parentNode = null;
78
        } else {
79 1
            $parentNode = new Support\Node();
80
        }
81
82 6
        if (!$this->isNewParentOutsideCurrentNodeTree($nodeId, $newParentId, $options)) {
83 4
            return false;
84
        }
85 6
        $newPosition = $this->getNewPosition($parentNode, $options->where);
86
87 6
        return $this->source->updateNodeParent($currentNode, $parentNode, $newPosition, $options->where);
88
    }
89
90
    /**
91
     * Move node to new position
92
     *
93
     * @param int<1, max> $nodeId
94
     * @param int<0, max> $newPosition
95
     * @param Support\Options $options
96
     * @return bool
97
     */
98 35
    public function move(int $nodeId, int $newPosition, Support\Options $options = new Support\Options()) : bool
99
    {
100 35
        $currentNode = $this->getNodeById($nodeId, $options);
101 35
        if (empty($currentNode)) {
102 8
            return false;
103
        }
104
105 28
        $parentNode = $this->source->selectParent($currentNode, $options);
106
107
        // move it
108 28
        if ($this->source->makeHole($parentNode, $newPosition, $newPosition > $currentNode->position, $options->where)) {
109 27
            return $this->source->updateNodeParent($currentNode, $parentNode, $newPosition, $options->where);
110
        }
111
112 1
        return false;
113
    }
114
115
    /**
116
     * Delete the selected taxonomy ID and pull children's parent ID to the same as selected one.<br>
117
     * Example: selected taxonomy ID is 4, its parent ID is 2. This method will be pull all children that has parent ID = 4 to 2 and delete the taxonomy ID 4.<br>
118
     * Always run <code>$NestedSet->rebuild()</code> after insert, update, delete to rebuild the correctly level, left, right data.
119
     *
120
     * @param int<1, max> $nodeId The selected taxonomy ID.
121
     * @param Support\Options $options Where array structure will be like this.
122
     * @return bool Return true on success, false for otherwise.
123
     */
124 8
    public function deletePullUpChildren(int $nodeId, Support\Options $options = new Support\Options()) : bool
125
    {
126
        // get this taxonomy parent id
127 8
        $node = $this->getNodeById($nodeId);
128 8
        if (empty($node)) {
129 1
            return false;
130
        }
131 8
        $parentNode = $this->source->selectParent($node, $options);
132
        // update this children first level.
133 8
        $this->source->updateChildrenParent($node, $parentNode, $options->where);
134
135 8
        return $this->source->deleteSolo($node, $options->where);
136
    }
137
138
    /**
139
     * Delete the selected taxonomy ID with its ALL children.<br>
140
     * Always run <code>$NestedSet->rebuild()</code> after insert, update, delete to rebuild the correctly level, left, right data.
141
     *
142
     * The columns `left`, `right` must have been built before using this method, otherwise the result will be incorrect.
143
     *
144
     * @param int<1, max> $nodeId The taxonomy ID to delete.
145
     * @param Support\Options $options Where array structure will be like this.
146
     * @return int|null Return number on success, return null for otherwise.
147
     */
148 7
    public function deleteWithChildren(int $nodeId, Support\Options $options = new Support\Options()) : ?int
149
    {
150 7
        $options->currentId = $nodeId;
151 7
        $options->unlimited = true;
152 7
        $result = $this->getNodesWithChildren($options);
153 7
        $i_count = 0;
154
155 7
        if (!empty($result->items)) {
156 4
            foreach ($result->items as $row) {
157 4
                if ($this->source->deleteSolo($row, $options->where)) {
158 4
                    $i_count++;
159
                }
160
            }
161
        }
162
163 7
        if (0 >= $i_count) {
164 3
            return null;
165
        }
166
167 4
        return $i_count;
168
    }
169
170
    /**
171
     * Get new position for taxonomy in the selected parent.
172
     *
173
     * @param int<0, max>|Support\Node|null $parent The parent ID. If root, set this to 0 or null.
174
     * @param Support\Conditions|null $where Where array structure will be like this.
175
     * @return int<1, max> Return the new position in the same parent.<br>
176
     *              WARNING! If there are no results or the results according to the conditions cannot be found. It always returns 1.
177
     */
178 13
    public function getNewPosition(int|Support\Node|null $parent, ?Support\Conditions $where = null) : int
179
    {
180 13
        $parentNode = is_object($parent)
181 5
            ? $parent
182 13
            : (
183 8
                is_null($parent)
184 4
                    ? null
185 8
                    : (
186 6
                        0 === $parent
187 2
                            ? new Support\Node()
188 8
                            : $this->getNodeById($parent)
189 8
                    )
190 13
            );
191 13
        $lastPosition = $this->source->selectLastPosition($parentNode, $where);
192
193 13
        return null === $lastPosition ? 1 : $lastPosition + 1;
194
    }
195
196
    /**
197
     * Get taxonomy from selected item and fetch its ALL children.<br>
198
     * Example: There are taxonomy tree like this. Root 1 > 1.1 > 1.1.1, Root 2, Root 3 > 3.1, Root 3 > 3.2 > 3.2.1, Root 3 > 3.2 > 3.2.2, Root 3 > 3.3<br>
199
     * Assume that selected item is Root 3. So, the result will be Root 3 > 3.1, 3.2 > 3.2.1, 3.2.2, 3.3<br>
200
     *
201
     * Warning! Even this method has options for search, custom where conditions,
202
     * but it is recommended that you should set the option to select only specific item.<br>
203
     * This method is intended to show results from a single target.
204
     *
205
     * The columns `left`, `right` must have been built before using this method, otherwise the result will be incorrect.
206
     *
207
     * @param Support\Options $options Available options
208
     *
209
     * @return Support\Result Return object of taxonomy data
210
     */
211 13
    public function getNodesWithChildren(Support\Options $options = new Support\Options()) : Support\Result
212
    {
213
        // set unwanted options that is available in `listTaxonomy()` method to defaults
214 13
        $options->parentId = null;
215 13
        $options->filterIdBy = [];
216 13
        $options->noSortOrder = false;
217
        // set required option.
218 13
        $options->listFlattened = true;
219
220 13
        return $this->listNodes($options);
221
    }
222
223
    /**
224
     * Get simple taxonomy from all known; set things to make the query more limited
225
     *
226
     * @param Support\Options $options
227
     * @return Support\Node|null
228
     */
229 2
    public function getNode(Support\Options $options = new Support\Options()) : ?Support\Node
230
    {
231 2
        $options = clone $options;
232 2
        $options->unlimited = false;
233 2
        $options->offset = 0;
234 2
        $options->limit = 1;
235 2
        $options->noSortOrder = false;
236 2
        $nodes = $this->source->selectLimited($options);
237 2
        if (empty($nodes)) {
238 1
            return null;
239
        }
240
241 1
        return reset($nodes);
242
    }
243
244
    /**
245
     * Get simple taxonomy by its ID; set things to make the query more limited
246
     *
247
     * @param int $nodeId
248
     * @param Support\Options $options
249
     * @return Support\Node|null
250
     */
251 54
    public function getNodeById(int $nodeId, Support\Options $options = new Support\Options()) : ?Support\Node
252
    {
253 54
        $customOptions = clone $options;
254 54
        $customOptions->currentId = $nodeId;
255
256 54
        $nodes = $this->source->selectSimple($customOptions);
257 54
        if (empty($nodes)) {
258 13
            return null;
259
        }
260
261 47
        return reset($nodes);
262
    }
263
264
    /**
265
     * Get taxonomy from selected item and fetch its parent in a line until root item.<br>
266
     * Example: There are taxonomy tree like this. Root1 > 1.1 > 1.1.1 > 1.1.1.1<br>
267
     * Assume that you selected at 1.1.1. So, the result will be Root1 > 1.1 > 1.1.1<br>
268
     * But if you set 'skipCurrent' to true the result will be Root1 > 1.1
269
     *
270
     * Warning! Even this method has options for search, custom where conditions,
271
     * but it is recommended that you should set the option to select only specific item.<br>
272
     * This method is intended to show results from a single target.
273
     *
274
     * The columns `left`, `right` must have been built before using this method, otherwise the result will be incorrect.
275
     *
276
     * @see http://mikehillyer.com/articles/managing-hierarchical-data-in-mysql/ Original source.
277
     * @param Support\Options $options Available options
278
     * @return Support\Result Return array object of taxonomy data
279
     */
280 18
    public function getNodesWithParents(Support\Options $options = new Support\Options()) : Support\Result
281
    {
282 18
        $result = new Support\Result();
283 18
        $result->items = $this->source->selectWithParents($options);
284 18
        $result->count = count($result->items);
285
286 18
        return $result;
287
    }
288
289
    /**
290
     * Rebuild children into array.
291
     *
292
     * @internal This method was called from `getTreeWithChildren()`.
293
     * @param array<int<0, max>, Support\Node> $array The array data that was get while running `getTreeWithChildren()`. This data contains 'children' object property but empty, it will be added here.
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<int<0, max>, Support\Node> at position 2 could not be parsed: Expected '>' at position 2, but found 'int'.
Loading history...
294
     * @return array<int<0, max>, Support\Node> Return added correct id of the children to data.
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<int<0, max>, Support\Node> at position 2 could not be parsed: Expected '>' at position 2, but found 'int'.
Loading history...
295
     */
296 124
    protected function getTreeRebuildChildren(array $array) : array
297
    {
298 124
        foreach ($array as $id => $row) {
299 124
            if (!is_null($row->parentId) && !empty($array[$row->parentId]) && ($row->parentId !== $row->id)) {
300 124
                $array[$row->parentId]->childrenIds[$id] = $id;
301 124
                $array[$row->parentId]->childrenNodes[$id] = $row;
302 124
            } elseif (is_null($row->parentId) && $this->tableSettings->rootIsNull && !empty($row->id)) {
303 30
                $array[0]->childrenIds[$id] = $id;
304 30
                $array[0]->childrenNodes[$id] = $row;
305
            }
306
        }
307
308 124
        return $array;
309
    }
310
311
    /**
312
     * Get the data nest tree with children.<br>
313
     * Its result will be look like this...<pre>
314
     * Array(
315
     *     [0] => Support\Node Object
316
     *         (
317
     *             [id] => 0
318
     *             [children] => Array
319
     *                 (
320
     *                     [1] => 1
321
     *                     [2] => 2
322
     *                     [3] => 3
323
     *                 )
324
     *         )
325
     *     [1] => Support\Node Object
326
     *         (
327
     *             [id] => 1
328
     *             [parent_id] => 0
329
     *             [level] => 1
330
     *             [children] => Array
331
     *                 (
332
     *                 )
333
     *         )
334
     *     [2] => Support\Node Object
335
     *         (
336
     *             [id] => 2
337
     *             [parent_id] => 0
338
     *             [level] => 1
339
     *             [children] => Array
340
     *                 (
341
     *                     [4] => 4
342
     *                     [5] => 5
343
     *                 )
344
     *         )
345
     *     [3] => Support\Node Object
346
     *         (
347
     *             [id] => 3
348
     *             [parent_id] => 0
349
     *             [level] => 1
350
     *             [children] => Array
351
     *                 (
352
     *                 )
353
     *         )
354
     *     [4] => Support\Node Object
355
     *         (
356
     *             [id] => 4
357
     *             [parent_id] => 2
358
     *             [level] => 2
359
     *             [children] => Array
360
     *                 (
361
     *                 )
362
     *         )
363
     *     [5] => Support\Node Object
364
     *         (
365
     *             [id] => 5
366
     *             [parent_id] => 2
367
     *             [level] => 2
368
     *             [children] => Array
369
     *                 (
370
     *                 )
371
     *         )
372
     * )</pre>
373
     *
374
     * Usually, this method is for get taxonomy tree data in the array format that suit for loop/nest loop verify level.
375
     *
376
     * @since 1.0
377
     * @internal This method was called from `rebuild()`.
378
     * @param Support\Options $options Where array structure will be like this.
379
     * @return array<int<0, max>, Support\Node> Return formatted array structure as seen in example of docblock.
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<int<0, max>, Support\Node> at position 2 could not be parsed: Expected '>' at position 2, but found 'int'.
Loading history...
380
     */
381 121
    protected function getTreeWithChildren(Support\Options $options = new Support\Options()) : array
382
    {
383
        // create a root node to hold child data about first level items
384 121
        $result = $this->source->selectSimple($options);
385 121
        $result[0] = clone $this->nodeBase; // hack for root node
386
387
        // now process the array and build the child data
388 121
        return $this->getTreeRebuildChildren($result);
389
    }
390
391
    /**
392
     * Detect that is this taxonomy's parent setting to be under this taxonomy's children or not.<br>
393
     * For example: Root 1 > 1.1 > 1.1.1 > 1.1.1.1 > 1.1.1.1.1<br>
394
     * Assume that you are editing 1.1.1 and its parent is 1.1. Now you change its parent to 1.1.1.1.1 which is under its children.<br>
395
     * The parent of 1.1.1 must be root, Root 1, 1.1 and never go under that.
396
     *
397
     * @param int<1, max> $currentNodeId The taxonomy ID that is changing the parent.
398
     * @param int<0, max>|null $newParentId The selected parent ID to check.
399
     * @param Support\Options $options Where array structure will be like this.
400
     * @return bool Return `false` if its parent is under its children (INCORRECT changes).<br>
401
     *              Return `false` if search result was not found (INCORRECT changes).<br>
402
     *              Return `true` if its parent is not under its children (CORRECT changes).
403
     */
404 10
    public function isNewParentOutsideCurrentNodeTree(int $currentNodeId, ?int $newParentId, Support\Options $options = new Support\Options()) : bool
405
    {
406 10
        if (empty($newParentId)) {
407
            // if parent is root, always return true because that is always correct!
408 3
            return true;
409
        }
410
411
        // check for selected parent that must not under this taxonomy.
412 8
        $options->currentId = $newParentId;
413 8
        $nodesWithParents = $this->getNodesWithParents($options);
414
415 8
        if (!empty($nodesWithParents->items)) {
416 8
            foreach ($nodesWithParents->items as $row) {
417 8
                if ($row->parentId === $currentNodeId) {
418 8
                    return false;
419
                }
420
            }
421
422 8
            return true;
423
        }
424
425 2
        return false;
426
    }
427
428
    /**
429
     * List taxonomy.
430
     *
431
     * The columns `left`, `right` must have been built before using this method, otherwise the result will be incorrect.
432
     *
433
     * @param Support\Options $options Available options
434
     */
435 78
    public function listNodes(Support\Options $options = new Support\Options()) : Support\Result
436
    {
437 78
        $output = new Support\Result();
438 78
        $output->count = $this->source->selectCount($options);
439 78
        $output->items = $this->source->selectLimited($options);
440
441 78
        if (!$options->listFlattened) {
442 24
            $output = $this->listNodesBuildTreeWithChildren($output, $options);
443
        }
444
445 78
        return $output;
446
    }
447
448
    /**
449
     * Build tree data with children.
450
     *
451
     * @internal This method was called from `listTaxonomy()`.
452
     * @param Support\Result $result The array item get from fetchAll() method using the PDO.
453
     * @param Support\Options $options Available options
454
     * @return Support\Result Return array data of formatted values.
455
     */
456 24
    protected function listNodesBuildTreeWithChildren(Support\Result $result, Support\Options $options) : Support\Result
457
    {
458 24
        $items = [];
459 24
        foreach ($result->items as &$item) {
460 23
            $items[$item->parentId][] = $item;
461
        }
462
463 24
        if (empty($options->filterIdBy)) {
464
            // without taxonomy_id_in option exists, this result can format to be hierarchical.
465 20
            foreach ($result->items as $row) {
466 19
                if (isset($items[$row->id])) {
467 17
                    $row->childrenNodes = $items[$row->id];
468 17
                    $row->childrenIds = array_map(fn (Support\Node $node) => $node->id, $row->childrenNodes);
469
                }
470
            }
471
472 20
            $partItems = ($items[0] ?? array_shift($items)); // this is important ([0]) for prevent duplicate items
473 20
            if (empty($partItems)) {
474 1
                return new Support\Result();
475
            } else {
476 19
                $result->items = $partItems;
477
            }
478
        }
479
480 23
        return $result;
481
    }
482
483
    /**
484
     * List taxonomy as flatten not tree.<br>
485
     * All parameters or arguments are same as `listTaxonomy()` method.
486
     *
487
     * @param Support\Options $options Available options
488
     */
489 47
    public function listNodesFlatten(Support\Options $options = new Support\Options()) : Support\Result
490
    {
491 47
        $options->listFlattened = true;
492
493 47
        return $this->listNodes($options);
494
    }
495
496
    /**
497
     * Rebuilds the tree data and save it to the database.<br>
498
     * This will rebuild the level, left, right values.
499
     *
500
     * The columns `left`, `right` must have been built before using this method, otherwise the result will be incorrect.
501
     *
502
     * @param Support\Options $options Where array structure will be like this.
503
     */
504 120
    public function rebuild(Support\Options $options = new Support\Options()) : void
505
    {
506
        // get taxonomy tree data in the array format that suit for loop/nest loop verify level.
507 120
        $data = $this->getTreeWithChildren($options);
508
509 120
        $n = 0; // need a variable to hold the running n tally
510 120
        $p = 0; // need a variable to hold the running position tally
511
512
        // rebuild positions
513 120
        $this->rebuildGeneratePositionData($data, 0, $p);
514
515
        // verify the level data. this method will be alter the $data value because it will be called as reference.
516
        // so, it doesn't need to use `$data = $this->rebuildGenerateTreeData()`;
517 120
        $this->rebuildGenerateTreeData($data, 0, 0, $n);
518
519 120
        foreach ($data as $id => $row) {
520 120
            if (0 === $id) {
521 120
                continue;
522
            }
523
524 120
            $this->source->updateLeftRightPos($row, $options->where);
525
        }
526
    }
527
528
    /**
529
     * Rebuild taxonomy level, left, right for tree data.<br>
530
     * This method will alter the $array value. It will be set level, left, right value.
531
     *
532
     * This method modify variables via argument reference without return anything.
533
     *
534
     * @internal This method was called from `rebuild()`.
535
     * @param array<int<0, max>, Support\Node> $array The data array, will be call as reference and modify its value.
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<int<0, max>, Support\Node> at position 2 could not be parsed: Expected '>' at position 2, but found 'int'.
Loading history...
536
     * @param int<0, max> $id The ID of taxonomy.
537
     * @param int<0, max> $level The level of taxonomy.
538
     * @param int<0, max> $n The tally or count number, will be call as reference and modify its value.
539
     */
540 121
    protected function rebuildGenerateTreeData(array &$array, int $id, int $level, int &$n) : void
541
    {
542 121
        $array[$id]->level = $level;
543 121
        $array[$id]->left = $n++;
544
545
        // loop over the node's children and process their data
546
        // before assigning the right value
547 121
        foreach ($array[$id]->childrenIds as $childNodeId) {
548 121
            $this->rebuildGenerateTreeData($array, $childNodeId, $level + 1, $n);
549
        }
550
551 121
        $array[$id]->right = $n++;
552
    }
553
554
    /**
555
     * Rebuild taxonomy positions for tree data.<br>
556
     * This method will alter the $array value. It will set position value.
557
     *
558
     * This method modify variables via argument reference without return anything.
559
     *
560
     * @internal This method was called from `rebuild()`.
561
     * @param array<int<0, max>, Support\Node> $array The data array, will be call as reference and modify its value.
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<int<0, max>, Support\Node> at position 2 could not be parsed: Expected '>' at position 2, but found 'int'.
Loading history...
562
     * @param int<0, max> $id The ID of taxonomy.
563
     * @param int<0, max> $n The position number, will be call as reference and modify its value.
564
     */
565 121
    protected function rebuildGeneratePositionData(array &$array, int $id, int &$n) : void
566
    {
567 121
        $array[$id]->position = ++$n;
568
569
        // loop over the node's children and process their data
570
        // before assigning the right value
571 121
        $p = 0;
572 121
        foreach ($array[$id]->childrenIds as $childNodeId) {
573 121
            $this->rebuildGeneratePositionData($array, $childNodeId, $p);
574
        }
575
576 121
        usort($array[$id]->childrenIds, function (int $a, int $b) use ($array) {
577 121
            return $array[$a]->position <=> $array[$b]->position;
578 121
        });
579
    }
580
}
581