<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace Cake\ORM\Behavior;
use Cake\Database\Expression\IdentifierExpression;
use Cake\Datasource\EntityInterface;
use Cake\Datasource\Exception\RecordNotFoundException;
use Cake\Event\Event;
use Cake\ORM\Behavior;
use Cake\ORM\Query;
use InvalidArgumentException;
use RuntimeException;
/**
* Makes the table to which this is attached to behave like a nested set and
* provides methods for managing and retrieving information out of the derived
* hierarchical structure.
*
* Tables attaching this behavior are required to have a column referencing the
* parent row, and two other numeric columns (lft and rght) where the implicit
* order will be cached.
*
* For more information on what is a nested set and a how it works refer to
* https://www.sitepoint.com/hierarchical-data-database-2/
*/
class TreeBehavior extends Behavior
{
/**
* Cached copy of the first column in a table's primary key.
*
* @var string
*/
protected $_primaryKey;
/**
* Default config
*
* These are merged with user-provided configuration when the behavior is used.
*
* @var array
*/
protected $_defaultConfig = [
'implementedFinders' => [
'path' => 'findPath',
'children' => 'findChildren',
'treeList' => 'findTreeList',
],
'implementedMethods' => [
'childCount' => 'childCount',
'moveUp' => 'moveUp',
'moveDown' => 'moveDown',
'recover' => 'recover',
'removeFromTree' => 'removeFromTree',
'getLevel' => 'getLevel',
'formatTreeList' => 'formatTreeList',
],
'parent' => 'parent_id',
'left' => 'lft',
'right' => 'rght',
'scope' => null,
'level' => null,
'recoverOrder' => null,
];
/**
* {@inheritDoc}
*/
public function initialize(array $config)
{
$this->_config['leftField'] = new IdentifierExpression($this->_config['left']);
$this->_config['rightField'] = new IdentifierExpression($this->_config['right']);
}
/**
* Before save listener.
* Transparently manages setting the lft and rght fields if the parent field is
* included in the parameters to be saved.
*
* @param \Cake\Event\Event $event The beforeSave event that was fired
* @param \Cake\Datasource\EntityInterface $entity the entity that is going to be saved
* @return void
* @throws \RuntimeException if the parent to set for the node is invalid
*/
public function beforeSave(Event $event, EntityInterface $entity)
{
$isNew = $entity->isNew();
$config = $this->getConfig();
$parent = $entity->get($config['parent']);
$primaryKey = $this->_getPrimaryKey();
$dirty = $entity->isDirty($config['parent']);
$level = $config['level'];
if ($parent && $entity->get($primaryKey) == $parent) {
throw new RuntimeException("Cannot set a node's parent as itself");
}
if ($isNew && $parent) {
$parentNode = $this->_getNode($parent);
$edge = $parentNode->get($config['right']);
$entity->set($config['left'], $edge);
$entity->set($config['right'], $edge + 1);
$this->_sync(2, '+', ">= {$edge}");
if ($level) {
$entity->set($level, $parentNode[$level] + 1);
}
return;
}
if ($isNew && !$parent) {
$edge = $this->_getMax();
$entity->set($config['left'], $edge + 1);
$entity->set($config['right'], $edge + 2);
if ($level) {
$entity->set($level, 0);
}
return;
}
if (!$isNew && $dirty && $parent) {
$this->_setParent($entity, $parent);
if ($level) {
$parentNode = $this->_getNode($parent);
$entity->set($level, $parentNode[$level] + 1);
}
return;
}
if (!$isNew && $dirty && !$parent) {
$this->_setAsRoot($entity);
if ($level) {
$entity->set($level, 0);
}
}
}
/**
* After save listener.
*
* Manages updating level of descendants of currently saved entity.
*
* @param \Cake\Event\Event $event The afterSave event that was fired
* @param \Cake\Datasource\EntityInterface $entity the entity that is going to be saved
* @return void
*/
public function afterSave(Event $event, EntityInterface $entity)
{
if (!$this->_config['level'] || $entity->isNew()) {
return;
}
$this->_setChildrenLevel($entity);
}
/**
* Set level for descendants.
*
* @param \Cake\Datasource\EntityInterface $entity The entity whose descendants need to be updated.
* @return void
*/
protected function _setChildrenLevel($entity)
{
$config = $this->getConfig();
if ($entity->get($config['left']) + 1 === $entity->get($config['right'])) {
return;
}
$primaryKey = $this->_getPrimaryKey();
$primaryKeyValue = $entity->get($primaryKey);
$depths = [$primaryKeyValue => $entity->get($config['level'])];
$children = $this->_table->find('children', [
'for' => $primaryKeyValue,
'fields' => [$this->_getPrimaryKey(), $config['parent'], $config['level']],
'order' => $config['left'],
]);
/** @var \Cake\Datasource\EntityInterface $node */
foreach ($children as $node) {
$parentIdValue = $node->get($config['parent']);
$depth = $depths[$parentIdValue] + 1;
$depths[$node->get($primaryKey)] = $depth;
$this->_table->updateAll(
[$config['level'] => $depth],
[$primaryKey => $node->get($primaryKey)]
);
}
}
/**
* Also deletes the nodes in the subtree of the entity to be delete
*
* @param \Cake\Event\Event $event The beforeDelete event that was fired
* @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
* @return void
*/
public function beforeDelete(Event $event, EntityInterface $entity)
{
$config = $this->getConfig();
$this->_ensureFields($entity);
$left = $entity->get($config['left']);
$right = $entity->get($config['right']);
$diff = $right - $left + 1;
if ($diff > 2) {
$query = $this->_scope($this->_table->query())
->delete()
->where(function ($exp) use ($config, $left, $right) {
/** @var \Cake\Database\Expression\QueryExpression $exp */
return $exp
->gte($config['leftField'], $left + 1)
->lte($config['leftField'], $right - 1);
});
$statement = $query->execute();
$statement->closeCursor();
}
$this->_sync($diff, '-', "> {$right}");
}
/**
* Sets the correct left and right values for the passed entity so it can be
* updated to a new parent. It also makes the hole in the tree so the node
* move can be done without corrupting the structure.
*
* @param \Cake\Datasource\EntityInterface $entity The entity to re-parent
* @param mixed $parent the id of the parent to set
* @return void
* @throws \RuntimeException if the parent to set to the entity is not valid
*/
protected function _setParent($entity, $parent)
{
$config = $this->getConfig();
$parentNode = $this->_getNode($parent);
$this->_ensureFields($entity);
$parentLeft = $parentNode->get($config['left']);
$parentRight = $parentNode->get($config['right']);
$right = $entity->get($config['right']);
$left = $entity->get($config['left']);
if ($parentLeft > $left && $parentLeft < $right) {
throw new RuntimeException(sprintf(
'Cannot use node "%s" as parent for entity "%s"',
$parent,
$entity->get($this->_getPrimaryKey())
));
}
// Values for moving to the left
$diff = $right - $left + 1;
$targetLeft = $parentRight;
$targetRight = $diff + $parentRight - 1;
$min = $parentRight;
$max = $left - 1;
if ($left < $targetLeft) {
// Moving to the right
$targetLeft = $parentRight - $diff;
$targetRight = $parentRight - 1;
$min = $right + 1;
$max = $parentRight - 1;
$diff *= -1;
}
if ($right - $left > 1) {
// Correcting internal subtree
$internalLeft = $left + 1;
$internalRight = $right - 1;
$this->_sync($targetLeft - $left, '+', "BETWEEN {$internalLeft} AND {$internalRight}", true);
}
$this->_sync($diff, '+', "BETWEEN {$min} AND {$max}");
if ($right - $left > 1) {
$this->_unmarkInternalTree();
}
// Allocating new position
$entity->set($config['left'], $targetLeft);
$entity->set($config['right'], $targetRight);
}
/**
* Updates the left and right column for the passed entity so it can be set as
* a new root in the tree. It also modifies the ordering in the rest of the tree
* so the structure remains valid
*
* @param \Cake\Datasource\EntityInterface $entity The entity to set as a new root
* @return void
*/
protected function _setAsRoot($entity)
{
$config = $this->getConfig();
$edge = $this->_getMax();
$this->_ensureFields($entity);
$right = $entity->get($config['right']);
$left = $entity->get($config['left']);
$diff = $right - $left;
if ($right - $left > 1) {
//Correcting internal subtree
$internalLeft = $left + 1;
$internalRight = $right - 1;
$this->_sync($edge - $diff - $left, '+', "BETWEEN {$internalLeft} AND {$internalRight}", true);
}
$this->_sync($diff + 1, '-', "BETWEEN {$right} AND {$edge}");
if ($right - $left > 1) {
$this->_unmarkInternalTree();
}
$entity->set($config['left'], $edge - $diff);
$entity->set($config['right'], $edge);
}
/**
* Helper method used to invert the sign of the left and right columns that are
* less than 0. They were set to negative values before so their absolute value
* wouldn't change while performing other tree transformations.
*
* @return void
*/
protected function _unmarkInternalTree()
{
$config = $this->getConfig();
$this->_table->updateAll(
function ($exp) use ($config) {
/** @var \Cake\Database\Expression\QueryExpression $exp */
$leftInverse = clone $exp;
$leftInverse->setConjunction('*')->add('-1');
$rightInverse = clone $leftInverse;
return $exp
->eq($config['leftField'], $leftInverse->add($config['leftField']))
->eq($config['rightField'], $rightInverse->add($config['rightField']));
},
function ($exp) use ($config) {
/** @var \Cake\Database\Expression\QueryExpression $exp */
return $exp->lt($config['leftField'], 0);
}
);
}
/**
* Custom finder method which can be used to return the list of nodes from the root
* to a specific node in the tree. This custom finder requires that the key 'for'
* is passed in the options containing the id of the node to get its path for.
*
* @param \Cake\ORM\Query $query The constructed query to modify
* @param array $options the list of options for the query
* @return \Cake\ORM\Query
* @throws \InvalidArgumentException If the 'for' key is missing in options
*/
public function findPath(Query $query, array $options)
{
if (empty($options['for'])) {
throw new InvalidArgumentException("The 'for' key is required for find('path')");
}
$config = $this->getConfig();
list($left, $right) = array_map(
function ($field) {
return $this->_table->aliasField($field);
},
[$config['left'], $config['right']]
);
$node = $this->_table->get($options['for'], ['fields' => [$left, $right]]);
return $this->_scope($query)
->where([
"$left <=" => $node->get($config['left']),
"$right >=" => $node->get($config['right']),
])
->order([$left => 'ASC']);
}
/**
* Get the number of children nodes.
*
* @param \Cake\Datasource\EntityInterface $node The entity to count children for
* @param bool $direct whether to count all nodes in the subtree or just
* direct children
* @return int Number of children nodes.
*/
public function childCount(EntityInterface $node, $direct = false)
{
$config = $this->getConfig();
$parent = $this->_table->aliasField($config['parent']);
if ($direct) {
return $this->_scope($this->_table->find())
->where([$parent => $node->get($this->_getPrimaryKey())])
->count();
}
$this->_ensureFields($node);
return ($node->get($config['right']) - $node->get($config['left']) - 1) / 2;
}
/**
* Get the children nodes of the current model
*
* Available options are:
*
* - for: The id of the record to read.
* - direct: Boolean, whether to return only the direct (true), or all (false) children,
* defaults to false (all children).
*
* If the direct option is set to true, only the direct children are returned (based upon the parent_id field)
*
* @param \Cake\ORM\Query $query Query.
* @param array $options Array of options as described above
* @return \Cake\ORM\Query
* @throws \InvalidArgumentException When the 'for' key is not passed in $options
*/
public function findChildren(Query $query, array $options)
{
$config = $this->getConfig();
$options += ['for' => null, 'direct' => false];
list($parent, $left, $right) = array_map(
function ($field) {
return $this->_table->aliasField($field);
},
[$config['parent'], $config['left'], $config['right']]
);
list($for, $direct) = [$options['for'], $options['direct']];
if (empty($for)) {
throw new InvalidArgumentException("The 'for' key is required for find('children')");
}
if ($query->clause('order') === null) {
$query->order([$left => 'ASC']);
}
if ($direct) {
return $this->_scope($query)->where([$parent => $for]);
}
$node = $this->_getNode($for);
return $this->_scope($query)
->where([
"{$right} <" => $node->get($config['right']),
"{$left} >" => $node->get($config['left']),
]);
}
/**
* Gets a representation of the elements in the tree as a flat list where the keys are
* the primary key for the table and the values are the display field for the table.
* Values are prefixed to visually indicate relative depth in the tree.
*
* ### Options
*
* - keyPath: A dot separated path to fetch the field to use for the array key, or a closure to
* return the key out of the provided row.
* - valuePath: A dot separated path to fetch the field to use for the array value, or a closure to
* return the value out of the provided row.
* - spacer: A string to be used as prefix for denoting the depth in the tree for each item
*
* @param \Cake\ORM\Query $query Query.
* @param array $options Array of options as described above.
* @return \Cake\ORM\Query
*/
public function findTreeList(Query $query, array $options)
{
$left = $this->_table->aliasField($this->getConfig('left'));
$results = $this->_scope($query)
->find('threaded', [
'parentField' => $this->getConfig('parent'),
'order' => [$left => 'ASC'],
]);
return $this->formatTreeList($results, $options);
}
/**
* Formats query as a flat list where the keys are the primary key for the table
* and the values are the display field for the table. Values are prefixed to visually
* indicate relative depth in the tree.
*
* ### Options
*
* - keyPath: A dot separated path to the field that will be the result array key, or a closure to
* return the key from the provided row.
* - valuePath: A dot separated path to the field that is the array's value, or a closure to
* return the value from the provided row.
* - spacer: A string to be used as prefix for denoting the depth in the tree for each item.
*
* @param \Cake\ORM\Query $query The query object to format.
* @param array $options Array of options as described above.
* @return \Cake\ORM\Query Augmented query.
*/
public function formatTreeList(Query $query, array $options = [])
{
return $query->formatResults(function ($results) use ($options) {
/** @var \Cake\Collection\CollectionTrait $results */
$options += [
'keyPath' => $this->_getPrimaryKey(),
'valuePath' => $this->_table->getDisplayField(),
'spacer' => '_',
];
return $results
->listNested()
->printer($options['valuePath'], $options['keyPath'], $options['spacer']);
});
}
/**
* Removes the current node from the tree, by positioning it as a new root
* and re-parents all children up one level.
*
* Note that the node will not be deleted just moved away from its current position
* without moving its children with it.
*
* @param \Cake\Datasource\EntityInterface $node The node to remove from the tree
* @return \Cake\Datasource\EntityInterface|false the node after being removed from the tree or
* false on error
*/
public function removeFromTree(EntityInterface $node)
{
return $this->_table->getConnection()->transactional(function () use ($node) {
$this->_ensureFields($node);
return $this->_removeFromTree($node);
});
}
/**
* Helper function containing the actual code for removeFromTree
*
* @param \Cake\Datasource\EntityInterface $node The node to remove from the tree
* @return \Cake\Datasource\EntityInterface|false the node after being removed from the tree or
* false on error
*/
protected function _removeFromTree($node)
{
$config = $this->getConfig();
$left = $node->get($config['left']);
$right = $node->get($config['right']);
$parent = $node->get($config['parent']);
$node->set($config['parent'], null);
if ($right - $left == 1) {
return $this->_table->save($node);
}
$primary = $this->_getPrimaryKey();
$this->_table->updateAll(
[$config['parent'] => $parent],
[$config['parent'] => $node->get($primary)]
);
$this->_sync(1, '-', 'BETWEEN ' . ($left + 1) . ' AND ' . ($right - 1));
$this->_sync(2, '-', "> {$right}");
$edge = $this->_getMax();
$node->set($config['left'], $edge + 1);
$node->set($config['right'], $edge + 2);
$fields = [$config['parent'], $config['left'], $config['right']];
$this->_table->updateAll($node->extract($fields), [$primary => $node->get($primary)]);
foreach ($fields as $field) {
$node->setDirty($field, false);
}
return $node;
}
/**
* Reorders the node without changing its parent.
*
* If the node is the first child, or is a top level node with no previous node
* this method will return false
*
* @param \Cake\Datasource\EntityInterface $node The node to move
* @param int|bool $number How many places to move the node, or true to move to first position
* @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found
* @return \Cake\Datasource\EntityInterface|false $node The node after being moved or false on failure
*/
public function moveUp(EntityInterface $node, $number = 1)
{
if ($number < 1) {
return false;
}
return $this->_table->getConnection()->transactional(function () use ($node, $number) {
$this->_ensureFields($node);
return $this->_moveUp($node, $number);
});
}
/**
* Helper function used with the actual code for moveUp
*
* @param \Cake\Datasource\EntityInterface $node The node to move
* @param int|bool $number How many places to move the node, or true to move to first position
* @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found
* @return \Cake\Datasource\EntityInterface|false $node The node after being moved or false on failure
*/
protected function _moveUp($node, $number)
{
$config = $this->getConfig();
list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']];
list($nodeParent, $nodeLeft, $nodeRight) = array_values($node->extract([$parent, $left, $right]));
$targetNode = null;
if ($number !== true) {
$targetNode = $this->_scope($this->_table->find())
->select([$left, $right])
->where(["$parent IS" => $nodeParent])
->where(function ($exp) use ($config, $nodeLeft) {
/** @var \Cake\Database\Expression\QueryExpression $exp */
return $exp->lt($config['rightField'], $nodeLeft);
})
->orderDesc($config['leftField'])
->offset($number - 1)
->limit(1)
->first();
}
if (!$targetNode) {
$targetNode = $this->_scope($this->_table->find())
->select([$left, $right])
->where(["$parent IS" => $nodeParent])
->where(function ($exp) use ($config, $nodeLeft) {
/** @var \Cake\Database\Expression\QueryExpression $exp */
return $exp->lt($config['rightField'], $nodeLeft);
})
->orderAsc($config['leftField'])
->limit(1)
->first();
if (!$targetNode) {
return $node;
}
}
list($targetLeft) = array_values($targetNode->extract([$left, $right]));
$edge = $this->_getMax();
$leftBoundary = $targetLeft;
$rightBoundary = $nodeLeft - 1;
$nodeToEdge = $edge - $nodeLeft + 1;
$shift = $nodeRight - $nodeLeft + 1;
$nodeToHole = $edge - $leftBoundary + 1;
$this->_sync($nodeToEdge, '+', "BETWEEN {$nodeLeft} AND {$nodeRight}");
$this->_sync($shift, '+', "BETWEEN {$leftBoundary} AND {$rightBoundary}");
$this->_sync($nodeToHole, '-', "> {$edge}");
$node->set($left, $targetLeft);
$node->set($right, $targetLeft + ($nodeRight - $nodeLeft));
$node->setDirty($left, false);
$node->setDirty($right, false);
return $node;
}
/**
* Reorders the node without changing the parent.
*
* If the node is the last child, or is a top level node with no subsequent node
* this method will return false
*
* @param \Cake\Datasource\EntityInterface $node The node to move
* @param int|bool $number How many places to move the node or true to move to last position
* @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found
* @return \Cake\Datasource\EntityInterface|false the entity after being moved or false on failure
*/
public function moveDown(EntityInterface $node, $number = 1)
{
if ($number < 1) {
return false;
}
return $this->_table->getConnection()->transactional(function () use ($node, $number) {
$this->_ensureFields($node);
return $this->_moveDown($node, $number);
});
}
/**
* Helper function used with the actual code for moveDown
*
* @param \Cake\Datasource\EntityInterface $node The node to move
* @param int|bool $number How many places to move the node, or true to move to last position
* @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found
* @return \Cake\Datasource\EntityInterface|false $node The node after being moved or false on failure
*/
protected function _moveDown($node, $number)
{
$config = $this->getConfig();
list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']];
list($nodeParent, $nodeLeft, $nodeRight) = array_values($node->extract([$parent, $left, $right]));
$targetNode = null;
if ($number !== true) {
$targetNode = $this->_scope($this->_table->find())
->select([$left, $right])
->where(["$parent IS" => $nodeParent])
->where(function ($exp) use ($config, $nodeRight) {
/** @var \Cake\Database\Expression\QueryExpression $exp */
return $exp->gt($config['leftField'], $nodeRight);
})
->orderAsc($config['leftField'])
->offset($number - 1)
->limit(1)
->first();
}
if (!$targetNode) {
$targetNode = $this->_scope($this->_table->find())
->select([$left, $right])
->where(["$parent IS" => $nodeParent])
->where(function ($exp) use ($config, $nodeRight) {
/** @var \Cake\Database\Expression\QueryExpression $exp */
return $exp->gt($config['leftField'], $nodeRight);
})
->orderDesc($config['leftField'])
->limit(1)
->first();
if (!$targetNode) {
return $node;
}
}
list(, $targetRight) = array_values($targetNode->extract([$left, $right]));
$edge = $this->_getMax();
$leftBoundary = $nodeRight + 1;
$rightBoundary = $targetRight;
$nodeToEdge = $edge - $nodeLeft + 1;
$shift = $nodeRight - $nodeLeft + 1;
$nodeToHole = $edge - $rightBoundary + $shift;
$this->_sync($nodeToEdge, '+', "BETWEEN {$nodeLeft} AND {$nodeRight}");
$this->_sync($shift, '-', "BETWEEN {$leftBoundary} AND {$rightBoundary}");
$this->_sync($nodeToHole, '-', "> {$edge}");
$node->set($left, $targetRight - ($nodeRight - $nodeLeft));
$node->set($right, $targetRight);
$node->setDirty($left, false);
$node->setDirty($right, false);
return $node;
}
/**
* Returns a single node from the tree from its primary key
*
* @param mixed $id Record id.
* @return \Cake\Datasource\EntityInterface
* @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found
*/
protected function _getNode($id)
{
$config = $this->getConfig();
list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']];
$primaryKey = $this->_getPrimaryKey();
$fields = [$parent, $left, $right];
if ($config['level']) {
$fields[] = $config['level'];
}
$node = $this->_scope($this->_table->find())
->select($fields)
->where([$this->_table->aliasField($primaryKey) => $id])
->first();
if (!$node) {
throw new RecordNotFoundException("Node \"{$id}\" was not found in the tree.");
}
return $node;
}
/**
* Recovers the lft and right column values out of the hierarchy defined by the
* parent column.
*
* @return void
*/
public function recover()
{
$this->_table->getConnection()->transactional(function () {
$this->_recoverTree();
});
}
/**
* Recursive method used to recover a single level of the tree
*
* @param int $counter The Last left column value that was assigned
* @param mixed $parentId the parent id of the level to be recovered
* @param int $level Node level
* @return int The next value to use for the left column
*/
protected function _recoverTree($counter = 0, $parentId = null, $level = -1)
{
$config = $this->getConfig();
list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']];
$primaryKey = $this->_getPrimaryKey();
$aliasedPrimaryKey = $this->_table->aliasField($primaryKey);
$order = $config['recoverOrder'] ?: $aliasedPrimaryKey;
$query = $this->_scope($this->_table->query())
->select([$aliasedPrimaryKey])
->where([$this->_table->aliasField($parent) . ' IS' => $parentId])
->order($order)
->disableHydration();
$leftCounter = $counter;
$nextLevel = $level + 1;
foreach ($query as $row) {
$counter++;
$counter = $this->_recoverTree($counter, $row[$primaryKey], $nextLevel);
}
if ($parentId === null) {
return $counter;
}
$fields = [$left => $leftCounter, $right => $counter + 1];
if ($config['level']) {
$fields[$config['level']] = $level;
}
$this->_table->updateAll(
$fields,
[$primaryKey => $parentId]
);
return $counter + 1;
}
/**
* Returns the maximum index value in the table.
*
* @return int
*/
protected function _getMax()
{
$field = $this->_config['right'];
$rightField = $this->_config['rightField'];
$edge = $this->_scope($this->_table->find())
->select([$field])
->orderDesc($rightField)
->first();
if (empty($edge->{$field})) {
return 0;
}
return $edge->{$field};
}
/**
* Auxiliary function used to automatically alter the value of both the left and
* right columns by a certain amount that match the passed conditions
*
* @param int $shift the value to use for operating the left and right columns
* @param string $dir The operator to use for shifting the value (+/-)
* @param string $conditions a SQL snipped to be used for comparing left or right
* against it.
* @param bool $mark whether to mark the updated values so that they can not be
* modified by future calls to this function.
* @return void
*/
protected function _sync($shift, $dir, $conditions, $mark = false)
{
$config = $this->_config;
foreach ([$config['leftField'], $config['rightField']] as $field) {
$query = $this->_scope($this->_table->query());
$exp = $query->newExpr();
$movement = clone $exp;
$movement->add($field)->add((string)$shift)->setConjunction($dir);
$inverse = clone $exp;
$movement = $mark ?
$inverse->add($movement)->setConjunction('*')->add('-1') :
$movement;
$where = clone $exp;
$where->add($field)->add($conditions)->setConjunction('');
$query->update()
->set($exp->eq($field, $movement))
->where($where);
$query->execute()->closeCursor();
}
}
/**
* Alters the passed query so that it only returns scoped records as defined
* in the tree configuration.
*
* @param \Cake\ORM\Query $query the Query to modify
* @return \Cake\ORM\Query
*/
protected function _scope($query)
{
$scope = $this->getConfig('scope');
if (is_array($scope)) {
return $query->where($scope);
}
if (is_callable($scope)) {
return $scope($query);
}
return $query;
}
/**
* Ensures that the provided entity contains non-empty values for the left and
* right fields
*
* @param \Cake\Datasource\EntityInterface $entity The entity to ensure fields for
* @return void
*/
protected function _ensureFields($entity)
{
$config = $this->getConfig();
$fields = [$config['left'], $config['right']];
$values = array_filter($entity->extract($fields));
if (count($values) === count($fields)) {
return;
}
$fresh = $this->_table->get($entity->get($this->_getPrimaryKey()), $fields);
$entity->set($fresh->extract($fields), ['guard' => false]);
foreach ($fields as $field) {
$entity->setDirty($field, false);
}
}
/**
* Returns a single string value representing the primary key of the attached table
*
* @return string
*/
protected function _getPrimaryKey()
{
if (!$this->_primaryKey) {
$primaryKey = (array)$this->_table->getPrimaryKey();
$this->_primaryKey = $primaryKey[0];
}
return $this->_primaryKey;
}
/**
* Returns the depth level of a node in the tree.
*
* @param int|string|\Cake\Datasource\EntityInterface $entity The entity or primary key get the level of.
* @return int|bool Integer of the level or false if the node does not exist.
*/
public function getLevel($entity)
{
$primaryKey = $this->_getPrimaryKey();
$id = $entity;
if ($entity instanceof EntityInterface) {
$id = $entity->get($primaryKey);
}
$config = $this->getConfig();
$entity = $this->_table->find('all')
->select([$config['left'], $config['right']])
->where([$primaryKey => $id])
->first();
if ($entity === null) {
return false;
}
$query = $this->_table->find('all')->where([
$config['left'] . ' <' => $entity[$config['left']],
$config['right'] . ' >' => $entity[$config['right']],
]);
return $this->_scope($query)->count();
}
}