Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add path enumerable trait #155

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Prev Previous commit
Next Next commit
Add ability to define a custom segment column.
This allows the path to be made up of segments from a different field, such as folder, or name. By default, path segments are derived from the primary key (usually an ID).
  • Loading branch information
bennothommo committed Dec 11, 2023
commit 1edb90e27e947638e8a3015952cea3c7e39ed1fa
90 changes: 81 additions & 9 deletions src/Database/Traits/PathEnumerable.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Winter\Storm\Database\Collection;
use Winter\Storm\Database\Model;
use Winter\Storm\Database\TreeCollection;
use Winter\Storm\Exception\ApplicationException;

/**
* "Enumerable path" model trait
Expand Down Expand Up @@ -48,6 +49,24 @@ trait PathEnumerable
*/
protected int|null|false $newParentId = false;

/**
* Defines the column name that will be used for the path segments. By default, the ID of the record will be used.
*
* protected string $segmentColumn = 'id';
*/

/**
* Defines the column used for storing the parent ID. By default, this will be `parent_id`.
*
* const PARENT_ID = 'parent_id';
*/

/**
* Defines the column used for storing the path. By default, this will be `path`.
*
* const PATH_COLUMN = 'path';
*/

public static function bootPathEnumerable(): void
{
static::extend(function (Model $model) {
Expand Down Expand Up @@ -149,7 +168,8 @@ public function scopeRoot(Builder $query): void
public function scopeDescendants(Builder $query): void
{
if (!$this->exists()) {
return;
// Nullify the query, as this record does not yet exist within the hierarchy
$query->whereRaw('0 = 1');
}

$query->where($this->getPathColumnName(), 'LIKE', $this->getPath() . '/%');
Expand All @@ -163,10 +183,18 @@ public function scopeDescendants(Builder $query): void
public function scopeAncestors(Builder $query): void
{
if (!$this->exists()) {
return;
// Nullify the query, as this record does not yet exist within the hierarchy
$query->whereRaw('0 = 1');
}

$query->whereIn($this->getKeyName(), $this->getAncestorIds());
$ancestorPaths = $this->getAncestorPaths();

if (!count($ancestorPaths)) {
// Nullify the query, as this record has no ancestors.
$query->whereRaw('0 = 1');
}

$query->whereIn($this->getPathColumnName(), $this->getAncestorPaths());
}

/**
Expand All @@ -177,10 +205,42 @@ public function scopeAncestors(Builder $query): void
public function getEnumerablePath(): string
{
if ($this->parent()->exists()) {
return $this->parent->{$this->getPathColumnName()} . '/' . $this->id;
return $this->parent->{$this->getPathColumnName()} . '/' . $this->getEnumerableSegment();
}

return '/' . $this->getEnumerableSegment();
}

public function getSegmentColumn(): string
{
if (!property_exists($this, 'segmentColumn')) {
return $this->primaryKey;
}

return '/' . $this->id;
return $this->segmentColumn;
}

/**
* Gets the enumerable segment of this record.
*
* By default, this will return the ID of the record to make up each segment of the path. You can change the column
* that makes up the path segments by defining another column name in the `$segmentColumn` property.
*
* @return string
*/
public function getEnumerableSegment(): string
{
if (!array_key_exists($this->getSegmentColumn(), $this->attributes)) {
throw new ApplicationException(
sprintf(
'The segment column "%s" does not exist on the model "%s".',
$this->segmentColumn,
get_class($this)
)
);
}

return (string) $this->getAttribute($this->getSegmentColumn());
}

/**
Expand Down Expand Up @@ -273,7 +333,7 @@ public function getParentColumnName(): string
}

/**
* Gets the parent column name.
* Gets the path column name.
*/
public function getPathColumnName(): string
{
Expand All @@ -291,15 +351,27 @@ public function getParentId(): ?int
}

/**
* Gets the ID of all direct ancestors of the current record.
* Gets the paths of all direct ancestors of the current record.
*
* @return int[]
*/
public function getAncestorIds(): array
public function getAncestorPaths(): array
{
$ids = explode('/', $this->getPath());
array_shift($ids);
array_pop($ids);
return $ids;

if (!count($ids)) {
return [];
}

$paths = [];

for ($i = 1; $i <= count($ids); $i++) {
$paths[] = '/' . implode('/', array_slice($ids, 0, $i));
}

return $paths;
}

/**
Expand Down
61 changes: 61 additions & 0 deletions tests/Database/Traits/PathEnumerableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,55 @@ public function testGetNestedRecords()
$this->assertEquals($child->name, $nested->get(1)->children->get(0)->children->get(0)->children->get(0)->name);
}

public function testPathsEnumeratedOnCreateWithDifferentSegmentColumn()
{
$grandparents = new TestModelEnumerablePathNameSegment([
'name' => 'Grandparents',
]);
$parents = new TestModelEnumerablePathNameSegment([
'name' => 'Parents',
]);
$daughter = new TestModelEnumerablePathNameSegment([
'name' => 'Daughter',
]);
$child = new TestModelEnumerablePathNameSegment([
'name' => 'Child',
]);

$grandparents->save();
$this->assertEquals('/Grandparents', $grandparents->path);
$this->assertEquals(0, $grandparents->getDepth());
$this->assertEquals(0, $grandparents->getParents()->count());

$parents->parent = $grandparents;
$parents->save();
$this->assertEquals('/Grandparents/Parents', $parents->path);
$this->assertEquals(1, $parents->getDepth());
$this->assertEquals(1, $parents->getParents()->count());

$daughter->parent = $parents;
$daughter->save();
$this->assertEquals('/Grandparents/Parents/Daughter', $daughter->path);
$this->assertEquals(2, $daughter->getDepth());
$this->assertEquals(2, $daughter->getParents()->count());

$child->parent = $daughter;
$child->save();
$this->assertEquals('/Grandparents/Parents/Daughter/Child', $child->path);
$this->assertEquals(3, $child->getDepth());
$this->assertEquals(3, $child->getParents()->count());

// Check hierarchy
$hierarchy = $child->getParents();
$this->assertEquals($grandparents->name, $hierarchy->get(0)->name);
$this->assertEquals($parents->name, $hierarchy->get(1)->name);
$this->assertEquals($daughter->name, $hierarchy->get(2)->name);

$root = TestModelEnumerablePathNameSegment::root()->get();
$this->assertCount(1, $root);
$this->assertEquals($grandparents->name, $root->first()->name);
}

protected function createTable()
{
$this->getBuilder()->create('path_enumerable', function ($table) {
Expand All @@ -200,3 +249,15 @@ class TestModelEnumerablePath extends \Winter\Storm\Database\Model
'name',
];
}

class TestModelEnumerablePathNameSegment extends \Winter\Storm\Database\Model
{
use \Winter\Storm\Database\Traits\PathEnumerable;

public $table = 'path_enumerable';
public $fillable = [
'name',
];

protected string $segmentColumn = 'name';
}
Loading