diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6b17797 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = false +indent_style = tab +indent_size = 4 +trim_trailing_whitespace = true + +[composer.json] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + + +[*.md] +trim_trailing_whitespace = false + +[*.js] +indent_style = tab +indent_size = 4 \ No newline at end of file diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 57c8a55..c23468a 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -12,17 +12,92 @@ on: - '**/*.markdown' jobs: - tests: + kill_previous: + name: 0️⃣ Kill previous runs runs-on: ubuntu-latest + # We want to run on external PRs, but not on our own internal PRs as they'll be run by the push to the branch. if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository) + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + with: + access_token: ${{ github.token }} + + php_syntax_errors: + name: 1️⃣ PHP - Syntax errors + runs-on: ubuntu-latest + needs: + - kill_previous + steps: + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.0 + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install dependencies + uses: ramsey/composer-install@v2 + + - name: Check source code for syntax errors + run: vendor/bin/parallel-lint --exclude .git --exclude vendor . + code_style_errors: + name: 2️⃣ PHP - Code Style errors + runs-on: ubuntu-latest + needs: + - php_syntax_errors + steps: + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: latest + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install dependencies + uses: ramsey/composer-install@v2 + + - name: Check source code for code style errors + run: PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --verbose --diff --dry-run + + phpstan: + name: 2️⃣ PHP 8.1 - PHPStan + runs-on: ubuntu-latest + needs: + - php_syntax_errors + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.0 + coverage: none + tools: phpstan + + - name: Install Composer dependencies + uses: ramsey/composer-install@v2 + + - name: Run PHPStan + run: vendor/bin/phpstan analyze + + tests: + name: 2️⃣ PHP ${{ matrix.php-version }} + needs: + - php_syntax_errors + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php-versions: ['8.0', '8.1', '8.2'] + php-version: + - 8.0 + - 8.1 + - 8.2 - name: PHP ${{ matrix.php-versions }} - env: COMPOSER_NO_INTERACTION: 1 extensions: curl, json, libxml, dom @@ -32,56 +107,19 @@ jobs: # Checks out a copy of your repository on the ubuntu machine - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - - name: Setup cache environment - id: extcache - uses: shivammathur/cache-extensions@v1 - with: - php-version: ${{ matrix.php-versions }} - extensions: ${{ env.extensions }} - key: ${{ env.key }} - - - name: Cache PHP Extensions - uses: actions/cache@v2 - with: - path: ${{ steps.extcache.outputs.dir }} - key: ${{ steps.extcache.outputs.key }} - restore-keys: ${{ steps.extcache.outputs.key }} - - - name: Cache Composer Dependencies - uses: actions/cache@v1 - with: - path: ~/.composer/cache/files - key: dependencies-composer-${{ hashFiles('composer.json') }} - name: Setup PHP Action uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php-versions }} + php-version: ${{ matrix.php-version }} extensions: ${{ env.extensions }} coverage: xdebug tools: pecl, composer - - name: Get composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache dependencies - uses: actions/cache@v2 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - name: Install Composer dependencies - run: composer install --no-interaction + uses: ramsey/composer-install@v2 - - name: Validate files - run: composer validate-files - - - name: Run PHPStan - run: composer phpstan - - name: Run tests run: composer run-tests diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..4b8efb1 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,45 @@ +in($dir); + }, + PhpCsFixer\Finder::create()->ignoreUnreadableDirs() +)->notName('*.blade.php'); +$rules = [ + '@Symfony' => true, + 'align_multiline_comment' => true, + 'array_indentation' => true, + 'backtick_to_shell_exec' => true, + 'increment_style' => ['style' => 'post'], + 'indentation_type' => true, + 'multiline_comment_opening_closing' => true, + 'no_php4_constructor' => true, + 'phpdoc_no_empty_return' => false, + 'single_blank_line_at_eof' => false, + 'yoda_style' => false, + 'concat_space' => ['spacing' => 'one'], + 'no_superfluous_phpdoc_tags' => false, + 'phpdoc_to_comment' => false, // required until https://github.com/phpstan/phpstan/issues/7486 got fixed + 'blank_line_between_import_groups' => false, // not PSR-12 compatible, but preserves old behaviour + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => null, // for PSR-12 compatability, this need to be `['class', 'function', 'const']`, but no grouping preserves old behaviour + ], + 'no_unneeded_control_parentheses' => [ + 'statements' => ['break', 'clone', 'continue', 'echo_print', 'switch_case', 'yield'], + ], +]; +$config = new PhpCsFixer\Config(); + +$config->setRiskyAllowed(true); +$config->setRules($rules); +$config->setIndent("\t"); +$config->setLineEnding("\n"); +$config->setFinder($finder); + +return $config; diff --git a/composer.json b/composer.json index 6bbf8f1..a60a0b9 100644 --- a/composer.json +++ b/composer.json @@ -1,44 +1,50 @@ { "name": "lychee-org/nestedset", "description": "Nested Set Model for Laravel 5.7 and up (fork with patches for Lychee)", - "keywords": ["laravel", "nested sets", "nsm", "database", "hierarchy"], + "keywords": [ + "laravel", + "nested sets", + "nsm", + "database", + "hierarchy" + ], "license": "MIT", - "authors": [ { "name": "Alexander Kalnoy", "email": "lazychaser@gmail.com" } ], - "repositories": [ { "type": "vcs", "url": "https://github.com/LycheeOrg/phpstan-lychee" } ], - "require": { "php": "^8.0", "illuminate/support": "^9.0", "illuminate/database": "^9.0", "illuminate/events": "^9.0" }, - - "autoload": { - "psr-4": { - "Kalnoy\\Nestedset\\": "src/" - } - }, - "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", "php-parallel-lint/php-parallel-lint": "^1.2", "phpunit/phpunit": "^9.5.20", "lychee-org/phpstan-lychee": "dev-master", "nunomaduro/larastan": "^2.0", "orchestra/testbench": "^7.15" }, - + "autoload": { + "psr-4": { + "Kalnoy\\Nestedset\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, "scripts": { "run-tests": [ "vendor/bin/phpunit -c phpunit.xml", @@ -47,13 +53,16 @@ "validate-files": [ "vendor/bin/parallel-lint --exclude vendor ." ], + "format": [ + "rm .php_cs.cache 2> /dev/null || true", + "vendor/bin/php-cs-fixer fix -v --config=.php-cs-fixer.php" + ], "phpstan": [ - "vendor/bin/phpstan analyze" + "vendor/bin/phpstan analyze" ] }, "minimum-stability": "dev", "prefer-stable": true, - "config": { "platform": { "php": "8.0.2" @@ -62,16 +71,14 @@ "sort-packages": true, "optimize-autoloader": true }, - "extra": { "branch-alias": { "dev-master": "v5.0.x-dev" }, - "laravel": { "providers": [ "Kalnoy\\Nestedset\\NestedSetServiceProvider" ] } } -} +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index 9f2085f..28bad9c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8acdda40a4e7414487456eadacefa722", + "content-hash": "b49ca9726c042246fbdf4c6cf6fb8552", "packages": [ { "name": "brick/math", diff --git a/src/AncestorsRelation.php b/src/AncestorsRelation.php index b59fba2..58375e1 100644 --- a/src/AncestorsRelation.php +++ b/src/AncestorsRelation.php @@ -6,53 +6,55 @@ class AncestorsRelation extends BaseRelation { - /** - * Set the base constraints on the relation query. - * - * @return void - */ - public function addConstraints() - { - if ( ! static::$constraints) return; - - $this->query->whereAncestorOf($this->parent) - ->applyNestedSetScope(); - } - - /** - * @param Model $model - * @param $related - * - * @return bool - */ - protected function matches(Model $model, $related) - { - return $related->isAncestorOf($model); - } - - /** - * @param QueryBuilder $query - * @param Model $model - * - * @return void - */ - protected function addEagerConstraint($query, $model) - { - $query->orWhereAncestorOf($model); - } - - /** - * @param $hash - * @param $table - * @param $lft - * @param $rgt - * - * @return string - */ - protected function relationExistenceCondition($hash, $table, $lft, $rgt) - { - $key = $this->getBaseQuery()->getGrammar()->wrap($this->parent->getKeyName()); - - return "{$table}.{$rgt} between {$hash}.{$lft} and {$hash}.{$rgt} and $table.$key <> $hash.$key"; - } + /** + * Set the base constraints on the relation query. + * + * @return void + */ + public function addConstraints() + { + if (!static::$constraints) { + return; + } + + $this->query->whereAncestorOf($this->parent) + ->applyNestedSetScope(); + } + + /** + * @param Model $model + * @param $related + * + * @return bool + */ + protected function matches(Model $model, $related) + { + return $related->isAncestorOf($model); + } + + /** + * @param QueryBuilder $query + * @param Model $model + * + * @return void + */ + protected function addEagerConstraint($query, $model) + { + $query->orWhereAncestorOf($model); + } + + /** + * @param $hash + * @param $table + * @param $lft + * @param $rgt + * + * @return string + */ + protected function relationExistenceCondition($hash, $table, $lft, $rgt) + { + $key = $this->getBaseQuery()->getGrammar()->wrap($this->parent->getKeyName()); + + return "{$table}.{$rgt} between {$hash}.{$lft} and {$hash}.{$rgt} and $table.$key <> $hash.$key"; + } } diff --git a/src/BaseRelation.php b/src/BaseRelation.php index e2bd7db..9011575 100644 --- a/src/BaseRelation.php +++ b/src/BaseRelation.php @@ -7,204 +7,204 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder; -use InvalidArgumentException; abstract class BaseRelation extends Relation { - /** - * @var QueryBuilder - */ - protected $query; - - /** - * @var NodeTrait|Model - */ - protected $parent; - - /** - * The count of self joins. - * - * @var int - */ - protected static $selfJoinCount = 0; - - /** - * AncestorsRelation constructor. - * - * @param QueryBuilder $builder - * @param Model $model - */ - public function __construct(QueryBuilder $builder, Model $model) - { - if ( ! NestedSet::isNode($model)) { - throw new InvalidArgumentException('Model must be node.'); - } - - parent::__construct($builder, $model); - } - - /** - * @param Model $model - * @param $related - * - * @return bool - */ - abstract protected function matches(Model $model, $related); - - /** - * @param QueryBuilder $query - * @param Model $model - * - * @return void - */ - abstract protected function addEagerConstraint($query, $model); - - /** - * @param $hash - * @param $table - * @param $lft - * @param $rgt - * - * @return string - */ - abstract protected function relationExistenceCondition($hash, $table, $lft, $rgt); - - /** - * @param EloquentBuilder $query - * @param EloquentBuilder $parent - * @param array $columns - * - * @return mixed - */ - public function getRelationExistenceQuery(EloquentBuilder $query, EloquentBuilder $parent, - $columns = [ '*' ] - ) { - $query = $this->getParent()->replicate()->newScopedQuery()->select($columns); - - $table = $query->getModel()->getTable(); - - $query->from($table.' as '.$hash = $this->getRelationCountHash()); - - $query->getModel()->setTable($hash); - - $grammar = $query->getQuery()->getGrammar(); - - $condition = $this->relationExistenceCondition( - $grammar->wrapTable($hash), - $grammar->wrapTable($table), - $grammar->wrap($this->parent->getLftName()), - $grammar->wrap($this->parent->getRgtName())); - - return $query->whereRaw($condition); - } - - /** - * Initialize the relation on a set of models. - * - * @param array $models - * @param string $relation - * - * @return array - */ - public function initRelation(array $models, $relation) - { - return $models; - } - - /** - * Get a relationship join table hash. - * - * @param bool $incrementJoinCount - * @return string - */ - public function getRelationCountHash($incrementJoinCount = true) - { - return 'nested_set_'.($incrementJoinCount ? static::$selfJoinCount++ : static::$selfJoinCount); - } - - /** - * Get the results of the relationship. - * - * @return mixed - */ - public function getResults() - { - return $this->query->get(); - } - - /** - * Set the constraints for an eager load of the relation. - * - * @param array $models - * - * @return void - */ - public function addEagerConstraints(array $models) - { - // The first model in the array is always the parent, so add the scope constraints based on that model. - // @link https://github.com/laravel/framework/pull/25240 - // @link https://github.com/lazychaser/laravel-nestedset/issues/351 - optional(reset($models))->applyNestedSetScope($this->query); - - $this->query->whereNested(function (Builder $inner) use ($models) { - // We will use this query in order to apply constraints to the - // base query builder - $outer = $this->parent->newQuery()->setQuery($inner); - - foreach ($models as $model) { - $this->addEagerConstraint($outer, $model); - } - }); - } - - /** - * Match the eagerly loaded results to their parents. - * - * @param array $models - * @param EloquentCollection $results - * @param string $relation - * - * @return array - */ - public function match(array $models, EloquentCollection $results, $relation) - { - foreach ($models as $model) { - $related = $this->matchForModel($model, $results); - - $model->setRelation($relation, $related); - } - - return $models; - } - - /** - * @param Model $model - * @param EloquentCollection $results - * - * @return Collection - */ - protected function matchForModel(Model $model, EloquentCollection $results) - { - $result = $this->related->newCollection(); - - foreach ($results as $related) { - if ($this->matches($model, $related)) { - $result->push($related); - } - } - - return $result; - } - - /** - * Get the plain foreign key. - * - * @return mixed - */ - public function getForeignKeyName() - { - // Return a stub value for relation - // resolvers which need this function. - return NestedSet::PARENT_ID; - } + /** + * @var QueryBuilder + */ + protected $query; + + /** + * @var NodeTrait|Model + */ + protected $parent; + + /** + * The count of self joins. + * + * @var int + */ + protected static $selfJoinCount = 0; + + /** + * AncestorsRelation constructor. + * + * @param QueryBuilder $builder + * @param Model $model + */ + public function __construct(QueryBuilder $builder, Model $model) + { + if (!NestedSet::isNode($model)) { + throw new \InvalidArgumentException('Model must be node.'); + } + + parent::__construct($builder, $model); + } + + /** + * @param Model $model + * @param $related + * + * @return bool + */ + abstract protected function matches(Model $model, $related); + + /** + * @param QueryBuilder $query + * @param Model $model + * + * @return void + */ + abstract protected function addEagerConstraint($query, $model); + + /** + * @param $hash + * @param $table + * @param $lft + * @param $rgt + * + * @return string + */ + abstract protected function relationExistenceCondition($hash, $table, $lft, $rgt); + + /** + * @param EloquentBuilder $query + * @param EloquentBuilder $parent + * @param array $columns + * + * @return mixed + */ + public function getRelationExistenceQuery(EloquentBuilder $query, EloquentBuilder $parent, + $columns = ['*'] + ) { + $query = $this->getParent()->replicate()->newScopedQuery()->select($columns); + + $table = $query->getModel()->getTable(); + + $query->from($table . ' as ' . $hash = $this->getRelationCountHash()); + + $query->getModel()->setTable($hash); + + $grammar = $query->getQuery()->getGrammar(); + + $condition = $this->relationExistenceCondition( + $grammar->wrapTable($hash), + $grammar->wrapTable($table), + $grammar->wrap($this->parent->getLftName()), + $grammar->wrap($this->parent->getRgtName())); + + return $query->whereRaw($condition); + } + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * + * @return array + */ + public function initRelation(array $models, $relation) + { + return $models; + } + + /** + * Get a relationship join table hash. + * + * @param bool $incrementJoinCount + * + * @return string + */ + public function getRelationCountHash($incrementJoinCount = true) + { + return 'nested_set_' . ($incrementJoinCount ? static::$selfJoinCount++ : static::$selfJoinCount); + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function getResults() + { + return $this->query->get(); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * + * @return void + */ + public function addEagerConstraints(array $models) + { + // The first model in the array is always the parent, so add the scope constraints based on that model. + // @link https://github.com/laravel/framework/pull/25240 + // @link https://github.com/lazychaser/laravel-nestedset/issues/351 + optional(reset($models))->applyNestedSetScope($this->query); + + $this->query->whereNested(function (Builder $inner) use ($models) { + // We will use this query in order to apply constraints to the + // base query builder + $outer = $this->parent->newQuery()->setQuery($inner); + + foreach ($models as $model) { + $this->addEagerConstraint($outer, $model); + } + }); + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param EloquentCollection $results + * @param string $relation + * + * @return array + */ + public function match(array $models, EloquentCollection $results, $relation) + { + foreach ($models as $model) { + $related = $this->matchForModel($model, $results); + + $model->setRelation($relation, $related); + } + + return $models; + } + + /** + * @param Model $model + * @param EloquentCollection $results + * + * @return Collection + */ + protected function matchForModel(Model $model, EloquentCollection $results) + { + $result = $this->related->newCollection(); + + foreach ($results as $related) { + if ($this->matches($model, $related)) { + $result->push($related); + } + } + + return $result; + } + + /** + * Get the plain foreign key. + * + * @return mixed + */ + public function getForeignKeyName() + { + // Return a stub value for relation + // resolvers which need this function. + return NestedSet::PARENT_ID; + } } diff --git a/src/Collection.php b/src/Collection.php index 8a61bd2..5b2b20e 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -7,137 +7,140 @@ final class Collection extends BaseCollection { - /** - * Fill `parent` and `children` relationships for every node in the collection. - * - * This will overwrite any previously set relations. - * - * @return $this - */ - public function linkNodes() - { - if ($this->isEmpty()) return $this; - - $groupedNodes = $this->groupBy($this->first()->getParentIdName()); - - /** @var NodeTrait|Model $node */ - foreach ($this->items as $node) { - if ( ! $node->getParentId()) { - $node->setRelation('parent', null); - } - - $children = $groupedNodes->get($node->getKey(), [ ]); - - /** @var Model|NodeTrait $child */ - foreach ($children as $child) { - $child->setRelation('parent', $node); - } - - $node->setRelation('children', BaseCollection::make($children)); - } - - return $this; - } - - /** - * Build a tree from a list of nodes. Each item will have set children relation. - * - * To successfully build tree "id", "_lft" and "parent_id" keys must present. - * - * If `$root` is provided, the tree will contain only descendants of that node. - * - * @param mixed $root - * - * @return Collection - */ - public function toTree($root = false) - { - if ($this->isEmpty()) { - return new static; - } - - $this->linkNodes(); - - $items = [ ]; - - $root = $this->getRootNodeId($root); - - /** @var Model|NodeTrait $node */ - foreach ($this->items as $node) { - if ($node->getParentId() == $root) { - $items[] = $node; - } - } - - return new static($items); - } - - /** - * @param mixed $root - * - * @return int - */ - protected function getRootNodeId($root = false) - { - if (NestedSet::isNode($root)) { - return $root->getKey(); - } - - if ($root !== false) { - return $root; - } - - // If root node is not specified we take parent id of node with - // least lft value as root node id. - $leastValue = null; - - /** @var Model|NodeTrait $node */ - foreach ($this->items as $node) { - if ($leastValue === null || $node->getLft() < $leastValue) { - $leastValue = $node->getLft(); - $root = $node->getParentId(); - } - } - - return $root; - } - - /** - * Build a list of nodes that retain the order that they were pulled from - * the database. - * - * @param bool $root - * - * @return static - */ - public function toFlatTree($root = false) - { - $result = new static; - - if ($this->isEmpty()) return $result; - - $groupedNodes = $this->groupBy($this->first()->getParentIdName()); - - return $result->flattenTree($groupedNodes, $this->getRootNodeId($root)); - } - - /** - * Flatten a tree into a non recursive array. - * - * @param Collection $groupedNodes - * @param mixed $parentId - * - * @return $this - */ - protected function flattenTree(self $groupedNodes, $parentId) - { - foreach ($groupedNodes->get($parentId, []) as $node) { - $this->push($node); - - $this->flattenTree($groupedNodes, $node->getKey()); - } - - return $this; - } - + /** + * Fill `parent` and `children` relationships for every node in the collection. + * + * This will overwrite any previously set relations. + * + * @return $this + */ + public function linkNodes() + { + if ($this->isEmpty()) { + return $this; + } + + $groupedNodes = $this->groupBy($this->first()->getParentIdName()); + + /** @var NodeTrait|Model $node */ + foreach ($this->items as $node) { + if (!$node->getParentId()) { + $node->setRelation('parent', null); + } + + $children = $groupedNodes->get($node->getKey(), []); + + /** @var Model|NodeTrait $child */ + foreach ($children as $child) { + $child->setRelation('parent', $node); + } + + $node->setRelation('children', BaseCollection::make($children)); + } + + return $this; + } + + /** + * Build a tree from a list of nodes. Each item will have set children relation. + * + * To successfully build tree "id", "_lft" and "parent_id" keys must present. + * + * If `$root` is provided, the tree will contain only descendants of that node. + * + * @param mixed $root + * + * @return Collection + */ + public function toTree($root = false) + { + if ($this->isEmpty()) { + return new static(); + } + + $this->linkNodes(); + + $items = []; + + $root = $this->getRootNodeId($root); + + /** @var Model|NodeTrait $node */ + foreach ($this->items as $node) { + if ($node->getParentId() == $root) { + $items[] = $node; + } + } + + return new static($items); + } + + /** + * @param mixed $root + * + * @return int + */ + protected function getRootNodeId($root = false) + { + if (NestedSet::isNode($root)) { + return $root->getKey(); + } + + if ($root !== false) { + return $root; + } + + // If root node is not specified we take parent id of node with + // least lft value as root node id. + $leastValue = null; + + /** @var Model|NodeTrait $node */ + foreach ($this->items as $node) { + if ($leastValue === null || $node->getLft() < $leastValue) { + $leastValue = $node->getLft(); + $root = $node->getParentId(); + } + } + + return $root; + } + + /** + * Build a list of nodes that retain the order that they were pulled from + * the database. + * + * @param bool $root + * + * @return static + */ + public function toFlatTree($root = false) + { + $result = new static(); + + if ($this->isEmpty()) { + return $result; + } + + $groupedNodes = $this->groupBy($this->first()->getParentIdName()); + + return $result->flattenTree($groupedNodes, $this->getRootNodeId($root)); + } + + /** + * Flatten a tree into a non recursive array. + * + * @param Collection $groupedNodes + * @param mixed $parentId + * + * @return $this + */ + protected function flattenTree(self $groupedNodes, $parentId) + { + foreach ($groupedNodes->get($parentId, []) as $node) { + $this->push($node); + + $this->flattenTree($groupedNodes, $node->getKey()); + } + + return $this; + } } diff --git a/src/DescendantsRelation.php b/src/DescendantsRelation.php index 4c6457d..ca64fc8 100644 --- a/src/DescendantsRelation.php +++ b/src/DescendantsRelation.php @@ -2,55 +2,55 @@ namespace Kalnoy\Nestedset; -use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; class DescendantsRelation extends BaseRelation { - - /** - * Set the base constraints on the relation query. - * - * @return void - */ - public function addConstraints() - { - if ( ! static::$constraints) return; - - $this->query->whereDescendantOf($this->parent) - ->applyNestedSetScope(); - } - - /** - * @param QueryBuilder $query - * @param Model $model - */ - protected function addEagerConstraint($query, $model) - { - $query->orWhereDescendantOf($model); - } - - /** - * @param Model $model - * @param $related - * - * @return mixed - */ - protected function matches(Model $model, $related) - { - return $related->isDescendantOf($model); - } - - /** - * @param $hash - * @param $table - * @param $lft - * @param $rgt - * - * @return string - */ - protected function relationExistenceCondition($hash, $table, $lft, $rgt) - { - return "{$hash}.{$lft} between {$table}.{$lft} + 1 and {$table}.{$rgt}"; - } + /** + * Set the base constraints on the relation query. + * + * @return void + */ + public function addConstraints() + { + if (!static::$constraints) { + return; + } + + $this->query->whereDescendantOf($this->parent) + ->applyNestedSetScope(); + } + + /** + * @param QueryBuilder $query + * @param Model $model + */ + protected function addEagerConstraint($query, $model) + { + $query->orWhereDescendantOf($model); + } + + /** + * @param Model $model + * @param $related + * + * @return mixed + */ + protected function matches(Model $model, $related) + { + return $related->isDescendantOf($model); + } + + /** + * @param $hash + * @param $table + * @param $lft + * @param $rgt + * + * @return string + */ + protected function relationExistenceCondition($hash, $table, $lft, $rgt) + { + return "{$hash}.{$lft} between {$table}.{$lft} + 1 and {$table}.{$rgt}"; + } } \ No newline at end of file diff --git a/src/NestedSet.php b/src/NestedSet.php index 7045471..1d330b3 100644 --- a/src/NestedSet.php +++ b/src/NestedSet.php @@ -6,78 +6,77 @@ class NestedSet { - /** - * The name of default lft column. - */ - const LFT = '_lft'; + /** + * The name of default lft column. + */ + public const LFT = '_lft'; - /** - * The name of default rgt column. - */ - const RGT = '_rgt'; + /** + * The name of default rgt column. + */ + public const RGT = '_rgt'; - /** - * The name of default parent id column. - */ - const PARENT_ID = 'parent_id'; + /** + * The name of default parent id column. + */ + public const PARENT_ID = 'parent_id'; - /** - * Insert direction. - */ - const BEFORE = 1; + /** + * Insert direction. + */ + public const BEFORE = 1; - /** - * Insert direction. - */ - const AFTER = 2; + /** + * Insert direction. + */ + public const AFTER = 2; - /** - * Add default nested set columns to the table. Also create an index. - * - * @param \Illuminate\Database\Schema\Blueprint $table - */ - public static function columns(Blueprint $table) - { - $table->unsignedInteger(self::LFT)->default(0); - $table->unsignedInteger(self::RGT)->default(0); - $table->unsignedInteger(self::PARENT_ID)->nullable(); + /** + * Add default nested set columns to the table. Also create an index. + * + * @param \Illuminate\Database\Schema\Blueprint $table + */ + public static function columns(Blueprint $table) + { + $table->unsignedInteger(self::LFT)->default(0); + $table->unsignedInteger(self::RGT)->default(0); + $table->unsignedInteger(self::PARENT_ID)->nullable(); - $table->index(static::getDefaultColumns()); - } + $table->index(static::getDefaultColumns()); + } - /** - * Drop NestedSet columns. - * - * @param \Illuminate\Database\Schema\Blueprint $table - */ - public static function dropColumns(Blueprint $table) - { - $columns = static::getDefaultColumns(); + /** + * Drop NestedSet columns. + * + * @param \Illuminate\Database\Schema\Blueprint $table + */ + public static function dropColumns(Blueprint $table) + { + $columns = static::getDefaultColumns(); - $table->dropIndex($columns); - $table->dropColumn($columns); - } + $table->dropIndex($columns); + $table->dropColumn($columns); + } - /** - * Get a list of default columns. - * - * @return array - */ - public static function getDefaultColumns() - { - return [ static::LFT, static::RGT, static::PARENT_ID ]; - } - - /** - * Replaces instanceof calls for this trait. - * - * @param mixed $node - * - * @return bool - */ - public static function isNode($node) - { - return $node instanceof Node; - } + /** + * Get a list of default columns. + * + * @return array + */ + public static function getDefaultColumns() + { + return [static::LFT, static::RGT, static::PARENT_ID]; + } + /** + * Replaces instanceof calls for this trait. + * + * @param mixed $node + * + * @return bool + */ + public static function isNode($node) + { + return $node instanceof Node; + } } \ No newline at end of file diff --git a/src/NestedSetServiceProvider.php b/src/NestedSetServiceProvider.php index b4516f7..2a14169 100644 --- a/src/NestedSetServiceProvider.php +++ b/src/NestedSetServiceProvider.php @@ -7,14 +7,14 @@ class NestedSetServiceProvider extends ServiceProvider { - public function register() - { - Blueprint::macro('nestedSet', function () { - NestedSet::columns($this); - }); + public function register() + { + Blueprint::macro('nestedSet', function () { + NestedSet::columns($this); + }); - Blueprint::macro('dropNestedSet', function () { - NestedSet::dropColumns($this); - }); - } + Blueprint::macro('dropNestedSet', function () { + NestedSet::dropColumns($this); + }); + } } \ No newline at end of file diff --git a/src/NodeTrait.php b/src/NodeTrait.php index 5e9ee78..81b4312 100644 --- a/src/NodeTrait.php +++ b/src/NodeTrait.php @@ -2,1243 +2,1257 @@ namespace Kalnoy\Nestedset; -use Exception; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Arr; -use LogicException; trait NodeTrait { - /** - * Pending operation. - * - * @var array - */ - protected $pending; - - /** - * Whether the node has moved since last save. - * - * @var bool - */ - protected $moved = false; - - /** - * @var \Carbon\Carbon - */ - public static $deletedAt; - - /** - * Keep track of the number of performed operations. - * - * @var int - */ - public static $actionsPerformed = 0; - - /** - * Sign on model events. - */ - public static function bootNodeTrait() - { - static::saving(function ($model) { - return $model->callPendingAction(); - }); - - static::deleting(function ($model) { - // We will need fresh data to delete node safely - // We must delete the descendants BEFORE we delete the actual - // album to avoid failing FOREIGN key constraints. - $model->refreshNode(); - $model->deleteDescendants(); - }); - - if (static::usesSoftDelete()) { - static::restoring(function ($model) { - static::$deletedAt = $model->{$model->getDeletedAtColumn()}; - }); - - static::restored(function ($model) { - $model->restoreDescendants(static::$deletedAt); - }); - } - } - - /** - * Set an action. - * - * @param string $action - * - * @return $this - */ - protected function setNodeAction($action) - { - $this->pending = func_get_args(); - - return $this; - } - - /** - * Call pending action. - */ - protected function callPendingAction() - { - $this->moved = false; - - if ( ! $this->pending && ! $this->exists) { - $this->makeRoot(); - } - - if ( ! $this->pending) return; - - $method = 'action'.ucfirst(array_shift($this->pending)); - $parameters = $this->pending; - - $this->pending = null; - - $this->moved = call_user_func_array([ $this, $method ], $parameters); - } - - /** - * @return bool - */ - public static function usesSoftDelete() - { - static $softDelete; - - if (is_null($softDelete)) { - $instance = new static; - - return $softDelete = method_exists($instance, 'bootSoftDeletes'); - } - - return $softDelete; - } - - /** - * @return bool - */ - protected function actionRaw() - { - return true; - } - - /** - * Make a root node. - */ - protected function actionRoot() - { - // Simplest case that do not affect other nodes. - if ( ! $this->exists) { - $cut = $this->getLowerBound() + 1; - - $this->setLft($cut); - $this->setRgt($cut + 1); - - return true; - } - - return $this->insertAt($this->getLowerBound() + 1); - } - - /** - * Get the lower bound. - * - * @return int - */ - protected function getLowerBound() - { - return (int)$this->newNestedSetQuery()->max($this->getRgtName()); - } - - /** - * Append or prepend a node to the parent. - * - * @param self $parent - * @param bool $prepend - * - * @return bool - */ - protected function actionAppendOrPrepend(self $parent, $prepend = false) - { - $parent->refreshNode(); - - $cut = $prepend ? $parent->getLft() + 1 : $parent->getRgt(); - - if ( ! $this->insertAt($cut)) { - return false; - } - - $parent->refreshNode(); - - return true; - } - - /** - * Apply parent model. - * - * @param Model|null $value - * - * @return $this - */ - protected function setParent($value) - { - $this->setParentId($value ? $value->getKey() : null) - ->setRelation('parent', $value); - - return $this; - } - - /** - * Insert node before or after another node. - * - * @param self $node - * @param bool $after - * - * @return bool - */ - protected function actionBeforeOrAfter(self $node, $after = false) - { - $node->refreshNode(); - - return $this->insertAt($after ? $node->getRgt() + 1 : $node->getLft()); - } - - /** - * Refresh node's crucial attributes. - */ - public function refreshNode() - { - if ( ! $this->exists || static::$actionsPerformed === 0) return; - - $attributes = $this->newNestedSetQuery()->getNodeData($this->getKey()); - - $this->attributes = array_merge($this->attributes, $attributes); -// $this->original = array_merge($this->original, $attributes); - } - - /** - * Relation to the parent. - * - * @return BelongsTo - */ - public function parent() - { - return $this->belongsTo(get_class($this), $this->getParentIdName()) - ->setModel($this); - } - - /** - * Relation to children. - * - * @return HasMany - */ - public function children() - { - return $this->hasMany(get_class($this), $this->getParentIdName()) - ->setModel($this); - } - - /** - * Get query for descendants of the node. - * - * @return DescendantsRelation - */ - public function descendants() - { - return new DescendantsRelation($this->newQuery(), $this); - } - - /** - * Get query for siblings of the node. - * - * @return QueryBuilder - */ - public function siblings() - { - return $this->newScopedQuery() - ->where($this->getKeyName(), '<>', $this->getKey()) - ->where($this->getParentIdName(), '=', $this->getParentId()); - } - - /** - * Get the node siblings and the node itself. - * - * @return \Kalnoy\Nestedset\QueryBuilder - */ - public function siblingsAndSelf() - { - return $this->newScopedQuery() - ->where($this->getParentIdName(), '=', $this->getParentId()); - } - - /** - * Get query for the node siblings and the node itself. - * - * @param array $columns - * - * @return \Illuminate\Database\Eloquent\Collection - */ - public function getSiblingsAndSelf(array $columns = [ '*' ]) - { - return $this->siblingsAndSelf()->get($columns); - } - - /** - * Get query for siblings after the node. - * - * @return QueryBuilder - */ - public function nextSiblings() - { - return $this->nextNodes() - ->where($this->getParentIdName(), '=', $this->getParentId()); - } - - /** - * Get query for siblings before the node. - * - * @return QueryBuilder - */ - public function prevSiblings() - { - return $this->prevNodes() - ->where($this->getParentIdName(), '=', $this->getParentId()); - } - - /** - * Get query for nodes after current node. - * - * @return QueryBuilder - */ - public function nextNodes() - { - return $this->newScopedQuery() - ->where($this->getLftName(), '>', $this->getLft()); - } - - /** - * Get query for nodes before current node in reversed order. - * - * @return QueryBuilder - */ - public function prevNodes() - { - return $this->newScopedQuery() - ->where($this->getLftName(), '<', $this->getLft()); - } - - /** - * Get query ancestors of the node. - * - * @return AncestorsRelation - */ - public function ancestors() - { - return new AncestorsRelation($this->newQuery(), $this); - } - - /** - * Make this node a root node. - * - * @return $this - */ - public function makeRoot() - { - $this->setParent(null)->dirtyBounds(); - - return $this->setNodeAction('root'); - } - - /** - * Save node as root. - * - * @return bool - */ - public function saveAsRoot() - { - if ($this->exists && $this->isRoot()) { - return $this->save(); - } - - return $this->makeRoot()->save(); - } - - /** - * Append and save a node. - * - * @param self $node - * - * @return bool - */ - public function appendNode(self $node) - { - return $node->appendToNode($this)->save(); - } - - /** - * Prepend and save a node. - * - * @param self $node - * - * @return bool - */ - public function prependNode(self $node) - { - return $node->prependToNode($this)->save(); - } - - /** - * Append a node to the new parent. - * - * @param self $parent - * - * @return $this - */ - public function appendToNode(self $parent) - { - return $this->appendOrPrependTo($parent); - } - - /** - * Prepend a node to the new parent. - * - * @param self $parent - * - * @return $this - */ - public function prependToNode(self $parent) - { - return $this->appendOrPrependTo($parent, true); - } - - /** - * @param self $parent - * @param bool $prepend - * - * @return self - */ - public function appendOrPrependTo(self $parent, $prepend = false) - { - $this->assertNodeExists($parent) - ->assertNotDescendant($parent) - ->assertSameScope($parent); - - $this->setParent($parent)->dirtyBounds(); - - return $this->setNodeAction('appendOrPrepend', $parent, $prepend); - } - - /** - * Insert self after a node. - * - * @param self $node - * - * @return $this - */ - public function afterNode(self $node) - { - return $this->beforeOrAfterNode($node, true); - } - - /** - * Insert self before node. - * - * @param self $node - * - * @return $this - */ - public function beforeNode(self $node) - { - return $this->beforeOrAfterNode($node); - } - - /** - * @param self $node - * @param bool $after - * - * @return self - */ - public function beforeOrAfterNode(self $node, $after = false) - { - $this->assertNodeExists($node) - ->assertNotDescendant($node) - ->assertSameScope($node); - - if ( ! $this->isSiblingOf($node)) { - $this->setParent($node->getRelationValue('parent')); - } - - $this->dirtyBounds(); - - return $this->setNodeAction('beforeOrAfter', $node, $after); - } - - /** - * Insert self after a node and save. - * - * @param self $node - * - * @return bool - */ - public function insertAfterNode(self $node) - { - return $this->afterNode($node)->save(); - } - - /** - * Insert self before a node and save. - * - * @param self $node - * - * @return bool - */ - public function insertBeforeNode(self $node) - { - if ( ! $this->beforeNode($node)->save()) return false; - - // We'll update the target node since it will be moved - $node->refreshNode(); - - return true; - } - - /** - * @param $lft - * @param $rgt - * @param $parentId - * - * @return $this - */ - public function rawNode($lft, $rgt, $parentId) - { - $this->setLft($lft)->setRgt($rgt)->setParentId($parentId); - - return $this->setNodeAction('raw'); - } - - /** - * Move node up given amount of positions. - * - * @param int $amount - * - * @return bool - */ - public function up($amount = 1) - { - $sibling = $this->prevSiblings() - ->defaultOrder('desc') - ->skip($amount - 1) - ->first(); - - if ( ! $sibling) return false; - - return $this->insertBeforeNode($sibling); - } - - /** - * Move node down given amount of positions. - * - * @param int $amount - * - * @return bool - */ - public function down($amount = 1) - { - $sibling = $this->nextSiblings() - ->defaultOrder() - ->skip($amount - 1) - ->first(); - - if ( ! $sibling) return false; - - return $this->insertAfterNode($sibling); - } - - /** - * Insert node at specific position. - * - * @param int $position - * - * @return bool - */ - protected function insertAt($position) - { - ++static::$actionsPerformed; - - $result = $this->exists - ? $this->moveNode($position) - : $this->insertNode($position); - - return $result; - } - - /** - * Move a node to the new position. - * - * @since 2.0 - * - * @param int $position - * - * @return int - */ - protected function moveNode($position) - { - $updated = $this->newNestedSetQuery() - ->moveNode($this->getKey(), $position) > 0; - - if ($updated) $this->refreshNode(); - - return $updated; - } - - /** - * Insert new node at specified position. - * - * @since 2.0 - * - * @param int $position - * - * @return bool - */ - protected function insertNode($position) - { - $this->newNestedSetQuery()->makeGap($position, 2); - - $height = $this->getNodeHeight(); - - $this->setLft($position); - $this->setRgt($position + $height - 1); - - return true; - } - - /** - * Update the tree when the node is removed physically. - */ - protected function deleteDescendants() - { - $lft = $this->getLft(); - $rgt = $this->getRgt(); - - $method = $this->usesSoftDelete() && $this->forceDeleting - ? 'forceDelete' - : 'delete'; - - // We must delete the nodes in correct order to avoid failing - // foreign key constraints when we delete an entire subtree. - // For MySQL we must avoid that a parent is deleted before its - // children although the complete subtree will be deleted eventually. - // Hence, deletion must start with the deepest node, i.e. with the - // highest _lft value first. - // Note: `DELETE ... ORDER BY` is non-standard SQL but required by - // MySQL (see https://dev.mysql.com/doc/refman/8.0/en/delete.html), - // because MySQL only supports "row consistency". - // This means the DB must be consistent before and after every single - // operation on a row. - // This is contrasted by statement and transaction consistency which - // means that the DB must be consistent before and after every - // completed statement/transaction. - // (See https://dev.mysql.com/doc/refman/8.0/en/ansi-diff-foreign-keys.html) - // ANSI Standard SQL requires support for statement/transaction - // consistency, but only PostgreSQL supports it. - // (Good PosgreSQL :-) ) - // PostgreSQL does not support `DELETE ... ORDER BY` but also has no - // need for it. - // The grammar compiler removes the superfluous "ORDER BY" for - // PostgreSQL. - $this->descendants() - ->orderBy($this->getLftName(), 'desc') - ->{$method}(); - - if ($this->hardDeleting()) { - $height = $rgt - $lft + 1; - - $this->newNestedSetQuery()->makeGap($rgt + 1, -$height); - - // In case if user wants to re-create the node - $this->makeRoot(); - - static::$actionsPerformed++; - } - } - - /** - * Restore the descendants. - * - * @param $deletedAt - */ - protected function restoreDescendants($deletedAt) - { - $this->descendants() - ->where($this->getDeletedAtColumn(), '>=', $deletedAt) - ->restore(); - } - - /** - * {@inheritdoc} - * - * @since 2.0 - */ - public function newEloquentBuilder($query) - { - return new QueryBuilder($query); - } - - /** - * Get a new base query that includes deleted nodes. - * - * @since 1.1 - * - * @return QueryBuilder - */ - public function newNestedSetQuery($table = null) - { - $builder = $this->usesSoftDelete() - ? $this->withTrashed() - : $this->newQuery(); - - return $this->applyNestedSetScope($builder, $table); - } - - /** - * @param string $table - * - * @return QueryBuilder - */ - public function newScopedQuery($table = null) - { - return $this->applyNestedSetScope($this->newQuery(), $table); - } - - /** - * @param mixed $query - * @param string $table - * - * @return mixed - */ - public function applyNestedSetScope($query, $table = null) - { - if ( ! $scoped = $this->getScopeAttributes()) { - return $query; - } - - if ( ! $table) { - $table = $this->getTable(); - } - - foreach ($scoped as $attribute) { - $query->where($table.'.'.$attribute, '=', - $this->getAttributeValue($attribute)); - } - - return $query; - } - - /** - * @return array - */ - protected function getScopeAttributes() - { - return null; - } - - /** - * @param array $attributes - * - * @return self - */ - public static function scoped(array $attributes) - { - $instance = new static; - - $instance->setRawAttributes($attributes); - - return $instance->newScopedQuery(); - } - - /** - * {@inheritdoc} - */ - public function newCollection(array $models = array()) - { - return new Collection($models); - } - - /** - * {@inheritdoc} - * - * Use `children` key on `$attributes` to create child nodes. - * - * @param self $parent - */ - public static function create(array $attributes = [], self $parent = null) - { - $children = Arr::pull($attributes, 'children'); - - $instance = new static($attributes); - - if ($parent) { - $instance->appendToNode($parent); - } - - $instance->save(); - - // Now create children - $relation = new EloquentCollection; - - foreach ((array)$children as $child) { - $relation->add($child = static::create($child, $instance)); - - $child->setRelation('parent', $instance); - } - - $instance->refreshNode(); - - return $instance->setRelation('children', $relation); - } - - /** - * Get node height (rgt - lft + 1). - * - * @return int - */ - public function getNodeHeight() - { - if ( ! $this->exists) return 2; - - return $this->getRgt() - $this->getLft() + 1; - } - - /** - * Get number of descendant nodes. - * - * @return int - */ - public function getDescendantCount() - { - return ceil($this->getNodeHeight() / 2) - 1; - } - - /** - * Set the value of model's parent id key. - * - * Behind the scenes node is appended to found parent node. - * - * @param int $value - * - * @throws Exception If parent node doesn't exists - */ - public function setParentIdAttribute($value) - { - if ($this->getParentId() == $value) return; - - if ($value) { - $this->appendToNode($this->newScopedQuery()->findOrFail($value)); - } else { - $this->makeRoot(); - } - } - - /** - * Get whether node is root. - * - * @return boolean - */ - public function isRoot() - { - return is_null($this->getParentId()); - } - - /** - * @return bool - */ - public function isLeaf() - { - return $this->getLft() + 1 == $this->getRgt(); - } - - /** - * Get the lft key name. - * - * @return string - */ - public function getLftName() - { - return NestedSet::LFT; - } - - /** - * Get the rgt key name. - * - * @return string - */ - public function getRgtName() - { - return NestedSet::RGT; - } - - /** - * Get the parent id key name. - * - * @return string - */ - public function getParentIdName() - { - return NestedSet::PARENT_ID; - } - - /** - * Get the value of the model's lft key. - * - * @return integer - */ - public function getLft() - { - return $this->getAttributeValue($this->getLftName()); - } - - /** - * Get the value of the model's rgt key. - * - * @return integer - */ - public function getRgt() - { - return $this->getAttributeValue($this->getRgtName()); - } - - /** - * Get the value of the model's parent id key. - * - * @return integer - */ - public function getParentId() - { - return $this->getAttributeValue($this->getParentIdName()); - } - - /** - * Returns node that is next to current node without constraining to siblings. - * - * This can be either a next sibling or a next sibling of the parent node. - * - * @param array $columns - * - * @return self - */ - public function getNextNode(array $columns = [ '*' ]) - { - return $this->nextNodes()->defaultOrder()->first($columns); - } - - /** - * Returns node that is before current node without constraining to siblings. - * - * This can be either a prev sibling or parent node. - * - * @param array $columns - * - * @return self - */ - public function getPrevNode(array $columns = [ '*' ]) - { - return $this->prevNodes()->defaultOrder('desc')->first($columns); - } - - /** - * @param array $columns - * - * @return Collection - */ - public function getAncestors(array $columns = [ '*' ]) - { - return $this->ancestors()->get($columns); - } - - /** - * @param array $columns - * - * @return Collection|self[] - */ - public function getDescendants(array $columns = [ '*' ]) - { - return $this->descendants()->get($columns); - } - - /** - * @param array $columns - * - * @return Collection|self[] - */ - public function getSiblings(array $columns = [ '*' ]) - { - return $this->siblings()->get($columns); - } - - /** - * @param array $columns - * - * @return Collection|self[] - */ - public function getNextSiblings(array $columns = [ '*' ]) - { - return $this->nextSiblings()->get($columns); - } - - /** - * @param array $columns - * - * @return Collection|self[] - */ - public function getPrevSiblings(array $columns = [ '*' ]) - { - return $this->prevSiblings()->get($columns); - } - - /** - * @param array $columns - * - * @return self - */ - public function getNextSibling(array $columns = [ '*' ]) - { - return $this->nextSiblings()->defaultOrder()->first($columns); - } - - /** - * @param array $columns - * - * @return self - */ - public function getPrevSibling(array $columns = [ '*' ]) - { - return $this->prevSiblings()->defaultOrder('desc')->first($columns); - } - - /** - * Get whether a node is a descendant of other node. - * - * @param self $other - * - * @return bool - */ - public function isDescendantOf(self $other) - { - return $this->getLft() > $other->getLft() && - $this->getLft() < $other->getRgt(); - } - - /** - * Get whether a node is itself or a descendant of other node. - * - * @param self $other - * - * @return bool - */ - public function isSelfOrDescendantOf(self $other) - { - return $this->getLft() >= $other->getLft() && - $this->getLft() < $other->getRgt(); - } - - /** - * Get whether the node is immediate children of other node. - * - * @param self $other - * - * @return bool - */ - public function isChildOf(self $other) - { - return $this->getParentId() == $other->getKey(); - } - - /** - * Get whether the node is a sibling of another node. - * - * @param self $other - * - * @return bool - */ - public function isSiblingOf(self $other) - { - return $this->getParentId() == $other->getParentId(); - } - - /** - * Get whether the node is an ancestor of other node, including immediate parent. - * - * @param self $other - * - * @return bool - */ - public function isAncestorOf(self $other) - { - return $other->isDescendantOf($this); - } - - /** - * Get whether the node is itself or an ancestor of other node, including immediate parent. - * - * @param self $other - * - * @return bool - */ - public function isSelfOrAncestorOf(self $other) - { - return $other->isSelfOrDescendantOf($this); - } - - /** - * Get whether the node has moved since last save. - * - * @return bool - */ - public function hasMoved() - { - return $this->moved; - } - - /** - * @return array - */ - protected function getArrayableRelations() - { - $result = parent::getArrayableRelations(); - - // To fix #17 when converting tree to json falling to infinite recursion. - unset($result['parent']); - - return $result; - } - - /** - * Get whether user is intended to delete the model from database entirely. - * - * @return bool - */ - protected function hardDeleting() - { - return ! $this->usesSoftDelete() || $this->forceDeleting; - } - - /** - * @return array - */ - public function getBounds() - { - return [ $this->getLft(), $this->getRgt() ]; - } - - /** - * @param $value - * - * @return $this - */ - public function setLft($value) - { - $this->attributes[$this->getLftName()] = $value; - - return $this; - } - - /** - * @param $value - * - * @return $this - */ - public function setRgt($value) - { - $this->attributes[$this->getRgtName()] = $value; - - return $this; - } - - /** - * @param $value - * - * @return $this - */ - public function setParentId($value) - { - $this->attributes[$this->getParentIdName()] = $value; - - return $this; - } - - /** - * @return $this - */ - protected function dirtyBounds() - { - $this->original[$this->getLftName()] = null; - $this->original[$this->getRgtName()] = null; - - return $this; - } - - /** - * @param self $node - * - * @return $this - */ - protected function assertNotDescendant(self $node) - { - if ($node == $this || $node->isDescendantOf($this)) { - throw new LogicException('Node must not be a descendant.'); - } - - return $this; - } - - /** - * @param self $node - * - * @return $this - */ - protected function assertNodeExists(self $node) - { - if ( ! $node->getLft() || ! $node->getRgt()) { - throw new LogicException('Node must exists.'); - } - - return $this; - } - - /** - * @param self $node - */ - protected function assertSameScope(self $node) - { - if ( ! $scoped = $this->getScopeAttributes()) { - return; - } - - foreach ($scoped as $attr) { - if ($this->getAttribute($attr) != $node->getAttribute($attr)) { - throw new LogicException('Nodes must be in the same scope'); - } - } - } - - /** - * @param array|null $except - * - * @return \Illuminate\Database\Eloquent\Model - */ - public function replicate(array $except = null) - { - $defaults = [ - $this->getParentIdName(), - $this->getLftName(), - $this->getRgtName(), - ]; - - $except = $except ? array_unique(array_merge($except, $defaults)) : $defaults; - - return parent::replicate($except); - } + /** + * Pending operation. + * + * @var array + */ + protected $pending; + + /** + * Whether the node has moved since last save. + * + * @var bool + */ + protected $moved = false; + + /** + * @var \Carbon\Carbon + */ + public static $deletedAt; + + /** + * Keep track of the number of performed operations. + * + * @var int + */ + public static $actionsPerformed = 0; + + /** + * Sign on model events. + */ + public static function bootNodeTrait() + { + static::saving(function ($model) { + return $model->callPendingAction(); + }); + + static::deleting(function ($model) { + // We will need fresh data to delete node safely + // We must delete the descendants BEFORE we delete the actual + // album to avoid failing FOREIGN key constraints. + $model->refreshNode(); + $model->deleteDescendants(); + }); + + if (static::usesSoftDelete()) { + static::restoring(function ($model) { + static::$deletedAt = $model->{$model->getDeletedAtColumn()}; + }); + + static::restored(function ($model) { + $model->restoreDescendants(static::$deletedAt); + }); + } + } + + /** + * Set an action. + * + * @param string $action + * + * @return $this + */ + protected function setNodeAction($action) + { + $this->pending = func_get_args(); + + return $this; + } + + /** + * Call pending action. + */ + protected function callPendingAction() + { + $this->moved = false; + + if (!$this->pending && !$this->exists) { + $this->makeRoot(); + } + + if (!$this->pending) { + return; + } + + $method = 'action' . ucfirst(array_shift($this->pending)); + $parameters = $this->pending; + + $this->pending = null; + + $this->moved = call_user_func_array([$this, $method], $parameters); + } + + /** + * @return bool + */ + public static function usesSoftDelete() + { + static $softDelete; + + if (is_null($softDelete)) { + $instance = new static(); + + return $softDelete = method_exists($instance, 'bootSoftDeletes'); + } + + return $softDelete; + } + + /** + * @return bool + */ + protected function actionRaw() + { + return true; + } + + /** + * Make a root node. + */ + protected function actionRoot() + { + // Simplest case that do not affect other nodes. + if (!$this->exists) { + $cut = $this->getLowerBound() + 1; + + $this->setLft($cut); + $this->setRgt($cut + 1); + + return true; + } + + return $this->insertAt($this->getLowerBound() + 1); + } + + /** + * Get the lower bound. + * + * @return int + */ + protected function getLowerBound() + { + return (int) $this->newNestedSetQuery()->max($this->getRgtName()); + } + + /** + * Append or prepend a node to the parent. + * + * @param self $parent + * @param bool $prepend + * + * @return bool + */ + protected function actionAppendOrPrepend(self $parent, $prepend = false) + { + $parent->refreshNode(); + + $cut = $prepend ? $parent->getLft() + 1 : $parent->getRgt(); + + if (!$this->insertAt($cut)) { + return false; + } + + $parent->refreshNode(); + + return true; + } + + /** + * Apply parent model. + * + * @param Model|null $value + * + * @return $this + */ + protected function setParent($value) + { + $this->setParentId($value ? $value->getKey() : null) + ->setRelation('parent', $value); + + return $this; + } + + /** + * Insert node before or after another node. + * + * @param self $node + * @param bool $after + * + * @return bool + */ + protected function actionBeforeOrAfter(self $node, $after = false) + { + $node->refreshNode(); + + return $this->insertAt($after ? $node->getRgt() + 1 : $node->getLft()); + } + + /** + * Refresh node's crucial attributes. + */ + public function refreshNode() + { + if (!$this->exists || static::$actionsPerformed === 0) { + return; + } + + $attributes = $this->newNestedSetQuery()->getNodeData($this->getKey()); + + $this->attributes = array_merge($this->attributes, $attributes); + // $this->original = array_merge($this->original, $attributes); + } + + /** + * Relation to the parent. + * + * @return BelongsTo + */ + public function parent() + { + return $this->belongsTo(get_class($this), $this->getParentIdName()) + ->setModel($this); + } + + /** + * Relation to children. + * + * @return HasMany + */ + public function children() + { + return $this->hasMany(get_class($this), $this->getParentIdName()) + ->setModel($this); + } + + /** + * Get query for descendants of the node. + * + * @return DescendantsRelation + */ + public function descendants() + { + return new DescendantsRelation($this->newQuery(), $this); + } + + /** + * Get query for siblings of the node. + * + * @return QueryBuilder + */ + public function siblings() + { + return $this->newScopedQuery() + ->where($this->getKeyName(), '<>', $this->getKey()) + ->where($this->getParentIdName(), '=', $this->getParentId()); + } + + /** + * Get the node siblings and the node itself. + * + * @return \Kalnoy\Nestedset\QueryBuilder + */ + public function siblingsAndSelf() + { + return $this->newScopedQuery() + ->where($this->getParentIdName(), '=', $this->getParentId()); + } + + /** + * Get query for the node siblings and the node itself. + * + * @param array $columns + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getSiblingsAndSelf(array $columns = ['*']) + { + return $this->siblingsAndSelf()->get($columns); + } + + /** + * Get query for siblings after the node. + * + * @return QueryBuilder + */ + public function nextSiblings() + { + return $this->nextNodes() + ->where($this->getParentIdName(), '=', $this->getParentId()); + } + + /** + * Get query for siblings before the node. + * + * @return QueryBuilder + */ + public function prevSiblings() + { + return $this->prevNodes() + ->where($this->getParentIdName(), '=', $this->getParentId()); + } + + /** + * Get query for nodes after current node. + * + * @return QueryBuilder + */ + public function nextNodes() + { + return $this->newScopedQuery() + ->where($this->getLftName(), '>', $this->getLft()); + } + + /** + * Get query for nodes before current node in reversed order. + * + * @return QueryBuilder + */ + public function prevNodes() + { + return $this->newScopedQuery() + ->where($this->getLftName(), '<', $this->getLft()); + } + + /** + * Get query ancestors of the node. + * + * @return AncestorsRelation + */ + public function ancestors() + { + return new AncestorsRelation($this->newQuery(), $this); + } + + /** + * Make this node a root node. + * + * @return $this + */ + public function makeRoot() + { + $this->setParent(null)->dirtyBounds(); + + return $this->setNodeAction('root'); + } + + /** + * Save node as root. + * + * @return bool + */ + public function saveAsRoot() + { + if ($this->exists && $this->isRoot()) { + return $this->save(); + } + + return $this->makeRoot()->save(); + } + + /** + * Append and save a node. + * + * @param self $node + * + * @return bool + */ + public function appendNode(self $node) + { + return $node->appendToNode($this)->save(); + } + + /** + * Prepend and save a node. + * + * @param self $node + * + * @return bool + */ + public function prependNode(self $node) + { + return $node->prependToNode($this)->save(); + } + + /** + * Append a node to the new parent. + * + * @param self $parent + * + * @return $this + */ + public function appendToNode(self $parent) + { + return $this->appendOrPrependTo($parent); + } + + /** + * Prepend a node to the new parent. + * + * @param self $parent + * + * @return $this + */ + public function prependToNode(self $parent) + { + return $this->appendOrPrependTo($parent, true); + } + + /** + * @param self $parent + * @param bool $prepend + * + * @return self + */ + public function appendOrPrependTo(self $parent, $prepend = false) + { + $this->assertNodeExists($parent) + ->assertNotDescendant($parent) + ->assertSameScope($parent); + + $this->setParent($parent)->dirtyBounds(); + + return $this->setNodeAction('appendOrPrepend', $parent, $prepend); + } + + /** + * Insert self after a node. + * + * @param self $node + * + * @return $this + */ + public function afterNode(self $node) + { + return $this->beforeOrAfterNode($node, true); + } + + /** + * Insert self before node. + * + * @param self $node + * + * @return $this + */ + public function beforeNode(self $node) + { + return $this->beforeOrAfterNode($node); + } + + /** + * @param self $node + * @param bool $after + * + * @return self + */ + public function beforeOrAfterNode(self $node, $after = false) + { + $this->assertNodeExists($node) + ->assertNotDescendant($node) + ->assertSameScope($node); + + if (!$this->isSiblingOf($node)) { + $this->setParent($node->getRelationValue('parent')); + } + + $this->dirtyBounds(); + + return $this->setNodeAction('beforeOrAfter', $node, $after); + } + + /** + * Insert self after a node and save. + * + * @param self $node + * + * @return bool + */ + public function insertAfterNode(self $node) + { + return $this->afterNode($node)->save(); + } + + /** + * Insert self before a node and save. + * + * @param self $node + * + * @return bool + */ + public function insertBeforeNode(self $node) + { + if (!$this->beforeNode($node)->save()) { + return false; + } + + // We'll update the target node since it will be moved + $node->refreshNode(); + + return true; + } + + /** + * @param $lft + * @param $rgt + * @param $parentId + * + * @return $this + */ + public function rawNode($lft, $rgt, $parentId) + { + $this->setLft($lft)->setRgt($rgt)->setParentId($parentId); + + return $this->setNodeAction('raw'); + } + + /** + * Move node up given amount of positions. + * + * @param int $amount + * + * @return bool + */ + public function up($amount = 1) + { + $sibling = $this->prevSiblings() + ->defaultOrder('desc') + ->skip($amount - 1) + ->first(); + + if (!$sibling) { + return false; + } + + return $this->insertBeforeNode($sibling); + } + + /** + * Move node down given amount of positions. + * + * @param int $amount + * + * @return bool + */ + public function down($amount = 1) + { + $sibling = $this->nextSiblings() + ->defaultOrder() + ->skip($amount - 1) + ->first(); + + if (!$sibling) { + return false; + } + + return $this->insertAfterNode($sibling); + } + + /** + * Insert node at specific position. + * + * @param int $position + * + * @return bool + */ + protected function insertAt($position) + { + static::$actionsPerformed++; + + $result = $this->exists + ? $this->moveNode($position) + : $this->insertNode($position); + + return $result; + } + + /** + * Move a node to the new position. + * + * @since 2.0 + * + * @param int $position + * + * @return int + */ + protected function moveNode($position) + { + $updated = $this->newNestedSetQuery() + ->moveNode($this->getKey(), $position) > 0; + + if ($updated) { + $this->refreshNode(); + } + + return $updated; + } + + /** + * Insert new node at specified position. + * + * @since 2.0 + * + * @param int $position + * + * @return bool + */ + protected function insertNode($position) + { + $this->newNestedSetQuery()->makeGap($position, 2); + + $height = $this->getNodeHeight(); + + $this->setLft($position); + $this->setRgt($position + $height - 1); + + return true; + } + + /** + * Update the tree when the node is removed physically. + */ + protected function deleteDescendants() + { + $lft = $this->getLft(); + $rgt = $this->getRgt(); + + $method = $this->usesSoftDelete() && $this->forceDeleting + ? 'forceDelete' + : 'delete'; + + // We must delete the nodes in correct order to avoid failing + // foreign key constraints when we delete an entire subtree. + // For MySQL we must avoid that a parent is deleted before its + // children although the complete subtree will be deleted eventually. + // Hence, deletion must start with the deepest node, i.e. with the + // highest _lft value first. + // Note: `DELETE ... ORDER BY` is non-standard SQL but required by + // MySQL (see https://dev.mysql.com/doc/refman/8.0/en/delete.html), + // because MySQL only supports "row consistency". + // This means the DB must be consistent before and after every single + // operation on a row. + // This is contrasted by statement and transaction consistency which + // means that the DB must be consistent before and after every + // completed statement/transaction. + // (See https://dev.mysql.com/doc/refman/8.0/en/ansi-diff-foreign-keys.html) + // ANSI Standard SQL requires support for statement/transaction + // consistency, but only PostgreSQL supports it. + // (Good PosgreSQL :-) ) + // PostgreSQL does not support `DELETE ... ORDER BY` but also has no + // need for it. + // The grammar compiler removes the superfluous "ORDER BY" for + // PostgreSQL. + $this->descendants() + ->orderBy($this->getLftName(), 'desc') + ->{$method}(); + + if ($this->hardDeleting()) { + $height = $rgt - $lft + 1; + + $this->newNestedSetQuery()->makeGap($rgt + 1, -$height); + + // In case if user wants to re-create the node + $this->makeRoot(); + + static::$actionsPerformed++; + } + } + + /** + * Restore the descendants. + * + * @param $deletedAt + */ + protected function restoreDescendants($deletedAt) + { + $this->descendants() + ->where($this->getDeletedAtColumn(), '>=', $deletedAt) + ->restore(); + } + + /** + * {@inheritdoc} + * + * @since 2.0 + */ + public function newEloquentBuilder($query) + { + return new QueryBuilder($query); + } + + /** + * Get a new base query that includes deleted nodes. + * + * @since 1.1 + * + * @return QueryBuilder + */ + public function newNestedSetQuery($table = null) + { + $builder = $this->usesSoftDelete() + ? $this->withTrashed() + : $this->newQuery(); + + return $this->applyNestedSetScope($builder, $table); + } + + /** + * @param string $table + * + * @return QueryBuilder + */ + public function newScopedQuery($table = null) + { + return $this->applyNestedSetScope($this->newQuery(), $table); + } + + /** + * @param mixed $query + * @param string $table + * + * @return mixed + */ + public function applyNestedSetScope($query, $table = null) + { + if (!$scoped = $this->getScopeAttributes()) { + return $query; + } + + if (!$table) { + $table = $this->getTable(); + } + + foreach ($scoped as $attribute) { + $query->where($table . '.' . $attribute, '=', + $this->getAttributeValue($attribute)); + } + + return $query; + } + + /** + * @return array + */ + protected function getScopeAttributes() + { + return null; + } + + /** + * @param array $attributes + * + * @return self + */ + public static function scoped(array $attributes) + { + $instance = new static(); + + $instance->setRawAttributes($attributes); + + return $instance->newScopedQuery(); + } + + /** + * {@inheritdoc} + */ + public function newCollection(array $models = []) + { + return new Collection($models); + } + + /** + * {@inheritdoc} + * + * Use `children` key on `$attributes` to create child nodes. + * + * @param self $parent + */ + public static function create(array $attributes = [], self $parent = null) + { + $children = Arr::pull($attributes, 'children'); + + $instance = new static($attributes); + + if ($parent) { + $instance->appendToNode($parent); + } + + $instance->save(); + + // Now create children + $relation = new EloquentCollection(); + + foreach ((array) $children as $child) { + $relation->add($child = static::create($child, $instance)); + + $child->setRelation('parent', $instance); + } + + $instance->refreshNode(); + + return $instance->setRelation('children', $relation); + } + + /** + * Get node height (rgt - lft + 1). + * + * @return int + */ + public function getNodeHeight() + { + if (!$this->exists) { + return 2; + } + + return $this->getRgt() - $this->getLft() + 1; + } + + /** + * Get number of descendant nodes. + * + * @return int + */ + public function getDescendantCount() + { + return ceil($this->getNodeHeight() / 2) - 1; + } + + /** + * Set the value of model's parent id key. + * + * Behind the scenes node is appended to found parent node. + * + * @param int $value + * + * @throws \Exception If parent node doesn't exists + */ + public function setParentIdAttribute($value) + { + if ($this->getParentId() == $value) { + return; + } + + if ($value) { + $this->appendToNode($this->newScopedQuery()->findOrFail($value)); + } else { + $this->makeRoot(); + } + } + + /** + * Get whether node is root. + * + * @return bool + */ + public function isRoot() + { + return is_null($this->getParentId()); + } + + /** + * @return bool + */ + public function isLeaf() + { + return $this->getLft() + 1 == $this->getRgt(); + } + + /** + * Get the lft key name. + * + * @return string + */ + public function getLftName() + { + return NestedSet::LFT; + } + + /** + * Get the rgt key name. + * + * @return string + */ + public function getRgtName() + { + return NestedSet::RGT; + } + + /** + * Get the parent id key name. + * + * @return string + */ + public function getParentIdName() + { + return NestedSet::PARENT_ID; + } + + /** + * Get the value of the model's lft key. + * + * @return int + */ + public function getLft() + { + return $this->getAttributeValue($this->getLftName()); + } + + /** + * Get the value of the model's rgt key. + * + * @return int + */ + public function getRgt() + { + return $this->getAttributeValue($this->getRgtName()); + } + + /** + * Get the value of the model's parent id key. + * + * @return int + */ + public function getParentId() + { + return $this->getAttributeValue($this->getParentIdName()); + } + + /** + * Returns node that is next to current node without constraining to siblings. + * + * This can be either a next sibling or a next sibling of the parent node. + * + * @param array $columns + * + * @return self + */ + public function getNextNode(array $columns = ['*']) + { + return $this->nextNodes()->defaultOrder()->first($columns); + } + + /** + * Returns node that is before current node without constraining to siblings. + * + * This can be either a prev sibling or parent node. + * + * @param array $columns + * + * @return self + */ + public function getPrevNode(array $columns = ['*']) + { + return $this->prevNodes()->defaultOrder('desc')->first($columns); + } + + /** + * @param array $columns + * + * @return Collection + */ + public function getAncestors(array $columns = ['*']) + { + return $this->ancestors()->get($columns); + } + + /** + * @param array $columns + * + * @return Collection|self[] + */ + public function getDescendants(array $columns = ['*']) + { + return $this->descendants()->get($columns); + } + + /** + * @param array $columns + * + * @return Collection|self[] + */ + public function getSiblings(array $columns = ['*']) + { + return $this->siblings()->get($columns); + } + + /** + * @param array $columns + * + * @return Collection|self[] + */ + public function getNextSiblings(array $columns = ['*']) + { + return $this->nextSiblings()->get($columns); + } + + /** + * @param array $columns + * + * @return Collection|self[] + */ + public function getPrevSiblings(array $columns = ['*']) + { + return $this->prevSiblings()->get($columns); + } + + /** + * @param array $columns + * + * @return self + */ + public function getNextSibling(array $columns = ['*']) + { + return $this->nextSiblings()->defaultOrder()->first($columns); + } + + /** + * @param array $columns + * + * @return self + */ + public function getPrevSibling(array $columns = ['*']) + { + return $this->prevSiblings()->defaultOrder('desc')->first($columns); + } + + /** + * Get whether a node is a descendant of other node. + * + * @param self $other + * + * @return bool + */ + public function isDescendantOf(self $other) + { + return $this->getLft() > $other->getLft() && + $this->getLft() < $other->getRgt(); + } + + /** + * Get whether a node is itself or a descendant of other node. + * + * @param self $other + * + * @return bool + */ + public function isSelfOrDescendantOf(self $other) + { + return $this->getLft() >= $other->getLft() && + $this->getLft() < $other->getRgt(); + } + + /** + * Get whether the node is immediate children of other node. + * + * @param self $other + * + * @return bool + */ + public function isChildOf(self $other) + { + return $this->getParentId() == $other->getKey(); + } + + /** + * Get whether the node is a sibling of another node. + * + * @param self $other + * + * @return bool + */ + public function isSiblingOf(self $other) + { + return $this->getParentId() == $other->getParentId(); + } + + /** + * Get whether the node is an ancestor of other node, including immediate parent. + * + * @param self $other + * + * @return bool + */ + public function isAncestorOf(self $other) + { + return $other->isDescendantOf($this); + } + + /** + * Get whether the node is itself or an ancestor of other node, including immediate parent. + * + * @param self $other + * + * @return bool + */ + public function isSelfOrAncestorOf(self $other) + { + return $other->isSelfOrDescendantOf($this); + } + + /** + * Get whether the node has moved since last save. + * + * @return bool + */ + public function hasMoved() + { + return $this->moved; + } + + /** + * @return array + */ + protected function getArrayableRelations() + { + $result = parent::getArrayableRelations(); + + // To fix #17 when converting tree to json falling to infinite recursion. + unset($result['parent']); + + return $result; + } + + /** + * Get whether user is intended to delete the model from database entirely. + * + * @return bool + */ + protected function hardDeleting() + { + return !$this->usesSoftDelete() || $this->forceDeleting; + } + + /** + * @return array + */ + public function getBounds() + { + return [$this->getLft(), $this->getRgt()]; + } + + /** + * @param $value + * + * @return $this + */ + public function setLft($value) + { + $this->attributes[$this->getLftName()] = $value; + + return $this; + } + + /** + * @param $value + * + * @return $this + */ + public function setRgt($value) + { + $this->attributes[$this->getRgtName()] = $value; + + return $this; + } + + /** + * @param $value + * + * @return $this + */ + public function setParentId($value) + { + $this->attributes[$this->getParentIdName()] = $value; + + return $this; + } + + /** + * @return $this + */ + protected function dirtyBounds() + { + $this->original[$this->getLftName()] = null; + $this->original[$this->getRgtName()] = null; + + return $this; + } + + /** + * @param self $node + * + * @return $this + */ + protected function assertNotDescendant(self $node) + { + if ($node == $this || $node->isDescendantOf($this)) { + throw new \LogicException('Node must not be a descendant.'); + } + + return $this; + } + + /** + * @param self $node + * + * @return $this + */ + protected function assertNodeExists(self $node) + { + if (!$node->getLft() || !$node->getRgt()) { + throw new \LogicException('Node must exists.'); + } + + return $this; + } + + /** + * @param self $node + */ + protected function assertSameScope(self $node) + { + if (!$scoped = $this->getScopeAttributes()) { + return; + } + + foreach ($scoped as $attr) { + if ($this->getAttribute($attr) != $node->getAttribute($attr)) { + throw new \LogicException('Nodes must be in the same scope'); + } + } + } + + /** + * @param array|null $except + * + * @return \Illuminate\Database\Eloquent\Model + */ + public function replicate(array $except = null) + { + $defaults = [ + $this->getParentIdName(), + $this->getLftName(), + $this->getRgtName(), + ]; + + $except = $except ? array_unique(array_merge($except, $defaults)) : $defaults; + + return parent::replicate($except); + } } diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 61aba6a..2f099fa 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -2,1088 +2,1090 @@ namespace Kalnoy\Nestedset; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; -use Illuminate\Database\Query\Builder as Query; use Illuminate\Database\Query\Builder as BaseQueryBuilder; -use Illuminate\Support\Arr; -use LogicException; +use Illuminate\Database\Query\Builder as Query; use Illuminate\Database\Query\Expression; +use Illuminate\Support\Arr; class QueryBuilder extends Builder { - /** - * @var NodeTrait|Model - */ - protected $model; - - /** - * Get node's `lft` and `rgt` values. - * - * @since 2.0 - * - * @param mixed $id - * @param bool $required - * - * @return array - */ - public function getNodeData($id, $required = false) - { - $query = $this->toBase(); - - $query->where($this->model->getKeyName(), '=', $id); - - $data = $query->first([ $this->model->getLftName(), - $this->model->getRgtName() ]); - - if ( ! $data && $required) { - throw new ModelNotFoundException; - } - - return (array)$data; - } - - /** - * Get plain node data. - * - * @since 2.0 - * - * @param mixed $id - * @param bool $required - * - * @return array - */ - public function getPlainNodeData($id, $required = false) - { - return array_values($this->getNodeData($id, $required)); - } - - /** - * Scope limits query to select just root node. - * - * @return $this - */ - public function whereIsRoot() - { - $this->query->whereNull($this->model->getParentIdName()); - - return $this; - } - - /** - * Limit results to ancestors of specified node. - * - * @since 2.0 - * - * @param mixed $id - * @param bool $andSelf - * - * @param string $boolean - * - * @return $this - */ - public function whereAncestorOf($id, $andSelf = false, $boolean = 'and') - { - $keyName = $this->model->getTable() . '.' . $this->model->getKeyName(); - - if (NestedSet::isNode($id)) { - $value = '?'; - - $this->query->addBinding($id->getRgt()); - - $id = $id->getKey(); - } else { - $valueQuery = $this->model - ->newQuery() - ->toBase() - ->select("_.".$this->model->getRgtName()) - ->from($this->model->getTable().' as _') - ->where($this->model->getKeyName(), '=', $id) - ->limit(1); - - $this->query->mergeBindings($valueQuery); - - $value = '('.$valueQuery->toSql().')'; - } - - $this->query->whereNested(function ($inner) use ($value, $andSelf, $id, $keyName) { - list($lft, $rgt) = $this->wrappedColumns(); - $wrappedTable = $this->query->getGrammar()->wrapTable($this->model->getTable()); - - $inner->whereRaw("{$value} between {$wrappedTable}.{$lft} and {$wrappedTable}.{$rgt}"); - - if ( ! $andSelf) { - $inner->where($keyName, '<>', $id); - } - }, $boolean); - - - return $this; - } - - /** - * @param $id - * @param bool $andSelf - * - * @return $this - */ - public function orWhereAncestorOf($id, $andSelf = false) - { - return $this->whereAncestorOf($id, $andSelf, 'or'); - } - - /** - * @param $id - * - * @return QueryBuilder - */ - public function whereAncestorOrSelf($id) - { - return $this->whereAncestorOf($id, true); - } - - /** - * Get ancestors of specified node. - * - * @since 2.0 - * - * @param mixed $id - * @param array $columns - * - * @return \Kalnoy\Nestedset\Collection - */ - public function ancestorsOf($id, array $columns = array( '*' )) - { - return $this->whereAncestorOf($id)->get($columns); - } - - /** - * @param $id - * @param array $columns - * - * @return \Kalnoy\Nestedset\Collection - */ - public function ancestorsAndSelf($id, array $columns = [ '*' ]) - { - return $this->whereAncestorOf($id, true)->get($columns); - } - - /** - * Add node selection statement between specified range. - * - * @since 2.0 - * - * @param array $values - * @param string $boolean - * @param bool $not - * - * @return $this - */ - public function whereNodeBetween($values, $boolean = 'and', $not = false) - { - $this->query->whereBetween($this->model->getTable() . '.' . $this->model->getLftName(), $values, $boolean, $not); - - return $this; - } - - /** - * Add node selection statement between specified range joined with `or` operator. - * - * @since 2.0 - * - * @param array $values - * - * @return $this - */ - public function orWhereNodeBetween($values) - { - return $this->whereNodeBetween($values, 'or'); - } - - /** - * Add constraint statement to descendants of specified node. - * - * @since 2.0 - * - * @param mixed $id - * @param string $boolean - * @param bool $not - * @param bool $andSelf - * - * @return $this - */ - public function whereDescendantOf($id, $boolean = 'and', $not = false, - $andSelf = false - ) { - if (NestedSet::isNode($id)) { - $data = $id->getBounds(); - } else { - $data = $this->model->newNestedSetQuery() - ->getPlainNodeData($id, true); - } - - // Don't include the node - if ( ! $andSelf) { - ++$data[0]; - } - - return $this->whereNodeBetween($data, $boolean, $not); - } - - /** - * @param mixed $id - * - * @return QueryBuilder - */ - public function whereNotDescendantOf($id) - { - return $this->whereDescendantOf($id, 'and', true); - } - - /** - * @param mixed $id - * - * @return QueryBuilder - */ - public function orWhereDescendantOf($id) - { - return $this->whereDescendantOf($id, 'or'); - } - - /** - * @param mixed $id - * - * @return QueryBuilder - */ - public function orWhereNotDescendantOf($id) - { - return $this->whereDescendantOf($id, 'or', true); - } - - /** - * @param $id - * @param string $boolean - * @param bool $not - * - * @return $this - */ - public function whereDescendantOrSelf($id, $boolean = 'and', $not = false) - { - return $this->whereDescendantOf($id, $boolean, $not, true); - } - - /** - * Get descendants of specified node. - * - * @since 2.0 - * - * @param mixed $id - * @param array $columns - * @param bool $andSelf - * - * @return Collection - */ - public function descendantsOf($id, array $columns = [ '*' ], $andSelf = false) - { - try { - return $this->whereDescendantOf($id, 'and', false, $andSelf)->get($columns); - } - - catch (ModelNotFoundException $e) { - return $this->model->newCollection(); - } - } - - /** - * @param $id - * @param array $columns - * - * @return Collection - */ - public function descendantsAndSelf($id, array $columns = [ '*' ]) - { - return $this->descendantsOf($id, $columns, true); - } - - /** - * @param $id - * @param $operator - * @param $boolean - * - * @return $this - */ - protected function whereIsBeforeOrAfter($id, $operator, $boolean) - { - if (NestedSet::isNode($id)) { - $value = '?'; - - $this->query->addBinding($id->getLft()); - } else { - $valueQuery = $this->model - ->newQuery() - ->toBase() - ->select('_n.'.$this->model->getLftName()) - ->from($this->model->getTable().' as _n') - ->where('_n.'.$this->model->getKeyName(), '=', $id); - - $this->query->mergeBindings($valueQuery); - - $value = '('.$valueQuery->toSql().')'; - } - - list($lft,) = $this->wrappedColumns(); - - $this->query->whereRaw("{$lft} {$operator} {$value}", [ ], $boolean); - - return $this; - } - - /** - * Constraint nodes to those that are after specified node. - * - * @since 2.0 - * - * @param mixed $id - * @param string $boolean - * - * @return $this - */ - public function whereIsAfter($id, $boolean = 'and') - { - return $this->whereIsBeforeOrAfter($id, '>', $boolean); - } - - /** - * Constraint nodes to those that are before specified node. - * - * @since 2.0 - * - * @param mixed $id - * @param string $boolean - * - * @return $this - */ - public function whereIsBefore($id, $boolean = 'and') - { - return $this->whereIsBeforeOrAfter($id, '<', $boolean); - } - - /** - * @return $this - */ - public function whereIsLeaf() - { - list($lft, $rgt) = $this->wrappedColumns(); - - return $this->whereRaw("$lft = $rgt - 1"); - } - - /** - * @param array $columns - * - * @return Collection - */ - public function leaves(array $columns = [ '*']) - { - return $this->whereIsLeaf()->get($columns); - } - - /** - * Include depth level into the result. - * - * @param string $as - * - * @return $this - */ - public function withDepth($as = 'depth') - { - if ($this->query->columns === null) $this->query->columns = [ '*' ]; - - $table = $this->wrappedTable(); - - list($lft, $rgt) = $this->wrappedColumns(); - - $alias = '_d'; - $wrappedAlias = $this->query->getGrammar()->wrapTable($alias); - - $query = $this->model - ->newScopedQuery('_d') - ->toBase() - ->selectRaw('count(1) - 1') - ->from($this->model->getTable().' as '.$alias) - ->whereRaw("{$table}.{$lft} between {$wrappedAlias}.{$lft} and {$wrappedAlias}.{$rgt}"); - - $this->query->selectSub($query, $as); - - return $this; - } - - /** - * Get wrapped `lft` and `rgt` column names. - * - * @since 2.0 - * - * @return array - */ - protected function wrappedColumns() - { - $grammar = $this->query->getGrammar(); - - return [ - $grammar->wrap($this->model->getLftName()), - $grammar->wrap($this->model->getRgtName()), - ]; - } - - /** - * Get a wrapped table name. - * - * @since 2.0 - * - * @return string - */ - protected function wrappedTable() - { - return $this->query->getGrammar()->wrapTable($this->getQuery()->from); - } - - /** - * Wrap model's key name. - * - * @since 2.0 - * - * @return string - */ - protected function wrappedKey() - { - return $this->query->getGrammar()->wrap($this->model->getKeyName()); - } - - /** - * Exclude root node from the result. - * - * @return $this - */ - public function withoutRoot() - { - $this->query->whereNotNull($this->model->getParentIdName()); - - return $this; - } - - /** - * Equivalent of `withoutRoot`. - * - * @since 2.0 - * @deprecated since v4.1 - * - * @return $this - */ - public function hasParent() - { - $this->query->whereNotNull($this->model->getParentIdName()); - - return $this; - } - - /** - * Get only nodes that have children. - * - * @since 2.0 - * @deprecated since v4.1 - * - * @return $this - */ - public function hasChildren() - { - list($lft, $rgt) = $this->wrappedColumns(); - - $this->query->whereRaw("{$rgt} > {$lft} + 1"); - - return $this; - } - - /** - * Order by node position. - * - * @param string $dir - * - * @return $this - */ - public function defaultOrder($dir = 'asc') - { - $this->query->orders = null; - - $this->query->orderBy($this->model->getLftName(), $dir); - - return $this; - } - - /** - * Order by reversed node position. - * - * @return $this - */ - public function reversed() - { - return $this->defaultOrder('desc'); - } - - /** - * Move a node to the new position. - * - * @param mixed $key - * @param int $position - * - * @return int - */ - public function moveNode($key, $position) - { - list($lft, $rgt) = $this->model->newNestedSetQuery() - ->getPlainNodeData($key, true); - - if ($lft < $position && $position <= $rgt) { - throw new LogicException('Cannot move node into itself.'); - } - - // Get boundaries of nodes that should be moved to new position - $from = min($lft, $position); - $to = max($rgt, $position - 1); - - // The height of node that is being moved - $height = $rgt - $lft + 1; - - // The distance that our node will travel to reach it's destination - $distance = $to - $from + 1 - $height; - - // If no distance to travel, just return - if ($distance === 0) { - return 0; - } - - if ($position > $lft) { - $height *= -1; - } else { - $distance *= -1; - } - - $params = compact('lft', 'rgt', 'from', 'to', 'height', 'distance'); - - $boundary = [ $from, $to ]; - - $query = $this->toBase()->where(function (Query $inner) use ($boundary) { - $inner->whereBetween($this->model->getLftName(), $boundary); - $inner->orWhereBetween($this->model->getRgtName(), $boundary); - }); - - return $query->update($this->patch($params)); - } - - /** - * Make or remove gap in the tree. Negative height will remove gap. - * - * @since 2.0 - * - * @param int $cut - * @param int $height - * - * @return int - */ - public function makeGap($cut, $height) - { - $params = compact('cut', 'height'); - - $query = $this->toBase()->whereNested(function (Query $inner) use ($cut) { - $inner->where($this->model->getLftName(), '>=', $cut); - $inner->orWhere($this->model->getRgtName(), '>=', $cut); - }); - - return $query->update($this->patch($params)); - } - - /** - * Get patch for columns. - * - * @since 2.0 - * - * @param array $params - * - * @return array - */ - protected function patch(array $params) - { - $grammar = $this->query->getGrammar(); - - $columns = []; - - foreach ([ $this->model->getLftName(), $this->model->getRgtName() ] as $col) { - $columns[$col] = $this->columnPatch($grammar->wrap($col), $params); - } - - return $columns; - } - - /** - * Get patch for single column. - * - * @since 2.0 - * - * @param string $col - * @param array $params - * - * @return string - */ - protected function columnPatch($col, array $params) - { - extract($params); - - /** @var int $height */ - if ($height > 0) $height = '+'.$height; - - if (isset($cut)) { - return new Expression("case when {$col} >= {$cut} then {$col}{$height} else {$col} end"); - } - - /** @var int $distance */ - /** @var int $lft */ - /** @var int $rgt */ - /** @var int $from */ - /** @var int $to */ - if ($distance > 0) $distance = '+'.$distance; - - return new Expression("case ". - "when {$col} between {$lft} and {$rgt} then {$col}{$distance} ". // Move the node - "when {$col} between {$from} and {$to} then {$col}{$height} ". // Move other nodes - "else {$col} end" - ); - } - - /** - * Get statistics of errors of the tree. - * - * @since 2.0 - * - * @return array - */ - public function countErrors() - { - $checks = []; - - // Check if lft and rgt values are ok - $checks['oddness'] = $this->getOdnessQuery(); - - // Check if lft and rgt values are unique - $checks['duplicates'] = $this->getDuplicatesQuery(); - - // Check if parent_id is set correctly - $checks['wrong_parent'] = $this->getWrongParentQuery(); - - // Check for nodes that have missing parent - $checks['missing_parent' ] = $this->getMissingParentQuery(); - - $query = $this->query->newQuery(); - - foreach ($checks as $key => $inner) { - $inner->selectRaw('count(1)'); - - $query->selectSub($inner, $key); - } - - return (array)$query->first(); - } - - /** - * @return BaseQueryBuilder - */ - protected function getOdnessQuery() - { - return $this->model - ->newNestedSetQuery() - ->toBase() - ->whereNested(function (BaseQueryBuilder $inner) { - list($lft, $rgt) = $this->wrappedColumns(); - - $inner->whereRaw("{$lft} >= {$rgt}") - ->orWhereRaw("({$rgt} - {$lft}) % 2 = 0"); - }); - } - - /** - * @return BaseQueryBuilder - */ - protected function getDuplicatesQuery() - { - $table = $this->wrappedTable(); - $keyName = $this->wrappedKey(); - - $firstAlias = 'c1'; - $secondAlias = 'c2'; - - $waFirst = $this->query->getGrammar()->wrapTable($firstAlias); - $waSecond = $this->query->getGrammar()->wrapTable($secondAlias); - - $query = $this->model - ->newNestedSetQuery($firstAlias) - ->toBase() - ->from($this->query->raw("{$table} as {$waFirst}, {$table} {$waSecond}")) - ->whereRaw("{$waFirst}.{$keyName} < {$waSecond}.{$keyName}") - ->whereNested(function (BaseQueryBuilder $inner) use ($waFirst, $waSecond) { - list($lft, $rgt) = $this->wrappedColumns(); - - $inner->orWhereRaw("{$waFirst}.{$lft}={$waSecond}.{$lft}") - ->orWhereRaw("{$waFirst}.{$rgt}={$waSecond}.{$rgt}") - ->orWhereRaw("{$waFirst}.{$lft}={$waSecond}.{$rgt}") - ->orWhereRaw("{$waFirst}.{$rgt}={$waSecond}.{$lft}"); - }); - - return $this->model->applyNestedSetScope($query, $secondAlias); - } - - /** - * @return BaseQueryBuilder - */ - protected function getWrongParentQuery() - { - $table = $this->wrappedTable(); - $keyName = $this->wrappedKey(); - - $grammar = $this->query->getGrammar(); - - $parentIdName = $grammar->wrap($this->model->getParentIdName()); - - $parentAlias = 'p'; - $childAlias = 'c'; - $intermAlias = 'i'; - - $waParent = $grammar->wrapTable($parentAlias); - $waChild = $grammar->wrapTable($childAlias); - $waInterm = $grammar->wrapTable($intermAlias); - - $query = $this->model - ->newNestedSetQuery('c') - ->toBase() - ->from($this->query->raw("{$table} as {$waChild}, {$table} as {$waParent}, $table as {$waInterm}")) - ->whereRaw("{$waChild}.{$parentIdName}={$waParent}.{$keyName}") - ->whereRaw("{$waInterm}.{$keyName} <> {$waParent}.{$keyName}") - ->whereRaw("{$waInterm}.{$keyName} <> {$waChild}.{$keyName}") - ->whereNested(function (BaseQueryBuilder $inner) use ($waInterm, $waChild, $waParent) { - list($lft, $rgt) = $this->wrappedColumns(); - - $inner->whereRaw("{$waChild}.{$lft} not between {$waParent}.{$lft} and {$waParent}.{$rgt}") - ->orWhereRaw("{$waChild}.{$lft} between {$waInterm}.{$lft} and {$waInterm}.{$rgt}") - ->whereRaw("{$waInterm}.{$lft} between {$waParent}.{$lft} and {$waParent}.{$rgt}"); - }); - - $this->model->applyNestedSetScope($query, $parentAlias); - $this->model->applyNestedSetScope($query, $intermAlias); - - return $query; - } - - /** - * @return $this - */ - protected function getMissingParentQuery() - { - return $this->model - ->newNestedSetQuery() - ->toBase() - ->whereNested(function (BaseQueryBuilder $inner) { - $grammar = $this->query->getGrammar(); - - $table = $this->wrappedTable(); - $keyName = $this->wrappedKey(); - $parentIdName = $grammar->wrap($this->model->getParentIdName()); - $alias = 'p'; - $wrappedAlias = $grammar->wrapTable($alias); - - $existsCheck = $this->model - ->newNestedSetQuery() - ->toBase() - ->selectRaw('1') - ->from($this->query->raw("{$table} as {$wrappedAlias}")) - ->whereRaw("{$table}.{$parentIdName} = {$wrappedAlias}.{$keyName}") - ->limit(1); - - $this->model->applyNestedSetScope($existsCheck, $alias); - - $inner->whereRaw("{$parentIdName} is not null") - ->addWhereExistsQuery($existsCheck, 'and', true); - }); - } - - /** - * Get the number of total errors of the tree. - * - * @since 2.0 - * - * @return int - */ - public function getTotalErrors() - { - return array_sum($this->countErrors()); - } - - /** - * Get whether the tree is broken. - * - * @since 2.0 - * - * @return bool - */ - public function isBroken() - { - return $this->getTotalErrors() > 0; - } - - /** - * Fixes the tree based on parentage info. - * - * Nodes with invalid parent are saved as roots. - * - * @param null|NodeTrait|Model $root - * - * @return int The number of changed nodes - */ - public function fixTree($root = null) - { - $columns = [ - $this->model->getKeyName(), - $this->model->getParentIdName(), - $this->model->getLftName(), - $this->model->getRgtName(), - ]; - - $dictionary = $this->model - ->newNestedSetQuery() - ->when($root, function (self $query) use ($root) { - return $query->whereDescendantOf($root); - }) - ->defaultOrder() - ->get($columns) - ->groupBy($this->model->getParentIdName()) - ->all(); - - return $this->fixNodes($dictionary, $root); - } - - /** - * @param NodeTrait|Model $root - * - * @return int - */ - public function fixSubtree($root) - { - return $this->fixTree($root); - } - - /** - * @param array $dictionary - * @param NodeTrait|Model|null $parent - * - * @return int - */ - protected function fixNodes(array &$dictionary, $parent = null) - { - $parentId = $parent ? $parent->getKey() : null; - $cut = $parent ? $parent->getLft() + 1 : 1; - - $updated = []; - $moved = 0; - - $cut = self::reorderNodes($dictionary, $updated, $parentId, $cut); - - // Save nodes that have invalid parent as roots - while ( ! empty($dictionary)) { - $dictionary[null] = reset($dictionary); - - unset($dictionary[key($dictionary)]); - - $cut = self::reorderNodes($dictionary, $updated, $parentId, $cut); - } - - if ($parent && ($grown = $cut - $parent->getRgt()) != 0) { - $moved = $this->model->newScopedQuery()->makeGap($parent->getRgt() + 1, $grown); - - $updated[] = $parent->rawNode($parent->getLft(), $cut, $parent->getParentId()); - } - - foreach ($updated as $model) { - $model->save(); - } - - return count($updated) + $moved; - } - - /** - * @param array $dictionary - * @param array $updated - * @param $parentId - * @param int $cut - * - * @return int - * @internal param int $fixed - */ - protected static function reorderNodes( - array &$dictionary, array &$updated, $parentId = null, $cut = 1 - ) { - if ( ! isset($dictionary[$parentId])) { - return $cut; - } - - /** @var Model|NodeTrait $model */ - foreach ($dictionary[$parentId] as $model) { - $lft = $cut; - - $cut = self::reorderNodes($dictionary, $updated, $model->getKey(), $cut + 1); - - if ($model->rawNode($lft, $cut, $parentId)->isDirty()) { - $updated[] = $model; - } - - ++$cut; - } - - unset($dictionary[$parentId]); - - return $cut; - } - - /** - * Rebuild the tree based on raw data. - * - * If item data does not contain primary key, new node will be created. - * - * @param array $data - * @param bool $delete Whether to delete nodes that exists but not in the data - * array - * @param null $root - * - * @return int - */ - public function rebuildTree(array $data, $delete = false, $root = null) - { - if ($this->model->usesSoftDelete()) { - $this->withTrashed(); - } - - $existing = $this - ->when($root, function (self $query) use ($root) { - return $query->whereDescendantOf($root); - }) - ->get() - ->getDictionary(); - - $dictionary = []; - $parentId = $root ? $root->getKey() : null; - - $this->buildRebuildDictionary($dictionary, $data, $existing, $parentId); - - /** @var Model|NodeTrait $model */ - if ( ! empty($existing)) { - if ($delete && ! $this->model->usesSoftDelete()) { - $this->model - ->newScopedQuery() - ->whereIn($this->model->getKeyName(), array_keys($existing)) - ->delete(); - } else { - foreach ($existing as $model) { - $dictionary[$model->getParentId()][] = $model; - - if ($delete && $this->model->usesSoftDelete() && - ! $model->{$model->getDeletedAtColumn()} - ) { - $time = $this->model->fromDateTime($this->model->freshTimestamp()); - - $model->{$model->getDeletedAtColumn()} = $time; - } - } - } - } - - return $this->fixNodes($dictionary, $root); - } - - /** - * @param $root - * @param array $data - * @param bool $delete - * - * @return int - */ - public function rebuildSubtree($root, array $data, $delete = false) - { - return $this->rebuildTree($data, $delete, $root); - } - - /** - * @param array $dictionary - * @param array $data - * @param array $existing - * @param mixed $parentId - */ - protected function buildRebuildDictionary(array &$dictionary, - array $data, - array &$existing, - $parentId = null - ) { - $keyName = $this->model->getKeyName(); - - foreach ($data as $itemData) { - /** @var NodeTrait|Model $model */ - - if ( ! isset($itemData[$keyName])) { - $model = $this->model->newInstance($this->model->getAttributes()); - - // Set some values that will be fixed later - $model->rawNode(0, 0, $parentId); - } else { - if ( ! isset($existing[$key = $itemData[$keyName]])) { - throw new ModelNotFoundException; - } - - $model = $existing[$key]; - - // Disable any tree actions - $model->rawNode($model->getLft(), $model->getRgt(), $parentId); - - unset($existing[$key]); - } - - $model->fill(Arr::except($itemData, 'children'))->save(); - - $dictionary[$parentId][] = $model; - - if ( ! isset($itemData['children'])) continue; - - $this->buildRebuildDictionary($dictionary, - $itemData['children'], - $existing, - $model->getKey()); - } - } - - /** - * @param string|null $table - * - * @return $this - */ - public function applyNestedSetScope($table = null) - { - return $this->model->applyNestedSetScope($this, $table); - } - - /** - * Get the root node. - * - * @param array $columns - * - * @return self - */ - public function root(array $columns = ['*']) - { - return $this->whereIsRoot()->first($columns); - } + /** + * @var NodeTrait|Model + */ + protected $model; + + /** + * Get node's `lft` and `rgt` values. + * + * @since 2.0 + * + * @param mixed $id + * @param bool $required + * + * @return array + */ + public function getNodeData($id, $required = false) + { + $query = $this->toBase(); + + $query->where($this->model->getKeyName(), '=', $id); + + $data = $query->first([$this->model->getLftName(), + $this->model->getRgtName(), ]); + + if (!$data && $required) { + throw new ModelNotFoundException(); + } + + return (array) $data; + } + + /** + * Get plain node data. + * + * @since 2.0 + * + * @param mixed $id + * @param bool $required + * + * @return array + */ + public function getPlainNodeData($id, $required = false) + { + return array_values($this->getNodeData($id, $required)); + } + + /** + * Scope limits query to select just root node. + * + * @return $this + */ + public function whereIsRoot() + { + $this->query->whereNull($this->model->getParentIdName()); + + return $this; + } + + /** + * Limit results to ancestors of specified node. + * + * @since 2.0 + * + * @param mixed $id + * @param bool $andSelf + * @param string $boolean + * + * @return $this + */ + public function whereAncestorOf($id, $andSelf = false, $boolean = 'and') + { + $keyName = $this->model->getTable() . '.' . $this->model->getKeyName(); + + if (NestedSet::isNode($id)) { + $value = '?'; + + $this->query->addBinding($id->getRgt()); + + $id = $id->getKey(); + } else { + $valueQuery = $this->model + ->newQuery() + ->toBase() + ->select('_.' . $this->model->getRgtName()) + ->from($this->model->getTable() . ' as _') + ->where($this->model->getKeyName(), '=', $id) + ->limit(1); + + $this->query->mergeBindings($valueQuery); + + $value = '(' . $valueQuery->toSql() . ')'; + } + + $this->query->whereNested(function ($inner) use ($value, $andSelf, $id, $keyName) { + list($lft, $rgt) = $this->wrappedColumns(); + $wrappedTable = $this->query->getGrammar()->wrapTable($this->model->getTable()); + + $inner->whereRaw("{$value} between {$wrappedTable}.{$lft} and {$wrappedTable}.{$rgt}"); + + if (!$andSelf) { + $inner->where($keyName, '<>', $id); + } + }, $boolean); + + return $this; + } + + /** + * @param $id + * @param bool $andSelf + * + * @return $this + */ + public function orWhereAncestorOf($id, $andSelf = false) + { + return $this->whereAncestorOf($id, $andSelf, 'or'); + } + + /** + * @param $id + * + * @return QueryBuilder + */ + public function whereAncestorOrSelf($id) + { + return $this->whereAncestorOf($id, true); + } + + /** + * Get ancestors of specified node. + * + * @since 2.0 + * + * @param mixed $id + * @param array $columns + * + * @return \Kalnoy\Nestedset\Collection + */ + public function ancestorsOf($id, array $columns = ['*']) + { + return $this->whereAncestorOf($id)->get($columns); + } + + /** + * @param $id + * @param array $columns + * + * @return \Kalnoy\Nestedset\Collection + */ + public function ancestorsAndSelf($id, array $columns = ['*']) + { + return $this->whereAncestorOf($id, true)->get($columns); + } + + /** + * Add node selection statement between specified range. + * + * @since 2.0 + * + * @param array $values + * @param string $boolean + * @param bool $not + * + * @return $this + */ + public function whereNodeBetween($values, $boolean = 'and', $not = false) + { + $this->query->whereBetween($this->model->getTable() . '.' . $this->model->getLftName(), $values, $boolean, $not); + + return $this; + } + + /** + * Add node selection statement between specified range joined with `or` operator. + * + * @since 2.0 + * + * @param array $values + * + * @return $this + */ + public function orWhereNodeBetween($values) + { + return $this->whereNodeBetween($values, 'or'); + } + + /** + * Add constraint statement to descendants of specified node. + * + * @since 2.0 + * + * @param mixed $id + * @param string $boolean + * @param bool $not + * @param bool $andSelf + * + * @return $this + */ + public function whereDescendantOf($id, $boolean = 'and', $not = false, + $andSelf = false + ) { + if (NestedSet::isNode($id)) { + $data = $id->getBounds(); + } else { + $data = $this->model->newNestedSetQuery() + ->getPlainNodeData($id, true); + } + + // Don't include the node + if (!$andSelf) { + $data[0]++; + } + + return $this->whereNodeBetween($data, $boolean, $not); + } + + /** + * @param mixed $id + * + * @return QueryBuilder + */ + public function whereNotDescendantOf($id) + { + return $this->whereDescendantOf($id, 'and', true); + } + + /** + * @param mixed $id + * + * @return QueryBuilder + */ + public function orWhereDescendantOf($id) + { + return $this->whereDescendantOf($id, 'or'); + } + + /** + * @param mixed $id + * + * @return QueryBuilder + */ + public function orWhereNotDescendantOf($id) + { + return $this->whereDescendantOf($id, 'or', true); + } + + /** + * @param $id + * @param string $boolean + * @param bool $not + * + * @return $this + */ + public function whereDescendantOrSelf($id, $boolean = 'and', $not = false) + { + return $this->whereDescendantOf($id, $boolean, $not, true); + } + + /** + * Get descendants of specified node. + * + * @since 2.0 + * + * @param mixed $id + * @param array $columns + * @param bool $andSelf + * + * @return Collection + */ + public function descendantsOf($id, array $columns = ['*'], $andSelf = false) + { + try { + return $this->whereDescendantOf($id, 'and', false, $andSelf)->get($columns); + } catch (ModelNotFoundException $e) { + return $this->model->newCollection(); + } + } + + /** + * @param $id + * @param array $columns + * + * @return Collection + */ + public function descendantsAndSelf($id, array $columns = ['*']) + { + return $this->descendantsOf($id, $columns, true); + } + + /** + * @param $id + * @param $operator + * @param $boolean + * + * @return $this + */ + protected function whereIsBeforeOrAfter($id, $operator, $boolean) + { + if (NestedSet::isNode($id)) { + $value = '?'; + + $this->query->addBinding($id->getLft()); + } else { + $valueQuery = $this->model + ->newQuery() + ->toBase() + ->select('_n.' . $this->model->getLftName()) + ->from($this->model->getTable() . ' as _n') + ->where('_n.' . $this->model->getKeyName(), '=', $id); + + $this->query->mergeBindings($valueQuery); + + $value = '(' . $valueQuery->toSql() . ')'; + } + + list($lft) = $this->wrappedColumns(); + + $this->query->whereRaw("{$lft} {$operator} {$value}", [], $boolean); + + return $this; + } + + /** + * Constraint nodes to those that are after specified node. + * + * @since 2.0 + * + * @param mixed $id + * @param string $boolean + * + * @return $this + */ + public function whereIsAfter($id, $boolean = 'and') + { + return $this->whereIsBeforeOrAfter($id, '>', $boolean); + } + + /** + * Constraint nodes to those that are before specified node. + * + * @since 2.0 + * + * @param mixed $id + * @param string $boolean + * + * @return $this + */ + public function whereIsBefore($id, $boolean = 'and') + { + return $this->whereIsBeforeOrAfter($id, '<', $boolean); + } + + /** + * @return $this + */ + public function whereIsLeaf() + { + list($lft, $rgt) = $this->wrappedColumns(); + + return $this->whereRaw("$lft = $rgt - 1"); + } + + /** + * @param array $columns + * + * @return Collection + */ + public function leaves(array $columns = ['*']) + { + return $this->whereIsLeaf()->get($columns); + } + + /** + * Include depth level into the result. + * + * @param string $as + * + * @return $this + */ + public function withDepth($as = 'depth') + { + if ($this->query->columns === null) { + $this->query->columns = ['*']; + } + + $table = $this->wrappedTable(); + + list($lft, $rgt) = $this->wrappedColumns(); + + $alias = '_d'; + $wrappedAlias = $this->query->getGrammar()->wrapTable($alias); + + $query = $this->model + ->newScopedQuery('_d') + ->toBase() + ->selectRaw('count(1) - 1') + ->from($this->model->getTable() . ' as ' . $alias) + ->whereRaw("{$table}.{$lft} between {$wrappedAlias}.{$lft} and {$wrappedAlias}.{$rgt}"); + + $this->query->selectSub($query, $as); + + return $this; + } + + /** + * Get wrapped `lft` and `rgt` column names. + * + * @since 2.0 + * + * @return array + */ + protected function wrappedColumns() + { + $grammar = $this->query->getGrammar(); + + return [ + $grammar->wrap($this->model->getLftName()), + $grammar->wrap($this->model->getRgtName()), + ]; + } + + /** + * Get a wrapped table name. + * + * @since 2.0 + * + * @return string + */ + protected function wrappedTable() + { + return $this->query->getGrammar()->wrapTable($this->getQuery()->from); + } + + /** + * Wrap model's key name. + * + * @since 2.0 + * + * @return string + */ + protected function wrappedKey() + { + return $this->query->getGrammar()->wrap($this->model->getKeyName()); + } + + /** + * Exclude root node from the result. + * + * @return $this + */ + public function withoutRoot() + { + $this->query->whereNotNull($this->model->getParentIdName()); + + return $this; + } + + /** + * Equivalent of `withoutRoot`. + * + * @since 2.0 + * @deprecated since v4.1 + * + * @return $this + */ + public function hasParent() + { + $this->query->whereNotNull($this->model->getParentIdName()); + + return $this; + } + + /** + * Get only nodes that have children. + * + * @since 2.0 + * @deprecated since v4.1 + * + * @return $this + */ + public function hasChildren() + { + list($lft, $rgt) = $this->wrappedColumns(); + + $this->query->whereRaw("{$rgt} > {$lft} + 1"); + + return $this; + } + + /** + * Order by node position. + * + * @param string $dir + * + * @return $this + */ + public function defaultOrder($dir = 'asc') + { + $this->query->orders = null; + + $this->query->orderBy($this->model->getLftName(), $dir); + + return $this; + } + + /** + * Order by reversed node position. + * + * @return $this + */ + public function reversed() + { + return $this->defaultOrder('desc'); + } + + /** + * Move a node to the new position. + * + * @param mixed $key + * @param int $position + * + * @return int + */ + public function moveNode($key, $position) + { + list($lft, $rgt) = $this->model->newNestedSetQuery() + ->getPlainNodeData($key, true); + + if ($lft < $position && $position <= $rgt) { + throw new \LogicException('Cannot move node into itself.'); + } + + // Get boundaries of nodes that should be moved to new position + $from = min($lft, $position); + $to = max($rgt, $position - 1); + + // The height of node that is being moved + $height = $rgt - $lft + 1; + + // The distance that our node will travel to reach it's destination + $distance = $to - $from + 1 - $height; + + // If no distance to travel, just return + if ($distance === 0) { + return 0; + } + + if ($position > $lft) { + $height *= -1; + } else { + $distance *= -1; + } + + $params = compact('lft', 'rgt', 'from', 'to', 'height', 'distance'); + + $boundary = [$from, $to]; + + $query = $this->toBase()->where(function (Query $inner) use ($boundary) { + $inner->whereBetween($this->model->getLftName(), $boundary); + $inner->orWhereBetween($this->model->getRgtName(), $boundary); + }); + + return $query->update($this->patch($params)); + } + + /** + * Make or remove gap in the tree. Negative height will remove gap. + * + * @since 2.0 + * + * @param int $cut + * @param int $height + * + * @return int + */ + public function makeGap($cut, $height) + { + $params = compact('cut', 'height'); + + $query = $this->toBase()->whereNested(function (Query $inner) use ($cut) { + $inner->where($this->model->getLftName(), '>=', $cut); + $inner->orWhere($this->model->getRgtName(), '>=', $cut); + }); + + return $query->update($this->patch($params)); + } + + /** + * Get patch for columns. + * + * @since 2.0 + * + * @param array $params + * + * @return array + */ + protected function patch(array $params) + { + $grammar = $this->query->getGrammar(); + + $columns = []; + + foreach ([$this->model->getLftName(), $this->model->getRgtName()] as $col) { + $columns[$col] = $this->columnPatch($grammar->wrap($col), $params); + } + + return $columns; + } + + /** + * Get patch for single column. + * + * @since 2.0 + * + * @param string $col + * @param array $params + * + * @return string + */ + protected function columnPatch($col, array $params) + { + extract($params); + + /** @var int $height */ + if ($height > 0) { + $height = '+' . $height; + } + + if (isset($cut)) { + return new Expression("case when {$col} >= {$cut} then {$col}{$height} else {$col} end"); + } + + /** @var int $distance */ + /** @var int $lft */ + /** @var int $rgt */ + /** @var int $from */ + /** @var int $to */ + if ($distance > 0) { + $distance = '+' . $distance; + } + + return new Expression('case ' . + "when {$col} between {$lft} and {$rgt} then {$col}{$distance} " . // Move the node + "when {$col} between {$from} and {$to} then {$col}{$height} " . // Move other nodes + "else {$col} end" + ); + } + + /** + * Get statistics of errors of the tree. + * + * @since 2.0 + * + * @return array + */ + public function countErrors() + { + $checks = []; + + // Check if lft and rgt values are ok + $checks['oddness'] = $this->getOdnessQuery(); + + // Check if lft and rgt values are unique + $checks['duplicates'] = $this->getDuplicatesQuery(); + + // Check if parent_id is set correctly + $checks['wrong_parent'] = $this->getWrongParentQuery(); + + // Check for nodes that have missing parent + $checks['missing_parent'] = $this->getMissingParentQuery(); + + $query = $this->query->newQuery(); + + foreach ($checks as $key => $inner) { + $inner->selectRaw('count(1)'); + + $query->selectSub($inner, $key); + } + + return (array) $query->first(); + } + + /** + * @return BaseQueryBuilder + */ + protected function getOdnessQuery() + { + return $this->model + ->newNestedSetQuery() + ->toBase() + ->whereNested(function (BaseQueryBuilder $inner) { + list($lft, $rgt) = $this->wrappedColumns(); + + $inner->whereRaw("{$lft} >= {$rgt}") + ->orWhereRaw("({$rgt} - {$lft}) % 2 = 0"); + }); + } + + /** + * @return BaseQueryBuilder + */ + protected function getDuplicatesQuery() + { + $table = $this->wrappedTable(); + $keyName = $this->wrappedKey(); + + $firstAlias = 'c1'; + $secondAlias = 'c2'; + + $waFirst = $this->query->getGrammar()->wrapTable($firstAlias); + $waSecond = $this->query->getGrammar()->wrapTable($secondAlias); + + $query = $this->model + ->newNestedSetQuery($firstAlias) + ->toBase() + ->from($this->query->raw("{$table} as {$waFirst}, {$table} {$waSecond}")) + ->whereRaw("{$waFirst}.{$keyName} < {$waSecond}.{$keyName}") + ->whereNested(function (BaseQueryBuilder $inner) use ($waFirst, $waSecond) { + list($lft, $rgt) = $this->wrappedColumns(); + + $inner->orWhereRaw("{$waFirst}.{$lft}={$waSecond}.{$lft}") + ->orWhereRaw("{$waFirst}.{$rgt}={$waSecond}.{$rgt}") + ->orWhereRaw("{$waFirst}.{$lft}={$waSecond}.{$rgt}") + ->orWhereRaw("{$waFirst}.{$rgt}={$waSecond}.{$lft}"); + }); + + return $this->model->applyNestedSetScope($query, $secondAlias); + } + + /** + * @return BaseQueryBuilder + */ + protected function getWrongParentQuery() + { + $table = $this->wrappedTable(); + $keyName = $this->wrappedKey(); + + $grammar = $this->query->getGrammar(); + + $parentIdName = $grammar->wrap($this->model->getParentIdName()); + + $parentAlias = 'p'; + $childAlias = 'c'; + $intermAlias = 'i'; + + $waParent = $grammar->wrapTable($parentAlias); + $waChild = $grammar->wrapTable($childAlias); + $waInterm = $grammar->wrapTable($intermAlias); + + $query = $this->model + ->newNestedSetQuery('c') + ->toBase() + ->from($this->query->raw("{$table} as {$waChild}, {$table} as {$waParent}, $table as {$waInterm}")) + ->whereRaw("{$waChild}.{$parentIdName}={$waParent}.{$keyName}") + ->whereRaw("{$waInterm}.{$keyName} <> {$waParent}.{$keyName}") + ->whereRaw("{$waInterm}.{$keyName} <> {$waChild}.{$keyName}") + ->whereNested(function (BaseQueryBuilder $inner) use ($waInterm, $waChild, $waParent) { + list($lft, $rgt) = $this->wrappedColumns(); + + $inner->whereRaw("{$waChild}.{$lft} not between {$waParent}.{$lft} and {$waParent}.{$rgt}") + ->orWhereRaw("{$waChild}.{$lft} between {$waInterm}.{$lft} and {$waInterm}.{$rgt}") + ->whereRaw("{$waInterm}.{$lft} between {$waParent}.{$lft} and {$waParent}.{$rgt}"); + }); + + $this->model->applyNestedSetScope($query, $parentAlias); + $this->model->applyNestedSetScope($query, $intermAlias); + + return $query; + } + + /** + * @return $this + */ + protected function getMissingParentQuery() + { + return $this->model + ->newNestedSetQuery() + ->toBase() + ->whereNested(function (BaseQueryBuilder $inner) { + $grammar = $this->query->getGrammar(); + + $table = $this->wrappedTable(); + $keyName = $this->wrappedKey(); + $parentIdName = $grammar->wrap($this->model->getParentIdName()); + $alias = 'p'; + $wrappedAlias = $grammar->wrapTable($alias); + + $existsCheck = $this->model + ->newNestedSetQuery() + ->toBase() + ->selectRaw('1') + ->from($this->query->raw("{$table} as {$wrappedAlias}")) + ->whereRaw("{$table}.{$parentIdName} = {$wrappedAlias}.{$keyName}") + ->limit(1); + + $this->model->applyNestedSetScope($existsCheck, $alias); + + $inner->whereRaw("{$parentIdName} is not null") + ->addWhereExistsQuery($existsCheck, 'and', true); + }); + } + + /** + * Get the number of total errors of the tree. + * + * @since 2.0 + * + * @return int + */ + public function getTotalErrors() + { + return array_sum($this->countErrors()); + } + + /** + * Get whether the tree is broken. + * + * @since 2.0 + * + * @return bool + */ + public function isBroken() + { + return $this->getTotalErrors() > 0; + } + + /** + * Fixes the tree based on parentage info. + * + * Nodes with invalid parent are saved as roots. + * + * @param NodeTrait|Model|null $root + * + * @return int The number of changed nodes + */ + public function fixTree($root = null) + { + $columns = [ + $this->model->getKeyName(), + $this->model->getParentIdName(), + $this->model->getLftName(), + $this->model->getRgtName(), + ]; + + $dictionary = $this->model + ->newNestedSetQuery() + ->when($root, function (self $query) use ($root) { + return $query->whereDescendantOf($root); + }) + ->defaultOrder() + ->get($columns) + ->groupBy($this->model->getParentIdName()) + ->all(); + + return $this->fixNodes($dictionary, $root); + } + + /** + * @param NodeTrait|Model $root + * + * @return int + */ + public function fixSubtree($root) + { + return $this->fixTree($root); + } + + /** + * @param array $dictionary + * @param NodeTrait|Model|null $parent + * + * @return int + */ + protected function fixNodes(array &$dictionary, $parent = null) + { + $parentId = $parent ? $parent->getKey() : null; + $cut = $parent ? $parent->getLft() + 1 : 1; + + $updated = []; + $moved = 0; + + $cut = self::reorderNodes($dictionary, $updated, $parentId, $cut); + + // Save nodes that have invalid parent as roots + while (!empty($dictionary)) { + $dictionary[null] = reset($dictionary); + + unset($dictionary[key($dictionary)]); + + $cut = self::reorderNodes($dictionary, $updated, $parentId, $cut); + } + + if ($parent && ($grown = $cut - $parent->getRgt()) != 0) { + $moved = $this->model->newScopedQuery()->makeGap($parent->getRgt() + 1, $grown); + + $updated[] = $parent->rawNode($parent->getLft(), $cut, $parent->getParentId()); + } + + foreach ($updated as $model) { + $model->save(); + } + + return count($updated) + $moved; + } + + /** + * @param array $dictionary + * @param array $updated + * @param $parentId + * @param int $cut + * + * @return int + * + * @internal param int $fixed + */ + protected static function reorderNodes( + array &$dictionary, array &$updated, $parentId = null, $cut = 1 + ) { + if (!isset($dictionary[$parentId])) { + return $cut; + } + + /** @var Model|NodeTrait $model */ + foreach ($dictionary[$parentId] as $model) { + $lft = $cut; + + $cut = self::reorderNodes($dictionary, $updated, $model->getKey(), $cut + 1); + + if ($model->rawNode($lft, $cut, $parentId)->isDirty()) { + $updated[] = $model; + } + + $cut++; + } + + unset($dictionary[$parentId]); + + return $cut; + } + + /** + * Rebuild the tree based on raw data. + * + * If item data does not contain primary key, new node will be created. + * + * @param array $data + * @param bool $delete Whether to delete nodes that exists but not in the data + * array + * @param null $root + * + * @return int + */ + public function rebuildTree(array $data, $delete = false, $root = null) + { + if ($this->model->usesSoftDelete()) { + $this->withTrashed(); + } + + $existing = $this + ->when($root, function (self $query) use ($root) { + return $query->whereDescendantOf($root); + }) + ->get() + ->getDictionary(); + + $dictionary = []; + $parentId = $root ? $root->getKey() : null; + + $this->buildRebuildDictionary($dictionary, $data, $existing, $parentId); + + /** @var Model|NodeTrait $model */ + if (!empty($existing)) { + if ($delete && !$this->model->usesSoftDelete()) { + $this->model + ->newScopedQuery() + ->whereIn($this->model->getKeyName(), array_keys($existing)) + ->delete(); + } else { + foreach ($existing as $model) { + $dictionary[$model->getParentId()][] = $model; + + if ($delete && $this->model->usesSoftDelete() && + !$model->{$model->getDeletedAtColumn()} + ) { + $time = $this->model->fromDateTime($this->model->freshTimestamp()); + + $model->{$model->getDeletedAtColumn()} = $time; + } + } + } + } + + return $this->fixNodes($dictionary, $root); + } + + /** + * @param $root + * @param array $data + * @param bool $delete + * + * @return int + */ + public function rebuildSubtree($root, array $data, $delete = false) + { + return $this->rebuildTree($data, $delete, $root); + } + + /** + * @param array $dictionary + * @param array $data + * @param array $existing + * @param mixed $parentId + */ + protected function buildRebuildDictionary(array &$dictionary, + array $data, + array &$existing, + $parentId = null + ) { + $keyName = $this->model->getKeyName(); + + foreach ($data as $itemData) { + /** @var NodeTrait|Model $model */ + if (!isset($itemData[$keyName])) { + $model = $this->model->newInstance($this->model->getAttributes()); + + // Set some values that will be fixed later + $model->rawNode(0, 0, $parentId); + } else { + if (!isset($existing[$key = $itemData[$keyName]])) { + throw new ModelNotFoundException(); + } + + $model = $existing[$key]; + + // Disable any tree actions + $model->rawNode($model->getLft(), $model->getRgt(), $parentId); + + unset($existing[$key]); + } + + $model->fill(Arr::except($itemData, 'children'))->save(); + + $dictionary[$parentId][] = $model; + + if (!isset($itemData['children'])) { + continue; + } + + $this->buildRebuildDictionary($dictionary, + $itemData['children'], + $existing, + $model->getKey()); + } + } + + /** + * @param string|null $table + * + * @return $this + */ + public function applyNestedSetScope($table = null) + { + return $this->model->applyNestedSetScope($this, $table); + } + + /** + * Get the root node. + * + * @param array $columns + * + * @return self + */ + public function root(array $columns = ['*']) + { + return $this->whereIsRoot()->first($columns); + } } diff --git a/tests/NodeTest.php b/tests/NodeTest.php index 3b0831a..d9b93ac 100644 --- a/tests/NodeTest.php +++ b/tests/NodeTest.php @@ -5,996 +5,994 @@ class NodeTest extends PHPUnit\Framework\TestCase { - public static function setUpBeforeClass(): void - { - $schema = Capsule::schema(); + public static function setUpBeforeClass(): void + { + $schema = Capsule::schema(); - $schema->dropIfExists('categories'); + $schema->dropIfExists('categories'); - Capsule::disableQueryLog(); + Capsule::disableQueryLog(); - $schema->create('categories', function (\Illuminate\Database\Schema\Blueprint $table) { - $table->increments('id'); - $table->string('name'); - $table->softDeletes(); - NestedSet::columns($table); - }); + $schema->create('categories', function (Illuminate\Database\Schema\Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->softDeletes(); + NestedSet::columns($table); + }); - Capsule::enableQueryLog(); - } + Capsule::enableQueryLog(); + } - public function setUp(): void - { - $data = include __DIR__.'/data/categories.php'; + public function setUp(): void + { + $data = include __DIR__ . '/data/categories.php'; - Capsule::table('categories')->insert($data); + Capsule::table('categories')->insert($data); - Capsule::flushQueryLog(); + Capsule::flushQueryLog(); - Category::resetActionsPerformed(); + Category::resetActionsPerformed(); - date_default_timezone_set('America/Denver'); - } + date_default_timezone_set('America/Denver'); + } - public function tearDown(): void - { - Capsule::table('categories')->truncate(); - } + public function tearDown(): void + { + Capsule::table('categories')->truncate(); + } - // public static function tearDownAfterClass() - // { - // $log = Capsule::getQueryLog(); - // foreach ($log as $item) { - // echo $item['query']." with ".implode(', ', $item['bindings'])."\n"; - // } - // } + // public static function tearDownAfterClass() + // { + // $log = Capsule::getQueryLog(); + // foreach ($log as $item) { + // echo $item['query']." with ".implode(', ', $item['bindings'])."\n"; + // } + // } - public function assertTreeNotBroken($table = 'categories') - { - $checks = array(); + public function assertTreeNotBroken($table = 'categories') + { + $checks = []; - $connection = Capsule::connection(); + $connection = Capsule::connection(); - $table = $connection->getQueryGrammar()->wrapTable($table); + $table = $connection->getQueryGrammar()->wrapTable($table); - // Check if lft and rgt values are ok - $checks[] = "from $table where _lft >= _rgt or (_rgt - _lft) % 2 = 0"; + // Check if lft and rgt values are ok + $checks[] = "from $table where _lft >= _rgt or (_rgt - _lft) % 2 = 0"; - // Check if lft and rgt values are unique - $checks[] = "from $table c1, $table c2 where c1.id <> c2.id and ". - "(c1._lft=c2._lft or c1._rgt=c2._rgt or c1._lft=c2._rgt or c1._rgt=c2._lft)"; + // Check if lft and rgt values are unique + $checks[] = "from $table c1, $table c2 where c1.id <> c2.id and " . + '(c1._lft=c2._lft or c1._rgt=c2._rgt or c1._lft=c2._rgt or c1._rgt=c2._lft)'; - // Check if parent_id is set correctly - $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 ". - "(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)"; + // Check if parent_id is set correctly + $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 " . + '(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)'; - foreach ($checks as $i => $check) { - $checks[$i] = 'select 1 as error '.$check; - } + foreach ($checks as $i => $check) { + $checks[$i] = 'select 1 as error ' . $check; + } - $sql = 'select max(error) as errors from ('.implode(' union ', $checks).') _'; + $sql = 'select max(error) as errors from (' . implode(' union ', $checks) . ') _'; + + $actual = $connection->selectOne($sql); - $actual = $connection->selectOne($sql); + $this->assertEquals(null, $actual->errors, "The tree structure of $table is broken!"); + $actual = (array) Capsule::connection()->selectOne($sql); + + $this->assertEquals(['errors' => null], $actual, "The tree structure of $table is broken!"); + } + + public function dumpTree($items = null) + { + if (!$items) { + $items = Category::withTrashed()->defaultOrder()->get(); + } + + foreach ($items as $item) { + echo PHP_EOL . ($item->trashed() ? '-' : '+') . ' ' . $item->name . ' ' . $item->getKey() . ' ' . $item->getLft() . ' ' . $item->getRgt() . ' ' . $item->getParentId(); + } + } + + public function assertNodeReceivesValidValues($node) + { + $lft = $node->getLft(); + $rgt = $node->getRgt(); + $nodeInDb = $this->findCategory($node->name); + + $this->assertEquals( + [$nodeInDb->getLft(), $nodeInDb->getRgt()], + [$lft, $rgt], + 'Node is not synced with database after save.' + ); + } + + /** + * @param $name + * + * @return \Category + */ + public function findCategory($name, $withTrashed = false) + { + $q = new Category(); + + $q = $withTrashed ? $q->withTrashed() : $q->newQuery(); + + return $q->whereName($name)->first(); + } + + public function testTreeNotBroken() + { + $this->assertTreeNotBroken(); + $this->assertFalse(Category::isBroken()); + } + + public function nodeValues($node) + { + return [$node->_lft, $node->_rgt, $node->parent_id]; + } + + public function testGetsNodeData() + { + $data = Category::getNodeData(3); + + $this->assertEquals(['_lft' => 3, '_rgt' => 4], $data); + } + + public function testGetsPlainNodeData() + { + $data = Category::getPlainNodeData(3); + + $this->assertEquals([3, 4], $data); + } + + public function testReceivesValidValuesWhenAppendedTo() + { + $node = new Category(['name' => 'test']); + $root = Category::root(); + + $accepted = [$root->_rgt, $root->_rgt + 1, $root->id]; + + $root->appendNode($node); + + $this->assertTrue($node->hasMoved()); + $this->assertEquals($accepted, $this->nodeValues($node)); + $this->assertTreeNotBroken(); + $this->assertFalse($node->isDirty()); + $this->assertTrue($node->isDescendantOf($root)); + } + + public function testReceivesValidValuesWhenPrependedTo() + { + $root = Category::root(); + $node = new Category(['name' => 'test']); + $root->prependNode($node); + + $this->assertTrue($node->hasMoved()); + $this->assertEquals([$root->_lft + 1, $root->_lft + 2, $root->id], $this->nodeValues($node)); + $this->assertTreeNotBroken(); + $this->assertTrue($node->isDescendantOf($root)); + $this->assertTrue($root->isAncestorOf($node)); + $this->assertTrue($node->isChildOf($root)); + } + + public function testReceivesValidValuesWhenInsertedAfter() + { + $target = $this->findCategory('apple'); + $node = new Category(['name' => 'test']); + $node->afterNode($target)->save(); + + $this->assertTrue($node->hasMoved()); + $this->assertEquals([$target->_rgt + 1, $target->_rgt + 2, $target->parent->id], $this->nodeValues($node)); + $this->assertTreeNotBroken(); + $this->assertFalse($node->isDirty()); + $this->assertTrue($node->isSiblingOf($target)); + } + + public function testReceivesValidValuesWhenInsertedBefore() + { + $target = $this->findCategory('apple'); + $node = new Category(['name' => 'test']); + $node->beforeNode($target)->save(); + + $this->assertTrue($node->hasMoved()); + $this->assertEquals([$target->_lft, $target->_lft + 1, $target->parent->id], $this->nodeValues($node)); + $this->assertTreeNotBroken(); + } - $this->assertEquals(null, $actual->errors, "The tree structure of $table is broken!"); - $actual = (array)Capsule::connection()->selectOne($sql); + public function testCategoryMovesDown() + { + $node = $this->findCategory('apple'); + $target = $this->findCategory('mobile'); - $this->assertEquals(array('errors' => null), $actual, "The tree structure of $table is broken!"); - } - - public function dumpTree($items = null) - { - if ( ! $items) $items = Category::withTrashed()->defaultOrder()->get(); - - foreach ($items as $item) { - echo PHP_EOL.($item->trashed() ? '-' : '+').' '.$item->name." ".$item->getKey().' '.$item->getLft()." ".$item->getRgt().' '.$item->getParentId(); - } - } - - public function assertNodeReceivesValidValues($node) - { - $lft = $node->getLft(); - $rgt = $node->getRgt(); - $nodeInDb = $this->findCategory($node->name); - - $this->assertEquals( - [ $nodeInDb->getLft(), $nodeInDb->getRgt() ], - [ $lft, $rgt ], - 'Node is not synced with database after save.' - ); - } - - /** - * @param $name - * - * @return \Category - */ - public function findCategory($name, $withTrashed = false) - { - $q = new Category; - - $q = $withTrashed ? $q->withTrashed() : $q->newQuery(); - - return $q->whereName($name)->first(); - } - - public function testTreeNotBroken() - { - $this->assertTreeNotBroken(); - $this->assertFalse(Category::isBroken()); - } - - public function nodeValues($node) - { - return array($node->_lft, $node->_rgt, $node->parent_id); - } - - public function testGetsNodeData() - { - $data = Category::getNodeData(3); - - $this->assertEquals([ '_lft' => 3, '_rgt' => 4 ], $data); - } - - public function testGetsPlainNodeData() - { - $data = Category::getPlainNodeData(3); - - $this->assertEquals([ 3, 4 ], $data); - } - - public function testReceivesValidValuesWhenAppendedTo() - { - $node = new Category([ 'name' => 'test' ]); - $root = Category::root(); - - $accepted = array($root->_rgt, $root->_rgt + 1, $root->id); - - $root->appendNode($node); - - $this->assertTrue($node->hasMoved()); - $this->assertEquals($accepted, $this->nodeValues($node)); - $this->assertTreeNotBroken(); - $this->assertFalse($node->isDirty()); - $this->assertTrue($node->isDescendantOf($root)); - } - - public function testReceivesValidValuesWhenPrependedTo() - { - $root = Category::root(); - $node = new Category([ 'name' => 'test' ]); - $root->prependNode($node); - - $this->assertTrue($node->hasMoved()); - $this->assertEquals(array($root->_lft + 1, $root->_lft + 2, $root->id), $this->nodeValues($node)); - $this->assertTreeNotBroken(); - $this->assertTrue($node->isDescendantOf($root)); - $this->assertTrue($root->isAncestorOf($node)); - $this->assertTrue($node->isChildOf($root)); - } - - public function testReceivesValidValuesWhenInsertedAfter() - { - $target = $this->findCategory('apple'); - $node = new Category([ 'name' => 'test' ]); - $node->afterNode($target)->save(); - - $this->assertTrue($node->hasMoved()); - $this->assertEquals(array($target->_rgt + 1, $target->_rgt + 2, $target->parent->id), $this->nodeValues($node)); - $this->assertTreeNotBroken(); - $this->assertFalse($node->isDirty()); - $this->assertTrue($node->isSiblingOf($target)); - } - - public function testReceivesValidValuesWhenInsertedBefore() - { - $target = $this->findCategory('apple'); - $node = new Category([ 'name' => 'test' ]); - $node->beforeNode($target)->save(); - - $this->assertTrue($node->hasMoved()); - $this->assertEquals(array($target->_lft, $target->_lft + 1, $target->parent->id), $this->nodeValues($node)); - $this->assertTreeNotBroken(); - } + $target->appendNode($node); - public function testCategoryMovesDown() - { - $node = $this->findCategory('apple'); - $target = $this->findCategory('mobile'); + $this->assertTrue($node->hasMoved()); + $this->assertNodeReceivesValidValues($node); + $this->assertTreeNotBroken(); + } - $target->appendNode($node); + public function testCategoryMovesUp() + { + $node = $this->findCategory('samsung'); + $target = $this->findCategory('notebooks'); - $this->assertTrue($node->hasMoved()); - $this->assertNodeReceivesValidValues($node); - $this->assertTreeNotBroken(); - } + $target->appendNode($node); - public function testCategoryMovesUp() - { - $node = $this->findCategory('samsung'); - $target = $this->findCategory('notebooks'); + $this->assertTrue($node->hasMoved()); + $this->assertTreeNotBroken(); + $this->assertNodeReceivesValidValues($node); + } - $target->appendNode($node); + public function testFailsToInsertIntoChild() + { + $this->expectException(Exception::class); - $this->assertTrue($node->hasMoved()); - $this->assertTreeNotBroken(); - $this->assertNodeReceivesValidValues($node); - } + $node = $this->findCategory('notebooks'); + $target = $node->children()->first(); - public function testFailsToInsertIntoChild() - { - $this->expectException(Exception::class); + $node->afterNode($target)->save(); + } - $node = $this->findCategory('notebooks'); - $target = $node->children()->first(); + public function testFailsToAppendIntoItself() + { + $this->expectException(Exception::class); - $node->afterNode($target)->save(); - } + $node = $this->findCategory('notebooks'); - public function testFailsToAppendIntoItself() - { - $this->expectException(Exception::class); + $node->appendToNode($node)->save(); + } - $node = $this->findCategory('notebooks'); + public function testFailsToPrependIntoItself() + { + $this->expectException(Exception::class); - $node->appendToNode($node)->save(); - } + $node = $this->findCategory('notebooks'); - public function testFailsToPrependIntoItself() - { - $this->expectException(Exception::class); + $node->prependTo($node)->save(); + } - $node = $this->findCategory('notebooks'); + public function testWithoutRootWorks() + { + $result = Category::withoutRoot()->pluck('name'); - $node->prependTo($node)->save(); - } + $this->assertNotEquals('store', $result); + } - public function testWithoutRootWorks() - { - $result = Category::withoutRoot()->pluck('name'); + public function testAncestorsReturnsAncestorsWithoutNodeItself() + { + $node = $this->findCategory('apple'); + $path = all($node->ancestors()->pluck('name')); - $this->assertNotEquals('store', $result); - } + $this->assertEquals(['store', 'notebooks'], $path); + } - public function testAncestorsReturnsAncestorsWithoutNodeItself() - { - $node = $this->findCategory('apple'); - $path = all($node->ancestors()->pluck('name')); + public function testGetsAncestorsByStatic() + { + $path = all(Category::ancestorsOf(3)->pluck('name')); - $this->assertEquals(array('store', 'notebooks'), $path); - } + $this->assertEquals(['store', 'notebooks'], $path); + } - public function testGetsAncestorsByStatic() - { - $path = all(Category::ancestorsOf(3)->pluck('name')); + public function testGetsAncestorsDirect() + { + $path = all(Category::find(8)->getAncestors()->pluck('id')); - $this->assertEquals(array('store', 'notebooks'), $path); - } + $this->assertEquals([1, 5, 7], $path); + } - public function testGetsAncestorsDirect() - { - $path = all(Category::find(8)->getAncestors()->pluck('id')); + public function testDescendants() + { + $node = $this->findCategory('mobile'); + $descendants = all($node->descendants()->pluck('name')); + $expected = ['nokia', 'samsung', 'galaxy', 'sony', 'lenovo']; - $this->assertEquals(array(1, 5, 7), $path); - } + $this->assertEquals($expected, $descendants); - public function testDescendants() - { - $node = $this->findCategory('mobile'); - $descendants = all($node->descendants()->pluck('name')); - $expected = array('nokia', 'samsung', 'galaxy', 'sony', 'lenovo'); + $descendants = all($node->getDescendants()->pluck('name')); - $this->assertEquals($expected, $descendants); + $this->assertEquals(count($descendants), $node->getDescendantCount()); + $this->assertEquals($expected, $descendants); - $descendants = all($node->getDescendants()->pluck('name')); + $descendants = all(Category::descendantsAndSelf(7)->pluck('name')); + $expected = ['samsung', 'galaxy']; - $this->assertEquals(count($descendants), $node->getDescendantCount()); - $this->assertEquals($expected, $descendants); + $this->assertEquals($expected, $descendants); + } - $descendants = all(Category::descendantsAndSelf(7)->pluck('name')); - $expected = [ 'samsung', 'galaxy' ]; + public function testWithDepthWorks() + { + $nodes = all(Category::withDepth()->limit(4)->pluck('depth')); - $this->assertEquals($expected, $descendants); - } + $this->assertEquals([0, 1, 2, 2], $nodes); + } - public function testWithDepthWorks() - { - $nodes = all(Category::withDepth()->limit(4)->pluck('depth')); + public function testWithDepthWithCustomKeyWorks() + { + $node = Category::whereIsRoot()->withDepth('level')->first(); - $this->assertEquals(array(0, 1, 2, 2), $nodes); - } + $this->assertTrue(isset($node['level'])); + } - public function testWithDepthWithCustomKeyWorks() - { - $node = Category::whereIsRoot()->withDepth('level')->first(); + public function testWithDepthWorksAlongWithDefaultKeys() + { + $node = Category::withDepth()->first(); - $this->assertTrue(isset($node['level'])); - } + $this->assertTrue(isset($node->name)); + } - public function testWithDepthWorksAlongWithDefaultKeys() - { - $node = Category::withDepth()->first(); + public function testParentIdAttributeAccessorAppendsNode() + { + $node = new Category(['name' => 'lg', 'parent_id' => 5]); + $node->save(); - $this->assertTrue(isset($node->name)); - } + $this->assertEquals(5, $node->parent_id); + $this->assertEquals(5, $node->getParentId()); - public function testParentIdAttributeAccessorAppendsNode() - { - $node = new Category(array('name' => 'lg', 'parent_id' => 5)); - $node->save(); + $node->parent_id = null; + $node->save(); - $this->assertEquals(5, $node->parent_id); - $this->assertEquals(5, $node->getParentId()); + $node->refreshNode(); - $node->parent_id = null; - $node->save(); + $this->assertEquals(null, $node->parent_id); + $this->assertTrue($node->isRoot()); + } - $node->refreshNode(); + public function testFailsToSaveNodeUntilNotInserted() + { + $this->expectException(Exception::class); - $this->assertEquals(null, $node->parent_id); - $this->assertTrue($node->isRoot()); - } + $node = new Category(); + $node->save(); + } - public function testFailsToSaveNodeUntilNotInserted() - { - $this->expectException(Exception::class); + public function testNodeIsDeletedWithDescendants() + { + $node = $this->findCategory('mobile'); + $node->forceDelete(); - $node = new Category; - $node->save(); - } + $this->assertTreeNotBroken(); - public function testNodeIsDeletedWithDescendants() - { - $node = $this->findCategory('mobile'); - $node->forceDelete(); + $nodes = Category::whereIn('id', [5, 6, 7, 8, 9])->count(); + $this->assertEquals(0, $nodes); - $this->assertTreeNotBroken(); + $root = Category::root(); + $this->assertEquals(8, $root->getRgt()); + } - $nodes = Category::whereIn('id', array(5, 6, 7, 8, 9))->count(); - $this->assertEquals(0, $nodes); + public function testNodeIsSoftDeleted() + { + $root = Category::root(); - $root = Category::root(); - $this->assertEquals(8, $root->getRgt()); - } + $samsung = $this->findCategory('samsung'); + $samsung->delete(); - public function testNodeIsSoftDeleted() - { - $root = Category::root(); + $this->assertTreeNotBroken(); - $samsung = $this->findCategory('samsung'); - $samsung->delete(); + $this->assertNull($this->findCategory('galaxy')); - $this->assertTreeNotBroken(); + sleep(1); - $this->assertNull($this->findCategory('galaxy')); + $node = $this->findCategory('mobile'); + $node->delete(); - sleep(1); + $nodes = Category::whereIn('id', [5, 6, 7, 8, 9])->count(); + $this->assertEquals(0, $nodes); - $node = $this->findCategory('mobile'); - $node->delete(); + $originalRgt = $root->getRgt(); + $root->refreshNode(); - $nodes = Category::whereIn('id', array(5, 6, 7, 8, 9))->count(); - $this->assertEquals(0, $nodes); + $this->assertEquals($originalRgt, $root->getRgt()); - $originalRgt = $root->getRgt(); - $root->refreshNode(); + $node = $this->findCategory('mobile', true); - $this->assertEquals($originalRgt, $root->getRgt()); + $node->restore(); - $node = $this->findCategory('mobile', true); + $this->assertNull($this->findCategory('samsung')); + $this->assertNotNull($this->findCategory('nokia')); + } - $node->restore(); + public function testSoftDeletedNodeisDeletedWhenParentIsDeleted() + { + $this->findCategory('samsung')->delete(); - $this->assertNull($this->findCategory('samsung')); - $this->assertNotNull($this->findCategory('nokia')); - } + $this->findCategory('mobile')->forceDelete(); - public function testSoftDeletedNodeisDeletedWhenParentIsDeleted() - { - $this->findCategory('samsung')->delete(); + $this->assertTreeNotBroken(); - $this->findCategory('mobile')->forceDelete(); + $this->assertNull($this->findCategory('samsung', true)); + $this->assertNull($this->findCategory('sony')); + } - $this->assertTreeNotBroken(); + public function testFailsToSaveNodeUntilParentIsSaved() + { + $this->expectException(Exception::class); - $this->assertNull($this->findCategory('samsung', true)); - $this->assertNull($this->findCategory('sony')); - } + $node = new Category(['title' => 'Node']); + $parent = new Category(['title' => 'Parent']); - public function testFailsToSaveNodeUntilParentIsSaved() - { - $this->expectException(Exception::class); + $node->appendTo($parent)->save(); + } - $node = new Category(array('title' => 'Node')); - $parent = new Category(array('title' => 'Parent')); + public function testSiblings() + { + $node = $this->findCategory('samsung'); + $siblings = all($node->siblings()->pluck('id')); + $next = all($node->nextSiblings()->pluck('id')); + $prev = all($node->prevSiblings()->pluck('id')); - $node->appendTo($parent)->save(); - } + $this->assertEquals([6, 9, 10], $siblings); + $this->assertEquals([9, 10], $next); + $this->assertEquals([6], $prev); - public function testSiblings() - { - $node = $this->findCategory('samsung'); - $siblings = all($node->siblings()->pluck('id')); - $next = all($node->nextSiblings()->pluck('id')); - $prev = all($node->prevSiblings()->pluck('id')); + $siblings = all($node->getSiblings()->pluck('id')); + $next = all($node->getNextSiblings()->pluck('id')); + $prev = all($node->getPrevSiblings()->pluck('id')); - $this->assertEquals(array(6, 9, 10), $siblings); - $this->assertEquals(array(9, 10), $next); - $this->assertEquals(array(6), $prev); + $this->assertEquals([6, 9, 10], $siblings); + $this->assertEquals([9, 10], $next); + $this->assertEquals([6], $prev); - $siblings = all($node->getSiblings()->pluck('id')); - $next = all($node->getNextSiblings()->pluck('id')); - $prev = all($node->getPrevSiblings()->pluck('id')); + $next = $node->getNextSibling(); + $prev = $node->getPrevSibling(); - $this->assertEquals(array(6, 9, 10), $siblings); - $this->assertEquals(array(9, 10), $next); - $this->assertEquals(array(6), $prev); + $this->assertEquals(9, $next->id); + $this->assertEquals(6, $prev->id); + } - $next = $node->getNextSibling(); - $prev = $node->getPrevSibling(); + public function testFetchesReversed() + { + $node = $this->findCategory('sony'); + $siblings = $node->prevSiblings()->reversed()->value('id'); - $this->assertEquals(9, $next->id); - $this->assertEquals(6, $prev->id); - } + $this->assertEquals(7, $siblings); + } - public function testFetchesReversed() - { - $node = $this->findCategory('sony'); - $siblings = $node->prevSiblings()->reversed()->value('id'); + public function testToTreeBuildsWithDefaultOrder() + { + $tree = Category::whereBetween('_lft', [8, 17])->defaultOrder()->get()->toTree(); - $this->assertEquals(7, $siblings); - } + $this->assertEquals(1, count($tree)); - public function testToTreeBuildsWithDefaultOrder() - { - $tree = Category::whereBetween('_lft', array(8, 17))->defaultOrder()->get()->toTree(); + $root = $tree->first(); + $this->assertEquals('mobile', $root->name); + $this->assertEquals(4, count($root->children)); + } - $this->assertEquals(1, count($tree)); + public function testToTreeBuildsWithCustomOrder() + { + $tree = Category::whereBetween('_lft', [8, 17]) + ->orderBy('title') + ->get() + ->toTree(); - $root = $tree->first(); - $this->assertEquals('mobile', $root->name); - $this->assertEquals(4, count($root->children)); - } + $this->assertEquals(1, count($tree)); - public function testToTreeBuildsWithCustomOrder() - { - $tree = Category::whereBetween('_lft', array(8, 17)) - ->orderBy('title') - ->get() - ->toTree(); + $root = $tree->first(); + $this->assertEquals('mobile', $root->name); + $this->assertEquals(4, count($root->children)); + $this->assertEquals($root, $root->children->first()->parent); + } - $this->assertEquals(1, count($tree)); + public function testToTreeWithSpecifiedRoot() + { + $node = $this->findCategory('mobile'); + $nodes = Category::whereBetween('_lft', [8, 17])->get(); - $root = $tree->first(); - $this->assertEquals('mobile', $root->name); - $this->assertEquals(4, count($root->children)); - $this->assertEquals($root, $root->children->first()->parent); - } + $tree1 = \Kalnoy\Nestedset\Collection::make($nodes)->toTree(5); + $tree2 = \Kalnoy\Nestedset\Collection::make($nodes)->toTree($node); - public function testToTreeWithSpecifiedRoot() - { - $node = $this->findCategory('mobile'); - $nodes = Category::whereBetween('_lft', array(8, 17))->get(); + $this->assertEquals(4, $tree1->count()); + $this->assertEquals(4, $tree2->count()); + } - $tree1 = \Kalnoy\Nestedset\Collection::make($nodes)->toTree(5); - $tree2 = \Kalnoy\Nestedset\Collection::make($nodes)->toTree($node); + public function testToTreeBuildsWithDefaultOrderAndMultipleRootNodes() + { + $tree = Category::withoutRoot()->get()->toTree(); - $this->assertEquals(4, $tree1->count()); - $this->assertEquals(4, $tree2->count()); - } + $this->assertEquals(2, count($tree)); + } - public function testToTreeBuildsWithDefaultOrderAndMultipleRootNodes() - { - $tree = Category::withoutRoot()->get()->toTree(); + public function testToTreeBuildsWithRootItemIdProvided() + { + $tree = Category::whereBetween('_lft', [8, 17])->get()->toTree(5); - $this->assertEquals(2, count($tree)); - } + $this->assertEquals(4, count($tree)); - public function testToTreeBuildsWithRootItemIdProvided() - { - $tree = Category::whereBetween('_lft', array(8, 17))->get()->toTree(5); + $root = $tree[1]; + $this->assertEquals('samsung', $root->name); + $this->assertEquals(1, count($root->children)); + } - $this->assertEquals(4, count($tree)); + public function testRetrievesNextNode() + { + $node = $this->findCategory('apple'); + $next = $node->nextNodes()->first(); - $root = $tree[1]; - $this->assertEquals('samsung', $root->name); - $this->assertEquals(1, count($root->children)); - } + $this->assertEquals('lenovo', $next->name); + } - public function testRetrievesNextNode() - { - $node = $this->findCategory('apple'); - $next = $node->nextNodes()->first(); + public function testRetrievesPrevNode() + { + $node = $this->findCategory('apple'); + $next = $node->getPrevNode(); - $this->assertEquals('lenovo', $next->name); - } + $this->assertEquals('notebooks', $next->name); + } - public function testRetrievesPrevNode() - { - $node = $this->findCategory('apple'); - $next = $node->getPrevNode(); + public function testMultipleAppendageWorks() + { + $parent = $this->findCategory('mobile'); - $this->assertEquals('notebooks', $next->name); - } + $child = new Category(['name' => 'test']); - public function testMultipleAppendageWorks() - { - $parent = $this->findCategory('mobile'); + $parent->appendNode($child); - $child = new Category([ 'name' => 'test' ]); + $child->appendNode(new Category(['name' => 'sub'])); - $parent->appendNode($child); + $parent->appendNode(new Category(['name' => 'test2'])); - $child->appendNode(new Category([ 'name' => 'sub' ])); + $this->assertTreeNotBroken(); + } - $parent->appendNode(new Category([ 'name' => 'test2' ])); + public function testDefaultCategoryIsSavedAsRoot() + { + $node = new Category(['name' => 'test']); + $node->save(); - $this->assertTreeNotBroken(); - } + $this->assertEquals(23, $node->_lft); + $this->assertTreeNotBroken(); - public function testDefaultCategoryIsSavedAsRoot() - { - $node = new Category([ 'name' => 'test' ]); - $node->save(); + $this->assertTrue($node->isRoot()); + } - $this->assertEquals(23, $node->_lft); - $this->assertTreeNotBroken(); + public function testExistingCategorySavedAsRoot() + { + $node = $this->findCategory('apple'); + $node->saveAsRoot(); - $this->assertTrue($node->isRoot()); - } + $this->assertTreeNotBroken(); + $this->assertTrue($node->isRoot()); + } - public function testExistingCategorySavedAsRoot() - { - $node = $this->findCategory('apple'); - $node->saveAsRoot(); + public function testNodeMovesDownSeveralPositions() + { + $node = $this->findCategory('nokia'); - $this->assertTreeNotBroken(); - $this->assertTrue($node->isRoot()); - } + $this->assertTrue($node->down(2)); - public function testNodeMovesDownSeveralPositions() - { - $node = $this->findCategory('nokia'); + $this->assertEquals($node->_lft, 15); + } - $this->assertTrue($node->down(2)); + public function testNodeMovesUpSeveralPositions() + { + $node = $this->findCategory('sony'); - $this->assertEquals($node->_lft, 15); - } + $this->assertTrue($node->up(2)); - public function testNodeMovesUpSeveralPositions() - { - $node = $this->findCategory('sony'); + $this->assertEquals($node->_lft, 9); + } - $this->assertTrue($node->up(2)); + public function testCountsTreeErrors() + { + $errors = Category::countErrors(); - $this->assertEquals($node->_lft, 9); - } + $this->assertEquals(['oddness' => 0, + 'duplicates' => 0, + 'wrong_parent' => 0, + 'missing_parent' => 0, ], $errors); - public function testCountsTreeErrors() - { - $errors = Category::countErrors(); + Category::where('id', '=', 5)->update(['_lft' => 14]); + Category::where('id', '=', 8)->update(['parent_id' => 2]); + Category::where('id', '=', 11)->update(['_lft' => 20]); + Category::where('id', '=', 4)->update(['parent_id' => 24]); - $this->assertEquals([ 'oddness' => 0, - 'duplicates' => 0, - 'wrong_parent' => 0, - 'missing_parent' => 0 ], $errors); + $errors = Category::countErrors(); - Category::where('id', '=', 5)->update([ '_lft' => 14 ]); - Category::where('id', '=', 8)->update([ 'parent_id' => 2 ]); - Category::where('id', '=', 11)->update([ '_lft' => 20 ]); - Category::where('id', '=', 4)->update([ 'parent_id' => 24 ]); + $this->assertEquals(1, $errors['oddness']); + $this->assertEquals(2, $errors['duplicates']); + $this->assertEquals(1, $errors['missing_parent']); + } - $errors = Category::countErrors(); + public function testCreatesNode() + { + $node = Category::create(['name' => 'test']); - $this->assertEquals(1, $errors['oddness']); - $this->assertEquals(2, $errors['duplicates']); - $this->assertEquals(1, $errors['missing_parent']); - } + $this->assertEquals(23, $node->getLft()); + } - public function testCreatesNode() - { - $node = Category::create([ 'name' => 'test' ]); + public function testCreatesViaRelationship() + { + $node = $this->findCategory('apple'); - $this->assertEquals(23, $node->getLft()); - } + $child = $node->children()->create(['name' => 'test']); - public function testCreatesViaRelationship() - { - $node = $this->findCategory('apple'); + $this->assertTreeNotBroken(); + } - $child = $node->children()->create([ 'name' => 'test' ]); + public function testCreatesTree() + { + $node = Category::create( + [ + 'name' => 'test', + 'children' => [ + ['name' => 'test2'], + ['name' => 'test3'], + ], + ]); - $this->assertTreeNotBroken(); - } + $this->assertTreeNotBroken(); - public function testCreatesTree() - { - $node = Category::create( - [ - 'name' => 'test', - 'children' => - [ - [ 'name' => 'test2' ], - [ 'name' => 'test3' ], - ], - ]); + $this->assertTrue(isset($node->children)); - $this->assertTreeNotBroken(); + $node = $this->findCategory('test'); - $this->assertTrue(isset($node->children)); + $this->assertCount(2, $node->children); + $this->assertEquals('test2', $node->children[0]->name); + } - $node = $this->findCategory('test'); + public function testDescendantsOfNonExistingNode() + { + $node = new Category(); - $this->assertCount(2, $node->children); - $this->assertEquals('test2', $node->children[0]->name); - } + $this->assertTrue($node->getDescendants()->isEmpty()); + } - public function testDescendantsOfNonExistingNode() - { - $node = new Category; + public function testWhereDescendantsOf() + { + $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); - $this->assertTrue($node->getDescendants()->isEmpty()); - } + Category::whereDescendantOf(124)->get(); + } - public function testWhereDescendantsOf() - { - $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + public function testAncestorsByNode() + { + $category = $this->findCategory('apple'); + $ancestors = all(Category::whereAncestorOf($category)->pluck('id')); - Category::whereDescendantOf(124)->get(); - } + $this->assertEquals([1, 2], $ancestors); + } - public function testAncestorsByNode() - { - $category = $this->findCategory('apple'); - $ancestors = all(Category::whereAncestorOf($category)->pluck('id')); + public function testDescendantsByNode() + { + $category = $this->findCategory('notebooks'); + $res = all(Category::whereDescendantOf($category)->pluck('id')); - $this->assertEquals([ 1, 2 ], $ancestors); - } + $this->assertEquals([3, 4], $res); + } - public function testDescendantsByNode() - { - $category = $this->findCategory('notebooks'); - $res = all(Category::whereDescendantOf($category)->pluck('id')); + public function testMultipleDeletionsDoNotBrakeTree() + { + $category = $this->findCategory('mobile'); - $this->assertEquals([ 3, 4 ], $res); - } + foreach ($category->children()->take(2)->get() as $child) { + $child->forceDelete(); + } - public function testMultipleDeletionsDoNotBrakeTree() - { - $category = $this->findCategory('mobile'); + $this->assertTreeNotBroken(); + } - foreach ($category->children()->take(2)->get() as $child) - { - $child->forceDelete(); - } + public function testTreeIsFixed() + { + Category::where('id', '=', 5)->update(['_lft' => 14]); + Category::where('id', '=', 8)->update(['parent_id' => 2]); + Category::where('id', '=', 11)->update(['_lft' => 20]); + Category::where('id', '=', 2)->update(['parent_id' => 24]); - $this->assertTreeNotBroken(); - } + $fixed = Category::fixTree(); - public function testTreeIsFixed() - { - Category::where('id', '=', 5)->update([ '_lft' => 14 ]); - Category::where('id', '=', 8)->update([ 'parent_id' => 2 ]); - Category::where('id', '=', 11)->update([ '_lft' => 20 ]); - Category::where('id', '=', 2)->update([ 'parent_id' => 24 ]); + $this->assertTrue($fixed > 0); + $this->assertTreeNotBroken(); - $fixed = Category::fixTree(); + $node = Category::find(8); - $this->assertTrue($fixed > 0); - $this->assertTreeNotBroken(); + $this->assertEquals(2, $node->getParentId()); - $node = Category::find(8); + $node = Category::find(2); - $this->assertEquals(2, $node->getParentId()); + $this->assertEquals(null, $node->getParentId()); + } - $node = Category::find(2); + public function testSubtreeIsFixed() + { + Category::where('id', '=', 8)->update(['_lft' => 11]); - $this->assertEquals(null, $node->getParentId()); - } + $fixed = Category::fixSubtree(Category::find(5)); + $this->assertEquals($fixed, 1); + $this->assertTreeNotBroken(); + $this->assertEquals(Category::find(8)->getLft(), 12); + } - public function testSubtreeIsFixed() - { - Category::where('id', '=', 8)->update([ '_lft' => 11 ]); + public function testParentIdDirtiness() + { + $node = $this->findCategory('apple'); + $node->parent_id = 5; - $fixed = Category::fixSubtree(Category::find(5)); - $this->assertEquals($fixed, 1); - $this->assertTreeNotBroken(); - $this->assertEquals(Category::find(8)->getLft(), 12); - } + $this->assertTrue($node->isDirty('parent_id')); - public function testParentIdDirtiness() - { - $node = $this->findCategory('apple'); - $node->parent_id = 5; + $node = $this->findCategory('apple'); + $node->parent_id = null; - $this->assertTrue($node->isDirty('parent_id')); + $this->assertTrue($node->isDirty('parent_id')); + } - $node = $this->findCategory('apple'); - $node->parent_id = null; + public function testIsDirtyMovement() + { + $node = $this->findCategory('apple'); + $otherNode = $this->findCategory('samsung'); - $this->assertTrue($node->isDirty('parent_id')); - } + $this->assertFalse($node->isDirty()); - public function testIsDirtyMovement() - { - $node = $this->findCategory('apple'); - $otherNode = $this->findCategory('samsung'); + $node->afterNode($otherNode); - $this->assertFalse($node->isDirty()); + $this->assertTrue($node->isDirty()); - $node->afterNode($otherNode); + $node = $this->findCategory('apple'); + $otherNode = $this->findCategory('samsung'); - $this->assertTrue($node->isDirty()); + $this->assertFalse($node->isDirty()); - $node = $this->findCategory('apple'); - $otherNode = $this->findCategory('samsung'); + $node->appendToNode($otherNode); - $this->assertFalse($node->isDirty()); + $this->assertTrue($node->isDirty()); + } - $node->appendToNode($otherNode); + public function testRootNodesMoving() + { + $node = $this->findCategory('store'); + $node->down(); - $this->assertTrue($node->isDirty()); - } + $this->assertEquals(3, $node->getLft()); + } - public function testRootNodesMoving() - { - $node = $this->findCategory('store'); - $node->down(); + public function testDescendantsRelation() + { + $node = $this->findCategory('notebooks'); + $result = $node->descendants; - $this->assertEquals(3, $node->getLft()); - } + $this->assertEquals(2, $result->count()); + $this->assertEquals('apple', $result->first()->name); + } - public function testDescendantsRelation() - { - $node = $this->findCategory('notebooks'); - $result = $node->descendants; + public function testDescendantsEagerlyLoaded() + { + $nodes = Category::whereIn('id', [2, 5])->get(); - $this->assertEquals(2, $result->count()); - $this->assertEquals('apple', $result->first()->name); - } + $nodes->load('descendants'); - public function testDescendantsEagerlyLoaded() - { - $nodes = Category::whereIn('id', [ 2, 5 ])->get(); + $this->assertEquals(2, $nodes->count()); + $this->assertTrue($nodes->first()->relationLoaded('descendants')); + } - $nodes->load('descendants'); + public function testDescendantsRelationQuery() + { + $nodes = Category::has('descendants')->whereIn('id', [2, 3])->get(); - $this->assertEquals(2, $nodes->count()); - $this->assertTrue($nodes->first()->relationLoaded('descendants')); - } + $this->assertEquals(1, $nodes->count()); + $this->assertEquals(2, $nodes->first()->getKey()); - public function testDescendantsRelationQuery() - { - $nodes = Category::has('descendants')->whereIn('id', [ 2, 3 ])->get(); + $nodes = Category::has('descendants', '>', 2)->get(); - $this->assertEquals(1, $nodes->count()); - $this->assertEquals(2, $nodes->first()->getKey()); + $this->assertEquals(2, $nodes->count()); + $this->assertEquals(1, $nodes[0]->getKey()); + $this->assertEquals(5, $nodes[1]->getKey()); + } - $nodes = Category::has('descendants', '>', 2)->get(); + public function testParentRelationQuery() + { + $nodes = Category::has('parent')->whereIn('id', [1, 2]); - $this->assertEquals(2, $nodes->count()); - $this->assertEquals(1, $nodes[0]->getKey()); - $this->assertEquals(5, $nodes[1]->getKey()); - } + $this->assertEquals(1, $nodes->count()); + $this->assertEquals(2, $nodes->first()->getKey()); + } - public function testParentRelationQuery() - { - $nodes = Category::has('parent')->whereIn('id', [ 1, 2 ]); + public function testRebuildTree() + { + $fixed = Category::rebuildTree([ + [ + 'id' => 1, + 'children' => [ + ['id' => 10], + ['id' => 3, 'name' => 'apple v2', 'children' => [['name' => 'new node']]], + ['id' => 2], + ], + ], + ]); - $this->assertEquals(1, $nodes->count()); - $this->assertEquals(2, $nodes->first()->getKey()); - } + $this->assertTrue($fixed > 0); + $this->assertTreeNotBroken(); - public function testRebuildTree() - { - $fixed = Category::rebuildTree([ - [ - 'id' => 1, - 'children' => [ - [ 'id' => 10 ], - [ 'id' => 3, 'name' => 'apple v2', 'children' => [ [ 'name' => 'new node' ] ] ], - [ 'id' => 2 ], + $node = Category::find(3); - ] - ] - ]); + $this->assertEquals(1, $node->getParentId()); + $this->assertEquals('apple v2', $node->name); + $this->assertEquals(4, $node->getLft()); - $this->assertTrue($fixed > 0); - $this->assertTreeNotBroken(); + $node = $this->findCategory('new node'); - $node = Category::find(3); + $this->assertNotNull($node); + $this->assertEquals(3, $node->getParentId()); + } - $this->assertEquals(1, $node->getParentId()); - $this->assertEquals('apple v2', $node->name); - $this->assertEquals(4, $node->getLft()); + public function testRebuildSubtree() + { + $fixed = Category::rebuildSubtree(Category::find(7), [ + ['name' => 'new node'], + ['id' => '8'], + ]); - $node = $this->findCategory('new node'); + $this->assertTrue($fixed > 0); + $this->assertTreeNotBroken(); - $this->assertNotNull($node); - $this->assertEquals(3, $node->getParentId()); - } + $node = $this->findCategory('new node'); - public function testRebuildSubtree() - { - $fixed = Category::rebuildSubtree(Category::find(7), [ - [ 'name' => 'new node' ], - [ 'id' => '8' ], - ]); + $this->assertNotNull($node); + $this->assertEquals($node->getLft(), 12); + } - $this->assertTrue($fixed > 0); - $this->assertTreeNotBroken(); + public function testRebuildTreeWithDeletion() + { + Category::rebuildTree([['name' => 'all deleted']], true); - $node = $this->findCategory('new node'); + $this->assertTreeNotBroken(); - $this->assertNotNull($node); - $this->assertEquals($node->getLft(), 12); - } + $nodes = Category::get(); - public function testRebuildTreeWithDeletion() - { - Category::rebuildTree([ [ 'name' => 'all deleted' ] ], true); + $this->assertEquals(1, $nodes->count()); + $this->assertEquals('all deleted', $nodes->first()->name); - $this->assertTreeNotBroken(); + $nodes = Category::withTrashed()->get(); - $nodes = Category::get(); + $this->assertTrue($nodes->count() > 1); + } - $this->assertEquals(1, $nodes->count()); - $this->assertEquals('all deleted', $nodes->first()->name); + public function testRebuildFailsWithInvalidPK() + { + $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); - $nodes = Category::withTrashed()->get(); + Category::rebuildTree([['id' => 24]]); + } - $this->assertTrue($nodes->count() > 1); - } + public function testFlatTree() + { + $node = $this->findCategory('mobile'); + $tree = $node->descendants()->orderBy('name')->get()->toFlatTree(); - public function testRebuildFailsWithInvalidPK() - { - $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + $this->assertCount(5, $tree); + $this->assertEquals('samsung', $tree[2]->name); + $this->assertEquals('galaxy', $tree[3]->name); + } - Category::rebuildTree([ [ 'id' => 24 ] ]); - } + // Commented, cause there is no assertion here and otherwise the test is marked as risky in PHPUnit 7. + // What's the purpose of this method? @todo: remove/update? + /*public function testSeveralNodesModelWork() + { + $category = new Category; - public function testFlatTree() - { - $node = $this->findCategory('mobile'); - $tree = $node->descendants()->orderBy('name')->get()->toFlatTree(); + $category->name = 'test'; - $this->assertCount(5, $tree); - $this->assertEquals('samsung', $tree[2]->name); - $this->assertEquals('galaxy', $tree[3]->name); - } + $category->saveAsRoot(); - // Commented, cause there is no assertion here and otherwise the test is marked as risky in PHPUnit 7. - // What's the purpose of this method? @todo: remove/update? - /*public function testSeveralNodesModelWork() - { - $category = new Category; + $duplicate = new DuplicateCategory; - $category->name = 'test'; + $duplicate->name = 'test'; - $category->saveAsRoot(); + $duplicate->saveAsRoot(); + }*/ - $duplicate = new DuplicateCategory; + public function testWhereIsLeaf() + { + $categories = Category::leaves(); - $duplicate->name = 'test'; + $this->assertEquals(7, $categories->count()); + $this->assertEquals('apple', $categories->first()->name); + $this->assertTrue($categories->first()->isLeaf()); - $duplicate->saveAsRoot(); - }*/ + $category = Category::whereIsRoot()->first(); - public function testWhereIsLeaf() - { - $categories = Category::leaves(); + $this->assertFalse($category->isLeaf()); + } - $this->assertEquals(7, $categories->count()); - $this->assertEquals('apple', $categories->first()->name); - $this->assertTrue($categories->first()->isLeaf()); + public function testEagerLoadAncestors() + { + $queryLogCount = count(Capsule::connection()->getQueryLog()); + $categories = Category::with('ancestors')->orderBy('name')->get(); - $category = Category::whereIsRoot()->first(); + $this->assertEquals($queryLogCount + 2, count(Capsule::connection()->getQueryLog())); - $this->assertFalse($category->isLeaf()); - } + $expectedShape = [ + 'apple (3)}' => 'store (1) > notebooks (2)', + 'galaxy (8)}' => 'store (1) > mobile (5) > samsung (7)', + 'lenovo (4)}' => 'store (1) > notebooks (2)', + 'lenovo (10)}' => 'store (1) > mobile (5)', + 'mobile (5)}' => 'store (1)', + 'nokia (6)}' => 'store (1) > mobile (5)', + 'notebooks (2)}' => 'store (1)', + 'samsung (7)}' => 'store (1) > mobile (5)', + 'sony (9)}' => 'store (1) > mobile (5)', + 'store (1)}' => '', + 'store_2 (11)}' => '', + ]; - public function testEagerLoadAncestors() - { - $queryLogCount = count(Capsule::connection()->getQueryLog()); - $categories = Category::with('ancestors')->orderBy('name')->get(); + $output = []; - $this->assertEquals($queryLogCount + 2, count(Capsule::connection()->getQueryLog())); + foreach ($categories as $category) { + $output["{$category->name} ({$category->id})}"] = $category->ancestors->count() + ? implode(' > ', $category->ancestors->map(function ($cat) { return "{$cat->name} ({$cat->id})"; })->toArray()) + : ''; + } - $expectedShape = [ - 'apple (3)}' => 'store (1) > notebooks (2)', - 'galaxy (8)}' => 'store (1) > mobile (5) > samsung (7)', - 'lenovo (4)}' => 'store (1) > notebooks (2)', - 'lenovo (10)}' => 'store (1) > mobile (5)', - 'mobile (5)}' => 'store (1)', - 'nokia (6)}' => 'store (1) > mobile (5)', - 'notebooks (2)}' => 'store (1)', - 'samsung (7)}' => 'store (1) > mobile (5)', - 'sony (9)}' => 'store (1) > mobile (5)', - 'store (1)}' => '', - 'store_2 (11)}' => '' - ]; + $this->assertEquals($expectedShape, $output); + } - $output = []; + public function testLazyLoadAncestors() + { + $queryLogCount = count(Capsule::connection()->getQueryLog()); + $categories = Category::orderBy('name')->get(); - foreach ($categories as $category) { - $output["{$category->name} ({$category->id})}"] = $category->ancestors->count() - ? implode(' > ', $category->ancestors->map(function ($cat) { return "{$cat->name} ({$cat->id})"; })->toArray()) - : ''; - } + $this->assertEquals($queryLogCount + 1, count(Capsule::connection()->getQueryLog())); - $this->assertEquals($expectedShape, $output); - } + $expectedShape = [ + 'apple (3)}' => 'store (1) > notebooks (2)', + 'galaxy (8)}' => 'store (1) > mobile (5) > samsung (7)', + 'lenovo (4)}' => 'store (1) > notebooks (2)', + 'lenovo (10)}' => 'store (1) > mobile (5)', + 'mobile (5)}' => 'store (1)', + 'nokia (6)}' => 'store (1) > mobile (5)', + 'notebooks (2)}' => 'store (1)', + 'samsung (7)}' => 'store (1) > mobile (5)', + 'sony (9)}' => 'store (1) > mobile (5)', + 'store (1)}' => '', + 'store_2 (11)}' => '', + ]; - public function testLazyLoadAncestors() - { - $queryLogCount = count(Capsule::connection()->getQueryLog()); - $categories = Category::orderBy('name')->get(); + $output = []; + + foreach ($categories as $category) { + $output["{$category->name} ({$category->id})}"] = $category->ancestors->count() + ? implode(' > ', $category->ancestors->map(function ($cat) { return "{$cat->name} ({$cat->id})"; })->toArray()) + : ''; + } + + // assert that there is number of original query + 1 + number of rows to fulfill the relation + $this->assertEquals($queryLogCount + 12, count(Capsule::connection()->getQueryLog())); - $this->assertEquals($queryLogCount + 1, count(Capsule::connection()->getQueryLog())); + $this->assertEquals($expectedShape, $output); + } + + public function testWhereHasCountQueryForAncestors() + { + $categories = all(Category::has('ancestors', '>', 2)->pluck('name')); + + $this->assertEquals(['galaxy'], $categories); - $expectedShape = [ - 'apple (3)}' => 'store (1) > notebooks (2)', - 'galaxy (8)}' => 'store (1) > mobile (5) > samsung (7)', - 'lenovo (4)}' => 'store (1) > notebooks (2)', - 'lenovo (10)}' => 'store (1) > mobile (5)', - 'mobile (5)}' => 'store (1)', - 'nokia (6)}' => 'store (1) > mobile (5)', - 'notebooks (2)}' => 'store (1)', - 'samsung (7)}' => 'store (1) > mobile (5)', - 'sony (9)}' => 'store (1) > mobile (5)', - 'store (1)}' => '', - 'store_2 (11)}' => '' - ]; + $categories = all(Category::whereHas('ancestors', function ($query) { + $query->where('id', 5); + })->pluck('name')); - $output = []; - - foreach ($categories as $category) { - $output["{$category->name} ({$category->id})}"] = $category->ancestors->count() - ? implode(' > ', $category->ancestors->map(function ($cat) { return "{$cat->name} ({$cat->id})"; })->toArray()) - : ''; - } - - // assert that there is number of original query + 1 + number of rows to fulfill the relation - $this->assertEquals($queryLogCount + 12, count(Capsule::connection()->getQueryLog())); - - $this->assertEquals($expectedShape, $output); - } - - public function testWhereHasCountQueryForAncestors() - { - $categories = all(Category::has('ancestors', '>', 2)->pluck('name')); - - $this->assertEquals([ 'galaxy' ], $categories); - - $categories = all(Category::whereHas('ancestors', function ($query) { - $query->where('id', 5); - })->pluck('name')); - - $this->assertEquals([ 'nokia', 'samsung', 'galaxy', 'sony', 'lenovo' ], $categories); - } - - public function testReplication() - { - $category = $this->findCategory('nokia'); - $category = $category->replicate(); - $category->save(); - $category->refreshNode(); - - $this->assertNull($category->getParentId()); - - $category = $this->findCategory('nokia'); - $category = $category->replicate(); - $category->parent_id = 1; - $category->save(); - - $category->refreshNode(); - - $this->assertEquals(1, $category->getParentId()); - } + $this->assertEquals(['nokia', 'samsung', 'galaxy', 'sony', 'lenovo'], $categories); + } + public function testReplication() + { + $category = $this->findCategory('nokia'); + $category = $category->replicate(); + $category->save(); + $category->refreshNode(); + + $this->assertNull($category->getParentId()); + + $category = $this->findCategory('nokia'); + $category = $category->replicate(); + $category->parent_id = 1; + $category->save(); + + $category->refreshNode(); + + $this->assertEquals(1, $category->getParentId()); + } } function all($items) { - return is_array($items) ? $items : $items->all(); + return is_array($items) ? $items : $items->all(); } \ No newline at end of file diff --git a/tests/ScopedNodeTest.php b/tests/ScopedNodeTest.php index c0eac24..70f572d 100644 --- a/tests/ScopedNodeTest.php +++ b/tests/ScopedNodeTest.php @@ -5,218 +5,218 @@ class ScopedNodeTest extends PHPUnit\Framework\TestCase { - public static function setUpBeforeClass(): void - { - $schema = Capsule::schema(); + public static function setUpBeforeClass(): void + { + $schema = Capsule::schema(); - $schema->dropIfExists('menu_items'); + $schema->dropIfExists('menu_items'); - Capsule::disableQueryLog(); + Capsule::disableQueryLog(); - $schema->create('menu_items', function (\Illuminate\Database\Schema\Blueprint $table) { - $table->increments('id'); - $table->unsignedInteger('menu_id'); - $table->string('title')->nullable(); - NestedSet::columns($table); - }); + $schema->create('menu_items', function (Illuminate\Database\Schema\Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('menu_id'); + $table->string('title')->nullable(); + NestedSet::columns($table); + }); - Capsule::enableQueryLog(); - } + Capsule::enableQueryLog(); + } - public function setUp(): void - { - $data = include __DIR__.'/data/menu_items.php'; + public function setUp(): void + { + $data = include __DIR__ . '/data/menu_items.php'; - Capsule::table('menu_items')->insert($data); + Capsule::table('menu_items')->insert($data); - Capsule::flushQueryLog(); + Capsule::flushQueryLog(); - MenuItem::resetActionsPerformed(); + MenuItem::resetActionsPerformed(); - date_default_timezone_set('America/Denver'); - } + date_default_timezone_set('America/Denver'); + } - public function tearDown(): void - { - Capsule::table('menu_items')->truncate(); - } + public function tearDown(): void + { + Capsule::table('menu_items')->truncate(); + } - public function assertTreeNotBroken($menuId) - { - $this->assertFalse(MenuItem::scoped([ 'menu_id' => $menuId ])->isBroken()); - } + public function assertTreeNotBroken($menuId) + { + $this->assertFalse(MenuItem::scoped(['menu_id' => $menuId])->isBroken()); + } - public function testNotBroken() - { - $this->assertTreeNotBroken(1); - $this->assertTreeNotBroken(2); - } + public function testNotBroken() + { + $this->assertTreeNotBroken(1); + $this->assertTreeNotBroken(2); + } - public function testMovingNodeNotAffectingOtherMenu() - { - $node = MenuItem::where('menu_id', '=', 1)->first(); + public function testMovingNodeNotAffectingOtherMenu() + { + $node = MenuItem::where('menu_id', '=', 1)->first(); - $node->down(); + $node->down(); - $node = MenuItem::where('menu_id', '=', 2)->first(); + $node = MenuItem::where('menu_id', '=', 2)->first(); - $this->assertEquals(1, $node->getLft()); - } + $this->assertEquals(1, $node->getLft()); + } - public function testScoped() - { - $node = MenuItem::scoped([ 'menu_id' => 2 ])->first(); + public function testScoped() + { + $node = MenuItem::scoped(['menu_id' => 2])->first(); - $this->assertEquals(3, $node->getKey()); - } + $this->assertEquals(3, $node->getKey()); + } - public function testSiblings() - { - $node = MenuItem::find(1); + public function testSiblings() + { + $node = MenuItem::find(1); - $result = $node->getSiblings(); + $result = $node->getSiblings(); - $this->assertEquals(1, $result->count()); - $this->assertEquals(2, $result->first()->getKey()); + $this->assertEquals(1, $result->count()); + $this->assertEquals(2, $result->first()->getKey()); - $result = $node->getNextSiblings(); + $result = $node->getNextSiblings(); - $this->assertEquals(2, $result->first()->getKey()); + $this->assertEquals(2, $result->first()->getKey()); - $node = MenuItem::find(2); + $node = MenuItem::find(2); - $result = $node->getPrevSiblings(); + $result = $node->getPrevSiblings(); - $this->assertEquals(1, $result->first()->getKey()); - } + $this->assertEquals(1, $result->first()->getKey()); + } - public function testDescendants() - { - $node = MenuItem::find(2); + public function testDescendants() + { + $node = MenuItem::find(2); - $result = $node->getDescendants(); + $result = $node->getDescendants(); - $this->assertEquals(1, $result->count()); - $this->assertEquals(5, $result->first()->getKey()); + $this->assertEquals(1, $result->count()); + $this->assertEquals(5, $result->first()->getKey()); - $node = MenuItem::scoped([ 'menu_id' => 1 ])->with('descendants')->find(2); + $node = MenuItem::scoped(['menu_id' => 1])->with('descendants')->find(2); - $result = $node->descendants; + $result = $node->descendants; - $this->assertEquals(1, $result->count()); - $this->assertEquals(5, $result->first()->getKey()); - } + $this->assertEquals(1, $result->count()); + $this->assertEquals(5, $result->first()->getKey()); + } - public function testAncestors() - { - $node = MenuItem::find(5); + public function testAncestors() + { + $node = MenuItem::find(5); - $result = $node->getAncestors(); + $result = $node->getAncestors(); - $this->assertEquals(1, $result->count()); - $this->assertEquals(2, $result->first()->getKey()); + $this->assertEquals(1, $result->count()); + $this->assertEquals(2, $result->first()->getKey()); - $node = MenuItem::scoped([ 'menu_id' => 1 ])->with('ancestors')->find(5); + $node = MenuItem::scoped(['menu_id' => 1])->with('ancestors')->find(5); - $result = $node->ancestors; + $result = $node->ancestors; - $this->assertEquals(1, $result->count()); - $this->assertEquals(2, $result->first()->getKey()); - } + $this->assertEquals(1, $result->count()); + $this->assertEquals(2, $result->first()->getKey()); + } - public function testDepth() - { - $node = MenuItem::scoped([ 'menu_id' => 1 ])->withDepth()->where('id', '=', 5)->first(); + public function testDepth() + { + $node = MenuItem::scoped(['menu_id' => 1])->withDepth()->where('id', '=', 5)->first(); - $this->assertEquals(1, $node->depth); + $this->assertEquals(1, $node->depth); - $node = MenuItem::find(2); + $node = MenuItem::find(2); - $result = $node->children()->withDepth()->get(); + $result = $node->children()->withDepth()->get(); - $this->assertEquals(1, $result->first()->depth); - } + $this->assertEquals(1, $result->first()->depth); + } - public function testSaveAsRoot() - { - $node = MenuItem::find(5); + public function testSaveAsRoot() + { + $node = MenuItem::find(5); - $node->saveAsRoot(); + $node->saveAsRoot(); - $this->assertEquals(5, $node->getLft()); - $this->assertEquals(null, $node->parent_id); + $this->assertEquals(5, $node->getLft()); + $this->assertEquals(null, $node->parent_id); - $this->assertOtherScopeNotAffected(); - } + $this->assertOtherScopeNotAffected(); + } - public function testInsertion() - { - $node = MenuItem::create([ 'menu_id' => 1, 'parent_id' => 5 ]); + public function testInsertion() + { + $node = MenuItem::create(['menu_id' => 1, 'parent_id' => 5]); - $this->assertEquals(5, $node->parent_id); - $this->assertEquals(5, $node->getLft()); + $this->assertEquals(5, $node->parent_id); + $this->assertEquals(5, $node->getLft()); - $this->assertOtherScopeNotAffected(); - } + $this->assertOtherScopeNotAffected(); + } - public function testInsertionToParentFromOtherScope() - { - $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + public function testInsertionToParentFromOtherScope() + { + $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); - $node = MenuItem::create([ 'menu_id' => 2, 'parent_id' => 5 ]); - } + $node = MenuItem::create(['menu_id' => 2, 'parent_id' => 5]); + } - public function testDeletion() - { - $node = MenuItem::find(2)->delete(); + public function testDeletion() + { + $node = MenuItem::find(2)->delete(); - $node = MenuItem::find(1); + $node = MenuItem::find(1); - $this->assertEquals(2, $node->getRgt()); + $this->assertEquals(2, $node->getRgt()); - $this->assertOtherScopeNotAffected(); - } + $this->assertOtherScopeNotAffected(); + } - public function testMoving() - { - $node = MenuItem::find(1); - $this->assertTrue($node->down()); + public function testMoving() + { + $node = MenuItem::find(1); + $this->assertTrue($node->down()); - $this->assertOtherScopeNotAffected(); - } + $this->assertOtherScopeNotAffected(); + } - protected function assertOtherScopeNotAffected() - { - $node = MenuItem::find(3); + protected function assertOtherScopeNotAffected() + { + $node = MenuItem::find(3); - $this->assertEquals(1, $node->getLft()); - } + $this->assertEquals(1, $node->getLft()); + } - // Commented, cause there is no assertion here and otherwise the test is marked as risky in PHPUnit 7. - // What's the purpose of this method? @todo: remove/update? - /*public function testRebuildsTree() - { - $data = []; - MenuItem::scoped([ 'menu_id' => 2 ])->rebuildTree($data); - }*/ + // Commented, cause there is no assertion here and otherwise the test is marked as risky in PHPUnit 7. + // What's the purpose of this method? @todo: remove/update? + /*public function testRebuildsTree() + { + $data = []; + MenuItem::scoped([ 'menu_id' => 2 ])->rebuildTree($data); + }*/ - public function testAppendingToAnotherScopeFails() - { - $this->expectException(LogicException::class); + public function testAppendingToAnotherScopeFails() + { + $this->expectException(LogicException::class); - $a = MenuItem::find(1); - $b = MenuItem::find(3); + $a = MenuItem::find(1); + $b = MenuItem::find(3); - $a->appendToNode($b)->save(); - } + $a->appendToNode($b)->save(); + } - public function testInsertingBeforeAnotherScopeFails() - { - $this->expectException(LogicException::class); + public function testInsertingBeforeAnotherScopeFails() + { + $this->expectException(LogicException::class); - $a = MenuItem::find(1); - $b = MenuItem::find(3); + $a = MenuItem::find(1); + $b = MenuItem::find(3); - $a->insertAfterNode($b); - } + $a->insertAfterNode($b); + } } \ No newline at end of file diff --git a/tests/data/categories.php b/tests/data/categories.php index 1f5b8ab..9b6368e 100644 --- a/tests/data/categories.php +++ b/tests/data/categories.php @@ -1,15 +1,15 @@ 1, 'name' => 'store', '_lft' => 1, '_rgt' => 20, 'parent_id' => null), - array('id' => 2, 'name' => 'notebooks', '_lft' => 2, '_rgt' => 7, 'parent_id' => 1), - array('id' => 3, 'name' => 'apple', '_lft' => 3, '_rgt' => 4, 'parent_id' => 2), - array('id' => 4, 'name' => 'lenovo', '_lft' => 5, '_rgt' => 6, 'parent_id' => 2), - array('id' => 5, 'name' => 'mobile', '_lft' => 8, '_rgt' => 19, 'parent_id' => 1), - array('id' => 6, 'name' => 'nokia', '_lft' => 9, '_rgt' => 10, 'parent_id' => 5), - array('id' => 7, 'name' => 'samsung', '_lft' => 11, '_rgt' => 14, 'parent_id' => 5), - array('id' => 8, 'name' => 'galaxy', '_lft' => 12, '_rgt' => 13, 'parent_id' => 7), - array('id' => 9, 'name' => 'sony', '_lft' => 15, '_rgt' => 16, 'parent_id' => 5), - array('id' => 10, 'name' => 'lenovo', '_lft' => 17, '_rgt' => 18, 'parent_id' => 5), - array('id' => 11, 'name' => 'store_2', '_lft' => 21, '_rgt' => 22, 'parent_id' => null), -); \ No newline at end of file +return [ + ['id' => 1, 'name' => 'store', '_lft' => 1, '_rgt' => 20, 'parent_id' => null], + ['id' => 2, 'name' => 'notebooks', '_lft' => 2, '_rgt' => 7, 'parent_id' => 1], + ['id' => 3, 'name' => 'apple', '_lft' => 3, '_rgt' => 4, 'parent_id' => 2], + ['id' => 4, 'name' => 'lenovo', '_lft' => 5, '_rgt' => 6, 'parent_id' => 2], + ['id' => 5, 'name' => 'mobile', '_lft' => 8, '_rgt' => 19, 'parent_id' => 1], + ['id' => 6, 'name' => 'nokia', '_lft' => 9, '_rgt' => 10, 'parent_id' => 5], + ['id' => 7, 'name' => 'samsung', '_lft' => 11, '_rgt' => 14, 'parent_id' => 5], + ['id' => 8, 'name' => 'galaxy', '_lft' => 12, '_rgt' => 13, 'parent_id' => 7], + ['id' => 9, 'name' => 'sony', '_lft' => 15, '_rgt' => 16, 'parent_id' => 5], + ['id' => 10, 'name' => 'lenovo', '_lft' => 17, '_rgt' => 18, 'parent_id' => 5], + ['id' => 11, 'name' => 'store_2', '_lft' => 21, '_rgt' => 22, 'parent_id' => null], +]; \ No newline at end of file diff --git a/tests/data/menu_items.php b/tests/data/menu_items.php index 5490f7d..2bd43ce 100644 --- a/tests/data/menu_items.php +++ b/tests/data/menu_items.php @@ -1,8 +1,10 @@ - 1, 'menu_id' => 1, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1' ], - [ 'id' => 2, 'menu_id' => 1, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2' ], - [ 'id' => 5, 'menu_id' => 1, '_lft' => 4, '_rgt' => 5, 'parent_id' => 2, 'title' => 'menu item 3' ], - [ 'id' => 3, 'menu_id' => 2, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1' ], - [ 'id' => 4, 'menu_id' => 2, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2' ], - [ 'id' => 6, 'menu_id' => 2, '_lft' => 4, '_rgt' => 5, 'parent_id' => 4, 'title' => 'menu item 3' ], + 1, 'menu_id' => 1, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1'], + ['id' => 2, 'menu_id' => 1, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2'], + ['id' => 5, 'menu_id' => 1, '_lft' => 4, '_rgt' => 5, 'parent_id' => 2, 'title' => 'menu item 3'], + ['id' => 3, 'menu_id' => 2, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1'], + ['id' => 4, 'menu_id' => 2, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2'], + ['id' => 6, 'menu_id' => 2, '_lft' => 4, '_rgt' => 5, 'parent_id' => 4, 'title' => 'menu item 3'], ]; \ No newline at end of file diff --git a/tests/models/Category.php b/tests/models/Category.php index 0d336f3..241482e 100644 --- a/tests/models/Category.php +++ b/tests/models/Category.php @@ -1,17 +1,18 @@