diff --git a/README.md b/README.md index 5290490..a6a5cd0 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ class User extends Model #### Nested Results -Use the `toTree()` method on the result collection to generate a nested tree: +Use the `toTree()` method on a result collection to generate a nested tree: ```php $users = User::tree()->get(); @@ -696,6 +696,7 @@ Supports Laravel 9+. - [Depth](#graphs-depth) - [Path](#graphs-path) - [Custom Paths](#graphs-custom-paths) +- [Nested Results](#graphs-nested-results) - [Recursive Query Constraints](#graphs-recursive-query-constraints) - [Known Issues](#graphs-known-issues) @@ -1013,6 +1014,46 @@ class Node extends Model } ``` +#### Nested Results + +Use the `toTree()` method on a result collection to generate a nested tree: + +```php +$nodes = Node::find($id)->descendants; + +$tree = $nodes->toTree(); +``` + +This recursively sets `children` relationships: + +```json +[ + { + "id": 1, + "children": [ + { + "id": 2, + "children": [ + { + "id": 3, + "children": [] + } + ] + }, + { + "id": 4, + "children": [ + { + "id": 5, + "children": [] + } + ] + } + ] + } +] +``` + #### Recursive Query Constraints You can add custom constraints to the CTE's recursive query. Consider a query where you want to traverse a node's diff --git a/src/Eloquent/Graph/Collection.php b/src/Eloquent/Graph/Collection.php new file mode 100644 index 0000000..bcc6d0f --- /dev/null +++ b/src/Eloquent/Graph/Collection.php @@ -0,0 +1,50 @@ + + */ +class Collection extends Base +{ + /** + * Generate a nested tree. + * + * @param string $childrenRelation + * @return static + */ + public function toTree(string $childrenRelation = 'children'): static + { + if ($this->isEmpty()) { + return $this; + } + + $parentKeyName = $this->first->relationLoaded('pivot') + ? 'pivot.' . $this->first()->getParentKeyName() + : 'pivot_' . $this->first()->getParentKeyName(); + $localKeyName = $this->first()->getLocalKeyName(); + $depthName = $this->first()->getDepthName(); + + $depths = $this->pluck($depthName); + + $graph = new static( + $this->where($depthName, $depths->min())->values() + ); + + $itemsByParentKey = $this->groupBy($parentKeyName); + + foreach ($this->items as $item) { + $item->setRelation( + $childrenRelation, + $itemsByParentKey[$item->$localKeyName] ?? new static() + ); + } + + return $graph; + } +} diff --git a/src/Eloquent/Traits/HasGraphAdjacencyList.php b/src/Eloquent/Traits/HasGraphAdjacencyList.php index a83c45f..907483c 100644 --- a/src/Eloquent/Traits/HasGraphAdjacencyList.php +++ b/src/Eloquent/Traits/HasGraphAdjacencyList.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Staudenmeir\LaravelAdjacencyList\Eloquent\Graph\Collection; use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Graph\Ancestors; use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Graph\Descendants; @@ -421,6 +422,17 @@ public function newEloquentBuilder($query) return new \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder($query); } + /** + * Create a new Eloquent Collection instance. + * + * @param array $models + * @return \Staudenmeir\LaravelAdjacencyList\Eloquent\Graph\Collection + */ + public function newCollection(array $models = []) + { + return new Collection($models); + } + /** * Set an additional constraint for the recursive query. * diff --git a/src/IdeHelper/RecursiveRelationsHook.php b/src/IdeHelper/RecursiveRelationsHook.php index 9e465b2..38a5eb0 100644 --- a/src/IdeHelper/RecursiveRelationsHook.php +++ b/src/IdeHelper/RecursiveRelationsHook.php @@ -4,10 +4,10 @@ use Barryvdh\LaravelIdeHelper\Console\ModelsCommand; use Barryvdh\LaravelIdeHelper\Contracts\ModelHookInterface; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; -use Staudenmeir\LaravelAdjacencyList\Eloquent\Collection; +use Staudenmeir\LaravelAdjacencyList\Eloquent\Collection as TreeCollection; +use Staudenmeir\LaravelAdjacencyList\Eloquent\Graph\Collection as GraphCollection; use Staudenmeir\LaravelAdjacencyList\Eloquent\HasGraphRelationships; use Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships; @@ -135,7 +135,7 @@ public function run(ModelsCommand $command, Model $model): void if (in_array(HasRecursiveRelationships::class, $traits)) { foreach (static::$treeRelationships as $relationship) { $type = $relationship['manyRelation'] - ? '\\' . Collection::class . '|\\' . $model::class . '[]' + ? '\\' . TreeCollection::class . '|\\' . $model::class . '[]' : '\\' . $model::class; $this->addRelationship($command, $relationship, $type); @@ -144,7 +144,7 @@ public function run(ModelsCommand $command, Model $model): void if (in_array(HasGraphRelationships::class, $traits)) { foreach (static::$graphRelationships as $relationship) { - $type = '\\' . EloquentCollection::class . '|\\' . $model::class . '[]'; + $type = '\\' . GraphCollection::class . '|\\' . $model::class . '[]'; $this->addRelationship($command, $relationship, $type); } diff --git a/tests/Graph/CollectionTest.php b/tests/Graph/CollectionTest.php new file mode 100644 index 0000000..6c15013 --- /dev/null +++ b/tests/Graph/CollectionTest.php @@ -0,0 +1,96 @@ +database === 'sqlsrv') { + $this->markTestSkipped(); + } + + $constraint = fn (Builder $query) => $query->whereIn('id', [2, 3]); + + $nodes = Node::subgraph($constraint)->orderBy('id')->get(); + + $graph = $nodes->toTree(); + + $this->assertEquals([2, 3], $graph->pluck('id')->all()); + $this->assertEquals([5], $graph[0]->children->pluck('id')->all()); + $this->assertEquals([7, 8], $graph[0]->children[0]->children->pluck('id')->all()); + $this->assertEquals([8], $graph[0]->children[0]->children[0]->children->pluck('id')->all()); + $this->assertEquals([6], $graph[1]->children->pluck('id')->all()); + } + + public function testToTreeWithRelationship() + { + $nodes = Node::find(2)->descendants()->orderBy('id')->get(); + + $graph = $nodes->toTree(); + + $this->assertEquals([5], $graph->pluck('id')->all()); + $this->assertEquals([7, 8], $graph[0]->children->pluck('id')->all()); + $this->assertEquals([8], $graph[0]->children[0]->children->pluck('id')->all()); + } + + public function testToTreeWithCycle() + { + if ($this->database === 'sqlsrv') { + $this->markTestSkipped(); + } + + $this->seedCycle(); + + $constraint = fn (Builder $query) => $query->where('id', 12); + + $nodes = NodeWithCycleDetection::subgraph($constraint)->orderBy('id')->get(); + + $graph = $nodes->toTree(); + + $this->assertEquals([12], $graph->pluck('id')->all()); + $this->assertEquals([13], $graph[0]->children->pluck('id')->all()); + $this->assertEquals([14], $graph[0]->children[0]->children->pluck('id')->all()); + $this->assertEmpty($graph[0]->children[0]->children[0]->children); + } + + public function testToTreeWithCycleAndStart() + { + if ($this->database === 'sqlsrv') { + $this->markTestSkipped(); + } + + $this->seedCycle(); + + $constraint = fn (Builder $query) => $query->where('id', 12); + + $nodes = NodeWithCycleDetectionAndStart::subgraph($constraint)->orderBy('id')->get(); + + $graph = $nodes->toTree(); + + $this->assertEquals([12], $graph->pluck('id')->all()); + $this->assertEquals([13], $graph[0]->children->pluck('id')->all()); + $this->assertEquals([14], $graph[0]->children[0]->children->pluck('id')->all()); + $this->assertEquals([12], $graph[0]->children[0]->children[0]->children->pluck('id')->all()); + } + + public function testToTreeWithEmptyCollection() + { + if ($this->database === 'sqlsrv') { + $this->markTestSkipped(); + } + + $constraint = fn (Builder $query) => $query->where('id', 1); + + $nodes = Node::subgraph($constraint)->where('id', 0)->orderBy('id')->get(); + + $graph = $nodes->toTree(); + + $this->assertEmpty($graph); + } +} diff --git a/tests/IdeHelper/RecursiveRelationsHookTest.php b/tests/IdeHelper/RecursiveRelationsHookTest.php index 9da4fd4..d63fac8 100644 --- a/tests/IdeHelper/RecursiveRelationsHookTest.php +++ b/tests/IdeHelper/RecursiveRelationsHookTest.php @@ -55,7 +55,7 @@ public function testGraphRelations() $command->shouldReceive('setProperty')->times(2); $command->shouldReceive('setProperty')->once()->with( 'ancestorsAndSelf', - '\Illuminate\Database\Eloquent\Collection|\Staudenmeir\LaravelAdjacencyList\Tests\IdeHelper\Models\Node[]', + '\Staudenmeir\LaravelAdjacencyList\Eloquent\Graph\Collection|\Staudenmeir\LaravelAdjacencyList\Tests\IdeHelper\Models\Node[]', true, false, "The node's recursive parents and itself.", diff --git a/tests/Tree/CollectionTest.php b/tests/Tree/CollectionTest.php index 3bbd679..31dc6c7 100644 --- a/tests/Tree/CollectionTest.php +++ b/tests/Tree/CollectionTest.php @@ -19,7 +19,7 @@ public function testToTree() $this->assertEquals([12], $tree[1]->children->pluck('id')->all()); } - public function testToTreeWithDescendants() + public function testToTreeWithRelationship() { $users = User::find(1)->descendants()->orderBy('id')->get();