├── .coveralls.yml ├── .gitignore ├── src ├── Exceptions │ ├── InconsistentTreeException.php │ ├── InvalidTraitInjectionClass.php │ ├── LTreeReflectionException.php │ └── LTreeUndefinedNodeException.php ├── Interfaces │ ├── ModelInterface.php │ ├── HasLTreeRelations.php │ ├── LTreeServiceInterface.php │ ├── LTreeInterface.php │ ├── LTreeModelInterface.php │ └── HasLTreeScopes.php ├── Providers │ ├── LTreeExtensionProvider.php │ └── LTreeServiceProvider.php ├── Schema │ ├── Grammars │ │ └── LTreeSchemaGrammar.php │ └── LTreeBlueprint.php ├── Relations │ ├── BelongsToAncestorsTree.php │ ├── BelongsToDescendantsTree.php │ └── AbstractBelongsToTree.php ├── Resources │ ├── LTreeResource.php │ └── LTreeResourceCollection.php ├── LTreeExtension.php ├── .meta.php ├── Traits │ ├── LTreeTrait.php │ ├── HasTreeRelationships.php │ └── LTreeModelTrait.php ├── Types │ └── LTreeType.php ├── Services │ └── LTreeService.php ├── Helpers │ ├── LTreeBuilder.php │ ├── LTreeHelper.php │ └── LTreeNode.php └── Collections │ └── LTreeCollection.php ├── .github ├── assignee.config.yml ├── dependabot.yml ├── workflows │ ├── auto_assignee.yml │ ├── auto_release.yml │ ├── stale.yml │ └── ci.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── release-drafter.yml ├── phpcs.xml ├── CONTRIBUTING.md ├── tests ├── _data │ ├── Mocks │ │ └── LTreeMocks.php │ ├── Models │ │ ├── CategoryStubResourceCollection.php │ │ ├── CategoryStubResource.php │ │ ├── CategorySomeStub.php │ │ ├── ProductStub.php │ │ └── CategoryStub.php │ └── Traits │ │ └── HasLTreeTables.php ├── LTreeBaseTestCase.php ├── TestCase.php ├── functional │ ├── Providers │ │ └── LTreeServiceProviderTest.php │ ├── Resources │ │ └── LTreeResourceTest.php │ ├── Types │ │ └── LTreeTypeTest.php │ ├── Models │ │ └── LTreeModelTest.php │ ├── Helpers │ │ ├── LTreeHelperTest.php │ │ └── LTreeNodeTest.php │ ├── Collections │ │ └── LTreeCollectionTest.php │ └── Relations │ │ └── BelongsToTreelTest.php └── FunctionalTestCase.php ├── ecs.yml ├── tests.sh ├── LICENSE ├── ecs.php ├── phpunit.xml.dist ├── composer.json ├── README.md └── CODE_OF_CONDUCT.md /.coveralls.yml: -------------------------------------------------------------------------------- 1 | coverage_clover: build/logs/clover.xml 2 | json_path: build/logs/coveralls-upload.json 3 | service_name: travis-ci 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /.idea 3 | .ecs_cache 4 | .phpunit.cache 5 | phpunit.xml 6 | /build 7 | .phpunit.result.cache 8 | .DS_Store 9 | composer.lock 10 | 11 | -------------------------------------------------------------------------------- /src/Exceptions/InconsistentTreeException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | ./ 4 | ./vendor/* 5 | ./tests/* 6 | ./.github/* 7 | 8 | 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: composer 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | open-pull-requests-limit: 10 9 | reviewers: 10 | - pvsaintpe 11 | assignees: 12 | - pvsaintpe 13 | labels: 14 | - type:build 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | - Fork it (https://github.com/umbrellio/laravel-ltree) 4 | - Create your feature branch (`git checkout -b feature/my-new-feature`) 5 | - Commit your changes (`git commit -am 'Add some feature'`) 6 | - Push to the branch (`git push origin feature/my-new-feature`) 7 | - Create new Pull Request 8 | 9 | -------------------------------------------------------------------------------- /.github/workflows/auto_assignee.yml: -------------------------------------------------------------------------------- 1 | name: 'Auto assign assignees or reviewers' 2 | on: pull_request 3 | 4 | jobs: 5 | add-reviews: 6 | name: "Auto assignment of a assignee" 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: kentaro-m/auto-assign-action@v1.1.2 10 | with: 11 | configuration-path: ".github/assignee.config.yml" 12 | -------------------------------------------------------------------------------- /src/Exceptions/LTreeReflectionException.php: -------------------------------------------------------------------------------- 1 | initLTreeService(); 18 | $this->initLTreeCategories(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Providers/LTreeExtensionProvider.php: -------------------------------------------------------------------------------- 1 | app->bind(LTreeServiceInterface::class, LTreeService::class); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Interfaces/LTreeInterface.php: -------------------------------------------------------------------------------- 1 | addColumn(LTreeType::TYPE_NAME, $column); 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v3 11 | with: 12 | repo-token: ${{ secrets.GITHUB_TOKEN }} 13 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days' 14 | days-before-stale: 30 15 | days-before-close: 5 16 | -------------------------------------------------------------------------------- /src/Interfaces/LTreeModelInterface.php: -------------------------------------------------------------------------------- 1 | phpunit.xml 9 | COMPOSER_MEMORY_LIMIT=-1 composer update 10 | composer lint 11 | php vendor/bin/phpunit -c phpunit.xml --migrate-configuration 12 | php -d xdebug.mode=coverage -d memory_limit=-1 vendor/bin/phpunit --coverage-html build --coverage-text 13 | -------------------------------------------------------------------------------- /tests/functional/Providers/LTreeServiceProviderTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(LTreeService::class, $service); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/Interfaces/HasLTreeScopes.php: -------------------------------------------------------------------------------- 1 | $model->getKey(), 24 | 'path' => $model->getLtreePath(LTreeInterface::AS_STRING), 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Relations/BelongsToAncestorsTree.php: -------------------------------------------------------------------------------- 1 | ancestorsOf($model); 21 | } 22 | 23 | protected function getOperator(): string 24 | { 25 | return '@>'; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Relations/BelongsToDescendantsTree.php: -------------------------------------------------------------------------------- 1 | descendantsOf($model); 21 | } 22 | 23 | protected function getOperator(): string 24 | { 25 | return '<@'; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Resources/LTreeResource.php: -------------------------------------------------------------------------------- 1 | toTreeArray($request, $this->resource->model), [ 19 | 'children' => static::collection($this->resource->getChildren())->toArray($request), 20 | ]); 21 | } 22 | 23 | /** 24 | * @param LTreeInterface $model 25 | */ 26 | abstract protected function toTreeArray($request, $model); 27 | } 28 | -------------------------------------------------------------------------------- /src/Resources/LTreeResourceCollection.php: -------------------------------------------------------------------------------- 1 | toTree($usingSort, $loadMissing); 19 | 20 | if ($sort) { 21 | $collection->sortTree($sort); 22 | } 23 | 24 | parent::__construct($collection->getChildren()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/LTreeExtension.php: -------------------------------------------------------------------------------- 1 | Blueprint::class, 22 | LTreeSchemaGrammar::class => PostgresGrammar::class, 23 | ]; 24 | } 25 | 26 | public static function getName(): string 27 | { 28 | return static::NAME; 29 | } 30 | 31 | public static function getTypes(): array 32 | { 33 | return [ 34 | static::NAME => LTreeType::class, 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Umbrellio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/.meta.php: -------------------------------------------------------------------------------- 1 | belongsTo(static::class); 28 | } 29 | 30 | public function parentAncestorsTree() 31 | { 32 | return $this->belongsToAncestorsTree(static::class, 'parent'); 33 | } 34 | 35 | public function parentDescendantsTree() 36 | { 37 | return $this->belongsToDescendantsTree(static::class, 'parent'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/_data/Models/ProductStub.php: -------------------------------------------------------------------------------- 1 | belongsTo(CategoryStub::class); 28 | } 29 | 30 | public function categoryAncestorsTree() 31 | { 32 | return $this->belongsToAncestorsTree(CategoryStub::class, 'category'); 33 | } 34 | 35 | public function categoryDescendantsTree() 36 | { 37 | return $this->belongsToDescendantsTree(CategoryStub::class, 'category'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | import(__DIR__ . '/vendor/umbrellio/code-style-php/umbrellio-cs.php'); 12 | 13 | $services = $containerConfigurator->services(); 14 | 15 | $services->set(PhpUnitTestAnnotationFixer::class) 16 | ->call('configure', [[ 17 | 'style' => 'annotation', 18 | ]]); 19 | 20 | $services->set(DeclareStrictTypesFixer::class); 21 | 22 | $services->set(BinaryOperatorSpacesFixer::class) 23 | ->call('configure', [[ 24 | 'default' => 'single_space', 25 | ]]); 26 | 27 | $parameters = $containerConfigurator->parameters(); 28 | 29 | $parameters->set('cache_directory', '.ecs_cache'); 30 | 31 | $parameters->set('exclude_files', ['vendor/*', 'database/*']); 32 | }; 33 | -------------------------------------------------------------------------------- /tests/_data/Models/CategoryStub.php: -------------------------------------------------------------------------------- 1 | getBaseLtreeProxyDeleteColumns(), ['is_deleted']); 31 | } 32 | 33 | public function getLtreeProxyUpdateColumns(): array 34 | { 35 | return array_merge($this->getBaseLTreeProxyUpdateColumns(), ['name']); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Traits/LTreeTrait.php: -------------------------------------------------------------------------------- 1 | getAttribute($this->getLtreeParentColumn()); 30 | return $value ? (int) $value : null; 31 | } 32 | 33 | public function getLtreePath($mode = LTreeInterface::AS_ARRAY) 34 | { 35 | $path = $this->getAttribute($this->getLtreePathColumn()); 36 | if ($mode === LTreeModelInterface::AS_ARRAY) { 37 | return $path !== null ? explode(LTreeType::TYPE_SEPARATE, $path) : []; 38 | } 39 | return (string) $path; 40 | } 41 | 42 | public function getLtreeLevel(): int 43 | { 44 | return is_array($path = $this->getLtreePath()) ? count($path) : 1; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Types/LTreeType.php: -------------------------------------------------------------------------------- 1 | map(static function ($value) { 28 | return (int) $value; 29 | }) 30 | ->toArray(); 31 | } 32 | 33 | public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string 34 | { 35 | if ($value === null) { 36 | return null; 37 | } 38 | 39 | if (is_scalar($value)) { 40 | $value = (array) $value; 41 | } 42 | 43 | return implode(static::TYPE_SEPARATE, $value); 44 | } 45 | 46 | public function getName(): string 47 | { 48 | return self::TYPE_NAME; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/FunctionalTestCase.php: -------------------------------------------------------------------------------- 1 | getConnectionParams(); 18 | 19 | $app['config']->set('database.default', 'main'); 20 | $app['config']->set('database.connections.main', [ 21 | 'driver' => 'pgsql', 22 | 'host' => $params['host'], 23 | 'port' => (int) $params['port'], 24 | 'database' => $params['database'], 25 | 'username' => $params['user'], 26 | 'password' => $params['password'], 27 | 'charset' => 'utf8', 28 | 'prefix' => '', 29 | 'schema' => 'public', 30 | ]); 31 | } 32 | 33 | private function getConnectionParams(): array 34 | { 35 | return [ 36 | 'driver' => $GLOBALS['db_type'] ?? 'pdo_pgsql', 37 | 'user' => $GLOBALS['db_username'], 38 | 'password' => $GLOBALS['db_password'], 39 | 'host' => $GLOBALS['db_host'], 40 | 'database' => $GLOBALS['db_database'], 41 | 'port' => $GLOBALS['db_port'], 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ./src 25 | 26 | ./src/.meta.php 27 | 28 | 29 | 30 | 31 | 32 | ./tests 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Services/LTreeService.php: -------------------------------------------------------------------------------- 1 | helper = $helper; 19 | } 20 | 21 | public function createPath(LTreeModelInterface $model): void 22 | { 23 | $this->helper->buildPath($model); 24 | } 25 | 26 | /** 27 | * @param LTreeModelInterface|Model $model 28 | */ 29 | public function updatePath(LTreeModelInterface $model): void 30 | { 31 | $columns = array_intersect_key($model->getAttributes(), array_flip($model->getLtreeProxyUpdateColumns())); 32 | 33 | $this->helper->moveNode($model, $model->ltreeParent, $columns); 34 | $this->helper->buildPath($model); 35 | } 36 | 37 | /** 38 | * @param LTreeModelInterface|Model $model 39 | */ 40 | public function dropDescendants(LTreeModelInterface $model): void 41 | { 42 | $columns = array_intersect_key($model->getAttributes(), array_flip($model->getLtreeProxyDeleteColumns())); 43 | 44 | $this->helper->dropDescendants($model, $columns); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "umbrellio/laravel-ltree", 3 | "type": "library", 4 | "description": "Extension LTree (Postgres) for Laravel", 5 | "keywords": [ 6 | "ltree", 7 | "tree", 8 | "postgres", 9 | "postgresql", 10 | "pg", 11 | "ltree-extension", 12 | "laravel", 13 | "php" 14 | ], 15 | "minimum-stability": "stable", 16 | "authors": [ 17 | { 18 | "name": "Korben Dallas", 19 | "email": "pvsaintpe@umbrellio.biz" 20 | } 21 | ], 22 | "license": "MIT", 23 | "require": { 24 | "php": "^8.3|^8.4", 25 | "laravel/framework": "^11.0", 26 | "doctrine/dbal": "^3.0", 27 | "umbrellio/laravel-pg-extensions": "^7.2", 28 | "umbrellio/laravel-common-objects": "*" 29 | }, 30 | "require-dev": { 31 | "umbrellio/code-style-php": "^1.0", 32 | "orchestra/testbench": "^9.0", 33 | "php-coveralls/php-coveralls": "^2.1", 34 | "squizlabs/php_codesniffer": "^3.5" 35 | }, 36 | "scripts": { 37 | "lint": [ 38 | "ecs check --config=ecs.php . --fix" 39 | ] 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "Umbrellio\\LTree\\": "src/" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Umbrellio\\LTree\\tests\\": "tests/" 49 | } 50 | }, 51 | "extra": { 52 | "laravel": { 53 | "providers": [ 54 | "Umbrellio\\LTree\\Providers\\LTreeServiceProvider", 55 | "Umbrellio\\LTree\\Providers\\LTreeExtensionProvider" 56 | ] 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## Changes 3 | 4 | $CHANGES 5 | 6 | change-template: '- **$TITLE** (#$NUMBER)' 7 | 8 | version-template: "$MAJOR.$MINOR.$PATCH" 9 | name-template: '$RESOLVED_VERSION' 10 | tag-template: '$RESOLVED_VERSION' 11 | 12 | categories: 13 | - title: 'Features' 14 | labels: 15 | - 'feature' 16 | - 'type:helper' 17 | - 'type:collections' 18 | - 'type:providers' 19 | - 'type:resources' 20 | - 'type:relations' 21 | - 'type:services' 22 | - 'type:interfaces' 23 | - 'type:services' 24 | - title: 'Bug Fixes' 25 | labels: 26 | - 'fix' 27 | - 'bugfix' 28 | - 'bug' 29 | - 'hotfix' 30 | - 'dependencies' 31 | - title: 'Maintenance' 32 | labels: 33 | - 'type:build' 34 | - 'refactoring' 35 | - 'theme:docs' 36 | - 'type:tests' 37 | - 'analysis' 38 | 39 | change-title-escapes: '\<*_&' 40 | 41 | version-resolver: 42 | major: 43 | labels: 44 | - major 45 | - refactoring 46 | minor: 47 | labels: 48 | - feature 49 | - minor 50 | - 'type:helper' 51 | - 'type:collections' 52 | - 'type:providers' 53 | - 'type:resources' 54 | - 'type:relations' 55 | - 'type:services' 56 | - 'type:interfaces' 57 | - 'type:services' 58 | patch: 59 | labels: 60 | - patch 61 | - type:build 62 | - bug 63 | - bugfix 64 | - hotfix 65 | - fix 66 | - theme:documentation 67 | - analysis 68 | default: patch 69 | -------------------------------------------------------------------------------- /tests/functional/Resources/LTreeResourceTest.php: -------------------------------------------------------------------------------- 1 | whereKey([7, 12])->get(), 20 | [ 21 | 'id' => 'desc', 22 | ] 23 | ); 24 | $this->assertSame($resource->toArray(new Request()), [ 25 | [ 26 | 'id' => 11, 27 | 'path' => '11', 28 | 'children' => [ 29 | [ 30 | 'id' => 12, 31 | 'path' => '11.12', 32 | 'children' => [], 33 | ], 34 | ], 35 | ], 36 | [ 37 | 'id' => 1, 38 | 'path' => '1', 39 | 'children' => [ 40 | [ 41 | 'id' => 3, 42 | 'path' => '1.3', 43 | 'children' => [ 44 | [ 45 | 'id' => 7, 46 | 'path' => '1.3.7', 47 | 'children' => [], 48 | ], 49 | ], 50 | ], 51 | ], 52 | ], 53 | ]); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/functional/Types/LTreeTypeTest.php: -------------------------------------------------------------------------------- 1 | type = new LTreeType(); 25 | $this->abstractPlatform = new PostgreSQLPlatform(); 26 | } 27 | 28 | #[Test] 29 | public function getSQLDeclaration(): void 30 | { 31 | $this->assertSame(LTreeType::TYPE_NAME, $this->type->getSQLDeclaration([], $this->abstractPlatform)); 32 | } 33 | 34 | #[Test] 35 | #[DataProvider('providePHPValues')] 36 | public function convertToPHPValue($value, $expected): void 37 | { 38 | $this->assertSame($expected, $this->type->convertToDatabaseValue($value, $this->abstractPlatform)); 39 | } 40 | 41 | public static function provideDatabaseValues(): Generator 42 | { 43 | yield [null, null]; 44 | yield ['1.2.3', [1, 2, 3]]; 45 | yield [1, [1]]; 46 | } 47 | 48 | #[Test] 49 | #[DataProvider('provideDatabaseValues')] 50 | public function convertToDatabaseValue($value, $expected): void 51 | { 52 | $this->assertSame($expected, $this->type->convertToPHPValue($value, $this->abstractPlatform)); 53 | } 54 | 55 | public static function providePHPValues(): Generator 56 | { 57 | yield [null, null]; 58 | yield [1, '1']; 59 | yield [[1], '1']; 60 | yield [[1, 2, 3], '1.2.3']; 61 | } 62 | 63 | #[Test] 64 | public function getTypeName(): void 65 | { 66 | $this->assertSame(LTreeType::TYPE_NAME, $this->type->getName()); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Traits/HasTreeRelationships.php: -------------------------------------------------------------------------------- 1 | belongsToTree(BelongsToAncestorsTree::class, $related, $throwRelation, $foreignKey, $ownerKey); 29 | } 30 | 31 | 32 | final protected function belongsToDescendantsTree( 33 | string $related, 34 | string $throwRelation, 35 | ?string $foreignKey = null, 36 | $ownerKey = null 37 | ) { 38 | return $this->belongsToTree( 39 | BelongsToDescendantsTree::class, 40 | $related, 41 | $throwRelation, 42 | $foreignKey, 43 | $ownerKey 44 | ); 45 | } 46 | 47 | final protected function belongsToTree( 48 | string $relationClass, 49 | string $related, 50 | string $throwRelation, 51 | ?string $foreignKey = null, 52 | $ownerKey = null 53 | ): AbstractBelongsToTree { 54 | $instance = $this->newRelatedInstance($related); 55 | 56 | if (!$instance instanceof LTreeModelInterface) { 57 | throw new InvalidTraitInjectionClass(sprintf( 58 | 'A class using this trait must implement an interface %s', 59 | LTreeModelInterface::class 60 | )); 61 | } 62 | 63 | if ($foreignKey === null) { 64 | $foreignKey = $this 65 | ->{$throwRelation}() 66 | ->getForeignKeyName(); 67 | } 68 | 69 | $ownerKey = $ownerKey ?: $instance->getKeyName(); 70 | 71 | return new $relationClass($instance->newQuery(), $this, $throwRelation, $foreignKey, $ownerKey); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-ltree 2 | 3 | [![Github Status](https://github.com/umbrellio/laravel-ltree/workflows/CI/badge.svg)](https://github.com/umbrellio/laravel-ltree/actions) 4 | [![Coverage Status](https://coveralls.io/repos/github/umbrellio/laravel-ltree/badge.svg?branch=master)](https://coveralls.io/github/umbrellio/laravel-ltree?branch=master) 5 | [![Latest Stable Version](https://poser.pugx.org/umbrellio/laravel-ltree/v/stable.png)](https://packagist.org/packages/umbrellio/laravel-ltree) 6 | [![Total Downloads](https://poser.pugx.org/umbrellio/laravel-ltree/downloads.png)](https://packagist.org/packages/umbrellio/laravel-ltree) 7 | [![Code Intelligence Status](https://scrutinizer-ci.com/g/umbrellio/laravel-ltree/badges/code-intelligence.svg?b=master)](https://scrutinizer-ci.com/code-intelligence) 8 | [![Build Status](https://scrutinizer-ci.com/g/umbrellio/laravel-ltree/badges/build.png?b=master)](https://scrutinizer-ci.com/g/umbrellio/laravel-ltree/build-status/master) 9 | [![Code Coverage](https://scrutinizer-ci.com/g/umbrellio/laravel-ltree/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/umbrellio/laravel-ltree/?branch=master) 10 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/umbrellio/laravel-ltree/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/umbrellio/laravel-ltree/?branch=master) 11 | 12 | LTree Extension (PostgreSQL) for Laravel. 13 | 14 | ## Installation 15 | 16 | Run this command to install: 17 | ```bash 18 | php composer.phar require umbrellio/laravel-ltree 19 | ``` 20 | 21 | ## How to use 22 | 23 | Implement your `Eloquent\Model` from `LTreeModelInterface` and use `LTreeModelTrait`. 24 | 25 | Use LTreeService for build path: 26 | 1. when create model: `createPath(LTreeModelInterface $model)` 27 | 2. when update model: `updatePath(LTreeModelInterface $model)` for update path for model and children 28 | 3. when delete model: `dropDescendants(LTreeModelInterface $model)` for delete children models 29 | 30 | The `get()` method returns `LTreeCollection`, instead of the usual `Eloquent\Collection`. 31 | 32 | `LTreeCollection` has a `toTree()` method that converts a flat collection to a tree. 33 | 34 | `LTreeResourceCollection` & `LTreeResource`, which take `LTreeCollection` as an argument, will also be useful. 35 | 36 | ## Authors 37 | 38 | Created by Korben Dallas. 39 | 40 | 41 | Supported by Umbrellio 42 | 43 | -------------------------------------------------------------------------------- /src/Helpers/LTreeBuilder.php: -------------------------------------------------------------------------------- 1 | pathField = $pathField; 22 | $this->idField = $idField; 23 | $this->parentIdField = $parentIdField; 24 | } 25 | 26 | public function build(LTreeCollection $items, bool $usingSort = true): LTreeNode 27 | { 28 | if ($usingSort === true) { 29 | $items = $items->sortBy($this->pathField, SORT_STRING); 30 | } 31 | 32 | $this->root = new LTreeNode(); 33 | 34 | foreach ($items as $item) { 35 | $node = new LTreeNode($item); 36 | $id = $item->{$this->idField}; 37 | $this->nodes[$id] = $node; 38 | } 39 | 40 | foreach ($items as $item) { 41 | [$id, $parentId, $path] = $this->getNodeIds($item); 42 | $node = $this->nodes[$id]; 43 | $parentNode = $this->getNode($id, $path, $parentId); 44 | $parentNode->addChild($node); 45 | } 46 | return $this->root; 47 | } 48 | 49 | private function getNodeIds($item): array 50 | { 51 | $parentId = $item->{$this->parentIdField}; 52 | $id = $item->{$this->idField}; 53 | $path = $item->{$this->pathField}; 54 | 55 | if ($id === $parentId) { 56 | throw new LTreeReflectionException($id); 57 | } 58 | return [$id, $parentId, $path]; 59 | } 60 | 61 | private function getNode(int $id, string $path, ?int $parentId): LTreeNode 62 | { 63 | if ($parentId === null || $this->hasNoMissingNodes($id, $path)) { 64 | return $this->root; 65 | } 66 | if (!isset($this->nodes[$parentId])) { 67 | throw new LTreeUndefinedNodeException($parentId); 68 | } 69 | return $this->nodes[$parentId]; 70 | } 71 | 72 | private function hasNoMissingNodes(int $id, string $path): bool 73 | { 74 | $subpath = substr($path, 0, -strlen(".{$id}")); 75 | $subpathIds = explode('.', $subpath); 76 | 77 | $missingNodes = 0; 78 | foreach ($subpathIds as $parentId) { 79 | if (!isset($this->nodes[$parentId])) { 80 | $missingNodes++; 81 | } 82 | } 83 | 84 | return $subpathIds > 0 && $missingNodes === count($subpathIds); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Collections/LTreeCollection.php: -------------------------------------------------------------------------------- 1 | first()) { 27 | return new LTreeNode(); 28 | } 29 | 30 | if ($loadMissing) { 31 | $this->loadMissingNodes($model); 32 | } 33 | 34 | if (!$this->withLeaves) { 35 | $this->excludeLeaves(); 36 | } 37 | 38 | $builder = new LTreeBuilder( 39 | $model->getLtreePathColumn(), 40 | $model->getKeyName(), 41 | $model->getLtreeParentColumn() 42 | ); 43 | 44 | return $builder->build($collection ?? $this, $usingSort); 45 | } 46 | 47 | public function withLeaves(bool $state = true): self 48 | { 49 | $this->withLeaves = $state; 50 | 51 | return $this; 52 | } 53 | 54 | public function loadMissingNodes($model): self 55 | { 56 | if ($this->hasMissingNodes($model)) { 57 | $this->appendAncestors($model); 58 | } 59 | 60 | return $this; 61 | } 62 | 63 | private function excludeLeaves(): void 64 | { 65 | foreach ($this->items as $key => $item) { 66 | /** @var LTreeModelTrait $item */ 67 | if ($item->ltreeChildren->isEmpty() && $item->getLtreeParentId()) { 68 | $this->forget($key); 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * @param LTreeInterface|ModelInterface $model 75 | */ 76 | private function hasMissingNodes($model): bool 77 | { 78 | $paths = collect(); 79 | 80 | foreach ($this->items as $item) { 81 | $paths = $paths->merge($item->getLtreePath()); 82 | } 83 | 84 | return $paths 85 | ->unique() 86 | ->diff($this->pluck($model->getKeyName())) 87 | ->isNotEmpty(); 88 | } 89 | 90 | /** 91 | * @param LTreeInterface|ModelInterface $model 92 | */ 93 | private function appendAncestors($model): void 94 | { 95 | $paths = $this 96 | ->pluck($model->getLtreePathColumn()) 97 | ->toArray(); 98 | $ids = $this 99 | ->pluck($model->getKeyName()) 100 | ->toArray(); 101 | 102 | /** @var Model $model */ 103 | $parents = $model::parentsOf($paths) 104 | ->whereKeyNot($ids) 105 | ->get(); 106 | 107 | foreach ($parents as $item) { 108 | $this->add($item); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Helpers/LTreeHelper.php: -------------------------------------------------------------------------------- 1 | getLtreeParentId()) { 22 | $parent = $model->ltreeParent; 23 | $pathValue = array_merge($pathValue, $parent->getLtreePath()); 24 | } 25 | $pathValue[] = $model->getKey(); 26 | DB::statement(sprintf( 27 | "UPDATE %s SET %s = text2ltree('%s') WHERE %s = %s", 28 | $model->getTable(), 29 | $model->getLtreePathColumn(), 30 | implode(LTreeType::TYPE_SEPARATE, $pathValue), 31 | $model->getKeyName(), 32 | $model->getKey() 33 | )); 34 | $model->refresh(); 35 | } 36 | 37 | /** 38 | * @param LTreeInterface|Model $model 39 | * @param LTreeInterface|Model|null $to 40 | */ 41 | public function moveNode($model, $to = null, array $columns = []): void 42 | { 43 | $pathName = $model->getLtreePathColumn(); 44 | $oldPath = $model->getLtreePath(LTreeModelInterface::AS_STRING); 45 | $newPath = $to ? $to->getLtreePath(LTreeModelInterface::AS_STRING) : ''; 46 | $expressions = static::wrapExpressions($columns); 47 | $expressions[] = " 48 | \"${pathName}\" = (text2ltree('${newPath}') || subpath(\"${pathName}\", (nlevel(text2ltree('${oldPath}')) - 1))) 49 | "; 50 | 51 | DB::statement(sprintf( 52 | "UPDATE %s SET %s WHERE (%s <@ text2ltree('%s')) = true", 53 | $model->getTable(), 54 | implode(', ', $expressions), 55 | $pathName, 56 | $oldPath 57 | )); 58 | $model->refresh(); 59 | } 60 | 61 | /** 62 | * @param LTreeInterface|Model $model 63 | */ 64 | public function dropDescendants($model, array $columns = []): void 65 | { 66 | $sql = sprintf( 67 | "UPDATE %s SET %s WHERE (%s <@ text2ltree('%s')) = true", 68 | $model->getTable(), 69 | implode(', ', static::wrapExpressions($columns)), 70 | $model->getLtreePathColumn(), 71 | $model->getLtreePath(LTreeModelInterface::AS_STRING) 72 | ); 73 | DB::statement($sql); 74 | $model->refresh(); 75 | } 76 | 77 | private function wrapExpressions(array $columns): array 78 | { 79 | $expressions = []; 80 | foreach ($columns as $column => $value) { 81 | switch (true) { 82 | case $value === null: 83 | $expressions[] = sprintf('%s = null', (string) $column); 84 | break; 85 | case is_string($value): 86 | $expressions[] = sprintf("%s = '%s'", (string) $column, (string) $value); 87 | break; 88 | default: 89 | $expressions[] = sprintf('%s = %s', (string) $column, (string) $value); 90 | } 91 | } 92 | return $expressions; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/functional/Models/LTreeModelTest.php: -------------------------------------------------------------------------------- 1 | assertSame($level, $this ->findNodeByPath($path) ->getLtreeLevel()); 20 | } 21 | 22 | public static function provideLevels(): Generator 23 | { 24 | yield 'root' => [ 25 | 'path' => '1', 26 | 'level' => 1, 27 | ]; 28 | yield 'second-level' => [ 29 | 'path' => '1.2', 30 | 'level' => 2, 31 | ]; 32 | yield 'third-level' => [ 33 | 'path' => '1.2.5', 34 | 'level' => 3, 35 | ]; 36 | } 37 | 38 | #[Test] 39 | #[DataProvider('providePaths')] 40 | public function parentsOf(array $paths, int $expectedCount): void 41 | { 42 | $this->assertCount($expectedCount, CategoryStub::parentsOf($paths)->get()); 43 | } 44 | 45 | public static function providePaths(): Generator 46 | { 47 | yield 'single_as_array' => [ 48 | 'paths' => ['11.12'], 49 | 'expectedCount' => 2, 50 | ]; 51 | yield 'all_as_array' => [ 52 | 'paths' => ['11.12', '1.2.5'], 53 | 'expectedCount' => 5, 54 | ]; 55 | } 56 | 57 | #[Test] 58 | public function root(): void 59 | { 60 | $node = $this->findNodeByPath('1.2.5'); 61 | $roots = $node::root()->get(); 62 | foreach ($roots as $root) { 63 | $this->assertNull($root->parent_id); 64 | } 65 | } 66 | 67 | #[Test] 68 | public function ancestors(): void 69 | { 70 | $root = $this->getRoot(); 71 | $node6 = $this->findNodeByPath('1.3.6'); 72 | $node2 = $this->findNodeByPath('1.2'); 73 | $this->assertSame(3, $root::ancestorsOf($node6)->get()->count()); 74 | $this->assertTrue($node2->isParentOf(5)); 75 | } 76 | 77 | #[Test] 78 | public function getAncestorByLevel(): void 79 | { 80 | $root = $this->getRoot(); 81 | $node2 = $this->findNodeByPath('1.2'); 82 | $node5 = $this->findNodeByPath('1.2.5'); 83 | $node6 = $this->findNodeByPath('1.3.6'); 84 | $node8 = $this->findNodeByPath('1.3.6.8'); 85 | $descendants = $root::descendantsOf($root)->withoutSelf(1); 86 | $this->assertGreaterThan(0, $descendants->count()); 87 | $descendants->each(static function ($descendant) use ($root) { 88 | $descendant->getAncestorByLevel($root->getKey()); 89 | }); 90 | 91 | $this->assertSame($node5->getAncestorByLevel(2)->getKey(), $node2->getKey()); 92 | $this->assertSame($node8->getAncestorByLevel(3)->getKey(), $node6->getKey()); 93 | } 94 | 95 | #[Test] 96 | public function children(): void 97 | { 98 | $node11 = $this->findNodeByPath('11'); 99 | 100 | $this->assertSame(1, $node11->ltreeChildren->count()); 101 | $this->assertSame(12, $node11->ltreeChildren->first()->getKey()); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at pvsaintpe@umbrellio.biz. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /src/Traits/LTreeModelTrait.php: -------------------------------------------------------------------------------- 1 | belongsTo(static::class, $this->getLtreeParentColumn()); 33 | } 34 | 35 | public function ltreeChildren(): HasMany 36 | { 37 | return $this->hasMany(static::class, $this->getLtreeParentColumn()); 38 | } 39 | 40 | public function isParentOf(int $id): bool 41 | { 42 | return self::descendantsOf($this)->withoutSelf($this->getKey())->find($id) !== null; 43 | } 44 | 45 | public function scopeParentsOf(Builder $query, array $paths): Builder 46 | { 47 | return $query->whereRaw(sprintf( 48 | "%s @> array['%s']::ltree[]", 49 | $this->getLtreePathColumn(), 50 | implode("', '", $paths) 51 | )); 52 | } 53 | 54 | public function scopeRoot(Builder $query): Builder 55 | { 56 | return $query->whereNull($this->getLtreeParentColumn()); 57 | } 58 | 59 | public function scopeDescendantsOf(Builder $query, LTreeModelInterface $model, bool $reverse = true): Builder 60 | { 61 | return $query->whereRaw(sprintf( 62 | "({$this->getLtreePathColumn()} <@ text2ltree('%s')) = %s", 63 | $model->getLtreePath(LTreeModelInterface::AS_STRING), 64 | $reverse ? 'true' : 'false' 65 | )); 66 | } 67 | 68 | public function scopeAncestorsOf(Builder $query, LTreeModelInterface $model, bool $reverse = true): Builder 69 | { 70 | return $query->whereRaw(sprintf( 71 | "({$this->getLtreePathColumn()} @> text2ltree('%s')) = %s", 72 | $model->getLtreePath(LTreeModelInterface::AS_STRING), 73 | $reverse ? 'true' : 'false' 74 | )); 75 | } 76 | 77 | public function scopeWithoutSelf(Builder $query, int $id): Builder 78 | { 79 | return $query->whereRaw(sprintf('%s <> %s', $this->getKeyName(), $id)); 80 | } 81 | 82 | public function getLtreeProxyUpdateColumns(): array 83 | { 84 | return [$this->getUpdatedAtColumn()]; 85 | } 86 | 87 | public function getLtreeProxyDeleteColumns(): array 88 | { 89 | return [$this->getDeletedAtColumn()]; 90 | } 91 | 92 | public function getAncestorByLevel(int $level = 1) 93 | { 94 | return static::ancestorByLevel($level)->first(); 95 | } 96 | 97 | public function scopeAncestorByLevel(Builder $query, int $level = 1, ?string $path = null): Builder 98 | { 99 | return $query->whereRaw(sprintf( 100 | "({$this->getLtreePathColumn()} @> text2ltree('%s')) and nlevel({$this->getLtreePathColumn()}) = %d", 101 | $path ?: $this->getLtreePath(LTreeModelInterface::AS_STRING), 102 | $level 103 | )); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/functional/Helpers/LTreeHelperTest.php: -------------------------------------------------------------------------------- 1 | createCategory([ 18 | 'id' => 15, 19 | 'path' => null, 20 | 'parent_id' => null, 21 | ]); 22 | $this->assertSame([], $node->getLtreePath()); 23 | $this->ltreeService->createPath($node); 24 | $this->assertSame('15', $node->getLtreePath(LTreeModelInterface::AS_STRING)); 25 | } 26 | 27 | #[Test] 28 | public function moveSubtrees(): void 29 | { 30 | $nodes = $this->getCategories(); 31 | $root = $this->getRoot(); 32 | /** @var CategoryStub $someNode */ 33 | $someNode = $nodes->find(11); 34 | $parentColumn = $root->getLtreeParentColumn(); 35 | $this->assertSame(1, $root::descendantsOf($someNode)->withoutSelf(11)->count()); 36 | $root->update([ 37 | $parentColumn => 11, 38 | ]); 39 | $this->ltreeService->updatePath($root); 40 | $this->assertSame(11, $root::descendantsOf($someNode)->withoutSelf(11)->count()); 41 | $this->assertSame(11, $root->getLtreeParentId()); 42 | } 43 | 44 | #[Test] 45 | public function proxyColumns(): void 46 | { 47 | $nodeMoscow = $this->findNodeByPath('1.3'); 48 | $nodeRussia = $this->findNodeByPath('1'); 49 | 50 | $this->assertSame('Moscow', $nodeMoscow->name); 51 | 52 | $nodeRussia->name = 'New Russia'; 53 | $nodeRussia->save(); 54 | $this->ltreeService->updatePath($nodeRussia); 55 | 56 | $nodeMoscow->refresh(); 57 | $this->assertSame('New Russia', $nodeMoscow->name); 58 | 59 | $nodeRussia->name = null; 60 | $nodeRussia->save(); 61 | $this->ltreeService->updatePath($nodeRussia); 62 | 63 | $nodeMoscow->refresh(); 64 | $this->assertNull($nodeMoscow->name); 65 | } 66 | 67 | #[Test] 68 | public function deleteRoot(): void 69 | { 70 | $root = $this->getRoot(); 71 | 72 | $this->assertTrue($root::descendantsOf($root)->exists()); 73 | $root::descendantsOf($root)->delete(); 74 | $this->assertFalse($root::descendantsOf($root)->exists()); 75 | } 76 | 77 | #[Test] 78 | public function deleteSubtree(): void 79 | { 80 | $root = $this->getRoot(); 81 | 82 | $this->assertSame(9, $root::descendantsOf($root)->withoutSelf(1)->count()); 83 | $root::descendantsOf($root)->withoutSelf(1)->delete(); 84 | $this->assertFalse($root::descendantsOf($root)->withoutSelf(1)->exists()); 85 | $this->assertSame(1, $root::descendantsOf($root)->count()); 86 | } 87 | 88 | #[Test] 89 | public function deleteViaServiceSubtree(): void 90 | { 91 | $root = $this->getRoot(); 92 | 93 | $this->assertSame(9, $root::descendantsOf($root)->withoutSelf(1)->count()); 94 | $root->update([ 95 | 'is_deleted' => 1, 96 | ]); 97 | $root->delete(); 98 | $root->refresh(); 99 | $this->ltreeService->dropDescendants($root); 100 | $this->assertFalse($root::whereKey($root->getKey())->exists()); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/_data/Traits/HasLTreeTables.php: -------------------------------------------------------------------------------- 1 | createCategory([ 28 | 'id' => 13, 29 | 'parent_id' => 13, 30 | 'path' => '13.13', 31 | 'name' => 'Self parent', 32 | ]); 33 | return $this->getCategories(); 34 | } 35 | 36 | protected function getCategoriesWithUnknownParent(): LTreeCollection 37 | { 38 | CategoryStub::query()->find(3)->delete(); 39 | return $this->getCategories(); 40 | } 41 | 42 | protected function getCategories(): LTreeCollection 43 | { 44 | return CategoryStub::query()->orderBy('name')->get(); 45 | } 46 | 47 | protected function getRandomCategories(): LTreeCollection 48 | { 49 | return CategoryStub::query()->inRandomOrder()->get(); 50 | } 51 | 52 | protected function createCategory(array $attributes): LTreeModelInterface 53 | { 54 | $model = new CategoryStub(); 55 | $model->fill($attributes); 56 | $model->save(); 57 | 58 | return $model; 59 | } 60 | 61 | protected function createProduct(array $attributes): LTreeModelInterface 62 | { 63 | $model = new ProductStub(); 64 | $model->fill($attributes); 65 | $model->save(); 66 | 67 | return $model; 68 | } 69 | 70 | protected function getRoot(): CategoryStub 71 | { 72 | return $this 73 | ->getCategories() 74 | ->find(1); 75 | } 76 | 77 | protected function findNodeByPath(string $path): CategoryStub 78 | { 79 | return $this 80 | ->getCategories() 81 | ->where('path', $path) 82 | ->first(); 83 | } 84 | 85 | private function initLTreeService(): void 86 | { 87 | DB::statement('CREATE EXTENSION IF NOT EXISTS LTREE'); 88 | Schema::create('categories', function (Blueprint $table) { 89 | $table->bigIncrements('id'); 90 | $table->bigInteger('parent_id') 91 | ->nullable(); 92 | $table->ltree('path') 93 | ->nullable(); 94 | $table->index('parent_id'); 95 | $table->timestamps(6); 96 | $table->string('name') 97 | ->nullable(); 98 | $table->softDeletes(); 99 | $table->tinyInteger('is_deleted') 100 | ->unsigned() 101 | ->default(1); 102 | $table->unique('path'); 103 | }); 104 | Schema::create('products', function (Blueprint $table) { 105 | $table->bigIncrements('id'); 106 | $table->bigInteger('category_id') 107 | ->nullable(); 108 | $table->timestamps(6); 109 | 110 | $table->foreign('category_id') 111 | ->on('categories') 112 | ->references('id'); 113 | }); 114 | DB::statement("COMMENT ON COLUMN categories.path IS '(DC2Type:ltree)'"); 115 | $this->ltreeService = app() 116 | ->make(LTreeServiceInterface::class); 117 | } 118 | 119 | private function initLTreeCategories(): void 120 | { 121 | foreach ($this->getTreeNodes() as $data) { 122 | $this->createCategory([ 123 | 'id' => $data[0], 124 | 'path' => $data[1], 125 | 'parent_id' => $data[2], 126 | 'name' => $data[3], 127 | ]); 128 | } 129 | } 130 | 131 | private function getTreeNodes(): array 132 | { 133 | return [ 134 | 1 => [1, '1', null, 'Russia'], 135 | 2 => [2, '1.2', 1, 'Saint-Petersburg'], 136 | 5 => [5, '1.2.5', 2, 'Gatchina'], 137 | 3 => [3, '1.3', 1, 'Moscow'], 138 | 6 => [6, '1.3.6', 3, 'Kazan'], 139 | 8 => [8, '1.3.6.8', 6, 'Tver'], 140 | 9 => [9, '1.3.6.9', 6, 'Romanovo'], 141 | 10 => [10, '1.3.6.10', 6, 'Sheremetevo'], 142 | 7 => [7, '1.3.7', 3, 'Rublevka'], 143 | 4 => [4, '1.4', 1, 'Omsk'], 144 | 11 => [11, '11', null, 'Britain'], 145 | 12 => [12, '11.12', 11, 'London'], 146 | ]; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Relations/AbstractBelongsToTree.php: -------------------------------------------------------------------------------- 1 | throughRelationName = $throughRelationName; 29 | $this->foreignKey = $foreignKey; 30 | $this->ownerKey = $ownerKey; 31 | 32 | parent::__construct($query, $child); 33 | } 34 | 35 | public function addConstraints(): void 36 | { 37 | if (static::$constraints) { 38 | /** @var Model $relation */ 39 | $relation = $this->parent->{$this->throughRelationName}; 40 | 41 | if ($relation) { 42 | $this->query = $this 43 | ->modifyQuery($relation->newQuery(), $relation) 44 | ->orderBy($this->getLTreeRelated()->getLtreePathColumn()); 45 | } 46 | } 47 | } 48 | 49 | public function addEagerConstraints(array $models): void 50 | { 51 | $key = $this->related->getTable() . '.' . $this->ownerKey; 52 | 53 | $whereIn = $this->whereInMethod($this->related, $this->ownerKey); 54 | 55 | $this->query->{$whereIn}($key, $this->getEagerModelKeys($models)); 56 | 57 | $table = $this 58 | ->getModel() 59 | ->getTable(); 60 | $alias = sprintf('%s_depends', $table); 61 | 62 | $related = $this->getLTreeRelated(); 63 | 64 | $this->query->join( 65 | sprintf('%s as %s', $table, $alias), 66 | function (JoinClause $query) use ($alias, $table, $related) { 67 | $query->whereRaw(sprintf( 68 | '%1$s.%2$s %4$s %3$s.%2$s', 69 | $alias, 70 | $related->getLtreePathColumn(), 71 | $table, 72 | $this->getOperator() 73 | )); 74 | } 75 | ); 76 | 77 | $this->query->orderBy($related->getLtreePathColumn()); 78 | 79 | $this->query->selectRaw(sprintf('%s.*, %s.%s as relation_id', $alias, $table, $this->ownerKey)); 80 | } 81 | 82 | public function match(array $models, Collection $results, $relation) 83 | { 84 | $dictionary = []; 85 | 86 | foreach ($results as $result) { 87 | $dictionary[$result->relation_id][] = $result; 88 | } 89 | 90 | foreach ($models as $model) { 91 | foreach ($dictionary as $related => $value) { 92 | if ($model->getAttribute($this->foreignKey) === $related) { 93 | $model->setRelation($relation, $this->related->newCollection($value)); 94 | } 95 | } 96 | } 97 | 98 | return $models; 99 | } 100 | 101 | public function getResults() 102 | { 103 | return $this->getParentKey() !== null 104 | ? $this->query->get() 105 | : $this->related->newCollection(); 106 | } 107 | 108 | 109 | /** 110 | * Initialize the relation on a set of models. 111 | * 112 | * @param string $relation 113 | * @return array 114 | */ 115 | public function initRelation(array $models, $relation) 116 | { 117 | foreach ($models as $model) { 118 | $model->setRelation($relation, $this->related->newCollection()); 119 | } 120 | 121 | return $models; 122 | } 123 | 124 | /** 125 | * @param Builder|LTreeModelTrait $query 126 | */ 127 | abstract protected function modifyQuery($query, Model $model): Builder; 128 | 129 | abstract protected function getOperator(): string; 130 | 131 | protected function getEagerModelKeys(array $models) 132 | { 133 | $keys = []; 134 | 135 | foreach ($models as $model) { 136 | if (($value = $model->{$this->foreignKey}) !== null) { 137 | $keys[] = $value; 138 | } 139 | } 140 | 141 | sort($keys); 142 | 143 | return array_values(array_unique($keys)); 144 | } 145 | 146 | private function getLTreeRelated(): LTreeModelInterface 147 | { 148 | return $this 149 | ->parent 150 | ->{$this->throughRelationName}() 151 | ->related; 152 | } 153 | 154 | private function getParentKey() 155 | { 156 | return $this->parent->{$this->foreignKey}; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/Helpers/LTreeNode.php: -------------------------------------------------------------------------------- 1 | model === null; 31 | } 32 | 33 | public function getParent(): ?self 34 | { 35 | return $this->parent; 36 | } 37 | 38 | public function setParent(?self $parent): void 39 | { 40 | $this->parent = $parent; 41 | } 42 | 43 | public function addChild(self $node): void 44 | { 45 | $this 46 | ->getChildren() 47 | ->add($node); 48 | $node->setParent($this); 49 | } 50 | 51 | public function getChildren(): Collection 52 | { 53 | if (!$this->children) { 54 | $this->children = new Collection(); 55 | } 56 | return $this->children; 57 | } 58 | 59 | public function countDescendants(): int 60 | { 61 | return $this 62 | ->getChildren() 63 | ->reduce( 64 | static function (int $count, self $node) { 65 | return $count + $node->countDescendants(); 66 | }, 67 | $this 68 | ->getChildren() 69 | ->count() 70 | ); 71 | } 72 | 73 | public function findInTree(int $id): ?self 74 | { 75 | if (!$this->isRoot() && $this->model->getKey() === $id) { 76 | return $this; 77 | } 78 | foreach ($this->getChildren() as $child) { 79 | $result = $child->findInTree($id); 80 | if ($result !== null) { 81 | return $result; 82 | } 83 | } 84 | return null; 85 | } 86 | 87 | public function each(callable $callback): void 88 | { 89 | if (!$this->isRoot()) { 90 | $callback($this); 91 | } 92 | $this 93 | ->getChildren() 94 | ->each(static function (self $node) use ($callback) { 95 | $node->each($callback); 96 | }); 97 | } 98 | 99 | public function toCollection(): LTreeCollection 100 | { 101 | $collection = new LTreeCollection(); 102 | $this->each(static function (self $item) use ($collection) { 103 | $collection->add($item->model); 104 | }); 105 | return $collection; 106 | } 107 | 108 | public function pathAsString() 109 | { 110 | return $this->model ? $this->model->getLtreePath(LTreeInterface::AS_STRING) : null; 111 | } 112 | 113 | public function toTreeArray(callable $callback) 114 | { 115 | return $this->fillTreeArray($this->getChildren(), $callback); 116 | } 117 | 118 | /** 119 | * Usage sortTree(['name' =>'asc', 'category'=>'desc']) or callback with arguments ($a, $b) and return -1 | 0 | 1 120 | * 121 | * @param array|callable $options 122 | */ 123 | public function sortTree($options) 124 | { 125 | $children = $this->getChildren(); 126 | $callback = $options; 127 | if (!is_callable($options)) { 128 | $callback = $this->optionsToCallback($options); 129 | } 130 | $children->each(static function ($child) use ($callback) { 131 | /** @var LTreeNode $child */ 132 | $child->sortTree($callback); 133 | }); 134 | $this->children = $children 135 | ->sort($callback) 136 | ->values(); 137 | } 138 | 139 | private function fillTreeArray(iterable $nodes, callable $callback) 140 | { 141 | $data = []; 142 | foreach ($nodes as $node) { 143 | $item = $callback($node); 144 | $children = $this->fillTreeArray($node->getChildren(), $callback); 145 | $item['children'] = $children; 146 | $data[] = $item; 147 | } 148 | return $data; 149 | } 150 | 151 | private function optionsToCallback(array $options): callable 152 | { 153 | return function ($a, $b) use ($options) { 154 | foreach ($options as $property => $sort) { 155 | if (!in_array(strtolower($sort), ['asc', 'desc'], true)) { 156 | throw new InvalidArgumentException("Order '${sort}'' must be asc or desc"); 157 | } 158 | $order = strtolower($sort) === 'desc' ? -1 : 1; 159 | $result = $a->{$property} <=> $b->{$property}; 160 | if ($result !== 0) { 161 | return $result * $order; 162 | } 163 | } 164 | return 0; 165 | }; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tests/functional/Collections/LTreeCollectionTest.php: -------------------------------------------------------------------------------- 1 | getCategories() 22 | ->toTree(); 23 | $this->assertSame(2, $tree->getChildren()->count()); 24 | $this->assertSame(3, $tree->getChildren()[0]->getChildren()->count()); 25 | $this->assertSame(1, $tree->getChildren()->find(11)->getChildren()->count()); 26 | $this->assertSame($tree, $tree->getChildren()[0]->getParent()); 27 | } 28 | 29 | #[Test] 30 | #[DataProvider('provideNoConstencyTree')] 31 | public function loadMissingNodes(array $items, array $expected): void 32 | { 33 | $this->assertSame( 34 | CategoryStub::query() 35 | ->whereKey($items) 36 | ->get() 37 | ->toTree() 38 | ->toCollection() 39 | ->sortBy(function (LTreeModelInterface $item) { 40 | return $item->getKey(); 41 | }) 42 | ->pluck('id') 43 | ->toArray(), 44 | $expected 45 | ); 46 | } 47 | 48 | #[Test] 49 | #[DataProvider('providePartialConstencyTree')] 50 | public function withoutLoadMissingForPartialTree(array $items, array $expected): void 51 | { 52 | $this->assertSame( 53 | CategoryStub::query() 54 | ->whereKey($items) 55 | ->get() 56 | ->toTree(true, false) 57 | ->toCollection() 58 | ->sortBy(function (LTreeModelInterface $item) { 59 | return $item->getKey(); 60 | }) 61 | ->pluck('id') 62 | ->toArray(), 63 | $expected 64 | ); 65 | } 66 | 67 | public static function provideTreeWithoutLeaves(): Generator 68 | { 69 | yield 'without_leaves' => [ 70 | 'items' => [10, 7, 12], 71 | 'expected' => [1, 3, 6, 11], 72 | ]; 73 | } 74 | 75 | #[Test] 76 | #[DataProvider('provideTreeWithoutLeaves')] 77 | public function withoutLeaves(array $items, array $expected): void 78 | { 79 | $this->assertSame( 80 | CategoryStub::query() 81 | ->whereKey($items) 82 | ->get() 83 | ->withLeaves(false) 84 | ->toTree() 85 | ->toCollection() 86 | ->sortBy(function (LTreeModelInterface $item) { 87 | return $item->getKey(); 88 | }) 89 | ->pluck('id') 90 | ->toArray(), 91 | $expected 92 | ); 93 | } 94 | 95 | public static function provideNoConstency(): Generator 96 | { 97 | yield 'non_consistent_without_loading' => [ 98 | 'items' => [1, 6, 8], 99 | 'expected' => [1, 6, 8], 100 | 'loadMissing' => false, 101 | ]; 102 | } 103 | 104 | #[Test] 105 | #[DataProvider('provideNoConstency')] 106 | public function withoutLoadMissingNodes(array $items, array $expected, bool $loadMissing): void 107 | { 108 | $this->expectException(LTreeUndefinedNodeException::class); 109 | $this->assertSame( 110 | CategoryStub::query() 111 | ->whereKey($items) 112 | ->get() 113 | ->toTree(true, false) 114 | ->toCollection() 115 | ->sortBy(function (LTreeModelInterface $item) { 116 | return $item->getKey(); 117 | }) 118 | ->pluck('id') 119 | ->toArray(), 120 | $expected 121 | ); 122 | } 123 | 124 | public static function provideNoConstencyTree(): Generator 125 | { 126 | yield 'non_consistent_with_loading' => [ 127 | 'items' => [7, 3, 12], 128 | 'expected' => [1, 3, 7, 11, 12], 129 | ]; 130 | yield 'consistent' => [ 131 | 'items' => [1, 3, 7], 132 | 'expected' => [1, 3, 7], 133 | ]; 134 | } 135 | public static function providePartialConstencyTree(): Generator 136 | { 137 | yield 'partial with single branch without single nodes' => [ 138 | 'items' => [3, 6, 7, 8, 9, 10], 139 | 'expected' => [3, 6, 7, 8, 9, 10], 140 | ]; 141 | yield 'partial with single branch without more nodes' => [ 142 | 'items' => [6, 8, 9, 10], 143 | 'expected' => [6, 8, 9, 10], 144 | ]; 145 | yield 'partial with more branches' => [ 146 | 'items' => [6, 8, 9, 10, 11, 12], 147 | 'expected' => [6, 8, 9, 10, 11, 12], 148 | ]; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /tests/functional/Relations/BelongsToTreelTest.php: -------------------------------------------------------------------------------- 1 | [ 20 | 'path' => '11.12', 21 | 'count' => 2, 22 | 'level1' => 11, 23 | 'level2' => 12, 24 | 'level3' => null, 25 | ]; 26 | yield 'three_levels' => [ 27 | 'path' => '1.2.5', 28 | 'count' => 3, 29 | 'level1' => 1, 30 | 'level2' => 2, 31 | 'level3' => 5, 32 | ]; 33 | } 34 | 35 | public static function provideBelongsDescendantsTree(): Generator 36 | { 37 | yield 'with_descendants' => [ 38 | 'path' => '1.3', 39 | 'count' => 6, 40 | 'level1' => 3, 41 | 'level2' => 6, 42 | 'level3' => 10, 43 | 'level4' => 8, 44 | 'level5' => 9, 45 | 'level6' => 7, 46 | ]; 47 | } 48 | 49 | #[Test] 50 | #[DataProvider('provideBelongsParentsTree')] 51 | public function getBelongsToParentsTree($path, $count, $level1, $level2, $level3) 52 | { 53 | $product = $this->createProduct([]); 54 | $product 55 | ->category() 56 | ->associate($this->findNodeByPath($path)); 57 | $product->save(); 58 | 59 | $item = ProductStub::query()->first(); 60 | $this->assertFalse(array_key_exists('category_ancestors_tree', $item->toArray())); 61 | $this->assertSame($level1, optional($item->categoryAncestorsTree->get(0))->getKey()); 62 | $this->assertSame($level2, optional($item->categoryAncestorsTree->get(1))->getKey()); 63 | $this->assertSame($level3, optional($item->categoryAncestorsTree->get(2))->getKey()); 64 | 65 | $itemWith = ProductStub::with('categoryAncestorsTree')->first(); 66 | $this->assertTrue(array_key_exists('category_ancestors_tree', $itemWith->toArray())); 67 | $this->assertSame($count, $itemWith->categoryAncestorsTree->count()); 68 | $this->assertSame($level1, optional($itemWith->categoryAncestorsTree->get(0))->getKey()); 69 | $this->assertSame($level2, optional($itemWith->categoryAncestorsTree->get(1))->getKey()); 70 | $this->assertSame($level3, optional($itemWith->categoryAncestorsTree->get(2))->getKey()); 71 | } 72 | 73 | #[Test] 74 | #[DataProvider('provideBelongsDescendantsTree')] 75 | public function getBelongsToDescendantsTree($path, $count, $level1, $level2, $level3, $level4, $level5, $level6) 76 | { 77 | $product = $this->createProduct([]); 78 | $product 79 | ->category() 80 | ->associate($this->findNodeByPath($path)); 81 | $product->save(); 82 | 83 | $item = ProductStub::query()->first(); 84 | $this->assertFalse(array_key_exists('category_descendants_tree', $item->toArray())); 85 | $this->assertSame($level1, optional($item->categoryDescendantsTree->get(0))->getKey()); 86 | $this->assertSame($level2, optional($item->categoryDescendantsTree->get(1))->getKey()); 87 | $this->assertSame($level3, optional($item->categoryDescendantsTree->get(2))->getKey()); 88 | $this->assertSame($level4, optional($item->categoryDescendantsTree->get(3))->getKey()); 89 | $this->assertSame($level5, optional($item->categoryDescendantsTree->get(4))->getKey()); 90 | $this->assertSame($level6, optional($item->categoryDescendantsTree->get(5))->getKey()); 91 | 92 | $itemWith = ProductStub::with('categoryDescendantsTree')->first(); 93 | 94 | $this->assertTrue(array_key_exists('category_descendants_tree', $itemWith->toArray())); 95 | $this->assertSame($count, $itemWith->categoryDescendantsTree->count()); 96 | $this->assertSame($level1, optional($itemWith->categoryDescendantsTree->get(0))->getKey()); 97 | $this->assertSame($level2, optional($itemWith->categoryDescendantsTree->get(1))->getKey()); 98 | $this->assertSame($level3, optional($itemWith->categoryDescendantsTree->get(2))->getKey()); 99 | $this->assertSame($level4, optional($itemWith->categoryDescendantsTree->get(3))->getKey()); 100 | $this->assertSame($level5, optional($itemWith->categoryDescendantsTree->get(4))->getKey()); 101 | $this->assertSame($level6, optional($itemWith->categoryDescendantsTree->get(5))->getKey()); 102 | } 103 | 104 | #[Test] 105 | public function missingParentsLtreeModel(): void 106 | { 107 | $rootSome = $this->getCategorySome([ 108 | 'id' => 16, 109 | 'path' => '16', 110 | 'parent_id' => null, 111 | ]); 112 | $rootSome->save(); 113 | 114 | $childSome = $this->getCategorySome([ 115 | 'id' => 17, 116 | 'path' => '16.17', 117 | 'parent_id' => $rootSome->getKey(), 118 | ]); 119 | $childSome->save(); 120 | 121 | $this->expectException(InvalidTraitInjectionClass::class); 122 | $childSome->parentAncestorsTree(); 123 | } 124 | 125 | public function missingDescendantsLtreeModel(): void 126 | { 127 | $rootSome = $this->getCategorySome([ 128 | 'id' => 16, 129 | 'path' => '16', 130 | 'parent_id' => null, 131 | ]); 132 | $rootSome->save(); 133 | 134 | $childSome = $this->getCategorySome([ 135 | 'id' => 17, 136 | 'path' => '16.17', 137 | 'parent_id' => $rootSome->getKey(), 138 | ]); 139 | $childSome->save(); 140 | 141 | $this->expectException(InvalidTraitInjectionClass::class); 142 | $rootSome->parentDescendantsTree(); 143 | } 144 | 145 | private function getCategorySome(array $data = []): CategorySomeStub 146 | { 147 | return new CategorySomeStub($data); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: 9 | - opened 10 | - reopened 11 | - edited 12 | - synchronize 13 | 14 | env: 15 | COVERAGE: '1' 16 | php_extensions: 'apcu, bcmath, ctype, curl, dom, iconv, intl, json, mbstring, opcache, openssl, pdo, pdo_pgsql, pcntl, pcov, posix, redis, session, simplexml, sockets, tokenizer, xml, xmlwriter, zip, xdebug' 17 | key: cache-v0.1 18 | DB_USER: 'postgres' 19 | DB_NAME: 'postgres' 20 | DB_PASSWORD: 'postgres' 21 | DB_HOST: '127.0.0.1' 22 | 23 | jobs: 24 | lint: 25 | runs-on: '${{ matrix.operating_system }}' 26 | timeout-minutes: 20 27 | strategy: 28 | matrix: 29 | operating_system: [ubuntu-latest] 30 | experimental: [false] 31 | php_versions: ['8.3'] 32 | include: 33 | - operating_system: 'ubuntu-latest' 34 | php_versions: '8.4' 35 | experimental: true 36 | fail-fast: false 37 | env: 38 | PHP_CS_FIXER_FUTURE_MODE: '0' 39 | name: 'Linter / PHP ${{ matrix.php_versions }} ' 40 | steps: 41 | - name: 'Checkout' 42 | uses: actions/checkout@v2 43 | - name: 'Setup cache environment' 44 | id: cache-env 45 | uses: shivammathur/cache-extensions@v1 46 | with: 47 | php-version: '${{ matrix.php_versions }}' 48 | extensions: '${{ env.php_extensions }}' 49 | key: '${{ env.key }}' 50 | - name: 'Cache extensions' 51 | uses: actions/cache@v1 52 | with: 53 | path: '${{ steps.cache-env.outputs.dir }}' 54 | key: '${{ steps.cache-env.outputs.key }}' 55 | restore-keys: '${{ steps.cache-env.outputs.key }}' 56 | - name: 'Setup PHP' 57 | uses: shivammathur/setup-php@v2 58 | with: 59 | php-version: ${{ matrix.php_versions }} 60 | extensions: '${{ env.php_extensions }}' 61 | ini-values: memory_limit=-1 62 | tools: pecl, composer 63 | coverage: none 64 | - name: 'Setup problem matchers for PHP (aka PHP error logs)' 65 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"' 66 | - name: 'Setup problem matchers for PHPUnit' 67 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"' 68 | - name: 'Install PHP dependencies with Composer' 69 | continue-on-error: ${{ matrix.experimental }} 70 | run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist --no-progress --no-suggest --optimize-autoloader 71 | working-directory: './' 72 | - name: 'Linting PHP source files' 73 | continue-on-error: ${{ matrix.experimental }} 74 | run: 'vendor/bin/ecs check --config=ecs.php .' 75 | test: 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | experimental: [false] 80 | operating_system: [ubuntu-latest] 81 | postgres: ['10', '11', '12', '13', '14', '15'] 82 | php_versions: ['8.3'] 83 | include: 84 | - operating_system: ubuntu-latest 85 | postgres: '16' 86 | php_versions: '8.4' 87 | experimental: true 88 | runs-on: '${{ matrix.operating_system }}' 89 | services: 90 | postgres: 91 | image: 'postgres:${{ matrix.postgres }}' 92 | env: 93 | POSTGRES_USER: ${{ env.DB_USER }} 94 | POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }} 95 | POSTGRES_DB: ${{ env.DB_NAME }} 96 | ports: 97 | - 5432:5432 98 | # needed because the postgres container does not provide a healthcheck 99 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 100 | name: 'Test / PHP ${{ matrix.php_versions }} / Postgres ${{ matrix.postgres }}' 101 | needs: 102 | - lint 103 | steps: 104 | - name: 'Checkout' 105 | uses: actions/checkout@v2 106 | with: 107 | fetch-depth: 1 108 | - name: 'Install postgres client' 109 | run: | 110 | sudo apt-get update -y 111 | sudo apt-get install -y libpq-dev postgresql-client 112 | - name: 'Setup cache environment' 113 | id: cache-env 114 | uses: shivammathur/cache-extensions@v1 115 | with: 116 | php-version: ${{ matrix.php_versions }} 117 | extensions: ${{ env.php_extensions }} 118 | key: '${{ env.key }}' 119 | - name: 'Cache extensions' 120 | uses: actions/cache@v1 121 | with: 122 | path: '${{ steps.cache-env.outputs.dir }}' 123 | key: '${{ steps.cache-env.outputs.key }}' 124 | restore-keys: '${{ steps.cache-env.outputs.key }}' 125 | - name: 'Setup PHP' 126 | uses: shivammathur/setup-php@v2 127 | with: 128 | php-version: ${{ matrix.php_versions }} 129 | extensions: ${{ env.php_extensions }} 130 | ini-values: 'date.timezone=UTC, upload_max_filesize=20M, post_max_size=20M, memory_limit=512M, short_open_tag=Off, xdebug.mode="develop,coverage"' 131 | coverage: xdebug 132 | tools: 'phpunit' 133 | - name: 'Install PHP dependencies with Composer' 134 | continue-on-error: ${{ matrix.experimental }} 135 | run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist --no-progress --no-suggest --optimize-autoloader 136 | working-directory: './' 137 | - name: 'Run Unit Tests with PHPUnit' 138 | continue-on-error: ${{ matrix.experimental }} 139 | run: | 140 | sed -e "s/\${USERNAME}/${{ env.DB_USER }}/" \ 141 | -e "s/\${PASSWORD}/${{ env.DB_PASSWORD }}/" \ 142 | -e "s/\${DATABASE}/${{ env.DB_NAME }}/" \ 143 | -e "s/\${HOST}/${{ env.DB_HOST }}/" \ 144 | phpunit.xml.dist > phpunit.xml 145 | ./vendor/bin/phpunit -c phpunit.xml --migrate-configuration 146 | ./vendor/bin/phpunit \ 147 | --stderr \ 148 | --coverage-clover build/logs/clover.xml \ 149 | --coverage-text 150 | working-directory: './' 151 | - name: 'Upload coverage results to Coveralls' 152 | if: ${{ !matrix.experimental }} 153 | env: 154 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 155 | COVERALLS_PARALLEL: true 156 | COVERALLS_FLAG_NAME: php-${{ matrix.php_versions }}-postgres-${{ matrix.postgres }} 157 | run: | 158 | ./vendor/bin/php-coveralls \ 159 | --coverage_clover=build/logs/clover.xml \ 160 | -v 161 | coverage: 162 | needs: test 163 | runs-on: ubuntu-latest 164 | name: "Code coverage" 165 | steps: 166 | - name: 'Coveralls Finished' 167 | uses: coverallsapp/github-action@v1.1.2 168 | with: 169 | github-token: ${{ secrets.GITHUB_TOKEN }} 170 | parallel-finished: true 171 | -------------------------------------------------------------------------------- /tests/functional/Helpers/LTreeNodeTest.php: -------------------------------------------------------------------------------- 1 | expectException(LTreeUndefinedNodeException::class); 26 | $this 27 | ->getCategoriesWithUnknownParent() 28 | ->toTree(); 29 | } 30 | 31 | #[Test] 32 | public function nodeCantBeParentToItself() 33 | { 34 | $this->expectException(LTreeReflectionException::class); 35 | $this 36 | ->getCategoriesWithSelfParent() 37 | ->toTree(); 38 | } 39 | 40 | #[Test] 41 | public function findSuccess(): void 42 | { 43 | $tree = $this 44 | ->getCategories() 45 | ->toTree(); 46 | foreach (range(1, 12) as $id) { 47 | $node = $tree->findInTree($id); 48 | $this->assertNotNull($node); 49 | $model = $node->model; 50 | $this->assertSame($id, $model->id); 51 | $this->assertInstanceOf(Model::class, $model); 52 | } 53 | $this->assertNotNull($tree->getChildren()->find(11)->findInTree(12)); 54 | $this->assertNotNull($tree->findInTree(1)->findInTree(2)); 55 | } 56 | 57 | #[Test] 58 | #[DataProvider('provideUnknownNodes')] 59 | public function findFail($node): void 60 | { 61 | $tree = $this 62 | ->getCategories() 63 | ->toTree(); 64 | $this->assertNull($tree->findInTree($node)); 65 | } 66 | 67 | public static function provideUnknownNodes(): Generator 68 | { 69 | yield '-1' => [ 70 | 'node' => -1, 71 | ]; 72 | yield '0' => [ 73 | 'node' => 0, 74 | ]; 75 | yield '999' => [ 76 | 'node' => 999, 77 | ]; 78 | } 79 | 80 | #[Test] 81 | public function countDescendants(): void 82 | { 83 | $tree = $this 84 | ->getCategories() 85 | ->toTree(); 86 | $this->assertSame(12, $tree->countDescendants()); 87 | $this->assertSame(9, $tree->findInTree(1)->countDescendants()); 88 | $this->assertSame(1, $tree->findInTree(2)->countDescendants()); 89 | $this->assertSame(5, $tree->findInTree(3)->countDescendants()); 90 | $this->assertSame(3, $tree->findInTree(6)->countDescendants()); 91 | $this->assertSame(1, $tree->findInTree(11)->countDescendants()); 92 | } 93 | 94 | #[Test] 95 | public function each() 96 | { 97 | $tree = $this 98 | ->getCategories() 99 | ->toTree(); 100 | $tree->sortTree([]); 101 | $collection = $tree->toCollection(); 102 | $this->hits = 0; 103 | $tree->each(function ($item) use ($collection) { 104 | $key = $collection->search($item->getModel()); 105 | $this->assertIsInt($key); 106 | $collection->pull($key); 107 | $this->hits++; 108 | }); 109 | $this->assertSame(12, $this->hits); 110 | $this->assertCount(0, $collection); 111 | } 112 | 113 | #[Test] 114 | public function toTreeOnEmptyCollection(): void 115 | { 116 | $collection = new LTreeCollection(); 117 | $this->assertInstanceOf(LTreeNode::class, $collection->toTree()); 118 | } 119 | 120 | #[Test] 121 | public function toCollection(): void 122 | { 123 | $tree = $this 124 | ->getCategories() 125 | ->toTree(); 126 | 127 | $this->assertSame('1', $tree->findInTree(1)->pathAsString()); 128 | 129 | $collection = $tree->toCollection(); 130 | $this->assertCount(12, $collection); 131 | for ($id = 1; $id <= 12; $id++) { 132 | $collection->find($id); 133 | } 134 | } 135 | 136 | #[Test] 137 | public function toTreeArray(): void 138 | { 139 | $formatter = static function ($item) { 140 | return [ 141 | 'my_id' => $item->id, 142 | 'custom' => $item->id * 10, 143 | ]; 144 | }; 145 | $tree = $this 146 | ->getRandomCategories() 147 | ->toTree(false); 148 | $array = $tree->toTreeArray($formatter); 149 | $this->assertIsArray($array); 150 | $this->assertCount(2, $array); 151 | foreach ($array as $item) { 152 | $this->assertArrayHasKey('my_id', $item); 153 | $this->assertArrayHasKey('custom', $item); 154 | $this->assertArrayNotHasKey('id', $item); 155 | } 156 | $node = $array[0]; 157 | $this->assertIsArray($node); 158 | $this->assertCount(3, $node); 159 | $this->assertArrayHasKey('my_id', $node); 160 | $this->assertArrayHasKey('custom', $node); 161 | $this->assertArrayNotHasKey('id', $node); 162 | } 163 | 164 | #[Test] 165 | public function nodePresenter() 166 | { 167 | $tree = $this 168 | ->getCategories() 169 | ->toTree(); 170 | $node = $tree->findInTree(1); 171 | // node method 172 | $this->assertTrue(method_exists($node, 'getChildren')); 173 | $this->assertNotNull($node->getChildren()); 174 | // model method 175 | $this->assertFalse(method_exists($node, 'getTable')); 176 | $this->assertNotNull($node->model->getTable()); 177 | } 178 | 179 | #[Test] 180 | public function sortFail(): void 181 | { 182 | $tree = $this 183 | ->getRandomCategories() 184 | ->toTree(); 185 | $this->expectException(InvalidArgumentException::class); 186 | $tree->sortTree(['name']); 187 | } 188 | 189 | #[Test] 190 | public function sort() 191 | { 192 | $tree = $this 193 | ->getRandomCategories() 194 | ->whereIn('id', [1, 2, 3, 4]) 195 | ->toTree(); 196 | $tree->sortTree([ 197 | 'name' => 'asc', 198 | ]); 199 | $sorted = $this->getSortedTree(); 200 | foreach ($tree->getChildren() as $key => $node) { 201 | $this->assertSame($sorted[$key]['id'], $node->id); 202 | $this->assertCount(count($sorted[$key]['children']), $node->getChildren()); 203 | foreach ($node->getChildren() as $childKey => $child) { 204 | $this->assertSame($child->id, $sorted[$key]['children'][$childKey]); 205 | } 206 | } 207 | } 208 | 209 | public function getSortedTree() 210 | { 211 | return [ 212 | [ 213 | 'id' => 1, 214 | 'children' => [3, 4, 2], 215 | ], 216 | [ 217 | 'id' => 4, 218 | 'children' => [], 219 | ], 220 | ]; 221 | } 222 | } 223 | --------------------------------------------------------------------------------