Skip to content
Open
Show file tree
Hide file tree
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
47 changes: 43 additions & 4 deletions src/Database/Traits/NestedTree.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ public function moveToNewParent()
elseif ($parentId !== false) {
$this->makeChildOf($parentId);
}

// The pending move (if any) has been processed and its depth realigned by moveTo(),
// so the depth pass that follows in the model.afterSave event can be skipped.
$this->moveToNewParentId = false;
}

/**
Expand Down Expand Up @@ -781,10 +785,19 @@ public function getChildCount()
/**
* Sets the depth attribute
*
* @param bool $force Recompute the depth even when this save did not move the node.
* @return \Winter\Storm\Database\Model
*/
public function setDepth()
public function setDepth($force = false)
{
/*
* Recomputing the depth costs a reload and an ancestor count inside a transaction on every
* save. Skip it when this save cycle did not move the node and a depth is already stored.
*/
if (!$force && $this->moveToNewParentId === false && $this->getDepth() !== null) {
return $this;
}

$this->getConnection()->transaction(function () {
$this->reload();

Expand Down Expand Up @@ -978,6 +991,12 @@ protected function moveTo($target, $position)
return $this;
}

/*
* Capture the depth before the move. The whole subtree moves intact, so every descendant
* shifts by the same depth difference as this node.
*/
$oldDepth = $this->getDepth();

/*
* Perform move
*/
Expand All @@ -989,10 +1008,30 @@ protected function moveTo($target, $position)
* Reapply alignments
*/
$target->reload();
$this->setDepth();
$this->setDepth(true);

foreach ($this->newQuery()->allChildren()->get() as $descendant) {
$descendant->save();
if ($oldDepth === null) {
// Without a previous depth the shared difference is unknown; realign each descendant
// from its own ancestry instead.
foreach ($this->newQuery()->allChildren()->get() as $descendant) {
$descendant->setDepth(true);
}
}
else {
// Realign all descendants with a single bulk update rather than saving each one,
// which would fire the full model lifecycle per descendant.
$depthDelta = $this->getDepth() - $oldDepth;

if ($depthDelta !== 0) {
$grammar = $this->getConnection()->getQueryGrammar();
$wrappedDepth = $grammar->wrap($this->getDepthColumnName());

$this->newQuery()->allChildren()->update([
$this->getDepthColumnName() => $this->getConnection()->raw(
sprintf('%s + (%d)', $wrappedDepth, $depthDelta)
)
]);
}
}

$this->reload();
Expand Down
83 changes: 83 additions & 0 deletions tests/Database/Traits/NestedTreeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Winter\Storm\Tests\Database\Traits;

use Illuminate\Support\Facades\DB;
use Winter\Storm\Database\Model;
use Winter\Storm\Tests\Database\Fixtures\CategoryNested;
use Winter\Storm\Tests\DbTestCase;
Expand Down Expand Up @@ -443,6 +444,88 @@ public function testToNestedArrayFromCollectionWithoutKey()
], $array);
}

public function testMoveSubtreeRealignsDescendantDepths()
{
$autumn = CategoryNested::where('name', 'Autumn Leaves')->first();
$winter = CategoryNested::where('name', 'Winter Snow')->first();

// Move the 'Autumn Leaves' subtree (three children) one level deeper, under 'Winter Snow'.
$autumn->makeChildOf($winter);
CategoryNested::flushDuplicateCache();

$this->assertEquals(2, CategoryNested::where('name', 'Autumn Leaves')->value('nest_depth'));
foreach (['September', 'October', 'November'] as $name) {
$this->assertEquals(3, CategoryNested::where('name', $name)->value('nest_depth'));
}

// Move the subtree back to the root level (two levels shallower).
CategoryNested::flushDuplicateCache();
CategoryNested::where('name', 'Autumn Leaves')->first()->makeRoot();
CategoryNested::flushDuplicateCache();

$this->assertEquals(0, CategoryNested::where('name', 'Autumn Leaves')->value('nest_depth'));
foreach (['September', 'October', 'November'] as $name) {
$this->assertEquals(1, CategoryNested::where('name', $name)->value('nest_depth'));
}
}

public function testParentIdChangeRealignsDescendantDepths()
{
$autumn = CategoryNested::where('name', 'Autumn Leaves')->first();
$winter = CategoryNested::where('name', 'Winter Snow')->first();

// Reassign the parent through the attribute, as a form submission would.
$autumn->parent_id = $winter->id;
$autumn->save();
CategoryNested::flushDuplicateCache();

$this->assertEquals(2, CategoryNested::where('name', 'Autumn Leaves')->value('nest_depth'));
foreach (['September', 'October', 'November'] as $name) {
$this->assertEquals(3, CategoryNested::where('name', $name)->value('nest_depth'));
}
}

public function testSaveWithoutMoveSkipsDepthRecompute()
{
$september = CategoryNested::where('name', 'September')->first();

// Plant a sentinel depth value behind the model's back.
DB::table('database_tester_categories_nested')
->where('id', $september->id)
->update(['nest_depth' => 42]);

// A save that does not move the node must not pay for a depth recompute.
$september = CategoryNested::find($september->id);
$september->name = 'September (renamed)';
$september->save();
CategoryNested::flushDuplicateCache();

$this->assertEquals(42, CategoryNested::where('id', $september->id)->value('nest_depth'));

// Moving the node realigns the depth from its ancestry again.
$green = CategoryNested::where('name', 'Category Green')->first();
$september->parent_id = $green->id;
$september->save();
CategoryNested::flushDuplicateCache();

$this->assertEquals(1, CategoryNested::where('id', $september->id)->value('nest_depth'));
}

public function testMoveSubtreeQueryCountIsConstant()
{
$autumn = CategoryNested::where('name', 'Autumn Leaves')->first();
$winter = CategoryNested::where('name', 'Winter Snow')->first();

DB::enableQueryLog();
$autumn->makeChildOf($winter);
$queries = count(DB::getQueryLog());
DB::disableQueryLog();

// The bulk depth realignment keeps a move at a fixed number of statements no matter how
// many descendants the subtree has (previously one full save cycle per descendant).
$this->assertLessThan(12, $queries);
}

public function seedSampleTree()
{
Model::unguard();
Expand Down
Loading