diff --git a/composer.json b/composer.json index 35d3f24..01b9b17 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "ext-simplexml": "*", "infection/infection": "^0.27|^0.29", "maglnet/composer-require-checker": "^4.1", + "php-forge/support": "^0.1", "phpstan/extension-installer": "^1.4", "phpstan/phpstan-strict-rules": "^2.0.3", "phpunit/phpunit": "^10.2", diff --git a/src/NestedSetsBehavior.php b/src/NestedSetsBehavior.php index ea1bdf4..7aadb6b 100644 --- a/src/NestedSetsBehavior.php +++ b/src/NestedSetsBehavior.php @@ -103,6 +103,11 @@ class NestedSetsBehavior extends Behavior */ public string $depthAttribute = 'depth'; + /** + * Stores the depth value for the current operation. + */ + protected int|null $depthValue = null; + /** * Name of the attribute that stores the left boundary value of the node in the nested set tree. * @@ -110,6 +115,11 @@ class NestedSetsBehavior extends Behavior */ public string $leftAttribute = 'lft'; + /** + * Stores the left value for the current operation. + */ + protected int|null $leftValue = null; + /** * Name of the attribute that stores the right boundary value of the node in the nested set tree. * @@ -117,6 +127,11 @@ class NestedSetsBehavior extends Behavior */ public string $rightAttribute = 'rgt'; + /** + * Stores the right value for the current operation. + */ + protected int|null $rightValue = null; + /** * Name of the attribute that stores the tree identifier for supporting multiple trees. */ @@ -130,21 +145,6 @@ class NestedSetsBehavior extends Behavior */ private Connection|null $db = null; - /** - * Stores the depth value for the current operation. - */ - private int|null $depthValue = null; - - /** - * Stores the left value for the current operation. - */ - private int|null $leftValue = null; - - /** - * Stores the right value for the current operation. - */ - private int|null $rightValue = null; - /** * Handles post-deletion updates for the nested set structure. * @@ -193,9 +193,7 @@ public function afterDelete(): void } $this->shiftLeftRightAttribute($this->getRightValue(), $deltaValue); - - $this->operation = null; - $this->node = null; + $this->invalidateCache(); } /** @@ -235,8 +233,7 @@ public function afterInsert(): void ); } - $this->operation = null; - $this->node = null; + $this->invalidateCache(); } /** @@ -268,17 +265,44 @@ public function afterUpdate(): void if ($this->operation === self::OPERATION_MAKE_ROOT) { $this->moveNodeAsRoot($currentOwnerTreeValue); + $this->invalidateCache(); return; } if ($this->node === null) { + $this->invalidateCache(); + return; } $context = $this->createMoveContext($this->node, $this->operation); - $this->moveNode($context); + $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; } /** diff --git a/tests/NestedSetsBehaviorTest.php b/tests/NestedSetsBehaviorTest.php index 2cf733e..c99007e 100644 --- a/tests/NestedSetsBehaviorTest.php +++ b/tests/NestedSetsBehaviorTest.php @@ -5,8 +5,9 @@ namespace yii2\extensions\nestedsets\tests; use LogicException; +use PHPForge\Support\Assert; use Throwable; -use yii\base\NotSupportedException; +use yii\base\{Behavior, NotSupportedException}; use yii\db\{ActiveRecord, Exception, StaleObjectException}; use yii\helpers\ArrayHelper; use yii2\extensions\nestedsets\NestedSetsBehavior; @@ -2282,4 +2283,463 @@ public function testMakeRootRefreshIsNecessaryForCorrectAttributeValues(): void "Reloaded node should have 'depth=0'.", ); } + + public function testCacheInvalidationAfterMakeRoot(): void + { + $this->createDatabase(); + + $root = new MultipleTree(['name' => 'Original Root']); + + $root->makeRoot(); + + $child = new MultipleTree(['name' => 'Child']); + + $child->appendTo($root); + + $behavior = $child->getBehavior('nestedSetsBehavior'); + + self::assertNotNull( + $behavior, + 'Behavior should be attached to the child node.', + ); + + 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.', + ); + + $child->makeRoot(); + + $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 testCacheInvalidationAfterAppendTo(): void + { + $this->createDatabase(); + + $root = new MultipleTree(['name' => 'Root']); + + $root->makeRoot(); + + $child1 = new MultipleTree(['name' => 'Child 1']); + + $child1->appendTo($root); + + $child2 = new MultipleTree(['name' => 'Child 2']); + + $behavior = $child2->getBehavior('nestedSetsBehavior'); + + self::assertNotNull( + $behavior, + 'Behavior should be attached to the child node.', + ); + + $child2->appendTo($root); + + $this->populateAndVerifyCache($behavior); + + $child2->appendTo($child1); + + $this->verifyCacheInvalidation($behavior); + } + + public function testCacheInvalidationAfterDeleteWithChildren(): void + { + $this->createDatabase(); + + $root = new MultipleTree(['name' => 'Root']); + + $root->makeRoot(); + + $child = new MultipleTree(['name' => 'Child']); + + $child->appendTo($root); + + $grandchild = new MultipleTree(['name' => 'Grandchild']); + + $grandchild->appendTo($child); + $behavior = $child->getBehavior('nestedSetsBehavior'); + + self::assertNotNull( + $behavior, + 'Behavior should be attached to the child node.', + ); + + $this->populateAndVerifyCache($behavior); + + $child->deleteWithChildren(); + + $this->verifyCacheInvalidation($behavior); + } + + public function testManualCacheInvalidation(): void + { + $this->createDatabase(); + + $root = new MultipleTree(['name' => 'Root']); + + $root->makeRoot(); + + $behavior = $root->getBehavior('nestedSetsBehavior'); + + self::assertNotNull( + $behavior, + 'Behavior should be attached to the root node.', + ); + + $this->populateAndVerifyCache($behavior); + + $root->invalidateCache(); + + $this->verifyCacheInvalidation($behavior); + + 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.', + ); + } + + public function testCacheInvalidationAfterInsertWithTreeAttribute(): void + { + $this->createDatabase(); + + $node = new MultipleTree(['name' => 'Root Node']); + + $behavior = $node->getBehavior('nestedSetsBehavior'); + + self::assertNotNull( + $behavior, + 'Behavior should be attached to the node.', + ); + + $node->makeRoot(); + + $this->populateAndVerifyCache($behavior); + + $node->invalidateCache(); + + $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.", + ); + 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 testCacheInvalidationAfterInsertWithoutTreeAttribute(): void + { + $this->createDatabase(); + + $node = new Tree(['name' => 'Root Node']); + + $behavior = $node->getBehavior('nestedSetsBehavior'); + + self::assertNotNull( + $behavior, + 'Behavior should be attached to the node.', + ); + + $node->makeRoot(); + + $this->populateAndVerifyCache($behavior); + + $node->invalidateCache(); + + $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 testAfterInsertCallsInvalidateCache(): void + { + $this->createDatabase(); + + $node = new ExtendableMultipleTree(['name' => 'Root Node']); + + $behavior = $node->getBehavior('nestedSetsBehavior'); + + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $behavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + ); + + $node->makeRoot(); + + 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.', + ); + } + + public function testAfterInsertCacheInvalidationIntegration(): void + { + $this->createDatabase(); + + $root = new MultipleTree(['name' => 'Original Root']); + + $root->makeRoot(); + + $child = new MultipleTree(['name' => 'Child Node']); + + $child->appendTo($root); + + $behavior = $child->getBehavior('nestedSetsBehavior'); + + 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'.", + ); + + $this->populateAndVerifyCache($behavior); + + $child->makeRoot(); + + $this->verifyCacheInvalidation($behavior); + + 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 testAfterUpdateCacheInvalidationWhenMakeRoot(): void + { + $this->createDatabase(); + + $root = new ExtendableMultipleTree(['name' => 'Root']); + + $root->makeRoot(); + + $child = new ExtendableMultipleTree(['name' => 'Child']); + + $child->appendTo($root); + + $behavior = $child->getBehavior('nestedSetsBehavior'); + + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $behavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + ); + + $this->populateAndVerifyCache($behavior); + + $behavior->setOperation(NestedSetsBehavior::OPERATION_MAKE_ROOT); + $behavior->afterUpdate(); + + $this->verifyCacheInvalidation($behavior); + } + + public function testAfterUpdateCacheInvalidationWhenMakeRootAndNodeItsNull(): void + { + $this->createDatabase(); + + $root = new ExtendableMultipleTree(['name' => 'Root']); + + $root->makeRoot(); + + $child = new ExtendableMultipleTree(['name' => 'Child']); + + $child->appendTo($root); + + $behavior = $child->getBehavior('nestedSetsBehavior'); + + self::assertInstanceOf( + ExtendableNestedSetsBehavior::class, + $behavior, + "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", + ); + + $this->populateAndVerifyCache($behavior); + + $behavior->setNode(null); + $behavior->afterUpdate(); + + $this->verifyCacheInvalidation($behavior); + } + + /** + * @phpstan-param Behavior $behavior + */ + private function populateAndVerifyCache(Behavior $behavior): void + { + Assert::invokeMethod($behavior, 'getDepthValue'); + Assert::invokeMethod($behavior, 'getLeftValue'); + Assert::invokeMethod($behavior, 'getRightValue'); + + self::assertNotNull( + Assert::inaccessibleProperty($behavior, 'depthValue'), + 'Depth value cache should be populated.', + ); + self::assertNotNull( + Assert::inaccessibleProperty($behavior, 'leftValue'), + 'Left value cache should be populated.', + ); + self::assertNotNull( + Assert::inaccessibleProperty($behavior, 'rightValue'), + 'Right value cache should be populated.', + ); + } + + /** + * @phpstan-param Behavior $behavior + */ + private function verifyCacheInvalidation(Behavior $behavior): void + { + self::assertNull( + Assert::inaccessibleProperty($behavior, 'depthValue'), + "Depth value cache should be invalidated after 'makeRoot()'/'afterInsert()'.", + ); + self::assertNull( + Assert::inaccessibleProperty($behavior, 'leftValue'), + "Left value cache should be invalidated after 'makeRoot()'/'afterInsert()'.", + ); + self::assertNull( + Assert::inaccessibleProperty($behavior, 'node'), + "Node cache should be 'null' after manual invalidation.", + ); + self::assertNull( + Assert::inaccessibleProperty($behavior, 'operation'), + "Operation cache should be 'null' after manual invalidation.", + ); + self::assertNull( + Assert::inaccessibleProperty($behavior, 'rightValue'), + "Right value cache should be invalidated after 'makeRoot()'/'afterInsert()'.", + ); + } } diff --git a/tests/support/stub/ExtendableNestedSetsBehavior.php b/tests/support/stub/ExtendableNestedSetsBehavior.php index 67c4a29..2396d69 100644 --- a/tests/support/stub/ExtendableNestedSetsBehavior.php +++ b/tests/support/stub/ExtendableNestedSetsBehavior.php @@ -14,6 +14,8 @@ */ final class ExtendableNestedSetsBehavior extends NestedSetsBehavior { + public bool $invalidateCacheCalled = false; + /** * @phpstan-var array */ @@ -44,7 +46,6 @@ public function exposedMoveNode(ActiveRecord $node, int $value, int $depth): voi { $this->calledMethods['moveNode'] = true; - // Create a mock context for testing compatibility $context = new \yii2\extensions\nestedsets\NodeContext( $node, 0, @@ -84,4 +85,21 @@ public function getCalledMethods(): array { return $this->calledMethods; } + + public function invalidateCache(): void + { + $this->invalidateCacheCalled = true; + + parent::invalidateCache(); + } + + public function setNode(ActiveRecord|null $node): void + { + $this->node = $node; + } + + public function setOperation(string|null $operation): void + { + $this->operation = $operation; + } }