Browse Source

Merge pull request #3164 from cakephp/3.0-tree-behavior

3.0 tree behavior
José Lorenzo Rodríguez 12 years ago
parent
commit
8903d7deae

+ 2 - 1
src/Database/Schema/MysqlSchema.php

@@ -232,7 +232,8 @@ class MysqlSchema extends BaseSchema {
  */
 	public function createTableSql(Table $table, $columns, $constraints, $indexes) {
 		$content = implode(",\n", array_merge($columns, $constraints, $indexes));
-		$content = sprintf("CREATE TABLE `%s` (\n%s\n)", $table->name(), $content);
+		$temporary = $table->temporary() ? ' TEMPORARY ' : ' ';
+		$content = sprintf("CREATE%sTABLE `%s` (\n%s\n)", $temporary, $table->name(), $content);
 		$options = $table->options();
 		if (isset($options['engine'])) {
 			$content .= sprintf(' ENGINE=%s', $options['engine']);

+ 2 - 1
src/Database/Schema/PostgresSchema.php

@@ -419,8 +419,9 @@ class PostgresSchema extends BaseSchema {
 		$content = array_merge($columns, $constraints);
 		$content = implode(",\n", array_filter($content));
 		$tableName = $this->_driver->quoteIdentifier($table->name());
+		$temporary = $table->temporary() ? ' TEMPORARY ' : ' ';
 		$out = [];
-		$out[] = sprintf("CREATE TABLE %s (\n%s\n)", $tableName, $content);
+		$out[] = sprintf("CREATE%sTABLE %s (\n%s\n)", $temporary, $tableName, $content);
 		foreach ($indexes as $index) {
 			$out[] = $index;
 		}

+ 2 - 1
src/Database/Schema/SqliteSchema.php

@@ -342,7 +342,8 @@ class SqliteSchema extends BaseSchema {
 	public function createTableSql(Table $table, $columns, $constraints, $indexes) {
 		$lines = array_merge($columns, $constraints);
 		$content = implode(",\n", array_filter($lines));
-		$table = sprintf("CREATE TABLE \"%s\" (\n%s\n)", $table->name(), $content);
+		$temporary = $table->temporary() ? ' TEMPORARY ' : ' ';
+		$table = sprintf("CREATE%sTABLE \"%s\" (\n%s\n)", $temporary, $table->name(), $content);
 		$out = [$table];
 		foreach ($indexes as $index) {
 			$out[] = $index;

+ 21 - 0
src/Database/Schema/Table.php

@@ -66,6 +66,13 @@ class Table {
 	protected $_options = [];
 
 /**
+ * Whether or not the table is temporary
+ *
+ * @var boolean
+ */
+	protected $_temporary = false;
+
+/**
  * The valid keys that can be used in a column
  * definition.
  *
@@ -499,6 +506,20 @@ class Table {
 	}
 
 /**
+ * Get/Set whether the table is temporary in the database
+ *
+ * @param boolean|null $set whether or not the table is to be temporary
+ * @return this|boolean Either the table instance, the current temporary setting
+ */
+	public function temporary($set = null) {
+		if ($set === null) {
+			return $this->_temporary;
+		}
+		$this->_temporary = (bool)$set;
+		return $this;
+	}
+
+/**
  * Generate the SQL to create the Table.
  *
  * Uses the connection to access the schema dialect

+ 634 - 0
src/Model/Behavior/TreeBehavior.php

@@ -0,0 +1,634 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://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. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         CakePHP(tm) v 3.0.0
+ * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+namespace Cake\Model\Behavior;
+
+use ArrayObject;
+use Cake\Collection\Collection;
+use Cake\Database\Expression\QueryExpression;
+use Cake\Event\Event;
+use Cake\ORM\Behavior;
+use Cake\ORM\Entity;
+use Cake\ORM\Table;
+use Cake\ORM\TableRegistry;
+
+class TreeBehavior extends Behavior {
+
+/**
+ * Table instance
+ *
+ * @var \Cake\ORM\Table
+ */
+	protected $_table;
+
+/**
+ * Default config
+ *
+ * These are merged with user-provided configuration when the behavior is used.
+ *
+ * @var array
+ */
+	protected $_defaultConfig = [
+		'implementedFinders' => [
+			'path' => 'findPath',
+			'children' => 'findChildren',
+		],
+		'implementedMethods' => [
+			'childCount' => 'childCount',
+			'moveUp' => 'moveUp',
+			'moveDown' => 'moveDown',
+			'recover' => 'recover'
+		],
+		'parent' => 'parent_id',
+		'left' => 'lft',
+		'right' => 'rght',
+		'scope' => null
+	];
+
+/**
+ * Constructor
+ *
+ * @param Table $table The table this behavior is attached to.
+ * @param array $config The config for this behavior.
+ */
+	public function __construct(Table $table, array $config = []) {
+		parent::__construct($table, $config);
+		$this->_table = $table;
+	}
+
+/**
+ * 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 the beforeSave event that was fired
+ * @param \Cake\ORM\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, Entity $entity) {
+		$isNew = $entity->isNew();
+		$config = $this->config();
+		$parent = $entity->get($config['parent']);
+		$primaryKey = (array)$this->_table->primaryKey();
+		$dirty = $entity->dirty($config['parent']);
+
+		if ($isNew && $parent) {
+			if ($entity->get($primaryKey[0]) == $parent) {
+				throw new \RuntimeException("Cannot set a node's parent as itself");
+			}
+
+			$parentNode = $this->_getParent($parent);
+			$edge = $parentNode->get($config['right']);
+			$entity->set($config['left'], $edge);
+			$entity->set($config['right'], $edge + 1);
+			$this->_sync(2, '+', ">= {$edge}");
+		}
+
+		if ($isNew && !$parent) {
+			$edge = $this->_getMax();
+			$entity->set($config['left'], $edge + 1);
+			$entity->set($config['right'], $edge + 2);
+		}
+
+		if (!$isNew && $dirty && $parent) {
+			$this->_setParent($entity, $parent);
+		}
+
+		if (!$isNew && $dirty && !$parent) {
+			$this->_setAsRoot($entity);
+		}
+	}
+
+/**
+ * Also deletes the nodes in the subtree of the entity to be delete
+ *
+ * @param \Cake\Event\Event the beforeDelete event that was fired
+ * @param \Cake\ORM\Entity the entity that is going to be saved
+ * @return void
+ */
+	public function beforeDelete(Event $event, Entity $entity) {
+		$config = $this->config();
+		$left = $entity->get($config['left']);
+		$right = $entity->get($config['right']);
+		$diff = $right - $left + 1;
+
+		if ($diff > 2) {
+			$this->_table->deleteAll([
+				"{$config['left']} >=" => $left + 1,
+				"{$config['left']} <=" => $right - 1
+			]);
+		}
+
+		$this->_sync($diff, '-', "> {$right}");
+	}
+
+/**
+ * Returns an entity with the left and right columns for the parent node
+ * of the node specified by the passed id.
+ *
+ * @param mixed $id The id of the node to get its parent for
+ * @return \Cake\ORM\Entity
+ * @throws \Cake\ORM\Error\RecordNotFoundException if the parent could not be found
+ */
+	protected function _getParent($id) {
+		$config = $this->config();
+		$primaryKey = (array)$this->_table->primaryKey();
+		$parentNode = $this->_scope($this->_table->find())
+			->select([$config['left'], $config['right']])
+			->where([$primaryKey[0] => $id])
+			->first();
+
+		if (!$parentNode) {
+			throw new \Cake\ORM\Error\RecordNotFoundException(
+				"Parent node \"{$config['parent']}\" was not found in the tree."
+			);
+		}
+
+		return $parentNode;
+	}
+
+/**
+ * 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\ORM\Entity $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->config();
+		$parentNode = $this->_getParent($parent);
+		$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->_table->primaryKey())
+			));
+		}
+
+		// 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\ORM\Entity $entity The entity to set as a new root
+ * @return void
+ */
+	protected function _setAsRoot($entity) {
+		$config = $this->config();
+		$edge = $this->_getMax();
+		$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->config();
+		$query = $this->_table->query();
+		$this->_table->updateAll([
+			$query->newExpr()->add("{$config['left']} = {$config['left']} * -1"),
+			$query->newExpr()->add("{$config['right']} = {$config['right']} * -1"),
+		], [$config['left'] . ' <' => 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, $options) {
+		if (empty($options['for'])) {
+			throw new \InvalidArgumentException("The 'for' key is required for find('path')");
+		}
+
+		$config = $this->config();
+		list($left, $right) = [$config['left'], $config['right']];
+		$node = $this->_table->get($options['for'], ['fields' => [$left, $right]]);
+
+		return $this->_scope($query)
+			->where([
+				"$left <=" => $node->get($left),
+				"$right >=" => $node->get($right)
+			]);
+	}
+
+/**
+ * Get the number of children nodes.
+ *
+ * @param integer|string $id The id of the record to read
+ * @param boolean $direct whether to count all nodes in the subtree or just
+ * direct children
+ * @return integer Number of children nodes.
+ */
+	public function childCount($id, $direct = false) {
+		$config = $this->config();
+		list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']];
+
+		if ($direct) {
+			$count = $this->_table->find()
+				->where([$parent => $id])
+				->count();
+			return $count;
+		}
+
+		$node = $this->_table->get($id, [$this->_table->primaryKey() => $id]);
+
+		return ($node->{$right} - $node->{$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 array $options Array of options as described above
+ * @return \Cake\ORM\Query
+ * @throws \Cake\ORM\Error\RecordNotFoundException When node was not found
+ * @throws \InvalidArgumentException When the 'for' key is not passed in $options
+ */
+	public function findChildren($query, $options) {
+		$config = $this->config();
+		$options += ['for' => null, 'direct' => false];
+		list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']];
+		list($for, $direct) = [$options['for'], $options['direct']];
+		$primaryKey = $this->_table->primaryKey();
+
+		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->_scope($this->_table->find())
+			->select([$right, $left])
+			->where([$primaryKey => $for])
+			->first();
+
+		if (!$node) {
+			throw new \Cake\ORM\Error\RecordNotFoundException("Node \"{$for}\" was not found in the tree.");
+		}
+
+		return $this->_scope($query)
+			->where([
+				"{$right} <" => $node->{$right},
+				"{$left} >" => $node->{$left}
+			]);
+	}
+
+/**
+ * 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 integer|string $id The id of the record to move
+ * @param integer|boolean $number How many places to move the node, or true to move to first position
+ * @throws \Cake\ORM\Error\RecordNotFoundException When node was not found
+ * @return boolean true on success, false on failure
+ */
+	public function moveUp($id, $number = 1) {
+		$config = $this->config();
+		list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']];
+		$primaryKey = $this->_table->primaryKey();
+
+		if (!$number) {
+			return false;
+		}
+
+		$node = $this->_scope($this->_table->find())
+			->select([$parent, $left, $right])
+			->where([$primaryKey => $id])
+			->first();
+
+		if (!$node) {
+			throw new \Cake\ORM\Error\RecordNotFoundException("Node \"{$id}\" was not found in the tree.");
+		}
+
+		if ($node->{$parent}) {
+			$parentNode = $this->_table->get($node->{$parent}, ['fields' => [$left, $right]]);
+
+			if (($node->{$left} - 1) == $parentNode->{$left}) {
+				return false;
+			}
+		}
+
+		$previousNode = $this->_scope($this->_table->find())
+			->select([$left, $right])
+			->where([$right => ($node->{$left} - 1)])
+			->first();
+
+		if (!$previousNode) {
+			return false;
+		}
+
+		$edge = $this->_getMax();
+		$this->_sync($edge - $previousNode->{$left} + 1, '+', "BETWEEN {$previousNode->{$left}} AND {$previousNode->{$right}}");
+		$this->_sync($node->{$left} - $previousNode->{$left}, '-', "BETWEEN {$node->{$left}} AND {$node->{$right}}");
+		$this->_sync($edge - $previousNode->{$left} - ($node->{$right} - $node->{$left}), '-', "> {$edge}");
+
+		if (is_int($number)) {
+			$number--;
+		}
+
+		if ($number) {
+			$this->moveUp($id, $number);
+		}
+
+		return true;
+	}
+
+/**
+ * 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 integer|string $id The id of the record to move
+ * @param integer|boolean $number How many places to move the node or true to move to last position
+ * @throws \Cake\ORM\Error\RecordNotFoundException When node was not found
+ * @return boolean true on success, false on failure
+ */
+	public function moveDown($id, $number = 1) {
+		$config = $this->config();
+		list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']];
+		$primaryKey = $this->_table->primaryKey();
+
+		if (!$number) {
+			return false;
+		}
+
+		$node = $this->_scope($this->_table->find())
+			->select([$parent, $left, $right])
+			->where([$primaryKey => $id])
+			->first();
+
+		if (!$node) {
+			throw new \Cake\ORM\Error\RecordNotFoundException("Node \"{$id}\" was not found in the tree.");
+		}
+
+		if ($node->{$parent}) {
+			$parentNode = $this->_table->get($node->{$parent}, ['fields' => [$left, $right]]);
+
+			if (($node->{$right} + 1) == $parentNode->{$right}) {
+				return false;
+			}
+		}
+
+		$nextNode = $this->_scope($this->_table->find())
+			->select([$left, $right])
+			->where([$left => $node->{$right} + 1])
+			->first();
+
+		if (!$nextNode) {
+			return false;
+		}
+
+		$edge = $this->_getMax();
+		$this->_sync($edge - $node->{$left} + 1, '+', "BETWEEN {$node->{$left}} AND {$node->{$right}}");
+		$this->_sync($nextNode->{$left} - $node->{$left}, '-', "BETWEEN {$nextNode->{$left}} AND {$nextNode->{$right}}");
+		$this->_sync($edge - $node->{$left} - ($nextNode->{$right} - $nextNode->{$left}), '-', "> {$edge}");
+
+		if (is_int($number)) {
+			$number--;
+		}
+
+		if ($number) {
+			$this->moveDown($id, $number);
+		}
+
+		return true;
+	}
+
+/**
+ * Recovers the lft and right column values out of the hirearchy defined by the
+ * parent column.
+ *
+ * @return void
+ */
+	public function recover() {
+		$this->_table->connection()->transactional(function() {
+			$this->_recoverTree();
+		});
+	}
+
+/**
+ * Recursive method used to recover a single level of the tree
+ *
+ * @param integer $counter The Last left column value that was assigned
+ * @param mixed $parentId the parent id of the level to be recovered
+ * @return integer Ne next value to use for the left column
+ */
+	protected function _recoverTree($counter = 0, $parentId = null) {
+		$config = $this->config();
+		list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']];
+		$pk = (array)$this->_table->primaryKey();
+
+		$query = $this->_scope($this->_table->query())
+			->select($pk)
+			->where(function($exp) use ($parentId, $parent) {
+				return $parentId === null ? $exp->isNull($parent) : $exp->eq($parent, $parentId);
+			})
+			->order($pk)
+			->hydrate(false)
+			->bufferResults(false);
+
+		$leftCounter = $counter;
+		foreach ($query as $row) {
+			$counter++;
+			$counter = $this->_recoverTree($counter, $row[$pk[0]]);
+		}
+
+		if ($parentId === null) {
+			return $counter;
+		}
+
+		$this->_table->updateAll(
+			[$left => $leftCounter, $right => $counter + 1],
+			[$pk[0] => $parentId]
+		);
+
+		return $counter + 1;
+	}
+
+/**
+ * Returns the maximum index value in the table.
+ *
+ * @return integer
+ */
+	protected function _getMax() {
+		return $this->_getMaxOrMin('max');
+	}
+
+/**
+ * Returns the minimum index value in the table.
+ *
+ * @return integer
+ */
+	protected function _getMin() {
+		return $this->_getMaxOrMin('min');
+	}
+
+/**
+ * Get the maximum|minimum index value in the table.
+ *
+ * @param string $maxOrMin Either 'max' or 'min'
+ * @return integer
+ */
+	protected function _getMaxOrMin($maxOrMin = 'max') {
+		$config = $this->config();
+		$field = $maxOrMin === 'max' ? $config['right'] : $config['left'];
+		$direction = $maxOrMin === 'max' ? 'DESC' : 'ASC';
+
+		$edge = $this->_scope($this->_table->find())
+			->select([$field])
+			->order([$field => $direction])
+			->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 integer $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 boolean $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['left'], $config['right']] as $field) {
+			$exp = new QueryExpression();
+			$mark = $mark ? '*-1' : '';
+			$template = sprintf('%s = (%s %s %s)%s', $field, $field, $dir, $shift, $mark);
+			$exp->add($template);
+
+			$query = $this->_scope($this->_table->query());
+			$query->update()->set($exp);
+			$query->where("{$field} {$conditions}");
+
+			$query->execute();
+		}
+	}
+
+/**
+ * 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) {
+		$config = $this->config();
+
+		if (is_array($config['scope'])) {
+			return $query->where($config['scope']);
+		} elseif (is_callable($config['scope'])) {
+			return $config['scope']($query);
+		}
+
+		return $query;
+	}
+}

+ 243 - 0
tests/Fixture/MenuLinkTreeFixture.php

@@ -0,0 +1,243 @@
+<?php
+/**
+ * Tree behavior class.
+ *
+ * Enables a model object to act as a node-based tree.
+ *
+ * CakePHP(tm) Tests <http://book.cakephp.org/2.0/en/development/testing.html>
+ * Copyright (c) Cake Software Foundation, Inc. (http://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. (http://cakefoundation.org)
+ * @link          http://book.cakephp.org/2.0/en/development/testing.html CakePHP(tm) Tests
+ * @since         CakePHP(tm) v 3.0.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Class NumberTreeFixture
+ *
+ * Generates a tree of data for use testing the tree behavior
+ *
+ */
+class MenuLinkTreeFixture extends TestFixture {
+
+/**
+ * fields property
+ *
+ * @var array
+ */
+	public $fields = array(
+		'id' => ['type' => 'integer'],
+		'menu' => ['type' => 'string', 'null' => false],
+		'lft' => ['type' => 'integer'],
+		'rght' => ['type' => 'integer'],
+		'parent_id' => 'integer',
+		'url' => ['type' => 'string', 'null' => false],
+		'title' => ['type' => 'string', 'null' => false],
+		'_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]]
+	);
+
+/**
+ * Records
+ *
+ * # main-menu:
+ *
+ *	- Link 1:1
+ *		- Link 2:2
+ *		- Link 3:3
+ *			- Link 4:4
+ *				- Link 5:5
+ *	- Link 6:6
+ *		- Link 7:7
+ *	- Link 8:8
+ *
+ * ***
+ *
+ * # categories:
+ *
+ *	- electronics:9
+ *		- televisions:10
+ *			- tube:11
+ *			- lcd:12
+ *			- plasma:13
+ *		- portable:14
+ *			- mp3:15
+ *				- flash:16
+ *			- cd:17
+ *			- radios:18
+ *
+ * **Note:** title:id
+ */
+	public $records = array(
+		array(
+			'id' => '1',
+			'menu' => 'main-menu',
+			'lft' => '1',
+			'rght' => '10',
+			'parent_id' => null,
+			'url' => '/link1.html',
+			'title' => 'Link 1',
+		),
+		array(
+			'id' => '2',
+			'menu' => 'main-menu',
+			'lft' => '2',
+			'rght' => '3',
+			'parent_id' => '1',
+			'url' => 'http://example.com',
+			'title' => 'Link 2',
+		),
+		array(
+			'id' => '3',
+			'menu' => 'main-menu',
+			'lft' => '4',
+			'rght' => '9',
+			'parent_id' => '1',
+			'url' => '/what/even-more-links.html',
+			'title' => 'Link 3',
+		),
+		array(
+			'id' => '4',
+			'menu' => 'main-menu',
+			'lft' => '5',
+			'rght' => '8',
+			'parent_id' => '3',
+			'url' => '/lorem/ipsum.html',
+			'title' => 'Link 4',
+		),
+		array(
+			'id' => '5',
+			'menu' => 'main-menu',
+			'lft' => '6',
+			'rght' => '7',
+			'parent_id' => '4',
+			'url' => '/what/the.html',
+			'title' => 'Link 5',
+		),
+		array(
+			'id' => '6',
+			'menu' => 'main-menu',
+			'lft' => '11',
+			'rght' => '14',
+			'parent_id' => null,
+			'url' => '/yeah/another-link.html',
+			'title' => 'Link 6',
+		),
+		array(
+			'id' => '7',
+			'menu' => 'main-menu',
+			'lft' => '12',
+			'rght' => '13',
+			'parent_id' => '6',
+			'url' => 'http://cakephp.org',
+			'title' => 'Link 7',
+		),
+		array(
+			'id' => '8',
+			'menu' => 'main-menu',
+			'lft' => '15',
+			'rght' => '16',
+			'parent_id' => null,
+			'url' => '/page/who-we-are.html',
+			'title' => 'Link 8',
+		),
+		array(
+			'id' => '9',
+			'menu' => 'categories',
+			'lft' => '1',
+			'rght' => '10',
+			'parent_id' => null,
+			'url' => '/cagetory/electronics.html',
+			'title' => 'electronics',
+		),
+		array(
+			'id' => '10',
+			'menu' => 'categories',
+			'lft' => '2',
+			'rght' => '9',
+			'parent_id' => '9',
+			'url' => '/category/televisions.html',
+			'title' => 'televisions',
+		),
+		array(
+			'id' => '11',
+			'menu' => 'categories',
+			'lft' => '3',
+			'rght' => '4',
+			'parent_id' => '10',
+			'url' => '/category/tube.html',
+			'title' => 'tube',
+		),
+		array(
+			'id' => '12',
+			'menu' => 'categories',
+			'lft' => '5',
+			'rght' => '8',
+			'parent_id' => '10',
+			'url' => '/category/lcd.html',
+			'title' => 'lcd',
+		),
+		array(
+			'id' => '13',
+			'menu' => 'categories',
+			'lft' => '6',
+			'rght' => '7',
+			'parent_id' => '12',
+			'url' => '/category/plasma.html',
+			'title' => 'plasma',
+		),
+		array(
+			'id' => '14',
+			'menu' => 'categories',
+			'lft' => '11',
+			'rght' => '20',
+			'parent_id' => null,
+			'url' => '/category/portable.html',
+			'title' => 'portable',
+		),
+		array(
+			'id' => '15',
+			'menu' => 'categories',
+			'lft' => '12',
+			'rght' => '15',
+			'parent_id' => '14',
+			'url' => '/category/mp3.html',
+			'title' => 'mp3',
+		),
+		array(
+			'id' => '16',
+			'menu' => 'categories',
+			'lft' => '13',
+			'rght' => '14',
+			'parent_id' => '15',
+			'url' => '/category/flash.html',
+			'title' => 'flash',
+		),
+		array(
+			'id' => '17',
+			'menu' => 'categories',
+			'lft' => '16',
+			'rght' => '17',
+			'parent_id' => '14',
+			'url' => '/category/cd.html',
+			'title' => 'cd',
+		),
+		array(
+			'id' => '18',
+			'menu' => 'categories',
+			'lft' => '18',
+			'rght' => '19',
+			'parent_id' => '14',
+			'url' => '/category/radios.html',
+			'title' => 'radios',
+		),
+	);
+
+}

+ 89 - 2
tests/Fixture/NumberTreeFixture.php

@@ -37,8 +37,95 @@ class NumberTreeFixture extends TestFixture {
 		'id' => ['type' => 'integer'],
 		'name' => ['type' => 'string', 'null' => false],
 		'parent_id' => 'integer',
-		'lft' => ['type' => 'integer', 'null' => false],
-		'rght' => ['type' => 'integer', 'null' => false],
+		'lft' => ['type' => 'integer'],
+		'rght' => ['type' => 'integer'],
 		'_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]]
 	);
+
+/**
+ * Records
+ *
+ *	- electronics:1
+ *		- televisions:2
+ *			- tube:3
+ *			- lcd:4
+ *			- plasma:5
+ *		- portable:6
+ *			- mp3:7
+ *				- flash:8
+ *			- cd:9
+ *			- radios:10
+ *	- alien ware: 11
+ *
+ * @var array
+ */
+	public $records = array(
+		array(
+			'name' => 'electronics',
+			'parent_id' => null,
+			'lft' => '1',
+			'rght' => '20'
+		),
+		array(
+			'name' => 'televisions',
+			'parent_id' => '1',
+			'lft' => '2',
+			'rght' => '9'
+		),
+		array(
+			'name' => 'tube',
+			'parent_id' => '2',
+			'lft' => '3',
+			'rght' => '4'
+		),
+		array(
+			'name' => 'lcd',
+			'parent_id' => '2',
+			'lft' => '5',
+			'rght' => '6'
+		),
+		array(
+			'name' => 'plasma',
+			'parent_id' => '2',
+			'lft' => '7',
+			'rght' => '8'
+		),
+		array(
+			'name' => 'portable',
+			'parent_id' => '1',
+			'lft' => '10',
+			'rght' => '19'
+		),
+		array(
+			'name' => 'mp3',
+			'parent_id' => '6',
+			'lft' => '11',
+			'rght' => '14'
+		),
+		array(
+			'name' => 'flash',
+			'parent_id' => '7',
+			'lft' => '12',
+			'rght' => '13'
+		),
+		array(
+			'name' => 'cd',
+			'parent_id' => '6',
+			'lft' => '15',
+			'rght' => '16'
+		),
+		array(
+			'name' => 'radios',
+			'parent_id' => '6',
+			'lft' => '17',
+			'rght' => '18'
+		),
+		array(
+			'name' => 'alien hardware',
+			'parent_id' => null,
+			'lft' => '21',
+			'rght' => '22'
+		)
+	);
+
 }

+ 19 - 0
tests/TestCase/Database/Schema/MysqlSchemaTest.php

@@ -724,6 +724,25 @@ SQL;
 	}
 
 /**
+ * Tests creating temporary tables
+ *
+ * @return void
+ */
+	public function testCreateTemporary() {
+		$driver = $this->_getMockedDriver();
+		$connection = $this->getMock('Cake\Database\Connection', [], [], '', false);
+		$connection->expects($this->any())->method('driver')
+			->will($this->returnValue($driver));
+		$table = (new Table('schema_articles'))->addColumn('id', [
+			'type' => 'integer',
+			'null' => false
+		]);
+		$table->temporary(true);
+		$sql = $table->createSql($connection);
+		$this->assertContains('CREATE TEMPORARY TABLE', $sql[0]);
+	}
+
+/**
  * Test primary key generation & auto-increment.
  *
  * @return void

+ 19 - 0
tests/TestCase/Database/Schema/PostgresSchemaTest.php

@@ -674,6 +674,25 @@ SQL;
 	}
 
 /**
+ * Tests creating temporary tables
+ *
+ * @return void
+ */
+	public function testCreateTemporary() {
+		$driver = $this->_getMockedDriver();
+		$connection = $this->getMock('Cake\Database\Connection', [], [], '', false);
+		$connection->expects($this->any())->method('driver')
+			->will($this->returnValue($driver));
+		$table = (new Table('schema_articles'))->addColumn('id', [
+			'type' => 'integer',
+			'null' => false
+		]);
+		$table->temporary(true);
+		$sql = $table->createSql($connection);
+		$this->assertContains('CREATE TEMPORARY TABLE', $sql[0]);
+	}
+
+/**
  * Test primary key generation & auto-increment.
  *
  * @return void

+ 19 - 0
tests/TestCase/Database/Schema/SqliteSchemaTest.php

@@ -715,6 +715,25 @@ SQL;
 	}
 
 /**
+ * Tests creating temporary tables
+ *
+ * @return void
+ */
+	public function testCreateTemporary() {
+		$driver = $this->_getMockedDriver();
+		$connection = $this->getMock('Cake\Database\Connection', [], [], '', false);
+		$connection->expects($this->any())->method('driver')
+			->will($this->returnValue($driver));
+		$table = (new Table('schema_articles'))->addColumn('id', [
+			'type' => 'integer',
+			'null' => false
+		]);
+		$table->temporary(true);
+		$sql = $table->createSql($connection);
+		$this->assertContains('CREATE TEMPORARY TABLE', $sql[0]);
+	}
+
+/**
  * Test primary key generation & auto-increment.
  *
  * @return void

+ 14 - 0
tests/TestCase/Database/Schema/TableTest.php

@@ -352,4 +352,18 @@ class TableTest extends TestCase {
 			->addConstraint('author_id_idx', $data);
 	}
 
+/**
+ * Tests the temporary() method
+ *
+ * @return void
+ */
+	public function testTemporary() {
+		$table = new Table('articles');
+		$this->assertFalse($table->temporary());
+		$this->assertSame($table, $table->temporary(true));
+		$this->assertTrue($table->temporary());
+		$table->temporary(false);
+		$this->assertFalse($table->temporary());
+	}
+
 }

+ 559 - 0
tests/TestCase/Model/Behavior/TreeBehaviorTest.php

@@ -0,0 +1,559 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://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. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         CakePHP(tm) v 3.0.0
+ * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+namespace Cake\Test\TestCase\Model\Behavior;
+
+use Cake\Collection\Collection;
+use Cake\Event\Event;
+use Cake\Model\Behavior\TranslateBehavior;
+use Cake\ORM\Entity;
+use Cake\ORM\TableRegistry;
+use Cake\TestSuite\TestCase;
+
+/**
+ * Translate behavior test case
+ */
+class TreeBehaviorTest extends TestCase {
+
+/**
+ * fixtures
+ *
+ * @var array
+ */
+	public $fixtures = [
+		'core.number_tree',
+		'core.menu_link_tree'
+	];
+
+	public function setUp() {
+		parent::setUp();
+		$this->table = TableRegistry::get('NumberTrees');
+		$this->table->addBehavior('Tree');
+	}
+
+	public function tearDown() {
+		parent::tearDown();
+		TableRegistry::clear();
+	}
+
+/**
+ * Tests the find('path') method
+ *
+ * @return void
+ */
+	public function testFindPath() {
+		$nodes = $this->table->find('path', ['for' => 9]);
+		$this->assertEquals([1, 6, 9], $nodes->extract('id')->toArray());
+
+		$nodes = $this->table->find('path', ['for' => 10]);
+		$this->assertEquals([1, 6, 10], $nodes->extract('id')->toArray());
+
+		$nodes = $this->table->find('path', ['for' => 5]);
+		$this->assertEquals([1, 2, 5], $nodes->extract('id')->toArray());
+
+		$nodes = $this->table->find('path', ['for' => 1]);
+		$this->assertEquals([1], $nodes->extract('id')->toArray());
+
+		// find path with scope
+		$table = TableRegistry::get('MenuLinkTrees');
+		$table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]);
+		$nodes = $table->find('path', ['for' => 5]);
+		$this->assertEquals([1, 3, 4, 5], $nodes->extract('id')->toArray());
+	}
+
+/**
+ * Tests the childCount() method
+ *
+ * @return void
+ */
+	public function testChildCount() {
+		// direct children for the root node
+		$countDirect = $this->table->childCount(1, true);
+		$this->assertEquals(2, $countDirect);
+
+		// counts all the children of root
+		$count = $this->table->childCount(1, false);
+		$this->assertEquals(9, $count);
+
+		// counts direct children
+		$count = $this->table->childCount(2, false);
+		$this->assertEquals(3, $count);
+
+		// count children for a middle-node
+		$count = $this->table->childCount(6, false);
+		$this->assertEquals(4, $count);
+
+		// count leaf children
+		$count = $this->table->childCount(10, false);
+		$this->assertEquals(0, $count);
+
+		// test scoping
+		$table = TableRegistry::get('MenuLinkTrees');
+		$table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]);
+		$count = $table->childCount(3, false);
+		$this->assertEquals(2, $count);
+	}
+
+/**
+ * Tests the childCount() plus callable scoping
+ *
+ * @return void
+ */
+	public function testCallableScoping() {
+		$table = TableRegistry::get('MenuLinkTrees');
+		$table->addBehavior('Tree', [
+			'scope' => function ($query) {
+				return $query->where(['menu' => 'main-menu']);
+			}
+		]);
+		$count = $table->childCount(1, false);
+		$this->assertEquals(4, $count);
+	}
+
+/**
+ * Tests the find('children') method
+ *
+ * @return void
+ */
+	public function testFindChildren() {
+		$table = TableRegistry::get('MenuLinkTrees');
+		$table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]);
+
+		// root
+		$nodeIds = [];
+		$nodes = $table->find('children', ['for' => 1])->all();
+		$this->assertEquals([2, 3, 4, 5], $nodes->extract('id')->toArray());
+
+		// leaf
+		$nodeIds = [];
+		$nodes = $table->find('children', ['for' => 5])->all();
+		$this->assertEquals(0, count($nodes->extract('id')->toArray()));
+
+		// direct children
+		$nodes = $table->find('children', ['for' => 1, 'direct' => true])->all();
+		$this->assertEquals([2, 3], $nodes->extract('id')->toArray());
+	}
+
+/**
+ * Tests that find('children') will throw an exception if the node was not found
+ *
+ * @expectedException \Cake\ORM\Error\RecordNotFoundException
+ * @return void
+ */
+	public function testFindChildrenException() {
+		$table = TableRegistry::get('MenuLinkTrees');
+		$table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]);
+		$query = $table->find('children', ['for' => 500]);
+	}
+
+/**
+ * Tests the moveUp() method
+ *
+ * @return void
+ */
+	public function testMoveUp() {
+		$table = TableRegistry::get('MenuLinkTrees');
+		$table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]);
+
+		// top level, wont move
+		$this->assertFalse($this->table->moveUp(1, 10));
+
+		// edge cases
+		$this->assertFalse($this->table->moveUp(1, 0));
+		$this->assertFalse($this->table->moveUp(1, -10));
+
+		// move inner node
+		$result = $table->moveUp(3, 1);
+		$nodes = $table->find('children', ['for' => 1])->all();
+		$this->assertEquals([3, 4, 5, 2], $nodes->extract('id')->toArray());
+		$this->assertTrue($result);
+
+		// move leaf
+		$this->assertFalse($table->moveUp(5, 1));
+
+		// move to first position
+		$table->moveUp(8, true);
+		$nodes = $table->find()
+			->select(['id'])
+			->where(function($exp) {
+				return $exp->isNull('parent_id');
+			})
+			->where(['menu' => 'main-menu'])
+			->order(['lft' => 'ASC'])
+			->all();
+		$this->assertEquals([8, 1, 6], $nodes->extract('id')->toArray());
+	}
+
+/**
+ * Tests that moveUp() will throw an exception if the node was not found
+ *
+ * @expectedException \Cake\ORM\Error\RecordNotFoundException
+ * @return void
+ */
+	public function testMoveUpException() {
+		$table = TableRegistry::get('MenuLinkTrees');
+		$table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]);
+		$table->moveUp(500, 1);
+	}
+
+/**
+ * Tests the moveDown() method
+ *
+ * @return void
+ */
+	public function testMoveDown() {
+		$table = TableRegistry::get('MenuLinkTrees');
+		$table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]);
+
+		// latest node, wont move
+		$this->assertFalse($this->table->moveDown(8, 10));
+
+		// edge cases
+		$this->assertFalse($this->table->moveDown(8, 0));
+		$this->assertFalse($this->table->moveUp(8, -10));
+
+		// move inner node
+		$result = $table->moveDown(2, 1);
+		$nodes = $table->find('children', ['for' => 1])->all();
+		$this->assertEquals([3, 4, 5, 2], $nodes->extract('id')->toArray());
+		$this->assertTrue($result);
+
+		// move leaf
+		$this->assertFalse( $table->moveDown(5, 1));
+
+		// move to last position
+		$table->moveDown(1, true);
+		$nodes = $table->find()
+			->select(['id'])
+			->where(function($exp) {
+				return $exp->isNull('parent_id');
+			})
+			->where(['menu' => 'main-menu'])
+			->order(['lft' => 'ASC'])
+			->all();
+		$this->assertEquals([6, 8, 1], $nodes->extract('id')->toArray());
+	}
+
+/**
+ * Tests that moveDown() will throw an exception if the node was not found
+ *
+ * @expectedException \Cake\ORM\Error\RecordNotFoundException
+ * @return void
+ */
+	public function testMoveDownException() {
+		$table = TableRegistry::get('MenuLinkTrees');
+		$table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]);
+		$table->moveDown(500, 1);
+	}
+
+/**
+ * Tests the recover function
+ *
+ * @return void
+ */
+	public function testRecover() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$expected = $table->find()->order('lft')->hydrate(false)->toArray();
+		$table->updateAll(['lft' => null, 'rght' => null], []);
+		$table->recover();
+		$result = $table->find()->order('lft')->hydrate(false)->toArray();
+		$this->assertEquals($expected, $result);
+	}
+
+/**
+ * Tests the recover function with a custom scope
+ *
+ * @return void
+ */
+	public function testRecoverScoped() {
+		$table = TableRegistry::get('MenuLinkTrees');
+		$table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]);
+		$expected = $table->find()
+			->where(['menu' => 'main-menu'])
+			->order('lft')
+			->hydrate(false)
+			->toArray();
+
+		$expected2 = $table->find()
+			->where(['menu' => 'categories'])
+			->order('lft')
+			->hydrate(false)
+			->toArray();
+
+		$table->updateAll(['lft' => null, 'rght' => null], ['menu' => 'main-menu']);
+		$table->recover();
+		$result = $table->find()
+			->where(['menu' => 'main-menu'])
+			->order('lft')
+			->hydrate(false)
+			->toArray();
+		$this->assertEquals($expected, $result);
+
+		$result2 = $table->find()
+			->where(['menu' => 'categories'])
+			->order('lft')
+			->hydrate(false)
+			->toArray();
+		$this->assertEquals($expected2, $result2);
+	}
+
+/**
+ * Tests adding a new orphan node
+ *
+ * @return void
+ */
+	public function testAddOrphan() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = new Entity(
+			['name' => 'New Orphan', 'parent_id' => null],
+			['markNew' => true]
+		);
+		$expected = $table->find()->order('lft')->hydrate(false)->toArray();
+		$this->assertSame($entity, $table->save($entity));
+		$this->assertEquals(23, $entity->lft);
+		$this->assertEquals(24, $entity->rght);
+
+		$expected[] = $entity->toArray();
+		$results = $table->find()->order('lft')->hydrate(false)->toArray();
+		$this->assertEquals($expected, $results);
+	}
+
+/**
+ * Tests that adding a child node as a decendant of one of the roots works
+ *
+ * @return void
+ */
+	public function testAddMiddle() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = new Entity(
+			['name' => 'laptops', 'parent_id' => 1],
+			['markNew' => true]
+		);
+		$this->assertSame($entity, $table->save($entity));
+		$this->assertEquals(20, $entity->lft);
+		$this->assertEquals(21, $entity->rght);
+
+		$result = $table->find()->order('lft')->hydrate(false)->toArray();
+		$table->recover();
+		$expected = $table->find()->order('lft')->hydrate(false)->toArray();
+		$this->assertEquals($expected, $result);
+	}
+
+/**
+ * Tests adding a leaf to the tree
+ *
+ * @return void
+ */
+	public function testAddLeaf() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = new Entity(
+			['name' => 'laptops', 'parent_id' => 2],
+			['markNew' => true]
+		);
+		$this->assertSame($entity, $table->save($entity));
+		$this->assertEquals(9, $entity->lft);
+		$this->assertEquals(10, $entity->rght);
+
+		$results = $table->find()->order('lft')->hydrate(false)->toArray();
+		$table->recover();
+		$expected = $table->find()->order('lft')->hydrate(false)->toArray();
+		$this->assertEquals($expected, $results);
+	}
+
+/**
+ * Tests moving a subtree to the right
+ *
+ * @return void
+ */
+	public function testReParentSubTreeRight() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = $table->get(2);
+		$entity->parent_id = 6;
+		$this->assertSame($entity, $table->save($entity));
+		$this->assertEquals(11, $entity->lft);
+		$this->assertEquals(18, $entity->rght);
+
+		$result = $table->find()->order('lft')->hydrate(false);
+		$expected = [1, 6, 7, 8, 9, 10, 2, 3, 4, 5, 11];
+		$this->assertEquals($expected, $result->extract('id')->toArray());
+		$numbers = [];
+		$result->each(function($v) use (&$numbers) {
+			$numbers[] = $v['lft'];
+			$numbers[] = $v['rght'];
+		});
+		sort($numbers);
+		$this->assertEquals(range(1, 22), $numbers);
+	}
+
+/**
+ * Tests moving a subtree to the left
+ *
+ * @return void
+ */
+	public function testReParentSubTreeLeft() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = $table->get(6);
+		$entity->parent_id = 2;
+		$this->assertSame($entity, $table->save($entity));
+		$this->assertEquals(9, $entity->lft);
+		$this->assertEquals(18, $entity->rght);
+
+		$result = $table->find()->order('lft')->hydrate(false)->toArray();
+		$table->recover();
+		$expected = $table->find()->order('lft')->hydrate(false)->toArray();
+		$this->assertEquals($expected, $result);
+	}
+
+/**
+ * Test moving a leaft to the left
+ *
+ * @return void
+ */
+	public function testReParentLeafLeft() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = $table->get(10);
+		$entity->parent_id = 2;
+		$this->assertSame($entity, $table->save($entity));
+		$this->assertEquals(9, $entity->lft);
+		$this->assertEquals(10, $entity->rght);
+
+		$result = $table->find()->order('lft')->hydrate(false)->toArray();
+		$table->recover();
+		$expected = $table->find()->order('lft')->hydrate(false)->toArray();
+		$this->assertEquals($expected, $result);
+	}
+
+/**
+ * Test moving a leaft to the left
+ *
+ * @return void
+ */
+	public function testReParentLeafRight() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = $table->get(5);
+		$entity->parent_id = 6;
+		$this->assertSame($entity, $table->save($entity));
+		$this->assertEquals(17, $entity->lft);
+		$this->assertEquals(18, $entity->rght);
+
+		$result = $table->find()->order('lft')->hydrate(false);
+		$expected = [1, 2, 3, 4, 6, 7, 8, 9, 10, 5, 11];
+		$this->assertEquals($expected, $result->extract('id')->toArray());
+		$numbers = [];
+		$result->each(function($v) use (&$numbers) {
+			$numbers[] = $v['lft'];
+			$numbers[] = $v['rght'];
+		});
+		sort($numbers);
+		$this->assertEquals(range(1, 22), $numbers);
+	}
+
+/**
+ * Tests moving a subtree as a new root
+ *
+ * @return void
+ */
+	public function testRootingSubTree() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = $table->get(2);
+		$entity->parent_id = null;
+		$this->assertSame($entity, $table->save($entity));
+		$this->assertEquals(15, $entity->lft);
+		$this->assertEquals(22, $entity->rght);
+
+		$result = $table->find()->order('lft')->hydrate(false);
+		$expected = [1, 6, 7, 8, 9, 10, 11, 2, 3, 4, 5];
+		$this->assertEquals($expected, $result->extract('id')->toArray());
+		$numbers = [];
+		$result->each(function($v) use (&$numbers) {
+			$numbers[] = $v['lft'];
+			$numbers[] = $v['rght'];
+		});
+		sort($numbers);
+		$this->assertEquals(range(1, 22), $numbers);
+	}
+
+/**
+ * Tests that trying to create a cycle throws an exception
+ *
+ * @expectedException RuntimeException
+ * @expectedExceptionMessage Cannot use node "5" as parent for entity "2"
+ * @return void
+ */
+	public function testReparentCycle() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = $table->get(2);
+		$entity->parent_id = 5;
+		$table->save($entity);
+	}
+
+/**
+ * Tests deleting a leaf in the tree
+ *
+ * @return void
+ */
+	public function testDeleteLeaf() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = $table->get(4);
+		$this->assertTrue($table->delete($entity));
+		$result = $table->find()->order('lft')->hydrate(false)->toArray();
+		$table->recover();
+		$expected = $table->find()->order('lft')->hydrate(false)->toArray();
+		$this->assertEquals($expected, $result);
+	}
+
+/**
+ * Tests deleting a subtree
+ *
+ * @return void
+ */
+	public function testDeleteSubTree() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = $table->get(6);
+		$this->assertTrue($table->delete($entity));
+		$result = $table->find()->order('lft')->hydrate(false)->toArray();
+		$table->recover();
+		$expected = $table->find()->order('lft')->hydrate(false)->toArray();
+		$this->assertEquals($expected, $result);
+	}
+
+/**
+ * Test deleting a root node
+ *
+ * @return void
+ */
+	public function testDeleteRoot() {
+		$table = TableRegistry::get('NumberTrees');
+		$table->addBehavior('Tree');
+		$entity = $table->get(1);
+		$this->assertTrue($table->delete($entity));
+		$result = $table->find()->order('lft')->hydrate(false)->toArray();
+		$table->recover();
+		$expected = $table->find()->order('lft')->hydrate(false)->toArray();
+		$this->assertEquals($expected, $result);
+	}
+
+}