diff --git a/README.md b/README.md index 48c785e..053a426 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ The trait provides various relationships: - `ancestors()`: The model's recursive parents. - `ancestorsAndSelf()`: The model's recursive parents and itself. +- `bloodline()`: The model's ancestors, descendants and itself. - `children()`: The model's direct children. - `childrenAndSelf()`: The model's direct children and itself. - `descendants()`: The model's recursive children. @@ -246,7 +247,7 @@ $descendants = User::find($id)->descendants()->depthFirst()->get(); ### Depth -The results of ancestor, descendant and tree queries include an additional `depth` column. +The results of ancestor, bloodline, descendant and tree queries include an additional `depth` column. It contains the model's depth *relative* to the query's parent. The depth is positive for descendants and negative for ancestors: @@ -295,7 +296,7 @@ $tree = User::treeOf($constraint, 3)->get(); ### Path -The results of ancestor, descendant and tree queries include an additional `path` column. +The results of ancestor, bloodline, descendant and tree queries include an additional `path` column. It contains the dot-separated path of local keys from the query's parent to the model: diff --git a/src/Eloquent/HasRecursiveRelationshipScopes.php b/src/Eloquent/HasRecursiveRelationshipScopes.php index 3fa00da..1d0363e 100644 --- a/src/Eloquent/HasRecursiveRelationshipScopes.php +++ b/src/Eloquent/HasRecursiveRelationshipScopes.php @@ -3,6 +3,7 @@ namespace Staudenmeir\LaravelAdjacencyList\Eloquent; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\JoinClause; use Staudenmeir\LaravelAdjacencyList\Query\Grammars\ExpressionGrammar; trait HasRecursiveRelationshipScopes @@ -206,7 +207,22 @@ protected function getRecursiveQuery(ExpressionGrammar $grammar, $direction, $fr $depth = $grammar->wrap($this->getDepthName()); - $recursiveDepth = $grammar->wrap($this->getDepthName()).' '.($direction === 'asc' ? '-' : '+').' 1'; + $joinColumns = [ + 'asc' => [ + $name.'.'.$this->getParentKeyName(), + $this->getQualifiedLocalKeyName(), + ], + 'desc' => [ + $name.'.'.$this->getLocalKeyName(), + $this->qualifyColumn($this->getParentKeyName()), + ], + ]; + + if ($direction === 'both') { + $recursiveDepth = "$depth + (case when {$joinColumns['desc'][1]}={$joinColumns['desc'][0]} then 1 else -1 end)"; + } else { + $recursiveDepth = $depth.' '.($direction === 'asc' ? '-' : '+').' 1'; + } $recursivePath = $grammar->compileRecursivePath( $this->getQualifiedLocalKeyName(), @@ -231,16 +247,29 @@ protected function getRecursiveQuery(ExpressionGrammar $grammar, $direction, $fr ); } - if ($direction === 'asc') { - $first = $this->getParentKeyName(); - $second = $this->getQualifiedLocalKeyName(); + if ($direction === 'both') { + $query->join($name, function (JoinClause $join) use ($joinColumns) { + $join->on($joinColumns['asc'][0], '=', $joinColumns['asc'][1]) + ->orOn($joinColumns['desc'][0], '=', $joinColumns['desc'][1]); + }); + + $depth = $this->getDepthName(); + + $query->where(function (Builder $query) use ($depth, $joinColumns) { + $query->where($depth, '=', 0) + ->orWhere(function (Builder $query) use ($depth, $joinColumns) { + $query->whereColumn($joinColumns['asc'][0], '=', $joinColumns['asc'][1]) + ->where($depth, '<', 0); + }) + ->orWhere(function (Builder $query) use ($depth, $joinColumns) { + $query->whereColumn($joinColumns['desc'][0], '=', $joinColumns['desc'][1]) + ->where($depth, '>', 0); + }); + }); } else { - $first = $this->getLocalKeyName(); - $second = $this->qualifyColumn($this->getParentKeyName()); + $query->join($name, $joinColumns[$direction][0], '=', $joinColumns[$direction][1]); } - $query->join($name, $name.'.'.$first, '=', $second); - if (!is_null($maxDepth)) { $query->where($this->getDepthName(), '<', $maxDepth); } diff --git a/src/Eloquent/HasRecursiveRelationships.php b/src/Eloquent/HasRecursiveRelationships.php index 9c44465..cd20456 100644 --- a/src/Eloquent/HasRecursiveRelationships.php +++ b/src/Eloquent/HasRecursiveRelationships.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Ancestors; +use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Bloodline; use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Descendants; use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\HasManyOfDescendants; use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\RootAncestor; @@ -154,6 +155,35 @@ protected function newAncestors(Builder $query, Model $parent, $foreignKey, $loc return new Ancestors($query, $parent, $foreignKey, $localKey, $andSelf); } + /** + * Get the model's bloodline. + * + * @return \Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Bloodline + */ + public function bloodline() + { + return $this->newBloodline( + (new static())->newQuery(), + $this, + $this->getQualifiedParentKeyName(), + $this->getLocalKeyName() + ); + } + + /** + * Instantiate a new Bloodline relationship. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Model $parent + * @param string $foreignKey + * @param string $localKey + * @return \Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Bloodline + */ + protected function newBloodline(Builder $query, Model $parent, $foreignKey, $localKey) + { + return new Bloodline($query, $parent, $foreignKey, $localKey); + } + /** * Get the model's children. * diff --git a/src/Eloquent/Relations/Bloodline.php b/src/Eloquent/Relations/Bloodline.php new file mode 100644 index 0000000..91d7429 --- /dev/null +++ b/src/Eloquent/Relations/Bloodline.php @@ -0,0 +1,38 @@ +query; + + return $query->withRelationshipExpression('both', $constraint, 0, $from); + } +} diff --git a/tests/BloodlineTest.php b/tests/BloodlineTest.php new file mode 100644 index 0000000..55c1911 --- /dev/null +++ b/tests/BloodlineTest.php @@ -0,0 +1,86 @@ +bloodline()->breadthFirst()->get(); + + $this->assertEquals([1, 2, 5, 8], $bloodline->pluck('id')->all()); + $this->assertEquals([-2, -1, 0, 1], $bloodline->pluck('depth')->all()); + $this->assertEquals(['5.2.1', '5.2', '5', '5.8'], $bloodline->pluck('path')->all()); + $this->assertEquals(['user-5/user-2/user-1', 'user-5/user-2', 'user-5', 'user-5/user-8'], $bloodline->pluck('slug_path')->all()); + } + + public function testEagerLoading() + { + $users = User::with( + [ + 'bloodline' => function (Bloodline $relation) { + $relation->breadthFirst()->orderBy('id'); + }, + ] + )->get(); + + $this->assertEquals(range(1, 9), $users[0]->bloodline->pluck('id')->all()); + $this->assertEquals([1, 2, 5, 8], $users[1]->bloodline->pluck('id')->all()); + $this->assertEquals([1, 2, 5, 8], $users[4]->bloodline->pluck('id')->all()); + $this->assertEquals(['5.2.1', '5.2', '5', '5.8'], $users[4]->bloodline->pluck('path')->all()); + $this->assertEquals(['user-5/user-2/user-1', 'user-5/user-2', 'user-5', 'user-5/user-8'], $users[4]->bloodline->pluck('slug_path')->all()); + } + + public function testLazyEagerLoading() + { + $users = User::all()->load( + [ + 'bloodline' => function (Bloodline $relation) { + $relation->breadthFirst()->orderBy('id'); + }, + ] + ); + + $this->assertEquals(range(1, 9), $users[0]->bloodline->pluck('id')->all()); + $this->assertEquals([1, 2, 5, 8], $users[1]->bloodline->pluck('id')->all()); + $this->assertEquals([1, 2, 5, 8], $users[4]->bloodline->pluck('id')->all()); + $this->assertEquals(['5.2.1', '5.2', '5', '5.8'], $users[4]->bloodline->pluck('path')->all()); + $this->assertEquals(['user-5/user-2/user-1', 'user-5/user-2', 'user-5', 'user-5/user-8'], $users[4]->bloodline->pluck('slug_path')->all()); + } + + public function testExistenceQuery() + { + if (DB::connection()->getDriverName() === 'sqlsrv') { + $this->markTestSkipped(); + } + + $descendants = User::first()->descendants()->has('bloodline', '<', 4)->get(); + + $this->assertEquals([4, 7], $descendants->pluck('id')->all()); + } + + public function testExistenceQueryForSelfRelation() + { + if (DB::connection()->getDriverName() === 'sqlsrv') { + $this->markTestSkipped(); + } + + $users = User::has('bloodline', '<', 4)->get(); + + $this->assertEquals([4, 7, 11, 12], $users->pluck('id')->all()); + } + + public function testUpdate() + { + $affected = User::find(5)->bloodline()->delete(); + + $this->assertEquals(4, $affected); + $this->assertNotNull(User::withTrashed()->find(1)->deleted_at); + $this->assertNotNull(User::withTrashed()->find(8)->deleted_at); + $this->assertNull(User::find(3)->deleted_at); + } +}