From 2352437ac7963befd4adee2347267c47ed414a91 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 4 Jul 2025 06:24:27 -0400 Subject: [PATCH 1/9] refactor: Implement code changes to enhance functionality and improve performance. --- ecs.php | 23 +- src/NestedSetsBehavior.php | 122 +- tests/NestedSetsBehaviorTest.php | 3869 ++++++++++++++++-------------- 3 files changed, 2079 insertions(+), 1935 deletions(-) diff --git a/ecs.php b/ecs.php index e323723..4f9879b 100644 --- a/ecs.php +++ b/ecs.php @@ -18,6 +18,28 @@ 'space_before_parenthesis' => true, ], ) + ->withConfiguredRule( + OrderedClassElementsFixer::class, + [ + 'order' => [ + 'use_trait', + 'constant_public', + 'constant_protected', + 'constant_private', + 'property_public', + 'property_protected', + 'property_private', + 'construct', + 'destruct', + 'magic', + 'phpunit', + 'method_public', + 'method_protected', + 'method_private', + ], + 'sort_algorithm' => 'alpha', + ], + ) ->withConfiguredRule( OrderedImportsFixer::class, [ @@ -49,7 +71,6 @@ ->withRules( [ NoUnusedImportsFixer::class, - OrderedClassElementsFixer::class, OrderedTraitsFixer::class, SingleQuoteFixer::class, ] diff --git a/src/NestedSetsBehavior.php b/src/NestedSetsBehavior.php index 8fbaee0..308fb80 100644 --- a/src/NestedSetsBehavior.php +++ b/src/NestedSetsBehavior.php @@ -75,43 +75,35 @@ class NestedSetsBehavior extends Behavior public const OPERATION_PREPEND_TO = 'prependTo'; /** - * Holds the reference to the current node involved in a nested set operation. - * - * Stores the {@see ActiveRecord} instance representing the node being manipulated by the behavior during operations - * such as insertion, movement, or deletion within the tree structure. - * - * @template T of NestedSetsBehavior + * Name of the attribute that stores the depth (level) of the node in the tree. * - * @phpstan-var ActiveRecord|null + * @phpstan-var 'depth' attribute name. */ - protected ActiveRecord|null $node = null; + public string $depthAttribute = 'depth'; /** - * Stores the current operation being performed on the node. + * Name of the attribute that stores the left boundary value of the node in the nested set tree. * - * Holds the operation type as a string identifier, such as 'appendTo', 'deleteWithChildren', or other defined - * operation constants. + * @phpstan-var 'lft' attribute name. */ - protected string|null $operation = null; + public string $leftAttribute = 'lft'; /** - * Name of the attribute that stores the depth (level) of the node in the tree. + * Name of the attribute that stores the right boundary value of the node in the nested set tree. * - * @phpstan-var 'depth' attribute name. + * @phpstan-var 'rgt' attribute name. */ - public string $depthAttribute = 'depth'; + public string $rightAttribute = 'rgt'; /** - * Stores the depth value for the current operation. + * Name of the attribute that stores the tree identifier for supporting multiple trees. */ - protected int|null $depthValue = null; + public string|false $treeAttribute = false; /** - * Name of the attribute that stores the left boundary value of the node in the nested set tree. - * - * @phpstan-var 'lft' attribute name. + * Stores the depth value for the current operation. */ - public string $leftAttribute = 'lft'; + protected int|null $depthValue = null; /** * Stores the left value for the current operation. @@ -119,21 +111,29 @@ class NestedSetsBehavior extends Behavior protected int|null $leftValue = null; /** - * Name of the attribute that stores the right boundary value of the node in the nested set tree. + * Holds the reference to the current node involved in a nested set operation. * - * @phpstan-var 'rgt' attribute name. + * Stores the {@see ActiveRecord} instance representing the node being manipulated by the behavior during operations + * such as insertion, movement, or deletion within the tree structure. + * + * @template T of NestedSetsBehavior + * + * @phpstan-var ActiveRecord|null */ - public string $rightAttribute = 'rgt'; + protected ActiveRecord|null $node = null; /** - * Stores the right value for the current operation. + * Stores the current operation being performed on the node. + * + * Holds the operation type as a string identifier, such as 'appendTo', 'deleteWithChildren', or other defined + * operation constants. */ - protected int|null $rightValue = null; + protected string|null $operation = null; /** - * Name of the attribute that stores the tree identifier for supporting multiple trees. + * Stores the right value for the current operation. */ - public string|false $treeAttribute = false; + protected int|null $rightValue = null; /** * Database connection instance used for executing queries. @@ -275,30 +275,6 @@ public function afterUpdate(): void $this->invalidateCache(); } - /** - * Invalidates cached attribute values and resets internal state. - * - * Clears the cached depth, left, and right attribute values, forcing them to be re-fetched from the owner model - * on next access. - * - * This method should be called after operations that modify the owner model's attributes to ensure that cached - * values remain consistent with the actual model state. - * - * Usage example: - * ```php - * // After modifying the model's attributes externally - * $behavior->invalidateCache(); - * ``` - */ - public function invalidateCache(): void - { - $this->depthValue = null; - $this->leftValue = null; - $this->node = null; - $this->operation = null; - $this->rightValue = null; - } - /** * Appends the current node as the last child of the specified target node. * @@ -669,6 +645,30 @@ public function insertBefore(ActiveRecord $node, bool $runValidation = true, arr return $this->executeOperation($node, self::OPERATION_INSERT_BEFORE, $runValidation, $attributes); } + /** + * Invalidates cached attribute values and resets internal state. + * + * Clears the cached depth, left, and right attribute values, forcing them to be re-fetched from the owner model + * on next access. + * + * This method should be called after operations that modify the owner model's attributes to ensure that cached + * values remain consistent with the actual model state. + * + * Usage example: + * ```php + * // After modifying the model's attributes externally + * $behavior->invalidateCache(); + * ``` + */ + public function invalidateCache(): void + { + $this->depthValue = null; + $this->leftValue = null; + $this->node = null; + $this->operation = null; + $this->rightValue = null; + } + /** * Determines whether the current node is a direct or indirect child of the specified parent node. * @@ -1383,20 +1383,12 @@ private function getDb(): Connection private function getDepthValue(): int { - if ($this->depthValue === null) { - $this->depthValue = $this->getOwner()->getAttribute($this->depthAttribute); - } - - return $this->depthValue; + return $this->depthValue ??= $this->getOwner()->getAttribute($this->depthAttribute); } private function getLeftValue(): int { - if ($this->leftValue === null) { - $this->leftValue = $this->getOwner()->getAttribute($this->leftAttribute); - } - - return $this->leftValue; + return $this->leftValue ??= $this->getOwner()->getAttribute($this->leftAttribute); } /** @@ -1425,11 +1417,7 @@ private function getOwner(): ActiveRecord private function getRightValue(): int { - if ($this->rightValue === null) { - $this->rightValue = $this->getOwner()->getAttribute($this->rightAttribute); - } - - return $this->rightValue; + return $this->rightValue ??= $this->getOwner()->getAttribute($this->rightAttribute); } /** diff --git a/tests/NestedSetsBehaviorTest.php b/tests/NestedSetsBehaviorTest.php index 1651525..c6d789a 100644 --- a/tests/NestedSetsBehaviorTest.php +++ b/tests/NestedSetsBehaviorTest.php @@ -24,1241 +24,1518 @@ final class NestedSetsBehaviorTest extends TestCase { - public function testReturnTrueAndMatchXmlAfterMakeRootNewForTreeAndMultipleTree(): void + public function testAfterInsertCacheInvalidationIntegration(): void { $this->createDatabase(); - $nodeTree = new Tree(['name' => 'Root']); + $root = new MultipleTree(['name' => 'Original Root']); - self::assertTrue( - $nodeTree->makeRoot(), - "'makeRoot()' should return 'true' when creating a new root node in 'Tree'.", - ); + $root->makeRoot(); - $nodeMultipleTree = new MultipleTree(['name' => 'Root 1']); + $child = new MultipleTree(['name' => 'Child Node']); - self::assertTrue( - $nodeMultipleTree->makeRoot(), - "'makeRoot()' should return 'true' when creating the first root node in 'MultipleTree'.", - ); + $child->appendTo($root); - $nodeMultipleTree = new MultipleTree(['name' => 'Root 2']); + $behavior = $child->getBehavior('nestedSetsBehavior'); - self::assertTrue( - $nodeMultipleTree->makeRoot(), - "'makeRoot()' should return 'true' when creating a second root node in 'MultipleTree'.", + self::assertNotNull( + $behavior, + 'Behavior should be attached to the child node.', ); - - $simpleXML = $this->loadFixtureXML('test-make-root-new.xml'); - - self::assertSame( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'makeRoot()' must match the expected XML structure.", + self::assertEquals( + 1, + $child->getAttribute('depth'), + "Child should start at depth '1'.", + ); + self::assertEquals( + 2, + $child->getAttribute('lft'), + "Child should start with 'lft=2'.", + ); + self::assertEquals( + 3, + $child->getAttribute('rgt'), + "Child should start with 'rgt=3'.", ); - } - public function testThrowExceptionWhenMakeRootWithTreeAttributeFalseAndRootExists(): void - { - $this->generateFixtureTree(); + $this->populateAndVerifyCache($behavior); - $node = new Tree(['name' => 'Root']); + $child->makeRoot(); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not create more than one root when "treeAttribute" is false.'); + $this->verifyCacheInvalidation($behavior); - $node->makeRoot(); + self::assertEquals( + 0, + $child->getAttribute('depth'), + "Child should be at depth '0' after becoming root.", + ); + self::assertEquals( + 1, + $child->getAttribute('lft'), + "Child should have 'lft=1' after becoming root.", + ); + self::assertEquals( + 2, + $child->getAttribute('rgt'), + "Child should have 'rgt=2' after becoming root.", + ); + self::assertEquals( + 0, + Assert::invokeMethod($behavior, 'getDepthValue'), + "New cached depth should be '0'.", + ); + self::assertEquals( + 1, + Assert::invokeMethod($behavior, 'getLeftValue'), + "New cached left should be '1'.", + ); + self::assertEquals( + 2, + Assert::invokeMethod($behavior, 'getRightValue'), + "New cached right should be '2'.", + ); } - public function testReturnTrueAndMatchXmlAfterPrependToNewNodeForTreeAndMultipleTree(): void + public function testAfterInsertCallsInvalidateCache(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = new Tree(['name' => 'New node']); + $node = new ExtendableMultipleTree(['name' => 'Root Node']); - $childOfNode = Tree::findOne(9); + $behavior = $node->getBehavior('nestedSetsBehavior'); - self::assertNotNull( - $childOfNode, - "Node with ID '9' must exist before calling 'prependTo()' on it in 'Tree'.", - ); - self::assertTrue( - $node->prependTo($childOfNode), - "'prependTo()' should return 'true' when prepending a new node to node '9' in 'Tree'.", + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $behavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", ); - $node = new MultipleTree(['name' => 'New node']); - - $childOfNode = MultipleTree::findOne(31); + $node->makeRoot(); - self::assertNotNull( - $childOfNode, - "Node with ID '31' must exist before calling 'prependTo()' on it in 'MultipleTree'.", - ); self::assertTrue( - $node->prependTo($childOfNode), - "'prependTo()' should return 'true' when prepending a new node to node '31' in 'MultipleTree'.", + $behavior->invalidateCacheCalled, + "'invalidateCache()' should be called during 'afterInsert()'.", ); - - $simpleXML = $this->loadFixtureXML('test-prepend-to-new.xml'); - - self::assertSame( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'prependTo()' must match the expected XML structure.", + self::assertNotFalse( + $node->treeAttribute, + 'Tree attribute should be set.', + ); + self::assertNotNull( + $node->getAttribute($node->treeAttribute), + "Tree attribute should be set after 'afterInsert()'.", + ); + self::assertEquals( + $node->getPrimaryKey(), + $node->getAttribute($node->treeAttribute), + 'Tree attribute should equal primary key for root node.', ); } - public function testThrowExceptionWhenAppendToNewNodeTargetIsNewRecord(): void + public function testAfterUpdateCacheInvalidationWhenMakeRoot(): void { - $this->generateFixtureTree(); - - $node = new Tree(['name' => 'New node']); + $this->createDatabase(); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not create a node when the target node is new record.'); + $root = new ExtendableMultipleTree(['name' => 'Root']); - $node->appendTo(new Tree()); - } + $root->makeRoot(); - public function testReturnTrueAndMatchXmlAfterInsertBeforeNewForTreeAndMultipleTree(): void - { - $this->generateFixtureTree(); + $child = new ExtendableMultipleTree(['name' => 'Child']); - $node = new Tree(['name' => 'New node']); + $child->appendTo($root); - $childOfNode = Tree::findOne(9); + $behavior = $child->getBehavior('nestedSetsBehavior'); - self::assertNotNull( - $childOfNode, - "Node with ID '9' should exist before calling 'insertBefore()' on it in 'Tree'.", - ); - self::assertTrue( - $node->insertBefore($childOfNode), - "'insertBefore()' should return 'true' when inserting a new node before node '9' in 'Tree'.", + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $behavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", ); - $node = new MultipleTree(['name' => 'New node']); - - $childOfNode = MultipleTree::findOne(31); - - self::assertNotNull( - $childOfNode, - "Node with ID '31' should exist before calling 'insertBefore()' on it in 'MultipleTree'.", - ); - self::assertTrue( - $node->insertBefore($childOfNode), - "'insertBefore()' should return 'true' when inserting a new node before node '31' in 'MultipleTree'.", - ); + $this->populateAndVerifyCache($behavior); - $simpleXML = $this->loadFixtureXML('test-insert-before-new.xml'); + $behavior->setOperation(NestedSetsBehavior::OPERATION_MAKE_ROOT); + $behavior->afterUpdate(); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'insertBefore()' must match the expected XML structure.", - ); + $this->verifyCacheInvalidation($behavior); } - public function testThrowExceptionWhenInsertBeforeNewNodeTargetIsNewRecord(): void + public function testAfterUpdateCacheInvalidationWhenMakeRootAndNodeItsNull(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = new Tree(['name' => 'New node']); + $root = new ExtendableMultipleTree(['name' => 'Root']); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not create a node when the target node is new record.'); + $root->makeRoot(); - $node->insertBefore(new Tree()); - } + $child = new ExtendableMultipleTree(['name' => 'Child']); - public function testThrowExceptionWhenInsertBeforeNewNodeTargetIsRoot(): void - { - $this->generateFixtureTree(); + $child->appendTo($root); - $node = new Tree(['name' => 'New node']); - $rootNode = Tree::findOne(1); + $behavior = $child->getBehavior('nestedSetsBehavior'); - self::assertNotNull( - $rootNode, - "Root node with ID '1' should exist before calling 'insertBefore()' on it in 'Tree'.", + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $behavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not create a node when the target node is root.'); + $this->populateAndVerifyCache($behavior); - $node->insertBefore($rootNode); + $behavior->setNode(null); + $behavior->afterUpdate(); + + $this->verifyCacheInvalidation($behavior); } - public function testReturnTrueAndMatchXmlAfterInsertAfterNewForTreeAndMultipleTree(): void + public function testAppendChildNodeToRootCreatesValidTreeStructure(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = new Tree(['name' => 'New node']); + $root = new Tree(['name' => 'Root']); - $childOfNode = Tree::findOne(9); + $root->makeRoot(); - self::assertNotNull( - $childOfNode, - "Node with ID '9' must exist before calling 'insertAfter()' on it in 'Tree'.", + self::assertEquals( + 1, + $root->lft, + "Root node left value should be '1' after 'makeRoot()'.", ); - - self::assertTrue( - $node->insertAfter($childOfNode), - "'insertAfter()' should return 'true' when inserting a new node after node '9' in 'Tree'.", + self::assertEquals( + 2, + $root->rgt, + "Root node right value should be '2' after 'makeRoot()'.", + ); + self::assertEquals( + 0, + $root->depth, + "Root node depth should be '0' after 'makeRoot()'.", ); - $node = new MultipleTree(['name' => 'New node']); + $child = new Tree(['name' => 'Child']); - $childOfNode = MultipleTree::findOne(31); + try { + $result = $child->appendTo($root); - self::assertNotNull( - $childOfNode, - "Node with ID '31' must exist before calling 'insertAfter()' on it in 'MultipleTree'.", - ); - self::assertTrue( - $node->insertAfter($childOfNode), - "'insertAfter()' should return 'true' when inserting a new node after node '31' in 'MultipleTree'.", - ); + self::assertTrue( + $result, + "'appendTo()' should return 'true' when successfully appending a child node.", + ); - $simpleXML = $this->loadFixtureXML('test-insert-after-new.xml'); + $root->refresh(); + $child->refresh(); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'insertAfter()' must match the expected XML structure.", - ); + self::assertGreaterThan( + $child->lft, + $child->rgt, + "Child node right value should be greater than its left value after 'appendTo()'.", + ); + self::assertEquals( + 1, + $child->depth, + "Child node depth should be '1' after being 'appendTo()' the root node.", + ); + } catch (Exception $e) { + self::fail('Real insertion failed: ' . $e->getMessage()); + } } - public function testThrowExceptionWhenInsertAfterNewNodeTargetIsRoot(): void + public function testAppendToWithRunValidationParameterUsingStrictValidation(): void { $this->generateFixtureTree(); - $node = new Tree(['name' => 'New node']); - - $rootNode = Tree::findOne(1); + $targetNode = Tree::findOne(2); self::assertNotNull( - $rootNode, - "Root node with ID '1' should exist before calling 'insertAfter()' on it in 'Tree'.", + $targetNode, + "Target node with ID '2' should exist before calling 'appendTo()'.", ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not create a node when the target node is root.'); - - $node->insertAfter($rootNode); - } - - public function testReturnTrueAndMatchXmlAfterMakeRootOnExistingMultipleTreeNode(): void - { - $this->generateFixtureTree(); + $invalidNode = new TreeWithStrictValidation(['name' => 'x']); - $node = MultipleTree::findOne(31); + $result1 = $invalidNode->appendTo($targetNode); + $hasError1 = $invalidNode->hasErrors(); - self::assertNotNull( - $node, - "Node with ID '31' must exist before calling 'makeRoot()' on it in 'MultipleTree'.", + self::assertFalse( + $result1, + "'appendTo()' should return 'false' when 'runValidation=true' and data fails validation.", + ); + self::assertTrue( + $hasError1, + "Node should have validation errors when 'runValidation=true' and data is invalid.", ); - $node->name = 'Updated node 2'; + $invalidNode2 = new TreeWithStrictValidation(['name' => 'x']); + + $result2 = $invalidNode2->appendTo($targetNode, false); + $hasError2 = $invalidNode2->hasErrors(); self::assertTrue( - $node->makeRoot(), - "'makeRoot()' should return 'true' when called on node '31' in 'MultipleTree'.", + $result2, + "'appendTo()' should return 'true' when 'runValidation=false', even with invalid data that would " . + 'fail validation.', + ); + self::assertFalse( + $hasError2, + "Node should not have validation errors when 'runValidation=false' because validation was skipped.", ); - $simpleXML = $this->loadFixtureXML('test-make-root-exists.xml'); + $persistedNode = TreeWithStrictValidation::findOne($invalidNode2->id); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSetMultipleTree()), - $simpleXML->asXML(), - "Resulting dataset after 'makeRoot()' must match the expected XML structure for 'MultipleTree'.", + self::assertNotNull( + $persistedNode, + 'Node should exist in database after appending to target node with validation disabled.', ); } - public function testThrowExceptionWhenMakeRootOnNonRootNodeWithTreeAttributeFalse(): void + public function testCacheInvalidationAfterAppendTo(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = Tree::findOne(9); + $root = new MultipleTree(['name' => 'Root']); - self::assertNotNull($node, "Node with ID '9' should exist before calling 'makeRoot()' on it in 'Tree'."); + $root->makeRoot(); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node as the root when "treeAttribute" is false.'); + $child1 = new MultipleTree(['name' => 'Child 1']); - $node->makeRoot(); - } + $child1->appendTo($root); - public function testThrowExceptionWhenMakeRootOnRootNodeInMultipleTree(): void - { - $this->generateFixtureTree(); + $child2 = new MultipleTree(['name' => 'Child 2']); - $node = MultipleTree::findOne(23); + $behavior = $child2->getBehavior('nestedSetsBehavior'); self::assertNotNull( - $node, - "Node with ID '23' should exist before calling 'makeRoot()' on it in 'MultipleTree'.", + $behavior, + 'Behavior should be attached to the child node.', ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move the root node as the root.'); + $child2->appendTo($root); - $node->makeRoot(); - } + $this->populateAndVerifyCache($behavior); - public function testReturnTrueAndMatchXmlAfterPrependToUpForTreeAndMultipleTree(): void - { - $this->generateFixtureTree(); + $child2->setAttribute('lft', 3); + $child2->save(); - $node = Tree::findOne(9); + $child2->appendTo($child1); - self::assertNotNull( - $node, - "Node with ID '9' must exist before calling 'prependTo()' on another node in 'Tree'.", - ); + $this->verifyCacheInvalidation($behavior); + } - $node->name = 'Updated node 2'; + public function testCacheInvalidationAfterDeleteWithChildren(): void + { + $this->createDatabase(); - $childOfNode = Tree::findOne(2); + $root = new MultipleTree(['name' => 'Root']); - self::assertNotNull( - $childOfNode, - "Target node with ID '2' must exist before calling 'prependTo()' on it in 'Tree'.", - ); - self::assertTrue( - $node->prependTo($childOfNode), - "'prependTo()' should return 'true' when moving node '9' as child of node '2' in 'Tree'.", - ); + $root->makeRoot(); - $node = MultipleTree::findOne(31); + $child = new MultipleTree(['name' => 'Child']); - self::assertNotNull( - $node, - "Node with ID '31' must exist before calling 'prependTo()' on another node in 'MultipleTree'.", - ); + $child->appendTo($root); - $node->name = 'Updated node 2'; + $grandchild = new MultipleTree(['name' => 'Grandchild']); - $childOfNode = MultipleTree::findOne(24); + $grandchild->appendTo($child); + $behavior = $child->getBehavior('nestedSetsBehavior'); self::assertNotNull( - $childOfNode, - "Target node with ID '24' must exist before calling 'prependTo()' on it in 'MultipleTree'.", - ); - self::assertTrue( - $node->prependTo($childOfNode), - "'prependTo()' should return 'true' when moving node '31' as child of node '24' in 'MultipleTree'.", + $behavior, + 'Behavior should be attached to the child node.', ); - $simpleXML = $this->loadFixtureXML('test-prepend-to-exists-up.xml'); + $this->populateAndVerifyCache($behavior); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'prependTo()' must match the expected XML structure.", - ); + $child->deleteWithChildren(); + + $this->verifyCacheInvalidation($behavior); } - public function testReturnTrueAndMatchXmlAfterPrependToDownForTreeAndMultipleTree(): void + public function testCacheInvalidationAfterInsertWithoutTreeAttribute(): void { - $this->generateFixtureTree(); - - $node = Tree::findOne(9); - - self::assertNotNull( - $node, - "Node with ID '9' should exist before calling 'prependTo()' on another node.", - ); + $this->createDatabase(); - $node->name = 'Updated node 2'; + $node = new Tree(['name' => 'Root Node']); - $childOfNode = Tree::findOne(16); + $behavior = $node->getBehavior('nestedSetsBehavior'); self::assertNotNull( - $childOfNode, - "Target node with ID '16' should exist before calling 'prependTo()' on it.", - ); - self::assertTrue( - $node->prependTo($childOfNode), - "'prependTo()' should return 'true' when moving node '9' as child of node '16' in 'Tree'.", + $behavior, + 'Behavior should be attached to the node.', ); - $node = MultipleTree::findOne(31); + $node->makeRoot(); - self::assertNotNull( - $node, - "Node with ID '31' should exist before calling 'prependTo()' on another node.", - ); + $this->populateAndVerifyCache($behavior); - $node->name = 'Updated node 2'; + $node->invalidateCache(); - $childOfNode = MultipleTree::findOne(38); + $this->verifyCacheInvalidation($behavior); - self::assertNotNull( - $childOfNode, - "Target node with ID '38' should exist before calling 'prependTo()' on it.", + self::assertEquals( + 0, + Assert::invokeMethod($behavior, 'getDepthValue'), + "New cached depth value should be '0' for root.", ); - self::assertTrue( - $node->prependTo($childOfNode), - "'prependTo()' should return 'true' when moving node '31' as child of node '38' in 'MultipleTree'.", + self::assertEquals( + 1, + Assert::invokeMethod($behavior, 'getLeftValue'), + "New cached left value should be '1' for root.", ); - - $simpleXML = $this->loadFixtureXML('test-prepend-to-exists-down.xml'); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'prependTo()' must match the expected XML structure.", + 2, + Assert::invokeMethod($behavior, 'getRightValue'), + "New cached right value should be '2' for root.", ); } - public function testReturnTrueAndMatchXmlAfterPrependToMultipleTreeWhenTargetIsInAnotherTree(): void + public function testCacheInvalidationAfterInsertWithTreeAttribute(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = MultipleTree::findOne(9); + $node = new MultipleTree(['name' => 'Root Node']); + + $behavior = $node->getBehavior('nestedSetsBehavior'); self::assertNotNull( - $node, - "Node with ID '9' must exist before attempting to prepend to a node in another tree.", + $behavior, + 'Behavior should be attached to the node.', ); - $node->name = 'Updated node 2'; + $node->makeRoot(); - $childOfNode = MultipleTree::findOne(53); + $this->populateAndVerifyCache($behavior); - self::assertNotNull( - $childOfNode, - "Target node with ID '53' must exist before attempting to prepend to it.", - ); - self::assertTrue( - $node->prependTo($childOfNode), - "'prependTo()' should return 'true' when moving node '9' as child of node '53' in another tree.", - ); + $node->invalidateCache(); - $simpleXML = $this->loadFixtureXML('test-prepend-to-exists-another-tree.xml'); + $this->verifyCacheInvalidation($behavior); self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSetMultipleTree()), - $simpleXML->asXML(), - "Resulting dataset after 'prependTo()' must match the expected XML structure for 'MultipleTree'.", + 0, + Assert::invokeMethod($behavior, 'getDepthValue'), + "New cached depth value should be '0' for root.", + ); + self::assertEquals( + 1, + Assert::invokeMethod($behavior, 'getLeftValue'), + "New cached left value should be '1' for root.", + ); + self::assertEquals( + 2, + Assert::invokeMethod($behavior, 'getRightValue'), + "New cached right value should be '2' for root.", + ); + self::assertNotFalse( + $node->treeAttribute, + 'Tree attribute should be set.', + ); + self::assertNotNull( + $node->getAttribute($node->treeAttribute), + "Tree attribute should be set after 'afterInsert()'.", + ); + self::assertNotNull( + $node->owner, + "Node owner should not be null after 'makeRoot()'.", + ); + self::assertEquals( + $node->owner->getPrimaryKey(), + $node->getAttribute($node->treeAttribute), + 'Tree attribute should equal primary key for root node.', ); } - public function testThrowExceptionWhenPrependToTargetIsNewRecord(): void + public function testCacheInvalidationAfterMakeRoot(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = Tree::findOne(9); + $root = new MultipleTree(['name' => 'Original Root']); - self::assertNotNull($node, "Node with ID '9' must exist before calling 'prependTo()' on another node."); + $root->makeRoot(); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is new record.'); + $child = new MultipleTree(['name' => 'Child']); - $node->prependTo(new Tree()); - } + $child->appendTo($root); - public function testThrowExceptionWhenPrependToTargetIsSame(): void - { - $this->generateFixtureTree(); + $behavior = $child->getBehavior('nestedSetsBehavior'); - $node = Tree::findOne(9); + self::assertNotNull( + $behavior, + 'Behavior should be attached to the child node.', + ); - self::assertNotNull($node, "Node with ID '9' should exist before calling 'prependTo()' on itself."); + self::assertEquals( + $child->getAttribute('depth'), + Assert::invokeMethod($behavior, 'getDepthValue'), + 'Initial cached depth value should match attribute.', + ); + self::assertEquals( + $child->getAttribute('lft'), + Assert::invokeMethod($behavior, 'getLeftValue'), + 'Initial cached left value should match attribute.', + ); + self::assertEquals( + $child->getAttribute('rgt'), + Assert::invokeMethod($behavior, 'getRightValue'), + 'Initial cached right value should match attribute.', + ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is same.'); + $child->makeRoot(); - $node->prependTo($node); + $this->verifyCacheInvalidation($behavior); + + self::assertEquals( + 0, + Assert::invokeMethod($behavior, 'getDepthValue'), + "New cached depth value should be '0' for root.", + ); + self::assertEquals( + 1, + Assert::invokeMethod($behavior, 'getLeftValue'), + "New cached left value should be '1' for root.", + ); + self::assertEquals( + 2, + Assert::invokeMethod($behavior, 'getRightValue'), + "New cached right value should be '2' for root.", + ); } - public function testThrowExceptionWhenPrependToTargetIsChild(): void + public function testChildrenMethodRequiresOrderByForCorrectTreeTraversal(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = Tree::findOne(9); + $root = new Tree(['name' => 'Root']); - self::assertNotNull($node, "Node with ID '9' must exist before calling 'prependTo()' on another node."); + $root->makeRoot(); - $childOfNode = Tree::findOne(11); + $childB = new Tree(['name' => 'Child B']); + $childC = new Tree(['name' => 'Child C']); + $childA = new Tree(['name' => 'Child A']); - self::assertNotNull($childOfNode, "Target node with ID '11' must exist before calling 'prependTo()' on it."); + $childB->appendTo($root); + $childC->appendTo($root); + $childA->appendTo($root); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is child.'); + $command = $this->getDb()->createCommand(); - $node->prependTo($childOfNode); - } + $command->update('tree', ['lft' => 4, 'rgt' => 5], ['name' => 'Child B'])->execute(); + $command->update('tree', ['lft' => 6, 'rgt' => 7], ['name' => 'Child C'])->execute(); + $command->update('tree', ['lft' => 2, 'rgt' => 3], ['name' => 'Child A'])->execute(); + $command->update('tree', ['rgt' => 8], ['name' => 'Root'])->execute(); - public function testReturnTrueAndMatchXmlAfterAppendToUpForTreeAndMultipleTree(): void - { - $this->generateFixtureTree(); + $root->refresh(); + $childrenList = $root->children()->all(); - $node = Tree::findOne(9); + $expectedOrder = ['Child A', 'Child B', 'Child C']; - self::assertNotNull( - $node, - "Node with ID '9' must exist before calling 'appendTo()' on another node.", + self::assertCount( + 3, + $childrenList, + "Children list should contain exactly '3' elements.", ); - $node->name = 'Updated node 2'; + foreach ($childrenList as $index => $child) { + self::assertInstanceOf( + Tree::class, + $child, + "Child at index {$index} should be an instance of 'Tree'.", + ); - $childOfNode = Tree::findOne(2); + if (isset($expectedOrder[$index])) { + self::assertEquals( + $expectedOrder[$index], + $child->getAttribute('name'), + "Child at index {$index} should be {$expectedOrder[$index]} in correct 'lft' order.", + ); + } + } + } - self::assertNotNull( - $childOfNode, - "Target node with ID '2' must exist before calling 'appendTo()' on it.", - ); - self::assertTrue( - $node->appendTo($childOfNode), - "'appendTo()' should return 'true' when moving node '9' as child of node '2' in 'Tree'.", - ); + public function testGetDepthValueMemoization(): void + { + $this->createDatabase(); - $node = MultipleTree::findOne(31); + $node = new Tree(['name' => 'Root']); - self::assertNotNull( - $node, - "Node with ID '31' must exist before calling 'appendTo()' on another node.", - ); + $node->makeRoot(); - $node->name = 'Updated node 2'; + $mock = $this->getMockBuilder(Tree::class) + ->onlyMethods(['getAttribute']) + ->getMock(); - $childOfNode = MultipleTree::findOne(24); + $mock->expects(self::once()) + ->method('getAttribute') + ->with('depth') + ->willReturn(42); + + $behavior = $mock->getBehavior('nestedSetsBehavior'); self::assertNotNull( - $childOfNode, - "Target node with ID '24' must exist before calling 'appendTo()' on it.", + $behavior, + 'Behavior should be attached to the node.', ); - self::assertTrue( - $node->appendTo($childOfNode), - "'appendTo()' should return 'true' when moving node '31' as child of node '24' in 'MultipleTree'.", + + $firstCall = Assert::invokeMethod($behavior, 'getDepthValue'); + + self::assertSame( + 42, + $firstCall, + 'First call should return the mocked value.', ); - $simpleXML = $this->loadFixtureXML('test-append-to-exists-up.xml'); + $secondCall = Assert::invokeMethod($behavior, 'getDepthValue'); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'appendTo()' must match the expected XML structure.", + self::assertSame( + 42, + $secondCall, + 'Second call should return the same cached value.', + ); + self::assertSame( + 42, + Assert::inaccessibleProperty($behavior, 'depthValue'), + 'Depth value should be cached after first access.', ); } - public function testReturnTrueAndMatchXmlAfterAppendToDownForTreeAndMultipleTree(): void + public function testGetLeftValueMemoization(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = Tree::findOne(9); + $node = new Tree(['name' => 'Root']); - self::assertNotNull( - $node, - "Node with ID '9' should exist before calling 'appendTo()' on another node.", - ); + $node->makeRoot(); - $node->name = 'Updated node 2'; + $mock = $this->getMockBuilder(Tree::class) + ->onlyMethods(['getAttribute']) + ->getMock(); - $childOfNode = Tree::findOne(16); + $mock->expects(self::once()) + ->method('getAttribute') + ->with('lft') + ->willReturn(123); + + $behavior = $mock->getBehavior('nestedSetsBehavior'); self::assertNotNull( - $childOfNode, - "Target node with ID '16' should exist before calling 'appendTo()' on it.", + $behavior, + 'Behavior should be attached to the node.', ); - self::assertTrue( - $node->appendTo($childOfNode), - "'appendTo()' should return 'true' when moving node '9' as child of node '16' in 'Tree'.", + + $firstCall = Assert::invokeMethod($behavior, 'getLeftValue'); + + self::assertSame( + 123, + $firstCall, + 'First call should return the mocked value.', ); - $node = MultipleTree::findOne(31); + $secondCall = Assert::invokeMethod($behavior, 'getLeftValue'); - self::assertNotNull( - $node, - "Node with ID '31' should exist before calling 'appendTo()' on another node.", + self::assertSame( + 123, + $secondCall, + 'Second call should return the same cached value.', + ); + self::assertSame( + 123, + Assert::inaccessibleProperty($behavior, 'leftValue'), + 'Left value should be cached after first access.', ); + } - $node->name = 'Updated node 2'; + public function testGetRightValueMemoization(): void + { + $this->createDatabase(); - $childOfNode = MultipleTree::findOne(38); + $node = new Tree(['name' => 'Root']); + $node->makeRoot(); + + $mock = $this->getMockBuilder(Tree::class) + ->onlyMethods(['getAttribute']) + ->getMock(); + + $mock->expects(self::once()) + ->method('getAttribute') + ->with('rgt') + ->willReturn(456); + + $behavior = $mock->getBehavior('nestedSetsBehavior'); self::assertNotNull( - $childOfNode, - "Target node with ID '38' should exist before calling 'appendTo()' on it.", + $behavior, + 'Behavior should be attached to the node.', ); - self::assertTrue( - $node->appendTo($childOfNode), - "'appendTo()' should return 'true' when moving node '31' as child of node '38' in 'MultipleTree'.", + + $firstCall = Assert::invokeMethod($behavior, 'getRightValue'); + + self::assertSame( + 456, + $firstCall, + 'First call should return the mocked value.', ); - $simpleXML = $this->loadFixtureXML('test-append-to-exists-down.xml'); + $secondCall = Assert::invokeMethod($behavior, 'getRightValue'); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'appendTo()' must match the expected XML structure.", + self::assertSame( + 456, + $secondCall, + 'Second call should return the same cached value.', + ); + self::assertSame( + 456, + Assert::inaccessibleProperty($behavior, 'rightValue'), + 'Right value should be cached after first access.', ); } - public function testReturnTrueAndMatchXmlAfterAppendToMultipleTreeWhenTargetIsInAnotherTree(): void + public function testInsertAfterWithRunValidationParameterUsingStrictValidation(): void { $this->generateFixtureTree(); - $node = MultipleTree::findOne(9); + $targetNode = Tree::findOne(9); self::assertNotNull( - $node, - "Node with ID '9' must exist before attempting to 'appendTo()' a node in another tree.", + $targetNode, + "Target node with ID '9' should exist before calling 'insertAfter()'.", + ); + self::assertFalse( + $targetNode->isRoot(), + "Target node with ID '9' should not be root for 'insertAfter()' operation.", ); - $node->name = 'Updated node 2'; + $invalidNode = new TreeWithStrictValidation(['name' => 'x']); - $childOfNode = MultipleTree::findOne(53); + $result1 = $invalidNode->insertAfter($targetNode); + $hasError1 = $invalidNode->hasErrors(); - self::assertNotNull( - $childOfNode, - "Target node with ID '53' must exist before attempting to 'appendTo()' it.", + self::assertFalse( + $result1, + "'insertAfter()' should return 'false' when 'runValidation=true' and data fails validation.", ); self::assertTrue( - $node->appendTo($childOfNode), - "'appendTo()' should return 'true' when moving node '9' as child of node '53' in another tree.", + $hasError1, + "Node should have validation errors when 'runValidation=true' and data is invalid.", ); - $simpleXML = $this->loadFixtureXML('test-append-to-exists-another-tree.xml'); + $invalidNode2 = new TreeWithStrictValidation(['name' => 'x']); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSetMultipleTree()), - $simpleXML->asXML(), - "Resulting dataset after 'appendTo()' must match the expected XML structure for 'MultipleTree'.", + $result2 = $invalidNode2->insertAfter($targetNode, false); + $hasError2 = $invalidNode2->hasErrors(); + + self::assertTrue( + $result2, + "'insertAfter()' should return 'true' when 'runValidation=false', even with invalid data that would " . + 'fail validation.', + ); + self::assertFalse( + $hasError2, + "Node should not have validation errors when 'runValidation=false' because validation was skipped.", + ); + + $persistedNode = TreeWithStrictValidation::findOne($invalidNode2->id); + + self::assertNotNull( + $persistedNode, + 'Node should exist in database after inserting after target node with validation disabled.', ); } - public function testThrowExceptionWhenAppendToTargetIsNewRecord(): void + public function testInsertBeforeWithRunValidationParameterUsingStrictValidation(): void { $this->generateFixtureTree(); - $node = Tree::findOne(9); + $targetNode = Tree::findOne(9); - self::assertNotNull($node, "Node with ID '9' must exist before calling 'appendTo()' on another node."); + self::assertNotNull( + $targetNode, + "Target node with ID '9' should exist before calling 'insertBefore'.", + ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is new record.'); + self::assertFalse( + $targetNode->isRoot(), + "Target node with ID '9' should not be root for 'insertBefore' operation.", + ); - $node->appendTo(new Tree()); - } + $invalidNode = new TreeWithStrictValidation(['name' => 'x']); - public function testThrowExceptionWhenAppendToTargetIsSame(): void - { - $this->generateFixtureTree(); + $result1 = $invalidNode->insertBefore($targetNode); + $hasError1 = $invalidNode->hasErrors(); - $node = Tree::findOne(9); + self::assertFalse( + $result1, + "'insertBefore()' should return 'false' when 'runValidation=true' and data fails validation.", + ); + self::assertTrue( + $hasError1, + "Node should have validation errors when 'runValidation=true' and data is invalid.", + ); - self::assertNotNull($node, "Node with ID '9' should exist before calling 'appendTo()' on another node."); + $invalidNode2 = new TreeWithStrictValidation(['name' => 'x']); - $childOfNode = Tree::findOne(9); + $result2 = $invalidNode2->insertBefore($targetNode, false); + $hasError2 = $invalidNode2->hasErrors(); - self::assertNotNull($childOfNode, "Target node with ID '9' should exist before calling 'appendTo()' on it."); + self::assertTrue( + $result2, + "'insertBefore()' should return 'true' when 'runValidation=false', even with invalid data that would " . + 'fail validation.', + ); + self::assertFalse( + $hasError2, + "Node should not have validation errors when 'runValidation=false' because validation was skipped.", + ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is same.'); + $persistedNode = TreeWithStrictValidation::findOne($invalidNode2->id); - $node->appendTo($childOfNode); + self::assertNotNull( + $persistedNode, + 'Node should exist in database after inserting before target node with validation disabled.', + ); } - public function testThrowExceptionWhenAppendToTargetIsChild(): void + public function testIsChildOfReturnsFalseWhenLeftValuesAreEqual(): void { $this->generateFixtureTree(); - $node = Tree::findOne(9); + $parentNode = Tree::findOne(2); + $childNode = Tree::findOne(3); - self::assertNotNull( - $node, - "Expected node with ID '9' to exist before calling 'appendTo()' on another node.", - ); + self::assertNotNull($parentNode, 'Parent node should exist for boundary testing.'); + self::assertNotNull($childNode, 'Child node should exist for boundary testing.'); - $childOfNode = Tree::findOne(11); + $originalChildLeft = $childNode->getAttribute('lft'); - self::assertNotNull( - $childOfNode, - "Expected target child node with ID '11' to exist before calling 'appendTo()' on it.", - ); + $parentLeft = $parentNode->getAttribute('lft'); + $childNode->setAttribute('lft', $parentLeft); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is child.'); + self::assertFalse( + $childNode->isChildOf($parentNode), + 'Node should not be child when left values are equal (tests <= condition).', + ); - $node->appendTo($childOfNode); + $childNode->setAttribute('lft', $originalChildLeft); } - public function testReturnTrueAndMatchXmlAfterInsertBeforeUpForTreeAndMultipleTree(): void + public function testIsChildOfReturnsFalseWhenRightValuesAreEqual(): void { $this->generateFixtureTree(); - $node = Tree::findOne(9); - - self::assertNotNull( - $node, - "Node with ID '9' must exist before calling 'insertBefore()' on another node.", - ); - - $node->name = 'Updated node 2'; + $parentNode = Tree::findOne(2); + $childNode = Tree::findOne(3); - $childOfNode = Tree::findOne(2); + self::assertNotNull($parentNode, 'Parent node should exist for boundary testing.'); + self::assertNotNull($childNode, 'Child node should exist for boundary testing.'); - self::assertNotNull( - $childOfNode, - "Target node with ID '2' must exist before calling 'insertBefore()' on it.", - ); - self::assertTrue( - $node->insertBefore($childOfNode), - "'insertBefore()' should return 'true' when moving node '9' before node '2' in 'Tree'.", - ); + $originalChildRight = $childNode->getAttribute('rgt'); - $node = MultipleTree::findOne(31); + $parentRight = $parentNode->getAttribute('rgt'); + $childNode->setAttribute('rgt', $parentRight); - self::assertNotNull( - $node, - "Node with ID '31' must exist before calling 'insertBefore()' on another node.", + self::assertFalse( + $childNode->isChildOf($parentNode), + 'Node should not be child when right values are equal (tests >= condition).', ); - $node->name = 'Updated node 2'; + $childNode->setAttribute('rgt', $originalChildRight); + } - $childOfNode = MultipleTree::findOne(24); + public function testIsLeafReturnsTrueForLeafAndFalseForRoot(): void + { + $this->generateFixtureTree(); - self::assertNotNull( - $childOfNode, - "Target node with ID '24' must exist before calling 'insertBefore()' on it.", - ); self::assertTrue( - $node->insertBefore($childOfNode), - "'insertBefore()' should return 'true' when moving node '31' before node '24' in 'MultipleTree'.", + Tree::findOne(4)?->isLeaf(), + "Node with ID '4' should be a leaf node (no children).", ); - - $simpleXML = $this->loadFixtureXML('test-insert-before-exists-up.xml'); - - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'insertBefore()' must match the expected XML structure.", + self::assertFalse( + Tree::findOne(1)?->isLeaf(), + "Node with ID '1' should not be a leaf node (has children or is root).", ); } - public function testReturnTrueAndMatchXmlAfterInsertBeforeDownForTreeAndMultipleTree(): void + public function testLeavesMethodRequiresOrderByForDeterministicResults(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = Tree::findOne(9); + $root = new Tree(['name' => 'Root']); - self::assertNotNull( - $node, - "Node with ID '9' should exist before calling 'insertBefore()' on another node.", - ); + $root->makeRoot(); - $node->name = 'Updated node 2'; + $leafA = new Tree(['name' => 'Leaf A']); - $childOfNode = Tree::findOne(16); + $leafA->appendTo($root); - self::assertNotNull( - $childOfNode, - "Target node with ID '16' should exist before calling 'insertBefore()' on it.", - ); - self::assertTrue( - $node->insertBefore($childOfNode), - "'insertBefore()' should return 'true' when moving node '9' before node '16' in 'Tree'.", - ); + $leafB = new Tree(['name' => 'Leaf B']); - $node = MultipleTree::findOne(31); + $leafB->appendTo($root); - self::assertNotNull( - $node, - "Node with ID '31' should exist before calling 'insertBefore()' on another node.", - ); + $leafC = new Tree(['name' => 'Leaf C']); - $node->name = 'Updated node 2'; + $leafC->appendTo($root); - $childOfNode = MultipleTree::findOne(38); + $command = $this->getDb()->createCommand(); - self::assertNotNull( - $childOfNode, - "Target node with ID '38' should exist before calling 'insertBefore()' on it.", - ); - self::assertTrue( - $node->insertBefore($childOfNode), - "'insertBefore()' should return 'true' when moving node '31' before node '38' in 'MultipleTree'.", - ); + $command->update('tree', ['lft' => 6, 'rgt' => 7], ['name' => 'Leaf C'])->execute(); + $command->update('tree', ['lft' => 4, 'rgt' => 5], ['name' => 'Leaf B'])->execute(); + $command->update('tree', ['lft' => 2, 'rgt' => 3], ['name' => 'Leaf A'])->execute(); + $command->update('tree', ['rgt' => 8], ['name' => 'Root'])->execute(); - $simpleXML = $this->loadFixtureXML('test-insert-before-exists-down.xml'); + $leafQuery = $root->leaves(); + $sql = $leafQuery->createCommand()->getRawSql(); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'insertBefore()' must match the expected XML structure.", + self::assertStringContainsString( + 'ORDER BY', + $sql, + "'leaves()' query should include 'ORDER BY' clause for deterministic results.", + ); + self::assertStringContainsString( + '`lft`', + $sql, + "'leaves()' query should order by 'left' attribute for consistent ordering.", ); - } - public function testReturnTrueAndMatchXmlAfterInsertBeforeMultipleTreeWhenTargetIsInAnotherTree(): void - { - $this->generateFixtureTree(); + $leaves = $leafQuery->all(); - $node = MultipleTree::findOne(9); - - self::assertNotNull( - $node, - "Node with ID '9' must exist before attempting to insert before a node in another tree.", - ); - - $node->name = 'Updated node 2'; - - $childOfNode = MultipleTree::findOne(53); + $expectedOrder = ['Leaf A', 'Leaf B', 'Leaf C']; - self::assertNotNull( - $childOfNode, - "Target node with ID '53' must exist before attempting to insert before it.", - ); - self::assertTrue( - $node->insertBefore($childOfNode), - "'insertBefore()' should return 'true' when moving node '9' before node '53' in another tree.", + self::assertCount( + 3, + $leaves, + "Leaves list should contain exactly '3' elements.", ); - $simpleXML = $this->loadFixtureXML('test-insert-before-exists-another-tree.xml'); + foreach ($leaves as $index => $leaf) { + self::assertInstanceOf( + Tree::class, + $leaf, + "Leaf at index {$index} should be an instance of 'Tree'.", + ); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSetMultipleTree()), - $simpleXML->asXML(), - "Resulting dataset after 'insertBefore()' must match the expected XML structure for 'MultipleTree'.", - ); + if (isset($expectedOrder[$index])) { + self::assertEquals( + $expectedOrder[$index], + $leaf->getAttribute('name'), + "Leaf at index {$index} should be {$expectedOrder[$index]} in correct 'lft' order.", + ); + } + } } - public function testThrowExceptionWhenInsertBeforeTargetIsNewRecord(): void + public function testMakeRootRefreshIsNecessaryForCorrectAttributeValues(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = Tree::findOne(9); + $root = new MultipleTree(['name' => 'Original Root']); - self::assertNotNull($node, "Node with ID '9' should exist before calling 'insertBefore()' on a new record."); + $root->makeRoot(); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is new record.'); + $child1 = new MultipleTree(['name' => 'Child 1']); - $node->insertBefore(new Tree()); - } + $child1->appendTo($root); - public function testThrowExceptionWhenInsertBeforeTargetIsSame(): void - { - $this->generateFixtureTree(); + $child2 = new MultipleTree(['name' => 'Child 2']); - $node = Tree::findOne(9); + $child2->appendTo($root); - self::assertNotNull($node, "Node with ID '9' should exist before calling 'insertBefore()' on itself."); + $grandchild = new MultipleTree(['name' => 'Grandchild']); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is same.'); + $grandchild->appendTo($child1); - $node->insertBefore($node); - } + $nodeToPromote = MultipleTree::findOne($child1->id); - public function testThrowExceptionWhenInsertBeforeTargetIsChild(): void - { - $this->generateFixtureTree(); + self::assertNotNull( + $nodeToPromote, + 'Child node should exist before promoting to root.', + ); + self::assertFalse( + $nodeToPromote->isRoot(), + "Node should not be root before 'makeRoot()' operation.", + ); - $node = Tree::findOne(9); + $originalLeft = $nodeToPromote->getAttribute('lft'); + $originalRight = $nodeToPromote->getAttribute('rgt'); + $originalDepth = $nodeToPromote->getAttribute('depth'); + $originalTree = $nodeToPromote->getAttribute('tree'); - self::assertNotNull( - $node, - "Node with ID '9' must exist before calling 'insertBefore()' on another node.", + $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()'.", ); - $childOfNode = Tree::findOne(11); + $grandchildAfter = MultipleTree::findOne($grandchild->id); self::assertNotNull( - $childOfNode, - "Target child node with ID '11' must exist before calling 'insertBefore()' on it.", + $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.", ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is child.'); + $reloadedNode = MultipleTree::findOne($nodeToPromote->id); - $node->insertBefore($childOfNode); + 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'.", + ); } - public function testThrowExceptionWhenInsertBeforeTargetIsRoot(): void + public function testMakeRootWithRunValidationParameterUsingStrictValidation(): void { - $this->generateFixtureTree(); - - $node = Tree::findOne(9); - - self::assertNotNull($node, "Node with ID '9' should exist before calling 'insertBefore()' on another node."); - - $rootNode = Tree::findOne(1); + $this->createDatabase(); - self::assertNotNull($rootNode, "Root node with ID '1' should exist before calling 'insertBefore()' on it."); + $invalidNode = new TreeWithStrictValidation(['name' => 'x']); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is root.'); + $result1 = $invalidNode->makeRoot(); + $hasError1 = $invalidNode->hasErrors(); - $node->insertBefore($rootNode); - } + self::assertFalse( + $result1, + "'makeRoot()' should return 'false' when 'runValidation=true' and data fails validation.", + ); + self::assertTrue( + $hasError1, + "Node should have validation errors when 'runValidation=true' and data is invalid.", + ); - public function testReturnTrueAndMatchXmlAfterInsertAfterUpForTreeAndMultipleTree(): void - { - $this->generateFixtureTree(); + $invalidNode2 = new TreeWithStrictValidation(['name' => 'x']); - $node = Tree::findOne(9); + $result2 = $invalidNode2->makeRoot(false); + $hasError2 = $invalidNode2->hasErrors(); - self::assertNotNull( - $node, - "Node with ID '9' must exist before calling 'insertAfter()' on another node.", + self::assertTrue( + $result2, + "'makeRoot()' should return 'true' when 'runValidation=false', even with invalid data that would fail " . + 'validation.', + ); + self::assertFalse( + $hasError2, + "Node should not have validation errors when 'runValidation=false' because validation was skipped.", ); - $node->name = 'Updated node 2'; - - $childOfNode = Tree::findOne(2); + $persistedNode = TreeWithStrictValidation::findOne($invalidNode2->id); self::assertNotNull( - $childOfNode, - "Target node with ID '2' must exist before calling 'insertAfter()' on it.", + $persistedNode, + "Node should exist in database after 'makeRoot()' with validation disabled.", ); self::assertTrue( - $node->insertAfter($childOfNode), - "'insertAfter()' should return 'true' when moving node '9' after node '2' in 'Tree'.", + $persistedNode->isRoot(), + "Node should be a root node after 'makeRoot()' operation.", + ); + self::assertEquals( + 1, + $persistedNode->lft, + "Root node should have left value of '1'.", ); + self::assertEquals( + 2, + $persistedNode->rgt, + "Root node should have right value of '2'.", + ); + self::assertEquals( + 0, + $persistedNode->depth, + "Root node should have depth of '0'.", + ); + } - $node = MultipleTree::findOne(31); + public function testManualCacheInvalidation(): void + { + $this->createDatabase(); - self::assertNotNull( - $node, - "Node with ID '31' must exist before calling 'insertAfter()' on another node.", - ); + $root = new MultipleTree(['name' => 'Root']); - $node->name = 'Updated node 2'; + $root->makeRoot(); - $childOfNode = MultipleTree::findOne(24); + $behavior = $root->getBehavior('nestedSetsBehavior'); self::assertNotNull( - $childOfNode, - "Target node with ID '24' must exist before calling 'insertAfter()' on it.", - ); - self::assertTrue( - $node->insertAfter($childOfNode), - "'insertAfter()' should return 'true' when moving node '31' after node '24' in 'MultipleTree'.", + $behavior, + 'Behavior should be attached to the root node.', ); - $simpleXML = $this->loadFixtureXML('test-insert-after-exists-up.xml'); + $this->populateAndVerifyCache($behavior); + + $root->invalidateCache(); + + $this->verifyCacheInvalidation($behavior); self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'insertAfter()' must match the expected XML structure.", + 0, + Assert::invokeMethod($behavior, 'getDepthValue'), + 'Depth value should be correctly retrieved after invalidation.', + ); + self::assertEquals( + 1, + Assert::invokeMethod($behavior, 'getLeftValue'), + 'Left value should be correctly retrieved after invalidation.', + ); + self::assertEquals( + 2, + Assert::invokeMethod($behavior, 'getRightValue'), + 'Right value should be correctly retrieved after invalidation.', ); } - public function testReturnTrueAndMatchXmlAfterInsertAfterDownForTreeAndMultipleTree(): void + public function testNodeStateAfterDeleteWithChildren(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = Tree::findOne(9); + $root = new Tree(['name' => 'Root']); - self::assertNotNull( - $node, - "Node with ID '9' should exist before calling 'insertAfter()' on another node.", - ); + $root->makeRoot(); - $node->name = 'Updated node 2'; + $child = new Tree(['name' => 'Child']); - $childOfNode = Tree::findOne(16); + $child->appendTo($root); - self::assertNotNull( - $childOfNode, - "Target node with ID '16' should exist before calling 'insertAfter()' on it.", - ); - self::assertTrue( - $node->insertAfter($childOfNode), - "'insertAfter()' should return 'true' when moving node '9' after node '16' in 'Tree'.", - ); + $grandchild = new Tree(['name' => 'Grandchild']); - $node = MultipleTree::findOne(31); + $grandchild->appendTo($child); - self::assertNotNull( - $node, - "Node with ID '31' should exist before calling 'insertAfter()' on another node.", + self::assertFalse( + $child->getIsNewRecord(), + 'Child node should not be marked as new record before deletion.', + ); + self::assertNotEmpty( + $child->getOldAttributes(), + 'Child node should have old attributes before deletion.', ); - $node->name = 'Updated node 2'; - - $childOfNode = MultipleTree::findOne(38); + $result = $child->deleteWithChildren(); - self::assertNotNull( - $childOfNode, - "Target node with ID '38' should exist before calling 'insertAfter()' on it.", + self::assertNotFalse( + $result, + 'DeleteWithChildren should return the number of deleted rows.', ); self::assertTrue( - $node->insertAfter($childOfNode), - "'insertAfter()' should return 'true' when moving node '31' after node '38' in 'MultipleTree'.", + $child->getIsNewRecord(), + "Child node should be marked as new record after deletion ('setOldAttributes(null)' effect).", ); - - $simpleXML = $this->loadFixtureXML('test-insert-after-exists-down.xml'); - - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - "Resulting dataset after 'insertAfter()' must match the expected XML structure.", + self::assertEmpty( + $child->getOldAttributes(), + 'Child node should have empty old attributes after deletion.', ); } - public function testReturnTrueAndMatchXmlAfterInsertAfterMultipleTreeWhenTargetIsInAnotherTree(): void + public function testParentsMethodRequiresOrderByForDeterministicResults(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = MultipleTree::findOne(9); + $rootA = new Tree(['name' => 'Root A']); - self::assertNotNull( - $node, - "Node with ID '9' must exist before attempting to insert after a node in another tree.", - ); + $rootA->makeRoot(); - $node->name = 'Updated node 2'; + $parentB = new Tree(['name' => 'Parent B']); - $childOfNode = MultipleTree::findOne(53); + $parentB->appendTo($rootA); - self::assertNotNull( - $childOfNode, - "Target node with ID '53' must exist before attempting to insert after it.", + $parentC = new Tree(['name' => 'Parent C']); + + $parentC->appendTo($parentB); + + $child = new Tree(['name' => 'Child']); + + $child->appendTo($parentC); + + $command = $this->getDb()->createCommand(); + + $command->update('tree', ['lft' => 4, 'rgt' => 7, 'depth' => 2], ['name' => 'Parent C'])->execute(); + $command->update('tree', ['lft' => 2, 'rgt' => 8, 'depth' => 1], ['name' => 'Parent B'])->execute(); + $command->update('tree', ['lft' => 1, 'rgt' => 9, 'depth' => 0], ['name' => 'Root A'])->execute(); + $command->update('tree', ['lft' => 5, 'rgt' => 6, 'depth' => 3], ['name' => 'Child'])->execute(); + + $child->refresh(); + $parentsQuery = $child->parents(); + $sql = $parentsQuery->createCommand()->getRawSql(); + + self::assertStringContainsString( + 'ORDER BY', + $sql, + "'parents()' query should include 'ORDER BY' clause for deterministic results.", ); - self::assertTrue( - $node->insertAfter($childOfNode), - "'insertAfter()' should return 'true' when moving node '9' after node '53' in another tree.", + self::assertStringContainsString( + '`lft`', + $sql, + "'parents()' query should order by 'left' attribute for consistent ordering.", ); - $simpleXML = $this->loadFixtureXML('test-insert-after-exists-another-tree.xml'); + $parents = $parentsQuery->all(); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSetMultipleTree()), - $simpleXML->asXML(), - "Resulting dataset after 'insertAfter()' must match the expected XML structure for 'MultipleTree'.", + $expectedOrder = ['Root A', 'Parent B', 'Parent C']; + + self::assertCount( + 3, + $parents, + "Parents list should contain exactly '3' elements.", ); + + foreach ($parents as $index => $parent) { + self::assertInstanceOf( + Tree::class, + $parent, + "Parent at index {$index} should be an instance of 'Tree'.", + ); + + if (isset($expectedOrder[$index])) { + self::assertEquals( + $expectedOrder[$index], + $parent->getAttribute('name'), + "Parent at index {$index} should be {$expectedOrder[$index]} in correct 'lft' order.", + ); + } + } } - public function testThrowExceptionWhenInsertAfterTargetIsNewRecord(): void + public function testPrependToWithRunValidationParameterUsingStrictValidation(): void { - $this->generateFixtureTree(); - - $node = Tree::findOne(9); + $this->createDatabase(); - self::assertNotNull($node, "Node with ID '9' must exist before attempting to insert after a new record."); + $parentNode = new TreeWithStrictValidation(['name' => 'Valid Parent']); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is new record.'); + $parentNode->makeRoot(false); - $node->insertAfter(new Tree()); - } + $childNode = new TreeWithStrictValidation( + [ + 'name' => 'x', + ], + ); - public function testThrowExceptionWhenInsertAfterTargetIsSame(): void - { - $this->generateFixtureTree(); + $resultWithValidation = $childNode->prependTo($parentNode); + $hasError1 = $childNode->hasErrors(); - $node = Tree::findOne(9); + self::assertFalse( + $resultWithValidation, + "'prependTo()' with 'runValidation=true' should return 'false' when validation fails.", + ); + self::assertTrue( + $hasError1, + "Node should have validation errors when 'runValidation=true' and data is invalid.", + ); - self::assertNotNull($node, "Node with ID '9' must exist before attempting to insert after itself."); + $childNode2 = new TreeWithStrictValidation( + [ + 'name' => 'x', + ], + ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is same.'); + $resultWithoutValidation = $childNode2->prependTo($parentNode, false); + $hasError2 = $childNode2->hasErrors(); - $node->insertAfter($node); + self::assertTrue( + $resultWithoutValidation, + "'prependTo()' with 'runValidation=false' should return 'true' when validation is skipped.", + ); + self::assertFalse( + $hasError2, + "Node should not have validation errors when 'runValidation=false' because validation was skipped.", + ); + self::assertSame( + 'x', + $childNode2->name, + "Node name should remain unchanged after 'prependTo()' with 'runValidation=false'.", + ); } - public function testThrowExceptionWhenInsertAfterTargetIsChild(): void + public function testProtectedApplyTreeAttributeConditionRemainsAccessibleToSubclasses(): void { - $this->generateFixtureTree(); - - $node = Tree::findOne(9); - - self::assertNotNull($node, "Node with ID '9' must exist before attempting to insert after its child node."); - - $childOfNode = Tree::findOne(11); + $this->createDatabase(); - self::assertNotNull($childOfNode, "Child node with ID '11' must exist before attempting to insert after it."); + $testNode = new ExtendableMultipleTree( + [ + 'name' => 'Extensibility Test Node', + 'tree' => 1, + ], + ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is child.'); + $extendableBehavior = $testNode->getBehavior('nestedSetsBehavior'); - $node->insertAfter($childOfNode); + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $extendableBehavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + ); } - public function testThrowExceptionWhenInsertAfterTargetIsRoot(): void + public function testProtectedBeforeInsertNodeRemainsAccessibleToSubclasses(): void { - $this->generateFixtureTree(); - - $node = Tree::findOne(9); - - self::assertNotNull($node, "Node with ID '9' should exist before attempting to insert after the root node."); - - $rootNode = Tree::findOne(1); + $this->createDatabase(); - self::assertNotNull($rootNode, "Root node with ID '1' should exist before attempting to insert after it."); + $testNode = new ExtendableMultipleTree( + [ + 'name' => 'Extensibility Test Node', + 'tree' => 1, + ], + ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not move a node when the target node is root.'); + $extendableBehavior = $testNode->getBehavior('nestedSetsBehavior'); - $node->insertAfter($rootNode); - } + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $extendableBehavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + ); - public function testReturnAffectedRowsAndMatchXmlAfterDeleteWithChildrenForTreeAndMultipleTree(): void - { - $this->generateFixtureTree(); + $extendableBehavior->exposedBeforeInsertNode(5, 1); + self::assertTrue( + $extendableBehavior->wasMethodCalled('beforeInsertNode'), + "'beforeInsertNode()' should remain protected to allow subclass customization.", + ); self::assertEquals( - 7, - Tree::findOne(9)?->deleteWithChildren(), - "Deleting node with ID '9' and its children from 'Tree' should affect exactly seven rows.", + 5, + $testNode->lft, + "'beforeInsertNode()' should set the 'left' attribute correctly.", ); self::assertEquals( - 7, - MultipleTree::findOne(31)?->deleteWithChildren(), - "Deleting node with ID '31' and its children from 'MultipleTree' should affect exactly seven rows.", + 6, + $testNode->rgt, + "'beforeInsertNode()' should set the 'right' attribute correctly.", ); - - $simpleXML = $this->loadFixtureXML('test-delete-with-children.xml'); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - 'XML dataset after deleting nodes with children should match the expected result.', + 1, + $testNode->depth, + "'beforeInsertNode()' should set the 'depth' attribute correctly.", ); } - public function testThrowExceptionWhenDeleteWithChildrenIsCalledOnNewRecordNode(): void + public function testProtectedBeforeInsertRootNodeRemainsAccessibleToSubclasses(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = new Tree(); + $rootTestNode = new ExtendableMultipleTree( + [ + 'name' => 'Root Test Node', + 'tree' => 2, + ], + ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not delete a node when it is new record.'); + $rootBehavior = $rootTestNode->getBehavior('nestedSetsBehavior'); - $node->deleteWithChildren(); - } + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $rootBehavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + ); - /** - * @throws StaleObjectException - * @throws Throwable - */ - public function testReturnOneWhenDeleteNodeForTreeAndMultipleTree(): void - { - $this->generateFixtureTree(); + $rootBehavior->exposedBeforeInsertRootNode(); + + self::assertTrue( + $rootBehavior->wasMethodCalled('beforeInsertRootNode'), + "'beforeInsertRootNode()' should remain protected to allow subclass customization.", + ); self::assertEquals( 1, - Tree::findOne(9)?->delete(), - "Deleting node with ID '9' from 'Tree' should affect exactly one row.", + $rootTestNode->lft, + "'beforeInsertRootNode()' should set 'left' attribute to '1'.", ); self::assertEquals( - 1, - MultipleTree::findOne(31)?->delete(), - "Deleting node with ID '31' from 'MultipleTree' should affect exactly one row.", + 2, + $rootTestNode->rgt, + "'beforeInsertRootNode()' should set 'right' attribute to '2'.", ); - - $simpleXML = $this->loadFixtureXML('test-delete.xml'); - self::assertEquals( - $this->buildFlatXMLDataSet($this->getDataSet()), - $simpleXML->asXML(), - 'XML dataset after deleting nodes should match the expected result.', + 0, + $rootTestNode->depth, + "'beforeInsertRootNode()' should set 'depth' attribute to '0'.", ); } - /** - * @throws Throwable - * @throws StaleObjectException - */ - public function testThrowExceptionWhenDeleteNodeIsNewRecord(): void + public function testProtectedMoveNodeAsRootRemainsAccessibleToSubclasses(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = new Tree(); + $sourceNode = new ExtendableMultipleTree( + [ + 'name' => 'Source Node', + 'tree' => 5, + ], + ); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Can not delete a node when it is new record.'); + $sourceNode->makeRoot(); + $sourceBehavior = $sourceNode->getBehavior('nestedSetsBehavior'); - $node->delete(); + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $sourceBehavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + ); + + $sourceBehavior->exposedMoveNodeAsRoot(); + + self::assertTrue( + $sourceBehavior->wasMethodCalled('moveNodeAsRoot'), + "'moveNodeAsRoot()' method should remain protected to allow subclass customization.", + ); } - /** - * @throws Throwable - * @throws StaleObjectException - */ - public function testThrowNotSupportedExceptionWhenDeleteIsCalledOnRootNode(): void + public function testProtectedMoveNodeRemainsAccessibleToSubclasses(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - $node = Tree::findOne(1); + $sourceNode = new ExtendableMultipleTree( + [ + 'name' => 'Source Node', + 'tree' => 4, + ], + ); - self::assertNotNull( - $node, - "Node with ID '1' should exist before attempting deletion.", + $sourceNode->makeRoot(); + + $targetNode = new ExtendableMultipleTree( + [ + 'name' => 'Target Node', + 'tree' => 4, + ], ); - $this->expectException(NotSupportedException::class); - $this->expectExceptionMessage( - 'Method "yii2\extensions\nestedsets\tests\support\model\Tree::delete" is not supported for deleting root nodes.', + $targetNode->appendTo($sourceNode); + $sourceBehavior = $sourceNode->getBehavior('nestedSetsBehavior'); + + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $sourceBehavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", ); - $node->delete(); + $sourceBehavior->exposedMoveNode($targetNode, 5, 2); + + self::assertTrue( + $sourceBehavior->wasMethodCalled('moveNode'), + "'moveNode()' should remain protected to allow subclass customization.", + ); } - /** - * @throws Throwable - */ - public function testThrowNotSupportedExceptionWhenInsertIsCalledOnTree(): void + public function testProtectedShiftLeftRightAttributeRemainsAccessibleToSubclasses(): void { - $this->generateFixtureTree(); - - $node = new Tree(['name' => 'Node']); + $this->createDatabase(); - $this->expectException(NotSupportedException::class); - $this->expectExceptionMessage( - 'Method "yii2\extensions\nestedsets\tests\support\model\Tree::insert" is not supported for inserting new nodes.', + $parentNode = new ExtendableMultipleTree( + [ + 'name' => 'Parent Node', + 'tree' => 3, + ], ); - $node->insert(); - } + $parentNode->makeRoot(); - /** - * @throws Throwable - * @throws StaleObjectException - */ - public function testReturnOneWhenUpdateNodeName(): void - { - $this->generateFixtureTree(); + $childNode = new ExtendableMultipleTree( + [ + 'name' => 'Child Node', + 'tree' => 3, + ], + ); - $node = Tree::findOne(9); + $childNode->appendTo($parentNode); + $childBehavior = $childNode->getBehavior('nestedSetsBehavior'); - self::assertNotNull($node, "Node with ID '9' should exist before attempting update."); + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $childBehavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + ); - $node->name = 'Updated node'; + $childBehavior->exposedShiftLeftRightAttribute(1, 2); - self::assertEquals(1, $node->update(), 'Updating the node name should affect exactly one row.'); + self::assertTrue( + $childBehavior->wasMethodCalled('shiftLeftRightAttribute'), + "'shiftLeftRightAttribute()' should remain protected to allow subclass customization.", + ); } - public function testReturnParentsForTreeAndMultipleTreeWithAndWithoutDepth(): void + public function testReturnAffectedRowsAndMatchXmlAfterDeleteWithChildrenForTreeAndMultipleTree(): void { $this->generateFixtureTree(); self::assertEquals( - require "{$this->fixtureDirectory}/test-parents.php", - ArrayHelper::toArray(Tree::findOne(11)?->parents()->all() ?? []), - "Parents for 'Tree' node with ID '11' do not match the expected result.", - ); - self::assertEquals( - require "{$this->fixtureDirectory}/test-parents-multiple-tree.php", - ArrayHelper::toArray(MultipleTree::findOne(33)?->parents()->all() ?? []), - "Parents for 'MultipleTree' node with ID '33' do not match the expected result.", + 7, + Tree::findOne(9)?->deleteWithChildren(), + "Deleting node with ID '9' and its children from 'Tree' should affect exactly seven rows.", ); self::assertEquals( - require "{$this->fixtureDirectory}/test-parents-with-depth.php", - ArrayHelper::toArray(Tree::findOne(11)?->parents(1)->all() ?? []), - "Parents with 'depth=1' for 'Tree' node with ID '11' do not match the expected result.", + 7, + MultipleTree::findOne(31)?->deleteWithChildren(), + "Deleting node with ID '31' and its children from 'MultipleTree' should affect exactly seven rows.", ); + + $simpleXML = $this->loadFixtureXML('test-delete-with-children.xml'); + self::assertEquals( - require "{$this->fixtureDirectory}/test-parents-multiple-tree-with-depth.php", - ArrayHelper::toArray(MultipleTree::findOne(33)?->parents(1)->all() ?? []), - "Parents with 'depth=1' for 'MultipleTree' node with ID '33' do not match the expected result.", + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + 'XML dataset after deleting nodes with children should match the expected result.', ); } @@ -1288,62 +1565,41 @@ public function testReturnChildrenForTreeAndMultipleTreeWithAndWithoutDepth(): v ); } - public function testReturnLeavesForTreeAndMultipleTree(): void + public function testReturnFalseWhenDeleteWithChildrenIsAbortedByBeforeDelete(): void { - $this->generateFixtureTree(); + $this->createDatabase(); - self::assertEquals( - require "{$this->fixtureDirectory}/test-leaves.php", - ArrayHelper::toArray(Tree::findOne(9)?->leaves()->all() ?? []), - "Leaves for 'Tree' node with ID '9' do not match the expected result.", + $node = $this->createPartialMock( + Tree::class, + [ + 'beforeDelete', + ], ); - self::assertEquals( - require "{$this->fixtureDirectory}/test-leaves-multiple-tree.php", - ArrayHelper::toArray(MultipleTree::findOne(31)?->leaves()->all() ?? []), - "Leaves for 'MultipleTree' node with ID '31' do not match the expected result.", + $node->setAttributes( + [ + 'id' => 1, + 'name' => 'Test Node', + 'lft' => 1, + 'rgt' => 2, + 'depth' => 0, + ], ); - } - - public function testReturnPrevNodesForTreeAndMultipleTree(): void - { - $this->generateFixtureTree(); + $node->setIsNewRecord(false); + $node->expects(self::once())->method('beforeDelete')->willReturn(false); - self::assertEquals( - require "{$this->fixtureDirectory}/test-prev.php", - ArrayHelper::toArray(Tree::findOne(9)?->prev()->all() ?? []), - "Previous nodes for 'Tree' node with ID '9' do not match the expected result.", - ); - self::assertEquals( - require "{$this->fixtureDirectory}/test-prev-multiple-tree.php", - ArrayHelper::toArray(MultipleTree::findOne(31)?->prev()->all() ?? []), - "Previous nodes for 'MultipleTree' node with ID '31' do not match the expected result.", + self::assertFalse( + $node->isTransactional(ActiveRecord::OP_DELETE), + "Node with ID '1' should not use transactional delete when 'beforeDelete()' returns 'false'.", ); - } - public function testReturnNextNodesForTreeAndMultipleTree(): void - { - $this->generateFixtureTree(); + $result = $node->deleteWithChildren(); - self::assertEquals( - require "{$this->fixtureDirectory}/test-next.php", - ArrayHelper::toArray(Tree::findOne(9)?->next()->all() ?? []), - "Next nodes for 'Tree' node with ID '9' do not match the expected result.", - ); - self::assertEquals( - require "{$this->fixtureDirectory}/test-next-multiple-tree.php", - ArrayHelper::toArray(MultipleTree::findOne(31)?->next()->all() ?? []), - "Next nodes for 'MultipleTree' node with ID '31' do not match the expected result.", + self::assertFalse( + $result, + "'deleteWithChildren()' should return 'false' when 'beforeDelete()' aborts the deletion process.", ); } - public function testReturnIsRootForRootAndNonRootNode(): void - { - $this->generateFixtureTree(); - - self::assertTrue(Tree::findOne(1)?->isRoot(), "Node with ID '1' should be identified as root."); - self::assertFalse(Tree::findOne(2)?->isRoot(), "Node with ID '2' should not be identified as root."); - } - public function testReturnIsChildOfForMultipleTreeNodeUnderVariousAncestors(): void { $this->generateFixtureTree(); @@ -1388,248 +1644,131 @@ public function testReturnIsChildOfForMultipleTreeNodeUnderVariousAncestors(): v ); } - public function testIsChildOfReturnsFalseWhenLeftValuesAreEqual(): void + public function testReturnIsRootForRootAndNonRootNode(): void { $this->generateFixtureTree(); - $parentNode = Tree::findOne(2); - $childNode = Tree::findOne(3); - - self::assertNotNull($parentNode, 'Parent node should exist for boundary testing.'); - self::assertNotNull($childNode, 'Child node should exist for boundary testing.'); - - $originalChildLeft = $childNode->getAttribute('lft'); - - $parentLeft = $parentNode->getAttribute('lft'); - $childNode->setAttribute('lft', $parentLeft); - - self::assertFalse( - $childNode->isChildOf($parentNode), - 'Node should not be child when left values are equal (tests <= condition).', - ); - - $childNode->setAttribute('lft', $originalChildLeft); + self::assertTrue(Tree::findOne(1)?->isRoot(), "Node with ID '1' should be identified as root."); + self::assertFalse(Tree::findOne(2)?->isRoot(), "Node with ID '2' should not be identified as root."); } - public function testIsChildOfReturnsFalseWhenRightValuesAreEqual(): void + public function testReturnLeavesForTreeAndMultipleTree(): void { $this->generateFixtureTree(); - $parentNode = Tree::findOne(2); - $childNode = Tree::findOne(3); - - self::assertNotNull($parentNode, 'Parent node should exist for boundary testing.'); - self::assertNotNull($childNode, 'Child node should exist for boundary testing.'); - - $originalChildRight = $childNode->getAttribute('rgt'); - - $parentRight = $parentNode->getAttribute('rgt'); - $childNode->setAttribute('rgt', $parentRight); - - self::assertFalse( - $childNode->isChildOf($parentNode), - 'Node should not be child when right values are equal (tests >= condition).', + self::assertEquals( + require "{$this->fixtureDirectory}/test-leaves.php", + ArrayHelper::toArray(Tree::findOne(9)?->leaves()->all() ?? []), + "Leaves for 'Tree' node with ID '9' do not match the expected result.", + ); + self::assertEquals( + require "{$this->fixtureDirectory}/test-leaves-multiple-tree.php", + ArrayHelper::toArray(MultipleTree::findOne(31)?->leaves()->all() ?? []), + "Leaves for 'MultipleTree' node with ID '31' do not match the expected result.", ); - - $childNode->setAttribute('rgt', $originalChildRight); } - public function testIsLeafReturnsTrueForLeafAndFalseForRoot(): void + public function testReturnNextNodesForTreeAndMultipleTree(): void { $this->generateFixtureTree(); - self::assertTrue( - Tree::findOne(4)?->isLeaf(), - "Node with ID '4' should be a leaf node (no children).", + self::assertEquals( + require "{$this->fixtureDirectory}/test-next.php", + ArrayHelper::toArray(Tree::findOne(9)?->next()->all() ?? []), + "Next nodes for 'Tree' node with ID '9' do not match the expected result.", ); - self::assertFalse( - Tree::findOne(1)?->isLeaf(), - "Node with ID '1' should not be a leaf node (has children or is root).", + self::assertEquals( + require "{$this->fixtureDirectory}/test-next-multiple-tree.php", + ArrayHelper::toArray(MultipleTree::findOne(31)?->next()->all() ?? []), + "Next nodes for 'MultipleTree' node with ID '31' do not match the expected result.", ); } - public function testThrowLogicExceptionWhenBehaviorIsNotAttachedToOwner(): void - { - $behavior = new NestedSetsBehavior(); - - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The "owner" property must be set before using the behavior.'); - - $behavior->parents(); - } - - public function testThrowLogicExceptionWhenBehaviorIsDetachedFromOwner(): void - { - $this->createDatabase(); - - $node = new Tree(['name' => 'Root']); - - $behavior = $node->getBehavior('nestedSetsBehavior'); - - self::assertInstanceOf(NestedSetsBehavior::class, $behavior); - - $node->detachBehavior('nestedSetsBehavior'); - - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The "owner" property must be set before using the behavior.'); - - $behavior->parents(); - } - - public function testReturnFalseWhenDeleteWithChildrenIsAbortedByBeforeDelete(): void + /** + * @throws StaleObjectException + * @throws Throwable + */ + public function testReturnOneWhenDeleteNodeForTreeAndMultipleTree(): void { - $this->createDatabase(); - - $node = $this->createPartialMock( - Tree::class, - [ - 'beforeDelete', - ], - ); - $node->setAttributes( - [ - 'id' => 1, - 'name' => 'Test Node', - 'lft' => 1, - 'rgt' => 2, - 'depth' => 0, - ], - ); - $node->setIsNewRecord(false); - $node->expects(self::once())->method('beforeDelete')->willReturn(false); - - self::assertFalse( - $node->isTransactional(ActiveRecord::OP_DELETE), - "Node with ID '1' should not use transactional delete when 'beforeDelete()' returns 'false'.", - ); - - $result = $node->deleteWithChildren(); + $this->generateFixtureTree(); - self::assertFalse( - $result, - "'deleteWithChildren()' should return 'false' when 'beforeDelete()' aborts the deletion process.", + self::assertEquals( + 1, + Tree::findOne(9)?->delete(), + "Deleting node with ID '9' from 'Tree' should affect exactly one row.", + ); + self::assertEquals( + 1, + MultipleTree::findOne(31)?->delete(), + "Deleting node with ID '31' from 'MultipleTree' should affect exactly one row.", ); - } - - public function testThrowExceptionWhenMakeRootIsCalledOnModelWithoutPrimaryKey(): void - { - $this->createDatabase(); - - $node = new class (['name' => 'Root without PK']) extends MultipleTree { - public static function primaryKey(): array - { - return []; - } - - public function makeRoot(): bool - { - return parent::makeRoot(); - } - }; - $this->expectException(Exception::class); - $this->expectExceptionMessage(sprintf('"%s" must have a primary key.', get_class($node))); + $simpleXML = $this->loadFixtureXML('test-delete.xml'); - $node->makeRoot(); + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + 'XML dataset after deleting nodes should match the expected result.', + ); } - public function testSetNodeToNullAndCallBeforeInsertNodeSetsLftRgtAndDepth(): void + /** + * @throws Throwable + * @throws StaleObjectException + */ + public function testReturnOneWhenUpdateNodeName(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $behavior = new class extends NestedSetsBehavior { - public function callBeforeInsertNode(int $value, int $depth): void - { - $this->beforeInsertNode($value, $depth); - } + $node = Tree::findOne(9); - public function setNodeToNull(): void - { - $this->node = null; - } + self::assertNotNull($node, "Node with ID '9' should exist before attempting update."); - public function getNodeDepth(): int|null - { - return $this->node !== null ? $this->node->getAttribute($this->depthAttribute) : null; - } - }; + $node->name = 'Updated node'; - $newNode = new Tree(['name' => 'Test Node']); + self::assertEquals(1, $node->update(), 'Updating the node name should affect exactly one row.'); + } - $newNode->attachBehavior('testBehavior', $behavior); - $behavior->setNodeToNull(); - $behavior->callBeforeInsertNode(5, 1); + public function testReturnParentsForTreeAndMultipleTreeWithAndWithoutDepth(): void + { + $this->generateFixtureTree(); self::assertEquals( - 5, - $newNode->lft, - "'beforeInsertNode' should set 'lft' attribute to '5' on the new node.", + require "{$this->fixtureDirectory}/test-parents.php", + ArrayHelper::toArray(Tree::findOne(11)?->parents()->all() ?? []), + "Parents for 'Tree' node with ID '11' do not match the expected result.", ); self::assertEquals( - 6, - $newNode->rgt, - "'beforeInsertNode' should set 'rgt' attribute to '6' on the new node.", + require "{$this->fixtureDirectory}/test-parents-multiple-tree.php", + ArrayHelper::toArray(MultipleTree::findOne(33)?->parents()->all() ?? []), + "Parents for 'MultipleTree' node with ID '33' do not match the expected result.", ); - - $actualDepth = $newNode->getAttribute('depth'); - self::assertEquals( - 1, - $actualDepth, - "'beforeInsertNode' method should set 'depth' attribute to '1' on the new node.", + require "{$this->fixtureDirectory}/test-parents-with-depth.php", + ArrayHelper::toArray(Tree::findOne(11)?->parents(1)->all() ?? []), + "Parents with 'depth=1' for 'Tree' node with ID '11' do not match the expected result.", + ); + self::assertEquals( + require "{$this->fixtureDirectory}/test-parents-multiple-tree-with-depth.php", + ArrayHelper::toArray(MultipleTree::findOne(33)?->parents(1)->all() ?? []), + "Parents with 'depth=1' for 'MultipleTree' node with ID '33' do not match the expected result.", ); } - public function testAppendChildNodeToRootCreatesValidTreeStructure(): void + public function testReturnPrevNodesForTreeAndMultipleTree(): void { - $this->createDatabase(); - - $root = new Tree(['name' => 'Root']); - - $root->makeRoot(); + $this->generateFixtureTree(); self::assertEquals( - 1, - $root->lft, - "Root node left value should be '1' after 'makeRoot()'.", - ); - self::assertEquals( - 2, - $root->rgt, - "Root node right value should be '2' after 'makeRoot()'.", + require "{$this->fixtureDirectory}/test-prev.php", + ArrayHelper::toArray(Tree::findOne(9)?->prev()->all() ?? []), + "Previous nodes for 'Tree' node with ID '9' do not match the expected result.", ); self::assertEquals( - 0, - $root->depth, - "Root node depth should be '0' after 'makeRoot()'.", + require "{$this->fixtureDirectory}/test-prev-multiple-tree.php", + ArrayHelper::toArray(MultipleTree::findOne(31)?->prev()->all() ?? []), + "Previous nodes for 'MultipleTree' node with ID '31' do not match the expected result.", ); - - $child = new Tree(['name' => 'Child']); - - try { - $result = $child->appendTo($root); - - self::assertTrue( - $result, - "'appendTo()' should return 'true' when successfully appending a child node.", - ); - - $root->refresh(); - $child->refresh(); - - self::assertGreaterThan( - $child->lft, - $child->rgt, - "Child node right value should be greater than its left value after 'appendTo()'.", - ); - self::assertEquals( - 1, - $child->depth, - "Child node depth should be '1' after being 'appendTo()' the root node.", - ); - } catch (Exception $e) { - self::fail('Real insertion failed: ' . $e->getMessage()); - } } public function testReturnShiftedLeftRightAttributesWhenChildAppendedToRoot(): void @@ -1678,1248 +1817,1244 @@ public function testReturnShiftedLeftRightAttributesWhenChildAppendedToRoot(): v ); } - public function testAppendToWithRunValidationParameterUsingStrictValidation(): void + public function testReturnTrueAndMatchXmlAfterAppendToDownForTreeAndMultipleTree(): void { $this->generateFixtureTree(); - $targetNode = Tree::findOne(2); + $node = Tree::findOne(9); self::assertNotNull( - $targetNode, - "Target node with ID '2' should exist before calling 'appendTo()'.", + $node, + "Node with ID '9' should exist before calling 'appendTo()' on another node.", ); - $invalidNode = new TreeWithStrictValidation(['name' => 'x']); + $node->name = 'Updated node 2'; - $result1 = $invalidNode->appendTo($targetNode); - $hasError1 = $invalidNode->hasErrors(); + $childOfNode = Tree::findOne(16); - self::assertFalse( - $result1, - "'appendTo()' should return 'false' when 'runValidation=true' and data fails validation.", + self::assertNotNull( + $childOfNode, + "Target node with ID '16' should exist before calling 'appendTo()' on it.", ); self::assertTrue( - $hasError1, - "Node should have validation errors when 'runValidation=true' and data is invalid.", + $node->appendTo($childOfNode), + "'appendTo()' should return 'true' when moving node '9' as child of node '16' in 'Tree'.", ); - $invalidNode2 = new TreeWithStrictValidation(['name' => 'x']); + $node = MultipleTree::findOne(31); - $result2 = $invalidNode2->appendTo($targetNode, false); - $hasError2 = $invalidNode2->hasErrors(); + self::assertNotNull( + $node, + "Node with ID '31' should exist before calling 'appendTo()' on another node.", + ); + + $node->name = 'Updated node 2'; + + $childOfNode = MultipleTree::findOne(38); + self::assertNotNull( + $childOfNode, + "Target node with ID '38' should exist before calling 'appendTo()' on it.", + ); self::assertTrue( - $result2, - "'appendTo()' should return 'true' when 'runValidation=false', even with invalid data that would " . - 'fail validation.', + $node->appendTo($childOfNode), + "'appendTo()' should return 'true' when moving node '31' as child of node '38' in 'MultipleTree'.", ); - self::assertFalse( - $hasError2, - "Node should not have validation errors when 'runValidation=false' because validation was skipped.", + + $simpleXML = $this->loadFixtureXML('test-append-to-exists-down.xml'); + + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'appendTo()' must match the expected XML structure.", ); + } - $persistedNode = TreeWithStrictValidation::findOne($invalidNode2->id); + public function testReturnTrueAndMatchXmlAfterAppendToMultipleTreeWhenTargetIsInAnotherTree(): void + { + $this->generateFixtureTree(); + + $node = MultipleTree::findOne(9); + + self::assertNotNull( + $node, + "Node with ID '9' must exist before attempting to 'appendTo()' a node in another tree.", + ); + + $node->name = 'Updated node 2'; + + $childOfNode = MultipleTree::findOne(53); + + self::assertNotNull( + $childOfNode, + "Target node with ID '53' must exist before attempting to 'appendTo()' it.", + ); + self::assertTrue( + $node->appendTo($childOfNode), + "'appendTo()' should return 'true' when moving node '9' as child of node '53' in another tree.", + ); + + $simpleXML = $this->loadFixtureXML('test-append-to-exists-another-tree.xml'); + + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSetMultipleTree()), + $simpleXML->asXML(), + "Resulting dataset after 'appendTo()' must match the expected XML structure for 'MultipleTree'.", + ); + } + + public function testReturnTrueAndMatchXmlAfterAppendToUpForTreeAndMultipleTree(): void + { + $this->generateFixtureTree(); + + $node = Tree::findOne(9); + + self::assertNotNull( + $node, + "Node with ID '9' must exist before calling 'appendTo()' on another node.", + ); + + $node->name = 'Updated node 2'; + + $childOfNode = Tree::findOne(2); + + self::assertNotNull( + $childOfNode, + "Target node with ID '2' must exist before calling 'appendTo()' on it.", + ); + self::assertTrue( + $node->appendTo($childOfNode), + "'appendTo()' should return 'true' when moving node '9' as child of node '2' in 'Tree'.", + ); + + $node = MultipleTree::findOne(31); + + self::assertNotNull( + $node, + "Node with ID '31' must exist before calling 'appendTo()' on another node.", + ); + + $node->name = 'Updated node 2'; + + $childOfNode = MultipleTree::findOne(24); + + self::assertNotNull( + $childOfNode, + "Target node with ID '24' must exist before calling 'appendTo()' on it.", + ); + self::assertTrue( + $node->appendTo($childOfNode), + "'appendTo()' should return 'true' when moving node '31' as child of node '24' in 'MultipleTree'.", + ); + + $simpleXML = $this->loadFixtureXML('test-append-to-exists-up.xml'); - self::assertNotNull( - $persistedNode, - 'Node should exist in database after appending to target node with validation disabled.', + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'appendTo()' must match the expected XML structure.", ); } - public function testInsertAfterWithRunValidationParameterUsingStrictValidation(): void + public function testReturnTrueAndMatchXmlAfterInsertAfterDownForTreeAndMultipleTree(): void { $this->generateFixtureTree(); - $targetNode = Tree::findOne(9); + $node = Tree::findOne(9); self::assertNotNull( - $targetNode, - "Target node with ID '9' should exist before calling 'insertAfter()'.", - ); - self::assertFalse( - $targetNode->isRoot(), - "Target node with ID '9' should not be root for 'insertAfter()' operation.", + $node, + "Node with ID '9' should exist before calling 'insertAfter()' on another node.", ); - $invalidNode = new TreeWithStrictValidation(['name' => 'x']); + $node->name = 'Updated node 2'; - $result1 = $invalidNode->insertAfter($targetNode); - $hasError1 = $invalidNode->hasErrors(); + $childOfNode = Tree::findOne(16); - self::assertFalse( - $result1, - "'insertAfter()' should return 'false' when 'runValidation=true' and data fails validation.", + self::assertNotNull( + $childOfNode, + "Target node with ID '16' should exist before calling 'insertAfter()' on it.", ); self::assertTrue( - $hasError1, - "Node should have validation errors when 'runValidation=true' and data is invalid.", + $node->insertAfter($childOfNode), + "'insertAfter()' should return 'true' when moving node '9' after node '16' in 'Tree'.", ); - $invalidNode2 = new TreeWithStrictValidation(['name' => 'x']); + $node = MultipleTree::findOne(31); - $result2 = $invalidNode2->insertAfter($targetNode, false); - $hasError2 = $invalidNode2->hasErrors(); + self::assertNotNull( + $node, + "Node with ID '31' should exist before calling 'insertAfter()' on another node.", + ); - self::assertTrue( - $result2, - "'insertAfter()' should return 'true' when 'runValidation=false', even with invalid data that would " . - 'fail validation.', + $node->name = 'Updated node 2'; + + $childOfNode = MultipleTree::findOne(38); + + self::assertNotNull( + $childOfNode, + "Target node with ID '38' should exist before calling 'insertAfter()' on it.", ); - self::assertFalse( - $hasError2, - "Node should not have validation errors when 'runValidation=false' because validation was skipped.", + self::assertTrue( + $node->insertAfter($childOfNode), + "'insertAfter()' should return 'true' when moving node '31' after node '38' in 'MultipleTree'.", ); - $persistedNode = TreeWithStrictValidation::findOne($invalidNode2->id); + $simpleXML = $this->loadFixtureXML('test-insert-after-exists-down.xml'); - self::assertNotNull( - $persistedNode, - 'Node should exist in database after inserting after target node with validation disabled.', + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'insertAfter()' must match the expected XML structure.", ); } - public function testInsertBeforeWithRunValidationParameterUsingStrictValidation(): void + public function testReturnTrueAndMatchXmlAfterInsertAfterMultipleTreeWhenTargetIsInAnotherTree(): void { $this->generateFixtureTree(); - $targetNode = Tree::findOne(9); + $node = MultipleTree::findOne(9); self::assertNotNull( - $targetNode, - "Target node with ID '9' should exist before calling 'insertBefore'.", - ); - - self::assertFalse( - $targetNode->isRoot(), - "Target node with ID '9' should not be root for 'insertBefore' operation.", + $node, + "Node with ID '9' must exist before attempting to insert after a node in another tree.", ); - $invalidNode = new TreeWithStrictValidation(['name' => 'x']); + $node->name = 'Updated node 2'; - $result1 = $invalidNode->insertBefore($targetNode); - $hasError1 = $invalidNode->hasErrors(); + $childOfNode = MultipleTree::findOne(53); - self::assertFalse( - $result1, - "'insertBefore()' should return 'false' when 'runValidation=true' and data fails validation.", - ); - self::assertTrue( - $hasError1, - "Node should have validation errors when 'runValidation=true' and data is invalid.", + self::assertNotNull( + $childOfNode, + "Target node with ID '53' must exist before attempting to insert after it.", ); - - $invalidNode2 = new TreeWithStrictValidation(['name' => 'x']); - - $result2 = $invalidNode2->insertBefore($targetNode, false); - $hasError2 = $invalidNode2->hasErrors(); - self::assertTrue( - $result2, - "'insertBefore()' should return 'true' when 'runValidation=false', even with invalid data that would " . - 'fail validation.', - ); - self::assertFalse( - $hasError2, - "Node should not have validation errors when 'runValidation=false' because validation was skipped.", + $node->insertAfter($childOfNode), + "'insertAfter()' should return 'true' when moving node '9' after node '53' in another tree.", ); - $persistedNode = TreeWithStrictValidation::findOne($invalidNode2->id); + $simpleXML = $this->loadFixtureXML('test-insert-after-exists-another-tree.xml'); - self::assertNotNull( - $persistedNode, - 'Node should exist in database after inserting before target node with validation disabled.', + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSetMultipleTree()), + $simpleXML->asXML(), + "Resulting dataset after 'insertAfter()' must match the expected XML structure for 'MultipleTree'.", ); } - public function testMakeRootWithRunValidationParameterUsingStrictValidation(): void + public function testReturnTrueAndMatchXmlAfterInsertAfterNewForTreeAndMultipleTree(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $invalidNode = new TreeWithStrictValidation(['name' => 'x']); + $node = new Tree(['name' => 'New node']); - $result1 = $invalidNode->makeRoot(); - $hasError1 = $invalidNode->hasErrors(); + $childOfNode = Tree::findOne(9); - self::assertFalse( - $result1, - "'makeRoot()' should return 'false' when 'runValidation=true' and data fails validation.", - ); - self::assertTrue( - $hasError1, - "Node should have validation errors when 'runValidation=true' and data is invalid.", + self::assertNotNull( + $childOfNode, + "Node with ID '9' must exist before calling 'insertAfter()' on it in 'Tree'.", ); - $invalidNode2 = new TreeWithStrictValidation(['name' => 'x']); - - $result2 = $invalidNode2->makeRoot(false); - $hasError2 = $invalidNode2->hasErrors(); - self::assertTrue( - $result2, - "'makeRoot()' should return 'true' when 'runValidation=false', even with invalid data that would fail " . - 'validation.', - ); - self::assertFalse( - $hasError2, - "Node should not have validation errors when 'runValidation=false' because validation was skipped.", + $node->insertAfter($childOfNode), + "'insertAfter()' should return 'true' when inserting a new node after node '9' in 'Tree'.", ); - $persistedNode = TreeWithStrictValidation::findOne($invalidNode2->id); + $node = new MultipleTree(['name' => 'New node']); + + $childOfNode = MultipleTree::findOne(31); self::assertNotNull( - $persistedNode, - "Node should exist in database after 'makeRoot()' with validation disabled.", + $childOfNode, + "Node with ID '31' must exist before calling 'insertAfter()' on it in 'MultipleTree'.", ); self::assertTrue( - $persistedNode->isRoot(), - "Node should be a root node after 'makeRoot()' operation.", - ); - self::assertEquals( - 1, - $persistedNode->lft, - "Root node should have left value of '1'.", - ); - self::assertEquals( - 2, - $persistedNode->rgt, - "Root node should have right value of '2'.", + $node->insertAfter($childOfNode), + "'insertAfter()' should return 'true' when inserting a new node after node '31' in 'MultipleTree'.", ); + + $simpleXML = $this->loadFixtureXML('test-insert-after-new.xml'); + self::assertEquals( - 0, - $persistedNode->depth, - "Root node should have depth of '0'.", + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'insertAfter()' must match the expected XML structure.", ); } - public function testPrependToWithRunValidationParameterUsingStrictValidation(): void + public function testReturnTrueAndMatchXmlAfterInsertAfterUpForTreeAndMultipleTree(): void { - $this->createDatabase(); - - $parentNode = new TreeWithStrictValidation(['name' => 'Valid Parent']); + $this->generateFixtureTree(); - $parentNode->makeRoot(false); + $node = Tree::findOne(9); - $childNode = new TreeWithStrictValidation( - [ - 'name' => 'x', - ], + self::assertNotNull( + $node, + "Node with ID '9' must exist before calling 'insertAfter()' on another node.", ); - $resultWithValidation = $childNode->prependTo($parentNode); - $hasError1 = $childNode->hasErrors(); + $node->name = 'Updated node 2'; - self::assertFalse( - $resultWithValidation, - "'prependTo()' with 'runValidation=true' should return 'false' when validation fails.", + $childOfNode = Tree::findOne(2); + + self::assertNotNull( + $childOfNode, + "Target node with ID '2' must exist before calling 'insertAfter()' on it.", ); self::assertTrue( - $hasError1, - "Node should have validation errors when 'runValidation=true' and data is invalid.", - ); - - $childNode2 = new TreeWithStrictValidation( - [ - 'name' => 'x', - ], + $node->insertAfter($childOfNode), + "'insertAfter()' should return 'true' when moving node '9' after node '2' in 'Tree'.", ); - $resultWithoutValidation = $childNode2->prependTo($parentNode, false); - $hasError2 = $childNode2->hasErrors(); + $node = MultipleTree::findOne(31); - self::assertTrue( - $resultWithoutValidation, - "'prependTo()' with 'runValidation=false' should return 'true' when validation is skipped.", - ); - self::assertFalse( - $hasError2, - "Node should not have validation errors when 'runValidation=false' because validation was skipped.", - ); - self::assertSame( - 'x', - $childNode2->name, - "Node name should remain unchanged after 'prependTo()' with 'runValidation=false'.", + self::assertNotNull( + $node, + "Node with ID '31' must exist before calling 'insertAfter()' on another node.", ); - } - public function testProtectedApplyTreeAttributeConditionRemainsAccessibleToSubclasses(): void - { - $this->createDatabase(); + $node->name = 'Updated node 2'; - $testNode = new ExtendableMultipleTree( - [ - 'name' => 'Extensibility Test Node', - 'tree' => 1, - ], + $childOfNode = MultipleTree::findOne(24); + + self::assertNotNull( + $childOfNode, + "Target node with ID '24' must exist before calling 'insertAfter()' on it.", + ); + self::assertTrue( + $node->insertAfter($childOfNode), + "'insertAfter()' should return 'true' when moving node '31' after node '24' in 'MultipleTree'.", ); - $extendableBehavior = $testNode->getBehavior('nestedSetsBehavior'); + $simpleXML = $this->loadFixtureXML('test-insert-after-exists-up.xml'); - self::assertInstanceOf( - ExtendableNestedSetsBehavior::class, - $extendableBehavior, - "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'insertAfter()' must match the expected XML structure.", ); } - public function testProtectedBeforeInsertNodeRemainsAccessibleToSubclasses(): void + public function testReturnTrueAndMatchXmlAfterInsertBeforeDownForTreeAndMultipleTree(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $testNode = new ExtendableMultipleTree( - [ - 'name' => 'Extensibility Test Node', - 'tree' => 1, - ], + $node = Tree::findOne(9); + + self::assertNotNull( + $node, + "Node with ID '9' should exist before calling 'insertBefore()' on another node.", ); - $extendableBehavior = $testNode->getBehavior('nestedSetsBehavior'); + $node->name = 'Updated node 2'; - self::assertInstanceOf( - ExtendableNestedSetsBehavior::class, - $extendableBehavior, - "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + $childOfNode = Tree::findOne(16); + + self::assertNotNull( + $childOfNode, + "Target node with ID '16' should exist before calling 'insertBefore()' on it.", + ); + self::assertTrue( + $node->insertBefore($childOfNode), + "'insertBefore()' should return 'true' when moving node '9' before node '16' in 'Tree'.", ); - $extendableBehavior->exposedBeforeInsertNode(5, 1); + $node = MultipleTree::findOne(31); - self::assertTrue( - $extendableBehavior->wasMethodCalled('beforeInsertNode'), - "'beforeInsertNode()' should remain protected to allow subclass customization.", + self::assertNotNull( + $node, + "Node with ID '31' should exist before calling 'insertBefore()' on another node.", ); - self::assertEquals( - 5, - $testNode->lft, - "'beforeInsertNode()' should set the 'left' attribute correctly.", + + $node->name = 'Updated node 2'; + + $childOfNode = MultipleTree::findOne(38); + + self::assertNotNull( + $childOfNode, + "Target node with ID '38' should exist before calling 'insertBefore()' on it.", ); - self::assertEquals( - 6, - $testNode->rgt, - "'beforeInsertNode()' should set the 'right' attribute correctly.", + self::assertTrue( + $node->insertBefore($childOfNode), + "'insertBefore()' should return 'true' when moving node '31' before node '38' in 'MultipleTree'.", ); + + $simpleXML = $this->loadFixtureXML('test-insert-before-exists-down.xml'); + self::assertEquals( - 1, - $testNode->depth, - "'beforeInsertNode()' should set the 'depth' attribute correctly.", + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'insertBefore()' must match the expected XML structure.", ); } - public function testProtectedBeforeInsertRootNodeRemainsAccessibleToSubclasses(): void + public function testReturnTrueAndMatchXmlAfterInsertBeforeMultipleTreeWhenTargetIsInAnotherTree(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $rootTestNode = new ExtendableMultipleTree( - [ - 'name' => 'Root Test Node', - 'tree' => 2, - ], + $node = MultipleTree::findOne(9); + + self::assertNotNull( + $node, + "Node with ID '9' must exist before attempting to insert before a node in another tree.", ); - $rootBehavior = $rootTestNode->getBehavior('nestedSetsBehavior'); + $node->name = 'Updated node 2'; - self::assertInstanceOf( - ExtendableNestedSetsBehavior::class, - $rootBehavior, - "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + $childOfNode = MultipleTree::findOne(53); + + self::assertNotNull( + $childOfNode, + "Target node with ID '53' must exist before attempting to insert before it.", + ); + self::assertTrue( + $node->insertBefore($childOfNode), + "'insertBefore()' should return 'true' when moving node '9' before node '53' in another tree.", ); - $rootBehavior->exposedBeforeInsertRootNode(); + $simpleXML = $this->loadFixtureXML('test-insert-before-exists-another-tree.xml'); + + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSetMultipleTree()), + $simpleXML->asXML(), + "Resulting dataset after 'insertBefore()' must match the expected XML structure for 'MultipleTree'.", + ); + } + + public function testReturnTrueAndMatchXmlAfterInsertBeforeNewForTreeAndMultipleTree(): void + { + $this->generateFixtureTree(); + + $node = new Tree(['name' => 'New node']); + $childOfNode = Tree::findOne(9); + + self::assertNotNull( + $childOfNode, + "Node with ID '9' should exist before calling 'insertBefore()' on it in 'Tree'.", + ); self::assertTrue( - $rootBehavior->wasMethodCalled('beforeInsertRootNode'), - "'beforeInsertRootNode()' should remain protected to allow subclass customization.", + $node->insertBefore($childOfNode), + "'insertBefore()' should return 'true' when inserting a new node before node '9' in 'Tree'.", ); - self::assertEquals( - 1, - $rootTestNode->lft, - "'beforeInsertRootNode()' should set 'left' attribute to '1'.", + $node = new MultipleTree(['name' => 'New node']); + + $childOfNode = MultipleTree::findOne(31); + + self::assertNotNull( + $childOfNode, + "Node with ID '31' should exist before calling 'insertBefore()' on it in 'MultipleTree'.", ); - self::assertEquals( - 2, - $rootTestNode->rgt, - "'beforeInsertRootNode()' should set 'right' attribute to '2'.", + self::assertTrue( + $node->insertBefore($childOfNode), + "'insertBefore()' should return 'true' when inserting a new node before node '31' in 'MultipleTree'.", ); + + $simpleXML = $this->loadFixtureXML('test-insert-before-new.xml'); + self::assertEquals( - 0, - $rootTestNode->depth, - "'beforeInsertRootNode()' should set 'depth' attribute to '0'.", + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'insertBefore()' must match the expected XML structure.", ); } - public function testProtectedShiftLeftRightAttributeRemainsAccessibleToSubclasses(): void + public function testReturnTrueAndMatchXmlAfterInsertBeforeUpForTreeAndMultipleTree(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $parentNode = new ExtendableMultipleTree( - [ - 'name' => 'Parent Node', - 'tree' => 3, - ], + $node = Tree::findOne(9); + + self::assertNotNull( + $node, + "Node with ID '9' must exist before calling 'insertBefore()' on another node.", ); - $parentNode->makeRoot(); + $node->name = 'Updated node 2'; - $childNode = new ExtendableMultipleTree( - [ - 'name' => 'Child Node', - 'tree' => 3, - ], + $childOfNode = Tree::findOne(2); + + self::assertNotNull( + $childOfNode, + "Target node with ID '2' must exist before calling 'insertBefore()' on it.", + ); + self::assertTrue( + $node->insertBefore($childOfNode), + "'insertBefore()' should return 'true' when moving node '9' before node '2' in 'Tree'.", ); - $childNode->appendTo($parentNode); - $childBehavior = $childNode->getBehavior('nestedSetsBehavior'); + $node = MultipleTree::findOne(31); - self::assertInstanceOf( - ExtendableNestedSetsBehavior::class, - $childBehavior, - "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + self::assertNotNull( + $node, + "Node with ID '31' must exist before calling 'insertBefore()' on another node.", ); - $childBehavior->exposedShiftLeftRightAttribute(1, 2); + $node->name = 'Updated node 2'; + + $childOfNode = MultipleTree::findOne(24); + self::assertNotNull( + $childOfNode, + "Target node with ID '24' must exist before calling 'insertBefore()' on it.", + ); self::assertTrue( - $childBehavior->wasMethodCalled('shiftLeftRightAttribute'), - "'shiftLeftRightAttribute()' should remain protected to allow subclass customization.", + $node->insertBefore($childOfNode), + "'insertBefore()' should return 'true' when moving node '31' before node '24' in 'MultipleTree'.", ); - } - public function testProtectedMoveNodeRemainsAccessibleToSubclasses(): void - { - $this->createDatabase(); + $simpleXML = $this->loadFixtureXML('test-insert-before-exists-up.xml'); - $sourceNode = new ExtendableMultipleTree( - [ - 'name' => 'Source Node', - 'tree' => 4, - ], + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'insertBefore()' must match the expected XML structure.", ); + } + public function testReturnTrueAndMatchXmlAfterMakeRootNewForTreeAndMultipleTree(): void + { + $this->createDatabase(); - $sourceNode->makeRoot(); + $nodeTree = new Tree(['name' => 'Root']); - $targetNode = new ExtendableMultipleTree( - [ - 'name' => 'Target Node', - 'tree' => 4, - ], + self::assertTrue( + $nodeTree->makeRoot(), + "'makeRoot()' should return 'true' when creating a new root node in 'Tree'.", ); - $targetNode->appendTo($sourceNode); - $sourceBehavior = $sourceNode->getBehavior('nestedSetsBehavior'); + $nodeMultipleTree = new MultipleTree(['name' => 'Root 1']); - self::assertInstanceOf( - ExtendableNestedSetsBehavior::class, - $sourceBehavior, - "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + self::assertTrue( + $nodeMultipleTree->makeRoot(), + "'makeRoot()' should return 'true' when creating the first root node in 'MultipleTree'.", ); - $sourceBehavior->exposedMoveNode($targetNode, 5, 2); + $nodeMultipleTree = new MultipleTree(['name' => 'Root 2']); self::assertTrue( - $sourceBehavior->wasMethodCalled('moveNode'), - "'moveNode()' should remain protected to allow subclass customization.", + $nodeMultipleTree->makeRoot(), + "'makeRoot()' should return 'true' when creating a second root node in 'MultipleTree'.", + ); + + $simpleXML = $this->loadFixtureXML('test-make-root-new.xml'); + + self::assertSame( + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'makeRoot()' must match the expected XML structure.", ); } - public function testProtectedMoveNodeAsRootRemainsAccessibleToSubclasses(): void + public function testReturnTrueAndMatchXmlAfterMakeRootOnExistingMultipleTreeNode(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $sourceNode = new ExtendableMultipleTree( - [ - 'name' => 'Source Node', - 'tree' => 5, - ], + $node = MultipleTree::findOne(31); + + self::assertNotNull( + $node, + "Node with ID '31' must exist before calling 'makeRoot()' on it in 'MultipleTree'.", ); - $sourceNode->makeRoot(); - $sourceBehavior = $sourceNode->getBehavior('nestedSetsBehavior'); + $node->name = 'Updated node 2'; - self::assertInstanceOf( - ExtendableNestedSetsBehavior::class, - $sourceBehavior, - "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + self::assertTrue( + $node->makeRoot(), + "'makeRoot()' should return 'true' when called on node '31' in 'MultipleTree'.", ); - $sourceBehavior->exposedMoveNodeAsRoot(); + $simpleXML = $this->loadFixtureXML('test-make-root-exists.xml'); - self::assertTrue( - $sourceBehavior->wasMethodCalled('moveNodeAsRoot'), - "'moveNodeAsRoot()' method should remain protected to allow subclass customization.", + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSetMultipleTree()), + $simpleXML->asXML(), + "Resulting dataset after 'makeRoot()' must match the expected XML structure for 'MultipleTree'.", ); } - public function testChildrenMethodRequiresOrderByForCorrectTreeTraversal(): void + public function testReturnTrueAndMatchXmlAfterPrependToDownForTreeAndMultipleTree(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $root = new Tree(['name' => 'Root']); + $node = Tree::findOne(9); - $root->makeRoot(); + self::assertNotNull( + $node, + "Node with ID '9' should exist before calling 'prependTo()' on another node.", + ); - $childB = new Tree(['name' => 'Child B']); - $childC = new Tree(['name' => 'Child C']); - $childA = new Tree(['name' => 'Child A']); + $node->name = 'Updated node 2'; - $childB->appendTo($root); - $childC->appendTo($root); - $childA->appendTo($root); + $childOfNode = Tree::findOne(16); - $command = $this->getDb()->createCommand(); + self::assertNotNull( + $childOfNode, + "Target node with ID '16' should exist before calling 'prependTo()' on it.", + ); + self::assertTrue( + $node->prependTo($childOfNode), + "'prependTo()' should return 'true' when moving node '9' as child of node '16' in 'Tree'.", + ); - $command->update('tree', ['lft' => 4, 'rgt' => 5], ['name' => 'Child B'])->execute(); - $command->update('tree', ['lft' => 6, 'rgt' => 7], ['name' => 'Child C'])->execute(); - $command->update('tree', ['lft' => 2, 'rgt' => 3], ['name' => 'Child A'])->execute(); - $command->update('tree', ['rgt' => 8], ['name' => 'Root'])->execute(); + $node = MultipleTree::findOne(31); - $root->refresh(); - $childrenList = $root->children()->all(); + self::assertNotNull( + $node, + "Node with ID '31' should exist before calling 'prependTo()' on another node.", + ); - $expectedOrder = ['Child A', 'Child B', 'Child C']; + $node->name = 'Updated node 2'; - self::assertCount( - 3, - $childrenList, - "Children list should contain exactly '3' elements.", + $childOfNode = MultipleTree::findOne(38); + + self::assertNotNull( + $childOfNode, + "Target node with ID '38' should exist before calling 'prependTo()' on it.", + ); + self::assertTrue( + $node->prependTo($childOfNode), + "'prependTo()' should return 'true' when moving node '31' as child of node '38' in 'MultipleTree'.", ); - foreach ($childrenList as $index => $child) { - self::assertInstanceOf( - Tree::class, - $child, - "Child at index {$index} should be an instance of 'Tree'.", - ); + $simpleXML = $this->loadFixtureXML('test-prepend-to-exists-down.xml'); - if (isset($expectedOrder[$index])) { - self::assertEquals( - $expectedOrder[$index], - $child->getAttribute('name'), - "Child at index {$index} should be {$expectedOrder[$index]} in correct 'lft' order.", - ); - } - } + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'prependTo()' must match the expected XML structure.", + ); } - public function testMakeRootRefreshIsNecessaryForCorrectAttributeValues(): void + public function testReturnTrueAndMatchXmlAfterPrependToMultipleTreeWhenTargetIsInAnotherTree(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $root = new MultipleTree(['name' => 'Original Root']); + $node = MultipleTree::findOne(9); - $root->makeRoot(); + self::assertNotNull( + $node, + "Node with ID '9' must exist before attempting to prepend to a node in another tree.", + ); - $child1 = new MultipleTree(['name' => 'Child 1']); + $node->name = 'Updated node 2'; - $child1->appendTo($root); + $childOfNode = MultipleTree::findOne(53); - $child2 = new MultipleTree(['name' => 'Child 2']); + self::assertNotNull( + $childOfNode, + "Target node with ID '53' must exist before attempting to prepend to it.", + ); + self::assertTrue( + $node->prependTo($childOfNode), + "'prependTo()' should return 'true' when moving node '9' as child of node '53' in another tree.", + ); - $child2->appendTo($root); + $simpleXML = $this->loadFixtureXML('test-prepend-to-exists-another-tree.xml'); - $grandchild = new MultipleTree(['name' => 'Grandchild']); + self::assertEquals( + $this->buildFlatXMLDataSet($this->getDataSetMultipleTree()), + $simpleXML->asXML(), + "Resulting dataset after 'prependTo()' must match the expected XML structure for 'MultipleTree'.", + ); + } - $grandchild->appendTo($child1); + public function testReturnTrueAndMatchXmlAfterPrependToNewNodeForTreeAndMultipleTree(): void + { + $this->generateFixtureTree(); - $nodeToPromote = MultipleTree::findOne($child1->id); + $node = new Tree(['name' => 'New node']); + + $childOfNode = Tree::findOne(9); self::assertNotNull( - $nodeToPromote, - 'Child node should exist before promoting to root.', + $childOfNode, + "Node with ID '9' must exist before calling 'prependTo()' on it in 'Tree'.", ); - self::assertFalse( - $nodeToPromote->isRoot(), - "Node should not be root before 'makeRoot()' operation.", + self::assertTrue( + $node->prependTo($childOfNode), + "'prependTo()' should return 'true' when prepending a new node to node '9' in 'Tree'.", ); - $originalLeft = $nodeToPromote->getAttribute('lft'); - $originalRight = $nodeToPromote->getAttribute('rgt'); - $originalDepth = $nodeToPromote->getAttribute('depth'); - $originalTree = $nodeToPromote->getAttribute('tree'); + $node = new MultipleTree(['name' => 'New node']); - $result = $nodeToPromote->makeRoot(); + $childOfNode = MultipleTree::findOne(31); - self::assertTrue( - $result, - "'makeRoot()' should return 'true' when converting node to root.", + self::assertNotNull( + $childOfNode, + "Node with ID '31' must exist before calling 'prependTo()' on it in 'MultipleTree'.", ); 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()'.", + $node->prependTo($childOfNode), + "'prependTo()' should return 'true' when prepending a new node to node '31' in 'MultipleTree'.", ); - self::assertNotEquals( - $originalDepth, - $nodeToPromote->getAttribute('depth'), - "Depth should have changed from original after 'makeRoot()'.", + + $simpleXML = $this->loadFixtureXML('test-prepend-to-new.xml'); + + self::assertSame( + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'prependTo()' must match the expected XML structure.", ); - self::assertNotEquals( - $originalTree, - $nodeToPromote->getAttribute('tree'), - "Tree should have changed from original after 'makeRoot()'.", + } + + public function testReturnTrueAndMatchXmlAfterPrependToUpForTreeAndMultipleTree(): void + { + $this->generateFixtureTree(); + + $node = Tree::findOne(9); + + self::assertNotNull( + $node, + "Node with ID '9' must exist before calling 'prependTo()' on another node in 'Tree'.", ); - $grandchildAfter = MultipleTree::findOne($grandchild->id); + $node->name = 'Updated node 2'; + + $childOfNode = Tree::findOne(2); self::assertNotNull( - $grandchildAfter, - "'Grandchild' should still exist after parent became root.", + $childOfNode, + "Target node with ID '2' must exist before calling 'prependTo()' on it in 'Tree'.", ); - self::assertEquals( - $nodeToPromote->getAttribute('tree'), - $grandchildAfter->getAttribute('tree'), - "'Grandchild' should be in the same tree as the new root.", + self::assertTrue( + $node->prependTo($childOfNode), + "'prependTo()' should return 'true' when moving node '9' as child of node '2' in 'Tree'.", ); - self::assertEquals( - 1, - $grandchildAfter->getAttribute('depth'), - "'Grandchild' depth should be recalculated relative to new root.", + + $node = MultipleTree::findOne(31); + + self::assertNotNull( + $node, + "Node with ID '31' must exist before calling 'prependTo()' on another node in 'MultipleTree'.", ); - $reloadedNode = MultipleTree::findOne($nodeToPromote->id); + $node->name = 'Updated node 2'; + + $childOfNode = MultipleTree::findOne(24); self::assertNotNull( - $reloadedNode, - "Node should exist in database after 'makeRoot()'.", + $childOfNode, + "Target node with ID '24' must exist before calling 'prependTo()' on it in 'MultipleTree'.", ); 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'.", + $node->prependTo($childOfNode), + "'prependTo()' should return 'true' when moving node '31' as child of node '24' in 'MultipleTree'.", ); + + $simpleXML = $this->loadFixtureXML('test-prepend-to-exists-up.xml'); + self::assertEquals( - 0, - $reloadedNode->getAttribute('depth'), - "Reloaded node should have 'depth=0'.", + $this->buildFlatXMLDataSet($this->getDataSet()), + $simpleXML->asXML(), + "Resulting dataset after 'prependTo()' must match the expected XML structure.", ); } - public function testCacheInvalidationAfterMakeRoot(): void + public function testSetNodeToNullAndCallBeforeInsertNodeSetsLftRgtAndDepth(): void { $this->createDatabase(); - $root = new MultipleTree(['name' => 'Original Root']); - - $root->makeRoot(); + $behavior = new class extends NestedSetsBehavior { + public function callBeforeInsertNode(int $value, int $depth): void + { + $this->beforeInsertNode($value, $depth); + } - $child = new MultipleTree(['name' => 'Child']); + public function setNodeToNull(): void + { + $this->node = null; + } - $child->appendTo($root); + public function getNodeDepth(): int|null + { + return $this->node !== null ? $this->node->getAttribute($this->depthAttribute) : null; + } + }; - $behavior = $child->getBehavior('nestedSetsBehavior'); + $newNode = new Tree(['name' => 'Test Node']); - self::assertNotNull( - $behavior, - 'Behavior should be attached to the child node.', - ); + $newNode->attachBehavior('testBehavior', $behavior); + $behavior->setNodeToNull(); + $behavior->callBeforeInsertNode(5, 1); self::assertEquals( - $child->getAttribute('depth'), - Assert::invokeMethod($behavior, 'getDepthValue'), - 'Initial cached depth value should match attribute.', - ); - self::assertEquals( - $child->getAttribute('lft'), - Assert::invokeMethod($behavior, 'getLeftValue'), - 'Initial cached left value should match attribute.', + 5, + $newNode->lft, + "'beforeInsertNode' should set 'lft' attribute to '5' on the new node.", ); self::assertEquals( - $child->getAttribute('rgt'), - Assert::invokeMethod($behavior, 'getRightValue'), - 'Initial cached right value should match attribute.', + 6, + $newNode->rgt, + "'beforeInsertNode' should set 'rgt' attribute to '6' on the new node.", ); - $child->makeRoot(); - - $this->verifyCacheInvalidation($behavior); + $actualDepth = $newNode->getAttribute('depth'); - self::assertEquals( - 0, - Assert::invokeMethod($behavior, 'getDepthValue'), - "New cached depth value should be '0' for root.", - ); self::assertEquals( 1, - Assert::invokeMethod($behavior, 'getLeftValue'), - "New cached left value should be '1' for root.", - ); - self::assertEquals( - 2, - Assert::invokeMethod($behavior, 'getRightValue'), - "New cached right value should be '2' for root.", + $actualDepth, + "'beforeInsertNode' method should set 'depth' attribute to '1' on the new node.", ); } - public function testCacheInvalidationAfterAppendTo(): void + public function testThrowExceptionWhenAppendToNewNodeTargetIsNewRecord(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $root = new MultipleTree(['name' => 'Root']); + $node = new Tree(['name' => 'New node']); - $root->makeRoot(); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not create a node when the target node is new record.'); - $child1 = new MultipleTree(['name' => 'Child 1']); + $node->appendTo(new Tree()); + } - $child1->appendTo($root); + public function testThrowExceptionWhenAppendToTargetIsChild(): void + { + $this->generateFixtureTree(); - $child2 = new MultipleTree(['name' => 'Child 2']); + $node = Tree::findOne(9); - $behavior = $child2->getBehavior('nestedSetsBehavior'); + self::assertNotNull( + $node, + "Expected node with ID '9' to exist before calling 'appendTo()' on another node.", + ); + + $childOfNode = Tree::findOne(11); self::assertNotNull( - $behavior, - 'Behavior should be attached to the child node.', + $childOfNode, + "Expected target child node with ID '11' to exist before calling 'appendTo()' on it.", ); - $child2->appendTo($root); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is child.'); - $this->populateAndVerifyCache($behavior); + $node->appendTo($childOfNode); + } - $child2->setAttribute('lft', 3); - $child2->save(); + public function testThrowExceptionWhenAppendToTargetIsNewRecord(): void + { + $this->generateFixtureTree(); - $child2->appendTo($child1); + $node = Tree::findOne(9); - $this->verifyCacheInvalidation($behavior); + self::assertNotNull($node, "Node with ID '9' must exist before calling 'appendTo()' on another node."); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is new record.'); + + $node->appendTo(new Tree()); } - public function testCacheInvalidationAfterDeleteWithChildren(): void + public function testThrowExceptionWhenAppendToTargetIsSame(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $root = new MultipleTree(['name' => 'Root']); + $node = Tree::findOne(9); - $root->makeRoot(); + self::assertNotNull($node, "Node with ID '9' should exist before calling 'appendTo()' on another node."); - $child = new MultipleTree(['name' => 'Child']); + $childOfNode = Tree::findOne(9); - $child->appendTo($root); + self::assertNotNull($childOfNode, "Target node with ID '9' should exist before calling 'appendTo()' on it."); - $grandchild = new MultipleTree(['name' => 'Grandchild']); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is same.'); - $grandchild->appendTo($child); - $behavior = $child->getBehavior('nestedSetsBehavior'); + $node->appendTo($childOfNode); + } - self::assertNotNull( - $behavior, - 'Behavior should be attached to the child node.', - ); + /** + * @throws Throwable + * @throws StaleObjectException + */ + public function testThrowExceptionWhenDeleteNodeIsNewRecord(): void + { + $this->generateFixtureTree(); - $this->populateAndVerifyCache($behavior); + $node = new Tree(); - $child->deleteWithChildren(); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not delete a node when it is new record.'); - $this->verifyCacheInvalidation($behavior); + $node->delete(); } - public function testNodeStateAfterDeleteWithChildren(): void + public function testThrowExceptionWhenDeleteWithChildrenIsCalledOnNewRecordNode(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $root = new Tree(['name' => 'Root']); + $node = new Tree(); - $root->makeRoot(); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not delete a node when it is new record.'); - $child = new Tree(['name' => 'Child']); + $node->deleteWithChildren(); + } - $child->appendTo($root); + public function testThrowExceptionWhenInsertAfterNewNodeTargetIsRoot(): void + { + $this->generateFixtureTree(); - $grandchild = new Tree(['name' => 'Grandchild']); + $node = new Tree(['name' => 'New node']); - $grandchild->appendTo($child); + $rootNode = Tree::findOne(1); - self::assertFalse( - $child->getIsNewRecord(), - 'Child node should not be marked as new record before deletion.', - ); - self::assertNotEmpty( - $child->getOldAttributes(), - 'Child node should have old attributes before deletion.', + self::assertNotNull( + $rootNode, + "Root node with ID '1' should exist before calling 'insertAfter()' on it in 'Tree'.", ); - $result = $child->deleteWithChildren(); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not create a node when the target node is root.'); - self::assertNotFalse( - $result, - 'DeleteWithChildren should return the number of deleted rows.', - ); - self::assertTrue( - $child->getIsNewRecord(), - "Child node should be marked as new record after deletion ('setOldAttributes(null)' effect).", - ); - self::assertEmpty( - $child->getOldAttributes(), - 'Child node should have empty old attributes after deletion.', - ); + $node->insertAfter($rootNode); + } + + public function testThrowExceptionWhenInsertAfterTargetIsChild(): void + { + $this->generateFixtureTree(); + + $node = Tree::findOne(9); + + self::assertNotNull($node, "Node with ID '9' must exist before attempting to insert after its child node."); + + $childOfNode = Tree::findOne(11); + + self::assertNotNull($childOfNode, "Child node with ID '11' must exist before attempting to insert after it."); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is child.'); + + $node->insertAfter($childOfNode); } - public function testManualCacheInvalidation(): void - { - $this->createDatabase(); + public function testThrowExceptionWhenInsertAfterTargetIsNewRecord(): void + { + $this->generateFixtureTree(); + + $node = Tree::findOne(9); + + self::assertNotNull($node, "Node with ID '9' must exist before attempting to insert after a new record."); - $root = new MultipleTree(['name' => 'Root']); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is new record.'); - $root->makeRoot(); + $node->insertAfter(new Tree()); + } - $behavior = $root->getBehavior('nestedSetsBehavior'); + public function testThrowExceptionWhenInsertAfterTargetIsRoot(): void + { + $this->generateFixtureTree(); - self::assertNotNull( - $behavior, - 'Behavior should be attached to the root node.', - ); + $node = Tree::findOne(9); - $this->populateAndVerifyCache($behavior); + self::assertNotNull($node, "Node with ID '9' should exist before attempting to insert after the root node."); - $root->invalidateCache(); + $rootNode = Tree::findOne(1); - $this->verifyCacheInvalidation($behavior); + self::assertNotNull($rootNode, "Root node with ID '1' should exist before attempting to insert after it."); - self::assertEquals( - 0, - Assert::invokeMethod($behavior, 'getDepthValue'), - 'Depth value should be correctly retrieved after invalidation.', - ); - self::assertEquals( - 1, - Assert::invokeMethod($behavior, 'getLeftValue'), - 'Left value should be correctly retrieved after invalidation.', - ); - self::assertEquals( - 2, - Assert::invokeMethod($behavior, 'getRightValue'), - 'Right value should be correctly retrieved after invalidation.', - ); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is root.'); + + $node->insertAfter($rootNode); } - public function testCacheInvalidationAfterInsertWithTreeAttribute(): void + public function testThrowExceptionWhenInsertAfterTargetIsSame(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $node = new MultipleTree(['name' => 'Root Node']); + $node = Tree::findOne(9); - $behavior = $node->getBehavior('nestedSetsBehavior'); + self::assertNotNull($node, "Node with ID '9' must exist before attempting to insert after itself."); - self::assertNotNull( - $behavior, - 'Behavior should be attached to the node.', - ); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is same.'); - $node->makeRoot(); + $node->insertAfter($node); + } - $this->populateAndVerifyCache($behavior); + public function testThrowExceptionWhenInsertBeforeNewNodeTargetIsNewRecord(): void + { + $this->generateFixtureTree(); - $node->invalidateCache(); + $node = new Tree(['name' => 'New node']); - $this->verifyCacheInvalidation($behavior); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not create a node when the target node is new record.'); - self::assertEquals( - 0, - Assert::invokeMethod($behavior, 'getDepthValue'), - "New cached depth value should be '0' for root.", - ); - self::assertEquals( - 1, - Assert::invokeMethod($behavior, 'getLeftValue'), - "New cached left value should be '1' for root.", - ); - self::assertEquals( - 2, - Assert::invokeMethod($behavior, 'getRightValue'), - "New cached right value should be '2' for root.", - ); - self::assertNotFalse( - $node->treeAttribute, - 'Tree attribute should be set.', - ); - self::assertNotNull( - $node->getAttribute($node->treeAttribute), - "Tree attribute should be set after 'afterInsert()'.", - ); - self::assertNotNull( - $node->owner, - "Node owner should not be null after 'makeRoot()'.", - ); - self::assertEquals( - $node->owner->getPrimaryKey(), - $node->getAttribute($node->treeAttribute), - 'Tree attribute should equal primary key for root node.', - ); + $node->insertBefore(new Tree()); } - public function testCacheInvalidationAfterInsertWithoutTreeAttribute(): void + public function testThrowExceptionWhenInsertBeforeNewNodeTargetIsRoot(): void { - $this->createDatabase(); - - $node = new Tree(['name' => 'Root Node']); + $this->generateFixtureTree(); - $behavior = $node->getBehavior('nestedSetsBehavior'); + $node = new Tree(['name' => 'New node']); + $rootNode = Tree::findOne(1); self::assertNotNull( - $behavior, - 'Behavior should be attached to the node.', + $rootNode, + "Root node with ID '1' should exist before calling 'insertBefore()' on it in 'Tree'.", ); - $node->makeRoot(); - - $this->populateAndVerifyCache($behavior); - - $node->invalidateCache(); - - $this->verifyCacheInvalidation($behavior); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not create a node when the target node is root.'); - self::assertEquals( - 0, - Assert::invokeMethod($behavior, 'getDepthValue'), - "New cached depth value should be '0' for root.", - ); - self::assertEquals( - 1, - Assert::invokeMethod($behavior, 'getLeftValue'), - "New cached left value should be '1' for root.", - ); - self::assertEquals( - 2, - Assert::invokeMethod($behavior, 'getRightValue'), - "New cached right value should be '2' for root.", - ); + $node->insertBefore($rootNode); } - public function testAfterInsertCallsInvalidateCache(): void + public function testThrowExceptionWhenInsertBeforeTargetIsChild(): void { - $this->createDatabase(); - - $node = new ExtendableMultipleTree(['name' => 'Root Node']); + $this->generateFixtureTree(); - $behavior = $node->getBehavior('nestedSetsBehavior'); + $node = Tree::findOne(9); - self::assertInstanceOf( - ExtendableNestedSetsBehavior::class, - $behavior, - "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + self::assertNotNull( + $node, + "Node with ID '9' must exist before calling 'insertBefore()' on another node.", ); - $node->makeRoot(); + $childOfNode = Tree::findOne(11); - self::assertTrue( - $behavior->invalidateCacheCalled, - "'invalidateCache()' should be called during 'afterInsert()'.", - ); - self::assertNotFalse( - $node->treeAttribute, - 'Tree attribute should be set.', - ); self::assertNotNull( - $node->getAttribute($node->treeAttribute), - "Tree attribute should be set after 'afterInsert()'.", - ); - self::assertEquals( - $node->getPrimaryKey(), - $node->getAttribute($node->treeAttribute), - 'Tree attribute should equal primary key for root node.', + $childOfNode, + "Target child node with ID '11' must exist before calling 'insertBefore()' on it.", ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is child.'); + + $node->insertBefore($childOfNode); } - public function testAfterInsertCacheInvalidationIntegration(): void + public function testThrowExceptionWhenInsertBeforeTargetIsNewRecord(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $root = new MultipleTree(['name' => 'Original Root']); + $node = Tree::findOne(9); - $root->makeRoot(); + self::assertNotNull($node, "Node with ID '9' should exist before calling 'insertBefore()' on a new record."); - $child = new MultipleTree(['name' => 'Child Node']); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is new record.'); - $child->appendTo($root); + $node->insertBefore(new Tree()); + } - $behavior = $child->getBehavior('nestedSetsBehavior'); + public function testThrowExceptionWhenInsertBeforeTargetIsRoot(): void + { + $this->generateFixtureTree(); - self::assertNotNull( - $behavior, - 'Behavior should be attached to the child node.', - ); - self::assertEquals( - 1, - $child->getAttribute('depth'), - "Child should start at depth '1'.", - ); - self::assertEquals( - 2, - $child->getAttribute('lft'), - "Child should start with 'lft=2'.", - ); - self::assertEquals( - 3, - $child->getAttribute('rgt'), - "Child should start with 'rgt=3'.", - ); + $node = Tree::findOne(9); - $this->populateAndVerifyCache($behavior); + self::assertNotNull($node, "Node with ID '9' should exist before calling 'insertBefore()' on another node."); - $child->makeRoot(); + $rootNode = Tree::findOne(1); - $this->verifyCacheInvalidation($behavior); + self::assertNotNull($rootNode, "Root node with ID '1' should exist before calling 'insertBefore()' on it."); - self::assertEquals( - 0, - $child->getAttribute('depth'), - "Child should be at depth '0' after becoming root.", - ); - self::assertEquals( - 1, - $child->getAttribute('lft'), - "Child should have 'lft=1' after becoming root.", - ); - self::assertEquals( - 2, - $child->getAttribute('rgt'), - "Child should have 'rgt=2' after becoming root.", - ); - self::assertEquals( - 0, - Assert::invokeMethod($behavior, 'getDepthValue'), - "New cached depth should be '0'.", - ); - self::assertEquals( - 1, - Assert::invokeMethod($behavior, 'getLeftValue'), - "New cached left should be '1'.", - ); - self::assertEquals( - 2, - Assert::invokeMethod($behavior, 'getRightValue'), - "New cached right should be '2'.", - ); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is root.'); + + $node->insertBefore($rootNode); } - public function testAfterUpdateCacheInvalidationWhenMakeRoot(): void + public function testThrowExceptionWhenInsertBeforeTargetIsSame(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $root = new ExtendableMultipleTree(['name' => 'Root']); + $node = Tree::findOne(9); - $root->makeRoot(); + self::assertNotNull($node, "Node with ID '9' should exist before calling 'insertBefore()' on itself."); - $child = new ExtendableMultipleTree(['name' => 'Child']); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is same.'); - $child->appendTo($root); + $node->insertBefore($node); + } - $behavior = $child->getBehavior('nestedSetsBehavior'); + public function testThrowExceptionWhenMakeRootIsCalledOnModelWithoutPrimaryKey(): void + { + $this->createDatabase(); - self::assertInstanceOf( - ExtendableNestedSetsBehavior::class, - $behavior, - "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", - ); + $node = new class (['name' => 'Root without PK']) extends MultipleTree { + public static function primaryKey(): array + { + return []; + } - $this->populateAndVerifyCache($behavior); + public function makeRoot(): bool + { + return parent::makeRoot(); + } + }; - $behavior->setOperation(NestedSetsBehavior::OPERATION_MAKE_ROOT); - $behavior->afterUpdate(); + $this->expectException(Exception::class); + $this->expectExceptionMessage(sprintf('"%s" must have a primary key.', get_class($node))); - $this->verifyCacheInvalidation($behavior); + $node->makeRoot(); } - public function testAfterUpdateCacheInvalidationWhenMakeRootAndNodeItsNull(): void + public function testThrowExceptionWhenMakeRootOnNonRootNodeWithTreeAttributeFalse(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $root = new ExtendableMultipleTree(['name' => 'Root']); + $node = Tree::findOne(9); - $root->makeRoot(); + self::assertNotNull($node, "Node with ID '9' should exist before calling 'makeRoot()' on it in 'Tree'."); - $child = new ExtendableMultipleTree(['name' => 'Child']); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node as the root when "treeAttribute" is false.'); - $child->appendTo($root); + $node->makeRoot(); + } - $behavior = $child->getBehavior('nestedSetsBehavior'); + public function testThrowExceptionWhenMakeRootOnRootNodeInMultipleTree(): void + { + $this->generateFixtureTree(); - self::assertInstanceOf( - ExtendableNestedSetsBehavior::class, - $behavior, - "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", - ); + $node = MultipleTree::findOne(23); - $this->populateAndVerifyCache($behavior); + self::assertNotNull( + $node, + "Node with ID '23' should exist before calling 'makeRoot()' on it in 'MultipleTree'.", + ); - $behavior->setNode(null); - $behavior->afterUpdate(); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move the root node as the root.'); - $this->verifyCacheInvalidation($behavior); + $node->makeRoot(); } - public function testLeavesMethodRequiresOrderByForDeterministicResults(): void + public function testThrowExceptionWhenMakeRootWithTreeAttributeFalseAndRootExists(): void { - $this->createDatabase(); + $this->generateFixtureTree(); - $root = new Tree(['name' => 'Root']); + $node = new Tree(['name' => 'Root']); - $root->makeRoot(); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not create more than one root when "treeAttribute" is false.'); - $leafA = new Tree(['name' => 'Leaf A']); + $node->makeRoot(); + } - $leafA->appendTo($root); + public function testThrowExceptionWhenPrependToTargetIsChild(): void + { + $this->generateFixtureTree(); - $leafB = new Tree(['name' => 'Leaf B']); + $node = Tree::findOne(9); - $leafB->appendTo($root); + self::assertNotNull($node, "Node with ID '9' must exist before calling 'prependTo()' on another node."); - $leafC = new Tree(['name' => 'Leaf C']); + $childOfNode = Tree::findOne(11); - $leafC->appendTo($root); + self::assertNotNull($childOfNode, "Target node with ID '11' must exist before calling 'prependTo()' on it."); - $command = $this->getDb()->createCommand(); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is child.'); - $command->update('tree', ['lft' => 6, 'rgt' => 7], ['name' => 'Leaf C'])->execute(); - $command->update('tree', ['lft' => 4, 'rgt' => 5], ['name' => 'Leaf B'])->execute(); - $command->update('tree', ['lft' => 2, 'rgt' => 3], ['name' => 'Leaf A'])->execute(); - $command->update('tree', ['rgt' => 8], ['name' => 'Root'])->execute(); + $node->prependTo($childOfNode); + } - $leafQuery = $root->leaves(); - $sql = $leafQuery->createCommand()->getRawSql(); + public function testThrowExceptionWhenPrependToTargetIsNewRecord(): void + { + $this->generateFixtureTree(); - self::assertStringContainsString( - 'ORDER BY', - $sql, - "'leaves()' query should include 'ORDER BY' clause for deterministic results.", - ); - self::assertStringContainsString( - '`lft`', - $sql, - "'leaves()' query should order by 'left' attribute for consistent ordering.", - ); + $node = Tree::findOne(9); - $leaves = $leafQuery->all(); + self::assertNotNull($node, "Node with ID '9' must exist before calling 'prependTo()' on another node."); - $expectedOrder = ['Leaf A', 'Leaf B', 'Leaf C']; + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is new record.'); - self::assertCount( - 3, - $leaves, - "Leaves list should contain exactly '3' elements.", - ); + $node->prependTo(new Tree()); + } - foreach ($leaves as $index => $leaf) { - self::assertInstanceOf( - Tree::class, - $leaf, - "Leaf at index {$index} should be an instance of 'Tree'.", - ); + public function testThrowExceptionWhenPrependToTargetIsSame(): void + { + $this->generateFixtureTree(); - if (isset($expectedOrder[$index])) { - self::assertEquals( - $expectedOrder[$index], - $leaf->getAttribute('name'), - "Leaf at index {$index} should be {$expectedOrder[$index]} in correct 'lft' order.", - ); - } - } + $node = Tree::findOne(9); + + self::assertNotNull($node, "Node with ID '9' should exist before calling 'prependTo()' on itself."); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Can not move a node when the target node is same.'); + + $node->prependTo($node); } - public function testParentsMethodRequiresOrderByForDeterministicResults(): void + public function testThrowLogicExceptionWhenBehaviorIsDetachedFromOwner(): void { $this->createDatabase(); - $rootA = new Tree(['name' => 'Root A']); + $node = new Tree(['name' => 'Root']); - $rootA->makeRoot(); + $behavior = $node->getBehavior('nestedSetsBehavior'); - $parentB = new Tree(['name' => 'Parent B']); + self::assertInstanceOf(NestedSetsBehavior::class, $behavior); - $parentB->appendTo($rootA); + $node->detachBehavior('nestedSetsBehavior'); - $parentC = new Tree(['name' => 'Parent C']); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "owner" property must be set before using the behavior.'); - $parentC->appendTo($parentB); + $behavior->parents(); + } - $child = new Tree(['name' => 'Child']); + public function testThrowLogicExceptionWhenBehaviorIsNotAttachedToOwner(): void + { + $behavior = new NestedSetsBehavior(); - $child->appendTo($parentC); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "owner" property must be set before using the behavior.'); - $command = $this->getDb()->createCommand(); + $behavior->parents(); + } - $command->update('tree', ['lft' => 4, 'rgt' => 7, 'depth' => 2], ['name' => 'Parent C'])->execute(); - $command->update('tree', ['lft' => 2, 'rgt' => 8, 'depth' => 1], ['name' => 'Parent B'])->execute(); - $command->update('tree', ['lft' => 1, 'rgt' => 9, 'depth' => 0], ['name' => 'Root A'])->execute(); - $command->update('tree', ['lft' => 5, 'rgt' => 6, 'depth' => 3], ['name' => 'Child'])->execute(); + /** + * @throws Throwable + * @throws StaleObjectException + */ + public function testThrowNotSupportedExceptionWhenDeleteIsCalledOnRootNode(): void + { + $this->generateFixtureTree(); - $child->refresh(); - $parentsQuery = $child->parents(); - $sql = $parentsQuery->createCommand()->getRawSql(); + $node = Tree::findOne(1); - self::assertStringContainsString( - 'ORDER BY', - $sql, - "'parents()' query should include 'ORDER BY' clause for deterministic results.", + self::assertNotNull( + $node, + "Node with ID '1' should exist before attempting deletion.", ); - self::assertStringContainsString( - '`lft`', - $sql, - "'parents()' query should order by 'left' attribute for consistent ordering.", + + $this->expectException(NotSupportedException::class); + $this->expectExceptionMessage( + 'Method "yii2\extensions\nestedsets\tests\support\model\Tree::delete" is not supported for deleting root nodes.', ); - $parents = $parentsQuery->all(); + $node->delete(); + } - $expectedOrder = ['Root A', 'Parent B', 'Parent C']; + /** + * @throws Throwable + */ + public function testThrowNotSupportedExceptionWhenInsertIsCalledOnTree(): void + { + $this->generateFixtureTree(); - self::assertCount( - 3, - $parents, - "Parents list should contain exactly '3' elements.", - ); + $node = new Tree(['name' => 'Node']); - foreach ($parents as $index => $parent) { - self::assertInstanceOf( - Tree::class, - $parent, - "Parent at index {$index} should be an instance of 'Tree'.", - ); + $this->expectException(NotSupportedException::class); + $this->expectExceptionMessage( + 'Method "yii2\extensions\nestedsets\tests\support\model\Tree::insert" is not supported for inserting new nodes.', + ); - if (isset($expectedOrder[$index])) { - self::assertEquals( - $expectedOrder[$index], - $parent->getAttribute('name'), - "Parent at index {$index} should be {$expectedOrder[$index]} in correct 'lft' order.", - ); - } - } + $node->insert(); } - /** * @phpstan-param Behavior $behavior */ From 3be19208c11a08a6f810b63edb496091ffd19d8e Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Fri, 4 Jul 2025 10:24:58 +0000 Subject: [PATCH 2/9] Apply fixes from StyleCI --- ecs.php | 2 +- tests/NestedSetsBehaviorTest.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ecs.php b/ecs.php index 4f9879b..b939d59 100644 --- a/ecs.php +++ b/ecs.php @@ -39,7 +39,7 @@ ], 'sort_algorithm' => 'alpha', ], - ) + ) ->withConfiguredRule( OrderedImportsFixer::class, [ diff --git a/tests/NestedSetsBehaviorTest.php b/tests/NestedSetsBehaviorTest.php index c6d789a..4ceffe4 100644 --- a/tests/NestedSetsBehaviorTest.php +++ b/tests/NestedSetsBehaviorTest.php @@ -2312,6 +2312,7 @@ public function testReturnTrueAndMatchXmlAfterInsertBeforeUpForTreeAndMultipleTr "Resulting dataset after 'insertBefore()' must match the expected XML structure.", ); } + public function testReturnTrueAndMatchXmlAfterMakeRootNewForTreeAndMultipleTree(): void { $this->createDatabase(); @@ -3055,6 +3056,7 @@ public function testThrowNotSupportedExceptionWhenInsertIsCalledOnTree(): void $node->insert(); } + /** * @phpstan-param Behavior $behavior */ From e039dcff38f64343d541fcbcf58307079ad84b64 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 4 Jul 2025 06:53:13 -0400 Subject: [PATCH 3/9] refactor: Reorganize test methods and enhance fixture loading in `NestedSetsQueryBehaviorTest`. --- tests/NestedSetsQueryBehaviorTest.php | 225 +++++++++--------- tests/TestCase.php | 108 ++++----- .../support/model/ExtendableMultipleTree.php | 25 +- tests/support/model/MultipleTree.php | 25 +- tests/support/model/Tree.php | 25 +- .../stub/ExtendableNestedSetsBehavior.php | 23 +- 6 files changed, 213 insertions(+), 218 deletions(-) diff --git a/tests/NestedSetsQueryBehaviorTest.php b/tests/NestedSetsQueryBehaviorTest.php index 4c2de32..3145739 100644 --- a/tests/NestedSetsQueryBehaviorTest.php +++ b/tests/NestedSetsQueryBehaviorTest.php @@ -11,119 +11,6 @@ final class NestedSetsQueryBehaviorTest extends TestCase { - public function testReturnLeavesForSingleAndMultipleTreeModels(): void - { - $this->generateFixtureTree(); - - self::assertEquals( - require "{$this->fixtureDirectory}/test-leaves-query.php", - ArrayHelper::toArray(Tree::find()->leaves()->all()), - "Should return correct leaf nodes for 'Tree' model.", - ); - self::assertEquals( - require "{$this->fixtureDirectory}/test-leaves-multiple-tree-query.php", - ArrayHelper::toArray(MultipleTree::find()->leaves()->all()), - "Should return correct leaf nodes for 'MultipleTree' model.", - ); - } - - public function testReturnRootsForSingleAndMultipleTreeModels(): void - { - $this->generateFixtureTree(); - - self::assertEquals( - require "{$this->fixtureDirectory}/test-roots-query.php", - ArrayHelper::toArray(Tree::find()->roots()->all()), - "Should return correct root nodes for 'Tree' model.", - ); - self::assertEquals( - require "{$this->fixtureDirectory}/test-roots-multiple-tree-query.php", - ArrayHelper::toArray(MultipleTree::find()->roots()->all()), - "Should return correct root nodes for 'MultipleTree' model.", - ); - } - - public function testThrowLogicExceptionWhenBehaviorIsNotAttachedToOwner(): void - { - $behavior = new NestedSetsQueryBehavior(); - - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The "owner" property must be set before using the behavior.'); - - $behavior->leaves(); - } - - public function testThrowLogicExceptionWhenBehaviorIsDetachedFromOwner(): void - { - $this->createDatabase(); - - $node = new TreeQuery(Tree::class); - - $behavior = $node->getBehavior('nestedSetsQueryBehavior'); - - self::assertInstanceOf(NestedSetsQueryBehavior::class, $behavior); - - $node->detachBehavior('nestedSetsQueryBehavior'); - - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The "owner" property must be set before using the behavior.'); - - $behavior->leaves(); - } - - public function testRootsMethodRequiresOrderByForCorrectTreeTraversal(): void - { - $this->createDatabase(); - - $rootA = new MultipleTree(['name' => 'Root A']); - - $rootA->makeRoot(); - - $rootC = new MultipleTree(['name' => 'Root C']); - - $rootC->makeRoot(); - - $rootB = new MultipleTree(['name' => 'Root B']); - - $rootB->makeRoot(); - - $rootD = new MultipleTree(['name' => 'Root D']); - - $rootD->makeRoot(); - $command = $this->getDb()->createCommand(); - - $command->update('multiple_tree', ['tree' => 1], ['name' => 'Root A'])->execute(); - $command->update('multiple_tree', ['tree' => 2], ['name' => 'Root B'])->execute(); - $command->update('multiple_tree', ['tree' => 3], ['name' => 'Root C'])->execute(); - $command->update('multiple_tree', ['tree' => 4], ['name' => 'Root D'])->execute(); - - $rootsList = MultipleTree::find()->roots()->all(); - - $expectedOrder = ['Root A', 'Root B', 'Root C', 'Root D']; - - self::assertCount( - 4, - $rootsList, - "Roots list should contain exactly '4' elements.", - ); - - foreach ($rootsList as $index => $root) { - self::assertInstanceOf( - MultipleTree::class, - $root, - "Root at index {$index} should be an instance of 'MultipleTree'.", - ); - - if (isset($expectedOrder[$index])) { - self::assertEquals( - $expectedOrder[$index], - $root->getAttribute('name'), - "Root at index {$index} should be {$expectedOrder[$index]} in correct 'tree' order.", - ); - } - } - } - public function testLeavesMethodRequiresLeftAttributeOrderingForConsistentResults(): void { $this->createDatabase(); @@ -189,6 +76,37 @@ public function testLeavesMethodRequiresLeftAttributeOrderingForConsistentResult } } } + public function testReturnLeavesForSingleAndMultipleTreeModels(): void + { + $this->generateFixtureTree(); + + self::assertEquals( + require "{$this->fixtureDirectory}/test-leaves-query.php", + ArrayHelper::toArray(Tree::find()->leaves()->all()), + "Should return correct leaf nodes for 'Tree' model.", + ); + self::assertEquals( + require "{$this->fixtureDirectory}/test-leaves-multiple-tree-query.php", + ArrayHelper::toArray(MultipleTree::find()->leaves()->all()), + "Should return correct leaf nodes for 'MultipleTree' model.", + ); + } + + public function testReturnRootsForSingleAndMultipleTreeModels(): void + { + $this->generateFixtureTree(); + + self::assertEquals( + require "{$this->fixtureDirectory}/test-roots-query.php", + ArrayHelper::toArray(Tree::find()->roots()->all()), + "Should return correct root nodes for 'Tree' model.", + ); + self::assertEquals( + require "{$this->fixtureDirectory}/test-roots-multiple-tree-query.php", + ArrayHelper::toArray(MultipleTree::find()->roots()->all()), + "Should return correct root nodes for 'MultipleTree' model.", + ); + } public function testRootsMethodRequiresLeftAttributeOrderingWhenTreeAttributeIsDisabled(): void { @@ -239,4 +157,85 @@ public function testRootsMethodRequiresLeftAttributeOrderingWhenTreeAttributeIsD ); } } + + public function testRootsMethodRequiresOrderByForCorrectTreeTraversal(): void + { + $this->createDatabase(); + + $rootA = new MultipleTree(['name' => 'Root A']); + + $rootA->makeRoot(); + + $rootC = new MultipleTree(['name' => 'Root C']); + + $rootC->makeRoot(); + + $rootB = new MultipleTree(['name' => 'Root B']); + + $rootB->makeRoot(); + + $rootD = new MultipleTree(['name' => 'Root D']); + + $rootD->makeRoot(); + $command = $this->getDb()->createCommand(); + + $command->update('multiple_tree', ['tree' => 1], ['name' => 'Root A'])->execute(); + $command->update('multiple_tree', ['tree' => 2], ['name' => 'Root B'])->execute(); + $command->update('multiple_tree', ['tree' => 3], ['name' => 'Root C'])->execute(); + $command->update('multiple_tree', ['tree' => 4], ['name' => 'Root D'])->execute(); + + $rootsList = MultipleTree::find()->roots()->all(); + + $expectedOrder = ['Root A', 'Root B', 'Root C', 'Root D']; + + self::assertCount( + 4, + $rootsList, + "Roots list should contain exactly '4' elements.", + ); + + foreach ($rootsList as $index => $root) { + self::assertInstanceOf( + MultipleTree::class, + $root, + "Root at index {$index} should be an instance of 'MultipleTree'.", + ); + + if (isset($expectedOrder[$index])) { + self::assertEquals( + $expectedOrder[$index], + $root->getAttribute('name'), + "Root at index {$index} should be {$expectedOrder[$index]} in correct 'tree' order.", + ); + } + } + } + + public function testThrowLogicExceptionWhenBehaviorIsDetachedFromOwner(): void + { + $this->createDatabase(); + + $node = new TreeQuery(Tree::class); + + $behavior = $node->getBehavior('nestedSetsQueryBehavior'); + + self::assertInstanceOf(NestedSetsQueryBehavior::class, $behavior); + + $node->detachBehavior('nestedSetsQueryBehavior'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "owner" property must be set before using the behavior.'); + + $behavior->leaves(); + } + + public function testThrowLogicExceptionWhenBehaviorIsNotAttachedToOwner(): void + { + $behavior = new NestedSetsQueryBehavior(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "owner" property must be set before using the behavior.'); + + $behavior->leaves(); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 5685f5f..0790c3b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -37,6 +37,13 @@ class TestCase extends \PHPUnit\Framework\TestCase protected string $fixtureDirectory = __DIR__ . '/support/data/'; + protected function setUp(): void + { + parent::setUp(); + + $this->mockConsoleApplication(); + } + public function getDb(): Connection { return Yii::$app->getDb(); @@ -126,6 +133,42 @@ protected function createDatabase(): void )->execute(); } + protected function generateFixtureTree(): void + { + $this->createDatabase(); + + $command = $this->getDb()->createCommand(); + + // Carga el XML en la tabla `tree` + $xml = new SimpleXMLElement("{$this->fixtureDirectory}/test.xml", 0, true); + + $children = $xml->children() ?? []; + + foreach ($children as $element => $treeElement) { + match ($element === 'tree') { + true => $command->insert( + 'tree', + [ + 'name' => $treeElement['name'], + 'lft' => $treeElement['lft'], + 'rgt' => $treeElement['rgt'], + 'depth' => $treeElement['depth'], + ], + )->execute(), + default => $command->insert( + 'multiple_tree', + [ + 'tree' => $treeElement['tree'], + 'name' => $treeElement['name'], + 'lft' => $treeElement['lft'], + 'rgt' => $treeElement['rgt'], + 'depth' => $treeElement['depth'], + ], + )->execute(), + }; + } + } + /** * @phpstan-import-type DataSetType from TestCase * @@ -165,40 +208,23 @@ protected function getDataSetMultipleTree(): array return array_values($dataSetMultipleTree); } - protected function generateFixtureTree(): void + protected function loadFixtureXML(string $fileName): SimpleXMLElement { - $this->createDatabase(); + $filePath = "{$this->fixtureDirectory}/{$fileName}"; - $command = $this->getDb()->createCommand(); + $file = file_get_contents($filePath); - // Carga el XML en la tabla `tree` - $xml = new SimpleXMLElement("{$this->fixtureDirectory}/test.xml", 0, true); + if ($file === false) { + throw new RuntimeException("Failed to load fixture file: {$filePath}"); + } - $children = $xml->children() ?? []; + $simpleXML = simplexml_load_string($file); - foreach ($children as $element => $treeElement) { - match ($element === 'tree') { - true => $command->insert( - 'tree', - [ - 'name' => $treeElement['name'], - 'lft' => $treeElement['lft'], - 'rgt' => $treeElement['rgt'], - 'depth' => $treeElement['depth'], - ], - )->execute(), - default => $command->insert( - 'multiple_tree', - [ - 'tree' => $treeElement['tree'], - 'name' => $treeElement['name'], - 'lft' => $treeElement['lft'], - 'rgt' => $treeElement['rgt'], - 'depth' => $treeElement['depth'], - ], - )->execute(), - }; + if ($simpleXML === false) { + throw new RuntimeException("Failed to parse XML from fixture file: {$filePath}"); } + + return $simpleXML; } protected function mockConsoleApplication(): void @@ -216,30 +242,4 @@ protected function mockConsoleApplication(): void ], ); } - - protected function loadFixtureXML(string $fileName): SimpleXMLElement - { - $filePath = "{$this->fixtureDirectory}/{$fileName}"; - - $file = file_get_contents($filePath); - - if ($file === false) { - throw new RuntimeException("Failed to load fixture file: {$filePath}"); - } - - $simpleXML = simplexml_load_string($file); - - if ($simpleXML === false) { - throw new RuntimeException("Failed to parse XML from fixture file: {$filePath}"); - } - - return $simpleXML; - } - - protected function setUp(): void - { - parent::setUp(); - - $this->mockConsoleApplication(); - } } diff --git a/tests/support/model/ExtendableMultipleTree.php b/tests/support/model/ExtendableMultipleTree.php index c7e3335..58fee77 100644 --- a/tests/support/model/ExtendableMultipleTree.php +++ b/tests/support/model/ExtendableMultipleTree.php @@ -17,11 +17,6 @@ */ class ExtendableMultipleTree extends ActiveRecord { - public static function tableName(): string - { - return '{{%multiple_tree}}'; - } - public function behaviors(): array { return [ @@ -32,12 +27,24 @@ public function behaviors(): array ]; } + /** + * @phpstan-return ExtendableMultipleTreeQuery + */ + public static function find(): ExtendableMultipleTreeQuery + { + return new ExtendableMultipleTreeQuery(static::class); + } + public function rules(): array { return [ ['name', 'required'], ]; } + public static function tableName(): string + { + return '{{%multiple_tree}}'; + } /** * @phpstan-return array @@ -48,12 +55,4 @@ public function transactions(): array self::SCENARIO_DEFAULT => self::OP_ALL, ]; } - - /** - * @phpstan-return ExtendableMultipleTreeQuery - */ - public static function find(): ExtendableMultipleTreeQuery - { - return new ExtendableMultipleTreeQuery(static::class); - } } diff --git a/tests/support/model/MultipleTree.php b/tests/support/model/MultipleTree.php index 7f462f6..f40e277 100644 --- a/tests/support/model/MultipleTree.php +++ b/tests/support/model/MultipleTree.php @@ -17,11 +17,6 @@ */ class MultipleTree extends ActiveRecord { - public static function tableName(): string - { - return '{{%multiple_tree}}'; - } - public function behaviors(): array { return [ @@ -32,12 +27,24 @@ public function behaviors(): array ]; } + /** + * @phpstan-return MultipleTreeQuery + */ + public static function find(): MultipleTreeQuery + { + return new MultipleTreeQuery(static::class); + } + public function rules(): array { return [ ['name', 'required'], ]; } + public static function tableName(): string + { + return '{{%multiple_tree}}'; + } /** * @phpstan-return array @@ -48,12 +55,4 @@ public function transactions(): array self::SCENARIO_DEFAULT => self::OP_ALL, ]; } - - /** - * @phpstan-return MultipleTreeQuery - */ - public static function find(): MultipleTreeQuery - { - return new MultipleTreeQuery(static::class); - } } diff --git a/tests/support/model/Tree.php b/tests/support/model/Tree.php index 4491fae..f5cc703 100644 --- a/tests/support/model/Tree.php +++ b/tests/support/model/Tree.php @@ -16,11 +16,6 @@ */ class Tree extends ActiveRecord { - public static function tableName(): string - { - return '{{%tree}}'; - } - public function behaviors(): array { return [ @@ -28,6 +23,14 @@ public function behaviors(): array ]; } + /** + * @phpstan-return TreeQuery + */ + public static function find(): TreeQuery + { + return new TreeQuery(static::class); + } + public function isTransactional($operation): bool { if ($operation === ActiveRecord::OP_DELETE) { @@ -43,6 +46,10 @@ public function rules(): array ['name', 'required'], ]; } + public static function tableName(): string + { + return '{{%tree}}'; + } /** * @phpstan-return array @@ -53,12 +60,4 @@ public function transactions(): array self::SCENARIO_DEFAULT => self::OP_ALL, ]; } - - /** - * @phpstan-return TreeQuery - */ - public static function find(): TreeQuery - { - return new TreeQuery(static::class); - } } diff --git a/tests/support/stub/ExtendableNestedSetsBehavior.php b/tests/support/stub/ExtendableNestedSetsBehavior.php index 2396d69..cb2efb7 100644 --- a/tests/support/stub/ExtendableNestedSetsBehavior.php +++ b/tests/support/stub/ExtendableNestedSetsBehavior.php @@ -14,12 +14,11 @@ */ final class ExtendableNestedSetsBehavior extends NestedSetsBehavior { - public bool $invalidateCacheCalled = false; - /** * @phpstan-var array */ public array $calledMethods = []; + public bool $invalidateCacheCalled = false; public function exposedBeforeInsertNode(int $value, int $depth): void { @@ -68,16 +67,6 @@ public function exposedShiftLeftRightAttribute(int $value, int $delta): void $this->shiftLeftRightAttribute($value, $delta); } - public function resetMethodCallTracking(): void - { - $this->calledMethods = []; - } - - public function wasMethodCalled(string $methodName): bool - { - return $this->calledMethods[$methodName] ?? false; - } - /** * @phpstan-return array */ @@ -93,6 +82,11 @@ public function invalidateCache(): void parent::invalidateCache(); } + public function resetMethodCallTracking(): void + { + $this->calledMethods = []; + } + public function setNode(ActiveRecord|null $node): void { $this->node = $node; @@ -102,4 +96,9 @@ public function setOperation(string|null $operation): void { $this->operation = $operation; } + + public function wasMethodCalled(string $methodName): bool + { + return $this->calledMethods[$methodName] ?? false; + } } From 540632f27065553ae09f7f6f955e6c2cd27d51bb Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Fri, 4 Jul 2025 10:53:44 +0000 Subject: [PATCH 4/9] Apply fixes from StyleCI --- tests/NestedSetsQueryBehaviorTest.php | 1 + tests/support/model/ExtendableMultipleTree.php | 1 + tests/support/model/MultipleTree.php | 1 + tests/support/model/Tree.php | 1 + 4 files changed, 4 insertions(+) diff --git a/tests/NestedSetsQueryBehaviorTest.php b/tests/NestedSetsQueryBehaviorTest.php index 3145739..bb39c07 100644 --- a/tests/NestedSetsQueryBehaviorTest.php +++ b/tests/NestedSetsQueryBehaviorTest.php @@ -76,6 +76,7 @@ public function testLeavesMethodRequiresLeftAttributeOrderingForConsistentResult } } } + public function testReturnLeavesForSingleAndMultipleTreeModels(): void { $this->generateFixtureTree(); diff --git a/tests/support/model/ExtendableMultipleTree.php b/tests/support/model/ExtendableMultipleTree.php index 58fee77..09ce8f6 100644 --- a/tests/support/model/ExtendableMultipleTree.php +++ b/tests/support/model/ExtendableMultipleTree.php @@ -41,6 +41,7 @@ public function rules(): array ['name', 'required'], ]; } + public static function tableName(): string { return '{{%multiple_tree}}'; diff --git a/tests/support/model/MultipleTree.php b/tests/support/model/MultipleTree.php index f40e277..72c312a 100644 --- a/tests/support/model/MultipleTree.php +++ b/tests/support/model/MultipleTree.php @@ -41,6 +41,7 @@ public function rules(): array ['name', 'required'], ]; } + public static function tableName(): string { return '{{%multiple_tree}}'; diff --git a/tests/support/model/Tree.php b/tests/support/model/Tree.php index f5cc703..8792953 100644 --- a/tests/support/model/Tree.php +++ b/tests/support/model/Tree.php @@ -46,6 +46,7 @@ public function rules(): array ['name', 'required'], ]; } + public static function tableName(): string { return '{{%tree}}'; From 206fe36ae2d523909801a0e3cf199d8492969216 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 4 Jul 2025 07:00:40 -0400 Subject: [PATCH 5/9] fix: Update comments for clarity in `TestCase` and bump dependency versions in `composer.json`. --- composer.json | 4 ++-- tests/TestCase.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 715a066..4219351 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,8 @@ "phpstan/extension-installer": "^1.4", "phpstan/phpstan-strict-rules": "^2.0.3", "phpunit/phpunit": "^10.2", - "rector/rector": "^2.0", - "symplify/easy-coding-standard": "^12.3", + "rector/rector": "^2.1", + "symplify/easy-coding-standard": "^12.5", "yii2-extensions/phpstan": "^0.3.0" }, "autoload": { diff --git a/tests/TestCase.php b/tests/TestCase.php index 0790c3b..c470e5e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -139,7 +139,7 @@ protected function generateFixtureTree(): void $command = $this->getDb()->createCommand(); - // Carga el XML en la tabla `tree` + // Load XML fixture data into database tables $xml = new SimpleXMLElement("{$this->fixtureDirectory}/test.xml", 0, true); $children = $xml->children() ?? []; From fb4343f25590818b8d6fba40add83603a337ee11 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 4 Jul 2025 07:11:42 -0400 Subject: [PATCH 6/9] refactor: Simplify root creation and update logic in `NestedSetsQueryBehaviorTest`. --- tests/NestedSetsQueryBehaviorTest.php | 28 ++++++++++----------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/tests/NestedSetsQueryBehaviorTest.php b/tests/NestedSetsQueryBehaviorTest.php index bb39c07..90187f1 100644 --- a/tests/NestedSetsQueryBehaviorTest.php +++ b/tests/NestedSetsQueryBehaviorTest.php @@ -163,32 +163,24 @@ public function testRootsMethodRequiresOrderByForCorrectTreeTraversal(): void { $this->createDatabase(); - $rootA = new MultipleTree(['name' => 'Root A']); - - $rootA->makeRoot(); - - $rootC = new MultipleTree(['name' => 'Root C']); - - $rootC->makeRoot(); - - $rootB = new MultipleTree(['name' => 'Root B']); + $treeIds = [1, 2, 3, 4]; + $rootNames = ['Root A', 'Root C', 'Root B', 'Root D']; + $expectedOrder = ['Root A', 'Root B', 'Root C', 'Root D']; - $rootB->makeRoot(); + foreach ($rootNames as $name) { + $root = new MultipleTree(['name' => $name]); - $rootD = new MultipleTree(['name' => 'Root D']); + $root->makeRoot(); + } - $rootD->makeRoot(); $command = $this->getDb()->createCommand(); - $command->update('multiple_tree', ['tree' => 1], ['name' => 'Root A'])->execute(); - $command->update('multiple_tree', ['tree' => 2], ['name' => 'Root B'])->execute(); - $command->update('multiple_tree', ['tree' => 3], ['name' => 'Root C'])->execute(); - $command->update('multiple_tree', ['tree' => 4], ['name' => 'Root D'])->execute(); + foreach ($expectedOrder as $index => $name) { + $command->update('multiple_tree', ['tree' => $treeIds[$index]], ['name' => $name])->execute(); + } $rootsList = MultipleTree::find()->roots()->all(); - $expectedOrder = ['Root A', 'Root B', 'Root C', 'Root D']; - self::assertCount( 4, $rootsList, From a72219c06bbe7be605837c553384f1bb60c46aff Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 4 Jul 2025 07:16:44 -0400 Subject: [PATCH 7/9] refactor: Move `deleteWithChildrenInternal()` method to private scope and update documentation. --- src/NestedSetsBehavior.php | 80 +++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/src/NestedSetsBehavior.php b/src/NestedSetsBehavior.php index 308fb80..7becb13 100644 --- a/src/NestedSetsBehavior.php +++ b/src/NestedSetsBehavior.php @@ -1079,46 +1079,6 @@ protected function beforeInsertRootNode(): void $this->getOwner()->setAttribute($this->depthAttribute, 0); } - /** - * Deletes the current node and all its descendant nodes from the nested set tree. - * - * Executes a bulk deletion of the node to which this behavior is attached, along with all its children, by - * constructing a condition that matches all nodes within the left and right boundaries of the current node. - * - * This method is used internally to efficiently remove entire subtrees in a single operation, ensuring the - * integrity of the nested set structure. - * - * It also triggers the appropriate lifecycle events before and after deletion, and resets the old attributes of the - * owner model. - * - * The method applies the tree attribute condition if multi-tree support is enabled, restricting the deletion to - * nodes within the same tree. - * - * @return false|int Number of rows deleted, or `false` if the deletion is unsuccessful for any reason. - */ - protected function deleteWithChildrenInternal(): bool|int - { - if ($this->getOwner()->beforeDelete() === false) { - return false; - } - - $result = $this->getOwner()::deleteAll( - QueryConditionBuilder::createRangeCondition( - $this->leftAttribute, - $this->getLeftValue(), - $this->rightAttribute, - $this->getRightValue(), - $this->treeAttribute, - $this->getTreeValue($this->getOwner()), - ), - ); - - $this->getOwner()->setOldAttributes(null); - $this->getOwner()->afterDelete(); - - return $result; - } - /** * Executes node movement using the provided context. * @@ -1246,6 +1206,46 @@ private function createMoveContext(ActiveRecord $targetNode, string|null $operat }; } + /** + * Deletes the current node and all its descendant nodes from the nested set tree. + * + * Executes a bulk deletion of the node to which this behavior is attached, along with all its children, by + * constructing a condition that matches all nodes within the left and right boundaries of the current node. + * + * This method is used internally to efficiently remove entire subtrees in a single operation, ensuring the + * integrity of the nested set structure. + * + * It also triggers the appropriate lifecycle events before and after deletion, and resets the old attributes of the + * owner model. + * + * The method applies the tree attribute condition if multi-tree support is enabled, restricting the deletion to + * nodes within the same tree. + * + * @return false|int Number of rows deleted, or `false` if the deletion is unsuccessful for any reason. + */ + private function deleteWithChildrenInternal(): bool|int + { + if ($this->getOwner()->beforeDelete() === false) { + return false; + } + + $result = $this->getOwner()::deleteAll( + QueryConditionBuilder::createRangeCondition( + $this->leftAttribute, + $this->getLeftValue(), + $this->rightAttribute, + $this->getRightValue(), + $this->treeAttribute, + $this->getTreeValue($this->getOwner()), + ), + ); + + $this->getOwner()->setOldAttributes(null); + $this->getOwner()->afterDelete(); + + return $result; + } + private function executeCrossTreeMove( NodeContext $context, string $treeAttribute, From 15fb74d06fa1ece9404696e611e79ea1c2200c9b Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 4 Jul 2025 07:19:06 -0400 Subject: [PATCH 8/9] refactor: Remove `exposedDeleteWithChildrenInternal()` method from `ExtendableNestedSetsBehavior`. --- tests/support/stub/ExtendableNestedSetsBehavior.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/support/stub/ExtendableNestedSetsBehavior.php b/tests/support/stub/ExtendableNestedSetsBehavior.php index cb2efb7..299f473 100644 --- a/tests/support/stub/ExtendableNestedSetsBehavior.php +++ b/tests/support/stub/ExtendableNestedSetsBehavior.php @@ -33,14 +33,6 @@ public function exposedBeforeInsertRootNode(): void $this->beforeInsertRootNode(); } - - public function exposedDeleteWithChildrenInternal(): bool|int - { - $this->calledMethods['deleteWithChildrenInternal'] = true; - - return $this->deleteWithChildrenInternal(); - } - public function exposedMoveNode(ActiveRecord $node, int $value, int $depth): void { $this->calledMethods['moveNode'] = true; From 7a02b5fc2ca37bf162a07aa4e2a4ed447c7195b6 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Fri, 4 Jul 2025 11:19:24 +0000 Subject: [PATCH 9/9] Apply fixes from StyleCI --- tests/support/stub/ExtendableNestedSetsBehavior.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/support/stub/ExtendableNestedSetsBehavior.php b/tests/support/stub/ExtendableNestedSetsBehavior.php index 299f473..ee5819c 100644 --- a/tests/support/stub/ExtendableNestedSetsBehavior.php +++ b/tests/support/stub/ExtendableNestedSetsBehavior.php @@ -33,6 +33,7 @@ public function exposedBeforeInsertRootNode(): void $this->beforeInsertRootNode(); } + public function exposedMoveNode(ActiveRecord $node, int $value, int $depth): void { $this->calledMethods['moveNode'] = true;