├── .env.dist ├── LICENSE.md ├── README.md ├── composer.json ├── database └── migrations │ └── 2020_01_01_000001_create_ltree_extension.php ├── docker-compose.mysql.yml ├── docker-compose.pgsql.yml ├── docker-compose.sqlite.yml ├── phpunit.xml.from10_5.dist └── src ├── AsTree.php ├── Casts └── AsPath.php ├── Collections └── NodeCollection.php ├── Database └── BuilderMixin.php ├── Exceptions └── CircularReferenceException.php ├── Relations ├── Ancestors.php ├── Descendants.php └── HasManyDeep.php ├── TreeServiceProvider.php └── ValueObjects └── Path.php /.env.dist: -------------------------------------------------------------------------------- 1 | PHP_VERSION=7.3 2 | DB_CONNECTION=pgsql 3 | PHPUNIT_CONFIG_FILE=phpunit.xml.dist 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 nevadskiy 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 2 | 3 | # 🌳 Tree-like structure for Eloquent models 4 | 5 | [![PHPUnit](https://img.shields.io/github/actions/workflow/status/nevadskiy/laravel-tree/phpunit.yml?branch=master)](https://packagist.org/packages/nevadskiy/laravel-tree) 6 | [![Code Coverage](https://img.shields.io/codecov/c/github/nevadskiy/laravel-tree?token=9X6AQQYCPA)](https://packagist.org/packages/nevadskiy/laravel-tree) 7 | [![Latest Stable Version](https://img.shields.io/packagist/v/nevadskiy/laravel-tree)](https://packagist.org/packages/nevadskiy/laravel-tree) 8 | [![License](https://img.shields.io/github/license/nevadskiy/laravel-tree)](https://packagist.org/packages/nevadskiy/laravel-tree) 9 | 10 | The package provides you with a simple solution that allows you to effortlessly create hierarchical structures for your Eloquent models. 11 | It leverages the [materialized path](#materialized-path) pattern to represent the hierarchy of your data. 12 | It can be used for a wide range of use cases such as managing categories, nested comments, and more. 13 | 14 | ## 🔌 Installation 15 | 16 | Install the package via Composer: 17 | 18 | ```bash 19 | composer require nevadskiy/laravel-tree 20 | ```` 21 | 22 | ## ✨ How it works 23 | 24 | When working with hierarchical data structures in your application, storing the structure using a self-referencing `parent_id` column is a common approach. 25 | While it works well for many use cases, it can become challenging when you need to make complex queries, such as finding all descendants of a given node. 26 | One of the simples and effective solutions is the [materialized path](#materialized-path) pattern. 27 | 28 | ### Materialized path 29 | 30 | The "materialized pattern" involves storing the full path of each node in the hierarchy in a separate `path` column as a string. 31 | The ancestors of each node are represented by a series of IDs separated by a delimiter. 32 | 33 | For example, the categories database table might look like this: 34 | 35 | | id | name | parent_id | path | 36 | |:---|:---------------|----------:|------:| 37 | | 1 | Science | null | 1 | 38 | | 2 | Physics | 1 | 1.2 | 39 | | 3 | Mechanics | 2 | 1.2.3 | 40 | | 4 | Thermodynamics | 2 | 1.2.4 | 41 | 42 | With this structure, you can easily retrieve all descendants of a node using a SQL query: 43 | 44 | ```SQL 45 | SELECT * FROM categories WHERE path LIKE '1.%' 46 | ``` 47 | 48 | #### PostgreSQL Ltree extension 49 | 50 | Using the [PostgreSQL ltree](https://www.postgresql.org/docs/current/ltree.html) extension we can go even further. This extension provides an additional `ltree` column type designed specifically for this purpose. 51 | In combination with a GiST index it allows executing lightweight and performant queries across an entire tree. 52 | 53 | Now the SQL query will look like this: 54 | 55 | ```SQL 56 | SELECT * FROM categories WHERE path ~ '1.*' 57 | ``` 58 | 59 | ## 🔨 Configuration 60 | 61 | All you have to do is to add a `AsTree` trait to the model and add a `path` column alongside the self-referencing `parent_id` column to the model's table. 62 | 63 | Let's get started by configuring a `Category` model: 64 | 65 | ```php 66 | ltree('path')->nullable()->spatialIndex(); 87 | ``` 88 | 89 | The complete migration file may look like this: 90 | 91 | ```php 92 | id(); 104 | $table->string('name'); 105 | $table->ltree('path')->nullable()->spatialIndex(); 106 | $table->timestamps(); 107 | }); 108 | 109 | Schema::table('categories', function (Blueprint $table) { 110 | $table->foreignId('parent_id') 111 | ->nullable() 112 | ->index() 113 | ->constrained('categories') 114 | ->cascadeOnDelete(); 115 | }); 116 | } 117 | 118 | public function down(): void 119 | { 120 | Schema::dropIfExists('categories'); 121 | } 122 | }; 123 | ``` 124 | 125 | Sometimes the Ltree extension may be disabled in PostgreSQL. To enable it, you can publish and run a package migration: 126 | 127 | ```bash 128 | php artisan vendor:publish --tag=pgsql-ltree-migration 129 | ``` 130 | 131 | #### Using MySQL or SQLite database 132 | 133 | To add a string `path` column with and an index, use the following code: 134 | 135 | ```php 136 | $table->string('path')->nullable()->index(); 137 | ``` 138 | 139 | ## 🚊 Usage 140 | 141 | Once you have configured your model, the package **automatically** handles all manipulations with the `path` attribute based on the parent, so you do not need to set it manually. 142 | 143 | ### Inserting models 144 | 145 | To insert a root node, simply save the model to the database: 146 | 147 | ```php 148 | $root = new Category(); 149 | $root->name = 'Science'; 150 | $root->save(); 151 | ``` 152 | 153 | To insert a child model, you only need to assign the `parent_id` attribute or use the `parent` or `children` relation: 154 | 155 | ```php 156 | $child = new Category; 157 | $child->name = 'Physics'; 158 | $child->parent()->associate($root); 159 | $child->save(); 160 | ``` 161 | 162 | As you can see, it works just as regular Eloquent models. 163 | 164 | ### Relations 165 | 166 | The `AsTree` trait provides the following relations: 167 | 168 | - [`parent`](#parent) 169 | - [`children`](#children) 170 | - [`ancestors`](#ancestors) (read-only) 171 | - [`descendants`](#descendants) (read-only) 172 | 173 | The `parent` and `children` relations use default Laravel `BelongsTo` and `HasMany` relation classes. 174 | 175 | The `ancestors` and `descendants` can be used only in the "read" mode, which means methods like `make` or `create` are not available. 176 | So to save related nodes you need to use the `parent` or `children` relation. 177 | 178 | #### Parent 179 | 180 | The `parent` relation uses the default Eloquent `BelongsTo` relation class that needs the `parent_id` column as a foreign key. 181 | It allows getting a parent of the node. 182 | 183 | ```php 184 | echo $category->parent->name; 185 | ``` 186 | 187 | #### Children 188 | 189 | The `children` relation uses a default Eloquent `HasMany` relation class and is a reverse relation to the `parent`. 190 | It allows getting all children of the node. 191 | 192 | ```php 193 | foreach ($category->children as $child) { 194 | echo $child->name; 195 | } 196 | ``` 197 | 198 | #### Ancestors 199 | 200 | The `ancestors` relation is a custom relation that works only in "read" mode. 201 | It allows getting all ancestors of the node (without the current node). 202 | 203 | Using the attribute: 204 | 205 | ```php 206 | foreach ($category->ancestors as $ancestor) { 207 | echo $ancestor->name; 208 | } 209 | ``` 210 | 211 | Using the query builder: 212 | 213 | ```php 214 | $ancestors = $category->ancestors()->get(); 215 | ``` 216 | 217 | Getting a collection with the current node and its ancestors: 218 | 219 | ```php 220 | $hierarchy = $category->joinAncestors(); 221 | ``` 222 | 223 | #### Descendants 224 | 225 | The `descendants` relation is a custom relation that works only in "read" mode. 226 | It allows getting all descendants of the node (without the current node). 227 | 228 | Using the attribute: 229 | 230 | ```php 231 | foreach ($category->descendants as $descendant) { 232 | echo $descendant->name; 233 | } 234 | ``` 235 | 236 | Using the query builder: 237 | 238 | ```php 239 | $ancestors = $category->descendants()->get(); 240 | ``` 241 | 242 | ### Querying models 243 | 244 | Getting root nodes: 245 | 246 | ```php 247 | $roots = Category::query()->root()->get(); 248 | ``` 249 | 250 | Getting nodes by the depth level: 251 | 252 | ```php 253 | $categories = Category::query()->whereDepth(3)->get(); 254 | ``` 255 | 256 | Getting ancestors of the node (including the current node): 257 | 258 | ```php 259 | $ancestors = Category::query()->whereSelfOrAncestorOf($category)->get(); 260 | ``` 261 | 262 | Getting descendants of the node (including the current node): 263 | 264 | ```php 265 | $descendants = Category::query()->whereSelfOrDescendantOf($category)->get(); 266 | ``` 267 | 268 | Ordering nodes by depth: 269 | 270 | ```php 271 | $categories = Category::query()->orderByDepth()->get(); 272 | $categories = Category::query()->orderByDepthDesc()->get(); 273 | ``` 274 | 275 | ### HasManyDeep 276 | 277 | The package provides the `HasManyDeep` relation that can be used to link, for example, a `Category` model that uses the `AsTree` trait with a `Product` model. 278 | 279 | That allows us to get products of a category and each of its descendants. 280 | 281 | Here is the code example on how to use the `HasManyDeep` relation: 282 | 283 | ```php 284 | products()->paginate(20); 307 | ``` 308 | 309 | ### Querying category products 310 | 311 | You can easily get the products of a category and each of its descendants using a query builder. 312 | 313 | 1st way (recommended): 314 | 315 | ```php 316 | $products = Product::query() 317 | ->join('categories', function (JoinClause $join) { 318 | $join->on('products.category_id', 'categories.id'); 319 | }) 320 | ->whereSelfOrDescendantOf($category) 321 | ->paginate(24, ['products.*']); 322 | ``` 323 | 324 | 2nd way (slower): 325 | 326 | ```php 327 | $products = Product::query() 328 | ->whereHas('category', function (Builder $query) use ($category) { 329 | $query->whereSelfOrDescendantOf($category); 330 | }) 331 | ->paginate(24); 332 | ``` 333 | 334 | ### Moving nodes 335 | 336 | When you move a node, the `path` column of the node and each of its descendants have to be updated as well. 337 | Fortunately, the package does this automatically using a single query every time it sees that the `parent_id` column has been updated. 338 | 339 | So basically to move a node along with its subtree, you need to update the `parent` node of the current node: 340 | 341 | ```php 342 | $science = Category::query()->where('name', 'Science')->firstOrFail(); 343 | $physics = Category::query()->where('name', 'Physics')->firstOrFail(); 344 | 345 | $physics->parent()->associate($science); 346 | $physics->save(); 347 | ``` 348 | 349 | ### Other examples 350 | 351 | #### Building a tree 352 | 353 | To build a tree, we need to call the `tree` method on the `NodeCollection`: 354 | 355 | ```php 356 | $tree = Category::query()->orderBy('name')->get()->tree(); 357 | ``` 358 | 359 | This method associates nodes using the `children` relation and returns only root nodes. 360 | 361 | #### Building breadcrumbs 362 | 363 | ```php 364 | echo $category->joinAncestors()->reverse()->implode('name', ' > '); 365 | ``` 366 | 367 | #### Deleting a subtree 368 | 369 | Delete the current node and all its descendants: 370 | 371 | ```php 372 | $category->newQuery()->whereSelfOrDescendantOf($category)->delete(); 373 | ``` 374 | 375 | ## 📚 Useful links 376 | 377 | - https://www.postgresql.org/docs/current/ltree.html 378 | - https://patshaughnessy.net/2017/12/13/saving-a-tree-in-postgres-using-ltree 379 | - https://patshaughnessy.net/2017/12/14/manipulating-trees-using-sql-and-the-postgres-ltree-extension 380 | 381 | ## ☕ Contributing 382 | 383 | Thank you for considering contributing. Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for more information. 384 | 385 | ## 📜 License 386 | 387 | The MIT License (MIT). Please see [LICENSE](LICENSE.md) for more information. 388 | 389 | [//]: # (@todo doc ordering) 390 | [//]: # (@todo doc available build methods) 391 | [//]: # (@todo doc postgres uuid and dashes) 392 | [//]: # (@todo doc custom query where['path', '~', '*.1.*]) 393 | [//]: # (@todo refactor with separate builders SimplePathBuilder, LtreePathBuilder) 394 | [//]: # (@todo split tests into more specific test cases) 395 | [//]: # (@todo add test case with all build methods) 396 | [//]: # (@todo add method `is` to relations that performs checks: $this->ancestors[]->is[$that]) 397 | [//]: # (@todo add method `is` to relations that performs checks: $this->descendants[]->is[$that]) 398 | [//]: # (@todo doc list with all available builder methods) 399 | [//]: # (@todo use model observer similar how Scout does it) 400 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nevadskiy/laravel-tree", 3 | "description": "Tree-like structure for Eloquent models.", 4 | "license": "MIT", 5 | "keywords": ["tree", "hierarchy", "materialized path", "path", "ltree", "laravel", "eloquent"], 6 | "authors": [ 7 | { 8 | "name": "Nevadskiy", 9 | "email": "nevadskiy@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.3|^8.0", 14 | "laravel/framework": ">=8.79" 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "^9.0|^10.5", 18 | "orchestra/testbench": ">=6.25", 19 | "friendsofphp/php-cs-fixer": "^3" 20 | }, 21 | "conflict": { 22 | "nesbot/carbon": "<2.62.1" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Nevadskiy\\Tree\\": "src/" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "Nevadskiy\\Tree\\Tests\\": "tests/" 32 | } 33 | }, 34 | "extra": { 35 | "laravel": { 36 | "providers": [ 37 | "Nevadskiy\\Tree\\TreeServiceProvider" 38 | ] 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /database/migrations/2020_01_01_000001_create_ltree_extension.php: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | tests/ 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | src/ 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/AsTree.php: -------------------------------------------------------------------------------- 1 | shouldDetectCircularReference()) { 34 | $model->detectCircularReference(); 35 | } 36 | }); 37 | 38 | static::updated(static function (self $model) { 39 | if ($model->shouldRebuildSubtreePaths()) { 40 | $model->rebuildSubtreePaths(); 41 | } 42 | }); 43 | } 44 | 45 | /** 46 | * Initialize the trait. 47 | */ 48 | protected function initializeAsTree(): void 49 | { 50 | $this->mergeCasts([ 51 | $this->getPathColumn() => AsPath::class, 52 | ]); 53 | } 54 | 55 | /** 56 | * Get the path's column name. 57 | */ 58 | public function getPathColumn(): string 59 | { 60 | return 'path'; 61 | } 62 | 63 | /** 64 | * Get the path of the model. 65 | */ 66 | public function getPath(): ?Path 67 | { 68 | return $this->getAttribute($this->getPathColumn()); 69 | } 70 | 71 | /** 72 | * Get the source column name of the model's path. 73 | */ 74 | public function getPathSourceColumn(): string 75 | { 76 | return $this->getKeyName(); 77 | } 78 | 79 | /** 80 | * Get the source value of the model's path. 81 | */ 82 | public function getPathSource(): string 83 | { 84 | return $this->getAttribute($this->getPathSourceColumn()); 85 | } 86 | 87 | /** 88 | * Get the parent key name. 89 | */ 90 | public function getParentKeyName(): string 91 | { 92 | return 'parent_id'; 93 | } 94 | 95 | /** 96 | * Get a relation with a parent category. 97 | */ 98 | public function parent(): BelongsTo 99 | { 100 | return $this->belongsTo(static::class, $this->getParentKeyName()); 101 | } 102 | 103 | /** 104 | * Get a relation with children categories. 105 | */ 106 | public function children(): HasMany 107 | { 108 | return $this->hasMany(static::class, $this->getParentKeyName()); 109 | } 110 | 111 | /** 112 | * Get the model's ancestors. 113 | */ 114 | public function ancestors(): Ancestors 115 | { 116 | return Ancestors::of($this); 117 | } 118 | 119 | /** 120 | * Get the model's descendants. 121 | */ 122 | public function descendants(): Descendants 123 | { 124 | return Descendants::of($this); 125 | } 126 | 127 | /** 128 | * @inheritdoc 129 | */ 130 | public function newCollection(array $models = []): NodeCollection 131 | { 132 | return new NodeCollection($models); 133 | } 134 | 135 | /** 136 | * Filter records by root. 137 | */ 138 | protected function scopeWhereRoot(Builder $query): void 139 | { 140 | $query->whereNull($this->getParentKeyName()); 141 | } 142 | 143 | /** 144 | * Determine if it is the root node. 145 | */ 146 | public function isRoot(): bool 147 | { 148 | return is_null($this->getAttribute($this->getParentKeyName())); 149 | } 150 | 151 | /** 152 | * Filter records by the given depth level. 153 | */ 154 | public function scopeWhereDepth(Builder $query, int $depth, string $operator = '='): void 155 | { 156 | $query->wherePathDepth($this->getPathColumn(), $depth, $operator); 157 | } 158 | 159 | /** 160 | * Order records by a depth. 161 | */ 162 | protected function scopeOrderByDepth(Builder $query, string $direction = 'asc'): void 163 | { 164 | $query->orderByPathDepth($this->getPathColumn(), $direction); 165 | } 166 | 167 | /** 168 | * Order records by a depth descending. 169 | */ 170 | protected function scopeOrderByDepthDesc(Builder $query): void 171 | { 172 | $query->orderByDepth('desc'); 173 | } 174 | 175 | /** 176 | * Join the ancestors of the model. 177 | */ 178 | public function joinAncestors(): NodeCollection 179 | { 180 | return $this->ancestors->sortByDepthDesc()->prepend($this); 181 | } 182 | 183 | /** 184 | * Determine if the current node is an ancestor of the given node. 185 | */ 186 | public function isAncestorOf(self $that): bool 187 | { 188 | return $this->getPath()->segments()->contains($that->getPathSource()) 189 | && ! $this->is($that); 190 | } 191 | 192 | /** 193 | * Determine if the current node is a descendant of the given node. 194 | */ 195 | public function isDescendantOf(self $that): bool 196 | { 197 | return $that->isAncestorOf($this); 198 | } 199 | 200 | /** 201 | * Get the event when to assign the model's path. 202 | */ 203 | protected static function assignPathOnEvent(): string 204 | { 205 | if (static::shouldAssignPathDuringInsert()) { 206 | return 'creating'; 207 | } 208 | 209 | return 'created'; 210 | } 211 | 212 | /** 213 | * Determine whether the path should be assigned during insert. 214 | */ 215 | protected static function shouldAssignPathDuringInsert(): bool 216 | { 217 | $model = new static(); 218 | 219 | if ($model->getIncrementing() && $model->getPathSourceColumn() === $model->getKeyName()) { 220 | return false; 221 | } 222 | 223 | return true; 224 | } 225 | 226 | /** 227 | * Assign a path to the model when it is created. 228 | */ 229 | protected function assignPathWhenCreated(): void 230 | { 231 | if ($this->shouldAssignPath()) { 232 | $this->assignPath(); 233 | $this->saveQuietly(); 234 | } 235 | } 236 | 237 | /** 238 | * Assign a path to the model when it is creating. 239 | */ 240 | protected function assignPathWhenCreating(): void 241 | { 242 | if ($this->shouldAssignPath()) { 243 | $this->assignPath(); 244 | } 245 | } 246 | 247 | /** 248 | * Determine whether the path attribute should be assigned. 249 | */ 250 | protected function shouldAssignPath(): bool 251 | { 252 | return ! $this->hasPath(); 253 | } 254 | 255 | /** 256 | * Assign the model's path to the model. 257 | */ 258 | public function assignPath(): void 259 | { 260 | $this->setAttribute($this->getPathColumn(), $this->buildPath()); 261 | } 262 | 263 | /** 264 | * Determine whether the model has the path attribute. 265 | */ 266 | public function hasPath(): bool 267 | { 268 | return ! is_null($this->getAttribute($this->getPathColumn())); 269 | } 270 | 271 | /** 272 | * Build the current path of the model. 273 | */ 274 | protected function buildPath(): Path 275 | { 276 | if ($this->isRoot()) { 277 | return Path::from($this->getPathSource()); 278 | } 279 | 280 | return Path::from($this->parent->getPath(), $this->getPathSource()); 281 | } 282 | 283 | /** 284 | * Determine if the parent node is changing when the model is not saved. 285 | */ 286 | public function isParentChanging(): bool 287 | { 288 | return $this->isDirty($this->getParentKeyName()); 289 | } 290 | 291 | /** 292 | * Determine if the parent node is changed when the model is saved. 293 | */ 294 | public function isParentChanged(): bool 295 | { 296 | return $this->wasChanged($this->getParentKeyName()); 297 | } 298 | 299 | /** 300 | * Determine whether the paths of the subtree should be rebuilt. 301 | */ 302 | protected function shouldRebuildSubtreePaths(): bool 303 | { 304 | return $this->isParentChanged(); 305 | } 306 | 307 | /** 308 | * Rebuild the paths of the subtree. 309 | */ 310 | protected function rebuildSubtreePaths(): void 311 | { 312 | $this->newQuery() 313 | ->whereSelfOrDescendantOf($this) 314 | ->rebuildPaths( 315 | $this->getPathColumn(), 316 | $this->getPath(), 317 | $this->isRoot() 318 | ? null 319 | : $this->parent->getPath(), 320 | ); 321 | } 322 | 323 | /** 324 | * Determine whether a circular reference should be detected on the node. 325 | */ 326 | protected function shouldDetectCircularReference(): bool 327 | { 328 | return $this->isParentChanging(); 329 | } 330 | 331 | /** 332 | * Detect a circular reference on the node. 333 | */ 334 | protected function detectCircularReference(): void 335 | { 336 | if ($this->hasCircularReference()) { 337 | $this->onCircularReferenceDetected(); 338 | } 339 | } 340 | 341 | /** 342 | * Determine whether the node has a circular reference. 343 | */ 344 | protected function hasCircularReference(): bool 345 | { 346 | if ($this->isRoot()) { 347 | return false; 348 | } 349 | 350 | return $this->parent->getPath()->segments()->contains($this->getPathSource()); 351 | } 352 | 353 | /** 354 | * Throw the circular reference exception. 355 | */ 356 | protected function onCircularReferenceDetected(): void 357 | { 358 | throw new CircularReferenceException($this); 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /src/Casts/AsPath.php: -------------------------------------------------------------------------------- 1 | usesPgsqlConnection($model)) { 24 | $value = $this->transformPgsqlPathFromDatabase($value); 25 | } 26 | 27 | return new Path($value); 28 | } 29 | 30 | /** 31 | * @inheritdoc 32 | */ 33 | public function set($model, string $key, $value, array $attributes): ?string 34 | { 35 | if (is_null($value)) { 36 | return null; 37 | } 38 | 39 | if (! $value instanceof Path) { 40 | throw new RuntimeException(sprintf('The "%s" is not a Path instance.', $key)); 41 | } 42 | 43 | $path = $value->getValue(); 44 | 45 | if ($this->usesPgsqlConnection($model)) { 46 | $path = $this->transformPgsqlPathToDatabase($path); 47 | } 48 | 49 | return $path; 50 | } 51 | 52 | /** 53 | * Determine if the model uses the PostgreSQL connection. 54 | */ 55 | protected function usesPgsqlConnection(Model $model): bool 56 | { 57 | return $model->getConnection() instanceof PostgresConnection; 58 | } 59 | 60 | /** 61 | * Transform the PostgreSQL path to database. 62 | */ 63 | protected function transformPgsqlPathToDatabase(string $path): string 64 | { 65 | if (Str::containsAll($path, ['-', '_'])) { 66 | throw new RuntimeException('The path cannot have mixed "-" and "_" characters.'); 67 | } 68 | 69 | return Str::replace('-', '_', $path); 70 | } 71 | 72 | /** 73 | * Transform the PostgreSQL path value from database. 74 | */ 75 | protected function transformPgsqlPathFromDatabase(string $path): string 76 | { 77 | return Str::replace('_', '-', $path); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Collections/NodeCollection.php: -------------------------------------------------------------------------------- 1 | associate(); 16 | 17 | $depth = $this->min(function (Model $node) { 18 | return $node->getPath()->getDepth(); 19 | }); 20 | 21 | return $this->filter(function (Model $node) use ($depth) { 22 | return $node->getPath()->getDepth() === $depth; 23 | }); 24 | } 25 | 26 | /** 27 | * Associate nodes using the "children" relation. 28 | */ 29 | public function associate(): NodeCollection 30 | { 31 | if ($this->isEmpty()) { 32 | return $this; 33 | } 34 | 35 | $parents = $this->groupBy($this->first()->getParentKeyName()); 36 | 37 | $this->each(function (Model $node) use ($parents) { 38 | $node->setRelation('children', $parents->get($node->getKey(), new static())); 39 | }); 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * Get root nodes of the collection. 46 | */ 47 | public function root(): NodeCollection 48 | { 49 | return $this->filter(function (Model $node) { 50 | return $node->isRoot(); 51 | }); 52 | } 53 | 54 | /** 55 | * Sort the collection by a depth level. 56 | */ 57 | public function sortByDepth(bool $descending = false): NodeCollection 58 | { 59 | return $this->sortBy(function (Model $node) { 60 | return $node->getPath()->getDepth(); 61 | }, SORT_REGULAR, $descending); 62 | } 63 | 64 | /** 65 | * Sort the collection in descending order by a depth level. 66 | */ 67 | public function sortByDepthDesc(): NodeCollection 68 | { 69 | return $this->sortByDepth(true); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Database/BuilderMixin.php: -------------------------------------------------------------------------------- 1 | '; 25 | 26 | /** 27 | * The descendant SQL operator. 28 | */ 29 | public const DESCENDANT = '<@'; 30 | 31 | /** 32 | * Add an ancestor where clause to the query. 33 | * 34 | * @todo handle case when root. 35 | */ 36 | public function whereAncestor(): callable 37 | { 38 | return function (string $column, ?Path $path, string $boolean = 'and') { 39 | if (is_null($path)) { 40 | return $this; 41 | } 42 | 43 | if ($this->getConnection() instanceof PostgresConnection) { 44 | return $this->where($column, '~', "*.{$path}", $boolean); 45 | } 46 | 47 | if ($this->getConnection() instanceof MySqlConnection) { 48 | return $this->whereIn($column, $path->getAncestorSet(), $boolean); 49 | } 50 | 51 | if ($this->getConnection() instanceof SQLiteConnection) { 52 | return $this->whereIn($column, $path->getAncestorSet(), $boolean); 53 | } 54 | 55 | throw new RuntimeException(vsprintf('Database connection [%s] is not supported.', [ 56 | get_class($this->getConnection()) 57 | ])); 58 | }; 59 | } 60 | 61 | /** 62 | * Add a self-or-ancestor where clause to the query. 63 | */ 64 | public function whereSelfOrAncestor(): callable 65 | { 66 | return function (string $column, ?Path $path, string $boolean = 'and') { 67 | if (is_null($path)) { 68 | return $this; 69 | } 70 | 71 | if ($this->getConnection() instanceof PostgresConnection) { 72 | return $this->where($column, BuilderMixin::ANCESTOR, $path, $boolean); 73 | } 74 | 75 | if ($this->getConnection() instanceof MySqlConnection) { 76 | return $this->whereIn($column, $path->getPathSet(), $boolean); 77 | } 78 | 79 | if ($this->getConnection() instanceof SQLiteConnection) { 80 | return $this->whereIn($column, $path->getPathSet(), $boolean); 81 | } 82 | 83 | throw new RuntimeException(vsprintf('Database connection [%s] is not supported.', [ 84 | get_class($this->getConnection()) 85 | ])); 86 | }; 87 | } 88 | 89 | /** 90 | * Add a self-or-ancestor where column clause to the query. 91 | */ 92 | public function whereColumnSelfOrAncestor(): callable 93 | { 94 | return function (string $first, string $second, string $boolean = 'and') { 95 | if ($this->getConnection() instanceof PostgresConnection) { 96 | return $this->whereColumn($first, BuilderMixin::ANCESTOR, $second, $boolean); 97 | } 98 | 99 | if ($this->getConnection() instanceof MySqlConnection) { 100 | return $this->whereRaw(sprintf('find_in_set(%s, path_to_ancestor_set(%s))', $first, $second), [], $boolean); 101 | } 102 | 103 | if ($this->getConnection() instanceof SQLiteConnection) { 104 | return $this->whereRaw(sprintf("instr('.' || %s || '.', '.' || %s || '.') > 0", $second, $first), [], $boolean); 105 | } 106 | 107 | throw new RuntimeException(vsprintf('Database connection [%s] is not supported.', [ 108 | get_class($this->getConnection()) 109 | ])); 110 | }; 111 | } 112 | 113 | /** 114 | * Add a self-or-ancestor "or where" clause to the query. 115 | */ 116 | public function orWhereSelfOrAncestor(): callable 117 | { 118 | return function (string $column, Path $path) { 119 | return $this->whereSelfOrAncestor($column, $path, 'or'); 120 | }; 121 | } 122 | 123 | /** 124 | * Add a self-or-ancestor where clause to the query from the given model. 125 | */ 126 | public function whereSelfOrAncestorOf(): callable 127 | { 128 | return function (Model $model, string $column = null, string $boolean = 'and') { 129 | return $this->whereSelfOrAncestor( 130 | $column ?: $model->newQuery()->qualifyColumn($model->getPathColumn()), 131 | $model->getPath(), 132 | $boolean 133 | ); 134 | }; 135 | } 136 | 137 | /** 138 | * Add a self-or-ancestor "or where" clause to the query from the given model. 139 | */ 140 | public function orWhereSelfOrAncestorOf(): callable 141 | { 142 | return function (Model $model, string $column = null) { 143 | return $this->orWhereSelfOrAncestor( 144 | $column ?: $model->newQuery()->qualifyColumn($model->getPathColumn()), 145 | $model->getPath(), 146 | ); 147 | }; 148 | } 149 | 150 | /** 151 | * Add a self-or-descendant where clause to the query. 152 | */ 153 | public function whereSelfOrDescendant(): callable 154 | { 155 | return function (string $column, ?Path $path, string $boolean = 'and') { 156 | if (is_null($path)) { 157 | return $this; 158 | } 159 | 160 | if ($this->getConnection() instanceof PostgresConnection) { 161 | return $this->where($column, BuilderMixin::DESCENDANT, $path, $boolean); 162 | } 163 | 164 | if ($this->getConnection() instanceof MySqlConnection) { 165 | return $this->whereNested(function (Builder $query) use ($column, $path) { 166 | $query->where($column, '=', $path); 167 | $query->orWhereDescendant($column, $path); 168 | }, $boolean); 169 | } 170 | 171 | if ($this->getConnection() instanceof SQLiteConnection) { 172 | return $this->whereNested(function (Builder $query) use ($column, $path) { 173 | $query->where($column, '=', $path); 174 | $query->orWhereDescendant($column, $path); 175 | }, $boolean); 176 | } 177 | 178 | throw new RuntimeException(vsprintf('Database connection [%s] is not supported.', [ 179 | get_class($this->getConnection()) 180 | ])); 181 | }; 182 | } 183 | 184 | /** 185 | * Add a descendant where clause to the query. 186 | */ 187 | public function whereDescendant(): callable 188 | { 189 | return function (string $column, Path $path, string $boolean = 'and') { 190 | if ($this->getConnection() instanceof PostgresConnection) { 191 | return $this->where($column, '~', "{$path}.*", $boolean); 192 | } 193 | 194 | if ($this->getConnection() instanceof MySqlConnection) { 195 | return $this->where($column, 'like', "{$path}.%", $boolean); 196 | } 197 | 198 | if ($this->getConnection() instanceof SQLiteConnection) { 199 | return $this->where($column, 'like', "{$path}.%", $boolean); 200 | } 201 | 202 | throw new RuntimeException(vsprintf('Database connection [%s] is not supported.', [ 203 | get_class($this->getConnection()) 204 | ])); 205 | }; 206 | } 207 | 208 | /** 209 | * Add a descendant "or where" clause to the query. 210 | */ 211 | public function orWhereDescendant(): callable 212 | { 213 | return function (string $column, Path $path) { 214 | return $this->whereDescendant($column, $path, 'or'); 215 | }; 216 | } 217 | 218 | /** 219 | * Add a self-or-descendant where column clause to the query. 220 | */ 221 | public function whereColumnSelfOrDescendant(): callable 222 | { 223 | return function (string $first, string $second, string $boolean = 'and') { 224 | if ($this->getConnection() instanceof PostgresConnection) { 225 | return $this->whereColumn($first, BuilderMixin::DESCENDANT, $second, $boolean); 226 | } 227 | 228 | if ($this->getConnection() instanceof MySqlConnection) { 229 | return $this->whereColumn($first, 'like', new Expression("concat({$second}, '%')"), $boolean); 230 | } 231 | 232 | if ($this->getConnection() instanceof SQLiteConnection) { 233 | return $this->whereColumn($first, 'like', new Expression("{$second} || '%'"), $boolean); 234 | } 235 | 236 | throw new RuntimeException(vsprintf('Database connection [%s] is not supported.', [ 237 | get_class($this->getConnection()) 238 | ])); 239 | }; 240 | } 241 | 242 | /** 243 | * Add a self-or-descendant "or where" clause to the query. 244 | */ 245 | public function orWhereSelfOrDescendant(): callable 246 | { 247 | return function (string $column, Path $path) { 248 | return $this->whereSelfOrDescendant($column, $path, 'or'); 249 | }; 250 | } 251 | 252 | /** 253 | * Add a self-or-descendant where clause to the query from the given model. 254 | */ 255 | public function whereSelfOrDescendantOf(): callable 256 | { 257 | return function (Model $model, string $column = null, string $boolean = 'and') { 258 | return $this->whereSelfOrDescendant( 259 | $column ?: $model->newQuery()->qualifyColumn($model->getPathColumn()), 260 | $model->getPath(), 261 | $boolean 262 | ); 263 | }; 264 | } 265 | 266 | /** 267 | * Add a self-or-descendant "or where" clause to the query from the given model. 268 | */ 269 | public function orWhereSelfOrDescendantOf(): callable 270 | { 271 | return function (Model $model, string $column = null) { 272 | return $this->orWhereSelfOrDescendant( 273 | $column ?: $model->newQuery()->qualifyColumn($model->getPathColumn()), 274 | $model->getPath(), 275 | ); 276 | }; 277 | } 278 | 279 | /** 280 | * Add a "depth" where clause to the query from the given model. 281 | */ 282 | public function wherePathDepth(): callable 283 | { 284 | return function (string $column, int $depth, string $operator = '=') { 285 | if ($this->getConnection() instanceof PostgresConnection) { 286 | return $this->where($this->compilePgsqlDepth($column), $operator, $depth); 287 | } 288 | 289 | if ($this->getConnection() instanceof MySqlConnection) { 290 | return $this->where($this->compileMysqlDepth($column), $operator, $depth); 291 | } 292 | 293 | if ($this->getConnection() instanceof SQLiteConnection) { 294 | return $this->where($this->compileSqliteDepth($column), $operator, $depth); 295 | } 296 | 297 | throw new RuntimeException(vsprintf('Database connection [%s] is not supported.', [ 298 | get_class($this->getConnection()) 299 | ])); 300 | }; 301 | } 302 | 303 | /** 304 | * Order records by a depth. 305 | */ 306 | public function orderByPathDepth(): callable 307 | { 308 | return function (string $column, string $direction = 'asc') { 309 | if ($this->getConnection() instanceof PostgresConnection) { 310 | return $this->orderBy($this->compilePgsqlDepth($column), $direction); 311 | } 312 | 313 | if ($this->getConnection() instanceof MySqlConnection) { 314 | return $this->orderBy($this->compileMysqlDepth($column), $direction); 315 | } 316 | 317 | if ($this->getConnection() instanceof SQLiteConnection) { 318 | return $this->orderBy($this->compileSqliteDepth($column), $direction); 319 | } 320 | 321 | throw new RuntimeException(vsprintf('Database connection [%s] is not supported.', [ 322 | get_class($this->getConnection()) 323 | ])); 324 | }; 325 | } 326 | 327 | /** 328 | * Compile the PostgreSQL "depth" function for the given column. 329 | */ 330 | protected function compilePgsqlDepth(): callable 331 | { 332 | return function (string $column) { 333 | return new Expression(sprintf('nlevel(%s)', $column)); 334 | }; 335 | } 336 | 337 | /** 338 | * Compile the MySQL "depth" function for the given column. 339 | */ 340 | protected function compileMysqlDepth(): callable 341 | { 342 | return function (string $column, string $separator = Path::SEPARATOR) { 343 | return new Expression(vsprintf("(length(%s) - length(replace(%s, '%s', ''))) + 1", [ 344 | $column, $column, $separator 345 | ])); 346 | }; 347 | } 348 | 349 | /** 350 | * Compile the SQLite "depth" function for the given column. 351 | */ 352 | protected function compileSqliteDepth(): callable 353 | { 354 | return function (string $column, string $separator = Path::SEPARATOR) { 355 | return new Expression(vsprintf("(length(%s) - length(replace(%s, '%s', ''))) + 1", [ 356 | $column, $column, $separator 357 | ])); 358 | }; 359 | } 360 | 361 | /** 362 | * Rebuild paths of the subtree according to the parent path. 363 | */ 364 | public function rebuildPaths(): callable 365 | { 366 | return function (string $column, Path $path, ?Path $parentPath = null) { 367 | if ($this->getConnection() instanceof PostgresConnection) { 368 | return $this->update([ 369 | $column => is_null($parentPath) 370 | ? new Expression($this->compilePgsqlSubPath($column, $path->getDepth())) 371 | : new Expression($this->compilePgsqlConcat([ 372 | sprintf("'%s'", $parentPath->getValue()), 373 | $this->compilePgsqlSubPath($column, $path->getDepth()) 374 | ])) 375 | ]); 376 | } 377 | 378 | if ($this->getConnection() instanceof MySqlConnection) { 379 | return $this->update([ 380 | $column => is_null($parentPath) 381 | ? new Expression($this->compileMysqlSubPath($column, $path->getDepth())) 382 | : new Expression($this->compileMysqlConcat([ 383 | sprintf("'%s'", $parentPath->getValue() . Path::SEPARATOR), 384 | $this->compileMysqlSubPath($column, $path->getDepth()) 385 | ])) 386 | ]); 387 | } 388 | 389 | if ($this->getConnection() instanceof SQLiteConnection) { 390 | return $this->update([ 391 | $column => is_null($parentPath) 392 | ? new Expression($this->compileSqliteSubPath($column, $path->getDepth())) 393 | : new Expression($this->compileSqliteConcat([ 394 | sprintf("'%s'", $parentPath->getValue() . Path::SEPARATOR), 395 | $this->compileSqliteSubPath($column, $path->getDepth()) 396 | ])) 397 | ]); 398 | } 399 | 400 | throw new RuntimeException(vsprintf('Database connection [%s] is not supported.', [ 401 | get_class($this->getConnection()) 402 | ])); 403 | }; 404 | } 405 | 406 | /** 407 | * Compile the PostgreSQL concat function. 408 | */ 409 | protected function compilePgsqlConcat(): callable 410 | { 411 | return function (array $values) { 412 | return implode(' || ', $values); 413 | }; 414 | } 415 | 416 | /** 417 | * Compile the MySQL concat function. 418 | */ 419 | protected function compileMysqlConcat(): callable 420 | { 421 | return function (array $values) { 422 | return sprintf("concat(%s)", implode(', ', $values)); 423 | }; 424 | } 425 | 426 | /** 427 | * Compile the SQLite concat function. 428 | */ 429 | protected function compileSqliteConcat(): callable 430 | { 431 | return function (array $values) { 432 | return implode(' || ', $values); 433 | }; 434 | } 435 | 436 | /** 437 | * Compile the MySQL sub path function. 438 | */ 439 | protected function compileMysqlSubPath(): callable 440 | { 441 | return function (string $column, int $depth) { 442 | if ($depth === 1) { 443 | return $column; 444 | } 445 | 446 | return vsprintf("substring(%s, length(substring_index(%s, '%s', %d)) + 2)", [ 447 | $column, 448 | $column, 449 | Path::SEPARATOR, 450 | $depth - 1 451 | ]); 452 | }; 453 | } 454 | 455 | /** 456 | * Compile the PostgreSQL sub path function. 457 | */ 458 | protected function compilePgsqlSubPath(): callable 459 | { 460 | return function (string $column, int $depth) { 461 | if ($depth === 1) { 462 | return $column; 463 | } 464 | 465 | return vsprintf('subpath(%s, %d)', [$column, $depth - 1]); 466 | }; 467 | } 468 | 469 | /** 470 | * Compile the SQLite sub path function. 471 | */ 472 | protected function compileSqliteSubPath(): callable 473 | { 474 | return function (string $column, int $depth) { 475 | if ($depth === 1) { 476 | return $column; 477 | } 478 | 479 | return vsprintf("substr(%s, length(substring_index(%s, '%s', %d)) + 2)", [ 480 | $column, 481 | $column, 482 | Path::SEPARATOR, 483 | $depth - 1 484 | ]); 485 | }; 486 | } 487 | } 488 | -------------------------------------------------------------------------------- /src/Exceptions/CircularReferenceException.php: -------------------------------------------------------------------------------- 1 | getKey())); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Relations/Ancestors.php: -------------------------------------------------------------------------------- 1 | newQuery(), $model); 22 | } 23 | 24 | /** 25 | * @inheritdoc 26 | */ 27 | public function addConstraints(): void 28 | { 29 | if (static::$constraints) { 30 | $this->query->where(function () { 31 | // @todo rewrite using whereAnscestorOf method. 32 | $this->query->whereSelfOrAncestorOf($this->related); 33 | $this->query->whereKeyNot($this->related->getKey()); 34 | }); 35 | } 36 | } 37 | 38 | /** 39 | * Set the constraints for an eager load of the relation. 40 | */ 41 | public function addEagerConstraints(array $models): void 42 | { 43 | $this->query->where(function (Builder $query) use ($models) { 44 | foreach ($models as $model) { 45 | // @todo rewrite using whereAnscestorOf method. 46 | $query->orWhereSelfOrAncestorOf($model); 47 | } 48 | }); 49 | } 50 | 51 | /** 52 | * @inheritdoc 53 | */ 54 | public function initRelation(array $models, $relation): array 55 | { 56 | foreach ($models as $model) { 57 | $model->setRelation($relation, $this->related->newCollection()); 58 | } 59 | 60 | return $models; 61 | } 62 | 63 | /** 64 | * @inheritdoc 65 | */ 66 | public function match(array $models, Collection $results, $relation): array 67 | { 68 | foreach ($models as $model) { 69 | $model->setRelation($relation, $results->filter(function (Model $result) use ($model) { 70 | return $model->isAncestorOf($result); 71 | })); 72 | } 73 | 74 | return $models; 75 | } 76 | 77 | /** 78 | * @inheritdoc 79 | */ 80 | public function getResults(): Collection 81 | { 82 | return ! is_null($this->related->isRoot()) 83 | ? $this->query->get() 84 | : $this->related->newCollection(); 85 | } 86 | 87 | /** 88 | * @inheritdoc 89 | */ 90 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']): Builder 91 | { 92 | return $query->select($columns) 93 | ->from("{$query->getModel()->getTable()} as ancestors") 94 | ->whereColumnSelfOrAncestor( 95 | "ancestors.{$this->related->getPathColumn()}", 96 | $this->related->qualifyColumn($this->related->getPathColumn()) 97 | ) 98 | ->whereColumn( 99 | "ancestors.{$this->related->getKeyName()}", 100 | '!=', 101 | $this->related->getQualifiedKeyName() 102 | ); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Relations/Descendants.php: -------------------------------------------------------------------------------- 1 | newQuery(), $model); 22 | } 23 | 24 | /** 25 | * @inheritdoc 26 | */ 27 | public function addConstraints(): void 28 | { 29 | if (static::$constraints) { 30 | $this->query->where(function () { 31 | $this->query->whereSelfOrDescendantOf($this->related); 32 | $this->query->whereKeyNot($this->related->getKey()); 33 | }); 34 | } 35 | } 36 | 37 | /** 38 | * Set the constraints for an eager load of the relation. 39 | */ 40 | public function addEagerConstraints(array $models): void 41 | { 42 | $this->query->where(function (Builder $query) use ($models) { 43 | foreach ($models as $model) { 44 | $query->orWhereSelfOrDescendantOf($model); 45 | } 46 | }); 47 | } 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | public function initRelation(array $models, $relation): array 53 | { 54 | foreach ($models as $model) { 55 | $model->setRelation($relation, $this->related->newCollection()); 56 | } 57 | 58 | return $models; 59 | } 60 | 61 | /** 62 | * @inheritdoc 63 | */ 64 | public function match(array $models, Collection $results, $relation): array 65 | { 66 | foreach ($models as $model) { 67 | $model->setRelation($relation, $results->filter(function (Model $result) use ($model) { 68 | return $model->isDescendantOf($result); 69 | })); 70 | } 71 | 72 | return $models; 73 | } 74 | 75 | /** 76 | * @inheritdoc 77 | */ 78 | public function getResults(): Collection 79 | { 80 | return $this->query->get(); 81 | } 82 | 83 | /** 84 | * @inheritdoc 85 | */ 86 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']): Builder 87 | { 88 | return $query->select($columns) 89 | ->from("{$query->getModel()->getTable()} as descendants") 90 | ->whereColumnSelfOrDescendant( 91 | "descendants.{$this->related->getPathColumn()}", 92 | $this->related->qualifyColumn($this->related->getPathColumn()) 93 | ) 94 | ->whereColumn( 95 | "descendants.{$this->related->getKeyName()}", 96 | '!=', 97 | $this->related->getQualifiedKeyName() 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Relations/HasManyDeep.php: -------------------------------------------------------------------------------- 1 | newQuery(), 32 | $parent, 33 | $foreignKey ?: $relatedInstance->qualifyColumn($parent->getForeignKey()), 34 | $localKey ?: $relatedInstance->getKeyName() 35 | ); 36 | } 37 | 38 | /** 39 | * Create a new model instance for a related model. 40 | */ 41 | protected static function newRelatedInstance(string $class, Model $parent) 42 | { 43 | return tap(new $class(), static function ($related) use ($parent) { 44 | if (! $related->getConnectionName()) { 45 | $related->setConnection($parent->getConnectionName()); 46 | } 47 | }); 48 | } 49 | 50 | /** 51 | * @inheritdoc 52 | */ 53 | public function addConstraints(): void 54 | { 55 | if (static::$constraints && $this->getParentKey()) { 56 | $this->joinParent(); 57 | 58 | $this->query->whereSelfOrDescendantOf($this->parent); 59 | 60 | $this->query->select($this->related->qualifyColumn('*')); 61 | } 62 | } 63 | 64 | /** 65 | * Join the parent's model table. 66 | */ 67 | protected function joinParent(): void 68 | { 69 | $this->query->join($this->parent->getTable(), function (JoinClause $join) { 70 | $join->on($this->getQualifiedForeignKeyName(), $this->getQualifiedParentKeyName()); 71 | }); 72 | } 73 | 74 | /** 75 | * @inheritdoc 76 | */ 77 | public function addEagerConstraints(array $models): void 78 | { 79 | $this->joinParent(); 80 | 81 | $this->query->where(function (Builder $query) use ($models) { 82 | foreach ($models as $model) { 83 | $query->orWhereSelfOrDescendantOf($model); 84 | } 85 | }); 86 | } 87 | 88 | /** 89 | * Execute the query as a "select" statement. 90 | * 91 | * @param array $columns 92 | */ 93 | public function get($columns = ['*']): Collection 94 | { 95 | if ($columns === ['*']) { 96 | $columns = ["{$this->related->getTable()}.*"]; 97 | } 98 | 99 | $columns = array_merge($columns, [ 100 | sprintf("{$this->parent->getTable()}.{$this->parent->getPathColumn()} as %s", self::HASH_PATH_COLUMN) 101 | ]); 102 | 103 | $this->query->withCasts([ 104 | self::HASH_PATH_COLUMN => AsPath::class, 105 | ]); 106 | 107 | return $this->query->get($columns); 108 | } 109 | 110 | /** 111 | * @inheritdoc 112 | */ 113 | public function match(array $models, Collection $results, $relation): array 114 | { 115 | foreach ($models as $model) { 116 | $model->setRelation($relation, $results->filter(function (Model $result) use ($model) { 117 | return $result->getAttribute(self::HASH_PATH_COLUMN)->segments()->contains($model->getPathSource()); 118 | })); 119 | } 120 | 121 | return $models; 122 | } 123 | 124 | /** 125 | * @inheritdoc 126 | */ 127 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']): Builder 128 | { 129 | $hash = $this->getRelationCountHash(); 130 | 131 | return $query->select($columns) 132 | ->join("{$this->parent->getTable()} as {$hash}", function (JoinClause $join) use ($hash) { 133 | $join->on($this->getForeignKeyName(), "{$hash}.{$this->getLocalKeyName()}"); 134 | }) 135 | ->whereColumnSelfOrAncestor( 136 | $this->parent->qualifyColumn($this->parent->getPathColumn()), 137 | "{$hash}.{$this->parent->getPathColumn()}" 138 | ); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/TreeServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerBuilderMixin(); 19 | $this->registerLtreeType(); 20 | $this->registerLtreeColumn(); 21 | } 22 | 23 | /** 24 | * Bootstrap any application services. 25 | */ 26 | public function boot(): void 27 | { 28 | $this->publishMigrations(); 29 | } 30 | 31 | /** 32 | * Register the query builder mixin. 33 | */ 34 | private function registerBuilderMixin(): void 35 | { 36 | Builder::mixin(new BuilderMixin()); 37 | } 38 | 39 | /** 40 | * Register the "ltree" column type for database. 41 | */ 42 | private function registerLtreeType(): void 43 | { 44 | Grammar::macro('typeLtree', function () { 45 | return 'ltree'; 46 | }); 47 | } 48 | 49 | /** 50 | * Register the "ltree" column on the blueprint. 51 | */ 52 | private function registerLtreeColumn(): void 53 | { 54 | Blueprint::macro('ltree', function (string $name) { 55 | return $this->addColumn('ltree', $name); 56 | }); 57 | } 58 | 59 | /** 60 | * Publish any package migrations. 61 | */ 62 | private function publishMigrations(): void 63 | { 64 | $this->publishes([__DIR__.'/../database/migrations' => database_path('migrations')], 'pgsql-ltree-migration'); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ValueObjects/Path.php: -------------------------------------------------------------------------------- 1 | map(function ($segment) { 31 | if ($segment instanceof Path) { 32 | return $segment->getValue(); 33 | } 34 | 35 | return $segment; 36 | }) 37 | ->implode(self::SEPARATOR) 38 | ); 39 | } 40 | 41 | /** 42 | * Make a new path instance. 43 | */ 44 | public function __construct(string $value) 45 | { 46 | $this->value = $value; 47 | } 48 | 49 | /** 50 | * Get the path's value. 51 | */ 52 | public function getValue(): string 53 | { 54 | return $this->value; 55 | } 56 | 57 | /** 58 | * Get segments of the path. 59 | */ 60 | public function segments(): Collection 61 | { 62 | return collect($this->explode()); 63 | } 64 | 65 | /** 66 | * Get the depth level of the path. 67 | */ 68 | public function getDepth(): int 69 | { 70 | return count($this->explode()); 71 | } 72 | 73 | /** 74 | * Explode a path to segments. 75 | */ 76 | protected function explode(): array 77 | { 78 | return explode(self::SEPARATOR, $this->getValue()); 79 | } 80 | 81 | /** 82 | * Convert the path into path set of ancestors including self. 83 | * 84 | * @todo rename 85 | * @example ["1", "1.2", "1.2.3", "1.2.3.4"] 86 | */ 87 | public function getPathSet(): array 88 | { 89 | $output = []; 90 | 91 | $parts = $this->explode(); 92 | 93 | for ($index = 1, $length = count($parts); $index <= $length; $index++) { 94 | $output[] = implode(self::SEPARATOR, array_slice($parts, 0, $index)); 95 | } 96 | 97 | return $output; 98 | } 99 | 100 | /** 101 | * Convert the path into path set of ancestors excluding self. 102 | */ 103 | public function getAncestorSet(): array 104 | { 105 | $output = $this->getPathSet(); 106 | 107 | array_pop($output); 108 | 109 | return $output; 110 | } 111 | 112 | /** 113 | * Get string representation of the object. 114 | */ 115 | public function __toString(): string 116 | { 117 | return $this->getValue(); 118 | } 119 | } 120 | --------------------------------------------------------------------------------