Passed
Push — master ( 687cbc...187bee )
by Petr
02:53
created

NestedSet::getNodesWithParents()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

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