diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml new file mode 100644 index 0000000..0bd18f9 --- /dev/null +++ b/.github/workflows/CI.yaml @@ -0,0 +1,100 @@ +name: Tests + +# Run this workflow every time a new commit pushed to your repository +on: + push: + paths-ignore: + - '**/*.md' + pull_request: + paths-ignore: + - '**/*.md' + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository) + + strategy: + fail-fast: false + matrix: + operating-system: [ubuntu-20.04] + php-versions: ['7.4', '8.0', '8.1'] + dependencies: ['no', 'low', 'beta'] + exclude: + - operating-system: ubuntu-20.04 + php-versions: '8.1' + dependencies: 'low' + + name: PHP ${{ matrix.php-versions }} - ${{ matrix.dependencies }} + + env: + COMPOSER_NO_INTERACTION: 1 + extensions: curl json libxml dom + key: cache-v1 # can be any string, change to clear the extension cache. + + steps: + + # Checks out a copy of your repository on the ubuntu machine + - name: Checkout code + uses: actions/checkout@v2 + + - 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: Fix beta + if: ${{ matrix.dependencies == 'beta' }} + run: perl -pi -e 's/^}$/,"minimum-stability":"beta"}/' composer.json + + - name: Setup PHP Action + uses: shivammathur/setup-php@2.8.0 + with: + php-version: ${{ matrix.php-versions }} + extensions: ${{ env.extensions }} + coverage: xdebug + tools: pecl, composer + + - name: PHP Show modules + run: php -m + + - 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 + if: ${{ matrix.dependencies != 'low' }} + run: composer update --no-interaction + + - name: Install Composer dependencies + if: ${{ matrix.dependencies == 'low' }} + run: composer update -vvv --prefer-lowest --prefer-stable --no-interaction + + - name: Validate files + run: composer validate-files + + - name: Run tests + run: composer run-tests \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 438e5dd..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Unit Tests - -on: - push: - branches: - - master - pull_request: - branches: - - "*" - schedule: - - cron: '0 0 * * *' - -jobs: - php-tests: - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - COMPOSER_NO_INTERACTION: 1 - - strategy: - fail-fast: false - matrix: - php: [8.1, 8.0, 7.4, 7.3, 7.2] - - name: P${{ matrix.php }} - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - coverage: none - tools: composer:v2 - - - name: Install dependencies - run: | - composer install -o --quiet - - - name: Execute Unit Tests - run: composer test diff --git a/.gitignore b/.gitignore index 6bef520..24a2013 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ composer.phar composer.lock .DS_Store +.php-cs-fixer.cache .phpunit.result.cache diff --git a/README.markdown b/README.markdown index 7e7341e..67cd0bd 100644 --- a/README.markdown +++ b/README.markdown @@ -1,11 +1,15 @@ -[](https://travis-ci.org/lazychaser/laravel-nestedset) -[](https://packagist.org/packages/kalnoy/nestedset) -[](https://packagist.org/packages/kalnoy/nestedset) -[](https://packagist.org/packages/kalnoy/nestedset) -[](https://packagist.org/packages/kalnoy/nestedset) +[](https://packagist.org/packages/lychee-org/nestedset) +[](https://packagist.org/packages/lychee-org/nestedset) +[](https://packagist.org/packages/lychee-org/nestedset) +[](https://packagist.org/packages/lychee-org/nestedset) This is a Laravel 4-8 package for working with trees in relational databases. +It is a fork of [lazychaser/laravel-nestedset](https://github.com/lazychaser/laravel-nestedset) and contains general patches which are required for using the library with [Lychee](https://github.com/LycheeOrg/Lychee). Note that the patches are **not** specific for Lychee, but a generally useful. Inter alia: + + * Routines respect a foreign key constraint on the parent-child-relation by taking care that changes to the tree are applied in the correct order. + * The code does not fail if the model which uses `NoteTrait` does not directly extend `Model` but indirectly inherits `Model` via another parent class. + * **Laravel 8.0** is supported since v6 * **Laravel 5.7, 5.8, 6.0, 7.0** is supported since v5 * **Laravel 5.5, 5.6** is supported since v4.3 @@ -13,10 +17,6 @@ This is a Laravel 4-8 package for working with trees in relational databases. * **Laravel 5.1** is supported in v3 * **Laravel 4** is supported in v2 -Although this project is completely free for use, I appreciate any support! - -- __[Donate via PayPal](https://www.paypal.me/lazychaser)__ - __Contents:__ - [Theory](#what-are-nested-sets) diff --git a/composer.json b/composer.json index ff3df84..fb15894 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "kalnoy/nestedset", - "description": "Nested Set Model for Laravel 5.7 and up", + "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"], "license": "MIT", @@ -25,9 +25,19 @@ }, "require-dev": { - "phpunit/phpunit": "7.*|8.*|9.*" + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^9.5.20" }, + "scripts": { + "run-tests": [ + "vendor/bin/phpunit -c phpunit.xml", + "vendor/bin/phpunit -c phpunit.xml --coverage-clover=coverage.xml" + ], + "validate-files": [ + "vendor/bin/parallel-lint --exclude vendor ." + ] + }, "minimum-stability": "dev", "prefer-stable": true, @@ -41,10 +51,5 @@ "Kalnoy\\Nestedset\\NestedSetServiceProvider" ] } - }, - "scripts": { - "test": [ - "@php ./vendor/bin/phpunit" - ] } } diff --git a/phpunit.xml b/phpunit.xml index 1e71a58..ab23f7f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,22 +1,13 @@ <?xml version="1.0" encoding="UTF-8"?> -<phpunit backupGlobals="false" - backupStaticAttributes="false" - bootstrap="phpunit.php" - colors="true" - convertErrorsToExceptions="true" - convertNoticesToExceptions="true" - convertWarningsToExceptions="true" - processIsolation="false" -> - <testsuites> - <testsuite name="Package Test Suite"> - <directory suffix=".php">./tests/</directory> - </testsuite> - </testsuites> - - <filter> - <whitelist> - <directory>./src</directory> - </whitelist> - </filter> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" backupStaticAttributes="false" bootstrap="phpunit.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"> + <coverage> + <include> + <directory>./src</directory> + </include> + </coverage> + <testsuites> + <testsuite name="Package Test Suite"> + <directory suffix=".php">./tests/</directory> + </testsuite> + </testsuites> </phpunit> \ No newline at end of file diff --git a/src/BaseRelation.php b/src/BaseRelation.php index 031eecf..e2bd7db 100644 --- a/src/BaseRelation.php +++ b/src/BaseRelation.php @@ -144,7 +144,7 @@ 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($models[0])->applyNestedSetScope($this->query); + 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 diff --git a/src/NestedSet.php b/src/NestedSet.php index 8ec8e02..7045471 100644 --- a/src/NestedSet.php +++ b/src/NestedSet.php @@ -77,7 +77,7 @@ public static function getDefaultColumns() */ public static function isNode($node) { - return is_object($node) && in_array(NodeTrait::class, (array)$node); + return $node instanceof Node; } } \ No newline at end of file diff --git a/src/Node.php b/src/Node.php new file mode 100644 index 0000000..dfcb12f --- /dev/null +++ b/src/Node.php @@ -0,0 +1,363 @@ +<?php + +namespace Kalnoy\Nestedset; + +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; + +/** + * Accompanies {@link \Kalnoy\Nestedset\NodeTrait}. + * + * This interface declares all public methods of a node which are implemented + * by {@link \Kalnoy\Nestedset\NodeTrait}. + * + * Every model which represents a node in a nested set, must realize this + * interface. + * This interface is mandatory such that + * {@link \Kalnoy\Nestedset\NestedSet::isNode()} recognizes an object as a + * node. + */ +interface Node +{ + /** + * Relation to the parent. + * + * @return BelongsTo + */ + public function parent(); + + /** + * Relation to children. + * + * @return HasMany + */ + public function children(); + + /** + * Get query for descendants of the node. + * + * @return DescendantsRelation + */ + public function descendants(); + + /** + * Get query for siblings of the node. + * + * @return QueryBuilder + */ + public function siblings(); + + /** + * Get the node siblings and the node itself. + * + * @return QueryBuilder + */ + public function siblingsAndSelf(); + + /** + * Get query for the node siblings and the node itself. + * + * @param array $columns + * + * @return EloquentCollection + */ + public function getSiblingsAndSelf(array $columns = ['*']); + + /** + * Get query for siblings after the node. + * + * @return QueryBuilder + */ + public function nextSiblings(); + + /** + * Get query for siblings before the node. + * + * @return QueryBuilder + */ + public function prevSiblings(); + + /** + * Get query for nodes after current node. + * + * @return QueryBuilder + */ + public function nextNodes(); + + /** + * Get query for nodes before current node in reversed order. + * + * @return QueryBuilder + */ + public function prevNodes(); + + /** + * Get query ancestors of the node. + * + * @return AncestorsRelation + */ + public function ancestors(); + + /** + * Make this node a root node. + * + * @return $this + */ + public function makeRoot(); + + /** + * Save node as root. + * + * @return bool + */ + public function saveAsRoot(); + + /** + * @param $lft + * @param $rgt + * @param $parentId + * + * @return $this + */ + public function rawNode($lft, $rgt, $parentId); + + /** + * Move node up given amount of positions. + * + * @param int $amount + * + * @return bool + */ + public function up($amount = 1); + + /** + * Move node down given amount of positions. + * + * @param int $amount + * + * @return bool + */ + public function down($amount = 1); + + /** + * @since 2.0 + */ + public function newEloquentBuilder($query); + + /** + * Get a new base query that includes deleted nodes. + * + * @since 1.1 + * + * @return QueryBuilder + */ + public function newNestedSetQuery($table = null); + + /** + * @param ?string $table + * + * @return QueryBuilder + */ + public function newScopedQuery($table = null); + + /** + * @param mixed $query + * @param ?string $table + * + * @return mixed + */ + public function applyNestedSetScope($query, $table = null); + + /** + * @param array $attributes + * + * @return self + */ + public static function scoped(array $attributes); + + public function newCollection(array $models = []); + + /** + * Get node height (rgt - lft + 1). + * + * @return int + */ + public function getNodeHeight(); + + /** + * Get number of descendant nodes. + * + * @return int + */ + public function getDescendantCount(); + + /** + * 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); + + /** + * Get whether node is root. + * + * @return bool + */ + public function isRoot(); + + /** + * @return bool + */ + public function isLeaf(); + + /** + * Get the lft key name. + * + * @return string + */ + public function getLftName(); + + /** + * Get the rgt key name. + * + * @return string + */ + public function getRgtName(); + + /** + * Get the parent id key name. + * + * @return string + */ + public function getParentIdName(); + + /** + * Get the value of the model's lft key. + * + * @return int + */ + public function getLft(); + + /** + * Get the value of the model's rgt key. + * + * @return int + */ + public function getRgt(); + + /** + * Get the value of the model's parent id key. + * + * @return int + */ + public function getParentId(); + + /** + * 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 = ['*']); + + /** + * 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 = ['*']); + + /** + * @param array $columns + * + * @return Collection + */ + public function getAncestors(array $columns = ['*']); + + /** + * @param array $columns + * + * @return Collection|self[] + */ + public function getDescendants(array $columns = ['*']); + + /** + * @param array $columns + * + * @return Collection|self[] + */ + public function getSiblings(array $columns = ['*']); + + /** + * @param array $columns + * + * @return Collection<self> + */ + public function getNextSiblings(array $columns = ['*']); + + /** + * @param array $columns + * + * @return Collection<self> + */ + public function getPrevSiblings(array $columns = ['*']); + + /** + * @param array $columns + * + * @return Node + */ + public function getNextSibling(array $columns = ['*']); + + /** + * @param array $columns + * + * @return Node + */ + public function getPrevSibling(array $columns = ['*']); + + /** + * @return array<int> + */ + public function getBounds(); + + /** + * @param $value + * + * @return $this + */ + public function setLft($value); + + /** + * @param $value + * + * @return $this + */ + public function setRgt($value); + + /** + * @param $value + * + * @return $this + */ + public function setParentId($value); + + /** + * @param array|null $except + * + * @return $this + */ + public function replicate(array $except = null); +} diff --git a/src/NodeTrait.php b/src/NodeTrait.php index 0b985ab..5e9ee78 100644 --- a/src/NodeTrait.php +++ b/src/NodeTrait.php @@ -49,10 +49,9 @@ public static function bootNodeTrait() 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(); - }); - - static::deleted(function ($model) { $model->deleteDescendants(); }); @@ -628,7 +627,31 @@ protected function deleteDescendants() ? 'forceDelete' : 'delete'; - $this->descendants()->{$method}(); + // 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; diff --git a/tests/models/Category.php b/tests/models/Category.php index bcce8e9..0d336f3 100644 --- a/tests/models/Category.php +++ b/tests/models/Category.php @@ -2,7 +2,7 @@ use \Illuminate\Database\Eloquent\Model; -class Category extends Model { +class Category extends Model implements \Kalnoy\Nestedset\Node { use \Illuminate\Database\Eloquent\SoftDeletes, \Kalnoy\Nestedset\NodeTrait; diff --git a/tests/models/DuplicateCategory.php b/tests/models/DuplicateCategory.php index a6f619a..34311a9 100644 --- a/tests/models/DuplicateCategory.php +++ b/tests/models/DuplicateCategory.php @@ -1,6 +1,6 @@ <?php -class DuplicateCategory extends \Illuminate\Database\Eloquent\Model +class DuplicateCategory extends \Illuminate\Database\Eloquent\Model implements \Kalnoy\Nestedset\Node { use \Kalnoy\Nestedset\NodeTrait; diff --git a/tests/models/MenuItem.php b/tests/models/MenuItem.php index 2e10a55..4c4268c 100644 --- a/tests/models/MenuItem.php +++ b/tests/models/MenuItem.php @@ -1,7 +1,7 @@ <?php -class MenuItem extends \Illuminate\Database\Eloquent\Model +class MenuItem extends \Illuminate\Database\Eloquent\Model implements \Kalnoy\Nestedset\Node { use \Kalnoy\Nestedset\NodeTrait;