Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -27,7 +27,8 @@
}
},
"require-dev": {
"phpunit/phpunit": "7.*|8.*|9.*|^10.5"
"phpunit/phpunit": "7.*|8.*|9.*|^10.5",
"ramsey/uuid": "^4.7"
},
"minimum-stability": "dev",
"prefer-stable": true,
8 changes: 7 additions & 1 deletion phpunit.php
Original file line number Diff line number Diff line change
@@ -9,4 +9,10 @@
$capsule->setAsGlobal();

include __DIR__.'/tests/models/Category.php';
include __DIR__.'/tests/models/MenuItem.php';
include __DIR__.'/tests/models/MenuItem.php';
include __DIR__.'/tests/ScopedNodeTestBase.php';
include __DIR__.'/tests/NodeTestBase.php';
include __DIR__.'/tests/models/CategoryUuid.php';
include __DIR__.'/tests/models/MenuItemUuid.php';
include __DIR__.'/tests/data/CategoryData.php';
include __DIR__.'/tests/data/MenuItemData.php';
11 changes: 8 additions & 3 deletions src/NestedSet.php
Original file line number Diff line number Diff line change
@@ -36,11 +36,16 @@ class NestedSet
*
* @param \Illuminate\Database\Schema\Blueprint $table
*/
public static function columns(Blueprint $table)
public static function columns(Blueprint $table, bool $withUuid = false)
{
$table->unsignedInteger(self::LFT)->default(0);
$table->unsignedInteger(self::RGT)->default(0);
$table->unsignedInteger(self::PARENT_ID)->nullable();

if ($withUuid) {
$table->uuid(self::PARENT_ID)->nullable()->index();
} else {
$table->unsignedInteger(self::PARENT_ID)->nullable();
}

$table->index(static::getDefaultColumns());
}
@@ -80,4 +85,4 @@ public static function isNode($node)
return is_object($node) && in_array(NodeTrait::class, (array)$node);
}

}
}
6 changes: 5 additions & 1 deletion src/NestedSetServiceProvider.php
Original file line number Diff line number Diff line change
@@ -13,8 +13,12 @@ public function register()
NestedSet::columns($this);
});

Blueprint::macro('nestedSetWithUuid', function () {
NestedSet::columns($this, true);
});

Blueprint::macro('dropNestedSet', function () {
NestedSet::dropColumns($this);
});
}
}
}
996 changes: 13 additions & 983 deletions tests/NodeTest.php

Large diffs are not rendered by default.

1,021 changes: 1,021 additions & 0 deletions tests/NodeTestBase.php

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions tests/NodeUuidTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

use Kalnoy\Nestedset\NestedSet;

class NodeUuidTest extends NodeTestBase
{
public function __construct($name = null)
{
parent::__construct($name);
$this->categoryData = new CategoryData();
}

protected function getTable(): string
{
return 'uuid_categories';
}

protected function getModelClass(): string
{
return CategoryUuid::class;
}

protected function createTable(\Illuminate\Database\Schema\Blueprint $table): void
{
$table->uuid('id')->primary();
$table->string('name');
$table->softDeletes();
NestedSet::columns($table, true);
}
}
236 changes: 14 additions & 222 deletions tests/ScopedNodeTest.php
Original file line number Diff line number Diff line change
@@ -1,238 +1,30 @@
<?php

use Illuminate\Database\Capsule\Manager as Capsule;
use Kalnoy\Nestedset\NestedSet;

class ScopedNodeTest extends PHPUnit\Framework\TestCase
class ScopedNodeTest extends ScopedNodeTestBase
{
public static function setUpBeforeClass(): void
public function __construct($name = null)
{
$schema = Capsule::schema();

$schema->dropIfExists('menu_items');

Capsule::disableQueryLog();

$schema->create('menu_items', function (\Illuminate\Database\Schema\Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('menu_id');
$table->string('title')->nullable();
NestedSet::columns($table);
});

Capsule::enableQueryLog();
}

public function setUp(): void
{
$data = include __DIR__.'/data/menu_items.php';

Capsule::table('menu_items')->insert($data);

Capsule::flushQueryLog();

MenuItem::resetActionsPerformed();

date_default_timezone_set('America/Denver');
}

public function tearDown(): void
{
Capsule::table('menu_items')->truncate();
}

public function assertTreeNotBroken($menuId)
{
$this->assertFalse(MenuItem::scoped([ 'menu_id' => $menuId ])->isBroken());
}

public function testNotBroken()
{
$this->assertTreeNotBroken(1);
$this->assertTreeNotBroken(2);
}

public function testMovingNodeNotAffectingOtherMenu()
{
$node = MenuItem::where('menu_id', '=', 1)->first();

$node->down();

$node = MenuItem::where('menu_id', '=', 2)->first();

$this->assertEquals(1, $node->getLft());
parent::__construct($name);
$this->menuItemData = new MenuItemData();
}

public function testScoped()
protected function getTable(): string
{
$node = MenuItem::scoped([ 'menu_id' => 2 ])->first();

$this->assertEquals(3, $node->getKey());
}

public function testSiblings()
{
$node = MenuItem::find(1);

$result = $node->getSiblings();

$this->assertEquals(1, $result->count());
$this->assertEquals(2, $result->first()->getKey());

$result = $node->getNextSiblings();

$this->assertEquals(2, $result->first()->getKey());

$node = MenuItem::find(2);

$result = $node->getPrevSiblings();

$this->assertEquals(1, $result->first()->getKey());
}

public function testDescendants()
{
$node = MenuItem::find(2);

$result = $node->getDescendants();

$this->assertEquals(1, $result->count());
$this->assertEquals(5, $result->first()->getKey());

$node = MenuItem::scoped([ 'menu_id' => 1 ])->with('descendants')->find(2);

$result = $node->descendants;

$this->assertEquals(1, $result->count());
$this->assertEquals(5, $result->first()->getKey());
return 'menu_items';
}

public function testAncestors()
protected function getModelClass(): string
{
$node = MenuItem::find(5);

$result = $node->getAncestors();

$this->assertEquals(1, $result->count());
$this->assertEquals(2, $result->first()->getKey());

$node = MenuItem::scoped([ 'menu_id' => 1 ])->with('ancestors')->find(5);

$result = $node->ancestors;

$this->assertEquals(1, $result->count());
$this->assertEquals(2, $result->first()->getKey());
return MenuItem::class;
}

public function testDepth()
protected function createTable(\Illuminate\Database\Schema\Blueprint $table): void
{
$node = MenuItem::scoped([ 'menu_id' => 1 ])->withDepth()->where('id', '=', 5)->first();

$this->assertEquals(1, $node->depth);

$node = MenuItem::find(2);

$result = $node->children()->withDepth()->get();

$this->assertEquals(1, $result->first()->depth);
}

public function testSaveAsRoot()
{
$node = MenuItem::find(5);

$node->saveAsRoot();

$this->assertEquals(5, $node->getLft());
$this->assertEquals(null, $node->parent_id);

$this->assertOtherScopeNotAffected();
}

public function testInsertion()
{
$node = MenuItem::create([ 'menu_id' => 1, 'parent_id' => 5 ]);

$this->assertEquals(5, $node->parent_id);
$this->assertEquals(5, $node->getLft());

$this->assertOtherScopeNotAffected();
}

public function testInsertionToParentFromOtherScope()
{
$this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class);

$node = MenuItem::create([ 'menu_id' => 2, 'parent_id' => 5 ]);
}

public function testDeletion()
{
$node = MenuItem::find(2)->delete();

$node = MenuItem::find(1);

$this->assertEquals(2, $node->getRgt());

$this->assertOtherScopeNotAffected();
}

public function testMoving()
{
$node = MenuItem::find(1);
$this->assertTrue($node->down());

$this->assertOtherScopeNotAffected();
}

protected function assertOtherScopeNotAffected()
{
$node = MenuItem::find(3);

$this->assertEquals(1, $node->getLft());
}

// Commented, cause there is no assertion here and otherwise the test is marked as risky in PHPUnit 7.
// What's the purpose of this method? @todo: remove/update?
/*public function testRebuildsTree()
{
$data = [];
MenuItem::scoped([ 'menu_id' => 2 ])->rebuildTree($data);
}*/

public function testAppendingToAnotherScopeFails()
{
$this->expectException(LogicException::class);

$a = MenuItem::find(1);
$b = MenuItem::find(3);

$a->appendToNode($b)->save();
}

public function testInsertingBeforeAnotherScopeFails()
{
$this->expectException(LogicException::class);

$a = MenuItem::find(1);
$b = MenuItem::find(3);

$a->insertAfterNode($b);
}

public function testEagerLoadingAncestorsWithScope()
{
$filteredNodes = MenuItem::where('title', 'menu item 3')->with(['ancestors'])->get();

$this->assertEquals(2, $filteredNodes->find(5)->ancestors[0]->id);
$this->assertEquals(4, $filteredNodes->find(6)->ancestors[0]->id);
}

public function testEagerLoadingDescendantsWithScope()
{
$filteredNodes = MenuItem::where('title', 'menu item 2')->with(['descendants'])->get();

$this->assertEquals(5, $filteredNodes->find(2)->descendants[0]->id);
$this->assertEquals(6, $filteredNodes->find(4)->descendants[0]->id);
$table->increments('id');
$table->unsignedInteger('menu_id');
$table->string('title')->nullable();
NestedSet::columns($table);
}
}
}
251 changes: 251 additions & 0 deletions tests/ScopedNodeTestBase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
<?php

use Illuminate\Database\Capsule\Manager as Capsule;

abstract class ScopedNodeTestBase extends PHPUnit\Framework\TestCase
{
abstract protected function getTable(): string;

abstract protected function getModelClass(): string;

abstract protected function createTable(\Illuminate\Database\Schema\Blueprint $table): void;

protected array $ids = [];
protected MenuItemData $menuItemData;

protected static function getTableName(): string
{
$testClass = get_called_class();
return (new $testClass('dummy'))->getTable();
}

public static function setUpBeforeClass(): void
{
$schema = Capsule::schema();
$table = static::getTableName();

$schema->dropIfExists($table);

Capsule::disableQueryLog();

$schema->create($table, function (\Illuminate\Database\Schema\Blueprint $table) {
$testClass = get_called_class();
(new $testClass('dummy'))->createTable($table);
});

Capsule::enableQueryLog();
}

public function setUp(): void
{
$this->ids = $this->menuItemData->getIds();
Capsule::table($this->getTable())->insert($this->menuItemData->getData());

Capsule::flushQueryLog();

$modelClass = $this->getModelClass();
$modelClass::resetActionsPerformed();

date_default_timezone_set('America/Denver');
}

public function tearDown(): void
{
Capsule::table($this->getTable())->truncate();
}

public function assertTreeNotBroken($menuId)
{
$this->assertFalse($this->getModelClass()::scoped(['menu_id' => $menuId])->isBroken());
}

public function testNotBroken()
{
$this->assertTreeNotBroken(1);
$this->assertTreeNotBroken(2);
}

public function testMovingNodeNotAffectingOtherMenu()
{
$node = $this->getModelClass()::where('menu_id', '=', 1)->first();

$node->down();

$node = $this->getModelClass()::where('menu_id', '=', 2)->first();

$this->assertEquals(1, $node->getLft());
}

public function testScoped()
{
$node = $this->getModelClass()::scoped(['menu_id' => 2])->first();

$this->assertEquals($this->ids[3], $node->getKey());
}

public function testSiblings()
{
$node = $this->getModelClass()::find($this->ids[1]);

$result = $node->getSiblings();

$this->assertEquals(1, $result->count());
$this->assertEquals($this->ids[2], $result->first()->getKey());

$result = $node->getNextSiblings();

$this->assertEquals($this->ids[2], $result->first()->getKey());

$node = $this->getModelClass()::find($this->ids[2]);

$result = $node->getPrevSiblings();

$this->assertEquals($this->ids[1], $result->first()->getKey());
}

public function testDescendants()
{
$node = $this->getModelClass()::find($this->ids[2]);

$result = $node->getDescendants();

$this->assertEquals(1, $result->count());
$this->assertEquals($this->ids[5], $result->first()->getKey());

$node = $this->getModelClass()::scoped(['menu_id' => 1])->with('descendants')->find($this->ids[2]);

$result = $node->descendants;

$this->assertEquals(1, $result->count());
$this->assertEquals($this->ids[5], $result->first()->getKey());
}

public function testAncestors()
{
$node = $this->getModelClass()::find($this->ids[5]);

$result = $node->getAncestors();

$this->assertEquals(1, $result->count());
$this->assertEquals($this->ids[2], $result->first()->getKey());

$node = $this->getModelClass()::scoped(['menu_id' => 1])->with('ancestors')->find($this->ids[5]);

$result = $node->ancestors;

$this->assertEquals(1, $result->count());
$this->assertEquals($this->ids[2], $result->first()->getKey());
}

public function testDepth()
{
$node = $this->getModelClass()::scoped(['menu_id' => 1])->withDepth()->where('id', '=', $this->ids[5])->first();

$this->assertEquals(1, $node->depth);

$node = $this->getModelClass()::find($this->ids[2]);

$result = $node->children()->withDepth()->get();

$this->assertEquals(1, $result->first()->depth);
}

public function testSaveAsRoot()
{
$node = $this->getModelClass()::find($this->ids[5]);

$node->saveAsRoot();

$this->assertEquals(5, $node->getLft());
$this->assertEquals(null, $node->parent_id);

$this->assertOtherScopeNotAffected();
}

public function testInsertion()
{
$node = $this->getModelClass()::create(['menu_id' => 1, 'parent_id' => $this->ids[5]]);

$this->assertEquals($this->ids[5], $node->parent_id);
$this->assertEquals(5, $node->getLft());

$this->assertOtherScopeNotAffected();
}

public function testInsertionToParentFromOtherScope()
{
$this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class);

$node = $this->getModelClass()::create(['menu_id' => 2, 'parent_id' => $this->ids[5]]);
}

public function testDeletion()
{
$node = $this->getModelClass()::find($this->ids[2])->delete();

$node = $this->getModelClass()::find($this->ids[1]);

$this->assertEquals(2, $node->getRgt());

$this->assertOtherScopeNotAffected();
}

public function testMoving()
{
$node = $this->getModelClass()::find($this->ids[1]);
$this->assertTrue($node->down());

$this->assertOtherScopeNotAffected();
}

protected function assertOtherScopeNotAffected()
{
$node = $this->getModelClass()::find($this->ids[3]);

$this->assertEquals(1, $node->getLft());
}

// Commented, cause there is no assertion here and otherwise the test is marked as risky in PHPUnit 7.
// What's the purpose of this method? @todo: remove/update?
/*public function testRebuildsTree()
{
$data = [];
$this->getModelClass()::scoped([ 'menu_id' => 2 ])->rebuildTree($data);
}*/

public function testAppendingToAnotherScopeFails()
{
$this->expectException(LogicException::class);

$a = $this->getModelClass()::find($this->ids[1]);
$b = $this->getModelClass()::find($this->ids[3]);

$a->appendToNode($b)->save();
}

public function testInsertingBeforeAnotherScopeFails()
{
$this->expectException(LogicException::class);

$a = $this->getModelClass()::find($this->ids[1]);
$b = $this->getModelClass()::find($this->ids[3]);

$a->insertAfterNode($b);
}

public function testEagerLoadingAncestorsWithScope()
{
$filteredNodes = $this->getModelClass()::where('title', 'menu item 3')->with(['ancestors'])->get();

$this->assertEquals($this->ids[2], $filteredNodes->find($this->ids[5])->ancestors[0]->id);
$this->assertEquals($this->ids[4], $filteredNodes->find($this->ids[6])->ancestors[0]->id);
}

public function testEagerLoadingDescendantsWithScope()
{
$filteredNodes = $this->getModelClass()::where('title', 'menu item 2')->with(['descendants'])->get();

$this->assertEquals($this->ids[5], $filteredNodes->find($this->ids[2])->descendants[0]->id);
$this->assertEquals($this->ids[6], $filteredNodes->find($this->ids[4])->descendants[0]->id);
}
}
30 changes: 30 additions & 0 deletions tests/ScopedNodeUuidTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

use Kalnoy\Nestedset\NestedSet;

class ScopedNodeUuidTest extends ScopedNodeTestBase
{
public function __construct($name = null)
{
parent::__construct($name);
$this->menuItemData = new MenuItemData();
}

protected function getTable(): string
{
return 'uuid_menu_items';
}

protected function getModelClass(): string
{
return MenuItemUuid::class;
}

protected function createTable(\Illuminate\Database\Schema\Blueprint $table): void
{
$table->uuid('id')->primary();
$table->unsignedInteger('menu_id');
$table->string('title')->nullable();
NestedSet::columns($table, true);
}
}
42 changes: 42 additions & 0 deletions tests/data/CategoryData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

class CategoryData
{
private array $ids;

public function __construct(bool $withUuid = false)
{
if ($withUuid) {
for ($i = 1; $i <= 200; $i++) {
$this->ids[$i] = (string)\Illuminate\Support\Str::uuid();
}
} else {
$this->ids = array_combine(range(1, 200), range(1, 200));
}
}

public function getData(): array
{
return array(
array('id' => $this->ids[1], 'name' => 'store', '_lft' => 1, '_rgt' => 20, 'parent_id' => null),
array('id' => $this->ids[2], 'name' => 'notebooks', '_lft' => 2, '_rgt' => 7, 'parent_id' => $this->ids[1]),
array('id' => $this->ids[3], 'name' => 'apple', '_lft' => 3, '_rgt' => 4, 'parent_id' => $this->ids[2]),
array('id' => $this->ids[4], 'name' => 'lenovo', '_lft' => 5, '_rgt' => 6, 'parent_id' => $this->ids[2]),
array('id' => $this->ids[5], 'name' => 'mobile', '_lft' => 8, '_rgt' => 19, 'parent_id' => $this->ids[1]),
array('id' => $this->ids[6], 'name' => 'nokia', '_lft' => 9, '_rgt' => 10, 'parent_id' => $this->ids[5]),
array('id' => $this->ids[7], 'name' => 'samsung', '_lft' => 11, '_rgt' => 14, 'parent_id' => $this->ids[5]),
array('id' => $this->ids[8], 'name' => 'galaxy', '_lft' => 12, '_rgt' => 13, 'parent_id' => $this->ids[7]),
array('id' => $this->ids[9], 'name' => 'sony', '_lft' => 15, '_rgt' => 16, 'parent_id' => $this->ids[5]),
array('id' => $this->ids[10], 'name' => 'lenovo', '_lft' => 17, '_rgt' => 18, 'parent_id' => $this->ids[5]),
array('id' => $this->ids[11], 'name' => 'store_2', '_lft' => 21, '_rgt' => 22, 'parent_id' => null),
);
}

public function getIds(): array
{
return $this->ids;
}
}



36 changes: 36 additions & 0 deletions tests/data/MenuItemData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

class MenuItemData
{
private array $ids;

public function __construct(bool $withUuid = false)
{
if ($withUuid) {
for ($i = 1; $i <= 6; $i++) {
$this->ids[$i] = (string) \Illuminate\Support\Str::uuid();
}
} else {
$this->ids = array_combine(range(1, 10), range(1, 10));
}
}

public function getData(): array
{
return [
array('id' => $this->ids[1], 'menu_id' => 1, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1'),
array('id' => $this->ids[2], 'menu_id' => 1, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2'),
array('id' => $this->ids[5], 'menu_id' => 1, '_lft' => 4, '_rgt' => 5, 'parent_id' => $this->ids[2], 'title' => 'menu item 3'),
array('id' => $this->ids[3], 'menu_id' => 2, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1'),
array('id' => $this->ids[4], 'menu_id' => 2, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2'),
array('id' => $this->ids[6], 'menu_id' => 2, '_lft' => 4, '_rgt' => 5, 'parent_id' => $this->ids[4], 'title' => 'menu item 3'),
];
}

public function getIds(): array {
return $this->ids;
}
}



15 changes: 0 additions & 15 deletions tests/data/categories.php

This file was deleted.

8 changes: 0 additions & 8 deletions tests/data/menu_items.php

This file was deleted.

12 changes: 12 additions & 0 deletions tests/models/CategoryUuid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

class CategoryUuid extends Category
{
use \Illuminate\Database\Eloquent\Concerns\HasUuids;

protected $table = 'uuid_categories';

protected $keyType = 'string';

public $incrementing = false;
}
12 changes: 12 additions & 0 deletions tests/models/MenuItemUuid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

class MenuItemUuid extends MenuItem
{
use \Illuminate\Database\Eloquent\Concerns\HasUuids;

protected $table = 'uuid_menu_items';

protected $keyType = 'string';

public $incrementing = false;
}