├── .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 | [](https://github.com/umbrellio/laravel-ltree/actions)
4 | [](https://coveralls.io/github/umbrellio/laravel-ltree?branch=master)
5 | [](https://packagist.org/packages/umbrellio/laravel-ltree)
6 | [](https://packagist.org/packages/umbrellio/laravel-ltree)
7 | [](https://scrutinizer-ci.com/code-intelligence)
8 | [](https://scrutinizer-ci.com/g/umbrellio/laravel-ltree/build-status/master)
9 | [](https://scrutinizer-ci.com/g/umbrellio/laravel-ltree/?branch=master)
10 | [](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 |
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 |
--------------------------------------------------------------------------------