Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 115 additions & 73 deletions src/NestedSetsBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

use LogicException;
use yii\base\{Behavior, NotSupportedException};
use yii\db\{ActiveQuery, ActiveRecord, Exception, Expression};
use yii\db\{ActiveQuery, ActiveRecord, Connection, Exception, Expression};

use function sprintf;

/**
* Nested set behavior for managing hierarchical data in {@see ActiveRecord} models.
Expand Down Expand Up @@ -119,6 +121,8 @@
*/
public string|false $treeAttribute = false;

private Connection|null $db = null;

/**
* Handles post-deletion updates for the nested set structure.
*
Expand Down Expand Up @@ -247,17 +251,19 @@
*/
public function afterUpdate(): void
{
$treeValue = $this->treeAttribute !== false ? $this->getOwner()->getAttribute($this->treeAttribute) : null;

match (true) {
$this->operation === self::OPERATION_APPEND_TO && $this->node !== null =>
$this->moveNode($this->node, $this->node->getAttribute($this->rightAttribute), 1),
$this->moveNode($this->node, $treeValue, $this->node->getAttribute($this->rightAttribute), 1),
$this->operation === self::OPERATION_INSERT_AFTER && $this->node !== null =>
$this->moveNode($this->node, $this->node->getAttribute($this->rightAttribute) + 1, 0),
$this->moveNode($this->node, $treeValue, $this->node->getAttribute($this->rightAttribute) + 1, 0),
$this->operation === self::OPERATION_INSERT_BEFORE && $this->node !== null =>
$this->moveNode($this->node, $this->node->getAttribute($this->leftAttribute), 0),
$this->moveNode($this->node, $treeValue, $this->node->getAttribute($this->leftAttribute), 0),
$this->operation === self::OPERATION_MAKE_ROOT =>
$this->moveNodeAsRoot(),
$this->moveNodeAsRoot($treeValue),
$this->operation === self::OPERATION_PREPEND_TO && $this->node !== null =>
$this->moveNode($this->node, $this->node->getAttribute($this->leftAttribute) + 1, 1),
$this->moveNode($this->node, $treeValue, $this->node->getAttribute($this->leftAttribute) + 1, 1),
default => null,
};

Expand Down Expand Up @@ -527,21 +533,21 @@
{
$this->operation = self::OPERATION_DELETE_WITH_CHILDREN;

if ($this->getOwner()->isTransactional(ActiveRecord::OP_DELETE) !== false) {

Check warning on line 536 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "NotIdentical": @@ @@ public function deleteWithChildren(): bool|int { $this->operation = self::OPERATION_DELETE_WITH_CHILDREN; - if ($this->getOwner()->isTransactional(ActiveRecord::OP_DELETE) !== false) { + if ($this->getOwner()->isTransactional(ActiveRecord::OP_DELETE) === false) { return $this->deleteWithChildrenInternal(); } $transaction = $this->getOwner()::getDb()->beginTransaction();

Check warning on line 536 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "NotIdentical": @@ @@ public function deleteWithChildren(): bool|int { $this->operation = self::OPERATION_DELETE_WITH_CHILDREN; - if ($this->getOwner()->isTransactional(ActiveRecord::OP_DELETE) !== false) { + if ($this->getOwner()->isTransactional(ActiveRecord::OP_DELETE) === false) { return $this->deleteWithChildrenInternal(); } $transaction = $this->getOwner()::getDb()->beginTransaction();
return $this->deleteWithChildrenInternal();
}

$transaction = $this->getOwner()::getDb()->beginTransaction();

try {
match ($result = $this->deleteWithChildrenInternal()) {

Check warning on line 543 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "MatchArmRemoval": @@ @@ $transaction = $this->getOwner()::getDb()->beginTransaction(); try { match ($result = $this->deleteWithChildrenInternal()) { - false => $transaction->rollBack(), default => $transaction->commit(), }; return $result;

Check warning on line 543 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "MatchArmRemoval": @@ @@ $transaction = $this->getOwner()::getDb()->beginTransaction(); try { match ($result = $this->deleteWithChildrenInternal()) { - false => $transaction->rollBack(), default => $transaction->commit(), }; return $result;
false => $transaction->rollBack(),
default => $transaction->commit(),
};

return $result;
} catch (Exception $e) {
$transaction->rollBack();

Check warning on line 550 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ }; return $result; } catch (Exception $e) { - $transaction->rollBack(); + throw $e; } }

Check warning on line 550 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ }; return $result; } catch (Exception $e) { - $transaction->rollBack(); + throw $e; } }

throw $e;
}
Expand Down Expand Up @@ -691,7 +697,7 @@
$nodeLeft = $node->getAttribute($this->leftAttribute);
$nodeRight = $node->getAttribute($this->rightAttribute);

if ($currentLeft <= $nodeLeft || $currentRight >= $nodeRight) {

Check warning on line 700 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "GreaterThanOrEqualTo": @@ @@ $currentRight = $owner->getAttribute($this->rightAttribute); $nodeLeft = $node->getAttribute($this->leftAttribute); $nodeRight = $node->getAttribute($this->rightAttribute); - if ($currentLeft <= $nodeLeft || $currentRight >= $nodeRight) { + if ($currentLeft <= $nodeLeft || $currentRight > $nodeRight) { return false; } if ($this->treeAttribute !== false) {

Check warning on line 700 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "LessThanOrEqualTo": @@ @@ $currentRight = $owner->getAttribute($this->rightAttribute); $nodeLeft = $node->getAttribute($this->leftAttribute); $nodeRight = $node->getAttribute($this->rightAttribute); - if ($currentLeft <= $nodeLeft || $currentRight >= $nodeRight) { + if ($currentLeft < $nodeLeft || $currentRight >= $nodeRight) { return false; } if ($this->treeAttribute !== false) {

Check warning on line 700 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "GreaterThanOrEqualTo": @@ @@ $currentRight = $owner->getAttribute($this->rightAttribute); $nodeLeft = $node->getAttribute($this->leftAttribute); $nodeRight = $node->getAttribute($this->rightAttribute); - if ($currentLeft <= $nodeLeft || $currentRight >= $nodeRight) { + if ($currentLeft <= $nodeLeft || $currentRight > $nodeRight) { return false; } if ($this->treeAttribute !== false) {

Check warning on line 700 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "LessThanOrEqualTo": @@ @@ $currentRight = $owner->getAttribute($this->rightAttribute); $nodeLeft = $node->getAttribute($this->leftAttribute); $nodeRight = $node->getAttribute($this->rightAttribute); - if ($currentLeft <= $nodeLeft || $currentRight >= $nodeRight) { + if ($currentLeft < $nodeLeft || $currentRight >= $nodeRight) { return false; } if ($this->treeAttribute !== false) {
return false;
}

Expand Down Expand Up @@ -800,7 +806,7 @@

$this->applyTreeAttributeCondition($condition);

return $this->getOwner()::find()->andWhere($condition)->addOrderBy([$this->leftAttribute => SORT_ASC]);

Check warning on line 809 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ { $condition = ['and', ['>', $this->leftAttribute, $this->getOwner()->getAttribute($this->leftAttribute)], ['<', $this->rightAttribute, $this->getOwner()->getAttribute($this->rightAttribute)], [$this->rightAttribute => new Expression($this->getOwner()::getDb()->quoteColumnName($this->leftAttribute) . '+ 1')]]; $this->applyTreeAttributeCondition($condition); - return $this->getOwner()::find()->andWhere($condition)->addOrderBy([$this->leftAttribute => SORT_ASC]); + return $this->getOwner()::find()->andWhere($condition)->addOrderBy([]); } /** * Creates the root node if the active record is new, or moves it as the root node in the nested set tree.

Check warning on line 809 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ { $condition = ['and', ['>', $this->leftAttribute, $this->getOwner()->getAttribute($this->leftAttribute)], ['<', $this->rightAttribute, $this->getOwner()->getAttribute($this->rightAttribute)], [$this->rightAttribute => new Expression($this->getOwner()::getDb()->quoteColumnName($this->leftAttribute) . '+ 1')]]; $this->applyTreeAttributeCondition($condition); - return $this->getOwner()::find()->andWhere($condition)->addOrderBy([$this->leftAttribute => SORT_ASC]); + return $this->getOwner()::find()->andWhere($condition)->addOrderBy([]); } /** * Creates the root node if the active record is new, or moves it as the root node in the nested set tree.
}

/**
Expand Down Expand Up @@ -943,7 +949,7 @@

$this->applyTreeAttributeCondition($condition);

return $this->getOwner()::find()->andWhere($condition)->addOrderBy([$this->leftAttribute => SORT_ASC]);

Check warning on line 952 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ $condition[] = ['>=', $this->depthAttribute, $this->getOwner()->getAttribute($this->depthAttribute) - $depth]; } $this->applyTreeAttributeCondition($condition); - return $this->getOwner()::find()->andWhere($condition)->addOrderBy([$this->leftAttribute => SORT_ASC]); + return $this->getOwner()::find()->andWhere($condition)->addOrderBy([]); } /** * Inserts the current node as the first child of the specified target node or moves it if it already exists.

Check warning on line 952 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ $condition[] = ['>=', $this->depthAttribute, $this->getOwner()->getAttribute($this->depthAttribute) - $depth]; } $this->applyTreeAttributeCondition($condition); - return $this->getOwner()::find()->andWhere($condition)->addOrderBy([$this->leftAttribute => SORT_ASC]); + return $this->getOwner()::find()->andWhere($condition)->addOrderBy([]); } /** * Inserts the current node as the first child of the specified target node or moves it if it already exists.
}

/**
Expand Down Expand Up @@ -1152,7 +1158,7 @@

$this->applyTreeAttributeCondition($condition);
$result = $this->getOwner()::deleteAll($condition);
$this->getOwner()->setOldAttributes(null);

Check warning on line 1161 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ $condition = ['and', ['>=', $this->leftAttribute, $this->owner?->getAttribute($this->leftAttribute)], ['<=', $this->rightAttribute, $this->owner?->getAttribute($this->rightAttribute)]]; $this->applyTreeAttributeCondition($condition); $result = $this->getOwner()::deleteAll($condition); - $this->getOwner()->setOldAttributes(null); + $this->getOwner()->afterDelete(); return $result; }

Check warning on line 1161 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ $condition = ['and', ['>=', $this->leftAttribute, $this->owner?->getAttribute($this->leftAttribute)], ['<=', $this->rightAttribute, $this->owner?->getAttribute($this->rightAttribute)]]; $this->applyTreeAttributeCondition($condition); $result = $this->getOwner()::deleteAll($condition); - $this->getOwner()->setOldAttributes(null); + $this->getOwner()->afterDelete(); return $result; }
$this->getOwner()->afterDelete();

return $result;
Expand All @@ -1177,29 +1183,27 @@
* and supports both root and non-root node moves.
*
* @param ActiveRecord $node Node to be moved within the nested set tree.
* @param mixed $treeValue Tree attribute value to which the node will be moved, or `false` if not applicable.
* @param int $value Left attribute value indicating the new position for the node.
* @param int $depth Depth offset to apply to the node and its descendants after the move.
*/
protected function moveNode(ActiveRecord $node, int $value, int $depth): void
protected function moveNode(ActiveRecord $node, mixed $treeValue, int $value, int $depth): void
{
$db = $this->getOwner()::getDb();
$leftValue = $this->getOwner()->getAttribute($this->leftAttribute);
$rightValue = $this->getOwner()->getAttribute($this->rightAttribute);
$depthValue = $this->getOwner()->getAttribute($this->depthAttribute);
$depthAttribute = $db->quoteColumnName($this->depthAttribute);
$depthValue = $this->getOwner()->getAttribute($this->depthAttribute);
$leftValue = $this->getOwner()->getAttribute($this->leftAttribute);
$nodeDepthValue = $node->getAttribute($this->depthAttribute);
$rightValue = $this->getOwner()->getAttribute($this->rightAttribute);

$depth = $nodeDepthValue - $depthValue + $depth;
$depthValue = $nodeDepthValue - $depthValue + $depth;

if (
$this->treeAttribute === false ||
$this->getOwner()->getAttribute($this->treeAttribute) === $node->getAttribute($this->treeAttribute)
) {
if ($this->treeAttribute === false || $treeValue === $node->getAttribute($this->treeAttribute)) {
$delta = $rightValue - $leftValue + 1;

$this->shiftLeftRightAttribute($value, $delta);

if ($leftValue >= $value) {

Check warning on line 1206 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "GreaterThanOrEqualTo": @@ @@ if ($this->treeAttribute === false || $treeValue === $node->getAttribute($this->treeAttribute)) { $delta = $rightValue - $leftValue + 1; $this->shiftLeftRightAttribute($value, $delta); - if ($leftValue >= $value) { + if ($leftValue > $value) { $leftValue += $delta; $rightValue += $delta; }

Check warning on line 1206 in src/NestedSetsBehavior.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "GreaterThanOrEqualTo": @@ @@ if ($this->treeAttribute === false || $treeValue === $node->getAttribute($this->treeAttribute)) { $delta = $rightValue - $leftValue + 1; $this->shiftLeftRightAttribute($value, $delta); - if ($leftValue >= $value) { + if ($leftValue > $value) { $leftValue += $delta; $rightValue += $delta; }
$leftValue += $delta;
$rightValue += $delta;
}
Expand All @@ -1221,7 +1225,7 @@
$this->applyTreeAttributeCondition($condition);
$this->getOwner()::updateAll(
[
$this->depthAttribute => new Expression($depthAttribute . sprintf('%+d', $depth)),
$this->depthAttribute => new Expression($depthAttribute . sprintf('%+d', $depthValue)),
],
$condition,
);
Expand Down Expand Up @@ -1252,8 +1256,6 @@

$this->shiftLeftRightAttribute($rightValue, -$delta);
} else {
$leftAttribute = $db->quoteColumnName($this->leftAttribute);
$rightAttribute = $db->quoteColumnName($this->rightAttribute);
$nodeRootValue = $node->getAttribute($this->treeAttribute);

foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) {
Expand All @@ -1277,31 +1279,13 @@
);
}

$delta = $value - $leftValue;

$this->getOwner()::updateAll(
[
$this->leftAttribute => new Expression($leftAttribute . sprintf('%+d', $delta)),
$this->rightAttribute => new Expression($rightAttribute . sprintf('%+d', $delta)),
$this->depthAttribute => new Expression($depthAttribute . sprintf('%+d', $depth)),
$this->treeAttribute => $nodeRootValue,
],
[
'and',
[
'>=',
$this->leftAttribute,
$leftValue,
],
[
'<=',
$this->rightAttribute,
$rightValue,
],
[
$this->treeAttribute => $this->getOwner()->getAttribute($this->treeAttribute),
],
],
$this->moveSubtreeToTargetTree(
$nodeRootValue,
$treeValue,
$depthValue,
$leftValue,
$value - $leftValue,
$rightValue,
);
$this->shiftLeftRightAttribute($rightValue, $leftValue - $rightValue - 1);
}
Expand All @@ -1324,40 +1308,23 @@
* - Shifts left and right boundaries of the node and its descendants to start from 1.
* - Shifts left/right values of remaining nodes to close the gap left by the moved subtree.
* - Updates the tree attribute to the new root identifier if multi-tree is enabled.
*
* @param mixed $treeValue Tree attribute value to which the node will be moved, or `false` if not applicable.
*/
protected function moveNodeAsRoot(): void
protected function moveNodeAsRoot(mixed $treeValue): void
{
$db = $this->getOwner()::getDb();
$depthValue = $this->getOwner()->getAttribute($this->depthAttribute);
$leftValue = $this->getOwner()->getAttribute($this->leftAttribute);
$nodeRootValue = $this->getOwner()->getPrimaryKey();
$rightValue = $this->getOwner()->getAttribute($this->rightAttribute);
$depthValue = $this->getOwner()->getAttribute($this->depthAttribute);
$treeValue = $this->treeAttribute !== false ? $this->getOwner()->getAttribute($this->treeAttribute) : null;
$leftAttribute = $db->quoteColumnName($this->leftAttribute);
$rightAttribute = $db->quoteColumnName($this->rightAttribute);
$depthAttribute = $db->quoteColumnName($this->depthAttribute);
$this->getOwner()::updateAll(
[
$this->leftAttribute => new Expression($leftAttribute . sprintf('%+d', 1 - $leftValue)),
$this->rightAttribute => new Expression($rightAttribute . sprintf('%+d', 1 - $leftValue)),
$this->depthAttribute => new Expression($depthAttribute . sprintf('%+d', -$depthValue)),
$this->treeAttribute => $this->getOwner()->getPrimaryKey(),
],
[
'and',
[
'>=',
$this->leftAttribute,
$leftValue,
],
[
'<=',
$this->rightAttribute,
$rightValue,
],
[
$this->treeAttribute => $treeValue,
],
],

$this->moveSubtreeToTargetTree(
$nodeRootValue,
$treeValue,
-$depthValue,
$leftValue,
1 - $leftValue,
$rightValue,
);
$this->shiftLeftRightAttribute($rightValue, $leftValue - $rightValue - 1);
}
Expand Down Expand Up @@ -1394,6 +1361,22 @@
}
}

/**
* Retrieves and caches the {@see Connection} object associated with the owner model.
*
* The connection is resolved on first access and stored for subsequent calls to improve performance and avoid
* redundant lookups.
*
* This method is used internally by operations that require direct database access, such as bulk updates or
* structural modifications to the nested set tree.
*
* @return Connection Database connection instance for the owner model.
*/
private function getDb(): Connection
{
return $this->db ??= $this->getOwner()::getDb();
}

/**
* Returns the {@see ActiveRecord} instance to which this behavior is currently attached.
*
Expand All @@ -1417,4 +1400,63 @@

return $this->owner;
}

/**
* Moves a subtree to a different tree or position within a multi-tree nested set structure.
*
* Updates the left, right, and depth attributes, as well as the tree attribute, for all nodes in the specified
* subtree.
*
* This method is used internally when moving a node and its descendants across trees or to a new root, ensuring
* that all affected nodes are updated in a single bulk operation.
*
* This operation is essential for maintaining the integrity of the nested set structure when reorganizing nodes
* between trees or promoting a node to root in a multi-tree configuration.
*
* @param mixed $newTreeValue Value to assign to the tree attribute for all nodes in the moved subtree.
* @param mixed $currentTreeValue Current tree attribute value of the nodes being moved.
* @param int $depth Depth offset to apply to all nodes in the subtree.
* @param int $leftValue Left boundary value of the subtree to move.
* @param int $positionOffset Amount to shift left and right attribute values for the subtree.
* @param int $rightValue Right boundary value of the subtree to move.
*/
private function moveSubtreeToTargetTree(
mixed $newTreeValue,
mixed $currentTreeValue,
int $depth,
int $leftValue,
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 => $newTreeValue,
],
[
'and',
[
'>=',
$this->leftAttribute,
$leftValue,
],
[
'<=',
$this->rightAttribute,
$rightValue,
],
[
$this->treeAttribute => $currentTreeValue,
],
],
);
}
}
4 changes: 2 additions & 2 deletions tests/support/stub/ExtendableNestedSetsBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,14 @@ public function exposedMoveNode(ActiveRecord $node, int $value, int $depth): voi
{
$this->calledMethods['moveNode'] = true;

$this->moveNode($node, $value, $depth);
$this->moveNode($node, null, $value, $depth);
}

public function exposedMoveNodeAsRoot(): void
{
$this->calledMethods['moveNodeAsRoot'] = true;

$this->moveNodeAsRoot();
$this->moveNodeAsRoot(null);
}

public function exposedShiftLeftRightAttribute(int $value, int $delta): void
Expand Down