├── .gitignore ├── NestedSetBehavior.php ├── NestedSetQuery.php ├── NestedSetQueryBehavior.php ├── README.md ├── composer.json └── schema ├── schema-many-roots.sql └── schema.sql /.gitignore: -------------------------------------------------------------------------------- 1 | # phpstorm project files 2 | .idea 3 | 4 | # netbeans project files 5 | nbproject 6 | 7 | # zend studio for eclipse project files 8 | .buildpath 9 | .project 10 | .settings 11 | 12 | # windows thumbnail cache 13 | Thumbs.db 14 | 15 | # composer vendor dir 16 | /vendor 17 | 18 | # composer itself is not needed 19 | composer.phar 20 | # composer.lock should not be committed as we always want the latest versions 21 | /composer.lock 22 | 23 | # Mac DS_Store Files 24 | .DS_Store 25 | 26 | # phpunit itself is not needed 27 | phpunit.phar 28 | # local phpunit config 29 | /phpunit.xml 30 | -------------------------------------------------------------------------------- /NestedSetBehavior.php: -------------------------------------------------------------------------------- 1 | 19 | * @author Wanderson Bragança 20 | */ 21 | class NestedSetBehavior extends Behavior 22 | { 23 | /** 24 | * @var ActiveQuery the owner of this behavior. 25 | */ 26 | public $owner; 27 | /** 28 | * @var bool 29 | */ 30 | public $hasManyRoots = true; 31 | /** 32 | * @var string 33 | */ 34 | public $titleAttribute = 'title'; 35 | /** 36 | * @var string 37 | */ 38 | public $idAttribute = 'id'; 39 | /** 40 | * @var string 41 | */ 42 | public $rootAttribute = 'root'; 43 | /** 44 | * @var string 45 | */ 46 | public $leftAttribute = 'lft'; 47 | /** 48 | * @var string 49 | */ 50 | public $rightAttribute = 'rgt'; 51 | /** 52 | * @var string 53 | */ 54 | public $levelAttribute = 'level'; 55 | /** 56 | * @var bool 57 | */ 58 | private $_ignoreEvent = false; 59 | /** 60 | * @var bool 61 | */ 62 | private $_deleted = false; 63 | /** 64 | * @var int 65 | */ 66 | private $_id; 67 | /** 68 | * @var array 69 | */ 70 | private static $_cached; 71 | /** 72 | * @var int 73 | */ 74 | private static $_c = 0; 75 | 76 | /** 77 | * @inheritdoc 78 | */ 79 | public function events() 80 | { 81 | return [ 82 | ActiveRecord::EVENT_AFTER_FIND => 'afterFind', 83 | ActiveRecord::EVENT_BEFORE_DELETE => 'beforeDelete', 84 | ActiveRecord::EVENT_BEFORE_INSERT => 'beforeInsert', 85 | ActiveRecord::EVENT_BEFORE_UPDATE => 'beforeUpdate', 86 | ]; 87 | } 88 | 89 | /** 90 | * @inheritdoc 91 | */ 92 | public function attach($owner) 93 | { 94 | parent::attach($owner); 95 | self::$_cached[get_class($this->owner)][$this->_id = self::$_c++] = $this->owner; 96 | } 97 | 98 | /** 99 | * Gets descendants for node. 100 | * @param int $depth the depth. 101 | * @return ActiveQuery. 102 | */ 103 | public function descendants($depth = null) 104 | { 105 | $query = $this->owner->find()->orderBy([$this->levelAttribute => SORT_ASC, $this->leftAttribute => SORT_ASC]); 106 | $db = $this->owner->getDb(); 107 | $query->andWhere($db->quoteColumnName($this->leftAttribute) . '>' 108 | . $this->owner->getAttribute($this->leftAttribute)); 109 | $query->andWhere($db->quoteColumnName($this->rightAttribute) . '<' 110 | . $this->owner->getAttribute($this->rightAttribute)); 111 | $query->addOrderBy($db->quoteColumnName($this->leftAttribute)); 112 | 113 | if ($depth !== null) { 114 | $query->andWhere($db->quoteColumnName($this->levelAttribute) . '<=' 115 | . ($this->owner->getAttribute($this->levelAttribute) + $depth)); 116 | } 117 | 118 | if ($this->hasManyRoots) { 119 | $query->andWhere( 120 | $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute, 121 | [':' . $this->rootAttribute => $this->owner->getAttribute($this->rootAttribute)] 122 | ); 123 | } 124 | 125 | return $query; 126 | } 127 | 128 | /** 129 | * Gets children for node (direct descendants only). 130 | * @return ActiveQuery. 131 | */ 132 | public function children() 133 | { 134 | return $this->descendants(1); 135 | } 136 | 137 | /** 138 | * Gets ancestors for node. 139 | * @param int $depth the depth. 140 | * @return ActiveQuery. 141 | */ 142 | public function ancestors($depth = null) 143 | { 144 | $query = $this->owner->find()->orderBy([$this->levelAttribute => SORT_DESC, $this->leftAttribute => SORT_ASC]); 145 | $db = $this->owner->getDb(); 146 | $query->andWhere($db->quoteColumnName($this->leftAttribute) . '<' 147 | . $this->owner->getAttribute($this->leftAttribute)); 148 | $query->andWhere($db->quoteColumnName($this->rightAttribute) . '>' 149 | . $this->owner->getAttribute($this->rightAttribute)); 150 | $query->addOrderBy($db->quoteColumnName($this->leftAttribute)); 151 | 152 | if ($depth !== null) { 153 | $query->andWhere($db->quoteColumnName($this->levelAttribute) . '>=' 154 | . ($this->owner->getAttribute($this->levelAttribute) - $depth)); 155 | } 156 | 157 | if ($this->hasManyRoots) { 158 | $query->andWhere( 159 | $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute, 160 | [':' . $this->rootAttribute => $this->owner->getAttribute($this->rootAttribute)] 161 | ); 162 | } 163 | 164 | return $query; 165 | } 166 | 167 | /** 168 | * Gets parent of node. 169 | * @return ActiveQuery. 170 | */ 171 | public function parent() 172 | { 173 | $query = $this->owner->find(); 174 | $db = $this->owner->getDb(); 175 | $query->andWhere($db->quoteColumnName($this->leftAttribute) . '<' 176 | . $this->owner->getAttribute($this->leftAttribute)); 177 | $query->andWhere($db->quoteColumnName($this->rightAttribute) . '>' 178 | . $this->owner->getAttribute($this->rightAttribute)); 179 | $query->addOrderBy($db->quoteColumnName($this->rightAttribute)); 180 | 181 | if ($this->hasManyRoots) { 182 | $query->andWhere( 183 | $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute, 184 | [':' . $this->rootAttribute => $this->owner->getAttribute($this->rootAttribute)] 185 | ); 186 | } 187 | 188 | return $query; 189 | } 190 | 191 | /** 192 | * Gets previous sibling of node. 193 | * @return ActiveQuery. 194 | */ 195 | public function prev() 196 | { 197 | $query = $this->owner->find(); 198 | $db = $this->owner->getDb(); 199 | $query->andWhere($db->quoteColumnName($this->rightAttribute) . '=' 200 | . ($this->owner->getAttribute($this->leftAttribute) - 1)); 201 | 202 | if ($this->hasManyRoots) { 203 | $query->andWhere( 204 | $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute, 205 | [':' . $this->rootAttribute => $this->owner->getAttribute($this->rootAttribute)] 206 | ); 207 | } 208 | 209 | return $query; 210 | } 211 | 212 | /** 213 | * Gets next sibling of node. 214 | * @return ActiveQuery. 215 | */ 216 | public function next() 217 | { 218 | $query = $this->owner->find(); 219 | $db = $this->owner->getDb(); 220 | $query->andWhere($db->quoteColumnName($this->leftAttribute) . '=' 221 | . ($this->owner->getAttribute($this->rightAttribute) + 1)); 222 | 223 | if ($this->hasManyRoots) { 224 | $query->andWhere( 225 | $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute, 226 | [':' . $this->rootAttribute => $this->owner->getAttribute($this->rootAttribute)] 227 | ); 228 | } 229 | 230 | return $query; 231 | } 232 | 233 | /** 234 | * Create root node if multiple-root tree mode. Update node if it's not new. 235 | * @param boolean $runValidation whether to perform validation. 236 | * @param array $attributes list of attributes. 237 | * @return boolean whether the saving succeeds. 238 | */ 239 | public function save($runValidation = true, $attributes = null) 240 | { 241 | if ($runValidation && !$this->owner->validate($attributes)) { 242 | return false; 243 | } 244 | 245 | if ($this->owner->getIsNewRecord()) { 246 | return $this->makeRoot($attributes); 247 | } 248 | 249 | $this->_ignoreEvent = true; 250 | $result = $this->owner->update(false, $attributes); 251 | $this->_ignoreEvent = false; 252 | 253 | return $result; 254 | } 255 | 256 | /** 257 | * Create root node if multiple-root tree mode. Update node if it's not new. 258 | * @param boolean $runValidation whether to perform validation. 259 | * @param array $attributes list of attributes. 260 | * @return boolean whether the saving succeeds. 261 | */ 262 | public function saveNode($runValidation = true, $attributes = null) 263 | { 264 | return $this->save($runValidation, $attributes); 265 | } 266 | 267 | /** 268 | * Deletes node and it's descendants. 269 | * @throws Exception. 270 | * @throws \Exception. 271 | * @return boolean whether the deletion is successful. 272 | */ 273 | public function delete() 274 | { 275 | if ($this->owner->getIsNewRecord()) { 276 | throw new Exception('The node can\'t be deleted because it is new.'); 277 | } 278 | 279 | if ($this->getIsDeletedRecord()) { 280 | throw new Exception('The node can\'t be deleted because it is already deleted.'); 281 | } 282 | 283 | $db = $this->owner->getDb(); 284 | 285 | if ($db->getTransaction() === null) { 286 | $transaction = $db->beginTransaction(); 287 | } 288 | 289 | try { 290 | $this->_ignoreEvent = true; 291 | if ($this->owner->isLeaf()) { 292 | $result = $this->owner->delete(); 293 | } elseif ($this->owner->beforeDelete()) { 294 | $condition = $db->quoteColumnName($this->leftAttribute) . '>=' 295 | . $this->owner->getOldAttribute($this->leftAttribute) . ' AND ' 296 | . $db->quoteColumnName($this->rightAttribute) . '<=' 297 | . $this->owner->getOldAttribute($this->rightAttribute); 298 | $params = []; 299 | 300 | if ($this->hasManyRoots) { 301 | $condition .= ' AND ' . $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute; 302 | $params[':' . $this->rootAttribute] = $this->owner->getOldAttribute($this->rootAttribute); 303 | } 304 | 305 | $result = $this->owner->deleteAll($condition, $params) > 0; 306 | $this->owner->afterDelete(); 307 | } 308 | $this->_ignoreEvent = false; 309 | 310 | if (!$result) { 311 | if (isset($transaction)) { 312 | $transaction->rollback(); 313 | } 314 | 315 | return false; 316 | } 317 | 318 | $this->shiftLeftRight( 319 | $this->owner->getAttribute($this->rightAttribute) + 1, 320 | $this->owner->getAttribute($this->leftAttribute) - $this->owner->getAttribute($this->rightAttribute) - 1 321 | ); 322 | 323 | if (isset($transaction)) { 324 | $transaction->commit(); 325 | } 326 | 327 | $this->correctCachedOnDelete(); 328 | } catch (\Exception $e) { 329 | if (isset($transaction)) { 330 | $transaction->rollback(); 331 | } 332 | 333 | throw $e; 334 | } 335 | 336 | return true; 337 | } 338 | 339 | /** 340 | * Deletes node and it's descendants. 341 | * @return boolean whether the deletion is successful. 342 | */ 343 | public function deleteNode() 344 | { 345 | return $this->delete(); 346 | } 347 | 348 | /** 349 | * Prepends node to target as first child. 350 | * @param ActiveRecord $target the target. 351 | * @param boolean $runValidation whether to perform validation. 352 | * @param array $attributes list of attributes. 353 | * @return boolean whether the prepending succeeds. 354 | */ 355 | public function prependTo($target, $runValidation = true, $attributes = null) 356 | { 357 | return $this->addNode( 358 | $target, 359 | $target->getAttribute($this->leftAttribute) + 1, 360 | 1, 361 | $runValidation, 362 | $attributes 363 | ); 364 | } 365 | 366 | /** 367 | * Prepends target to node as first child. 368 | * @param ActiveRecord $target the target. 369 | * @param boolean $runValidation whether to perform validation. 370 | * @param array $attributes list of attributes. 371 | * @return boolean whether the prepending succeeds. 372 | */ 373 | public function prepend($target, $runValidation = true, $attributes = null) 374 | { 375 | return $target->prependTo( 376 | $this->owner, 377 | $runValidation, 378 | $attributes 379 | ); 380 | } 381 | 382 | /** 383 | * Appends node to target as last child. 384 | * @param ActiveRecord $target the target. 385 | * @param boolean $runValidation whether to perform validation. 386 | * @param array $attributes list of attributes. 387 | * @return boolean whether the appending succeeds. 388 | */ 389 | public function appendTo($target, $runValidation = true, $attributes = null) 390 | { 391 | return $this->addNode( 392 | $target, 393 | $target->getAttribute($this->rightAttribute), 394 | 1, 395 | $runValidation, 396 | $attributes 397 | ); 398 | } 399 | 400 | /** 401 | * Appends target to node as last child. 402 | * @param ActiveRecord $target the target. 403 | * @param boolean $runValidation whether to perform validation. 404 | * @param array $attributes list of attributes. 405 | * @return boolean whether the appending succeeds. 406 | */ 407 | public function append($target, $runValidation = true, $attributes = null) 408 | { 409 | return $target->appendTo( 410 | $this->owner, 411 | $runValidation, 412 | $attributes 413 | ); 414 | } 415 | 416 | /** 417 | * Inserts node as previous sibling of target. 418 | * @param ActiveRecord $target the target. 419 | * @param boolean $runValidation whether to perform validation. 420 | * @param array $attributes list of attributes. 421 | * @return boolean whether the inserting succeeds. 422 | */ 423 | public function insertBefore($target, $runValidation = true, $attributes = null) 424 | { 425 | return $this->addNode( 426 | $target, 427 | $target->getAttribute($this->leftAttribute), 428 | 0, 429 | $runValidation, 430 | $attributes 431 | ); 432 | } 433 | 434 | /** 435 | * Inserts node as next sibling of target. 436 | * @param ActiveRecord $target the target. 437 | * @param boolean $runValidation whether to perform validation. 438 | * @param array $attributes list of attributes. 439 | * @return boolean whether the inserting succeeds. 440 | */ 441 | public function insertAfter($target, $runValidation = true, $attributes = null) 442 | { 443 | return $this->addNode( 444 | $target, 445 | $target->getAttribute($this->rightAttribute) + 1, 446 | 0, 447 | $runValidation, 448 | $attributes 449 | ); 450 | } 451 | 452 | /** 453 | * Move node as previous sibling of target. 454 | * @param ActiveRecord $target the target. 455 | * @return boolean whether the moving succeeds. 456 | */ 457 | public function moveBefore($target) 458 | { 459 | return $this->moveNode( 460 | $target, 461 | $target->getAttribute($this->leftAttribute), 462 | 0 463 | ); 464 | } 465 | 466 | /** 467 | * Move node as next sibling of target. 468 | * @param ActiveRecord $target the target. 469 | * @return boolean whether the moving succeeds. 470 | */ 471 | public function moveAfter($target) 472 | { 473 | return $this->moveNode( 474 | $target, 475 | $target->getAttribute($this->rightAttribute) + 1, 476 | 0 477 | ); 478 | } 479 | 480 | /** 481 | * Move node as first child of target. 482 | * @param ActiveRecord $target the target. 483 | * @return boolean whether the moving succeeds. 484 | */ 485 | public function moveAsFirst($target) 486 | { 487 | return $this->moveNode( 488 | $target, 489 | $target->getAttribute($this->leftAttribute) + 1, 490 | 1 491 | ); 492 | } 493 | 494 | /** 495 | * Move node as last child of target. 496 | * @param ActiveRecord $target the target. 497 | * @return boolean whether the moving succeeds. 498 | */ 499 | public function moveAsLast($target) 500 | { 501 | return $this->moveNode( 502 | $target, 503 | $target->getAttribute($this->rightAttribute), 504 | 1 505 | ); 506 | } 507 | 508 | /** 509 | * Move node as new root. 510 | * @throws Exception. 511 | * @throws \Exception. 512 | * @return boolean whether the moving succeeds. 513 | */ 514 | public function moveAsRoot() 515 | { 516 | if (!$this->hasManyRoots) { 517 | throw new Exception('Many roots mode is off.'); 518 | } 519 | 520 | if ($this->owner->getIsNewRecord()) { 521 | throw new Exception('The node should not be new record.'); 522 | } 523 | 524 | if ($this->getIsDeletedRecord()) { 525 | throw new Exception('The node should not be deleted.'); 526 | } 527 | 528 | if ($this->owner->isRoot()) { 529 | throw new Exception('The node already is root node.'); 530 | } 531 | 532 | $db = $this->owner->getDb(); 533 | 534 | if ($db->getTransaction() === null) { 535 | $transaction = $db->beginTransaction(); 536 | } 537 | 538 | try { 539 | $left = $this->owner->getAttribute($this->leftAttribute); 540 | $right = $this->owner->getAttribute($this->rightAttribute); 541 | $levelDelta = 1 - $this->owner->getAttribute($this->levelAttribute); 542 | $delta = 1 - $left; 543 | $this->owner->updateAll( 544 | [ 545 | $this->leftAttribute => new Expression($db->quoteColumnName($this->leftAttribute) 546 | . sprintf('%+d', $delta)), 547 | $this->rightAttribute => new Expression($db->quoteColumnName($this->rightAttribute) 548 | . sprintf('%+d', $delta)), 549 | $this->levelAttribute => new Expression($db->quoteColumnName($this->levelAttribute) 550 | . sprintf('%+d', $levelDelta)), 551 | $this->rootAttribute => $this->owner->getPrimaryKey(), 552 | ], 553 | $db->quoteColumnName($this->leftAttribute) . '>=' . $left . ' AND ' 554 | . $db->quoteColumnName($this->rightAttribute) . '<=' . $right . ' AND ' 555 | . $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute, 556 | [':' . $this->rootAttribute => $this->owner->getAttribute($this->rootAttribute)] 557 | ); 558 | $this->shiftLeftRight($right + 1, $left - $right - 1); 559 | 560 | if (isset($transaction)) { 561 | $transaction->commit(); 562 | } 563 | 564 | $this->correctCachedOnMoveBetweenTrees(1, $levelDelta, $this->owner->getPrimaryKey()); 565 | } catch (\Exception $e) { 566 | if (isset($transaction)) { 567 | $transaction->rollback(); 568 | } 569 | 570 | throw $e; 571 | } 572 | 573 | return true; 574 | } 575 | 576 | /** 577 | * Determines if node is descendant of subject node. 578 | * @param ActiveRecord $subj the subject node. 579 | * @return boolean whether the node is descendant of subject node. 580 | */ 581 | public function isDescendantOf($subj) 582 | { 583 | $result = ($this->owner->getAttribute($this->leftAttribute) > $subj->getAttribute($this->leftAttribute)) 584 | && ($this->owner->getAttribute($this->rightAttribute) < $subj->getAttribute($this->rightAttribute)); 585 | 586 | if ($this->hasManyRoots) { 587 | $result = $result && ($this->owner->getAttribute($this->rootAttribute) 588 | === $subj->getAttribute($this->rootAttribute)); 589 | } 590 | 591 | return $result; 592 | } 593 | 594 | /** 595 | * Determines if node is leaf. 596 | * @return boolean whether the node is leaf. 597 | */ 598 | public function isLeaf() 599 | { 600 | return $this->owner->getAttribute($this->rightAttribute) 601 | - $this->owner->getAttribute($this->leftAttribute) === 1; 602 | } 603 | 604 | /** 605 | * Determines if node is root. 606 | * @return boolean whether the node is root. 607 | */ 608 | public function isRoot() 609 | { 610 | return $this->owner->getAttribute($this->leftAttribute) == 1; 611 | } 612 | 613 | /** 614 | * Returns if the current node is deleted. 615 | * @return boolean whether the node is deleted. 616 | */ 617 | public function getIsDeletedRecord() 618 | { 619 | return $this->_deleted; 620 | } 621 | 622 | /** 623 | * Sets if the current node is deleted. 624 | * @param boolean $value whether the node is deleted. 625 | */ 626 | public function setIsDeletedRecord($value) 627 | { 628 | $this->_deleted = $value; 629 | } 630 | 631 | /** 632 | * Handle 'afterFind' event of the owner. 633 | * @param Event $event event parameter. 634 | */ 635 | public function afterFind($event) 636 | { 637 | self::$_cached[get_class($this->owner)][$this->_id = self::$_c++] = $this->owner; 638 | } 639 | 640 | /** 641 | * Handle 'beforeInsert' event of the owner. 642 | * @param Event $event event parameter. 643 | * @throws Exception. 644 | * @return boolean. 645 | */ 646 | public function beforeInsert($event) 647 | { 648 | if ($this->_ignoreEvent) { 649 | return true; 650 | } else { 651 | throw new Exception('You should not use ActiveRecord::save() or ActiveRecord::insert() methods when NestedSet behavior attached.'); 652 | } 653 | } 654 | 655 | /** 656 | * Handle 'beforeUpdate' event of the owner. 657 | * @param Event $event event parameter. 658 | * @throws Exception. 659 | * @return boolean. 660 | */ 661 | public function beforeUpdate($event) 662 | { 663 | if ($this->_ignoreEvent) { 664 | return true; 665 | } else { 666 | throw new Exception('You should not use ActiveRecord::save() or ActiveRecord::update() methods when NestedSet behavior attached.'); 667 | } 668 | } 669 | 670 | /** 671 | * Handle 'beforeDelete' event of the owner. 672 | * @param Event $event event parameter. 673 | * @throws Exception. 674 | * @return boolean. 675 | */ 676 | public function beforeDelete($event) 677 | { 678 | if ($this->_ignoreEvent) { 679 | return true; 680 | } else { 681 | throw new Exception('You should not use ActiveRecord::delete() method when NestedSet behavior attached.'); 682 | } 683 | } 684 | 685 | /** 686 | * @param int $key. 687 | * @param int $delta. 688 | */ 689 | private function shiftLeftRight($key, $delta) 690 | { 691 | $db = $this->owner->getDb(); 692 | 693 | foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) { 694 | $condition = $db->quoteColumnName($attribute) . '>=' . $key; 695 | $params = []; 696 | 697 | if ($this->hasManyRoots) { 698 | $condition .= ' AND ' . $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute; 699 | $params[':' . $this->rootAttribute] = $this->owner->getAttribute($this->rootAttribute); 700 | } 701 | 702 | $this->owner->updateAll( 703 | [$attribute => new Expression($db->quoteColumnName($attribute) . sprintf('%+d', $delta))], 704 | $condition, 705 | $params 706 | ); 707 | } 708 | } 709 | 710 | /** 711 | * @param ActiveRecord $target. 712 | * @param int $key. 713 | * @param int $levelUp. 714 | * @param boolean $runValidation. 715 | * @param array $attributes. 716 | * @throws Exception. 717 | * @throws \Exception. 718 | * @return boolean. 719 | */ 720 | private function addNode($target, $key, $levelUp, $runValidation, $attributes) 721 | { 722 | if (!$this->owner->getIsNewRecord()) { 723 | throw new Exception('The node can\'t be inserted because it is not new.'); 724 | } 725 | 726 | if ($this->getIsDeletedRecord()) { 727 | throw new Exception('The node can\'t be inserted because it is deleted.'); 728 | } 729 | 730 | if ($target->getIsDeletedRecord()) { 731 | throw new Exception('The node can\'t be inserted because target node is deleted.'); 732 | } 733 | 734 | if ($this->owner->equals($target)) { 735 | throw new Exception('The target node should not be self.'); 736 | } 737 | 738 | if (!$levelUp && $target->isRoot()) { 739 | throw new Exception('The target node should not be root.'); 740 | } 741 | 742 | if ($runValidation && !$this->owner->validate()) { 743 | return false; 744 | } 745 | 746 | if ($this->hasManyRoots) { 747 | $this->owner->setAttribute($this->rootAttribute, $target->getAttribute($this->rootAttribute)); 748 | } 749 | 750 | $db = $this->owner->getDb(); 751 | 752 | if ($db->getTransaction() === null) { 753 | $transaction = $db->beginTransaction(); 754 | } 755 | 756 | try { 757 | $this->shiftLeftRight($key, 2); 758 | $this->owner->setAttribute($this->leftAttribute, $key); 759 | $this->owner->setAttribute($this->rightAttribute, $key + 1); 760 | $this->owner->setAttribute($this->levelAttribute, $target->getAttribute($this->levelAttribute) + $levelUp); 761 | $this->_ignoreEvent = true; 762 | $result = $this->owner->insert(false, $attributes); 763 | $this->_ignoreEvent = false; 764 | 765 | if (!$result) { 766 | if (isset($transaction)) { 767 | $transaction->rollback(); 768 | } 769 | 770 | return false; 771 | } 772 | 773 | if (isset($transaction)) { 774 | $transaction->commit(); 775 | } 776 | 777 | $this->correctCachedOnAddNode($key); 778 | } catch (\Exception $e) { 779 | if (isset($transaction)) { 780 | $transaction->rollback(); 781 | } 782 | 783 | throw $e; 784 | } 785 | 786 | return true; 787 | } 788 | 789 | /** 790 | * @param array $attributes. 791 | * @throws Exception. 792 | * @throws \Exception. 793 | * @return boolean. 794 | */ 795 | private function makeRoot($attributes) 796 | { 797 | $this->owner->setAttribute($this->leftAttribute, 1); 798 | $this->owner->setAttribute($this->rightAttribute, 2); 799 | $this->owner->setAttribute($this->levelAttribute, 1); 800 | 801 | if ($this->hasManyRoots) { 802 | $db = $this->owner->getDb(); 803 | 804 | if ($db->getTransaction() === null) { 805 | $transaction = $db->beginTransaction(); 806 | } 807 | 808 | try { 809 | $this->_ignoreEvent = true; 810 | $result = $this->owner->insert(false, $attributes); 811 | $this->_ignoreEvent = false; 812 | 813 | if (!$result) { 814 | if (isset($transaction)) { 815 | $transaction->rollback(); 816 | } 817 | 818 | return false; 819 | } 820 | 821 | if ($this->owner->getAttribute($this->rootAttribute)) { 822 | if (isset($transaction)) { 823 | $transaction->commit(); 824 | } 825 | return $result; 826 | } 827 | 828 | $this->owner->setAttribute($this->rootAttribute, $this->owner->getPrimaryKey()); 829 | $primaryKey = $this->owner->primaryKey(); 830 | 831 | if (!isset($primaryKey[0])) { 832 | throw new Exception(get_class($this->owner) . ' must have a primary key.'); 833 | } 834 | 835 | $this->owner->updateAll( 836 | [$this->rootAttribute => $this->owner->getAttribute($this->rootAttribute)], 837 | [$primaryKey[0] => $this->owner->getAttribute($this->rootAttribute)] 838 | ); 839 | 840 | if (isset($transaction)) { 841 | $transaction->commit(); 842 | } 843 | } catch (\Exception $e) { 844 | if (isset($transaction)) { 845 | $transaction->rollback(); 846 | } 847 | 848 | throw $e; 849 | } 850 | } else { 851 | $this->_ignoreEvent = true; 852 | $result = $this->owner->insert(false, $attributes); 853 | $this->_ignoreEvent = false; 854 | 855 | if (!$result) { 856 | return false; 857 | } 858 | } 859 | 860 | return true; 861 | } 862 | 863 | /** 864 | * @param ActiveRecord $target. 865 | * @param int $key. 866 | * @param int $levelUp. 867 | * @throws Exception. 868 | * @throws \Exception. 869 | * @return boolean. 870 | */ 871 | private function moveNode($target, $key, $levelUp) 872 | { 873 | if ($this->owner->getIsNewRecord()) { 874 | throw new Exception('The node should not be new record.'); 875 | } 876 | 877 | if ($this->getIsDeletedRecord()) { 878 | throw new Exception('The node should not be deleted.'); 879 | } 880 | 881 | if ($target->getIsDeletedRecord()) { 882 | throw new Exception('The target node should not be deleted.'); 883 | } 884 | 885 | if ($this->owner->equals($target)) { 886 | throw new Exception('The target node should not be self.'); 887 | } 888 | 889 | if ($target->isDescendantOf($this->owner)) { 890 | throw new Exception('The target node should not be descendant.'); 891 | } 892 | 893 | if (!$levelUp && $target->isRoot()) { 894 | throw new Exception('The target node should not be root.'); 895 | } 896 | 897 | $db = $this->owner->getDb(); 898 | 899 | if ($db->getTransaction() === null) { 900 | $transaction = $db->beginTransaction(); 901 | } 902 | 903 | try { 904 | $left = $this->owner->getAttribute($this->leftAttribute); 905 | $right = $this->owner->getAttribute($this->rightAttribute); 906 | $levelDelta = $target->getAttribute($this->levelAttribute) - $this->owner->getAttribute($this->levelAttribute) 907 | + $levelUp; 908 | 909 | if ($this->hasManyRoots && $this->owner->getAttribute($this->rootAttribute) !== 910 | $target->getAttribute($this->rootAttribute)) { 911 | 912 | foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) { 913 | $this->owner->updateAll( 914 | [$attribute => new Expression($db->quoteColumnName($attribute) 915 | . sprintf('%+d', $right - $left + 1))], 916 | $db->quoteColumnName($attribute) . '>=' . $key . ' AND ' 917 | . $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute, 918 | [':' . $this->rootAttribute => $target->getAttribute($this->rootAttribute)] 919 | ); 920 | } 921 | 922 | $delta = $key - $left; 923 | $this->owner->updateAll( 924 | [ 925 | $this->leftAttribute => new Expression($db->quoteColumnName($this->leftAttribute) 926 | . sprintf('%+d', $delta)), 927 | $this->rightAttribute => new Expression($db->quoteColumnName($this->rightAttribute) 928 | . sprintf('%+d', $delta)), 929 | $this->levelAttribute => new Expression($db->quoteColumnName($this->levelAttribute) 930 | . sprintf('%+d', $levelDelta)), 931 | $this->rootAttribute => $target->getAttribute($this->rootAttribute), 932 | ], 933 | $db->quoteColumnName($this->leftAttribute) . '>=' . $left . ' AND ' 934 | . $db->quoteColumnName($this->rightAttribute) . '<=' . $right . ' AND ' 935 | . $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute, 936 | [':' . $this->rootAttribute => $this->owner->getAttribute($this->rootAttribute)] 937 | ); 938 | $this->shiftLeftRight($right + 1, $left - $right - 1); 939 | 940 | if (isset($transaction)) { 941 | $transaction->commit(); 942 | } 943 | 944 | $this->correctCachedOnMoveBetweenTrees($key, $levelDelta, $target->getAttribute($this->rootAttribute)); 945 | } else { 946 | $delta = $right - $left + 1; 947 | $this->shiftLeftRight($key, $delta); 948 | 949 | if ($left >= $key) { 950 | $left += $delta; 951 | $right += $delta; 952 | } 953 | 954 | $condition = $db->quoteColumnName($this->leftAttribute) . '>=' . $left . ' AND ' 955 | . $db->quoteColumnName($this->rightAttribute) . '<=' . $right; 956 | $params = []; 957 | 958 | if ($this->hasManyRoots) { 959 | $condition .= ' AND ' . $db->quoteColumnName($this->rootAttribute) . '=:' . $this->rootAttribute; 960 | $params[':' . $this->rootAttribute] = $this->owner->getAttribute($this->rootAttribute); 961 | } 962 | 963 | $this->owner->updateAll( 964 | [ 965 | $this->levelAttribute => new Expression($db->quoteColumnName($this->levelAttribute) 966 | . sprintf('%+d', $levelDelta)), 967 | ], 968 | $condition, 969 | $params 970 | ); 971 | 972 | foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) { 973 | $condition = $db->quoteColumnName($attribute) . '>=' . $left . ' AND ' 974 | . $db->quoteColumnName($attribute) . '<=' . $right; 975 | $params = []; 976 | 977 | if ($this->hasManyRoots) { 978 | $condition .= ' AND ' . $db->quoteColumnName($this->rootAttribute) . '=:' 979 | . $this->rootAttribute; 980 | $params[':' . $this->rootAttribute] = $this->owner->getAttribute($this->rootAttribute); 981 | } 982 | 983 | $this->owner->updateAll( 984 | [$attribute => new Expression($db->quoteColumnName($attribute) 985 | . sprintf('%+d', $key - $left))], 986 | $condition, 987 | $params 988 | ); 989 | } 990 | 991 | $this->shiftLeftRight($right + 1, -$delta); 992 | 993 | if (isset($transaction)) { 994 | $transaction->commit(); 995 | } 996 | 997 | $this->correctCachedOnMoveNode($key, $levelDelta); 998 | } 999 | } catch (\Exception $e) { 1000 | if (isset($transaction)) { 1001 | $transaction->rollback(); 1002 | } 1003 | 1004 | throw $e; 1005 | } 1006 | 1007 | return true; 1008 | } 1009 | 1010 | /** 1011 | * Correct cache for [[delete()]] and [[deleteNode()]]. 1012 | */ 1013 | private function correctCachedOnDelete() 1014 | { 1015 | $left = $this->owner->getAttribute($this->leftAttribute); 1016 | $right = $this->owner->getAttribute($this->rightAttribute); 1017 | $key = $right + 1; 1018 | $delta = $left - $right - 1; 1019 | 1020 | foreach (self::$_cached[get_class($this->owner)] as $node) { 1021 | /** @var $node ActiveRecord */ 1022 | if ($node->getIsNewRecord() || $node->getIsDeletedRecord()) { 1023 | continue; 1024 | } 1025 | 1026 | if ($this->hasManyRoots && $this->owner->getAttribute($this->rootAttribute) 1027 | !== $node->getAttribute($this->rootAttribute)) { 1028 | continue; 1029 | } 1030 | 1031 | if ($node->getAttribute($this->leftAttribute) >= $left 1032 | && $node->getAttribute($this->rightAttribute) <= $right) { 1033 | $node->setIsDeletedRecord(true); 1034 | } else { 1035 | if ($node->getAttribute($this->leftAttribute) >= $key) { 1036 | $node->setAttribute( 1037 | $this->leftAttribute, 1038 | $node->getAttribute($this->leftAttribute) + $delta 1039 | ); 1040 | } 1041 | 1042 | if ($node->getAttribute($this->rightAttribute) >= $key) { 1043 | $node->setAttribute( 1044 | $this->rightAttribute, 1045 | $node->getAttribute($this->rightAttribute) + $delta 1046 | ); 1047 | } 1048 | } 1049 | } 1050 | } 1051 | 1052 | /** 1053 | * Correct cache for [[addNode()]]. 1054 | * @param int $key. 1055 | */ 1056 | private function correctCachedOnAddNode($key) 1057 | { 1058 | foreach (self::$_cached[get_class($this->owner)] as $node) { 1059 | /** @var $node ActiveRecord */ 1060 | if ($node->getIsNewRecord() || $node->getIsDeletedRecord()) { 1061 | continue; 1062 | } 1063 | 1064 | if ($this->hasManyRoots && $this->owner->getAttribute($this->rootAttribute) 1065 | !== $node->getAttribute($this->rootAttribute)) { 1066 | continue; 1067 | } 1068 | 1069 | if ($this->owner === $node) { 1070 | continue; 1071 | } 1072 | 1073 | if ($node->getAttribute($this->leftAttribute) >= $key) { 1074 | $node->setAttribute( 1075 | $this->leftAttribute, 1076 | $node->getAttribute($this->leftAttribute) + 2 1077 | ); 1078 | } 1079 | 1080 | if ($node->getAttribute($this->rightAttribute) >= $key) { 1081 | $node->setAttribute( 1082 | $this->rightAttribute, 1083 | $node->getAttribute($this->rightAttribute) + 2 1084 | ); 1085 | } 1086 | } 1087 | } 1088 | 1089 | /** 1090 | * Correct cache for [[moveNode()]]. 1091 | * @param int $key. 1092 | * @param int $levelDelta. 1093 | */ 1094 | private function correctCachedOnMoveNode($key, $levelDelta) 1095 | { 1096 | $left = $this->owner->getAttribute($this->leftAttribute); 1097 | $right = $this->owner->getAttribute($this->rightAttribute); 1098 | $delta = $right - $left + 1; 1099 | 1100 | if ($left >= $key) { 1101 | $left += $delta; 1102 | $right += $delta; 1103 | } 1104 | 1105 | $delta2 = $key - $left; 1106 | 1107 | foreach (self::$_cached[get_class($this->owner)] as $node) { 1108 | /** @var $node ActiveRecord */ 1109 | if ($node->getIsNewRecord() || $node->getIsDeletedRecord()) { 1110 | continue; 1111 | } 1112 | 1113 | if ($this->hasManyRoots && $this->owner->getAttribute($this->rootAttribute) 1114 | !== $node->getAttribute($this->rootAttribute)) { 1115 | continue; 1116 | } 1117 | 1118 | if ($node->getAttribute($this->leftAttribute) >= $key) { 1119 | $node->setAttribute( 1120 | $this->leftAttribute, 1121 | $node->getAttribute($this->leftAttribute) + $delta 1122 | ); 1123 | } 1124 | 1125 | if ($node->getAttribute($this->rightAttribute) >= $key) { 1126 | $node->setAttribute( 1127 | $this->rightAttribute, 1128 | $node->getAttribute($this->rightAttribute) + $delta 1129 | ); 1130 | } 1131 | 1132 | if ($node->getAttribute($this->leftAttribute) >= $left 1133 | && $node->getAttribute($this->rightAttribute) <= $right) { 1134 | $node->setAttribute( 1135 | $this->levelAttribute, 1136 | $node->getAttribute($this->levelAttribute) + $levelDelta 1137 | ); 1138 | } 1139 | 1140 | if ($node->getAttribute($this->leftAttribute) >= $left 1141 | && $node->getAttribute($this->leftAttribute) <= $right) { 1142 | $node->setAttribute( 1143 | $this->leftAttribute, 1144 | $node->getAttribute($this->leftAttribute) + $delta2 1145 | ); 1146 | } 1147 | 1148 | if ($node->getAttribute($this->rightAttribute) >= $left 1149 | && $node->getAttribute($this->rightAttribute) <= $right) { 1150 | $node->setAttribute( 1151 | $this->rightAttribute, 1152 | $node->getAttribute($this->rightAttribute) + $delta2 1153 | ); 1154 | } 1155 | 1156 | if ($node->getAttribute($this->leftAttribute) >= $right + 1) { 1157 | $node->setAttribute( 1158 | $this->leftAttribute, 1159 | $node->getAttribute($this->leftAttribute) - $delta 1160 | ); 1161 | } 1162 | 1163 | if ($node->getAttribute($this->rightAttribute) >= $right + 1) { 1164 | $node->setAttribute( 1165 | $this->rightAttribute, 1166 | $node->getAttribute($this->rightAttribute) - $delta 1167 | ); 1168 | } 1169 | } 1170 | } 1171 | 1172 | /** 1173 | * Correct cache for [[moveNode()]]. 1174 | * @param int $key. 1175 | * @param int $levelDelta. 1176 | * @param int $root. 1177 | */ 1178 | private function correctCachedOnMoveBetweenTrees($key, $levelDelta, $root) 1179 | { 1180 | $left = $this->owner->getAttribute($this->leftAttribute); 1181 | $right = $this->owner->getAttribute($this->rightAttribute); 1182 | $delta = $right - $left + 1; 1183 | $delta2 = $key - $left; 1184 | $delta3 = $left - $right - 1; 1185 | 1186 | foreach (self::$_cached[get_class($this->owner)] as $node) { 1187 | /** @var $node ActiveRecord */ 1188 | if ($node->getIsNewRecord() || $node->getIsDeletedRecord()) { 1189 | continue; 1190 | } 1191 | 1192 | if ($node->getAttribute($this->rootAttribute) === $root) { 1193 | if ($node->getAttribute($this->leftAttribute) >= $key) { 1194 | $node->setAttribute( 1195 | $this->leftAttribute, 1196 | $node->getAttribute($this->leftAttribute) + $delta 1197 | ); 1198 | } 1199 | 1200 | if ($node->getAttribute($this->rightAttribute) >= $key) { 1201 | $node->setAttribute( 1202 | $this->rightAttribute, 1203 | $node->getAttribute($this->rightAttribute) + $delta 1204 | ); 1205 | } 1206 | } elseif ($node->getAttribute($this->rootAttribute) 1207 | === $this->owner->getAttribute($this->rootAttribute)) { 1208 | if ($node->getAttribute($this->leftAttribute) >= $left 1209 | && $node->getAttribute($this->rightAttribute) <= $right) { 1210 | $node->setAttribute( 1211 | $this->leftAttribute, 1212 | $node->getAttribute($this->leftAttribute) + $delta2 1213 | ); 1214 | $node->setAttribute( 1215 | $this->rightAttribute, 1216 | $node->getAttribute($this->rightAttribute) + $delta2 1217 | ); 1218 | $node->setAttribute( 1219 | $this->levelAttribute, 1220 | $node->getAttribute($this->levelAttribute) + $levelDelta 1221 | ); 1222 | $node->setAttribute($this->rootAttribute, $root); 1223 | } else { 1224 | if ($node->getAttribute($this->leftAttribute) >= $right + 1) { 1225 | $node->setAttribute( 1226 | $this->leftAttribute, 1227 | $node->getAttribute($this->leftAttribute) + $delta3 1228 | ); 1229 | } 1230 | 1231 | if ($node->getAttribute($this->rightAttribute) >= $right + 1) { 1232 | $node->setAttribute( 1233 | $this->rightAttribute, 1234 | $node->getAttribute($this->rightAttribute) + $delta3 1235 | ); 1236 | } 1237 | } 1238 | } 1239 | } 1240 | } 1241 | 1242 | /** 1243 | * Destructor. 1244 | */ 1245 | public function __destruct() 1246 | { 1247 | unset(self::$_cached[get_class($this->owner)][$this->_id]); 1248 | } 1249 | } 1250 | -------------------------------------------------------------------------------- /NestedSetQuery.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class NestedSetQuery extends \yii\db\ActiveQuery 16 | { 17 | public function behaviors() 18 | { 19 | return [ 20 | [ 21 | 'class' => NestedSetQueryBehavior::className(), 22 | ] 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /NestedSetQueryBehavior.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class NestedSetQueryBehavior extends Behavior 16 | { 17 | /** 18 | * @var ActiveQuery the owner of this behavior. 19 | */ 20 | public $owner; 21 | 22 | /** 23 | * Gets root node(s). 24 | * @return ActiveRecord the owner. 25 | */ 26 | public function roots() 27 | { 28 | /** @var $modelClass ActiveRecord */ 29 | $modelClass = $this->owner->modelClass; 30 | $model = new $modelClass; 31 | $this->owner->andWhere($modelClass::getDb()->quoteColumnName($model->leftAttribute) . '=1'); 32 | unset($model); 33 | return $this->owner; 34 | } 35 | 36 | public function options($root = 0, $level = null) 37 | { 38 | $res = []; 39 | if (is_object($root)) { 40 | $res[$root->{$root->idAttribute}] = str_repeat('—', $root->{$root->levelAttribute} - 1) 41 | . ((($root->{$root->levelAttribute}) > 1) ? '›': '') 42 | . $root->{$root->titleAttribute}; 43 | 44 | if ($level) { 45 | foreach ($root->children()->all() as $childRoot) { 46 | $res += $this->options($childRoot, $level - 1); 47 | } 48 | } elseif (is_null($level)) { 49 | foreach ($root->children()->all() as $childRoot) { 50 | $res += $this->options($childRoot, null); 51 | } 52 | } 53 | } elseif (is_scalar($root)) { 54 | if ($root == 0) { 55 | foreach ($this->roots()->all() as $rootItem) { 56 | if ($level) { 57 | $res += $this->options($rootItem, $level - 1); 58 | } elseif (is_null($level)) { 59 | $res += $this->options($rootItem, null); 60 | } 61 | } 62 | } else { 63 | $modelClass = $this->owner->modelClass; 64 | $model = new $modelClass; 65 | $root = $modelClass::find()->andWhere([$model->idAttribute => $root])->one(); 66 | if ($root) { 67 | $res += $this->options($root, $level); 68 | } 69 | unset($model); 70 | } 71 | } 72 | return $res; 73 | } 74 | 75 | public function dataFancytree($root = 0, $level = null) 76 | { 77 | $data = array_values($this->prepareData2Fancytree($root, $level)); 78 | return $this->makeData2Fancytree($data); 79 | } 80 | 81 | private function prepareData2Fancytree($root = 0, $level = null) 82 | { 83 | $res = []; 84 | if (is_object($root)) { 85 | $res[$root->{$root->idAttribute}] = [ 86 | 'key' => $root->{$root->idAttribute}, 87 | 'title' => $root->{$root->titleAttribute} 88 | ]; 89 | 90 | if ($level) { 91 | foreach ($root->children()->all() as $childRoot) { 92 | $aux = $this->prepareData2Fancytree($childRoot, $level - 1); 93 | 94 | if (isset($res[$root->{$root->idAttribute}]['children']) && !empty($aux)) { 95 | $res[$root->{$root->idAttribute}]['folder'] = true; 96 | $res[$root->{$root->idAttribute}]['children'] += $aux; 97 | 98 | } elseif(!empty($aux)) { 99 | $res[$root->{$root->idAttribute}]['folder'] = true; 100 | $res[$root->{$root->idAttribute}]['children'] = $aux; 101 | } 102 | } 103 | } elseif (is_null($level)) { 104 | foreach ($root->children()->all() as $childRoot) { 105 | $aux = $this->prepareData2Fancytree($childRoot, null); 106 | if (isset($res[$root->{$root->idAttribute}]['children']) && !empty($aux)) { 107 | $res[$root->{$root->idAttribute}]['folder'] = true; 108 | $res[$root->{$root->idAttribute}]['children'] += $aux; 109 | 110 | } elseif(!empty($aux)) { 111 | $res[$root->{$root->idAttribute}]['folder'] = true; 112 | $res[$root->{$root->idAttribute}]['children'] = $aux; 113 | } 114 | } 115 | } 116 | } elseif (is_scalar($root)) { 117 | if ($root == 0) { 118 | foreach ($this->roots()->all() as $rootItem) { 119 | if ($level) { 120 | $res += $this->prepareData2Fancytree($rootItem, $level - 1); 121 | } elseif (is_null($level)) { 122 | $res += $this->prepareData2Fancytree($rootItem, null); 123 | } 124 | } 125 | } else { 126 | $modelClass = $this->owner->modelClass; 127 | $model = new $modelClass; 128 | $root = $modelClass::find()->andWhere([$model->idAttribute => $root])->one(); 129 | if ($root) { 130 | $res += $this->prepareData2Fancytree($root, $level); 131 | } 132 | unset($model); 133 | } 134 | } 135 | return $res; 136 | } 137 | 138 | private function makeData2Fancytree(&$data) 139 | { 140 | $tree = []; 141 | foreach ($data as $key => &$item) { 142 | if (isset($item['children'])) { 143 | $item['children'] = array_values($item['children']); 144 | $tree[$key] = $this->makeData2Fancytree($item['children']); 145 | } 146 | $tree[$key] = $item; 147 | } 148 | return $tree; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Nested Set behavior for Yii 2 2 | ============================= 3 | 4 | This extension allows you to get functional for nested set trees. 5 | 6 | Installation 7 | ------------ 8 | 9 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 10 | 11 | Either run 12 | 13 | ```sh 14 | php composer.phar require wbraganca/yii2-nested-set-behavior "*" 15 | ``` 16 | 17 | or add 18 | 19 | ```json 20 | "wbraganca/yii2-nested-set-behavior": "*" 21 | ``` 22 | 23 | to the require section of your `composer.json` file. 24 | 25 | Configuring 26 | -------------------------- 27 | 28 | First you need to configure model as follows: 29 | 30 | ```php 31 | use wbraganca\behaviors\NestedSetBehavior; 32 | use wbraganca\behaviors\NestedSetQuery; 33 | 34 | class Category extends ActiveRecord 35 | { 36 | public function behaviors() 37 | { 38 | return [ 39 | [ 40 | 'class' => NestedSetBehavior::className(), 41 | // 'rootAttribute' => 'root', 42 | // 'levelAttribute' => 'level', 43 | // 'hasManyRoots' => true 44 | ], 45 | ]; 46 | } 47 | 48 | public static function find() 49 | { 50 | return new NestedSetQuery(get_called_class()); 51 | } 52 | } 53 | ``` 54 | 55 | There is no need to validate fields specified in `leftAttribute`, 56 | `rightAttribute`, `rootAttribute` and `levelAttribute` options. Moreover, 57 | there could be problems if there are validation rules for these. Please 58 | check if there are no rules for fields mentioned in model's rules() method. 59 | 60 | In case of storing a single tree per database, DB structure can be built with 61 | `schema/schema.sql`. If you're going to store multiple trees you'll need 62 | `schema/schema-many-roots.sql`. 63 | 64 | By default `leftAttribute`, `rightAttribute` and `levelAttribute` values are 65 | matching field names in default DB schemas so you can skip configuring these. 66 | 67 | There are two ways this behavior can work: one tree per table and multiple trees 68 | per table. The mode is selected based on the value of `hasManyRoots` option that 69 | is `false` by default meaning single tree mode. In multiple trees mode you can 70 | set `rootAttribute` option to match existing field in the table storing the tree. 71 | 72 | Selecting from a tree 73 | --------------------- 74 | 75 | In the following we'll use an example model `Category` with the following in its 76 | DB: 77 | 78 | ~~~ 79 | - 1. Mobile phones 80 | - 2. iPhone 81 | - 3. Samsung 82 | - 4. X100 83 | - 5. C200 84 | - 6. Motorola 85 | - 7. Cars 86 | - 8. Audi 87 | - 9. Ford 88 | - 10. Mercedes 89 | ~~~ 90 | 91 | In this example we have two trees. Tree roots are ones with ID=1 and ID=7. 92 | 93 | ### Getting all roots 94 | 95 | ```php 96 | $roots = Category::find()->roots()->all(); 97 | ``` 98 | 99 | Result: 100 | 101 | Array of Active Record objects corresponding to Mobile phones and Cars nodes. 102 | 103 | ### Getting all descendants of a node 104 | 105 | ```php 106 | $category = Category::findOne(1); 107 | if ($category) { 108 | $descendants = $category->descendants()->all(); 109 | var_dump($descendants); 110 | } 111 | ``` 112 | 113 | Result: 114 | 115 | Array of Active Record objects corresponding to iPhone, Samsung, X100, C200 and Motorola. 116 | 117 | ### Getting all children of a node 118 | 119 | ```php 120 | $category = Category::findOne(1); 121 | if ($category) { 122 | $descendants = $category->children()->all(); 123 | var_dump($descendants); 124 | } 125 | ``` 126 | 127 | Result: 128 | 129 | Array of Active Record objects corresponding to iPhone, Samsung and Motorola. 130 | 131 | ### Getting all ancestors of a node 132 | 133 | ```php 134 | $category = Category::findOne(5); 135 | if ($category) { 136 | $ancestors = $category->ancestors()->all(); 137 | var_dump($ancestors); 138 | } 139 | ``` 140 | 141 | Result: 142 | 143 | Array of Active Record objects corresponding to Samsung and Mobile phones. 144 | 145 | ### Getting parent of a node 146 | 147 | ```php 148 | $category = Category::findOne(9); 149 | if ($category) { 150 | $parent = $category->parent()->one(); 151 | var_dump($parent); 152 | } 153 | ``` 154 | 155 | Result: 156 | 157 | Array of Active Record objects corresponding to Cars. 158 | 159 | ### Getting node siblings 160 | 161 | Using `NestedSet::prev()` or 162 | `NestedSet::next()`: 163 | 164 | ```php 165 | $category = Category::findOne(9); 166 | if ($category) 167 | $nextSibling = $category->next()->one(); 168 | } 169 | ``` 170 | 171 | Result: 172 | 173 | Array of Active Record objects corresponding to Mercedes. 174 | 175 | ### Getting the whole tree 176 | 177 | You can get the whole tree using standard AR methods like the following. 178 | 179 | For single tree per table: 180 | 181 | ```php 182 | Category::find()->addOrderBy('lft')->all(); 183 | ``` 184 | 185 | For multiple trees per table: 186 | 187 | ```php 188 | Category::find()->andWhere('root = ?', [$root_id])->addOrderBy('lft')->all(); 189 | ``` 190 | 191 | Modifying a tree 192 | ---------------- 193 | 194 | In this section we'll build a tree like the one used in the previous section. 195 | 196 | ### Creating root nodes 197 | 198 | You can create a root node using `NestedSet::saveNode()`. 199 | 200 | ```php 201 | $root = new Category; 202 | $root->title = 'Mobile Phones'; 203 | $root->saveNode(); 204 | 205 | $root = new Category; 206 | $root->title = 'Cars'; 207 | $root->saveNode(); 208 | ``` 209 | 210 | Result: 211 | 212 | ~~~ 213 | - 1. Mobile Phones 214 | - 2. Cars 215 | ~~~ 216 | 217 | ### Adding child nodes 218 | 219 | There are multiple methods allowing you adding child nodes. To get more info 220 | about these refer to API. Let's use these 221 | to add nodes to the tree we have: 222 | 223 | ```php 224 | $category1 = new Category; 225 | $category1->title = 'Ford'; 226 | 227 | $category2 = new Category; 228 | $category2->title = 'Mercedes'; 229 | 230 | $category3 = new Category; 231 | $category3->title = 'Audi'; 232 | 233 | $root = Category::findOne(1); 234 | $category1->appendTo($root); 235 | $category2->insertAfter($category1); 236 | $category3->insertBefore($category1); 237 | ``` 238 | 239 | Result: 240 | 241 | ~~~ 242 | - 1. Mobile phones 243 | - 3. Audi 244 | - 4. Ford 245 | - 5. Mercedes 246 | - 2. Cars 247 | ~~~ 248 | 249 | Logically the tree above doesn't looks correct. We'll fix it later. 250 | 251 | ```php 252 | $category1 = new Category; 253 | $category1->title = 'Samsung'; 254 | 255 | $category2 = new Category; 256 | $category2->title = 'Motorola'; 257 | 258 | $category3 = new Category; 259 | $category3->title = 'iPhone'; 260 | 261 | $root = Category::findOne(2); 262 | $category1->appendTo($root); 263 | $category2->insertAfter($category1); 264 | $category3->prependTo($root); 265 | ``` 266 | 267 | Result: 268 | 269 | ~~~ 270 | - 1. Mobile phones 271 | - 3. Audi 272 | - 4. Ford 273 | - 5. Mercedes 274 | - 2. Cars 275 | - 6. iPhone 276 | - 7. Samsung 277 | - 8. Motorola 278 | ~~~ 279 | 280 | ```php 281 | $category1 = new Category; 282 | $category1->title = 'X100'; 283 | 284 | $category2 = new Category; 285 | $category2->title = 'C200'; 286 | 287 | $node = Category::findOne(3); 288 | $category1->appendTo($node); 289 | $category2->prependTo($node); 290 | ``` 291 | 292 | Result: 293 | 294 | ~~~ 295 | - 1. Mobile phones 296 | - 3. Audi 297 | - 9. С200 298 | - 10. X100 299 | - 4. Ford 300 | - 5. Mercedes 301 | - 2. Cars 302 | - 6. iPhone 303 | - 7. Samsung 304 | - 8. Motorola 305 | ~~~ 306 | 307 | Moving a node making it a new root 308 | --------------------------------- 309 | 310 | There is a special `moveAsRoot()` method that allows moving a node and making it 311 | a new root. All descendants are moved as well in this case. 312 | 313 | Example: 314 | 315 | ```php 316 | $node = Category::findOne(10); 317 | $node->moveAsRoot(); 318 | ``` 319 | 320 | Recursive tree traversal 321 | ------------------------ 322 | 323 | ```php 324 | Category::find()->options(); // List all the tree 325 | Category::find()->options(1); // List all category in tree with root.id=1 326 | Category::find()->options(1, 3); // List 3 levels of category in tree with root.id=1 327 | ``` 328 | 329 | Data format for [Fancytree](https://github.com/wbraganca/yii2-fancytree-widget). 330 | ------------------------- 331 | 332 | ```php 333 | Category::find()->dataFancytree(); // List all the tree 334 | Category::find()->dataFancytree(1); // List all category in tree with root.id=1 335 | Category::find()->dataFancytree(1, 3); // List 3 levels of category in tree with root.id=1 336 | ``` 337 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wbraganca/yii2-nested-set-behavior", 3 | "description": "This extension allows you to get functional for nested set trees.", 4 | "keywords": ["yii2", "extension", "yii2 behavior", "nested set", "yii2-nested-set-behavior"], 5 | "homepage": "https://github.com/wbraganca/yii2-nested-set-behavior", 6 | "type": "yii2-extension", 7 | "license": "BSD-3-Clause", 8 | "authors": [ 9 | { 10 | "name": "Alexander Kochetov", 11 | "email": "wbraganca@gmail.com" 12 | }, 13 | { 14 | "name": "Wanderson Bragança", 15 | "email": "wanderson.wbc@gmail.com" 16 | } 17 | ], 18 | "minimum-stability": "dev", 19 | "require": { 20 | "yiisoft/yii2": "*" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "wbraganca\\behaviors\\": "" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /schema/schema-many-roots.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `tbl_category` ( 2 | `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `root` INT(10) UNSIGNED DEFAULT NULL, 4 | `lft` INT(10) UNSIGNED NOT NULL, 5 | `rgt` INT(10) UNSIGNED NOT NULL, 6 | `level` SMALLINT(5) UNSIGNED NOT NULL, 7 | PRIMARY KEY (`id`), 8 | KEY `root` (`root`), 9 | KEY `lft` (`lft`), 10 | KEY `rgt` (`rgt`), 11 | KEY `level` (`level`) 12 | ); 13 | -------------------------------------------------------------------------------- /schema/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `tbl_category` ( 2 | `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, 3 | `lft` INT(10) UNSIGNED NOT NULL, 4 | `rgt` INT(10) UNSIGNED NOT NULL, 5 | `level` SMALLINT(5) UNSIGNED NOT NULL, 6 | PRIMARY KEY (`id`), 7 | KEY `lft` (`lft`), 8 | KEY `rgt` (`rgt`), 9 | KEY `level` (`level`) 10 | ); 11 | --------------------------------------------------------------------------------