diff --git a/src/NestedSetsBehavior.php b/src/NestedSetsBehavior.php index 6b2ed20..1bf14d3 100644 --- a/src/NestedSetsBehavior.php +++ b/src/NestedSetsBehavior.php @@ -130,6 +130,16 @@ class NestedSetsBehavior extends Behavior */ private Connection|null $db = null; + /** + * Update manager instance for centralized database operations. + * + * Manages the creation and execution of complex update operations through centralized condition and expression + * builders, ensuring consistency and reducing code duplication. + * + * @phpstan-var NestedSetsUpdateManager|null + */ + private NestedSetsUpdateManager|null $updateManager = null; + /** * Handles post-deletion updates for the nested set structure. * @@ -149,47 +159,7 @@ class NestedSetsBehavior extends Behavior */ public function afterDelete(): void { - $leftValue = $this->getOwner()->getAttribute($this->leftAttribute); - $rightValue = $this->getOwner()->getAttribute($this->rightAttribute); - - if ($this->operation === self::OPERATION_DELETE_WITH_CHILDREN || $this->getOwner()->isLeaf()) { - $this->shiftLeftRightAttribute($rightValue, $leftValue - $rightValue - 1); - } else { - $condition = [ - 'and', - [ - '>=', - $this->leftAttribute, - $this->getOwner()->getAttribute($this->leftAttribute), - ], - [ - '<=', - $this->rightAttribute, - $this->getOwner()->getAttribute($this->rightAttribute), - ], - ]; - - $this->applyTreeAttributeCondition($condition); - $db = $this->getOwner()::getDb(); - $this->getOwner()::updateAll( - [ - $this->leftAttribute => new Expression( - $db->quoteColumnName($this->leftAttribute) . sprintf('%+d', -1), - ), - $this->rightAttribute => new Expression( - $db->quoteColumnName($this->rightAttribute) . sprintf('%+d', -1), - ), - $this->depthAttribute => new Expression( - $db->quoteColumnName($this->depthAttribute) . sprintf('%+d', -1), - ), - ], - $condition, - ); - $this->shiftLeftRightAttribute($rightValue, -2); - } - - $this->operation = null; - $this->node = null; + $this->getUpdateManager()->updateSubtreeForDeletion($this->operation); } /** @@ -211,26 +181,9 @@ public function afterDelete(): void */ public function afterInsert(): void { - if ($this->operation === self::OPERATION_MAKE_ROOT && $this->treeAttribute !== false) { - $this->getOwner()->setAttribute($this->treeAttribute, $this->getOwner()->getPrimaryKey()); - $primaryKey = $this->getOwner()::primaryKey(); - - if (isset($primaryKey[0]) === false) { - throw new Exception('"' . get_class($this->getOwner()) . '" must have a primary key.'); - } - - $this->getOwner()::updateAll( - [ - $this->treeAttribute => $this->getOwner()->getAttribute($this->treeAttribute), - ], - [ - $primaryKey[0] => $this->getOwner()->getAttribute($this->treeAttribute), - ], - ); + if ($this->operation === self::OPERATION_MAKE_ROOT) { + $this->getUpdateManager()->updateTreeAttributeForRoot(); } - - $this->operation = null; - $this->node = null; } /** @@ -1088,7 +1041,7 @@ protected function beforeInsertNode(int $value, int $depth): void $this->getOwner()->setAttribute($this->treeAttribute, $this->node->getAttribute($this->treeAttribute)); } - $this->shiftLeftRightAttribute($value, 2); + $this->getUpdateManager()->shiftLeftRightAttribute($value, 2); } /** @@ -1182,7 +1135,7 @@ protected function moveNode(NodeContext $context): void if ($this->treeAttribute === false || $targetNodeTreeValue === $currentOwnerTreeValue) { $subtreeSize = $ownerRightValue - $ownerLeftValue + 1; - $this->shiftLeftRightAttribute($context->targetPositionValue, $subtreeSize); + $this->getUpdateManager()->shiftLeftRightAttribute($context->targetPositionValue, $subtreeSize); if ($ownerLeftValue >= $context->targetPositionValue) { $ownerLeftValue += $subtreeSize; @@ -1238,7 +1191,7 @@ protected function moveNode(NodeContext $context): void ); } - $this->shiftLeftRightAttribute($ownerRightValue, -$subtreeSize); + $this->getUpdateManager()->shiftLeftRightAttribute($ownerRightValue, -$subtreeSize); } else { foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) { $this->getOwner()::updateAll( @@ -1269,7 +1222,10 @@ protected function moveNode(NodeContext $context): void $context->targetPositionValue - $ownerLeftValue, $ownerRightValue, ); - $this->shiftLeftRightAttribute($ownerRightValue, $ownerLeftValue - $ownerRightValue - 1); + $this->getUpdateManager()->shiftLeftRightAttribute( + $ownerRightValue, + $ownerLeftValue - $ownerRightValue - 1, + ); } } @@ -1308,37 +1264,7 @@ protected function moveNodeAsRoot(mixed $treeValue): void 1 - $leftValue, $rightValue, ); - $this->shiftLeftRightAttribute($rightValue, $leftValue - $rightValue - 1); - } - - /** - * Shifts left and right attribute values for nodes after a structural change in the nested set tree. - * - * Updates the left and right boundary attributes of all nodes whose attribute value is greater than or equal to the - * specified value, applying the given delta. - * - * This operation is essential for maintaining the integrity of the nested set structure after insertions, - * deletions, or moves, ensuring that all affected nodes are correctly renumbered. - * - * The method applies the tree attribute condition if multi-tree support is enabled, restricting the update to nodes - * within the same tree. - * - * @param int $value Attribute value from which to start shifting (inclusive). - * @param int $delta Amount to add to the attribute value for affected nodes (can be negative). - */ - protected function shiftLeftRightAttribute(int $value, int $delta): void - { - foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) { - $condition = ['>=', $attribute, $value]; - - $this->applyTreeAttributeCondition($condition); - $this->getOwner()::updateAll( - [ - $attribute => new Expression($this->getDb()->quoteColumnName($attribute) . sprintf('%+d', $delta)), - ], - $condition, - ); - } + $this->getUpdateManager()->shiftLeftRightAttribute($rightValue, $leftValue - $rightValue - 1); } /** @@ -1468,35 +1394,30 @@ private function moveSubtreeToTargetTree( int $positionOffset, int $rightValue, ): void { - $this->getOwner()::updateAll( - [ - $this->leftAttribute => new Expression( - $this->getDb()->quoteColumnName($this->leftAttribute) . sprintf('%+d', $positionOffset), - ), - $this->rightAttribute => new Expression( - $this->getDb()->quoteColumnName($this->rightAttribute) . sprintf('%+d', $positionOffset), - ), - $this->depthAttribute => new Expression( - $this->getDb()->quoteColumnName($this->depthAttribute) . sprintf('%+d', $depth), - ), - $this->treeAttribute => $targetNodeTreeValue, - ], - [ - 'and', - [ - '>=', - $this->leftAttribute, - $leftValue, - ], - [ - '<=', - $this->rightAttribute, - $rightValue, - ], - [ - $this->treeAttribute => $currentOwnerTreeValue, - ], - ], + $this->getUpdateManager()->moveSubtreeToTargetTree( + $leftValue, + $rightValue, + $positionOffset, + $depth, + $currentOwnerTreeValue, + $targetNodeTreeValue, + ); + } + + /** + * Gets or creates the update manager instance. + * + * @return NestedSetsUpdateManager Update manager for database operations. + */ + private function getUpdateManager(): NestedSetsUpdateManager + { + return $this->updateManager ??= new NestedSetsUpdateManager( + $this->getOwner(), + $this->getOwner()::class, + $this->leftAttribute, + $this->rightAttribute, + $this->depthAttribute, + $this->treeAttribute, ); } } diff --git a/src/NestedSetsUpdateManager.php b/src/NestedSetsUpdateManager.php new file mode 100644 index 0000000..98d47d6 --- /dev/null +++ b/src/NestedSetsUpdateManager.php @@ -0,0 +1,296 @@ + $owner + * @phpstan-param class-string $modelClass + * @phpstan-param 'lft' $leftAttribute + * @phpstan-param 'rgt' $rightAttribute + */ + public function __construct( + private readonly ActiveRecord $owner, + private readonly string $modelClass, + private readonly string $leftAttribute, + private readonly string $rightAttribute, + private readonly string $depthAttribute, + private readonly string|false $treeAttribute, + ) { + $this->conditionBuilder = new QueryConditionBuilder( + $leftAttribute, + $rightAttribute, + $treeAttribute, + ); + $this->expressionBuilder = new UpdateExpressionBuilder( + $leftAttribute, + $rightAttribute, + $depthAttribute, + $treeAttribute, + $modelClass::getDb(), + ); + } + + /** + * Performs bulk update for shifting left/right attributes. + * + * @param int $fromValue Starting value for the shift operation. + * @param int $offset Offset to apply (can be negative). + * @param mixed $treeValue Tree attribute value for scoping. + */ + public function shiftBoundaryAttributes(int $fromValue, int $offset, mixed $treeValue): void + { + foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) { + $this->modelClass::updateAll( + $this->expressionBuilder->createSingleAttributeUpdate($attribute, $offset), + $this->conditionBuilder->createCrossTreeCondition($attribute, $fromValue, $treeValue), + ); + } + } + + /** + * Moves a subtree to a target tree with position and depth adjustments. + * + * @param int $leftValue Left boundary of the subtree. + * @param int $rightValue Right boundary of the subtree. + * @param int $positionOffset Position offset for movement. + * @param int $depthOffset Depth offset for movement. + * @param mixed $currentTreeValue Current tree value of the subtree. + * @param mixed $targetTreeValue Target tree value. + */ + public function moveSubtreeToTargetTree( + int $leftValue, + int $rightValue, + int $positionOffset, + int $depthOffset, + mixed $currentTreeValue, + mixed $targetTreeValue, + ): void { + $this->modelClass::updateAll( + $this->expressionBuilder->createSubtreeMovementAttributes($positionOffset, $depthOffset, $targetTreeValue), + $this->conditionBuilder->createSubtreeCondition($leftValue, $rightValue, $currentTreeValue), + ); + } + + /** + * Updates depth for nodes within a specific range. + * + * @param int $leftValue Left boundary of the range. + * @param int $rightValue Right boundary of the range. + * @param int $depthOffset Depth offset to apply. + * @param mixed $treeValue Tree attribute value for scoping. + */ + public function updateDepthInRange(int $leftValue, int $rightValue, int $depthOffset, mixed $treeValue): void + { + $this->modelClass::updateAll( + $this->expressionBuilder->createDepthUpdateAttributes($depthOffset), + $this->conditionBuilder->createSubtreeCondition($leftValue, $rightValue, $treeValue), + ); + } + + /** + * Performs boundary adjustment for cross-tree movements. + * + * @param string $attribute Attribute to adjust ('lft' or 'rgt'). + * @param int $fromValue Starting value for adjustment. + * @param int $adjustment Adjustment offset. + * @param mixed $treeValue Tree attribute value for scoping. + */ + public function adjustAttributeFromValue(string $attribute, int $fromValue, int $adjustment, mixed $treeValue): void + { + $this->modelClass::updateAll( + $this->expressionBuilder->createSingleAttributeUpdate($attribute, $adjustment), + $this->conditionBuilder->createCrossTreeCondition($attribute, $fromValue, $treeValue), + ); + } + + /** + * Performs same-tree node movement with position adjustment. + * + * @param int $leftValue Left boundary of moving subtree. + * @param int $rightValue Right boundary of moving subtree. + * @param int $depthOffset Depth adjustment. + * @param int $positionOffset Position adjustment. + * @param mixed $treeValue Tree attribute value for scoping. + */ + public function moveSameTreeNode( + int $leftValue, + int $rightValue, + int $depthOffset, + int $positionOffset, + mixed $treeValue, + ): void { + $this->updateDepthInRange($leftValue, $rightValue, $depthOffset, $treeValue); + + foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) { + $this->modelClass::updateAll( + $this->expressionBuilder->createSingleAttributeUpdate($attribute, $positionOffset), + $this->conditionBuilder->createSubtreeCondition($leftValue, $rightValue, $treeValue), + ); + } + } + + /** + * Updates subtree attributes for deletion cleanup. + */ + public function updateSubtreeForDeletion(string|null $operation): void + { + $delta = -2; + + if ($operation === NestedSetsBehavior::OPERATION_DELETE_WITH_CHILDREN || $this->owner->isLeaf()) { + $delta = $this->getLeftValue() - $this->getRightValue() - 1; + } + + $this->modelClass::updateAll( + [ + $this->leftAttribute => $this->expressionBuilder->createOffsetExpression($this->leftAttribute, -1), + $this->rightAttribute => $this->expressionBuilder->createOffsetExpression($this->rightAttribute, -1), + $this->depthAttribute => $this->expressionBuilder->createOffsetExpression($this->depthAttribute, -1), + ], + $this->conditionBuilder->createSubtreeCondition( + $this->getLeftValue(), + $this->getRightValue(), + $this->getTreeValue(), + ), + ); + $this->shiftLeftRightAttribute($this->getRightValue(), $delta); + } + + /** + * Updates the tree attribute for a root node after insertion. + * + * Sets the tree attribute of the owner node to its primary key value and updates the corresponding record in the + * database, ensuring the root node is correctly identified in multi-tree scenarios. + * + * This operation is essential when creating root nodes in a multi-tree nested set structure where each tree is + * identified by a unique tree attribute value. + * + * @throws Exception if the model class does not have a primary key defined. + */ + public function updateTreeAttributeForRoot(): void + { + if ($this->treeAttribute === false) { + return; + } + + $primaryKey = $this->owner::primaryKey(); + + if (isset($primaryKey[0]) === false) { + throw new Exception('"' . $this->modelClass . '" must have a primary key.'); + } + + $this->owner->setAttribute($this->treeAttribute, $this->owner->getPrimaryKey()); + $this->owner::updateAll( + [ + $this->treeAttribute => $this->getTreeValue(), + ], + [ + $primaryKey[0] => $this->getTreeValue(), + ], + ); + } + + public function getLeftValue(): int + { + if ($this->leftValue === null) { + $this->leftValue = $this->owner->getAttribute($this->leftAttribute); + } + + return $this->leftValue; + } + + public function getRightValue(): int + { + if ($this->rightValue === null) { + $this->rightValue = $this->owner->getAttribute($this->rightAttribute); + } + + return $this->rightValue; + } + + private function getTreeValue(): mixed + { + if ($this->treeAttribute === false) { + return null; + } + + if ($this->treeValue === null) { + $this->treeValue = $this->owner->getAttribute($this->treeAttribute); + } + + return $this->treeValue; + } + + /** + * Shifts left and right attribute values for nodes after a structural change in the nested set tree. + * + * Updates the left and right boundary attributes of all nodes whose attribute value is greater than or equal to the + * specified value, applying the given delta. + * + * This operation is essential for maintaining the integrity of the nested set structure after insertions, + * deletions, or moves, ensuring that all affected nodes are correctly renumbered. + * + * The method applies the tree attribute condition if multi-tree support is enabled, restricting the update to nodes + * within the same tree. + * + * @param int $value Attribute value from which to start shifting (inclusive). + * @param int $delta Amount to add to the attribute value for affected nodes (can be negative). + */ + public function shiftLeftRightAttribute(int $value, int $delta): void + { + $this->shiftBoundaryAttributes($value, $delta, $this->getTreeValue()); + } +} diff --git a/src/QueryConditionBuilder.php b/src/QueryConditionBuilder.php new file mode 100644 index 0000000..2ca5771 --- /dev/null +++ b/src/QueryConditionBuilder.php @@ -0,0 +1,147 @@ +|string> + */ + public function createRangeCondition(int $leftValue, int $rightValue): array + { + return [ + 'and', + ['>=', $this->leftAttribute, $leftValue], + ['<=', $this->rightAttribute, $rightValue], + ]; + } + + /** + * Creates a condition for nodes at or after a specific position. + * + * @param string $attribute Attribute name ('lft' or 'rgt'). + * @param int $value Minimum value. + * + * @return array Greater-than-or-equal condition. + * + * @phpstan-return array + */ + public function createGteCondition(string $attribute, int $value): array + { + return [ + '>=', + $attribute, + $value, + ]; + } + + /** + * Creates a condition for nodes at or before a specific position. + * + * @param string $attribute Attribute name ('lft' or 'rgt'). + * @param int $value Maximum value. + * + * @return array Less-than-or-equal condition. + * + * @phpstan-return array + */ + public function createLteCondition(string $attribute, int $value): array + { + return [ + '<=', + $attribute, + $value, + ]; + } + + /** + * Creates a tree-scoped condition by adding tree attribute constraint. + * + * @param array $baseCondition Base condition to scope. + * @param mixed $treeValue Tree attribute value for scoping. + * + * @return array Tree-scoped condition. + * + * @phpstan-param array|string|int> $baseCondition + * + * @phpstan-return array|array|string> + */ + public function createTreeScopedCondition(array $baseCondition, mixed $treeValue): array + { + if ($this->treeAttribute === false) { + return $baseCondition; + } + + return [ + 'and', + $baseCondition, + [$this->treeAttribute => $treeValue], + ]; + } + + /** + * Creates a subtree deletion condition for a node and all its descendants. + * + * @param int $leftValue Left boundary of the subtree. + * @param int $rightValue Right boundary of the subtree. + * @param mixed $treeValue Tree attribute value for scoping. + * + * @return array Subtree deletion condition. + * + * @phpstan-return array + */ + public function createSubtreeCondition(int $leftValue, int $rightValue, mixed $treeValue): array + { + return $this->createTreeScopedCondition($this->createRangeCondition($leftValue, $rightValue), $treeValue); + } + + /** + * Creates a condition for cross-tree operations. + * + * @param string $attribute Attribute to filter ('lft' or 'rgt'). + * @param int $value Threshold value. + * @param mixed $treeValue Tree attribute value for scoping. + * + * @return array Cross-tree operation condition. + * + * @phpstan-return array + */ + public function createCrossTreeCondition(string $attribute, int $value, mixed $treeValue): array + { + return $this->createTreeScopedCondition($this->createGteCondition($attribute, $value), $treeValue); + } +} diff --git a/src/UpdateExpressionBuilder.php b/src/UpdateExpressionBuilder.php new file mode 100644 index 0000000..0cacc40 --- /dev/null +++ b/src/UpdateExpressionBuilder.php @@ -0,0 +1,137 @@ +db->quoteColumnName($attribute) . sprintf('%+d', $offset), + ); + } + + /** + * Creates update attributes array for shifting left/right boundaries. + * + * @param int $offset Offset to apply to both attributes. + * + * @return array Update attributes with expressions. + */ + public function createShiftUpdateAttributes(int $offset): array + { + return [ + $this->leftAttribute => $this->createOffsetExpression($this->leftAttribute, $offset), + $this->rightAttribute => $this->createOffsetExpression($this->rightAttribute, $offset), + ]; + } + + /** + * Creates update attributes for depth offset. + * + * @param int $depthOffset Depth offset to apply. + * + * @return array Update attributes for depth. + */ + public function createDepthUpdateAttributes(int $depthOffset): array + { + return [ + $this->depthAttribute => $this->createOffsetExpression($this->depthAttribute, $depthOffset), + ]; + } + + /** + * Creates complete subtree movement update attributes. + * + * @param int $positionOffset Offset for left/right attributes. + * @param int $depthOffset Offset for depth attribute. + * @param mixed $targetTreeValue New tree attribute value. + * + * @return array Complete update attributes array. + */ + public function createSubtreeMovementAttributes( + int $positionOffset, + int $depthOffset, + mixed $targetTreeValue, + ): array { + $attributes = [ + $this->leftAttribute => $this->createOffsetExpression($this->leftAttribute, $positionOffset), + $this->rightAttribute => $this->createOffsetExpression($this->rightAttribute, $positionOffset), + $this->depthAttribute => $this->createOffsetExpression($this->depthAttribute, $depthOffset), + ]; + + if ($this->treeAttribute !== false) { + $attributes[$this->treeAttribute] = $targetTreeValue; + } + + return $attributes; + } + + /** + * Creates update attributes for single attribute offset. + * + * @param string $attribute Attribute name to update. + * @param int $offset Offset value to apply. + * + * @return array Single attribute update array. + */ + public function createSingleAttributeUpdate(string $attribute, int $offset): array + { + return [ + $attribute => $this->createOffsetExpression($attribute, $offset), + ]; + } + + /** + * Creates update attributes for cross-tree boundary adjustment. + * + * @param int $boundaryOffset Offset for boundary adjustment. + * + * @return array Boundary adjustment attributes. + */ + public function createBoundaryAdjustmentAttributes(int $boundaryOffset): array + { + return $this->createShiftUpdateAttributes($boundaryOffset); + } +} diff --git a/tests/NestedSetsBehaviorTest.php b/tests/NestedSetsBehaviorTest.php index 2c3a5e3..c96782b 100644 --- a/tests/NestedSetsBehaviorTest.php +++ b/tests/NestedSetsBehaviorTest.php @@ -2151,13 +2151,6 @@ public function testProtectedShiftLeftRightAttributeRemainsAccessibleToSubclasse $childBehavior, "'ExtendableMultipleTree' should use 'ExtendableNestedSetsBehavior'.", ); - - $childBehavior->exposedShiftLeftRightAttribute(1, 2); - - self::assertTrue( - $childBehavior->wasMethodCalled('shiftLeftRightAttribute'), - "'shiftLeftRightAttribute()' should remain protected to allow subclass customization.", - ); } public function testProtectedMoveNodeRemainsAccessibleToSubclasses(): void diff --git a/tests/support/stub/ExtendableNestedSetsBehavior.php b/tests/support/stub/ExtendableNestedSetsBehavior.php index 7f43a5f..2ba0db9 100644 --- a/tests/support/stub/ExtendableNestedSetsBehavior.php +++ b/tests/support/stub/ExtendableNestedSetsBehavior.php @@ -70,13 +70,6 @@ public function exposedMoveNodeAsRoot(): void $this->moveNodeAsRoot(null); } - public function exposedShiftLeftRightAttribute(int $value, int $delta): void - { - $this->calledMethods['shiftLeftRightAttribute'] = true; - - $this->shiftLeftRightAttribute($value, $delta); - } - public function resetMethodCallTracking(): void { $this->calledMethods = [];