Skip to content

Commit

Permalink
Add toTree() method for graphs
Browse files Browse the repository at this point in the history
  • Loading branch information
staudenmeir committed Dec 30, 2023
1 parent 55281f3 commit 48d0b84
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 6 deletions.
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,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();
Expand Down Expand Up @@ -662,6 +662,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)

#### <a name="graphs-getting-started">Getting Started</a>
Expand Down Expand Up @@ -985,6 +986,46 @@ class Node extends Model
}
```

#### <a name="graphs-nested-results">Nested Results</a>

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": []
}
]
}
]
}
]
```

#### <a name="graphs-recursive-query-constraints">Recursive Query Constraints</a>

You can add custom constraints to the CTE's recursive query. Consider a query where you want to traverse a node's
Expand Down
50 changes: 50 additions & 0 deletions src/Eloquent/Graph/Collection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace Staudenmeir\LaravelAdjacencyList\Eloquent\Graph;

use Illuminate\Database\Eloquent\Collection as Base;

/**
* @template TKey of array-key
* @template TModel of \Illuminate\Database\Eloquent\Model
*
* @extends \Illuminate\Database\Eloquent\Collection<TKey, TModel>
*/
class Collection extends Base
{
/**
* Generate a nested tree.
*
* @param string $childrenRelation
* @return static<int, TModel>
*/
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;
}
}
12 changes: 12 additions & 0 deletions src/Eloquent/Traits/HasGraphAdjacencyList.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
*
Expand Down
7 changes: 4 additions & 3 deletions src/IdeHelper/RecursiveRelationsHook.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
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;

Expand Down Expand Up @@ -148,7 +149,7 @@ protected function setTreeRelationProperties(ModelsCommand $command, Model $mode
{
foreach (static::$treeRelationMap as $relationDefinition) {
$type = $relationDefinition['manyRelation']
? '\\' . Collection::class . '|' . class_basename($model) . '[]'
? '\\' . TreeCollection::class . '|' . class_basename($model) . '[]'
: class_basename($model);

$command->setProperty(
Expand Down Expand Up @@ -176,7 +177,7 @@ protected function setTreeRelationProperties(ModelsCommand $command, Model $mode
protected function setGraphRelationProperties(ModelsCommand $command, Model $model): void
{
foreach (static::$graphRelationMap as $relationDefinition) {
$type = '\\' . EloquentCollection::class . '|' . class_basename($model) . '[]';
$type = '\\' . GraphCollection::class . '|' . class_basename($model) . '[]';

$command->setProperty(
$relationDefinition['name'],
Expand Down
96 changes: 96 additions & 0 deletions tests/Graph/CollectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace Staudenmeir\LaravelAdjacencyList\Tests\Graph;

use Illuminate\Database\Eloquent\Builder;
use Staudenmeir\LaravelAdjacencyList\Tests\Graph\Models\Node;
use Staudenmeir\LaravelAdjacencyList\Tests\Graph\Models\NodeWithCycleDetection;
use Staudenmeir\LaravelAdjacencyList\Tests\Graph\Models\NodeWithCycleDetectionAndStart;

class CollectionTest extends TestCase
{
public function testToTree()
{
if ($this->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);
}
}
2 changes: 1 addition & 1 deletion tests/IdeHelper/RecursiveRelationsHookTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function testGraphRelations()
$command->shouldReceive('setProperty')->times(2);
$command->shouldReceive('setProperty')->once()->with(
'ancestorsAndSelf',
'\Illuminate\Database\Eloquent\Collection|Node[]',
'\Staudenmeir\LaravelAdjacencyList\Eloquent\Graph\Collection|Node[]',
true,
false,
"The node's recursive parents and itself.",
Expand Down
2 changes: 1 addition & 1 deletion tests/Tree/CollectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down

0 comments on commit 48d0b84

Please sign in to comment.