Skip to content
Merged
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
24 changes: 20 additions & 4 deletions src/NestedSetsBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -809,15 +809,17 @@ public function leaves(): ActiveQuery
* Sets the internal operation state to {@see self::OPERATION_MAKE_ROOT} and triggers the save process on the owner
* model.
*
* - If the attached {@see ActiveRecord} is a new record, this method creates it as the root node of a new tree.
* - If the attached {@see ActiveRecord} is a new record, this method creates it as the root node of a new tree,
* setting `left=1`, `right=2`, and `depth=0`.
* - If the record already exists, it moves the node to become the root node, updating the nested set structure
* accordingly.
* accordingly and adjusting all affected nodes in the tree.
*
* This operation is essential for initializing or reorganizing hierarchical data structures, such as category
* trees, where nodes can be promoted to root status or new trees can be started.
*
* The actual creation or movement is performed by saving the owner model, which triggers the appropriate lifecycle
* events and updates the tree structure.
* events and updates the tree structure. Upon successful save, the model is automatically refreshed to ensure all
* nested set attributes reflect their current database values.
*
* @param bool $runValidation Whether to perform validation before saving the record.
* @param array|null $attributes List of attributes that need to be saved. Defaults to `null`, meaning all
Expand All @@ -829,7 +831,13 @@ public function leaves(): ActiveQuery
*
* Usage example:
* ```php
* // Create a new root node
* $category = new Category(['name' => 'Electronics']);
* $category->makeRoot();
*
* // Move existing node to become root
* $existingNode = Category::findOne(5);
* $existingNode->makeRoot();
* ```
*
* @phpstan-param array<string, mixed>|null $attributes
Expand All @@ -838,7 +846,15 @@ public function makeRoot(bool $runValidation = true, array|null $attributes = nu
{
$this->operation = self::OPERATION_MAKE_ROOT;

return $this->getOwner()->save($runValidation, $attributes);
$owner = $this->getOwner();

$result = $owner->save($runValidation, $attributes);

if ($result === true) {
$owner->refresh();
}

return $result;
}

/**
Expand Down
131 changes: 131 additions & 0 deletions tests/NestedSetsBehaviorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2322,4 +2322,135 @@ public function testChildrenMethodRequiresOrderByForCorrectTreeTraversal(): void
}
}
}

public function testMakeRootRefreshIsNecessaryForCorrectAttributeValues(): void
{
$this->createDatabase();

$root = new MultipleTree(['name' => 'Original Root']);

$root->makeRoot();

$child1 = new MultipleTree(['name' => 'Child 1']);

$child1->appendTo($root);

$child2 = new MultipleTree(['name' => 'Child 2']);

$child2->appendTo($root);

$grandchild = new MultipleTree(['name' => 'Grandchild']);

$grandchild->appendTo($child1);

$nodeToPromote = MultipleTree::findOne($child1->id);

self::assertNotNull(
$nodeToPromote,
'Child node should exist before promoting to root.',
);
self::assertFalse(
$nodeToPromote->isRoot(),
"Node should not be root before 'makeRoot()' operation.",
);

$originalLeft = $nodeToPromote->getAttribute('lft');
$originalRight = $nodeToPromote->getAttribute('rgt');
$originalDepth = $nodeToPromote->getAttribute('depth');
$originalTree = $nodeToPromote->getAttribute('tree');

$result = $nodeToPromote->makeRoot();

self::assertTrue(
$result,
"'makeRoot()' should return 'true' when converting node to root.",
);
self::assertTrue(
$nodeToPromote->isRoot(),
"Node should be identified as root after 'makeRoot()' - this requires 'refresh()' to work.",
);
self::assertEquals(
1,
$nodeToPromote->getAttribute('lft'),
"Root node left value should be '1' after 'makeRoot()' - requires 'refresh()' to see updated value.",
);
self::assertEquals(
4,
$nodeToPromote->getAttribute('rgt'),
"Root node right value should be '4' after 'makeRoot()' - requires 'refresh()' to see updated value.",
);
self::assertEquals(
0,
$nodeToPromote->getAttribute('depth'),
"Root node depth should be '0' after 'makeRoot()' - requires 'refresh()' to see updated value.",
);
self::assertEquals(
$nodeToPromote->getAttribute('id'),
$nodeToPromote->getAttribute('tree'),
"Tree attribute should equal node ID for new root - requires 'refresh()' to see updated value.",
);
self::assertNotEquals(
$originalLeft,
$nodeToPromote->getAttribute('lft'),
"Left value should have changed from original after 'makeRoot()'.",
);
self::assertNotEquals(
$originalRight,
$nodeToPromote->getAttribute('rgt'),
"Right value should have changed from original after 'makeRoot()'.",
);
self::assertNotEquals(
$originalDepth,
$nodeToPromote->getAttribute('depth'),
"Depth should have changed from original after 'makeRoot()'.",
);
self::assertNotEquals(
$originalTree,
$nodeToPromote->getAttribute('tree'),
"Tree should have changed from original after 'makeRoot()'.",
);

$grandchildAfter = MultipleTree::findOne($grandchild->id);

self::assertNotNull(
$grandchildAfter,
"'Grandchild' should still exist after parent became root.",
);
self::assertEquals(
$nodeToPromote->getAttribute('tree'),
$grandchildAfter->getAttribute('tree'),
"'Grandchild' should be in the same tree as the new root.",
);
self::assertEquals(
1,
$grandchildAfter->getAttribute('depth'),
"'Grandchild' depth should be recalculated relative to new root.",
);

$reloadedNode = MultipleTree::findOne($nodeToPromote->id);

self::assertNotNull(
$reloadedNode,
"Node should exist in database after 'makeRoot()'.",
);
self::assertTrue(
$reloadedNode->isRoot(),
'Reloaded node should be root.',
);
self::assertEquals(
1,
$reloadedNode->getAttribute('lft'),
"Reloaded node should have 'left=1'.",
);
self::assertEquals(
4,
$reloadedNode->getAttribute('rgt'),
"Reloaded node should have 'right=4'.",
);
self::assertEquals(
0,
$reloadedNode->getAttribute('depth'),
"Reloaded node should have 'depth=0'.",
);
}
}