1
0
Fork 0
mirror of https://github.com/Oreolek/yii2-nested-sets.git synced 2024-05-01 16:39:20 +03:00
yii2-nested-sets/src/NestedSetsBehavior.php
Alexander Yakovlev a5ed820083 Merge commit 'b22faa8' from maz0717
Conflicts:
	src/NestedSetsBehavior.php
2016-02-15 13:36:40 +07:00

721 lines
25 KiB
PHP

<?php
/**
* @link https://github.com/creocoder/yii2-nested-sets
* @license http://opensource.org/licenses/BSD-3-Clause
*/
namespace oreolek\nestedsets;
use yii\base\Behavior;
use yii\base\NotSupportedException;
use yii\db\ActiveRecord;
use yii\db\AfterSaveEvent;
use yii\db\Exception;
use yii\db\Expression;
/**
* NestedSetsBehavior
*
* @property ActiveRecord $owner
*/
class NestedSetsBehavior extends Behavior
{
const OPERATION_MAKE_ROOT = 'makeRoot';
const OPERATION_PREPEND_TO = 'prependTo';
const OPERATION_APPEND_TO = 'appendTo';
const OPERATION_INSERT_BEFORE = 'insertBefore';
const OPERATION_INSERT_AFTER = 'insertAfter';
const OPERATION_DELETE_WITH_CHILDREN = 'deleteWithChildren';
/**
* @var string|false
*/
public $treeAttribute = false;
/**
* @var boolean
*/
public $treeAttributeById = true;
/**
* @var string
*/
public $leftAttribute = 'lft';
/**
* @var string
*/
public $rightAttribute = 'rgt';
/**
* @var string
*/
public $depthAttribute = 'depth';
/**
* @var string|null
*/
protected $operation;
/**
* @var ActiveRecord|null
*/
protected $node;
/**
* @inheritdoc
*/
public function events()
{
return [
ActiveRecord::EVENT_BEFORE_INSERT => 'beforeInsert',
ActiveRecord::EVENT_AFTER_INSERT => 'afterInsert',
ActiveRecord::EVENT_BEFORE_UPDATE => 'beforeUpdate',
ActiveRecord::EVENT_AFTER_UPDATE => 'afterUpdate',
ActiveRecord::EVENT_BEFORE_DELETE => 'beforeDelete',
ActiveRecord::EVENT_AFTER_DELETE => 'afterDelete',
];
}
/**
* Creates the root node if the active record is new or moves it
* as the root node.
* @param boolean $runValidation
* @param array $attributes
* @return boolean
*/
public function makeRoot($runValidation = true, $attributes = null)
{
$this->operation = self::OPERATION_MAKE_ROOT;
return $this->owner->save($runValidation, $attributes);
}
/**
* Creates a node as the first child of the target node if the active
* record is new or moves it as the first child of the target node.
* @param ActiveRecord $node
* @param boolean $runValidation
* @param array $attributes
* @return boolean
*/
public function prependTo($node, $runValidation = true, $attributes = null)
{
$this->operation = self::OPERATION_PREPEND_TO;
$this->node = $node;
return $this->owner->save($runValidation, $attributes);
}
/**
* Creates a node as the last child of the target node if the active
* record is new or moves it as the last child of the target node.
* @param ActiveRecord $node
* @param boolean $runValidation
* @param array $attributes
* @return boolean
*/
public function appendTo($node, $runValidation = true, $attributes = null)
{
$this->operation = self::OPERATION_APPEND_TO;
$this->node = $node;
return $this->owner->save($runValidation, $attributes);
}
/**
* Creates a node as the previous sibling of the target node if the active
* record is new or moves it as the previous sibling of the target node.
* @param ActiveRecord $node
* @param boolean $runValidation
* @param array $attributes
* @return boolean
*/
public function insertBefore($node, $runValidation = true, $attributes = null)
{
$this->operation = self::OPERATION_INSERT_BEFORE;
$this->node = $node;
return $this->owner->save($runValidation, $attributes);
}
/**
* Creates a node as the next sibling of the target node if the active
* record is new or moves it as the next sibling of the target node.
* @param ActiveRecord $node
* @param boolean $runValidation
* @param array $attributes
* @return boolean
*/
public function insertAfter($node, $runValidation = true, $attributes = null)
{
$this->operation = self::OPERATION_INSERT_AFTER;
$this->node = $node;
return $this->owner->save($runValidation, $attributes);
}
/**
* Deletes a node and its children.
* @return integer|false the number of rows deleted or false if
* the deletion is unsuccessful for some reason.
* @throws \Exception
*/
public function deleteWithChildren()
{
$this->operation = self::OPERATION_DELETE_WITH_CHILDREN;
if (!$this->owner->isTransactional(ActiveRecord::OP_DELETE)) {
return $this->deleteWithChildrenInternal();
}
$transaction = $this->owner->getDb()->beginTransaction();
try {
$result = $this->deleteWithChildrenInternal();
if ($result === false) {
$transaction->rollBack();
} else {
$transaction->commit();
}
return $result;
} catch (\Exception $e) {
$transaction->rollBack();
throw $e;
}
}
/**
* @return integer|false the number of rows deleted or false if
* the deletion is unsuccessful for some reason.
*/
protected function deleteWithChildrenInternal()
{
if (!$this->owner->beforeDelete()) {
return false;
}
$condition = [
'and',
['>=', $this->leftAttribute, $this->owner->getAttribute($this->leftAttribute)],
['<=', $this->rightAttribute, $this->owner->getAttribute($this->rightAttribute)]
];
$this->applyTreeAttributeCondition($condition);
$result = $this->owner->deleteAll($condition);
$this->owner->setOldAttributes(null);
$this->owner->afterDelete();
return $result;
}
/**
* Gets the parents of the node.
* @param integer|null $depth the depth
* @return \yii\db\ActiveQuery
*/
public function parents($depth = null)
{
$condition = [
'and',
['<', $this->leftAttribute, $this->owner->getAttribute($this->leftAttribute)],
['>', $this->rightAttribute, $this->owner->getAttribute($this->rightAttribute)],
];
if ($depth !== null) {
$condition[] = ['>=', $this->depthAttribute, $this->owner->getAttribute($this->depthAttribute) - $depth];
}
$this->applyTreeAttributeCondition($condition);
return $this->owner->find()->andWhere($condition)->addOrderBy([$this->leftAttribute => SORT_ASC]);
}
/**
* Gets the children of the node.
* @param integer|null $depth the depth
* @return \yii\db\ActiveQuery
*/
public function children($depth = null)
{
$condition = [
'and',
['>', $this->leftAttribute, $this->owner->getAttribute($this->leftAttribute)],
['<', $this->rightAttribute, $this->owner->getAttribute($this->rightAttribute)],
];
if ($depth !== null) {
$condition[] = ['<=', $this->depthAttribute, $this->owner->getAttribute($this->depthAttribute) + $depth];
}
$this->applyTreeAttributeCondition($condition);
return $this->owner->find()->andWhere($condition)->addOrderBy([$this->leftAttribute => SORT_ASC]);
}
public function subtree($depth = null)
{
$condition = [
'and',
['>=', $this->leftAttribute, $this->owner->getAttribute($this->leftAttribute)],
['<=', $this->rightAttribute, $this->owner->getAttribute($this->rightAttribute)],
];
if ($depth !== null) {
$condition[] = ['<=', $this->depthAttribute, $this->owner->getAttribute($this->depthAttribute) + $depth];
}
$this->applyTreeAttributeCondition($condition);
return $this->owner->find()->andWhere($condition)->addOrderBy([$this->leftAttribute => SORT_ASC]);
}
/**
* Gets the leaves of the node.
* @return \yii\db\ActiveQuery
*/
public function leaves()
{
$condition = [
'and',
['>', $this->leftAttribute, $this->owner->getAttribute($this->leftAttribute)],
['<', $this->rightAttribute, $this->owner->getAttribute($this->rightAttribute)],
[$this->rightAttribute => new Expression($this->owner->getDb()->quoteColumnName($this->leftAttribute) . '+ 1')],
];
$this->applyTreeAttributeCondition($condition);
return $this->owner->find()->andWhere($condition)->addOrderBy([$this->leftAttribute => SORT_ASC]);
}
/**
* Gets the previous sibling of the node.
* @return \yii\db\ActiveQuery
*/
public function prev()
{
$condition = [$this->rightAttribute => $this->owner->getAttribute($this->leftAttribute) - 1];
$this->applyTreeAttributeCondition($condition);
return $this->owner->find()->andWhere($condition);
}
/**
* Gets the next sibling of the node.
* @return \yii\db\ActiveQuery
*/
public function next()
{
$condition = [$this->leftAttribute => $this->owner->getAttribute($this->rightAttribute) + 1];
$this->applyTreeAttributeCondition($condition);
return $this->owner->find()->andWhere($condition);
}
/**
* Determines whether the node is root.
* @return boolean whether the node is root
*/
public function isRoot()
{
return $this->owner->getAttribute($this->leftAttribute) == 1;
}
/**
* Determines whether the node is child of the parent node.
* @param ActiveRecord $node the parent node
* @return boolean whether the node is child of the parent node
*/
public function isChildOf($node)
{
$result = $this->owner->getAttribute($this->leftAttribute) > $node->getAttribute($this->leftAttribute)
&& $this->owner->getAttribute($this->rightAttribute) < $node->getAttribute($this->rightAttribute);
if ($result && $this->treeAttribute !== false) {
$result = $this->owner->getAttribute($this->treeAttribute) === $node->getAttribute($this->treeAttribute);
}
return $result;
}
/**
* Determines whether the node is leaf.
* @return boolean whether the node is leaf
*/
public function isLeaf()
{
return $this->owner->getAttribute($this->rightAttribute) - $this->owner->getAttribute($this->leftAttribute) === 1;
}
/**
* @throws NotSupportedException
*/
public function beforeInsert()
{
if ($this->node !== null && !$this->node->getIsNewRecord()) {
$this->node->refresh();
}
switch ($this->operation) {
case self::OPERATION_MAKE_ROOT:
$this->beforeInsertRootNode();
break;
case self::OPERATION_PREPEND_TO:
$this->beforeInsertNode($this->node->getAttribute($this->leftAttribute) + 1, 1);
break;
case self::OPERATION_APPEND_TO:
$this->beforeInsertNode($this->node->getAttribute($this->rightAttribute), 1);
break;
case self::OPERATION_INSERT_BEFORE:
$this->beforeInsertNode($this->node->getAttribute($this->leftAttribute), 0);
break;
case self::OPERATION_INSERT_AFTER:
$this->beforeInsertNode($this->node->getAttribute($this->rightAttribute) + 1, 0);
break;
default:
throw new NotSupportedException('Method "'. get_class($this->owner) . '::insert" is not supported for inserting new nodes.');
}
}
/**
* @throws Exception
*/
protected function beforeInsertRootNode()
{
if ($this->treeAttribute === false && $this->owner->find()->roots()->exists()) {
throw new Exception('Can not create more than one root when "treeAttribute" is false.');
}
if ($this->treeAttribute !== false && !$this->treeAttributeById && $this->owner->getAttribute($this->treeAttribute) === null) {
throw new Exception("Tree attribute '{$this->treeAttribute}' must be set for root record.");
}
$this->owner->setAttribute($this->leftAttribute, 1);
$this->owner->setAttribute($this->rightAttribute, 2);
$this->owner->setAttribute($this->depthAttribute, 0);
}
/**
* @param integer $value
* @param integer $depth
* @throws Exception
*/
protected function beforeInsertNode($value, $depth)
{
if ($this->node->getIsNewRecord()) {
throw new Exception('Can not create a node when the target node is new record.');
}
if ($depth === 0 && $this->node->isRoot()) {
throw new Exception('Can not create a node when the target node is root.');
}
$this->owner->setAttribute($this->leftAttribute, $value);
$this->owner->setAttribute($this->rightAttribute, $value + 1);
$this->owner->setAttribute($this->depthAttribute, $this->node->getAttribute($this->depthAttribute) + $depth);
if ($this->treeAttribute !== false) {
$this->owner->setAttribute($this->treeAttribute, $this->node->getAttribute($this->treeAttribute));
}
$this->shiftLeftRightAttribute($value, 2);
}
/**
* @throws Exception
*/
public function afterInsert()
{
if ($this->operation === self::OPERATION_MAKE_ROOT && $this->treeAttribute !== false && $this->treeAttributeById) {
$this->owner->setAttribute($this->treeAttribute, $this->owner->getPrimaryKey());
$primaryKey = $this->owner->primaryKey();
if (!isset($primaryKey[0])) {
throw new Exception('"' . get_class($this->owner) . '" must have a primary key.');
}
$this->owner->updateAll(
[$this->treeAttribute => $this->owner->getAttribute($this->treeAttribute)],
[$primaryKey[0] => $this->owner->getAttribute($this->treeAttribute)]
);
}
$this->operation = null;
$this->node = null;
}
/**
* @throws Exception
*/
public function beforeUpdate()
{
if ($this->node !== null && !$this->node->getIsNewRecord()) {
$this->node->refresh();
}
switch ($this->operation) {
case self::OPERATION_MAKE_ROOT:
if ($this->treeAttribute === false) {
throw new Exception('Can not move a node as the root when "treeAttribute" is false.');
}
if ($this->owner->isRoot()) {
throw new Exception('Can not move the root node as the root.');
}
if (!$this->treeAttributeById) {
if ($this->owner->getOldAttribute($this->treeAttribute) === $this->owner->getAttribute($this->treeAttribute)) {
throw new Exception('Can not move a node as the root when its tree attribute "'. $this->treeAttribute . '" is not changed.');
}
if ($this->owner->find()->andWhere([
$this->treeAttribute => $this->owner->getAttribute($this->treeAttribute),
$this->leftAttribute => 1,
])->one()) {
throw new Exception('Can not move a node as the root when another root with this "'. $this->treeAttribute . '" already exists.');
}
}
break;
case self::OPERATION_INSERT_BEFORE:
case self::OPERATION_INSERT_AFTER:
if ($this->node->isRoot()) {
throw new Exception('Can not move a node when the target node is root.');
}
case self::OPERATION_PREPEND_TO:
case self::OPERATION_APPEND_TO:
if ($this->node->getIsNewRecord()) {
throw new Exception('Can not move a node when the target node is new record.');
}
if ($this->owner->equals($this->node)) {
throw new Exception('Can not move a node when the target node is same.');
}
if ($this->node->isChildOf($this->owner)) {
throw new Exception('Can not move a node when the target node is child.');
}
}
}
/**
* @param AfterSaveEvent $event
* @return void
*/
public function afterUpdate($event)
{
switch ($this->operation) {
case self::OPERATION_MAKE_ROOT:
$this->moveNodeAsRoot($event);
break;
case self::OPERATION_PREPEND_TO:
$this->moveNode($this->node->getAttribute($this->leftAttribute) + 1, 1);
break;
case self::OPERATION_APPEND_TO:
$this->moveNode($this->node->getAttribute($this->rightAttribute), 1);
break;
case self::OPERATION_INSERT_BEFORE:
$this->moveNode($this->node->getAttribute($this->leftAttribute), 0);
break;
case self::OPERATION_INSERT_AFTER:
$this->moveNode($this->node->getAttribute($this->rightAttribute) + 1, 0);
break;
default:
return;
}
$this->operation = null;
$this->node = null;
}
/**
* @param AfterSaveEvent $event
* @return void
*/
public function moveNodeAsRoot($event)
{
$db = $this->owner->getDb();
$leftValue = $this->owner->getAttribute($this->leftAttribute);
$rightValue = $this->owner->getAttribute($this->rightAttribute);
$depthValue = $this->owner->getAttribute($this->depthAttribute);
$treeValue = $this->treeAttributeById ? $this->owner->getAttribute($this->treeAttribute) : $event->changedAttributes[$this->treeAttribute];
$leftAttribute = $db->quoteColumnName($this->leftAttribute);
$rightAttribute = $db->quoteColumnName($this->rightAttribute);
$depthAttribute = $db->quoteColumnName($this->depthAttribute);
$this->owner->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->treeAttributeById ? $this->owner->getPrimaryKey() : $this->owner->getAttribute($this->treeAttribute),
],
[
'and',
['>=', $this->leftAttribute, $leftValue],
['<=', $this->rightAttribute, $rightValue],
[$this->treeAttribute => $treeValue]
]
);
$this->shiftLeftRightAttribute($rightValue + 1, $leftValue - $rightValue - 1);
}
/**
* @param integer $value
* @param integer $depth
*/
public function moveNode($value, $depth)
{
$db = $this->owner->getDb();
$leftValue = $this->owner->getAttribute($this->leftAttribute);
$rightValue = $this->owner->getAttribute($this->rightAttribute);
$depthValue = $this->owner->getAttribute($this->depthAttribute);
$depthAttribute = $db->quoteColumnName($this->depthAttribute);
$depth = $this->node->getAttribute($this->depthAttribute) - $depthValue + $depth;
if ($this->treeAttribute === false
|| $this->owner->getAttribute($this->treeAttribute) === $this->node->getAttribute($this->treeAttribute)) {
$delta = $rightValue - $leftValue + 1;
$this->shiftLeftRightAttribute($value, $delta);
if ($leftValue >= $value) {
$leftValue += $delta;
$rightValue += $delta;
}
$condition = ['and', ['>=', $this->leftAttribute, $leftValue], ['<=', $this->rightAttribute, $rightValue]];
$this->applyTreeAttributeCondition($condition);
$this->owner->updateAll(
[$this->depthAttribute => new Expression($depthAttribute . sprintf('%+d', $depth))],
$condition
);
foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) {
$condition = ['and', ['>=', $attribute, $leftValue], ['<=', $attribute, $rightValue]];
$this->applyTreeAttributeCondition($condition);
$this->owner->updateAll(
[$attribute => new Expression($db->quoteColumnName($attribute) . sprintf('%+d', $value - $leftValue))],
$condition
);
}
$this->shiftLeftRightAttribute($rightValue + 1, -$delta);
} else {
$leftAttribute = $db->quoteColumnName($this->leftAttribute);
$rightAttribute = $db->quoteColumnName($this->rightAttribute);
$nodeRootValue = $this->node->getAttribute($this->treeAttribute);
foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) {
$this->owner->updateAll(
[$attribute => new Expression($db->quoteColumnName($attribute) . sprintf('%+d', $rightValue - $leftValue + 1))],
['and', ['>=', $attribute, $value], [$this->treeAttribute => $nodeRootValue]]
);
}
$delta = $value - $leftValue;
$this->owner->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->owner->getAttribute($this->treeAttribute)],
]
);
$this->shiftLeftRightAttribute($rightValue + 1, $leftValue - $rightValue - 1);
}
}
/**
* @throws Exception
* @throws NotSupportedException
*/
public function beforeDelete()
{
if ($this->owner->getIsNewRecord()) {
throw new Exception('Can not delete a node when it is new record.');
}
if ($this->owner->isRoot() && $this->operation !== self::OPERATION_DELETE_WITH_CHILDREN) {
throw new NotSupportedException('Method "'. get_class($this->owner) . '::delete" is not supported for deleting root nodes.');
}
$this->owner->refresh();
}
/**
* @return void
*/
public function afterDelete()
{
$leftValue = $this->owner->getAttribute($this->leftAttribute);
$rightValue = $this->owner->getAttribute($this->rightAttribute);
if ($this->owner->isLeaf() || $this->operation === self::OPERATION_DELETE_WITH_CHILDREN) {
$this->shiftLeftRightAttribute($rightValue + 1, $leftValue - $rightValue - 1);
} else {
$condition = [
'and',
['>=', $this->leftAttribute, $this->owner->getAttribute($this->leftAttribute)],
['<=', $this->rightAttribute, $this->owner->getAttribute($this->rightAttribute)]
];
$this->applyTreeAttributeCondition($condition);
$db = $this->owner->getDb();
$this->owner->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 + 1, -2);
}
$this->operation = null;
$this->node = null;
}
/**
* @param integer $value
* @param integer $delta
*/
protected function shiftLeftRightAttribute($value, $delta)
{
$db = $this->owner->getDb();
foreach ([$this->leftAttribute, $this->rightAttribute] as $attribute) {
$condition = ['>=', $attribute, $value];
$this->applyTreeAttributeCondition($condition);
$this->owner->updateAll(
[$attribute => new Expression($db->quoteColumnName($attribute) . sprintf('%+d', $delta))],
$condition
);
}
}
/**
* @param array $condition
*/
protected function applyTreeAttributeCondition(&$condition)
{
if ($this->treeAttribute !== false) {
$condition = [
'and',
$condition,
[$this->treeAttribute => $this->owner->getAttribute($this->treeAttribute)]
];
}
}
}