Test Failed
Push — master ( f74f09...dc3255 )
by Petr
02:47
created

NestedSet::add()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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