├── .github
└── workflows
│ └── run-tests.yml
├── .gitignore
├── CHANGELOG.markdown
├── README.markdown
├── TODO.markdown
├── UPGRADE.markdown
├── composer.json
├── phpunit.php
├── phpunit.xml
├── src
├── AncestorsRelation.php
├── BaseRelation.php
├── Collection.php
├── DescendantsRelation.php
├── NestedSet.php
├── NestedSetServiceProvider.php
├── NodeTrait.php
└── QueryBuilder.php
└── tests
├── .gitkeep
├── NodeTest.php
├── ScopedNodeTest.php
├── data
├── categories.php
└── menu_items.php
└── models
├── Category.php
├── DuplicateCategory.php
└── MenuItem.php
/.github/workflows/run-tests.yml:
--------------------------------------------------------------------------------
1 | name: Unit Tests
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 | branches:
9 | - "**"
10 | schedule:
11 | - cron: '0 0 * * *'
12 |
13 | jobs:
14 | php-tests:
15 | runs-on: ubuntu-latest
16 |
17 | timeout-minutes: 15
18 |
19 | env:
20 | COMPOSER_NO_INTERACTION: 1
21 |
22 | strategy:
23 | fail-fast: false
24 | matrix:
25 | php: [8.0, 8.1, 8.2, 8.3, 8.4]
26 |
27 | name: PHP${{ matrix.php }}
28 |
29 | steps:
30 | - name: Checkout code
31 | uses: actions/checkout@v2
32 |
33 | - name: Setup PHP
34 | uses: shivammathur/setup-php@v2
35 | with:
36 | php-version: ${{ matrix.php }}
37 | coverage: none
38 | tools: composer:v2
39 |
40 | - name: Install dependencies
41 | run: |
42 | composer install -o --quiet
43 |
44 | - name: Execute Unit Tests
45 | run: composer test
46 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor
2 | /.idea
3 | .phpunit.cache/
4 | composer.phar
5 | composer.lock
6 | .DS_Store
7 | .phpunit.result.cache
8 |
--------------------------------------------------------------------------------
/CHANGELOG.markdown:
--------------------------------------------------------------------------------
1 | ### 4.3.4
2 | * Support Laravel 5.8
3 |
4 | ### 4.3.3
5 | * Support Laravel 5.7
6 |
7 | ### 4.3.2
8 | * Support Laravel 5.6
9 | * Added `nestedSet` and `dropNestedSet` blueprint macros
10 |
11 | ### 4.3.0
12 | * Support Laravel 5.5
13 | * Added `fixSubtree` and `rebuildSubtree` methods
14 | * Increased performance of tree rebuilding
15 |
16 | ### 4.2.7
17 |
18 | * #217: parent_id, lft and rgt are reset when replicating a node
19 |
20 | ### 4.2.5
21 |
22 | * #208: dirty parent and bounds when making a root
23 | * #206: fixed where has on descendants
24 | * refactored ancestors and descendants relations
25 |
26 | ### 4.2.4
27 |
28 | * Fixed issues related to rebuilding tree when using `SoftDeletes` and scoping
29 |
30 | ### 4.2.3
31 |
32 | * Added `whereAncestorOrSelf`, `ancestorsAndSelf`, `descendantsOrSelf`,
33 | `whereDescendantOrSelf` helper methods
34 | * #186: rebuild tree removes nodes softly when model uses SoftDeletes trait
35 | * #191: added `whereIsLeaf` and `leaves` method, added `isLeaf` check on node
36 |
37 | ### 4.1.0
38 |
39 | * #75: Converted to trait. Following methods were renamed:
40 | - `appendTo` to `appendToNode`
41 | - `prependTo` to `prependToNode`
42 | - `insertBefore` to `insertBeforeNode`
43 | - `insertAfter` to `insertAfterNode`
44 | - `getNext` to `getNextNode`
45 | - `getPrev` to `getPrevNode`
46 | * #82: Fixing tree now handles case when nodes pointing to non-existing parent
47 | * The number of missing parent is now returned when using `countErrors`
48 | * #79: implemented scoping feature
49 | * #81: moving node now makes model dirty before saving it
50 | * #45: descendants is now a relation that can be eagerly loaded
51 | * `hasChildren` and `hasParent` are now deprecated. Use `has('children')`
52 | `has('parent')` instead
53 | * Default order is no longer applied for `siblings()`, `descendants()`,
54 | `prevNodes`, `nextNodes`
55 | * #50: implemented tree rebuilding feature
56 | * #85: added tree flattening feature
57 |
58 | ### 3.1.1
59 |
60 | * Fixed #42: model becomes dirty before save when parent is changed and using `appendTo`,
61 | `prependTo`, `insertBefore`, `insertAfter`.
62 |
63 | ### 3.1.0
64 |
65 | * Added `fixTree` method for fixing `lft`/`rgt` values based on inheritance
66 | * Dropped support of Laravel < 5.1
67 | * Improved compatibility with different databases
68 |
69 | ### 3.0.0
70 |
71 | * Support Laravel 5.1.9
72 | * Renamed `append` to `appendNode`, `prepend` to `prependNode`
73 | * Renamed `next` to `nextNodes`, `prev` to `prevNodes`
74 | * Renamed `after` to `afterNode`, `before` to `beforeNode`
75 |
76 | ### 2.4.0
77 |
78 | * Added query methods `whereNotDescendantOf`, `orWhereDescendantOf`, `orWhereNotDescendantOf`
79 | * `whereAncestorOf`, `whereDescendantOf` and every method that depends on them can now accept node instance
80 | * Added `Node::getBounds` that returns an array of node bounds that can be used in `whereNodeBetween`
81 |
82 | ### 2.3.0
83 |
84 | * Added `linkNodes` method to `Collection` class
85 |
86 | ### 2.2.0
87 |
88 | * Support Laravel 5
89 |
90 | ### 2.1.0
91 |
92 | * Added `isChildOf`, `isAncestorOf`, `isSiblingOf` methods
93 |
94 | ### 2.0.0
95 |
96 | * Added `insertAfter`, `insertBefore` methods.
97 | * `prepend` and `append` methods now save target model.
98 | * You can now call `refreshNode` to make sure that node has updated structural
99 | data (lft and rgt values).
100 | * The root node is not required now. You can use `saveAsRoot` or `makeRoot` method.
101 | New model is saved as root by default.
102 | * You can now create as many nodes and in any order as you want within single
103 | request.
104 | * Laravel 2 is supported but not required.
105 | * `ancestorsOf` now doesn't include target node into results.
106 | * New constraint methods `hasParent` and `hasChildren`.
107 | * New method `isDescendantOf` that checks if node is a descendant of other node.
108 | * Default order is not applied by default.
109 | * New method `descendantsOf` that allows to get descendants by id of the node.
110 | * Added `countErrors` and `isBroken` methods to check whether the tree is broken.
111 | * `NestedSet::createRoot` has been removed.
112 | * `NestedSet::column` doesn't create a foreign key anymore.
113 |
114 | ### 1.1.0
115 |
116 | * `Collection::toDictionary` is now obsolete. Use `Collection::groupBy`.
117 | * Laravel 4.2 is required
118 |
--------------------------------------------------------------------------------
/README.markdown:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/lazychaser/laravel-nestedset)
2 | [](https://packagist.org/packages/kalnoy/nestedset)
3 | [](https://packagist.org/packages/kalnoy/nestedset)
4 | [](https://packagist.org/packages/kalnoy/nestedset)
5 | [](https://packagist.org/packages/kalnoy/nestedset)
6 |
7 | This is a Laravel package for working with trees in relational databases.
8 |
9 | * **Laravel 12.0** is supported since v6.0.5
10 | * **Laravel 11.0** is supported since v6.0.4
11 | * **Laravel 10.0** is supported since v6.0.2
12 | * **Laravel 9.0** is supported since v6.0.1
13 | * **Laravel 8.0** is supported since v6.0.0
14 | * **Laravel 5.7, 5.8, 6.0, 7.0** is supported since v5
15 | * **Laravel 5.5, 5.6** is supported since v4.3
16 | * **Laravel 5.2, 5.3, 5.4** is supported since v4
17 | * **Laravel 5.1** is supported in v3
18 | * **Laravel 4** is supported in v2
19 |
20 | __Contents:__
21 |
22 | - [Theory](#what-are-nested-sets)
23 | - [Documentation](#documentation)
24 | - [Inserting nodes](#inserting-nodes)
25 | - [Retrieving nodes](#retrieving-nodes)
26 | - [Deleting nodes](#deleting-nodes)
27 | - [Consistency checking & fixing](#checking-consistency)
28 | - [Scoping](#scoping)
29 | - [Requirements](#requirements)
30 | - [Installation](#installation)
31 |
32 | What are nested sets?
33 | ---------------------
34 |
35 | Nested sets or [Nested Set Model](http://en.wikipedia.org/wiki/Nested_set_model) is
36 | a way to effectively store hierarchical data in a relational table. From wikipedia:
37 |
38 | > The nested set model is to number the nodes according to a tree traversal,
39 | > which visits each node twice, assigning numbers in the order of visiting, and
40 | > at both visits. This leaves two numbers for each node, which are stored as two
41 | > attributes. Querying becomes inexpensive: hierarchy membership can be tested by
42 | > comparing these numbers. Updating requires renumbering and is therefore expensive.
43 |
44 | ### Applications
45 |
46 | NSM shows good performance when tree is updated rarely. It is tuned to be fast for
47 | getting related nodes. It'is ideally suited for building multi-depth menu or
48 | categories for shop.
49 |
50 | Documentation
51 | -------------
52 |
53 | Suppose that we have a model `Category`; a `$node` variable is an instance of that model
54 | and the node that we are manipulating. It can be a fresh model or one from database.
55 |
56 | ### Relationships
57 |
58 | Node has following relationships that are fully functional and can be eagerly loaded:
59 |
60 | - Node belongs to `parent`
61 | - Node has many `children`
62 | - Node has many `ancestors`
63 | - Node has many `descendants`
64 |
65 | ### Inserting nodes
66 |
67 | Moving and inserting nodes includes several database queries, so it is
68 | highly recommended to use transactions.
69 |
70 | __IMPORTANT!__ As of v4.2.0 transaction is not automatically started
71 |
72 | Another important note is that __structural manipulations are deferred__ until you
73 | hit `save` on model (some methods implicitly call `save` and return boolean result
74 | of the operation).
75 |
76 | If model is successfully saved it doesn't mean that node was moved. If your application
77 | depends on whether the node has actually changed its position, use `hasMoved` method:
78 |
79 | ```php
80 | if ($node->save()) {
81 | $moved = $node->hasMoved();
82 | }
83 | ```
84 |
85 | #### Creating nodes
86 |
87 | When you simply creating a node, it will be appended to the end of the tree:
88 |
89 | ```php
90 | Category::create($attributes); // Saved as root
91 | ```
92 |
93 | ```php
94 | $node = new Category($attributes);
95 | $node->save(); // Saved as root
96 | ```
97 |
98 | In this case the node is considered a _root_ which means that it doesn't have a parent.
99 |
100 | #### Making a root from existing node
101 |
102 | ```php
103 | // #1 Implicit save
104 | $node->saveAsRoot();
105 |
106 | // #2 Explicit save
107 | $node->makeRoot()->save();
108 | ```
109 |
110 | The node will be appended to the end of the tree.
111 |
112 | #### Appending and prepending to the specified parent
113 |
114 | If you want to make node a child of other node, you can make it last or first child.
115 |
116 | *In following examples, `$parent` is some existing node.*
117 |
118 | There are few ways to append a node:
119 |
120 | ```php
121 | // #1 Using deferred insert
122 | $node->appendToNode($parent)->save();
123 |
124 | // #2 Using parent node
125 | $parent->appendNode($node);
126 |
127 | // #3 Using parent's children relationship
128 | $parent->children()->create($attributes);
129 |
130 | // #5 Using node's parent relationship
131 | $node->parent()->associate($parent)->save();
132 |
133 | // #6 Using the parent attribute
134 | $node->parent_id = $parent->id;
135 | $node->save();
136 |
137 | // #7 Using static method
138 | Category::create($attributes, $parent);
139 | ```
140 |
141 | And only a couple ways to prepend:
142 |
143 | ```php
144 | // #1
145 | $node->prependToNode($parent)->save();
146 |
147 | // #2
148 | $parent->prependNode($node);
149 | ```
150 |
151 | #### Inserting before or after specified node
152 |
153 | You can make `$node` to be a neighbor of the `$neighbor` node using following methods:
154 |
155 | *`$neighbor` must exists, target node can be fresh. If target node exists,
156 | it will be moved to the new position and parent will be changed if it's required.*
157 |
158 | ```php
159 | # Explicit save
160 | $node->afterNode($neighbor)->save();
161 | $node->beforeNode($neighbor)->save();
162 |
163 | # Implicit save
164 | $node->insertAfterNode($neighbor);
165 | $node->insertBeforeNode($neighbor);
166 | ```
167 |
168 | #### Building a tree from array
169 |
170 | When using static method `create` on node, it checks whether attributes contains
171 | `children` key. If it does, it creates more nodes recursively.
172 |
173 | ```php
174 | $node = Category::create([
175 | 'name' => 'Foo',
176 |
177 | 'children' => [
178 | [
179 | 'name' => 'Bar',
180 |
181 | 'children' => [
182 | [ 'name' => 'Baz' ],
183 | ],
184 | ],
185 | ],
186 | ]);
187 | ```
188 |
189 | `$node->children` now contains a list of created child nodes.
190 |
191 | #### Rebuilding a tree from array
192 |
193 | You can easily rebuild a tree. This is useful for mass-changing the structure of
194 | the tree.
195 |
196 | ```php
197 | Category::rebuildTree($data, $delete);
198 | ```
199 |
200 | `$data` is an array of nodes:
201 |
202 | ```php
203 | $data = [
204 | [ 'id' => 1, 'name' => 'foo', 'children' => [ ... ] ],
205 | [ 'name' => 'bar' ],
206 | ];
207 | ```
208 |
209 | There is an id specified for node with the name of `foo` which means that existing
210 | node will be filled and saved. If node is not exists `ModelNotFoundException` is
211 | thrown. Also, this node has `children` specified which is also an array of nodes;
212 | they will be processed in the same manner and saved as children of node `foo`.
213 |
214 | Node `bar` has no primary key specified, so it will be created.
215 |
216 | `$delete` shows whether to delete nodes that are already exists but not present
217 | in `$data`. By default, nodes aren't deleted.
218 |
219 | ##### Rebuilding a subtree
220 |
221 | As of 4.2.8 you can rebuild a subtree:
222 |
223 | ```php
224 | Category::rebuildSubtree($root, $data);
225 | ```
226 |
227 | This constraints tree rebuilding to descendants of `$root` node.
228 |
229 | ### Retrieving nodes
230 |
231 | *In some cases we will use an `$id` variable which is an id of the target node.*
232 |
233 | #### Ancestors and descendants
234 |
235 | Ancestors make a chain of parents to the node. Helpful for displaying breadcrumbs
236 | to the current category.
237 |
238 | Descendants are all nodes in a sub tree, i.e. children of node, children of
239 | children, etc.
240 |
241 | Both ancestors and descendants can be eagerly loaded.
242 |
243 | ```php
244 | // Accessing ancestors
245 | $node->ancestors;
246 |
247 | // Accessing descendants
248 | $node->descendants;
249 | ```
250 |
251 | It is possible to load ancestors and descendants using custom query:
252 |
253 | ```php
254 | $result = Category::ancestorsOf($id);
255 | $result = Category::ancestorsAndSelf($id);
256 | $result = Category::descendantsOf($id);
257 | $result = Category::descendantsAndSelf($id);
258 | ```
259 |
260 | In most cases, you need your ancestors to be ordered by the level:
261 |
262 | ```php
263 | $result = Category::defaultOrder()->ancestorsOf($id);
264 | ```
265 |
266 | A collection of ancestors can be eagerly loaded:
267 |
268 | ```php
269 | $categories = Category::with('ancestors')->paginate(30);
270 |
271 | // in view for breadcrumbs:
272 | @foreach($categories as $i => $category)
273 | {{ $category->ancestors->count() ? implode(' > ', $category->ancestors->pluck('name')->toArray()) : 'Top Level' }}
274 | {{ $category->name }}
275 | @endforeach
276 | ```
277 |
278 | #### Siblings
279 |
280 | Siblings are nodes that have same parent.
281 |
282 | ```php
283 | $result = $node->getSiblings();
284 |
285 | $result = $node->siblings()->get();
286 | ```
287 |
288 | To get only next siblings:
289 |
290 | ```php
291 | // Get a sibling that is immediately after the node
292 | $result = $node->getNextSibling();
293 |
294 | // Get all siblings that are after the node
295 | $result = $node->getNextSiblings();
296 |
297 | // Get all siblings using a query
298 | $result = $node->nextSiblings()->get();
299 | ```
300 |
301 | To get previous siblings:
302 |
303 | ```php
304 | // Get a sibling that is immediately before the node
305 | $result = $node->getPrevSibling();
306 |
307 | // Get all siblings that are before the node
308 | $result = $node->getPrevSiblings();
309 |
310 | // Get all siblings using a query
311 | $result = $node->prevSiblings()->get();
312 | ```
313 |
314 | #### Getting related models from other table
315 |
316 | Imagine that each category `has many` goods. I.e. `HasMany` relationship is established.
317 | How can you get all goods of `$category` and every its descendant? Easy!
318 |
319 | ```php
320 | // Get ids of descendants
321 | $categories = $category->descendants()->pluck('id');
322 |
323 | // Include the id of category itself
324 | $categories[] = $category->getKey();
325 |
326 | // Get goods
327 | $goods = Goods::whereIn('category_id', $categories)->get();
328 | ```
329 |
330 | #### Including node depth
331 |
332 | If you need to know at which level the node is:
333 |
334 | ```php
335 | $result = Category::withDepth()->find($id);
336 |
337 | $depth = $result->depth;
338 | ```
339 |
340 | Root node will be at level 0. Children of root nodes will have a level of 1, etc.
341 |
342 | To get nodes of specified level, you can apply `having` constraint:
343 |
344 | ```php
345 | $result = Category::withDepth()->having('depth', '=', 1)->get();
346 | ```
347 |
348 | __IMPORTANT!__ This will not work in database strict mode
349 |
350 | #### Default order
351 |
352 | All nodes are strictly organized internally. By default, no order is
353 | applied, so nodes may appear in random order and this doesn't affect
354 | displaying a tree. You can order nodes by alphabet or other index.
355 |
356 | But in some cases hierarchical order is essential. It is required for
357 | retrieving ancestors and can be used to order menu items.
358 |
359 | To apply tree order `defaultOrder` method is used:
360 |
361 | ```php
362 | $result = Category::defaultOrder()->get();
363 | ```
364 |
365 | You can get nodes in reversed order:
366 |
367 | ```php
368 | $result = Category::reversed()->get();
369 | ```
370 |
371 | To shift node up or down inside parent to affect default order:
372 |
373 | ```php
374 | $bool = $node->down();
375 | $bool = $node->up();
376 |
377 | // Shift node by 3 siblings
378 | $bool = $node->down(3);
379 | ```
380 |
381 | The result of the operation is boolean value of whether the node has changed its
382 | position.
383 |
384 | #### Constraints
385 |
386 | Various constraints that can be applied to the query builder:
387 |
388 | - __whereIsRoot()__ to get only root nodes;
389 | - __hasParent()__ to get non-root nodes;
390 | - __whereIsLeaf()__ to get only leaves;
391 | - __hasChildren()__ to get non-leave nodes;
392 | - __whereIsAfter($id)__ to get every node (not just siblings) that are after a node
393 | with specified id;
394 | - __whereIsBefore($id)__ to get every node that is before a node with specified id.
395 |
396 | Descendants constraints:
397 |
398 | ```php
399 | $result = Category::whereDescendantOf($node)->get();
400 | $result = Category::whereNotDescendantOf($node)->get();
401 | $result = Category::orWhereDescendantOf($node)->get();
402 | $result = Category::orWhereNotDescendantOf($node)->get();
403 | $result = Category::whereDescendantAndSelf($id)->get();
404 |
405 | // Include target node into result set
406 | $result = Category::whereDescendantOrSelf($node)->get();
407 | ```
408 |
409 | Ancestor constraints:
410 |
411 | ```php
412 | $result = Category::whereAncestorOf($node)->get();
413 | $result = Category::whereAncestorOrSelf($id)->get();
414 | ```
415 |
416 | `$node` can be either a primary key of the model or model instance.
417 |
418 | #### Building a tree
419 |
420 | After getting a set of nodes, you can convert it to tree. For example:
421 |
422 | ```php
423 | $tree = Category::get()->toTree();
424 | ```
425 |
426 | This will fill `parent` and `children` relationships on every node in the set and
427 | you can render a tree using recursive algorithm:
428 |
429 | ```php
430 | $nodes = Category::get()->toTree();
431 |
432 | $traverse = function ($categories, $prefix = '-') use (&$traverse) {
433 | foreach ($categories as $category) {
434 | echo PHP_EOL.$prefix.' '.$category->name;
435 |
436 | $traverse($category->children, $prefix.'-');
437 | }
438 | };
439 |
440 | $traverse($nodes);
441 | ```
442 |
443 | This will output something like this:
444 |
445 | ```
446 | - Root
447 | -- Child 1
448 | --- Sub child 1
449 | -- Child 2
450 | - Another root
451 | ```
452 |
453 | ##### Building flat tree
454 |
455 | Also, you can build a flat tree: a list of nodes where child nodes are immediately
456 | after parent node. This is helpful when you get nodes with custom order
457 | (i.e. alphabetically) and don't want to use recursion to iterate over your nodes.
458 |
459 | ```php
460 | $nodes = Category::get()->toFlatTree();
461 | ```
462 |
463 | Previous example will output:
464 |
465 | ```
466 | Root
467 | Child 1
468 | Sub child 1
469 | Child 2
470 | Another root
471 | ```
472 |
473 | ##### Getting a subtree
474 |
475 | Sometimes you don't need whole tree to be loaded and just some subtree of specific node.
476 | It is show in following example:
477 |
478 | ```php
479 | $root = Category::descendantsAndSelf($rootId)->toTree()->first();
480 | ```
481 |
482 | In a single query we are getting a root of a subtree and all of its
483 | descendants that are accessible via `children` relation.
484 |
485 | If you don't need `$root` node itself, do following instead:
486 |
487 | ```php
488 | $tree = Category::descendantsOf($rootId)->toTree($rootId);
489 | ```
490 |
491 | ### Deleting nodes
492 |
493 | To delete a node:
494 |
495 | ```php
496 | $node->delete();
497 | ```
498 |
499 | **IMPORTANT!** Any descendant that node has will also be deleted!
500 |
501 | **IMPORTANT!** Nodes are required to be deleted as models, **don't** try do delete them using a query like so:
502 |
503 | ```php
504 | Category::where('id', '=', $id)->delete();
505 | ```
506 |
507 | This will break the tree!
508 |
509 | `SoftDeletes` trait is supported, also on model level.
510 |
511 | ### Helper methods
512 |
513 | To check if node is a descendant of other node:
514 |
515 | ```php
516 | $bool = $node->isDescendantOf($parent);
517 | ```
518 |
519 | To check whether the node is a root:
520 |
521 | ```php
522 | $bool = $node->isRoot();
523 | ```
524 |
525 | Other checks:
526 |
527 | * `$node->isChildOf($other);`
528 | * `$node->isAncestorOf($other);`
529 | * `$node->isSiblingOf($other);`
530 | * `$node->isLeaf()`
531 |
532 | ### Checking consistency
533 |
534 | You can check whether a tree is broken (i.e. has some structural errors):
535 |
536 | ```php
537 | $bool = Category::isBroken();
538 | ```
539 |
540 | It is possible to get error statistics:
541 |
542 | ```php
543 | $data = Category::countErrors();
544 | ```
545 |
546 | It will return an array with following keys:
547 |
548 | - `oddness` -- the number of nodes that have wrong set of `lft` and `rgt` values
549 | - `duplicates` -- the number of nodes that have same `lft` or `rgt` values
550 | - `wrong_parent` -- the number of nodes that have invalid `parent_id` value that
551 | doesn't correspond to `lft` and `rgt` values
552 | - `missing_parent` -- the number of nodes that have `parent_id` pointing to
553 | node that doesn't exists
554 |
555 | #### Fixing tree
556 |
557 | Since v3.1 tree can now be fixed. Using inheritance info from `parent_id` column,
558 | proper `_lft` and `_rgt` values are set for every node.
559 |
560 | ```php
561 | Node::fixTree();
562 | ```
563 |
564 | ### Scoping
565 |
566 | Imagine you have `Menu` model and `MenuItems`. There is a one-to-many relationship
567 | set up between these models. `MenuItem` has `menu_id` attribute for joining models
568 | together. `MenuItem` incorporates nested sets. It is obvious that you would want to
569 | process each tree separately based on `menu_id` attribute. In order to do so, you
570 | need to specify this attribute as scope attribute:
571 |
572 | ```php
573 | protected function getScopeAttributes()
574 | {
575 | return [ 'menu_id' ];
576 | }
577 | ```
578 |
579 | But now, in order to execute some custom query, you need to provide attributes
580 | that are used for scoping:
581 |
582 | ```php
583 | MenuItem::scoped([ 'menu_id' => 5 ])->withDepth()->get(); // OK
584 | MenuItem::descendantsOf($id)->get(); // WRONG: returns nodes from other scope
585 | MenuItem::scoped([ 'menu_id' => 5 ])->fixTree(); // OK
586 | ```
587 |
588 | When requesting nodes using model instance, scopes applied automatically based
589 | on the attributes of that model:
590 |
591 | ```php
592 | $node = MenuItem::findOrFail($id);
593 |
594 | $node->siblings()->withDepth()->get(); // OK
595 | ```
596 |
597 | To get scoped query builder using instance:
598 |
599 | ```php
600 | $node->newScopedQuery();
601 | ```
602 |
603 | #### Scoping and eager loading
604 |
605 | Always use scoped query when eager loading:
606 |
607 | ```php
608 | MenuItem::scoped([ 'menu_id' => 5])->with('descendants')->findOrFail($id); // OK
609 | MenuItem::with('descendants')->findOrFail($id); // WRONG
610 | ```
611 |
612 | Requirements
613 | ------------
614 |
615 | - PHP >= 5.4
616 | - Laravel >= 4.1
617 |
618 | It is highly suggested to use database that supports transactions (like MySql's InnoDb)
619 | to secure a tree from possible corruption.
620 |
621 | Installation
622 | ------------
623 |
624 | To install the package, in terminal:
625 |
626 | ```
627 | composer require kalnoy/nestedset
628 | ```
629 |
630 | ### Setting up from scratch
631 |
632 | #### The schema
633 |
634 | For Laravel 5.5 and above users:
635 |
636 | ```php
637 | Schema::create('table', function (Blueprint $table) {
638 | ...
639 | $table->nestedSet();
640 | });
641 |
642 | // To drop columns
643 | Schema::table('table', function (Blueprint $table) {
644 | $table->dropNestedSet();
645 | });
646 | ```
647 |
648 | For prior Laravel versions:
649 |
650 | ```php
651 | ...
652 | use Kalnoy\Nestedset\NestedSet;
653 |
654 | Schema::create('table', function (Blueprint $table) {
655 | ...
656 | NestedSet::columns($table);
657 | });
658 | ```
659 |
660 | To drop columns:
661 |
662 | ```php
663 | ...
664 | use Kalnoy\Nestedset\NestedSet;
665 |
666 | Schema::table('table', function (Blueprint $table) {
667 | NestedSet::dropColumns($table);
668 | });
669 | ```
670 |
671 | #### The model
672 |
673 | Your model should use `Kalnoy\Nestedset\NodeTrait` trait to enable nested sets:
674 |
675 | ```php
676 | use Kalnoy\Nestedset\NodeTrait;
677 |
678 | class Foo extends Model {
679 | use NodeTrait;
680 | }
681 | ```
682 |
683 | ### Migrating existing data
684 |
685 | #### Migrating from other nested set extension
686 |
687 | If your previous extension used different set of columns, you just need to override
688 | following methods on your model class:
689 |
690 | ```php
691 | public function getLftName()
692 | {
693 | return 'left';
694 | }
695 |
696 | public function getRgtName()
697 | {
698 | return 'right';
699 | }
700 |
701 | public function getParentIdName()
702 | {
703 | return 'parent';
704 | }
705 |
706 | // Specify parent id attribute mutator
707 | public function setParentAttribute($value)
708 | {
709 | $this->setParentIdAttribute($value);
710 | }
711 | ```
712 |
713 | #### Migrating from basic parentage info
714 |
715 | If your tree contains `parent_id` info, you need to add two columns to your schema:
716 |
717 | ```php
718 | $table->unsignedInteger('_lft');
719 | $table->unsignedInteger('_rgt');
720 | ```
721 |
722 | After [setting up your model](#the-model) you only need to fix the tree to fill
723 | `_lft` and `_rgt` columns:
724 |
725 | ```php
726 | MyModel::fixTree();
727 | ```
728 |
729 | License
730 | =======
731 |
732 | Copyright (c) 2017 Alexander Kalnoy
733 |
734 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
735 |
736 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
737 |
738 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
739 |
--------------------------------------------------------------------------------
/TODO.markdown:
--------------------------------------------------------------------------------
1 | * Convert query builder to extension
2 | * Implement tree update algorithm
--------------------------------------------------------------------------------
/UPGRADE.markdown:
--------------------------------------------------------------------------------
1 | ### Upgrading from 4.0 to 4.1
2 |
3 | Nested sets feature has been moved to trait `Kalnoy\Nestedset\NodeTrait`, but
4 | old `Kalnoy\Nestedset\Node` class is still available.
5 |
6 | Some methods on trait were renamed (see changelog), but still available on legacy
7 | node class.
8 |
9 | Default order is no longer applied for `siblings()`, `descendants()`,
10 | `prevNodes`, `nextNodes`.
11 |
12 | ### Upgrading to 3.0
13 |
14 | Some methods were renamed, see changelog for more details.
15 |
16 | ### Upgrading to 2.0
17 |
18 | Calling `$parent->append($node)` and `$parent->prepend($node)` now automatically
19 | saves `$node`. Those functions returns whether the node was saved.
20 |
21 | `ancestorsOf` now return ancestors only, not including target node.
22 |
23 | Default order is not applied automatically, so if you need nodes to be in tree-order
24 | you should call `defaultOrder` on the query.
25 |
26 | Since root node is not required now, `NestedSet::createRoot` method has been removed.
27 |
28 | `NestedSet::columns` now doesn't create a foreign key for a `parent_id` column.
29 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "kalnoy/nestedset",
3 | "description": "Nested Set Model for Laravel 5.7 and up",
4 | "keywords": [
5 | "laravel",
6 | "nested sets",
7 | "nsm",
8 | "database",
9 | "hierarchy"
10 | ],
11 | "license": "MIT",
12 | "authors": [
13 | {
14 | "name": "Alexander Kalnoy",
15 | "email": "lazychaser@gmail.com"
16 | }
17 | ],
18 | "require": {
19 | "php": "^8.0",
20 | "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
21 | "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
22 | "illuminate/events": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0"
23 | },
24 | "autoload": {
25 | "psr-4": {
26 | "Kalnoy\\Nestedset\\": "src/"
27 | }
28 | },
29 | "require-dev": {
30 | "phpunit/phpunit": "7.*|8.*|9.*|^10.5"
31 | },
32 | "minimum-stability": "dev",
33 | "prefer-stable": true,
34 | "extra": {
35 | "branch-alias": {
36 | "dev-master": "v5.0.x-dev"
37 | },
38 | "laravel": {
39 | "providers": [
40 | "Kalnoy\\Nestedset\\NestedSetServiceProvider"
41 | ]
42 | }
43 | },
44 | "scripts": {
45 | "test": [
46 | "@php ./vendor/bin/phpunit"
47 | ]
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/phpunit.php:
--------------------------------------------------------------------------------
1 | addConnection([ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'prfx_' ]);
7 | $capsule->setEventDispatcher(new \Illuminate\Events\Dispatcher);
8 | $capsule->bootEloquent();
9 | $capsule->setAsGlobal();
10 |
11 | include __DIR__.'/tests/models/Category.php';
12 | include __DIR__.'/tests/models/MenuItem.php';
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ./tests/
6 | ./tests/data
7 | ./tests/models
8 |
9 |
10 |
11 |
12 | ./src
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/AncestorsRelation.php:
--------------------------------------------------------------------------------
1 | query->whereAncestorOf($this->parent)
19 | ->applyNestedSetScope();
20 | }
21 |
22 | /**
23 | * @param Model $model
24 | * @param $related
25 | *
26 | * @return bool
27 | */
28 | protected function matches(Model $model, $related)
29 | {
30 | return $related->isAncestorOf($model);
31 | }
32 |
33 | /**
34 | * @param QueryBuilder $query
35 | * @param Model $model
36 | *
37 | * @return void
38 | */
39 | protected function addEagerConstraint($query, $model)
40 | {
41 | $query->orWhereAncestorOf($model);
42 | }
43 |
44 | /**
45 | * @param $hash
46 | * @param $table
47 | * @param $lft
48 | * @param $rgt
49 | *
50 | * @return string
51 | */
52 | protected function relationExistenceCondition($hash, $table, $lft, $rgt)
53 | {
54 | $key = $this->getBaseQuery()->getGrammar()->wrap($this->parent->getKeyName());
55 |
56 | return "{$table}.{$rgt} between {$hash}.{$lft} and {$hash}.{$rgt} and $table.$key <> $hash.$key";
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/BaseRelation.php:
--------------------------------------------------------------------------------
1 | getParent()->replicate()->newScopedQuery()->select($columns);
83 |
84 | $table = $query->getModel()->getTable();
85 |
86 | $query->from($table.' as '.$hash = $this->getRelationCountHash());
87 |
88 | $query->getModel()->setTable($hash);
89 |
90 | $grammar = $query->getQuery()->getGrammar();
91 |
92 | $condition = $this->relationExistenceCondition(
93 | $grammar->wrapTable($hash),
94 | $grammar->wrapTable($table),
95 | $grammar->wrap($this->parent->getLftName()),
96 | $grammar->wrap($this->parent->getRgtName()));
97 |
98 | return $query->whereRaw($condition);
99 | }
100 |
101 | /**
102 | * Initialize the relation on a set of models.
103 | *
104 | * @param array $models
105 | * @param string $relation
106 | *
107 | * @return array
108 | */
109 | public function initRelation(array $models, $relation)
110 | {
111 | return $models;
112 | }
113 |
114 | /**
115 | * Get a relationship join table hash.
116 | *
117 | * @param bool $incrementJoinCount
118 | * @return string
119 | */
120 | public function getRelationCountHash($incrementJoinCount = true)
121 | {
122 | return 'nested_set_'.($incrementJoinCount ? static::$selfJoinCount++ : static::$selfJoinCount);
123 | }
124 |
125 | /**
126 | * Get the results of the relationship.
127 | *
128 | * @return mixed
129 | */
130 | public function getResults()
131 | {
132 | return $this->query->get();
133 | }
134 |
135 | /**
136 | * Set the constraints for an eager load of the relation.
137 | *
138 | * @param array $models
139 | *
140 | * @return void
141 | */
142 | public function addEagerConstraints(array $models)
143 | {
144 | $this->query->whereNested(function (Builder $inner) use ($models) {
145 | // We will use this query in order to apply constraints to the
146 | // base query builder
147 | $outer = $this->parent->newQuery()->setQuery($inner);
148 |
149 | foreach ($models as $model) {
150 | $this->addEagerConstraint($outer, $model);
151 | }
152 | });
153 | }
154 |
155 | /**
156 | * Match the eagerly loaded results to their parents.
157 | *
158 | * @param array $models
159 | * @param EloquentCollection $results
160 | * @param string $relation
161 | *
162 | * @return array
163 | */
164 | public function match(array $models, EloquentCollection $results, $relation)
165 | {
166 | foreach ($models as $model) {
167 | $related = $this->matchForModel($model, $results);
168 |
169 | $model->setRelation($relation, $related);
170 | }
171 |
172 | return $models;
173 | }
174 |
175 | /**
176 | * @param Model $model
177 | * @param EloquentCollection $results
178 | *
179 | * @return Collection
180 | */
181 | protected function matchForModel(Model $model, EloquentCollection $results)
182 | {
183 | $result = $this->related->newCollection();
184 |
185 | foreach ($results as $related) {
186 | if ($this->matches($model, $related)) {
187 | $result->push($related);
188 | }
189 | }
190 |
191 | return $result;
192 | }
193 |
194 | /**
195 | * Get the plain foreign key.
196 | *
197 | * @return mixed
198 | */
199 | public function getForeignKeyName()
200 | {
201 | // Return a stub value for relation
202 | // resolvers which need this function.
203 | return NestedSet::PARENT_ID;
204 | }
205 | }
206 |
--------------------------------------------------------------------------------
/src/Collection.php:
--------------------------------------------------------------------------------
1 | isEmpty()) return $this;
20 |
21 | $groupedNodes = $this->groupBy($this->first()->getParentIdName());
22 |
23 | /** @var NodeTrait|Model $node */
24 | foreach ($this->items as $node) {
25 | if ( ! $node->getParentId()) {
26 | $node->setRelation('parent', null);
27 | }
28 |
29 | $children = $groupedNodes->get($node->getKey(), [ ]);
30 |
31 | /** @var Model|NodeTrait $child */
32 | foreach ($children as $child) {
33 | $child->setRelation('parent', $node);
34 | }
35 |
36 | $node->setRelation('children', BaseCollection::make($children));
37 | }
38 |
39 | return $this;
40 | }
41 |
42 | /**
43 | * Build a tree from a list of nodes. Each item will have set children relation.
44 | *
45 | * To successfully build tree "id", "_lft" and "parent_id" keys must present.
46 | *
47 | * If `$root` is provided, the tree will contain only descendants of that node.
48 | *
49 | * @param mixed $root
50 | *
51 | * @return Collection
52 | */
53 | public function toTree($root = false)
54 | {
55 | if ($this->isEmpty()) {
56 | return new static;
57 | }
58 |
59 | $this->linkNodes();
60 |
61 | $items = [ ];
62 |
63 | $root = $this->getRootNodeId($root);
64 |
65 | /** @var Model|NodeTrait $node */
66 | foreach ($this->items as $node) {
67 | if ($node->getParentId() == $root) {
68 | $items[] = $node;
69 | }
70 | }
71 |
72 | return new static($items);
73 | }
74 |
75 | /**
76 | * @param mixed $root
77 | *
78 | * @return int
79 | */
80 | protected function getRootNodeId($root = false)
81 | {
82 | if (NestedSet::isNode($root)) {
83 | return $root->getKey();
84 | }
85 |
86 | if ($root !== false) {
87 | return $root;
88 | }
89 |
90 | // If root node is not specified we take parent id of node with
91 | // least lft value as root node id.
92 | $leastValue = null;
93 |
94 | /** @var Model|NodeTrait $node */
95 | foreach ($this->items as $node) {
96 | if ($leastValue === null || $node->getLft() < $leastValue) {
97 | $leastValue = $node->getLft();
98 | $root = $node->getParentId();
99 | }
100 | }
101 |
102 | return $root;
103 | }
104 |
105 | /**
106 | * Build a list of nodes that retain the order that they were pulled from
107 | * the database.
108 | *
109 | * @param bool $root
110 | *
111 | * @return static
112 | */
113 | public function toFlatTree($root = false)
114 | {
115 | $result = new static;
116 |
117 | if ($this->isEmpty()) return $result;
118 |
119 | $groupedNodes = $this->groupBy($this->first()->getParentIdName());
120 |
121 | return $result->flattenTree($groupedNodes, $this->getRootNodeId($root));
122 | }
123 |
124 | /**
125 | * Flatten a tree into a non recursive array.
126 | *
127 | * @param Collection $groupedNodes
128 | * @param mixed $parentId
129 | *
130 | * @return $this
131 | */
132 | protected function flattenTree(self $groupedNodes, $parentId)
133 | {
134 | foreach ($groupedNodes->get($parentId, []) as $node) {
135 | $this->push($node);
136 |
137 | $this->flattenTree($groupedNodes, $node->getKey());
138 | }
139 |
140 | return $this;
141 | }
142 |
143 | }
--------------------------------------------------------------------------------
/src/DescendantsRelation.php:
--------------------------------------------------------------------------------
1 | query->whereDescendantOf($this->parent)
21 | ->applyNestedSetScope();
22 | }
23 |
24 | /**
25 | * @param QueryBuilder $query
26 | * @param Model $model
27 | */
28 | protected function addEagerConstraint($query, $model)
29 | {
30 | $query->orWhereDescendantOf($model);
31 | }
32 |
33 | /**
34 | * @param Model $model
35 | * @param $related
36 | *
37 | * @return mixed
38 | */
39 | protected function matches(Model $model, $related)
40 | {
41 | return $related->isDescendantOf($model);
42 | }
43 |
44 | /**
45 | * @param $hash
46 | * @param $table
47 | * @param $lft
48 | * @param $rgt
49 | *
50 | * @return string
51 | */
52 | protected function relationExistenceCondition($hash, $table, $lft, $rgt)
53 | {
54 | return "{$hash}.{$lft} between {$table}.{$lft} + 1 and {$table}.{$rgt}";
55 | }
56 | }
--------------------------------------------------------------------------------
/src/NestedSet.php:
--------------------------------------------------------------------------------
1 | unsignedInteger(self::LFT)->default(0);
42 | $table->unsignedInteger(self::RGT)->default(0);
43 | $table->unsignedInteger(self::PARENT_ID)->nullable();
44 |
45 | $table->index(static::getDefaultColumns());
46 | }
47 |
48 | /**
49 | * Drop NestedSet columns.
50 | *
51 | * @param \Illuminate\Database\Schema\Blueprint $table
52 | */
53 | public static function dropColumns(Blueprint $table)
54 | {
55 | $columns = static::getDefaultColumns();
56 |
57 | $table->dropIndex($columns);
58 | $table->dropColumn($columns);
59 | }
60 |
61 | /**
62 | * Get a list of default columns.
63 | *
64 | * @return array
65 | */
66 | public static function getDefaultColumns()
67 | {
68 | return [ static::LFT, static::RGT, static::PARENT_ID ];
69 | }
70 |
71 | /**
72 | * Replaces instanceof calls for this trait.
73 | *
74 | * @param mixed $node
75 | *
76 | * @return bool
77 | */
78 | public static function isNode($node)
79 | {
80 | return is_object($node) && in_array(NodeTrait::class, (array)$node);
81 | }
82 |
83 | }
--------------------------------------------------------------------------------
/src/NestedSetServiceProvider.php:
--------------------------------------------------------------------------------
1 | callPendingAction();
48 | });
49 |
50 | static::deleting(function ($model) {
51 | // We will need fresh data to delete node safely
52 | $model->refreshNode();
53 | });
54 |
55 | static::deleted(function ($model) {
56 | $model->deleteDescendants();
57 | });
58 |
59 | if (static::usesSoftDelete()) {
60 | static::restoring(function ($model) {
61 | static::$deletedAt = $model->{$model->getDeletedAtColumn()};
62 | });
63 |
64 | static::restored(function ($model) {
65 | $model->restoreDescendants(static::$deletedAt);
66 | });
67 | }
68 | }
69 |
70 | /**
71 | * Set an action.
72 | *
73 | * @param string $action
74 | *
75 | * @return $this
76 | */
77 | protected function setNodeAction($action)
78 | {
79 | $this->pending = func_get_args();
80 |
81 | return $this;
82 | }
83 |
84 | /**
85 | * Call pending action.
86 | */
87 | protected function callPendingAction()
88 | {
89 | $this->moved = false;
90 |
91 | if ( ! $this->pending && ! $this->exists) {
92 | $this->makeRoot();
93 | }
94 |
95 | if ( ! $this->pending) return;
96 |
97 | $method = 'action'.ucfirst(array_shift($this->pending));
98 | $parameters = $this->pending;
99 |
100 | $this->pending = null;
101 |
102 | $this->moved = call_user_func_array([ $this, $method ], $parameters);
103 | }
104 |
105 | /**
106 | * @return bool
107 | */
108 | public static function usesSoftDelete()
109 | {
110 | static $softDelete;
111 |
112 | if (is_null($softDelete)) {
113 | $instance = new static;
114 |
115 | return $softDelete = method_exists($instance, 'bootSoftDeletes');
116 | }
117 |
118 | return $softDelete;
119 | }
120 |
121 | /**
122 | * @return bool
123 | */
124 | protected function actionRaw()
125 | {
126 | return true;
127 | }
128 |
129 | /**
130 | * Make a root node.
131 | */
132 | protected function actionRoot()
133 | {
134 | // Simplest case that do not affect other nodes.
135 | if ( ! $this->exists) {
136 | $cut = $this->getLowerBound() + 1;
137 |
138 | $this->setLft($cut);
139 | $this->setRgt($cut + 1);
140 |
141 | return true;
142 | }
143 |
144 | return $this->insertAt($this->getLowerBound() + 1);
145 | }
146 |
147 | /**
148 | * Get the lower bound.
149 | *
150 | * @return int
151 | */
152 | protected function getLowerBound()
153 | {
154 | return (int)$this->newNestedSetQuery()->max($this->getRgtName());
155 | }
156 |
157 | /**
158 | * Append or prepend a node to the parent.
159 | *
160 | * @param self $parent
161 | * @param bool $prepend
162 | *
163 | * @return bool
164 | */
165 | protected function actionAppendOrPrepend(self $parent, $prepend = false)
166 | {
167 | $parent->refreshNode();
168 |
169 | $cut = $prepend ? $parent->getLft() + 1 : $parent->getRgt();
170 |
171 | if ( ! $this->insertAt($cut)) {
172 | return false;
173 | }
174 |
175 | $parent->refreshNode();
176 |
177 | return true;
178 | }
179 |
180 | /**
181 | * Apply parent model.
182 | *
183 | * @param Model|null $value
184 | *
185 | * @return $this
186 | */
187 | protected function setParent($value)
188 | {
189 | $this->setParentId($value ? $value->getKey() : null)
190 | ->setRelation('parent', $value);
191 |
192 | return $this;
193 | }
194 |
195 | /**
196 | * Insert node before or after another node.
197 | *
198 | * @param self $node
199 | * @param bool $after
200 | *
201 | * @return bool
202 | */
203 | protected function actionBeforeOrAfter(self $node, $after = false)
204 | {
205 | $node->refreshNode();
206 |
207 | return $this->insertAt($after ? $node->getRgt() + 1 : $node->getLft());
208 | }
209 |
210 | /**
211 | * Refresh node's crucial attributes.
212 | */
213 | public function refreshNode()
214 | {
215 | if ( ! $this->exists || static::$actionsPerformed === 0) return;
216 |
217 | $attributes = $this->newNestedSetQuery()->getNodeData($this->getKey());
218 |
219 | $this->attributes = array_merge($this->attributes, $attributes);
220 | // $this->original = array_merge($this->original, $attributes);
221 | }
222 |
223 | /**
224 | * Relation to the parent.
225 | *
226 | * @return BelongsTo
227 | */
228 | public function parent()
229 | {
230 | return $this->belongsTo(get_class($this), $this->getParentIdName())
231 | ->setModel($this);
232 | }
233 |
234 | /**
235 | * Relation to children.
236 | *
237 | * @return HasMany
238 | */
239 | public function children()
240 | {
241 | return $this->hasMany(get_class($this), $this->getParentIdName())
242 | ->setModel($this);
243 | }
244 |
245 | /**
246 | * Get query for descendants of the node.
247 | *
248 | * @return DescendantsRelation
249 | */
250 | public function descendants()
251 | {
252 | return new DescendantsRelation($this->newQuery(), $this);
253 | }
254 |
255 | /**
256 | * Get query for siblings of the node.
257 | *
258 | * @return QueryBuilder
259 | */
260 | public function siblings()
261 | {
262 | return $this->newScopedQuery()
263 | ->where($this->getKeyName(), '<>', $this->getKey())
264 | ->where($this->getParentIdName(), '=', $this->getParentId());
265 | }
266 |
267 | /**
268 | * Get the node siblings and the node itself.
269 | *
270 | * @return \Kalnoy\Nestedset\QueryBuilder
271 | */
272 | public function siblingsAndSelf()
273 | {
274 | return $this->newScopedQuery()
275 | ->where($this->getParentIdName(), '=', $this->getParentId());
276 | }
277 |
278 | /**
279 | * Get query for the node siblings and the node itself.
280 | *
281 | * @param array $columns
282 | *
283 | * @return \Illuminate\Database\Eloquent\Collection
284 | */
285 | public function getSiblingsAndSelf(array $columns = [ '*' ])
286 | {
287 | return $this->siblingsAndSelf()->get($columns);
288 | }
289 |
290 | /**
291 | * Get query for siblings after the node.
292 | *
293 | * @return QueryBuilder
294 | */
295 | public function nextSiblings()
296 | {
297 | return $this->nextNodes()
298 | ->where($this->getParentIdName(), '=', $this->getParentId());
299 | }
300 |
301 | /**
302 | * Get query for siblings before the node.
303 | *
304 | * @return QueryBuilder
305 | */
306 | public function prevSiblings()
307 | {
308 | return $this->prevNodes()
309 | ->where($this->getParentIdName(), '=', $this->getParentId());
310 | }
311 |
312 | /**
313 | * Get query for nodes after current node.
314 | *
315 | * @return QueryBuilder
316 | */
317 | public function nextNodes()
318 | {
319 | return $this->newScopedQuery()
320 | ->where($this->getLftName(), '>', $this->getLft());
321 | }
322 |
323 | /**
324 | * Get query for nodes before current node in reversed order.
325 | *
326 | * @return QueryBuilder
327 | */
328 | public function prevNodes()
329 | {
330 | return $this->newScopedQuery()
331 | ->where($this->getLftName(), '<', $this->getLft());
332 | }
333 |
334 | /**
335 | * Get query ancestors of the node.
336 | *
337 | * @return AncestorsRelation
338 | */
339 | public function ancestors()
340 | {
341 | return new AncestorsRelation($this->newQuery(), $this);
342 | }
343 |
344 | /**
345 | * Make this node a root node.
346 | *
347 | * @return $this
348 | */
349 | public function makeRoot()
350 | {
351 | $this->setParent(null)->dirtyBounds();
352 |
353 | return $this->setNodeAction('root');
354 | }
355 |
356 | /**
357 | * Save node as root.
358 | *
359 | * @return bool
360 | */
361 | public function saveAsRoot()
362 | {
363 | if ($this->exists && $this->isRoot()) {
364 | return $this->save();
365 | }
366 |
367 | return $this->makeRoot()->save();
368 | }
369 |
370 | /**
371 | * Append and save a node.
372 | *
373 | * @param self $node
374 | *
375 | * @return bool
376 | */
377 | public function appendNode(self $node)
378 | {
379 | return $node->appendToNode($this)->save();
380 | }
381 |
382 | /**
383 | * Prepend and save a node.
384 | *
385 | * @param self $node
386 | *
387 | * @return bool
388 | */
389 | public function prependNode(self $node)
390 | {
391 | return $node->prependToNode($this)->save();
392 | }
393 |
394 | /**
395 | * Append a node to the new parent.
396 | *
397 | * @param self $parent
398 | *
399 | * @return $this
400 | */
401 | public function appendToNode(self $parent)
402 | {
403 | return $this->appendOrPrependTo($parent);
404 | }
405 |
406 | /**
407 | * Prepend a node to the new parent.
408 | *
409 | * @param self $parent
410 | *
411 | * @return $this
412 | */
413 | public function prependToNode(self $parent)
414 | {
415 | return $this->appendOrPrependTo($parent, true);
416 | }
417 |
418 | /**
419 | * @param self $parent
420 | * @param bool $prepend
421 | *
422 | * @return self
423 | */
424 | public function appendOrPrependTo(self $parent, $prepend = false)
425 | {
426 | $this->assertNodeExists($parent)
427 | ->assertNotDescendant($parent)
428 | ->assertSameScope($parent);
429 |
430 | $this->setParent($parent)->dirtyBounds();
431 |
432 | return $this->setNodeAction('appendOrPrepend', $parent, $prepend);
433 | }
434 |
435 | /**
436 | * Insert self after a node.
437 | *
438 | * @param self $node
439 | *
440 | * @return $this
441 | */
442 | public function afterNode(self $node)
443 | {
444 | return $this->beforeOrAfterNode($node, true);
445 | }
446 |
447 | /**
448 | * Insert self before node.
449 | *
450 | * @param self $node
451 | *
452 | * @return $this
453 | */
454 | public function beforeNode(self $node)
455 | {
456 | return $this->beforeOrAfterNode($node);
457 | }
458 |
459 | /**
460 | * @param self $node
461 | * @param bool $after
462 | *
463 | * @return self
464 | */
465 | public function beforeOrAfterNode(self $node, $after = false)
466 | {
467 | $this->assertNodeExists($node)
468 | ->assertNotDescendant($node)
469 | ->assertSameScope($node);
470 |
471 | if ( ! $this->isSiblingOf($node)) {
472 | $this->setParent($node->getRelationValue('parent'));
473 | }
474 |
475 | $this->dirtyBounds();
476 |
477 | return $this->setNodeAction('beforeOrAfter', $node, $after);
478 | }
479 |
480 | /**
481 | * Insert self after a node and save.
482 | *
483 | * @param self $node
484 | *
485 | * @return bool
486 | */
487 | public function insertAfterNode(self $node)
488 | {
489 | return $this->afterNode($node)->save();
490 | }
491 |
492 | /**
493 | * Insert self before a node and save.
494 | *
495 | * @param self $node
496 | *
497 | * @return bool
498 | */
499 | public function insertBeforeNode(self $node)
500 | {
501 | if ( ! $this->beforeNode($node)->save()) return false;
502 |
503 | // We'll update the target node since it will be moved
504 | $node->refreshNode();
505 |
506 | return true;
507 | }
508 |
509 | /**
510 | * @param $lft
511 | * @param $rgt
512 | * @param $parentId
513 | *
514 | * @return $this
515 | */
516 | public function rawNode($lft, $rgt, $parentId)
517 | {
518 | $this->setLft($lft)->setRgt($rgt)->setParentId($parentId);
519 |
520 | return $this->setNodeAction('raw');
521 | }
522 |
523 | /**
524 | * Move node up given amount of positions.
525 | *
526 | * @param int $amount
527 | *
528 | * @return bool
529 | */
530 | public function up($amount = 1)
531 | {
532 | $sibling = $this->prevSiblings()
533 | ->defaultOrder('desc')
534 | ->skip($amount - 1)
535 | ->first();
536 |
537 | if ( ! $sibling) return false;
538 |
539 | return $this->insertBeforeNode($sibling);
540 | }
541 |
542 | /**
543 | * Move node down given amount of positions.
544 | *
545 | * @param int $amount
546 | *
547 | * @return bool
548 | */
549 | public function down($amount = 1)
550 | {
551 | $sibling = $this->nextSiblings()
552 | ->defaultOrder()
553 | ->skip($amount - 1)
554 | ->first();
555 |
556 | if ( ! $sibling) return false;
557 |
558 | return $this->insertAfterNode($sibling);
559 | }
560 |
561 | /**
562 | * Insert node at specific position.
563 | *
564 | * @param int $position
565 | *
566 | * @return bool
567 | */
568 | protected function insertAt($position)
569 | {
570 | ++static::$actionsPerformed;
571 |
572 | $result = $this->exists
573 | ? $this->moveNode($position)
574 | : $this->insertNode($position);
575 |
576 | return $result;
577 | }
578 |
579 | /**
580 | * Move a node to the new position.
581 | *
582 | * @since 2.0
583 | *
584 | * @param int $position
585 | *
586 | * @return int
587 | */
588 | protected function moveNode($position)
589 | {
590 | $updated = $this->newNestedSetQuery()
591 | ->moveNode($this->getKey(), $position) > 0;
592 |
593 | if ($updated) $this->refreshNode();
594 |
595 | return $updated;
596 | }
597 |
598 | /**
599 | * Insert new node at specified position.
600 | *
601 | * @since 2.0
602 | *
603 | * @param int $position
604 | *
605 | * @return bool
606 | */
607 | protected function insertNode($position)
608 | {
609 | $this->newNestedSetQuery()->makeGap($position, 2);
610 |
611 | $height = $this->getNodeHeight();
612 |
613 | $this->setLft($position);
614 | $this->setRgt($position + $height - 1);
615 |
616 | return true;
617 | }
618 |
619 | /**
620 | * Update the tree when the node is removed physically.
621 | */
622 | protected function deleteDescendants()
623 | {
624 | $lft = $this->getLft();
625 | $rgt = $this->getRgt();
626 |
627 | $method = $this->usesSoftDelete() && $this->forceDeleting
628 | ? 'forceDelete'
629 | : 'delete';
630 |
631 | $this->descendants()->{$method}();
632 |
633 | if ($this->hardDeleting()) {
634 | $height = $rgt - $lft + 1;
635 |
636 | $this->newNestedSetQuery()->makeGap($rgt + 1, -$height);
637 |
638 | // In case if user wants to re-create the node
639 | $this->makeRoot();
640 |
641 | static::$actionsPerformed++;
642 | }
643 | }
644 |
645 | /**
646 | * Restore the descendants.
647 | *
648 | * @param $deletedAt
649 | */
650 | protected function restoreDescendants($deletedAt)
651 | {
652 | $this->descendants()
653 | ->where($this->getDeletedAtColumn(), '>=', $deletedAt)
654 | ->restore();
655 | }
656 |
657 | /**
658 | * {@inheritdoc}
659 | *
660 | * @since 2.0
661 | */
662 | public function newEloquentBuilder($query)
663 | {
664 | return new QueryBuilder($query);
665 | }
666 |
667 | /**
668 | * Get a new base query that includes deleted nodes.
669 | *
670 | * @since 1.1
671 | *
672 | * @return QueryBuilder
673 | */
674 | public function newNestedSetQuery($table = null)
675 | {
676 | $builder = $this->usesSoftDelete()
677 | ? $this->withTrashed()
678 | : $this->newQuery();
679 |
680 | return $this->applyNestedSetScope($builder, $table);
681 | }
682 |
683 | /**
684 | * @param string|null $table
685 | *
686 | * @return QueryBuilder
687 | */
688 | public function newScopedQuery($table = null)
689 | {
690 | return $this->applyNestedSetScope($this->newQuery(), $table);
691 | }
692 |
693 | /**
694 | * @param mixed $query
695 | * @param string|null $table
696 | *
697 | * @return mixed
698 | */
699 | public function applyNestedSetScope($query, $table = null)
700 | {
701 | if ( ! $scoped = $this->getScopeAttributes()) {
702 | return $query;
703 | }
704 |
705 | if ( ! $table) {
706 | $table = $this->getTable();
707 | }
708 |
709 | foreach ($scoped as $attribute) {
710 | $query->where($table.'.'.$attribute, '=',
711 | $this->getAttributeValue($attribute));
712 | }
713 |
714 | return $query;
715 | }
716 |
717 | /**
718 | * @return array
719 | */
720 | protected function getScopeAttributes()
721 | {
722 | return null;
723 | }
724 |
725 | /**
726 | * @param array $attributes
727 | *
728 | * @return QueryBuilder
729 | */
730 | public static function scoped(array $attributes)
731 | {
732 | $instance = new static;
733 |
734 | $instance->setRawAttributes($attributes);
735 |
736 | return $instance->newScopedQuery();
737 | }
738 |
739 | /**
740 | * {@inheritdoc}
741 | */
742 | public function newCollection(array $models = array())
743 | {
744 | return new Collection($models);
745 | }
746 |
747 | /**
748 | * {@inheritdoc}
749 | *
750 | * Use `children` key on `$attributes` to create child nodes.
751 | *
752 | * @param self|null $parent
753 | */
754 | public static function create(array $attributes = [], ?self $parent = null)
755 | {
756 | $children = Arr::pull($attributes, 'children');
757 |
758 | $instance = new static($attributes);
759 |
760 | if ($parent) {
761 | $instance->appendToNode($parent);
762 | }
763 |
764 | $instance->save();
765 |
766 | // Now create children
767 | $relation = new EloquentCollection;
768 |
769 | foreach ((array)$children as $child) {
770 | $relation->add($child = static::create($child, $instance));
771 |
772 | $child->setRelation('parent', $instance);
773 | }
774 |
775 | $instance->refreshNode();
776 |
777 | return $instance->setRelation('children', $relation);
778 | }
779 |
780 | /**
781 | * Get node height (rgt - lft + 1).
782 | *
783 | * @return int
784 | */
785 | public function getNodeHeight()
786 | {
787 | if ( ! $this->exists) return 2;
788 |
789 | return $this->getRgt() - $this->getLft() + 1;
790 | }
791 |
792 | /**
793 | * Get number of descendant nodes.
794 | *
795 | * @return int
796 | */
797 | public function getDescendantCount()
798 | {
799 | return ceil($this->getNodeHeight() / 2) - 1;
800 | }
801 |
802 | /**
803 | * Set the value of model's parent id key.
804 | *
805 | * Behind the scenes node is appended to found parent node.
806 | *
807 | * @param int $value
808 | *
809 | * @throws Exception If parent node doesn't exists
810 | */
811 | public function setParentIdAttribute($value)
812 | {
813 | if ($this->getParentId() == $value) return;
814 |
815 | if ($value) {
816 | $this->appendToNode($this->newScopedQuery()->findOrFail($value));
817 | } else {
818 | $this->makeRoot();
819 | }
820 | }
821 |
822 | /**
823 | * Get whether node is root.
824 | *
825 | * @return boolean
826 | */
827 | public function isRoot()
828 | {
829 | return is_null($this->getParentId());
830 | }
831 |
832 | /**
833 | * @return bool
834 | */
835 | public function isLeaf()
836 | {
837 | return $this->getLft() + 1 == $this->getRgt();
838 | }
839 |
840 | /**
841 | * Get the lft key name.
842 | *
843 | * @return string
844 | */
845 | public function getLftName()
846 | {
847 | return NestedSet::LFT;
848 | }
849 |
850 | /**
851 | * Get the rgt key name.
852 | *
853 | * @return string
854 | */
855 | public function getRgtName()
856 | {
857 | return NestedSet::RGT;
858 | }
859 |
860 | /**
861 | * Get the parent id key name.
862 | *
863 | * @return string
864 | */
865 | public function getParentIdName()
866 | {
867 | return NestedSet::PARENT_ID;
868 | }
869 |
870 | /**
871 | * Get the value of the model's lft key.
872 | *
873 | * @return integer
874 | */
875 | public function getLft()
876 | {
877 | return $this->getAttributeValue($this->getLftName());
878 | }
879 |
880 | /**
881 | * Get the value of the model's rgt key.
882 | *
883 | * @return integer
884 | */
885 | public function getRgt()
886 | {
887 | return $this->getAttributeValue($this->getRgtName());
888 | }
889 |
890 | /**
891 | * Get the value of the model's parent id key.
892 | *
893 | * @return integer
894 | */
895 | public function getParentId()
896 | {
897 | return $this->getAttributeValue($this->getParentIdName());
898 | }
899 |
900 | /**
901 | * Returns node that is next to current node without constraining to siblings.
902 | *
903 | * This can be either a next sibling or a next sibling of the parent node.
904 | *
905 | * @param array $columns
906 | *
907 | * @return self
908 | */
909 | public function getNextNode(array $columns = [ '*' ])
910 | {
911 | return $this->nextNodes()->defaultOrder()->first($columns);
912 | }
913 |
914 | /**
915 | * Returns node that is before current node without constraining to siblings.
916 | *
917 | * This can be either a prev sibling or parent node.
918 | *
919 | * @param array $columns
920 | *
921 | * @return self
922 | */
923 | public function getPrevNode(array $columns = [ '*' ])
924 | {
925 | return $this->prevNodes()->defaultOrder('desc')->first($columns);
926 | }
927 |
928 | /**
929 | * @param array $columns
930 | *
931 | * @return Collection
932 | */
933 | public function getAncestors(array $columns = [ '*' ])
934 | {
935 | return $this->ancestors()->get($columns);
936 | }
937 |
938 | /**
939 | * @param array $columns
940 | *
941 | * @return Collection|self[]
942 | */
943 | public function getDescendants(array $columns = [ '*' ])
944 | {
945 | return $this->descendants()->get($columns);
946 | }
947 |
948 | /**
949 | * @param array $columns
950 | *
951 | * @return Collection|self[]
952 | */
953 | public function getSiblings(array $columns = [ '*' ])
954 | {
955 | return $this->siblings()->get($columns);
956 | }
957 |
958 | /**
959 | * @param array $columns
960 | *
961 | * @return Collection|self[]
962 | */
963 | public function getNextSiblings(array $columns = [ '*' ])
964 | {
965 | return $this->nextSiblings()->get($columns);
966 | }
967 |
968 | /**
969 | * @param array $columns
970 | *
971 | * @return Collection|self[]
972 | */
973 | public function getPrevSiblings(array $columns = [ '*' ])
974 | {
975 | return $this->prevSiblings()->get($columns);
976 | }
977 |
978 | /**
979 | * @param array $columns
980 | *
981 | * @return self
982 | */
983 | public function getNextSibling(array $columns = [ '*' ])
984 | {
985 | return $this->nextSiblings()->defaultOrder()->first($columns);
986 | }
987 |
988 | /**
989 | * @param array $columns
990 | *
991 | * @return self
992 | */
993 | public function getPrevSibling(array $columns = [ '*' ])
994 | {
995 | return $this->prevSiblings()->defaultOrder('desc')->first($columns);
996 | }
997 |
998 | /**
999 | * Get whether a node is a descendant of other node.
1000 | *
1001 | * @param self $other
1002 | *
1003 | * @return bool
1004 | */
1005 | public function isDescendantOf(self $other)
1006 | {
1007 | return $this->getLft() > $other->getLft() &&
1008 | $this->getLft() < $other->getRgt() &&
1009 | $this->isSameScope($other);
1010 | }
1011 |
1012 | /**
1013 | * Get whether a node is itself or a descendant of other node.
1014 | *
1015 | * @param self $other
1016 | *
1017 | * @return bool
1018 | */
1019 | public function isSelfOrDescendantOf(self $other)
1020 | {
1021 | return $this->getLft() >= $other->getLft() &&
1022 | $this->getLft() < $other->getRgt();
1023 | }
1024 |
1025 | /**
1026 | * Get whether the node is immediate children of other node.
1027 | *
1028 | * @param self $other
1029 | *
1030 | * @return bool
1031 | */
1032 | public function isChildOf(self $other)
1033 | {
1034 | return $this->getParentId() == $other->getKey();
1035 | }
1036 |
1037 | /**
1038 | * Get whether the node is a sibling of another node.
1039 | *
1040 | * @param self $other
1041 | *
1042 | * @return bool
1043 | */
1044 | public function isSiblingOf(self $other)
1045 | {
1046 | return $this->getParentId() == $other->getParentId();
1047 | }
1048 |
1049 | /**
1050 | * Get whether the node is an ancestor of other node, including immediate parent.
1051 | *
1052 | * @param self $other
1053 | *
1054 | * @return bool
1055 | */
1056 | public function isAncestorOf(self $other)
1057 | {
1058 | return $other->isDescendantOf($this);
1059 | }
1060 |
1061 | /**
1062 | * Get whether the node is itself or an ancestor of other node, including immediate parent.
1063 | *
1064 | * @param self $other
1065 | *
1066 | * @return bool
1067 | */
1068 | public function isSelfOrAncestorOf(self $other)
1069 | {
1070 | return $other->isSelfOrDescendantOf($this);
1071 | }
1072 |
1073 | /**
1074 | * Get whether the node has moved since last save.
1075 | *
1076 | * @return bool
1077 | */
1078 | public function hasMoved()
1079 | {
1080 | return $this->moved;
1081 | }
1082 |
1083 | /**
1084 | * @return array
1085 | */
1086 | protected function getArrayableRelations()
1087 | {
1088 | $result = parent::getArrayableRelations();
1089 |
1090 | // To fix #17 when converting tree to json falling to infinite recursion.
1091 | unset($result['parent']);
1092 |
1093 | return $result;
1094 | }
1095 |
1096 | /**
1097 | * Get whether user is intended to delete the model from database entirely.
1098 | *
1099 | * @return bool
1100 | */
1101 | protected function hardDeleting()
1102 | {
1103 | return ! $this->usesSoftDelete() || $this->forceDeleting;
1104 | }
1105 |
1106 | /**
1107 | * @return array
1108 | */
1109 | public function getBounds()
1110 | {
1111 | return [ $this->getLft(), $this->getRgt() ];
1112 | }
1113 |
1114 | /**
1115 | * @param $value
1116 | *
1117 | * @return $this
1118 | */
1119 | public function setLft($value)
1120 | {
1121 | $this->attributes[$this->getLftName()] = $value;
1122 |
1123 | return $this;
1124 | }
1125 |
1126 | /**
1127 | * @param $value
1128 | *
1129 | * @return $this
1130 | */
1131 | public function setRgt($value)
1132 | {
1133 | $this->attributes[$this->getRgtName()] = $value;
1134 |
1135 | return $this;
1136 | }
1137 |
1138 | /**
1139 | * @param $value
1140 | *
1141 | * @return $this
1142 | */
1143 | public function setParentId($value)
1144 | {
1145 | $this->attributes[$this->getParentIdName()] = $value;
1146 |
1147 | return $this;
1148 | }
1149 |
1150 | /**
1151 | * @return $this
1152 | */
1153 | protected function dirtyBounds()
1154 | {
1155 | $this->original[$this->getLftName()] = null;
1156 | $this->original[$this->getRgtName()] = null;
1157 |
1158 | return $this;
1159 | }
1160 |
1161 | /**
1162 | * @param self $node
1163 | *
1164 | * @return $this
1165 | */
1166 | protected function assertNotDescendant(self $node)
1167 | {
1168 | if ($node == $this || $node->isDescendantOf($this)) {
1169 | throw new LogicException('Node must not be a descendant.');
1170 | }
1171 |
1172 | return $this;
1173 | }
1174 |
1175 | /**
1176 | * @param self $node
1177 | *
1178 | * @return $this
1179 | */
1180 | protected function assertNodeExists(self $node)
1181 | {
1182 | if ( ! $node->getLft() || ! $node->getRgt()) {
1183 | throw new LogicException('Node must exists.');
1184 | }
1185 |
1186 | return $this;
1187 | }
1188 |
1189 | /**
1190 | * @param self $node
1191 | */
1192 | protected function assertSameScope(self $node)
1193 | {
1194 | if ( ! $scoped = $this->getScopeAttributes()) {
1195 | return;
1196 | }
1197 |
1198 | foreach ($scoped as $attr) {
1199 | if ($this->getAttribute($attr) != $node->getAttribute($attr)) {
1200 | throw new LogicException('Nodes must be in the same scope');
1201 | }
1202 | }
1203 | }
1204 |
1205 | /**
1206 | * @param self $node
1207 | */
1208 | protected function isSameScope(self $node): bool
1209 | {
1210 | if ( ! $scoped = $this->getScopeAttributes()) {
1211 | return true;
1212 | }
1213 |
1214 | foreach ($scoped as $attr) {
1215 | if ($this->getAttribute($attr) != $node->getAttribute($attr)) {
1216 | return false;
1217 | }
1218 | }
1219 |
1220 | return true;
1221 | }
1222 |
1223 | /**
1224 | * @param array|null $except
1225 | *
1226 | * @return \Illuminate\Database\Eloquent\Model
1227 | */
1228 | public function replicate(?array $except = null)
1229 | {
1230 | $defaults = [
1231 | $this->getParentIdName(),
1232 | $this->getLftName(),
1233 | $this->getRgtName(),
1234 | ];
1235 |
1236 | $except = $except ? array_unique(array_merge($except, $defaults)) : $defaults;
1237 |
1238 | return parent::replicate($except);
1239 | }
1240 | }
1241 |
--------------------------------------------------------------------------------
/src/QueryBuilder.php:
--------------------------------------------------------------------------------
1 | toBase();
35 |
36 | $query->where($this->model->getKeyName(), '=', $id);
37 |
38 | $data = $query->first([ $this->model->getLftName(),
39 | $this->model->getRgtName() ]);
40 |
41 | if ( ! $data && $required) {
42 | throw new ModelNotFoundException;
43 | }
44 |
45 | return (array)$data;
46 | }
47 |
48 | /**
49 | * Get plain node data.
50 | *
51 | * @since 2.0
52 | *
53 | * @param mixed $id
54 | * @param bool $required
55 | *
56 | * @return array
57 | */
58 | public function getPlainNodeData($id, $required = false)
59 | {
60 | return array_values($this->getNodeData($id, $required));
61 | }
62 |
63 | /**
64 | * Scope limits query to select just root node.
65 | *
66 | * @return $this
67 | */
68 | public function whereIsRoot()
69 | {
70 | $this->query->whereNull($this->model->getParentIdName());
71 |
72 | return $this;
73 | }
74 |
75 | /**
76 | * Limit results to ancestors of specified node.
77 | *
78 | * @since 2.0
79 | *
80 | * @param mixed $id
81 | * @param bool $andSelf
82 | *
83 | * @param string $boolean
84 | *
85 | * @return $this
86 | */
87 | public function whereAncestorOf($id, $andSelf = false, $boolean = 'and')
88 | {
89 | $keyName = $this->model->getTable() . '.' . $this->model->getKeyName();
90 | $model = null;
91 |
92 | if (NestedSet::isNode($id)) {
93 | $model = $id;
94 | $value = '?';
95 |
96 | $this->query->addBinding($id->getRgt());
97 |
98 | $id = $id->getKey();
99 | } else {
100 | $valueQuery = $this->model
101 | ->newQuery()
102 | ->toBase()
103 | ->select("_.".$this->model->getRgtName())
104 | ->from($this->model->getTable().' as _')
105 | ->where($this->model->getKeyName(), '=', $id)
106 | ->limit(1);
107 |
108 | $this->query->mergeBindings($valueQuery);
109 |
110 | $value = '('.$valueQuery->toSql().')';
111 | }
112 |
113 | $this->query->whereNested(function ($inner) use ($model, $value, $andSelf, $id, $keyName) {
114 | list($lft, $rgt) = $this->wrappedColumns();
115 | $wrappedTable = $this->query->getGrammar()->wrapTable($this->model->getTable());
116 |
117 | $inner->whereRaw("{$value} between {$wrappedTable}.{$lft} and {$wrappedTable}.{$rgt}");
118 |
119 | if ( ! $andSelf) {
120 | $inner->where($keyName, '<>', $id);
121 | }
122 | if ($model !== null) {
123 | // we apply scope only when Node was passed as $id.
124 | // In other cases, according to docs, query should be scoped() before calling this method
125 | $model->applyNestedSetScope($inner);
126 | }
127 | }, $boolean);
128 |
129 | return $this;
130 | }
131 |
132 | /**
133 | * @param $id
134 | * @param bool $andSelf
135 | *
136 | * @return $this
137 | */
138 | public function orWhereAncestorOf($id, $andSelf = false)
139 | {
140 | return $this->whereAncestorOf($id, $andSelf, 'or');
141 | }
142 |
143 | /**
144 | * @param $id
145 | *
146 | * @return QueryBuilder
147 | */
148 | public function whereAncestorOrSelf($id)
149 | {
150 | return $this->whereAncestorOf($id, true);
151 | }
152 |
153 | /**
154 | * Get ancestors of specified node.
155 | *
156 | * @since 2.0
157 | *
158 | * @param mixed $id
159 | * @param array $columns
160 | *
161 | * @return \Kalnoy\Nestedset\Collection
162 | */
163 | public function ancestorsOf($id, array $columns = array( '*' ))
164 | {
165 | return $this->whereAncestorOf($id)->get($columns);
166 | }
167 |
168 | /**
169 | * @param $id
170 | * @param array $columns
171 | *
172 | * @return \Kalnoy\Nestedset\Collection
173 | */
174 | public function ancestorsAndSelf($id, array $columns = [ '*' ])
175 | {
176 | return $this->whereAncestorOf($id, true)->get($columns);
177 | }
178 |
179 | /**
180 | * Add node selection statement between specified range.
181 | *
182 | * @since 2.0
183 | *
184 | * @param array $values
185 | * @param string $boolean
186 | * @param bool $not
187 | * @param Query $query
188 | *
189 | * @return $this
190 | */
191 | public function whereNodeBetween($values, $boolean = 'and', $not = false, $query = null)
192 | {
193 | ($query ?? $this->query)->whereBetween($this->model->getTable() . '.' . $this->model->getLftName(), $values, $boolean, $not);
194 |
195 | return $this;
196 | }
197 |
198 | /**
199 | * Add node selection statement between specified range joined with `or` operator.
200 | *
201 | * @since 2.0
202 | *
203 | * @param array $values
204 | *
205 | * @return $this
206 | */
207 | public function orWhereNodeBetween($values)
208 | {
209 | return $this->whereNodeBetween($values, 'or');
210 | }
211 |
212 | /**
213 | * Add constraint statement to descendants of specified node.
214 | *
215 | * @since 2.0
216 | *
217 | * @param mixed $id
218 | * @param string $boolean
219 | * @param bool $not
220 | * @param bool $andSelf
221 | *
222 | * @return $this
223 | */
224 | public function whereDescendantOf($id, $boolean = 'and', $not = false,
225 | $andSelf = false
226 | ) {
227 | $this->query->whereNested(function (Query $inner) use ($id, $andSelf, $not) {
228 | if (NestedSet::isNode($id)) {
229 | $id->applyNestedSetScope($inner);
230 | $data = $id->getBounds();
231 | } else {
232 | // we apply scope only when Node was passed as $id.
233 | // In other cases, according to docs, query should be scoped() before calling this method
234 | $data = $this->model->newNestedSetQuery()
235 | ->getPlainNodeData($id, true);
236 | }
237 |
238 | // Don't include the node
239 | if (!$andSelf) {
240 | ++$data[0];
241 | }
242 |
243 | return $this->whereNodeBetween($data, 'and', $not, $inner);
244 | }, $boolean);
245 |
246 | return $this;
247 | }
248 |
249 | /**
250 | * @param mixed $id
251 | *
252 | * @return QueryBuilder
253 | */
254 | public function whereNotDescendantOf($id)
255 | {
256 | return $this->whereDescendantOf($id, 'and', true);
257 | }
258 |
259 | /**
260 | * @param mixed $id
261 | *
262 | * @return QueryBuilder
263 | */
264 | public function orWhereDescendantOf($id)
265 | {
266 | return $this->whereDescendantOf($id, 'or');
267 | }
268 |
269 | /**
270 | * @param mixed $id
271 | *
272 | * @return QueryBuilder
273 | */
274 | public function orWhereNotDescendantOf($id)
275 | {
276 | return $this->whereDescendantOf($id, 'or', true);
277 | }
278 |
279 | /**
280 | * @param $id
281 | * @param string $boolean
282 | * @param bool $not
283 | *
284 | * @return $this
285 | */
286 | public function whereDescendantOrSelf($id, $boolean = 'and', $not = false)
287 | {
288 | return $this->whereDescendantOf($id, $boolean, $not, true);
289 | }
290 |
291 | /**
292 | * Get descendants of specified node.
293 | *
294 | * @since 2.0
295 | *
296 | * @param mixed $id
297 | * @param array $columns
298 | * @param bool $andSelf
299 | *
300 | * @return Collection
301 | */
302 | public function descendantsOf($id, array $columns = [ '*' ], $andSelf = false)
303 | {
304 | try {
305 | return $this->whereDescendantOf($id, 'and', false, $andSelf)->get($columns);
306 | }
307 |
308 | catch (ModelNotFoundException $e) {
309 | return $this->model->newCollection();
310 | }
311 | }
312 |
313 | /**
314 | * @param $id
315 | * @param array $columns
316 | *
317 | * @return Collection
318 | */
319 | public function descendantsAndSelf($id, array $columns = [ '*' ])
320 | {
321 | return $this->descendantsOf($id, $columns, true);
322 | }
323 |
324 | /**
325 | * @param $id
326 | * @param $operator
327 | * @param $boolean
328 | *
329 | * @return $this
330 | */
331 | protected function whereIsBeforeOrAfter($id, $operator, $boolean)
332 | {
333 | if (NestedSet::isNode($id)) {
334 | $value = '?';
335 |
336 | $this->query->addBinding($id->getLft());
337 | } else {
338 | $valueQuery = $this->model
339 | ->newQuery()
340 | ->toBase()
341 | ->select('_n.'.$this->model->getLftName())
342 | ->from($this->model->getTable().' as _n')
343 | ->where('_n.'.$this->model->getKeyName(), '=', $id);
344 |
345 | $this->query->mergeBindings($valueQuery);
346 |
347 | $value = '('.$valueQuery->toSql().')';
348 | }
349 |
350 | list($lft,) = $this->wrappedColumns();
351 |
352 | $this->query->whereRaw("{$lft} {$operator} {$value}", [ ], $boolean);
353 |
354 | return $this;
355 | }
356 |
357 | /**
358 | * Constraint nodes to those that are after specified node.
359 | *
360 | * @since 2.0
361 | *
362 | * @param mixed $id
363 | * @param string $boolean
364 | *
365 | * @return $this
366 | */
367 | public function whereIsAfter($id, $boolean = 'and')
368 | {
369 | return $this->whereIsBeforeOrAfter($id, '>', $boolean);
370 | }
371 |
372 | /**
373 | * Constraint nodes to those that are before specified node.
374 | *
375 | * @since 2.0
376 | *
377 | * @param mixed $id
378 | * @param string $boolean
379 | *
380 | * @return $this
381 | */
382 | public function whereIsBefore($id, $boolean = 'and')
383 | {
384 | return $this->whereIsBeforeOrAfter($id, '<', $boolean);
385 | }
386 |
387 | /**
388 | * @return $this
389 | */
390 | public function whereIsLeaf()
391 | {
392 | list($lft, $rgt) = $this->wrappedColumns();
393 |
394 | return $this->whereRaw("$lft = $rgt - 1");
395 | }
396 |
397 | /**
398 | * @param array $columns
399 | *
400 | * @return Collection
401 | */
402 | public function leaves(array $columns = [ '*'])
403 | {
404 | return $this->whereIsLeaf()->get($columns);
405 | }
406 |
407 | /**
408 | * Include depth level into the result.
409 | *
410 | * @param string $as
411 | *
412 | * @return $this
413 | */
414 | public function withDepth($as = 'depth')
415 | {
416 | if ($this->query->columns === null) $this->query->columns = [ '*' ];
417 |
418 | $table = $this->wrappedTable();
419 |
420 | list($lft, $rgt) = $this->wrappedColumns();
421 |
422 | $alias = '_d';
423 | $wrappedAlias = $this->query->getGrammar()->wrapTable($alias);
424 |
425 | $query = $this->model
426 | ->newScopedQuery('_d')
427 | ->toBase()
428 | ->selectRaw('count(1) - 1')
429 | ->from($this->model->getTable().' as '.$alias)
430 | ->whereRaw("{$table}.{$lft} between {$wrappedAlias}.{$lft} and {$wrappedAlias}.{$rgt}");
431 |
432 | $this->query->selectSub($query, $as);
433 |
434 | return $this;
435 | }
436 |
437 | /**
438 | * Get wrapped `lft` and `rgt` column names.
439 | *
440 | * @since 2.0
441 | *
442 | * @return array
443 | */
444 | protected function wrappedColumns()
445 | {
446 | $grammar = $this->query->getGrammar();
447 |
448 | return [
449 | $grammar->wrap($this->model->getLftName()),
450 | $grammar->wrap($this->model->getRgtName()),
451 | ];
452 | }
453 |
454 | /**
455 | * Get a wrapped table name.
456 | *
457 | * @since 2.0
458 | *
459 | * @return string
460 | */
461 | protected function wrappedTable()
462 | {
463 | return $this->query->getGrammar()->wrapTable($this->getQuery()->from);
464 | }
465 |
466 | /**
467 | * Wrap model's key name.
468 | *
469 | * @since 2.0
470 | *
471 | * @return string
472 | */
473 | protected function wrappedKey()
474 | {
475 | return $this->query->getGrammar()->wrap($this->model->getKeyName());
476 | }
477 |
478 | /**
479 | * Exclude root node from the result.
480 | *
481 | * @return $this
482 | */
483 | public function withoutRoot()
484 | {
485 | $this->query->whereNotNull($this->model->getParentIdName());
486 |
487 | return $this;
488 | }
489 |
490 | /**
491 | * Equivalent of `withoutRoot`.
492 | *
493 | * @since 2.0
494 | * @deprecated since v4.1
495 | *
496 | * @return $this
497 | */
498 | public function hasParent()
499 | {
500 | $this->query->whereNotNull($this->model->getParentIdName());
501 |
502 | return $this;
503 | }
504 |
505 | /**
506 | * Get only nodes that have children.
507 | *
508 | * @since 2.0
509 | * @deprecated since v4.1
510 | *
511 | * @return $this
512 | */
513 | public function hasChildren()
514 | {
515 | list($lft, $rgt) = $this->wrappedColumns();
516 |
517 | $this->query->whereRaw("{$rgt} > {$lft} + 1");
518 |
519 | return $this;
520 | }
521 |
522 | /**
523 | * Order by node position.
524 | *
525 | * @param string $dir
526 | *
527 | * @return $this
528 | */
529 | public function defaultOrder($dir = 'asc')
530 | {
531 | $this->query->orders = null;
532 |
533 | $this->query->orderBy($this->model->getLftName(), $dir);
534 |
535 | return $this;
536 | }
537 |
538 | /**
539 | * Order by reversed node position.
540 | *
541 | * @return $this
542 | */
543 | public function reversed()
544 | {
545 | return $this->defaultOrder('desc');
546 | }
547 |
548 | /**
549 | * Move a node to the new position.
550 | *
551 | * @param mixed $key
552 | * @param int $position
553 | *
554 | * @return int
555 | */
556 | public function moveNode($key, $position)
557 | {
558 | list($lft, $rgt) = $this->model->newNestedSetQuery()
559 | ->getPlainNodeData($key, true);
560 |
561 | if ($lft < $position && $position <= $rgt) {
562 | throw new LogicException('Cannot move node into itself.');
563 | }
564 |
565 | // Get boundaries of nodes that should be moved to new position
566 | $from = min($lft, $position);
567 | $to = max($rgt, $position - 1);
568 |
569 | // The height of node that is being moved
570 | $height = $rgt - $lft + 1;
571 |
572 | // The distance that our node will travel to reach it's destination
573 | $distance = $to - $from + 1 - $height;
574 |
575 | // If no distance to travel, just return
576 | if ($distance === 0) {
577 | return 0;
578 | }
579 |
580 | if ($position > $lft) {
581 | $height *= -1;
582 | } else {
583 | $distance *= -1;
584 | }
585 |
586 | $params = compact('lft', 'rgt', 'from', 'to', 'height', 'distance');
587 |
588 | $boundary = [ $from, $to ];
589 |
590 | $query = $this->toBase()->where(function (Query $inner) use ($boundary) {
591 | $inner->whereBetween($this->model->getLftName(), $boundary);
592 | $inner->orWhereBetween($this->model->getRgtName(), $boundary);
593 | });
594 |
595 | return $query->update($this->patch($params));
596 | }
597 |
598 | /**
599 | * Make or remove gap in the tree. Negative height will remove gap.
600 | *
601 | * @since 2.0
602 | *
603 | * @param int $cut
604 | * @param int $height
605 | *
606 | * @return int
607 | */
608 | public function makeGap($cut, $height)
609 | {
610 | $params = compact('cut', 'height');
611 |
612 | $query = $this->toBase()->whereNested(function (Query $inner) use ($cut) {
613 | $inner->where($this->model->getLftName(), '>=', $cut);
614 | $inner->orWhere($this->model->getRgtName(), '>=', $cut);
615 | });
616 |
617 | return $query->update($this->patch($params));
618 | }
619 |
620 | /**
621 | * Get patch for columns.
622 | *
623 | * @since 2.0
624 | *
625 | * @param array $params
626 | *
627 | * @return array
628 | */
629 | protected function patch(array $params)
630 | {
631 | $grammar = $this->query->getGrammar();
632 |
633 | $columns = [];
634 |
635 | foreach ([ $this->model->getLftName(), $this->model->getRgtName() ] as $col) {
636 | $columns[$col] = $this->columnPatch($grammar->wrap($col), $params);
637 | }
638 |
639 | return $columns;
640 | }
641 |
642 | /**
643 | * Get patch for single column.
644 | *
645 | * @since 2.0
646 | *
647 | * @param string $col
648 | * @param array $params
649 | *
650 | * @return string
651 | */
652 | protected function columnPatch($col, array $params)
653 | {
654 | extract($params);
655 |
656 | /** @var int $height */
657 | if ($height > 0) $height = '+'.$height;
658 |
659 | if (isset($cut)) {
660 | return new Expression("case when {$col} >= {$cut} then {$col}{$height} else {$col} end");
661 | }
662 |
663 | /** @var int $distance */
664 | /** @var int $lft */
665 | /** @var int $rgt */
666 | /** @var int $from */
667 | /** @var int $to */
668 | if ($distance > 0) $distance = '+'.$distance;
669 |
670 | return new Expression("case ".
671 | "when {$col} between {$lft} and {$rgt} then {$col}{$distance} ". // Move the node
672 | "when {$col} between {$from} and {$to} then {$col}{$height} ". // Move other nodes
673 | "else {$col} end"
674 | );
675 | }
676 |
677 | /**
678 | * Get statistics of errors of the tree.
679 | *
680 | * @since 2.0
681 | *
682 | * @return array
683 | */
684 | public function countErrors()
685 | {
686 | $checks = [];
687 |
688 | // Check if lft and rgt values are ok
689 | $checks['oddness'] = $this->getOdnessQuery();
690 |
691 | // Check if lft and rgt values are unique
692 | $checks['duplicates'] = $this->getDuplicatesQuery();
693 |
694 | // Check if parent_id is set correctly
695 | $checks['wrong_parent'] = $this->getWrongParentQuery();
696 |
697 | // Check for nodes that have missing parent
698 | $checks['missing_parent' ] = $this->getMissingParentQuery();
699 |
700 | $query = $this->query->newQuery();
701 |
702 | foreach ($checks as $key => $inner) {
703 | $inner->selectRaw('count(1)');
704 |
705 | $query->selectSub($inner, $key);
706 | }
707 |
708 | return (array)$query->first();
709 | }
710 |
711 | /**
712 | * @return BaseQueryBuilder
713 | */
714 | protected function getOdnessQuery()
715 | {
716 | return $this->model
717 | ->newNestedSetQuery()
718 | ->toBase()
719 | ->whereNested(function (BaseQueryBuilder $inner) {
720 | list($lft, $rgt) = $this->wrappedColumns();
721 |
722 | $inner->whereRaw("{$lft} >= {$rgt}")
723 | ->orWhereRaw("({$rgt} - {$lft}) % 2 = 0");
724 | });
725 | }
726 |
727 | /**
728 | * @return BaseQueryBuilder
729 | */
730 | protected function getDuplicatesQuery()
731 | {
732 | $table = $this->wrappedTable();
733 | $keyName = $this->wrappedKey();
734 |
735 | $firstAlias = 'c1';
736 | $secondAlias = 'c2';
737 |
738 | $waFirst = $this->query->getGrammar()->wrapTable($firstAlias);
739 | $waSecond = $this->query->getGrammar()->wrapTable($secondAlias);
740 |
741 | $query = $this->model
742 | ->newNestedSetQuery($firstAlias)
743 | ->toBase()
744 | ->from($this->query->raw("{$table} as {$waFirst}, {$table} {$waSecond}"))
745 | ->whereRaw("{$waFirst}.{$keyName} < {$waSecond}.{$keyName}")
746 | ->whereNested(function (BaseQueryBuilder $inner) use ($waFirst, $waSecond) {
747 | list($lft, $rgt) = $this->wrappedColumns();
748 |
749 | $inner->orWhereRaw("{$waFirst}.{$lft}={$waSecond}.{$lft}")
750 | ->orWhereRaw("{$waFirst}.{$rgt}={$waSecond}.{$rgt}")
751 | ->orWhereRaw("{$waFirst}.{$lft}={$waSecond}.{$rgt}")
752 | ->orWhereRaw("{$waFirst}.{$rgt}={$waSecond}.{$lft}");
753 | });
754 |
755 | return $this->model->applyNestedSetScope($query, $secondAlias);
756 | }
757 |
758 | /**
759 | * @return BaseQueryBuilder
760 | */
761 | protected function getWrongParentQuery()
762 | {
763 | $table = $this->wrappedTable();
764 | $keyName = $this->wrappedKey();
765 |
766 | $grammar = $this->query->getGrammar();
767 |
768 | $parentIdName = $grammar->wrap($this->model->getParentIdName());
769 |
770 | $parentAlias = 'p';
771 | $childAlias = 'c';
772 | $intermAlias = 'i';
773 |
774 | $waParent = $grammar->wrapTable($parentAlias);
775 | $waChild = $grammar->wrapTable($childAlias);
776 | $waInterm = $grammar->wrapTable($intermAlias);
777 |
778 | $query = $this->model
779 | ->newNestedSetQuery('c')
780 | ->toBase()
781 | ->from($this->query->raw("{$table} as {$waChild}, {$table} as {$waParent}, $table as {$waInterm}"))
782 | ->whereRaw("{$waChild}.{$parentIdName}={$waParent}.{$keyName}")
783 | ->whereRaw("{$waInterm}.{$keyName} <> {$waParent}.{$keyName}")
784 | ->whereRaw("{$waInterm}.{$keyName} <> {$waChild}.{$keyName}")
785 | ->whereNested(function (BaseQueryBuilder $inner) use ($waInterm, $waChild, $waParent) {
786 | list($lft, $rgt) = $this->wrappedColumns();
787 |
788 | $inner->whereRaw("{$waChild}.{$lft} not between {$waParent}.{$lft} and {$waParent}.{$rgt}")
789 | ->orWhereRaw("{$waChild}.{$lft} between {$waInterm}.{$lft} and {$waInterm}.{$rgt}")
790 | ->whereRaw("{$waInterm}.{$lft} between {$waParent}.{$lft} and {$waParent}.{$rgt}");
791 | });
792 |
793 | $this->model->applyNestedSetScope($query, $parentAlias);
794 | $this->model->applyNestedSetScope($query, $intermAlias);
795 |
796 | return $query;
797 | }
798 |
799 | /**
800 | * @return $this
801 | */
802 | protected function getMissingParentQuery()
803 | {
804 | return $this->model
805 | ->newNestedSetQuery()
806 | ->toBase()
807 | ->whereNested(function (BaseQueryBuilder $inner) {
808 | $grammar = $this->query->getGrammar();
809 |
810 | $table = $this->wrappedTable();
811 | $keyName = $this->wrappedKey();
812 | $parentIdName = $grammar->wrap($this->model->getParentIdName());
813 | $alias = 'p';
814 | $wrappedAlias = $grammar->wrapTable($alias);
815 |
816 | $existsCheck = $this->model
817 | ->newNestedSetQuery()
818 | ->toBase()
819 | ->selectRaw('1')
820 | ->from($this->query->raw("{$table} as {$wrappedAlias}"))
821 | ->whereRaw("{$table}.{$parentIdName} = {$wrappedAlias}.{$keyName}")
822 | ->limit(1);
823 |
824 | $this->model->applyNestedSetScope($existsCheck, $alias);
825 |
826 | $inner->whereRaw("{$parentIdName} is not null")
827 | ->addWhereExistsQuery($existsCheck, 'and', true);
828 | });
829 | }
830 |
831 | /**
832 | * Get the number of total errors of the tree.
833 | *
834 | * @since 2.0
835 | *
836 | * @return int
837 | */
838 | public function getTotalErrors()
839 | {
840 | return array_sum($this->countErrors());
841 | }
842 |
843 | /**
844 | * Get whether the tree is broken.
845 | *
846 | * @since 2.0
847 | *
848 | * @return bool
849 | */
850 | public function isBroken()
851 | {
852 | return $this->getTotalErrors() > 0;
853 | }
854 |
855 | /**
856 | * Fixes the tree based on parentage info.
857 | *
858 | * Nodes with invalid parent are saved as roots.
859 | *
860 | * @param null|NodeTrait|Model $root
861 | *
862 | * @return int The number of changed nodes
863 | */
864 | public function fixTree($root = null)
865 | {
866 | $columns = [
867 | $this->model->getKeyName(),
868 | $this->model->getParentIdName(),
869 | $this->model->getLftName(),
870 | $this->model->getRgtName(),
871 | ];
872 |
873 | $dictionary = $this->model
874 | ->newNestedSetQuery()
875 | ->when($root, function (self $query) use ($root) {
876 | return $query->whereDescendantOf($root);
877 | })
878 | ->defaultOrder()
879 | ->get($columns)
880 | ->groupBy($this->model->getParentIdName())
881 | ->all();
882 |
883 | return $this->fixNodes($dictionary, $root);
884 | }
885 |
886 | /**
887 | * @param NodeTrait|Model $root
888 | *
889 | * @return int
890 | */
891 | public function fixSubtree($root)
892 | {
893 | return $this->fixTree($root);
894 | }
895 |
896 | /**
897 | * @param array $dictionary
898 | * @param NodeTrait|Model|null $parent
899 | *
900 | * @return int
901 | */
902 | protected function fixNodes(array &$dictionary, $parent = null)
903 | {
904 | $parentId = $parent ? $parent->getKey() : null;
905 | $cut = $parent ? $parent->getLft() + 1 : 1;
906 |
907 | $updated = [];
908 | $moved = 0;
909 |
910 | $cut = self::reorderNodes($dictionary, $updated, $parentId, $cut);
911 |
912 | // Save nodes that have invalid parent as roots
913 | while ( ! empty($dictionary)) {
914 | $dictionary[null] = reset($dictionary);
915 |
916 | unset($dictionary[key($dictionary)]);
917 |
918 | $cut = self::reorderNodes($dictionary, $updated, $parentId, $cut);
919 | }
920 |
921 | if ($parent && ($grown = $cut - $parent->getRgt()) != 0) {
922 | $moved = $this->model->newScopedQuery()->makeGap($parent->getRgt() + 1, $grown);
923 |
924 | $updated[] = $parent->rawNode($parent->getLft(), $cut, $parent->getParentId());
925 | }
926 |
927 | foreach ($updated as $model) {
928 | $model->save();
929 | }
930 |
931 | return count($updated) + $moved;
932 | }
933 |
934 | /**
935 | * @param array $dictionary
936 | * @param array $updated
937 | * @param $parentId
938 | * @param int $cut
939 | *
940 | * @return int
941 | * @internal param int $fixed
942 | */
943 | protected static function reorderNodes(
944 | array &$dictionary, array &$updated, $parentId = null, $cut = 1
945 | ) {
946 | if ( ! isset($dictionary[$parentId])) {
947 | return $cut;
948 | }
949 |
950 | /** @var Model|NodeTrait $model */
951 | foreach ($dictionary[$parentId] as $model) {
952 | $lft = $cut;
953 |
954 | $cut = self::reorderNodes($dictionary, $updated, $model->getKey(), $cut + 1);
955 |
956 | if ($model->rawNode($lft, $cut, $parentId)->isDirty()) {
957 | $updated[] = $model;
958 | }
959 |
960 | ++$cut;
961 | }
962 |
963 | unset($dictionary[$parentId]);
964 |
965 | return $cut;
966 | }
967 |
968 | /**
969 | * Rebuild the tree based on raw data.
970 | *
971 | * If item data does not contain primary key, new node will be created.
972 | *
973 | * @param array $data
974 | * @param bool $delete Whether to delete nodes that exists but not in the data
975 | * array
976 | * @param null $root
977 | *
978 | * @return int
979 | */
980 | public function rebuildTree(array $data, $delete = false, $root = null)
981 | {
982 | if ($this->model->usesSoftDelete()) {
983 | $this->withTrashed();
984 | }
985 |
986 | $existing = $this
987 | ->when($root, function (self $query) use ($root) {
988 | return $query->whereDescendantOf($root);
989 | })
990 | ->get()
991 | ->getDictionary();
992 |
993 | $dictionary = [];
994 | $parentId = $root ? $root->getKey() : null;
995 |
996 | $this->buildRebuildDictionary($dictionary, $data, $existing, $parentId);
997 |
998 | /** @var Model|NodeTrait $model */
999 | if ( ! empty($existing)) {
1000 | if ($delete && ! $this->model->usesSoftDelete()) {
1001 | $this->model
1002 | ->newScopedQuery()
1003 | ->whereIn($this->model->getKeyName(), array_keys($existing))
1004 | ->delete();
1005 | } else {
1006 | foreach ($existing as $model) {
1007 | $dictionary[$model->getParentId()][] = $model;
1008 |
1009 | if ($delete && $this->model->usesSoftDelete() &&
1010 | ! $model->{$model->getDeletedAtColumn()}
1011 | ) {
1012 | $time = $this->model->fromDateTime($this->model->freshTimestamp());
1013 |
1014 | $model->{$model->getDeletedAtColumn()} = $time;
1015 | }
1016 | }
1017 | }
1018 | }
1019 |
1020 | return $this->fixNodes($dictionary, $root);
1021 | }
1022 |
1023 | /**
1024 | * @param $root
1025 | * @param array $data
1026 | * @param bool $delete
1027 | *
1028 | * @return int
1029 | */
1030 | public function rebuildSubtree($root, array $data, $delete = false)
1031 | {
1032 | return $this->rebuildTree($data, $delete, $root);
1033 | }
1034 |
1035 | /**
1036 | * @param array $dictionary
1037 | * @param array $data
1038 | * @param array $existing
1039 | * @param mixed $parentId
1040 | */
1041 | protected function buildRebuildDictionary(array &$dictionary,
1042 | array $data,
1043 | array &$existing,
1044 | $parentId = null
1045 | ) {
1046 | $keyName = $this->model->getKeyName();
1047 |
1048 | foreach ($data as $itemData) {
1049 | /** @var NodeTrait|Model $model */
1050 |
1051 | if ( ! isset($itemData[$keyName])) {
1052 | $model = $this->model->newInstance($this->model->getAttributes());
1053 |
1054 | // Set some values that will be fixed later
1055 | $model->rawNode(0, 0, $parentId);
1056 | } else {
1057 | if ( ! isset($existing[$key = $itemData[$keyName]])) {
1058 | throw new ModelNotFoundException;
1059 | }
1060 |
1061 | $model = $existing[$key];
1062 |
1063 | // Disable any tree actions
1064 | $model->rawNode($model->getLft(), $model->getRgt(), $parentId);
1065 |
1066 | unset($existing[$key]);
1067 | }
1068 |
1069 | $model->fill(Arr::except($itemData, 'children'))->save();
1070 |
1071 | $dictionary[$parentId][] = $model;
1072 |
1073 | if ( ! isset($itemData['children'])) continue;
1074 |
1075 | $this->buildRebuildDictionary($dictionary,
1076 | $itemData['children'],
1077 | $existing,
1078 | $model->getKey());
1079 | }
1080 | }
1081 |
1082 | /**
1083 | * @param string|null $table
1084 | *
1085 | * @return $this
1086 | */
1087 | public function applyNestedSetScope($table = null)
1088 | {
1089 | return $this->model->applyNestedSetScope($this, $table);
1090 | }
1091 |
1092 | /**
1093 | * Get the root node.
1094 | *
1095 | * @param array $columns
1096 | *
1097 | * @return self
1098 | */
1099 | public function root(array $columns = ['*'])
1100 | {
1101 | return $this->whereIsRoot()->first($columns);
1102 | }
1103 | }
1104 |
--------------------------------------------------------------------------------
/tests/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lazychaser/laravel-nestedset/3cfc56a9759fb592bc903056166bfc0867f9e679/tests/.gitkeep
--------------------------------------------------------------------------------
/tests/NodeTest.php:
--------------------------------------------------------------------------------
1 | dropIfExists('categories');
13 |
14 | Capsule::disableQueryLog();
15 |
16 | $schema->create('categories', function (\Illuminate\Database\Schema\Blueprint $table) {
17 | $table->increments('id');
18 | $table->string('name');
19 | $table->softDeletes();
20 | NestedSet::columns($table);
21 | });
22 |
23 | Capsule::enableQueryLog();
24 | }
25 |
26 | public function setUp(): void
27 | {
28 | $data = include __DIR__.'/data/categories.php';
29 |
30 | Capsule::table('categories')->insert($data);
31 |
32 | Capsule::flushQueryLog();
33 |
34 | Category::resetActionsPerformed();
35 |
36 | date_default_timezone_set('America/Denver');
37 | }
38 |
39 | public function tearDown(): void
40 | {
41 | Capsule::table('categories')->truncate();
42 | }
43 |
44 | // public static function tearDownAfterClass()
45 | // {
46 | // $log = Capsule::getQueryLog();
47 | // foreach ($log as $item) {
48 | // echo $item['query']." with ".implode(', ', $item['bindings'])."\n";
49 | // }
50 | // }
51 |
52 | public function assertTreeNotBroken($table = 'categories')
53 | {
54 | $checks = array();
55 |
56 | $connection = Capsule::connection();
57 |
58 | $table = $connection->getQueryGrammar()->wrapTable($table);
59 |
60 | // Check if lft and rgt values are ok
61 | $checks[] = "from $table where _lft >= _rgt or (_rgt - _lft) % 2 = 0";
62 |
63 | // Check if lft and rgt values are unique
64 | $checks[] = "from $table c1, $table c2 where c1.id <> c2.id and ".
65 | "(c1._lft=c2._lft or c1._rgt=c2._rgt or c1._lft=c2._rgt or c1._rgt=c2._lft)";
66 |
67 | // Check if parent_id is set correctly
68 | $checks[] = "from $table c, $table p, $table m where c.parent_id=p.id and m.id <> p.id and m.id <> c.id and ".
69 | "(c._lft not between p._lft and p._rgt or c._lft between m._lft and m._rgt and m._lft between p._lft and p._rgt)";
70 |
71 | foreach ($checks as $i => $check) {
72 | $checks[$i] = 'select 1 as error '.$check;
73 | }
74 |
75 | $sql = 'select max(error) as errors from ('.implode(' union ', $checks).') _';
76 |
77 | $actual = $connection->selectOne($sql);
78 |
79 | $this->assertEquals(null, $actual->errors, "The tree structure of $table is broken!");
80 | $actual = (array)Capsule::connection()->selectOne($sql);
81 |
82 | $this->assertEquals(array('errors' => null), $actual, "The tree structure of $table is broken!");
83 | }
84 |
85 | public function dumpTree($items = null)
86 | {
87 | if ( ! $items) $items = Category::withTrashed()->defaultOrder()->get();
88 |
89 | foreach ($items as $item) {
90 | echo PHP_EOL.($item->trashed() ? '-' : '+').' '.$item->name." ".$item->getKey().' '.$item->getLft()." ".$item->getRgt().' '.$item->getParentId();
91 | }
92 | }
93 |
94 | public function assertNodeReceivesValidValues($node)
95 | {
96 | $lft = $node->getLft();
97 | $rgt = $node->getRgt();
98 | $nodeInDb = $this->findCategory($node->name);
99 |
100 | $this->assertEquals(
101 | [ $nodeInDb->getLft(), $nodeInDb->getRgt() ],
102 | [ $lft, $rgt ],
103 | 'Node is not synced with database after save.'
104 | );
105 | }
106 |
107 | /**
108 | * @param $name
109 | *
110 | * @return \Category
111 | */
112 | public function findCategory($name, $withTrashed = false)
113 | {
114 | $q = new Category;
115 |
116 | $q = $withTrashed ? $q->withTrashed() : $q->newQuery();
117 |
118 | return $q->whereName($name)->first();
119 | }
120 |
121 | public function testTreeNotBroken()
122 | {
123 | $this->assertTreeNotBroken();
124 | $this->assertFalse(Category::isBroken());
125 | }
126 |
127 | public function nodeValues($node)
128 | {
129 | return array($node->_lft, $node->_rgt, $node->parent_id);
130 | }
131 |
132 | public function testGetsNodeData()
133 | {
134 | $data = Category::getNodeData(3);
135 |
136 | $this->assertEquals([ '_lft' => 3, '_rgt' => 4 ], $data);
137 | }
138 |
139 | public function testGetsPlainNodeData()
140 | {
141 | $data = Category::getPlainNodeData(3);
142 |
143 | $this->assertEquals([ 3, 4 ], $data);
144 | }
145 |
146 | public function testReceivesValidValuesWhenAppendedTo()
147 | {
148 | $node = new Category([ 'name' => 'test' ]);
149 | $root = Category::root();
150 |
151 | $accepted = array($root->_rgt, $root->_rgt + 1, $root->id);
152 |
153 | $root->appendNode($node);
154 |
155 | $this->assertTrue($node->hasMoved());
156 | $this->assertEquals($accepted, $this->nodeValues($node));
157 | $this->assertTreeNotBroken();
158 | $this->assertFalse($node->isDirty());
159 | $this->assertTrue($node->isDescendantOf($root));
160 | }
161 |
162 | public function testReceivesValidValuesWhenPrependedTo()
163 | {
164 | $root = Category::root();
165 | $node = new Category([ 'name' => 'test' ]);
166 | $root->prependNode($node);
167 |
168 | $this->assertTrue($node->hasMoved());
169 | $this->assertEquals(array($root->_lft + 1, $root->_lft + 2, $root->id), $this->nodeValues($node));
170 | $this->assertTreeNotBroken();
171 | $this->assertTrue($node->isDescendantOf($root));
172 | $this->assertTrue($root->isAncestorOf($node));
173 | $this->assertTrue($node->isChildOf($root));
174 | }
175 |
176 | public function testReceivesValidValuesWhenInsertedAfter()
177 | {
178 | $target = $this->findCategory('apple');
179 | $node = new Category([ 'name' => 'test' ]);
180 | $node->afterNode($target)->save();
181 |
182 | $this->assertTrue($node->hasMoved());
183 | $this->assertEquals(array($target->_rgt + 1, $target->_rgt + 2, $target->parent->id), $this->nodeValues($node));
184 | $this->assertTreeNotBroken();
185 | $this->assertFalse($node->isDirty());
186 | $this->assertTrue($node->isSiblingOf($target));
187 | }
188 |
189 | public function testReceivesValidValuesWhenInsertedBefore()
190 | {
191 | $target = $this->findCategory('apple');
192 | $node = new Category([ 'name' => 'test' ]);
193 | $node->beforeNode($target)->save();
194 |
195 | $this->assertTrue($node->hasMoved());
196 | $this->assertEquals(array($target->_lft, $target->_lft + 1, $target->parent->id), $this->nodeValues($node));
197 | $this->assertTreeNotBroken();
198 | }
199 |
200 | public function testCategoryMovesDown()
201 | {
202 | $node = $this->findCategory('apple');
203 | $target = $this->findCategory('mobile');
204 |
205 | $target->appendNode($node);
206 |
207 | $this->assertTrue($node->hasMoved());
208 | $this->assertNodeReceivesValidValues($node);
209 | $this->assertTreeNotBroken();
210 | }
211 |
212 | public function testCategoryMovesUp()
213 | {
214 | $node = $this->findCategory('samsung');
215 | $target = $this->findCategory('notebooks');
216 |
217 | $target->appendNode($node);
218 |
219 | $this->assertTrue($node->hasMoved());
220 | $this->assertTreeNotBroken();
221 | $this->assertNodeReceivesValidValues($node);
222 | }
223 |
224 | public function testFailsToInsertIntoChild()
225 | {
226 | $this->expectException(Exception::class);
227 |
228 | $node = $this->findCategory('notebooks');
229 | $target = $node->children()->first();
230 |
231 | $node->afterNode($target)->save();
232 | }
233 |
234 | public function testFailsToAppendIntoItself()
235 | {
236 | $this->expectException(Exception::class);
237 |
238 | $node = $this->findCategory('notebooks');
239 |
240 | $node->appendToNode($node)->save();
241 | }
242 |
243 | public function testFailsToPrependIntoItself()
244 | {
245 | $this->expectException(Exception::class);
246 |
247 | $node = $this->findCategory('notebooks');
248 |
249 | $node->prependTo($node)->save();
250 | }
251 |
252 | public function testWithoutRootWorks()
253 | {
254 | $result = Category::withoutRoot()->pluck('name');
255 |
256 | $this->assertNotEquals('store', $result);
257 | }
258 |
259 | public function testAncestorsReturnsAncestorsWithoutNodeItself()
260 | {
261 | $node = $this->findCategory('apple');
262 | $path = all($node->ancestors()->pluck('name'));
263 |
264 | $this->assertEquals(array('store', 'notebooks'), $path);
265 | }
266 |
267 | public function testGetsAncestorsByStatic()
268 | {
269 | $path = all(Category::ancestorsOf(3)->pluck('name'));
270 |
271 | $this->assertEquals(array('store', 'notebooks'), $path);
272 | }
273 |
274 | public function testGetsAncestorsDirect()
275 | {
276 | $path = all(Category::find(8)->getAncestors()->pluck('id'));
277 |
278 | $this->assertEquals(array(1, 5, 7), $path);
279 | }
280 |
281 | public function testDescendants()
282 | {
283 | $node = $this->findCategory('mobile');
284 | $descendants = all($node->descendants()->pluck('name'));
285 | $expected = array('nokia', 'samsung', 'galaxy', 'sony', 'lenovo');
286 |
287 | $this->assertEquals($expected, $descendants);
288 |
289 | $descendants = all($node->getDescendants()->pluck('name'));
290 |
291 | $this->assertEquals(count($descendants), $node->getDescendantCount());
292 | $this->assertEquals($expected, $descendants);
293 |
294 | $descendants = all(Category::descendantsAndSelf(7)->pluck('name'));
295 | $expected = [ 'samsung', 'galaxy' ];
296 |
297 | $this->assertEquals($expected, $descendants);
298 | }
299 |
300 | public function testWithDepthWorks()
301 | {
302 | $nodes = all(Category::withDepth()->limit(4)->pluck('depth'));
303 |
304 | $this->assertEquals(array(0, 1, 2, 2), $nodes);
305 | }
306 |
307 | public function testWithDepthWithCustomKeyWorks()
308 | {
309 | $node = Category::whereIsRoot()->withDepth('level')->first();
310 |
311 | $this->assertTrue(isset($node['level']));
312 | }
313 |
314 | public function testWithDepthWorksAlongWithDefaultKeys()
315 | {
316 | $node = Category::withDepth()->first();
317 |
318 | $this->assertTrue(isset($node->name));
319 | }
320 |
321 | public function testParentIdAttributeAccessorAppendsNode()
322 | {
323 | $node = new Category(array('name' => 'lg', 'parent_id' => 5));
324 | $node->save();
325 |
326 | $this->assertEquals(5, $node->parent_id);
327 | $this->assertEquals(5, $node->getParentId());
328 |
329 | $node->parent_id = null;
330 | $node->save();
331 |
332 | $node->refreshNode();
333 |
334 | $this->assertEquals(null, $node->parent_id);
335 | $this->assertTrue($node->isRoot());
336 | }
337 |
338 | public function testFailsToSaveNodeUntilNotInserted()
339 | {
340 | $this->expectException(Exception::class);
341 |
342 | $node = new Category;
343 | $node->save();
344 | }
345 |
346 | public function testNodeIsDeletedWithDescendants()
347 | {
348 | $node = $this->findCategory('mobile');
349 | $node->forceDelete();
350 |
351 | $this->assertTreeNotBroken();
352 |
353 | $nodes = Category::whereIn('id', array(5, 6, 7, 8, 9))->count();
354 | $this->assertEquals(0, $nodes);
355 |
356 | $root = Category::root();
357 | $this->assertEquals(8, $root->getRgt());
358 | }
359 |
360 | public function testNodeIsSoftDeleted()
361 | {
362 | $root = Category::root();
363 |
364 | $samsung = $this->findCategory('samsung');
365 | $samsung->delete();
366 |
367 | $this->assertTreeNotBroken();
368 |
369 | $this->assertNull($this->findCategory('galaxy'));
370 |
371 | sleep(1);
372 |
373 | $node = $this->findCategory('mobile');
374 | $node->delete();
375 |
376 | $nodes = Category::whereIn('id', array(5, 6, 7, 8, 9))->count();
377 | $this->assertEquals(0, $nodes);
378 |
379 | $originalRgt = $root->getRgt();
380 | $root->refreshNode();
381 |
382 | $this->assertEquals($originalRgt, $root->getRgt());
383 |
384 | $node = $this->findCategory('mobile', true);
385 |
386 | $node->restore();
387 |
388 | $this->assertNull($this->findCategory('samsung'));
389 | $this->assertNotNull($this->findCategory('nokia'));
390 | }
391 |
392 | public function testSoftDeletedNodeisDeletedWhenParentIsDeleted()
393 | {
394 | $this->findCategory('samsung')->delete();
395 |
396 | $this->findCategory('mobile')->forceDelete();
397 |
398 | $this->assertTreeNotBroken();
399 |
400 | $this->assertNull($this->findCategory('samsung', true));
401 | $this->assertNull($this->findCategory('sony'));
402 | }
403 |
404 | public function testFailsToSaveNodeUntilParentIsSaved()
405 | {
406 | $this->expectException(Exception::class);
407 |
408 | $node = new Category(array('title' => 'Node'));
409 | $parent = new Category(array('title' => 'Parent'));
410 |
411 | $node->appendTo($parent)->save();
412 | }
413 |
414 | public function testSiblings()
415 | {
416 | $node = $this->findCategory('samsung');
417 | $siblings = all($node->siblings()->pluck('id'));
418 | $next = all($node->nextSiblings()->pluck('id'));
419 | $prev = all($node->prevSiblings()->pluck('id'));
420 |
421 | $this->assertEquals(array(6, 9, 10), $siblings);
422 | $this->assertEquals(array(9, 10), $next);
423 | $this->assertEquals(array(6), $prev);
424 |
425 | $siblings = all($node->getSiblings()->pluck('id'));
426 | $next = all($node->getNextSiblings()->pluck('id'));
427 | $prev = all($node->getPrevSiblings()->pluck('id'));
428 |
429 | $this->assertEquals(array(6, 9, 10), $siblings);
430 | $this->assertEquals(array(9, 10), $next);
431 | $this->assertEquals(array(6), $prev);
432 |
433 | $next = $node->getNextSibling();
434 | $prev = $node->getPrevSibling();
435 |
436 | $this->assertEquals(9, $next->id);
437 | $this->assertEquals(6, $prev->id);
438 | }
439 |
440 | public function testFetchesReversed()
441 | {
442 | $node = $this->findCategory('sony');
443 | $siblings = $node->prevSiblings()->reversed()->value('id');
444 |
445 | $this->assertEquals(7, $siblings);
446 | }
447 |
448 | public function testToTreeBuildsWithDefaultOrder()
449 | {
450 | $tree = Category::whereBetween('_lft', array(8, 17))->defaultOrder()->get()->toTree();
451 |
452 | $this->assertEquals(1, count($tree));
453 |
454 | $root = $tree->first();
455 | $this->assertEquals('mobile', $root->name);
456 | $this->assertEquals(4, count($root->children));
457 | }
458 |
459 | public function testToTreeBuildsWithCustomOrder()
460 | {
461 | $tree = Category::whereBetween('_lft', array(8, 17))
462 | ->orderBy('title')
463 | ->get()
464 | ->toTree();
465 |
466 | $this->assertEquals(1, count($tree));
467 |
468 | $root = $tree->first();
469 | $this->assertEquals('mobile', $root->name);
470 | $this->assertEquals(4, count($root->children));
471 | $this->assertEquals($root, $root->children->first()->parent);
472 | }
473 |
474 | public function testToTreeWithSpecifiedRoot()
475 | {
476 | $node = $this->findCategory('mobile');
477 | $nodes = Category::whereBetween('_lft', array(8, 17))->get();
478 |
479 | $tree1 = \Kalnoy\Nestedset\Collection::make($nodes)->toTree(5);
480 | $tree2 = \Kalnoy\Nestedset\Collection::make($nodes)->toTree($node);
481 |
482 | $this->assertEquals(4, $tree1->count());
483 | $this->assertEquals(4, $tree2->count());
484 | }
485 |
486 | public function testToTreeBuildsWithDefaultOrderAndMultipleRootNodes()
487 | {
488 | $tree = Category::withoutRoot()->get()->toTree();
489 |
490 | $this->assertEquals(2, count($tree));
491 | }
492 |
493 | public function testToTreeBuildsWithRootItemIdProvided()
494 | {
495 | $tree = Category::whereBetween('_lft', array(8, 17))->get()->toTree(5);
496 |
497 | $this->assertEquals(4, count($tree));
498 |
499 | $root = $tree[1];
500 | $this->assertEquals('samsung', $root->name);
501 | $this->assertEquals(1, count($root->children));
502 | }
503 |
504 | public function testRetrievesNextNode()
505 | {
506 | $node = $this->findCategory('apple');
507 | $next = $node->nextNodes()->first();
508 |
509 | $this->assertEquals('lenovo', $next->name);
510 | }
511 |
512 | public function testRetrievesPrevNode()
513 | {
514 | $node = $this->findCategory('apple');
515 | $next = $node->getPrevNode();
516 |
517 | $this->assertEquals('notebooks', $next->name);
518 | }
519 |
520 | public function testMultipleAppendageWorks()
521 | {
522 | $parent = $this->findCategory('mobile');
523 |
524 | $child = new Category([ 'name' => 'test' ]);
525 |
526 | $parent->appendNode($child);
527 |
528 | $child->appendNode(new Category([ 'name' => 'sub' ]));
529 |
530 | $parent->appendNode(new Category([ 'name' => 'test2' ]));
531 |
532 | $this->assertTreeNotBroken();
533 | }
534 |
535 | public function testDefaultCategoryIsSavedAsRoot()
536 | {
537 | $node = new Category([ 'name' => 'test' ]);
538 | $node->save();
539 |
540 | $this->assertEquals(23, $node->_lft);
541 | $this->assertTreeNotBroken();
542 |
543 | $this->assertTrue($node->isRoot());
544 | }
545 |
546 | public function testExistingCategorySavedAsRoot()
547 | {
548 | $node = $this->findCategory('apple');
549 | $node->saveAsRoot();
550 |
551 | $this->assertTreeNotBroken();
552 | $this->assertTrue($node->isRoot());
553 | }
554 |
555 | public function testNodeMovesDownSeveralPositions()
556 | {
557 | $node = $this->findCategory('nokia');
558 |
559 | $this->assertTrue($node->down(2));
560 |
561 | $this->assertEquals($node->_lft, 15);
562 | }
563 |
564 | public function testNodeMovesUpSeveralPositions()
565 | {
566 | $node = $this->findCategory('sony');
567 |
568 | $this->assertTrue($node->up(2));
569 |
570 | $this->assertEquals($node->_lft, 9);
571 | }
572 |
573 | public function testCountsTreeErrors()
574 | {
575 | $errors = Category::countErrors();
576 |
577 | $this->assertEquals([ 'oddness' => 0,
578 | 'duplicates' => 0,
579 | 'wrong_parent' => 0,
580 | 'missing_parent' => 0 ], $errors);
581 |
582 | Category::where('id', '=', 5)->update([ '_lft' => 14 ]);
583 | Category::where('id', '=', 8)->update([ 'parent_id' => 2 ]);
584 | Category::where('id', '=', 11)->update([ '_lft' => 20 ]);
585 | Category::where('id', '=', 4)->update([ 'parent_id' => 24 ]);
586 |
587 | $errors = Category::countErrors();
588 |
589 | $this->assertEquals(1, $errors['oddness']);
590 | $this->assertEquals(2, $errors['duplicates']);
591 | $this->assertEquals(1, $errors['missing_parent']);
592 | }
593 |
594 | public function testCreatesNode()
595 | {
596 | $node = Category::create([ 'name' => 'test' ]);
597 |
598 | $this->assertEquals(23, $node->getLft());
599 | }
600 |
601 | public function testCreatesViaRelationship()
602 | {
603 | $node = $this->findCategory('apple');
604 |
605 | $child = $node->children()->create([ 'name' => 'test' ]);
606 |
607 | $this->assertTreeNotBroken();
608 | }
609 |
610 | public function testCreatesTree()
611 | {
612 | $node = Category::create(
613 | [
614 | 'name' => 'test',
615 | 'children' =>
616 | [
617 | [ 'name' => 'test2' ],
618 | [ 'name' => 'test3' ],
619 | ],
620 | ]);
621 |
622 | $this->assertTreeNotBroken();
623 |
624 | $this->assertTrue(isset($node->children));
625 |
626 | $node = $this->findCategory('test');
627 |
628 | $this->assertCount(2, $node->children);
629 | $this->assertEquals('test2', $node->children[0]->name);
630 | }
631 |
632 | public function testDescendantsOfNonExistingNode()
633 | {
634 | $node = new Category;
635 |
636 | $this->assertTrue($node->getDescendants()->isEmpty());
637 | }
638 |
639 | public function testWhereDescendantsOf()
640 | {
641 | $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
642 |
643 | Category::whereDescendantOf(124)->get();
644 | }
645 |
646 | public function testAncestorsByNode()
647 | {
648 | $category = $this->findCategory('apple');
649 | $ancestors = all(Category::whereAncestorOf($category)->pluck('id'));
650 |
651 | $this->assertEquals([ 1, 2 ], $ancestors);
652 | }
653 |
654 | public function testDescendantsByNode()
655 | {
656 | $category = $this->findCategory('notebooks');
657 | $res = all(Category::whereDescendantOf($category)->pluck('id'));
658 |
659 | $this->assertEquals([ 3, 4 ], $res);
660 | }
661 |
662 | public function testMultipleDeletionsDoNotBrakeTree()
663 | {
664 | $category = $this->findCategory('mobile');
665 |
666 | foreach ($category->children()->take(2)->get() as $child)
667 | {
668 | $child->forceDelete();
669 | }
670 |
671 | $this->assertTreeNotBroken();
672 | }
673 |
674 | public function testTreeIsFixed()
675 | {
676 | Category::where('id', '=', 5)->update([ '_lft' => 14 ]);
677 | Category::where('id', '=', 8)->update([ 'parent_id' => 2 ]);
678 | Category::where('id', '=', 11)->update([ '_lft' => 20 ]);
679 | Category::where('id', '=', 2)->update([ 'parent_id' => 24 ]);
680 |
681 | $fixed = Category::fixTree();
682 |
683 | $this->assertTrue($fixed > 0);
684 | $this->assertTreeNotBroken();
685 |
686 | $node = Category::find(8);
687 |
688 | $this->assertEquals(2, $node->getParentId());
689 |
690 | $node = Category::find(2);
691 |
692 | $this->assertEquals(null, $node->getParentId());
693 | }
694 |
695 | public function testSubtreeIsFixed()
696 | {
697 | Category::where('id', '=', 8)->update([ '_lft' => 11 ]);
698 |
699 | $fixed = Category::fixSubtree(Category::find(5));
700 | $this->assertEquals($fixed, 1);
701 | $this->assertTreeNotBroken();
702 | $this->assertEquals(Category::find(8)->getLft(), 12);
703 | }
704 |
705 | public function testParentIdDirtiness()
706 | {
707 | $node = $this->findCategory('apple');
708 | $node->parent_id = 5;
709 |
710 | $this->assertTrue($node->isDirty('parent_id'));
711 |
712 | $node = $this->findCategory('apple');
713 | $node->parent_id = null;
714 |
715 | $this->assertTrue($node->isDirty('parent_id'));
716 | }
717 |
718 | public function testIsDirtyMovement()
719 | {
720 | $node = $this->findCategory('apple');
721 | $otherNode = $this->findCategory('samsung');
722 |
723 | $this->assertFalse($node->isDirty());
724 |
725 | $node->afterNode($otherNode);
726 |
727 | $this->assertTrue($node->isDirty());
728 |
729 | $node = $this->findCategory('apple');
730 | $otherNode = $this->findCategory('samsung');
731 |
732 | $this->assertFalse($node->isDirty());
733 |
734 | $node->appendToNode($otherNode);
735 |
736 | $this->assertTrue($node->isDirty());
737 | }
738 |
739 | public function testRootNodesMoving()
740 | {
741 | $node = $this->findCategory('store');
742 | $node->down();
743 |
744 | $this->assertEquals(3, $node->getLft());
745 | }
746 |
747 | public function testDescendantsRelation()
748 | {
749 | $node = $this->findCategory('notebooks');
750 | $result = $node->descendants;
751 |
752 | $this->assertEquals(2, $result->count());
753 | $this->assertEquals('apple', $result->first()->name);
754 | }
755 |
756 | public function testDescendantsEagerlyLoaded()
757 | {
758 | $nodes = Category::whereIn('id', [ 2, 5 ])->get();
759 |
760 | $nodes->load('descendants');
761 |
762 | $this->assertEquals(2, $nodes->count());
763 | $this->assertTrue($nodes->first()->relationLoaded('descendants'));
764 | }
765 |
766 | public function testDescendantsRelationQuery()
767 | {
768 | $nodes = Category::has('descendants')->whereIn('id', [ 2, 3 ])->get();
769 |
770 | $this->assertEquals(1, $nodes->count());
771 | $this->assertEquals(2, $nodes->first()->getKey());
772 |
773 | $nodes = Category::has('descendants', '>', 2)->get();
774 |
775 | $this->assertEquals(2, $nodes->count());
776 | $this->assertEquals(1, $nodes[0]->getKey());
777 | $this->assertEquals(5, $nodes[1]->getKey());
778 | }
779 |
780 | public function testParentRelationQuery()
781 | {
782 | $nodes = Category::has('parent')->whereIn('id', [ 1, 2 ]);
783 |
784 | $this->assertEquals(1, $nodes->count());
785 | $this->assertEquals(2, $nodes->first()->getKey());
786 | }
787 |
788 | public function testRebuildTree()
789 | {
790 | $fixed = Category::rebuildTree([
791 | [
792 | 'id' => 1,
793 | 'children' => [
794 | [ 'id' => 10 ],
795 | [ 'id' => 3, 'name' => 'apple v2', 'children' => [ [ 'name' => 'new node' ] ] ],
796 | [ 'id' => 2 ],
797 |
798 | ]
799 | ]
800 | ]);
801 |
802 | $this->assertTrue($fixed > 0);
803 | $this->assertTreeNotBroken();
804 |
805 | $node = Category::find(3);
806 |
807 | $this->assertEquals(1, $node->getParentId());
808 | $this->assertEquals('apple v2', $node->name);
809 | $this->assertEquals(4, $node->getLft());
810 |
811 | $node = $this->findCategory('new node');
812 |
813 | $this->assertNotNull($node);
814 | $this->assertEquals(3, $node->getParentId());
815 | }
816 |
817 | public function testRebuildSubtree()
818 | {
819 | $fixed = Category::rebuildSubtree(Category::find(7), [
820 | [ 'name' => 'new node' ],
821 | [ 'id' => '8' ],
822 | ]);
823 |
824 | $this->assertTrue($fixed > 0);
825 | $this->assertTreeNotBroken();
826 |
827 | $node = $this->findCategory('new node');
828 |
829 | $this->assertNotNull($node);
830 | $this->assertEquals($node->getLft(), 12);
831 | }
832 |
833 | public function testRebuildTreeWithDeletion()
834 | {
835 | Category::rebuildTree([ [ 'name' => 'all deleted' ] ], true);
836 |
837 | $this->assertTreeNotBroken();
838 |
839 | $nodes = Category::get();
840 |
841 | $this->assertEquals(1, $nodes->count());
842 | $this->assertEquals('all deleted', $nodes->first()->name);
843 |
844 | $nodes = Category::withTrashed()->get();
845 |
846 | $this->assertTrue($nodes->count() > 1);
847 | }
848 |
849 | public function testRebuildFailsWithInvalidPK()
850 | {
851 | $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
852 |
853 | Category::rebuildTree([ [ 'id' => 24 ] ]);
854 | }
855 |
856 | public function testFlatTree()
857 | {
858 | $node = $this->findCategory('mobile');
859 | $tree = $node->descendants()->orderBy('name')->get()->toFlatTree();
860 |
861 | $this->assertCount(5, $tree);
862 | $this->assertEquals('samsung', $tree[2]->name);
863 | $this->assertEquals('galaxy', $tree[3]->name);
864 | }
865 |
866 | // Commented, cause there is no assertion here and otherwise the test is marked as risky in PHPUnit 7.
867 | // What's the purpose of this method? @todo: remove/update?
868 | /*public function testSeveralNodesModelWork()
869 | {
870 | $category = new Category;
871 |
872 | $category->name = 'test';
873 |
874 | $category->saveAsRoot();
875 |
876 | $duplicate = new DuplicateCategory;
877 |
878 | $duplicate->name = 'test';
879 |
880 | $duplicate->saveAsRoot();
881 | }*/
882 |
883 | public function testWhereIsLeaf()
884 | {
885 | $categories = Category::leaves();
886 |
887 | $this->assertEquals(7, $categories->count());
888 | $this->assertEquals('apple', $categories->first()->name);
889 | $this->assertTrue($categories->first()->isLeaf());
890 |
891 | $category = Category::whereIsRoot()->first();
892 |
893 | $this->assertFalse($category->isLeaf());
894 | }
895 |
896 | public function testEagerLoadAncestors()
897 | {
898 | $queryLogCount = count(Capsule::connection()->getQueryLog());
899 | $categories = Category::with('ancestors')->orderBy('name')->get();
900 |
901 | $this->assertEquals($queryLogCount + 2, count(Capsule::connection()->getQueryLog()));
902 |
903 | $expectedShape = [
904 | 'apple (3)}' => 'store (1) > notebooks (2)',
905 | 'galaxy (8)}' => 'store (1) > mobile (5) > samsung (7)',
906 | 'lenovo (4)}' => 'store (1) > notebooks (2)',
907 | 'lenovo (10)}' => 'store (1) > mobile (5)',
908 | 'mobile (5)}' => 'store (1)',
909 | 'nokia (6)}' => 'store (1) > mobile (5)',
910 | 'notebooks (2)}' => 'store (1)',
911 | 'samsung (7)}' => 'store (1) > mobile (5)',
912 | 'sony (9)}' => 'store (1) > mobile (5)',
913 | 'store (1)}' => '',
914 | 'store_2 (11)}' => ''
915 | ];
916 |
917 | $output = [];
918 |
919 | foreach ($categories as $category) {
920 | $output["{$category->name} ({$category->id})}"] = $category->ancestors->count()
921 | ? implode(' > ', $category->ancestors->map(function ($cat) { return "{$cat->name} ({$cat->id})"; })->toArray())
922 | : '';
923 | }
924 |
925 | $this->assertEquals($expectedShape, $output);
926 | }
927 |
928 | public function testLazyLoadAncestors()
929 | {
930 | $queryLogCount = count(Capsule::connection()->getQueryLog());
931 | $categories = Category::orderBy('name')->get();
932 |
933 | $this->assertEquals($queryLogCount + 1, count(Capsule::connection()->getQueryLog()));
934 |
935 | $expectedShape = [
936 | 'apple (3)}' => 'store (1) > notebooks (2)',
937 | 'galaxy (8)}' => 'store (1) > mobile (5) > samsung (7)',
938 | 'lenovo (4)}' => 'store (1) > notebooks (2)',
939 | 'lenovo (10)}' => 'store (1) > mobile (5)',
940 | 'mobile (5)}' => 'store (1)',
941 | 'nokia (6)}' => 'store (1) > mobile (5)',
942 | 'notebooks (2)}' => 'store (1)',
943 | 'samsung (7)}' => 'store (1) > mobile (5)',
944 | 'sony (9)}' => 'store (1) > mobile (5)',
945 | 'store (1)}' => '',
946 | 'store_2 (11)}' => ''
947 | ];
948 |
949 | $output = [];
950 |
951 | foreach ($categories as $category) {
952 | $output["{$category->name} ({$category->id})}"] = $category->ancestors->count()
953 | ? implode(' > ', $category->ancestors->map(function ($cat) { return "{$cat->name} ({$cat->id})"; })->toArray())
954 | : '';
955 | }
956 |
957 | // assert that there is number of original query + 1 + number of rows to fulfill the relation
958 | $this->assertEquals($queryLogCount + 12, count(Capsule::connection()->getQueryLog()));
959 |
960 | $this->assertEquals($expectedShape, $output);
961 | }
962 |
963 | public function testWhereHasCountQueryForAncestors()
964 | {
965 | $categories = all(Category::has('ancestors', '>', 2)->pluck('name'));
966 |
967 | $this->assertEquals([ 'galaxy' ], $categories);
968 |
969 | $categories = all(Category::whereHas('ancestors', function ($query) {
970 | $query->where('id', 5);
971 | })->pluck('name'));
972 |
973 | $this->assertEquals([ 'nokia', 'samsung', 'galaxy', 'sony', 'lenovo' ], $categories);
974 | }
975 |
976 | public function testReplication()
977 | {
978 | $category = $this->findCategory('nokia');
979 | $category = $category->replicate();
980 | $category->save();
981 | $category->refreshNode();
982 |
983 | $this->assertNull($category->getParentId());
984 |
985 | $category = $this->findCategory('nokia');
986 | $category = $category->replicate();
987 | $category->parent_id = 1;
988 | $category->save();
989 |
990 | $category->refreshNode();
991 |
992 | $this->assertEquals(1, $category->getParentId());
993 | }
994 |
995 | }
996 |
997 | function all($items)
998 | {
999 | return is_array($items) ? $items : $items->all();
1000 | }
1001 |
--------------------------------------------------------------------------------
/tests/ScopedNodeTest.php:
--------------------------------------------------------------------------------
1 | dropIfExists('menu_items');
13 |
14 | Capsule::disableQueryLog();
15 |
16 | $schema->create('menu_items', function (\Illuminate\Database\Schema\Blueprint $table) {
17 | $table->increments('id');
18 | $table->unsignedInteger('menu_id');
19 | $table->string('title')->nullable();
20 | NestedSet::columns($table);
21 | });
22 |
23 | Capsule::enableQueryLog();
24 | }
25 |
26 | public function setUp(): void
27 | {
28 | $data = include __DIR__.'/data/menu_items.php';
29 |
30 | Capsule::table('menu_items')->insert($data);
31 |
32 | Capsule::flushQueryLog();
33 |
34 | MenuItem::resetActionsPerformed();
35 |
36 | date_default_timezone_set('America/Denver');
37 | }
38 |
39 | public function tearDown(): void
40 | {
41 | Capsule::table('menu_items')->truncate();
42 | }
43 |
44 | public function assertTreeNotBroken($menuId)
45 | {
46 | $this->assertFalse(MenuItem::scoped([ 'menu_id' => $menuId ])->isBroken());
47 | }
48 |
49 | public function testNotBroken()
50 | {
51 | $this->assertTreeNotBroken(1);
52 | $this->assertTreeNotBroken(2);
53 | }
54 |
55 | public function testMovingNodeNotAffectingOtherMenu()
56 | {
57 | $node = MenuItem::where('menu_id', '=', 1)->first();
58 |
59 | $node->down();
60 |
61 | $node = MenuItem::where('menu_id', '=', 2)->first();
62 |
63 | $this->assertEquals(1, $node->getLft());
64 | }
65 |
66 | public function testScoped()
67 | {
68 | $node = MenuItem::scoped([ 'menu_id' => 2 ])->first();
69 |
70 | $this->assertEquals(3, $node->getKey());
71 | }
72 |
73 | public function testSiblings()
74 | {
75 | $node = MenuItem::find(1);
76 |
77 | $result = $node->getSiblings();
78 |
79 | $this->assertEquals(1, $result->count());
80 | $this->assertEquals(2, $result->first()->getKey());
81 |
82 | $result = $node->getNextSiblings();
83 |
84 | $this->assertEquals(2, $result->first()->getKey());
85 |
86 | $node = MenuItem::find(2);
87 |
88 | $result = $node->getPrevSiblings();
89 |
90 | $this->assertEquals(1, $result->first()->getKey());
91 | }
92 |
93 | public function testDescendants()
94 | {
95 | $node = MenuItem::find(2);
96 |
97 | $result = $node->getDescendants();
98 |
99 | $this->assertEquals(1, $result->count());
100 | $this->assertEquals(5, $result->first()->getKey());
101 |
102 | $node = MenuItem::scoped([ 'menu_id' => 1 ])->with('descendants')->find(2);
103 |
104 | $result = $node->descendants;
105 |
106 | $this->assertEquals(1, $result->count());
107 | $this->assertEquals(5, $result->first()->getKey());
108 | }
109 |
110 | public function testAncestors()
111 | {
112 | $node = MenuItem::find(5);
113 |
114 | $result = $node->getAncestors();
115 |
116 | $this->assertEquals(1, $result->count());
117 | $this->assertEquals(2, $result->first()->getKey());
118 |
119 | $node = MenuItem::scoped([ 'menu_id' => 1 ])->with('ancestors')->find(5);
120 |
121 | $result = $node->ancestors;
122 |
123 | $this->assertEquals(1, $result->count());
124 | $this->assertEquals(2, $result->first()->getKey());
125 | }
126 |
127 | public function testDepth()
128 | {
129 | $node = MenuItem::scoped([ 'menu_id' => 1 ])->withDepth()->where('id', '=', 5)->first();
130 |
131 | $this->assertEquals(1, $node->depth);
132 |
133 | $node = MenuItem::find(2);
134 |
135 | $result = $node->children()->withDepth()->get();
136 |
137 | $this->assertEquals(1, $result->first()->depth);
138 | }
139 |
140 | public function testSaveAsRoot()
141 | {
142 | $node = MenuItem::find(5);
143 |
144 | $node->saveAsRoot();
145 |
146 | $this->assertEquals(5, $node->getLft());
147 | $this->assertEquals(null, $node->parent_id);
148 |
149 | $this->assertOtherScopeNotAffected();
150 | }
151 |
152 | public function testInsertion()
153 | {
154 | $node = MenuItem::create([ 'menu_id' => 1, 'parent_id' => 5 ]);
155 |
156 | $this->assertEquals(5, $node->parent_id);
157 | $this->assertEquals(5, $node->getLft());
158 |
159 | $this->assertOtherScopeNotAffected();
160 | }
161 |
162 | public function testInsertionToParentFromOtherScope()
163 | {
164 | $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
165 |
166 | $node = MenuItem::create([ 'menu_id' => 2, 'parent_id' => 5 ]);
167 | }
168 |
169 | public function testDeletion()
170 | {
171 | $node = MenuItem::find(2)->delete();
172 |
173 | $node = MenuItem::find(1);
174 |
175 | $this->assertEquals(2, $node->getRgt());
176 |
177 | $this->assertOtherScopeNotAffected();
178 | }
179 |
180 | public function testMoving()
181 | {
182 | $node = MenuItem::find(1);
183 | $this->assertTrue($node->down());
184 |
185 | $this->assertOtherScopeNotAffected();
186 | }
187 |
188 | protected function assertOtherScopeNotAffected()
189 | {
190 | $node = MenuItem::find(3);
191 |
192 | $this->assertEquals(1, $node->getLft());
193 | }
194 |
195 | // Commented, cause there is no assertion here and otherwise the test is marked as risky in PHPUnit 7.
196 | // What's the purpose of this method? @todo: remove/update?
197 | /*public function testRebuildsTree()
198 | {
199 | $data = [];
200 | MenuItem::scoped([ 'menu_id' => 2 ])->rebuildTree($data);
201 | }*/
202 |
203 | public function testAppendingToAnotherScopeFails()
204 | {
205 | $this->expectException(LogicException::class);
206 |
207 | $a = MenuItem::find(1);
208 | $b = MenuItem::find(3);
209 |
210 | $a->appendToNode($b)->save();
211 | }
212 |
213 | public function testInsertingBeforeAnotherScopeFails()
214 | {
215 | $this->expectException(LogicException::class);
216 |
217 | $a = MenuItem::find(1);
218 | $b = MenuItem::find(3);
219 |
220 | $a->insertAfterNode($b);
221 | }
222 |
223 | public function testEagerLoadingAncestorsWithScope()
224 | {
225 | $filteredNodes = MenuItem::where('title', 'menu item 3')->with(['ancestors'])->get();
226 |
227 | $this->assertEquals(2, $filteredNodes->find(5)->ancestors[0]->id);
228 | $this->assertEquals(4, $filteredNodes->find(6)->ancestors[0]->id);
229 | }
230 |
231 | public function testEagerLoadingDescendantsWithScope()
232 | {
233 | $filteredNodes = MenuItem::where('title', 'menu item 2')->with(['descendants'])->get();
234 |
235 | $this->assertEquals(5, $filteredNodes->find(2)->descendants[0]->id);
236 | $this->assertEquals(6, $filteredNodes->find(4)->descendants[0]->id);
237 | }
238 | }
--------------------------------------------------------------------------------
/tests/data/categories.php:
--------------------------------------------------------------------------------
1 | 1, 'name' => 'store', '_lft' => 1, '_rgt' => 20, 'parent_id' => null),
5 | array('id' => 2, 'name' => 'notebooks', '_lft' => 2, '_rgt' => 7, 'parent_id' => 1),
6 | array('id' => 3, 'name' => 'apple', '_lft' => 3, '_rgt' => 4, 'parent_id' => 2),
7 | array('id' => 4, 'name' => 'lenovo', '_lft' => 5, '_rgt' => 6, 'parent_id' => 2),
8 | array('id' => 5, 'name' => 'mobile', '_lft' => 8, '_rgt' => 19, 'parent_id' => 1),
9 | array('id' => 6, 'name' => 'nokia', '_lft' => 9, '_rgt' => 10, 'parent_id' => 5),
10 | array('id' => 7, 'name' => 'samsung', '_lft' => 11, '_rgt' => 14, 'parent_id' => 5),
11 | array('id' => 8, 'name' => 'galaxy', '_lft' => 12, '_rgt' => 13, 'parent_id' => 7),
12 | array('id' => 9, 'name' => 'sony', '_lft' => 15, '_rgt' => 16, 'parent_id' => 5),
13 | array('id' => 10, 'name' => 'lenovo', '_lft' => 17, '_rgt' => 18, 'parent_id' => 5),
14 | array('id' => 11, 'name' => 'store_2', '_lft' => 21, '_rgt' => 22, 'parent_id' => null),
15 | );
--------------------------------------------------------------------------------
/tests/data/menu_items.php:
--------------------------------------------------------------------------------
1 | 1, 'menu_id' => 1, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1' ],
3 | [ 'id' => 2, 'menu_id' => 1, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2' ],
4 | [ 'id' => 5, 'menu_id' => 1, '_lft' => 4, '_rgt' => 5, 'parent_id' => 2, 'title' => 'menu item 3' ],
5 | [ 'id' => 3, 'menu_id' => 2, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1' ],
6 | [ 'id' => 4, 'menu_id' => 2, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2' ],
7 | [ 'id' => 6, 'menu_id' => 2, '_lft' => 4, '_rgt' => 5, 'parent_id' => 4, 'title' => 'menu item 3' ],
8 | ];
--------------------------------------------------------------------------------
/tests/models/Category.php:
--------------------------------------------------------------------------------
1 |