├── .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 | [![Build Status](https://travis-ci.org/lazychaser/laravel-nestedset.svg?branch=master)](https://travis-ci.org/lazychaser/laravel-nestedset) 2 | [![Total Downloads](https://poser.pugx.org/kalnoy/nestedset/downloads.svg)](https://packagist.org/packages/kalnoy/nestedset) 3 | [![Latest Stable Version](https://poser.pugx.org/kalnoy/nestedset/v/stable.svg)](https://packagist.org/packages/kalnoy/nestedset) 4 | [![Latest Unstable Version](https://poser.pugx.org/kalnoy/nestedset/v/unstable.svg)](https://packagist.org/packages/kalnoy/nestedset) 5 | [![License](https://poser.pugx.org/kalnoy/nestedset/license.svg)](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 |